基本概念

定义一个函数为虚函数,是为了允许用基类的指针来调用子类的这个函数。定义一个函数为纯虚函数,才代表函数没有被实现。纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。

在C++中,有两种数据成员(class data members):static 和nonstatic,以及三种类成员函数(class member functions):static、nonstatic和virtual。

C语言纯POD的结构体(struct)

我们用 C 语言实现一个类似面向对象的类,想达到面向对象中数据和操作封装到一起的效果,只能给 struct 里面添加函数指针,然后给函数指针赋值。

  1. // 写法一
  2. #include <stdio.h>
  3. typedef struct Actress {
  4. int height;
  5. int weight;
  6. int age;
  7. void (*desc)(struct Actress*);
  8. } Actress;
  9. void profile(Actress* obj) {
  10. printf("height:%d weight:%d age:%d\n", obj->height, obj->weight, obj->age);
  11. }
  12. int main() {
  13. Actress a;
  14. a.height = 168;
  15. a.weight = 50;
  16. a.age = 20;
  17. a.desc = profile;
  18. a.desc(&a);
  19. return 0;
  20. }

函数指针是有空间成本的,这样写的话每个实例化的对象中都会有一个指针大小(比如 8 字节)的空间占用,如果实例化 N 个对象,每个对象有 M 个成员函数,那么就要占用 NM8 的内存。

  1. // 写法二
  2. #include <stdio.h>
  3. typedef struct Actress {
  4. int height;
  5. int weight;
  6. int age;
  7. } Actress;
  8. void desc(Actress* obj) {
  9. printf("height:%d weight:%d age:%d\n", obj->height, obj->weight, obj->age);
  10. }
  11. int main() {
  12. Actress a;
  13. a.height = 168;
  14. a.weight = 50;
  15. a.age = 20;
  16. desc(&a);
  17. return 0;
  18. }

根据对象的内存分布 C++ 编译器实际会帮你生成一个类似上例中 C 语言写法二的形式(这也算是C++ 零成本抽象指导方针的一个体现)。当然实际并不完全一致。

C++ 中类和操作的封装只是对于程序员而言的。而编译器编译之后其实还是面向过程的代码。编译器帮你给成员函数增加一个额外的类指针参数,运行期间传入对象实际的指针。类的数据(成员变量)和操作(成员函数)其实还是分离的。

每个函数都有地址(指针),不管是全局函数还是成员函数在编译之后几乎类似。 在类不含有虚函数的情况下,编译器在编译期间就会把函数的地址确定下来,运行期间直接去调用这个地址的函数即可。这种函数调用方式也就是所谓的『静态绑定』(static binding)。

函数重载和多态

不同的类型有相同的接口,就叫做多态。多态分为四种:重载多态、强制多态、包含多态和参数多态。重载多态分为两种:函数重载和运算符重载。函数重载只是多态这个概念中非常小的一部分。

函数重载属于一种多态,叫做特设多态(Ad hoc polymorphism)。特设多态的意思是,一个函数有,有限数量的多种不同的实现,依赖参数的类型来选择调用特定版本的函数实现。这种选择在编译期就可以判断,所以称为静态多态。

虚函数

在 C++ 中父类声明的对象实际引用的是子类的对象,那么调用的函数就是子类的函数。一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。

  1. class A
  2. {
  3. public:
  4. virtual void foo()
  5. {
  6. cout<<"A::foo() is called"<<endl;
  7. }
  8. };
  9. class B:public A
  10. {
  11. public:
  12. void foo()
  13. {
  14. cout<<"B::foo() is called"<<endl;
  15. }
  16. };
  17. int main(void)
  18. {
  19. A *a = new B();
  20. a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
  21. return 0;
  22. }

包含多态就是,同样的操作,我们可以作用于 A 和 A 的所有子类型(比如B)上。在包含多态中,由于值是不定类型的,可能是任何的子类型,实现也可能是任意子类型中配套的任意实现,我们编译期拿不到足够的信息,所以需要运行时通过动态分派查找具体的类型对应的实现,所以包含多态是动态多态。

在好多语言中,为了凸现这个参数要动态分派,我们会把它放到点号前面,比如 foo.fuck(‘zhihu’) 中的 foo。

