12.3. Polymorphism In Depth

Time: 00:11:46 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PDF)
Review

Polymorphism is the third and final characteristic that defines the object-oriented paradigm. Admittedly, any problem we can solve with polymorphism, we can solve without it. However, the polymorphic solution will always be more elegant, compact, and easier to follow - once you understand polymorphism. So, if the following discussion seems confusing at times, remember that any alternative solution will involve either maintaining numerous variables, passing them as function arguments, duplicating large amounts of code, or creating, maintaining, passing, and using arrays of function pointers. Polymorphism is simple in comparison.

A key concept to keep in mind throughout the following discussion is that the compiler uses different algorithms to locate polymorphic functions than it uses to find member variables and non-polymorphic functions. In the latter case, the compiler creates simple expressions. But polymorphism requires pointers, tables, and address arithmetic.

Function Binding, Part 2

Programmers say that "normal" (i.e., non-polymorphic) functions have compile time binding, early binding, or static binding. These terms suggest that the compiler system can connect a function call to the function's machine instructions when programmers compile and link the program. Polymorphism delays function binding from compile-time to run-time. Computer scientists use several different terms to denote this kind of binding:

These terms only mean the program delays the function binding (i.e., delaying which function gets called) until the program runs. To see how we enable polymorphism, we return to the problem of drawing shapes, but this time polymorphism replaces the switch statement to select the correct drawing function.

Drawing Shapes With Polymorphism

The first and most obvious change that we must make to the previous problem is to introduce a new superclass named Shape and to make the original classes subclasses of this new class. Figure 1 illustrates the UML class diagram that describes the class hierarchy for the restated problem:

The UML Shape class diagram or hierarcy consisting of four classes: Shape, Circle, Rectangle, and Triangle. None of the classes show member variables.

Shape
-----------------
-----------------
+draw() : void

Circle
-----------------
-----------------
+draw() : void

Rectangle
-----------------
-----------------
+draw() : void

Triangle
-----------------
-----------------
+draw() : void
UML class diagram of the Shape classes. Polymorphism requires inheritance and an overridden function. Shape is the superclass or parent class; Circle, Rectangle, and Triangle are all subclasses or children of Shape. The draw functions in the subclasses override the draw function in the Shape class.

Finally, we have all of the pieces in place to make the final step from a member function to a polymorphic function - that is, we are ready to switch on polymorphism, which we do by adding the virtual keyword to a function's prototype. The code appearing in Figure 2 illustrates making "virtual" functions and building an inheritance relationship.

class Shape
{
    public:
	virtual void draw();
};
class Circle : public Shape
{
    public:
	virtual void draw();
};
class Rectangle : public Shape
{
    public:
	virtual void draw();
};
class Triangle : public Shape
{
    public:
	virtual void draw();
};
Making functions virtual. The virtual keyword is the last ingredient in the polymorphic recipe. Once all of the other requirements are in place, making a function virtual in the superclass switches on or enables polymorphism. Note that once a function is made virtual in the superclass (Shape) that all overridden versions of the function in the subclasses are automatically virtual, making the "virtual" keyword optional in the subclasses (Circle, Rectangle, and Triangle).

The introduction of the Shape class, along with upcasting, allows us to simplify the code from the previous version of the shape drawing problem. The previous version required three separate variables, one for each kind of shape that the program can create and draw. Furthermore, if we added a new shape, an Ellipse for example, the new shape would require adding a new variable to the program. Using inheritance and upcasting, we can have a Shape pointer variable that can point to any shape, including shapes that we create in the future.

cout << "C:\tCircle" << endl;
cout << "S:\tRectangle" << endl;
cout << "T:\tTriangle" << endl;

cout << "Please choose a shape: ";

char	choice;
cin >> choice;
cin.ignore();

Circle*		c;
Rectangle*	r;
Triangle*	t;
Shape*		s = nullptr;

