🐍 Python Course · Stage 1 · Lesson 16 of 89 · Object-Oriented Python
Course Overview →

🟩 Start with the Beginner tab. Read it fully before moving on.

← Context Managers Dataclasses →
thecodex.expert · The Codex Family of Knowledge
Python

Object-Oriented Python in Python

Classes, instances, dunder methods, properties, dataclasses — Python's complete object model in one place.

Python 3.13 docs.python.org Last verified:
Canonical Definition

Python OOP uses classes as blueprints for objects — instances carry their own state (instance attributes), methods are functions bound to instances via the descriptor protocol, and inheritance follows C3 linearisation (MRO).

🟩 Beginner

Classes — blueprints for objects

What you'll learn: How to define a class, create objects from it, and give them data (attributes) and behaviour (methods). After this tab you can model real-world things in code — a User, a Product, a BankAccount — and give them the data and actions they need.

How to read this tab: Focus on the class definition syntax: class Name:, def __init__(self, ...):, self.attribute = value. Then try writing a small class yourself — a Dog with a name attribute and a bark() method is the classic first exercise.

⏱ 50 min 📄 2 sections 🔶 Prerequisite: Functions & Scope
The analogy

A class is a blueprint. An instance is the house built from it. The blueprint defines what rooms every house has and what they do — the actual house has its own rooms filled with its own furniture.

💡

The mental model: a class is a blueprint; an object (instance) is a thing built from that blueprint. Every car on the road is an instance of the Car blueprint. The blueprint says "cars have 4 wheels and can accelerate". The specific car says "this car is red and currently at 60km/h". __init__ is the method that runs when a new instance is created.

Classes and instances

Pythonclasses_basics.py
class BankAccount:
    # Class attribute — shared by ALL instances
    bank_name = "The Codex Bank"

    def __init__(self, owner, balance=0):
        # Instance attributes — unique to each instance
        self.owner = owner
        self.balance = balance

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

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

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

    def __str__(self):
        return f"{self.owner}'s account: ₹{self.balance:,}"

# Create instances
acc1 = BankAccount("Priya", 10000)
acc2 = BankAccount("Rahul")

acc1.deposit(5000)
print(acc1)           # Priya's account: ₹15,000
print(repr(acc1))     # BankAccount('Priya', 15000)
print(acc1.bank_name) # The Codex Bank
print(BankAccount.bank_name)  # same — class attribute
💡

Use inheritance when there's a genuine "is-a" relationship. A Dog IS-A Animal. A CheckingAccount IS-A BankAccount. Don't force inheritance where composition (having an object as an attribute) would be cleaner. The rule: if you find yourself writing super().__init__() and then overriding almost everything, you probably want composition instead.

Inheritance

Pythoninheritance.py
class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

    def speak(self):
        return f"{self.name} says {self.sound}"

    def __repr__(self):
        return f"{type(self).__name__}({self.name!r})"

class Dog(Animal):
    def __init__(self, name):
        super().__init__(name, "Woof")   # call parent __init__

    def fetch(self, item):
        return f"{self.name} fetches the {item}!"

class Cat(Animal):
    def __init__(self, name):
        super().__init__(name, "Meow")

    def speak(self):   # override parent method
        return f"{self.name} purrs softly"

d = Dog("Rex")
c = Cat("Whiskers")

print(d.speak())    # Rex says Woof
print(c.speak())    # Whiskers purrs softly
print(d.fetch("ball"))

# Polymorphism — same interface, different behaviour
animals = [Dog("Rex"), Cat("Whiskers"), Dog("Bruno")]
for animal in animals:
    print(animal.speak())   # calls correct speak() for each type

Dunder methods make your objects feel native. Implement __str__ so print() shows something useful. Implement __len__ so len() works. Implement __eq__ so == comparison works. Implement __iter__ so your object can be used in a for loop. Each dunder method hooks your class into a Python built-in operation.

Special methods (dunder methods)

Special methods let your class integrate with Python's built-in syntax. Define __add__ and your objects work with +. Define __len__ and len() works. Define __iter__ and __next__ and your class is iterable with for.

