Python Tutorials

Choose a lesson to begin

Object-Oriented Programming (OOP)

Object-Oriented Programming is a paradigm where you organize code around objects that combine data and behavior. OOP makes complex programs easier to build, understand, and maintain.

Why OOP?

Without OOP:

# Scattered data
player1_name = "Alice"
player1_health = 100
player1_inventory = []
player2_name = "Bob"
player2_health = 100
player2_inventory = []

Functions that need to know structure

def take_damage(name, health, amount): health -= amount return health

With OOP:

class Player:
    def __init__(self, name):
        self.name = name
        self.health = 100
        self.inventory = []
    def take_damage(self, amount):
        self.health -= amount
player1 = Player("Alice")
player2 = Player("Bob")
player1.take_damage(20)  # Clean and intuitive

Classes and Objects

A class is a blueprint. An object is an instance of that class.

class Dog:
    pass  # Empty class for now

Create objects (instances)

dog1 = Dog() dog2 = Dog() print(type(dog1)) # <class '__main__.Dog'> print(dog1 == dog2) # False - different objects

The __init__ Method (Constructor)

Initialize object attributes when created:

class Dog:
    def __init__(self, name, age):
        """Constructor - called when creating new Dog."""
        self.name = name  # Instance attribute
        self.age = age
        self.tricks = []

Create dogs

buddy = Dog("Buddy", 3) max_dog = Dog("Max", 5) print(buddy.name) # Buddy print(max_dog.age) # 5

self refers to the instance being created/used.

Instance Methods

Methods are functions that belong to objects:

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
    def deposit(self, amount):
        """Add money to account."""
        if amount > 0:
            self.balance += amount
            print(f"Deposited ${amount}. New balance: ${self.balance}") 
    def withdraw(self, amount):
        """Remove money from account."""
        if amount > self.balance:
            print("Insufficient funds!")
        else:
            self.balance -= amount
            print(f"Withdrew ${amount}. New balance: ${self.balance}")
    def get_balance(self):
        """Return current balance."""
        return self.balance

Use it

account = BankAccount("Alice", 1000) account.deposit(500) # Deposited $500. New balance: $1500 account.withdraw(200) # Withdrew $200. New balance: $1300 print(account.get_balance()) # 1300

Instance vs Class Attributes

class Dog:
    # Class attribute (shared by ALL dogs)
    species = "Canis familiaris"
    def __init__(self, name, age):
        # Instance attributes (unique to each dog)
        self.name = name
        self.age = age

buddy = Dog("Buddy", 3) max_dog = Dog("Max", 5) print(buddy.species) # Canis familiaris print(max_dog.species) # Canis familiaris

Change class attribute

Dog.species = "Canis lupus familiaris" print(buddy.species) # Changed for all instances!

Class Methods and Static Methods

class Pizza:
    def __init__(self, size, toppings):
        self.size = size
        self.toppings = toppings
    @classmethod
    def margherita(cls):
        """Factory method for margherita pizza."""
        return cls("medium", ["mozzarella", "tomato", "basil"])
    @classmethod
    def pepperoni(cls):
        """Factory method for pepperoni pizza."""
        return cls("large", ["mozzarella", "pepperoni"])
    @staticmethod
    def is_valid_size(size):
        """Utility function - doesn't need instance or class."""
        return size in ["small", "medium", "large"]

Use factory methods

pizza1 = Pizza.margherita() pizza2 = Pizza.pepperoni()

Use static method

print(Pizza.is_valid_size("medium")) # True print(Pizza.is_valid_size("jumbo")) # False

Inheritance

Create specialized classes from general ones:

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return "Some sound"
class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"
class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

Create instances

dog = Dog("Buddy") cat = Cat("Whiskers") print(dog.speak()) # Buddy says Woof! print(cat.speak()) # Whiskers says Meow!

The super() Function

Call parent class methods:

class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model   
    def describe(self):
        return f"{self.brand} {self.model}"
class Car(Vehicle):
    def __init__(self, brand, model, doors):
        super().__init__(brand, model)  # Call parent constructor
        self.doors = doors
    def describe(self):
        parent_desc = super().describe()  # Call parent method
        return f"{parent_desc} with {self.doors} doors"
car = Car("Toyota", "Camry", 4)
print(car.describe())  # Toyota Camry with 4 doors

Encapsulation (Private Attributes)

Use leading underscore for "private" attributes:

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance  # "Private" by convention
    def deposit(self, amount):
        if amount > 0:
            self._balance += amount
    def get_balance(self):
        return self._balance
    # Don't allow direct modification
    # account._balance = 1000000  # Bad practice but possible
account = BankAccount("Alice", 1000)

Use method instead of direct access

