Table of Contents
- Understanding OOP: Core Principles
- Classes and Objects: The Building Blocks
- Encapsulation: Protecting Data and Behavior
- Inheritance: Reusing and Extending Code
- Polymorphism: Flexibility in Action
- Advanced OOP Concepts
- Practical Application: Building a Library Management System
- Common OOP Pitfalls and How to Avoid Them
- Conclusion
- References
1. Understanding OOP: Core Principles
OOP revolves around four key principles that promote modularity, reusability, and maintainability:
1.1 Encapsulation
Bundles data (attributes) and methods (functions that operate on data) into a single unit called a class. Restricts direct access to some attributes to prevent unintended modification (data hiding).
1.2 Inheritance
Allows a class (child) to inherit attributes and methods from another class (parent). Enables code reuse and the creation of hierarchical relationships.
1.3 Polymorphism
Literally means “many forms.” Allows objects of different classes to be treated as objects of a common superclass. Enables flexibility (e.g., different implementations of a method shared by multiple classes).
1.4 Abstraction
Hides complex implementation details and exposes only essential features. For example, a Car class might expose a start_engine() method without revealing how the engine ignites internally.
2. Classes and Objects: The Building Blocks
2.1 What is a Class?
A class is a blueprint or template for creating objects. It defines the attributes (data) and methods (functions) that objects of that class will have.
2.2 What is an Object?
An object is an instance of a class. Think of a class as a “blueprint” for a house, and an object as a specific house built from that blueprint.
2.3 Defining a Class in Python
In Python, classes are defined using the class keyword. Let’s create a simple Car class:
class Car:
# Class attribute (shared by all instances)
wheels = 4 # All cars have 4 wheels
# Constructor: Initializes instance attributes
def __init__(self, make, model, year):
self.make = make # Instance attribute
self.model = model # Instance attribute
self.year = year # Instance attribute
self.is_running = False # Default state
# Instance method: Defines behavior
def start_engine(self):
self.is_running = True
print(f"{self.make} {self.model} engine started.")
# Another instance method
def stop_engine(self):
self.is_running = False
print(f"{self.make} {self.model} engine stopped.")
2.4 Creating Objects (Instances)
To create an object, call the class name with arguments for the __init__ method (constructor):
# Create a Car object
my_car = Car(make="Toyota", model="Camry", year=2023)
# Access attributes
print(my_car.make) # Output: Toyota
print(my_car.wheels) # Output: 4 (class attribute)
# Call methods
my_car.start_engine() # Output: Toyota Camry engine started.
print(my_car.is_running) # Output: True
2.5 Types of Attributes and Methods
- Instance Attributes: Unique to each object (e.g.,
make,modelinCar). - Class Attributes: Shared by all instances (e.g.,
wheels = 4). - Instance Methods: Operate on instance data (require
selfas the first parameter). - Class Methods: Operate on class-level data (use
@classmethoddecorator, first parameter iscls). - Static Methods: Utility functions unrelated to instance/class data (use
@staticmethod, noself/cls).
Example of class and static methods:
class Car:
wheels = 4
@classmethod
def update_wheels(cls, new_wheels):
cls.wheels = new_wheels # Modifies class attribute
@staticmethod
def is_valid_year(year):
return 1900 <= year <= 2024 # No access to cls/self
# Usage
Car.update_wheels(6)
print(Car.wheels) # Output: 6
print(Car.is_valid_year(2023)) # Output: True
3. Encapsulation: Protecting Data and Behavior
Encapsulation ensures that sensitive data is hidden from outside the class and can only be accessed/modified via controlled methods (getters and setters). Python uses naming conventions to enforce encapsulation:
public: Accessible anywhere (no underscores, e.g.,name).protected: Intended for internal use (single underscore, e.g.,_age).private: Strictly internal (double underscore, e.g.,__balance; name-mangled to_ClassName__balance).
Example: A Bank Account with Encapsulation
class BankAccount:
def __init__(self, account_holder, initial_balance=0):
self.account_holder = account_holder # Public attribute
self.__balance = initial_balance # Private attribute (name-mangled)
# Getter: Safely access private balance
def get_balance(self):
return self.__balance
# Setter: Validate and modify balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
print(f"Deposited ${amount}. New balance: ${self.__balance}")
else:
print("Deposit amount must be positive.")
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew ${amount}. New balance: ${self.__balance}")
else:
print("Invalid withdrawal amount.")
# Usage
account = BankAccount("Alice", 1000)
print(account.get_balance()) # Output: 1000
account.deposit(500) # Output: Deposited $500. New balance: $1500
account.withdraw(300) # Output: Withdrew $300. New balance: $1200
# Trying to access private attribute directly (raises AttributeError)
print(account.__balance) # Error: 'BankAccount' object has no attribute '__balance'
4. Inheritance: Reusing and Extending Code
Inheritance lets you create a new class (child) that reuses, extends, or modifies the behavior of an existing class (parent). This avoids code duplication and builds hierarchies.
4.1 Basic Inheritance
Syntax: class ChildClass(ParentClass):
Example: A ElectricCar child class inheriting from Car:
class ElectricCar(Car):
def __init__(self, make, model, year, battery_capacity):
super().__init__(make, model, year) # Call parent constructor
self.battery_capacity = battery_capacity # New attribute
# Override parent method
def start_engine(self):
self.is_running = True
print(f"{self.make} {self.model} (Electric) started silently.")
# New method specific to ElectricCar
def charge_battery(self):
print(f"Charging {self.battery_capacity}kWh battery...")
# Usage
my_tesla = ElectricCar("Tesla", "Model 3", 2024, 75)
my_tesla.start_engine() # Output: Tesla Model 3 (Electric) started silently.
my_tesla.charge_battery() # Output: Charging 75kWh battery...
print(my_tesla.wheels) # Inherited class attribute: 4
4.2 Types of Inheritance
- Single Inheritance: Child inherits from one parent (most common).
- Multiple Inheritance: Child inherits from multiple parents (use
class Child(P1, P2):). - Multilevel Inheritance: Chain of inheritance (e.g.,
A -> B -> C). - Hierarchical Inheritance: Multiple children inherit from the same parent.
4.3 The super() Function
super() calls methods from the parent class. In multiple inheritance, Python uses the Method Resolution Order (MRO) to determine which parent method to call first (use ChildClass.__mro__ to view MRO).
5. Polymorphism: Flexibility in Action
Polymorphism allows objects of different classes to respond to the same method name in different ways.
5.1 Method Overriding
Child classes can override parent methods to provide specialized behavior.
Example: A Shape hierarchy with polymorphic area() methods:
class Shape:
def area(self):
raise NotImplementedError("Subclasses must implement area().")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius **2 # Override area()
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side** 2 # Override area()
# Polymorphic function
def print_area(shape):
print(f"Area: {shape.area()}")
# Usage
circle = Circle(radius=5)
square = Square(side=4)
print_area(circle) # Output: Area: 78.5
print_area(square) # Output: Area: 16
5.2 Method Overloading
Python does not support traditional method overloading (multiple methods with the same name but different parameters). Instead, use default arguments or *args to simulate it:
class Calculator:
def add(self, a, b=0, c=0):
return a + b + c
calc = Calculator()
print(calc.add(2)) # Output: 2 (a=2, b=0, c=0)
print(calc.add(2, 3)) # Output: 5 (a=2, b=3, c=0)
print(calc.add(2, 3, 4)) # Output: 9 (a=2, b=3, c=4)
6. Advanced OOP Concepts
6.1 Abstract Classes and Interfaces
An abstract class cannot be instantiated and requires subclasses to implement its abstract methods (enforced via abc module). It defines a “contract” for subclasses.
Example with abc.ABC (Abstract Base Class):
from abc import ABC, abstractmethod
class Vehicle(ABC): # Abstract base class
@abstractmethod
def start(self):
pass # No implementation
@abstractmethod
def stop(self):
pass
class Bike(Vehicle):
def start(self): # Must implement start()
print("Bike started with kick.")
def stop(self): # Must implement stop()
print("Bike stopped with brakes.")
# Usage
my_bike = Bike()
my_bike.start() # Output: Bike started with kick.
6.2 Composition vs. Inheritance
- Inheritance: “Is-a” relationship (e.g.,
ElectricCar is a Car). - Composition: “Has-a” relationship (e.g.,
Car has an Engine).
Prefer composition over inheritance when:
- You need flexibility (e.g., a car can swap engines).
- Inheritance hierarchies become too complex.
Example of composition:
class Engine:
def start(self):
print("Engine started.")
class Car:
def __init__(self):
self.engine = Engine() # Car "has-a" Engine
def start_engine(self):
self.engine.start() # Delegate to Engine
my_car = Car()
my_car.start_engine() # Output: Engine started.
6.3 Magic Methods (Dunder Methods)
Magic methods (e.g., __init__, __str__) have double underscores (__) and enable custom behavior for built-in operations (e.g., printing, arithmetic).
Common magic methods:
__init__: Constructor (initializes objects).__str__: String representation for users (usestr(obj)).__repr__: String representation for debugging (userepr(obj)).__add__: Defines behavior for+operator.
Example: A Vector class with magic methods:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # Enable vector addition
return Vector(self.x + other.x, self.y + other.y)
def __str__(self): # User-friendly string
return f"Vector({self.x}, {self.y})"
def __repr__(self): # Debug-friendly string
return f"Vector({self.x}, {self.y})"
# Usage
v1 = Vector(2, 3)
v2 = Vector(4, 5)
v3 = v1 + v2 # Uses __add__
print(v3) # Output: Vector(6, 8) (uses __str__)
7. Practical Application: Building a Library Management System
Let’s apply OOP principles to build a simple Library Management System with three classes: Book, Member, and Library.
Step 1: Define Classes
class Book:
def __init__(self, title, author, isbn):
self.title = title
self.author = author
self.isbn = isbn
self.is_available = True
def __str__(self):
return f"{self.title} by {self.author} (ISBN: {self.isbn})"
class Member:
def __init__(self, name, member_id):
self.name = name
self.member_id = member_id
self.borrowed_books = []
def borrow_book(self, book):
if book.is_available:
self.borrowed_books.append(book)
book.is_available = False
print(f"{self.name} borrowed: {book}")
else:
print(f"Sorry, {book.title} is unavailable.")
def return_book(self, book):
if book in self.borrowed_books:
self.borrowed_books.remove(book)
book.is_available = True
print(f"{self.name} returned: {book}")
class Library:
def __init__(self):
self.books = []
self.members = []
def add_book(self, book):
self.books.append(book)
print(f"Added to library: {book}")
def register_member(self, member):
self.members.append(member)
print(f"Registered member: {member.name} (ID: {member.member_id})")
Step 2: Use the System
# Create books
book1 = Book("1984", "George Orwell", "9780451524935")
book2 = Book("To Kill a Mockingbird", "Harper Lee", "9780061120084")
# Create library and add books
library = Library()
library.add_book(book1)
library.add_book(book2)
# Register member
member = Member("Alice", "M001")
library.register_member(member)
# Borrow and return books
member.borrow_book(book1) # Alice borrowed: 1984 by George Orwell (ISBN: 9780451524935)
member.borrow_book(book1) # Sorry, 1984 is unavailable.
member.return_book(book1) # Alice returned: 1984 by George Orwell (ISBN: 9780451524935)
8. Common OOP Pitfalls and How to Avoid Them
8.1 Mutable Default Arguments
Using mutable defaults (e.g., [], {}) in method parameters can lead to unexpected behavior, as the same object is reused across calls.
Bad:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] (unexpected!)
Fix: Use None as the default and initialize inside the method:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
8.2 Shallow vs. Deep Copy
Copying objects with mutable attributes (e.g., lists) using copy.copy() (shallow copy) may lead to unintended side effects. Use copy.deepcopy() for nested objects.
8.3 Overusing Inheritance
Prefer composition over inheritance to avoid rigid hierarchies (e.g., “god classes” with too many responsibilities).
9. Conclusion
Mastering OOP in Python unlocks the ability to build modular, scalable, and maintainable applications. By leveraging classes, objects, encapsulation, inheritance, and polymorphism, you can model complex real-world systems with clarity.
Remember: OOP is a tool, not a rule. Practice by refactoring procedural code into OOP, experiment with advanced concepts like magic methods, and always prioritize readability.