虚函数的实现

多态的特性体现在函数上,当子类的对象赋值给父类的指针时,由于继承关系,在内存层面上看子类对象的数据是直接插到父类对象的后面的,所以此时的指针只可以访问到父类的数据。但是由于函数的存储机制和对象的数据是分开的就需要针对子类的对象赋值给父类的指针时这种情况做处理。

C++具体多态的实现一般是编译器厂商自由发挥的。但无独有偶,使用虚表指针来实现多态几乎是最常见做法(基本上已经是最好的多态实现方法)。

image.png

vptr 直接指向的虚函数的位置,对象的地址即为虚函数指针地址,我们可以取得虚函数指针的地址。

  1. #include <stdio.h>
  2. class Actress {
  3. public:
  4. Actress(int h, int w, int a):height(h),weight(w),age(a){};
  5. virtual void desc() {
  6. printf("height:%d weight:%d age:%d\n", height, weight, age);
  7. }
  8. virtual void name() {
  9. printf("I'm a actress");
  10. }
  11. int height; // 身高
  12. int weight; // 体重
  13. int age; // 年龄(注意,这不是数据库,不必一定存储生日)
  14. };
  15. class Sensei: public Actress {
  16. public:
  17. Sensei(int h, int w, int a, const char* c):Actress(h, w, a){
  18. snprintf(cup, sizeof(cup), "%s", c);
  19. };
  20. virtual void desc() {
  21. printf("height:%d weight:%d age:%d cup:%s\n", height, weight, age, cup);
  22. }
  23. virtual void name() {
  24. printf("I'm a sensei");
  25. }
  26. char cup[4];
  27. };
  28. int main() {
  29. Sensei s(168, 50, 20, "36D");
  30. s.desc();
  31. Actress* a = &s;
  32. a->desc();
  33. Actress& a2 = s;
  34. a2.desc();
  35. return 0;
  36. }
  37. *** Dumping AST Record Layout
  38. 0 | class Actress
  39. 0 | (Actress vtable pointer)
  40. 8 | int height
  41. 12 | int weight
  42. 16 | int age
  43. | [sizeof=24, dsize=20, align=8,
  44. | nvsize=20, nvalign=8]
  45. *** Dumping AST Record Layout
  46. 0 | class Sensei
  47. 0 | class Actress (primary base)
  48. 0 | (Actress vtable pointer)
  49. 8 | int height
  50. 12 | int weight
  51. 16 | int age
  52. 20 | char [4] cup
  53. | [sizeof=24, dsize=24, align=8,
  54. | nvsize=24, nvalign=8]

可以看出子类对象的特有属性 cup 是直接插到父类对象的后面的,所以子类对象转换为父类指针时,父类数据的访问是没有问题的。如果父类的函数是普通成员函数,那么函数调用时编译器就会自动替换成地址调用。

image.png

在继承关系中,子类的虚函数表具有包容心,即父类的虚函数表肯定在子类中有有一份完整拷贝。Sensei 是子类。当把 Sensei 类的对象转换为 Actress 类的类型时,访问虚函数表访问的是子类的虚表。

纯虚函数的实现

  1. class A {
  2. public:
  3. virtual void name() {
  4. printf("I'm a actress");
  5. }
  6. };
  7. class B :public A {
  8. };
  9. int main() {
  10. B b;
  11. b.name();
  12. return 0;
  13. }

由于虚表的指针是在对象的首地址的,所以父类被继承时,父类的虚函数也会被继承。纯虚函数跟其他函数的不同之处是,其它虚函数都是把函数地址放在虚表中,调用的时候根据地址调用函数,而纯虚函数因为没有实现,虚表中第一项放的地址是_purecall这个函数,用于在非法调用的时候弹出出错信息。

所以子类中没有实现时,虚表中空有声明,就会报错。

多态的作用

在实际的项目开发中,多态更多的用处就是方便传参,铁男在设计的时候,大招方法根本不知道你要传进来什么英雄,所以权宜之下,就设置参数为所有英雄的父类。这样放大招会将子类英雄进行类型转换,然后有父类调用子类的方法。