Basic Inheritance in Python: Complete Object-Oriented Programming Subclass and Superclass Guide

Basic inheritance in python

Basic inheritance in python

Inheritance was the first object-oriented concept that actually clicked for me, mostly because Python’s syntax for it is so lightweight that I could experiment with it in the interpreter for ten minutes and understand the shape of it. But “lightweight syntax” hid some real depth — method resolution, super(), overriding versus extending, and how attribute lookup climbs the class hierarchy. This guide covers basic (single) inheritance from the ground up, with enough internal detail that you understand not just how to write it, but why it behaves the way it does.

The Core Idea

Inheritance lets one class (the subclass or child class) acquire the attributes and methods of another class (the superclass, base class, or parent class), so you can reuse and specialize behavior instead of rewriting it.

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

    def describe(self):
        return f"{self.name} says {self.speak()}"


class Dog(Animal):
    def speak(self):
        return "Woof"


d = Dog("Rex")
print(d.describe())  # Rex says Woof

Dog didn’t redefine __init__ or describe — it inherited both from Animal, and only overrode speak. When describe calls self.speak(), it uses Dog‘s version, not Animal‘s, because method lookup happens on the actual runtime type of self, not on where describe is defined. This is polymorphism in action, and it’s the entire point of inheritance: shared structure, specialized behavior.

super(): Extending Rather Than Replacing

Overriding a method entirely (like speak above) is common, but often you want to extend a parent’s behavior rather than fully replace it. super() gives you access to the parent class’s version of a method from within the child:

class Animal:
    def __init__(self, name):
        self.name = name
        self.energy = 100

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

d = Dog("Rex", "Labrador")
print(d.name, d.energy, d.breed)  # Rex 100 Labrador

Without super().__init__(name), Dog would need to duplicate self.name = name and self.energy = 100 itself, and any future change to Animal.__init__ wouldn’t automatically propagate to Dog. Calling super() keeps the initialization logic in one place.

This pattern extends to any method, not just __init__:

class Animal:
    def describe(self):
        return f"This is an animal named {self.name}"

class Dog(Animal):
    def describe(self):
        base = super().describe()
        return base + ", and it's a very good dog"

print(Dog().describe() if False else "")  # illustrative

Attribute Lookup Order

When you access instance.attribute, Python searches:

  1. The instance’s own __dict__.
  2. The class’s __dict__.
  3. Each base class’s __dict__, in MRO order, up the chain.
  4. object, the ultimate base of every class in Python 3.
class Base:
    greeting = "Hello from Base"

class Child(Base):
    pass

c = Child()
print(c.greeting)  # Hello from Base (found on Base, not Child or c)

c.greeting = "Hi from instance"
print(c.greeting)         # Hi from instance
print(Child.greeting)     # Hello from Base (class attribute untouched)

Setting c.greeting creates a new entry in the instance’s own __dict__; it doesn’t touch Base.greeting at all. This is why mutating a class attribute through self.x = ... shadows rather than modifies the inherited value — a distinction that trips people up constantly, especially with mutable class attributes (lists, dicts).

Checking Relationships: isinstance and issubclass

print(isinstance(d, Dog))     # True
print(isinstance(d, Animal))  # True — Dog IS-A Animal
print(issubclass(Dog, Animal))# True
print(Dog.__bases__)          # (<class 'Animal'>,)
print(Dog.__mro__)            # (Dog, Animal, object)

isinstance checks against the entire inheritance chain, not just the exact type — a Dog instance is also, correctly, an Animal instance. This matters for writing functions that should accept any subclass:

def feed(animal: Animal):
    print(f"Feeding {animal.name}")

feed(d)  # works fine, Dog is-an Animal

Overriding vs Extending vs Not Touching

Three distinct things a subclass can do with an inherited method:

class Shape:
    def area(self):
        raise NotImplementedError

    def describe(self):
        return f"Area: {self.area()}"

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):          # override — fully replaces parent's behavior
        return self.side ** 2

class LoggedSquare(Square):
    def area(self):          # extend — calls parent, adds behavior
        result = super().area()
        print(f"Computed area: {result}")
        return result

class TinySquare(Square):
    pass                     # inherit unchanged — uses Square.area directly

Shape.area deliberately raises NotImplementedError — a common pattern for defining an interface that concrete subclasses are required to implement. This is Python’s lightweight stand-in for an abstract method (the more formal version uses abc.ABC and @abstractmethod).

Enforcing Implementation with abc

If you want Python to actually refuse to instantiate a class that hasn’t implemented required methods, use the abc module:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14159 * self.radius ** 2

s = Shape()
# TypeError: Can't instantiate abstract class Shape with abstract method area

This turns a documentation convention (raise NotImplementedError) into an enforced contract, checked at instantiation time.

Class Attributes vs Instance Attributes in Inheritance

class Vehicle:
    wheels = 4  # class attribute, shared unless overridden

class Motorcycle(Vehicle):
    wheels = 2  # overrides the inherited class attribute

class Car(Vehicle):
    pass

print(Motorcycle().wheels)  # 2
print(Car().wheels)         # 4 (inherited from Vehicle)

Motorcycle doesn’t modify Vehicle.wheels — it defines its own wheels attribute directly on Motorcycle, which takes priority in the MRO search order for any Motorcycle instance.

Real-World Use Case: A Small Plugin System

Inheritance shines when you have a family of related behaviors that share structure but differ in specifics — a very common real pattern is a base class defining a template, and subclasses filling in the specifics:

class DataExporter:
    def export(self, data):
        formatted = self.format(data)
        self.write(formatted)

    def format(self, data):
        raise NotImplementedError

    def write(self, content):
        print(content)  # default: print to console


class CSVExporter(DataExporter):
    def format(self, data):
        return "\n".join(",".join(row) for row in data)


class JSONExporter(DataExporter):
    def format(self, data):
        import json
        return json.dumps(data)


CSVExporter().export([["a", "b"], ["1", "2"]])
JSONExporter().export([["a", "b"], ["1", "2"]])

This is the template method patternexport defines the overall algorithm shape, and subclasses customize only the format step. It’s one of the most practical, everyday uses of basic inheritance in real projects.

Common Mistakes

FAQs

Q: Does every Python class inherit from something, even if I don’t write it explicitly? Yes — every class implicitly inherits from object if no other base is specified, which is where common methods like __repr__, __eq__, and __hash__ ultimately come from by default.

Q: What’s the difference between overriding a method and shadowing an attribute? They’re the same underlying mechanism — defining a name in the subclass (or instance) that takes priority over the inherited one during lookup — whether that name is a method or a plain attribute.

Q: When should I prefer composition over inheritance? When the relationship isn’t truly “is-a.” If a Car “has-a” Engine, composition (self.engine = Engine()) is more honest and flexible than trying to make Car inherit from Engine.

Summary

Basic inheritance in Python lets a subclass reuse a superclass’s attributes and methods while overriding or extending specific pieces of behavior, all governed by a predictable attribute-lookup order. super() is the tool for extending rather than replacing parent behavior, isinstance/issubclass let you check relationships, and the abc module lets you turn “should implement this” into an enforced rule. Understood well, inheritance is a clean way to express shared structure — understood poorly, it becomes a tangle of deep hierarchies that’s harder to reason about than the duplication it was meant to avoid.

References

Exit mobile version