Object-Oriented Programming

Module Objectives

By completing this module you will be able to:

  • Create classes and objects with attributes and methods
  • Use inheritance to reuse and extend code
  • Apply encapsulation to protect data
  • Implement polymorphism for flexible code
  • Use dataclasses to create data classes easily

Why Object-Oriented Programming?

So far we’ve organized our code with functions: small machines that receive data, process it, and return results. This works great, but imagine you’re building a video game. You have players, enemies, items, levels… Each one has its own data (health, position, name) and its own behaviors (move, attack, collect items).

With just functions, you’d end up with a bunch of scattered dictionaries and functions that have no clear relationship to each other. Object-Oriented Programming (OOP) allows you to group data and behaviors into a single unit called an object.

The architect’s metaphor

Think of a class as the blueprint of a house. The blueprint defines how many rooms it will have, where the kitchen will be, the size of the garden… But the blueprint is not a house, it’s just the specification.

When you build actual houses following that blueprint, each house is an object (or instance). They all follow the same design, but each one has its own address, paint color, and furniture.

Classes and objects: the mold and the cookies

A class is like a cookie cutter. It defines the shape that all cookies will have. Each object is an individual cookie made with that cutter: they all have the same shape, but each one can have different color or decoration.

Your first class

 1class Dog:
 2    """Represents a dog with name and age."""
 3
 4    # Class attribute (shared by all dogs)
 5    species = "Canis lupus familiaris"
 6
 7    def __init__(self, name, age):
 8        """Initialize a new dog."""
 9        # Instance attributes (unique to each dog)
10        self.name = name
11        self.age = age
12
13    def bark(self):
14        """The dog barks."""
15        return f"{self.name} says: Woof!"
16
17    def have_birthday(self):
18        """The dog has another birthday."""
19        self.age += 1
20        return f"Happy birthday {self.name}! You are now {self.age} years old."

Creating objects (instances)

 1# We create two dogs using the class as a mold
 2my_dog = Dog("Max", 3)
 3your_dog = Dog("Luna", 5)
 4
 5# Each dog has its own attributes
 6print(my_dog.name)  # Max
 7print(your_dog.name)  # Luna
 8
 9# But they share the class attribute
10print(my_dog.species)  # Canis lupus familiaris
11print(your_dog.species)  # Canis lupus familiaris
12
13# Each dog can execute its methods
14print(my_dog.bark())  # Max says: Woof!
15print(your_dog.have_birthday())  # Happy birthday Luna! You are now 6 years old.
What is self?

self is a reference to the current object. When you call my_dog.bark(), Python automatically passes my_dog as the first argument (self). That’s why methods always have self as the first parameter: they need to know which specific object they’re working with.

Class attributes vs instance attributes

The apartment building metaphor

Imagine an apartment building. The building name, address, and common areas (pool, gym) are shared by all residents: these are class attributes. But each apartment has its own door number, decoration, and tenants: these are instance attributes.

If they change the building’s name, it affects everyone. If a neighbor paints their living room blue, only their apartment changes.

They are defined outside of __init__, directly in the class body. They are shared by all instances.

 1class Car:
 2    # Class attributes (shared)
 3    wheels = 4
 4    manufactured = 0
 5
 6    def __init__(self, brand, color):
 7        self.brand = brand    # Instance attribute
 8        self.color = color    # Instance attribute
 9        Car.manufactured += 1  # Increment class counter
10
11# All cars have 4 wheels
12c1 = Car("Toyota", "red")
13c2 = Car("Ford", "blue")
14
15print(c1.wheels)          # 4
16print(c2.wheels)          # 4
17print(Car.wheels)         # 4 (access from class)
18print(Car.manufactured)   # 2 (shared counter)

Common uses:

  • Counters of created instances
  • Constant values (like wheels = 4)
  • Shared default configuration

They are defined inside __init__ using self.attribute. They are unique to each object.

 1class Pet:
 2    species = "Unknown"  # Class attribute
 3
 4    def __init__(self, name, age):
 5        self.name = name  # Instance attribute
 6        self.age = age    # Instance attribute
 7
 8# Each pet has its own name and age
 9cat = Pet("Whiskers", 3)
