Chapter 12

Object-Oriented Programming

12.0 Intro 12.1 Design & Methods 12.2 Four Pillars 12.3 Advanced Topics

Use ← → arrow keys or Space to navigate  |  Press 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).

12.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 returns 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

12.2 The Four Pillars of OOP

Encapsulation  ·  Polymorphism  ·  Inheritance  ·  Abstraction

Encapsulation

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

ConventionMeaning
namePublic — use freely
_nameInternal — avoid outside class
__nameName-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):
        ...   # no implementation here

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 of the class. 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 — especially __init__ — 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)

12.3 Advanced OOP Topics

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

Comparison Dunder Methods

MethodOperator
__eq__==
__lt__<
__le__<=
__gt__>
__ge__>=
Shortcut: Define __eq__ + one of the ordering methods, then decorate with @functools.total_ordering to get the rest automatically.
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
print(sorted([s1, s2], reverse=True))  # by GPA

Hashability & __hash__

  • Objects used as dict keys or in sets must be hashable.
  • If you define __eq__, Python sets __hash__ = None (unhashable) by default.
  • To restore hashability, define __hash__ explicitly — it must be consistent with __eq__.
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

Define dunder methods to make built-in operators work on your objects.

MethodOperator
__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

A decorator that auto-generates __init__, __repr__, and __eq__ from annotated fields — eliminating boilerplate.

OptionEffect
eq=True (default)Auto-generates __eq__
order=TrueAlso generates <, <=, >, >=
frozen=TrueImmutable; enables __hash__
field(default_factory=…)Safe mutable default (e.g. list)
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)          # Student(name='Alice', gpa=3.8, ...)
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 to it from one instance affects all instances.
# WRONG — all instances share one list!
class Broken:
    items = []    # class variable

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

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

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

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

Static & Class Methods

DecoratorFirst paramTypical use
noneselfRegular method on an instance
@classmethodclsAlternative constructors; access class state
@staticmethodUtility; 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 12 — Quick Reference

ConceptKey syntax / notes
Class definitionclass Name: + __init__(self, …)
Instance creationobj = Name(args)
Property@property getter; @attr.setter for validation
Inheritanceclass Child(Parent):; use super().__init__()
Abstract methodsfrom abc import ABC, abstractmethod; @abstractmethod
Ordering__eq__ + __lt__ + @total_ordering
Operator overload__add__, __mul__, __len__, …
@dataclassorder=True, frozen=True, field(default_factory=…)
Class method@classmethod + cls param; for alt constructors
Static method@staticmethod; no self or cls

End of Chapter 12

Next up: Ch13 — Functional Programming

map · filter · reduce · lambda · decorators · context managers