account.deposit(500) print(account.get_balance())

Python has no true private attributes - leading underscore is just a convention saying "don't touch this directly."

Properties (Getters and Setters)

Make attributes that act like methods:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius 
    @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 using Fahrenheit."""
        self.celsius = (value - 32) * 5/9

Use like attributes

temp = Temperature(25) print(temp.celsius) # 25 print(temp.fahrenheit) # 77.0 temp.fahrenheit = 86 # Sets via property print(temp.celsius) # 30.0 # temp.celsius = -300 # Raises ValueError

Magic Methods (Dunder Methods)

Special methods that Python calls automatically:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        """Called by str() and print()."""
        return f"Point({self.x}, {self.y})"
    def __repr__(self):
        """Called by repr() and in interactive shell."""
        return f"Point(x={self.x}, y={self.y})"
    def __add__(self, other):
        """Called by + operator."""
        return Point(self.x + other.x, self.y + other.y)
    def __eq__(self, other):
        """Called by == operator."""
        return self.x == other.x and self.y == other.y 
    def __len__(self):
        """Called by len()."""
        return int((self.x**2 + self.y**2) ** 0.5)
p1 = Point(1, 2)
p2 = Point(3, 4)
print(p1)           # Point(1, 2)
print(p1 + p2)      # Point(4, 6)
print(p1 == p2)     # False
print(len(p1))      # 2 (distance from origin)

Common magic methods:

Composition vs Inheritance

Sometimes it's better to have something than be something:

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower 
    def start(self):
        return "Engine started"
class Car:
    def __init__(self, brand, engine):
        self.brand = brand
        self.engine = engine  # Composition - Car HAS an Engine
    def start(self):
        return f"{self.brand}: {self.engine.start()}"

Create car with engine

engine = Engine(200) car = Car("Toyota", engine) print(car.start()) # Toyota: Engine started

When to use each:

Real-World Example: RPG Character System

class Character:
    def __init__(self, name, health, attack, defense):
        self.name = name
        self.max_health = health
        self.health = health
        self.attack = attack
        self.defense = defense
        self.level = 1
    def take_damage(self, damage):
        actual_damage = max(0, damage - self.defense)
        self.health = max(0, self.health - actual_damage)  
        if self.health == 0:
            print(f"{self.name} has been defeated!")
        else:
            print(f"{self.name} took {actual_damage} damage! HP: {self.health}/{self.max_health}")
    def heal(self, amount):
        old_health = self.health
        self.health = min(self.max_health, self.health + amount)
        healed = self.health - old_health
        print(f"{self.name} healed {healed} HP! HP: {self.health}/{self.max_health}")
    
    def is_alive(self):
        return self.health > 0
class Warrior(Character):
    def __init__(self, name):
        super().__init__(name, health=150, attack=30, defense=20)
        self.rage = 0
    def special_attack(self, target):
        damage = self.attack + self.rage
        print(f"{self.name} uses Rage Strike for {damage} damage!")
        target.take_damage(damage)
        self.rage = min(100, self.rage + 10)
class Mage(Character):
    def __init__(self, name):
        super().__init__(name, health=80, attack=50, defense=5)
        self.mana = 100 
    def special_attack(self, target):
        if self.mana >= 30:
            damage = self.attack * 2
            print(f"{self.name} casts Fireball for {damage} damage!")
            target.take_damage(damage)
            self.mana -= 30
        else:
            print(f"{self.name} has insufficient mana!")
class Healer(Character):
    def __init__(self, name):
        super().__init__(name, health=100, attack=15, defense=10)
        self.mana = 150
    
    def special_attack(self, target):
        if self.mana >= 20:
            target.heal(40)
            self.mana -= 20
        else:
            print(f"{self.name} has insufficient mana!")

Create party

warrior = Warrior("Conan") mage = Mage("Gandalf") healer = Healer("Elara")

Battle simulation

enemy = Character("Dragon", 200, 40, 15) warrior.special_attack(enemy) mage.special_attack(enemy) enemy.take_damage(warrior.attack) healer.special_attack(warrior)

Design Patterns: Factory

class Animal:
    def speak(self):
        pass
class Dog(Animal):
    def speak(self):
        return "Woof!"
class Cat(Animal):
    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

animal = AnimalFactory.create_animal("dog") print(animal.speak()) # Woof!

Try It Out!

  • Create a Book class with title, author, and pages
  • Build a Library class that manages a collection of books
  • Create a Shape parent class with Circle and Rectangle children
  • Implement a Stack class with push/pop/peek methods
  • Design a simple TodoList class with add/remove/complete functionality
  • Key Takeaways

    OOP helps you organize complex programs into manageable, reusable pieces! 🐍

    🐍 Python Runner