Effective C++手册Item 31: 最小化文件之间的编译依赖

Item 31: 最小化文件之间的编译依赖

作者:Scott Meyers

译者:fatalerror99 (iTePub's Nirvana)

发布:http://blog.csdn.net/fatalerror99/

你进入到你的程序中,并对一个类的实现进行了细微的改变。提醒你一下,不是类的接口,只是实现,仅仅是 private 的东西。然后你重建(rebuild)这个程序,预计这个任务应该只花费几秒钟。毕竟只有一个类被改变。你在 Build 上点击或者键入 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;        // implementation detail
      Date theBirthDate;          // implementation detail
      Address theAddress;         // implementation detail
};

在这里,如果不访问 Person 的实现使用到的类,也就是 string,Date 和 Address 的定义,类 Person 就无法编译。这样的定义一般通过 #include 指令提供,所以在定义 Person 类的文件中,你很可能会找到类似这样的东西:

#include 
#include "date.h"
#include "address.h"

不幸的是,这样就建立了定义 Person 的文件和这些头文件之间的编译依赖关系。如果这些头文件中的一些发生了变化,或者这些头文件所依赖的文件发生了变化,包含 Person 类的文件和使用了 Person 的文件一样必须重新编译,这样的层叠编译依赖关系为项目带来数不清的麻烦。

你也许想知道 C++ 为什么坚持要将一个类的实现细节放在类定义中。例如,你为什么不能这样定义 Person,单独指定这个类的实现细节呢?

namespace std {
     class string;             // forward declaration (an incorrect
}                              // one — see below)
class Date;                    // forward declaration
class Address;                 // forward declaration
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 (for basic_string)。造成的结果就是,string 的前向声明(forward declaration)是不正确的。正确的前向声明要复杂得多,因为它包括另外的模板。然而,这还不是要紧的,因为你不应该试着手动声明标准库的部件。作为替代,直接使用适当的 #includes 并让它去做。标准头文件不太可能成为编译的瓶颈,特别是在你的构建环境允许你利用预编译头文件时。如果解析标准头文件真的成为一个问题。你也许需要改变你的接口设计,避免使用导致不受欢迎的 #includes 的标准库部件。

第二个(而且更重要的)难点是前向声明的每一件东西必须让编译器在编译期间知道它的对象的大小。考虑:

int main()
{
 int x;                // define an int
 Person p( params );   // define a Person
   ...
}

当编译器看到 x 的定义,它们知道它们必须为保存一个 int 分配足够的空间(一般是在栈上)。这没什么问题,每一个编译器都知道一个 int 有多大。当编译器看到 p 的定义,它们知道它们必须为一个 Person 分配足够的空间,但是它们怎么推测出一个 Person 对象有多大呢?它们得到这个信息的唯一方法是参考这个类的定义,但是如果一个省略了实现细节的类定义是合法的,编译器怎么知道要分配多大的空间呢?

这个问题在诸如 Smalltalk 和 Java 这样的语言中就不会发生,因为,在这些语言中,当一个类被定义,编译器仅仅为一个指向一个对象的指针分配足够的空间。也就是说,它们处理上面的代码就像这些代码是这样写的:

int main()
{
  int x;               // define an int
  Person *p;           // define a pointer to a Person
  ...
}

当然,这是合法的 C++,所以你也可以自己来玩这种“将类的实现隐藏在一个指针后面”的游戏。对 Person 做这件事的一种方法就是将它分开到两个类中,一个仅仅提供一个接口,另一个实现这个接口。如果那个实现类名为 PersonImpl,Person 就可以如此定义:

#include                       // standard library components
                                       // shouldn't be forward-declared
#include                       // for tr1::shared_ptr; see below
class PersonImpl;                      // forward decl of Person impl. class
class Date;                            // forward decls of classes used in
class Address;                         // Person interface
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:                                   // ptr to implementation;
  std::tr1::shared_ptr pImpl;  // see Item 13 for info on
};                                         // std::tr1::shared_ptr

这样,主类(Person)除了一个指向它的实现类(PersonImpl)的指针(这里是一个 tr1::shared_ptr ——参见 Item 13)之外不包含其它数据成员。这样一个设计经常被说成是使用了 pimpl 惯用法(指向实现的指针 "pointer to implementation")。在这样的类中,那个指针的名字经常是 pImpl,就像上面那个。

用这样的设计,使 Person 的客户脱离 dates,addresses 和 persons 的细节。这些类的实现可以随心所欲地改变,但 Person 的客户却不必重新编译。另外,因为他们看不到 Person 的实现细节,客户就不太可能写出以某种方式依赖那些细节的代码。这就是接口和实现的真正分离。

这个分离的关键就是用对声明的依赖替代对定义的依赖。这就是最小化编译依赖的精髓:只要能实现,就让你的头文件独立自足,如果不能,就依赖其它文件中的声明,而不是定义。其它每一件事都从这个简单的设计策略产生。所以:

  • 当对象的引用和指针可以做到时就避免使用对象。仅需一个类型的声明,你就可以定义到这个类型的引用或指针。而定义一个类型的对象必须要存在这个类型的定义。

  • 只要你能做到,就用对类声明的依赖替代对类定义的依赖。注意你声明一个使用一个类的函数时绝对不需要有这个类的定义,即使这个函数通过传值方式传递或返回这个类:

    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)的头文件 包含 iostream 组件的声明,而它们相应的定义在几个不同的头文件中,包括

    在其它方面也有启发意义,而且它解释了本 Item 的建议对于模板和非模板一样有效。尽管 Item 30 解释了在很多构建环境中,模板定义的典型特征是位于头文件中,但有些环境允许模板定义在非头文件中,所以为模板提供一个仅有声明的头文件依然是有意义的。 就是一个这样的头文件。

    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    // 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 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::create(const std::string& name,
                                            const Date& birthday,
                                            const Address& addr)
{
  return std::tr1::shared_ptr(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 类。

  • 库头文件应该以完整并且只有声明的形式存在。无论是否包含模板都适用于这一点。