C++覆辙录

978-7-115-37259-8
作者: 【美】Stephen C. Dewhurst(史蒂芬 C. 杜赫斯特)
译者: 高博
编辑: 傅道坤
分类: C++

图书目录:

详情

本书是C++大师Stephen C. Dewhurst在多年教授C++课程中所遇到的常见错误的心得笔记上编写而成的。本书所有章节都从一个众所周知的、在日常编码或设计实践经常遭遇的问题入手,先指出其不足,再对其背后思想中存在的合理与不合理之处深入剖析,最后取其精华弃其糟粕,给出一个简洁、通用、美轮美奂的方案,指出有关如何规避或纠正它们的建议,从而有助于C++软件工程师避免重蹈他们前辈的覆辙。

图书摘要

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].译者注:那是个有关临时对象生存时域的问题。

第1章 基础问题

说一个问题是基础的,并不就是说它不是严重的或不是普遍存在的。事实上,本章所讨论的基础问题的共同特点比起在以后章节讨论的技术复杂度而言,可能更侧重于使人警醒。这里讨论的问题,由于它们的基础性,在某种程度上可以说它们普遍存在于几乎所有的C++代码中。

常见错误1:过分积极的注释

很多注释都是画蛇添足,它们只会让源代码更难读,更难维护,并经常把维护工程师引入歧途。考虑下面的简单语句:

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都是标准库组件,对它们加上任何注释都是成心添乱,而且给定义得好好的标准操作规格描述带来了(非必要的)不确定性。注释之害并非与生俱来。注释常常必不可少。但注释必须(和代码一起)维护。维护注释常常比维护它们注解的代码要难。注释不应该描述显而易见的事,或把在别的地方已经说清楚的东西再聒噪一遍。我们的目标不是要消灭注释,而是在代码容易理解和维护的前提下,尽可能少写注释。

常见错误2:幻数

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; // 没问题幻数有百害而无一利。

请使用枚举常量或初始化了的具名常量。

常见错误3:全局变量

很难找到任何理由去硬生生地声明什么全局变量。全局变量阻碍了代码重用,而且使代码变得更难维护。它们阻碍重用是因为任何使用了全局变量的代码就立刻与之耦合,这使得全局变量一改它们也非得跟着改,从而使任何重用都不可能了。它们使代码变得更难维护的原因是很难甄别出哪些代码用了某个特定的全局变量,因为任何代码都有访问它们的权限。

全局变量增加了模块间的耦合,因为它们往往作为幼稚的模块间消息传递机制的设施存在。就算它们能担此重任,从实践角度来说 [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();

更重要的是,这种受控的访问为使用了单件设计模式的型别适应未来的变化带来了灵活性,并且消除了对现有代码的影响。以后当我们要切换到多线程的环境,或是要改成允许一种以上的环境并存的设计,或是随便要求怎么变时,我们都可以通过更改使用了单件设计模式之型别的实现来搞定这一切,而这就像我们先前更改包装全局变量的那个函数一样随心所欲。

常见错误4:未能区分函数重载和形参默认值

函数重载和形参默认值之间其实并无干系。不过,这两个独立的语言特征有时会被混淆,因为它们会模塑出语法上非常相像的函数用法接口。当然,看似一样的接口其背后的抽象意义却大相径庭:

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]

常见错误5:对引用的认识误区

对于引用的使用,主要存在两个常见的问题。首先,它们经常和指针搞混。其次,它们未被充分利用。好多在 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); // 把函数本身(隐式)转换成指涉到函数的指针,再使用提领语法

请注意区别引用和指针。

常见错误6:对常量(性)的认识误区

在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]

常见错误7:无视基础语言的精妙之处

大多数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语句低级的、少见的语义来实现了树遍历操作。

每当我使用上面这样的结构时,我总能收到强烈的、负面的甚至是骂骂咧咧的反应。而且我确实同意这种代码可不适合给维护工程师中的新手来打理,但是这样的结构——尤其是封装良好的、文档化了的版本——确实在对性能要求甚高或非常特殊的编码中有自己的一席之地。一句话,对基础语言难点的熟练掌握会对你大有裨益。

常见错误8:未能区分可访问性和可见性

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中为最多。

常见错误9:使用糟糕的语言

当一个更大的世界入侵了 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”时能派上用场。

常见错误10:无视(久经考验的)习惯用法

“很早就有人发现,最杰出的作家有时对修辞学的条条框框置若罔顾。不过,每当他们这么做的时候,读者总能在这样的语句中找到一些补偿的闪光点,以抵消违反规则带来的代价。也只有他们确信有这样的效果存在他们才这么做,否则他们会尽一切可能去因循那些既成的规则”(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]

常见错误11:聪明反被聪明误

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++软件工程师身上算一个常见的问题。请时刻牢记,几乎在所有的场合下,遵循习惯用法、清晰的表达和一点点效率的折损都好过小聪明、模棱两可和维护便利的丧失

常见错误12:嘴上无毛,办事不牢

