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

Chapter 6 Modularization

for (const auto& fruit : fruits) {

std::cout << fruit.getTypeOfInstanceAsString() << ", ";

}

std::cout << std::endl; return 0;

}

The output of stdout, both the object-oriented variant in Listing 6-2 and the implementation with the type erasure idiom in Listing 6-5, are identical:

class Apple, class Peach,

The advantage of the template-based solution is that the types do not need a common base class and it is still type safe. The template works with all data types that have a public interface expected by them. The downside is that the type erasure idiom has a significant higher degree of complexity compared to the much simpler implementation using dynamic polymorphism. Another drawback is that it has a performance issue during object construction, since the created objects have to be copied into the ObjectModel, resulting in additional copy-constructor calls.

Liskov Substitution Principle (LSP)

“Basically, the Liskov Substitution Principle states that you cannot create an octopus by extending a dog with four additional fake legs.”

—Mario Fusco (@mariofusco), September 15, 2013, on Twitter

The object-oriented key concepts of inheritance and polymorphism seem relatively simple at first glance. Inheritance is a taxonomical concept that should be used to build a specialization hierarchy of types, that is, subtypes are derived from a more general type. Polymorphism means in general, that one single interface is provided as an access possibility to objects of different types, as discussed in the former section about type erasure.

So far, so good. But sometimes you get into situations where a subtype does not want to fit into a type hierarchy. Let’s discuss a very popular example that is often used to illustrate the problem.

240

Chapter 6 Modularization

The Square-Rectangle Dilemma

Suppose that we are developing a class library with primitive types of shapes for drawing on a canvas, for example, a Circle, a Rectangle, a Triangle, and a TextLabel. Visualized as an UML class diagram, this library might look like Figure 6-2.

Figure 6-2.  A class library of different shapes

The abstract base class Shape has attributes and operations that are the same for all specific shapes. For example, it is the same for all shapes how they can be moved from one position to another position on the canvas. However, the Shape cannot know how specific shapes can be shown (drawn) or hidden (erased). Therefore, these operations are abstract, that is, they cannot be (fully) implemented in Shape.

241

Chapter 6 Modularization

In C++, an implementation of the abstract class Shape (and the class Point that is required by Shape) might look like Listing 6-6.

Listing 6-6.  The Point and Shape Classes

class Point final { public:

Point() = default;

Point(const unsigned int initialX, const unsigned int initialY) : x { initialX }, y { initialY } { }

void setCoordinates(const unsigned int newX, const unsigned int newY) { x = newX;

y = newY;

}

// ...more member functions here...

private:

unsigned int x { 0 }; unsigned int y { 0 };

};

class Shape { public:

Shape() = default;

virtual ~Shape() = default;

void moveTo(const Point& newCenterPoint) { hide();

centerPoint = newCenterPoint; show();

}

virtual void show() = 0; virtual void hide() = 0; // ...

private:

Point centerPoint; bool isVisible{ true };

};

242

Chapter 6 Modularization

void Shape::show() { isVisible = true;

}

void Shape::hide() { isVisible = false;

}

FINAL SPECIFIER [C++11]

The final specifier, available since C++11, can be used in two ways.

On the one hand, you can use this specifier to avoid individual virtual member functions from being overridden in derived classes, like in this example:

class AbstractBaseClass { public:

virtual void doSomething() = 0;

};

class Derived1 : public AbstractBaseClass { public:

void doSomething() override final { //...

}

};

class Derived2 : public Derived1 { public:

void doSomething() override { // Causes a compiler error! //...

}

};

In addition, you can also mark a complete class as final, like the class Point in our Shape library. This ensures that a developer cannot use such a class as a base class for inheritance.

class NotDerivable final { // ...

};

243

Chapter 6 Modularization

Of all concrete classes in the Shapes library, we take an exemplary look at one, the Rectangle. See Listing 6-7.

Listing 6-7.  The Important Parts of the Rectangle Class

