Object-Oriented Programming (OOP) organizes code around objects that combine data and behavior. Python supports OOP fully while remaining flexible enough for other paradigms.

Classes and Objects

  class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self.balance = balance

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

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

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

account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account)  # BankAccount(owner='Alice', balance=1300.0)
  
  • __init__ — constructor, called on instantiation
  • self — reference to the current instance
  • __repr__ — developer-friendly string representation

Instance vs Class Attributes

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

    def __init__(self, name):
        self.name = name          # instance attribute — unique

d1 = Dog("Buddy")
d2 = Dog("Max")
print(d1.species)  # Canis familiaris
d1.species = "Wolf"  # creates instance attribute, doesn't change class
print(d2.species)    # Canis familiaris
  

Inheritance

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

    def speak(self):
        raise NotImplementedError("Subclass 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!"

animals = [Dog("Buddy"), Cat("Whiskers")]
for animal in animals:
    print(animal.speak())  # polymorphism
  

super()

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

class Manager(Employee):
    def __init__(self, name, salary, department):
        super().__init__(name, salary)
        self.department = department
  

Encapsulation

Python uses naming conventions instead of strict access control:

  class Account:
    def __init__(self, balance):
        self._balance = balance       # "protected" — convention
        self.__pin = "1234"           # name mangled to _Account__pin

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = value
  
Convention Meaning
name Public
_name Internal use (convention)
__name Name mangled (harder to access accidentally)

Abstract Base Classes

  from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def charge(self, amount: float) -> str:
        pass

    @abstractmethod
    def refund(self, transaction_id: str) -> bool:
        pass

class StripeProcessor(PaymentProcessor):
    def charge(self, amount):
        return f"stripe_txn_{amount}"

    def refund(self, transaction_id):
        return True

# PaymentProcessor()  # TypeError — can't instantiate ABC
  

Magic (Dunder) Methods

  class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = 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 __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __len__(self):
        return 2

    def __getitem__(self, index):
        return (self.x, self.y)[index]

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

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

Common magic methods: __str__, __repr__, __eq__, __lt__, __len__, __getitem__, __call__, __enter__/__exit__.

Composition Over Inheritance

Prefer composing objects over deep inheritance hierarchies:

  class Engine:
    def start(self):
        return "Engine running"

class Car:
    def __init__(self):
        self.engine = Engine()  # has-a, not is-a

    def start(self):
        return self.engine.start()

# vs fragile inheritance:
# class Vehicle: ...
# class Motorized(Vehicle): ...
# class Car(Motorized): ...
  

dataclasses — Less Boilerplate

  from dataclasses import dataclass, field

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

    @property
    def display(self):
        return f"{self.name}: ${self.price:.2f}"
  

See also Advanced Topics for full dataclass coverage.

When to Use OOP

Use classes when… Use functions when…
Modeling entities with state Transforming data
Multiple related methods on shared state Single-purpose operations
Building frameworks/libraries Scripts and utilities

Python doesn’t require everything to be a class — use OOP when it clarifies structure, not by default.