PythonMastery
intermediate 18 min read · lesson 1 of 13 in Python Intermediate

Object-Oriented Programming

1 · The lesson

read

A class is a blueprint for bundling related state and the behaviour that acts on it. When you find yourself passing the same dictionary into a dozen functions — update_balance(account, ...), withdraw(account, ...), format_account(account) — that's the moment to organise them into a class. The data and the operations belong together.

This lesson covers the syntax, the dunder methods that make a class feel native, the difference between class and instance state, and the decorators (@classmethod, @staticmethod, @property) that round out a well-designed object.


1. Why OOP — When State and Behaviour Belong Together

Pure functions are the cleanest tool when you have inputs and want outputs. But the moment data has identity — this account, that user, my shopping cart — and many operations mutate it over time, a class organises the code around the thing being modelled.

python
# Procedural — works, but everything orbits a loose dict
def make_account(owner, balance=0):
    return {"owner": owner, "balance": balance}

def deposit(account, amount):
    account["balance"] += amount

def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("insufficient funds")
    account["balance"] -= amount

acc = make_account("Linus", 100)
deposit(acc, 50)
withdraw(acc, 30)

The dict carries no contract — anyone can write acc["balnace"] = 999 (typo) and the bug surfaces three functions later. The OOP version keeps the operations attached to the data:

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

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

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

acc = Account("Linus", 100)
acc.deposit(50)
acc.withdraw(30)

Same logic, fewer moving parts, and acc.balnace would raise AttributeError immediately.


2. class, __init__, and self

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

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

__init__ runs once when you call Point(3, 4) — Python creates a new empty instance, passes it as the first argument (self), and you populate it. self isn't a keyword; it's just the conventional name for "the instance being operated on". You could call it this or me — your reviewers would not be amused.

Every method gets self as its first parameter. Python wires it in automatically when you call p.method(...).

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

    def translate(self, dx, dy):
        self.x += dx
        self.y += dy

p = Point(3, 4)
p.translate(1, 2)               # Python passes p as self
print(p.x, p.y)                 # 4 6

p.translate(1, 2) is sugar for Point.translate(p, 1, 2).


3. __repr__ vs __str__

By default, printing an instance gives you a useless <__main__.Point object at 0x7f...>. Two dunders fix this:

  • __repr__ — unambiguous, developer-facing. Ideally valid Python that recreates the object.
  • __str__ — friendly, user-facing.
python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

    def __str__(self):
        return f"({self.x}, {self.y})"

p = Point(3, 4)
print(repr(p))                  # Point(x=3, y=4)
print(str(p))                   # (3, 4)
print(p)                        # (3, 4)         — print uses __str__
[p, p]                          # [Point(x=3, y=4), Point(x=3, y=4)]  — containers use __repr__

If you only define __repr__, Python falls back to it for str(...) too. Always define __repr__. Skip __str__ unless you genuinely have a separate user-facing representation.


4. __eq__ and Identity vs Equality

By default, a == b is true only when a and b are the same object in memory — same as a is b. Two distinct Point(3, 4) instances compare unequal.

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

a = Point(3, 4)
b = Point(3, 4)
print(a == b)                   # False — different objects
print(a is b)                   # False

Define __eq__ to compare by value:

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

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

print(Point(3, 4) == Point(3, 4))   # True
print(Point(3, 4) == "hello")       # False — NotImplemented falls back

Return NotImplemented (the sentinel, not False) when the types don't match — Python will try the other operand's __eq__ before giving up.