我们软件工程师在提出建议方面是巨人,但一到行动的时候就成了矮子。我们不懈地奉劝人家不要使用全局变量、不好的变量名称、幻数,等等,但在自己的代码里却常常放入这些东西。这种现象使我困惑多年,直到有一次偶然读到一本描写青少年行为学的杂志时才豁然开朗。对于青少年来说,指责别人的冒险行为是常事,但是他们常常有一种“个人幻想”,相信他们自己对相同行为的一切负面效应都具有免疫力。那么我可以说,作为一个群体来说,软件工程师看来是深受情商欠佳之苦的。

我曾经带过这么一些项目。在这些项目里有些软件工程师不仅拒绝服从编码规范,甚至会因为被要求缩进4 个空格而不是2个这样的小事而威胁要退出团队。我曾经面临过这样的境遇:在软件开发会议上,只要有一个派系的人参加,另一个就不参加。我曾经见过这样的软件工程师:他们故意地写没有文档的、令人费解的代码,这样其他人就没法去动这些代码了。我曾经见过这样根本不合格的软件工程师:他们拒绝接受比他们年长——或比他们年幼、或说话太直、或太吹毛求疵——的同事的任何意见,并引起灾难性的结果。

无论在情商意义上是年少轻狂亦或成熟稳重,作为一个专业的软件工程师,我们都有一些数量的成人的——或至少是专业的——责任(参见美国计算机器协会在 ACM Code of Ethics and Professional ConductSoftware 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架构的机器。

第2章 语法问题

C++语言的语法和词法结构博大精深。此复杂性的一部分是从 C 语言那里继承而来的,另一部分则是为支撑某些特定的语言特性所要求的。

本章中我们将考察一组语法相关的头疼问题。其中有些属于常见的手误,但是错误的代码仍然能够通过编译,只不过会以出人意料的方式运行罢了。另外一些则是由于一段代码的语法结构及它们的运行期行为不再互为表里。其余的部分,我们主要研究语法层面的灵活余地带来的问题:明明是一字不差的代码,不同的软件工程师能从中得出大相径庭的结论来。

常见错误13:数组定义和值初始化的语法形式混淆

我们从堆上申请创建一个包含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这个值来初始化之

常见错误14:捉摸不定的评估求值次序

再没有比为迷糊的软件工程师设下的评估求值次序陷阱更能发现 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]

常见错误15:(运算符)优先级问题

本条款不讨论到底是伯爵夫人还是男爵夫人该在晚宴时坐在大使的旁座(此问题无解)。我们要讨论的是在 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)。

常见错误16:for语句引发的理解障碍

C++语言中有若干位置可以合法地在一个受限辖域(restricted scope)内,而不仅仅是平凡的一个嵌套闭合语句区块(nested block,即一对大括号之间的部分)中做一个变量声明。举例来说,在if语句的判别式部分就可以做一个变量声明。该变量在根据判别式控制跳转到的语句,无论true的部分还是false的部分内都有效:

if (char *theName = lookup(name) ){

if (isprint(buffer[i]))

// 做一些有关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){

massage(buffer[i]);

// ...

}}

这种写法丑陋得可以,所以当编译器的改进使得它失去存在的必要时,一定会被维护工程师发觉并移除。它还有其他的优点:他鼓励撰写初次代码的软件工程师使用for语句的新语义,并给这段代码的维护工程师省却了不少额外的麻烦。

常见错误17:取大优先解析原则带来的问题

当面对如下表达式时,你何以措手足?

++++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);

常见错误18:声明饰词次序的小聪明

就语言本身所限,声明饰词孰先孰后纯属无关紧要的形而上之争:

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; // 正确:指涉到常量的指针

常见错误19:“函数还是对象”的多义性

对象的默认初始化语句不应该写成一个空的初始化实参列表的形式,因为它会被解释成一个函数声明:

String s("Semantics,not Syntax! "); // 显式指定了用以初始化的实参

String t; // 默认的对象初始化语句 [27]

String x(); [28]// 一个函数声明

这是一个C++语言内廪的多义性。实践角度来说,语言“掷硬币”决定了x是一个函数声明 [29]。请注意,该多义性问题在new表达式中并不发作

String *sp1 = new String(); // 这里没有多义性[30]

String *sp2 = new String; // 一样的意思

当然,第二种形式更好。因为它被更广泛地使用,和对象的声明语句也更具正交性。

常见错误20:效果漂移的型别量化饰词

内建数组不可能有常量性或挥发性,所以修饰它的型别量化饰词(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; // 没问题

};

最好还是避免这种用法,当下的编译器并不能很好地理解它,而且它还会给维护工程师带来诸多困惑。

常见错误21:自反初始化

在以下的代码里,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的值。

常见错误22:静态连接型别和外部连接型别

根本没有本条款名称所述的这类东西。但是,经验丰富的 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]

常见错误23:运算符函数名字查找的反常行为

重载的运算符真的只不过就是可以用中序语法调用的地地道道的成员函数或非成员函数罢了。它们是“语法糖”:

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],于是编译器找到两个候选函数,并正确地匹配到了其中的非成员版本。

常见错误24:晦涩难懂的operator ->

内建的 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/C++实践
代码审计——C/C++实践
CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程