Learning Goals
3 minBy the end of this lesson you can:
- Design a small class hierarchy that shares a common interface.
- Use
NotImplementedErrorin the parent to enforce the contract. - Write generic loop code that works on any class in the family.
- Mix inheritance polymorphism with class methods that take other instances (overlap checks).
Warm-Up · The Plan
5 minWe'll build a tiny shape library used by a fictional CAD app. The classes:
Shape (parent) ├─ Circle ├─ Square ├─ Rectangle ├─ Triangle └─ RegularPolygon
Every shape has three things in common:
area()— return the area.perimeter()— return the perimeter.describe()— print a one-line summary.
The parent Shape defines describe for everyone but raises NotImplementedError on area and perimeter. Each child fills those in.
Once five classes share an interface, every report, every loop, every aggregator becomes generic. Add a sixth shape — say a Hexagon — and not one line of report code changes.
Task 1 · The Parent
5 min# shapes.py — base class enforcing the interface class Shape: """Base class. Children must implement area() and perimeter().""" def area(self): raise NotImplementedError("Each Shape must implement area()") def perimeter(self): raise NotImplementedError("Each Shape must implement perimeter()") def describe(self): name = type(self).__name__ print(f" {name:<18} area={self.area():.2f} perim={self.perimeter():.2f}")
type(self).__name__ gives the child class's name as a string — so describe labels each shape correctly without each child having to specify.
Task 2 · The Children
10 minimport math class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return math.pi * self.radius ** 2 def perimeter(self): return 2 * math.pi * self.radius class Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side ** 2 def perimeter(self): return 4 * self.side class Rectangle(Shape): def __init__(self, w, h): self.w = w; self.h = h def area(self): return self.w * self.h def perimeter(self): return 2 * (self.w + self.h) class Triangle(Shape): """Right-angled triangle — base, height, and hypotenuse via Pythagoras.""" def __init__(self, base, height): self.base = base; self.height = height def area(self): return 0.5 * self.base * self.height def perimeter(self): hyp = math.sqrt(self.base ** 2 + self.height ** 2) return self.base + self.height + hyp class RegularPolygon(Shape): """N-sided polygon — regular (equal sides).""" def __init__(self, sides, side_length): self.sides = sides self.side_length = side_length def area(self): # Standard formula: (n × s²) / (4 × tan(π/n)) n = self.sides s = self.side_length return (n * s * s) / (4 * math.tan(math.pi / n)) def perimeter(self): return self.sides * self.side_length
Five classes. Each has a different area, a different perimeter. describe is inherited from the parent — no overriding needed.
Task 3 · Generic Operations
10 mindef total_area(shapes): return sum(s.area() for s in shapes) def total_perimeter(shapes): return sum(s.perimeter() for s in shapes) def largest(shapes): return max(shapes, key=lambda s: s.area()) def sort_by_area(shapes): return sorted(shapes, key=lambda s: s.area()) def filter_by_min_area(shapes, threshold): return [s for s in shapes if s.area() >= threshold] canvas = [ Circle(3), Square(4), Rectangle(5, 2), Triangle(3, 4), RegularPolygon(6, 2), ] print("=== Inventory ===") for s in canvas: s.describe() print(f"\nTotal area : {total_area(canvas):.2f}") print(f"Total perimeter: {total_perimeter(canvas):.2f}") big = largest(canvas) print(f"\nLargest shape: {type(big).__name__} (area {big.area():.2f})") print("\n=== Sorted (small → large) ===") for s in sort_by_area(canvas): s.describe() print("\n=== Area >= 10 ===") for s in filter_by_min_area(canvas, 10): s.describe()
Five generic functions. Each works on any list of shapes — past, present, future. total_area, largest, sort_by_area all use s.area() without caring which shape is which.
Task 4 · Inter-Shape Interaction (stretch)
8 minAdd a method to Shape that compares against another shape.
class Shape: # ... existing methods ... def bigger_than(self, other): return self.area() > other.area() c1 = Circle(3) c2 = Square(4) print(c1.bigger_than(c2)) # True/False — works for any pair
The method lives on Shape (the parent), so every child inherits it. It calls self.area() and other.area() — both polymorphic. Adding this one method gives every shape a meaningful comparison without writing 5×5 = 25 specific pairs.
Putting It All Together · shapes.py
8 minAssemble the four tasks into one file. Add a sixth shape — your choice — and confirm it slots in without changing any of the generic functions.
Show one complete solution
# shapes.py — final, with all five classes + generic ops import math class Shape: def area(self): raise NotImplementedError def perimeter(self): raise NotImplementedError def describe(self): name = type(self).__name__ print(f" {name:<18} area={self.area():.2f} perim={self.perimeter():.2f}") def bigger_than(self, other): return self.area() > other.area() class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return math.pi * self.radius ** 2 def perimeter(self): return 2 * math.pi * self.radius class Square(Shape): def __init__(self, side): self.side = side def area(self): return self.side ** 2 def perimeter(self): return 4 * self.side class Rectangle(Shape): def __init__(self, w, h): self.w = w; self.h = h def area(self): return self.w * self.h def perimeter(self): return 2 * (self.w + self.h) class Triangle(Shape): def __init__(self, base, height): self.base = base; self.height = height def area(self): return 0.5 * self.base * self.height def perimeter(self): return self.base + self.height + math.sqrt(self.base ** 2 + self.height ** 2) class RegularPolygon(Shape): def __init__(self, sides, side_length): self.sides = sides; self.side_length = side_length def area(self): n, s = self.sides, self.side_length return (n * s * s) / (4 * math.tan(math.pi / n)) def perimeter(self): return self.sides * self.side_length # Sixth shape — slotted in! class Ellipse(Shape): def __init__(self, a, b): self.a = a; self.b = b def area(self): return math.pi * self.a * self.b def perimeter(self): # Ramanujan's approximation return math.pi * (3 * (self.a + self.b) - math.sqrt((3 * self.a + self.b) * (self.a + 3 * self.b))) # Generic operations def total_area(shapes): return sum(s.area() for s in shapes) def largest(shapes): return max(shapes, key=lambda s: s.area()) canvas = [ Circle(3), Square(4), Rectangle(5, 2), Triangle(3, 4), RegularPolygon(6, 2), Ellipse(4, 3), # the new one ] print("=== Canvas ===") for s in canvas: s.describe() print(f"\nTotal area : {total_area(canvas):.2f}") print(f"Largest : {type(largest(canvas)).__name__}") # Bigger-than comparison print(f"\nCircle(3) bigger than Square(4)? {Circle(3).bigger_than(Square(4))}")
Non-negotiables: a Shape parent with the interface contract, five (or six) children each implementing area + perimeter, and generic operations that work on any of them. The Ellipse add proves the contract scales — adding a shape changes only the shape's own file, not the report code.
Recap · The First 12 Lessons
5 minYou've completed the OOP foundation of Level 3.
Classes 101 PY-L3-01 blueprint vs instance Attributes PY-L3-02 data on self Methods PY-L3-03 functions with self __init__ PY-L3-04 auto-setup constructor Many objects PY-L3-05 lists of instances Dungeon Hero game PY-L3-06 a real class in anger Inheritance PY-L3-07 code reuse via Child(Parent) Override + super PY-L3-08 customise inherited methods Monster family PY-L3-09 inheritance applied Encapsulation PY-L3-10 _underscore convention Polymorphism basics PY-L3-11 same method, different classes Polymorphism practice PY-L3-12 shapes library
Three big OOP ideas now sit in your fingers: encapsulation (objects hide their data), inheritance (children reuse parent code), polymorphism (same interface, different implementations). Real software is mostly these three pieces, plus a few advanced patterns coming in PY-L3-13 onwards.
The remaining 36 lessons cover dunders (__str__, __eq__, __add__), @property and @classmethod, lambdas + comprehensions deep dive, generators with yield, recursion (including fractal art with turtle), classic algorithms (binary search, sorting, complexity), and the L3 data structures (stacks, queues, linked lists). It ends with the Dungeon Quest capstone and PCEP exam prep.
Homework
4 minTake the shapes library and add three real-world generic operations:
average_area(shapes)— return the mean area.group_by_area_bucket(shapes)— return a dict{"small": [...], "medium": [...], "large": [...]}with thresholds of 10 and 50.shapes_at_least_as_big_as(shapes, reference_shape)— return a list of shapes whose area is >=reference_shape.area().
Sample · three new helpers
def average_area(shapes): return sum(s.area() for s in shapes) / len(shapes) def group_by_area_bucket(shapes): buckets = {"small": [], "medium": [], "large": []} for s in shapes: a = s.area() if a < 10: buckets["small"].append(s) elif a < 50: buckets["medium"].append(s) else: buckets["large"].append(s) return buckets def shapes_at_least_as_big_as(shapes, ref): threshold = ref.area() return [s for s in shapes if s.area() >= threshold] # Usage print(f"Avg area: {average_area(canvas):.2f}") groups = group_by_area_bucket(canvas) for label, items in groups.items(): print(f"\n{label}:") for s in items: s.describe() print(f"\nShapes >= Circle(2): {len(shapes_at_least_as_big_as(canvas, Circle(2)))}")
Non-negotiables: three functions, each generic (works on any shape mix), and each using s.area() polymorphically. The whole point of L3 so far: write once, scale forever.