Intermediate

Object-Oriented Programming in Python

Build scalable applications with classes, objects, and OOP principles

Object-Oriented Programming (OOP) is a programming paradigm that organizes code around "objects" - bundles of data and functionality. It's one of the most powerful ways to structure complex programs, making code more reusable, maintainable, and scalable.

Python's OOP capabilities enable you to model real-world entities, create custom data types, and build sophisticated applications with clean, organized code.

📚 What You'll Learn

  • Classes and objects fundamentals
  • The __init__ constructor and self parameter
  • Instance and class attributes
  • Methods and special methods (magic methods)
  • Inheritance and method overriding
  • Polymorphism and encapsulation
  • Property decorators
  • Building real-world applications with OOP

1. Classes and Objects Basics

A class is a blueprint for creating objects. An object is an instance of a class.

# Simple class definition
class Dog:
    """A simple Dog class."""
    
    # Class attribute (shared by all instances)
    species = "Canis familiaris"
    
    # Constructor method (runs when object is created)
    def __init__(self, name, age):
        # Instance attributes (unique to each object)
        self.name = name
        self.age = age
    
    # Instance method
    def bark(self):
        return f"{self.name} says Woof!"
    
    def get_info(self):
        return f"{self.name} is {self.age} years old"

# Creating objects (instances)
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

# Accessing attributes
print(dog1.name)       # Output: Buddy
print(dog2.age)        # Output: 5
print(dog1.species)    # Output: Canis familiaris

# Calling methods
print(dog1.bark())     # Output: Buddy says Woof!
print(dog2.get_info()) # Output: Max is 5 years old
💡 Key Concepts:
  • self - Refers to the instance itself
  • __init__ - Constructor that initializes the object
  • Instance attributes - Unique to each object
  • Class attributes - Shared by all instances

2. Building a Practical Class

Example: Bank Account

class BankAccount:
    """Represents a bank account with basic operations."""
    
    # Class attribute - applies to all accounts
    interest_rate = 0.02  # 2% annual interest
    
    def __init__(self, account_number, owner, balance=0):
        """Initialize a new bank account."""
        self.account_number = account_number
        self.owner = owner
        self.balance = balance
        self.transactions = []
    
    def deposit(self, amount):
        """Deposit money into the account."""
        if amount > 0:
            self.balance += amount
            self.transactions.append(f"Deposit: +${amount:.2f}")
            return f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}"
        return "Invalid amount"
    
    def withdraw(self, amount):
        """Withdraw money from the account."""
        if amount > self.balance:
            return "Insufficient funds"
        if amount > 0:
            self.balance -= amount
            self.transactions.append(f"Withdrawal: -${amount:.2f}")
            return f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}"
        return "Invalid amount"
    
    def apply_interest(self):
        """Apply interest to the account."""
        interest = self.balance * self.interest_rate
        self.balance += interest
        self.transactions.append(f"Interest: +${interest:.2f}")
    
    def get_statement(self):
        """Get account statement."""
        statement = f"\n=== Account Statement ===\n"
        statement += f"Account: {self.account_number}\n"
        statement += f"Owner: {self.owner}\n"
        statement += f"Balance: ${self.balance:.2f}\n"
        statement += f"\nRecent Transactions:\n"
        for transaction in self.transactions[-5:]:  # Last 5
            statement += f"  {transaction}\n"
        return statement

# Usage
account = BankAccount("123456", "Alice", 1000)
print(account.deposit(500))
print(account.withdraw(200))
account.apply_interest()
print(account.get_statement())

3. Special Methods (Magic Methods)

Special methods (also called dunder methods) allow you to define how objects behave with built-in Python operations.

class Book:
    """A book with special methods."""
    
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
    
    def __str__(self):
        """String representation for users."""
        return f"'{self.title}' by {self.author}"
    
    def __repr__(self):
        """String representation for developers."""
        return f"Book('{self.title}', '{self.author}', {self.pages})"
    
    def __len__(self):
        """Return length (number of pages)."""
        return self.pages
    
    def __eq__(self, other):
        """Check equality based on title and author."""
        return (self.title == other.title and 
                self.author == other.author)
    
    def __lt__(self, other):
        """Compare books by page count."""
        return self.pages < other.pages
    
    def __add__(self, other):
        """Combine pages of two books."""
        return self.pages + other.pages

# Usage
book1 = Book("Python Basics", "John Doe", 250)
book2 = Book("Advanced Python", "Jane Smith", 400)

