There are many principles to keep in mind when writing code. Regardless of whether you are writing C++ in a mostly object-oriented programming manner or not, you should keep in mind the SOLID and DRY principles.
SOLID principles
SOLID is a set of practices that can help you write cleaner and less bug-prone software. It's an acronym made from the first letters of the five concepts behind it:
- Single responsibility principle
- Open-closed principle
- Liskov substitution principle
- Interface segregation
- Dependency Inversion
Single responsibility principle
In short, the Single Responsibility Principle (SRP) means each code unit should have exactly one responsibility. This means writing functions that do one thing only, creating types that are responsible for a single thing, and creating higher-level components that are focused on one aspect only.For instance, our class manages some type of resources, such as file handles, and parses strings to find numbers:
class FileManagerAndParser {
public:
int read(char* s, std::streamsize n) { return 0; }
void write(const char* s, std::streamsize n) {}
std::vector<int> parse(const std::string &s);
};
When maintaining this class and inheriting from it, you will need to track changes of both functionalities instead of doing it separately. Moreover, some derived classes may simply not need additional functionality. Therefore, better to split the class into two classes having one responsibility:
class FileManager {
public:
int read(char* s, std::streamsize n) { return 0; }
void write(const char* s, std::streamsize n) {}
};
class Parser {
std::vector<int> parse(const std::string &s);
};
Often, if you see a function with And in its name, it's violating the SRP and should be refactored. Another sign is when a function has comments indicating what each code block of the function does. Each such block would be probably better off as a distinct function.The most known anti-pattern violating the single responsibility principle is the use of God objects that know too much or do too much. Following the single responsibility principle means decomposing complex classes that do many things at once into simple specialized ones. This principle is intended to simplify further modifications and maintenance by reducing complexity, but excessive decomposition can be harmful as it introduces more complexity or makes maintenance more difficult.
A related topic is the principle of least knowledge, also known as the Law of Demeter. In its essence, it says that no object should know more than necessary about other objects, so it doesn't depend on any of their internals and an object should only communicate with its immediate neighbors. Applying it leads to more maintainable code with fewer interdependencies between components. These recommendations are easy to remember:
each unit should only know about the units that are closely related to it
each unit should only talk to its immediate friends
It shouldn’t talk to strangers
The principle was proposed by Ian Holland at Northeastern University in 1987. It was named after the Demeter project, which was itself inspired by Demeter, the Greek Goddess of Agriculture.
Open-closed principle
The Open-Closed Principle (OCP) states that software entities (such as functions, classes, and modules) are supposed to be both open for extension and closed for modification. Open for extension means that new functionalities can be added without changing the existing code. Closed for modification means existing software entities shouldn't be changed, as this can often cause bugs elsewhere in the system. A great example of this principle in C++ is the <<
operator in std::ostream
closed for modification, but you can extend this class to support your custom class. All you need to do is to overload the operator:
std::ostream &operator<<(std::ostream &stream, const MyPair<int, int>
&mp) {
stream << mp.firstMember() << ", ";
stream << mp.secondMember();
return stream;
}
Note that our implementation of operator<<
is a free (non-member) function. You should prefer those to member functions if possible as it actually helps encapsulation. For more details on this, consult the article by Scott Meyers in the Further reading section at the end of this chapter. If you don't want to provide public access to some field that you wish to print to ostream
, you can make operator<<
a friend function, as shown:
class MyPair {
// ...
friend std::ostream &operator<<(std::ostream &stream,
const MyPair &mp);
};
std::ostream &operator<<(std::ostream &stream, const MyPair &mp) {
stream << mp.first_ << ", ";
stream << mp.second_ << ", ";
stream << mp.secretThirdMember_;
return stream;
}
Friend classes, methods and functions in C++ are useful when a class and its friends have a special relationship and when protected and private members of the class must be hidden from other classes. In such cases, strong coupling is intentional, for example, as in the case of the <<
operator or for testing private class members.Note that this definition of OCP we discussed is slightly different from the more common one related to polymorphism. The latter is about creating base classes that can't be modified themselves but are open for others to inherit from them.Speaking of polymorphism, let's move on to the next principle, which uses it correctly.
Liskov substitution principle
In essence, the Liskov Substitution Principle (LSP) states that if a function uses a pointer or reference to a base class, the function must be able to use the pointer or reference to objects of derived classes without knowing it. This rule is sometimes broken because the techniques we apply in source code do not always work in real-world abstractions.A classic example is the relationship between squares and rectangles. Mathematically speaking, the former is a specialization of the latter, so there's an is a relationship between them. This tempts us to create a Square
class that inherits from the Rectangle
class. So, we could end up with code like the following:
class Rectangle {
public:
Rectangle(double width, double height) : width_(width), height_(height) {}
virtual ~Rectangle() = default;
virtual double area() { return width_ * height_; }
virtual void setWidth(double width) { width_ = width; }
virtual void setHeight(double height) { height_ = height; }
private:
double width_;
double height_;
};
class Square : public Rectangle {
public:
Square(double side) : Rectangle(side, side) {}
~Square() override = default;
double area() override { return Rectangle::area(); }
void setWidth(double width) override {
Rectangle::setWidth(width);
Rectangle::setHeight(width);
}
void setHeight(double height) override { setWidth(height); }
};
Casting the derived class Square to its base class Rectangle results in a conceptual error:
Rectangle* s1 = new Rectangle(2, 3);
Rectangle* s2 = new Square(4);
s2->setWidth(5);
s2->setHeight(6);
std::cout << s1->area() << std::endl; // 2*3=6 (expected)
std::cout << s2->area() << std::endl; // 6*6=36, but 5*6=30
How should we implement the members of the Square
class? If we want to follow the LSP and save the users of such classes from unexpected behavior, we can't: our square stops being a square if we set different values in setWidth
and setHeight
because the dimensions of a square are always equal. We can either stop having a square (not expressible using the preceding code) or modify the height as well, thus making the square look different than a rectangle. Therefore, the Square
class has problematic setWidth
and setHeight
functions as a workaround. If the width changes, the height changes too and vice versa, but we have the base class Rectangle and expect the sides to change independently.If your code violates the LSP, it's likely that you're using an incorrect abstraction. In our case, Square
shouldn't inherit from Rectangle
after all. A better approach could be making the two implement a Shape
interface:
class Shape {
public:
virtual double area() = 0;
virtual ~Shape() = default;
};
class Rectangle : public Shape {
public:
Rectangle(double width, double height) : width_(width), height_(height) {}
~Rectangle() override = default;
double area() override { return width_ * height_; }
virtual void setWidth(double width) { width_ = width; }
virtual void setHeight(double height) { height_ = height; }
private:
double width_;
double height_;
};
class Square : public Shape {
public:
Square(double side) : side_(side) {}
~Square() override = default;
double area() override { return side_ * side_; }
void setSide(double side) { side_ = side; }
private:
double side_;
};
The conceptual error is resolved without loss of functionality since the Shape class is the base class of both classes:
Shape* s1 = new Rectangle(2, 3);
Square* s = new Square(4);
s->setSide(5);
Shape* s2 = s;
std::cout << s1->area() << std::endl; // 2*3=6 (expected)
std::cout << s2->area() << std::endl; // 5*5=25 (expected)
Since we are on the topic of interfaces, let's move on to the next item, which is also related to them.
Interface segregation principle
The interface segregation principle is just about what its name suggests. It is formulated as follows:
No client should be forced to depend on methods that it does not use.
That sounds pretty obvious, but it has some connotations that aren't that obvious. Firstly, you should prefer more but smaller interfaces to a single big one. Secondly, when you're adding a derived class or are extending the functionality of an existing one, you should think before you extend the interface the class implements.Let's show this on an example that violates this principle, starting with the following interface:
class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
};
We could have a simple class that implements it:
class Blender : public IFoodProcessor {
public:
void blend() override;
};
So far so good. Now say we want to model another, more advanced food processor and we recklessly tried to add more methods to our interface:
class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
virtual void slice() = 0;
virtual void dice() = 0;
};
class AnotherFoodProcessor : public IFoodProcessor {
public:
void blend() override;
void slice() override;
void dice() override;
};
Now we have an issue with the Blender
class as it doesn't support this new interface – there's no proper way to implement it. We could try to hack a workaround or throw std::logic_error
, but a much better solution would be to just split the interface into two, each with a separate responsibility:
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 our AnotherFoodProcessor
can just implement both interfaces, and we don't need to change the implementation of our existing food processor.We have one last SOLID principle left, so let's learn about it now.
Dependency inversion principle
Dependency inversion is a principle useful for decoupling by inverting the dependency relationship. In essence, it means that high-level modules should not depend on lower-level ones. Instead, both should depend on the same abstraction because classes should not rely on the implementation details of their dependencies.C++ allows two ways to inverse the dependencies between your classes. The first one is the regular, polymorphic approach and the second uses templates. Let's see how to apply both of them in practice.Assume you're modeling a notification system that is supposed to have SMS and email channels. A simple approach would be to write it like so:
class SMSNotifier {
public:
void sendSMS(const std::string &message) {
std::cout << "SMS channel: " << message << std::endl;
}
};
class EMailNotifier {
public:
void sendEmail(const std::string &message) {
std::cout << "Email channel: " << message << std::endl;
}
};
class NotificationSystem {
public:
void notify(const std::string &message) {
sms_.sendSMS(message);
email_.sendEmail(message);
}
private:
SMSNotifier sms_;
EMailNotifier email_;
};
Each notifier is constructed by the NotificationSystem
class. This approach is not ideal, though, since now the higher-level concept, NotificationSystem
, depends on lower-level ones – modules for individual notifiers. Let's see how applying dependency inversion using polymorphism changes this. We can define our notifiers to depend on an interface as follows:
class Notifier {
public:
virtual ~Notifier() = default;
virtual void notify(const std::string &message) = 0;
};
class SMSNotifier : public Notifier {
public:
void notify(const std::string &message) override { sendSMS(message); }
private:
void sendSMS(const std::string &message) {
std::cout << "SMS channel: " << message << std::endl;
}
};
class EMailNotifier : public Notifier {
public:
void notify(const std::string &message) override { sendEmail(message); }
private:
void sendEmail(const std::string &message) {
std::cout << "Email channel: " << message << std::endl;
}
};
Now, the NotificationSystem
class no longer has to know the implementations of the notifiers. Because of this, it has to accept them as constructor arguments:
class NotificationSystem {
public:
using Notifiers = std::vector<std::unique_ptr<Notifier>>;
explicit NotificationSystem(Notifiers notifiers)
: notifiers_{std::move(notifiers)} {}
void notify(const std::string &message) {
for (const auto ¬ifier : notifiers_) {
notifier->notify(message);
}
}
private:
Notifiers notifiers_;
};
In this approach, NotificationSystem
is decoupled from the concrete implementations and instead depends only on the polymorphic interface named Notifier
. The lower level concrete classes also depend on this interface. This can help you shorten your build time and allows for much easier unit testing – now you can easily pass mocks as arguments in your test code.Using dependency inversion with virtual dispatch comes at a cost, however, as now we're dealing with memory allocations and the dynamic dispatch has overhead on its own. Sometimes C++ compilers can detect that only one implementation is being used for a given interface and will remove the overhead by performing devirtualization (often you need to mark the function as final
for this to work). Here, however, two implementations are used, so the performance cost of dynamic dispatch (commonly implemented as jumping through virtual method tables, or vtables for short) must be paid.There is another way of inverting dependencies that doesn't have those drawbacks. Let's see how this can be done using a variadic template from C++11, a generic lambda from C++14, and variant, either from C++17 or a third-party library such as Abseil or Boost.If you're not familiar with variant
, it's just a class that can hold any of the types passed as template parameters. Because we're using a variadic template that can have any number of parameters, we can pass however many types we like. To call a function on the object stored in the variant, we can either extract it using std::get
or use std::visit
and a callable object – in our case, the generic lambda. It shows how duck-typing looks in practice.First are the notifier classes:
class SMSNotifier {
public:
void notify(const std::string &message) { sendSMS(message); }
private:
void sendSMS(const std::string &message) {
std::cout << "SMS channel: " << message << std::endl;
}
};
class EMailNotifier {
public:
void notify(const std::string &message) { sendEmail(message); }
private:
void sendEmail(const std::string &message) {
std::cout << "Email channel: " << message << std::endl;
}
};
Now we don't rely on an interface anymore, so no virtual dispatch will be done. The NotificationSystem
class will still accept a vector of Notifiers
:
template <typename... T>
class NotificationSystem {
public:
using Notifiers = std::vector<std::variant<T...>>;
explicit NotificationSystem(Notifiers notifiers)
: notifiers_{std::move(notifiers)} {}
void notify(const std::string &message) {
for (auto ¬ifier : notifiers_) {
std::visit([&](auto &n) { n.notify(message); }, notifier);
}
}
private:
Notifiers notifiers_;
};
Since all our notifier classes implement the notify
function, the code will compile and run. If your notifier classes would have different methods, you could, for instance, create a function object that has overloads of operator()
for different types.Because NotificationSystem
is now a template, we have to either specify the list of types each time we create it or provide a type alias. You can use the final class like so:
using MyNotificationSystem = NotificationSystem<SMSNotifier, EMailNotifier>;
auto sn = SMSNotifier{};
auto en = EMailNotifier{};
auto ns = MyNotificationSystem{{sn, en}};
ns.notify("Quinn, Wade, Arturo, Rembrandt");
This approach is guaranteed to not allocate separate memory for each notifier or use a virtual table. However, in some cases, this approach results in less extensibility, since once the variant is declared, you cannot add another type to it.
It’s noteworthy that we used dependency injection in our examples. It is a software engineering technique to implement the dependency inversion principle. It's about injecting the dependencies from the outside through constructors or setters rather than creating them internally, which is beneficial to code testability (think about injecting mock objects, for example). There are frameworks for injecting dependencies across entire applications, such as Boost.DI, Google Fruit, Hypodermic, Kangaru, Wallaroo.
The DRY rule
DRY is short for don't repeat yourself. It means you should avoid code duplication, and reuse when it's possible. This means you should create a function or a function template if your code repeats similar operations a few times. Also, instead of creating several similar types, you should consider writing a template.Let’s look at an example where two functions implement the same functionality and see how we can eliminate the duplication using a template:
// two functions implement the same functionality to return minimal int and double values
int minimum(const int& x, const int& y) { return x < y ? x : y; }
double minimum(const double& x, const double& y) { return x < y ? x : y; }
// one template function replaces them to remove duplicated functionality
template <typename T>
T minimum(const T& x, const T& y) {
return x < y ? x : y;
}
// the calls do not differ before and after applying the rule
cout << minimum(3, 5) << endl;
cout << minimum(3.0, 5.0) << endl;
It's also important not to reinvent the wheel when it's not necessary, that is, not to repeat others' work. Nowadays there are dozens of well-written and mature libraries that can help you with writing high-quality software faster. We'd like to specifically mention a few of them: Boost, Folly, Abseli, Qt, EASTL, BDE.Sometimes duplicating code can have its benefits, however. One such scenario is developing microservices. Of course, it's always a good idea to follow DRY inside a single microservice, but violating the DRY rule for code used in multiple services can actually be worth it. Whether we're talking about model entities or logic, it's easier to maintain multiple services when code duplication is allowed.Imagine having multiple microservices reusing the same code for an entity. Suddenly one of them needs to modify one field. All the other services now have to be modified as well. The same goes for dependencies of any common code. With dozens or more microservices that have to be modified because of changes unrelated to them, it's often easier for maintenance to just duplicate the code.Since we're talking about dependencies and maintenance, let's proceed to the next section, which discusses a closely related topic.