Teacher's Guide

Chapter 5: Advanced Classes

Teaching Objectives

By the end of this chapter, students should:

  • Understand object-oriented programming concepts at an advanced level
  • Master Python's special methods (dunder methods)
  • Learn about properties, descriptors, and attribute access control
  • Comprehend class and static methods
  • Learn about inheritance, multiple inheritance, and mixins
  • Understand metaclasses and their applications
  • Apply these concepts to create well-designed class hierarchies

Preparation

Before teaching this chapter, ensure:

  • Students have a solid understanding of basic class creation and usage
  • You have prepared examples that demonstrate the power of advanced class features
  • You have real-world examples of effective class design
  • Students are familiar with basic inheritance and method overriding

Lesson Overview

1. Special Methods (Magic/Dunder Methods) (25 minutes)

Start by explaining special methods:

  • Special methods (also called magic or dunder methods) allow classes to implement Python's built-in behaviors
  • They are surrounded by double underscores (e.g., __init__, __str__)
  • They enable operator overloading and customizing object behavior

Creating classes with special methods:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    # String representation
    def __str__(self):
        return f"Vector({self.x}, {self.y})"
    
    # Representation for developers
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"
    
    # Addition operator (+)
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    # Subtraction operator (-)
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    # Equality comparison (==)
    def __eq__(self, other):
        if not isinstance(other, Vector):
            return False
        return self.x == other.x and self.y == other.y
    
    # Length (magnitude) of the vector
    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5
    
    # Boolean evaluation
    def __bool__(self):
        return bool(self.x or self.y)

# Testing our Vector class
v1 = Vector(3, 4)
v2 = Vector(1, 2)

print(f"v1 = {v1}")          # Uses __str__
print(f"v2 = {v2}")
print(f"v1 + v2 = {v1 + v2}")  # Uses __add__
print(f"v1 - v2 = {v1 - v2}")  # Uses __sub__
print(f"v1 == v2: {v1 == v2}")  # Uses __eq__
print(f"|v1| = {abs(v1)}")     # Uses __abs__
print(f"bool(v1): {bool(v1)}")  # Uses __bool__
print(f"bool(Vector(0, 0)): {bool(Vector(0, 0))}")

Teaching points:

  • Special methods make classes behave like built-in types
  • They allow intuitive operations like +, -, ==, etc.
  • Most operators can be overloaded using special methods
  • __str__ is for human-readable representation, __repr__ is for developer-focused representation

More special methods:

class ShoppingCart:
    def __init__(self):
        self.items = []
    
    # Add item to cart
    def add(self, item, price, quantity=1):
        self.items.append({"item": item, "price": price, "quantity": quantity})
    
    # Length operation (len())
    def __len__(self):
        return sum(item["quantity"] for item in self.items)
    
    # Containment check (in operator)
    def __contains__(self, item_name):
        return any(item["item"] == item_name for item in self.items)
    
    # Iteration (for loop)
    def __iter__(self):
        return iter(self.items)
    
    # Indexing (cart[0])
    def __getitem__(self, index):
        return self.items[index]
    
    # Total price calculation
    def __call__(self):
        return sum(item["price"] * item["quantity"] for item in self.items)

# Using our shopping cart
cart = ShoppingCart()
cart.add("Apple", 0.5, 3)
cart.add("Banana", 0.3, 6)
cart.add("Orange", 0.7, 2)

print(f"Items in cart: {len(cart)}")  # Uses __len__
print(f"'Apple' in cart: {'Apple' in cart}")  # Uses __contains__
print(f"'Mango' in cart: {'Mango' in cart}")  # Uses __contains__

print("Cart items:")
for item in cart:  # Uses __iter__
    print(f"  {item['quantity']} x {item['item']}: ${item['price']:.2f} each")

print(f"First item: {cart[0]['item']}")  # Uses __getitem__
print(f"Total price: ${cart():.2f}")  # Uses __call__

Teaching points:

  • __len__ enables the len() function
  • __contains__ enables the in operator
  • __iter__ enables iteration with for loops
  • __getitem__ enables indexing and slicing
  • __call__ makes objects callable like functions

2. Properties and Attribute Access (20 minutes)

Explain properties and attribute control:

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius
    
    # Getter for celsius
    @property
    def celsius(self):
        return self._celsius
    
    # Setter for celsius
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value
    
    # Getter for fahrenheit (calculated)
    @property
    def fahrenheit(self):
        return self.celsius * 9/5 + 32
    
    # Setter for fahrenheit (converts to celsius)
    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9
    
    # Another property (read-only)
    @property
    def kelvin(self):
        return self.celsius + 273.15

