☰
C++ struct与class核心差异:默认访问权限、内存对齐与多态实践
2026/10/1 12:14:51 网站建设 项目流程

C++里的struct和class,是我见过最多初学者栽跟头的地方。看起来它们什么都能做——都能定义结构体变量、都能在内部放函数、都能拿来继承——但换来的结果就是经常有人问我:为什么我在class里写的成员,外面一访问就编译报错?为什么我用class去继承一个struct,基类的函数全部不能调用了?这些现象的背后其实只差一句话:默认访问权限不同。今天这篇我把结构体(struct)和类(class)的核心知识完整梳理一遍,从语法差异、内存布局、生命周期,一路讲到抽象类、虚函数和面试高频考点,尽量讲清楚每个“为什么”,让基础一般的读者也能一次弄明白。

1. struct 和 class 的“唯一语法区别”,以及它带来的连锁反应

先给结论:在C++的语法层面,struct和class只有两个默认值不同。第一,成员默认访问权限:struct的成员默认是public,class的成员默认是private。第二,继承方式的默认值:struct默认是public继承,class默认是private继承。除了这两点,struct和class在能力上完全等价,class能写的构造函数、析构函数、成员函数、静态成员、运算符重载、模板继承,struct统统可以写。

1.1 默认访问控制:public 和 private 的分水岭

看这个最经典的例子:

