Object-Oriented Programming in Python

Object-Oriented Programming (OOP) is a programming paradigm that uses objects and classes to organize code for designing applications and computer programs. Python is a multi-paradigm programming language that fully supports OOP principles while remaining simple and flexible.

This guide explores Python's OOP features, including classes, objects, inheritance, encapsulation, polymorphism, and more. Whether you're new to OOP or looking to deepen your understanding, this guide will help you master OOP concepts in Python.

Classes and Objects Fundamentals

Defining Classes

A class is a blueprint for creating objects, providing initial values for state (attributes) and implementations of behavior (methods). In Python, classes are defined using the class keyword.

# Basic class definition
class Person:
    # Class attribute (shared by all instances)
    species = "Homo sapiens"
    
    # Initialize method (constructor)
    def __init__(self, name, age):
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age
    
    # Instance method
    def introduce(self):
        return f"Hello, my name is {self.name} and I am {self.age} years old."
    
    # Instance method with parameters
    def celebrate_birthday(self):
        self.age += 1
        return f"Happy birthday! Now I'm {self.age} years old."
        
    # Class method (operates on the class itself)
    @classmethod
    def from_birth_year(cls, name, birth_year):
        import datetime
        current_year = datetime.datetime.now().year
        age = current_year - birth_year
        return cls(name, age)
    
    # Static method (doesn't access class or instance)
    @staticmethod
    def is_adult(age):
        return age >= 18

Creating and Using Objects

Objects are instances of classes. When you create an object, you're creating a specific instance based on the class blueprint.

# Creating instances (objects)
person1 = Person("Alice", 30)
person2 = Person("Bob", 25)

# Accessing instance attributes
print(person1.name)  # Alice
print(person2.age)   # 25

# Accessing class attributes (via instance or class)
print(person1.species)  # Homo sapiens
print(Person.species)   # Homo sapiens

# Calling instance methods
print(person1.introduce())  # Hello, my name is Alice and I am 30 years old.
print(person1.celebrate_birthday())  # Happy birthday! Now I'm 31 years old.

# Using a class method
person3 = Person.from_birth_year("Charlie", 1995)
print(person3.age)  # Current age based on birth year

# Using a static method
print(Person.is_adult(20))  # True
print(person1.is_adult(15))  # False (can also be called from instance)

Inheritance and Polymorphism

Inheritance

Inheritance allows a class to inherit attributes and methods from another class. The class that inherits is called a subclass (or derived class), and the class from which it inherits is called the superclass (or base class).

# Base class
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
        
    def make_sound(self):
        return "Some generic animal sound"
    
    def __str__(self):
        return f"{self.name} is a {self.species}"

# Derived class inheriting from Animal
class Dog(Animal):
    def __init__(self, name, breed):
        # Call to parent class constructor
        super().__init__(name, species="Dog")
        self.breed = breed
        
    # Override method from parent class
    def make_sound(self):
        return "Woof!"
    
    # Add new method
    def fetch(self, item):
        return f"{self.name} fetched the {item}!"

# Another derived class
class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name, species="Cat")
        self.color = color
        
    def make_sound(self):
        return "Meow!"
    
    def scratch(self):
        return f"{self.name} scratches the furniture!"

# Creating and using derived objects
dog = Dog("Rex", "German Shepherd")
cat = Cat("Whiskers", "Tabby")

print(dog)  # Rex is a Dog
print(dog.make_sound())  # Woof!
print(dog.fetch("ball"))  # Rex fetched the ball!

print(cat)  # Whiskers is a Cat
print(cat.make_sound())  # Meow!
print(cat.scratch())  # Whiskers scratches the furniture!

# Testing inheritance
print(isinstance(dog, Dog))     # True
print(isinstance(dog, Animal))  # True
print(isinstance(dog, Cat))     # False

Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common superclass. The most common use of polymorphism in OOP is when a parent class reference is used to refer to a child class object.

# Using polymorphism with inheritance
def animal_sound(animal):
    return animal.make_sound()

# Function works for any class that inherits from Animal
print(animal_sound(dog))  # Woof!
print(animal_sound(cat))  # Meow!

# Polymorphism with a collection
animals = [
    Animal("Generic Animal", "Unknown"),
    Dog("Buddy", "Golden Retriever"),
    Cat("Felix", "Black")
]

for animal in animals:
    # Same method call works for all classes
    print(f"{animal.name} says: {animal.make_sound()}")

# Duck typing: Python doesn't care about type, only behavior
class Duck:
    def make_sound(self):
        return "Quack!"
        
duck = Duck()
print(animal_sound(duck))  # Quack! (works even though Duck doesn't inherit from Animal)

Encapsulation

Encapsulation is the bundling of data and the methods that operate on that data into a single unit (the class) and restricting access to some of the object's components. In Python, encapsulation is more about convention than strict enforcement.