class Rectangle : public Shape { public:

Rectangle() = default;

Rectangle(const unsigned int initialWidth, const unsigned int initialHeight) :

width { initialWidth }, height { initialHeight } { }

void show() override { Shape::show();

// ...code to show a rectangle here...

}

void hide() override { Shape::hide();

// ...code to hide a rectangle here...

}

void setWidth(const unsigned int newWidth) { width = newWidth;

}

void setHeight(const unsigned int newHeight) { height = newHeight;

}

void setEdges(const unsigned int newWidth, const unsigned int newHeight) { width = newWidth;

height = newHeight;

}

// ...

private:

unsigned int width{ 2 }; unsigned int height{ 1 };

};

244

Chapter 6 Modularization

The client code wants to use all shapes in a similar fashion, no matter which particular instance (Rectangle, Circle, etc.) it is confronted with. For instance, all shapes should be shown on a canvas at one blow, which can be achieved using the following code:

#include "Shapes.h" // Circle, Rectangle, etc.

#include <memory> #include <vector>

using ShapePtr = std::shared_ptr<Shape>; using ShapeCollection = std::vector<ShapePtr>;

void showAllShapes(const ShapeCollection& shapes) { for (auto& shape : shapes) {

shape->show();

}

}

int main() {

ShapeCollection shapes; shapes.push_back(std::make_shared<Circle>()); shapes.push_back(std::make_shared<Rectangle>()); shapes.push_back(std::make_shared<TextLabel>()); // ...etc...

showAllShapes(shapes); return 0;

}

Now let’s assume that users formulate a new requirement for our library: they want to have a square!

Probably everyone is immediately reminded of geometry lessons in school. At that time your teacher may have said that a square is a special kind of rectangle that has four sides of equal length. Thus, a first obvious solution seems to be that we derive a new class called Square from Rectangle, as depicted in Figure 6-3.

245

Chapter 6 Modularization

Figure 6-3.  Is deriving a square from the rectangle class a good idea?

At first glance, this seems to be a feasible solution. The Square inherits the interface and the implementation of Rectangle. This is good to avoid code duplication (see the DRY principle discussed in Chapter 3), because the Square can easily reuse the behavior implemented in Rectangle.

A square just has to fulfill one additional and simple requirement that is shown in the UML diagram as a constraint in class Square: {width = height}. This constraint means that an instance of type Square ensures in all circumstances that its edges are always the same length.

So we first implement our Square by deriving it from our Rectangle:

class Square : public Rectangle { public:

//...

};

246

Chapter 6 Modularization

But in fact, this is not a good solution!

Note that the Square inherits all operations of the Rectangle. That means that we can do the following with an instance of Square:

Square square;

 

 

square.setHeight(10);

//

Err...changing only the height of a square?!

square.setEdges(10, 20);

//

Uh oh!

First of all, it would be very puzzling for users of Square to see that it provides a setter with two parameters (remember the principle of least astonishment in Chapter 3). They think: Why are there two parameters? Which parameter is used to set the length of all edges? Must I put both parameters to the same value? What happens if I don’t?

The situation is even more dramatic when we do the following:

std::unique_ptr<Rectangle> rectangle = std::make_unique<Square>(); // ...and somewhere else in the code...

rectangle->setEdges(10, 20);

In this case, the client code uses a setter that makes sense. Both edges of a rectangle can be manipulated independently. That’s not a surprise; it’s exactly the expectation. However, the result may be weird. The instance of type Square would de facto not be a square after such a call anymore, because it has two different edge lengths. So we have once again committed a violation of the principle of least astonishment, and much worse: we violated the Square’s class invariant.

However, one could now argue that we can declare setEdges(), setWidth(), and setHeight() as virtual in the Rectangle class and override these member functions in the Square class with an alternative implementation, which throws an exception in case of unsolicited use. Furthermore, we provide a new member function called setEdge() in the Square class instead, as shown in Listing 6-8.

Listing 6-8.  A Bad Implementation of Square That Tries to “Erase” Unwanted Inherited Features

