C++ track: lab 7: Inheritance


Inheritance

Inheritance is an object-oriented programming technique that allows us to model relationships between objects. A nice example for this relationship is fruit. Imagine you have a class Fruit already defined. Perhaps it has some methods such as seedsRemaining() and numCalories(). Here's what a header file Fruit.hh might look like:

class Fruit 
{
  private:
    Color *color;
    int calories;
    int seeds;

  public:
    Fruit();  // no arg constructor
    Fruit(Color *c, int cal, int s);
    ~Fruit();

    int seedsRemaining() const;
    int numCalories() const;
}

What about a banana? Are there "methods" intrinsic to a banana that do not apply to every Fruit? Sure. Maybe I want to check to see if my banana stillHasPeel() (that would be an appealing method to write). You can likely come up with others. In C++, we can represent the fact that our class Banana inherits from the class Fruit like so:

class Banana : public Fruit
{
  private:
    bool peelExists;
  
  public:
    Banana();  // no arg constructor
    Banana(Color *c, int cal, int s, bool peel);
    ~Banana();

    bool stillHasPeel() const;
}

Notice the single colon, followed by the keyword public (which means "public inheritance" in this context --- there are other types of inheritance which are used less often, such as private and protected inheritance), followed by the name of the class to inherit from. Note that we must #include the declaration of Fruit via its header file for this code to work.

What you have done here is to say that Bananas are Fruits and can thus do anything a Fruit can do, but can also perhaps do more (and perhaps do the same thing in a different way). So even though you haven't specified that a Banana has a numCalories method, it does have one because it inherits the method from the Fruit class. Therefore, if I instantiate a Banana, I can call any public method belonging to the class as well as any public method belonging to its parent class:

Banana t();

if (t.stillHasPeel() == true) 
{
    cout << "t still has a peel!" << endl;
}

if (t.numCalories() > 200)
{
    cout << "This is a big honkin' banana!" << endl;
}

Of course, Banana is just one of the many "subclasses" or "child classes" we could "derive" from Fruit (which is known as the "superclass" or "base class" or "parent class" of Banana). One could imagine having Apples, Oranges, heck, maybe a whole cornucopia of delicious seed-bearing treats. If we had enough sub-classes of Fruit which all had peels, we might decide that the stillHasPeel() method is intrinsic to all those, so we could add another layer of functionality into our inheritance tree, if you will, like so:

In this manner, large amounts of complexity in object hierarchies can be managed at any level of granularity you wish.

protected fields and methods

When inheritance enters the picture, some things that were simple to understand before become more complicated. Previously, we understood that most fields of an object should be private because they represented details of the implementation, and most methods should be public so that they could be used by other code (although private methods are also useful on occasion). With inheritance, if a private field is declared in a superclass, then instances of its subclass will not have direct access to that field! private really means private! Sometimes this is what you want, but often you want fields in a class to be directly accessible to instances of subclasses, but not directly accessible to any other code. This kind of field with "selective access" is called a protected field (note that you can have protected methods too). Whether you should usually use private or protected fields is a controversial topic in the object-oriented programming community, but the best answer is that it depends on the situation.

Overriding methods

Sometimes you want to change what a method does in an instance of a subclass as compared to what it does in an instance of a class. You know that if a method is defined in a superclass and you don't define the method in the subclass, the version of the method that will be used is the version in the superclass (that's how object-oriented programming encourages code reuse). But you can also redefine the method in the subclass, as long as it has the same type of arguments and returns the same type of values. This is called overriding a method.

However, it's not quite that simple (is it ever?). One of the neat things about object-oriented programming is that you can write functions that take arguments which are instances of a given class, and then pass those functions instances of a subclass of the original class and they will still work. For example, let's say we add a new method to our class Fruit that returns a string containing information about the fruit; we'll call it getInfo(). We might use it like this:

void print_fruit_info(Fruit *f)
{
    std::cout << f->getInfo() << endl;
}

However, the information for a Banana might be different from that for a Fruit, so we'd want to override the getInfo() method in the Banana class. Fine and dandy. But C++ guarantees us that we can pass a pointer to a Banana object to the function print_fruit_info(), because a Banana is also a Fruit. But if we define the getInfo() methods in the Fruit and Banana classes as usual, we'll find, to our surprise, that print_fruit_info() will call the Fruit version of getInfo() even if we pass it a pointer to a Banana object. That's not what we want. We want the system to be smart enough so that it "does the right thing" with a Banana object, which in this case means calling the Banana object's getInfo() method if a pointer to a Banana object was passed to print_fruit_info(). This "smart" behavior is called polymorphism, and it's the only really clever thing that object-oriented programming gives you (as well as being the hardest thing to understand!). To make the getInfo() method polymorphic, it has to be declared virtual in the Fruit class (you can also declare it virtual in the Banana class as well or not; it won't matter either way). It will look like this:

class Fruit 
{
  protected:   // Might as well change this too...
    Color *color;
    int calories;
    int seeds;

  public:
    Fruit();
    Fruit(Color *c, int cal, int s);
    ~Fruit();

    int seedsRemaining() const;
    int numCalories() const;
    virtual std::string getInfo();
}

If you leave out the virtual keyword, the print_fruit_info() will always use the Fruit version of getInfo(), which is not what you want. Adding the virtual keyword means that the "right" method for the object will always be called, even if it's passed to a function that expects an instance of a superclass of the object. Adding the virtual keyword totally changes the meaning of the function!

