Part 2 · Intermediate Python · Module 10

Classes & Objects

Every value you have used so far, from 3 to "hello" to [1, 2], is an object built from a class. In this module you learn to write your own classes, so that data and the operations that keep it valid travel together as one unit.

⏱ ~2.5 hoursPrerequisites: M05 Functions, M06 Collections, M07 Errors

Why object-oriented programming?

Imagine you are tracking bank accounts with the tools from Part 1. You might store each account as a dictionary and write functions that operate on it:

account = {"owner": "Ada", "balance": 100.0}

def deposit(acct, amount):
    if amount <= 0:
        raise ValueError("deposit must be positive")
    acct["balance"] += amount

deposit(account, 50)
account["balance"] = -1_000_000   # nothing stops this!

This works, but it has three problems that grow with the size of a program:

  1. Nothing ties the data to its rules. The deposit function checks that amounts are positive, but any other line of code can write straight into account["balance"] and bypass the check.
  2. The shape is implicit. Is the key "balance" or "bal"? A typo creates a new key silently instead of failing.
  3. Related functions are scattered. Nothing groups deposit, withdraw, and statement together; a reader has to hunt for them.

Object-oriented programming (OOP) answers all three by bundling state (data) and behavior (functions) into one unit called an object. The blueprint for a kind of object is a class. The class decides what data each object holds and which operations are allowed, so the rules live right next to the data they protect. The set of rules that must always hold for an object (for example, "the balance is never negative") is called its invariant, and a well-designed class makes it impossible to break its invariant through its public interface.

OOP is not the only good style. Python happily mixes plain functions, modules, and classes, and you will learn functional techniques in M18. Reach for a class when you have state that must stay consistent plus operations on that state. If you only have data, a dataclass or dict is often enough (M13); if you only have behavior, a function is enough.

Remember: a class bundles data with the rules and operations for that data, so invariants are enforced in one place.

Classes and instances

A class statement creates a new type. Calling the class like a function creates an instance of that type: a new, separate object. You already know this pattern: list() creates a new list, and int("5") creates an int. Your own classes work the same way.

class Dog:
    """A dog with a name."""      # a docstring, just like functions

rex = Dog()        # call the class to make an instance
fido = Dog()       # a second, independent instance

print(type(rex))              # <class '__main__.Dog'>
print(isinstance(rex, Dog))   # True
print(rex is fido)            # False: two distinct objects

Vocabulary you will use for the rest of the course:

Python is flexible: you can attach attributes to an instance at any time. That flexibility is useful for exploration but dangerous in real code, because each instance could end up with a different shape. Run this to see it:

The fix is to give every instance the same set of attributes the moment it is created. That is the job of __init__.

Remember: the class is the blueprint, calling it creates an instance, and attributes are names hanging off an object.

__init__ and self

When you call Dog("Rex", 3), Python does two things. First it creates a brand-new, empty instance. Then it calls the class's __init__ method, passing the new instance as the first argument, followed by your arguments. __init__ is called an initializer (people often say "constructor", which is close enough). Its job is to put the new object into a valid starting state.

class Dog:
    def __init__(self, name, age):
        self.name = name     # store on THIS instance
        self.age = age

    def describe(self):
        return f"{self.name} is {self.age}"

rex = Dog("Rex", 3)
print(rex.describe())     # Rex is 3

The first parameter, self, is the instance being worked on. It is not a keyword; it is an ordinary parameter name that every Python programmer agrees to use. Inside a method, self.name = name means "attach an attribute called name to this particular object." Without self., name would just be a local variable that vanishes when __init__ returns.

Where do the attributes actually live? Each instance has its own dictionary, __dict__, that maps attribute names to values. Step through the tracer below and watch two instances being built. Notice that self and the variable that eventually receives the object carry the same "obj N" identity tag: they are the same object.

Three things to take from the trace:

  1. __init__ runs once per instance, with self bound to the new object.
  2. rex.birthday() changed only Rex's age; Fido's dictionary is untouched. Each instance has independent state.
  3. __init__ must return None. If you write return something in it, Python raises TypeError, because the call to the class already returns the new instance.

Returning self from a method, as increment does, enables method chaining: a.increment().increment(). It is a stylistic choice; many methods that mutate an object return None instead (like list.append) to make it obvious that they mutate.

Remember: __init__ receives the brand-new instance as self; anything you want to keep must be stored as self.something.

Attributes and methods in depth

A method is just a function stored on the class. When you look it up through an instance, Python produces a bound method: a small object that remembers both the function and the instance. Calling the bound method calls the function with the instance inserted as the first argument. These two lines do exactly the same thing:

