Object-Oriented Programming

Classes, objects, and OOP principles in Python.

Classes and Objects

Basic Class

class Dog:
    """A simple dog class."""

    species = "Canis familiaris"  # Class attribute (shared)

    def __init__(self, name, age):
        """Initialize instance attributes."""
        self.name = name          # Instance attribute
        self.age = age

    def bark(self):
        """Instance method."""
        return f"{self.name} says woof!"

    def description(self):
        return f"{self.name} is {self.age} years old"


# Create instances
buddy = Dog("Buddy", 5)
miles = Dog("Miles", 3)

# Access attributes
buddy.name        # "Buddy"
buddy.species     # "Canis familiaris"
Dog.species       # "Canis familiaris"

# Call methods
buddy.bark()      # "Buddy says woof!"

The init Method

Constructor - runs when creating an instance:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.area = width * height  # Computed at init

    def __init__(self, width, height=None):
        """Optional parameter - square if height not given."""
        self.width = width
        self.height = height or width

Instance vs Class Attributes

class Counter:
    count = 0           # Class attribute - shared by all instances

    def __init__(self):
        Counter.count += 1
        self.id = Counter.count  # Instance attribute

c1 = Counter()      # Counter.count = 1, c1.id = 1
c2 = Counter()      # Counter.count = 2, c2.id = 2
c3 = Counter()      # Counter.count = 3, c3.id = 3

Methods

Instance Methods

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

    def bark(self):  # self = the instance
        return f"{self.name} barks"

Class Methods

Receive the class, not instance:

class Dog:
    count = 0

    def __init__(self, name):
        self.name = name
        Dog.count += 1

    @classmethod
    def get_count(cls):
        return cls.count

    @classmethod
    def from_string(cls, dog_string):
        """Alternative constructor."""
        name, age = dog_string.split("-")
        return cls(name)

Dog.get_count()           # 0
buddy = Dog("Buddy")
Dog.get_count()           # 1
miles = Dog.from_string("Miles-3")

Static Methods

No access to instance or class:

class Math:
    @staticmethod
    def add(a, b):
        return a + b

Math.add(2, 3)    # 5

Properties

Computed attributes with getter/setter:

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

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

    @radius.setter
    def radius(self, value):
        """Setter with validation."""
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        """Read-only computed property."""
        return 3.14159 * self._radius ** 2


c = Circle(5)
c.radius          # 5 (calls getter)
c.radius = 10     # (calls setter)
c.area            # 314.159 (computed)
# c.area = 100    # Error - no setter

Inheritance

Basic Inheritance

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

    def speak(self):
        raise NotImplementedError("Subclass must implement")


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


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


buddy = Dog("Buddy")
buddy.speak()     # "Buddy says woof!"

super()

Call parent class methods:

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


class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call parent __init__
        self.breed = breed

Method Resolution Order (MRO)

class A:
    def method(self):
        return "A"

class B(A):
    def method(self):
        return "B"

class C(A):
    def method(self):
        return "C"

class D(B, C):  # Multiple inheritance
    pass

d = D()
d.method()      # "B" - B comes before C

# Check MRO
D.__mro__
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

isinstance and issubclass

buddy = Dog("Buddy")

isinstance(buddy, Dog)      # True
isinstance(buddy, Animal)   # True
isinstance(buddy, Cat)      # False

issubclass(Dog, Animal)     # True
issubclass(Dog, Cat)        # False

Dunder (Magic) Methods

Special methods with double underscores:

String Representation

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

    def __str__(self):
        """Human-readable (print, str())."""
        return f"({self.x}, {self.y})"

    def __repr__(self):
        """Developer-readable (debugging, repr())."""
        return f"Point({self.x}, {self.y})"


p = Point(3, 4)
print(p)          # (3, 4)
repr(p)           # Point(3, 4)

Comparison

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

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

    def __lt__(self, other):
        return (self.x, self.y) < (other.x, other.y)

    # Use @functools.total_ordering to auto-generate
    # __le__, __gt__, __ge__ from __eq__ and __lt__

Arithmetic

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 __rmul__(self, scalar):  # scalar * vector
        return self.__mul__(scalar)


v1 = Vector(1, 2)
v2 = Vector(3, 4)
v3 = v1 + v2      # Vector(4, 6)
v4 = v1 * 2       # Vector(2, 4)
v5 = 2 * v1       # Vector(2, 4) - uses __rmul__

Container Emulation

class MyList:
    def __init__(self):
        self._items = []

    def __len__(self):
        return len(self._items)

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

    def __setitem__(self, index, value):
        self._items[index] = value

    def __contains__(self, item):
        return item in self._items

    def __iter__(self):
        return iter(self._items)

Context Manager

class Timer:
    def __enter__(self):
        import time
        self.start = time.time()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        import time
        self.elapsed = time.time() - self.start
        print(f"Elapsed: {self.elapsed:.2f}s")
        return False  # Don't suppress exceptions


with Timer() as t:
    # do something
    pass

Callable Objects

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor


double = Multiplier(2)
double(5)         # 10
triple = Multiplier(3)
triple(5)         # 15

Dataclasses (3.7+)

Reduce boilerplate for data-holding classes:

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float
    label: str = "origin"  # Default value

# Automatically generates:
# __init__, __repr__, __eq__

p = Point(3, 4)
print(p)          # Point(x=3, y=4, label='origin')


@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)  # Computed, not in __init__

    def __post_init__(self):
        self.area = self.width * self.height


@dataclass(frozen=True)  # Immutable
class FrozenPoint:
    x: float
    y: float

Dataclass Options

@dataclass(
    init=True,        # Generate __init__
    repr=True,        # Generate __repr__
    eq=True,          # Generate __eq__
    order=False,      # Generate comparison methods
    frozen=False,     # Make immutable
    slots=False,      # Use __slots__ (3.10+)
)
class MyClass:
    pass

Encapsulation

Convention-Based Privacy

class Account:
    def __init__(self, balance):
        self._balance = balance      # "Protected" - single underscore
        self.__secret = "hidden"     # "Private" - name mangled

    def get_balance(self):
        return self._balance


a = Account(100)
a._balance          # Works (but discouraged)
a.__secret          # AttributeError
a._Account__secret  # Works (name mangling)

Using Properties for Encapsulation

class BankAccount:
    def __init__(self, initial_balance=0):
        self._balance = initial_balance
        self._transactions = []

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

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount
        self._transactions.append(f"+{amount}")

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        self._transactions.append(f"-{amount}")

Abstract Base Classes

Define interfaces:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        """Calculate area."""
        pass

    @abstractmethod
    def perimeter(self):
        """Calculate perimeter."""
        pass


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)


# shape = Shape()  # Error - can't instantiate ABC
rect = Rectangle(3, 4)  # OK

Composition vs Inheritance

Prefer composition ("has-a") over inheritance ("is-a"):

# Inheritance - tight coupling
class Dog(Animal):
    pass

# Composition - loose coupling, more flexible
class Dog:
    def __init__(self, name):
        self.name = name
        self.legs = Legs(4)
        self.voice = Voice("bark")

    def speak(self):
        return self.voice.make_sound()

Practice

# 1. Basic class with properties
@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0

    @property
    def total_value(self):
        return self.price * self.quantity


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

    def get_pay(self):
        return self.salary


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

    def get_pay(self):
        return self.salary + self.bonus


# 3. Context manager
class DatabaseConnection:
    def __init__(self, db_name):
        self.db_name = db_name

    def __enter__(self):
        print(f"Connecting to {self.db_name}")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Closing connection to {self.db_name}")
        return False

with DatabaseConnection("mydb") as conn:
    print("Doing database stuff")