Don't know C++ (2) (inheritance and composition)
My gaps in knowledge have become unbearable. I need to review my understanding of several concepts seriously, from the beginning.
Until I make these ideas clear, I cannot work on other things with peace of mind. Why am I so weak at this?
Chapter 14 of Thinking in C++ opens with this idea:
One of C++’s most important features is code reuse. To go further, however, you need more than copying and modifying code.
This chapter introduces two ways to do that. The first is direct: create an object of an existing class inside a new class. Because the new class is composed of existing-class objects, this approach is called composition.
The second approach is more complex. Create a new class as a type of an existing class. Rather than modifying the existing class, take its form and add code to it. This is called inheritance.
1. Composition syntax
1 | class X |
More commonly, the embedded object is made private so it becomes part of the implementation.
1 | class Y |
2. Inheritance syntax
In code, use the class name as before, but add a colon and the base-class name before the opening brace. For multiple inheritance, list multiple base classes separated by commas. Doing so gives the derived class the base class’s data members and member functions.
1 | class Y:public X |
Y inherits from X, which means Y contains all of X’s data members and member functions.
All private members of X remain private in Y. Inheritance does not let Y bypass access protection. X’s private members still occupy storage, but cannot be accessed directly.
Also, inheritance is private by default. Without public before the base class, all of the base class’s public members become private in the derived class. That is usually not what we want; use the public keyword to keep them public.
After redefining a function, you may still want to call the base-class version. Calling set() directly invokes the local version and recurses; to call the base class’s set(), explicitly name the base class with the scope-resolution operator.
3. Constructor initializer lists
C++ provides dedicated syntax for calling constructors of subobjects: a constructor initializer list. Its form resembles inheritance syntax. In inheritance, the base class is placed after a colon before the class body. In an initializer list, calls to subobject constructors appear after the constructor’s parameter list and colon, before the function body. For a class MyType derived from Bar, where Bar has a single int constructor parameter:
1 | MyType::MyType(int i):Bar(i){} |
3.1 Initializing member objects
For composition, use the same syntax for member objects, except that you give the object name rather than a class name. Separate multiple constructor calls in an initializer list with commas.
1 | MyType2::MyType2(int i):Bar(i),m(i+1){} |
3.2 Built-in types in initializer lists
For syntactic consistency, you can regard a built-in type as a type with a one-argument constructor whose argument has the same type as the variable being initialized. For example:
1 | class X |
This makes built-in types behave a little like objects. Remember, though, that these are not real constructors. In particular, initialization does not occur unless you explicitly use this pseudo-constructor form.
4. Combining composition and inheritance
Although initializer lists often require explicit constructor calls, explicit destructor calls are unnecessary: every type has only one destructor, and it takes no arguments. The compiler still ensures that every destructor is called. Across the hierarchy, destruction begins at the lowest derived layer and continues back to the root.
4.1 Constructor and destructor call order
1 |
|
The program outputs:
1 | Base1 constructor |
As shown, construction starts at the root of the class hierarchy. At each level, the base-class constructor runs first, followed by member-object constructors. Destructors run in exactly the reverse order. This matters because of dependencies: constructors and destructors of a derived class must assume that the base-class subobject is available and has either already been constructed or has not yet been destroyed.
Another important detail: for member objects, construction order is not affected by their order in the initializer list. It is determined by their declaration order in the class. Otherwise, different constructors could establish different construction orders and a destructor would not know which order to reverse, causing dependency problems.
5. Name hiding
1 | class Base |
Base has overloads of f(). Derived1 does not change f(), so both base-class overloads remain available. Derived2 redefines one version of f() but not the other, so the other overload cannot be used. Derived3 hides both base-class versions by changing the return type, and Derived4 does so by changing the parameter list. In general, when a derived class redefines one overloaded base-class function, every other base-class overload with that name is automatically hidden. The next post explains how virtual affects this.
6. Functions that are not inherited automatically
Not every function is inherited automatically. Constructors and destructors manage object creation and destruction, but each only knows what to do for objects at its own level. Therefore constructors and destructors at every layer must be called: they cannot be inherited and must be created separately for every derived class.
Likewise, operator= cannot be inherited because it performs work analogous to a constructor.
1 | class GameBoard |
Output:
1 | GameBoard() |
Several points are worth noting:
operator Other() performs an automatic conversion from a Game object to an object of the embedded Other class.
Checkers explicitly defines its default constructor, copy constructor, and assignment operator. In the default constructor, the default base-class constructor is called automatically, which is what we want. But once you write your own copy constructor or assignment operator, the compiler assumes you know what you are doing and no longer calls the base-class version for you. If you want it, you must call it explicitly. In Checkers’ copy constructor, this happens in the initializer list:
1 | Checkers(const Checker &c):Game(c){} |
For the Checkers assignment operator, the base-class call is on the first line of the function body:
1 | Game::operator=(c); |
7. Subtype setup
Suppose you want a class around an ifstream object that both opens a file and keeps its filename. You can use composition and embed both ifstream and string in the new class:
1 | class FName1 |
Here ifstream & is an overloaded type conversion. Reference
Declaration: operator XX(); has no return type; XX is the target type.
Usage: XX a = (XX)obj;
There is a problem: we may want to use an FName1 object anywhere an ifstream is used by providing an automatic conversion from FName1 to ifstream &, but in main:
1 | file.close(); |
One option is to add close() to FName1:
1 | void close(){file.close();} |
This works if you need only a few functions from ifstream. In that case, composition is appropriate because you use only part of the class. If you need every function it provides, use inheritance instead.
1 | class FName2:public ifstream |
Now every member function that works with an ifstream also works with an FName2. Non-member functions that require an ifstream, such as getline(), can use FName2 as well.