In the previous article, we talked about the basic principles of clean code. This time, we want to introduce you to the SOLID acronym, which is a set of rules for object-oriented programming that, when applied, makes code safer and easier to maintain. In this article, we will try to discuss its principles.
The individual letters of the SOLID acronym stand for:
- SRP: The Single Responsibility Principle
- OCP: The Open-Closed Principle
- LSP: The Liskov Substitution Principle
- ISP: The Interface Segregation Principle
- DIP: The Dependency Inversion Principle
Let's start by familiarizing ourselves with the first principle.
SRP: The Single Responsibility Principle
This principle states that "There should be only one reason to change a class." What does this mean? Each class should have a specifically defined responsibility, scope of operation, and a single purpose. It is better to have more classes with their functionalities separated than to create "god classes" that handle multiple functionalities at once. For example, we can have a class called HardwareHandler, which reads information about computer hardware from a file and saves it to a database. As we can see from this simple example, the single responsibility principle has been violated. In the appropriate scenario, the class should be divided into two separate classes: FileReader for reading information from a file and HardwareRepository, which would handle the storage of hardware information in the database.
The example is trivial, but it shows what the single responsibility principle is all about. Each class has its specific single purpose. In significantly more complex applications, the lack of narrowed responsibility of classes will lead to confusion, where it will be difficult for programmers, especially those who are not the authors of the code, to understand the god classes, and the lack of understanding of program operations can translate into an increase in the number of errors and their undetected occurrence.
However, the above example does not mean that a class can have only one method to avoid breaking the SRP rule. In the case of the HardwareRepository class, we could add a method there that modifies information about hardware or deletes data from the database, and this rule would still be preserved. The point is for the class to maintain its single purpose. HardwareRepository will handle actions related to communication with the database in the context of computer hardware. However, it is not responsible for reading or writing data from a file or for processing information, and that's what SRP is all about.
OCP: The Open-Closed Principle
This principle states that our code should be open for extension but closed for modification. This means that changes to the application should be made by creating new code rather than modifying existing code. What is the advantage of such an approach? In addition to the cleanliness and reduced complexity of the code, it ensures backward compatibility of the system.
Failure to adhere to the principle of closure to modification is illustrated by a simple example of a library for our favorite language that is designed to support data processing in an application. In the new version, the authors changed the number of parameters passed to the existing methods. The result is that when we download the new version of the library, errors will appear in our code that will need to be corrected so that the application can function properly. As we can see, this is not the best approach to software development and illustrates the lack of backward compatibility when the OCP principle is violated.
The principle of openness to extension can be illustrated by the following example:
We have a Client class that uses TextWriter and XmlWriter classes to write data to a file in txt and xml formats. TextWriter and XmlWriter are separate unrelated classes with a write() method. If we add another format, we will have to add support for another class in the Client class, and worse yet, this process will be repeated every time a new extension is added. This is not the best solution. Is there a way out of this situation?
Basically, OCP is about abstraction. If we create a FileWriter interface with a write() method, then implement it in the TextWriter and XmlWriter classes, and finally declare that the Client uses this interface, it will be able to freely use any new implementation of the FileWriter interface. Thus, our code will become open for extension (ability to add support for new formats) while being closed for modification (we do not modify the behavior of the Client class).
Figure 1: Example described above.
Figure 2: Using an interface to achieve the OCP principle.
LSP: The Liskov Substitution Principle
This principle was developed in 1988 by Barbara Liskov. Its content is as follows: "Functions that use pointers or references to base classes must be able to use objects of classes that inherit from the base classes, without exact knowledge of those objects". This means that if we create an instance of a derived class, regardless of what is in the pointer to the variable, calling the method originally defined in the base class should yield the same results. This principle applies to the correct implementation of inheritance.
Let's look at an example:
Figure 3: Animal interface and its implementation in classes
We create the Animal interface with the run() method. Then we implement it in the Dog and Shark classes. Here, in this somewhat abstract representation, a problem arises. A dog can run, but a shark cannot. The implementation of the run() method for a shark would have to be different. It would have to change its character relative to the base class. One solution is to move to a higher level of abstraction and change the name of the method from run to move. However, a better solution is to extract two interfaces. An example is presented below:
Figure 4: Mammal interface and its implementation in the Dog class
Figure 5: Fish interface and its implementation in the Shark class
We create the Mammal and Fish interfaces with the run() and swim() methods respectively. Then we implement them in the individual classes. This time the Shark class does not have to change the behavior of the inherited method. The Liskov Substitution Principle has been preserved.
By using LSP, we can be sure that the derived class does not change anything in the behavior of the base class, and at the same time, our code is more precise and readable. This, however, requires careful consideration of the structure of classes and their inheritance.
ISP: The Interface Segregation Principle
This principle states that "many specific interfaces are better than one general one". Poorly designed interfaces tend to grow and become inconsistent. This principle ensures that a class does not have to implement methods that it will not use and is not dependent on them. The goal is to create more specific interfaces focused on a particular area of operation. Let's move on to an example:
Figure 6: The Robot class implements the IEmployee interface.
The Robot class implements the IEmployee interface by providing implementations for the work() and eat() methods. The second method is unnecessary in this case, since robots cannot eat. The solution is to create more specific interfaces and divide responsibilities.
Figure 7: The Robot class implements the more detailed IWork interface.
We create the IWork interface with the work() method. Now the Robot class, using this interface, is not required to implement any unnecessary methods. The interface has one responsibility, and the code itself, although the example is simple, becomes more transparent and is not cluttered with unnecessary functionality.
DIP: The Dependency Inversion Principle
This principle states that "high-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions." This means that when creating our applications, we should ensure that the modules contained in them depend on abstractions rather than implementations. This allows us to easily introduce changes to the software and reuse components, as demonstrated in the example below:
Figure 8: Diagram of a program for copying characters.
The diagram above shows a program that copies characters. In this scenario, we see that data is retrieved from the keyboard using the Keyboard Reader class and then passed to the Console Writer class, which is responsible for printing the data to the console. As we can see, both classes could easily be reusable in other parts of the program. However, this is not the case with the Copy class, which depends on lower-level modules. What if we wanted the characters to be saved to disk instead of being printed to the console? The DIP principle comes to our aid, and we can use abstractions in the form of interfaces.
Figure 9: Adding abstraction in the form of an interface to implement the dependency inversion principle.
The high-level module now depends on an abstraction in the form of an interface rather than a concrete implementation. Adding a new module to save to disk should not be a problem in this case.
We hope that this article has helped to familiarize you with the SOLID principles and how to apply them. The examples were inspired by the book "Agile Principles, Patterns, and Practices in C#" by Robert C. Martin and Micah Martin, as well as the sources listed below. If you are interested in further exploring the topic, we recommend checking them out.