第31条:
要努力减少文件间的编译依赖
为了更新某个类的某个功能实现,你可能需要在浩瀚
C++
的代码中做出一个细小的修改,要提醒你的是,修改的地方不是类接口,而是实现本身,并且仅仅是私有成员。完成修改之后,你需要对程序进行重新构建,这时你肯定会认为这一过程将十分短暂,毕竟你只对一个类做出了修改。当你按下“构建”按钮,或输入
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( params ); //
定义一个
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 idiom
(指向实现的指针)。在这样的类中,指针名通常为
pImpl
,就像上面代码中一样。
通过这样的设计,
Person
的客户端程序员将会与日期、地址和人这些信息隔离开。你可以随时修改这些类的具体实现,但是
Person
的客户端程序员不需要重新编译。另外,由于客户端程序员无法得知
Person
的具体实现细节,他们就不容易编写出依赖于这些细节的代码。这样做真正起到了分离接口和实现的目的。
这项分离工作的关键所在,就是用声明的依赖来取代定义的依赖。这就是最小化编译依赖的核心所在:只要可行,就要将头文件设计成自给自足的,如果不可行,那么就依赖于其他文件中的声明语句,而不是定义。其他一切事情都应遵从这一基本策略。于是有:
l
只要使用对象的引用或指针可行时,就不要使用对象。
只要简单地通过类型声明,你就可以定义出类型的引用和指针。反观定义类型对象的情形,你就必须要进行类型定义了。
l
只要可行,就用类声明依赖的方式取代类定义依赖。
请注意你在使用一个类时,如果你需要声明一个函数,那么在任何情况下定义出这个类都不是必须的。即使这个函数以传值方式传递或返回这个类的对象:
class Date; //
类声明
Date today(); //
这样是可行的
void clearAppointments(Date d);//
但并没有必要对
Date
类做出定义
当然,传值方式在通常情况下都不会是优秀的方案,但是如果你发现某些情景下不得不使用传值方式时,就会引入不必要的编译依赖,你依然难择其咎。
在不定
义
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
流组件的声明,这些
IO
流组件相应的定义分别存放在不同的几个头文件中,包括:
<sstream>
、
<streambuf>
、
<fstream>
以及
<iostream>
。
从另一个角度来讲,使用
<iosfwd>
作示例也是颇有裨益的,因为它告诉我们本节中的建议不仅对非模板的类有效,而且对模板同样适用。尽管在第
30
条中分析过,在许多构建环境中,模板定义通常保存在头文件中,一些构建环境中还是允许将模板定义放置在非头文件的代码文件里,因此提供为模板提供仅包含声明的头文件并不是没有意义的。
<iosfwd>
就是这样一个头文件。
C++
提供了
export
关键字,它用于分离模板声明和模板定义。但是遗憾的是,编译器对
export
的支持是十分有限的,实际操作中
export
更似鸡肋。因此在高效
C++
编程中,
export
究竟扮演什么角色,讨论这个问题还为时尚早。
诸如
Person
此类使用
pimpl idiom
的类通常称为句柄类。为了避免你对这样的类如何完成这些工作产生疑问,一个途径就是将类中所有的函数调用放在相关的具体实现类之前,并且让这些具体实现类去做真实的工作。请看下面的示例,其中演示了
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
条
当然,与此同时,必须要对支持接口类的接口的具体类进行定义,并且必须有真实的构造函数得到调用。比如说,接口类
Person
必须有一个具体的派生类
RealPerson
,它应当为其继承而来的虚函数提供具体实现:
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;
};
有