10dog = Pet("Max", 5)
11
12cat.name = "Fluffy"  # Only changes this cat
13print(cat.name)      # Fluffy
14print(dog.name)      # Max (unchanged)
15
16# But if we change the class attribute...
17Pet.species = "Domestic animal"
18print(cat.species)   # Domestic animal
19print(dog.species)   # Domestic animal (both changed!)

Common uses:

  • Unique data for each object (name, id, state)
  • Any value that varies between instances
Feature Class attribute Instance attribute
Where defined Outside init Inside init with self.
Shared Yes, among all instances No, unique per object
Access Class.attribute or object.attribute Only object.attribute
Modification Affects all instances Only affects that object
Typical example Counters, constants Name, age, state
Beware of lists as class attributes

If you use a list as a class attribute, all instances share the same list:

 1class Team:
 2    members = []  # Danger! Shared list
 3
 4    def add(self, name):
 5        self.members.append(name)
 6
 7t1 = Team()
 8t2 = Team()
 9t1.add("Ana")
10print(t2.members)  # ['Ana'] - Also appears in t2!

Solution: Initialize lists in __init__:

1class Team:
2    def __init__(self):
3        self.members = []  # Each team has its own list

Methods: the object’s abilities

An object is like an employee who has a resume (their attributes: name, experience, skills) and can perform tasks (their methods: work, introduce themselves, calculate their salary).

Types of methods

They work with the specific object’s data. They receive self.

 1class Calculator:
 2    def __init__(self, initial_value=0):
 3        self.value = initial_value
 4
 5    def add(self, n):
 6        self.value += n
 7        return self  # Allows chaining
 8
 9    def subtract(self, n):
10        self.value -= n
11        return self
12
13calc = Calculator(10)
14calc.add(5).subtract(3)
15print(calc.value)  # 12

They don’t need access to the object or the class. They’re normal functions that live inside the class for organization.

 1class Math:
 2    @staticmethod
 3    def is_even(n):
 4        return n % 2 == 0
 5
 6    @staticmethod
 7    def is_prime(n):
 8        if n < 2:
 9            return False
10        for i in range(2, int(n**0.5) + 1):
11            if n % i == 0:
12                return False
13        return True
14
15# Called without creating an instance
16print(Math.is_even(4))    # True
17print(Math.is_prime(7))   # True

They work with the class itself, not instances. They receive cls (the class). Useful for creating instances in alternative ways.

 1class Date:
 2    def __init__(self, day, month, year):
 3        self.day = day
 4        self.month = month
 5        self.year = year
 6
 7    @classmethod
 8    def from_string(cls, date_str):
 9        """Create a Date from a string 'dd-mm-yyyy'."""
10        day, month, year = map(int, date_str.split("-"))
11        return cls(day, month, year)
12
13    @classmethod
14    def today(cls):
15        """Create a Date with today's date."""
16        from datetime import date
17        today = date.today()
18        return cls(today.day, today.month, today.year)
19
20# Different ways to create a Date
21d1 = Date(15, 6, 2024)
22d2 = Date.from_string("25-12-2024")
23d3 = Date.today()

Encapsulation: the safe box

Imagine an ATM. You interact with a screen and buttons (the public interface), but you don’t have direct access to the money or internal mechanisms (the private details). This is encapsulation: hiding implementation details and exposing only what’s necessary.

Privacy conventions in Python

Python doesn’t have true privacy like other languages, but it uses conventions:

 1class BankAccount:
 2    def __init__(self, holder, initial_balance):
 3        self.holder = holder            # Public: free access
 4        self._balance = initial_balance # "Private": don't touch from outside
 5        self.__pin = "1234"             # "Very private": name mangling
 6
 7    def deposit(self, amount):
 8        if amount > 0:
 9            self._balance += amount
10            return True
11        return False
12
13    def withdraw(self, amount):
14        if 0 < amount <= self._balance:
15            self._balance -= amount
16            return True
17        return False
18
19account = BankAccount("Ana", 1000)
20
21# Public: normal access
22print(account.holder)  # Ana
23
24# "Private": technically accessible, but you shouldn't
25print(account._balance)  # 1000 (works, but bad practice)
26
27# "Very private": Python renames it internally
28# print(account.__pin)  # Error: AttributeError
29print(account._BankAccount__pin)  # 1234 (name mangling)
Convention, not enforcement

