Derived Classes and Inheritance

In addition to encapsulation and information hiding, C++ classes provide two features that are fundamental to object-oriented programming:

  • Inheritance: the ability for a class to reuse the interface or functionality of another class.

  • Subtype polymorphism: the ability to use an object of a more specific type where an object of a more general type is expected.

We will discuss inheritance today, deferring subtype polymorphism until next time.

To motivate the concept of inheritance, consider the following definitions of Chicken and Duck ADTs:

class Chicken {
public:
  Chicken(const string &name_in)
    : age(0), name(name_in),
      roads_crossed(0) {
    cout << "Chicken ctor" << endl;
  }

  string get_name() const {
    return name;
  }

  int get_age() const {
    return age;
  }

  void cross_road() {
    ++roads_crossed;
  }

  void talk() const {
    cout << "bawwk" << endl;
  }

private:
  int age;
  string name;
  int roads_crossed;
};
class Duck {
public:
  Duck(const string &name_in)
    : age(0), name(name_in),
      num_ducklings(0) {
    cout << "Duck ctor" << endl;
  }

  string get_name const {
    return name;
  }

  int get_age() const {
    return age;
  }

  void have_babies() {
    num_ducklings += 7;
  }

  void talk() const {
    cout << "quack" << endl;
  }

private:
  int age;
  string name;
  int num_ducklings;
};

The two ADTs are nearly identical – both have age and name member variables, with their corresponding get functions, and both have a talk() member function that makes the appropriate chicken or duck sound. In terms of differences, chickens tend to cross roads (since they don’t fly very well), so we keep track of how often they do that. On the other hand, ducks are often accompanied by their ducklings, so we keep track of how many they have.

Intuitively, it makes sense for Chicken and Duck to share a lot of functionality, since chickens and ducks are both types of birds.

_images/09_bird_hierarchy.svg

Figure 44 Relationship between different kinds of birds.

This “is-a” relationship, where a Chicken is a Bird and a Duck is also a Bird, can be encoded with inheritance. We write Bird as a base class, then write Chicken and Duck as classes that inherit or derive from Bird. We place the common functionality in Bird, which then gets inherited by the derived classes.

class Bird {
public:
  Bird(const string &name_in)
    : age(0), name(name_in) {
    cout << "Bird ctor" << endl;
  }

  string get_name() const {
    return name;
  }

  int get_age() const {
    return age;
  }

  void have_birthday() {
    ++age;
  }

  void talk() const {
    cout << "tweet" << endl;
  }

private:
  int age;
  string name;
};

Here, all birds have a name and an age, and the generic sound a bird makes is “tweet”.

For more a more specific kind of bird, we obtain the functionality of the base class by deriving from it:

class Chicken : public Bird {
  ...
};

The syntax for deriving from a base class is to put a colon after the name of the derived class, then the public keyword, then the name of the base class. This results in public inheritance, where it is part of the interface of Chicken that it derives from Bird. Without the public keyword, it would be private inheritance 1, where it is an implementation detail and not part of the interface that Chicken derives from Bird.

1

As with member accessibility, the default inheritance is public if the struct keyword is used to define a class type and private if the class keyword is used. With private inheritance, the (non-private) inherited members are private to the derived class, so that outside code may not access those members. There is also protected inheritance, where classes that derive from the derived class can access the (non-private) inherited members of the derived class, but outside code cannot.

Now that Chicken derives from Bird, it inherits the functionality of the Bird class, and the public interface of Bird is also supported by Chicken:

Chicken c("Myrtle");
c.have_birthday();
cout << c.get_name() << " " << c.get_age();  // prints Myrtle 1

Functionality that is specific to a particular kind of bird goes in the class for that bird:

class Chicken : public Bird {
public:
  ...

  void cross_road() {
    ++roads_crossed;
  }

  void talk() const {
    cout << "bawwk" << endl;
  }

private:
  int roads_crossed;
};

Here, we have the additional data member roads_crossed; name and age are inherited from Bird. (They are not directly accessible from the Chicken class, however, since they are private. We will come back to this later.) Figure 45 illustrates the layout of Bird and Chicken in memory.

int main() {
  Bird big_bird("Big Bird");
  Chicken myrtle("Myrtle");
}
_images/09_derived_class_layout.svg

Figure 45 The layout of a derived-class object contains a subset that matches the layout of a base-class object.

The memory of Chicken actually consists of a Bird piece, plus the additional Chicken members. Thus, the data members defined by Bird are also included in a Chicken.

