OOP Foundations in Python

This notebook takes you from "what is an object?" to building a small food-delivery ordering system with classes. Every idea comes with code you can run, so keep the notebook open and run each cell as you read.

Learning Objectives

By the end of this notebook you will be able to:

  1. Explain object identity, mutability and references in Python
  2. Write classes with __init__, attributes and methods
  3. Protect an object's data with properties and validation
  4. Use inheritance and polymorphism without breaking the Liskov Substitution Principle
  5. Decide when composition is a better fit than inheritance
  6. Write data-focused classes quickly with @dataclass
  7. Implement the dunder methods you'll actually use (__len__, __iter__, __eq__, ...)
  8. Combine all of the above in a small real-world project

Prerequisites

  • Python 3.10 or newer
  • Comfortable with variables, loops and functions
  • Comfortable with lists and dictionaries

How to Use This Notebook

  • Run the cells from top to bottom. Later cells reuse classes defined earlier.
  • Try it cells are for you. Write your answer first, then open Show solution to compare.
  • Each section ends with a Quick recap. Read only the recaps when revising before an exam.

Why Object-Oriented Programming? Real-World Examples

Before writing any code, let's look at apps you already use. OOP models the real world by representing things as objects with attributes (data) and behaviors (actions).


Example 1: A University System

Think about how a university works:

Real-World Entity Class Attributes (Data) Behaviors (Methods)
A student Student name, student_id, gpa, enrolled_courses enroll(), drop_course(), calculate_gpa()
A professor Professor name, employee_id, department, courses_teaching assign_grade(), publish_research()
A course Course code, title, credits, students_enrolled add_student(), remove_student(), get_roster()

Each kind of thing becomes a class. Each specific thing (the student "Alice", the course "CS101") is an object made from that class.


Example 2: Food Delivery App (Foodmandu, Pathao Food)

Real-World Entity Class Attributes Behaviors
A customer Customer name, phone, address, payment_method place_order(), track_order(), rate_restaurant()
A restaurant Restaurant name, location, menu_items, rating accept_order(), update_menu(), close_for_day()
An order Order items, total_price, status, delivery_time calculate_total(), apply_discount(), update_status()
A delivery rider Rider name, vehicle, current_location, orders_delivered accept_delivery(), mark_delivered(), navigate_to()

Notice that an Order contains menu items and belongs to a Customer. Objects holding other objects is called composition.


Example 3: Banking System (Your Bank App)

Real-World Entity Class Attributes Behaviors
Account BankAccount account_number, balance, owner, type deposit(), withdraw(), transfer(), get_statement()
Savings Account SavingsAccount (inherits from BankAccount) + interest_rate calculate_interest(), apply_interest()
Customer Customer name, accounts[], contact_info open_account(), close_account()
Transaction Transaction amount, date, type, from_account, to_account execute(), reverse()

A SavingsAccount can do everything a BankAccount can, plus earn interest. Building a new class on top of an existing one is called inheritance.


Example 4: Ride-Sharing App (Pathao, inDrive)

Concept Real-World Example OOP Principle
Class The concept of "a rider" Blueprint/template
Object You, specifically, as a rider Instance of the class
Attribute Your name, phone number, rating Data stored in object
Method Request a ride, cancel ride, rate driver Actions the object can do
Inheritance BikeRide and CarRide are both types of Ride Child classes from parent
Encapsulation Your payment details are hidden, you just tap "Pay" Hiding internal details
Polymorphism "Calculate fare" works differently for Bike vs Car Same method, different behavior

The Four Pillars of OOP (Preview)

Pillar What It Means Real-World Analogy
Encapsulation Bundle data + methods together; hide internal details A TV remote: you press buttons without knowing the electronics inside
Abstraction Show only essential features, hide complexity A car dashboard: you see speed, not engine internals
Inheritance Create new classes based on existing ones A "Mountain Bike" is a type of "Bicycle" which is a type of "Vehicle"
Polymorphism Same interface, different implementations "Make sound" → Dog barks, Cat meows, Bird chirps

Quick Mental Exercise

Before we code, think about these:

  1. Your phone: What are its attributes? What methods does it have?
  2. A playlist on Spotify: What data does it store? What can you do with it?
  3. An airline booking: What objects are involved? How do they relate?

Think pair share: Turn to your neighbor and describe one real-world system as classes with attributes and methods.


From the Real World to Code

Whenever you design a program with classes, ask these four questions:

  1. What things exist? Those become classes.
  2. What does each thing know? Those become attributes.
  3. What can each thing do? Those become methods.
  4. How are the things connected? That tells you whether to use composition or inheritance.

Keep these questions in mind; we'll answer them again in the mini-project at the end.


1. Quick Warm-Up: Object Identity, Mutability & References

Everything in Python is an object: numbers, strings, lists, functions, even classes. Before writing our own classes, we need to be clear about how Python stores objects and what a variable really is.

A variable is a name that points to an object. Two names can point to the same object.

# Object Identity: Every object has a unique ID
a = [1, 2, 3]
b = a           # b references the SAME object as a
c = [1, 2, 3]   # c is a NEW object with same content

print(f"a = {a}")
print(f"b = {b}")
print(f"c = {c}")
print()
print(f"id(a) = {id(a)}")
print(f"id(b) = {id(b)}")
print(f"id(c) = {id(c)}")
print()
print(f"a is b: {a is b}")   # True - same object
print(f"a is c: {a is c}")   # False - different objects
print(f"a == c: {a == c}")   # True - equal values
a = [1, 2, 3]
b = [1, 2, 3]
c = [1, 2, 3]

id(a) = 4459232064
id(b) = 4459232064
id(c) = 4459126144

a is b: True
a is c: False
a == c: True

a and b are two names for one list, so they have the same id. c looks the same but is a separate list. That's the difference between is (same object?) and == (same value?).

# Mutability: Lists are mutable, strings/tuples are not
my_list = [1, 2, 3]
original_id = id(my_list)

my_list.append(4)  # Modifies in place
print(f"After append: {my_list}")
print(f"Same object? {id(my_list) == original_id}")  # True!

# Contrast with strings (immutable)
my_string = "hello"
string_id = id(my_string)
my_string = my_string + " world"  # Creates NEW object
print(f"\nAfter concatenation: {my_string}")
print(f"Same object? {id(my_string) == string_id}")  # False!
After append: [1, 2, 3, 4]
Same object? True

After concatenation: hello world
Same object? False

A list can be changed in place, so it keeps its identity. A string can't be changed, so + builds a brand-new string and the name now points to that.

This matters for functions too. Default argument values are created once, when the function is defined, so a mutable default gets shared between calls:

# Reference traps: Mutable default arguments
def add_item_bad(item, items=[]):
    """BAD: Default list is shared across calls!"""
    items.append(item)
    return items

def add_item_good(item, items=None):
    """GOOD: Create new list if not provided."""
    if items is None:
        items = []
    items.append(item)
    return items

# Watch the difference
print("Bad function:")
print(add_item_bad("a"))  # ['a']
print(add_item_bad("b"))  # ['a', 'b'] - Oops!

