Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 11

Object Relationships

Ways objects relate to each other without inheritance.

11.1 Association

Association is the most general relationship between two objects: one object uses or interacts with another, but neither owns the other — both exist independently, before, during, and after the interaction. It's the "uses-a" relationship, as opposed to inheritance's "is-a" (chapter 12).

Association                     - two objects interact; neither owns the other
    │
    └── Whole-part relationships   - one object is made up of / contains another
            │
       ┌────┴─────┐
       ▼          ▼
  Aggregation   Composition        - two different strengths of "owns," covered
  ("has-a",     ("owns-a",         next in 11.2 and 11.3
   weak)         strong)

A Teacher and a Student are associated — a teacher can teach a student — but neither is part of the other, and both can exist with no connection at all:

In [1]18 lines
class Teacher:
    def __init__(self, name):
        self.name = name

    def teach(self, student):
        print(f"{self.name} teaches {student.name}")

class Student:
    def __init__(self, name):
        self.name = name

teacher = Teacher("Ms. Rao")
ada = Student("Ada")
grace = Student("Grace")

teacher.teach(ada)     # Ms. Rao teaches Ada
teacher.teach(grace)   # Ms. Rao teaches Grace
# neither Teacher nor Student holds the other as part of itself -> just collaboration
Ms. Rao teaches Ada
Ms. Rao teaches Grace

In practice — a service-layer class in a real backend. A NotificationService that sends an email or push notification to a User is association in miniature: the service interacts with the user object to do its job, but doesn't own the user's lifecycle — the user exists in the system long before and after any single notification is sent.

This association happens to be one-directional — Teacher knows about Student through the method's parameter, but Student has no idea which teachers exist. An association can just as easily be bidirectional (each side holds a reference to the other) or one-to-many/many-to-many (one teacher, many students, and vice versa) — the defining trait either way is simply that no object owns the other's lifecycle. The next two sections cover the special case where one object is made of another — association's stronger, whole-part cousins.

11.2 Aggregation

Aggregation is a whole-part "has-a" relationship where the part can exist entirely independently of the whole — it's usually created outside the whole and simply handed in, the whole doesn't manage its lifecycle, and the same part can even belong to more than one whole. This is why it's called a weak association: destroying the whole leaves the part completely unaffected.

A Team has Players, but a player is a person who exists whether or not they're on this particular team right now:

In [2]17 lines
class Player:
    def __init__(self, name):
        self.name = name

class Team:
    def __init__(self, name, players=None):
        self.name = name
        self.players = players if players is not None else []   # holds references, doesn't create them

    def add_player(self, player):
        self.players.append(player)

ada = Player("Ada")     # players are built independently of any team
grace = Player("Grace")

falcons = Team("Falcons", [ada, grace])
print([p.name for p in falcons.players])   # ['Ada', 'Grace']
['Ada', 'Grace']

In practice — an e-commerce Order aggregating a Customer. The customer who places an order exists in the system whether or not that particular order is ever placed, and the same customer is referenced by many other orders too — exactly the Team/Player shape above, just with Order/Customer in place of Team/Player.

Deleting the whole doesn't touch the part. Adding a __del__ (called when an object is actually destroyed) makes this observable: removing the team never prints "Player destroyed," because the ada variable still holds its own reference to that object — the team was never the only thing keeping it alive:

Note — what "__del__" actually is. The double-underscore name is a dunder method — one of a set of special methods Python calls automatically in response to built-in syntax or built-in functions, never by writing obj.__del__() directly. __del__ specifically responds to an object being destroyed; the full mechanism, and the complete catalog of dunder methods, is covered in 13.1.

In [3]15 lines
class Player:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f"Player {self.name} destroyed")

ada = Player("Ada")
falcons = Team("Falcons", [ada])

print("deleting falcons...")
del falcons   # only removes the TEAM's reference to ada
print("done - no 'Player destroyed' message above")

print(ada.name)   # still fully alive and usable
deleting falcons...
done - no 'Player destroyed' message above
Ada

Going deeper — this timing is a CPython implementation detail, not a language guarantee. __del__ firing (or not firing) the instant the last reference disappears relies on CPython's reference-counting scheme (19.2) collecting an object the moment its refcount hits zero. Other Python implementations don't promise this at all, and even in CPython a reference cycle defeats it — two or more objects only reachable from each other can sit uncollected until the cyclic garbage collector eventually runs, at a time the program doesn't control (19.3). Treat __del__ firing as a useful demonstration of lifecycle here, not as a technique to build real cleanup logic on.

The same part can belong to more than one whole. Since a Team only stores a reference rather than owning the player outright, that exact same Player object can be handed to a second team too:

In [4]4 lines
hawks = Team("Hawks")
hawks.add_player(ada)   # ada joins a second team, unmodified and undestroyed

print([p.name for p in hawks.players])   # ['Ada'] -> the very same Player object, shared
['Ada']