#include <stdexcept>

// ...

class IllegalOperationCall : public std::logic_error { public:

247

Chapter 6 Modularization

explicit IllegalOperationCall(std::string_view message) : logic_error(message) { }

};

class Square : public Rectangle { public:

Square() : Rectangle { 2, 2 } { }

explicit Square(const unsigned int edgeLength) : Rectangle { edgeLength, edgeLength } { }

void setEdges([[maybe_unused]] const unsigned int newWidth, [[maybe_unused]] const unsigned int newHeight) override { throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };

}

virtual void setWidth([[maybe_unused]] const unsigned int newWidth) override { throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };

}

virtual void setHeight([[maybe_unused]] const unsigned int newHeight) override {

throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };

}

void setEdgeLength(const unsigned int length) { Rectangle::setEdges(length, length);

}

private:

static constexpr char* const ILLEGAL_OPERATION_MSG {

"Unsolicited call of a prohibited operation on an instance of class Square!" };

};

Well, I think it’s obvious that that would be a terribly bad design. It violates a fundamental principle of object-orientation, that a derived class must not delete inherited properties of their base class. It is definitely not a solution to our problem. First, the new setter setEdge() would not be visible if we want to use an instance of Square as

248

Chapter 6 Modularization

a Rectangle. Furthermore, all the other setters throw an exception if they are used. This is really abysmal! It ruined object-orientation.

So, what’s the fundamental problem here? Why does the obviously sensible derivation of a Square class from a Rectangle cause so many difficulties?

The explanation is this: Deriving Square from Rectangle violates an important principle in object-oriented software design—the Liskov Substitution Principle (LSP)! Barbara Liskov, an American computer scientist who is an institute professor at

the Massachusetts Institute of Technology (MIT), and Jeannette Wing, who was the President’s Professor of Computer Science at Carnegie Mellon University until 2013, formulated the principle in a 1994 paper as follows:

“Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S, where S is a subtype of T.”

—Barbara Liskov, Jeanette Wing [Liskov94]

Well, that’s not necessarily a definition for everyday use. A definition suitable for everyday use is that derived classes must fully satisfy the contract of their base class, so that clients using a pointer or reference typed with this base class can use instances of the derived classes without knowing them.

In fact, that means the following: Derived types must be completely substitutable for their base types. In our example this is not possible. An instance of the Square type cannot substitute a Rectangle. The reason for that lies in the constraint {width = height} (a so-called class invariant) that would be enforced by the Square, but the Rectangle cannot fulfill that constraint.

The Liskov Substitution Principle stipulates the following rules for type and class hierarchies:

•\

The preconditions (see the section entitled “Prevention Is Better

 

Than Aftercare” in Chapter 5 about preconditions) of a base class

 

cannot be strengthened in a derived subclass.

•\

Postconditions (see the section entitled “Prevention Is Better Than

 

Aftercare” in Chapter 5) of a base class cannot be weakened in a

 

derived subclass.

•\

All invariants of a base class must not be changed or violated through

 

a derived subclass.

249

Chapter 6

Modularization

•\

The history constraint (also known as the “history rule”): The internal

 

state of an instance of a class should only be changed through its

 

public interface, i.e., through public method calls (encapsulation).

 

Of course, a newly derived class from this class is basically allowed to

 

introduce new public methods. However, the history constraint states

 

that these newly introduced methods in the derived class are not

 

allowed to modify the state of its instances in a manner prohibited

 

according to the base class. In other words, a derived class should

 

never ignore the constraints imposed by its base class, because

 

that would break any client code that relies on these constraints.

 

For instance, if the base class is designed to be the blueprint for an

 

immutable object (see Chapter 9 about immutable classes), the

 

derived class should not invalidate this property of immutability with

 

the help of newly introduced member functions. That’s, by the way,

 

the reason that immutable classes should be declared as final!