print("\nGood function:")
print(add_item_good("a"))  # ['a']
print(add_item_good("b"))  # ['b'] - Correct!
Bad function:
['a']
['a', 'b']

Good function:
['a']
['b']

Try It: Predict the Output

Write down what you think will be printed, then run the cell to check.

# Try to predict before running!
x = [1, 2, 3]
y = x
x.append(4)
y = y + [5]

print(f"x = {x}")
print(f"y = {y}")
print(f"x is y: {x is y}")

# Why are they different?
# Hint: x.append() modifies in place, y + [5] creates a new list
x = [1, 2, 3, 4]
y = [1, 2, 3, 4, 5]
x is y: False

Quick recap

  • A variable is a name pointing to an object. is checks for the same object; == checks for equal values.
  • Lists, dicts and sets are mutable (change in place). Strings, numbers and tuples are immutable.
  • Never use [] or {} as a default argument. Use None and create the object inside the function.

2. Classes & Objects

A class is a blueprint. An object (also called an instance) is one thing built from that blueprint. One Student class, many student objects.

2.1 The __init__ Method and Instance Attributes

When you write Student("Alice", ...), Python creates an empty object and immediately calls __init__ on it. self is that new object, so self.name = name stores the name on that particular student.

class Student:
    """Represents a student enrolled in a course."""

    def __init__(self, name: str, student_id: str, gpa: float = 0.0):
        """
        Initialize a new Student instance.

        Args:
            name: Student's full name
            student_id: Unique identifier
            gpa: Grade point average (default 0.0)
        """
        self.name = name              # Instance attribute
        self.student_id = student_id  # Instance attribute
        self.gpa = gpa               # Instance attribute with default

    def is_honor_roll(self) -> bool:
        """Check if student qualifies for honor roll."""
        return self.gpa >= 3.5

# Creating instances
alice = Student("Alice Johnson", "STU001", 3.8)
bob = Student("Bob Smith", "STU002", 2.9)

print(f"{alice.name} (GPA: {alice.gpa})")
print(f"Honor roll? {alice.is_honor_roll()}")
print()
print(f"{bob.name} (GPA: {bob.gpa})")
print(f"Honor roll? {bob.is_honor_roll()}")
Alice Johnson (GPA: 3.8)
Honor roll? True

Bob Smith (GPA: 2.9)
Honor roll? False

2.2 Class Attributes vs Instance Attributes

  • Instance attributes (set on self) belong to one object. Alice's balance is not Bob's balance.
  • Class attributes (written directly in the class body) are shared by every object of that class.
class BankAccount:
    """A simple bank account with class-level tracking."""

    # Class attributes: shared by ALL instances
    bank_name = "Python National Bank"
    total_accounts = 0

    def __init__(self, owner: str, balance: float = 0.0):
        # Instance attributes: unique to each instance
        self.owner = owner
        self.balance = balance
        self.account_number = BankAccount.total_accounts + 1

        # Increment class attribute
        BankAccount.total_accounts += 1

    def deposit(self, amount: float) -> None:
        """Add money to the account."""
        if amount > 0:
            self.balance += amount

# Create accounts
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob", 500)

print(f"Bank: {BankAccount.bank_name}")  # Access via class
print(f"Total accounts: {BankAccount.total_accounts}")
print()
print(f"Account {acc1.account_number}: {acc1.owner} - ${acc1.balance}")
print(f"Account {acc2.account_number}: {acc2.owner} - ${acc2.balance}")

# Both instances share the same class attribute
assert acc1.bank_name == acc2.bank_name == BankAccount.bank_name
print("\n✓ Class attribute shared correctly")
Bank: Python National Bank
Total accounts: 2

Account 1: Alice - $1000
Account 2: Bob - $500

✓ Class attribute shared correctly

2.3 Methods: Instance, Class, and Static

Python classes can have three kinds of methods:

Kind First parameter Use it when...
Instance method self the method needs this object's data (most methods)
Class method (@classmethod) cls the method works with the class itself, e.g. an alternative way to create objects
Static method (@staticmethod) none it's a helper that belongs with the class but needs no object or class data
import datetime

class Employee:
    """Demonstrates different method types."""

    company = "Tech Corp"
    employee_count = 0

    def __init__(self, name: str, hire_year: int):
        self.name = name
        self.hire_year = hire_year
        Employee.employee_count += 1

    # Instance method: operates on self
    def years_employed(self) -> int:
        """Calculate years with company."""
        return datetime.date.today().year - self.hire_year

    # Class method: operates on class, not instance
    @classmethod
    def get_company_info(cls) -> str:
        """Return company information."""
        return f"{cls.company} - {cls.employee_count} employees"

    # Alternative constructor using classmethod
    @classmethod
    def from_string(cls, data: str) -> "Employee":
        """Create employee from 'name,year' string."""
        name, year = data.split(",")
        return cls(name.strip(), int(year.strip()))

    # Static method: utility function, no self or cls
    @staticmethod
    def is_valid_year(year: int) -> bool:
        """Check if hire year is reasonable."""
        current_year = datetime.date.today().year
        return 1990 <= year <= current_year

# Using instance method
emp1 = Employee("Alice", 2020)
print(f"{emp1.name} - {emp1.years_employed()} years employed")

# Using class method
print(Employee.get_company_info())

# Using classmethod as alternative constructor
emp2 = Employee.from_string("Bob, 2022")
print(f"Created from string: {emp2.name} ({emp2.hire_year})")

# Using static method
print(f"Is 2020 valid? {Employee.is_valid_year(2020)}")
print(f"Is 1800 valid? {Employee.is_valid_year(1800)}")
Alice - 6 years employed
Tech Corp - 1 employees
Created from string: Bob (2022)
Is 2020 valid? True
Is 1800 valid? False

2.4 __repr__ vs __str__

  • __repr__: Unambiguous representation for developers (debugging)
  • __str__: Readable representation for users (display)

print() and f-strings use __str__. The interactive shell, the debugger and lists use __repr__.

Rule of thumb: __repr__ should look like the code that would recreate the object.

class Point:
    """A 2D point with proper string representations."""

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        """Developer representation - can recreate object."""
        return f"Point({self.x}, {self.y})"

    def __str__(self) -> str:
        """User-friendly representation."""
        return f"({self.x}, {self.y})"

p = Point(3.5, 4.2)

print(f"repr: {repr(p)}")  # Point(3.5, 4.2)
print(f"str:  {str(p)}")   # (3.5, 4.2)
print(f"print: {p}")       # Uses __str__

# In a list, __repr__ is used
points = [Point(1, 2), Point(3, 4)]
print(f"List: {points}")
repr: Point(3.5, 4.2)
str:  (3.5, 4.2)
print: (3.5, 4.2)
List: [Point(1, 2), Point(3, 4)]

Try It: Create a Book Class

Create a Book class with:

  • title, author and pages as instance attributes
  • total_books as a class attribute that counts how many books have been created
  • a __repr__ that returns Book('Title', 'Author', pages)
  • an is_long() method that returns True if the book has more than 300 pages
