SOLID on cats

SOLID on cats

Every programmer has at least once heard about the principles of SOLID. At interviews and exams at universities, many of us tried to remember what this principle of Lyskov was about. However, it is unlikely that the goal of teachers and interviewers is to force us to learn lines from textbooks. SOLID really helps you write quality code once you get the hang of it! If you haven’t done it yet, welcome to the hangman. Let’s take another look at how the well-known principles are arranged. I promise – without suffocation, we will consider everything using examples with cats.

Use the table of contents if you don’t want to read the entire text:
→ The principle of unity of responsibility
→ Principle of openness/closedness
→ The substitution principle of Barbara Lyskiv
→ Principle of separation of interfaces
→ The principle of inversion of dependencies
→ Conclusion

The principle of unity of responsibility

There should be separate places for play and food.

The Single Responsibility Principle (SRP) states that each class or module should have only one reason for change. That is, he should perform only one task.

It seems to be easy, but where to apply this principle? But what does it even mean to have only one reason to change? Let’s figure it out using a cat example.

My cat’s name is Borya. Now I will try to implement it.

class BoryaCoolCat:
    def eat(self):
        print("Омномном")
    def play(self):
        print("Тыгыдык")
    def save_to_database(self):
        # Код для сохранения Борика в базу данных
        Pass

Boryan is an advanced boy with us – he can soften, and basically preserve himself. Let’s say the vet told Bora to eat wet food only in the morning. Let’s then split the eat function into breakfasts and other meals:

class BoryaCoolCat:
    def morning_eat(self):
        print("Омномном")
    def eat(self):
        print("Не люблю сухой корм")
    def play(self):
        print("Тыгыдык")
    def save_to_database(self):
        # Код для сохранения Борика в базу данных
        pass

Now let’s assume that one of the toys of our big boy will be the morning meal from the table. To reflect this in the code, let’s split the play function, as we did with the eat function above:

class BoryaCoolCat:
    def morning_eat(self):
        print("Омномном")
    def eat(self):
        print("Не люблю сухой корм")
    def morning_eat_play(self):
        print("О конфетки, буду катать их по полу!")
    def play(self):
        print("Тыгыдык")
    def save_to_database(self):
        # Код для сохранения Борика в базу данных
        pass

It is already clear that the code is not very clear. Without knowing the history of its writing, it is difficult to guess how morning_eat differs from morning_eat_play. Now let’s make our cat a little more solid — combine methods with a similar subject to classes. Those related to food (morning_eat and eat) will be placed in the CatFeeding class. Thus, this class will have only one reason for change – feeding. Similarly, we will move the functions morning_eat_play and play to the CatPlay class, and save_to_database to CatDatabase.

class CatFeeding:
    def morning_eat(self):
        print("Омномном")
    def eat(self):
        print("Не люблю сухой корм")
class CatPlay:
    def morning_eat_play(self):
        print("О конфетки, буду катать их по полу!")
    def play(self):
        print("Тыгыдык")
class CatDatabase:
    def save_to_database(self, cat):
        # Код для сохранения объекта cat в базу данных
        print(f"{cat.name} сохранён в базу данных.")

Great, now each class has only one reason to change – feeding, playing or saving in the DB! But the main thing here is to know the measure and not to breed classes for each method.

The principle of openness/closure

If a cat learns to hiss, it must not learn to meow.

Open/Closed Principle (OCP) – software entities (classes, modules, functions, etc.) must be open for extension, but closed for modification.

Extensions, modifications, blah blah blah … Now with the help of Bori we will figure it all out! We present our boy with a speech:

class BoryaCoolCat:
    def speak(self):
        return "Мяу!"

After living with him for a while, I realized that he has two moods in the morning: gentle beauty and killer cyborg. It will be strange if he talks the same in both cases. Let’s try to improve Boryan:

class BoryaCoolCat:
    def speak(self, nice_mood: bool):
        if nice_mood:
            return "Мур-мур-мур"
        return "Шшшшшшш"

We can see how our function gets bigger, it becomes easier to make a mistake in it. If Bora gets some more mood, the function will grow. Let’s cast solidity.

class BoryaCat:
    def speak(self):
        return "Мяу!"
class NiceCat(BoryaCat):
    def speak(self):
        return "Мур-мур-мур"
class AngryCat(BoryaCat):
    def speak(self):
        return "Шшшшшшш"

See what our classrooms look like now. They are open to adding new logic, but closed to changing the current one. Changing the original logic is a very dangerous procedure. The method can be used in different places of the code, when changing it is very difficult to find all the errors. But if we do not change the initial logic, but add new cases, the working code will not break, and we will be able to implement the new functionality without problems!

The substitution principle of Barbara Lyskiv

If we like toys, then a mouse and candy canes.

Barbara Liskov’s substitution principle (Liskov Substitution Principle, LSP) states that subclass objects should be interchangeable with superclass objects without changing the desired properties of the program. This means that if class S is a subclass of class T, then objects of class T must be substitutable objects of class S without violating the correctness of the program.

