跪拜 Guibai
← Back to the summary

Python OOP: Where Composition Beats Inheritance

Python Object-Oriented Programming: Classes, Inheritance, Polymorphism, and Composition

Author: vipxieliang Tags: Python

Table of Contents

1. Introduction

Object-oriented programming (OOP) is one of the most commonly used programming paradigms in Python. It encapsulates data and operations on that data together, making code model the real world more closely and making it easier to reuse and extend.

This text covers five core topics: classes and instances, attributes and methods, inheritance and method overriding, polymorphism, and the practice of composition over inheritance. After reading, you should be able to independently design a class system that is clearly structured and easy to maintain.

The diagram below shows the knowledge path of this text, helping you build an overall understanding:

flowchart TD
  A["Python Object-Oriented Programming"] --> B["Classes and Instances"]
  A --> C["Attributes and Methods"]
  A --> D["Inheritance and Method Overriding"]
  A --> E["Polymorphism"]
  A --> F["Composition over Inheritance"]
  B --> B1["class definition / instantiation / self"]
  C --> C1["instance attributes / class attributes / property"]
  D --> D1["overriding / super() / MRO"]
  E --> E1["duck typing / abstract base classes"]
  F --> F1["has-a delegation / dependency injection"]

2. Classes and Instances

2.1 Defining a Class

In Python, use the class keyword to define a class. Class names conventionally use CapWords (CamelCase).

class Dog:
    """A simple Dog class"""

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return f"{self.name} is barking"

2.2 Creating Instances

A class is just a template; instances are actual objects that take up memory and carry concrete data.

d1 = Dog("Wangcai", 3)
d2 = Dog("Xiaohei", 5)

print(d1.bark())  # Wangcai is barking
print(d2.bark())  # Xiaohei is barking
print(d1 is d2)   # False, two independent instances

Each instance has its own independent attribute space; d1.name and d2.name do not affect each other.

2.3 What self Is

self represents the current instance itself. When calling d1.bark(), Python actually executes Dog.bark(d1), and self is d1. It is a conventional name, but changing it is strongly discouraged.

3. Attributes and Methods

3.1 Instance Attributes and Class Attributes

class Dog:
    species = "Canidae"          # Class attribute, shared by all instances

    def __init__(self, name):
        self.name = name      # Instance attribute, independent per instance

d1 = Dog("Wangcai")
d2 = Dog("Xiaohei")

print(d1.species, d2.species)  # Canidae Canidae
Dog.species = "Canidae"
print(d1.species)              # Canidae, class attribute changed, visible to all instances

Note: Assigning a value to a same-named attribute through an instance creates a new attribute on the instance, thereby "shadowing" the class attribute.

d1.species = "custom"
print(d1.species)  # custom
print(d2.species)  # Canidae

3.2 Instance Methods, Class Methods, and Static Methods

class Circle:
    pi = 3.14159

    def __init__(self, radius):
        self.radius = radius

    def area(self):                 # Instance method: first parameter is self
        return self.pi * self.radius ** 2

    @classmethod
    def from_diameter(cls, diameter):  # Class method: first parameter is cls
        return cls(diameter / 2)

    @staticmethod
    def is_valid_radius(r):         # Static method: no self/cls
        return r > 0

3.3 Attribute Encapsulation and property

Python has no true private members. Underscores are a convention: _name means protected, and __name triggers name mangling.

When validation or computed attributes are needed, @property is more elegant:

class Person:
    def __init__(self, age):
        self._age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("Age cannot be negative")
        self._age = value

p = Person(20)
p.age = 25
print(p.age)   # 25
# p.age = -1   # raises ValueError

4. Inheritance and Method Overriding

4.1 Basic Inheritance

A subclass specifies its parent class in parentheses after the class name, inheriting the parent's attributes and methods.

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

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

class Cat(Animal):
    def speak(self):
        return f"{self.name}: Meow"

class Dog(Animal):
    def speak(self):
        return f"{self.name}: Woof"

The inheritance relationship above can be shown in the following diagram:

classDiagram
  class Animal {
    +name
    +speak()
  }
  class Cat {
    +speak()
  }
  class Dog {
    +speak()
  }
  class Puppy {
    +age
    +speak()
  }
  Animal <|-- Cat
  Animal <|-- Dog
  Dog <|-- Puppy

4.2 Method Overriding and super()

When a subclass defines a method with the same name as the parent, that is overriding. To reuse the parent's logic, call it with super().

class Puppy(Dog):
    def __init__(self, name, age):
        super().__init__(name)   # reuse parent initialization
        self.age = age

    def speak(self):
        base = super().speak()   # reuse parent behavior
        return f"{base} (in a cute voice)"