print(book1)              # Calls __str__: 'Python Basics' by John Doe
print(repr(book2))        # Calls __repr__
print(len(book1))         # Calls __len__: 250
print(book1 < book2)      # Calls __lt__: True
print(book1 + book2)      # Calls __add__: 650

# Common special methods:
# __init__   - Constructor
# __str__    - String representation (for print)
# __repr__   - Developer representation
# __len__    - len() function
# __eq__     - == operator
# __lt__     - < operator
# __add__    - + operator
# __getitem__- [] indexing
# __call__   - Make object callable like a function

4. Inheritance

Inheritance allows a class to inherit attributes and methods from another class, promoting code reuse.

# Base class (Parent)
class Animal:
    """Base class for all animals."""
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def speak(self):
        """Generic speak method."""
        return "Some sound"
    
    def get_info(self):
        """Get basic info."""
        return f"{self.name} is {self.age} years old"

# Derived class (Child)
class Dog(Animal):
    """Dog class inherits from Animal."""
    
    def __init__(self, name, age, breed):
        # Call parent constructor
        super().__init__(name, age)
        self.breed = breed
    
    # Override parent method
    def speak(self):
        return f"{self.name} barks: Woof!"
    
    # Add new method specific to Dog
    def fetch(self):
        return f"{self.name} is fetching the ball"

class Cat(Animal):
    """Cat class inherits from Animal."""
    
    def __init__(self, name, age, indoor=True):
        super().__init__(name, age)
        self.indoor = indoor
    
    def speak(self):
        return f"{self.name} meows: Meow!"
    
    def scratch(self):
        return f"{self.name} is scratching the furniture"

# Usage
dog = Dog("Buddy", 3, "Golden Retriever")
cat = Cat("Whiskers", 2)

print(dog.get_info())    # Inherited from Animal
print(dog.speak())       # Overridden in Dog
print(dog.fetch())       # Specific to Dog

print(cat.speak())       # Overridden in Cat
print(cat.scratch())     # Specific to Cat

Multiple Inheritance

class Flyer:
    """Mixin for flying ability."""
    def fly(self):
        return f"{self.name} is flying"

class Swimmer:
    """Mixin for swimming ability."""
    def swim(self):
        return f"{self.name} is swimming"

class Duck(Animal, Flyer, Swimmer):
    """Duck can fly and swim."""
    def __init__(self, name, age):
        super().__init__(name, age)
    
    def speak(self):
        return f"{self.name} quacks: Quack!"

# Usage
duck = Duck("Donald", 2)
print(duck.speak())
print(duck.fly())
print(duck.swim())

5. Encapsulation and Properties

Encapsulation hides internal details and controls access to data using private attributes and properties.

class Employee:
    """Employee with encapsulated salary."""
    
    def __init__(self, name, salary):
        self.name = name
        self._salary = salary  # Protected (convention)
        self.__bonus = 0       # Private (name mangling)
    
    # Property - acts like an attribute but runs code
    @property
    def salary(self):
        """Getter for salary."""
        return self._salary
    
    @salary.setter
    def salary(self, value):
        """Setter for salary with validation."""
        if value < 0:
            raise ValueError("Salary cannot be negative")
        self._salary = value
    
    @property
    def annual_income(self):
        """Calculated property."""
        return (self._salary * 12) + self.__bonus
    
    def give_bonus(self, amount):
        """Give bonus (controlled access to private attribute)."""
        if amount > 0:
            self.__bonus = amount

# Usage
emp = Employee("Alice", 5000)

# Use property like an attribute
print(emp.salary)        # Calls getter: 5000
emp.salary = 5500        # Calls setter
print(emp.salary)        # 5500

# Validation in setter
try:
    emp.salary = -1000   # Raises ValueError
except ValueError as e:
    print(f"Error: {e}")

emp.give_bonus(2000)
print(emp.annual_income) # 68000 (5500*12 + 2000)

# Private attributes are name-mangled
# print(emp.__bonus)     # AttributeError
# print(emp._Employee__bonus)  # Works but don't do this!

6. Polymorphism

Polymorphism allows different classes to be treated uniformly through a common interface.

class Shape:
    """Base class for shapes."""
    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)
]

# Same method call works for all shapes
for shape in shapes:
    print(f"{shape.__class__.__name__}:")
    print(f"  Area: {shape.area():.2f}")
    print(f"  Perimeter: {shape.perimeter():.2f}")

