Book Image

Software Architecture with C++

By : Adrian Ostrowski, Piotr Gaczkowski
Book Image

Software Architecture with C++

By: Adrian Ostrowski, Piotr Gaczkowski

Overview of this book

Software architecture refers to the high-level design of complex applications. It is evolving just like the languages we use, but there are architectural concepts and patterns that you can learn to write high-performance apps in a high-level language without sacrificing readability and maintainability. If you're working with modern C++, this practical guide will help you put your knowledge to work and design distributed, large-scale apps. You'll start by getting up to speed with architectural concepts, including established patterns and rising trends, then move on to understanding what software architecture actually is and start exploring its components. Next, you'll discover the design concepts involved in application architecture and the patterns in software development, before going on to learn how to build, package, integrate, and deploy your components. In the concluding chapters, you'll explore different architectural qualities, such as maintainability, reusability, testability, performance, scalability, and security. Finally, you will get an overview of distributed systems, such as service-oriented architecture, microservices, and cloud-native, and understand how to apply them in application development. By the end of this book, you'll be able to build distributed services using modern C++ and associated tools to deliver solutions as per your clients' requirements.
Table of Contents (24 chapters)
1
Section 1: Concepts and Components of Software Architecture
5
Section 2: The Design and Development of C++ Software
6
Architectural and System Design
10
Section 3: Architectural Quality Attributes
15
Section 4: Cloud-Native Design Principles
21
About Packt

Dependency inversion principle

Dependency inversion is a principle useful for decoupling. In essence, it means that high-level modules should not depend on lower-level ones. Instead, both should depend on abstractions.

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 software development project that is supposed to have frontend and backend developers. A simple approach would be to write it like so:

class FrontEndDeveloper {
public:
void developFrontEnd();
};

class BackEndDeveloper {
public:
void developBackEnd();
};

class Project {
public:
void deliver() {
fed_.developFrontEnd();
bed_.developBackEnd();
}
private:
FrontEndDeveloper fed_;
BackEndDeveloper bed_;
};

Each developer is constructed by the Project class. This approach is not ideal, though, since now the higher-level concept, Project, depends on lower-level ones – modules for individual developers. Let's see how applying dependency inversion using polymorphism changes this. We can define our developers to depend on an interface as follows:

class Developer {
public:
virtual ~Developer() = default;
virtual void develop() = 0;
};

class FrontEndDeveloper : public Developer {
public:
void develop() override { developFrontEnd(); }
private:
void developFrontEnd();
};

class BackEndDeveloper : public Developer {
public:
void develop() override { developBackEnd(); }
private:
void developBackEnd();
};

Now, the Project class no longer has to know the implementations of the developers. Because of this, it has to accept them as constructor arguments:

class Project {
public:
using Developers = std::vector<std::unique_ptr<Developer>>;
explicit Project(Developers developers)
: developers_{std::move(developers)} {}

void deliver() {
for (auto &developer : developers_) {
developer->develop();
}
}

private:
Developers developers_;
};

In this approach, Project is decoupled from the concrete implementations and instead depends only on the polymorphic interface named Developer. 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 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, a generic lambda from C++14, and variant, either from C++17 or a third-party library such as Abseil or Boost. First are the developer classes:

class FrontEndDeveloper {
public:
void develop() { developFrontEnd(); }
private:
void developFrontEnd();
};

class BackEndDeveloper {
public:
void develop() { developBackEnd(); }
private:
void developBackEnd();
};

Now we don't rely on an interface anymore, so no virtual dispatch will be done. The Project class will still accept a vector of Developers:

template <typename... Devs>
class Project {
public:
using Developers = std::vector<std::variant<Devs...>>;

explicit Project(Developers developers)
: developers_{std::move(developers)} {}

void deliver() {
for (auto &developer : developers_) {
std::visit([](auto &dev) { dev.develop(); }, developer);
}
}

private:
Developers developers_;
};

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, 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. Since all our developer classes implement the develop function, the code will compile and run. If your developer classes would have different methods, you could, for instance, create a function object that has overloads of operator() for different types.

Because Project 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 MyProject = Project<FrontEndDeveloper, BackEndDeveloper>;
auto alice = FrontEndDeveloper{};
auto bob = BackEndDeveloper{};
auto new_project = MyProject{{alice, bob}};
new_project.deliver();

This approach is guaranteed to not allocate separate memory for each developer 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.

As the last thing to mention about dependency inversion, we'd like to note that there is a similarly named idea called dependency injection, which we even used in our examples. It's about injecting the dependencies through constructors or setters, which can be beneficial to code testability (think about injecting mock objects, for example). There are even whole frameworks for injecting dependencies throughout whole applications, such as Boost.DI. Those two concepts are related and often used together.