p = Puppy("Doudou", 1)
print(p.speak())  # Doudou: Woof (in a cute voice)

4.3 Multiple Inheritance and MRO

Python supports multiple inheritance. Method lookup order is determined by the MRO (Method Resolution Order), following the C3 linearization algorithm.

class A:
    def hello(self):
        return "A"

class B(A):
    def hello(self):
        return "B -> " + super().hello()

class C(A):
    def hello(self):
        return "C -> " + super().hello()

class D(B, C):
    pass

print(D().hello())      # B -> C -> A
print([c.__name__ for c in D.__mro__])  # ['D', 'B', 'C', 'A', 'object']

The method lookup order (MRO) for D is shown below. super() calls upward along this chain:

flowchart LR
  D["D"] --> B["B"]
  B --> C["C"]
  C --> A["A"]
  A --> O["object"]

While multiple inheritance is powerful, it easily adds complexity and should be used cautiously in real projects.

5. Polymorphism

Polymorphism means the same interface has different implementations on different types. Python is a dynamically typed language and naturally supports "duck typing": as long as an object has the needed method, it can be handled uniformly without requiring an inheritance relationship.

class Duck:
    def speak(self):
        return "Quack"

class Robot:
    def speak(self):
        return "Beep"

def make_sound(obj):
    print(obj.speak())

for item in [Duck(), Robot(), Cat("Mimi")]:
    make_sound(item)

Output:

Quack
Beep
Mimi: Meow

make_sound doesn't care about the object's concrete type, only whether it has a speak method. This is the decoupling capability polymorphism brings.

The calling process of polymorphism can be summarized as follows:

flowchart TD
  S["make_sound(obj)"] --> Q{"Does obj have a speak method?"}
  Q -->|Yes| R["Call obj.speak()"]
  Q -->|No| X["Raises AttributeError"]
  R --> D1["Duck: Quack"]
  R --> D2["Robot: Beep"]
  R --> D3["Cat: Meow"]

If you need to explicitly constrain the interface, you can use abstract base classes:

from abc import ABC, abstractmethod

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

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

    def area(self):
        return self.side ** 2

# Shape()  # TypeError: abstract classes cannot be instantiated
print(Square(3).area())  # 9

6. The Practice of Composition over Inheritance

6.1 Problems with Inheritance

Inheritance expresses an "is-a" relationship. When a subclass inherits merely to reuse code, common problems appear:

6.2 Using Composition to Express "has-a"

Composition means holding one object as an attribute of another and reusing functionality through delegation, expressing a "has-a" relationship.

class Engine:
    def start(self):
        return "Engine started"

class Wheel:
    def roll(self):
        return "Wheel rolling"

class Car:
    def __init__(self):
        self.engine = Engine()
        self.wheels = [Wheel() for _ in range(4)]

    def start(self):
        return self.engine.start()

    def run(self):
        return ", ".join(w.roll() for w in self.wheels)

car = Car()
print(car.start())  # Engine started
print(car.run())    # Wheel rolling, Wheel rolling, Wheel rolling, Wheel rolling

Car doesn't need to inherit Engine; it just holds an engine object. When you swap the engine implementation, Car's code barely changes.

The composition relationship is shown below; Car reuses functionality by holding Engine and Wheel:

classDiagram
  class Car {
    +Engine engine
    +Wheel[] wheels
    +start()
    +run()
  }
  class Engine {
    +start()
  }
  class Wheel {
    +roll()
  }
  Car *-- Engine
  Car *-- Wheel

6.3 A More Practical Example

Suppose you need to log messages. Rather than writing a subclass for every combination, you can split strategies by output destination.

class ConsoleWriter:
    def write(self, msg):
        print(f"[console] {msg}")

class FileWriter:
    def __init__(self, path):
        self.path = path

    def write(self, msg):
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(msg + "\n")

class Logger:
    def __init__(self, writer):
        self.writer = writer

    def log(self, msg):
        self.writer.write(msg)

Logger(ConsoleWriter()).log("hello")
Logger(FileWriter("app.log")).log("hello")

With inheritance, you'd need ConsoleLogger, FileLogger, and then the number of subclasses explodes when you add formatting, filtering, and other dimensions. Composition lets each dimension vary independently.

6.4 Selection Advice

Scenario Recommendation
The subclass truly is a kind of the parent (is-a) Inheritance
Just want to reuse a class's functionality (has-a) Composition
Need to switch behavior at runtime Composition + dependency injection
Need to define a unified interface Abstract base classes / protocols

In one sentence: Prefer composition, and use inheritance only when there is a clear is-a relationship and the parent class design is stable.

7. Conclusion

In real projects, figuring out whether objects are "is-a" or "has-a" first often leads to the correct structural choice.