switch (choice)
{
	case 'C' :
	case 'c' :
		s = new Circle();
		break;

	case 'S' :
	case 's' :
		s = new Rectangle();
		break;

	case 'T' :
	case 't' :
		s = new Triangle();
		break;
}
Instantiating a Shape object. This version of the code replaces the separate variables (indicated by striking them out) with a single Shape pointer variable (highlighted in yellow). When each shape object (Circle, Rectangle, or Triangle) is created, it is upcast to a Shape. Upcasting and pointer variables are two more polymorphism requirements.

The most profound changes occur in the third block of code - the code where the shape is dawn. The previous version required a switch statement to determine which shape the user created and to then call the appropriate draw function (see Drawing the shape). This version dispenses with the switch statement and replaces it with a polymorphic function call.

if (s != nullptr)
	s->draw();
Drawing the shape polymorphically. Pointer s can potentially point to a Shape, a Circle, a Rectangle, or a Triangle. Which draw function should be called - i.e., to which draw function should s->draw() be bound? Through polymorphism, the correct function is selected at the moment of the function call itself.

The complete polymorphic version of the demonstration program: shape-poly.cpp

You really must compare Figure 4 above to Figure 3 in the original shape drawing problem. The two code fragments do the same thing! They both draw the user's selected shape. The polymorphic version above does not bind to the correct draw function until the time of the function call. At that time, it is the object to which the pointer points that determines which function is called.

Polymorphism

Requirements for polymorphism:

  1. Inheritance - this requirement is satisfied by adding the Shape class and making it the superclass of the specialized shapes.
  2. Function overriding - each class provides an overridden version of the draw function.
  3. Upcasting - done in Figure 3, above, when the shape objects are instantiated.
  4. A pointer or reference variable - variable s in Figures 3 and 4
  5. A virtual function - in Figure 2 all versions of draw are virtual

Assignment Function Call
Shape* s = new Circle(...);
 
void render(Shape* s) {...}
render(new Circle(...));
Upcasting is easy to see when done with an assignment operation, but upcasting most often takes place during a function call when an instance of a subclass is passed as a parameter to a function with a superclass argument.

The function override is necessary because the compiler always begins searching in the symbol table with the pointer-variable class-type. In this example, the compiler enters the symbol table at the Shape entry, and compilation fails if it doesn't find a draw function. Once it finds a virtual function, draw for example, polymorphism takes over.


Non-polymorphic Polymorphic
s->draw(); s->draw();
Polymorphism: early vs. late binding. In the absence of polymorphism, the pointer variable's class type determines which function is called (blue). In this case, the function call is bound to a function at compile time (early binding). When all five requirements necessary for polymorphism are present, it is the object itself that determines which function is called (red). In this case, function binding can only occur at run time (late binding).

Let's return to the drawing problem introduced at the beginning of the chapter. With polymorphism in place, what do we need to do if the user wishes to add an Ellipse shape?

  1. We must create a new Ellipse class, a subclass of Shape, similar to the Circle, Rectangle, and Triangle classes illustrated in Figures 1 and 2. The new Ellipse class must override the draw function. That draw function is virtual automatically, making the virtual keyword optional.
  2. We must add a new case to the prompt and instantiation code shown in Figure 3.
  3. The function call illustrated in Figure 4 requires no modification!

The polymorphic solution has the same three steps as our initial solution, so it may not seem like much of an improvement. But step 3 of the polymorphic solution is substantially different than the original. An actual program typically uses objects in many locations throughout the code - potentially spread across thousands of lines of code and hundreds of files. Without polymorphism, programmers must find and update every place the program uses the object, and the odds of missing one of these are quite high. Alternatively, the polymorphic solution doesn't require any changes.

UML class diagram of three classes:

Employee
-----------------
-----------------
+calc_pay() : double

SalariedEmployee
-----------------
-salary : double
-----------------
+calc_pay() : double

SalesEmployee
-----------------
-commission : double
-----------------
+calc_pay() : double

SalesEmployee is a subclass of SalariedEmployee, and SalariedEmployee is a subclass of Employee.
Different kinds of employees. Salaried employees earn a salary, and sales employees earn a salary plus a commission.

Accessing Member Variables Without Downcasting

