Level 4 · Pythonic

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
WordMeans
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
selfThe particular instance this call is about
self.name = nameAn 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
LOGIC[Medium: Success]

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
🪤 Mutable class attributes are the list-default trap again

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
🔒 Python has no private

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:

Exercise 1

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 120
Exercise 2

Find 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([])
Exercise 3

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 zero

Setting fahrenheit quietly routes through the celsius setter, so the validation applies to both scales without being written twice. That is the payoff of properties.

+100 XP