Item 31:将文件间的编译依赖降至最低

对齐 Effective C++ 第三版 Item 31:区分完整类型与前置声明需求,以 pimpl handle class 和 abstract interface class 隔离实现,正确处理 incomplete type 析构、复制、factory、ABI 与 modules 边界。

学习目标

  • 能解释 public header 的 textual include 如何形成 compilation dependency blast radius
  • 能实现 forward declaration 加 pimpl handle class,正确处理 incomplete unique owner 的 destructor/copy/move
  • 能比较 handle class、interface class、header-only template 与 module 的编译、运行和 ABI 取舍
从 public header 到客户 TU 的依赖路径private layout 可见时,implementation change 会沿 textual include 扩散Address.hppprivate cache 改动person.hppby-value private layoutui.cppservice.cpptests.cpppimpl boundaryperson.hpp 只保留 forward declaration + unique owner;完整 PersonImpl 留在 source目标:实现字段变化不穿透 compilation interface隔离 rebuild blast radius,同时保留 self-contained header
上图先展示“直接 include”的扩散,再把同一变化收进 pimpl 的实现边界;真正的成本仍需通过重编译和 ABI 实验验收。

边界选择实验

先预测:Address.hpp 改一行,谁必须重编译?

选择一个 public boundary,观察完整类型、pimpl handle class 和 interface class 如何改变传播范围。

编译依赖边界:改动会传到哪里?compilation dependencies 不是 include 数量,而是实现变化触发的可见范围person.hpp完整 Address / Datepublic compilation interfacetextual include实现细节进入 public header没有隐藏层ui.cppimporterservice.cppimportertests.cppimporter改动实验Address 改一行 → 全部客户 TU访问直接,但 compilation dependency blast radius 最大。先找 completeness requirement,再选择隔离边界forward declaration · handle class · interface class · pimpl idiom

当前证据

Address 改一行 → 全部客户 TU

访问直接,但 compilation dependency blast radius 最大。

验收问题

你的选择是否仍满足 self-contained header?下一步应做 header isolation test、touch experiment 和 ABI diff。

猜一猜:只把 Address 的一个 private 字段移到 PersonImpl,三种边界中哪些 translation units 会重新编译?先选一个方案,再用下面的证据核对。

从 Address 私有字段导致全库重编译开始

Person header 直接包含所有字段类型:

#include "address.hpp"
#include "date.hpp"
#include <string>
 
class Person {
public:
    Person(std::string name, Date birthday, Address address);
 
private:
    std::string name_;
    Date birthday_;
    Address address_;
};

Address 只增加一个 private cache,却会改变 address.hpp;person.hpp 包含它,所有包含 Person 的 translation units 都可能重编译。

Item 31 的原则是 Minimize compilation dependencies between files(将文件间的编译依赖降至最低)。目标不是让 include 数量好看,而是让 implementation change 不沿 public interface 扩散。

interface 与 implementation 应分离

Person 用户需要知道可调用 operations,不一定需要知道 name/address/date 怎样存储。

public header 泄漏 private by-value members 时,C++ access control 隐藏访问权限,却没有隐藏 layout/compile dependency。

需要额外间接层把 storage 从 public class layout 移走。

forward declaration 能声明 incomplete type

class Date;
class Address;
class PersonImpl;

compiler 可声明 pointer/reference 和只涉及它们的函数:

void printAddress(const Address& value);
Address* findAddress(PersonId id);

函数 definition 解引用、访问 member 或构造 Address 时仍需 include 完整定义。

何时必须完整类型

by-value data member 需要 sizeof/alignment,base class 必须完整,inline body 若调用 member 也需要 definition。

class Address;
 
class InvalidPerson {
    Address address_; // 错误:layout 需要完整 Address
};

pointer/reference 自身大小已知,因此可指向 incomplete type。

pimpl 建立 handle class

public Person 只保存 implementation owner:

// person.hpp
#pragma once
#include <memory>
#include <string_view>
 
class PersonImpl;
 
class Person {
public:
    Person(std::string_view name, const Date& birthday, const Address& address);
    ~Person();
 
