Chapter 14: Inheritance and Composition
Section 1
Inheritance is a way for a new class to reuse the attributes and methods of an existing class, by declaring that the new class is a kind of the existing one. If Book is a class with a title and an author, then Ebook might inherit from Book, gaining its title and author handling automatically, while adding a file size and a download method of its own. That reuse saves code, because the child class does not need to repeat the parent's implementation, and it creates a relationship that Python understands: an ebook is a book, so anywhere a book is expected, an ebook can be used, because it provides everything a book provides, plus more. That substitutability, where a child can stand in for a parent, is the core idea behind inheritance, and it is what makes inheritance more than mere code reuse.
Inheritance also creates a hierarchy, where classes are arranged from general to specific, with each level adding detail. A hierarchy might start with a general Publication, then a Book that adds an author, then an Ebook that adds a file size, each level refining the previous one. That hierarchy can be a useful way to organise a domain, because it reflects how general concepts specialise into specific ones, and it allows code written for a general level to work with any specific level below it, which is a powerful form of reuse. Hierarchies can also become a liability, however, when they grow deep or tangled, because a change to a general class can affect many specific classes in ways that are hard to predict, and because a specific class may inherit behaviour it does not need, simply because it sits below a general class in the hierarchy. That liability is why inheritance should be used thoughtfully, with hierarchies kept shallow and meaningful, rather than deep and speculative.
For beginners, the practical lesson is to use inheritance when the kind-of relationship is clear and stable, such as an ebook being a kind of book, or a savings account being a kind of bank account, and when the child genuinely needs most of the parent's behaviour, not just a small part of it. If the relationship is unclear, or if the child needs only a little of the parent, composition, covered later in this chapter, is often a better choice, because it allows reuse without the commitment of a hierarchy. Learning to distinguish a genuine kind-of relationship from a mere similarity, or from a has-a relationship, is a key design skill, and this chapter will help you develop it, through examples, guidelines, and warnings about common pitfalls.
class Book:
def __init__(self, title):
self.title = title
class Ebook(Book):
def __init__(self, title, size):
super().__init__(title)
self.size = size
print(Ebook("Dune", 1200).title)
Check: What does it mean for a child class to be substitutable for its parent class?
Section 2
super() also works for other methods, not just __init__, allowing a child to extend a parent method, rather than replacing it entirely. An ebook's describe method, for example, might call super().describe() to get the parent's description, and then add its file size to it, reusing the parent's formatting while adding its own detail. That extension pattern, call the parent, then add to the result, is common and clear, because it shows that the child's behaviour builds on the parent's, rather than duplicating it, and it ensures that a change to the parent's description, such as a new format, automatically appears in the child's description, without needing to update the child separately. That automatic propagation of parent changes is a benefit of inheritance, provided the parent's changes are compatible with the child's expectations, which is why parent classes should be designed with extension in mind, documenting which methods are meant to be overridden and which are meant to be called as-is.
Understanding super() also requires understanding the method resolution order, or MRO, which is the order in which Python searches classes in a hierarchy for a method, when a method is called on an object. For a simple hierarchy, with one parent, the order is intuitive: the child's class first, then the parent, then the parent's parent, and so on, until the method is found or the search fails. For multiple inheritance, where a class inherits from several parents, the order is more complex, and Python uses a specific algorithm, called C3 linearisation, to produce a consistent order that respects the hierarchy, which will be discussed later in this chapter. For now, the key point is that super() does not simply mean the immediate parent; it means the next class in the MRO, which, in a simple hierarchy, is the parent, but in a complex hierarchy, may be a different class, and understanding that distinction is important for advanced inheritance, though it rarely matters for simple, single-parent hierarchies.
A common mistake with super() is to call the parent method directly, by name, such as Book.__init__(self, title), rather than through super(), which works for a simple hierarchy, but breaks in more complex hierarchies, where the next class in the MRO is not the named parent, and it also makes refactoring harder, because renaming the parent requires changing every direct call. Using super() consistently, therefore, is a best practice, even in simple cases, because it prepares the code for future complexity, and because it expresses the intent, call the next method in the hierarchy, rather than call this specific parent, which is more accurate and more maintainable. That habit, like other best practices in this book, costs little in simple cases and saves a great deal in complex ones.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return super().speak() + " Woof"
print(Dog("Bingo").speak())
Check: Why should a child class's __init__ call super().__init__() instead of skipping the parent's initialisation?
Section 3
Overriding should respect the parent method's contract, meaning that the child's method should accept the same arguments, return the same kind of value, and maintain the same invariants, as the parent's method, even if its implementation differs, because callers who use a child through a parent reference, expecting parent behaviour, should not be surprised by a child that behaves incompatibly. That principle, sometimes called the Liskov substitution principle, states that a child should be substitutable for its parent, not just in the sense of providing the same methods, but in the sense of behaving in a way that is consistent with the parent's promises, so that code written for the parent works correctly with any child, without needing to know which child it is dealing with. Violating that principle, by overriding a method to do something completely different, or to raise an exception where the parent would succeed, or to require different arguments, breaks substitutability, and it causes bugs in code that treats children as parents, which is a common pattern in polymorphic programs.
A related guideline is to override only when the child's behaviour genuinely differs, and to call the parent method when the child's behaviour extends, rather than replaces, the parent's, as discussed for super(). Overriding a method merely to duplicate the parent's implementation, with a small change, is a sign that the method should be refactored, perhaps by extracting the varying part into a separate method that children override, while the common part remains in the parent, a pattern that keeps the parent's logic in one place, while allowing children to vary only the part that needs to vary. That refactoring, from duplication to extension, is a common improvement in hierarchies, and it is driven by noticing that several children override the same method in similar ways, with only a small difference, which suggests that the difference should be isolated, rather than repeated.
Finally, overriding should be documented, by noting in the child's method docstring that it overrides a parent method, and by explaining how its behaviour differs, so that readers who know the parent's method can understand the child's specialisation without comparing implementations line by line. That documentation, like other documentation, helps maintainers, who need to know whether a change to the parent method will affect children, and whether a change to a child method will break substitutability, which are questions that arise whenever a hierarchy evolves. A hierarchy whose overriding is documented is easier to maintain than one where overriding is implicit and must be discovered by searching for method names, because documentation makes the hierarchy's structure visible, rather than hidden in code.
class Shape:
def area(self):
return 0
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
print(Square(4).area())
Check: What does the Liskov substitution principle require of a child class that overrides a parent method?
Section 4
Composition is often preferred by experienced programmers, by default, with inheritance used only when the is-a relationship is clear, stable, and genuinely requires substitutability, because composition is more modular: each part can be developed, tested, and changed independently, and the containing class can use different parts in different situations, without needing a new subclass for each combination. For example, a car that composes an engine can use a petrol engine, a diesel engine, or an electric motor, by containing a different engine object, while a car that inherits from an engine would need a separate subclass for each engine type, and would be locked into that engine type by its place in the hierarchy, which is less flexible and harder to maintain as new engine types are added. That flexibility, through composition, is why many design guidelines recommend favouring composition over inheritance, not as a ban on inheritance, but as a default that should be departed from only when inheritance's specific benefits, substitutability and shared interface, are genuinely needed.
Composition also makes testing easier, because a containing class can be tested with mock or simple contained objects, that simulate the real ones, without needing to create the full hierarchy, whereas a child class in an inheritance hierarchy cannot easily be tested without its parent, because its behaviour depends on the parent's implementation, which must be understood and set up for the test. That testability, like modularity, is a practical benefit that matters in real programs, where tests must be fast, focused, and independent, and where a class that can be tested in isolation, with simple collaborators, is easier to verify than one that is entangled in a hierarchy. Composition, therefore, is not just a design preference; it is a testing strategy, that keeps classes independent and testable.
For beginners, the practical lesson is to ask, for each relationship between classes, whether it is is-a or has-a, and to choose inheritance for is-a, composition for has-a, and to be suspicious of a relationship that is neither, or that is both, because such a relationship may indicate a modelling error, where two concepts have been conflated, or where a class is trying to do too much. That question, is-a or has-a, asked consistently, will guide you to the right choice in most cases, and it will prevent the most common inheritance mistakes, such as inheriting to reuse code when composition would be clearer, or composing when inheritance would provide a needed substitutability, because the question forces you to think about the relationship's meaning, not just about code reuse, which is a secondary benefit, not the primary criterion for choosing between inheritance and composition.
class Engine:
def start(self):
return "engine started"
class Car:
def __init__(self):
self.engine = Engine()
def start(self):
return self.engine.start()
print(Car().start())
Check: How do you decide whether a relationship between two classes is is-a (inheritance) or has-a (composition)?
Section 5
Polymorphism through inheritance is explicit: children inherit a parent's interface, and they override methods to provide their own implementations, with the guarantee, if the Liskov principle is respected, that they will behave consistently with the parent, so that code written for the parent will work with any child. Polymorphism through duck typing is implicit: classes that happen to provide the same methods can be used interchangeably, without a common parent, because Python checks for methods at call time, rather than requiring a declared relationship, which is more flexible, because it allows unrelated classes to be used together, but also riskier, because there is no guarantee that a class providing a method with the right name will behave consistently with other classes that provide the same method, unless that consistency is documented and tested. Both forms are used in Python, and understanding their trade-offs, explicit guarantee versus flexible coincidence, helps you choose the right form for a given situation, and helps you write code that is robust to new classes, whether they arrive through inheritance or through duck typing.
A common use of polymorphism is in collections of heterogeneous objects, such as a list containing dogs, cats, and birds, all of which provide speak, where a loop can call speak on each, without checking its type, and each will respond appropriately, which is far cleaner than a loop that checks types and calls different methods for each type, with a growing chain of conditions that must be updated for every new type added. That loop, written polymorphically, is open to extension, because adding a new animal type requires only defining its class, with its speak method, and adding it to the list, without changing the loop, which is a practical benefit that grows as the number of types grows, and which is a reason why polymorphism is considered a core principle of object-oriented design, not just a theoretical idea.
For beginners, the lesson is to write code that relies on interfaces, not on types, where possible, by calling methods that objects provide, rather than checking their class and branching, because that code will be more flexible, more concise, and easier to extend, and it will work with both inherited and duck-typed polymorphism, without needing to know which form is being used. That interface-based style, sometimes called programming to an interface, is a habit that develops with practice, and it is reinforced by designing classes with clear, consistent interfaces, as discussed in the previous chapter, because a clear interface is what makes polymorphic code possible, by giving different classes a common set of methods that callers can rely on, regardless of the specific class involved.
class Dog:
def speak(self):
return "Woof"
class Cat:
def speak(self):
return "Meow"
for animal in [Dog(), Cat()]:
print(animal.speak())
Check: Why is code that calls a common method on different objects, without checking their types, easier to extend than code that branches on type?
Section 6
Abstract base classes are useful when a hierarchy has a common interface, but no meaningful default implementation for some methods, such as a Shape class with an abstract area method, where each specific shape, such as a square or a circle, must compute its area differently, and where a generic Shape has no area to compute, because it is not a specific shape, but a concept that groups specific shapes. Defining area as abstract, therefore, expresses that every shape must have an area, without pretending that a generic shape has one, and it forces each subclass author to think about area for their specific shape, rather than inheriting a meaningless default, such as returning zero, which might be mistaken for a correct area, or might hide a forgotten implementation. That forcing, through abstraction, is a form of design by contract, where the base class specifies what subclasses must do, without specifying how, leaving the how to each subclass, which is appropriate when the how varies by subclass, but the what is common to all.
Abstract base classes should be used sparingly, for interfaces that are genuinely shared and that have required methods, rather than for every parent class, because they add complexity, by requiring subclasses to implement methods, and because they can make a hierarchy rigid, where a subclass that wants to reuse most of a parent's behaviour, but not all of it, is forced to implement a method it does not need, or to provide a stub that raises an error, which is awkward. A parent class with concrete methods, that subclasses can override when needed, is more flexible, and it is the right choice when a default implementation is meaningful, or when subclasses may not need to override every method. Abstract methods are for the case where a default would be meaningless or misleading, and where forcing implementation is better than allowing a meaningless default to be inherited silently.
For beginners, abstract base classes are a concept to recognise, rather than a tool to use frequently, because most learning hierarchies can be expressed with concrete parent classes, where methods have defaults or where subclasses override as needed, without needing the enforcement of abstraction. Knowing that abstraction exists, and recognising it in libraries, where interfaces are often defined as abstract base classes, will help you read those libraries, by showing you which methods you must implement when you subclass, and which methods you may inherit, which is information that is essential for using a library correctly, even if you do not define abstract classes yourself in your own programs yet. As your programs grow, and your hierarchies become more formal, abstraction will become a natural tool, for defining interfaces that must be implemented, rather than merely suggested.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
print(Square(3).area())
Check: Why would you mark a method as abstract in a base class, rather than giving it a default implementation?
Section 7
Multiple inheritance should therefore be used sparingly, and only when the parents are truly independent, with little overlap in their methods, and when the child's relationship to each parent is a genuine is-a relationship, not merely a desire to reuse code from two places, because code reuse through multiple inheritance creates a tangled hierarchy that is hard to understand, hard to test, and hard to maintain, as changes to either parent can affect the child in ways that are not obvious from the child's definition alone. A common alternative, for combining independent aspects, is composition, where a class contains objects for each aspect, rather than inheriting from both, which is clearer, because the contained objects are explicit attributes, with their own interfaces, rather than implicit parents whose methods are merged into the child's namespace, where they can conflict or be shadowed without warning. Composition, in the multiple-inheritance case, is often the clearer choice, because it makes the combination explicit, rather than hiding it in a hierarchy.
When multiple inheritance is used, it is often in the form of mixins, which are small classes that provide a specific, focused set of methods, such as a Loggable mixin that adds logging, or a Serializable mixin that adds conversion to a dictionary, and that are designed to be combined with other classes, rather than to stand alone as a complete entity. Mixins are typically placed after the main parent in the inheritance list, and they are designed to be cooperative, using super() to chain their methods with other mixins and with the main parent, so that several mixins can be combined in a predictable way, each adding its behaviour without conflicting with the others. That cooperative design, through super(), is what makes mixins workable, despite the complexity of multiple inheritance, because it ensures that each mixin's method is called in turn, in MRO order, rather than one mixin shadowing another entirely, which would lose behaviour.
For beginners, the lesson is to avoid multiple inheritance in your own programs, except for simple, well-understood cases, such as combining a main class with a small, focused mixin, and to prefer composition for combining independent aspects, because composition is easier to understand and to debug, and because multiple inheritance's complexity, through MRO and method conflicts, is a common source of bugs that are hard to diagnose, especially for programmers who are still learning single inheritance. Recognising multiple inheritance in libraries, where it is sometimes used for mixins, will help you read those libraries, but you do not need to use it yourself to write good programs, and many good programs use only single inheritance, or no inheritance at all, relying on composition for reuse, which is a perfectly valid and often superior design.
class Loggable:
def log(self, message):
print("LOG:", message)
class Book(Loggable):
def __init__(self, title):
self.title = title
b = Book("Dune")
b.log("created")
Check: Why is multiple inheritance riskier than single inheritance, and what is a mixin?
Section 8
You can inspect a class's MRO by accessing its __mro__ attribute, or by calling its mro() method, which returns a tuple or a list of classes in search order, and that inspection is a useful debugging tool, when a method call does not behave as expected, because it shows exactly which classes will be searched, in which order, and therefore which method definition will be found first. A common debugging step, when a method from an unexpected parent is called, is to print the MRO and to check whether the parent order is as intended, or whether a mixin or a parent has been listed in the wrong order, causing its method to shadow another's, which is a mistake that is easy to make and hard to spot without inspecting the MRO, because the class definition alone does not make the search order obvious, especially in a deep hierarchy with several parents at each level.
The MRO also explains how super() works, because super() does not call the immediate parent directly, but calls the next method in the MRO, after the current class, which, in a cooperative multiple inheritance hierarchy, allows each class in the chain to contribute its behaviour, in turn, rather than only the immediate parent contributing, and the chain ending there. That cooperative chaining, through super(), is what makes mixins work, as discussed earlier, because each mixin's method calls super() to invoke the next mixin's method, and so on, until the main parent's method is reached, with each layer adding its behaviour, in a predictable order, determined by the MRO. Without understanding the MRO, super() in a multiple inheritance hierarchy can seem mysterious, because it does not always call the class you think of as the parent, but with the MRO in mind, its behaviour becomes predictable, as the next class in a well-defined order.
For beginners, the practical lesson is to keep hierarchies simple, so that the MRO is intuitive, to inspect the MRO when behaviour is surprising, and to use super() consistently, so that cooperative chaining works, even in simple hierarchies, where it may seem unnecessary, because that consistency prepares the code for future complexity, and because it expresses the intent to call the next method in the hierarchy, rather than a specific parent, which is more accurate and more maintainable, as discussed earlier. A simple hierarchy, with a clear MRO, is a hierarchy that is easy to understand, easy to test, and easy to maintain, which is the goal of good inheritance design, whether single or multiple, and keeping hierarchies simple is the best way to ensure that the MRO remains a helpful tool, rather than a source of confusion.
class A:
def greet(self):
return "A"
class B(A):
pass
class C(B):
pass
print([cls.__name__ for cls in C.__mro__])
Check: What is the method resolution order, and how can inspecting it help you debug an unexpected method call?
Section 9
Inheritance can lead to a problem known as the fragile base class problem, where a change to a parent class, even a seemingly harmless one, such as adding a new method, changing a method's implementation, or renaming an internal attribute, breaks a child class that depended on the parent's old behaviour, in a way that the parent author did not anticipate, because the child relied on details that were not part of the parent's documented interface, but that happened to work with the old implementation. That fragility arises because inheritance creates a tight coupling between parent and child, where the child depends not just on the parent's public interface, but also, in practice, on its implementation details, such as the order in which methods are called, the attributes it sets, or the methods it uses internally, which the parent author may change without realising that a child depends on them, because those dependencies are not visible in the parent's code, but in the child's code, which the parent author may not have seen.
Composition, by contrast, is less vulnerable to the fragile base class problem, because a containing class depends only on the contained object's public interface, not on its implementation, and because the contained object can be replaced with another object that provides the same interface, without affecting the containing class, as long as the interface remains stable, which is a weaker coupling than inheritance, where the child depends on the parent's implementation in many subtle ways. That weaker coupling is a major reason why composition is often preferred, as discussed earlier, because it allows parts to evolve independently, without the risk that a change to one part will break another in an unexpected way, which is a property that becomes more important as a program grows, and as more classes depend on each other, because the cost of a fragile change grows with the number of dependents.
For beginners, the lesson is to be aware of the fragile base class problem, to design parent classes with stable, documented interfaces, to avoid relying on parent implementation details in child classes, and to prefer composition when the relationship does not genuinely require inheritance's substitutability, because those practices reduce fragility, even if they do not eliminate it entirely, and they make hierarchies more maintainable over time. Awareness of fragility, like awareness of other inheritance pitfalls, does not mean avoiding inheritance altogether, but means using it with eyes open, understanding its costs as well as its benefits, and choosing it only when its benefits, reuse and substitutability, outweigh its costs, tight coupling and fragility, for the specific relationship in question.
class Base:
def process(self):
return self.step()
def step(self):
return "base step"
class Child(Base):
def step(self):
return "child step"
print(Child().process())
Check: What is the fragile base class problem, and why does it make changing a parent class risky?
Section 10
Designing a hierarchy starts with identifying the general concept, the parent, and the specific concepts, the children, and with deciding what behaviour belongs at each level, so that common behaviour is in the parent, and specific behaviour is in the children, with no duplication and no behaviour in a level where it does not belong. That decision, about placement, is the core of hierarchy design, and it should be guided by asking, for each method, whether it applies to all children, in which case it belongs in the parent, or only to some children, in which case it belongs in those children, or to a intermediate parent that groups those children, if such a grouping is meaningful.
Hierarchy design also involves deciding how deep the hierarchy should be, with a general guideline to keep it shallow, typically no more than two or three levels, because deeper hierarchies become hard to understand, hard to test, and hard to maintain, as each level adds a layer of indirection, and as a method call may traverse many levels before finding its implementation, making it hard to predict which method will be called, and hard to debug when the wrong method is called. A shallow hierarchy, with a parent and its immediate children, is easy to understand, because the relationship between parent and child is direct, and the method resolution is simple, while a deep hierarchy, with many intermediate levels, each adding a small refinement, may be more precise in modelling a domain, but it pays for that precision with complexity, which often outweighs the benefit, especially when the intermediate levels do not correspond to meaningful domain concepts, but are merely organisational conveniences.
A third hierarchy design consideration is whether to use inheritance at all, versus composition, for a given relationship, as discussed throughout this chapter, and that decision should be revisited as a hierarchy evolves, because a relationship that started as inheritance, when it seemed like a kind-of relationship, may turn out, as the program grows, to be better modelled as composition, when the child needs to change its parent-like behaviour dynamically, or when it needs to combine behaviours from several sources, which inheritance cannot do cleanly, without multiple inheritance's complexity.
Finally, hierarchy design, like other design, is iterative, and your first hierarchy will rarely be perfect, so you should expect to refactor it, by moving methods between levels, by splitting a parent, by merging children, or by replacing inheritance with composition, as you learn more about the domain and about how the hierarchy is actually used, rather than how you imagined it would be used. That iteration, driven by real use and by testing, is how good hierarchies emerge, not through perfect initial design, but through continuous improvement, guided by principles such as shallow depth, clear placement, and genuine kind-of relationships, which you can apply at each iteration, to judge whether the hierarchy is improving or becoming more complex without benefit. Embracing iteration, as with other design, is what allows a hierarchy to remain useful as a program grows, rather than becoming a liability that constrains it.
class Vehicle:
def move(self):
return "moving"
class Car(Vehicle):
def move(self):
return "driving"
print(Car().move())
Check: Why should inheritance hierarchies generally be kept shallow rather than deep?
Section 11
Dependency injection also makes testing easier, because a containing class can be tested with a simple, fake part, that records calls or returns known values, without needing the real part, which may be complex, slow, or dependent on external resources. A library class tested with a fake book, for example, can be checked for its collection logic, without needing real books with full data, and a car class tested with a fake engine can be checked for its start logic, without needing a real engine. That testability, through injection, is a major benefit of composition, and it is a reason why many experienced programmers design classes to receive their collaborators as arguments, rather than creating them internally, even when the collaborators are not variable in production, because the testability benefit alone justifies the extra argument, and because it makes the class's dependencies explicit, visible in its constructor signature, rather than hidden inside its implementation.
A containing class should delegate to its parts, rather than duplicating their logic, by calling their methods and using their results, so that each part remains responsible for its own behaviour, and the containing class remains responsible for coordination, such as deciding which part to call, in what order, and how to combine their results. That delegation, where a library's list_available method calls each book's is_available method, rather than checking book attributes directly, preserves encapsulation, because the library does not need to know how availability is determined, only that each book can answer whether it is available, and it allows the book's availability logic to change, without changing the library, as long as its method interface remains stable. Delegation, therefore, is not just a coding pattern; it is a way of respecting boundaries between classes, so that each class can evolve independently, as long as its interface is preserved.
Finally, composition can be nested, with a class containing objects that themselves contain objects, forming a tree of parts, such as a library containing shelves, each containing books, or a car containing an engine, which contains pistons, and that nesting can continue to any depth, as the domain requires, without the complexity of a deep inheritance hierarchy, because each level is a simple has-a relationship, with a clear owner and a clear part, rather than a kind-of relationship that must satisfy substitutability at every level. That nesting, through composition, is how complex systems are built from simple parts, in programming as in engineering, by combining parts into larger assemblies, which can themselves be parts of even larger assemblies, with each assembly tested and understood in isolation, before being combined, which is a scalable way to build complexity without losing clarity.
class Engine:
def start(self):
return "started"
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
return self.engine.start()
print(Car(Engine()).start())
Check: What is dependency injection, and how does receiving a part as an argument make a class easier to test?
Section 12
A more subtle issue is method shadowing, where a child defines a method with the same name as a parent method, but with a different signature, such as different arguments, which breaks substitutability, because code that calls the method through a parent reference, with parent arguments, will fail when given a child, or will behave unexpectedly, because the child's method expects different arguments. That signature change is a common inheritance mistake, often made when a child needs extra information that the parent method did not require, and the remedy is usually to add the extra information as an optional argument, with a default, so that the child's method remains compatible with the parent's signature, while also accepting the extra argument when it is provided, or to add a new method for the child's specific behaviour, rather than changing the parent method's signature. That compatibility, in signatures as in behaviour, is part of the Liskov principle, and it should be checked whenever a method is overridden, by comparing its signature with the parent's, and ensuring that it accepts at least the parent's arguments, and returns a compatible kind of value.
Another subtlety is that a child class inherits not just public methods, but also private-by-convention attributes and methods, those with a leading underscore, which are intended for internal use by the parent, and which a child should generally not rely on, or override, unless it is designed to do so, because those internals may change in a future version of the parent, breaking the child in the fragile base class way discussed earlier. A child that accesses a parent's underscore attribute directly, or that overrides an underscore method, is depending on an implementation detail, not on a public interface, and that dependency should be avoided, unless the parent explicitly documents that attribute or method as intended for subclass use, in which case it is effectively part of the subclass interface, even if it is marked private for external callers. That distinction, between public interface, subclass interface, and internal implementation, is a refinement of encapsulation for hierarchies, and it helps both parent and child authors know what is safe to rely on, and what may change.
For beginners, the practical lesson is to treat parent attributes and methods as a contract, to shadow only intentionally, with documentation, to keep overridden method signatures compatible, and to avoid relying on underscore internals, unless they are documented for subclass use, because those habits will make your hierarchies more robust, even if they seem like extra care in simple cases, where the parent and child are written by the same person, and where the risks seem low. That care pays off when the parent is later changed, or when the child is used by someone else, who expects the contract to hold, and who will be surprised if it does not, which is the same reason why all interface contracts, not just inheritance contracts, deserve careful attention in program design.
class Vehicle:
wheels = 4
class Bike(Vehicle):
wheels = 2
print(Vehicle.wheels, Bike.wheels)
Check: Why should an overriding method keep a signature compatible with the parent method it replaces?
Section 13
Extracting a parent should be done carefully, by first identifying exactly what is shared, and what differs, and by ensuring that the extracted parent represents a genuine general concept, not merely a container for shared code, because a parent that is not a meaningful concept, but only a code-sharing device, will create a confusing hierarchy, where children inherit from a parent that does not make sense as a kind-of relationship, and where future readers will struggle to understand why the hierarchy exists, or what the parent means in domain terms. A parent extracted from two book-like classes, for example, should be a meaningful publication concept, if both are publications, or it should not be extracted as a parent at all, but as a shared helper, used through composition, if the shared code does not correspond to a domain concept. That domain meaningfulness test, applied to extracted parents, prevents hierarchies that are technically correct, in that they share code, but conceptually confusing, in that they do not model the domain clearly.
A related refactoring is pushing down, where a method that is in a parent, but that is only used by some children, is moved down to those children, or to a more specific parent that groups only those children, because a parent method that is not applicable to all children violates the idea that a parent's interface should be usable by all its children, and it forces other children to inherit a method they should not use, which is confusing and potentially harmful, if they call it by mistake. Pushing down, like pulling up, is driven by noticing misplacement, where a method's applicability does not match its level in the hierarchy, and by moving it to the level where it does match, which improves the hierarchy's clarity, by ensuring that each level's interface matches the concept it represents, neither too broad, with methods that do not apply, nor too narrow, with methods missing that should apply.
Both refactorings, pulling up and pushing down, are part of the iterative design process discussed earlier, where a hierarchy is not fixed at creation, but evolves as duplication and misplacement are noticed, through use and testing, and where each refactoring should be accompanied by tests, to ensure that behaviour is preserved, or intentionally changed, and that no child is broken by the move. That test-driven refactoring, where tests are run before and after a move, to confirm that behaviour is unchanged, except where intended, is a safe way to improve a hierarchy, because it gives confidence that the refactoring, which is a structural change, has not introduced a behavioural change, which would be a bug, rather than an improvement. Refactoring with tests is a discipline that applies to all code changes, but it is especially important for hierarchies, where a change at one level can affect many children in ways that are not obvious from the changed code alone.
class Publication:
def __init__(self, title):
self.title = title
class Book(Publication):
pass
class Magazine(Publication):
pass
print(Book("Dune").title)
Check: What is pulling up in hierarchy refactoring, and when should shared behaviour be pulled up into a parent?
Section 14
Delegation can also be used to adapt an interface, where an object provides a method that calls another object's method with different arguments, or that transforms its result, to match the interface expected by callers, which is a form of wrapping, where the delegating object acts as an adapter between its callers and the object it contains, hiding differences in interface, or in behaviour, that callers should not need to know about. That adaptation is useful when reusing a class whose interface does not quite match the needs of a new context, because it allows the class to be reused without modification, by wrapping it in an adapter that translates between interfaces, rather than by changing the class itself, which might break its other users, or by duplicating its logic in a new class, which would create maintenance duplication. Adapters, through delegation, are a practical way to reuse code in new contexts, without modifying the reused code, which is a benefit that grows as code is reused across more contexts, each with slightly different interface needs.
A delegation chain, where an object delegates to another, which delegates to another, and so on, can become long, and it can be hard to trace, because a call on the outermost object may travel through several objects before reaching the one that actually performs the work, which makes debugging harder, as the traceback may show several delegation layers, and as the cause of a failure may be in a deeply nested delegate, rather than in the object that was originally called. Keeping delegation chains short, by delegating directly to the object that does the work, rather than through intermediate objects that merely forward, helps keep programs understandable, and it prevents a pattern where every class is a thin wrapper around another, with no real behaviour of its own, which is a sign that the design may be over-decomposed, with too many layers for the problem's complexity. That balance, between delegation for modularity and directness for clarity, is a design judgement, like many others in this chapter, that should be guided by readability and by the actual complexity of the problem, rather than by a rule that everything must be delegated, or that nothing should be.
For beginners, delegation is a pattern to recognise and to use in composition, by calling methods on contained objects, rather than accessing their attributes directly, or duplicating their logic, because that recognition will help you write classes that are modular and maintainable, even if you do not use the term delegation explicitly, or design formal delegation chains, in your early programs. Simply asking, for each operation a class performs, whether it should do the work itself, or ask a contained object to do it, and choosing the latter when the work belongs to the contained object's domain, is enough to apply delegation in practice, and it will lead to classes that respect boundaries, that are easier to test, and that are easier to change, because each piece of logic lives in the class that owns it, not in the class that happens to use it.
class Storage:
def save(self, item):
return f"saved {item}"
class Manager:
def __init__(self):
self.storage = Storage()
def save(self, item):
return self.storage.save(item)
print(Manager().save("task"))
Check: How does delegation in composition keep each class focused on its own responsibility?
Section 15
In Python, lifetime is managed by references: an object remains alive as long as at least one reference to it exists, and it is reclaimed when no references remain, which means that a containing object that holds a reference to a part keeps that part alive, as long as the container itself is alive, while a part that is also referenced elsewhere will survive the container's deletion, because the other reference keeps it alive. That reference-based lifetime, therefore, implements both strong and weak composition naturally, depending on whether other references exist, without needing explicit ownership declarations, as in some languages, but it also means that a programmer must think about references, to predict whether a part will survive its container, or whether it will be reclaimed, which is important for programs that manage resources, such as files or connections, where a part's lifetime may need to be controlled explicitly, through closing or releasing, rather than left to garbage collection, as discussed for __del__ in the previous chapter.
A practical consequence of reference-based lifetime is that a container should not assume it owns a part merely because it holds it, unless it created the part itself, and it should not close or destroy a part that it received as an argument, unless that ownership has been explicitly agreed, because the caller may still need the part after the container is done with it, and closing it prematurely would break the caller's use. That ownership agreement, whether explicit in documentation, or implicit in the pattern of creation versus injection, should be clear, so that both container and caller know who is responsible for a part's lifecycle, and so that resources are not closed twice, or left open, due to confusion about ownership. Documenting ownership, in a class's docstring, especially for parts that hold resources, is a small step that prevents a class of lifecycle bugs, where a resource is mismanaged because ownership was unclear.
For beginners, the lesson is to think about lifetime when designing composition, by asking whether a part is created by the container, or received from outside, and whether it should survive the container, and to document that answer, even informally, so that future readers, including yourself, can predict a program's behaviour when objects are created and discarded. That lifetime thinking, like other design thinking in this chapter, is not about Python syntax, but about modelling, about understanding the domain's relationships, and about making those relationships explicit in code, through creation patterns, through documentation, and through careful handling of references, so that a program's behaviour matches its domain, not just its code.
class Book:
def __init__(self, title):
self.title = title
class Library:
def __init__(self, books):
self.books = books
books = [Book("Dune")]
lib = Library(books)
print(len(lib.books))
Check: What is the difference between a part that is owned by its container and a part that is merely referenced by it?
Section 16
Combining patterns also requires care about method names, because a class that inherits methods from a parent, and that delegates to composed objects, may have methods with similar names, or may need to expose composed objects' methods through its own interface, by defining wrapper methods that delegate, rather than exposing the composed object directly, which would break encapsulation, by allowing callers to bypass the containing class's rules, and to manipulate the part directly, in ways that the container cannot control or validate. Wrapper methods, that delegate while adding validation, logging, or coordination, preserve the container's control, while still providing access to the part's behaviour, which is a balance between encapsulation and usability, that should be struck deliberately, by deciding, for each part method, whether it should be exposed through the container, and if so, whether it should be exposed directly, through a wrapper, or not at all, because not every part method is relevant to the container's users, and exposing irrelevant methods clutters the container's interface and creates dependencies that are hard to change later.
A combined design should also be tested at each level, with the parent's behaviour tested through the child, the part's behaviour tested in isolation, and the combined behaviour tested through the container, with its parts, to ensure that inheritance and composition work together correctly, and that a change to either the parent or a part does not break the container in an unexpected way. That layered testing, like layered design, keeps each part's tests focused, while also checking their integration, which is important, because integration bugs, where two correct parts interact incorrectly, are common, and they are only caught by tests that exercise the combination, not by tests that exercise each part alone. Writing integration tests, for a combined class, therefore, is as important as writing unit tests for its parent and its parts, and it should be done as part of developing the combined class, not as an afterthought, when integration problems are discovered in a larger program.
For beginners, combining inheritance and composition may seem advanced, but it is actually the natural next step after learning each pattern separately, because real entities have both kinds and parts, and a program that models them accurately will need both patterns, in different places, for different relationships. Starting with a simple combined example, such as a vehicle hierarchy with composed engines, or a publication hierarchy with composed authors, and testing it thoroughly, will build confidence in combining patterns, and will show that the combination is not a new, separate technique, but merely the application of both familiar techniques, each to the relationship it best fits, in the same program, which is a reassuring realisation, that advanced design is often just the thoughtful combination of basic patterns, rather than a completely new idea.
class Vehicle:
def move(self):
return "moving"
class Engine:
def start(self):
return "started"
class Car(Vehicle):
def __init__(self):
self.engine = Engine()
print(Car().move(), Car().engine.start())
Check: Why do most real classes use both inheritance for their kind and composition for their parts?
Section 17
A common design question is whether to inherit from a built-in type, such as list or dict, to create a custom collection, or to compose a built-in type, by holding it as an attribute and delegating to it, and the answer, in most cases, is to compose, rather than inherit, because inheriting from a built-in exposes all of its methods, including those that may break your class's invariants, if called directly, and because built-in types have complex, optimised implementations, where overriding a method may not affect all operations, as some operations bypass the overridden method and call the built-in implementation directly, leading to inconsistent behaviour that is hard to debug. For example, a custom list class that inherits from list and overrides append to validate items, may find that other list operations, such as extending or slicing, bypass the overridden append, and add invalid items without validation, because those operations are implemented in the built-in, not through append, which is a subtle inconsistency that composition avoids, by holding a list internally and exposing only the methods you choose, each with validation, so that all access goes through your methods, and no operation can bypass validation.
There are cases where inheriting from a built-in is appropriate, such as when you genuinely need a type that behaves exactly like a list or dict, with only a small addition, and when you understand the built-in's implementation well enough to know which methods to override, and to test that all operations respect your addition, but those cases are rarer than they appear, and they require careful testing, with all operations, not just the ones you overrode, to ensure consistency. For beginners, the guideline is clear: prefer composition for custom collections, by holding a built-in as an attribute, and prefer inheritance from built-ins only when you have a specific reason, and when you have tested thoroughly, because the pitfalls of built-in inheritance, through bypassed overrides and exposed methods, are subtle and easy to miss, while the benefits of composition, control and encapsulation, are immediate and clear.
That guideline, like the general guideline to prefer composition, is not a ban, but a default, to be departed from with reason and with testing, and it illustrates a broader principle, that inheritance should be used when the relationship is a genuine is-a, with substitutability, and when the parent's interface is appropriate to expose in full, not when it is merely a convenient way to reuse a built-in's methods, because convenience reuse, through inheritance, often brings unwanted methods and inconsistent behaviour, while composition provides reuse with control, which is usually the better trade, even if it requires writing a few wrapper methods, because those wrappers are the price of a clean, focused interface, which is worth paying for a class that will be used and maintained over time.
class Shelf:
def __init__(self):
self._books = []
def add(self, book):
self._books.append(book)
def __len__(self):
return len(self._books)
Check: Why is composing a built-in list usually safer than inheriting from list for a custom collection?
Section 18
Refactoring should also be guided by tests, as discussed earlier, with tests written before refactoring, to capture the current behaviour, and run after each step, to confirm that behaviour is preserved, or intentionally changed, with test failures indicating either a bug in the refactoring, or a behaviour change that was not intended, which should be investigated before proceeding, rather than ignored, because ignoring a failing test during refactoring is a sign that the refactoring has changed behaviour in a way that was not planned, which may be a bug, or may be a legitimate change that should be documented and communicated to callers, but which should not be allowed to pass silently, as an accidental side effect of a structural change. That test discipline, applied to refactoring, turns a risky structural change into a series of safe, verified steps, each of which can be understood and, if necessary, reverted independently, without losing the whole refactoring, which is a practical benefit that makes large refactorings feasible, even for beginners, provided they proceed incrementally and with tests.
A related refactoring consideration is that changing from inheritance to composition, or vice versa, changes the public interface, in that a child class that was previously substitutable for its parent, through inheritance, will no longer be substitutable, if it now composes the parent instead, unless it explicitly provides the parent's interface through delegation, which means that callers who used the child as a parent will break, unless they are updated, or unless the new class provides a compatible interface through wrappers, which is a compatibility concern that should be considered before refactoring, by checking who uses the class, and how, and by deciding whether the refactoring's benefit outweighs the cost of updating callers, or whether a compatibility layer, such as wrapper methods that preserve the old interface, is needed during a transition period. That compatibility thinking, like other interface thinking, is part of responsible refactoring, because a refactoring that improves a class's internal design, but breaks all its callers, may not be an improvement overall, unless the callers can be updated, or unless the benefit is large enough to justify the breakage, which is a judgement that depends on the program's size, its users, and its stage of development.
For beginners, refactoring between inheritance and composition may seem daunting, but it is a skill that develops through practice, starting with small hierarchies, where the effects are easy to see, and where tests can be written quickly, and progressing to larger ones, where incremental steps and test discipline become essential, and where the experience gained from small refactorings builds confidence for larger ones. That progression, from small to large, with tests at each step, is the same progression that applies to all programming skills, and it is a reassuring reminder that advanced techniques, like large refactorings, are not qualitatively different from basic ones, but are merely basic techniques, applied carefully, incrementally, and with verification, at a larger scale, which is a perspective that makes advanced work feel accessible, rather than intimidating.
class Book:
def __init__(self, title):
self.title = title
class Library:
def __init__(self):
self.books = []
def add(self, book):
self.books.append(book)
Check: Why should a large refactoring, such as changing inheritance to composition, be done in small, tested steps?
Section 19
A class hierarchy can be used to model a domain's taxonomy, such as animals, vehicles, or publications, where each level represents a category, and each child represents a subcategory, with the hierarchy reflecting the domain's classification, rather than merely organising code, and that taxonomic modelling can be useful, because it gives the program a structure that mirrors the domain, making it easier for domain experts to understand, and easier for programmers to find where a particular concept belongs, by following the taxonomy from general to specific.
A more flexible approach to taxonomy, in many programs, is to use composition and attributes, rather than inheritance, to represent categories, such as a book object with a category attribute, or a vehicle object with a type attribute, rather than a separate class for each category, because attributes can change, can be combined, and can be queried, without requiring a new class for each combination, which is especially valuable when categories overlap, such as a book that is both fiction and a bestseller, or a vehicle that is both electric and a car, where a hierarchy would require multiple inheritance, or a complex set of intermediate classes, to represent the combination, while attributes can represent it simply, by setting two attributes, or by holding a list of categories, which is a more natural model for overlapping, fluid categories than a rigid hierarchy.
For beginners, the lesson is to be cautious about modelling a domain taxonomy as a class hierarchy, and to consider whether categories are truly fixed, hierarchical, and mutually exclusive, which would justify a hierarchy, or whether they are fluid, overlapping, and changing, which would suggest attributes, or composition, as a better model, because that consideration, made early, can prevent a hierarchy that becomes a burden, as the domain evolves, and that must be refactored later, at greater cost, than if attributes had been used from the start. That caution does not mean never using a hierarchy for taxonomy, because some taxonomies are stable and hierarchical, such as a simple animal classification, or a publication hierarchy, where a hierarchy is clear and useful, but it means checking, for each taxonomy, whether its properties match inheritance's strengths, stability and hierarchy, or whether they match composition's strengths, flexibility and combination, and choosing accordingly, rather than defaulting to a hierarchy because a taxonomy looks hierarchical at first glance.
Finally, taxonomic modelling, whether through hierarchy or through attributes, should be driven by the program's needs, not by a desire to replicate a domain taxonomy in full, because a program rarely needs every category in a domain, but only those that affect its behaviour, such as categories that change how an object is processed, displayed, or stored, while categories that do not affect behaviour can be represented as data, such as a string attribute, without needing a class or a special method, which keeps the program focused on behaviour, rather than on classification for its own sake.
class Book:
def __init__(self, title, category):
self.title = title
self.category = category
print(Book("Dune", "fiction").category)
Check: When is representing a category as an attribute better than creating a separate subclass for it?
Section 20
A program that uses inheritance and composition poorly, by contrast, will have deep, tangled hierarchies, with children that depend on parent implementation details, with containers that expose their parts directly, breaking encapsulation, and with relationships that do not match the domain, such as inheriting to reuse code, or composing when substitutability is needed, which will make the program hard to understand, hard to test, and hard to change, because a change in one class may break others in ways that are not obvious, and because the system's structure, rather than clarifying the domain, obscures it, with a hierarchy that must be navigated to understand even simple operations. That poor use, while it may work initially, will become a burden as the program grows, because the cost of its complexity grows with its size, while the benefit of its reuse, if any, remains fixed, which is a bad trade that becomes worse over time, and that should be avoided by applying the principles in this chapter, from the start, rather than by refactoring later, when the cost is higher, though refactoring, as discussed, remains possible and valuable, even for a poorly designed hierarchy, if done incrementally and with tests.
As you move to the next chapter, on dunder methods and protocols, you will see inheritance and composition in a new light, because special methods allow custom classes to participate in Python's protocols, such as iteration, comparison, and context management, and those protocols can be implemented through inheritance, by inheriting a protocol's methods, or through composition, by delegating to a contained object that implements the protocol, and the choice between those implementations, like other choices in this chapter, will depend on the relationship's meaning, and on the desired interface, rather than on a rule that one pattern is always better. That continued relevance, of inheritance and composition, in later chapters, shows that they are not isolated topics, but foundational patterns, that recur in different forms, as you learn new Python features, and that the judgement you develop in this chapter, about when to use each, and how to use them well, will serve you in every later chapter, where classes are combined, extended, and integrated with Python's language features, in increasingly sophisticated ways.
Keep practising, by building programs that use both patterns, by reviewing your hierarchies for depth, for meaning, and for fragility, and by refactoring them when they become unclear, because that practice, more than any single rule, is what develops the judgement to choose well between inheritance and composition, and to use each well, in the programs you build, whether they are small learning projects, or larger, real-world applications, where the stakes of good design are higher, and where the principles in this chapter, applied consistently, will make the difference between a program that is a pleasure to maintain, and one that is a burden to change. That difference, in maintainability, is the ultimate measure of design quality, and it is achieved, not by using a particular pattern, but by using patterns thoughtfully, with clarity, flexibility, and respect for the domain, as this chapter has encouraged throughout.
class Publication:
def __init__(self, title):
self.title = title
class Book(Publication):
def __init__(self, title, author):
super().__init__(title)
self.author = author
print(Book("Dune", "Frank Herbert").author)
Check: How will the judgement about inheritance versus composition help you in later chapters on protocols and special methods?
Section 21
When a child class overrides a method, it may also need to override related methods, to keep them consistent, because methods in a parent often work together, assuming each other's behaviour, and changing one without changing the others can break that assumption. For example, if a parent class defines both __eq__ and __hash__, and a child overrides __eq__ to compare different attributes, it should also override __hash__ to match, or it should make itself unhashable deliberately, because otherwise equal objects may have different hashes, breaking sets and dictionaries, as discussed earlier. That consistency, across related methods, is a responsibility of overriding, not just of the overridden method itself, and it requires thinking about which methods form a group, such as equality and hashing, comparison methods, or a method and its helper, and ensuring that overriding one does not leave the others inconsistent, which is a subtle bug that may not appear until the object is used in a context that relies on the group, such as a set, where the inconsistency becomes visible as a missing or duplicate entry.
Related methods also include a method and the attributes it relies on, because a child that overrides a method to use a new attribute, without ensuring that attribute is set in its __init__, will fail when the method is called, because the attribute does not exist, even though the parent method, which did not use that attribute, worked correctly. That attribute dependency, like method grouping, should be checked when overriding, by asking what data the new method needs, whether that data is set in the child's initialisation, and whether it is updated correctly when the object's state changes, because a method that depends on data that is not maintained will fail, or will produce wrong results, in ways that are hard to trace, as the failure may occur far from the place where the data should have been set, or updated. That checking, of dependencies, is part of responsible overriding, and it should be done as a matter of routine, whenever a method is overridden, by reviewing the new method's dependencies and ensuring they are satisfied by the child's initialisation and by its other methods.
A related practice is to keep overridden methods small and focused, so that their dependencies are easy to see, and so that they can be tested in isolation, without needing to set up a complex state, which is a benefit that grows as a hierarchy grows, because a large overridden method, with many dependencies, is hard to understand and hard to test, while a small one, with few dependencies, is easy to understand and to verify, even in a complex hierarchy. That smallness, like smallness in other methods, is a design guideline, not a rigid rule, but it is especially valuable for overridden methods, because they are read in the context of a parent method, and a reader must compare the two to understand the difference, which is easier when both are small and focused, than when either is large and diffuse, with many intertwined concerns that must be compared line by line to find the difference that matters.
class Shape:
def area(self):
return 0
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius * self.radius
print(Circle(2).area())
Check: Why should overriding one method sometimes require overriding related methods, such as __hash__ when you override __eq__?
Section 22
Template methods should be designed carefully, with the fixed steps documented as not to be overridden, or as to be overridden only with care, and with the variable steps documented as intended for overriding, with their signatures, their expected results, and any invariants they must maintain, because a child author who does not know which steps are fixed and which are variable may override a fixed step, breaking the algorithm's structure, or may fail to override a variable step, leaving a default that is inappropriate for their specific case, both of which are mistakes that clear documentation can prevent, by making the template's design explicit, rather than leaving it to be inferred from code, where the distinction between fixed and variable steps may not be obvious, especially if the fixed steps are not marked in any way, and the variable steps look like ordinary methods that happen to be overridden in existing children, but that a new child author might not realise they should override.
A template method also creates a dependency from the parent to the child, in that the parent's algorithm calls child methods, which is the reverse of the usual dependency, where a child depends on a parent, and that reverse dependency, sometimes called inversion, requires that the parent's algorithm be robust to child implementations that may be slow, may raise exceptions, or may return unexpected values, because the parent cannot control the child's implementation, only its interface, and it must handle failures in child steps gracefully, or document that child steps must not fail, or must return values of a certain kind, so that the parent's algorithm can rely on them. That robustness, or that documented contract, is essential for template methods to work safely, because otherwise a faulty child step can break the parent's algorithm in a way that is hard to diagnose, as the failure occurs in the parent's code, but its cause is in the child's step, which the parent author did not write, and which may be far from the parent's code in the program's structure.
For beginners, template methods are a pattern to recognise, rather than a pattern to use frequently, because most learning programs do not need a formal template, with fixed and variable steps, and can achieve similar reuse through ordinary methods and composition, without the complexity of a parent algorithm that calls child methods, which can be confusing, especially when debugging, because the flow of control goes from parent to child and back, rather than in a single direction, which is harder to trace than a straightforward call from a caller to a method. Recognising template methods in frameworks, however, will help you use those frameworks correctly, by knowing which methods you are meant to override, and which you are meant to leave alone, which is information that is essential for extending a framework, even if you do not design template methods yourself yet, and which will become more relevant as you work with larger, framework-based programs, where template methods are a common way to provide extension points, without requiring subclasses to reimplement an entire workflow.
class Report:
def generate(self):
return self.header() + self.body() + self.footer()
def header(self):
return "Header\n"
def footer(self):
return "Footer\n"
def body(self):
return "Body\n"
print(Report().generate())
Check: What is a template method, and how does it let a parent define an algorithm's structure while children fill in variable steps?
Section 23
Class attribute inheritance also interacts with instance attribute shadowing, where an instance that assigns an attribute with the same name as a class attribute creates its own instance attribute, which shadows the class attribute for that instance only, while other instances continue to see the class attribute, which is a behaviour that can be surprising, if an instance is expected to share a class attribute, but instead shadows it, by assigning to it, thinking it is updating the shared value, when it is actually creating a private value for itself alone. That shadowing, through instance assignment, is a common pitfall, especially for mutable class attributes, as discussed in the previous chapter, where assigning through an instance does not update the class attribute, but creates an instance attribute, which is a distinction that must be understood to work with class attributes in hierarchies, where a child class attribute may be shadowed by an instance attribute, or a parent class attribute may be shadowed by a child class attribute, each with different scopes and different effects, which can be confusing if the scopes are not kept clear in the programmer's mind.
A guideline for class attributes in hierarchies is to use them for truly shared, usually immutable, values, such as a category name, a default setting, or a type identifier, and to access them through the class, rather than through an instance, when updating them, if updating is ever needed, to avoid accidental shadowing, and to document whether a class attribute is meant to be overridden by children, or whether it is meant to be fixed for the whole hierarchy, because that documentation helps child authors know whether they should override it, and helps parent authors know whether changing it will affect children, which is information that is essential for maintaining a hierarchy's constants, just as it is for maintaining its methods, though constants are often overlooked in documentation, because they seem simpler than methods, while in fact their inheritance behaviour can be just as subtle, and just as prone to unintended effects, as method inheritance.
For beginners, class attributes in hierarchies are a detail to be aware of, rather than a feature to use extensively, because most learning hierarchies can be expressed with instance attributes, set in __init__, which are clearer, because they are per-object, and because they do not involve the complexities of class-level sharing and shadowing, which can confuse, especially when combined with inheritance, where a class attribute may be defined at several levels, each shadowing the previous, and where an instance attribute may shadow them all, creating a chain of values that must be traced to understand which value an instance will actually see. Keeping class attributes simple, using them only for clear, shared constants, and preferring instance attributes for per-object data, is a safe guideline, that will prevent most class-attribute pitfalls, while still allowing you to use class attributes where they genuinely help, such as for a shared default that should be visible through the class itself, without creating an instance.
class Publication:
kind = "publication"
class Book(Publication):
kind = "book"
print(Publication.kind, Book.kind)
Check: How does a child class overriding a parent class attribute affect instances of the parent versus instances of the child?
Section 24
Type checking, however, should be used sparingly, because code that branches on type, with a chain of isinstance checks, each handling a different subclass differently, is a sign that polymorphism is not being used, and that the differences between subclasses should instead be handled through overridden methods, so that the code can call a common method, rather than checking types and calling different methods for each type, which is a pattern that must be updated for every new subclass, and that duplicates the branching logic in every place that handles the hierarchy, rather than concentrating it in the subclasses themselves, where each subclass's behaviour is defined once, in its overridden method, and where adding a new subclass requires only defining its class, not updating every type-checking branch in the program. That polymorphic alternative, to type checking, is cleaner, more extensible, and less error-prone, and it should be preferred whenever the differences between subclasses can be expressed as different implementations of a common method, which is the case for most genuine kind-of differences, where each kind behaves differently in a way that can be captured by a method.
There are cases where type checking is appropriate, such as when a function needs to handle an object differently based on whether it provides a certain capability, or when it needs to validate that an argument is of an expected type, before proceeding, or when it needs to handle a special case, such as a specific subclass that requires additional processing, in addition to the common polymorphic behaviour, rather than instead of it, where a type check for that special case, followed by polymorphic handling for the common case, is a reasonable combination, that acknowledges both the common interface and the special need, without forcing the special need into a polymorphic method that would be inappropriate for other subclasses. Those cases, where type checking is used for validation or for a special case, rather than for dispatching all behaviour, are legitimate, and they should be documented, so that readers understand why type checking was used, rather than polymorphism, and so that future maintainers do not mistakenly replace it with polymorphism, losing the special handling that the type check provided.
For beginners, the guideline is to prefer polymorphism, by calling common methods, and to use isinstance only for validation, or for a genuine special case, and to avoid long chains of type checks that dispatch behaviour, because those chains are a code smell, indicating that the hierarchy's methods are not being used effectively, and that the program's logic is scattered, rather than concentrated in the classes where it belongs. That guideline, like other guidelines in this chapter, is not a rigid ban, but a default, to be departed from with reason, and with documentation, when a special case genuinely requires it, because the goal is not to avoid type checking entirely, but to use it where it is the clearest, most maintainable solution, and to use polymorphism where it is clearer, which, in most cases, will be the majority of the time, for behaviour that varies by kind.
class Animal:
pass
class Dog(Animal):
pass
print(isinstance(Dog(), Animal), isinstance(Dog(), Dog))
Check: Why is isinstance more flexible than checking an object's exact type when inheritance is involved?
Section 25
Hierarchy documentation should also include examples, showing how to create objects of different classes, how to use them polymorphically, and how to extend the hierarchy with a new child, because examples are often clearer than prose, especially for a hierarchy, where the relationships are best understood by seeing them in use, rather than by reading a description of them, and because an example of extending the hierarchy, by defining a new child with its overridden methods, teaches a future maintainer how to add to the hierarchy correctly, without breaking its invariants, which is a task that will arise as a program grows, and that should be as easy as possible, with guidance, rather than left to guesswork, where a new child author might override the wrong methods, or fail to override required ones, causing a bug that could have been prevented by a clear example.
As a hierarchy evolves, its documentation must evolve with it, by updating the overview when a new child is added, when a parent method changes, or when an invariant changes, because a hierarchy overview that is out of date is worse than no overview, as it will mislead readers into thinking the hierarchy works one way, when it actually works another, leading them to write code that relies on outdated assumptions, which will fail, or behave unexpectedly, when run against the current hierarchy. That maintenance, like other documentation maintenance, should be done as part of each change, rather than as a separate, occasional effort, because a change that updates code but not documentation creates a drift that grows over time, until documentation is so out of date that it is ignored entirely, which defeats its purpose, and which is a common fate for documentation that is not maintained alongside code, in any kind of program, but especially in hierarchies, where relationships are central and where outdated relationship information is particularly misleading.
Finally, hierarchy documentation should note any known limitations, or any areas where the hierarchy is intentionally incomplete, such as a parent class that is not meant to be instantiated directly, but only through its children, or a method that is abstract and must be implemented, or a child that is experimental and may change, because noting those limitations helps readers avoid using the hierarchy in ways it was not designed for, and helps maintainers know which parts are stable and which are still evolving, which is information that is essential for safe use and safe maintenance, especially in a shared codebase, where different people may use and extend the hierarchy, with different levels of familiarity, and where a clear note about limitations can prevent a misuse that would otherwise require a bug fix and a refactoring to correct, after the fact, rather than being prevented by a simple note at the start.
class Shape:
"""Base for shapes; subclasses must implement area()."""
def area(self):
raise NotImplementedError
Check: Why should a hierarchy be documented as a whole, in addition to documenting each class individually?
Section 26
Polymorphic tests should check not just that methods exist, but that they behave consistently, such as that a child's overridden method returns the same kind of value as the parent's, that it does not raise an exception where the parent would succeed, for valid inputs, and that it maintains the parent's invariants, such as a balance never going negative, or a status always being one of a set of allowed values, because those consistencies are what allow code written for the parent to work with children, without needing to know which child it is dealing with, and a test that checks only existence, without checking behaviour, would pass for a child that provides a method with the right name, but the wrong behaviour, which is a false sense of security, worse than no test, because it suggests that substitutability holds, when it does not, leading to bugs in polymorphic code that were not caught by testing.
Testing a hierarchy also involves testing the parent class itself, where it is meant to be instantiated, by creating parent objects and checking their behaviour, and testing abstract parents, where they are not meant to be instantiated, by checking that instantiation fails, or by testing through a minimal concrete subclass, that implements the abstract methods in a trivial way, solely for testing the parent's concrete methods, which is a technique that allows parent logic to be tested without needing a real child, and that isolates parent bugs from child bugs, because a failure in a parent test, run through a minimal subclass, points to the parent, not to a specific child, which is a clearer diagnosis than a failure in a child test, which could be due to either the parent or the child, and which requires further investigation to determine which. That isolation, through minimal subclasses for testing, is a useful technique for abstract parents, and it should be used when parent logic is non-trivial, and when it needs to be verified independently of any specific child.
Finally, hierarchy tests should be maintained as the hierarchy evolves, by adding tests for new children, by updating polymorphic tests to include them, and by checking that existing children still pass their tests after a parent change, because a parent change that breaks a child test indicates either a bug in the parent change, or a child that relied on an implementation detail that has changed, which, as discussed for fragile base classes, should be investigated, rather than ignored, by either fixing the parent change to preserve the child's expected behaviour, or by updating the child to rely only on the parent's public interface, which is a decision that should be made deliberately, with an understanding of the hierarchy's contract, rather than by simply updating the test to match the new, possibly incorrect, behaviour, which would hide a real bug behind a changed expectation, and which is a testing anti-pattern that should be avoided, especially in hierarchies, where a parent change can have wide, subtle effects.
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof"
for animal in [Animal(), Dog()]:
print(animal.speak())
Check: How can polymorphic tests, run with objects of each child class, verify that substitutability holds?
Section 27
A hierarchy's design can be improved by considering how it will be used in real code, not just in abstract terms, by writing example code that uses the hierarchy, as a client would, and by checking whether that code is clear, concise, and free of type checks, workarounds, or special cases, because example client code that is awkward, with many conditionals, or with manual handling for each child, indicates that the hierarchy's interface is not serving its clients well, and that it should be redesigned, perhaps by moving a method to the parent, by adding a polymorphic method, or by replacing inheritance with composition, for a relationship that is not serving clients as a kind-of relationship should.
Client-driven design also involves considering the most common client operations, and ensuring that they are easy, with sensible defaults and with methods that match client needs, rather than requiring clients to perform several steps, or to understand internal details, to achieve a simple goal, such as creating a child object, or calling a common method, which should be as simple as possible, even if that simplicity requires additional convenience methods, or alternative constructors, in the hierarchy, to support common client workflows, without forcing clients to repeat a sequence of steps, or to know about parent initialisation details, that should be hidden behind a clean interface.
A hierarchy that is designed for its clients will also be easier to document, because its documentation can be written in terms of client goals, such as how to create a book, how to list available books, or how to add a new kind of publication, rather than in terms of its internal structure, such as which class inherits from which, which, while necessary for maintainers, is less helpful for clients, who care about what they can do, not about how the hierarchy is organised internally, except where that organisation affects their choices, such as which class to instantiate, or which method to override, when extending the hierarchy.
For beginners, client-driven design is a mindset to adopt early, by writing example client code before finalising a hierarchy, and by asking whether that code is clear and concise, because that mindset will prevent many hierarchy design mistakes, such as over-engineering, with deep levels that clients do not need, or under-designing, with a parent that does not provide the common methods clients need, forcing them to duplicate logic, or to check types, in their own code, which are signs that the hierarchy is not serving its clients, and that it should be revised, before it is used in a larger program, where revising it will be more costly, because more client code will depend on it, and because changing it will require updating those clients, which is a cost that grows with use, and that should be avoided by designing for clients from the start, even in small programs, where the client may be only yourself, but where the discipline of writing example client code still pays off, by revealing design flaws early, when they are cheap to fix.
class Book:
def __init__(self, title):
self.title = title
def describe(self):
return f"Book: {self.title}"
for book in [Book("Dune"), Book("Emma")]:
print(book.describe())
Check: Why should a hierarchy be judged by how easy its client code is to write, rather than by how elegant it looks in isolation?
Section 28
Splitting a hierarchy can also reveal that the original parent was doing too much, by trying to serve two different groups of children, with different needs, and that each group deserves its own parent, with its own interface, tailored to its needs, rather than a single parent that tries to serve both, with a compromise interface that serves neither well, which is a sign of a parent that has become a god class, in hierarchy form, trying to be all things to all children, and that should be split, even if the split requires updating callers, because the long-term benefit, of clearer, more focused parents, outweighs the short-term cost, of updating callers, especially if the split is done incrementally, with tests, and with a transition period, as described above, which reduces the short-term cost, by allowing callers to update gradually, rather than all at once, which is a practical approach to a refactoring that would otherwise be disruptive.
Merging parents, by contrast, is appropriate when two parents have become similar, through evolution, with overlapping methods and attributes, and where maintaining them separately duplicates logic, and creates confusion about which parent to use for a new child, because the distinction between them has blurred, or was never clear, and merging them into a single parent, with a unified interface, simplifies the hierarchy, by removing a level or a branch that no longer serves a purpose, though merging, like splitting, should be done carefully, with tests, and with consideration for callers, who may have relied on the distinction between the merged parents, such as by checking type, or by overriding a method that existed in only one parent, and who will need to be updated, or provided with a compatibility layer, if the merge changes behaviour for their use case, which should be checked before merging, by reviewing callers and by running tests, to ensure that the merge preserves behaviour, or intentionally changes it, with documentation of the change.
For beginners, splitting and merging hierarchies may seem like advanced refactorings, but they are actually natural extensions of the iterative design process, where a hierarchy is not fixed, but evolves, as understanding grows, and where each evolution should be approached as a small, tested refactoring, rather than as a large, risky restructuring, which is a mindset that applies to all refactoring, and that will serve you well as your programs grow, because it allows you to improve design continuously, without fear, by relying on tests to catch mistakes, and by proceeding incrementally, with each step verified, before moving to the next, which is a discipline that turns refactoring from a daunting task into a routine, manageable activity, that you can perform confidently, even on a hierarchy that is used in many places, provided you proceed carefully and with verification at each step.
class Publication:
def __init__(self, title):
self.title = title
class Book(Publication):
pass
print(Book("Dune").title)
Check: When might splitting a parent class into two more focused parents improve a hierarchy?
Section 29
Performance can become a concern when a hierarchy is used in a tight loop, with many objects and many method calls, such as in a simulation or a game, where a method is called thousands of times per second, and where the overhead of MRO lookup, or of delegation through composition, can accumulate, though even in those cases, the overhead is usually small compared with the work done inside the method itself, such as calculations or I/O, which dominate the total time, and optimising the hierarchy, by flattening it or by inlining methods, should only be done after measuring, with a profiler, to confirm that hierarchy lookup is actually a bottleneck, rather than assuming it is, because premature optimisation, based on a guess about performance, often complicates code without improving it measurably, which is a waste of effort and a source of bugs, as discussed for other kinds of optimisation, where clarity should come first, and optimisation second, after measurement.
A related performance consideration is memory, where each object in a hierarchy carries the attributes of all its ancestors, through its instance dictionary, and where a hierarchy with many attributes, at many levels, can use more memory per object than a simpler design, though for ordinary programs, with a modest number of objects, that memory use is negligible, and it should not drive design decisions, unless a program creates millions of objects, in which case memory optimisation, such as using __slots__, which is an advanced technique that restricts attributes to a fixed set, and reduces per-object memory, may be considered, though it also restricts flexibility, by preventing new attributes from being added dynamically, which is a trade-off that should be made only when memory is genuinely a concern, and when the program's objects are well-understood, with a fixed set of attributes, which is a condition that holds for some programs, but not for most learning programs, where flexibility is more valuable than memory savings, and where __slots__ should not be used prematurely, as it adds complexity and can break code that expects dynamic attributes.
For beginners, the lesson is to not worry about hierarchy performance, unless measurement shows it is a problem, and to focus instead on clarity, correctness, and maintainability, which are the qualities that matter most for learning programs, and for most real programs, where performance is adequate, and where the cost of a confusing hierarchy, in terms of bugs and maintenance time, far outweighs the cost of a slightly slower method call, which is a perspective that will help you prioritise design decisions correctly, by asking first whether a hierarchy is clear and correct, and only later, if measurement shows a problem, whether it is fast enough, and if not, how to optimise it, without sacrificing clarity unnecessarily, which is a balanced approach to performance, that applies to all programming, not just to hierarchies, and that will serve you well as you encounter performance questions in later chapters, where profiling and measurement will be discussed in more depth.
class Animal:
def speak(self):
return "..."
print(Animal().speak())
Check: Why should hierarchy performance rarely drive design decisions for ordinary programs?
Section 30
This chapter's final section, before the remaining sections that explore further details, summarises the key distinctions between inheritance and composition, as a reference for future design decisions, by restating that inheritance models is-a, with substitutability, reuse through a parent, and a hierarchy that must be kept shallow, meaningful, and stable, while composition models has-a, with delegation, flexibility through contained parts, and a structure that can be nested, changed, and tested independently, without the coupling and fragility of a hierarchy, and that the choice between them should be based on the relationship's meaning, not on code reuse alone, because reuse through inheritance, when the relationship is not is-a, creates a misleading hierarchy, while reuse through composition, when substitutability is needed, fails to provide it, and both mistakes lead to programs that are harder to understand and to maintain, than programs that model relationships accurately, with the appropriate pattern for each.
That summary, while useful as a reference, should not be treated as a rigid rule, because real relationships are often ambiguous, with elements of both is-a and has-a, or with a relationship that changes over time, from is-a to has-a, or vice versa, as understanding evolves, and in those ambiguous cases, the best choice may be to start with the simpler pattern, usually composition, and to refactor to inheritance later, if substitutability becomes clearly needed, or to start with inheritance, if the is-a relationship is clear, and to refactor to composition later, if flexibility becomes more important than substitutability, with each refactoring done incrementally and with tests, as discussed earlier, so that the design can evolve, without a large, risky change, and without being locked into an initial choice that turns out to be wrong, which is a realistic approach, because initial design choices are often made with incomplete information, and a design process that allows for evolution, through refactoring, is more robust than one that assumes the initial choice must be perfect, and that resists change, even when evidence suggests it is needed.
As you continue, keep the summary in mind, but also keep in mind that design is a practice, not a set of rules, and that the best way to learn it is to build programs, to review their designs, to notice when a hierarchy is deep, or a composition is awkward, or a relationship is mis-modelled, and to refactor, with tests, to improve it, learning from each iteration, what works and what does not, for the specific domain and the specific program, rather than applying a rule blindly, without considering context, which is a lesson that applies to all design guidelines in this book, not just to inheritance and composition, because guidelines are heuristics, that point in a generally good direction, but that must be adapted to context, with judgement, which is developed through practice, not through memorisation, and which is the ultimate skill that this book aims to develop, through its chapters, its examples, and its exercises, each of which is an opportunity to practise judgement, in a small, safe setting, before applying it to larger, real-world programs, where judgement matters more, and where mistakes are more costly.
class Book:
def __init__(self, title):
self.title = title
class Library:
def __init__(self):
self.books = []
def add(self, book):
self.books.append(book)
Check: In one sentence each, how would you summarise when to use inheritance and when to use composition?
Section 31
A subclass can be used to extend a parent's behaviour in a way that is not just overriding a single method, but adding a whole new set of methods that are specific to the subclass, while still inheriting the parent's general methods, which is a natural way for a hierarchy to grow, as specific kinds gain specific capabilities that general kinds do not have. For example, a Book parent might have methods for title and author, while an Ebook child adds methods for file size, download, and format conversion, which are specific to electronic books, and which would not make sense for a printed book, so they belong only in the child, not in the parent, where they would be inherited inappropriately by printed books, which cannot be downloaded or converted in the same way. That addition, of child-specific methods, is a clean way to specialise, because it does not change parent behaviour, it only adds to it, for the specific kind, and it allows client code that knows it is dealing with an ebook to use ebook-specific methods, while client code that deals with books generally, through the parent interface, can ignore those specific methods, and work with any book, whether printed or electronic, through the common interface, which is a balance between general and specific use, that a well-designed hierarchy provides.
Child-specific methods should be used carefully, however, because client code that relies on them cannot work with a general parent object, only with the specific child, which limits its polymorphism, and which means that code that checks for a specific child type, or that calls child-specific methods, is not fully polymorphic, in the sense discussed earlier, where code should work with any object providing a common interface, without needing to know its specific kind.
A related guideline is that child-specific methods should not be added merely to reuse a parent's code, or to group unrelated functionality under a child, because that grouping, like other misplacements, will confuse readers, who will expect child-specific methods to be specific to the child's kind, not merely convenient places to put code that happens to use the child, and who will be surprised if a method that could apply to any book is found only in one child, where other children cannot use it, even though they should be able to, which is a sign that the method belongs in the parent, not in the child, and that it should be moved up, as discussed for pulling up, rather than left in the child, where it is inaccessible to other children that need it. That placement thinking, for child-specific methods, is the same as for parent methods, but in reverse: a method belongs in the child only if it is specific to that child's kind, and if it would be inappropriate, or meaningless, for other children, or for the parent, to have it, which is a test that can be applied to any new method, by asking whether it makes sense for a general parent object, or for sibling children, and if it does, moving it up, rather than leaving it in one child, where it will be duplicated, or missed, by others.
class Book:
def __init__(self, title):
self.title = title
class Ebook(Book):
def download(self):
return f"downloading {self.title}"
print(Ebook("Dune").download())
Check: When is adding a method only to a child class, rather than to its parent, the correct design choice?
Section 32
When a class inherits from a parent, it also inherits the parent's docstrings, unless it overrides a method with its own docstring, in which case the child's docstring replaces the parent's, for that method, which means that documentation, like behaviour, can be inherited, and that a child method without a docstring will show the parent's docstring, when inspected with help(), which can be confusing, if the child's behaviour differs from the parent's, but its documentation does not, because a reader who relies on the inherited docstring will be misled about the child's actual behaviour, which may have changed, even though its name and signature have not, which is a documentation pitfall that mirrors the behavioural pitfall of overriding without respecting the parent's contract, in that both involve a child that appears, from its interface, to be like its parent, but that actually differs, in a way that is not visible from its name alone, and that must be made visible through documentation, or through a change in name, if the difference is large enough to warrant it.
That documentation inheritance, therefore, should be handled deliberately, by giving every overridden method its own docstring, even if it is short, explaining how its behaviour differs from the parent's, or, if it does not differ, noting that it inherits the parent's behaviour, with no change, though in the latter case, overriding at all may be unnecessary, unless the override is needed for another reason, such as to change a default, or to add logging, in which case the docstring should explain that reason, so that readers do not wonder why a method was overridden, only to do the same thing as its parent, which is a question that arises in hierarchies, where an override that appears to be a no-op may actually be doing something subtle, such as calling the parent method with different arguments, or adding a side effect, which should be documented, to prevent a maintainer from removing the override as redundant, and thereby losing its subtle effect, which would be a bug introduced by a well-intentioned cleanup, based on incomplete documentation.
A class docstring for a child should also explain its relationship to its parent, by stating what kind of parent it is, what it adds, and what it changes, rather than merely repeating the parent's docstring, or giving a generic description that could apply to any child, because a child class docstring that does not mention its parent, or its specific additions, fails to help readers understand the hierarchy, and forces them to compare the child with its parent, method by method, to find the differences, which is the same tedious process discussed for child-specific methods, and which a good child docstring can short-circuit, by summarising the differences up front, in a few sentences, so that readers can grasp the child's purpose quickly, and can decide whether it is the right class for their task, without reading its entire implementation, which is the purpose of a class docstring in general, but which is especially important for children, where the purpose is defined in relation to a parent, not in isolation.
class Animal:
"""An animal."""
def speak(self):
"""Make a sound."""
return "..."
class Dog(Animal):
"""A dog; speaks by barking."""
def speak(self):
"""Return a bark."""
return "Woof"
print(Dog().speak())
Check: Why should every overridden method have its own docstring, even if it is short?
Section 33
A hierarchy can be tested by creating objects of each class, and by checking that they behave as expected, both individually, and when used polymorphically, as discussed earlier, but it can also be tested by checking the hierarchy's structure itself, such as by asserting that a child is a subclass of its parent, with issubclass, or that an object is an instance of a parent, with isinstance, which are structural tests, that verify the hierarchy's relationships, rather than its behaviour, and that can catch a refactoring mistake, such as a child that was accidentally changed to inherit from a different parent, or to not inherit at all, which would break polymorphic code, even if the child's individual methods still worked, because its relationship to the parent, which polymorphic code relies on, would be broken, and that breakage would not be caught by behavioural tests that only check the child's methods, without checking its place in the hierarchy, which is a structural property that deserves its own tests, especially in a hierarchy that is used polymorphically, where structure is part of the contract, not just an implementation detail.
A balanced testing approach, therefore, uses behavioural tests as the primary check, for what objects do, and structural tests only where structure is part of the contract, with a clear reason, documented in the test, for why structure matters in that case, so that future maintainers can judge whether a structural test failure, after a refactoring, indicates a real breakage, or merely a structural change that preserves behaviour, and that should be allowed, by updating the test, rather than by reverting the refactoring, which is a judgement that requires understanding the test's purpose, not just its failure, and that is easier to make when the test's purpose is documented, rather than left implicit, as a bare assertion that a class is a subclass of another, without explaining why that relationship matters, to clients, or to the program's behaviour, which is information that a future maintainer needs, to decide whether to preserve the relationship, or to update the test, when refactoring.
For beginners, the lesson is to focus on behavioural tests, for hierarchies, as for other code, by checking what objects do, when used individually and polymorphically, and to add structural tests only when you have a clear reason, such as a public API promise, or a framework requirement, and to document that reason, so that structural tests do not become a barrier to refactoring, but remain a useful check, for the specific structural properties that matter, which is a balanced approach, that gives you the benefits of testing structure, where it matters, without the brittleness of testing structure everywhere, which would make your test suite a hindrance, rather than a help, as your hierarchy evolves, which is a fate that should be avoided, by keeping tests focused on behaviour, and on the few structural properties that are genuinely contractual, rather than incidental.
class Animal:
pass
class Dog(Animal):
pass
print(issubclass(Dog, Animal))
Check: When is a structural test, such as checking issubclass, appropriate, and when does it make tests too brittle?
Section 34
A parent method sometimes calls another method that a child overrides. That is dynamic dispatch: the method actually run is chosen by the object's real class, not by the class where the calling code was written. It is what makes template methods work, where a parent defines a workflow and children fill in individual steps. It also means reading a parent method alone can mislead you, because its behaviour in practice depends on child overrides that are not visible in the parent's code.
That power needs documentation. A parent designed for extension should mark which methods are hooks meant for overriding, what they should return, and what invariants they must keep. Without that guidance, a child author may override the wrong method, or fail to override a required one, and the resulting bug will appear in the parent's code even though its cause is in the child. Clear hook documentation prevents that confusion and makes extension a deliberate act rather than guesswork.
A particular danger arises during initialisation. A child's __init__ usually calls super().__init__() first. If the parent's __init__ calls an overridable method, the child's version runs before the child's own attributes have been set, because the child's __init__ has not yet run past the super() call. The child's method may then access an attribute that does not exist yet, causing an error that is hard to trace, because the traceback points at creation time rather than at the real timing problem.
The safest guideline is to avoid calling overridable methods from __init__ unless you have a clear reason and have checked that child methods do not rely on attributes set after super(). That discipline prevents a class of subtle, timing-related bugs. Inheritance is not just about individual methods; it is about how methods interact through the hierarchy and in what order they run, and that interaction deserves as much design attention as any single method.
class Base:
def __init__(self):
self.value = self.get_value()
def get_value(self):
return 0
class Child(Base):
def get_value(self):
return 42
print(Child().value)
Dynamic dispatch also explains why a parent algorithm can be tested with a simple child that records which hook methods were called, in what order, and with what arguments. That recording child, sometimes called a test double, lets you verify the parent's workflow without needing a real child, because the workflow's structure, its order and its calls, can be checked independently of any specific child's implementation. That separation, between workflow structure and step implementation, is exactly what makes template methods testable, and it is a benefit that compensates for the tracing difficulty, provided the hooks are documented and the test doubles are simple. Testing the workflow separately from its steps also helps when a hook's implementation changes, because the workflow test should still pass, confirming that the structure is intact, while only the step's own test needs updating.
Check: Why can calling an overridable method from a parent's __init__ cause a problem in a child class?
Section 35
Wrapping is a form of composition in which one object holds another object and adds behaviour around it, so callers talk to the outer wrapper while the wrapper passes the real work on to the inner object. Logging is a clear example, because a logging wrapper can print a message before a save, call the storage object to do the actual saving, and then report that the save has finished, all without changing the storage class itself. This separation keeps each class focused on one job, since the storage class stays concerned only with storage and the logging class stays concerned only with logging, and each class can therefore be tested and changed on its own.
Wrapping also differs from inheritance in a way that matters for runtime flexibility. Inheritance fixes behaviour when a class is defined, so every instance of a subclass receives the same added behaviour whether it needs it or not. Wrapping can instead be applied to a single object after that object has been created, leaving other objects of the same class unwrapped. That choice can depend on configuration or on data that is only known while the program runs. Consider a program that saves files where most saves need no log entry but a few sensitive saves need a full audit trail. With inheritance you would need a separate subclass for logged saves, while with wrapping you can start with a plain storage object and wrap it only when a sensitive save is required.
Wrapping does have a cost that should be weighed before it is used. The wrapper must offer the same interface as the wrapped object. If the wrapped object gains a new method then the wrapper may need a matching method, or callers will be unable to reach the new method through the wrapper. This maintenance work is the price of the extra layer, and it is worth paying only when the added behaviour is genuinely useful for selected objects rather than for every object of a class.
The example below shows a logging wrapper around a simple storage class. The wrapper prints a message, delegates the save to the wrapped object, and returns whatever the wrapped object returns, so the caller sees the same result as before with a log message added as a side benefit.
class Storage:
def save(self, item):
return f"saved {item}"
class Logger:
def __init__(self, wrapped):
self.wrapped = wrapped
def save(self, item):
print("saving", item)
return self.wrapped.save(item)
plain = Storage()
logged = Logger(plain)
print(logged.save("task"))
print(plain.save("task"))
Notice the contrast between the two calls at the end of the example. The wrapped call prints a log message first, while the plain call does not, yet both calls save the item in exactly the same way. That contrast is the whole point of wrapping, because behaviour is added to one object without changing its class and without affecting other objects of the same class. Used with care, this pattern keeps classes small and focused, and it makes added behaviour easy to add, easy to remove, and easy to test in isolation.
Check: How does wrapping an object through composition allow behaviour, like logging, to be added without inheritance?
Section 36
A parent class designed for extension serves three different audiences, and each audience needs a different level of access to the class. The first audience is external callers, who use the public interface to get work done and who should not need to know how the parent works inside. The second audience is subclass authors, who extend the parent to create new behaviour and who need hooks they can override plus helpers they can call from their own methods. The third audience is the parent class itself, which uses internal details to implement its own methods and which must be free to change those details when its implementation improves.
If these three levels are not kept distinct then problems follow, because a subclass author may rely on an internal method by accident and that method may later change or disappear. The subclass then breaks even though the public interface stayed the same, and the parent author may not even know that the subclass existed, so the breakage feels unfair to both sides. A distinct subclass interface prevents that confusion by naming the methods that subclasses may override, naming the methods that subclasses may call, and stating what each hook is for and what value it must return.
Naming helps with this distinction but it is not enough on its own. A leading underscore often marks a method as private, yet in a parent class that mark can be ambiguous, because it might mean internal only or it might mean intended for subclass use. A reader cannot tell which meaning applies from the underscore alone, so documentation must resolve the ambiguity by stating plainly whether a method is for subclasses or is internal only. Without that note a subclass author might avoid a useful hook and duplicate its logic, or might use a purely internal method and break when it changes.
A common design that makes the subclass interface concrete is the template method pattern. In this pattern a public method defines the overall steps of an operation and calls a hook at the point where behaviour may vary, while subclasses override only the hook rather than copying the whole template method. This keeps the overall flow in one place, limits what a subclass can change, and protects the invariants of the parent, and that limit is a feature rather than a flaw because it channels extension through defined points.
class Base:
def _hook(self):
return "hook"
def run(self):
return self._hook()
class Child(Base):
def _hook(self):
return "child hook"
print(Base().run())
print(Child().run())
In this example the hook is marked with an underscore, yet its role still needs documentation in a real project, because the docstring should state that subclasses may override it and what value it must return. Without that note a reader might treat it as purely internal, while with that note the subclass interface becomes explicit and safe to use. For beginners this distinction may look like an advanced detail, but it is really a natural extension of public versus private, since hierarchies add a third audience that needs its own interface. Clear documentation for that interface makes both extension and change safer, because both sides benefit from a boundary that is written down rather than guessed.
Check: Why does a parent class designed for extension need a distinct subclass interface, separate from its public interface?
Section 37
A parent class can define a plugin interface: the methods every plugin must implement, plus any common behaviour plugins can reuse. The main program then discovers plugin subclasses and uses them polymorphically through the parent interface, without knowing each plugin's specific class in advance. New plugins can be added simply by defining a new subclass, without changing the main program, as long as they implement the interface correctly. That extensibility is the main benefit of a plugin system, and it grows as more plugins are added by different authors at different times.
For that to work, the plugin interface must be stable, documented, and tested. If the interface is unclear, new plugin authors will guess at what they must implement and may write plugins that fail in subtle ways. A good test is to write a sample plugin using only the parent's documentation, without reading the main program's code. If that sample is straightforward, the interface is clear; if it requires guesswork or workarounds, the interface should be simplified before real plugins are built on it.
The parent should also keep its helper behaviour minimal. A plugin parent that provides too much shared behaviour, or behaviour that conflicts with a plugin's specific needs, will discourage new plugins rather than encourage them. The parent is both a contract, stating what plugins must do, and a helper, providing what they can reuse, and those two roles should be balanced so the contract stays simple while the helper stays genuinely useful.
Plugin systems show inheritance used for extensibility rather than mere code reuse. The purpose is not just to share implementation, but to allow third parties to extend a program without modifying its core. That purpose should guide the design: keep the interface small, document it well, and test it with a sample plugin before relying on it.
class Plugin:
def run(self):
raise NotImplementedError
class MyPlugin(Plugin):
def run(self):
return "plugin ran"
print(MyPlugin().run())
A plugin interface should also provide a way to discover plugins and to report failures when a plugin does not implement the interface correctly. Discovery might be by scanning a folder, by importing named modules, or by registering subclasses explicitly, and each method has trade-offs in explicitness and in ease of use. Failure reporting matters just as much: a plugin that fails to implement a required method should produce a clear error at load time, naming the plugin and the missing method, rather than failing later, in the middle of a run, when the missing method is finally called, because early, clear failure saves debugging time and prevents a faulty plugin from corrupting a run. Clear discovery and clear failure reporting together make a plugin system welcoming to new authors, rather than frustrating.
Check: How does a parent class act as a plugin interface, allowing new subclasses to extend a program without changing it?
Section 38
Composition can forward many methods from a container to a contained object. Writing each forwarding method by hand is tedious when the contained interface is large, but it has a major advantage: the container's interface is explicit. A reader can see, in the container's code, exactly which methods it provides, what arguments they take, and what added behaviour, such as logging or validation, each one performs. That explicitness makes the container easier to understand, document, and test.
Python offers an automatic alternative through __getattr__, which is called when an attribute lookup fails. A container can use it to delegate unknown attributes to the contained object dynamically, without defining each method. That creates a transparent wrapper whose interface appears to match the contained object's, plus the container's own methods. It is convenient, especially when the contained interface is large or changes often, because the wrapper need not be updated for each new method.
The cost is clarity. With automatic forwarding, the container's interface is implicit: it cannot be seen in its definition, and must be inferred from the contained object's interface and the __getattr__ implementation. Readers will wonder where a method comes from when they see it called on the container but cannot find it in its code. Documentation generators and type checkers also struggle with dynamic interfaces, which makes the wrapper harder to work with in larger projects.
For most programs, explicit forwarding is the better choice, even though it requires writing a few extra methods. Those methods make the interface visible and therefore understandable. Reserve __getattr__ forwarding for cases where the contained interface is large or dynamic and explicit forwarding would be impractical, and document the forwarding clearly when you do use it.
class Wrapper:
def __init__(self, wrapped):
self._wrapped = wrapped
def __getattr__(self, name):
return getattr(self._wrapped, name)
Automatic forwarding also interacts with special methods, which Python looks up on the class, not on the instance, and therefore does not route through __getattr__ in the same way as ordinary methods. A wrapper that relies on __getattr__ may therefore fail to forward special methods such as iteration or length, even though it forwards ordinary methods correctly, which is a surprising inconsistency that can cause bugs when a wrapped object is used in a context that expects those protocols. Explicit forwarding, or explicitly defining the special methods on the wrapper, avoids that inconsistency, because each protocol method is visible in the wrapper's definition and can be tested directly. That special-method gap is a good reason to test a wrapper thoroughly with the same operations, including iteration and length, that you expect callers to use, rather than only with ordinary method calls. Explicit forwarding also makes it easier to add logging or validation to a single method without affecting others, because each method is a separate place where that added behaviour can be inserted.
Check: Why is explicit method forwarding usually clearer than automatic forwarding through __getattr__, despite being more verbose?
Section 39
Serialisation, converting an object to bytes for storage or transmission and reconstructing it later, adds complications for hierarchies and shared objects. An object's class, its attributes, and its relationships must all survive the round trip, so that the reconstructed object behaves like the original. Inheritance complicates this because a child's attributes include those inherited from its parent, and all of them must be captured in the correct form. Composition complicates it because a contained object may be shared between several containers, and pickling must preserve that sharing rather than creating a separate copy for each container, which would break the sharing that the program relied on.
Those issues are easy to overlook in programs tested only in memory. A class design that works perfectly while objects are alive may fail, or behave incorrectly, when its objects are saved and loaded, because a shared object becomes duplicated or a hierarchy attribute is lost. The remedy is to test persistence explicitly: round-trip objects through pickling or another format and check that reconstructed objects have the same attributes, the same relationships, and the same sharing where sharing matters.
For most learning programs, the standard library's pickling will handle ordinary classes correctly, and you do not need custom serialisation code. The lesson is simply to be aware that persistence is part of a class's lifecycle, not an afterthought. When you design classes that will be saved, think about which attributes must be preserved, whether shared objects must stay shared, and whether a version marker is needed so old saved data can still be loaded after the class changes.
That awareness, tested with a round trip, prevents a class of bugs that only appear after a program restarts and reloads its data, which is often the worst time to discover them.
import pickle
class Book:
def __init__(self, title):
self.title = title
data = pickle.dumps(Book("Dune"))
print(pickle.loads(data).title)
Versioning is another serialisation concern: a class that gains a new attribute in a later version must still be able to load data saved by the earlier version, by filling in a sensible default for the missing attribute, and it must handle data that contains an attribute it no longer uses, by ignoring it rather than failing. That tolerance, like tolerance in file loading, makes persistence robust across program updates, which is essential for user data that should not be lost when a program is upgraded. Without it, every class change risks breaking every saved file, which is a cost users should not have to pay for an improvement they did not ask to lose data over. Testing a round trip after every class change that affects saved data is a small habit that protects users from silent data loss.
Check: Why can serialising an object be more complex when it is part of a hierarchy or shares contained objects with other objects?
Section 40
When you must choose between inheritance and composition the most important question is simple, and it should be asked before any code is written. Is the new class a kind of the old class, or does it merely use one? That question is more useful than asking which pattern saves more code, because code reuse alone is a weak reason to choose either pattern and meaning should come first, with structure following meaning. A true is-a relationship means the new class can stand in for the old class everywhere. A savings account is a kind of bank account and an ebook is a kind of book, and code written for the general class should work with the specific class without special cases or type checks.
A has-a relationship means the new class contains or uses another object, so a car has an engine and a library has books, yet neither pair is an is-a relationship. A car is not a kind of engine and a library is not a kind of book, so modelling a has-a relationship with inheritance creates a false claim that one thing is another thing when it is not. That false claim will cause trouble later, because methods will be inherited that make no sense for the child and callers will be misled about what the object can actually do.
The practical way to apply the question is to test substitution with a concrete example. Imagine a real use of the parent class, then try the child in its place, and watch for surprises in behaviour, in meaning, or in the methods that become available. If the result is correct and unsurprising then inheritance is worth considering, but if the result is confusing or wrong then composition is the better choice. This test also helps when a class needs most of a parent behaviour but not all of it. Needing most of the behaviour is not enough, and if it needs only part of the behaviour then it probably uses the parent rather than being one.
Composition also keeps future change easier in doubtful cases, because a composed part can be swapped for a different part, tested on its own, and reused by other classes without a hierarchy. Inheritance locks the child into the parent family, which is fine when the family is stable and the meaning is clear, but becomes a burden when the family grows or changes direction.
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof"
class Library:
def __init__(self, books):
self.books = books
print(Dog().speak())
print(len(Library(["Dune"]).books))
The example above shows both patterns side by side in a compact form. A dog inherits from an animal because a dog is an animal, while a library contains books rather than inheriting from them, because a library is not a book. Return to this question whenever a design feels awkward, because awkwardness often signals a mismatch between meaning and structure. A class that inherits methods it never uses, a class that fights its parent, or a class that needs a different parent at runtime are all warning signs. In each case asking again whether the relationship is is-a or has-a can guide the refactor.
Check: What is the single most important question to ask when choosing between inheritance and composition for a relationship?
Section 41
When a hierarchy is used with type hints, which are covered in a later chapter, a child type is considered compatible with a parent type. A function annotated to accept a parent can also accept a child, mirroring runtime substitutability. A function annotated to accept a child cannot accept a general parent, because the parent may not provide the child's specific methods. That static compatibility mirrors the runtime rule, and a well-designed hierarchy will usually type-check cleanly as well as run correctly.
Type checking, however, only verifies interface compatibility, not behavioural compatibility. A child may keep the same method signatures while changing a method's meaning, and a type checker will not detect that. Behavioural compatibility, the deeper requirement of the Liskov principle, must still be verified through tests that check results, not just types. Type hints are therefore a useful complement to testing, not a replacement for it.
Type hints also interact with composition. A containing class that receives a part as an argument can annotate that argument with the part's type, or better, with a protocol describing only the methods it needs. That is more flexible, because it allows any object providing those methods, including simple test doubles, to be used as the part, without requiring it to inherit from a specific parent. Annotating with the most general type that still provides the needed methods keeps the class reusable.
The guideline is the same as for runtime design: describe only what you need, and no more. A specific type annotation that is unnecessarily restrictive can force callers to inherit from an arbitrary parent merely to satisfy the annotation, which is a form of coupling that composition is meant to avoid. General, protocol-based annotations preserve flexibility while still giving readers and checkers useful information.
class Book:
def __init__(self, title):
self.title = title
class Library:
def __init__(self, books):
self.books = books
print(len(Library([Book("Dune")]).books))
Type annotations for hierarchies also help readers, because they make the expected relationship explicit in a signature, rather than leaving it to be inferred from method calls or from documentation alone. A reader who sees a parameter annotated with a parent type knows immediately that any child is acceptable, while a reader who sees a child type knows that only that specific kind, or its own children, will do. That explicitness in signatures is a powerful form of documentation that is checked automatically, which makes it more reliable than prose alone, even though it does not replace behavioural tests, which remain necessary to verify that a child actually behaves like its parent in practice, not just in type, and not just in theory, in real programs, every day. Type annotations also make refactoring safer, because a checker will flag places where a changed parent type no longer matches its children.
Check: How does type compatibility mirror runtime substitutability for a child class used where a parent is expected?
Section 42
A hierarchy's evolution can be managed by versioning its interface. A new version of a parent class, with new methods or changed behaviour, can be introduced alongside the old version, or with a deprecation period during which old methods continue to work but warn that they will be removed. That gives child authors and callers time to update, rather than breaking them immediately with a sudden change. That courtesy is especially important for a parent used by many children or by external code, where a sudden break would affect many programs at once.
A gradual transition, with warnings and documentation of what has changed and how to update, is far more helpful than a sudden break that forces immediate updates under pressure. Some users cannot update immediately, due to time, resources, or dependencies on other code that has not yet updated. Deprecation gives them a timeline and a plan, which reduces fear of updating and encourages adoption of new versions, because updates become predictable rather than disruptive.
Versioning also requires deciding, for each change, whether it is breaking or not. A breaking change requires callers or child authors to update their code, such as removing a method, changing a signature, or changing behaviour in a way that affects results. A non-breaking change adds a new method, or adds an optional argument with a default that preserves old behaviour, while providing new capabilities for new callers. That distinction should be made deliberately for each change, by asking whether old code will still work without modification.
That discipline requires thinking about compatibility from users' perspectives, not just from the author's. A change that seems small, or that does not affect the parent's own tests, may still break child uses that the author did not anticipate. Testing with representative children and callers, and documenting each change as breaking or non-breaking in release notes, helps users judge whether they need to update and how urgently, which maintains trust in a shared hierarchy over time.
class Book:
def __init__(self, title):
self.title = title
print(Book("Dune").title)
Deprecation warnings should also be visible in a way that users cannot easily miss, such as through warnings that appear when deprecated code is used, rather than only through release notes that users may not read. A warning at runtime, naming the deprecated method clearly and suggesting its replacement explicitly, reaches users at the moment they are affected, which is far more effective than a note in documentation that they may never open. That visibility, combined with a clear timeline and clear replacement guidance, turns deprecation from a passive note into an active guide, helping users update before removal actually breaks their code. A deprecation period should be long enough for users to plan, test, and deploy their updates without rushing.
Check: Why should breaking changes to a parent class be introduced through deprecation rather than suddenly?
Section 43
When a hierarchy is used in a team, communication becomes as important as code. Different team members may work on different classes in the hierarchy at the same time, and may make changes that affect each other without realising it, unless they communicate through documentation, code reviews, and discussions about the hierarchy's design, its invariants, and its intended evolution. A change by one person, without communication, can break another person's work in a way that is hard to diagnose, because the breakage may not appear until the other person's code is run with the changed parent, and the cause may not be obvious from code that appears unchanged and correct in isolation.
That social aspect is often overlooked in technical discussions that focus on syntax and patterns, but it is essential in practice. A hierarchy is a shared structure that many people depend on and may change, and a change to it should be reviewed by child authors before it is merged, and discussed before it is implemented, so its impact can be assessed by those who will be affected. That process takes time, but it saves far more time than it costs, by preventing breakages that would otherwise require debugging and fixing after the fact, under pressure and with less information about the change that caused them.
Team communication also needs a shared vocabulary for discussing relationships, such as is-a, has-a, parent, child, override, and delegate, so team members can discuss a hierarchy without ambiguity about what they mean. Without agreed terms, a discussion about whether a relationship is is-a or has-a becomes a discussion about terminology, which wastes time and causes confusion. Agreeing on terms in advance, and using them consistently in code, documentation, and discussion, helps a team think clearly about design, because clear language leads to clear thinking.
That discipline applies to all technical communication, but it is especially important for hierarchies, where relationships are central and where ambiguous language about relationships can lead to ambiguous designs that are hard to understand and maintain. Clear, shared vocabulary is therefore part of good hierarchy design, not an optional extra.
class Team:
def __init__(self, name):
self.name = name
print(Team("Library").name)
Communication about hierarchies should also happen in code reviews, where a proposed parent change can be examined by child authors before it is merged, rather than after it has already broken their code. A review that includes the hierarchy's stakeholders, not just its author, is more likely to catch impacts that the author overlooked, because each stakeholder brings knowledge of how they use the hierarchy, which the author may not share. That collaborative review is therefore a practical safeguard, not just a social nicety, and it should be part of the workflow for any shared hierarchy.
Check: Why does teamwork make communication about a hierarchy's design as important as its code?
Section 44
A hierarchy can be visualised through diagrams that show classes as boxes and relationships as arrows, with inheritance arrows pointing from child to parent and composition arrows pointing from container to contained. Those diagrams are not a substitute for code or documentation, but they can help readers understand a hierarchy's structure at a glance, especially for a large hierarchy where classes are spread across modules and relationships are implicit in parent lists and attribute assignments rather than explicit in a single place. A diagram makes structure visible in one place, which helps readers grasp the overall shape before diving into individual classes.
Diagrams can also help designers spot problems, such as deep hierarchies, tangled relationships, or classes that do not fit their place, which may not be obvious from code alone but become visible as visual complexity. That visibility is a benefit beyond mere presentation: it can actually improve design by making problems apparent that were previously hidden in code. An informal diagram showing the main classes and relationships is often more useful than a formal diagram showing every detail, because the formal one may be hard to read and may quickly become out of date.
That maintenance risk is real. A diagram created once and never updated becomes misleading rather than helpful as code evolves. To avoid that, keep diagrams simple, update them when the hierarchy changes, or generate them from code automatically so they cannot drift out of date without manual effort. That balance, between the benefits of visualisation and its maintenance costs, should guide whether a hierarchy warrants a diagram at all. For a small hierarchy, code alone is often sufficient, and a diagram would add little beyond what a few lines already show clearly.
Diagrams should also be used sparingly in documentation, alongside prose that explains why a hierarchy is designed as it is, what its invariants are, and how to use and extend it correctly. A diagram shows structure, but not rationale or usage, which must be explained in words. Together, diagram and prose give readers a complete picture that neither alone can provide.
class Animal:
pass
class Dog(Animal):
pass
print(issubclass(Dog, Animal))
Generating diagrams from code also has a benefit beyond freshness: it ensures that the diagram reflects the actual hierarchy, including classes that an author might have forgotten to include in a hand-drawn diagram, or relationships that were added without updating the drawing. An automatically generated diagram may be less pretty than a hand-drawn one, but its accuracy makes it more trustworthy, which is the quality that matters most for a diagram used to understand a system. Accuracy, in diagrams as in documentation, should always take precedence over polish and presentation in every diagram.
Check: How can a simple diagram help you spot problems in a large hierarchy that code alone might hide?
Section 45
When you finish designing a hierarchy, it is useful to review it against a checklist, by asking, for each class, whether its responsibility is clear and focused, whether its interface is small and consistent, whether its methods respect parent contracts, whether its relationships are genuinely is-a or has-a as modelled, whether its hierarchy is shallow, whether its documentation explains its place in the hierarchy, and whether its tests cover both individual behaviour and polymorphic use. That systematic review can catch design problems that a casual review might miss, where attention is drawn to the most salient aspect while other equally important aspects are ignored until they cause problems later, when they are harder to fix because the design has already been used in many places.
A checklist is especially valuable for hierarchies, where a problem in one class can affect many others through inheritance or composition, and where early detection is far cheaper than late detection after the problem has propagated to clients. A problem that is caught at its source, before it spreads, can be fixed once; a problem that spreads must be fixed in many places, and its symptoms may appear in client code far from the hierarchy itself, making diagnosis harder. Systematic review therefore prevents a diagnostic difficulty that is specific to hierarchies, where source and symptom can be far apart due to polymorphism hiding the source behind a general interface.
A checklist review should also be done by someone other than the hierarchy's author, where possible. An author may be blind to problems in their own design due to familiarity with their intentions, which may not be clear to others who must use or maintain the hierarchy without sharing those intentions. A reviewer, reading the hierarchy as a new user would, can note where understanding breaks down or where documentation does not answer questions that arise. Those gaps should be filled before the hierarchy is released for shared use, rather than after users have already formed incorrect understandings that are hard to correct.
That peer review, combined with a checklist, turns review from a casual glance into a structured check that improves both clarity and correctness, which is essential for a hierarchy that will be used by others.
class Book:
def __init__(self, title):
self.title = title
print(Book("Dune").title)
A checklist should also include a question about whether the hierarchy's tests will catch a regression if a parent method changes, because a hierarchy without tests that exercise parent behaviour through children is vulnerable to silent breakage. If no test checks that children still satisfy parent contracts after a parent change, then a parent change that breaks a child may go unnoticed until a user reports it, which is too late for a cheap fix. Including test coverage in the checklist therefore connects design review with testing review, ensuring both are checked together.
Check: What should a hierarchy design checklist cover, beyond checking individual methods for correctness?
Section 46
A hierarchy's test suite should include tests for the parent class, for each child class, for polymorphic use, and for structural properties where structure is contractual. Those tests should be organised by class and by relationship, so that a failing test points clearly to the class or relationship that broke, rather than to the hierarchy as a whole. That organisation is especially valuable for hierarchies, where a failure in one class can cause failures in many others through inheritance. Without organisation, a single root cause can produce many failures, making it hard to tell which failure is the root and which are merely consequences.
A useful diagnostic strategy is to check parent tests before child tests. If parent tests fail and child tests fail in the same way, the pattern indicates a parent problem rather than many independent child problems. Fixing the parent first, then re-running child tests, will often clear the cascade. That pattern recognition is supported by organised tests and hindered by disorganised ones, which obscure the pattern among failures and can lead to wasted effort chasing consequences as if they were causes.
Test organisation should also include edge cases at each level, such as empty collections in a container class, invalid transitions in a stateful child, or missing methods in a plugin subclass. Edge cases at one level can affect other levels through inheritance or composition, and a hierarchy that passes happy-path tests at each level may still fail on edge cases that reveal inconsistencies between levels, such as a parent assuming a non-empty collection while a child allows an empty one. Edge-case tests surface those differing assumptions explicitly, so they can be made consistent or documented as intentional differences, rather than being discovered by accident in production where they are harder to diagnose and more costly to fix.
Planning test organisation alongside hierarchy design, rather than as an afterthought, keeps the suite useful for diagnosis as the hierarchy grows.
class Book:
def __init__(self, title):
self.title = title
print(Book("Dune").title)
Organised tests also make it easier for a new team member to understand a hierarchy, because they can read tests grouped by class and see, for each class, what behaviour is expected, in what order, and with what edge cases. That readability turns the test suite into documentation, in executable form, which is especially valuable for hierarchies, where behaviour is distributed across levels and where a new member might otherwise struggle to see how levels interact. A well-organised suite therefore serves onboarding and learning as well as diagnosis and debugging for everyone on the team. Test organisation also helps when a hierarchy is refactored, because tests grouped by class make it clear which classes are affected by a structural change and which are not.
Check: How should hierarchy tests be organised so that a parent failure is not mistaken for many independent child failures?
Section 47
When a hierarchy is documented, its documentation should include a changelog, or history, of changes to its classes, methods, and relationships, so readers familiar with an older version can understand what has changed and how to update their code, without comparing old and new code line by line to find differences. That comparison is tedious and error-prone, and can miss subtle changes, such as a method that has changed its behaviour without changing its signature, or a relationship that has changed from inheritance to composition without changing the public methods in an obvious way. Such a structural change can still break clients that relied on the old relationship, for example code that checked isinstance against an old parent, and a changelog that notes the relationship change helps those clients update, whereas a method list alone would not convey the impact.
A thorough changelog therefore documents structural changes alongside method changes, so users can assess a change's impact completely rather than partially. That completeness matters because users may depend on a hierarchy's structure as well as on its methods, in ways the author may not have anticipated, and a changelog that ignores structure will underestimate the impact and lead to incomplete updates and bugs that appear only after updating.
A changelog should also note deprecations and removals, with timelines for when deprecated methods or relationships will be removed entirely, so users can plan their updates rather than being surprised by a removal in a future version without warning. That timeline is especially important for hierarchies, where a deprecated method in a parent may affect many children and clients, and where sudden removal would break many programs at once. Deprecation warnings in code, together with changelog entries stating clearly what is deprecated, why, and when it will be removed, give users fair warning and time to update, which is a reasonable approach to evolving shared code.
That gradual, predictable evolution, supported by changelogs and deprecations, balances the need to improve a hierarchy against the need not to break its users unexpectedly.
class Book:
def __init__(self, title):
self.title = title
print(Book("Dune").title)
A changelog entry should also be written in terms of user impact, not just in terms of internal changes, because users need to know whether they must act, and if so, what action to take, rather than merely knowing that a class was refactored internally. An entry that says a parent was split, without explaining which clients are affected or how to update, leaves users to guess at the impact, which is almost as unhelpful as no entry at all. User-focused changelog writing, with clear action guidance, is therefore a skill worth developing alongside the technical skill of making the change itself.
Check: Why should a hierarchy changelog document structural changes, like a parent change, as well as method changes?
Section 48
A hierarchy can be used with data classes, which simplify class definition by generating methods such as __init__, __repr__, and __eq__ automatically from attribute declarations. That convenience can reduce boilerplate in simple classes, but it interacts with inheritance in ways that should be understood before using data classes in a hierarchy. A data class inheriting from another data class will generate methods based on its own attributes plus those inherited from its parent, in an order determined by the hierarchy and by the data class machinery, which can be surprising if the order is not as expected. If a parent is not a data class but a regular class, the child's generated methods may not include parent attributes correctly, or may fail to initialise them correctly through super().
That complexity arises from combining automatic generation with inheritance, and it should be considered when deciding whether to use data classes in a hierarchy or regular classes with manually written methods. Manual methods, while more verbose, are more explicit and easier to understand in a hierarchy, where the order of initialisation and the inclusion of attributes must be clear and predictable without relying on generation rules that may not be obvious to a reader unfamiliar with data class machinery. That clarity benefit should be weighed against the convenience of automatic generation, especially in hierarchies where clarity is already challenged by inheritance itself, and where adding generation on top can make a hierarchy even harder to understand.
That does not mean data classes should never be used in hierarchies. In a simple hierarchy of value objects, where each class adds a few attributes and where generation works predictably, data classes can be a clear and concise way to define the hierarchy without boilerplate. In a complex hierarchy with custom initialisation or non-trivial equality, manual methods may be clearer and safer than generation that would need to be overridden or customised in ways that negate its convenience.
The key is consistency: choose one approach for a hierarchy, document it, and follow it as the hierarchy grows, rather than mixing generated and manual styles ad hoc, which makes the system harder to predict and maintain.
from dataclasses import dataclass
@dataclass
class Book:
title: str
print(Book("Dune"))
Consistency in class definition style also helps tooling, because documentation generators, type checkers, and linters can work more reliably with a uniform style than with a mixture, where each class must be handled differently. A tool that understands data classes may not understand manual classes in the same way, and vice versa, leading to incomplete documentation or missed type errors for some classes in a hierarchy. Uniform style therefore improves not just human readability, but also the reliability of automated tools that support development.
Check: Why should a hierarchy use a consistent style for defining its classes, rather than mixing data classes and manual classes ad hoc?
Section 49
When you design a hierarchy, you should also consider its future, by asking how it might need to change as requirements evolve, as new kinds are added, as old kinds are removed, or as the domain itself changes. You cannot predict every future change in detail, but you can design for changeability, through shallow hierarchies, clear interfaces, documented invariants, and tests that make change safer and easier when it comes. That future-proofing does not require predicting the future; it requires making the present design easy to change in the future, which is a more achievable goal than prediction and one that should guide hierarchy design from the start.
Favouring simplicity over cleverness, clarity over conciseness, and flexibility through composition where appropriate over rigidity through deep inheritance are choices that make future change easier. A simple, clear, flexible hierarchy can be refactored to accommodate new requirements with less effort and less risk than a complex, clever, rigid hierarchy optimised for its current requirements at the expense of future changeability. That is a common design mistake: over-fitting to current needs, so the hierarchy becomes brittle when needs change, as they inevitably will in any real program that lives beyond its initial release.
Future-proofing also means avoiding premature optimisation for future kinds that may never be needed, such as creating intermediate parent classes for kinds that do not yet exist in the domain. That speculative design adds complexity without benefit and makes the hierarchy harder to understand for its current kinds, because readers must navigate through levels that serve no current purpose and may never serve a future one. A just-in-time approach is better: add structure only when a new kind actually arises and actually requires a new class or level, while keeping the current design easy to change when that happens.
That distinction, between designing for changeability and designing for predicted changes, is central to good design: the former is prudent and sustainable over time, the latter is speculative and wasteful in practice, and the former should be pursued while the latter is avoided.
class Book:
def __init__(self, title):
self.title = title
print(Book("Dune").title)
Changeability also depends on keeping a hierarchy's public interface stable, even as its internals evolve, because clients depend on the interface, not on internals, and a stable interface allows internals to change without breaking clients. That stability is achieved by distinguishing public methods, which should change rarely and only through deprecation, from internal methods, which can change freely, and by documenting that distinction clearly. A hierarchy with a stable public interface and flexible internals is changeable in the ways that matter, without being unstable for its users. Changeable designs also tend to be simpler designs, because simplicity leaves room for future adaptation without tangled dependencies.
Check: What is the difference between designing a hierarchy for changeability and designing it for predicted future kinds?
Section 50
The final message of this chapter returns to the simple idea with which it began: inheritance is about is-a, and composition is about has-a, and choosing between them correctly for each relationship is the most important design skill in this chapter. That choice matters more than knowing the syntax of super(), the details of the method resolution order, or the specifics of abstract base classes or mixins, because those features are tools that serve the basic choice. Without that basic choice made correctly, the tools will be misapplied to relationships that do not fit them, leading to hierarchies that are confusing, fragile, and hard to maintain, regardless of how skilfully the tools themselves are used in isolation.
Design begins with understanding relationships in the domain and modelling them accurately in code, and tools and patterns are secondary to that understanding. That understanding should be developed first, through thinking about the domain in plain language, as discussed in the previous chapter for classes in general and here for relationships specifically. Asking, in words, whether one thing is a kind of another or has another as a part is the most reliable guide to choosing between inheritance and composition, and it should be applied before writing any code for a hierarchy, by discussing the relationship with colleagues or in a design note, and by checking whether the plain-language description matches the code you are about to write.
That cycle, of design, discussion, implementation, and review, should be repeated for each important relationship until its modelling is clear and its code is clear as a consequence. A mismatch between plain language and code will reveal itself as awkward client code, or as a hierarchy that is hard to extend or test, which are symptoms of a mis-modelled relationship that should prompt re-examination in plain language and refactoring to match the corrected understanding. Catching mis-modelling early, before more code depends on it, avoids a cost that only grows as dependents accumulate.
That thoughtful approach, practised through building programs that use inheritance and composition well and through reviewing and refactoring them when relationships become unclear, is what will make you a thoughtful programmer, not just a competent one, and it is the approach this chapter hopes to encourage as you move on to later topics.
class Book:
def __init__(self, title):
self.title = title
class Ebook(Book):
pass
print(Ebook("Dune").title)
Plain-language thinking also helps when a hierarchy is presented to non-programmers, such as domain experts or users, because they can validate the relationships in terms they understand, without needing to read code. A domain expert who hears that an ebook is a kind of book, or that a library has books, can confirm or correct that description based on their knowledge of the domain, catching modelling errors that a programmer working alone might miss. That validation by domain experts is a powerful check on design accuracy.
Check: Why is choosing correctly between is-a and has-a more important than mastering advanced inheritance features?
Practical Task
Build a small program that models a library using both inheritance and composition: a Publication base class (title, with a describe method), a Book subclass (adds author) and a Magazine subclass (adds issue number), each overriding describe appropriately and calling super().__init__ correctly; and a Library class that composes a list of publications (not inheriting from a list), with methods to add a publication, list all publications, and find publications by title. Write tests that check: a Book is an instance of Publication; describe differs appropriately for book and magazine; the library can hold mixed publications and list them polymorphically; and finding a missing title returns None. Also test that a Library does not expose list methods directly (it composes, rather than inherits, a list).
Chapter Questions
- What is the difference between inheritance (is-a) and composition (has-a), and how do you decide which to use for a given relationship?
- Explain the role of
super()in a child class's__init__, and why skipping it can break parent invariants. - What is the Liskov substitution principle, and how does it constrain what a child class may do when overriding a parent method?
- Why is composition often preferred over inheritance for reuse, and what are the main risks of deep inheritance hierarchies?
- Describe how you would test a hierarchy, including polymorphic tests that check substitutability.