Case Study: The Shape Hierarchy
The Scenario
The Shape hierarchy is the "Hello, World!" of object-oriented programming — and for good reason. Shapes are familiar, their properties are well-defined, and the design decisions involved illustrate every concept from this chapter: inheritance, method overriding, polymorphism, abstract classes, and the Liskov Substitution Principle.
You're building a simple geometry module. You need to represent circles, rectangles, and triangles. Each shape has an area and a perimeter. Some shapes have color and fill properties. You want to be able to process any collection of shapes uniformly — calculating total area, finding the largest shape, rendering them all to a screen.
Let's build it step by step.
Step 1: The Abstract Base Class
We start with what all shapes have in common. Every shape has a color and can calculate its area and perimeter. But the formulas differ — there's no generic "shape area" formula. This makes Shape a perfect candidate for an abstract base class.
from abc import ABC, abstractmethod
import math
class Shape(ABC):
"""Abstract base class for all geometric shapes.
Subclasses MUST implement area() and perimeter().
"""
def __init__(self, color: str = "black", filled: bool = False):
self.color = color
self.filled = filled
@abstractmethod
def area(self) -> float:
"""Calculate and return the area of this shape."""
...
@abstractmethod
def perimeter(self) -> float:
"""Calculate and return the perimeter of this shape."""
...
def describe(self) -> str:
"""Return a human-readable description."""
fill_str = "filled" if self.filled else "unfilled"
return (f"{type(self).__name__} | color: {self.color} | "
f"{fill_str} | area: {self.area():.2f} | "
f"perimeter: {self.perimeter():.2f}")
def __str__(self) -> str:
return self.describe()
def __repr__(self) -> str:
return f"{type(self).__name__}(color={self.color!r}, filled={self.filled})"
Notice that describe(), __str__(), and __repr__() are concrete methods — they have full implementations. They call self.area() and self.perimeter(), which will be resolved polymorphically to the subclass versions at runtime. This is the Template Method pattern: the parent defines the skeleton, and subclasses fill in the specifics.
Also notice that you cannot create a Shape directly:
# This fails:
# s = Shape("red", True)
# TypeError: Can't instantiate abstract class Shape
# with abstract methods area, perimeter
Step 2: Concrete Subclasses
Circle
class Circle(Shape):
"""A circle defined by its radius."""
def __init__(self, radius: float, color: str = "black",
filled: bool = False):
super().__init__(color, filled)
if radius <= 0:
raise ValueError(f"Radius must be positive, got {radius}")
self.radius = radius
def area(self) -> float:
return math.pi * self.radius ** 2
def perimeter(self) -> float:
return 2 * math.pi * self.radius
def diameter(self) -> float:
"""Circle-specific method."""
return 2 * self.radius
Rectangle
class Rectangle(Shape):
"""A rectangle defined by width and height."""
def __init__(self, width: float, height: float,
color: str = "black", filled: bool = False):
super().__init__(color, filled)
if width <= 0 or height <= 0:
raise ValueError(
f"Dimensions must be positive, got {width}x{height}"
)
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def perimeter(self) -> float:
return 2 * (self.width + self.height)
def is_square(self) -> bool:
"""Rectangle-specific method."""
return math.isclose(self.width, self.height)
Triangle
class Triangle(Shape):
"""A triangle defined by three side lengths."""
def __init__(self, side_a: float, side_b: float, side_c: float,
color: str = "black", filled: bool = False):
super().__init__(color, filled)
# Validate triangle inequality
if (side_a + side_b <= side_c or
side_a + side_c <= side_b or
side_b + side_c <= side_a):
raise ValueError(
f"Invalid triangle: sides {side_a}, {side_b}, {side_c} "
f"violate the triangle inequality"
)
self.side_a = side_a
self.side_b = side_b
self.side_c = side_c
def area(self) -> float:
"""Use Heron's formula."""
s = self.perimeter() / 2 # Semi-perimeter
return math.sqrt(
s * (s - self.side_a) * (s - self.side_b) * (s - self.side_c)
)
def perimeter(self) -> float:
return self.side_a + self.side_b + self.side_c
def is_equilateral(self) -> bool:
"""Triangle-specific method."""
return (math.isclose(self.side_a, self.side_b) and
math.isclose(self.side_b, self.side_c))
Step 3: Polymorphism in Action
Now the payoff. We can write functions that work with any shape, without knowing the specific type:
def total_area(shapes: list[Shape]) -> float:
"""Calculate total area of all shapes."""
return sum(shape.area() for shape in shapes)
def largest_shape(shapes: list[Shape]) -> Shape:
"""Find the shape with the largest area."""
return max(shapes, key=lambda s: s.area())
def print_report(shapes: list[Shape]) -> None:
"""Print a formatted report of all shapes."""
print(f"{'Type':<12} {'Color':<10} {'Area':>10} {'Perimeter':>10}")
print("-" * 44)
for shape in shapes:
print(f"{type(shape).__name__:<12} {shape.color:<10} "
f"{shape.area():>10.2f} {shape.perimeter():>10.2f}")
print("-" * 44)
print(f"{'Total':<12} {'':<10} {total_area(shapes):>10.2f}")
shapes = [
Circle(5, color="red", filled=True),
Rectangle(4, 7, color="blue"),
Triangle(3, 4, 5, color="green", filled=True),
Circle(2.5, color="yellow"),
Rectangle(10, 10, color="purple", filled=True),
]
print_report(shapes)
print()
print(f"Largest shape: {largest_shape(shapes)}")
Expected output:
Type Color Area Perimeter
--------------------------------------------
Circle red 78.54 31.42
Rectangle blue 28.00 22.00
Triangle green 6.00 12.00
Circle yellow 19.63 15.71
Rectangle purple 100.00 40.00
--------------------------------------------
Total 232.17
Largest shape: Rectangle | color: purple | filled | area: 100.00 | perimeter: 40.00
The print_report function doesn't contain a single isinstance check. It doesn't know about circles, rectangles, or triangles. It just knows about shapes — and every shape knows how to calculate its own area and perimeter. If you add a Pentagon class tomorrow, print_report works with it automatically.
Step 4: The Square Dilemma
Should Square be a subclass of Rectangle? Mathematically, a square is a rectangle where width equals height. But in code, this can violate the Liskov Substitution Principle.
Consider:
class Square(Rectangle):
"""A square is a rectangle where width == height... right?"""
def __init__(self, side: float, color: str = "black",
filled: bool = False):
super().__init__(side, side, color, filled)
This seems fine at first. But what if someone later modifies a shape's dimensions?
def stretch_horizontally(rect: Rectangle, factor: float) -> None:
"""Double the width of a rectangle."""
rect.width = rect.width * factor
sq = Square(5, color="red")
print(f"Before: {sq.width}x{sq.height}, area={sq.area()}")
# Before: 5x5, area=25.00
stretch_horizontally(sq, 2)
print(f"After: {sq.width}x{sq.height}, area={sq.area()}")
# After: 10x5, area=50.00 -- it's no longer a square!
The square has become a non-square rectangle — its invariant (width == height) has been broken. Code that receives a Square and expects it to stay square after modification will produce wrong results.
Better design: Make Square a sibling of Rectangle that both inherit from Shape, or have Rectangle.is_square() check the condition dynamically.
Discussion Questions
-
The
Triangleclass validates the triangle inequality in__init__. Why is it important to validate input during construction rather than inarea()orperimeter()? -
If you needed to add a
draw()method that renders shapes on screen, where would you put it? Would it be abstract? Would it use composition (a separateRendererobject)? -
The
describe()method inShapecallsself.area()— a method defined in the subclass. How does this work ifShapedoesn't have anarea()implementation? -
The Shape hierarchy uses class inheritance for the type system (
Circleis aShape) and method overriding for behavior (Circle.area()replacesShape.area()). Can you think of a situation where you'd want the type relationship but NOT the behavior relationship? -
How would you design a
CompoundShapeclass that contains multiple shapes and calculates its area as the sum of its component areas? Would it use inheritance, composition, or both?