概述
假设你对C++程序的某个class实现文件做了些轻微修改。注意,修改的不是class接口,而是实现,而且只改private成分。然后重新建置这个程序,并预计只花数秒就好。毕竟只有一个class被修改。你按下“Build”按钮或键入make(或其它类似命令),然后大吃一惊,然后感到窘困,因为你意识到整个世界都被重新编译和连接了!当这种事情发生,难道你不气恼吗?
问题出在C++并没有把“将接口从实现中分离”这事做得很好。Class的定义式不只详细叙述了class接口,还包括十足的实现细目。例如:
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::string theName; // 实现细目
Date theBirthDate; // 实现细目
Address theAddress; // 实现细目
};
这里的class Person无法通过编译——如果编译器没有取得其实现代码所用到的classes string,Date和Address的定义式。这样的定义式通常由#include指示符提供,所以Person定义文件的最上方很可能存在这样的东西:
#include <string>
#include "date.h"
#include "address.h"
不幸的是,这么一来便是在Person定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件中有任何一个被改变,或这些头文件所依赖的其他头文件有任何改变,那么每一个含入Person class的文件就得重新编译,任何使用Person class的文件也必须重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。
你或许会奇怪,为什么C++坚持将class的实现细目置于class定义式中?为什么不这样定义Person,将实现细目分开叙述?
namespace std {
class string; // 前置声明(不正确,详下)
}
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;
...
};
如果可以那么做,Person的客户就只需要在Person接口被修改过时才重新编译。
这个想法存在两个问题:
第一,string不是个class,它是个typedef(定义为basic_string<char>)。因此上述针对string而做的前置声明并不正确;正确的前置声明比较复杂,因为涉及额外的templates。然而那并不要紧,因为你本来就不该尝试手工声明一部分标准程序库。你应该仅仅使用适当的#includes完成目的。标准头文件不太可能成为编译瓶颈,特别是如果你的建置环境允许你使用预编译头文件。如果解析标准头文件真的是个问题,你可能需要改变你的接口设计,避免使用标准程序库中“引发不受欢迎之#includes”那一部分。
第二: 关于“前置声明每一件东西”的另一个(同时也是比较重要的)困难是,编译器必须在编译期间知道对象的大小。考虑这个:
int main()
{
int x; // 定义一个int
Person p(params); // 定义一个Person
...
}
当编译器看到x的定义式,它知道必须分配多少内存(通常位于stack内)才够持有一个int。没问题,每个编译器都知道一个int有多大。当编译器看到p的定义式,它也知道必须分配足够空间以放置一个Person,但它如何知道一个Person对象多大呢?编译器获得这项信息的唯一办法就询问class定义式。然而如果class定义式可以合法地不列出实现细目,编译器如何知道该分配多少空间?
此问题在Smalltalk,Java等语言上并不存在,因为当我们以那种语言定义对象时,编译器只分配足够空间给一个指针(用以指向该对象)使用。也就是说它们将上述代码视同这样:
int main()
{
int x; // 定义一个int
Person *p; // 定义一个指针指向Person对象
...
}
这当然也是合法的C++代码,所以你也可以自己玩玩“将对象实现细目隐藏于一个指针背后”的游戏。针对Person我们也可以这样做:把Person分割为两个classes,一个只提供接口,另一个负责实现该接口。如果负责实现的那个所谓implementation class取名为PersonImpl,Person将定义如下:
#include <string>
#include <memory> // 此乃为了tr1::shared_ptr而含入
class PersonImpl; // Person实现类的前置声明
class Date; // Person接口用到的classes的前置声明
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; // 指针,指向实现物:std::tr1::shared_ptr 见条款13
};
在这里,main class(Person)只内含一个指针成员(这里使用tr1::shared_ptr,见条款13),指向其实现类(PersonImpl)。这般设计常被称为pimpi idiom(pimpl是“point to implementation”的缩写)。这种classes内的指针名称往往就是pImpl,就像上面代码那样。
这样的设计下,Person的客户就完全与Dates,Addresses以及Persons的实现细目分离了。那些classes的任何实现修改都不需要Person客户端重新编译。这真正是“接口与实现分离”!
这个分离的关键在于以“声明的依存性”替换“定义的依存性”,那正是编译依存性最小化的本质:现实中让头文件尽可能自我满足,万一做不到,则让它与其它文件内的声明式(而非定义式)相依。其它每件事都源自于这个简单的设计策略:
- 如果使用object references或object pointers可以完成任务,就不要使用objects。你可以只靠一个类型声明式就定义出指向该类型的references和pointers;但如果定义某类型的objects,就需要用到该类型的定义式。
- 如果可以,尽量以class声明式替换class定义式。注意,当你声明一个函数而它用到某个class时,你并不需要该class的定义;纵使函数以by value方式传递该类型的参数(或返回值)亦然:
class Date; // class声明式
Date today(); // 没问题,这里并不需要
void clearAppointments(Date d); // Date的定义式
当然,pass-by-value一般而言是个糟糕的主意(见条款20),但如果你发现因为某种因素被迫使用它,并不能够就此为“非必要之编译依存关系”导入正当性。
声明today函数和clearAppointments函数而无需定义Date,这种能力可能会令你惊讶,但它并不是真的那么神奇。一旦任何人调用那些函数,调用之前Date定义式一定得先曝光才行。
- 为声明式和定义式提供不同的头文件。为了促进严守上述准则,需要两个头文件,一个用于声明式,一个用于定义。当然,这些文件必须保持一致性,如果有个声明式被改变了,两个文件都得改变。
像Person这样使用pimpl idiom的classes,往往被称为Handles classes。也许你会纳闷,这样的classes如何真正做点事情。办法之一是将它们的所有函数转交给相应的实现类(implementation classes)并有后者完成实际工作。例如下面是Person两个成员函数的实现:
#include "Person.h" // 我们正在实现Person class,所以必须#include其class定义式
#include "PersonImpl.h" // 我们也必须#include PersonImpl的class定义式,否则无法调用其成员函数;
// 注意,PersonImpl有着和Person完全相同的成员函数,两者接口完全相同。
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。
在Handle classes身上,成员函数必须通过implementation pointer取得对象数据。那会为每一次访问增加一层间接性。而每一个对象消耗的内存数量必须增加implementation pointer的大小。最后,implementation pointer必须初始化(在Handle class构造函数内),指向一个动态分配得来的implementation object,所以你将蒙受因动态内存分配(及其后的释放动作)而来的额外开销,以及遭遇bad_alloc异常(内存不足)的可能性。
至于Interface classes,由于每个函数都是virtual,所以你必须为每次函数调用付出一个间接跳跃成本(见条款7)。此外Interface class派生的对象必须内含一个vptr(virtual table pointer),这个指针可能会增加存放对象所需的内存数量——实际取决于这个对象除了Interface class之外是否还有其他virtual函数来源。
最后,不论Handle classes或Interface classes,一旦脱离inline函数都无法有太大作为。条款30解释过为什么函数本体为了被inlined必须(很典型地)置于头文件内,但Handle classes和Interface classes正是特别被设计用来隐藏实现细节,如函数本体。
请记住
- 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义式。基于此构想的两个手段是Handle classes和Interface classes。
- 程序库头文件应该以“完全且仅有声明式”的形式存在。这种做法不论是否涉及templates都适用。
个人观点:该条款描述的内容挺多,用了很多实例来论证,刚开始阅读时不太理解,反复看了4遍才感觉理解其中的观点。其中的核心思想是为了降低文件编译依存关系,尽量以指针来代替类定义。在实际项目开发中,一般情况下都很少在意这种情况,改动之后编译时间长一点都能接受,不过开发时尽量避免在.h文件中include大量的其它文件,最好能用@class。
最后
以上就是迷你野狼为你收集整理的将文件间的编译依存关系降至最低——条款31的全部内容,希望文章能够帮你解决将文件间的编译依存关系降至最低——条款31所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复