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}")