MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ struct的内存布局特点

2022-11-152.8k 阅读

C++ struct的内存布局基础

简单struct定义与内存布局

在C++ 中,struct是一种用户自定义的数据类型,用于将不同类型的数据组合在一起。例如,我们定义一个简单的struct

struct Point {
    int x;
    int y;
};

在这个Point结构体中,它包含两个int类型的成员变量xy。在大多数常见的编译器和平台下,int类型通常占用4个字节。因此,Point结构体实例在内存中占用的空间大小为两个int类型的大小之和,即8个字节。这是因为结构体成员变量在内存中是按照定义的顺序依次排列的。

我们可以通过sizeof运算符来验证Point结构体的大小:

#include <iostream>

struct Point {
    int x;
    int y;
};

int main() {
    std::cout << "Size of Point struct: " << sizeof(Point) << " bytes" << std::endl;
    return 0;
}

上述代码运行后,输出结果将是“Size of Point struct: 8 bytes”,这与我们预期的一致。

结构体对齐原则引入

然而,实际情况并非总是如此简单。考虑以下结构体:

struct Data {
    char c;
    int i;
};

按照之前的思路,char类型占用1个字节,int类型占用4个字节,那么Data结构体大小应该是5个字节。但是,当我们使用sizeof运算符时:

#include <iostream>

struct Data {
    char c;
    int i;
};

int main() {
    std::cout << "Size of Data struct: " << sizeof(Data) << " bytes" << std::endl;
    return 0;
}

输出结果往往是8个字节,而不是5个字节。这是因为C++ 遵循结构体对齐原则。

结构体对齐原则

什么是结构体对齐

结构体对齐是指编译器为了提高内存访问效率,在分配内存空间时,会按照一定的规则对结构体成员变量的地址进行对齐。具体来说,每个成员变量的起始地址必须是该成员变量类型大小的整数倍。

Data结构体为例,char类型的c成员变量占用1个字节。但是,由于int类型的i成员变量要求其起始地址是4的倍数(因为int类型通常占用4个字节),所以在c成员变量之后,编译器会填充3个字节,使得i成员变量的起始地址满足4字节对齐的要求。这样,Data结构体的总大小就是8个字节。

对齐系数与编译器默认对齐

不同编译器有不同的默认对齐系数。在GCC编译器中,默认对齐系数通常与目标平台相关,例如在32位系统上,默认对齐系数可能是4字节;在64位系统上,默认对齐系数可能是8字节。而在Visual Studio编译器中,默认对齐系数也会根据项目设置有所不同,常见的默认对齐系数有8字节。

我们可以通过#pragma pack指令来改变编译器的默认对齐系数。例如,将对齐系数设置为1:

#include <iostream>

#pragma pack(1)
struct Data {
    char c;
    int i;
};
#pragma pack()

int main() {
    std::cout << "Size of Data struct: " << sizeof(Data) << " bytes" << std::endl;
    return 0;
}

上述代码中,#pragma pack(1)将对齐系数设置为1,此时结构体成员变量将紧密排列,不会进行填充。所以,Data结构体的大小将是5个字节。#pragma pack()则恢复到编译器的默认对齐设置。

嵌套结构体的对齐

当结构体中包含嵌套结构体时,对齐规则会变得更加复杂。例如:

struct Inner {
    char c;
    short s;
};

struct Outer {
    Inner inner;
    int i;
};

首先分析Inner结构体,char类型占用1个字节,short类型占用2个字节。由于short类型要求其起始地址是2的倍数,所以在c之后会填充1个字节,Inner结构体的大小为4个字节。

对于Outer结构体,Inner类型的inner成员变量占用4个字节。而int类型的i成员变量要求其起始地址是4的倍数,inner的大小已经是4字节对齐,所以i直接紧跟在inner之后。因此,Outer结构体的大小为8个字节。

我们可以通过代码验证:

#include <iostream>

struct Inner {
    char c;
    short s;
};

struct Outer {
    Inner inner;
    int i;
};

int main() {
    std::cout << "Size of Inner struct: " << sizeof(Inner) << " bytes" << std::endl;
    std::cout << "Size of Outer struct: " << sizeof(Outer) << " bytes" << std::endl;
    return 0;
}

