CMPUT-274 · Lesson 5 of 12 · 29 min

Object-oriented programming II: inheritance, composition, interfaces and design principles

Lesson 4 introduced objects, encapsulation and polymorphism. This lesson covers the ways classes relate to one another. Inheritance lets one class extend another, reusing its code and standing in for it. Composition builds objects out of other objects. Interfaces and abstract base classes state what an object must be able to do without saying how. These tools are powerful and easy to misuse, so the lesson ends with the design principles, known by the acronym SOLID, that experienced programmers use to keep object-oriented code flexible. Software engineering at the scale of teams and large systems is the subject of CMPUT-301; here the focus is on the paradigm's building blocks.

Inheritance: extending a class

A subclass inherits the attributes and methods of its superclass and can add new ones or override inherited ones. Inheritance models an is-a relationship: a savings account is an account, so code that works with accounts should work with savings accounts. Overriding methods is how subclasses specialize behavior, and `super()` lets an overriding method call the superclass version so that the subclass extends rather than replaces it.

Subclasses inherit withdraw and override month_end
class Account:
    def __init__(self, owner, balance=0):
        self.owner, self.balance = owner, balance
    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount
    def month_end(self):
        pass                                    # nothing by default
    def __repr__(self):
        return f"{type(self).__name__}({self.owner}, {self.balance:.2f})"

class Savings(Account):
    def __init__(self, owner, balance=0, rate=0.02):
        super().__init__(owner, balance)        # reuse the parent's setup
        self.rate = rate
    def month_end(self):                        # override: add interest
        self.balance += self.balance * self.rate / 12

class Everyday(Account):
    FEE = 4.00
    def month_end(self):                        # override: charge a fee
        self.balance -= self.FEE

accounts = [Savings("Ana", 1200), Everyday("Ben", 300), Account("Cy", 50)]
for a in accounts:
    a.month_end()                               # dynamic dispatch picks each override
print(accounts)
print(isinstance(accounts[0], Account), Savings.__mro__[1].__name__)
Output
[Savings(Ana, 1202.00), Everyday(Ben, 296.00), Account(Cy, 50.00)]
True Account

`Savings` reuses `withdraw` and `__repr__` without repeating them, and every subclass can be used wherever an `Account` is expected. The method resolution order, `__mro__`, lists the classes Python searches when looking up an attribute; for single inheritance it is simply the chain of superclasses. Python also allows multiple inheritance, with the order computed by an algorithm called C3 linearization, but most designs are clearer with single inheritance plus composition.

The Liskov substitution principle

Barbara Liskov stated the rule that makes inheritance safe: if code works correctly with objects of a superclass, it must work correctly with objects of any subclass. A subclass may accept more and promise more, but it must not require more of its callers or deliver less than the superclass promised. Violations look harmless and cause surprising bugs, because client code that is correct for the superclass breaks when a subclass object arrives.

Worked problem

The square–rectangle problem

A Rectangle class has set_width and set_height. A Square subclass overrides both so that setting either sets both, keeping the sides equal. Client code does r.set_width(4); r.set_height(5); assert r.area() == 20. What happens when r is a Square, and what does it teach?

Show solution
  1. For a Rectangle, width 4 and height 5 give area 20, so the assertion holds.
  2. For a Square, set_height(5) also sets the width to 5, so the area is 25 and the assertion fails.
  3. The client relied on a property the Rectangle promised: setting the height does not change the width. Square breaks that promise.
  4. Geometrically a square is a rectangle, but a mutable Square is not a substitutable mutable Rectangle.

Answer: The assertion fails. Is-a in the real world does not guarantee substitutability in code. Fixes include making shapes immutable, so no setters exist, or not relating Square and Rectangle by inheritance at all.

Abstract base classes and interfaces

Often a superclass exists only to define what its subclasses must do. An abstract base class declares abstract methods without implementing them, and cannot itself be instantiated. Python's `abc` module provides `ABC` and `@abstractmethod`; a subclass that does not implement every abstract method also cannot be instantiated, so the mistake is caught when the object is created rather than when the missing method is first called. Java has abstract classes and also interfaces, which declare methods and can provide default implementations but hold no instance state.

