好的,各位观众老爷们,今天咱们来聊聊C++对象模型,这玩意儿听起来玄乎,但其实就是把C++的类和对象在内存里怎么摆放、继承怎么实现、多态怎么玩儿这些事儿给扒个精光。保证让大家听完之后,下次看到C++代码,脑子里直接浮现出内存布局图,指哪打哪,倍儿有面子!
第一部分:对象模型的基石——内存布局
首先,咱们得知道,C++的类和对象,最终都要落到实处,也就是内存里。那内存是怎么安排它们的呢?
-
数据成员的存储
一个类的对象,最基本的就是要存储它的数据成员。这些数据成员在内存里是挨个排列的,顺序就是它们在类定义里出现的顺序。
class Person { public: int age; double height; char name[20]; };
想象一下,
Person
类的对象在内存里就像一个柜子,age
是第一个抽屉,放着年龄;height
是第二个抽屉,放着身高;name
是第三个抽屉,放着名字。Person p; p.age = 30; p.height = 1.75; strcpy(p.name, "张三"); // 注意strcpy的使用安全
在内存中,它们就是这样排布的:
地址 内容 对应变量 0x0000 30 age
0x0004 1.75 height
0x000C "张三…" name
- 注意:这里的地址只是示意,实际地址会根据编译器和平台而变。
- 注意:这里假设
int
占4字节,double
占8字节,char
占1字节。 - 注意:内存对齐可能会影响实际的布局,稍后会讲到。
-
内存对齐(Memory Alignment)
这玩意儿是C++对象模型里一个很重要的概念。简单来说,编译器为了让CPU更高效地访问数据,会强制某些类型的数据从特定的地址开始存储。
例如,某些CPU可能更喜欢
int
类型的变量从地址是4的倍数的地方开始存储,double
类型的变量从地址是8的倍数的地方开始存储。如果编译器发现某个变量没有对齐,就会在它前面填充一些空白字节,让它对齐。
class Example { public: char a; int b; char c; };
你可能会觉得
sizeof(Example)
应该是 1 + 4 + 1 = 6 字节,但实际上它可能是 12 字节。为什么?因为int b
必须对齐到 4 的倍数,char c
也要对齐到1的倍数。内存布局可能是这样的:
地址 内容 对应变量 0x0000 a a
0x0001 Padding 0x0002 Padding 0x0003 Padding 0x0004 b b
0x0008 c c
0x0009 Padding 0x000A Padding 0x000B Padding a
占 1 字节。- 为了让
b
从 4 的倍数地址开始,编译器在a
后面填充了 3 字节。 b
占 4 字节。c
占 1 字节。- 为了让结构体的总大小是最大对齐值的倍数(这里是
int
的4),编译器在c
后面填充了3个字节,使得整个结构体大小为12字节。
如何避免内存对齐带来的空间浪费?
- 合理安排成员变量的顺序,尽量把相同类型的变量放在一起。
- 使用
#pragma pack(n)
可以设置对齐值,但要谨慎使用,因为它可能会影响程序的兼容性和性能。
-
静态成员的存储
静态成员变量不属于任何一个对象,它们是属于整个类的。因此,静态成员变量存储在全局数据区,而不是在对象的内存空间里。
class MyClass { public: static int count; }; int MyClass::count = 0; // 初始化静态成员变量
MyClass::count
存储在全局数据区,所有的MyClass
对象共享同一个count
变量。
第二部分:继承的奥秘——内存布局的变化
继承是面向对象编程的重要特性,它允许我们创建一个新的类,从现有的类继承属性和方法。那么,继承是如何影响对象的内存布局的呢?
-
单继承
在单继承中,派生类的对象会包含基类的所有成员变量,并且这些成员变量会按照基类中的顺序排列在派生类对象的前面。
class Base { public: int base_data; }; class Derived : public Base { public: int derived_data; };
Derived
类的对象在内存里是这样的:地址 内容 对应变量 0x0000 base_data Base::base_data
0x0004 derived_data Derived::derived_data
可以看到,
Base
的成员变量base_data
排在Derived
的成员变量derived_data
前面。 -
多继承
多继承是指一个类可以继承多个基类。在这种情况下,派生类的对象会包含所有基类的成员变量,并且这些成员变量会按照基类在派生类定义中出现的顺序排列。
class Base1 { public: int base1_data; }; class Base2 { public: int base2_data; }; class Derived : public Base1, public Base2 { public: int derived_data; };
Derived
类的对象在内存里是这样的:地址 内容 对应变量 0x0000 base1_data Base1::base1_data
0x0004 base2_data Base2::base2_data
0x0008 derived_data Derived::derived_data
可以看到,
Base1
的成员变量base1_data
排在Base2
的成员变量base2_data
前面,而Base2
的成员变量base2_data
排在Derived
的成员变量derived_data
前面。 -
虚继承
虚继承是为了解决多继承带来的菱形继承问题而引入的。菱形继承是指一个类通过多条继承路径继承了同一个基类。
class Base { public: int base_data; }; class Derived1 : public Base { public: int derived1_data; }; class Derived2 : public Base { public: int derived2_data; }; class Final : public Derived1, public Derived2 { public: int final_data; };
如果没有虚继承,
Final
类的对象会包含两份Base
类的成员变量base_data
,这会导致二义性问题。为了解决这个问题,可以使用虚继承:
class Base { public: int base_data; }; class Derived1 : virtual public Base { public: int derived1_data; }; class Derived2 : virtual public Base { public: int derived2_data; }; class Final : public Derived1, public Derived2 { public: int final_data; };
通过虚继承,
Final
类的对象只会包含一份Base
类的成员变量base_data
,并且Derived1
和Derived2
会共享同一个Base
类的子对象。虚继承的实现原理
虚继承的实现原理比较复杂,它需要在每个虚继承的派生类对象中添加一个指向虚基类子对象的指针,这个指针叫做 虚基类指针(Virtual Base Pointer,vbp)。
Final
类的对象在内存里大致是这样的(简化版):地址 内容 对应变量 0x0000 vbp Derived1
的虚基类指针0x0004 derived1_data Derived1::derived1_data
0x0008 vbp Derived2
的虚基类指针0x000C derived2_data Derived2::derived2_data
0x0010 final_data Final::final_data
0x0014 base_data Base::base_data
Derived1
和Derived2
各自包含一个vbp
,指向Base
类的子对象。Base
类的子对象存储在Final
对象的最后面。
当访问
Final
对象的base_data
成员时,编译器会通过vbp
找到Base
类的子对象,从而访问到base_data
成员。虚继承的代价
虚继承虽然解决了菱形继承问题,但它也带来了一些额外的开销:
- 增加了对象的内存大小,因为需要存储
vbp
。 - 增加了访问成员变量的时间开销,因为需要通过
vbp
间接访问。
因此,应该谨慎使用虚继承,只有在真正需要解决菱形继承问题时才应该使用它。
第三部分:多态的魔法——虚函数和虚函数表
多态是面向对象编程的另一个重要特性,它允许我们使用基类的指针或引用来操作派生类的对象,并且可以根据对象的实际类型来调用不同的函数。
C++ 通过虚函数来实现多态。
-
虚函数
虚函数是指在基类中声明为
virtual
的函数。当派生类重写(override)了基类的虚函数时,就可以实现多态。class Animal { public: virtual void speak() { std::cout << "Animal speak" << std::endl; } }; class Dog : public Animal { public: void speak() override { std::cout << "Woof!" << std::endl; } }; class Cat : public Animal { public: void speak() override { std::cout << "Meow!" << std::endl; } };
Animal* animal1 = new Dog(); Animal* animal2 = new Cat(); animal1->speak(); // 输出 "Woof!" animal2->speak(); // 输出 "Meow!"
可以看到,虽然
animal1
和animal2
都是Animal
类型的指针,但它们调用speak()
函数时,会根据对象的实际类型(Dog
和Cat
)来调用不同的函数。 -
虚函数表(Virtual Function Table,vtable)
虚函数表是实现多态的关键。每个包含虚函数的类都会有一个虚函数表,虚函数表是一个存储虚函数指针的数组。
当一个类包含虚函数时,编译器会在该类的对象中添加一个指向虚函数表的指针,这个指针叫做 虚指针(Virtual Pointer,vptr)。
Animal
、Dog
和Cat
类的对象在内存里大致是这样的(简化版):-
Animal 对象:
地址 内容 对应变量 0x0000 vptr Animal
类的 vtable:索引 内容 0 Animal::speak()
-
Dog 对象:
地址 内容 对应变量 0x0000 vptr Dog
类的 vtable:索引 内容 0 Dog::speak()
-
Cat 对象:
地址 内容 对应变量 0x0000 vptr Cat
类的 vtable:索引 内容 0 Cat::speak()
当调用
animal1->speak()
时,编译器会做以下操作:- 通过
animal1
指针找到对象的vptr
。 - 通过
vptr
找到对象的vtable
。 - 在
vtable
中找到speak()
函数的指针。 - 调用
speak()
函数。
由于
animal1
指向的是Dog
类的对象,因此会调用Dog::speak()
函数。 -
-
多重继承下的虚函数表
如果一个类继承了多个包含虚函数的基类,那么该类会拥有多个虚函数表。
class Base1 { public: virtual void func1() { std::cout << "Base1::func1" << std::endl; } }; class Base2 { public: virtual void func2() { std::cout << "Base2::func2" << std::endl; } }; class Derived : public Base1, public Base2 { public: void func1() override { std::cout << "Derived::func1" << std::endl; } void func2() override { std::cout << "Derived::func2" << std::endl; } };
Derived
类的对象在内存里大致是这样的(简化版):地址 内容 对应变量 0x0000 vptr1 0x0004 vptr2 Derived
类的 vtable1 (对应Base1
):索引 内容 0 Derived::func1()
Derived
类的 vtable2 (对应Base2
):索引 内容 0 Derived::func2()
Derived
类会包含两个vptr
,分别指向Base1
和Base2
的虚函数表。 -
虚析构函数
如果一个类包含虚函数,那么它的析构函数也应该声明为虚函数。
class Base { public: virtual ~Base() { std::cout << "Base destructor" << std::endl; } }; class Derived : public Base { public: ~Derived() override { std::cout << "Derived destructor" << std::endl; } };
Base* base = new Derived(); delete base; // 输出 "Derived destructor" 和 "Base destructor"
如果
Base
类的析构函数不是虚函数,那么delete base
只会调用Base
类的析构函数,而不会调用Derived
类的析构函数,这会导致内存泄漏。为什么需要虚析构函数?
当使用基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类对象中分配的资源无法被释放,从而造成内存泄漏。
将基类的析构函数声明为虚函数,可以确保在删除基类指针指向的派生类对象时,会先调用派生类的析构函数,然后再调用基类的析构函数,从而正确地释放派生类对象中分配的资源。
总结
C++ 对象模型是一个复杂而精妙的系统,它涉及到内存布局、继承和多态等多个方面。理解 C++ 对象模型对于编写高效、可靠的 C++ 代码至关重要。
- 内存布局: 了解对象在内存中的存储方式,包括数据成员的排列顺序、内存对齐等。
- 继承: 掌握单继承、多继承和虚继承的内存布局,以及虚继承的实现原理和代价。
- 多态: 理解虚函数和虚函数表的作用,以及虚析构函数的重要性。
希望今天的讲座能够帮助大家更好地理解 C++ 对象模型。记住,理解底层原理才能写出更牛逼的代码! 下课!