The interpretation of the generalization relationship (the arrow between Square and Rectangle) in the class diagram in Figure 6-2 is often translated with “…IS A…”: Square IS A Rectangle. But that could be misleading. In mathematics it may be possible to say that a square is a special kind of rectangle, but in programming it is not!

To deal with this problem, the clients have to know with which specific type they are working. Some developers might now say, “No problem, this can be done by using Run-­ Time Type Information (RTTI).” See Listing 6-9.

RUN-TIME TYPE INFORMATION (RTTI)

The term run-time type information (sometimes also run-time type identification) denotes a C++ mechanism to access information about an object’s data type at runtime. The general concept behind RTTI is called type introspection and is available also in other programming languages, like Java.

In C++, the typeid operator (defined in the <typeinfo> header) and dynamic_cast<T> (see the section about C++ casts in Chapter 4) belong to RTTI. For instance, to determine the class of an object at runtime, you can write:

const std::type_info& typeInformationAboutObject = typeid(instance);

250

Chapter 6 Modularization

The const reference of type std::type_info (also defined in the <typeinfo> header) now holds information about the object’s class, for example, the class’s name. Since C++11, a hash code is also available (std::type_info::hash_code()), which is identical to the std::type_info objects referring to the same type.

It is important to know that RTTI is available only to classes that are polymorphic, that is, for classes that have at least one virtual function, either directly or through inheritance. In addition, RTTI can be turned on or off on some compilers. For example, when using the gcc (GNU Compiler Collection), RTTI can be disabled by using the -fno-rtti option.

Listing 6-9.  Another “Hack”: Using RTTI to Distinguish Between Different Types of Shapes During Runtime

using ShapePtr = std::shared_ptr<Shape>; using ShapeCollection = std::vector<ShapePtr>; //...

void resizeAllShapes(const ShapeCollection& shapes) { try {

for (const auto& shape : shapes) {

const auto rawPointerToShape = shape.get();

if (typeid(*rawPointerToShape) == typeid(Rectangle)) {

Rectangle* rectangle = dynamic_cast<Rectangle*>(rawPointerToShape); rectangle->setEdges(10, 20);

//Do more Rectangle-specific things here...

}else if (typeid(*rawPointerToShape) == typeid(Square)) { Square* square = dynamic_cast<Square*>(rawPointerToShape); square->setEdge(10);

}else {

//...

}

}

}catch (const std::bad_typeid& ex) {

//Attempted a typeid of NULL pointer!

}

251

Chapter 6 Modularization

Don’t do this! This cannot, and it should not, be the appropriate solution, especially not in a clean and modern C++ program. Many of the benefits of object-orientation, such as dynamic polymorphism, are counteracted.

Caution  Whenever you are compelled to use RTTI in your program to distinguish between different types, it is a distinct “design smell,” that is, an obvious indicator of bad object-oriented software design!

In addition, our code will be heavily polluted with lousy if-else constructs and the readability will go down the drain. And as if this wasn’t enough, the try-catch construct also makes it clear that something could go wrong.

But what can we do?

First of all, we should take another careful look at what a square really is.

From a pure mathematical point of view, a square can be regarded as a rectangle with equal edge lengths. So far, so good. But this definition cannot be directly transferred into an object-oriented type hierarchy. A square is not a subtype of a rectangle!

Instead, having a square shape is merely a special state of a rectangle. If a rectangle has identical edge lengths, which is solely a state of the rectangle, we usually give such particular rectangle a special name in our natural language: we then speak about a square!

That means that we just need to add an inspector method to our Rectangle class to query its state, allowing us to waive an explicit class Square. According to the KISS principle (see Chapter 3), this solution might be completely sufficient to satisfy the new requirement. Furthermore, we can provide a convenient setter method to clients to set both edge lengths equally. See Listing 6-10.

Listing 6-10.  A Simple Solution Without an Explicit Class Called Square

class Rectangle : public Shape { public:

// ...

void setEdgesToEqualLength(const unsigned int newLength) { setEdges(newLength, newLength);

}

252