# Python's encapsulation conventions
class BankAccount:
    def __init__(self, account_holder, balance=0):
        # Public attribute (accessible directly)
        self.account_holder = account_holder
        
        # Protected attribute (convention: single underscore)
        # Indicates it shouldn't be accessed directly outside the class
        # But can be accessed if needed
        self._balance = balance
        
        # Private attribute (convention: double underscore)
        # Name mangling makes it harder to access outside the class
        self.__account_number = self.__generate_account_number()
        
    # Private method
    def __generate_account_number(self):
        import random
        return random.randint(10000000, 99999999)
    
    # Public methods - interface to interact with the object
    def deposit(self, amount):
        if amount > 0:
            self._balance += amount
            return f"Deposited ${amount}. New balance: ${self._balance}"
        return "Amount must be positive"
    
    def withdraw(self, amount):
        if 0 < amount <= self._balance:
            self._balance -= amount
            return f"Withdrew ${amount}. New balance: ${self._balance}"
        return "Insufficient funds or invalid amount"
    
    def get_balance(self):
        return self._balance
    
    def get_account_details(self):
        # Last 4 digits of account number for security
        return f"Account Holder: {self.account_holder}, Account: XXXX{str(self.__account_number)[-4:]}"

# Using the encapsulated class
account = BankAccount("John Smith", 1000)

# Accessing public attribute and methods
print(account.account_holder)  # John Smith
print(account.deposit(500))    # Deposited $500. New balance: $1500
print(account.get_balance())   # 1500

# Accessing protected attribute (not recommended but possible)
print(account._balance)  # 1500

# Trying to access private attribute
# print(account.__account_number)  # AttributeError

# Name mangling: Python renames private attributes to _ClassName__attribute
print(account._BankAccount__account_number)  # Can access but not recommended

# The intended way to access private information
print(account.get_account_details())

Property Decorators

Python's property decorators provide a powerful way to implement encapsulation, allowing controlled access to attributes.

# Using property decorators for controlled attribute access
class Person:
    def __init__(self, name, age):
        self._name = name
        self._age = age
    
    # Getter property
    @property
    def name(self):
        return self._name
    
    # Setter property
    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError("Name must be a string")
        if len(value) < 2:
            raise ValueError("Name must be at least 2 characters")
        self._name = value
    
    # Getter property
    @property
    def age(self):
        return self._age
    
    # Setter property with validation
    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise TypeError("Age must be an integer")
        if value < 0 or value > 120:
            raise ValueError("Age must be between 0 and 120")
        self._age = value
    
    # Read-only property (no setter)
    @property
    def is_adult(self):
        return self._age >= 18

# Using properties
person = Person("Alice", 30)

# Access attributes through properties
print(person.name)  # Alice
print(person.age)   # 30

# Modify attributes through properties (using setters)
person.name = "Alicia"
person.age = 31

# Validation will raise exceptions
# person.name = ""       # ValueError: Name must be at least 2 characters
# person.age = 150      # ValueError: Age must be between 0 and 120
# person.age = "thirty" # TypeError: Age must be an integer

# Read-only property
print(person.is_adult)  # True
# person.is_adult = False  # AttributeError: can't set attribute

Advanced Inheritance Concepts

Multiple Inheritance

Python supports multiple inheritance, allowing a class to inherit from more than one parent class.

# Multiple inheritance example
class Employee:
    def __init__(self, name, employee_id):
        self.name = name
        self.employee_id = employee_id
    
    def display_employee_info(self):
        return f"Employee: {self.name}, ID: {self.employee_id}"

class Developer:
    def __init__(self, programming_languages):
        self.programming_languages = programming_languages
    
    def code(self):
        return f"Coding in {', '.join(self.programming_languages)}"

# Class inheriting from both Employee and Developer
class SoftwareEngineer(Employee, Developer):
    def __init__(self, name, employee_id, programming_languages, team):
        # Call both parent constructors
        Employee.__init__(self, name, employee_id)
        Developer.__init__(self, programming_languages)
        self.team = team
    
    def display_info(self):
        employee_info = self.display_employee_info()
        coding_info = self.code()
        return f"{employee_info}. {coding_info}. Team: {self.team}"

# Using multiple inheritance
engineer = SoftwareEngineer("Alice", "E12345", ["Python", "JavaScript", "Rust"], "Backend")
print(engineer.display_info())

# Method Resolution Order (MRO)
print(SoftwareEngineer.__mro__)

Abstract Base Classes

Abstract Base Classes (ABCs) define a common interface for derived classes, enforcing certain methods to be implemented.

# Abstract Base Classes
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        # Abstract method must be implemented by all non-abstract derived classes
        pass
    
    @abstractmethod
    def perimeter(self):
        pass
    
    # Concrete method (already implemented)
    def description(self):
        return f"A shape with area {self.area()} and perimeter {self.perimeter()}"

# Cannot instantiate abstract class
# shape = Shape()  # TypeError: Can't instantiate abstract class

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    # Implementation of abstract method
    def area(self):
        return self.width * self.height
    
    # Implementation of abstract method
    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

# Using concrete implementations
rectangle = Rectangle(5, 4)
circle = Circle(3)

for shape in [rectangle, circle]:
    print(shape.description())

Mixins

