Shuffy

不断的学习,不断的思考,才能不断的进步.Let's do better together!
posts - 102, comments - 43, trackbacks - 0, articles - 19
【转】http://www.cppblog.com/tiandejian/archive/2008/01/01/ec_31.html

第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 的具体实现细节,他们就不容易编写出依赖于这些细节的代码。这样做真正起到了分离接口和实现的目的。

这项分离工作的关键所在,就是用声明的依赖来取代定义的依赖。这就是最小化编译依赖的核心所在:只要可行,就要将头文件设计成自给自足的,如果不可行,那么就依赖于其他文件中的声明语句,而不是定义。其他一切事情都应遵从这一基本策略。于是有:

只要使用对象的引用或指针可行时,就不要使用对象。 只要简单地通过类型声明,你就可以定义出类型的引用和指针。反观定义类型对象的情形,你就必须要进行类型定义了。

只要可行,就用类声明依赖的方式取代类定义依赖。 请注意你在使用一个类时,如果你需要声明一个函数,那么在任何情况下定义出这个类都不是必须的。即使这个函数以传值方式传递或返回这个类的对象:

class Date;                     // 类声明

 

Date today();                   // 这样是可行的

void clearAppointments(Date d);// 但并没有必要对 Date 类做出定义

当然,传值方式在通常情况下都不会是优秀的方案,但是如果你发现某些情景下不得不使用传值方式时,就会引入不必要的编译依赖,你依然难择其咎。

在不定 Date 的具体实现的情况下,就可以声明 today clearAppointments C++ 的这 一能力恐怕会让你感到吃惊,但是实际上这一行为又没有想象中那么古怪。如果代码中任意一处调用了这些函数,那 么在这次调用前的某处必须要对 Date 进行 定义。此时你又有了新的疑问:为什么我们要声明没有人调用的函数呢 , 这不是多此一举吗?这一疑问的答案很简单:这种函数并不是没有人调用,而是不是所有人都会去调用。假设你的库中包含许多函数声明,这并不意味着每一位客户端程序员都会使用到所有的函数。上文的做法中,提供类定义的职责将从头文件中的函数声明转向客户端文件中包含的函数调用,通过这一过程,你就排除了手工造成的客户端类定义依赖,这些依赖实际上是多余的。

为声明和定义分别提供头文件。 为了进一步贯彻上文中的思想,头文件必须要一分为二:一个存放声明,另一个存放定义。当然这些文件必须保持相互协调。如果某处的一个声明被修改了,那么相应的定义处就必须做出相应的修改。于是,库的客户端程序员就应该始终使用 #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;

};

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 条)。这一指针也可能会使保存一个对象所需要的空间加大,这取决于接口类是否是该对象中虚函数的唯一来源。

最后,无论是句柄类还是接口类,都不适合于过多使用内联。句柄和接口类都是特别设计用来隐藏诸如函数体等具体实现内容的。

然而,仅仅由于句柄类和接口类会带来一些额外的开销而远离它们,这样的做法存在致命的错误。虚函数也一样,你并不希望忽略这些问题,是吗?(如果你真希望忽略些问题,那么你可能看错书了。)你应该把使用这些技术看作一个革命性的手段。在开发过层中,使用句柄类和接口类,来减少在具体实现有改动时为客户端程序员带来的影响。在程序的速度和 / 或大小的变动太大,足以体现出类之间所增加的耦合度时,还是可以适时使用具体的类来取代句柄类和接口类。

铭记在心

最小化编译依赖的基本理念就是使用声明依赖代替定义依赖。基于这一理念有两种实现方式,它们是:句柄类和接口类。

库头文件必须以完整、并且仅存在声明的形式出现。无论是否涉及模板。

 


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理