Lesson 3: Classes and Object-Oriented Programming#

Introduction#

Object-Oriented Programming (OOP) is like organizing your code the way the real world is organized. Think of classes as blueprints (like architectural plans for a house) and objects as the actual houses built from those blueprints.

Why OOP Matters:

  • Modularity: Break complex systems into manageable pieces

  • Reusability: Write code once, use it in many contexts

  • Maintainability: Changes in one place donโ€™t break everything

  • Real-world modeling: Code that mirrors how we think about problems

What Youโ€™ll Learn:

  • Classes and objects (instances)

  • Instance variables and methods

  • Class variables and methods

  • Magic methods (dunder methods)

  • Properties and encapsulation

  • Inheritance and polymorphism

  • Composition over inheritance

  • Abstract base classes

  • Dataclasses (modern Python)

  • Common design patterns

1. Creating Your First Class#

A class is a blueprint for creating objects. Objects are instances of classes.

Basic Class Structure#

class Dog:
    """A simple Dog class."""
    
    def __init__(self, name, age):
        """Initialize a new Dog instance.
        
        Args:
            name (str): The dog's name
            age (int): The dog's age
        """
        self.name = name  # Instance variable
        self.age = age    # Instance variable
    
    def bark(self):
        """Make the dog bark."""
        return f"{self.name} says Woof!"
    
    def get_info(self):
        """Return information about the dog."""
        return f"{self.name} is {self.age} years old"

# Create objects (instances)
buddy = Dog("Buddy", 3)
max_dog = Dog("Max", 5)

print(buddy.bark())          # Buddy says Woof!
print(max_dog.get_info())    # Max is 5 years old

# Each object has its own data
print(f"Buddy's age: {buddy.age}")
print(f"Max's age: {max_dog.age}")

Understanding self#

self refers to the specific instance of the class. Itโ€™s how the instance accesses its own data.

class Person:
    def __init__(self, name):
        self.name = name  # self.name is the instance variable
    
    def greet(self):
        # self refers to the specific instance calling this method
        print(f"Hello, I'm {self.name}")

alice = Person("Alice")
bob = Person("Bob")

alice.greet()  # self = alice
bob.greet()    # self = bob

2. Instance Variables vs Class Variables#

Instance Variables#

Unique to each instance (defined with self).

class Car:
    def __init__(self, make, model):
        self.make = make      # Instance variable
        self.model = model    # Instance variable

car1 = Car("Toyota", "Camry")
car2 = Car("Honda", "Civic")

print(f"Car 1: {car1.make} {car1.model}")
print(f"Car 2: {car2.make} {car2.model}")

Class Variables#

Shared by all instances (defined in the class body).

class Dog:
    # Class variable (shared by all dogs)
    species = "Canis familiaris"
    count = 0  # Track number of dogs created
    
    def __init__(self, name):
        self.name = name  # Instance variable (unique to each dog)
        Dog.count += 1    # Increment class variable

dog1 = Dog("Buddy")
dog2 = Dog("Max")
dog3 = Dog("Charlie")

# All dogs share the same species
print(f"{dog1.name}'s species: {dog1.species}")
print(f"{dog2.name}'s species: {dog2.species}")

# Class variable accessible from class
print(f"Total dogs created: {Dog.count}")

# Modifying class variable affects all instances
Dog.species = "Canis lupus familiaris"
print(f"Updated species for all: {dog1.species}")

3. Types of Methods#

Instance Methods#

Operate on instance data (use self).

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
    
    # Instance method
    def deposit(self, amount):
        """Deposit money into account."""
        if amount > 0:
            self.balance += amount
            return f"Deposited ${amount}. Balance: ${self.balance}"
        return "Invalid amount"
    
    # Instance method
    def withdraw(self, amount):
        """Withdraw money from account."""
        if 0 < amount <= self.balance:
            self.balance -= amount
            return f"Withdrew ${amount}. Balance: ${self.balance}"
        return "Insufficient funds"

account = BankAccount("Alice", 1000)
print(account.deposit(500))
print(account.withdraw(200))

Class Methods#

Operate on class data (use @classmethod decorator and cls).