In order to properly initialize a derived-class object, its constructor must ensure that its base-class subobject is also appropriately initialized. The base class may have private member variables, which cannot be accessed from the derived class, so the derived class does not initialize the inherited members directly. Instead, it invokes a base-class constructor in the member-initializer list of its own constructors:

class Chicken : public Bird {
public:
  Chicken(const string &name_in)
    : Bird(name_in), roads_crossed(0) {
    cout << "Chicken ctor" << endl;
  }

  ...

private:
  int roads_crossed;
};

In C++, a derived-class constructor always invokes a constructor for the base class. If an explicit invocation does not appear in the member-initializer list, there is an implicit call to the default constructor. If the base class has no default constructor, an error results:

class Chicken : public Bird {
public:
  Chicken(const string &name_in)
    : roads_crossed(0) {  // ERROR: implicit call to Bird(), which doesn't exist
    cout << "Chicken ctor" << endl;
  }

  ...
};

For completeness, the following is an implementation of Duck as a derived class of Bird:

class Duck : public Bird {
public:
  Duck(const string &name_in)
    : Bird(name_in), num_ducklings(0) {
    cout << "Duck ctor" << endl;
  }

  void have_babies() {
    num_ducklings += 7;
  }

  void talk() const {
    cout << "quack" << endl;
  }

private:
  int num_ducklings;
};

By writing Bird and deriving from it in both Chicken and Duck, we have avoided duplication of the shared functionality.

Ordering of Constructors and Destructors

We have already seen that in most cases, a constructor is invoked when a class-type object is created. Similarly, a destructor is invoked when a class-type object’s lifetime is over. For a local variable, this is when the variable goes out of scope. The following illustrates an example:

class Foo {
public:
  Foo() {                        // constructor
    cout << "Foo ctor" << endl;
  }

  ~Foo() {                       // destructor
    cout << "Foo dtor" << endl;
  }
};

void func() {
  Foo x;
}

int main() {
  cout << "before call" << endl;
  func();
  cout << "after call" << endl;
}

The class Foo has a custom destructor, written as ~Foo(), which runs when a Foo object is dying. Here, we just have both the constructor and destructor print messages to standard out. The following is printed when the code is run:

before call
Foo ctor
Foo dtor
after call

We will cover destructors in more detail later in the course. For now, we concern ourselves solely with the order in which constructors and destructors execute when we have derived classes.

When there are multiple objects that are constructed and destructed, C++ follows a “socks-and-shoes” ordering: when we put on socks and shoes in the morning, we put on socks first, then our shoes. In the evening, however, when we take them off, we do so in the reverse order: first our shoes, then our socks. In the case of a derived class, C++ will always construct the base-class subobject before initializing the derived-class pieces. Destruction is in the reverse order: first the derived-class destructor runs, then the base-class one. The following illustrates this order:

class Bird {
public:
  Bird(const string &name_in)
    : age(0), name(name_in) {
    cout << "Bird ctor" << endl;
  }

  ~Bird() {
    cout << "Bird dtor" << endl;
  }

  ...
};

class Chicken : public Bird {
public:
  Chicken(const string &name_in)
    : Bird(name_in), roads_crossed(0) {
    cout << "Chicken ctor" << endl;
  }

  ~Chicken() {
    cout << "Chicken dtor" << endl;
  }

  ...
};

int main() {
  cout << "construction:" << endl;
  Chicken myrtle("Myrtle");
  cout << "destruction:" << endl;
}

The following results from running the code:

construction:
Bird ctor
Chicken ctor
destruction:
Chicken dtor
Bird dtor

When creating a Chicken object, the invocation of the Bird constructor is the first thing that happens in the Chicken constructor. This is true regardless of whether or not an explicit call to the Bird constructor appears, and regardless of ordering of the member-initializer list. Then the rest of the Chicken constructor runs. When a Chicken object is dying, first the code in the Chicken destructor runs. Then the code in the Bird destructor automatically runs. (It is generally erroneous to invoke the base-class destructor explicitly, since the compiler always does so implicitly.)

The following code creates both a Chicken and a Duck object:

int main() {
  cout << "construction:" << endl;
  Chicken myrtle("Myrtle");
  Duck donald("Donald");
  cout << "destruction:" << endl;
}

Assuming that Duck has a destructor that prints out "Duck dtor", the code prints the following:

construction:
Bird ctor
Chicken ctor
Bird ctor
Duck ctor
destruction:
Duck dtor
Bird dtor
Chicken dtor
Bird dtor