    Person(Person&&) noexcept;
    Person& operator=(Person&&) noexcept;
 
    std::string name() const;
 
private:
    std::unique_ptr<PersonImpl> pImpl_;
};

Person layout 只包含固定大小 unique owner;Address/Date/container changes 不再改变 public layout。

unique owner 的 destructor 要放在完整类型可见处

unique_ptr<PersonImpl> 可以声明时指向 incomplete type,但默认 deleter 真正 delete 时需要 PersonImpl 完整。

// person.cpp
#include "person.hpp"
#include "person_impl.hpp"
 
Person::~Person() = default;
Person::Person(Person&&) noexcept = default;
Person& Person::operator=(Person&&) noexcept = default;

将 destructor inline/defaulted 在 header 可能过早实例化 delete 路径。move/copy operations 也应按 compiler/library 行为放在 source 并测试。

handle class 要明确 value 与 ownership 语义

unique pimpl 默认让 Person 不可复制。若 Person 应是 value type,需要实现 deep copy:

Person::Person(const Person& rhs)
    : pImpl_(std::make_unique<PersonImpl>(*rhs.pImpl_)) {}
 
Person& Person::operator=(Person rhs) {
    swap(rhs);
    return *this;
}

若应共享 identity,可用 shared owner,但要明确 thread safety、copy-on-write 与 cycle。不能让 pointer 选择偶然决定 public semantics。

copy-and-swap 还能提供 strong assignment guarantee,但成本需测量。

inline forwarding 会重新引入实现依赖

Person operations 通常在 source 中转交:

std::string Person::name() const {
    return pImpl_->name();
}

若把 body 写进 header,compiler 需要看见 PersonImpl member declarations,pimpl 边界被打穿。

LTO 可在构建时跨 source 优化 forwarding,不必为内联永久泄漏 header dependency。

interface class 隐藏全部 concrete representation

另一策略是 pure abstract interface:

class Person {
public:
    virtual ~Person() = default;
    virtual std::string name() const = 0;
    virtual Date birthday() const = 0;
 
    static std::unique_ptr<Person> create(
        std::string_view name,
        const Date& birthday,
        const Address& address);
};

factory 在 source 中构造 concrete implementation,客户只依赖 abstract API。

它适合 runtime replaceability/plugin,但引入 virtual dispatch、heap allocation 和 pointer ownership。

handle class 与 interface class 的取舍

handle class 保留普通 value-like syntax、固定 public layout 和非 virtual API;interface class 支持多个 implementations 和 runtime polymorphism。

不要为了减少 rebuild 把所有小 value types 都 heap/polymorphic 化;间接访问、allocation、cache locality 与复杂 ownership 是真实成本。

标准库类型不要自行前置声明

不能在 namespace std 手写 class string;;标准类型可能是 template specialization/alias,实现声明由标准 header 负责。

可在 public API 使用 string_view/span 等轻量 vocabulary types,但仍 include 对应标准 header。

前置声明仅用于自己控制且声明形式稳定的 user types。

include what you use 与最小依赖并不冲突

header 必须 self-contained:单独 include 时能编译;不应依赖客户先包含某个文件。

最小依赖指移除不必要完整定义,不是制造隐式 include order。

source file 仍应显式 include implementation 真正使用的 headers。

modules 减少文本解析但不取消语义依赖

C++ modules 可把 exported interface 与 private implementation partitions 分开,避免每个 TU 重复预处理相同 headers。

但 exported by-value field/layout、inline/template body 和 ABI 变化仍会使 importers 语义依赖;module cache 也需重建。

pimpl/interface separation 在 modules 时代仍用于 ABI 和变化隔离,不只是加速 parser。

ABI 稳定需要定义可变范围

pimpl 让 Person public sizeof/alignment 稳定,新增 Impl fields 不改变客户 layout。它不能自动保证 vtable、exception ABI、allocator 和 semantic compatibility。

版本测试要同时检查 ABI diff 和 behavior contract。

用 include graph 与触碰实验验收

