Classes & Objects

The Course
•
January 10, 2026
•
9 min read

You have been using objects since Day 1. "hello".upper() calls a method on a string object; [1, 2].append(3) calls one on a list object. Today you make your own.

Warm-up: from Days 1 to 5

From memory first. If one of these takes you back to an earlier lesson, that’s the warm-up working — it found something worth another look.

1. Predict all three lines.

Warm-up: predict Python
import json
from collections import Counter

data = json.loads('{"tags": ["a", "b", "a"], "count": 2}')
print(type(data["count"]).__name__, data["tags"][-1])
print(Counter(data["tags"]).most_common(1)[0])

try:
    print(data["missing"])
except KeyError as error:
    print("no key:", error)
Check your prediction
int a
('a', 2)
no key: 'missing'

JSON numbers come back as Python numbers, not text, and [-1] is the last tag — which is "a" again. most_common(1) is a list of one pair, so [0] takes the pair itself. And a KeyError names the missing key, in quotes.

2. Fix. Three days of step counts, which should total 14200.

Warm-up: fix Python
with open("steps.txt", "w") as file:
    file.write("4200\n6100\n3900\n")

total = 0
with open("steps.txt") as file:
    for line in file:
        total += line

print(total)
Check your fix
with open("steps.txt", "w") as file:
    file.write("4200\n6100\n3900\n")

total = 0
with open("steps.txt") as file:
    for line in file:
        total += int(line)

print(total)
14200

Every line of a file is text — "4200\n", newline and all. int() converts it, and doesn’t mind the newline.

3. Write safe_int(text, default=0): it returns int(text), or the default if text isn’t a whole number. This line should print 12 0 -1:

Warm-up: write Python
# def safe_int(text, default=0): ...

print(safe_int("12"), safe_int("twelve"), safe_int("", -1))
Check your answer
def safe_int(text, default=0):
    try:
        return int(text)
    except ValueError:
        return default

print(safe_int("12"), safe_int("twelve"), safe_int("", -1))
12 0 -1

Try the conversion; if it raises the one exception you expect, return the fallback. int("") raises ValueError too.

The problem classes solve

Here is a program written with the tools you already have:

Data and behaviour, drifting apart Python
account_owner = "Ada"
account_balance = 100

def deposit(balance, amount):
    return balance + amount

account_balance = deposit(account_balance, 50)
print(account_owner, account_balance)

# Now add a second account, and a third...
other_owner = "Alan"
other_balance = 20

Every new account needs another pair of variables, and nothing links a balance to its owner. A class ties them together.

The same thing as a class Python
class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return self.balance

ada = Account("Ada", 100)
alan = Account("Alan", 20)

ada.deposit(50)

print(ada.owner, ada.balance)
print(alan.owner, alan.balance)

Two independent accounts, each carrying its own data and the behaviour that belongs to it.

The vocabulary

  • A class is the blueprint: Account.
  • An instance is one thing built from it: ada.
  • Attributes are the data on an instance: ada.balance.
  • Methods are functions defined in the class: ada.deposit(50).

__init__ and self

__init__ runs automatically when you create an instance. It is where attributes are set up.

Watching it happen Python
class Book:
    def __init__(self, title, pages):
        print(f"  building a Book: {title}")
        self.title = title
        self.pages = pages
        self.page_read = 0        # attributes not from arguments are fine too

    def read(self, pages):
        self.page_read = min(self.page_read + pages, self.pages)
        return self.progress()

    def progress(self):
        return f"{self.page_read}/{self.pages} pages"

dune = Book("Dune", 412)
print(dune.progress())
print(dune.read(100))
print(dune.read(400))

self is the instance the method was called on. Python passes it automatically: writing dune.read(100) calls read(dune, 100). Every method takes self first — forget it and you get TypeError: read() takes 1 positional argument but 2 were given.

self is a convention, not a keyword

You could call it anything. Nobody does. Call it self and every Python programmer will understand your code instantly.

Instance attributes versus class attributes

An attribute defined in the class body is shared by every instance. One defined on self belongs to that instance alone.

Shared or personal Python
class Dog:
    species = "Canis familiaris"        # class attribute - shared

    def __init__(self, name):
        self.name = name                # instance attribute - personal

rex = Dog("Rex")
fido = Dog("Fido")

print(rex.name, fido.name)
print(rex.species, fido.species)

Dog.species = "Canis lupus familiaris"  # change it once, everyone sees it
print(rex.species, fido.species)
A mutable class attribute is shared, not copied

species above is a string, and strings cannot be changed, so sharing one is harmless. A list, dictionary or set in the class body is another matter: there is exactly one of it, and changing it through any instance changes it for every instance at once — the same one-object-shared-by-everyone shape as the mutable default argument on Day 4.

One list, every dog Python
class Dog:
    tricks = []                    # one list, shared by every Dog

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

rex = Dog("Rex")
fido = Dog("Fido")

rex.tricks.append("roll over")     # meant for Rex alone...
print("rex: ", rex.tricks)
print("fido:", fido.tricks)        # ...but Fido knows it too
print("Dog: ", Dog.tricks)         # because it was the class's list all along

