跳转至

多态

基础

关键字

virtual 修饰的类成员函数称为虚函数,可以由子类重写。

虚函数后加 = 0 ,就成了纯虚函数,包含纯虚函数的类被称作抽象类,抽象类不能实例化对象,继承抽象类的派生类只有重写纯虚函数后才能进行实例化。

C++11 中还给出两个与多态相关的关键字:

  • final:修饰虚函数,表示该虚函数不能再被重写
  • override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

多态构成条件

多态构成的条件有以下两点:

  • 必须通过基类的指针或者引用调用虚函数
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

在派生类中重写基类中的虚函数需要保证函数名字(虚析构不需要名字相同)、参数列表完全相同。

我们要明确的是派生类继承的是基类虚函数的声明,重写的是实现。

判断下面程序的输出:

struct A
{
    virtual void fun(int val = 1){cout << "A" << val << endl;}
    void test(){fun();}
};

struct B:public A
{
    virtual void fun(int val = 0){cout << "B" << val << endl;}
};

int main()
{
    B* p = new B;
    p->test();
    return 0;
}

先说答案,输出B1。

这里 B 的指针调用继承自 A 的函数 test,在 test 中的 this 的类型是 A* ,使用基类指针调用派生类对象中重写的虚函数,所以构成多态。

但是我们刚说过派生类继承的是基类虚函数的声明,重写的是实现,所以 val 的值是 1,但是实现是派生类的,所以会输出 B1

多态原理

虚函数表

每个带有虚函数的类都会被加入一个 虚函数表指针 ,他指向一个虚函数表,表中的函数都是当前类下的虚函数地址。该表被存储在静态区中。当实例对象调用虚函数时,就要通过虚函数表去查找虚函数的地址。

我们可以通过观察 VS 下监视窗口看到:

#include<iostream>
using namespace std;
class BsaeA
{
public:
    virtual void fun1() { cout << "f1" << endl; }
    virtual void fun2() { cout << "f2" << endl; }
};

int main()
{
    BaseA a;
    return 0;
}
Image title

当派生类重写这些虚函数时,在派生类中的虚函数表对应的函数地址会被覆盖为派生类中重写的函数地址。

我们知道当基类指针或引用指向派生类时,会进行切片,去维护派生类对象属于基类的部分。所以当我们拿指向派生类的基类指针调用重写的虚函数时,他就会在从该派生类对象所指向的虚函数表中找该函数地址,所以最后调用的函数就是派生类重写的函数。

当然没有在派生类重写的虚函数,在虚函数表中的函数地址是与基类中的相同的。

#include<iostream>
using namespace std;
class BaseA
{
public:
    virtual void fun1() { cout << "f1" << endl; }
    virtual void fun2() { cout << "f2" << endl; }
};

class B :public BaseA
{
public:
    virtual void fun1() { cout << "B::f1" << endl; }
};

int main()
{
    BaseA a; 
    B b;
    return 0;
}
Image title

我们可以看到被重写的函数 fun1 地址变成了 0x00be1488 ,没有被重写的 fun2 地址与基类相同。

要注意的是,当有多个基类时,每个基类的虚函数表指针是分开的。这也是可以理解的,因为不同的基类指针指向派生类时,所切片的内容肯定是对应基类的内容,那么想要实现不同类的多态,就必须要将不同类的虚函数表指针分开。

#include<iostream>
using namespace std;
class BaseA
{
public:
    virtual void fun1() { cout << "f1" << endl; }
    virtual void fun2() { cout << "f2" << endl; }
};
class BaseB
{
public:
    virtual void fun3() { cout << "f1" << endl; }
    virtual void fun4() { cout << "f2" << endl; }
};
class son:public BaseA ,public BaseB
{
public:
    virtual void fun1() { cout << "son::f1" << endl; }
    virtual void fun3() { cout << "son::f3" << endl; }
};

int main()
{
    son a;
    return 0;
}
Image title

他们的逻辑关系就如下图:

Image title

易错

重载、重写、重定义(隐藏)

重载

  • 两个函数在同一作用域下。
  • 函数名相同,参数不同。

重写

  • 两个函数分别在基类和派生类的作用域。
  • 函数名、参数、返回值(协变除外)都相同。
  • 两个函数都是虚函数

隐藏

  • 两个函数分别在基类和派生类的作用域。
  • 函数名相同。

分别处于基类和派生类中的同名函数不构成重写就是隐藏。

内联函数可以是虚函数吗

是可以的,但是当采用多态调用时,内联函数不会在调用位置展开,当正常调用时,内联函数才会展开。

静态成员函数可以是虚函数吗

不可以。静态成员函数在调用时是不会传递 this 指针的,但对于 virtual 虚函数,它的调用恰恰使用 this 指针。在有虚函数的类实例中,this 指针调用 vptr 指针,指向的是 vtable(虚函数列表) ,通过虚函数列表找到需要调用的虚函数的地址。总体来说虚函数的调用关系是:this 指针-> vptr -> vtable -> 虚函数。

所以说,static 静态函数没有this指针,也就无法找到虚函数了。所以静态成员函数不能是虚函数。他们的关键区别就是 this 指针。

构造函数可以是虚函数吗

不可以。虚函数表指针是在构造函数阶段初始化的,虚函数调用又需要虚函数表。所以如果构造函数是虚函数,想调用构造函数就需要虚函数表指针,但虚函数表指针还未初始化,就会有问题。

析构函数可是虚函数吗

可以,而且最好是虚函数。因为当父类指针指向一块 new 来的子类对象时,只有析构函数构成多态调用,才能调用子类对象的析构函数,正确释放子类对象的内存空间。

对象访问普通函数快还是虚函数快

首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

虚函数表是在什么阶段生成的?存在哪的

虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。