在你的开发工作中,你可能需要对于某个类的具体实现做出一个细小的修改。提醒你一下,修改的地方不是类接口,而是实现本身,并且仅仅是私有部分。完成之后,你开始对程序进行重新构建,这时你肯定会认为这一过程将十分短暂,毕竟你只对一个类做出了修改。当你按下“构建”按钮,或输入make命令(或者其他什么等价的操作)之后,你惊呆了,然后你脸上便呈现出一个大大的囧字,因为你发现整个世界都重新编译并重新链接了!真是人神共愤!
问题的症结在于:C++并不擅长区分接口和实现。一个类的定义不仅指定了类接口的内容,而且指明了相当数量的实现细节。请看下面的示例:
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; // 具体实现
};
这里,如果无法访问Person具体实现所使用的类(即string、Date和Address)定义,那么Person类将不能够得到编译。通常这些定义通过#include指令来提供,因此在定义Person类的文件中,你应该能够找到这样的内容:
#include <string>
#include "date.h"
#include "address.h"
不幸的是,这样做使得定义Person的文件对这些头文件产生了依赖。如果任一个头文件的内容被修改了,或者这些头文件所依赖的另外某个头文件被修改,那么包含Person类的文件就必须重新编译,有多少个文件包含Person,就要进行多少次编译操作。这种层叠式的编译依赖将招致无法估量的灾难式后果。
你可能会考虑:为什么C++坚持要将类具体实现的细节放在类定义中呢?假如说,如果我们换一种方式定义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的客户来说,仅在类接口有改动时,才需要进行重新编译。
这种想法存在着两个问题。首先,string不是一个类,它是一个typedef(typedef basic_string<char> string)。于是,针对string的前置声明就是非法的。实际上恰当的前置声明要复杂的多,因为它涉及到其他的模板。然而这不是主要问题,因为你本来就不应该尝试手工声明标准库的内容。仅仅使用恰当的#include指令就可以了。标准头文件一般都不会成为编译中的瓶颈,尤其是在你的编译环境允许你利用预编译的头文件时更为突出。如果分析标准头文件对你来说的确是件麻烦事,那么你可能就需要改变你的接口设计,以避免使用那些会带来多余#include指令的标准类成员。
对所有的类做前置声明会遇到的第二个(同时也是更显著的)难题是:在编译过程中,编译器需要知道对象的大小。请观察下面的代码:
int main()
{
int x; // 定义一个int
Person p( 参数 ); // 定义一个Person
...
}
当编译器看到了x的定义时,它们就知道该为其分配足够的内存空间(通常位于栈中)以保存一个int值。这里没有问题。每一种编译器都知道int的大小。当编译器看到p的定义时,他们知道该为其分配足够的空间以容纳一个Person,但是他们又如何得知Person对象的大小呢?得到这一信息的唯一途径就是通过类定义,但是如果省略类定义具体实现细节是合法的,那么编译器又如何得知需要分配多大空间呢?
同样的问题不会在Smalltalk和Java中出现,因为在这些语言中,每当定义一个对象时,编译器仅仅分配指向该对象指针大小的空间。也就是说,在这些语言中,上面的代码将做如下的处理:
int main()
{
int x; // 定义一个int
Person *p; // 定义一个Person
...
}
当然,这段代码在C++中是合法的,于是你可以自己通过“将对象实现隐藏在指针之后”来玩转前置声明。对于Person而言,实现方法之一就是将其分别放在两个类中,一个只提供接口,另一个存放接口对应的具体实现。暂且将具体实现类命名为PersonImpl,Person类的定义应该是这样的:
#include <string> // 标准库成员,不允许对其进行前置声明
#include <memory> // 为使用tr1::shared_ptr; 稍后介绍
class PersonImpl; // Person实现类的前置声明
class Date; // Person接口中使用的类的前置声明
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
在这里,主要的类(Person)仅仅包括一个数据成员——一个指向其实现类(PersonImpl)的指针(这里是一个tr1::shared_ptr,参见条目13)。我们通常将这样的设计称为pimpl惯用法(指向实现的指针)。在这样的类中,指针名通常为pImpl,就像上面代码中一样。
通过这样的设计,Person的客户将会与日期、地址和人这些具体信息隔离开。你可以随时修改这些类的具体实现,但是Person的客户不需要重新编译。另外,由于客户无法得知Person的具体实现细节,他们就不容易编写出依赖于这些细节的代码。这样做真正起到了分离接口和实现的目的。
这项分离工作的关键所在,就是用声明的依赖来取代定义的依赖。这就是最小化编译依赖的核心所在:只要可行,就要将头文件设计成自给自足的,如果不可行,那么就依赖于其他文件中的声明语句,而不是定义。其他一切事情都应遵从这一基本策略。于是有:
l 只要使用对象的引用或指针可行时,就不要使用对象。只要简单地通过类型声明,你就可以定义出类型的引用和指针。反观定义类型对象的情形,你就必须要进行类型定义了。
l 只要可行,就用类声明依赖的方式取代类定义依赖。请注意,如果你需要声明一个函数,该函数会使用某个类,那么在任何情况下类的定义都不是必须的。即使这个函数以传值方式传递或返回这个类的对象:
class Date; // 类声明
Date today(); // 这样是可行的
void clearAppointments(Date d); // 没有必要对Date类做出定义
当然,传值方式在通常情况下都不会是优秀的方案(参见条目20),但是如果你出于某种原因使用了传值方式时,此时必将引入不必要的编译依赖,你依然难择其咎。
即使不定义Date的具体实现,today和clearAppointments依然可以正确声明,C++的这一能力可能会让你感到吃惊,但是实际上这一行为又没有想象中那么古怪。如果代码中任意一处调用了这些函数,那么在这次调用前的某处必须要对Date进行定义。此时你又有了新的疑问:为什么我们要声明没有人调用的函数呢,这不是多此一举吗?这一疑问的答案很简单:这种函数并不是没有人调用,而是不是所有人都会去调用。假设你的库中包含许多函数声明,这并不意味着每一位客户都会使用到所有的函数。上文的做法中,提供类定义的职责将从头文件中的函数声明转向客户端文件中包含的函数调用,通过这一过程,你就排除了手工造成的客户端类定义依赖,这些依赖实际上是多余的。
l 为声明和定义分别提供头文件。为了进一步贯彻上文中的思路,头文件必须要一分为二:一个存放声明,另一个存放定义。当然这些文件必须保持相互协调。如果某处的一个声明被修改了,那么相应的定义处就必须做出相应的修改。于是,库的客户就应该始终使用#include指令来包含一个声明头文件,而不是自己进行前置声明,库的作者应提供两个头文件。比如说,在Date的客户期望声明today和clearAppointments时,就应该无需向上文中那样,对Date进行前置声明。更好的方案是用#include指令来引入恰当的声明头文件:
#include "datefwd.h" // 包含Date类声明(而不是定义)的头文件
Date today(); // 同上
void clearAppointments(Date d);
头文件“datefwd.h”中仅包含声明,这一名字基于C++标准库中的<iosfwd>(参见条目54)。<iosfwd>包含着IO流组件的声明,这些组件相应的定义分别存放在不同的几个头文件中,包括:<sstream>、<streambuf>、<fstream>以及<iostream>。
从另一个角度来讲,使用<iosfwd>作示例还有一定的示范效应,因为它告诉我们本节中的建议不仅对非模板的类有效,而且对模板同样适用。尽管在条目30中分析过,在许多构建环境中,模板定义通常保存在头文件中,一些构建环境中还是允许将模板定义放置在非头文件的代码文件里,因此提供为模板提供仅包含声明的头文件并不是没有意义的。<iosfwd>就是这样一个头文件。
C++提供了export关键字,它用于分离模板声明和模板定义。但是遗憾的是,支持export的编译器是十分有限的,现实中export的应用更是寥寥无几。因此在高效C++编程中,export究竟扮演什么角色,讨论这个问题还为时尚早。
诸如Person此类使用pimpl惯用法的类通常称为句柄类。为了避免你对这样的类如何工作产生疑问,一个途径就是将类中所有的函数调用放在相关的具体实现类之前,并且让这些具体实现类去做真实的工作。请看下面的示例,其中演示了Person的成员函数应该如何实现:
#include "Person.h" // 我们将编写Person类的具体实现,
// 因此此处必须包含类定义。
#include "PersonImpl.h" // 同时,此处必须包含PersonImpl的类定义,
// 否则我们将不能调用它的成员函数;请注意,
// 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的构造函数是如何调用PersonImpl的构造函数的(通过使用new - 参见条目16),以及Person::name是如何调用PersonImpl::name的。这两点很重要。将Person定制为一个句柄类并不会改变它所做的事情,而是仅仅改变它做事情的方式。
除了句柄类的方法,我们还可以采用一种称为“接口类”的方法来将Person定制为特种的抽象基类。这种类的目的就是为派生类指定一个接口(参见条目34)。于是,通常情况下它没有数据成员,没有构造函数,但是拥有一个虚析构函数(参见条目7),以及一组指定接口用的纯虚函数。
接口类与Java和.NET中的接口一脉相承,但是C++并没有像Java和.NET中那样对接口做出非常严格的限定。比如说,无论是Java还是.NET都不允许接口中出现数据成员或者函数实现,但是C++对这些都没有做出限定。C++拥有更强灵活性是有实用价值的。就像条目36中所解释的那样,由于非虚函数的具体实现对于同一层次中所有的类都应该保持一致,因此不妨将这些函数实现放置在声明它们的接口类中,这样做是有意义的。
Person的接口类可以是这样的:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};
这个类的客户必须要基于Person的指针和引用来编写程序,因为实例化一个包含纯虚函数的类是不可能的。(然而,实例化一个继承自Person的类却是可行的——参见下文。)就像句柄类的客户一样,接口类客户除非遇到接口类的接口有改动的情况,其他任何情况都不需要对代码进行重新编译。
接口类的客户必须有一个创建新对象的手段。通常情况下,它们可以通过调用真正被实例化的派生类中的一个函数来实现,这个函数扮演的角色就是派生类的构造函数。这样的函数通常被称作工厂函数(参见条目13)或者虚构造函数。这种函数返回一个指向动态分配对象的指针(最好是智能指针——参见条目18),这些动态分配的对象支持接口类的接口。这样的函数通常位于接口类中,并且声明为static的:
class Person {
public:
...
static std::tr1::shared_ptr<Person> // 返回一个tr1::shared_ptr,
create(const std::string& name, // 它指向一个Person对象,这个
const Date& birthday, // Person对象由给定的参数初始化,
const Address& addr); // 为什么返回智能指针参见条目18
...
};
客户这样使用:
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
当然,与此同时,必须要对支持某一接口类的接口的具体类做出定义,并且必须有真实的构造函数得到调用。比如说,有一个具体的派生类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; // 这里省略了这些函数的具体实现,
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还可以以一个更加贴近现实的方法来实现,它应能够创建不同种类的派生类对象,创建的过程基于某些相关信息,例如:新加入的函数的参数值、从一个文件或数据库中得到读到的数值,环境变量,等等。
RealPerson向我们展示了实现接口类的两种最通用的实现机制之一:它的接口规范继承自接口类(Person),然后实现接口中的函数。第二种实现接口类的方法牵扯到多重继承,那是条目40中探索的主题。
句柄类和接口类将接口从实现中分离开来,因此降低了文件间的编译依赖。如果你喜欢吹毛求疵,那么你一定在等待我来添加一条注释。“这么多变魔术般古怪的事情会带来多大开销?”这个问题的答案就是计算机科学中极为普遍的一个议题:你的程序在运行时更慢了一步,另外,每个对象所占的空间更大了一点。
使用句柄类的情况下,成员函数必须通过实现指针来取得对象的数据。这样无形中增加了每次访问时迂回的层数。同时,为保证每个对象都拥有足够的内存空间,你必须增大实现指针所指向的区域的内存空间。最后,你必须要对实现指针进行初始化(在句柄类的构造函数中),以便于将其指向一个动态分配的实现对象,这样做将会招致动态内存分配(以及由此产生的释放)所带来的固有的开销,也有可能遭遇bad_alloc(内存不足)异常。
由于对于接口类来说每次函数调用都是虚拟的,因此你在每调用一次函数的过程中你就会为其付出一次间接跳转的代价(参见条目7)。同时,派生自接口类的对象必须包含一个虚函数表指针(依然参见条目7)。这一指针也可能会加大保存一个对象所需要的空间,这取决于接口类是否是该对象中虚函数的唯一来源。
最后,无论是句柄类还是接口类,都不适合于过多使用内联。条目30中解释了为什么一般情况下要将内联的函数体放置在头文件中,然而句柄和接口类都是特别设计用来隐藏诸如函数体等具体实现内容的。
然而,对于句柄类和接口类来说,仅仅由于它们会带来一些额外的开销就远离它们,也是一个严重的错误。你并不会因为虚函数存在缺点放弃使用它,不是吗?(如果你真的想放弃,那么你可能看错书了。)你应该以一个进化演进的方式来使用这些技术。在开发过程中,尝试使用句柄类和接口类来减少改动具体实现时为客户带来的影响。在生产环境中,如果应用这些技术导致程序的速度和/或大小的变动足够显著,从而冲淡了不同的类之间所增加关系度的影响,应适时使用具体的类来取代句柄类和接口类。
时刻牢记
l 最小化编译依赖的基本理念就是使用声明依赖代替定义依赖。基于这一理念有两种实现方式,它们是:句柄类和接口类。
l 库的头文件必须以完整、并且仅存在声明的形式出现。无论是否涉及模板。