Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
roth_stephan_clean_c20_sustainable_software_development_patt.pdf
Скачиваний:
29
Добавлен:
27.03.2023
Размер:
7.26 Mб
Скачать

Chapter 9 Design Patterns and Idioms

As a consequence, we completely forfeit the possibility to take advantage of polymorphism to supply an alternative implementation. Just think about unit testing. How can it succeed at all to implement a real unit test, if something is used inside the implementation of the class to be tested that cannot be replaced easily by a test double? See the section about test doubles in Chapter 2.

And remember all the rules for good unit tests discussed in Chapter 2, especially unit-test independence. A global object like a singleton sometimes holds a mutable state. How can the independence of tests be ensured, if many or nearly all of the classes in a code base are dependent on one single object that has a lifecycle that ends with the termination of the program, and that possibly holds a state that is shared between them?

Another disadvantage of singletons is that if they have to be changed due to new or changing requirements, this change could trigger a cascade of changes in all the dependent classes. All the dependencies visible in Figure 9-1 pointing to the singleton are potential propagation paths for changes.

Finally, it is also very difficult to ensure in a distributed system—which, by the way, is the normal case in software architecture nowadays—that exactly one instance of a class exists. Just imagine the Microservices pattern, where a complex software system is composed of many small, independent, and distributed processes. In such an environment, singletons are not only difficult to protect against multiple instantiations, but they are also problematic because of the tight coupling they foster.

So, maybe you will ask now: “Okay, I’ve got it, singletons are basically bad, but what are the alternatives?” The perhaps surprisingly simple answer, which of course requires some further explanation, is this: Just create one and inject it everywhere its needed!

Dependency Injection to the Rescue

In the aforementioned interview with Erich Gamma et al., the authors also made a statement about those design patterns, which they would like to include in a new revision of their book. They nominated only a few patterns that would possibly make it into their legendary work and one of them is dependency injection.

Basically, dependency injection (DI) is a technique in which the independent service objects needed by a dependent client object are supplied from the outside. The client object does not have to take care of its required service objects itself, or actively request the service objects, for example, from a factory (see the Factory pattern later in this chapter), or from a service locator.

383

Chapter 9 Design Patterns and Idioms

The intent behind DI could be formulated as follows:

“Decouple components from their required services in such a way that the components do not have to know the names of these services, nor how they have to be acquired.”

Let’s look at a specific example, the Logger already mentioned, for example, a service class, which offers the possibility to write log entries. Such loggers have often been implemented as singletons. Hence, every client of the logger is dependent on that global singleton object, as depicted in Figure 9-2.

Figure 9-2.  Three domain-specific classes of a web shop are dependent on the logger singleton

Listing 9-3 shows how the logger singleton class might look in source code (only the relevant parts are shown).

Listing 9-3.  The Logger Implemented as a Singleton

#include <string_view>

class Logger final { public:

static Logger& getInstance() { static Logger theLogger { }; return theLogger;

}

384

Chapter 9 Design Patterns and Idioms

void writeInfoEntry(std::string_view entry) { // ...

}

void writeWarnEntry(std::string_view entry) { // ...

}

void writeErrorEntry(std::string_view entry) { // ...

}

};

STD::STRING_VIEW [SINCE C++17]

Since C++17, there is a new class available in the C++ language standard: std:: string_ view (defined in the <string_view> header). Objects of this class are very performant proxies (Proxy is, by the way, also a design pattern) of a string, which are cheap to construct (there is no memory allocation for raw string data) and thus also cheap to copy.

Another nice feature is that std::string_view can also serve as an adaptor for C-style strings (char*), character arrays, and even for proprietary string implementations from different frameworks such as CString (MFC) or QString (Qt):

CString aString("I'm a string object of the MFC type CString"); std::string_view viewOnCString { (LPCTSTR)aString };

Therefore, it is the ideal class to represent strings whose data is already owned by someone else and if read-only access is required, for example, for the duration of a function’s execution. For instance, instead of the widespread constant references to std::string, now std::string_view should be used as a replacement for read-only string function parameters in a modern C++ program.

We now just pick out for demonstration purposes one of those many classes that use the logger singleton in its implementation to write log entries, the CustomerRepository class, shown in Listing 9-4.

385

Chapter 9 Design Patterns and Idioms

Listing 9-4.  An Excerpt from the CustomerRepository Class

