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 thelen()function__contains__enables theinoperator__iter__enables iteration withforloops__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
@abstractmethoddecorator 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:
-
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 -
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