《Effective C++》:条款31:将文件间的编译依存关系降至最低
假如你在修改程序,只是修改了某个class的接口的实现,而且修改的是private部分。之后,你编译时,发现好多文件都被重新编译了。这种问题的发生,在于没有把“将接口从实现中分离”。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 the BirthDate;
Address theAddress;
};
要想编译,还要把class中用到的string、Date、Address包含进来。在Person
定义文件的最前面,应该有:
#include<string>
#include"date.h"
#include"address.h"
这样一来,Person
定义文件和其含入文件之间形成了一种编译依存关系(compilation dependency)。如果这些头文件有一个修改,那么使用Person class的文件要重新编译。这样的连串编译依存关系(cascading compliation dependencies)会给项目造成许多不便。
那么为什么C++把class的实现细目置于class定义式中?可以把实现细目分开:
namespace std{
class string;//前置声明
}
class Date;//前置声明
class Address;//前置声明
class Person{
public:
……
};
首先不讨论前置声明是否正确(实际上是错误的),如果可以这么做,Person的客户只需要在Person接口被修改时才重新编译。但是这个想法有两个问题。
- 1、string不是个class,它是个typedef,定义为basic_string。上面对string的前者声明并不正确,正确的前置声明比较复杂,因为涉及额外的templates。实际上,我们不应该声明标准库,使用#include即可。标准头文件一般不会成为编译瓶颈,尤其是在你的建置环境中允许使用预编译头文件(precompiled headers)。如果解析(parsing)标准头文件是个问题,一般情况是你需要修改你的接口设计。
- 2、前置声明的每一件东西,编译器必须在编译期间知道对象的大小。例如
int main()
{
int x;
Person p( params);
……
}
当编译器看到x定义式,必须知道给x分配多少内存;之后当编译器看到p的定义时,也应该知道必须给p分配多少内存。如果class的定义式不列出实现细目,编译器无法知道给p分配多少空间。
这个问题在Java等语言上不存在,因为它们在定义对象时,编译器只是分配一个指针(用来指向该对象)。上述代码实现是这个样子:
int main()
{
int x;
Person* p;//定义一个指向Person的指针
……
}
在C++中,也可以这样做,将对象实现细目隐藏在一个指针背后。针对Person,可以把它分为两个classes,一个负责提供接口,另一个负责实现该接口。负责实现的接口取名为PersonImpl(Person implementation):
#include<string>
#include<memory>
class PersonImpl; //前置声明
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;//指针,指向实现
};
这样的设计称为pimpl idiom(pimpl:pointer to implementation)。Person的客户和Date、Address以及Person的实现细目分离了。classes的任何实现修改都不要客户端重新编译。此外,客户还无法看到Person的实现细目,也就不会写出“取决于那些细目的代码”,真正实现了“接口与实现分离”。
这个分离的关键在于“声明的依存性”替换了“定义的依存性”,这正是编译依存性最小化的本质:现实中,让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明(不是定义)相依。其他都源于以下设计策略:
- 如果使用object references或object pointers可以完成任务,就不要使用object。因为,使用references或pointers只需要一个声明,而定义objects需要使用该类型的定义。
- 如果可以,尽量以class声明式替换class定义式。但是,当声明函数使用某个class时,即使是by value方式传递该类型参数/返回值,都不需要class定义。但是在使用这些函数时,这些classes在调用函数前一定要先曝光。客户终究是要知道classes的定义式,但是这样做的意义在于:将提供class定义式(通过#include完成)的义务,从函数声明所在头文件,转移到函数调用的客户文件。
- 为声明式和定义式提供两个不同的头文件。程序中,不应该让客户给出前置声明,程序作者一般提供两个头文件,一个用于声明式,一个用于定义式。在C++标准库的头文件中(条款54),
<iosfwd>
内含iostream各组件的声明式,其对应定义分布在不同文件件,包括<sstream>
,<streambuf>
,<fstream>
,<iostream>
。
<iosfwd>
说明,本条款同样适用于templates和non-templates。条款 30中提到,template通常定义在头文件内,但也有些建置环境允许template定义在非头文件;这样就可以将“只含声明式”的头文件提供给templates。<iosfwd>
就是这样一个文件。
C++中提供关键字export来将template声明和定义分割在不同文件内。但是支持export关键字的编译器并不多。
像Person这样使用pimpl idiom的classes叫做Handle classes。这样的class真正做事的方法之一是将他们所有的函数转交给相应的实现类(implementation classes),由实现类完成实际工作。例如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();
}
……
在PersonImpl中,有着和Person完全相同的成员函数,两者接口完全相同。
还有一种实现Handle class的办法,那就是令Person成为一种特殊的abstract base class(抽象基类),称作Interface class。这样的class成员变量,只是描述derived classes接口(条款 34),也没有构造函数,只有一个virtual析构函数( 条款 7)和这一组pure virtual函数,用来叙述整个接口。
Interface classes类似Java和.NET的Interface,但是C++的Interface class不同于Java和.NET中的Interface,它允许有变量,更具有弹性。正如 条款 36所言,“non-virtual函数的实现”对继承体系内所有classes都应该相同。将这样的函数实现为Interface class(其中写有相应声明)的一部分也是合理的。
像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或references,因为内含pure virtual函数的class无法实例化。这样一来,只要Interface class的接口不被修改,其他客户就不需要重新编译。
Interface class的客户在为class创建新对象时,通常使用一个特殊函数,这个函数扮演“真正将被具体化”的那个derived classes的构造函数的角色。这样的函数叫做工程函数factory(条款13)或virtual构造函数,它们返回指针(更有可能为智能指针,**条款**18),指向动态分配所得对象,这个对象支持Interface class接口。factory函数通常声明为static
class Person{
public:
……
static std::tr1::shared_ptr<Person create(……)
……
};
客户这样使用
std::string name;
Date dateOfBirth;
Address address;
std::tr1::shared_ptr<Person> pp(Person::create(……));
std::cout<<pp->name()<<"was born"<<pp->birthDate()<<"and now lives at"<<pp->address();
支持Interface class接口的那个具体类(concrete classes)在真正的构造函数调用之前要被定义好。例如,有个RealPerson继承了Person
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;
……
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));
}
RealPerson实现Interface class的机制是:从Interface class继承接口,然后实现出接口所覆盖的函数。还有一种实现方法,设计多重继承,在**条款**40探讨。
Handle classes和Interface classes解除了接口和实现之间的耦合关系,从而降低了编译依存性。但是为了也带了一些代价:使你丧失了运行期间若干速度,又开辟了超出对象若干内存。
在Handle classes身上,成员函数通过implementation pointer取得对象数据。这样为访问增加了一层间接性,内存也增加了implementation pointer的大小。implementation pointer的初始化,还要带了动态开辟内存的额外开销,蒙受遭遇bad_alloc异常的可能性。
在Interface classes身上,每个函数都是virtual的,所以每次调用要付出一个间接跳跃(indirect jump)成本。其派生对象会有一个vptr(virtual table pointer,**条款**7),增加了对象所需内存。
Handle classes和Interface classes,一旦脱离inline函数,都无法有太大作为。**条款**30说明为什么inline函数要置于头文件,但Handle classes和Interface classes被设计用来隐藏实现细节。
我们要做的是,在程序中使用Handle classes和Interface classes,以求实现代码有所变化时,对其客户带来最小影响。但如果它们导致的额外成本过大,例如导致运行速度或对象大小差异过大,以至于classes之间的耦合相比之下不成为关键时,就以具体类(concrete classes)替换Handle classes和Interface classes。
总结
- 支持“编译依存性最小化”的一般构想是:相依于声明式,不要相依于定义时。基于此构想的两个手段是Handle classes和Interface classes。
- 程序库头文件应该以“完全且仅有声明式”(full and declaration-only forms)的形式存在。不论是否这几templates,这种做法都是适用。
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。