# Using our Temperature class
temp = Temperature()
print(f"Default temperature: {temp.celsius}°C, {temp.fahrenheit}°F, {temp.kelvin}K")

temp.celsius = 25
print(f"After setting celsius: {temp.celsius}°C, {temp.fahrenheit}°F, {temp.kelvin}K")

temp.fahrenheit = 68
print(f"After setting fahrenheit: {temp.celsius}°C, {temp.fahrenheit}°F, {temp.kelvin}K")

try:
    temp.celsius = -300  # Should raise an error
except ValueError as e:
    print(f"Error: {e}")

# Note that kelvin is read-only
try:
    temp.kelvin = 300
except AttributeError as e:
    print(f"Error setting kelvin: {e}")

Teaching points:

  • Properties provide getter/setter behavior while maintaining a clean API
  • Properties can perform validation on assignments
  • They can create computed/derived attributes
  • They help enforce encapsulation by controlling access
  • Read-only properties are created by defining only the getter

Attribute access with __getattr__ and __setattr__:

class FlexibleDict:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)
    
    # Handle missing attributes
    def __getattr__(self, name):
        print(f"Attribute '{name}' not found, returning None")
        return None
    
    # Control attribute setting
    def __setattr__(self, name, value):
        print(f"Setting attribute '{name}' to {value}")
        if name.startswith('_'):
            # Private attributes (starting with _) are stored normally
            super().__setattr__(name, value)
        else:
            # Public attributes are stored in uppercase
            super().__setattr__(name.upper(), value)
    
    # Handle attribute access (even for existing attributes)
    def __getattribute__(self, name):
        print(f"Getting attribute '{name}'")
        # For public attributes, try the uppercase version
        if not name.startswith('_') and not name.isupper():
            try:
                return super().__getattribute__(name.upper())
            except AttributeError:
                return super().__getattribute__(name)
        return super().__getattribute__(name)

# Using our FlexibleDict
obj = FlexibleDict(name="John", age=30)
print(f"obj.name: {obj.name}")
print(f"obj.age: {obj.age}")
print(f"obj.NAME: {obj.NAME}")

obj.job = "Developer"
print(f"After setting job, obj.job: {obj.job}")
print(f"obj.JOB: {obj.JOB}")

print(f"Non-existent attribute: {obj.address}")

Teaching points:

  • __getattr__ handles access to missing attributes
  • __setattr__ intercepts all attribute assignments
  • __getattribute__ intercepts all attribute access (even existing ones)
  • Be careful with __getattribute__ to avoid infinite recursion

3. Class and Static Methods (15 minutes)

Explain class and static methods:

class MathOperations:
    # Class variable
    pi = 3.14159
    
    def __init__(self, value):
        self.value = value
    
    # Instance method (has access to self)
    def double(self):
        return self.value * 2
    
    # Class method (has access to the class via cls)
    @classmethod
    def from_string(cls, string_value):
        try:
            value = float(string_value)
            return cls(value)  # Creates a new instance
        except ValueError:
            return None
    
    # Another class method
    @classmethod
    def get_pi(cls):
        return cls.pi
    
    # Static method (no access to instance or class)
    @staticmethod
    def is_positive(num):
        return num > 0
    
    # Static method for utility function
    @staticmethod
    def add(a, b):
        return a + b

# Using instance methods
math = MathOperations(5)
print(f"Double of 5: {math.double()}")

# Using class methods
math_from_string = MathOperations.from_string("10.5")
print(f"Created from string, double is: {math_from_string.double()}")
print(f"Pi value: {MathOperations.get_pi()}")

# Using static methods
print(f"Is 5 positive? {MathOperations.is_positive(5)}")
print(f"Is -2 positive? {MathOperations.is_positive(-2)}")
print(f"5 + 3 = {MathOperations.add(5, 3)}")

# Static methods don't need the class
add_func = MathOperations.add
print(f"Using extracted add function: 7 + 8 = {add_func(7, 8)}")

Teaching points:

  • Instance methods have access to instance data via self
  • Class methods have access to class data via cls (useful for alternative constructors)
  • Static methods are utility functions that don't access instance or class data
  • Class methods can be used to create factory methods or alternative constructors
  • Static methods are functionally the same as regular functions but organizationally belong to the class