rex.describe()          # the normal way
Dog.describe(rex)       # what Python does for you

This is why self must be the first parameter. There is no hidden magic: the dot lookup simply pre-fills that argument.

Attribute lookup order

When you read obj.attr, Python first checks the instance's own __dict__. If the name is not there, it checks the class (and later, in M12, the parent classes). Only if all of those fail do you get AttributeError. Writing obj.attr = value always writes into the instance's __dict__; it never modifies the class. That asymmetry (reads fall through to the class, writes stay on the instance) is the key to the next section.

Methods that keep invariants

Good methods do more than read and write fields. They express operations in the problem's own language, and they validate inputs so the object can never enter an invalid state. Compare account.balance -= 50 with account.withdraw(50): the method can refuse an overdraft, record a transaction, and raise a clear error.

class Thermostat:
    def __init__(self, target):
        self.target = target

    def raise_by(self, degrees):
        if degrees <= 0:
            raise ValueError("degrees must be positive")
        self.target += degrees

Remember: obj.method(x) is Class.method(obj, x). Reads search the instance, then the class; writes go to the instance.

Class attributes vs instance attributes

A name assigned directly in the class body (not inside a method) is a class attribute. It lives once, in the class's own namespace, and every instance can read it through the lookup fallback you just learned. Class attributes are perfect for constants and shared configuration:

class Circle:
    PI = 3.14159            # class attribute: shared, defined once

    def __init__(self, radius):
        self.radius = radius    # instance attribute: one per object

    def area(self):
        return self.PI * self.radius ** 2

Because writes go to the instance, assigning c.PI = 3 does not change the class. It creates a new instance attribute that shadows (hides) the class attribute for that one object. Other circles still see Circle.PI.

The shared-mutable trap

Shadowing protects you when you assign. It does not protect you when you mutate. If a class attribute holds a mutable object such as a list, then self.items.append(x) reads the class's list (via the fallback) and modifies it in place. Every instance shares that single list. This is the class-level cousin of the mutable default argument bug from M05.

In the trace, a.items and b.items carry the same identity tag: one list. In FixedBasket, each instance builds its own list inside __init__, so the tags differ. The references visualizer shows the same idea with plain names: two names, one object.

A legitimate use of a shared mutable class attribute is counting or registering instances. Note that you must update it through the class name; self.count += 1 would read the class value and then write a new instance attribute, leaving the class counter unchanged.

Remember: class attributes are shared; instance attributes are per-object. Never put a per-object mutable container in the class body.

Properties: attribute syntax, method power

Suppose your Temperature class started with a plain self.celsius attribute, and hundreds of lines elsewhere read and write t.celsius. Later you realize that values below absolute zero (−273.15 °C) must be rejected. In Java you would have needed getCelsius()/setCelsius() methods from day one. In Python you don't: you can swap the plain attribute for a property later without changing any calling code.

A property is a method that is accessed like an attribute. The @property decorator turns a method into a getter; @name.setter attaches a setter that runs on assignment. (A decorator is a function that wraps another function; you will build your own in M15. For now, treat @property as special syntax.)

class Temperature:
    ABSOLUTE_ZERO = -273.15

    def __init__(self, celsius):
        self.celsius = celsius        # goes through the setter below!

    @property
    def celsius(self):
        """Temperature in degrees Celsius."""
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < self.ABSOLUTE_ZERO:
            raise ValueError(f"{value} is below absolute zero")
        self._celsius = float(value)

    @property
    def fahrenheit(self):            # read-only, computed on demand
        return self._celsius * 9 / 5 + 32

The real value is stored in self._celsius (the underscore says "internal", see the next sections). The public name celsius is the property. Two details make this robust:

Remember: a property lets you add validation or computation behind attribute syntax, so you can start simple and tighten the rules later without breaking callers.

@classmethod and @staticmethod

Ordinary methods receive the instance. Sometimes you need a method that works with the class itself, before any instance exists. The most common case is an alternative constructor: a second way to build an object from a different kind of input. The built-ins are full of them: dict.fromkeys(...), int.from_bytes(...), datetime.fromisoformat(...).

A @classmethod receives the class as its first argument, conventionally named cls. Calling cls(...) inside it builds an instance. Using cls rather than the hard-coded class name means subclasses automatically get instances of the subclass (you will see why that matters in M12).

A @staticmethod receives neither the instance nor the class. It is a plain function that lives inside the class's namespace because it belongs there conceptually, such as a validation helper.

KindFirst argumentTypical use
Instance methodself (the instance)Read or change one object's state
@classmethodcls (the class)Alternative constructors, class-wide configuration
@staticmethodnoneHelpers related to the class that need no state

