🏗️ Phase 3 · OOP 🟡 Intermediate MODULE 11

OOP & Classes

⏱️ 45 min
📖 Theory + Code
🧩 5 Questions
🏗️ 1 Challenge
Phase 3 progress17%
🎯 What you'll learn: The 4 pillars of Object-Oriented Programming. How to define classes with class, write __init__, use self, create instance and class attributes, define instance methods and class methods, and use dunder methods (__str__, __repr__, __len__) to make objects feel like built-in Python types.

What is Object-Oriented Programming?

OOP is a programming paradigm that organises code around objects — bundles of related data (attributes) and behaviour (methods). A class is the blueprint; an instance is the actual object created from that blueprint.

Think of a class as the cookie cutter and an instance as an actual cookie. You can bake hundreds of different cookies from the same cutter — each with its own flavour and decoration — but they all share the same shape.

📦
Encapsulation
Bundle data and methods together. Hide internal details behind a clean interface. Objects manage their own state.
🧬
Inheritance
A child class inherits attributes and methods from a parent class, then adds or overrides its own. Promotes reuse.
🎭
Polymorphism
Different classes can respond to the same method call in their own way. One interface, multiple implementations.
🔒
Abstraction
Hide complex implementation. Users interact with a simple interface without needing to know how it works inside.

Defining a Class

Use the class keyword. The __init__ method (the constructor) runs automatically every time you create an instance. self refers to the instance being created — it must be the first parameter of every instance method.

class Student:← class name, PascalCase by convention
    __init__(self, name, age):← constructor — runs on Student("Ali", 20)
        self.name = name← instance attribute — unique per object
        self.age = age
    def greet(self):← instance method — always takes self
        return f"Hi, I'm {self.name}!"
classes_basics.py
PYTHON
class Student:
    """Represents a student with name, age, and grades."""

    # Class attribute — shared by ALL instances
    school = "BitWithBite Academy"

    def __init__(self, name, age, gpa=0.0):
        # Instance attributes — unique per object
        self.name   = name
        self.age    = age
        self.gpa    = gpa
        self.grades = []   # mutable — create fresh per instance!

    def add_grade(self, score):
        """Add a grade and recalculate GPA."""
        self.grades.append(score)
        self.gpa = sum(self.grades) / len(self.grades)

    def is_honours(self):
        """Return True if GPA is 3.7 or above."""
        return self.gpa >= 3.7

    def profile(self):
        return f"{self.name}, {self.age}, GPA {self.gpa:.2f}"


# Creating instances
ali  = Student("Ali", 20)
sara = Student("Sara", 22, 3.9)

# Access instance attributes
print(ali.name)        # Ali
print(sara.gpa)        # 3.9

# Access class attribute (shared)
print(ali.school)      # BitWithBite Academy
print(Student.school) # BitWithBite Academy — same

# Call instance methods
ali.add_grade(88)
ali.add_grade(92)
ali.add_grade(78)
print(ali.profile())    # Ali, 20, GPA 86.00
print(ali.is_honours()) # False (86 < 87 threshold)
⚠️
Never use mutable defaults in class attributes
If you write grades = [] as a class attribute (outside __init__), every instance shares the same list — adding a grade for Ali would appear in Sara's grades too! Always create mutable defaults inside __init__ with self.grades = [].

Dunder Methods — Making Objects Pythonic

Dunder methods (double-underscore methods, also called magic methods) let your objects behave like built-in Python types. Define __str__ to control how an object looks when printed; define __len__ to make len(obj) work; define __eq__ to control == comparisons.

dunder_methods.py
PYTHON
class ShoppingCart:
    def __init__(self, owner):
        self.owner = owner
        self.items = []

    def add(self, item, price):
        self.items.append((item, price))

    def total(self):
        return sum(p for _, p in self.items)

    # __str__ — called by print() and str()
    def __str__(self):
        lines = [f"  {i:20} PKR {p:,.0f}" for i, p in self.items]
        return f"🛒 {self.owner}'s Cart\n" + "\n".join(lines) + f"\n  Total: PKR {self.total():,.0f}"

    # __repr__ — called in the REPL / for debugging
    def __repr__(self):
        return f"ShoppingCart(owner={self.owner!r}, items={len(self.items)})"

    # __len__ — called by len(cart)
    def __len__(self):
        return len(self.items)

    # __contains__ — called by 'item' in cart
    def __contains__(self, item_name):
        return item_name in [i for i, _ in self.items]

    # __add__ — called by cart1 + cart2
    def __add__(self, other):
        merged = ShoppingCart(f"{self.owner}&{other.owner}")
        merged.items = self.items + other.items
        return merged


cart = ShoppingCart("Fatima")
cart.add("Python Book", 1500)
cart.add("Keyboard", 3200)
cart.add("Mouse", 1800)

print(cart)            # calls __str__
print(len(cart))       # 3
print("Mouse" in cart) # True
🎭
Every operator in Python calls a dunder method
a + b calls a.__add__(b). a == b calls a.__eq__(b). len(a) calls a.__len__(). str(a) calls a.__str__(). a[0] calls a.__getitem__(0). This is how Python makes everything consistent — even integers and lists are just objects with dunder methods.

Class Methods & Static Methods

Not all methods need access to a specific instance. Python gives you two decorators: @classmethod for methods that work with the class itself (not an instance), and @staticmethod for utility functions that happen to live inside a class but don't need self or cls.