4. Advanced Inheritance and Method Resolution Order (25 minutes)

Explore inheritance concepts:

# Base class
class Animal:
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return "Some sound"
    
    def introduce(self):
        return f"I am {self.name}, and I say {self.speak()}"

# Single inheritance
class Dog(Animal):
    def speak(self):
        return "Woof!"
    
    def fetch(self):
        return f"{self.name} is fetching a ball"

# Another child class
class Cat(Animal):
    def speak(self):
        return "Meow!"
    
    def scratch(self):
        return f"{self.name} is scratching"

# Multiple inheritance
class DogCat(Dog, Cat):
    def speak(self):
        dog_sound = Dog.speak(self)
        cat_sound = Cat.speak(self)
        return f"{dog_sound} and {cat_sound}"

# Using the classes
animal = Animal("Generic Animal")
dog = Dog("Buddy")
cat = Cat("Whiskers")
hybrid = DogCat("Confusion")

print(animal.introduce())
print(dog.introduce())
print(dog.fetch())
print(cat.introduce())
print(cat.scratch())
print(hybrid.introduce())
print(hybrid.fetch())
print(hybrid.scratch())

# Method Resolution Order (MRO)
print(f"DogCat MRO: {DogCat.__mro__}")

Teaching points:

  • Inheritance allows classes to reuse and extend functionality
  • Method overriding allows child classes to customize behavior
  • Multiple inheritance allows a class to inherit from multiple parents
  • Method Resolution Order (MRO) determines which method is called in multiple inheritance
  • Python uses the C3 linearization algorithm to create the MRO

Mixins:

# Mixin class (designed to add functionality)
class SwimMixin:
    def swim(self):
        return f"{self.name} is swimming"

class FlyMixin:
    def fly(self):
        return f"{self.name} is flying"

# Using mixins
class Duck(Animal, SwimMixin, FlyMixin):
    def speak(self):
        return "Quack!"

class Penguin(Animal, SwimMixin):
    def speak(self):
        return "Honk!"

# Using the classes with mixins
duck = Duck("Donald")
penguin = Penguin("Pingu")

print(duck.introduce())
print(duck.swim())
print(duck.fly())
print(penguin.introduce())
print(penguin.swim())

# MRO again
print(f"Duck MRO: {Duck.__mro__}")

Teaching points:

  • Mixins are classes designed to add specific functionality
  • They provide a way to share behavior without deep inheritance hierarchies
  • They're particularly useful for "can do" capabilities rather than "is a" relationships
  • In Python, mixins are just regular classes used in a specific way

Abstract Base Classes:

from abc import ABC, abstractmethod

# Abstract base class
class Shape(ABC):
    @abstractmethod
    def area(self):
        pass
    
    @abstractmethod
    def perimeter(self):
        pass
    
    def describe(self):
        return f"Area: {self.area()}, Perimeter: {self.perimeter()}"

# Concrete implementation
class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14159 * self.radius ** 2
    
    def perimeter(self):
        return 2 * 3.14159 * self.radius

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)

# Using abstract base classes
try:
    shape = Shape()  # This should fail
except TypeError as e:
    print(f"Error creating Shape: {e}")

circle = Circle(5)
rectangle = Rectangle(4, 6)

print(f"Circle: {circle.describe()}")
print(f"Rectangle: {rectangle.describe()}")

Teaching points:

  • Abstract Base Classes (ABCs) define interfaces that derived classes must implement
  • @abstractmethod decorator marks methods that must be overridden
  • ABCs cannot be instantiated directly
  • They ensure consistent interfaces across implementations
  • They're useful for establishing contracts or protocols

5. Descriptors (15 minutes)

Explain descriptors:

# Descriptor class
class TypedProperty:
    def __init__(self, name, property_type, default=None):
        self.name = f"_{name}"  # Private attribute name
        self.property_type = property_type
        self.default = default
    
    def __get__(self, instance, owner):
        if instance is None:
            return self  # Access via class
        return getattr(instance, self.name, self.default)
    
    def __set__(self, instance, value):
        if not isinstance(value, self.property_type):
            raise TypeError(f"Expected {self.property_type}, got {type(value)}")
        setattr(instance, self.name, value)