This sharing is also chapter 2.4's aliasing danger, showing up in class design. falcons was deleted just above (11.2's __del__ demonstration), but hawks and the shared ada object are still alive — adding ada to a second team makes the aliasing angle explicit. Because both teams hold a reference to the exact same Player object (not copies), mutating it through one team's list is visible through the other team too — exactly the aliasing behavior 2.4 covers for plain variables, just reached through two different Team.players lists instead of two bare names:

In [5]6 lines
eagles = Team("Eagles")
eagles.add_player(ada)   # same Player object hawks already holds

hawks.players[0].name = "Ada Lovelace"          # mutate the SAME Player object, reached via hawks
print(eagles.players[0].name)                       # Ada Lovelace -> visible through eagles too
print(hawks.players[0] is eagles.players[0])   # True -> literally one object, two references (the aliasing bug angle)
Ada Lovelace
True

11.3 Composition

Composition is a whole-part "owns-a" relationship where the part's lifecycle is tied to the whole — the whole builds its own part internally, nothing else ever holds a reference to it, and it can't outlive (or be shared outside of) the whole that made it. This is why it's called a strong association.

A Car doesn't borrow an Engine from somewhere else — it builds its own, right in its constructor:

In [6]15 lines
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
        print(f"Engine built ({horsepower}hp)")

class Car:
    def __init__(self, model, horsepower):
        self.model = model
        self.engine = Engine(horsepower)   # Car builds its OWN engine - nobody else can reach it

    def start(self):
        print(f"{self.model} starting, engine: {self.engine.horsepower}hp")

car = Car("Model S", 500)   # Engine built (500hp)
car.start()                     # Model S starting, engine: 500hp
Engine built (500hp)
Model S starting, engine: 500hp

In practice — the composition counterpart to the aggregation example above. That same Order also composes its own LineItems: a line item ("2x Mouse @ $19.99") has no meaning or existence outside the order it belongs to, is built by the order itself, and is deleted along with it — the Car/Engine shape, not the Team/Player one, even though both relationships live on the very same Order class.

Deleting the whole destroys the part too — the exact opposite of 11.2's aggregation result. No variable outside Car ever held a reference to the Engine, so once the Car is gone, nothing keeps it alive either:

In [7]19 lines
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def __del__(self):
        print(f"Engine ({self.horsepower}hp) destroyed")

class Car:
    def __init__(self, model, horsepower):
        self.model = model
        self.engine = Engine(horsepower)

car = Car("Model S", 500)
print("deleting car...")
del car   # the engine goes with it - nothing else ever referenced it
print("done")
# deleting car...
# Engine (500hp) destroyed
# done
deleting car...
Engine (500hp) destroyed
done

Going deeper — the same CPython-refcounting caveat from 11.2 applies here too. Engine (500hp) destroyed printing immediately, right when car is deleted, is CPython's reference-counting implementation reclaiming the object the instant its refcount hits zero — not a promise the language itself makes. A reference cycle involving the Engine (or a different interpreter's implementation choices) can delay or change this timing; see 19.3 for a genuine cycle that defeats immediate collection.

Aggregation vs. composition, side by side:

Aggregation (Team/Player) Composition (Car/Engine)
Who creates the part created outside, passed in the whole builds it itself, internally
Part's lifecycle independent — outlives the whole tied to the whole — dies with it
Can the part be shared? yes, by multiple wholes at once no, it belongs to exactly one whole
Typical code shape def __init__(self, part): self.part = part def __init__(self): self.part = Part(...)

UML diagrams traditionally draw these with a diamond on the "whole" end, hollow for the weak relationship and filled for the strong one:

Team    ◇──────  Player     aggregation (hollow diamond) — the part can outlive the whole
Car     ◆──────  Engine     composition (filled diamond) — the part dies with the whole

11.4 Delegation and Reuse Without Inheritance

Delegation is when an object, instead of implementing a behavior itself, holds a reference to another object (usually via composition or aggregation) and forwards the call to it. It's a way to reuse another class's behavior through a "has-a" relationship, instead of inheritance's "is-a" (chapter 12) — the object doing the delegating isn't a kind of the thing it delegates to, it just uses one:

In [8]13 lines
class Horn:
    def honk(self):
        return "Beep!"

class Car:
    def __init__(self):
        self.horn = Horn()   # composition (11.3) - Car builds its own Horn

    def honk(self):
        return self.horn.honk()   # delegation - Car forwards the call instead of implementing it itself

car = Car()
print(car.honk())   # Beep!
Beep!

Going deeper — the "Law of Demeter" is the professional name for why car.honk() beats car.horn.honk(). Informally: "only talk to your immediate friends." A caller reaching through car.horn.honk() directly is coupled to how Car happens to be built internally (that it has a horn attribute, that Horn has a honk() method) — change either implementation detail and every caller breaks. Chains like obj.a.b.c.d() (sometimes called "train-wreck" code) are the code smell this principle warns against. Delegation with a narrow, purpose-built method (Car.honk()) is how a class honors it: callers depend only on Car's own interface, never on what's inside it.

