SOLID Principles in Python - Part 1

SOLID Principles in Python - Part 1

·

6 min read

Python has gained a flurry of popularity among programming languages, according to StackOverflow 2022 survey. It is used in many areas such as automation, testing, web frameworks, data science etc. Python is an object-oriented language. It means everything is an object in Python even if you do not realize it. You can live by using functions. But in some cases, you will need to organize data and objects by the use of classes.

These classes need to be designed to achieve two important things:

  • Maintainability

  • Extensibility

To design such an object-oriented system, it is important to apply industry standards; SOLID principles.

SOLID are referring to five principles to design high-quality object-oriented classes that are extendible and maintainable. Those principles were first mentioned by Robert Martin in his paper published in 2000. It became a standard in object-oriented programming and can be asked in interviews (including big tech). So learning this will make you a better engineer. Plus, it will form a good foundation for design patterns. You can apply these to other object-oriented languages like Java, C#, and C++. SOLID is taking its name from the first letters of those principles below.

  • Single Responsibility Principle

  • Open-Closed Principle

  • Liskov Substitution

  • Interface Segregation

  • Dependency Inversion

In this blog, we will talk about the first two principles. We go through the code examples. Intermediate knowledge in OOP and Python would be good to be able to understand the code examples.

Single Responsibility Principle

As it says, the class should only have one, single reason to change. If you are writing a class that does different things, then it is good to separate its responsibility into smaller classes.

Bad Example

Let's explain this with an example. Assume that we have a Student class. To define and store each student, we create instances from this class by giving the student name, surname and unique id number. This class is capable of doing three things:

  • Init method (to initialize the object with student details)

  • get_student_info (class method to print out the student details)

  • register (class method to register the student).

class Student:
    def __init__(self, name, surname, identification_n):
        self.name = name
        self.surname = surname
        self.identification_n = identification_n

    def get_student_info(self):
        print(f"Student {self.name} {self.surname} has ID: {self.identification_n}.")

    def register(self):
        print(f"Student with ID: {self.identification_n} is registered.")
        self.registered = True

Enis = Student("Enis", "Arik", 115)
Enis.get_student_info()
Enis.register()

Assume that you will need to change the register method. For this, you have to touch Student class. But Student class should not be responsible for the register method. They are two different things. This is why it violates single-responsiblity principle.

Good Example

What we can do here is very simple. We create a new class for register named as Registerer. Inside, we have a method with an input argument student object. This way, we have two distinct classes Student and Registerer. They are both responsible for a single purpose.

class Student:
    def __init__(self, name, surname, identification_n):
        self.name = name
        self.surname = surname
        self.identification_n = identification_n

    def get_student_info(self):
        print(f"Student {self.name} {self.surname} has ID: {self.identification_n}.")

class Registerer:
    def register(self, student):
        print(f"Student with ID {student.identification_n} is registered.")
        self.registered = True

Enis = Student("Enis", "Arik", 115)
Enis.get_student_info()
registerer = Registerer()
registerer.register(Enis)

Open-Closed Principle

This principle argues that a class should be closed for modification but open for extension. It means, if we want to extend the system with new logic or things etc., we should be able to do this without modifying the existing ones.

Let's go over an example to understand this better.

Bad Example

In this example, we keep Student class the same as before. But we have a new class called CourseRegisterer. This has two different methods that register students for math and chemistry courses.

class Student:
    def __init__(self, name, surname, identification_n):
        self.name = name
        self.surname = surname
        self.identification_n = identification_n

    def get_student_info(self):
        print(f"Student {self.name} {self.surname} has ID: {self.identification_n}.")

class CourseRegisterer:
    def register_math(self, student):
        print(f"Student with ID {student.identification_n} is registered to math course.")
    def register_chemistry(self, student):
        print(f"Student with ID {student.identification_n} is registered to chemistry course.")

Enis = Student("Enis", "Arik", 115)
Emre = Student("Emre", "Arik", 120)
Enis.get_student_info()
Emre.get_student_info()
courseRegistry = CourseRegisterer()
courseRegistry.register_math(Enis)
courseRegistry.register_chemistry(Emre)

You probably understood the bad smell here. If you look closely into CourseRegisterer class, you see that it is responsible for two different course registries. If you want to add another course (like physics), you need to modify this class by adding a new method named as register_physics. This is violating open-closed principle. Because this class should not be open to modification.

Good Example

You can imagine that we can have many courses. So in the future, the number of courses may increase and we need to extend our program. To make this easier, we can create a super-class (a.k.a parent class) named as RegisterCourse, with a blueprint. This will include a draft method register. We create sub-classes from this such as RegisterMath, RegisterChemistry. You notice that I created a new class called RegisterPsychology. What we realize here is adding new courses is super simple. Just create a new sub-class for a new course and that's it. While we add new functionality, we do not have to modify the existing classes. This is the beauty of this design.

class Student:
    def __init__(self, name, surname, identification_n):
        self.name = name
        self.surname = surname
        self.identification_n = identification_n

    def get_student_info(self):
        print(f"Student {self.name} {self.surname} has ID: {self.identification_n}.")

class RegisterCourse:
    def register(self, student):
        pass

class RegisterMath(RegisterCourse):
    def register(self, student):
        print(f"Student with ID {student.identification_n} is registered to math course.")

class RegisterChemistry(RegisterCourse):
    def register(self, student):
        print(f"Student with ID {student.identification_n} is registered to chemistry course.")

class RegisterPsychology(RegisterCourse):
    def register(self, student):
        print(f"Student with ID {student.identification_n} is registered to psychology course.")

Enis = Student("Enis", "Arik", 115)
Emre = Student("Emre", "Arik", 120)
Seda = Student("Seda", "Williams", 130)
Enis.get_student_info()
Emre.get_student_info()
Seda.get_student_info()
math_registerer = RegisterMath()
math_registerer.register(Enis)
chemistry_registerer = RegisterChemistry()
chemistry_registerer.register(Emre)
psychology_registerer = RegisterPsychology()
psychology_registerer.register(Seda)

Taking a step further.

I should make a note here about something important. If you add more course registry classes but forgot to implement register() method as it is defined in the superclass, then it is a problem. You violate the blueprint of the superclass. To avoid this, we can use abstract classes in python. Below provides an example. We import abc library into our script and use ABC as an abstract class for RegisterCourse. abstractmethod provides a blueprint for a method register that has to be provided in its subclasses. If you do not override this method in sub-classes, then it will complain during run-time. This provides consistency and a set of rules for subclasses.

from abc import ABC, abstractmethod

class RegisterCourse(ABC):
    @abstractmethod
    def register(self, student):
        pass

class RegisterMath(RegisterCourse):
    def register(self, student):
        print(f"Student with ID {student.identification_n} is registered to math course.")

class RegisterChemistry(RegisterCourse):
    def register(self, student):
        print(f"Student with ID {student.identification_n} is registered to chemistry course.")

Recap

Understanding SOLID principles is the key to writing extendible and maintainable object-oriented programs. In this blog, I covered the first two principles. I hope, the explanation and examples make it clear to readers. We saw the two principles of SOLID. Code examples are uploaded in GitHub. In the next part (Part 2), I will write about the other principles. Thanks for your patience!