class Pizza:
    def __init__(self, ingredients):
        self.ingredients = ingredients
    
    def __repr__(self):
        return f"Pizza({self.ingredients})"
    
    @classmethod
    def margherita(cls):
        """Factory method to create a Margherita pizza."""
        return cls(['mozzarella', 'tomatoes', 'basil'])
    
    @classmethod
    def pepperoni(cls):
        """Factory method to create a Pepperoni pizza."""
        return cls(['mozzarella', 'tomatoes', 'pepperoni'])

# Use class methods as factory methods
pizza1 = Pizza.margherita()
pizza2 = Pizza.pepperoni()

print(pizza1)
print(pizza2)

Static Methods#

Donโ€™t use instance or class data (use @staticmethod decorator).

class Math:
    @staticmethod
    def add(x, y):
        """Add two numbers."""
        return x + y
    
    @staticmethod
    def is_even(n):
        """Check if number is even."""
        return n % 2 == 0

# Call static methods without creating an instance
print(Math.add(5, 3))      # 8
print(Math.is_even(10))    # True

# Can also call from instance (but uncommon)
m = Math()
print(m.add(10, 20))       # 30

4. Magic Methods (Dunder Methods)#

Magic methods (double underscore methods) allow you to define how objects behave with built-in operations.

__str__ and __repr__#

class Book:
    def __init__(self, title, author, year):
        self.title = title
        self.author = author
        self.year = year
    
    def __str__(self):
        """Human-readable string (for end users)."""
        return f"{self.title} by {self.author}"
    
    def __repr__(self):
        """Developer-friendly representation (for debugging)."""
        return f"Book('{self.title}', '{self.author}', {self.year})"

book = Book("1984", "George Orwell", 1949)

print(str(book))   # Uses __str__: 1984 by George Orwell
print(repr(book))  # Uses __repr__: Book('1984', 'George Orwell', 1949)
print(book)        # print() uses __str__ if available

Comparison Magic Methods#

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def __eq__(self, other):
        """Check equality (==)."""
        return self.age == other.age
    
    def __lt__(self, other):
        """Less than (<)."""
        return self.age < other.age
    
    def __le__(self, other):
        """Less than or equal (<=)."""
        return self.age <= other.age
    
    def __repr__(self):
        return f"Person('{self.name}', {self.age})"

alice = Person("Alice", 30)
bob = Person("Bob", 25)
charlie = Person("Charlie", 30)

print(f"alice == charlie: {alice == charlie}")  # True (same age)
print(f"bob < alice: {bob < alice}")            # True (25 < 30)

# Can now sort people by age
people = [alice, bob, charlie]
people.sort()
print(f"Sorted by age: {people}")

Arithmetic Magic Methods#

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        """Add two vectors (+)."""
        return Vector(self.x + other.x, self.y + other.y)
    
    def __mul__(self, scalar):
        """Multiply vector by scalar (*)."""
        return Vector(self.x * scalar, self.y * scalar)
    
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(5, 7)

print(f"v1 + v2 = {v1 + v2}")  # Vector(7, 10)
print(f"v1 * 3 = {v1 * 3}")    # Vector(6, 9)

Container Magic Methods#

class Playlist:
    def __init__(self):
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __len__(self):
        """Return length with len()."""
        return len(self.songs)
    
    def __getitem__(self, index):
        """Enable indexing with []."""
        return self.songs[index]
    
    def __contains__(self, song):
        """Enable 'in' operator."""
        return song in self.songs

playlist = Playlist()
playlist.add_song("Bohemian Rhapsody")
playlist.add_song("Stairway to Heaven")
playlist.add_song("Hotel California")

print(f"Playlist length: {len(playlist)}")
print(f"First song: {playlist[0]}")
print(f"Has Stairway: {'Stairway to Heaven' in playlist}")

# Can iterate because of __getitem__
for song in playlist:
    print(f"  - {song}")

5. Properties and Encapsulation#

Properties provide controlled access to attributes.

