C++数据封装以及定义结构的详细讲解
前言数据封装encapsulation大概是被讲得最多、也被做得最浅的一个概念。很多教程把它等价成一条操作指令把成员变量改成private再给每个变量配一个getXxx()和一个setXxx()。这样做完之后代码看起来面向对象了实际上封装的目的一点都没达到。封装的真正目的只有一个让对象任何时候都处在合法状态。数学上把这种对象必须始终满足的条件叫做类不变式class invariant。一个Range类必须保证下界不超过上界一个Email类必须保证字符串里有且只有一个一个链表必须保证头尾指针一致。这些条件只有在所有修改入口都把关时才能成立而给每个字段配一个 setter恰恰是把把关的机会全部让出去了。第二个常见误解是关于struct和class。两者的唯一区别是默认访问权限和默认继承方式struct默认publicclass默认private。除此之外没有任何区别——struct可以有构造函数、虚函数、私有成员class也可以用聚合初始化。选哪个纯粹是表达这东西是个纯数据袋还是一个有不变式的类型。本文以 C17 为基准代码在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译。一、struct 与 class只有两个默认值不同项目struct默认class默认成员访问权限publicprivate继承方式struct D : B公有继承私有继承#include iostreamstruct Point { // 默认 publicint x 0;int y 0;int sum() const { return x y; }};class Blob { // 默认 privateint x_ 0; // 没写访问说明符所以是 privatepublic:int x() const { return x_; }void set_x(int v) { x_ v; }};class Derived1 : Point {}; // class 默认私有继承struct Derived2 : Point {}; // struct 默认公有继承int main() {Point p{1, 2}; // 聚合初始化std::cout p.sum() \n; // 3Blob b;b.set_x(5);std::cout b.x() \n; // 5// Derived1 d1; d1.sum(); // 错误私有继承sum 对外不可访问Derived2 d2; // 公有继承d2.x 1;d2.y 2;std::cout d2.sum() \n; // 3return 0;}注意class Derived1 : Point {}这一行——很多人以为不写public就是公有继承结果发现基类的接口全都不见了。这是私有继承的典型症状继承关系存在但对外完全不可见。二、封装的实质守住不变式下面这个Range类把下界不大于上界作为不变式在每一个可以改变状态的入口都做了检查#include iostream#include stdexceptclass Range {public:Range(int lo, int hi) : lo_(lo), hi_(hi) {if (lo hi) {throw std::invalid_argument(lo 不能大于 hi);}}int lo() const noexcept { return lo_; }int hi() const noexcept { return hi_; }// 只暴露操作不暴露字段bool contains(int v) const noexcept { return lo_ v v hi_; }void set_lo(int v) {if (v hi_) {throw std::invalid_argument(set_lo: lo 不能大于 hi);}lo_ v; // 不变式 lo_ hi_ 依然成立}void set_hi(int v) {if (v lo_) {throw std::invalid_argument(set_hi: hi 不能小于 lo);}hi_ v;}private:int lo_;int hi_;};int main() {Range r(1, 10);std::cout r.contains(5) r.contains(50) \n; // 1 0r.set_lo(3);try {r.set_lo(99); // 被拒绝对象状态不变} catch (const std::invalid_argument e) {std::cout 被拒绝: e.what() \n;}std::cout r.lo() r.hi() \n; // 3 10return 0;}对比一下每个字段配一个 setter的做法如果lo_和hi_都是public调用方可以随时写出r.lo_ 99;对象立刻进入非法状态而所有后续依赖不变式的代码比如contains、二分查找、区间合并都会跟着出错而且出错的现场离肇事现场很远。封装换来的不是好看而是错误在发生的那一刻就被拦住。判断一个类封装得好不好有个简单的自检把它的所有 public 成员函数列出来看看有没有哪一对是先 setA 再 setB 才有意义的。如果有说明应该提供的是一个同时改两者的操作而不是两个 setter。三、const 正确性与 mutableconst成员函数承诺调用它不会改变对象的可观察状态。它是封装契约的一部分只有const成员函数能对const对象调用所以一个类型能不能在只读上下文里用取决于你把哪些成员函数标成了const。#include iostreamclass Cached {public:explicit Cached(int seed) : seed_(seed) {}// const 成员函数内部修改 mutable 成员是允许的// 缓存不影响对象的可观察状态所以不违反 const 承诺int value() const {if (!valid_) {cache_ compute();valid_ true;}return cache_;}int seed() const noexcept { return seed_; }private:int compute() const { return seed_ * 2; }int seed_;mutable int cache_ 0;mutable bool valid_ false;};int main() {const Cached c(21);std::cout c.value() c.value() \n; // 42 42return 0;}这里必须强调一句上面这种带mutable的惰性缓存不是线程安全的。两个线程同时第一次调用value()会同时写cache_和valid_这是数据竞争属于 UB。要在线程间共享得加锁锁对象本身也要是mutable才能在const函数里用或者干脆去掉缓存。另一种更隐蔽的破坏方式来自指针成员。const作用在一个指针成员上得到的是常量指针T* const指向不能改而不是指向常量的指针const T*内容不能改。所以一个const成员函数完全可以合法地交出一份可写权限#include iostreamclass Holder {public:explicit Holder(int* p) : p_(p) {}// const 成员函数p_ 本身不能改指向但 p_ 指向的 int 照样能改int* get() const { return p_; }private:int* p_;};int main() {int value 1;const Holder h(value); // h 是 const 对象*h.get() 99; // 通过 const 对象改了外部数据std::cout value \n; // 99return 0;}这段代码完全合法h是const对象get()是const成员函数但外部数据照样被改了。要真正封住get()应该返回const int*或者干脆不把指针暴露出去。同理要暴露内部集合时返回const引用只能挡住直接修改却会把内部表示绑进公开接口更好的做法是返回迭代器范围或者直接提供for_each风格的操作接口。四、结构的三种定义方式定义一个新的数据结构时先想清楚它属于哪一类类型特征典型写法聚合aggregate纯数据没有不变式允许逐字段初始化struct成员全 public不加构造函数有不变式的类型必须经由构造函数校验字段全私有class构造函数 最小接口需要隐藏实现的类型头文件不能暴露实现细节编译依赖、ABIPimpl 惯用法聚合在 C17 里的判定条件是没有用户提供user-provided、没有explicit、也没有继承来的构造函数没有 private 或 protected 的非静态数据成员没有虚函数没有虚基类、私有或受保护的基类。这里有一个版本差异必须记住C20 把条件改成了没有用户声明或继承来的构造函数提案 P1008R1。后果是下面这个类在 C17 里是聚合在 C20 里就不是了struct Config {Config() default; // 用户声明但没有用户提供int port 8080;bool debug false;};int main() {// C17Config 是聚合这行合法// C20Config 不是聚合因为有用户声明的构造函数这行编不过Config c{9090, true};return c.port 9090 ? 0 : 1;}写 C17 代码时它是合法的将来升到 C20需要把这行改成Config c; c.port 9090; c.debug true;或者删掉那个 default的构造函数。PimplPointer to Implementation也叫编译期防火墙是把实现藏进 .cpp 的标准手段// widget.h#ifndef WIDGET_H#define WIDGET_H#include memoryclass Widget {public:Widget();~Widget(); // 必须在 .cpp 里定义Widget(const Widget);Widget operator(const Widget);Widget(Widget) noexcept;Widget operator(Widget) noexcept;void set(int v);int get() const;private:struct Impl; // 只声明不定义std::unique_ptrImpl pimpl_;};#endif // WIDGET_H// widget.cpp#include widget.h#include string#include utilitystruct Widget::Impl { // 完整定义只出现在这里int value 0;std::string note;};Widget::Widget() : pimpl_(std::make_uniqueImpl()) {}Widget::~Widget() default; // Impl 在这里已是完整类型Widget::Widget(const Widget other): pimpl_(std::make_uniqueImpl(*other.pimpl_)) {}Widget Widget::operator(const Widget other) {if (this ! other) {*pimpl_ *other.pimpl_; // 自赋值必须先判断}return *this;}Widget::Widget(Widget) noexcept default;Widget Widget::operator(Widget) noexcept default;void Widget::set(int v) { pimpl_-value v; }int Widget::get() const { return pimpl_-value; }这段代码里有三个必须同时满足的约束析构函数不能在头文件里写 default。默认的析构函数会销毁std::unique_ptrImpl而销毁动作要求Impl是完整类型头文件里Impl只是声明编译器会报不完整类型相关的错误。移动赋值同理unique_ptr的移动赋值会析构它原来持有的对象所以也必须在Impl完整的地方定义。拷贝要手写std::unique_ptr不可拷贝所以想让Widget可拷贝就必须自己写深拷贝如上面的std::make_uniqueImpl(*other.pimpl_)并且处理自赋值。代价是每次访问成员都要多一次指针间接收益是头文件只依赖memory改Impl的字段不会触发任何包含这个头文件的 .cpp 重新编译。常见坑点场景❌ 错误写法✅ 正确写法说明默认继承方式class D : Base {};以为拿到的是公有继承写全class D : public Base {};class的默认继承是私有继承基类接口对外全部不可见形式化封装每个字段配一个get/set暴露操作把不变式守在构造函数和修改函数里只加 setter 等于把校验机会让给调用方构造函数不校验构造函数直接赋值不做范围检查在构造函数里校验非法就抛std::invalid_argument否则对象一出生就可能处于非法状态且无处可查Pimpl 析构头文件里~Widget() default;头文件只声明在 .cpp 里写 default默认析构需要Impl完整类型头文件里只有声明Pimpl 移动赋值头文件里operator(Widget) noexcept default;同样放到 .cpp 里定义移动赋值会析构原对象也需要完整类型返回内部引用std::vectorint items() { return v_; }或返回内部元素的非 const 引用返回迭代器、const引用或提供操作接口一旦交出去封装就失效了mutable缓存在多线程共享的对象上用mutable做惰性缓存加锁锁对象也标mutable或去掉缓存无同步的并发写是数据竞争属 UB聚合判定以为struct一定是聚合检查是否满足聚合条件C20 起有用户声明构造函数的类不再是聚合总结主题关键结论struct与class只有默认访问权限和默认继承方式不同其余完全等价封装的目的维持类不变式让非法状态无法被构造出来接口设计暴露操作而不是字段构造函数是校验的第一道也是最重要的一道关const承诺不改可观察状态mutable用于缓存和互斥量这类物理上要改、逻辑上不改的成员结构定义纯数据用聚合有不变式用封装类要隔离编译依赖用 PimplPimpl 约束析构、移动赋值等特殊成员函数必须在实现文件里定义因为那里才有完整类型数据封装不是把变量改成private就完事的仪式而是把你对什么状态是合法的这个判断从每个调用者脑子里搬进类型本身的代码里。判断有没有做到就看一个问题一个不懂这个类的人能不能只用它的 public 接口把它搞成一个非法状态如果答案是能那它还没封装好。