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. While encapsulation and inheritance can stand independently, polymorphism requires inheritance, which is only possible in those systems that also support 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 the syntax and the behavior of polymorphic systems 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 one 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 us to delay choosing which function to call until the moment of the call itself! You may be asking 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, pointers to different kinds of shapes.
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 and must therefore be passed as arguments to the other functions in the program.

Complete demonstration program: shape-proc.cpp

Figure 3 provides at least a partial answer to 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 possible choices while running. Our current program seems to do this well enough.

But suppose 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 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, classes in a real program typically have many functions making their objects more complex and used many times throughout a program. Each time a function is called in an actual program, 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, with each file consisting of hundreds or thousands of lines of code, which is a common situation in "real world" programs. When we modify such a program, there is a real risk that some instances of the Figure-3-like code will be overlooked and not updated, which may introduce 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

Normally, when the compiler processes a function, it generates the machine code corresponding to the function body. The operating system loads the function's machine instructions into memory with the rest of the program when it runs. The compiler binds the call to that location in memory for each function call. That is, the compiler generates instructions to jump to that location in memory, execute the function's instructions, and then return to the calling point. The compiler creates the binding when it identifies the address of a function and generates the instructions needed to jump to that address. 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:

These terms are synonyms with the same 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. 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 that are 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.