Using @property#

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius  # "Private" attribute (convention)
    
    @property
    def celsius(self):
        """Get temperature in Celsius."""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        """Set temperature in Celsius with validation."""
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        """Get temperature in Fahrenheit."""
        return self._celsius * 9/5 + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        """Set temperature in Fahrenheit."""
        self._celsius = (value - 32) * 5/9

temp = Temperature(25)
print(f"Celsius: {temp.celsius}ยฐC")
print(f"Fahrenheit: {temp.fahrenheit}ยฐF")

# Set using Fahrenheit
temp.fahrenheit = 100
print(f"New Celsius: {temp.celsius:.2f}ยฐC")

# Validation works
# temp.celsius = -300  # ValueError!

Computed Properties#

class Circle:
    def __init__(self, radius):
        self.radius = radius
    
    @property
    def diameter(self):
        """Computed property."""
        return self.radius * 2
    
    @property
    def area(self):
        """Computed property."""
        import math
        return math.pi * self.radius ** 2
    
    @property
    def circumference(self):
        """Computed property."""
        import math
        return 2 * math.pi * self.radius

circle = Circle(5)
print(f"Radius: {circle.radius}")
print(f"Diameter: {circle.diameter}")
print(f"Area: {circle.area:.2f}")
print(f"Circumference: {circle.circumference:.2f}")

6. Inheritance#

Inheritance allows classes to inherit attributes and methods from parent classes.

Basic Inheritance#

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
    
    def speak(self):
        return "Some generic sound"
    
    def get_info(self):
        return f"{self.name} is a {self.species}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "Dog")  # Call parent __init__
        self.breed = breed
    
    def speak(self):  # Override parent method
        return f"{self.name} says Woof!"
    
    def fetch(self):  # New method specific to Dog
        return f"{self.name} fetches the ball!"

class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name, "Cat")
        self.color = color
    
    def speak(self):
        return f"{self.name} says Meow!"

# Create instances
dog = Dog("Rex", "Golden Retriever")
cat = Cat("Whiskers", "Orange")

print(dog.get_info())    # Inherited method
print(dog.speak())       # Overridden method
print(dog.fetch())       # Dog-specific method

print(cat.get_info())
print(cat.speak())

Multiple Inheritance#

class Flyer:
    def fly(self):
        return "Flying through the air!"

class Swimmer:
    def swim(self):
        return "Swimming through water!"

class Duck(Flyer, Swimmer):
    def __init__(self, name):
        self.name = name
    
    def quack(self):
        return f"{self.name} says Quack!"

# Duck can both fly and swim
donald = Duck("Donald")
print(donald.fly())
print(donald.swim())
print(donald.quack())

7. Polymorphism#

Polymorphism allows objects of different classes to be treated uniformly.

class Shape:
    def area(self):
        raise NotImplementedError("Subclass must implement area()")
    
    def perimeter(self):
        raise NotImplementedError("Subclass must implement perimeter()")

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        import math
        return math.pi * self.radius ** 2
    
    def perimeter(self):
        import math
        return 2 * math.pi * self.radius

# Polymorphism in action
shapes = [
    Rectangle(5, 3),
    Circle(4),
    Rectangle(10, 2)
]

for shape in shapes:
    print(f"{shape.__class__.__name__}:")
    print(f"  Area: {shape.area():.2f}")
    print(f"  Perimeter: {shape.perimeter():.2f}")

8. Composition Over Inheritance#

Composition means building complex objects from simpler ones.

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
        self.running = False
    
    def start(self):
        self.running = True
        return "Engine started"
    
    def stop(self):
        self.running = False
        return "Engine stopped"

class Wheel:
    def __init__(self, size):
        self.size = size

class Car:
    """Car HAS-A engine and wheels (composition)."""
    
    def __init__(self, make, model, horsepower):
        self.make = make
        self.model = model
        # Composition: Car contains other objects
        self.engine = Engine(horsepower)
        self.wheels = [Wheel(17) for _ in range(4)]
    
    def start(self):
        return self.engine.start()
    
    def stop(self):
        return self.engine.stop()
    
    def get_info(self):
        return f"{self.make} {self.model} with {self.engine.horsepower}hp"

car = Car("Toyota", "Camry", 203)
print(car.get_info())
print(car.start())
print(f"Engine running: {car.engine.running}")

