Fork me on GitHub

读书笔记 effective c++ Item 31 把文件之间的编译依赖降到最低

 

1. 牵一发而动全身

现在开始进入你的C++程序,你对你的类实现做了一个很小的改动。注意,不是接口,只是实现,而且是private部分。然后你需要rebuild你的程序,计算着这个build应该几秒钟就足够了。毕竟,只修改了一个类。你点击了build 或者输入了make( 或者其他方式),你被惊到了,然后羞愧难当,因为你意识到整个世界都被重新编译和重新链接了!当这些发生时你不觉的感到愤恨么?

2. 编译依赖是如何发生的

问题出在C++并不擅长将接口从实现中分离出来。类定义不仅指定了类的接口也同时指定了许多类的细节。举个例子:

 1 class Person {
 2 public:
 3 Person(const std::string& name, const Date& birthday,
 4 const Address& addr);
 5 std::string name() const;
 6 std::string birthDate() const;
 7 std::string address() const;
 8 ...
 9 private:
10 std::string theName; // implementation detail
11 Date theBirthDate; // implementation detail
12 
13 Address theAddress;              // implementation detail
14 
15 };

 

 

这里,类Person的实现需要使用一些类的定义,也就是string,Date,和Address,如果类Person对这些类的定义没有访问权,那么Person不会被编译通过。这些定义通过使用#include指令来提供,所以在定义Person类的文件中,你可能会发现像下面这样的代码:

1 #include <string>
2 
3 #include "date.h"
4 
5 #include "address.h"

 

不幸的是,定义Person类的文件和上面列出的头文件之间建立了编译依赖。任何一个头文件被修改,或者这些头文件依赖的文件被修改,包含Person类的文件就必须要重新编译,使用Person的任何文件也必须要重新编译。这样的级联编译依赖会对一个工程造成无尽的伤痛。

 

3. 尝试将类的实现分离出来

你可能想知道为什么C++坚持将类的实现细节放在类定义中。举个例子,你为什么不能这么定义Person类,将指定类的实现细节单独分离开来。

 1 namespace std {
 2 class string; // forward declaration (an incorrect
 3 } // one — see below)
 4 class Date; // forward declaration
 5 class Address; // forward declaration
 6 class Person {
 7 public:
 8 Person(const std::string& name, const Date& birthday,
 9 const Address& addr);
10 std::string name() const;
11 std::string birthDate() const;
12 std::string address() const;
13 ...
14 };

 

如果这是可能的 ,Person的用户只有在类的接口被修改的时候才必须要重新编译。

 

这个想法有两个问题。首先,string不是类,它是一个typedef(basic_string<char>的typedef)。因此,对string的前置声明是不正确的。合适的前置声明实质上更加复杂,因为它涉及到了额外的模板。然而这没关系,因为你不应该尝试对标准库的某些部分进行手动声明。相反,简单的使用合适的#include来达到目的。标准头文件看上去不像是编译的瓶颈,特别是你的编译环境允许你利用预编译头文件。如果标准头文件的解析真的是一个问题,你可能需要修改你的接口设计来避免使用标准库的某些部分(使用标准库的某些部分需要使用不受欢迎的#includes)。

 

对每件事情进行前置声明的第二个难点(并且是更加明显的)是需要处理如下问题:在编译过程中编译器需要知道对象的大小。考虑:

1 int main()
2 {
3 int x;                      // define an int
4 
5 Person p( params ); // define a Person
6 
7 
8 ...
9 }

 

当编译器看到x的定义时,它们知道必须要为一个int分配足够的空间。这没问题。编译器知道一个int有多大。当编译器看到p的定义时,它们知道必须要为一个Person分配足够的空间,但是他们如何知道一个Person对象有多大呢?唯一的方法就是通过查看类定义,但是对于一个类的定义来说,如果将其实现细节忽略掉是合法的,编译器如何知道需要分配多少空间呢?

这种问题不会出现在像Smalltalk 和Java这样的语言中,因为当在这些语言中定义一个对象时,编译器只为指向对象的指针分配足够的空间。对于上面的代码,它们会像下面这样进行处理:

1 int main()
2 {
3 int x;         // define an int
4 
5 Person *p; // define a pointer to a Person
6 ...
7 }

 

