4 多态的原理
4.1 虚函数表指针
我们以一道题来引入多态的原理。
下面编译为 32 位程序的运行结果是什么() A、编译报错 B、运行报错 C、8 D、12
class Base {
public:
virtual void Func1() { cout << "Func1()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
Base b;
cout << sizeof(b) << endl;
return 0;
}
按照我们之前的知识,这题答案应该选:C。但我们不妨多留一个心眼:这题如果是考察内存对齐,为什么要加一个虚函数呢?是不是没有这么简单。
我们来看下运行结果:为什么?Base 类中除了 _b 和 _ch 成员,还多一个 _vfptr 成员放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v 代表 virtual,f 代表 function)。一个含有虚函数的类中至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
class Base {
public:
virtual void Func1() { cout << "Func1()" << endl; }
virtual void Func2() { cout << "Func2()" << endl; }
void Func3() { cout << "Func3()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
虚函数表其实是一个数组,该数组中存放着该类中所有虚函数的地址。虚函数表本质是一个函数指针数组,而 _vfptr 则是指向这个数组的指针。通过图片我们也可以看到:虚函数表中放着虚函数 Func1() 和 Func2() 的地址,因为 Func3() 不是虚函数,并没有放进去。
4.2 多态的原理
认识到了虚表指针的存在,我们就可以进一步来了解多态的原理啦。
我们结合具体的样例来学习。
class Person {
public:
virtual void BuyTicket() { cout << "买票 - 全价" << endl; }
protected:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票 - 打折" << endl; }
protected:
int _id;
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "买票 - 优先" << endl; }
protected:
string _codename;
};
void Func(Person* ptr) {
ptr->BuyTicket();
}
int main() {
Person ps;
Student st;
Soldier sr;
Func(&ps);
Func(&st);
Func(&sr);
return 0;
}
上述代码中有三个类,每个类都有一个虚表指针。
可以看到,三个类中虚函数表的 BuyTicket() 函数指针的地址都是不同的。
多态是怎么做到指向谁就去调用谁的呢?在编译阶段,编译器检查语法,看满不满足多态的条件。如果满足多态,在编译这段指令时,底层不再是编译时通过调用对象确定函数的地址,而是变成:在运行时,到指向对象的虚函数表中去找对应虚函数的地址,进行调用。
这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。
对 Func() 函数的 ptr 来说,不论传递的是父类对象还是子类对象,在它眼里都是父类对象,不同的是子类需要进行切片,ptr 看到的是子类切片后剩下的父类对象。但是没关系,如果满足多态条件,ptr 会进入这个父类的虚函数表中查找对应的虚函数的地址,找到谁就调用谁。
满足多态时的汇编代码:
前面的 mov 指针简单来说就是:找到 _vfptr 指针,再找到对应的虚函数表,再找到对应的函数指针,最后将指针给 eax 寄存器,寄存器去 call 函数地址。
下面,我将父类的 virtual 去掉,他们就不满足多态的条件了,再来看看他们的汇编代码。
class Person {
public:
void BuyTicket() { cout << "买票 - 全价" << endl; }
protected:
string _name;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票 - 打折" << endl; }
protected:
int _id;
};
class Soldier : public Person {
public:
virtual void BuyTicket() { cout << "买票 - 优先" << endl; }
protected:
string _codename;
};
两句代码搞定,ptr 是父类的指针,直接调用父类的 BuyTicket() 函数,与指向的对象无关。
4.3 动态绑定和静态绑定
- 对不满足多态条件(指针或者引用 + 调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
- 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就叫做动态绑定。
// ptr 是指针+BuyTicket 是虚函数满足多态条件。
// 这里是动态绑定,编译在运行时到 ptr 指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax // BuyTicket 不是虚函数,不满足多态条件。
// 这里是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)
从运行效率上来说,静态绑定更高一点,毕竟只有两句指令。
4.4 虚函数表
- 这一点我们前面已经讲过了。
- 同一个类的对象虚函数表共用,不同类型对象虚表各自独立。
基类对象的虚函数表中存放基类所有虚函数的地址。
class Base {
public:
virtual void Func1() { cout << "Func1()" << endl; }
virtual void Func2() { cout << "Func2()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
int main() {
Base b1;
Base b2;
Base b3;
return 0;
}
这也解释了为什么虚函数不放在对象中,而是放在一个数组之中,因为不同的对象好共享。如果不把虚函数地址放在虚函数表中,而是放在对象之中,那么每个对象都要存一份,太过冗余。像这样放在一个公共的地方,无论有几个虚函数,都只需多 4 个字节来存储指针就行。
-
派生类由两部分构成,继承下来的基类和自己的成员,一般情况下继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但需要注意的是,这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
-
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
-
派生类的虚函数表包含:基类的虚函数地址,派生类重写的虚函数地址,派生类自己的虚函数地址。
什么意思呢?
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base {
public:
// 重写基类的 func1
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func1" << endl; }
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
现在基类 Base 中有两个虚函数,派生类 Derive 中重写了 func1(),并且有一个自己的虚函数 func3()。
派生类的虚函数表生成逻辑是这样的:先将基类的虚函数表拷贝一份看有无完成重写/覆盖。派生类 Derive 重写了 func1 函数,就会用重写的 func1 将基类的 func1 进行覆盖,func2 并没有完成重写,不管最后再加上自己的虚函数。
-
虚函数表本质是一个存虚函数指针的指针数组,一般情况下这个数组最后面放了一个
0x00000000标记。(这个 C++ 并没有明确规定,各个编译器自行定义的,VS 系列编译器会在后面放个0x00000000标记,g++ 系列编译器不会放) -
虚函数存在哪?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段 (常量区) 的,只是虚函数的地址又存到了虚表中。
-
虚函数表存在哪?这个问题严格来说并没有标准答案,C++ 标准并没有规定,我们写下面的代码可以对比验证一下。VS 下是存在代码段(常量区)。
int main() {
int i = 0;
static int j = 1;
int* p1 = new int;
const char* p2 = "xxxxxxxx";
printf("栈:%p\n", &i);
printf("静态区:%p\n", &j);
printf("堆:%p\n", p1);
printf("常量区:%p\n", p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf("Person 虚表地址:%p\n", *(int*)p3);
printf("Student 虚表地址:%p\n", *(int*)p4);
printf("虚函数地址:%p\n", &Base::func1);
printf("普通函数地址:%p\n", &Base::func5);
return 0;
}
运行结果:
可以看到,虚表的地址和常量区的最接近。我们可以大致判定 VS 下虚函数表是放在代码段的。