Remember: use @classmethod (with cls) for alternative constructors; use @staticmethod sparingly for stateless helpers.

Encapsulation, name mangling, and __dict__

Encapsulation means hiding an object's internal details behind a public interface so that callers depend only on what you promise. Many languages enforce it with private keywords. Python instead relies on conventions and trusts programmers: "we are all consenting adults here."

SpellingMeaningEnforced?
namePublic: part of the interface; callers may use itn/a
_nameInternal: "please don't touch, it may change without notice"No (convention only; also hidden from from module import *)
__nameClass-private: renamed to _ClassName__name to avoid clashes in subclassesPartly (name mangling)
__name__Special ("dunder") names reserved for Python's protocolsDon't invent your own

Name mangling happens at compile time: inside a class body, any identifier written __secret (two leading underscores, at most one trailing) is rewritten to _ClassName__secret. Its purpose is not security; it prevents a subclass from accidentally overwriting a parent's internal attribute with the same name. Anyone can still reach the value via the mangled name.

__dict__ (or the built-in vars(obj)) is an invaluable debugging tool: it shows exactly what an instance stores. The class has its own __dict__ holding methods and class attributes, which is why vars(Vault) lists check.

Remember: _name means internal by convention; __name is mangled to _Class__name to avoid subclass clashes, not to provide security. vars(obj) shows what an object really stores.

Designing a class from requirements

Writing the syntax is the easy part. The hard part, and the skill that separates professionals, is deciding what the class should be. Here is a repeatable process, applied to a real requirement:

"A library lends books to members. A member can borrow at most three books at a time. Each loan records the date it was borrowed. Members can return books, and staff need to see which books a member currently has."

  1. Find the nouns. library, book, member, loan, date. Nouns that have their own data and rules become classes: Book, Member, Loan. Simple values (a date, a title) stay as attributes.
  2. Find the verbs. borrow, return, list current books. Verbs become methods on the class that owns the data they change: Member.borrow(book), Member.return_book(book), Member.current_books().
  3. State the invariants. "At most three active loans." "A book can't be returned if it wasn't borrowed." Each becomes a check in a method that raises a clear exception.
  4. Choose the public interface. What must callers be able to do? Everything else gets an underscore. Here the list of loans is internal (_loans); callers use current_books().
  5. Draw it. A UML class diagram shows each class as a box with attributes and methods, and lines for relationships. + marks public, - marks internal.
classDiagram
  class Member {
    +str name
    +int MAX_LOANS$
    -list~Loan~ _loans
    +borrow(book, on) Loan
    +return_book(book) None
    +current_books() list~Book~
  }
  class Loan {
    +Book book
    +date borrowed_on
  }
  class Book {
    +str isbn
    +str title
  }
  Member "1" --> "0..3" Loan : holds
  Loan "*" --> "1" Book : of
    

The $ after MAX_LOANS marks a class (static) attribute. The arrow labels give multiplicity: one member holds zero to three loans. Translating the diagram into code is now mechanical:

Remember: nouns become classes, verbs become methods, invariants become checks, and the public interface is the smallest set of operations callers need.

Practice

Each exercise runs automated tests against your code. Work through them in order; they build from syntax to design.

Write a class Rectangle whose initializer takes width and height and stores them as attributes with those names. Add methods area() and perimeter(), and a method is_square() that returns True when width equals height.

Hint

Inside every method, reach the data through self.width and self.height.

Show solution
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 is_square(self):
        return self.width == self.height

The Playlist class below has the shared-mutable bug: every playlist shares one list of songs. Fix it so each playlist has its own songs. Also keep a class attribute created that counts how many playlists have ever been made (starting at 0), and give each playlist a name. add(song) appends a song; songs must be a list attribute.

Hint

Create the list in __init__ with self.songs = []. For the counter, write Playlist.created += 1; self.created += 1 creates a shadowing instance attribute.

Show solution
class Playlist:
    created = 0

    def __init__(self, name):
        self.name = name
        self.songs = []
        Playlist.created += 1

    def add(self, song):
        self.songs.append(song)

Write Temperature(celsius) with a celsius property that raises ValueError for values below -273.15 (including in the initializer), and a fahrenheit property with both a getter (c * 9 / 5 + 32) and a setter that converts back and stores Celsius (so setting an impossible Fahrenheit also raises ValueError).

Hint

Store the value in self._celsius. The Fahrenheit setter can simply do self.celsius = (value - 32) * 5 / 9, reusing the Celsius validation.