Pythondunder_methods.py
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, y

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __len__(self):
        return 2   # always 2 components

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __abs__(self):
        return (self.x**2 + self.y**2) ** 0.5

    def __bool__(self):
        return self.x != 0 or self.y != 0   # zero vector is falsy

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)    # Vector(4, 6)
print(v1 * 3)     # Vector(3, 6)
print(len(v1))    # 2
print(abs(v2))    # 5.0
print(v1 == Vector(1, 2))  # True

✅ Beginner tab complete — check your understanding

  • I can define a class with __init__ and at least one other method
  • I understand what self refers to (the specific object being operated on)
  • I can create two objects from the same class and give them different attribute values
  • I can write a subclass that inherits from a parent class and adds new behaviour

Continue to Error Handling →

🔵 Intermediate

Dunder methods, properties, and dataclasses

What you'll learn: Special methods (dunders like __str__, __len__, __eq__) that let your objects behave like built-in types. @property for computed attributes with getter/setter logic. Dataclasses — Python 3.7's elegant shorthand for data-holding classes.

How to read this tab: The dataclasses section is the most immediately useful. After reading it, rewrite one of your simple classes as a dataclass and feel how much boilerplate disappears.

⏱ 50 min 📄 3 sections 🔶 Prerequisite: Beginner tab
📎

@property is the Python way of getters/setters. Instead of getAge() and setAge() like Java, Python uses @property to create an attribute that runs code when accessed. The @property.setter decorator lets you validate or transform the value being assigned. Use it when you need computed attributes or validation — not just to wrap a simple stored value.

Properties and descriptors

The @property decorator lets you define getter, setter, and deleter for an attribute while accessing it like a plain attribute — not a method call. This is Python's way of enforcing encapsulation without ugly getX()/setX() Java-style methods.

Pythonproperties.py
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius   # private by convention (single underscore)

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

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

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

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5/9   # delegates validation to celsius setter

t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0

t.fahrenheit = 32
print(t.celsius)     # 0.0

# t.celsius = -300   # raises ValueError

Use dataclasses for data-holding classes. If your class mostly stores data (attributes) with minimal behaviour, @dataclass writes __init__, __repr__, and __eq__ for you automatically. You just declare the fields with type annotations. This is now the standard way to write data classes in Python — you'll see it everywhere in modern code.

Dataclasses (Python 3.7+)

@dataclass (PEP 557) automatically generates __init__, __repr__, __eq__, and optionally __lt__/__hash__ for a class based on its field annotations. It replaces 20+ lines of boilerplate with a decorator.

Pythondataclasses.py
from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float

@dataclass
class Employee:
    name: str
    department: str
    salary: float = 50000.0   # default value
    skills: list = field(default_factory=list)  # mutable default

    def give_raise(self, percent: float) -> None:
        self.salary *= (1 + percent / 100)

# Auto-generated __init__, __repr__, __eq__
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)           # Point(x=1.0, y=2.0)
print(p1 == p2)     # True

emp = Employee("Priya", "Engineering")
emp.skills.append("Python")
emp.give_raise(10)
print(emp)  # Employee(name='Priya', department='Engineering', salary=55000.0, skills=['Python'])

# @dataclass(frozen=True) makes it immutable (and hashable)
@dataclass(frozen=True)
class FrozenPoint:
    x: float
    y: float

fp = FrozenPoint(1.0, 2.0)
# fp.x = 3.0  # FrozenInstanceError
📎

Multiple inheritance is powerful but complex. Python uses the C3 linearisation algorithm (MRO) to determine which parent class's method to call when multiple parents define the same method. The practical rule: use super() in every __init__ in your hierarchy, and Python will call them in MRO order. Call type.mro() on any class to see the resolution order.

Multiple inheritance and MRO

Python supports multiple inheritance. When a method is called, Python uses the C3 linearisation algorithm (Method Resolution Order — MRO) to determine which class's method to call. ClassName.__mro__ shows the resolution order. super() always follows the MRO, which is why super() is safe in multiple inheritance scenarios.

Pythonmro.py
class A:
    def method(self): return "A"

class B(A):
    def method(self): return "B"

class C(A):
    def method(self): return "C"

class D(B, C):   # inherits from both B and C
    pass