输出结果将分别是“Size of Inner struct: 4 bytes”和“Size of Outer struct: 8 bytes”。

结构体内存布局与类的关系

struct与class的内存布局相似性

在C++ 中,structclass在内存布局方面有很多相似之处。实际上,struct可以看作是一种特殊的class,它们唯一的区别在于默认的访问权限,struct默认成员访问权限是public,而class默认是private

例如,我们将之前的Point结构体改为类:

class Point {
public:
    int x;
    int y;
};

这个Point类在内存布局上与之前的Point结构体是完全一样的。我们同样可以使用sizeof运算符来验证:

#include <iostream>

class Point {
public:
    int x;
    int y;
};

int main() {
    std::cout << "Size of Point class: " << sizeof(Point) << " bytes" << std::endl;
    return 0;
}

输出结果将是“Size of Point class: 8 bytes”,与Point结构体的大小相同。

成员函数对内存布局的影响

无论是struct还是class,成员函数并不占用结构体或类实例的内存空间。例如:

struct Point {
    int x;
    int y;
    void print() {
        std::cout << "x: " << x << ", y: " << y << std::endl;
    }
};

Point结构体的大小仍然是8个字节,因为成员函数print的代码存储在程序的代码段,而不是结构体实例的内存空间中。多个Point结构体实例共享这一份print函数代码。

虚函数与虚表指针

structclass包含虚函数时,情况就有所不同了。例如:

struct Shape {
    virtual void draw() {
        std::cout << "Drawing a shape" << std::endl;
    }
};

为了实现多态性,编译器会为包含虚函数的structclass添加一个虚表指针(通常缩写为vptr)。这个虚表指针指向一个虚函数表(vtable),虚函数表中存储了虚函数的地址。在大多数常见的编译器和平台下,指针类型占用8个字节(64位系统)或4个字节(32位系统)。

所以,上述Shape结构体在64位系统下的大小为8个字节,在32位系统下的大小为4个字节。我们可以通过代码验证:

#include <iostream>

struct Shape {
    virtual void draw() {
        std::cout << "Drawing a shape" << std::endl;
    }
};

int main() {
    std::cout << "Size of Shape struct: " << sizeof(Shape) << " bytes" << std::endl;
    return 0;
}

在64位系统上运行,输出结果将是“Size of Shape struct: 8 bytes”。

如果Shape结构体还包含其他成员变量,例如:

struct Shape {
    int id;
    virtual void draw() {
        std::cout << "Drawing a shape" << std::endl;
    }
};

在64位系统下,由于虚表指针占用8个字节,int类型的id成员变量占用4个字节,并且要满足对齐要求,Shape结构体的大小将是16个字节。

结构体内存布局与继承

单一继承下的内存布局

当一个struct继承自另一个struct时,内存布局会发生相应的变化。例如:

struct Base {
    int a;
};

struct Derived : Base {
    int b;
};

在这种单一继承的情况下,Derived结构体的内存布局是先放置Base结构体的成员变量,然后再放置Derived结构体自身的成员变量。所以,Derived结构体的大小为Base结构体大小加上Derived结构体新增成员变量的大小。Base结构体中int类型的a成员变量占用4个字节,Derived结构体中int类型的b成员变量占用4个字节,因此Derived结构体的大小为8个字节。

我们可以通过代码验证:

#include <iostream>

struct Base {
    int a;
};

struct Derived : Base {
    int b;
};

int main() {
    std::cout << "Size of Base struct: " << sizeof(Base) << " bytes" << std::endl;
    std::cout << "Size of Derived struct: " << sizeof(Derived) << " bytes" << std::endl;
    return 0;
}

输出结果将分别是“Size of Base struct: 4 bytes”和“Size of Derived struct: 8 bytes”。

虚继承下的内存布局

虚继承是一种特殊的继承方式,用于解决菱形继承问题。例如:

struct A {
    int a;
};

struct B : virtual public A {
    int b;
};

struct C : virtual public A {
    int c;
};

struct D : B, C {
    int d;
};

