目录
1. 多态的原理
1.1. 虚函数表
1.2. 多态原理
1.3. 静态绑定和动态绑定
1.3.1. 运行时决议
1.3.2. 编译时决议
1.4. 为什么基类的对象调用虚函数不能构成多态
2. 单继承中的虚函数表
2.1. 同类型对象的虚表
2.2. 单继承的对象的虚表
2.2.1. 内存窗口查看
2.2.2. 通过实现打印函数 my_print_vft() 打印虚函数表
3. 多继承的虚函数表
3.1. 派生类类型的大小
3.2. 查看 Base1 的虚表
3.3. 查看 Base2 的虚表
3.3.1. 方法一:跳过 sizeof(Base1) 个字节
3.3.2. 方法二: 切片
3.4. 观察派生类对象指向的两个虚表的内容
3.4.1. 派生类对象调用 Derive::Func1 的汇编
3.4.2. Base1* ptr1 调用 Derive::Func1 的汇编
3.4.3. Base2* ptr2 调用 Derive::Func1 的汇编
3.4.4. 总结
1. 多态的原理
1.1. 虚函数表
首先,我们计算一下下面这个类实例化的对象有多大?
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
protected:
int _a;
char _ch;
};
void Test6()
{
A a;
std::cout << sizeof a << std::endl;
}
如果我们没有学过多态的原理,可能看到这个问题,肯定会脱口而出结果是8,因为我们一看,这不就是内存对齐吗,但是在这里真的是8吗?
结果如下:
不好意思啊,结果不是8,而是12, 那我就要问了,为什么呢?
我们通过监视窗口查看一下这个类实例化对象的底层结构:
我们发现:
- 对于这个类来说,它不仅有它的成员属性,还多了一个指针,并且这个指针是一个二级指针,类型为(void**),而这个指针我们称之为虚函数表指针(virtual function table pointer),简称虚表指针;
- 这个二级指针 (虚表指针) 指向的是一个函数指针数组,该数组存放的每一项是一个函数指针,指向对应的虚函数的实际代码地址,即虚函数的地址会进入虚表;
- 一个含有虚函数的类中都有一个虚函数表指针,因为虚函数的地址要放到虚函数表中,虚函数表也被称之为虚表;
- 注意:对象里面是没有虚表的,而是有虚表的指针。
正因为多了这个指针,所以上面A类实例化对象的大小是12 。
我们说,这个虚表指针指向的是一张表,表中存储的是虚函数的地址,因此,接下来就通过监视窗口,验证一下:
可以看到,虚表指针所指向的虚表中的确存储了这个虚函数的地址。
那么接下来的问题就是,一个类中的所有虚函数都会进入这张表 (虚表) 吗?非虚函数会进入虚表吗?
验证 demo 如下:
class A
{
public:
// 虚函数
virtual void Func1() { std::cout << "haha" << std::endl; }
virtual void Func2() { std::cout << "haha" << std::endl; }
virtual void Func3() { std::cout << "haha" << std::endl; }
// 非虚函数
void Func4() { std::cout << "haha" << std::endl; }
};
void Test7(void)
{
A a;
}
启动进程,并通过监视窗口得到如下现象:
可以看到,对于一个类而言,这个类中所有的虚函数,都会进入虚表 (对象中只有虚表指针,而无虚表),而对于非虚函数而言,不会进入虚表。
上面我们只讨论了的虚表指针是没有继承参与的,接下来,我们将引入继承,看看基类和派生类的虚表指针是怎样的,测试 demo 如下:
class A
{
public:
// Func1 将完成虚函数的重写
virtual void Func1() { std::cout << "haha" << std::endl; }
// Func2 不完成虚函数的重写
virtual void Func2() { std::cout << "hehe" << std::endl; }
// 非虚函数
void Func3() { std::cout << "heihei" << std::endl; }
};
class B : public A
{
public:
// Func1 将完成虚函数的重写
virtual void Func1() { std::cout << "xxxx" << std::endl; }
};
void Test8(void)
{
A a;
B b;
}
运行进程,并通过监视窗口以及内存窗口观察现象:
a 对象的 _vfptr 指向的内容:
b 对象的 _vfptr 指向的内容:
接下来,我们就来分析一下:
首先,我们发现,无论是基类还是派生类都有自己的虚表指针,分别指向不同的虚表,只不过这个虚表并不存在对象中。
- Func1:
- 首先,Func1 被B类继承后,因为它是一个虚函数,故会进入B类的虚表指针指向的内容 (虚表);
- 其次,我们发现,此时A类的虚表指针和B类的虚表指针指向的虚表中的 Func1 的地址是不同的。为什么呢? 这是因为B类对Func1完成了虚函数重写,重写是语法上的叫法,即对 Func1 的实现进行重写;但在原理层,本质上就是一个 (函数) 指针的覆盖,因此,重写是语法的叫法,覆盖是原理层的叫法;
- 如果你觉得上面不好理解,可以这样理解,B类在继承A类时,会创建一个虚表指针,这个虚表指针指向的虚表的初始内容就是A类虚表指针指向的虚表的内容,但由于,B类对 Func1 完成了虚函数重写,故B类会对虚表指针指向的虚表的内容进行覆盖 (本质上就是一个函数地址的覆盖),故,我们看到两个虚表指针指向的 Func1 的地址不同。
- Func2:
- 首先,Func2 被B类继承下来后,因为它是一个虚函数,故会进入B类的虚表指针指向的内容 (虚表);
- 其次,B类自身并没有对 Func2 进行虚函数的重写,因此,也就不会对虚表指针指向的内容进行覆盖,故我们看到,两个虚表指针指向的 Func2 的地址是一样的。
- Func3:
- Func3 也被B类继承下来,但由于它是一个非虚函数,故不会进入虚表,也就不会产生后续话题了。
1.2. 多态原理
接下来,我们就要来探讨多态的原理了,为什么多态可以做到:
- 如果我指向的是一个基类对象,就调用基类的接口;
- 如果我指向的是一个派生类对象,就调用派生类的接口。
测试 demo 如下:
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
virtual void Print() { std::cout << "hehe" << std::endl; }
};
void Func(A* ptr)
{
ptr->Print();
}
void Test9(void)
{
A a;
B b;
Func(&a);
Func(&b);
}
首先,运行进程,通过监视窗口得到如下结果:
接下来,得到A实例化的对象和B实例化的对象的对象模型,如下:
分析:
此时已经构成多态调用的条件了, 虚函数的重写,基类的指针或者引用调用这个虚函数。
- 当调用 Func(&a) 时,此时的 Ptr 指向的就是一个A类对象,因为此时的 Print 是一个虚函数,故在寻址时,会直接通过A类对象中的虚表指针找到虚表中对应的虚函数,那么此时调用的就是基类的 Print;
- 当调用 Func(&b) 时,此时的 Ptr 指向的就是一个B类对象,因为此时的 Print 也是一个虚函数,故在寻址时,会直接通过B类对象中的虚表指针找到虚表中对应的虚函数,那么此时调用的就是派生类的 Print;
可以看到,多态调用是根据不同类型中的虚表指针找到不同的虚表,得到特定的虚函数地址,进行调用,因此,当指向基类时,就通过基类的虚表指针找到对应的虚表,调用的就是基类的虚函数;当指向派生类时,就通过派生类的虚表指针找到对应的虚表,调用的就是派生类的虚函数,而这就是多态调用的原理。
1.3. 静态绑定和动态绑定
静态绑定是指在编译时就确定对象或函数的具体类型和实现。在静态绑定时,编译器会根据变量或表达式的静态类型来确定调用的方法,这样可以在编译阶段就直接将函数调用解析为特定的函数地址,提高程序的运行效率;
动态绑定是指在运行时根据对象的实际类型来确定调用的函数或方法。在动态绑定时,程序会在运行时根据对象的动态类型来决定调用哪个函数的动态绑定地址,这种机制实现了多态性,使得程序能够根据具体情况动态选择调用的函数,提高灵活性和扩展性。
在调用函数时,编译器通常有两种方式,一种是编译时决议,另一种就是运行时决议:
- 编译时决议是指在编译阶段确定程序中各个函数调用的具体实现,也就是在编译时就能确定调用哪个函数。这种静态绑定的方式可以提高程序的性能,但是缺乏灵活性。
- 而运行时决议是指在程序运行过程中,根据对象的实际类型来动态确定调用哪个函数的过程。这种动态绑定的方式在多态性的实现中非常重要,能够让程序根据实际情况来决定执行哪个函数,从而实现更灵活的功能。
1.3.1. 运行时决议
多态调用就是一个运行时决议,即一种动态绑定的方式。具体就是,多态调用会在运行时,编译器会根据对象实际指向的类型,获得相应的虚表指针,在通过虚表指针找到相应的虚表,再通过虚表调用特定的虚函数,这就是一种运行时决议。
测试 demo 如下:
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
virtual void Print() { std::cout << "hehe" << std::endl; }
};
void Func(A* ptr)
{
ptr->Print();
}
void Test9(void)
{
B a;
Func(&b);
}
运行进程,查看反汇编,得到如下现象:
上面就是一个运行时决议。
1.3.2. 编译时决议
首先,我们知道,多态调用和普通调用是相对立的,那么普通调用就一定是编译时决议吗?测试 demo 如下:
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
// 未完成虚函数的重写
};
void Func(A* ptr)
{
ptr->Print();
}
void Test9(void)
{
B b;
Func(&b);
}
可以看到, B类并没有对A类的 Print 进行重写,因此,不符合多态调用,故,上面是一个普通调用,可是,上面是编译时决议吗? 运行进程,并通过汇编,得到如下现象:
惊奇的发现,此时竟然还是一个运行时决议。
事实上,编译器在这里处理的很暴力,只要你调用的是一个虚函数,它在实际处理中,都会以运行时决议的方式去处理,而只要是一个非虚函数,那么才是一个编译时决议,如下 demo :
class A
{
public:
void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
virtual void Print() { std::cout << "hehe" << std::endl; }
};
void Func(A* ptr)
{
ptr->Print();
}
void Test9(void)
{
B b;
Func(&b);
}
运行进程,通过汇编得到如下现象:
可以看到,此时就是一个编译时决议,即在编译链接阶段,就确定好了调用函数的地址。同时经过两个 demo 也论证了,普通调用不一定就是编译时决议,只有调用的是非虚函数,才是编译时决议。
最后,我们发现,运行时决议是比编译时决议更复杂的,太多的运行时决议可能会影响效率,因此,如果目的不是为了构成多态 (运行时决议),不要将函数定义为虚函数,因为编译器在处理虚函数时,采取的默认方案就是运行时决议,而非虚函数,编译器采取的是编译时决议。
补充: 下面的 demo 是运行时决议还是编译时决议呢?
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
virtual void Print() { std::cout << "hehe" << std::endl; }
};
void Test22(void)
{
A a1;
B b1;
// 编译时决议还是运行时决议?
a1.Print();
// 编译时决议还是运行时决议?
b1.Print();
}
启动进程,并通过反汇编得到如下结果:
我们发现,都是编译时决议,不是说好了只要调用虚函数,都是运行时决议吗?
注意,这里有个意外,如果是普通对象调用虚函数,那么就是编译时决议。
而如果是指针或者引用调用虚函数,才是运行时决议,如下:
void Test23(void)
{
A a1;
B b1;
A& a2 = a1;
// 对象的引用调用虚函数
a2.Print();
B* ptr = &b1;
// 对象的地址调用虚函数
ptr->Print();
}
现象如下:
1.4. 为什么基类的对象调用虚函数不能构成多态
多态的两个条件:
- 虚函数的重写;
- 基类的指针/引用调用虚函数。
对于虚函数的重写这个条件,没有什么疑问,因为如果不重写,派生类中的虚表指针指向的虚表的内容就是基类虚表指针指向的虚表的内容,即此时虚表中的函数指针没有发生覆盖,派生类怎么调用自己的虚函数呢?
因此,虚函数的重写毋庸置疑。
可是,基类的指针/引用可以调用虚函数这个条件,为什么没有基类的对象调用虚函数呢?
我们在这里以一种反证法的思路来讨论这个问题。
假设基类的对象调用虚函数构成多态行为,那么在有些场景下是有问题的,如下 demo:
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
virtual void Print() { std::cout << "hehe" << std::endl; }
};
void Func(A target)
{
target.Print();
}
void Test9(void)
{
B b;
Func(b);
}
因为此时 target 是由b切片得到的 (实际上是将派生类中基类的属性拷贝给这里的 target),如果此时要构成多态行为,是不是需要调用B类的虚函数? 是不是也就意味着在切片的过程中,需要将派生类的虚表指针也拷贝过去?那么如果此时一个基类对象,被一个派生类对象赋值了,还能保证这个基类对象的中所指向虚表一定是基类所需要的虚表吗?
此时是不是就会导致紊乱了? 因为此时这个基类对象中的虚表指针所指向的虚表可能是派生类对象中虚表指针所指向的虚表,这就出现问题了。
同时,我们知道,一般情况下,析构函数在继承场景下都是会搞成虚函数的,如果此时,一个基类对象的虚表是派生类对象的虚表 (在这里简单说了,对象只有虚表指针,而无虚表),那么这个指向基类对象在清理资源时,就调用的是派生类的析构,这合理吗? 一个基类对象调用的是派生类对象的析构,很显然,编译器不会这样做,故基类的对象调用虚函数不能构成多态。
2. 单继承中的虚函数表
2.1. 同类型对象的虚表
对于下面的 demo,A 实例化出的两个对象是各自有一个虚表?还是共用一个虚表?
class A
{
public:
virtual void Print1() { std::cout << "haha" << std::endl; }
virtual void Print2() { std::cout << "hehe" << std::endl; }
};
void Test10(void)
{
A a1;
A a2;
}
运行进程,并通过监视窗口,得到如下现象:
可以清晰地看到的,对于同一个类型实例化出的不同对象,它们所存储的是同一个虚表指针,指向的是同一张虚表,表中的内容当然一样。
2.2. 单继承的对象的虚表
对于下面的 demo, A类实例化的对象和B类实例化的对象,是共用同一个虚表还是各自拥有一个虚表?
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
virtual void Print() { std::cout << "hehe" << std::endl; }
};
void Test11(void)
{
A a;
B b;
}
上面的 demo,派生类对虚函数进行了重写,因此,至少可以肯定的是两个对象的虚表指针所指向的虚表中的内容不一样 (函数地址被覆盖)。
运行进程,并通过监视窗口,得到如下现象:
可以清晰地看到,基类和派生类各自私有一个虚表指针,并指向不同的虚表。
如果此时,派生类没有完成虚函数的重写呢? 会发生什么变化? 测试 demo 如下:
class A
{
public:
virtual void Print() { std::cout << "haha" << std::endl; }
};
class B : public A
{
public:
};
void Test11(void)
{
A a;
B b;
}
运行进程,并通过监视窗口,得到如下现象:
可以清晰地看到,尽管此时派生类没有对虚函数进行重写,但是它们还是各自私有一个虚表指针,指向不同的虚表,虚表中的内容是一样的 (因为没有发生指针的覆盖)。
注意,对象里面没有虚表,只有虚表的指针。
最后,不管派生类对虚函数是否完成了重写,基类和派生类各自私有一个虚表指针,该指针指向不同的虚表。
接下来,我们讨论一个更复杂的场景,测试 demo 如下:
class A
{
public:
virtual void Func1() { std::cout << "class A Func1()" << std::endl; }
virtual void Func2() { std::cout << "class A Func2()" << std::endl; }
};
class B : public A
{
public:
virtual void Func1() { std::cout << "class B Func1()" << std::endl; }
virtual void Func3() { std::cout << "class B Func3()" << std::endl; }
};
void Test13()
{
A a;
B b;
}
上面的 demo:
- Func1() 这个虚函数被派生类进行了重写,故派生类和基类所指向的虚表中的 Func1() 是不一样的 (函数地址被覆盖了);
- Func2() 这个虚函数派生类没有完成重写,故派生类和基类所指向的虚表中的 Func2() 是一样的 (函数地址一致);
- 但是最关键的是,派生类中有一个自己的虚函数 Func3(),现在的问题就是 Func3() 会进入派生类的虚表吗?
运行进程,通过监视窗口,得到如下现象:
从结果来看,发现派生类的虚表中没有 Func3(),难道说 Func3 这个虚函数不会进入派生类的虚表吗?
答案是:不是,Func3() 会进入派生类的虚表中,这里监视窗口没有显示是因为,监视窗口有时候不是那么真实,它给我们隐藏了,好,既然 Func3() 会进入虚表,请证明:
2.2.1. 内存窗口查看
调出内存窗口,查看b对象的 __vfptr (虚表的地址),得到如下现象:
我们发现:
- 0x00a314b5 就是 B::Func1 的地址;
- 0x00a314ba 就是 B::Func2 的地址;
- 剩下的 0x00a314bf 是 B::Func3 的地址吗?
上面第三个结果内存窗口不能确定,因此我们提出另一种解决方案:
2.2.2. 通过实现打印函数 my_print_vft() 打印虚函数表
前置性认识:
- 我们知道,虚函数表其实是一个函数指针数组,即如果想要打印虚函数表,是不是只要找到虚函数表的首地址,通过解引用或者下标索引就可以遍历这个函数指针数组即虚表;
- 而 vs 对虚表做了处理,虚表的最后一项为 nullptr,从上面的内存窗口也可以看出,故遍历的终止条件就有了,Linux下没有做过这个处理。
第一步:假设现在已经得到了虚表的首地址,该如何实现 my_print_vft() ?
// 虚表是什么呢?是一个函数指针数组,那么虚表的首地址是?是一个函数指针*
// 由于我们定义的接口都是无参无返回值的,因此这个函数指针的类型就是 void(*)()
// 我们可以对这个函数指针类型重命名
// 注意对普通类型重命名,如int : typedef int my_val_type;
// 而对于函数指针是这样: typedef void(*vft_ptr)(); 即对void(*)() 重命名为 vft_ptr
// vs下对虚表做了处理,即虚表的最后一项为nullptr, 因此我们遍历时的循环结束条件就有了
typedef void(*vft_ptr)();
// void my_print_vft(vft_ptr v_table[])
// 这里的 v_table 就是虚表的首地址
void my_print_vft(vft_ptr* v_table)
{
for (size_t i = 0; v_table[i]; ++i)
{
// 打印每一项(函数指针)即虚函数的地址
printf("vft_ptr[%d]: %p ", i, v_table[i]);
// 光是地址无法区分,因此我们还是调用对应的虚函数,用于辨别
v_table[i]();
}
}
第二步:如何得到虚表的首地址呢?即虚表指针?
首先,不论是派生类还是基类,虚表的首地址都是对象的前4个(32位平台)字节或者前8个(64位平台)字节。
注意:
我们需要的是虚表的首地址。 类型是 (vft_ptr*) 即函数指针* ;
对象的前 4/8 字节就是我们需要的虚表的首地址;
但是此时这 4/8 字节的内容的类型与函数指针* 不匹配,因此需要强转。
即这个过程可以看作两步:
- 第一步:得到对象的前4/8字节内容(32位是4字节,64位是8字节);
- 第二步:对这 4/8 字节内容进行类型转换,使类型匹配。
现在有一个B类实例化的对象, 比如 B b,如何得到对象b的虚表的首地址,步骤如下:
- 得到对象的地址: &b;
- 强转为 int* : (int*)(&b);
- 对 int* 解引用,得到就是4字节的内容: *((int*)(&b));
- 此时的4字节内容类型为 int,故需要进行类型转换: (函数指针*)*((int*)(&b)),即 (vft_ptr*)*((int*)(&b))。
测试 demo 如下:
void Test13()
{
A a;
B b;
// 打印虚表, 并调用虚函数
my_print_vft((vft_ptr*)(*(int*)&a));
std::cout << "--------------------------------\n";
my_print_vft((vft_ptr*)(*(int*)&b));
}
现象如下:
通过上面的 demo,我们可以肯定,Func3() 这个虚函数是会进入派生类的虚表中的。
这也印证了我们之前说过的只要是虚函数,就会进入虚表,你是基类的虚函数就进入基类的虚表,你是派生类的虚函数,就进入派生类的虚表。
3. 多继承的虚函数表
上面我们讨论的都是单继承的虚函数表,那么多继承中的虚表呢?所用的测试 demo 如下:
class Base1
{
public:
virtual void Func1() { std::cout << "class Base1 Func1()" << std::endl; }
virtual void Func2() { std::cout << "class Base1 Func2()" << std::endl; }
public:
int _b1 = 1;
};
class Base2
{
public:
virtual void Func1() { std::cout << "class Base2 Func1()" << std::endl; }
virtual void Func2() { std::cout << "class Base2 Func2()" << std::endl; }
public:
int _b2 = 2;
};
class Derive : public Base1, public Base2
{
public:
virtual void Func1() { std::cout << "class Derive Func1()" << std::endl; }
virtual void Func3() { std::cout << "class Derive Func3()" << std::endl; }
public:
int _d = 3;
};
Base1 和 Base2两个基类分别被 Derive 派生类继承,Derive 派生类中对 Func1 进行了重写,但没有对 Func3 完成重写。
3.1. 派生类类型的大小
void Test14()
{
Derive d;
std::cout << "Derive size: " << sizeof(d) << std::endl;
}
分析:
- 首先,派生类类型中肯定有如下成员: _b1、_b2、_d,这三个成员所占的大小是12字节;
- 接下来,问题的关键就在于,派生类中有几个虚表指针呢? 是一个,还是两个呢?
带着问题,启动进程,得到如下结果:
从结果来看,派生类中应该是有两个虚表指针的,为了进一步验证,我们启动进程,调出监视窗口,观察派生类对象,现象如下:
可以看到,派生类对象中的确有两个虚表指针,因此,派生类的大小是 3个int成员 + 2 个虚表指针,32位的程序中,大小为20。
我们知道,只要是虚函数就会进入虚表,基类的虚函数进入基类的虚表,派生类的虚函数进入派生类的虚表。
可通过上面的现象,我们发现一个问题,派生类中有一个虚函数 Func3,它进入了哪一个虚表呢? 是Base1的虚表,还是Base2的虚表? 而上面的监视窗口无法观察到,因此,我们只能借助打印函数,将虚表的每一项都打印出来。
在打印之前,由于多继承较为复杂一点,因此我们将派生类的对象模型给描述出来,以方便后续处理,如下:
同时,我们要知道,多继承打印虚表与单继承打印虚表的打印函数是一样的,只不过得到虚表指针的过程可能略有差异。
3.2. 查看 Base1 的虚表
在32位机器下,虚表的地址是4个字节大小,对于 Base1 这个基类而言,因为它是先被 Derive 继承的,故 Derive 对象模型中的前4个字节就是Base1中虚表指针,因此处理过程与单继承如出一辙,具体步骤如下:
- 得到派生类对象的地址:&d;
- 强转为 int* : (int*)(&d);
- 对 int* 解引用,得到就是4字节的内容: *((int*)(&d));
- 此时的4字节内容类型为 int,故需要进行类型转换: (函数指针*)*((int*)(&d)),即 (vft_ptr*)*((int*)(&d));
- 调用打印函数 my_print_vft()。
实现如下:
void Test15()
{
Derive d;
// 派生类对象中 Base1 的虚表
my_print_vft((vft_ptr*)*(int*)&d);
}
结果如下:
通过上面的结果,可以知道,派生类独自有的虚函数 Func3() 会优先进入 Base1 指向的虚表 (先继承的基类指向的虚表),因此,正确的派生类(Derive) 对象模型如下:
3.3. 查看 Base2 的虚表
不过为了严谨性,我们还需要打印一下 Base2 所指向的虚表。
打印 Base2 所指向的虚表有两个方法:
- 跳过 sizeof(Base1) 个字节;
- 切片
具体如下:
3.3.1. 方法一:跳过 sizeof(Base1) 个字节
参考 Derive 的对象模型,我们发现,Base2 并不是对象模型中最开始的部分,而是隔了一个Base1,因此,在这里需要调过 Base1,具体步骤如下:
- 得到派生类对象的地址:&d;
- 强转为 char* : (char*)(&d);
- 跳过 Base1 个字节:((char*)(&d) + sizeof(Base1));
- 强转为 int*:(int*)((char*)(&d) + sizeof(Base1));
- 对 int* 解引用,得到就是4字节的内容: *(int*)((char*)(&d) + sizeof(Base1));
- 此时的4字节内容类型为 int,故需要进行类型转换: (函数指针*)*(int*)((char*)(&d) + sizeof(Base1)),即 (vft_ptr*)*(int*)((char*)(&d) + sizeof(Base1));
- 调用打印函数 my_print_vft()。
实现如下:
void Test16()
{
Derive d;
// 派生类对象中 Base2 的虚表
my_print_vft((vft_ptr*)*(int*)((char*)(&d) + sizeof(Base1)));
}
结果如下:
3.3.2. 方法二: 切片
我们知道,派生类的对象可以赋值给基类的对象、指针、引用,这种语法我们称之为切片或者切割,切片能让我们轻松获得派生类对象中 Base2 的起始地址,步骤如下:
- 得到派生类对象的地址:&d;
- 通过切片得到 Base2 的起始地址:Base2* ptr = &d;
- 强转为 int*:(int*)ptr;
- 对 int* 解引用,得到就是4字节的内容: *(int*)ptr;
- 此时的4字节内容类型为 int,故需要进行类型转换: (函数指针*)*(int*)ptr,即 (vft_ptr*)*(int*)ptr。
实现如下:
void Test17()
{
Derive d;
// 通过切片得到派生类对象中Base2的起始地址
Base2* ptr = &d;
// 派生类对象中 Base2 的虚表
my_print_vft((vft_ptr*)*(int*)ptr);
}
现象如下:
3.4. 观察派生类对象指向的两个虚表的内容
经过上面的测试,我们已经能够打印两个基类的虚表的内容,不过这里隐藏一个问题,需要说明一下,测试 demo 如下:
void Test18(void)
{
Derive d;
// 派生类对象中 Base1 的虚表
my_print_vft((vft_ptr*)*(int*)&d);
std::cout << "-----------------------------------------" << std::endl;
Base2* ptr = &d;
// 派生类对象中 Base2 的虚表
my_print_vft((vft_ptr*)*(int*)ptr);
}
现象如下:
Func1 这个虚函数是 Base1和 Base2 都有的一个虚函数,派生类对 Func1 完成了虚函数的重写,虚函数重写的本质实际上就是指针的覆盖,那么派生类对 Func1 完成了虚函数重写,按道理讲,此时派生类对象指向的两个虚表中的 Func1 的地址应该是一样的!但是,我们通过上面的现象发现,它们不一样,这是为什么呢?
甚至如果是下面的 demo,就更奇怪了,如下:
void Test19(void)
{
Derive d;
// 派生类对象中 Base1 的虚表
my_print_vft((vft_ptr*)*(int*)&d);
std::cout << "-----------------------------------------" << std::endl;
Base2* ptr = &d;
// 派生类对象中 Base2 的虚表
my_print_vft((vft_ptr*)*(int*)ptr);
std::cout << "-----------------------------------------" << std::endl;
// 打印函数的地址时:
// 普通函数可以不加 '&'
// 但是成员函数必须加上 '&'
// --- 打印派生类的 Func1() 函数的地址 ---
printf("derive::func1: %p\n", &Derive::Func1);
}
现象如下:
我们发现一个非常奇怪的现象,这三个函数地址按道理讲应该是一模一样的,因为,它们都是派生类的 Func1 的地址,都是指向派生类的 Func1 函数,但从结果来看,它们不一样。
事实上,它们都是指向派生类的 Func1 函数,但编译器在这里做了一些处理,为了能够更清楚的解释这个问题,我们需要调用三种方式调用 Derive::Func1,并通过汇编查看它们的调用过程,测试 demo 如下:
void Test20(void)
{
Derive d;
// --- 派生类对象中 Base1 的虚表 ---
my_print_vft((vft_ptr*)*(int*)&d);
std::cout << "-----------------------------------------" << std::endl;
Base2* ptr = &d;
// --- 派生类对象中 Base2 的虚表 ---
my_print_vft((vft_ptr*)*(int*)ptr);
std::cout << "-----------------------------------------" << std::endl;
// --- 打印派生类的 Func1() 函数的地址 ---
printf("Derive::Func1: %p\n", &Derive::Func1);
std::cout << "-----------------------------------------" << std::endl;
// 1. 派生类对象直接调用 Func1()
d.Func1();
// 2. 切片, Base1* ptr1 调用 Func1()
Base1* ptr1 = &d;
ptr1->Func1();
// 3. 切片, Base2* ptr2 调用 Func1()
Base2* ptr2 = &d;
ptr2->Func1();
}
运行现象 (为了与后面的汇编现象匹配):
3.4.1. 派生类对象调用 Derive::Func1 的汇编
具体如下:
3.4.2. Base1* ptr1 调用 Derive::Func1 的汇编
具体如下:
3.4.3. Base2* ptr2 调用 Derive::Func1 的汇编
具体如下:
3.4.4. 总结
可以看到,无论以上面的任一方式,最后调用的 Func1()是一定的 (都是 Derive::Func1 ),只不过不同的调用可能会做一些封装 (为了处理不同的情况)。
具体解释:
- 首先:这里存在很多编译器的行为,比如我们打印的地址事实上并不是函数 (Derive::Func1) 的真实地址(即函数的第一条指令的地址),而是一个跳转指令 (例如这里的 jmp 指令 ) 的地址,跳转指令会跳转至函数真正的执行语句。但是我们依然可以理解为虚表中存储的是虚函数的地址!
- 其次:上图中打印派生类对象指向的两个虚表中的 Func1 地址,一个是派生类对象中 Base1 指向的虚表,另一个是派生类对象中 Base2指向的虚表,两个虚表中的 Func1 函数地址不同,这里是因为 ptr2 去调用这个 Func1 时,要进行一些额外操作:因为 ptr2 指向的是基类Base2 的虚表指针 (切片),且此时 ptr2 指向的派生类对象,故调用 Func1 需要事先将此指针减一些偏移量(偏移量就是sizeof(Base1),即执行eax - 8) ,使其指向派生类对象的开始,即派生类对象的地址,因此打印的函数地址不同;
- 最后,虽然上面打印的 Func1 的地址各不相同,但本质上,它们都是一样的,因为它们最终都是调用的是派生类中的 Func1,即 Derive::Func1。
至此,对于这个问题的讨论结束。