这当然是合法的C++代码,所以你可以自己玩“将对象细节隐藏在指针后面”的游戏。对于Person来说,一种实现方式就是将其分成两个类,一个只提供接口,另一个实现接口。如果实现类的被命名为PersonImpl,Person将会被定义如下:

 1 #include <string> // standard library components
 2 // shouldn’t be forward-declared
 3 
 4 #include <memory> // for tr1::shared_ptr; see below
 5 
 6 class PersonImpl; // forward decl of Person impl. class
 7 
 8 class Date;             // forward decls of classes used in
 9 
10  
11 
12 class Address;                                                                      // Person interface
13 
14 class Person {                                                                      
15 
16 public:                                                                                
17 
18 Person(const std::string& name, const Date& birthday,       
19 
20 const Address& addr);                                                        
21 
22 std::string name() const;                                                    
23 
24 std::string birthDate() const;                                              
25 
26 std::string address() const;                                                 
27 
28 ...                                                                                        
29 
30 private:                                                                               // ptr to implementation;
31 
32 
33 std::tr1::shared_ptr<PersonImpl> pImpl; // see Item 13 for info on
34 }; // std::tr1::shared_ptr

 

这里,主类(Person)没有包含任何数据成员,只包含了指向类实现的指针(PersonImpl),一个tr1::shared_ptr指针(Item 13)。这样一个设计就是通常所说的“pimpl idiom”(指向实现的指针)。在这样的类中,指针的名字通常为pImpl,如上面所示。

使用这个设计,Person的用户脱离了datas,address和persons的实现细节。这些类的实现可以随意修改,但Person用户不需要重新编译。此外,因为他们不能够看到Person的实现细节,用户应该不会写出依赖这些细节的代码。这就对实现和接口进行了真正的分离。

4. 最小化编译依赖的设计策略

分离的关键在于把对定义的依赖替换为对声明的依赖。这是最小化编译依赖的本质:在切实可行的情况下让你的头文件能够自给自足,如果达不到这个要求,依赖其他文件中的声明而不是定义。其他的设计都来自于这个简单的设计策略。因此:

 

  • 当使用指向对象的引用和指针能够做到时就不要使用对象。你可以只用一个声明来定义指向一个类型的引用和指针。而定义一个类型的对象则需要使用类的定义。
  • 尽可能用类的声明替换类的定义。注意使用类来声明一个函数的时候你绝不会用到这个类的定义,甚至使用按值传递参数或者按值返回也不需要:

 

1 class Date; // class declaration
2 
3 Date today();     // fine — no definition
4 
5    id clearAppointments(Date d); // of Date is needed

 

 

当然,按值传递通常情况下是一个坏方法(Item 20),但是如果你发现自己因为某种原因需要使用它,引入不必要的编译依赖也是没有任何理由的。

 

声明today和clearAppointments时不需要对Date进行定义可能会让你感到吃惊,但是它不像看上去那样让人好奇。如果任何人调用这些函数,Data的定义必须在函数调用之前被看到。你可能纳闷为什么声明无人调用的函数呢?很简单。不是没有人会调用它们,而是不是所有人都会调用它们。如果你有一个库包含很多函数声明,每个用户都调用每个函数是不太可能的。通过把在声明函数的头文件中提供类定义的责任转移到包含函数调用的客户文件中,你就消除了不必要的人为造成的对类型定义的用户依赖。

 

  • 为声明和定义分别提供头文件

 

为了符合上述方针,头文件需要成对使用:一个用于声明,一个用于定义。当然这些文件应该保持一致。如果一个地方的声明被修改了,两个地方必须同时修改。最后,库的用户应该总是#include一个声明文件,而不是自己对其进行前向声明,。举个例子,Date类的客户想声明today和clearAppointments,这里就不用像上面那样对Date进行前向声明了。而是应该#include包含了声明的头文件:

1 #include "datefwd.h" // header file declaring (but not
2 
3 // defining) class Date
4 
5 Date today(); // as before
6 
7 void clearAppointments(Date d);

 

头文件“datefwd.h”只包含声明,它的命名是基于标准C++库的头<iosfwd>(Item 54)。<iosfwd>包含了iostream组件的声明,与这些声明相对应的定义被放在几个不同的头中,包括<sstream>,<streambuf>,<fstream>和<iostream>。

