C++ 的多态

多态,顾名思义——多种形态,通俗点讲就是“一个接口,多种实现”。(编写本篇时知识架构尚未完善,如有疏漏还请指教。)


1. 为什么需要多态

工程中经常碰到千变万化的需求,如果为每个需求都单独设计一套接口和逻辑,无疑会造成大量代码冗余,同时也降低了可维护性。

就比如早期的手机充电接口,21世纪初的每家功能机厂商都实现了私有的充电接口,出门在外带什么充电器都不如带一个万能充,既能给自己的设备充电,还能充别人的设备。此时这个万能充就用一套接口满足了多种充电需求,无疑高效地解决了我们的充电问题。

现在虽然充电接口大都统一成Type-C接口,但是依旧存在快充协议不兼容的问题。虽然出门在外一套充电器都能充电,但是没有快充速度特别慢。这时支持PD快充协议的设备和充电器自然成了首选。既避免了多带一套充电器的开销,又解决了充电速度慢的问题。

可见,统一接口是极有必要的。程序自然也该如此,这样既能应对多变的需求,又能避免重复代码造成冗余,是以不变应万变。

2. 多态的编程实现

直接上代码(来源:《C++ 多态 | 菜鸟教程》)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream> 
using namespace std;
 
class Shape {
   protected:
      int width, height;
   public:
      Shape( int a=0, int b=0)
      {
         width = a;
         height = b;
      }
      int area()
      {
         cout << "Parent class area :" <<endl;
         return 0;
      }
};
class Rectangle: public Shape{
   public:
      Rectangle( int a=0, int b=0):Shape(a, b) { }
      int area ()
      { 
         cout << "Rectangle class area :" <<endl;
         return (width * height); 
      }
};
class Triangle: public Shape{
   public:
      Triangle( int a=0, int b=0):Shape(a, b) { }
      int area ()
      { 
         cout << "Triangle class area :" <<endl;
         return (width * height / 2); 
      }
};
// 程序的主函数
int main( )
{
   Shape *shape;
   Rectangle rec(10,7);
   Triangle  tri(10,5);
 
   // 存储矩形的地址
   shape = &rec;
   // 调用矩形的求面积函数 area
   shape->area();
 
   // 存储三角形的地址
   shape = &tri;
   // 调用三角形的求面积函数 area
   shape->area();
   
   return 0;
}

当上面的代码被编译和执行时,它会产生下列结果:

1
2
Parent class area :
Parent class area :

我们本来的目的是将父类作为统一的接口,为不同的需求派生不同的子类,通过父类指针指向实例化的子类对象,并调用子类对象的方法输出对应内容。然而实际的输出的却是父类方法的内容。

程序没有报错,为何输出和预想的不同?因为在编译期间,area方法已经被编译器设置为基类中的版本,即静态链接,也叫早期绑定。此时程序以指针的类型确定调用的方法。这和我们的目的背道而驰,自然是不行的。

那么应该如何实现这个目的呢?这时我们需要引入 virtual 关键字。在基类Shapearea方法声明前放置关键字virtual,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Shape {
   protected:
      int width, height;
   public:
      Shape( int a=0, int b=0)
      {
         width = a;
         height = b;
      }
      virtual int area()
      {
         cout << "Parent class area :" <<endl;
         return 0;
      }
};

此时编译执行的结果如下:

1
2
Rectangle class area :
Triangle class area :

可以看到程序达成了目的:执行子类对象的方法,并输出对应内容。现在我们可以将父类作为接口,为各种不同的需求派生子类来实现对应解决方案。

此时在关键字virtual的修饰下,编译器看的是指针的内容,而不是它的类型。因此,由于 trirec 类实例化对象的地址存储在 *shape 中,所以会调用各自的 area() 函数。

正如您所看到的,每个子类都有一个函数area()的独立实现。这就是多态的一般使用方式。有了多态,您可以有多个不同的类,都带有同一个名称但具有不同实现的函数,函数的参数甚至可以是相同的。

