Unit 3 · Classes, Modules & Files

Lesson · Unit 3 · 9 min read

Inheritance, and the case for using less of it than you think.

Inheritance lets you share behavior between related classes without copy-paste. The syntax is small, the temptation to overuse it is large. Here's the right syntax, the right times to reach for it, and the cleaner alternative most code should use instead.

Section · 01

The basic shape

To make a class inherit from another, put the parent in parens after the class name:

class Notifier:
    def __init__(self, recipient):
        self.recipient = recipient

    def send(self, message):
        raise NotImplementedError("Subclasses must implement send().")


class EmailNotifier(Notifier):
    def send(self, message):
        print(f"[email -> {self.recipient}] {message}")


class SMSNotifier(Notifier):
    def send(self, message):
        print(f"[sms -> {self.recipient}] {message}")


# Each subclass inherits __init__ from the parent automatically
e = EmailNotifier("ada@yorksims.com")
s = SMSNotifier("+15555550123")

e.send("Welcome.")    # [email -> ada@yorksims.com] Welcome.
s.send("Welcome.")    # [sms -> +15555550123] Welcome.

Three things happened there:

1. Notifier defined the SHAPE — every subclass has a recipient and a send().
2. Each subclass FILLED IN send() with its own implementation.
3. The shared __init__ wasn't repeated — subclasses inherited it for free.

Section · 02

Overriding methods

A subclass can replace any method it inherits. We did that with send() above. The rule: Python looks up methods on the most-specific class first, then walks up the chain.

class Notifier:
    def label(self):
        return "generic"

    def announce(self):
        print(f"Sending via {self.label()} channel.")


class EmailNotifier(Notifier):
    def label(self):
        return "email"


e = EmailNotifier()
e.announce()       # "Sending via email channel."

Notice announce() was defined on the parent, but when it calls self.label(), Python uses the subclass’s version. That’s polymorphism — same method name, different behavior depending on the actual object type.

Section · 03

super() — calling the parent on purpose

Sometimes you don’t want to replace a parent method — you want to extend it. super()gives you access to the parent’s version:

class Notifier:
    def __init__(self, recipient):
        self.recipient = recipient
        self.sent_count = 0

    def send(self, message):
        self.sent_count = self.sent_count + 1


class LoggingNotifier(Notifier):
    def __init__(self, recipient, log_file):
        super().__init__(recipient)        # run the parent's __init__ first
        self.log_file = log_file           # then add subclass-specific state

    def send(self, message):
        super().send(message)              # increment the counter
        with open(self.log_file, "a") as f:
            f.write(f"{self.recipient}: {message}\n")

The most common use of super() is inside an overriding __init__. If the parent has setup work (assigning attributes, opening connections, registering itself somewhere), you almost always want that to run before your subclass adds its own state on top.

Forget the super().__init__() call and you get an instance with half its attributes missing — a classic bug.

Section · 04

When NOT to use inheritance

Inheritance is a strong relationship. A subclass “is a” instance of its parent — an EmailNotifier really is a Notifier. If that “is a” statement doesn’t feel true, inheritance is the wrong tool.

Two common red flags:

1. You're using inheritance just to reuse code.

# WRONG — Cart doesn't "is a" Database. They share NO conceptual identity.
class Cart(Database):
    def add_item(self, item):
        self.execute("INSERT ...")    # using DB methods via inheritance

When the only reason to inherit is “the parent has methods I want,” use composition instead — hold the helper as an attribute:

# RIGHT — composition. Cart has a db, it isn't one.
class Cart:
    def __init__(self, db):
        self.db = db
        self.items = []

    def add_item(self, item):
        self.items.append(item)
        self.db.execute("INSERT ...")

2. Your hierarchy is more than 2 levels deep.

Notifier <- EmailNotifier <- HtmlEmailNotifier <- TrackedHtmlEmailNotifier is a maintenance nightmare. Every change to Notifier ripples through four layers. By the third subclass, find another model: composition, mixins, or splitting into smaller independent classes.

A practical rule: prefer composition for “has a” relationships. Use inheritance only when there’s a real “is a” relationship AND multiple classes genuinely share the same shape and behavior. Most real codebases get along fine with one or two carefully-designed base classes and a lot of composition.

Section · 05

isinstance — checking type at runtime

You can ask whether an object is an instance of a class (or any of its parents) with isinstance:

e = EmailNotifier("ada@yorksims.com")

isinstance(e, EmailNotifier)    # True
isinstance(e, Notifier)         # True — inherited
isinstance(e, SMSNotifier)      # False

Use this for sanity checks at module boundaries, not as a regular control-flow tool. If you’re writing if isinstance(x, Foo): ... elif isinstance(x, Bar): ... inside a function, that’s usually a sign you should be calling a method on x and letting polymorphism do the dispatch — the whole point of inheritance.

Curriculum source

Lesson content is original to YorkSims. Topic structure aligns with Python for Everybody by Dr. Charles R. Severance (py4e.com), licensed under Creative Commons Attribution 3.0 Unported.