假设你修改了某个类的实现------不是类接口,只是实现部分,仅仅是一些私有成员。然后你重新构建程序,心想这应该只花几秒钟,毕竟只改动了一个类。你点击"构建"或输入 make(或其他等价命令),结果却震惊地发现------整个世界都被重新编译和链接了!你难道不恨这种时刻吗?
问题的根源在于,C++ 在接口与实现的分离方面做得不够好。类的定义不仅指明了接口,还包含了相当多的实现细节。例如:

这里的问题在于,Person 类的编译离不开它所使用的那些类(即 string、Date 和 Address)的定义。这些定义通常通过 #include 指令提供,所以在定义 Person 类的文件中,你很可能看到类似这样的内容:

遗憾的是,这就在定义 Person 的文件与这些头文件之间建立了编译依赖。只要其中任何一个头文件发生了改动,或者它们所依赖的头文件发生了改动,包含 Person 类的文件就必须重新编译,所有用到 Person 的文件也一样。这种级联式的编译依赖,已经给无数项目带来了难以言表的痛苦。
你可能会想:为什么 C++ 非要把类的实现细节塞进类定义里呢?比如,为什么不能像下面这样定义 Person,把实现细节单独拎出来呢?

如果真能这样做,那么 Person 的客户就只有在类的接口发生变化时才需要重新编译。
但这个想法存在两个问题。第一,string 并非一个类,而是一个 typedef(即 basic_string<char> 的别名),所以上面的前置声明是不正确的。正确的声明要复杂得多,还涉及额外的模板。不过这其实不重要------你压根不该尝试手动声明标准库的各个部分。正确的做法是直接使用相应的 #include,一了百了。标准头文件不太可能成为编译瓶颈,尤其当你的构建环境支持预编译头文件时。如果解析标准头文件确实成了问题,那可能需要调整接口设计,设法避免用到那些会引发不必要 #include 的标准库组件。
第二个(也更严重的)难点在于:编译器在编译期间必须知道对象的大小。想一想:

当编译器看到 x 的定义时,它们知道需要分配足够的空间(通常在栈上)来容纳一个 int,这没有问题------每个编译器都知道 int 有多大。但当编译器看到 p 的定义时,它们知道需要为一个 Person 分配足够的空间,可它们怎么知道一个 Person 对象有多大呢?唯一能获取这一信息的途径就是查阅类定义。但如果类定义中允许省略实现细节,编译器又凭什么知道该分配多少空间呢?
这个问题在 Smalltalk 和 Java 这样的语言中根本不会出现,因为在这类语言中定义一个对象时,编译器只分配足够存放一个指针的空间。也就是说,它们会把上面的代码当作下面这样来处理:

这种做法在 C++ 中当然是合法的,所以你也可以亲自玩"把对象实现藏在指针背后"的把戏。对 Person 而言,一种实现方式是把类拆成两个:一个只提供接口,另一个负责实现该接口。如果实现类叫作 PersonImpl,那么 Person 的定义大致如下:

在这种设计中,主类(Person)的数据成员仅包含一个指向其实现类(PersonImpl)的指针(此处用的是 tr1::shared_ptr,参见条款13)。这种设计通常被称为 pimpl 惯用法("pointer to implementation",即"指向实现的指针")。采用这种写法的类中,指针成员的名字通常叫 pImpl,就像上面那样。
借助这个设计,Person 的客户就与 Date、Address 以及 Person 自身的实现细节彻底解耦了。这些类的实现可以随意修改,但 Person 的客户无需重新编译。此外,由于客户无法看到 Person 实现的细节,他们也就不太可能写出依赖这些细节的代码。这真正实现了接口与实现的分离。
这种分离的关键在于:把对定义的依赖替换为对声明的依赖。这正是最小化编译依赖的本质------只要可行,就让你的头文件能够自给自足;做不到时,就依赖其他文件中的声明而非定义。其他所有做法都源自这一简单的设计策略。因此:
1.只要能用对象引用或指针,就不要用对象本身。你可以仅凭一个类型声明就定义出指向该类型的引用或指针;但要定义该类型的对象,就必须先有该类型的定义。
2.尽可能依赖类声明而非类定义。注意,声明一个使用某类的函数时,你从来不需要该类的定义------即使是按值传递或返回该类类型也不需要:

当然,按值传递通常不是什么好主意(参见条款20),但如果你出于某种原因确实用了它,那也没有理由因此引入不必要的编译依赖。
你不用先定义 Date 就能声明函数 today 和 clearAppointments,这也许让你感到意外,但其实并不像看上去那么离奇。如果有人调用了这些函数,那么 Date 的定义必须在调用之前可见。那你可能又会问:何必费劲声明那些没人调用的函数呢?道理很简单------并不是没人调用,而是并非每个人都调用。如果你的库里有几十个函数声明,不太可能每个客户端都会调用其中每一个函数。通过把提供类定义的责任从你的函数声明头文件转移到调用函数的客户源文件中,你就消除了客户对他们实际上并不需要的类型定义的人为依赖。
3.为声明和定义分别提供独立的头文件。为了便于遵循上述指导原则,头文件应该成对出现:一个用于声明,另一个用于定义。当然,这些文件必须保持一致------如果一个地方的声明改了,两个地方都得同步更新。因此,库的客户端应该始终 #include 一个声明头文件,而不是自己手动做前置声明;库的作者则应当同时提供这两个头文件。例如,上面想要声明 today 和 clearAppointments 的 Date 客户,就不应该像前面那样手动前置声明 Date,而应该 #include 对应的声明头文件:

