1Inheritance and Class Hierarchies
▶
Inheritance is one of the foundational pillars of object-oriented programming. At its core, it allows you to define a new class that automatically receives all of the attributes and behavior of an existing class, without copying a single line of code. The new class — called the child class, subclass, or derived class — builds on top of the parent class, superclass, or base class. This relationship is not just a programming convenience; it is meant to model something real and meaningful about the entities in your problem domain.
In engineering contexts this is especially natural. A rectangular beam is a beam. A temperature sensor is a sensor. A simply supported bridge is a structure. These "is-a" relationships are precisely what inheritance is designed to capture. When you recognise an is-a relationship between two concepts, inheritance lets you write the shared logic once in the parent class and have every child class benefit from it automatically. Adding a new beam type tomorrow does not require you to re-implement load calculations that are common to all beams — those live in the parent, and the new subclass inherits them for free.
Python signals the inheritance relationship using parentheses in the class definition line. The parent class name is placed inside those parentheses, immediately after the subclass name:
class Beam:
"""Base class representing a generic structural beam."""
def __init__(self, material, length):
self.material = material # e.g. 'steel', 'concrete'
self.length = length # in metres
def describe(self):
return (f"A {self.length}-m {self.material} beam.")
def self_weight(self, density, area):
"""Returns the beam's own weight in Newtons."""
return density * area * self.length * 9.81
class RectangularBeam(Beam): # RectangularBeam inherits from Beam
pass
Even with the bare pass body, RectangularBeam already possesses describe() and self_weight() because it inherits everything from Beam. This is the key promise of inheritance: shared logic defined once in the parent class does not need to be repeated in child classes.
Defining Subclasses
A real subclass is rarely empty. It exists because it has something extra — either additional attributes that only make sense for that specific type, or additional methods that extend what the parent can do, or both. The subclass declares its own __init__ method to introduce those new attributes, and it adds new methods to expand functionality beyond what the parent provides.
Consider a rectangular beam, which has a width and a height in addition to the material and length that all beams share:
class RectangularBeam(Beam):
"""A beam with a rectangular cross-section."""
def __init__(self, material, length, width, height):
super().__init__(material, length) # initialise parent attributes
self.width = width # cross-section width, metres
self.height = height # cross-section height, metres
def cross_sectional_area(self):
return self.width * self.height
def second_moment_of_area(self):
"""I for bending about the horizontal centroidal axis."""
return (self.width * self.height ** 3) / 12
RectangularBeam now has four attributes (material, length, width, height) and four methods (describe, self_weight, cross_sectional_area, second_moment_of_area). The first two attributes and first two methods came entirely from Beam — not a single extra line was needed for them in the subclass.
A critically important consequence of inheritance is substitutability: a subclass instance can be used anywhere the parent class is expected. Any function that accepts a Beam object will work correctly when handed a RectangularBeam instead, because a RectangularBeam is guaranteed to have everything a Beam has. This property is known in computer science as the Liskov Substitution Principle, and it is what makes inheritance genuinely useful in large systems.
Calling the Parent Constructor with super()
When you define __init__ in a subclass, Python does not automatically call the parent's __init__. If you skip it, the attributes defined in the parent are never created, and you will get AttributeError the first time any code tries to access them. The solution is to call super().__init__() explicitly at the start of the subclass constructor, passing along all arguments that the parent requires.
class CircularBeam(Beam):
"""A beam with a solid circular cross-section."""
def __init__(self, material, length, diameter):
super().__init__(material, length) # MUST call this
self.diameter = diameter
def cross_sectional_area(self):
import math
return math.pi * (self.diameter / 2) ** 2
def second_moment_of_area(self):
import math
return (math.pi * self.diameter ** 4) / 64
Why use super() rather than calling Beam.__init__(self, material, length) directly? Two reasons. First, super() respects Python's Method Resolution Order (MRO) — if the inheritance hierarchy ever becomes more complex (multiple levels, or even multiple inheritance), super() ensures the correct parent is always called, whereas the hard-coded class name would break. Second, it makes your intent explicit and readable: you are delegating to "whatever the parent is", not to a specific class whose name might change.
super() is not limited to __init__. You can call any overridden parent method through it whenever you want the parent's behaviour plus something extra in the subclass:
class RectangularBeam(Beam):
def describe(self):
base_description = super().describe() # reuse parent logic
return (f"{base_description} "
f"Cross-section: {self.width*1000:.0f} mm × "
f"{self.height*1000:.0f} mm.")
This is a powerful pattern: extend the parent's method rather than replace it entirely.
Method Overriding
Overriding means the subclass defines a method whose name is identical to a method in the parent class. When Python looks up a method on an instance, it searches the subclass first. If it finds a match there, it uses it and stops looking — the parent's version is bypassed entirely for that instance. This is controlled by Python's Method Resolution Order, which guarantees that the most specific (closest to the actual object's class) implementation is always called first.
class Beam:
def failure_mode(self):
return "Generic beam failure (shear, bending, or buckling)."
class RectangularBeam(Beam):
def failure_mode(self):
return "Rectangular beam may fail in lateral-torsional buckling."
class CircularBeam(Beam):
def failure_mode(self):
return "Circular beam is torsionally stiff; failure often by bending."
b = Beam("steel", 5)
rb = RectangularBeam("steel", 5, 0.1, 0.2)
cb = CircularBeam("steel", 5, 0.15)
print(b.failure_mode()) # Generic beam failure ...
print(rb.failure_mode()) # Rectangular beam may fail ...
print(cb.failure_mode()) # Circular beam is torsionally stiff ...
Even though all three objects respond to the same failure_mode() call, each produces a result appropriate to its type. This is the essence of polymorphism — the same interface, different behaviours. Overriding enables specialised behaviour while preserving a consistent interface across all subclasses. Code that iterates over a list of beams and calls failure_mode() on each one does not need to know or check which specific beam type it is dealing with; Python handles the dispatch automatically.
When to Use Inheritance Versus Composition
Inheritance is a powerful tool, but it can be misused. A common mistake is reaching for inheritance whenever two classes share some behaviour, even when the is-a relationship does not hold. The correct test is simple: ask yourself whether the subclass genuinely is a more specific version of the parent, or whether it merely has a or uses a thing of that type.
- Use inheritance when the relationship is truly is-a. A
RectangularBeamis aBeam. ATemperatureSensoris aSensor. ASteelMaterialis aMaterial. The subclass should be substitutable for the parent in every context. - Use composition when one object has or uses another. A
Bridgehas beams — it is not itself a beam. ADataLoggeruses a sensor — it is not itself a sensor. In these cases, theBridgeclass should hold a list ofBeamobjects as an attribute, not inherit fromBeam.
# Composition example: a Bridge HAS beams, it IS NOT a beam
class Bridge:
def __init__(self, name, beams):
self.name = name
self.beams = beams # a list of Beam (or subclass) instances
def total_self_weight(self, density):
total = 0
for beam in self.beams:
area = beam.cross_sectional_area()
total += beam.self_weight(density, area)
return total
Overusing inheritance creates rigid hierarchies that are difficult to modify. If you later realise a subclass needs attributes from two unrelated parent classes, or that the taxonomy was wrong from the start, restructuring a deep inheritance tree is painful. Composition is almost always more flexible: you can swap out one component for another, or mix different combinations of components, without touching the surrounding class at all. A widely cited principle in software design is to favour composition over inheritance — not because inheritance is bad, but because composition tends to produce more adaptable designs. Inheritance shines specifically when the is-a relationship is stable and genuine.
Practical Inheritance Example: Sensor Hierarchy
To see all of these ideas working together, consider a sensor hierarchy relevant to environmental or structural monitoring. Every sensor, regardless of what it measures, shares certain properties: it has an identifier, a physical location, and an operating range. Every sensor must also be able to report a reading. These shared characteristics belong in a base Sensor class.
class Sensor:
"""Base class for all sensor types."""
def __init__(self, sensor_id, location, min_range, max_range):
self.sensor_id = sensor_id
self.location = location # e.g. (x, y, z) tuple in metres
self.min_range = min_range # minimum measurable value
self.max_range = max_range # maximum measurable value
def read_value(self):
"""Return the current sensor reading. Must be overridden."""
raise NotImplementedError(
f"{type(self).__name__} must implement read_value()."
)
def is_in_range(self, value):
"""Check whether a value falls within the sensor's operating range."""
return self.min_range <= value <= self.max_range
def status_report(self):
reading = self.read_value()
in_range = self.is_in_range(reading)
status = "OK" if in_range else "OUT OF RANGE"
return (f"Sensor {self.sensor_id} @ {self.location}: "
f"reading={reading:.2f}, status={status}")
Notice that read_value() raises NotImplementedError. This is a deliberate design choice: the base class declares that every sensor must have this method, but it cannot provide a meaningful default implementation because measurement logic is entirely specific to each sensor type. Subclasses are required to override it.
import random
class TemperatureSensor(Sensor):
"""Measures ambient temperature in degrees Celsius."""
def __init__(self, sensor_id, location, calibration_offset=0.0):
super().__init__(sensor_id, location, min_range=-40, max_range=85)
self.calibration_offset = calibration_offset
def read_value(self):
# Simulate a hardware read; in reality this would query ADC hardware
raw = random.uniform(15, 30)
return raw + self.calibration_offset
class StrainGauge(Sensor):
"""Measures strain in microstrain (με)."""
def __init__(self, sensor_id, location, gauge_factor=2.0):
super().__init__(sensor_id, location, min_range=-5000, max_range=5000)
self.gauge_factor = gauge_factor
def read_value(self):
# Simulate a resistance-based strain measurement
raw_resistance_change = random.uniform(-0.005, 0.005)
strain_microstrain = (raw_resistance_change / self.gauge_factor) * 1e6
return strain_microstrain
class AccelerationSensor(Sensor):
"""Measures acceleration in m/s²."""
def __init__(self, sensor_id, location, axis='z'):
super().__init__(sensor_id, location, min_range=-50, max_range=50)
self.axis = axis
def read_value(self):
return random.uniform(-2, 2)
def status_report(self):
# Override to include axis information
base = super().status_report()
return f"{base} [axis={self.axis}]"
Now consider the payoff. A monitoring system that manages many sensors can treat them all identically, calling status_report() on each one without caring which specific subclass it holds:
sensors = [
TemperatureSensor("T-001", (0, 0, 10), calibration_offset=0.5),
TemperatureSensor("T-002", (5, 0, 10)),
StrainGauge("SG-001", (2.5, 0, 0), gauge_factor=2.1),
AccelerationSensor("ACC-001", (2.5, 0, 5), axis='z'),
]
for sensor in sensors:
print(sensor.status_report())
This loop works correctly for every sensor type, including types that did not exist when the loop was written. If a team later develops a Humiditysensor subclass, they add it to the list and the loop handles it without modification. This is the open/closed principle in practice: the monitoring code is open for extension (new sensor types can be added) and closed for modification (existing monitoring code does not change).
The AccelerationSensor class also illustrates how super() can extend rather than replace a parent method. Its overridden status_report() calls super().status_report() to get the standard output, then appends axis information. This avoids duplicating the formatting logic that already lives in Sensor, and ensures that if that formatting changes in the future, the change automatically propagates to AccelerationSensor as well.
Inheritance, used thoughtfully, produces code that mirrors the natural structure of the problem — hierarchies of things that genuinely are related — and yields systems that are compact, readable, easy to test at each level of the hierarchy, and straightforward to extend over time. The discipline of asking "is this truly an is-a relationship?" before reaching for inheritance, combined with a clear understanding of super(), method overriding, and the MRO, gives you the tools to design class hierarchies that remain clean and maintainable as a codebase grows.