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

Chapter 6 Modularization

structure of an object-oriented program on an abstract level. But Mr. Kay’s elucidations are definitely not sufficient enough to answer the following important questions:

•\

How do I find and form the “cells” (objects)?

•\

How do I design the publically available interface of those cells?

•\

How do I govern who can communicate with whom (dependencies)?

Object-orientation (OO) is primarily a mindset, and less a matter of the language used. And it can also be abused and misapplied.

I’ve seen many programs written in C++, or in a pure OO-language like Java, where classes are used, but these classes only constitute large namespaces wrapping a procedural program. Or slightly sarcastically expressed: Fortran-like programs can be written in nearly any programming language, obviously. On the other hand, every

developer who has internalized object-oriented thinking will be able to develop software with an object-oriented design even in languages like ANSI-C, Assembler, or using shell scripts.

Principles for Good Class Design

The widespread and well-known mechanism for the formation of the previously described modules in object-oriented languages is the concept of a class. Classes are considered encapsulated software modules that combine structural features (attributes, data members, fields) and behavioral features (member functions, methods, operations) together into one cohesive unit.

In programming languages with object-oriented facilities like C++, classes are the next higher structuring concept above functions. They are often described as the

blueprints of the objects (instances). That’s reason enough to investigate the concept of classes further. In this section, I give several important clues for designing and writing good classes in C++.

Keep Classes Small

In my career as a software developer, I have seen many classes that were very large. Many thousands of lines of code were no rarity. On closer inspection, I’ve noticed that these large classes often were only used as namespaces for a more or less procedural program, whose developers commonly did not understand object-orientation.

230

Chapter 6 Modularization

I think that the problems with such large classes are obvious. If classes contain several thousand lines of code, they are difficult to understand, and their maintainability and testability is usually bad, not to mention reusability. And according to several studies, large classes generally contain a higher number of defects. And, of course, they usually always violate the SRP.

THE GOD CLASS ANTI-PATTERN

In many systems, there are exceptionally large classes with many attributes and several hundred operations. The names of these classes often end with “…Controller,” “…Manager,” or “…Helpers.” Developers often argue that somewhere in the system must be one central instance that pulls the strings and coordinates everything. The results of this way of thinking are such giant classes with very poor cohesion (see the section about strong cohesion in Chapter 3). They are like a convenience store that offers a colorful palette of goods.

Such classes are called God Classes, God Objects, or sometimes also The Blob (The Blob is a 1958 American horror/science-fiction film about an alien amoeba that eats the citizens of a village.) This is a so-called anti-pattern, a synonym for what is perceived as bad design. A God Class is an untamable beast, horrible to maintain, difficult to understand, not testable, error prone, and has also a huge amount of dependencies to other classes. During the lifecycle of the system, such classes get bigger and bigger. This makes the problems worse.

What has been proven as a good rule for a function’s size (see the section entitled “Let Them be Small” in Chapter 4), seems to be also good advice for the size of classes:

Classes should be small!

If small size is an objective in class design, then the immediate next question is this: How small?

For functions, I’ve given a number of lines of code in Chapter 4. Wouldn’t it be even possible to define a number of lines for classes that would be perceived as good or proper?

In The ThoughtWorks Anthology [Thought08], Jeff Bay contributed an essay entitled “Object Calisthenics: 9 Steps to Better Software Design Today” that advises no more than 50 lines of code for a single class.

An upper limit of about 50 lines seems to be out of the question for many developers. It appears that they feel a kind of unexplainable resistance against creating classes. They often argue as follows: “Not more than 50 lines? But that will result in a huge amount

231

Chapter 6 Modularization

of tiny little classes, with just a few members and functions.” And then they will surely conjure up an example that is irreducible to classes of such a small size.

I’m convinced that those developers are totally wrong. I’m pretty sure that every software system can be decomposed into such small elementary building blocks.

