Note: Python OOP, Object-Oriented Programming Python, Python classes, Python objects, Python inheritance, Python polymorphism, Python encapsulation, Python abstraction, Python tutorial, learn Python, Python for beginners, advanced Python OOP.
This is where Python truly shines for building complex, scalable, and maintainable applications. We're diving into Object-Oriented Programming (OOP), a powerful programming paradigm that helps you structure your code by thinking about "objects" that combine data and behavior.
Mastering OOP is a game-changer. It allows you to model real-world concepts in your code, leading to more intuitive, reusable, and robust software.
Let's explore the core concepts of OOP in Python!
Core Concepts of OOP
Object-Oriented Programming (OOP) is a paradigm based on the concept of "objects," which can contain data (attributes) and code (methods). The main ideas behind OOP are:
Encapsulation: Bundling data and the methods that operate on that data within a single unit (an object). It hides the internal state of an object from the outside world and only exposes a public interface.
Inheritance: A mechanism where a new class (subclass or derived class) inherits properties and behaviors from an existing class (superclass or base class). This promotes code reusability.1
Polymorphism: The ability of objects of different classes to respond to the same method call in their own specific ways. It means "many forms."
Abstraction: Hiding complex implementation details and showing only the essential features of an object. It focuses on "what" an object does rather than "how" it does it.
These four pillars form the foundation of OOP and will guide our journey through this module.
Classes and Objects
In Python OOP, everything revolves around classes and objects.
A class is a blueprint or a template for creating objects. It defines a set of attributes (data) and methods (functions) that the objects3 created from this class will have. Think of it like a cookie cutter.
An object (or instance) is a concrete realization of a class. It's a specific "thing" created from the blueprint, with its own unique set of attribute values. Think of it as the actual cookie made from the cutter.
Example 1: Basic Class and Object (Beginner)
Let's create a simple Dog class and then make a Dog object.
Python
# filename: oop_basic_dog.py
# Define a class named Dog.
# A class is a blueprint for creating objects (instances).
class Dog:
# Attributes common to all dogs (class-level attributes)
species = "Canis familiaris"
# The __init__ method is a special constructor method.
# It's called automatically when a new object is created from the class.
# 'self' refers to the instance of the class being created.
def __init__(self, name, breed):
# Instance attributes: unique to each object
self.name = name # Assign the 'name' parameter to the object's 'name' attribute
self.breed = breed # Assign the 'breed' parameter to the object's 'breed' attribute
# A method (function defined within a class)
def bark(self):
"""Makes the dog bark."""
return f"{self.name} says Woof!"
# Create objects (instances) of the Dog class
# Fido and Buddy are now objects, each with their own 'name' and 'breed'.
fido = Dog("Fido", "Golden Retriever")
buddy = Dog("Buddy", "Labrador")
# Access attributes of the objects
print(f"Fido's name: {fido.name}")
print(f"Buddy's breed: {buddy.breed}")
print(f"Fido's species: {fido.species}") # Accessing a class-level attribute
# Call methods on the objects
print(fido.bark())
print(buddy.bark())
Example 2: Multiple Objects of the Same Class (Beginner to Intermediate)
Demonstrates that each object has its own state.
Python
# filename: oop_multiple_books.py
class Book:
"""A class to represent a book."""
def __init__(self, title, author, num_pages):
self.title = title
self.author = author
self.num_pages = num_pages
self.is_open = False # Default state for a new book
def open_book(self):
"""Changes the book's state to open."""
if not self.is_open:
self.is_open = True
return f"'{self.title}' is now open."
return f"'{self.title}' is already open."
def close_book(self):
"""Changes the book's state to closed."""
if self.is_open:
self.is_open = False
return f"'{self.title}' is now closed."
return f"'{self.title}' is already closed."
# Create multiple book objects
book1 = Book("The Hitchhiker's Guide to the Galaxy", "Douglas Adams", 193)
book2 = Book("1984", "George Orwell", 328)
# Interact with each object independently
print(book1.open_book())
print(book2.open_book())
print(f"Is '{book1.title}' open? {book1.is_open}")
print(f"Is '{book2.title}' open? {book2.is_open}")
print(book1.close_book())
print(f"Is '{book1.title}' open? {book1.is_open}")
print(f"Is '{book2.title}' open? {book2.is_open}") # book2 remains open
Example 3: Class vs. Instance Attributes (Intermediate)
Understanding the difference between attributes shared by all instances and attributes unique to each instance.
Python
# filename: oop_car_class_instance_attributes.py
class Car:
"""A class to represent a car."""
# Class attribute: shared by all instances of Car
# This value is the same for all cars unless explicitly changed for an instance.
number_of_wheels = 4
engine_type = "Internal Combustion" # Default engine type
def __init__(self, make, model, year, color):
# Instance attributes: unique to each car object
self.make = make
self.model = model
self.year = year
self.color = color
self.engine_started = False
def start_engine(self):
"""Starts the car's engine."""
if not self.engine_started:
self.engine_started = True
return f"The {self.color} {self.year} {self.make} {self.model} engine started."
return "Engine is already running."
# Create car objects
car1 = Car("Toyota", "Camry", 2020, "Blue")
car2 = Car("Honda", "Civic", 2022, "Red")
# Access instance attributes
print(f"Car 1: {car1.make} {car1.model}, Color: {car1.color}")
print(f"Car 2: {car2.make} {car2.model}, Color: {car2.color}")
# Access class attributes
print(f"All cars have {Car.number_of_wheels} wheels.")
print(f"Car 1 has {car1.number_of_wheels} wheels (accessed via instance).")
# Change a class attribute (affects all instances unless overridden)
Car.number_of_wheels = 6 # Imagine a futuristic car model
print(f"\nAfter changing class attribute:")
print(f"All cars now have {Car.number_of_wheels} wheels.")
print(f"Car 1 now has {car1.number_of_wheels} wheels.") # This also reflects the change
# You can also set an instance attribute that shadows a class attribute
car2.number_of_wheels = 8 # Custom wheels for car2
print(f"Car 2 now has {car2.number_of_wheels} wheels (instance override).")
print(f"Car 1 still has {car1.number_of_wheels} wheels (from class).")
print(f"Class still has {Car.number_of_wheels} wheels.")
Example 4: Object State and Behavior (Intermediate to Advanced)
A more complex example showing how methods modify object state.
Python
# filename: oop_account_management.py
class BankAccount:
"""A simple bank account class."""
def __init__(self, account_number, initial_balance=0.0):
self.account_number = account_number
self.balance = initial_balance
print(f"Account {self.account_number} created with balance: ${self.balance:.2f}")
def deposit(self, amount):
"""Deposits money into the account."""
if amount > 0:
self.balance += amount
return f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}"
return "Deposit amount must be positive."
def withdraw(self, amount):
"""Withdraws money from the account."""
if amount > 0:
if self.balance >= amount:
self.balance -= amount
return f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}"
return "Insufficient funds."
return "Withdrawal amount must be positive."
def get_balance(self):
"""Returns the current account balance."""
return f"Account {self.account_number} balance: ${self.balance:.2f}"
# Create bank account objects
account1 = BankAccount("12345", 1000.00)
account2 = BankAccount("67890") # Starts with default 0.0 balance
# Perform transactions
print(account1.deposit(200.50))
print(account1.withdraw(500.00))
print(account1.withdraw(800.00)) # Insufficient funds
print(account2.deposit(150.00))
print(account2.get_balance())
print(account1.get_balance()) # Check account1 again
Example 5: Combining Class and Object Attributes for Configuration (Advanced)
Using a class attribute for default configuration that can be overridden by instance attributes.
Python
# filename: oop_logger_config.py
class Logger:
"""A customizable logging utility."""
# Class attributes for default configuration
DEFAULT_LEVEL = "INFO"
DEFAULT_FORMAT = "[%(levelname)s] %(message)s"
DESTINATION_FILE = None # Default to console output
def __init__(self, name, level=None, format_str=None, output_file=None):
self.name = name
# Instance attributes can override class defaults
self.level = level if level is not None else Logger.DEFAULT_LEVEL
self.format_str = format_str if format_str is not None else Logger.DEFAULT_FORMAT
self.output_file = output_file if output_file is not None else Logger.DESTINATION_FILE
if self.output_file:
self._file_handle = open(self.output_file, 'a') # Open file in append mode
else:
self._file_handle = None
def _log(self, level, message):
"""Internal method to handle logging based on configured output."""
formatted_message = self.format_str % {'levelname': level, 'message': message}
if self._file_handle:
self._file_handle.write(formatted_message + '\n')
self._file_handle.flush() # Ensure it's written immediately
else:
print(formatted_message)
def info(self, message):
if self.level == "INFO" or self.level == "DEBUG":
self._log("INFO", message)
def debug(self, message):
if self.level == "DEBUG":
self._log("DEBUG", message)
def error(self, message):
self._log("ERROR", message)
def close(self):
"""Closes the file handle if one was opened."""
if self._file_handle:
self._file_handle.close()
print(f"Logger '{self.name}' file '{self.output_file}' closed.")
# Create loggers with different configurations
console_logger = Logger("ConsoleLogger")
file_logger = Logger("FileLogger", level="DEBUG", output_file="app.log")
custom_format_logger = Logger("CustomLogger", format_str="(%(levelname)s) -- %(message)s")
# Test loggers
print("--- Console Logger Output ---")
console_logger.info("Application started.")
console_logger.debug("This debug message will not show.") # Level is INFO by default
console_logger.error("Something went wrong!")
print("\n--- File Logger Output (check app.log) ---")
file_logger.info("User logged in.")
file_logger.debug("Database query took 10ms.") # This debug message WILL show
file_logger.error("Failed to connect to external service.")
print("\n--- Custom Format Logger Output ---")
custom_format_logger.info("Processing complete.")
# Change a class-level default (this affects new loggers, not existing ones)
Logger.DEFAULT_LEVEL = "DEBUG"
new_default_logger = Logger("NewDefaultLogger")
print("\n--- New Default Logger Output ---")
new_default_logger.info("New default logger info.")
new_default_logger.debug("New default logger debug.")
# Clean up: close file loggers
file_logger.close()
# Remove the log file if it exists for clean re-runs
import os
if os.path.exists("app.log"):
os.remove("app.log")
Attributes and Methods
Within a Python class, you'll find attributes and methods.
Attributes: These are variables that store data associated with an object. They define the state of an object.
Instance attributes: Unique to each object (e.g., name, age for a Person object).
Class attributes: Shared by all objects of a class (e.g., species for a Dog class).
Methods: These are functions defined within a class that operate on the object's data (attributes). They define the behavior of an object. The first parameter of any instance method is conventionally self.
Example 1: Basic Attributes and Methods (Beginner)
Python
# filename: oop_basic_attributes_methods.py
class Product:
"""A class to represent a product in an inventory."""
# Class attribute: shared by all products
currency = "USD"
def __init__(self, name, price, quantity):
# Instance attributes: unique to each product object
self.name = name
self.price = price
self.quantity = quantity
self.is_available = True if quantity > 0 else False
# Instance method: operates on the object's instance attributes
def get_info(self):
"""Returns a string with product information."""
return f"{self.name} ({self.quantity} in stock) - {self.price:.2f} {self.currency}"
def sell(self, amount):
"""Decreases the quantity by the amount sold."""
if self.quantity >= amount:
self.quantity -= amount
if self.quantity == 0:
self.is_available = False
return f"Sold {amount} of {self.name}. Remaining: {self.quantity}"
return f"Not enough {self.name} in stock to sell {amount}."
# Create product objects
item1 = Product("Laptop", 1200.00, 5)
item2 = Product("Mouse", 25.50, 20)
# Access attributes
print(f"{item1.name} price: {item1.price} {item1.currency}")
print(f"{item2.name} quantity: {item2.quantity}")
# Call methods
print(item1.get_info())
print(item2.sell(5))
print(item2.get_info())
print(item1.sell(6)) # Try to sell more than available
print(item1.get_info()) # Check status after failed sale
Example 2: Read-Only Attributes and Attributes with Default Values (Beginner to Intermediate)
Python
# filename: oop_read_only_default_attributes.py
class User:
"""A class to represent a user with a default status."""
def __init__(self, user_id, username, is_active=True):
# Instance attributes
self._user_id = user_id # Convention for "read-only" (see Encapsulation)
self.username = username
self.is_active = is_active # Attribute with a default value
def display_profile(self):
"""Displays user profile information."""
status = "active" if self.is_active else "inactive"
return f"User ID: {self._user_id}, Username: {self.username}, Status: {status}"
def deactivate_user(self):
"""Changes user status to inactive."""
if self.is_active:
self.is_active = False
return f"User '{self.username}' deactivated."
return f"User '{self.username}' is already inactive."
# Create user objects
user1 = User("U001", "Alice") # is_active defaults to True
user2 = User("U002", "Bob", is_active=False) # Explicitly set is_active to False
print(user1.display_profile())
print(user2.display_profile())
print(user1.deactivate_user())
print(user1.display_profile())
print(user2.deactivate_user()) # Already inactive
# Attempt to "change" a read-only attribute (convention, not enforced)
# user1._user_id = "U999" # This would work, but goes against convention
# print(user1.display_profile())
Example 3: Class Methods and Static Methods (Intermediate)
Beyond instance methods, classes can have class methods (operate on the class itself, using cls as the first argument) and static methods (don't operate on instance or class, just reside within the class's namespace).
Python
# filename: oop_class_static_methods.py
class Settings:
"""
A class to manage application settings.
Demonstrates class methods and static methods.
"""
_config = {} # A class attribute to store configuration
@classmethod
def load_defaults(cls):
"""
Class method: Operates on the class itself (cls).
Loads default settings into the _config dictionary.
"""
cls._config['theme'] = 'dark'
cls._config['notifications'] = True
cls._config['language'] = 'en'
print(f"Defaults loaded for {cls.__name__}.")
@classmethod
def get_setting(cls, key):
"""Class method: Gets a setting from the class-level config."""
return cls._config.get(key, "Setting not found")
@staticmethod
def validate_theme(theme):
"""
Static method: Does not operate on instance or class.
It's just a utility function logically grouped with the Settings class.
"""
valid_themes = ['dark', 'light', 'system']
return theme in valid_themes
def __init__(self, user_settings=None):
"""Instance method: Initializes an instance with potentially custom settings."""
self.user_settings = user_settings if user_settings is not None else {}
# Ensure defaults are loaded if not already
if not Settings._config:
Settings.load_defaults()
def get_user_preferred_theme(self):
"""Instance method: Gets the user's specific theme, falling back to class default."""
return self.user_settings.get('theme', Settings.get_setting('theme'))
# --- Using the Settings class ---
# Load defaults via the class method
Settings.load_defaults()
# Access class-level settings
print(f"Default theme: {Settings.get_setting('theme')}")
# Use the static method
print(f"Is 'dark' a valid theme? {Settings.validate_theme('dark')}")
print(f"Is 'blue' a valid theme? {Settings.validate_theme('blue')}")
# Create instances
user1_settings = Settings({"theme": "light"})
user2_settings = Settings() # Uses class defaults
print(f"User 1 preferred theme: {user1_settings.get_user_preferred_theme()}")
print(f"User 2 preferred theme: {user2_settings.get_user_preferred_theme()}")
# Modify a class setting (affects all *new* instances' defaults unless explicitly set)
print("\nChanging class default language...")
Settings._config['language'] = 'es' # Directly modify the class attribute
print(f"New default language: {Settings.get_setting('language')}")
# Old instances retain their original _config reference unless they retrieve it again
print(f"User 1's language (will reflect new default if not overridden): {Settings.get_setting('language')}")
# For more robust configuration, consider singleton patterns or more sophisticated config management.
Example 4: Attributes as Objects (Intermediate to Advanced)
Attributes themselves can be instances of other classes, showing composition.
Python
# filename: oop_composition_example.py
class Address:
"""A class to represent a physical address."""
def __init__(self, street, city, state, zip_code):
self.street = street
self.city = city
self.state = state
self.zip_code = zip_code
def get_full_address(self):
return f"{self.street}, {self.city}, {self.state} {self.zip_code}"
class ContactInfo:
"""A class to represent contact details, including an Address object."""
def __init__(self, email, phone, address):
self.email = email
self.phone = phone
self.address = address # This attribute is an instance of Address
def display_contact(self):
address_str = self.address.get_full_address() if self.address else "N/A"
return (f"Email: {self.email}\n"
f"Phone: {self.phone}\n"
f"Address: {address_str}")
class Person:
"""A class to represent a person, composed of ContactInfo."""
def __init__(self, name, age, contact_info):
self.name = name
self.age = age
self.contact_info = contact_info # This attribute is an instance of ContactInfo
def introduce(self):
return f"Hi, my name is {self.name}, I am {self.age} years old."
def get_contact_details(self):
return self.contact_info.display_contact()
# Create Address object
home_address = Address("123 Main St", "Anytown", "CA", "90210")
# Create ContactInfo object, passing the Address object
person_contact = ContactInfo("person@example.com", "555-123-4567", home_address)
# Create Person object, passing the ContactInfo object
alice = Person("Alice", 30, person_contact)
print(alice.introduce())
print("\n--- Alice's Contact Details ---")
print(alice.get_contact_details())
# Access nested attributes
print(f"\nAlice lives on: {alice.contact_info.address.street}")
Example 5: Dynamic Attribute Creation and Deletion (Advanced)
While generally discouraged for structural attributes, Python allows dynamic attribute manipulation.
Python
# filename: oop_dynamic_attributes.py
class DynamicObject:
"""
A class demonstrating dynamic attribute creation and deletion.
Use with caution, as it can make code harder to read and maintain.
"""
def __init__(self, initial_value):
self.fixed_attribute = initial_value
def add_attribute(self, name, value):
"""Adds a new attribute to the instance."""
setattr(self, name, value)
print(f"Added attribute '{name}' with value '{value}'")
def get_attribute(self, name):
"""Gets an attribute's value."""
return getattr(self, name, "Attribute not found")
def delete_attribute(self, name):
"""Deletes an attribute from the instance."""
if hasattr(self, name):
delattr(self, name)
print(f"Deleted attribute '{name}'")
else:
print(f"Attribute '{name}' does not exist.")
# Create an object
obj = DynamicObject(100)
print(f"Initial fixed_attribute: {obj.fixed_attribute}")
# Dynamically add attributes
obj.add_attribute("dynamic_field_1", "Hello")
obj.add_attribute("data_point_2", 42)
obj.new_property = "Pythonic" # Direct assignment also creates dynamic attributes
# Access dynamically created attributes
print(f"dynamic_field_1: {obj.dynamic_field_1}")
print(f"data_point_2: {obj.get_attribute('data_point_2')}")
print(f"new_property: {obj.new_property}")
# Attempt to get a non-existent attribute
print(f"non_existent_attribute: {obj.get_attribute('non_existent_attribute')}")
# Dynamically delete attributes
obj.delete_attribute("dynamic_field_1")
print(f"After deletion: {obj.get_attribute('dynamic_field_1')}") # Will show "Attribute not found"
obj.delete_attribute("another_non_existent") # Will indicate it doesn't exist
self Keyword
The self keyword is a crucial convention in Python class definitions. It's the first parameter of any instance method (including the __init__ constructor).
self refers to the instance of the class on which the method is being called.
It allows methods to access and modify the instance's attributes and call other instance methods.
When you call a method on an object (e.g., my_object.method()), Python automatically passes the object itself as the first argument to the method, which is received by the self parameter. You don't explicitly pass it during the call.
Example 1: Basic self Usage (Beginner)
Python
# filename: oop_self_basic.py
class Robot:
"""A simple robot class demonstrating 'self'."""
def __init__(self, name):
# 'self.name' refers to the 'name' attribute of the specific Robot object
self.name = name
self.energy = 100 # Initial energy for each robot
def say_name(self):
"""A method to make the robot introduce itself."""
# 'self.name' is used to access the 'name' attribute of the current object
return f"Beep boop, my name is {self.name}."
def charge(self, amount):
"""Charges the robot's energy."""
self.energy += amount # Modifying the 'energy' attribute of the current object
return f"{self.name} charged. Energy: {self.energy}%"
# Create robot objects
robot1 = Robot("Robo1")
robot2 = Robot("Unit-X")
# Call methods. 'self' is implicitly passed.
print(robot1.say_name())
print(robot2.say_name())
print(robot1.charge(20))
print(robot2.charge(50))
Example 2: self in Method Chaining (Beginner to Intermediate)
Methods can return self to allow for method chaining (calling multiple methods on the same object in a single line).
Python
# filename: oop_self_chaining.py
class Calculator:
"""A simple calculator to demonstrate method chaining."""
def __init__(self, initial_value=0):
self.result = initial_value
def add(self, num):
self.result += num
return self # Return self to allow chaining
def subtract(self, num):
self.result -= num
return self # Return self to allow chaining
def multiply(self, num):
self.result *= num
return self # Return self to allow chaining
def get_result(self):
return self.result
# Create a calculator object
calc = Calculator(10)
# Chain methods
final_result = calc.add(5).subtract(2).multiply(3).get_result()
print(f"Chained calculation result: {final_result}")
# Separate operations
calc2 = Calculator(5)
calc2.add(10)
calc2.subtract(3)
print(f"Separate operations result: {calc2.get_result()}")
Example 3: Passing self to Other Functions/Methods (Intermediate)
An object can pass itself (self) as an argument to another function or a method of another object.
Python
# filename: oop_self_passing.py
class Reporter:
"""A class responsible for reporting on objects."""
def generate_report(self, obj):
"""Generates a simple report about an object."""
# We can access attributes and call methods on the passed 'obj' (which is 'self' from another class)
report_str = f"--- Report for {obj.name} ---\n"
report_str += f"Current status: {obj.get_status()}\n"
report_str += f"Energy level: {obj.energy_level}\n"
return report_str
class Device:
"""A device class that can be reported on."""
def __init__(self, name, initial_energy):
self.name = name
self.energy_level = initial_energy
self.is_on = False
def turn_on(self):
self.is_on = True
return f"{self.name} is now ON."
def turn_off(self):
self.is_on = False
return f"{self.name} is now OFF."
def get_status(self):
return "ON" if self.is_on else "OFF"
def decrease_energy(self, amount):
self.energy_level -= amount
if self.energy_level < 0:
self.energy_level = 0
return f"{self.name} energy decreased. Current: {self.energy_level}"
# Create objects
reporter = Reporter()
phone = Device("Smartphone", 80)
laptop = Device("Laptop", 100)
print(phone.turn_on())
phone.decrease_energy(10)
print("\n--- Phone Report ---")
print(reporter.generate_report(phone)) # Pass the 'phone' object (which is 'self' from Device)
print("\n--- Laptop Report ---")
print(reporter.generate_report(laptop)) # Pass the 'laptop' object
Example 4: self and Class Methods (Intermediate to Advanced)
When using @classmethod, the first parameter is conventionally cls (for class), not self (for instance). This cls refers to the class itself.
Python
# filename: oop_self_classmethod.py
class Animal:
"""A base class for animals, showing instance and class methods."""
total_animals_created = 0 # Class attribute
def __init__(self, name):
self.name = name # Instance attribute
Animal.total_animals_created += 1 # Increment class attribute on each instance creation
def speak(self):
"""Instance method: each animal speaks uniquely."""
raise NotImplementedError("Subclasses must implement 'speak' method.")
@classmethod
def get_animal_count(cls):
"""
Class method: operates on the class itself (cls).
Accesses the class attribute 'total_animals_created'.
"""
return f"Total animals created: {cls.total_animals_created}"
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
# Create instances
dog1 = Dog("Max")
cat1 = Cat("Whiskers")
dog2 = Dog("Bella")
# Call instance methods
print(dog1.speak())
print(cat1.speak())
# Call class method via the class
print(Animal.get_animal_count())
# Call class method via an instance (it still operates on the class)
print(dog2.get_animal_count())
Example 5: self and Instance Variables in Complex Scenarios (Advanced)
Understanding self in methods that create or manage other objects.
Python
# filename: oop_self_complex.py
class Task:
"""Represents a single task."""
def __init__(self, name, priority="medium"):
self.name = name
self.priority = priority
self.completed = False
def mark_completed(self):
self.completed = True
return f"Task '{self.name}' marked as completed."
def __str__(self): # Special method for string representation
status = "Completed" if self.completed else "Pending"
return f"Task: {self.name} (Priority: {self.priority}, Status: {status})"
class Project:
"""
Manages a collection of tasks.
'self' is used extensively here to manage the project's state.
"""
def __init__(self, project_name):
self.project_name = project_name
self.tasks = [] # A list to hold Task objects
def add_task(self, task_name, priority="medium"):
# Create a new Task object and add it to *this* project's tasks list.
new_task = Task(task_name, priority)
self.tasks.append(new_task)
return f"Task '{task_name}' added to project '{self.project_name}'."
def get_task_by_name(self, task_name):
"""Finds a task within *this* project by name."""
for task in self.tasks:
if task.name == task_name:
return task
return None # Return None if not found
def complete_task(self, task_name):
"""Marks a task in *this* project as completed."""
task = self.get_task_by_name(task_name)
if task:
return task.mark_completed() # Call a method on the *Task* object
return f"Task '{task_name}' not found in '{self.project_name}'."
def get_project_summary(self):
"""Generates a summary of *this* project's tasks."""
summary = f"--- Project: {self.project_name} ---\n"
if not self.tasks:
summary += "No tasks yet.\n"
else:
for task in self.tasks:
summary += f" - {task}\n" # Uses Task's __str__ method
return summary
# Create a project
my_dev_project = Project("Website Redesign")
# Add tasks to the project (using 'self' within add_task)
print(my_dev_project.add_task("Design UI", "high"))
print(my_dev_project.add_task("Develop Backend API"))
print(my_dev_project.add_task("Write Documentation", "low"))
print("\n" + my_dev_project.get_project_summary())
# Complete a task (using 'self' to find and then modify the Task object)
print(my_dev_project.complete_task("Design UI"))
print(my_dev_project.complete_task("NonExistent Task"))
print("\n" + my_dev_project.get_project_summary())
__init__ Constructor Method
The __init__ method is a special method in Python classes, often referred to as the constructor. It's automatically called whenever a new instance (object) of the class is created.
- Its primary purpose is to initialize the attributes of the newly created object.
- It takes
selfas its first parameter, followed by any other parameters needed to set up the object's initial state. - It does not explicitly return a value (it implicitly returns
None).
Example 1: Basic __init__ (Beginner)
Python
# filename: oop_init_basic.py
class Dog:
"""A class for dogs with basic initialization."""
def __init__(self, name, age):
# 'self.name' and 'self.age' are instance attributes
self.name = name # Initialize the 'name' attribute with the provided 'name' argument
self.age = age # Initialize the 'age' attribute with the provided 'age' argument
print(f"A new dog named {self.name} (age {self.age}) has been created!")
def introduce(self):
return f"Woof! My name is {self.name} and I am {self.age} years old."
# When we create an object, __init__ is automatically called.
dog1 = Dog("Max", 3)
dog2 = Dog("Lucy", 5)
print(dog1.introduce())
print(dog2.introduce())
Example 2: __init__ with Default Values (Beginner to Intermediate)
Providing default values for parameters in __init__ makes your class more flexible.
Python
# filename: oop_init_defaults.py
class Car:
"""A car class with optional default values for attributes."""
def __init__(self, make, model, year, color="White", mileage=0):
self.make = make
self.model = model
self.year = year
self.color = color # Default to "White" if not provided
self.mileage = mileage # Default to 0 if not provided
self.engine_on = False
print(f"A {self.color} {self.year} {self.make} {self.model} created with {self.mileage} miles.")
def start_engine(self):
if not self.engine_on:
self.engine_on = True
return f"The {self.make} engine started."
return "Engine is already running."
# Create cars using default values
car1 = Car("Toyota", "Camry", 2020) # Color and mileage will be defaults
car2 = Car("Honda", "Civic", 2022, color="Blue") # Only color is specified
car3 = Car("Ford", "Mustang", 1969, "Red", 50000) # All values specified
print(car1.start_engine())
print(car2.color)
print(car3.mileage)
Example 3: __init__ with Validation (Intermediate)
You can add logic inside __init__ to validate input parameters, ensuring that objects are created in a valid state.
Python
# filename: oop_init_validation.py
class BankAccount:
"""A bank account that validates initial balance."""
def __init__(self, account_number, initial_balance):
if not isinstance(account_balance, (int, float)):
raise TypeError("Initial balance must be a number.")
if initial_balance < 0:
raise ValueError("Initial balance cannot be negative.")
self.account_number = account_number
self.balance = initial_balance
print(f"Account {self.account_number} created with balance: ${self.balance:.2f}")
def deposit(self, amount):
if amount > 0:
self.balance += amount
return f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}"
return "Deposit amount must be positive."
# Create valid accounts
account1 = BankAccount("ACC001", 1000.00)
account2 = BankAccount("ACC002", 0)
# Attempt to create invalid accounts (will raise errors)
try:
account3 = BankAccount("ACC003", -500.00) # Negative balance
except ValueError as e:
print(f"Error creating account3: {e}")
try:
account4 = BankAccount("ACC004", "abc") # Non-numeric balance
except TypeError as e:
print(f"Error creating account4: {e}")
Example 4: __init__ and Composition (Intermediate to Advanced)
When one object contains another object as an attribute, the __init__ method often takes an instance of the contained class as an argument.
Python
# filename: oop_init_composition.py
class Engine:
"""Represents a car engine."""
def __init__(self, engine_type, horsepower):
self.engine_type = engine_type
self.horsepower = horsepower
self.is_running = False
def start(self):
self.is_running = True
return f"{self.engine_type} engine started with {self.horsepower} HP."
class Car:
"""A car class that 'has-a' (composes) an Engine object."""
def __init__(self, make, model, engine): # 'engine' parameter expects an Engine object
self.make = make
self.model = model
self.engine = engine # Assign the Engine object as an attribute
self.speed = 0
def drive(self, speed):
if self.engine.is_running: # Accessing methods/attributes of the composed object
self.speed = speed
return f"{self.make} {self.model} is driving at {self.speed} km/h."
return "Engine is off. Cannot drive."
# Create an Engine object first
v6_engine = Engine("V6", 250)
electric_engine = Engine("Electric", 300)
# Create Car objects, passing the Engine objects
sedan = Car("Toyota", "Camry", v6_engine)
ev = Car("Tesla", "Model 3", electric_engine)
print(v6_engine.start())
print(sedan.drive(100))
print(ev.drive(0)) # Engine is off
print(electric_engine.start())
print(ev.drive(80))
print(f"Sedan engine type: {sedan.engine.engine_type}")
Example 5: __init__ and Class Attributes (Advanced)
__init__ can interact with class attributes, especially for tracking instances or global settings.
Python
# filename: oop_init_class_attributes.py
class ProductManager:
"""
Manages products and assigns unique IDs using a class attribute for the next ID.
"""
_next_id = 1000 # Class attribute to generate unique IDs
_all_products = {} # Class attribute to store all created product instances
def __init__(self, name, price):
self.product_id = ProductManager._next_id # Assign current ID
self.name = name
self.price = price
self.in_stock = True
ProductManager._next_id += 1 # Increment for the next product
ProductManager._all_products[self.product_id] = self # Store instance
print(f"Product '{self.name}' with ID {self.product_id} created.")
@classmethod
def get_product_by_id(cls, prod_id):
"""Class method to retrieve a product instance by its ID."""
return cls._all_products.get(prod_id)
@classmethod
def get_total_products(cls):
"""Class method to get the total number of products created."""
return len(cls._all_products)
def mark_out_of_stock(self):
self.in_stock = False
return f"Product {self.product_id} ({self.name}) is now out of stock."
# Create products
prod1 = ProductManager("Smartphone", 800)
prod2 = ProductManager("Headphones", 150)
prod3 = ProductManager("Charger", 30)
print(f"\nTotal products created: {ProductManager.get_total_products()}")
# Retrieve a product by ID
found_prod = ProductManager.get_product_by_id(1001)
if found_prod:
print(f"Found product: {found_prod.name}, Price: ${found_prod.price}")
print(found_prod.mark_out_of_stock())
# Attempt to retrieve a non-existent product
non_existent_prod = ProductManager.get_product_by_id(9999)
if non_existent_prod is None:
print("Product with ID 9999 not found.")
Encapsulation
Encapsulation is one of the fundamental principles of OOP. It involves bundling the data (attributes) and the methods (functions) that operate on that data into a single unit (an object), and restricting direct access to some of an object's components.
In Python, encapsulation is primarily achieved through convention rather than strict enforcement, using naming conventions to suggest how attributes should be accessed.
Public, Protected, and Private Attributes (Convention vs. Enforcement)
Python doesn't have true private keywords like Java or C++. Instead, it relies on naming conventions:
Public Attributes: Attributes (and methods) that can be accessed directly from outside the class. No special naming convention. (e.g., self.name)
Protected Attributes: Indicated by a single leading underscore (e.g., _protected_attribute). This is a convention telling developers that this attribute is intended for internal use within the class and its subclasses, but it can still be accessed from outside. It's a "gentle" warning.
Private Attributes: Indicated by two leading underscores (e.g., __private_attribute). Python performs "name mangling" on these attributes, meaning their names are changed by the interpreter to _ClassName__private_attribute. This makes them harder, but not impossible, to access from outside, serving as a stronger warning against direct external modification. It's still a convention, but with a slight enforcement mechanism.
Example 1: Public Attributes (Beginner)
Python
# filename: oop_encapsulation_public.py
class Character:
"""A simple character class with public attributes."""
def __init__(self, name, health):
self.name = name # Public attribute
self.health = health # Public attribute
self.level = 1 # Public attribute with default
def display_status(self):
"""Displays character status."""
return f"{self.name} - Health: {self.health}, Level: {self.level}"
# Create an object
hero = Character("Arthur", 100)
print(hero.display_status())
# Access and modify public attributes directly
hero.health -= 20
hero.level += 1
print(hero.display_status())
hero.weapon = "Sword" # You can even add new public attributes dynamically
print(f"Hero's weapon: {hero.weapon}")
Example 2: Protected Attributes (Convention) (Beginner to Intermediate)
Python
# filename: oop_encapsulation_protected.py
class DatabaseConnector:
"""
A class to manage database connection details.
_host is a protected attribute (convention).
"""
def __init__(self, host, username, password):
self._host = host # Protected attribute (convention: internal use)
self.username = username # Public attribute
self._password = password # Protected for sensitive data, but still accessible!
def connect(self):
"""Simulates connecting to the database."""
# Internal method can access _host
return f"Connecting to database at {self._host} with user {self.username}..."
# Create a connector object
db_conn = DatabaseConnector("localhost", "admin", "secure_pass")
print(db_conn.connect())
# Accessing protected attributes (possible, but discouraged)
print(f"Directly accessing protected host: {db_conn._host}")
print(f"Directly accessing protected password: {db_conn._password}") # Still accessible!
# Modifying protected attributes (possible, but discouraged)
db_conn._host = "new_host.com"
print(f"New host after direct modification: {db_conn._host}")
Example 3: "Private" Attributes (Name Mangling) (Intermediate)
Python
# filename: oop_encapsulation_private.py
class Employee:
"""
A class for employees with a "private" salary attribute.
__salary uses name mangling.
"""
def __init__(self, name, employee_id, salary):
self.name = name
self.employee_id = employee_id
self.__salary = salary # "Private" attribute due to double underscore
def display_info(self):
"""Displays employee information (can access private attributes)."""
return f"Name: {self.name}, ID: {self.employee_id}, Salary: ${self.__salary:.2f}"
def _calculate_bonus(self, percentage):
"""Protected method: For internal use."""
return self.__salary * percentage
# Create an employee object
emp = Employee("Jane Doe", "E001", 60000)
print(emp.display_info())
# Attempt to access __salary directly (will fail)
try:
print(f"Attempting to access salary directly: {emp.__salary}")
except AttributeError as e:
print(f"Error: {e}")
# Accessing via name mangling (possible but explicitly circumvents protection)
# The actual name becomes _ClassName__attributeName
print(f"Accessing mangled name: {emp._Employee__salary}")
# Call protected method (possible, but discouraged for external use)
print(f"Calculated bonus: ${emp._calculate_bonus(0.1):.2f}")
Getters and Setters (@property decorator)
While Python embraces the "we are all consenting adults" philosophy regarding attribute access, sometimes you need more control over how attributes are accessed or modified. This is where getters and setters come into play, often implemented using the built-in @property decorator.
A getter method is used to retrieve the value of an attribute.
A setter method is used to set or modify the value of an attribute,4 often including validation logic.
The @property decorator allows you to define methods that can be accessed like attributes, giving you control over attribute access without changing how external code interacts with your class.
Example 1: Basic Getter and Setter with @property (Beginner)
Python
# filename: oop_property_basic.py
class Circle:
"""A circle class with radius property."""
def __init__(self, radius):
self._radius = 0 # Initialize a "private" backing attribute
self.radius = radius # This will call the setter
@property # The getter method for 'radius'
def radius(self):
"""The radius property."""
return self._radius
@radius.setter # The setter method for 'radius'
def radius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Radius must be a number.")
if value < 0:
raise ValueError("Radius cannot be negative.")
self._radius = value # Set the value of the backing attribute
def calculate_area(self):
import math
return math.pi * self._radius**2
# Create a circle object
my_circle = Circle(5)
print(f"Initial radius: {my_circle.radius}") # Access getter like an attribute
print(f"Area: {my_circle.calculate_area():.2f}")
# Modify radius using the setter (like an attribute assignment)
my_circle.radius = 7
print(f"New radius: {my_circle.radius}")
print(f"New area: {my_circle.calculate_area():.2f}")
# Attempt invalid assignments (will raise errors due to setter validation)
try:
my_circle.radius = -2
except ValueError as e:
print(f"Error setting radius: {e}")
try:
my_circle.radius = "abc"
except TypeError as e:
print(f"Error setting radius: {e}")
Example 2: Read-Only Property (Beginner to Intermediate)
You can define a property with only a getter, making it read-only.
Python
# filename: oop_property_read_only.py
class Temperature:
"""A class to represent temperature in Celsius with a read-only Fahrenheit property."""
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if not isinstance(value, (int, float)):
raise TypeError("Temperature must be a number.")
self._celsius = value
@property # Read-only property
def fahrenheit(self):
"""Calculates Fahrenheit from Celsius."""
return (self._celsius * 9/5) + 32
# Create a temperature object
temp = Temperature(25)
print(f"Celsius: {temp.celsius}")
print(f"Fahrenheit: {temp.fahrenheit:.2f}") # Access read-only property
# Change Celsius, Fahrenheit updates automatically
temp.celsius = 0
print(f"New Celsius: {temp.celsius}")
print(f"New Fahrenheit: {temp.fahrenheit:.2f}")
# Attempt to set read-only property (will cause AttributeError)
try:
temp.fahrenheit = 100 # This will raise an AttributeError
except AttributeError as e:
print(f"Error setting fahrenheit directly: {e}")
Example 3: Property with Deletion (@property.deleter) (Intermediate)
Less common, but you can define a deleter method for a property.
Python
# filename: oop_property_deleter.py
class ConfigManager:
"""A simple config manager with a deletable setting."""
def __init__(self, default_value):
self._setting = default_value
@property
def setting(self):
"""The main setting property."""
return self._setting
@setting.setter
def setting(self, value):
if value is None:
raise ValueError("Setting cannot be None.")
self._setting = value
print(f"Setting updated to: {self._setting}")
@setting.deleter # The deleter method for 'setting'
def setting(self):
"""Deletes the setting and resets it to None."""
print("Deleting setting...")
self._setting = None
# Create a config manager
cfg = ConfigManager("initial_value")
print(f"Current setting: {cfg.setting}")
cfg.setting = "new_value"
print(f"Updated setting: {cfg.setting}")
# Delete the setting
del cfg.setting
print(f"Setting after deletion: {cfg.setting}")
# Try to set it back after deletion
cfg.setting = "restored_value"
print(f"Restored setting: {cfg.setting}")
Example 4: Using Properties for Derived Attributes (Intermediate to Advanced)
Properties are excellent for attributes that are calculated from other attributes.
Python
# filename: oop_property_derived.py
class Rectangle:
"""A rectangle class with derived properties for area and perimeter."""
def __init__(self, length, width):
self._length = 0
self._width = 0
self.length = length # Use setters to validate
self.width = width # Use setters to validate
@property
def length(self):
return self._length
@length.setter
def length(self, value):
if not isinstance(value, (int, float)) or value < 0:
raise ValueError("Length must be a non-negative number.")
self._length = value
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if not isinstance(value, (int, float)) or value < 0:
raise ValueError("Width must be a non-negative number.")
self._width = value
@property # Derived attribute: calculated from length and width
def area(self):
"""Calculates the area of the rectangle."""
return self.length * self.width # Accesses via getters, not _length directly
@property # Derived attribute: calculated from length and width
def perimeter(self):
"""Calculates the perimeter of the rectangle."""
return 2 * (self.length + self.width) # Accesses via getters
# Create a rectangle
rect = Rectangle(10, 5)
print(f"Rectangle Length: {rect.length}, Width: {rect.width}")
print(f"Area: {rect.area}") # Access like an attribute, but it's a calculated value
print(f"Perimeter: {rect.perimeter}") # Access like an attribute
# Change dimensions
rect.length = 12
rect.width = 6
print(f"\nNew Length: {rect.length}, New Width: {rect.width}")
print(f"New Area: {rect.area}")
print(f"New Perimeter: {rect.perimeter}")
# Invalid assignment
try:
rect.length = -5
except ValueError as e:
print(f"Error setting length: {e}")
Example 5: Complex Property Logic (Advanced)
Properties can encapsulate more complex logic, like lazy loading or caching.
Python
# filename: oop_property_complex.py
import time
class DataSet:
"""
A class representing a dataset that might be expensive to load.
Uses a property for lazy loading of data.
"""
def __init__(self, name, source_file=None):
self.name = name
self._source_file = source_file
self._data = None # This will store the loaded data
@property
def data(self):
"""
Loads data from the source file only when accessed for the first time
(lazy loading) and caches it.
"""
if self._data is None:
print(f"Loading data for '{self.name}' from {self._source_file} (this might take time)...")
time.sleep(2) # Simulate a long loading operation
if self._source_file:
# In a real scenario, you'd read from the file
self._data = [f"Item {i}" for i in range(5)] # Dummy data
else:
self._data = []
print("Data loaded!")
return self._data
@data.setter
def data(self, new_data):
"""Allows direct setting of data, overriding lazy loading for this instance."""
print(f"Manually setting data for '{self.name}'.")
self._data = new_data
@data.deleter
def data(self):
"""Clears the cached data, forcing a reload on next access."""
print(f"Clearing cached data for '{self.name}'.")
self._data = None
# Create a dataset object
my_dataset = DataSet("Customer Data", "customers.csv")
print(f"Dataset '{my_dataset.name}' created.")
# Data is not loaded yet
print("Accessing data for the first time...")
print(f"Data: {my_dataset.data}") # This will trigger the loading
print("\nAccessing data again (should be fast, already loaded)...")
print(f"Data: {my_dataset.data}") # This will use the cached data
# Manually set data
my_dataset.data = ["New A", "New B"]
print(f"\nData after manual set: {my_dataset.data}")
# Delete cached data, forcing a reload
del my_dataset.data
print("\nAccessing data after deletion (will reload)...")
print(f"Data: {my_dataset.data}") # Triggers reload
Inheritance
Inheritance is a cornerstone of OOP that allows a new class (the subclass or derived class) to inherit properties (attributes) and behaviors (methods) from an existing class (the superclass or base class). This promotes code reusability and establishes an "is-a" relationship (e.g., a Dog is a Animal).
Single Inheritance
A class inherits from only one base class.
Example 1: Basic Single Inheritance (Beginner)
Python
# filename: oop_single_inheritance_basic.py
class Animal:
"""Base class for all animals."""
def __init__(self, name):
self.name = name
self.alive = True
def eat(self):
return f"{self.name} is eating."
def sleep(self):
return f"{self.name} is sleeping."
class Dog(Animal): # Dog inherits from Animal
"""Derived class representing a Dog."""
def __init__(self, name, breed):
# Call the constructor of the base class (Animal)
# to initialize inherited attributes like 'name'.
super().__init__(name)
self.breed = breed # Add dog-specific attribute
def bark(self):
return f"{self.name} the {self.breed} says Woof!"
# Create objects
generic_animal = Animal("Leo")
print(generic_animal.eat())
my_dog = Dog("Buddy", "Golden Retriever")
# Dog object can use inherited methods from Animal
print(my_dog.eat())
print(my_dog.sleep())
# Dog object can use its own specific methods
print(my_dog.bark())
print(f"{my_dog.name} is a {my_dog.breed}.")
Example 2: Overriding Methods in Single Inheritance (Beginner to Intermediate)
A subclass can provide its own implementation of a method that is already defined in its superclass.
Python
# filename: oop_single_inheritance_override.py
class Vehicle:
"""Base class for vehicles."""
def __init__(self, brand, model):
self.brand = brand
self.model = model
def start(self):
return f"The {self.brand} {self.model} engine starts."
def drive(self):
return f"The {self.brand} {self.model} is driving."
class Car(Vehicle):
"""Derived class for Cars."""
def __init__(self, brand, model, num_doors):
super().__init__(brand, model)
self.num_doors = num_doors
# Override the drive method
def drive(self):
return f"The {self.brand} {self.model} with {self.num_doors} doors is cruising on the road."
class Bicycle(Vehicle):
"""Derived class for Bicycles."""
def __init__(self, brand, model, type_bike):
super().__init__(brand, model)
self.type_bike = type_bike
# Override the start method (a bicycle doesn't really "start" an engine)
def start(self):
return f"The {self.brand} {self.model} {self.type_bike} is ready to pedal!"
# Also override the drive method
def drive(self):
return f"The {self.brand} {self.model} {self.type_bike} is being pedaled."
# Create objects
my_car = Car("Toyota", "Corolla", 4)
my_bike = Bicycle("Trek", "FX 2", "Hybrid")
generic_vehicle = Vehicle("Generic", "Mode X")
print(generic_vehicle.start())
print(generic_vehicle.drive())
print(my_car.start()) # Inherited from Vehicle
print(my_car.drive()) # Overridden in Car
print(my_bike.start()) # Overridden in Bicycle
print(my_bike.drive()) # Overridden in Bicycle
Example 3: Extending Functionality in Single Inheritance (Intermediate)
Adding new methods and attributes to the subclass.
Python
# filename: oop_single_inheritance_extend.py
class Employee:
"""Base class for all employees."""
def __init__(self, employee_id, name, department):
self.employee_id = employee_id
self.name = name
self.department = department
def get_details(self):
return f"ID: {self.employee_id}, Name: {self.name}, Dept: {self.department}"
class Manager(Employee):
"""Derived class for Managers, extending Employee."""
def __init__(self, employee_id, name, department, team_size):
super().__init__(employee_id, name, department)
self.team_size = team_size # New attribute specific to Manager
# New method specific to Manager
def manage_team(self):
return f"{self.name} is managing a team of {self.team_size} people in {self.department}."
# Override and extend existing method
def get_details(self):
# Call the base class method and add more information
base_details = super().get_details()
return f"{base_details}, Role: Manager, Team Size: {self.team_size}"
# Create objects
emp1 = Employee("E001", "Alice", "Sales")
mgr1 = Manager("M001", "Bob", "Marketing", 10)
print(emp1.get_details())
print(mgr1.get_details()) # Uses the overridden and extended method
print(mgr1.manage_team()) # Uses the new method
Example 4: Private Attributes and Inheritance (Intermediate to Advanced)
Demonstrates how name mangling affects "private" attributes in inheritance.
Python
# filename: oop_single_inheritance_private_attr.py
class BaseClass:
def __init__(self):
self.public_attr = "I am public"
self._protected_attr = "I am protected"
self.__private_attr = "I am private" # Name-mangled to _BaseClass__private_attr
def display_base_private(self):
return f"BaseClass can access its private: {self.__private_attr}"
class DerivedClass(BaseClass):
def __init__(self):
super().__init__()
self.derived_public_attr = "I am derived public"
def display_derived_attrs(self):
# Can access public and protected attributes
print(f"Public from base: {self.public_attr}")
print(f"Protected from base: {self._protected_attr}")
# Cannot directly access __private_attr from BaseClass without name mangling
try:
print(f"Attempting direct access to private from base: {self.__private_attr}")
except AttributeError as e:
print(f"Error accessing private from base directly in derived: {e}")
# Accessing the mangled name is possible, but against principles
print(f"Accessing mangled name in derived: {self._BaseClass__private_attr}")
# Create objects
base_obj = BaseClass()
derived_obj = DerivedClass()
print(base_obj.display_base_private())
print("\n--- Derived Class Attributes ---")
derived_obj.display_derived_attrs()
# Verify mangling
print(f"\nBase obj dict keys: {base_obj.__dict__.keys()}")
print(f"Derived obj dict keys: {derived_obj.__dict__.keys()}")
Example 5: Multiple Inheritance Levels (Advanced)
A class can inherit from a class that itself inherits from another class, forming an inheritance chain.
Python
# filename: oop_single_inheritance_levels.py
class Organism:
"""Grandparent class."""
def __init__(self, name):
self.name = name
self.is_living = True
def grow(self):
return f"{self.name} is growing."
class Mammal(Organism):
"""Parent class, inherits from Organism."""
def __init__(self, name, fur_color):
super().__init__(name)
self.fur_color = fur_color
self.has_warm_blood = True
def give_birth(self):
return f"{self.name} gives live birth."
class Human(Mammal):
"""Child class, inherits from Mammal (which inherits from Organism)."""
def __init__(self, name, fur_color, occupation):
super().__init__(name, fur_color) # Calls Mammal's __init__
self.occupation = occupation
self.has_consciousness = True
def think(self):
return f"{self.name} is thinking about {self.occupation}."
# Override grow method
def grow(self):
return f"{self.name} is growing and learning!"
# Create an object of the deepest class
person = Human("Alice", "Brown", "Software Engineer")
# Access attributes from all levels of inheritance
print(f"Name: {person.name}") # From Organism
print(f"Fur color: {person.fur_color}") # From Mammal
print(f"Occupation: {person.occupation}") # From Human
print(f"Is living: {person.is_living}") # From Organism
print(f"Has warm blood: {person.has_warm_blood}") # From Mammal
# Call methods from all levels, including overridden ones
print(person.grow()) # Overridden in Human
print(person.eat()) # From Organism
print(person.give_birth()) # From Mammal
print(person.think()) # From Human
Multiple Inheritance
Multiple Inheritance allows a class to inherit from more than one base class. This can be powerful but also complex, as it introduces potential issues like the "diamond problem" (where a method is defined in two parent classes that share a common ancestor). Python addresses this using the Method Resolution Order (MRO).
Example 1: Basic Multiple Inheritance (Beginner)
Python
# filename: oop_multiple_inheritance_basic.py
class Flyer:
"""A mixin class for objects that can fly."""
def fly(self):
return "I can fly!"
class Swimmer:
"""A mixin class for objects that can swim."""
def swim(self):
return "I can swim!"
class Duck(Flyer, Swimmer): # Duck inherits from both Flyer and Swimmer
"""A duck that can fly and swim."""
def __init__(self, name):
self.name = name
def quack(self):
return f"{self.name} says Quack!"
# Create a Duck object
donald = Duck("Donald")
print(donald.quack())
print(donald.fly()) # Inherited from Flyer
print(donald.swim()) # Inherited from Swimmer
Example 2: Attribute Resolution in Multiple Inheritance (Beginner to Intermediate)
When attributes or methods with the same name exist in multiple parent classes, Python follows the MRO to determine which one to use.
Python
# filename: oop_multiple_inheritance_attribute_resolution.py
class A:
def __init__(self):
self.value = "Value from A"
def show_value(self):
return f"A: {self.value}"
class B:
def __init__(self):
self.value = "Value from B"
self.another_value = "Only in B"
def show_value(self):
return f"B: {self.value}"
class C(A, B): # Inherits from A first, then B
def __init__(self):
super().__init__() # Calls A's __init__ due to MRO
# To call B's init too, you'd do:
# B.__init__(self) # Explicit call for B's attributes
# Or, more correctly, use super() on all parents for cooperative inheritance
# super().__init__() will correctly call A's init, and then A's super() call
# will correctly call B's init if B is in A's MRO.
# This is where MRO and cooperative super() get complex.
# For simplicity, for distinct attributes, call them explicitly or rely on MRO for same-named ones.
# Let's refine this example.
# Correct way to handle multiple initializations using cooperative super()
# if your __init__ methods are designed for it (they don't re-initialize common base classes)
# For this simple example, let's keep it direct for clarity on attribute lookup.
# If A and B don't have common ancestors, calling super() once handles the MRO.
# If they do, then super() becomes key for diamond problem.
# Let's ensure this example clearly shows attribute lookup by MRO.
# The 'value' attribute will come from the first parent in MRO that defines it.
# By default, super().__init__() in C(A, B) will only call A's __init__.
# So 'value' will be "Value from A".
pass # A's init already called by super().__init__()
class D(B, A): # Inherits from B first, then A
def __init__(self):
super().__init__() # Calls B's __init__ due to MRO
# Create objects
obj_c = C()
obj_d = D()
print(f"C's value: {obj_c.value}") # Prints "Value from A" because A is first in MRO
print(f"C's show_value(): {obj_c.show_value()}") # Calls A's show_value
# D's MRO is (D, B, A, object)
print(f"D's value: {obj_d.value}") # Prints "Value from B" because B is first in MRO
print(f"D's show_value(): {obj_d.show_value()}") # Calls B's show_value
print(f"D's another_value: {obj_d.another_value}") # Accessible because it's in B
Example 3: Diamond Problem and MRO (Intermediate to Advanced)
The "diamond problem" occurs when a class inherits from two classes that have a common ancestor. MRO dictates the order in which methods are searched.
Python
# filename: oop_multiple_inheritance_diamond.py
class LivingThing:
"""Common ancestor."""
def __init__(self, name):
self.name = name
def identify(self):
return f"{self.name} is a living thing."
class Animal(LivingThing):
"""Parent 1, inherits from LivingThing."""
def __init__(self, name):
super().__init__(name)
self.can_move = True
def identify(self):
return f"{self.name} is an animal."
def locomotion(self):
return f"{self.name} moves."
class Plant(LivingThing):
"""Parent 2, inherits from LivingThing."""
def __init__(self, name):
super().__init__(name)
self.can_photosynthesize = True
def identify(self):
return f"{self.name} is a plant."
def nutrition(self):
return f"{self.name} makes its own food."
class Organism(Animal, Plant): # Inherits from Animal and Plant
"""Child class, facing the diamond problem."""
def __init__(self, name):
# The crucial part: super() calls methods according to MRO.
# This single super().__init__() will correctly call LivingThing's __init__ only once.
super().__init__(name)
self.is_complex = True
def describe(self):
return f"{self.identify()} and is complex: {self.is_complex}. It {self.locomotion()} and {self.nutrition()}"
# Create an object
complex_organism = Organism("HybridCreature")
# Check MRO (important for understanding method resolution)
print(f"MRO for Organism: {Organism.__mro__}")
# Output: (<class '__main__.Organism'>, <class '__main__.Animal'>, <class '__main__.Plant'>, <class '__main__.LivingThing'>, <class 'object'>)
# Due to MRO, Organism's identify() will resolve to Animal's identify() first.
print(complex_organism.identify())
print(complex_organism.locomotion()) # From Animal
print(complex_organism.nutrition()) # From Plant
print(complex_organism.describe())
Method Resolution Order (MRO)
MRO is the order in which Python searches for methods and attributes in a class hierarchy, especially crucial in multiple inheritance. Python uses a C3 linearization algorithm to determine the MRO, ensuring that a method from a subclass is preferred over a superclass method, and that methods from earlier base classes in the class definition are preferred over later ones.
You can check the MRO of any class using:
ClassName.__mro__ attribute
ClassName.mro() method
help(ClassName)
Example 1: MRO in Single Inheritance (Beginner)
Python
# filename: oop_mro_single.py
class Grandparent:
pass
class Parent(Grandparent):
pass
class Child(Parent):
pass
print(f"Child MRO: {Child.__mro__}")
# Output: (<class '__main__.Child'>, <class '__main__.Parent'>, <class '__main__.Grandparent'>, <class 'object'>)
Example 2: MRO in Simple Multiple Inheritance (Beginner to Intermediate)
Python
# filename: oop_mro_multiple_simple.py
class Base1:
pass
class Base2:
pass
class Derived(Base1, Base2): # Base1 is checked before Base2
pass
print(f"Derived MRO: {Derived.__mro__}")
# Output: (<class '__main__.Derived'>, <class '__main__.Base1'>, <class '__main__.Base2'>, <class 'object'>)
Example 3: MRO in Diamond Problem (Intermediate)
This reiterates the diamond problem MRO.
Python
# filename: oop_mro_diamond_revisited.py
class A:
def method(self):
print("Method from A")
class B(A):
def method(self):
print("Method from B")
class C(A):
def method(self):
print("Method from C")
class D(B, C): # Inherits from B, then C
# No method here, so it will look in B first, then C, then A
pass
class E(C, B): # Inherits from C, then B
# No method here, so it will look in C first, then B, then A
pass
print(f"D MRO: {D.__mro__}")
# Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
# When D.method() is called, it will execute B's method.
print(f"E MRO: {E.__mro__}")
# Output: (<class '__main__.E'>, <class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
# When E.method() is called, it will execute C's method.
d_obj = D()
d_obj.method() # Calls B's method
e_obj = E()
e_obj.method() # Calls C's method
super() Function
The super() function provides a way to call a method or access an attribute from a parent (superclass) in the MRO. It's especially useful in inheritance for:
Calling the parent class's __init__ method to ensure parent attributes are initialized.
Calling an overridden method in the parent class from the subclass.
Enabling cooperative multiple inheritance by ensuring that all __init__ methods in the MRO are called.
Example 1: super() in __init__ (Beginner)
Python
# filename: oop_super_init.py
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
print(f"Person __init__ called for {name}")
def introduce(self):
return f"Hi, my name is {self.name} and I am {self.age} years old."
class Student(Person):
def __init__(self, name, age, student_id):
# Call the __init__ method of the parent class (Person)
super().__init__(name, age)
self.student_id = student_id # Student-specific attribute
print(f"Student __init__ called for {name}")
def get_student_info(self):
return f"{self.introduce()} My student ID is {self.student_id}."
# Create objects
person1 = Person("Alice", 30)
student1 = Student("Bob", 20, "S12345")
print(person1.introduce())
print(student1.get_student_info())
Example 2: super() for Calling Overridden Methods (Beginner to Intermediate)
Python
# filename: oop_super_override_method.py
class Vehicle:
def __init__(self, type):
self.type = type
def move(self):
return f"The {self.type} is moving."
class Car(Vehicle):
def __init__(self, type, color):
super().__init__(type)
self.color = color
def move(self):
# Call the parent's move method and extend its behavior
base_move = super().move()
return f"{base_move} on wheels."
class Boat(Vehicle):
def __init__(self, type, length):
super().__init__(type)
self.length = length
def move(self):
# Call the parent's move method and extend its behavior
base_move = super().move()
return f"{base_move} on water."
# Create objects
generic_vehicle = Vehicle("unknown")
car = Car("car", "red")
boat = Boat("boat", 10)
print(generic_vehicle.move())
print(car.move()) # Uses overridden method that calls super()
print(boat.move()) # Uses overridden method that calls super()
Example 3: super() with Multiple Inheritance and MRO (Intermediate)
This is where super() truly shines in coordinating initializers in complex hierarchies.
Python
# filename: oop_super_multiple_inheritance.py
class MixinA:
def __init__(self):
print("Initializing MixinA")
# Call the next method in the MRO
super().__init__()
self.prop_a = "from A"
class MixinB:
def __init__(self):
print("Initializing MixinB")
# Call the next method in the MRO
super().__init__()
self.prop_b = "from B"
class BaseClass:
def __init__(self):
print("Initializing BaseClass")
# Call object's __init__ (the end of the MRO chain)
super().__init__()
self.prop_base = "from Base"
class CombinedClass(MixinA, MixinB, BaseClass):
def __init__(self):
print("Initializing CombinedClass")
# This single super().__init__() will traverse the MRO correctly:
# CombinedClass -> MixinA -> MixinB -> BaseClass -> object
super().__init__()
self.prop_combined = "from Combined"
# Create an object
obj = CombinedClass()
print("\n--- Object Attributes ---")
print(f"prop_a: {obj.prop_a}")
print(f"prop_b: {obj.prop_b}")
print(f"prop_base: {obj.prop_base}")
print(f"prop_combined: {obj.prop_combined}")
print(f"\nMRO for CombinedClass: {CombinedClass.__mro__}")
Example 4: super() for Class Methods (Intermediate to Advanced)
super() can also be used to call class methods from parent classes.
Python
# filename: oop_super_classmethod.py
class Logger:
_log_level = "INFO"
@classmethod
def set_log_level(cls, level):
cls._log_level = level
print(f"Log level set to: {cls._log_level} in {cls.__name__}")
@classmethod
def log_message(cls, message):
if cls._log_level == "INFO":
print(f"[{cls._log_level}] {message}")
class DebugLogger(Logger):
_log_level = "DEBUG" # Override class attribute
@classmethod
def log_message(cls, message):
# Call parent's log_message if still needed, or completely override
if cls._log_level == "DEBUG":
print(f"[{cls._log_level}] DEBUG: {message}")
else:
# Optionally, call the superclass method
super().log_message(message)
# Test loggers
Logger.set_log_level("INFO")
Logger.log_message("This is a general info message.")
DebugLogger.set_log_level("INFO") # Setting level via subclass
DebugLogger.log_message("This is a debug message, will use super's log_message if level is INFO.")
DebugLogger.set_log_level("DEBUG")
DebugLogger.log_message("This is a debug message, will use DebugLogger's own method.")
Example 5: super() and Cooperative Inheritance of Methods (Advanced)
For methods other than __init__, super() helps in building a chain of calls across the inheritance hierarchy.
Python
# filename: oop_super_cooperative_methods.py
class ComponentA:
def process(self, data):
print("ComponentA processing...")
processed_data = data + "_A"
# Call the next process method in the MRO
return super().process(processed_data) if hasattr(super(), 'process') else processed_data
class ComponentB:
def process(self, data):
print("ComponentB processing...")
processed_data = data + "_B"
# Call the next process method in the MRO
return super().process(processed_data) if hasattr(super(), 'process') else processed_data
class FinalProcessor(ComponentA, ComponentB):
def process(self, data):
print("FinalProcessor processing...")
# Start the chain of super() calls
final_data = super().process(data + "_Final")
return f"Completely Processed: {final_data}"
class SimpleProcessor(ComponentA): # Only inherits from ComponentA
def process(self, data):
print("SimpleProcessor processing...")
# This will call ComponentA's process, which will then call object's process (which does nothing)
return super().process(data + "_Simple")
# Create objects
fp = FinalProcessor()
sp = SimpleProcessor()
# Process data
print("\n--- FinalProcessor Chain ---")
result_fp = fp.process("Initial")
print(result_fp)
print(f"MRO for FinalProcessor: {FinalProcessor.__mro__}")
print("\n--- SimpleProcessor Chain ---")
result_sp = sp.process("Initial")
print(result_sp)
print(f"MRO for SimpleProcessor: {SimpleProcessor.__mro__}")
Polymorphism
Polymorphism means "many forms." In OOP, it refers to the ability of different classes to respond to the same method call in their own specific ways. This allows you to write more generic and flexible code that can work with objects of various types, as long as they provide the required interface (i.e., have the same method names).
Method Overriding
Method overriding is a specific form of polymorphism where a subclass provides a unique implementation for a method that is already defined in its superclass.
Example 1: Basic Method Overriding (Beginner)
Python
# filename: oop_polymorphism_override_basic.py
class Animal:
def __init__(self, name):
self.name = name
def make_sound(self):
"""Generic sound method."""
return "Generic animal sound"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
def make_sound(self): # Overriding the make_sound method
return "Woof! Woof!"
class Cat(Animal):
def __init__(self, name, color):
super().__init__(name)
self.color = color
def make_sound(self): # Overriding the make_sound method
return "Meow!"
# Create objects of different animal types
animal1 = Animal("Leo")
dog1 = Dog("Buddy", "Labrador")
cat1 = Cat("Whiskers", "Tabby")
# Call the same method on different objects
print(animal1.make_sound())
print(dog1.make_sound()) # Dog's version is called
print(cat1.make_sound()) # Cat's version is called
# Demonstrate polymorphism in a loop
animals = [animal1, dog1, cat1]
print("\n--- Polymorphic Animal Sounds ---")
for animal in animals:
print(f"{animal.name}: {animal.make_sound()}")
Example 2: Overriding and Extending Parent Method (Beginner to Intermediate)
Python
# filename: oop_polymorphism_override_extend.py
class Employee:
def __init__(self, name, base_salary):
self.name = name
self.base_salary = base_salary
def calculate_pay(self):
return self.base_salary
class Manager(Employee):
def __init__(self, name, base_salary, bonus):
super().__init__(name, base_salary)
self.bonus = bonus
def calculate_pay(self): # Overriding and extending
base_pay = super().calculate_pay() # Call parent's method
return base_pay + self.bonus
class Contractor(Employee):
def __init__(self, name, hourly_rate, hours_worked):
# Contractor might not have a base_salary in the same sense
# So we can pass 0 or define a dummy value to satisfy Employee's __init__
super().__init__(name, 0) # Initialize with a dummy base_salary for parent
self.hourly_rate = hourly_rate
self.hours_worked = hours_worked
def calculate_pay(self): # Completely overriding
return self.hourly_rate * self.hours_worked
# Create objects
emp = Employee("Alice", 50000)
mgr = Manager("Bob", 70000, 10000)
cont = Contractor("Charlie", 50, 160)
# Demonstrate polymorphism
employees = [emp, mgr, cont]
print("--- Calculating Pay ---")
for person in employees:
print(f"{person.name}'s pay: ${person.calculate_pay():.2f}")
Duck Typing
Duck Typing is a concept in Python (and other dynamically-typed languages) that is closely related to polymorphism. It means: "If it walks like a duck and quacks like a duck, then it is a duck."
In Python, the type of an object doesn't matter as much as what methods it has. If two different objects have the same method signature, you can treat them interchangeably within code that calls that method, even if they don't share a common inheritance hierarchy.
Example 1: Basic Duck Typing (Beginner)
Python
# filename: oop_duck_typing_basic.py
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Duck:
def speak(self):
return "Quack!"
# A function that doesn't care about the object's class, only if it can 'speak'
def make_animal_speak(animal):
"""Takes any 'animal' object and makes it speak."""
print(animal.speak())
# Create different animal objects
dog_obj = Dog()
cat_obj = Cat()
duck_obj = Duck()
# Pass different objects to the same function
make_animal_speak(dog_obj)
make_animal_speak(cat_obj)
make_animal_speak(duck_obj)
# What if an object doesn't 'speak'?
class Car:
def drive(self):
return "Vroom!"
car_obj = Car()
try:
make_animal_speak(car_obj) # This will raise an AttributeError
except AttributeError as e:
print(f"Error: {e} - Car cannot speak!")
Example 2: Duck Typing with Multiple Methods (Beginner to Intermediate)
Python
# filename: oop_duck_typing_multiple_methods.py
class Report:
def generate(self):
return "Generating generic report..."
class PDFReport:
def generate(self):
return "Generating PDF report..."
def export(self):
return "Exporting to PDF file..."
class HTMLReport:
def generate(self):
return "Generating HTML report..."
def render_web(self):
return "Rendering in browser..."
def process_report(report_obj):
"""Processes any report object that has a 'generate' method."""
print(report_obj.generate())
def export_if_possible(report_obj):
"""Checks if the report object can 'export' before calling it."""
if hasattr(report_obj, 'export'): # Check for method existence using hasattr()
print(report_obj.export())
else:
print(f"Report of type {type(report_obj).__name__} cannot be exported.")
# Create various report objects
generic_report = Report()
pdf_report = PDFReport()
html_report = HTMLReport()
process_report(generic_report)
process_report(pdf_report)
process_report(html_report)
print("\n--- Exporting Reports ---")
export_if_possible(pdf_report)
export_if_possible(html_report) # Does not have 'export'
Example 3: Duck Typing in Data Processing (Intermediate)
Python
# filename: oop_duck_typing_data_processing.py
class ListProcessor:
def process_data(self, data_list):
return [item.upper() for item in data_list]
class StringProcessor:
def process_data(self, data_string):
return data_string.replace(" ", "_").lower()
class NumberProcessor:
def process_data(self, data_numbers):
return sum(data_numbers)
def handle_processing(processor, data):
"""
Handles data processing using any processor object
that has a 'process_data' method.
"""
print(f"Processing '{data}' with {type(processor).__name__}:")
result = processor.process_data(data)
print(f"Result: {result}\n")
# Create processor objects
list_p = ListProcessor()
string_p = StringProcessor()
number_p = NumberProcessor()
# Process different types of data with different processors
handle_processing(list_p, ["apple", "banana", "cherry"])
handle_processing(string_p, "Hello World Python")
handle_processing(number_p, [10, 20, 30])
# What if a processor doesn't match?
class ImageProcessor:
def resize_image(self, image):
return "Resized image"
image_p = ImageProcessor()
try:
handle_processing(image_p, "my_image.jpg") # This will fail
except AttributeError as e:
print(f"Error: {e} - ImageProcessor does not have 'process_data'.")
Abstraction
Abstraction in OOP refers to the concept of hiding complex implementation details and showing only the essential features5 or relevant information to the user. It focuses on "what" an object does rather than "how" it does it. In Python, abstraction is often achieved using abstract base classes (ABCs).
Abstract Base Classes (abc module)
An Abstract Base Class (ABC) is a class that cannot be instantiated directly. It's designed to be inherited from, providing a blueprint of methods that its concrete (non-abstract) subclasses must implement. The abc module in Python provides the infrastructure for defining ABCs.
You mark a class as an ABC by inheriting from ABC in the abc module.
You mark methods as abstract using the @abstractmethod decorator.
Abstract Methods
An abstract method is a method declared in an abstract base class that has no implementation (or a very minimal one) in the ABC itself. Subclasses are then required to provide a concrete implementation for these methods. If a subclass fails to implement all abstract methods of its ABC, it also becomes an abstract class and cannot be instantiated.
Example 1: Basic Abstract Base Class (Beginner)
Python
# filename: oop_abstraction_basic.py
from abc import ABC, abstractmethod
class Shape(ABC): # Inherit from ABC to make this an abstract base class
"""Abstract base class for geometric shapes."""
def __init__(self, name):
self.name = name
@abstractmethod # This decorator marks the method as abstract
def area(self):
"""Abstract method to calculate the area. Must be implemented by subclasses."""
pass # No implementation in the abstract class
@abstractmethod # Another abstract method
def perimeter(self):
"""Abstract method to calculate the perimeter. Must be implemented by subclasses."""
pass
def get_info(self): # Concrete method (has an implementation)
return f"This is a {self.name}."
# Attempt to instantiate an abstract class (will raise TypeError)
try:
some_shape = Shape("Generic Shape")
except TypeError as e:
print(f"Error instantiating Shape: {e}")
class Circle(Shape):
def __init__(self, name, radius):
super().__init__(name)
self.radius = radius
def area(self): # Must implement abstract 'area' method
import math
return math.pi * self.radius**2
def perimeter(self): # Must implement abstract 'perimeter' method
import math
return 2 * math.pi * self.radius
class Rectangle(Shape):
def __init__(self, name, length, width):
super().__init__(name)
self.length = length
self.width = width
def area(self): # Must implement abstract 'area' method
return self.length * self.width
def perimeter(self): # Must implement abstract 'perimeter' method
return 2 * (self.length + self.width)
# Create concrete objects
my_circle = Circle("My Circle", 5)
my_rectangle = Rectangle("My Rectangle", 10, 4)
print(my_circle.get_info())
print(f"Circle Area: {my_circle.area():.2f}")
print(f"Circle Perimeter: {my_circle.perimeter():.2f}")
print(my_rectangle.get_info())
print(f"Rectangle Area: {my_rectangle.area()}")
print(f"Rectangle Perimeter: {my_rectangle.perimeter()}")
Example 2: Enforcing Interface with ABCs (Beginner to Intermediate)
ABCs ensure that any class that claims to be a certain type (e.g., PaymentGateway) provides specific functionalities.
Python
# filename: oop_abstraction_interface.py
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
"""Abstract base class defining the interface for payment gateways."""
@abstractmethod
def process_payment(self, amount):
"""Must implement payment processing logic."""
pass
@abstractmethod
def refund_payment(self, transaction_id):
"""Must implement refund logic."""
pass
def get_supported_currencies(self): # Concrete method, optional to override
return ["USD", "EUR"]
class PayPalGateway(PaymentGateway):
def process_payment(self, amount):
print(f"PayPal: Processing payment of ${amount:.2f}")
# Actual PayPal API call would go here
return {"status": "success", "transaction_id": "PP12345"}
def refund_payment(self, transaction_id):
print(f"PayPal: Refunding transaction {transaction_id}")
# Actual PayPal API call
return {"status": "refunded"}
class StripeGateway(PaymentGateway):
def process_payment(self, amount):
print(f"Stripe: Processing payment of ${amount:.2f}")
# Actual Stripe API call
return {"status": "paid", "transaction_id": "STRIPE67890"}
def refund_payment(self, transaction_id):
print(f"Stripe: Refunding transaction {transaction_id}")
# Actual Stripe API call
return {"status": "refund_complete"}
# Create gateway objects
paypal = PayPalGateway()
stripe = StripeGateway()
# Use polymorphism: any PaymentGateway object can be used here
def handle_transaction(gateway, amount, refund_id=None):
print(f"\n--- Using {type(gateway).__name__} ---")
result = gateway.process_payment(amount)
print(f"Payment result: {result}")
print(f"Supported currencies: {gateway.get_supported_currencies()}")
if refund_id:
refund_result = gateway.refund_payment(refund_id)
print(f"Refund result: {refund_result}")
handle_transaction(paypal, 100.00, "PP12345")
handle_transaction(stripe, 50.00, "STRIPE67890")
# What if a class tries to inherit but doesn't implement all methods?
class IncompleteGateway(PaymentGateway):
def process_payment(self, amount):
print("Incomplete: Only processing payment.")
return {"status": "partial"}
try:
incomplete = IncompleteGateway() # This will raise TypeError because refund_payment is not implemented
except TypeError as e:
print(f"\nError: {e} - IncompleteGateway is still abstract!")
Example 3: Abstract Property (Intermediate)
You can also define abstract properties, forcing subclasses to implement a getter/setter for a specific attribute.
Python
# filename: oop_abstraction_abstract_property.py
from abc import ABC, abstractproperty
class DataService(ABC):
"""Abstract base class for data services with an abstract 'endpoint' property."""
@abstractproperty # Defines an abstract property
def endpoint(self):
"""
Abstract property: Subclasses must provide a getter for this.
Optionally, they can provide a setter.
"""
pass
@abstractmethod
def fetch_data(self):
"""Abstract method to fetch data."""
pass
class UserDataService(DataService):
def __init__(self, user_id):
self._user_id = user_id
@property # Concrete implementation of the abstract property
def endpoint(self):
return f"https://api.example.com/users/{self._user_id}"
def fetch_data(self):
print(f"Fetching user data from: {self.endpoint}")
return {"id": self._user_id, "name": "Test User", "data": "..."}
class ProductDataService(DataService):
def __init__(self, product_id):
self._product_id = product_id
@property
def endpoint(self):
return f"https://api.example.com/products/{self._product_id}"
def fetch_data(self):
print(f"Fetching product data from: {self.endpoint}")
return {"id": self._product_id, "name": "Test Product", "price": 99.99}
# Create service objects
user_service = UserDataService(123)
product_service = ProductDataService(456)
# Use abstract methods and properties polymorphically
print(user_service.fetch_data())
print(product_service.fetch_data())
print(f"User service endpoint: {user_service.endpoint}")
print(f"Product service endpoint: {product_service.endpoint}")
Example 4: Real-world Analogy of Abstraction (Intermediate to Advanced)
Think of a remote control for a TV. You use buttons like "Power," "Volume Up," "Channel Down." You don't need to know the complex electronics inside the remote or how it sends signals to the TV. This is abstraction – you interact with a simple interface, hiding the complexity.
Python
# filename: oop_abstraction_real_world.py
from abc import ABC, abstractmethod
class RemoteControl(ABC):
"""Abstract interface for a generic remote control."""
@abstractmethod
def power_on_off(self):
pass
@abstractmethod
def volume_up(self):
pass
@abstractmethod
def volume_down(self):
pass
@abstractmethod
def change_channel(self, channel_number):
pass
class TVRemote(RemoteControl):
"""Concrete implementation for a TV remote."""
def __init__(self, brand):
self.brand = brand
self.is_on = False
self.volume = 10
self.channel = 1
def power_on_off(self):
self.is_on = not self.is_on
status = "ON" if self.is_on else "OFF"
print(f"{self.brand} TV is now {status}.")
def volume_up(self):
if self.is_on:
self.volume = min(self.volume + 1, 100)
print(f"Volume up. Current volume: {self.volume}")
else:
print("TV is off.")
def volume_down(self):
if self.is_on:
self.volume = max(self.volume - 1, 0)
print(f"Volume down. Current volume: {self.volume}")
else:
print("TV is off.")
def change_channel(self, channel_number):
if self.is_on:
if 0 < channel_number <= 999: # Simple validation
self.channel = channel_number
print(f"Channel changed to: {self.channel}")
else:
print("Invalid channel number.")
else:
print("TV is off.")
# The user (client code) interacts with the abstract interface.
# They don't need to know if it's a TVRemote, SoundSystemRemote, etc.
def operate_remote(remote: RemoteControl): # Type hinting for clarity
print(f"\n--- Operating {type(remote).__name__} ---")
remote.power_on_off()
remote.volume_up()
remote.volume_up()
remote.change_channel(5)
remote.volume_down()
remote.power_on_off()
my_tv_remote = TVRemote("Samsung")
operate_remote(my_tv_remote)
# Imagine another remote implementation for a sound system
# class SoundSystemRemote(RemoteControl): ...
# You could pass it to operate_remote() and it would work as long as it implements the abstract methods.
Example 5: Designing Complex Systems with Abstraction (Advanced)
For large systems, ABCs are critical for defining architectural layers and ensuring components adhere to contracts.
Python
# filename: oop_abstraction_complex_system.py
from abc import ABC, abstractmethod
class DataSource(ABC):
"""Abstract base class for different data sources."""
@abstractmethod
def connect(self):
"""Establishes connection to the data source."""
pass
@abstractmethod
def read_data(self, query):
"""Reads data based on a query."""
pass
@abstractmethod
def disconnect(self):
"""Closes connection to the data source."""
pass
class DatabaseSource(DataSource):
def __init__(self, db_url):
self.db_url = db_url
self._connection = None
def connect(self):
print(f"Connecting to database: {self.db_url}")
self._connection = "DB_Connection_Object" # Simulate connection
return True
def read_data(self, query):
if not self._connection:
print("Error: Not connected to database.")
return []
print(f"Executing DB query: '{query}'")
return [f"DB_Record_{i}" for i in range(3)] # Simulate data
def disconnect(self):
if self._connection:
print(f"Disconnecting from database: {self.db_url}")
self._connection = None
return True
return False
class APIResourceSource(DataSource):
def __init__(self, api_url, api_key):
self.api_url = api_url
self.api_key = api_key
self._session = None
def connect(self):
print(f"Connecting to API at: {self.api_url}")
self._session = "API_Session_Object" # Simulate session
return True
def read_data(self, query):
if not self._session:
print("Error: Not connected to API.")
return []
print(f"Calling API endpoint: {self.api_url}/{query}")
return [f"API_Data_Item_{i}" for i in range(2)] # Simulate data
def disconnect(self):
if self._session:
print(f"Closing API session: {self.api_url}")
self._session = None
return True
return False
class DataProcessor:
"""Processes data from any DataSource."""
def __init__(self, source: DataSource): # Type hint to expect a DataSource ABC
self.source = source
def process_and_display(self, query):
try:
self.source.connect()
raw_data = self.source.read_data(query)
processed_data = [d.upper() for d in raw_data] # Simple processing
print(f"Processed data: {processed_data}")
finally:
self.source.disconnect()
# Create different data sources
db_source = DatabaseSource("jdbc:mysql://localhost/app_db")
api_source = APIResourceSource("https://api.example.com/v1", "my_secret_key")
# Create data processors with different sources
db_processor = DataProcessor(db_source)
api_processor = DataProcessor(api_source)
# Process data using the generic DataProcessor, which works due to abstraction
print("\n--- Processing with Database Source ---")
db_processor.process_and_display("SELECT * FROM users")
print("\n--- Processing with API Source ---")
api_processor.process_and_display("products/active")
This concludes the module on Object-Oriented Programming in Python. You've learned about the core pillars of OOP, how to define classes and objects, manage their attributes and methods, leverage inheritance for code reuse, implement polymorphism, and use abstraction for cleaner, more maintainable code. Keep practicing these concepts, as they are fundamental to becoming a proficient Python developer.