加入收藏 | 设为首页 | 会员中心 | 我要投稿 李大同 (https://www.lidatong.com.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 百科 > 正文

减少文件间的编译依赖

发布时间:2020-12-13 23:13:00 所属栏目:百科 来源:网络整理
导读:为了更新某个类的某个功能实现,你可能需要在浩瀚 C++ 的代码中做出一个细小的修改,要提醒你的是,修改的地方不是类接口,而是实现本身,并且仅仅是私有成员。完成修改之后,你需要对程序进行重新构建,这时你肯定会认为这一过程将十分短暂,毕竟你只对 一

为了更新某个类的某个功能实现,你可能需要在浩瀚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 {

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 {

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

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

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

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

class Date;//类声明

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

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

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

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

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

...

staticstd::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 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,addr));

}

Person::create还有可以以一个更加贴近现实的方法来实现,它应能够创建不同种类的派生类对象,创建的过程基于某些相关信息,例如:新加入的函数的参数值、从一个文件或数据库中得到读到的数值,环境变量,等等。

RealPerson向我们展示了实现接口类的两种通用的实现机制之一:它的接口规范继承自接口类(Person),然后实现接口中的函数。第二种实现接口类的方法牵扯到多重继承,那是第40条中探索的主题。

句柄类和接口类将接口从实现中分离开来,因此降低了文件间的编译依赖。如果你是一个喜欢吹毛求疵的人,那么你一定又在想法挖苦本届的思想了:“做了这么多变魔术般古怪的事情,我又能得到什么呢?”这个问题的答案就是计算机科学中极为普遍的一个议题:你的程序在运行时更慢了一步,另外,每个对象所占的空间更大了一点。

使用句柄类的情况下,成员函数必须通过实现指针来取得对象的数据。这样无形中增加了每次访问时迂回的层数。同时,实现指针所指向的对象所占的空间更大了一些,你必须要考虑这一问题。最后,你必须要对实现指针进行初始化(在句柄类的构造函数中),以便于将其指向一个动态分配的实现对象,于是你就必须自己承担动态内存分配(以及相关的释放)内在的开销以及遭遇bad_alloc(内存越界)异常的可能性。

由于对于接口类来说每次函数调用都是虚拟的,因此你在每调用一次函数的过程中你就会为其付出一次间接跳转的代价(参见第7条)。同时,派生自接口类的对象必须包含一个虚函数表指针(依然参见第7条)。这一指针也可能会使保存一个对象所需要的空间加大,这取决于接口类是否是该对象中虚函数的唯一来源。

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

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

铭记在心

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

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

(编辑:李大同)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章
      热点阅读