The SOLID principles were introduced by Robert C. Martin also known as "Uncle Bob" in his 2000 paper "Design Principles and Design Patterns". These concepts were later built upon by Michael Feathers, who introduced us to the acronym SOLID. These principles aim to make the code more readable, easy to maintain, extensible, reusable, and without repetition.
The following five concepts make up our SOLID principles:
Single Responsibility
Open/Closed
Liskov Substitution
InterfaceSegregation
Dependency Inversion
Looking at these concepts may appear daunting at first but in this article, we'll try to understand these principles using simple C++ examples.
1. Single Responsibility
This principle states that a class should only have one responsibility. To state this principle more technically, only one potential change (database logic, logging logic, and so on) in the software's specification should be able to affect the specification of the class.
A few of the advantages of following this principle are:
Testing - A class with one responsibility will have far fewer test cases.
Lower coupling - Less functionality in a single class will have fewer dependencies.
Organization - Smaller, well-organized classes are easier to search than monolithic ones.
Let's try to understand this principle using a simple Book example.
class Book {
private:
string bookName;
string author;
string text;
public:
Book() = delete;
Book(string bk, string a, string t):bookName{bk}, author{a}, text{t}
{}
bool findByAuthor(string authName) {
return (author == authName);
}
bool findByName(string bkName) {
return (bookName == bkName);
}
};
Now our book works well, and we can store as many books as we like in our application. After some time there is a need to add print capabilities to Book and we if are not careful then we will design something like the below which breaks the single responsibility principle.
class BadBook {
private:
string bookName;
string author;
string text;
public:
BadBook () = delete;
BadBook (string bk, string a, string t):bookName{bk}, author{a}, text{t} {}
bool findByAuthor(string authName) {
return (author == authName);
}
bool findByName(string bkName) {
return (bookName == bkName);
}
void printTextToConsole(string text) {
//print to std console
}
};
To make matters even worse, there is an additional requirement to print Book details to other mediums. e.g. to the serial port or to the socket. To fix this mess we should implement a separate class that deals with printing our text.
class BookPrinter {
public:
BookPrinter() {}
void printTextToConsole(string text) {
// print to std console
}
// in future we can add other medium to print data
void printTextToSerial(string text) {
// print via serial port
}
void printTextToOtherMediumstring(string text) {
// print using other medium
}
};
Now we've developed a class that relieves the Book of its printing duties, but we can also leverage our BookPrinter class to send a text to other mediums.
2. Open for Extension, Closed for Modification
The second principle in SOLID is known as the open-close principle. Simply put, classes should be open for extension but closed for modification. In doing this, we stop ourselves from modifying existing code, which can cause potentially a new bug in the otherwise perfectly fine application.
Of course, there is one exception to the rule is when we are fixing existing bugs in the class. Let's explore this concept with the help of a simple Guitar class.
class Guitar {
private:
string make;
string model;
int volume;
public:
Guitar() = delete;
Guitar(string mk, string mdl, int v): make{mk}, model{mdl}, volume{v}
{}
void setVolume(int v) {
volume = v;
}
int getVolume(int v) {
return volume;
}
string getModel() {
return model;
}
string getMake() {
return make;
}
};
We've launched this product and everyone like our Guitar but looking at the competition we decided to add a few new features like a cool flame pattern which can appeal to the younger generation. We may be tempted to modify the existing class and add a flame pattern but doing so may lead to errors if our class is being used by others in our team.
Hence we stick to the open-closed principle and simply extended our Guitar class.
class GuitarWithFlames : public Guitar {
private:
string flameColor;
public:
GuitarWithFlames(string mk, string mdl, int v, string fColor):
Guitar(mk, mdl, v),flameColor{fColor} {}
string getflameColor() {
return flameColor;
}
};
By extending the Guitar class we can be sure that our existing application won't be affected.
3. Liskov Substitution
The third principle is known as Liskov substitution and it is one of the most complex principles. It deals with runtime polymorphism and simply stated it means, derived classes must be substitutable for their base classes. In other words, if class A is a subtype of class B, we should be able to replace B with A without disrupting the behavior of our program.
Let's use the Vehicle class which is an abstract class and act as an interface to further application.
class IVehicle {
public:
IVehicle() {}
virtual void startEngine() =0; // pure virtual function
virtual void accelerate() =0; // pure virtual function
virtual ~IVehicle() {}
};
Above we've defined the Vehicle interface with a couple of methods that all vehicles should have. For example, a car will have a start engine and accelerate functionality and a truck will have similar functionality. But under the hood, cars, and trucks will use different engine types to implement the same functionality.
Using the above interface let's implement something concrete.
class Car : public IVehicle {
private:
Engine engine;
public:
Car();
~Car();
void startEngine() {
engine.on(); // trun on the engine
}
void accelerate() {
engine.powerUp(10); // move forward
}
};
The above Car code describes that we have an engine that can be turned on and we can increase the power output from the engine to increase the speed of the car.
As we are transitioning toward cleaner energy, let's implement the electric car. But looking at the interface, electric cars do not have engines.
class ElectricCar : public IVehicle {
private:
ElectricEngine motor;
public:
ElectricCar();
~ElectricCar();
void startEngine() {
motor.on(); // trun on the engine or
// throw invalid_operation("Engine not found");
}
void accelerate() {
motor.speedUp(1000); // increase RPM of motor
}
};
Hence when startEngine() method is invoked in class ElectricCar, we can either implement it using an electric drive train instead of an internal combustion engine or simply throw an exception. As far as usage is concerned, we can simply use pointer to base class or reference to access derived class.
void testVehicle()
{
IVehicle* vptr = new Car();
vptr->startEngine(); // Car::startEngine()
vptr->accelerate(); // Car::accelerate()
}
4. Interface Segregation
The fourth principle is known as Interface Segregation and it simply means that larger interfaces should be split into smaller ones. By doing so we ensure that clients only need to implement interfaces that are required for their use case and nothing extra.
Let's try to understand this principle using a food processor example.
class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
};
class Blender : public IFoodProcessor {
public:
void blend() override;
};
So far so good. We've implemented Blender using IFoodProcessor. But let's say we want another advanced food processor and we recklessly tried adding more methods in our interface/abstract class.
class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blen() = 0;
virtual void slice() = 0;
virtual void dice() = 0;
};
class AnotherFoodProcessor: public IFoodProcessor {
public:
void blend() override;
void slice() override;
void dice() override;
};
Although we've achieved our objective of advanced food processors, we've created issues for the Blender class as it does not support newer interfaces as there is no proper way to implement additional functionality.
As per the interface segregation principle, we should split the bigger interface into smaller interfaces.
class IBlender {
public:
virtual ~IBlender() = default;
virtual void blend() = 0;
};
class ICutter {
public:
virtual ~ICutter() = default;
virtual void slice() = 0;
virtual void dice() = 0;
};
Now Blender class can implement interfaces by inheriting from IBlender class and AnotherFoodProcessor can implement interfaces from IBlender and ICutter.
5. Dependency Inversion
The fifth principle is known as dependency inversion which refers to the decoupling of software modules. This way instead of high-level modules depending on low-level modules, both will depend on abstraction.
Let's try to understand this principle using a simple game object Player which interacts with the toggle door.
class Player
{
public:
Player() {}
void interactWith(Door *door) {
if (door) {
door->toggleOpen();
}
}
};
class Door
{
public:
Door() {}
void toggleOpen() {
// Open or close the door
m_open = !m_open;
if (m_open) {
std::cout << "Door is open" << std::endl;
} else {
std::cout << "Door is closed" << std::endl;
}
}
private:
bool m_open = false;
};
At a first glance, everything looks fine. The player is able to interact with doors in the game. However, extensibility should always be on our minds. In our implementation, we reduced code extensibility and reusability due to the dependence of the Player class on the Door class. What if the player wants to interact with other objects in the game? We will need to write a separate method for every new object.
We can fix the above coupling issue by abstracting interact and Door then Player can use InteractiveObject to interact with the door. Now if Player need to ineract with other object then we can simply pass other concrete class of InteractiveObject without spending time or effort in Player.
class InteractiveObject
{
public:
virtual void interact() = 0;
virtual ~InteractiveObject() = default;
};
class Door : public InteractiveObject
{
public:
Door() {}
void interact() override {
// Open or close the door
m_open = !m_open;
if (m_open) {
std::cout << "Door is open" << std::endl;
} else {
std::cout << "Door is closed" << std::endl;
}
}
private:
bool m_open = false;
};
class Player
{
public:
Player() {}
void interactWith(InteractiveObject* door) {
if (door) {
door->interact();
}
}
};
Conclusion
In this article, we've taken a deep dive into the SOLID principles for object-oriented design. We've started with a quick bit of SOLID history and why these principles exist. Letter by letter we've broken down the meaning of each principle with simple-to-understand code.
As you've reached here, this is the link to the Youtube video on SOLID principle by Robert Martin. Enjoy :)