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

Chapter 6 Modularization

class Shape, and not on the embedded instance of the Rectangle used. This is one small drawback of this solution: some parts inherited from the base class Shape are idle.

Obviously, with this solution we will lose the possibility that an instance of Square can be assigned to a Rectangle:

std::unique_ptr<Rectangle> rectangle = std::make_unique<Square>(); // Compiler error!

The principle behind this solution to cope with inheritance problems in OO is called “favor composition over inheritance” (FCoI), sometimes also named “favor delegation over inheritance.” For the reuse of functionality, object-oriented programming basically has two options: inheritance (“white box reuse”) and composition or delegation (“black box reuse”). It is sometimes better to treat another type in a way as it would be a black box, that is, to use it only through its well-defined public interface, instead of deriving

a subtype from this type. Reuse by composition/delegation fosters looser coupling between classes than reuse by inheritance.

Interface Segregation Principle (ISP)

We know interfaces as a way to foster loose coupling between classes. In a previous section about the open-closed principle, you learned that interfaces are a way to have an extension and variation point in the code. An interface is like a contract: classes may request services through this contract, which may be offered by other classes that fulfill the contract.

But what problems can arise when these contracts become too extensive, that is, if an interface becomes too broad or “fat”? The consequences can best be demonstrated with an example. Check out the interface in Listing 6-12.

Listing 6-12.  An Interface for Birds

class Bird { public:

virtual ~Bird() = default;

virtual void fly() = 0; virtual void eat() = 0; virtual void run() = 0; virtual void tweet() = 0;

};

255

Chapter 6 Modularization

This interface is implemented by several concrete birds, for example, by a Sparrow. See Listing 6-13.

Listing 6-13.  The Sparrow Class Overrides and Implements All Pure Virtual Member Functions of Bird

class Sparrow : public Bird { public:

void fly() override { //...

}

void eat() override { //...

}

void run() override { //...

}

void tweet() override { //...

}

};

So far, so good. And now assume that we have another concrete Bird: a Penguin. See Listing 6-14.

Listing 6-14.  The Penguin Class

class Penguin : public Bird { public:

void fly() override { // ???

}

//...

};

Although a penguin is undoubtedly a bird, they cannot fly. Although our interface is relatively small, because it declares only four simple member functions, these declared services cannot, obviously, be offered by each bird species.

256

Chapter 6 Modularization

The interface segregation principle (ISP) states that an interface should not be bloated with member functions that are not required by implementing classes, or that these classes cannot implement in a meaningful way. In our example, the Penguin class cannot provide a meaningful implementation for Bird::fly(), but Penguin is enforced to overwrite that member function.

The interface segregation principle says that we should segregate a “fat interface” into smaller and highly cohesive interfaces. The resulting small interfaces are also referred to as role interfaces. See Listing 6-15.

Listing 6-15.  The Three Role Interfaces as a Better Alternative to the Broad Bird Interface

class Lifeform { public:

virtual ~Lifeform() = default; virtual void eat() = 0; virtual void move() = 0;

};

class Flyable { public:

virtual ~Flyable() = default; virtual void fly() = 0;

};

class Audible { public:

virtual ~Audible() = default; virtual void makeSound() = 0;

};

These small role interfaces can now be combined very flexibly. This means that the implementing classes only need to provide a meaningful functionality for those declared member functions, which they can implement in a sensible manner. See Listing 6-16.

257

Chapter 6 Modularization

Listing 6-16.  The Sparrow and Penguin Classes Implement the Relevant Interfaces

class Sparrow : public Lifeform, public Flyable, public Audible { //...

};

class Penguin : public Lifeform, public Audible { //...

};

Acyclic Dependency Principle

Sometimes there is the need for two classes to “know” each other. For example, let’s assume that we’re developing a web shop. So that certain use cases can be implemented, the class representing a customer in this web shop must know its related account.

For other use cases, it is necessary that the account can access its owner, which is a customer.

In UML, this mutual relationship looks like Figure 6-5.

Figure 6-5.  The association relationships between the Customer and Account classes

This is known as a circular dependency. Both classes, either directly or indirectly, depend on each other. In this case, there are only two classes. Circular dependencies can also occur with several software units involved.

Let’s look at how that circular dependency shown in Figure 6-4 can be implemented in C++.

What definitely would not work in C++ is Listings 6-17 and 6-18.

258

Chapter 6 Modularization

Listing 6-17.  The Contents of the Customer.h File

#pragma once

#include "Account.h"

class Customer { // ...

private:

Account account_;

};

Listing 6-18.  The Contents of the Account.h File

#pragma once

#include "Customer.h"

class Account { private:

Customer owner_;

};

I think that the problem is obvious here. As soon as someone used the Account or Customer classes, they would trigger a chain reaction while compiling. For example, the Account owns an instance of Customer who owns an instance of Account who owns an instance of Customer, and so on, and so on… Due to the strict processing order of C++ compilers, this implementation will result in compiler errors.

These compiler errors can be avoided, for example, by using references or pointers in combination with forward declarations. A forward declaration is the declaration of an identifier (e.g., of a type, like a class) without defining the full structure of that identifier. Therefore, such types are sometimes also called incomplete types. Hence, they can only be used for pointers or references, but not for an instance member variable, because the compiler knows nothing about its size. See Listings 6-19 and 6-20.

259

Chapter 6 Modularization

Listing 6-19.  The Modified Customer with a Forward-Declared Account

#pragma once

class Account;

class Customer { public:

// ...

void setAccount(Account* account) { account_ = account;

}

// ...

private:

Account* account_;

};

Listing 6-20.  The Modified Account with a Forward-Declared Customer

#pragma once

class Customer;

class Account { public:

//...

void setOwner(Customer* customer) { owner_ = customer;

}

//...

private:

Customer* owner_;

};

Hand on heart: do you feel a little bit unwell with this solution? If yes, it’s for good reasons! The compiler errors are gone, but this “fix” produces a bad gut feeling. Listing 6-21 shows how both classes are used.

260

Chapter 6 Modularization

Listing 6-21.  Creating the Instances of Customer and Account and Wiring Them Circularly Together

#include "Account.h" #include "Customer.h" // ...

Account* account = new Account { }; Customer* customer = new Customer { }; account->setOwner(customer); customer->setAccount(account);

// ...

I’m sure that a serious problem is obvious: what happens if, for example, the instance of Account will be deleted, but the instance of Customer still exists? Well, the instance

of Customer will contain a dangling pointer then, that is, a pointer to No-Man’s Land! Using or dereferencing such a pointer can cause serious issues, like undefined behavior and application crashes. Don’t have high hopes: using std::shared_ptr<T> instead of regular pointers is not a solution either. On the contrary, that will result in memory leaks.

Forward declarations are pretty useful for certain things, but using them to deal with circular dependencies is a really bad practice. It is a creepy workaround that is supposed to conceal a fundamental design problem.

The problem is the circular dependency itself. This is bad design. The Customer and Account classes cannot be separated. Thus, they cannot be used independently of one another, nor are they testable independently of one another. This makes unit testing considerably more difficult.

The problem gets even worse if we have the situation depicted in Figure 6-6.

261