Notifications

No notifications

/Phase 3

Object-Oriented Programming

Object-Oriented Programming in Python

OOP organizes code around objects — instances of classes that bundle data (attributes) and behavior (methods) together.

Creating a Class

class Dog:
    species = "Canis familiaris"   # Class attribute

def __init__(self, name, age): self.name = name # Instance attribute self.age = age

def bark(self): return f"{self.name} says Woof!"

Instance vs Class Attributes

TypeDefinedShared?Access
ClassIn class bodyYes, all instancesClassName.attr or self.attr
InstanceIn __init__ via selfNo, per objectself.attr

Inheritance

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

def speak(self): raise NotImplementedError

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

Key OOP Pillars

PillarDescription
EncapsulationBundle data + methods; hide internals with _ or __ prefix
InheritanceChild class inherits from parent; extend or override behavior
PolymorphismSame interface, different implementations (speak() varies by animal)
AbstractionHide complexity; expose simple interface

Special (Dunder) Methods

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)

@property — Managed Attributes

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

@property def radius(self): return self._radius

@radius.setter def radius(self, value): if value < 0: raise ValueError("Radius must be non-negative") self._radius = value

> Tip: Favor composition over inheritance when the relationship is "has-a" rather than "is-a". Use inheritance only when there's a genuine parent-child relationship.

On this page

Detailed Theory

Object-Oriented Programming bundles data (attributes) and behaviour (methods) into reusable units called *classes*. In Python, OOP is opt-in — you can write whole programs in functions — but for anything with state, lifecycle, or polymorphic behaviour (HTTP servers, ML models, game entities, parsers), classes shine.

What a Class Actually Is

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

def greet(self): return f"hi, I'm {self.name}"

u = User("Alice", 30) print(u.greet()) # hi, I'm Alice

A class is a blueprint. An instance (u) is a specific object built from it. __init__ runs at construction; self refers to the instance being acted on.

The Four Pillars (Quickly)

  • Encapsulation — group state and behaviour together; hide internals.
  • Inheritance — a class can extend another (class Admin(User):).
  • Polymorphism — different classes can implement the same method differently.
  • Abstraction — expose the minimum useful interface; keep messy details inside.

Attributes: Instance vs Class

class Counter:
    total = 0                # class-level (shared)
    def __init__(self):
        self.value = 0       # instance-level (per object)
    def step(self):
        self.value += 1
        Counter.total += 1

Instance attrs live on each object. Class attrs are shared — great for constants and counters, but watch out: assigning to a *mutable* class attribute via self can cause subtle sharing bugs.

Methods: Instance, Class, Static

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

def describe(self): # instance method return f"pizza with {self.toppings}"

@classmethod def margherita(cls): # alternative constructor return cls(["tomato", "mozzarella"])

@staticmethod def is_vegetarian(toppings): # utility, no self/cls return "pepperoni" not in toppings

Beginner Mistakes to Skip

1. Forgetting self. Every instance method's first parameter must be self. 2. Calling Class.method(arg) and confusing yourself. Use instance.method(arg)self is supplied automatically. 3. Mutable class attributes. class C: items = [] is shared by all instances. Initialise mutables in __init__ instead. 4. is to compare instances. Use == (and define __eq__ if needed). 5. Forgetting to call super().__init__(...) in a subclass that needs the parent's initialisation to run. 6. Treating Python's _/__ as real privacy. It's convention. Don't fight name-mangling — just use it consistently.

Intermediate: Inheritance & super()

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

class Dog(Animal): def speak(self): return "woof"

class Puppy(Dog): def speak(self): parent = super().speak() return f"{parent} (tiny)"

super() calls the next class in the MRO (Method Resolution Order). Always prefer super() over hard-coding a parent class name — it survives refactors.

Intermediate: Dunder Methods ("Magic Methods")

class Money:
    def __init__(self, amount, currency="USD"):
        self.amount, self.currency = amount, currency
    def __repr__(self):    return f"Money({self.amount}, {self.currency!r})"
    def __str__(self):     return f"{self.amount} {self.currency}"
    def __eq__(self, o):   return self.amount == o.amount and self.currency == o.currency
    def __lt__(self, o):   return self.amount < o.amount
    def __add__(self, o):  return Money(self.amount + o.amount, self.currency)
    def __hash__(self):    return hash((self.amount, self.currency))

Dunders let your class plug into Python's built-in operators and protocols (+, ==, <, len(), in, for ... in, with, f"", etc.).

Most-used: __init__, __repr__, __str__, __eq__, __hash__, __len__, __iter__, __contains__, __enter__/__exit__.

Intermediate: @property for Computed / Validated Attributes

class Circle:
    def __init__(self, r):
        self._r = r
    @property
    def radius(self):
        return self._r
    @radius.setter
    def radius(self, v):
        if v < 0: raise ValueError("negative")
        self._r = v
    @property
    def area(self):
        return 3.14159 * self._r ** 2

c = Circle(3) c.radius = 5 # validated print(c.area) # like an attribute, computed on read

Get the cleanliness of attributes with the safety of methods.

Intermediate: Dataclasses — Less Boilerplate

from dataclasses import dataclass, field

@dataclass class Point: x: float y: float tags: list[str] = field(default_factory=list)

p = Point(3, 4) print(p) # Point(x=3, y=4, tags=[])

@dataclass auto-generates __init__, __repr__, __eq__ (and optionally ordering, hashing, immutability with frozen=True). Default for any "value object" you'd otherwise hand-write.

Advanced: MRO & Multiple Inheritance

Python resolves multi-parent calls via C3 linearisation. Inspect with Cls.mro() or Cls.__mro__.

class A:                 def m(self): print("A")
class B(A):              def m(self): super().m(); print("B")
class C(A):              def m(self): super().m(); print("C")
class D(B, C):           def m(self): super().m(); print("D")
D().m()    # A C B D

Multiple inheritance is powerful but easy to misuse — prefer mixins (small, focused, well-named classes) and composition over deep hierarchies.

Advanced: Abstract Base Classes (ABCs)

from abc import ABC, abstractmethod

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

class Circle(Shape): def __init__(self, r): self.r = r def area(self): return 3.14159 * self.r ** 2

ABCs document a contract and refuse instantiation when methods are missing. Many standard collection types (Iterable, Sized, Mapping) are ABCs you can register against.

Advanced: __slots__, Composition, & Performance

  • __slots__ = ('x', 'y') — disables the per-instance __dict__, saving memory for large numbers of small objects.
  • Composition over inheritance — hold collaborators as attributes (self.logger = ...) instead of inheriting from them. Easier to test, easier to swap.
  • Frozen dataclasses (@dataclass(frozen=True)) give you immutable, hashable value objects — great as dict keys.

Advanced: When *Not* to Use a Class

  • A bag of pure functions is fine — don't wrap them in a class for symmetry.
  • A simple record can be a NamedTuple or @dataclass, not a full class with custom methods.
  • A one-method class with no state is just a function in disguise.
Reach for OOP when there's *real* state, lifecycle, or polymorphism; otherwise, plain functions and data are simpler.

Practice Path

1. Write a BankAccount class with deposit, withdraw, __repr__, and a @property balance that can't go negative. 2. Convert it to a @dataclass and compare line counts. 3. Add an AnimalDog/Cat hierarchy with a polymorphic speak(); iterate a list of them and call speak() on each. 4. Implement a context-manager class with __enter__ / __exit__ that times the block (compare with @contextmanager from contextlib).