Yes, if classes are to be small, you will have more of them. But that’s OO! In object-­ oriented software development, a class is an equally natural language element such as a function or a variable. In other words, do not be afraid to create small classes. Small classes are much easier to use, to understand, and to test.

Nonetheless, that leads to a fundamental question: Is the definition of an upper limit for lines of code basically the right way? I think that the metric of lines of code (LOC) can be a helpful indicator. Too many LOCs are a smell. You can take a careful look at classes with more than 50 lines. But it is not necessarily the case that many lines of code are always a problem. A much better criterion is the amount of responsibilities of a class. Classes that follow the SRP are usually small and have few dependencies. They are clear, easy to understand, and can be tested easily.

Responsibility is a much better criterion than the amount of lines of code of a class. There can be classes with 100, 200, or even 500 lines, and it can be perfectly okay if those classes do not violate the single responsibility principle. Nonetheless, a high LOC count can be an indicator. It is a clue that says: “You should take a look at these classes! Maybe everything is fine, but maybe they are so big because they have too many responsibilities.”

Open-Closed Principle (OCP)

“All systems change during their lifecycles. This must be borne in mind when developing systems expected to last longer than the first version.”

—Ivar Jacobson, Swedish computer scientist, 1992

Another important guideline for any kind of software unit, but especially for class design, is the open-closed principle (OCP). It states that software entities (modules, classes, functions, etc.) should be open for extension, but closed for modification.

It is a simple fact that software systems evolve over time. New requirements must constantly be satisfied, and existing requirements must be changed according to customer needs or technology progress. These extensions should be made not only in an elegant manner and with as little effort as possible. They should be especially made in such a way that existing code does not need to be changed. It would be fatal if any new

232

Chapter 6 Modularization

requirement led to a cascade of changes and adjustments in existing and well-tested parts of the software.

One way to support this principle in object-orientation is the concept of inheritance (we will discuss another way in the following section). With inheritance it is possible to add new functionality to a class without modifying that class. Furthermore, there are many object-oriented design patterns that foster OCP, such as strategy or decorator (see Chapter 9 about design patterns).

In the section about loose coupling in Chapter 3, we discussed a design that supports OCP very well (see Figure 3-6). There we decoupled a switch and a lamp through an interface. Through this step, the design is closed against modification but pleasantly open for extensions. We can add more switchable devices easily, and we don’t need to touch the Switch and Lamp classes or the Switchable interface. And as you can easily imagine, another advantage of such a design is that it is very easy to provide a test double (e.g., a mock object) for testing purposes (see the section about test doubles in Chapter 2).

But is an interface, which in C++ is nothing but an abstract class as a base type of a type hierarchy, the only way to support the OCP?

A Short Comparison of Type Erasure Techniques

“Inheritance is the base class of evil.”

—Sean Parent, GoingNative 2013

In January 2020, I was at the conference OOP in Munich, one of the most famous software developer conferences in German-speaking countries and beyond. One evening I had dinner at the hotel with Peter Sommerlad, member of the ISO C++ standardization committee and co-author of the seminal work Pattern-Oriented Software Architecture. When we came to talk about the first edition of Clean C++, he gave me an interesting feedback: “Too much virtual.”

So, I think it is time to talk about inheritance and dynamic polymorphism—their advantages, disadvantages, and alternatives.

When developers are asked about the central core concept and killer feature of OO, they often mention dynamic polymorphism. Polymorphism, a compound word of the Greek prefix “poly-” for many, and the suffix “-morph” for the form or shape, means the provision of a single interface to entities of different types. In fact, dynamic polymorphism is just a special form of a more general concept in C++ called type erasure.

233

Chapter 6 Modularization

TYPE ERASURE

C++ type erasure is a set of techniques that provide a generic interface to various underlying types, while hiding the underlying type information from the client code. In other words, the client code does not know the concrete types; it only knows and uses some kind of abstract interface. Thus, it is also an application of the information hiding principle from Chapter 3 and also makes the code more open-closed.

Note that type erasure in C++ is different than what is known by the same term in Java.