# Your code here:
class Book:
    total_books = 0

    def __init__(self, title: str, author: str, pages: int):
        # TODO: Initialize attributes
        pass

    def __repr__(self) -> str:
        # TODO: Return representation
        pass

    def is_long(self) -> bool:
        # TODO: Check if pages > 300
        pass

# Test your implementation:
# book1 = Book("Python Crash Course", "Eric Matthes", 544)
# book2 = Book("Clean Code", "Robert Martin", 464)
# print(book1)
# print(f"Is long? {book1.is_long()}")
# print(f"Total books: {Book.total_books}")
Show solution
class Book:
    total_books = 0

    def __init__(self, title: str, author: str, pages: int):
        self.title = title
        self.author = author
        self.pages = pages
        Book.total_books += 1

    def __repr__(self) -> str:
        return f"Book({self.title!r}, {self.author!r}, {self.pages})"

    def is_long(self) -> bool:
        return self.pages > 300

Quick recap

  • A class is a blueprint; an object is one thing built from it.
  • __init__ runs when an object is created and stores its data on self.
  • Instance attributes belong to one object; class attributes are shared by all objects.
  • Instance methods get self, class methods get cls, static methods get neither.
  • __str__ is for users, __repr__ is for developers.

3. Encapsulation & Properties

Encapsulation means keeping an object's data and the code that works on it together, and controlling how that data can be changed from outside.

Python doesn't have true private like Java. It relies on naming conventions instead:

Name Meaning
balance Public. Anyone can read and change it.
_balance Internal. "Please don't touch this from outside." Python won't stop you; it's a convention.
__balance Name-mangled to _ClassName__balance to avoid clashes in subclasses. Rarely needed.

3.1 The @property Decorator

A property looks like a normal attribute to whoever uses the class, but behind the scenes it runs a method. That lets you add validation or calculations without changing how the attribute is used.

class Temperature:
    """Temperature with validation using properties."""

    def __init__(self, celsius: float = 0.0):
        self._celsius = celsius  # Protected attribute

    @property
    def celsius(self) -> float:
        """Get temperature in Celsius."""
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        """Set temperature with validation."""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero is not possible")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        """Get temperature in Fahrenheit (computed property)."""
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value: float) -> None:
        """Set temperature via Fahrenheit."""
        self.celsius = (value - 32) * 5/9  # Uses celsius setter validation

# Usage looks like simple attributes
temp = Temperature(25)
print(f"Celsius: {temp.celsius}°C")
print(f"Fahrenheit: {temp.fahrenheit}°F")

temp.fahrenheit = 98.6
print(f"\nAfter setting to 98.6°F:")
print(f"Celsius: {temp.celsius:.1f}°C")

# Validation works
try:
    temp.celsius = -300
except ValueError as e:
    print(f"\n✓ Validation error: {e}")
Celsius: 25°C
Fahrenheit: 77.0°F

After setting to 98.6°F:
Celsius: 37.0°C

✓ Validation error: Temperature below absolute zero is not possible

temp.fahrenheit is never stored anywhere. It's calculated from _celsius every time you read it. And because the Fahrenheit setter goes through the Celsius setter, the absolute-zero check protects both.

3.2 Validation Rules with Properties

Properties are a good place to enforce rules about your data. If every change goes through a setter, invalid data can never get into the object, not even through __init__.

class User:
    """User with validated email and age."""

    def __init__(self, username: str, email: str, age: int):
        self.username = username
        self.email = email  # Uses setter
        self.age = age      # Uses setter

    @property
    def email(self) -> str:
        return self._email

    @email.setter
    def email(self, value: str) -> None:
        if "@" not in value or "." not in value.split("@")[-1]:
            raise ValueError(f"Invalid email format: {value}")
        self._email = value.lower().strip()

    @property
    def age(self) -> int:
        return self._age

    @age.setter
    def age(self, value: int) -> None:
        if not isinstance(value, int):
            raise TypeError("Age must be an integer")
        if not 0 <= value <= 150:
            raise ValueError("Age must be between 0 and 150")
        self._age = value

    @property
    def is_adult(self) -> bool:
        """Read-only computed property."""
        return self._age >= 18

# Valid user
user = User("alice", "[email protected]", 25)
print(f"Username: {user.username}")
print(f"Email: {user.email}")  # Normalized to lowercase
print(f"Age: {user.age}")
print(f"Is adult: {user.is_adult}")

# Test validations
test_cases = [
    ("invalid_email", "bademail", 20),
    ("invalid_age_type", "[email protected]", "twenty"),
    ("invalid_age_range", "[email protected]", -5),
]

print("\nValidation tests:")
for name, email, age in test_cases:
    try:
        User(name, email, age)
    except (ValueError, TypeError) as e:
        print(f"  ✓ {name}: {e}")
Username: alice
Email: [email protected]
Age: 25
Is adult: True

Validation tests:
  ✓ invalid_email: Invalid email format: bademail
  ✓ invalid_age_type: Age must be an integer
  ✓ invalid_age_range: Age must be between 0 and 150

Quick recap

  • A leading underscore (_name) means "internal". It's a convention, not a lock.
  • @property runs code when an attribute is read; @x.setter runs code when it's assigned.
  • Put validation in setters, and assign through them in __init__, so bad data never gets in.

4. Inheritance & Polymorphism

Inheritance lets a new class (the child) reuse everything from an existing class (the parent) and add or change only what's different.

Polymorphism means you can call the same method on different kinds of objects and each one does the right thing for itself. In the loop below, describe() doesn't care whether it's dealing with a rectangle or a circle.

Shape is an abstract base class (ABC): you can't create a plain Shape, and every child must implement the methods marked @abstractmethod.

4.1 Base Class and Child Class

import math
from abc import ABC, abstractmethod

class Shape(ABC):
    """Abstract base class for shapes."""

    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def area(self) -> float:
        """Calculate and return the area."""
        pass

    @abstractmethod
    def perimeter(self) -> float:
        """Calculate and return the perimeter."""
        pass

    def describe(self) -> str:
        """Common method for all shapes."""
        return f"{self.name}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"


class Rectangle(Shape):
    """Rectangle implementation."""

    def __init__(self, width: float, height: float):
        super().__init__("Rectangle")  # Call parent __init__
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

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


class Circle(Shape):
    """Circle implementation."""

    def __init__(self, radius: float):
        super().__init__("Circle")
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2

    def perimeter(self) -> float:
        return 2 * math.pi * self.radius


# Polymorphism in action: same interface, different implementations
shapes = [Rectangle(5, 3), Circle(4), Rectangle(2, 2)]

for shape in shapes:
    print(shape.describe())

# Verify inheritance
rect = Rectangle(10, 5)
print(f"\nIs Rectangle a Shape? {isinstance(rect, Shape)}")
print(f"Is Rectangle a Rectangle? {isinstance(rect, Rectangle)}")
Rectangle: area=15.00, perimeter=16.00
Circle: area=50.27, perimeter=25.13
Rectangle: area=4.00, perimeter=8.00

Is Rectangle a Shape? True
Is Rectangle a Rectangle? True

4.2 Method Overriding and super()

A child class can override a parent method (replace it completely) or extend it (call the parent's version with super() and add to it).

class Vehicle:
    """Base class for vehicles."""

    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year

    def start(self) -> str:
        return f"Starting {self.brand} {self.model}"

    def info(self) -> str:
        return f"{self.year} {self.brand} {self.model}"


class ElectricCar(Vehicle):
    """Electric car with battery."""

    def __init__(self, brand: str, model: str, year: int, battery_kwh: float):
        super().__init__(brand, model, year)  # Call parent __init__
        self.battery_kwh = battery_kwh

    def start(self) -> str:
        # Override parent method
        return f"🔋 Silently starting {self.brand} {self.model}"

    def info(self) -> str:
        # Extend parent method
        base_info = super().info()  # Get parent's info
        return f"{base_info} (Electric, {self.battery_kwh} kWh)"

    def charge(self, kwh: float) -> str:
        """New method specific to ElectricCar."""
        return f"Charging {kwh} kWh"


# Regular vehicle
car = Vehicle("Honda", "Civic", 2022)
print(car.start())
print(car.info())

print()

# Electric car
tesla = ElectricCar("Tesla", "Model 3", 2023, 75)
print(tesla.start())    # Overridden
print(tesla.info())     # Extended with super()
print(tesla.charge(50)) # New method
Starting Honda Civic
2022 Honda Civic

🔋 Silently starting Tesla Model 3
2023 Tesla Model 3 (Electric, 75 kWh)
Charging 50 kWh

4.3 Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of a subclass without breaking the program."

In plain words: if your code works with a parent class, it must keep working when you hand it a child class.

The classic counter-example: in maths a square is a rectangle, so it's tempting to write class Square(Rectangle). But code using a rectangle expects that changing the width leaves the height alone. A square can't keep that promise.

# BAD: Violates LSP
class RectangleBad:
    def __init__(self, width: float, height: float):
        self._width = width
        self._height = height

    @property
    def width(self) -> float:
        return self._width

    @width.setter
    def width(self, value: float) -> None:
        self._width = value

    @property
    def height(self) -> float:
        return self._height

    @height.setter
    def height(self, value: float) -> None:
        self._height = value

    def area(self) -> float:
        return self._width * self._height


class SquareBad(RectangleBad):
    """Violates LSP: changing width also changes height."""

    def __init__(self, side: float):
        super().__init__(side, side)

    @RectangleBad.width.setter
    def width(self, value: float) -> None:
        self._width = value
        self._height = value  # Forces square constraint

    @RectangleBad.height.setter
    def height(self, value: float) -> None:
        self._height = value
        self._width = value  # Forces square constraint


def calculate_expected_area(shape: RectangleBad) -> float:
    """Function that expects normal Rectangle behavior."""
    shape.width = 5
    shape.height = 4
    # Expect area to be 5 * 4 = 20
    return shape.area()


rect = RectangleBad(3, 3)
square = SquareBad(3)

print(f"Rectangle area: {calculate_expected_area(rect)}")  # 20 ✓
print(f"Square area: {calculate_expected_area(square)}")    # 16 ✗ (LSP violated!)
print("\n⚠️ Square violates LSP: behavior changed unexpectedly")
Rectangle area: 20
Square area: 16

⚠️ Square violates LSP: behavior changed unexpectedly

The function was written for rectangles and got a wrong answer from a "rectangle". The fix is to stop pretending a square is a rectangle and give both a common parent instead:

# GOOD: LSP-compliant design using shared base
from abc import ABC, abstractmethod

class ShapeLSP(ABC):
    """Abstract base for shapes - LSP compliant."""

    @abstractmethod
    def area(self) -> float:
        pass


class RectangleGood(ShapeLSP):
    """Rectangle that doesn't promise width/height independence."""

    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class SquareGood(ShapeLSP):
    """Square as its own type, not a Rectangle subclass."""

    def __init__(self, side: float):
        self.side = side

    def area(self) -> float:
        return self.side ** 2