<iosfwd>有另外一个指导性的意义,就是要弄清楚这个条款的建议不仅适用于templates,同样适用于非templates。虽然Item30解释了在许多编译环境中·,模板定义通常会放在头文件中,一些编译环境也允许将模板定义放在非头文件中,因此为模板提供只包含声明的头仍然是有意义的。<iosfwd>就是这样的头。

 

C++中同样提供了export关键字,它可以使模板声明从模板定义中分离出来。不行的是,支持export的编译器是稀少的,在现实世界中使用export的经验同样稀少。因此,评价

export会在高效C++编程中发挥什么作用还为时尚早。,

 

5. 句柄类

像Person这样使用了“指向实现的指针”(pimpl idiom)的类通常被叫做句柄类(handle class),如果你想知道这样的类是如何做到无所不能的,一种方法是将所有的函数调用转移到对应的实现类中,真正的工作在实现类中进行。举个例子,下面展示了Person类的两个成员函数是如何被实现的:

 1 #include "Person.h" // we’re implementing the Person class,
 2 
 3 // so we must #include its class definition
 4 
 5 #include "PersonImpl.h" // we must also #include PersonImpl’s class
 6 
 7 // definition, otherwise we couldn’t call
 8 
 9 // its member functions; note that
10 
11 // PersonImpl has exactly the same public
12 
13 // member functions as Person — their
14 
15 // interfaces are identical
16 
17 Person::Person(const std::string& name, const Date& birthday,
18 
19 const Address& addr)
20 
21 : pImpl(new PersonImpl(name, birthday, addr))
22 
23 {}
24 
25 std::string Person::name() const
26 
27 {
28 
29 return pImpl->name();
30 
31 }

 

 

注意Person构造函数是如何调用PersonImpl构造函数的(通过使用new Item 16),以及Person::name是如何调用PersonImpl::name的,这很重要。将Person类定义成句柄类并没有改变Person类能做什么,只是修改了Person类做什么的实现方式。

6. 抽象基类

使用句柄类的另外一种替代方法是将Person定义成特殊的抽象基类,也就是接口类。使用这种类的意图是为派生类指定一个接口(Item 34)。这种类没有数据成员,没有构造函数,有一个虚析构函数(item7)和一系列纯虚函数。

接口类同Java和.NET中的接口是类似的,但Java和.NET对接口强加了限制,c++却没有这做。举个例子,Java和.NET都不允许在接口中声明数据成员或者实现函数,但C++对这两者都没有限制。C++的这种更强的灵活性是有用的。在Item36中解释道,在一个继承体系中应该为所有类实现相同的非虚函数,因此对于在接口类中被声明的函数,作为接口类的一部分对其进行实现是有意义的。

 

一个Person类的接口实现可能像下面这个样子:

 1 class Person {
 2 
 3 public:
 4 
 5 virtual ~Person();
 6 
 7 virtual std::string name() const = 0;
 8 
 9 virtual std::string birthDate() const = 0;
10 
11 virtual std::string address() const = 0;
12 
13 ...
14 
15 };

 

这个类的用户必须依靠Person指针或者引用来进行编程,因为不可能实例化包含纯虚函数的类。(然而实例化Person的派生类却是可能的)。就像句柄类的用户一样,接口类只有在其接口发生变化的情况下才需要重新编译,其它情况都不需要。

 

一个接口类的用户必须要有创建新对象的方法。通常情况下,这通过调用扮演派生类构造函数角色的函数来实现,当然派生类是可以被实例化的。这样的函数通常被叫做工厂函数(Item13)或者虚构造函数(virtual constructors)。它们返回指向动态分配对象的指针(用智能指针比较好Item 18)。这样的函数在接口类中通常被声明为static:

 1 class Person {
 2 
 3 public:
 4 
 5 ...
 6 
 7 static std::tr1::shared_ptr<Person> // return a tr1::shared_ptr to a new
 8 
 9 create(const std::string& name, // Person initialized with the
10 
11 const Date& birthday, // given params; see Item18 for
12 
13 const Address& addr); // why a tr1::shared_ptr is returned
14 
15 ...
16 
17 };

 

