引言:编译时间成本
在项目中我们都会碰到修改既存类的情况:某个class实现文件做了些轻微改变,修改的不是接口,而是实现,而且只改private成分。
重新build这个程序,并预计只花数秒就好,当按下“Build”,结果整个世界都被重新编译和链接了!
问题是在c++并没有把“将接口从实现中分离”做得很好。class 的定义式不只详细叙述了class接口,还包括十足的实现细目:
例如:
1 class Person{ 2 public: 3 Person(const std::string& name, const Date& birthday, const Address& addr); 4 std::string name() const; 5 std::string birthDate() const; 6 std::string address() const; 7 ... 8 private: 9 std::string theName; //实现细节 10 Date theBirthDate; //实现细节 11 Address theAddress; //实现细节 12 };
在这个类上方,应该还存在着:
1 #include <string> 2 #include "date.h" 3 #include "address.h"
头文件。
这样一来,便在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些文件所依赖的其他头文件有任何改变,那么每个含入Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。这样的的连串编译依存关系(cascading compilation dependencies)会对许多项目造成难以形容的灾难。
第一节 实现细节和声明分开
为什么c++坚持将class的实现细目置于class定义式中?为什么不这样定义Person,将实现细目分开叙述:
1 namespace std { class string;} //前置声明(不正确) 2 class Date; //前置声明 3 class Address; //前置声明 4 class Person{ 5 public: 6 Person(const std::string& name, const Date& birthday, const Address& addr); 7 std::string name() const; 8 std::string birthDate() const; 9 std::string address() const; 10 ... 11 };
如果这样,Person的客户就只有在Person接口被修改时才重新编译。
但这样有两个问题:
第一,string不是个class,它是个typedef。因此string前置声明并不正确,而且你本来就不应该尝试手工声明一部分标准程序库。你应该仅仅使用适当的#includes完成目的。标准头文件不太可能成为编译瓶颈。
第二,编译器必须在编译期间知道对象的大小,考虑这个:
1 int main() 2 { 3 int x; // 定义一个int 4 Person p(params); // 定义一个Person 5 }
编译器看到X的时候,它都知道一个int有多大。但是当它看到p的时候,知道必须分配足够空间放置一个Person,但是他必须知道一个Person对象多大,获得这一信息的唯一办法是询问class定义式。然而,如果class定义式可以合法的不列出实现细目,编译器如何知道该分配多少空间?
针对这个问题,smalltalk,java实现了一个类似下面代码的逻辑:
1 int main() 2 { 3 int x; // 定义一个int 4 Person p(params); // 定义一个指针指向Person 5 }
这样的功能在C++被叫做PIMPL,即:
1 #include <string> 2 #include <memory> 3 class PersonImpl; 4 class Date; 5 class Address; 6 class Person{ 7 public: 8 Person(const std::string& name, const Date& birthday, const Address& addr); 9 std::string name()const; 10 std::string birthDate() const; 11 std::string address()const; 12 ... 13 private: 14 std::tr1::shared_ptr<PersonImpl> pImpl; //指向实现物的指针 15 };
这样,Person的客户就完全与Date,Address以及Person的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。
这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明式(而非定义式)相依。其他每件事都源自于这个简单的涉及策略:
如果用object reference 或 object pointer可以完成任务,就不要用objects。可以只靠声明式定义出指向该类型的pointer和reference;但如果定义某类型的objects,就需要用到该类型的定义式。
如果能够,尽量以class声明式替换class定义式。当你声明一个函数而它用到某个class时,你并不需要该class的定义式,纵使函数以by value方式传递该类型的参数(或返回值)亦然:
1 class Date; //class 声明式 2 Date today(); 3 void clearAppiontments(Date d);
为声明式和定义式提供不同的头文件。
这种使用pimpl idiom的classes,往往被称为Handle classes。
这种classes的办法之一就是将他们的所有函数转交给相应的实现类(implementation classes)并由后者完成实际工作。
1 #include "Person.h" 2 #include "PersonImpl.h" 3 Person::Person(const std::string& name, const Date& birthday, const Address& addr) 4 : pImpl(new PersonImpl(name, birthday, addr)) 5 {} 6 std::string Person::name() const 7 { 8 return pImpl->name(); 9 }
另一个制作Handle class的办法是,令Person称为一种特殊的abstract base class(抽象基类)称为Interface classes。这种class的目的是详细一一描述derived classes的接口,因此它通常不带成员变量,也没有构造函数,只有一个virtual析构函数以及一组pure virtual函数,又来叙述整个接口。
一个针对Person而写的Interface class或许看起来像这样:
1 class Person{ 2 public: 3 virtual ~Person(); 4 virtual std::string name() const = 0; 5 virtual std::string birthday() const = 0; 6 virtual std::string address() const = 0; 7 ... 8 };
◆总结
1.支持“编译依存最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
2.程序库头文件应该以“安全且仅有声明式”(full and declaration-only forms)的形式存在。这种做法不论是否涉及templates都适用。