C++ 对象模型:从内存布局到继承多态的底层原理

好的,各位观众老爷们,今天咱们来聊聊C++对象模型,这玩意儿听起来玄乎,但其实就是把C++的类和对象在内存里怎么摆放、继承怎么实现、多态怎么玩儿这些事儿给扒个精光。保证让大家听完之后,下次看到C++代码,脑子里直接浮现出内存布局图,指哪打哪,倍儿有面子!

第一部分:对象模型的基石——内存布局

首先,咱们得知道,C++的类和对象,最终都要落到实处,也就是内存里。那内存是怎么安排它们的呢?

  1. 数据成员的存储

    一个类的对象,最基本的就是要存储它的数据成员。这些数据成员在内存里是挨个排列的,顺序就是它们在类定义里出现的顺序。

    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字节。
    • 注意:内存对齐可能会影响实际的布局,稍后会讲到。
  2. 内存对齐(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) 可以设置对齐值,但要谨慎使用,因为它可能会影响程序的兼容性和性能。
  3. 静态成员的存储

    静态成员变量不属于任何一个对象,它们是属于整个类的。因此,静态成员变量存储在全局数据区,而不是在对象的内存空间里。

    class MyClass {
    public:
      static int count;
    };
    
    int MyClass::count = 0; // 初始化静态成员变量

    MyClass::count 存储在全局数据区,所有的 MyClass 对象共享同一个 count 变量。

第二部分:继承的奥秘——内存布局的变化

继承是面向对象编程的重要特性,它允许我们创建一个新的类,从现有的类继承属性和方法。那么,继承是如何影响对象的内存布局的呢?

  1. 单继承

    在单继承中,派生类的对象会包含基类的所有成员变量,并且这些成员变量会按照基类中的顺序排列在派生类对象的前面。

    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 前面。

  2. 多继承

    多继承是指一个类可以继承多个基类。在这种情况下,派生类的对象会包含所有基类的成员变量,并且这些成员变量会按照基类在派生类定义中出现的顺序排列。

    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 前面。

  3. 虚继承

    虚继承是为了解决多继承带来的菱形继承问题而引入的。菱形继承是指一个类通过多条继承路径继承了同一个基类。

    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,并且 Derived1Derived2 会共享同一个 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
    • Derived1Derived2 各自包含一个 vbp,指向 Base 类的子对象。
    • Base 类的子对象存储在 Final 对象的最后面。

    当访问 Final 对象的 base_data 成员时,编译器会通过 vbp 找到 Base 类的子对象,从而访问到 base_data 成员。

    虚继承的代价

    虚继承虽然解决了菱形继承问题,但它也带来了一些额外的开销:

    • 增加了对象的内存大小,因为需要存储 vbp
    • 增加了访问成员变量的时间开销,因为需要通过 vbp 间接访问。

    因此,应该谨慎使用虚继承,只有在真正需要解决菱形继承问题时才应该使用它。

第三部分:多态的魔法——虚函数和虚函数表

多态是面向对象编程的另一个重要特性,它允许我们使用基类的指针或引用来操作派生类的对象,并且可以根据对象的实际类型来调用不同的函数。

C++ 通过虚函数来实现多态。

  1. 虚函数

    虚函数是指在基类中声明为 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!"

    可以看到,虽然 animal1animal2 都是 Animal 类型的指针,但它们调用 speak() 函数时,会根据对象的实际类型(DogCat)来调用不同的函数。

  2. 虚函数表(Virtual Function Table,vtable)

    虚函数表是实现多态的关键。每个包含虚函数的类都会有一个虚函数表,虚函数表是一个存储虚函数指针的数组。

    当一个类包含虚函数时,编译器会在该类的对象中添加一个指向虚函数表的指针,这个指针叫做 虚指针(Virtual Pointer,vptr)

    AnimalDogCat 类的对象在内存里大致是这样的(简化版):

    • 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() 时,编译器会做以下操作:

    1. 通过 animal1 指针找到对象的 vptr
    2. 通过 vptr 找到对象的 vtable
    3. vtable 中找到 speak() 函数的指针。
    4. 调用 speak() 函数。

    由于 animal1 指向的是 Dog 类的对象,因此会调用 Dog::speak() 函数。

  3. 多重继承下的虚函数表

    如果一个类继承了多个包含虚函数的基类,那么该类会拥有多个虚函数表。

    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,分别指向 Base1Base2 的虚函数表。

  4. 虚析构函数

    如果一个类包含虚函数,那么它的析构函数也应该声明为虚函数。

    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++ 对象模型。记住,理解底层原理才能写出更牛逼的代码! 下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注