假设某一天你打开自己的C++程序代码,然后对某个类的实现做了小小的改动。提醒你,改动的不是接口,而是类的实现,也就是说,只是细节部分。然后你准备重新生成程序,心想,编译和链接应该只会花几秒种。毕竟,只是改动了一个类嘛!于是你点击了一下”Rebuild”,或输入make(或其它类似命令)。然而,等待你的是惊愕,接着是痛苦。因为你发现,整个世界都在被重新编译、重新链接!
当这一切发生时,你难道仅仅只是愤怒吗?
问题发生的原因在于,在将接口从实现分离这方面,C++做得不是很出色。尤其是,C++的类定义中不仅包含接口规范,还有不少实现细节。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Person { public: Person(const string& name, const Date& birthday, const Address& addr, const Country& country); virtual ~Person(); ...????????????????????? // 简化起见,省略了拷贝构造 // 函数和赋值运算符函数 string name() const; string birthDate() const; string address() const; string nationality() const; private: string name_;??????????? // 实现细节 Date birthDate_;???????? // 实现细节 Address address_;??????? // 实现细节 Country citizenship_;??? // 实现细节 }; |
这很难称得上是一个很高明的设计,虽然它展示了一种很有趣的命名方式:当私有数据和公有函数都想用某个名字来标识时,让前者带一个尾部下划线就可以区别了。这里要注意到的重要一点是,Person的实现用到了一些类,即string, Date,Address和Country;Person要想被编译,就得让编译器能够访问得到这些类的定义。这样的定义一般是通过#include指令来提供的,所以在定义Person类的文件头部,可以看到象下面这样的语句:
1 2 3 4 |
#include <string>?????????? // 用于string类型 (熟悉标准库) #include "date.h" #include "address.h" #include "country.h" |
遗憾的是,这样一来,定义Person的文件和这些头文件之间就建立了编译依赖关系。所以如果任一个辅助类(即string, Date,Address和Country)改变了它的实现,或任一个辅助类所依赖的类改变了实现,包含Person类的文件以及任何使用了Person类的文件就必须重新编译。对于Person类的用户来说,这实在是令人讨厌,因为这种情况用户绝对是束手无策。
那么,你一定会奇怪为什么C++一定要将一个类的实现细节放在类的定义中。例如,为什么不能象下面这样定义Person,使得类的实现细节与之分开呢?
class string;???????? // “概念上” 提前声明string 类型
// (熟悉标准库)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Date;?????????? // 提前声明 class Address;??????? // 提前声明 class Country;??????? // 提前声明 class Person { public: Person(const string& name, const Date& birthday, const Address& addr, const Country& country); virtual ~Person(); ...????????????????????? // 拷贝构造函数, operator= string name() const; string birthDate() const; string address() const; string nationality() const; }; |
如果这种方法可行的话,那么除非类的接口改变,否则Person 的用户就不需要重新编译。大系统的开发过程中,在开始类的具体实现之前,接口往往基本趋于固定,所以这种接口和实现的分离将大大节省重新编译和链接所花的时间。
可惜的是,现实总是和理想相抵触,看看下面你就会认同这一点:
1 2 3 4 5 6 7 8 9 |
int main() { int x;????????????????????? // 定义一个int Person p(...);????????????? // 定义一个Person // (为简化省略参数) ... } |
当看到x的定义时,编译器知道必须为它分配一个int大小的内存。这没问题,每个编译器都知道一个int有多大。然而,当看到p的定义时,编译器虽然知道必须为它分配一个Person大小的内存,但怎么知道一个Person对象有多大呢?唯一的途径是借助类的定义,但如果类的定义可以合法地省略实现细节,编译器怎么知道该分配多大的内存呢?
原则上说,这个问题不难解决。有些语言如Smalltalk,Eiffel和Java每天都在处理这个问题。它们的做法是,当定义一个对象时,只分配足够容纳这个对象的一个指针的空间。也就是说,对应于上面的代码,他们就象这样做:
1 2 3 4 5 6 7 |
int main() { int x;???????????????????? // 定义一个int Person *p;???????????????? // 定义一个Person指针 ... } |
你可能以前就碰到过这样的代码,因为它实际上是合法的C++语句。这证明,程序员完全可以自己来做到 “将一个对象的实现隐藏在指针身后”。
下面具体介绍怎么采用这一技术来实现Person接口和实现的分离。首先,在声明Person类的头文件中只放下面的东西:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// 编译器还是要知道这些类型名, // 因为Person的构造函数要用到它们 class string;????? // 对标准string来说这样做不对, // 原因(熟悉标准库) class Date; class Address; class Country; // 类PersonImpl将包含Person对象的实 // 现细节,此处只是类名的提前声明 class PersonImpl; class Person { public: Person(const string& name, const Date& birthday, const Address& addr, const Country& country); virtual ~Person(); ...?????????????????????????????? // 拷贝构造函数, operator= string name() const; string birthDate() const; string address() const; string nationality() const; private: PersonImpl *impl;???????????????? // 指向具体的实现类 }; |
现在Person的用户程序完全和string,date,address,country以及person的实现细节分家了。那些类可以随意修改,而Person的用户却落得个自得其乐,不闻不问。更确切的说,它们可以不需要重新编译。另外,因为看不到Person的实现细节,用户不可能写出依赖这些细节的代码。这是真正的接口和实现的分离。
分离的关键在于,”对类定义的依赖” 被 “对类声明的依赖” 取代了。所以,为了降低编译依赖性,我们只要知道这么一条就足够了:只要有可能,尽量让头文件不要依赖于别的文件;如果不可能,就借助于类的声明,不要依靠类的定义。其它一切方法都源于这一简单的设计思想。
下面就是这一思想直接深化后的含义:
· 如果可以使用对象的引用和指针,就要避免使用对象本身。定义某个类型的引用和指针只会涉及到这个类型的声明。定义此类型的对象则需要类型定义的参与。
· 尽可能使用类的声明,而不使用类的定义。因为在声明一个函数时,如果用到某个类,是绝对不需要这个类的定义的,即使函数是通过传值来传递和返回这个类:
1 2 3 4 |
class Date;??????????????????? // 类的声明 Date returnADate();??????????? // 正确 ---- 不需要Date的定义 void takeADate(Date d); |
当然,传值通常不是个好主意(尽量用“传引用”而不用“传值”),但出于什么原因不得不这样做时,千万不要还引起不必要的编译依赖性。
如果你对returnADate和takeADate的声明在编译时不需要Date的定义感到惊讶,那么请跟我一起看看下文。其实,它没看上去那么神秘,因为任何人来调用那些函数,这些人会使得Date的定义可见。”噢” 我知道你在想,”为什么要劳神去声明一个没有人调用的函数呢?” 不对!不是没有人去调用,而是,并非每个人都会去调用。例如,假设有一个包含数百个函数声明的库(可能要涉及到多个名字空间—-划分全局名字空间),不可能每个用户都去调用其中的每一个函数。将提供类定义(通过#include 指令)的任务从你的函数声明头文件转交给包含函数调用的用户文件,就可以消除用户对类型定义的依赖,而这种依赖本来是不必要的、是人为造成的。
· 不要在头文件中再(通过#include指令)包含其它头文件,除非缺少了它们就不能编译。相反,要一个一个地声明所需要的类,让使用这个头文件的用户自己(通过#include指令)去包含其它的头文件,以使用户代码最终得以通过编译。一些用户会抱怨这样做对他们来说很不方便,但实际上你为他们避免了许多你曾饱受的痛苦。事实上,这种技术很受推崇,并被运用到C++标准库(熟悉标准库)中;头文件<iosfwd>就包含了iostream库中的类型声明(而且仅仅是类型声明)。
Person类仅仅用一个指针来指向某个不确定的实现,这样的类常常被称为句炳类(Handle class)或信封类(Envelope class)。(对于它们所指向的类来说,前一种情况下对应的叫法是主体类(Body class);后一种情况下则叫信件类(Letter class)。)偶尔也有人把这种类叫 “Cheshire猫” 类,这得提到《艾丽丝漫游仙境》中那只猫,当它愿意时,它会使身体其它部分消失,仅仅留下微笑。
你一定会好奇句炳类实际上都做了些什么。答案很简单:它只是把所有的函数调用都转移到了对应的主体类中,主体类真正完成工作。例如,下面是Person的两个成员函数的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include "Person.h"????????? // 因为是在实现Person类, // 所以必须包含类的定义 #include "PersonImpl.h"????? // 也必须包含PersonImpl类的定义, // 否则不能调用它的成员函数。 // 注意PersonImpl和Person含有一样的 // 成员函数,它们的接口完全相同 Person::Person(const string& name, const Date& birthday, const Address& addr, const Country& country) { impl = new PersonImpl(name, birthday, addr, country); } string Person::name() const { return impl->name(); } |
请注意Person的构造函数怎样调用PersonImpl的构造函数(隐式地以new来调用,对应的new和delete要采用相同的形式)以及Person::name怎么调用PersonImpl::name。这很重要。使Person成为一个句柄类并不改变Person类的行为,改变的只是行为执行的地点。
除了句柄类,另一选择是使Person成为一种特殊类型的抽象基类,称为协议类(Protocol class)。根据定义,协议类没有实现;它存在的目的是为派生类确定一个接口(区分接口继承和实现继承)。所以,它一般没有数据成员,没有构造函数;有一个虚析构函数(确定基类有虚析构函数),还有一套纯虚函数,用于制定接口。Person的协议类看起来会象下面这样:
1 2 3 4 5 6 7 8 9 |
class Person { public: virtual ~Person(); virtual string name() const = 0; virtual string birthDate() const = 0; virtual string address() const = 0; virtual string nationality() const = 0; }; |
Person类的用户必须通过Person的指针和引用来使用它,因为实例化一个包含纯虚函数的类是不可能的(但是,可以实例化Person的派生类—-参见下文)。和句柄类的用户一样,协议类的用户只是在类的接口被修改的情况下才需要重新编译。
当然,协议类的用户必然要有什么办法来创建新对象。这常常通过调用一个函数来实现,此函数扮演构造函数的角色,而这个构造函数所在的类即那个真正被实例化的隐藏在后的派生类。这种函数叫法挺多(如工厂函数(factory function),虚构造函数(virtual constructor)),但行为却一样:返回一个指针,此指针指向支持协议类接口的动态分配对象。这样的函数象下面这样声明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
// makePerson是支持Person接口的 // 对象的"虚构造函数" ( "工厂函数") Person* makePerson(const string& name,???????? // 用给定的参数初始化一个 const Date& birthday,?????? // 新的Person对象,然后 const Address& addr,??????? // 返回对象指针 const Country& country); 用户这样使用它: string name; Date dateOfBirth; Address address; Country nation; ... // 创建一个支持Person接口的对象 Person *pp = makePerson(name, dateOfBirth, address, nation); ... cout? << pp->name()????????????? // 通过Person接口使用对象 << " was born on " << pp->birthDate() << " and now lives at " << pp->address(); ... delete pp;?????????????????????? // 删除对象 |
makePerson这类函数和它创建的对象所对应的协议类(对象支持这个协议类的接口)是紧密联系的,所以将它声明为协议类的静态成员是很好的习惯:
1 2 3 4 5 6 7 8 9 |
class Person { public: ...??????? // 同上 // makePerson现在是类的成员 static Person * makePerson(const string& name, const Date& birthday, const Address& addr, const Country& country); |
这样就不会给全局名字空间(或任何其他名字空间)带来混乱,因为这种性质的函数会很多(划分全局名字空间)。
当然,在某个地方,支持协议类接口的某个具体类(concrete class)必然要被定义,真的构造函数也必然要被调用。它们都背后发生在实现文件中。例如,协议类可能会有一个派生的具体类RealPerson,它具体实现继承而来的虚函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class RealPerson: public Person { public: RealPerson(const string& name, const Date& birthday, const Address& addr, const Country& country) :? name_(name), birthday_(birthday), address_(addr), country_(country) {} virtual ~RealPerson() {} string name() const;????????? // 函数的具体实现没有 string birthDate() const;???? // 在这里给出,但它们 string address() const;?????? // 都很容易实现 string nationality() const; private: string name_; Date birthday_; Address address_; Country country_; |
有了RealPerson,写Person::makePerson就是小菜一碟:
1 2 3 4 5 6 7 |
Person * Person::makePerson(const string& name, const Date& birthday, const Address& addr, const Country& country) { return new RealPerson(name, birthday, addr, country); } |
实现协议类有两个最通用的机制,RealPerson展示了其中之一:先从协议类(Person)继承接口规范,然后实现接口中的函数。另一种实现协议类的机制涉及到多继承,这将是明智地使用多继承的话题。
是的,句柄类和协议类分离了接口和实现,从而降低了文件间编译的依赖性。”但,所有这些把戏会带来多少代价呢?”,我知道你在等待罚单的到来。答案是计算机科学领域最常见的一句话:它在运行时会多耗点时间,也会多耗点内存。
句柄类的情况下,成员函数必须通过(指向实现的)指针来获得对象数据。这样,每次访问的间接性就多一层。此外,计算每个对象所占用的内存大小时,还应该算上这个指针。还有,指针本身还要被初始化(在句柄类的构造函数内),以使之指向被动态分配的实现对象,所以,还要承担动态内存分配(以及后续的内存释放)所带来的开销 —- 如果写了operator new就要同时写operator delete。
对于协议类,每个函数都是虚函数,所有每次调用函数时必须承担间接跳转的开销。而且,每个从协议类派生而来的对象必然包含一个虚指针。这个指针可能会增加对象存储所需要的内存数量(具体取决于:对于对象的虚函数来说,此协议类是不是它们的唯一来源)。
最后一点,句柄类和协议类都不大会使用内联函数。使用任何内联函数时都要访问实现细节,而设计句柄类和协议类的初衷正是为了避免这种情况。
但如果仅仅因为句柄类和协议类会带来开销就把它们打入冷宫,那就大错特错。正如虚函数,你难道会不用它们吗?(如果回答不用,那你正在看一本不该看的书!)相反,要以发展的观点来运用这些技术。在开发阶段要尽量用句柄类和协议类来减少 “实现” 的改变对用户的负面影响。如果带来的速度和/或体积的增加程度远远大于类之间依赖性的减少程度,那么,当程序转化成产品时就用具体类来取代句柄类和协议类。希望有一天,会有工具来自动执行这类转换。
有些人还喜欢混用句柄类、协议类和具体类,并且用得很熟练。这固然使得开发出来的软件系统运行高效、易于改进,但有一个很大的缺点:还是必须得想办法减少程序重新编译时消耗的时间。
You actually said that very well. fluconazol 150 mg
Thanks a lot, Valuable information! aarp recommended canadian pharmacies
Appreciate it. Quite a lot of posts!
canada pharmacies online
With thanks. I value this. hemp oil vs cbd oil
Perfectly spoken genuinely. . buy viagra online
Kudos. Plenty of knowledge!
What Is Cymbalta 60mg
You said it nicely.. online pharmacies canada
Thanks. Valuable information! Mylan Provigil Generic
Thanks, Quite a lot of tips!
150 Mg Allopurinol
Nicely put, With thanks! hemp seed oil
Good write ups. Thank you. Female Viagra Filbanserin
Amazing forum posts. Thanks! How To Get Bactrim Out Of Your System
Incredible a lot of wonderful facts. Cialis Quanto Tempo Prima Va Assunto
Thanks, I appreciate it! Drug Called Provigil
You’ve made your stand quite effectively.. cephalexin 500 mg for dogs