#include "Customer.h" #include "Identifier.h" #include "Logger.h"

class CustomerRepository { public:

//...

Customer findCustomerById(const Identifier& customerId) { Logger::getInstance().writeInfoEntry("Starting to search for a customer specified by a

given unique identifier..."); // ...

}

// ...

};

In order to get rid of the singleton and replace the Logger object with a test double during unit tests, we must first apply the dependency inversion principle (DIP; see Chapter 6). This means that we have to introduce an abstraction (an interface) and make both the CustomerRepository and the concrete Logger dependent on that interface, as depicted in Figure 9-3.

Figure 9-3.  Decoupling through the applied dependency inversion principle

386

Chapter 9 Design Patterns and Idioms

Listing 9-5 shows how the newly introduced LoggingFacility interface looks in source code.

Listing 9-5.  The LoggingFacility Interface

#include <memory>

#include <string_view>

class LoggingFacility { public:

virtual ~LoggingFacility() = default;

virtual void writeInfoEntry(std::string_view entry) = 0; virtual void writeWarnEntry(std::string_view entry) = 0; virtual void writeErrorEntry(std::string_view entry) = 0;

};

using Logger = std::shared_ptr<LoggingFacility>;

The StandardOutputLogger is one example of a specific Logger class that implements the LoggingFacility interface and writes the log on standard output, as its name suggests. See Listing 9-6.

Listing 9-6.  One Possible Implementation of a LoggingFacility: the

StandardOutputLogger

#include "LoggingFacility.h" #include <iostream>

class StandardOutputLogger : public LoggingFacility { public:

void writeInfoEntry(std::string_view entry) override { std::cout << "[INFO] " << entry << std::endl;

}

void writeWarnEntry(std::string_view entry) override { std::cout << "[WARNING] " << entry << std::endl;

}

387

Chapter 9 Design Patterns and Idioms

void writeErrorEntry(std::string_view entry) override { std::cout << "[ERROR] " << entry << std::endl;

}

};

Next, we need to modify the CustomerRepository class. First, we create a new member variable of the smart pointer type alias Logger. This pointer instance is passed into the class via an initialization constructor. In other words, we allow an instance

of a class that implements the LoggingFacility interface to be injected into the CustomerRepository object during construction. We also delete the default constructor, because we do not want to allow a CustomerRepository to be created without a logger. Furthermore, we remove the direct dependency in the implementation to the singleton and instead use the smart pointer Logger to write log entries. See Listing 9-7.

Listing 9-7.  The Modified Customer Repository Class

#include "Customer.h" #include "Identifier.h" #include "LoggingFacility.h"

class CustomerRepository { public:

CustomerRepository() = delete;

explicit CustomerRepository(const Logger& loggingService) : logger { loggingService } { }

//...

Customer findCustomerById(const Identifier& customerId) { logger->writeInfoEntry("Starting to search for a customer specified by a given unique identifier...");

// ...

}

// ...

private: // ...

Logger logger;

};

388

Chapter 9 Design Patterns and Idioms

As a consequence of this refactoring, the CustomerRepository class is no longer dependent on a specific logger. Instead, the CustomerRepository simply has a dependency on an abstraction (interface) that is now explicitly visible in the class and its interface, because it is represented by a member variable and a constructor parameter. That means that the CustomerRepository class now accepts service objects for logging purposes that are passed in from outside, as shown in Listing 9-8.

Listing 9-8.  The Logger Object Is Injected Into the Instance of CustomerRepository

Logger logger = std::make_shared<StandardOutputLogger>(); CustomerRepository customerRepository { logger };

This design change has significantly positive effects. A loose coupling is promoted, and the client object CustomerRepository can now be configured with various service objects that provide logging functionality, as can be seen in the UML class diagram in Figure 9-4.

Figure 9-4.  The CustomerRepository class can be supplied with specific logging implementations via its constructor

Moreover, the testability of the CustomerRepository class has been significantly improved. There are no hidden dependencies to singletons anymore. Now we can easily replace a real logging service by a mock object (see Chapter 2 about unit tests and test doubles). We can equip the mock object with spy methods, for example, to check inside the unit test and determine which data would leave the CustomerRepository object via the LoggingFacility interface. See Listing 9-9.

389

Chapter 9 Design Patterns and Idioms

Listing 9-9.  A Test Double (Mock Object) to Unit Test Classes That Have a Dependency on LoggingFacility

namespace test {

#include "../src/LoggingFacility.h"

#include <string>

#include <string_view>

class LoggingFacilityMock : public LoggingFacility { public:

void writeInfoEntry(std::string_view entry) override { recentlyWrittenLogEntry = entry;

}

void writeWarnEntry(std::string_view entry) override { recentlyWrittenLogEntry = entry;

}

void writeErrorEntry(std::string_view entry) override { recentlyWrittenLogEntry = entry;

}

std::string_view getRecentlyWrittenLogEntry() const { return recentlyWrittenLogEntry;

}

private:

std::string recentlyWrittenLogEntry;

};

using MockLogger = std::shared_ptr<LoggingFacilityMock>;

}

In the unit test in Listing 9-10, you can see the mock object in action.

390

Chapter 9 Design Patterns and Idioms

Listing 9-10.  An Example Unit Test Using the Mock Object

#include "../src/CustomerRepository.h" #include "LoggingFacilityMock.h" #include <gtest/gtest.h>

namespace test {

TEST(CustomerTestCase, WrittenLogEntryIsAsExpected) { MockLogger logger = std::make_shared<LoggingFacilityMock>(); CustomerRepository customerRepositoryToTest { logger }; Identifier customerId { 1234 };

customerRepositoryToTest.findCustomerById(customerId);

ASSERT_EQ("Starting to search for a customer specified by a given unique identifier...",

logger->getRecentlyWrittenLogEntry());}

}

In the previous example, I presented dependency injection as a pattern to remove annoying singletons, but of course this is only one of many applications. Basically,

a good object-oriented software design should ensure that the involved modules or components are as loosely coupled as possible, and dependency injection is the key to this goal. By consistently applying this pattern, a software design will emerge that has a very flexible plug-in architecture. As a kind of positive side effect, this technique results in highly testable objects as well.

The responsibility for object creation and linking is removed from the objects themselves and is centralized in an infrastructure component, the so-called assembler or injector. This component (see Figure 9-5) usually operates at program startup and processes something like a “construction plan” (e.g., a configuration file) for the whole software system; that is, it instantiates the objects and services in the correct order and injects the services into the objects that needs them.

391

Chapter 9 Design Patterns and Idioms

Figure 9-5.  The assembler is responsible for object creation and injection

Pay attention to the pleasant dependency situation. The direction of the creation dependencies (dashed arrows with stereotype «Create») leads away from the Assembler to the other modules (classes). In other words, no class in this design “knows” that such an infrastructure element like an Assembler exists. (That’s not completely correct, because at

least one other element in the software system knows about the existence of this component, because the assemble process must be triggered by someone, usually at program start.)

Somewhere within the Assembler component, something like the code in Listing 9-11 could possibly be found.

Listing 9-11.  Parts of the Implementation of the Assembler Could Look Like This

// ...

Logger loggingServiceToInject = std::make_shared<StandardOutputLogger>();

auto customerRepository = std::make­ _shared<CustomerRepository>

(loggingServiceToInject);

// ...

392

Chapter 9 Design Patterns and Idioms

This DI technique is called constructor injection, because the service object to be injected is passed as an argument to an initialization constructor of the client object. The advantage of constructor injection is that the client object gets completely initialized during its construction and is immediately usable then.

But what do we do if service objects are to be injected into client objects while the program is running, for instance, if a client object is only occasionally created during program execution, or the specific logger should be exchanged at runtime? Then the client object must provide a setter for the service object, as in the example in Listing 9-12.

Listing 9-12.  The Customer Class Provides a Setter to Inject a Logger

#include "Address.h" #include "LoggingFacility.h"

class Customer { public:

Customer() = default;

void setLoggingService(const Logger& loggingService) { logger = loggingService;

}

//...

private:

Address address; Logger logger;

};

This DI technique is called setter injection. And, of course, it is also possible to combine constructor injection and setter injection.

Dependency injection is a design pattern that makes a software design loosely coupled and eminently configurable. It allows the creation of different product configurations for different customers or intended purposes of a software product. It greatly increases the testability of a software system, since it enables developers to inject mock objects very easily. Therefore, this pattern should not be ignored when designing any serious software system. If you want to dive deeper into this pattern, I recommend you read the trend-setting blog article “Inversion of Control Containers and the Dependency Injection pattern” written by Martin Fowler [Fowler04].

393