An abstract base class: subclasses must implement export before they can be created
from abc import ABC, abstractmethod

class Exporter(ABC):
    @abstractmethod
    def export(self, rows): ...
    def export_all(self, tables):            # template method built on export
        return [self.export(rows) for rows in tables]

class CsvExporter(Exporter):
    def export(self, rows):
        return "\n".join(",".join(map(str, r)) for r in rows)

class BrokenExporter(Exporter):              # forgot to implement export
    pass

print(CsvExporter().export_all([[(1, "a"), (2, "b")]]))
for cls in (Exporter, BrokenExporter):
    try:
        cls()
    except TypeError as err:
        print(f"{cls.__name__}: {err}")
Output
['1,a\n2,b']
Exporter: Can't instantiate abstract class Exporter without an implementation for abstract method 'export'
BrokenExporter: Can't instantiate abstract class BrokenExporter without an implementation for abstract method 'export'

`export_all` is a template method: a concrete method in the abstract class that calls the abstract operation, so every subclass gets it for free. This combination, an abstract class that defines the skeleton of an algorithm and subclasses that fill in steps, is one of the most common legitimate uses of inheritance.

Composition over inheritance

Composition builds an object by giving it other objects as parts and delegating work to them. It models a has-a relationship: a car has an engine. The design advice composition over inheritance, popularized by the 1994 book on design patterns by Gamma, Helm, Johnson and Vlissides, reflects hard experience. Inheritance couples a subclass tightly to its superclass's implementation, so a change in the parent can break every child, a problem called the fragile base class. Deep hierarchies also tend to explode: if notifications can be sent by email or text and can be urgent or normal, inheritance needs a class for every combination, while composition needs one object for each choice.

Left side: an inheritance tree with Notifier at the top and four leaf classes, UrgentEmail, NormalEmail, UrgentSms and NormalSms. Right side: a single Notifier box holding two parts, a channel which is Email or Sms and a policy which is Urgent or Normal.
Combinations multiply under inheritance; with composition, each choice is a separate part
Composition: two channels and two policies give four behaviors from four small classes
class Email:
    def send(self, to, text): return f"email to {to}: {text}"
class Sms:
    def send(self, to, text): return f"text to {to}: {text[:20]}"

class Urgent:
    def format(self, text): return "URGENT " + text.upper()
class Normal:
    def format(self, text): return text

class Notifier:                       # composed of a channel and a policy
    def __init__(self, channel, policy):
        self.channel, self.policy = channel, policy
    def notify(self, to, text):
        return self.channel.send(to, self.policy.format(text))

for ch in (Email(), Sms()):
    for po in (Urgent(), Normal()):
        print(Notifier(ch, po).notify("ana", "server room temperature high"))
n = Notifier(Email(), Normal())
n.policy = Urgent()                   # policy swapped at run time
print(n.notify("ben", "backup failed"))
Output
email to ana: URGENT SERVER ROOM TEMPERATURE HIGH
email to ana: server room temperature high
text to ana: URGENT SERVER ROOM T
text to ana: server room temperat
email to ben: URGENT BACKUP FAILED

With composition, adding a third channel, say a chat message, adds one class and immediately works with both policies. Behavior can also change at run time by replacing a part, something a fixed class hierarchy cannot do. The cost is a little more wiring: the `Notifier` must be given its parts. Use inheritance for genuine is-a relationships where substitutability holds and the superclass was designed to be extended; use composition for everything else.

Inheritance versus composition
QuestionInheritanceComposition
Relationship modeledis-ahas-a, uses-a
CouplingTight: subclass depends on parent internalsLoose: depends only on the part's interface
When behavior is fixedAt class definition timeCan change at run time by swapping parts
Combinations of featuresOne class per combinationOne part per feature
Typical good useTemplate methods, framework base classesStrategies, policies, services, plug-ins

The SOLID principles