# Both work correctly with the interface
shapes_lsp = [RectangleGood(5, 4), SquareGood(4)]
for s in shapes_lsp:
    print(f"{s.__class__.__name__}: area = {s.area()}")

print("\n✓ LSP compliant: each shape behaves consistently")
RectangleGood: area = 20
SquareGood: area = 16

✓ LSP compliant: each shape behaves consistently

Quick recap

  • A child class inherits everything from its parent and can override or extend it.
  • super() calls the parent's version of a method.
  • An abstract base class defines methods every child must implement.
  • Polymorphism: the same method call does different things depending on the object's class.
  • Liskov Substitution Principle: a child must work anywhere its parent is expected. If it can't, don't inherit.

5. Composition vs Inheritance

Relationship Example
Inheritance "is-a" A Dog is an Animal
Composition "has-a" A Car has an Engine

With composition, a class holds other objects as attributes and asks them to do the work. You can swap one part (say, a bigger SSD) without touching the rest.

Prefer composition over inheritance. It's more flexible and keeps classes loosely connected. Use inheritance only when the "is-a" relationship is genuinely true.

5.1 Composition Example: Computer System

class CPU:
    """Central Processing Unit component."""

    def __init__(self, brand: str, cores: int, speed_ghz: float):
        self.brand = brand
        self.cores = cores
        self.speed_ghz = speed_ghz

    def process(self, task: str) -> str:
        return f"CPU processing '{task}' on {self.cores} cores at {self.speed_ghz}GHz"


class RAM:
    """Memory component."""

    def __init__(self, size_gb: int, type_: str = "DDR4"):
        self.size_gb = size_gb
        self.type_ = type_

    def load(self, data: str) -> str:
        return f"Loading '{data}' into {self.size_gb}GB {self.type_} RAM"


class Storage:
    """Storage component."""

    def __init__(self, size_gb: int, type_: str = "SSD"):
        self.size_gb = size_gb
        self.type_ = type_

    def save(self, file: str) -> str:
        return f"Saving '{file}' to {self.size_gb}GB {self.type_}"


class Computer:
    """Computer composed of components (has-a relationship)."""

    def __init__(self, name: str, cpu: CPU, ram: RAM, storage: Storage):
        self.name = name
        self.cpu = cpu        # Has-a CPU
        self.ram = ram        # Has-a RAM
        self.storage = storage  # Has-a Storage

    def run_program(self, program: str) -> None:
        """Run a program using all components."""
        print(f"--- {self.name} running '{program}' ---")
        print(self.storage.save(program))
        print(self.ram.load(program))
        print(self.cpu.process(program))

    def specs(self) -> str:
        """Return computer specifications."""
        return (f"{self.name}: {self.cpu.brand} {self.cpu.cores}-core, "
                f"{self.ram.size_gb}GB RAM, {self.storage.size_gb}GB {self.storage.type_}")


# Build a computer using composition
gaming_pc = Computer(
    name="Gaming Rig",
    cpu=CPU("AMD Ryzen 9", 12, 4.8),
    ram=RAM(32, "DDR5"),
    storage=Storage(2000, "NVMe SSD")
)

print(gaming_pc.specs())
print()
gaming_pc.run_program("Cyberpunk 2077")
Gaming Rig: AMD Ryzen 9 12-core, 32GB RAM, 2000GB NVMe SSD