When programming with objects, it's common to "hide" variables by making them private class members. Programs access private variables through the class's public interface - its public member functions. For example, Figure 7 illustrates different kinds of employees with different member variables and ways of calculating their pay. If salaried employees are paid twice a month, then their pay is salary / 24. A sales employee is a salaried employee by inheritance. So, sales employees earn a salary, paid twice per month, plus a commission. Logically, a sales employee's pay is salary / 24 + commission.

But salary is private in the SalariedEmployee class, so a SalesEmployee object cannot access it. The proper object-oriented solution is to call a member function in the superclass. All three classes define a public member function named calc_pay() that calculates the pay for a specific kind of employee. The SalesEmployee calc_pay() function can call the SalariedEmployee calc_pay() function and add the commission. This scheme works well until we introduce an upcast.

If we upcast a SalesEmployee object to SalariedEmployee, it won't be able to access commission without a downcast. Without saying why we may want to do the upcast, let's note that we can achieve an elegant solution by making calc_pay() polymorphic. Compare the requirements for polymorphism to Figures 7 and 8, and verify that all polymorphism requirements are satisfied.

SalariedEmployee SalesEmployee
virtual double calc_pay()
{
	return salary / 24;
}
virtual double calc_pay()
{
	return SalariedEmployee::calc_pay() + commission;
}
(a)(b)
Overridden calc_pay functions. Each function can access the private member variables in their defining class, and a subclass function can call the overridden superclass function using the superclass name and the scope resolution operator (highlighted).
  1. Calculates the pay for a SalariedEmployee using the private member variable salary.
  2. Calculates the pay for a SalesEmployee by calling the SalariedEmployee calc_pay() function and adding the private member variable commission.

Safe Downcasting With Polymorphism

Previously, we saw that an an upcast followed by an incorrect downcast was potentially meaningless and dangerous. Although polymorphism greatly reduces the need to downcast to access the member variables in an object, it is sometimes necessary. In these cases, polymorphism provides a way to ensure that the downcast is meaningful and safe. For example, suppose that sometimes after an object is selected and instantiated (Figure 3 above) that we need to downcast pointer s to a specific shape class. If the user selected a Circle, we don't want to downcast s to a Rectangle or a Triangle. How do we know to which shape to safely downcast s? Polymorphism gives us two different ways of solving this problem:

#include <iostream>
#include <typeinfo>
using namespace std;

int main()
{
    Shape*	s = new Circle;
    Circle*	c;
    Rectangle*	r;
    Triangle*	t;

    if (typeid(*s) == typeid(Circle))
	c = (Circle *)s;
    else if (typeid(*s) == typeid(Rectangle))
	r = (Rectangle *)s;
    else if (typeid(*s) == typeid(Triangle))
	t = (Triangle *)s;

    return 0;
}
#include <iostream>
using namespace std;

int main()
{
    Shape*	s = new Circle;
    Circle*	c;
    Rectangle*	r;
    Triangle*	t;

    if (dynamic_cast<Circle*>(s) != nullptr)
	c = dynamic_cast<Circle*>(s);
    else if (dynamic_cast<Rectangle*>(s) != nullptr)
	r = dynamic_cast<Rectangle*>(s);
    else if (dynamic_cast<Triangle*>(s) != nullptr)
	t = dynamic_cast<Triangle*>(s);

    return 0;
}
 
(a)(b)
Safe downcasting with typeid or dynamic_cast. Both typeid and dynamic_cast require that the classes have at least one virtual function in order to identify a safe downcast.
  1. The typeid function returns an instance of type_info (similar to an instance of Java's Class class). So
    • typeid(*s) returns a type_info object that describes s (the * is the dereference operator)
    • typeid(Circle) returns a type_info object that describes the Circle class
    If the two type_info objects are the same, s points to a Circle object and the downcast is safe. This code will compile even when the classes do not have a polymorphic function, but it will fail to choose a safe downcast (see the downloadable code).
  2. dynamic_cast represents an attempt to cast the pointer to the class named in angle brackets. The cast returns nullptr if it fails (indicating an unsafe downcast) or non-nullptr if it succeeds (indicating a safe downcast). Note that when performing an actual downcast operation that dynamic_cast<Circle*>(s) is identical in behavior to (Circle *)s.
Downloadable code: cast.cpp