In Python, _attribute is a signal that it’s internal. __attribute makes Python rename it to _Class__attribute (name mangling), making accidental access harder. But remember: “We’re all consenting adults here” is Python’s philosophy.

Properties: elegant getters and setters

Instead of directly accessing attributes, you can use properties to control how they’re read and written:

 1class Temperature:
 2    def __init__(self, celsius):
 3        self._celsius = celsius
 4
 5    @property
 6    def celsius(self):
 7        """Getter: runs when reading temperature.celsius"""
 8        return self._celsius
 9
10    @celsius.setter
11    def celsius(self, value):
12        """Setter: runs when assigning temperature.celsius = value"""
13        if value < -273.15:
14            raise ValueError("Temperature below absolute zero doesn't exist!")
15        self._celsius = value
16
17    @property
18    def fahrenheit(self):
19        """Computed property (read-only)."""
20        return self._celsius * 9/5 + 32
21
22temp = Temperature(25)
23print(temp.celsius)     # 25 (uses getter)
24print(temp.fahrenheit)  # 77.0
25
26temp.celsius = 30       # Uses setter
27print(temp.celsius)     # 30
28
29# temp.celsius = -300   # ValueError: Temperature below absolute zero doesn't exist!

Inheritance: the family tree

A child inherits characteristics from their parents, but can have their own. In OOP, a child class inherits attributes and methods from a parent class, and can add or modify behaviors.

 1class Animal:
 2    """Base class for all animals."""
 3
 4    def __init__(self, name, age):
 5        self.name = name
 6        self.age = age
 7
 8    def introduce(self):
 9        return f"I'm {self.name} and I'm {self.age} years old."
10
11    def make_sound(self):
12        raise NotImplementedError("Subclasses must implement this method")
13
14
15class Dog(Animal):
16    """A dog is an animal with a breed."""
17
18    def __init__(self, name, age, breed):
19        super().__init__(name, age)  # Call parent's __init__
20        self.breed = breed
21
22    def make_sound(self):
23        return "Woof!"
24
25    def fetch_ball(self):
26        return f"{self.name} is fetching the ball."
27
28
29class Cat(Animal):
30    """A cat is an animal with fur color."""
31
32    def __init__(self, name, age, color):
33        super().__init__(name, age)
34        self.color = color
35
36    def make_sound(self):
37        return "Meow!"
38
39    def purr(self):
40        return f"{self.name} is purring..."
41
42
43# Usage
44max_dog = Dog("Max", 3, "Labrador")
45mishi = Cat("Mishi", 2, "orange")
46
47print(max_dog.introduce())   # I'm Max and I'm 3 years old. (inherited)
48print(max_dog.make_sound())  # Woof! (overridden)
49print(max_dog.fetch_ball())  # Max is fetching the ball. (new)
50
51print(mishi.introduce())     # I'm Mishi and I'm 2 years old. (inherited)
52print(mishi.make_sound())    # Meow! (overridden)
53print(mishi.purr())          # Mishi is purring... (new)
super() is your friend

super() lets you call methods from the parent class. It’s especially useful in __init__ to avoid duplicating initialization of inherited attributes.

Checking inheritance

1# isinstance() checks if an object is of a class (or its parents)
2print(isinstance(max_dog, Dog))     # True
3print(isinstance(max_dog, Animal))  # True
4print(isinstance(max_dog, Cat))     # False
5
6# issubclass() checks relationship between classes
7print(issubclass(Dog, Animal))      # True
8print(issubclass(Cat, Dog))         # False

Polymorphism: same message, different response

Polymorphism means “many forms”. Imagine a universal remote control: the “power” button works with any TV, but each brand implements it differently internally.

In Python, this is achieved naturally thanks to duck typing: “If it walks like a duck and quacks like a duck, then it’s a duck”.

 1class Shape:
 2    """Base class for geometric shapes."""
 3
 4    def area(self):
 5        raise NotImplementedError
 6
 7    def perimeter(self):
 8        raise NotImplementedError
 9
