Item 19:设计 class 犹如设计 type

对齐 Effective C++ 第三版 Item 19:把 class 当作完整新类型设计,系统决定合法值、对象创建销毁、初始化与赋值、复制移动、运算符、类型转换及继承契约。

学习目标

  • 能解释为什么 class design as type design,并列出合法值、生命周期、操作和错误契约
  • 能比较初始化与赋值、值语义与身份语义,据此设计 copy/move/destructor
  • 能判断 type conversions、operators 与 inheritance 是否满足领域规律和 substitutability
Class design as type design把每个 public member 视为对调用者新增的一条类型承诺observable type contract合法值 · 生命周期 · 操作 · 转换 · 失败 · 成本值域与不变量value domainprivate + validating factoryinvariant preservation生命周期object creation and destructionvalue / identity semanticscopy · move · destructor状态与操作initialization and assignmentdomain algebra · closurefailure guarantee边界与替换inheritance / substitutabilitytype conversionscomposition for reuse契约验收证据compile-negative testscopy / move / lifetime testsbase contract testsbenchmark / allocation evidence
类型设计的验收顺序:先定义可观察契约,再把值域、生命周期、操作与替换关系落成可测试的边界。

类型契约实验

从领域对象选择类型语义

先预测对象代表独立的值、唯一资源还是可替换的抽象,再切换场景查看 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 把所有责任推给每个调用者。

设计应先写出契约,再选择字段。字段只是实现契约的一种方式。

先定义合法值与不变量

类型首先要回答“什么对象是合法的”。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,要么失败且对象不变。

对象创建销毁是一份协议

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,但前提完全不同:初始化面对尚不存在的值,赋值面对一个已有合法对象。

Money price = Money::fromMinor(1999, Currency::CNY).value(); // initialization
Money backup = price;                                        // copy initialization
backup = discount;                                           // assignment

assignment 要说明币种是否可改变、失败时 lhs 是否保持原值、self-assignment 是否安全。若类型持有多个资源,copy-and-swap 或先构造 candidate 再 commit 可提供强异常保证。

移动后的源对象不必保持原值,但必须可析构、可重新赋值,并满足文档声明的最小状态。

按值传递暴露了什么成本

类型设计还要回答 pass-by-value 的含义与成本。Money 只有两个机器字且复制独立,按值可能合理;Connection 不可复制,只能 borrow 或 transfer。

对于多态基类,按值传递还会 slicing;对于小型 value type,过度强制 const reference 可能增加间接访问。接口应从语义和测量决定,而不是套统一口号。

操作集合要符合领域代数

能实现 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 的全部可观察契约。

若 FixedRateMoney 企图继承 Money,但禁止 currency 变化,而 base assignment 允许变化,substitutability 已破坏。只复用实现时应使用 composition 或 private implementation helper。

多态基类还要决定 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 验证构造、转换、继承、泛型与性能承诺

资料与写作方式声明

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

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

名词解释

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

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. 问题 1:审查 Money 初稿。 针对 public amount/currency/scale,写出完整 type contract 与迁移后的最小接口。
  1. 问题 2:设计 Connection 生命周期。 连接持有唯一 socket,open 会失败,业务需要借用和转移但不能复制。
  1. 问题 3:判断继承与转换。 FixedRateMoney 想继承 Money 并隐式接受整数,同时禁止改变 currency,分析问题并重构。

讨论

评论区加载中…