Composition & Aggregation
How objects are assembled from other objects — the has-a relationship in depth. We build Order, OrderItem, and Payment, explore composition vs aggregation, and map both to SysML v2 part and reference semantics.
One interface, many implementations — how the same method call produces different behaviour depending on the object’s type, from runtime dispatch to operator overloading.
Polymorphism (from Greek: “many forms”) is the ability of different objects to respond to the same message in their own way. You call the same method on different objects and each does something appropriate for its own type — without the calling code needing to know which type it is dealing with.
| Type | How Python achieves it |
|---|---|
| Subtype polymorphism | Overriding methods in subclasses. The runtime type determines which version runs. |
| Duck typing | Any object that implements the expected methods can be used, regardless of its class hierarchy. |
| Operator overloading | Dunder methods (__add__, __lt__, etc.) give standard operators object-specific meanings. |
Book works correctly with any current or future subtype, without modification. This is what makes OOD code extensible.
When Python calls a method, it looks up the method on the actual type of the object at runtime — not the declared type of the variable. This is dynamic dispatch:
catalogue: list[Book] = [
DigitalBook("978-1-11", "Clean Code", "Martin", 29.99, "PDF", 5.2, "..."),
PhysicalBook("978-1-22", "Refactoring", "Fowler", 39.99, 5, 520, "...", "WH1"),
DigitalBook("978-1-33", "Pragmatic Programmer", "Hunt", 34.99, "EPUB", 8.4, "..."),
PhysicalBook("978-1-44", "SICP", "Abelson", 24.99, 3, 680, "...", "WH2"),
]
# Same call, different behaviour per type — dynamic dispatch
for book in catalogue:
print(book.get_details()) # dispatches to Digital or Physical
print(f" Available: {book.is_available()}") # inherited — same for all
Book hierarchy with polymorphic methods. Both subclasses override get_details(); Book provides __str__, __lt__, __eq__ so all subtypes inherit comparison and string behaviour automatically.Python does not require objects to share a common base class for polymorphic use. Any object that has the right methods can be used — “if it walks like a duck and quacks like a duck, it is a duck.”
# A completely independent class — not a Book subclass class GiftCard: def __init__(self, code: str, value: float): self.code = code; self.value = value; self.stock = 1 def get_details(self) -> str: return f"Gift Card {self.code} | £{self.value:.2f}" def is_available(self) -> bool: return self.stock > 0 # print_catalogue only cares about get_details() and is_available() def print_catalogue(items) -> None: for item in items: print(item.get_details()) if not item.is_available(): print(" *** OUT OF STOCK ***") mixed = [ DigitalBook("978-1-11", "Clean Code", "Martin", 29.99, "PDF", 5.2, "..."), GiftCard("GC-2024-001", 25.00), ] print_catalogue(mixed) # works with both — no inheritance needed
print_catalogue() works with any object that has get_details() and is_available(), regardless of class hierarchy. This is the most flexible form of polymorphism in Python and makes functions trivially easy to test with simple stub objects.
| Pattern | When to use it |
|---|---|
| Duck typing (just call the method) | You only need the common interface. Preferred default. |
isinstance(x, DigitalBook) | You need a method only DigitalBook has (e.g. download()). Use sparingly. |
isinstance(x, Book) | You need to confirm it's a Book before calling Book-specific methods. Acceptable for validation. |
Python uses dunder methods to integrate objects with the language itself. These are a form of polymorphism — the same built-in function behaves differently depending on the object it receives:
# Add to Book def __str__(self) -> str: return self.get_details() # delegates to the overridden method def __repr__(self) -> str: return (f"{self.__class__.__name__}(isbn={self.isbn!r}, " f"title={self.title!r}, price={self.price!r})") # Add to ShoppingCart def __len__(self) -> int: return len(self._items) def __contains__(self, b): return b in self._items def __iter__(self): return iter(self._items) # Usage book = DigitalBook("978-1-11", "Clean Code", "Martin", 29.99, "PDF", 5.2, "...") print(book) # "Clean Code by Martin | PDF (5.2 MB) £29.99" repr(book) # "DigitalBook(isbn='978-1-11', title='Clean Code', ...)" print(len(cart)) # 1 print(book in cart) # True for b in cart: print(b)
__str__ is for end users: readable and concise. __repr__ is for developers: precise enough to reconstruct the object. When only implementing one, prefer __repr__ — Python uses it as a fallback for __str__ in the REPL and in containers like lists.
Python lets you define the behaviour of standard operators (==, <, +, in) for your own types. This is operator overloading: the same operator behaves differently depending on the types involved.
# Add to Book def __eq__(self, other) -> bool: if not isinstance(other, Book): return NotImplemented return self.isbn == other.isbn def __hash__(self) -> int: # required when __eq__ is defined return hash(self.isbn) def __lt__(self, other) -> bool: if not isinstance(other, Book): return NotImplemented return self.price < other.price # ShoppingCart merge def __add__(self, other: "ShoppingCart") -> "ShoppingCart": if not isinstance(other, ShoppingCart): return NotImplemented merged = ShoppingCart(f"{self.cart_id}+{other.cart_id}") for b in self: merged.add_item(b) for b in other: merged.add_item(b) return merged
# Standard Python idioms now work naturally: books.sort() # uses __lt__ to sort by price for b in books: print(f" £{b.price:.2f} {b.title}") # £24.99 SICP # £29.99 Clean Code # £39.99 Refactoring b1 = DigitalBook("978-1-11", "Clean Code", "Martin", 29.99, "PDF", 5.2, "...") b2 = DigitalBook("978-1-11", "Clean Code", "Martin", 29.99, "EPUB", 6.0, "...") print(b1 == b2) # True — same ISBN, different format unique_books = {b1, b2} print(len(unique_books)) # 1 (requires __hash__) merged = cart_a + cart_b print(len(merged)) # len(cart_a) + len(cart_b)
Book and ShoppingCart with operator overloading. Book gains __str__, __repr__, __lt__, __eq__, __hash__. ShoppingCart gains __len__, __contains__, __iter__, __add__ — making it behave like a native Python sequence.__eq__ automatically sets __hash__ to None, making the class unhashable (cannot be used in sets or as dict keys). Always define __hash__ alongside __eq__. A safe default: return hash(self.isbn).
The real payoff of polymorphism: functions that work correctly with any current or future Book subtype, without modification.
from typing import Sequence # 1. Apply a discount to any collection of books def apply_sale(books: Sequence[Book], discount: float) -> None: for book in books: book.apply_discount(discount) print(f" {book.title} -> £{book.price:.2f}") # 2. Split a mixed catalogue by type def split_catalogue(books): digital = [b for b in books if isinstance(b, DigitalBook)] physical = [b for b in books if isinstance(b, PhysicalBook)] return digital, physical # 3. Total shipping for physical books only def total_shipping(books: Sequence[Book]) -> float: return sum( b.get_shipping_cost() for b in books if isinstance(b, PhysicalBook) ) apply_sale(catalogue, 15) digital, physical = split_catalogue(catalogue) print(f"Shipping: £{total_shipping(physical):.2f}")
apply_sale() is open for extension (adding AudioBook works automatically) and closed for modification (you never change apply_sale() when adding a new subtype). This is the Open/Closed Principle — one of the SOLID principles covered in Module 7.
In SysML v2, polymorphism arises naturally from the subclassification and redefinition operators introduced in Module 3. An action declared on an abstract part def is automatically polymorphic:
abstract part def Book { abstract action def get_details : String; // polymorphic action def is_available : Boolean; } part def DigitalBook :> Book { :>> get_details : String; // redefines — dispatched at runtime } part def PhysicalBook :> Book { :>> get_details : String; // different redefinition } // catalogue holds any Book subtype — polymorphic collection part catalogue : Book[0..*];
abstract action def + :>> redefinitionsBook[*] multiplicitypart def is automatically available to all specialisations. This lets SysML v2 models express that a system can handle any component satisfying an interface — directly analogous to duck typing at the modelling level.
Python resolves method calls at runtime on the actual type of the object — not the declared type of the variable.
Any object with the right methods can be used polymorphically. No shared base class required. The most flexible form.
__str__ for users, __repr__ for developers. When only implementing one, prefer __repr__.
Always define __hash__ alongside __eq__ to keep objects usable in sets and dicts.
Polymorphic functions are open for extension, closed for modification. Adding a new subtype never requires changing existing code.
Polymorphism via abstract action def + :>> redefinitions. part catalogue : Book[*] is a polymorphic collection.
How objects are assembled from other objects — the has-a relationship in depth. We build Order, OrderItem, and Payment, explore composition vs aggregation, and map both to SysML v2 part and reference semantics.