用户像下面这样使用:

 1 std::string name;
 2 
 3 Date dateOfBirth;
 4 
 5 Address address;
 6 
 7 ...
 8 
 9 // create an object supporting the Person interface
10 
11 std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
12 
13 ...
14 
15 std::cout << pp->name() // use the object via the
16 
17 << " was born on " // Person interface
18 
19 << pp->birthDate()
20 
21 << " and now lives at "
22 
23 << pp->address();
24 
25 ... // the object is automatically
26 
27 // deleted when pp goes out of
28 
29 // scope — see Item13

 

当然,必须定义支持接口类接口的具现类,并且在具现类中必须调用真正的构造函数。这在包含了虚构造函数实现的文件中都会发生。举个例子:Person接口类可能有一个具现化派生类RealPerson,它为继承自基类的虚函数提供了实现:

 1 class RealPerson: public Person {
 2 
 3 public:
 4 
 5 RealPerson(const std::string& name, const Date& birthday,
 6 
 7 const Address& addr)
 8 
 9 :  theName(name), theBirthDate(birthday), theAddress(addr)
10 
11 {}
12 
13 virtual ~RealPerson() {}
14 
15 std::string name() const; // implementations of these
16 
17 std::string birthDate() const; // functions are not shown, but
18 
19 std::string address() const; // they are easy to imagine
20 
21 private:
22 
23 std::string theName;
24 
25 Date theBirthDate;
26 
27 Address theAddress;
28 
29 };

 

给出RealPerson的定义后,实现Person::create就变得微不足道了:

 1 std::tr1::shared_ptr<Person> Person::create(const std::string& name,
 2 
 3 const Date& birthday,
 4 
 5 const Address& addr)
 6 
 7 {
 8 
 9 return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday,
10 
11 addr));
12 
13 }

 

Person::create的一个更加现实的实现是创建不同的派生类对象,对象类型可能会依赖于额外的函数参数,从文件或者数据库中读取的数据或环境变量等等。

 

实现一个接口类有两个最普通的机制,RealPerson展示出了其中的一个:它的接口继承自接口类(Person),然后在接口中实现函数。实现接口类的第二种方法涉及到多继承,在Item40中会涉及到这个话题。

 

 

7. 使用接口类和句柄类需要花费额外的开销

句柄类和接口类将接口从实现中解耦出来,从而减少了文件间的编译依赖。你可能会问,这种伎俩会让我付出什么?答案也是计算机科学中的常见回答:它会让运行时速度变慢,

另外会为每个对象分配额外的空间。

 

在句柄类的例子中,成员函数必须通过指向实现的指针才能到达对象数据。这为每次访问添加了一个间接层。你必须将这个实现指针的大小添加到存储每个对象需要的内存容量上。最后,实现指针必须被初始化为指向动态分配的实现对象,因此你引入了动态分配内存(还有接下来的内存销毁)所固有的开销,还有可能遇到的bad_alloc(内存溢出)异常。

 

对接口类来说,每次函数调用都是virtual的,所以在你每次调用一个函数的时候,都会有一次间接跳转的开销(Item 7)。同时,从接口类中派生出来的对象必须包含一个虚表指针(Item7)。这个指针可能会增加对象存储需要的内存容量,取决于接口类是否为此对象提供虚函数的唯一来源。

 

最后,脱离inline函数句柄类和接口类都不会有很大作为。Item30解释了为什么函数体必须要放在头文件中从而被inline,但是句柄和接口类正是用来设计隐藏像函数体一样的实现细节的。

 

如果你仅仅因为额外的开销就放弃使用句柄类和接口类就大错特错了。虚函数也是如此,你并不想放弃它们,对吧?(如果想放弃,你看的书就是错误的)相反,使用一种循序渐进的方式来使用它们。在开发过程中使用接口类和句柄类最小化修改实现造成的对用户的影响。如果使用句柄类和接口类对速度和容量造成的影响要大于类之间的耦合,那么用具现类进行替换。

 

8. 总结:

最小化编译依赖背后的知道思想是依赖于声明而不是依赖于定义。基于这个思想的方法是句柄类和接口类。

库头文件应该以完全且仅有声明的方式存在。不管是否涉及到模板都适用。

posted @ 2017-03-12 16:25  HarlanC  阅读(1294)  评论(0编辑  收藏  举报