In other words, introducing an OO-style type hierarchy with an abstract base class as the single interface to all derived classes is only one way to realize type erasure. It is certainly not always the best solution under all circumstances, because it has, for instance, a few disadvantageous, although mostly only with small effects on performance. The quality requirements that the software have to satisfy, as well as the constraints of the execution environment, play a very important role here. In a demanding environment with very ambitious performance requirements or limited memory, as is sometimes the case in embedded software development, an OO-based approach can quickly become problematic. Another disadvantage is that we are

somehow forced to use them predominantly via pointers or references, and we have to take care about the resource management (memory allocation and deallocation).

This is what Peter Sommerlad meant with his above quoted point of criticism, “too much virtual.” But what other forms of type erasure are there in C++?

C had a primitive form of type erasure, namely using a void pointer (void*). An example is the C Standard Library function qsort that uses the well-known QuickSort algorithm to sort a given array (although the C standard does not require it to implement as a QuickSort):

void qsort(void* base, size_t nitems, size_t size, int(*comparator)(const void*, const void*));

The last parameter of qsort() is the function that compares two elements. The idea is to provide a high degree of flexibility so that qsort() can be used for any given type and with user-defined sorting criteria. As you can see, these two elements are represented by two unsafe void pointers.

234

Chapter 6 Modularization

Even if this is another form of type erasure, functions of the C Standard Library should of course no longer be used in a modern C++ program; remember the section entitled “About Old C-style in C++ Projects” in Chapter 4.

A much safer way to implement type erasure is using C++ templates.

•\

The std::function class template (since C++11; header

 

<functional>) is a general-purpose polymorphic function wrapper,

 

i.e., it provides a uniform interface to a function, a function-like

 

object, or a lambda expression with a specified call signature. We

 

discuss this template in more detail in Chapter 7 on functional

 

programming.

•\

The std::variant class template (since C++17; header <variant>)

 

represents something like a type-safe union. An instance of a

 

std::variant can hold a value typed by one of the types specified as

 

its template arguments. For example, a std::variant<int, double>

 

can hold either an integral value or a double precision floating-point

 

value (and in some rare cases when something goes wrong, it can

 

also hold nothing).

•\

The Algorithm Library (the <algorithm> header) defines numerous

 

flexible function templates for a variety of purposes (see the

 

section entitled “Take Advantage of <algorithm>” in Chapter 5).

 

For example, there is also a type-safe replacement for the legacy C

 

function qsort() discussed previously: std::sort(). This function

 

template works for all data types and for different data containers,

 

e.g., old C-style arrays. Furthermore, it is faster than C’s qsort(),

 

because C++ compilers can optimize templated code.

In addition to these possibilities provided by the C++ Standard Library, developers can of course implement type erasure with templates. Let’s consider the example in Listings 6-1 and 6-2 with dynamic polymorphism in OO.

Listing 6-1.  A Simple Class Hierarchy

#include <string> #include <memory>

235

Chapter 6 Modularization

class Fruit { public:

virtual ~Fruit() = default;

virtual std::string getTypeOfInstanceAsString() const = 0;

};

class Apple final : public Fruit {

std::string getTypeOfInstanceAsString() const override { return "class Apple";

}

};

class Peach final : public Fruit {

std::string getTypeOfInstanceAsString() const override { return "class Peach";

}

};

using FruitPointer = std::shared_ptr<Fruit>;

Listing 6-2.  Concrete Instances Used via Their Abstract Base Classes

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

#include <vector>

using Fruits = std::vector<FruitPointer>;

int main() {

FruitPointer fruit1 = std::make_shared<Apple>(); FruitPointer fruit2 = std::make_shared<Peach>(); Fruits fruits{ fruit1, fruit2 };

for (const auto& fruit : fruits) {

std::cout << fruit->getTypeOfInstanceAsString() << ", ";

}

std::cout << std::endl;

return 0;

}

236

Chapter 6 Modularization

