SOLID principles

SOLID is an acronym for the first five principles of object-oriented design (OOD) by Robert C. Martin (also known as Uncle Bob).

These principles set out practices for developing software with a view to maintainability and extensibility as the project grows. Adopting these principles can also help to avoid code smells, refactor code and develop agile or adaptive software.

SOLID stands for:

S – Single Responsibility Principle

A class’s methods should be focused on a single purpose.

O – Open-Closed Principle

Objects should be open for extension but closed for modification.

L – Liskov Substitution Principle

Subclasses should be substitutable for their superclasses.

I – Interface Segregation Principle

Objects should not depend on methods they do not use.

D – The Dependency Inversion Principle

Abstractions should not depend on details.

Single Responsibility Principle

The Single Responsibility Principle states that each class should fulfil only one task:

A class should have one, and only one, reason to change.

– SRP: The Single Responsibility Principle by Robert C. Martin

Let’s take, for example, an application that takes a collection of shapes – circles and squares – and calculates the sum of the perimeters of all the shapes in the collection.

First, create the Form classes with the necessary parameters. For squares, this is the side length, and for circles, the diameter:

class Form:
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y


class Square(Form):
    def __init__(self, length=1, x=0, y=0):
        super().__init__(x, y)
        self.length = length


class Circle(Form):
    def __init__(self, diameter=1, x=0, y=0):
        super().__init__(x, y)
        self.diameter = diameter

Now you can create a class called SquaresAndCircles containing the logic for calculating the perimeters of all squares and circles:

import gc


class SquaresAndCircles:
    pi = 3.14159

    @classmethod
    def circumferences(cls):
        csum = 0
        for obj in gc.get_objects():
            if isinstance(obj, Square):
                csum = csum + 4 * obj.length
            if isinstance(obj, Circle):
                csum = csum + obj.diameter * cls.pi
        return csum

The SquaresAndCircles class handles the logic required to calculate the perimeters of all squares and circles. This fulfils the Single Responsibility Principle.

Open-Closed Principle

The Open-Closed Principle states:

Modules should be both open (for extension) and closed (for modification).

Accordingly, the code would require very little modification to add new functionality, for example, via a subclass.

Let’s look at the SquaresAndCircles class and focus on the circumferences() method. Imagine a scenario in which the sum of additional shapes, such as triangles, pentagons, hexagons, etc., needs to be calculated. You would have to constantly edit this class and add further if blocks. That would violate the Open-Closed Principle. One way to improve this method is to remove the logic for calculating the circumference of each shape from the SquaresAndCircles class and attach it to the classes for the specific shapes. Here, the circumference calculations are defined in the Square and Circle classes:

class Square(Form):
    def __init__(self, length=1, x=0, y=0):
        super().__init__(x, y)
        self.length = length

    def circumference(self):
        return 4 * self.length


class Circle(Form):
    pi = 3.14159

    def __init__(self, diameter=1, x=0, y=0):
        super().__init__(x, y)
        self.diameter = diameter

    def circumference(self):
        return self.diameter * Circle.pi

The sum method circumferences() in the CircumferenceFormInstances class can then be rewritten as follows:

class CircumferenceFormInstances:
    def circumferences():
        csum = 0
        for obj in gc.get_objects():
            if isinstance(obj, Form) and hasattr(obj, "circumference"):
                csum = csum + obj.circumference()
        return csum

This fulfils the Open-Closed Principle.

Tip

If your code is not yet open to new requirements, you should first reorganise (refactor) the existing code so that it is open to the new functionality. Only then should you add new code.

Refactoring refers to the process of modifying a software system in such a way that the external behaviour of the code remains unchanged, whilst its internal structure is improved.

– Refactoring by Martin Fowler

Note

Safe refactoring relies on tests. If you are genuinely restructuring the code without changing its behaviour, the existing tests should continue to pass at every stage. The tests act as a safety net that justifies your confidence in the new structure of the code. If they fail,

  • you have inadvertently broken the code,

  • or the existing tests are faulty.

Liskov Substitution Principle

The Liskov Substitution Principle states that a programme which uses objects of the base class must also function correctly with objects of the subclass. There are two rules of thumb that are helpful for adhering to the Liskov Substitution Principle:

  1. Avoid overriding concrete methods wherever possible.

  2. If you do so nonetheless, check whether you can call the overridden method from within the overriding method.

Let’s extend the Form class so that classes derived from it can be moved in the x and y directions:

class Form:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def move(self, delta_x, delta_y):
        self.x = self.x + delta_x
        self.y = self.y + delta_y

You can then move both squares and circles along the x- and y-axes:

>>> import forms
>>> s1 = forms.Square()
>>> c1 = forms.Circle()
>>> s1.x, s1.y, c1.x, c1.y
(0, 0, 0, 0)
>>> s1.move(4, 5)
>>> c1.move(2, 3)
>>> s1.x, s1.y, c1.x, c1.y
(4, 5, 2, 3)

Note

The Liskov Substitution Principle also applies to Duck typing: any object that claims to be a duck must fully implement the duck’s API. Duck types should be interchangeable. Applying logic across different data types of objects is known as polymorphism.

Interface Segregation Principle

The Interface Segregation Principle applies the Single Responsibility Principle to interfaces in order to isolate specific behaviour. If a change is required to part of your code, extracting an object that plays a particular role makes it possible to support the new behaviour without having to modify the existing code. This is preferable to hard-coded specialisations.

In the previous example, for instance, we checked whether our Form object actually provided a circumference() method. This is necessary in case shapes such as Point or Line are added later, which do not have a circumference.

Note

In this context, the Law of Demeter is also of interest; it states that objects should only communicate with objects in their immediate vicinity. This effectively restricts the list of other objects to which an object can send a message and reduces coupling between objects: an object can only communicate with its neighbours, but not with the neighbours of its neighbours; objects can only send messages to those directly involved.

The Dependency Inversion Principle

The Dependency Inversion Principle can be defined as follows:

Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

– The Dependency Inversion Principle by Robert C. Martin

circumferences() should not be defined within the Forme class, as there are shapes without a circumference.