虚函数
C++中可以通过基类指针或引用操纵子类。也可以对一个子类取地址,并将其当做父类对象使用。但是当使用父类指针操纵子类对象时,函数调用会发生错误: |
#include <iostream>
using namespace std;
class base{//基类
public:
void show(void){
cout<<"base调用"<<std::endl;}
};
class subClass:public base{//公有继承
public:
void show(void){
cout<<"subBase调用"<<std::endl;}
};
void displayClass(base& a){//通过基类引用调用子类函数
a.show();
}
int main() {
base base1;
subClass subClass1;
cout<<"调用base类"<<endl;
displayClass(base1);
cout<<"调用subClass类"<<endl;
displayClass(subClass1);
return 0;
}
而在`displayClass`函数的两次调用中,调用的都是基类中的`show`函数,这与我们的意图明显不符。
1. 早捆绑和晚捆绑
出现这种问题的原因很明显:displayClass函数没有判断出参数`a`的实际类型,导致在调用`show`函数时,全部是按照`base`中的`show`函数进行调用的。
将函数调用与函数进行联系称为`捆绑`。上面的例子中,参数a的show函数在编译期就已经和`base`中的`show`函数捆绑在了一起。导致无论a的实际类型是什么,调用的永远是`base`中的`show`函数。这种函数捆绑方式被称为`早捆绑`。
而 将函数调用与函数体捆绑推迟到运行时被称为`晚捆绑`,使用`晚捆绑`可以根据参数真实类型调用到正确的函数。
所谓`晚捆绑`,其目的实际上是为了实现`运行时类型识别`,确保根据左值的实际类型调用到正确的函数。
2. 声明虚函数
C++使用`虚函数`实现`晚捆绑`,要使用`虚函数`,只需要在`成员函数中使用virtual关键字`:
#include <iostream>
using namespace std;
class base{//基类
public:
virtual void show(void){
cout<<"base调用"<<std::endl;}
};
class subClass:public base{//公有继承
public:
void show(void){
cout<<"subBase调用"<<std::endl;}
};
void displayClass(base& a){//通过基类引用调用子类函数
a.show();
}
int main() {
base base1;
subClass subClass1;
cout<<"调用base类"<<endl;
displayClass(base1);
cout<<"调用subClass类"<<endl;
displayClass(subClass1);
return 0;
}
使用`virtual`修饰的成员函数的`函数捆绑`会被推迟的运行时进行。确保调用到正确的函数。
当基类中的某个函数是虚函数时,其在派生类中被重写的函数依然是虚函数,无论是否添加了virtual关键字。
3. 虚函数的实现机理
当类中存在虚函数时,编译器将在编译时在这个类的`构造函数`的起始处添加一些代码,用于实现`晚捆绑`的机制。
当一个类对象创建时,这段代码将会为该对象创建一个表(VTABLE
),包含这个类中的所有虚函数地址。并且将会自动为类添加一个不可见的成员指针:vpointer(称为VPTR),指向这个对象的`VTABLE`。
一旦对象的VPTR指向正确的相应的VTABLE,对象的类型就会被建立,当虚函数调用时用于帮助完成`晚捆绑`。
实际上,VPTR位于对象的起始处,所以,this指针的内容对应于VPTR。
当通过基类指针调用派生类对象时,程序将会实现通过对象的`VPTR`指向正确的`VTABLE`,然后在`VTABLE`中进行寻址,找到正确的函数。
4. 继承和VTABLE
当继承一个含有虚函数的类时,派生类的虚有函数和基类中的虚有函数在各自的VTABLE拥有相同的位置。而派生类中新增加的虚有函数将会添加到上述虚有函数位置之后。
当基类中的虚有函数没有被重写时,VTABLE中的地址将会指向基类相应函数的地址。若基类中的虚有函数被重写,则指向重写后的函数。
5.虚函数的重写
与普通的函数重写不同,虚函数在进行重写时不允许改变函数的返回类型。
但是和普通的函数重写相同的是:虚函数在进行重写后,基类的其他重载版本全部被隐藏。
在对虚函数进行重写时,可以使用`override`关键字表名正在重写一个基类函数。若重写时书写发生错误,将会发生编译器错误:
void f() override{};
6. 虚函数的返回值
虽然虚函数重写时的返回类型不允许发生变化,但是却可以改变返回值的类型。
抽象基类和纯虚函数
有时希望基类的使用仅仅是为了提供接口,而没有实际作用。这时候可能只想在基类中声明函数而不进行实现,而且不希望生成基类对象。要做到这一点,可以使用`纯虚函数`。
纯虚函数用于抑制类对象的生成,当类中存在任一个纯虚函数时,这个类就称为了一个`抽象类`。
纯虚函数的声明
要声明纯虚函数,只需要使用`=0`修饰即可:
class base{
public:
void f()=0;
};
抽象函数只声明,不实现。
纯虚函数函数体
纯虚函数虽然可以只声明,不实现,但是有时又需要为纯虚函数提供函数体,为其子类提供通用函数。
纯虚函数的函数体与其声明必须分离:
class base{
public:
virtual void f()=0;
};
inline void base::f() {
cout<<"base纯虚函数体";
}
class derived:public base{
public:
void f()override {
base::f();
}
};
要调用纯虚函数体,则必须使用`::`限定符进行限定。
构造函数和析构函数中的晚捆绑
类的构造函数不允许声明为一个虚有函数,而析构函数可以。但是相同的是:在构造函数或析构函数体中,晚捆绑机制不起任何作用。主要原因是:
构造函数执行时,类型信息还未完全建立,虚机制不可用。
如果析构函数中晚捆绑机制可用,则基类对象的析构可能会依赖于子类对象,但是子类对象先于基类析构,
也就是类型信息虽然存在,但是不可信。
当类对象生成时,构造函数将沿继承树向下进行构造,即先调用基类构造函数,再调用子类构造函数。确保对象初始化成功。
而当类对象进行析构时,则沿继承树的反方向进行,先调用子类的析构函数,再调用基类的析构函数。
虚析构函数
当通过父类指针操纵子类对象时,为了确保析构函数正确调用,需要将父类的析构函数声明为虚有,确保调用到正确的析构函数。
虚析构函数的行为与其他虚函数的行为完全一致。
当类中存在虚函数时,应当总是将析构函数声明为虚函数,即使其什么也不做。
纯虚析构函数
纯虚析构函数的作用和其他纯虚函数的用途相同,用于抑制函数对象的生成。
派生类可以不对纯虚析构函数进行重写,这一点和其他纯虚函数不同。
例:基于对象的继承
当不使用模板建立一个容器类时,要想使容器类容纳多种类型,意味着容器容纳的指针应为void,则意味着容器对所容纳的对象没有所有权。为了正确析构函数,必须将析构函数声明为虚有。
当delete一个void*时,不会调用析构函数。也就是说容器对所容纳的对象生命周期没有控制权,即:容器对所容纳的对象没有所有权。