Python - Classes and Objects Tutorial

• Classes define blueprints for objects with attributes (data) and methods (behavior), enabling organized, reusable code through encapsulation and abstraction

Key Insights

• Classes define blueprints for objects with attributes (data) and methods (behavior), enabling organized, reusable code through encapsulation and abstraction • Instance methods operate on object data using self, while class methods use cls for class-level operations, and static methods provide utility functions without accessing class or instance state • Python’s special methods (__init__, __str__, __repr__) control object lifecycle and behavior, while properties enable controlled attribute access with validation logic

Understanding Classes and Objects

Classes serve as templates for creating objects. An object is an instance of a class containing both data (attributes) and functions (methods) that operate on that data.

class BankAccount:
    def __init__(self, account_number, balance=0):
        self.account_number = account_number
        self.balance = balance
    
    def deposit(self, amount):
        if amount > 0:
            self.balance += amount
            return True
        return False
    
    def withdraw(self, amount):
        if 0 < amount <= self.balance:
            self.balance -= amount
            return True
        return False

# Creating objects
account1 = BankAccount("ACC001", 1000)
account2 = BankAccount("ACC002")

account1.deposit(500)
print(account1.balance)  # 1500

The __init__ method initializes object state when creating instances. The self parameter references the current instance, allowing methods to access and modify object attributes.

Instance, Class, and Static Methods

Python supports three method types, each serving distinct purposes.

class Employee:
    company_name = "TechCorp"  # Class attribute
    employee_count = 0
    
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary
        Employee.employee_count += 1
    
    # Instance method
    def give_raise(self, amount):
        self.salary += amount
        return self.salary
    
    # Class method
    @classmethod
    def change_company_name(cls, new_name):
        cls.company_name = new_name
    
    @classmethod
    def from_string(cls, emp_string):
        name, salary = emp_string.split('-')
        return cls(name, int(salary))
    
    # Static method
    @staticmethod
    def is_workday(day):
        return day not in ['Saturday', 'Sunday']

# Usage
emp1 = Employee("Alice", 75000)
emp1.give_raise(5000)

# Alternative constructor using class method
emp2 = Employee.from_string("Bob-80000")

# Class method modifies class state
Employee.change_company_name("InnovateTech")

# Static method doesn't access instance or class
print(Employee.is_workday("Monday"))  # True

Instance methods access instance data via self. Class methods receive cls and operate on class-level data. Static methods are utility functions that don’t require access to class or instance state.

Special Methods (Magic Methods)

Special methods customize object behavior for built-in operations.

class Product:
    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity
    
    def __str__(self):
        # Human-readable string representation
        return f"{self.name} - ${self.price}"
    
    def __repr__(self):
        # Developer-friendly representation
        return f"Product('{self.name}', {self.price}, {self.quantity})"
    
    def __eq__(self, other):
        # Define equality comparison
        if isinstance(other, Product):
            return self.name == other.name and self.price == other.price
        return False
    
    def __lt__(self, other):
        # Define less-than comparison for sorting
        return self.price < other.price
    
    def __add__(self, other):
        # Define addition behavior
        if isinstance(other, Product) and self.name == other.name:
            return Product(self.name, self.price, self.quantity + other.quantity)
        raise ValueError("Cannot add different products")

# Usage
p1 = Product("Laptop", 999, 5)
p2 = Product("Laptop", 999, 3)

print(str(p1))  # Laptop - $999
print(repr(p1))  # Product('Laptop', 999, 5)
print(p1 == p2)  # True
p3 = p1 + p2
print(p3.quantity)  # 8

Common special methods include __init__, __str__, __repr__, __eq__, __lt__, __add__, __len__, and __getitem__.

Properties and Encapsulation

Properties provide controlled access to attributes with validation and computed values.

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

# Usage
temp = Temperature(25)
print(temp.celsius)  # 25
print(temp.fahrenheit)  # 77.0

temp.fahrenheit = 86
print(temp.celsius)  # 30.0

# Validation in action
try:
    temp.celsius = -300
except ValueError as e:
    print(e)  # Temperature below absolute zero

The @property decorator creates getter methods, while @<property>.setter creates setters. This pattern enables attribute access syntax while maintaining validation logic.

Inheritance and Polymorphism

Inheritance allows classes to extend functionality from parent classes.

class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model
        self.mileage = 0
    
    def drive(self, distance):
        self.mileage += distance
        return f"Drove {distance} miles"
    
    def info(self):
        return f"{self.brand} {self.model}"

class ElectricVehicle(Vehicle):
    def __init__(self, brand, model, battery_capacity):
        super().__init__(brand, model)
        self.battery_capacity = battery_capacity
        self.charge_level = 100
    
    def drive(self, distance):
        charge_needed = distance * 0.3
        if self.charge_level >= charge_needed:
            self.charge_level -= charge_needed
            return super().drive(distance)
        return "Insufficient charge"
    
    def charge(self, amount):
        self.charge_level = min(100, self.charge_level + amount)

class GasVehicle(Vehicle):
    def __init__(self, brand, model, tank_size):
        super().__init__(brand, model)
        self.tank_size = tank_size
        self.fuel_level = tank_size
    
    def drive(self, distance):
        fuel_needed = distance * 0.05
        if self.fuel_level >= fuel_needed:
            self.fuel_level -= fuel_needed
            return super().drive(distance)
        return "Insufficient fuel"
    
    def refuel(self):
        self.fuel_level = self.tank_size

# Polymorphism in action
vehicles = [
    ElectricVehicle("Tesla", "Model 3", 75),
    GasVehicle("Toyota", "Camry", 15)
]

for vehicle in vehicles:
    print(vehicle.info())
    print(vehicle.drive(50))

The super() function calls parent class methods. Polymorphism allows different classes to implement the same interface differently, enabling flexible code that works with various object types.

Composition Over Inheritance

Composition builds complex objects by combining simpler ones, offering greater flexibility than inheritance.

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 GPS:
    def __init__(self):
        self.location = (0, 0)
    
    def navigate(self, destination):
        return f"Navigating to {destination}"

class Car:
    def __init__(self, brand, engine, has_gps=False):
        self.brand = brand
        self.engine = engine
        self.gps = GPS() if has_gps else None
    
    def start(self):
        return self.engine.start()
    
    def navigate(self, destination):
        if self.gps:
            return self.gps.navigate(destination)
        return "No GPS available"

# Usage
engine = Engine(300)
car = Car("BMW", engine, has_gps=True)
print(car.start())  # Engine started
print(car.navigate("Downtown"))  # Navigating to Downtown

Composition creates “has-a” relationships rather than “is-a” relationships, making code more modular and easier to modify.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.