JEPA4Japan · tutorials

Composition, Inheritance, and Polymorphism

1,203 words 6 min read #Python

Choose composition first and use shared interfaces to keep object systems flexible.

Course progress Course outline 24 of 24 lessons available

Build a robot from replaceable helpers

Imagine a study robot holding a score calculator. The robot has a calculator; it is not a kind of calculator. That relationship is composition.

  1. Main object a study plan
  2. Helper a score rule
  3. Has-a plug in another helper
Composition joins objects while keeping their jobs separate.

Receive the helper in __init__() and delegate work to it:

class PlainHeading:
    def make(self, text):
        return text


class Report:
    def __init__(self, heading_maker):
        self.heading_maker = heading_maker

    def title(self, text):
        return self.heading_maker.make(text)


print(Report(PlainHeading()).title("Study plan"))
Study plan

The dependency is visible and replaceable. Passing a collaborator from outside is a simple form of dependency injection. Tests can inject a tiny fake; production code can inject a real service.

Swap helpers that keep one promise

Another helper may do the same job differently:

class PlainHeading:
    def make(self, text):
        return text


class LoudHeading:
    def make(self, text):
        return text.upper() + "!"


for helper in [PlainHeading(), LoudHeading()]:
    print(helper.make("Study plan"))
Study plan
STUDY PLAN!

This is polymorphism: one call works with different objects because they offer a compatible capability. No shared base class is required.

  1. Contract make(text)
  2. Accept one string
  3. Return one heading string
  4. Same meaning no surprise side effects
A matching method name is useful only when its behavior keeps the same promise.

A helper named make() that returns None, deletes data, or prints instead of returning text breaks the contract. Test every intended implementation with the same small examples.

Inherit only for a true is-a relationship

Inheritance says that a subclass really is a specialized base object and can safely stand in for it. A reading activity can be a kind of activity:

class Activity:
    def __init__(self, title, minutes):
        if minutes < 0:
            raise ValueError("minutes must not be negative")
        self.title = title
        self.minutes = minutes

    def summary(self):
        return f"{self.title}: {self.minutes} min"


class ReadingActivity(Activity):
    def __init__(self, title, minutes, pages):
        super().__init__(title, minutes)
        self.pages = pages

    def summary(self):
        return f"{self.title}: {self.pages} pages"


item = ReadingActivity("Python docs", 25, 12)
print(item.summary())
print([cls.__name__ for cls in ReadingActivity.mro()])
Python docs: 12 pages
['ReadingActivity', 'Activity', 'object']

The subclass overrides summary(). super() continues lookup after the current class according to the method resolution order, or MRO. It is more accurate than saying “call my parent,” especially with multiple inheritance.

  1. ReadingActivity look here first
  2. Activity then the base
  3. object the final base
The MRO is Python's ordered path for method lookup.

Use the substitution test: code promised an Activity; can it receive ReadingActivity without new type checks or a weaker meaning? If not, the “is-a” claim is probably false.

Prefer small capabilities to tall family trees

Inheritance is not a coupon for free code reuse. Warning signs include a subclass that overrides almost everything, repeated isinstance() branches, or names such as LoudJsonScoredReport multiplying for every option combination.

Formatting, storage, and scoring are independent helpers. Compose them so each can change alone. A capability-based function simply asks for the operation it needs:

def show(result):
    print(result.render())

Any object with a compatible render() can participate. Keep that contract small and keep inheritance chains shallow. Multiple inheritance has a deterministic C3 MRO, but it also adds cooperation rules; reach for it only when the model truly needs it.

Tiny project: Composable Study Scoring

Create study_scoring.py. This complete program uses only built-in Python features. Score rules and reporters are injected helpers; the plan never branches on their exact classes.

class FixedScore:
    def __init__(self, points):
        if isinstance(points, bool) or not isinstance(points, int) or points < 0:
            raise ValueError("points must be a non-negative integer")
        self.points = points

    def calculate(self, minutes):
        return self.points


class MinuteScore:
    def __init__(self, minutes_per_point):
        if (
            isinstance(minutes_per_point, bool)
            or not isinstance(minutes_per_point, int)
            or minutes_per_point <= 0
        ):
            raise ValueError("minutes_per_point must be a positive integer")
        self.minutes_per_point = minutes_per_point

    def calculate(self, minutes):
        return minutes // self.minutes_per_point


class TextReport:
    def render(self, rows):
        lines = [
            f"{title}: {minutes} min -> {points} pts"
            for title, minutes, points in rows
        ]
        lines.append(f"Total: {sum(points for _, _, points in rows)} pts")
        return "\n".join(lines)


class CompactReport:
    def render(self, rows):
        total = sum(points for _, _, points in rows)
        return f"{len(rows)} activities / {total} pts"


class StudyPlan:
    def __init__(self, score_rule, reporter):
        self.score_rule = score_rule
        self.reporter = reporter

    def report(self, sessions):
        rows = [
            (title, minutes, self.score_rule.calculate(minutes))
            for title, minutes in sessions
        ]
        return self.reporter.render(rows)


def verify_rule(rule):
    result = rule.calculate(20)
    assert not isinstance(result, bool)
    assert isinstance(result, int) and result >= 0


def verify_reporter(reporter):
    assert isinstance(reporter.render([("Test", 10, 2)]), str)


sessions = [("Python", 45), ("Reading", 30)]

minute_plan = StudyPlan(MinuteScore(5), TextReport())
fixed_plan = StudyPlan(FixedScore(3), CompactReport())

print(minute_plan.report(sessions))
print(fixed_plan.report(sessions))

for rule in [MinuteScore(5), FixedScore(3)]:
    verify_rule(rule)
for reporter in [TextReport(), CompactReport()]:
    verify_reporter(reporter)
print("Contracts passed.")

Run python3 study_scoring.py and check:

Python: 45 min -> 9 pts
Reading: 30 min -> 6 pts
Total: 15 pts
2 activities / 6 pts
Contracts passed.

Boundary reminder: MinuteScore(0) is rejected. A new collaborator must satisfy the whole behavior contract, not merely own a correctly named method.

Three tiny missions

  1. Swap a helper. Add BonusScore with calculate(minutes) without editing StudyPlan.
  2. Test is-a. Create VideoActivity(Activity) and state what every Activity caller may still rely on.
  3. Read the path. Add one more subclass and print its mro() before predicting which override runs.

You are ready for Chapter 15 when…

  • you can tell has-a composition from is-a inheritance;
  • you can inject and replace a collaborator;
  • you can state and test a behavioral contract;
  • you can use capability-based polymorphism without a shared base class;
  • you reserve inheritance for substitutable subtypes;
  • you can explain overriding, super(), and a simple MRO;
  • you avoid deep hierarchies and exact-type switch chains;
  • you can run both scoring plans and see Contracts passed.

Next, you will make compact data records with dataclasses and replace loose choice strings with enums.