7. Real-World Example: Library System

from datetime import datetime, timedelta

class LibraryItem:
    """Base class for library items."""
    def __init__(self, title, item_id):
        self.title = title
        self.item_id = item_id
        self.checked_out = False
        self.due_date = None
    
    def checkout(self, days=14):
        """Check out the item."""
        if self.checked_out:
            return f"{self.title} is already checked out"
        self.checked_out = True
        self.due_date = datetime.now() + timedelta(days=days)
        return f"Checked out: {self.title}. Due: {self.due_date.strftime('%Y-%m-%d')}"
    
    def return_item(self):
        """Return the item."""
        if not self.checked_out:
            return f"{self.title} is not checked out"
        self.checked_out = False
        was_late = datetime.now() > self.due_date
        self.due_date = None
        return "Late return!" if was_late else "Returned on time"

class Book(LibraryItem):
    def __init__(self, title, item_id, author, pages):
        super().__init__(title, item_id)
        self.author = author
        self.pages = pages
    
    def __str__(self):
        status = "Checked Out" if self.checked_out else "Available"
        return f"Book: '{self.title}' by {self.author} ({pages} pages) - {status}"

class DVD(LibraryItem):
    def __init__(self, title, item_id, director, duration):
        super().__init__(title, item_id)
        self.director = director
        self.duration = duration
    
    def checkout(self, days=7):  # DVDs have shorter checkout period
        return super().checkout(days)
    
    def __str__(self):
        status = "Checked Out" if self.checked_out else "Available"
        return f"DVD: '{self.title}' by {self.director} ({self.duration} min) - {status}"

class Library:
    """Manages library items."""
    def __init__(self, name):
        self.name = name
        self.items = []
    
    def add_item(self, item):
        """Add item to library."""
        self.items.append(item)
        print(f"Added: {item.title}")
    
    def find_item(self, title):
        """Find item by title."""
        for item in self.items:
            if item.title.lower() == title.lower():
                return item
        return None
    
    def list_available(self):
        """List all available items."""
        available = [item for item in self.items if not item.checked_out]
        if not available:
            print("No items available")
            return
        
        print(f"\nAvailable items at {self.name}:")
        for item in available:
            print(f"  - {item}")

# Usage
library = Library("City Library")

# Add items
library.add_item(Book("Python Crash Course", "B001", "Eric Matthes", 544))
library.add_item(Book("Clean Code", "B002", "Robert Martin", 464))
library.add_item(DVD("The Matrix", "D001", "Wachowskis", 136))

# Check out item
book = library.find_item("Python Crash Course")
if book:
    print(book.checkout())

# List available
library.list_available()

8. Best Practices

✅ OOP Best Practices

  • Single Responsibility: Each class should have one clear purpose
  • Use Inheritance Wisely: Prefer composition over inheritance when appropriate
  • Encapsulation: Keep internal details private, expose clean interfaces
  • DRY Principle: Don't repeat yourself - use inheritance and methods
  • Meaningful Names: Class names should be nouns, method names should be verbs
  • Documentation: Use docstrings to document classes and methods

Key Takeaways

✅ What You've Learned

  • Classes & Objects: Create blueprints and instances
  • Constructor: Initialize objects with __init__
  • Special Methods: Customize object behavior with dunder methods
  • Inheritance: Reuse code and create hierarchies
  • Encapsulation: Control access with properties and private attributes
  • Polymorphism: Use common interfaces for different types

🎯 What's Next?

With OOP mastered, you're ready for:

  • Working with Libraries: Leverage Python's ecosystem
  • Web Development: Build with Flask or Django
  • Data Science: Use pandas, numpy with OOP principles

📝 Knowledge Check

Test your understanding of Object-Oriented Programming!

Question 1: What is a class in Python?

A) A function
B) A blueprint for creating objects
C) A variable
D) A module

Question 2: What is the purpose of __init__?

A) To delete an object
B) To print an object
C) To initialize object attributes
D) To import a class

Question 3: What is inheritance?

A) A class acquiring properties from another class
B) Creating multiple objects
C) Deleting a class
D) Hiding class details

Question 4: What is encapsulation?

A) Creating many classes
B) Deleting attributes
C) Inheriting from multiple classes
D) Hiding internal implementation details

Question 5: What does 'self' represent?

A) The class itself
B) The instance of the class
C) A global variable
D) The parent class