9. Abstract Base Classes#

Abstract base classes define interfaces that subclasses must implement.

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    """Abstract base class for payment processors."""
    
    @abstractmethod
    def process_payment(self, amount):
        """Process a payment. Must be implemented by subclasses."""
        pass
    
    @abstractmethod
    def refund(self, amount):
        """Refund a payment. Must be implemented by subclasses."""
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount):
        return f"Processing ${amount} via credit card"
    
    def refund(self, amount):
        return f"Refunding ${amount} to credit card"

class PayPalProcessor(PaymentProcessor):
    def process_payment(self, amount):
        return f"Processing ${amount} via PayPal"
    
    def refund(self, amount):
        return f"Refunding ${amount} via PayPal"

# Cannot instantiate abstract class
# processor = PaymentProcessor()  # TypeError!

# Can instantiate concrete implementations
cc = CreditCardProcessor()
pp = PayPalProcessor()

print(cc.process_payment(100))
print(pp.process_payment(50))

10. Dataclasses (Python 3.7+)#

Dataclasses reduce boilerplate for classes that mainly store data.

from dataclasses import dataclass, field
from typing import List

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0
    tags: List[str] = field(default_factory=list)
    
    def total_value(self):
        return self.price * self.quantity

# Automatically generates __init__, __repr__, __eq__
p1 = Product("Laptop", 1200, 5, ["electronics", "computers"])
p2 = Product("Mouse", 25)

print(p1)  # Product(name='Laptop', price=1200, quantity=5, ...)
print(f"Total value: ${p1.total_value()}")

# Equality comparison works
p3 = Product("Mouse", 25)
print(f"p2 == p3: {p2 == p3}")  # True

Frozen Dataclasses (Immutable)#

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(10, 20)
print(p)

# Cannot modify frozen dataclass
# p.x = 30  # FrozenInstanceError!

11. Design Patterns#

Singleton Pattern#

class Singleton:
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

# All instances are the same object
s1 = Singleton()
s2 = Singleton()

print(f"s1 is s2: {s1 is s2}")  # True
print(f"id(s1): {id(s1)}")
print(f"id(s2): {id(s2)}")

Factory Pattern#

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class AnimalFactory:
    @staticmethod
    def create_animal(animal_type):
        if animal_type == "dog":
            return Dog()
        elif animal_type == "cat":
            return Cat()
        else:
            raise ValueError(f"Unknown animal type: {animal_type}")

# Use factory to create objects
dog = AnimalFactory.create_animal("dog")
cat = AnimalFactory.create_animal("cat")

print(dog.speak())
print(cat.speak())

Exercises#

Exercise 1: Basic Class#

Create a Rectangle class with:

  • width and height attributes

  • area() and perimeter() methods

  • is_square() method

  • __str__() method that returns โ€œRectangle(width x height)โ€

# Your code here

Exercise 2: Properties#

Create a Person class with:

  • Private _age attribute

  • age property with getter and setter

  • Setter should validate age is between 0 and 150

  • birth_year property that calculates from age (current year - age)

from datetime import datetime

# Your code here

Exercise 3: Inheritance#

Create a class hierarchy:

  • Vehicle base class with brand, year, and start_engine() method

  • Car subclass that adds num_doors

  • Motorcycle subclass that adds has_sidecar

  • Override start_engine() in each subclass with specific messages

# Your code here

Exercise 4: Magic Methods#

Create a Money class that:

  • Stores amount and currency

  • Implements __add__() to add two Money objects (same currency only)

  • Implements __sub__() to subtract

  • Implements __eq__() and __lt__() for comparison

  • Implements __str__() that returns โ€œ$100.00 USDโ€

# Your code here

Exercise 5: Composition#

Create a Library system using composition:

  • Book class with title, author, ISBN

  • Library class that contains multiple books

  • Methods: add_book(), remove_book(), find_by_author(), list_all()

  • Implement __len__() to return number of books

# Your code here

Exercise 6: Real-World Application#

