Lesson 3 / 6

03. OOP in Python — Classes, Inheritance & Magic Methods

TL;DR

Python OOP is duck-typed — if it quacks like a duck, it's a duck. No interfaces needed. Use dunder methods to make your classes work with built-in operators. Use dataclasses to skip boilerplate. Multiple inheritance works via C3 linearization (MRO).

Python’s OOP is fundamentally different from Java or C++. There are no access modifiers (no private, protected, public). There are no interfaces. There’s no method overloading. Instead, Python uses duck typing, dunder methods, and conventions.

Python class hierarchy and Method Resolution Order with diamond inheritance

Classes — The Basics

class User:
    """A user in the system."""

    # Class variable — shared by all instances
    default_role = "viewer"

    def __init__(self, name: str, email: str):
        # Instance variables — unique to each instance
        self.name = name
        self.email = email
        self.role = User.default_role

    def promote(self, new_role: str):
        self.role = new_role

    def __repr__(self):
        return f"User(name={self.name!r}, role={self.role!r})"

alice = User("Alice", "[email protected]")
alice.promote("admin")
print(alice)  # User(name='Alice', role='admin')

self Explained

self is not a keyword — it’s a convention. It’s the first argument to every instance method and refers to the current instance. It’s equivalent to this in Java/JS, but explicit.

class Dog:
    def bark(self):
        return f"{self.name} says woof!"

# When you call d.bark(), Python translates it to Dog.bark(d)
d = Dog()
d.name = "Rex"
d.bark()           # "Rex says woof!"
Dog.bark(d)        # Same thing — self is just d

Dunder (Magic) Methods

Dunder methods let your class work with Python’s built-in operations.

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

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

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

    # Hashing (needed if you want to use as dict key or in set)
    def __hash__(self):
        return hash((self.x, self.y))

    # Arithmetic operators
    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)

    # Absolute value (magnitude)
    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

    # Boolean (is it a zero vector?)
    def __bool__(self):
        return abs(self) > 0

    # Make it iterable (for unpacking)
    def __iter__(self):
        yield self.x
        yield self.y

# Now the class works naturally with Python
v1 = Vector(3, 4)
v2 = Vector(1, 2)
v1 + v2        # Vector(4, 6)
v1 * 3         # Vector(9, 12)
abs(v1)        # 5.0
bool(Vector(0, 0))  # False
x, y = v1      # x=3, y=4 (unpacking via __iter__)

Inheritance

class Animal:
    def __init__(self, name: str):
        self.name = name

    def speak(self):
        raise NotImplementedError("Subclasses must implement speak()")

class Dog(Animal):
    def speak(self):
        return f"{self.name} says woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow!"

# Polymorphism — no interface declaration needed
animals = [Dog("Rex"), Cat("Whiskers"), Dog("Buddy")]
for animal in animals:
    print(animal.speak())
# Rex says woof!
# Whiskers says meow!
# Buddy says woof!

super() — Calling Parent Methods

class Employee:
    def __init__(self, name: str, salary: float):
        self.name = name
        self.salary = salary

class Manager(Employee):
    def __init__(self, name: str, salary: float, team_size: int):
        super().__init__(name, salary)  # Call parent __init__
        self.team_size = team_size

m = Manager("Alice", 150000, 8)
m.name       # "Alice" (inherited)
m.team_size  # 8

Multiple Inheritance and MRO

class A:
    def greet(self):
        return "A"

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

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

class D(B, C):
    pass

d = D()
d.greet()       # "B" — follows MRO
D.__mro__       # (D, B, C, A, object)

# super() follows MRO, not just "parent"
class B(A):
    def greet(self):
        next_greet = super().greet()  # Calls C.greet(), not A.greet()!
        return f"B -> {next_greet}"

Privacy Convention

Python has no private keyword. Instead, it uses naming conventions:

class Account:
    def __init__(self, balance):
        self.balance = balance          # Public
        self._internal_id = "abc123"    # "Private by convention" — don't touch from outside
        self.__secret = "hidden"        # Name-mangled to _Account__secret

a = Account(100)
a.balance              # 100 (public, fine)
a._internal_id         # "abc123" (works, but you shouldn't)
# a.__secret           # AttributeError
a._Account__secret     # "hidden" (name mangling — you CAN access it, but don't)

The Python philosophy: “We’re all consenting adults.” A single underscore _prefix means “this is internal” and that’s enough.

Dataclasses (Python 3.7+)

Dataclasses eliminate the boilerplate of __init__, __repr__, __eq__, etc.

from dataclasses import dataclass, field

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0
    tags: list[str] = field(default_factory=list)

# Auto-generated: __init__, __repr__, __eq__
p = Product("Widget", 9.99, 100, ["sale", "new"])
print(p)  # Product(name='Widget', price=9.99, quantity=100, tags=['sale', 'new'])

p2 = Product("Widget", 9.99, 100, ["sale", "new"])
p == p2   # True (auto __eq__)

# Immutable dataclass
@dataclass(frozen=True)
class Point:
    x: float
    y: float

pt = Point(3, 4)
# pt.x = 5  # FrozenInstanceError!

# Post-init processing
@dataclass
class Order:
    items: list[str]
    subtotal: float
    tax_rate: float = 0.08
    total: float = field(init=False)  # Computed, not passed in

    def __post_init__(self):
        self.total = self.subtotal * (1 + self.tax_rate)

order = Order(["Widget"], 100.0)
order.total  # 108.0

Abstract Base Classes

When you do need interface-like contracts:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        ...

    @abstractmethod
    def perimeter(self) -> float:
        ...

# shape = Shape()  # TypeError: Can't instantiate abstract class

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

Protocols (Structural Typing — Python 3.8+)

Protocols are Python’s answer to “duck typing with type checking”:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str: ...

class Circle:
    def draw(self) -> str:
        return "Drawing circle"

class Square:
    def draw(self) -> str:
        return "Drawing square"

def render(shape: Drawable) -> None:
    """Accepts any object with a draw() method — no inheritance needed."""
    print(shape.draw())

render(Circle())   # Works — Circle has draw()
render(Square())   # Works — Square has draw()

Key Takeaways

  • Python OOP is duck-typed — implement the right methods, don’t worry about interfaces.
  • Use dunder methods (__add__, __eq__, __repr__, etc.) to integrate with Python’s operators and built-ins.
  • super() follows the MRO (C3 linearization), not just the direct parent.
  • Use _prefix for private-by-convention. Don’t use __mangling unless you have a specific reason.
  • Use @dataclass for data-holding classes — it generates boilerplate for free.
  • Use ABC for strict contracts. Use Protocol for structural (duck) typing with type checkers.