Effective C++条款31:将文件间的编译依存关系降至最低(Minimize compilation dependencies between files)


《Effective C++》是一本轻薄短小的高密度的“专家经验积累”。本系列就是对Effective C++进行通读:

第5章:实现

在这里插入图片描述


条款31:将文件间的编译依存关系降至最低

  假设你对你的类实现做了一个很小的改动。注意,不是class接口,而是实现,而且只改private部分。然后重新建制这个程序,大概几秒钟就足够了。毕竟,只修改了一个类。你点击了build 或者输入了make( 或者其他方式),你被惊到了,然后感到窘迫,因为你意识到整个世界都被重新编译和重新链接了!当这些发生时你不觉的感到懊恼?

1、编译依赖是如何发生的

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

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;   // 实现细节
};

  这里的class Person无法通过编译——如果编译器没有取得其实现代码所用到的classes string,Date,和Address的定义,这些定义通过使用#include指令来提供,所以在定义Person类的文件中,你可能会发现像下面这样的代码:

#include <string>
#include "date.h"
#include "address.h"

  不幸的是,定义Person类的文件和上面列出的头文件之间形成了一种编译依存关系。任何一个头文件被修改,或者这些头文件依赖的文件被修改,包含Person类的文件就必须要重新编译,使用Person的任何文件也必须要重新编译。这样的连串编译依存关系会对许多项目造成难以形容的灾难。

2、将类的实现分离出来

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

namespace std {
    
    
	class string; // 前置声明(errer,详情见下面叙述)
} 
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(定义为basic_string<char>)。因此,对string的前置声明是不正确的。正确的前置声明比较复杂,因为它涉及到了额外的模板。然而这没关系,因为你不应该尝试对标准库的某些部分进行手动声明。你应该使用合适的#include来达到目的。标准头文件看上去不像是编译的瓶颈,特别是你的编译环境允许你利用预编译头文件。如果标准头文件的解析真的是一个问题,你可能需要修改你的接口设计来避免使用标准库的某些部分(使用标准库的某些部分需要使用不受欢迎的#includes)。

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

int main()
{
    
    
	int x;                      // 定义一个int
	Person p( params ); // define a Person
	...
}

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

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

int main()
{
    
    
	int x;         
	Person *p; 
	...
}

  对于Person来说,一种实现方式就是将其分成两个类,一个只提供接口,另一个实现接口。如果实现类的被命名为PersonImpl,Person将会被定义如下:

#include <string> 
#include <memory> 

class PersonImpl; 
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;
	...
private:                                                                               
	std::tr1::shared_ptr<PersonImpl> pImpl; 
}; 

  在这里,主类(Person只包含了指向类实现的指针(PersonImpl),一个tr1::shared_ptr指针(见条款13)。这样一个设计就是通常所说的“pimpl idiom”(指向实现的指针,是pointer to implementation的缩写)。在这样的类中,指针的名字通常为pImpl,如上面所示。

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

3、分离的关键与编译依存性最小化

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

  • 如果使用指向对象的引用和指针能够完成任务时就不要使用对象。你可以只用一个声明来定义指向一个类型的引用和指针。而定义一个类型的对象则需要使用类的定义。

  • 如果能够,尽量以类的声明替换类的定义。注意,使用类来声明一个函数的时候你绝不会用到这个类的定义,甚至使用按值传递参数或者按值返回也不需要:

class Date;
Date today();   
id clearAppointments(Date d); 

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

  声明today和clearAppointments时不需要对Date进行定义,可能会让你感到吃惊,但是它不像看上去那样让人好奇。如果任何人调用这些函数,Data的定义必须在函数调用之前被看到。.

  为什么声明无人调用的函数呢?因为不是没有人会调用它们,而是不是所有人都会调用它们。如果你有一个库包含很多函数声明,每个用户都调用每个函数是不太可能的。通过把在声明函数的头文件中提供类定义的责任转移到包含函数调用的客户文件中,你就消除了不必要的人为造成的对类型定义的用户依赖。

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

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

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

  C++中同样提供了export关键字,它可以使模板声明从模板定义中分离出来。不行的是,支持export的编译器是稀少的,在现实世界中使用export的经验同样稀少。因此,若要评价export会在高效C++编程中发挥什么作用还言之过早。,

4、句柄类

4.1 制作句柄类的方法之一

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

#include "Person.h" 
#include "PersonImpl.h"
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构造函数是通过使用new调用PersonImpl构造函数,,以及Person::name函数内调用PersonImpl::name,这是重要的。让Person类定义

4.2 制作句柄类的方法之二

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

  接口类同Java和.NET中的接口是类似的,但Java和.NET对接口强加了限制,c++却没有这做。举个例子,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

  • ② 虚构造函数(virtual constructors)。它们返回指向动态分配对象的指针(用智能指针比较好条款18)。这样的函数在接口类中通常被声明为static:

class Person {
    
    
public:
	...
	static std::tr1::shared_ptr<Person> 
	create(const std::string& name, 
		   const Date& birthday, 
		   const Address& addr); 
	...
};

  客户这样使用它们:

std::string name;
Date dateOfBirth;
Address address;
...
//创建一个对象,支持Person接口
std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() 
<< " was born on " 
<< pp->birthDate()
<< " and now lives at "
<< pp->address();
...

  当然,必须定义支持接口类接口的具现类,并且在具现类中必须调用真正的构造函数。这在包含了虚构造函数实现的文件中都会发生。举个例子: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展示出了其中的一个:它的接口继承自接口类(Person),然后在接口中实现函数。实现接口类的第二种方法涉及到多继承,在条款40中会涉及到这个话题。

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

  句柄类和接口类将接口从实现中解耦出来,从而减少了文件间的编译依赖。你可能会问,这种伎俩会让我付出什么?答案是:

  • 它会让运行时速度变慢
  • 会为每个对象分配额外的空间

6、牢记

  • 将大多数inlining限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。

  • 不要只因为function templates出现在头文件,就将它们声明为inline。

总结

期待大家和我交流,留言或者私信,一起学习,一起进步!

猜你喜欢

转载自blog.csdn.net/CltCj/article/details/128270672