Explain SOLID principles in Python.
SOLID is an acronym for five fundamental design principles in object-oriented programming, proposed by Robert C. Martin (Uncle Bob). These principles are intended to make software designs more understandable, flexible, and maintainable. Applying SOLID in Python helps developers create clean, modular, and easy-to-extend codebases, reducing technical debt and improving collaboration.
1. Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class should have only one reason to change. This means a class should ideally have only one primary responsibility. If a class has multiple responsibilities, changes related to one responsibility might inadvertently affect others, making the class fragile and harder to maintain.
Violating SRP: Consider a Report class that is responsible for both generating the report content and printing it to the console.
class Report:
def __init__(self, title, content):
self.title = title
self.content = content
def generate_report(self):
# Logic to format and generate report content
return f"--- {self.title} ---\n{self.content}"
def print_report(self):
# Logic to print the report
print(self.generate_report())
# Usage
report = Report("Monthly Sales", "Details of sales for the month...")
report.print_report()
Adhering to SRP: We can separate the responsibilities into different classes. One class handles report generation, and another handles printing. This way, if the printing mechanism changes (e.g., print to PDF), the ReportGenerator class remains unaffected.
class ReportGenerator:
def __init__(self, title, content):
self.title = title
self.content = content
def generate(self):
return f"--- {self.title} ---\n{self.content}"
class ReportPrinter:
def print_report(self, report_content):
print(report_content)
# Usage
report_data = ReportGenerator("Monthly Sales", "Details of sales for the month...")
printer = ReportPrinter()
printer.print_report(report_data.generate())
2. Open/Closed Principle (OCP)
The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means you should be able to add new functionality without altering existing, working code. New features should be added by extending the system, not by changing the core components.
Violating OCP: Imagine an AreaCalculator class that calculates the area of various shapes. If a new shape type is introduced, the AreaCalculator class needs to be modified.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
class Circle:
def __init__(self, radius):
self.radius = radius
class AreaCalculator:
def calculate_area(self, shapes):
total_area = 0
for shape in shapes:
if isinstance(shape, Rectangle):
total_area += shape.width * shape.height
elif isinstance(shape, Circle):
total_area += 3.14159 * shape.radius ** 2
# If a new shape (e.g., Triangle) is added, this method must be modified.
return total_area
# Usage
rect = Rectangle(10, 5)
circle = Circle(7)
calculator = AreaCalculator()
print(calculator.calculate_area([rect, circle]))
Adhering to OCP: Define a common interface (using abstract base classes in Python) for shapes. New shapes extend this interface by implementing the required methods, and the calculator works with the interface, not specific implementations. This way, the AreaCalculator is closed for modification but open for extension.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Triangle(Shape): # New shape, added without modifying AreaCalculator
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
class AreaCalculator:
def calculate_area(self, shapes: list[Shape]):
total_area = 0
for shape in shapes:
total_area += shape.area()
return total_area
# Usage
rect = Rectangle(10, 5)
circle = Circle(7)
triangle = Triangle(6, 4)
calculator = AreaCalculator()
print(calculator.calculate_area([rect, circle, triangle]))
3. Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that subtypes must be substitutable for their base types. That is, if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.). In simpler terms, a child class should not alter the expected behavior of its parent class.
Violating LSP: A common example involves Rectangle and Square. If Square inherits from Rectangle, and Rectangle allows independent setting of width and height, then Square's behavior (where width must equal height) violates Rectangle's contract when attempting to set dimensions independently.
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
def get_area(self):
return self._width * self._height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
self._height = value # Setting width also changes height for a square!
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
self._width = value # Setting height also changes width for a square!
def set_size_and_get_area(rect: Rectangle, new_width, new_height):
rect.width = new_width
rect.height = new_height
print(f"Set width={new_width}, height={new_height}. Expected area: {new_width * new_height}. Actual area: {rect.get_area()}")
# Usage
rect = Rectangle(10, 20)
set_size_and_get_area(rect, 15, 25) # Expected 375, Actual 375 (15*25)
square = Square(10)
set_size_and_get_area(square, 15, 25) # Expected 375. Actual: 25*25=625 because height was set last, forcing width to 25.
# This violates the expectation of the `Rectangle` type.
Adhering to LSP: Instead of inheriting, Rectangle and Square should either implement a common Shape interface without violating each other's contracts, or Square should not be a direct subtype of Rectangle in a way that breaks polymorphism. If a square's behavior is fundamentally different from a rectangle's regarding dimension manipulation, then it shouldn't be treated as a direct substitute.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def get_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
class Square(Shape): # Not inheriting from Rectangle directly
def __init__(self, side):
self.side = side
def get_area(self):
return self.side * self.side
def print_shape_area(shape: Shape):
print(f"Area: {shape.get_area()}")
# Usage
rect = Rectangle(10, 20)
square = Square(10)
print_shape_area(rect)
print_shape_area(square)
4. Interface Segregation Principle (ISP)
The Interface Segregation Principle states that clients should not be forced to depend on interfaces they do not use. Instead of one large, general-purpose interface, create many small, role-specific interfaces. This prevents classes from being burdened with methods they don't need to implement, leading to cleaner code and fewer unexpected side effects.
Violating ISP: Imagine a single Worker interface with methods for work, eat, and sleep. Not all 'workers' might need all these capabilities (e.g., a simple robot might only work and not eat or sleep).
from abc import ABC, abstractmethod
class Worker(ABC): # A 'fat' interface
@abstractmethod
def work(self):
pass
@abstractmethod
def eat(self):
pass
@abstractmethod
def sleep(self):
pass
class HumanWorker(Worker):
def work(self):
print("Human working...")
def eat(self):
print("Human eating...")
def sleep(self):
print("Human sleeping...")
class RobotWorker(Worker):
def work(self):
print("Robot working...")
def eat(self): # Robots don't eat! Forced to implement, potentially with an empty body or an error.
pass
def sleep(self): # Robots don't sleep! Forced to implement.
pass
# Usage
human = HumanWorker()
robot = RobotWorker()
human.work()
human.eat()
human.sleep()
robot.work()
robot.eat() # This call is semantically meaningless for a robot
robot.sleep() # This call is semantically meaningless for a robot
Adhering to ISP: Segregate the Worker interface into smaller, more focused interfaces. HumanWorker can implement Workable, Feedable, and Sleepable, while RobotWorker only implements Workable.
from abc import ABC, abstractmethod
class Workable(ABC):
@abstractmethod
def work(self):
pass
class Feedable(ABC):
@abstractmethod
def eat(self):
pass
class Sleepable(ABC):
@abstractmethod
def sleep(self):
pass
class HumanWorker(Workable, Feedable, Sleepable):
def work(self):
print("Human working...")
def eat(self):
print("Human eating...")
def sleep(self):
print("Human sleeping...")
class RobotWorker(Workable): # Only implements Workable
def work(self):
print("Robot working...")
# Usage
human = HumanWorker()
robot = RobotWorker()
human.work()
human.eat()
human.sleep()
robot.work()
# robot.eat() # This would raise an AttributeError as RobotWorker doesn't implement Feedable if we tried to call it.
5. Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states: 1) High-level modules should not depend on low-level modules. Both should depend on abstractions. 2) Abstractions should not depend on details. Details should depend on abstractions. This principle aims to reduce coupling between components, making the system more flexible and testable by making dependencies on abstract interfaces rather than concrete implementations.
Violating DIP: A BackendDeveloper (high-level module) directly depends on a concrete MySQLDatabase (low-level module). If the database type needs to change (e.g., to PostgreSQL), the BackendDeveloper class needs modification.
class MySQLDatabase:
def connect(self):
print("Connecting to MySQL database.")
return "MySQL Connection"
def disconnect(self):
print("Disconnecting from MySQL database.")
class BackendDeveloper:
def __init__(self):
self.db = MySQLDatabase() # Direct dependency on concrete low-level module
def develop_application(self):
connection = self.db.connect()
print(f"Developing application with {connection}.")
self.db.disconnect()
# Usage
dev = BackendDeveloper()
dev.develop_application()
Adhering to DIP: Introduce an abstraction (an abstract base class) for the database. BackendDeveloper depends on this abstraction, and concrete database implementations (like MySQLDatabase, PostgreSQLDatabase) also depend on it. The database dependency is 'injected' into the BackendDeveloper.
from abc import ABC, abstractmethod
class Database(ABC): # Abstraction
@abstractmethod
def connect(self):
pass
@abstractmethod
def disconnect(self):
pass
class MySQLDatabase(Database): # Low-level module depends on abstraction
def connect(self):
print("Connecting to MySQL database.")
return "MySQL Connection"
def disconnect(self):
print("Disconnecting from MySQL database.")
class PostgreSQLDatabase(Database): # Another low-level module
def connect(self):
print("Connecting to PostgreSQL database.")
return "PostgreSQL Connection"
def disconnect(self):
print("Disconnecting from PostgreSQL database.")
class BackendDeveloper: # High-level module depends on abstraction
def __init__(self, db: Database): # Inject dependency
self.db = db
def develop_application(self):
connection = self.db.connect()
print(f"Developing application with {connection}.")
self.db.disconnect()
# Usage
mysql_dev = BackendDeveloper(MySQLDatabase())
mysql_dev.develop_application()
print("\n--- Switching to PostgreSQL ---\n")
postgres_dev = BackendDeveloper(PostgreSQLDatabase())
postgres_dev.develop_application()
By consistently applying the SOLID principles, Python developers can write code that is more robust, easier to maintain, and simpler to extend. While sometimes requiring more initial design effort, the long-term benefits in terms of code quality, flexibility, and reduced technical debt are substantial.