多重继承和纯抽象类
Bill Venners:我在1991至1996这5年间,几乎一直 仅仅使用C++编程。在那时,我认为多重继承唯一目的就是让我能够从多个基类中继承它们各自的数据和函数 — 不管是虚 拟函数还是非虚拟函数。那时候,我和我使用C++的同事几乎从未想过可以使用一种不含任何数据而仅包含 纯虚函数的类,也就是现在Java中被称为接口的东西。最近您好像又越来越多地提起了抽象类这个概念,我想问问是不是最近在实验的过程中发现了一些我们以前未曾注意到的对纯 接口类进行多重继承的好处,抑或是您认为我们以前对抽象类重视得不够?
Bjarne Stroustrup:我在对人们解释这个问题的过程中遇到了很多问题,而且我也一直不能理解为什么让人们理解这个问题是如此困难。自C++出现那天起,就存在着包含数据成员的类和不包含数据成员的类。在过去,人们强调利用一个最基础的设施以及该设施内部的 东西来构造软件系统,而那个“最基本的设施”通常就是抽象基类。从80年代中叶到80年代末,那些仅由 虚拟函数组合而成的类通常都被称为ABCs(Abstract Base Classes 抽象基类)。1987年,我在C++中加入了 纯虚函数的概念,一个纯虚函数必须被其派生类重写。借助此概念,你可以在一个C++类中通过将其成员函数 声明为纯虚函数的方法表明该类是一个纯接口类。从那以后,我就一直强调在C++中,有一种主要的使用类的方法就是让该类不包含任何状态, 而仅仅作为一个接口。
从C++的 角度来看,一个抽象类和一个接口之间没有任何区别。有时,我们习惯使用“纯抽象类”这个词来表示某个类仅仅只含有纯虚函数(不包含任何数据成员),它是抽象类的最常见的形式。当我试图向人们解释这个概念时,我发现如果我不先向他们介绍 纯虚函数这个语言中被直接支持的概念,人们就很难接受它。有些人仅仅因为可以在基类中放入一些数据成员,就觉得他们必须这样做。他们这样做,就等于构造了经典的不稳定基类,当然同时也就 招致该结构所带来的一切问题。当我向人们介绍C++中直接支持抽象基类的概念时,情况稍微好一些,不过仍然有许多人不能理解它。我认为这是由于我自身的原因所造成的教育上的失败 — 我低估了做这件事的难度。这与早些时候Simula社团在理解新概念上的失败异常相似。有些新概念难以理解,部分原因 在于许多人并不是真的想去学习一些全新的东西,他们自以为自己已经知道了答案。而一旦以为自己已经知道了答案,再去学一些新东西就会变得非常困难了。在1991年的《The C++ Programming Language》第二版中,有几个例子描述了抽象类的概念,可不幸的是,我并没有在全书从头至尾都贯穿这个思想。
Bill Venners:使用纯抽象类有什么好处?什么时候我们应该使用纯抽象类而不是使用更为普遍的多重继承?
Bjarne Stroustrup:最明显的例子就是“多接口、单实现”,这是一种很常见的情况。例如 ,你的系统也许既需要序列化功能,也需要迭代功能,那么这两个功能都可以接口的形式利用抽象类提供。然后,如果需要提供一个支持序列化的容器,你只需要让容器类继承序列化抽象类和迭代抽象类就可以了 ,而这种多重继承的形式已被Java和C#采纳。
另一种通常需要使用多重继承的情况是仅仅通过多重继承将手头的一些类组合起来。它们每一个都没有特别复杂的语义,将其组合起来完全是出于使用上的方便。当然,你也可以使用委托的模式来完成这个工作,也就是说,你可以在对象中 容纳一个指向真正实现某些功能的对象指针。这种方法虽然也不错,但每当你在间接对象中添加一个新方法时,你都需要在自己的类中对应地增加一个新方法。这种做法真让人头痛,而且也没有直截了当地表示出原本的想法,维护 起来则更是费时费力。最后一种情况是你需要从两个类中分别继承它们各自的状态。在这种情况下,当这两个类都非常复杂或它们的语义相互影响时,你很容易陷入混乱之中。 然而你可以通过减少过度继承的方法尽量减少这种情况发生的次数,而当你不可避免地需要使用继承时,你可以通过尽量减少过度使用多重继承达到目的,而如果到了连多重继承都是非要不可的时候,那么你应该尽量回避那些复杂的 变数。总的来说,在对一个具体问题建立一个模型时,你应该让该模型尽量简单,但不致于过分简单。
有些人经常会说他并不需要多重继承,因为所有多重继承能做的事情都能通过单继承完成,只是要使用我上面提到的那个名为“委托”的小技巧而已。更进一步,你也并不需要任何继承,因为所有单继承能够完成的事都可以通过类之间的 转发完成。实际上,你根本不需要任何类,因为你完全可以利用指针和数据结构来达到目的。可为什么你会想要建立类呢?什么时候使用语言内建设施比较方便?什么时候你宁愿用一种绕弯的方法呢?我见过 有很多场合多重继承甚至是非常复杂的多重继承发挥了重要作用。总体上来说,我更喜欢使用语言提供的功能来处理事情。
我们应对复杂情形的另外一种方法是利用模板进行组合。具体而言就是提供多个模板参数,而每个参数都是一个完全独立的类,它们都是你能够进行组合的抽象的具体实现。这些类每一个都是完全独立的,只有最后的派生类才与它们 中的每一个存在依赖关系。有时候在一个模板内部根据继承关系进行组合是很便捷的,而有时则需另想办法(例如你可以将每一个单独的类作为一个数据成员存储或仅 存储它们各自的指针)。这里有一个你有时需要从多个类中继承状态的例子:你有一个配置器对象,它知道如何处理关于内存的分配和销毁的问题,你也有一个存取器对象,只要你把内存地址给它,它就能处理关于内存存取的问题。现在,你准备将他们都用于你的一个项目实现中 ,就让我们假设是一个操作矩阵的复杂函数吧,此时你至少已经拥有了两个状态量,可是并没有带来那些对多重继承心存疑虑的人所担心的那些问题。基本上, 你用一些非常简单的词汇就可以将运作的情况解释清楚。
多范型程序设计
Bill Venners:另一个我以前用C++写程序的时候未曾听说过的概念是多范型编程 ,即在程序中使用多种范型。最近您似乎经常谈到这个概念。C++支持什么样的程序设计风格?在同一程序中组合运用各种风格有何优点?
Bjarne Stroustrup:多范型程序设计并不是一个新概念 ,它不过是描述事物的一种新方法而已。就像我并不能成功地教育人们应该使用作为接口的抽象类来代替状态易变的类一样,我在解释如何使用各种不同的程序范型时也遇到了很多困难。 不过,在我的关于C++的第一本书中有如下描述:C++支持传统的C形式的程序设计,并且比C做得更好;C++支持数据抽象以及面向对象思想。数据抽象的思想基本上就是你在利用Ada或者类似的其他语言写程序时 采用的思想,这种编程思想非常适合处理有着确定的标准概念(如复数、向量和上对角矩阵)的高性能数值计算问题。这些问题要求对于那些标准概念有着非常高效的实现,并将这些概念用一些相互联系的类来表达。
Bill Venners:数据抽象就是指不带任何继承关系的 各自独立的类吗?
Bjarne Stroustrup:基本上是。面向对象编程的意义就在于你可以使用类继承的概念 ,这个概念首先由Simula引入。当你阅读我以前写的一些材料时,你会发现在有关数据抽象的主题后面,我通常会跟着写上“我们还需要一种将容器内元素的型别参数化,并且能针对该种容器实施某种操作的机制”,这种思想就是后来发展起来的所谓 的泛型程序设计。至于面向对象的思想则于稍晚些时候出现,并且迅速攫取了许多人的注意力,使他们在进行程序设计时特别关注于类继承的概念。而在C++世界,泛型程序设计的思想从80年代末起,就开始缓慢地从数据抽象思想中显现出它的一些独立的特性,发展到今天,它已经取得了令人瞩目的成绩 ,我们对于这种思想的了解程度也今非昔比了,所以我需要将它提出来,单独描述。
Bill Venners:泛型的思想就是我针对 类型T编写代码,而T的具体类型将在以后确定?
Bjarne Stroustrup:没错。不过当你说“模板类型T”的时候,你已经落后了 ,现在的说法应该是“所有T类型”。我在1981年写的关于“带类的C”(后来发展成为C++)的第一篇论文中就已经提出了参数化类型的概念。不过那时候,我虽然提出了正确的问题,可是却给出了错误的答案 。我那时解释说我们可以使用宏机制来解决问题,可那只会产生出一堆恶心的代码。幸运的是,一个正确的问题比一个正确的答案重要得多,因为至少当你有了一个正确的问题时,你总可以通过努力来找到它的答案。相比于我定义它们时而言,现在我们对于参数化机制有了更深刻地认识。我的意思是,我当时仅仅看到了这个问题重要性的一部分,也只看到了这个问题答案的一部分。不过令人高兴的是,我那时看到的已经足够多了。现在,由于C++使用模板对参数化机制 提供了直接的支持,我们已经可以在C++中完成很多在上个世纪八九十年代时不可能完成的任务了。
其实从一开始就存在对多范型编程的需要。这就是我为什么在说“C++支持面向对象编程”时通常还要加上“而且比一些其他语言做得更好”,我从不说“C++是一门面向对象的语言”。因为我从不认为这个世界上只存在一种正确的 编程方式。从一开始,对于各种编程范型的需要就一直存在,我常列出的范型有C形式的程序设计、数据抽象、面向对象以及泛型式程序设计,它们都得到了C++的直接支持 ,而且从一开始,将这些范型组合使用的例子也一直存在,我现在只是更着重强调了这一点而已。我认为我在这个问题上的教育工作做得比较成功,也许我更擅长让人理解这个概念 ,也许整个C++社群已经足够成熟,能够很轻松地领悟对多范型的需求。尽管如此,在C++社群中,还存在着许多 待以理解的问题,特别是如何将各种风格组合在一起以产生最好、最有效率和最易维护的代码。
说到这个问题,我恰好在一次阅读我的《The C++ Programming Language》一书第3版的一篇书评时有过一次愉快的体验。如果我没记错的话,书评者应该是Al Stevens,他说他认为第三版比原来的版本更容易阅读。为了证实这个想法,他回过头去重新检阅了一下原来的版本,看看它们是否真如他所想得那样糟糕。结果他得出的结论是:第一版不是很糟,不过是讲述问题不如现在的版本清晰而已。可是当那本书的第一版出版时,他也曾写过一篇书评,其间提到那本书几乎是不可理解的。先锋们的工作在现在来看,的确是难以理解,不过通过他们的努力工作,思想和社群都会慢慢地成熟起来。如果你回到C++最初出现的时代,你将会发现现在很多显而易见的概念在那时都显得如此难以理解,你一定会奇怪,为什么对于使用这些如此基本的元素,人们都存在这么多的困难呢?我不是很理解为什么我不能很好的将这些想法教给人们,可我自己从这些想法中学到了很多东西。
资源分配就是初始化
Bill Venners:另一项当年我在使用C++编程时从未听过的概念就是“资源分配就是初始化”。您可以给我解释一下这项与内存管理、资源管理 和异常安全都有一定关系的技术吗?
Bjarne Stroustrup:如果我创建了10000个对象,并且得到了它们的指针,那么我就需要显式地删除10000个对象,既不是9999个,也不是10001个。天哪!我可不知道要怎样才能做到!如果要让我直接控制10000个对象的话,我相信我马上会抓狂的。这就是为什么我在开始时说过 ,如果以使用malloc的方法来使用new的话,你就已经被麻烦纠缠上了。所以,在很久以前我就想过“但我可以正确地处理较低数量的对象 啊”。如果我手头只有一百个对象需要管理,那么我可以有相当的自信来操控这一百个对象。当然,如果这个数量能够下降到100的话,那么我就开始沾沾自喜了,因为我完全可以肯定自己能够正确地处理这 种情况。
例如容器就是一个系统管理对象的方式。虽然你可以在容器中放入一些指向其他对象的指针,然而你也可以利用容器的构造函数和析构函数自动地为你管理被包含的对象。这个 技术的关键之处在于将分配操作隐藏起来了。既然你不需要直接显式地分配任何东西,那么显然你也无需操心与之相关的对象的销毁任务,对那些资源具有“拥有权”的东西会负责 销毁它们。在这里,拥有权的概念是一个中心点。我们可以说一个容器“拥有”它内部的对象,因为那些对象是直接存放在容器内部的,而对于一个存放对象指针的容器来说,事情则稍微复杂一点 ,我们既可以让容器拥有指针所指向的对象,也可以不这样。不管怎么说,在这里我们仅仅需要作出一个决定:拥有,还是放弃。这样,与前者相比较而言,我们将复杂度降低了1000倍。如果你将这项技术 反复用在越来越多的地方,那么你的代码从表面上来看,将会看不到任何分配和销毁的操作了。
你需要关注的另一件事情就是更一般范围的资源管理。你应该如何管理一个文件?难道是采用老式的文件指针的方法吗?如果是这样,你就需要使用open操作去初始化文件指针,并且要牢牢记住当文件使用完毕将其关闭。如前所言,你不应该在分配操作后无所事事,然后去使用那种完全“裸露”的指针,你 应该认识到,打开操作的实质就是分配一个文件操作句柄。所以作为替代,我们可以为一个文件或者说是文件的句柄建立起相应的资源对象。当对文件执行打开操作时,就初始化一个文件句柄。如果在构造函数中成功打开了一个文件,那么在析构函数中就会将这个文件关闭。所以,“资源分配就是初始化”的说法的实质是利用构造函数和析构函数来隐藏显式的分配 和释放操作,这项技术有时被简称为“RAII”,听起来有些笨拙。凑巧的是,在异常处理领域,这项技术也被发现是非常必要的 ,因为异常处理的主要宗旨就是确保程序能够以一种合理的状态运行。这就表示它不会泄漏资源、不会改变不变式等,而这些要求就带来了同样的资源管理问题。再强调一次:资源管理的最主要工具就是构造函数和析构函数。
Bill Venners:所以异常安全的意思就是如果在我的类中抛出了一个异常,那么我的类的析构函数会自动负责清理工作 — 关闭那些当类析构时需要关闭的资源,并且保证我所使用的不变式不会因此受到影响。
Bjarne Stroustrup:你说得对。基本上是这样,不过详细讨论起来,这个问题也没有如此简单 ,关于它有一套完整的理论,人们可以从《The C++ Programming Language》第三版附录E中获得 有关信息。如果你手头的第三版并未包含附录E的话,那么你应该换一本更新的了。不过如果你已经身无分文,再也买不起一本新的话,也可以 到我的主页下载附录E的全文。无论如何,如果你手头的C++书籍没有一章关于异常安全的内容的话,那它已经过时了。
异常表示发生了一些坏的(至少是不期望的)的事情,并且你希望让别人帮助你从中恢复过来。要想达到这个目的,你要确保在抛出一个异常之前,你已经将你领域内的混乱情况清理干净了。更具体地说 ,你不应该在堆内分配了一个对象,然后再紧跟着又丢出一个异常,这样做会“泄漏”这个对象。如果你分配了这个资源,那你要么将它销毁,要么在抛出异常前将 其拥有权转移给别的什么东西。异常被抛出后,程序执行的流程将会沿着原本的调用链被层层解开,在每一层调用被解开时,都要保证在该层内分配的所有需要释放的资源都得到了正确 地释放。如果你不使用“资源分配就是初始化”技术,你就需要写一个try代码块,并且还需要提供一块能够捕捉所有异常的代码,在这段代码中,进行必要的清理工作,然后将异常重新抛出,就像在Java中写finally代码块一样。当然,如果你忘了写这个finally代码块,你也就制造了一个BUG。你必须确保每次代码运行到这 儿时都保持正常,而我认为这是不可能的事情。最简单也是最容易管理的方法就是使用“资源分配就是初始化”。