Class Relationships
By the Syncfusion DevTutorials team · Last updated
Has-a instead of is-a: aggregation where the part is passed in, composition where the whole builds it, and how to tell them apart.
Inheritance answers one question: is this a kind of that? A ScienceTeacher is a Teacher.
Most of the time the answer is no. A classroom is not a kind of teacher, and a school is not a kind of classroom. But a classroom has a teacher, and a school has classrooms. That second relationship is the one you will use far more often, and it comes in two strengths.
Here is that has-a relationship in code — a classroom holding a teacher object it was handed:
A classroom has a teacher
class Teacher:
def __init__(self, name):
self.name = name
class Classroom:
def __init__(self, label, teacher):
self.label = label
self.teacher = teacher
mrs_rao = Teacher("Mrs Rao")
room = Classroom("7A", mrs_rao)
print(room.label, "is taught by", room.teacher.name)
print(room.teacher is mrs_rao)
Inheritance is is-a. Aggregation and composition are both has-a. The difference between the two is whether the part can live without the whole.
What You Will Learn
In this lesson, you will learn:
- The difference between is-a and has-a, and why has-a is usually the right answer
- What aggregation is, and how to spot it: the part was made outside and handed in
- What composition is: the whole makes its own parts, and they go when it goes
- How to tell them apart in two seconds by asking one question
- Why "favour composition over inheritance" is advice worth following
By the end of this lesson, you will be able to tell aggregation and composition apart at a glance, choose between them with one question, and build objects that hold their parts together without pretending to be something they are not.
Aggregation
What Is Aggregation?
Aggregation is a has-a relationship where the part exists on its own. It was made elsewhere, it is handed to the whole, and it carries on existing after the whole is gone.
A classroom has a teacher. The teacher existed before that classroom and will still be employed after it is closed. The classroom borrows them.
The Has-A Relationship
Here is the has-a relationship in full — the whole keeps a reference to the part and hands work to it:
Reading the relationship out loud
class Teacher:
def __init__(self, name, subject):
self.name = name
self.subject = subject
def describe(self):
return f"{self.name} teaches {self.subject}"
class Classroom:
def __init__(self, label, teacher):
self.label = label
self.teacher = teacher
def describe(self):
return f"Class {self.label}: {self.teacher.describe()}"
mrs_rao = Teacher("Mrs Rao", "Maths")
room = Classroom("7A", mrs_rao)
print(room.describe())
Classroom has a Teacher. It does not try to be one, and it does not inherit anything from one.
Creating Aggregated Objects
The sign of aggregation is in the __init__ line: the part arrives as a parameter.
The part is made outside and passed in
class Teacher:
def __init__(self, name):
self.name = name
class Classroom:
def __init__(self, label, teacher):
self.label = label
self.teacher = teacher
mrs_rao = Teacher("Mrs Rao")
morning = Classroom("7A", mrs_rao)
afternoon = Classroom("8B", mrs_rao)
print(morning.teacher.name, afternoon.teacher.name)
print(morning.teacher is afternoon.teacher)
One teacher, two classrooms, and both hold the same object. That sharing is only possible because the teacher is not owned by either room.
Accessing Aggregated Objects
You reach the part through the whole, with a dot for each step.
One dot per step
class Teacher:
def __init__(self, name, subject):
self.name = name
self.subject = subject
class Classroom:
def __init__(self, label, teacher):
self.label = label
self.teacher = teacher
room = Classroom("7A", Teacher("Mrs Rao", "Maths"))
print(room.teacher)
print(room.teacher.name)
print(room.teacher.subject.upper())
The Part Outlives the Whole
This is the test that settles it. Delete the classroom and ask whether the teacher is still there.
The teacher survives the classroom
class Teacher:
def __init__(self, name):
self.name = name
class Classroom:
def __init__(self, label, teacher):
self.label = label
self.teacher = teacher
mrs_rao = Teacher("Mrs Rao")
room = Classroom("7A", mrs_rao)
print("Before:", room.teacher.name)
del room
print("After the classroom is gone:", mrs_rao.name)
An Aggregation Example
Here is aggregation at a larger size — a school holding classrooms, while the staff list stays outside where it belongs:
A school of classrooms, and a staff list of its own
class Teacher:
def __init__(self, name):
self.name = name
class Classroom:
def __init__(self, label, teacher):
self.label = label
self.teacher = teacher
class School:
def __init__(self, name):
self.name = name
self.classrooms = []
def add(self, classroom):
self.classrooms.append(classroom)
staff = [Teacher("Mrs Rao"), Teacher("Mr Khan")]
school = School("Greenfield")
school.add(Classroom("7A", staff[0]))
school.add(Classroom("8B", staff[1]))
for room in school.classrooms:
print(room.label, "—", room.teacher.name)
print("Staff still listed:", [teacher.name for teacher in staff])
Benefits of Aggregation
- Sharing. One teacher object can be used by several classrooms, and a change to their name shows everywhere.
- Independent lifetimes. Closing a classroom does not sack the teacher.
- Easier testing. You can hand in a simple stand-in object instead of the real one.
- Looser coupling. The classroom only needs the teacher to have a
name; it does not care which class it came from.
Sharing cuts both ways. Because both classrooms hold the same teacher object, changing mrs_rao.name changes what both rooms report. That is usually what you want — but only because there is genuinely one teacher.
Composition
What Is Composition?
Composition is a has-a relationship where the whole owns the part. The whole creates it, nothing else holds it, and when the whole is gone the part has no reason to exist.
A classroom has desks. Those desks belong to that room. Close the room and the desks are not reassigned to a teacher — they go with it.
A Strong Has-A Relationship
Here is composition in code — the room builds its own desks:
The room makes its own desks
class Desk:
def __init__(self, number):
self.number = number
class Classroom:
def __init__(self, label, desk_count):
self.label = label
self.desks = [Desk(number) for number in range(1, desk_count + 1)]
room = Classroom("7A", 4)
print(room.label, "has", len(room.desks), "desks")
print([desk.number for desk in room.desks])
Compare this __init__ with the aggregation one. No Desk is passed in. The room is handed a number and builds the desks itself.
Creating Composed Objects
Here are the two relationships side by side in one class, so you can see the difference in a single glance:
Aggregation and composition, side by side
class Teacher:
def __init__(self, name):
self.name = name
class Desk:
def __init__(self, number):
self.number = number
class Classroom:
def __init__(self, label, teacher, desk_count):
self.label = label
self.teacher = teacher
self.desks = [Desk(number) for number in range(1, desk_count + 1)]
room = Classroom("7A", Teacher("Mrs Rao"), 3)
print("Teacher passed in :", room.teacher.name)
print("Desks built inside:", len(room.desks))
One class, both relationships, and the difference is visible in a single line each.
Object Lifecycle Dependency
Nothing outside the room holds those desks, so when the room goes, they go too. Python collects an object when nothing refers to it any more.
Nothing else is holding the desks
import sys
class Desk:
def __init__(self, number):
self.number = number
class Classroom:
def __init__(self, label, desk_count):
self.label = label
self.desks = [Desk(number) for number in range(1, desk_count + 1)]
room = Classroom("7A", 2)
first_desk = room.desks[0]
print("References to the first desk:", sys.getrefcount(first_desk) - 1)
outside = Desk(99)
print("References to a desk nobody owns:", sys.getrefcount(outside) - 1)
The desk inside the room is held by the room's list and by the name first_desk. The point is that without that extra name, the room's list is the only thing holding it — so the room's lifetime is the desk's lifetime.
A Composition Example
Here is composition at work — a report card that makes its own rows:
A report card made of its own rows
class Row:
def __init__(self, subject, mark):
self.subject = subject
self.mark = mark
def line(self):
status = "pass" if self.mark >= 35 else "fail"
return f" {self.subject:<10}{self.mark:>4} {status}"
class ReportCard:
def __init__(self, student, results):
self.student = student
self.rows = [Row(subject, mark) for subject, mark in results.items()]
def show(self):
print(self.student)
for row in self.rows:
print(row.line())
print(f" {'Average':<10}{self.average():>4.0f}")
def average(self):
return sum(row.mark for row in self.rows) / len(self.rows)
card = ReportCard("Asha", {"Maths": 78, "Science": 85, "English": 30})
card.show()
Nobody outside ever holds a Row. They are made from the marks, they live in the card, and they go with it.
Benefits of Composition
- Clear ownership. One object is responsible for the part, so there is no question who may change it.
- No surprises from sharing. Nothing else holds the part, so nothing else can change it under you.
- Tidy cleanup. The part goes when the whole goes, with nothing left behind.
- Small classes. A big class becomes a whole made of small, testable parts.
Composition is only real if nothing leaks. The moment a method returns self.desks, the caller holds the list and can add to or empty it. Return a copy — list(self.desks) — or a tuple, if the ownership is meant to mean anything.
A leak, and the fix
class Classroom:
def __init__(self, label, desk_count):
self.label = label
self._desks = list(range(1, desk_count + 1))
def leaky_desks(self):
return self._desks
def safe_desks(self):
return list(self._desks)
room = Classroom("7A", 3)
room.leaky_desks().clear()
print("After clearing what leaky_desks returned:", room._desks)
room = Classroom("7A", 3)
room.safe_desks().clear()
print("After clearing what safe_desks returned:", room._desks)
Aggregation against Composition
Key Differences
Here are the differences side by side:
| Aggregation | Composition | |
|---|---|---|
| Strength | Weak has-a | Strong has-a |
| Who makes the part | Somebody else | The whole itself |
| How it arrives | Passed into __init__ | Built inside __init__ |
| Can be shared | Yes | No |
| When the whole is deleted | The part lives on | The part goes too |
| Read it as | "uses a" | "is made of" |
| Example | A classroom and its teacher | A classroom and its desks |
The One Question
Can the part exist on its own, and be used by something else?
- Yes → aggregation. Make it outside, pass it in.
- No → composition. Build it inside.
The question answered in code
class Book:
def __init__(self, title):
self.title = title
class Page:
def __init__(self, number):
self.number = number
class Library:
def __init__(self, books):
self.books = books
class Novel:
def __init__(self, title, page_count):
self.title = title
self.pages = [Page(n) for n in range(1, page_count + 1)]
shared = Book("Python for Schools")
library = Library([shared])
print("Aggregation — the book exists without the library:", shared.title)
novel = Novel("A Short Story", 3)
print("Composition — the pages exist only inside:", len(novel.pages))
Real-World Examples
Here is how the choice looks in familiar places:
| Whole | Part | Which | Why |
|---|---|---|---|
| School | Teachers | Aggregation | A teacher can move to another school |
| School | Buildings | Composition | A building is not reassigned when the school closes |
| Classroom | Teacher | Aggregation | Shared across several classes |
| Classroom | Desks | Composition | Bought for that room |
| Playlist | Songs | Aggregation | The same song is in many playlists |
| Report card | Rows | Composition | A row has no meaning on its own |
| Order | Customer | Aggregation | The customer existed before the order |
| Order | Order lines | Composition | A line means nothing without its order |
When to Use Each
Use aggregation when the part is a real thing in its own right — a person, a product, an account — that other parts of the program also know about.
Use composition when the part is detail: a piece you invented to keep a big class small, that nobody outside needs to name.
And use inheritance only when the child genuinely is a kind of the parent, and can be used anywhere the parent can.
Composition where inheritance was tempting
class Engine:
def start(self):
return "engine running"
class CarByInheritance(Engine):
pass
class CarByComposition:
def __init__(self):
self.engine = Engine()
def start(self):
return self.engine.start()
print(CarByInheritance().start())
print(CarByComposition().start())
print(isinstance(CarByInheritance(), Engine))
Both print the same thing, and the first one is wrong. A car is not an engine — and that claim is not just untidy: isinstance(car, Engine) now answers True, so any code that checks "is this an engine?" will accept a car. The second version says what is actually true, and swapping in a different engine later costs one line.
"Favour composition over inheritance" is the usual version of this advice. The reason is plain: a child class is tied to its parent's every change, while a part is only tied to the few methods you actually call on it.
Common mistakes
Before moving on, here are some of the mistakes beginners make with these two relationships.
Each row is one mistake. The middle column is what you will actually see on screen.
| Mistake | What you see | Why it happens | Fix |
|---|---|---|---|
| Inheriting for a has-a | isinstance(car, Engine) is True | Inheritance claims is-a | Hold the part as an attribute |
| Building the part inside when it should be shared | Two wholes get two different parts | That is composition, not aggregation | Pass the part into __init__ |
| Passing the part in when nobody else should have it | An outsider changes it later | That is aggregation, not composition | Build it inside |
| Returning the internal list | AttributeError-free, but the caller empties it | The caller got the real list | Return list(self.desks) |
A mutable default, def __init__(self, desks=[]) | Every object shares one list | The default is made once | desks=None, then self.desks = desks or [] |
Expecting del room to delete the teacher | The teacher is still there | Other names still hold it | That is the point of aggregation |
Reaching through several objects, a.b.c.d | Fragile code, breaks on any change | The caller knows too much | Give the whole a method that does it |
Thinking self.desks = desks copies | Changing the caller's list changes yours | A name is bound, not copied | self.desks = list(desks) |
What to Remember
- Inheritance is is-a. Aggregation and composition are both has-a.
- Aggregation: the part is made elsewhere and passed in. It can be shared, and it outlives the whole.
- Composition: the whole builds the part itself. Nothing else holds it, and it goes when the whole goes.
- The giveaway is in
__init__— a part that arrives as a parameter is aggregated; one built inside is composed. - Ask one question: can the part exist on its own and be used by something else?
- Shared parts mean shared changes. That is the point, and the risk.
- Composition only holds if nothing leaks: return a copy, not the internal list.
- A mutable default like
desks=[]is made once and shared by every object. UseNoneand build inside. - Reaching through
a.b.c.dmeans the caller knows too much; give the whole a method instead. - Favour composition over inheritance — a part ties you to a few methods, a parent ties you to everything it does.
Next: Multithreading