🐍 Python Q91 / 170

Explain SOLID principles in Python.

AI-Powered Answer ✓ Answered

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.

python
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.

python
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.

python
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.

python
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.

python
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.

python
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).

python
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.

python
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.

python
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.

python
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.