d = D()
print(d.method())   # B — follows MRO
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

# Mixin pattern — add behaviour without deep inheritance
class JSONMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)

class TimestampMixin:
    def created_at(self):
        from datetime import datetime
        return datetime.now().isoformat()

class User(JSONMixin, TimestampMixin):
    def __init__(self, name, email):
        self.name = name
        self.email = email

user = User("Priya", "priya@example.com")
print(user.to_json())    # {"name": "Priya", "email": "priya@example.com"}
print(user.created_at())
Commonly confused
Name mangling (__name) is not true privacy. Attributes starting with __ (double underscore) are name-mangled to _ClassName__name by Python — they are still accessible, just renamed. This is a convention for subclass collision avoidance, not access control. Single underscore (_name) is the idiomatic convention for "internal use".
Class attributes and instance attributes are different. A class attribute is shared by all instances. If an instance assigns to the same name, it creates an instance attribute that shadows the class attribute — the class attribute is unchanged. Mutating a mutable class attribute (like a list) through one instance affects all instances.
__repr__ and __str__ are different. __repr__ should return an unambiguous representation (ideally valid Python to recreate the object). __str__ should return a readable description for end users. print() uses __str__ first; repr() uses __repr__. If only __repr__ is defined, __str__ falls back to it.

✅ Intermediate tab complete — check your understanding

  • I can implement __str__ so print(my_object) shows something readable
  • I can implement __eq__ so two objects with the same data compare as equal
  • I can use @property to create a computed attribute
  • I can write a dataclass and know when it's preferable to a regular class

Continue to Error Handling →

🔴 Expert

MRO, metaclasses, and the descriptor protocol

What you'll learn: Multiple inheritance and the C3 Method Resolution Order — how Python decides which method to call when multiple parent classes define the same method. Metaclasses — classes whose instances are classes. The descriptor protocol — the mechanism behind @property, @staticmethod, and @classmethod.

How to read this tab: Read after Stage 2. Metaclasses especially are rarely needed in application code — but understanding them explains how frameworks like Django's ORM, SQLAlchemy, and Python's own dataclasses work internally.

⏱ 35 min 📄 3 sections 🔶 Prerequisite: After Stage 2

Metaclasses are advanced. A metaclass is the class of a class — it controls how classes themselves are created. You almost certainly don't need to write a metaclass in application code. But understanding that class definitions go through a metaclass explains how Django models, SQLAlchemy, and Python's own dataclasses and ABCs work internally.

The Python object model: metaclasses

In Python, classes are themselves objects — instances of a metaclass. The default metaclass is type. When Python executes a class statement, it calls the metaclass to create the class object. A custom metaclass can intercept class creation to add or modify attributes, enforce constraints, register classes, or implement the class pattern. The process: type.__new__(mcs, name, bases, namespace) creates the class; type.__init__(cls, name, bases, namespace) initialises it. Abstract Base Classes (abc.ABCMeta) use a custom metaclass to enforce abstract method implementation.

Descriptor protocol

Python Language Reference §3.3.2: a descriptor is any object that defines __get__, __set__, or __delete__. When a descriptor is accessed through a class or instance, Python calls these methods instead of returning the object directly. property, classmethod, staticmethod, and __slots__ are all implemented as descriptors. Non-data descriptors (only __get__) are overridden by instance variables. Data descriptors (have __set__ or __delete__) take precedence over instance variables — this is how property setters work.

✅ Expert tab complete

  • I can use super() correctly in a multiple inheritance hierarchy
  • I can explain what MRO is and use type.mro() to inspect it
  • I know what a metaclass is and can give one real-world example of where they're used
  • I understand why @property works (it's a descriptor object)

Continue to Error Handling →

Sources

1
Python Language Reference §3 — Data model. docs.python.org/3/reference/datamodel.html.
2
Python Language Reference §3.3.2 — Customising attribute access and the descriptor protocol.
3
PEP 557 — Data Classes. peps.python.org/pep-0557/.
4
PEP 3119 — Introducing Abstract Base Classes. peps.python.org/pep-3119/.
Source confidence: High Last verified: Primary source: Python Language Reference · docs.python.org/3/reference/