Inheritance, Overriding, and Composition
Context
A class can be built on top of another, taking everything the first defines and adding or replacing parts. A replaced method can still call the original, and an alternative is to hold another object inside rather than inherit from it.
The libraries ahead lean on the first pattern heavily, and on a shared interface such as fit and predict that works across models that share nothing internally. In your own code it is how three loaders for three file formats share one validation routine and differ only in how bytes are read. Holding an object inside is the other half of the choice, and it fits better whenever the relationship is one of use rather than of kind.
Choosing inheritance for a relationship that is not one of kind produces a hierarchy that has to be fought later, and the more immediate failure is a replaced setup step that never calls the original, so the object is missing the attributes the parent was supposed to establish and fails with a missing attribute on first use, which can come long after creation. The previous lesson gave objects their own data, this lesson lets classes share and specialize.
Goal
- Derive a subclass from a superclass so it inherits the parent's attributes and methods.
- Override an inherited method to specialize a subclass's behavior.
- Call the parent's version of a method with
super(), extending rather than fully replacing it. - Distinguish an "is-a" relationship (inheritance) from a "has-a" relationship (composition) and choose correctly.
- Build a class by composing instances of other classes and explain when this beats inheritance.
Prerequisites
Optional, neither is a gate. The lesson stands on its own, they are here if you want subclassing demonstrated before you write one.
- YouTube - "Python OOP Tutorial 4: Inheritance - Creating Subclasses" by Corey Schafer. The full video, covering subclassing, overriding and
super()live. - Official docs - The Python Tutorial, "Inheritance". The authoritative model of subclassing and of how a method is resolved.
Table of Contents
- Inheritance
- Subclass and Superclass
- Method Override
- super()
- Composition
Theory
Inheritance
What. Inheritance is deriving a new class from an existing one so that the new class automatically gains the existing class's attributes and methods.
Why. When several kinds of thing share most of their behavior and differ only in details, inheritance lets you write the shared part once in a parent and let each specialized child reuse it instead of copying it.
When. You reach for inheritance when the relationship is genuinely "is-a", a MinMaxScaler is a kind of Transform, and the child legitimately shares the parent's interface and most of its behavior.
You declare inheritance by naming the parent in parentheses after the new class: class Child(Parent):. From that point the child starts with everything the parent has. Any method defined on the parent can be called on a child instance as if it were defined there, because the child inherits it. This is the reuse engine of object-oriented design, the common behavior lives in one place, and specialized types build on top of it rather than restating it.
Inheritance models a hierarchy of kinds. A general Transform class might know how to describe itself and hold a name, and many specific transforms, scaling, normalizing, encoding, are all kinds of Transform that share that common ground. Writing the shared logic once in the parent means a bug fixed there is fixed for every child that does not override that method, and a capability added there is gained by every child that does not already define that name.
class Transform:
def __init__(self, name):
self.name = name # every transform has a name
# shared behavior, available to this class AND every subclass
def describe(self):
return f"<Transform: {self.name}>"
# 'Scaler' inherits everything from 'Transform'
class Scaler(Transform):
pass # no new code yet, but it already HAS Transform's behavior
s = Scaler("minmax") # uses Transform's __init__ via inheritance
print(s.name) # minmax
print(s.describe()) # <Transform: minmax>A subclass starts life with the full set of its parent's capabilities, which is why this one works while adding nothing. Even so it is meaningful, it establishes a distinct type that is a kind of Transform.
DON'T:
- DON'T use inheritance just to reuse a handy method when the child is not genuinely a kind of the parent, that misuse leads to brittle, confusing hierarchies (composition, later, is the right tool there).
- DON'T redefine the parent's
__init__in the child unless you need to, an empty subclass correctly inherits the parent's initializer as-is.
Subclass and Superclass
What. The subclass is the derived child, the superclass is the parent it derives from, two names for the two ends of one inheritance relationship.
Why. Naming the relationship precisely lets you talk about who provides behavior (the superclass) and who specializes it (the subclass), which is essential once hierarchies have several levels.
When. You use the vocabulary constantly when reasoning about inheritance: "the subclass overrides the superclass's method", "super() calls up to the superclass".
A subclass is a kind of its superclass, and this is more than a slogan, Python treats it literally. An instance of a subclass passes an isinstance check against its superclass, because it genuinely is one. This is the substitution principle in action: anywhere the code expects a Transform, a Scaler instance is accepted, because a Scaler is a Transform. That promise, kept by a subclass that honors its parent, is what makes inheritance safe to rely on, and it is why isinstance (rather than an exact-type check) is the inheritance-aware way to ask "is this the kind of thing I need".
Hierarchies can be deeper than one level, a StandardScaler could subclass Scaler, which subclasses Transform, and every "is-a" link holds transitively. A StandardScaler is a Scaler and also a Transform. The vocabulary scales with the hierarchy: each class is a subclass of the one above and a superclass of the ones below.
class Transform:
def __init__(self, name):
self.name = name
class Scaler(Transform): # Scaler is a SUBCLASS of Transform
pass
s = Scaler("minmax")
# a subclass instance IS an instance of its superclass
print(isinstance(s, Scaler)) # True
print(isinstance(s, Transform)) # True (is-a holds)
print(isinstance(s, object)) # True (everything is an object)
# the exact class, for contrast
print(type(s) is Scaler) # True
print(type(s) is Transform) # False (exact type is Scaler)The two kinds of check are asking different questions. isinstance asks "is this a Transform or a kind of one" and respects the hierarchy, while an exact-type check asks "is this precisely a Transform" and wrongly rejects every subclass, which is why the first is the one you want.
DON'T:
- DON'T use
type(x) is Superclassto test membership in a hierarchy, it rejects valid subclass instances, useisinstance(x, Superclass). - DON'T confuse "is a subclass of" (a relationship between classes) with "is an instance of" (a relationship between an object and a class),
issubclasstests the former,isinstancethe latter.
Method Override
What. A method override is a subclass defining a method with the same name as one it inherited, replacing the inherited behavior for instances of that subclass.
Why. Specialization is the whole point of subclassing, a Scaler needs its own describe or its own transform logic, and overriding lets it supply that without disturbing the parent or its siblings.
When. You override whenever a subclass must behave differently from its parent for a particular method, which is the most common reason a subclass has any body at all.
When you call a method on an instance, Python looks for that method on the instance's own class first, and only falls through to the superclass if the subclass does not define it. So if Scaler defines its own describe, calling describe() on a Scaler runs the Scaler version, not the Transform one, the subclass's definition overrides the inherited one. Other subclasses that did not override still get the parent's version, so each type gets the behavior appropriate to it.
This is polymorphism in its everyday form: the same method name, called the same way, does the right thing for whatever object it is called on. A loop over a list of mixed Transform subclasses can call describe() on each, and each responds with the version its class has, its own override where it defines one and the inherited one where it does not. The caller writes one line, the objects supply the variation.
class Transform:
def __init__(self, name):
self.name = name
def describe(self):
return f"<Transform: {self.name}>"
# a method subclasses are MEANT to override with real logic
def apply(self, x):
# the base version does nothing useful, just returns input
return x
class Scaler(Transform):
# OVERRIDE: Scaler supplies its own apply behavior
def apply(self, x):
return x / 100 # pretend min-max scaling into 0..1
class Logger(Transform):
# OVERRIDE: a different describe, same name, different behavior
def describe(self):
return f"[Logger named {self.name}]"
s = Scaler("minmax")
g = Logger("audit")
print(s.apply(50)) # 0.5 (Scaler's overridden apply)
print(g.apply(50)) # 50 (inherited base apply, not overridden)
print(s.describe()) # <Transform: minmax> (inherited describe)
print(g.describe()) # [Logger named audit] (Logger's overridden describe)Each subclass replaces exactly the behavior it needs and inherits the rest, so the same call adapts to whichever object it lands on. That adaptability is polymorphism, and it is what lets one piece of code drive many related types without asking any of them what they are.
DON'T:
- DON'T change a method's expected meaning when overriding, an overridden
applyshould still apply something, not secretly do something unrelated, or polymorphic callers break. - DON'T copy the parent's entire method just to add one line, when you want to extend rather than replace, use
super(), covered next.
super()
What. super() gives a subclass a handle to its superclass, letting an overriding method call the parent's version of that same method.
Why. Often a subclass wants to extend the parent's behavior, not discard it, run the parent's setup and then add its own, and super() is how you reuse the parent's logic instead of duplicating it.
When. You use super() most often inside an overridden __init__, to let the parent initialize its part before the child initializes its own, and in any override that should build on the parent's result.
The most important use is in __init__. When a subclass needs its own attributes plus the parent's, it defines its own __init__, calls super().__init__(...) to run the parent's initializer (which sets up the inherited attributes), and then sets the new attributes the subclass adds. Without a call to the parent's __init__, the parent's initialization never runs, and the inherited attributes simply do not exist on the instance, a common and confusing source of AttributeError.
super() also shines when overriding a non-__init__ method: the override can call super().method() to get the parent's result and then refine it. This expresses "do what the parent does, and a bit more" directly, keeping the shared logic in the parent where a fix benefits everyone. The mechanism is the same demystified call you already understand, super() simply routes the lookup to the superclass while keeping self bound to the current instance.
class Transform:
def __init__(self, name):
self.name = name # the parent sets up the inherited attribute
def describe(self):
return f"<Transform: {self.name}>"
class StandardScaler(Transform):
def __init__(self, name, with_mean):
# run the PARENT's __init__ first, setting self.name
super().__init__(name)
# then add this subclass's own attribute
self.with_mean = with_mean
# EXTEND describe: reuse the parent's text, then add to it
def describe(self):
base = super().describe() # the parent's version
return f"{base} (with_mean={self.with_mean})"
ss = StandardScaler("std", True)
print(ss.name) # std (set by the parent's __init__)
print(ss.with_mean) # True (set by the subclass)
print(ss.describe()) # <Transform: std> (with_mean=True)Extending through super().describe() rather than retyping the parent's wording means a later change to the parent flows into every subclass for free, where a copied string would have to be found and changed in each of them. This "initialize the parent, then specialize" pattern is how nearly every real subclass is built.
DON'T:
- DON'T forget
super().__init__(...)in a subclass that defines its own__init__, the parent's attributes will be missing and you will hitAttributeErrorlater. - DON'T hardcode the parent's name like
Transform.__init__(self, name)in routine code,super()names no class, so it keeps working when the hierarchy changes, and under multiple inheritance (a class with more than one parent) it runs a shared parent once where hardcoded names run it twice.
Composition
What. Composition is building a class by holding instances of other classes as attributes, rather than inheriting from them, modeling a "has-a" relationship instead of an "is-a" one.
Why. Many relationships are not "is-a" at all, a Pipeline is not a kind of Transform, it has a list of transforms, and forcing such relationships into inheritance produces tangled, fragile hierarchies, while composition keeps them clean and flexible.
When. You choose composition whenever the honest relationship is "has-a" or "uses-a": an optimizer has a model's parameters, a pipeline has stages, a request has a logger. If you cannot say "X is a Y" with a straight face, compose instead of inherit.
This is the design instinct that the whole module has been building toward. Inheritance and composition are two ways to relate types, and choosing between them is a judgment you make constantly. The test is the English sentence. A StandardScaler is a Transform, inheritance fits. A Pipeline is not a Transform, it has a sequence of transforms it runs in order, composition fits. Getting this right is the difference between a structure that bends gracefully as requirements change and one that fights you.
Composition works by storing other objects as attributes and delegating to them. A Pipeline holds a list of transform objects and, in its own apply, calls each held object's apply in turn, threading the data through. The pipeline does not inherit any transform's behavior, it orchestrates independent objects. This is the structural sibling of the function composition you met earlier, there you fed one function's output into the next, here you feed one object's output into the next, the same chaining idea lifted from functions to objects.
class Transform:
def __init__(self, name):
self.name = name
def apply(self, x):
return x
class Scaler(Transform):
def apply(self, x):
return x / 100
class Shifter(Transform):
def __init__(self, name, by):
super().__init__(name)
self.by = by
def apply(self, x):
return x + self.by
# 'Pipeline' is NOT a Transform, it HAS-A list of transforms (composition)
class Pipeline:
def __init__(self, steps):
# store other objects as an attribute, the heart of composition
self.steps = steps # a list of Transform instances
# delegate: run each held object's apply in sequence
def apply(self, x):
for step in self.steps:
# thread the data through one object after another
x = step.apply(x)
return x
def __repr__(self):
names = ", ".join([step.name for step in self.steps])
return f"Pipeline([{names}])"
pipe = Pipeline([Scaler("minmax"), Shifter("offset", 0.5)])
print(pipe) # Pipeline([minmax, offset])
print(pipe.apply(50)) # 1.0 (50 / 100 = 0.5, then + 0.5 = 1.0)Notice the payoff. The pipeline works with any object whose apply takes the value and returns the next one, and that has a name for the __repr__, so a new transform type drops in without Pipeline changing at all, and the steps rearrange and reuse freely because they are independent objects rather than baked-in parent behavior.
# the choice, stated as the sentence test:
# "a StandardScaler IS A Transform" -> inheritance: class StandardScaler(Transform)
# "a Pipeline HAS A list of Transforms" -> composition: self.steps = [...]
# if "X is a Y" sounds wrong, you want composition, not inheritanceReaching for inheritance because it superficially reuses code, when the real relationship is "has-a", is the classic design mistake, and what it costs is independence, two types that should have been able to change separately no longer can. Favoring composition for "has-a" keeps each class focused and the structure able to evolve, which is exactly the instinct you lean on when you assemble preprocessing steps, models and evaluation into the larger machine-learning systems ahead.
DON'T:
- DON'T subclass a type merely to reuse its methods when the relationship is "has-a", hold an instance of it instead and delegate.
- DON'T make a composed object reach into its parts' internals, call their public methods (
step.apply(x)) so each part stays encapsulated and replaceable.
Lab Exercises
Lab Exercise 1: A Specialized Shape, Then One Call Over Three Types
Define a superclass Shape whose __init__ stores a name and whose area() returns 0.0. Define a subclass Square that adds a side, calls the parent's initializer, and overrides area() to return side ** 2. Create a Square, print its name and area, and print three checks: isinstance against Square, isinstance against Shape, and type(sq) is Shape. Add a comment saying why the last one differs from the other two.
Then build a list holding a plain Transform, a Scaler that overrides apply only, and a Logger that overrides describe only. Loop over the list calling both methods on each element and print the results. Add a comment stating which class each of the six answers came from.
Lab Exercise 2: Extending a Parent, Then Holding One Instead
Define a superclass Estimator whose __init__ stores a name and whose summary() returns f"Estimator {self.name}". Define RidgeEstimator, which adds an alpha, calls super().__init__, and overrides summary() so it reuses the parent's text through super().summary() and appends the alpha. Print the summary.
Then define Shifter, a Transform subclass adding a by amount, and a Pipeline that holds a list of transforms rather than subclassing Transform. Give Pipeline an apply that threads a value through each held step in order, and a __repr__ listing the step names.
Run a value through a pipeline of a Scaler and a Shifter, print the pipeline and the result, then print isinstance(pipe, Transform) and add a comment saying what that answer proves about the relationship you built.
Solution 1: A Specialized Shape, Then One Call Over Three Types
class Shape:
def __init__(self, name):
self.name = name
def area(self): # the base version, meant to be overridden
return 0.0
class Square(Shape):
def __init__(self, side):
super().__init__("square") # let the parent install 'self.name'
self.side = side # then add what this subclass carries
def area(self): # OVERRIDE, a real calculation
return self.side ** 2
sq = Square(4)
print(sq.name, sq.area()) # square 16
print(isinstance(sq, Square)) # True
print(isinstance(sq, Shape)) # True, the is-a link holds
print(type(sq) is Shape) # False
# The last one asks a different question. 'isinstance' asks "is this a
# Shape or a kind of one", which a Square is. 'type(sq) is Shape' asks "is
# this exactly a Shape", which it is not, and never will be.class Transform:
def __init__(self, name):
self.name = name
def apply(self, x):
return x
def describe(self):
return f"<Transform: {self.name}>"
class Scaler(Transform):
def apply(self, x): # overrides apply only
return x / 100
class Logger(Transform):
def describe(self): # overrides describe only
return f"[Logger named {self.name}]"
steps = [Transform("identity"), Scaler("minmax"), Logger("audit")]
for step in steps:
print(step.describe(), step.apply(50))
# <Transform: identity> 50
# <Transform: minmax> 0.5
# [Logger named audit] 50
# Six answers from three classes. 'Scaler' supplied its own apply and
# inherited describe, 'Logger' did the opposite, and the plain 'Transform'
# supplied both. The loop never asks what type anything is.The loop is the point of the second block. One call site, three types, and no branch on the object's class anywhere, because each object already carries the version of the method that suits it.
DON'T:
- DON'T skip
super().__init__(...)inSquare, without itself.nameis never installed and the failure surfaces at the first read rather than at construction. - DON'T write
if type(step) is Scaler:inside that loop to pick behavior, overriding is what removes the need for it, and a fourth subclass would need a fourth branch.
Solution 2: Extending a Parent, Then Holding One Instead
class Estimator:
def __init__(self, name):
self.name = name
def summary(self):
return f"Estimator {self.name}"
class RidgeEstimator(Estimator):
def __init__(self, name, alpha):
super().__init__(name)
self.alpha = alpha
def summary(self): # EXTEND rather than replace
return f"{super().summary()} (alpha={self.alpha})"
print(RidgeEstimator("ridge", 0.5).summary())
# Estimator ridge (alpha=0.5)super().summary() rather than a retyped copy of the parent's wording is what keeps one string in one place. Change the parent's format later and the subclass follows without being touched.
class Shifter(Transform):
def __init__(self, name, by):
super().__init__(name)
self.by = by
def apply(self, x):
return x + self.by
# 'Pipeline' subclasses no Transform. It HOLDS transforms.
class Pipeline:
def __init__(self, steps):
self.steps = steps # the composition, other objects as attributes
def apply(self, x):
for step in self.steps: # thread the value through each in turn
x = step.apply(x)
return x
def __repr__(self):
return f"Pipeline([{', '.join([step.name for step in self.steps])}])"
pipe = Pipeline([Scaler("minmax"), Shifter("offset", 0.5)])
print(pipe) # Pipeline([minmax, offset])
print(pipe.apply(50)) # 1.0, that is 50 / 100 then + 0.5
print(isinstance(pipe, Transform)) # False
# 'False' is the correct answer and the design working. A Pipeline is not
# a kind of Transform, it HAS a list of them, so it deliberately fails the
# is-a test while still being able to run every one of them.DON'T:
- DON'T make
PipelinesubclassTransformto "reuse" the interface, it is not a kind of transform and inheriting would claim it is. - DON'T have
Pipelinereach intostep.by, call the publicstep.apply(x)so a step can change its internals freely.
Theory Assessment
Q1: What Comes For Free, and What Counts As Being One
Explain what an empty subclass, one whose whole body is pass, automatically gains from its superclass. Then explain why isinstance(scaler, Transform) is True when Scaler subclasses Transform, and why that check is preferable to type(scaler) is Transform.
Hint: For the first half, ask which initializer runs when you construct the empty subclass. For the second, one of the two checks respects the hierarchy and the other refuses to.
Q2: Which Version Runs, and What the Parent Was Supposed to Do
When you call obj.describe() and both the subclass and the superclass define describe, explain how Python decides which one runs. Then explain what super().__init__(name) does inside a subclass's own __init__, and what specifically breaks when it is left out.
Hint: Lookup starts on the instance's own class and walks upward. For the second half, ask which attributes exist on the object afterwards, and when you find out.
Q3: The Sentence Test, and Running Objects You Did Not Inherit From
Give the one-sentence test for choosing between inheritance and composition, and apply it to a Pipeline and the Transform steps it runs. Then explain how that Pipeline produces a result without inheriting any transform behavior at all.
Hint: Say the relationship out loud and judge whether "is-a" is honest. For the second half, ask what the pipeline does with each object it is holding.
Q4: A Subclass That Builds, Passes Every Check, and Then Fails
Read the following without running it. State the output of each of the first three print calls, then name the exception the fourth raises and the class whose code it is raised inside.
class Transform:
def __init__(self, name):
self.name = name
def describe(self):
return f"<{self.name}>"
def apply(self, x):
return x
class Scaler(Transform):
def __init__(self, name, factor):
self.factor = factor
def apply(self, x):
return x * self.factor
s = Scaler("minmax", 0.01)
t = Transform("identity")
print(isinstance(s, Transform), type(s) is Transform)
print(t.apply(50), s.apply(50))
print(t.describe())
print(s.describe())Scaler("minmax", 0.01) raises nothing, the object passes its isinstance check, and its overridden method works. Say what is actually wrong, why none of the first three lines could have revealed it, and why the frame that raises is not the frame that is wrong.
Hint: Count the attributes Scaler.__init__ installs against the ones the class's methods read. One method is overridden and one is inherited, and only one of them needs the missing attribute.
Answer 1:
An empty subclass gains everything the superclass defines: every method, and the superclass's __init__. Constructing Scaler("minmax") when Scaler's whole body is pass runs Transform.__init__, so the instance gets self.name exactly as a Transform would, and s.describe() runs Transform.describe. The subclass has added nothing and is still fully functional, because inheritance starts a class off with the complete set of its parent's capabilities. What the empty class does add is a name: Scaler is a type of its own that is also a kind of Transform, and that is exactly what isinstance checks.
isinstance(scaler, Transform) is True because the is-a link is literal rather than a slogan. A Scaler genuinely is a Transform, so anywhere the code expects a Transform, a Scaler is accepted, and one that honors its parent can stand in. type(scaler) is Transform asks the narrower question "is the exact class Transform", which is False, and using it to test membership in a hierarchy rejects every valid subclass. isinstance is the inheritance-aware check and it is the one that matches what the code actually needs.
DON'T redefine __init__ in a subclass that has nothing to add, the inherited one is already correct and redefining it is where the missing-super() bug starts.
Answer 2:
Python looks for the method on the instance's own class first and only walks upward when it does not find one, so a describe defined on the subclass is the one found and run, and the superclass's version runs only if that override calls it, as super().describe() does. Other subclasses that did not override still get the parent's version, which is how one call site serves several types without any branching.
super().__init__(name) runs the superclass's initializer, which is what installs the inherited attributes on the instance. Leaving it out raises nothing by itself, and unless construction reads one of the attributes the parent never set, the object is built and looks fine. What breaks is later: the attributes the parent was supposed to set simply do not exist, so the first inherited method that reads one fails with AttributeError, at a line in the parent's code that is entirely correct. The distance between the mistake and the symptom is what makes this bug expensive.
DON'T hardcode Transform.__init__(self, name) in routine code, super() keeps working when the hierarchy changes and runs a shared parent once under multiple inheritance, where hardcoded names run it twice.
Answer 3:
The test is one English sentence: if "X is a Y" is honestly true, use inheritance, and if only "X has a Y" or "X uses a Y" is true, use composition. Applied here, "a Pipeline is a Transform" is not true, a pipeline is not a kind of transform, while "a Pipeline has a list of Transform steps" is exactly right, so composition is the honest model and Pipeline inherits from no transform.
It produces a result by delegation. It holds the transform objects in an attribute and, inside its own apply, calls each held object's apply in turn, feeding each result into the next. No transform behavior was inherited, it was borrowed at the moment it was needed by calling a public method on an object the pipeline happens to own. The payoff is that the pipeline runs any object whose apply takes a value and returns one, so new step types drop in without Pipeline changing at all.
DON'T subclass a type merely to reuse its methods, hold an instance and call them, which keeps the two types independent and replaceable.
Answer 4:
The first three lines print:
True False
50 0.5
<identity>The fourth raises AttributeError: 'Scaler' object has no attribute 'name', and it is raised inside Transform.describe, on the line return f"<{self.name}>".
What is wrong is that Scaler.__init__ never calls super().__init__(name). It stores self.factor and stops, so name is accepted as a parameter, used for nothing, and discarded. The instance is built without the attribute its parent was supposed to install.
None of the first three lines could have revealed it. isinstance is about the class hierarchy, which is intact, Scaler really does subclass Transform regardless of what its initializer forgot. And s.apply(50) works because apply is the method Scaler overrode, and the override reads self.factor, which does exist. Every part of the object that Scaler wrote itself is fine.
The frame that raises is not the frame that is wrong, which is the trap. Transform.describe is correct code, it reads an attribute every Transform is supposed to have, and it is the first thing to ask for something the broken initializer never provided. The fix belongs in a different class, in Scaler.__init__, and nothing in the traceback points at it. This is the same shape as 1.6.1's Q4, where the raising frame was innocent, now produced by inheritance instead of by a short row.
DON'T read an AttributeError from a parent's method as a bug in the parent, check first whether the subclass's __init__ ever let the parent run.
Assignment
Assignment: A Composed, Interface-Enforced Transform Pipeline
Build a small framework in one file, in this order.
Define an abstract base class Transform with an abstract method apply(self, x), so that a subclass which forgets to implement it cannot be instantiated at all. Define three concrete transforms: Scale multiplying by a stored factor, Clip clamping between stored low and high bounds, and Round rounding to a stored number of digits. Each stores its own configuration in __init__ and overrides apply.
Then define a Pipeline that composes a list of transforms and applies them in order, with a __repr__ listing the class name of each step.
Demonstrate three things rather than describing them. That Transform() cannot be instantiated, and print the error. That a pipeline of all three transforms a value correctly, showing the intermediate result of each step alongside the final one. And that a subclass declaring itself a Transform while forgetting apply also cannot be instantiated, which is the whole reason the base class is abstract rather than merely conventional.
Hints:
class Transform(ABC):with@abstractmethod def apply(self, x): ...is the whole declaration, and the enforcement happens when something tries to construct an incomplete subclass, not when the subclass is defined.Pipelineis a has-a relationship, so it holds the transforms and delegates to eachapplyin sequence, and it does NOT subclassTransform.
Solution:
# 'abc' ships with Python and provides the abstract-base-class tools.
from abc import ABC, abstractmethod
class Transform(ABC):
@abstractmethod
def apply(self, x): # '...' is a placeholder body, like 'pass', and no subclass can be built until 'apply' is overridden
...
class Scale(Transform):
def __init__(self, factor):
self.factor = factor
def apply(self, x):
return x * self.factor
class Clip(Transform):
def __init__(self, low, high):
self.low = low
self.high = high
def apply(self, x):
# clamp into the range
return max(self.low, min(self.high, x))
class Round(Transform):
def __init__(self, digits):
self.digits = digits
def apply(self, x):
return round(x, self.digits)
# 'Pipeline' subclasses no Transform. It HOLDS transforms and delegates.
class Pipeline:
def __init__(self, steps):
self.steps = steps
def apply(self, x):
for step in self.steps:
x = step.apply(x)
return x
def __repr__(self):
names = ", ".join([type(step).__name__ for step in self.steps])
return f"Pipeline([{names}])"try:
Transform() # the base class declares an abstract method
except TypeError as err:
print("TypeError:", err)
# TypeError: Can't instantiate abstract class Transform without an implementation for abstract method 'apply'
pipe = Pipeline([Scale(0.1), Clip(0.0, 5.0), Round(2)])
print(pipe) # Pipeline([Scale, Clip, Round])
value = 83.456
for step in pipe.steps: # show each stage, not only the answer
value = step.apply(value)
print(type(step).__name__, "->", value)
# Scale -> 8.345600000000001
# Clip -> 5.0
# Round -> 5.0
print(pipe.apply(83.456)) # 5.0class Broken(Transform): # declares itself a Transform, implements nothing
pass
try:
Broken()
except TypeError as err:
print("TypeError:", err)
# TypeError: Can't instantiate abstract class Broken without an implementation for abstract method 'apply'The Broken case is the one that justifies the whole design. A plain base class with an apply that returns its input would have let Broken() construct happily and silently pass values straight through, and the pipeline would run, report nothing wrong, and produce numbers that were never transformed. The abstract base moves that failure from "wrong answer, no error" to "cannot be built", which is the fail-fast instinct from 1.6.3 applied to a class rather than to an argument.
Notice also where the enforcement happens. Defining Broken raises nothing, the error arrives at the moment something tries to construct one, so the check is about instances rather than about the class declaration.
Scale, Clip and Round define __init__ without calling super().__init__(), and that is safe here only because Transform sets no attributes of its own, so there is nothing for it to install. The moment the base gains one, the DON'T in Theory applies.
The Scale -> 8.345600000000001 line is worth not tidying away. That is ordinary binary floating point from 83.456 * 0.1, and printing the stage results rather than only the final answer is what makes it visible. Clip then bounds it at 5.0 and Round leaves that alone, so the imprecision never reaches the output here, which is luck rather than design.
Pipeline staying outside the hierarchy is the last decision worth defending. It could have subclassed Transform and implemented apply, which would even have type-checked, and it would have been a lie: a pipeline is not a kind of transform.
DON'T subclass a type to borrow its interface when the relationship is has-a, and don't reach for an abstract base for every base class, it earns its place only where a missing method would fail silently instead of loudly.
Additional Resources
Optional, and nothing later depends on either. Worth opening if you want to enforce an interface rather than trust one, or the exact behavior of the two tools this lesson leans on.
- Official docs -
abc, theABCandabstractmethodentries. How to make a missing method a construction-time failure instead of a silent one. - Official docs - Built-in Functions, the
superandisinstanceentries. The precise behavior of both, including whatsuper()does with more than one parent.
Summary
There is a reason this lesson sits at the end of the module. Everything before it in this section taught you to build a single well-made object, data bundled with behavior, state protected behind a clean interface. This lesson is about how such objects relate, and relationships are where design lives. Inheritance lets a family of types share a common foundation, so that a dozen specialized transforms can all be kinds of one Transform and gain its behavior for free, each overriding only what makes it distinct. Done honestly, it captures real structure in your domain and rewards you every time you fix a bug in the parent and watch it vanish from every child at once.
But the deeper lesson is restraint, and it lives in the single sentence test you now carry. Not every relationship is "is-a", and the most common mistake in object-oriented design is forcing a "has-a" relationship into an inheritance hierarchy because it happens to reuse some code. A pipeline is not a kind of transform, it has transforms, and recognizing that, choosing to compose rather than inherit, is the difference between a structure that flexes as your needs change and one that locks you into a hierarchy you will come to resent. Composition is also the quiet echo of something you already knew: it is function composition grown up, one object's output feeding the next, the same chaining idea you used with functions now lifted to the level of whole objects.
You have now walked the full arc of this section, from a lone class with a single method to families of types relating through inheritance and assemblies of objects collaborating through composition. This is precisely the vocabulary the libraries ahead are written in. When you stack preprocessing steps into a pipeline, when you swap one estimator for another because they share an interface, when you read a class hierarchy in someone else's framework and immediately grasp what is shared and what is specialized, you will be using exactly the judgment you built here. The data and machine-learning tools that fill the rest of this curriculum are not a new world you must learn from scratch. They are this world, applied. You have finished the foundation, and you can now read and reason about the structures everything else is built from.
Cheat Sheet
| Concept | Syntax | Models | When to Use |
|---|---|---|---|
| Inheritance | class Child(Parent): |
"is-a" | child genuinely is a kind of parent |
| Subclass / superclass | child / parent in the chain | hierarchy | naming the two ends |
| Override | redefine a method in the child | specialization | child must behave differently |
super().__init__(...) |
inside child __init__ |
extend parent | run parent setup, then add own |
super().method() |
inside an override | extend, not replace | reuse parent result, then refine |
isinstance(x, Parent) |
hierarchy-aware check | "is-a" test | accepting any subclass |
| Composition | self.parts = [...] + delegate |
"has-a" | object holds/uses other objects |
| Abstract base | class B(ABC) + @abstractmethod |
required interface | forcing subclasses to implement |
class Transform: # superclass: shared ground
def __init__(self, name):
self.name = name
def apply(self, x):
return x
class Scaler(Transform): # subclass: IS-A Transform
def apply(self, x): # override: specialize behavior
return x / 100
class StandardScaler(Scaler):
def __init__(self, name, with_mean):
super().__init__(name) # run parent setup first
self.with_mean = with_mean # then add own attribute
class Pipeline: # composition: HAS-A list of steps
def __init__(self, steps):
self.steps = steps # holds other objects
def apply(self, x):
for step in self.steps: # delegate to each in turn
x = step.apply(x) # one object's output -> next's input
return x
# "is a" -> inherit, "has a" -> compose