全文共分四部分。
第一部分谈了系统设计的基本原则和方法。笔者这部分认为非常精彩,大师们对于系统设计的一般原则和方法的理解都是类似的。象笔者这种菜鸟只有拜读之后恍然大悟然后顶礼膜拜的份了。
第二部分,第三部分谈了程序设计的风格问题,笔者认为其中关于类的设计的讨论可以说是一针见血。任何有志于面向对象程序设计的高级程序员(无论使用何种语言开发)都应该读一读这部分。
第四部分谈了抽象和效率的问题。其中一个重要观点就是,抽象有利于效率而不是反之。
全文:
在2003年9月的JAOO会议上,Bill Venners和Bjarne Stroustrup碰面并进行了这次谈论。 Bjarne Stroustrup是C++语言的发明人。
0. 精致以及其他设计理念(Elegance and Other Design Ideals) Bjarne Stroustrup谈论了软件设计的许多方面。包括如何将小的软件扩展为大的软件,避免在设计者和用户之间区分类,过早一般化(generalization)的危险和精致(elegance)的本质等等。
0.1. 编码之前思考(Thinking Before You Code) Bill Venners: 在和Biltek的谈话中,你说过:“我不是使用支持工具进行巧妙设计的信徒,但是我强烈支持系统地使用数据抽象,面向对象编程,和generic programming。不拥有支持库和模板,不进行事先的总体设计,而是埋头写下一页页的代码,这是在浪费时间。这是给维护增建困难。”你认为进行多少事先的设计合适?编码之前我们该思考多长时间?
Bjarne Stroustrup: 取决于问题的规模。如果你今天下午就要使用这个程序,使用信封背面做做设计就可以了。如果你的系统需要多年才能完成,那你当然需要更多的事先设计。项目需要的人越多,需要进行越多的设计工作。然而,系统越大,事先的设计越困难。设计工具不能给你很多的反馈,所以我认为建立一个更小的系统,慢慢扩展更好一点。有一个基本原则,每一个成功的大型项目都是由小项目扩展而来的。每个小项目又是由更小的项目扩展而成的。依此类推。
有些人认为我说的是建立原型。某种程度上是的。但不全是。原型有时候可能是个陷阱。如果原型是由完全不同的开发者使用不同的工具开发出来和真实的应用程序不同的东西,你就会有麻烦。例如,相对于开发面向10000个客户的真实应用程序,你可以用更好的软硬件加上更好的程序员开发面向10个客户的原型。你可以忽略一些非标准的问题。你可以忽略兼容性问题。最后你发现,原型得到的方案无法应用到更大规模的实际应用程序上。
有时候你可以先实现系统的部分功能。但我强调尽可能早地进行集成测试。例如,写个简单的“hello world”程序。在分布式系统上,你就为分布式系统的每台电脑都写个程序,然后让它们交谈。于是你发现Java ORB不能和你的C++ ORB交谈,或者你的在sun机器上的java虚拟机不能和HP机器上java虚拟机交谈。当然我只是举例。但是可以肯定的是,“总”会有未料到的问题发生。我不是在谈原型。我在谈真实环境下的真实系统的试验。尽可能早的进行这些试验。
0.2. 用库思考(Thinking in Libraries) Bill Venners: 我经常听你宣称C++支持库的设计。哪种程度上应用程序开发者以可以认为自己是开发库呢?我们需要把应用程序分成这个库或者那个库吗?或者你说的库就是字面意义上会给很多人使用的库?
Bjarne Stroustrup: 我认为人们应该把编程分成库的开发和库的使用两步。今天你写支持库,明天你使用它。但是人们很多时候不这样做。即使我一个人写一个很小的程序。我也总是先写一些支持类和支持函数。我不喜欢直接开始写一个完整的应用程序。所以,不管你是写应用程序还是写库,你都需要划分模块。
C++的一个问题是有太多的库,但是没有一个大卖场有所有这些库。C++不是对GUI支持不好。C++有GUI库。问题是C++有25个GUI库。有些GUI库不错。但是人们说:“可是C++没有标准GUI库。”Python不一样。人们知道到哪里去找Python的GUI库。所以C++的问题是有太多库,但是所有的库市场宣传都做的不够(It"s a problem of riches, plurality, and a lack of marketing)。搞C++的看来都很穷,没有钱来提供一个可以找到所有库的大卖场。
绝对不能认为自己已经掌握了完美的库的设计艺术。抱着这样的态度去设计库,一定会失败。在我开始使用你设计的库后,你认为大功告成,于是拍拍屁股走人。这可不对。库的设计者必须是库的使用者,或者至少,设计者必须长期维护库。和用户长期交流,你才能知道什么是正确的抽象,什么是正确的细节。库需要演化。最好的库是由库的使用者设计的。类的使用者和设计者是两拨人,这可不对。
0.3. 哪些纳入语言标准,哪些放弃(What to Leave In, What to Leave Out) Bill Venners: C++内容很多。语言或者库的内容越多,语言或者库的使用者越得心应手,但是使用者必须学习的东西也多。即使为了知道哪些东西可以忽略,使用者们也必须学习很多东西。作为C++语言和标准库的设计者,你是如何决定哪些特性放进标准,哪些忽略的呢?
Bjarne Stroustrup: 这是很难决定的。我没有一种足够好的哲学来做决定,我想别人也没有。在C++语言中,我试着把对所有人而不是少数人有益的特性放进标准。我试图放进标准的是一些通用的工具。例如,我不把GUI特性放进标准。因为很多程序没有GUI界面。我不试图增加对数据库的标准支持。因为许多人并不使用数据库。货币也不作为语言特性,因为你可以使用支持货币的库。我尽可能使语言简洁,避免冗余,避免人们学习太多的东西。我在模板(template),内联(inline),重载(overload)方面做的工作,消除了冗余,消除了危险的强制类型转换。这是最好的例子。
当然,我刚才说的既可以适用于标准库的设计,也适用于语言的设计。很难简单地在库和语言之间画一条线。我总是试图把一些能够实现抽象机制的特性加入C++语言。所以和别的一些语言不同,我没有将advanced vector加入语言特性中,因为我可以在库中实现该特性。我没有提供一些操作字符串的工具,但是我提供了一些抽象机制,可以在此基础上建立字符串类。我没有提供属性(properties),因为你可以自己实现属性类。简单的说,我反对现在语言设计的一些流行趋势,这些语言把一些具体的和只用于某些特定场合的工具也加入语言本身。这是我个人的设计理念。
在我的书《The Design and Evolution of C++》中,我详细讨论了我的设计理念。阅读这本书可以使你避免某些人(直接说,就是我)常犯的错误,例如事后诸葛亮,试图亡羊补牢啦等等。我的书中诚实地记录了我在设计C++期间的所有想法,包括我犯的所有错误。很不幸的是,计算机界的历史是常常是修正主义者(revisionist)的历史。但是我不是修正主义者。C++的标准库和使用风格有过变化,但是语言本身没有改变。
0.4. 防止自我伤害(Protecting People From Themselves) Bill Venners: 语言和库的设计者通常想的是避免使用者自我伤害(protecting users from themselves)。但是你设计C++的哲学似乎是给C++的使用者最大的灵活性和方便,即使这种灵活和方便会导致他们自我伤害。在你的C++ FAQ中,你写道,“(作为语言设计者)你如果试图保护使用者避免一些简单明显的错误,你就会间接地给他们制造了新的和不明显的错误。”那么你认为语言设计者应该怎么做呢?
Bjarne Stroustrup:某些语言设计者把使用者当作未成年的小孩子。我不会这样做。也许我是有点过于浪漫了。我认为程序员比一般的计算机用户聪明。我认为程序员是值得尊敬的专业人员。
此外,我认为语言的设计者有意地限制程序员实现一个任务的选择权是剥夺了他们的公民权利。我经常看到用户由于想要实现被系统限制的功能而不得不去求系统的供应商。最好的例子就是某些第四代语言和一些所谓能从模型或者图中产生代码的工具。在这些例子里,如果你要用到某种特性,而你的开发工具没有提供这种特性,你就没有办法了。你能做的就是求工具的供应商在下一个版本中增加新的特性。我看到的最过分的例子就是一个用户竟然不能使用自己定制的排序(sort)。在这些语言中,你当然也不能用语言本身写标准库或者更先进的库。于是你把语言的开发者分成了两类。一类是不被信任的普通开发者。一类是拥有开发库的特权的高级开发者。当然不用奇怪的是,后者都是为供应商工作的。
我认为人都是平等的。我的理想就是如果你不喜欢随语言提供的库,你就可以用语言自己写一个。语言本身保证了你的自由选择权。当然那些靠语言赚钱的供应商是不会喜欢我的理想的。
C的设计原则就是你可以用C语言实现标准库。C++的原则也是如此。别的语言是否有这样的原则就难说了。别的语言用C或者C++实现语言的标准库。这种做法也说的过去。但是这样就存在风险。这个风险就是,使用语言的用户变成了低级使用者,他们被所谓的语言专家控制,而这些专家则使用C或者C++完成实际的工作。这些专家都是供应商的人。如果你使用的语言具有通用性(generality),你可以获得安全。免于被供应商控制的安全。
0.5. 为无法想象设计(Design Beyond Imagination) Bill Venners: 你和Rogue Wave说过,“我所创造的工具,应该能做我无法想象的事情。”
Bjarne Stroustrup: 是的。
Bill Venners: 怎么做到这一点
Bjarne Stroustrup: 听。从很多人那里听,然后追求通用性(generality)。
Bill Venners: 通用性(generality)指的是对具体问题的抽象解决方案吗?或者是很多人都要使用的工具?请定义通用性(generality)。
Bjarne Stroustrup: 让我举例说明。类(class)可以做任何事。C++没有为大的对象,小的对象,面向对象的类(object-oriented classes),值类型(value types),GUI事件(GUI events),属性(Properties),线程(threads)等等分别定义类的概念。类就是类。C++的类的概念适用于各种场合,并且有足够的效率。“因为使用类是昂贵的(expensive),所以类的概念只适用于大的昂贵的操作(big expensive operations)。”,这不是我的设计。(译者按:本句很难翻译,我的理解是Bjarne Stroustrup的理念是,类的概念必须有通用性,为此他确保类是有效率的。全文为“I didn"t decide that classes should be only for big expensive operations because it"s expensive to call them.”)
通用性(generality)不仅仅是指不限制用户。通用性(generality)还有数学的意义。你确保所有的例子都能工作。所有的逻辑设计空间(logical design space)都被覆盖。你说语言必须能做这五件事,不需要更多了。为什么是五?我们都知道,你设计时写下的待实现功能清单永远是不全的。在开发过程中,你总会发现你必须做第六件事情。例如,C++没有对事件系统(event system)的内建支持,但是C++t的通用机制可以使你实现任何操作系统下的事件系统(event system)。
0.6. 过早的通用(Premature Generalization) Bill Venners: 现在让我们回到使用库开发应用程序的程序员。他们何时该使用通用工具解决解决一个具体问题呢(与此相对应的是为特定问题写下特定的代码)?
Bjarne Stroustrup: 我不知道。很难泛泛而谈。我只能回答“看情况而定”。我个人一般倾向于在有多次重复的经验前先建立具体的案例。因为人的本性如此。如果我第二次碰到相似的问题了,我会说,“等等,我解决过这个问题”。我已经有了这个对这个具体问题的经验和教训,于是我开始考虑是否能够有个比较抽象的解决方案。有些不成熟的设计者的做法相反,在获得一个具体案例的经验前就试图给出一个通用方案。
Bill Venners: 听起来象是过早的通用(premature generalization)。
Bjarne Stroustrup: 对,就是这样。过早的通用,就如过早的优化。
0.7. 丑陋的语法对应丑陋的操作(Ugly Syntax for Ugly Operations) Bill Venners: 在你的(C++ Style and Technique FAQ),你说过,“丑陋的操作必须要用丑陋的语法来表达”,我认为这种设计方法很有趣。你不鼓励但是不限制某种做法,于是你就用丑陋的语法来表达这种操作。
Bjarne Stroustrup: 对。这种丑陋的操作就是类型转换(cas)t。Cast是必须的。在C++中,编译器可以对不安全的cast进行检查。相对于C是一种改善。我有意把语法设计的难看点,因为cast本身是丑陋的不安全的操作。
0.8. 精致以及其他设计理念(Elegance and Other Design Ideals) Bill Venners: 当我们进行设计时,我们的设计理念和设计目标应该是什么?为什么?你一直谈到精致(elegance)。投资精致(elegance)的商业回报是什么?或者说你认为设计的目的是什么?
Bjarne Stroustrup: “精致的(elegant)”和“简单的(simple)”是紧密相关的两个词。“易于理解的(Understandable)”也是他们的近义词。这些词意味着什么?这些词意味着你可以使用工具。你可以优化你的程序。你可以维护你的程序。你可以移植你的程序。你可以从你的程序中分离出逻辑单元,单独处理它。如果你的程序只是由一大堆“烂糊面”代码组成的,你就不可以对你的程序做任何事情。
顺便提一下。我认为“可宣言的(declarative)”,就是最精致(elegant)的特性之一。这是我喜欢静态类型语言(statically typed languages)的理由之一。你可以说:这是一个双精度浮点数矩阵。于是很多东西都明了了。你明白这里的乘法是矩阵乘法而不是普通的数值乘法。所有关于矩阵的理论都适用于你宣称的矩阵类型。你声明(declare)一个拥有构造函数(constructor)和析构函数(destructor)的类,然后你构造对象,然后你宣称资源将会在析构函数(destructor)中被释放。非常清晰。不管是对机器还是对人来说都非常清晰。
当我使用精致(elegant),...
Bill Venners: 这里的精致(elegant)是什么意思?
Bjarne Stroustrup: 很难说清楚。当你看到数学中的证明时,你可以说这个证明是精致的(elegant),清晰的(clear),通用的(general),短小的(short)。我以前学的是数学。精致对我来说是有一种被神圣的光辉照耀的感觉。它是一种美。美是和人的需求有关系的。例如可维护性和可快速开发性。因为你是在比较高的抽象层面构建程序,你可以比较高效地开发程序。
我总是告诉我的学生,必须懒惰。我讨厌很长的程序,因为实际工程中你不会有足够的时间写长的程序的。精致的程序是短的。使人们惊讶的是,如果你把好的C++代码和同等水平的别的语言比较,你会发现C++更短。当你使用标准库或者别的库写C++代码时,你得到的是短的代码,因为你可以简洁地(succinctly)表达你的思想。这就是我的理想。说你想说的。清晰地说。尽可能通用地说。于是代码短而快。
1. 有效的C++风格(The C++ Style Sweet Spot) Bjarne Stroustrup和Bill Venners讨论了两种不良的C++编程风格,一是过于底层的编程风格,二是过于面向对象的编程风格。
1.1. 超越C的层次(Climbing Above C-Level) Bill Venners:你曾经说过,C++社区还没有充分使用C++提供的便利。如果程序员能够重新考虑完善他们的编程风格,他们可以使自己的程序同时实现易写,正确,易维护,高效这几个目标。能不能请你具体谈一谈?
Bjarne Stroustrup: 给出不要如何做的建议比给出如何做的建议要容易。所以我总是从容易的部分开始。很多人认为C++就是增加了新特性的C,所以他们的代码总是有很多数组和指针,用起new来和malloc差不多。这样的写出来的代码抽象层次比较低。当然以C风格写C++程序是一种C++入门的方法,但是这样不能充分利用C++。
我认为刚开始使用C++,最好从标准库入手。例如使用标准库的vector,而不是使用普通的array。vector知道自己的大小(size),但是array不是这样。你可以手动或自动地增加vector的大小。但是要动态的增建array的大小,你就必须手动的调用malloc,relloc,memcpy等等函数。C++也提供了一些inline函数以取代macro。C++的string类也比C的字符串好。不好的编程风格的一个明显标志是使用了很多强制类似转换(cast)。这表示你在bit和byte的低层次编程而不是一个更抽象的层次编程。
避免低层次编程并不是意味着要写很多类,而是意味着应该充分利用库。stl当然是应该首先利用的。还有别的许多库可以利用。例如,没有必要在C的层次进行多线程编程,你可以使用boost.threads之类的C++线程库(C++ threading library)。类似的线程库有好多。如果你想使用回调(callback),可以使用libsigc++。这样你可以在更抽象的层次编程,避免了纠缠于易错的细节。
有些人不公平地批评这些库是低效的。因为他们假设如果库是精致优美的(elegant)并且提供了更抽象的接口,那么库就是低效的。一些特定的情况下,使用库比手工硬编码(hardcode)慢一些。我的建议是使用库,除非碰到了上述特定的情况。有时候使用库和手工硬编码的效率是一样的。例如vector和array一样快。
1.2. 面向对象狂(Object-Orientaphilia) Bjarne Stroustrup: 有时候麻烦的是人们走向了反面。面向对象狂们相信“万物皆对象”。所以他们建议做任何事都要以创建类开始,然后要把这个类塞进一个包含许多虚函数的类继承树(class hierarchy )中去。Java就是一个例子。
我认为并不是所有的类都需要有一个父类的。例如整数类型就没有必要有一个父类。如果硬要给它个父类,既无效率,也不精致(elegant)。
你可以创建并自由使用独立的类(没有父类)。例如复数类就不需要有虚函数。除非应用需要,否则没有必要使用继承(inheritance)。对于图像类(graphics class),这种需要是很强烈的。例如我以前在书中引用过的shape类。但是很多情况下你根本就不需要使用继承。
所以刚开始你可以就使用简单的抽象。标准库已经提供了很多现成的例子,例如vector,string等等。除非必需,不要使用继承。继承用的太多往往伴随着强制类型转换(cast)的大量使用,特别是从基类转换到子类(casting from base classes to derived classes)。最新版本的C++提供了动态类型转换(dynamic cast)的工具,至少这是类型安全的。但是请尽量少用。好的设计是仅仅当你从程序外向程序内引入一个对象时使用强制类型转换(cast)。当你从外部引入一个对象的时候,你通常在一段时间以后才能知道它的正确类型,这时候需要类型转换(cast)。
Bill Venners: 低层次编程或面向对象狂会导致什么问题呢?
Bjarne Stroustrup: 用C风格编程,就会遇到C的问题。你会碰到缓冲区溢出问题和指针问题。最后得到的是不易维护的代码,低抽象层次的编程的代价就是开发时间和维护时间。
一个庞大的类继承树(big class hierarchy)导致同样的问题。你写了比实际需要更多的代码,各个模块间的联系太多。我特别讨厌那种有很多get和set函数的类。这些类实际上不应该被设计为类,而应该是一个结构体(structure)。
1.3. 类必须实现不变式(Classes Should Enforce Invariants) Bjarne Stroustrup: 我的基本原则式真正的类必须有一个接口,有一个隐含的不变式(invariant)
Bill Venners: 不变式(invariant)是什么东西?
Bjarne Stroustrup: 什么使一个对象(object)有效?是不变式(invariant)。我以vector举例说明。vector知道自己有n个元素(element),vector也知道自己有一个指针指向这些元素。以上两点就是不变式(invariant)。如果vector实际上竟然有n+1个元素,就出问题了。如果vector包含的指针为0,也表示有bug。所以在定义一个类前,你必须明确什么是不变式(invariant),类的接口必须体现了不变式(invariant)。如果类的成员函数(member function)不能体现不变式,也许这个函数应该放到类的外面比较好。牢记类的不变式,你就能得到一个简洁而小的接口,易于理解和维护。
Bill Venners: 不变式(invariant)是建立类的必要条件?因为类的责任就是维护不变式(invariant)?
Bjarne Stroustrup: 说得对。
Bill Venners: 不变式就是类中的各数据间的关系。
Bjarne Stroustrup: 对。如果任何数据都可以随意定义自己的值,就没有必要创建类。使用结构体(structure)就行了。考虑有一个数据结构只有姓名和地址两个字段,如果随便什么字符串都可以作为有效的姓名或者地址。那么结构体(structure)就够了。把姓名和地址作为私有成员封装到一个类中,再提供一堆访问私有成员的接口函数,是很愚蠢的。更愚蠢的就是再去设计一个基类,把这些接口函数设计为虚函数。没有必要。
Bill Venners: 你说没有必要是因为,数据的表示(representation)有且仅有一种。如果你要把这一种表示(representation)定义为一个函数,意味着将来你会修改表示(representation)。
Bjarne Stroustrup: 对。但是有些表示(representation)并不改变。例如整数,浮点数,复数等等。设计时你不得不做决定。
仍旧以上面的姓名和地址的数据结构为例。接下来,如果你从简单的结构体(structure)转向了真正的类。也许你不会把那个类的名字叫做“姓名和地址的组合类”。也许你会把那个类命名为“个人通讯录”。然后你觉得需要确保“个人通讯录”中的地址应该是有意义的字符串,等等等等。接下来你必须考虑数据的不同表示(representation)。例如姓名字段定义为私有数据成员吗?是否需要定义虚函数?你必须做设计而不是随意定义一些类和函数,你需要使用C++的语法来表达你的核心的设计思想,而不是简单的定义几个私有数据就完事了。(译者按:原文为semantics that you are defending,字面意思是保卫你所使用的符号的意义。)
例如,构造函数(constructor)建立了环境供成员函数(member function)在其中操作数据。也就是说,构造函数(constructor)建立了不变式(invariant)。要建立不变式(invariant),你必须获得资源。析构函数(destructor)做的事情正相反。资源可以是内存,文件,锁,socket等等。
1.4. 设计简单的界面(Designing Simple Interfaces) Bill Venners: 你刚才说不变式(invariant)帮助你决定了什么应该进入界面(interface)。你能进一步讲一讲你是如何设计界面的吗?如果一个函数有责任维护不变式(invariant)就应该在类中,对吗?
Bjarne Stroustrup: 对。
Bill Venners: 任何使用了数据,但是不维护(defend)不变式(invariant)的操作,就不需要放在class中?
Bjarne Stroustrup:让我举个例子。有时候某些操作必须直接访问数据才能完成。例如如果你要改变vector的大小,前提条件你必须能够移动其中的元素(element),修改存储大小的变量。如果你没有直接修改数据的权限,为了完成操作,你必须访问有权限的接口函数。但是一个在vector中查找指定元素的操作最好不要定义为vector的成员函数。
另一个例子是日期类,修改日期类中的年月日数据的操作当然应该是类的成员函数,但是查找下个周末的函数不应该是成员函数。我曾经看到日期类有60或者70个接口函数。接口函数直接访问数据。也就是说,如果你修改了类的数据结构,你就必须检查并修改所有类的接口函数。
如果日期类只有比如说10个必需的接口。你可以把其余的50个接口建立在这10个接口之上。其余50个接口可以放在支持库中(supporting library)。现在这种设计思想已经被普遍接受了。甚至在java中也是如此。
过去二十年我就在鼓吹这种设计思想。但是人们就是喜欢把所有的东西都丢到类和子类中去。比如说前面那个糟糕的日期类。如果你需要一些简单的工具操作日期,你必须继承自那个日期类。最后一切都乱七八糟。我仅仅是想自由的组合一些工具进行简单的操作,为什么需要继承呢?如果我想组合使用我的一些工具和你的一些工具,难道我需要定义一个子类,同时继承你的日期类和我的日期类吗?继承引入了不必要的依赖关系。
2. 现代C++风格(Modern C++ Style) Stroustrup讨论了multiple inheritance,pure abstract classes,multi-paradigm programming,以及如何在初始化时获得资源。
2.1. 多重继承和Pure Abstract Classes(Multiple Inheritance and Pure Abstract Classes) Bill Venners: 我有5年C++编程经验(1991年-1996年)。过去我认为多重继承(Multiple Inheritance)就是让我同时继承多个类的已经实现的虚函数和数据。我从来也没有想过要用类似于java中的interface的技术,用C++来描述的话,就是一个仅仅拥有纯虚函数(pure virtual functions)没有任何数据成员的抽象类(abstract class)。我从来没有在C++中多重继承多个纯接口(pure interfaces),也没有看到过别人用过。你一直在大力推荐多重继承纯接口(pure interfaces)的技术。使用这种技术需要经验吗,或者说我们就是不恰当地使用了抽象类?
Bjarne Stroustrup: 我不明白这种技术为什么会难于理解?从C++诞生的第一天,就有不带数据成员的类。在上世纪80年代,抽象基类(abstract base classes)被简称为ABCs。在1987年我给出了抽象类(Abstract Class)的明确定义,抽象类就是有纯虚函数的类。从那时开始我就反复地指出抽象类(Abstract Class)的一个重要应用就是定义interface。
人们可以选择把数据放在基类(base class)中,于是他们总是这么做。我不明白为什么他们这么做。这说明我的失败。我不能教育人们接受新概念。这有点象Simula社区以前碰到过的问题。一些新观念就是不能传播开来。当人们认为他们知道答案的话,他们拒绝学习新东西。
Bill Venners: 使用纯抽象类(pure abstract classe)的好处是什么?什么时候应该多重继承多个纯抽象类(pure abstract classe)。
Bjarne Stroustrup: 最典型的就是多重继承多个接口,然后在一个类中实现这些接口。例如你的系统有persistence的概念(译者按:persistent的对象可以在程序结束,启动后能够保留自己状态。对象的状态可以记录在数据库中,文件中等等)。系统也有iteraction的概念(译者按:iterator的概念可以参考任意的C++教程)。现在如果你要实现persistent container,你就必须同时继承persistence抽象类和提供iteraction服务的抽象类。(译者按:通俗点的例子,奶牛对于我们来说是牛,对于牧民来说是挣钱机器,所以奶牛必须同时实现牛和挣钱机器两种接口)。Java和C#中有很多应用。
还有一种用法就是你想把手头的几个类组合一下。这几个类都很简单。所以方便的做法就是多重继承。使用委托(delegation),也就是说,使用指向对象的指针,并且封装被指向对象的接口,当然也不错。但是维护起来有点麻烦,因为被指向对象增加了一个接口,你就得自己手动封装一个接口。最后一种用法就是你想继承两个对象的状态(state of two classes)。如果这两个类很复杂或者两个类所表达的概念(semantics)互相影响,你就麻烦了。当然你可以避免滥用多重继承(overuse inheritance)。不要再使用多重继承玩更复杂的花样,尽可能简单直接。
如果有人说你不需要多重继承。他说的很对。你只需要单继承(single inheritance)就够了,因为你可以用我前面介绍过的委托(delegation)技术。进一步,你可以连继承都根本不使用。因为任何单继承都可以使用委托技术替换。事实上,你也不需要类,因为你可以使用指针和结构体来模仿类。(译者按:我认为Bjarne Stroustrup在说反话)那么你究竟想干什么?标准的语言特性不用而使用特殊技巧实现同样功能,为什么?我个人选择是什么方便用什么。
多重继承的一个复杂应用就是使用模板进行组合(composition using templates)。你的模板需要几个参数类,这些参数类都是完全独立的,互相没有依赖关系。只有同时多重继承他们的子类依赖于他们。有时候在模板中进行组合(composition)是很方便的。有时候不方便(所以你使用委托)。这里我给出个多重继承的例子,你获得一个知道如何和获得内存的allocator的对象,你也有一个访问内存的access对象。你可以用多重继承来实现matrix对象。matrix对象同时获得了两个父类的状态(state),但是不必担心两个父类的状态会互相干扰。通常,能简洁地解释的多重继承,就是好的多重继承。
2.2. 多范型编程(Multi-Paradigm Programming) Bill Venners: 你好像也经常谈到多范型编程(Multi-Paradigm Programming)。这又是一个我在用C++编程时从来没有听说过的东西。C++支持什么样的编程风格?将这些风格组合在一个程序中有什么好处?
Bjarne Stroustrup: 多范型编程(Multi-paradigm programming)并不是什么新东西。这又是我的一个失败。我不能教育人们接受多范型编程(Multi-Paradigm Programming)的概念。但是你可以看看我写的第一本C++的书。我在书中宣称C++支持传统C风格编程,支持数据抽象(data abstraction),也支持面向对象编程(object-oriented programming)。数据抽象就是Ada中你写代码的方式。数据抽象在高性能数值问题中非常有用。复数,vector,对角矩阵,这些都是独立的对象,必须被高效的实现。
Bill Venners: 数据抽象(Data abstraction)就是独立的不使用继承的类?
Bjarne Stroustrup: 差不多。面向对象编程(Object-oriented programming)就是你使用继承的地方。我以前曾经写过:“我们必须使容器(container)中的元素类型参数化” 后来这个相反就变成了generic programming。接着面向对象编程(object-oriented programming)一起来了,于是人们都迷上了继承。在C++世界里,generic programming的概念慢慢从数据抽象(data abstraction)中浮现,时间大概在80年代到90年代。现在Generic programming是非常流行了。
Bill Venners:generic programming就是我使用generic type T然后编译时决定具体类型吗? Bjarne Stroustrup: 对,当你说,“模板类型T(template type T)”,意思就是“对所有类型T”。我在1981年的第一篇关于“带类的C”(后来演化成了C++)就提到了类型参数化(parameterized types)。但是对于这个问题我给出了错误的解决方案,我试图使用宏。幸好在提出了正确的问题后,给出错误的方案也不要紧,因为我们最终解决了它。现在我们使用模板技术,在80,90年代的C++是难以想象的。
多范型编程(multi-paradigm programming)的概念就是从那篇论文开始的。这就是为什么我一直说“C++支持面向对象编程”,然后又说“比别的语言支持的更好”。注意我没有说,“C++是一种面向对象编程语言”。我从来也不认为只有一种正确的方式写代码。C++同时支持C风格编程,数据抽象,面向对象编程,generic programming。现在我更强调多范型编程。也许是我的教学水平有长进了。或者是C++社区比较成熟了,可以接受多种风格编程。当然还有很多教育工作要做,例如如何组合多种风格来创造最好的,最有效率的,最易维护的代码。
顺便说一下,几年前我读了一篇关于我的书《The C++ Programming Language》第三版的书评,让我很高兴。书评的作者Al Stevens说我的第三版比第一版好多了。然而,他又会过头去读了第一版,发觉也不像他记得的那么坏。以前第一版出来的时候他写了一篇书评,说第一版难以读懂。思想在成熟。社区在成熟。过去被认为难以理解的概念,现在则被认为是理所当然。有时候我很怀疑,这些概念当初为什么就不能被理解?当然从那时候到现在我也学了很多东西。
2.3. 获得资源就是初始化(Resource Acquisition is Initialization) Bill Venners:当我用C++编程时,我从来也没有听说过“获得资源就是初始化”这种技术。你能谈一谈吗?特别是关于内存管理(memory management),资源管理(resource management),和异常安全(exception safety)?
Bjarne Stroustrup:如果我创建了10000个对象,并有指针指向它们,当然我也要负责删除它们。我要删除的是10000个对象,不是10001个,也不是9999个。要做对并不容易,如果手动管理这10000个对象,很容易出错。就象我以前说过的,如果你使用new就象使用malloc一样,你就会有麻烦。但是如果我能够只管理100个或者10个对象的话,就不会有什么大问题了。
例如,一个容器(container)是一种管理多个容器的方法。放在容器中的元素可以是指向对象的指针,容器的构造函数和析构函数(constructer/destructer)负责这些对象的生存期。我想说明的是,如果你不手动分配资源(allocate something) ,你也不必手动释放资源。这里的关键是谁拥有资源。谁拥有谁管理。一个容器内如果有1000个指针,那么现在的问题就是容器是否拥有这些指针所指向的对象?如果拥有,容器就必须负责释放对象。这样就把管理对象的问题简化了。
接下来我们谈一谈资源的一般性概念。如何管理文件?以前你使用文件指针(file pointer)。你使用open操作初始化文件指针。你必须记着使用close操作关闭文件。实际上没有必要这么麻烦。你可以创建一个资源对象(resource object)用来管理文件,我们称之为文件句柄(file handle)。文件句柄(file handle)的构造函数打开文件,析构函数关闭文件。这就是我们所说的“获得资源就是初始化(Resource Acquisition is Initialization)”。简称为“RAII”。这种技术也可以勇于异常处理(exception handle),因为异常处理(exception handle)的一个目标就是保持你的程序在一个合理的(reasonable)状态。所谓“合理的(reasonable)”就是无资源泄漏(leaking resource)和无不变式被破坏(broken invariants)。让我再强调一遍,构造函数和析构函数(constructer/destructer)是主要资源管理工具。
Bill Venners: 所以异常安全(exception safety)意味着如果异常发生在一个类中,我需要清理资源,保证不变式(invariant)不被破坏?
Bjarne Stroustrup: 对。差不多。在我的书《The C++ Programming Language》第三版附录E中有完整的描述。如果有些人的第三版没有附录E,他们应该买最新版的第三版。如果他们太穷买不起第三版,可以到我主页上下载附录E(译者按:http://www.research.att.com/~bs/3rd.html)。如果你手头上的C++书籍没有异常安全(exception safety)的内容,说明这本书需要更新了。
异常(Exception)意味着不好的(常常是没有料到的)事情发生了,你需要帮助。在你丢出异常给别人前,你不得不先尽可能的清理本地资源(local mess)。然后异常回到函数调用堆栈的上一层,在这一层的函数也必须确保释放资源。如果你不使用“获得资源就是初始化(Resource Acquisition is Initialization)”技术,你就得写try代码段(try block),你必须catch所有的异常,以便做完清理工作然后丢出异常。有点象java的finally代码段(java block)。如果你忘了写finally,那么就有bug了。你每次过一遍代码的时候必须把每件事情都做对。这种做法本身就很容易制造bug。还是用“获得资源就是初始化(Resource Acquisition is Initialization)”更好。
3. 抽象和效率(Abstraction and Efficiency) Bjarne Stroustrup讨论了如何提高抽象层次,为什么编程就是理解(why programming is understanding),面向对象编程(OOPs)是如何发生的,过早的优化和谨慎的优化的区别。
3.1. 提高抽象层次(Raising the Level of Abstraction) Bill Venners: 我是从Borland公司的“C++世界”教学录像中学习C++的。在录像开始,你宣称你发明C++是为了提高编程的抽象层次。
Bjarne Stroustrup: 对。
Bill Venners: 什么是“提高抽象层次(Raising the Level of Abstraction)”,有什么好处?
Bjarne Stroustrup: 高的抽象层次是好的,无论在什么编程语言中。我们是如何理解问题的,我们就应该如何解决问题。我可以理解你的代码,但是我不必是个编译器。
抽象(Abstraction)是我们理解事物的方法(mechanism)。用数学描述一种解决方案,意味这我们确实理解了问题。用一堆循环凑合出来一堆东西,然后用它去套问题的一个个特例,意味这我们没有解决问题。总有诱惑存在,为特定问题给出特定的解决方案。然而,正确的做法是,将特定问题看成是一个通用问题的特例,然后使用通用的类(general class)来表示出这个特例。通常我们总会忽略这个特定问题的某个特定部分,于是会在将来制造更多的问题。例如,如果你掌握了关于矩阵操作的理论,你写的代码将会在矩阵理论的层次上运作,于是你的代码就更小,更清晰,更正确,更易于维护。
我认为提高抽象层次在所有的智力工作中都是很重要的基本原则。我不认为需要质疑这个基本原则。但是有些人质疑这个原则,因为他们认为抽象层次高的代码是低效率的。有个人听说了我在一次演讲中提倡在C++线性代数的应用中使用矩阵库。于是他对我说,“用矩阵库比用数组慢多了,我不能使用低效率的库。”使他大为惊讶的是,我的回答是,“要想达到我在那次演讲中所指出的线性代数演算的效率,你最好不要使用数组”。
比最快的代码还要快的就是没有代码。以上面的矩阵的例子继续讨论,如果把你的代码抽象层次提高到矩阵操作层次,编译器可以获得足够的类型信息来消除冗余操作。如果你在数组层次写代码,你就不能消除这些冗余操作。如果你认为你自己在这个领域比任何人都聪明以至于可以手动消除冗余操作,那当然没有问题。大多数情况下,你在数组层次写的代码更慢一点。理想的情况是人来负责矩阵层次的算法分析,低层次的数组操作分析由编译器来完成。
我最喜欢的两个例子,一个是matrix times vector操作,C++可以做的比Fortran更好;
一个是简单排序,C++比C做的更好。在这两个例子中C++可以将问题描述的非常清晰简洁。 当然这种事情不是每天都发生,但是当它发生时是如此美丽。
3.2. 编程即理解(Programming is Understanding) Bill Venners: 关于静态类型和动态类型有很多争论。强类型(strong typing)的拥护者们常常宣称虽然动态类型语言(dynamically typed language)可以帮助你快速建立原型,但是只有静态类型语言(statically typed language)才能真正建立一个强壮的系统。然而,你似乎强调的是静态类型有助于代码优化。那么你认为静态类型(static type)的优点是什么?
Bjarne Stroustrup: 有很多优点。第一点,利于理解。我们说这个操作可以用于整数,然后我有一个整数,接下来都很简单明了。
Bill Venners: 对谁简单明了,程序员还是编译器?
Bjarne Stroustrup: 程序员。我趋向于更象人一点(译者按:原文为anthropromorphize,使非人的东西具有人的特点或者人的个性,所以Bill Venners会有误解。)
Bill Venners: 使程序员更象人一点?
Bjarne Stroustrup: 使编译器。我这样说是因为我写过编译器,很有趣。作为程序员,我觉得自己可以更好的理解静态类型语言(statically typed language)写的代码。
在动态类型语言(dynamically typed language)中,你对一个对象进行操作,然后希望你操作的是正确的类型。否则一个运行期bug就发生了。当然,这时候的一般处理就是调试器被自动载入,如果有个程序员坐在一个终端前,他可以很容易的写些测试代码,进行快速调试。但是,你不可能用这种方法找出所有的bug。如果问题发生的时候没有程序员在场,就有大麻烦了。在有些环境下,例如电话交换机,嵌入式系统,如果一个bug导致调试器被自动载入,没有人知道该怎么办。
静态类型使得代码易写易读。例如,如果我申明一个函数参数的类型应该是Temperature_reading,别人不需要去检索我的代码来搞清楚参数类型到底是什么,他只需要看我的接口申明就可以了。我也不需要动态的检查用户给我参数的类型是否正确。编译器做了所有的检查工作。静态类型可以强迫我思考什么是本质的?我不会随便定义个类型,然后指望调用者(caller)和被调用者(callee)关于参数类型在运行期能够取得一致,或者如果不一致,至少有一方能够进行类型检查。
用Kristen Nygaard(译者按:Kristen Nygaard(1926年-2002年),挪威当代计算机科学家,社会活动家,图灵奖和冯·诺依曼奖获得者,SIMULA-67 语言的创始人,面向对象技术的先驱)的话来说,编程即理解(programming is understanding)。意思是:如果你不理解,你就不能编程,当你编程,你就开始理解了。这是基本原则。例如,我觉得一个整数向量要比一个对象指针好理解(译者按:我对此深有体会。我就碰到用MyClass** 来表示元素为MyClass的向量,理解和调试苦不堪言)。当然,你可以问那个对象指针是否包含整数向量?或者字符串向量?如果你需要自定义的容器,当然可以这样做。但是我建议你优先考虑语言自带的数据结构。某种特定类型的向量要好于包含通用类型的通用容器。为什么?一堆苹果就是一堆苹果,我不希望运行时我的一堆苹果被某些人当成一堆蔬菜来处理。甚至连Java和C#也开始提供机制来支持我的观点。
你不可能让你的整个系统是静态类型的,因为当你编译你的系统并且部署到特定平台上去的时候,你不能肯定你的系统是否只需要编译成一个二进制部件并且这个部件永不变更。所以我们用到了虚函数(virtual function)。虚函数的优点是你可以连接到已经经过完全静态类型检查但是还是不是完全了解的单元。无论你连接时的接口是如何的,你可以询问被连接的部件一些问题,然后根据回答启动服务。例如“你是否使用Shape接口?”如果回答“是”,那么你们就使用Shape接口交流。如果回答“否”,你就说“啊哈”,然后继续。这种提问回答机制C++是支持的,C++提供了dynamic_cast机制。dynamic_cast和别的动态类型语言的区别是,你不会运算到了一半才发现“天哪,我正在使用的接口不是我想要的接口”。
静态类型使得编译器可以进行大量优化。静态类型语言可以比动态类型语言快50倍。这里我说的快是用整数表示的。
Bill Venners: 整数?
Bjarne Stroustrup: 我不是在谈百分比。如果我告诉你快10%,50%,你会争辩说效率高一点低一点有什么关系,明年升级硬件就可以了。但是如果我告诉你快3倍,5倍,10倍,50倍,效果可能就不一样了。这就是效率的区别,在一些大型机器上,快两倍就意味着成功,否则就是失败。
Bill Venners: 你不是在比较动态方法和静态方法,你在谈优化,对吗?
Bjarne Stroustrup: 对。
Bill Venners:编译器是如何优化的?为什么编译器在知道确切的类型信息后可以进行大量的优化?
Bjarne Stroustrup: 让我们举个简单的例子。C++中的虚函数不是直接的函数调用。它比直接的函数调用慢25%。这通常无所谓。但是如果这个虚函数是个很小的函数,比如说是比较两个整数的操作。那么虚函数可能慢10倍到30倍。如果这个比较操作正好在程序的关键路径上,比如在一个嵌套循环的内部,那么效率的差别就很大了。这就是为什么C++的排序要比C的排序快。C的排序使用迂回的函数指针来进行比较操作。而C++使用函数对象(function object),也就是内联静态绑定的函数(statically bound inline function),没有任何函数调用开销。
3.3. 过早的优化和谨慎的优化(Premature or Prudent Optimization) Bill Venners: C++文化关注效率。是否存在一些不必要的优化?在早期优化中如何区分不必要的优化和谨慎的优化?
Bjarne Stroustrup: 某些C++程序员关注优化。某些优化是合理的。某些仅仅是不必要的恐惧造成的。有两种方式关注效率。我的方式是确保抽象可以有效地转化成机器语言,确保抽象易于理解。
如果我进行线性演算,我需要matric类。如果我进行图像处理,我需要图像类。进行字符串操作,我要字符串类。我首先要做的事就是恰当地提升抽象层次。接下来我要当心的是高层算法的效率。我要当心的是如果程序可以访问本地文件,就不要访问internet。如果程序可以访问内存缓存,就不要访问文件。我曾经看到有人用建模工具产生的代码,为了写入两个字段,连续访问了数据库文件的同一条记录两次。不要这样做。我认为这就是你要关心的正确的优化。
现在,如果你正确的建模了,你进行了合理的抽象,你开始优化。这时的后期优化是合理的。有些人因为对抽象有莫名的恐惧,于是拒绝使用库提供的数据结构和算法,而是使用自己手工编写的代码。这样的代码是无法理解的。这是个问题。因为在任何系统中,你总需要在将来的某个时候重新分析检查你的旧代码,进行调试和升级。
如果有正确的抽象,你就可以测量效率,找出瓶颈。如果你使用了map,你可能会发现map有点慢,这是很可能的,如果map里存储了几百万个元素的话。于是你可以考虑使用哈希表(hash table)。
现在,如果所有的代码都是你手工写的。你就不能找出问题的瓶颈。也许你认为自己手工实现了map,但是很有可能你实现的只是一个有点象map的数据结构。如果你自己实现的数据结构运行起来不是很好,你知道该用什么数据结构代替它吗?你的代码层次很低,所以你不可能知道问题出在哪里。通常手工实现的代码往往没有很好的接口,于是也无法使用profiler来测量瓶颈。由于没有正确的进行抽象,工具也不能帮上忙。如果你不能在代码中明确地命名并标示出某个东西,你当然不能指望工具告诉你“就是某个东西导致了低效率”。
作者:Bill Venners
译者:redguardtoo
原文网址:http://www.artima.com/intv/elegance.html