Classes and Objects 🏛️
You have been using objects since Lesson 1: every string, list and dictionary is one. This is the lesson where you build your own.
The problem a class solves
# Without a class: data and behaviour drift apart
guybrush = {"name": "Guybrush", "insults": 7, "hp": 100}
def take_damage(pirate, amount):
pirate["hp"] -= amount
return pirate["hp"]
print(take_damage(guybrush, 30))
print(guybrush["hp"])
70
70
That works. It also has nothing stopping you writing guybrush["hp"] in one
place and guybrush["health"] in another, or calling
take_damage on a dictionary that represents a ship. A class ties the shape
of the data to the operations that are allowed on it, and gives the pair a name.
Your first class
class Pirate:
"""Someone with a name, some insults and a will to live."""
def __init__(self, name, insults=0):
self.name = name
self.insults = insults
self.hp = 100
def learn(self, insult):
self.insults += 1
return f"{self.name} learns: {insult}"
def take_damage(self, amount):
self.hp = max(0, self.hp - amount)
return self.hp
guybrush = Pirate("Guybrush", insults=3)
elaine = Pirate("Elaine")
print(guybrush.name, guybrush.insults, guybrush.hp)
print(elaine.name, elaine.insults, elaine.hp)
print(guybrush.learn("You fight like a dairy farmer!"))
print(guybrush.insults, elaine.insults)
print(guybrush.take_damage(30))
Guybrush 3 100
Elaine 0 100
Guybrush learns: You fight like a dairy farmer!
4 0
70
| Word | Means |
|---|---|
class Pirate: | A blueprint. No pirate exists yet |
Pirate("Guybrush") | Build one. This is an instance |
__init__ | Runs automatically when an instance is built. Set up the data here |
self | The particular instance this call is about |
self.name = name | An attribute: data belonging to this instance |
def learn(self, ...) | A method: a function belonging to the class |
self, demystified
class Counter:
def __init__(self):
self.count = 0
def bump(self):
self.count += 1
a = Counter()
b = Counter()
a.bump()
a.bump()
b.bump()
print(a.count, b.count)
# a.bump() is literally shorthand for this
Counter.bump(a)
print(a.count)
2 1
3
self is not magic and it is not a keyword. It is just the first parameter, and Python passes the instance into it automatically when you use the dot. a.bump() and Counter.bump(a) are the same call written two ways.
Which is why forgetting self in a method definition gives you 'takes 0 positional arguments but 1 was given'. Python passed the instance and your function had nowhere to put it.
Instance attributes versus class attributes
class Pirate:
crew_name = "The Sea Monkeys" # shared by every pirate
count = 0
def __init__(self, name):
self.name = name # unique to each pirate
Pirate.count += 1
a = Pirate("Guybrush")
b = Pirate("Elaine")
print(a.crew_name, b.crew_name)
print(Pirate.count)
Pirate.crew_name = "The Mighty Pirates"
print(a.crew_name, b.crew_name)
a.crew_name = "Solo Act" # this creates an INSTANCE attribute
print(a.crew_name, b.crew_name)
The Sea Monkeys The Sea Monkeys
2
The Mighty Pirates The Mighty Pirates
Solo Act The Mighty Pirates
A class attribute that is a list or dict is shared by every instance, exactly like a mutable default argument. Put mutable state in __init__ as self.something = [], always.
class Bad:
items = [] # one list, shared by all
class Good:
def __init__(self):
self.items = [] # a fresh list per instance
a, b = Bad(), Bad()
a.items.append("grog")
print("Bad: ", a.items, b.items)
c, d = Good(), Good()
c.items.append("grog")
print("Good:", c.items, d.items)
Bad: ['grog'] ['grog']
Good: ['grog'] []
Making objects print nicely
class Pirate:
def __init__(self, name, insults=0):
self.name = name
self.insults = insults
def __repr__(self):
"""For programmers: should look like the code that rebuilds it."""
return f"Pirate({self.name!r}, insults={self.insults})"
def __str__(self):
"""For humans."""
return f"{self.name} ({self.insults} insults)"
guy = Pirate("Guybrush", 8)
print(guy) # uses __str__
print(repr(guy)) # uses __repr__
print([guy]) # containers always use __repr__
print(f"{guy} vs {guy!r}")
Guybrush (8 insults)
Pirate('Guybrush', insults=8)
[Pirate('Guybrush', insults=8)]
Guybrush (8 insults) vs Pirate('Guybrush', insults=8)
Without __repr__ you get <__main__.Pirate object at
0x104f2b3d0>, which tells you nothing while debugging. Writing
__repr__ is the single highest-value five seconds you can spend on a class.
If you only write one, write that one: print falls back to it when
__str__ is missing.
Properties: computed attributes
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""Computed on demand, but used like an attribute."""
return self.width * self.height
@property
def description(self):
return "square" if self.width == self.height else "oblong"
r = Rectangle(3, 4)
print(r.area, r.description)
r.width = 4
print(r.area, r.description)
12 oblong
16 square
@property turns a method into something you read like an attribute: no
brackets. It lets you start with a plain attribute and later replace it with a
calculation without changing a single line of the code that uses it. That is a genuinely
useful escape hatch, and it is why Python does not need Java-style getters everywhere.
Validation with a setter
class Account:
def __init__(self, balance=0):
self._balance = balance # the underscore means "internal"
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError(f"balance cannot be negative, got {value}")
self._balance = value
acc = Account(100)
acc.balance = 250
print(acc.balance)
try:
acc.balance = -50
except ValueError as err:
print("Refused:", err)
250
Refused: balance cannot be negative, got -50
A single leading underscore (_balance) is a convention meaning 'this is internal, do not touch'. Nothing enforces it. Python's philosophy here is 'we are all consenting adults': you are trusted not to reach into someone else's internals, and if you do, the breakage is yours to own. Two underscores (__balance) triggers name mangling, which discourages accidents but still is not real privacy.
When a class is the wrong answer
# Not a class. This is a function wearing a costume.
class Calculator:
def add(self, a, b):
return a + b
# Just write the function
def add(a, b):
return a + b
print(add(2, 3))
5
Reach for a class when you have state plus behaviour that belongs to it. If:
- there is no state, write a function;
- the state never changes and there is no behaviour, use a dataclass or a named tuple;
- you have exactly one instance and it exists forever, a module is a fine singleton;
- you are writing
classbecause it feels more professional, stop.
A bank account
Write an Account class with a holder, a balance, deposit, withdraw (refusing overdrafts), a transaction count, and a good __repr__.
Reveal solution
class Account:
"""A very simple bank account that refuses to go negative."""
def __init__(self, holder, balance=0):
self.holder = holder
self.balance = balance
self.transactions = 0
def deposit(self, amount):
if amount <= 0:
raise ValueError("deposit must be positive")
self.balance += amount
self.transactions += 1
return self.balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError(f"cannot withdraw {amount}, balance is {self.balance}")
self.balance -= amount
self.transactions += 1
return self.balance
def __repr__(self):
return f"Account({self.holder!r}, balance={self.balance})"
acc = Account("Guybrush", 100)
acc.deposit(50)
acc.withdraw(30)
print(acc)
print(f"{acc.transactions} transactions")
try:
acc.withdraw(1000)
except ValueError as err:
print("Refused:", err)
Account('Guybrush', balance=120)
2 transactions
Refused: cannot withdraw 1000, balance is 120Find the shared-state bug
Every deck somehow contains everyone's cards. Why?
class Deck:
cards = []
def add(self, card):
self.cards.append(card)Reveal solution
cards is a class attribute, so there is exactly one list and every deck shares it. self.cards.append does not create a new list, it mutates the shared one.
class Deck:
def __init__(self):
self.cards = [] # a fresh list for every deck
def add(self, card):
self.cards.append(card)
return self
def __repr__(self):
return f"Deck({self.cards})"
a, b = Deck(), Deck()
a.add("ace")
print(a, b)
Deck(['ace']) Deck([])Temperature with validation
Write a Temperature class storing Celsius, exposing fahrenheit as a readable and writable property, and refusing anything below absolute zero.
Reveal solution
class Temperature:
"""A temperature, stored in Celsius, readable in either scale."""
ABSOLUTE_ZERO_C = -273.15
def __init__(self, celsius=0.0):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < self.ABSOLUTE_ZERO_C:
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
def __repr__(self):
return f"Temperature({self._celsius:.1f})"
t = Temperature(100)
print(t, t.fahrenheit)
t.fahrenheit = 32
print(t, t.celsius)
try:
Temperature(-300)
except ValueError as err:
print("Refused:", err)
Temperature(100.0) 212.0
Temperature(0.0) 0.0
Refused: -300C is below absolute zeroSetting fahrenheit quietly routes through the celsius setter, so the validation applies to both scales without being written twice. That is the payoff of properties.