虚函数是在基类中使用关键字virtual声明的函数。在派生类中重新定义基类中定义的虚函数时,会告诉编译器不要静态链接到该函数。我们想要的是在程序中任意点可以根据所调用的对象类型来选择调用的函数,这种操作被称为动态链接,或后期绑定。

3. 从虚函数到多态

3.1. 虚函数的“重写”

在上面我们使用virtual关键字修饰了基类Shapearea方法,此时该方法就可以叫作虚函数。而“虚函数”正是实现多态的先决条件之一。

这里有必要提一下函数的重写、重载(覆盖)和重定义(隐藏)为后续内容做铺垫:

  • 重载:函数名相同,参数列表不同并且在同一作用域就构成了重载,返回值可以相同也可以不同。
  • 重写:就是狸猫换太子,在派生类的作用域中,其重写的虚函数必须和基类的虚函数的函数名、参数列表、返回值均相同(协变和析构函数除外)。
  • 重定义:就是继承中的同名隐藏,当派生类中有一个函数和基类的函数名相同,不管参数是否相同,只要该函数不为虚函数,则它不构成重写即为重定义(同名隐藏)。

在基类中实现虚函数作为接口,在派生类中“重写”该函数,然后就可以通过基类的指针或引用去调用重写后的虚函数,实现多态。

在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

3.2. 虚函数重写的例外

