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. Suppose you pass the same dictionary into a dozen functions: update_balance(account, ...), withdraw(account, ...), format_account(account). That is a sign you should 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 complete a well-designed class.


1. Why OOP: When State and Behaviour Belong Together

Pure functions are the cleanest tool when you have inputs and want outputs. But sometimes data has an identity (this account, that user, my shopping cart), and many operations change it over time. Then a class organises the code around the thing you are modelling.

python
# Procedural style: it works, but all the code depends on 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 has no fixed structure. 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)

The logic is the same, but the data and the operations are now in one place. And reading 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 your code fills it with attributes. self isn't a keyword. It is only the conventional name for "the instance this method works on". You could call it this or me, but other Python programmers would find your code harder to read.

Every method gets self as its first parameter. Python passes 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 a shorter way to write Point.translate(p, 1, 2).


3. __repr__ vs __str__

By default, printing an instance gives you something unhelpful like <__main__.Point object at 0x7f...>. Two dunder methods fix this:

  • __repr__: unambiguous, for developers. Ideally it is valid Python that recreates the object.
  • __str__: friendly, for users.
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)]  (a list shows its items with __repr__)

If you only define __repr__, Python uses it for str(...) too. Always define __repr__. Skip __str__ unless you really need a different text for users.


4. __eq__ and Identity vs Equality

By default, a == b is true only when a and b are the same object in memory, just like 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  (they are two 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 makes Python fall back to an identity check)

Return NotImplemented (a special built-in value, not False) when the types don't match. Python will then try the other operand's __eq__. If that also returns NotImplemented, == falls back to identity and gives False.

One thing to watch: once you define __eq__, Python sets __hash__ to None and your instances become unhashable (they can't go in a set or be a dict key). If you want both, define __hash__ to return 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 by all dogs

    def __init__(self, name):
        self.name = name                # instance attribute, one for each 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 useful for constants and defaults. They cause bugs when the value is mutable:

python
class Team:
    members = []                        # Bad: every Team shares this one list

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

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

This is the same problem as the mutable default argument 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

There are three kinds of method. They differ in 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 that parses '2026-05-14'."""
        y, m, d = s.split("-")
        return cls(int(y), int(m), int(d))      # using cls(...) makes this work for subclasses too

    @staticmethod
    def is_leap(year):
        """A plain helper. It gets no self and 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). Because it calls cls(...), a subclass that uses it gets an instance of the subclass 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 you write obj.name like a normal attribute, but Python runs a method when you read it.

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 after area)
print(c.diameter)                       # 10

A property is read-only by default: c.area = 100 raises AttributeError. Add a setter when you really 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, which checks the value
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 use @property from the start. Begin with plain attributes, and change one into a property only when you need validation or a computed 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 detail in the dataclasses 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                       # a local variable, thrown away 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. If a subclass defines its own __init__ and does not call the parent's, the parent's attributes are never set. We cover 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 hides the class one

a = Counter()
a.bump()
print(a.total)                          # 1
print(Counter.total)                    # 0       (the class attribute is unchanged)

self.total += 1 reads Counter.total (0), adds 1, and assigns the result to self.total. That creates a new instance attribute. If you really want a shared counter, write Counter.total += 1.

5. Writing a class when a function would do. A class with one method and no state is a function with extra code around it. 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. Keep a transactions list. Each entry is 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 (one for each account!)
        ...

    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. That is 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 = []                  # one list per account, so create it 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, because a and b are equal)

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' as soon as 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, and self is the conventional name for the instance.
  • __repr__ is the unambiguous developer view, and __str__ is the user-facing one. Define __repr__ always.
  • Default == compares identity. __eq__ lets you compare by value, but it makes instances unhashable unless you also define __hash__.
  • Class attributes are shared, but 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 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 did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.