accelerated-cpp学习笔记,参考电力出版社的《Accelerated C++》翻译,这里是第13章内容。本章的内容主要在于如何使用OOP的关键技术——继承和动态绑定,涉及了类在继承过程中的静态绑定和动态绑定问题。介绍了继承时类的各个成员的动作,定义虚拟函数进行动态绑定的操作,另外,还介绍了一个简单的句柄类,用来管理基类和它的派生类,把继承中可能的隐患隐藏起来。
第13章 继承与动态绑定——继承与虚函数
继承与动态绑定是面对对象编程(OOP)的关键技术,它要解决的是在不改变对象核心特性的情况下,衍生出针对特定对象的特殊操作,从而达到代码复用的效果。
1 一个简单的Core类
在一个类与另一个类比较起来,除了一些扩充以外其余部分完全相同的情况下,可以考虑使用继承特性。这里以学生信息StudentInfo
类为例,一个类集成了一些核心的公共的操作,把它定义成Core
类,它仍保留了StudentInfo
类的操作,另一个类增加了与研究生学分成绩相关的操作。此外,Core
类中还加入了一个私有成员函数,用来读取学生记录中所有学生公共部分的数据。
class Core {
public:
Core();
Core(std::istream&);
std::string name() const;
std::istream& read(std::istream&);
double grade() const;
private:
std::istream& read_common(std::istream&);
std::string n;
double midterm, final;
std::vector<double> homework;
};
Grad
类将定义一些额外的操作以满足额外的要求:
class Grad : public Core{
public:
Grad();
Grad(std::istream&);
double grade() const;
std::istream& read(std::istream&);
private:
double thesis;
};
Grad
类型是从Core
类派生(继承)出来的,或者说Core
类是一个基类,Core
类中的每个成员(构造函数、析构函数与赋值运算符函数除外)也是Grad
的成员,Grad
类可以定义自己的成员,也可以重定义基类的成员,但是不能删除任何基类成员。继承中使用到了public
关键字,它表示从基类派生过来的是接口部分,而不是实现部分,实际上,private
部分是不能被继承的,但是派生类可以通过继承的接口访问。
公有继承:基类的公有部分和保护部分属性不变
保护继承:基类的公有部分和保护部分继承为派生类的保护部分
私有继承:基类的公有部分和保护部分继承为派生类的私有部分,默认继承方式
1.1 保护类型
上面的定义中Core
类中的所有四个成员数据以及read_common
成员函数都不能被Grad
类中的成员函数访问,因为它们是私有成员,但是要实现Grad
类中的grade
函数与read
函数,就必须能访问这些私有成员,所以,可以将它们声明为protected
:
class Core {
public:
Core();
Core(std::istream&);
std::string name() const;
std::istream& read(std::istream&);
double grade() const;
protected:
std::istream& read_common(std::istream&);
double midterm, final;
std::vector<double> homework;
private:
std::string n;
};
protected
关键字表示它们是保护成员,可以被派生类访问,同时又保持这些成员不能被类的其他使用者访问。因为n
是Core
类的私有成员,而Grad
类并没有权限访问它,只能通过Core
类的公有成员函数访问。
1.2 操作函数
在类中共定义了四个构造函数和六个操作函数,先来看怎么实现操作函数。为了能储存不同数量的家庭作业成绩,成绩必须来自每个记录的结尾处。现在,假定每条记录至少由一个学生的姓名、期中考试成绩和期末考试成绩组成。如果这是一个本科生的记录,那么后面马上加上家庭作业的成绩,如果这是一个研究生的记录,就先在期末考试成绩后面加上论文成绩,再加上家庭作业成绩。
由此,可以写出Core
类的操作函数:
string Core::name() const { return n; }
double Core::grade() const {
return ::grade(midterm, final, homework);
}
istream& Core::read_common(istream& in) {
in >> n >> midterm >> final;
return in;
}
istream& Core::read(istream& in) {
read_common(in);
read_hw(in, homework);
return in;
}
Grad::read
函数体与上面的Core::read
基本相同,只是在调用read_hw
函数之前先进行了读取thesis
数据的操作:
istream& Grad::read(istream& in) {
read_common(in);
in >> thesis;
read_hw(in, homework);
return in;
}
注意到,在Grad::read
函数中,无需声明就可以调用基类中的成员函数与成员数据,因为这些成员也是Grad
类的成员,也可以显式地表明这些成员来自于Core
类,只需要使用域运算符Core::read_common
。Grad::grade()
函数有所不同,它需要计算论文成绩thesis
的影响:
double Grad::grade() const {
return min(Core::grade(), thesis);
}
在这里,因为调用的是基类中的grade
函数,用以计算不包括论文分数的成绩,所以用域运算符进行声明必不可少,否则就会调用Grad::grade
函数。
1.3 继承与构造函数
在介绍构造函数之前,必须先理解编译器是如何生成一个派生类的对象的。对于所有的自定义类型,编译器都要先为对象分配内存空间。接下来调用一个合适的构造函数初始化对象。如果这个对象是派生类类型的,那么编译器将在构造对象的过程中另外増加一个步骤以构造对象的基类部分数据。派生类型对象在构造的时候经过以下步骤:
- 为整个对象分配内存空间(包括基类中与派生类中定义的数据)
- 调用基类的构造函数以初始化对象中的基类部分数据(特殊的部分)
- 用构造初始化器对对象的派生类部分数据进行初始化
- 如果有的话,执行派生类构造函数的函数体
惟一的一个新内容是选择调用基类中的哪一个构造函数,显然,用的是构造初始化器来指定想要调用的基类构造函数。在派生类的构造初始化器中使用它的基类名,并在基类名后面附上一个参数列表(可以为空)。这些参数是用来构造对象中基类部分的初始值,同时编译器根据参数的个数与类型来选择调用基类中的哪一个构造函数。如果初始化的时候没有指定调用基类中的哪一个构造函数,编译器将调用基类默认构造函数来构造对象的基类部分。
class Core {
public:
// Core类的默认构造函数
Core() : midterm(0), final(0) {}
// 用一个istream类型变量来构造一个Core对象
Core(std::istream& is) { read(is); }
// 其他内容
};
class Grad {
public:
// 两个构造函数都隐式地调用Core::Core()函数来初始化对象中的基类部分
Grad() : thesis(0) {}
Grad(std::istream& is) { read(is); }
// 其他内容
};
Grad
类的默认构造函数是用来直接生成一个Grad
类型对象的,它先构造基类部分,并且把thesis
初始化为0
,绝大部分工作是隐式进行的,因为默认构造函数是空的,所以编译器隐式地调用Core
类的默认构造函数对midterm
、final
、homework
和name
数据成员进行初始化。同样,Core
类的默认构造函数在初始化过程中也是调用它们的默认构造函数,对没有默认构造函数的midterm
和final
显式地初始化。除了这些工作,Grad
类的构造函数没有其他要做,所以它的函数体是空的。
用一个istream
类型的变量来生成一个Grad
类型对象的过程与Core
类一样,都是通过调用read
成员函数实现的。但是在调用 read
函数之前,要先(隐式地)调用基类的默认构造函数来初始化对象的基类部分。然后用Grad::read
(因为这个构造函数是Grad类的一个成员)来调用read
函数,read
函数会从is
变量中读进一个值赋给thesis
。对于下面两条语句:
Grad g;
Grad g(cin);
由Grad
类构造的对象,在初始化时将先调用Core
类的构造函数初始化基类部分,虽然再调用派生类的构造函数初始化派生类独有的部分。
2 多态和虚拟函数
StudentInfo
类中还有一个非成员函数,它是模型接口的一部分,即sort
函数,用于把学生记录按照字母顺序进行排列,它调用了compare
函数,用以对比两个学生记录:
bool compare(const Core& c1, const Core& c2) {
return c1.name() < c2.name();
}
compare
函数可以对两个Core
类型记录进行比较,也可以对两个Grad
类型记录进行比较,甚至可以对一个Core
类型和一个Grad
类型记录进行比较。这是因为Grad
类是由Core
类派生来的,每个Grad
类型对象都有一个Core
类的部分,所以compare
函数的引用参数可以是Grad
类型中的Core
类部分,也可以是一个平常的Core
类型对象。对于一个Grad
类型记录来说,当调用c1.name()
函数时,实现上调用的是从Core
类中继承而来的name
函数,因为Grad
类并没有另外定义这样一个函数,所以,它将从对象的基类部分获得n
的值。
同样,可以用一个指向Core
类型的指针或者一个Core
类型对象本身作为compare
函数的参数,无论哪种情况,都可以以Grad
类型对象的名义调用compare
函数,如果函数以指针作为参数,可以将指向Grad
的指针传给它,编译器会将Grad*
转换成Core*
,并将指针绑定到Grad
对象中的Core
部分。如果该函数以一个Core
类型对象为参数,那么实际传递过去的只是对象的Core
类部分。在下面我们将看到,向函数传递一个对象本身作为参数,与传递对象的一个引用作为参数有着很大的差别。
2.1 虚拟函数
刚刚比较的name
函数只在Core
类中定义,由Core
类和派生类共享,但对于不共享的函数grade
,Grad
类中重新定义了该函数,如果将它作为compare
函数的比较对象,如下
bool compare_grade(const Core& c1, const Core& c2) {
return c1.grade() < c2.grade();
}
当比较的是Core
类型对象时,显然,将会调用Core::grade
函数,但如果比较的是Grad
类型对象,考虑到thesis
变量的存在,必须要调用Grad::grade
函数,这里对两个函数并未做出区分,所以会得到错误的结果。希望的是,在运行时,compare
函数会根据参数类型调用不同的grade
函数,而参数的类型只有到运行的时候才会被知道。
因此,C++提供了虚拟函数(virtual):
virtual double grade() const; // Core类中声明为virtual
现在,grade
是一个虚拟函数,并且会被继承到派生类中,因此在派生类中无需声明virtual
关键字,compare_grade
函数将由参数的实际类型决定调用哪个grade
函数。virtual
关键字只能在类的定义中被使用,如果函数体单独实现,不需要重复virtual
关键字。
2.2 动态绑定
只有在以引用或者指针为参数调用虚拟函数时,虚拟函数的多态性才有意义,如果以对象的名义调用一个虚拟函数,那在编译的时候就可以确定对象的类型,在运行的时候也不会改变。例如
bool compare_grade(Core c1, Core c2) {
return c1.grade() < c2.grade();
}
当传入Grad
类型对象作为参数时,这个对象将被删减得只剩下Core
类部分,然后将这一个部分的一个副本传给compare_grade
函数。因为参数仍然是Core
类型的对象,所以这种对Grad
类型对象的函数调用是静态绑定的——它们在编译的时候就已经被确定了——调用Core::grade
函数。
动态绑定,是指在运行的时候才决定调用什么函数,而不是在编译的时候就确定下来。如果以一个对象的名义调用一个虚拟函数,这个调用就是静态绑定的,因为这个对象不可能在运行的时候改变类型。相反,如果通过一个指针或者一个引用来调用虚拟函数,那么函数将是动态绑定的——也就是说在运行的时候才被绑定。
Core c;
Grad g;
Core* p;
Core& r = g;
c.grade(); // 对Core::grade()函数进行静态绑定
g.grade(); // 对Grad::grade()函数进行静态绑定
p->grade(); // 根据p所指向对象的类型进行动态绑定
r.grade(); // 根据r所引用对象的类型进行动态绑定
在要求一个指向基类对象的指针或引用的地方,使用一个指向派生类的指针或引用了来代替,这是OOP的一个关键概念:多态性(polymorphism)。在通过一个指针或者一个引用调用虚拟函数的时候,实际上就在进行一个多态的调用。引用(或指针)参数的类型是固定的,但是参数所引用(或所指向)的对象的类型可以是所引用(或所指向)对象的类型,也可以是由该类派生出来的任何一个子类,因此,可以通过一种类型来调用许多函数中的一个。
值得注意的是,对于非虚拟函数,如果程序没有调用,那么是可以只声明而不定义的,但对于虚拟函数,则必须定义,否则可能会导致错误。
2.3 一个小结
除了grade
函数外,还希望可以根据read
函数的实际参数类型决定调用哪一个read
函数,最后完成的类如下:
class Core {
public:
Core() {}
Core(std::istream& is) { read(is); }
std::string name() const;
// 新增加的virtual关键字
virtual std::istream& read(std::istream&);
virtual double grade() const;
protected:
std::istream& read_common(std::istream&);
double midterm, final;
std::vector<double> homework;
private:
std::string n;
};
class Grad : public Core{
public:
Grad()
Grad(std::istream& is) { read(is); }
double grade() const;
std::istream& read(std::istream&);
private:
double thesis;
};
bool compare(const Core&, const Core&);
3 读取不同类型记录
假如需要读取并处理文件中的Core
记录,可以写出这样的代码
int main () {
vector<Core> students;
Core record;
string::size_type maxlen = 0;
// 读取并储存数据
while (record.read(cin)) {
maxlen = max(maxlen, record.name().size());
students.push_back(record);
}
sort(students.begin(), students.end(), compare);
// 输出学生姓名与成绩
for(vector<Core>::size_type i = 0; i != students.size(); ++i) {
cout << setw(maxlen+1) << students[i].name();
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;
}
该程序在运行时,record.read(cin)
调用的read
函数取决于record
的实际类型,显然在编译时就可以确定为Core
类型,所以这个调用是静态绑定的。同样,在students[i].grade()
中调用的grade
函数也是静态绑定的。也就是说,如果需要读取Grad
类型记录,就需要再写一个程序,虽然它们做的事是同样的。为了节省代码,需要写一个可以同时处理两种类型的程序,那么就需要在这些地方将类型依赖性去掉:
- 对向量的定义(向量用来保存读出来的数据)
- 对局部的临时变量的定义(临时变量用于暂存从记录中读出来的数据)
read
函数和grade
函数
排除掉这些,剩下的代码都是与类型无关的,或者如name
函数和compare
函数这样,对两个类型都是一样的。显然,对于把read
和grade
函数定义成虚拟的就可以解决,前面两个问题——临时变量用什么类型以及在容器中保存什么类型的数据——也可以用同样的办法解决。
3.1 容纳未知类型的容器
很明显,在这里,这样的定义依赖于类型的种类:
vector<Core> students; // students中的每个对象必须是Core类型
Core record; // record必须是Core类型对象
要想去掉这些定义对类型的依赖性,尝试效仿虚拟函数的做法,定义指针类型对象而不是对象本身,比如vector<Core*>
类型,然后把record
也定义成指针,这样就实现了在程序中的动态绑定,同时消除了在向量和局部临时变量的定义中存在的类型依赖性。但是,这样的做法将给用户带来太多的麻烦,如下:
int main () {
vector<Core*> students;
Core* record;
while (record->read(cin)) { // 报错
// ...
}
}
这段程序将导致错误,因为record
还没有指向任何一个实际的对象。**解决的办法就是,要求用户亲自管理好从文件中读取的数据所占用的内存,因此,用户不得不检测程序正在读的记录的类型。**假如研究生的记录以G字母打头,本科生的记录以U字母打头。
在重写这个程序之前,还需要一个带有两个指向Core
类型对象的指针的新比较函数,用以对一个包含指针的容器进行排序。注意,因为不能把一个重载函数命名为一个模板参数,编译器将无法决定调用哪个版本,因此,这个函数不能叫compare
。
bool compare_core_ptrs(const Core* cp1, const Core* cp2) {
return compare(*cp1, *cp2);
}
在完成比较函数之后,重写主函数,这时候对数据的操作是通过控制指针来完成的。
int main () {
vector<Core*> students;
Core* record;
char ch;
string::size_type maxlen = 0;
// 读取并储存数据
while (cin >> ch) {
if (ch == 'U')
record = new Core; // 手动分配内存
else
record = new Grad;
record->read(cin); // 虚拟调用read函数
maxlen = max(maxlen, record->name().size()); // 间接引用name函数
students.push_back(record);
}
sort(students.begin(), students.end(), compare_core_ptrs);
// 输出学生姓名与成绩
for(vector<Core*>::size_type i = 0; i != students.size(); ++i) {
cout << setw(maxlen+1) << students[i]->name();
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;
}
delete students[i]; // 释放在读文件时产生的临时变量
}
return 0;
}
在运行中,通过检测输入流中的首字符判断将要读取的对象的类型,从而为该类型分配合适的内存空间,然后利用生成的临时对象record
来决定调用哪个版本的read
函数。在调用read
函数和name
函数时,是通过间接引用record
实现的,因为它是一个指针类型。在输出时,students[i]
生成一个指针,所以也要通过间接引用调用grade
函数,最后释放对象所占用的内存空间。
3.2 虚拟析构函数
上面的程序已经基本能工作了,只是在输出循环中删除对象的时候还存在问题,即在程序中为Grad
和Core
都分配了内存,但工作的指针类型为指向Core
类型的指针,而不是Grad
类型,使得在删除这个指针的时候,删除的是一个指向Core
类型的指针,即调用的是Core
类型的析构函数。
在对一个指针调用delete
函数的时候,首先是调用了指针所指向对象的析构函数,然后对象所占用的内存空间被释放,这个析构函数是编译器自动合成的。因此,当程序删除students[i]
中的指针的时候,它可能指向一个Grad
类型对象,但它被定义成指向一个Core
类型对象,导致删除不完全。在实现中,是通过将析构函数定义为虚拟函数解决的:
class Core {
public:
virtual ~Core() {}
};
现在,程序将会根据students[i]
实际指向的对象的类型来决定调用哪个析构函数。注意到,析构函数的函数体是空的,这是因为删除一个Core
类型对象的唯一工作是删除对象的成员数据,编译器会自动完成这项工作,所以这个析构函数就没什么事可做了。而Grad
类继承了这个虚拟析构函数,所以没必要给派生类重写一个析构函数。
4 一个简单的句柄类
在前面的程序中,实现了对两种不同类型的记录的处理,虽然,为了管理这些指针,程序中加入了太多的复杂性,也引入了一些可能导致错误的缺陷:用户必须亲自为这些记录分配内存以及释放内存,也经常要对一个指针间接引用以得到指针指向的对象。接下来,将介绍一种新的方法——句柄类,使写出的程序保持简洁性,又不会产生那么多的问题。
前面使用到一个指向Core
类型的指针,该指针可以指向Core
对象或Grad
类型对象的指针,但使用这个指针存在可能导致错误的隐患,现在,可以写一个新的类封装指向Core
类型对象的指针,这样,就把隐患在用户面前隐藏起来:
class StudentInfo {
public:
StudentInfo() : cp(0) {}
StudentInfo(std::istream& is) : cp(0) { read(is); }
StudentInfo(const StudentInfo&);
StudentInfo& operator=(const StudentInfo&);
~StudentInfo() { delete cp; }
std::istream& read(std::istream&);
std::string name() const {
if (cp) return cp->name();
else throw std::runtime_error("unintialized Student");
}
double grade() const {
if (cp) return cp->grade();
else throw std::runtime_error("unintialized Student");
}
static bool compare(const StudentInfo& s1, const StudentInfo& s2) {
return s1.name() < s2.name();
}
private:
Core* cp;
};
StudentInfo
类的对象,既可以表示一个Core
类型对象,又可以表示一个Grad
类型对象,而且不用担心为StudentInfo
对应的对象进行内存分配。在带有一个istream
类型参数的构造函数中调用了StudentInfo::read
函数,这个构造函数为适当的类型对象分配内存空间,然后从指定的istream
中读取数据,对对象进行初始化。
这里还需要一个拷贝构造函数、一个赋值运算符函数和一个析构函数来管理这个指针。因为在Core
类中声明了虚拟析构函数,所以以Core
类型对象的指针为成员数据的StudentInfo
类在释放内存时都能正常工作。
因为用户在程序中可能会用到StudentInfo
类的对象,所以在该类中必须提供和Core
类相同的接口。由于grade
函数是一个虚拟函数,所以在StudentInfo
类中通过cp
调用该函数时,实际上还是调用的cp
所指向类型的grade
函数。name
函数在两个类型中是一样的,实际调用的是Core::name
函数。
接口中的另一个函数是compare
操作,在Core
类中,它是一个全局非成员函数,所以在这里把它定义成静态成员函数。静态成员函数与类关联,不与任何一个对象关联,因此它不能访问对象的成员,也不能对任何对象进行操作,同时,它的函数名将带有它所属类的限定词,其地位等同于声明一个StudentInfo::compare
的全局函数,也因此,它不会与Core
类的compare
函数产生重载。此外,当compare
函数调用StudentInfo::name
函数时,如果cp
不为0
,对
StudentInfo::name
的调用实际上就是在调用Core::name
。而如果cp
等于0
,那么这一函数调用将产生一个异常,并把这个异常传播给compare
函数的调用者。因为compare
函数用的是StudentInfo
类的公有接口,所以该函数不需要直接检测cp
是否为0
。像其他的用户代码一样,compare
函数会把问题转给StudentInfo
类去做。
4.1 读取句柄
StudentInfo::read()
函数有三个任务:首先它必须释放该句柄指向对象(如果句柄指针不为0
)占用的空间,其次它必须判断将要读入的对象是什么类型,最后他还要为正确类型的对象分配合适的内存空间,然后从输入流中读取数据对对象进行初始化:
istream& StudentInfo::read(istream& is) {
delete cp; // 如果cp为空,delete删除一个空指针是无害的
char ch;
is >> ch;
if (ch == 'U') cp = new Core(is);
else cp = new Grad(is);
return is;
}
4.2 复制句柄对象
不同于普通类的拷贝操作,这里的拷贝不确定需要复制的对象的实际类型,一个解决的办法是,在Core
类声明一个虚拟函数,这个函数生成一个新的对象,用来储存原来那个对象的一个副本:
class Core {
friend class StudentInfo;
protected:
virtual Core* clone() const { return new Core(*this); }
/* ... */
}
clone
函数是为了实现一个特殊目的,所以并没有放在Core
类的公共接口中。Core
类还把StudentInfo
类声明为友元类,意味着StudentInfo
类的所有成员都称为Core
类的友元,同时它还可以调用clone
函数了。这里的clone
函数只复制了一个Core
类型的对象,所以还需要在Grad
类中重定义该函数用以复制Grad
类型对象:
class Grad {
protected:
Grad* clone() const { return new Grad(*this); }
/* ... */
}
这里的派生类中重定义的clone
函数的返回类型不同于基类,一般来说,在派生类中重定义基类的一个函数,其参数列表和返回类型都是一样的,但是,如果基类中的函数返回一个指向基类类型对象的指针(或引用),那么派生类相应的函数将返回一个指向派生类类型对象的指针(或引用)。此外,在派生类中并不需要另外指定StudentInfo
为友元类,这并不是因为友元关系也会被继承,而是在StudentInfo
类中无需直接调用Grad
类型对象的clone
函数,只需要通过Core::clone
的虚拟调用来间接调用Grad::clone
函数。
随后,就可以写出拷贝构造函数和赋值运算符函数了:
StudentInfo::StudentInfo(const StudentInfo& s) : cp(0) {
if (s.cp) cp = s.cp->clone();
}
StudentInfo& StudentInfo::operator=(const StudentInfo& s) {
if (&s != this) {
delete cp;
if (s.cp) cp = s.cp->clone();
else cp = 0;
}
return *this;
}
在复制和赋值操作中,首先会对s.cp
进行判断,如果它指向一个实际的对象,就调用clone
函数复制该对象,否则cp
仍为0
,cp
在一开始就被初始化为0
了。此外,在调用clone
函数之前还需要做些预备工作:首先,必须谨防发生自我赋值,可以通过对比两个操作数以确保两个对象的地址的不同来实现;其次,在位不同的对象进行赋值的时候,必须在令cp
指向新建对象之前先释放cp
当前所指对象的内存空间。
4.3 使用句柄类的主函数
在写出了句柄类之后,就可以通过它来读取两种不同记录:
int main () {
vector<StudentInfo> students;
StudentInfo record;
string::size_type maxlen = 0;
// 读取并储存数据
while (record.read(cin)) {
maxlen = max(maxlen, record.name().size()); // 间接引用name函数
students.push_back(record);
}
sort(students.begin(), students.end(), compare_core_ptrs);
// 输出学生姓名与成绩
for(vector<StudentInfo>::size_type i = 0; i != students.size(); ++i) {
cout << setw(maxlen+1) << students[i].name();
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;
}
现在,record.read()
会根据输入流的首字母决定将要读取的记录的类型,然后为新建的该类型对象分配合适的内存空间,并用输入流读出的数据初始化这个新对象。构造函数将用读取的数据初始化相应类型的对象,然后在record
中保存一个指向该对象的指针,在将这个对象复制到向量中时,作为副作用,StudentInfo
的拷贝构造函数被调用。
接下来就是排序,随后,在输出循环中的代码保持不变。在每一步循环中,students[i]
都代表一个StudentInfo
类型
对象。该对象包含一个指向Core
类型对象或Grad
类型对象的指针。在调用StudentInfo
类的grade
函数时,该函数使用指针调用底层对象的虚拟函数grade
。在运行时根据句柄实际所指对象的类型来决定调用哪一个版本的grade
函数。
最后当退出主函数的时候,在read
函数里为StudentInfo
类中的成员建立并分配内存空间的对象将会被自动释放。在退出主函数的时候,向量对象将被刪除,向量类的析构函数将删除students
中的每个元素,这又会调用StudentInfo
类的析构函数。在这些析构函数运行之后,在read
函数中建立并分配内存的对象都将被删除。
5 可能忽视的细节
虽然继承与动态绑定的技巧十分强大有效,但是至少在它们刚出现的时候,还是蒙着一层神秘面纱的。我们已经研究过一个使用这类技巧的例子了,现在再来看看这些技巧的一些微妙之处,如果不加注意,它们可能会带来一些麻烦。
5.1 继承与容器
前面提到,如果要把Core
类型对象保存在一个容器中,那么这个容器就只能存放Core
类型对象,不能同时存放其他对象。这一点看起来让人迷惑:因为按常理应该是能在这个容器中存放Core
类型对象以及Core
类的派生类的对象的。但是,如果仔细斟酌一下是如何实现Vec
类的,将会发现,Vec
类不得不为容器中的对象分配内存空间。在分配内存的时候,必须告诉系统要为哪一种类型的对象分配内存。这里没有像虚拟函数那样的机制来自动判断要为哪一种类型的对象分配内存。
如果仍然坚持vector<Core>
可以用来储存Core
类型对象和Grad
类型对象,尽管可以这样做,但是这将会导致出人意料地结果,例如
vector<Core> students;
Grad g(cin);
students.push_back(g);
因为push_back
函数的参数类型是指向向量中的类型对象中元素的引用,所以可以把Core
类型对象作为参数传入,也可以把它的派生类型对象作为参数传入(前面提到过)。而push_back
函数会以为它得到的参数是一个Core
类型的对象,所以它会调用Core
类的构造函数,只把g
对象的Core
类部分复制过来,而g
中Grad
类特有的成员数据将被丢弃。
5.2 调用哪个函数
有一点十分重要,那就是如果一个基类函数与一个派生类函数具有相同的函数名,但是两个函数的参数个数与参数类型都不相同,那么它们就像完全不相干的两个函数一样。例如,添加一个辅助函数用来改变学生的期末考试分数。对于Core
类学生,这个函数只能用来改变期末考试成绩;而对于Grad
类的学生,这个函数带有两个参数,第一个参数还是用来改变期末考试成绩,而第二个参数是用来改变论文分数的:
void Core::regrade(double d) { final = d; }
void Grad::regrade(double d, double d2) { final = d; thesis = d2; }
如果r
是一个Core
类型对象的引用,那么:
r.regrade(100); // 正常运行,调用Core::regrade函数
r.regrade(100, 100); // 编译错误,因为Core::regrade函数只带一个参数
即使r
指向一个Grad
类的对象,第二个函数调用也会导致编译错误,因为r
是指向Core
类型对象的引用。如果r
是一个指向Grad
类型对象的引用,那么接下来发生的事情会更加让人莫名其妙:
r.regrade(100); // 编译错误,因为Grad::regrade函数带有两个参数
r.regrade(100, 100); // 正常运行,调用Grad::regrade函数
Grad
类的regrade
函数带有两个参数。虽然有一个带一个参数的基类Core
类的regrade
函数版本,但是因为在派生类中也有regrade
这个成员函数,所以这个版本的regrade
函数被隐藏了起来。如果想要在一个派生类型对象中以一个基类类型对象的名义调用这个版本的函数,就要对它进行显式地调用:
r.Core::regrade(100); // 正常运行,调用Core::regrade函数
如果要把regrade()
函数声明为虚拟函数,那么必须把基类与派生类中该函数的接口声明成一样的,比如可以像下面这样在Core
版本的regrade()
函数中另外増加一个具有默认值的不被使用的参数:
virtual void Core::regrade(double d, double=0) { final = d; }