Object-oriented Python · about 40 min

Classes and instances

Why this matters

So far your data has lived in built-in types: numbers, strings, lists, dicts. Real programs model things (accounts, users, network connections, game pieces) that bundle data with behavior. You could pass a dict around plus a pile of functions that operate on it, but nothing ties them together or stops someone from misspelling a key. A class lets you define your own type: a template that says what data each object holds and what it can do. Every built-in type you have used is itself a class; this lesson shows you how to write your own and what Python actually does when you do.

A first class

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

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

p = Point(3, 4)
print(p.x, p.y)
print(p.distance_from_origin())
3 4
5.0

Piece by piece:

What really happens when you call a class

Calling Point(3, 4) performs two steps:

Point(3, 4)
   |
   | 1. Point.__new__(Point) creates an empty instance  -> obj
   | 2. Point.__init__(obj, 3, 4) fills it in            (must return None)
   v
 obj is returned and bound to the name p

You almost never write __new__; the default one allocates the object. __init__ does not create the object, it receives one that already exists, which is why it gets self and returns nothing.

self and bound methods

Where does self come from in p.distance_from_origin()? There is no argument in the parentheses. The rule: when you look up a function through an instance, Python returns a bound method that remembers the instance. Calling it passes the instance as the first argument. These two lines do the same thing:

p.distance_from_origin()
Point.distance_from_origin(p)

You can see the binding:

>>> p.distance_from_origin
<bound method Point.distance_from_origin of <__main__.Point object at 0x7f...>>
>>> Point.distance_from_origin
<function Point.distance_from_origin at 0x7f...>

self is not a keyword, just a universally followed convention for the first parameter. Name it anything else and your colleagues will be confused, but Python will not care.

Objects are namespaces backed by dicts

An instance stores its attributes in a dictionary called __dict__:

p = Point(3, 4)
print(p.__dict__)
p.label = "corner"        # attributes can be added at any time
print(p.__dict__)
{'x': 3, 'y': 4}
{'x': 3, 'y': 4, 'label': 'corner'}

The class is an object too, with its own __dict__ holding the methods (and anything else defined in the class body):

print(type(p))                                   # <class '__main__.Point'>
print('distance_from_origin' in Point.__dict__)  # True
print('distance_from_origin' in p.__dict__)      # False

This is the key to attribute lookup. When you write p.name, Python (simplifying slightly):

1. looks in p.__dict__            found? use it
2. looks in type(p).__dict__      found? use it (functions become bound methods)
3. looks in the parent classes    (inheritance, lesson 4)
4. raises AttributeError

So methods are shared: there is one distance_from_origin function stored in the class, and every instance finds it by falling through to step 2. Data assigned through self lands in each instance's own dict, so each point has its own x and y.

Assigning always writes to the instance: p.x = 10 puts x into p.__dict__, never into the class. This asymmetry (read falls through, write stays local) explains the lookup quiz in this lesson and an important pitfall in lesson 3.

Methods that change state

Methods can read and modify the instance's attributes. That is the point of bundling:

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

    def adjust(self, delta):
        self.target += delta
        self.changes += 1
        return self.target

t = Thermostat(20)
t.adjust(2)
t.adjust(-1)
print(t.target, t.changes)
21 2

Every instance has separate state:

a = Thermostat(18)
b = Thermostat(25)
a.adjust(1)
print(a.target, b.target)   # 19 25

Classes are types

A class you write is a type exactly like int or list:

print(isinstance(p, Point))   # True
print(type(p) is Point)       # True
print(type(3) is int)         # True
print(type(Point))            # <class 'type'>

That last line says the class Point is itself an instance of type. In Python, classes are objects created at run time when the class statement executes: the body runs like ordinary code, its names are collected into a dict, and type builds the class object from them. Nothing about a class is fixed at compile time.

Common mistakes

Forgetting self in the method definition.

class Dog:
    def bark():
        return "woof"

Dog().bark()
TypeError: Dog.bark() takes 0 positional arguments but 1 was given

The "1 given" is the instance Python passed automatically. Add self as the first parameter.

Forgetting self. when using an attribute.

class Dog:
    def __init__(self, name):
        self.name = name
    def greet(self):
        return "I am " + name
NameError: name 'name' is not defined

Inside a method, attributes are reached through self. A bare name is a local or global variable, which does not exist here.

Misspelling __init__. def __int__(self, ...) or def _init_(self, ...) is silently just another method, so Point(3, 4) fails with TypeError: Point() takes no arguments.

Typos create new attributes. self.balence = 0 happily creates a misspelled attribute, and later self.balance raises AttributeError: 'Account' object has no attribute 'balance'. Read the attribute names in the error closely.

Recap

Going further: in the REPL, create an instance and call vars(obj) (the same as obj.__dict__) and dir(obj) to list everything reachable from it. Read the tutorial chapter "Classes" at docs.python.org, sections 9.3 and 9.4.

Exercises

Exercise 1 · quiz

What is self?

Given

class Dog:
    def bark(self):
        return "woof"

rex = Dog()

which call is exactly equivalent to rex.bark()?

Answer
  • Dog.bark(rex)

Looking up bark on an instance gives a bound method: the function from the class with the instance already filled in as the first argument. rex.bark() becomes Dog.bark(rex), which is why every method's first parameter (by convention named self) receives the instance. Dog.bark() fails with TypeError: Dog.bark() missing 1 required positional argument: 'self'.

Exercise 2 · write code

A Counter class

In solution.py, write a class Counter with:

Example:

c = Counter(10)
c.increment()      # 11
c.increment(5)     # 16
c.reset()
c.value            # 10

Two counters must be independent of each other.

File: solution.py

Starting point

class Counter:
    def __init__(self, start=0):
        pass

    def increment(self, step=1):
        pass

    def reset(self):
        pass
Hint 1

Attributes are created by assigning to self.something inside a method, usually __init__.

Hint 2

You need two attributes: the current value and the original start value, so reset can restore it.

Hint 3

increment must both update self.value and return self.value.

Reference solution
class Counter:
    def __init__(self, start=0):
        self.start = start
        self.value = start

    def increment(self, step=1):
        self.value += step
        return self.value

    def reset(self):
        self.value = self.start

Exercise 3 · quiz

Instance versus class lookup

What does this print?

class Lamp:
    color = "white"

a = Lamp()
b = Lamp()
a.color = "red"
print(a.color, b.color, Lamp.color)
Answer
  • red white white

a.color = "red" creates an entry in a.__dict__; it does not touch the class. Attribute lookup checks the instance's __dict__ first, then the class. So a.color finds the instance's own "red", while b.color falls through to the class attribute "white".

Exercise 4 · write code

Bank account with history

In solution.py, write a class Account:

Careful: each account needs its own history list.

File: solution.py

Starting point

class Account:
    history = []

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

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

    def withdraw(self, amount):
        pass

    def statement(self):
        pass
Hint 1

A list defined in the class body is shared by every instance. Create self.history = [] inside __init__ instead.

Hint 2

In withdraw, check first and raise ValueError(...) before changing anything.

Hint 3

Build statement as a list of strings and finish with "\n".join(lines).

Reference solution
class Account:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0
        self.history = []

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

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError(f"insufficient funds: balance {self.balance}, asked {amount}")
        self.balance -= amount
        self.history.append(("withdraw", amount))

    def statement(self):
        lines = [f"{kind} {amount}" for kind, amount in self.history]
        lines.append(f"balance {self.balance}")
        return "\n".join(lines)