先预测修改 PersonImpl 一个 private field 会重编译哪些 translation units,再执行验证:

  • include graph/-ftime-trace 找出 public header 重依赖和解析热点。
  • touch Address implementation/header,统计增量重编译 targets。
  • header isolation tests 验证每个 public header 自包含。
  • compile-negative tests 确认 public header 不能访问 incomplete Impl members。
  • pimpl destructor/copy/move 在只 include person.hpp 的客户 TU 编译链接运行。
  • ABI checker 比较 Person sizeof/alignment/symbols 在 Impl 修改前后稳定。
  • interface factory tests 覆盖 create failure、virtual destruction 和多个 implementations。
  • runtime benchmark 测量 allocation、indirection、virtual dispatch 与 cache locality 成本。
  • modules on/off 构建比较 parser 时间,同时验证 semantic dependency 未被误判为消失。

重构成功的证据是实现变化只重编译拥有实现的少量 targets,而不是 include 数量下降。

小结

  • public header 包含 implementation definitions 会让小改动沿 include graph 形成 rebuild blast radius
  • forward declaration 只提供 incomplete type 名称;by-value layout、inheritance 和 member access 需要 complete type
  • pimpl handle class 以固定大小 owner 隔离表示,destructor/copy/move 应在完整 Impl 可见处定义
  • interface class 加 polymorphic factory 完全隐藏 concrete representation,代价是 virtual/heap/ownership
  • self-contained header 与最小依赖并不冲突;标准库类型必须包含标准 header
  • modules 减少 textual parsing,但 layout、template、inline 和 ABI 的 semantic dependency 仍存在

资料与写作方式声明

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

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

名词解释

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

compilation dependency

文件为解析布局或生成代码依赖另一文件的关系。

rebuild blast radius

header 变化触发的下游重编译范围。

compilation interface

客户编译所需的公开声明和契约。

compilation implementation

应局限于实现单元的字段算法与 helper。

private-layout exposure

private fields 仍暴露 layout 编译依赖。

forward declaration

只声明类型名字而不提供完整定义。

incomplete type

名字已知但 layout/members 未知的类型。

complete type

大小、alignment、members 与 bases 已知的类型。

completeness requirement point

语言操作首次需要完整定义的位置。

handle class

持有 implementation owner 并转交操作的 public class。

pimpl idiom

以 pointer-to-implementation 隐藏 private representation。

out-of-line destructor

在 source 的完整类型环境定义 destructor。

incomplete-owner destruction rule

deleter 销毁时需完整 pointee 的约束。

deep-copy pimpl

复制 handle 时创建独立 implementation。

handle ownership semantics

handle copy/move 映射的值或身份契约。

out-of-line forwarding function

source 中转交到 Impl 的薄操作。

implementation-revealing inline

要求 header 看见 Impl 的 inline body。

interface class

仅含 pure virtual contract 的抽象 base。

polymorphic factory

隐藏 concrete creation 并返回 interface owner。

handle implementation model

固定 handle 转发并强调值/ABI 的模型。

interface implementation model

pure virtual 支持运行期替换的模型。

standard-library declaration boundary

只从标准 header 获得 std 声明。

vocabulary type

公共边界表达数据和 ownership 的稳定类型。

self-contained header

不依赖 include 顺序即可独立编译的 header。

header isolation test

单独编译 public header 的验证。

module interface unit

向 importers 导出 declarations 的模块单元。

semantic compilation dependency

对 exported layout/template/inline 的语义依赖。

binary compatibility surface

已编译客户依赖的 layout 和 symbols。

semantic compatibility

公开行为契约的跨版本兼容。

compilation dependency matrix

依赖、重编译、ABI 和成本的审计表。

练习

  1. 问题 1:把 Person 改为 pimpl。 要求客户只 include person.hpp,支持 move 和 deep copy,请列出关键定义位置。
  1. 问题 2:选择 handle class 或 interface class。 值对象只有一个实现但需 ABI 稳定;插件有多个运行期实现,分别选择。
  1. 问题 3:审计 modules 迁移。 团队认为改为 import 后可以公开全部 fields/templates 而不再有编译依赖,请纠正并验证。

讨论

评论区加载中…