虚函数、虚表
- created: 2024-06-28T10:55+08:00
- modified: 2024-11-17T16:05+08:00
- published: 2024-11-17T16:18+08:00
- category: C-CPP
virtual
关键字的作用
多态分为静态多态和运行时多态。
C++ 有一点和 Java 不一样,如果没有 virtual
关键字,是没法实现运行时多态的。
父类指针指向子类对象调用虚函数会调用子类重写版本
父类指针指向子类对象时候,指针调用 virtual 关键字修饰的函数,会调用子类的重写(override)版本。
例子:对于 Shape 父类,子类有 Square、Circle,定义 virtual float area() const;
。
可以对 vector<Shape*> shapes
迭代获取所有图形的面积,下面的例子在 ./shape-area.cpp
#include <iostream>
#include <vector>
#include <memory>
using std::make_shared;
using std::shared_ptr;
using std::vector;
class Shape
{
public:
virtual double area() const = 0; // pure virtual function, have to be implemented in derived classes
// if use this function, none of the derived classes will be able to override
// double area()
// {
// std::cout << "Shape::area()" << std::endl;
// return 0;
// }
~Shape() {};
};
class Circle : public Shape
{
public:
double r_;
Circle(double r) : r_(r) {}
double area() const { return 3.14 * r_ * r_; }
~Circle() { std::cout << "~Circle()" << std::endl; }
};
class Square : public Shape
{
public:
double s_; // side length of square
Square(double s) : s_(s) {}
double area() const { return s_ * s_; }
~Square() { std::cout << "~Square()" << std::endl; }
};
int main()
{
vector<shared_ptr<Shape>> shapes;
shapes.push_back(make_shared<Circle>(10));
shapes.push_back(make_shared<Square>(10));
for (const auto &shape : shapes)
{
std::cout << shape->area() << std::endl;
}
}
typeid(BaseClass*) -> DerivedClass::typeinfo
The typeid operator (C++ only) - IBM Documentation
IBM 上提供了 typeid
的用例,该运算符返回一个 type_info
的实例。
同样是继承 B -> A
, D -> C
,只差了一个 virtual 关键字。
如果没有 virtual,那么提供给 typeid(ptr)
指向什么类型,它就返回什么类型。
#include <iostream>
#include <typeinfo>
using namespace std;
struct A
{
virtual ~A() {} // "virtual" keyword
};
struct B : A
{
};
struct C
{
};
struct D : C
{
};
int main()
{
B b_obj;
A *ap = &b_obj;
A &ar = b_obj;
cout << "ap: " << typeid(*ap).name() << endl; // B
cout << "ar: " << typeid(ar).name() << endl; // B
D d_obj;
C *cp = &d_obj;
C &cr = d_obj;
cout << "cp: " << typeid(*cp).name() << endl; // C
cout << "cr: " << typeid(cr).name() << endl; // C
}
虚表
继承带来的两个指令层面的问题:this
的调整和向下转型
- 使用父类指针调用重写的子类函数的时候,函数传入的 this 指针应该指向子类,所以 this 的位置可能需要调整
// 假设 C-> A, C-> B,是一个多继承的结构 // A, B, C 都实现了 f() A* p_a = new C(); B* p_b = new C(); p_a->f(); // C::f() 被调用,调用时候 this 需要保证指向一个 C 类实例,p_a 不一定指向 C 类实例 p_b->f(); // C::f() 被调用,调用时候 this 需要保证指向一个 C 类实例,p_b 不一定指向 C 类实例
- 父类指针转换为子类的时候,指针的位置可能需要调整:
// 假设 C-> A, C-> B,是一个多继承的结构, class C: public A, public B A* p_a = new C(); B* p_b = new C(); B* p_real_b = new B(); C* p_c = dynamic_cast<C*>(p_b); // cast success C* p_c_fail = dynamic_cast<C*>(p_real_b); // cast failed, p_c_failed = nullptr
参考代码,见 ./multi-inherit.cpp:
// A B
// \ /
// C
#include <iostream>
class A
{
public:
virtual void f() { std::cout << "A::f()" << std::endl; }
};
class B
{
public:
char b_ = 'b';
public:
virtual void f() { std::cout << "B::f()" << std::endl; }
};
class C : public A, public B
{
public:
void f() { std::cout << "C::f()" << std::endl; }
};
int main()
{
A *p_a = new C();
B *p_b = new C();
p_a->f();
p_b->f();
C *p_c = dynamic_cast<C *>(p_b);
if (p_c)
{
std::cout << "dynamic_cast success" << std::endl;
}
B *p_real_b = new B();
C *p_cast_failed = dynamic_cast<C *>(p_real_b);
if (p_cast_failed == nullptr)
{
std::cout << "dynamic_cast failed" << std::endl;
}
return 0;
}
单继承 与 RTTI
In computer programming, run-time type information or run-time type identification (RTTI) is a feature of some programming languages (such as C++, Object Pascal, and Ada) that exposes information about an object's data type at runtime. Run-time type information - Wikipedia
提供一个单继承的例子,来自1
struct A
{
int ax;
virtual void f0() {}
};
struct B : public A
{
int bx;
virtual void f1() {}
};
struct C : public B
{
int cx;
void f0() override {}
virtual void f2() {}
};
内存布局如下:
C VTable(不完整)
+------------+
object | RTTI for C |
0 - struct A, B, C, ptr_C --------------> +------------+
8 - int ax | C::f0() |
12 - int bx +------------+
16 - int cx | B::f1() |
+------------+
| C::f2() |
sizeof(C): 24 align: 8 +------------+
单继承对于要解决的两个问题的方案是:
- 父类指针调用子类方法,this 不需要调整了,实例的内存布局中,大家都在最上面。
- 父类向下转型总是可以成功,不需要调整指针位置。
多继承下,primary base 在头部,其他父类在尾部
A B
\ /
C
加入有多继承,第一个继承的类 A 就是 C 的 primary base
thunk
用来解决父类指针调用子类函数时候,this 的调整offset_to_top
用来解决父类向下转型对 this 的调整
以下例子还是来自1:
C Vtable (7 entities)
+--------------------+
struct C | offset_to_top (0) |
object +--------------------+
0 - struct A (primary base) | RTTI for C |
0 - vptr_A -----------------------------> +--------------------+
8 - int ax | C::f0() |
16 - struct B +--------------------+
16 - vptr_B ----------------------+ | C::f1() |
24 - int bx | +--------------------+
28 - int cx | | offset_to_top (-16)|
sizeof(C): 32 align: 8 | +--------------------+
| | RTTI for C |
+------> +--------------------+
| Thunk C::f1() |
+--------------------+
thunk 要调整的偏移不一定等于 offset_to_top, 因为可能还有其他的继承情况,如下:
B.f
|
A C.f
\ /
D
B 中的 thunk 会到 C,而 B 的 offset_to_top 会到 A。
下面这个例子展示了多继承情况下,父类指针指向的对象相同(都是 C),但是指针的地址却不同:
struct A
{
int a{0};
virtual ~A() {} // a virtual keyword
};
struct B
{
int b{1};
virtual ~B() {}
};
struct C : public A, B
{
int c{2};
};
int main()
{
C c_obj = *new C();
C *c_ptr = &c_obj;
A *a_ptr = &c_obj;
B *b_ptr = &c_obj;
std::cout << "c ptr:" << c_ptr << std::endl; // 0x2b0f3ffda0
std::cout << "a ptr:" << a_ptr << std::endl; // 0x2b0f3ffda0 // base class, same address
std::cout << "b ptr:" << b_ptr << std::endl; // 0x2b0f3ffdac // there is an offset
return 0;
}
虚继承下,virtual class 在实例的尾部
例子还是来自 1:
struct A
{
int ax;
virtual void f0() {}
virtual void bar() {}
};
struct B : virtual public A /****************************/
{ /* */
int bx; /* A */
void f0() override {} /* v/ \v */
}; /* / \ */
/* B C */
struct C : virtual public A /* \ / */
{ /* \ / */
int cx; /* D */
void f0() override {} /* */
}; /****************************/
struct D : public B, public C
{
int dx;
void f0() override {}
};
B 的内存模型,A 作为 virtual base,会放到尾部。 所以当向上转型到 A 的时候,不能再使用 offset_to_top 了,而是要使用 vbase_offset。
vcall_offset 和 thunk 是对问题一「父类指针调用子类函数需要调整 this」的另一种解决方法, 将 thunk 需要调整的偏移记录在虚表中,而不是直接跳转到一段独立的代码去调整 this 再 call 子类的方法。
B VTable
+---------------------+
| vbase_offset(16) |
+---------------------+
| offset_to_top(0) |
struct B +---------------------+
object | RTTI for B |
0 - vptr_B -------------------------> +---------------------+
8 - int bx | B::f0() |
16 - struct A +---------------------+
16 - vptr_A --------------+ | vcall_offset(0) |x--------+
24 - int ax | +---------------------+ |
| | vcall_offset(-16) |o----+ |
| +---------------------+ | |
| | offset_to_top(-16) | | |
| +---------------------+ | |
| | RTTI for B | | |
+--------> +---------------------+ | |
| Thunk B::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+
D 的内存模型
D VTable
+---------------------+
| vbase_offset(32) |
+---------------------+
struct D | offset_to_top(0) |
object +---------------------+
0 - struct B (primary base) | RTTI for D |
0 - vptr_B ----------------------> +---------------------+
8 - int bx | D::f0() |
16 - struct C +---------------------+
16 - vptr_C ------------------+ | vbase_offset(16) |
24 - int cx | +---------------------+
28 - int dx | | offset_to_top(-16) |
32 - struct A (virtual base) | +---------------------+
32 - vptr_A --------------+ | | RTTI for D |
40 - int ax | +---> +---------------------+
sizeof(D): 48 align: 8 | | D::f0() |
| +---------------------+
| | vcall_offset(0) |x--------+
| +---------------------+ |
| | vcall_offset(-32) |o----+ |
| +---------------------+ | |
| | offset_to_top(-32) | | |
| +---------------------+ | |
| | RTTI for D | | |
+--------> +---------------------+ | |
| Thunk D::f0() |o----+ |
+---------------------+ |
| A::bar() |x--------+
+---------------------+
为什么不全都使用 non-virtual Thunk 呢?StackOverflow 上说是为了减少 Cache Miss c++ - Why are virtual thunks necessary? - Stack Overflow
总结
虚表要解决继承带来的两个问题:
- 使用父类指针调用重写的子类函数的时候,需要调整 this
- 向下转型
解决方法:
- 将类的内存布局是嵌套,子类包着父类,构造的时候从内而外,析构的时候从外到内
- 因为内存布局是嵌套的,指针会依据自己的类型调整指向对象的具体位置,以保证在访问对象成员时候的偏移一致
- 实例内部会有一个 vptr 专门指向虚表,多继承下的 Derived 实例内部会有多个 vptr 指向不同基类的虚表,如
D->P1, D->P2
,D 内部会有两根 vptr - 多继承下,虚表内部需要
- offset_to_top:从子类到基类的偏移,用于从子类访问父类成员调整 this 指针、用于子类父类类型转换的时候调整指针
- non-virtual Thunk: 调用函数的时候调整 this
- 虚继承下,虚表内部需要:
- offset_to_top
- virtual Thunk 和 vcall_offset:不再将 this 偏移的调整放到 Thunk 中
- vbase_offset: 此时内存布局下,基类不在最顶上,所以需要各个子类到基类的偏移
面试问题
实例考查
struct A {
int a = 0;
char b;
long long c = 1;
virtual void printa() = 0;
virtual void printb() { cout << b; }
void printc() { cout << c; }
};
struct B : public A {
void printa() { cout << 1; }
void printb() { cout << 2; }
void printc() { cout << 3; }
};
void print();
int main() {
A* bar = new B();
}
问 bar->printa(); 然后 bar->printb(); 然后 bar->printc();,让对比这三次访问的速度。第一次最慢,因为要先找 vtable 再找函数地址。第二次稍快,因为 vtable 会在 cache 里。第三次最快,因为静态绑定使得函数地址就在汇编里。
sizeof(A)
在 64 位下,为 24,查看 clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts test.cpp
。
因为 vptr 占据了 8 个 bytes。
*** Dumping AST Record Layout
0 | struct A
0 | (A vtable pointer)
8 | int a
12 | char b
16 | long long c
| [sizeof=24, dsize=24, align=8,
| nvsize=24, nvalign=8]
虚表什么时候创建
编译时期构建的。
“表格中的 virtual functions 地址是如何被建构起来的?在 C++ 中,virtual functions(可经由其 class object 被调用)可以在编译时期获知。此外,这一组地址是固定不变的,执行期不可能新增或替换之。由于程序执行时,表格的大小和内容都不会改变,所以其建构和存取皆可以由编译器完全掌控,不需要执行期的任何介入。” C++中的虚函数表是什么时期建立的? - 峰子_仰望阳光 - 博客园
C++ 类的虚函数表和虚函数在内存中的位置 虚函数表指针是虚函数表所在位置的地址。虚函数表指针属于对象实例。因而通过 new 出来的对象的虚函数表指针位于堆,声名对象的虚函数表指针位于栈 虚函数表位于只读数据段(.rodata),即:C++ 内存模型中的常量区; 虚函数代码则位于代码段(.text),也就是 C++ 内存模型中的代码区
C++ virtual 函数可以被标记为 inline 吗
虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。 inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
virtual 和 构造函数、析构函数的关系
构造函数不能被标记为 virtual。IDE 也会报红的。 virtual 意思是,根据对象的真实类型调用函数,但是在对象没有被构造出来前,其没有任何类型。
析构函数建议标记为 virtual
可以在构造函数和析构函数内调用 virtual 函数,但是调用的函数是当前类型的。 因为构造过程和析构过程会修改虚表内容。 比如 Derived -> Base,在 Base 的构造函数中调用虚函数,此时 Derived 的虚表还未被建立,vtable 中存放的还是 Base 中的函数地址 。 Derived 构造函数中,Derived 虚表已经建立好了,所以此时调用的是自己函数。
class Base
{
public:
Base()
{
std::cout << "Base ctor()" << std::endl;
print();
};
virtual void print()
{
std::cout << "print in Base" << std::endl;
}
virtual ~Base()
{
std::cout << "Base dtor()" << std::endl;
print();
}
};
class Derived : public Base
{
public:
void print() {
std::cout << "print in Derived" << std::endl;
}
Derived()
{
std::cout << "Derived ctor()" << std::endl;
print();
}
~Derived()
{
std::cout << "Derived dtor()" << std::endl;
print();
}
};
int main()
{
Base *ptr = new Derived();
delete ptr;
return 0;
}
// output:
// Base ctor()
// print in Base
// Derived ctor()
// print in Derived
// Derived dtor()
// print in Derived
// Base dtor()
// print in Base
1. C++中虚函数、虚继承内存模型 - 知乎 ↩