Classes and Objects

Medium45 min read

Defining Classes

Why Object Orientation Matters

The Problem: Procedural code with global mutable state and ad-hoc dicts becomes unreadable once a system grows past ~1000 lines.

The Solution: Classes group data and behavior, dataclasses kill the __init__/__repr__/__eq__ boilerplate, and __slots__ + frozen instances give immutability and memory savings when you need them.

Real Impact: Done well, classes are the difference between a script that works once and a system that scales to a team — they make invariants explicit and refactoring safe.

Real-World Analogy

Think of a class as a blueprint and an object as a building:

  • Class = the architectural plan — describes what attributes/methods exist
  • Instance = an actual building constructed from the plan — has its own values
  • self = the building referring to itself when it acts on its own state
  • @dataclass = auto-generated paperwork — __init__/__repr__/__eq__ for free
  • Inheritance = extending a base plan to a specialised variant
class Dog:
    # Class attribute — shared by all instances
    species = "Canis lupus familiaris"

    def __init__(self, name: str, age: int) -> None:
        # Instance attributes — unique per object
        self.name = name
        self.age = age

    def bark(self) -> str:
        return f"{self.name} says woof!"

    def __repr__(self) -> str:
        return f"Dog(name={self.name!r}, age={self.age})"


rex = Dog("Rex", 3)
print(rex.bark())          # Rex says woof!
print(rex)                # Dog(name='Rex', age=3)

self is a convention

The first parameter of an instance method receives the instance. The name self is a convention — any name works, but always use self in production code.

Inheritance

class Animal:
    def __init__(self, name: str):
        self.name = name

    def speak(self) -> str:
        raise NotImplementedError

class Dog(Animal):
    def __init__(self, name: str, breed: str):
        super().__init__(name)       # call parent
        self.breed = breed

    def speak(self) -> str:
        return f"{self.name} (a {self.breed}) barks"


class Puppy(Dog):
    def speak(self) -> str:
        return f"{self.name} yips"

print(isinstance(Puppy("Bo", "Lab"), Animal))   # True

Multiple Inheritance and MRO

Python supports multiple inheritance. Method resolution follows the Method Resolution Order (MRO) — left to right, depth first, with C3 linearization.

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

print(D.__mro__)
# (D, B, C, A, object)

Dataclasses (Python 3.7+)

The boilerplate-free way to define value classes. @dataclass auto-generates __init__, __repr__, and __eq__.

from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    email: str
    is_admin: bool = False
    tags: list[str] = field(default_factory=list)


u = User("Alice", "[email protected]")
print(u)
# User(name='Alice', email='[email protected]', is_admin=False, tags=[])

# Equality is by value, not identity
User("A", "x") == User("A", "x")        # True

Frozen / Immutable Dataclasses

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float

# Hashable (can be dict key / set member); attempts to assign raise FrozenInstanceError

slots=True for memory

Without __slots__, every instance carries a per-instance __dict__. slots=True on a dataclass declares attributes statically and saves ~30-50% memory per instance.

Methods: instance, class, static

class Circle:
    pi = 3.14159

    def __init__(self, radius: float):
        self.radius = radius

    # Instance method — receives self
    def area(self) -> float:
        return Circle.pi * self.radius ** 2

    # Class method — receives the class
    @classmethod
    def unit(cls):
        return cls(1)

    # Static method — no implicit first arg
    @staticmethod
    def describe() -> str:
        return "A closed curve where all points are equidistant from a center."

Circle.unit()              # alt constructor — Circle(1)
Circle.describe()          # no instance needed

Properties

class Temperature:
    def __init__(self, celsius: float):
        self._c = celsius

    @property
    def celsius(self) -> float:
        return self._c

    @celsius.setter
    def celsius(self, value: float):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._c = value

    @property
    def fahrenheit(self) -> float:
        return self._c * 9/5 + 32

Dunder (Magic) Methods

Special methods that hook into Python's syntax. Implement them to make your objects behave like built-ins.

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

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

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

    def __hash__(self):
        return hash((self.x, self.y))

    def __len__(self):
        return int((self.x**2 + self.y**2) ** 0.5)

Common Dunder Methods

MethodEnables
__init__Constructor
__repr__Developer-friendly string (in REPL, debug)
__str__User-friendly string (used by print)
__eq__, __lt__ ...==, <, etc.
__hash__Use as dict key / set member
__len__len(obj)
__iter__, __next__Iteration protocol
__getitem__, __setitem__obj[key] syntax
__enter__, __exit__Context manager (with)
__call__Instance is callable like a function

🎯 Practice Exercises

Exercise 1: BankAccount

Class with deposit, withdraw, and a balance property. Raise on overdrafts. Add __repr__ and __eq__.

Exercise 2: Money dataclass

Define a frozen, slotted Money dataclass with amount and currency. Implement __add__ that refuses to add different currencies.

Exercise 3: Stack

Implement a Stack class. Support push, pop, peek, __len__, __bool__ (empty?), and iteration.

Exercise 4: Alternate constructor

Add a @classmethod from_string to your Money dataclass that parses "USD 12.50".