Chapter 9

Object-Oriented Programming

10.0 Intro · 10.1 Design & Methods · 10.2 Four Pillars · 10.3 Advanced Topics

← → or Space to navigate · F for fullscreen

What is OOP?

Organizing code around objects that combine data and behavior

Why Object-Oriented Programming?

Procedural approach

Data and functions are separate — you pass data to functions and hope nothing breaks.

name = "Lia"
balance = 100.0

def deposit(balance, amount):
    return balance + amount

balance = deposit(balance, 50)

OOP approach

Data and behavior are bundled together — the object knows what it can do.

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

    def deposit(self, amount):
        self.balance += amount

acc = BankAccount("Lia", 100.0)
acc.deposit(50)

OOP benefits: encapsulation (data is protected), reuse (inherit & extend), modeling (code mirrors the real world).

9.1 Design & Methods

Classes, instances, __init__, methods, properties

Classes and Instances

  • A class is a blueprint.
  • An instance is one concrete object created from that blueprint.
  • self refers to this instance inside the class.
  • Each instance has its own copy of instance attributes.

type(obj) → the class · isinstance(obj, Cls) → membership check

class Point:
    def __init__(self, x, y):
        self.x = x   # instance attribute
        self.y = y

    def distance_to(self, other):
        return ((self.x - other.x)**2 +
                (self.y - other.y)**2) ** 0.5

p1 = Point(0, 0)   # instance
p2 = Point(3, 4)

print(p1.distance_to(p2))   # 5.0
print(isinstance(p1, Point)) # True

The __init__ Method

  • Called automatically when an instance is created.
  • Sets up instance attributes on self.
  • Can have default parameter values.
  • Does not return anything (implicitly None).
class BankAccount:
    def __init__(self, owner="Unknown",
                 balance=0.0):
        self.owner = owner
        self._balance = balance  # _ = convention

acc = BankAccount("Lia", 500.0)
print(acc.owner)    # Lia
print(acc._balance) # 500.0

Key Dunder Methods

__init__ Called on creation; sets up attributes.
__str__ Human-readable string; called by print().
__repr__ Developer string; called in the REPL.
__eq__ Defines == between objects.
__lt__ Defines <; enables sorting.
__hash__ Needed for use as dict key or in a set.
__add__ Defines + operator.
__len__ Defines len(obj).
__contains__ Defines in operator.

Dunder = "double underscore". Python calls them automatically behind the scenes.

Properties — @property

  • Use a method like an attribute.
  • Add computed or validated access.
  • Use @attr.setter to validate writes.

_balance signals internal use. It is a convention, not enforcement.

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

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

    @balance.setter
    def balance(self, value):   # setter
        if value < 0:
            raise ValueError("Negative balance")
        self._balance = value

acc = BankAccount(100)
print(acc.balance)   # 100  (no parentheses!)
acc.balance = 200    # calls setter
acc.balance = -5     # raises ValueError

9.2 The Four Pillars of OOP

Encapsulation · Polymorphism · Inheritance · Abstraction

Encapsulation

Bundle data + behavior together, and restrict direct access to internals.

Convention Meaning
name Public — use freely
_name Internal — avoid outside class
__name Name-mangled — strongest hint

Python relies on convention, not hard enforcement. Respect the single underscore.

class Thermostat:
    def __init__(self, temp):
        self._temp = temp   # internal state

    @property
    def temperature(self):
        return self._temp

    def set_temperature(self, value):
        if value < 0 or value > 40:
            raise ValueError("Out of range")
        self._temp = value

t = Thermostat(22)
t.set_temperature(25)   # OK
t.set_temperature(100)  # ValueError

Inheritance

  • A child class inherits all attributes and methods of its parent.
  • Use super() to call the parent's method.
  • issubclass(Child, Parent)True
  • Child can override any parent method.
class Animal:
    def __init__(self, name):
        self.name = name

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

class Dog(Animal):
    def speak(self):          # override
        return f"{self.name} barks"

class Cat(Animal):
    def speak(self):          # override
        return f"{self.name} meows"

pets = [Dog("Rex"), Cat("Luna")]
for p in pets:
    print(p.speak())
# Rex barks
# Luna meows

Polymorphism

  • Same method name, different behavior depending on the object's type.
  • Lets you write code that works on any object with the right interface.
  • Achieved through method overriding and duck typing.

Duck typing: If it walks like a duck and quacks like a duck, it's a duck. Python checks behavior, not type.

class Shape:
    def area(self): ...

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

class Rectangle(Shape):
    def __init__(self, w, h): self.w, self.h = w, h
    def area(self): return self.w * self.h

shapes = [Circle(5), Rectangle(3, 4)]
for s in shapes:
    print(s.area())   # each behaves correctly

Abstraction & Abstract Base Classes

  • Hide implementation details; expose only what the caller needs.
  • Abstract Base Class (ABC) defines a required interface.
  • Any subclass must implement the abstract methods or Python raises TypeError.