--- Gaming Rig running 'Cyberpunk 2077' ---
Saving 'Cyberpunk 2077' to 2000GB NVMe SSD
Loading 'Cyberpunk 2077' into 32GB DDR5 RAM
CPU processing 'Cyberpunk 2077' on 12 cores at 4.8GHz

Computer doesn't know how to process, load or save anything. It hands each job to the part that does. Want a faster machine? Pass in a different CPU object; the Computer class doesn't change.

Quick recap

  • Inheritance models "is-a"; composition models "has-a".
  • Composition lets you swap parts without changing the class that uses them, so reach for it first.

6. Dataclasses

Many classes exist mainly to hold data. Writing __init__, __repr__ and __eq__ for each of them gets repetitive. The @dataclass decorator writes those for you from the type-annotated fields.

6.1 Basic Dataclass

from dataclasses import dataclass, field
from typing import List

# Regular class (lots of boilerplate)
class ProductOld:
    def __init__(self, name: str, price: float, quantity: int = 0):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"ProductOld(name={self.name!r}, price={self.price}, quantity={self.quantity})"

    def __eq__(self, other):
        if not isinstance(other, ProductOld):
            return NotImplemented
        return (self.name, self.price, self.quantity) == (other.name, other.price, other.quantity)


# Dataclass (same functionality, less code!)
@dataclass
class Product:
    """Product with auto-generated __init__, __repr__, __eq__."""
    name: str
    price: float
    quantity: int = 0  # Default value

    def total_value(self) -> float:
        """You can still add custom methods."""
        return self.price * self.quantity


# Usage
laptop = Product("MacBook Pro", 2499.99, 5)
mouse = Product("Magic Mouse", 99.99)

print(laptop)  # Auto __repr__
print(f"Total value: ${laptop.total_value():.2f}")

# Auto __eq__
mouse2 = Product("Magic Mouse", 99.99)
print(f"\nmouse == mouse2: {mouse == mouse2}")  # True
Product(name='MacBook Pro', price=2499.99, quantity=5)
Total value: $12499.95

mouse == mouse2: True

6.2 Frozen Dataclass (Immutable)

With frozen=True the fields can't be changed after the object is created, just like a tuple. Frozen objects are also hashable, so they can be used as dictionary keys or put in sets.

@dataclass(frozen=True)
class Coordinate:
    """Immutable coordinate - cannot be modified after creation."""
    latitude: float
    longitude: float

    def distance_to(self, other: "Coordinate") -> float:
        """Simplified distance calculation (not real geo distance)."""
        import math
        return math.sqrt(
            (self.latitude - other.latitude) ** 2 +
            (self.longitude - other.longitude) ** 2
        )

# Create immutable coordinates
home = Coordinate(27.7172, 85.3240)  # Kathmandu
office = Coordinate(27.6915, 85.3420)

print(f"Home: {home}")
print(f"Distance: {home.distance_to(office):.4f}")

# Cannot modify frozen dataclass
try:
    home.latitude = 28.0  # Will raise FrozenInstanceError
except Exception as e:
    print(f"\n✓ Cannot modify frozen: {type(e).__name__}")

# Frozen dataclasses can be used as dict keys or set elements
locations = {home: "Home", office: "Office"}
print(f"\nLocations dict: {locations}")
Home: Coordinate(latitude=27.7172, longitude=85.324)
Distance: 0.0314

✓ Cannot modify frozen: FrozenInstanceError

Locations dict: {Coordinate(latitude=27.7172, longitude=85.324): 'Home', Coordinate(latitude=27.6915, longitude=85.342): 'Office'}

6.3 Default Factory for Mutable Defaults

Remember the mutable-default trap from Section 1? Dataclasses protect you from it: items: list = [] is an error. Use field(default_factory=list) so each object gets its own new list.

from dataclasses import dataclass, field
from typing import List, Dict

@dataclass
class ShoppingCart:
    """Shopping cart with mutable defaults done right."""
    customer_name: str
    # WRONG: items: List[str] = []  # Would share list across instances!
    # RIGHT: Use field with default_factory
    items: List[str] = field(default_factory=list)
    prices: Dict[str, float] = field(default_factory=dict)

    def add_item(self, item: str, price: float) -> None:
        self.items.append(item)
        self.prices[item] = price

    def total(self) -> float:
        return sum(self.prices.values())


# Each instance gets its own list/dict
cart1 = ShoppingCart("Alice")
cart1.add_item("Laptop", 999.99)
cart1.add_item("Mouse", 49.99)

cart2 = ShoppingCart("Bob")
cart2.add_item("Keyboard", 129.99)

print(f"Cart 1 ({cart1.customer_name}): {cart1.items} = ${cart1.total():.2f}")
print(f"Cart 2 ({cart2.customer_name}): {cart2.items} = ${cart2.total():.2f}")

# Verify they're independent
assert cart1.items is not cart2.items
print("\n✓ Each cart has independent items list")
Cart 1 (Alice): ['Laptop', 'Mouse'] = $1049.98
Cart 2 (Bob): ['Keyboard'] = $129.99

✓ Each cart has independent items list

Quick recap

  • @dataclass generates __init__, __repr__ and __eq__ from the fields.
  • frozen=True makes objects read-only and hashable.
  • Use field(default_factory=list) (or dict) for mutable defaults.
  • Use __post_init__ if you need to validate fields after creation.

7. Dunder Methods You Actually Use

"Dunder" is short for double underscore, as in __len__. You almost never call them directly. Python calls them for you: len(x) calls x.__len__(), for item in x calls x.__iter__(), and so on. Implement them, and your objects work with Python's built-in syntax.

7.1 Container Methods: __len__, __iter__, __contains__

class Playlist:
    """A playlist that behaves like a container."""

    def __init__(self, name: str):
        self.name = name
        self._songs: List[str] = []

    def add(self, song: str) -> None:
        self._songs.append(song)

    def __len__(self) -> int:
        """Enable len(playlist)."""
        return len(self._songs)

    def __iter__(self):
        """Enable for song in playlist."""
        return iter(self._songs)

    def __contains__(self, song: str) -> bool:
        """Enable 'song in playlist'."""
        return song in self._songs

    def __getitem__(self, index: int) -> str:
        """Enable playlist[0]."""
        return self._songs[index]

    def __repr__(self) -> str:
        return f"Playlist('{self.name}', {len(self)} songs)"


# Create and populate playlist
playlist = Playlist("Road Trip Mix")
playlist.add("Hotel California")
playlist.add("Bohemian Rhapsody")
playlist.add("Stairway to Heaven")

# Now use Python's built-in operations
print(f"Playlist: {playlist}")
print(f"Length: {len(playlist)}")                    # __len__
print(f"First song: {playlist[0]}")                  # __getitem__
print(f"Contains 'Yesterday': {'Yesterday' in playlist}")  # __contains__
print(f"Contains 'Hotel California': {'Hotel California' in playlist}")

print("\nAll songs:")
for song in playlist:  # __iter__
    print(f"  ♪ {song}")
Playlist: Playlist('Road Trip Mix', 3 songs)
Length: 3
First song: Hotel California
Contains 'Yesterday': False
Contains 'Hotel California': True