Mixins are classes designed to provide additional functionality that can be "mixed in" to other classes.

# Mixin example
class SerializationMixin:
    def to_dict(self):
        # Convert object attributes to dictionary
        return {key: value for key, value in self.__dict__.items()
                if not key.startswith('_')}
    
    def to_json(self):
        import json
        return json.dumps(self.to_dict())

class LoggingMixin:
    def log(self, message):
        import datetime
        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] {self.__class__.__name__}: {message}")

# Using mixins with a class
class Product(SerializationMixin, LoggingMixin):
    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity
    
    def total_value(self):
        return self.price * self.quantity

# Using the functionality from mixins
product = Product("Laptop", 999.99, 5)
product.log("Product initialized")
print(product.to_dict())
print(product.to_json())

Special Methods and Operator Overloading

Special methods (also called dunder methods for "double underscore") allow classes to emulate built-in types or implement operator overloading.

# Class with special methods for operator overloading
class Vector:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z
    
    # String representation
    def __str__(self):
        return f"Vector({self.x}, {self.y}, {self.z})"
    
    # Formal representation
    def __repr__(self):
        return f"Vector(x={self.x}, y={self.y}, z={self.z})"
    
    # Vector addition
    def __add__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
        raise TypeError("Can only add Vector objects")
    
    # Vector subtraction
    def __sub__(self, other):
        if isinstance(other, Vector):
            return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
        raise TypeError("Can only subtract Vector objects")
    
    # Scalar multiplication
    def __mul__(self, scalar):
        if isinstance(scalar, (int, float)):
            return Vector(self.x * scalar, self.y * scalar, self.z * scalar)
        raise TypeError("Scalar must be a number")
    
    # Right scalar multiplication (for expressions like 2 * vector)
    def __rmul__(self, scalar):
        return self.__mul__(scalar)
    
    # Vector magnitude (length)
    def __abs__(self):
        return (self.x**2 + self.y**2 + self.z**2) ** 0.5
    
    # Equality comparison
    def __eq__(self, other):
        if isinstance(other, Vector):
            return self.x == other.x and self.y == other.y and self.z == other.z
        return False
    
    # Length (returns the vector's dimension, not magnitude)
    def __len__(self):
        return 3  # 3D vector
    
    # Make the object iterable
    def __iter__(self):
        return iter([self.x, self.y, self.z])
    
    # Get item (vector[0] returns x, etc.)
    def __getitem__(self, key):
        if key == 0 or key == 'x':
            return self.x
        elif key == 1 or key == 'y':
            return self.y
        elif key == 2 or key == 'z':
            return self.z
        raise IndexError("Vector index out of range")

# Using the Vector class with operator overloading
v1 = Vector(1, 2, 3)
v2 = Vector(4, 5, 6)

# String representation
print(str(v1))    # Vector(1, 2, 3)
print(repr(v1))   # Vector(x=1, y=2, z=3)

# Operator overloading
v3 = v1 + v2      # Vector(5, 7, 9)
v4 = v2 - v1      # Vector(3, 3, 3)
v5 = v1 * 2       # Vector(2, 4, 6)
v6 = 3 * v2       # Vector(12, 15, 18)

# Magnitude
mag = abs(v1)     # 3.7416573867739413

# Equality
print(v1 == Vector(1, 2, 3))  # True
print(v1 == v2)               # False

# Length
print(len(v1))    # 3

# Iteration
for component in v1:
    print(component)  # Prints 1, 2, 3 on separate lines

# Indexing
print(v1[0])      # 1
print(v1['y'])    # 2

Context Managers

Context managers implement the context management protocol using __enter__ and __exit__ methods, commonly used with the with statement for resource management.

# Implementing a context manager
class FileManager:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None
    
    # Called when entering the with block
    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file
    
    # Called when exiting the with block (even if an exception occurs)
    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.file:
            self.file.close()
        # Return True to suppress exceptions, False to propagate them
        return False

# Using the context manager
# with FileManager('example.txt', 'w') as f:
#     f.write('Hello, World!')
# # File is automatically closed when exiting the with block

# Another example with error handling
class DatabaseConnection:
    def __init__(self, host, user, password):
        self.host = host
        self.user = user
        self.password = password
        self.connection = None
    
    def __enter__(self):
        print(f"Connecting to database at {self.host}")
        # Simulate connection
        self.connection = {"host": self.host, "user": self.user, "connected": True}
        return self.connection
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Closing database connection")
        if self.connection:
            self.connection["connected"] = False
            self.connection = None
        
        if exc_type is not None:
            print(f"An error occurred: {exc_val}")
            # Handle specific exceptions if needed
            return False  # Propagate the exception

# Using the context manager
# try:
#     with DatabaseConnection("localhost", "admin", "password") as conn:
#         print(f"Connected: {conn['connected']}")
#         # Simulate an operation
#         print("Performing database operations")
#         # Optionally raise an exception to test error handling
#         # raise ValueError("Database query failed")
# except Exception as e:
#     print(f"Error caught outside context manager: {e}")
Back to Cheat Sheet