PEARSON
C++覆辙录
C++ Gotchas:Avoiding Common Problems in Coding and Design
[美]Stephen C.Dewhurst 著
高博 译
人民邮电出版社
北京
图书在版编目(CIP)数据
C++覆辙录/(美)杜赫斯特(Dewhurst,S.C.)著;高博译.--北京:人民邮电出版社,2016.4
ISBN 978-7-115-37259-8
Ⅰ.①C… Ⅱ.①杜…②高… Ⅲ.①C语言—程序设计 Ⅳ.①TP312
中国版本图书馆CIP数据核字(2015)第191898号
版权声明
Authorized translation from the English language edition,entitled C++ Gotchas: Avoiding Common Problems in Coding and Design,9780321125187 by Stephen C.Dewhurst,published by Pearson Education,Inc,publishing as Addison Wesley Professional,Copyright © 2003 Pearson Education,Inc.
All rights reserved.No part of this book may be reproduced or transmitted in any form or by any means,electronic or mechanical,including photocopying,recording or by any information storage retrieval system,without permission from Pearson Education,Inc.
CHINESE SIMPLIFIED language edition published by PEARSON EDUCATION ASIA LTD.,and POSTS &TELECOMMUNICATIONS PRESS Copyright © 2016.
本书封面贴有Pearson Education(培生教育出版集团)激光防伪标签。无标签者不得销售。
◆著 [美]Stephen C.Dewhurst
译 高博
责任编辑 傅道坤
责任印制 张佳莹 焦志炜
◆人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
三河市海波印务有限公司印刷
◆开本:800×1000 1/16
印张:21.75
字数:430千字 2016年4月第1版
印数:1-3000册 2016年4月河北第1次印刷
著作权合同登记号 图字:01-2014-5618号
定价:69.00元
读者服务热线:(010)81055410 印装质量热线:(010)81055316
反盗版热线:(010)81055315
本书是C++大师Stephen C.Dewhurst根据多年教授C++课程中所遇到的常见错误的心得笔记编写而成。本书所有章节都从一个众所周知的、在日常编码或设计实践经常遭遇的问题入手,先指出其不足,再对其背后思想中存在的合理与不合理之处深入剖析,最后取其精华,去其糟粕,给出一个简洁、通用的方案,给出如何规避或纠正它们的建议,从而有助于 C++软件工程师避免重蹈前辈的覆辙。
本书适合具有一定C++编程经验的读者阅读。
从本书的上一版付梓,至今不觉一晃又七年矣。七年间,太多的语言和框架销声匿迹,或是面目全非。而 C++语言虽然也有了标准的版本更新,并且新的语言标准中在核心语言部分引入了不少重大的改变,但是总体来说,一方面,C++语言在代际兼容方面的表现当属最优秀之列(“仅仅使用新版本的编译器来重新编译一遍旧代码,就能够收获不少额外的好处”,Scott Meyers语);另一方面,C++语言的底层设计哲学,比如“不为未使用的特性付出性能代价”等,是始终未变的。作为一种多范型语言,C++语言坚持在各个范型之间追求平衡感,力求给予程序员以最灵活的方式将范型加以组合,以尽可能直接的方式将问题域映射到解域,而非像很多语言一样必须在解决问题时削足适履。任何一种语言,都必须经过时间的考验,方能说明其生命力是否强大。C++语言在目前新语言以每年近30种的速度问世的前提下,能够多年如一日地占据编程语言排行榜的前五位,不能不说它确实是一种长盛不衰的程序设计语言。
毋庸讳言,C++98 在时下仍然是使用最广泛的标准版本。虽然采用未来时态学习C++11/14,甚至追学C++17,永远是值得提倡的。但是,连C++之父Bjarne Stroustrup也说,C++语言的学习,重要之处在于掌握正确的思维(见《程序员》2014年12月刊,译者采访编译)。而欲掌握正确的思维,有两个基本的方法。一曰在实践中认识,即在日常工作中使用而非只在论坛上看到只言片语的例程就以为自己掌握了最新技术,其实每种新的语言特性应用起来都有要求的语境。高手能用的新招式,你未必能用。二曰考察语言特性的历史变迁,看看C++98的语法在不同的标准版本下有着怎样不同的语义,这些变化的原因是什么,如何在变化的过程中保证最大程度的代际兼容性,这些都是非常有意思的课题。还有,可以看看C++98中由于语言设计问题会带来程序员容易犯的哪些错误——这正是本书的主题——而在新的语言标准中,这些问题是如何通过语言设计的改进而解决的,甚至不妨问一问自己,如果手头只有 C 编译器又该怎么办。只有掌握了这样全面的信息,才能说学活了,才能在无论有没有先进语言设计支持的前提下都能够采用适当方式解决问题,才能说掌握了正确的思维。
译者也幸。由于种种原因,上一版的图书中,本人意欲加入的译者注十去其七。本以为已无再版可能,全系培生教育集团版权经理李乐强先生鼓励,又承人民邮电出版社信息分社刘涛社长支持,我将尘封有年的旧译稿又翻出来仔细从头到尾审改一次,并将旧译中的加注选了相当部分还原出来。但求能达到初心里让读者能够通过读这一本书,能参考到十几二十本书的相关内容对照阅读、全面深刻地理解书中通过病理学方式讲述的 C++语言的重要知识点之目标。如果能实现这一点,也是满足了我个人的一点小小心愿。成书过程中,承淘宝高级经理林应、百度高级工程师徐章宁、刘海平和林向东、华为社区运营总监林旅强、Ucloud CEO季昕华、亮风台合伙人唐荣兴、Ping++市场总监冯飞等费心审阅稿件并受益于他们的反馈良多,在此一并致谢。当然限于本人才疏,缺点错误在所难免,此概应由我本人一体负责。家人在我译书过程中体谅照顾甚多,希望本书的出版,能给你们带来快乐。
2016年3月
草于上海交通大学软件学院
经过近一年的工作,这本近四百页的小册子终于和大家见面了。
这本书从一个读者的角度来看,当然主要地可以视为是对于当之无愧的C++大师Stephen C.Dewhurst在近十五年前原创的一本技术书籍的译作。但如果从译者的本意出发,它未尝不可以说是我本人十年来学习 C++、领悟C++和运用 C++的一个小结。2005 年起,我开始陆续在论坛中发表一些零碎的技术文章和翻译作品,并在企业和大学里作了一些演讲。和真正的一线工程师,以及即将踏上工程师岗位的同道们作了一些比较深入的交流之后,我才真真切切地感受到他们对于书本知识转化为真正实力的那种热切的渴求。现在每年出版的有关C++的书籍车载斗量,但是如何能把这些“知识”尽可能多地转化成工程师手中对付真正的项目需求的“武器”?我感到自己负有责任来做一些工作,来对这个问题做出自己尝试性的解答。那末,最好的方式是创作一本新书吗?经过再三的权衡,我认为并非如此。作为一个未在C/C++ Users Journal或是Dr.Dobb上发表过任何文字的人,原创很难企及自己欲达成的号召力。并且,原创的话就意味着要自己照顾一切技术细节,我还决没有自大到认为已经有了那种实力的程度。可是,是否仅仅再去翻译一本新的C++著作呢?那也不是。C++近几年来已不比往昔,新著作的翻译效率简直高得惊人,但单纯的翻译工作其实并不能消释读书人的费解。那末,我就想到:为什么不能挑选一本书,一方面将它翻译过来,另一方面以它作为“蓝本”,将自己的见解以笔记的形式融入其文字,并引导读者参读其它的技术书籍呢?对于某一个特定的技术细节,我希望达到的效果是:读者能够从我的翻译这“小小的一隅”延拓开去,从深度而言他们能够参阅其它专门就此发力的技术资料,获得某种技术或习惯用法的历史背景、推导逻辑、常见形式等翔实、全面、准确的信息;从广度而言,他们可以了解到编码与设计、细节与全局的关系,从而做到取舍中见思路、简化中见智慧,真正地把 C++这种优秀的、有着长久生命力的程序设计语言背后的有关软件工程的科学和艺术的成分“提炼”出来,化为自己实实在在的内功提升。这样的工作,我认为才是有它的价值在的,也是我这些年来下苦功夫研读了一二十种C++的高质量书籍,以及使用C++交付了一些成功的工程之后有实力完成的——这就是我创作本书的初衷和原动力——以技术翻译为主体,并进行“笔记体”的再创作予读者以诠释和阅读参考的附加值,这就是我的答案。
不过,选取这样的一本作为“蓝本”的书籍殊非易事。首先,它本身需要有相当的深度和广度,否则难以面面俱到,从而也就难以体现 C++语言在各个层次上的大能。其次,它必须有相当的发散性,否则它就难以和已有的大量资料相结合,难以引导读者去重读他们之间已经看过,但未能充分理解的资料。再次,它还要有明确的主题组织,否则很可能会陷入空谈,使读者感觉难以理解和掌握,从而不能发挥应有的“知识”向“实力”的转化之效。最后,C++ Gotchas落入我的视线,研读数次之后,我觉得它不仅完全符合“蓝本”的一切要求,并且Stephen C.Dewhurst大师还在数个方面给予了我太多的启迪:这本书所有的章节都从一个众所周知的、在日常编码或设计实践经常遭遇的问题入手,先是就事论事地指出其不足,再是对其背后思想中存在何种合理与不合理之处深入剖析,最后取之精华弃之糟粕,给出一个简洁、通用、美轮美奂的方案。有的条款中,大师会给出数种不同的解决之道,并一一评点其优劣之处,指出其适用场合;有的条款中,大师步步推进,先是给出一个去除错误的解,再进一步地优化它,直至与某种习惯用法和设计模式接壤作为点题之笔。从翻译的过程中,我自己真的是受益良多,希望我的读者能够收获更大。
在本书的翻译中,清华大学出版社的龙启铭编辑给予了我很大的帮助和鼓励,并促成这本书最终完稿。微软亚洲研究院的徐宁研究员和 EMC 中国的柴可夫工程师通读了全书,并给予了全面的审阅意见,包括不少技术和文字的问题,在此向他们深深致谢。另外,Hewlett-Packard总部的Craig Hilderbrandt经理、上海交通大学计算机系的张尧弼教授、Phoenix中国的唐文蔚高级工程师、谷歌中国的龚理工程师、微软亚洲工程院的魏波工程师、微软全球技术中心的陈曦工程师和 SAP 中国的劳佳工程师也都在本书写作的过程中给了我不小的帮助,在此一并致谢。当然,书中的错误和纰漏在所难免,这些理应由我本人负全部责任。另外要感谢的还有我的家人和同事们,没有你们的支持,我不可能坚持到底。希望本书的出版能够给你们带来快乐。
高博
2008年11月
于微软亚洲工程院上海分院
本书之渊薮乃是近20年的小小挫折、大错特错、不眠之夜和在键盘的敲击中不觉而过的无数周末。里面收集了普遍的、严重的或有意思的C++常见错误,共计九十有九。其中的大多数,(实在惭愧地说)都是我个人曾经犯过的。
术语“gotcha”[1]有其云谲波诡的形成历史和汗牛充栋的不同定义。但在本书中,我们将它定义为 C++范畴里既普遍存在又能加以防范的编码和设计问题。这些常见错误涵盖了从无关大局的语法困扰,到基础层面上的设计瑕疵,再到源自内心的离经叛道等诸方面。
大约10年前,我开始在我教授的C++课程的相关材料中添加个别常见错误的心得笔记。我的感觉是,指出这些普遍存在的误解和误用,配合以正确的用法指导就像给学生打了预防针,让他们自觉地与这些错误作斗争,更可以帮助新入门的 C++软件工程师避免重蹈他们前辈的覆辙。大体而言,这种方法行之有效。我也深受鼓舞,于是又收集了一些互相关联的常见错误的集合,在会议上作演讲用。未想这些演讲大受欢迎(或是同病相怜之故也未可知?),于是就有人鼓励我写一本“常见错误之书”。
任何有关规避或修复 C++常见错误的讨论都涉及了其他的议题,最多见的是设计模式、习惯用法以及C++语言特征的技术细节。
这并非一本讲设计模式的书,但我们经常在规避或修复 C++常见错误时发现设计模式是如此管用的方法。习惯上,设计模式的名字我们把每个单词的首字母大写,比如模板方法(Template Method)设计模式或桥接(Bridge)设计模式。当我们提及一种设计模式的时候,若它不是很复杂,则简介其工作机制,而详细的讨论则放在它们和实际代码相结合的时候才进行。除非特别说明,本书不提供设计模式的完全描述或极为详尽的讨论,这些内容可以参考 Erich Gamma 等人编写的 Design Patterns 一书。无环访问者(Acyclic Visitor)、单态(Monostate)和空件(Null Object)等设计模式的描述请参见Robert Martin编写的Agile Software Development一书。
从常见错误的视角来看,设计模式有两个可贵的特质。首先,它们描述了已经被验证成功的设计技术,这些技术在特定的软件环境中可以采用自定义的手法搞出很多新的设计花样。其次,或许更重要的是,提及设计模式的应用,对于文档的贡献不仅在于使运用的技术一目了然,同时也使应用设计模式的原因和效果一清二楚。
举例来说,当我们看到在一个设计里应用了桥接设计模式时,我们就知道在一个机制层里,一个抽象数据型别的实现并分解成了一个接口类和一个实现类。犹有进者,我们也知道了这样做是为了强有力地把接口部分同底层实现剥离,是故底层实现的改变将不会影响到接口的用户。我们不仅知道这种剥离会带来运行时的开销,还知道此抽象数据型别的源代码应该怎么安排,并知道很多其他细节。
一个设计模式的名字是关于某种技术极为丰富的信息和经验之高效、无疑义的代号。在设计和撰写文档时仔细而精确地运用设计模式及其术语会使代码洗练,也会阻止常见错误的发生。
C++是一门复杂的软件开发语言,而一种语言愈是复杂,习惯用法在软件开发中之运用就愈是重要。对一种软件开发语言来说,习惯用法就是常用的、由低阶语言特征构成的高阶语言结构的特定用法组合。总的来说,这和设计模式与高阶设计的关系差不多。是故,在C++ 语言里,我们可以直接讨论复制操作、函数对象、智能指针以及抛出异常等概念,而不需要一一指出它们在语言层面上的最低阶实现细节。
有一点要特别强调一下,那就是习惯用法并不仅仅是一堆语言特征的常见组合,它更是一组对此种特征组合之行为的期望。复制操作是什么意思呢?当异常被抛出的时候,我们能指望发生什么呢?大多数本书中的建议都是在提请注意以及建议应用 C++编码和设计中的习惯用法。很多这里列举的常见错误常常可以直接视作对某种 C++习惯用法的背离,而这些常见错误对应的解决方案则常常可以直接视作对某种 C++习惯用法的皈依(参见常见错误10)。
本书在 C++语言的犄角旮旯里普遍被误解的部分着了重墨,因为这些语言材料也是常见错误的始作俑者。这些材料中的某些部分可能让人有武林秘笈的感觉,但如果不熟悉它们,就是自找麻烦,在通往 C++语言专家的阳关大道上也会平添障碍。这些语言死角本身研究起来就是其乐无穷,而且产出颇丰。它们被引入 C++语言总有其来头,专业的 C++软件工程师经常有机会在进行高阶的软件开发和设计时用到它们。
另一个把常见错误和设计模式联系起来的东西是,描述相对平凡的实例对于两者来说是差不多同等重要的。平凡的设计模式是重要的。在某些方面,它们也许比在技术方面更艰深的设计模式更为重要,因为平凡的设计模式更有可能被普遍应用。所以从对平凡设计模式的描述中获得的收益就会以杠杆方式造福更大范围的代码和设计。
差不多以完全相同的方式,本书中描述的常见错误涵盖了很宽范围内的技术困难,从如何成为一个负责的专业软件工程师的循循善诱(常见错误12)到避免误解虚拟继承下的支配原则的苦口良言(常见错误79)。不过,就与设计模式类似的情况看,表现得负责专业当然比懂得什么支配原则要对日复一日的软件开发工作来得受用。
本书有两个指导思想。第一个是有关习惯用法的极端重要性。这对于像C++这样的复杂语言来说尤为重要。对业已形成的习惯用法的严格遵守使我们能够既高效又准确地和同行交流。第二个是对“其他人迟早会来维护我们写的代码”这件事保持清醒头脑。这种维护可能是直截了当的,所以这就要求我们把代码写得很洗练,以使那些称职的维护工程师一望即知;这种维护也可能是拐了好几道弯的,在那种情况下我们就得保证即使远在天边的某个变化影响了代码的行为,它仍然能够给出正确的结果。
本书中的常见错误以一组小的论说文章的形式呈现,其中每一组都讨论了一个常见错误或一些相互关联的常见错误,以及有关如何规避或纠正它们的建议。由于常见错误这个主题内廪的无政府倾向,我不敢说哪本书可以特别集中有序地讨论它。然而,在本书中,所有的常见错误都按照其错误本质或应用(误用)所涉的领域归类到相应的章节。
还有,对一个常见错误的讨论无可避免地会牵涉到其他的常见错误。当这种关联有它的意义时——通常确实是有的——我会显式地作出链接标记。其实,这种每个常见错误的为了增强关联性的描述本身也是有其讨厌之处的。比方说经常遇到一种情况就是还没来得及描述一个常见错误自身,倒先把为什么会犯这个错误的前因后果交代了一大篇。要说清这些个前因后果呢,好家伙,又非得扯上某种技术啦、习惯用法啦、设计模式啦或是语言细节什么的,结果在言归正传之前要兜更大的圈子。我已经尽力把这种发散式的跑题减到最少了,但要是说完全消除了这种现象,那我就没说实话。要把 C++程序设计做到很高效的境界,那就得在非常多水火不容的方面作出如履薄冰的协调,想在研究大量相似的主题前就对语言作出像样的病理学分析,那只能说是不现实的。
把这本书从第1个常见错误到第99个常见错误这么挨个地读下去,不仅毫无必要,而且也谈不上明智。一气儿服下这么一帖虎狼之剂恐怕会让你一辈子再也学不成 C++了。比较好的阅读方法应该是拣一条你不巧犯过的,或是你看上去有点儿意思的常见错误开始看,再沿着里面的链接看一些相关的。另一种办法就是你干脆由着性子,想看哪儿看哪儿,也行。
本书也使用了一些固定格式来阐明内容。首先,错误的和不提倡的代码以灰色背景来提示,而正确和适当的代码却没有任何背景。其次,这里作示意用的代码为了简洁和突出重点,都经过了编辑。这么做的一个结果是,这里示例用的代码若是没有额外的支撑代码往往不能单独通过编译。那些并非平凡无用的示例源代码则可以在作者的网站里找到:www.semantics.org。所有这样的代码都由一个相对路径引出,像这样:
gotcha00/somecode.cpp
最后,提个忠告:你不要把常见错误的重要性提升到和习惯用法、设计模式一样[2]。一个你已经学会正确地使用习惯用法和设计模式的标志是,当某个习惯用法或是设计模式正好是你手头的设计或编码对症良方时,它就“神不知鬼不觉地”在你最需要时从你的脑海里浮现出来了。
对常见错误的清醒意识就好比是对危险的条件反射:一回错,二回过。就像对待火柴和枪械一样,你不必非得烧伤或是走火打中了脑袋才学乖。总之,只要加强戒备就行了。把我这本手册当作是你面对 C++常见错误时自我保护的武器吧!
Stephen C.Dewhurst
于美国马萨诸塞州卡佛市
2002年7月
[1].译者注:gotcha在本书中通译为“常见错误”,固然较之原文失之神韵,倒也算得通俗易懂。
[2].译者注:作者用心良苦,怕读者“近墨者黑”,好的没记住反而坏的学会了。所以特意提醒所有读者,常见错误有些奇技淫巧,但毕竟难登大雅之堂。
编辑们经常在图书的“致谢”里落得个坐冷板凳的下场,有时甚至用一句“……其实我也挺感谢我那编辑的,我估计在我拼了命爬格子的时候此人大概肯定也是出过一点什么力的吧”就打发了。Debbie Lafferty,也就是本人的编辑,负责本书的问世。有一次,我拿着一本不足为道的介绍性的程序设计教材去找她搞个不足为道的合作提案,结果她反而建议我把其中一个有关常见错误的章节扩展成一本书。我不肯。她坚持。她赢了。值得庆幸的是,Debbie 在胜利面前表现得特别有风度,只是淡淡了说了一句站在编辑立场上的“你瞧,我叫你写的吧。”当然不止于此,在我拼了命爬格子的时候,她是颇出了一些力的。
我也感谢那些无私奉献了他们的时间和专业技能来使本书变得更好的审阅者们。审阅一本未经推敲的稿件是相当费时的,常常也是枯燥乏味的,有时甚至会气不打一处来,而且几乎肯定是讨不着什么好的(参见常见错误12),这里要特别赞美一下我的审阅者们入木三分而又深中肯綮的修改意见。Steve Clamage、Thomas Gschwind、Brian Kernighan、Patrick McKillen、Jeffrey Oldham、Dan Saks、Matthew Wilson和Leor Zolman对书中的技术问题、行业规矩、誊对校样、代码片段和偶然出现的冷嘲热讽都提出了自己的宝贵意见。
Leor在稿件出来之前很久就开始了对本书的“审阅”,书中一些常见错误的原始版本只是我在互联网论坛里发的一些帖子,他针对这些帖子回复了不少逆耳忠言。Sarah Hewins是我最好的朋友,同时也是最不留情的批评家,不过这两个头衔都是在审阅我一改再改的稿件时获称的。David R.Dewhurst在写作项目进行的时候经常把我拉回正轨。Greg Comeau慷慨地让我有幸使用他堪称一流的标准C++编译器来校验书里的代码[1]。
就像所有关于 C++的任何有意义的工作那样,本书也是集体智慧的结晶。这些年来,我的很多学生、客户和同事为我在 C++常见错误面前表现的呆头呆脑和失足跌跤可没少数落过我,并且他们中的好多人都帮我找到了问题的解决之道。当然,这些特别可贵的贡献者中的大部分都没法在这里一一谢过,不过有些提供了直接贡献的人还是可以列举如下的。
常见错误11中的Select模板和常见错误70中的OpNewCreator策略都取自Andrei Alexandrescu编写的Modern C++Design一书。
我在常见错误 44 中描述了有关返回一个常量形参的引用带来的问题[2],此问题我初见于Cline等人编写的C++FA Q s一书(我客户的代码中在此之后马上就用上了这个解决方案)。此书还描述了我在常见错误 73 中提到的用于规避重载虚函数的技术。
常见错误83中的那个Cptr模板,其实是Nicolai Josuttis编写的The C++Standard Library一书中CountedPtr模板的一个变形。
Scott Meyers在他的More Effective C++一书中,对运算符&&、||和,,的重载之不恰当性提出了比我在常见错误 14 的描述更深入的见解。他也在他的Effective C++一书中,对我在常见错误58中讨论的二元运算符以值形式返回的必要性作了更细节的描述,还在Effective STL一书中描述了我在常见错误68里说的对auto_ptr的误用。在后置自增、自减运算符中返回常量值的技术,也在他的More Effective C++一书中提到了。
Dan Saks对我在常见错误8中描述的前置声明文件技术提出了最有说服力的论据,他也是区别出常见错误17中提及的“中士运算符”的第一人,他也说服了我在enum型别的自增和自减中不去做区间校验,这一点被我写在了常见错误87中。
Herb Sutter的More Exceptional C++一书中的条款36促使我去重读了C++标准8.5节,然后修正了我对形参初始化的理解(见常见错误57)。
常见错误10、27、32、33、38~41、70、72~74、89、90、98和 99 中的一些材料出自我先是在C++ Report,后来在The C/C++ Users Journal撰写的Common Knowledge专栏。
[1].译者注:这应该就是著名的Comeau C/C++ Front/End编译器。
[2].译者注:那是个有关临时对象生存时域的问题。
说一个问题是基础的,并不就是说它不是严重的或不是普遍存在的。事实上,本章所讨论的基础问题的共同特点比起在以后章节讨论的技术复杂度而言,可能更侧重于使人警醒。这里讨论的问题,由于它们的基础性,在某种程度上可以说它们普遍存在于几乎所有的C++代码中。
很多注释都是画蛇添足,它们只会让源代码更难读,更难维护,并经常把维护工程师引入歧途。考虑下面的简单语句:
a = b; // 将b赋值给a
这个注释难道比代码本身更能说明这个语句的意义吗?因而它是完全无用的。事实上,它比完全无用还要坏。它是害人精。首先,这条注释转移了代码阅读者的注意力,增加了阅读量因而使代码更费解。其次,要维护的东西更多了,因为注释也是要随着它描述的代码的更改而更改的。最后,这个对注释的更改常常会被遗忘 [1]:
c = b; // 将b赋值给a
仔细的维护工程师不会武断地说注释是错的[2],所以他就被迫要去检视整个程序以确定到底是注释错了呢,还是有意为之呢(c可能是a的引用),还是本来正确只是比较间接的呢(赋值给c可能引发一些传播效应以使a的值也发生相应变化),等等,总之这一行就根本不应该带注释。
a = b;
还是这代码本来的样子最清楚地表明了其意义,也没有额外的注释需要维护。这在精神上也符合老生常谈,亦即“最有效率的代码就是根本不存在的代码”。这条经验对于注释也适用:最好的注释就是根本用不着写的注释,因为要注释的代码已经“自注释”了。
另一些常见的非必要的注释的例子经常可以在型别的定义里见到,它们要么是病态的编码标准的怪胎,要么就是出自C++新手:
class C {
// 公开接口
public:
C(); // 默认构造函数
~C(); // 析构函数
// ...
};
你会觉得别人在挑战你的智商。要是某个维护工程师连“public:”是什么意思都需要教,你还敢让他碰你的代码吗?对于任何有经验的 C++软件工程师而言,这些注释除了给代码添乱、增加需要维护的文本数量以外没有任何用处:
class C {
// 公开接口
protected:
C( int ); // 默认构造函数
public:
virtual~C(); // 析构函数
// ...
};
软件工程师还有一种强烈的心理趋势就是尽量不要“平白无故”地在源文件文本中多写哪怕一行。这里公布一个有趣的本行业秘密:如果某种结构(函数啦、型别的公开接口啦什么的)能被塞在一“页”里,也就在三四十行左右 [3]的话,它就很容易理解。假如有些内容跑到第二页去了,它理解起来就难了一倍。如果三页才塞得下,据估计理解难度就成原来的4倍了 [4]。一种特别声名狼藉的编码实践就是把更改日志作为注释插入到源文件的头部或尾部:
/* 6/17/02 SCD把一个该死的bug干掉了 */
这到底是有用的信息,抑或是仅仅是维护工程师的自吹自擂?在这行注释被写下以后的一两个星期,它怎么看也不再像是有用的了,但它却也许要在代码里粘上很多年,欺骗着一批又一批的维护工程师。最好是用你的版本控制软件来做这种无用注释真正想做的事,C++的源代码文件里可没有闲地方来放这些劳什子。
想不用注释却又要使代码意义明确、容易维护的最好办法就是遵循简单易行的、定义良好的命名习惯来为你使用的实体(函数、型别、变量等)取个清晰的、反映其抽象含义的名字。(函数)声明中形参的名字尤其重要。考虑一个带有3个同一型别引数的函数:
/*
从源到目的执行一个动作
第一个引数是动作编码(action code),第二个引数是源(source),第三个引数是目的(destination)
*/
void perform( int,int,int );
这也不算太坏吧,不过如果引数是七八个而不是3个你又该写多少东西呢?我们明明可以做得更好:
void perform( int actionCode,int source,int destination); [5]
这就好多了。按理,我们还需要写一行注释来说明这个函数的用途(而不是如何实现的)。形参的一个最引人入胜之处就是,不像注释,它们是随着余下的代码一起更改的,即使改了也不影响代码的意义。话虽然这么说,但我不能想像任何一个软件工程师在引数意义改变了的时候,会不给它取个新名字 [6]。但我能举出一串软件工程师来,他们改了代码但老是忘记维护注释。
Kathy Stark在Programming in C++中说得好:“如果在程序里用意义明确、脱口而出的名字,那么注释只是偶尔才需要。如果不用意义明确的名字,即使加上了注释也不能让代码更好懂一些。”
另一种最大程度地减少注释书写的办法是采用标准库中的或人尽皆知的组件:
printf( "Hello,World!" ); // 在屏幕上打印“Hello,World”
上面这个注释不但是无用的,而且只在部分情况下正确。标准库组件不仅是“自注释”的,并且有关它们的文档汗牛充栋,有口皆碑。
swap( a,a+1 );
sort( a,a+max );
copy( a,a+max,ostream_iterator<T>(cout,"\n") );
因为swap、sort和copy都是标准库组件,对它们加上任何注释都是成心添乱,而且给定义得好好的标准操作规格描述带来了(非必要的)不确定性。注释之害并非与生俱来。注释常常必不可少。但注释必须(和代码一起)维护。维护注释常常比维护它们注解的代码要难。注释不应该描述显而易见的事,或把在别的地方已经说清楚的东西再聒噪一遍。我们的目标不是要消灭注释,而是在代码容易理解和维护的前提下,尽可能少写注释。
class Portfolio {
幻数,用在这里时其含义是上下文里出现的裸字面常量(raw numeric literal),本来它们应该是具名常量(named constant)才对:
// ...
class Portfolio {
// ...
Contract *contracts_[10];
char id_[10];
};
幻数带来的主要问题是它们没有(抽象)语义,它们只是个量罢了。一个“10”就是一个“10”,你看不出它的意思是“合同的数量”或是“标识符的长度”。这就是为什么当我们阅读和维护带有幻数的代码时,不得不一个个地去搞清楚每个光秃秃的量到底代表的是什么意思。没错,这样也能勉强度日,但带来的是不必要的精力浪费以及准确性的牺牲。
就拿上面这个设计得很差的表示公文包(Portfolio)的型别来说,它能够管理最多10个合同。当合同数愈来愈多的时候(10个不够用了),我们决定把合同数增加至32个(如果你对安全性和正确性很挑剔,那最好是改用STL中的 vector组件)。我们立刻陷入了困境,因为必须一个个去检查那些用了Portfolio型别的源文件里出现的每一个字面常量“10”,并逐个甄别每个“10”是不是代表“最多合同数”这个意思。
};
实际情况可能会更糟。在一些很大的、长期的项目里,有时“最多合同数是 10”这件事成了临时的军规,这个(远非合理的)知识被硬编码在某些根本没有包含Portfolio型别头文件的代码中:
for( int i = 0; i < 10; ++i )
// ...
上面这个“10”是代表“最大合同数”的意思呢?还是“标识符的最大长度”?抑或是毫不相干的其他意思?
一堆臭味相投的字面常量要是不巧凑在了一块儿,史上最有碍观瞻的代码就这么诞生了:
if( Portfolio *p = getPortfolio() )
for( int i = 0; i < 10; ++i )
p->contracts_[i] = 0,p->id_[i] = '\0';
现在维护工程师可有事做了。他们不得不在Portfolio型别中出现的毫不相关的、但正好值相同的两个“10”之间费劲地识别出它们各自的意思并分别处理 [7]。当这一切头疼的事有着极为简单的解决方案时,我们真的没有理由不去做:
enum { maxContracts = 10,idlen = 10 };
Contract *contracts_[maxContracts];
char id_[idlen];
在其所在辖域有着明确含义的枚举常量同时还有着不占空间,也没有任何运行期成本的巨大优点。
幻数的一个不那么显而易见的坏处是它会以意想不到的方式降低它所代表的型别的精度,它们也不占有相应的存储空间[8]。拿字面常量40000来说,它的实际型别是平台相关的。如果int型别尺寸的内存能把它塞下,它就是int型别的。要是塞不下呢,它就成了long型别的。要是我们不想在平台移植的当口引狼入室(试想根据型别进行的函数重载解析规则在这里能把我们逼疯的情形),我们还是老老实实地自己指定型别吧,这比让编译器或平台替我们做这件事要好得远:
const long patienceLimit = 40000;
另一个字面常量带来的潜在威胁来源于它们没有地址这件事。好吧,就算这不是个会天天发生的问题,但是有的时候将引用绑定到常量是有其作用的。
const long *p1 = &40000; // 错误![9]
const long *p2 = &patienceLimit; // 没问题
const long &r1 = 40000; // 合法,不过常见错误44会告诉你另一些精彩故事
const long &r2 = patienceLimit; // 没问题幻数有百害而无一利。
请使用枚举常量或初始化了的具名常量。
很难找到任何理由去硬生生地声明什么全局变量。全局变量阻碍了代码重用,而且使代码变得更难维护。它们阻碍重用是因为任何使用了全局变量的代码就立刻与之耦合,这使得全局变量一改它们也非得跟着改,从而使任何重用都不可能了。它们使代码变得更难维护的原因是很难甄别出哪些代码用了某个特定的全局变量,因为任何代码都有访问它们的权限。
全局变量增加了模块间的耦合,因为它们往往作为幼稚的模块间消息传递机制的设施存在。就算它们能担此重任,从实践角度来说 [10],要从大型软件的源代码中去掉任何全局变量都几乎不可能 [11]。这还是说他们能正常工作的情况。不过可不要忘了,全局变量是不设防的。随便哪个维护你代码的C++新手都能让你对全局变量有强烈依赖的软件所玩的把戏随时坍台。
全局变量的辩护者们经常拿它的“方便”来说事。这真是自私自利之徒的无耻之争。要知道,软件的维护常常比它的初次开发要花费更多时间,而使用全局变量就意味着把烂摊子扔给了维护工程师。假设我们有一个系统,它有一个全局可访问的“环境”,并且我们按需求保证确实只有“一个”。
不幸的是,我们选择了使用全局变量来表示它:
extern Environment * const theEnv;
我们的需求一时如此,但马上就行不通了。在软件就要交付之前,我们会发现,可能同时存在的环境要增加到两个、三个或是在系统启动时指定的或根本就是完全动态的某个数。这种在软件发布的最后时刻发生的变更实属家常便饭。在备有无微不至的源代码控制过程的大项目里,这个变更会引发极费时间、涉及所有源文件的更改,即使在最细小的和最直截了当的那些地方也不例外。整个过程预计要几天到几星期不等。假如我们不用全局变量这个灾星,只要5分钟我们就能搞定这一切:
Environment *theEnv();
仅仅是把对于值的访问加了函数的包装,我们就获得了可贵的可扩充性。要是再加上函数重载,或是给予函数形参以默认值,我们就根本不要怎么改源代码了:
Environment *theEnv( EnvCode whichEnv = OFFICIAL );
另一个全局变量引起的问题并不能一望即知。此问题的来源是全局变量经常要求延迟到运行期才进行的静态初始化。C++语言里如果静态变量用来初始化的值不能在编译期就计算妥当,那么这个初始化的动作就会被拖到运行期。这是许多致命后果的始作俑者(此问题非常重要,常见错误55专门来讨论此问题):
extern Environment * const theEnv = new OfficialEnv;
如果改用函数或 class来充当访问全局信息的掮客,初始化动作就会被延后,从而也就变得安全无虞了:
gotcha03/environment.h
class Environment {
public:
static Environment &instance();
virtual void op1() = 0;
// ...
protected:
Environment();
virtual~Environment();
private:
static Environment *instance_;
// ...
};
gotcha03/environment.cpp
// ...
Environment *Environment::instance_ = 0;
Environment &Environment::instance() {
if( !instance_ )
instance_ = new OfficialEnv;
return *instance_;
}
在上述例子中,我们采用了称为单件设计模式(Singleton Pattern)的一个简单实现 [12],以所谓“缓式求值”形式完成静态指针的初始化动作(如果一定要在技术上钻牛角尖的话,好吧,这是赋值,不是初始化)。是故,我们能够保证Environment对象的数量不会超过一个。请注意,Environment型别没有给予其构造函数public访问层级,所以Environment型别的用户只能用它公开出来的instance()成员函数来取得这个静态指针。而且,我们不必在第一次访问Environment对象之前就创建它 [13]:
Environment::instance().op1();
更重要的是,这种受控的访问为使用了单件设计模式的型别适应未来的变化带来了灵活性,并且消除了对现有代码的影响。以后当我们要切换到多线程的环境,或是要改成允许一种以上的环境并存的设计,或是随便要求怎么变时,我们都可以通过更改使用了单件设计模式之型别的实现来搞定这一切,而这就像我们先前更改包装全局变量的那个函数一样随心所欲。
函数重载和形参默认值之间其实并无干系。不过,这两个独立的语言特征有时会被混淆,因为它们会模塑出语法上非常相像的函数用法接口。当然,看似一样的接口其背后的抽象意义却大相径庭:
gotcha04/c12.h
class C1 {
public:
void f1( int arg = 0 );
// ...
};
gotcha04/c12.cpp
// ...
C1 a;
a.f1(0);
a.f1();
型别C1的设计者决定给予函数f1()一个形参的默认值。这样一来,C1的使用者就有了两个选择:要么显式地给函数f1()一个实参,要么通过不指定任何实参的方式隐式地给函数f1()一个实参 0。所以,上述两个函数调用产生的动作序列 [14]是完全相同的。
gotcha04/c12.h
class C2 {
public:
void f2();
void f2( int );
// ...
};
gotcha04/c12.cpp
// ...
C2 a;
a.f2(0);
a.f2();
型别C2的实现则有很大不同。其使用者的选择是根据给予的实参数目调用两个虽然名字都叫f2(),却是完全不同的函数中的某一个。在我们早先那个C1型别的例子里,两个函数调用产生的动作序列是完全相同的,但在这个例子里它们产生的却是完全不同的动作序列了。这是因为两个函数调用的结果是调用了不同的函数。
通过对成员函数C1::f1()和C2:f2()取址,我们就拿到了有关这两种接口之间最本质的不同点的直接证据:
gotcha04/c12.cpp
void (C1::*pmf)() = &C1::f1; //错误!
void (C2::*pmf)() = &C2::f2;
我们实现C2 型别的方法决定了指涉到成员函数的指针pmf指涉到了没有带任何形参的那个f2()函数。因为pmf是个指涉到没有带任何形参的成员函数的指针,编译器能够正确地选择第一个f2()作为它应该指涉到的函数。而对于C1型别来说,我们将收到编译期错误,因为唯一的名叫f1()的成员函数带有一个int型别的形参 [15]。
函数重载主要用于一组抽象意义相同、但实现不同的函数。而形参默认值主要出于简化考量,为函数提供更简洁的接口[16]。函数重载和形参默认值是两个毫不相干的语言特征,它们是出于不同的目的而设计,行为也完全不同。请仔细地区分它们(更详细的信息请参见常见错误73和74)[17]。
对于引用的使用,主要存在两个常见的问题。首先,它们经常和指针搞混。其次,它们未被充分利用。好多在 C++工程里使用的指针实际上只是 C阵营那些老顽固的杰作,该是引用翻身的时候了。
引用并非指针。引用只是其初始化物的别名。记好了,能对引用做的唯一操作就是初始化它。一旦初始化结束,引用就是其初始化物的另一种写法罢了(凡事皆有例外,请看常见错误44)。引用是没有地址的,甚至它们有可能不占任何存储:
int a = 12;
int &ra = a;
int *ip = &ra; // ip指涉到a的地址
a = 42; // ra的值现在也成42了
由于这个原因(引用没有地址),声明引用的引用、指涉到引用的指针或引用的数组都是不合法的(尽管 C++标准委员会已经在讨论至少在某些上下文环境里允许引用的引用)。
int &&rri = ra; // 错误!
int &*pri; // 错误!
int &ar[3]; // 错误!
引用不可能带有常量性或挥发性,因为别名不能带有常量性或挥发性。尽管引用可以是某个带有常量性或挥发性的实体的引用。如果用关键字const或volatile来修饰引用,就会收到一个编译期错误:
int &const cri = a; // 错误!
const int &rci = a; // 没问题
不过,比较诡异的是,如果把 const或 volatile饰词加在引用型别上面,并不会被C++语言判定为非法。编译器不会为此报错,而是简单地忽略这些饰词:
typedef int *PI;
typedef int &RI;
const PI p = 0; // p是常量指针
const RI r = a; // 没有常量引用,r就是个平凡的引用没有空引用,也没有型别为void的引用。
C *p = 0; // p是空指针
C &rC = *p; // 把引用绑定到空指针上,其结果未有定义
extern void &rv; // 试图声明型别为void的引用会引起编译期错误
引用就是其不可变更的初始化物的别名,既然是别名,总得是“某个东西”的别名,这“某个东西”一定要实际存在才成。
不管怎样你都要记住,我可没说引用只能是简单变量名的别名。其实,任何能作为左值的(如果你不清楚什么是左值,请看常见错误6)复杂表达式都能作为引用的初始化物:
int &el = array[n-6][m-2];
el = el*n-3;
string &name = p->info[n].name;
if( name == "Joe" )
process( name );
如果函数的返回值具有引用型别,这就意味着可以对该函数的返回值重新赋值。一个经常被津津乐道的典型例子是表示数组之抽象数据型别的索引函数(index function)[18]:
gotcha05/array.h
template <typename T,int n>
class Array {
public:
T &operator [](int i)
{ return a_[i]; }
const T &operator [](int i) const
{ return a_[i];}
// ...
private:
T a_[n];
};
那个引用返回值能使对数组元素的赋值在语法上颇为自然了:
Array<int,12>ia;
ia[3] = ia[0];
引用的另一个用途,就是可以让函数在其返回值之外多传递几个值:
Name *lookup( const string &id,Failure &reason );
// ...
string ident;
// ...
Failure reasonForFailure;
if( Name *n = lookup( ident,reasonForFailure ) ) {
// 查找成功则执行的例程
}
else {
// 如果查找失败,那么由reasonForFailure的值返回错误代号
}
在对象身上实施目标型别为引用型别的强制型别转换操作的话,其效果与用非引用的相同型别进行的强制转换有着截然不同的效果:
char *cp = reinterpret_cast<char *>(a);
reinterpret_cast<char *&>(a) = cp;
在上述代码的第一行里,我们对一个int型变量实施了到指针型别强制型别转换(我们在这里使用了reinterpret_cast运算符,这好过使用形如“(char *) a”的旧式强制型别转换操作。要想知道这是出于何种考量,请看常见错误40)。这个操作的详细情况分解如下:一个int型变量的值被存储于一个副本中,并随即被按位当作指针型别来解释 [19]。
而第二个强制型别转换操作则是完全另一番景象。转换成引用型别的强制型别转换操作的意义是把int型变量本身解释成指针型别,成为左值的是这个变量本身[20],我们继而可以对它赋值。也许这个操作会引发一次核心转储(dump core,俗称“吐核”,也就是操作系统级的崩溃),不过那不是我们现在谈论的主题,再说,使用reinterpret_cast本身也就暗示着该操作没把可移植性纳入考量。和上述形式差不多的、没有转换成引用型别的强制型别转换操作的一次赋值尝试则会无可挽回地失败,因为这样的强制型别转换操作的结果是右值而不是左值 [21]。
reinterpret_cast<char *>(a) = 0; // 错误!
指涉到数组的引用保留了数组尺寸,而指针则不保留。
int ary[12];
int *pary = ary; // pary指涉到数组ary的第一个元素
int (&rary)[12] = ary; // rary是整个数组ary的引用
int ary2[3][4];int (*pary2)[4] = ary2; // pary2指涉到数组ary2的第一个元素
int (&rary2)[3][4] = ary2; // rary2是整个数组ary2的引用
引用的这个性质有时在数组作为实参被传递给函数时有用(欲知详情,请看常见错误34)。
同样可以声明函数的引用:
int f( double );
int (* const pf)(double) = f; // pf是指涉到函数f()的常量指针
int (&rf)(double) = f; // rf是函数f()的引用
指涉到函数的常量指针和函数的引用从编码实践角度来看,并无很大不同。除了一点,那就是指针可以显式地使用提领语法,而对引用是不能使用显式提领语法的,除非它被隐式转换成指涉到函数的指针 [22]。
a = pf( 12.3 ); // 直接用函数指针名调用函数
a = (*pf)(12.3); // 使用提领语法也是可以的
a = rf( 12.3 ); // 通过引用调用函数
a = f( 12.3 ); // 直接调用函数本身
a = (*rf)(12.3); // 把引用(隐式)转换成指涉到函数的指针,再使用提领语法
a = (*f)(12.3); // 把函数本身(隐式)转换成指涉到函数的指针,再使用提领语法
请注意区别引用和指针。
在C++中的常量性概念是平凡的,但是这和我们对 const先入为主的理解不太符合。首先我们要特别注意以 const饰词修饰的变量声明和字面常量的区别:
int i = 12;
const int ci = 12;
字面常量12不是C++概念中的常量。它是个字面常量。字面常量没有地址,永远不可能改变其值。i 是个对象,有自己的地址,其值可变。用const关键字修饰声明的ci也是个对象,有自己的地址,尽管在本例中其值不可变。
我们说i和ci可以作为左值使用,而字面常量12却只能作为右值。这两个术语来源于伪表达式L=R,说明只有左值能出现在赋值表达式左侧,右值则只能出现在赋值表达式右侧。但这种定义对C++和标准C来说并不成立,因为在本例中ci是左值,但不能被赋值,因为它是个不可修改的左值。
如果把左值理解为“能放置值的地方”,那么右值就是没有与之相关的地址的值。
int *ip1 = &12; // 错误!
12 = 13; // 错误!
const int *ip2 = &ci; // 没问题
ci = 13; // 错误!
最好仔细考虑一下上面ip2的声明中出现的const,这个饰词描述了对我们通过ip2对ci的操作的约束,而不是对于ci的一般操作的约束 [23]。如果我们想声明指涉到常量的指针,我们应该这么办:
const int *ip3 = &i;
i = 10; // 没问题,约束的不是i的一般操作而是通过ip3对i的操作
*ip3 = 10; // 错误!
这里我们就有了一个指涉到const int型别的指针,而这个const int型别对象又是一个普通int型别对象的引用。这里的常量性仅仅限制了我们能通过ip3做什么事。这不表明i不会变,只是对它的修改不能通过ip3进行。如果我们再把问题说细一点,请看下面这个把const和volatile结合使用的例子:
extern const volatile time_t clock;
这个const饰词的存在表明我们未被允许(在代码中显式地直接)修改变量clock的值,但是同时存在volatile饰词说明clock的值肯定还是会通过其他途径发生变更 [24]。
大多数C++软件工程师都自信满满地认为自己对所谓C++的“基础语言”,也就是C++继承自C语言的那部分了如指掌。实际情况是,即使经验丰富的C++软件工程师,有时也会对最基础的C/C++语句和运算符的某些妙用一无所知。
逻辑运算符不能算难懂,对吗?但刚入行的 C++软件工程师却总是不能让它们物尽其用。你看到下面的代码时是不是会怒从胆边生?
bool r = false;
if( a < b )
r = true;
正解如下:
bool r = a<b;
gotcha07/bool.cpp
int ctr = 0;
for( int i = 0; i < 8; ++i )
if( options & 1<<(8+i) )
if( ctr++ ) {
cerr << "Too many options selected";
break;
}
何必这样如此费心地逐位比较?你忘记位屏蔽算法了吗?
typedef unsigned short Bits;
inline Bits repeated( Bits b,Bits m )
{ return b & m & (b & m)-1; }
// ...
if( repeated( options,0XFF00 ) )
cerr << "Too many options selected";
咳,现在的年轻人都怎么了,连这点布尔逻辑常识都没能好好掌握。
还有,很多软件工程师都把“如果条件运算符表达式的两个选择结果都是左值,那么这个表达式本身就是个左值”这回事儿抛在脑后了(有关左值的讨论,参见常见错误6)。所以必然有些人就会写出如此代码:
// 版本1
if( a < b )
a = val();
else if( b < c )
b = val();
else
c = val();
// 版本2
a<b ? (a = val()) : b<c ? (b = val()) : (c = val());
而对 C++怀有正确观念的熟手稍加点化,上述代码马上变得短小精悍,简直酷毙了:
// 版本3
(a<b?a:b<c?b:c) = val();
如果你觉得这个貌似武林秘笈的小贴士似乎只是和布尔逻辑毫不相干的花拳绣腿,那么我得提醒你,在 C++代码的很多上下文(比如构造函数的成员初始化列表,或抛出异常时 throw表达式,等等)中,除了表达式别无选择。
另有一点需要特别引起重视,就是在前两个版本中,val这个实体出现了不止一次,而在最后一个版本里,它只出现了一次。要是 val 是个函数的名字,那还好说。如果它是个函数宏,它的多次出现就极有可能带来非预期的副作用(常见错误 26 有更详细的讨论)。这种场合下,使用条件表达式而非if语句就并非可有可无的细节了。说实在的,我也不甚提倡这种结构被普遍使用,但我确实要大声呼吁这种结构要被普遍了解。对于想晋级专家级C++ 软件工程师的人们而言,这种用法必须在它能够大显身手时成为能够想到的工具之一。这也解释了它何以没有从C语言中被去掉而成为C++语言的一部分。
让人惊讶的是,像内建的索引运算符居然也经常被误解。我们都知道数组的名字和指针都能够使用索引运算符。
int ary[12];
int *p = &ary[5];
p[2] = 7;
此内建的索引运算符只是对于某些指针算术和提领运算符的一种简写法。像上面p[2]这个表达式和*(p+2)是完全等价的。从C的年代就一直在摸爬滚打的C++软件工程师都知道索引运算符的操作数可以是负数,所以p[-2]这样的表达式是有合式定义的,它不过就等价于*(p-2),如果你愿意写成*(p+-2)也没问题。不过,似乎不是每个工程师都学好了加法交换律,否则为什么好多C++软件工程师看到下面这个表达式会吃惊不小?
(-2)[p] = 6;
背后的变换极为平凡:p[-2]等价于*(p+-2),后者等价于*(-2+p),而*(-2+p)不就等价于(-2)[p]吗(上式中的圆括号不能省略,因为索引运算符的优先级比单目减法运算符要高)?
这究竟有何值得一提?的确值得一提!首先,此交换律仅适用于内建的索引运算符。所以,当我们看到形如6[p]的表达式时,我们就知道这里的索引运算符是内建的而不是用户自定义的(尽管p可能并不是指针或数组名)。还有,这样的表达式是你在鸡尾酒会上显摆的好谈资。当然了,在你一时冲动地把这种语法用到产品代码中之前,还是先静下心来看看常见错误 11为妙。
大多数C++软件工程师都觉得switch语句是非常基础的。可惜他们不知道它能基础到何种地步。其实switch语句的形式语法就是如此平凡:
switch( expression ) statement
这平凡无奇的语法却能导出出人意料的推论。
典型情况是,switch表达式后面跟着一个语句区块。在这个语句区块里有一系列case标记的语句,然后根据计算决定跳转到这个语句区块的某个语句处执行。C/C++新手遇到的第一块绊脚石就是“直下式”(fallthrough)计算。也就是说和绝大多数语言不同的是,switch语句根据表达式的计算结果把执行点转到相应的case语句以后,它就甩手不管了。接下来执行什么,完全是软件工程师的事:
switch( e ) {
default:
theDefault:
cout << "default" << endl;
// 直下式计算
case 'a':
case 0:
cout << "group 1" << endl;
break;
case max-15:
case Select<(MAX>12),A,B>::Result::value:
cout << "group 2" << endl;
goto theDefault;
}
如果是有意去利用直下式计算的话——更多的人可能是由于疏忽才不小心让直下式计算引起了错误的执行流——我们习惯上要在适当的地方加上注释,提醒将来的维护工程师,我们这里使用直下式计算是有意为之的。不然,维护工程师就会像条件反射一样以为我们是漏掉了 break语句,并给我们添上,这样就错了。
记住,case语句的标签必须是整型常量性的表达式。换句话说,编译器必须能在编译期就算出这些表达式的值来。不过从上面这个例子你也应该能够看出,这些常量性的表达式能够用多么丰富多彩的写法来书写。而switch表达式本身一定是整型,或者有能够转换到整型的其他型别也可以。比如上面这个 e就可以是个带有型别转换运算符的、能够转型到整型的class对象。
同样要记住,switch语句的平凡语法暗示着我们能够把语句区块写成比上面的例子更非结构化的形式。在switch语句里的任何地方都能用case标记,而且不一定要在同一个嵌套层级里:
switch( expr )
default:
if( cond1 ) {
case 1: stmt1;case 2: stmt2;
}
else {
if( cond2 )
case 3:stmt2;
else
case 0: ;
}
这样的代码看起来有点傻(容我直言,确实很傻),但是这种对于基础语言边角部分的理解,有时候相当有用。比如利用上述的 switch语句的性质,就曾在C++编译器中做出了一个复杂数据结构内部迭代的有效实现:
gotcha07/iter.cpp
bool Postorder::next() {
switch( pc )
case START:
while( true )
if( !lchild() ) {
pc = LEAF;
return true;
case LEAF:
while( true )
if( sibling() )
break;
else
if( parent() ) {
pc = INNER;
return true;
case INNER: ;
}
else {
pc = DONE;
case DONE: return false;
}
}
}
在上述代码中,我们使用了switch语句低级的、少见的语义来实现了树遍历操作。
每当我使用上面这样的结构时,我总能收到强烈的、负面的甚至是骂骂咧咧的反应。而且我确实同意这种代码可不适合给维护工程师中的新手来打理,但是这样的结构——尤其是封装良好的、文档化了的版本——确实在对性能要求甚高或非常特殊的编码中有自己的一席之地。一句话,对基础语言难点的熟练掌握会对你大有裨益。
C++语言压根儿没有实现什么数据隐藏,它实现了的是访问层级。在class中具有protected和private访问层级并非不可见,只是不能访问罢了。如同一切可见而不可及的事物一样(经理的形象跃入脑海),他们总是惹出各种麻烦。
最显而易见的问题就是即使是 class的实现仅仅更改了一些貌似不可见的部分,也会带来必须重新编译代码的苦果。考虑一个简单的 class,我们为其添加一个新的数据成员:
class C {
public:
C( int val ) : a_( val ),
b_( a_ ) // 新添加的代码
{}
int get_a() const { return a_; }
int get_b() const { return b_; } // 新添加的代码
private:
int b_; // 新添加的代码
int a_;
};
上例中,修改造成了class的若干种变化。有些变化是可见的,有些则不然。由于添加了新的数据成员,class的尺寸发生了变化,这一点是可见的。这个变化对给所有使用了该class型别的对象的、提领成该class型别的对象的或是对该class型别的指针作了指针算术运算的代码,或是以其他的什么方式引用了这个class的尺寸数据或是引用了其成员名字的代码等应用,都带来了深刻的影响。这里要特别注意的是,新的数据成员的引入所占的位置,同样也会影响旧的成员a_在class对象内的偏移量。一旦a_在class对象内的偏移量真的变了,那所有a_作为数据成员的引用,或是指涉到a_的指涉到数据成员的指针 [25]将统统失效。顺便说一句,该class对象之成员初始化列表的行为是未可预期的,b_被初始化成了一个未有定义的值(欲知详情,请参见常见错误52)。
而最主要的不可见变化,在于编译器隐式提供的复制构造函数和赋值运算符的语义。默认地,这些函数被定义成inline的。是故,它们编译后的代码就会被插入任何使用一个C对象来初始化另一个C对象、或是使用一个C对象给另一个C对象赋值的代码中(常见错误49里提及了有关这些函数的更多信息)。
这个对class C简单的修改带来的最主要的结果(让我们把上面提到的一个引入的缺陷暂时搁下不提),就是几乎所有用到过class C的代码统统需要重新编译过。如果是大项目,这种重新编译可能会旷日持久。如果class C是在一个头文件里定义的,所有包含了这个源文件的代码都连带地需要重新编译过。有一个办法能够缓解此一境况,那就是使用class C的前置声明。具体做法倒也简明,就是当不需要除名字以外的其他信息时,像下面这样写一句非完整的class声明:
class C;
就是这么一句平凡的、非完整的声明语句,使得我们仍然可以声明基于class C的指针和引用,前提是我们不进行任何需要class C的尺寸和成员的名称的操作 [26],包括那些继承自class C的派生类初始化class C部分子对象的操作(可是你看看常见错误39,凡事皆有例外)。
这种手段可谓行之有效,不过要想免吃维护阶段的苦头,还要谨记严格区分“仅提供非完整的class声明”和“提供完整class定义”的代码,不要把它们写到同一个源文件中去。也就是说,想为复杂冗长的class定义提供上述轻量级替身的软件工程师,请不要忘记提供一个放置各种适当前置声明的 [27]专用头文件。
比如上例中,如果class C的完整定义是放在c.h这个头文件中的,我们就会考虑提供一个 cfwd.h,里面只放置非完整的 class声明。如果所有的应用都用不着C的完整定义,那么包含c.h就不如包含cfwd.h。这样做有什么好处呢?因为 C这个名字的含义在未来可能会发生变化,使得一个简单的前置声明不容于新环境。比如,C 可能会在未来的实现中成为typedef:
template <typename T>
class Cbase{
// ...
};
typedef Cbase<int> C;
很清楚,那个头文件c.h的作者是在尽力避免当前class C的用户去修改他们的源代码 [28],不过,任何在包含了c.h以后还想继续使用“C的不完整声明”的企图都会触发毫不留情的编译期错误:
#include "c.h"
// ...
class C; // 错误!C现在不再是class的名字,而是typedef。
因而,如果提供了一个前置声明专用头文件cfwd.h的话,这样问题就根本不会出现了 [29]。所以,这个锦囊妙计就在标准库中催生了iosfwd,它就是人尽皆知的iostream头文件对应的前置声明专用头文件。
更为常见的是,由于必须对使用了class C的代码进行重新编译,结果就使得对已经部署了软件打补丁这件事很难做。这么一来,也许最管用的解决方案就是把 class的接口与其实现分离,从而要达到真正的数据隐藏之境,而其不二法门则是运用桥接设计模式(Bridge Pattern)。
桥接设计模式需要把目标型别分为两个部分,也就是接口部分和实现部分:
gotcha08/cbridge.h
class C {
public:
C( int val );
~C();
int get_a() const;
int get_b() const;
private:
Cimpl *impl_;
};
gotcha08/cbridge.cpp
class Cimpl {
public:
Cimpl( int val ) : a_( val ),b_( a_ ) {}
~Cimpl() {}
int get_a() const { return a_; }
int get_b() const { return b_; }
private:
int a_;
int b_;
};
C::C( int val )
: impl_( new Cimpl( val ) ) {}
C::~C()
{ delete impl_; }
int C::get_a() const
{ return impl_->get_a(); }
int C::get_b() const
{ return impl_->get_b(); }
此新接口包含了class C的原始接口,但class实现则被移入了一个在一般应用中不可见的实现类里。class C的新版本仅仅包含了一个指涉到实现类的指针,而整个实现,包括class C的成员函数,现在都对使用了class C的代码不可见了 [30]。任何对于class C实现的修改 [31],只要不改变class C的接口 [32],影响就会被牢牢地箝制在一个单独的实现文件里了 [33]。
运用桥接模式显然要付出一些运行时的成本,因为一个class C对象现在需要用两个对象,而不是一个对象来表示了,而且调用所有的成员函数的动作现在由于是间接调用,也做不成inline的了。无论如何,它带来的好处是大幅节省了编译时间,而且不必重新编译就能发布使用了class C的代码的更新。这在大多数情况下,可谓物美价廉。
此项技术已经被广泛应用多年,因而也被冠以数种趣名,如“pimpl习惯用法”和“柴郡猫技术(Cheshire Cat technique)”[34]之美誉 [35]。
不可访问的成员在通过继承接口访问时,会造成派生类成员和基类成员的语义发生变化。考虑如下的基类和派生类:
class B {
public:
void g();
private:
virtual void f(); // 新添加的代码
};
class D : public B {
public:
void f();
private:
double g; // 新添加的代码
};
在class B这个基类中添加了一个私有访问的虚函数,导致了原先派生类中的非虚函数变成了虚函数,添加在class D中的私有访问的数据成员则遮掩了B中的一个函数。这就是为什么继承常常被视为“白盒”复用 [36],因为对class的任何修改都在非常基本的层面同时影响着基类和派生类的语义。
一种能够削弱此类问题的方法,是采用一种简明的、根据功能划分名字的命名规范。典型的办法是为型别的名字、私有访问的数据成员的名字或其他什么东西的名字使用不同的规范以示区分。在本书中,我们的规范是使用全大写的型别名字,并在数据成员的后面附加一个下划线(它们应该都只有 private 访问层级成员函数!),而对于其他的名字(除一些特例外)我们用小写字母打头的名字。如果遵守这样的规范,我们在上面的例子中就不会意外地遮掩基类中的成员函数了。不过,最要紧的是不要建立极复杂的命名规范,因为如此规范往往形同具文。
此外,绝对不要让变量的型别成为其名字的一部分。比如,把一个整型变量index命名为iIndex是对代码的理解和维护主动搞破坏。首先,名字应该描述实体的抽象意义,而不是它的实现细节(抽象性甚至在内建型别中就已经发挥了它的影响)。再有,大多数的情况下,变量的型别改变的时候,它的名字不会同步地跟着其型别变化。这么一来,变量的名字就成了迷惑维护工程师有关其型别信息的利器。
其他方法在一些别的地方时有讨论,特别在常见错误 70、73、74和 77中为最多。
当一个更大的世界入侵了 C++社群原本悠然自得的乐土之时,它们带来了一些足堪天谴的语言和编码实践。本节乃是为了厘清返璞归真的 C++语言所使用的正确适当、堪称典范之用语和行为。
用语
表1-1列出了最常见的用语错误,以及它们对应的正确形式。
没有什么所谓“纯虚基类”。纯虚函数是有的,而包含有或是未能改写(override)此种函数的类,我们并不叫它“纯虚基类”,而是叫它“抽象类”。
C++语言中是没有“方法”的。Java和Smalltalk里才有方法一说。当你颇带着一丝自命不凡地就面向对象的话题侃侃而谈之时,你可能使用像“消息”和“方法”这种用语。但如果你开始脚踏实地,开始讨论你的设计对应的C++实现时,最好还是使用“函数调用”或“成员函数”来表达。
还有一些不足为信的C++专家(是在说你吗?)使用“destructed”作为“constructed”的对应词。这明显是英语没学好 [37],正确的对应词是“destroyed”。
C++ 语言中确实有强制型别转换(或曰型别转换)运算符——事实上只有 4 个(static_cast、dynamic_cast、const_cast以及reinterpret_cast)。遗憾的是,“强制型别转换运算符”常常被不正确地用于表达“成员型别转换运算符”,而后者指定了某种对象何以被隐式地转换到另外的型别。
class C {
operator int *()const; // 成员型别转换运算符
//...
};
当然用强制转换运算符来完成型别转换的工作也是允许的,只要你不把用语搞混就成。
请参见常见错误31中有关“常量指针”和“指涉到常量的指针”的讨论,以加深对本主题的理解。
空指针
从前,当软件工程师使用预处理符号NULL来表示空指针时,他会遭遇潜在的灾难:
void doIt( char * );
void doIt( void * );
C *cp = NULL;
麻烦出在NULL这个符号在不同的平台上,有很多种定义的方法:
#define NULL ((char *)0)
#define NULL ((void *)0)
#define NULL 0
这些各扫门前雪的不同定义严重损害了C++语言的可移植性:
doIt( NULL ); // 平台相关抑或模棱两可?
C *cp = NULL; // 错误?
事实上,在C++语言里是没有办法直接表示空指针的。但我们可以保证的是,数字字面常量0可以转换成任何一种指针型别对应的空指针。那也就是传统的C++语言保证可移植性和正确性的用法 [38]。现在,C++标准规定像(void *)0这样的定义是不允许的 [39],可见这是个和NULL的使用并无多大干系的技术问题(如若不然,NULL岂不是成了格外受人青睐的预处理符号?其实它是普通不过的)。可是,真正领会了C++语言精神的软件工程师仍然使用字面常量0[40]。任何其他用法都会使你显得相当非主流。
缩略词
C++软件工程师都有缩略词强迫症,不过与管理层相比,可谓小巫见大巫。
表1-2在你的同事给你来上一句“RVO将不会应用到POD上,所以你最好自己写个自定义的复制ctor”时能派上用场。
“很早就有人发现,最杰出的作家有时对修辞学的条条框框置若罔顾。不过,每当他们这么做的时候,读者总能在这样的语句中找到一些补偿的闪光点,以抵消违反规则带来的代价。也只有他们确信有这样的效果存在他们才这么做,否则他们会尽一切可能去因循那些既成的规则”(Strunk和White,The Elements of Style)[41]。
以上这条被经常引用的对于英语散文写作的经典导引,却常常在指导软件开发中的文本书写风格方面时也屡试不爽。我对本条金科玉律以及背后的深邃思想心悦诚服。不过,我对它还有不甚满意的方面,那就是它在脱离了上下文的前提下,并未揭示为何通常情况下因循修辞学的既成规则是事半功倍的,也未有阐明这些既成规则究竟是怎么来的。相比Strunk高高在上的圣断,我倒更对White的朴实无华的“牛道”之比喻情有独衷。
仍在使用中的语言就像是奶牛群行经的道路:它的缔造者即奶牛群本身 [42],而这些缔造道路的奶牛们在踏出这条道路以后,或是一时兴起,或是实际有需,有时继续沿着它走,有时则大大偏离。日复一日,这道路也历经沧桑变迁。对于一头特定的奶牛而言,它没有义务非得沿着它亲身参与缔造的羊肠小道框出的轮廓行走不可。不过它如果因循而行,常常会因此得益,而若非如此,则不免会因为不知身处何处和不知行往何方而给自己平添障碍(E.B.White,The New Yorker刊载文章)。
软件开发的程序语言并不像自然语言那般复杂,因而我们“撰写清晰代码”的这一目标肯定比“书写漂亮的自然语言的句子”要容易企及。当然,像C++那样的语言的复杂性已经使得其开发软件时的效能与一套标准用法和习惯用法紧密相依。C++语言的设计大可不拘小节,它给予软件工程师足够的弹性。但是,那些久经考验的习惯用法为开发的效率和清晰的交流开启了方便之门。对于这些习惯用法的无意忽视甚至有意违背,无异是对误解和误用的开门揖盗。
很多本书中的建议都包括了发掘和运用 C++编码和设计中的习惯用法。很多这里列举的常见错误都可以直接视作对某个 C++习惯用法的背离。而针对它们提出的正解,则又经常可以看成是向相应习惯用法的归顺。出现这种情况有个好理由:有关 C++编码和设计中的习惯用法乃是 C++软件工程师社群的所有人一起总结并不断地加以完善的。那些不管用的或是已经过气的方法会逐渐失宠直至被抛弃。能够得以流传的习惯用法,都是那些持续演化以适应它们的应用环境的。意识到,并积极运用 C++编码和设计中的习惯用法是产出清晰、有效和可维护的 C++代码和设计的最确信无疑的坦途之一。
作为称职的专业软件工程师,我们可要时时刻刻告诫自己我们平常撰写的代码和设计都已被涵盖在某个习惯用法的语境里了 [43]。一旦我们识别出了某种编码和设计中的习惯用法,我们既可以选择呆在它为我们营造的安乐小窝里,也可以选择在理性思考后为自己的特殊需要而暂时背离它。无论如何,大多数情况下我们还是因循守旧一点好。有一点可以肯定的是,如果我们连半点儿也没有意识到什么习惯用法的存在,我们的半路迷途就是注定的了。
我并不想在不经意间给你留下“C++软件开发中的习惯用法就像讨厌的紧身衣一样把设计流程的方方面面绑得死死的”这么个印象。远非如此。恰当运用习惯用法会让你的设计流程和有关设计的交流变得极其简化,给设计师们留下了发挥其创作天赋的无尽空间。也有这样的时候,哪怕是最合理的、最司空见惯的软件开发中的习惯用法都会在碰到某种设计时不合用。遇到这种情况,设计师就不得不另辟蹊径了。
最常用,也是最有用的C++语言的习惯用法之一就是复制操作的习惯用法。所有 C++里的抽象数据型别都需要做出有关它的赋值运算符和复制构造函数的决定,那就是:允许编译器自行生成它们,还是软件工程师自己手写它们,还是干脆禁止对它们的访问(参见常见错误49)。
如果软件工程师打算手写这些操作,我们很清楚应该怎么写。当然了,编写这些操作的“标准”方法在过去的很多年里是不断演化的。这是习惯用法并非恣意妄为的好处之一:它们总是朝着适应当下用法趋势的方向演化。
class X {
public:
X( const X & );
X &operator =( const X & );
// ...
};
虽然C++语言在如何定义复制操作这方面留下了很大的发挥空间,但是把它们像上面几行代码展示的那样声明却几乎肯定是个好主意:两个操作 [44]都以一个指涉到常量的引用为实参,赋值运算符不是虚函数 [45],返回值是指涉到非常量的引用型别 [46]。显然,这些操作中的任何一个都不应该修改其操作数。如果它们修改了,这是让人莫名其妙的。
X a;
X b( a ); // a不会被修改
a = b; // b不会被修改
除了某些情况,比如C++标准库里的auto_ptr模板就比较特立独行。这是个资源句柄,它能够在从堆上分配的存储不再有用时,把这些存储的善后清理工作做好。
void f() {
auto_ptr<Blob> blob( new Blob );
// ...
// 在此处把分配给Blob型别对象的存储自动清除
}
好极了。不过如果那些还在念书的实习生们写下这样大大咧咧的代码,可如何是好?
void g( auto_ptr<Blob> arg ) {
// ...
// 在此处把分配给Blob型别对象的存储自动清除
}
void f() {
auto_ptr<Blob> blob( new Blob );
g( blob );
// 哎呀,在此处把分配给Blob型别对象的存储又清除了一遍!
}
一种解决之道是把auto_ptr的复制操作彻底禁止,但这么一来就会严重限制它的用途,也使得好多 auto_ptr 的习惯用法化为泡影。另一种是为auto_ptr装备引用计数,但那么一来,使用资源句柄的代价就将膨胀。所以,标准的auto_ptr采取的做法是故意地背离了复制操作的习惯用法:
template <class T>
class auto_ptr[47] {
public:
auto_ptr( auto_ptr & );
auto_ptr &operator =( auto_ptr & );
// ...
private:
T *object_;
};
这里,操作符右边的操作数实参并不具有常量性!当一个auto_ptr使用另一个auto_ptr对象初始化或被赋值为另一个auto_ptr对象时,这个用于初始化或赋值的源对象便中止了对它指涉的从堆上分配的对象的所有权,具体做法是把它内部原本指涉到对象的指针置空。
就像背离了习惯用法通常所发生的那样,对于如何用好auto_ptr对象从一开始就引起了不少困惑。当然,这个对已经存在的习惯用法的背离也搞出了不少多产的、围绕着所用权议题的新用法,而将auto_ptr对象用作数据的“源”和“汇”看起来成为了获利颇丰的新的设计领域。从效果上说,对已经存在的业已成功的习惯用法采取了深思熟虑的背离,反而产生了一系列新的习惯用法 [48]。
C++语言和C语言看起来会吸引相当多的人去张扬个性(你有没有听说过一个叫“Obfuscated Eiffel”的比赛?)[49]。在这些软件工程师的思维里,两点间的最短距离是普通欧氏空间之球面扭曲上的大圆。
试举一例:在C++语言的圈子里(且不论这个圈子是不是普通欧氏空间里的),代码的排版格式纯粹是为了方便解读代码的人类的,而对于代码[50]的意义,只要语汇块的次序还是按原先的次序的依次出现,就怎么都无所谓。这最后一个附加条款殊为重要,比如,以下这两行表示的是非常不同的意思 [51](但是请看常见错误87):
a+++++b; // 错误![52]
a+++ ++b; // 没问题
以下两行也是同出一辙(参见常见错误17):
ptr->*m; // 没问题
ptr-> *m; // 错误![53]
上面的例子容易让大多数 C++软件工程师同意,只要注意不去趟语汇块划分错误的浑水,代码的排版格式就再次高枕无忧地和代码的意义无关了。因此,把一个声明变量的语句写在一行里还是分成两行写,结果别无二致。(有一些软件开发环境的调试器以及其他工具组件是依据代码的行数,而不是其他更精确的定位逻辑来实现的。这样的工具经常强迫软件工程师去把本来可以写在一行里的代码硬分成既不自然也不方便的数行来写,以得到更精准的错误提示错误,或是设置更精准的断点,等等。这不是 C++语言的毛病,而是C++软件开发环境作者的毛病。)
long curLine = LINE ; // 取得当前行数值
long curLine
= LINE
; // 同样的声明 [54]
绝大多数的 C++软件工程师在这一点上都会犯错。让我们看一个平凡的用模板元编程实现的可以在编译期遴选一种型别的设施:
gotcha11/select.h
template <bool cond,typename A,typename B>
struct Select {
typedef A Result;
};
template <typename A,typename B>
struct Select<false,A,B> {
typedef B Result;
};
具现 Select 模板的过程是先在编译期对一个条件评估求值,然后根据此表达式的布尔结果具现此模板的两个版本之一。这相当于一个编译期的 if 语句说“如果条件为真,那么内含的Result的型别就是A,否则它的型别就是B。”
gotcha11/lineno.cpp
Select< sizeof(int)==sizeof(long),int,long >::Result temp = 0;
上面这个语句声明了一个变量temp,如果在某特定平台上int型别和long型别占用的字节数是一样的,那么变量的temp的型别就是int,否则它的型别就是long。
再让我们看看前面声明的那个curLine吧。我们干嘛没事找事地写浪费那么多空格的空间呢?不过权且让我们没什么理由地把问题复杂化好了:
gotcha11/lineno.cpp
const char CM = CHAR_MAX;
const Select< LINE <=CM,char,long>::Result curLine = LINE ;
上面这段代码是管用的(且算它正确),但是这一行太长了,所以维护工程师便随后稍稍把它重新排了一下版:
gotcha11/lineno.cpp
const Select< LINE <=CM,char,long>::Result
curLine = LINE ;
现在我们的代码里有了一个bug,你看出来了吗?
在代码行数为CHAR_MAX(它可能小到只有127)的那一行里,以上的声明会导致什么结果?
curLine的型别会被声明为char,并被初始化为char型别的最大值。随着我们把初始化源放到了下一行,我们就会把curLine的值初始化为char型别的最大值还要大 1 的数。这个结果很可能会指出,当前行数是一个负数(比如−128)[55]。多么聪明啊!
聪明反被聪明误在 C++软件工程师身上算一个常见的问题。请时刻牢记,几乎在所有的场合下,遵循习惯用法、清晰的表达和一点点效率的折损都好过小聪明、模棱两可和维护便利的丧失。
我们软件工程师在提出建议方面是巨人,但一到行动的时候就成了矮子。我们不懈地奉劝人家不要使用全局变量、不好的变量名称、幻数,等等,但在自己的代码里却常常放入这些东西。这种现象使我困惑多年,直到有一次偶然读到一本描写青少年行为学的杂志时才豁然开朗。对于青少年来说,指责别人的冒险行为是常事,但是他们常常有一种“个人幻想”,相信他们自己对相同行为的一切负面效应都具有免疫力。那么我可以说,作为一个群体来说,软件工程师看来是深受情商欠佳之苦的。
我曾经带过这么一些项目。在这些项目里有些软件工程师不仅拒绝服从编码规范,甚至会因为被要求缩进4 个空格而不是2个这样的小事而威胁要退出团队。我曾经面临过这样的境遇:在软件开发会议上,只要有一个派系的人参加,另一个就不参加。我曾经见过这样的软件工程师:他们故意地写没有文档的、令人费解的代码,这样其他人就没法去动这些代码了。我曾经见过这样根本不合格的软件工程师:他们拒绝接受比他们年长——或比他们年幼、或说话太直、或太吹毛求疵——的同事的任何意见,并引起灾难性的结果。
无论在情商意义上是年少轻狂亦或成熟稳重,作为一个专业的软件工程师,我们都有一些数量的成人的——或至少是专业的——责任(参见美国计算机器协会在 ACM Code of Ethics and Professional Conduct 和 Software Engineering Code of Ethics and Professional Practice对此类问题所持观点)。首先,我们对我们自己选择的专业负有责任。从而我们应该做出有质量的工作,并在我们的能力范围内做到最高的标准。
其次,我们对身处的社会和居住的星球负有责任。我们选择的专业在科学研究和实际服务的方面都是平等一员。如果我们的工作不是为将身处的世界变得更好而作出贡献,我们就是在浪费我们的才智和时间,而最终浪费的,是自己的生命。
第三,我们对参与的社区负有责任。所以我们应该共享我们的长处,来影响公共政策。在我们这个愈来愈技术化的社会里,最重要的决策都是那些受法学或政治学教育的人作出的,但那些人在技术方面一窍不通。比如,某个州曾经一度把π的近似值以法律形式规定为3。这很滑稽(当然,那些以轮胎为基础的交通工具在这条法律寿终正寝之前只能颠簸不已),但我们看到的许多秘而不宣的政策就不那么好玩了。我们有义务告知那些政坛精英们作为政策基础的理性、技术和在数目字上的来龙去脉。
第四,我们对同事负有责任。所以我们应该有大度风范。这就包括我们应该遵守编码和设计的“地方政策”(如果这些“地方政策”不好,我们应该变更它们而不是无视它们),写出易于维护的代码,在表达我们自己意见的同时,也倾听别人怎么说。
这绝对不是让你随波逐流、装老好人,或是为鼓励屈从团队权威和沉湎市井俗见的愚见而摇旗呐喊。我的一些最满意的专业协作就是和一些离经叛道、身居要职、行事诡异的独行侠们共同完成的。但是这些值得珍惜的同事中的每一个都既尊重我,也尊重我的想法(他们在我理应受嘉奖时不吝溢美之辞,也在我犯错时直言不讳),在和我一起工作时努力完成那些我们商议好了要完成的东西。
第五,我们对同行负有责任。从而,我们应该共享知识和经验。
最后,我们对自己负有责任。我们的工作和思想起码应该让我们自己感到满意,并让我们自己觉得选择从事这一行情有可原。如果我们对我们从事的工作富有激情,如果我们从事的工作已经融入成为我们自身的一部分,这些责任就不再是一种负担,而是一种快乐了。
[1].译者注:因为注释没有随着代码更改,这个语句看起来就成了下面这个样子。
[2].译者注:说不定是代码错了呢。
[3].译者注:即在普通的屏幕上能在一页内显示得下。
[4].译者注:源文件文本长度与理解难度成指数关系,所以能少写一行非必要的注释就少写一行。
[5].译者注:这很明显是Herb Sutter倡导的命名规则(原谅我又多写了一行注释)。
[6].译者注:参见(Kernighan,et al.,2002),§8.7)。
[7].译者注:如果“最大合同数”和“标识符的最大长度”变成了不同的数,上述的初始化循环就要从一个变成两个了。而事实上就应该是两个毫不相关的初始化循环,由于值碰巧是同一个而耦合了它们,这也是幻数背后的临时观念导致的不良编码实践习惯。
[8].译者注:具名常量则理论上占有存储空间,尽管一般会经由常量折叠予以消除。
[9].译者注:字面常量无法取址,它们没有地址。
[10].译者注:如果要改变机制,不再使用某些全局变量的话。
[11].译者注:此型别全局变量会分散在各个源文件里并在各个地方使用,块间耦合度因而就变得很高了。
[12].译者注:参见(Meyers,2006),条款4,那里有一个更漂亮的单件设计模式的实现。有关控制对象数量——不仅仅是限制为1个的更深入讨论,参见(Meyers,2007),条款26。
[13].译者注:这符合C++“不为用不到的东西付出成本”的语言设计哲学。
[14].译者注:即产生的目标代码。
[15].译者注:因而编译器找不到不带形参的f1()。
[16].译者注:也就是能让函数在被调用时少指定几个实参,不用老是反复地指定几个相同值的实参。
[17].译者注:参见(Meyers,2001),条款24,它用更具体的例子说明了本章讨论的问题。
[18].译者注:即不带常量性的那个operator []()成员运算符。
[19].译者注:作者想强调的是,a的值首先被复制到一个临时对象中,reinterpret_cast的操作数并非a本身,而是a的这个副本,也就这个临时对象。
[20].译者注:而不是什么复制而成的临时对象了。
[21].译者注:这一段非常重要,请仔细阅读以真正领会它的意思。它主要说了这么一件事:转换成引用型别的强制型别转换操作的操作数是对象本身,因而它是个左值。否则它的操作数就是一个临时对象,而对临时对象赋值是没有任何意义的,只能引起概念混乱,所以C++语言把这样的结果规定为右值,禁止进行赋值。这就像int型变量a、b相加的表达式a+b的结果也是一个临时对象,因而不能对它赋值是同一个道理。
[22].译者注:以下这6行代码主要想说明,C++的函数调用语法很灵活,无论是通过使用函数名本身、指涉到函数的指针还是函数的引用来调用函数,都既可以用名字本身,也可以使用提领语法。尽管后两行在语法上其实是经过了一个隐式型别转换,因而会带来效率上的损失。
[23].译者注:也就是说,变量本身不具常量性,具有常量性的是通过指针提领的那个能够作为左值的表达式。虽然这两者从观念上来看,是同一个对象。这就让我们理解,C++的常量性不是根据低级地址绑定的,而是富有高级的对象语义的。
[24].译者注:这个例子说明了C++里的常量性的观念只是限制了在代码中对const修饰的变量显式的直接修改,对于其他方式的修改,并不是C++语言中的常量性所要求的。总体来看,本文指出了常量性是高级的操作。
[25].译者注:若不重新编译的话。
[26].译者注:如指针算术。
[27].译者注:而不放置class定义的。
[28].译者注:这个可怜的作者是想造出一种假象,使得像“C c;”这样的语句能够继续合法,遗憾的是任何假象都会在某些情况下被揭穿。
[29].译者注:那些根本用不着C的完整定义的代码才不管C是class还是typedef。
[30].译者注:使用了桥接设计模式以后,有关class C的代码就不再修改了,修改的只是实现类,也就是class Cimpl的代码。这样,使用了class C的代码就不必重新编译,因为对于C来说,内存布局没有发生任何变化。这也就是Herb Sutter所谓的“编译防火墙”,参见(Sutter,2003),§4。
[31].译者注:亦即对于class Cimpl的修改。
[32].译者注:亦即具有public访问层级成员函数。
[33].译者注:这就通过修改了实现的可见性,给软件工程师带来了可贵的最小编译代价和变化影响的可控性,也就是让使用了class C的代码“眼不见,心不烦”。
[34].译者注:“柴郡猫”大致相当于“神龙见首不见尾”的含义。
[35].译者注:关于此主题的更多信息,参见(Meyers,2006),条款 31。
[36].译者注:亦即源代码必须可见的情况下才能进行的复用。
[37].译者注:destruct是非及物动词,不可能有-ed分词形式。
[38].译者注:请参见(Stroustrup,2002),§ 11.2.3。
[39].译者注:请参见(Koenig,1996),§16.3.5。
[40].译者注:C++11中引入了关键词nullptr,作者写作此节时标准尚末定案,关于该关键词参见(Lippman,2015)。
[41].作者注:这段引言的实际作者是William Strunk,在本书的原始卷宗里就已经有这段话了。但是,这本书的再次风靡是1959年White的功劳。
[42].译者注:世上本没有路,走的牛多了,也就成了路。
[43].译者注:这是真正的大师经验之谈,这种时时刻刻准备套用习惯用法的良好实践,不仅可以使我们摆脱从头再造轮子的重复之累,更能使我们从别人擅长的技术中受益,并集中精力在自己擅长的问题上,十分符合分工互惠的经济学原理。有无这种职业敏感是专业软件工程师和业余代码写手的极大不同,后者比较热衷于自己写点新鲜的东西,自诩“创新”,但是专业性却不敢恭维。
[44].译者注:完成复制的操作是由两个函数来完成的,一个是复制构造函数,还有一个是赋值运算符。
[45].译者注:而复制构造函数不允许声明为虚函数。
[46].译者注:应该返回指涉到*this的引用,请参考(Meyers,2006),条款10。
[47].译者注:标准的auto_ptr还实现了这些非模板复制操作对应的模板成员函数,但是经验是相似的,参见常见错误88。
[48].译者注:auto_ptr在最新的C++14标准中已被置为不推荐使用的语言特性。
[49].译者注:这是一个以恶搞为能事的比赛,缘起是Perl语言,后发展至C语言。参赛者比的是谁能把合法的代码写得最难看懂,或是排版成各种花样。
[50].译者注:对机器而言。
[51].译者注:这两行的“字面字符”是完全相同的,但这并不意味着它们表示着同样的“语汇块”。
[52].译者注:本行等价于a++ ++ + b,而a++不是一个左值。
[53].译者注:->*合起来才是一个运算符。
[54].译者注:但是,结果变得毫无意义了。同样的建议参见(Kernighan,2002),§ 1。
[55].译者注:在硬件采用补码编码的机器上就会如此,比如IBM PC架构的机器。
C++语言的语法和词法结构博大精深。此复杂性的一部分是从 C 语言那里继承而来的,另一部分则是为支撑某些特定的语言特性所要求的。
本章中我们将考察一组语法相关的头疼问题。其中有些属于常见的手误,但是错误的代码仍然能够通过编译,只不过会以出人意料的方式运行罢了。另外一些则是由于一段代码的语法结构及它们的运行期行为不再互为表里。其余的部分,我们主要研究语法层面的灵活余地带来的问题:明明是一字不差的代码,不同的软件工程师能从中得出大相径庭的结论来。
我们从堆上申请创建一个包含12个整数的数组,怎么样呀?没问题:
int *ip = new int(12);
到目前为止似乎一切正常,那么让我们在数组上耍些花样。耍完以后,再把分配的内存予以回收。
for (int i = 0; i < 12; ++i)
ip[i] = i;
delete [] ip;
注意我们用的那对空的中括号,它告知编译器ip指涉到的是一个包含一堆整数的数组,而不是单个的一个整数。等等,事实真的是这样吗?
其实,ip指涉到的恰恰是单个的一个整数,被初始化成了12。我们犯了一个常见的手误,把一对中括号打成了一对小括号。这么一来,循环体里就会出现非法访问(索引值大于0的部分统统如此),内存回收这句也行不通了。可是,没有几个编译器能在编译期就把这个错误给逮出来。因为一个指针既可以指涉到单个的一个对象,也可以指涉到包含一堆对象的数组,而且循环体内的索引语句和数组的内存回收语句在语法意义上可谓无懈可击。这么一来,我们直到运行期才会意识到犯了错误。
也许连运行期都安然无恙。没错,访问对象数组所占空间结束之后的位置是非法的(虽然标准保证了访问对象数组结束之后的一个对象元素是可以的)[1],把应用于数组的内存回收语法应用到并非数组的纯量上也是非法的。但做了非法的事,并不意味着你就没有机会逍遥法外(想想华尔街操盘手们干的勾当)。以上的代码在一些平台能够完美运行,但在另一些平台上则会在运行时崩溃,在某些特定平台上还会玩出别的古怪花样来,到底会如何,就全看特定的线程或进程在运行时是怎么操作堆内存了。正确的内存申请语法,当然如下所示:
int *ip = new int[12];
说不定,最好的内存申请形式就是根本不去自己做这个内存申请:直接用标准库中的组件就好:
std::vector<int> iv(12);
for (int i = 0; i < iv.size(); ++i)
iv[i] = i;
// 不用显式地回收内存[2]
标准的vector模板几乎和手工写出的数组版本一样高效,但它更安全,编码更快,还起到了“自注释”的效果。一般地,相对于裸数组而言,优先使用vector模板。顺便提一句,相同的语法错误在一句平凡的声明语句里就会发生,但这个错误通常相对比较容易发现:
int a[12]; // 包含12个int型对象的数组
int b(12); // 一个int型对象,以12这个值来初始化之
再没有比为迷糊的软件工程师设下的评估求值次序陷阱更能发现 C++语言的 C 语言渊源印记了。本条款讨论同一个根源问题的若干不同表现形式,那就是C语言和C++语言在表达式如何评估求值的问题上留下了很大的处理余地。这种灵活性能够使得编译器生成高度优化的可执行代码,但同时也要求软件工程师更仔细地审视涉及这个问题的源代码,以防止对评估求值次序作出任何了无依据、先入为主的假设。
函数实参的评估求值次序
int i = 12;
int &ri = i;
int f(int,int);
// ...
int result1 = f(i,i *= 2); // 不可移植
函数实参的评估求值并没有固定的次序。所以,传递给f的值既可能是12、24[3],也可能是24、24[4]。仔细点的软件工程师可能会保证凡是在实参表里出现一次以上的变量,在传递时不改变其值。但是即使如此也并非万无一失:
int result2 = f(i,ri *= 2); // 不可移植
int result3 = f(p(),q()); // 危险……
在第一种情况下,ri是i的别名。所以,result2的值和result1一样徘徊于两可之间。在第二种情况下,我们实际上假设了p和q以什么次序来评估求值是无关紧要的。即使当前情况下这是成立的,我们也不能保证以后这就成立。问题在于,对于这个“p和q以什么次序来调用决定于编译器实现”的约束,我们在任何地方也没有文档说明[5]。
最好的做法是手动消除函数实参评估求值过程中的副作用:
result1 = f(i,i * 2);
result2 = f(i,ri*2);[6]
int a = p();
result3 = f(a,q());[7]
子表达式的评估求值次序
子表达式的评估求值次序也一样不固定:
a = p() + q();
函数 p可能在 q之前调用,也可能正相反。运算符的优先级和结合性对评估求值次序没有影响。
a = p() + q() * r();
3个函数p、q和r可能以6种(P3)次序中的任何一种被评估求值。乘法运算符相对于加法运算符的高优先级只能保证q和r的返回值的积在被加到p的返回值上之前被评估求值。同样的道理,加法运算符的左结合性也不决定下式中的 p、q和 r以何种次序被评估求值,这个结合性只是保证了先对 p、q的返回值之和评估求值,再把这个和与 r的返回值相加:
a = p() + q() + r();
加括号也无济于事:
a = (p() + q()) * r();
p和q返回值之和会先被计算出来,但r可能是(也可能不是)第一个被评估求值的函数。唯一能保证固定的子表达式评估求值次序的做法就是使用显式的、软件工程师手工指定的中间变量 [8]:
a = p();
int b = q();
a = (a + b) * r();
这样的问题出现的频率有多高呢?反正足以每年让一两个周末泡汤就是了。考虑图2-1,一个表示语法树的继承谱系片断,它被用来实现一个做算术运算的计算器。
以下实现代码是不可移植的:
gotcha14/e.cpp
int Plus::eval() const
{return l_->eval() + r_-> eval();}
int Assign::eval() const
{return id -> set(e_->eval());}
问题在于Plus::eval的实现,因为左子树和右子树的评估求值次序是不固定的。不过对加法而言,真的会坏事吗?毕竟,加法不是有交换律成立的吗?考虑以下的表达式:
(a = 12) + a //[9]
根据在Plus::eval中左子树和右子树谁先进行评估求值的次序之异,以上表达式的值既可能是24,也可能是a原先的值加上12。如果我们规定该计算器的算术规则里,赋值运算比加法运算优先,那么Plus::eval的实现必须用一个显式的中间变量来把评估求值次序固定下来:
gotcha14/e.cpp
int Plus::eval() const {
int lft = l_eval();
return lft + r_eval();
}
定位new的评估求值次序
实话实说,这个问题倒并不是那么常出现的。new运算符的定位语法允许不仅向申请内存的对象的初始化函数(一般来说就是某个构造函数)传递实参,同时也向实际执行这个内存分配的函数operator new传递实参 [10]。
Thing *pThing =
new (getHeap(),getConstraint()) Thing(initval());
第一个实参列表 [11]被传递给一个能够接受这样一些实参的operator new。第二个实参列表被传递给了一个Thing型别的构造函数。注意,函数的评估求值次序问题在两个函数实参列表里都存在:我们不知道getHeap和getConstraint中的哪一个函数会被先评估求值。犹有进者,我们连operator new和Thing型别的构造函数这两个函数实参列表中的哪一个列表会被先评估求值 [12]都不得而知。当然,我们有把握的是operator new会比Thing型别的构造函数先调用(我们需要先为对象拿到存储,然后再去在这个存储上初始化它)。
将评估求值次序固定下来的运算符
有些运算符有着与众不同的可靠性——如果把他们单独拿出来说的话,比如逗号运算符确实能把其子表达式的评估求值次序给固定下来:
result = expr1,expr2;
这个语句肯定先对expr1评估求值,再对expr2评估求值,然后把expr2的评估求值结果赋给result。逗号运算符会被滥用,导致一些诡异的代码:
return f(),g(),h();
上面这段代码的作者的情商有待提高。使用更加符合习惯的代码风格,除非你有意想要使维护工程师陷入困惑:
f();
g();
return h();
逗号运算符的唯一常用场合就是在 for 语句中的增量部分,如果迭代变量不止一个的话它就派上了用场:
for (int i=0,j=MAX; i<=j; ++i,--j) // ...
注意,后一个逗号才是逗号运算符,前一个只是声明语句的分隔符。
逻辑运算符&&和||的短路算法特性是更有用的,利用这一点我们就有机会以一种简约的、符合习惯用法的方式表达出很复杂的逻辑条件。
if (f() && g()) // ...
if (p() || q() || r()) // ...
第一个表达式是说:“对f评估求值,如果结果为false,那么表达式的值就是false;如果结果是true,那么再对g评估求值,并以该结果作为表达式的值。”第二个表达式是说:“按照从左到右的次序依次对p、q和r评估求值,只要有一个结果为true就停下来。如果3个结果都是false,那么表达式的值就是false,否则就是true。”有了能把代码变得如此简约的好工具,这也就难怪以 C/C++语言作为开发语言的软件工程师在他们的代码里这样频繁地应用这些运算符了。
三目条件运算符(读作“问号冒号运算符”)也起到了把其实参的评估求值次序固定下来的作用:
expr1 ? expr2 : expr3
第一个表达式会首先被评估求值,然后第二个和第三个表达式中的一个会被选中并评估求值,被选中并评估求值的表达式求得的结果就作为整个条件表达式的值。
a = f() + g() ? p() : q();
在上面这种情况下我们对所有子表达式的评估求值次序有一定的把握。我们知道 f和g肯定会比p或q先进行评估求值(尽管f和 g之间的评估求值次序是不固定的),我们还知道p和q中只有其中一个会被评估求值。为增强可读性,给上面这个表达式增加一对可有可无的括号也许是个不坏的主意:
a = (f() + g()) ? p() : q();
如果不加这对括号,此代码的维护工程师——出于业务不精或仓促上阵——有可能会错意,以为它与下面这样的表达式等价:
a = f() + (g() ? p() : q());
不当的运算符重载
既然内建的运算符有着这么有用的语义,我们就不该试图去重载它们。对于C++语言来说,运算符重载只是“语法糖”,换言之,我们只是用了一种更易为人接受的语法形式来书写函数调用。举个例子来说,我们可以重载运算符&&来接受两个Thing型别的实参:
bool operator &&(const Thing &,const Thing&);
当我们以中置运算符的形式来调用它的时候,维护工程师很有可能认为它和内建运算符一样具有短路算法的语义,可是这样认为就错了:
Thing &tf1();
Thing &tf2();
// ...
if (tf1() && tf2()) // ...
上面这段代码和以下这个函数调用具有一模一样的语义:
if (operator &&(tf1(),tf2())) // ...
正如我们所见,tf1和tf2无论如何都会被评估求值,而且次序还不固定。这个问题在重载运算符||和逗号运算符时都同出一辙。三目条件运算符禁止被重载,也算不幸中的万幸 [13]。
本条款不讨论到底是伯爵夫人还是男爵夫人该在晚宴时坐在大使的旁座(此问题无解)。我们要讨论的是在 C++语言中的多层级化的运算符优先级如何带来一些令人困扰的问题。
优先级和结合性
在一种程序设计语言中引入不同层级的运算符优先级通常来说是好事一桩,因为这样就可以不必使用多余的、分散注意力的括号而能把复杂表达式简化。(但是请注意,在复杂的或是比较晦涩的、亦即并非所有代码读者都能很好理解的表达式中显式地加上括号以表明意义,这是正确的想法。当然了,在那些平凡的、众人皆知的情况下一般来说还是不加不必要的括号反而最让人觉得清楚。)
a = a + b * c;
在上面的表达式中,我们知道乘法运算符具有最高的优先级,或者说最高的绑定强度,所以我们先执行那个乘法操作。赋值运算符的优先级是最低的,所以我们最后做赋值操作。
b = a = a + b + c;
这种情况下,我们知道加法操作会比赋值操作先执行,因为加法运算符的优先级比赋值运算符的优先级要高。但是哪个加法会先执行,又是哪个赋值会先执行呢?这就迫使我们去考察运算符的结合性了。在 C++语言中,一个运算符要么是左结合的,要么是右结合的。一个左结合的运算符,比如加法运算符,会首先绑定它左边的那个实参。是故,我们先算出a、b之和,然后才把它加到c上去。
赋值运算符是右结合的,所以我们首先把a+b+c的结果赋给a,然后才把a的值赋给b。有些语言里有非结合的运算符:如果@是一个非结合的运算符,那么形如 a@b@c 的表达式就是不合法的。合情起见,C++语言里没有非结合的运算符。
优先级带来的问题
iostream库的设计初衷是允许软件工程师使用尽可能少的括号:
cout << "a+b=" << a+b << endl;
加法运算符的优先级比左移位运算符要高,所以我们的解析过程是符合期望的:a+b先被评估求值,然后结果被发送给了cout。
cout << a ? f() : g();
这里,C++语言中唯一的三目运算符给我们惹了麻烦,但不是因为它是三目运算符的关系,而是因为它的优先级比左移运算符要低。所以,照编译器的理解,我们是产生了执行代码让cout左移a位,然后把这个结果用作该三目运算符所需的一个判别表达式。可悲的是,这段代码居然是完全合法的!(一个像cout这样的输出流对象有一个隐式型别转换运算符operator void*,它能够隐式地把cout << a的计算结果转型为一个void *型别的指针值。而根据这个指针值为空与否,它又可以被转型为一个true或false。)[14]这是一个我们非加括号不可的情况:
cout << (a? f() :g());
如果你想被别人觉得精神方面无懈可击,你还可以再进一步:
if (a)
cout << f();
else
cout << g();
这种写法也许不如前一种写法那么令人浮想联翩,但是它的确有着又清楚、又容易维护的优点。
很少有采用C++语言的软件工程师会在处理指涉到classes的指针时遭遇运算符优先级带来的问题,因为大家都知道operator ->和运算符.具有非常高的优先级。是故,像“a =++ptr->mem;”的意思就是要一个将ptr指涉的对象含有的成员mem自增后的结果。如果我们是想让这个ptr指针先自增,我们原本会写“a = (++ptr)->mem;”,或也许“++ptr; a = ptr->mem;”,或哪天心情特别糟的话,一怒之下写成“a = (++ptr,ptr->mem);”。
指涉到成员的指针则完全是另一回事了。它们必须在一个 class对象的语境里做提领操作(参见常见错误46)。为了这个,两个专用提领运算符被引入了语言:operator ->*用来从指涉到一个class对象的指针提领一个指涉到该对象的class成员的指针,运算符.*用来从一个class对象提领一个该对象的class成员的指针。
指涉到成员函数的指针通常用起来会比较头疼,但是它们一般不会造成特别严重的语法问题:
class C {
// ...
void f( int );
int mem;
};
void (C::*pfmem)(int) = &C::f
int C::*pdmem = &C::mem;[15]
C *cp = new C;
// ...
cp->*pfmem(12); // 错误!
我们的代码通不过编译,因为函数调用运算符 operator()的优先级高于operator ->*。问题在于,将函数提领之前(此时其地址尚未决议),我们无法调用它。这里,加括号是必须的:
(cp->*pfmem)(12);[16]
指涉到数据成员的指针相对来说更容易出问题,考虑以下的表达式:
a = ++cp->*pdmem
变量cp和上面那个是同一个指涉到class对象的指针,pdmem不是一个class成员的名字,而是一个指涉到成员的指针的名字。在这种情况下,由于operator ->*的优先级不如运算符++高,cp会在指涉到成员的指针被提领前实施自增。除非cp指涉到的是一个class对象的数组,否则这个提领动作肯定不知道会得到什么结果[17]。
指涉到class成员的指针是一个好多C++软件工程师都没理解透的概念。为了让你代码的维护工程师未来的日子好过些,我们还是本着平淡是真的精神使用它吧:
++cp;
a = cp->*pdmem;
结合性带来的问题
大多数C++运算符是左结合的,而且C++语言里没有非结合的运算符。但这并不能阻止有些聪明过头的软件工程师以下面的方式来使用这些运算符:
int a = 3,b =2,c = 1;
// ...
if (a > b > c) // 合法,但很有可能是错的……
这段代码完全合法,但极有可能辞不达意。表达式“3 > 2 > 1”的结果是false。就像大多数 C++运算符一样,operator >是左结合的,所以我们先计算子表达式“3>2”,结果是 true。然后余下的就是计算“true>1”。为了计算这个,我们首先对true实施目标为整数型别的型别转换,结果实际就是在对“1>1”评估求值,其结果显然是false。
在这种情况下,软件工程师很可能本意是想写出条件“a>b && b>c”。或者,出于某种难以启口的理由,软件工程师实际上就是想要那样的结果,但那样的话更好的写法应该是“a>b?1>c:0”或是“(c-(a>b))<0”——即使是这两种写法也很怪,足以让维护工程师一愣。所以,遇到这种情况写个注释诚属有情可原(参见常见错误1)。
C++语言中有若干位置可以合法地在一个受限辖域(restricted scope)内,而不仅仅是平凡的一个嵌套闭合语句区块(nested block,即一对大括号之间的部分)中做一个变量声明。举例来说,在if语句的判别式部分就可以做一个变量声明。该变量在根据判别式控制跳转到的语句,无论true的部分还是false的部分内都有效:
if (char *theName = lookup(name) ){
// 做一些有关theName的操作
// 这里就越过了theName的辖域(theName不再有效)
以前的岁月里,此种变量很可能在if语句之外声明,在我们已经不需要它的时候仍然赖着不走,并带来麻烦。
char *theName = lookup(name);
if (theName){
// 做一些有关theName的操作
}
// theName在这里仍然有效
// ...
一般来讲,把一个变量的辖域在其所在的代码内加以限制是个好主意。因为在进行代码维护时,出于一些超出我个人理解能力的原因,这样辖域太广的变量会死灰复燃,用于一些压根无关的目的。这给文档簿记和后期维护带来的影响,说实话,相当负面(参见常见错误48)。
}
theName = new char[ISBN_LEN]; // theName又被用来存储ISBN号了
}
对 for 语句而言以上说法依然成立。一个迭代变量的声明可以作为语句的第一部分:
for (int i = 0; i < bufSize; ++i){
if (!buffer[i])
break;
if (i == bufSize) // 原先是合法的,现在不合法了,i超出了其辖域
// ...
上面的代码在许多年间都是合法的C++代码,但是迭代变量的辖域后来作了调整。以前,迭代变量的辖域规定为从它被声明的那个位置(恰在初始化运算符之前,参见常见错误 21)一直到包含该for语句的那个闭合语句区块的结束位置 [18]。在C++语言新规定的语义中,迭代变量的辖域被限定到了for语句本身的结束位置。尽管大多数C++软件工程师都觉得这个调整于情于理皆无可指摘——它和语言的其余部分更加正交 [19],也使得循环更加容易得以优化,诸如此类——但是不得不去收拾for语句旧用法的烂摊子这一事实也实实在在地给一些维护工程师造成了一些落枕般的痛苦。
有时候这个痛苦就不止像落枕那个程度了,考虑下面的代码片段中悄然变化的变量含义:
int i = 0;
void f(){
for (int i = 0; i < bufSize; i++){
if (!buffer[i])
break;
}
if (i == bufSize) // 这个i是整个文件辖域里的i
// ...
}
幸运的是,犯这种错误的机会毕竟不多,何况任何一个有质量可言的编译器都会就此大声警告。严肃对待编译器警告(不要关闭编译器警告)[20],避免让外层辖域的变量遮掩了内层辖域里的同名变量。还有,坚决让全局变量下岗(参见常见错误3)。
让人想不通的是,迭代变量的辖域调整造成的最具破坏性的后果是它居然使一些C++软件工程师在写for语句时养成了一些坏毛病:
int i;
for (i = 0; i < bufSize; ++i){
if (isprint(buffer[i]))
massage(buffer[i]); // 译注:“按摩”内存?
// ...
if (some_condition)
continue;
// ...
}
这是C代码,不是C++代码。没错,这段代码的好处是在迭代变量辖域定义的调整前后具有相同的语义。但看看我们付出了什么代价:首先,迭代变量在for语句结束时仍然保持有效 [21];其次,i没有被初始化。这两点在代码初成时都没有问题,但是在维护时期,缺乏经验的维护工程师会在i被初始化之前就使用它,或是for语句结束之后违反作者想让i“挥挥手不带走一片云彩”的本意而继续使用它。
另一个问题是有些软件工程师干脆就不用for语句了:
int i = 0;
while (i < bufSize){
if (isprint(buffer[i]))
massage(buffer[i]);
// ...
if (some_condition)
continue; // 错误!
// ...
++i;
}
for语句和while语句并非完全等价。比如,如果循环体内有一个continue语句,整个程序就有了一个难以察觉的语义改变。本例中,会引起死循环,它提醒我们哪里肯定出了什么毛病 [22]。我们并非总是幸运儿。
如果你很走运地工作在一个支持 for 语句新语义的平台上,最好的做法就是从善如流。
不过,不幸的现实是我们的代码恐怕必须在不同的、在对 for 语句语义的解释相互矛盾的平台上编译。“保持两种解释下的兼容性”似乎是一个写出以下代码的好理由:
int i;
for (i = 0; i < bufSize; ++i){
if (isprint(buffer[i]))
massage(buffer[i]);
// ...
}
无论如何,我都大声呼吁所有的 for 语句都应该在新语义下书写,为避免迭代变量的辖域过大的问题,你可以把 for 语句置入一个闭合语句区块内(即在for语句外面套一对大括号):
{for (int i = 0; i < bufSize; ++i){
if (isprint(buffer[i]))
massage(buffer[i]);
}}
这种写法丑陋得可以,所以当编译器的改进使得它失去存在的必要时,一定会被维护工程师发觉并移除。它还有其他的优点:他鼓励撰写初次代码的软件工程师使用for语句的新语义,并给这段代码的维护工程师省却了不少额外的麻烦。
当面对如下表达式时,你何以措手足?
++++p->*mp;
你可曾有幸和“中士运算符”[23]打过交道?
template <typename T>
class R{
// ...
friend ostream &operator <<< // 一个“中士运算符”?
T>(ostream &,const R&);
};
你可曾为“下面的表达式是否合法”的问题迟疑过?
a+++++b
欢迎进入取大优先解析原则的世界!在 C++源代码被编译的早期阶段,编译器中负责“词法分析”的部分有一项任务,就是把源码输入流打碎成一个个地“单词”,或曰“词法单位”。当词法分析过程遇到一个形如“->*”的字符序列时,它可以同样合理地把它解释成3个词法单位(“-”、“>”和“*”)、2 个词法单位(“->”和“*”)或是单个 1 个的词法单位(“->*”)。为了摆脱此类多义性的局面,词法分析引入了取大优先解析原则,也就是总是能取多长的可以作为词法单位的字符序列就取多长:取大优先嘛。
表达式“a+++++b”是非法的,因为它被解析成了“a++ ++ +b”,但对像“a++”这样的右值应用后置自增运算符是非法的。如果你想把一个后置自增的a和一个前置自增b的相加,你至少要加一个空格:“a+++ ++b”。如果你哪怕考虑到了你的维护工程师一点点,你就会再加一个空格,尽管严格说来不是必要的:“a++ + ++b”。多加几个括号的话,也实在不会有谁抱怨你什么:“(a++) + (++b)”。
取大优先解析原则除了在两种常见情况下,多数都是作为问题解决者而不是制造者的形象出现。不过在这两种情况下,的确令人生厌。第一种情况是用一个模板具现化的结果型别来具现化另一个模板。举例来说,采用标准库里的元素的话,某软件工程师打算声明一个list,其元素是以string对象为元素的vector容器:
list<vector<string>> lovos; // 错误!
倒霉的是,在具现语法里两个相毗邻的右半个尖括号被解释成了一个右移位运算符,于是我们就犯了一个语法错误。空格在这种情况下是非加不可的:
list<vector<string> > lovos;
另一种情况是为指针型别的形参给予默认初始化值的时候:
void process(const char*=0); // 错误!
这个声明企图在形参列表里使用运算符*=。语法错误。这种错误属于“自作孽,不可活”——如果代码作者记得给形参一个名字,就根本不会犯这种错误。现在你明白了,给予形参名字不仅起了“自注释”的作用,同时也让取大优先解析原则带来的问题消弭于未现:
void process(const char *processId = 0);
就语言本身所限,声明饰词孰先孰后纯属无关紧要的形而上之争:
int const extern size = 1024; // 合法,但有离奇不经之嫌
无论如何,如果没有令人信服的理由去背离习惯用法,那么顶好还是接受有关声明饰词次序事实上的标准:先写连接饰词,再写量化饰词,再写型别。
extern const int size = 1024; // 正常下面这个指针的型别是什么呀?
int const *ptr = &size;
对,这是一个指涉到常量整数型别的指针。但你根本难以置信有多少软件工程师会把它误读成一个指涉到一般整数型别的常量指针 [24]:
int * const ptr2 = &size; // 错误!
以上是两种完全不同的型别。当然了,第一种指针型别可以指涉到一个常量整数型别,第二种不行 [25]。很多软件工程师会随口把“指涉到常量型别的指针”念成“常量指针”,这不是一个好习惯,它只会把你要表达的真实意思(“指涉到常量型别的指针”)传达给那些粗枝大叶之徒,而真正字斟句酌的称职后生则会被你的言辞误导(理解成“指涉到一般型别的常量指针”)。
当然需要承认的是,标准库里有一个字面上表示“常量迭代器”之意的const_iterator概念,它无可救药地实际上表示一个“指涉到常量元素的迭代器”,而这些迭代器自身却并不具常量性(标准委员会的家伙们某天吃错了药不是你要向他们学坏的理由)。仔细区分“指涉到常量型别的指针”和“常量指针”(参见常见错误31)。
由于声明饰词次序在技术层面上无关紧要,一个指涉到常量的指针可以以两种形式声明:
const int *pci1;
int const *pci2;
有些 C++专家比较提倡第二种书写形式,因为他们认为对于更复杂的指针型别声明来说,这种写法更容易读:
int const * const * pp1;
把量化饰词 const置于若干声明饰词的最后,这样我们就可以倒过来读所有的指针型别的饰词。从右到左,pp1的指涉物是一个常量(const)指针,后者指涉到一个整数常量(const int)。而习惯的饰词次序则不支持这样的平凡规则:
const int * const * pp2; // pp2的型别和pp1完全相同[26]
前一种饰词次序的安排也没有带来太多复杂性,一个 C++维护工程师若是在要阅读和维护的代码里存在这样的片段,他也应该是有能力搞定的。更重要的是,指涉到指针的指针或是其他类似的声明是较少见的,尤其少见于交由 C++新手打理的接口代码里。典型情况是,它们藏匿于基础实现码的深处。平凡的、直截了当的指涉到常量的指针就常见得多。所以,还是遵循习惯用法以避免误解较佳:
const int *pci1; // 正确:指涉到常量的指针
对象的默认初始化语句不应该写成一个空的初始化实参列表的形式,因为它会被解释成一个函数声明:
String s("Semantics,not Syntax! "); // 显式指定了用以初始化的实参
String t; // 默认的对象初始化语句 [27]
String x(); [28]// 一个函数声明
这是一个C++语言内廪的多义性。实践角度来说,语言“掷硬币”决定了x是一个函数声明 [29]。请注意,该多义性问题在new表达式中并不发作:
String *sp1 = new String(); // 这里没有多义性[30]
String *sp2 = new String; // 一样的意思
当然,第二种形式更好。因为它被更广泛地使用,和对象的声明语句也更具正交性。
内建数组不可能有常量性或挥发性,所以修饰它的型别量化饰词(const或volatile)的效果实际上会漂移,转而应用到其持有物的某个适当位置:
typedef int A[12];
extern const A ca; // 由12个常量整数型别元素构成的数组
typedef int *AP[12][12];
volatile AP vm; // 指涉到整数型别元素的挥发性指针构成的二维数组
volatile int *vm2[12][12]; // 指涉到挥发性整数型别元素的指针构成的二维数组
以上的解释合情合理,因为所谓数组,其名字的意义也不过就是指涉到其元素的指针。它本身并不占用存储,从而也谈不上什么常量性或挥发性这些和存储状态相关的概念,所以量化饰词的效果实际是应用到数组的元素上去了。不过要保持警惕,编译器经常对付不了太过复杂的情况。举例来说,vm的型别常常被编译器错误地解释成和vm2的型别是一样的 [31]。
对函数声明的处理方式比较含糊。过去,一般的 C++语言实现也允许相同的量化饰词效果漂移:
typedef int FUN(char *);
typedef const FUN PF; // 原先的情况:PF指涉到一个返回const int的函数
// 现在:非法
现在标准却说,应用于函数声明量化饰词只能用于声明一个“顶级”的typedef,并且这个typedef还只能用于声明一个非静态的成员函数 [32]:
typedef int MF() const;
MF nonmemfunc; // 错误!
class C{
MF memfunc; // 没问题
};
最好还是避免这种用法,当下的编译器并不能很好地理解它,而且它还会给维护工程师带来诸多困惑。
在以下的代码里,var的值变成了多少?
int var = 12;
{
double var = var;
// ...
}
未有定义。C++语言中,某个名字在它的初始化对象被解析到之前就进入了其辖域的话,在初始化对象引用到这个名字时,它引用到的不是别的,正是这个刚刚被声明的对象。没有几个软件工程师会写出像上面这么莫名其妙的声明代码,但也许复制、粘贴的手法会让你陷入困境:
int copy = 12; // 某深藏不露的变量
// ...
int y = (3*x+2*copy+5)/z; // 将y的赋值运算符的右手边操作数剪切……
// ...
void f(){
// 这里需要y的初始化值
int copy = (3*x+2*copy+5)/z; // 把上面的剪切内容粘贴到此
}
用预处理符号的话,你会犯和恣意复制、粘贴的行为完全一样的错误(参见常见错误26):
int copy = 12; // 某深藏不露的变量
// ...
#define Expr ((3*x+2*copy+5)/z);
// ...
void g(){
// 这里需要y的初始化值
int copy = Expr; // 噩梦重现
}
此问题的另一种表现形式就是命名时把型别的名字和非型别的名字弄混了:
struct buf{
char a,b,c,d;
};
// ...
void aFunc(){
char *buf = new char[sizeof(buf)];
// ...
那个局域里的buf很可能会获取4字节的内存,足够放置一个char *。这个错误可能会很久都校验不出来,尤其在型别struct buf和指针型别变量buf具有相同大小的时候 [33]。遵守一个把型别和非型名的名字区分清楚的命名约定就可以在这个问题上防患于未然(参见常见错误12):
struct Buf{
char a,b,c,d;
};
// ...
void aFunc(){
char *buf = new char[sizeof(Buf)]; // 没问题
// ...
}
现在我们知道怎么解决下面这样的问题了:
int var = 12;
{
double var = var;
// ...
}
但它的变形呢?
const int val = 12;
{
enum {val = val};
// ...
}
枚举量val的值是多少?未有定义吗?再猜一次。正确答案是其值为12,理由是枚举量的声明位置,与变量不同,是在它的初始化对象(严格地说,是枚举量的定义)之后的。“=”之后的那个val,是在外层辖域中的常量。这段讨论把我们带入了一个更错综复杂的局面:
const int val = val;
{
enum {val = val};
// ...
谢天谢地,这个枚举定义是非法的。其枚举量的初始化对象不是一个整数型别的常量,因为在以上情况下,编译器无法在编译期获知外层辖域中的那个val的值。
根本没有本条款名称所述的这类东西。但是,经验丰富的 C++软件工程师却常常写出好像把连接类型饰词应用于型别的声明语句,把刚入道的 C++新手带坏了:
// ...
static class Repository{
// ...
} repository; // 静态连接的
Repository backUp; // 不是静态连接的
也许确实可以说某种型别有连接类型,但是连接类型饰词却总是绑定到对象或函数,而不是型别的。如此说来还是写得清楚些好:
class Repository{
};
static Repository repository;
static Repository backUp;
需要提请注意的是,较之于使用连接类型饰词static,匿名名字空间可能是更好的选择:
namespace{
Repository repository;
Repository backUp;
}
名字repository和backUp现在有了外部连接类型,从而就能够比一个以连接类型饰词static修饰的名字在更多的地方(如模板具现化时)大显身手。而且,就像静态对象一样,它们在当前编译单元(translation unit)以外的地方是不可访问的 [34]。
重载的运算符真的只不过就是可以用中序语法调用的地地道道的成员函数或非成员函数罢了。它们是“语法糖”:
class String{
public:
String &operator =(const String&);
friend String operator +(const String&,const String&);
String operator–();
operator const char*() const;
// ...
};
String a,b,c;
// ...
a = b;
a.operator =(b); // 和上一个语句意义相同
a + b;
operator + (a,b); // 和上一个语句意义相同
a = -b;
a.operator =(b.operator-()); // 和上一个语句意义相同
const char *cp = a;
cp = a.operator const char*(); // 和上一个语句意义相同
如果要评选“最佳清晰奖”,那么中序记法必可荣膺。典型情况下,我们要使用一个被重载的运算符时都是用中序记法的(即“左手边操作数运算符右手边操作数”的写法)。毕竟我们之所以要重载运算符最原始的出发点不就是这个么?一般地,当我们不用中序记法时,函数调用语法比对应的中序记法更清晰。一个教科书般的例子就是基类的复制赋值运算符在派生类的复制赋值运算符实现中被调用的场合:
class A : {
protected:
A &operator =(const A &);
//…
};
class B : public A {
public:
B &operator =(const B&);
//…
};
B &B::operator =(const B&b){
if (&b != this){
A::operator =(b); // 好过"(*static_cast<A*const>(this))=b"
// 为B的其他局部变量赋值
}
return *this;[35]
}
还有一些场合我们使用函数调用语法而不用中序记法——尽管中序记法在这些场合的使用完全正确合理——中序记法在这些场合显得太怪异丑陋,会让一个维护工程师花几分钟才能回过神来:
value_type *Iter::operator ->() const
{return &operator*();} // 好过"&*(*this)"
还有一些让人左右为难的情况,不管中不中序,写出来的东西都挺难看的:
bool operator !=(const Iter &that) const
{return !(*this == that);} // 或者"!operator==(that)"
无论如何请注意,使用中序语法时的名字查找序列和使用函数调用语法时不同,这会带来出人意料的结果:
class X{
public:
X &operator %( const X&) const;
void f();
// ...
};
X &operator %(const X&,int);
void X::f(){
X& anX = *this;
anX % 12; // 没问题,调用非成员函数
operator %(anX,12); // 错误!
}
当我们使用函数调用语法时,名字查找序列遵从标准形式。在成员函数X::f的情况下,编译器首先在class X里找一个名字叫“operator %”的函数。只要找到了,它就不会在更外层的辖域里继续找其他同名的函数了。
不幸的是,我们企图向一个二元运算符传递 3 个实参。因为成员函数operator %有一个隐式的实参this,我们显式向它传递的2个实参会让编译器误以为我们想要把一个二元运算符以不正确的三元形式调用。一个正确的调用或者显式地识别出非成员版本的operator %(::operator %(anX,12)),或者向成员函数operator %传递正确数量的实参(operator %(anX))。
使用中序记法驱使编译器搜索了左操作数指定的辖域(那就是在class X里搜索,原因是anX具有X型别),于是找出了一个成员函数operator %,然后又找出了一个非成员版本的operator %[36],于是编译器找到两个候选函数,并正确地匹配到了其中的非成员版本。
内建的 operator ->是二元的,左手边的操作数是一个指针,右手边的操作数是一个class成员的名字。而重载版本的operator ->则是一元的:
gotcha24/ptr.h
class Ptr{
public:
Ptr( T *init);
T *operator ->();
// ...
private:
T *tp_;
};
对重载版本的operator->的调用,必须返回一个可以用直接或间接地调用内建的operator->访问其成员的对象 [37]:
gotcha24/ptr.cpp
Ptr p( new T );
p->f(); // 表示"p.operator ->()->f()"!
用某种视角来看,我们可以把实际发生的事理解成词法单位->没有被“吃掉”,而是保留下来“派真正的用场”,如同内建的 operator ->一样。典型地,重载版本的 operator ->被赋予了一些额外的语义,以支持“智能指针”型别:
gotcha24/ptr.cpp
T *Ptr::operator ->(){
if ( today() == TUESDAY )
abort();
else
return tp_;
}
前面说过了,重载版本的 operator ->必须返回支持成员访问操作的“某物”。此“某物”并非一定要是个内建的指针。它亦可以是一个重载了operator ->的class对象:
gotcha24/ptr.h
class AugPtr{
public:
AugPtr(T *init) : p_(init){}
Ptr &operator ->();
// ...
private:
Ptr p_;
};
gotcha24/ptr.cpp
Ptr &AugPtr::operator ->(){
if (today() == FRIDAY)
cout<<’\a’<<flush;
return p_;
}
这样就可以支持智能指针的级联应用(cascading)了:
gotcha24/ptr.cpp
AugPtr ap( new T );
ap->f(); // 实际上是"ap.operator ->().operator ->()->f()"!
请注意,operator ->的调用序列的触发(activation)总是由包含operator ->定义的对象 [38]静态决定的,而且该调用序列总是终结于返回指涉到class对象的内建指针的调用。举个例子,对AugPtr调用operator ->总是会触发以下调用序列:先是调用AugPtr::operator->,接着调用Ptr::operator->,再接着调用T *型别内建指针上的Ptr::operator->(若要检视一个更具实践参考意义的例子,请参见常见错误83)。
[1].译者注:这是一个很容易被忽略的重要补充说明。为什么要保证访问数组后的一个对象元素是能够做到的呢?这实际上是为了和for语句的迭代算子习惯用法相一致,也是STL的“前闭后开区间”习惯用法相一致。但是,如果用指针指涉到了这样的一个位置,它是不能被提领的。有关这个问题更详细的说明,参见(Stroustrup,2001),§5.3。
[2].译者注:也不用显式地申请内存。
[3].译者注:i先评估求值。
[4].译者注:i *= 2先评估求值。
[5].译者注:这里提到的问题是特别值得国内的软件开发从业人员思考的,把即使是看起来不相关的两个函数的返回值作为另一个函数的实参的值这样司空见惯的事都存在着一个隐含着的评估求值次序问题,从而会给未来带来隐患。无论这种隐患是不是会实际发生,都必须在文档中写清楚做了这样的一个假设,这样才能说是专业的行为。这也才是国外的软件工业真正值得我们花大力气去学习的地方——不仅要学习如何用代码实现某个功能,更重要的是在无数的细节方面保证代码的质量,也正是这些方面拉开了我们的差距,要迎头赶上就必须从这些方面做起。
[6].译者注:如果需要把i的值乘以2,可以在前或后插入i*=2。
[7].译者注:这样就保证了p在q之前被调用,隐患被消除了。
[8].译者注:原文是 temporaries,为了不和 C++中的临时变量概念冲突,此处译为中间变量,意为软件工程师而不是编译器指定的具有临时作用的变量。在此处,显然它有改变语义的语法作用。
[9].译者注:测试用例可谓用心良苦!把可能的路径都考虑到了并覆盖。
[10].译者注:一般而言这是传递了一个地址,这时 operator new 不去做申请内存的工作,而是假定内存已经分配好了。不过,这只是标准的定位 new 的定义,它的函数声明大致形如“void* operator new(std::size_t,void*pMemory) throw();”。定 位new的一般定义里,这个operator new的额外实参并不一定是地址,而可能是任何东西,也可能不止一个实参,本例就是如此。有关定位new的更多信息,参见(Meyers,2006),条款 52和(Sutter,2003),条款36。后一篇参考文献非常晦涩难懂,但仔细读过一定受益匪浅。
[11].译者注:请特别注意,这是一个实参列表,而不是一个逗号表达式。
[12].译者注:甚至有可能交叉进行评估求值。
[13].有关这个问题的更深入讨论,参见(Meyers,2007),条款7和(Sutter,et al.,2005),条款 30。
[14].译者注:这被称为Schwarz问题,参见(Lippman,2001),§ 2。
[15].译者注:这些是C++语言里不常用的声明语法,牢记。
[16].译者注:指涉到成员的指针除了包括一个运算符,还有一个名字。
[17].译者注:此错误发生在运行期,编译期校验不出来,所以更不好。有关这个议题,请参见(Meyers,2001),条款46。
[18].译者注:即右半边的大括号处。
[19].译者注:此处意为更具一致性,比如和if语句的情况就一致了,语言不应该允许“语法天窗”。
[20].译者注:编译器的警告表示被校验的代码虽然语法上是语言允许的,但是往往反映了语法或语义上违反了公认的习惯用法,或是落入了常见错误的圈套,很可能包含着语义错误——语法错误给出的就不是警告,而是产生会中常见错误16:for语句引发的理解障碍止编译的错误了。警告往往意味着:“软件工程师这么写往往表示他没有意识到他在犯错,你最好回头检查一下你的代码,确保你真的就是想表达你现在的代码将要产生的语义——它确实合法并且有一个语义,但大多数情况下这个语义不是人们想要的。你如果看也不看,你将来很可能吃苦头。”一句话,编译错误反映了语法层面上的错误,编译警告反映了语义层上的潜在错误。后者更微妙,也更难调试,所以一定要重视起来。有关这个问题,参见(Meyers,2006),条款 53。
[21].译者注:这是作者想要强调的重点,这也是为什么他说这是C代码——C语言里不允许在普通语句后声明变量,只能在代码起始处声明。
[22].译者注:continue语句使循环语句执行流直接跳回了循环体的第一句重新开始执行,对于for语句而言i会自增,而while语句中i的自增就被跳过了,所以循环就始终不能结束。这种微妙的区别可能是for语句和while语句唯一的区别,软件工程师要特别留心。有关continue语句的进一步说明参见(Eckel,2002),§3.2.6或(Pohl,2003),§9.7。
[23].译者注:中士军衔的肩章是“三道杠”,此处为不合习惯用法写码风格的讽刺说法。
[24].译者注:其实判定一个指针是指涉到常量还是本身是常量,唯一要看的就是const关键字位于声明语句的星号前面还是后面——若是在其前,就说明它指涉到常量,否则说明它本身是常量。有关这一点,参见(Meyers,2006),条款3。
[25].译者注:初学者一定不能跳过这句话,而要问自己一句:为什么第二种指针型别不能指涉到一个常量整数型别?经过观察和思考,就会得出结论:尽管指针本身是常量,它被初始化以后再不能被修改了,但它指涉到的内容却是允许修改的,而这就违反了它预备指涉到的内容原有的常量性,因而会被语言拒绝。如果还不清楚,建议阅读(Stroustrup,2001),§ 5.4.1。本书中对此问题亦有展开,参见常见错误31。
[26].译者注:本书作者显然在后来的岁月里改变了他的有关饰词次序的看法,变得更加包容。他在本书里把两种饰词的次序中的一个打上了“不推荐”的烙印,显然觉得两种用法里一种优于另一种。但他在另一本比较晚近出版的书中谈到这个问题时,就表示两种用法选择哪一种“无关紧要”了。参见(Dewhurst,2006),条款7。而Scott Meyers在谈到量化饰词应该放在型别前还是后时,则更直截了当地说“你应该同时适应两者”,参见(Meyers,2006),条款3。根据这些材料及其变迁的历史轨迹,我们可以说,读别人的代码时,不应该误读,而自己在撰写代码时则纯属风格问题,可以根据自己的理解方向和喜好来选择一种,并固定下来,在编码实践中沉淀为自己的代码风格的一部分。
[27].译者注:对象名后不带括号。
[28].译者注:这里声明了一个不带实参并返回String型别的函数,可能违反代码作者本意。
[29].译者注:这里作者没有展开说,其实想避免这样的多义性,即确定 x 是一个函数声明而不是一个默认的对象初始化语句的方法是显式地在实参列表里写一个“void”,即把最后一句写成“String x(void);”即可。参见(Lippman,et al.,2006),§ 7.1.2。另外,这个多义性问题也不仅仅表现于默认的对象初始化语句和空函数形参表这种情况,只要是函数形参的名字被省略的情况都有可能引起这种多义性。一个例子可以参见(Meyers,2003),条款6。这也从另一个侧面让我们认识到函数形参的名字不被省略的重要性,参见常见错误1和17。
[30].译者注:出于一致性考量不推荐。
[31].译者注:上面这个多维数组的例子,是比较典型的。我们去读,可以比较清楚地了解到:AP是一个指涉到一般整数型别的指针构成的数组,所以数组的元素型别是指针型别,后来的volatile饰词效果是加到了指针型别上的,相当于“int * volatile vm[12][12]”。但对编译器的实现而言,在巨大的工作量下,这一个语言细节很有可能被误解。有关复杂指针、数组和函数交织在一起的声明,比较简略的说明参见(Dewhurst,2006),条款17;一个完整的说明和手工分析的方法参见(Kernighan,et al.,1997),§ 5.12,但后者并未涉及量化饰词的讨论。
[32].译者注:参见(Koenig,1996),§8.3.5,实际上这就禁止了对typedef声明的函数型别再使用任何量化饰词。
[33].译者注:但是移植时一定会出问题。
[34].译者注:相对于本书作者对匿名空间中的对象连接类型为外部连接的斩钉截铁,Bruce Eckel似乎在这个问题上犹豫不定,引述他在(Eckel,2002),§10.2.1.1中的一段原文:“If you put local names in an unnamed namespace,you don’t need to give them internal linkage by making them static.”,这句话的大陆中译本译文原文是“如果把一个局部名字放在一个未命名的名字空间中,不需要加上说明就可以让它们作内部连接。”如果按这种理解,那么 Bruce Eckel 就在连接类型的认识上就是有一个明确判断的。但Bruce Eckel的原文也可以理解为:“如果将一个局部的名字放置在一个匿名名字空间内,你就不需要再为其指定连接类型饰词 static 以设定其连接类型为内部连接。”关键在于隐式说明的部分是“只需要将一个局部的名字放置在一个匿名名字空间内,而不是通过为其指定连接类型饰词static,就可以设定其连接类型为内部连接”,还是“不需要设定其连接类型为内部连接,亦即它可能是外部连接也可能是内部连接,但无论如何它都像内部连接一样工作”。后一种理解很可能是更符合标准的,参见(Koenig,1996),脚注78,引述原文“Although entities in an unnamed namespace might have external linkage,they are effectively qualified by a name unique to their translation unit and therefore can never be seen from any other translation unit.”这里用了情态动词might,意指不确定的判断。又见(Stroustrup,2001),§9.2,引述原文“An unnamed namespace(§8.2.5)can be used to make names local to a compilation unit.The effect of an unnamed namespace is very similar to that of internal linkage...Having a name local to a translation unit and also using that same name elsewhere for an entity with external linkage is asking for trouble.”这段话仍然没有一个明确的说法,但从匿名名字空间本身的常识来说,无论匿名名字空间里的名字所指涉到的实体是不是具有外部连接类型,在一个编译单元中又怎么能够获知另一个编译单元中的匿名名字空间里的名字呢?不管怎样,使用关键字static作为连接类型饰词可能才是一个糟糕的主意,这一点在(Stroustrup,2001),§ B.2.3中倒是已经明确地把它标为“受贬斥的,不再推荐使用的”语言特性了。
[35].译者注:返回*this是一个习惯用法,支持连续赋值。
[36].译者注:使用中序记法会搜索包括成员与非成员的对应运算符重载,这是两种记法会引起的名字查找过程的不同,也是它们唯一的本质差异。这个问题的更深入讨论参见(Lippman,et al.,2006),§ 14.9.5。
[37].译者注:如果不是这样,岂非调用该运算符的形式就成了“Ptr p(new T); p->;”?
[38].译者注:不一定非得是class对象,也可以是一个内建指针。
预处理可能是 C++代码编译过程中最为危机四伏的阶段(phase)。预处理器只扫语汇块(token,构造出C++源代码的“单词”)的门前雪,对于C++语言其余部分的精巧结构却不闻不问,无论在词法还是语义的意义上讲都如出一辙。事实上,预处理器对它自身的蛮力并无清醒意识,如同其他大而无脑的东西一样,它能造成可怕的破坏。
本章中所提的建议欲让预处理器去执行那些只需蛮力而与 C++语言没有太大干系的任务,而若是要完成的工作是个细活,还是免用为佳。
// ...
使用 C++语言的软件工程师不会用#define来定义字面量,因为在 C++语言中,这种用法会导致软件缺陷和可移植性问题。考虑一个典型的 C 风格的#define用法:
#define MAX 1<<16
有关预处理器符号(preprocessor symbol)的最基本的问题在于在C++编译器本身有机会检视它们之前,展开动作就已经完成。而预处理器对于C++的代码辖域和型别规则完全浑然无知:
void f( int );
void f( long );
// ...
f( MAX ); // 调用的是哪个f呢?
当编译器执行重载解析的时刻,预处理器符号 MAX仅仅是整型量 1<<16。而1<<16作为一个值既可以是int型别,也可以是long型别,这依赖于编译代码时的目的平台。把这段代码拿到另一个平台上去编译,完全可能会调用到重载函数的不同版本。
#define 预处理器指令完全没把 C++的代码辖域纳入考量。当下,绝大多数的 C++基础设施都是封装在名字空间里的。这样的做法有很多优点,但哪一个都比不上不同的设施之间不会相互影响来得大。不幸的是,#define的辖域并未被限定在名字空间中:
namespace Influential { [1]
# define MAX 1<<16
// ...}
namespace Facility {
const int max = 512;
// ...
}
// ...
int a[MAX]; // 哎呀,糟糕!
这个软件工程师忘记把名字max汇入,而且把它误拼成MAX了。不管怎样,预处理器把MAX替换成了1<<16,于是代码稀里糊涂地通过了编译。“我咋会用了这么多内存呢……”对于此类问题的一个必杀技,当然,是使用一个初始化了的常量:
const int max = 1<<9;
这么一来,max的型别就在所有的平台上全都一样了,而且名字max也遵循着惯常的辖域规则。请注意,使用max很可能和使用#define同样高效。因为编译器被赋予了自主权,能够在max被用作右值时使用其初始化值代替它本身,以将其占用的存储优化掉 [2]。但无论如何,max是一个左值(它只是碰巧是一个不可修改的左值,参见常见错误 6),它有一个地址,而且我们能够指涉到它。而这对于字面量来说是不可能的:
const int *pmax = &Facility::max;
const int *pMAX = &MAX; // 错误![3]
另一个#define 字面量带来的问题是有关词法分析方面的,这里且不提预处理器在做替换时的句法方面的问题渊源。如上,我们使用#define 定义的MAX还没有引起什么问题,不过如果像下面这么做可就难说了:
int b[MAX*2];
对,因为没有给定义的表达式加上括号,我们实际上在尝试声明一个硕大无朋的整型数组:
int b[ 1<<16*2 ];
我们得承认,这个错误源自于没有好好组织#define 的内容。不过,这种错误在使用相应的初始化过的常量时是没有机会现身的。
在class的辖域问题上也有同样的毛病。这里,我们想让某个值仅在class辖域里能被取得,其他任何地方都不可以。传统的 C++给出的解决方案是使用枚举量:
class Name {
// ...
void capitalize();
enum { nameLen = 32 };
char name_[nameLen];
};
枚举量nameLen不占用存储,有着合式的型别,而且仅在class辖域里能被取得——这当然也包括该class的所有成员函数:
void Name::capitalize() {
for( int i = 0; i < nameLen; ++i )
if( name_[i] )
name_[i] = toupper( name_[i] );
else
break;
}
在class内部声明静态整型常量数据成员,并以整型表达式初始化之也是合法做法,但还没有被广泛支持 [4](参见常见错误59):
class Name {
static const int nameLen_ = 32;
};
// ...
const int Name::nameLen_; // 这里不能写成初始化语句!
但是静态常量数据成员所占用的存储可能不会被优化掉,对于平凡整型常
量来说,使用传统枚举量的手法是较佳的选项。
int kount = 0;
C语言中,#define经常被用来定义伪函数——当避免函数调用的开销带来的效率的重要性被置于安全性之上时:
// ...
#define repeated(b,m) (b & m & (b & m)-1)
// ...
无须多说,所有使用预处理器时的警告在这里都仍然有效。具体而言,上面这个定义是颇有瑕疵的:
void aFunc() {
typedef unsigned short Bits;
extern void g();
enum { bit01 = 1<<0,bit02 = 1<<1,bit03 = 1<<2,// ...
Bits a = 0;
const Bits mask = bit02 | bit03 | bit06;
// ...
if( repeated( a+bit02,mask ) ) // 哎呀,错了!
这里,我们在未给伪函数的定义式充分加上括号的方面重蹈覆辙。正确的定义不会给任何可能的(由于缺少括号而带来的运算符结合性方面的)错误一点机会:
#define repeated(b,m) ((b) & (m) & ((b) & (m))-1)
当然,除了副作用之外。另一个看似得体的伪函数应用就会同时遭遇错误结果和多义性:
if( repeated( a+=bit02,mask ) ) // 祸不单行
// ...
该伪函数的第一个实参有副作用 [5]。如果repeated是一个真正的函数,在它被调用前,该副作用会准确地只发生一次。但对于repeated现在这个特定的定义来说,副作用被以未定次序执行了两次(参见常见错误14)[6]。伪函数的特别危险之处在于它们的使用和真正的函数别无二致,但其语义却有天壤之别。正因为它和真正的函数的这种形似,使得C++高手有时也会在伪函数前马失前蹄,因为他们不假思索地认为自己在调用真正的函数。
在C++语言中,inline函数几乎在任何时候都是相对于伪函数而言更佳的选择。因为它才展现了函数调用的适当语义,它和非 inline 函数(non-inline function)在语义上具有相同的含义:
inline Bits repeated( Bits b,Bits m )
{ return b & m & (b & m)-1; }
宏被用作伪函数时,仍然饱受将其用于显式常量时所遭遇的辖域问题之苦(参见常见错误25)[7]:
gotcha26/execbump.cpp
int kount = 0;
#define execBump( func ) (func(),++kount)
// ...
void aFunc() {
extern void g();
int kount;
while( kount++ < 10 )
execBump( g ); // 对局部变量(而非全局变量)kount做了自增操作!
}
调用execBump的软件工程师一点都没有意识到(但愿如此)它实际上只是引用到了某个名字拼写成kount的变量 [8],结果一不小心就修改了局部变量kount的值,而不是全局的那个。更好的做法肯定是使用真正的函数:
gotcha26/execbump.cpp
inline void execBump( void (*func)() )
{ func(); ++kount; }
通过使用inline函数,在函数体被编译的时刻,标识符kount就和那个全局变量(而不是局部变量)kount 绑定了。在函数被调用时,这个名字不会和另一个 kount变量再绑定一次(不过,我们还是用了全局变量,想知道这么做会带来的问题,参见常见错误3)。
更好的解决方案也许是使用函数对象来为计数过程做更漂亮的封装:
gotcha26/execbump.cpp
class ExecBump { // 单态设计模式。参见常见错误69
public:
void operator ()( void (*func)() )
{ func(); ++count_; }
int get_count() const
{ return count_; }
private:
static int count_;
};
int ExecBump::count_ = 0;
// ...
ExecBump exec;
int count = 0;
while( count++ < 10 )
exec( g );
}
伪函数的正确用法比较罕见,而且往往和预处理器符号_ _LINE_ _、_ _FILE__、_ _DATE_ _或_ _TIME_ _难解难分:
gotcha26/myassert.h
#define myAssert( e ) ((!(e))?void(std::cerr << "Failed: " \
<< #e << " line " << _LINE_ << std::endl): void())
有关这个断言宏,参见常见错误28。
void buggy() {
void operation() {
#if用于调试
我们怎么向程序中插入一些调试代码呀?所有人都知道要用预处理器:
ServerData x;
void buggy() {
#ifndef NDEBUG
// 一些调试用的代码
#endif
// 一些生产代码
#ifndef NDEBUG
// 另一些调试用的代码
#endif
}
……但是都错了。软件工程战线上的老兵油子们都会滔滔不绝地翻炒一些当年勇,说什么调试版本的程序运行得毫无问题,但是“顺手”定义一个符号NDEBUG就会使生产环境的代码莫名其妙地罢工。
这一点都不奇怪。我们事实上是在讨论两个毫不相干的程序,只不过它们刚巧是由同一个源代码文件生成的罢了。就算只是为了看看有没有句法错误,你就得把同一份源代码编译两次才成。写代码的正道是把调试版本的想法老老实实地融入到你写出来的东西里,而且要写就只写一个单独的程序:
#ifndef NDEBUG
void doit();
if( debug ) {
#endif
// 一些调试用的代码
}
}
// 一些生产代码
}
if( debug ) {
// 另一些调试用的代码
}
}
那么,把调试代码留在由生产环境的代码最终生成的可执行文件里也不要紧吗?那样不会浪费空间吗?这些非必要的条件分支不会带来时间开销吗?不会的——如果调试代码在最终生成的可执行文件里根本就不存在的话。编译器的拿手好戏就是识别和剔除无用的代码。它们在这方面能做到的可比我们可怜巴巴地想用#ifndef实现的好太多了。我们要做的一切不过是让编译器别无选择:
const bool debug = false;
表 达 式 debug 是 C++ 标 准 中 所 谓 整 型 常 量 表 达 式(integer constant-expression)。任何C++编译器都必须在编译期对整型常量表达式做好评估求值,以期用于数组尺寸界定表达式(array bound expression)、case标签 [9]和位域长度(bitfield length)等。任何合格的编译器都具备将如下不可达代码(unreachable code)(从最终生成的可执行文件里)剔除的能力:
if( false ) {
// 不可达代码
}
没错,就连 5 年前你向你老板抱怨过的那个老掉牙的编译器搞定这个都不成问题。尽管编译器会剔除这些不可达的代码,但它还是会对这些代码做一次完整的语法分析和静态语义校验。
根据标准中有关常量表达式的定义,编译器甚至可以把使用了更复杂的表达式来做判断的代码都消灭干净:
if( debug && debuglvl > 5 && debugopts&debugmask ) {
// 有可能不可达的代码
在执行代码剔除方面,编译器有可能连更复杂的情况都能对付。比如,我们可能把我最中意的inline函数作为条件表达式的一部分:
typedef unsigned short Bits;
inline Bits repeated( Bits b,Bits m )
{ return b & m & (b & m)-1; }
// ...
if( debug && repeated( debugopts,debugmask ) ) {
// 有可能不可达的代码
error( "One option only" );
无论如何,因为涉及到一个函数调用(不管是不是inline函数),整个表达式就不再是一个常量表达式了。是故,编译器能不能在编译期对其评估求值是不能保证的,所以代码剔除有可能不会发生。如果这里你硬要做代码剔除,就没有一个可移植的办法。一个在C 语言里摸爬滚打了太久的软件工程师可能会建议你这样写:
#define repeated(b,m) ((b) & (m) & ((b) & (m))-1)
别理他们(参见常见错误26)。
请注意,在程序里放些条件编译的代码有时也有可取之处,像下面这样就可以在编译时取得常量的值:
const bool debug =
false
#else
true
;
连这样最小化的条件编译代码其实也并无必要。通常来说,更好的做法是通过makefile或类似的基础设施来选择使用调试代码还是生产代码。
#if用于可移植性代码
“无论如何,”你一脸自以为是地说,“我的代码想做成平台无关的(platform independent)。因而我不得不用#if来满足不同平台的需求。”为了证明你的观点,你拿出了以下的代码:
void operation() {
// 一些可移植的代码
#ifdef PLATFORM_A
// 做一些(在A平台上才能做的)操作
a(); b(); c();
#endif
#ifdef PLATFORM_B
// 做一些(在B平台上才能做的)操作
d(); e();
#endif
}
这段代码并不是平台无关的,而只不过是多平台依赖的(multiplatform dependent)。任何一个平台上的任何一点改动都不仅会要求整个源代码重新编译,而且所有平台上的源代码全得一起改掉。你实现了跨平台的最大耦合度,这还真是一个令人瞩目的改进哩,然而却并没有任何正面的作用。但这还只是潜伏在的 operation 实现中的真正问题中无关痛痒的一小部分。函数即抽象。函数operation是对于同一操作在不同平台上的不同实现的抽象。当我们使用高阶语言时,我们通常会使用同样的源代码来实现不同平台上的同一抽象。举例来说,令a、b和c为int型别的变量,表达式a = b + c会对于不同的处理器有不同方式的呈现,但该表达式的意义却在不同的处理器上都相当接近,是故我们一般地可以在所有的平台上都使用相同的源代码。这也不是放之四海而皆准的,尤其是当我们的操作必须以操作系统或特定库相依的方式定义时就更是如此。
函数operation的实现指明了在它所支持的两个平台上应该做“同一”的操作,也可能在一开始的确如此。
随着代码维护的推进,缺陷报告倾向于只在其中某个特定的平台的情形下被提交和修正。仅仅过了一段短短的时间,operation 在不同平台上意义就会渐行渐远,这下子你实际上就是在不同的平台下做着不同的操作。注意,这些不同行为可是必要的,因为用户们会依赖于平台相依的operation代表的意义。如果要一开始就写出operation的正确实现,那肯定是通过一个平台相依的接口来为不同的平台提供不同的实现:
// 一些可移植的代码
doSomething(); // 可移植的接口
}
通过显式的抽象,在维护的过程中不同平台保持operation的意义不变的希望就大大增加了。函数doSomething的声明可以放在各种平台相依的那部分源代码中(如果doSomething是个inline函数,则可以放入平台相依的头文件中)。选择平台的机制由makefile而不是#if来管理。请注意,不管是增加还是去掉某个特定的平台,现在都不要求任何源代码更改。
那么classes呢?
和函数一样,class 亦抽象。抽象在不同的编译期和运行期可以有不同实现,取决于实现的具体情况。和函数一样,使用#if 来改动 class 在不同平台下的实现令人作呕,而且荆棘遍野:
class Doer {
# if ONSERVER
ServerData x;
# else
ClientData x;
# endif
void doit();
// ...
};
void Doer::doit() {
# if ONSERVER
// 做服务器端的操作
# else
// 做客户端的操作
# endif
}
严格地讲,这段代码是非法的,除非符号ONSERVER同时在不同的编译单元有定义,或同时无定义。但有时非法反而是好事。把数个不同版本的Doer的定义分散在不同的编译单元,尔后再把它们无误地链接起来才是普遍情况 [10]。运行时的错误通常是让人费解的,也很难跟踪。
幸甚至哉,这种会引起软件缺陷的技术现在已不像过去那么大行其道。最明显的用于表达此类灵活性的做法就是使用多态:
class Doer { // 平台无关
public:
virtual~Doer();
virtual void doit() = 0;
};
class ServerDoer : public Doer { // 平台依赖
};
class ClientDoer : public Doer { // 平台依赖
void doit();
ClientData x;
};
现实世界的考察
我们考察了数个相当平凡的使用同一份源代码来表示不同程序的尝试。通过这些平凡的例子我们了解到,使用习惯用法和设计模式来重构源代码,使之具有更好的可维护性才是最直截了当的有效做法。
不幸的是,现实情况经常更糟糕,更为复杂。典型情况是,源代码并不是由独一个符号(如NDEBUG)作为改变行为的实参,而是被若干符号共同控制的。这些符号中的每一个都还有若干个不同的可能取值,它们还有可能被用作组合。一如前示,每个符号及其取值的组合都会向程序中添加必要的抽象行为,而且一个都不能少。从实践视角来看,就算有可能把程序受控于这些符号的行为进行分解,这样的源代码重构也不可避免地至少会在一个平台上带来软件行为的改变。
无论如何,这种重构迟早会变得非做不可,因为程序的抽象意义无法很容易地通过上百个符号的及其值的组合来判定,更不用说如果连句法正确性都不能一目了然的情况下会怎么样了。避免使用#if作为源代码的版本控制才是正道。
我的确对#define 的很多种用法都深恶痛绝,唯对定义在<cassert>中的assert宏情有独钟。说句实话,我鼓励大家多多使用它——前提是用好它。但问题就在于能不能用好它。
实现的方式固然百家争鸣,不过assert宏多数情况下和下面的定义相差不远:
gotcha28/myassert.h
#ifndef NDEBUG
#define assert(e) ((e) \
? ((void)0) \
: assert_failed(#e,FILE ,LINE ) )
#else
#define assert(e) ((void)0)
#endif
如果NDEBUG有定义,那我们就没有在调试模式下,assert宏就会展开成一个空操作(no-op)。否则,我们就处在调试模式下,在此特定实现中,assert宏就会展开成一个条件表达式以对某特定条件进行谓词测试。若该条件测试结果为 false,则我们生成一条诊断信息并调用 abort,以无条件强制终止程序运行。
使用 assert 宏优于使用注释来文档化前置条件、后置条件及不变量(invariant)。一条assert宏,在生效时,会对执行特定条件来个运行时校验,所以不会被轻轻松松地被当作一个注释而被无视(参见常见错误1)。
与注释不同的是,由于违反了assert宏的正确性校验的错误通常来说都被更正了,因为“调用 abort”这种后果会使得“代码需要维护”一事变得迫在眉睫,必须马上处理了。
gotcha28/myassert.cpp
template <class Cont>
void doit( Cont &c,int index ) {
assert( index >= 0 && index < c.size() ); // #1
assert( process( c[index] ) ); // #2
// ...
}
在上面这段代码中,我们演示了几个使用assert宏的过程中犯下的用法错误。标了#2 的那行代码是明显的误用,因为我们在调用一个函数,而这个函数被放到assert宏中去以后可能会有其副作用。这段代码的行为会随着NDEBUG符号有否被定义而有本质的不同 [11]。这种assert宏的用法会导致在调试模式下代码行为完全正确,而把调试模式关掉后原有的软件缺陷就复现了。于是你会又打开调试模式,缺陷又消失了。然后你再关掉调试模式,结果……(死循环!)
标了#1的那行代码错得更微妙。Cont class的成员函数size很有可能是一个常量成员函数,是故,它不会有副作用,对吗?错!除了size这个名字的习惯意义之外,我们找不到任何理由来保证该成员函数具有常量语义。就算它真的是常量成员函数,也不能保证对它的调用就对代码的行为没有副作用。再退一步讲,就算执行完size函数后c的逻辑状态没有改变,它的物理状态仍然可能发生了变化(参见常见错误82)。最后,我们可不要忘了使用assert宏就是为了找出代码缺陷来。即使调用size函数的本意并非要向代码行为中引入什么可觉察的变化效应,它的实现仍然可能包含缺陷,使得这种效应出现。我们当然希望对assert宏的使用会将代码缺陷暴露于光天化日,而不是将它们藏匿起来。正确的assert宏的用法会避免其条件语句带有任何潜在的副作用:
template <class Cont>
void doit( Cont &c,int index ) {
const int size = c.size();[12]
assert( index >= 0 && index < size ); // 正确
// ...
}
显然,assert宏并非万金油,但它的确在位于注释和异常之间的某个位置扮演了代码文档化及捕捉非法行为的适当角色。其最大问题在于它到底是一个伪函数,是故它也无可避免地带着前面条款中描述的有关伪函数的种种先天不足(参见常见错误26)。好在它是一个标准化了的伪函数,这也就暗示着其不足之处已为世人熟知。只要使用时多长个心眼,assert宏就能为我们造福。
[1].译者注:该名字空间的意思是“会施加影响的”。
[2].译者注:编译器的这种称为常量折叠的优化能够让一些不良代码的问题暴露,参见常见错误32。
[3].译者注:&1<<16是非法的。
[4].译者注:现在所有的主流编译器都已经支持了这一做法。
[5].译者注:即在调用函数前对a的第二位做了带进位加一的操作。
[6].译者注:有关#define和函数副作用互动带来的负面影响的更深入讨论,参见(Kernighan,et al.,2002),§1.4。
[7].译者注:看来作者还是把伪函数和宏看成不同的概念的,宏的意义在于它也可以被展开成为非函数形式的结果,其实宏的概念是包含了伪函数的。
[8].译者注:纯粹是字符串替换,和辖域没有任何关系。不会因为#define在全局辖域,就只引用到全局辖域中的那个kount。
[9].译者注:用于switch表达式,参见常见错误7。
[10].译者注:作者的意思是说,这里只要有无定义的情况不一,就会引发编译期的错误,这反而有利于调试。
[11].译者注:如果 NDEBUG宏未被定义,这段代码就等于完全把调用 process函数的语句从代码中屏蔽掉了。有没有调用process函数,可能会对代码的行为产生重大影响。作者想强调的就是使用assert宏和使用普通的if语句的重大区别就在于前者在非调试模式下把条件语句本身完全屏蔽而不执行了,后者则无论如何都会执行它,而只不过是根据计算结果来决定其他的语句是否执行罢了。由此我们可以得出结论:如果条件语句本身会影响代码的行为,就不应该使用把它当作assert宏的实参。否则很有可能会出现下文所描述的“先有鸡还是先有蛋”的佯谬。
[12].译者注:避免了size未被调用的潜在副作用。
C++语言的型别系统相对于其包罗万象而言,其复杂程度可谓相得。这种与生俱来的复杂性由于以下的事实而变本加厉:在编译代码的过程中,用户自定义的型别转换运算符一旦存在,就有可能被隐式调用。以成效论,通过为 C++语言增加抽象数据型别的手法来延拓其本身的手法意味着软件设计工程师被赋予了设计有效的、安全的和互洽的(coherent)型别系统之职责。C++语言很大程度上是静态型别主导的(largely statically typed),是故,卓有成效的设计就能够化此复杂性于初现。
不幸的是,糟糕的编码能让最互洽的型别设计也无法如期运作。在本章里,我们就将考察一些常见的能够击溃静态型别安全性(static type safety)的编码实践。我们也将考察对于 C++语言的一些能够导致静态型别安全性隐患的常见误解。
连C语言的软件工程师都明白,使用型别转换时,以void *为中介型别是个次等选项,能不用就别用。因为在强制型别转换中,转换到void *型别的结果会将带型别的指针(typed pointer)的型别信息悉数抹除。在典型情况下,只要以void *为型别转换的中介型别,则软件工程师必须“牢记”这已经被抹除了型别信息的指针的原始型别信息,并适时地再通过一次强制型别转换恢复它。如果在后来的这次强制型别转换中,供应了正确的指针原始型别信息,那么天下太平(只是,当然,必须得由人来记住上次转制型别转换时抹掉的型别信息的话,这昭示了这份软件设计亟需改进)。
void *vp = new int(12);
// ...
int *ip = static_cast<int *>(vp); // 能挣扎着运作一阵子
不幸的是,连这种貌似平凡的void *用法也会为可移植性缺陷大开方便之门。记住,我们如果不得不进行强制型别转换时使用static_cast来进行相对安全和可移植的型别转换。比如,我们会使用static_cast把一个基类型别的指针转化成一个使用 public 方式派生于之的型别的指针。对于非安全的、平台相依的型别转换,我们除了使用reinterpret_cast之外别无选择。举例来说,我们可能使用reinterpret_cast把一个整数型别的变量转化成一个指针,或是把指涉到一种型别的指针转化成一个指涉到一种不相干的型别的指针。
char *cp = static_cast<char *>(ip); // 错误!
char *cp = reinterpret_cast<char *>(ip); // 合法
在代码中使用reinterpret_cast是对使用和维护它的软件工程师发出的一个明确信号,那就是这段代码不仅在进行一个强制型别转换,而且这个转换未有可移植性方面的深思熟虑。而若是以void *作为型别转换的中介型别,这个至关重要的警告 [1]就被扼杀了。
char *cp = static_cast<char *>(vp);
// 把指涉到int的指针的地址放入了一个指涉到char的指针!
还有更糟的情况。考虑某用户界面系统的实现,它允许把某种“窗口组件”(Widget)的地址先存储起来以备后用:
typedef void *Widget;
void setWidget( Widget );
Widget getWidget();
使用这个接口的软件工程师会发现他们要想使用这个库的话,就必须用脑子记住存储的Widget指涉到的对象的真实型别,这样他们才能在想要取用该Widget指涉到的对象时恢复其型别。
// 某个头文件里定义的接口
class Button {
// ...
};
class MyButton : public Button {
//[2]
// ...
};
// 另一个文件里的代码
MyButton *mb = new MyButton;
setWidget( mb );
// 完全不相干的另一段代码
Button *b = static_cast<Button *>(getWidget()); // 也许可以运作!
上面这段代码通常可以运作,尽管我们在提取(extract)Widget 时损失了部分的型别信息。在利用Widget作为中介型别来指涉到存储的对象时,我们明明是让它指涉到一个MyButton型别的对象的,但是在提取时,却是按照一个 Button 型别来提取的。这样的代码能够运作的原因是和一个class对象在内存中的可能布局是有关的。
典型情况下,派生类对象把其基类子对象的存储放在其偏移量为0的位置,这么一来就好像派生类对象中的基类子对象部分是其第一个数据成员似的,而所有派生类所包含的其他成员则直接附在基类子对象之后,如图4-1所示。是故,派生类对象的地址通常等同于其基类子对象之地址(请注意,C++标准只保证以 void *为中介型别来存储的地址在设置和提取时使用同一型别,才能获取正确的结果。上面这段代码即使在单继承条件下也有可能会无法运作,参见常见错误70)。
不过,这段代码不堪一击。在维护的过程中,一个小小的改动就会引入一个缺陷。具体来讲,一个直截了当、正大光明的、把单继承更改为多继承的改动就能让这段代码彻底崩溃:
//某个头文件里定义的接口
class Subject {
// ...
};
class ObservedButton : public Subject,public Button {
//[3]
// ...
};
// 另一个文件里的代码
ObservedButton *ob = new ObservedButton;
setWidget( ob );
// ...
Button *badButton = static_cast<Button *>(getWidget()); // 完蛋!
问题出在多继承条件下派生类的内存布局上。一个 ObservedButton 型别的对象包含两个基类子对象,但只有其中一个和派生类的完整 class对象自身的地址相同。典型地,基类列表中的第一个基类型别的子对象(此处指Subject型别)会被放置于派生类的偏移量为0处,而第二个基类型别的子对象(此处指Button型别)紧随其后,再后面才是派生类的自有成员,如图4-2所示。在多继承条件下,单一对象往往有多个合法地址。
通常情况下,这不成其为问题。因为编译器总是对每种基类型别子对象在派生类中的偏移量了如指掌,从而在编译期就能经过必要调整计算出正确的地址:
Button *bp = new ObservedButton;
ObservedButton *obp = static_cast<ObservedButton *>(bp);
在上述代码中,bp正确地指涉到了ObservedButton型别的对象中存储其Button基类子对象的地址,而不是它的起始地址(即偏移量为0的地址)。当我们试图把一个指涉到Button型别的指针强制转换回指涉到ObservedButton型别的指针时,编译器是能够调整其地址,使它指涉到其起始地址的。对编译器来说,这不过是举手之劳。因为它清楚地了解基类和派生类的所有型别信息[5],所以它也就知道了每个基类型别的子对象在派生类的class对象中的偏移量。
现在,真相大白。当我们使用setWidget函数时,抹除了所有的型别信息。当我们企图对的getWidget的结果执行强制型别转换时,编译器就没法知道怎么去调整地址了。结果就是一个指涉到Button型别的指针实际上指涉到了一个Subject型别的子对象去了。
和强制型别转换自身一样,指涉到void型别的指针亦有其用武之地,不过在使用它们的过程中要多长个心眼。在接口中使用void *作为型别转换的中介型别,并要求用户在调用接口中的某个函数时重新提供另一个函数抹掉的型别信息,这终究不是办法。
截切问题发生在企图把一个派生类对象的内容复制到一个基类对象的存储的时刻。其结果是,那些派生类型别专属的(derived class-specific)数据(即数据成员)和行为(即成员函数)会被切除,这通常是一种错误,或是始料未及的行为。
// ...
class Employee {
public:
virtual~Employee();
virtual void pay() const;
// ...
protected:
void setType( int type )
{ myType_ = type; }
private:
int myType_; // 糟糕的用法,若想知道为什么糟糕,参见常见错误69
};
class Salaried : public Employee {
// ...
};
Employee employee;
Salaried salaried;
employee = salaried; // (salaried对象)发生了截切!
这个从salaried到employee的赋值操作是地地道道的合法操作,因为Salaried class对象皆为(is-a,表示后一个型别是前一个型别的泛化型别)Employee class对象,但是结果却很有可能并非我们想要的。赋值操作完成以后,employee的行为,无论它的虚成员函数还是非虚成员函数,都表现为一个Employee class对象的行为了 [6]。另外,所有Salaried型别专属的数据成员都不会被复制 [7]。
最具破坏力的结果在于,employee的状态会被置为一个salaried对象的Employee型别子对象的一个副本。这有什么关系呢?这是因为,一个从Employee型别派生的Salaried class对象会使用其Employee型别子对象部分来存储一些对于Salaried型别合式的(Salaried-appropriate)值,但这些值对于一个Employee class对象来说,却未必就有意义[8]。为了说明问题,我们假设大凡从 Employee 型别派生的型别都会使用其Employee型别子对象部分的某个数据成员来存储其型别标识码(注意,这是不良的设计实践,仅在此处为说明问题而使用。参见常见错误69)。截切问题出现后,employee就只能像一个失去了一切动态型别信息的Employee class对象一般运作,然而从代码上乍一看,它却号称自己是一个Salaried class对象。
在真枪实弹的实践中,被截切之class对象的状态与行为脱节[9]是更加难以察觉的,是故,它引发的破坏就更大。
截切问题的最常见来源就是一个派生类的 class 对象被以传值方式(by value)传递给一个基类型别的形参:
此问题可以经由以传递引用或指针方式(by reference/pointer),而不用传值方式来传递实参来加以避免。如果这么做了,截切问题就不会发生。因为派生类的 class对象在此情况下根本并未被复制,形参只是作为实参的一个别名而存在(参见常见错误5):
void rightSize( Employee &asset );
// ...
rightSize( salaried ); // 这回没有截切问题了
截切问题也可能以其他形式现身,尽管不那么常见。比如说,把一个派生类的class对象的基类型别子对象部分复制到另一个派生类的class对象的基类型别子对象部分中去,这也是一种可能的截切 [11]:
Employee *getNextEmployee();
// 获取一个Employee的动态对象
Employee *ep = getNextEmployee();
*ep = salaried; // 又发生截切
如果发生了截切相关的问题,这通常意味着在继承谱系设计中存在某些设计不周之虞。最好的,也是最简单易行的避免截切苦恼之道就是避免让具象类(concrete class)成为基类(参见常见错误93):
class Employee {
public:
virtual~Employee();
virtual void pay() const = 0;
// ...
};
void fire( Employee ); // 编译错误,运气不错
void rightSize( Employee & ); // 没问题
Employee *getNextEmployee(); // 没问题
Employee *ep = getNextEmployee(); // 没问题
*ep = salaried; // 编译错误
Employee e2( salaried ); // 编译错误
抽象类是不能被具现的,是故绝大多数会引发截切问题的情形都会在编译期被截获。
值得注意的是,在很少的情况下,也会有意地使用截切来实现对派生类的class 对象的行为或型别的变换。典型地,这种情况下没有数据成员被切除,所谓截切只是用来重新解释基类对象,赋诸带有不同派生类的 class对象之行为。这技术虽然有用,但毕竟少见,而且绝对不应该作为普适接口(general-purpose interface)的一部分。
开门见山,我们先把几个术语理顺。常量指针(const pointer)是指一个拥有常量值的指针。说到常量指针,其中并不包含“其指涉物是否常量”的任何暗示。没错儿,C++标准库里确实有个叫const_iterator的概念量,恰恰是本身非常量的迭代器 [12],指涉到一组常量元素,不过那属于“标准委员会荣誉出品”的贵恙吧。
const char *pci; // 指涉到常量的指针
char * const cpi = 0; // 常量指针
char const *pci2; // 同样是指涉到常量的指针,参见常见错误18
const char * const cpci = 0; // 指涉到常量的常量指针
char *ip; // 普通指针
C++标准规定允许进行“增加常量性”的无条件型别转换。举例来说,我们可以把一个指涉到非常量的指针复制到一个指涉到常量的指针。这样,我们就可以——当然也可以做许多其他事情——把一个指涉到 char 型别非常量的指针传递给标准库中的strcmp或strlen函数,尽管它们只声明接受指涉到char型别常量的实参。直觉上我们能够理解为什么允许指涉到常量的指针去指涉到非常量(但本身不失其指涉物的常量性),因为我们并没有因此丧失任何数据声明时的约束。我们当然也理解为什么逆向的型别转换被封印,因为这么一来我们就取得了比数据被声明时更多的权限:
size_t strlen( const char * );
// ...
int i = strlen( cpi ); // 没问题(实参传递)
pci = ip; // 也没问题(指针赋值)
ip = pci; // 错误!
请注意C++语言规范在数据的常量性问题上采取了一种保守的观点:其实这么说也可以——在不立即引起核心转储的前提下——我们可以修改指涉到常量的指针所指涉的数据,只要这些数据实际上在未被指涉的语义中并非常量 [13],或是它们确系常量,但是运行该代码的硬件平台并不把常量数据分配到内存储器的只读区域 [14]。总之,常量性的典型用途是在设计期的一种立场表达,同时也是一种系统属性标识。C++语言看来会强制推行设计者的意图。
对目标型别为指涉物为常量的指针型别的型别转换适用的令人愉悦的平凡情形对于目标型别为指涉物为指涉到常量的指针型别的(包括指涉级数多于 2级,且最终指涉物为常量的指针型别的)型别转换而言就失灵了。考虑把一个指涉物为指涉到char型别(非常量)的指针转换成一个指涉物为指涉到char常量型别的指针的尝试(亦即把char **型别转化为const char **型别):
char **ppc;
const char **ppcc = ppc; // 错误!
char *p;
看起来完全无害,但正如许多看起来无害的型别转换一样,它在型别系统上开了个天窗:
do
const T t = init; [16]
T *pt; [17]
const T **ppt = &pt; // 编译期错误,还算走运
*ppt = &t; // 把一个const T *赋值到一个T *!
*pt = value; // t违反了声明时的常量性!①
这里想引起重视的话题已经在C++标准§4.4中有了明文规定,章节标题是“量化饰词相关的型别转换”(从技术上说,const和volatile在C语言里被称为“型别量化饰词”,但C++标准倾向于将其称为“cv-量化饰词”。我本人还是倾向于仍然称它们为“型别量化饰词”)[18]。在那里,我们可以找到如下一些平凡的规则,以决定相应的型别转换是否能够执行。
① 译者注:作者上例的解释实在太过简略,给理解带来了不小困难。理解的关键在于,*ppt是一个指向常量的指针pt而是一个指向非常量的指针。而如果这两者的物理地址相同的话,就可以经由*ppt取得一个常量的地址,再经由pt提领出该地址,并以的“指向非常量的指针”的权限向其写入新值。这就是为什么在指涉级别的每一级都不能降低对于指针指涉物的常量性的要求,否则就会出现这种因同一地址的“双重身份”——本例中是*ppt和pt,而能够通过完全合法的手段先经由指涉到常量地址的指针型别身份取得一个常量的地址,再经由指涉到非常量地址的指针型别身份向这个地址写入新值。这不仅是上文中提到的“在型别系统里开了天窗”,而且还会引起常量折叠方面的问题,详见下文。这个例子本身是(Koenig,1996),§4.4中的一个几乎完全相同的代码示例,除此之外仅在(Stroustrup,2002),§ 14.3.4.1中提到了一个此问题的简化过了的变形。
在满足以下条件的前提下,在型别转换中允许向多级指针(multilevel pointer)的非首级指针型别上添加cv-量化饰词。
两指针型别T1和T2称为相似的,如果存在型别T及整数n > 0使得:T1是带cv1,0(量化饰词)的指针,指涉到cv1,1的指针……指涉到cv1,n−1的指针,指涉到cv1,n的T型别
并且
T2是带cv2,0(量化饰词)的指针,指涉到cv2,1的指针……指涉到cv2,n−1的指针,指涉到cv2,n的T型别
其中,各cvi,j代表const、volatile、const volatile或空量化饰词。
换言之,两个相似的指针型别就是它们具有相同的基型别(base type),而且有相同数量的“*”(指涉级数)。所以,举例来说,型别char * const**和const char ***相似,但型别int * const *和int ***不相似[19]。
在首级后的指针型别上施用的cv-量化饰词的n维元组(n-tuple),亦 即指针型别 T1 中的 cv1,1、cv1,2、……、cv1,n,称为该指针型别的 cv-量化饰词标识(signature)。当且仅当下列条件满足时,型别为 T1 的表达式能够被型别转换操作转换到型别T2。
}
指针型别相似。
对所有j > 0,若const存在于cv1,j中,那么const也要存在
于cv2,j中,同样的要求也适用于volatile[20]。
// ...
对所有j > 0,cv1,j若与cv2,j不同,那么对于所有的0 < k<j,都要求const存在于cv2,k中[21]。
有了这些规则作为武装——还有一点点把它们一条一条读完的耐心——我们就可以顺利地判断指针型别进行型别转换的合法性,就像这样:
int * * * const cnnn = 0;
// n是3,cv-量化饰词标识是none、none和none
int * * const * ncnn = 0;
// n是3,cv-量化饰词标识是const、none和none
int * const * * nncn = 0;
// cv-量化饰词标识是none、const和none
int * const * const * nccn = 0;
// cv-量化饰词标识是const、const和none
const int * * * nnnc = 0;
// cv-量化饰词标识是none、none和const
// 规则应用举隅
ncnn = cnnn; // 没问题
nncn = cnnn; // 错误!
nccn = cnnn; // 没问题
ncnn = cnnn; // 没问题
nnnc = cnnn; // 错误!
以上这些规则看起来像是武林秘笈,其实要用到它们的时候还真不少。考虑下面这种常见情形:
extern char *namesOfPeople[];
for( const char **currentName = namesOfPeople; // 错误!
*currentName; currentName++ ) // ...
以我的个人经验,对于这个编译期错误的典型反应是给编译器供应商开具一份缺陷报告(file a bug report),掩耳盗铃地去掉它,然后在未来的某个时间点来一次核心转储。一如既往,编译器是正确的,犯错的是软件开发工程师。
让我们重新考虑一下我们早先例子的一个特殊情形:
typedef int T;
const T t = 12345;
T *pt;
const T **ppt = (const T **)&pt; // 一次罪恶的强制型别转换
*ppt = &t; // 把一个const T的地址放入了一个T *!
*pt = 54321; // t违反了声明时的常量性!
这段代码真正悲剧性的方面在于,此缺陷可能多年都呆在那里未被检测到,直到做了几个平凡的维护工作以后才会自行暴露。比如,我们可能会取用t的值:
cout << t; // 输出可能是12345
因为编译器可以自行决定用一个常量的初始化物代替这个常量本身 [22],上面这语句极有可能在常量的值被改成54321(或无论什么值)后仍然输出12345。过了一阵子,一个只有一点点不同的用法就扯掉了该缺陷的遮羞布 [23]:
const T *pct = &t;
cout << t; // 输出是12345
cout << *pct; // 这里输出却是54321!
使用引用型别或标准库组件以避免引入多级指针的复杂性,通常来说是较好的设计。例如,在 C 语言中要在函数中修改一个指针值,普遍的做法是传入指针的地址(也就是指涉到指针的指针):
gotcha32/gettoken.cpp
// get_token返回一个指针,指向被ws指涉到的字符串分隔的区组中的下一个。
// 实参中的指针被更改为指涉到返回的语汇块(token)之后的那个位置。
char *get_token( char **s,char *ws = " \t\n" ) {
char *p;
do
for( p = ws; *p && **s != *p; p++ );
while( *p ? *(*s)++ : 0 );
char *ret = *s;
do
for( p = ws; *p && **s != *p; p++ );
while( *p ? 0 : **s ? (*s)++ : 0 );
if( **s ) {
**s = '\0';
++*s;
}
return ret;
}
extern char *getInputBuffer();
char *tokens = getInputBuffer();
// ...
while( *tokens )
cout << get_token( &tokens ) << endl;
如果用 C++语言来写的话,我们就把这个指针实参按传递指涉到非常量的引用的方式传递。这让函数的实现显得更洗练,而且更重要的是,调用它的方式也变得不那么佶屈聱牙了:
gotcha32/gettoken.cpp
char *get_token( char *&s,const char *ws = " \t\n" ) {
for( p = ws; *p && *s != *p; p++ );
while( *p ? *s++ : 0 );
char *ret = s;
do
for( p = ws; *p && *s != *p; p++ );
while( *p ? 0 : *s ? s++ : 0 );
if( *s ) *s++ = '\0';
return ret;
// ...
while( *tokens )
cout << get_token( tokens ) << endl;
我们那个最初的例子如果使用标准库提供的组件来写的话,就安全多了:
extern vector<string> namesOfPeople;
在面对指涉物为指涉到派生类型别的指针型别的型别时,我们遇到了相似的困境:
D1 d1;
D1 *d1p = &d1; // 没问题
B **ppb1 = &d1p; // 编译期错误!还算走运
D2 *d2p;
B **ppb2 = &d2p; // 编译期错误!还算走运
*ppb2 = *ppb1; // 现在d2p指涉到一个D1型别的对象了!
是不是看起来很眼熟?正如常量性在引入了多一级的间接层(指涉级别)之后就不能再保持了一样,对于皆然性(is-a property,或译“盖然性”)来说也是如此。虽然说指涉到派生类的指针皆为(is-a)指涉到以public方式继承的基类的指针,但指涉物为指涉到派生类的指针的指针却不是指涉物为指涉到以public方式继承的基类的指针的指针。和讨论常量性的例子类似地,这种会引发错误结果的情形乍看之下并无不妥。当然了,坏的接口设计以及对其非恰当的使用臭味相投的结果就是代码缺陷:
void doBs( B *bs[],B *pb ) {
for( int i = 0; bs[i]; ++i )
if( somecondition( bs[i],pb ) )
bs[i] = pb; // 大事不妙![24]
}
// ...
extern D1 *array[];
D2 *aD2 = getMeAD2();
doBs( (B **)array,aD2 ); // 另一种被不应该的型别转换扼杀的希望
软件开发工程师故伎重演,断定编译器错了,并使用型别转换绕开了型别系统的静态型别校验。在此例中,负责设计函数接口的软件工程师自己也难逃干系。更安全的设计肯定会使用标准库容器,它们就像内建数组一样禁止通过型别转换来瞒天过海。
C/C++语言中的数组走的是极简抽象主义(minimal)路线。实在地讲,数组名并不比指涉到数组首元素的指针字面量(a pointer literal)多了多少内容:
int a[5];
int * const pa = a;
int * const *ppa = &pa;
const int alen = sizeof(a)/sizeof(a[0]); // alen的值为5
// [25]
实践角度言之,数组名和常量指针之间的仅有区别在于使用sizeof对数组名进行评估求值时,求出的是数组的尺寸,而对常量指针就只会求出指针本身的尺寸来。还有就是数组名本身不占用任何存储,它也没有地址。说得更明白一点:数组是有地址的,这个地址是通过数组名来指明的(indicated by the array name),但数组名自身并无地址:
int *ip = a; // a是一个指向数组首元素的指针
int (*ap)[5] = &a; // &a是数组的地址,不是a的地址[26]
int (*ap2)[sizeof(a)/sizeof(a[0])] = &a;
// 概念同上[27]
int **pip = &ip; // &ip是一个指针的地址,不是数组的地址
这些概念同样适用于多维数组,或者更恰当地表述为数组元素为数组的数组。不过要记住,多维数组的首元素的型别是个数组,而不是其基型别 [28]:
int aa[2][3];
const int aalen = sizeof(aa)/sizeof(aa[0]); // aalen的值为2
是故,aa实质上可以视作一个指针字面量,指涉到数组元素为整型量三元组的数组的首元素。它并不是一个指涉到整型量的指针。这个事实会造成一些出人意料的——尽管技术上讲是完全合情合理的——结果:
void processElems( int *,size_t );
void processElems( void *,size_t );
// ...
processElems( a,alen );
processElems( aa,aalen ); // 哎呀,糟糕!
第一个对于重载函数processElems的调用匹配到了取用int *型别实参的那个版本——a作为数组名而言不过是一个乔装打扮过的int *而已。但第二个调用匹配到了函数processElems取用void *型别实参的那个版本,这很可能并不如软件工程师所愿。多维数组的数组名之型别是指涉到其首元素的指针,亦即某特定尺寸的数组,而并不是一个指涉到其基型别的数组的指针。并不存在一个从型别int (*)[3](亦即一指涉到整型量三元组的数组的指针)到int *的隐式型别转换,不过倒是有一个从该型别向void *的隐式型别转换(是故匹配成功)[29]:
int (* const paa)[3] = aa; //[30]
int (* const *ppaa)[3] = &paa; // [31]
void processElems( int (*)[3],size_t );
// ...
processElems( aa,aalen ); // 没问题
多维数组问题多多。其更好的替代物,总体来说是实现了抽象意义上的多维数组的标准库容器组件,或是定制的容器(special-purpose container) [32]。若应用场合确实要求裸多维数组,最好也还是作些封装。向一个新手暴露下面的接口是很不负责任的:
int *(*(*aryCallback)(int *(*)[n]))[n];
这当然对你我而言可以知道是个指涉到一个函数的指针,该函数的实参型别是指涉到一个指针数组的指针,该指针数组的元素全是指涉到 n 个 int变量的指针,返回值的型别和实参的型别相同。好吧,我承认我在卖弄(参见常见错误11)。一个 typedef就能给这个语句带来可观的简化:
typedef int *(*PA)[n];
PA (*aryCallback)(PA); // 更人道的写法
将基类型别的指针转换至派生型别的指针(是所谓“向下转型”)有时会求出一个错误的地址,如下例及图4-3所示。编译器所玩的偏移量算术(delta arithmetic)的把戏会先入为主地认为基类的地址 [33]从属于型别转换表达式中指定的派生类对象的基类型别子对象:
class A { public: virtual~A(); };
class B { public: virtual~B(); };
class D : public A,public B {};
class E : public B {};
B *bp = getMeAB(); // 取得一个派生自B的动态对象
D *dp = static_cast<D*>(bp); // 真的安全吗?!
一条阳关大道是更改设计,使得向下转型失去其存在必要——系统化地使用向下转型往往是设计亟待改进的信号。如果向下转型确实在所难免,使用dynamic_cast来做这件事一般而言是明智之举。它会做一次运行期校验(runtime check)来确保型别转换的正确无误 [34]:
if( D *dp = dynamic_cast<D *>(bp) ) [35] {
// 转型成功
}
else {
// 转型失败
}
滥用型别转换运算符的结果就是代码复杂度的增加。因为编译器调用它们的方式是隐式的,是故,如果在同一个class中大量使用型别转换运算符,就会造成多义性(ambiguity):
class Cell {
public:
// ...
operator int() const;
operator double() const;
operator const char *() const;
typedef char **PPC;
operator PPC() const;
// 意犹未尽……
};
// ...
public:
一个Cell对象能够响应如此众多的型别转换方面的需求,以至于其用户经常会发现它会同时响应其中不止一个,结果造成编译期的多义性(解析失败的)错误。更糟糕的情况是并未出现这样的编译期错误,用户却仍然对编译器究竟隐式调用了哪一个型别转换运算符拿捏不准。因此,最好是在没有许多型别转换路径的情况下才使用型别转换运算符来分派(dispense)隐式的型别转换操作,而更可取的另外一种做法则是使用直截了当的显式型别转换函数 [36]:
class Cell {
// ...
public:
};
// ...
int toInt() const;
double toDouble() const;
const char *toPtrConstChar() const;
// ...
};
char **toPtrPtrChar() const;
};
// 其他函数
};
一般地,在 class里不应该有多于一个的型别转换运算符,如果非有不可的话。如果出现了两个,那就是谨慎的代码审阅者需要特别地予以厘清的。如果出现了3个以上,那就说明代码非改不可了。
即使只有一个型别转换运算符,它也可能和仅有一个实参的构造函数一起造成多义性:
class B;
class A {
public:
A( const B & );
// ...
};
class B {
public:
operator A() const;
// ...
};
从B型别隐式转换到A型别有两条路:A的构造函数,以及B的型别转换运算符。结果就是多义性错误:
extern A a;
extern B b;
a = b; // 多义性错误!
a = b.operator A(); // 这个可行,不过用法太过乖戾
a = A(b); // 多义性错误!
请注意,没有任何直接的手法命令编译器去调用构造函数或是对其取址。因此,尽管表达式A(b)常常引发一个构造函数的调用,它本身却并非就是一个构造函数的调用。它原初的意义是说,请把b用任意办法转换成一个A对象,所以多义性错误仍然无可避免。(令人不爽的是,大多数编译器都根本不会标识出该错误,而是径自用 A 的构造函数来完成这样一个型别转换的操作。)
通常来说比较好的实践是使用显式型别转换函数进行型别转发分派 [37],将仅有一个实参的构造函数打上explicit标志 [38],彻底避免隐式型别转换,除非能说明它们的存在的确有其合理之处。如果非用不带的explicit构造函数和型别转换运算符的话,经验上讲比较倾向于使用构造函数来转换用户自定义型别,而使用型别转换运算符来转换内建型别。
型别转换运算符被设计出来的目的是推进一个抽象数据型别集成到已有的型别系统中去,手法是提供某种隐式型别转换来映射(mirror)那些在内建型别中就支持的隐式型别转换。使用型别转换运算符来实现有其他含义的(value-added,即内建型别中并不支持的隐式型别转换)型别转换应该视作一种错误:
class Complex {
// ...
operator double() const;
};
Complex velocity = x + y;
double speed = velocity;
class Container {
// ...
virtual operator Iterator *() const = 0;
Container &c = getNewContainer();
Iterator *i = c;
这里,设计Complex class的软件工程师是想使用double型别的运算符求复向量的模[39]。不过,使用这个接口的用户可能会假定这个double型别的运算符返回的是复向量的实部,或虚部,或斜率,或是任何有关该复向量的合理值。这种转换到底是干什么的,不清楚 [40]。
而设计Container class抽象类的软件工程师的本意是想实现工厂设计模式(Factory Method),以返回一个指涉到适当的继承自Container型别的具体迭代器之class对象的指针。不过,我们并不是要把Container型别转换到Iterator型别,所以把工厂设计模式以隐式型别转换的手法来实现既令人费解,又与目的睽离。万一工厂模式在维护期间需要加一个实参,
可就头疼了。因为型别转换运算符是不能带实参的,所以就必须重写成一个非运算符形式的函数。如此一来,所有Container class只能被迫去定位和重写所有隐式调用了型别转换运算符的代码。
最好还是保留型别转换运算符去作它们原初的支持内建型别的隐式型别转换原义的型别转换目的之用。而对于上述设计的目的,更好的接口总是由非运算符形式的函数写出来的:
class Complex {
// ...
double magnitude() const;
Complex velocity = x + y;
double speed = velocity.magnitude();
class Container {
// ...
virtual Iterator *genIterator() const = 0;
};
Container &c = getNewContainer();
Iterator *i = c.genIterator();
这个建议,我敢保证,甚至对于平凡的到bool型别(或者有时是 void *型别)的转换都适用,这种型别转换通常用以判断对象是否具备有效性或可用性:
class X {
virtual operator bool() const = 0;
// ...
};
// ...
extern X &a;
if( a ) {
// a可用
还是那句话,这种对于型别转换运算符画蛇添足式的应用,会让人感觉含义不清。随着维护的进行,我们有可能希望针对 X 对象的状态进行更细的区分,以确定其是否有效、有否可用或是否已被破坏等。那么坦坦荡荡地写出来有什么不好呢:
class X {
public:
virtual bool isValid() const = 0;
virtual bool isUsable() const = 0;
// ...
if( a.isValid() ) {
标准库中的输入输出流使用了型别转换运算符,为校验流对象的状态提供了一个便利手法:
if( cout ) // cout是否处于良好运作的状态?
// ...
一个到void *型别的转换提供了这样的一种机制,上面这个语句差不多会被解释成这个样子:
if( static_cast<bool>(cout.operator void *()) ) // ...
如果流对象的状态异常,这个型别转换运算符会返回一个空指针,反之它就返回一个非空指针。因为从指针型别到bool型别的隐式型别转换是内建的,它就能够用来校验流对象的状态。不幸的是,这样一来它也就能够用来给一个指涉到void型别的指针赋值:
void *coutp = cout; // 不容于习惯,而且几乎完全无用
cout << cout << cin << cerr; // 打出来一些void *型别的地址值
虽然把一个流对象转换到void *型别看起来奇奇怪怪的,但还是比转换到bool型别的问题更少:
cout >> 12; // 不能编译,避免了潜在问题
这里我们犯了一个常见错误,就是在输出流语句中把左移运算符打成右移运算符了。如果存在一个从流对象直接到bool型别的型别转换,那上面这个语句就能顺利编译。因为cout会被转化成一个bool型别的对象,然后这个对象又被隐式转换成一个int型别的对象,然后再被右移12位。显然,一个流对象隐式转换到void *型别是优于隐式转换到bool型别的,不过比起隐式型别换转运算符分派,更好的设计还是使用意义明确的、只有一种含义的显式成员函数 [41]:
if( !cout.fail() )
只有一个实参的构造函数既规定了一种初始化,也规定了一种型别转换。和型别转换运算符一样,构造函数引发的型别转换也是隐式进行的,有时候这会提供某种便利性:
class String {
public:
String( const char * );
operator const char *() const;
// ...
};
String name1( "Fred" ); // 直接初始化
name1 = "Joe"; // 隐式型别转换
const char *cname = name1; // 隐式型别转换
String name2 = cname; // 隐式型别转换,复制初始化
String name3 = String( cname ); // 显式型别转换,复制初始化
(参见常见错误 56。)不过,隐式进行的构造函数型别转换常常会导致难以理解的代码,还会引入晦涩难解的代码缺陷。考虑一个带有固定最大尺寸的栈模板:
template <class T>
class BoundedStack {
public:
BoundedStack( int maxSize );
~BoundedStack();
bool operator ==( const BoundedStack & ) const;
void push( const T & );
void pop();
const T &top() const;
// ...
};
从BoundedStack具现的型别有着普通的栈操作,如push和pop等,还可以比较两个栈的相等性。当我们具现某BoundedStack<T>时,必须指定其最大尺寸。
BoundedStack<double> s( 128 );
s.push( 37.0 );
s.push( 232.78 );
// ...
麻烦在于,其只带一个实参的构造函数会引发隐式的型别转换动作,而某些情况下我们恐怕更希望得到一个编译期错误:
if( s == 37 ) { // 糟糕!
// ...
在这种情况下,多半我们原本是打算写一个类似于s.top() == 37的判断条件。倒霉的是,这段代码将一声不吭地通过编译,因为编译器能够把一个整型量37转换成一个BoundedStack<double>对象,尔后将其作为实参传递给 BoundedStack<double>::operator ==。以成效论,编译器是生成了下面的代码:
BoundedStack<double> stackTemp( 37 );
bool resultTemp( s.operator ==( stackTemp ) );
stackTemp.~BoundedStack<double>();
if( resultTemp ) {
// ...
这段结果代码的特点是形式合法、结果错误、效率低下。一种较为安全的替代方案是为BoundedStack模板的构造函数声明加上explicit。这么一来,explicit 关键字就通知编译器不许将构造函数用作隐式型别转换之用,尽管它仍可以用作显式型别转换之用:
template <class T>
class BoundedStack {
public:
explicit BoundedStack( int maxSize );
// ...
};
// ...
if( s == 37 ) { //编译期错误,很走运
// ...
if( s.top() == 37 ) { // 正确,无型别转换
// ...
if( s == static_cast< BoundedStack<double> >(37) ) {//显式型别转换,结果亦正确
// ...
隐式型别转换的暗中作乱给人带来的心理阴影实在让偶尔必要的显式型别转换功能带来的好处显得微不足道。是故,在绝大多数构造函数的声明语句中加上explicit关键字是实践中采用的,也是值得的行为。
请注意,将一个构造函数声明为explicit同样也影响到它的合法初始化语句集,这些语句也会被人用来声明一个 class对象的。让我们把上面那个String class改动一下,看看原本合法的初始化语句都会受到什么影响:class String {
public:
explicit String( const char * );
operator const char *() const;
// ...
};
String name1( "Fred" ); // 没问题
name1 = "Joe"; // 错误!(从const char *到String的隐式型别转换被禁止)
const char *cname = name1; // 逆向隐式型别转换,没问题
String name2 = cname; // 错误!(原因同上)
String name3 = String( cname ); // 显式型别转换,没问题
生成隐式的临时对象——对name2及String::operator =的实参进行复制初始化过程的一个部分——现在成为非法的了。对name3 的初始化仍然是合法的,因为型别转换是显式写出的(尽管更可取的形式是用static_cast来执行这个初始化动作,参见常见错误40)[42]。一如平常,使用直接初始化优于复制初始化(参见常见错误56)。
不过让我们暂时把有关explicit的议题放在一边,先来看一种原本颇有启发意义,现在多少有些过时的技术,它能够在不使用关键字的前提下实现很接近于explicit的语义:
class StackInit {
public:
StackInit( size_t s ) : size_( s ) {}
int getSize() const { return size_; }
private:
int size_;
};
template <class T>
class BoundedStack {
public:
BoundedStack( const StackInit &init );
// ...
};
因为BoundedStack的构造函数并未以explicit声明,所以它会把StackInit对象隐式转换成BoundedStack的某具现类对象。但是编译器不会尝试先把一个整型量转换成一个StackInit对象,继而做第二次转换把该对象转换到BoundedStack的某具现类型别。标准规定,编译器对于用户自定义的隐式型别转换仅做一次尝试 [43]:
BoundedStack<double> s( 128 ); // 没问题
BoundedStack<double> t = 128; // 没问题
if( s == 37 ) { // 错误!
// ...
这种技术给出的行为几乎和explicit关键字所达成的效果别无二致。对于s和t的声明是合法的,因为它们只要求了一次用户自定义的型别转换,把128转换成了一个 StackInit 对象,然后就把该对象作为实参传递给了BoundedStack<double>的构造函数。然而编译器是不会尝试把 37 转换到BoundedStack<double>型别以和 s调用 BoundedStack<double>::operator==作比较的,因为这要求两次用户自定义的型别转换:先是由 int 型别到StackInit型别,再由StackInit型别到BoundedStack<double>型别。
在多继承条件下,一个派生类型别的 class对象可能会有多个合法地址。每个完整的 class对象的基类子对象都有一个与众不同的地址,而这些地址中的每一个都是该完整对象的合法地址。(在某些实现中,即使在单继承的条件下,一个class对象也可能有两个合法地址。参见常见错误70。)
class A { /* ...*/ };
class B { /* ...*/ };
class C : public A,public B { /* ...*/ };
// ...
C *cp = new C;
A *ap = cp; // 没问题
B *bp = cp; // 没问题
在上例中,在C对象中的B型别子对象很可能是相对于C对象的起始地址有一个固定的偏移量。是故,把派生类型别指针 cp转换到 B *型别就会依据该偏移量来调整指针 cp,以取得一个合法的、指涉到 B型别子对象的地址。这种型别转换是型别安全的、高效的,由编译器全盘统筹。
相似地,派生类对象中多个合法地址的存在也迫使 C++语言必须厘请指针变量作比较的精确意义:
if( bp == cp ) // ...
我们通过这个指针变量的比较所问的问题并非是“这两个指针变量的每一位都一样吗”,而是“这两个指针是否指涉到同一个对象”。那么对于该条件的实现就变得复杂一些了,但仍然是高效、型别安全并且全自动的。编译器可能会以下面的方式实现这个指针变量的比较:
if( bp ? (char *)bp-delta==(char *)cp : cp==0 )
无论旧式的还是新式的强制型别转换(old- and new-style cast)都可以被用以执行将对派生类型别的 class对象地址的偏移量算术考虑在内的型别转换工作。不过,与上面的型别转换不同的是,不能保证转换的结果是合法地址。(的确,可以作出这样的保证,但它会带来其他的问题。参见常见错误97、98和99。)
B *gimmeAB();
bp = gimmeAB();
cp = static_cast<C *>(bp); cp = (C *) bp;
typedef C *CP;
cp = CP( bp );
所有以上的 3 个强制型别转换都会在bp上执行偏移量算术,但只有在bp指涉到的是一个C对象中的B型别子对象时,计算出来的地址值才是合法的。如果这个前提不成立,那转换的结果就是一个非法地址,等价于某些天才写出来的C风格的代码 [44]:
cp = (C *)((char *)bp–delta);
而 reinterpret_cast诚哉如斯名,仅仅将其实参按位地、一位不差地解释成其他的什么东西。它卓有成效地关闭了偏移量算术的开关。(准确地讲,标准说了这样的强制型别转换是由各个编译器实现决定的,但普适的理解是它“抹除了继承谱系的一切信息”。当然了,这种行为究竟会做些什么在标准里可是只字未提——reinterpret_cast 甚至有可能把指针的位都涂改掉。)
cp = reinterpret_cast<C *>(bp); // 对,我就是想捣乱,来个核心转储,怎么着?
所有这些对强制型别转换的使用都要求B型别的指针所指涉的class对象承担其接口的金刚钻所揽不下的瓷器活。我们做了一个糟糕的设计,因为我们对 class对象的权能知之甚少,而且还使用了静态强制型别转换来逼一个class对象去扮演一个它无力承担的角色。最好还是避免对class对象做静态强制型别转换。后面,我将大声疾呼同样不要对 class对象做动态强制型别转换。你现在明白我想说什么了。
非完整的class型别(仅有声明)没有定义(或暂时未将包含定义的编译单元含入),但仍然可以声明指涉到此类型别的指针和引用,并且可以声明以该型别为实参及返回值的型别的函数 [45]。这条实践经验是普遍而且有用的:
class Y;
class Z;
Y *convert( Z * );[46]
不过当软件工程师非要刨根问底时,问题也随之现身。虽说傻人有傻福,但毕竟有个限度:
Y *convert( Z *zp )
{ return reinterpret_cast<Y *>(zp); }
在这里,reinterpret_cast 是非写不可的,因为编译器(连型别 Y 和 Z各自的型别信息都毫不知情)完全不知道型别Y和Z之间到底有何种关系。
是故,编译器至多不过是让我们把指涉到 Z 型别的指针按位重新解释成一个指涉到Y型别的指针罢了。说不定这样暂时还能蒙混过关:
class Y { /* ...*/ };
class Z : public Y { /* ...*/ };
很可能Z对象中的Y型别子对象的地址和Z型别的完整class对象自身的地址是一样的。但好景不长,另外的地方修改了代码的话,该强制型别转换会立刻失效(参见常见错误38和70)。
class X { /* ...*/ };
class Z : public X,public Y { /* ...*/ };
极有可能 reinterpret_cast会屏蔽偏移量算术,因而我们就会得到一个错误的Z对象中的Y型别子对象的地址。
其实,reinterpret_cast并非不二之选,我们本来完全也能使用旧式强制型别转换。乍看之下,这是一个较佳选项,因为旧式强制型别转换若是在处理时能够获知足够的型别信息,它是会照顾到偏移量算术的。不过,这种可伸缩性总是伴随着头疼的问题。因为我们会从一个表面上完全相同的型别转换中得到截然不同的结果,这取决于型别转换发生时哪些型别信息是已有定义的:
Y *convert( Z *zp )
{ return (Y *)zp; }
// ...
class Z : public X,public Y { // ...
// ...
Z *zp = new Z;
Y *yp1 = convert( zp );
Y *yp2 = (Y *)zp;
cout << zp << ' ' << yp1 << ' ' << yp2 << endl;
以上代码执行后,yp1的值既可能和zp相等,也可能和yp2相等,这取决于函数convert是在class Z之前还是之后定义。
若是 convert 是一个模板函数,被具现为许多不同的版本的目标文件(object file),情况就变得难以估量地复杂了。这种情况下,该强制型别转换的终极意义就只能取决于链接器的特有癖好了(参见常见错误11)。在这里,使用reinterpret_cast优于使用旧式强制型别转换,因此它至少能给出一致的错误结果。要是我来作决定的话,我肯定对两者都敬而远之。
不要使用旧式强制型别转换。它们太会自说自话,而且它们的用法过于简单随意。考虑这样一个头文件:
// emp.h
// ...
const Person *getNextEmployee();
// ...
假设该头文件在整个工程中都被含入,包括下面这段代码:
#include "emp.h"
// ...
Person *victim = (Person *)getNextEmployee();
dealWith( victim );
// ...
任何将常量性抹除的强制型别转换都有潜在的危险性和不可移植性。即便假设这个代码作者明察秋毫,能够确信此处的这个强制型别转换是正确的,也是可移植的,这代码仍然可以说有两个错误。首先,这个型别转换所要求的比所需要的条件要强许多。其次,该代码作者落入了C++ 新手常犯的“二等语义”的错误陷阱:这段代码等于是默认要求 getNextEmployee 表现出来的,却并未公开的抽象在未来不会发生任何改变。
实质上,这种对于getNextEmployee的调用断言它一经写出,便不会在之后发生变更。事实当然并非如此。不久之后,该头文件的作者就意识到“雇员”根本不是“人”[47],于是他就相应地更改了设计:
// emp.h
// ...
const Employee *getNextEmployee();
// ...
不幸的是,上面那个强制型别转换仍然合法,只不过它所做的事情从去掉其指涉物的常量性变成了更改其指涉物的操作集 [48]罢了。通过执行这个强制型别转换,我们实际上是挑衅说自己比编译器对型别系统懂得还多。在最初的情况下,可能的确如此。但是一旦头文件被维护了,我们根本不可能去一个个地检查使用了该头文件的所有代码,而我们对编译器下达的不可一世的指令也就因此失去了改正的机会。而若是使用了新式强制型别转换,编译器就能够发现这个变更,并标识出错误来:
#include "emp.h"
// ...
Person *victim = const_cast<Person *>(getNextEmployee());
dealWith( victim );
请注意即便我们使用了const_cast——这已经比使用旧式强制型别转换来得好多了——这段代码仍然危机四伏。因为我们仍然做了这样的假定:不管代码怎么变,未公开的——甚至是碰巧的——getNextEmployee和dealWith有关const_cast的型别转换结果达成的默契会继续有效[49]。
说到“静态强制型别转换”——毫不奇怪地——我的意思是指“非动态强制型别转换”(non-dynamic cast)。在这样的定义下,静态强制型别转换的内涵不仅包括了 static_cast 运算符,也包括了 reinterpret_cast、const_cast以及旧式强制型别转换。
静态强制型别转换的基本问题在于它是静态的。只要使用了静态强制型别转换,我们实际上就是在要求编译器把对象本来的权能(capability)晾在一边,并接受我们强加的认识。静态强制型别转换的结果往往一开始能够正确运作,但它对于未来调整了有关对象型别布局信息的代码变化缺乏适应性。这种变化往往发生在远离静态强制型别转换调用点的地方,而维护工程师往往不会再去修改这些做了静态强制型别转换的代码了。同时,静态强制型别转换还关闭了编译器本来会执行的(对于对象的型别信息的正确性)诊断。
强制型别转换并非总是可恶的,但使用它们的时候必须小心翼翼,不要让远离调用点的一个代码改动导致强制型别转换预期的效果失效。较具实践意义的观点是:这样的要求实际上就是说,不要对抽象数据型别做强制型别转换,尤其是更不要对在一个继承谱系中的抽象数据型别做强制型别转换。
考虑一个平凡的继承谱系:
class B {
public:
virtual~B();
virtual void op1() = 0;
};
class D1 : public B {
public:
void op1();
void op2();
virtual int thisop();
};
与该继承谱系相关联的是一个充作工厂的函数,用以生产某种 B 的动态对象。一开始,我们只有这么一个派生类,所以实现相当的平凡:
B *getAB() { return new D1; }
但原始的软件工程师或是维护工程师偏偏可能需要从 getAB返回的指针身上取用D1专属的操作。正确的做法是应该更改设计,使得这些操作成为静态可知的(known statically)。如果实在做不到,或是成本太高,那也应该在痛苦反省之后使用dynamic_cast。无论如何,像下面这样使用静态强制型别转换都是很糟糕的主意:
B *bp = getAB();
D1 *d1p = static_cast<D1 *>(bp);
d1p->op1();
d1p->op2();
int a = d1p->thisop();
上面这段代码唯一能够运作的可能就是返回的指针的确指涉到了一个D1对象。但这种局面不太会一直保持下去,因为新的派生类会被加入继承谱系,而工厂函数也随之会被升级:
class D2 : public B {
public: void op1(); void op2(); virtual char thatop(); }; // ... B *getAB() { if( rand() & 1 ) return new D1; else return new D2; }
需要提请注意的是,这些变化可能是由这个静态强制型别转换的软件工程师本人在远离这个不怎么常用的调用点的地方进行的。人们可能期望对于getAB函数的这次改动会至少触发一次代码的重新编译 [50],但即使是这种要求也不一定会得到满足。并且就算代码被重新编译了,由于使用的是静态强制型别转换,这也就会强迫编译器关闭了任何可能进行的诊断。对于这段代码在getAB函数返回了一个指涉到D2 对象的前提下到底会做些什么,其实是没有什么保证的,但很有可能它仍然会跌跌撞撞地跑起来。下面的注释揭示了其在现有条件下的行为观察所得:
B *bp = getAB(); // gets a D2
D1 *d1p = static_cast<D1 *>(bp); // 强D2型别以为D1型别
d1p->op1(); // #1:调用了D2::op1!
d1p->op2(); // #2:调用了D1::op2!!
int a = d1p->thisop(); // #3:调用了D2::thatop!!!
撇开行为无保证这一点不谈,标了#1的那行代码很可能得到“正确”的结果。当然,如果op1从指涉到基类型别的指针来调用就更好了,那样的话能够保证有正确的行为。
标了#2的那行代码问题就更大些,它是一个对于D1对象的非虚成员函数的调用,不幸的是这个调用会被绑定到D2对象那里去。即便如此,它仍然有可能正常运作。
问题最大的还是标了#3的那行代码,就静态的情形而言,我们是在调用D1对象的名字叫thisop、返回值的型别为char的虚成员函数。而实际的结果则是动态的:我们调用了D2对象的名字叫thatop、返回值的型别为char的虚成员函数。如果这样的代码都通行无虞,我们也就是在把一个char型别的结果复到一个int型别的对象中去了。
对于使用静态强制型别转换,Scott Meyers 如是说,“通常这意味着你和编译器之间的契约被撕毁了。”以成效论,静态强制型别转换不仅是对编译器说:“少废话,按我说的来。”(和人类的讨论对象这么一说,也基本上可以保证任何有效的沟通都就此结束了。)而且,静态强制型别转换也是对于被实施此种转换之抽象数据型别接口的无视。设身处地的、权衡再三的、对于 class对象的权能加以充分尊重的解决方案也许比不问青红皂白只会蛮干的强制型别转换需要更多的精细功夫,但它带来的通常是更健壮、更可移植、更可用的代码和接口。
考虑一个带operator ==的String class:
class String {
public:
String( const char * = "" );
~String();
friend bool operator ==( const String &,const String & );
friend bool operator !=( const String &,const String & );
// ...
private:
char *s_;
};
inline bool
operator ==( const String &a,const String &b )
{ return strcmp( a.s_,b.s_ ) == 0; }
inline bool
operator !=(const String &a,const String &b )
{ return !(a == b); }
请注意,这个设计使用了未加explicit关键字的、只有一个实参的构造函数,又使用了非成员的(non-member)operator ==。是故,我们其实是
在鼓励用户利用隐式型别转换的优势来简化其代码:
String s( "Hello,World!" );
String t( "Yo!" );
if( s == t ) {
// ...
}
else if( s == "Howdy!" ) { // 隐式型别转换
// ...
}
第一个条件,s == t,是高效运作的。运算符operator ==的两个引用型别的形参分别以s和 t为初始化物,而且执行字符串比较的是标准库中的strcmp函数。如果编译器决定以inline方式调用operator ==(很可能会的,除非一个抑制性的调试状态开关被打开了),运行期这个语句实际上就只会生成一个对strcmp函数的平凡调用。
后一个条件,s == "Howdy!",就不那么高效了,尽管结果正确。为了初始化operator==的第二个实参,编译器就得先从const char*型别的字面量"Howdy!"生成一个String型别的临时class对象,然后再以该对象为初始化物来完成实参的初始化动作。当运算符返回值时,该对象必须被析构。整个调用的效果差不多就像这样:
String temp( "Howdy!" );
bool result = operator ==( s,temp );
temp.~String();
if( result ) {
// ...
}
在这种特定情况下,额外的代价相对于便利性的提升还是值得的,因为它同时使得String class的实现和用户的调用代码都变得短小而且清晰。但还是有两种情形使得隐式型别转换变得不可接受。第一种,当然是这种型别转换被到处使用,以致性能和效率的开销过大。还有一种情形就是从const char*型别转换到String型别的过程在某个地方引起了多义性或不确定性,然后设计工程师情急之下为String型别的构造函数之声明加了个explicit关键字。
给String class的operator ==加上足够的重载版本就可以轻松搞定这个问题 [51]:
class String {
public:
explicit String( const char * = "" );
~String();
friend bool operator ==( const String &,const String & );
friend bool operator !=( const String &,const String & );
friend bool operator ==( const String &,const char * );
friend bool operator !=( const String &,const char * );
friend bool operator ==( const char *,const String & );
friend bool operator !=( const char *,const String & );
// 译注:没有“两个const char *实参”的重载版本!
// ...
};
现在该运算符的任何合法的实参型别组合都能够精确匹配到适当的解析结果。可惜的是,现在String class变得庞大,也更加难以理解了。所以该优化动作最好还是在性能剖析(profiling)的结果指出了这样做的必要性以后才着手实施[52]。
C++新手常犯的一个错误就是在以传引用方式更好的情况下,使用传值方式来传递一个对象作为函数实参。考虑取用一个String型别实参的函数 [53]:
String munge( String s ) {
// munge s...
return s;
}
// ...
String t( "Munge Me" );
t = munge( t );
对于这段代码,真的难以找出任何赞赏之辞,但 C++新手们就是忍不住非要写出这种代码来。
对于munge函数的调用关于形参s要求一个复制初始化,等到返回时又要求另一个复制初始化,而且它还要把局部变量 s 的资源给析构掉。因为是把用munge函数处理过的t给复制回去,我们可能指望这被实现为一个空操作。哪有这种好事?编译器收到的指令是要把 munge函数的返回值先存到一个临时的对象中去(该对象必须马上又被析构掉),所以这个赋值操作中没有任何优化的余地。所以,这短短的一个赋值语句会引发一共有 6 个函数调用。
更好的做法是重写munge函数的实现,将其实参声明为引用型别足矣:
void munge( String &s ) {
// munge s ...
}
// ...
munge( t );
只留下一次函数调用了。修改前后两个版本的函数的含义有些许不同之处,任何munge函数作用在s上的效果会立刻反映到t身上,而不是在返回的时刻才如此。(这些不同之处在munge函数抛出异常、意外中断或调用了另一个实参型别中有指涉到 t的引用的函数时就能够明显感觉到了。)无论如何,总体的复杂性降低了,而且生成的代码尺寸更小,也运行得更快了。
传递引用方式对于模板的实现来说,就更加至关重要了,因为若以传值方式来做的话,预期某种用以具现模板的型别带来的实参传递开销是无法做到的:
template <typename T>
bool operator >( const T &a,const T &b )
{ return b < a; }
以传递引用方式来传递实参开销小而且固定,不随着模板具现的实参型别的变化而变化。但也有一类实参型别,比方说内建型别或尺寸很小的平凡 class型别,传值方式对它们而言可能更好些。如果这种情况一定要引起重视的话,模板可以被重载(如果是函数模板)或特化(如果是class模板)。
犹有进者,习惯用法有时也为传值方式摇旗呐喊。比如,在C++ STL的应用中,以传值方式传递函数对象作为实参乃是约定俗成。(所谓函数对象就是某重载了operator ()的class对象。它和一般的class对象相比并无特殊之处,除了它能以函数调用的语法工作之外。)
比如,我们可以声明一个函数对象以用作谓词(predicate):该函数对象根据其实参来回答一个“是”或“否”的问题:
struct IsEven : public std::unary_function<int,bool> {
bool operator ()( int a )const
{ return !(a & 1); }
};
一个IsEven对象没有数据成员、没有虚函数、没有构造和析构函数 [54]。将这样的class对象以传值方式进行实参传递代价不高(常常会不产生任何代价)。事实上,在STL中为传递函数对象而生成匿名临时对象被认为是一种良好的实践 [55]:
extern int a[n];
int *thatsOdd = partition( a,a+n,IsEven() );
表达式IsEven()创建了一个型别为IsEven的匿名临时对象,尔后该对象被以传值方式传递给了partition算法(参见常见错误43),并在函数调用完成后被析构。当然,这种习惯用法得以运作的前提是另一个习惯用法:这些STL御用的函数对象都足够地短小精悍,适于以传值方式作为实参传递。
在某些特定场合下,编译器不得不创建临时对象。标准有言,此种对象的生存时域是从其被创建的时间点处始,至最大可能的闭合表达式结束处终(是标准所谓“全表达式”,full-expression)。一个常见的问题是始料未
及地在此种对象被析构之后还对其存在相依:
class String {
public:
// ...
~String()
{ delete [] s_; }
friend String operator +( const String &,const String & );
operator const char *() const
{ return s_; }
private:
char *s_;
};
// ...
String s1,s2;
printf( "%s",(const char *)(s1+s2) ); // #1
const char* p = s1+s2; // #2
printf( "%s",p ); // #3
对于String class的二元operator +运算符的实现而言,要求返回值被存储于临时String对象中是稀松平常的。上面两次用到它的时候,这种结果都发生了。在标为#1的那个例子中,s1+s2的结果被按位原样复制(dump)到一个临时String对象中,该对象在被以传值方式作为实参传递到printf之前,先被转换到const char*型别。等到printf的调用一结束 [56],该临时String对象就被析构掉了。此语句得以运作的原因是在前述对象被使用的全部场合,它都还存在(或者说还未被析构)。
在标为#2的例子里,s1+s2的结果被按位原样复制到一个临时String对象中,该对象被转换到 const char*型别,到这一步为止全都一样。区别在于在本例的情况下,临时String对象在初始化完指针p以后就被析构掉了。当p被用在调用的语句中时,它已然是指涉到一块包含着被析构的String对象的存储块。未定义行为。
这个缺陷真正悲剧性的方面在于代码可能会(至少在测试时看起来)一直运作如仪。比如,当临时String对象回收其字符数组的存储块时,operator delete[]可能仅仅在该存储块上打了一个“未使用”标记,但并未改变其内容。如果该存储块的内容在语句#2和#3之间没有发生变化,代码就始终能够运作。若是这段代码随后被嵌入到一个多线程的应用程序代码中去,它就会时不时地冒错。
更好的做法是使用相对复杂些(却不引起生存时域问题)的表达式,或声明一个具名的临时对象以延拓String对象的生存周期:
String temp = s1+s2; [57]
const char *p = temp;
printf( "%s",p );
请注意,“临时对象具有受限的生存时域”常常被作为一个优势而加以利用。一种常见的实践是在使用标准库来撰写代码时,使用函数对象来定制其组件:
class StrLenLess
: public binary_function<const char *,const char *,bool> {
public:
bool operator() ( const char *a,const char *b ) const
{ return strlen(a) < strlen(b); }
};
// ...
sort( start,end,StrLenLess() );
表达式 StrLenLess()使得编译器生成了一个匿名的临时 StrLenLess对象,它会一直存活到 sort算法调用结束返回为止。而若是使用显式的具名变量则会代码更为冗长,而且使得当前辖域被多余的名字所污染(参见常见错误48):
StrLenLess comp;
sort( start,end,comp );
// comp仍然赖在当前辖域中
另一个临时对象的生存时域所带来的问题主要起源于在标准 C++编译器出现之前撰写的那些老旧代码。在C++标准付梓之前,有关临时对象的生存时域并无统一规则。结果就是有些编译器直到临时对象所在的语句区块结束时才析构它们(即遇到“}”才析构),而有些编译器则直到临时对象所在的语句结束时才析构它们(即遇到“;”才析构),等等。当我们着手重构这些老旧代码的时候,请保持高度警惕,当心那些在临时对象的生存时域方面悄然发生的意义变更,以及它们所带来的传播效应和缺陷。
引用就是其初始化物的别名(参见常见错误 5)。初始化完成后,就可以任意用引用来代替其初始化物而不会改变代码的意义。好吧……多数如此。
int a = 12;
int &r = a;
return a;
++a; // 等同于++r
else
int *ip = &r; // 等同于&a
int &r2 = 12; // 错误!12是个字面常量
}
引用必须用左值来初始化。基本上,这意味着引用的初始化物必须既有一个地址,也有一个值(参见常见错误 6)。不过谈及指涉到常量的引用时,事情就开始变得复杂起来了。指涉到常量的引用仍然必须用左值来初始化,不过编译器很乐意——在这种特定情况下——为此从一个非左值出发来创建一个左值(临时对象)。
const int &r3 = 12; // 没问题
引用r3指涉到编译器隐式分配内存并创建的匿名int型别对象。一般情况下,这种编译器的生存时域持续到全表达式的结束处。不过,在这种特例下,标准网开一面,保证该临时对象会和以其为初始化物的引用共存亡。请注意该对象与以其为初始化物的引用之间并无联结纽带,所以下面这段居心叵测的代码,万幸,不会改变字面量12的意义:
const_cast<int &>(r3) = 11; // 赋值给那个匿名临时对象,或者程序直接崩溃
// ...
编译器还会为身为左值的初始化物产生出临时对象,并且与这个引用声明时指涉到的型别还不一样:
const string &name = "Fred"; // 没问题
short s = 123;
const int &r4 = s; // 没问题
这里我们就遭遇了语义困境,因为“引用就是其初始化物的别名”的观念已经令人熟视无睹,就很容易把“此处的初始化物是个匿名临时对象,而非源代码中显式写明的那个变量”一事忘到九霄云外。比如,任何发生在short (int)型别s对象身上的变化都不会反映其引用(实际上s只是匿名对象——真正的引用的初始化物——的初始化物罢了)r4身上:
s = 321; // r4的值仍是123
const int *ip = &r4; // 并非s的地址
这真的会成为问题吗?当然——如果你帮它一把的话。考虑一种设计,欲使用typedefs来企及可移植性目标。也许是使用一个在整个项目范围内应用的(project-wide)头文件,试图固定下来一个平台无关的名字,用以表示尺寸随着平台不同而不同的整数的型别名字:
// 头文件big/sizes.h
typedef short Int16;
typedef int Int32;
// 头文件small/sizes.h
typedef int Int16;
typedef long Int32;
// ...
(请注意,我们没有用#if来把所有的 typedefs搅在一起,然后放入同一个文件中。这简直邪恶透顶,而且会让你的周末、声名和小命统统报销。参见常见错误27。)这么做没有任何错,因为软件工程师们可以使用一致的名字了。要命的是,他们可不总是那么听话:
#include <sizes.h>
// ...
Int32 val = 123;
const int &theVal = val;
val = 321;
cout << theVal;
如果我们在“大而宽”平台上做开发,theVal就是val的别名,我们就把321 传给了 cout。而过了一会儿,我们自恃“平台无关”而把同样的这段代码放到“小而窄”平台上重新编译一把,theVal就移情别恋到一个匿名对象身上,于是我们传给cout就是123了。这个变化来得悄无声息,如果没有那一行输出语句的话我们可发现不了。
另一扇通往临时对象生存时域问题的万劫不复之门由指涉到常量的引用开启。我们前面已经知道,编译器会确保这样的匿名临时对象会将生存时域延拓到初始化后的引用存在的全部时域,这看起似乎是一个安全无虞的设计。让我们考察一个平凡的函数:
const string &
select( bool cond,const string &a,const string &b ) {
if( cond )
return a;
else
return b;
}
// ...
string first,second;
bool useFirst = false;
// ...
const string &name = select( useFirst,first,second ); // 没问题
乍看之下,此函数完全无害:毕竟只是返回两个实参中的一个罢了。引发问题的是那个return语句。我们来考察另一个把问题完全暴露出来的函数:
const string &crashAndBurn() {
string temp( "Fred" );
return temp;
}
// ...
const string &fred = crashAndBurn();
cout << fred; // 糟糕!
这里我们显式地返回了一个指涉到局部变量的引用。返回的时候局部变量被析构,调用该函数的用户的手里空余一个指涉到已驾鹤西游的对象的无效句柄。幸运的是,绝大多数编译器都会对这种情形给出一个警告。但它们往往面对下述情形时不会给出警告,这么说吧,它们给不出来:
const string &name = select( useFirst,"Joe","Besser" );
cout << name; // 糟糕(原因同上)!
问题在于select函数的第二个和第三个实参都是指涉到常量的引用,所以它们被用一个临时(string<char>)对象初始化了。因为这些临时对象对于select 函数而言并非局部对象,它们的生存时域仅仅到达了全表达式的结束处——恰为select函数返回之后和这些值发挥作用之前的那个时刻。一个能够运作的方案是把这个函数调用嵌入一个辖域更大的表达式:
cout << select( useFirst,"Joe","Besser" ); // 能够运作,但不堪一击
这种代码由C++行家来写就能正常运作,但是被C++新手一折腾就会坏事。
一种较为安全的手法就是不要返回型别为指涉到常量的引用的形参。具体到我们的 select 函数的话,解决方案至少有两种合理选择。标准库中的string模板并不带多态设施(也就说,它没有虚成员函数),这样我们就可以断言指涉到该型别的实参不会实际上指涉到派生于之的其他型别。于是,我们就能放心大胆地按值返回(return by value)而不必为截切问题操心,但我们的确也申报了一些开销,用以调用的string(模板的某现类型别,一般而言是默认的以char型别来具现)的复制构造函数来初始化这个返回值:
string
select( bool cond,const string &a,const string &b ) {
if( cond )
return b;
另一种备选方案是把形参之引用的指涉物改为非常量,这样一旦要求在初始化时生成临时对象,就会引发一个编译期错误。这个改动轻而易举地将我们前面举的例子入了另册:
string &
select( bool cond,string &a,string &b ) {
if( cond )
return a;
else
return b;
}
这两种方案中没有一种令人热血沸腾,但是哪一种都比在代码里留一个随时起爆的定时炸弹要好。
class Screen {
// ...
是的,你为此自感罪孽深重。你也肯定不好意思向同事开口。甚至你周遭的亲朋好友也开始为此对你疏远。但你已经被逼到墙角,手里拿着一个设计得奇烂无比的代码模块,而且刚从老板那里接到了一个没有可能完成的任务——而你本来应该昨天就搞定的。嗯,差不多只有这样才是你慨然使用dynamic_cast的时机。
且认为我们面对的问题是决定一个抽象的Screen对象是否(动态)型别为EntryScreen[58]还是其他什么继承自Screen型别的型别。问题在于你拿到的是个已然设计到一半,本来可以用其他更泛型的手法实现的Screen型别的继承谱系。你的第一个冲动可能是扩张所有Screen型别的继承谱系的接口,以取得所要求的型别相关的信息。
public:
// ...
//...
virtual bool isEntryScreen() const
{ return false; }
};
class EntryScreen : public Screen {
public:
bool isEntryScreen() const
{ return true; }
};
// ...
Screen *getCurrent();
if( getCurrent()->isEntryScreen() )
这种做法的问题在于赋予了查询 Screen 对象的动态型别一事以合法地位。作为基类,Screen class实际上是在非常招摇地延请维护工程师开口打探隐私:“喂,你的动态型别是EntryScreen吗?”好家伙,这下子可就捅了马蜂窝啦,未来的维护工程师会问出更多的此类问题来(参见常见错误98):
class Screen {
public:
//...
virtual bool isEntryScreen() const
{ return false; }
virtual bool isPricingScreen() const
{ return false; }
virtual bool isSwapScreen() const
{ return false; }
// 没完没了了
};
显然,这么一个接口设计出来就是为了被像下面这样用的:
// ...
if( getCurrent()->isEntryScreen() )
// ...
else if( getCurrent()->isPricingScreen() )
// ...
else if( getCurrent()->isSwapScreen() )
// ...
这有一点点像switch语句,除了更慢、更不可维护之外。此时,应该一咬牙一闭眼,毅然决然地祭出稍微不那么邪恶的 dynamic_cast 翻天印。这里使用强制型别转换会——至少给人以希望会——把丑恶的境况暂时掩盖起来而不是继续败坏下去,在未来的某一天,重构代码的同时可以把这些东西去掉:
if( EntryScreen *es = dynamic_cast<EntryScreen *>(sp) ) {
// 做有关输入屏幕的操作
}
如果这个强制型别转换成功了,es将指涉到EntryScreen型别的某物——或者是一个完整的EntryScreen对象,或是EntryScreen型别的某个继承类对象中EntryScreen型别子对象。但如果这个强制型别转换失败的话,是什么意思呢?使用dynamic_cast进行的强制型别转换可能由于以下4种原因中的任何一种而返回一个空指针作为失败标志:强制型别转换无法进行,此其一;如果根本没有指涉到EntryScreen型别的某物——或者是一个完整的EntryScreen对象,或是EntryScreen型别的某个继承类对象中EntryScreen型别子对象,强制型别转换就会以失败而告终;作为强制型别转换的源的sp本身是空指针,此其二;如此强制型别转换会照样返回一个空指针;我们企图把一个无法访问的基类作为强制型别转换的源型别或目的型别 [59],此其三;强制型别转换会因多义性半途而废,此其四。
型别转换中的多义性问题在设计良好的继承谱系中并不常见,但是在结构和访问层级设计很差的继承谱系中却时时现身。
如图4-4所示,这是一个平凡的带有多继承的继承谱系。我们假定A型别是有多态设施的(亦即其含有虚成员函数),并且此处只使用了public方式的继承。在图中的情形下,一个D对象含有两个 A型别的子对象,换句话说,至少有一次A型别被用作基类时未以虚拟继承方式进行:
D *dp = new D;
A *ap = dp; // 多义性错误!
ap = dynamic_cast<A *>(dp); // 多义性错误!
对于ap的初始化是包含多义性错误的,因为它能指涉到两个在合理性方面不相上下的地址。不过,一旦我们通过执行偏移量算术得到了两个 A 型别子对象的地址之一,那指涉到继承谱系中任一其他型别子对象都不再会有多义性错误了。
B *bp = dynamic_cast<B *>(ap); // 可以运作
C *cp = dynamic_cast<C *>(ap); // 可以运作
不管ap指涉到两个A型别子对象的地址中的哪一个,对其执行型别转换使之指涉到B或C型别的子对象,或是完整的D对象本身都不会再出现多义性错误了,因为完整的D对象只包含这些型别的子对象的唯一实体。
有意思的是,如果两次 A 型别被用作基类时皆以虚拟继承方式进行,多义性问题也就不复存在了,因为这么一来,完整的D对象也只包含一个 A型别子对象了:
D *dp = new D;
A *ap = dp; // 现在没有多义性了
ap = dynamic_cast<A *>(dp); // 也没问题了
我们可以把这继承谱系弄得稍微再复杂点儿,以重新引入多义性,如图4-5所示。在改动后的继承谱系中,原先的多义性是不在了,因为 D 对象仍然只包含一个A型别子对象:
A *ap = new D; // 仍然无多义性问题
但是,多义性问题又在另一处死灰复燃:
E *ep = dynamic_cast<E *>(ap); // 崩溃!
手心手背都是肉,ap不知道在型别转换之后指涉到哪个E型别子对象比较好。但我们可以消除这个多义性困境,只要把话说得再精确些就好:
E *ep = dynamic_cast<B *>(ap); // 没问题
因为D对象只包含一个B型别子对象,所以从A *型别转换到B *型别并不存在多义性,而接下来的一步从B *型别转换到(指涉到)其public方式继承的基类型别(的指针型别,即E *型别)根本不需要一个强制型别转换。不过,请注意这个解决方案把A和 E型别之下的继承谱系结构知识作了硬编码,这会给今后的维护带来麻烦。最好能够简化整个继承谱系结构以化动态多义性之可能于未现。
既然我们正在讨论是有关 dynamic_cast 的主题,这里就有必要指出其语义的一些精妙之处。首先 dynamic_cast 不一定就是动态的,因为它不一定就会做运行期校验。如果使用 dynamic_cast 来把一个派生类型别的指针或引用转换到其public方式继承的基类型别(即向上而不是向下转型)时,就用不着运行期校验,因为编译器能够静态地决定该强制型别转换必然能够成功。当然了,这种情况下任何强制型别转换都是不需要的,因为从派生类型别到其以 public 方式继承的基类型别的型别转换是语言内建的。(尽管语言规则在这方面的规定乍看之下比较鸡肋,但是它们确实为模板代码的撰写提供了不小的方便,在那里需要操作的是什么型别一般都不能预先得知。)把指涉到带有多态设施型别的指针或引用强制型别转换至void *型别也是合法的。在这种情况下,转换结果是指针指到了“最深派生类型别”子对象的起始位置,或是干脆一不做、二不休,指涉到了指针原本指涉到的对象本身。当然了,如果这样做的话,我们永远都不可能知道指涉物是什么,但至少我们得到了一个地址嘛……
关于指涉到成员的指针的型别转换规则是符合逻辑的,但常常被认为是违反直觉的。对指涉到成员的指针的实现方式来一番考察往往有助于理解。一个指针指涉到的是一个内存区域。它本身包含一个地址,这样就可以提领该地址以访问这块内存。(下面这段代码使用了两个不良实践手法:把数据成员放在public访问区段,以及遮掩了基类的非虚成员函数。它们仅作演示之用,绝不代表本人明示或暗示的推荐立场。参见常见错误8和71。)
class Employee {
public:
double level_;
virtual void fire() = 0;
bool validate() const;
};
class Hourly : public Employee {
public:
double rate_;
void fire();
bool validate() const;
};
// ...
Hourly *hp = new Hourly,h;
// ...
*hp = h;
请注意,某个特定的 class对象的数据成员的地址并不是什么指涉到数据成员的指针,而只是个普通不过的指涉到某个特定的 class对象的某个特定的数据成员的指针而已:
double *ratep = &hp->rate_;
指涉到成员的指针并非指针。指涉到成员的指针的值根本不是地址,它也不指涉到任何特定的 class对象或位置。指涉到成员的指针指涉到的是非特定 class对象的特定成员。是故,若想提领指涉到成员的指针就必须供应一个特定的class对象才是:
double Hourly::*hvalue = &Hourly::rate_;
hp->*hvalue = 1.85;
h.*hvalue = hp->*hvalue;
运算符.*和->*是用来提领指涉到成员的指针的二元提领运算符,分别用以从class对象或指涉到class对象的指针处做提领操作(不过,参见常见错误15和17)。指涉到成员的指针hvalue以Hourly型别之成员rate_初始化,尔后被用以提领Hourly对象h之rate_成员及Hourly型别指针hp指涉到的class对象之rate_成员。
通常实现指涉到数据成员的指针之手法是将其实现为偏移量(offset)。亦即,对数据成员取址,就像我们上面对 Hourly::rate_取址时做的,实际上给出的是从 class 对象的起始起址到该数据成员所在位置之间的字节数。典型地,这个偏移量被算出来的数大1,这样就可以用0来表示指涉到数据成员的指针的空值了。提领指涉到数据成员的指针的通常就是在对象地址上加上由指涉到数据成员的指针中存储的偏移量(再减去1)以得到一个目的地址,再对该地址提领,实际上就等于在访问 class对象的对应数据成员了。例如,表达式:
hp->*hvalue = 1.85;
就可以被翻译成这样:
*(double *)((char *)&h+(hvalue-1)) = 1.85;
再看另一个指涉到数据成员的指针:
double Employee::*evalue = &Employee::level_;
Employee *ep = hp;
因为Hourly对象皆为Employee对象,我们想把evalue从指涉到任一型别的指针身上提领出来都不成问题。这是众所周知的从派生类型别到基类型别的隐式型别转换:
ep->*evalue = hp->*evalue;
在任何场合下都可以用Employee对象来代替Hourly对象。但这一招用在指涉到成员的指针的型别转换上就不灵了:
evalue = hvalue; // 错误!
虽说没有指涉到派生类型别成员的指针到指涉到基类型别成员的指针的型别转换,不过反其道而行之,却一路绿灯:
hvalue = evalue; // 没问题
我们称这种现象为“逆变性”:系统内建的指涉到成员的指针的隐式型别转换路径和基于皆然关系的指涉到 class对象的指针的隐式型别转换路径恰好相反。(不要把逆变性和返回值型别的协变性搞混了。参见常见错误77。)只要仔细一想,那有点反直觉的语言规则背后的逻辑就会水落石出。既然Hourly对象皆为Employee对象,它也就含有一个Employee型别的子对象。是故,在Employee对象内有效的偏移量对于Hourly对象仍然有效,但反过来就不一定了。那也就是说,指涉到基类型别成员的指针到指涉能够安全地执行到派生类型别成员的指针的型别转换,但逆向的型别转换则行不通:
T SomeClass::*mptr;
...ptr->*mptr ...
在上述代码片段中,ptr指针能够合法地指涉到SomeClass对象或是任何以 public 方式派生自 SomeClass 对象。而指涉到成员的指针可以持有SomeClass 型别的某个成员的地址偏移量或是任何可访问的基类型别的某个成员的地址偏移量。
逆变性也存在于指涉到成员函数的指针之中。它和指涉到数据成员的指针差不多同样反直觉,也同样合情合理,如果仔细想过的话:
void (Employee::*action1)() = &Employee::fire;
(hp->*action1)(); // Hourly::fire
bool (Employee::*action2)() const = &Employee::validate;
(hp->*action2)(); // Employee::validate
指涉到成员函数的指针的实现手法多种多样,但基本上都是使用了某些小结构。这些小结构存储着必要的信息以判定某个成员函数是否为虚函数,还有其他平台相关的型别信息以处理与实现相关的技术细节,以及了解在继承条件下的class对象结构。上面这段代码中的第一句,通过action1我们实际上是在调用 Hourly::fire,因为&Employee::fire 是一个指涉到虚成员函数的指针。而后一句,通过 action2 我们实际上是在调用Employee::validate,因为&Employee::validate 是一个指涉到非虚成员函数的指针:
action2 = &Hourly::validate; // 错误!
bool (Hourly::*action3)() = &Employee::validate; // 没问题
又来了,还是逆变性惹的祸。把派生类型别(Hourly)的成员 validate函数的地址偏移量赋值给一个指涉到基类型别(Employee)的成员函数的指针是行不通的,而以指涉到基类型别(Employee)的成员函数的指针作为指涉到派生类型别(Hourly)的成员函数的指针的初始化物则可以畅行无虞。和指涉到数据成员的指针一样,指涉到成员函数的指针也将访问层级的安全性纳入了考量。有可能&Hourly::validate去尝试访问那些(仅在Hourly型别中存在而)在Employee型别中不存在的数据或函数。而另一方面,任何&Employee::validate能够访问到的成员,肯定也存在于型别Hourly中。
[1].译者注:事实上,很多设计良好的编译器会将其标识为错误。
[2].译者注:注意,MyButton型别以public方式继承于Button型别。
[3].译者注:这回改成了多继承。
[4].译者注:这里的效果是指偏移量信息的缺失。
[5].译者注:主要是指各个基类的尺寸信息和整个class对象的内存布局,这样才能算出偏移量。
[6].译者注:Salaried型别信息完全被隔离了,虚函数表中根本没有放入Salaried型别所指定对应函数入口地址。
[7].译者注:同样,有关它们的型别信息也被直接丢弃而没有参与复制的过程。
[8].译者注:一个“原生的”Employee class对象的数据成员可能永远都不会出现某些只有派生的Salaried class对象的Employee型别子对象部分才会出现的值的组合。但一经截切赋值,这些对于“原生的”Employee class对象而言的非法状态——不是由语言禁止的非法状态,而是由于状态的含义对人类来说有合法非法之分——就经由派生的Salaried class对象的Employee型别子对象复制到“原生的”Employee class对象中去,而如果复制行为只在“原生的”Employee class对象之间进行,就不会出现这种情况。但另一方面,“在有截切情况出现的前提下仍保持赋值结果的状态合法性”属于设计的一部分。
[9].译者注:亦即发生了截切问题的派生类对象持有派生类型别专属的状态,却只表现基类型别的行为。
[10].译者注:函数名是“解雇”,实参名是“倒霉蛋”。
[11].译者注:这种情况下,可以视为一种型别窄转换,以尽最大可能使赋值操作合法化。亦即赋值的对象并不一定是具有较窄型别,也有可能是必须转换至较窄型别才能完成赋值的其他型别。但无论如何,只要当中必须有一步截切,那么该赋值操作就会引起截切问题。
[12].译者注:和指针一样是指涉元素的游标概念。
[13].译者注:也就是说让一个声明为指涉到常量的指针指涉到一个本来不是常量的数据,那么就不能通过这个指涉到常量的指针来修改这个数据了,一个例子请参见常见错误6。
[14].译者注:前者是 C++语义中的常量性,此处是物理意义上的常量性——比前者更加本质的常量性,作者想表达的是只要不在最底层,亦即物理层处为数据加上了常量性,其实从代码运行时理论上说就可以落实为生成修改它的代码。但C++语言并未这样做,从而可以体现出C++是极为严格而保守地推行最大范围的常量性约束。
[15].译者注:包括指涉级数多于2级,且最终指涉物为常量的指针型别的情形。
[16].译者注:初始化一个T型别的对象。
[17].译者注:声明一个指涉到普通T型别的指针。
[18].译者注:作者在此处直接引用的只是部分标准原文,(Koenig,1996),§ 4.4 中还涉及到了混有指向成员的多级指针型别,是非常晦涩难懂的章节。如果想研究明白,务请多读几遍。
[19].译者注:此处是因为指涉级数不同,前者是2级,后者是3级,是故不相似。
[20].译者注:原文中多了一个句首“The”。本条的意义在于转换源型别T1的量化饰词不能少于转换目的型别T2,即在每级指涉中允许“增加常量性”以及“增加挥发性”。只允许通过型别转换在每级指涉中增加约束,而不允许减少约束。
[21].译者注:原文中多了一个句首“The”。这里的原因前面解释过了,如果不能保证常量性不同的前向级别中每一级的常量性,就会在型别系统上开天窗。
[22].译者注:是所谓常量折叠,为数不多的被普遍施行的优化之一,比如对于上例,编译器可以决定直接用值12345——t的初始化物——来代替 t本身,也就是说不管t的值被怎么变动也是扬汤止沸,因为编译出来的目标码根本没有从t的地址取值。
[23].译者注:提领操作阻止了常量折叠,现在编译器不得不插入一条取址指令。有关常量折叠的更深入讨论,参见(Eckel,2002),§ 8.1。
[24].译者注:质量不佳的编译器有时会接受。
[25].译者注:在函数中要取用数组大小,通常就用这种办法来临时计算出来,以增加可移植性。
[26].译者注:因而和a等价。
[27].译者注:更具可移植性。
[28].译者注:数组的基型别指数组的最终维所含元素的型别,在此例中为int。
[29].译者注:这个出人意料的结果和不良的接口设计有关,也进一步说明了 void *作为型别转换的目的型别在“抹除了太多的型别信息”和“作为型别匹配选项能够命中的范围太过泛型”诸方面所带来的负面影响,参见常见错误29。
[30].译者注:取址操作取得多维数组的首地址。
[31].译者注:通过多级指针而非数组名来间接寻址。
[32].译者注:神似而非形似。
[33].译者注:并不是一个基类对象的绝对地址,而是一个基类型别子对象在一个派生类对象中的偏移量。
[34].译者注:是故,dynamic_cast会带来比较大的运行期开销。
[35].译者注:dynamic_cast会做运行期校验确保偏移量算术求出正确地址。
[36].译者注:有关用户自定义的隐式型别转换运算符的危害以及其他可能出现的形式、显式型别转换函数的优势以及更深入的解决方案的讨论,参见(Meyers,2007),条款5。Scott Meyers在这里举了一个很好的例子,就是标准库中string模板的c_str成员函数。
[37].译者注:原文是“It’s typically better to dispense with conversion operators...”,与上文矛盾,应为笔误。
[38].译者注:事实上,最好将所有的构造函数皆标为explicit。
[39].译者注:length,,该运算符的实现大约应该是以inline方式写作return sqrt(real() * real() + imag() * imag());。
[40].译者注:造成这种结果的一部分原因是不能给予型别转换运算符一个名字而导致的。
[41].译者注:不过,标准库中的输入输出流这部分的设计已经年代久远,而现存的大量代码也已经不可能再为之修订了。隐式型别转换运算符给代码维护带来的问题由此可见一斑。
[42].译者注:有关型别转换表达式的形式,参见(Eckel,2002),§3.7.11。
[43].译者注:对于内建型别则允许多次尝试。
[44].译者注:事实上,这应该是编译器生成的,手工得到delta没有可移植做法。
[45].译者注:Herb Sutter指出,这样的函数甚至可以再把非完整的class型别的实参作为调用另外的函数时的实参传递,并且仍然不需要定义。参见(Sutter,2003),条款26。
[46].译者注:甚至可以写Y *convert( Z *z) {return f(z);}。
[47].译者注:作者这里使用了双关调侃,其技术含义是getNextEmployee应该返回一个指涉到Employee型别的指针,而不是一个指涉到Person型别的指针。
[48].译者注:这并非截切。
[49].译者注:作者是想说,随着代码的维护,dealWith可能需要的并非一个去掉了常量性的getNextEmployee所返回指针的指涉物。
[50].译者注:这样,这次意外的重新编译可能会促使合格的软件工程师重新思考其背后的原因。
[51].译者注:有关应该何时以成员函数为接口、何时应该以非成员函数为接口的问题,有助于加深理解为何作者会给出这样的解决方案。参见(Meyers,2006),条款23、24。
[52].译者注:尽管前面指出了一些潜在的性能问题,但作者还是建议实际做过性能剖析以后再着手优化。这对于防止想当然地进行优化而言是多么有益而中肯的建议!这样做不仅彰显了把代码写得短小、清楚——甚至牺牲一些并不一定会带来重大影响的性能也在所不惜——的重要性,而且对于那些懒得做性能剖析,看到了潜在问题就以为真的是性能瓶颈所在的人,也不啻为一记当头棒喝。有关使用性能剖析器和手工分析的技术和实践的讨论,参见(Kernighan,et al.,2002),§7.2、(Bentley,2006),§ 9.1、(Meyers,2007),条款16和(Sutter,2002),条款 12。
[53].译者注:单词“munge”似乎是一个计算机行业特有的动词,表示从一个字符串出发作一个可逆的变换来保护其初始形式,甚至有说法是munge是modify until not guessed easily的首字母拼写,没有适当的中文对应词汇。例如“对电子邮件地址进行munge”,往往就是指把一个电子邮件变换成不带“@”的形式,这样Web页上的电子邮件地址就不会被蜘蛛程序等抓取以用作垃圾邮件的发送对象。
[54].译者注:作者在这里实际上指明了“何种对象才适用当作传值方式进行实参传递”的标准。
[55].译者注:不过由于这种以传值方式作为默认实参传递方式的影响,也给STL中使用函数对象带来了一些限制。上面所述的函数对象的特征——没有数据成员、没有虚函数等实际上不仅是为了减少传值方式带来的开销,也可以视为是对于class对象状态所要求的必要约束。具体的技术细节的讨论,参见(Meyers,2003),条款39。
[56].译者注:是为全表达式。
[57].译者注:String temp( s1+s2 );或许更好也未可知。
[58].译者注:其具体意义则是判断一个屏幕是否是一个供输入的屏幕。
[59].译者注:可能是非完整型别。
C++语言的初始化语义微妙、复杂而且至关重要。在这复杂性背后的动机并非恣意妄为。许多使用C++语言进行的编码工作就是使用classes来设计抽象数据型别。从根本上说,我们扩展C++基础语言的手段就是把新的数据型别添加到型别系统的其余部分 [1]。一方面,我们热衷于语言的设计,实现出可用而全能的型别。另一方面,我们又醉心于翻译机能的构造,力求让编译器把我们做出来的这些抽象数据型别给翻译成尽可能高效的目标码。高效的、支持工业强度的数据抽象之中流砥柱,乃是隐藏在class对象初始化和复制操作之中的小小细节。
与代码的高效同等要紧的,自然是其正确性。对 C++语言的初始化语义内廪的复杂性未能胸有成竹,就会落入误用迷途。
本章我们将考察初始化实现的种种要点,以及如何让编译器乖乖地对用户定义的初始化和复制操作进行优化。我们也将研究由于对初始化语义的误解而导致的一些常见错误的来龙去脉。
从技术视角看去,赋值和初始化没什么关系。它们之间泾渭分明,用在不同的场合。所谓初始化,就是把一段生鲜存储(raw storage)变成一个对象的过程。对于一个 class对象来说,这会涵盖搭建一系列内部机能,以支持虚函数和虚基类、运行时型别信息(runtime type information,参见常见错误53和78)以及其他型别相依的(type-dependent)信息。而赋值,则是在一个有着合式定义的(well-defined)对象的某种状态上所作的一个变换,使该对象处于一个新的状态。赋值操作不触及实现对象之型别相依的行为背后的内部机能。赋值操作也从来就不会在一段生鲜存储上实施。
业内有个习惯,即如果在某些场合下,复制构造语义是不二之选,那么复制赋值语义则同样也是如影随形,反之亦然。不把复制语义下的赋值和初始化一齐纳入考量,其苦果就是缺陷:
class SloppyCopy[2] {
public:
SloppyCopy &operator =( const SloppyCopy & );
//注意:此class有一个编译器默认生成的复制构造函数:SloppyCopy(const SloppyCopy &)
private:
T *ptr;
};
void f( SloppyCopy ); // 以by-value方式传递实参
SloppyCopy sc;
f( sc ); // ptr的指涉对象成了同一个(互为别名),极有可能是个错误
实参的按值传递时发生的复制操作是由初始化操作,而非赋值操作完成的。而初始化操作又是由 SloppyCopy 的复制构造函数完成的。由于在SloppyCopy中,显式声明的复制构造函数缺位,编译器只好代劳。在本例中,编译器的好心却办了坏事(参见常见错误49和常见错误53)。
习惯上,可以作出假定:即使复制构造和复制赋值乃是绝不相同的两种操作,它们仍然应该有着相似的、互洽的意义:
extern T a,b; //[3]
};
b = a;
T c( a );
在以上的代码里,型别T的用户会期望b和c的值像是同模所铸。换言之,对于上述代码之后的语句来说,T型别对象的当前值是籍由赋值还是初始化所取得,应该无关紧要。对于这种互洽性的假设在 C++社群中是如此根深蒂固,以至于标准库也只得屈尊降贵,依此行事:
gotcha47/rawstorage.h
template <class Out,class T>
class raw_storage_iterator
: public iterator<output_iterator_tag,void,void,void,void> {[4]
public:
raw_storage_iterator& operator =( const T& element );
// ...
protected:
Out cur_;
};
template <class Out,class T>
raw_storage_iterator<Out,T> &
raw_storage_iterator<Out,T>::operator =( const T &val ) {
T *elem = &*cur_; [5]// 取得指涉到element的指针
new ( elem ) T(val); // placement复制构造函数
return *this; [6]
}
标准库中的raw_storage_iterator对象用于向一段未初始化的存储赋值。若是平常计议,一个赋值运算符总是要求它的两边实参皆为已然正常初始化的对象。否则,若赋值操作试图先把其左手边实参的存储清空以作成新值的话,岂非要出问题了。举例来说,若是正欲被赋值的对象内含一个指涉到从堆上分配的存储块的指针,赋值操作一般而言会在设置该对象的新值之前,清除这个存储块的旧有内容。如果被赋值的对象本身并未有初始化,那么对其未初始化的指针成员的清除操作就会陷入“未定义行为”的泥沼:
gotcha47/rawstorage.cpp
struct X {
T *t_;
X &operator =( const X &rhs ) {
if( this != &rhs ) [7] {
delete t_; t_ = new T(*rhs.t_); }
return *this;
}
// ...
// ...
X x;
X *buf = (X *)malloc( sizeof(X) ); // 取得生鲜存储
X &rx = *buf; // 引用指涉到生鲜存储,亡命之徒的勾当
rx = x; // 错误!
标准库中的copy算法的功能是将一个输入序列的内容复制到一个输出序列,以赋值操作来完成每个元素的复制 [8]:
template <class In,class Out>
Out std::copy( In b,In e,Out r ) {
while( b != e )
*r++ = *b++;
return r;
}
在一个未初始化的X数组上应用copy算法很可能会归于失败:
gotcha47/rawstorage.cpp
X a[N];
X *ary = (X *)malloc( N*sizeof(X) );
copy( a,a+N,ary ); // 向一段生鲜内存赋值
赋值操作有一点像(但不是完全一样!)一个清除操作紧接着一个复制构造操作。而raw_storage_iterator之所以能够允许赋值操作应用于生鲜存储,是因为它把赋值语义作了“仅相当于一个复制构造”的重新诠释,而略过了会引起问题的“清除”步骤[9]。不过,这种帽子下的戏法能够运行如仪的前提,乃是“赋值和复制构造产生众望所归的相似结果”这个假设。
gotcha47/rawstorage.cpp
raw_storage_iterator<X *,X> ri( ary );
copy( a,a+N,ri ); // 没问题
这不是想喧宾夺主地说,class X的设计师应该密切关注所有(无可否认地既困难又冷僻的那些)标准库的细节,以写出一个正确实现来。但是,设计工程师确实应该充分意识到复制构造和赋值操作的互洽性这个普适的、符合习惯的假设。如果一个抽象数据型别不支持这个互洽性,它就不能说与标准库相得益彰。而同一个支持这种互洽性的型别相比,它自然相形见绌。
另一个常见的误解是当“赋值”和下述的初始化搅在一起的时候:
T d = a; // 这不是赋值
此处的“=”符号并非赋值运算符,事实上是d以a来进行复制构造语义下的初始化的。这算是我们撞了大运,不然的话我们就会在一段未初始化的存储上实施赋值操作了(但是,请看常见错误56)。
bool f( const char *s ) {
在C语言和C++语言中最常见的错误来源之一,就是未初始化的变量,而这个问题却真是发生得毫无理由。把变量的声明和它的初始化语句分开写,压根就没有任何好处:
int a;
a = 12;
string s;
s = "Joe";
if( function ) {
这简直是胡闹。这个刚被声明的整数在下一个赋值动作完成之前,会先持有一个未定值。而这个string对象会被其默认构造函数正确地初始化,不过它持有的值立刻被下一句赋值语句覆写了(参见常见错误51)。这两个变量都应该在其声明语句中就使用显式的手法来完成初始化:
int a = 12;
char *buffer = (char *)malloc( length+1 );
string s( "Joe" );
// ...
真正的危险在于,经过一些维护,在未实施初始化的声明语句和其首次被赋值的语句之间可能会插入一些其他代码。比起上面这两段傻代码,典型的真实场景会隐藏得更深些:
}
bool f( const char *s ) {
size_t length;
if( !s ) return false;
length = strlen( s );
char *buffer = (char *)malloc( length+1 );
// ...
}
这里面的变量length不仅有未初始化的问题,而且它还应该是一个常量。这段代码的作者一定是忘记了,C++语言和C语言不一样。C++语言中,声明是语句的一种——更精确地说,是声明语句。是故,声明可以写在语句能够出现的任何位置 [10]:
if( !s ) return false;
const size_t length = strlen( s );
}
}
}
我们来检视一下另一个在代码维护中常常会犯的毛病。下面这段代码可谓平淡无奇:
void process( const char *id ) {
Name *function = lookupFunction( id );
// ...
当下来看,变量function的声明算不得一无是处。不过,在维护的过程中它就会逐渐暴露出其麻烦本质。就像我们一早提及的那样,维护工程师常常会拿局部变量来干许多风马牛不相及的勾当。这是为什么呢?也许是因为它们已经在那里了,我猜 [11]。
void process( const char *id ) {
Name *function = lookupFunction( id );
if( function ) {
// 这里function代表的是“函数”
}
else if( function = lookupArgument( id ) ) {
// Oops!这里function突然又代表与其字面不相符的“实参”之义了
}
}
现在这段代码还不到有缺陷的程度,尽管我想对于不谙于此诡计的审阅者而言,这段用来处理实参(argument)的代码已是带来委实沉重的阅读负担了(“诸位,在这段代码里每当我提到‘函数’——function——时,我实际是在说‘实参’——argument”)。不过,如果这个原始代码的作者杀了个回马枪,其结果又该作何解释?
void process( const char *id ) {
Name *function = lookupFunction( id );
if( function ) {
// 同上
}
else if( function = lookupArgument( id ) ) {
// 同上,function的涵义悄然变化了
}
// ...
if( function ) {
// 但这里又把它当原义使用了,全然忘记了中间的涵义变化,一塌糊涂
}
}
这下子,我们会在后续处理中,把明明已经“变节”的已经表示实参的那个变量function又当成函数的原意来使用了 [12]。
最佳的实践是把一个名字精确地控制在原始作者意图让其发挥作用的那个辖域中。那些仍然在其辖域中,却已经不再有用的名字就像是缺少管束、无所事事的愣头青,百无聊赖,只会到处惹是生非。最初的那个函数里就应该把变量function的辖域控制到仅在它预期被使用的地方:
void process( const char *id ) {
if( Name *function = lookupFunction( id ) ) {
// ...
}
控制变量名字辖域的做法把“复用”它的企图消弭于无形,这样对于代码的维护动作也会变得合理多了:
void process( const char *id ) {
if( Name *function = lookupFunction( id ) ) {
// ...
postprocess( function );
}
else if( Name *argument = lookupArgument( id ) ) {
// ...
}
}
C++语言彰显初始化时机和辖域之重要性。它提供了一系列的语言特性,协助软件工程师保证每个名字都被正确地初始化,并且其辖域被精确地控制在预期的应用范围之内。
class NBString {
C++语言对于其复制操作极其审慎。复制操作对于使用C++语言进行的软件开发而言同样也是重要有加,尤其是对于 class 对象而言,就更是如此。这种操作是如此之重要,以至于如果你自己不为某个 class撰写一个的话,编译器就会代劳。有时,甚至在你貌似已经提供了你自己版本的复制操作时,编译器还会将你晾在一边,不依不饶地使用它生成的版本。在某些情况下,编译器实现的复制操作是正确的,但在另一些时候并非如此。为何我们需要精细地研究编译器对有关复制操作的期望?如上所述。
public:
注意,复制赋值操作和复制构造函数(以及其他任何的构造函数,还有所有的析构函数)都是不从基类继承的。是故,所有的 class 都得定义自己独一无二的复制操作。
复制构造函数的默认实现,就是执行一个按成员进行的初始化操作(member-by-member initialization)。所谓按成员进行的初始化操作,这和C风格的(C-style)按位进行的结构体复制操作完全不搭界。考虑下面这个简单的class实现:
template <int maxlen>
};
public:
explicit NBString( const char *name );
// ...
// ...
private:
private:
std::string name_;
std::string name_;
size_t len_;
char s_[maxlen];
};
假定没有定义任何复制操作,那么编译器就会暗中替我们合成出来。它们具有public访问层级,而且是inline的。
NBString<32> a( "String 1" );
NBString<32> b( a );
编译器隐式合成的复制构造函数会执行一个按成员进行的初始化操作:调用string的复制构造函数来以a.name_初始化b.name_,以a.len_初始化b.len_,并以a.s_的各个元素来初始化a.len_的各个元素。(不知从哪里来的灵机一动,标准规定纯量型别,如内建型别、enums及指针将在此隐式合成的复制构造函数中经由赋值方式取得值,而并非经由初始化方式。标准委员会里那帮家伙的脑袋瓜里究竟在想什么才弄出了这么一个规定来,这超出了我的理解能力。总之一样:对于那些纯量型别而言,不管是用赋值还是初始化,从结果上说都是半斤八两)[13]。
b = a;
相似地,隐式合成的复制赋值操作会执行一个按成员进行的赋值操作:调用string的赋值运算符来将b.name_赋值为a.name_,将b.len_赋值为a.len_,将b.s_的各个元素赋值为a.s_中的对应元素。
这些隐式合成的复制操作将实现正确的、合乎习惯用法的复制语义(不过,参见常见错误81)。但是,考虑一个具名的(named)、尺寸受限的(bounded)字符串class [14]:
class NBString {
public:
explicit NBString( const char *name,int maxlen = 32 )
: name_(name),len_(0),maxlen_(maxlen),
s_(new char[maxlen] )
{ s_[0] = '\0'; }
~NBString()
{ delete [] s_; }
// ...
private:
std::string name_;
size_t len_;
size_t maxlen_;
char *s_;
};
构造函数现在会设置字符串的最大尺寸,而且字符串中用于字符元素的存储现在并非是对象的物理属性的一部分了 [15]。现在,隐式合成的、编译器代劳的复制操作可就出乖露丑了:
NBString c( "String 2" );
NBString d( c );
NBString e( "String 3" );
e = c;
隐式复制构造函数把c和d的s_成员设置成指涉到同一个从堆上分配的存储块去了。当 d和 c的析构函数依次企图清除同一段内存时,此存储块将会遭受“二次清除”之苦。相似地,当e被赋值为c的时候,e的s_成员被设置成和c的s_成员一样的存储块时,对c的清除操作也会让e的s_成员陷入空悬(dangling),一如刚才的下场(参见常见错误53中一段有关在虚基类存在的情境下复制赋值操作如何由编译器合成的讨论)。
NBString的正确实现必须将撰写复制操作的重任完完整整地自行承担[16],而不能让编译器越俎代庖:
class NBString {
public:
// ...
NBString( const NBString & );
NBString &operator =( const NBString & );
size_t len_;
size_t maxlen_;
char *s_;
};
// ...
NBString::NBString( const NBString &that )
: name_(that.name_),len_(that.len_),maxlen_(that.maxlen_),
s_(strcpy(new char[that.maxlen_],that.s_))
//[17]
{}
NBString &NBString::operator =( const NBString &rhs ) {
//[18]
if( this != &rhs ) {
name_ = rhs.name_;
char *temp = new char[rhs.maxlen_];
len_ = rhs.len_;
maxlen_ = rhs.maxlen_;
delete [] s_;
s_ = strcpy( temp,rhs.s_ );
}
return *this;
}
所有的class设计工程师都务必在复制操作方面三思。对于复制操作而言,它们或是由软件工程师显式地撰写(而且在class实现的每次变更之机,皆要例行维护),或是由编译器隐式合成(不过,在class实现的每次变更之机,也定要检视此一决定是否已经不再适用),或是由以下这段例行代码明白拒绝才是 [19]:
class NBString {
// ...
private:
NBString( const NBString &);
NBString &operator =( const NBString & );
// ...
仅声明private访问层级的复制操作,但不给出其定义的做法有在该class上禁用复制操作之效。编译器由于已经显式声明了复制操作之故,不会再隐式合式一个复制操作实现,而大多数的代码也不具有对该 class的复制操作的访问层级。任何在该 class的成员函数以及友元中意外调用的复制操作,都会在连接时由于未有函数定义而被捕获。
想在如何实现复制操作这个题目上从编译器那里蒙混过关,是绝无可能的。下面这段代码列举了 3 种企图与编译器斗智斗勇的代码,它们虽然乍看之下不乏奇思,结果却是徒劳无益:
class Derived;
class Base {
public:
Derived &operator =( const Derived & );
virtual Base &operator =( const Base & );
};
class Derived : public Base {
public:
using Base::operator =; // 被掩蔽(hidden)
template <class T>
Derived &operator =( const T & ); // 并非复制赋值操作
Derived &operator =( const Base & ); // 并非复制赋值操作
};
我们已经知道复制操作并无继承语义,即使是那个看起来极尽乐善好施之能事的经由using声明从非虚基类导入(import)的位于基类辖域内的赋值运算符也不能阻止编译器合成另一个,并把这个隐式合成的版本显式地掩蔽了导入的实现(请亦注意,在基类型别中显式地写出了派生类型别的名字,这是糟糕的设计。参见常见错误69)。
使用模板(即实参化型别,并指定当前class的名字为实参的尝试)赋值成员函数也没用:模板成员从不用于实现复制操作(参见常见错误88),从而编译器会隐式合成一个并运作如仪。基类里的复制赋值运算符倒是可以在派生类中以虚函数方式改写,但改写后的那个赋值运算符并非一个复制赋值运算符 [20](参见常见错误76)。C++语言在这一点上就是这样不依不饶:要么你自己撰写复制操作,要么编译器就替你合成一个,别耍滑头。
没什么理由说,让编译器隐式合成复制操作就必然会出问题。尽管通常最好还是只在一些平凡的classes上——更准确地说,是在具有平凡的结构的 classes 上——才让这样的隐式定义得以实施。事实上,对于平凡的classes 而言,放手让编译器代劳是较佳选项,这是基于效率的决策。考虑一个仅由数据的平凡聚集构成的class:
struct Record {
char name[maxname];
long id;
size_t seq;
};
让编译器自行合成该平凡 class 的复制操作,是绝对无可非议的。像这样的class叫做POD(Plain Old Data,参见常见错误9),是一个似C的(C-like)结构体。针对这种情形下,编译器隐式合成的复制操作,由标准仔细地定义过,以与C语言原有的复制语义相符,也就是采取按拉复制的实现方式。
特别地,如果某给定平台上有一种“迅捷执行 n 个字节的复制”的指令,编译器就能够自行决定在按位复制时是否使用该指令。此种优化甚至对于非POD的classes也仍然能够照搬。
常见错误49中的那个原始的、模板化的NBString实现就可以合情合理地先调用string型别成员name_的对应复制操作,而对于其余的成员,则放心施行迅捷按位复制大法。
间或,某些classes的撰写者会想要自行主持按位复制之大计。这通常只是班门弄斧,因为编译器对于无论是 class实现之细节还是平台之规格的了解都要比软件工程师技高一筹。手工打造的按位复制实现较之于编译器合成之版本而言,既有效率不彰之嫌,又有更多缺陷。
class Record {
public:
Record( const Record &that )
{ *this = that; }
Record &operator =( const Record &that )
{ memcpy( this,&that,sizeof(Record) ); return *this; }
// ...
private:
char name[maxname];
long id;
size_t seq;
};
我们的Record POD现在已经升级为一个真正的class[21],是故,为它写了几个显式的复制操作。这纯属画蛇添足,因为编译器可以生成更具效率且正确无虞的版本 [22]。真正的麻烦在Record class继续其演化时才突显:
class Record {
public:
virtual~Record();
Record( const Record &that )
{ *this = that; }
Record &operator =( const Record &that )
{ memcpy( this,&that,sizeof(Record) ); return *this; }
// ...
private:
char name[maxname];
long id;
size_t seq;
};
现在事态看来有些不妙。按位复制语义不再适用于该 class的结构了。添加了虚函数的动作导致编译器在该 class的实现方面加入了些许新料,典型地,是一个指涉到虚函数表的指针(参见常见错误78)。
经由编译器隐式合成的复制操作对这些秘而不宣的class实现机理照顾有加:复制构造函数会给予这个指针正确的初始设置,复制赋值运算符也会小心翼翼地不去碰复制目的对象内的这个指针。我们给出的以memcpy实现的版本却不问青红皂白,在复制构造函数和复制赋值运算符中设置完这个指针后,又将其覆写。其他的一些对class的变更也会在按位复制的语义下引起类似问题:派生自一个虚基类、添加了一个具有非平凡复制语义型别的数据成员,或是引入了一个指涉到未经封装的存储的指针,等等 [23]。
一般地,在没有确凿数据说明性能上的确必要、的确能够带来可量化的改进时,就手工为某个 class写按位复制的实现并非明智之举。如果真的要这样做,那么在对该class的实现作任何变更时,必得对此重新仔细检视。当然,在class实现的外部对class进行按位复制更是不登大雅之堂。如果说,用memcpy来实现class的复制操作是无知者无畏,那么暗地里实施按位暴力覆写(bit-blasting)则简直是自杀行径:
extern Record *exemplaryRecord;
char buffer[sizeof(Record)];
memcpy( buffer,exemplaryRecord,sizeof(buffer) );
写下这段代码的软件工程师大概是自觉不能见人(他的确应该有此感受),于是他把这段臭不可闻的代码塞到了一个远离Record实现的源文件里。如此一来,任何对Record进行的与按位复制语义不兼容的变更将无法(在编译期)被检测出来,直至酿成运行时(runtime)的大错。如果非这样写不可,就一定要用以下的方式以确保被调用的是 class自身专属之版本(编译器隐式合成版本)方可(参见常见错误62):
(void) new (buffer) Record( *exemplaryRecord );
Person::Person( const string &name )
在构造函数中,所有语法上要求初始化的成员都需要进行初始化。是初始化,不是赋值。要求初始化的成员包括常量、引用、具有构造函数的 class型别的对象,以及基类型别的子对象(但是,有关常量和引用成员的更深入讨论,参见常见错误81)。
class Thing {
public:
Thing( int );
};
class Melange : public Thing {
public:
Melange( int );
private:
const int aConst;
const int &aRef;
Thing aClassObj;
};
// ...
Melange::Melange( int )
{} // 错误!
编译器会在Melange的构造函数处标出4处错误:1处是为其基类的初始化;3处是为其成员的初始化。能在编译阶段就捕捉到的错误,算不得大错。我们能够在这些错误给任何人带来负面影响之前把它们搞定:
Melange::Melange( int arg )
: Thing( arg ),aConst( arg ),aRef( aConst ),aClassObj( arg )
{}
当软件工程师将初始化的重任抛在脑后时,他就陷入了更为恶毒的迷局。但是,这样写出来的代码居然是合法的:
class Person {
public:
Person( const string &name );
// ...
private:
string name_;
};
// ...
Person::Person( const string &name )
{ name_ = name; }
这段在合法性方面堪称“完美”的代码,把目标代码变得膨胀,并把的Person的构造函数的运行时间增加了几乎一倍。由于string型别具有构造函数,该型别的对象作为Person型别的成员,就必须初始化之。而string型别另有一个默认构造函数,在未指定初始化源对象的情形之下该默认构造函数就会被调用。是故,Person的构造函数就调用了string的默认构造函数,而这一调用的结果不过是昙花一现地立刻被下一条赋值动作覆写掉罢了。一个改进的实现仅仅是做个一次性的初始化操作就结束:
: name_( name )
{}
在构造函数的函数体中做初始化操作时,相对于赋值,宁使用成员初始化列表(member initialization list)[24]。
当然了,我们也不想把这条建议推进到另一个逻辑的极端。中庸才是王道。考虑以下这个非标准的String型别的构造函数:
class String {
public:
String( const char *init = "" );
// ...
private:
char *s_;
};
// ...
String::String( const char *init )
: s_(strcpy(new char[strlen(init?init:"")+1],init?init:"") )
{}
这样写就不免过分了些。对于该个案,写成赋值的形式是更可接受的:
String::String( const char *init ) {
if( !init ) init = "";
s_ = strcpy( new char[strlen(init)+1],init );
}
有两种数据成员是不允许在成员初始化列表中进行初始化的:静态数据成员和数组。常见错误59专门用来讨论静态数据成员的问题。而数组成员的各个元素的初始化,非得写成构造函数中的赋值不可。作为替代方案的——常常也是较佳方案的——标准库中的容器组件,如vector 模板,应作为比数组成员更优先的选择。
根据C++语言标准,class对象的各个组成部分的初始化次序是定死的(参见常见错误49)。
虚基类的子对象部分,无论它在继承型别中位于何处。
非虚直接基类(non-virtual immediate base class)的子对象部分,该次序取决于它们在基类中的成员列表中的位置。
本class的数据成员,该次序与其声明次序相同。
这个规定里暗示了一个 class的任何构造函数都必须按照这样的次序来初始化其各个组成部分。更具体地,这也就是说,一旦编译器积极介入,构造函数的成员初始化列表中孰先孰后的问题就绝非无关紧要了:
class C {
public:
C( const char *name );
private:
const int len_;
string n_;
};
// ...
C::C( const char *name )
: n_( name ),len_( n_.length() ) // 错误!
{}
由于len_成员在成员声明列表中是占先的,它在初始化操作的执行次序中也是排在n_之前的。尽管看上去在成员初始化列表中,len_貌似是写在n_后面的。在此情形下,我们其实是企图调用一个未有初始化过的对象的成员函数,这将导致未定义行为的苦果。
把成员初始化列表中的各个元素排列成基类在先、专属成员与声明列表次序相同的做法,被视为合乎常理。也就是说,以和标准规定的实际初始化次序相吻合的次序来排列。犹有进者,消除成员初始化列表中的成员次序相依性亦是一种上佳实践:
C::C( const char *name )
: len_( strlen(name) ),n_( name ) [25]
{}
至于为什么成员初始化次序要规定成和成员初始化列表中的次序无关,参见常见错误67。
class D : public B,public C { members };
一个class对象中的虚基类的子对象和非虚基类的子对象,布局是不同的。非虚基类型别子对象的典型布局会如同它是派生类型别一个普通数据成员一样,如图 5-1如示。是故,非虚基类的子对象有可能在一个派生类型别的对象中出现多于一次:
class A { members };
class B : public A {members };
class C : public A {members };
class D : public B,public C { members };
而虚基类型别子对象仅在对象中出现一次,哪怕它在构成完整对象的class阶栅(lattice,即继承谱系)中多次出现也不例外,如图5-2所示。
class A { members };
class B : public virtual A { members };
class C : public virtual A { members };
为求演示之便,我们此处使用的是相当老旧的指针式虚基类实现。在一个完整的对象中 A 型别子对象本来应该出现的地方,我们放置了一个指涉到存放单一 A 型别子对象的共享存储的指针。更多的现实环境下,这种指涉到存放子对象的共享存储的手法一般是不用的,取而代之的是使用一个偏移量,或是虚函数表中的附加信息来完成。不过,我们以下的讨论适用于任何一种实现手法。
典型地,存放虚基类子对象的共享存储是附在完整对象之后的。上例中,完整对象的型别是D,是故A型别的虚基类子对象的存储是放在所有D的数据成员之后的。若一个对象的“最深派生类”(most derived class)是B,则其存储布局必然又将不同。
三思之下,你会认识到只有最深派生类才可能知晓虚基类子对象的精确地址。一个型别为B的对象既有可能本身是一个完整的对象,又有可能是内嵌在其他对象中的一个子对象 [26]。是故,负责初始化该class阶栅中的所有虚基类子对象,以及准备好访问它们的底层机制之重任就落到了最深派生类对象的肩上 [27]。
在最深派生类的型别为B的情况下,如图5-3所示,就是B的构造函数来初始化虚基类子对象 A,并将指针指涉到它,以完成虚基类子对象访问机制的准备工作:
B::B( int arg )
: A( arg ) {}
在如图 5-2 所示的对象中,它的最深派生类的型别是D,所以D的构造函数会负责初始化虚基类子对象A,并在B和C中备妥指涉到该对象的指针,以完成虚基类子对象访问机制的准备工作,另外还将完成其直接继承基类(B和C)子对象的初始化工作 [28]:
D::D( int arg )
: A( arg ),B( arg ),C( arg+1 ) {}
这样,一旦虚基类子对象 A被 D的构造函数所初始化,它就不会被 B或 C 的构造函数的再初始化一遍。一种编译器可能采用的实现策略是它会向B或C的构造函数传递一个标识值(flag),或是一个A型别的指针,意思是说:“喂,提醒一句,你可不要把虚基类子对象A再初始化一遍喔。”这里面没有什么怪力乱神。我们再来看另一个 D 的构造函数:
D::D()
: B( 11 ),C( 12 ) {}
这里面揭示了一个使用虚基类时十分常见的误解与缺陷的来源。D的构造函数仍然初始化了虚基类子对象A,但这次它是隐式通过调用A的默认构造函数来完成的。而当它调用B的构造函数来初始化子对象B的时候,它也不会把虚基类子对象A再初始化一遍了 [29]。是故,对虚基类子对象A的非默认构造函数的调用动作不会发生 [30]。
简而言之,只有当一个设计清楚彰显了虚基类的使用场合时,才是它出场的最佳时机(换言之,一旦设计清楚彰显了虚基类的使用确有必要,就不应该使用虚基类之外的手法)。犹有进者,那些意图设计成虚基类的class,最简单的设计手法就是把它们设计成“接口类”(interface class)。接口类不包含数据成员,一般而言其成员函数(也许唯一的例外就是析构函数)全是纯虚函数,它们通常不声明任何构造函数,也没有平凡的默认构造函数:
class A {
public:
virtual~A();
virtual void op1() = 0;
virtual int op2( int src,int dest ) = 0;
// ...
};
inline A::~A()
{}
从此良言不仅对于构造函数的实现过程中防止缺陷大有帮助,对于赋值操作的实现过程也有百利而无一害。标准专门有规定说,编译器供应的复制赋值运算符可能会,也可能不会把一个虚基类子对象赋值多次 [31]。倘若所有的虚基类都同时也是接口类的话,那么编译器供应的复制赋值运算符就肯定实现为空操作(还记得吗?任何class的内部机能,如指涉到虚函数表的指针,不受赋值操作的影响,只在初始化时设置),而且如此一来多次赋值也不会导致缺陷。
对于在继承谱系中含有虚基类的情况下实现赋值运算符的一般解决方案中,总是或多或少地包括对于对象中包含虚基类子对象时的构造函数的语义模仿 [32]。
考虑上述图5-1所示的class D的实现,它其中包含两个非虚基类的A型别的子对象。在此情形中,就像的构造函数一样,一个软件工程师撰写的复制赋值运算符可以完全以其直接基类(immediate base class)提供的设施实现:
gotcha53/virtassign.cpp
D &D::operator =( const D &rhs ) {
if( this != &rhs ) {
B::operator =( rhs ); // 对B型别的子对象赋值。
C::operator =( rhs ); // 对C型别的子对象赋值。
//[33]
// 对D特有的数据成员赋值。
}
return *this;
}
上面这个赋值的实现作了一个合情合理的假设,就是D中的B、C型别的子对象会对于它们分别内含的一个非虚基类的 A 型别的子对象以适当的方式完成赋值的动作。和构造函数一样,这种平凡的、分层进行的手法对于赋值操作的实现来说,在有虚拟继承存在的条件下也玩不转。也和构造函数一样,继承谱系中的最深派生类应该完成虚基类子对象的赋值,同时应该想方设法阻止位于最深派生类和虚基类子对象的层次之间的那些基类子对象重复执行对于虚基类子对象的赋值操作:
gotcha53/virtassign.cpp
D &D::operator =( const D &rhs ) {
if( this != &rhs ) {
A::operator =( rhs ); // 对虚基类子对象A赋值。
B::nonvirtAssign( rhs ); // 对B型别的子对象赋值,除了A部分。
C::nonvirtAssign( rhs ); // 对C型别的子对象赋值,除了A部分。
// 对D特有的数据成员赋值。
}
return *this;
}
这里,我们在 B 和 C 的实现中引入了几个特殊的“类赋值”成员函数(assignment-like member function)。它们完成的操作和其复制赋值运算符在其他的部分完全一样,只是把对虚基类赋值的那部分去掉了。这种做法的确可以有效运作,不过显然它引入了复杂性,并且要求 D 除了其直接基类之外,还要密切关注整个继承谱系的结构调整。任何对于这个继承谱系的结构变动,都会带来重写 D 的实现的业务需求。是故,一如前言,打算被用作虚基类的class,最好还是实现为接口类。
在一个完整对象中的布局隐含着一个推论,那就是不允许使用静态的向下强制型别转换操作(static downcast)将虚基类对象转换至其派生类型别。
A *ap = gimmeanA();
D *dp = static_cast<D *>(ap); // 错误!
dp = (D *)ap; // 错误!
使用 reinterpret_cast 运算符把一个虚基类转化成其某个派生类是合法的。如图5-4所示,这样的转化结果可能是一个无效的地址,因而没啥用。唯一可靠的、能够在一个虚基类型别的指针或引用上执行一个向下强制型别转换的办法是使用dynamic_cast(可是,参见常见错误45):
if( D *dp = dynamic_cast<D *>(ap) ) {
// 对dp进行一些操作
}
当然了,在编码实践中把 dynamic_cast 作为常规武器,往往显示着设计中存在纰漏(参见常见错误98和99)。
以下是几个平凡的组件(component):
class M {
public:
M();
M( const M & );
~M();
return *this;
M &operator =( const M & );
}
// ...
};
class B {
public:
virtual~B();
protected:
B();
B( const B & );
B &operator =( const B & );
// ...
};
让我们以这些组件为出发点,编写成一个新的 class——看起来让编译器替我们卖命做些额外工作也未尝不可:
class D : public B {
M m_;
};
既然D并不从其基类(亦即B)中继承构造函数、析构函数及复制赋值运算符的衣钵,编译器只好忍气吞声,乖乖地根据组件的成员分类来给出相应的实现体(具体内容参见常见错误49)。例如,编译器实现版本的D之构造函数将是一个具有public访问层级的、inline的成员函数。该构造函数会先调用基类 B的默认构造函数,尔后是数据成员的M型别的默认构造函数。而析构函数,一如既往,会走一条相反的路线:先析构成员,再调用基类的析构函数。
有意思的部分在于复制操作。编译器实现的复制构造函数会执行一个按成员进行的初始化(member-by-member initialization),如同我们写了下面这样的代码似的:
D::D( const D &init )
: B( init ),m_( init.m_ )
{}
而编译器实现的复制赋值运算符会执行一个按成员进行的赋值操作(member-by-member assignment),如同我们写了下面这样的代码:
D &D::operator =( const D &that ) {
B::operator =( that );
m_ = that.m_;
return *this;
}
如果我们向这个 class中加了一个不支持这些操作的数据成员,结果会怎么样呢?比如,我们加一个指涉到从堆上分配的X型别的指针成员试试:
class D : public B {
public:
D();
~D();
D( const D & );
D &operator =( const D & );
private:
M m_;
X *xp_; // 新加的数据成员
};
这么一来,我们就得自己动手来显式地实现这些操作了 [34]。默认构造函数和析构函数的实现可谓直奔主题,我们可以把很多麻烦事向编译器一推了之:
D::D()
: xp_( new X )
{}
D::~D()
{ delete xp_; }
编译器会任劳任怨地按应有的次序默默调用基类和成员的默认构造函数及析构函数。这就误导了我们想当然地以为同样的事也会发生在自己实现的复制构造函数和复制赋值运算符身上,不过,这次我们碰了大钉子:
D::D( const D &init )
: xp_( new X(*init.xp_) )
{}
D &D::operator =( const D &rhs ) {
delete xp_;
xp_ = new X(*rhs.xp_);
return *this;
}
这两个实现能够顺顺当当地通过编译,一个编译错误都不会有……然后在运行时出错。我们写的这个复制构造函数的确是正确地把xp_成员初始化为指涉到其初始化物(initializer)的成员xp_所指涉物的一个副本,不过基类子对象和成员m_分别用了B和M的默认构造函数,而并非调用对应class的复制构造函数来初始化。而对于复制赋值运算符来说,其基类部分和成员m_的值则根本没变 [35]。
只要你决定从编译器手里收回权力,亲自捉刀来撰写这些不从基类继承的、独一无二的成员函数,你就得全盘接手,而不能半推半就:
D::D( const D &init )
: B( init ),m_( init.m_ ),xp_( new X(*init.xp_) )
{}
D &D::operator =( const D &rhs ) {
if( this != &rhs ) {
B::operator =( rhs );
x *tmp = new X(*rhs.xp_);
m_ = rhs.m_;
delete xp_;
xp_ = tmp;
}
以上所述对于默认构造函数和析构函数也同样适用,只不过对于默认构造函数来说,对其基类和成员m_的默认构造函数的调用恰好就是正确地生成代码而已。我是想省一些打字的力气,不过如果你不嫌麻烦,也可以显式写全 [36]:
D::D()
: B(),m_(),xp_( new X ){}
所有C++代码中的静态数据都会在被访问之前完成初始化。大多数情况下,此种初始化工作都会完成于二进制镜像(program image)已加载,但代码的执行还未开始的时刻。如果未给它们指定初始化物,它们就会被以全零模式(all zeros)初始化。
static int question; // 0
extern int answer = 42;
const char *terminalType; // null
bool isVT100; // false
const char **ptt = &terminalType;
所有这些初始化动作都可以看作是“同时”进行的,次序问题在这里不会惹是生非。
我们同样也可以使用运行期静态初始化(runtime static initialization)。在此情况下,对于编译单元(translation unit)之间的次序是没有任何保证的(所谓一个编译单元,基本上就是一个预处理文件)。这是软件缺陷的一个高发地带,因为在源代码毫发未动的情况下,运行期静态初始化次序却会发生变化:
// in file term.cpp
const char *terminalType = getenv( "TERM" );
// in file vt100.cpp
extern const char *terminalType;
bool isVT100 = strcmp( terminalType,"vt100" )==0; // 错了吗?
在terminalType和isVT100的初始化之间存在着并不显明的次序相依性,但是 C++语言没有,也不可能保证一个特定的初始化次序。这个常见错误一般会在现存的、运行如仪的代码被移植到另一个碰巧使用了不同的运行期静态初始化次序的平台时发生。还有可能在源代码不变(平台也不变)的前提下,只是建构的手续作了一点变更或是某个先前使用静态链接的设施变成了动态链接,它也会冒出来捣乱。
一定要记住,静态 class对象的默认初始化列表同样也会引发运行期静态初始化:
class TermInfo {
public:
TermInfo()
: type_( ::terminalType )
{}
private:
std::string type_;
};
// ...
TermInfo myTerm; // 运行期静态初始化!
最好的预防运行期静态初始化困境的办法就是尽可能地减少外部链接的变量,包括静态的class数据成员(参见常见错误3)。
除此之外,一种解决方案是把问题转化为对于在同一编译单元内初始化次序的相依性。这个次序是有着良好定义的,在同一编译单元内静态变量的初始化次序是和他们获取定义的次序一致的 [37]。比如,如果上面terminalType和isVT100的定义如果是同一文件中按照相同次序出现的,那就并不存在可移植性问题。可即使是这样的过程,也仍有可能会出现初始化次序的问题,如果一个外部的函数(包括成员函数)使用了一个静态的变量,而该函数会被其他的编译单元中运行期静态初始化过程直接或间接地调用 [38]:
extern const char *termType()
{ return terminalType; }
再除此之外,另一种做法就是使用缓式评估求值来代替初始化。典型做法一般都是采用单件设计模式的某种变形来完成的(参见常见错误3)。
作为最后一种解法,我们可以显式地将初始化次序以代码形式固定下来,使用标准技术就可以做到。其中一种标准技术就是 Schwarz 计数器,它因其发明者Jerry Schwarz而得名,而且被采用于他撰写的输入/输出流库的实现中:
gotcha55/term.h
extern const char *terminalType;
// 其他要初始化的东西
class InitMgr { // Schwarz计数器
public:
InitMgr()
{ if( !count_++ ) init(); }
~InitMgr()
{ if( !--count_ ) cleanup(); }
void init();
void cleanup();
private:
static long count_; // 每个进程生成一个
};
namespace { InitMgr initMgr; } // 每次头文件包含生成一个
gotcha55/term.cpp
extern const char *terminalType = 0;
long InitMgr::count_ = 0;
void InitMgr::init() {
if( !(terminalType = getenv( "TERM" )) )
terminalType = "VT100";
// 其他的初始化工作
}
void InitMgr::cleanup() {
// 所有要求的清除工作 vs.
}
Schwarz计数器会计数它被不同的头文件包含的次数 [39],对于每个进程,InitMgr class的静态成员count_会有一个单独的实例。但是,每当term.h被包含一次,就会分配一个新的InitMgr对象,而这当然就会要求一次运行期静态初始化。此时,InitMgr class的构造函数就会校验count_成员来确定这是不是该进程内InitMgr对象的“首次”初始化。如果是的,初始化工作就被触发执行。
相反地,如果进程正常结束,这些具有析构函数的静态 InitMgr 型别的class对象就会被依次析构。在每次InitMgr对象执行析构工作的时候,其析构函数就会将静态成员count_减1,当count_被减到0的时候,所有要求的清理工作都会被触发执行。
当然,总有粗暴的尤其是钻牛角尖的编码能够将Schwarz计数器这等铜墙铁壁都彻底打破。一句话,尽量别用静态变量,永远都别做运行期静态初始化。
在我工作的那个年代,浅陋的初始化俯拾皆是。考虑一个平凡class Y:
class Y
{public:
Y( int );
~Y();
};
毫无悬念地,对于一个Y对象进行一个平凡的初始化过程有3种写法 [40]。这3种写法貌似是等价的。貌似而已。
Y a( 1066 );
Y b = Y(1066);
Y c = 1066;
穷其实情而论,这3种写法很可能最终产生了完全一样的目标码,但不能因此就说它们是彼此等价的。对于a的初始化称为直接初始化,它的确精确地达成了人们的期望。这个初始化是直接通过一次对于Y::Y(int)的调用来完成的。
而对于b和 c的初始化就复杂得多了,事实上,它们是太过复杂了。它们都是复制初始化。在对 b初始化的情形中,我们先要求产生一个Y型别的匿名临时对象,以值1066初始化之。然后,该匿名临时对象被用作Y型别的复制构造函数的参数来完成对 b 的初始化。最后,我们调用析构函数将该匿名临时对象析构,实质上,我们在要求编译器产生类似于下面的代码:
Y temp( 1066 ); // 以值1066初始化匿名临时对象
Y b( temp ); // 以匿名临时对象为初始化物复制构造b
temp.~Y(); // 析构函数被调用(以析构匿名临时对象)
对 c初始化时的语义与 b完全相同,只不过产生匿名临时对象的要求不那么彰明罢了。我们来对 Y 的实现稍作改动,加一个自定义版本的复制构造函数看看会怎么样:
class Y {
public:
Y( int );
Y( const Y & )
{ abort(); }
~Y();
};
显然,Y 对象对于任何复制构造函数都没有容忍度。不过,如果我们重新编译和运行这个小程序的话,所有这 3 个初始化语句可能都不声不响地通过了,并未引起进程中止(亦即,abort并未有被调用)。这说明了什么?
这说明了标准允许编译器执行某种代码变换,去除这个临时对象的初始化和复制构造函数的调用,以产生和直接初始化一模一样的目标码。请注意这不是一个平凡的“优化”,因为代码的实际行为改变了(你看,我们本来应该中止进程的,但是没有)。大多数C++编译器都会做这样的代码变换,但标准并没有强制要求这么做。既然存在着这样的不确定性,那还是言为心声,在class对象的声明语句中使用直接初始化的好:
Y a(1066),b(1066),c(1066);
如果你脾气很倔,想保证编译器不去做这种代码变换,理由是你想利用临时对象的初始化和复制构造函数的调用带来的副作用,或是你就是想要一个又大又没效率的应用程序。不幸的是,既然符合标准的编译器能够自行决定是否做此种代码变换,想保证你要的语义并不容易。避免代码变换的可移植的做法(而不是借助平台相依的编译开关或#pragma 达到目的)实在令人不忍卒视:
struct {
char b_[sizeof(Y)];
} aY; // 准备一个对齐的、和Y具有相同尺寸的存储块
new (&aY) Y(1066); // 使用定位new创建一个中间对象
Y d( reinterpret_cast<Y &>(aY) ); // 复制构造
reinterpret_cast<Y &>(aY).~Y(); // (手动调用析构函数)析构中间对象
这几乎完整再现了不做代码变换时的复制初始化语义。区别在于,aY所使用的存储很可能之后就不会在栈存储中被再次利用了,而编译器创建的临时对象如果也使用了这样的一个存储可能就可以循环利用(参见常见错误66)。不过,如果只是故意要写出又大又没效率的代码来的话,大可不必费此周章。
理解该代码变换换的一个要点在于了解编译器是在校验过原始代码的语义之后才实施这个变换的。如果未经变换的初始化根本就是错的,那么编译器就会报错,即使做完变换后错误会消失 [41]。考虑一个class X:
class X {
public:
X( int );
~X();
// ...
private:
X( const X & );
};
X a( 1066 ); // 没问题
X b = 1066; // 错误!
X c = X(1066); // 错误!
未经代码变换的b和c的初始化过程要求访问X型别的复制构造函数,但是设计X型别的软件工程师决定禁止对其class对象的复制构造,是故他把X型别的复制构造函数声明于private访问区段 [42]。即使代码变换本来应该消除掉对于X型别的复制构造函数的调用,这段代码仍然通不过编译。
直接和复制初始化对非 class对象也同样起作用,不过结果是可预期的,也是可移植的:
int i(12); // 直接初始化
int j = 12; // 复制初始化,结果相同
对于这种型别,想用哪种形式的初始化都可以,就选看起来最顺眼的那种好了。不过,在模板内,还是使用直接初始化会比较好,因为那里变量的型别在具现之前都是未知的。考虑一个简化了的计算序列长度的泛型算法,它不仅接受序列的迭代器型别作为实参(In),还把它的计数器型别作为另一个实参(N):
gotcha56/seqlength.cpp
template <typename N,typename In>
void seqLength( N &len,In b,In e ) {
N n( 0 ); // 要这样写,不要写成"N n = 0;"
while( b != e ) {
++n;
++b;
}
len = n;
}
在这个实现中,(对N进行)直接初始化的采用使得我们能够处理(我承认这比较诡异)自定义的、禁止进行复制构造的数值型别。而一个对 N 采用了复制初始化的seqLength实现则不会允许我们这么做。
为了让代码更简单,更具可移植性,一个较佳的做法是在 class对象,或“有可能是class对象之实体”的声明语句中使用直接初始化。
我们都知道形参是以实参为初始化物来完成初始化动作的,但是,是怎样的初始化——直接还是复制初始化?这还不简单,写段测试代码来验证一下不就得了:
class Y {
public:
Y( int );
~Y();
private:
Y( const Y & );
// ...
};
void f( Y yFormalArg ) {
// ...
}
// ...
f( 1337 );
如果实参传递是以直接初始化来实现的,对于 f 的调用就应该正确无误。如果它是用复制初始化来实现的,编译器就应该报错,因为复制初始化隐式要求一个对Y型别声明在private访问区段的构造函数的调用。大多数编译器都允许这个调用,所以我们可能得出结论说实参传递以直接初始化实现。遗憾的是,大多数编译器都错了,或起码跟不上形势。标准上说得明明白白:实参传递是以复制初始化实现的,所以上面对于 f 的调用是非法的。对于yFormalArg的初始化和下面的这个声明语句的语义完全相同:
Y yFormalArg = 1337; // 错误!
如果我们想写出符合标准的、可移植的、能够在编译器朝着标准的细节靠拢的过程中仍旧岿然不动的代码,我们就要避免写出像上面那样调用 f 的代码来。
上面的代码还可能会导致性能问题。如果调用f的函数拥有访问Y型别声明在private访问区段的构造函数的权限 [43],这调用虽然可以成功,但是就相当于写了类似于下面的代码:
Y temp( 1337 );
yFormalArg( temp );
// f的函数体...
yFormalArg.~Y();
temp.~Y();
换言之,对于形参的初始化会包括一个临时对象的构造、形参的复制构造,在函数返回时,先是形参被析构,接着临时对象也被析构。4个函数调用,这还不算f本身。幸运的是大多数编译器都会做代码变换以消除临时对象的构造以及形参的复制构造,产生出直接初始一般的目标码:
yFormalArg( 1337 );
// f的函数体...
yFormalArg.~Y();
无论如何,即使这样也不能说已经做到了最佳。我们用 Y 对象来初始化yFormalArg怎么样呀?
Y aY( 1453 );
f( aY );
这里我们仍然有一次以aY为初始化物的、对于yFormalArg的复制构造函数的调用,在 f 函数返回时还有一次析构。一个好得多的做法是尽一切可能避免把 class对象以传值方式做实参传递,应该偏好使用指涉到常量的引用方式做实参传递:
void fprime( const Y &yFormalArg );
// ...
fprime( 1337 ); // 能够运作!没有复制构造了。
fprime( aY ); // 能够运作,效率很好
在前一个语句中,编译器会产生一个Y型别的临时对象,以值 1337 初始化之,并以该临时对象为初始化物来初始化用作引用的形参。该临时对象会在fprime返回的瞬间灰飞烟灭(参见常见错误44,那里我讨论了返回这种实参的极大危险性)。不仅在效率上,它等同于做过代码变换后的解决方案,并且它还是具有在标准C++语言框架下的合法性 [44]。而后一个调用fprime的语句则完全不会引发临时对象的构造,避免了此开销,而且它还消除了函数返回时调用析构函数的必要性。
operator +( String &dest,const String &lhs,const String &rhs ) {
String operator +( const String &lhs,const String &rhs ) {
很多情况下,让函数以传值方式返回结果是必须的。比如,下面这个String class 实现了一个二元连接运算符,它就必须把两个已知的型别的对象的连接结果以传值方式返回:
class String {
public:
String( const char * );
return temp;
String( const String & );
}
String &operator =( const String &rhs );
String &operator +=( const String &rhs );
friend String
operator +( const String &lhs,const String &rhs );
// ...
private:
char *s_;
};
如同形参的初始化一样,对于return语句引发的函数返回值的初始化也是通过复制初始化完成的:
String temp( lhs );
temp += rhs;
return temp;
逻辑上讲,在函数的调用点会以temp为初始化物来调用复制构造函数,以初始化一个返回值数据区(return area),然后 temp 被析构。一般情况下,编译器的实现手法是把返回值目的数据区作为函数的一个隐式的额外实参,如同函数是像下面这样书写的一样:
void
operator +( String &dest,const String &lhs,const String &rhs ) {
String temp( lhs );
temp += rhs;
dest.String::String( temp ); // 编译器生成的复制构造函数调用
temp.~String();
}
请注意编译器能够生成一个对复制构造函数的直接的、字面的调用,但我们是不能这么写。仅靠软件工程师(由于不是编译器,没有某些权限)一己之力是不够的,必须借助一些秘笈以达目的:
new (&dest) String( temp ); // 定位new的技巧,参见常见错误62
这个变换的一个隐式的推论是:对 class对象而言,以函数的返回值为初始化物,要比以函数的返回值对其赋值来得高效:
String ab( a+b ); // 高效
ab = a + b; // 未必高效
在ab的声明语句中,编译器可以自由地直接将a+b的计算结果复制到ab的数据区域中去。不过,这对于赋值语句来说就绝无可能了。赋值运算符是String class的一个成员函数,它执行的操作类似于先把ab析构,再对它重新做一次初始化;是故,永远都不应该尝试对未初始化的存储赋值(参见常见错误47):
String &String::operator =( const String &rhs );
为了初始化String型别的成员运算符函数的rhs实参,编译器被迫将a+b的结果复制到一个临时对象中,用该临时对象来初始化rhs,尔后在operator =返回时析构该临时对象。为着效率起见,初始化是要优于赋值的。
考虑一下在返回的表达式的型别有别于函数声明中的返回值的型别时,复制初始化的语义是什么:
String promote( const char *str )
{ return str; }
这里,复制初始化的语义强制要求str被用作一个局部String型别的临时class 对象的初始化物,而该临时对象然后被用来做返回值的复制构造的原本。最后,该临时对象会被析构。不过,编译器还是被允许对返回值的初始化应用和声明语句的初始化和形参初始化相同的代码变换。很有可能编译器会调用 String class 的某个复制构造函数之外的构造函数来直接以 str 为初始化物来完成返回值的初始化,釜底抽薪地阻止局部临时对象的创建。当“以直接初始化语义代替复制初始化语义”的代码变换在有关函数返回值的语境中被提及时,它通常被称为“返回值优化”(return value optimization),或 RVO。
软件工程师常常受到蛊惑,去使用更加低级的设施来获取更高的效率:
String operator +( const String &lhs,const String &rhs ) {
char *buf = new char[ strlen(lhs.s_)+strlen(rhs.s_)+1];
String temp( strcat( strcpy( buf,lhs.s_ ),rhs.s_ ) );
delete [] buf;
return temp;
}
不幸的是,这段代码可能要比我们之前那个operator +的实现要着实慢上好些。我们分配了一个局部字符存储块,在其中连接了两个String型别的实参的字符表示,而所有这一切不过是为了使用这个存储块来初始化一个String型别的返回值,而这个存储块本身则被“用后即抛”了。
遇到这种情形的话,有时在 class 型别的实现中引入一个“计算型构造函数”会有些助益。所谓计算型构造函数,它是class型别的实现的一个组成部分,并且通常具有private访问层级。它在基本意义上是一个“助手”函数,只不过以构造函数的手法来实现,用以操控仅构造函数才拥有特权的属性,而其他成员函数都做不到。一般地,令人感兴趣的此种属性就是只有构造函数的处理对象才是一块未初始化过的存储,从而间接地保证不需要做任何“清场”的工作:
class String {
// ...
private:
String( const char *a,const char *b ) { // 计算型构造函数
s_ = new char[ strlen(a)+strlen(b)+1];
strcat( strcpy( s_,a ),b );
}
char *s_;
};
这里我们使用该计算型构造函数来为该 String class 的其他成员函数提供有效率的返回值模塑机制(facilitate):
inline String operator +( const String &a,const String &b )
{ return String( a.s_,b.s_ ); }
回想一下,函数返回值的复制初始化就等同于下面这个声明:
String retval = String( a.s_,b.s_ );
如果编译器对初始化执行了代码变换,那我们就得到了相当于直接初始化的作用机制:
String retval( a.s_,b.s_ );
普遍地,计算型构造函数是平凡的,可以被用作 inline 函数。调用它的operator + 现在也成了一个inline函数的合适候选,其结果是一个效率高度提升的实现,等价于手工打造的(很可能更高效的)解决方案。但是,请注意计算型构造函数并未向型别的公开接口中添加任何东西。是故,它应该被理解为 class型别的实现(而非接口)的一部分,并且应该声明在private 访问区段。任何只有一个实参的计算型构造函数都应该无一例外地在声明中加上explicit,以防止与该class型别的隐式型别转换集(set of implicit conversion)相冲突(参见常见错误37)。
C++语言的编译器还会在函数返回值初始化的语境中实施另一种常见的代码变换,称为“具名量的返回值优化”(named return value optimization),或NRV。这种变换和RVO很相似,不过它允许具名的局部变量持有返回值。考虑的最原始的实现:
String operator +( const String &lhs,const String &rhs ) {
String temp( lhs );
temp += rhs;
}
如果编译器对这段代码应用了NRV,局部变量temp就会被替换为调用点处返回值的实际目标数据区域的引用。以效果论,就如同函数是像下面这样书写的一样:
void
dest.String::String( lhs ); // 编译器生成的复制构造函数调用
dest += rhs;
}
NRV 得以成功实施的典型场合是编译器能够确认所有的返回表达式都一样,并指涉到同一个局部变量。为了增加NRV成功实施的机会,最好是只有一句返回语句,返回单独一个局部变量,或者实在不行的话,让所有的返值语句都返回同一个局部变量。总之是愈平凡愈好。请注意,NRV尽管全称有一个“优化”的字眼,却是一种代码变换,而不是一种优化,因为任何临时对象的初始化带来副作用都会随着初始化过程的抹除而被一并抹除。
从代码变换中收获的性能回报有时会十分可观,所以应该想方设法来扩大它们的应用范围。方法就是使用计算型构造函数,或是使用平凡局部变量来持有返回值。
静态数据成员独立于其宿主 class型别的任何对象实例,一般地,它在任何对象实例被构造之前就已经存在。注意下面这些约束,它们一般是存在的:如同成员函数(无论是静态的还是非静态的)一样,静态数据成员有着外部的链接,而且仅作用于其宿主class的辖域:
class Account {
// ...
private:
static const int idLen = 20;
static const int prefixLen;
static long numAccounts;
};
// ...
const int Account::idLen;
const int Account::prefixLen = 4;
long Account::numAccounts = 0;
对于整数型别的常量以及枚举型别的静态数据成员而言,初始化动作在class 结构的内部或外部做都可以,但是只能做一次。但是对于整数型别的常量而言,使用枚举型别常常是以整数字面量初始化(非枚举整数型别静态成员)的一个合理的替代解决方案:
class Account {
// ...
private:
enum {
idLen = 20,
prefixLen = 4
};
static long numAccounts;
// ...
};
long Account::numAccounts = 0;
枚举量在一般情况下可以完全替代整数型别的常量。不过,它们不占用存储,是故不能被指涉,它们的型别也不同于int,是故,当枚举量被用作实参来调用重载函数时,可能会干扰函数的重载解析匹配。请注意,尽管在class结构外部写一个numAccounts的定义是必要的,但对其显式的初始化却并不必要,如果不提供显式定义,它会被默认地使用全零模式或 0 来初始化。不过,显式地给予一个 0 来完成初始化仍然是个上佳实践,因为这么一来就等于先发制人地阻止一个做维护工作的软件工程师把它初始化成别的什么东西 [45](出于某种原因,1或−1比较常见)。参见常见错误25。
使用运行期静态初始化来初始化静态 class 型别成员是个糟糕透顶的主意。静态成员本身可能还没来得及被初始化呢,而静态的 class对象可能已经就已经被运行期静态初始化给初始化掉了:
class Account {
public:
Account() {
...calculateCount() ...
}
// ...
static long numAccounts;
static const int fudgeFactor;
int calculateCount()
{ return numAccounts+fudgeFactor;
};
// ...
static Account myAcct; // 糟糕!
// ...
long Account::numAccounts = 0;
const int Account::fudgeFactor = atoi(getenv("FUDGE"));
在静态数据成员fudgeFactor得到其定义之前,Account对象myAcct就抢先来获取定义了。这样,myAcct 的构造函数在调用其成员函数calculateCount时,就会使用其未经初始化的静态数据成员fudgeFactor (参见常见错误55)。静态数据成员fudgeFactor的值将是0,因为它是使用了静态成员的全零模式初始化过程来初始化的。如果恰好对于静态数据成员fudgeFactor来说是一个有效值,那这将是一个极难检出的软件缺陷。
有些软件工程师企图采用在 class的每个构造函数中“初始化”静态数据成员的做法来规避这个问题。这是不可能的,因为静态数据成员不能出现在构造函数的成员初始化列表里,而一旦代码的执行流到达了构造函数的函数体中,就不可能再做任何初始化的工作,而只能赋值了:
Account::Account() {
// ...
fudgeFactor = atoi( getenv( "FUDGE" ) ); // 错误!
}
唯一的出路就是把fudgeFactor声明成非静态数据成员,然后为每个构造函数撰写“缓式初始化”代码(参见常见错误 3),然后祈祷所有来维护这段代码的软件工程师都能记得把所有对初始化代码的改动,都挨个地在所有的构造函数里做出相应的改动。
最好还是把静态数据成员像其他静态成员一样处置。别使用它们,如果可以的话。如果你非用它们不可,那就好好初始化它们,但不要使用运行期静态初始化,谢天谢地。
[1].译者注:参见(Koenig,et al.,2008),§ 25。
[2].译者注:有关这一段代码所存在的问题,是 C++新手和一切含有指针和引用语义的语言的新人都极容易犯的著名错误,即“别名错误”。事实上此class的名字也是其行为的隐喻,即“浅拷贝”——最简单的含义就是对于一个class 对象的指针成员不复制其指涉之物,而直接复制其地址值的复制行为。有关此问题在 C++语言中的进一步探讨,参见(Meyers,2001),条款 11。
[3].译者注:此处extern关键字为严谨所加,意表实现可略。
[4].译者注:此段从STL中抽取的代码可能有些难以理解。若要了解更多相关内容,参见(Austern,2003),§3.1和§ 14.4。
[5].译者注:(Meyers,2003),条款 16解释了什么需要使用如此不同寻常的语法。
[6].译者注:operator =返回*this的理由参见(Meyers,2006),条款10。
[7].译者注:这个“自赋值”判断的深入探讨参见(Meyers,2006),条款 11。
[8].译者注:下述的算法实现中,b和e用来指定一个前闭后开区间。
[9].译者注:通过采用准“定位”复制构造代替类似realloc()的清除加重新从头构造的语义。
[10].译者注:意即声明不必放在函数的最前面,这样可以省却不少非必要的声明前置带来的存储浪费。有关此话题的深入讨论,请参见(Meyers,2006),条款26。以上这段文字的核心思想在于,变量的辖域的起点应该在它有必要被声明的那一点开始,而不应该提前。
[11].译者注:这是对写码风格的一个深度批判。一个变量应该有多局部?一个不够局部化的变量能够带来多大的麻烦?维护工程师为了省事,不重新声明一个实体而“复用”现成却并不相干的局部变量,这究竟是谁之过?从本条款内容来看,作者是将代码原作者和维护工程师各打五十大板。
[12].译者注:这在分布式开发或是长长的代码里,是很容易发生的事,特别是维护和上次部署之间隔了很长时间的话。
[13].译者注:参见(Koenig,1996),§12.8,第8节对class成员的复制行为如何按型别区分的说明。
[14].译者注:这就是为何把这个class命名为NBString,请注意上面的实现是使用模板实参来指定尺寸,而下面这个实现则是使用一个额外的函数实参来指定。
[15].译者注:class对象本身的数据成员的状态是所谓物理属性,此处s_成员的状态可能并未变化,但“字符串中用于字符元素的存储”——即s_成员的指涉物——却可能已经发生了变化了。如果class对象使用其物理属性之外的某些状态来表示 class对象本身的逻辑状态,这就昭示着复制操作必须被接管。换言之,编译器隐式生成的复制操作只照顾class对象的物理属性。
[16].译者注:既要写复制构造函数,也要写复制赋值运算符。
[17].译者注:注意s_的初始化方式,这里利用了strcpy()返回值的副作用。
[18].译者注:请仔细研读这个复制赋值的实现,对初学C++的读者而言,应该熟读成诵。
[19].译者注:有关此段代码的更深入讨论,参见(Meyers,2006),条款6。
[20].译者注:只能说是一个以基类对象为实参的赋值运算符。
[21].译者注:C++语言中,型别的数据语义不会因为使用的关键字是 struct 还是 class而发生变化。有关此议题的更深入讨论,参见(Lippman,2001),§1.2。
[22].译者注:软件工程师能够控制的范围有限,不能直接以标准C++语句来表达CPU指令级别的安排,但是优化的编译器却能够这样做。
[23].译者注:这些变更之所以会引起问题,起缘于两个方面:或是发生了 class数据语义学变化,如添加了隐式的指针;或是由于class的物理状态之外别有文章,参见常见错误47。
[24].译者注:有关此议题的更深入讨论,参见(Meyers,2001),条款12。
[25].译者注:这里没有改动声明次序,然后用n_.length()来初始化len_,而是直接用strlen(name)来初始化len_,这就消除了成员间的次序相依性。
[26].译者注:而最深派生类的对象则只可能是一个完整的对象。这里涉及到一个问题,就是如何在编译期判断在一个包含复杂继承层次的 class阶栅中,两种型别之间哪一种的继承层次较深?换言之,哪一种型别更泛化?这个问题的部分答案参见(Alexandrescu,2005),§ 2.7和(Sutter,2002),条款 4。当然,真实的编译器作者应该可以获知更多的语境信息。有关虚基类子对象布局的更多讨论,请参见(Lippman,2001),§3.4 和(Stroustrup,2002),§12.4.1。
[27].译者注:作者这段文字写得非常晦涩,这里面的逻辑关系需要补充说明。为何由于“非最深派生类既有可能本身是一个完整的对象,又有可能是内嵌在其他对象中的一个子对象”,它们就不可以完成虚基类子对象的初始化工作,以及访问机制的准备工作?问题的关键在于两点:一是由于虚基类的定义,这样的初始化工作在每个对象中只做一次,而在整个对象的布局信息没有明了之前如果做了这样的工作,那么在同一层次或更深层次的类中就无法确定是不是应该再做一次,而如果仅在最深派生类中做这件事,就可以保证这样的工作只做一次。二是根据(Lippman,2001)的有关数据语义学的阐述,有些编译器会把虚基类子对象的地址记录为一个偏移量,显然只有最深派生类才知道这个偏移量的精确值是多少,因为它知道有关对象布局除虚基类子对象以外的完整尺寸信息,而其他任何类都不可能了解这个信息,因为它不了解在同一层次或更深层次的类中有哪些不同的成员或是添加了什么样的成员,当然也就不知道尺寸会如何增加或变化了。有关为何只有最深派生类才应该负责虚基类子对象的初始化工作,以及访问机制的准备工作的一个比较形象而完整的说明,参见(Lippman,2001),§ 5.2。
[28].译者注:在这种情况下,由于显式地在成员初始化列表里指定了对于虚基类子对象 A 的初始化形式,亦即调用 A的构造函数的形式,所以就能够调用到正确的多个 A 的重载版本的构造函数中适当的一个。而根据上文所示,正好子对象B也是需要以这种形式来初始化虚基类子对象A的,可谓歪打正着。
[29].译者注:调用C的构造函数来初始化子对象C的时候也一样。
[30].译者注:作者省去了很多语境的解释,其竭力想表达的意思就是:如果不手动指定虚基类子对象的构造函数的形式,最深派生类就会调用它的默认构造函数——即本条款题目所揭示的对于虚基类子对象的默认初始化——而且由于对于虚基类子对象只初始化一次的原则会阻止初始化动作的再度发生,并且虚基类子对象在一个最深派生类型别的完整对象中只有唯一副本,而这可能并非 class设计者的初衷。想要“先初始化一个虚基类子对象,再在以后的调用动作中修改其状态”是做不到的,这样的企图会因为虚基类子对象只初始化一次的原则而失败。
[31].译者注:参见(Koenig,1996),§12.8。
[32].译者注:虽然标准规定了在此处的灵活性,但赋值运算符和对应版本的构造函数的语义一致性也是一个重要的习惯用法,以避免造成不必要的讶异,这是本章中反复强调过的核心精神。在此处,作者应该是意指对于虚基类子对象的赋值运算也尽可能地做到只赋值一次,和构造函数的初始化行为相洽。
[33].译者注:同时也完成了B、C型别的子对象内含的A型别的子对象的赋值。
[34].译者注:如果内有需要动态配置内存的成员,那么就需要自己动手实现复制构造函数和复制赋值实现,否则会造成别名问题。更深入的讨论,参见(Meyers,2001),条款11。
[35].译者注:这段话的核心思想是编译器并不会智能地甄别出构造函数的“类型”,不管是默认构造函数还是复制构造函数还是其他任何构造函数,如果没有明确地在构造函数的函数体中显式写出对象各组成部分的初始化方式,它就会用默认方式把所有的未指明部分初始化。而如果是复制赋值运算符的话,编译器更是不会擅自添加代码来覆写被赋值对象的未指明部分,而是会保留原值不动——Stanley B.Lippman甚至指出,这种情况下编译器连是否会暗中合成该复制赋值运算符都不一定,参见(Lippman,2001),§5.3。这实际上把本条款的标题概念赋予了一个更广的外延——它不仅谈到了复制构造函数,也谈到了复制赋值运算符,不仅谈到了基类子对象部分的初始化和赋值,也谈到了成员部分的初始化和赋值。有关该部分的更完整讨论,参见(Meyers,2006),条款 12。
[36].译者注:如果觉得尚达不到作者的段位,还是老老实实地写全为妙,少记一条规则带来的好处往往大于少打几个字。
[37].译者注:就是和它们在代码中出现的次序一致。
[38].译者注:作者的意思是说,尽管在同一编译单元内静态变量的初始化次序有着良好定义,但是它们可能会被某个外部定义的函数取用,而这个外部定义的函数又可以被其他的编译单元中的运行期静态初始化过程调用,这么一来“同一编译单元”这个辖域的边界又被打破了。换言之,问题又回到了原有的老路上,又变回了跨编译单元的运行期静态初始化问题。
[39].译者注:利用匿名名字空间。
[40].译者注:回字有四种写法,你知道吗?
[41].译者注:作者的意思是说,这个代码变换是在初始化过程本身可以执行的前提下才会做的,如果初始化本身就是非法的,那是不能指望把通过这一步优化掉把非法变成合法的。但一旦初始化是合法的,那不管初始化的过程中包含什么代码都统统会被优化掉。
[42].译者注:同时应该禁止访问的是其复制赋值运算符,参见(Meyers,2006),条款6。
[43].译者注:Y型别的成员函数及其友元。
[44].译者注:因为不再需要调用复制构造函数,也就不必担心访问区段的问题了。
[45].译者注:并非所有的软件工程师都能立刻反应出来“隐式初始化即全零模式初始化”,有的人会误以为这里是不是写错了,然后画蛇添足地多写一句就坏了。
C++语言在内存管理方面提供了巨大的灵活性,但只有少部分程序员对可用的机制有着通透的理解。在语言的这个部分,重载、名字隐藏、构造函数和析构函数、异常、静态函数和虚函数、运算符和非运算符函数统统同台登场,为内存管理提供了极高的灵活性和可定制性。不幸的是,也许这无可避免地带来了一定程度的复杂性。
在本章中,我们将考察 C++语言中各色各样的语言特性是如何组织起来共同完成内存管理的重任,它们如何时不时地产生出人意料的交互,以及应该如何简化它们之间的这种交互。
内存——这只是程序需要管理的诸多资源中的一种,我们同样会考察如何将其他资源的管理等效变换成内存的管理,从而能够利用 C++语言高度发达的内存管理机制也管理好其他种类的资源。
Widget对象和基型别为Widget的数组是同一种东西么?显然不是。那为何如此众多的 C++软件工程师内存的分配和回收对于数组和纯量采用了不同的运算符这件事大惑不解?
我们知道应该如何为单独一个Widget对象分配和回收内存,我们使用new和delete运算符来做这两件事:
Widget *w = new Widget( arg );
// ...
delete w;
和C++语言的大多数运算符不同,new运算符的行为不可以通过重载加以改变。它总是调用一个名为 operator new 的函数以内建地获取一定量的存储,然后把它初始化。在以上Widget的具体例子中,new运算符先是引发了一次带有一个size_t型别实参的operator new函数的调用,然后在取得的未初始化的存储上调用Widget型别的构造函数以产生一个Widget对象。而delete运算符先是调用了Widget型别的析构函数,然后调用了一个名为 operator delete 的函数以内建地回收掉先前被业已结束生命时域的Widget对象占用掉的存储。
内存分配和回收行为的各种花样都是通过重载、替换或遮掩函数operator new和operator delete的手法玩出来的,而不是通过改变new和delete运算符的行为。
我们同样也知道怎么去为一个基型别为Widget的数组分配和回收内存,但我们不用new和delete运算符:
w = new Widget[n];
// ...
delete [] w;
我们在这里使用 new []和 delete []运算符(读作“数组 new”和“数组delete”)。与new和delete运算符的相似之处在于,数组new和数组delete运算符的行为也是被固化了的。数组new首先调用名为operator new []的函数去获取一些存储,然后(如果可能的话)从头到尾地为每个分配了内存的数组元素做默认初始化。数组delete则先以初始化次序的逆序析构所有的数组元素,然后调用名为operator delete []的函数将内存回收。
插句题外话,请注意使用标准库中的vector组件通常是更好的设计创意。相对于数组而言,vector组件和它几乎具备同样的效率,但特别安全而且高效。一般地,vector组件可以看作是一种“智能”数组,具备相似的语义。不过,当vector组件被析构时,它是从头到尾地对元素做析构操作的:这和数组做析构操作的次序恰好相反。
内存管理函数必须被正确地配对。如果使用 new 运算符来获取存储,就应该使用delete运算符来回收。如果使用malloc函数来获取存储,就应该用free函数来回收。有时候,硬用free函数去配对new运算符,或是用malloc函数去配对delete运算符在非常有限的一些平台上能够“运作”一段时间,不过没有任何保证说这样的代码能够持续运作:
int *ip = new int(12);
// ...
free(ip); //错误!
ip = static_cast<int *>(malloc( sizeof(int) ));
*ip = 12;
// ...
delete ip; // 错误!
对于数组new和数组delete而言,也有同样的要求。一个常见的错误就是使用数组 new 来分配一整个数组的内存,却使用针对纯量的普通 delete运算符来回收。如同驴唇不对马嘴的new运算符和free函数的配对一样,这样的代码可能在某种特定情形下撞大运般地得以运作,但是无论如何都属于大错特错,必将在未来的某个时刻崩溃:
double *dp = new double[1];
// ...
delete dp; // 错误!
请注意,编译器标识不出对于针对纯量的普通delete运算符的误用,因为它分辨不出指针究竟指涉到了一个数组还是单独一个纯量元素。编译器的典型手法是在使用数组new分配内存时,会在紧邻着分配给数组的存储的区域插入一些额外信息,不仅指出分配了的存储尺寸,还包括已分配内存的数组中有多少个元素。这个信息将被检视,接着就被数组delete拿来派析构数组时之用。这种额外信息可能在使用针对纯量的普通 new 运算符时使用不同的格式,如果针对纯量的普通delete运算符被调用于一块经由数组new分配的存储上,这种用于指示存储尺寸和元素数量的计数信息——本来是打算被数组delete来解释和利用的——就很有可能被针对纯量的普通delete运算符误读,引发未定义行为。还有可能编译器为针对纯量和数组的分配准备的是不同的内存池(memory pool,指不同的物理或逻辑内存区域)。在这种情况下,使用针对纯量的普通delete运算符把数组内存池中的存储退还到纯量内存池中去的行为极有可能灾难性地崩溃。
delete [] dp; // 没问题
对于有关针对数组和纯量的内存分配的观念模糊不清,同样也体现在内存管理成员函数上:
class Widget {
public:
void *operator new( size_t );
void operator delete( void *,size_t );
// ...
};
Widget class的作者决定自己动手来定制该class的内存管理,不过他忘记了针对数组的内存分配和回收的运算符和针对纯量的运算符有着不同的名字这一回事了,是故针对数组的这部分内存管理运算符未被其成员版本遮掩:
Widget *w = new Widget( arg ); // 没问题
// ...
delete w; // 没问题
w = new Widget[n]; // 糟糕!
// ...
delete [] w; // 糟糕!
因为Widget class并未声明operator new []和operator delete []成员函数,针对以 Widget 为基型别的数组使用的就是这些函数的全局版本。这很有可能引发不正确的行为,而提供这些函数的成员版本是Widget class的设计工程师的应尽之责。
或者,相反地,编译器提供了正确的行为,Widget class的作者也应该明确地向未来的维护工程师明白地指出这一点,否则做维护的工程师会出于职业敏感以提供这些“缺失”函数的方式来矫枉过正地“修正”该问题。将设计文档化的上佳选择不是使用画蛇添足的注释,而是让代码本身说话:
class Widget {
public:
void *operator new( size_t );
void operator delete( void *,size_t );
void *operator new[]( size_t n )
{ return ::operator new[](n); }
void operator delete[]( void *p,size_t )
{ ::operator delete[](p); }
// ...
};
这些inline成员版本的函数 [1]不耗用任何运行期成本,同时也能让最漫不经心的维护工程师明白代码作者的意图,是要让Widget class使用全局版本的数组内存管理函数。
有些问题连问都不应该问,“某次内存分配有否成功”就属于这类问题。让我们先考察一下过去的黑暗年代里,C++软件工程师是怎样分配内存的。下面是一些代码,对于每次内存分配都小心地做了“是否成功”的校验:
bool error = false;
String **array = new String *[n];
if( array ) {
for( String **p = array; p < array+n; ++p ) {
String *tmp = new String;
if( tmp )
*p = tmp;
else {
error = true;
break;
}
}
}
else
error = true;
if( error )
handleError();
用这种风格写代码的软件工程师一定吃尽了苦头,不过要是能把所有的内存分配错误都检测出来,那也算值了。可惜,他做不到。不走运的话,String class 的构造函数就有可能遭遇内存分配错误,而且根本没有什么容易的办法能够在构造函数之外获知这种错误。有一种办法虽然能够实现,但一点都不优雅:那就是让的String class构造函数在其class对象中放置一种能被检测到的错误状态位,这样就能设置标识以让用户完成校验。就算我们有修改 String型别源代码的权限,能实现这样的行为,这种解决之道也只是让上面这段代码的作者,以及所有未来的维护工程师又再多加了一个可供校验的条件来罢了(换言之就是工作强度、维护难度和出错概率进一步增加)。
或者干脆就不做校验好了。反正这种错误校验代码是不太可能一出手就写得完全正确的,而且肯定愈是改动,错得愈是厉害。那么还不如根本不去费心做校验:
String **array = new String *[n];
for( String **p = array; p < array+n; ++p )
*p = new String;
这段代码更短小、更清楚、更高效,也完全正确。标准的 new 运算符行为是在分配失败时抛出一个bad_alloc型别的异常。这就让我们能够在程序的余下部分中封装错误处理代码,从而得到一个洗练、清晰,并且很大程度上也更有效率的设计。
在任何情况下,校验标准形式 new 运算符之调用的返回结果都不能起到检测错误的功效。
因为new运算符的调用要么成功,要么就抛出异常:
int *ip = new int;
if( ip ) { // 条件总是true
// ...
}
else {
// 永远不会执行
}
也可以使用标准的“无抛异常型”版本的operator new,以在分配失败时返回空指针:
int *ip = new (nothrow) int;
if( ip ) { // 条件几乎总是true
// ...
}
else {
// 几乎永远不会执行
}
无论如何,这只是又回到了 new 运算符旧语义的老路,这回还加上了骇人听闻的糟糕语法。改进的方法是避免使用这种为了向下兼容而引入的语法,为new运算符相关的异常预先做设计,以及撰写处理代码。
运行期系统也具备自动对付一种特别恶毒的内存分配失败错误的能力。回想一下,new运算符内建地做了两次函数调用:一次是调用operator new以取得存储,接着另一次是调用构造函数在该存储上完成初始化:
Thing *tp = new Thing( arg );
如果我们捕获了一个bad_alloc型别的异常,我们知道有了一个内存分配错误,不过这错误从哪里来的呢?这错误有可能是在最原初的为 Thing对象进行存储分配时发生的,也可能是调用Thing型别的构造函数时发生的。在前一种情况下,根本没有内存需要回收,因为tp根本没有被设定去指涉到任何东西。而在后一种情况下,我们需要回收已经分配但尚未初始化的由tp指涉的、堆上的内存。不过,要判断到底属于哪一种情况是困难的,甚至是不太可能的。
幸运的是,运行期系统出面帮我们搞定了这件事。如果为 Thing对象进行存储分配成功了,而调用Thing型别的构造函数失败,从而抛出异常的话,运行期系统会调用一个适当的operator delete(参见常见错误62)来完成内存回收大计。
用自定义版本替换标准的、全局的版本的 operator new、operator delete、数组 new和数组delete,几乎总是不佳的设计,尽管标准允许人们这样做。这些标准版本的函数通常为通用的存储管理高度优化过,而用户自定义的替换版本不太可能做得更好(不过,使用成员版本的内存管理函数来定制某个特定的类或继承谱系的内存管理,倒常常见其合理性)。
为特殊目的设计的版本的、实现了与标准版本具有不同行为的 operator new和operator delete有很大的概率会引入缺陷,因为标准库和很多第三方库中有很大一部分代码的正确性和这些标准版本的 operator new 和operator delete的行为相依。
比较保险的做法是重载全局版本的operator new和operator delete,而不要替换它们。假设我们打算使用某个特定的字符模式来填充新分配的存储:
void *operator new( size_t n,const string &pat ) {
char *p = static_cast<char *>(::operator new( n ));
const char *pattern = pat.c_str();
if( !pattern [0] )
pattern = "\0"; // 两个空字符
const char *f = pattern;
for( int i = 0; i < n; ++i ) {
if( !*f )
f = pattern;
p[i] = *f++;
}
return p;
}
这个版本的operator new接受一个string型别的代表模式的实参,该模式被用于填充到新分配的存储中去。编译器运用重载解析机制将标准的operator new和我们带有两个实参的版本区分开来。
string fill( "<garbage>" );
string *string1 = new string( "Hello" ); // 标准版本
string *string2 =
new (fill) string( "World!" ); // 重载版本
标准同样定义了一个重载版本的operator new,它除了有第一个size_t型别的实参之外,还附加了void *型别的第二个实参。标准的实现十分平凡——它不过是简单地将第二个实参返回而已(语法 throw()是一种异常规格,指明了函数不会发出任何异常。它可以在下面的讨论中很安全地完全无视,而且一般情况下都可以不用理它)。
void *operator new( size_t,void *p ) throw()
{ return p; }
这是标准版本的“定位new”,用于在特定的内存位置上构造对象之用(与标准版本的、只有一个实参的 operator new 不同,替换标准版本的定位new的尝试是非法的)。实质上,我们使用它的目的是为了诱使编译器为我们调用构造函数。举例而言,如果在为一个嵌入式系统撰写软件,我们可能会想在某特定的位置构造“状态注册”对象:
class StatusRegister {
// ...
};
void *regAddr = reinterpret_cast<void *>(0XFE0000);
// ...
// 将注册对象放置在regAddr指涉到的内存
StatusRegister *sr = new (regAddr) StatusRegister;
自然而然地,由定位 new构造的对象必须在某些地方被析构掉。但是,由于定位new并未有分配任何存储,所以必须确保不能实施存储回收。回忆一下,delete 运算符的固有行为是首先调用已经走到生命时域尽头的对象的析构函数,再调用 operator delete 回收它们所占用的存储。在对象由定位 new“分配”存储的情况,就必须显式调用析构函数,以避免任何回收存储的尝试:
sr->~StatusRegister(); // 显式调用析构函数,没有operator delete函数调用
定位 new 和显式析构明白不过地是有用的语言特性,但同样明白的是它们如果不谨慎地滥用的话也是极端危险的(一个来自标准库的例子,参见常见错误47)。
注意,在我们能够重载operator delete的同时,这些重载的版本从来不会在标准的delete表达式中被调用:
void *operator new( size_t n,Buffer &buffer );
// 重载版本的operator new
void operator delete( void *p,
Buffer &buffer ); // 对应重载版本的operator new
// ...
Thing *thing1 = new Thing; // 调用标准版本的operator new
Buffer buf;
Thing *thing2 = new (buf) Thing; // 调用重载版本的operator new
delete thing2; // 错误,本来应该调用重载版本的operator delete
delete thing1; // 正确,调用标准版本的operator delete
如同被定位new构造的对象,我们被迫显式地调用对象的析构函数,接着再显式地回收对象的存储,方法是直接调用适当的operator delete函数:
thing2->~Thing(); // 正确,析构Thing
operator delete( thing2,buf ); // 正确,调用重载版本的operator delete
从实践的角度讲,由于了解内存分配函数到这种深度的软件工程师凤毛麟角,由重载的全局版本的 operator new 分配的存储经常会被误用标准的全局版本的operator delete来回收。避免这个错误引发后果的一条可行之道是保证所有由重载的全局版本的 operator new 存储分配动作实际上是转发了分配请求由标准的全局版本的 operator new 实施。这就是我们先前在第一个 operator new 的重载实现中所做的,而且我们这个处女作能够和标准的全局版本的operator delete有着良好的互动:
string fill( "<garbage>" );
string *string2 = new (fill) string( "World!" );
// ...
delete string2; // 运作如仪
对于重载的全局版本的 operator new 而言,原则上要么就不分配任何存储,要么就应该是标准的全局版本的 operator new 的平凡包装(在内存分配方面仅仅是个转发函数)。
一般而论,最好的做法是不要遮掩任何全局辖域内的内存管理函数。替代地,通过使用这些函数的成员版本来定制某个 class或继承谱系的内存管理。
在常见错误61临近末尾之处,我们注意到,在遇到new表达式的初始化过程抛出异常的情况下,某个“适当的”operator delete会被运行期系统调用:
Thing *tp = new Thing( arg );
如果对于 Thing对象的内存分配成功,而其构造函数抛出异常,那么运行期系统会调用一个适当的operator delete以回收由tp指涉的、未经初始化的内存。在上例中,这个所谓适当的operator delete或是全局版本的operator delete(void *),或是某个函数签名式相同的、成员版本的operator delete。无论如何,一个不同的 operator new 会隐式决定调用的是一个不同的operator delete:
Thing *tp = new (buf) Thing( arg );
在这种情况下,所谓适当的operator delete是具有两个实参的、对应于给Thing对象分配内存的重载版本的operator new的那个版本:operator delete( void *,Buffer & )——这就是运行期系统会调用的函数。
C++语言为内存管理行为的定制提供了巨大的灵活性,但为了获得这种灵活性是付出复杂性的代价。标准的全局版本的 operator new 和 operator delete对于绝大多数需求来说都已经够用了。只有在必要性十分明确的前提下,更复杂的手段才值得一试。
String::String( const char *s )
成员版本的operator new和operator delete在其从属对象被构造和析构时被调用。内存分配表达式所在的辖域与此毫不相干:
class String {
public:
void *operator new( size_t ); // 成员版本的operator new
void operator delete( void * ); // 成员版本的operator delete
void *operator new[]( size_t ); // 成员版本的operator new[]
void operator delete [] ( void * ); // 成员版本的operator delete[]
String( const char * = "" );
// ...
};
void f() {
String *sp = new String( "Heap" ); // 调用的是String::operator new
int *ip = new int( 12 ); // 调用的是::operator new
delete ip; // 调用的是::operator delete
delete sp; // 调用的是String::delete
}
再说一遍,内存分配表达式所在的辖域是无关紧要的,是要求内存分配的对象型别决定了哪个函数被调用:
String::String( const char *s )
: s_( strcpy( new char[strlen(s)+1],s ) )
{}
这个字符数组的内存分配表达式出现在String型别的辖域内,但是这次分配是经由全局的数组 new 调用的函数,而不是 String 型别的成员版本的opernew new[]完成的。字符并非String对象。显式量化在此可以大显神威:
: s_( strcpy( reinterpret_cast<char *>
(String::operator new[](strlen(s)+1 )),s ) )
{}
如果我们能够用类似“String::new char[strlen(s)+1]”去通过数组new访问String型别成员版本的operator new[]就好了(你倒是给这句话做个词法分析试试!)[2],但这个语句是非法的(尽管我们可以用::new来访问全局的operator new和operator new[],用::delete来访问全局的operator delete和operator delete[])。
很多 C++语言的教科书作者都会用抛出字符串字面常量的例子来演示“何为异常”:
throw "Stack underflow!";
他们也知道这属于应受谴责的实践,但他们还是照写不误,理由是“这是一个教学用例”。不幸的是,他们常常忘记提醒他们无辜的读者们,如果对这个例子亦步亦趋,其结果必然堕入万劫不复的深渊。
永远不要抛出字符串字面常量作为异常对象。最主要的理由是这些异常对象终究会被捕获,而它们被捕获的依据是其型别,而不是值:
try {
// ...
}
catch( const char *msg ) {
string m( msg );
if( m == "stack underflow" ) // ...
else if( m == "connection timeout" ) // ...
else if( m == "security violation" ) // ...
else throw;
}
抛出和捕获字符串字面常量在实践方面的一个错误的效应是没有任何有关该异常的信息被编码在异常对象的型别中。这种型别方面的非精确性要求catch子句(clause)截获所有这样的异常,并逐条检视其值,以确定其是否适用。更糟糕的是,这种值的比较也是高度不精确的,并且很容易就在维护期间被“错误消息”(字符串字面量的值)的大小写或格式化 [3]的不经意的改变弄垮。如果上例被破坏,那我们可能就永远不知道发生了栈的下溢。这些有关不要使用字符串字面常量作为异常对象的评论也同样适用于其他内建或标准的型别。抛出整型字面常量、浮点型字面常量、string(模板具现)对象、或(如果你真的哪天吃错药的话)set(模板具现)对象或以float型别具现的vector模板的class对象……都会引发同样的问题。简单地说吧,之所以抛出内建型别的对象作为异常对象会引发问题,是因为我们单凭其型别不知道它代表什么,所以也不知道怎么去应付。抛出这样的异常的人实际上是在拿我们开涮:“现在代码里有一个糟得不能再糟的情况发生了,是什么呢——嘿,自己猜!”我们只能硬着头皮去玩一场我们动不动就败得很惨的“真心话大冒险”。
异常型别是一种抽象数据型别,代表一种异常。对于它的设计指南和任何其他的抽象数据型别并无不同:识别并命名一种概念,决定该概念的一组抽象操作集,并实现之。在实现的过程中,仔细考虑初始化、复制及型别转换。简单吧?用字符串字面常量表示异常,和用它表示复数实际上相比也没好到哪里去。技术上说这也不是不能做,但实践而论,这绝对是既乏味又容易出错的。
那么什么样的抽象概念能够用来表示栈下溢的异常呢?这样就对了:
class StackUnderflow {};
经常地,异常对象的型别给出了异常所要求的全部信息,但在型别内配发一些显式声明的成员函数也是屡见不鲜。无论如何,以这种方式内嵌一些描述性的文本可谓举手之劳。而稍微不那么常见的做法,则是把另一些有关异常的信息也在异常对象中存储起来:
class StackUnderflow {
public:
StackUnderflow( const char *msg = "stack underflow" );
virtual~StackUnderflow();
virtual const char *what() const;
// ...
};
如果要这样做,请把这个返回描述性文本的函数做成一个虚成员函数,命名为what,函数签名式也如上所示。这么做是为了和标准库中的异常型别保持一致,因为所有标准库中的异常型别都提供了这样的一个函数。其实,最好的做法是直接从标准库中的异常型别派生出自己的异常型别:
class StackUnderflow : public std::runtime_error {
public:
explicit StackUnderflow( const char *msg = "stack underflow" )
: std::runtime_error( msg ) {}
};
如此编码就可以让这个异常既能以 StackUnderflow 型别捕获,也能以runtime_error 型别捕获,或以非常泛化的标准 exception 型别捕获(runtime_error型别以public方式派生自exception型别)。同样,提供一个泛化的、而并非标准的异常型别通常也是好的设计。典型地,这样的型别可以被当作基类,以供所有的其他异常型别从其派生,以表示从某特定模块或库中抛出的异常:
class ContainerFault {
public:
virtual~ContainerFault();
virtual const char *what() const = 0;
// ...
};
class StackUnderflow
: public std::runtime_error,public ContainerFault {
public:
explicit StackUnderflow( const char *msg = "stack underflow" )
: std::runtime_error( msg ) {}
const char *what() const
{ return std::runtime_error::what(); }
};
最后,也要给异常型别赋以适当的复制和析构语义。特别地,抛出一个异常就隐式地说明该异常对象必须允许复制构造,因为这就是运行期异常机制在抛出异常时所做的一切(参见常见错误65),该被复制构造了的对象也必须在被处理之后被析构。通常,我们可以让编译器帮我们写这些操作的实现(参见常见错误49):
class StackUnderflow
: public std::runtime_error,public ContainerFault {
public:
explicit StackUnderflow( const char *msg = "stack underflow" )
: std::runtime_error( msg ) {}
// StackUnderflow( const StackUnderflow & );
// StackUnderflow &operator =( const StackUnderflow & );
const char *what() const
{ return std::runtime_error::what(); }
};
这么一来,我们的栈抽象数据型别的用户就可以做自己的选择,把一个栈下溢的异常检测实现为一个 StackUnderflow 型别的捕获(它们了解我们的栈抽象数据型别,而且对此密切注视),或是作为一个稍泛化的ContainerFault型别的捕获(它们了解自己正在使用我们的容器库,而对一切容器型别抛出的异常都保持高度警惕),或是作为一个runtime_error型别的捕获(它们对我们的容器库一无所知,但是它们想自行处理所有标准的运行时异常),或是作为一个exception型别的捕获(它们已经准备好去处理任何可能的标准异常)。
对于普遍意义上的异常处理的策略和架构问题的争论现在仍如日中天。不过,有关异常抛出和捕获的低层次指导原则一方面已经被充分理解,另一方面却仍然普遍被当作一纸空文。
当执行到一个 throw语句时,运行期异常处理机制会将异常对象复制到一个临时对象中,并放到某个“安全”位置。这个临时对象的放置位置是高度平台相依的,但可以保证的是该临时对象会在异常处理结束之前持续存在。这意味着一直到最后一个使用了该临时对象的 catch子句完成之前,它都一直保持可用状态,即使有若干个不同的 catch子句为该异常临时对象而执行也是如此。异常临时对象的这种特性十分关键,因为——恕我直言——当你抛出一个异常之时,天灾人祸是已然发生了。这个异常临时对象是这个灾难大漩涡中唯一风平浪静的涡眼。
}
这就是为什么抛出一个指针作为异常对象是不明智的:
throw new StackUnderflow( "operator stack" );
StackUnderflow对象在堆上的地址被复制到了安全的位置,不过它指涉到的堆上的那段内存却并未受其荫福。这个情况在指涉到运行期栈上的内存的指针身上也会发生:
StackUnderflow e( "arg stack" );
throw &e;
这时,被指针异常对象(记住,被抛出的是这个指针,而不是它指涉到的东西)指涉到的内存在异常被捕获时可能已经驾鹤西游(顺便说一句,当一个字符串字面量被抛出时,倒不仅是首字符的地址,而是整个字符数组的内容被复制到临时对象中的。但这并不能带来任何实践上的用处,因为我们永远都不应该抛出字符串字面常量。参见常见错误64)。更不必说,指针还有可能为空值。谁愿意去招惹这些不必要的麻烦?不要抛出指针,抛出对象:
StackUnderflow e( "arg stack" );
throw e;
该异常对象立即被异常机制复制到一个临时对象中,所以那个对 e 的声明诚属多余。习惯上,抛出匿名的临时对象即可:
throw StackUnderflow( "arg stack" );
使用匿名临时对象可以清楚不过地表明这个 StackUnderflow 对象仅作异常对象之用,因为其生命时域仅限于该 throw表达式本身。其实显式声明的变量e在throw表达式执行后也会被析构,它仍然位于辖域内,仍然可以被访问,直到包含其声明的闭合块结束处为止。使用一个匿名临时对象同时也打消了一些有关异常处理的“创意十足”的痴心妄想:
static StackUnderflow e( "arg stack" );
extern StackUnderflow *argstackerr;
argstackerr = &e;
throw e;
这里,我们聪明过头的软件工程师想保留下异常对象的地址以备后用,多半是想在下面的一组catch子句中使用。不幸的是,指针argstackerr并没有指涉到异常对象(异常对象是一个临时对象,并且位置不明),而是把一个指涉到了已经被析构的对象的指针作为其初始化物。异常处理代码本来已是是非之地,可不能再让怪异的缺陷火上浇油了。
什么是最佳的异常对象捕获方式?肯定不是以传值方式:
try {
// ...
}
catch( ContainerFault fault ) {
// ...
}
仔细考虑一下,如果这个 catch 子句成功地捕获了一个被抛出的StackUnderflow 对象的话会发生什么……截切!因为 StackUnderflow型别对ContainerFault型别有皆然性,我们能够以该异常对象为fault的初始化物,但我们会把派生类的数据和行为抹得干干净净(参见常见错误30)。无论如何,在这个特定的例子中,我们并不会遭遇截切之苦,因为ContainerFault型别——作为基类型别而言还算合理——是个抽象类(参见常见错误 93)。是故,该 catch 子句非法。我们没有办法以值方式捕获ContainerFault型别的异常对象(抽象类不能构造class对象)。
以值方式捕获异常对象会使我们遭遇更龌龊的问题:
catch( StackUnderflow fault ) {
// 做部分恢复
fault.modifyState(); // 局部异常对象
throw; // 重新抛出当前异常
}
在 catch子句中部分的恢复动作,在异常对象中记录下恢复动作的状态,再将异常对象重新抛出,这并不罕见。不幸的是,这不是上面这段代码所做的工作。这个 catch子句做了部分的恢复工作,将恢复动作的状态记录在异常对象的一个局部副本中,然后重新抛出了未加改变的异常对象。
为了让世界清静些,避免所有这些麻烦,我们总是抛出匿名临时对象作为异常对象,并以引用方式捕获它们。
当心不要将值语义复制的问题在处理代码里重新引入。这个问题最常见于在处理代码中抛出了一个新异常对象,而非既有的异常对象:
catch( ContainerFault &fault ) {
// 做部分恢复
if( condition )
throw; // 重新抛出
else {
ContainerFault myFault( fault );
myFault.modifyState(); // 仍是局部异常对象
throw myFault; // 抛出了新的异常对象
}
}
在这种情况下,记录下的变更未有遗失,但异常对象的原始动态型别则遗失了。假设抛出的原始异常具有StackUnderflow型别,当它以指涉到ContainerFault型别的引用的形式被捕获时,其动态型别仍是StackUnderflow,是故,重新原样抛出的对象就有机会既被检视StackUnderflow型别的catch子句捕获,也可以被检视ContainerFault型别的catch子句捕获。但是,新的异常对象只具有ContainerFault型别,就不能再被检视StackUnderflow型别的catch子句捕获了。是故,较佳的做法还是重新抛出既有的异常对象,而非处理原始的异常之后,抛出一个新的异常对象:
catch( ContainerFault &fault ) {
// 做部分恢复
if( !condition )
fault.modifyState();
throw;
幸亏基类型别 ContainerFault 是个抽象类,所以上面那个错误不可能发生(仍然因为抽象类不能构造class对象);以public方式继承的基类一般应该是抽象的。显然,这条建议在你必须抛出完全不同型别的异常对象时不适用:
catch( ContainerFault &fault ) {
// 做部分恢复
if( out_of_memory )
throw bad_alloc(); // 抛出新型别的异常对象
fault.modifyState();
throw; // 重新抛出
}
另一个比较常见的问题和catch子句的次序安排有关。因为catch子句是依其出现次序被加以检视的(和if–else if结构相似,不同于switch语句),其检视型别的次序应该从具象到泛化这样安排。若是次序关系不大的型别,就采用符合逻辑的次序即可:
catch( ContainerFault &fault ) {
// 做部分恢复
fault.modifyState();
throw;
}
catch( StackUnderflow &fault ) {
// ...
}
catch( exception & ) {
// ...
}
这样一个处理次序永远都无法捕获 StackUnderflow 型别的异常对象,因为有比它更泛化的ContainerFault型别的捕获子句在先。
异常处理机制造成了很多将代码变得复杂的机会,但其实大可不必如此。在抛出和捕获异常时,尽可能将过程简化。
char buf[MAX];
千万不要返回一个指涉到局部量的指针或引用。绝大多数编译器都会就此发出警告:请正视这些警告。
消逝的栈帧
如果一个变量是auto的,在函数返回时它占用的存储就会被回收:
sprintf( buffer,"label%d",labNo++ );
// ...
char *newLabel1() {
static int labNo = 0;
char buffer[16]; // 参见常见错误2
if( buf[i] == '\0' ) {
buf[i] = '*';
return buffer;
++count;
}
这个函数的可恶之处在于它时不时地能运作一阵子。在return语句执行时,函数newLabel1的栈帧(stack frame)会从函数执行栈(execution stack)中弹出,回收一切相关存储(包括buffer的存储)给随后的函数调用之用。但是,如果这个值的内容在下面的函数调用之前仍然保留着没有被覆写 [4],返回的指针尽管理论上讲已属非法,但是从实践上讲却仍可用:
char *uniqueLab = newLabel1();
char mybuf[16],*pmybuf = mybuf;
while( *pmybuf++ = *uniqueLab++ );
任何维护工程师都不可能对这段代码坐视很久,维护工程师可能决定在堆上分配这些存储:
char *pmybuf = new char[16];
维护工程师可能决定不再手工实现存储块的复制:
strcpy( pmybuf,uniqueLab );
维护工程师可能决定使用比裸字符存储更加抽象的型别:
std::string mybuf( uniqueLab );
以上的代码变更中的任何一个都有可能造成uniqueLab指涉到的局部存储遭到破坏 [5]。
局部静态量佯谬[6]
若一个局部变量被声明为静态的,那么未来对于该局部变量位于其辖域中的同一函数的调用就会影响到先前的调用取得的结果 [7]:
char *newLabel2() {
static int labNo = 0;
static char buffer[16];
sprintf( buffer,"label%d",labNo++ );
return buffer;
}
和buffer相关联的存储在函数返回后的确仍可用,但对该函数的任何其他调用都会影响之前的返回结果:
// 情形1
cout << "first: " << newLabel2() << ' ';
cout << "second: " << newLabel2() << endl;
// 情形2
cout << "first: " << newLabel2() << ' '
<< "second: " << newLabel2() << endl
在情形1 中,我们输出的是两个不同的标签。在情形2 中,我们却有可能(也不一定)输出同一个标签两次 [8]。顺理成章地,有些人可能会一开始就注意到newLabel2函数实现的奇异之处,然后写出了情形 1 中的代码以将其纳入考量。但是之后的维护工程师可能对newLabel2 函数的怪癖并不知情,然后很可能把两个输出语句并成一句,这就引入了缺陷。更糟糕的是,合并后的语句可能和分开时表现出同样的行为,然后在未来的某个不可预知的时刻突然变卦(参见常见错误14)。
未能采用习惯用法的困境
另一种危机也同样含而不露。需要时刻牢记一点:函数的用户通常来讲都是没有权限检视其实现的,所以只能通过看它的声明来了解怎样取得预期的返回值。在写些注释起到一定告知作用的同时(参见常见错误 1),在函数设计上应该狠下功夫以推进其被正确调用(以及不容易被误用)。
不要返回指涉到在函数辖域内所分配之存储的引用。这种函数的用户绝对会忘记回收该存储,从而导致内存泄漏:
int &f()
{ return *new int( 5 ); }
// ...
int i = f(); // 内存泄漏!
欲调用该函数的正确代码,必须把引用再转换成一个地址(这样还何必要用引用?),或是复制这个结果以后再把内存回收。您在拿我开心吗?
int *ip = &f(); // 一种丑恶的途径
int &tmp = f(); // 另一种
int i = tmp;
delete &tmp;
若是把这个妙计用在运算符重载身上,那简直就是无可救药了:
Complex &operator +( const Complex &a,const Complex &b )
{ return *new Complex( a.re+b.re,a.im+b.im ); }
// ...
Complex a,b,c;
a = b + c + a + b; // 千疮百孔式的内存泄漏
返回指涉到函数辖域内分配的存储的指针,或干脆不要分配存储,直接以传值方式返回:
int *f() { return new int(5); }
Complex operator +( Complex a,Complex b )
{ return Complex( a.re+b.re,a.im+b.im ); }
//[9]
习惯上,用户看到函数的返回值是指针型别,就会有“自己有可能最终要负责回收其返回值指涉到的存储之义务”的觉悟,通常也会花点功夫来看看到底是不是真有这种要求(比如,通过参阅其注释)。如果看到函数的返回值是引用型别,用户通常不会有这种反应。
局部辖域引发的问题
我们遭遇的局部变量生命时域问题并不囿于函数边界,在同一函数的嵌套辖域中也时有发生:
void localScope( int x ) {
char *cp = 0;
if( x ) {
char buf1[] = "asdf";
cp = buf1; // 糟糕的设计!
char buf2[] = "qwerty";
char *cp1 = buf2;
// ...
}
if( x-1 ) {
char *cp2 = 0; // 是否覆写了buf1?
// ...
}
if( cp )
printf( cp ); // 可能会崩溃
}
编译器在如何安排局部量的存储方面留有很大的余地。有些平台和编译器甚至会选择将buf1和cp2的存储叠置(overlay)。这完全合法,因为buf1的cp2辖域和生存时域都是分离的。如果它们的存储确乎这样叠置,buf1就会被破坏无遗,而printf的结果也会受到影响(它可能什么也不输出)。为了提高可移植性,最好不要有对于栈帧布局的相依性。
“杀手锏”:static饰词
当面对一个棘手的缺陷,有时加上一个 static饰词以后问题就“莫名消失”了:
// ...
char buf[MAX];
long count = 0;
// ...
int i = 0;
while( i++ <= MAX )
if( buf[i] == '\0' ) {
buf[i] = '*';
++count;
}
assert( count <= i );
// ...
这段代码有一个写得很失败的循环,它有时会越过 buf 数组的后边界把count 的内容覆写掉,导致断言失败。好在我们的软件工程师在疯狂地砸电脑的间隙中还残存着一点想修正缺陷的想法儿,他情急之下把 count声明为一个局部静态变量,结果这段代码居然开始“正常工作”了:
char buf[MAX];
static long count;
// ...
count = 0;
int i = 0;
while( i++ <= MAX )
if( buf[i] == '\0' ) {
buf[i] = '*';
++count;
}
assert( count <= i );
很多软件工程师都不再会质疑他们有如神赐的好运:如此容易地就把这个该死的缺陷搞定,他们就会对这段代码从此敬而远之。不幸的是,问题并未消失,只是转移到了别处。它在暗处隐藏,随时会出来兴风作浪。
把局部变量count加上static饰词,其效果就是把与它相关联的存储从该函数的栈帧中移到了内存中完全不同的区域,该区域专用于静态变量的存储。因为与 count相关联的该存储已经被移走,所以当然它也就不再会被覆写了。不过,这么一来不仅会面临我们在“局部静态量佯谬”中描述的困境,并且可能其他的——或未来的某个局部变量——同样会被覆写。正确的解决方案一如既往:老老实实地修正这个缺陷,不要掩耳盗铃:
long count = 0;
// ...
for( int i = 1; i < MAX; ++i )
}
void doDB() {
说丢人也真的很丢人,那就是很多新入行的 C++软件工程师都体会不到构造函数和析构函数美妙绝伦的对称性。绝大多数此类软件工程师都活在语言为他们准备的温室中,不用为指针和手动内存管理操心。安全可靠。难得糊涂。所谓编程也只不过就是精确地按照语言设计者替人们一手安排好的路子亦步亦趋。一条道儿走到黑。这就是他们的编码之道。
可喜的是,C++语言十分重视一线的实践者,为语言的用法提供了很大的灵活性。这并不是说,我们不讲章法、没有具有指导意义的习惯用法(参见常见错误10)。此种习惯用法中有一种名为“RAII”。这个习惯用法的名字是有那么长,但是它是一种易用、扩展性强的技术,用以将资源与内存绑定,而且以高效、可预期的方式加以管理。
// 操作数据库
构造函数和析构函数的执行序列次序完全互逆(mirror images of each other)。当class对象被构造的时候,其初始化执行序列次序放之四海而皆准:虚基类子对象先被构造(标准原文是“在基类的有向无环谱系图中以深度优先、从左至右的出现次序进行”),接下来是它们的直接基类(子对象)以其在 class定义中基类列表的出现次序依次构造,接下来是其数据成员以其声明次序依次构造,最后执行的是构造函数的函数体。而析构函数则实现了完全逆向的执行次序:先执行析构函数的函数体,再是数据成员以其声明次序之逆序依次析构,再是其直接基类(子对象)以其在class定义中基类列表的出现次序之逆序依次析构,最后是虚基类子对象的(原次序之逆序的)析构。把构造函数想像成一个向栈中压入执行序列,而析构函数则是逆序弹出这个序列的过程,是有助理解的。构造和析构的这种对称性被置于如此重要的地位,以至于所有class对象的构造函数都必须以相同次序执行该初始化,即使成员初始化列表以不同次序写就,也不能动摇这一点(参见常见错误52)。
作为初始化结果的一个副作用,构造函数除了要负构造起对象之责外,还要采集对象所需资源以供其用。通常,申请这些资源的次序是卡死的(举例而言,往数据库写入任何内容之前,必须先将其锁定;要覆写文件之前,必须取得文件句柄)。典型地,事务的析构过程则包括按资源申请的逆序释放它们。有可能一个 class具有多个构造函数,但却只有一个析构函数,这个事实提示了我们所有的构造函数都需要以相同的执行序列来完成其各个组成部分的初始化工作。
(顺便说一句,事情并非一开始就是这样的。在语言发展的非常早期的阶段,构造函数各组成部分的初始化次序并不是语言固定的,这引发了软件项目在各个层级上的复杂性。如同绝大多数 C++语言规则一样,这一条规则也是深思熟虑和生产实践相结合的产物。)
构造函数和析构函数的这种对称性即使从单个 class对象的结构延拓到多个对象的情形时也会依样行事。考虑一个平凡的用于跟踪的class:
gotcha67/trace.h
}
class Trace {
public:
Trace( const char *msg )
}
: m_( msg ) { cout << "Entering " << m_ << endl; }
~Trace()
{ cout << "Exiting " << m_ << endl; }
private:
const char *m_;
};
这个跟踪之用的 class似乎有点平凡得过头了,它假定其初始化物是有效的,而且生存时域至少和 Trace对象本身一样长,不过无论如何对于我们现在要演示的东西来说已经足够了。Trace对象在构造时会输出一段消息,析构时又会输出一段,所以它可以被用来跟踪执行流:
gotcha67/trace.cpp
Trace a( "global" );
void loopy( int cond1,int cond2 ) {
Trace b( "function body" );
it: Trace c( "later in body" );
if( cond1 == cond2 )
return;
if( cond1-1 ) {
Trace d( "if" );
static Trace stat( "local static" );
while( --cond1 ) {
Trace e( "loop" );
if( cond1 == cond2 )
goto it;
}
Trace f( "after loop" );
}
Trace g( "after if" );
以实参4和2调用函数,会产生如下输出 [11]。
这组消息明白不过地指出了每个 Trace对象的生存时域是如何与执行流所处的辖域相关联的。尤其请注意goto和return语句在活动的Trace对象的生存时域方面产生的影响。这两个应用都并非堪为典范的编码实践,但是它们都在代码维护的过程中容易出现(维护工程师迫于进度压力等,可能会写出这样敷衍了事的代码)。
void doDB() {
lockDB();
// 操作数据库
unlockDB();
}
在以上的代码中,我们很仔细地在访问数据库之前锁定了它,并在完成访问之后给它解了锁。不幸的是,机关算尽太聪明,一朝维护前功尽。尤其在加锁和解锁的语句之前隔了很长的间距时就更是如此:
void doDB() {
lockDB();
// ...
if( i_feel_like_it )
return;
// ...
unlockDB();
}
现在只要doDB函数一闹情绪,我们的代码中就多了一个缺陷:数据库没能解锁,那它肯定会在其他地方招惹不小的麻烦。实际上连原始的代码也写得漏洞百出,因为在数据库已被锁定但尚未解锁之间,可能会有异常被抛出。这会造成和任何绕过 unlockDB 函数的旁路逻辑(branch)一样的后果:数据库一直被锁着。
我们可以尝试来动手解决这个问题,通过把异常显式纳入考量的办法,给维护者一段刻板冗长的喧嚣:
void doDB() {
lockDB();
try {
// 操作数据库
}
catch( ...) {
unlockDB();
throw;
}
unlockDB();
}
这段代码啰嗦,没有技术含量,极难维护,会给你贴上“冗余俱乐部荣誉会员”的标签。正确书写的、异常安全的代码通常很少运用try语句区块。它们不用try语句区块,而是使用RAII习惯用法:
class DBLock {
public:
DBLock() { lockDB(); }
~DBLock() { unlockDB(); }
};
DBLock lock;
// 操作数据库
}
DBLock对象的构造过程申请了锁资源。而无论何种原因导致了该class对象离开了其所在辖域,析构过程都会回收资源并将数据库解锁。这种习惯用法在 C++中运用得是如此广泛,以至于很多人熟视无睹。任何时候只要你使用了标准库中的 string、vector、list或任何其他宿主(模板)型别(host,亦即容器或智能指针),你就在享受RAII了。
顺便提一下,要对DBLock这样的资源持有class的两个常见问题保持清醒认识:
void doDB() {
DBLock lock1; // 正确
DBLock lock2(); // 错误!
DBLock(); // 错误!
对lock1的声明是正确无误的:它是个DBLock对象,恰在声明语句的结束分号之前进入辖域,而在包含它的语句区块结束处(此处指函数结束处)离开辖域。第二个语句把 lock2声明成了一个没有实参、返回一个DBLock对象的函数(参见常见错误 19)。这并非一个错误,只是多数情况下代码的效果不是人们的初衷,因为这样写是不会有锁定和解锁的动作发生的。
接下去的一行是个表达式语句(expression-statement),它创建了一个匿名的DBLock型别的临时class对象。它的确会在数据库上做一个锁定动作,但是临时对象在表达式结束处(恰在结束分号前)就会离开辖域,此时数据库会被立刻解锁。这大概也不是人们期望的结果吧。
标准库中的auto_ptr模板 [12]是相当有用的、为从堆上分配的对象准备的通用型资源句柄。参见常见错误10和常见错误68。
标准库中的auto_ptr模板是既平凡又有用的资源句柄,它有着不平常的复制语义(参见常见错误10)。大多数auto_ptr模板的应用都是直截了当的:
template <typename T>
void print( Container<T> &c ) {
auto_ptr< Iter<T> > i( c.genIter() );
}
for( i->reset(); !i->done(); i->next() ) {
cout << i->get() << endl;
examine( c );
}
// 隐式进行的清理工作
这是auto_ptr的一个普遍应用,用以确保从堆上分配的对象占用的存储和资源在指涉到它们的指针或引用离开其辖域时得到回收(一个更完整的Container继承谱系的表现,参见常见错误90)。刚才那段代码做了一个假定,那就是由genIter返回的Iter<T> 对象是从堆上分配了内存。是故,会调用auto_ptr<Iter<T> >会调用delete运算符以在离开其辖域时回收对象的内存 [13]。
但是,在auto_ptr的使用过程中有两种常见的错误。第一种是错误地假定auto_ptr可以指涉到数组:
void calc( double src[],int len ) {
double *tmp = new double[len];
// ...
delete [] tmp;
}
这个calc函数相当脆弱,因为完成了内存分配的tmp数组会有可能会无法回收它所占用的内存,如果函数执行到一半时抛出异常,或是代码维护以后有可能在中间提早返回的话。资源句柄是我们此时需要的东西,而
auto_ptr则是我们的标准之选:
void calc( double src[],int len ) {
auto_ptr<double> tmp( new double[len] );
// ...
}
可是,auto_ptr只是持有单个对象的标准资源句柄,而并非用于持有对象数组的。当tmp离开其辖域,其析构函数被触发时,只有针对纯量的delete运算符会被调用在double型别对象的数组上(参见常见错误60),因为,不幸的是,编译器是无法分辨出一个指针指涉到的究竟是单个对象还是一个数组的。更不幸的是,这段代码在某些平台上有时还能勉强运作,导致问题在移植到新平台或是把现有平台升级到一个新版本时才曝光。
更理想的解决方案是使用标准库中的vector组件来持有double型别对象的数组。标准库中的vector组件本质上就是为数组设计的资源句柄,是某种意义上的“auto_array”,还带有一堆附加的基础设施。同时,不再使用粗糙而危险的“以指针型别的形参冒充数组”的写码风格也是好事一件:
void calc( vector<double> &src ) {
vector<double> tmp( src.size() );
// ...
}
另一种常见的错误就是使用auto_ptr(具现的型别)作为STL容器的基型别。STL容器对其基型别的种类并不吹毛求疵,但它们的确要求符合习惯的复制语义 [14]。
事实上,标准以这种方式定义auto_ptr:当它被用来具现STL容器时会得到非法结果。这种用法会导致编译时错误(可能错误消息在这种情况下会十分艰涩难懂)。当然,很多流行的实现目前还落后于标准。
在某个广泛应用的auto_ptr实现中,其复制语义实际上作为容量基型别而言是适用的,所以它们就可以在彼处大行其道。但这么一来,一旦取得不同的或是更新版本的标准库,代码就会通不过编译。这很烦人,但是良药苦口利于病。
更糟糕的情形是 auto_ptr 的实现只是部分地符合标准:在这种错误实现中,可以用它来具现STL容器模板,但是其复制语义却并不符合STL容器模板的要求。如同在常见错误10中提及的那样,复制auto_ptr模板具现对象将指涉到的对象的控制权转移(到复制目的中去,是所谓所有权流转),然后把复制源设为空:
auto_ptr<Employee> e1( new Hourly );
auto_ptr<Employee> e2( e1 ); // e1现在为空
e1 = e2; // e2现在为空
这种特性在很多语境中都有其用武之地,唯在满足 STL 容器模板的要求方面无计可施:
vector <auto_ptr<Employee> > payroll;
// ...
list< auto_ptr<Employee> > temp;
copy( payroll.begin(),payroll.end(),back_inserter(temp) );
在有些 auto_ptr的实现大错特错的平台上,以上代码可以编译,但它的行为却让人大呼上当。持有 auto_ptr<Employee>对象的 vector会将其内容复制到list中去,不过当这个复制过程完成以后,该vector持有的指针变得空空如也。
永远不要把auto_ptr(具现的型别)作为STL容器的基型别,即使你当下在使用的平台对你网开一面。
[1].译者注:注意,代码中并无inline关键字,但编译器仍然可以作出将其作为inline函数的决定,有关这一问题的一段十分精彩的论述,参见(Sutter,2006),条款26。
[2].译者注:作者的意思是说这句话会被编译器的词法分析阶段解释成一个声明语句,“String::new”是一个String型别的成员型别,而new又是一个关键字不能做标识符。是故,在这里会中止分析,报语法错。
[3].译者注:多一个或少一个空格之类。
[4].译者注:原文是“if the value is copied before another function is called...”,但其实不一定要“复制到某个别处”才能保持不被覆写。
[5].译者注:第一个变更有可能会复用前面从堆上回收的内存;第二个、第三个变更会有新的函数调用,从而破坏原来的函数执行栈。而最原始的代码反而恰巧把破坏的可能性降到了最低,因为它没有使用函数调用而是用手工循环的方式来做复制动作的。
[6].译者注:原文“Static Interference”是双关幽默,本意是电子战中的“静电干扰”。
[7].译者注:这种副作用有时也有其积极效果,比如它可以被应用在产生随机数的算法里。一种利用了该副作用的有效应用是所谓线性同余法,参见(Knuth,2002),§ 3.2.1。
[8].译者注:因为编译器有可能做出优化决定对于同一个函数调用直接插入结果来代替调用动作。更何况即使真的调用了两次,也不能确定它们的评估求值次序,下文也有提及。
[9].译者注:不清楚作者为什么要把这两段也标上“不良代码”的背景色,原书如此。
[10].译注:RAII是Resource Acquisition Is Initialization(资源获取即初始化)的缩写。
[11].译者注:原文只列出了函数输出,译者加上了cond1和cond2在每个执行流跟踪消息输出时刻的值,并加以注记。
[12].译者注:以及由Boost社区提供原始实现,已经被纳入标准的Technical Report 1部分的tr1::shared_ptr模板。有关该模板,参见(Meyers,2006),条款18。
[13].译者注:应该从这段代码中仔细体会 for 语句形式的灵活多样,另外还要注意的是上述代码仔细地以独立语句将新生成的迭代器置入了资源句柄中。这里要强调的是后者,因为如若不然,将产生潜在的资源泄漏可能,具体的讨论参见(Meyers,2006),条款17。
[14].译者注:“auto_ptr容器”,Container of auto_ptrs,简称COAP,现在已经被绝大多数的编译器禁止。有关此问题的更深入讨论,参见(Meyers,2003),条款8。
和数据抽象一样不可或缺,继承和多态也是面向对象思想的重要基础工具。C++语言中的多态是高效、灵活的,但也相当复杂。
本章中,我们会检视 C++中的多态提供的灵活性在何种情形下会被滥用,也将提出一些削弱与多态相伴之复杂性的指导原则。在这个过程中,我们将细究继承和虚函数采用何种手法实现,以及采用每一种不同的实现手法是如何反过来影响C++语言本身的。
“我的第一个C++程序”最明白不过的标志之一就是在class里放一个数据成员作为该型别的特征码(我就在第一个 C++程序中来了这么一手,结果给自己带来了无尽的痛苦)。在面向对象思想中,对象的型别由其行为决定,而非由其状态决定。有着良好设计的 C++代码中要求必须有一个型别特征码的情形是罕见的,而在任何情况下以数据成员的形式表示型别特征码都是多此一举。
class Base {
public:
enum Tcode { DER1,DER2,DER3 };
Base( Tcode c ) : code_( c ) {}
virtual~Base();
int tcode() const
{ return code_; }
virtual void f() = 0;
private:
Tcode code_;
};
class Der1 : public Base {
public:
Der1() : Base( DER1 ) {}
void f();
};
上面的代码是对于此问题的非常典型的表现。问题出在设计工程师对于完全使用面向对象思想进行设计还没有十足的把握,还不能游刃有余地在良构的继承谱系中一致地运用动态绑定技术。之所以非得放一个型别特征码在那里,是因为设计工程师主观地认定迟早需要采用switch语句(与病态的旧式C语言结构臭味相投)来精确判定究竟面对的是Base class的哪个派生类。这种思想大错特错。在面向对象的设计中采用型别特征码犹如在跳水时把一只脚探出去做跳水状,另一只脚还留在跳板上:这种尝试是不会有好下场的,着地时会疼得死去活来。
在C++语言中,我们绝对不会在采用面向对象思想的代码片段中依型别特征码进行逻辑分派(switch on type codes)。绝对不会。最主要的问题当然是出在Base型别中的Tcode枚举型别成员身上。只要在这个继承谱系中添加一个派生类,就会要求源代码变更。而基类对派生类不仅知道其派生类的太多细节,也与之紧密耦合。而且,是不是现存代码中依型别特征码的枚举进行逻辑分派的部分都依据新的变化升级过了呢?没有任何保证。在C语言代码维护中的一个常见问题就是在变更后的型别特征码进行逻辑分派的代码中,只更新了 98%的地方 [1]。这样的问题如果采用虚函数的话就压根儿不会出现,对设计工程师而言,实在不用再大费周章地把已经解决了的问题再弄出来了。
以数据成员存储的型别特征码还会引发一些其他难以捉摸的问题。型别特征码可能会从Base class的一个派生类复制到另一个派生类中。在一个庞大而复杂的采用型别特征码的软件中,这种情况时有发生:
Base *bp1 = new Der1;
Base *bp2 = new Der2;
*bp2 = *bp1; // 灾难爆发!
请注意,Der2对象并未改变其型别。型别由行为决定,而Der2型别的行为主要地是由其构造函数在初始化阶段如何将其构造起来这件事决定的。举例而言,这个过程中指涉到虚函数表的指针会由编译器向对象中隐式地插入,它会决定对象成员函数的调用是否需要使用动态绑定机制。指涉到虚函数表的指针(等编译器为实现机制而准备的成员会有专门的处理智能)不会被上面的代码改变,但是显式声明的Base型别的数据成员则会(参见常见错误50和常见错误78)。如 图7-1所示,在bp2指涉到的对象中,只有带阴影的部分会被赋值语句更改。
对象的型别一经初始化指定,便矢志不渝。但是,指涉到 Der2 对象的指针bp2却通过型别特征码的重新赋值,扬言自己指涉到的是个Der1对象。所有依型别特征码进行逻辑分派的代码都会相信这套鬼话,而所有正确地基于动态绑定技术的代码都会置之不理。该对象行为有精神分裂症候。
如果在一些罕见的设计中确实要求一个型别特征码的话,最好遵循两个实现方面的指导原则。首先,不要以数据成员的形式存储特征码。使用虚函数代替它,因为其效果是将型别特征码更直接地与实际型别的行为相关联,而且能够在更广泛的场合下避免对象的精神分裂:
public:
class Base {
public:
enum Tcode { DER1,DER2,DER3 };
};
Base();
virtual~Base();
virtual int tcode() const = 0;
virtual void f() = 0;
// ...
};
class Der1 : public Base {
public:
Der1() : Base() {}
void f();
int tcode() const
{ return DER1; }
};
其次,最好保持基类对于其派生类的一无所知,因为这样可以有效地降低继承谱系内的耦合,还能够满足在维护阶段随时添加或删除派生类种类的需要。这实际上就暗示了型别特征码集合能够在代码之外进行维护,可以遵循某种官方标准来维护型别特征码列表,或指定某种算法或流程来产生型别特征码集合。每个派生类个体都对自己的型别特征码了如指掌,但其余的代码却对此付诸阙如。
让设计师被迫使用型别特征码的常见情形是面向对象的模块必须与非面向对象的模块交互。比如,必须从外部读入某种“消息”,而消息的型别是根据某个初始的整型特征码指定的。消息的长度和其余部分的结构由特征码指定,这种情形下,设计师何以措手足?
一般而论,最好是做出一个设计防火墙。在这种情况下,设计中与消息的外部表示交互的那个部分会依整型特征码分派,以生成某个并不内含型别特征码的适当对象。而整个设计而言,则可以简单地忽略型别特征码而径用动态绑定。请注意,如有必要,从对象本身重新生成原始消息乃是举手之劳,因为对象要感知其原始的特征码,是并无必要将其以数据成员的形式加以存储的 [2]。
这种解决手法的一个不足之处,在于当消息集合发生变化时,分派的代码需要同步修改和重新编译。由于设计防火墙的存在,任何更改和重新编译都仅限于加了防火墙的分派代码本身:
Msg *firewall( RawMsgSource &src ) {
switch( src.msgcode ) {
case MSG1:
return new Msg1( src );
case MSG2:
return new Msg2( src );
// ...
}
有些情况下,连这种受限的重新编译也是不可接受的。比如,可能需要在程序在线运行时添加新的消息类型。如果遭遇此种情形,则宜利用控制结构与生俱来的可替换性,以解释器形式的运行期数据结构替换编译期的条件代码 [3]。以我们上面代码中的消息为例,我们可以用一列从同一基类派生的对象型别 [4],每种代表一个不同的消息型别。
gotcha69/firewall.h
class MsgType {
public:
virtual~MsgType() {}
virtual int code() const = 0;
virtual Msg *generate( RawMsgSource & ) const = 0;
class Firewall { // 单态设计模式
void addMsgType( const MsgType * );
Msg *genMsg( RawMsgSource & );
private:
typedef std::vector<MsgType *> C;
typedef C::iterator I;
static C types_;
};
这里写的解释器代码比较粗糙:我们仅仅是遍历了一个序列,然后搜索匹配的消息特征码。如果命中,则生成一个对应消息对象:
gotcha69/firewall.cpp
Msg *Firewall::genMsg( RawMsgSource &src ) {
int code = src.msgcode;
for( I i( types_.begin() ); i != types_.end(); ++i )
if( code == i->code() )
return i->generate( src );
return 0;
}
现在的数据结构有很好的可扩展性以识别新的消息型别:
void Firewall::addMsgType( const MsgType *mt )
{ types_.push_back(mt); }
消息型别个体的实现就易如反掌了:
class Msg1Type : public MsgType {
public:
Msg1Type()
{ Firewall::addMsgType( this ); }
int code() const
{ return MSG1; }
Msg *generate( RawMsgSource &src ) const
{ return new Msg1( src ); }
};
以MsgType型别的派生class Class对象填充该表的手法多种多样。其中最易行的手法是直接声明一个对应型别的静态变量。其构造函数有着一个副作用,就是把这该MsgType型别的派生class Class对象添加到Firewall型别所声明的静态表结构中去 [5]:
static Msg1Type msg1type;
请注意这些静态对象的初始化次序并不会引发问题。如果会的话,常见错误55的附文就有用武之地了。新的MsgType动态型别对象能够在运行期通过动态加载(dynamic loading)被添加到该表结构中。
说到静态对象,请注意在上述Firewall class的实现中,只有含有静态数据成员,但这些成员是被非静态的成员函数操控的。这是单态设计模式的一个实例。在避免使用全局变量的方面,单态设计模式是相对于单件设计模式的另一种选择。单件设计模式迫使其用户透过调用其instance静态成员函数来访问其独一无二的对象实例。如果以单件设计模式实现,我们只需要像下面这样做就可以了 ②:
Firewall::instance().addMessageType( mt );
② 译者注:单态设计模式的核心在于一个静态容器成员,这个容器中可以放置多态迭代器,而具体落实到何种型别则由外部消息源来决定。作者一步步地为我们展示了如何改进与非面向对象部分代码的交互,以及将表示与实现相分离的技术:以型别特征码为代表的裸表示是最差的,用虚函数浅浅地加一层包装的话改进不彰,再接下来则是引用了静态型别分派将维护局部化,最后祭出单态设计模式的翻天印,由浅入深可谓用心良苦。
而单态设计模式则允许创建任意数量的对象,但它们都通过同一个静态数据成员来被访问,而且访问方式也无一定的规约(protocol,主要是指成员函数的接口可以任意指定):
Firewall fw;
fw.genMsg( rawsource );
FireWall().genMsg( rawsource ); // 虽然通过不同的class对象,但状态相同
本条款所谈论的主题是过去15年中几乎所有C++教科书中都会涉及的。首先,不仅析构函数是否应该声明为虚函数含糊不清,class 是否应该被用作基类的设计取舍也缺乏文档。如果某 class的析构函数未被声明为虚函数,那么它很有可能并不会被用作基类。
未定义行为
标准的出台使得这条“未将析构函数声明为虚函数的class不用作基类”的忠告更多了一些强制性的意味。首先,如果基类型别未将析构函数声明为虚函数,那么经由基类接口来析构派生类对象现在将带来未定义行为的恶果:
class Base {
Resource *br;
// ...
~Base() // 注意:未声明为虚函数
{ delete br; }
};
class Derived : public Base {
OtherResource *dr;
// ...
~Derived()
{ delete dr; }
};
Base *bp = new Base;
// ...
delete bp; // 没事儿
bp = new Derived;
// ...
delete bp; // 悄无声息地,灾难发生
有可能出现的情形是你调用了基类的构造函数来析构派生类对象:缺陷现身!这么一来,编译器爱干什么都行。(来个核心转储?或者发封充斥着脏话的电子邮件给你老板?还是帮你终身订阅 This Week in Object-Oriented COBOL杂志?)
};
虚拟静态成员函数
往好的方面讲,如果将基类型别的析构函数声明为虚函数,你就可以企及“虚拟静态成员函数”之境。饰词virtual和static是互斥的,并且成员版本的内存管理运算符(new、delete、new[]和 delete[])都是静态成员函数。不过,当调用虚析构函数时,在析构对象的过程中,调用的是最专属的成员版本的operator delete,尤其是存在一个对应的成员版本的operator new的前提下(参见常见错误63):
class B {
public:
virtual~B();
void *operator new( size_t );
void operator delete( void *,size_t );
};
class D : public B {
public:
~D();
void *operator new( size_t );
void operator delete( void *,size_t );
// ...
B *bp = getABofSomeSort();
// ...
delete bp; // 调用了派生类的operator delete!
由于基类型别的析构函数被声明为虚函数,标准就能保证我们将调用从属于“在class的动态型别之辖域”之成员版本的operator delete。是故,我们可能调用的是派生类之辖域内的operator delete。而派生类之成员版本的operator delete(理应当然地)位于派生类之辖域中,所以调用的实际上是派生类成员版本的operator delete[6]。
总而言之,尽管operator delete是个静态成员函数,一旦基类型别的析构函数被声明为虚函数,就可以保证派生类专属的operator delete会被调用,即使析构动作是通过基类型别的指针进行也毫不含糊。以上面这段代码为例,对指针进行的析构动作会调用D型别的析构函数,接着是 D型别成员版本的 operator delete,而传给该 operator delete 的第二个实参会是sizeof(D)而非sizeof(B)。干净利落!虚拟和静态的绝配。
利用基类虚析构函数之副作用
早期的 C++代码经常带有这样的假设前提:在单继承条件下,基类子对象的地址和整个class对象本身具有相同的地址(参见常见错误29)。
class B {
int b1,b2;
};
class D : public B {
int d1,d2;
};
D *dp = new D;
B *bp = dp;
虽然标准对此保证只字未提,在上述条件下,D 型别的完整 class 对象中几乎可以肯定是把B型别的子对象放在最前面的,如图7-2所示。
无论如何,如果派生类中声明了一个虚函数,那么对象中就会含有一个指涉到虚函数表的指针(vptr)被编译器隐式插入(参见常见错误 78)。这种情况下有两种对象内存布局比较常见,如图7-3所示。
在第一种情况下,这种有关在单继承条件下基类子对象的地址和整个class对象本身具有相同地址的不堪一击的假设居然还能跌跌撞撞地挺过去。但在第二种情况下,它就彻底坍台了。当然了,对这个问题的终极解决方案就是重写任何涉及非标准假定的代码。这就是说,你必须停止使用void *型别的指针来持有指涉到 class 对象的指针(参见常见错误 29)。撇开这个不谈,如果在基类中插入了虚函数,编译器基本上会将其实现为符合该非标准假定的内存布局,如图7-4所示。
通常来说,这样一个基类虚函数的最佳候选就是虚析构函数。
凡事皆有例外
就连这种最基础的习惯用法也有例外。例如,有时将一组型别名称、静态成员函数和静态数据成员包装在一个干净的包中,殊为便利:
namespace std {
template <class Arg,class Res>
struct unary_function {[7]
typedef Arg argument_type;
typedef Res result_type;
};
}
在这种情况下,就没必要使用虚析构函数,因为从该模板具现的classes并无需要回收的资源 [8]。它还被仔细地设计,以确保这些从它具现的型别被用作基类时,没有任何存储和运行时间的开销:
struct Expired : public unary_function<Deal *,bool> {
bool operator ()( const Deal *d ) const
{ return d->expired(); }
};
最后,unary_function是标准库中的组件。经验丰富的C++软件工程师都不会把它当作一个全功能的基类,是故亦不会通过 unary_function 的接口来操作其派生类对象。这是个特例。
下面的代码例子另一个取自某个有口皆碑的但并非标准的库[9]。此间设计与取自标准库中的例子遵循了同样的约束,但是因为它无标准可依,作者就不能指望用户能够对这个class有多么熟悉 [10]:
namespace Loki {
struct OpNewCreator {
template <class T>
static T *Create() { return new T; }
protected:
~OpNewCreator() {}
};
}
作者在此处采用的解决方案是声明一个具有访问层级protected的inline的非虚析构函数。这就涵盖了有关存储和时间代价的要求,使得该析构函数不容易被误用,而且也开宗明义地提示了该class并无被用作基类的打算 [11]。
尽管有这些例外,将基类型别的析构函数声明为虚函数仍然在绝大多数情况下是个好的设计[12]。
非虚成员函数指定了以基类为根的继承谱系或子谱系中的不变量。做派生类设计的软件工程师不能够改写非虚成员函数,虽然能够,但是不应该遮掩它们(参见常见错误77)。这条规则背后的逻辑也是直截了当的:如果不这么做,多态的概念就会被彻底打破。
public:
所谓一个 class对象具有多态特质,是指它只有一个实现体,却兼具多种型别。以我们对抽象数据型别的理解,可以知道型别就是指一组操作,这些操作以该型别中可访问的接口表示。举例而言,Circle对象皆为Shape型别,是故,毫不奇怪地,通过(Circle或Shape)任意一个型别的接口调用同一操作,都应该表现出一致的行为:
class Shape {
public:
virtual~Shape();
virtual void draw() const = 0;
void move( Point );
// ...
};
class Circle : public Shape {
public:
Circle();
~Circle();
void draw() const;
void move( Point );
// ...
};
设计Circle型别决定遮掩基类型别中的move成员函数(可能基类型别假定取用的Point型别的实参是指位于上部的顶点,而Circle型别的版本中则指圆心)。现在,同一个 Circle对象会依调用函数的接口不同而表现出不同的行为:
void doShape( Shape *s,void (Shape::*op)(Point),Point p ) { (s->*op)( p ); } Circle *c = new Circle; Point pt( x,y ); c->move( pt ); doShape( c,&Shape::move,pt ); // 糟糕!
// ...
遮掩基类型别中的非虚(成员)函数给继承谱系的运用带来了复杂性,如果没有某种补偿措施的话:
};
class B {
public:
public:
void f();
void f( int );
};
class D : public B {
public:
// ...
void f(); // 坏主意!
};
};
B *bp = new D;
bp->f(); // 糟糕!明明想调用的是D::f(),结果调用了B::f()!
D *dp = new D;
dp->f( 123 ); // 错误!B::f(int)被遮掩 [13]!
虚函数和纯虚函数就是指定成员函数之行为实现依型别之可变性的机制。对虚函数而言,在派生类中改写它,就保证了只有一种实现——因此,只有一组操作——在运行期能够对某特定的 class 对象而言可用。是故,class 对象表现出来的行为不会与调用的接口相依,即不管是从基类还是派生类型别的指针或引用,都会调用派生类改写版本的成员函数。
凡事都有两面性,请注意虚函数可以以非虚方式调用,只要通过使用辖域运算符(scope operator)即可,但这只是其接口自有的合法操作,不应该被视作设计的一部分。无论如何,如果从这个角度来说的话,基类型别中的虚函数即使被改写了,也仍然有办法通过派生类型别的指针和引用来调用其真身:
class Msg {
virtual void send();
class XMsg : public Msg {
void send();
};
// ...
XMsg *xmsg = new XMsg;
xmsg->send(); // 调用了派生类改写后的XMsg::send
xmsg->Msg::send(); // 调用了基类真身,在一般语法调用时被遮蔽的Msg::send
这是一种不得已而为之的变通手段,并非设计本意。不过,被改写的基类型别虚函数可以以非虚方式调用的事实是可以提升为一种设计的:这种调用被普遍地用作一种由基类提供的在全部或部分派生类型别改写版本中共享的基础的实现。
装饰器设计模式(Decorator Pattern)的标准实现就是这种设计的一个通用的示例。装饰器设计模式的作用是在继承谱系中已有成员函数的基础上增强功能,而不是将其替换:
gotcha71/msgdecorator.h
class MsgDecorator : public Msg {
public:
void send() = 0;
// ...
private:
Msg *decorated_;
inline void MsgDecorator::send() {
decorated_->send(); // 转发调用
}
MsgDecorator class是个抽象类,因为它声明了纯虚函数send。从它派生的具象类必须要改写纯虚函数MsgDecorator::send。无论如何,尽管它不能以普通虚函数的方式来调用(除了在一些极端特殊的、非标准的、摇摇欲坠的情形下。参见常见错误 75),send还是可以以非虚方式透过辖域运算符调用。MsgDecorator::send之实现提供了一个通用的、共享的实现,所有MsgDecorator型别的派生类只要改写它,就必须给它一个实现 [14]。若需要调用基类给出的这个默认实现,只须以非虚方式调用即可:
gotcha71/msgdecorator.cpp
void BeepDecorator::send() {
MsgDecorator::send(); // 实现基本功能
cout << '\a' << flush; // 实现特有的附加功能
}
另一种可行的手法是声明一个protected访问层级的虚函数,以给出通用的函数实现。但是给出定义的纯虚函数更彰显了其用于派生类函数迫使其改写的初衷。
模板方法设计模式(Template Method Pattern)将算法分离成不变部分和可变部分。我们把在继承谱系中保持不变的算法写作基类中的非虚成员函数。当然了,即使是非虚成员函数也允许派生类型别中将算法加以部分定制。典型的手法是通过调用改写过的具有protected访问层级的虚函数(记住,模板方法设计模式和C++语言中的模板没有任何关系)。
这就允许基类型别的设计者能够保证算法的总体结构,但也能给予派生类型别的设计者某种程度上的定制自由:
class Base {
public:
// ...
void algorithm();
protected:
virtual bool hook1() const;
virtual void hook2() = 0;
};
void Base::algorithm() {
// ...
if( hook1() ) {
// ...
hook2();
}
// ...
}
模板方法设计模式在虚函数和非虚函数之间提供了某种折衷。动用我们已知的有关基类型别设计习惯用法的知识,检视一下从该习惯用法本身我们能够给派生类的设计工程师传达多少沟通信息,是有教益的:
class Base {
public:
virtual~Base(); // 我是个基类(若非如此何以要定义虚析构函数)
virtual bool verify() const = 0; // 你得自行校验(verify)
virtual void doit(); // 你可以想怎么做(do)就怎么做
long id() const; // 要么和这个函数共存,要么走开
void jump(); // 当我说“跳”(jump)时,你的问题是……
protected:
virtual double howHigh() const; // ……从多高(high)的地方跳……
virtual int howManyTimes() const = 0; // ……跳他几回(times)……
};
许多设计新手都错误地假定,要搞设计就要搞得愈灵活愈好。这些设计工程师经常犯这样的错误,就是把模板方法设计模式中的算法部分声明成虚函数,而且沾沾自喜地觉得这么做了以后带来的额外灵活性会让派生类的设计工程师受益。错!给予派生类的设计工程师最大助益的,是基类中无多义性的规约 [15]。如果在通用型代码 [16]要求从模板方法设计模式中获取某个特定的常规行为,那么派生类必须对之予以认可(即不能改变这个常规行为的模式)和实现(即应该改写这个常规行为模式所要求的虚函数以使常规行为的模式落实为一种特定配置的行为)。
class Thing {
下面这段基类的代码片段有何问题:
public:
class Thing {
public:
// ...
virtual void update( int );
virtual void update( double );
};
考虑某个从它派生的型别,设计工程师决定只让int版本的update表现出特有行为:
class MyThing : public Thing {
public:
// ...
void update( int );
};
};
这里,重载和改写这两种风马牛不相及的 C++语言特性令人不快地不期而遇。其结果和对基类型别中非虚(成员)函数的遮掩如出一辙:MyThing对象的行为将与访问接口相依:
MyThing *mt = new MyThing;
Thing *t = mt;
t->update( 12.3 ); // 没问题,调用的是基类型别中的Thing::update
mt->update( 12.3 ); // 糟糕,调用的是派生类型别中的MyThing::update!
函数调用“mt->update( 12.3 )”将在派生类辖域中找到一个名字为update的函数,然后又发现这是个成功的匹配——只要将实参从double型别(窄)转换至int型别即可 [17]。这很有可能并非软件工程师的初衷。即使这是某个世界观与常人迥异的软件工程师有意为之,这样的代码也只能把未来的维护工程师的脑子弄糊涂。
在对于重载虚函数的行为口诛笔伐之前,我们可以像一些不足为训的C++教科书所宣称的那样,要求派生类的设计工程师把带重载的虚函数中的每一个都加以改写。这样的做法并无实践意义,因为它等于是要求派生类的设计工程师都去遵循某个太过特殊的与基类型别的知识太过相关的规则[18]。
现实情况是很多派生类,例如用以扩展代码框架(framework)的那些 [19],都是在对于基类型别本身以及其编码和设计时约定俗成的语境毫不知情的前提下加以研发的。
总而言之,避免重载虚函数并不会将给基类接口带来任何剧烈的约束。如果在基类型别成员函数的运用中的确存在重载的必要,那么完全有理由重载非虚成员函数,然后将其分派的任务丢给名字各异的虚函数:
// ...
void update( int );
void update( double );
protected:
virtual void updateInt( int );
virtual void updateDouble( double );
inline void Thing::update( int a )
{ updateInt( a ); }
inline void Thing::update( double d )
{ updateDouble( d ); }
现在派生类型别的设计师可以自由决定独立地改写这些虚函数中的任意一个,并且不会打破多态观念。当然了,派生类型别中不应该把任何成员函数命名为 update,“不得遮掩基类型别中的非虚成员函数”的禁令此处仍然有效。
此规则亦有例外,但相对来说比较罕见。在访问者设计模式(Visitor Pattern)的通用实现中就有这么一个特例(参见常见错误77)。
其实这和重载虚函数的问题是一回事。与重载一样,默认实参初始化物亦不过是一种语法糖,用以在不添加新行为代码的情况下改变函数的接口:
class Thing {
// ...
virtual void doitNtimes( int numTimes = 12 );
};
class MyThing : public Thing {
// ...
void doitNtimes( int numTimes = 10 );
};
由于静态绑定与动态绑定时的 class对象行为相异而引发的问题通常是很难跟踪的:
Thing *t = new MyThing;
t->doitNtimes();
// ...
对于上面这个class的假定是对于Thing型别(及未改写doitNtimes虚函数的派生类型别)的class对象默认将doitNtimes函数执行12次 [20],但对于MyThing对象而言,只执行10次。不幸的是,默认实参初始化物乃是静态绑定的,从基类型别出发以静态方式决定的默认实参初始化物是 12,于是这个值就被传给了以动态方式绑定的派生类型别的虚函数调用中。
我们若试图以要求所有的派生类型别的设计工程师在改写时严格而精确地重复基类中的虚函数实参默认初始化物,以解决这个问题的话,这可就成了糟糕透顶的主意,理由有若干。
};
首先,软件工程师都颇自以为是,有些人根本不把编码建议当回事(他们会一看到基类型别中指定了虚函数实参默认初始化物就对其嗤之以鼻,并自己另搞一套)。
其次,这种建议将派生类置于“基类型别变化”摇摇欲坠的危卵之上。一旦虚函数实参默认初始化物发生变化,就必得协调所有的派生类都要作出相应变化。典型情况下,这办不到。
最后,实参默认初始化物的涵义有可能发生变化,这取决于它出现在源代码的哪一部分。句法上一模一样的实参默认初始化物,在基类和派生类辖域的语境中可能代表不同的意思:
// 头文件thing.h
const int minim = 12;
namespace SCI {
class Thing {
// ...
virtual void doitNtimes( int numTimes = minim ); // 意为::minim
};
}
// 头文件mything.h
namespace SCI {
const int minim = 10;
class MyThing : public Thing {
// ...
void doitNtimes( int numTimes = minim ); // 意为SCI::minim
};
}
很难指责派生类的设计工程师使用了错误的minim[21],尤其在SCI::minim的声明在MyThing class写完以后才添加的情形下。
最简单也是最直截了当的手法就是避免在虚函数中提供实参默认初始化物。如同重载虚函数的解决方案一样,我们可以通过小小的inline戏法达成我们在接口方面的目标:
class Thing {
void doitNtimes( int numTimes = minim )
{ doitNtimesImpl( numTimes ); }
protected:
virtual void doitNtimesImpl( int numTimes );
以Thing型别为根的继承谱系的用户可以静态地从基类型别的接口中取得实参默认值,而派生类也可以自由地变更函数的行为,而不用为什么虚函数实参默认初始化物的静态绑定特征而操心了 [22]。
class B {
class D : public B {
构造函数的作用是为对象截获相应的资源,俾使与对象相关的操作得以运作,而析构函数则用以将这些资源清算。我们何不显式地将此间架设计定案(architectural decision)彰显于我们的基类型别中呢?
public:
class B {
public:
B() { seize(); }
virtual~B() { release(); }
protected:
virtual void seize() {}
virtual void release() {}
};
而派生类则能够通过改写基类型别中的函数来定制其资源申请的行为:
class D : public B {
public:
D() {}
~D() {}
void seize() {
B::seize(); // 获取基类所需的资源
// 获取派生类所需的资源
}
void release() {
// 回收派生类所需的资源
B::release(); // 回收基类所需的资源
}
};
// ...
D x; // 并无资源被截取或清算!
在对 x初始化的第一步中,派生类型别的构造函数将调用基类型别的构造函数,从而引发一个对虚函数seize的调用。在对x析构的最后一步中,派生类型别的析构函数将调用基类型别的析构函数,从而引发一个对虚函数release的调用。只是,这个过程中并无任何资源的截取和清算的动作。
问题在于,在基类型别的构造函数被派生类型别的构造函数调用起来的那个时间点,class对象x尚不能说是D型别的。在x内部调用起来初始化B型别子对象的基类型别构造函数在这个时间点上只能使它表现出 B 型别的行为。是故,当虚函数 seize被调用起来以后,它实际上是被绑定到(即落实为)了B::seize。相同的情境也在析构函数身上以完全逆向的方式发生。当运行到派生类型别的析构函数开始调用基类型别的析构函数的那个时间点时,class对象 x已经不再是 D型别的(由于析构过程与构造过程的对称性,该时间点上D型别的相关信息已被悉数抹除),其B型别子对象仍然只可能表现出B型别的行为(而决不可能表现出其动态型别的行为)。对于虚函数(此时一点都不“虚”)release的调用只会绑定到B::release (而决不可能绑定到D::release,因为理论它得不到所需要的型别信息)。在这种场合下,最直截了当的解决方案就是为复杂 class对象之构造和析构准备的内建机制。为基类子对象截取或清算资源的代码就应该放在基类型别的构造函数和析构函数里(换言之,这里应该各扫门前雪,不要企图再让基类代理派生类的资源管理之责)。
};
public:
B() {
// 获取基类所需的资源
}
virtual~B() {
};
// 回收基类所需的资源
// ...
}
D() {
// 获取派生类所需的资源
}
~D() {
// 回收派生类所需的资源
}
D x; // 现在可以运作了
顺便提及,有时候可能调用纯虚函数的话会得到虚拟化的而非静态的调用序列:
class Abstract {
public:
Abstract();
Abstract( const Abstract & );
virtual bool validate() const = 0;
// ...
};
bool Abstract::validate() const
{ return true; }
Abstract::Abstract() {
if( validate() ) // 企图调用纯虚函数
// ...
};
无论如何,标准把这种行为规定为未定义行为。从特定平台的观察所得包括引发一个异常中止的虚函数调用、以空指针的函数调用,或是真的调用了Abstract::validate(这个才是真正危险的)。即使行为符合预期,代码仍然是脆弱而不可移植的。
注意,本条款只涉及在对象当前处于构造和析构阶段时调用虚函数的情形。如果说在构造函数和析构函数中调用另外的、已经构造完毕并且未开始析构的class对象的虚函数,那当然是完全无可非议的:
Abstract::Abstract( const Abstract &that ) {
if( that.validate() ) // 没问题
// ...
}
template <typename T>
赋值运算符声明为虚函数是合法的,但是虚赋值却鲜见其合理之处。举个例子来说,我们可能搞一个表示容器的继承谱系,它支持从基类接口出发的虚赋值:
class Container {
class Container {
template <typename T>
class Container {
public:
virtual Container &operator =( const T & ) = 0;
// ...
};
template <typename T>
class List : public Container<T> {
List &operator =( const T & );
// ...
};
template <typename T>
class Array : public Container<T> {
Array &operator =( const T & );
// ...
};
// ...
Container<int> &c( getCurrentContainer() );
c = 12; // 意义明确否?
public:
public:
请注意,这些都不是复制赋值运算符,因为实参的型别并非容器型别(欲知为何派生类改写的赋值运算符的返回值可以与基类型别中的不同,参见常见错误 77)。此处,赋值运算符意指将所有 Container中的元素置为相同的值。不幸的是,实践经验表明对于赋值运算符的此类运用有时会被误解,有些人觉得此处赋值运算符用来改变容器的尺寸,另一些则断言它只是用来修改首元素的值(参见常见错误84)。较为安全的接口则不会使用运算符重载,而是改用不会引发歧义的非运算符成员函数:
// ...
// ...
};
template <typename T>
virtual void setAll( const T &newElementValue ) = 0;
class List : public Container<T> {
};
// ...
// ...
};
template <typename T>
c.setAll( 12 ); // 意义明确
class Array : public Container<T> {
复制赋值运算符亦可声明为虚函数,但这很少会成为一个正确的设计,因为派生类型别中即使写了复制赋值运算符,也并不是对基类型别中的复制赋值运算符的改写:
template <typename T>
class Container {
public:
virtual Container &operator =( const Container & ) = 0;
// ...
};
template <typename T>
class List : public Container<T> {
List &operator =( const List & ); // 并未改写
List &operator =( const Container<T> & ); // 这才改写了……
// ...
};
// ...
Container<int> &c1 = getMeAList();
Container<int> &c2 = getMeAnArray();
c1 = c2; // 将数组赋值给线性表,这算什么意思?
Container<int> &c2 = getMeAnArray();
// ...
虚复制赋值运算符将使得从一个派生类对象向另一种完全不同的派生类对象的赋值成为可能!这几乎没有可能成为有意义的赋值动作。别搞什么虚复制赋值运算符的名堂。
有人可能会举上面的 Container继承谱系例子来说明虚复制赋值运算符的用途,因为有时将一种容器(数组)之内容赋值给另一种内容(线性表)的确是有意义的。不过,这相当于假定任意一种容器都了解所有其余的容器的实现细节(这通常是糟糕的设计实践),或某种使用的平台有相当程度的细节介入。一种更平凡的、也是更好的解决方案是使用某名字为copyContent的Container型别的非虚成员函数,或并非Container型别的成员函数,但以虚函数或迭代器的手法从复制源处提取值,然后将该值插入复制目的中:
Container<int> &c1 = getMeAList();
Container<int> &c( getCurrentContainer() );
c1.copyContent( c2 ); // 将数组的内容复制给线性表
从标准库的容器组件中就可以发现这种解决途径,在彼处,可以取用现存的不同型别的容器中的一个(前闭后开)序列来初始化某个容器:
vector<int> v;
// ...
list<int> el( v.begin(),v.end() );
通常,虚复制构造是比虚赋值更好的设计思路。当然,C++语言中并无所谓虚构造函数,但我们的确有一种“虚构造函数”的习惯用法,现在更多的人称之为原型设计模式(Prototype Pattern)。相比对一个未知对象赋值而言,我们做的是克隆(clone)它。基类型别提供一个名为clone的纯虚函数,然后由派生类改写之,用以返回一个其 class对象自身的精确复制副本。典型情况是,该副本由派生类型别的复制构造函数生成,是故可以把这个clone函数实现的操作视为虚复制构造:
gotcha90/container.h
virtual Container *clone() const = 0;
};
List( const List & );
List *clone() const
{ return new List( *this ); }
template <typename T>
Array( const Array & );
Array *clone() const
{ return new Array( *this ); }
// ...
Container<int> *cp = getCurrentContainer();
Container<int> *cp2 = cp->clone();
应用原型设计模式的做法以成效论,就好比说“我也不清楚我究竟获取了何种动态型别的对象,但我希望所有人在浑然不知的前提下取得期望的结果”。
在技术讨论中,过了好几分钟才发现参与讨论的对方居然不能区分重载和改写,这总是令人震惊不已。若是双方对以依语句区块进行结构划分的(block-structured)遮掩一无所知,那么谈话势必进入死胡同。其实,事情远不必弄到这种地步,要分清这几个概念简直是小菜一碟。
C++语言中的重载,简单地讲,就是为不同的函数使用同一标识符,并且这些函数位于同一辖域。后一个条件相当关键:
bool process( Credit & );
// ...
bool process( Acceptance & );
};
// ...
bool process( OrderForm & );
};
这3个全局函数显然互为重载关系。它们都使用了标识符process来标识,并且它们位于同一全局辖域。编译器能够根据调用process时提供的实参的型别来区分应该调用它们中的哪一个。这说得通。如果我要求处理(process)一个型别为Acceptance的class对象,那么我理所当然地期望该调用被解析为上述第二个函数的调用,而不是第一个或第三个。在C++语言中,函数名字 [23]是由函数声明中的标识符(此处指process)和其形参的型别组合而成的。让我们把这3个函数嵌入到一个class中去:
class Processor {
public:
virtual~Processor();
bool process( Credit & );
bool process( Acceptance & );
bool process( OrderForm & );
};
重载仍然是重载,编译器仍然会根据实参的型别作正确的解析以调用正确的成员函数。Processor 型别中的虚析构函数说明了设计者有意将其用作基类,那么我们可以放心大胆地通过继承手法扩展其功能:
class MyProcessor : public Processor {
public:
bool process( Rejection & );
// ...
};
但不是这么个做法。派生类型别中的 process 函数并不是基类型别中的process函数之重载同胞。前者遮掩了后者:
Acceptance a;
MyProcessor p;
p.process( a ); // 错误!
当编译器在派生类的辖域中查找(look up)名字时,它只命中了一个候选函数。该函数被声明为只有一个Rejection型别的形参,所以我们收到一个“实参型别不匹配”的错误(除非有某种途径把Acceptance型别转换到Rejection型别)。没别的了。编译器不会再继续在辖域闭包(enclosing scope)中搜索可作为候选的其他process函数。派生类型别中的process函数声明在派生类辖域中,而并非基类辖域中,是故它不和基类中的函数形成重载关系。
将基类型别中的声明使用 using 声明语句(using-declaration)汇入(import)派生类辖域中也是可以做到的:
class MyProcessor : public Processor {
public:
using Processor::process;
bool process( Rejection & );
// ...
};
现在4个函数都在同一辖域了,派生类型别中的process函数现在和显式汇入的基类型别中的process函数形成了重载关系。注意,这并非什么值得效仿的设计手法,因为它太过复杂,而复杂的设计相对于平凡的设计而言总是次选,除非它能够有什么高明之处作为补偿。
在这里的情况下,Rejection对象只能经由MyProcessor型别的接口来处理了,如果经由基类Processor型别的接口来处理Rejection对象就会收到一条编译期错误。如果存在某种方法可以将 Rejection 型别转换到Acceptance、OrderForm或Credit型别中的一种或多种,这样的话,调用能够经由任一型别的接口成功实施,但是将表现出完全不同的行为。
改写只能在基类型别中存在虚函数时才能发生。没别的了。改写和重载可是一丝半点的关系都没有 [24]。基类型别中的非虚(成员)函数没法被改写,而只能被遮掩:
class Doer {
public:
virtual~Doer();
bool doit( Credit & );
virtual bool doit( Acceptance & );
virtual bool doit( OrderForm & );
virtual bool doit( Rejection & ); [25]
// ...
};
class MyDoer : public Doer {
private:
bool doit( Credit & ); // #1,遮掩
bool doit( Acceptance & ); // #2,改写
virtual bool doit( Rejection & ) const; // #3,未改写
double doit( OrderForm & ); // #4,错误!
(请注意,上述以 Doer型别为根的继承谱系仅供演示之用,并非用作设计实践典范。尤其是,重载虚函数是难得能够被接受的,参见常见错误73。)
标了#1的那个版本的doit函数没有改写基类型别中对应的函数。它的确做到了的,是把基类型别中的4个doit函数统统给遮掩了。
标了#2 的函数才真的改写了基类型别中对应的函数。注意,访问层级对改写毫无影响。基类型别中的函数有public访问层级,而派生类型别中的函数有private访问层级也不要紧,反之亦然。习惯上,派生类改写的函数和基类型别中对应的函数具有相同的访问层级。在某些场合下,和标准的实践有所偏离也诚属情有可原:
class Visitor {
public:
virtual void visit( Acceptance & );
virtual void visit( Credit & );
virtual void visit( OrderForm & );
virtual int numHits();
};
class ValidVisitor : public Visitor {
void visit( Acceptance & ); // 改写void
visit( Credit & ); // 改写
int numHits( int ); // #5,非虚成员函数
在此情形下,此继承谱系的设计工程师打算赋予该继承谱系的用户定制基类型别行为的能力,但同时又要求他们必须使用基类型别的接口。于是,设计工程师为达到这个目的,采用的手法是赋予基类型别中的函数public访问层级,而在派生类型别中以private访问层级改写之。
还请注意,在派生类中改写基类型别中对应的函数时,是否使用关键字virtual 完全无关紧要。派生类型别中声明对应函数时,使用或不使用该关键字,意义如出一辙:
class MyValidVisitor : public ValidVisitor {
void visit( Credit & ); // 改写
void visit( OrderForm & ); // 改写
int numHits(); // #6,虚成员函数,改写了Visitor::numHits
};
常见的误解是“只要在派生类中改写基类型别中对应的函数时不加virtual关键字即可阻止该函数在更深的派生类(more derived class)中被改写”。事实并非如此,MyValidVisitor:: visit( Credit & )就改写了基类ValidVisitor和Visitor型别中的对应函数。
派生类改写隔级基类(remote base class)型别中对应的函数也是完全合法的。MyValidVisitor:: visit( OrderForm & )就改写了隔了一级的基类Visitor型别中的对应函数。
甚至,派生类改写隔层基类型别中对应的函数时,此函数在该派生类辖域中不可见时都合法。比如,标了#5 的函数 ValidVisitor::numHits并未改写其基类型别中的函数Visitor::numHits,但它对更深的派生类遮掩了基类型型别中(名为 numHits)的函数。但即使有了这么一层遮掩,MyValidVisitor:: numHits还是照样改写了Visitor::numHits。
标了#3 的 MyDoer 型别的成员函数比较微妙。它确实是虚函数,但只是因为它是如此声明而已。它并未改写基类型别中对应的虚函数,因其是一个常量成员函数(const member function),而基类型别中并无对应的的虚常量成员函数。常量性是函数签名式的一部分(参见常见错误82)。
上述标了#4的MyDoer型别的成员函数是个错误。它改写了基类型别中对应的虚函数,但它不具有兼容的返回值型别——基类型别中对应的虚函数返回bool型别,而派生类型别改写的版本则返回double型别。这会引发编译期错误。一般而言,若派生类改写了基类型别中对应的函数,它们必须有相同的返回值型别 [26]。这是为了确保运行期绑定发生时的静态型别安全性。派生类型别中改写的虚函数通常经由基类型别接口调用(毕竟这才是虚函数的存在理由),编译器必须生成这样的代码,它假定函数调用的返回值型别——不管它在运行期是绑定到了基类型别中的函数还是经派生类改写过的版本——就是基类型别中声明的那个。
在标了#4 的那个非法的函数声明中,派生类型别中改写的虚函数会试图将具有尺寸sizeof(double)的对象复制到某个为存储尺寸为sizeof(bool)的返回值的内存位置。即使尺寸兼容(亦即sizeof(bool)至少和sizeof(double)一样大),将double型别的值硬按bool型别来解释,也不太可能会得到一致的合理结果。
此规则有一种例外,称为“协变返回值型别”(covariant return types) (别把它和逆变性搞混了,参见常见错误 46)。基类型别中的成员函数的返回值型别和派生类型别中改写的函数的返回值型别具有协变性,当且仅当它们都是指涉到classes的指针或引用型别,且派生类型别中的函数的返回值型别对基类型别中的函数的返回值型别有皆然性。这段话读起来太长了,我们看两个协变返回值型别的典型例子:
class B {
virtual B *clone() const = 0;
virtual Visitor *genVisitor() const;
// ...
};
class D : public B {
D *clone() const;
ValidVisitor *genVisitor() const;
};
派生类中的clone函数返回一个指针,指涉到发出克隆请求的class对象的副本(这是原型设计模式的实现,参见常见错误76)。典型情况下,请求经由基类型别接口发出,对于被克隆class对象的精确型别并不知情:
B *aB = getAnObjectDerivedFromB();
B *anotherLikeThat = aB->clone();
有时我们知道一些更精确的型别信息,我们不想浪费这些信息,但也不想硬做一个向下转型的动作:
D *aD = getAnObjectThatIsAtLeastD();
D *anotherLikeThatD = aD->clone();
若没有协变返回值型别,我们就得将返回值型别从B *型别向下转换至D *型别:
D *anotherLikeThatD = static_cast<D *>(aD->clone());
请注意,在这种情形下,使用效率更高的static_cast优于dynamic_cast,因为我们了解D型别的clone函数操作返回的就是D对象。如果是其他的场合,使用dynamic_cast(或根本避免一个转型动作)更安全,是故为更佳选项。
函数genVisitor(此为工厂方法设计模式——Factory Method Pattern——的实例,参见常见错误 90)表明了协变返回值型别中涉及的型别未必和这些函数身在其中的继承谱系相关。
改写机制是 C++语言中极为灵活有用的工具。但它也是付出了复杂性代价才换来的。本章中的其余各条提供了一些建议以控制改写机制的复杂性,同时在需要用到它时能够使之物尽其用。
许多 C++新手对 C++语言中改写机制之实现的理解仅限皮毛。有时对这个实现机制的来龙去脉作个详解确实会有助于理解:有不止一种有效手法能够在 C++语言中实现出虚函数和改写机制,下面这种处理手法描述了其中比较常见的一种。
public:
public:
我们先来看一个单继承条件下的平凡实现:
class B {
virtual int f1();
};
};
};
public:
D *dp = new D;
virtual int f1();
virtual void f2( int );
public:
virtual int f3( int );
};
在此种虚函数的实现中,class 包含的每一个虚函数都被编译器指派了一个相应的索引值。举例而言,B::f1被指派了索引值0,B::f2被指派了索引值1,依此类推。这些索引值被用以访问一张指涉到函数的指针表。索引值为0的表项存储着B::f1的地址,索引值为1的表项存储着B::f2的地址,依此类推。每个 class对象中都包含这么一个被编译器隐式插入的指针,指涉到那张函数指针表。如图7-5所示,这是B对象的一种可能布局。
恕我多言,彼函数指针表称为“vtbl”,读作“vee table”[27],而彼指涉到vtbl的指针称为“vptr”,读作“vee pointer”[28]。B型别的构造函数负责初始化(每个B对象中的)vptr,使之指涉到适当的vtbl(参见常见错误75)。函数调用:
B *bp = new B;
bp->f3(12);
会被翻译成下面这个样子 [29]:
(*(bp->vptr)[2])(bp,12);
我们通过对vtbl做索引提领,以将要调用的函数之索引值取得其地址。尔后,我们生成一个间接调用,将class对象本身的地址作为隐式的“this指针”实参传递给该函数。C++ 语言中的虚函数机制是极为高效的。因为间接调用(即直接从地址出发,以函数调用运算符触发的非具名函数调用)基本上在所有的计算机硬件体系结构中都是高度优化过的[30]。而所有相同对象共享同一个vtbl[31]。在单继承条件下,每个class对象仅内含一个vptr,无论它其中声明了多少个虚函数 [32]。
我们再来看一个派生类改写了基类型别中的一些虚函数以后的实现:
class B {
virtual void f2( int );
virtual int f3( int );
class D : public B {
int f1();
virtual void f4();
int f3( int );
};
每个D对象都内含一个基类B型别的子对象。典型地,但并非普遍地(参见常见错误70),基类型别子对象置于派生类对象的最前面(是故,偏移量为0),而且派生类型别添加的数据成员都附在基类型别子对象部分之后,如图7-6所示。
我们再重新检视一下先前看过的那个虚函数调用,不过这次我们使用D型别,而不是B对象:
B *bp = new D;
bp->f3(12);
编译器会生成完全一样的调用序列,但是这次我们将在运行期被绑定到一个D::f3而非B::f3的调用:
(*(bp->vptr)[2])(bp,12);
虚函数机制的作用在真正多态的代码才能够更明显地体会到,在这样的代码中,操作对象的精确型别完全未知:
B *bp = getSomeSortOfB();
bp->f3(12);
编译器生成的调用序列拥有这样的强大威力:无需重新编译,它就可以调用任何B型别的派生类型别中的f3函数,即使这些派生类型别还不存在(亦即,后来添入继承谱系的也可以)。
从纯技术的视角看,所谓改写就是在编译器构造派生类对象的vtbl时,将其中存储着基类型别中的虚函数地址的表项替换成派生类型别中的对应函数地址的过程。我们的上述例子中,D 型别改写了其基类型别中的虚函数f1和f3,继承了其虚函数f2的实现,并添加了一个新的虚函数f4。这些变化都精确地反映在D型别的vtbl中了。
在多继承条件下的虚函数机制在细节方面要复杂一些,但是其思路本质则别无二致。其复杂性的来源,不过是因为派生类型别的对象有了多于一个基类型别的子对象,从而也就有了多于一个的合法地址。考虑下面的继承谱系:
class B1 { /* ...*/ };
class B2 { /* ...*/ };
class D : public B1,public B2 { /* ...*/ };
派生类对象可以经由其任一基类型别的接口操作,这是就是皆然关系的涵义。是故,一个D对象可以被D型别、B1型别或B2型别中的任一种的指针或引用指涉:
B1 *b1p = dp;
B2 *b2p = dp;
只有基类型别子对象的其中一个能被放在派生类对象的偏移量 0 处,所以多继承条件下基类型别子对象一般是按照其在派生类型别定义中的基类型别列表中的次序依次排列于派生类对象中。以D型别为例,B1型别的子对象将先出现,接着是B2型别的子对象,如图7-7所示(参见常见错误38)。
我们往这个平凡的多继承谱系中加几个虚函数,把它充实一下:
class B1 {
virtual void f1();
virtual void f2();
class B2 {
virtual void f2();
virtual void f3( int );
virtual void f4();
};
B1型别和B2型别都有各自的虚函数,所以它们的class对象也会各自持有型别专属的vptr,如图7-8所示。
D型别对B1型别和B2型别都有皆然性。是故,它内含两个vptrs,指涉到两个关联的vtbls,如图7-9所示:
class D : public B1,public B2 {
public:
void f2();
void f3( int );
virtual void f5();
请注意,D::f2将两个基类型别中的f2函数都改写了。而起到改写功能的派生类型别中的函数会将所有基类型别中名字和标记(实参的数量、型别和次序)相同的函数统统改写掉[译者注:其实这里存在着一个潜在的佯谬。如果不同的基类型别中存在着函数签名式相同,而返回型别不同的话,那么派生类该改写哪个是好呢?举个具体的例子,若存在以下的两个基类:
class B1{
public:
virtual~B1();
virtual int f1();
};
class B2{
public:
virtual~B2(){}
virtual double f1();
};
则无论改写哪一个派生类都会报错:
class D: public B1,B2{
public:
int f1(); // 错误!
};
// 来自Microsoft Visual Studio 2008 Version 9.0.30729.1 SP的错误信息:
// error C2555: 'D::f1':overriding virtual function return type
// differs and is not covariant from 'B2::f1'
// 意为:D::f1对于B2::f1虚函数的改写版本之返回值与其相异,并且不具协变性!
但转而讨好第二个基类,前一个又会大声抱怨:
class D: public B1,B2{
public:
double f1(); // 错误!
};
// error C2555: 'D::f1':overriding virtual function return type
// differs and is not covariant from 'B1::f1'
// 意为:D::f1对于B1::f1虚函数的改写版本之返回值与其相异,并且不具协变性!
如果干脆两个都置之不理,就会收到两条错误消息:
class D: public B1,B2{
public:
char * f1(); // 错误!
};
// error C2555: 'D::f1':overriding virtual function return type
// differs and is not covariant from 'B1::f1'
// error C2555: 'D::f1':overriding virtual function return type
// differs and is not covariant from 'B2::f1'
// 现在,两个基类都开始抱怨了。
但如果把函数签名式也改掉,编译算是能够通过了,但是多态性又被打破:
class D: public B1,B2{
public:
int f1(int);
};
D *dp = new D;
B1 *b1p = dp; dp->f1( 0 );
b1p->f1(); // 糟糕,现在基类型别和派生类型别的接口不同了!
由此我们可以得出初步的结论:在多继承条件下,若是多个基类型别存在接口冲突——即虚函数的标记式相同,返回值的型别却相异并且无协变性,那么派生类是无法直接改写它们的。解决方案是引入一些中介型别,一般称为接口类——Interface class,对基类型别中的虚函数进行转发改名。有关这种技术的介绍,参见(Stroustrup,2001),§ 25.6和(Sutter,2002),条款 26。请注意那些所谓“切割连体双婴”的问题和这里并不完全相同,因为那些基类型别中的虚函数的函数签名式和返回值型别都相同,是故它解决的问题是“如何避免多个基类型别中存着若干个函数签名式和返回值型别都完全相同的虚函数,被同一个派生类型别作相同的改写,而本来它们应该以不同的方式改写”,此处的问题是“多个基类型别中存着若干个函数签名式相同,但返回值型别并不相同的虚函数,这样会造成派生类的改写左右为难的情形”,但接口类的确对于后一个问题碰巧也是解决方案。本书中也多处提到了接口类这种重要的技术,参见常见错误53和常见错误92。总而言之,多继承条件下基类型别中存着若干个函数签名式相同的成员函数——无论是否虚函数——是其复杂性的重要来源,有关多继承的一个比较全面、客观的介绍,参见(Meyers,2006),条款40。不管该基类型别是其直接基类(direct base class)还是其基类的基类(……的基类)]。注意,尽管D型别添加了一个新的虚函数(D::f5),编译器却并没有往D型别专属的class对象位置插入一个新的vptr。典型地,派生类型别新添加的虚函数将会附加到其基类型别之一的vtbl中去 [33]。
不过,我们确实还是有一个未解决的问题的。让我们看看可能出现的代码:
B2 *b2p = new D;
b2p->f3(12);
这是很常见的代码实践,所做的工作不过是通过基类型别之一的接口来操作派生类对象。若是编译器生成和前面述及的单继承条件下相同的调用序列,我们就会因无效的this指针值而陷入尴尬的境地:
(*(b2p->vptr)[1])(b2p,12);
原因就在于该调用被动态绑定 [34]到了D::f3,它要求一个隐式的this指针实参,指涉到触发该调用的D对象的起始位置。不幸的是,b2p指涉到的触发该调用的D对象的B2 型别子对象的起始位置,它和整个D对象的起始位置有数个字节的偏移量,如图7-7所示。因此有必要对this指针实参“校正”这个偏移量的值,以把b2p的值调整为指涉到整个D对象的起始位置。
幸运的是,在构造派生类型别中的vtbl时,编译器就对修正值的确数了如指掌,因为它精确地知道它正在为其构造vtbl的派生类型别信息,同时也知道其各个基类子对象在派生类对象中的偏移量。有数种常用方法可以利用该修正值,比如在实际函数调用之前先运行一些短小的代码(这样的代码歪打正着地得了“thunk”之名),或是支持成员函数的多入点(multiple entry point,即成员函数可以有不止一个入口地址)。从概念上讲,表示this指针调整操作的最干净利落的手法就是把偏移量信息直接放入vtbl中,然后修改调用序列以达目的[35],如图7-10所示。
现在,vtbl的表项成了一些小结构,存储着成员函数的地址(fptr)和准
备加到this指针上的偏移量(delta),于是,调用序列变成了:
(*(b2p->vptr)[1].fptr)(b2p+(b2p->vptr)[1].delta,12);
这个代码可以被所有平台的硬件体系结构高度优化,它不像看起来那么成本高昂。
你可能有时会想,自己怎么选了这么一种语言来写代码,这种语言里包括了众多概念,诸如友元、private访问层级、受限友元(bound friends)[36],以及支配原则。本条款中,我们将考察继承谱系设计中的支配原则,为什么它是难以理解的,为什么它有时又是必不可少的。也许发表一个声明说自己从来不用为这种事操心是简单不过的,但迟早有一天,C++专家们会和这个议题来个面对面——可能是你自己,也可能是你的同事会撞这个大运,那么最好还是为此做好准备。有备无患嘛!
只有虚拟继承的语境中才会出现支配原则,还是看图说话为佳。如图 7-11所示,如果A型别是B型别的基类,则标识符B::name会对A::name形成支配。请注意,支配原则会延拓至其他的查找路径。比如,如果编译器在D型别的辖域中查找标识符name,则它既会找到B::name,也会经由另一条路径找到 A::name。不过,由于支配原则的存在,并不会报多义性错误。B::name起了支配作用。
请注意,同样的情况,若是不在虚拟继承条件下,就会报多义性错误。如图7-12所示,在D型别的辖域中对标识符name的查找有多义性。因为在C型别的辖域中,B::name并不对A::name形成支配。
支配原则看起来是一条相当诡异的语言规则,但是如果没有支配原则的话,在很多情况下就不可能为使用了虚拟继承的派生类型别构造vtbl。简而言之,动态绑定和虚拟继承的概念组合必然导出支配原则。
我们首先看一个平凡的带虚拟继承的继承谱系,如图7-13所示。我们可以将完整的D对象的可能布局表示为内含3个(由于虚拟继承的存在,不是4个)基类型别子对象,以及指涉到共享的V型别子对象的指针,如图7-14所示(实现的手法多种多样。这种实现有一点过时了,但是它比较方便图示,并且它逻辑等价于任何其他实现)。
就像人们所期望的那样,(虚)成员函数D::f的声明,如图7-13所示,同时改写了B1::f和V::f:
B2 *b2p = new D;
b2p->f(); // 调用了D::f
我们再看另一种情况,如图7-15所示。这个例子显然是非法的,因为B1::f和B2::f中的任何一个都可以被用以改写V::f。这会在编译期收到一条多义性错误。
最后,支配原则闪亮登场:
B2 *b2p = new D;
b2p->f(); // (正确地)调用了B1::f()!
如图7-16所示,标识符B1::f在所有路径支配了V::f,于是D对象中的V型别子对象的vtbl(之表项)就设置成了B1::f。如果没有支配原则,这里就也会引发一个多义性错误,因为D对象中的V型别子对象的vtbl既可以设置成V::f,也可以设置成B1::f,而支配原则在这里消除了多义性,指定B1::f为优选 [37]。
[1].译者注:此处的“98%”意思是并没有全部更新,而只要没有全部更新,哪怕只遗漏了一处也会引入缺陷。正所谓行百里,半九十。而且,这麻烦纯属自找。
[2].译者注:此种手法其实是做了一个面向对象的封装,将分派动作毕其功于一役。更重要的是,它使用了一个从“特征码”——原始的、非封装的量,到“结构”——面向对象的量的映射,这样的话实际上就是隐式地做出了动态绑定的一个原始实现。
[3].译者注:作者此处讲得晦涩,实际上意思就是以运行期可以添加和移除的动态型别代替静态的分派机制。
[4].译者注:此即所谓“运行期数据结构”:它可以动态地添加和移除,而不用更改源代码或重新编译。当然,这也增加了设计的复杂性和运行期的成本。
[5].译者注:原文是“The constructor will have the side effect of adding the MsgType to the static list in Firewall”,注意这里list使用了代码字体,但实际上Firewall型别中声明的是一个vector,应为笔误。作者的意思应该只是表示一个表结构而已,并非采用list实现。
[6].译者注:作者之所以大费周章地采用三段论式的说明,是为了逻辑严密起见,当然也有些饶舌之趣。此论述能够级联化,有可能调用的是最深派生类之成员版本的operator delete。
[7].译者注:这是标准namespace std中表示抽象一元函数的模板unary_function。
[8].译者注:请注意这里使用的classes是而原始的声明则是一个struct,而且还是模板,有关此主题的一段有趣讨论,参见(Lippman,2001),§1.2。
[9].译者注:这个库,即著名的由骨灰级C++大师Alexandrescu撰写的Loki库。有关该库的全面介绍,参见(Alexandrescu,2005)。
[10].译者注:以Alexandrescu神作亦不能作如此指望,你我更不该妄自尊大。
[11].译者注:这里插入一点题外话,C++标准委员会主席Herb Sutter指出,标准库组件有时可移植性却不如非标准库组件,因为标准库组件有时会插入一些额外的、供特定平台之用的默认形参——是所谓Peekaboo实参——而导致函数签名式的变化。有关这个问题的一个例子,参见(Sutter,2006),第4条。
[12].译者注:本书出版时,有关“是否必须要将基类型别的析构函数声明为虚函数”的讨论仍如火如荼,这个问题当时看起来还没有一个明确的结论,而且正如读者所见,这里给出的仍然是一个不明不白、不温不火的结语。什么叫“在绝大多数情况下是个好的设计”?给出的特例又具有何种能够明确描述的特征以区别于“绝大多数情况”?若干年后,Herb Sutter在深刻分析了虚函数——准确地说是C++语言中的虚拟性概念以后,提出了一个有关基类型别的析构函数是否应该声明为虚函数的完整论断:“基类型别的析构函数应该或者声明为具有public访问层级的虚函数,或者声明为具有protected访问层级的非虚函数。”伴随着这个论断出台的,是很长、很枯燥但是极有价值的分析和推论,和若干其他有关虚拟性和函数接口设计的理论成果和工程实践指导原则。对于这段论述的充分理解,将大大推进读者在面向对象思想方面的深度。我们注意到作者举的这个反例,恰是“具有protected访问层级的非虚函数”!这应该并非巧合,而必然是在工程上已经广泛运用,但是一时难以概括特征的结果。由此可见西方工程师在技术细节方面的苦修、慎言,以及社群和同道之间广泛、长期的合作。C++语言能有后来的成功,与此道中人合力打磨不无关系。而这个工程实践中得真知,真知灼见中出理论,科学理论又用回工程、指导工程的循环实令人叹为观止:这才是真正的技术含量!有关Herb Sutter对于虚拟性和面向对象思想与实践的论述,参见(Sutter,2006),第18条。
[13].译者注:原书此段印排错误,已纠正。
[14].译者注:请注意,派生类虽然必须以改写的方式给予这个纯虚函数一个实现,但在此实现中并不一定非要使用基类给出的这个默认实现,而可以径自改写成适当的其他实现。事实上,很多纯虚函数都只有声明而不予实现,有一个非空的实现反倒是并不平凡的。这种“给予纯虚函数一个定义”的用法,包括非虚成员函数代表了型别设计中的不变量,Scott Meyers曾经作过非常详细的讨论,参见(Meyers,2006),条款34。
[15].译者注:unambiguous contract,即基类中的不变性——以非虚成员函数表示的行为或算法,这个行为或算法有某种可控模式,是好的,但如果这根本就是一个连可控的模式都没有,而是可能根本就被彻底改写掉的话,这种“灵活性”给派生类的设计工程师带来的除了无所适从之外还能是什么呢?这就是作者竭力想在本章中想要传达给读者的核心思想,这个例子举得倒并不能让读者一目了然。
[16].译者注:原文是generic code,但此处“generic”并非指C++模板中的“泛型”,应予注意。
[17].译者注:有意思的是,即使某个派生类型别中 update函数具有 private访问层级也一样会破坏原始接口,甚至根本没有匹配时也一样。有关这个议题的深入讨论,参见(Sutter,2006),条款16。
[18].译者注:实际上这种要求带来了极大的耦合,想想如果往基类型别的重载虚函数中添加、删除或改动某些版本,按照这种要求的话会发生什么。
[19].译者注:有关C++标准库的扩展的详尽介绍,参见(Becker,2008)。
[20].译者注:此为望名生义,未给出实现体。
[21].译者注:因为文件可以很大,软件工程师很难关心到全部的声明和定义。
[22].译者注:作者未有提及的是为何虚函数的实参默认初始化物会有反直觉的静态绑定行为,这实际上是出于性能考量。
[23].译者注:作者应该意指函数签名式——signature,但这里却用了术语name。
[24].译者注:“改写”和“重载”的英文——override和overload的形式比较相近,容易混淆。
[25].译者注:重载虚函数不推荐。
[26].译者注:注意,返回值型别并非函数签名式的一部分,重载解析时并不要求函数有相同的返回值型别。
[27].译者注:中文称为“虚函数表”,或“虚表”。
[28].译者注:中文称为“指涉到虚函数表的指针”、“虚表指针”或“虚指针”。
[29].译者注:这里我们可以看到很明显的C++的Cfront传统,虽然前面也有一些语句翻译的例子,但这一句属于非常典型地先把C++语句先翻译成C语句再送交目标码编译,有关这段历史注记,参见(Stroustrup,2002),§3.3。
[30].译者注:时间代价。
[31].译者注:空间代价。
[32].译者注:当然,vtbl会依虚函数的个数而扩张。
[33].如图7-9所示,此处是将这个表项加入了B1型别的vtbl中,单继承条件下也是一样的,如图7-6所示。
[34].译者注:动态绑定和运行期绑定是一回事。
[35].译者注:最早出现的是thunk技术,甚至出现在C++语言之前——有人戏谑地称之为“Knuth名字倒过来写”,现在主要是Sun公司编译器以“split functions”的形式支持;多入点技术是Microsoft编译器支持的;而直接扩张vtbl结构则是IBM编译器采用的技术。有关这些技术的详细介绍和对比,参见(Lippman,2001),§ 4.2。
[36].译者注:模板和友元的一种交叉概念,参见(Vandevoorde,et al.,2008),§8.4、(Sutter,2006),第8条和(Brokken,2001)。
[37].译者注:避免禁止了一个不必要标为错误的逻辑,并且也比较符合设计初衷。
设计有效的抽象数据型别的工作是不折不扣的科学,同时也是一门艺术。要设计出具有产品强度的软件接口,需要的是深厚的技术功底、相当程度的社会心理学把握,以及相当的工程经验。没有什么东西比清晰易懂、一望即知的接口更能够为代码理解的深入和维护的准确提供保证。
本章中,我们将考察在设计型别接口时经常会犯的若干错误,然后就如何摆平它们提出建议。我们也将考察几个与实现相关的问题是如何影响接口设计的。
抽象数据型别中,所有的数据成员都应该只赋予private访问层级。但是,若是某 class 只有一堆具有 private 访问层级的数据,外加一堆具有public访问层级的状态访问接口,这并不就能说它是一个抽象数据型别。回想一下数据抽象的目的是什么:它是想将问题的描述从型别的具体实现中解放出来,使得代码的读者和撰写者能够直接使用问题领域的语言(language of the problem domain)进行交流。为达到这一目的,抽象数据型别将被定义为纯粹的一组操作,至于具体有哪些操作,这取决于我们对于“该型别究竟代表什么”的抽象观念。考虑一个栈:
{ return value_; }
template <class T>
class UnusableStack { [1]
public:
UnusableStack();
~UnusableStack();
T *getStack();
void setStack( T * );
int getTop();
void setTop( int );
private:
T *s_;
int top_;
};
public:
对于上面这个模板,我们能够做出的唯一正面评价也就是这名字起得相当符合实际。这里没有任何抽象在,而只是胡乱拼凑的一堆数据罢了。其公开接口并未有为其用户提供一个有效的抽象,而且连最起码的实现隔离都没有做。正确的栈实现提供了清晰的抽象观念,还有实现的无关性:
template <class T>
class Stack {
private:
Stack();
};
~Stack();
void push( const T & );
private:
T &top();
T *s_;
void pop();
bool empty() const;
int top_;
};
注意,在实践中,没有任何一个设计工程师会写出像UnusableStack那么不靠谱的接口来。随便哪个合格的软件工程师都知道栈有哪些操作,写出相应接口的工作几乎是靠本能完成的。但这个特殊的例子并不适用于所有的抽象数据型别,特别是在我们为自己并非专家的问题领域做型别设计的情形中就更是如此。如果遇到这种情形的话,作为专业的软件工程师或项目经理而言,我们应该虚心地把自己当小学生,与领域专家通力合作,不仅要确定软件客户究竟需要哪些抽象数据型别,还应该确定这些抽象数据型别支持哪些操作。要识别出哪些项目是缺乏与领域专家的沟通,一个最确信无疑的办法就是看哪些项目有着巨大百分比的带有取/设状态接口的classes就行了。
话虽然这么讲,但型别的接口中存在一部分访问器,亦即取/设状态接口函数,恐怕也诚属在所难免。那么,怎样才是这种函数的合理形式?我们有不少常见的可能形式:
class C {
public:
int getValue1() const // 取/设状态接口函数风格1
void setValue1( int value )
{ value_ = value; }
int &value2() // 取/设状态接口函数风格2
{ return value_; }
int setValue3( int value ) // 取/设状态接口函数风格3
{ return value_ = value; }
int value4( int value ) { // 取/设状态接口函数风格4
int old = value_;
value_ = value;
return old;
}
int value_;
其中风格2着墨最简,也最为来者不拒,不过也最为危机四伏。由于返回了指涉到private访问层级的实现,value2函数又比具有public访问层级的数据成员好在哪里呢?这个 class 的用户会对当前实现形成强烈的相依性,还能直接访问内部数据。这种形式很有问题,即使仅提供了只读访问权限也一样。考虑某个以标准库容器组件实现的class:
class Users {
public:
const std::map<std::string,User> &getUserContainer() const
{ return users_; }
// ...
private:
std::map<std::string,User> users_;
};
该“取状态”函数将太多不足为外人道的细节信息暴露了,它清楚地指出了用户元素容器是以标准库中的 map 容器组件实现的。任何调用该公开接口函数的代码现在可以(也一般就会)形成对此特定 Users实现的相依性了。若是性能剖析工具揭示出vector容器组件才是更高效的实现,所有使用了Users型别代码就都得重新写过。这种访问器函数根本就不应该存在。风格3有着较为怪异的行为,因为它并未提供对数据成员当前值的访问,但是它允许为数据成员设置新值,并返回该新值(你本来应该记录下旧有值的,不管怎么样你把这个值覆写了,不是吗)。它的确允许用户写出“a +=setValue3(12)”这样的表达式,而不是写出两个短语句“setValue1(12);a +=getValue1();”。但真正的问题在于,许多用户看到这样的接口就会假定它返回的是旧有的值,这会引起很多难以定位的缺陷。
我们的风格4乍看之下挺诱人的,因为它只用了一个函数就同时提供了取得旧值和设置新值的双重功能。可是,如果只是想取得当前值的话,却要费些周章:
int current = c.value4( 0 ); // 取(到current中)/设(为哑值0)
c.value4( current ); // 恢复为current保存的旧有值
为了取得当前值,我们必须先为value4函数提供一个新的哑值。这个哑值随后还必须再还原成旧有的值。这种手法看起来的确有那么一点拐弯抹角,不过这种技术还真的沾了一些 C++标准的龙脉,而且被标准库中的基础设施set_new_handler、set_unexpected和set_terminate所采用,用以注册内存管理和异常处理的回调函数(callback function)。典型地,这些函数被用以实现以栈风格注册的回调函数,而无须显式地使用栈的语法:
typedef void (*new_handler)(); // 回调函数型别
// ...
new_handler old_handler = set_new_handler( handler ); // 压栈
// ...
set_new_handler( old_handler ); // 从栈顶弹出
使用这种机制来取得当前注册的回调函数也相对麻烦些。以下就是实现这个操作的C++习惯用法:
new_handler handler = set_new_handler( 0 ); // 取得当前异常处理函数
set_new_handler( handler ); // 恢复为刚才的异常处理函数
但无论如何,如果不是这种标准的回调注册用途,还是不要把这种用法扩大化为佳。它增加了不必要的成本,将平凡的取当前值操作复杂化,使得异常安全性和可重入性更难照顾,而且还容易和风格3的代码弄混,一如先前所述。
风格1是较优的选项。它提供了最直截了当的实现机制,足够高效,而且,最关键的是它不会让代码读者产生任何歧义:
int a = c.getValue1(); // 取状态,显而易见
c.setValue1( 12 ); // 设置状态,显而易见
要是你的型别设计中非有取/设状态接口不可的话,那就用风格1吧。
有一条经典的忠告是“所有能够用作常量的东西都应该声明为常量。”另一条同样经典的忠告是“若是有什么东西并不总是用作常量,那就不要将其声明为常量。”各取其长,这些建议提示我们,是否加上常量性的决定实际上要求对于构造过程的当前形势及未来演化的洞见,“带有尽可能多的常量性,但过犹不及”。
本条未中,我将试图使你相信,在 class中声明常量或引用数据成员很少会有用。常量或引用数据成员使得型别更难协同工作、要求不自然的复制语义,而且诱使维护工程师做出危险的改动。
我们首先来看一个平凡的带有常量和引用数据成员的class:
class C {
public:
return *this;
C();
// ...
private:
int a_;
const int b_;
int &ra_;
};
其构造函数负责初始化其常量和引用数据成员:
C::C()
: a_( 12 ),b_( 12 ),ra_( a_ )
{}
到目前为止还没有什么问题。那么我们再声明几个 C 对象,并初始化它们试试:
C x; // 默认构造函数
C y( x ); // 复制构造函数
啊呀!这个复制构造函数是从哪里冒出来的呢?是编译器替我们隐式生成的。而默认情况下,这样的复制构造函数会执行一个按成员进行的初始化,以x的成员来初始化y的成员(参见常见错误49)。不幸的是,这个默认实现会将 y的 a_成员设置为 x的 a_成员的引用。既然说到了经典忠告的话题,那么这里就赠上另一条经典的忠告:“考虑为内含指涉到其他数据的句柄(通常是指针或引用)的class任何自行撰写复制构造函数”:
C::C( const C &that )
: a_( that.a_ ),b_( that.b_ ),ra_( a_ )
{}
让我们继续使用这个C对象:
x = y; // 错误!
这里的问题在于编译器没有办法为我们生成一个赋值运算符。默认情况下,它会试图为我们生成一个直截了当的将y的每个成员赋值给 x的对应成员的赋值运算符。可是对于C对象来说,这做不到,因为其b_和ra_成员不能被赋值。的确,这样一个赋值运算符所重蹈之覆辙,正是默认复制构造函数的前车之鉴。
麻烦了,想写出这个型别的赋值运算符殊非易事。考虑第一个尝试:
C &C::operator =( const C &that ) {
a_ = that.a_; // OK
b_ = that.b_; // error!
}
赋值给常量是非法的。这里的危险性在于,一些颇具“创新意识”的维护工程师面对我们的代码时,可能会发誓无论如何也要将赋值进行到底。通常,首当其冲的求助对象就是强制型别转换:
int *pb = const_cast<int *>(&b_);
*pb = that.b_;
现在,实事求是地讲,这段代码不会引起任何运行期问题,因为b_成员身为非常量的 C 对象的一部分,是不太可能被放入只读内存区段(read-only segment)中的。但无论如何这不能说是一种自然的实现,而且这种伎俩并不适用于引用成员(注意,在这个特定的赋值运算符中,没有必须重新将 C型别的引用数据型别绑定一次,因为它已经指涉到其自身的a_成员了)。
有些维护工程师实在已经被标新立异的冲动搞昏了头,他们会铤而走险,根本不去把y赋值给x,而是先把x彻底析构掉,然后再以为y初始化物,重新把x初始化一遍:
C &C::operator =( const C &that ) {
if( this != &that ) {
this->~C(); // 调用析构函数
new (this) C(that); // 调用复制构造函数
}
return *this;
}
在过去的岁月里,人们已经浪费了大量的口水把这种做法提出来,然而,最终还是拒绝了它。退一步讲,就算它在一个很受限的前提下得以运作——一时能够运作——它也太复杂,不能用在大项目里,而且肯定会在未来的某个时刻成为麻烦。想想如果 C 型别最后成了一个基类的话会发生什么事。很有可能派生类型别的赋值运算符会调用其基类 C 型别的赋值运算符。而析构函数的调用,如果是虚拟的,它就会析构掉整个派生类对象,而不仅仅是其 C 型别(子对象)部分;如果不是虚拟的,就会导致未定义行为。因此千万别这么干。
最简单易行也是最直截了当的解决手法,就是干脆避免使用常量和引用数据成员。因为我们所有的数据成员都只有private访问层级(它们的确只有private访问层级,对吧),我们已经有了对付无意修改的足够保护级别了。从另一方面讲,如果引入常量或引用数据成员的初衷是想阻止编译器隐式生成默认的赋值运算符的话,那么另有一种习惯用法能够达成此目的(参见常见错误49):
class C {
// ...
private:
int a_;
int b_;
int *pa_;
C( const C & ); // 禁止复制构造
C &operator =( const C & ); // 禁止复制赋值
};
常量或引用数据成员很少有用。因此别用它们。
语法
对于常量成员函数,人们的第一印象就是它那让人信心全无的声明语法。关键字 const跟在函数声明屁股后面,活像是黑客做的手脚,但其实并不是。就像 C++语言中其他的声明语法那样,常量成员函数的声明语法一方面符合逻辑,一方面又令人费解:
class BoundedString {
public:
explicit BoundedString( int len );
// ...
size_t length() const;
void set( char c );
void wipe() const;
private:
char * const buf_;
int len_;
size_t maxLen_;
};
我们首先看一下位于private访问区段中的数据成员buf_,它被声明为一个指涉到字符的常量指针(此处仅供演示之需,参见常见错误81)。指针 本身是常量,而指涉到的内容并不是常量,所以型别量化饰词 const跟在指针饰词之后。如果我们把const放在“*”前面,它的意义就变成了以常量性修饰基型别char,这样我们就声明了一个指涉到常量字符的非常量指针。对于常量成员函数length而言,其实一切皆同。如果我们把const放在函数名字前面,那么我们就声明了一个不带任何实参、返回值型别为带常量性的size_t的成员函数。关键词const出现在函数首部后面,指示了带有常量性的是函数本身,而不是其返回值型别。
平凡意义下的语义和运行机制
那么,让一个成员函数带上常量性又是什么意思呢?通常的答案是,一个常量成员函数不会更改其 class对象。这是一种平凡的表述,而编译器实现的手法也相当平凡。
任何非静态成员函数其实都被编译器隐式插入了一个指针型别的实参,以在调用时有一种指涉到class对象自身的途径。在函数内部,关键字this被用以给出该指针的值:
BoundedString bs( 12 );
cout << bs.length(); // "this"就是&bs
BoundedString *bsp = &bs;
cout << bsp->length(); // "this"就是bsp
对于X型别的非常量成员函数而言,其this指针的型别是X * const;亦即,它是一个指涉到非常量 X 对象的常量指针。该指针自身是不能被修改的(是故,它可以保证总是指涉到同一个X对象),但通过它可以修改它指涉的 X 对象的数据成员。在非常量成员函数的辖域内,任何对于非静态成员的访问都是经由该指涉到非常量的常量指针完成的:
void BoundedString::set( char c ) {
for( int i = 0; i < maxLen_; ++i )
buf_[i] = c;
buf_[maxLen_] = '\0';
}
而对于 X 型别的常量成员函数而言,其 this 指针的型别是 const X *const;亦即,它是一个指涉到常量X对象的常量指针。该指针自身和其指涉到的class对象都是不可修改的:
size_t BoundedString::length() const
{ return strlen( buf_ ); }
本质而论,所谓常量成员函数之“常量”,不过就是说调用它时隐式传入的this指针的常量性。举个例子,考虑为BoundedString型别声明一个非成员的判等运算符(equality operator,即operator ==):
bool operator ==( const BoundedString &lhs,
const BoundedString &rhs );
这个函数并不对其实参做任何改动——它仅仅检视其实参——是故,左手边的实参和右手边的实参都理应被声明为指涉到常量的引用。这个原则同样也适用于对等的成员函数:
class BoundedString {
// ...
bool operator <( const BoundedString &rhs );
bool operator >=( const BoundedString &rhs ) const;
};
记住,重载版本的二元运算符函数左手边的实参是以this指针的形式隐式地传给函数的,而右手边的实参则用以初始化其显式声明的形参(在上面两个成员运算符函数中名为rhs)。判断大于或等于的运算符的声明是适当的,因为该声明保证了函数对其左手边和右手边的实参都不会做任何修改。但是,判断小于的运算符有一个不合适的声明,因为它只对其右手边的实参给出了安全性的承诺,而其左手边的实参却并未享受此种庇佑。这种未施之惠可能在我们以最直截了当的手法实现 operator >=时引发大声的抱怨:
bool BoundedString::operator >=( const BoundedString &rhs ) const
{ return !(*this < rhs); }
我们将在调用operator <时收到一个编译器的错误。因为当我们将*this作为第一个隐式的形参的实参传递给operator <时,实际上是试图用一个常量class对象的地址来初始化一个非常量成员函数的this指针。
常量成员函数的深层内涵
我们前面讲了常量成员函数的实现机制,但是常量成员函数的深层内涵在C++资深成员组成的社区中已经进行了更具广度的反思。考虑BoundedString的wipe成员函数的一种实现:
void BoundedString::wipe() const
{ buf_[0] = '\0'; }
这个实现完全合法,但仅仅合法并不意味着它也符合行为规范或结果预期。此wipe函数并不改动其调用时this指针所指涉的class对象,因为它并未改变BoundedString型别的数据成员的值。不过,它更改了class对象以外的数据,而这些数据将反过来影响class对象的行为。BoundedString对象的逻辑状态在调用完wipe函数之后的确变化了 [2],因为this指针的常量性只能约束BoundedString型别的数据成员自身,对于外部数据它可是鞭长莫及,但是外部数据也不能不说是BoundedString对象的逻辑状态的一个组成部分。
BoundedString型别的大多数用户都会对调用完一个“常量”成员函数以后,对象的行为发生了变化这种事情感到既吃惊又恼火。因为函数改变了class对象的逻辑状态,它理应不被声明被 const。这就是为什么我们早些时候的成员函数set并未声明为const,尽管编译器是允许它被声明为const的。
反过来,我们检视一下成员函数 length 的实现。这个函数是极为明白的应该具备常量性的,因为检测BoundedString对象长度信息并不会改变其逻辑状态。最直截了当的实现会采用标准库函数strlen,就像我们上面做的那样。这有可能是最佳实现了,因为它好用、高效,而且结果正确。但是,假如我们经过观察,发现很多字符串从来不取用其长度,而另外一些则不断地反复取用,这种情况下一种不同的实现就有可能是更佳选项:
size_t BoundedString::length() const {
if( len_ < 0 )
len_ = strlen( buf_ );
return len_;
}
这种情况下,我们决定将当前的字符串长度存储在 class对象中,以达成缓式评估求值策略。这么一来,如果字符串从来不取用其长度,几乎就没有什么运行期开销;如果长度被反复取用,则开销会降到最低程度。不幸的是,编译器会将对数据成员len_的赋值标为一个错误,因为常量成员函数不允许对class对象作任何修改。
当然,要搞定这件事,我们只要不把length声明成const就可以了,但它违反了函数的逻辑初衷,而且从此以后就不能为BoundedString型别的常量 class对象检测长度了(参见常见错误6 和常见错误 31)。我们欲去除length之常量性,是出于实现动机,但是,若从实践角度出发,则实现的手法不应该影响抽象数据型别的接口。
一种常见的不良实践是在常量成员函数的辖域内“将常量性通过强制型别转换去除”:
size_t BoundedString::length() const {
if( len_ < 0 )
const_cast<int &>(len_) = strlen( buf_ );
return len_;
}
// ...
BoundedString a(12);
int alen = a.length(); // 可以运作
const BoundedString b(12);
int blen = b.length(); // 未定义行为!
任何在常量对象的构造和析构过程之外修改它的企图都会落入“未定义行为”的魔掌。是故,通过b调用length函数可能会运作——也可能会在代码测试和交付之后很久突然停止运作。这种对于型别转换运算符const_cast的新奇用法帮不上任何忙。
适当的解决方案是将数据成员len_的声明加上mutable饰词。存储类别饰词mutable可以用以修饰非静态、非常量、非引用的数据成员 [3],其效果是指明这些数据成员可以安全地被常量成员函数修改(被非常量成员函数修改也没问题)。
class BoundedString {
// ...
private:
char * const buf_;
mutable int len_;
size_t maxLen_;
};
对于C++软件工程师来说,常量成员函数实现了其从属对象的“逻辑常量性”。换言之,在调用常量成员函数的前后,可见的class对象状态不会改变,即使其物理状态实际上被改变了[4]。
单靠 C++语言本身无法区分强聚合关系(aggregation)或弱聚合关系(acquaintance)。而未能区分就会导致各式各样的软件缺陷,包括内存泄漏和别名问题:
class Employee {
public:
virtual~Employee();
void setRole( Role *newRole );
const Role *getRole() const;
// ...
private:
Role *role_;
// ...
};
单从上面这个接口很难看出Employee对象是拥有一个Role对象呢,还是简单地指涉到它,而与很多其他的Employee对象共享之。如果Employee型别的用户不假思索地断言Employee型别和Role型别的关系是(前者)拥有(后者),而Employee型别的设计工程师却并非如此实现,那就会有麻烦:
Employee *e1 = getMeAnEmployee();
Employee *e2 = getMeAnEmployee();
Role *r = getMeSomethingToDo();
e1->setRole( r );
e2->setRole( r ); // 缺陷1!
delete r; // 缺陷2!
如果Employee型别的设计工程师决定Employee型别和Role型别的关系是(前者)拥有(后者),那么标了“缺陷 1”的那一行的结果就是两个Employee对象在同一个Role对象上形成了别名。最起码,这也会针对Role对象引发多次析构的问题,因为在e1和e2被析构时,它们的析构函数都会在无意中被共享的Role对象身上实施。
标了“缺陷2”的那一行的问题更大。这里,Employee型别的用户断言其成员函数setRole必定做出了一个其Role型别之实参的副本。从而进行了资源分配的 Role对象(r)必须回收其资源。如果实际情况并非如此,那么e1和e2就都持有了一个空悬指针。
C++老手会检视 setRole 函数的实现体,并依据发现的线索而识别出setRole函数采用的是如下的实现之一:
void Employee::setRole( Role *newRole ) // 版本1
{ role_ = newRole; }
void Employee::setRole( Role *newRole ) { // 版本2
delete role_;
role_ = newRole;
}
void Employee::setRole( Role *newRole ) { // 版本3
delete role_;
role_ = newRole->clone();
}
版本1的setRole函数说明Employee对象并不拥有其Role型别的数据成员,因为它在设置指针指涉到一个新的Role对象之前,并未做过任何尝试来清理当下之指涉物(我们假定这段代码体现的就是设计者的初衷,而不是一个浅薄的缺陷)。
版本2的setRole函数说明Employee对象拥有其Role型别的数据成员,而且在运行之后,将拥有权转移至调用它时提供的Role型别的实参。版本3的setRole函数亦说明Employee对象拥有其Role型别的数据成员,不同的是它制作了调用它时提供的Role型别的实参的一个副本(尔后拥有该副本),而不是拿到其实参的所有权就算数。注意,在版本3的情形中,将实参的型别声明为const Role *newRole要优于Role *newRole。
可是,抽象数据型别的用户一般都没有检视其实现的权限,因为这样的权限一旦开放就容易造成数据隐藏的破坏,还容易造成对特定实现的相依性。举例而言,版本 1 的setRole函数未有析构当前指涉的Role型别的数据成员,可这并不一定就代表了Employee型别的设计工程师想要共享其Role型别的数据成员的初衷,这有可能只是个缺陷罢了。无论如何,当巨大数量的用户代码都当它是这种设计初衷的话,它也就不再是个缺陷了——它变成了一个功能点(feature)[5]。
因为直接使用 C++代码来指定拥有权的有无是做不到的,我们只能转而依靠命名习惯、形参的型别以及(是的,在这种特定的情况下)注释:
class Employee {
public:
virtual~Employee();
void adoptRole( Role *newRole ); // 取得拥有权
void shareRole( const Role *sharedRole ); // 无拥有权
void copyRole( const Role *roleToCopy ); // 取得副本的拥有权
const Role *getRole() const;
// ...
};
名字 adoptRole、shareRole和 copyRole已经足够地不同寻常,用户会因此过来看一眼注释。如果注释足够言简意赅,它被甚至有可能会被维护(参见常见错误1)。
一种有关拥有权的沟通障碍源自指针容器。考虑基型别为指针型别的线性表:
gotcha83/ptrlist.h
template <class T> class PtrList;
template <> class PtrList<void> {
// ...
};
template <class T>
class PtrList : private PtrList<void> {
public:
PtrList();
~PtrList();
void append( T *newElem );
// ...
};
这段代码的问题再一次落入“容器型别的设计工程师和用户之间有关拥有权有无可能存在的认识鸿沟”的老套:
PtrList<Employee> staff;
staff.append( new Techie );
上面的代码说明 PtrList模板的用户断言该容器会取得 append成员函数实参指涉物的拥有权,亦即从PtrList具现的型别的析构函数会析构所有其持有的元素的指涉物。如果该容器实际上并没有做这件事,那就会形成内存泄漏。而下面的代码作者则做了个不同的假设:
PtrList<Employee> management;
Manager theBoss;
management.append( &theBoss );
在这种情形下,PtrList 模板的用户断言该容器并不拥有其持有元素之指涉物的拥有权。如果实际情形与此相左,则PtrList模板(具现型别的析构函数)将试图在未经分配的存储上做析构动作。
避免拥有权问题沟通无效的最佳做法就是采用标准库中的容器组件。因为这些容器是C++ 语言标准的一部分,有经验的C++软件工程师都会对其行为了然于心。若是持有指针元素的话,标准库中的容器组件将对指针本身清理妥当,但对其指涉物则毫发不伤:
std::list<Employee *> management;
Manager theBoss;
management.push_back( &theBoss ); // 没问题
如果想要将容器持有元素的指涉物也一并析构,我们有若干种做法。最直截了当的做法就是手工清理:
template <class Container>
void releaseElems( Container &c ) {
typedef typename Container::iterator I;
for( I i = c.begin(); i != c.end(); ++i )
delete *i;
}
// ...
std::list<Employee *> staff;
staff.push_back( new Techie );
// ...
releaseElems( staff ); // 资源清理
不过,手工清理的代码容易漏写,可能是在代码维护的过程中被不小心删除或放错了位置,在可能发生异常的场合就尤其显得脆弱(异常会改变正常的代码执行流)。大多数情况下,使用智能指针取代裸指针是更佳的选项(注意,标准库中的auto_ptr组件由于其乖张的复制语义,而并不适合用做容器持有的元素型别。参见常见错误68)。使用智能指针的一个平凡例子如下:
gotcha83/cptr.h
template <class T>
class Cptr {
public:
Cptr( T *p ) : p_( p ),c_( new long( 1 ) ) {}
~Cptr() { if( !--*c_ ) { delete c_; delete p_; } }
Cptr( const Cptr &init )
: p_( init.p_ ),c_( init.c_ ) { ++*c_; }
Cptr &operator =( const Cptr &rhs ) {
if( this != &rhs ) { [6]
if( !--*c_ ) { delete c_; delete p_; }
p_ = rhs.p_;
++*(c_ = rhs.c_);
}
return *this;
}
T &operator *() const
{ return *p_; }
T *operator ->() const
{ return p_; }
private:
T *p_;
long *c_;
};
上述容器模板的具现型别持有的是智能指针,而非裸指针(参见常见错误24)。当容器开始析构其元素时,智能指针的语义也随之开始析构其指涉物:
std::vector< Cptr<Employee> > staff;
staff.push_back( new Techie );
staff.push_back( new Temp );
staff.push_back( new Consultant ); // 不再需要写显式的清理代码
智能指针的用途还可以延拓到更为复杂多变的情形:
std::list< Cptr<Employee> > expendable;
expendable.push_back( staff[2] );
expendable.push_back( new Temp );
expendable.push_back( staff[1] );
当容器 expendable 迈越其辖域雷池之时,它将正确地对其第二个元素Temp执行清理,然后将其第一个和第三个元素的引用计数减去相应数量,因为它与容器共享这两个指针。而当 staff也最终走出其辖域时,其持有的3个指针元素都将被清理干净。
其实即使不用运算符重载,天下照样太平:
class Stack {
class Complex {
public:
public:
Stack();
Complex( double real = 0.0,double imag = 0.0 );
~Stack();
friend Complex add( const Complex &,const Complex & );
friend Complex div( const Complex &,const Complex & );
friend Complex mul( const Complex &,const Complex & );
// ...
};
// ...
Z = add( add( R,mul( mul( j,omega ),L ) ),
div( 1,mul( j,omega ),C ) ) );
// ...
运算符重载常被讥为“语法糖”,但它为读写代码的过程带来了更多的认同感,并使得设计工程师的意图更轻松地得以传达:
class Complex {
Complex( double real = 0.0,double imag = 0.0 );
friend Complex operator +( const Complex &,const Complex & );
friend Complex operator *( const Complex &,const Complex & );
friend Complex operator /( const Complex &,const Complex & );
// ...
};
// ...
Z = R + j*omega*L + 1/(j*omega*C);
此中序形式表达的交流阻抗计算公式 [7]完全正确,但是前面那个函数形式表达的公式却错了 [8]。但是如果不采用运算符重载,这个错误就很难一望即知。
在扩展已有的代码框架,比如输入/输出流库和STL等方面,运算符重载同样可以大显身手:
ostream &operator <<( ostream &os,const Complex &c )
{ return os << '(' << c.r_ << "," << c.i_ << ')'; }
此类成功的应用常常会冲昏 C++设计新手的头脑,从而将运算符重载的应用范围延拓得过大:
template <typename T>
class Stack {
public:
Stack();
~Stack();
void operator +( const T & ); // 压栈
T &operator *(); // 取栈顶
void operator -(); // 弹出栈顶
operator bool() const; // 判空
// ...
};
// ...
Stack<int> s;
s + 12;
s + 13;
if( s ) {
int a = *s;
-s;
// ...
天才之作吗?不,这只是幼稚可笑的满纸荒唐言。运算符重载存在的意义只是为了使得代码以基本和通用的形式使之更可读,而不是为了给予设计工程师以卖弄的资本。使用运算符重载时必须将代码读者的既成习惯纳入考量,因此,任何富有经验的代码读者对重载的运算符做出的合理假定都要予以尊重。一个适当的栈实现应该采用普适的、非运算符形式的栈操作名来命名其成员函数:
template <typename T>
public:
void push( const T & );
T &top();
void pop();
bool isEmpty() const;
};
// ...
Stack<int> s;
s.push( 12 );
s.push( 13 );
if( !s.isEmpty() ) {
int a = s.top();
s.pop();
// ...
请注意,重载的运算符之意义必须对其全体用户皆不言自明才能算数。即使其意义能被你本人以及75%的同事而言显而易见,而会让另外的 25%对其理解和应用一头雾水,那么该运算符带来的麻烦就会比它解决得多得多。我个人犯过的一次错误使我对这个问题有了清醒的认识。我当时做一个平凡的数组模板的设计:
gotcha05/array.h
template <class T,int n>
class Array {
public:
Array();
explicit Array( const T &val );
Array &operator =( const T &val ); // 对所有人都显而易见吗?
// ...
private:
T a_[n];
};
// ...
Array<float,100> ary( 0 );
ary = 123; // 真的有那么显而易见吗?
我自己是百分之百地对“赋值操作的效果显而易见”一事确信无疑。这不就是把123这个值赋值给数组的每一个元素吗,是不是很明白呀?对于巨大百分比的Array模板的用户而言,这个语句的意义并非这么明白。有些老资格的软件工程师认为该语句的意义是改变数组的尺寸,使之能够持有123个元素。有些则认为其效果就是将其首元素赋值为123。我当然知道只有我的理解是正确的,有其他想法的人统统不靠谱,但是“实用性”的最高指示让我不得不乖乖地为这个操作把名字改回不引发任何歧义的非运算符版本 [9]:
ary.setAll( 123 ); // 乏味,但是明白
除非重载一个运算符比使用非运算符版本明显更好,否则就不要进行运算符重载。
运算符的优先级属于用户对于运算符之行为的期望的一部分,而若是这部分期望落了空,运算符将走上被误用的不归途。考虑一个非标准的复数实现:
class Complex {
public:
Complex( double = 0,double = 0 );
friend Complex operator +( const Complex &,const Complex & );
friend Complex operator *( const Complex &,const Complex & );
friend Complex operator ^( const Complex &,const Complex & );
// ...
};
我们想为该复数型别定义一个幂指运算符,可惜C++语言中并没有这么一个幂指运算符 [10]。因为我们不能向C++语言中引入新的运算符 [11],所以我们作出了将现有的一个运算符拉来充壮丁的决定,该运算符对于复数型别的运算并无内建的定义:这就是异或运算符(exclusive-or)。
实际上我们已经陷入麻烦了,因为资深的C++软件工程师会将形如a^b的运算符正确地理解为将 a与 b作按位异或运算的结果,而不会认为是求 a的b次幂。但是,这里面还有一个隐藏得更深的问题:
a = -1 + e ^ (i*pi);
在数学记法和支持幂指运算符的语言中,幂指运算具有非常高的优先级。是故,这段代码的作者一定很期望上面这个语句中,幂指运算比加法运算有着更高的优先级:
a = -1 + (e ^ (i*pi));
但事实上,编译器可完全没法体会任何人对于幂指运算优先级的期望。从它的视角看去,只有一个异或运算符,然后它正确地做了表达式解析:
a = (-1 + e) ^ (i*pi);
在此情形之下,还是舍弃运算符重载的高招,老老实实地使用平凡的非运算符函数为佳:
a = -1 + pow( e,(i*pi) );
运算符的优先级亦是其接口的组成部分。请保证重载运算符的优先级符合用户的期望。
重载运算符理应允许隐式转换至实参型别的任何可能的型别转换得以实施:
class Complex {
public:
Complex( double re = 0.0,double im = 0.0 );
// ...
};
举例而言,Complex 型别的构造函数允许从内建数值型别转换至 Complex型别。非成员版本的函数允许此种型别转换在其任一实参上执行:
Complex add( const Complex &,const Complex & );
Complex c1,c2;
double d;
add(c1,c2);add(c1,d); // 等价于add( c1,Complex(d,0.0) );
add(d,c1); // 等价于add( Complex(d,0.0),c1 );
非成员版本的operator +运算符函数同样也允许同样的隐式型别转换:
Complex operator +( const Complex &,const Complex & );
c1 + c2;
operator +(c1,c2); // 同上
c1 + d;
operator +(c1,d); // 同上
d + c1;
operator +(d,c1); // 同上
不过,如果我们将Complex型别的二元加法以成员函数的手法实现,那就将引入与隐式型别转换相关的对称性破缺(asymmetry):
class Complex {
public:
// 成员运算符
Complex operator +( const Complex & ) const; // 二元运算符
Complex operator -( const Complex & ) const; // 二元运算符
Complex operator -() const; // 一元运算符
// ...
};
// ...
c1 + c2; // fine.
c1.operator +(c2); // 没问题
c1 + d; // fine.
c1.operator +(d); // 没问题
d + c1; // 错误![12]
d.operator +(c1); // 错误!
这种情况下,编译器无法在成员运算符的第一个实参上施行隐式的、用户自定义的型别转换。如果这种型别转换是期望接口的一部分,那也就暗示着以成员手法实现该二元运算并不合适。非成员友元允许在其第一个实参上施行任何合法的型别转换,而成员函数则只允许进行基于皆然关系的型别转换(参见常见错误42)。
即使是一流的C软件工程师,也会在自增/自减运算符的前置式和后置式皆堪服用时信手拈来,将其任意换用:
int j;
for( j = 0; j < max; j++ ) /* C语言语境中,这样做没问题 */
不过,这种代码在C++语言中已经显得不合时宜。当自增/自减运算符的前置式和后置式都能使用时,前置式总是优于后置式。理由与其实现相关。自增/自减运算符常被重载后用于支持迭代器或智能指针。它们既可以用成员手法实现,也可以用非成员手法实现,但通常都是作为 class型别成员而实现:
class Iter {
public:
Iter &operator ++(); // 前置式
Iter operator ++(int); // 后置式
Iter &operator --(); // 前置式
Iter operator --(int); // 后置式
// ...
};
前置式应该返回一个可修改的左值,以模拟内建运算符的行为。以成效论,这就要求该运算符返回指涉到对象自身的引用:
Iter &Iter::operator ++() {
// 对*this执行自增操作
return *this;
}
// ...
int j = 0;
++++j; // 没问题,不过j+=2也许更好些
Iter i;
++++++++i; // 没问题,但有标新立异之嫌
后置式与前置式以有无一个哑 int 型别的实参相区分。编译器只是简单地传递一个0作为实参来将后置式与前置式区分开来:
Iter i;
++i; // 等价于i.operator ++();
i++; // 等价于i.operator ++(0);
i.operator ++(1024); // 合法,但令人费解
通常意义下,后置式形式的自增/自减运算符会忽略该int型别的实参 [13]。为了模拟内建的后置式形式的自增/自减运算符,其重载版本必须返回一个持有对象自增前之原始值的class对象副本。后置式形式的自增/自减运算符
一般利用其对应前置式版本实现:
Iter Iter::operator ++(int) {
Iter temp( *this );
++*this;
return temp;
}
以成效论,必须要求后置式形式的自增/自减运算符以传值方式返回结果。即使应用了可能的常见代码变换,如具名的返回值优化(参见常见错误58),使用后置式形式的自增/自减运算符在对于class对象而言也仍然极有可能会慢一拍。考虑STL的一个常见应用:
vector<T> v;
// ...
vector<T>::iterator end( v.end() );
for( vector<T>::iterator vi( v.begin() ); vi != end;
vi++ ) { // 笨!
// ...
如果该vector(模板具现)对象的迭代器实现为平凡指针,那么使用后置式形式的自增运算符的运行期开销就不存在;而如果它以 class对象的面目出现,开销就很可观了。是故,任何情况下,在 C++语言中,前置式总是优于后置式。很多泛型算法的实现都对这个建议唯马首是瞻,并且更深入地推进之(推进得稍嫌走火入魔),从而避免为后置式形式的自增/自减运算符付出任何代价:
template <typename In,typename Out>
Out myCopy( In b,In e,Out r ) {
while( b != e ) {
// 不写*r++ = *b++;
*r = *b;
++r;
++r;
++b;
}
return r;
}
注意,内建的后置式形式的自增/自减运算符得到的是个右值。换言之,操作的结果值没有相关联的地址,不可以用在要求左值的操作中(参见常见错误6):
int a = 12;
++a = 10; // 没问题
++++a; // 没问题
a++ = 10; // 错误!
a++++; // 错误!
不幸的是,我们早些时候给出的后置式形式的自增运算符却返回了一个编译器生成的匿名临时 class对象。标准指出,这样的值并非左值,但我们仍然能够调用这么一个 class对象的成员函数,这也就意味着它能够被自增或重新赋值。但这个被自增、重新赋值的临时对象将在表达式的结束点处被析构!
Iter i;
Iter j;
++i = j; // 没问题
i++ = j; // 合法,但实际上应该标为错误!
从j到i的赋值动作并未对i持有的值产生丝毫影响,这个值被赋给了匿名临时class对象,后者在被自增之前可能持有i的原始值。用户自定义版本的后置式形式的自增/自减运算符若要给出一个更安全的实现,则应该返回常量:
class Iter {
public:
Iter &operator ++(); // prefix
const Iter operator ++(int); // postfix
Iter &operator --(); // prefix
const Iter operator --(int); // postfix
// ...
};
// ...
i++ = j; // 不合法!
i++++; // 不合法!
这样做可以阻止大多数的对于后置式形式的自增/自减运算符之返回值的意外误用,但是只能做到防君子不防小人。该返回值的确修改不了,但它仍然有个地址:
const Iter *ip = &i++;
这个聪明过头的软件工程师成功地取得了一个地址,但该地址不属于i,而属于一个编译器生成的匿名临时 class对象,而且在指针被初始化之后该对象旋即被析构。这种恶毒的、蓄意的代码欺诈必被严惩不贷(参见常见错误11)。
上面提过,大多数用户自定义的自增/自减运算符皆以成员函数手法实现。当然,这对于枚举型别的自增/自减运算符并不适用,因为枚举型别不能拥有自己的成员函数:
enum Sin { pride,covetousness,lust,anger,
gluttony,envy,sloth,future_use,num_sins };
inline Sin &operator ++( Sin &s )
{ return s = static_cast<Sin>(s+1); }
inline const Sin operator ++( Sin &s,int ) {
Sin ret( s );
s = ++s;
return ret;
}
请注意,这些函数里并未出现任何范围校验代码。软件工程师选用枚举型别而非更高级的 class型别来表示一个概念,多半是出于效率考量。而任何针对该型别进行的范围校验都将使这个初衷落空。而且,如果这样做还会带来大量非必要的有关结束条件的冗余校验:
for( Sin s = pride; s != num_sins; ++s ) // ...
对于模板成员函数的一个普遍用途是用以实现构造函数。举个例子,很多标准库中的容器组件都会有个模板化的构造函数,这样就允许使用一个元素序列(前闭后开区间)来初始化该容器:
template <Currency currency>
template <typename T>
class Cont {
public:
template <typename In>
Cont( In b,In e );
// ...
};
模板化的构造函数的运用使得容器能够接受任意型别的输入序列,若非如此,容器型别的实用性也将大打折扣。而标准库中的auto_ptr模板组件也使用了模板成员函数:
template <class X>
class auto_ptr {
public:
auto_ptr( auto_ptr & ); // 复制构造函数
template <class Y>
auto_ptr( auto_ptr<Y> & );
auto_ptr &operator =( auto_ptr & ); // 复制赋值运算符
};
template <class Y>
auto_ptr &operator =( auto_ptr<Y> & );
// ...
请注意,auto_ptr 组件除了声明了其模板化的构造函数和赋值运算符之外,还显式地声明了其复制操作。为了取得正确的结果行为,这么做是必须的。因为模板成员函数决不会为完成复制操作而具现。一如既往,只要显式声明的复制构造函数及复制赋值运算符缺失,编译器便会在必要时生成。而对于模板具现时不考虑复制操作这个例外,常常成为偶发问题的来源:
gotcha88/money.h
enum Currency { CAD,DM,USD,Yen };
template <Currency currency>
class Money {
public:
Money( double amt );
template <Currency otherCurrency>
Money( const Money<otherCurrency> & );
template <Currency otherCurrency>
Money &operator =( const Money<otherCurrency> & );
~Money();
double get_amount() const
{ return amt_; }
// ...
private:
Curve *myCurve_;
double amt_;
};
// ...
Money<Yen> acct1( 1000000.00 );
Money<DM> acct2( 123.45 );
Money<Yen> acct3( acct2 ); // 模板化的构造函数
Money<Yen> acct4( acct1 ); // 编译器生成的复制构造函数!
acct3 = acct2; // 模板化的赋值运算符
acct4 = acct1; // 编译器生成的赋值运算符!
其实这只是 C++语言的一个非常老旧的型别设计问题穿了一个新马甲而已。只要 class型别中内含一个指针或其他不能自我管理的资源句柄,对于该型别的复制操作就必须严加控制,以防内存泄漏或别名问题现身。考虑上述模板化赋值运算符的实现片断:
gotcha88/money.h
template <Currency otherCurrency>
Money<currency> &
Money<currency>::operator =( const Money<otherCurrency> &rhs ) {
amt_ = myCurve_->
convert( currency,otherCurrency,rhs.get_amount() );
}
很明显,在Money模板的实现中很关键的一点在于myCurve_指涉到的Curve对象在被赋值期间既不能被修改,也不能被共享。而这(修改——内存泄漏、共享——别名问题)正是编译器生成版本的复制操作干的好事,而且连个招呼都不打:
template <Currency currency>
Money<currency> &
Money<currency>::operator =( const Money<currency> &that ) {
myCurve_ = that.myCurve_;
// Curve对象中发生了内存泄漏、别名问题和意外修改等灾难!
amt_ = rhs.amt ;
}
Money模板本该显式给出其复制操作的实现。
复制操作永不经由模板成员函数完成。做任何型别的设计工作,都要费心于其复制操作(参见常见错误49)。
[1].译者注:名字意为“不可用的栈”。
[2].译者注:作者是想强调物理状态未变化。
[3].译者注:不应该声明引用数据成员,参见常见错误81。
[4].译者注:这最后一节讲了两个问题,先是讲了外部数据如何影响 class对象的行为,再讲了内部数据如何做到对用户隐藏变更,即“状态变化而行为不变化”。由此,更加突显了面向对象的思想实是对象行为相关而非对象状态相关——即使是物理状态。
[5].译者注:在很多大型的软件公司中都会发生这种尴尬的局面,很多软件中本来是缺陷的代码,结果开发者甚至最终用户的使用习惯被长时间扭曲。结果,这些缺陷由于要保证向下的兼容性反而不能修正了,也就顺水推舟地成为了软件的“功能点”。有时这种从设计缺陷演变而成的“功能点”还会成为产品——不一定是软件产品——的狂热拥护者的社区文化的重要组成部分,比如苹果公司的单键鼠标。
[6].译者注:若新添加的并非已有指涉物的指针。
[7].译者注:数学形式为,R是电阻,jωCω = 2πf是角频率,L是电感,C是电容,j是复数单位。
[8].译者注:应为add(R,add( mul (mul(j,omega),L) ),div (1,mul (mul(j,omega),C)))))。
[9].译者注:作者看来教训不浅,其实他在前面提到过这个反例,参见常见错误76。
[10].译者注:Visual Basic中倒是提供了这个幂指运算符,参见(Microsoft Corporation,2007)。
[11].译者注:禁止这样做的技术原因,参见(Stroustrup,2002)。
[12].译者注:并没有double型别的operator +成员。
[13].译者注:有些编译器会因为声明了一个具名实参而不使用之发出警告,是故一般以匿名形式给出该 int 型别的实参如下例所示,即可屏蔽该警告。
继承谱系设计有相当的难度。型别继承谱系必须留足机动性,以备未来的合理延拓。但另一方面又必须保持足够的稳定性,以彰显设计核心。它们必须尽可能地容易理解,但同时又要有效地完成问题领域的抽象。和其他软件组件的设计截然不同的是,继承谱系将在其被软件工程师初次设计、编译和分发之后很久仍会被加以延拓或修改。是故,负责继承谱系设计的软件工程师必须要前瞻性地考虑到向用户开放哪些延拓和定制的权限,并依此实施设计方案。
继承谱系设计需要深入基层,摆平各方面对于设计方案的压力,最终企及一个最优的裁夺,但正如在线性规划中遇到了同样性质的问题一样,同样的设计方案或许会产生许多可能的最优解。有效的继承谱系设计常常就是经验和洞见的结晶,而并非一组硬性规定的推行。如果说对内容的影响,那就是本章中的各个建议相对于前面各章而言有比较大的柔性,主观色彩也稍浓些。
无论如何,继承谱系设计自有继承谱系设计的常见错误。有些是把其他语言的设计实践往 C++语言上生搬硬套所致,其他的则多见于经验不足的设计新手,当然也有的是标新立异却糟糕透顶的主意。这里我们会将它们一一曝光。
当心持有class对象的数组,尤其是持有基类对象的数组。考虑一个“实施器”,用以将某个函数在数组持有的每一个元素上实施:
gotcha89/apply.cpp
void apply( B array[],int length,void (*f)( B & ) ) {
for( int i = 0; i < length; ++i )
f( array[i] );
}
// ...
D *dp = new D[3];
apply( dp,3,somefunc ); // 灾难爆发!
问题在于,传给apply函数的第一个实参是“指涉到B对象的指针”而非“持有B对象的数组”。正如编译器所支持的那样,我们使用了一个D *型别的指针来初始化一个B *型别的指针。若B型别是D型别以public方式继承的基类,这样做完全合法,因为“D对象”皆为“B对象”。但是,“持有D对象的数组”可不是“持有B对象的数组”,上述代码会由于将含有B型别尺寸偏移量信息的指针算术应用到持有D对象的数组而一败涂地。
如图9-1所示,apply函数期望array指针指涉到持有B对象的数组(图左),但实际上它实际上指涉到的是持有D对象的数组(图右)。回忆一下,数组的索引操作只是指针算术的缩写(参见常见错误 7),因 此 array[i]等价于*(array+i)。不幸的是,编译器会基于“array指针指涉到持有 B对象的数组”的前提做指针的加法运算。如果派生对象具有更大的尺寸,或迥异的内存布局,那么索引操作将取得错误的地址。
对此种数组做增量操作会使其以很夸张的方式地停止运作。如果 B 型别作为基类设计为抽象类(一般而言这是个好主意),它就会阻止持有B对象的数组得以成功构造,但是apply函数却仍然运作如仪(尽管结果不对),因为它处理的是指涉到B对象的指针,而非B对象本身。将apply函数的形参声明为指涉到数组的引用是可以运作的(比如可以写成B (&array)[3]),但是不具实践意义,因为我们这么一来就必须指定数组尺寸(这个例子中是3),从而失去了apply函数的普遍性,而且不能传递指针(指涉到一个已分配内存的数组)作为实参(必须使用提领运算符,给用户带来了强迫性的习惯变化)。
总而言之,持有基类对象的数组一般而言是不提倡使用的,而任何持有任意型别的 class对象的数组都得受到严密监视(任何型别都可能在未来成为多态的)。
使用泛型算法来取代为某种特定型别手工编码的函数是一种改进:
for_each( dp,dp+3,somefunc );
标准库中的for_each算法的运用给予了编译器对传递给函数模板的实参做实参型别推导(argument type deduction)的机会。从派生类型别到其以public方式继承的基类型别的隐式型别转换不成其问题,因为根本未进行此种型别转换。编译器将直接用D型别来具现for_each函数模板 [1]。不过,这和我们的设计初衷不符,因为这里使用编译期多态取代了运行时多态。
较佳的解决方案是让数组持有指涉到class对象的指针,而非class对象本身。这么一来,就可以在数组上进行多态应用,而不会引发指针算术相关的错误,因为所有的指针尺寸都一样:
void apply_prime( B *array[],int length,void (*f)( B * ) ) {
for( int i = 0; i < length; ++i )
f( array[i] );
}
通常,更佳的做法是彻底舍弃裸数组,而使用某种标准库中的容器组件,一般选用vector组件。具备强烈型别校验权能的(strongly typed)标准库容器组件之运用,在持有 class对象时将指针算术相关的错误消弭于未现,而在持有指涉到class对象的指针时则允许多态应用:
vector<B> vb; // 不允许放置D对象!
vector<B *> vbp; // 可以进行多态应用
对C++软件工程师而言,STL容器组件应该是容器应用的首选。不过,STL容器组件并不能满足所有的需求,因为它们为了发挥出强大的功能,不得不设置一定的限制。有关STL容器组件的一个有意思的事实是:由于它们都是以模板实现的,其结构和行为诸多方面都是在编译期就决定了的。这种特点带来了短小精悍的实现,在编译期精确地转化成恰好合用的静态执行码。
public:
但是,并不一定在编译期就能准备好所有的相关信息。举个例子,考虑一个简化了的、面向框架的(framework-oriented)结构,它意欲支持“开放性封闭原则”(open-closed principle),亦即它可以被修改、延拓,而不必因此重新编译代码框架。在这样一个平凡的框架内,有一个容器继承谱系,以及一组平行的迭代器继承谱系:
gotcha90/container.h
template <typename T>
};
class Container {
virtual~Container();
virtual Iter<T> *genIter() const = 0; // 工厂方法设计模式
virtual void insert( const T & ) = 0;
// ...
}
template <typename T>
class Iter {
public:
virtual~Iter();
virtual void reset() = 0;
virtual void next() = 0;
virtual bool done() const = 0;
virtual T &get() const = 0;
};
我们可以基于这些抽象类写一些代码,尔后编译之,过一段时间通过添加派生的容器和迭代器型别来延拓它:
gotcha90/main.cpp
template <typename T>
void print( Container<T> &c ) {
auto_ptr< Iter<T> > i( c.genIter() );
for( i->reset(); !i->done(); i->next() )
cout << i->get() << endl;
在设计中使用平行继承谱系通常来讲是有问题的,因为对其中一个继承谱系的修改会要求其他的继承谱系作出对应的变化。我们希望能够将代码更
改集中于一处。无论如何,由于在Container型别中运用了工厂方式设计模式(Factory Method Pattern),多少对我们Container/Iter平行继承谱系的问题起到了一定的缓解作用。
工厂方式设计模式为抽象基类接口的用户提供了一种机制,使他们能够生成适当派生类对象,同时又能对其具体型别保持不知情。具体到 Container型别这个抽象基类,其genIter工厂方法接口的用户实际上是说:“给我生成一个Iter型别的适当派生类对象,但细节就请缄口不言吧。”通常,工厂方法设计模式是病态的基于型别分派的代码的替换方案(参见常见错误96)。换言之,我们永远不会希望写出表达这样意思的代码:“喂,Container(基类),如果你实际上是个Array(派生类对象),那么你就返回给我一个ArrayIter(型别的迭代器),不然,如果你实际上是个Set(派生类对象),那么你就返回给我一个SetIter(型别的迭代器),不然……”
设计可替换的 Container 之动态型别相当容易。Set<T>型别因此对Container<T>型别有可替换性,而惯常的Set<T> *型别对Container<T> *型别的可替换性也依然有效。而在基类 Container 型别中存在着纯虚的genIter工厂方法接口对于具象类的设计工程师而言也是个显式的提示,使之在以Iter为根的继承谱系中做好相应的维护工作:
template <typename T>
SetIter<T> *Set<T>::genIter() const
{ return new SetIter<T>( *this ); } // 写得更漂亮的SetIter
可是,有一种不幸的但又是普遍存在的趋势,就是看到容量持有元素型别的可替换性,就想当然地认为持有这些元素的容器型别亦有可替换性。我们知道这样的可替换性在数组这个 C++ 语言内建的容器上是没有的。持有派生类对象的数组对于持有基类对象的数组并无可替换性(参见常见错误89)。这同样也警示着用户自定义的持有可替换型别元素的容器型别不能如此行事。考虑下面的平凡容器继承谱系,它支持了一种金融证券券价框架:
gotcha90/bondlist.h
class Object
{ public: virtual~Object(); };
class Instrument : public Object
{ public: virtual double pv() const = 0; };
class Bond : public Instrument [2]
{ public: double pv() const; }; [3]
class ObjectList {
public:
void insert( Object * );
Object *get();
// ...
};
class BondList : public ObjectList { // 糟糕的设计!
public:
void insert( Bond *b )
{ ObjectList::insert( b ); }
Bond *get()
{ return static_cast<Bond *>(ObjectList::get()); }
// ...
};
gotcha90/bondlist.cpp
double bondPortfolioPV( BondList &bonds ) {
double sumpv = 0.0;
for( each bond in list ) {
Bond *b = current bond;
sumpv += b->pv();
}
return sumpv;
protected
}
现在,使用一组指涉到Object对象的指针来实现一组指涉到Bond对象的指针并无大碍(尽管更佳的设计是用void *型别的指针实现,而将整个Object型别的概念回溯至低级的裸内存处理手法,参见常见错误97)。错误在于在不可替换的型别上采用了以public方式继承的派生,从而不得不导出皆然关系,而不是以private方式继承的派生或是以组合方式使用之[4]。本质上,我们是将指涉到可替换的基类动态型别的指针以容器包装,与此同时,却以不可替换的形式将其呈现。无论如何,这里可没有我们在使用指涉到指针的指针(或指针数组)时所具有的好运,编译器无能为力,不能为我们提示做下的蠢事(参见常见错误33):
gotcha90/bondlist.cpp
class UnderpaidMinion : public Object {
public:
virtual double pay()
{ /* 向过劳而薪水可怜的人的账户里打一百万美金 */ }
};
void sneaky( ObjectList &list ) [5]
{ list.insert( new UnderpaidMinion ); } [6]
void victimize() { [7]
BondList &blist = getBondList();
sneaky( blist );
bondPortfolioPV( blist ); // 得逞了!
}
这里我们成功地用一个姊妹型别(sibling,意指一组平行继承谱系中各个型别之间的关系)的class对象替换了另一个。我们在券价框架本来期望一个Bond对象的地方插入了一个UnderpaidMinion对象。在大多数的环境中,这么做的结果将是触发一个UnderpaidMinion::pay而非Bond::pv的调用,亦即一个无法检测的运行期错误。正如持有派生类对象的数组对持有基类对象的数组不具有可替换性一样,持有派生类对象或指涉到其的指针的用户自定义容器型别对持有基类对象或指涉到其的指针的用户自定义容器型别亦不具有可替换性。容器型别之间的可替换性,如果需要的话,那么设计时就应该关注其本身的结构,而非其持有元素型别的结构。
型别成员具有何种访问层级,有时取决于立场。打个比方,基类型别中具有 public访问层级的成员,站在其以 private方式继承的派生类的视角看就只有private访问层级:
class Inst {
public:
int units() const
{ return units_; }
// ...
private:
int units_;
// ...
};
class Sbond : private Inst {
// ...
};
// ...
void doUnits() {
Sbond *bp = getNextBond();
Inst *ip = (Inst *)bp; // 旧式强制型别转换在此处是必要的
bp->units(); // 错误!
ip->units(); // 合法
}
这种特别的情形,尽管很有意思,却并不常见。一般情况下,当需要将基类型别的接口暴露给派生类型别时,我们一般采用以public方式继承的手法。以private方式继承几乎仅仅是在只需要继承基类型别的实现(而非接口)时才会采用。从指涉到派生类型别的指针到指涉到基类型别的指针,必须使用强制型别转换,这就是不良设计的明显标志。
说句题外话,请注意使用旧式强制型别转换在指涉到以private方式继承的派生类型别的指针到指涉到基类型别的指针的型别转换中的必要性。一般地,我们会认为使用较安全的static_cast运算符是较佳选项,然而这里我们却不能使用它。使用static_cast运算符不能将派生类型别转换至访问层级受限的基类型别。不幸的是,使用旧式强制型别转换将抑制掉Sbond型别和Inst型别的关系在未来发生变化时可能发生的错误(参见常见错误40和常见错误41)。我的个人观点是根本就不应该使用那个强制型别转换,整个继承谱系应该重新设计。
所以,我们给予基类型别一个虚析构函数,给予其访问器函数 protected访问层级,尔后派生一些适当的、可替换的classes:
class Inst {
public:
virtual~Inst();
// ...
protected:
int units() const
{ return units_; }
private:
int units_;
};
class Bond : public Inst {
public:
double notional() const[8]
{ return units() * faceval_; }
// ...
private:
double faceval_; [9]
};
class Equity : public Inst { [10]
public:
double notional() const
{ return units() * shareval_; }
bool compare( Bond * ) const;
// ...
private:
double shareval_; [11]
};
基类型别中用以返回金融证券数量的成员函数(units),现在有了protected 访问层级,意指其意图给派生类型别应用。无论是债券还是股票,其名义价值的计算都要取得这个信息才能完成。
人心不古,现如今,将一支债券和一支股票作比较乃是家常便饭,所以Equity型别就声明了一个compare成员函数来完成这个工作:
bool Equity::compare( Bond *bp ) const {
int bunits = bp->units(); // 错误!
return units() < bunits;
}
许多软件工程师会吃惊地发现第一个访问具有protected访问层级的成员函数units的企图会遭遇一个“越级访问”错误。出现这种情况的原因是因为这个访问由Equity型别的成员函数发出,但请求的却是访问一个Bond型别的成员函数。对于非静态成员而言,protected 访问层级不仅要求发出请求访问的函数需要是派生类型别的成员或友元,并且还要求作为访问目标的class对象与发出请求访问的函数有相同的型别(或等价地,是该型别以 public方式继承的派生类对象亦可),或是该型别授予了访问权限的非成员友元。
在上述情况里,Equity型别的成员或友元不能被信任而取得Bond对象视角中对于units操作的正确解释。基类Inst型别的确为其派生类准备了成员函数 units,但它也将其解释权下放到了每一个派生类型别自身。在上述compare成员函数所处的情况下,如果仅知道每种金融证券的数量,而不知道派生类特有的(而且是只有protected访问层级的)信息——此处指债券面值和股价——的话,比较操作就很难说有什么意义在。这种针对 protected访问层级的额外访问层级校验机制有着解除派生类型别之间的耦合的益处。尝试以传递指涉到Bond型别的指针作为指涉到Inst型别的指针的实参来解决此问题是毫无助益的:
bool Equity::compare( Inst *ip ) const {
int bunits = ip->units(); // 错误!
return units() < bunits;
}
对于继承而来的具有protected访问层级的成员的访问被严格地限制在派生类型别(包括从此派生类再次以public方式继承的派生类型别)的class对象之成员函数范围的调用中。如果真有必要调用像compare这样的函数的话,最佳的替换方案是将其在继承谱系中的位置提升,这样它的存在不会给既有的派生类型别带来更大的耦合:
bool Inst::unitCompare( const Inst *ip ) const
{ return units() < ip->units(); }
如果这样做不可行,而你又不在乎给Equity型别和Bond型别来上那么一点耦合的话(你本来应当在乎才是),共有友元(mutual friend)也可以充当此任:
class Bond : public Inst {
public:
friend bool compare( const Equity *,const Bond * );
// ...
};
class Equity : public Inst {
public:
friend bool compare( const Equity *,const Bond * );
// ...
};
bool compare( const Equity *eq,const Bond *bond )
{ return eq->units() < bond->units(); }
继承机制以两种方式赞助代码复用。首先,它允许不同的派生类型别中共享的实现代码被抽出来放到同一个基类型别中去。其次,它允许基类型别的接口被所有public方式继承的派生类共享。代码共享和接口共享都是继承谱系设计的可取目标,不过两者之中接口共享才是更加重要的。
只是主要地为了在派生类型别中复用基类型别中的实现代码而选择以public方式继承,常常会造成不自然的、难以维护的,说到底是不够地道的设计。原因在于若以代码复用作为以public方式继承的缘起会限制基类型别接口的延拓,从而想要设计具备可替换性的派生类型别就会困难有加。这么一来,为基类型别的“规约”而撰写的泛型代码能被派生类型别利用的程度也就大大受限。典型地,大部分代码复用是通过利用大规模的泛型代码复用而非基类型别中的代码共享加以实现的。
利用为基类型别规约而撰写的泛型代码,其应用是如此的普遍,以至于为实现这一点而专门在继承谱系中设计一个接口类作为根基类型别常常很有意义。所谓“接口类”就是没有数据成员、析构函数声明为虚函数、所有的普通成员函数皆为纯虚函数、不声明构造函数的基类型别。接口类有时也被称为“协议类”(protocol class),因其实际上在未提及任何实现层面的时机为如何应用其所在的继承谱系指定了某种协议(“杂化接口类”——mix-in 与接口类有些类似,但杂化接口类可能含有最低限度的必要数据和实现)。
使用接口类作为根基类型别简化了未来对继承谱系的维护,省却了应用大把的设计模式如装饰器 [12]、复合型别(Composite Pattern)和代理型别(Proxy Pattern)等(接口类的应用同样缓解了虚基类相关的技术困境,参见常见错误53)。
接口类的经典应用实例是采用调度器设计模式(Command Pattern)以实现抽象回调效用的继承谱系。举个例子,我们可能有个图形用户界面,以Button型别代表其按钮概念,当按钮被按下时则触发一个Action型别代表其动作序列概念的回调:
gotcha92/button.h
class Action {
public:
virtual~Action();
virtual void operator ()() = 0;
virtual Action *clone() const = 0;
};
class Button {
public:
Button( const char *label );
~Button();
void press() const;
void setAction( const Action * );
private:
string label_;
Action *action_;
};
调度器设计模式将操作封装到class对象中,是故,所有class对象能够享用的好处都能够为操作所利用。尤其是我们将在下面看到的,调度器设计模式的运用将使我们能够在设计中组合其他的设计模式。
请注意在Action型别的实现中有一个重载的operator ()。其实我们也可以用一个非运算符版本的成员函数,比如execute什么的。但是在C++语言中使用重载的函数调用运算符 operator ()是一种编码实践的习惯用法,它说明Action型别意欲模塑函数的抽象,正如若是某个型别重载了operator ->,则其class对象意欲被用作“智能指针”一样(参见常见错误24和常见错误83)。Action型别同时还使用了原型设计模式,它声明了成员函数clone,用以复制一个Action型别(或其派生类型别)的class对象,而不必知道其具体型别(参见常见错误76)。
我们第一个从 Action 型别派生的具象类型别使用了空件设计模式(Null Object Pattern)以构建出无所事事的Action派生类对象,并且满足所有的Action型别规约。NullAction型别对Action型别有皆然性:
gotcha92/button.h
class NullAction : public Action {
public:
void operator ()()
{}
NullAction *clone() const
{ return new NullAction; }
};
在Action代码框架既存的前提下,写出安全而灵活的Button型别的实现可谓举手之劳。空件设计模式的运用保证了当Button型别的press成员函数被触发时(意即按钮被按下时)总会做些什么,尽管有的时候“做些什么”意味着“啥也不做”(参见常见错误96)。
gotcha92/button.cpp
Button::Button( const char *label )
: label_( label ),action_( new NullAction ) {}
void Button::press() const
{ (*action_)(); }
实例 [13]原型设计模式的运用使得Button型别能够拥有自己的一个Action型别(或其派生类型别)的class对象的副本,而对其具体型别保持一无所知:
gotcha92/button.cpp
void Button::setAction( const Action *action )
{ delete action_; action_ = action->clone(); }
这就是我们Button/Action框架的基础结构,而且如图9-2所示,我们可以向其中添加其他的具象操作型别(亦即,与 NullAction 不同,还是有所作为的)而不必重新编译此框架。
在以Action型别为根的继承谱系中存在一个接口类,这使我们得以进一步地增强该继承谱系的功能。比如,我们可以使用复合设计模式来通过Button型别相关的事件触发一系列Action型别所代表的抽象操作:
gotcha92/moreactions.h
class Macro : public Action { [14]
public:
void add( const Action *a )
{ a_.push_back( a->clone() ); }
void operator ()() {
for( I i(a_.begin()); i != a_.end(); ++i )
(**i)();
}
Macro *clone() const {
Macro *m = new Macro;
for( CI i(a_.begin()); i != a_.end(); ++i )
m->add((*i).operator ->());
return m;
}
private:
typedef list< Cptr<Action> > C;
typedef C::iterator I;
typedef C::const_iterator CI;
C a_;
};
在以Action型别为根的继承谱系中存在一个轻量级的(lightweight)接口类,使得我们能够应用空件设计模式和复合设计模式,如图9-3所示。如果Action型别作为基类在其中放置了任何可观测的实现代码,那么就会迫使所有的派生类型别继承之,而且还必须承受所有因实现继承而给构造和析构过程带来的副作用。这样做的一个直接后果就是使得应用单件、复合
public
或其他常用设计模式的可能性化为乌有。
但是,设计工程师会经常在接口类提供的灵活性和相对具象的基类型别提供的代码复用程度及总体更优的性能之间举棋不定。打个比方说,可能很多从Action型别派生的具象类型别中都有着重复的实现码,而本来可以将这些实现码放入Action型别作为基类的实现中。不过,这么一来,我们就要牺牲掉一些给继承谱系添砖加瓦的能力,一如我们上面应用复合设计模式时所做的那些改进。如果复用代码的需求诚属情有可原,我们可以允许引入一个手工打造的、纯以代码复用为目标的额外基类(ActionShr),从而在两种方案之间各取所长,如图9-4所示 [15]。
无论如何,滥用这种引入一个手工打造的、纯以代码复用为目标的额外基类的手法将给继承谱系带来过多的额外基类,其中有许多会与问题域中的实际模型毫不相干,而仅仅是为了软件工程师的方便,从而变得难以理解和维护。
总而言之,应该集中精力于接口继承。至于适当的、高效的代码复用,则会水到渠成。
从设计的视角来看,以public方式继承的基类一般应该是抽象类,因之代表着问题域中的抽象概念。我们既不需要也不希望形而上的概念充斥我们的生活空间(打个比方,形而上的雇员、水果和输入/输出设计看起来是什么样子),我们同样也不希望只有一堆抽象接口实现出class对象来充斥我们的代码空间。
在C++语言中,我们自有对于实现的可行性考量。尤其是对于截切以及与之相关的问题,比如复制操作的实现,我们更是关心备至(参见常见错误30、常见错误49和常见错误65)。总而言之,以public方式继承的基类型别一般应该是抽象的 [16]。
设计基类型别和独立式(standalone)型别时,软件工程师要考虑的东西大不相同,而客户代码对待基类型别和独立式型别的处理手法也有天壤之别。是故,在设计型别之前先考虑清楚欲设计的是何种型别属于明智之举。
在软件编码的早期就识别出某个型别有发展成为基类型别的潜质,并将其变换成平凡的、由两个型别组成的继承谱系,这是一种“在未来时态下编码”的范例。因其迫使该继承谱系之用户遵循抽象接口的规约,并且简化了未来对于该继承谱系的功能延拓。如果未能如此行事,而是搞了一个具象类在先,之后再要发展派生类,我们自己,或是我们的用户只能被迫重写既有的框架代码。这样的平凡继承谱系可能应该被命名为“继承谱系的退化形式”(可能有些人会对这个名字感觉别扭,但我们是在数学意义上,而不是道德意义上使用术语“退化”的)。
若是独立式型别变身而成为基类型别,这会对既有的代码造成灾难性破坏。独立式型别常常被实现为带有“值语义”(value semantic),亦即,它们被设计为按值复制(copy by value)提供方便,而用户则被鼓励以传值方式传递这些型别的实参,以传值方式返回这些对象,或是在这些对象之间赋值 [17]。
若是这样的型别成了基类型别,所有复制操作都有了被截切的潜在危险(参见常见错误 30)。独立式型别同样可能鼓励用户使用数组来持有其 class对象,然后就会导向指针算术相关的问题(参见常见错误89)。如果在假定class 对象有着固定的尺寸和行为的前提下写下了某些泛型代码,那肯定会遭遇更多的龌龊问题。如果某个型别可能成为基类型别,那么,请从一开始就把它写成抽象基类。
相反,许多或者不如说大多数型别永远都不会成为基类,那就不应该走这条路。理所当然,微型的、效率为先的型别是不应该设计为继承谱系的。其他常见的、不应纳入继承谱系的型别包括抽象数字型别、日期型别、字符串型别,诸如此类。身为设计工程师,要靠我们的经验、理性和洞见的张力,方能运筹帷幄。
class Bond {
过广或过深的继承往往是不良设计的表现。经常地,这种继承谱系的出现是其中的型别职责划分不清造成的。考虑一个平凡的表示形状的继承谱系,如图9-5所示。
public:
// ...
一开始,这些形状显示出来全是蓝色的。结果,一个刚刚入行的、初次和这个继承谱系打交道的 C++新手被派来延拓这个表示形状的继承谱系,以使形状可以显示以红色或者蓝色来显示。这没问题。
如图9-6所示,我们遭遇了经典“指数式”膨胀的继承谱系。每向其中添加一种颜色,我们就要为所有的形状添加一种新的派生类型别。而每向其中添加一种新的形状,我们就要为其添加所有的颜色相关的派生类型别。这样做很蠢,而且改正的手法很明显:我们应该采用复合设计模式,其结果如图9-7所示。
Square型别对Shape型别有皆然性,而Shape型别复合了(has-a)Color型别。并非所有继承的滥用实例都错得这么明显。考虑一个用以表现金融期权的继承谱系,该继承谱系中使用了各种金融工具来表示期权组合,如图9-8所示。
这里,我们采用单个的期权基类型别,所有的具象类型别都是期权类别和其运用的金融工具的组合。我们再一次地遭遇了过度膨胀的继承谱系,因为我们只要增加一种新的期权类别或是金融工具,就要在这个表现金融期权的继承谱系中添加诸多型别。典型地,正确设计是将数个平凡的继承谱系采用复合设计模式加以组合,如图 9-9所示,这大大好过使用单个的、一损俱损的笨重结构。
};
Option型别复合了Instrument型别。以上这些糟糕的继承谱系其实是领域分析不足的伴生品,但是有时看上去完全正确的领域分析却还是会一败涂地。仍以金融工具为例,让我们看一个去繁就简的债券型别的实现:
// ...
Money pv() const; // 计算现值
};
Bond 型别的成员函数 pv 用以计算债券的现值。无论如何,要实施此种计算可能有多种算法。而处理这种情况的一种手法是把所有的可能算法集中到一个函数里,然后根据某个特征值选择其中的一种算法:
class Bond {
public:
// ...
Money pv() const;
enum Model { Official,My,Their };
void setModel( Model );
private:
// ...
Model model_;
};
Money Bond::pv() const {
Money result;
switch( model_ ) {
case Official:
// ...
return result;
case My:
// ...
return result;
case Their:
// ...
return result;
}
}
但是,这么一种实现使得添加新的券价模型殊为困难,因为它要求源代码变更和重新编译。那么,面向对象的设计原则不是要求我们使用继承和动态绑定吗来实现行为定制吗?于是,我们就依此实现,如图9-10所示。
遗憾的是,这种实现手法只能在派生于Bond型别的具象类对象被构造时将pv成员函数的行为固定下来,但之后就无法更改。犹有进者,Bond型别之实现的其他部分可能会发生独立于pv成员函数的行为的其他变化。而这么一来,又回到组合爆炸的老路上去了。
举个例子来说,Bond型别可能有另一个成员函数,用以计算债券价值的波幅。如果该算法并不关心当前的债券价格,那么只要添加新的券值算法和波幅算法,就得在继承谱系中添加每一种代表该券值/波幅算法组合的新派生类型别。一般地,我们使用继承手法来做整个 class对象的行为定制,而非仅对其某种操作做行为定制。
一如我们先前对五颜六色的形状们所做的,正确的做法是采用复合设计模式。特别地,在这里我们采用策略设计模式(Strategy Pattern)来为臃肿的、以Bond型别为根的继承谱系减肥,如图9-11所示。
策略设计模式将算法的实现部分从函数体中移到了一个隔离的实现继承谱系中:
class PVModel { // 现值策略
public:
virtual~PVModel();
virtual Money pv( const Bond * ) = 0;
};
class VModel { / 波幅策略
public:
virtual~VModel();
virtual double volatility( const Bond * ) = 0;
class Bond {
// ...
Money pv() const
{ return pvmodel_->pv( this ); }
double volatility() const
{ return vmodel_->volatility( this ); }
void adoptPVModel( PVModel *m ) [18]
{ delete pvmodel_; pvmodel_ = m; }
void adoptVModel( VModel *m ) [19]
{ delete vmodel_; vmodel_ = m; }
private:
PVModel *pvmodel_;
VModel *vmodel_;
};
经由策略设计模式,我们不仅简化了以Bond型别为根的继承谱系,同时还获得了在运行期轻松替换pv和volatility成员函数之行为的灵活性。
面向对象阵营中没有依型别分派主义者的容身之地:
void process( Employee *e ) {
switch( e->type() ) { // 邪恶的代码!
case SALARY: fireSalary( e ); break;
case HOURLY: fireHourly( e ); break;
case TEMP: fireTemp( e ); break;
default: throw UnknownEmployeeType();
}
}
还是多态手法来得靠谱:
void process( Employee *e )
{ e->fire(); }
多态的好处简直不胜枚举。它更容易理解。它即使添加新职员型别也不必重新编译代码。它不会造成型别相关的运行期错误。它生成的目标代码短小精悍。请采用动态绑定手法实现型别相依的需求,而不要采用条件式的分派(参见常见错误69、常见错误90和常见错误98)。
用多态来替换条件式代码的手法是如此地有效,以至于即使原来是纯条件式代码,也要把它归约成型别分派然后采用这种替换手法以解决问题。考虑一段平凡的条件式代码,用以处理(调用process成员函数)窗口组件(Widget对象)。Widget型别的公开接口中有个process成员函数,但是,根据class对象所在的内存位置的不同,在调用该函数前必须做些额外的准备工作:
if( w位于本地内存 )
w->process();
else if( w位于共享内存 )
采用令人毛骨悚然的手法处理
else if( w位于远程 )
采用更加令人发指的手法处理
else
error()
这样的条件式代码不仅有可能会漏检情况从而无法实施(“我倒是想‘处理’这个‘窗口组件’,但我从没见过它所在的内存位置!”),并且还会在整个源代码中被到处复制。只要Widget对象可能出现的内存位置种类有了增加或删减,这些散布各处、互不相干的条件式代码必须依此同步更新。更好的解决方案是将内存位置信息编码成Widget型别信息的一部分,如图9-12所示。
这种解决方案之应用是如此普遍,以至于得而名之。这是代理设计模式(Proxy Pattern)的一个应用实例。根据内存位置的不同而采取相应Widget对象之访问机制分派现在被编码成Widget型别信息的一部分,而只需一个虚函数调用即可实现此种分派。犹有进者,此代码不必将分派算法到处散布,虚函数的调用不会漏掉任何一种当前实行的分派机制:
Widget *w = getNextWidget();
w->process();
避免条件式代码的另一个大优点是如此显而易见,以至于经常被人视而不见:避免做出错误选择的不二法门就是不做任何选择。简而言之,在代码中愈少出现条件式代码,就愈少犯有关条件式分派的错误。
空件设计模式亦可看作代理设计模式的一种特殊形式。考虑一个函数,它返回一种必须加以“处理”(调用handle成员函数)的“设施”(Device对象):
class Device {
public:
virtual~Device();
virtual void handle() = 0;
};
// ...
Device *getDevice();
Device型别是个抽象类,代表若干不同种类的设施。而getDevice亦有可能无法取得Device对象,是故,我们写下了如下代码来尝试取得并处理一个Device对象:
if( Device *curDevice = getDevice() )
curDevice->handle();
这段小小的代码实际上体现了我们做的一个决定。在这种情况下,我们有可能会担心,在未来维护工程师会不会忘记在处理设施前,忘记校验getDevice的返回值。
而空件设计模式则提示我们去人为地发明一种Device型别的派生类,它满足所有 Device型别约束(亦即,它所代表的设施抽象是可以被处理的),但它不做任何事。本质上说,它以完全合法的方式空转:
class NullDevice : public Device {
public:
void handle() {}
};
// ...
Device &getDevice();
现在getDevice不会取不到返回值,我们因而得以移除部分条件式代码,还免除了潜在缺陷之累:
getDevice().handle();
十多年前,C++开发者社区得出结论,“单根”形式的继承谱系(就是所有的型别皆从一个根型别派生,该型别通常名为Object)在C++语言中并非有效设计。有很多理由能够用以反对这种尝试,既有设计层面的,也有实现层面的 [20]。
以设计工程师的视角看,单根谱系常常为内含“泛型对象”的容器型别应用推波助澜。这些容器持有之物会发展成何种形态、表现出何种行为殊难定论。Bjarne Stroustrup提出过一个经典的反例,那就是将一队战舰塞入铅笔帽中——此为单根谱系所欣然准许,但可能会给铅笔帽的主人带来莫名惊诧。
设计新手中普遍存在但又十分危险的错误思想是“一个体系结构应该愈灵活愈好”。错!软件体系结构应该尽可能与问题领域相符,仅为未来合理延拓之考量而留出适当余地。当“软件熵”急剧增加,新的需求已经难以加入到既有的结构时,那也就该重构整个设计了。企图先验地建立一个具有最大灵活性的软件体系结构,犹如不加以性能剖析就欲写出最高性能的代码。其结果就是软件体系结构将大而无用,撰写的代码也将捉襟见肘(参见常见错误72)。
这种对于体系结构的误解,再加上不愿花大功夫做好一个复杂问题之抽象的惰性,其结果就经常是把单根谱系拿出来敷衍了事:
class Object {
public:
Object( void *,const type_info & );
virtual~Object();
const type_info &type();
void *object();
// ...
};
这个设计工程师完全将理解问题领域、模塑适当抽象的职业要求抛在了脑后,他只是浅浅地包了一层“概念”,而这个“概念”能够套用在任何其他完全不相干的型别上。任何型别的对象都能包装成一个Object对象,而我们可以构造一个容器,持有Object对象,然后将任何东西都往里面塞(的确,我们经常会这么干)。
设计工程师可能还会提供某种方法,实施一个型别安全的从Object型别到其包装的class对象型别的转换:
template <class T>
T *dynamicCast( Object *o ) {
if( o && o->type() == typeid(T) )
return reinterpret_cast<T *>(o->object());
return 0;
}
乍看之下,这种手法还不赖(除了有点儿有碍观瞻之外),但是想想如果我们需要从一个什么东西都能放的容器里往外拿东西的话会发生什么:
void process( list<Object *> &cup ) {
typedef list<Object *>::iterator I;
for( I i(cup.begin()); i != cup.end(); ++i ) {
if( Pencil *p =
dynamicCast<Pencil>(*i) )
p->write();
else if( Battleship *b =
dynamicCast<Battleship>(*i) )
b->anchorsAweigh();
else
throw InTheTowel();
}
}
这个单根谱系的所有用户都要被迫参加一个很傻很天真的“真心话大冒险”节目,它所做的不过是把其所持有之 class对象的型别加以恢复——而这些型别信息根本一开始就不应该遗失。换言之,如果一个铅笔帽装不下战舰,这并不能反映铅笔帽的任何设计缺陷。而代码的其他的部分中,如果其他应该能放入的一个铅笔帽的东西放不进去的话,这就能够反映出铅笔帽的任何设计缺陷了。但是允许把战舰向一个铅笔帽里塞的这种灵活性,在应用领域中并无任何对应概念,因而这并非我们应该鼓励或听之任之的代码。若在局部设计中出现了对于单根谱系的需求,往往说明在其他地方有了什么设计师未能尽虑之处。
由于有关铅笔帽和战舰的设计抽象是真实世界的简化模型(不管“真实”在这里是什么意思),对应于真实世界的情形是怎样的,还是值得一想。想像一下,如果你是个(物理)铅笔帽的设计师,有一天你突然收到客诉说他的战舰塞不进你的铅笔帽,你是会更改你对于铅笔帽的设计呢,还是会给他提供某种其他形式的帮助?[21]
这种设计上的失职流毒甚广。任何持有Object对象之容器的应用都有带来无数型别相关错误的潜在危险。任何变更了以 Object 型别包装的 class对象之型别的动作,都会引发任意数量的代码维护需求,并且维护过的代码指不定哪天又会不能用了。最后,由于根本未能提供有效的体系结构,所有持有Object对象之容器的用户都面临着“如何从这些无名游魂般的对象身上提取所需信息”的挠头问题。
每当这样的设计出现,检测和报告错误的途径将变得五花八门而且互不兼容。比如说,某个用户可能觉得老是进行“喂,是铅笔吗?不对?那你是战舰吗?也不对?”这样的问答不是个了局,就转而开始走上了权能查询(capability-query)之路。而这么做的下场也是半斤八两(参见常见错误99)。
通常,设计中存在不适当的单根谱系这个事实并不像我们在上面举的例子中的那样一望即知。考虑一个表现资产的继承谱系,如图9-13所示。
这个以 Asset为根的继承谱系究竟是不是过度泛化了,并不能一望即知,尤其在这种高阶的设计略图中。经常地,设计中的取舍是否得当,必须等到相当数量的细化设计甚至编码尘埃落定才能水落石出。如果继承谱系过度泛化,将编码导向了一些声名狼藉的实践(参见常见错误 98 和常见错误99),那多半说明设计中存在着单根谱系,需要通过重构将其消除。否则,该继承谱系的泛化程度可能还在可以接受的范围之内的。
有时,搞活一下我们的死脑筋,设计就能得到有效的改进,甚至源代码都可以保持不动。与单根谱系相关的绝大多数问题,其罪魁祸首都是过度泛化的基类型别。如果我们来个概念重整(reconceptulization),将基类型别视为接口类,然后将这个重整后的概念以新的设计图展示给用户,如图 9-14所示,我们就可以避免前述的糟糕代码实践。
我们重整后的设计不再是一个单根谱系,而是 3 个独立的继承谱系,代表着其接口所对应的子系统。这无非是一个概念方面的改进,但很关键。现在雇员、车辆和合同可以被各个资产子系统作为资产做各种操作,但是作为资产子系统本身而言,因其对从Asset型别派生的具象类型别并不知情,它也不会暴露其操作的 class对象的更精确型别信息。同样的情况也适用于其他的接口类(亦可视为子系统),是故,在运行期出现型别相关的错误的概率就微乎其微了。
本条款中,我们将关注一个在面向对象的设计中经常被滥用的语言权能:运行期型别信息(runtime type information,RTTI)。C++语言中内建地标准化了在运行期查询型别信息的行为,并且悄然使之高效运作。但是,一方面在运行期的确可以合理查询型别信息,但另一方面,RTTI的适当应用场合其实并不多见,而且它们几乎永远都不应该用作某种设计的底层工具。说惭愧也真够惭愧,许多 C++社区经年累月积攒起来的有关如何做出良好、有效的继承谱系的智慧经常因为其采用了危如累卵、过度泛化、佶屈聱牙、难以维护、漏洞百出的RTTI手法来实现,而不得不忍痛割爱。
考虑下面这个表示雇员的脆弱基类型别。有时,一个规模相当可观的子系统在开发和测试完毕以后,必须为其加入一些功能点。比如,下面这个基类型别Employee的接口一看就知道少了些什么:
class Employee {
public:
Employee( const Name &name,const Address &address );
virtual~Employee();
void adoptRole( Role *newRole );
const Role *getRole( int ) const;
// ...
};
对,我们需要精兵简政的能力才是(当然了,解雇员工时得付一笔补偿金,class但那个功能的实现可以等下个新版本出来了再说)。管理层要求我们为软件增加一个解雇雇员的功能,只通过指涉到Employee基类型别的指针就要搞定,而且不能要求重新编译或是改动整个以 Employee 型别为根的继承谱系。显然,正式工和按小时领薪水的计时工解雇的方式有所不同:
void terminate( Employee * );
void terminate( SalaryEmployee * );
void terminate( HourlyEmployee * );
最直奔主题来实现这个想法的办法就是蛮干。我们直接遍历一张表,询问雇员的种类:
void terminate( Employee *e ) {
if( HourlyEmployee *h = dynamic_cast<HourlyEmployee *>(e) )
terminate( h );
else if( SalaryEmployee *s = dynamic_cast<SalaryEmployee *>(e) )
terminate( s );
else
throw UnknownEmployeeType( e );
}
从效率实务和运行期潜在风险的视角来看,此实现手法显然问题良多。总体而言,C++语言是建立在静态型别的基础上的,其动态绑定机制(即虚函数)也仍然是静态校验的,那也就是说,我们应该可以完全规避此类型别的运行期错误才是(比如传入一个完全不是从Employee型别派生的具象类型别的实参,此错误应该可以在编译期而非运行期捕获)。这也就足够说服设计工程师,上面的terminate函数的实现只能解燃眉之急,而不宜作为可扩展的长久之计。
如果把这个设计还原成其欲模塑的问题领域的真实场景,其低劣不堪就更昭昭然了:
负责窗口组件设计的副总裁“砰”地推门进入她的办公室,盛怒溢于言表。因为本月内她的尊享车位已经第三次被她上个月雇请的临时软件工程师的破旧座驾鸠占鹊巢。“给我把Dewhurst马上叫来!”她在内线电话中吼到。
过了一会儿,她用能直透人心的冷峻眼光和掷地有声的低沉噪门向这个倒霉蛋工程师宣判道:“如果你是个按小时领钱的计时工(if( HourlyEmployee *h=dynamic_cast<HourlyEmployee *>(e))),你就依照按小时领钱的计时工的方式被解雇(terminate(h));如果你是个正经被雇用的全职雇员(if( SalaryEmployee *s= dynamic_cast<SalaryEmployee *>(e) )),你就依照正式工的方式被解雇(terminate( s ));否则,你就从我眼前消失,让其他什么人来撵你滚蛋吧(throw UnknownEmployeeType( e ))”[22]。
我是一个咨询师(consultant),所以,若是管理层倚仗RTTI做事,那我就永远不必担心会丢了饭碗。而说到正确的解决方案,当然,是把适当的操作置入Employee基类型别,并采用标准的、型别安全的、基于动态绑定的手法来解决运行期的型别分派问题:
class Employee {
public:
Employee( const Name &name,const Address &address );
virtual~Employee();
void adoptRole( Role *newRole );
const Role *getRole( int ) const;
virtual bool isPayday() const = 0;
virtual void pay() = 0;
virtual void terminate() = 0;
// ...
};
……她用能直透人心的冷峻眼光和掷地有声的低沉噪门向这个倒霉蛋工程师宣判道:“你可以卷铺盖走人了!”
有时,RTTI是必要的,或是优于其他的设计选择的。正如我们所见,在面对设计不尽如人意的第三方库时,RTTI就是一种方便的临时解决方案。如果既有的源代码必须变更以满足业务需求、重新编译又不现实、但原始的代码又不符合面向对象的基本要求,此时别无它法,仍然只能请RTTI出山。RTTI在解决一些特殊的问题领域如调试器、浏览器之类时,亦有一些罕见的、零星的趁手应用。最后,如果问题领域有着内廪的非正交模型,那这种先天不足很可能就会以RTTI的形式彰显。
由于RTTI 在C++语言中属于被标准化的部分,这就导致了很多设计工程师放着简易、高效、易改的设计手法不用而转用RTTI工具。但我们要记住,RTTI的用武之地实际上在于挽救既有的糟糕体系结构,这些糟糕体系结构的来源是纠结的蛮干、肤浅的分析以及其他设计工程师由于失职而未能提供的灵活性。
实践上,我们很少向class对象打探其具体型别方面的隐私。
事实上,上条中讨论的所谓在 terminate函数中运用了 RTTI的明显设计问题,通常是软件工程师走投无路时的蛮干手法,以及不良的项目管理的后果,真正设计方面的问题倒还在其次。不过,有些动态强制型别转换和多继承臭味相投的“高级”应用,却经常被充作某些体系结构的基础。
新雇员在上班第一天向人力资源部门报到,然后他被告知:“你去和其他的公司资产一起做个登记。”然后,他被带到一个队伍里。这个队伍里既有一长队的其他雇员,说来也奇怪,居然还排着一大堆乱七八糟的办公设备、车辆、家具和法务合同什么的。
等终于排到他的时候,劈面而来的是好多莫名其妙的问题:“你烧汽油么?”“你能写代码么?”“我能复印你么?”对这些问题统统回答了“不”以后,他被告知可以回家待命了。但他心里还在犯嘀咕:怎么就没人问他会不会拖地板呢?这好像才是他被雇来要干的活呀!
是不是听起来有些匪夷所思?(如果你在大公司工作过的话,也许已经见怪不怪了。)这的确比较匪夷所思,因为这是一个权能查询之非恰当运用的实例。让我们暂时离开人力资源部门一段时间,到金融事业大厅转转,看看那里用以表达金融工具的继承谱系是什么模样。假设我们现在正做证券交易。假设我们手里有现成的一个券价子系统和一个留存子系统,我们想把这些既有代码整合到新的继承谱系中去。各个子系统的规定已经明示于接口类中,所有用户都得从这些型别中派生:
class Saveable { // 留存子系统接口
public:
virtual~Saveable();
}
virtual void save() = 0;
// ...
};
class Priceable { // 券价子系统接口
public:
virtual~Priceable();
virtual void price() = 0;
// ...
};
有些从(另一个抽象基类)Deal型别派生的具象类型别同时也遵循子系统的规定,并复用了其代码。这属于标准、高效和正确无误的多继承应用:
class Deal { [23]
public:
virtual void validate() = 0;
// ...
};
class Bond
: public Deal,public Priceable
{/* ...*/};
class Swap [24]
: public Deal,public Priceable,public Saveable
{/* ...*/};
现在我们要添加通过传入一个指涉到Deal基类型别的指针就可以“处理”某笔交易的能力。浅陋的实现会直奔主题地询问 class对象的型别信息,这并不比我们上条中用terminate函数来做解雇雇员的尝试好到哪里去:
void processDeal( Deal *d ) {
d->validate();
if( Bond *b = dynamic_cast<Bond *>(d) )
b->price();
else if( Swap *s = dynamic_cast<Swap *>(d) ) {
s->price();
s->save();
}
else
throw UnknownDealType( d );
}
另一种同样令人忧心的流行做法是不询问 class对象的型别,转而询问它能做哪些操作。这常被称为“权能查询”:
void processDeal( Deal *d ) {
d->validate();
if( Priceable *p = dynamic_cast<Priceable *>(d) )
p->price();
if( Saveable *s = dynamic_cast<Saveable *>(d) )
s->save();
}
每个基类型别都代表了一组权能。在继承谱系中横跨基类型别实施dynamic_cast,或曰“跨领域强制型别转换”,与询问通过某 class对象能否调用某个或某组函数是等价的,如图 9-15 所示。这第二个版本的processDeal函数实质上就是在说:“交易先生啊,你给自己做一下合法性校验。如果你能询到价,你就询一下价;如果你能被留存,你就留存。”
这种手法比起前一个processDeal函数的实现来说,只能说是改进了一丁点儿。它不是那么的不堪一击,至少对于新派生的交易具象类型别而言,它还能勉强应付。但无论如何,它还是备受效率低下和维护难行之苦患。考虑若是继承谱系中出现了一个新的接口类的话,将会发生什么事,如图9-16所示。
继承谱系中添加了如此权能,是不会被发觉的。说到底,所有的既有代码根本不会想到去询问某笔交易是否合法的问题(但从另一方面讲,这却是领域分析结果中相当现实的一个问题)。如同我们早先用以实现解雇雇员的解决方案一样,运用依权能进行的查询手法来实现交易处理只能说是一种不得已而为之的临时方案,不能用作体系结构的底层设计。
在面向对象的设计中实施依型别或是依权能进行的查询,其根本问题在于,class 对象的一些最本质的行为(不是通过其本身体现)要通过其外部的某种检测机制才能了解。这样的手法与数据抽象的原则背道而驰,甚至和面向对象的软件开发的最深层基本思想也格格不入。一旦采用了这些手法,抽象数据型别的意义就不再是封装在实现它的型别结构之中,而是散布到源代码的汪洋大海里去了。
和以Employee型别为根的继承谱系一样,往以Deal型别为根的继承谱系中添加某种权能的最安全、最高效的手法亦是平凡不过:
class Deal {
public:
virtual void validate() = 0;
virtual void process() = 0;
// ...
};
class Bond : public Deal,public Priceable {
public:
void validate();
void price();
void process() {
validate();
price();
}
};
class Swap : public Deal,public Priceable,public Saveable {
public:
void validate();
void price();
void save();
void process() {
validate();
price();
save();
};
// 其他权能
我们还可以运用一些其他的技术,在不改动继承谱系结构的前提下——如果既有的设计已经定下了这种结构——对权能查询的实现手法作某些改进。访问者设计模式使得继承谱系能够自由地添加新的权能,但是要对继承谱系加以维护的话,它就显得脆弱不堪了。无环访问者设计模式(Acyclic Visitor Pattern)比访问者设计模式要坚挺一些,但它要求一个(仅仅是单独一个)权能查询,而这个查询有可能会无法如期运作。即使是这些并非完美的实现手法,无论如何,相对于在设计中系统采用权能查询而言,也不能不说是一种实实在在的改进。
一般地,如果权能查询成为必由之路,往往说明设计中存在不良成分。平凡、高效、型别安全、百试百灵的虚函数调用手法总是更佳的选择。
新雇员在上班第一天向人力资源部门报到。他被带到一个很多其他雇员已在其中的队伍里。等排到他的时候,有人对他说,“去干活吧!”由于他是被雇来做清洁工的,他领到了一柄拖把,然后当天余下的时间他就用来拖地板了。
[1].译者注:有关此处型别推导的技术细节,参见(Vandevoorde,et al.,2008),§ 11.4。有关for_each函数模板的函数签名式和应用实例,参见(Josuttis,2002),§ 9.4。
[2].译者注:bond的意思是“债券”。
[3].译者注:present value的意思是“现值”。
[4].译者注:这一段非常晦涩。其实所谓“可替换性”就是指基类型别对于以public方式继承的派生类型别在接口意义上的皆然性。而实际上虽然Object型别对Bond型别有皆然性,也就是说Object型别对Bond型别有可替换性,但是,尽管ObjectList型别和BondList型别的接口是一样的,前者对后者却并无皆然性,因为接口后面的涵义可以是说是风马牛不相及。所以,这样硬使用需要皆然性或曰可替换性保证的以public方式继承的手法,是不良设计,作者想表达的也就是“容器持有元素型别之间具有可替换性并不能推论得到持有可替换性型别的元素的容器型别之间亦有可替换性”这个意思。
[5].译者注:sneaky的意思是“卑劣勾当”。
[6].译者注:Underpaid minion的意思是收入不抵付出的奴才,多半指软件工程师自嘲,整个代码有“既然你写这样难以维护的代码来逼我加班,我就要让你收不到有价证券(bonds),而改要付加班费(pay)”的讽刺意味。
[7].译者注:victimize的意思是“欺负”。
[8].译者注:notional value的意思是“名义价值”。
[9].译者注:face value的意思是“票面价值”。
[10].译者注:equity的意思是“普通股”、“股票”。
[11].译者注:share value的意思是“股价”。
[12].译者注:参见常见错误71。
[13].译者注:本章中的图示使用了大量的统一建模图例,统一建模语言之图例部分的完整介绍参见 (Adaptive Ltd.;Alcatel; Borland Software Corporation; Computer Associates International,Inc.; Telefonaktiebolaget LM Ericsson;Fujitsu;Hewlett-Packard Company; I-Logix Inc.; International Business Machines Corporation; IONA Technologies; et al.,2007),PartⅡ。
[14].译者注:作者这里采用了术语“宏”(macro)。
[15].译者注:代码实现作为习题留给读者。
[16].译者注:本条款短得有些怪异,而且有言之无物,甚至循环论证、自相矛盾之嫌。但是,具象类不应该作为基类——甚至更进一步说,不应该被作为非最深派生类,这是正确的建议。有关此建议更详细的说明,可以参见作者在本条款中列举的参考条目,以及(Meyers,2006),条款33、(Sutter,2006),第 22条。
[17].译者注:所有这些操作都涉及复制操作。
[18].译者注:加载现值策略。
[19].译者注:加载波幅策略。
[20].译者注:许多现在的所谓面向对象的语言如Java和C#却是内建地支持这个设计的,盖因思想之本源就大相径庭。C++语言乃以“兼容已有 C 语言代码”为己任横空出世,处处以底层效率为优先考量。而其他的语言或平台却多少以追求概念之纯粹与完整为目标,加之计算机体系结构由计算密集型向存储密集型迁移,存储器的造价已经极为低廉,个人电脑动辄数个吉兆字节的内存储器,也难怪设计工程师已经少了很多精益求精“压榨”性能的动力,反而多了“以存储器的大量支用换取良好设计和优秀算法带来的收益”的借口。其实,计算机科学的精神就这样一点一滴地失却了。
[21].译者注:作者是委婉地表达这个客户的脑子可能有些毛病,应该向精神健康中心致电。
[22].译者注:与代码的对应是译者添加的。
[23].译者注:deal的意思是“生意”、“交易”。
[24].译者注:swap的意思是“掉期交易”。
本书中对于句式的调整可谓大刀阔斧,唯求以中文的最佳方式完整地传达作者原意为准。为补足于原文中略去而实际上对阅读有影响的部分,尚有大量内容以括弧补辑形式或直接添入原文。而本书对于术语的运用则如临深渊、如履薄冰。对于术语的准确、统一翻译方面,绝不敢造次,或妄自发明新术语。所有的术语皆参考了大量出版物中的使用频率和习惯,以及酌情采用更权威、醒目的中国台湾地区译法而成,力求准确、醒目和统一。如“对象”一词,已经为大陆以压倒性习惯采为英文“object”之译法,尽管多有不备之嫌,也只得从众。但“实参”一词,则坚持使用中国台湾地区的译法。盖因“参数”一词太过模糊(是应用程序的配置参数,还是数学上的参数方程之“参数”?),指“在函数调用前传入的输入值实体”这个概念时,则应该选用“实参”来译“argument”和“parameter”。其他一些术语如“饰词”(qualifier)、提领”(dereference)等也皆因中国台湾地区的译法生动传神而予以采用。另外,有些术语亦会采用数学或科学研究中的严谨表述而非日常表述,如“校验”(check,表示正确性的验证,不译为“检查”)、“平凡的”(simple,表示符合形式要求的最小结构,不译为“简单”)、“合式的”(well-formed)等。相信一来这些译法已经为一些相当流行的高质量译本采用,读者应该已有经验;一来这些词的确很有张力,能够为读者迅速掌握,若给您一时带来些许不便,也请见谅为盼。
以下表格整理了本书中采用的术语之中英文对照。
续表
① 译者注:有时“byte”亦用作度量存储尺寸之量词。
续表
① 译者注:本书中在“class”表示单独的“用户自定义型别”概念时,常就语境译作“型别”、“class型别”,或保留不译(使用关键字字体class)。和其他的单词组成复合概念的修饰成分时,一律不译,如“class模板”、“class对象”等;而作为复合概念之主体成分时,则译为“类”,如“基类”、“派生类”、“抽象类”和“具象类”等。
续表
① 译者注:凡书中提及的软件研发相关职业,皆称“工程师”以顺潮流之变,而不再以“设计师”、“程序员”等非正式名称相称,以示敬意。
续表
① 译者注:本书中一律不译,并以关键字字体inline标识。
续表
① 译者注:本书中“is-a”皆译为“皆为”而非“是一个”,以彰明此面向对象术语内涵。另有“is-a”相关的术语如“皆然性”、“皆然关系”。同样,“has-a”不译为“拥有”,而译为“复合”。
续表
① 译者注:作为标准库组件引用时,一律称“标准库中的vector组件”。
^ Operator (Visual Basic) [Online],Microsoft Corporation,Visual Basic Developer Center.,Microsoft Corporation,11 2007.- 10 31,2008.-http://msdn.microsoft.com/en-us/library/zh100ckf.aspx.
C++ Coding Standards,101 Rules,Guidelines,and Best Practices [Book] /auth.Sutter Herb and Alexandrescu Andrei /,人民邮电出版社,2005.- 1.- 影印版.- ISBN 7-115-13770-6.
C++ Common Knowledge,Essential Intermediate Programming [Book] /auth.Dewhurst Stephen C./ ed.陈贤舜 / trans.荣耀.- 崇文区:人民邮电出版社,2006.- 1.- 简体中文译本.- ISBN 7-115-14101-0.
C++ Distilled,A Concise ANSI/ISO Reference and Style Guide [Book] / auth.Pohl Ira / ed.孙志超 / trans.王树武and陈朔鹰.- 西城区:机械工业出版社,2003.- 1.- 简体中文译本.- ISBN 7-111-12746-3.
C++ Primer [Book] / auth.Lippman Stanley B.,Lajoie Josée and Moo Babara E./ ed.杨海玲.-崇文区:人民邮电出版社,2006.- 4.- 影印版.- ISBN 7-115-15169-5.
C++ Templates: The Complete Guide [Book] / auth.Vandevoorde David and Josuttis Nicolai M./ ed.付飞 / trans.陈伟柱.- 崇文区:人民邮电出版社,2008.- 简体中文译本.- ISBN 978-7-115-17181-8.
Chapter 18: Templates [Online] / auth.Brokken Frank B.// C++ Annotations.-Computing Center,University of Groningen,2001.– 10 27,2008.-http://burks.bton.ac.uk/burks/language/cpp/cpptut/cplus018.htm#l315.
Effective C++,50 Specific Ways to Improve Your Programs and Designs [Book] / auth.Meyers Scott Douglas / ed.周筠and孟岩 / trans.侯捷.- 武汉市:华中科技大学出版社,2001.- 2.- 简体中文译本.- ISBN 7-5609-2525-1.
Effective C++,55 Specific Ways to Improve Your Programs and Designs[Book] / auth.Meyers Scott Douglas / ed.周筠.- 海淀区:电子工业出版社,2006.- 3.- 影印版.- ISBN 7-121-00827-0.
Effective STL,50 Specific Ways to Improve Your Use of Standard Template Library [Book] / auth.Meyers Scott Douglas / ed.乔晶.- 海淀区:中国电力出版社,2003.- 1.- 影印版.- ISBN 7-5083-1497-2.
Exceptional C++ Style,40 New Engineering Puzzles,Programming Problems,and Solutions [Book] / auth.Sutter Herb / ed.杨海玲 / trans.刘未鹏.- 北京:人民邮电出版社,2006.- 简体中文译本.- ISBN 7-115-14225-4.
Exceptional C++: 47 Engineering Puzzles,Programming Problems,and Solutions [Book] / auth.Sutter Herb / ed.乔晶 / trans.卓小涛.- 海淀区:中国电力出版社,2003.- 1.- 简体中文译本.- ISBN 7-5083-1485-9.
Generic Programming and the STL,Using and Extending the C++Standard Template Library [Book] / auth.Austern Matthew H./ ed.姚贵胜 /trans.侯捷.- 西城区:中国电力出版社,2003.- 1.- 简体中文译本.- ISBN 7-5083-1487-5.
Inside The C++ Object Model [Book] / auth.Lippman Stanley B./ ed.周筠and王凯 / trans.侯捷.- 武汉市:华中科技大学出版社,2001.- 1.- 简体中文译本/作者亲名签名纪念本.- ISBN 7-5609-2418-2.
Modern C++ Design,Generic Programming and Design Patterns Applied [Book] / auth.Alexandrescu Andrei / ed.周筠 / trans.侯捷and 於春景.- 武汉市:华中科技大学出版社,2005.- 1.- 简体中文译本.- ISBN 7-5609-2906-0/TP·501.
More Effective C++,35 New Ways to Improve Your Programs and Designs [Book] / auth.Meyers Scott Douglas / ed.李南丰 / trans.刘晓伟.- 西城区:机械工业出版社,2007.- 1.- 简体中文译本.- ISBN 978-7-111-21070-2.
More Exceptional C++: 40 New Engineering Puzzles,Programming Problems,and Solutions [Book] / auth.Sutter Herb / ed.周筠and肖翔 / trans.於春景.- 武汉市:华中科技大学出版社,2002.- 1.- 简体中文译本.- ISBN 7-5609-2771-8.
OMG Unified Modeling Language (OMG UML),Infrastructure,V2.1.2[Online]/auth.Adaptive Ltd.; Alcatel; Borland Software Corporation; Computer Associates International,Inc.; Telefonaktiebolaget LM Ericsson; Fujitsu;Hewlett-Packard Company; I-Logix Inc.; International Business Machines Corporation; IONA Technologies; et al.// OMG Modeling and Metadata Specifications.- Object Management Group,11 4,2007.- 2.1.2.- 11 2,2008.-http://www.omg.org/docs/formal/07-11-04.pdf.
Programming Pearls [Book]/auth.Bentley Jon / ed.刘映欣.- 北京:人民邮电出版社,2006.- 2.- 影印版.- ISBN 7-115-15171-7.
Ruminations on C++ [Book] / auth.Koenig Andrew and Moo Barbara E./ ed.付飞 / trans.黄晓春.- 崇文区: 人民邮电出版社,2008.- 1.- 简体中文译本.-ISBN 978-7-115-17178-8/TP.
The Art of Computer Programming [Book] / auth.Knuth Donald Ervin.- 海淀区:清华大学出版社,2002.- 3rd Edition:Vol.2/Seminumerical Algorithms:7.- 影印版.- ISBN 7-302-05815-6//TP·3440.
The C Programming Language [Book] / auth.Kernighan Brian W.and Ritchie Dennis M..- 海淀区:清华大学出版社,1997.- 2.- 影印版.- ISBN 7-302-02412-X.
The C++ Programming Language [Book] / auth.Stroustrup Bjarne / ed.张海波.- 东城区:高等教育出版社,2001.- Special.- 影印版/作者亲笔签名纪念本.- ISBN 7-04-010095-9.
The C++ Standard Library Extensions: A Tutorial and Reference [Book] /auth.Becker Pete / ed.李南丰 / trans.史晓明.- 西城区:机械工业出版社,2008.- 简体中文译本.- ISBN 978-7-111-23675-7.
The C++ Standard Library: A Tutorial and Reference [Book] / auth.Josuttis Nicolai M./ ed.周筠 / trans.侯捷和孟岩.- 武汉市:华中科技大学出版社,2002.- 简体中文译本.- ISBN 7-5609-2782-3/TP·478.
The Design and Evolution of C++ [Book] / auth.Stroustrup Bjarne / ed.华章.- 西城区:机械工业出版社,2002.- 1.- 影印版/作者亲笔签名纪念本.-ISBN 7-111-09592-8.
The Practice of Programming [Book] / auth.Kernighan Brian W.and Pike Rob / ed.华章.-西城区:机械工业出版社,2002.- 1.- 影印版.- ISBN 7-111-09157-4.
Thinking in C++ [Book] / auth.Eckel Bruce / ed.姚蕾 / trans.刘宗田,袁兆山and潘秋菱.- 西城区:机械工业出版社,2002.- 2:Vol.1/Introduction to Standard C++:2.- 简体中文译本.- ISBN 7-111-10807-8.
Working Paper for Draft Proposed International Standard for Information Systems–Programming Language C++ [Report] = X3J16/96-0225/WG21/N1043:标准草案 / auth.Koenig Andrew / Accredited Standards Committee ; X3,Information Processing Systems.-Murray Hill,NJ 07974:American National Standards Institute,1996.- 打印稿.