但是由于实际需要,虚函数的重写也存在例外:

  • 协变
    子类重写父类虚函数时,与父类虚函数仅有返回值类型不同,且父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。示例如下(引自《【C++】多态》):

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    
    #include "iostream"
    using namespace std;
    
    class Person // 父类
    {
      public:
        virtual Person* pointer(void) // 返回值为Person*
        {
            cout << "Person* pointer(void)" << endl;
            return new Person;
        }
    };
    
    class Student : public Person // 派生类继承父类
    {
      public:
        virtual Student* pointer(void) // 返回值为Student*
        {
            cout << "Student* pointer(void)" << endl;
            return new Student;
        }
    };
    
    int main(void) {
        Person p, *ptr;
        Student s;
    
        ptr = &p;
        ptr->pointer(); // 调用父类方法
    
        ptr = &s;
        ptr->pointer(); // 调用子类方法
        return 0;
    }
    // 运行结果如下:
    // Person* pointer(void)
    // Student* pointer(void)
    

    可以看到,虽然pointer()函数返回值不同,但是仍然实现了多态。

  • 析构函数重写
    由于析构函数的函数名是有要求的,所以从代码角度看,子类和父类析构函数的函数名不同,破坏了多态的要求。但是编译器对所有析构函数的函数名都做了特殊处理,编译后析构函数的名称统一处理成destructor,所以只要基类的析构函数为虚函数,此时派生类的析构函数只要定义就与基类的析构函数构成重写。

    很容易在存在继承关系的代码中看到虚析构的存在,基类的虚析构可以保障基类指针指向的派生类对象在析构时,由内向外层层析构直到基类对象析构完毕,避免只调用基类析构而遗漏派生类造成的错误析构。例如:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    
    #include "iostream"
    using namespace std;
    
    class Parent {
      public:
        Parent(void) { cout << "Parent has been created!" << endl; }
        ~Parent() { cout << "Parent has been deleted!" << endl; }
    };
    
    class Child : public Parent {
      public:
        Child(void) { cout << "Child has been created!" << endl; }
        ~Child() { cout << "Child has been deleted!" << endl; }
    };
    
    int main(void) {
        Parent* p = new Parent;
        cout << "-------------------------" << endl;
        Parent* c = new Child;
        cout << "-------------------------" << endl;
        delete p;
        cout << "-------------------------" << endl;
        delete c;
    
        return 0;
    }
    
    // 运行结果如下:
    // Parent has been created!
    // -------------------------
    // Parent has been created!
    // Child has been created!
    // -------------------------
    // Parent has been deleted!
    // -------------------------
    // Parent has been deleted!
    

    释放子类对象时却只释放了父类对象,造成了内存泄露。应该将父类析构用virtual关键字修饰为虚析构,此时子类析构就算不加virtual也继承了父类虚函数的属性,成为虚析构(工程中建议添加virtual显式声明为虚函数)。运行结果如下,可见其完全析构,没有造成内存泄漏。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    // 运行结果如下:
    // Parent has been created!
    // -------------------------
    // Parent has been created!
    // Child has been created!
    // -------------------------
    // Parent has been deleted!
    // -------------------------
    // Child has been deleted!
    // Parent has been deleted!
    

    但是虚析构是必须的吗?未必,以下分类讨论不需要虚析构的情况:

    • 不需要用基类指针指向派生类的对象时,即不作为接口使用的基类,可以不进行虚析构。因为派生类实例化的对象在析构时由内向外进行析构,即先析构子类,再析构父类。此时析构派生类的对象,可以由内向外充分析构,不会造成遗漏。例程如下:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      
      #include "iostream"
      using namespace std;
      
      class Parent {
        public:
          Parent(void) { cout << "Parent has been created!" << endl; }
          ~Parent() { cout << "Parent has been deleted!" << endl; }
      };
      
      class Child : public Parent {
        public:
          Child(void) { cout << "Child has been created!" << endl; }
          ~Child() { cout << "Child has been deleted!" << endl; }
      };
      
      int main(void) {
          Parent p;
          Child c;
      
          return 0;
      }
      
      // 运行结果如下:
      // Parent has been created!
      // Parent has been created!
      // Child has been created!
      // Child has been deleted!
      // Parent has been deleted!
      // Parent has been deleted!
      

      解析:首先实例化父类对象执行父类构造函数,然后实例化子类对象。由于继承时,构造函数由外向内进行构造(和析构相反),所以依次执行父类构造、子类构造。然后由内向外依次析构子类对象、父类对象。至于最后的父类对象析构,是程序执行时压栈,后进先出的特性决定的(数据结构知识)。

    • 所有派生类在析构函数中均不进行资源释放等收尾操作,可以不进行虚析构。很好理解,析构的目的就是对象结束时进行收尾工作,比如处理数据、释放资源等。不涉及这些操作,则析构正确与否,不影响程序运行。

    • 不会被public继承的类,可以不进行虚析构。因为privateprotected继承,在非友元(friend)函数和类中无法用基类指针指向派生类。

    在以上几种情况下,析构函数不声明为virtual也可以。而且虚函数在运行时才能确定对象类型,需要进行动态链接,相比于静态链接开销大、效率低。但是我们很难保证以上情形被100%遵循,所以实际开发要综合考虑,没有放诸四海而皆准的规则。

3.3. 虚函数的注意事项

  • 编译器不允许将构造函数设置成虚函数,构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。

    此外虚函数对应一个虚函数表,这个虚函数表是存储在对象的内存空间的。如果构造函数是虚的,就需要通过虚函数表来调用,但对象还没有实例化,也就是内存空间还没有,更不要说在虚函数表中调用了,所以构造函数不能是虚函数。

  • 只需要在虚函数的声明处加上virtual关键字,函数定义处可加可不加。

  • 只有类的成员函数才能声明为虚函数,友元函数不是类的成员函数,故不能将友元函数声明为虚函数。

  • 静态成员函数不能成为虚函数。静态函数在编译时就绑定成功,而虚函数在运行时才能确定。并且虚函数有隐藏的this指针,归属于实例化的对象,只能通过对象来调用;而静态成员函数没有this指针,属于类而不属于具体对象,无法通过对象来调用。因此二者不能同时成立。

  • 内联(inline)函数不能成为虚函数。内联是在编译期将函数内容展开到函数调用处,以空间换时间提升运行效率,是静态的;而虚函数是动态调用的,在编译期并不知道调用方,所以不能内联展开,编译期会忽略。

  • 虚函数的“重写”一节,我提到了重定义(同名隐藏),在这里细化一下:

    • 如果派生类函数和基类函数同名,且参数不同,此时不论有无virtual关键字,基类的函数将被隐藏(注意该情况不等于重载)。
    • 如果派生类函数和基类函数同名,且参数相同,但是基类函数没有virtual关键字,则基类函数被隐藏(注意该情况不等于重写,因为对返回值不做要求);如果基类函数有virtual关键字,编译器会认为这是对基类虚函数的重写,如果返回值类型和基类函数不同会报错(协变例外)。
    • 如果派生类重写基类虚函数,则派生类的其他重名函数不论是否为虚函数都会被隐藏。

