ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ struct与class核心差异:默认访问权限、内存对齐与多态实践

C++ struct与class核心差异:默认访问权限、内存对齐与多态实践 C里的struct和class是我见过最多初学者栽跟头的地方。看起来它们什么都能做——都能定义结构体变量、都能在内部放函数、都能拿来继承——但换来的结果就是经常有人问我为什么我在class里写的成员外面一访问就编译报错为什么我用class去继承一个struct基类的函数全部不能调用了这些现象的背后其实只差一句话默认访问权限不同。今天这篇我把结构体struct和类class的核心知识完整梳理一遍从语法差异、内存布局、生命周期一路讲到抽象类、虚函数和面试高频考点尽量讲清楚每个“为什么”让基础一般的读者也能一次弄明白。1. struct 和 class 的“唯一语法区别”以及它带来的连锁反应先给结论在C的语法层面struct和class只有两个默认值不同。第一成员默认访问权限struct的成员默认是publicclass的成员默认是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里你也可以显式写publicstruct里也可以写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带不变量用 classC 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占偏移0int b要求4字节对齐于是编译器跳过偏移1、2、3把b放在偏移4中间3个字节是paddingb占4~7char c放在偏移8最后为了让整个结构体大小是4的倍数末尾再补3个字节总共12。Data2里a在0c在1b从偏移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}; // C11 聚合初始化推荐 Point p3{.x 1, .y 2}; // C20 指定初始化器 Point p4{}; // 零初始化C风格{1, 2}最大的问题是必须严格按成员声明顺序写一旦结构体里加了一个字段后面的全部错位。C11的聚合初始化本质上也是顺序初始化但语义更干净。C20开始支持指定初始化器可以直接写成员名代码一目了然但有两个限制必须按声明顺序指定且不能跳过靠前的字段。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::vectorchar或std::unique_ptrchar[]让标准库替你管理内存。这就是“规则之三”的现代解读一旦你需要自定义析构、拷贝构造、拷贝赋值这三个函数之一通常意味着这个类正在管理某种资源那就需要认真考虑这三个函数或者直接换成RAII容器。3.3 移动语义临时对象的性能救赎C11引入右值引用之后移动语义成了性能优化的关键。简单说移动构造函数把临时对象的资源“偷”过来而不是拷贝一份。还是用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 抽象类和普通类的核心区别能不能实例化普通类可以放心创建对象抽象类不行。一个类只要包含至少一个纯虚函数就成了抽象类只能被继承不能直接实例化。纯虚函数的写法是函数声明后面加 0class 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为publicclass为private默认继承方式不同struct为publicclass为private除此以外二者能力完全等价struct也可以有构造、多态空class的大小是多少1字节保证不同对象有不同的内存地址否则空对象数组无法区分元素为什么析构函数需要是virtual通过基类指针delete派生类对象时保证派生类资源被正确释放构造函数不能声明为virtual抽象类是什么含纯虚函数不能实例化纯虚函数可以有函数体但类依然是抽象类结构体大小如何计算内存对齐成员偏移为对齐数整数倍总大小为最宽基本类型对齐数倍数给出具体例子并说明pack的影响深拷贝和浅拷贝区别浅拷贝逐成员复制裸指针会导致双重释放规则之三移动语义什么是POD类型布局简单、可memcpy的类型可用于跨语言、序列化、共享内存答题时最忌背概念。我建议每个答案后面都顺手举一个自己写过的例子哪怕三五句话面试官都会觉得你是真正用过而不是刷题刷出来的。5.2 工程中的 structPOD、跨语言与序列化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的核心知识才算真正内化成自己的东西。
返回列表