Perhaps the most obscure principle of all. When I read it for the first time, I felt like I was on a matan in the first year. What are the S? what T? Classes, subclasses… You, Lyskov, didn’t sit still. But get ready, now we will defeat this principle once and for all!

Let’s make Bory’s trinkets class. In it, we will create a method that will return how Borya is happy and loves his toys.

class BoryasStuff:
    def enjoy(self):
        print("Ура")

Bori has a candy cane, my hands and everything on the table. These are all his stuff, so they should inherit from Boryas Stuff.

class CandyWrapper(BoryasStuff):
    def enjoy(self):
        print("Ура, шелестеть!")
class MommysHand(BoryasStuff):
    def enjoy(self):
        print("Ура, царапать!")
class TableThings(BoryasStuff):
    def enjoy(self):
        print("Урааа, скидывать на пол!")

I recently bought Bora an expensive mouse that can run away from him and make sounds. But, as usual, the more expensive the toy, the less Borya is interested in it. So its implementation will look like this:

class CoolMouse(BoryasStuff):
    def enjoy(self):
        raise NotImplementedError("Какая мышь? Где мой трижды погрызанный фантик?")

This is where the principle of Barbar Lyskov is violated. The fact is that according to it we should be able to use all child classes in the same way as parent classes. But in the BoryasStuff class, unlike the CoolMouse child class, the enjoy method is executed without problems.

If we can call BoryasStuff().enjoy(), then we can also call CoolMouse().enjoy(). To comply with the principle, we can either exclude the enjoy method from BoryasStuff, or not inherit CoolMouse from BoryasStuff, that’s it!

class BoryasStuff:
    pass
class CandyWrapper(BoryasStuff):
    def enjoy(self):
        print("Ура, шелестеть!")
class MommysHand(BoryasStuff):
    def enjoy(self):
        print("Ура, царапать!")
class TableThings(BoryasStuff):
    def enjoy(self):
        print("Урааа, скидывать на пол!")
class CoolMouse(BoryasStuff):
    def hate(self):
        print("Какая мышь? Где мой трижды погрызанный фантик?")

The principle of separation of interfaces

There’s no point in knowing how to swim if you never do it.

The Interface Segregation Principle (ISP) states that clients should depend on which interfaces they use where.

Somehow we trifle with class choices. Let’s create a shared cat interface. What can they do? Running, swimming, and, of course, screaming at 5 in the morning.

class Cat:
    def run(self):
        pass
    def swim(self):
        pass
    def morning_yell(self):
        pass

But my Boryk is a house serf. Once he swam in the bathroom and he really didn’t like it, we decided not to torture him. But if we inherit Borya from Cat, theoretically someone could force him to swim, which he wouldn’t want to do. Let’s separate the interfaces so we can inherit only what we need:

class Walkable:
    def run(self):
        pass
class Hateable:
    def morning_yell(self):
        pass
class Swimmable:
    def swim(self):
        pass

Now everything is fine! We can simply inherit Borya from Walkable and Hateable. This is the principle of interface separation. We should not inherit what we do not use.

The principle of inversion of dependencies

The fact that cats meow does not affect all other animals. But the fact that animals make sounds affects cats.

The Dependency Inversion Principle says:

  • Top-level modules should not depend on bottom-level modules. Both must depend on abstractions.”
  • “Abstractions should not depend on details. Details must depend on abstractions.”

So many words and so hard to find meaning in them. Let’s analyze the use of this principle from an example. Most animals make sounds. Let’s write an interface for this:

from abc import ABC, abstractmethod
class Sound(ABC):
    @abstractmethod
    def make_sound(self):
        pass

Now let’s define a cat sound:

class CatSound(Sound):
    def make_sound(self):
        return "Мяу!"

See, cat sounds are inherited from animal sounds. Everything looks logical. Now let’s implement Borya again:

class BoryaCoolCat:
    def __init__(self, sound: Sound):
        self.sound = sound
    def speak(self):
        return self.sound.make_sound()

Notice the juice? In the constructor of the class, we get a sound of type Sound. So, if sooner or later we decide that Borya is not a cat, but, for example, a crocodile, we will not need to rewrite the BoryaCoolCat class. Just pass any other class that inherits from Sound to it!

This principle works very well in large projects. This is where the DI container comes in handy. Perhaps in future articles I will touch on this interesting topic.

Conclusion


At the end of our sweet journey through principles and bowls, I would like to emphasize that all this is not just a set of rules. Yes, you can study them before the exam or social security and not remember them again. But they really help to create better and more maintainable code.

Good code, like cats, needs attention and care. By applying SOLID principles, we not only improve its structure, but also make it more understandable for other developers (and ourselves in the future). After all, it allows you to focus on solving tasks, and not on dealing with the consequences of crutches.

Creating quality code is not just a task, but an art. I hope this article has inspired you to look at SOLID principles from a new angle and start applying them in your practice.

Related posts