(二)C++对象内存模型那些事儿:类的继承和多态
(二)C++对象内存模型那些事儿:类的继承和多态
1. 关于多态
多态是面向对象编程的一个重要特性,它允许我们使用一个接口来表示多种形态的对象。多态的主要优点是它可以提高代码的可重用性和可扩展性。通过使用多态,我们可以编写出更加通用的代码,这些代码可以处理任何符合特定接口的对象,而不需要关心对象的具体类型。这使得我们可以更容易地添加新的类型,而不需要修改已有的代码。 C++ 实现多态主要有两种途径:静态多态(编译时多态)和动态多态(运行时多态)。
而一般来说,只有指针和引用的类型能够实现运行时多态。深层次的直接原因是出于内存安全考虑,虚函数指针不会被拷贝。后面会展开。
1.1 静态多态
静态多态在编译期间就能确定调用哪个函数,也可以叫静态绑定(早绑定),在这个阶段,编译器就能确定所有数据成员的确切类型、大小和内存位置。主要包括以下形式:
-
函数重载(Function Overloading): 允许在同一作用域内使用相同的函数名,但参数列表必须不同(类型、数量或顺序)。编译器根据传入参数的类型和数量来决定调用哪个版本的函数。
对于函数和运算符来说,静态多态指的是同名的函数或相同的运算符能够自动选择合适的函数进行调用。一般来说,静态多态强调的是同一个作用域中函数名相同的情况。如果在两个独立的类 A 和 B 中都有
Foo()
方法,那么A::Foo()
和B::Foo()
不属于静态多态的概念,因为这两个Foo()
函数在不同的作用域下,只需要在各自的作用域中进行符号解析即可匹配。编译器处理函数调用的流程如下:
- 符号解析:编译器在解析函数调用时,会根据函数名和参数列表查找所有可能的函数定义。
- 重载决议:编译器根据参数类型和数量选择最匹配的函数。如果有多个匹配,编译器会根据重载决议规则选择最佳匹配。
- 生成代码:编译器生成调用选定函数的代码。
函数和运算符的静态多态主要指的是第二阶段的操作,这部分实现只需要编译器会根据参数类型和数量选择最匹配的函数即可。
-
模板(Templates): 泛型编程的一种形式,允许编写与类型无关的代码。编译器根据传递给模板的具体类型生成具体的函数或类实例,实现了编译时的多态性。
模板的静态多态性体现在编译时通过模板实例化生成具体类型的代码。这种多态性在编译时确定,而不是在运行时,因此被称为静态多态。与函数和运算符的静态多态不完全是同一个概念。函数和运算符的静态多态指的是在同一个作用域下,在编译期间就能找到合适的函数进行调用;而模板的静态多态则是在实例化后,在编译期间就能找到合适的函数进行调用。
编译器处理模板函数调用的流程如下:
- 模板定义:编译器首先解析模板定义,但不生成代码。
- 模板实例化:当模板被具体类型使用时,编译器根据具体类型实例化模板,生成相应的代码。
- 类型检查:编译器在实例化模板时进行类型检查,确保模板代码对具体类型有效。
- 生成代码:编译器生成实例化后的模板代码。
模板的多态主要指的是第二阶段的过程,只需要知道实现是依赖于编译器对模板实例化即可。
简单来说就是,对于函数重载,编译器能够根据参数列表知道实际调用的是哪一个函数,从而生成正确的汇编代码;对于模板来说,编译器能够根据类型参数知道实际调用的是哪一个函数,从而生成正确的汇编代码。
1.2 动态多态
1.2.1 动态多态的形式
动态多态则是在程序运行时决定调用哪个函数,也可以叫叫做动态绑定(晚绑定),主要依赖于虚函数机制:在基类中声明函数为虚函数(使用 virtual 关键字),并在派生类中重写(Override)这些函数。通过基类的指针或引用指向派生类对象,调用虚函数时,会根据对象的实际类型动态地调用相应的函数实现。
1.2.2 动态多态(虚函数)是如何实现的?
在 C++ 中,虚函数的实现主要依赖于虚函数表(也称为 vtable)。每一个有虚函数的类,编译器都会为其生成一个虚函数表,表是一个函数指针数组,表中包含了该类及其基类的所有虚函数地址。每一个该类的对象,都会有一个指向虚函数表的指针(通常称为 vptr)。
虚表的构造和虚指针的初始化通常发生在对象构造时。当一个对象被创建时,编译器会自动将该对象的 vptr 初始化为指向该类的虚函数表。
当我们通过基类指针调用虚函数时,实际上是通过这个指针找到虚函数表,然后在表中查找并调用对应的函数。编译时就能确定这个虚函数的偏移地址,在运行时的时候,就会去查看当前对象的虚函数指针,根据虚函数指针找到对应的虚函数表,基于编译时确定的偏移地址去调用。
如果 Foo 类是父类,Bar1 和 Bar2 是子类,而 func()是 Foo 类非纯虚函数的时候。那么就会有分别对应的三个虚函数表(Foo, Bar1,Bar2 各一个)。对象实例化的时候就会有一个指针指向一个虚函数表,虚函数表里有一个 Foo 类函数地址。这个时候不管静态解析类型是什么,比如说是 Foo 类,但调用 func()方法的时候,因为编译器知道 func()是虚函数方法。就都是通过虚函数指针找到实际调用对象。
1.2.3 虚函数调用例子
我们定义了一个基类Shape
和一个派生类Circle
。基类中定义了一个虚函数draw
,派生类中重写了这个函数。在main
函数中,我们创建了一个Circle
对象,然后通过一个Shape
类型的指针来调用draw
函数。由于draw
函数是虚函数,所以实际调用的是Circle
类的draw
函数,而不是Shape
类的draw
函数。
以下是一个简单的图示,展示了虚函数表的工作原理:
Shape object: Circle object: Shape vtable: Circle vtable:
+-------------+ +-------------+ +-------------+ +-------------+
| vptr | | vptr | | draw() | | draw() |
| ... | | ... | | ... | | ... |
+-------------+ +-------------+ +-------------+ +-------------+
| | | |
| | v v
| | Shape::draw() Circle::draw()
| |
v v
Shape::vtable Circle::vtable
在这个图示中,Shape
对象和Circle
对象都有一个vptr
,这是一个指向虚函数表的指针。Shape
的vptr
指向Shape
的虚函数表,Circle
的vptr
指向Circle
的虚函数表。虚函数表中存储了虚函数的地址,所以当我们通过Shape
指针调用draw
函数时,实际上是通过vptr
找到虚函数表,然后在表中查找并调用对应的函数。
简单来说,
- 每一个有虚函数的对象下的都会有一个
vtable
,如上面的Shape vtable
和Circle vtable
. - 每一个从有虚函数的对象下来的实例都会有一个
vptr
,vptr
指向vtable
, 如上面的Shape object
和Circle object
. - 当我对某一个
Shape object
调用draw()
函数时,查询方式都是通过vptr
找到vtable
的draw()
。
1.2.4 动态多态导致类对象的内存布局改变
-
无动态多态时的内存布局(即当一个类不包含虚函数时)
- 对象头部:通常只包含直接的数据成员。对象的大小直接由其数据成员的总大小决定,加上可能的 padding(用于对齐)。
-
访问速度:因为函数调用是静态绑定的,编译器在编译时期就能确定调用哪个函数,因此访问速度快。
-
含有动态多态时的内存布局 当一个类包含虚函数或继承自含有虚函数的基类时:
- 虚函数表指针(vptr):对象内存布局中会额外包含一个指向虚函数表(vtbl)的指针。这个 vptr 通常位于对象的最开始位置,但这也取决于具体的编译器实现。
- 虚函数表(vtbl):不在对象实例内,而是在类的内存区域。它存储了该类及其基类中所有虚函数的地址。
- 对象大小:由于增加了 vptr,对象的总大小会比无多态时增加(通常是一个指针大小,如 4 字节或 8 字节)。
- 访问速度:虚函数调用需要通过 vptr 间接访问虚函数表,再根据表中地址调用实际函数,因此相对于静态绑定,动态调用会有一定的性能开销。
- 多态行为:通过基类指针或引用来调用虚函数时,能够根据对象的实际类型执行相应的派生类函数,实现了运行时的多态性。
1.2.5 指针和引用才能多态
动态绑定:当使用基类的指针或引用指向派生类对象时,通过这些指针或引用调用虚函数时,实际执行的是派生类中重写的函数。这是因为编译器在运行时根据对象的实际类型(而不是引用或指针的类型)来决定调用哪个函数,这就是所谓的动态绑定或晚期绑定。
void playSound(const Animal& animal) {
animal.makeSound(); // 动态绑定,调用实际对象的 makeSound 方法
}
Animal* animalPtr1 = new Dog();
animalPtr1->makeSound(); // 动态绑定,调用 Dog::makeSound
playSound(*animalPtr1); // 动态绑定,调用 Dog::makeSound
Animal animalPtr2 = *animalPtr2;
animalPtr2.makeSound(); // Animal::makeSound
playSound(*animalPtr1); // Animal::makeSound
1.2.6 总结
-
虚函数表指针与虚函数表
- 虚函数表指针(vptr):每个包含至少一个虚函数的类的实例对象中,都会有一个隐含的指针,这个指针称为虚函数表指针。它通常位于对象内存布局的起始位置。这个指针指向该对象所属类的虚函数表。
- 虚函数表:虚函数表是一个存储函数指针的数组,这些函数指针分别指向类中声明为虚的成员函数。这些函数可以是本类定义的,也可以是从基类继承而来并通过虚继承覆盖的。虚函数表中的函数地址按照声明的顺序排列。
- 虚函数地址存储:在编译阶段,编译器会为每个包含虚函数的类生成一个虚函数表,并将这些虚函数的地址填入表中相应的位置。当对象实例化时(对象创建时),其虚函数表指针会被初始化为指向正确的虚函数表。
-
虚函数表指针位置 虚函数表指针位于对象的内存的开头还是末尾取决于编译器的实现。但主流实践和预期是 vptr 位于对象内存的开始位置,如 MSVC 和 g++。
-
虚函数表分析
- 一个类只有包含虚函数才会存在虚函数表,同属于一个类的实例化对象共享同一个虚函数表。每个对象的 vptr(虚函数表指针),所指向的地址(虚函数表首地址)相同。
- 虚函数表存储在程序的只读数据段(.rodata 段)中。这是因为虚函数表的内容在程序运行期间是不变的,它包含了类中虚函数的地址,这些地址在编译时期就已经确定,并且不会随着程序的运行而改变。将虚函数表置于只读数据段有助于保护其不被意外修改,同时也有利于内存管理,因为这部分内存通常被映射为不可写,提升了程序的安全性。
- 子类会继承父类中的虚函数,即在父类是虚函数,子类不显示声明为虚函数,依然是虚函数。
- 当一个子类继承自一个具有虚函数的父类时,编译器会为子类生成一个新的虚函数表,其中包含父类虚函数的地址(如果没有被子类重写的话)。如果子类重写了父类的某个或某些虚函数,子类的虚函数表中对应项会被更新,指向子类中重写后函数的地址,以确保多态行为能正确实现——即通过基类指针或引用来调用函数时,会调用到子类中实际重写的方法。
2. 关于继承
2.1 一般类成员数据和类成员函数
一般父类的的成员数据和成员函数可以看成就是子类的,无区别,只是一般可能是先父类内存布局,再到子类内存布局这样子。
至于public
, private
, protected
都是指导编译器和限制开发者的,实际上无法和汇编底层代码建立直接的抽象。只是访问成员的时候,相当于编译器层面做一下if-else
判断。
2.2 this 调整与切割
“切割”(Object Slicing)问题:当将一个派生类对象赋值给基类对象或以基类对象的方式传递时,派生类特有的部分(即超出基类的部分)将会被“切掉”,因为基类对象没有足够的空间来容纳派生类的额外数据成员。这种现象称为“对象切片”。
#include <iostream>
class Animal {
public:
Animal() { std::cout << "Animal constructor" << std::endl; }
Animal(const Animal& other) {
std::cout << "Animal copy constructor" << std::endl;
}
virtual void eat() { std::cout << "Animal eats" << std::endl; }
};
class Dog : public Animal {
public:
Dog() { std::cout << "Dog constructor" << std::endl; }
void eat() override { std::cout << "Dog eats" << std::endl; }
};
int main() {
Animal dog = Dog(); // 对象切片,调用 Animal 的拷贝构造函数
dog.eat(); // 调用 Animal::eat()
return 0;
}
/*
1. 为什么调用的是animal::eat()方法?
2. Animal dog = Dog();这个过程发生了什么?
抛开Dog的构造过程,Animal dog = Dog(),这个过程是拷贝构造,调用了Animal的拷贝构造函数。
由于 `Animal` 类的拷贝构造函数的形参是 `const Animal&`,因此需要将 `Dog` 对象转换为 `Animal` 对象。
也就是说dog首先会发生一次隐式转换。
在这个过程中Dog()的部分会被切掉,只剩下Animal的部分,被拷贝到dog中,
因为dog在栈上申请内存的时候是按照Animal的大小申请的,dog的内存布局是按照Animal来的。
所以切掉Dog()的部分,只剩下Animal的部分是显然的。
这个过程就是对象切片。
根据结果我们知道,虚函数指针也被切掉了。那为什么虚函数指针也被切掉了呢?
如果派生类有虚函数指针的时候,父类没有虚函数指针,因此Dog的虚函数指针被切掉了。是很好理解的。
可如果父类有虚函数指针,为什么不能直接拷贝子类的虚函数指针呢?
这是为了安全考虑,编译器禁止了这种行为。
如果子类的虚函数指针被拷贝到父类的对象中,那么在这个对象就可以调用子类的虚函数。
这个时候子类的虚函数有可能是使用了子类特有的类成员变量的,但是父类的对象中并没有这个成员变量,因此会出现问题的。
因此编译器在设计的时候,为了避免这个问题,直接将子类的虚函数指针给切掉了。
*/
2.3 多继承的时候,虚函数怎么处理的?
在 C++的多继承中,每个基类都有自己的虚函数表。当一个类从多个基类继承时,它会有多个虚函数表指针,每个指针指向一个基类的虚函数表。当我们通过基类指针调用虚函数时,会根据指针的类型找到对应的虚函数表,然后在表中查找并调用对应的函数。
一般情况下,如果有多继承,且父类都是有虚函数的话,就会有多个vptr
。编译器生成代码的时候也能够知道用哪个vptr
,忽略编译器额外时间开销的话,多继承和单一继承的虚函数调用开销是一样的。
3. 关于虚继承
3.1 虚继承是什么?有什么用?
虚继承是 C++中的一种特殊的继承方式,主要用于解决多继承中的菱形继承问题。在菱形继承中,如果不使用虚继承,那么最底层的派生类会继承多份基类的数据和方法,这会导致资源的浪费和访问的歧义。而解决菱形继承的,关键思想在于保证父类数据的唯一。 为了实现父类数据的唯一,派生类都不直接持有父类数据,而是通过一个指针找到父类数据。 这个指针就是 vbptr,父类数据则存储在 vbtable 表中。当出现菱形继承的时候,则会有两个 vbptr 指针。编译器会发现这两个指针指向同一个表地址,就优化为一个指针。 这样子就可以保证数据唯一了。
以下是一个不使用虚继承的菱形继承例子,这将导致编译错误:
class Base { public: int x; };
class Derived1 : public Base { };
class Derived2 : public Base { };
class MostDerived : public Derived1, public Derived2 { };
int main() {
MostDerived md;
md.x = 10; // 编译错误:MostDerived中有两份Base::x,编译器无法确定应该访问哪一份
return 0;
}
在这个例子中,Derived1
和Derived2
都继承了Base
,所以在MostDerived
中有两份Base::x
。当我们试图访问md.x
时,编译器无法确定我们应该访问哪一份Base::x
,所以会报错。
这个问题可以通过使用虚继承来解决。虚继承会让从多个路径继承来的同一个基类,在派生类中只保留一份拷贝。这样,就不会出现上述的编译错误,因为在MostDerived
中只有一份Base::x
。但是如果不调用md.x
,是可以通过的。我猜这个时候,md
是有两份x
未初始数据的。只要不调用md.x
导致编译器无法确定使用哪一个,就没问题。
3.2 虚继承的原理是什么?
class A { int a; };
class B : virtual A { int b; };
class C : virtual A { int c; };
class D : public B, public C { int d; };
对于 B 和 C 来说,因为是虚继承,因此 A 的数据就不直接在 B 和 C 了。
3.3 虚继承下的虚函数
虚继承下的虚函数里涉及复杂的指针调整,浅尝辄止,了解即可。
在虚继承场景下,虚函数的调用机制本身并未发生根本性变化,依旧基于 vptr 和 VTable 来实现动态多态。区别在于,虚继承通过引入 vbptr 和 vbtable 来解决继承路径中的共享基类实例问题,这不影响虚函数的调用流程,但影响了基类数据成员的访问和构造/析构过程。理解这一点对于设计和维护复杂的类继承结构至关重要。
99 quiz
1. 当对象调用一个普通成员函数,和调用一个虚函数,编译器是怎么区别对待的?
当对象调用一个普通成员函数和调用一个虚函数时,编译器的处理方式是不同的。
- 对于普通成员函数,编译器在编译时就能确定函数的地址,所以在生成的汇编代码中,函数调用会直接转换为对应的函数地址。
- 对于虚函数,编译器在编译时不能确定函数的地址,因为虚函数的调用需要在运行时通过虚函数表来确定。所以在生成的汇编代码中,函数调用会转换为通过虚函数表来查找函数地址。
2. 调用虚函数的时候,是通过 vptr 找到对应的虚函数表,再调用实际函数。那么虚函数表很大的时候,开销会增加吗?
虽然说调用实际函数是在虚函数表找的,但是这个虚函数在表中的位置是固定的。编译器在编译时已经确定了每个虚函数在虚函数表中的索引。因此运行的时候不需要遍历表找到方法,而是编译期间的时候就能确定确定了。换句话说,同一继承体系下,不同类的相同虚函数在虚函数表的偏移地址都是一样的。运行时都是直接用偏移地址,而不是遍历找到虚函数的。
但是虚函数表很大,也许会对编译开销有影响;运行时偏移地址很大,有可能会内存缓存不友好。但这些都相对不重要。可忽略
3. 怎么理解调用虚函数的开销?什么时候需要考虑?
非内联函数的直接调用大概是 45-90ns 级别。如果是虚函数则大概是 90-180ns 级别。除此之外,一般函数直接调用的时候分支预测和指令预取命中率会更高。虚函数的间接调用是不利于优化的。这部分的开销也需要考虑的。
但总的而言,这个开销是固定的,如果一个函数不考虑纳秒级别的优化,就不需要考虑虚函数带来的影响。如果是到了纳秒级别优化的时候,也建议实际测一下开销,测了才能知道虚函数的开销是否不可接受。
4. 对于非多态类型和多态类型,如何获取类型信息(type_info)?
-
对于非多态类型(即没有虚函数的类),类型信息(type_info)通常可以通过编译时的类型信息直接获取,不需要通过虚拟表(vtable)来访问。
-
对于多态类型(即包含至少一个虚函数的类),每个对象会有一个虚拟表(vtable),其中包含了指向该类型 type_info 对象的指针。这样,可以通过对象的虚拟表在运行时动态地访问到其类型信息。
5. C++多继承的时候,如何处理同名成员变量?同名成员函数?
如果两个基类有同名的成员变量或成员函数,那么在派生类中需要通过作用域解析运算符(::)来指定要访问哪个基类的成员。
如果是直接对派生类访问两个基类同名的成员变量,就会报错。
6. 如果一个类多继承,且父类都有虚函数,那这个类有几个虚表?
一个。
- 派生类整合所有基类和自己的虚函数到一个单一的虚函数表中。
- 如果存在多重继承,派生类实例在内存中会为每个基类保持一个子对象,这些子对象的起始位置可能包含一个虚函数指针(如果相应的基类有虚函数)。这些不同的虚函数指针虽然都指向同一个虚函数表,但它们在表中的偏移量可能不同,这是因为每个基类的虚函数在表中的排列考虑到了多继承的顺序和各自的虚函数集合。
- 编译器通过这些虚函数指针和潜在的偏移量调整,确保当通过不同基类的指针调用虚函数时,能够正确地定位到派生类中覆写的函数或者基类的函数。
7. 继承可以理解为两种,接口继承和实现继承?
- 接口继承:简单来说就是有虚函数,存在多态行为的;
- 实现继承:简单来说就是父类没有虚函数,是通过继承方式做组合的。当通过继承做组合的时候,和普通成员变量做组合的区别在于被组合函数是否需要直接对外。
8. todo-多继承的时候,子类指针能转为父类指针吗?
9. todo-理解虚函数、多重继承、虚基类以及 RTTI 所带来的开销
C++的特性和编译器会很大程度上影响程序的效率,所以我们有必要知道编译器在一个 C++特性后面做了些什么事情。
例如虚函数,指向对象的指针或者引用的类型是不重要的,大多数编译器使用的是 virtual table(vtbl)和 virtual table pointers(vptr)来进行实现
vtbl:
class C1{
public:
C1();
virtual ~C1();
virtual void f1();
virtual int f2(char c)const;
virtual void f3(const string& s);
void f4()const
}
vtbl 的虚拟表类似于下面这样,只有虚函数在里面,非虚函数的 f4 不在里面:
___
|___| → ~C1()
|___| → f1()
|___| → f2()
|___| → f3()
如果按照上面的这种,每一个虚函数都需要一个地址空间的话,那么如果拥有大量虚函数的类,就会需要大量的地址存储这些东西,这个 vtbl 放在哪里根据编译器的不同而不同
vptr:
__________
|__________| → 存放类的数据
|__________| → 存放vptr
每一个对象都只存储一个指针,但是在对象很小的时候,多于的 vptr 将会看起来非常占地方。在使用 vptr 的时候,编译器会先通过 vptr 找到对应的 vtbl,然后通过 vtbl 开始找到指向的函数事实上对于函数:
pC1->f1();
他的本质是:
(*pC1->vptr[i])(pC1);
在使用多继承的时候,vptr 会占用很大的地方,并且非常恶心,所以不要用多继承
RTTI:能够让我们在 runtime 找到对象的类信息,那么就肯定有一个地方存储了这些信息,这个特性也可以使用 vtbl 实现,把每一个对象,都添加一个隐形的数据成员 type_info,来存储这些东西,从而占用很大的空间
如果一个函数声明在 A.h 且没有用 inline 修饰,定义在 A.cpp,在 B.cpp 使用。那么这个函数在 B.cpp 使用的时候能否被内联展开优化?
如果一个函数声明在 A.h 且使用 inline 修饰,那么在 B.cpp 使用的时候,这个函数在 B.cpp 能否不被内联展开优化?如果能的话,这个函数定义被实际放在哪里?如何实现的?
10. todo-使构造函数和非成员函数具有虚函数的行为**
class NewsLetter{
private:
static NLComponent *readComponent(istream& str);
virtual NLComponent *clone() const = 0;
};
NewsLetter::NewsLetter(istream& str){
while(str){
components.push_back(readComponent(str));
}
}
class TextBlock: public NLComponent{
public:
virtual TextBlock*clone()const{
return new TextBlock(*this);
}
}
在上面那段代码当中,readComponent 就是一个具有构造函数行为(因为能够创建出新的对象)的函数,我们叫做虚拟构造函数
clone() 叫做虚拟拷贝构造函数,相当于拷贝一个新的对象
通过这种方法,我们上面的 NewsLetter 构造函数就可以这样:
NewsLetter::NewsLetter(const NewsLetter& rhs){
while(str){
for(list<NLComponent*>::const_iterator it=rhs.component.begin(); it!=rhs.component.end();it++){
components.push_back((*it)->clone());
}
}
}
Enjoy Reading This Article?
Here are some more articles you might like to read next: