Item 19:设计 class 犹如设计 type
对齐 Effective C++ 第三版 Item 19:把 class 当作完整新类型设计,系统决定合法值、对象创建销毁、初始化与赋值、复制移动、运算符、类型转换及继承契约。
学习目标
- 能解释为什么 class design as type design,并列出合法值、生命周期、操作和错误契约
- 能比较初始化与赋值、值语义与身份语义,据此设计 copy/move/destructor
- 能判断 type conversions、operators 与 inheritance 是否满足领域规律和 substitutability
类型契约实验
从领域对象选择类型语义
先预测对象代表独立的值、唯一资源还是可替换的抽象,再切换场景查看 copy、转换和继承证据。
先回答一个问题
复制后的对象是否应独立、相等且可安全按值传递?
推荐契约
private representation + validating factory;同币种运算保持 operation closure,复制不共享可变资源。
若忽略它
public amount/currency 让非法值和跨币种运算绕过 invariant。
当前场景 · Money · value type
private representation + validating factory;同币种运算保持 operation closure,复制不共享可变资源。
从 class 初稿的问题开始
声明一个 class,就是向语言加入一种新 type。调用者会创建、复制、移动、比较、转换、存储和销毁它;编译器会根据声明决定哪些表达式合法。因此 Item 19 的要求是 Treat class design as type design(设计 class 犹如设计 type)。
先预测:如果只看到一个 class 声明,你能否说出它允许的合法值、复制方式、失败保证和可接受的转换?若不能,说明还没有完成 type design。
下面这个初稿只有存储,没有类型设计:
struct Money {
long long amount;
int currency;
int scale;
};它允许负 scale、未知 currency、不同币种直接相加,也没有说明 amount 是元、分还是最小货币单位。public fields 把所有责任推给每个调用者。
↡用户只通过 public API 能观察到的值域、操作结果、失败、复杂度与生命周期保证。设计应先写出契约,再选择字段。字段只是实现契约的一种方式。
先定义合法值与不变量
类型首先要回答“什么对象是合法的”。Money 至少需要 currency、scale 与数值范围的一致规则。
↡一个类型允许表示的全部合法状态集合。class Money {
public:
static Result<Money, MoneyError> fromMinor(
std::int64_t amount,
Currency currency);
std::int64_t minorUnits() const noexcept { return amount_; }
Currency currency() const noexcept { return currency_; }
private:
Money(std::int64_t amount, Currency currency) noexcept;
std::int64_t amount_;
Currency currency_;
};private constructor 与 validating factory 确保公开世界只能看到合法 Money。每个 modifier 要么保持 invariant,要么失败且对象不变。
↡一次 public operation 完成后,类型不变量仍成立的保证。对象创建销毁是一份协议
object creation and destruction(对象创建销毁)不只是 constructor/destructor 语法。需要回答:允许 default construction 吗;创建会失败吗;失败怎样报告;对象拥有资源吗;谁能销毁;销毁是否可抛;是否允许 stack、heap 和跨模块创建。
↡从输入验证、对象发布到最终销毁的一整套创建和释放规则。对没有合理“空金额”的 Money,可以不提供 default constructor。对 identity object 如数据库连接,创建可能走 factory,析构归还连接,复制被删除,移动转移句柄。
class Connection {
public:
static Result<Connection, ConnectError> open(Endpoint);
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
Connection(Connection&&) noexcept;
Connection& operator=(Connection&&) noexcept;
~Connection() noexcept;
};先决定 value 还是 identity,再决定 special members;不能因为字段“刚好可复制”就默认允许复制。
初始化与赋值必须分开回答
initialization and assignment(初始化与赋值)看起来都把 rhs 内容放进 lhs,但前提完全不同:初始化面对尚不存在的值,赋值面对一个已有合法对象。
↡在对象生命周期开始时一次建立表示和不变量的操作。 ↡用新值替换一个已存在合法对象,同时规定失败保证和 self-assignment 行为的操作。Money price = Money::fromMinor(1999, Currency::CNY).value(); // initialization
Money backup = price; // copy initialization
backup = discount; // assignmentassignment 要说明币种是否可改变、失败时 lhs 是否保持原值、self-assignment 是否安全。若类型持有多个资源,copy-and-swap 或先构造 candidate 再 commit 可提供强异常保证。
↡操作失败时原对象保持调用前可观察状态的异常保证。移动后的源对象不必保持原值,但必须可析构、可重新赋值,并满足文档声明的最小状态。
↡move operation 后源对象仍满足类型允许的、可安全销毁和重新赋值的状态。按值传递暴露了什么成本
类型设计还要回答 pass-by-value 的含义与成本。Money 只有两个机器字且复制独立,按值可能合理;Connection 不可复制,只能 borrow 或 transfer。
↡复制、移动、析构和间接访问给调用表达式带来的时间与空间代价。对于多态基类,按值传递还会 slicing;对于小型 value type,过度强制 const reference 可能增加间接访问。接口应从语义和测量决定,而不是套统一口号。
↡参数形式直接表达借用、复制或 ownership transfer 的调用契约。操作集合要符合领域代数
能实现 operator 不等于应该提供。Money 的加法只对同币种闭合;不同币种需要 ExchangeRateContext,不能悄悄读取 global 汇率。
Result<Money, CurrencyMismatch> operator+(Money lhs, Money rhs);
Money convert(
Money source,
Currency target,
const ExchangeRateTable& rates,
RoundingPolicy rounding);比较也要决定 total ordering 是否存在。金额币种不同,直接 operator< 可能没有语义;显式转换后比较更诚实。
类型转换扩大了合法表达式集合
type conversions(类型转换)决定哪些其他类型能悄悄进入当前类型。隐式转换每增加一个,overload resolution 与调用者推理都会更复杂。
↡无需调用者显式写出转换即可由编译器插入的类型变化。Money(long long) 不应隐式,因为整数没有 currency;operator long long() 也会静默丢失币种。可改用命名 factory 和 accessor:
auto price = Money::fromMinor(1999, Currency::CNY);
auto units = price.value().minorUnits();无损且符合直觉的转换才可能隐式,例如 iterator 到 const_iterator;仍要测试 overload ambiguity。单参数 constructor 默认应先考虑 explicit。
继承必须回答 substitutability
inheritance(继承)不是代码复用按钮,而是在类型系统中声明关系。public inheritance 表示派生对象可在任何要求 base 的地方使用,并保持 base 的全部可观察契约。
↡任何接受 base 的正确程序替换为 derived 后,仍保持原有可观察正确性。若 FixedRateMoney 企图继承 Money,但禁止 currency 变化,而 base assignment 允许变化,substitutability 已破坏。只复用实现时应使用 composition 或 private implementation helper。
↡一个类型以成员对象组合另一个实现能力,而不向用户声明 is-a 关系。多态基类还要决定 virtual destructor、clone、copy policy、object slicing 与异常规格;这些都是 type contract,不是后补细节。
暴露与封装决定演进空间
public data、inline representation assumptions 和返回内部 handle 都会把实现变成用户依赖。类型应暴露语义操作,而不是存储步骤。
↡在不破坏用户代码的前提下更换表示、缓存、验证或算法的设计余量。Money 可以今天使用 int64 minor units,未来换成 decimal;只要 observable contract 不变,调用者无需修改。若公开 amount/scale 字段,迁移会穿透整个代码库。
↡用户源码或二进制已经依赖、后续版本必须维持或显式迁移的公开行为集合。越多 public member、conversion 和 operator,compatibility surface 越大。最小接口不是少写代码,而是减少未来无法撤回的承诺。
泛型需求与性能保证也要提前决定
若类型要进入 unordered container,需要 equality 与 hash 一致;要排序需要 ordering;要做 compile-time value 需要 literal-type 条件;要跨 ABI 需要稳定 layout 或 opaque handle。
↡为模板、容器或算法使用而必须满足的语法和语义要求集合。性能也是 observable contract 的一部分。用户可能依赖 move 是 O(1)、lookup 是对数复杂度、destructor 不阻塞。若无法长期保证,不要在接口文档中随意承诺。
↡类型操作对时间、空间、分配和阻塞行为给出的可依赖上界。用类型契约矩阵验收设计
先预测 Money 初稿会允许哪些非法表达式,再为每类决策建立证据:
- 构造:非法 currency、overflow、wire scale 不会发布对象。
- 复制移动:value type 独立;identity type 不可复制;moved-from 可析构和赋值。
- 赋值:self-assignment 与失败注入保持 invariant,资源不重复释放。
- 转换:整数不能隐式变 Money,信息损失必须命名且可见。
- 运算:不同币种相加返回明确错误,rounding policy 不依赖 global state。
- 继承:base contract tests 对每个 derived 重跑,验证 substitutability。
- 泛型:equality/hash、ordering 与 container requirements 保持一致。
- 性能:benchmark 验证承诺的复制、移动和 allocation 行为。
这张矩阵能区分“代码当前能跑”和“类型契约已经设计并可持续验证”。
小结
- 设计 class 就是在设计新 type,应先写 observable contract,再选字段与函数
- 合法值和 invariant 决定对象何时可以发布,创建与销毁共同形成生命周期协议
- initialization 建立对象,assignment 替换已有对象,两者的失败和 self-assignment 契约不同
- value/identity 语义决定 copy、move、destructor 和参数 ownership
- operator、type conversion 与 inheritance 都会扩大合法表达式集合,必须符合领域规律
- 用 type contract matrix 验证构造、转换、继承、泛型与性能承诺
名词解释
本章出现的专业名词,用大白话再讲一遍。
- class design as type design
从完整可观察契约出发设计 class。
- observable type contract
用户可观察的值、操作、失败和生命周期保证。
- value domain
- 类型允许表示的全部合法状态。
- representation invariant
对象生命周期内必须保持的状态关系。
- invariant preservation
public operation 完成后不变量仍成立。
- lifecycle protocol
验证、发布、转移和销毁对象的完整规则。
- value semantics
复制后语义相等且生命周期独立的值行为。
- identity semantics
表示唯一实体、不能随意复制的身份行为。
- initialization contract
对象生命周期开始时建立表示与不变量的规则。
- assignment contract
替换已有对象时的失败和 self-assignment 规则。
- strong assignment guarantee
赋值失败时原对象保持不变。
- valid moved-from state
移动后仍可安全析构和重新赋值的状态。
- value-transfer cost
复制、移动、析构和间接访问的成本。
- parameter ownership semantics
参数表达借用、复制或 ownership transfer。
- domain algebra
- 与领域规律一致的操作集合。
- operation closure
操作对所有合法输入都有明确合法结果。
- implicit conversion
编译器无需调用者明示即可插入的转换。
- explicit conversion boundary
调用点可见的验证或信息损失边界。
- substitutability
derived 替换 base 后仍保持 base 契约。
- composition for reuse
用成员组合复用实现而不声明 is-a。
- representation freedom
不破坏用户即可更换内部表示的空间。
- compatibility surface
用户已经依赖、后续必须维持的公开行为。
- generic contract
模板、容器和算法要求的语法语义集合。
- complexity guarantee
操作时间、空间、分配或阻塞的承诺。
- type contract matrix
类型设计决策与测试证据的对应清单。
练习
- 问题 1:审查 Money 初稿。 针对 public amount/currency/scale,写出完整 type contract 与迁移后的最小接口。
- 问题 2:设计 Connection 生命周期。 连接持有唯一 socket,open 会失败,业务需要借用和转移但不能复制。
- 问题 3:判断继承与转换。 FixedRateMoney 想继承 Money 并隐式接受整数,同时禁止改变 currency,分析问题并重构。