10
11class Rectangle(Shape):
12    def __init__(self, width, height):
13        self.width = width
14        self.height = height
15
16    def area(self):
17        return self.width * self.height
18
19    def perimeter(self):
20        return 2 * (self.width + self.height)
21
22
23class Circle(Shape):
24    def __init__(self, radius):
25        self.radius = radius
26
27    def area(self):
28        return 3.14159 * self.radius ** 2
29
30    def perimeter(self):
31        return 2 * 3.14159 * self.radius
32
33
34class Triangle(Shape):
35    def __init__(self, base, height, side1, side2, side3):
36        self.base = base
37        self.height = height
38        self.sides = (side1, side2, side3)
39
40    def area(self):
41        return (self.base * self.height) / 2
42
43    def perimeter(self):
44        return sum(self.sides)
45
46
47# Polymorphism in action
48shapes = [
49    Rectangle(4, 5),
50    Circle(3),
51    Triangle(6, 4, 5, 5, 6)
52]
53
54for shape in shapes:
55    # Same code, different result depending on shape type
56    print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}")
57
58# Output:
59# Area: 20.00, Perimeter: 18.00
60# Area: 28.27, Perimeter: 18.85
61# Area: 12.00, Perimeter: 16.00

Special methods: Python’s magic

Special methods (or “dunder methods”, for the double underscores) are like translators that allow your objects to “speak” with the rest of Python. When Python needs to print an object, compare it, or add it, it looks for these methods.

The most common ones

 1class Vector:
 2    """A 2D vector with mathematical operations."""
 3
 4    def __init__(self, x, y):
 5        self.x = x
 6        self.y = y
 7
 8    def __repr__(self):
 9        """Technical representation (for developers)."""
10        return f"Vector({self.x}, {self.y})"
11
12    def __str__(self):
13        """Friendly representation (for users)."""
14        return f"({self.x}, {self.y})"
15
16    def __eq__(self, other):
17        """Equality comparison: v1 == v2"""
18        return self.x == other.x and self.y == other.y
19
20    def __add__(self, other):
21        """Vector addition: v1 + v2"""
22        return Vector(self.x + other.x, self.y + other.y)
23
24    def __sub__(self, other):
25        """Vector subtraction: v1 - v2"""
26        return Vector(self.x - other.x, self.y - other.y)
27
28    def __mul__(self, scalar):
29        """Scalar multiplication: v * 3"""
30        return Vector(self.x * scalar, self.y * scalar)
31
32    def __len__(self):
33        """Length (magnitude) of the vector: len(v)"""
34        return int((self.x**2 + self.y**2)**0.5)
35
36    def __bool__(self):
37        """Boolean value: if v:"""
38        return self.x != 0 or self.y != 0
39
40
41# Natural usage thanks to special methods
42v1 = Vector(3, 4)
43v2 = Vector(1, 2)
44
45print(v1)           # (3, 4) - uses __str__
46print(repr(v1))     # Vector(3, 4) - uses __repr__
47print(v1 == v2)     # False - uses __eq__
48print(v1 + v2)      # (4, 6) - uses __add__
49print(v1 * 2)       # (6, 8) - uses __mul__
50print(len(v1))      # 5 - uses __len__
51
52if v1:              # uses __bool__
53    print("v1 is not the zero vector")

Table of useful special methods

Method Operation Example
init Constructor obj = Class()
str String conversion str(obj), print(obj)
repr Technical representation repr(obj)
eq Equality obj1 == obj2
lt, gt Comparison obj1 < obj2, obj1 > obj2
add, sub Arithmetic obj1 + obj2, obj1 - obj2
len Length len(obj)
getitem Indexing obj[key]
iter Iteration for x in obj:
call Calling obj()
contains Membership x in obj

Dataclasses: OOP without bureaucracy

Creating a class with __init__, __repr__, and __eq__ can be tedious for classes that mainly store data. Dataclasses (Python 3.7+) generate all this automatically.

The form metaphor

