Chapter 6 inheritance and object-oriented design
Many people think that inheritance is the whole of object-oriented programming. Whether this point of view is correct remains to be discussed, but the number of terms in other chapters in this book is sufficient to prove that more tools will be provided to you for efficient C ++ programming, instead of simply inheriting a class from another class.
However, the hierarchy of design and implementation classes is fundamentally different from everything in C. Only in the field of inheritance and object-oriented design are you most likely to fundamentally rethink the methods of software system construction. In addition, C ++ provides a variety of confusing object-oriented components, including public, protected, and private base classes, virtual and non-virtual base classes, and virtual and non-virtual member functions. These components not only interact with each other, but also interact with other components of C ++. Therefore, it is hard to understand the meaning of each component, when to use them, and how to best combine them with the object-oriented part of C ++.
Another reason for making things more complex is that many different components in C ++ seem to be doing the same thing more or less. For example:
· If you need to design a group of classes with common features, it is necessary to use inheritance so that all classes are derived from a common base class, or are they all generated from a common code framework using templates?
· The implementation of Class A should use Class B. Should a have a data member of type B, or should a private inherit from B?
· If you want to design a type-safe same-family container class that is not provided in the standard library (clause 49 lists the container classes actually provided in the standard library), use the template, or is it best to create a type-safe interface for a class that uses a common (void *) pointer?
In the terms of this chapter, I will instruct you how to answer such questions. Of course, I cannot take into account all aspects of object-oriented design. On the contrary, I will focus on what different components in C ++ really mean and what you actually do when using a component. For example, public inheritance means "yes" (For details, refer to Clause 35). If you make it a different meaning, it will cause trouble. Similarly, virtual functions mean "interfaces must be inherited", and non-virtual functions mean "interfaces and implementations must be inherited ". The Meaning between them will bring endless pain to C ++ programmers.
If you can understand the meaning of various components in C ++, you will find that your understanding of object-oriented design has greatly changed. Instead of worrying about the different components provided by C ++, you are thinking about what to do for your software system. Once you know what you want to do, it is easy to convert it into the corresponding C ++ component.
Do what you want and understand what you do! The importance of these two points has never been too high. The following articles will discuss how to efficiently implement these two points in detail. Cla44 summarizes the correspondence between C ++ object-oriented components and their meanings. It is the best summary of this chapter and can be used as a concise reference for future use.
Cla35: Make public inheritance reflect the meaning of "yes"
In "Some must watch while some must sleep" (W. h. freeman and Company, 1974) William Dement tells a story about how he makes students remember the most important part of his course. "It is said," he told his students that "average British students do not remember any other history except that the Battle of Hastings occurred in 1066. "," If a child does not remember any other history, "Dement emphasizes," He must remember the day 1066. "But for students in his class, there are only a few topics that can interest them. For example, sleeping pills can cause insomnia. So he begged his students to remember only a few important historical events even if they forget anything else he taught in class. In addition, he kept instilling this basic idea into students throughout the semester.
At the end of the semester, the last question of the final exam is, "please write down what you will remember all your life from the course ". He was surprised when Dement evaluated and revised the exam. Almost all students wrote "1066 ".
So here I also tell you with an extremely trembling voice that an important rule in C ++ object-oriented programming is: Public inheritance means "yes ". Remember this rule.
When writing down Class D ("derived") to inherit from Class B ("base") public, you are actually telling the compiler (and the person who reads the code ): every object of Type D is also an object of type B, but the opposite is not true. You mean: B represents a more extensive concept than D, d indicates a more specific concept than B. You are declaring that the object of Type D can also be used wherever type B can be used, because the object of each type D is an object of type B. On the contrary, if you need an object of Type D, the object of type B will not work: Each D "is a" B, but the opposite is not true.
C ++ adopts the above explanation of Public inheritance. Let's look at this example:
Class person {...};
Class student: public person {...};
From our daily experience, we know that every student is a person, but not everyone is a student. This is exactly what the above hierarchy declares. We hope that any fact about the founding of "people"-if there is a birthday-is also true of "Students", but we do not want, any fact about the establishment of "Students"-if they all go to school in a certain school-is also true for "persons. The concept of people is more extensive than that of students. Students are specific types of people.
In the C ++ world, any function whose parameter is of the person type (or a person pointer or reference) you can actually get a student object (or a pointer to student or a reference to student ):
Void Dance (const person & P); // anyone can dance
Void Study (const student & S); // only students can learn
Person P; // P is a person
Student s; // s is a student
Dance (p); // correct, P is a person
Dance (s); // correct, S is a student,
// A student "is a" person
Study (s); // correct
Study (p); // error! P is not a student
This is only true for public inheritance. That is to say, only when student public inherits from person will c ++ behave as I described. Private inheritance is totally different (see clause 42). As for protection of inheritance, no one knows what it means. In addition, the fact that student "is a" person does not indicate that the student array "is a" person array. For more information about this topic, see the M3 clause.
The equivalence relationship between public inheritance and "is" sounds simple, but it may not always be so intuitive in practical applications. Sometimes intuition will mislead you. For example, there is the fact that penguins are birds and that birds can fly. To simply express these facts in C ++, we will do this:
Class bird {
Public:
Virtual void fly (); // birds fly
...
};
Class Penguin: Public bird {// penguin is a bird
...
};
Suddenly we are confused, because this hierarchy means that penguins will fly, and we know that this is not the truth. What happened?
This is because the language (Chinese) is not strict. It doesn't mean that all birds can fly. Generally, only those birds with flying capabilities can fly. If it is more accurate, we all know that there are actually many birds that won't fly, so we will provide the following level structure, which better reflects the reality:
Class bird {
... // The Fly function is not declared
};
Class flyingbird: Public bird {
Public:
Virtual void fly ();
...
};
Class nonflyingbird: Public bird {
... // The Fly function is not declared
};
Class Penguin: Public nonflyingbird {
... // The Fly function is not declared
};
This level is more loyal to what we know than the original design.
However, the discussion on birds cannot be completely over yet. In some software systems, it is perfect to say that penguins are birds. For example, if the program only has a relationship with the bird's mouth and wings and does not involve flying, the initial design would be appropriate. This may seem annoying, but it reflects the simple fact that no design is ideal for any software. A good design is inseparable from the current and future functions of the software system (see section M32 ). If the program does not involve flying and will not be used in the future, it is very reasonable to let penguin derive from bird. In fact, it is better than the design that distinguishes between flying and flying, because this distinction is not used in your design. Adding redundant classes to the design hierarchy is a bad design, just like creating wrong inheritance relationships between classes.
To solve the problem of "all birds fly, penguins are birds, and penguins are not flying", you can also consider using another method. That is, re-define the fly function for Penguin to generate a runtime error:
Void error (const string & MSG); // defined elsewhere
Class Penguin: Public bird {
Public:
Virtual void fly () {error ("penguins can't fly! ");}
...
};
For example, Smalltalk prefers this method, but it is important to note that the above Code may be totally different from what you think. It does not mean that "Penguins do not fly", but "penguins fly, but it is a mistake to let them fly ".
How can we differentiate them? This can be distinguished from the time when an error is detected. The "Penguin will not fly" command is issued by the compiler, and "Making penguin fly is a mistake" can only be detected at runtime.
Do not define the fly function in the penguin object to indicate that "Penguin will not fly:
Class bird {
... // The Fly function is not declared
};
Class nonflyingbird: Public bird {
... // The Fly function is not declared
};
Class Penguin: Public nonflyingbird {
... // The Fly function is not declared
};
If you want penguin to fly, the compiler will condemn your violation:
Penguin P;
P. Fly (); // error!
The behavior obtained using the Smalltalk method is completely different from this. In that way, the compiler won't even say anything.
The Processing Method of C ++ is fundamentally different from that of smalltalk. Therefore, as long as C ++ is used for programming, C ++ should be used to do things. In addition, checking errors during compilation has some technical advantages over checking errors during runtime. For details, see section 46.
You may say that you have little knowledge about birds. But you can use your elementary geometric knowledge, right? I mean, should Rectangles and squares always be complex?
Well, answer this simple question: can a square class inherit from the class rectangle (rectangle) public?
Rectangle
^
|?
Square
"Of course! "You may say with disdain," Everyone knows that a square is a rectangle, but in turn it is usually not true. "That's true, at least in high school. But I don't think we are still high school students.
Take a look at the following code:
Class rectangle {
Public:
Virtual void setheight (INT newheight );
Virtual void setwidth (INT newwidth );
Virtual int height () const; // returns the current value
Virtual int width () const; // returns the current value
...
};
Void makebigger (rectangle & R) // function for increasing the r Area
{
Int oldheight = R. Height ();
R. setwidth (R. Width () + 10); // increase the r width by 10.
Assert (R. Height () = oldheight); // The height of assertion R has not changed.
}
Obviously, assertions will never fail. Makebigger only changes the r width and the height has never been modified.
Now let's look at the code below. It adopts public inheritance so that the square can be treated as a rectangle:
Class square: Public rectangle {...};
Square S;
...
Assert (S. Width () = S. Height (); // this is true for all squares
Makebigger (s); // inherited, s "is a" rectangle
// You can increase its area.
Assert (S. Width () = S. Height (); // This is still true for all squares
Obviously, like the previous assertion, this assertion will never fail. As defined, the width and height of a square are equal.
Now there is a problem. How can we coordinate the following assertions?
· Before makebigger is called, the width and height of S are equal;
· Inside makebigger, the width of S is changed, and the height is not changed;
· After the result is returned from makebigger, the height of S is equal to the width. (Note that S is passed to makebigger through reference, so makebigger modifies s rather than copying S)
How is it?
Welcome to the wonderful world of public inheritance. Here, your instincts in other fields of research-including mathematics-may not serve you as you expected. In the preceding example, the most fundamental problem is that the rules applied to the rectangle (the width change and height do not matter) are not applicable to the square (the width and height must be the same ). However, public inheritance claims that: Anything that applies to the Base Class Object-any! ---- It also applies to derived class objects. In the example of Rectangles and squares (and a similar example of set involved in Clause 40), the claimed principle does not apply, therefore, using public inheritance to indicate their relationships will only be incorrect. Of course, the compiler will not block you from doing so, but as we can see, it cannot ensure that the program can work normally. As every programmer knows, code compilation does not mean it works properly.
But don't worry too much about your accumulated software development intuition, which will be useless when you step into object-oriented design. The knowledge is still very valuable, but since you have added the inheritance tool in your own design treasure, you need to use a new perspective to expand your professional intuition, to instruct you to develop correct object-oriented programs. Soon, you will think Penguin's idea of inheriting from bird or square from rectangle is ridiculous, it's as ridiculous as someone shows you a function with several pages. Maybe it is the correct method to solve the problem, but it is not suitable.
Of course, the "yes" relationship does not exist in the unique relationship between classes. The other two common relationships between classes are "one" and "implemented ". These relationships are discussed in terms 40 and 42. It is not uncommon for one of the two relationships to be incorrectly expressed as "yes", which leads to incorrect design. Therefore, make sure that you understand the differences between these relationships and how to best express them in C ++.
Cla36: differentiate interface inheritance and implement inheritance
The concept of (public) Inheritance looks very simple. Further analysis shows that it consists of two parts: inheritance of function interfaces and Inheritance of function implementations. The differences between the two types of inheritance are exactly the same as the differences between the function declaration and the function definition discussed in this document.
As a class designer, you sometimes want the derived class to inherit only the interface (Declaration) of the member function. Sometimes, you want the derived class to inherit both the interface and Implementation of the function, but allow the class to be rewritten; sometimes you want to inherit both interfaces and implementations, and do not allow the derived class to rewrite anything.
To better understand the differences between these options, let's look at the class hierarchy below, which is used to represent the geometric shape in a graphic program:
Class shape {
Public:
Virtual void draw () const = 0;
Virtual void error (const string & MSG );
Int objectid () const;
...
};
Class rectangle: Public shape {...};
Class ellipse: Public shape {...};
The pure virtual function draw makes shape an abstract class. Therefore, you cannot create an instance of the Shape class, but only an instance of its derived class. However, all classes inherited from shape (public) are greatly affected by shape, because:
· The interfaces of member functions are always inherited. As stated in Clause 35, the meaning of public inheritance is "yes". Therefore, all the facts that are true to the base class must also be true to the derived class. Therefore, if a function applies to a class, it will certainly apply to its subclass.
The shape class declares three functions. The first function, draw, draws the current object on a certain painting surface. The second function, error, is called by other member functions to report error information. The third function, objectid, returns a unique integer identifier of the current object (Clause 17 provides an example of how to use this function ). Each function declares in a different way: draw is a pure virtual function; error is a simple (non-pure ?) Virtual function; objectid is a non-virtual function. What are the meanings of these different statements?
First, look at the pure virtual function draw. The most notable feature of pure virtual functions is that they must be re-declared in any specific class that inherits them, and they are often not defined in abstract classes. When we put these two features together, we will realize that:
· The purpose of defining a pure virtual function is to make the derived class only inherit the function interface.
This makes sense for the shape: Draw function, because it is reasonable to make all shape objects be drawn, but the shape class cannot be shape :: draw provides a reasonable default implementation. For example, the algorithm used to draw an elliptical image is very different from the algorithm used to draw a rectangle. For example, the above shape: Draw declaration is like telling the subclass designer, "you must provide a draw function, but I don't know how you will implement it. "
By the way, defining a pure virtual function is also possible. That is to say, you can provide implementation for shape: draw, and the C ++ compiler will not block it, but the only way to call it is to specify which call is complete through the Class Name:
Shape * PS = new shape; // error! Shape is abstract
Shape * PS1 = new rectangle; // correct
PS1-> draw (); // call rectangle: Draw
Shape * PS2 = new ellipse; // correct
PS2-> draw (); // call ellipse: Draw
PS1-> shape: Draw (); // call shape: Draw
PS2-> shape: Draw (); // call shape: Draw
In general, apart from being able to impress your programmers at cocktail parties, it is generally not helpful to understand this usage. However, as we will see later, it can be applied as a mechanism to provide a default Implementation of "more secure than common" for simple (non-pure) virtual functions.
Sometimes it is useful to declare a class that does not contain anything except pure virtual functions. This class is called the Protocol class. It provides only function interfaces for the derived classes and is not implemented at all. The terms of agreement have been introduced in Clause 34 and will be mentioned again in Clause 43.
Simple Virtual functions are a little different from pure virtual functions. As an example, a derived class inherits the interface of a function, but a simple virtual function also provides an implementation. A derived class can choose to rewrite them or not rewrite them. Think for a moment to realize:
· The purpose of declaring a simple virtual function is to make the derived class inherit the interface and default Implementation of the function.
Specific to shape: error, this interface is said, each class must provide a function that can be called when an error occurs, but each class can handle the error in any way they think appropriate. If a class does not want to do anything special, you can use the default error processing function provided in the shape class. That is to say, the shape: Error Declaration tells the subclass designer, "you must support the error function. But if you do not want to write your own version, you can use the default version in the shape class. "
In fact, it is dangerous to provide function declarations and default implementations for simple virtual functions. For more information, see the Plane hierarchy of XYZ airlines. XYZ has only two types of aircraft, A and B, and the flight modes of the two types are the same. Therefore, XYZ has designed such a hierarchy:
Class airport {...}; // represents an airplane
Class airplane {
Public:
Virtual void fly (const airport & Destination );
...
};
Void airplane: Fly (const airport & Destination)
{
Default Code for flying a plane to a destination
}
Class modela: Public airplane {...};
Class modelb: Public airplane {...};
Airplane: Fly is declared as virtual because fly functions must be supported by all aircraft and different aircraft models must be implemented in principle. To avoid repeated code writing in modela and modelb, the default flight behavior is provided by the airplane: Fly function. modela and modelb inherit this function.
This is a typical object-oriented design. The two classes share the same features (implementing the fly method). Therefore, this common feature is transferred to the base class and the two classes inherit this feature. This design makes the commonalities clear and avoids code duplication. In the future, it is easy to enhance functions and maintain features for a long time-all of which are highly praised by object-oriented technology. XYZ is really proud of it.
Now let us assume that Company XYZ has made a fortune and decided to introduce a new type of aircraft, type C. C is different from a and B. In particular, the flight mode is different.
XYZ programmers added a class for Type C in the above hierarchy, but they forgot to redefine the fly function because they were eager to put the new aircraft into use:
Class modelc: Public airplane {
... // The Fly function is not declared
};
Then they did something similar in the program:
Airport JFK (...); // JFK is an airport in New York City
Airplane * pA = new modelc;
...
Pa-> fly (JFK); // call airplane: fly!
This will cause a tragedy: the attempt to make the modelc object fly like modela or modelb. This kind of behavior can not be exchanged for passengers' trust in you!
The problem here is not airplane: fly has default behavior, but modelc can inherit this line without explicit declaration. Fortunately, it is easy to provide default behavior for sub-classes and give them only when the sub-classes want them. The trick is to cut off the connection between the virtual function interface and its default implementation. The following is a method:
Class airplane {
Public:
Virtual void fly (const airport & Destination) = 0;
...
Protected:
Void defaultfly (const airport & Destination );
};
Void airplane: defaultfly (const airport & Destination)
{
Default Code for flying a plane to a destination
}
Note airplane: fly has become a pure virtual function, which provides the flight interface. The default implementation still exists in the airplane class, but now it exists in the form of an independent function (defaultfly. If modela and modelb want to execute the default behavior, they simply make an inline call to defaultfly in their fly function bodies (for the relationship between inner and virtual functions, see Clause 33 ):
Class modela: Public airplane {
Public:
Virtual void fly (const airport & Destination)
{Destinfly fly (destination );}
...
};
Class modelb: Public airplane {
Public:
Virtual void fly (const airport & Destination)
{Destinfly fly (destination );}
...
};
For modelc classes, it is impossible to inherit the incorrect fly implementation accidentally. Because the pure virtual function in airplane forces modelc to provide its own fly version.
Class modelc: Public airplane {
Public:
Virtual void fly (const airport & Destination );
...
};
Void modelc: Fly (const airport & Destination)
{
Code for modelc to a specific destination
}
This method won't be foolproof (programmers may also make mistakes due to "copy and paste"), but it is much more reliable than the original design. Airplane: defaultfly is declared as protected because it is indeed only the Implementation Details of airplane and Its Derived classes. Airplane users only care about flying planes, but not how they are implemented.
Airplane: defaultfly is also an important non-virtual function. Because no subclass will redefine this function, clause 37 illustrates this fact. If defafly fly is a virtual function, it will return to this question: if some subclasses should redefine defaultfly and forget to do so, what should we do?
Some people oppose separating interfaces from default implementations as separate functions, such as fly and defaultfly above. They believe that at least this will pollute the class namespace, because so many similar function names are spreading. However, they still agree that interfaces and default implementations should be separated. How can we solve this apparent conflict? You can use this fact: a pure virtual function must be declared again in the subclass, but it can still have its own implementation in the base class. The following airplane uses this to redefine a pure virtual function:
Class airplane {
Public:
Virtual void fly (const airport & Destination) = 0;
...
};
Void airplane: Fly (const airport & Destination)
{
Default Code for flying a plane to a destination
}
Class modela: Public airplane {
Public:
Virtual void fly (const airport & Destination)
{Airplane: Fly (destination );}
...
};
Class modelb: Public airplane {
Public:
Virtual void fly (const airport & Destination)
{Airplane: Fly (destination );}
...
};
Class modelc: Public airplane {
Public:
Virtual void fly (const airport & Destination );
...
};
Void modelc: Fly (const airport & Destination)
{
Code for modelc to a specific destination
}
This design is almost the same as the previous one, but the pure virtual function airplane: Fly replaces the independent function airplane: defaultfly. In essence, fly has been divided into two basic parts. Its Declaration illustrates its interface (the derived class must be used), and its definition describes its default behavior (the derived class may be used, but must be explicitly requested ). However, after the combination of fly and defaultfly, different protection levels can no longer be declared for these two functions: originally the protected code (in defaultfly) now it becomes public (because it is in Fly ).
Finally, let's talk about the shape non-virtual function, objectid. When a member function is a non-virtual function, its behavior in the derived class should not be different. In fact, a non-virtual member function represents a kind of special immutability, because it represents a behavior that will not change-no matter how special a derived class has. So,
· The purpose of declaring a non-virtual function is to make the derived class inherit the interface and mandatory implementation of the function.
We can think that the declaration of shape: objectid is to say, "Each shape object has a function to generate an object identifier, and the object identifier is always generated in the same way. This method is determined by the shape: objectid definition. The derived class cannot change it. "Because a non-virtual function represents a kind of special immutability, it cannot be redefined in the subclass. Article 37 of this article is discussed.
After understanding the differences between pure virtual functions, simple virtual functions, and non-virtual functions in Declaration, You can precisely specify what you want the derived class to inherit: is it just an interface or an interface and a default implementation? Or, the interface and a forced implementation? Because these different types of declarations refer to different things, you must carefully choose between them when declaring a member function. Only in this way can we avoid two mistakes that programmers without experience often make.
The first error is to declare all functions as non-virtual functions. This makes the derived classes less specialized; non-virtual destructor are especially problematic (see clause 14 ). Of course, it is reasonable not to use the designed class as the base class (the m34 clause provides an example that you will do so ). In this case, it is appropriate to specifically declare a group of non-virtual member functions. However, all functions are declared as non-virtual functions. In most cases, they are caused by ignorance of the differences between virtual and non-virtual functions, or worry about the impact of virtual functions on program performance (see section M24 ). In fact, almost any class used as the base class has virtual functions (see clause 14 again ).
If you are worried about the overhead of the virtual function, let me introduce the 80-20 law (refer to the M16 clause ). It points out that in a typical program, 80% of the running time is spent on executing 20% of the Code. This law is very important because it means that, on average, 80% of function calls can be virtual functions, and they do not have even a negligible impact on the overall performance of the program. Therefore, before worrying about whether the virtual function overhead can be borne, you may wish to focus on the code that will actually affect the 20%.
Another common problem is to declare all functions as virtual functions. Sometimes this is true-for example, Protocol Class is evidence (see article 34 ). However, this often shows that class designers lack the courage to express their firm stance. Some functions cannot be redefined in a derived class. In this case, they must be explicitly declared as non-virtual functions. You cannot make your function seem to be able to do anything for anyone-as long as they spend some time redefining all functions. Remember, if there is a base class B, A derived class D, and a member function Mf, then each of the following calls to MF must work properly:
D * Pd = new D;
B * pb = Pd;
Pb-> MF (); // call MF through the base class pointer
Pd-> MF (); // call MF through the pointer of the derived class
Sometimes, you must declare MF as a non-virtual function to ensure that everything works as expected (see article 37 ). If you need the immutability of the particularity, let's say it!