Item 22:将数据成员声明为 private

对齐 Effective C++ 第三版 Item 22:通过 private 数据成员获得语法一致性、精细访问控制与表示封装,并解释 protected 数据为何仍会泄漏不变量和实现依赖。

学习目标

  • 能解释 private 数据成员如何提供语法一致性、访问控制和 representation freedom
  • 能设计 query/command 接口,把不变量、同步、缓存和错误处理封装在类边界内
  • 能分析 protected 数据对派生类暴露的依赖,并改造成 private storage 与受控 hook
类设计原则:封装与接口public接口:容易被正确使用protected供派生类访问private成员变量实现细节隐藏由外向内:接口 → 继承钩子 → 实现封装 = 控制用户能看到什么、能改什么四条核心原则最小接口只暴露必要操作,多余接口是负担条款 18数据封装成员变量 private,通过函数控制访问条款 22const 正确性能加 const 就加,编译器帮你查错条款 3by ref-to-const避免按值拷贝,省开销防切片条款 20设计 class 犹如设计 type新对象的创建/销毁、初始化与赋值、值传递、合法状态、继承体系——每个问题都是一道设计关卡条款 18-25:接口设计、成员封装、pass-by-ref-to-const、non-member 函数、不抛异常的 swap
类设计原则:左侧类分层结构(public 接口、protected 继承钩子、private 实现细节),右侧四条核心原则(最小接口、数据封装、const 正确性、pass-by-ref-to-const)。

表示边界实验

从字段暴露回到语义接口

先预测哪一种表示泄漏会扩大变更范围,再切换边界并查看验证证据。

表示暴露

调用者能直接写 quantity 与 reserved,无法集中维护不变量。

受控边界

private data member 只允许 class 与明确 friend 访问,外部改走 semantic query / command。

当前场景 · public field → private data member

private data member 只允许 class 与明确 friend 访问,外部改走 semantic query / command。

从一个可写库存字段的问题开始

一个库存对象直接公开数量:

struct Inventory {
    int quantity;
    int reserved;
};
 
inventory.quantity = -3;
inventory.reserved = 20;

任何调用者都能制造 quantity < 0reserved > quantity。class 无法知道谁修改、何时修改,也无法在一个原子操作中维护两个字段关系。

Item 22 的原则是 Declare data members private(将成员变量声明为 private)。private 不是风格偏好,而是把类型契约与存储方式分开的边界。

先预测新增“预留量不能超过库存”的规则需要修改多少调用点;公开字段方案无法给出可靠清单。

语法一致性让调用者不猜表示

若公开字段用 item.quantity,计算属性用 item.available(),调用者必须记住每个名字背后是字段还是函数。全部通过函数访问后形成 syntactic consistency(语法一致性):

class Inventory {
public:
    int quantity() const noexcept;
    int reserved() const noexcept;
    int available() const noexcept;
};

今天 quantity() 读取字段,明天可读取 packed bits、atomic、cache 或远端 snapshot;调用语法不变。

一致性不是要求 getter/setter 机械覆盖每个字段。available()getQuantityMinusReserved() 更接近领域含义,也保留实现演进空间。

访问控制可以逐项授予

public field 只有“所有人可读写”。private storage 配合函数可表达无访问、只读、验证写入、能力受限或按角色授权。

class Inventory {
public:
    int available() const noexcept;
    Result<void, StockError> reserve(int amount);
    Result<void, StockError> release(int amount);
 
private:
    int quantity_ = 0;
    int reserved_ = 0;
};

reserve 是 command,不是裸 setter:它验证正数、剩余库存,并同时更新状态或返回错误。

封装的是变化概率

encapsulation(封装)的价值不是“别人看不到代码”,而是限制有多少代码依赖某个可能变化的决策。

若一百个调用点读取 public vector,它们会依赖容器类型、元素布局、迭代失效和同步方式。字段 private 后,只有 class implementation 依赖这些选择。

可以把 vector 换为 tree、内存值换为数据库查询、即时计算换为 cache,而不要求用户改语法。真正不变的是 public contract。

函数边界容纳未来行为

字段读取看似零成本,但未来可能需要 lock、lazy load、权限检查、统计、单位转换或错误。函数提供了插入这些行为的位置。

int Inventory::available() const noexcept {
    std::scoped_lock lock(mutex_);
    return quantity_ - reserved_;
}

这里函数是否真能保持 noexcept 要按锁和数据源重新审查;接口演进可能需要 Result。private 让迁移可集中完成,但不能免除契约版本设计。

不变量必须只有一个写入口集合

Inventory 的 invariant 涉及多个字段。若 friend、derived 和外部代码都能直接写,无法证明每次 transition 都合法。

private data 将写入口收束到 constructors、assignment 和少量 commands。审查者可以逐个检查:成功后 invariant 成立;失败时对象保持何种状态;并发是否原子。

这对异常安全、线程安全和持久化一致性使用同一条边界。

private 也改善可测试性

测试不应把字段布局当作断言目标,而应通过 public behavior 验证 invariant。这样 representation 改变不会让正确行为测试全部重写。

Inventory stock = Inventory::withQuantity(10);
CHECK(stock.reserve(4));
CHECK(stock.available() == 6);
CHECK_FALSE(stock.reserve(7));
CHECK(stock.available() == 6);

必要的 white-box 测试可放在实现模块,但不应让生产 API 为测试公开字段。

protected 数据并没有形成封装

把字段从 public 改为 protected 只缩小了客户集合,没有隐藏表示。每个 derived class 仍能直接读写,并依赖字段名、类型和不变量。

原书的结论是 protected 不封装。第三方可以在库外派生,base author 甚至不知道所有依赖者;一旦 vector 改成 tree,未知派生类可能编译失败或暗中改变行为。

用 protected 函数提供扩展点

派生类可能需要参与流程,但需要的是能力,不是字段。

class InventoryBase {
protected:
    Result<void, StockError> appendValidated(Item item);
    std::span<const Item> snapshotItems() const;
 
private:
    std::vector<Item> items_;
};

hook 可以验证、锁定和记录;representation 改变时只更新 hook 实现。若 snapshot view 有 lifetime/失效约束,必须明确,甚至改为 callback/value snapshot。

friend 是局部例外而非默认通道

friend 也能访问 private data,因此会扩大封装边界。某些 tightly coupled operator、serializer 或 test seam 可以合理使用,但要计算依赖成本。

优先问能否通过 public API 完成;若 friend 必须存在,让数量少、职责窄、与 class 同一维护单元,并在 representation 变化时一起审查。

按变化实验验收封装

先预测把 quantity_ 从 int 换成 atomic/remote snapshot 会影响哪些文件,再执行验证:

  • 静态搜索确认 production client 不直接访问 data member。
  • compile-negative tests 验证普通调用者和 derived 都不能写 private storage。
  • invariant tests 覆盖 reserve/release 边界、失败不变和 overflow。
  • concurrency tests 验证多字段 transition 原子且读取一致。
  • representation swap spike 替换容器/存储,public contract tests 无需改动。
  • derived compatibility tests 只依赖 protected operations,不依赖 field/layout。
  • API review 清点 getter/setter,删除不对应真实 use case 的万能访问。

如果“换一种表示”导致大面积 client 修改,当前设计还没有实现真正封装。

小结

  • data members private 提供统一函数语法、精细 access control 和 representation freedom
  • getter/setter 不等于封装,应以领域 query/command 控制权限并维护 invariant
  • encapsulation 限制实现决策的 change blast radius,让同步、缓存和存储可局部演进
  • private 收束 mutation paths,使异常安全和并发原子性可以逐项证明
  • protected data 仍向 derived 暴露表示;应改为 private data 与 protected semantic hook
  • representation swap test 能直接验证客户是否依赖实现细节

资料与写作方式声明

本章以Effective C++, Third Edition, Item 22权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

名词解释

本章出现的专业名词,用大白话再讲一遍。

public representation exposure

调用者可直接读写对象表示。

private data member

只有 class 与明确 friend 可直接访问的数据。

syntactic consistency

所有属性通过统一函数形式访问。

semantic query

描述领域观察而不泄漏存储方式的接口。

fine-grained access control

逐项授予读写和失败处理权限。

invariant-preserving command

原子维护相关不变量的领域操作。

encapsulation
把实现决策隐藏在稳定接口后。
change blast radius

实现变化需要修改和验证的范围。

representation freedom

保持契约时更换内部表示的自由。

behavioral insertion point

可集中加入验证、同步或观测的函数边界。

contract migration

public contract 演进时的兼容策略。

mutation closure

所有写路径都由 class 明确掌控。

atomic state transition

相关字段完整提交或保持原状态。

behavior-oriented test

仅验证 public observable contract 的测试。

representation-coupled test

直接依赖字段和布局的脆弱测试。

protected data exposure

派生类直接访问 base data representation。

derived representation coupling

派生实现对 base 存储细节的依赖。

protected operation hook

向派生类开放的受控语义能力。

semantic extension contract

派生类只依赖稳定能力的扩展契约。

friend access grant

外部函数或类型的 private 访问授权。

encapsulation boundary set

所有能直接依赖 private 表示的代码集合。

representation swap test

替换内部表示以验证封装边界的实验。

练习

  1. 问题 1:重构库存字段(data members private、invariant-preserving command、access control)。 public quantity/reserved 已有大量直接读写,请设计新接口和迁移顺序。
  1. 问题 2:处理 protected vector(protected、encapsulation、semantic extension contract)。 多个第三方 derived 直接 push/erase,base 想换成 tree 并加锁,请设计兼容扩展点。
  1. 问题 3:审查自动生成的 getter/setter(syntactic consistency、representation freedom、private data member)。 用户对象 12 个字段全部可读写,请判断哪些接口应保留。

讨论

评论区加载中…