PYTH-102 · Lesson 5 of 12 · 26 min
Inheritance and composition
▶ Video coming soon A video version of this lesson is on the way.
Ridgeline does not sell only coffee. The shop also sells mugs and brewing gear, which have a price and a stock count but no roast date, and gift cards, which have a price but are never out of stock. Jordan's Coffee class from lesson 4 does not fit these products, but writing three unrelated classes would repeat the same price and stock code three times. And an online order is not a kind of product at all: it is made of products, a customer and a shipping method.
These two situations correspond to the two main ways of building classes from other classes. Inheritance expresses an is-a relationship: a coffee is a product, so Coffee can inherit what all products share and add what is special about coffee. Composition expresses a has-a relationship: an order has lines, and each line has a product, so Order holds other objects as attributes. This lesson covers both, along with method overriding, super(), polymorphism and duck typing, abstract base classes and your own exception classes, and gives practical rules for choosing between inheritance and composition.
Inheritance
A class can be based on another class by naming it in parentheses after the class name. The new class, the subclass or child, inherits every method and class attribute of the base class, or parent, and can add its own. Inheritance lets common behavior be written once, in the base class, and reused by every subclass.
class Product:
def __init__(self, sku, name, price, stock=0):
self.sku, self.name, self.price, self.stock = sku, name, price, stock
def value(self):
return round(self.stock * self.price, 2)
def label(self):
return f"{self.sku} {self.name} {self.price:.2f}"
class Coffee(Product):
bag_g = 340
class Mug(Product):
pass
items = [Coffee("RC-101", "Sunrise Blend", 18.50, 42), Mug("MG-01", "Ridgeline mug", 16.00, 24)]
for item in items:
print(item.label(), item.value())
print(isinstance(items[0], Product), isinstance(items[1], Coffee))
print(issubclass(Coffee, Product), Coffee.__bases__[0].__name__)RC-101 Sunrise Blend 18.50 777.0 MG-01 Ridgeline mug 16.00 384.0 True False True Product
Coffee and Mug have no __init__ of their own, so creating one runs Product.__init__. When Python looks up a method on an object, it searches the object's class first, then that class's base class, and so on up the chain, stopping at the first match. isinstance answers whether an object is an instance of a class or any of its subclasses, which is why a Coffee is also a Product. issubclass asks the same question about classes.
Overriding methods and super()
A subclass can replace an inherited method by defining a method with the same name. This is called overriding. Often the new method wants to do what the parent's method did and then a little more. The built-in super() gives access to the parent class's version, so the subclass can extend the behavior rather than copying it.
class Product:
def __init__(self, sku, name, price, stock=0):
self.sku, self.name, self.price, self.stock = sku, name, price, stock
def label(self):
return f"{self.sku} {self.name} {self.price:.2f}"
class Coffee(Product):
def __init__(self, sku, name, price, stock=0, roast="medium", origin="blend"):
super().__init__(sku, name, price, stock) # let Product set the shared fields
self.roast = roast
self.origin = origin
def label(self): # override, extending the parent's
return super().label() + f" [{self.roast} roast, {self.origin}]"
class GiftCard(Product):
def __init__(self, sku, amount):
super().__init__(sku, f"Gift card {amount:.0f}", amount)
def label(self): # override, replacing it
return f"{self.sku} {self.name} (never runs out)"
print(Coffee("RC-102", "Ethiopia Guji", 22.00, 15, "light", "Ethiopia").label())
print(GiftCard("GC-25", 25.00).label())
print(Product("MG-01", "Ridgeline mug", 16.00).label())RC-102 Ethiopia Guji 22.00 [light roast, Ethiopia] GC-25 Gift card 25 (never runs out) MG-01 Ridgeline mug 16.00
Coffee.__init__ accepts two extra arguments, passes the shared ones to Product.__init__ through super(), and then sets its own. This is the standard pattern: a subclass's __init__ should almost always call super().__init__ so that the parent's set-up is not skipped. Forgetting it leaves the object without the attributes the parent's methods expect, and the error appears later, far from its cause.
Polymorphism
Because every product has a label method, code that prints labels does not need to know which kind of product it has. It calls item.label(), and each object runs its own version. This ability of different classes to respond to the same method call in their own way is called polymorphism, from the Greek for many forms. It removes long if-elif chains that test the type of an object, and it means a new kind of product can be added without changing the code that uses products.
class Product:
def __init__(self, name, stock):
self.name, self.stock = name, stock
def is_low(self):
return self.stock < 10
class Coffee(Product):
pass
class Mug(Product):
def is_low(self): # mugs come in cases of 6: low below one case
return self.stock < 6
class GiftCard(Product):
def __init__(self, name):
super().__init__(name, stock=0)
def is_low(self): # digital: never runs out
return False
shelf = [Coffee("Sumatra Dark", 9), Mug("Ridgeline mug", 8), GiftCard("Gift card 25")]
for item in shelf:
print(f"{item.name:<14}", "LOW" if item.is_low() else "ok")Sumatra Dark LOW Ridgeline mug ok Gift card 25 ok
Duck typing
Python takes polymorphism further than many languages. The loop above does not actually need the objects to inherit from Product; it only needs each one to have a name attribute and an is_low method. Python checks for the method when it is called, not when the object is created. This approach is called duck typing, from the saying "if it walks like a duck and quacks like a duck, it is a duck". Many built-in functions work this way: len works on anything with a __len__ method, and a for loop works on anything that can be iterated, which lesson 7 explains.
class RoastBatch: # not a Product at all
def __init__(self, batch_id, bags):
self.name, self.bags = batch_id, bags
def is_low(self):
return self.bags < 20
def low_report(things):
return [t.name for t in things if t.is_low()]
print(low_report([RoastBatch("B-0412", 30), RoastBatch("B-0413", 12)]))['B-0413']
Duck typing keeps code flexible, but it means a missing method is only discovered when the code runs. Clear names, docstrings that say what an argument must provide, the type hints of lesson 11 and good tests are how Python programmers keep that flexibility safe.
Try it yourself
A subclass with super()
A Product class is provided by the setup. Write a subclass Merch(Product) whose __init__(self, sku, name, price, stock, variant) calls super().__init__ for the first four values and stores variant as an attribute.
Override label() so it returns the parent's label followed by a space and the variant code in square brackets, for example MG-01 Ridgeline mug 16.00 [BLK].
class Merch(Product):
def __init__(self, sku, name, price, stock, variant):
self.variant = variant
m = Merch('MG-01', 'Ridgeline mug', 16.00, 24, 'BLK')
print(m.label())Abstract base classes
Sometimes a base class exists only to define what its subclasses must provide, and it makes no sense to create an instance of the base class itself. Ridgeline ships orders by courier, by post and by in-store pickup; every shipping method must be able to quote a cost, but there is no such thing as a generic shipping method. The abc module lets you mark such a class as abstract, and mark the methods every subclass must implement with @abstractmethod. Python then refuses to create an object from any class that has not implemented them all.
from abc import ABC, abstractmethod
class ShippingMethod(ABC):
@abstractmethod
def cost(self, weight_g):
"""Return the shipping cost for a parcel weight in grams."""
def describe(self, weight_g): # shared, uses the abstract method
return f"{type(self).__name__}: {self.cost(weight_g):.2f}"
class Courier(ShippingMethod):
def cost(self, weight_g):
return 9.50 + 2.00 * (weight_g // 1000)
class Pickup(ShippingMethod):
def cost(self, weight_g):
return 0.0
for method in (Courier(), Pickup()):
print(method.describe(2400))
try:
ShippingMethod()
except TypeError:
print("ShippingMethod cannot be instantiated: cost is abstract")Courier: 13.50 Pickup: 0.00 ShippingMethod cannot be instantiated: cost is abstract
The abstract class can still contain working methods, such as describe, which every subclass inherits. This combination, a fixed overall procedure in the base class that calls methods the subclasses fill in, is a common design pattern. Abstract base classes are most useful in larger programs and libraries, where they document the expected interface and catch a missing method as soon as someone tries to create an object, rather than when the method is first called.
Your own exception classes
Exceptions are classes too, arranged in an inheritance hierarchy: ValueError inherits from Exception, which inherits from BaseException. An except clause catches the named class and all of its subclasses. Defining your own exception classes, usually as subclasses of an existing one, lets the rest of the program catch exactly the problems it knows how to handle.
class RidgelineError(Exception):
"""Base class for errors in the Ridgeline tools."""
class OutOfStock(RidgelineError):
def __init__(self, sku, wanted, available):
super().__init__(f"{sku}: wanted {wanted}, only {available} left")
self.sku, self.wanted, self.available = sku, wanted, available
class UnknownProduct(RidgelineError, KeyError):
pass
def sell(stock, sku, qty):
if sku not in stock:
raise UnknownProduct(sku)
if qty > stock[sku]:
raise OutOfStock(sku, qty, stock[sku])
stock[sku] -= qty
stock = {"RC-104": 9}
for sku, qty in [("RC-104", 4), ("RC-104", 20), ("RC-999", 1)]:
try:
sell(stock, sku, qty)
print("sold", qty, "of", sku)
except OutOfStock as err:
print("Out of stock:", err, "| short by", err.wanted - err.available)
except RidgelineError as err:
print(type(err).__name__, err)sold 4 of RC-104 Out of stock: RC-104: wanted 20, only 5 left | short by 15 UnknownProduct 'RC-999'
Every Ridgeline error inherits from RidgelineError, so the command loop can catch them all with one except clause while still letting genuine bugs, such as a TypeError, crash loudly. OutOfStock carries extra attributes that the handler can use. UnknownProduct inherits from two classes, which is allowed in Python and here means existing code that catches KeyError still works. That is multiple inheritance; it has its uses, but it is best kept to simple cases like this one.
Composition
Inheritance is powerful but rigid: a subclass is tied to every detail of its parent, and a change to the parent can break subclasses in surprising ways. Many relationships are not is-a relationships at all. An order is not a kind of customer or a kind of product; it has a customer and has products. Composition models this by giving an object other objects as attributes and having it delegate work to them.
from dataclasses import dataclass, field
@dataclass
class Product:
sku: str
name: str
price: float
@dataclass
class OrderLine:
product: Product
qty: int
def amount(self):
return round(self.product.price * self.qty, 2)
class Courier:
def cost(self, lines):
return 9.50
class Pickup:
def cost(self, lines):
return 0.0
@dataclass
class Order:
customer: str
shipping: object
lines: list = field(default_factory=list)
def add(self, product, qty):
self.lines.append(OrderLine(product, qty))
def total(self):
goods = sum(line.amount() for line in self.lines)
return round(goods + self.shipping.cost(self.lines), 2)
sunrise = Product("RC-101", "Sunrise Blend", 18.50)
mug = Product("MG-01", "Ridgeline mug", 16.00)
order = Order("Priya", Courier())
order.add(sunrise, 2)
order.add(mug, 1)
print(order.total())
order.shipping = Pickup() # swap a part at run time
print(order.total())62.5 53.0
The order does not know how shipping is priced; it asks its shipping object. Changing from courier to pickup is a one-line assignment, made while the program runs. With inheritance, the same flexibility would have needed a CourierOrder subclass and a PickupOrder subclass, and then more subclasses for every combination with gift wrapping or discounts. The design guideline "favor composition over inheritance" comes from exactly this experience.
A practical rule: use inheritance when the subclass genuinely is a special case of the parent, will be used anywhere the parent is expected, and needs most of the parent's behavior. Use composition for everything else, especially when you want to combine behaviors or swap them at run time. Keep inheritance hierarchies shallow; two or three levels are plenty for almost any program.
Worked problem
Is-a or has-a?
For each pair, choose inheritance or composition: (a) EspressoBlend and Coffee; (b) Cafe and Menu; (c) Barista and Employee; (d) Inventory and dict; (e) LoyaltyCard and Customer.
Show solution
- (a) An espresso blend is a coffee and can be used wherever a coffee is expected: inheritance.
- (b) A cafe has a menu, and the menu can change: composition.
- (c) A barista is an employee, but roles change over time, so storing a role on Employee is often better: usually composition.
- (d) An inventory has a dictionary of coffees; inheriting from dict would expose every dictionary method, including ones that bypass the stock rules: composition.
- (e) A customer has a loyalty card, and a card is not a customer: composition.
Answer: Only (a) is a clear is-a relationship. When in doubt, compose.
Try it yourself
Compose a gift box
Write a class GiftBox that is composed of other objects rather than inheriting from them. Its __init__(self, wrap_fee) stores the fee and starts an empty list items.
Add add(product), which appends a product, and total(), which returns the sum of the products' price attributes plus the wrap fee, rounded to 2 decimals. The setup defines a Product dataclass with sku, name and price.
class GiftBox(Product):
pass
box = GiftBox(4.50)Common mistakes
MistakeForgetting to call super().__init__ in a subclass __init__.
InsteadCall super().__init__(...) first so the parent sets up its attributes.
MistakeTesting types with long if-elif chains on type(obj).
InsteadGive each class its own version of the method and call it: polymorphism.
MistakeInheriting just to reuse a few methods, as in class Inventory(dict).
InsteadHold the dictionary as an attribute and expose only the operations you want.
MistakeBuilding deep inheritance trees with many levels.
InsteadKeep hierarchies shallow and combine behaviors with composition.
MistakeCatching Exception everywhere, hiding real bugs.
InsteadDefine a base exception for your program and catch that.
MistakeCopying the parent's method body into an override and editing it.
InsteadCall super().method() and add only what is different.
Summary and key terms
- A subclass inherits the methods and class attributes of its base class and can add or override them.
- super() calls the parent class's version of a method; subclass __init__ methods should call super().__init__.
- isinstance and issubclass test class relationships, including inherited ones.
- Polymorphism lets different classes answer the same method call in their own way; duck typing needs only the right methods.
- Abstract base classes define methods every subclass must implement and cannot be instantiated themselves.
- Custom exceptions are subclasses of Exception; a shared base class lets callers catch a program's own errors.
- Composition builds objects from other objects (has-a); prefer it to inheritance unless the relationship is truly is-a.
- inheritance
- Defining a class as a specialized version of another class.
- base class
- The class another class inherits from; also called the parent or superclass.
- subclass
- A class that inherits from another; also called the child.
- overriding
- Defining a method in a subclass with the same name as an inherited one.
- super()
- A built-in that gives access to the parent class's methods.
- polymorphism
- Different classes responding to the same method call in their own ways.
- duck typing
- Using an object according to the methods it has rather than its class.
- abstract base class
- A class that defines required methods and cannot itself be instantiated.
- composition
- Building an object from other objects held as attributes.
- delegation
- An object handing work to one of the objects it is composed of.
Practice problems
Work each problem on paper first, then reveal the answer to check.
class Decaf(Coffee): pass. Which __init__ runs for Decaf(...)?
Show answer
Coffee's, or Product's if Coffee does not define one.
Write a label method for a subclass that adds " (decaf)" to the parent's label.
Show answer
def label(self): return super().label() + " (decaf)"
Why does except RidgelineError also catch OutOfStock?
Show answer
OutOfStock is a subclass of RidgelineError, and except catches subclasses.
What happens if a subclass of an abstract class does not implement every abstract method?
Show answer
Creating an instance of it raises TypeError.
Give one reason to prefer composition for Order and ShippingMethod.
Show answer
The shipping method can be swapped at run time without new subclasses.
Is a Customer with a list of orders an example of inheritance or composition?
Show answer
Composition: a customer has orders.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.