This object-oriented variant of type erasure is type-safe, simple, and straightforward, but has the known small disadvantage of dynamic polymorphism: each lookup in

the virtual function table costs a tiny little bit of runtime performance. I think in most applications, this drawback is irrelevant (remember “Be Careful with Optimizations” in Chapter 3), but maybe in some time-critical environments it might be an issue.

Furthermore, inheritance is one of the strongest forms of tight coupling; it is white-box-­ reuse because the derived classes know their base class and its implementation.

Let’s now discuss an alternative implementation using C++ templates: The erasure idiom, also known as “duck-typing”.

DUCK-TYPING

The U.S. writer and poet James Whitcomb Riley (1849 – 1916) was supposed to have coined the phrase: “When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck.”

The so-called “duck test” is a form of abductive reasoning. The test says that people can identify an unknown subject by just studying that subject’s behavior or its habitual

characteristics. In object-oriented programming, this principle is used to specify the type of a thing or object by its behavioral characteristics, i.e., the functionality that the object has.

Let’s first look at the two simple classes Apple and Peach, which now no longer have a common base class. See Listing 6-3.

Listing 6-3.  The Apple and Peach Classes Without a Common Base Class Such as Fruit

#include <string>

class Apple { public:

std::string getTypeOfInstanceAsString() const { return "class Apple";

}

};

237

Chapter 6 Modularization

class Peach { public:

std::string getTypeOfInstanceAsString() const { return "class Peach";

}

};

To enable clients to call the getTypeOfInstanceAsString() method without having to know whether it is an instance of an Apple or a Peach, we need the class template in Listing 6-4.

Listing 6-4.  The PolymorphicObjectWrapper Class for Realizing Type Erasure

#include <concepts>

#include <memory> #include <string>

template<typename Class>

concept ClassWithConstCallableMethod = requires (const Class& c) { { c.getTypeOfInstanceAsString() } -> std::same_as<std::string>;

};

class PolymorphicObjectWrapper { public:

template<ClassWithConstCallableMethod T> PolymorphicObjectWrapper(const T& obj) :

wrappedObject_(std::make_shared<ObjectModel<T>>(obj)) {}

std::string getTypeOfInstanceAsString() const { return wrappedObject_->getTypeOfInstanceAsString();

}

private:

struct ObjectConcept {

virtual ~ObjectConcept() = default;

virtual std::string getTypeOfInstanceAsString() const = 0;

};

238

Chapter 6 Modularization

template< ClassWithConstCallableMethod T> struct ObjectModel final : ObjectConcept {

ObjectModel(const T& obj) : object_(obj) {}

std::string getTypeOfInstanceAsString() const override { return object_.getTypeOfInstanceAsString();

}

private:

T object_;

};

std::shared_ptr<ObjectConcept> wrappedObject_;

};

The PolymorphicObjectWrapper class has a smart pointer named wrappedObject_ that is typed by the inner interface or abstract class ObjectConcept. The inner class template ObjectModel<T> implements this interface. Concrete implementations of

ObjectModel<T> (such as ObjectModel<Apple> or ObjectModel<Peach>) are accessed via the abstract class ObjectConcept. The PolymorphicObjectWrapper class forwards calls of the getTypeOfInstanceAsString() method to its ObjectConcept interface, which is overridden by a concrete ObjectModel<T> subclass. That subclass ultimately calls getTypeOfInstanceAsString() on the underlying type. For this to work, all concrete types used for the template parameter T must fulfill an interface contract, i.e., they must have public methods that fit to those that are declared by the inner interface ObjectConcept. We ensure that this requirement is satisfied by defining a C++ concept named ClassWithConstCallableMethod (see the section about concepts in Chapter 5). See Listing 6-5.

Listing 6-5.  An Exemplary Use of PolymorphicObjectWrapper

#include "Fruits.h"

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

#include <vector>

using Fruits = std::vector<PolymorphicObjectWrapper>;

int main() {

Fruits fruits{ Apple(), Peach() };

239