Create a ShoppingCart system:

  • Product dataclass with name, price, category

  • CartItem with product and quantity

  • ShoppingCart class with methods:

    • add_item(product, quantity)

    • remove_item(product)

    • get_total()

    • apply_discount(percentage)

    • get_items_by_category(category)

from dataclasses import dataclass

# Your code here

Self-Check Quiz#

1. What is the difference between a class and an object?

Answer A class is a blueprint/template, while an object is a specific instance created from that class.

2. What does self represent?

Answer The specific instance of the class that is calling the method.

3. Whatโ€™s the difference between instance and class variables?

Answer Instance variables are unique to each instance (defined with self), class variables are shared by all instances.

4. When should you use @classmethod vs @staticmethod?

Answer Use @classmethod when you need access to the class (cls). Use @staticmethod when the method doesn't need access to instance or class.

5. What is the purpose of init()?

Answer Constructor method that initializes new instances of the class.

6. Whatโ€™s the difference between str() and repr()?

Answer __str__() is for end users (readable), __repr__() is for developers (debugging, should be unambiguous).

7. What is inheritance?

Answer A mechanism where a child class inherits attributes and methods from a parent class.

8. What does super() do?

Answer Calls methods from the parent class, commonly used to call parent's __init__().

9. What is polymorphism?

Answer The ability to treat objects of different classes uniformly, often through method overriding.

10. When should you use composition over inheritance?

Answer When you want a "has-a" relationship rather than "is-a". Composition is more flexible and avoids inheritance complexity.

Key Takeaways#

โœ… Classes are blueprints; objects are instances

โœ… self refers to the current instance

โœ… Instance variables are unique per object; class variables are shared

โœ… Magic methods define how objects interact with Python operators

โœ… Properties provide controlled attribute access with validation

โœ… Inheritance creates โ€œis-aโ€ relationships between classes

โœ… Polymorphism allows treating different objects uniformly

โœ… Composition creates โ€œhas-aโ€ relationships (often better than inheritance)

โœ… Abstract base classes define interfaces that subclasses must implement

โœ… Dataclasses reduce boilerplate for data-focused classes

Pro Tips#

๐Ÿ’ก Single Responsibility Principle - Each class should do one thing well

๐Ÿ’ก Prefer composition over inheritance - More flexible and less coupled

๐Ÿ’ก Use properties for computed attributes - Cleaner than getter/setter methods

๐Ÿ’ก Implement repr() for debugging - Makes development easier

๐Ÿ’ก Use dataclasses for data containers - Less boilerplate code

๐Ÿ’ก Follow naming conventions - _private (convention), __dunder__ (magic)

๐Ÿ’ก Use ABC for interfaces - Enforce that subclasses implement required methods

๐Ÿ’ก Keep inheritance shallow - Deep hierarchies are hard to maintain

๐Ÿ’ก Type hints improve clarity - def process(self, data: List[int]) -> bool:

๐Ÿ’ก Document your classes - Good docstrings save time later

Common Mistakes to Avoid#

โŒ Forgetting self parameter - First parameter of instance methods must be self โœ… def method(self):

โŒ Modifying class variables unintentionally - Can affect all instances โœ… Use instance variables for data unique to each object

โŒ Not calling super().init() - Parent initialization wonโ€™t run โœ… Always call parent init in subclasses

โŒ Using mutable default arguments - def __init__(self, items=[]) โœ… Use None and create new object: items = items or []

โŒ Deep inheritance hierarchies - Hard to understand and maintain โœ… Prefer composition or shallow hierarchies

โŒ Ignoring repr() - Makes debugging harder โœ… Always implement repr for your classes

Next Steps#

You now understand Object-Oriented Programming! Next topics:

  1. Error Handling - Try/except, custom exceptions, context managers

  2. File I/O - Reading and writing files, working with paths

  3. Iterators and Generators - Advanced iteration patterns

  4. Decorators (Advanced) - Class decorators, functools

  5. Design Patterns - Strategy, Observer, Factory, etc.

Practice Projects:

  • Build a library management system

  • Create a banking application with accounts and transactions

  • Design a game with characters, items, and inventory

  • Implement a simple e-commerce system

OOP is fundamental to modern software development - master it! ๐Ÿš€