class_static_methods.py
PYTHON
class BankAccount:
    _total_accounts = 0    # class attribute — tracked globally
    INTEREST_RATE   = 0.05 # constant — class attribute

    def __init__(self, owner, balance=0):
        self.owner   = owner
        self.balance = balance
        BankAccount._total_accounts += 1

    # Instance method — needs self
    def deposit(self, amount):
        self.balance += amount

    def apply_interest(self):
        self.balance *= (1 + BankAccount.INTEREST_RATE)

    # Class method — receives cls, not self
    @classmethod
    def total_accounts(cls):
        """How many accounts have been created?"""
        return cls._total_accounts

    # Alternative constructor — common pattern
    @classmethod
    def from_dict(cls, data):
        """Create an account from a dict."""
        return cls(data["owner"], data["balance"])

    # Static method — utility, no cls/self needed
    @staticmethod
    def validate_amount(amount):
        """Return True if amount is a positive number."""
        return isinstance(amount, (int, float)) and amount > 0

    def __str__(self):
        return f"Account({self.owner}, PKR {self.balance:,.2f})"


acc1 = BankAccount("Ali", 10000)
acc2 = BankAccount.from_dict({"owner": "Sara", "balance": 25000})

print(BankAccount.total_accounts())     # 2
print(BankAccount.validate_amount(-50)) # False
acc1.deposit(5000)
acc1.apply_interest()
print(acc1)   # Account(Ali, PKR 15,750.00)

Properties — Controlled Attribute Access

The @property decorator lets you access a method like an attribute. This is Python's way of adding validation or computation to attribute access without breaking the interface. Pair it with @name.setter to validate values on assignment.

properties.py
PYTHON
class Temperature:
    """Store temperature in Celsius with validation."""

    def __init__(self, celsius):
        self.celsius = celsius   # triggers the setter below

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):
        """Read-only computed property."""
        return self._celsius * 9/5 + 32

    @property
    def kelvin(self):
        return self._celsius + 273.15

    def __str__(self):
        return f"{self.celsius:.1f}°C = {self.fahrenheit:.1f}°F = {self.kelvin:.2f}K"


t = Temperature(100)
print(t)                 # 100.0°C = 212.0°F = 373.15K
print(t.fahrenheit)      # accessed like attribute, not method call!

t.celsius = 0             # triggers setter — validated
print(t)                 # 0.0°C = 32.0°F = 273.15K

# t.celsius = -300  ← ValueError: Temperature below absolute zero!
Use @property for computed and validated attributes
Properties make your API clean: callers write obj.fahrenheit not obj.get_fahrenheit(). And if you later need to add validation to an attribute that was previously public, you can add a property without breaking any code that already uses it — callers never see the difference.
🧩 Knowledge Check
5 questions — OOP & Classes
1. What does self refer to inside a class method?
2. What is the difference between a class attribute and an instance attribute?
3. Which dunder method controls what print(obj) displays?
4. What decorator creates a method that receives cls instead of self?
5. What does @property allow you to do?
🏗️
Challenge — Library Book System
Build two classes with full dunder support
Task: Build a library system with two classes:

Book: attributes title, author, pages, available=True. Methods: checkout() sets available=False, return_book() sets True. Properties: status returns "Available"/"Checked Out". Dunders: __str__, __repr__.

Library: attribute books (list). Methods: add_book(book), find_by_author(author) → list, available_books() → list. Dunders: __len__ returns total books, __contains__ checks if title is in library. Class method from_list(cls, book_list) creates library from list of dicts.
💡 Show hints
  • @property for status: return "Available" if self.available else "Checked Out"
  • Library __contains__: return any(b.title == item for b in self.books)
  • from_list classmethod: loop the list, create Book objects, add them
  • find_by_author: use list comprehension with .lower() for case-insensitive match
library_system.py — Sample Solution
PYTHON
class Book:
    def __init__(self, title, author, pages):
        self.title    = title
        self.author   = author
        self.pages    = pages
        self.available= True

    @property
    def status(self):
        return "Available" if self.available else "Checked Out"

    def checkout(self):
        if not self.available: raise ValueError("Already checked out!")
        self.available = False

    def return_book(self): self.available = True

    def __str__(self):
        return f"'{self.title}' by {self.author} [{self.status}]"
    def __repr__(self):
        return f"Book({self.title!r}, {self.author!r})"


class Library:
    def __init__(self, name):
        self.name  = name
        self.books = []

    def add_book(self, book): self.books.append(book)

    def find_by_author(self, author):
        return [b for b in self.books if author.lower() in b.author.lower()]

    def available_books(self):
        return [b for b in self.books if b.available]

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

    def __contains__(self, title):
        return any(b.title == title for b in self.books)

    @classmethod
    def from_list(cls, name, book_dicts):
        lib = cls(name)
        for d in book_dicts: lib.add_book(Book(**d))
        return lib

    def __str__(self):
        return f"{self.name} Library — {len(self)} books"


lib = Library.from_list("City", [
    {"title":"Python Crash Course", "author":"Eric Matthes", "pages":544},
    {"title":"Clean Code",         "author":"Robert Martin",  "pages":431},
])
print(lib)
print("Clean Code" in lib)  # True
lib.books[0].checkout()
print(len(lib.available_books()))  # 1
🎉
Lesson 11 Complete!
Classes mastered! Next — Inheritance lets you build powerful class hierarchies.
← Course Home
Phase 3 · OOPLesson 11 of 6