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:

AggregationComposition
StrengthWeak has-aStrong has-a
Who makes the partSomebody elseThe whole itself
How it arrivesPassed into __init__Built inside __init__
Can be sharedYesNo
When the whole is deletedThe part lives onThe part goes too
Read it as"uses a""is made of"
ExampleA classroom and its teacherA 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:

WholePartWhichWhy
SchoolTeachersAggregationA teacher can move to another school
SchoolBuildingsCompositionA building is not reassigned when the school closes
ClassroomTeacherAggregationShared across several classes
ClassroomDesksCompositionBought for that room
PlaylistSongsAggregationThe same song is in many playlists
Report cardRowsCompositionA row has no meaning on its own
OrderCustomerAggregationThe customer existed before the order
OrderOrder linesCompositionA 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.

MistakeWhat you seeWhy it happensFix
Inheriting for a has-aisinstance(car, Engine) is TrueInheritance claims is-aHold the part as an attribute
Building the part inside when it should be sharedTwo wholes get two different partsThat is composition, not aggregationPass the part into __init__
Passing the part in when nobody else should have itAn outsider changes it laterThat is aggregation, not compositionBuild it inside
Returning the internal listAttributeError-free, but the caller empties itThe caller got the real listReturn list(self.desks)
A mutable default, def __init__(self, desks=[])Every object shares one listThe default is made oncedesks=None, then self.desks = desks or []
Expecting del room to delete the teacherThe teacher is still thereOther names still hold itThat is the point of aggregation
Reaching through several objects, a.b.c.dFragile code, breaks on any changeThe caller knows too muchGive the whole a method that does it
Thinking self.desks = desks copiesChanging the caller's list changes yoursA name is bound, not copiedself.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. Use None and build inside.
  • Reaching through a.b.c.d means 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