Chapter 9

Object-Oriented Programming

9.0 Intro · 9.1 Design & Methods · 9.2 Four Pillars · 9.3 Advanced Topics

← → or Space to navigate · F for fullscreen

Learning Goals

By the end of this chapter, you will be able to:

  1. Explain what OOP is, distinguish a class from an instance, and recognize built-in values as instances of classes
  2. Define a class with __init__, attributes, and methods, and give its objects readable __str__ and __repr__ representations
  3. Protect an object's state with naming conventions and @property
  4. Build a class hierarchy with inheritance, super(), and method overriding, and use polymorphism to call the same method on different classes
  5. Define an abstract base class with abc that requires its subclasses to implement certain methods
  6. Implement comparison and arithmetic special methods, and choose among @dataclass, namedtuple, @staticmethod, and @classmethod

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

A Calculator Class

  • CalcTwo performs operations on two numbers.
  • One class can hold several methods; each takes self.
  • self.last_result lets each instance remember its own most recent result.
  • subtract and multiply follow the same pattern as add.
c1 = CalcTwo()
c2 = CalcTwo()

print(c1.add(10, 5))     # 15
print(c2.divide(21, 3))  # 7.0
print(c1.last_result)    # 15 (independent of c2)
print(c1)                # CalcTwo(last_result=15)
class CalcTwo:
    def __init__(self):
        self.last_result = None

    def add(self, a, b):
        self.last_result = a + b
        return self.last_result

    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        self.last_result = a / b
        return self.last_result

    def __str__(self):
        return f"CalcTwo(last_result={self.last_result})"

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 with @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

Polymorphism

  • Different object types respond to the same method call in their own way.
  • Each account type implements month_end(); the caller needs no if/elif per type.
  • Python dispatches to the class-specific method at runtime.
accounts = [CheckingAccount('Alice', 1200),
            SavingsAccount('Bob', 5000)]

for account in accounts:
    account.month_end()   # polymorphic call
    print(f'{account.owner}: {account.balance:.2f}')
# Alice: 1190.00
# Bob: 5050.00

One interface, many behaviors. A LoanAccount with its own month_end() joins the same loop unchanged.

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

    def month_end(self):
        self.balance -= 10   # monthly service fee

class SavingsAccount:
    def __init__(self, owner, balance, rate=0.01):
        self.owner, self.balance = owner, balance
        self.rate = rate

    def month_end(self):
        self.balance += self.balance * self.rate

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

super() and Method Overriding

super()

Call the parent's method without naming the parent. Here it reuses BankAccount.__init__.

class SavingsAccount(BankAccount):
    def __init__(self, account_number, owner,
                 balance=0.0, rate=0.01):
        super().__init__(account_number, owner, balance)
        self.rate = rate      # child-only attribute

savings = SavingsAccount('S-1001', 'Ava', 1200.00, rate=0.02)
savings.deposit(300.00)   # inherited from BankAccount
print(savings)   # S-1001: Ava - $1500.00

Method Overriding

A child method with the same name replaces the parent's; add a rule, then call super().

class LimitedSavingsAccount(SavingsAccount):
    def __init__(self, account_number, owner, balance=0.0,
                 rate=0.01, min_balance=100.0):
        super().__init__(account_number, owner, balance, rate)
        self.min_balance = min_balance

    def withdraw(self, amount):                # override
        if self.balance - amount < self.min_balance:
            raise ValueError('Below minimum balance')
        super().withdraw(amount)               # reuse parent

lsa = LimitedSavingsAccount('S-9001', 'Nina', 500.0,
                            min_balance=200.0)
lsa.withdraw(250)
print(lsa)       # S-9001: Nina - $250.00

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

9.3 Advanced OOP Topics

Comparison dunders · Operator overloading · @dataclass · namedtuple · Class variables · 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!

namedtuple

namedtuple from collections creates a lightweight, immutable record class in one line: named fields plus tuple indexing and unpacking.

namedtuple @dataclass
Definition One line Class with annotations
Mutable? No Yes (by default)
Methods Not typical Supported

Use namedtuple for simple, read-only data records. Use _replace() to get a modified copy.

from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
p = Point(3, 4)
print(p)              # Point(x=3, y=4)
print(p.x, p[1])      # 3 4
x, y = p              # tuple unpacking

try:
    p.x = 10
except AttributeError as e:
    print(e)          # can't set attribute

print(p._replace(x=10))   # Point(x=10, y=4)

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, so 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 (none) 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=…)
namedtuple namedtuple("Point", ["x", "y"]); immutable, fields by name or index
Class method @classmethod + cls param, for alternate constructors
Static method @staticmethod, no self or cls

End of Chapter 9

Next: Chapter 10, Functional Programming

map · filter · lambda · decorators · recursion · functools