Python Book · Chapter 10

Classes and objects

A class bundles data together with the functions that work on it. Most CS courses build on this idea, called object-oriented programming, so it is worth getting comfortable with early.

Your first class

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount


acct = BankAccount("Ada")
acct.deposit(50)
acct.withdraw(20)
print(acct.balance)   # 30

A class is a blueprint; acct is an object (or instance) built from it. You can make as many objects as you like, and each has its own data.

A readable printout: __repr__

By default, printing an object shows something unhelpful like <__main__.BankAccount object at 0x...>. Define __repr__ to fix that:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x}, {self.y})"

print(Point(3, 4))   # Point(3, 4)

Methods with double underscores on both sides are called special methods (or "dunder" methods). Python calls them for you at particular moments.

Equality: __eq__

Without help, two objects are equal only if they are the very same object:

Point(1, 2) == Point(1, 2)   # False, until we define __eq__

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)

Point(1, 2) == Point(1, 2)   # True

Inheritance

A class can build on another, reusing its methods and adding or changing some:

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0, rate=0.02):
        super().__init__(owner, balance)
        self.rate = rate

    def add_interest(self):
        self.deposit(self.balance * self.rate)


s = SavingsAccount("Grace", 100)
s.add_interest()
print(s.balance)   # 102.0

super().__init__ runs the parent class's setup, so you do not repeat it. SavingsAccount also gets deposit and withdraw for free. Use inheritance when the new class really is a kind of the old one; otherwise, store one object inside another.

Dataclasses

Classes that mostly hold data need a lot of repetitive code. The dataclass decorator writes __init__, __repr__ and __eq__ for you from type-annotated fields:

from dataclasses import dataclass

@dataclass
class Student:
    name: str
    year: int
    gpa: float = 0.0

s = Student("Ada", 2, 3.9)
print(s)                          # Student(name='Ada', year=2, gpa=3.9)
s == Student("Ada", 2, 3.9)       # True

Exercises

1. Rectangle

Write a Rectangle class with width and height, and methods area() and perimeter().

Show answer
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)

r = Rectangle(3, 4)
r.area(), r.perimeter()   # (12, 14)

2. A counter that remembers

Write a class Counter with increment(), reset() and a value attribute starting at 0. Then make two counters and show that they are independent.

Show answer
class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

    def reset(self):
        self.value = 0

a = Counter()
b = Counter()
a.increment()
a.increment()
b.increment()
print(a.value, b.value)   # 2 1

Each object has its own self.value. (The standard library also has a collections.Counter, which is a different thing.)

3. Overdraft

Write CheckingAccount, a subclass of BankAccount that allows the balance to go down to −100 but no further.

Show answer
class CheckingAccount(BankAccount):
    LIMIT = -100

    def withdraw(self, amount):
        if self.balance - amount < self.LIMIT:
            raise ValueError("overdraft limit reached")
        self.balance -= amount

c = CheckingAccount("Linus", 20)
c.withdraw(100)
print(c.balance)   # -80

Defining withdraw again in the subclass overrides the parent's version. LIMIT is a class attribute, shared by every checking account.