第13章 继承与动态绑定——继承与虚函数

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关键字表示它们是保护成员,可以被派生类访问,同时又保持这些成员不能被类的其他使用者访问。因为nCore类的私有成员,而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_commonGrad::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类的默认构造函数对midtermfinalhomeworkname数据成员进行初始化。同样,Core类的默认构造函数在初始化过程中也是调用它们的默认构造函数,对没有默认构造函数的midtermfinal显式地初始化。除了这些工作,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类和派生类共享,但对于不共享的函数gradeGrad类中重新定义了该函数,如果将它作为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函数这样,对两个类型都是一样的。显然,对于把readgrade函数定义成虚拟的就可以解决,前面两个问题——临时变量用什么类型以及在容器中保存什么类型的数据——也可以用同样的办法解决。

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 虚拟析构函数

上面的程序已经基本能工作了,只是在输出循环中删除对象的时候还存在问题,即在程序中为GradCore都分配了内存,但工作的指针类型为指向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仍为0cp在一开始就被初始化为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类部分复制过来,而gGrad类特有的成员数据将被丢弃。

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; }

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值