聊一聊C++类的内存布局

774 阅读8分钟

关于 this

多数人都知道的是, C++ 中的每一个对象都可以使用 this 指针来访问自己的地址, 但是可能没有考虑过以下问题:

  1. 空类 class 的大小为什么是 1 byte?
  2. this 既然叫做指针, 保存在哪里 ? 占用类对象的空间大小吗 ?
  3. 返回对象指针的时候, this*this 的区别 ?
  4. 为什么 this 指针可以指向 类对象的地址 ?

空类

1. 空类的大小为 1 byte

注意: 平时讲的 类的大小 其实就是 类的实例化的对象所占的空间大小 ! 类本身只是一种 类型定义, 属于特殊的结构体, 本身并没有大小可言, 所以 sizeof() 运算符对一个类型名操作, 得到的是具有该类型实体的大小

#include <iostream>
using namespace std;

class Empty {

};

struct Empty2 {

};


int main(int argc, char **argv) {
    Empty obj_empty1;
    Empty obj_empty2;
    Empty2 struct_empty;
    std::cout << sizeof(obj_empty1) << std::endl;    // 1
    std::cout << sizeof(Empty) << std::endl;         // 1

    // &obj_empty1 = 0x7fff9402efae
    std::cout << "&obj_empty1 = " << &obj_empty1 << std::endl;
    // &obj_empty2 = 0x7fff9402efaf
    std::cout << "&obj_empty2 = " << &obj_empty2 << std::endl;

    std::cout << sizeof(struct_empty) << std::endl;  // 1
    std::cout << sizeof(Empty2) << std::endl;        // 1

}

在 C++ 中空类会占用 1 个 byte, 是为了让对象的实例可以互相区分, 也就是说, 空类同样可以被实例化, 并且每一个实例在内存中都有独一无二的地址, 因此, 编译器会给空类隐含加上 1 个byte, 这样空类实例化之后就会拥有独一无二的内存地址,

如果没有这一个字节的占位, 空类就无所谓实例化了, 因为实例化的过程就是在内存中分配一块地址.

但是值得注意的是:

如果一个空类作为基类, 该类的大小就会优化为 0. 即 "空白基类最优化"

2. 空白基类最优化

/***********************************************************************************************************************
 * @swcomponent
 * @file         main.cpp
 * @date         2023/2/14
 * @brief        探究 C++ 中空类的大小
 * @author       ZHANG Hao (snowzhang183@126.com)
 **********************************************************************************************************************/

#include <iostream>
using namespace std;

class EmptyBase1 {

};

// 1. 单继承中的空白基类最优化问题
class EmptySingleDerived : public EmptyBase1 {
public:
    int a;
protected:
private:
};

class EmptyBase2 {

};

// 2. 多继承中的空白基类最优化问题
class EmptyMultipleDerived : public EmptyBase1, EmptyBase2 {
public:
    int a;
protected:
private:
};


int main(int argc, char **argv) {
    // 单继承
    EmptyBase1 empty_base1;
    EmptySingleDerived empty_derived1 {};

    std::cout << "sizeof(empty_base)        = " << sizeof(empty_base1) << std::endl;
    // sizeof(empty_derived1) = 4 而不是 5 因为存在空白基类优化
    std::cout << "sizeof(empty_derived1)    = " << sizeof(empty_derived1) << std::endl;
    // address of empty_base1    = 0x7fff7e007d5e
    std::cout << "address of empty_base1    = " << &empty_base1 << std::endl;
    // address of empty_derived1 = 0x7fff7e007d60
    std::cout << "address of empty_derived1 = " << &empty_derived1 << std::endl;

    // 多继承
    EmptyBase2 empty_base2;
    EmptyMultipleDerived empty_derived2 {};
    std::cout << "sizeof(empty_base2)          = " << sizeof(empty_base2) << std::endl;
    std::cout << "sizeof(empty_derived2)       = " << sizeof(empty_derived2) << std::endl;
    // address of empty_base2    = 0x7fff7e007d5f
    std::cout << "address of empty_base2    = " << &empty_base2 << std::endl;
    // address of empty_derived2 = 0x7fff7e007d64
    std::cout << "address of empty_derived2 = " << &empty_derived2 << std::endl;

    return 0;
}

一个对象的大小 >= 所有非静态成员(non static) 成员大小的总和

this 指针

一个对象的 this 指针并不是对象本身的一部分, 不会影响 sizeof(obj) 的大小 !

this 的作用域是在类的内部, 当在类的 非静态成员函数中访问类的非静态成员时, 编译器会自动将对象本身的地址 作为一个隐含参数传递给函数. (这句话非常非常重要)

