Classes

Put state and behaviour together, understand what self is, and know which attributes are shared and which are not.

14of 24
≈ 14Min read
5Parts

The shape

__init__ runs on an object that has already been created — its job is to set it up, not to make it. There is no new, and there is no declaration of which attributes exist: assigning self.x creates it.

py
class Cart:
    currency = "USD"                 # CLASS attribute — one, shared by all carts

    def __init__(self, owner):       # not a constructor: the object already exists
        self.owner = owner           # INSTANCE attributes — one set per cart
        self.items = []

    def add(self, name, price):
        self.items.append((name, price))
        return self                  # returning self allows chaining

    def total(self):
        return sum(price for _, price in self.items)

cart = Cart("ada")
cart.add("door", 1200).add("handle", 90)
cart.total()        # 1290

self is not magic

cart.total() looks the method up on the class and calls it with cart as the first argument. self is just the name everybody gives that parameter — the language does not require it, and using any other name will confuse every reader you have.

That also explains the rule people find strange: every method must declare self, because the call always passes it.

py
cart.total()          # what you write
Cart.total(cart)      # what actually happens

The shared-attribute trap

A class attribute is created once, when the class statement runs — the same rule as a default argument, and the same bug. Mutable state belongs in __init__; class attributes are for constants and defaults that never change.

Reading self.currency finds the class attribute; writing self.currency = "EUR" creates an instance attribute that shadows it, leaving every other object untouched.

py
class Cart:
    items = []            # ONE list, shared by every Cart ever made

a, b = Cart(), Cart()
a.items.append("door")
b.items               # ['door']  ← the same list

class Cart:
    def __init__(self):
        self.items = []   # a new list per cart

Three kinds of method

@classmethod receives the class, which is how you write alternative constructors that still work in a subclass. @property turns a method into an attribute access, which is why Python has no getters and setters: start with a plain attribute, and turn it into a property the day it needs logic, without changing a single caller.

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

    def describe(self):                     # instance method — needs an object
        return f"{self.celsius}°C"

    @classmethod
    def from_fahrenheit(cls, f):            # alternative constructor
        return cls((f - 32) * 5 / 9)

    @staticmethod
    def is_freezing(celsius):               # related, but needs nothing
        return celsius <= 0

    @property
    def fahrenheit(self):                   # computed, used like an attribute
        return self.celsius * 9 / 5 + 32

t = Temperature.from_fahrenheit(212)
t.fahrenheit        # 212.0 — no parentheses

Inheritance, sparingly

Python has multiple inheritance and a defined order (the MRO, visible as Cls.__mro__), but deep hierarchies are almost always the wrong answer. Prefer composition — an object that has a pricing strategy is easier to change than one that is a subclass of three things.

And because Python is duck-typed, you do not need a common base class to treat two objects the same way. They only need the same methods.

py
class Door:
    def price(self):
        return 1000

class ArmouredDoor(Door):
    def price(self):
        return super().price() + 800        # super() finds the next class along

Practice