Imagine you have to fill out a form with name, address, phone… Normally you’d write everything by hand. Dataclasses are like a pre-filled form where you only specify the fields and Python does the rest.

Traditional class vs dataclass

 1class TraditionalProduct:
 2    def __init__(self, name, price, quantity=0):
 3        self.name = name
 4        self.price = price
 5        self.quantity = quantity
 6
 7    def __repr__(self):
 8        return f"Product(name={self.name!r}, price={self.price}, quantity={self.quantity})"
 9
10    def __eq__(self, other):
11        return (self.name == other.name and
12                self.price == other.price and
13                self.quantity == other.quantity)
 1from dataclasses import dataclass
 2
 3@dataclass
 4class Product:
 5    name: str
 6    price: float
 7    quantity: int = 0
 8
 9    def total(self):
10        return self.price * self.quantity

Advanced features

 1from dataclasses import dataclass, field
 2from typing import List
 3
 4@dataclass
 5class Order:
 6    customer: str
 7    products: List[str] = field(default_factory=list)  # Mutable list
 8    order_id: int = field(default=0, repr=False)       # Not shown in repr
 9
10    def add_product(self, product):
11        self.products.append(product)
12
13@dataclass(frozen=True)  # Immutable (can't be modified after creation)
14class Coordinate:
15    x: float
16    y: float
17
18# Usage
19order = Order("Ana")
20order.add_product("Laptop")
21order.add_product("Mouse")
22print(order)  # Order(customer='Ana', products=['Laptop', 'Mouse'])
23
24coord = Coordinate(10.5, 20.3)
25# coord.x = 15  # Error: FrozenInstanceError (immutable)

When to use each approach?

Situation Recommendation
Simple data without behavior Dictionary or namedtuple
Data with simple validation @dataclass
Data with complex behavior Full class
Multiple variants of something similar Inheritance
Pure functions without state Regular functions
State + related behavior Class with methods

Practical Exercises

Exercise 1: Inventory system

Create a Product class with name, price, and stock. Implement methods to:

  • Sell products (reduce stock)
  • Restock products (increase stock)
  • Calculate total stock value

Then create an Inventory class that manages a collection of products with methods to:

  • Add products
  • Search by name
  • List products with low stock (< 5 units)
 1from dataclasses import dataclass, field
 2from typing import List, Optional
 3
 4@dataclass
 5class Product:
 6    name: str
 7    price: float
 8    stock: int = 0
 9
10    def sell(self, quantity: int) -> bool:
11        """Sells products if there's enough stock."""
12        if quantity <= self.stock:
13            self.stock -= quantity
14            return True
15        return False
16
17    def restock(self, quantity: int) -> None:
18        """Restocks inventory."""
19        self.stock += quantity
20
21    def total_value(self) -> float:
22        """Calculates total stock value."""
23        return self.price * self.stock
24
25
26class Inventory:
27    def __init__(self):
28        self._products: List[Product] = []
29
30    def add(self, product: Product) -> None:
31        """Adds a product to inventory."""
32        self._products.append(product)
33
34    def search(self, name: str) -> Optional[Product]:
35        """Searches for a product by name."""
36        for product in self._products:
37            if product.name.lower() == name.lower():
38                return product
39        return None
40
41    def low_stock(self, threshold: int = 5) -> List[Product]:
42        """Lists products with low stock."""
43        return [p for p in self._products if p.stock < threshold]
44
45    def inventory_value(self) -> float:
46        """Calculates total inventory value."""
47        return sum(p.total_value() for p in self._products)
48
49
50# Test
51inventory = Inventory()
52inventory.add(Product("Laptop", 999.99, 10))
53inventory.add(Product("Mouse", 29.99, 3))
54inventory.add(Product("Keyboard", 79.99, 15))
55
56laptop = inventory.search("laptop")
57if laptop:
58    laptop.sell(2)
59    print(f"Sold 2 laptops. Remaining stock: {laptop.stock}")
60
61print("Products with low stock:")
62for p in inventory.low_stock():
63    print(f"  - {p.name}: {p.stock} units")
64
65print(f"Total inventory value: ${inventory.inventory_value():.2f}")
Exercise 2: Employee hierarchy

