Monday, March 17, 2003

Using inheritance and dynamic binding (K&M -- Chapter 13)

Base classes, derived classes and dynamic function invocation

Previously, our student grading program was just concerned with a single type of student. Now, we introduce a new type of student, namely, a graduate student. Graduate students are identical to regular students except that graduate students must complete a thesis and their final grade is determined by their course work grade and their thesis grade (their final mark is the lesser of the two).

To accommodate for this difference we extract all the details that is common between undergraduate and graduate students and put them in a class call Core. We then use this class as a base class upon which a graduate student can be derived. (i.e. a graduate student class can be considered as a subclass of a core (undergraduate) student class).

We also introduce another accessibility keyword in the context of a class, namely protected. Protected members can be accessed by the class in which they are defined any by its derived classes. It cannot be accessed outside the context of any of these classes. In the example below, we want the Grad class to have access to the midterm and final exams marks as well as to the homework vector. So we put these data members in the protected section of the base class.

We can define the header file for the two classes as follows:


#ifndef GUARD_Core_h
#define GUARD_Core_h

#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

class Core {
public:
	Core(): midterm(0), final(0) { }
	Core(std::istream& is) { read(is); }

	std::string name() const;

	// as defined in 13.1.2/230
	virtual std::istream& read(std::istream&);
	virtual double grade() const;

	virtual ~Core() { }

protected:
	// accessible to derived classes
	std::istream& read_common(std::istream&);
	double midterm, final;
	std::vector<double> homework;

	virtual Core* clone() const { return new Core(*this); }

private:
	// accessible only to `Core'
	std::string n;
	friend class Student_info;
};

class Grad: public Core {
public:
	Grad(): thesis(0) { }
	Grad(std::istream& is) { read(is); }

	// as defined in 13.1.2/230; Note: `grade' and `read'
	// are `virtual' by inheritance
	double grade() const;
	std::istream& read(std::istream&);
private:
	double thesis;
	Grad* clone() const { return new Grad(*this); }
};

bool compare(const Core&, const Core&);
bool compare_Core_ptrs(const Core* cp1, const Core* cp2);

#endif

Note that a Grad student is exactly the same as a Core student, except that the class defines an extra data member to store the thesis grade. Because the method used to read in each type of student is different (since we must read in the thesis mark for graduate students) and because the calculating of the final grade is also different for undergraduate and graduate students, we make the read() and grade function virtual in the base class, Core. By making these functions virtual, we are telling the compiler that when we invoke either of these function via a pointer or a reference to a Core object, the actual function called will depend upon the actual type of the pointer or reference. This is known as dynamic binding or dynamic function invocation. For example, consider the following code fragment:

	Core *p;
	p = new Core;
	p->read(cin);	// Invoke Core::read()

	p = new Grad;
	p->read(cin);	// Invoke Grad::read()

Note that the actual read() function invoked depends upon the type of pointer assigned to p. One other important point to note here is that given a type which is a pointer to an object of a base class (p in the above fragment, for example), we can assign to it a pointer to an object of that base class (obviously) or any object of any class derived from this base class. Hence, in the above code fragment, p = new Grad is a perfectly legal operation since new Grad will return a pointer to an object of type Grad and Grad is derived from Core.

If we got rid of the pointer, then static invocation will result. For example, consider the following code fragment:

	Core	c;
	Core	c1;
	Grad	g1;

	c = c1;
	c.read(cin);	// Invoke Core::read()

	c = g1;
	c.read(cin);	// Invoke Core::read()

Even though the assignment c = g1 took place before the second invocation of the read(), the read() method that is defined by Core is invoked and not the read() method as defined by Grad. During the c = g1 assignment, none of the extra details that makes g1 a Grad object (e.g. the thesis data member) are preserved when the assignment to c are made. Therefore, c is very much a Core object and the invocation of the read() method will be statically determined during compile time (and not dynamically during run-time).

Overriding vs. Overloading

The Grad class essentially defines its own version of the virtual read() and grade() functions. This is known as a derived class overriding its base class's virtual functions. If the Grad class did not define its own read() method, for example, then the base class's read() method will be called.

Overriding is different from overloading in that the number and types of the arguments must be identical for overriding (if not, then the method in the derived class essentially shadows the method in the base class). In overloading, the number and/or types of the arguments must be different in order to ensure unambiguous compile-time resolution of calls to the overloaded functions.

The Core.cpp source module.

The following source module defines all the methods of the Core and Grad classes that were not defined in the header file. Note the differences in the implementation of the read() and grade() methods between the two classes.


#include <algorithm>

using std::min;

#include "Core.h"
#include "grade.h"

using std::istream;
using std::string;
using std::vector;
std::istream& read_hw(std::istream& in, std::vector<double>& hw);

string Core::name() const { return n; }

double Core::grade() const
{
	return ::grade(midterm, final, homework);
}

istream& Core::read_common(istream& in)
{
	// read and store the student's name and exam grades
	in >> n >> midterm >> final;
	return in;
}

istream& Core::read(istream& in)
{
	read_common(in);
	read_hw(in, homework);
	return in;
}

istream& Grad::read(istream& in)
{
	read_common(in);
	in >> thesis;
	read_hw(in, homework);
	return in;
}

double Grad::grade() const
{
	return min(Core::grade(), thesis);
}

bool compare(const Core& c1, const Core& c2)
{
	return c1.name() < c2.name();
}

bool compare_Core_ptrs(const Core* cp1, const Core* cp2)
{
	return compare(*cp1, *cp2);
}

Finally, we can define a main() function that employs the above two classes. Note the only major differences between this main() function and its earlier implementations in the student grading problem:


#include <vector>
#include <string>
#include <algorithm>
#include <iomanip>
#include <iostream>
#include <stdexcept>