You cannot instantiate an abstract class directly.

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def charge(self, amount): ...

class CreditCard(PaymentMethod):
    def charge(self, amount):
        print(f"Charging ${amount} to card")

class PayPal(PaymentMethod):
    def charge(self, amount):
        print(f"Sending ${amount} via PayPal")

# PaymentMethod()  ← TypeError!
cc = CreditCard()
cc.charge(50)   # Charging $50 to card

Class Variables & super()

Class Variables

Shared across all instances. Set on the class, not on self.

class Counter:
    count = 0           # class variable

    def __init__(self):
        Counter.count += 1

a = Counter()
b = Counter()
print(Counter.count)  # 2

super()

Call the parent class's method without hardcoding the parent's name.

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance,
                 interest_rate):
        super().__init__(owner, balance)
        self.interest_rate = interest_rate

    def apply_interest(self):
        self._balance *= (1 + self.interest_rate)

9.3 Advanced OOP Topics

Comparison dunders · Operator overloading · @dataclass · Static & class methods

Comparison Dunder Methods

Method Operator
__eq__ ==
__lt__ <
__le__ <=
__gt__ >
__ge__ >=

Shortcut: Define __eq__ + one ordering method, then use @functools.total_ordering to derive the rest.

from functools import total_ordering

@total_ordering
class Student:
    def __init__(self, name, gpa):
        self.name = name
        self.gpa = gpa

    def __eq__(self, other):
        return self.gpa == other.gpa

    def __lt__(self, other):
        return self.gpa < other.gpa

s1 = Student("Alice", 3.8)
s2 = Student("Bob",   3.5)
print(s1 > s2)   # True  ← derived by decorator

Hashability & __hash__

  • Objects used as dict keys or in sets must be hashable.
  • Defining __eq__ sets __hash__ = None by default (unhashable).
  • Define __hash__ explicitly to restore hashability.

Rule: Objects that compare equal must have the same hash.

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

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

    def __hash__(self):
        return hash((self.x, self.y))

p = Point(1, 2)
seen = {p}             # works — hashable
memo = {p: "origin"}   # works as dict key

Operator Overloading

Method Operator
__add__ +
__sub__ -
__mul__ *
__len__ len()
__getitem__ obj[i]
__contains__ in
class Vector:
    def __init__(self, x, y):
        self.x, self.y = x, 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 __repr__(self):
        return f"Vector({self.x}, {self.y})"

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

@dataclass

Auto-generates __init__, __repr__, and __eq__ from annotated fields.

Option Effect
eq=True (default) Auto-generates __eq__
order=True Also generates <, <=, >, >=
frozen=True Immutable; enables __hash__
field(default_factory=…) Safe mutable default
from dataclasses import dataclass, field

@dataclass(order=True)
class Student:
    name: str
    gpa: float
    courses: list = field(default_factory=list)

s1 = Student("Alice", 3.8)
s2 = Student("Bob",   3.5)
print(s1 > s2)     # True  (order=True)

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

p = Point(1, 2)
# p.x = 9   ← FrozenInstanceError
d = {p: "origin"}  # hashable!

Class vs. Instance Variables

  • Instance variable: set on self; unique per object.
  • Class variable: set on the class; shared across all instances.

Mutation trap: a mutable class variable (like a list) is shared — appending from one instance affects all instances.

# WRONG — all instances share one list!
class Broken:
    items = []

# CORRECT — each instance gets its own list
class Fixed:
    def __init__(self):
        self.items = []
class Player:
    count = 0         # class variable

    def __init__(self, name):
        self.name = name      # instance var
        Player.count += 1

p1 = Player("Lia")
p2 = Player("Kai")

print(Player.count)   # 2  (shared)
print(p1.name)        # Lia (unique)
print(p2.name)        # Kai (unique)

Static & Class Methods

Decorator First param Typical use
(none) self Regular instance method
@classmethod cls Alternative constructors; access class state
@staticmethod Utility; logically related but needs no instance or class
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @classmethod
    def from_fahrenheit(cls, f):   # alt constructor
        return cls((f - 32) * 5/9)

    @staticmethod
    def is_valid(celsius):         # utility
        return -273.15 <= celsius

t = Temperature.from_fahrenheit(212)
print(t.celsius)              # 100.0
print(Temperature.is_valid(-300))  # False

Chapter 9 — Quick Reference

Concept Key syntax / notes
Class definition class Name: + __init__(self, …)
Property @property getter; @attr.setter for validation
Inheritance class Child(Parent): + super().__init__()
Abstract methods from abc import ABC, abstractmethod
Ordering __eq__ + __lt__ + @total_ordering
Operator overload __add__, __mul__, __len__, …
@dataclass order=True, frozen=True, field(default_factory=…)
Class method @classmethod + cls param — for alt constructors
Static method @staticmethod — no self or cls

End of Chapter 9

Next: Chapter 10 — Functional Programming

map · filter · lambda · decorators · recursion · functools