C++动态多态探究

151 阅读13分钟

什么是多态?

多态性指相同对象收到不同消息或不同对象收到相同消息时产生不同的行为。C++支持两种多态性:编译时多态性,运行时多态性。

  1. 编译时多态性(静态多态):通过函数重载和模版(泛型编程)来实现。

  2. 运行时多态性(动态多态):通过子类重写父类成员函数来实现。

本文主要探究的是动态多态。

动态多态的应用

这里举例一个场景:

  1. 我们定义一个Animal基类,它有一个run函数。
  2. Animal分别派生出两个子类PersonDog,它们都分别重写了run函数。
  3. 创建PersonDog对象,用Animal指针指向他们,然后分别调用run函数。

iOS实现

class Animal {

  var age: Int64 = 10

   

  func run() {

    print("Animal run")

  }

}



class Person: Animal {

  override func run() {

    print("Person run")

  }

}



class Dog: Animal {

  override func run() {

    print("Dog run")

  }

}



func AnimalRun(_ animal: Animal) {

  animal.run()

}



func PersonRun(_ person: Person) {

  person.run()

}



func DogRun(_ dog: Dog) {

  dog.run()

}



let person: Person = Person()

PersonRun(person) // Person run

let dog: Dog = Dog()

DogRun(dog) // Dog run



AnimalRun(person) // Person run

AnimalRun(dog) // Dog run

通过打印内容可以知道,AnimalRun根据入参对象的真实类型,调用对应子类重写的run函数,具备了多态的能力。

C++实现

class Animal {

public:

  int64_t age = 10;

  

  void run() {

    printf("Animal run\n");

  }

};



class Person: public Animal {

public:

  void run() {

    printf("Person run\n");

  }

};



class Dog: public Animal {

public:

  void run() {

    printf("Dog run\n");

  }

};



void AnimalRun(Animal *animal) {

  animal->run();

}



void PersonRun(Person *person) {

  person->run();

}



void DogRun(Dog *dog) {

  dog->run();

}



int main(int argc, char * argv[]) {



  Person *person = new Person();

  PersonRun(person); // Person run

  Dog *dog = new Dog();

  DogRun(dog); // Dog run



  AnimalRun(person); // Animal run

  AnimalRun(dog); // Animal run



  return 0;

}

这里把Swift的代码翻译成了C++代码,通过打印内容可以发现AnimalRun函数输出的居然是“Animal run”,没有根据入参对象真实类型调用对应的run函数,所以并不具备多态的能力。why?

汇编分析

不妨从汇编的角度看看调用过程来分析下为什么不具备多态。首先看main函数的汇编代码,下面图中几个红色框的汇编代码代表通过bl指令跳转到对应的函数地址上去执行,汇编对应的C++代码分别是PersonRun(person);DogRun(dog);AnimalRun(person);AnimalRun(dog);,每个函数都有自己的函数地址,第三和第四个红色框调用相同的函数理所当然的它们跳转的函数地址也是一样的。

进入main函数通过bl跳转到的PersonRun函数中,下图是PersonRun函数实现对应的汇编代码,可以看到红色框中通过bl指令跳转到Person::run函数中,所以打印出来"Person run"。

进入main函数通过bl跳转到的DogRun函数中,下图是DogRun函数实现对应的汇编代码,可以看到红色框中通过bl指令跳转到Dog::run函数中,所以打印出来"Dog run"。

进入main函数通过bl跳转到的AnimalRun函数中,下图是AnimalRun函数实现对应的汇编代码,可以看到红色框中通过bl指令跳转到Aniaml::run函数中,所以打印出来"Animal run"。

通过汇编可以发现,其实在AnimalRun函数中是写死调用的是Aniaml::run函数。在默认情况下,只会根据指针类型调用对应的函数,并不存在多态。那怎样才能在C++中实现多态呢?

虚函数

在C++中如果想要一个函数具备多态的能力,需要在这个函数前面加上virtual关键字。 而加上virtual关键字的函数叫做虚函数。

class Animal {

public:

  int64_t age = 10;

  

  virtual void run() {

    printf("Animal run\n");

  }

};



class Person: public Animal {

public:

  void run() {

    printf("Person run\n");

  }

};



class Dog: public Animal {

public:

  void run() {

    printf("Dog run\n");

  }

};