Robert C. Martin collected five design principles for object-oriented code under the acronym SOLID. They are guidelines, not laws, and each can be overdone, but together they describe code that is easy to extend and hard to break. Each principle is a response to a common way object-oriented systems become rigid.

The SOLID principles
PrincipleStatementProblem it prevents
Single responsibilityA class should have one reason to changeGod classes that mix unrelated concerns
Open–closedOpen for extension, closed for modificationEditing tested code for every new feature
Liskov substitutionSubtypes must be usable wherever their base type isSubclasses that break client assumptions
Interface segregationPrefer small, focused interfaces to large onesClasses forced to implement methods they do not need
Dependency inversionDepend on abstractions, not concrete classesHigh-level logic tied to low-level details such as a specific database

Worked problem

Applying single responsibility and dependency inversion

A ReportGenerator class reads sales from a PostgreSQL database, computes monthly totals, formats them as HTML and emails the result. Identify the problems and redesign it.

Show solution
  1. It has four reasons to change: database schema, business rules, presentation and delivery. That violates single responsibility.
  2. The computation is tied to PostgreSQL, so it cannot be tested without a database. That violates dependency inversion.
  3. Split it: a SalesSource interface with a fetch method, a pure function monthly_totals(sales), a Formatter interface with HtmlFormatter, and a Sender interface with EmailSender.
  4. ReportGenerator receives a source, a formatter and a sender in its constructor and only coordinates them; tests pass in an in-memory source and a fake sender.

Answer: Separate the concerns into small parts behind interfaces and inject them. The business rule becomes a pure function, which also shows how functional and object-oriented ideas combine.

Dependency inversion makes the report testable with fake parts
from typing import Protocol

class SalesSource(Protocol):
    def fetch(self) -> list[tuple[str, float]]: ...

class Sender(Protocol):
    def send(self, body: str) -> None: ...

def monthly_totals(sales):                       # pure business rule
    totals = {}
    for month, amount in sales:
        totals[month] = totals.get(month, 0) + amount
    return dict(sorted(totals.items()))

class ReportGenerator:                           # depends only on abstractions
    def __init__(self, source: SalesSource, sender: Sender):
        self.source, self.sender = source, sender
    def run(self):
        totals = monthly_totals(self.source.fetch())
        self.sender.send("; ".join(f"{m}: {t:.2f}" for m, t in totals.items()))

class FakeSource:                                # test doubles, no database needed
    def fetch(self): return [("2026-02", 120.0), ("2026-01", 80.5), ("2026-02", 30.0)]
class CaptureSender:
    def __init__(self): self.sent = []
    def send(self, body): self.sent.append(body)

cap = CaptureSender()
ReportGenerator(FakeSource(), cap).run()
print(cap.sent)
Output
['2026-01: 80.50; 2026-02: 150.00']

Inheritance and composition in Java

Java supports single inheritance of classes with `extends` and multiple inheritance of interfaces with `implements`. Since Java 17, a class or interface can be `sealed`, listing exactly which subclasses are permitted. Sealing closes a hierarchy so that the compiler knows every variant, which connects object-oriented hierarchies to the algebraic data types of lesson 3: a sealed interface with record implementations is Java's version of an enum with fields, and a switch over it can be checked for exhaustiveness.

A sealed interface with records: a closed hierarchy that Java's switch checks for completeness
public class Sealed {
    sealed interface Payment permits Cash, Card, Transfer {}
    record Cash(double amount) implements Payment {}
    record Card(double amount, String last4) implements Payment {}
    record Transfer(double amount, String bank) implements Payment {}

    static String describe(Payment p) {
        return switch (p) {                        // exhaustive: no default needed
            case Cash c -> String.format("cash %.2f", c.amount());
            case Card c -> String.format("card ending %s, %.2f", c.last4(), c.amount());
            case Transfer t -> String.format("transfer from %s, %.2f", t.bank(), t.amount());
        };
    }

    public static void main(String[] args) {
        Payment[] ps = { new Cash(12.5), new Card(80, "4421"), new Transfer(250, "Northern Credit Union") };
        for (Payment p : ps) System.out.println(describe(p));
    }
}
Output
cash 12.50
card ending 4421, 80.00
transfer from Northern Credit Union, 250.00

