SOLID principles of object-oriented programming
What's SOLID? Probably everything you can put in your pocket, but what I’d like to tell you about are a few great software development practices. Formed as a handful of easy-to-remember principles, they were a conclusion... of generations of programmers, doing their thing. The famous Robert C. Martin is considered to be the father of those principles. Not thought of as law, but more of a tool you want to use to produce high-quality code. Tool easy to maintain and extend at the same time. In this article, I'll cover the definition of each principle. Together, they give a solid base to object-oriented programming.
Many explanations of this rule treat classes as basic instances, from which a solution is built.
The official explanation is as follows:
A class should have only one reason to change.
In fact, it has a much broader application throughout the entire system. But, since we base on classes and their functions, let's stick to them. This principle is not about grouping functions into separate classes so that they:
- operate on the same entity,
- solve a particular issue of a programming nature.
It's about grouping those functions in such a way that, when a change is requested, they change together with as little impact on other classes as possible.
What's a change?
A change is not a bug fix or refactoring - those are changes to the code. Applications are used by people: they click and things happen - this is what we’re talking about here.
In the context of this principle, a change is a difference between the existing (or not) and the desired behaviour or the outcome of execution. For example, right now the system I am working on only allows pictures to be shown on the product page, but I want to display videos there, too.
The reason to change, in this case, is the idea of uploading videos that show what a product looks like and how it works. Adding the feature, in effect, changes the current application's code and new code is created.
You might want to read those articles too:
What's a responsibility?
Responsibility is a part of the entire system (e.g. a module) that has only one job in the context of a given business logic. It can consist of many coordinated classes, each responsible for a part of the problem. It still does only one thing, though, so when a change needs to be done, it should affect only a single piece of the entire solution.
The following quote from a more recent publication of Robert C. Martin, couldn't word this principle any clearer:
Gather together the things that change for the same reasons. Separate those things that change for different reasons.
Have this in the back of your head when you create new classes or refactor those already existing.
A responsibility that is single
Changes to a single, independent portion of code ensure that other things won't break or execute improperly after introducing them. When a change needs to be made, you want to modify only the part that you need to. One of the last things you want to happen in your code is the seemingly endless chain of fixes across the entire system that originates from a simple modification and that could be avoided.
Interfaces are the most basic abstractions in programming - there is nothing more primitive than a set of functions. Even if an interface is made from a few others (extending), it is still a basic structure that can't be simplified. Interfaces should be prioritized as a starting point for every new class.
A class should implement an interface as a whole. If there is no need or a possibility of doing so, this indicates a fault in its definition - it demands too much functionality to be implemented. The solution for this is to split the interface into at least two more focused ones (keeping the previous principle in mind). If a need to implement multiple interfaces at once ever arises, there is no problem, since you can do so with as many interfaces as you want. Only remember that they should be independent of each other and none of them should require another to be implemented simultaneously.
Every functionality will expand. That’s an inevitable part of development. You need to make sure that the one already existing will still work after making changes to the code. This narrows down to the following two conclusions:
Opening a module for extension
You should allow the functionality to grow in such a way that doesn't require changes in a corresponding classes' code. A module that's open for extension allows to add functionality to the system by writing additional code. If anything requires making changes in an existing class, verify it against the single responsibility principle, and if you have to add a method or two to a class that should have them, don't hesitate. Otherwise, you will need to create another class, though.
Closing for modification
You shouldn't influence the base class' behaviour in its descendants. You shouldn't override existing methods, change the return type, and so on.
The next principle says more about it.
Even when you don't implement an interface in your class, it's still a part of it. After all, if you removed all of the code (implementation) from each of its methods, you'd be left with the interface alone. In the inheritance hierarchy, each of the derived classes would:
- expand their parent class' interface (composition),
- modify it (polymorphism),
- both of the above.
The principle, named after the renowned American computer scientist - Barbara Liskov, states that if there exists a chain of inheritance, in each derived class, its parent's interface remains untouched, and the object can be used the same way no matter what subtype of it is requested.
What do we substitute?
It's made this way to allow use of any version of a class in places where you'd expect an object of a root class or any of its descendants/predecessors. It implies that the code is compliant with the open/closed principle as it forces particular constructs, e.g. through the use of a protected final accessibility modifier, which allows you to use a function in a derived class, but disallows the change of its implementation.
In other words, you should be able to use derived classes in places you're forced to use their parent type. Remember that the existing functionality is probably thoroughly tested, and when you change it (and you don’t want to do that), you'd probably need to test it once again.
A dependency, in this particular case, should be understood as being able to use a functionality delivered by another class/module. You see, when A depends on B it means that A uses everything that B can do. A derived class uses its parent's functionality - hence the derived class depends on the superclass.
It is not about making the superclass dependent upon its descendants - when creating an abstract class that has some of its methods implemented we're given a derived class, and the derived class can be used the same way as in the context of its parent, which complies with the substitution principle, so everything's all right here.
The thing is that old school development had some rules that changed over time. One of them was making high-level, more complex and more specialized instances depend upon simpler ones that acted as boilerplates. It was not about what an object was, but what could be done with it. That way every new class had a fully implemented functionality of its parent, and of the new one that it was requested to have.
The inheritance chain was composed mostly of concretions. When one of those classes had to be changed, it implied changes in all of its descendants and this violates the single responsibility principle (a class changes when it shouldn't) and the one we're talking about right now.
Robert C. Martin came to the conclusion that making modules depend on anything is absurd, and so they should depend on nothing. Well, why are we required to make modules dependent upon abstractions, then? Because an abstraction is an idea of what the object is capable of. We use interfaces and use them to make sure that an object of correct characteristics is passed on as a method parameter or returned as a function result.
So the inversion, as he said, is a way of communicating that the thinking should be changed and that we should do it the other way.
High-level modules should not depend on low-level modules
Modules and classes that are high-level (more specialized) should not depend on the low-level ones, because their functionality depends on an implementation of the base instances. When a low-level module changes, so do all of those that use it as a base - totally not recommended.
Both high and low-level modules should depend on abstractions
Talking about classes, each of them should be derived from a certain abstraction. It can be an abstract class, but the most appropriate and preferable would be an interface. The model situation is when the inheritance hierarchy is two-level at most: it begins with an interface and ends with one direct implementation.
Abstractions should not depend on details
The derived class should never require the superclass to adjust itself to its needs. It should rely only on the functionality that's being provided. No class should contain methods, fields and dependencies that it doesn't use. A class that is a basic subject to others shouldn't burden them with such things, hoping that child classes will make use of them.
Details should depend on abstractions
That's fairly easy - you should fully implement an interface that is well-constructed (in compliance with the interface segregation principle). If your class changes, it shouldn't require making changes in the superclass or the interface.
Object-oriented programming principles summary
As you can see, many of these rules overlap, but in some situations, they seem mutually exclusive. Like closing for modifications means playing with a base class, while dependency inversion denies the existence of any - in an ideal case.
You need to verify whether any of these rules could be applied to your projects - remember it's a tool that you can make use of, or not - that's up to you.
Now, let's summarize each rule in few words:
Single responsibility principle
A class should only have one reason to change.
It has, when it does only one thing (single responsibility) and doesn't depend on other implementations (dependency inversion).
Software entities should be open for extension, but closed for modification.
Add new features by adding new code. Don't mess with a functionality that's already complete.
Liskov substitution principle
Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
You should be able to use an object in places where its less specialized, more basic type is requested, e.g. function parameters returned types. That or a base class. Or an interface the base class is implementing. And the functionality should work the same in all of those cases.
Interface segregation principle
Many client-specific interfaces are better than a one general-purpose interface.
It is better to have many interfaces and use them entirely than have one superhero interface that tells a class to implement functions it won't use.
Dependency inversion principle
One should depend upon abstractions, not concretions.
Making classes and modules depend on nothing but the basic abstraction allows you to create and use versions that are the most appropriate at given a situation.