All songs:
  ♪ Hotel California
  ♪ Bohemian Rhapsody
  ♪ Stairway to Heaven

7.2 Equality and Ordering: __eq__, __lt__, etc.

from functools import total_ordering

@total_ordering  # Generates other comparisons from __eq__ and __lt__
class Version:
    """Software version with comparison support."""

    def __init__(self, major: int, minor: int, patch: int):
        self.major = major
        self.minor = minor
        self.patch = patch

    def __repr__(self) -> str:
        return f"Version({self.major}, {self.minor}, {self.patch})"

    def __str__(self) -> str:
        return f"{self.major}.{self.minor}.{self.patch}"

    def __eq__(self, other: object) -> bool:
        """Enable == comparison."""
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)

    def __lt__(self, other: "Version") -> bool:
        """Enable < comparison. @total_ordering provides <=, >, >=."""
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)

    def __hash__(self) -> int:
        """Make hashable for use in sets/dicts."""
        return hash((self.major, self.minor, self.patch))


# Create versions
v1 = Version(1, 0, 0)
v2 = Version(1, 2, 3)
v3 = Version(2, 0, 0)
v4 = Version(1, 2, 3)

print(f"v1 = {v1}, v2 = {v2}, v3 = {v3}")
print(f"v2 == v4: {v2 == v4}")
print(f"v1 < v2: {v1 < v2}")
print(f"v3 > v2: {v3 > v2}")

# Sorting works automatically
versions = [v3, v1, v2]
print(f"\nSorted: {sorted(versions)}")
v1 = 1.0.0, v2 = 1.2.3, v3 = 2.0.0
v2 == v4: True
v1 < v2: True
v3 > v2: True

Sorted: [Version(1, 0, 0), Version(1, 2, 3), Version(2, 0, 0)]

With @total_ordering we only wrote __eq__ and __lt__; Python filled in <=, > and >=. Because < works, sorted() works too.

Watch out: if you define __eq__, Python removes the default __hash__. Define __hash__ as well if the objects need to go in a set or be used as dict keys.

Quick recap

  • Dunder methods let your objects work with len(), for, in, [], ==, < and sorted().
  • With @total_ordering you only need __eq__ and __lt__.
  • If you define __eq__, also define __hash__ when objects must be hashable.

8. Real-Life Mini-Project: Food Delivery Order System

Time to put everything together. We'll build the core of a food-delivery app like Foodmandu. Going back to our four design questions:

Question Answer
What things exist? Customer, MenuItem, Order, discount rules
What does each thing know? prices, categories, customer details, items in an order
What can each thing do? add items, calculate totals, apply discounts, complete an order
How are they connected? an Order has a customer and menu items (composition); each discount rule is a DiscountStrategy (inheritance)

8.1 Core Classes

from dataclasses import dataclass, field
from typing import List, Optional
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum


class Category(Enum):
    """Menu item categories."""
    APPETIZER = "appetizer"
    MAIN = "main"
    DESSERT = "dessert"
    BEVERAGE = "beverage"


@dataclass
class MenuItem:
    """A single menu item with pricing."""
    name: str
    price: float
    category: Category
    is_vegetarian: bool = False

    def __post_init__(self):
        if self.price < 0:
            raise ValueError("Price cannot be negative")

    def __str__(self) -> str:
        veg = " (V)" if self.is_vegetarian else ""
        return f"{self.name}{veg} - ${self.price:.2f}"


@dataclass
class Customer:
    """A customer with loyalty status."""
    name: str
    email: str
    phone: str
    is_premium: bool = False
    orders_count: int = 0

    def __str__(self) -> str:
        status = "Premium" if self.is_premium else "Regular"
        return f"{self.name} ({status} - {self.orders_count} orders)"


# Abstract base for discount strategies (Strategy Pattern preview)
class DiscountStrategy(ABC):
    """Abstract discount strategy."""

    @abstractmethod
    def calculate_discount(self, subtotal: float, customer: Customer) -> float:
        """Return discount amount."""
        pass

    @property
    @abstractmethod
    def name(self) -> str:
        pass


class NoDiscount(DiscountStrategy):
    """No discount applied."""

    def calculate_discount(self, subtotal: float, customer: Customer) -> float:
        return 0.0

    @property
    def name(self) -> str:
        return "No discount"


class PremiumDiscount(DiscountStrategy):
    """10% off for premium customers."""

    def calculate_discount(self, subtotal: float, customer: Customer) -> float:
        if customer.is_premium:
            return subtotal * 0.10
        return 0.0

    @property
    def name(self) -> str:
        return "Premium 10% off"


class BulkDiscount(DiscountStrategy):
    """15% off for orders over $50."""

    def calculate_discount(self, subtotal: float, customer: Customer) -> float:
        if subtotal >= 50:
            return subtotal * 0.15
        return 0.0

    @property
    def name(self) -> str:
        return "Bulk 15% off (orders $50+)"


print("✓ Core classes defined")
✓ Core classes defined

8.2 The Order Class (Composition)

class Order:
    """
    An order that HAS-A customer and HAS MenuItems (composition).
    Demonstrates encapsulation and composition.
    """

    _order_counter = 0  # Class attribute for order numbering
    TAX_RATE = 0.13     # 13% tax

    def __init__(self, customer: Customer, discount_strategy: DiscountStrategy = None):
        Order._order_counter += 1
        self._order_id = Order._order_counter
        self._customer = customer              # Composition: Order HAS-A Customer
        self._items: List[tuple[MenuItem, int]] = []  # (item, quantity)
        self._discount_strategy = discount_strategy or NoDiscount()
        self._created_at = datetime.now()
        self._is_completed = False

    @property
    def order_id(self) -> int:
        return self._order_id

    @property
    def customer(self) -> Customer:
        return self._customer

    @property
    def is_completed(self) -> bool:
        return self._is_completed

    def add_item(self, item: MenuItem, quantity: int = 1) -> None:
        """Add item to order."""
        if self._is_completed:
            raise ValueError("Cannot modify completed order")
        if quantity < 1:
            raise ValueError("Quantity must be at least 1")
        self._items.append((item, quantity))

    @property
    def subtotal(self) -> float:
        """Calculate subtotal before tax and discount."""
        return sum(item.price * qty for item, qty in self._items)

    @property
    def discount_amount(self) -> float:
        """Calculate discount based on strategy."""
        return self._discount_strategy.calculate_discount(
            self.subtotal, self._customer
        )

    @property
    def tax_amount(self) -> float:
        """Calculate tax on discounted amount."""
        return (self.subtotal - self.discount_amount) * self.TAX_RATE

    @property
    def total(self) -> float:
        """Calculate final total."""
        return self.subtotal - self.discount_amount + self.tax_amount

    def complete(self) -> None:
        """Mark order as completed."""
        self._is_completed = True
        self._customer.orders_count += 1

    # Container methods
    def __len__(self) -> int:
        """Return number of line items."""
        return len(self._items)

    def __iter__(self):
        """Iterate over items."""
        return iter(self._items)

    def __str__(self) -> str:
        lines = [
            f"═══ Order #{self._order_id} ═══",
            f"Customer: {self._customer.name}",
            f"Date: {self._created_at.strftime('%Y-%m-%d %H:%M')}",
            "─" * 30,
        ]
        for item, qty in self._items:
            lines.append(f"  {qty}x {item.name:<20} ${item.price * qty:.2f}")
        lines.extend([
            "─" * 30,
            f"  {'Subtotal:':<22} ${self.subtotal:.2f}",
            f"  {self._discount_strategy.name:<22} -${self.discount_amount:.2f}",
            f"  {'Tax (13%):':<22} ${self.tax_amount:.2f}",
            "═" * 30,
            f"  {'TOTAL:':<22} ${self.total:.2f}",
        ])
        return "\n".join(lines)


