Classes, Methods & Protocols
In this chapter, we'll learn about object-oriented programming in Python with a TDD approach. We'll cover:
- Defining classes with
class - Instance methods and the
selfparameter - The
__init__constructor - Python protocols (dunder methods)
- Properties and encapsulation
- Inheritance basics
Your first class
Let's build a Rectangle class using TDD.
Write the test first
Create test_shapes.py:
from shapes import Rectangle
def test_rectangle_area():
rectangle = Rectangle(10, 5)
got = rectangle.area()
want = 50
assert got == want
Make it compile
Create shapes.py:
class Rectangle:
pass
Run the test:
TypeError: Rectangle() takes no arguments
Add the constructor
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
The __init__ method is Python's constructor. It's called when you create a new instance. The self parameter refers to the instance being created.
Run the test:
AttributeError: 'Rectangle' object has no attribute 'area'
Add the area method
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
The test passes!
Add more tests
def test_rectangle_perimeter():
rectangle = Rectangle(10, 5)
got = rectangle.perimeter()
want = 30
assert got == want
Implementation:
class Rectangle:
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)
Understanding self
self is the first parameter of every instance method. It's how methods access the instance's attributes:
class Dog:
def __init__(self, name):
self.name = name # Store name on the instance
def bark(self):
return f"{self.name} says woof!" # Access instance attribute
Test:
def test_dog_bark():
dog = Dog("Buddy")
got = dog.bark()
want = "Buddy says woof!"
assert got == want
Dunder methods (protocols)
Python uses special methods (called "dunder" methods for "double underscore") to define how objects behave with built-in operations.
__str__ - String representation
def test_rectangle_str():
rectangle = Rectangle(10, 5)
got = str(rectangle)
want = "Rectangle(10 x 5)"
assert got == want
Implementation:
class Rectangle:
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)
def __str__(self):
return f"Rectangle({self.width} x {self.height})"
__repr__ - Developer representation
__repr__ should return a string that could recreate the object:
def test_rectangle_repr():
rectangle = Rectangle(10, 5)
got = repr(rectangle)
want = "Rectangle(10, 5)"
assert got == want
Implementation:
def __repr__(self):
return f"Rectangle({self.width}, {self.height})"
__eq__ - Equality
def test_rectangle_equality():
r1 = Rectangle(10, 5)
r2 = Rectangle(10, 5)
r3 = Rectangle(5, 10)
assert r1 == r2
assert r1 != r3
Implementation:
def __eq__(self, other):
if not isinstance(other, Rectangle):
return NotImplemented
return self.width == other.width and self.height == other.height
__lt__ and comparison operators
For sorting, implement __lt__ (less than):
def test_rectangle_comparison():
r1 = Rectangle(10, 5) # area = 50
r2 = Rectangle(6, 6) # area = 36
assert r2 < r1 # Compare by area
assert sorted([r1, r2]) == [r2, r1]
Implementation:
def __lt__(self, other):
return self.area() < other.area()
With __lt__ and __eq__, Python can derive the other comparison operators.
__len__ - Length
class Playlist:
def __init__(self):
self.songs = []
def add(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
Test:
def test_playlist_length():
playlist = Playlist()
assert len(playlist) == 0
playlist.add("Song 1")
playlist.add("Song 2")
assert len(playlist) == 2
Properties
Properties let you control access to attributes:
class Circle:
def __init__(self, radius):
self._radius = radius # Convention: underscore means "private"
@property
def radius(self):
"""Get the radius."""
return self._radius
@radius.setter
def radius(self, value):
"""Set the radius, ensuring it's positive."""
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
"""Calculate area (read-only property)."""
import math
return math.pi * self._radius ** 2
Test:
import pytest
from shapes import Circle
def test_circle_radius():
circle = Circle(5)
assert circle.radius == 5
circle.radius = 10
assert circle.radius == 10
def test_circle_negative_radius():
circle = Circle(5)
with pytest.raises(ValueError):
circle.radius = -1
def test_circle_area():
import math
circle = Circle(5)
expected = math.pi * 25
assert circle.area == pytest.approx(expected)
Note: pytest.approx handles floating-point comparison.
Class attributes vs instance attributes
class Dog:
# Class attribute - shared by all instances
species = "Canis familiaris"
def __init__(self, name):
# Instance attribute - unique to each instance
self.name = name
Test:
def test_dog_attributes():
dog1 = Dog("Buddy")
dog2 = Dog("Max")
# Instance attributes are unique
assert dog1.name == "Buddy"
assert dog2.name == "Max"
# Class attribute is shared
assert dog1.species == "Canis familiaris"
assert dog2.species == "Canis familiaris"
assert Dog.species == "Canis familiaris"
Class methods and static methods
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, fahrenheit):
"""Create a Temperature from Fahrenheit."""
celsius = (fahrenheit - 32) * 5 / 9
return cls(celsius)
@staticmethod
def is_freezing(celsius):
"""Check if temperature is at or below freezing."""
return celsius <= 0
Test:
def test_temperature_from_fahrenheit():
temp = Temperature.from_fahrenheit(32)
assert temp.celsius == pytest.approx(0)
def test_is_freezing():
assert Temperature.is_freezing(0) is True
assert Temperature.is_freezing(-5) is True
assert Temperature.is_freezing(10) is False
Inheritance
Classes can inherit from other classes:
class Shape:
"""Base class for shapes."""
def area(self):
raise NotImplementedError("Subclasses must implement area()")
def perimeter(self):
raise NotImplementedError("Subclasses must implement perimeter()")
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)
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side) # Call parent constructor
Test:
def test_square():
square = Square(5)
assert square.area() == 25
assert square.perimeter() == 20
def test_square_is_rectangle():
square = Square(5)
assert isinstance(square, Rectangle)
assert isinstance(square, Shape)
Composition over inheritance
Often, composition is preferable to inheritance:
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return "Engine started"
class Car:
def __init__(self, engine):
self.engine = engine # Composition: Car HAS an Engine
def start(self):
return self.engine.start()
Test:
def test_car_with_engine():
engine = Engine(200)
car = Car(engine)
assert car.start() == "Engine started"
assert car.engine.horsepower == 200
A complete example: BankAccount
Let's build a BankAccount class with full TDD:
# test_bank.py
import pytest
from bank import BankAccount
def test_initial_balance():
account = BankAccount("Alice", 100)
assert account.balance == 100
def test_deposit():
account = BankAccount("Alice", 100)
account.deposit(50)
assert account.balance == 150
def test_withdraw():
account = BankAccount("Alice", 100)
account.withdraw(30)
assert account.balance == 70
def test_withdraw_insufficient_funds():
account = BankAccount("Alice", 100)
with pytest.raises(ValueError, match="Insufficient funds"):
account.withdraw(150)
def test_account_str():
account = BankAccount("Alice", 100)
assert str(account) == "BankAccount(Alice: $100.00)"
Implementation:
# bank.py
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
def __str__(self):
return f"BankAccount({self.owner}: ${self._balance:.2f})"
def __repr__(self):
return f"BankAccount({self.owner!r}, {self._balance})"
Wrapping up
We've covered:
- Classes - Define with
class ClassName: __init__- Constructor methodself- Reference to the current instance- Instance methods - Functions that operate on instance data
- Dunder methods -
__str__,__repr__,__eq__,__lt__,__len__ - Properties -
@propertyfor controlled attribute access - Class vs instance attributes - Shared vs unique data
@classmethodand@staticmethod- Alternative constructors and utilities- Inheritance - Create specialized classes with
class Child(Parent): - Composition - Building classes from other classes
TDD and classes
When testing classes:
- Test the constructor and initial state
- Test each method independently
- Test edge cases and error conditions
- Test how the class interacts with built-in functions (
len,str,==)
Classes are a powerful way to organize code. Combined with TDD, you can build robust, well-tested object-oriented systems.