One sharp edge: once you define __eq__, Python sets __hash__ to None and your instances become unhashable (can't go in a set or dict key). If you want both, define __hash__ returning a hash of the same fields you compared, or use @dataclass(frozen=True) — covered in the dataclasses lesson.


5. Class Attributes vs Instance Attributes

Variables defined inside __init__ (with self.) belong to each instance. Variables defined at the class body level are shared by every instance.

python
class Dog:
    species = "Canis familiaris"        # class attribute — shared

    def __init__(self, name):
        self.name = name                # instance attribute — per-dog

a = Dog("Rex")
b = Dog("Buddy")
print(a.species, b.species)             # Canis familiaris Canis familiaris
print(a.name, b.name)                   # Rex Buddy

Class attributes are great for constants and defaults. They are dangerous when mutable:

python
class Team:
    members = []                        # BAD — one list shared by every Team

    def add(self, name):
        self.members.append(name)

a, b = Team(), Team()
a.add("Linus")
b.add("Ada")
print(a.members)                        # ['Linus', 'Ada']   — they share the list!
print(b.members)                        # ['Linus', 'Ada']
print(a.members is b.members)           # True

Same trap as the mutable-default-arg problem from functions. Fix it by giving each instance its own list in __init__:

python
class Team:
    def __init__(self):
        self.members = []

6. @classmethod and @staticmethod

Three flavours of method, distinguished by what they receive as their first argument:

  • Regular method → self (the instance).
  • @classmethod → cls (the class itself). For alternative constructors and class-level operations.
  • @staticmethod → nothing automatic. For utility functions that logically belong to the class but don't touch instance or class state.
python
class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_string(cls, s):
        """Alternative constructor — parses '2026-05-14'."""
        y, m, d = s.split("-")
        return cls(int(y), int(m), int(d))      # cls(...) — works for subclasses too

    @staticmethod
    def is_leap(year):
        """Pure utility — no self, no cls."""
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

d = Date.from_string("2026-05-14")
print(d.year, d.month, d.day)           # 2026 5 14
print(Date.is_leap(2024))               # True

Use @classmethod for alternative constructors (from_csv_row, from_json, from_dict) — cls(...) ensures subclasses get their own type back. Use @staticmethod sparingly; if the function doesn't touch class state, a module-level function is often clearer.


7. @property — Pythonic Getters

Java-style getters and setters (getName(), setName()) are not Pythonic. @property lets attributes look like attributes but run code on access.

python
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

    @property
    def diameter(self):
        return self.radius * 2

c = Circle(5)
print(c.area)                           # 78.53975   — note: no parentheses
print(c.diameter)                       # 10

Read-only by default — c.area = 100 raises AttributeError. Add a setter when you genuinely need one:

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

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(20)
t.celsius = 25                          # runs the setter, validates
print(t.fahrenheit)                     # 77.0
# t.celsius = -300                      # ValueError

Convention: store the backing value in self._name (single underscore = "private by convention") and expose self.name as the property. Don't reach for @property on day one — start with plain attributes and promote to a property only when you need validation or a derived value.


8. The @dataclass Shortcut

A class that just holds data with __init__, __repr__, and __eq__ is so common that the standard library generates it for you:

python
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p = Point(3, 4)
print(p)                                # Point(x=3, y=4)
print(p == Point(3, 4))                 # True

Three lines do what would take fifteen by hand. We cover @dataclass in depth in the next lesson — including frozen=True, field(default_factory=...), and when not to use it.


Common Mistakes

1. Mutable class attributes shared across instances — see Section 5. The list/dict/set defined at class-body level is one object shared by every instance. If you want per-instance state, initialise it in __init__.

2. Forgetting self.

python
class Counter:
    def __init__(self):
        count = 0                       # local variable — discarded when __init__ returns!

    def bump(self):
        self.count += 1                 # AttributeError: 'Counter' object has no attribute 'count'

Every instance attribute needs the self. prefix on the assignment.

3. Forgetting super().__init__() in subclasses — a subclass that defines its own __init__ and forgets to call the parent's leaves the parent's attributes uninitialised. We unpack this fully in inheritance.

4. Confusing class vs instance access

python
class Counter:
    total = 0                           # class attribute

    def bump(self):
        self.total += 1                 # ⚠️ creates an instance attribute that shadows the class one

a = Counter()
a.bump()
print(a.total)                          # 1
print(Counter.total)                    # 0       — class attribute untouched

self.total += 1 reads Counter.total (0), adds 1, and assigns to self.total — making a fresh instance attribute. If you genuinely want a shared counter, write Counter.total += 1.

5. Reaching for a class when a function would do. A class with one method and no state is just a function with extra ceremony. def parse(text): ... beats class Parser: def parse(self, text): ... until you have real state to manage.


🎯 Your Turn — A BankAccount Class

Build a BankAccount with the full set of OOP tools. It should:

1. Take account_number, owner, and optional balance (default 0) in __init__.
2. Track a transactions list — each entry a (kind, amount) tuple.
3. Have deposit(amount) and withdraw(amount) methods. Withdraw raises ValueError if amount exceeds the balance.
4. Have a __repr__ like BankAccount(account_number='A001', owner='Linus', balance=120).
5. Compare equal by account_number only (two accounts with the same number are "the same account").
6. Provide a @classmethod from_csv_row(row) that parses "A001,Linus,100" into a new account.

Skeleton:

python
class BankAccount:
    def __init__(self, account_number, owner, balance=0):
        # TODO 1: store the three attributes
        # TODO 2: initialise an empty transactions list (per-instance!)
        ...

    def deposit(self, amount):
        # TODO 3: increase balance, append ("deposit", amount) to transactions
        ...

    def withdraw(self, amount):
        # TODO 4: raise ValueError if amount > balance, else subtract and log
        ...

    def __repr__(self):
        ...

    def __eq__(self, other):
        # TODO 5: compare by account_number only; return NotImplemented for non-accounts
        ...

    @classmethod
    def from_csv_row(cls, row):
        # TODO 6: split on ",", convert balance to int, call cls(...)
        ...


a = BankAccount("A001", "Linus", 100)
a.deposit(50)
a.withdraw(30)
print(a)                                # BankAccount(account_number='A001', owner='Linus', balance=120)
print(a.transactions)                   # [('deposit', 50), ('withdraw', 30)]

b = BankAccount.from_csv_row("A001,Different Person,9999")
print(a == b)                           # True — same account number
Hint 1 — Per-instance lists Put self.transactions = [] inside __init__. If you put transactions = [] at class-body level, every account would share the same list — the bug from Section 5.
Hint 2 — Equality by one field Inside __eq__, check isinstance(other, BankAccount) first. If not, return NotImplemented. Otherwise compare self.account_number == other.account_number.
Show full solution
python
class BankAccount:
    def __init__(self, account_number, owner, balance=0):
        self.account_number = account_number
        self.owner = owner
        self.balance = balance
        self.transactions = []                  # per-instance — must live in __init__

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

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount
        self.transactions.append(("withdraw", amount))

    def __repr__(self):
        return (f"BankAccount(account_number={self.account_number!r}, "
                f"owner={self.owner!r}, balance={self.balance})")

    def __eq__(self, other):
        if not isinstance(other, BankAccount):
            return NotImplemented
        return self.account_number == other.account_number

    def __hash__(self):
        return hash(self.account_number)        # keeps instances usable as dict keys

    @classmethod
    def from_csv_row(cls, row):
        num, owner, balance = row.split(",")
        return cls(num.strip(), owner.strip(), int(balance.strip()))


a = BankAccount("A001", "Linus", 100)
a.deposit(50)
a.withdraw(30)
print(a)
print(a.transactions)

b = BankAccount.from_csv_row("A001, Different Person, 9999")
print(a == b)                                   # True
print({a, b})                                   # {BankAccount(...)} — one entry, hash collapsed

The __hash__ definition is what lets you put accounts into a set even after overriding __eq__. Without it, you'd get TypeError: unhashable type: 'BankAccount' the moment you tried.


What You Learned

  • A class packages state (attributes) and behaviour (methods) under one name. Use one when data has identity and many operations act on it.
  • __init__ initialises an instance; self is the conventional name for the instance.
  • __repr__ is the unambiguous developer view; __str__ is the user-facing one. Define __repr__ always.
  • Default == is identity. __eq__ lets you compare by value — and unhashes the class unless you also define __hash__.
  • Class attributes are shared; instance attributes (self.x) are per-object. Never put a mutable default at class-body level.
  • @classmethod for alternative constructors using cls(...). @staticmethod for class-related utilities. @property for computed/derived attributes.

Next: Inheritance & Polymorphism — building hierarchies, the super() call, and when not to inherit.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.