# Using descriptors
class Person:
    name = TypedProperty("name", str)
    age = TypedProperty("age", int)
    height = TypedProperty("height", float, 0.0)
    
    def __init__(self, name, age, height=None):
        self.name = name
        self.age = age
        if height is not None:
            self.height = height

# Testing the descriptor
person = Person("John", 30, 1.85)
print(f"Name: {person.name}, Age: {person.age}, Height: {person.height}")

try:
    person.age = "thirty"  # Should fail
except TypeError as e:
    print(f"Error setting age: {e}")

# Class access to descriptors
print(f"Person.name: {Person.name}")

Teaching points:

  • Descriptors control attribute access with __get__, __set__, and __delete__ methods
  • They're useful for implementing validation, type checking, or computed properties
  • Property decorators are built using descriptors under the hood
  • Descriptors can be reused across multiple classes
  • They're a powerful way to control attribute behavior

6. Metaclasses (20 minutes)

Introduce metaclasses:

# Basic metaclass example
class Meta(type):
    def __new__(mcs, name, bases, namespace):
        print(f"Creating class {name}")
        
        # Add a class attribute
        namespace['added_by_meta'] = "This was added by the metaclass"
        
        # Modify existing methods
        if 'greet' in namespace:
            original_greet = namespace['greet']
            
            def wrapped_greet(self):
                return f"Before: {original_greet(self)} :After"
            
            namespace['greet'] = wrapped_greet
        
        return super().__new__(mcs, name, bases, namespace)

# Using the metaclass
class Person(metaclass=Meta):
    def __init__(self, name):
        self.name = name
    
    def greet(self):
        return f"Hello, I'm {self.name}"

# Testing the metaclass
person = Person("Alice")
print(f"Person.added_by_meta: {Person.added_by_meta}")
print(f"person.greet(): {person.greet()}")

Teaching points:

  • Metaclasses are the "classes of classes"
  • They control class creation and behavior
  • __new__ is called when a class is being created
  • They can modify, add, or remove attributes and methods
  • They're a powerful but advanced feature that should be used sparingly

Practical metaclass example:

# Singleton metaclass
class Singleton(type):
    _instances = {}
    
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

# Registry metaclass
class Registry(type):
    classes = {}
    
    def __new__(mcs, name, bases, namespace):
        cls = super().__new__(mcs, name, bases, namespace)
        if name != 'Registrable':  # Don't register the base class
            mcs.classes[name] = cls
        return cls

# Using the singleton metaclass
class DatabaseConnection(metaclass=Singleton):
    def __init__(self, host, port):
        self.host = host
        self.port = port
        print(f"Initializing connection to {host}:{port}")
    
    def execute(self, query):
        print(f"Executing query: {query}")

# Using the registry metaclass
class Registrable(metaclass=Registry):
    pass

class TextProcessor(Registrable):
    @staticmethod
    def process(data):
        return data.upper()

class NumberProcessor(Registrable):
    @staticmethod
    def process(data):
        return int(data) * 2

# Testing the singleton
conn1 = DatabaseConnection("localhost", 3306)
conn2 = DatabaseConnection("example.com", 8080)  # Should reuse the same instance

print(f"conn1 === conn2: {conn1 is conn2}")
print(f"conn1.host: {conn1.host}, conn2.host: {conn2.host}")

# Testing the registry
print("Registered classes:")
for name, cls in Registry.classes.items():
    print(f"  {name}: {cls}")

# Using registered classes
data = "test"
for name, cls in Registry.classes.items():
    print(f"Processing with {name}: {cls.process(data)}")

Teaching points:

  • Metaclasses can implement patterns like Singleton or Registry
  • They're often used for framework-level functionality
  • They allow for class-level customization beyond what inheritance provides
  • Common applications include ORMs, serialization frameworks, and API libraries

7. Guided Practice (20 minutes)

Have students work through these exercises:

  1. Building a validating data class:

    # Exercise: Create a class that validates fields by type and constraints
    
    class Field:
        def __init__(self, field_type, required=True, min_value=None, max_value=None):
            self.field_type = field_type
            self.required = required
            self.min_value = min_value
            self.max_value = max_value
        
        # TODO: Implement __set__ and __get__ methods for validation
    
    class Model:
        # TODO: Add metaclass or class methods to process Field definitions
        pass
    
    # Example usage
    class Person(Model):
        name = Field(str, required=True)
        age = Field(int, min_value=0, max_value=120)
        email = Field(str, required=False)
    
    # TODO: Create a Person instance and test validation
    
  2. Extending built-in types:

    # Exercise: Create a custom dictionary with additional features
    
    class HistoryDict(dict):
        # TODO: Add functionality to track changes (additions, updates, deletions)
        # TODO: Implement methods to view history, undo changes, etc.
        pass
    
    # TODO: Test the HistoryDict with various operations
    