Common mistakes

  • MistakeUsing inheritance to reuse a few methods from an unrelated class.

    InsteadInherit only for genuine, substitutable is-a relationships; otherwise compose and delegate.

  • MistakeOverriding a method so that it requires more or promises less than the parent.

    InsteadRespect the Liskov substitution principle; client code written for the parent must still work.

  • MistakeBuilding deep hierarchies with a class for every combination of features.

    InsteadFactor independent choices into separate parts and compose them.

  • MistakeCreating huge interfaces that every implementer must fully support.

    InsteadSplit interfaces by client need so classes implement only what they use.

  • MistakeHard-coding concrete dependencies such as a specific database class inside business logic.

    InsteadDepend on an abstraction and inject the concrete implementation, which also makes testing easy.

Summary and key terms

  • Inheritance lets a subclass reuse and override its superclass and stand in for it through dynamic dispatch.
  • The Liskov substitution principle requires subclasses to honor every promise of their superclass.
  • Abstract base classes and interfaces state what subclasses must implement; template methods build on abstract steps.
  • Composition builds objects from parts and delegates to them, avoiding fragile base classes and combinatorial hierarchies.
  • Prefer composition unless there is a true, substitutable is-a relationship.
  • SOLID summarizes five principles: single responsibility, open–closed, Liskov substitution, interface segregation and dependency inversion.
  • Sealed hierarchies in Java close the set of subclasses, linking object-oriented design to algebraic data types.
inheritance
A mechanism by which a subclass acquires the attributes and methods of a superclass.
overriding
Providing a subclass implementation of a method already defined in the superclass.
Liskov substitution principle
Objects of a subtype must be usable wherever objects of the supertype are expected without breaking correctness.
abstract base class
A class that declares methods subclasses must implement and that cannot itself be instantiated.
template method
A method defining an algorithm's skeleton that calls steps implemented by subclasses.
composition
Building an object from other objects that it holds and delegates to.
delegation
Handing a request to another object that performs the work.
fragile base class
The problem that changes in a superclass can break subclasses that depend on its implementation.
dependency inversion
Making high-level code depend on abstractions rather than on concrete low-level classes.
sealed class
A class or interface that restricts which classes may extend or implement it.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write a subclass Manager of Employee that overrides pay() to add a bonus to the parent's pay.

    Show answer

    `class Manager(Employee):` with `def pay(self): return super().pay() + self.bonus`.

  2. Why can a read-only Square safely be treated as a read-only Rectangle when a mutable one cannot?

    Show answer

    Without setters there is no promise that setting one side leaves the other unchanged, so every property the client can rely on holds for squares too.

  3. What happens if you instantiate an ABC subclass that does not implement an abstract method?

    Show answer

    Python raises TypeError when the object is created.

  4. Give an example of a has-a relationship and an is-a relationship.

    Show answer

    A car has an engine (composition); a truck is a vehicle (inheritance).

  5. With 3 channels and 4 formatting policies, how many classes does each design need?

    Show answer

    Inheritance with one class per combination needs 12 leaf classes; composition needs 3 + 4 = 7 part classes plus one Notifier.

  6. Which SOLID principle does a class that both parses files and draws charts violate?

    Show answer

    Single responsibility: it has two unrelated reasons to change.

  7. How does dependency inversion help testing?

    Show answer

    Code depends on interfaces, so tests can pass in fake implementations instead of real databases or networks.

  8. State the open–closed principle in your own words.

    Show answer

    You should be able to add new behavior by adding code, such as a new class, without editing existing, tested code.

  9. What does sealed add to a Java interface?

    Show answer

    It restricts implementations to a listed set, so the compiler knows all variants and can check switches for exhaustiveness.

  10. Explain the fragile base class problem with an example.

    Show answer

    If a superclass changes add_all to call add for each element, a subclass that overrides add to count items may double-count, breaking without any change to the subclass.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz