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