There's one other complicating point (aren't there a lot of them?). Polymorphism works as long as the argument to the function is a pointer or a reference, but if it's just a plain object, like this:

void print_fruit_info(Fruit f)  // Note: type of f is "Fruit" and not "Fruit *"
{
    std::cout << f->getInfo() << endl;
}

then it won't work (the superclass method will always be called). That's because the original object will get copied into a brand new Fruit object when the function print_fruit_info() is called. Watch out for this pitfall!

Another issue is that when you have virtual methods, your destructor should also be virtual. This makes sense; if you have a collection of Fruit * pointers, of which some point to Fruits and some point to Bananas, and you want to call delete on all of them, you want to make sure that the correct destructor for each object is called. You can accomplish this by making the destructor virtual. If you forget to do this (a very common bug!) then the wrong destructor may get called on an object, which could lead to memory leaks or a core dump.

Calling superclass methods and constructors

Sometimes you are overriding a method of a subclass of a particular class, and you want to call the original class' version of the method (and then do something else in addition to that). How do you do that? In the example above, you might have this:

// In Fruit.cc:
Fruit::getInfo()
{
    std::string s = "I'm a big, juicy, delicious fruit!";
    return s;
}

// In Banana.cc:
Banana::getInfo()
{
    // Call the superclass method:
    std::string s = Fruit::getInfo();

    // Now do something else in addition to that.
    s += "\n";  // Add a newline.
    s += "And not only that, I'm a sweet, tasty banana!";
    return s;
}

In other words, if you want to call a superclass method you have to qualify it explicitly with the name of the class. In this case, if you just called getInfo() and not Fruit::getInfo() you'd end up in an infinite loop (can you see why?).

Another thing you may want to do is to call a superclass constructor inside the constructor of a subclass. The way to do this is to put the superclass constructor call inside the member initialization list of the constructor. For instance, we might have:

// In Fruit.cc:
Fruit::Fruit(Color *c, int cal, int s)
  : color(c), calories(cal), seeds(s)  // initialize fields
{ }  // nothing else to do

// In Banana.cc:
Banana::Banana(Color *c, int cal, int s, bool peel)
  : peelExists(peel),  // initialize fields
    Fruit(c, cal, s),  // call superclass constructor
{ }  // nothing else to do

A one-minute introduction to the Standard Template Library (STL)

There are certain classes that are so generally useful that they are provided as a standard feature of C++. Since many of them use templates, the template-using classes are collected into a library called the "Standard Template Library" or STL for short. In fact, the STL also contains templated functions as well as classes, but we'll leave that discussion for the advanced C++ track. For our purposes, what you need to know is that there are a lot of collection classes in the STL. Collection classes are classes that implement various kinds of collections of objects. These include:

STL classes are quite easy to use. First, you #include the appropriate header file e.g.

#include <vector> // for vectors
#include <list>   // for lists
#include <map>    // for maps

Then, when you need a particular kind of templated object, you create it as you'd expect:

vector<int> v;               // Creates a vector of integers.
list<std::string> ls;        // Creates a linked list of strings.
map<std::string, double> m;  // Creates a map between strings and doubles.

Note that some templates (like map) take multiple template parameters; this is completely legal and often useful. Also note that STL template classes often overload operators to behave in reasonable ways. So you can do things like this:

v[0] = 42;
std::cout << "The first vector element is: " << v[0] << endl;
m["Joe"] = 1001;
std::cout << "Joe's ID number is: " << m["Joe"] << endl;

Both the vector and the map class overload the square brackets (indexing) operator so that it behaves the way that you would expect it to. So vectors can be treated like arrays (even though internally they're very different), and although we haven't seen anything quite like maps before, they behave like arrays that use arbitrary objects as indexes (here, string objects).

One subtle point about STL container classes: if the object stored in the container is a pointer, then deleteing the container will not delete the pointer. Managing the memory corresponding to the pointer is the user's responsibility.

There is much, much, much more to say about the STL. It's covered in much more detail in the advanced C++ track (as well as in many fine books), so if you're interested you should take that track (and look for those books).


Program to write

Given the following base class Employee (in Employee.hh and Employee.cc):

#include <string>

class Employee {
protected:
  double       payRate;
  std::string  name;

public:
  Employee(std::string empName, double empRate);
  
  double pay() const;
  void print() const;
};

Do the following:

  1. Derive a new class Hourly from Employee, where Hourly meets the following criteria:

  2. Derive another new class Executive from Employee, where Executive meets the following criteria:

  3. Be sure to use the #ifndef/#define/#endif technique in each of your header files to avoid multiple inclusion of header files.

  4. Write a short program called lab7.cc that:
    1. Creates a map<int, Employee *> to map Social Security Numbers to Employee objects,
    2. creates an Hourly employee, liberally gives them some hours worked, and then adds them to the map with SSN 250124332, which is that employee's social security number, of course,
    3. creates an Executive employee, liberally applies a bonus to their salary for this term, and adds them with SSN 007324232,
    4. goes back and extracts the two Employee *'s from the map by using the bracket-operator, and
    5. calls the print() method and then displays the return of the pay() method for each of these objects in turn.
    6. At this point, if you've done everything correctly so far, you'll realize that the output of the program when you run it is not what you really intended. You can fix that by editing the Employee.hh class and making a couple of very simple changes. Do that.

That's all, folks!