struct Point { int x; // 默认 public,外部可直接访问 int y; }; class PointClass { int x; // 默认 private,外部访问会编译报错 int y; };

然后定义一个对象试试:

Point p; p.x = 1; // 编译通过 PointClass pc; pc.x = 1; // 编译错误:x 是 private 成员

很多刚接触C++的人会在这里懵住,觉得class一定有什么特殊的神秘规则。其实没有,class里你也可以显式写public,struct里也可以写private,但是从祖师爷那继承下来的默认值不一样。C语言里struct只是数据聚合,根本不存在封装这个概念,C++为了兼容C语言,把struct保留了下来,同时又引入了class。所以从设计动机看,class才是C++真正新增的“类”,struct像是“披着C外壳的类”。面试如果被问到二者区别,能说出这一层历史,比单纯背结论要加分。

1.2 继承时的默认方式:一个容易忽略的坑

默认继承方式这个坑非常隐蔽,因为它不会立刻报错,只会让代码行为变得奇怪:

struct Base { void hello() { std::cout << "hello\n"; } }; class Derived : Base { // 这里默认是 private 继承 }; Derived d; d.hello(); // 编译错误:hello 在 private 继承下外部不可见

你以为是在写class Derived : public Base,但漏写了public,于是默认变成了private继承。private继承意味着什么?基类的public成员在派生类里全部变成private,外部没法访问了。更隐蔽的是,如果你用的是struct Derived : Base,默认public继承,照样能用。这就导致同一个项目里,有人用class继承忘记写public时报错,有人用struct继承一切正常,看起来玄学一样。

我的建议很简单:不要依赖任何默认继承方式,继承时永远显式写出public、protected还是private。特别是class继承struct这种写法,必须写public。private继承并不是没用,它更多用于“实现复用”而不是“接口继承”,但初学者绝大多数场景下不碰它。

1.3 工程上如何取舍:数据聚合用 struct,带不变量用 class

C++ Core Guidelines里有一条非常实用的建议:如果这个类型的全部成员都是公有数据,并且所有字段都允许外部直接读写,用struct;如果类型内部有需要保护的不变量,就把它封装成class。

什么是不变量?说白了就是“这个对象任何时刻都必须满足的约束”。举个例子,一个银行账户的余额不能是负数,外部如果直接改balance_字段,就很容易把值改成负数,破坏业务规则。再比如一个日期对象,外部如果能直接改month,改成13,整个对象的合法性就崩了。所以这类有约束、有规则的类型,应该设计成class,把字段设为private,只通过deposit、withdraw这种公开方法去操作,方法内部做合法性校验。

反之,如果只是一个纯粹的数据盒子,比如矩形宽高、坐标点、学生基本信息登记表,没有“必须合法的约束”,直接用struct并默认公开所有字段,代码会非常清爽。标准库的std::pair、std::tuple本质上也是这种“数据盒子”思维,团队里读代码时看到struct,第一反应就是“这是个数据包”,看到class,第一反应是“这里有行为逻辑”。这种约定能明显降低心智负担。

2. 结构体内存布局、初始化与链表:最容易上手也最容易踩坑的部分

语法搞清楚之后,我建议马上研究内存布局。这个领域的坑通常不致命,但很有代表性,尤其是sizeof(结构体)不等于成员大小之和这一点,刚入门的人十有八九要在这里愣一下。

2.1 内存对齐:为什么 sizeof 不等于成员大小之和

先直接跑个程序:

#include <iostream> struct Data { char a; int b; char c; }; struct Data2 { char a; char c; int b; }; int main() { std::cout << sizeof(Data) << std::endl; // 通常是 12 std::cout << sizeof(Data2) << std::endl; // 通常是 8 }

同样三个成员、总共6个字节的数据,换了一下声明顺序,结构体大小就完全不同,一个12一个8。原因就是内存对齐。CPU在读取4字节、8字节这样的数据时,希望数据地址是对应字节数的整数倍,否则可能需要访问两次内存,甚至在部分架构上直接报错。编译器为了满足CPU的胃口,会在成员之间插入padding填充字节,把每个成员放到合适的偏移上。

以Data为例:char a占偏移0,int b要求4字节对齐,于是编译器跳过偏移1、2、3,把b放在偏移4,中间3个字节是padding;b占4~7,char c放在偏移8;最后为了让整个结构体大小是4的倍数,末尾再补3个字节,总共12。Data2里a在0,c在1,b从偏移4开始,中间只垫2个字节,结尾不需要额外填充,所以是8。

这里记住三条规则就够了:每个成员的对齐数是自身大小和编译器默认对齐数里的较小值;成员地址必须是对齐数的整数倍;结构体整体大小必须是最宽基本类型成员对齐数的整数倍。工程里,写网络协议、文件格式、共享内存以及跨语言调用时,必须清楚这个布局,不然对端按字节流解析结构体,很容易错位。如果需要紧凑布局,可以用#pragma pack(push, 1)强制按1字节对齐:

#pragma pack(push, 1) struct NetPacket { uint8_t type; uint32_t length; uint16_t flags; }; #pragma pack(pop)

这个结构体打包后大小固定为7字节,方便直接塞进字节流。代价是字段访问可能变慢,因为CPU访问非对齐数据时不一定高效。所以要不要压缩对齐,得看跨平台、跨语言的需求有多强。

2.2 结构体初始化方式的演进:从 C 风格到指定初始化器

结构体初始化方式,这些年变化挺大,我分几代说清楚:

struct Point { int x; int y; }; Point p1 = {1, 2}; // C风格,按声明顺序初始化 Point p2{1, 2}; // C++11 聚合初始化,推荐 Point p3{.x = 1, .y = 2}; // C++20 指定初始化器 Point p4{}; // 零初始化

C风格{1, 2}最大的问题是必须严格按成员声明顺序写,一旦结构体里加了一个字段,后面的全部错位。C++11的聚合初始化本质上也是顺序初始化,但语义更干净。C++20开始支持指定初始化器,可以直接写成员名,代码一目了然,但有两个限制:必须按声明顺序指定,且不能跳过靠前的字段。Point p{.y = 2}这样直接指定y是非法的,因为跳过了x。

另外,聚合初始化只适用于没有用户自定义构造函数的简单结构体。一旦你给结构体加了构造函数,花括号就会去匹配构造函数,聚合初始化的行为会变得不可依赖,所以给结构体加构造函数时,我建议干脆配合类内成员默认值使用:

struct FileInfo { std::string path; int size = -1; // 默认值 bool readonly = false; }; FileInfo f; // 所有字段都有安全默认值

这种写法在工程里非常常见,结构体既有默认值,又能保持数据盒子的简单性。还有一个小细节:C语言里用fscanf读结构体成员时,必须传成员的地址,比如fscanf(fp, "%d", &s.age);,不能直接传&s,否则读进去的内存布局完全不对。这个坑在C/C++混合项目里还经常遇到,值得留意。

2.3 结构体指针、链表与典型的嵌入式写法

结构体里能不能放同类型结构体本身?不能。因为那会让结构体大小变成无限循环。但是可以放指向同类型结构体的指针,这就是链表的基础写法:

struct Node { int data; Node* next; // 指针指向下一个节点 };

Node* next为什么合法?因为指针大小是固定的,64位下8字节,32位下4字节,编译器不需要知道下一个Node的完整布局。这是“自引用结构体”的典型用法,也是热词里“c++结构体链表基本语法”对应的问题。写一个最简单的单链表增删查:

Node* createNode(int val) { return new Node{val, nullptr}; } void insertAfter(Node* prev, Node* node) { node->next = prev->next; prev->next = node; } void freeList(Node* head) { while (head) { Node* next = head->next; delete head; head = next; } }

这里new Node{val, nullptr}正好用到了前面讲的聚合初始化,C++里定义结构体变量可以不写struct关键字,直接Node node;就行,C语言里则必须写struct Node node;。链表用到结构体指针时,注意一个常见错误:忘记给next初始化为nullptr。很多野指针问题就是这么来的,所以创建节点时一定要用聚合初始化把所有指针成员置空,或者显式赋nullptr。

3. class 的生命周期管理:构造、拷贝、析构与移动语义

这一章是class相比struct真正拉开差距的地方。如果你把class当纯数据盒子,那它和struct没区别;但一旦class拥有资源、约束和状态,构造、拷贝、析构这一套生命周期管理就必须认真对待。这也直接关系到热词里“c++面试题”最常见的几个陷阱。

3.1 初始化列表:为什么是“初始化”而不是“赋值”

先看一个看似无害的写法:

class Student { std::string name_; int age_; public: Student(const std::string& name, int age) { name_ = name; // 先默认构造空字符串,再赋值 age_ = age; } };

这段代码能跑,但name_实际上被初始化两次:构造函数体开始之前,std::string会先执行默认构造,生成一个空字符串;进入函数体后,再执行一次拷贝赋值,把参数拷进去。对于string还好,如果成员是const类型、引用类型,或者根本没有默认构造函数的类型,这种“先默认构造再赋值”的写法会直接编译失败。

正确写法是初始化列表:

class Student { std::string name_; int age_; public: Student(const std::string& name, int age) : name_(name), age_(age) {} };

初始化列表在构造函数体执行之前就把成员初始化好了,每个成员只被构造一次,没有多余步骤。const成员和引用成员必须这样初始化:在构造函数体内它们已经存在且不能再被赋值。另一个容易被警告的点是初始化顺序。成员初始化的顺序永远跟成员在类里的声明顺序一致,而不是初始化列表的书写顺序。如果你写了class Input { int b; int a; public: Input() : a(0), b(0) {} };,实际执行顺序还是b先a后,编译器会给出-Wreorder警告。所以写初始化列表时,尽量按声明顺序写,减少阅读误解。

3.2 拷贝控制:浅拷贝是怎么把程序搞崩的

假设你写了一个管理堆内存的Buffer类:

class Buffer { char* data_; std::size_t size_; public: explicit Buffer(std::size_t size) : size_(size), data_(new char[size]) {} ~Buffer() { delete[] data_; } };

这个类看起来没什么问题,但它使用了默认拷贝行为。一旦发生下面的代码:

Buffer a(1024); Buffer b(a); // 浅拷贝:b.data_ 和 a.data_ 指向同一块内存

程序结束时,a和b会各自调用析构函数,对同一块内存执行两次delete[],直接双重释放,轻则崩溃,重则触发未定义行为。这就是“浅拷贝”最经典的危害。

解决办法是重写拷贝构造和拷贝赋值,做真正的深拷贝:

Buffer(const Buffer& other) : size_(other.size_), data_(new char[other.size_]) { std::copy(other.data_, other.data_ + other.size_, data_); } Buffer& operator=(const Buffer& other) { if (this == &other) return *this; // 自赋值保护 delete[] data_; size_ = other.size_; data_ = new char[size_]; std::copy(other.data_, other.data_ + size_, data_); return *this; }

这里有两个细节要提醒。第一,赋值运算符必须处理自赋值,否则先delete再拷贝,自己就把自己毁了。第二,new可能抛异常,一旦new失败,原对象已经被delete了,等于自毁。工程上更稳的做法是“copy-and-swap”,利用局部临时对象的自动析构来保证异常安全。当然,最彻底的解决方案是别手写裸指针,直接用std::vector<char>或std::unique_ptr<char[]>,让标准库替你管理内存。这就是“规则之三”的现代解读:一旦你需要自定义析构、拷贝构造、拷贝赋值这三个函数之一,通常意味着这个类正在管理某种资源,那就需要认真考虑这三个函数,或者直接换成RAII容器。

3.3 移动语义:临时对象的性能救赎

C++11引入右值引用之后,移动语义成了性能优化的关键。简单说,移动构造函数把临时对象的资源“偷”过来,而不是拷贝一份。还是用Buffer举例:

Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; // 必须置空,防止源对象析构时重复释放 other.size_ = 0; }

这个函数不拷贝数据,只是把指针转手。转移后必须把源对象的data_置空,否则源对象析构时还是会delete同一块内存。移动语义最大的价值在于:函数返回一个局部Buffer对象时,编译器会优先调用移动构造,省掉一次深拷贝,返回大对象成本骤降。

这里有个新手特别容易忽略的坑:一旦你自定义了析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,编译器通常不会自动生成移动构造函数。这意味着你以为能用移动优化,实际却默默回退到了拷贝操作,性能原地踏步。所以“规则之三”升级成了“规则之五”:如果你需要自定义上述任意一个特殊成员,最好把析构、拷贝构造、拷贝赋值、移动构造、移动赋值五个一起想清楚。如果类里用的是智能指针而不是裸指针,这五个问题通常自动消失,因为unique_ptr可移动但不能拷贝,shared_ptr提供了安全的引用计数拷贝。

另外,移动后的源对象处于“合法但未指定”的状态。你可以重新给它赋值,但不要依赖它移动前的数据。我曾经见过有人把std::move后的vector继续用于打印,结果数据已经被掏空,跑出一个空结果,排查半天。正确的心态是:移动是为了转移资源,转移完就把源对象当垃圾处理,尽早重置。

4. 抽象类、虚函数与多态:理解 class 继承真正的工作原理

到了这里,class的核心知识已经跳过“语法”进入“设计”层面。抽象类、虚函数和多态,是C++面向对象里最有价值也最容易翻车的地方,面试追问往往就是从这里开始的。

4.1 抽象类和普通类的核心区别:能不能实例化

普通类可以放心创建对象,抽象类不行。一个类只要包含至少一个纯虚函数,就成了抽象类,只能被继承,不能直接实例化。纯虚函数的写法是函数声明后面加= 0:

class Shape { public: virtual double area() const = 0; // 纯虚函数 virtual ~Shape() = default; }; class Rectangle : public Shape { double w_, h_; public: Rectangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } };

Shape s;这样定义对象会编译失败,因为Shape没有纯虚函数的实现,编译器不允许你创建“不完整概念”的对象。但Rectangle把所有纯虚函数实现了,所以可以实例化。抽象类可以拥有数据成员、构造函数、普通成员函数,这些在派生类构造时都会用到,只是抽象类本身不能“直接存在”。

为什么需要抽象类?因为它表达的是一种接口契约。你可以只关注“它能算什么面积”,不必关心它到底是矩形还是圆。写高层代码时依赖抽象类,不依赖具体派生类,这就是依赖倒置原则。C++没有interface关键字,抽象类就是最接近接口的机制。

顺带说一个冷门考点:纯虚函数也可以有函数体,比如virtual void f() = 0;,在类外写void Base::f() { ... }是完全合法的,但类仍然是抽象类,不能实例化。这主要用于给派生类提供一个默认实现,派生类可以显式调用Base::f()。

4.2 虚函数和多态:vtable 与 vptr 的最小解释

多态是怎么实现的?每个含有虚函数的类,编译器都会生成一张虚函数表,也就是vtable。每个对象内部被悄悄插入一个指针,叫vptr,指向所属类的vtable。调用虚函数的时候,编译器不再是直接Call某个固定地址,而是先取出vptr,再从vtable里查到真正的函数地址,然后再调用。

看这个例子:

class Base { public: virtual void speak() { std::cout << "base\n"; } virtual ~Base() = default; }; class Derived : public Base { public: void speak() override { std::cout << "derived\n"; } }; void call(Base& b) { b.speak(); // 多态绑定 } int main() { Derived d; call(d); // 输出 derived }

虽然call的形参是Base引用,但运行时d的vptr指向Derived的vtable,所以speak最终调用的是Derived版本。这就是“动态绑定”。注意两个必要条件:必须是虚函数,而且必须通过基类指针或引用来调用。如果直接按值传参,void call(Base b),对象会被切片,派生类部分被切掉,调用的永远是Base版本,多态消失。

因为vptr的存在,包含虚函数的类,sizeof会比“纯数据”大一个指针大小,64位下多8字节。这是可以接受的成本。另一个要点是:析构函数应该声明为virtual。试想Base* p = new Derived();,然后delete p;,如果虚析构函数不存在,delete只会调用Base的析构函数,Derived里的资源就泄漏了。这个问题面试经常考,回答时抓住资源泄漏和未定义行为两条主线。

4.3 对象构造和析构顺序:多态中必须遵守的次序

派生类对象的构造顺序是固定的:先基类构造函数,再成员构造函数,最后派生类构造函数体。析构顺序完全反过来:先派生类析构函数体,再成员析构,最后基类析构。这个顺序意味着,在基类构造函数执行期间,派生类部分还没有初始化,对象还“不是”一个真正的派生类对象。

所以基类构造函数里调用虚函数时,不会发生多态:

class Base { public: Base() { f(); } virtual void f() { std::cout << "Base::f\n"; } }; class Derived : public Base { public: void f() override { std::cout << "Derived::f\n"; } }; int main() { Derived d; // 输出 Base::f }

很多人第一次看到这个结果会愣住:明明f是虚函数,为什么调用的是基类版本?原因就是构造期间vptr还指向基类的vtable,虚函数调用被解析到基类。这也是面试里特别喜欢的陷阱题。正确的设计原则是:不要在构造函数里调用虚函数,不要让构造函数依赖派生类的动态行为。

还有一个和继承相关的常见坑:名字隐藏。如果在派生类里定义了与基类同名但不同参的函数,基类所有同名重载都会被隐藏,哪怕参数完全不同。解决方法是显式using Base::set;把基类重载引入作用域,或者避免在派生类里定义同名函数。这个坑在大型类继承体系里特别容易踩,因为看起来调用的是重载,实际上编译直接报“没有匹配的函数”。

5. 面试官常问的 struct/class 考点与工程中的选型建议

把核心知识讲完之后,我最后系统整理一下面试高频点和工程经验。无论是准备c++面试题,还是日常写代码,这部分都值得反复看。

5.1 高频面试题清单与答题要点

下面这组问题,基本覆盖了struct和class的主要考点:

问题一分钟答题要点想加分就说这个
struct和class的区别默认成员访问权限不同:struct为public,class为private;默认继承方式不同:struct为public,class为private除此以外二者能力完全等价,struct也可以有构造、多态
空class的大小是多少1字节保证不同对象有不同的内存地址,否则空对象数组无法区分元素
为什么析构函数需要是virtual通过基类指针delete派生类对象时,保证派生类资源被正确释放构造函数不能声明为virtual
抽象类是什么含纯虚函数,不能实例化纯虚函数可以有函数体,但类依然是抽象类
结构体大小如何计算内存对齐:成员偏移为对齐数整数倍,总大小为最宽基本类型对齐数倍数给出具体例子,并说明pack的影响
深拷贝和浅拷贝区别浅拷贝逐成员复制,裸指针会导致双重释放规则之三,移动语义
什么是POD类型布局简单、可memcpy的类型可用于跨语言、序列化、共享内存

答题时最忌背概念。我建议每个答案后面都顺手举一个自己写过的例子,哪怕三五句话,面试官都会觉得你是真正用过,而不是刷题刷出来的。

5.2 工程中的 struct:POD、跨语言与序列化

POD类型在工程里非常有用。它没有虚函数、没有自定义构造/析构、没有复杂的内存布局,可以用memcpy直接搬字节。所以网络协议结构体、共享内存映射、与C库交互的数据结构,最适合用struct来定义:

#pragma pack(push, 1) struct ProtocolHeader { uint32_t magic; uint16_t version; uint8_t type; }; #pragma pack(pop)

这种结构体可以直接从socket收到的字节流里memcpy出来,或者mmap到共享内存区域供多进程直接读取。但要注意:跨平台时,#pragma pack对齐规则和大小端都会影响布局,不同编译器的默认对齐数也可能不同。最保险的方式是结构体里只放固定大小的整数类型,并且用static_assert(sizeof(ProtocolHeader) == expected_size, "layout changed")把布局锁死。

热词里出现的“c#调用c++出现access violation c0000005”这类问题,十有八九也出在结构体布局上。C#侧定义了托管结构体,C++侧定义了自己的结构体,两边sizeof不一致、内存对齐不一致、字段顺序不一致,一传指针过去就直接访问越界。解决办法就是确保两侧结构体布局完全一致,必要时关闭对齐、固定字段顺序、用显式布局标记。这类问题排查起来特别痛苦,所以写接口设计时就要把结构体布局当成接口的一部分来维护。

5.3 我的选择标准:简单粗暴但很有用

说了这么多理论和踩坑,最后分享一下我在工程里的实际选择标准。第一,如果这个类型只是“装着几个值”,没有业务规则,所有字段都应该公开,我用struct。第二,如果这个类型内部有约束、有状态、有不能被外部篡改的字段,我用class,字段全private,只暴露操作函数。第三,struct里也可以写构造函数和成员默认值,这跟它作为数据盒子的定位不冲突,反而能减少大量未初始化错误。第四,当代码里一个struct对象被到处传递、参与各种业务逻辑时,通常说明它该升级成class了;反过来,如果一个class只有一堆public字段,没有任何方法逻辑,那多半是过度设计,直接精简成struct更省心。

判断标准不要搞太复杂,就一句话:数据用struct,行为用class。很多老项目里,一个结构体用了三五年,后来逐渐长出业务逻辑,但一开始没设计好,所有字段被外部直接改,导致bug频出。反过来,有人什么类都写class,内部全是public,等于穿着铠甲裸奔。说白了,struct和class的区别不是纪律,而是一个代码可读性和维护性的信号:看到struct,就知道这是数据包;看到class,就知道这里有行为、有约束。保持这个信号一致,团队协作时大家都会舒服很多。

我个人在实际编码中还有一个体会:新写的代码如果发现一个class的移动构造、拷贝构造、析构函数三兄弟一个都没出现,那通常是一件好事,说明设计足够简单,资源都由标准库容器管好了;一旦你发现自己手写析构、拷贝赋值,先别急着写,停下来想想是不是该换成智能指针或容器了。把这套思路想清楚,struct和class的核心知识才算真正内化成自己的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询