12.1. An Introduction To Polymorphism

Time: 00:05:25 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PDF)
Review

The object-oriented paradigm has three defining characteristics: encapsulation, inheritance, and polymorphism. Encapsulation and inheritance can stand independently, but polymorphism requires inheritance. Although polymorphism is a potent tool, it isn't easy to fully appreciate its power outside large, complex programs. Therefore, this semester focuses on its syntax and behavior while forgoing more useful examples. Rest assured that subsequent courses will build on this basic foundation.

Chapter 12 covers several topics, but they all lead up to and revolve around a central theme: polymorphism. Stated without detail, polymorphism is just a way of choosing which function to execute when multiple functions match a function call. Remarkably, it permits the program to delay choosing a function until runtime - until the moment of the call itself! You ask yourself, "Why wouldn't I know which function to call?" Let's set the stage by posing a problem and outlining a solution that doesn't use polymorphism. Later, we'll revisit the problem and see how a polymorphic solution can simplify the program.

Drawing Shapes Without Polymorphism

Our proposed program must perform three tasks:

The three parts of our program might look something like this:
class Circle
{
    public:
	void draw();
};
class Rectangle
{
    public:
	void draw();
};
class Triangle
{
    public:
	void draw();
};
Specifying shape classes. These classes are representative of the typical classes appearing in a program. They are the same in both the polymorphic and the non-polymorphic examples.
cout << "C:\tCircle" << endl;
cout << "R:\tRectangle" << endl;
cout << "T:\tTriangle" << endl;

cout << "Please choose a shape: ";

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

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

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

	case 'R' :
	case 'r' :
		r = new Rectangle(...);
		break;

	case 'T' :
	case 't' :
		t = new Triangle(...);
		break;
}
Instantiating a specific shape object. The switch statement interprets the user's choice by instantiating the appropriate shape. The switch and the character variable driving it are necessary for both the program's polymorphic and non-polymorphic versions. The non-polymorphic solution also requires three variables, each a pointer to a different shape.
switch (choice)
{
	case 'C' :
	case 'c' :
		c->draw();
		break;

	case 'R' :
	case 'r' :
		r->draw();
		break;

	case 'T' :
	case 't' :
		t->draw();
		break;
}
Drawing the shape. Based on the user's selection, a switch statement calls the correct draw function. The switch statement represents the code needed to access or use the object. An actual program repeats this code block whenever and wherever it needs to use that object. We can implement this simple demonstration entirely main, but an actual program would have many functions. The variables choice, c, r, and t are required to access the user's shape, so they must be passed as arguments to the other functions in the program.

Complete demonstration program: shape-proc.cpp

Figure 3 partially answers the question, "Why wouldn't I know which function to call?" The programmer doesn't know which function to call until the user selects a shape, which only happens after the program starts running. So, the program must be sufficiently dynamic to select the correct draw function from several choices while running. Our current program does this but with more variables than necessary. Furthermore, imagine that the user wishes to add another shape to the program: an Ellipse. What code must we modify?

  1. We must create a new Ellipse class similar to the classes illustrated in Figure 1
  2. We must add a new case to the prompt and instantiation code shown in Figure 2
  3. We must locate every occurrence of the code represented by Figure 3 and add a new case
Adding a new Shape class. Programs inevitably grow and evolve. A good, robust design allows programmers to make changes without introducing errors.

Steps 1 and 2 are inescapable regardless of how we write the code. Fortunately, both steps are localized - each step requires editing code in one place in one file. In this unrealistically simplified example, step 3 seems on par with the first two, but this is not the case. The switch statement illustrated in step 3 illustrates using the object once. An authentic program must repeat the switch statement whenever and wherever the program uses the user's shape. Furthermore, real programs have many more functions than this simple example. The functions are generally larger and use the program objects repeatedly, making the objects more difficult to locate and update. Each time the program calls a function, a code block similar to Figure 3 is necessary. This code structure is bulky and cumbersome, even without modifying the program.

Now imagine that our program consists of scores of files, each consisting of hundreds or thousands of lines of code, which is common in "real world" programs. When we modify such a program, there is a real risk that programmers may overlook some instances of the Figure-3-like code and fail to update them, potentially introducing a bug that might not be detected until a catastrophic failure occurs. The problem represented by Figure 3 arises from the practice of binding a function call to a specific function too early at compile time.

Function Binding, Part 1

When the compiler processes a function, it generates the machine code from the function body, which the operating system loads into memory with the rest of the program when it runs. Normally, the compiler binds a function call to the function's machine code by generating instructions to jump to the code's memory location. The program executes the machine code before returning to the calling point. So, for the function calls

  1. c->draw();
  2. r->draw();
  3. t->draw();

appearing in Figure 3, the compiler can bind statement 1 to the Circle draw function, statement 2 to Rectangle, and statement 3 to Triangle. Programmers use three terms to name and describe this kind of binding:

The terms are synonyms, meaning the compiler can bind a function call to a specific set of machine instructions when it compiles the program. Compile time-binding works most of the time - all the functions we have written this semester are processed this way. But, as Figure 3 demonstrates, compile-time binding doesn't always work and is sometimes inconvenient and awkward. Polymorphism allows us to write more elegant code that is easier to maintain.

Polymorphism Requirements

Polymorphism is active by default in Java programs, and programmers must explicitly deactivate it when it's not wanted. Conversely, before programmers can use polymorphism in a C++ program, they must activate it by satisfying five requirements. We have previously explored some of those requirements, but others are new, and we'll explore them in greater detail in subsequent sections.

Requirements For Polymorphism
  1. Inheritance
  2. Function overriding
  3. A pointer or reference variable (polymorphism cannot operate through an automatic or stack variable)
  4. Upcasting
  5. A virtual function

These required features roughly outline the remainder of the chapter.

Overloading and Overriding Functions

The terms overloading and overriding are similar and therefore easily confused. Let's take a moment and review their similarities and their differences. You might find it worthwhile to review Figure 2 where the terms were first introduced.

Overloaded FunctionsOverridden Functions
  • Are defined in the same class
  • Must have unique argument lists
  • May have different return types
  • Are defined in two classes related by inheritance
  • Must have the same name
  • Must have exactly the same argument list
  • Must have the same return type

Although polymorphism requires a function override, programs may override functions without making them polymorphic.