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

  1. The Triangle class validates the triangle inequality in __init__. Why is it important to validate input during construction rather than in area() or perimeter()?

  2. 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 separate Renderer object)?

  3. The describe() method in Shape calls self.area() — a method defined in the subclass. How does this work if Shape doesn't have an area() implementation?

  4. The Shape hierarchy uses class inheritance for the type system (Circle is a Shape) and method overriding for behavior (Circle.area() replaces Shape.area()). Can you think of a situation where you'd want the type relationship but NOT the behavior relationship?

  5. How would you design a CompoundShape class that contains multiple shapes and calculates its area as the sum of its component areas? Would it use inheritance, composition, or both?