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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
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:
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