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 without an
# implementation for 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:
"""Multiply the width of a rectangle by factor."""
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
stretch_horizontally(sq, 2)
print(f"After: {sq.width}x{sq.height}, area={sq.area()}")
# After: 10x5, area=50 -- it's no longer a square!
The square has become a non-square rectangle — its invariant (a rule that must always hold for the object, here width == height) has been broken. Code that receives a Square and expects it to stay square after modification will produce wrong results.
Can you fix this inside Square by making the width setter change the height too? That's exactly the version in the Liskov walkthrough in Section 15.9 of the chapter, and it fails the other way: now stretch_horizontally changes the height, which no caller of a Rectangle function expects. As long as squares are mutable, there's no way to make Square honor both its own rule and Rectangle's promises. That's the dilemma.
Better design: Make Square a sibling of Rectangle that both inherit from Shape, or skip the Square class entirely and have Rectangle.is_square() check the condition whenever you need it.
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()supplies the body thatShape's abstractarea()leaves out). 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?