fido.tricks = ["beg"]              # assigning is not mutating
print("fido:", fido.tricks)        # a new list, belonging to fido alone
print("Dog: ", Dog.tricks)         # the shared list is untouched

That last pair of lines is what makes the bug hard to spot. fido.tricks.append(...) reaches through to the class’s list, while fido.tricks = [...] creates a new instance attribute that hides it — so the same attribute name behaves one way when you change the list and another way when you replace it.

The cure is the one from Day 4: give every instance its own.

A list per dog Python
class Dog:
    def __init__(self, name):
        self.name = name
        self.tricks = []           # a fresh list, built once per dog

rex = Dog("Rex")
fido = Dog("Fido")

rex.tricks.append("roll over")
print("rex: ", rex.tricks)
print("fido:", fido.tricks)

Methods that guard the data

The point of putting behaviour beside data is that the class can enforce its own rules.

A class with rules Python
class Account:
    def __init__(self, owner, balance=0):
        if balance < 0:
            raise ValueError("balance cannot start negative")
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.balance += amount

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

account = Account("Ada", 100)
account.deposit(50)
account.withdraw(30)
print(account.balance)

account.withdraw(1000)

A leading underscore means “internal”

Python has no private attributes. It has a convention: a name starting with _ is an implementation detail, and other code should leave it alone.

Convention over enforcement Python
class Timer:
    def __init__(self):
        self._start = 0          # internal - do not touch from outside
        self.laps = []           # public

    def lap(self, seconds):
        self.laps.append(seconds)

    def best(self):
        return min(self.laps) if self.laps else None

timer = Timer()
timer.lap(12.4)
timer.lap(11.9)
print(timer.best())
print(timer._start)      # possible, but you are on your own

Before you write your own

Predict. Two tallies, built from one class. Do they share anything?

What will this print? Python
class Tally:
    def __init__(self, name):
        self.name = name
        self.count = 0

    def add(self, n=1):
        self.count += n
        return self.count

a = Tally("cups")
b = Tally("steps")
a.add()
a.add(2)
print(a.count, b.count)
print(b.add(10), a.name)
Check your prediction
3 0
10 cups

Each instance gets its own count, set up in __init__, so adding to a leaves b at zero. add both changes the attribute and returns it.

Fix. This should print Ada has 150. It crashes in add.

Where did the amount go? Python
class Wallet:
    def __init__(self, owner, amount):
        self.owner = owner
        amount = amount

    def add(self, extra):
        self.amount += extra

wallet = Wallet("Ada", 100)
wallet.add(50)
print(f"{wallet.owner} has {wallet.amount}")
Check your fix
class Wallet:
    def __init__(self, owner, amount):
        self.owner = owner
        self.amount = amount

    def add(self, extra):
        self.amount += extra

wallet = Wallet("Ada", 100)
wallet.add(50)
print(f"{wallet.owner} has {wallet.amount}")
Ada has 150

amount = amount assigned the parameter to itself — a local name that vanished when __init__ returned. Only self.something = ... puts data on the object. The error, 'Wallet' object has no attribute 'amount', is true: it never got one.

Exercise

A shopping cart

Write a Cart class with an empty list of items. Give it add(name, price), total() returning the sum of prices, and count() returning how many items. Adding a negative price should raise a ValueError.

When it works, the last line prints 2 75.49000000000001 — Day 1’s float story again, since 49.99 has no exact binary form. round(cart.total(), 2) makes it 75.49.

pass below means “nothing here” — Python needs a body after each colon, so it holds the place until you write one. Replace it as you fill each method in.

class Cart:
    def __init__(self):
        pass          # your code here

    def add(self, name, price):
        pass          # your code here

    def total(self):
        return 0      # your code here

    def count(self):
        return 0      # your code here

cart = Cart()
cart.add("keyboard", 49.99)
cart.add("mouse", 25.50)
print(cart.count(), cart.total())
Hint 1: the idea

The cart needs somewhere to keep its items: a list, created in __init__ on self, so that every cart has its own. Each method then works on that list — add appends, total adds up the prices, count counts. The negative-price check goes at the top of add, before anything is stored.

Hint 2: a first step

Put self.items = [] in __init__, and in add, append the pair: self.items.append((name, price)). Then count is just the length of self.items. Get count() printing 2 before you start on total.

Show one solution
class Cart:
    def __init__(self):
        self.items = []

    def add(self, name, price):
        if price < 0:
            raise ValueError(f"price cannot be negative: {price}")
        self.items.append((name, price))

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

    def count(self):
        return len(self.items)

cart = Cart()
cart.add("keyboard", 49.99)
cart.add("mouse", 25.50)
print(cart.count(), round(cart.total(), 2))

self.items = [] lives in __init__, so each cart gets its own list — the mutable-attribute rule in practice. Each item is a (name, price) tuple, which is Day 3’s “fixed record” advice applied.

What you learned

  • A class is a blueprint; an instance is one object built from it.
  • __init__ sets up attributes when an instance is created.
  • self is the instance, passed automatically as the first argument.
  • Class attributes are shared; instance attributes belong to one object.
  • Anything mutable that each instance should own belongs in __init__.
  • A leading underscore marks something as internal by convention.
Last updated on January 10, 2026

Was this article helpful?

Your response is saved on this device.