void AnimalRun(Animal *animal) {

  animal->run();

}



void PersonRun(Person *person) {

  person->run();

}



void DogRun(Dog *dog) {

  dog->run();

}



int main(int argc, char * argv[]) {



  Person *person = new Person();

  PersonRun(person); // Person run

  Dog *dog = new Dog();

  DogRun(dog); // Dog run



  AnimalRun(person); // Person run

  AnimalRun(dog); // Dog run

  

  return 0;

}

Animal类中的run函数前加上virtual关键字后,执行AnimalRun(person);AnimalRun(dog);函数后分别输出了“Person run”和“Dog run”,通过打印可以发现此时Animal::run函数已经具备了多态的能力。为什么加上virtual关键字后就能实现多态的能力呢?

动态多态实现原理

内存分析

首先我们对比一个类在没有虚函数和有虚函数的时候,这个类在内存中占用的大小是否发生了变化。

class Animal {

public:

  int64_t age = 10;

  

  void run() {

    printf("Animal run\n");

  }

  

  // 这里再额外添加一个eat函数

  void eat() {

    printf("Animal eat\n");

  }

};



class Person: public Animal {

public:

  void run() {

    printf("Person run\n");

  }

  

  void eat() {

    printf("Person eat\n");

  }

};



int main(int argc, char * argv[]) {



  Person *person = new Person();

  printf("%lu\n", sizeof(Animal)); // 8

  printf("%lu\n", sizeof(Person)); // 8

  printf("%p\n", &(*person)); // 0x283e34000

  printf("%p\n", &(person->age)); // 0x283e34000



  return 0;

}

Animal类中没有声明虚函数时,一个Person对象内存占用大小为8个字节,并且age的内存地址跟Person的内存地址相同,所以对象占用的8个字节就是age属性占用的内存。

class Animal {

public:

  int64_t age = 10;

  

  virtual void run() {

    printf("Animal run\n");

  }



  virtual void eat() {

    printf("Animal eat\n");

  }

};



class Person: public Animal {

public:

  void run() {

    printf("Person run\n");

  }

  

  void eat() {

    printf("Person eat\n");

  }

};



int main(int argc, char * argv[]) {



  Person *person = new Person();

  printf("%lu\n", sizeof(Animal)); // 16

  printf("%lu\n", sizeof(Person)); // 16

  printf("%p\n", &(*person)); // 0x281ab0000

  printf("%p\n", &(person->age)); // 0x281ab0008

  

  return 0;

}

Animal类中有声明虚函数时一个Person对象内存占用大小变成了16个字节,多了8个字节,而age属性的内存地址相对于Person对象的地址偏移了8个字节,说明在age前多了一个占8个字节的东西。可以猜想一下在64位下多出了8个字节的内存占用到底是个什么东西呢?

虚表

多态的实现原理是虚表(虚函数表),这个虚表里面存储着最终需要调用的虚函数地址。 当一个类中有声明虚函数时,这个类就会额外分配一个指针的内存空间,而这个指针存放的正是这个类的虚表地址。

无法复制加载中的内容

汇编分析

我们运行下面代码,从汇编角度去分析和证明C++如何通过虚表方式实现多态的能力。

class Animal {

public:

  int64_t age = 10;

   

  virtual void run() {

    printf("Animal run\n");

  }

  

  virtual void eat() {

    printf("Animal eat\n");

  }

};



class Person: public Animal {

public:

  void run() {

    printf("Person run\n");

  }

  

  void eat() {

    printf("Person eat\n");

  }

};



void AnimalRun(Animal *animal) {

  animal->run();

}



void AnimalEat(Animal *animal) {

  animal->eat();

}



int main(int argc, char * argv[]) {

   

  Person *person = new Person();

  AnimalRun(person); // Person run

  AnimalEat(person); // Person eat

   

  return 0;

}

首先来看Person *person = new Person();对应的汇编代码,这里x0寄存器存放person对象的内存地址,并且里面的内容尚未初始化,同时x0作为传参寄存器准备通过bl函数跳转指令传递到下一个函数中使用,而跳转的函数正是Person的构造函数。

// 这里[sp, #0x8]是读取这个地址里面的内容并存放在x0中

// sp, #0x8为*person指针的地址值,其实就是读取指针的内容,拿到Person对象的地址

