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.

Coffee and Mug inherit everything from Product
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__)
Output
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.

Overriding __init__ and label, with super() calling the parent versions
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())
Output
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.

On the left, an inheritance tree: Product at the top with sku, name, price, stock and the methods value and label. Arrows labeled is-a point up to it from Coffee (adds roast and origin, overrides label), Mug, and GiftCard (overrides label and is never low). On the right, a composition diagram: an Order box contains a Customer, a ShippingMethod and a list of OrderLine boxes; each OrderLine has a Product and a quantity. These links are labeled has-a.
Inheritance builds is-a relationships; composition builds has-a relationships

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.

One loop, three behaviors: each class answers is_low in its own way
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")
Output
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.

Anything with the right attributes works: duck typing
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)]))
Output
['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.

An abstract base class defines what every shipping method must provide
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")
Output
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.

A small exception hierarchy for the Ridgeline tools
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)
Output
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.

An Order composed of lines, products and a shipping method
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())
Output
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
  1. (a) An espresso blend is a coffee and can be used wherever a coffee is expected: inheritance.
  2. (b) A cafe has a menu, and the menu can change: composition.
  3. (c) A barista is an employee, but roles change over time, so storing a role on Employee is often better: usually composition.
  4. (d) An inventory has a dictionary of coffees; inheriting from dict would expose every dictionary method, including ones that bypass the stock rules: composition.
  5. (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.

  1. class Decaf(Coffee): pass. Which __init__ runs for Decaf(...)?

    Show answer

    Coffee's, or Product's if Coffee does not define one.

  2. Write a label method for a subclass that adds " (decaf)" to the parent's label.

    Show answer

    def label(self): return super().label() + " (decaf)"

  3. Why does except RidgelineError also catch OutOfStock?

    Show answer

    OutOfStock is a subclass of RidgelineError, and except catches subclasses.

  4. What happens if a subclass of an abstract class does not implement every abstract method?

    Show answer

    Creating an instance of it raises TypeError.

  5. Give one reason to prefer composition for Order and ShippingMethod.

    Show answer

    The shipping method can be swapped at run time without new subclasses.

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

Take the quiz