Python Part I · Module 16 · Object-Oriented Programming
Inheritance
Two kinds of job in a flow — a simulation and a synthesis run — share most of what they do and differ in a little. Writing both from scratch duplicates the shared part; writing one and special-casing it with flags produces a class that is three classes in a trench coat. Inheritance is the third option: declare that one class is built on another, and it receives everything the other has without a line of code. What it receives, and exactly when Python decides where a name comes from, is more worth knowing than the syntax — and the feature is also the most over-used in the module, so the test for when not to reach for it arrives in the same chapter.
Intermediate16 min readPythonPart IFundamentalsOOPInheritance
Part I · Module 16 · Chapter 16.9 · Inheritance
1. You Have Already Written One
Chapter 14.8 defined a custom exception:
class ToolError(RuntimeError):
"""A tool we invoked failed."""
e = ToolError("vcs exited 137")
print("message :", e)
print("class :", type(e).__name__)
print("is a RuntimeError:", isinstance(e, RuntimeError))
print("is an Exception :", isinstance(e, Exception))message : vcs exited 137
class : ToolError
is a RuntimeError: True
is an Exception : TrueToolError has a body of one docstring and nothing else, yet it stores a
message, prints it, and can be caught as a RuntimeError. All of that came from
the name in the parentheses.
That is inheritance, and this chapter is about what it did.
2. The Syntax and the Claim
class Job:
def __init__(self, name):
self.name = name
def run(self):
return f"running {self.name}"
def describe(self):
return f"job {self.name}"
class SimulationJob(Job):
pass
s = SimulationJob("smoke")
print("run() :", s.run())
print("describe():", s.describe())
print("name :", s.name)
print("own body defines:", sorted(n for n in SimulationJob.__dict__ if not n.startswith("__")))run() : running smoke
describe(): job smoke
name : smoke
own body defines: []SimulationJob defines nothing — the last line proves it. It has run,
describe, and an __init__ that stores name, because
class SimulationJob(Job): says it is built on Job.
Vocabulary: Job is the parent (or base, or superclass); SimulationJob is
the child (or derived class, or subclass).
3. What the Child Receives
Everything, and it is worth being precise about the list:
class Job:
default_timeout = 3600 # a class attribute
def __init__(self, name): # a constructor
self.name = name
def run(self): # a method
return f"running {self.name}"
def __repr__(self): # a special method
return f"Job({self.name!r})"
class SimulationJob(Job):
pass
s = SimulationJob("smoke")
print("class attribute :", s.default_timeout)
print("instance attr :", s.name)
print("method :", s.run())
print("special method :", repr(s))class attribute : 3600
instance attr : smoke
method : running smoke
special method : Job('smoke')All four worked. Note the __repr__: it says Job(...) even though the object
is a SimulationJob, because the inherited method hard-codes the word. That is
a real wart, and Chapter 16.11 fixes it by overriding.
4. How Python Finds a Name
Not by copying — by searching, at the moment of the call:
class Job:
def run(self):
return "Job.run"
class SimulationJob(Job):
pass
print("search order:", [c.__name__ for c in SimulationJob.__mro__])
print("same function object:", SimulationJob.run is Job.run)search order: ['SimulationJob', 'Job', 'object']
same function object: True__mro__ is the method resolution order: the list of classes Python walks,
in order, looking for a name. SimulationJob first, then Job, then object —
the class every class inherits from, which is where Chapter 16.8's default
__repr__ came from.
Because the search happens at call time, changing the parent changes existing children immediately:
class Job:
def run(self):
return "original"
class SimulationJob(Job):
pass
s = SimulationJob()
print("before:", s.run())
Job.run = lambda self: "replaced at runtime"
print("after :", s.run())before: original
after : replaced at runtimeThe instance already existed and picked up the new version, because the lookup walks the MRO every time rather than being resolved once at creation.
5. isinstance Follows the Chain
class Job:
pass
class SimulationJob(Job):
pass
s = SimulationJob()
print("isinstance(s, SimulationJob):", isinstance(s, SimulationJob))
print("isinstance(s, Job) :", isinstance(s, Job))
print("type(s) is Job :", type(s) is Job)
print("type(s) is SimulationJob :", type(s) is SimulationJob)
print("issubclass(SimulationJob, Job):", issubclass(SimulationJob, Job))isinstance(s, SimulationJob): True
isinstance(s, Job) : True
type(s) is Job : False
type(s) is SimulationJob : True
issubclass(SimulationJob, Job): Trueisinstance(s, Job) is True — a SimulationJob is a Job. That is the
whole promise, and the reason Chapter 14.8's except FlowError could catch a
ParseError.
type(s) is Job is False — type() asks for the exact class and ignores
the hierarchy. Writing type(x) is SomeClass where you meant isinstance is a
common way to break polymorphic code, because it rejects every subclass.
6. The Inherited Mutable Attribute
Chapter 16.4 Section 5 showed a mutable class attribute is shared by all instances. With inheritance it is shared across the whole family, and the sharing is invisible from the child's source:
class Job:
log = [] # one list, for every descendant
def __init__(self, name):
self.name = name
def record(self, msg):
self.log.append(f"{self.name}: {msg}")
class SimulationJob(Job):
pass
class SynthesisJob(Job):
pass
a = SimulationJob("smoke")
b = SynthesisJob("top")
a.record("started")
print("a.log :", a.log)
print("b.log :", b.log)
print("Job.log :", Job.log)
print("one object :", a.log is b.log is Job.log)a.log : ['smoke: started']
b.log : ['smoke: started']
Job.log : ['smoke: started']
one object : TrueA synthesis job is reporting a simulation job's log line. Nothing in
SynthesisJob's source mentions a list at all — the shared state is in the
parent, possibly in another file.
The fix is Chapter 16.4's: mutable state belongs in __init__, one list per
instance.
7. The Test: Is-A, Not Has-A
Inheritance is over-used immediately after it is learned. The test:
Can every instance of the child be used wherever the parent is expected?
If yes, it is a subclass. If you catch yourself thinking "but this one does not
really have a run()", it is not.
class Job:
def __init__(self, name):
self.name = name
def run(self):
return f"running {self.name}"
class SimulationJob(Job):
"""A simulation IS a job -- it runs, it has a name."""
class Regression:
"""A regression HAS jobs. It is not one."""
def __init__(self, jobs):
self.jobs = jobs
def run_all(self):
results = []
for j in self.jobs:
results.append(j.run())
return results
r = Regression([SimulationJob("smoke"), SimulationJob("cdc")])
print(r.run_all())
print("Regression is a Job:", isinstance(r, Job))['running smoke', 'running cdc']
Regression is a Job: FalseRegression could have been written class Regression(Job): and it would have
worked — it would have acquired a name and a run() that mean nothing for a
collection. The substitution test rejects it: handing a Regression to code
expecting a Job would call a run() that runs one imaginary job instead of
all of them.
That is composition, and Chapter 16.13 is about why it is the default.
8. Inheriting From a Built-In
The parent does not have to be your class. Python's own types are classes (Chapter 16.1 Section 2), and you can build on them:
class Filelist(list):
"""A list of source files that can emit itself in the tool's format."""
def as_text(self):
return "\n".join(self)
f = Filelist(["rtl/alu.sv", "rtl/decoder.sv"])
f.append("tb/env.sv")
print("len :", len(f))
print("indexing :", f[0])
print("is a list :", isinstance(f, list))
print("mro :", [c.__name__ for c in Filelist.__mro__])
print("own method :")
print(f.as_text())len : 3
indexing : rtl/alu.sv
is a list : True
mro : ['Filelist', 'list', 'object']
own method :
rtl/alu.sv
rtl/decoder.sv
tb/env.svFilelist received every list method — append, indexing, len(), iteration —
and added one of its own. isinstance(f, list) is True, so it can be passed
to anything expecting a list.
This is occasionally exactly right, and more often a trap. The substitution test
from Section 7 passes easily here, but a second question applies: do you want
every list method? Filelist has inherited sort, reverse, clear and
pop, none of which it has an opinion about, and a caller can empty it.
For most "a collection plus a bit of behaviour" cases, composition (Chapter 16.13) is the better answer — hold a list rather than being one. Inherit from a built-in when you genuinely want the full interface, which is rarer than it looks.
9. Predict, Run, Observe
class Base:
kind = "base"
def describe(self):
return f"I am {self.kind}"
class Child(Base):
kind = "child"
class GrandChild(Child):
pass
for obj in (Base(), Child(), GrandChild()):
names = [c.__name__ for c in type(obj).__mro__]
print(f"{type(obj).__name__:<11} {obj.describe():<14} mro={names}")Base I am base mro=['Base', 'object']
Child I am child mro=['Child', 'Base', 'object']
GrandChild I am child mro=['GrandChild', 'Child', 'Base', 'object']describe is defined once, in Base, and gives different answers.
It reads self.kind, and self.kind resolves by walking the MRO of the
actual object — so a Child finds its own kind before reaching Base's.
GrandChild says "child" because it defines no kind and the search reaches
Child first.
This is the mechanism behind every remaining chapter in the module: a method written in a parent calls back into whatever the real object turns out to be.
10. Exercises
Exercise 1 — the empty child. Write a parent with two methods and a child
whose body is pass. Call both methods on the child.
Exercise 2 — what was received. Print the child's __dict__ and confirm it
defines nothing while the methods still work.
Exercise 3 — the MRO. Print __mro__ for a three-level hierarchy and say
which class supplies each name.
Exercise 4 — same function. Show that Child.method is Parent.method for an
inherited method, and explain what that proves about copying.
Exercise 5 — late change. Create an instance, then replace a method on the parent. Confirm the existing instance picks it up.
Exercise 6 — isinstance versus type. For a child instance, evaluate
isinstance(x, Parent) and type(x) is Parent. Say which you would use in a
function accepting any job.
Exercise 7 — the shared list. Reproduce Section 6. Then fix it and prove two siblings no longer share.
Exercise 8 — is-a or has-a. For each, choose: (a) TimeoutError and
Exception (b) Regression and Job (c) SynthesisJob and Job (d) Report
and a list of results (e) PosixPath and Path.
11. Summary
class Child(Parent):builds one class on another; the child receives methods, class attributes,__init__and special methods.- A child with a body of
passalready works — you wrote one in Chapter 14.8. - Nothing is copied. Python searches the MRO at call time, so
Child.method is Parent.method. - Because lookup is at call time, changing a parent changes existing instances immediately.
objectis at the end of every MRO, which is where the default__repr__comes from.isinstancefollows the chain;type(x) is Cdoes not — the second rejects every subclass.- A mutable class attribute is shared across the whole family, invisibly from the child's own source.
- A method in the parent reads
self, so it resolves against the real object's class — the mechanism behind overriding and polymorphism. - You can inherit from a built-in such as
list, receiving its entire interface — including the methods you did not want. - The test is substitution: can every child be used wherever the parent is expected?
- Inheriting merely to reuse code is the common mistake; that is composition.
The next chapter adds to a parent's behaviour instead of replacing it.
Where this fits
Part of the Python curriculum.
