Python OOP: Where Composition Beats Inheritance
Python Object-Oriented Programming: Classes, Inheritance, Polymorphism, and Composition
Author: vipxieliang Tags: Python
Table of Contents
- 1. Introduction
- 2. Classes and Instances
- 3. Attributes and Methods
- 4. Inheritance and Method Overriding
- 5. Polymorphism
- 6. The Practice of Composition over Inheritance
- 7. Conclusion
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
- Instance attributes: defined in
__init__viaself.xxx, each instance gets its own copy. - Class attributes: defined directly in the class body, shared by all instances.
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
- Instance methods operate on specific object data.
- Class methods are often used to provide alternative constructors.
- Static methods are ordinary functions placed in the class namespace.
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:
- Inheritance hierarchies become too deep, raising comprehension costs.
- Changes to the parent class ripple through all subclasses (the fragile base class problem).
- Multiple inheritance makes the MRO complex and behavior hard to predict.
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
- Classes define templates; instances carry concrete data;
selfpoints to the current instance. - Instance attributes are independent; class attributes are shared;
propertymakes attribute access more controllable. - Inheritance expresses is-a relationships; overriding with
super()reuses parent logic. - Polymorphism lets callers depend only on interfaces; Python's duck typing makes this more natural.
- Composition reuses functionality through delegation and is usually more flexible and maintainable than inheritance.
In real projects, figuring out whether objects are "is-a" or "has-a" first often leads to the correct structural choice.