Show solution
class Temperature:
    ABSOLUTE_ZERO = -273.15

    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < self.ABSOLUTE_ZERO:
            raise ValueError(f"{value} C is below absolute zero")
        self._celsius = float(value)

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9

Write a class Color with attributes r, g, b (ints 0–255). Add:

  • a @staticmethod clamp(value) that limits an int to the range 0–255;
  • an initializer that clamps each component;
  • a @classmethod from_hex(text) that accepts "#ff8800" or "ff8800" and returns a Color (use int(part, 16));
  • a method to_hex() returning lowercase "#rrggbb".
Hint

text.lstrip("#") removes the hash. Slice pairs with text[0:2], text[2:4], text[4:6]. Format with f"{n:02x}".

Show solution
class Color:
    def __init__(self, r, g, b):
        self.r = self.clamp(r)
        self.g = self.clamp(g)
        self.b = self.clamp(b)

    @staticmethod
    def clamp(value):
        return max(0, min(255, int(value)))

    @classmethod
    def from_hex(cls, text):
        digits = text.lstrip("#")
        if len(digits) != 6:
            raise ValueError(f"expected 6 hex digits, got {text!r}")
        r, g, b = (int(digits[i:i + 2], 16) for i in (0, 2, 4))
        return cls(r, g, b)

    def to_hex(self):
        return f"#{self.r:02x}{self.g:02x}{self.b:02x}"

Design a Cart class. Requirements:

  • add(name, price, qty=1) adds an item; adding the same name again increases its quantity. price must be >= 0 and qty >= 1, otherwise raise ValueError.
  • remove(name) removes an item entirely; raise KeyError if it isn't there.
  • total is a read-only property: the sum of price × quantity.
  • count is a read-only property: total number of units.
  • items() returns a new list of (name, price, qty) tuples in insertion order, so callers can't mutate the cart's internals.
Hint

Store {name: [price, qty]} in self._items. Use @property without a setter for total and count. Dicts keep insertion order.

Show solution
class Cart:
    def __init__(self):
        self._items = {}

    def add(self, name, price, qty=1):
        if price < 0:
            raise ValueError("price must be non-negative")
        if qty < 1:
            raise ValueError("qty must be at least 1")
        if name in self._items:
            self._items[name][1] += qty
        else:
            self._items[name] = [price, qty]

    def remove(self, name):
        if name not in self._items:
            raise KeyError(name)
        del self._items[name]

    @property
    def total(self):
        return sum(price * qty for price, qty in self._items.values())

    @property
    def count(self):
        return sum(qty for _, qty in self._items.values())

    def items(self):
        return [(name, price, qty) for name, (price, qty) in self._items.items()]

Mini-project: a BankAccount with history

Mini-projectBankAccount with a transaction history

Build a small, well-encapsulated banking model in a Pixi project. This is the kind of class you will write constantly as a professional: a small object that guards money and keeps an audit trail.

Set up

pixi init bank
cd bank
mkdir -p src
pixi run python src/bank.py

Specification

  • Transaction: a small class with kind ("deposit", "withdrawal", "transfer-in", "transfer-out"), amount, balance_after, and timestamp (datetime.now()).
  • BankAccount(owner, opening_balance=0): stores owner publicly, the balance internally as _balance, and the history in _history (a list of Transaction). Each account gets a unique number from a class-level counter.
  • balance is a read-only property. history() returns a copy (a tuple) of the transactions.
  • deposit(amount) and withdraw(amount) validate input (positive amounts; no overdraft) and raise ValueError with a clear message.
  • transfer_to(other, amount) moves money atomically: either both accounts change or neither does.
  • BankAccount.from_csv_row("Ada,150.00") is a classmethod alternative constructor.
  • statement() returns a multi-line string with one line per transaction.
acct = BankAccount("Ada", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct.balance)        # 120.0
print(acct.statement())
acct.balance = 1_000_000   # AttributeError: read-only

Acceptance criteria

  • Every way to change the balance goes through a method that validates input
  • balance cannot be assigned from outside the class
  • Mutating the result of history() cannot change the account
  • Account numbers are unique and increase (class attribute, updated through the class)
  • A failed transfer_to (insufficient funds) leaves both balances and histories unchanged
  • from_csv_row uses cls(...)
  • A short script at the bottom (under if __name__ == "__main__":) demonstrates every feature and runs with pixi run python src/bank.py

Stretch goals

  • Add a SavingsAccount idea to your notes: what would change? (You will implement it with inheritance in M12.)
  • Store money as integer cents, or use decimal.Decimal, to avoid floating-point rounding (see M02).
  • Draw your design as a Mermaid classDiagram in a README.md.

Quiz

Recap

Going further