Virtual Member Functions
Now that you've learned about inheritance and constructors, let's take a look at how derived-class member functions may be redefined or overridden. Open the Repl from Lesson 15A and we'll continue with our simple "finger-exercise" example that lets you concentrate on one piece of the inheritance puzzle at a time.
A derived class may override member functions in the base class. The base class must permit that by using the keyword virtual on the prototype. Let's see how that works by modifying the Person class to add a new virtual toString() member function and a virtual destructor like this:
class Person
{
public:
. . .
virtual std::string toString() const;
virtual ~Person() = default;
private:
std::string name;
};
It is up to the base class designer to decide which member functions may be overridden and which may not. Member functions which allow derived classes to override them should be preceded with the keyword virtual.
As soon as you add a single virtual function, you should add a virtual destructoras shown in the Person class header. This uses the =default keyword to keep the synthesized destructor written by the compiler.
Implementing toString()
The implementation of toString(), in person.cpp does not repeat the keyword virtual. Let's have it display the person's name, like this:
string Person::toString() const
{
return "Name: " + name;
}
The Student class inherits Person::toString(). If the Student class does nothing else, then there is no difference between a virtual member function and a regular member function. To see this, modify main() to add the following two lines:
cout << "pete->" << pete.toString() << endl;
cout << "steve->" << steve.toString() << endl;
When you run the sample program it looks like this.
The variable pete prints out the name as you'd expect (since pete is a Person object). The variable steve also uses the new toString() member function defined in Person. To steve, it is just another inherited member.
Overriding toString()
When another class (like Student) wants to provide a different implementation for a virtual member function, like toString(), it must:
- Use exactly the same signature (number and type of parameters) as the original virtual function in the base class. There are no conversions between int and double for instance as with overloading.
- Return exactly the same type as the original member functions.
Let's override toString() in the Student class. Here's the header:
class Student : public Person
{
public:
Student(const std::string name, long sid);
long getID() const;
std::string toString() const;
private:
long studentID;
};
Note that the prototype is copied exactly from Person::toString(), except for the keyword virtual. You do not need to repeat the word virtual in the derived class definition, (although you may for documentation purposes). A virtual function is always virtual, and a non-virtual function cannot be made virtual in one of its subclasses.
Implementing Student::toString()
Here's one possible implementation of Student::toString():
string Student::toString() const
{
return "Name: " + getName() // inherited member
+ ", ID: " + to_string(studentID);
}
While this works, it doesn't take advantage of the of the already-written toString() member function in the Person class; in the Student version of toString(), you're duplicating exactly the same code.
Is there some way to run the original version of toString(), and just add on the new parts you want, like the studentID? Is there some way you can combine inherited and overridden member functions?
Yes, there is.
Combining toString()
Student inherits both getName() and toString() from Person. When you create a Student, you can use both of those members if they were defined in Student.
Put that to work by calling the inherited version of toString() from inside the new overridden toString() member function. Use the scope resolution operator to specify that you wish to call the base class version of toString().
string Student::toString() const
&lbracel;
return Person::toString() // base-class member
+ ", ID: " + to_string(studentID);
}
If you forget to use the scope-resolution operator, your program blows up the stack and crashes. At least in Java it is polite enough to give you a StackOverflowError when you try to run it. In C++, you'll just see a seg-fault message.
Don't confuse method overriding (which is what we're doing here), with method overloading. With overloading, two or more methods have the same name, but different parameter lists. Overloaded methods are in the same class but overridden methods are in a subclass and they must have exactly the same parameters and return type as the method that they are overriding.
The override Keyword
While always a logic error for a derived class to redefine a non-virtual function, it is not a syntax error. C++ 11 added new contextual keywords that allow the compiler to catch such logic errors that previously were often hidden, and turn them into syntax errors that can be caught at compile time.
To tell the compiler that you intend to override a base class function, add the contextual keyword override to the end of the member function declaration like this:
std::string toString() const override;
Now, if you were to forget the virtual in the base class, trying to (incorrectly) override a non-virtual inherited member function, or misspell the name of the member function, or provide the wrong arguments, the compiler catches those errors and warns you when you compile, like this:
does not override std::string Person::toString()
Class Hierarchy
You first met a class hierarchy when we looked at the related stream classes. The class ios represents a general stream type, used for any kind of I/O. The classes istream and ostream generalize the notions of input and output streams. The C++ file and string-stream classes fall naturally into their appropriate position.
Each class shown here is a derived class (subclass) of the class that appears above it in the hierarchy. istream and ostream are both derived classes of ios, while ios is a base class (superclass) of both istream and ostream. Similar relationships exist at all different levels of this diagram. For example, ifstream is derived from istream, and ostream is the base class of ofstream.
Stream Substitutability
Writing data to a file is almost as easy as printing it on the screen. Once an ofstream object is set up, you can use the << operator with the file stream in the same way you can with the cout object:
int x = 42;
ofstream out("myfile.dat");
cout << "x->" << x << endl; // of course this works
out << "x->" << x << endl; // this works as well
Well, the question is, why does that work? To understand this, think back to the write function that you created for the Point structure:
ostream& write(ostream& out, const Point& p)
{
out << "(" << p.x << ", " << p.y << ")";
return out;
};
This works with cout and cerr, both of which are ostream objects.
Point p = {4, 2};
write(cout, p) << endl;
write(cerr, p) << endl;
So, what do you have to do to adapt the function so that it works with ofstream objects and maybe even ostringstream objects? The answer, perhaps surprisingly, is that you do not have to do anything; it already works with ofstream objects just as it does with ostream objects like cout, because every ofstream object IS-A ostream object through the principle of substitutability.
Substitution vs. Conversion
C++ allows automatic conversions between the built-in numeric types; with numeric conversion, the compiler runs a built-in algorithm and tries to calculate the closest value that you desire. That's not what happens with objects in a class hierarchy.
When you pass an ofstream object to a function that expects an ostreams&, no conversion takes place at all! Instead, the ofstream object is automatically treated as if it were an ostream object, because the ostream and ofstream classes are related as in a special way through inheritance. Because the ofstream class is derived from the ostream class we can substitute it for the expected ostream parameter.
We can do that because the derived class inherits all of the characteristics of its base class, so that anything an ostream object can do, an ofstream object can do as well, by definition. This ability to allow a derived or subclass object to be used in any context that expects a base-class object is known as the Liskov Substitution Principle, after computer scientist Barbara Liskov.