print("✓ Order class defined")
✓ Order class defined

8.3 Demo: Creating Orders

# Create menu items
menu = [
    MenuItem("Spring Rolls", 8.99, Category.APPETIZER, is_vegetarian=True),
    MenuItem("Chicken Wings", 12.99, Category.APPETIZER),
    MenuItem("Margherita Pizza", 16.99, Category.MAIN, is_vegetarian=True),
    MenuItem("Grilled Salmon", 24.99, Category.MAIN),
    MenuItem("Tiramisu", 9.99, Category.DESSERT),
    MenuItem("Iced Tea", 3.99, Category.BEVERAGE),
]

# Create customers
alice = Customer("Alice", "[email protected]", "555-1234", is_premium=True)
bob = Customer("Bob", "[email protected]", "555-5678")

print("=== Menu ===")
for item in menu:
    print(f"  {item}")
print()

# Create an order with premium discount
order1 = Order(alice, PremiumDiscount())
order1.add_item(menu[3], 2)  # 2x Grilled Salmon
order1.add_item(menu[0], 1)  # 1x Spring Rolls
order1.add_item(menu[5], 2)  # 2x Iced Tea

print(order1)
=== Menu ===
  Spring Rolls (V) - $8.99
  Chicken Wings - $12.99
  Margherita Pizza (V) - $16.99
  Grilled Salmon - $24.99
  Tiramisu - $9.99
  Iced Tea - $3.99

═══ Order #1 ═══
Customer: Alice
Date: 2026-10-29 15:32
──────────────────────────────
  2x Grilled Salmon       $49.98
  1x Spring Rolls         $8.99
  2x Iced Tea             $7.98
──────────────────────────────
  Subtotal:              $66.95
  Premium 10% off        -$6.70
  Tax (13%):             $7.83
══════════════════════════════
  TOTAL:                 $68.09

8.4 What-If Scenarios

print("═══ WHAT-IF SCENARIOS ═══\n")

# Scenario 1: Regular customer, no discount vs bulk discount
order_no_disc = Order(bob, NoDiscount())
order_bulk = Order(bob, BulkDiscount())

# Add same items to both
for order in [order_no_disc, order_bulk]:
    order.add_item(menu[2], 2)  # 2x Pizza
    order.add_item(menu[4], 2)  # 2x Tiramisu

print("Scenario 1: Same order, different discount strategies")
print(f"  No discount total: ${order_no_disc.total:.2f}")
print(f"  Bulk discount total: ${order_bulk.total:.2f}")
print(f"  Savings: ${order_no_disc.total - order_bulk.total:.2f}")

print()

# Scenario 2: Premium customer with different order sizes
print("Scenario 2: Premium customer - small vs large order")
small_order = Order(alice, PremiumDiscount())
small_order.add_item(menu[5], 1)  # Just a drink

large_order = Order(alice, PremiumDiscount())
large_order.add_item(menu[3], 3)  # 3x Salmon
large_order.add_item(menu[4], 2)  # 2x Tiramisu

print(f"  Small order (1 drink): ${small_order.total:.2f}")
print(f"  Large order: ${large_order.total:.2f}")
print(f"  Premium discount on large: ${large_order.discount_amount:.2f}")

print()

# Scenario 3: Comparing strategies for the same large order
print("Scenario 3: Which discount is better for $70+ order?")
base_amount = 70.0

premium_disc = PremiumDiscount().calculate_discount(base_amount, alice)
bulk_disc = BulkDiscount().calculate_discount(base_amount, alice)

print(f"  On ${base_amount:.2f} order:")
print(f"    Premium (10%): saves ${premium_disc:.2f}")
print(f"    Bulk (15%): saves ${bulk_disc:.2f}")
print(f"  → Bulk discount is better by ${bulk_disc - premium_disc:.2f}")
═══ WHAT-IF SCENARIOS ═══

Scenario 1: Same order, different discount strategies
  No discount total: $60.97
  Bulk discount total: $51.83
  Savings: $9.15

Scenario 2: Premium customer - small vs large order
  Small order (1 drink): $4.06
  Large order: $96.56
  Premium discount on large: $9.50

Scenario 3: Which discount is better for $70+ order?
  On $70.00 order:
    Premium (10%): saves $7.00
    Bulk (15%): saves $10.50
  → Bulk discount is better by $3.50

8.5 Where Each Concept Shows Up

Concept Where to find it in the project
Classes & objects Customer, MenuItem, Order and the objects created in the demo
Class attributes Order._order_counter, Order.TAX_RATE
Encapsulation & properties Order.subtotal, Order.total, read-only order_id
Inheritance & polymorphism NoDiscount, PremiumDiscount, BulkDiscount all replace DiscountStrategy
Composition Order holds a Customer and a list of MenuItems
Dataclasses MenuItem, Customer (with validation in __post_init__)
Dunder methods Order.__len__, Order.__iter__, MenuItem.__str__

Notice how adding a new discount (say, a festival discount during Dashain) means writing one new class. Order doesn't change at all.


9. Practice Section: In-Class Exercises

Each exercise has a hint, the expected behaviour, and a test you can uncomment once your code is ready. Try it yourself before opening the solution.

Exercise 1: Counter Class

Create a Counter class that:

  • starts at 0, or at a value you pass in
  • has increment(), decrement() and reset() methods
  • has a read-only value property

Hint: Store the count in self._value and expose it with @property (no setter).

Expected: after two increment() calls, c.value is 2.

# Exercise 1: Your code here
class Counter:
    pass

# Test (uncomment when ready):
# c = Counter()
# c.increment()
# c.increment()
# assert c.value == 2
# c.decrement()
# assert c.value == 1
# c.reset()
# assert c.value == 0
# print("✓ Exercise 1 passed!")
Show solution
class Counter:
    def __init__(self, start: int = 0):
        self._start = start
        self._value = start

    @property
    def value(self) -> int:
        return self._value

    def increment(self) -> None:
        self._value += 1

    def decrement(self) -> None:
        self._value -= 1

    def reset(self) -> None:
        self._value = self._start

Exercise 2: Temperature Converter (Classmethod)

Add a from_fahrenheit(cls, f) classmethod to convert from Fahrenheit.

Hint: Formula is (f - 32) * 5/9

Expected: Temperature.from_fahrenheit(212).celsius → 100.0

