[Effective C++ --031]将文件间的编译依存关系降至最低

引言:编译时间成本

在项目中我们都会碰到修改既存类的情况:某个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都适用。

posted @ 2015-01-22 13:38  依然冷月  阅读(263)  评论(0编辑  收藏  举报