1. this 指针的作用

  1. 在类的 非静态成员函数中 return 类对象本身的时候, 直接使用 return *this
  2. 参数和成员变量名相同时, this->num = num;

为什么 return *this; 表示的是类对象本身 ?

图片.png

所以 *this 其实就是类中的第一个数据成员 !

/***********************************************************************************************************************
 * @swcomponent
 * @file         main.cpp
 * @date         2023/2/7
 * @brief        探究 this,  *this,  &this 的区别和作用
 * @author       ZHANG Hao (snowzhang183@126.com)
 **********************************************************************************************************************/

#include <iostream>
using namespace std;

/**
 *                                   a (a 是这一块内存的名称), a.c 就可以取到变量 c
 * *this 是浅拷贝, 不改变原来
 * &this 是对象自身
 *
 */

class Test {
public:
    char c;
    int d;

    Test *GetThis() {
        printf("GetThis      : Address of this           = %p\r\n", this);
        return this;
    }  // 返回当前对象的地址

    Test GetCopy() {
        printf("GetCopy      : Address of *this (Test)   = %p\r\n", &*this);
        return *this;
    }  // 返回当前对象的 拷贝,  浅拷贝
    // *this 和 obj 作用一致, &*this 就相当于  &obj

    Test &GetReference() {
        printf("GetReference : Address of *this (Test &) = %p\r\n", &*this);
        return *this;
    }  // 返回当前对象自身

protected:
private:
};

int main(int argc, char **argv) {
    // sizeof(ClassName) 实际上就是当前类实例化对象的内存大小
    printf("size of class Test  = %u\r\n", sizeof(Test));

    Test obj_test;
    obj_test.c = 'c';
    obj_test.d = 2;

    // obj_test 和 obj_test.c 等价,  类似 数组首地址表示 第一个元素
    printf("obj_test.c          = %c \r\n", obj_test);  // obj_test
    printf("Address of obj_test = %p\r\n", &obj_test);

    obj_test.GetThis();
    obj_test.GetCopy();
    obj_test.GetReference();

    printf("\r\n");

    // 验证 Test GetCopy() { return *this; } 返回的是浅拷贝
    Test obj_test2 = obj_test.GetCopy();
    obj_test2.c += 2;
    // 看下修改 obj_test2.c 是否会影响到 obj_test.c
    printf("obj_test.c = %c\r\n", obj_test.c);
    printf("Address of obj_test2 = %p\r\n", &obj_test2);



    // 验证 Test &GetReference() { return *this; } 返回的是 对象自身
    // 注意类型需要是 Test &
    Test & obj_test3 = obj_test.GetReference();  // 传递引用并没有开辟新的内存, 只是起了新的别名, !!!
    obj_test3.c += 2;
    printf("obj_test.c = %c\r\n", obj_test.c);
    printf("Address of obj_test3 = %p\r\n", &obj_test3);

    return 0;
}

/**
size of class Test  = 8
obj_test.c          = c
Address of obj_test = 0x7ffc5a9a8d08
GetThis      : Address of this           = 0x7ffc5a9a8d08
GetCopy      : Address of *this (Test)   = 0x7ffc5a9a8d08
GetReference : Address of *this (Test &) = 0x7ffc5a9a8d08

GetCopy      : Address of *this (Test)   = 0x7ffc5a9a8d08
obj_test.c = c
Address of obj_test2 = 0x7ffc5a9a8d10
GetReference : Address of *this (Test &) = 0x7ffc5a9a8d08
obj_test.c = e
Address of obj_test3 = 0x7ffc5a9a8d08
 */

多态

关于多态(polymorphism) ?

在 C++ 中, 允许通过 基类(BaseClass) 类型的指针 or 引用 来访问 派生类中的 成员函数. 并且 允许需要执行的函数在运行时 进行延迟绑定 (late binding), 称之为 多态.

所以:

多态的前提条件是 存在类的继承关系.

多态实现的方法是C++ 的续函数机制 (虚函数指针 vptr 和 虚函数表 vptl)

函数重载 overload

函数重载 overload 也称为 多态, 发生在 静态编译阶段, 根据函数的 参数类型 的区别就确定了应该调用的函数

函数重写 overwrite

函数重写 overwrite 也叫 覆盖 override 是通过 虚函数机制实现的, 动态绑定的多态.

为什么需要多态

  1. 多态意味着 可以用同一个函数名去执行不同的动作, 对函数命名复用的同时, 还可以简化代码.
  2. 多态约定了实现的接口, 便于 子类的具体功能的实现, 而 接口不变
  3. 可以实现各个子类之间互相不影响, 从而提高了代码的拓展性
  4. 便于后期代码维护, 如果不是多态, 就需要手动进行判断具体需要执行的方法 (会有一堆的 if ... else ...)