# Exercise 2: Your code here
class TemperatureEx2:
    def __init__(self, celsius: float):
        self.celsius = celsius

    # TODO: Add from_fahrenheit classmethod

# Test:
# t = TemperatureEx2.from_fahrenheit(212)
# assert t.celsius == 100.0
# print("✓ Exercise 2 passed!")
Show solution
class TemperatureEx2:
    def __init__(self, celsius: float):
        self.celsius = celsius

    @classmethod
    def from_fahrenheit(cls, f: float) -> "TemperatureEx2":
        return cls((f - 32) * 5 / 9)

Exercise 3: Password Property with Validation

Create a UserAccount class where the password property must be at least 8 characters.

Hint: Raise ValueError in the setter if validation fails.

Expected: Setting password to "abc" should raise ValueError.

# Exercise 3: Your code here
class UserAccount:
    pass

# Test:
# user = UserAccount("alice", "secure123")  # Should work
# try:
#     user.password = "short"  # Should raise ValueError
#     print("✗ Should have raised ValueError")
# except ValueError:
#     print("✓ Exercise 3 passed!")
Show solution
class UserAccount:
    def __init__(self, username: str, password: str):
        self.username = username
        self.password = password  # goes through the setter

    @property
    def password(self) -> str:
        return self._password

    @password.setter
    def password(self, value: str) -> None:
        if len(value) < 8:
            raise ValueError("Password must be at least 8 characters")
        self._password = value

Exercise 4: Animal Inheritance

Create Animal base class and Dog, Cat subclasses with speak() method.

Hint: Each subclass returns different sound ("Woof!" vs "Meow!").

Expected: Dog("Rex").speak() → "Rex says Woof!"

# Exercise 4: Your code here
class Animal:
    pass

class Dog(Animal):
    pass

class Cat(Animal):
    pass

# Test:
# assert Dog("Rex").speak() == "Rex says Woof!"
# assert Cat("Whiskers").speak() == "Whiskers says Meow!"
# print("✓ Exercise 4 passed!")
Show solution
class Animal:
    sound = "..."

    def __init__(self, name: str):
        self.name = name

    def speak(self) -> str:
        return f"{self.name} says {self.sound}"


class Dog(Animal):
    sound = "Woof!"


class Cat(Animal):
    sound = "Meow!"

Exercise 5: Dataclass with Validation

Create a Product dataclass with name, price, stock. Use __post_init__ to validate price >= 0.

Hint: __post_init__ runs after auto-generated __init__.

Expected: Product("X", -5, 10) should raise ValueError.

# Exercise 5: Your code here
from dataclasses import dataclass

@dataclass
class ProductEx5:
    name: str
    price: float
    stock: int
    # TODO: Add __post_init__ for validation

# Test:
# p = ProductEx5("Widget", 9.99, 100)  # Should work
# try:
#     ProductEx5("Bad", -5, 10)  # Should raise
#     print("✗ Should have raised ValueError")
# except ValueError:
#     print("✓ Exercise 5 passed!")
Show solution
from dataclasses import dataclass

@dataclass
class ProductEx5:
    name: str
    price: float
    stock: int

    def __post_init__(self):
        if self.price < 0:
            raise ValueError("Price cannot be negative")

Exercise 6: Make it Iterable

Add __len__ and __iter__ to this TaskList class.

Hint: Delegate to the internal _tasks list.

Expected: list(task_list) should return the tasks.

# Exercise 6: Your code here
class TaskList:
    def __init__(self):
        self._tasks = []

    def add(self, task: str):
        self._tasks.append(task)

    # TODO: Add __len__
    # TODO: Add __iter__

# Test:
# tl = TaskList()
# tl.add("Buy milk")
# tl.add("Call mom")
# assert len(tl) == 2
# assert list(tl) == ["Buy milk", "Call mom"]
# print("✓ Exercise 6 passed!")
Show solution
class TaskList:
    def __init__(self):
        self._tasks = []

    def add(self, task: str):
        self._tasks.append(task)

    def __len__(self) -> int:
        return len(self._tasks)

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

Exercise 7: Composition - Library System

Create Library that HAS Book objects. Implement add_book() and find_by_author().

Hint: Store books in a list, filter by author name.

Expected: library.find_by_author("Rowling") returns matching books.

# Exercise 7: Your code here
@dataclass
class BookEx7:
    title: str
    author: str

class Library:
    pass
    # TODO: Implement add_book and find_by_author

# Test:
# lib = Library()
# lib.add_book(BookEx7("Harry Potter", "J.K. Rowling"))
# lib.add_book(BookEx7("Clean Code", "Robert Martin"))
# rowling_books = lib.find_by_author("Rowling")
# assert len(rowling_books) == 1
# print("✓ Exercise 7 passed!")
Show solution
from dataclasses import dataclass

@dataclass
class BookEx7:
    title: str
    author: str


class Library:
    def __init__(self):
        self._books: list[BookEx7] = []

    def add_book(self, book: BookEx7) -> None:
        self._books.append(book)

    def find_by_author(self, name: str) -> list[BookEx7]:
        return [b for b in self._books if name.lower() in b.author.lower()]

Exercise 8: Implement __eq__ and __hash__

Make Student comparable by student_id and usable in sets.

Hint: Two students are equal if they have the same student_id.

Expected: Student("Alice", 1) == Student("Alice Smith", 1) → True

# Exercise 8: Your code here
class StudentEx8:
    def __init__(self, name: str, student_id: int):
        self.name = name
        self.student_id = student_id

    # TODO: Add __eq__ (compare by student_id)
    # TODO: Add __hash__ (hash the student_id)

# Test:
# assert StudentEx8("Alice", 1) == StudentEx8("Alice Smith", 1)
# assert StudentEx8("Alice", 1) != StudentEx8("Bob", 2)
# assert len({StudentEx8("Alice", 1), StudentEx8("Alice Smith", 1)}) == 1
# print("✓ Exercise 8 passed!")
Show solution
class StudentEx8:
    def __init__(self, name: str, student_id: int):
        self.name = name
        self.student_id = student_id

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, StudentEx8):
            return NotImplemented
        return self.student_id == other.student_id

    def __hash__(self) -> int:
        return hash(self.student_id)

Summary & Cheat Sheet

Topic Remember this
Objects & references Variables are names pointing to objects. is = same object, == = same value.
Classes class Name: + __init__(self, ...). self is the object being created or used.
Attributes Instance attributes on self; class attributes in the class body, shared by all.
Methods instance (self), @classmethod (cls), @staticmethod (neither).
Encapsulation _name for internal data, @property + setter for validation.
Inheritance class Child(Parent):, super().method() to reuse the parent's version.
Polymorphism Same method name, different behaviour per class.
LSP A child must work anywhere the parent works.
Composition "has-a". Prefer it over inheritance.
Dataclasses @dataclass, frozen=True, field(default_factory=list), __post_init__.
Dunder methods __repr__, __str__, __len__, __iter__, __contains__, __getitem__, __eq__, __lt__, __hash__.

What's Next

The next notebook, Design Patterns (SOLID), builds directly on this one. The discount strategies from the mini-project are your first taste of the Strategy pattern and the Open/Closed principle.