3.4. 纯虚函数

在虚函数的后面写上“=0”,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类令其必须重写,另外更突出了接口继承的特性。

3.5. 虚函数的拓展内容

  • 此处延伸两个可能用到的关键字,了解即可:

    • final:修饰虚函数,表示该虚函数不能再被继承,通俗来讲就是一旦在基类中加上final关键字,则在派生类中就无法被重写
    • override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。这个和final关键字刚好是相反的,他是在派生类中进行使用的。
  • 虚函数之于继承的意义:

    • 实现继承
      普通函数的继承是一种实现继承,派生类继承基类函数,继承的是函数的实现。
    • 接口继承
      虚函数是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写以实现多态。尤其是抽象类的产生,强制派生类必须重写基类纯虚函数,否则派生类无法实例化出对象,类的功能大打折扣。
  • virtual可以将函数修饰为虚函数,另外还可以用来修饰继承关系实现虚继承。因为我暂时没有用到虚继承的需求,故此处不做延伸,留待后话。

4. 多态的底层原理

此处引用《C++ 一篇搞懂多态的实现原理》进行介绍:

「多态」的关键在于通过基类指针或引用调用一个虚函数时,编译时不能确定调用的到底是基类还是派生类的函数,在运行时才能确定。那么我们用 sizeof 来输出有虚函数的类和没有虚函数的类的大小,会是什么结果呢?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class A 
{
public:
    int i;
    virtual void Print() { } // 虚函数
};

class B
{
public:
    int n;
    void Print() { } 
};

int main() 
{
    cout << sizeof(A) << ","<< sizeof(B);
    return 0;
}

// 64位系统中运行结果如下:
// 16,4
// 
// 这里解释以下这个12的差值:
// 64位操作系统的指针大小是8字节,因为内存对齐,不满8字节也会占用8字节空间;
// 所以类对象大小是两个vptr大小2*8=16,而不是4+8=12,因而得出后文中8字节的差值。

从上面的结果,可以发现有虚函数的类,多出了 8 个字节,在 64 位机子上指针类型大小正好是 8 个字节,这多出 8 个字节的指针有什么作用呢?

4.1. 虚函数表

每一个有「虚函数」的类(或有虚函数的类的派生类)都有一个「虚函数表」,该类的任何对象中都放着虚函数表的指针。「虚函数表」中列出了该类的「虚函数」地址。多出来的 8 个字节就是用来放「虚函数表」的地址。

首先依然是惯例——上代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 基类
class Base 
{
public:
    int i;
    virtual void Print() { } // 虚函数
};

// 派生类
class Derived : public Base
{
public:
    int n;
    virtual void Print() { } // 虚函数
};

上面 Derived 类继承了 Base 类,两个类都有「虚函数」,那么它「虚函数表」的形式可以理解成下图:

虚函数表

多态的函数调用语句被编译成一系列根据基类指针所指向的(或基类引用所引用的)对象中存放的虚函数表的地址,在虚函数表中查找虚函数地址,并调用虚函数的指令。

4.2. 证明虚函数表指针的作用

在上面我们用 sizeof 运算符计算了有虚函数的类的大小,发现多出了 8 字节大小(64位系统),这多出来的 8 个字节就是指向「虚函数表的指针」。「虚函数表」中列出了该类的「虚函数」地址。