如何使用多态

  1. 在 C++ 中, 多态的实现是通过 覆盖 override

而决定是否覆盖函数的关键点在于 该基类中的成员函数 是否有关键字 virtual 修饰, 被修饰的成员函数被称之为 虚函数, 然后在 派生类(Derived Class)中定义的 同名函数 覆盖 (override)

多态的原理

C++ 基类中包含 virtual 修饰的成员函数, 编译器 将会在类的内存模型中添加 虚函数表的指针, 即 vptr.

该指针占用的大小 (sizeof(void *)) 和平台相关, 该 vptr 指向存储在别处的虚函数表 (vtbl)

vtbl 中又存放着类中的虚拟成员函数的地址

关于内存对齐

memory alignment:

成员变量在类中的内存存储 不一定是连续的, 是按照编译器的设置, 按照 内存块来存储, 这个对齐的内存块大小的取值, 就是 内存对齐.

内存对齐规则:

  1. 类的第一个成员变量放在类内存中 offset(偏移量) = 0 的地方, 之后的 成员变量的对齐按照 #pragma pack(n) 指定的数值 和 这个成员变量类型所占字节数中, 比较小 的那个进行 成员变量间补齐

  2. 在成员变量完成 各自的内存对齐之后, 该类(结构 or 联合) 本身也要进行内存对齐. 对齐按照 #pragma pack(n) 指定的数值 和 类中最大成员变量类型所占字节数中比较小的那个进行 (类中最后一个成员变量结尾后 补齐) 类大小需要是 对齐值的整数倍.

  3. #pragma pack(n) 作为一个预编译指令 用来 设置结构内存对齐的字节数

要注意: n 的缺省数值是编译器设置的, 一般为 8, 合法的数值分别是 1, 2, 4, 8, 16


@Q: 为什么要进行内存对齐 ?
@A: 内存对齐是为了 提高 cpu 读取数据的效率, 并不是所有的硬件平台都能够随意访问任意位置的内存, 因为一些平台的cpu, 如果读取的数据是未对齐的话, 将拒绝访问 or 抛出硬件异常.

内存对齐的规则:

  1. 假设结构体的起始地址为 0x0, 结构体 第一个成员相当结构体首地址的偏移量(offset) = 0, 此后每个成员的 对齐值为 其自身对齐大小与 #progama pack 指定的数值中较小的那个

  2. 结构体中所有成员完成字节对齐之后, 结构体自身的 内存对齐值 为 所有成员中对齐值最大的那个

有效对齐值: 是给定值 #pragma pack(n) 和 结构体(类) 中最长数据类型长度中 较小 的那个, 有效对齐值也叫 对齐单位.

  1. 结构体第一个成员的偏移量 (offset) 为 0, 以后每个成员相对于结构体 首地址的 offset 都是该成员大小 与 有效对齐值 中较小那个的 整数倍, 如有需要编译器会在成员之间加上填充字节

  2. 结构体的总大小为 有效对齐值 的整数倍, 如有需要编译器会在最末一个成员之后加上填充字节

  3. 对于含有其他结构体变量的情况, 该结构体变量存储位置 从自己的最大对齐数的整数倍处开始, 而结构体的整体大小就是 所有最大对齐数 (含嵌套的结构体的对齐数) 的整数倍 (最小公倍数)

e.g.

struct A {
    char a;           // 1
    int b;            // 4
    short c;          // 2
    long long int d;  // 8
    char e;           // 1
};

按照字节对齐(align)规则:
align 对齐(v)
on linux: 默认对齐系数是 4, #pragma pack(4)

根据 规则-1:
该结构体中最大的成员变量是 long long int, 8 byte > 对齐系数 4
所以 有效对齐值(对齐单位) = 4

struct A {
    char a:   // 1 < 4,  offset: 0x00 ~ 0x01  1 byte
    根据 以后每个成员相对于结构体 **首地址的** offset 都是该成员大小 与 **有效对齐值** 中较小那个的 **整数倍**
    int b;    // 4 <= 4, offset: 0x04(开始) ~ 0x08  4 bytes
    short c;  // 2 < 4,  offset: 0x08(0x08 刚好是 2 的整数倍) ~ 0x10  2 bytes
    long long int d;  // 8 > 4;  0x10 不是 4 的整数倍, 所以从 0x12(开始) ~ 0x20  8 bytes
    char e;   // 1 < 4;  offset: 0x20 ~ 0x21
};

根据 规则-2: 由于 21 不是 4 的整数倍, 所以补全到 24

图解:

内存对齐2.drawio.png

如果使用默认的 8 bytes 对齐,

2023-02-18_17-06.png

多态指针偏移demo

64 bit 操作系统上 64 bit 编译器默认是 8 bytes 对齐

this 隐含传参