在虚继承中,为了确保共享的基类A只有一份实例,编译器会引入额外的机制。通常,编译器会为每个虚继承的子类添加一个虚基表指针(vbptr),这个指针指向一个虚基表(vbtable),虚基表中存储了虚基类相对于子类对象起始地址的偏移量。

B类为例,在64位系统下,B类除了int类型的b成员变量占用4个字节外,还会有一个虚基表指针占用8个字节,再加上A类中int类型的a成员变量占用4个字节,由于对齐要求,B类的大小为16个字节。

D类的情况更为复杂,它继承自BC,而BC又虚继承自AD类中会包含B类和C类的虚基表指针,以及A类的一份实例,还有自身的int类型的d成员变量。在64位系统下,D类的大小通常为32个字节。

我们可以通过代码验证:

#include <iostream>

struct A {
    int a;
};

struct B : virtual public A {
    int b;
};

struct C : virtual public A {
    int c;
};

struct D : B, C {
    int d;
};

int main() {
    std::cout << "Size of A struct: " << sizeof(A) << " bytes" << std::endl;
    std::cout << "Size of B struct: " << sizeof(B) << " bytes" << std::endl;
    std::cout << "Size of C struct: " << sizeof(C) << " bytes" << std::endl;
    std::cout << "Size of D struct: " << sizeof(D) << " bytes" << std::endl;
    return 0;
}

在64位系统上运行,输出结果大致为“Size of A struct: 4 bytes”、“Size of B struct: 16 bytes”、“Size of C struct: 16 bytes”和“Size of D struct: 32 bytes”。

结构体内存布局的实际应用

内存优化

了解结构体内存布局对于优化内存使用非常重要。例如,在一些对内存空间要求苛刻的嵌入式系统中,合理调整结构体成员变量的顺序可以减少内存浪费。

假设我们有一个结构体:

struct Record {
    char c;
    int i;
    short s;
};

按照默认的对齐规则,这个结构体的大小会因为对齐填充而比较大。如果我们调整成员变量的顺序:

struct Record {
    int i;
    short s;
    char c;
};

这样,int类型的i成员变量首先占用4个字节,short类型的s成员变量紧跟其后占用2个字节,char类型的c成员变量占用1个字节,由于最后一个成员变量不需要额外的对齐填充,整个结构体的大小就会减小。

与外部接口交互

在与外部接口(如网络协议、文件格式等)交互时,结构体的内存布局也非常关键。例如,在网络通信中,某些协议规定了数据的特定格式,我们需要确保C++ 结构体的内存布局与协议规定的格式一致,以避免数据传输错误。

假设我们要解析一个简单的网络数据包格式,数据包格式如下:

字段类型大小
标识符char1字节
长度short2字节
数据char[]可变长度

我们可以定义如下结构体:

#pragma pack(1)
struct Packet {
    char identifier;
    short length;
    char data[0];
};
#pragma pack()

这里使用了零长度数组(C99特性,C++ 中部分编译器支持)来表示可变长度的数据部分。通过设置#pragma pack(1),确保结构体成员紧密排列,与网络数据包格式一致。

性能优化

合理的结构体内存布局还可以提高程序的性能。由于现代CPU在访问内存时,以缓存行为单位进行数据读取。如果结构体成员变量的布局能够使得相关的数据紧密排列在连续的内存空间中,就可以减少缓存未命中的次数,提高CPU对内存的访问效率。

例如,在一个图形渲染程序中,可能会有一个表示顶点的结构体:

struct Vertex {
    float x;
    float y;
    float z;
    float u;
    float v;
};

由于float类型通常占用4个字节,并且这个结构体的成员变量紧密排列,在进行顶点数据处理时,CPU可以更高效地从内存中读取这些数据,提高渲染性能。

综上所述,深入理解C++ struct的内存布局特点,对于编写高效、稳定的程序至关重要。无论是在内存优化、与外部接口交互还是性能提升方面,都能为开发者提供有力的支持。通过合理运用结构体对齐原则、考虑继承和虚函数等特性对内存布局的影响,开发者可以更好地控制程序的内存使用和运行效率。同时,在实际应用中,根据具体的需求和场景,灵活调整结构体的定义和内存布局,以达到最佳的效果。