下面用代码的例子,来证明「虚函数表指针」的作用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 基类
class A 
{
public: 
    virtual void Func()  // 虚函数
    { 
        cout << "A::Func" << endl; 
    }
};

// 派生类
class B : public A 
{
public: 
    virtual void Func()  // 虚函数
    { 
        cout << "B::Func" << endl;
    }
};

int main() 
{
    A a;
    
    A * pa = new B();
    pa->Func(); // 多态
    
    // 64位程序指针为8字节
    int * p1 = (int *) & a;
    int * p2 = (int *) pa;
    
    * p2 = * p1;
    pa->Func();
    
    return 0;
}

// 运行结果如下:
// B::Func
// A::Func

解析:

  • 第 25-26 行代码中的 pa 指针指向的是 B 类对象,所以 pa->Func() 调用的是 B 类对象的虚函数 Func(),输出内容是 B::Func
  • 第 29-30 行代码的目的是令 p1 指针指向 A 类的头 8 个字节的「虚函数表指针」、p2 指针指向 B 类的头 8 个字节的「虚函数表指针」;
  • 第 32 行代码目的是把 A 类的「虚函数表指针」 赋值给 B 类的「虚函数表指针」,所以相当于把 B 类的「虚函数表指针」 替换 成了 A 类的「虚函数表指针」;
  • 由于第 32 行的作用,把 B 类的「虚函数表指针」 替换 成了 A 类的「虚函数表指针」,所以第 33 行调用的是 A 类的虚函数 Func() ,输出内容是 A::Func

通过上述的代码和讲解,可以有效地证明「虚函数表的指针」的作用。

4.3. 多态实现原理总结

「虚函数表的指针」指向的是「虚函数表」,「虚函数表」里存放的是类里的「虚函数」地址。

在类中声明虚函数时,编译器会在类中生成一个虚函数表(一个数组,数组中的每一个元素都是虚函数的入口地址)。虚函数表是一个存储类成员函数指针的数据结构,是由编译器自动生成与维护的。virtual修饰的成员函数会被编译器放入虚函数表中,若派生类重写了基类虚函数则以派生类虚函数入口地址替换表内的基类虚函数入口地址,否则使用基类虚函数入口地址。

基类虚函数在虚函数表中的索引(下标)是固定的,不随继承层次的增加而改变,派生类新增的虚函数放在虚函数表的最后。

存在虚函数时,每个对象中都有一个指向虚函数表的指针(vptr指针,始终位于对象的开头位置),这个指针指向对象所属类的虚函数表。那么在运行时的调用过程中,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚函数表。通过查表索引到正确的虚函数进行调用,进而实现多态的特性。

可以说,虚表指针使得多态得以实现,虚表指针的正确初始化决定了实现是否正常。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。

5. 归纳收束

基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。

以中文搜索“C++ 多态”关键词,指出构成多态的条件如下:

  • 必须存在继承关系;
  • 继承关系必须有同名虚函数并且它们是重写关系;
  • 存在基类类型的指针或者引用,通过该指针引用或调用虚函数。

默认情况通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数(Virtual Function)。C++中虚函数的唯一用处就是构成多态

C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。

通俗而言,比如游戏中完成一个回合后需要更新数据,如果没有多态,则只能一个个去调用对象的update方法,但是有了多态后,我们可以在循环内通过基类指针去调用派生类对象的update方法,减少了代码量同时优化了代码逻辑。

同时多态基于动态链接可以让旧代码可以调用新代码,提高代码的重用性,起到向后拓展的作用,因而常用于框架的编写。

此外多态还有诸多妙用,在此就不一一罗列了。

但多态并不是万金油,其高灵活性是以效率为代价换来的,相较于静态链接的普通成员函数会慢一些。所以出于效率考虑,没有必要将所有成员函数都声明为虚函数。

关于多态的探讨到此就告一段落,感谢诸多同道撰写的文章为本文提供思路,希望大家共同进步。共勉!

引用

感谢以下诸位的知识分享,智慧因传播而闪耀。