当对象的引用和指针可以做到时就避免使用对象。仅需一个类型的声明,你就可以定义到这个类型的引用或指针。而定义一个类型的对象必须要存在这个类型的定义。只要你能做到,就用对类声明的依赖替代对类定义的依赖。注意你声明一个使用一个类的函数时绝对不需要有这个类的定义,即使这个函数通过传值方式传递或返回这个类:class Date; // class declaration
Date today(); // fine - no definition
void clearAppointments(Date d); // of Date is needed
当然,传值通常不是一个好主意(参见 Item 20),但是如果你发现你自己因为某种原因而使用它,依然不能为引入不必要的编译依赖辩解。
不声明 Date 就可以声明 today 和 clearAppointments 的能力可能会令你感到惊奇,但是它其实并不像看上去那么不同寻常。如果有人调用这些函数,则 Date 的定义必须在调用之前被看到。为什么费心去声明没有人调用的函数,你想知道吗?很简单。并不是没有人调用它们,而是并非每个人都要调用它们。如果你有一个包含很多函数声明的库,每一个客户都要调用每一个函数是不太可能的。通过将提供类定义的责任从你的声明函数的头文件转移到客户的包含函数调用的文件,你就消除了客户对他们并不真的需要的类型的依赖。
为声明和定义分别提供头文件。为了便于坚持上面的指导方针,头文件需要成对出现:一个用于声明,另一个用于定义。当然,这些文件必须保持一致。如果一个声明在一个地方被改变了,它必须在两处都被改变。得出的结果是:库的客户应该总是 #include 一个声明文件,而不是自己前向声明某些东西,而库的作者应该提供两个头文件。例如,想要声明 today 和 clearAppointments 的 Date 的客户不应该像前面展示的那样手动前向声明 Date。更合适的是,它应该 #include 适当的用于声明的头文件:#include "datefwd.h" // header file declaring (but not
// defining) class Date
Date today(); // as before
void clearAppointments(Date d);
仅有声明的头文件的名字 "datefwd.h" 基于来自标准 C++ 库(参见 Item 54)的头文件 <iosfwd>。<iosfwd> 包含 iostream 组件的声明,而它们相应的定义在几个不同的头文件中,包括 <sstream>,<streambuf>,<fstream> 和 <iostream>。
<iosfwd> 在其它方面也有启发意义,而且它解释了本 Item 的建议对于模板和非模板一样有效。尽管 Item 30 解释了在很多构建环境中,模板定义的典型特征是位于头文件中,但有些环境允许模板定义在非头文件中,所以为模板提供一个仅有声明的头文件依然是有意义的。<iosfwd> 就是一个这样的头文件。
C++ 还提供了 export 关键字允许将模板声明从模板定义中分离出来。不幸的是,支持 export 的编译器非常少,而与 export 打交道的实际经验就更少了。结果是,现在就说 export 在高效 C++ 编程中扮演什么角色还为时尚早。
像 Person 这样的使用 pimpl 惯用法的类经常被称为 Handle 类。为了避免你对这样的类实际上做什么事的好奇心,一种方法是将所有对他们的函数调用都转送给相应的实现类,而使用实现类来做真正的工作。例如,这就是两个 Person 的成员函数可以被如何实现的例子:
#include "Person.h" // we're implementing the Person class,
// so we must #include its class definition
#include "PersonImpl.h" // we must also #include PersonImpl's class
// definition, otherwise we couldn't call
// its member functions; note that
// PersonImpl has exactly the same
// member functions as Person - their
// interfaces are identical
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 ——参见 Item 16),以及 Person::name 是如何调用 PersonImpl::name 的。这很重要。使 Person 成为一个 Handle 类不需要改变 Person 要做的事情,仅仅是改变了它做事的方法。
另一个不同于 Handle 类的候选方法是使 Person 成为一个被叫做 Interface 类的特殊种类的抽象基类。这样一个类的作用是为派生类指定一个接口(参见 Item 34)。结果,它的典型特征是没有数据成员,没有构造函数,有一个虚析构函数(参见 Item 7)和一组指定接口的纯虚函数。
Interface 类类似 Java 和 .NET 中的 Interfaces,但是 C++ 并不会为 Interface 类强加那些 Java 和 .NET 为 Interfaces 强加的种种约束。例如,Java 和 .NET 都不允许 Interfaces 中有数据成员和函数实现,但是 C++ 不禁止这些事情。C++ 的巨大弹性是有用处的。就像 Item 36 解释的,在一个继承体系的所有类中非虚拟函数的实现应该相同,因此将这样的函数实现为声明它们的 Interface 类的一部分就是有意义的。
一个 Person 的 Interface 类可能就像这样:
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 派生的类是可能的——参见后面。)和 Handle 类的客户一样,除非 Interface 类的接口发生变化,否则 Interface 类的客户不需要重新编译。
一个 Interface 类的客户必须有办法创建新的对象。他们一般通过调用一个为“可以真正实例化的派生类”扮演构造函数的角色的函数做到这一点的。这样的函数一般称为 factory 函数(参见 Item 13)或虚拟构造函数(virtual constructors)。他们返回指向动态分配的支持 Interface 类的接口的对象的指针(智能指针更合适——参见 Item 18)。这样的函数在 Interface 类内部一般声明为 static:
class Person {
public:
...
static std::tr1::shared_ptr<Person> // return a tr1::shared_ptr to a new
create(const std::string& name, // Person initialized with the
const Date& birthday, // given params; see Item 18 for
const Address& addr); // why a tr1::shared_ptr is returned
...
};
客户就像这样使用它们:
std::string name;
Date dateOfBirth;
Address address;
...
// create an object supporting the Person interface
std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() // use the object via the
<< " was born on " // Person interface
<< pp->birthDate()
<< " and now lives at "
<< pp->address();
... // the object is automatically
// deleted when pp goes out of
// scope - see Item 13
当然,在某些地点,必须定义支持 Interface 类的接口的具体类并调用真正的构造函数。这所有的一切发生的场合,在那个文件中所包含虚拟构造函数的实现之后的地方。例如,Interface 类 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; // implementations of these
std::string birthDate() const; // functions are not shown, but
std::string address() const; // they are easy to imagine
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 示范了两个最通用的实现一个 Interface 类机制之一:从 Interface 类(Person)继承它的接口规格,然后实现接口中的函数。实现一个 Interface 类的第二个方法包含多继承(multiple inheritance),在 Item 40 中探讨这个话题。
Handle 类和 Interface 类从实现中分离出接口,因此减少了文件之间的编译依赖。如果你是一个喜好挖苦的人,我知道你正在找小号字体写成的限制。“所有这些把戏会骗走我什么呢?”你小声嘀咕着。答案是计算机科学中非常平常的:它会消耗一些运行时的速度,再加上每个对象的一些额外的内存。
在 Handle 类的情况下,成员函数必须通过实现的指针得到对象的数据。这就在每次访问中增加了一个间接层。而且你必须在存储每一个对象所需的内存量中增加这一实现的指针的大小。最后,这一实现的指针必须被初始化(在 Handle 类的构造函数中)为指向一个动态分配的实现的对象,所以你要承受动态内存分配(以及随后的释放)所固有的成本和遭遇 bad_alloc (out-of-memory) 异常的可能性。
对于 Interface 类,每一个函数调用都是虚拟的,所以你每调用一次函数就要支付一个间接跳转的成本(参见 Item 7)。还有,从 Interface 派生的对象必须包含一个 virtual table 指针(还是参见 Item 7)。这个指针可能增加存储一个对象所需的内存的量,依赖于这个 Interface 类是否是这个对象的虚函数的唯一来源。
最后,无论 Handle 类还是 Interface 类都不能在 inline 函数的外面大量使用。Item 30 解释了为什么函数本体一般必须在头文件中才能做到 inline,但是 Handle 类和 Interface 类一般都设计成隐藏类似函数本体这样的实现细节。
然而,因为它们所涉及到的成本而简单地放弃 Handle 类和 Interface 类会成为一个严重的错误。虚拟函数也是一样,但你还是不能放弃它们,你能吗?(如果能,你看错书了。)作为替代,考虑以一种改进的方式使用这些技术。在开发过程中,使用 Handle 类和 Interface 类来最小化实现发生变化时对客户的影响。当能看出在速度和/或大小上的不同足以证明增加类之间的耦合是值得的时候,可以用具体类取代 Handle 类和 Interface 类供产品使用。
Things to Remember
最小化编译依赖后面的一般想法是用对声明的依赖取代对定义的依赖。基于此想法的两个方法是 Handle 类和 Interface 类。库头文件应该以完整并且只有声明的形式存在。无论是否包含模板都适用于这一点。