8. Advanced Project: Custom Collection Type (15 minutes)

Lead students through a more complex example:

# Creating a specialized collection type

class SortedList:
    def __init__(self, iterable=None, key=None):
        self._key = key or (lambda x: x)
        self._data = []
        if iterable:
            self._data = sorted(iterable, key=self._key)
    
    def __len__(self):
        return len(self._data)
    
    def __getitem__(self, index):
        return self._data[index]
    
    def __iter__(self):
        return iter(self._data)
    
    def __str__(self):
        return str(self._data)
    
    def __repr__(self):
        return f"SortedList({self._data})"
    
    def add(self, item):
        # Find insertion point using binary search
        from bisect import bisect_left
        
        # Transform items for comparison if key function is provided
        if self._key is not lambda x: x:
            # Create list of keys for existing items
            keys = [self._key(item) for item in self._data]
            # Find insertion point based on item's key
            pos = bisect_left(keys, self._key(item))
        else:
            # Use item directly if no key function
            pos = bisect_left(self._data, item)
            
        self._data.insert(pos, item)
        return self
    
    def remove(self, item):
        try:
            self._data.remove(item)
        except ValueError:
            raise ValueError(f"{item} not in list")
        return self
    
    def __add__(self, other):
        if isinstance(other, SortedList):
            # Create a new sorted list with combined elements
            return SortedList(self._data + other._data, key=self._key)
        elif isinstance(other, list):
            return SortedList(self._data + other, key=self._key)
        else:
            return NotImplemented

# Testing the SortedList
numbers = SortedList([3, 1, 4, 1, 5, 9, 2, 6])
print(f"Sorted numbers: {numbers}")

names = SortedList(["Charlie", "Alice", "Bob", "David"], key=lambda x: x.lower())
print(f"Sorted names: {names}")

names.add("Eve").add("Aaron")
print(f"After adding names: {names}")

names.remove("Bob")
print(f"After removing 'Bob': {names}")

combined = numbers + SortedList([8, 7])
print(f"Combined lists: {combined}")

# Test sorting with key
people = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
    {"name": "Charlie", "age": 35}
]

by_name = SortedList(people, key=lambda p: p["name"])
print("Sorted by name:")
for person in by_name:
    print(f"  {person['name']}, {person['age']}")

by_age = SortedList(people, key=lambda p: p["age"])
print("Sorted by age:")
for person in by_age:
    print(f"  {person['name']}, {person['age']}")

Teaching points:

  • Custom collection types can be created by implementing the appropriate special methods
  • The binary search algorithm provides efficient insertion into sorted collections
  • Key functions allow flexible sorting criteria
  • Method chaining (returning self) allows for fluent interfaces
  • Operator overloading makes custom collections behave like built-in types

9. Review and Discussion (10 minutes)

  • Review the key concepts covered
  • Ask students to explain in their own words:
    • What are special methods and how do they work?
    • How do properties and descriptors control attribute access?
    • What is the difference between class methods and static methods?
    • How does multiple inheritance work in Python?
    • What are metaclasses and when might you use them?

Common Challenges and Solutions

  • Special methods confusion: Students may be overwhelmed by the number of special methods. Focus on the most commonly used ones first.
  • Inheritance complexity: Multiple inheritance can be confusing. Use diagrams to visualize the MRO.
  • Metaclass concepts: Metaclasses are abstract and can be hard to grasp. Use simple examples first before moving to complex ones.
  • Property vs. descriptor: Clarify when to use each by focusing on reusability.

Extension Activities

For students who finish early:

  • Challenge them to implement a custom context manager using __enter__ and __exit__
  • Have them create a data validation framework using descriptors
  • Ask them to implement a simple ORM (Object-Relational Mapper) using metaclasses

Assessment

Look for these indicators of understanding:

  • Students can implement appropriate special methods for their classes
  • They can use properties and descriptors correctly for attribute control
  • They understand when to use class methods vs. static methods
  • They can design effective inheritance hierarchies
  • They grasp the concept of metaclasses even if they don't use them frequently

Resources

Chapter 5: Advanced Classes | Teacher's Guide