条款31: 将文件间的编译依存关系降至最低
Minimize compilation dependencies between files.假设对C++程序的某个 class 实现文件做了轻微修改. 注意修改的不是 class 接口,而是实现,而且只改 private 成分.然后重新build这个程序,并预计花费数秒就好.毕竟只有一个 class 被修改,结果大吃一惊! 貌似整个世界都被重新编译和连接了!
问题出在C++并没有把"将接口从实现中分离"这件事做的很好. class 的定义式不只是详细叙述了 class 接口,还包括实现细目.例如:
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() cnst;
std::string birthDate() const;
std::string address() const;
...
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
这里的 class Person无法通过编译——如果编译器没有取得实现代码所用到的 class string, Date和Address的定义式.这样的定义式通常是由#include提示符提供,所以Person定义文件的最上方很可能存在这样的东西:
#include <string>
#include "date.h"
#include "address.h"
不幸的是,这样一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系.如果这些头文件中有任何一个被改变,或者这些头文件所依赖的其他头文件有任何改变,那么每一个含入Person class 的文件就要重新编译,任何使用Person class 的文件也必须重新编译.
为什么C++坚持将 class 的实现细目置(数据成员)于 class 定义式中?为什么不在定义式中只是定义函数?
因为编译器必须在编译期间知道对象的大小.考虑这个:
int main() {
int x;
Person p(params); // 定义一个Person
}
当编译器看到 x 的定义式,它知道必须分配多少内存(通常位于stack内)才够维持有一个 int.OK,每一个编译器都知道一个 int 有多大.当编译器看到 p 的定义式,它也知道必须分配足够空间以放置一个Person,但
它如何知道一个Person对象有多大呢?编译器获得这项信息的
唯一办法就是询问 class 定义式.然而
如果 class 定义式可以合法地不列出实现细目(成员变量),编译器如何知道分配多少空间?
此问题在Java语言上并不存在,因为当以Java定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用,也就是将上述代码视为下面这样:
int main() {
int x;
Person *p; // 定义一个指针指向Person对象
}
这对C++也是合法的代码,所以可以
将对象实现细目隐藏于一个指针背后.
针对Person可以这样做:把Person分割为两个 class,一个只提供接口,另一个负责实现该接口.如果负责实现的那个所谓implementation class 取名为PersonImpl,Person将定义如下:
#include <string>
#include <memory> // 为了tr1::shared_ptr
class PersonImpl; // Person实现类的前置声明
class Date;
class Address;
class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::tr1::shared_ptr<PersonImpl> pImpl; // 指针,指向实现物
};
main class(Person)只内含一个指针成员(
这里使用tr1::shared_ptr,详见条款13),
指向其实现类PersonImpl.这样的设计称为pimpl idiom(pimpl是pointer to implementation).这种 class 内的指针名称往往就是pImpl.
这样的设计,Person的客户就完全与Date,Address以及Person的实现细目分离了.那些 class 的任何实现修改都不需要Person客户端重新编译.此外由于客户无法看到Person的实现细目,也就不可能写出"取决于那些细目"的代码.这是真正的"接口与实现分离"!
这个 分离的关键在于以"声明的依存性"替换"定义的依存性",那正是 编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依.其他每一件事都源自于这个简单的设计策略:
1.如果使用object reference或object pointer可以先完成任务,就不要使用object.可以只靠一个类型声明式就定义出指向该类型的reference和pointer,但如果定义类型的object,就需要用到该类型的定义式.
2.如果能够,尽量以 class 声明式替换 class 定义式. 注意,当声明一个函数而它用到某个 class 时,并不需要该 class 的定义:纵使函数以by value方式传递该类型的参数(或返回值)亦然:
class Date; // class声明式
Date today(); // OK,这里并不需要Date的定义式
void clearAppointment(Date d); // OK
声明tody函数和clearAppointment函数而无需定义Date,这种能力可能令人惊讶.一旦任何人调用那些函数,调用之前Date定义式一定要先曝光.那么或许会疑惑,为何费心声明一个没有人调用的函数呢?其实,并非没人调用,而是并非每个人都调用.假设一个函数库内包含数百个函数声明,不可能每个客户都调用每一个函数.
如果能够将"提供class的定义"(通过#include完成)的义务从"函数声明所在"的头文件转到"内含函数调用"的客户文件,
便可将"并非真正必要的类型定义"与客户端之间的
编译依存性去除掉.
3.为声明式和定义式提供不同的头文件.为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义式.因此程序库客户应该总是#include一个声明文件而非前置声明若干函数.
像Person这样使用pimpl idiom的 class,往往被称为 Handle class.那么这样的 class 如何真正做点事情?其中一个 办法是将它们的所有函数转交给相应的实现类(implementation class)并由后者完成实际工作.例如下面是Person两个成员函数的实现:
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}
std::string Person::name() const {
return pImpl->name();
}
请注意,Person构造函数以 new(详见
条款16)调用PersonImpl构造函数,以及Person::name函数内调用PersonImpl::name.这是重要的,让Person变成一个Handle class 并不会改变它做的事,只会改变它做事的方法.
另一个制作Handle class 的办法是, 令Person成为一种特殊的abstract base class(抽象基类),称为Interface class.这种 class 的 目的是详细描述derived class 的接口(详见条款34), 因此它通常不带成员变量,也没有构造函数,只有一个 virtual 析构函数(详见 条款7) 以及一组pure virtual 函数,用来叙述整个接口.
Interface class 类似Jave和.Net的Interface,但C++的Interface class 并不需要负担Java和.NET的Interface所需负担的责任.例如,Java和.NET都不允许在Interfaces内实现成员变量或成员函数,但C++不禁止这两样东西.
一个针对Person而写的Interface class 或许看起来像这样:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
};
这个 class 的客户必须以Person的pointers和reference来撰写应用程序,因为它不可能针对"内含pure virtual函数"的Person class 具现出实体.就像Handle class 的客户一样,除非Interface class 的接口被修改否则客户不需要重新编译.
Interface class 的客户必须有办法 为这种 class 创建新对象,他们 通常调用一个特殊函数,此函数扮演"真正将被具现化"的那个derived class 的构造函数角色.这样的函数通常称为 factory(工厂)函数(详见 条款13)或 virtual 构造函数.它们返回指针(或更为可取的智能指针,详见 条款18),指向动态分配所得对象,而该对象支持Interface class 的接口.这样的 函数又往往在Interface class 内被声明为static.
class Person {
public:
...
static std::tr1::shared_ptr<Person> // 返回一个tr1::shared_ptr指向一个新的Person
create(const std::string& name, const Date& birthday, const Address& addr);
...
};
客户会这样使用它们:
std::string name;
Date dateOfBirth;
Address address;
...
// 创建一个对象,支持Person接口
std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() // 通过Person的接口使用这个对象
<< "was born on " << pp->birthDate() << " and now lives at "
<< pp->address();
... // 当pp离开作用域,对象会被自动删除,详见条款13
当然,支持Interface class 接口的那个具象类必须被定义出来,而真正的构造函数必须被调用.一切都在 virtual 构造函数实现码所在的文件内秘密发生.假设Interface class Person有个具象的derived class RealPerson,后者提供继承而来的 virtual 函数的实现:
class RealPerson : public Person {
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr)
{}
virtual ~RealPerson() {}
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
有了RealPerson之后,写出Person::create就真的一点都不稀奇了:
std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr) {
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
一个更现实的Person::create实现代码会创建不同类型的derived class 对象,取决于诸如额外参数值,读自文件或数据库的数据,环境变量等等.
RealPerson示范实现Interface class 的两个最常见机制之一: 从Interface class 继承接口规格,然后实现出接口所覆盖的函数.Interface class 的第二个实现方法涉及多重继承,详见条款40.
Handle class 和Interface class 解除了接口和实现之间的耦合关系,从而降低文件间的编译依存性.这些方法付出的代价是:在运行期丧失若干速度,又为每个对象超额付出若干内存.
在Handle class 身上,成员函数必须通过implementation pointer取得对象数据.那会为每一次访问增加一层间接性.而每一个对象消耗的内存数量必须增加implementation pointer的大小.最后,implementation pointer必须初始化(在Handle class 构造函数内),指向一个动态分配得来的implementation object,所以将蒙受动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性.
至于Interface class,由于每个函数都是 virtual,所以必须为每次函数调用付出一个间接跳跃(indirect jump)成本(详见 条款7).此外Interface class 派生的对象必须内含一个vptr(virtual table pointer,详见 条款7),这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface class 之外是否还有其他 virtual 函数来源.
最后,不论Handle class 或Interface class,一旦脱离 inline 函数都无法有太大作为.
如果只因为若干额外成本就不考虑Handle class 和Interface class,将是严重的错误. virtual 函数不也带来成本吗?但是绝对不会放弃它们.因此 应该考虑以渐进方式使用这些技术. 在程序发展过程中使用Handle class 和Interface class 以求实现码有所变化时对其客户带来最小冲击.而 当它们导致速度或大小差异过大以至于 class 之间的额外相比之下不成为关键时,就以具象类(contrete class)替换Handle class 和Interface class.
注意:
支持"编译依存性最小化"的一般构想是:相依于声明式,不要相依于定义式.基于此构想的两个手段是Handle class 和Interface class .
程序库头文件应该以"完全仅有声明式"(full and declaration-only forms)的形式存在,这种做法不论是否涉及 template 都适用.