这个仅包含声明的头文件命名为 datefwd.h,其灵感来自标准 C++ 库中的头文件 <iosfwd>(参见条款54)。<iosfwd> 包含了 iostream 各组件的声明,而它们对应的定义则分散在几个不同的头文件中,包括 <sstream>、<streambuf>、<fstream> 和 <iostream>。
<iosfwd> 还有另一个值得借鉴之处:它清楚地表明,本条款的建议同样适用于模板和非模板。虽然条款30指出,在许多构建环境中模板的定义通常放在头文件里,但有些构建环境允许将模板定义放在非头文件中,因此为模板提供仅包含声明的头文件依然是有意义的------<iosfwd> 本身就是这样一个头文件。
C++ 还提供了 export 关键字,意图实现模板声明与定义的分离。可惜,编译器对 export 的支持非常有限,实际项目中的使用经验更是少之又少。因此,现在要断言 export 在高效 C++ 编程中能扮演什么角色,还为时过早。
像 Person 这样采用 pimpl 惯用法的类,通常被称为 句柄类(Handle classes)。如果你好奇这类类到底是怎么干活的,一种方式就是把所有函数调用转发给对应的实现类,由那些实现类来完成真正的工作。例如,下面是 Person 两个成员函数的实现方式:

注意观察 Person 的构造函数是如何调用 PersonImpl 构造函数的(通过 new,参见条款16),以及 Person::name 是如何调用 PersonImpl::name 的。这一点很重要------把 Person 做成一个句柄类,并不会改变 Person 的功能,只是改变了它实现功能的方式。
句柄类之外,还有另一种做法:把 Person 做成一种特殊的抽象基类,称为接口类(Interface class)。这种类的目的是为派生类指定一个接口(参见条款34),因此它通常没有数据成员,没有构造函数,有一个虚析构函数(参见条款7),以及一组用来描述接口的纯虚函数。
接口类与 Java 和 .NET 中的接口(Interface)类似,但 C++ 对接口类的限制没有 Java 和 .NET 那么严格------例如,Java 和 .NET 都不允许在接口中放置数据成员或函数实现,但 C++ 对这两者都不禁止。C++ 的这种灵活性有时很有用。正如条款36所解释的,继承体系中的所有类应该共享相同的非虚函数实现,所以把这类函数作为接口类的一部分来实现是合理的。
Person 的接口类大致如下:

该类的客户必须通过 Person 的指针或引用来编写代码,因为包含纯虚函数的类无法被实例化(不过,从 Person 派生的类是可以实例化的,见下文)。与句柄类的客户一样,接口类的客户只有在接口类的接口被修改时才需要重新编译。
接口类的客户必须有一种方式来创建新对象。通常,他们通过调用一个扮演"真正被实例化的派生类的构造函数"角色的函数来实现,这类函数通常称为工厂函数(factory functions)(参见条款13)或虚构造函数(virtual constructors)。它们返回指向动态分配对象的指针(最好是智能指针,参见条款18),这些对象支持该接口类的接口。这类函数通常在接口类内部声明为 static:

客户使用方式如下:

当然,最终一定得定义那些真正支持接口类的具象类,并调用真正的构造函数。这些工作都发生在虚构造函数实现所在的文件中,对外不可见。比如,接口类 Person 可以有一个具象派生类 RealPerson,来实现它继承的那些虚函数:

有了 RealPerson 之后,写 Person::create 就真的很简单了:

一个更现实的 Person::create 实现,会根据额外函数参数的值、从文件或数据库读取的数据、环境变量等因素,创建不同类型的派生类对象。
RealPerson 展示了实现接口类的两种最常见机制之一:从接口类(Person)继承接口规范,然后实现接口中的各个函数。另一种实现方式涉及多重继承,这是条款40探讨的话题。
句柄类和接口类将接口与实现解耦,从而减少了文件间的编译依赖。我知道你肯定在等着看代价------"这些花招到底要付出什么?"答案是计算机科学里常见的那一套:运行时速度会打些折扣,每个对象也会多占一些内存。
就句柄类而言,成员函数要通过实现指针才能访问对象数据------每次访问都多了一层间接性。另外,每个对象还要额外存储一个实现指针的大小。最后,实现指针需要在句柄类的构造函数中初始化,指向一个动态分配的实现对象,所以你还得承担动态内存分配(以及后续释放)本身的开销,还有可能遇到 bad_alloc(内存不足)异常。
就接口类而言,每次函数调用都是虚调用,所以每次调用都多付出一次间接跳转的代价(参见条款7)。此外,从接口类派生的对象还必须包含一个虚表指针(同样参见条款7)。这个指针可能会增加存储对象所需的内存,具体取决于接口类是否为该对象唯一提供虚函数来源。
最后,句柄类和接口类都很难从内联函数中获得多少好处。条款30解释过,函数体通常必须放在头文件中才能被内联,而句柄类和接口类恰恰是为了隐藏函数体这类实现细节而设计的。
不过,仅仅因为句柄类和接口类伴随一定的代价就弃之不用,那将是一个严重的错误------虚函数也有代价,可你并不会因此就放弃它们吧(如果是的话,那你就看错书了)?更好的做法是采用渐进的方式使用这些技术:开发阶段先用句柄类和接口类,把对客户的影响降到最低;等到实际运行时,如果证实速度或内存方面的差异大到足以证明增加类间耦合是值得的,再换成具象类投入生产。
切记:
1.最小化编译依赖的核心思路是依赖声明而非定义。基于这一思路的两种实现手法是句柄类和接口类。
2.库的头文件应以"完整版"和"仅声明版"两种形态存在。无论是否涉及模板,这一原则都适用。