#include "Core.h"

using std::cout;		using std::cin;
using std::domain_error;	using std::endl;
using std::setprecision;	using std::setw;
using std::streamsize;		using std::sort;
using std::string;		using std::vector;

using std::max;

int main()
{
	vector<Core*> students;         // store pointers, not objects
	Core* record;                   // temporary must be a pointer as well
	char ch;
	string::size_type maxlen = 0;

	// read and store the data
	while (cin >> ch) {
		if (ch == 'U')
			record = new Core;      // allocate a `Core' object
		else
			record = new Grad;      // allocate a `Grad' object
		record->read(cin);          // `virtual' call
		maxlen = max(maxlen, record->name().size());// dereference
		students.push_back(record);
	}

	// pass the version of `compare' that works on pointers
	sort(students.begin(), students.end(), compare_Core_ptrs);

	// write the names and grades
	for (vector<Core*>::size_type i = 0;
	     i != students.size(); ++i) {
		// `students[i]' is a pointer that we dereference
		// to call the functions
		cout << students[i]->name()
		     << string(maxlen + 1 - students[i]->name().size(), ' ');
		try {
			double final_grade = students[i]->grade();
			streamsize prec = cout.precision();
			cout << setprecision(3) << final_grade
			     << setprecision(prec) << endl;

		} catch (domain_error e) {
			cout << e.what() << endl;
		}
		// free the object allocated when reading
		delete students[i];      
	}
	return 0;
}

Using handles to solve the problem

We can also use a common C++ idiom called a handle to solve the above problem. In this example, the main() function is a bit cleaner as complexity is pushed onto a handle class which maintains a pointer to a Core object (this pointer, of course can then be used to point to either a Core object or a Grad object).

We define the Student_info class the represent the handle for each student object. Note that this class does all the pointer maintenance for us so that the user of this class (e.g. the main() function) does not have to deal with all these details.

Of particular importance is the use of the clone() method which creates a duplicate of the object in the context of Student_info's copy constructor and the assignment operator. Because the handle class only maintains a pointer to a Core object, we have no idea what actual type is pointed to by the pointer. By defining a trivial virtual clone() method for each class, we can ensure that during the invocation of the handle's copy constructor or assignment operator, that an object of the appropriate class will be created for us. In the Core class, the clone() method is defined as:

	virtual Core* clone() const { return new Core(*this); }

whereas in the the Grad class, the clone() method is defined as:

	Grad* clone() const { return new Grad(*this); }

Note that the return types for the clone() virtual function are different (this is allowed by C++). Note that we generally do not want classes other than the Student_info class to access the clone() method. Therefore, this method is made protected inside the Core class. In order for the Student_info class to get access to the clone() method, the entire Student_info class is made a friend of the Core class.


#ifndef GUARD_Student_info_h
#define GUARD_Student_info_h

#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

#include "Core.h"

class Student_info {
public:
	// constructors and copy control
	Student_info(): cp(0) { }
	Student_info(std::istream& is): cp(0) { read(is); }
	Student_info(const Student_info&);
	Student_info& operator=(const Student_info&);
	~Student_info() { delete cp; }

	// operations
	std::istream& read(std::istream&);

	std::string name() const {
		if (cp) return cp->name();
		else throw std::runtime_error("uninitialized Student");
	}
	double grade() const {
		if (cp) return cp->grade();
		else throw std::runtime_error("uninitialized Student");
	}

	static bool compare(const Student_info& s1,
	                    const Student_info& s2) {
		return s1.name() < s2.name();
	}

private:
	Core* cp;
};

#endif


#include <iostream>

#include "Core.h"
#include "Student_info.h"

using std::istream;

istream& Student_info::read(istream& is)
{
	delete cp;          // delete previous object, if any

	char ch;
	is >> ch;           // get record type

	if (ch == 'U') {
		cp = new Core(is);
	} else {
		cp = new Grad(is);
	}

	return is;
}

Student_info::Student_info(const Student_info& s): cp(0)
{
	if (s.cp) cp = s.cp->clone();
}

Student_info& Student_info::operator=(const Student_info& s)
{
	if (&s != this) {
		delete cp;
		if (s.cp)
			cp = s.cp->clone();
		else
			cp = 0;
	}
	return *this;
}

The main() function

Again, we define our main() function below. Note that this main function is almost identical to the original main() function that we saw in earlier lectures. The only change is that the global sort function is passed a static comparison method, as defined in the Student_info class. Because this method is static, there is no need to invoke this method via an object of type Student_info. Instead we use this function simply by using the class name and the scope resolution operator (i.e. Student_info::compare).


#include <algorithm>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

#include "Student_info.h"

using std::cin;			using std::cout;
using std::domain_error;	using std::endl;
using std::setprecision;	using std::setw;
using std::sort;		using std::streamsize;
using std::string;		using std::vector;

using std::max;

int main()
{
	vector<Student_info> students;
	Student_info record;
	string::size_type maxlen = 0;

	// read and store the data
	while (record.read(cin)) {
		maxlen = max(maxlen, record.name().size());
		students.push_back(record);
	}

	// alphabetize the student records
	sort(students.begin(), students.end(), Student_info::compare);

	// write the names and grades
	for (vector<Student_info>::size_type i = 0;
	     i != students.size(); ++i) {
		cout << students[i].name()
		     << string(maxlen + 1 - students[i].name().size(), ' ');
		try {
			double final_grade = students[i].grade();
			streamsize prec = cout.precision();
			cout << setprecision(3) << final_grade
			     << setprecision(prec) << endl;
		} catch (domain_error e) {
			cout << e.what() << endl;
		}
	}
	return 0;
}

Last modified: Mon Mar 17 14:09:53 2003