Create a base Employee class with name and base salary. Then create subclasses:

  • Developer: has a main programming language and 20% bonus
  • Manager: has a team of employees and 30% bonus
  • Director: inherits from Manager with 50% bonus

Each class should have a calculate_salary() method that returns the salary with bonus.

 1from dataclasses import dataclass, field
 2from typing import List
 3
 4@dataclass
 5class Employee:
 6    name: str
 7    base_salary: float
 8
 9    def calculate_salary(self) -> float:
10        """Calculates total salary."""
11        return self.base_salary
12
13    def __str__(self):
14        return f"{self.name} - Salary: ${self.calculate_salary():.2f}"
15
16
17@dataclass
18class Developer(Employee):
19    language: str = "Python"
20
21    def calculate_salary(self) -> float:
22        """Developers get 20% bonus."""
23        return self.base_salary * 1.20
24
25
26@dataclass
27class Manager(Employee):
28    team: List[Employee] = field(default_factory=list)
29
30    def calculate_salary(self) -> float:
31        """Managers get 30% bonus."""
32        return self.base_salary * 1.30
33
34    def add_employee(self, employee: Employee):
35        self.team.append(employee)
36
37    def list_team(self):
38        print(f"{self.name}'s team:")
39        for emp in self.team:
40            print(f"  - {emp}")
41
42
43@dataclass
44class Director(Manager):
45    def calculate_salary(self) -> float:
46        """Directors get 50% bonus."""
47        return self.base_salary * 1.50
48
49
50# Test
51dev1 = Developer("Ana", 50000, "Python")
52dev2 = Developer("Luis", 55000, "JavaScript")
53manager = Manager("Carlos", 70000)
54director = Director("Maria", 100000)
55
56manager.add_employee(dev1)
57manager.add_employee(dev2)
58director.add_employee(manager)
59
60print(dev1)     # Ana - Salary: $60000.00
61print(dev2)     # Luis - Salary: $66000.00
62print(manager)  # Carlos - Salary: $91000.00
63print(director) # Maria - Salary: $150000.00
64
65manager.list_team()
Exercise 3: Shapes with special methods

Create a Rectangle class that implements:

  • __init__ with width and height
  • __str__ for readable representation
  • __eq__ to compare two rectangles
  • __add__ to “add” rectangles (creates a new one with summed areas, as a square)
  • __lt__ and __gt__ to compare by area
  • area and perimeter properties
 1import math
 2
 3class Rectangle:
 4    def __init__(self, width: float, height: float):
 5        self.width = width
 6        self.height = height
 7
 8    @property
 9    def area(self) -> float:
10        return self.width * self.height
11
12    @property
13    def perimeter(self) -> float:
14        return 2 * (self.width + self.height)
15
16    def __str__(self):
17        return f"Rectangle({self.width}x{self.height})"
18
19    def __repr__(self):
20        return f"Rectangle({self.width}, {self.height})"
21
22    def __eq__(self, other):
23        if not isinstance(other, Rectangle):
24            return False
25        return self.width == other.width and self.height == other.height
26
27    def __lt__(self, other):
28        return self.area < other.area
29
30    def __gt__(self, other):
31        return self.area > other.area
32
33    def __add__(self, other):
34        """Adds areas and creates an equivalent square."""
35        new_area = self.area + other.area
36        side = math.sqrt(new_area)
37        return Rectangle(side, side)
38
39
40# Test
41r1 = Rectangle(4, 5)
42r2 = Rectangle(3, 3)
43r3 = Rectangle(4, 5)
44
45print(r1)              # Rectangle(4x5)
46print(f"Area: {r1.area}, Perimeter: {r1.perimeter}")
47
48print(r1 == r2)        # False
49print(r1 == r3)        # True (same dimensions)
50
51print(r1 > r2)         # True (20 > 9)
52print(r1 < r2)         # False
53
54r_sum = r1 + r2
55print(f"Sum: {r_sum} with area {r_sum.area:.2f}")  # area = 29

Quiz

🎮 Quiz: Object-Oriented Programming

0 / 0
Loading questions...

Previous: Advanced Functions Next: Optimization