We see the same ordering in Duck construction and destruction. Furthermore, we see that because myrtle is constructed before donald, it is destructed after donald – socks-and-shoes ordering here as well.

Name Lookup and Hiding

When a member access is applied to an object, the compiler follows a specific process to look up the given name:

  • The compiler starts by looking for a member with that name in the compile-time or static type of the object. (We will discuss static and dynamic types next time.)

  • If no member with that name is found, the compiler repeats the process on the base class of the given type. If the type has no base class, a compiler error is generated.

  • If a member with the given name is found, the compiler then checks whether or not the member is accessible and whether the member can be used in the given context. 2 If not, a compiler error is generated – the lookup process does not proceed further.

2

If name lookup finds a set of overloaded functions within the same class, the compiler performs overload resolution to determine which overload is the most appropriate. We will discuss function overloading in more detail next time.

As an example, consider the following member accesses:

Chicken myrtle("Myrtle");
myrtle.get_age();
myrtle.talk();
myrtle.age;
myrtle.undefined;
  • For myrtle.get_age(), the compiler first looks for a get_age member defined in the Chicken class. Since Chicken does not define such a member, the compiler looks for a get_age member in its base Bird class. There is indeed a get_age member in Bird, so the compiler then checks that the member is accessible and is a function that can be called with no arguments. These checks succeed, so the lookup process terminates successfully.

  • For myrtle.talk(), the compiler looks for a talk member in Chicken. There is one, so it then checks to make sure it is accessible and is a function that can be called with no arguments. This succeeds, and at runtime, it is Chicken::talk() that is called. The function Bird::talk() is hidden, since the name lookup process never gets to it.

  • For myrtle.age, the compiler looks for an age member in Chicken. There is none, so the compiler looks in Bird. There is such a member, so the compiler checks whether it is accessible from the given context. The member is private, so this check fails, and the compiler reports an error.

  • For myrtle.undefined, the compiler looks for an undefined member in Chicken. There is none, so the compiler looks in Bird. There is no such member, and Bird has no base class, so the compiler reports an error.

As another example, consider the following code:

class Base {
public:
  int x;
  void foo(const string &s);
};

class Derived : public Base {
public:
  void x();
  void foo(int i);
};

int main() {
  Derived d;
  int a = d.x;
  d.foo("hello");
}

When looking up d.x, the compiler finds a member x in Derived. However, it is a member function, which cannot be assigned to an int. Thus, the compiler reports an error – it does not consider the hidden x that is defined in Base.

Similarly, when looking up d.foo, the compiler finds a member foo in Derived. Though it is a function, it cannot be called with a string literal, so the compiler reports an error. Again, the compiler does not consider the foo that is defined in Base; that member is hidden by the foo defined in Derived.

To summarize, C++ does not consider the context in which a member is used until after its finds a member of the given name. This is in contrast to some other languages, which consider context in the lookup process itself.

On occasion, we wish to access a hidden member rather than the member that hides it. The most common case is when a derived-class version of a function calls the base-class version as part of its functionality. The following illustrates this in Chicken:

class Chicken : public Bird {
public:
  ...

  void talk() const {
    if (age >= 1) {              // ERROR: age is private in Bird
      cout << "bawwk" << endl;
    } else {
      // baby chicks make more of a tweeting rather than clucking noise
      Bird::talk();              // call Bird's version of talk()
    }
  }
};

By using the scope-resolution operator, we are specifically asking for the Bird version of talk(), enabling access to it even though it is hidden. 3

3

Interestingly, we can apply the scope-resolution operator as part of dot or arrow syntax in order to access the base-class version: myrtle.Bird::talk(). However, this is very uncommon in real code, and we will not do it at all in this course.

The code above does have a problem: it accesses the age member of Bird, which, though it is not hidden, is private and so not accessible to Chicken. There are two solutions to this problem:

  • We can declare age to be protected, which allows derived classes of Bird to access the member but not the outside world.

  • We can use the public get_age() function instead.

The former strategy is not desirable; since the member variable age is an implementation detail, making it protected exposes implementation details to the derived classes. Instead, we should use a get function to abstract the implementation detail. We could choose to make such a function protected, so that it is part of the interface that derived classes have access to but not the outside world. For age, we already have a public get function, so we can just use that:

class Chicken : public Bird {
public:
  ...

  void talk() const {
    if (get_age() >= 1) {
      cout << "bawwk" << endl;
    } else {
      // baby chicks make more of a tweeting rather than clucking noise
      Bird::talk();              // call Bird's version of talk()
    }
  }
};