SOLID
SOLID is a set of principles that were first introduced by Robert C. Martin in his book Agile Software Development, Principles, Patterns, and Practices, in 2000. Robert C. Martin, also known as Uncle Bob, is a software engineer, author, and speaker. He is considered one of the most influential figures in the software development industry, known for his work on the SOLID principles and his contributions to the field of object-oriented programming. Martin has been a software developer for more than 40 years and has worked on a wide variety of projects, from small systems to large enterprise systems. He is also a well-known speaker and has given presentations on software development at many conferences and events around the world. He is an advocate of agile methodologies, and he has been influential in the development of the Agile Manifesto. The SOLID principles were developed as a way to help developers create more maintainable and scalable code by promoting good design practices. The principles were based on Martin’s experience as a software developer and his observation that many software projects suffer from poor design, which makes them difficult to understand, change, and maintain over time.
The SOLID principles are intended to be a guide for object-oriented software design, and they are based on the idea that software should be easy to understand, change, and extend over time. The principles are meant to be applied in conjunction with other software development practices, such as test-driven development and continuous integration. By following SOLID principles, developers can create code that is more robust, less prone to bugs, and easier to maintain over time.
The Single Responsibility Principle
The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented software design. It states that a class should have only one reason to change, meaning that a class should have only one responsibility. This principle is intended to promote code that is easy to understand, change, and test.
The idea behind the SRP is that a class should have a single, well-defined purpose. This makes it easier to understand the class’s behavior and makes it less likely that changes to the class will have unintended consequences. When a class has only one responsibility, it is also less likely to have bugs, and it is easier to write automated tests for it.
Applying the SRP can be a useful way to improve the design of a software system by making it more modular and easier to understand. By following this principle, a developer can create classes that are small, focused, and easy to reason about. This makes it easier to maintain and improve the software over time.
Let us look at a messaging system that supports multiple message types sent over the network. The system has a Message
class that receives sender and receiver IDs and raw data to be sent. Additionally, it supports saving messages to the disk and sending itself via the send
method:
class Message { public: Message(SenderId sender_id, ReceiverId receiver_id, const RawData& data) : sender_id_{sender_id}, receiver_id_{receiver_id}, raw_data_{data} {} SenderId sender_id() const { return sender_id_; } ReceiverId receiver_id() const { return receiver_id_; } void save(const std::string& file_path) const { // serializes a message to raw bytes and saves // to file system } std::string serialize() const { // serializes to JSON return {"JSON"}; } void send() const { auto sender = Communication::get_instance(); sender.send(sender_id_, receiver_id_, serialize()); } private: SenderId sender_id_; ReceiverId receiver_id_; RawData raw_data_; };
The Message
class is responsible for multiple concerns, such as saving messages from/to the filesystem, serializing data, sending messages, and holding the sender and receiver IDs and raw data. It would be better to separate these responsibilities into different classes or modules.
The Message
class is only responsible for storing the data and serializing it to JSON format:
class Message { public: Message(SenderId sender_id, ReceiverId receiver_id, const RawData& data) : sender_id_{sender_id}, receiver_id_{receiver_id}, raw_data_{data} {} SenderId sender_id() const { return sender_id_; } ReceiverId receiver_id() const { return receiver_id_; } std::string serialize() const { // serializes to JSON return {"JSON"}; } private: SenderId sender_id_; ReceiverId receiver_id_; RawData raw_data_; };
The save
method can be extracted to a separate MessageSaver
class, having a single responsibility:
class MessageSaver { public: MessageSaver(const std::string& target_directory); void save(const Message& message) const; };
And the send
method is implemented in a dedicated MessageSender
class. All three classes have a single and clear responsibility, and any further changes in any of them would not affect the others. This approach allows isolating the changes in the code base. It becomes crucial in a complex system requiring long compilation.
In summary, the SRP states that a class should have only one reason to change, meaning that a class should have only one responsibility. This principle is intended to promote code that is easy to understand, change, and test, and it helps in creating a more modular, maintainable, and scalable code base. By following this principle, developers can create classes that are small, focused, and easy to reason about.
Other applications of the SRP
The SRP can be applied not only to classes but also to larger components, such as applications. At the architecture level, the SRP is often implemented as microservices architecture. The idea of microservices is to build a software system as a collection of small, independent services that communicate with each other over a network rather than building it as a monolithic application. Each microservice is responsible for a specific business capability and can be developed, deployed, and scaled independently from the other services. This allows for greater flexibility, scalability, and ease of maintenance, as changes to one service do not affect the entire system. Microservices also enable a more agile development process, as teams can work on different services in parallel, and also allows for a more fine-grained approach to security, monitoring, and testing, as each service can be handled individually.
The Open-Closed Principle
The Open-Closed principle states that a module or class should be open for extension but closed for modification. In other words, it should be possible to add new functionality to a module or class without modifying its existing code. This principle helps to promote software maintainability and flexibility. An example of this principle in C++ is the use of inheritance and polymorphism. A base class can be written with the ability to be extended by derived classes, allowing for new functionality to be added without modifying the base class. Another example is using interfaces or abstract classes to define a contract for a set of related classes, allowing new classes to be added that conform to the contract without modifying existing code.
The Open-closed Principle can be used to improve our message-sending components. The current version supports only one message type. If we want to add more data, we need to change the Message
class: add fields, hold a message type as an additional variable, and not to mention serialization based on this variable. In order to avoid changes in existing code, let us rewrite the Message
class to be purely virtual, providing the serialize
method:
class Message { public: Message(SenderId sender_id, ReceiverId receiver_id) : sender_id_{sender_id}, receiver_id_{receiver_id} {} SenderId sender_id() const { return sender_id_; } ReceiverId receiver_id() const { return receiver_id_; } virtual std::string serialize() const = 0; private: SenderId sender_id_; ReceiverId receiver_id_; };
Now, let us assume that we need to add another two message types: a “start” message supporting start delay (often done for debugging purposes) and a “stop” message supporting stop delay (can be used for scheduling); they can be implemented as follows:
class StartMessage : public Message { public: StartMessage(SenderId sender_id, ReceiverId receiver_id, std::chrono::milliseconds start_delay) : Message{sender_id, receiver_id}, start_delay_{start_delay} {} std::string serialize() const override { return {"naive serialization to JSON"}; } private: const std::chrono::milliseconds start_delay_; }; class StopMessage : public Message { public: StopMessage(SenderId sender_id, ReceiverId receiver_id, std::chrono::milliseconds stop_delay) : Message{sender_id, receiver_id}, stop_delay_{stop_delay} {} std::string serialize() const override { return {"naive serialization to JSON"}; } private: const std::chrono::milliseconds stop_delay_; };
Note that none of the implementations requires changes in other classes, and each of them provides its own version of the serialize
method. The MessageSender
and MessageSaver
classes do not need additional adjustments to support the new class hierarchy of messages. However, we are going to change them too. The main reason is to make them extendable without requiring changes. For example, a message can be saved not only to the filesystem but also to remote storage. In this case, MessageSaver
becomes purely virtual:
class MessageSaver { public: virtual void save(const Message& message) const = 0; };
The implementation responsible for saving to the filesystem is a class derived from MessageSaver
:
class FilesystemMessageSaver : public MessageSaver { public: FilesystemMessageSaver(const std::string& target_directory); void save(const Message& message) const override; };
And the remote storage saver is another class in the hierarchy:
class RemoteMessageSaver : public MessageSaver { public: RemoteMessageSaver(const std::string& remote_storage_address); void save(const Message& message) const override; };
The Liskov Substitution Principle
The Liskov Substitution Principle (LSP) is a fundamental principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. This principle is also known as the Liskov principle, named after Barbara Liskov, who first formulated it. The LSP is based on the idea of inheritance and polymorphism, where a subclass can inherit the properties and methods of its parent class and can be used interchangeably with it.
In order to follow the LSP, subclasses must be “behaviorally compatible” with their parent class. This means that they should have the same method signatures and follow the same contracts, such as input and output types and ranges. Additionally, the behavior of a method in a subclass should not violate any of the contracts established in the parent class.
Let’s consider a new Message
type, InternalMessage
, which does not support the serialize
method. One might be tempted to implement it in the following way:
class InternalMessage : public Message { public: InternalMessage(SenderId sender_id, ReceiverId receiver_id) : Message{sender_id, receiver_id} {} std::string serialize() const override { throw std::runtime_error{"InternalMessage can't be serialized!"}; } };
In the preceding code, InternalMessage
is a subtype of Message
but cannot be serialized, throwing an exception instead. This design is problematic for a few reasons:
- It breaks the Liskov Substitution Principle: As per the LSP, if
InternalMessage
is a subtype ofMessage
, then we should be able to useInternalMessage
whereverMessage
is expected without affecting the correctness of the program. By throwing an exception in theserialize
method, we are breaking this principle. - The caller must handle exceptions: The caller of
serialize
must handle exceptions, which might not have been necessary when dealing with otherMessage
types. This introduces additional complexity and the potential for errors in the caller code. - Program crashes: If the exception is not properly handled, it could lead to the program crashing, which is certainly not a desirable outcome.
We could return an empty string instead of throwing an exception, but this still violates the LSP, as the serialize
method is expected to return a serialized message, not an empty string. It also introduces ambiguity, as it’s not clear whether an empty string is the result of a successful serialization of a message with no data or an unsuccessful serialization of InternalMessage
.
A better approach is to separate the concerns of a Message
and a SerializableMessage
, where only SerializableMessage
s have a serialize
method:
class Message { public: virtual ~Message() = default; // other common message behaviors }; class SerializableMessage : public Message { public: virtual std::string serialize() const = 0; }; class StartMessage : public SerializableMessage { // ... }; class StopMessage : public SerializableMessage { // ... }; class InternalMessage : public Message { // InternalMessage doesn't have serialize method now. };
In this corrected design, the base Message
class does not include a serialize
method, and a new SerializableMessage
class has been introduced that includes this method. This way, only messages that can be serialized will inherit from SerializableMessage
, and we adhere to the LSP.
Adhering to the LSP allows for more flexible and maintainable code, as it enables the use of polymorphism and allows for substituting objects of a class with objects of its subclasses without affecting the overall behavior of the program. This way, the program can take advantage of the new functionality provided by the subclass while maintaining the same behavior as the superclass.
The Interface Segregation Principle
The Interface Segregation Principle (ISP) is a principle in object-oriented programming that states that a class should only implement the interfaces it uses. In other words, it suggests that interfaces should be fine-grained and client-specific rather than having a single, large, and all-encompassing interface. The ISP is based on the idea that it is better to have many small interfaces that each define a specific set of methods rather than a single large interface that defines many methods.
One of the key benefits of the ISP is that it promotes a more modular and flexible design, as it allows for the creation of interfaces that are tailored to the specific needs of a client. This way, it reduces the number of unnecessary methods that a client needs to implement, and also it reduces the risk of a client depending on methods that it does not need.
An example of the ISP can be observed when creating our example messages from MessagePack or JSON files. Following the best practices, we would create an interface providing two methods, from_message_pack
and from_json
.
The current implementations need to implement both methods, but what if a particular class does not need to support both options? The smaller the interface, the better. The MessageParser
interface will be split into two separate interfaces, each requiring the implementation of either JSON or MessagePack:
class JsonMessageParser { public: virtual std::unique_ptr<Message> parse(const std::vector<uint8_t>& message_pack) const = 0; }; class MessagePackMessageParser { public: virtual std::unique_ptr<Message> parse(const std::vector<uint8_t>& message_pack) const = 0; };
This design allows for objects derived from JsonMessageParser
and MessagePackMessageParser
to understand how to construct themselves from JSON and MessagePack, respectively, while preserving the independence and functionality of each function. The system remains flexible as new smaller objects can still be composed to achieve the desired functionality.
Adhering to the ISP makes the code more maintainable and less prone to errors, as it reduces the number of unnecessary methods that a client needs to implement, and it also reduces the risk of a client depending on methods that it does not need.
The Dependency inversion principle
The Dependency inversion principle is based on the idea that it is better to depend on abstractions rather than on concrete implementations, as it allows for greater flexibility and maintainability. It allows the decoupling of high-level modules from low-level modules, making them more independent and less prone to changes in the low-level modules. This way, it makes it easy to change low-level implementations without affecting high-level modules and vice versa.
The DIP can be illustrated for our messaging system if we try to use all the components via another class. Let us assume that there is a class responsible for message routing. In order to build such a class, we are going to use MessageSender
as a communication module, Message
based classes, and MessageSaver
:
class MessageRouter { public: MessageRouter(ReceiverId id) : id_{id} {} void route(const Message& message) const { if (message.receiver_id() == id_) { handler_.handle(message); } else { try { sender_.send(message); } catch (const CommunicationError& e) { saver_.save(message); } } } private: const ReceiverId id_; const MessageHandler handler_; const MessageSender sender_; const MessageSaver saver_; };
The new class provides only one route
method, which is called once a new message is available. The router handles the message to the MessageHandler
class if the message’s sender ID equals the router’s. Otherwise, the router forwards the message to the corresponding receiver. In case the delivery of the message fails and the communication layer throws an exception, the router saves the message via MessageSaver
. Those messages will be delivered some other time.
The only problem is that if any dependency needs to be changed, the router’s code has to be updated accordingly. For example, if the application needs to support several types of senders (TCP and UDP), the message saver (filesystem versus remote) or message handler’s logic changes. In order to make MessageRouter
agnostic to such changes, we can rewrite it using the DIP principle:
class BaseMessageHandler { public: virtual ~BaseMessageHandler() {} virtual void handle(const Message& message) const = 0; }; class BaseMessageSender { public: virtual ~BaseMessageSender() {} virtual void send(const Message& message) const = 0; }; class BaseMessageSaver { public: virtual ~BaseMessageSaver() {} virtual void save(const Message& message) const = 0; }; class MessageRouter { public: MessageRouter(ReceiverId id, const BaseMessageHandler& handler, const BaseMessageSender& sender, const BaseMessageSaver& saver) : id_{id}, handler_{handler}, sender_{sender}, saver_{saver} {} void route(const Message& message) const { if (message.receiver_id() == id_) { handler_.handle(message); } else { try { sender_.send(message); } catch (const CommunicationError& e) { saver_.save(message); } } } private: ReceiverId id_; const BaseMessageHandler& handler_; const BaseMessageSender& sender_; const BaseMessageSaver& saver_; }; int main() { auto id = ReceiverId{42}; auto handler = MessageHandler{}; auto sender = MessageSender{ Communication::get_instance()}; auto saver = FilesystemMessageSaver{"/tmp/undelivered_messages"}; auto router = MessageRouter{id, sender, saver}; }
In this revised version of the code, MessageRouter
is now decoupled from specific implementations of the message handling, sending, and saving logic. Instead, it relies on abstractions represented by BaseMessageHandler
, BaseMessageSender
, and BaseMessageSaver
. This way, any class that derives from these base classes can be used with MessageRouter
, which makes the code more flexible and easier to extend in the future. The router is not concerned with the specifics of how messages are handled, sent, or saved – it only needs to know that these operations can be performed.
Adhering to the DIP makes code more maintainable and less prone to errors. It decouples high-level modules from low-level modules, making them more independent and less prone to changes in low-level modules. It also allows for greater flexibility, making it easy to change low-level implementations without affecting high-level modules and vice versa. Later in this book, dependency inversion will help us mock parts of the system while developing unit tests.