<+48>: str  x0, [sp, #0x8]

// 这里是跳转到Person的默认构造函数中

<+52>: bl   0x10047fde0        ; Person::Person at main.cpp:16

跳转后这里经过一顿函数现场保护操作后,实际上没做什么事情,x0仍然存放person对象的内存地址,然后作为参数传到下一个函数中,这里通过bl跳转到下个函数。

进入到Person的构造函数后,这里汇编代码主要做了三件事:

  1. 查找Person的虚函数表地址。

  2. 调用父类的构造函数。

  3. 将Person的虚函数表地址写入到对象中虚表指针中(替换掉父类的虚表)。

<+0>: sub  sp, sp, #0x30       ; =0x30 

<+4>: stp  x29, x30, [sp, #0x20]

<+8>: add  x29, sp, #0x20      ; =0x20 

// 将当前pc寄存器的值后三位清0,然后再加一页的偏移(一页4kb 0x1000),最后然后存放在x8中

<+12>: adrp  x8, 1

// 这里读取[x8, #0x8]地址中的内容,并存到x8中

<+16>: ldr  x8, [x8, #0x8]

// 将x8的值加上0x10偏移后,重新存到x8中

// 通过register read x8可以发现是vtable for Person

<+20>: add  x8, x8, #0x10       ; =0x10 

// 将x0(person地址值)存到[x29, #-0x8]地址中

<+24>: stur  x0, [x29, #-0x8]

// 这里将[x29, #-0x8]内容存到x9中,既x0 x9内容相同

<+28>: ldur  x9, [x29, #-0x8]

// 将x9的值赋值给x0

<+32>: mov  x0, x9

// x8内容保存到[sp, #0x10],保存vtable for Person地址值

<+36>: str  x8, [sp, #0x10]

// x9内容保存到[sp, #0x8] ,person地址值

<+40>: str  x9, [sp, #0x8]

// 跳转到Animal的默认构造函数中

<+44>: bl   0x1048cbe60        ; Animal::Animal at main.cpp:3

// 将vtable for Person地址值存到x8

<+48>: ldr  x8, [sp, #0x10]

// 将person地址值存到x9

<+52>: ldr  x9, [sp, #0x8]

// 将vtable for Person地址值写入到person地址的前8个字节中

// 这里其实是覆盖了vtable for Animal

<+56>: str  x8, [x9]

<+60>: mov  x0, x9

<+64>: ldp  x29, x30, [sp, #0x20]

<+68>: add  sp, sp, #0x30       ; =0x30 

<+72>: ret  

进入到Animal的默认构造函数中,这里主要做了两件事:

  1. 查找Animal的虚函数表地址,并写入到对象的虚表指针中。

  2. 给属性age赋值。

<+0>: sub  sp, sp, #0x10       ; =0x10 

// 将当前pc寄存器的值后三位清0,然后再加一页的偏移(一页4kb 0x1000),最后然后存放在x8中

<+4>: adrp  x8, 1

// 这里读取x8中地址的内容,并存到x8中

<+8>: ldr  x8, [x8]

// 将x8的值加上0x10偏移后,重新存到x8中

// 通过register read x8可以发现是vtable for Animal

<+12>: add  x8, x8, #0x10       ; =0x10 

// 将x0(person地址值)存到[sp, #0x8]地址中

<+16>: str  x0, [sp, #0x8]

// 这里将[sp, #0x8]内容存到x9中,既x0 x9内容相同

<+20>: ldr  x9, [sp, #0x8]

// 这里将x8的内容存到x9所保存的地址上

// x8保存着Animal虚表的地址,而x9保存着person的地址

// 这里是将Animal虚表的地址写入到person的前8个字节中

// 所以person的前8个字节是一个指针,指向的是Animal虚表

<+24>: str  x8, [x9]

// 这里将0xa存到x8中

<+28>: mov  x8, #0xa

// 将x8的内容保存到[x9, #0x8]地址上

// 这里[x9, #0x8]就是person地址偏移8个字节,就是属性age的地址,将0xa存到age中

<+32>: str  x8, [x9, #0x8]

<+36>: mov  x0, x9

<+40>: add  sp, sp, #0x10       ; =0x10 

<+44>: ret   

Person对象初始化结束后回到main函数中,我们接着来看AnimalRun(person);的调用过程,这通过bl跳转到AnimalRun函数中。

AnimalRun函数中,我们来看看animal->run();的调用过程。这里主要是做的事情是,通过读取x0得到person对象的地址值,然后读取person对象的地址值前8个字节(读取虚表指针指向的地址)得到虚表地址值找到虚表,最后读取虚表地址值的前8个字节得到Person::run函数的地址值,并通过blr指令跳转到Person::run函数打印了“Person run”。

<+0>: sub  sp, sp, #0x20       ; =0x20 

<+4>: stp  x29, x30, [sp, #0x10]

<+8>: add  x29, sp, #0x10      ; =0x10 

// 这里将x0内容保存到[sp, #0x8]地址中

// 而x0保存的是person对象的地址值

<+12>: str  x0, [sp, #0x8]

// 这里将[sp, #0x8]地址中的内容保存到x8

// 这里就实就变成x0 x8都保存这person对象的地址值

<+16>: ldr  x8, [sp, #0x8]

// 读取x8所保存地址中的前8个字节,保存到x9中

// 这里x8保存person的地址,person地址的前8个字节是虚表指针的地址

<+20>: ldr  x9, [x8]

// 读取x9所保存地址中的前8个字节,保存到x9中

// 这里x9保存虚表指针地址,读取指针地址得到Person虚表地址

// Person虚表地址前8个字节保存这Person::run的函数地址

<+24>: ldr  x9, [x9]

// 将x8赋值给x0

// x8保存着person对象地址,作为传参到下个函数(作为this指针使用)

<+28>: mov  x0, x8

// 读取并跳转到x9保存的地址

// 这里就是跳转到Person::run函数

<+32>: blr  x9

<+36>: ldp  x29, x30, [sp, #0x10]

<+40>: add  sp, sp, #0x20       ; =0x20 

<+44>: ret  

执行完AnimalRun函数后回到main函数,同样的也看看AnimalEat函数的调用过程。

AnimalEat函数中,我们来看看animal->eat();的调用过程。我们可以发现这里主要是做的事情跟AnimalEun函数做的事情基本相同,唯一的区别是Person::eat函数地址值保存在Person虚表地址偏移8个字节的地方。通过读取x0得到person对象的地址值,然后读取person对象的地址值前8个字节(读取虚表指针指向的地址)得到虚表地址值找到虚表,最后虚表地址值加上8字节的偏移后得到一个新的地址值,读取该地址的前8个字节得到Person::run函数的地址值,并通过blr指令跳转到Person::eat函数打印了“Person eat”。

<+0>: sub  sp, sp, #0x20       ; =0x20 

<+4>: stp  x29, x30, [sp, #0x10]

<+8>: add  x29, sp, #0x10      ; =0x10 

// 这里将x0内容保存到[sp, #0x8]地址中

// 而x0保存的是person对象的地址值

<+12>: str  x0, [sp, #0x8]

// 这里将[sp, #0x8]地址中的内容保存到x8

// 这里就实就变成x0 x8都保存这person对象的地址值

<+16>: ldr  x8, [sp, #0x8]

// 读取x8所保存地址中的前8个字节,保存到x9中

// 这里x8保存person的地址,person地址的前8个字节是虚表指针的地址

<+20>: ldr  x9, [x8]

// 将x9保存的地址值加上8个字节的偏移得到一个新的地址值,然后读取该地址前8个字节的内容并保存到x9上

// 这里x9保存虚表指针地址,读取指针地址得到Person虚表地址

// Person虚表前8个字节保存的是Person::run函数地址,偏移8个字节后,再读取8个字节得到的是Person::eat的函数地址

<+24>: ldr  x9, [x9, #0x8]

// 将x8赋值给x0

// x8保存着person对象地址,作为传参到下个函数(作为this指针使用)

<+28>: mov  x0, x8

// 读取并跳转到x9保存的地址

// 这里就是跳转到Person::eat函数

<+32>: blr  x9

<+36>: ldp  x29, x30, [sp, #0x10]

<+40>: add  sp, sp, #0x20       ; =0x20 

<+44>: ret   

以上就是C++中通过虚表的方式实现多态的原理。当然C++中多态的内容不止这些,还有纯虚函数虚继承,在虚继承中,虚表的作用就不止是存储函数地址了,还会记录虚基类中的属性在派生类中的内存地址偏移,这里篇幅有限就不展开了。