Why prefer delegation over inheriting for reuse — a Stack needs list's behavior (5.1), but inheriting from list would reuse all of it, including methods that break the whole point of a stack (insert() at an arbitrary position, sort()). Delegation reuses only the parts that make sense, by holding a list internally and exposing a narrower interface on top of it:

In [9]20 lines
class Stack:
    def __init__(self):
        self._items = []   # composition: Stack HAS a list, it isn't one

    def push(self, item):
        self._items.append(item)   # delegates to list.append

    def pop(self):
        return self._items.pop()     # delegates to list.pop

    def __len__(self):
        return len(self._items)      # delegates to list's own __len__

s = Stack()
s.push(1)
s.push(2)
print(s.pop())   # 2
print(len(s))      # 1

print(hasattr(s, "insert"), hasattr(s, "sort"))   # False False -> only push/pop/len exist at all
2
1
False False

In practice — wrapping a third-party SDK behind your own interface. A PaymentGateway class that internally holds a Stripe or PayPal SDK client and exposes only charge()/refund() is this same delegation pattern applied to real integration code: the rest of the application depends only on PaymentGateway's narrow interface, so switching payment providers later means changing one class instead of every call site that touches payments.

Contrast that with subclassing list directly — every list method comes along for free, whether it belongs on a stack or not:

In [10]8 lines
class BadStack(list):   # inheritance - reuses list's behavior, but exposes ALL of it
    pass

bs = BadStack()
bs.append(1)
bs.insert(0, 99)   # nothing stops a caller from breaking stack discipline like this
bs.sort()             # or this
print(list(bs))         # [1, 99] -> inserted out of stack order, then sorted -> not a stack anymore
[1, 99]

Going deeper — BadStack(list) is a textbook Liskov Substitution Principle violation. The Liskov Substitution Principle (the "L" in SOLID) says a subclass should be usable anywhere its parent is expected, without breaking the caller's assumptions about that parent's behavior. BadStack is a list (it inherits from one), so any code that expects list-like discipline can legally call .insert() or .sort() on it — perfectly valid list operations that nonetheless silently violate everything "stack" is supposed to mean (push/pop at one end only). The type system says BadStack can substitute for a list everywhere; the actual behavior says it can't be trusted to keep stack invariants. The composition-based Stack above never makes that promise in the first place, which is exactly why it can't break it.

11.5 Choosing Between Relationships

A quick decision path for which relationship fits a given situation:

Does A need to interact with B, without owning it?
   └── yes → Association (11.1)

Does A need to be MADE OF B (a whole-part relationship)?
   ├── B can exist on its own / be shared with other wholes → Aggregation (11.2)
   └── B belongs exclusively to A, and dies when A does      → Composition (11.3)

Does A want to reuse B's behavior, without being a kind of B?
   └── yes → Delegation (11.4) — hold B, forward the calls that make sense

Is A fundamentally a specialized kind of B?
   └── yes → Inheritance (chapter 12) — not a relationship this chapter covers
Relationship Ownership Part's lifecycle Typical shape
Association none independent a method parameter or a plain reference
Aggregation weak "has-a" independent, shareable def __init__(self, part): self.part = part
Composition strong "owns-a" tied to the whole def __init__(self): self.part = Part(...)
Delegation (built on aggregation or composition) — def method(self): return self.part.method()

One scenario can use all four at once. A Library: it associates with Members (lends them books, doesn't own them), aggregates those same Members (they exist before and after joining), composes its own Shelf internally, and delegates storage decisions to that shelf instead of handling them itself:

In [11]40 lines
class Book:
    def __init__(self, title):
        self.title = title

class Shelf:
    def __init__(self):
        self.books = []   # composition: built inside Library, never shared elsewhere

    def store(self, book):
        self.books.append(book)

    def has_space(self):
        return len(self.books) < 3

class Member:
    def __init__(self, name):
        self.name = name

class Library:
    def __init__(self, name, members=None):
        self.name = name
        self.members = members if members is not None else []   # aggregation - members exist independently
        self.shelf = Shelf()                                             # composition - Library builds its own shelf

    def lend(self, book, member):   # association - interacts with a Member, doesn't own it
        print(f"{self.name} lends '{book.title}' to {member.name}")

    def store_book(self, book):
        if self.shelf.has_space():   # delegation - forwards the space-check to Shelf
            self.shelf.store(book)
            print(f"stored '{book.title}'")
        else:
            print("no space left")

ada = Member("Ada")   # exists independently of any library
library = Library("Central Library", [ada])

book = Book("Python 101")
library.store_book(book)   # stored 'Python 101'
library.lend(book, ada)      # Central Library lends 'Python 101' to Ada
stored 'Python 101'
Central Library lends 'Python 101' to Ada