C++99个常见错误


MICROSOFT CHINA RESEARCH AND DEVELOPMENT GROUP C++:99 个常见错误 避免编码和设计中的常见问题 [美] Stephen C. Dewhurst (著); 高博 (译); 最近修订日期 12/3/2008 本文档现为 Release Candidate 版本,仅供审阅用户参考,在知识共享 署名-非商业性 使用- 禁 止 演 绎 2.5 中 国 大 陆 许 可 前 提 下 分 发 ( 详 见 http://creativecommons.org/licenses/by-nc-nd/2.5/cn)。审阅用户请发送审阅意见至 feedback@gaobo.org,或致电(86) 159 218 19896。 i 目录 目录 ........................................................................................................................................................... i 技术翻译:一种笔记体的创作尝试(译者序) ................................................................................... i 中英文术语对照表 ................................................................................................................................. iii 前言 ........................................................................................................................................................ vii 致谢 ......................................................................................................................................................... xi 第一章 基础问题 .................................................................................................................................. 1 常见错误 1:过分积极的注释........................................................................................................................ 2 常见错误 2:幻数 ........................................................................................................................................... 4 常见错误 3:全局变量 ................................................................................................................................... 6 常见错误 4:未能区分函数重载和形式引数默认值 .................................................................................... 8 常见错误 5:对引用的认识误区 .................................................................................................................... 9 常见错误 6:对常量(性)的认识误区 ...................................................................................................... 13 常见错误 7:无视基础语言的精妙之处 ...................................................................................................... 14 常见错误 8:未能区分可访问性和可见性 .................................................................................................. 18 常见错误 9:使用糟糕的语言...................................................................................................................... 22 常见错误 10:无视(久经考验的)习惯用法 ............................................................................................ 25 常见错误 11:聪明反被聪明误 .................................................................................................................... 28 常见错误 12:嘴上无毛,办事不牢 ............................................................................................................ 30 第二章 语法问题 ................................................................................................................................ 33 常见错误 13:数组定义和值初始化的语法形式混淆 ................................................................................ 34 常见错误 14:捉摸不定的评估求值次序 .................................................................................................... 35 常见错误 15:(运算符)优先级问题 .......................................................................................................... 40 常见错误 16:for 语句引发的理解障碍................................................................................................... 43 常见错误 17:取大优先解析原则带来的问题 ............................................................................................ 46 常见错误 18:声明饰词次序的小聪明 ........................................................................................................ 48 常见错误 19:“函数还是对象”的多义性 .................................................................................................... 49 常见错误 20:效果漂移的型别量化饰词 .................................................................................................... 50 常见错误 21:自反初始化 ........................................................................................................................... 51 常见错误 22:静态连接型别和外部连接型别 ............................................................................................ 53 常见错误 23:运算符函数名字查找的反常行为 ........................................................................................ 54 常见错误 24:晦涩难懂的 operator -> ................................................................................................ 56 第三章 预处理器问题 ........................................................................................................................ 59 ii 常见错误 25:使用#define 定义的字面量 .............................................................................................. 60 常见错误 26:使用#define 定义的伪函数(函数宏) .......................................................................... 62 常见错误 27:#if 的滥用 ........................................................................................................................... 64 常见错误 28:断言(assert 宏)的副作用 ............................................................................................ 69 第四章 型别转换问题 ........................................................................................................................ 73 常见错误 29:以 void *为型别转换的中介型别 .................................................................................... 74 常见错误 30:截切问题 ............................................................................................................................... 77 常见错误 31:对目标型别为指涉物为常量的指针型别的型别转换的认识误区 ..................................... 79 常见错误 32:对以指涉物为指涉到常量的指针型别的型别为目标型别的型别转换的认识误区 ......... 80 常见错误 33:对以指涉物为指涉到基类型别的指针型别的型别为目标型别的型别转换的认识误区 . 84 常见错误 34:指涉到多维数组的指针带来的问题 .................................................................................... 85 常见错误 35:未经校验的向下转型 ............................................................................................................ 87 常见错误 36:型别转换运算符的误用 ........................................................................................................ 88 常见错误 37:始料未及的构造函数型别转换 ............................................................................................ 92 常见错误 38:在多继承条件下进行强制型别转换 .................................................................................... 95 常见错误 39:对非完整型别做强制型别转换 ............................................................................................ 96 常见错误 40:旧式强制型别转换 ................................................................................................................ 98 常见错误 41:静态强制型别转换 ................................................................................................................ 99 常见错误 42:形式引数引发临时对象生成的初始化 .............................................................................. 101 常见错误 43:临时对象的生存时域 .......................................................................................................... 105 常见错误 44:引用和临时对象.................................................................................................................. 107 常见错误 45:(动态强制型别转换运算符)dynamic_cast 带来的多义性解析失败 ....................... 110 常见错误 46:对逆变性的误解.................................................................................................................. 114 第五章 初始化问题 .......................................................................................................................... 119 常见错误 47:赋值与初始化混淆 .............................................................................................................. 120 常见错误 48:位于非适当辖域的变量 ...................................................................................................... 123 常见错误 49:未能意识到 C++语言中复制操作的固守行为 .................................................................. 126 常见错误 50:按位复制的 class 对象 .................................................................................................... 129 常见错误 51:未能区分构造函数中的初始化和赋值 .............................................................................. 132 常见错误 52:未能在成员初始化列表中保持次序一致性 ...................................................................... 133 常见错误 53:对于虚基类(子对象)进行默认初始化 .......................................................................... 134 常见错误 54:复制构造函数对基类子对象初始化的未预期行为 .......................................................... 140 常见错误 55:运行期静态初始化次序 ...................................................................................................... 143 常见错误 56:直接 vs. 复制初始化 ......................................................................................................... 145 常见错误 57:对引数的直接初始化 .......................................................................................................... 148 常见错误 58:无视返回值优化.................................................................................................................. 150 常见错误 59:在构造函数中初始化静态(数据)成员 .......................................................................... 154 第六章 内存和资源管理问题 .......................................................................................................... 157 常见错误 60:未能区分纯量与数组的内存分配机制 .............................................................................. 158 常见错误 61:内存分配失败校验 .............................................................................................................. 160 常见错误 62:(用自定义版本)替换全局的内存管理运算符(所调用的函数) ................................. 162 iii 常见错误 63:成员版本的 operator new 和 operator delete 的辖域和调用机制混淆 .......... 165 常见错误 64:抛出字符串字面常量(作为异常对象) .......................................................................... 167 常见错误 65:未能正确理解和利用异常处理机制 .................................................................................. 169 常见错误 66:滥用局部量地址.................................................................................................................. 173 常见错误 67:未能采用 RAII(资源获取即初始化)习惯用法 ............................................................. 178 常见错误 68:对 auto_ptr 的误用 ........................................................................................................ 182 第七章 多态问题 .............................................................................................................................. 185 常见错误 69:型别特征码 ......................................................................................................................... 186 常见错误 70:将基类析构函数声明为非虚函数 ...................................................................................... 191 常见错误 71:对非虚(成员)函数的遮掩 .............................................................................................. 196 常见错误 72:(以)过分灵活的(方式滥用)模板方法设计模式 ........................................................ 198 常见错误 73:重载虚函数 ......................................................................................................................... 200 常见错误 74:为引数指定默认初始化物的虚函数 .................................................................................. 201 常见错误 75:在构造函数和析构函数中调用虚函数 .............................................................................. 203 常见错误 76:虚赋值 ................................................................................................................................. 205 常见错误 77:未能区分(函数的)重载、改写和遮掩 .......................................................................... 208 常见错误 78:未能深入理解虚函数和改写的实现机制 .......................................................................... 213 常见错误 79:支配原则议题 ..................................................................................................................... 221 第八章 型别设计问题 ...................................................................................................................... 227 常见错误 80:取/设状态接口.................................................................................................................. 228 常见错误 81:常量和引用数据成员 .......................................................................................................... 231 常见错误 82:未能理解常量成员函数 ...................................................................................................... 233 常见错误 83:未能区分强聚合和弱聚合 .................................................................................................. 237 常见错误 84:非适当的运算符重载 .......................................................................................................... 242 常见错误 85:(运算符)优先级和重载 .................................................................................................... 245 常见错误 86:友元 vs. 成员运算符 ......................................................................................................... 246 常见错误 87:自增/自减运算符的问题 .................................................................................................. 247 常见错误 88:对模板化的复制操作的认识误区 ...................................................................................... 250 第九章 继承谱系设计问题 .............................................................................................................. 253 常见错误 89:持有 class 对象的数组 .................................................................................................... 254 常见错误 90:非适当的容器型别之可替换性 .......................................................................................... 255 常见错误 91:未能理解 protected 访问层级 ...................................................................................... 259 常见错误 92:为代码复用而以 public 方式继承 ................................................................................. 262 常见错误 93:以 public 方式继承具象类 ............................................................................................. 266 常见错误 94:未能运用继承谱系的退化形式 .......................................................................................... 267 常见错误 95:继承的滥用 ......................................................................................................................... 268 常见错误 96:依型别分派的控制结构 ...................................................................................................... 272 常见错误 97:单根谱系 ............................................................................................................................. 274 常见错误 98:向 class 对象打探隐私 .................................................................................................... 277 常见错误 99:权能查询问题 ..................................................................................................................... 280 引用书目 ................................................................................................................................................... i i 技术翻译:一种笔记体的创作尝试(译者 序) 经过近一年的工作,这本近四百页的小册子终于和大家见面了。 这本书从一个读者的角度来看,当然主要地可以视为是对于当之无愧的 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 大师还在数个方面给予了我太多的启迪:这本书所有的章节都从一 个众所周知的、在日常编码或设计实践经常遭遇的问题入手,先是就事论事地指出其不足, 再是对其背后思想中存在何种合理与不合理之处深入剖析,最后取之精华弃之糟粕,给出一 ii 个简洁、通用、美轮美奂的方案。有的条款中,大师会给出数种不同的解决之道,并一一评 点其优劣之处,指出其适用场合;有的条款中,大师步步推进,先是给出一个去除错误的解, 再进一步地优化它,直至与某种习惯用法和设计模式接壤作为点题之笔。从翻译的过程中, 我自己真的是受益良多,希望我的读者能够收获更大。 在本书的翻译中,清华大学出版社的龙启铭编辑给予了我很大的帮助和鼓励,并促成这本书 最终完稿。微软亚洲研究院的徐宁研究员和 EMC 中国的柴可夫工程师通读了全书,并给予 了全面的审阅意见,包括不少技术和文字的问题,在此向他们深深致谢。另外, Hewlett-Packard 总部的 Craig Hilderbrandt 经理、上海交通大学计算机系的张尧弼教授、 Phoenix 中国的唐文蔚高级工程师、谷歌中国的龚理工程师、微软亚洲工程院的魏波工程师、 微软全球技术中心的陈曦工程师和 SAP 中国的劳佳工程师也都在本书写作的过程中给了我 不小的帮助,在此一并致谢。当然,书中的错误和纰漏在所难免,这些理应由我本人负全部 责任。另外要感谢的还有我的家人和同事们,没有你们的支持,我不可能坚持到底。希望本 书的出版能够给你们带来快乐。 高博 2008 年 11 月 于微软亚洲工程院上海分院 iii 中英文术语对照表 本书中对于句式的调整可谓大刀阔斧,唯求以中文的最佳方式完整地传达作者原意为准。为 补足于原文中略去而实际上对阅读有影响的部分,尚有大量内容以括弧补辑形式直接添入原 文。而本书对于术语的运用则如临深渊、如履薄冰。对于术语的准确、统一翻译方面,绝不 敢造次,或妄自发明新术语。所有的术语皆参考了大量出版物中的使用频率和习惯,以及酌 情采用更权威、醒目的台湾地区译法而成,力求准确、醒目和统一。如“对象”一词,已经 为大陆以压倒性习惯采为英文“object”之译法,尽管多有不备之嫌,也只得从众。但“引 数”一词,则坚持使用台湾译法。盖因“参数”一词太过模糊(是应用程序的配置参数,还 是数学上的参数方程之“参数”?),指“在函数调用前传入的输入值实体”这个概念时, 则应该选用“引数”来译“argument”和“parameter”。其它一些术语如“饰词”(qualifier)、 “提领”(dereference)等也皆因台湾译法生动传神而予以采用。另外,有些术语亦会采用 数学或科学研究中的严谨表述而非日常表述,如“校验”(check,表示正确性的验证,不译 为“检查”)、“平凡的”(simple,表示符合形式要求的最小结构,不译为“简单”),“合式 的”(well-formed)等。相信一来这些译法已经为一些相当流行的高质量译本采用,读者应 该已有经验;一来这些词的确很有张力,能够为读者迅速掌握,若给您一时带来些许不便, 也请见谅为盼。 以下表格整理了本书中采用的术语之中英文对照: 英文术语 中文译文 abstract 抽象 abstraction 抽象性 access 访问 access level 访问层级 accessibitily 可访问性 acquaintance 弱聚合 address 地址 address-of 取址(操作/运算符) aggregation 强聚合 algorithm 算法 allocate 分配 architecture 体系结构 argument 引数 array 数组 assert(-ion) 断言 assign(-ment) (operator) 赋值(操作/运算符) base class 基类(型别) base type 基型别 binary operator 二元运算符 ii 英文术语 中文译文 binding 绑定 bit mask 位屏蔽 bitwise 按位 block (语句)区块 boolean 布尔(量/值) boundary 边界 breakpoint 断点 build 建构 built-in 内建 byte 字节1 call 调用 callback 回调 character 字符 class 类2 client 用户 code 代码 compatible 兼容 compile time 编译期 compiler 编译器 component 组件 composition 复合 concrete 具象的 connection 连接/联系 constraint 约束/规约 construct 构造过程 constructor 构造函数 const(-ness) 常量性 constant 常量 container 容器 contravariance 逆变性 copy(动词) 复制 copy(名词) 副本 covariant 协变性 create 创建 custom 自定义 dangling 空悬(的) data 数据 1 有时“byte”亦用作度量存储尺寸之量词。 2 本书中在“class”表示单独的“用户自定义型别”概念时,常就上下文译作“型别”、“class 型别”, 或保留不译(使用关键字字体 class)。和其它的单词组成复合概念的修饰成分时,一律不译,如“class 模板”、“ class 对象”等等;而作为复合概念之主体成分时,则译为“类”,如“基类”、“派生类”、“抽 象类”和“具象类”等。 iii 英文术语 中文译文 database 数据库 data member 数据成员 data structure 数据结构 debug 调试 debugger 调试器 declaration 声明 default 默认 definition 定义 delivery 交付 dependent 相依(的) dereference 提领 derived class 派生类(型别) design pattern 设计模式 designer 设计工程师1 destroy 析构过程 desctructor 析构函数 directive 预处理器指令 document 文档(化) dump core 核心转储 dynamic binding 动态绑定 entity 实体 encapsulation 封装 enum(-eration) 枚举(型别) equality 相等 equivalence 等价 evaluation 评估求值 exception 异常 extend 延拓 explicit 显式(的) expression 表达式 fallthrough 直下式 file 文件 framework (代码/设计)框架 full-expression 全表达式 function 函数 function call operator 函数调用运算符 function object 函数对象 function template 函数模板 generic 泛型 1 凡书中提及的软件研发相关职业,皆称“工程师”以顺潮流之变,而不再以“设计师”、“程序员”等非 正式名称相称,以示敬意。 iv 英文术语 中文译文 global 全局(的) handle 句柄 handler 处理代码 header 头文件 heap 堆 hierarchy 继承谱系 identifier 标识符 implement(-ation) 实现 implicit 隐式(的) independent 无关(的) information 信息 inheritance 继承 inline 内联1 initialization list 成员初始化列表 instance 实例 instantiate 具现 interface 接口 interpreter 解释器 invariants 不变量 invoke 调用 is-a 皆为2 iterator 迭代器 lexical 词法(的) library 库 lifetime 生存时域 linker 连接器 literal 字面常量 list 线性表 local 局部(的) lock 锁 loop 循环 lvalue 左值 macro 宏 maintainer 维护工程师 maintenance (软件)维护 maximal munch 取大优先解析原则 named 具名(的) 1 本书中一律不译,并以关键字字体 inline 标识。 2 本书中“is-a”皆译为“皆为”而非“是一个”,以彰明此面向对象术语内涵。另有“is-a”相关的术语如 “皆然性”、“皆然关系”。同样,“has-a”不译为“拥有”,而译为“复合”。 v 英文术语 中文译文 offset 偏移(量)1 overlay 叠置 parallel 平行 parameter (形式)引数 parse 解析 pass by reference 按引用传递 pass by value 按值传递 pattern 设计模式 placement delete 定位 delete placement new 定位 new pointer 指针 polymorphism 多态 preprocessor 预处理器 print 打印/输出 process(动词) 处理 process(名词) 进程 profiling 性能剖析 program 程序代码 programmer 软件工程师 project 项目 pseudo 伪 quality 质量 raw 生鲜的/裸(的) recursive 递归 refer to 指涉到 reference 引用 regular expression 正则表达式 resolve 解析 return 返回 return type 返回值型别 return value 返回值 runtime 运行期 rvalue 右值 save 存储 scope 辖域 search 搜索 self- 自反(的) semantics 语义 signature (函数)签名(式) 1 原作中有时会以“delta”称此概念,这是借用了数学中通用的表示差分的希腊字母Δ之故。 vi 英文术语 中文译文 simple 平凡的 size 尺寸 smart pointor 智能指针 source 源代码 specification 规格 stack 栈 standard library 标准库 statement 语句 static 静态(的) string 字符串 support 支持 syntactical 句法(的) template 模板 temporary object 临时对象 text 文本 thread 线程 throw 抛出 token 语汇块 tuple 元组 type 型别 type deduction 型别推导 unary function 一元函数 user 用户 user interface 用户界面 value 值 variable 变量 vector 向量1 visibility 可见性 virtual function 虚函数 1 作为标准库组件引用时,一律称“标准库中的 vector 组件”。 vii 前言 本书之渊薮乃是近二十年的小小挫折、大错特错、不眠之夜和在键盘的敲击中不觉而过的无 数周末。里面收集了普遍的、严重的或有意思的 C++常见错误,共计九十有九。其中的大多 数,(实在惭愧地说)都是我个人曾经犯过的。 术语“gotcha”(译注:本书通译为“常见错误”,固然较之原文失之神韵,倒也算得通俗易 懂)有其云谲波诡的形成历史和汗牛充栋的不同定义。但在本书中,我们定义它在 C++范畴 里的含义为既普遍存在、又能加以防范的 C++编码和设计问题。这些常见错误涵盖了从无关 大局的语法困扰,到基础层面上的设计瑕疵,到源自内心的离经叛道等诸方面。 大约十年前,我开始在我教授的 C++课程的相关材料中添加个别常见错误的心得笔记。我的 感觉是,指出这些普遍存在的误解和误用,配合以正确的用法指导就像给学生打了预防针, 让他们自觉地与这些错误作斗争,更可以帮助新入门的 C++软件工程师避免重蹈他们前辈的 覆辙。大体而言,这种方法行之有效。我也深受鼓舞,于是又收集了一些互相关联的常见错 误的集子,在会议上作演讲用。未想这些演讲大受欢迎(或是同病相怜之故也未可知?), 于是就有人鼓励我写一本“常见错误之书”。 任何有关规避或修复 C++常见错误的讨论都涉及了其它的议题,最多见的是设计模式、习惯 用法,以及 C++语言特征的技术细节。 这并非一本讲设计模式的书,但我们经常在规避或修复 C++常见错误时发现设计模式是如此 管用的方法。习惯上,设计模式的名字我们把每个单词的首字母大写,比如模板方法设计模 式(Template Method)或桥接设计模式(Bridge)。 当我们提及一种设计模式的时候,若它 不是很复杂,则简介其工作机制,而详细的讨论则放在它们和实际代码相结合的时候才进行。 除非特别说明,本书不提供设计模式的完全描述或极为详尽的讨论,这些材料可以参考 Erich Gamma et al.,Design Patterns。无环访问者(Acyclic Visitor)、单态(Monostate)和 空件(Null Object)等设计模式的描述请参见 Robert Martin,Agile Software Development。 从常见错误的视角来看,设计模式有两个可贵的特质。首先,它们描述了已经被验证成功的 设计技术,这些技术在特定的软件环境中可以采用自定义的手法搞出很多新的设计花样。其 次,或许更重要的是,提及设计模式的应用,对于文档的贡献不仅在于使运用的技术一目了 然,同时也使应用设计模式的原因和效果一清二楚了。 举例来说,当我们看到在一个设计里应用了桥接设计模式时,我们就知道在一个机制层里, 一个抽象数据型别的实现并分解成了一个接口类和一个实现类。犹有进者,我们知道这样做 是为了强有力地把接口部分同底层实现剥离,是故底层实现的改变将不会影响到接口的用户。 我们还知道这种剥离会带来运行时的开销、还知道此抽象数据型别的源代码应该怎么安排, 还知道很多其它细节。 一个设计模式的名字是关于某种技术极为丰富的信息和经验之高效、无疑义的代号。在设计 viii 和撰写文档时仔细而精确地运用设计模式及其术语会使代码洗练,也会阻止常见错误的发生。 C++是一门复杂的软件开发语言,而一种语言愈是复杂,习惯用法在软件开发中之运用就愈 是重要。对一种软件开发语言来说,习惯用法就是常用的、由低阶语言特征构成的高阶语言 结构的特定用法组合。总的来说,这和设计模式与高阶设计的关系差不多。是故,在 C++ 语言里我们可以直接讨论复制操作、函数对象、智能指针以及抛出异常等概念而不需要一一 指出他们在语言层面上的最低阶实现细节。 有一点要特别强调一下,那就是习惯用法并不仅仅是一堆语言特征的常见组合,它更是一组 对此种特征组合之行为的期望。复制操作是什么意思呢?当异常被抛出的时候,我们能指望 发生什么呢?大多数本书中的建议都是在提请注意以及建议应用 C++编码和设计中的习惯 用法。很多这里列举的常见错误常常可以直接视作对某种 C++习惯用法的背离,而这些常见 错误对应的解决方案则常常可以直接视作对某种 C++习惯用法的皈依(参见常见错误 10)。 本书在 C++语言的犄角旮旯里普遍被误解的部分着了重墨,因为这些语言材料也是常见错误 的始作俑者。这些材料中的某些部分可能让人有武林秘笈的感觉,但如果不熟悉它们,就是 自找麻烦,在通往 C++语言专家的阳关大道上也会平添障碍。这些语言死角本身研究起来就 是其乐无穷,而且产出颇丰。它们被引入 C++语言总有其来头,专业的 C++软件工程师经 常有机会在进行高阶的软件开发和设计时用到它们。 另一个把常见错误和设计模式联系起来的东西是,描述相对平凡的实例对于两者来说是差不 多同等重要的。平凡的设计模式是重要的。在某些方面,它们也许比在技术方面更艰深的设 计模式更为重要,因为平凡的设计模式更有可能被普遍应用。所以从对平凡设计模式的描述 中获得的收益就会以杠杆方式造福更大范围的代码和设计。 差不多以完全相同的方式,本书中描述的常见错误涵盖了很宽范围内的技术困难,从 如何成 为一个负责的专业软件工程师的循循善诱(常见错误 12)到避免误解虚拟继承下的支配原 则的苦口良言(常见错误 79)。 不过,就与设计模式类似的情况看,表现得负责专业当然比 懂得什么支配原则要对日复一日的软件开发工作来得受用。 本书有两个指导思想。第一个是有关习惯用法的极端重要性。这对于像 C++这样的复杂语言 来说尤为重要。对业已形成的习惯用法的严格遵守使我们能够既高效又准确地和同行交流。 第二个是对“其他人迟早会来维护我们写的代码”这件事保持清醒头脑。这种维护可能是直 截了当的,所以这就要求我们把代码写得很洗练,以使那些称职的维护工程师一望即知;这 种维护也可能是拐了好几道弯的,在那种情况下我们就得保证即使远在天边的某个变化影响 了代码的行为,它仍然能够给出正确的结果。 本书中的常见错误以一组小的论说文章的形式呈现,其中每一组都讨论了一个常见错误或一 些相互关联的常见错误,以及有关如何规避或纠正它们的建议。由于常见错误这个主题内廪 的无政府倾向,我不敢说哪本书可以特别集中有序地讨论它。然而,在本书中,所有的常见 错误都按照其错误本质或应用(误用)所涉的领域归类到相应的章节。 还有,对一个常见错误的讨论无可避免地会牵涉到其它的常见错误。当这种关联有它的意义 时——通常确实是有的——我就显式地作出链接标记。其实,这种每个常见错误的为了增强 ix 关联性的描述本身也是有其讨厌之处的。比方说经常遇到一种情况就是还没来得及描述一个 常见错误自己,倒先把为什么会犯这个错误的前因后果交代了一大篇。要说清这些个前因后 果呢,好家伙,又非得扯上某种技术啦、习惯用法啦、设计模式啦或是语言细节什么的,结 果在言归正传之前要兜更大的圈子。我已经尽力把这种发散式的跑题减到最少了,但要是说 完全消除了这种现象,那我就没说实话。要把 C++程序设计做到很高效的境界,那就得在非 常多水火不容的方面作出如履薄冰的协调,想在研究大量相似的主题前就对语言作出像样的 病理学分析,那只能说是不现实的。 把这本书从第 1 个常见错误到第 99 个常见错误这么挨个地读下去,不仅是毫无必要的,而 且也谈不上明智。一气儿服下这么一帖虎狼之剂恐怕会让你一辈子再也学不成 C++了。比较 好的阅读方法应该是拣一条你不巧犯过的,或是你看上去有点儿意思的常见错误开始看,再 沿着里面的链接看一些相关的。另一种办法就是你干脆由着性子,想看哪儿看哪儿,也行。 本书的文本里也使用了一些固定格式来阐明内容。首先,错误的和不提倡的代码以灰色背景 来提示,而正确和适当的代码却没有任何背景。其次,这里作示意用的代码为了简洁和突出 重点,都经过了编辑。这么做的一个结果是,这里示例用的代码若是没有额外的支撑代码往 往不能单独通过编译。那些并非平凡无用的示例源代码则可以在作者的网站里找到: www.semantics.org。所有这样的代码都由一个相对路径引出,像这样: gotcha00/somecode.cpp 最后,提个忠告:你不要把常见错误的重要性提升到和习惯用法、设计模式一样。(译注: 作者用心良苦,怕读者“近墨者黑”,好的没记住反而坏的学会了。所以特意提醒所有读者, 常见错误有些奇技淫巧,但毕竟不登大雅之堂。)一个你已经学会正确地使用习惯用法和设 计模式的标志是,当某个习惯用法或是设计模式正好是你手头的设计或编码对症良方时,它 就“神不知鬼不觉地”在你最需要时从你的脑海里浮现出来了。 对常见错误的清醒意识就好比是对危险的条件反射:一回错,二回过。就像对待火柴和枪械 一样,你不必非得烧伤或是走火打中了脑袋才学乖。总之,只要加强戒备就行了。把我这本 手册当作是你面对 C++常见错误时自我保护的武器吧。 Stephen C. Dewhurst Carver, Massachusetts 2002 年 7 月 xi 致谢 编辑们经常在书的“致谢”里落得个坐冷板凳的下场,有时用一句“„„其实我也挺感谢我 那编辑的,我估计在我拼了命爬格子的时候此人大概肯定也是出过一点什么力的吧”就打发 了。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++编译器来校验书里的代码(译注:这应该就是著名 的 Comeau C/C++ Front/End 编译器)。 就像所有关于 C++的任何有意义的工作那样,本书也是集体智慧的结晶。这些年来,很多我 的学生、客户和同事们为我在 C++常见错误面前表现的呆头呆脑和失足跌跤可没少数落过我, 并且他们中的好多人都帮我找到了问题的解决之道。当然,这些特别可贵的贡献者中的大部 分都没法在这里一一谢过,不过有些提供了直接贡献的人还是可以列举如下的: 常见错误 11 中的 Select 模板,和常见错误 70 中的 OpNewCreator 策略都取自 Andrei Alexandrescu,Modern C++ Design。 我在常见错误 44 中描述了有关返回一个常量形式引数的引用带来的问题(译注:是个有关 临时对象生存时域的问题),此问题我初见于 Cline et al.,C++ FAQs(我客户的代码中在此 之后马上就用上了这个解决方案)。此书还描述了我在常见错误 73 中提到的用于规避重载虚 函数的技术。 常见错误 83 中的那个 Cptr 模板,其实是 Nicolai Josuttis,The C++ Standard Library 中 CountedPtr 模板的一个变形。 Scott Meyers 在他的 More Effective C++中,对运算符&&、||、和,的重载之不恰当性提出了 比我在常见错误 14 的描述更深入的见解。他也在他的 Effective C++中,对我在常见错误 58 xii 中讨论的二元运算符以值形式返回的必要性作了更细节的描述,还在 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 专栏。 第一章 基础问题 说一个问题是基础的,并不就是说它不是严重的或不是普遍存在的。事实上,本章所讨论的 基础问题的共同特点比起在以后章节讨论的技术复杂度而言,可能更侧重于使人警醒。这里 讨论的问题由于它们的基础性,在某种程度上可以说它们存在于几乎所有的 C++代码中。 第一章 基础问题 2 常见错误 1:过分积极的注释 很多注释都是画蛇添足。它们只是让源代码更难读、更难维护,并经常把维护工程师引入歧 途。考虑下面的简单语句: a = b; // 将 b 赋值给 a 这个注释难道比代码本身更能说明这个语句的意义吗?因而它是完全无用的。事实上,它比 完全无用还要坏。它是害人精。首先,这条注释转移了代码阅读者的注意力,增加了阅读量 因而使代码更费解。其次,要维护的东西更多了,因为注释也是要随着它描述的代码的更改 而更改的。第三,这个对注释的更改常常就没有做。(译注:因为注释没有随着代码更改, 这个语句看起来就成了下面这个样子。) c = b; // 将 b 赋值给 a 仔细的维护工程师不会武断地说注释是错的(译注:说不定是代码错了呢),所以他就被迫 要去检视整个程序以确定注释到底是错了的呢,还是好意的呢(c 可能是 a 的引用),还是 微妙的呢(赋值给 c 可能引发一些传播效应以使 a 的值也发生相应变化)等等,总之这一 行就根本不应该带注释。 a = b; 还是这代码本来的样子最清楚地表明了其意义,也没有额外的注释需要维护。这在精神上也 符合老生常谈,亦即“最有效率的代码就是根本不存在的代码”。这条经验对于注释也适用: 最好的注释就是根本用不着写的注释,因为要注释的代码已经“自注释”了。 另一些常见的非必要的注释的例子经常可以在型别的定义里见到,它们要么是病态的编码标 准的怪胎,要么就是出自 C++新手: class C { // 公开接口 public: C(); // 默认构造函数 ~C(); // 析构函数 // ... }; 你会觉得别人在挑战你的智商。要是某个维护工程师连“public:”是什么意思都需要教, 你还敢让他碰你的代码吗?对于任何有经验的 C++软件工程师而言,这些注释除了给代码添 乱、增加需要维护的文本数量以外没有任何用处。 class C { // 公开接口 protected: 常见错误 1:过分积极的注释 3 C( int ); // 默认构造函数 public: virtual ~C(); // 析构函数 // ... }; 软件工程师还有一种强烈的心理趋势就是尽量不要“平白无故”地在源文件文本中多写哪怕 一行。这里公布一个有趣的本行业秘密:如果某种结构(函数啦、型别的公开接口啦什么的) 能被塞在一“页”里,也就在三四十行左右(译注:亦即,在普通的屏幕上能在一页内显示 得下)的话,它就很容易理解。假如有些内容跑到第二页去了,它理解起来就难了一倍。如 果三页才塞得下,据估计理解难度就成原来的四倍了。(译注:源文件文本长度与理解难度 成指数关系,所以能少写一行非必要的注释就少写一行。) 一种特别声名狼藉的编码实践就是把更改日志作为注释插入到源文件的头部或尾部: /* 6/17/02 SCD 把一个该死的 bug 干掉了 */ 这到底是有用的信息,抑或是仅仅是维护工程师的自吹自擂?在这行注释被写下以后的一两 个星期,它怎么看也不再像是有用的了,但它却也许要在代码里粘上很多年,欺骗着一批又 一批的维护工程师。顶好是用你的版本控制软件来做这种无用注释真正想做的事,C++的源 代码文件里可没有闲地方来放这些劳什子。 想不用注释却又要使代码意义明确、容易维护的最好办法就是遵循简单易行的、定义良好的 命名习惯来为你使用的实体(函数啦、型别啦、变量啦等等)取个清晰的、反映其抽象含义 的名字。(函数)声明中形式引数的名字尤其重要。考虑一个带有三个同一型别引数的函数: /* 从源到目的执行一个动作 第一个引数是动作编码(action code),第二个引数是源(source),第三个引数是目 的(destination) */ void perform( int, int, int ); 这也不算太坏吧,不过如果引数是七八个而不是三个你又该写多少东西呢?我们明明可以做 得更好: void perform( int actionCode, int source, int destination); // 译注:这很明显是 Herb Sutter 倡导的命名规则(原谅我又多写了一行注释,) 这就好多了。按理,我们还需要写一行注释来说明这个函数的用途(而不是如何实现的)。 形式引数的一个最引人入胜之处就是,不像注释,它们是随着余下的代码一起更改的,即使 改了也不影响代码的意义。话虽然这么说,但我不能想像任何一个软件工程师在引数意义改 变了的时候,会不给它取个新名字。(译注:参见(Kernighan, et al., 2002),§8.7),但我能举 出一串软件工程师来,他们改了代码但老是忘记维护注释。 第一章 基础问题 4 Kathy Stark 在 Programming in C++中说得好:“如果在程序里用意义明确、脱口而出的名字, 那么注释只是偶尔才需要。如果不用意义明确的名字,即使加上了注释也不能让代码更好懂 一些。” 另一种最大程度地减少注释书写的办法是采用标准库中的、或人尽皆知的组件: printf( "Hello, World!" ); // 在屏幕上打印“Hello, World” 上面这个注释不但是无用的,而且只在部分情况下正确。标准库组件不仅是“自注释”的, 并且有关它们的文档汗牛充栋,有口皆碑。 swap( a, a+1 ); sort( a, a+max ); copy( a, a+max, ostream_iterator(cout,"\n") ); 因为 swap、sort 和 copy 都是标准库组件,对它们加上任何注释都是成心添乱,而且给 定义得好好的标准操作规格描述带来了(非必要的)不确定性。 注释之害并非与生俱来。注释常常必不可少。但注释必须(和代码一起)维护。维护注释常 常比维护它们注解的代码要难。注释不应该描述显而易见的事,或把在别的地方已经说清 楚的东西再聒噪一遍。我们的目标不是要消灭注释,而是在代码容易理解和维护的前提下, 尽可能少写注释。 常见错误 2:幻数 幻数,用在这里时其含义是上下文里出现的裸字面常量(raw numeric literal),本来它们应 该是具名常量(named constant)才对。 class Portfolio { // ... Contract *contracts_[10]; char id_[10]; }; 幻数带来的主要问题是它们没有(抽象)语义,它们只是个量罢了。一个“10”就是一个 “10”,你看不出它的意思是“合同的数量”或是“标识符的长度”。这就是为什么当我们阅 读和维护带有幻数的代码时,不得不一个个地去搞清楚每个光秃秃的量到底代表的是什么意 思。没错,这样也能勉强度日,但带来的是不必要的精力浪费以及准确性的牺牲。 就拿上面这个设计得很差的表示公文包(portfolio)的型别来说,它能够管理最多 10 个合同。 当合同数愈来愈多的时候(10 个不够用了),我们决定把合同数增加至 32 个。(如果你对安 全性和正确性很挑剔,那顶好是改用 STL 中的 vector 组件。)我们立刻陷入了困境,因为 必须一个个去检查那些用了 Portfolio 型别的源文件里出现的每一个字面常量“10”,并 常见错误 2:幻数 5 逐个甄别每个“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”之间费劲地识别出它们各自的意思并分别处理。(译注:如果“最大合 同数”和“标识符的最大长度”变成了不同的数,上述的初始化循环就要从一个变成两个了。 而事实上就应该是两个毫不相关的初始化循环,由于值碰巧是同一个而耦合了它们,这也是 幻数背后的临时观念导致的不良编码实践习惯。)当这一切头疼的事有着极为简单的解决方 案时,我们真的没有理由不去做: class Portfolio { // ... enum { maxContracts = 10, idlen = 10 }; Contract *contracts_[maxContracts]; char id_[idlen]; }; 在其所在辖域有着明确含义的枚举常量同时还有着不占空间,也没有任何运行期成本的巨 大优点。 幻数的一个不那么显而易见的坏处是它会以意想不到的方式降低它所代表的型别的精度,它 们也不占有相应的存储空间(译注:具名常量则理论上占有存储空间,尽管一般会经由常量 折叠予以消除)。拿字面常量 40000 来说,它的实际型别是平台相关的。如果 int 型别尺寸 的内存能把它塞下,它就是 int 型别的。要是塞不下呢,它就成了 long 型别的。要是我 们不想在平台移植的当口引狼入室(想想根据型别进行的函数重载解析规则在这里能把我们 逼疯的情形!),我们还是老老实实地自己指定型别吧,这比让编译器或平台替我们做这件事 要好得远: const long patienceLimit = 40000; 第一章 基础问题 6 另一个字面常量带来的潜在威胁来源于它们没有地址这件事。好吧,就算这不是个会天天发 生的问题,但是有的时候将引用绑定到常量是有其作用的。 const long *p1 = &40000; // 错误!(译注:字面常量无法取址,它们没有地址。) const long *p2 = &patienceLimit; // 没问题 const long &r1 = 40000; // 合法,不过常见错误 44 会告诉你另一些精彩故事 const long &r2 = patienceLimit; // 没问题 幻数有百害而无一利。请使用枚举常量或初始化了的具名常量。 常见错误 3:全局变量 很难找到任何理由去硬生生地声明什么全局变量。全局变量阻碍了代码重用,而且使代码变 得更难维护。它们阻碍重用是因为任何使用了全局变量的代码就立刻与之耦合,这使得全局 变量一改它们也非得跟着改,从而使任何重用都不可能了。它们使代码变得更难维护的原因 是很难甄别出哪些代码用了某个特定的全局变量,因为任何代码都有访问它们的权限。 全局变量增加了模块间的耦合,因为它们往往作为幼稚的模块间消息传递机制的设施存在。 就算它们能担此重任,从实践角度来说,(译注:如果要改变机制,不再使用某些全局变量 的话)要从大型软件的源代码中去掉任何全局变量都几乎不可能。(译注:此型别全局变量 会分散在各个源文件里并在各个地方使用,块间耦合度因而就变得很高了。)这还是说他们 能正常工作的情况。不过可不要忘了,全局变量是不设防的。随便哪个维护你代码的 C++ 新手都能让你对全局变量有强烈依赖的软件所玩的把戏随时坍台。 全局变量的辩护者们经常拿它的“方便”来说事。这真是自私自利之徒的无耻之争。要知道, 软件的维护常常比它的初次开发要花费更多时间,而使用全局变量就意味着把烂摊子扔给了 维护工程师。假设我们有一个系统,它有一个全局可访问的“环境”,并且(我们按需求保 证)确实只有“一个”。不幸的是,我们选择了使用全局变量(来表示它): extern Environment * const theEnv; 我们的需求一时如此,但马上就行不通了。在软件就要交付之前,我们会发现,可能同时存 在的环境要增加到两个、或三个、或是在系统启动时指定的、或根本就是完全动态的某个数。 这种在软件发布的最后时刻发生的变更实属家常便饭。在备有无微不至的源代码控制过程的 大项目里,这个变更会引发极费时间、涉及所有源文件的更改,即使在最细小的和最直截了 当的那些地方也不例外。整个过程预计要几天到几星期不等。假如我们不用全局变量这个灾 星,只要五分钟我们就能搞掂这一切: Environment *theEnv(); 仅仅是把对于值的访问加了函数的包装,我们就获得了可贵的可扩充性。要是再加上函数 重载,或是给予函数形式引数以默认值,我们就根本不要怎么改源代码了。 常见错误 3:全局变量 7 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)的一个简单实现(译注: 参见(Meyers, 2006),条款 4,那里有一个更漂亮的单件设计模式的实现。有关控制对象数量 ——不仅仅是限制为 1 个的更深入讨论,参见(Meyers, 2007),条款 26),以所谓“缓式求值” 形式完成静态指针的初始化动作(如果一定要在技术上钻钻牛角尖的话,好吧,这是赋值, 不是初始化)。是故,我们能够保证 Environment 对象的数量不会超过一个。请注意, 第一章 基础问题 8 Environment 型别没有给予其构造函数 public 访问层级,所以 Environment 型别的 用户只能用它公开出来的 instance()成员函数来取得这个静态指针。而且,我们不必在 第一次访问 Environment 对象之前就创建它(译注:这符合 C++“不为用不到的东西付 出成本”的语言设计哲学): 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。所以,上述两个函数调用产生的动作序列(译注:即 产生的目标代码)是完全相同的。 gotcha04/c12.h class C2 { public: 常见错误 5:对引用的认识误区 9 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 型别的形式引数。(译注:因而编 译器找不到不带形式引数的 f1()。) 函数重载主要用于一组抽象意义相同,但实现不同的函数。而形式引数默认值主要出于简 化考量,为函数提供更简洁的接口。(译注:也就是能让函数在被调用时少指定几个引数, 不用老是反复地指定几个相同值的引数。)函数重载和形式引数默认值是两个毫不相干的语 言特征,它们出于不同的目的而设计,行为也完全不同。请仔细地区分它们。(更详细的信 息请参见常见错误 73 和 74)(译注:参见(Meyers, 2001),条款 24,它用更具体的例子说明 了本章讨论的问题。) 常见错误 5:对引用的认识误区 对于引用的使用,主要存在两个常见的问题。首先,它们经常和指针搞混。其次,它们未被 充分利用。好多在 C++工程里使用的指针实际上只是 C 阵营那些老顽固的杰作,该是引用 第一章 基础问题 10 翻身的时候了。 引用并非指针。引用只是其初始化物的别名。记好了,能对引用做的唯一操作就是初始化 它。一旦初始化结束,引用就是其初始化物的另一种写法罢了。(凡事皆有例外,请看常见 错误 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)复杂表达式都能作为引用的初始化物。 常见错误 5:对引用的认识误区 11 int &el = array[n-6][m-2]; el = el*n-3; string &name = p->info[n].name; if( name == "Joe" ) process( name ); 如果函数的返回值具有引用型别,这就意味着可以对该函数的返回值重新赋值。一个经常被 津津乐道的典型例子是表示数组之抽象数据型别的索引函数(index function)(译注:即不 带常量性的那个 operator []()成员运算符)。 gotcha05/array.h template class Array { public: T &operator [](int i){ return a_[i]; } const T &operator [](int i) const{ return a_[i]; } // ... private: T a_[n]; }; 那个引用返回值就使得对数组元素的赋值在语法上颇为自然了: Arrayia; ia[3] = ia[0]; 引用的另一个用途,就是可以让函数在其返回值之外多传递几个值: Name *lookup( const string &id, Failure &reason ); // ... string ident; // ... Failure reasonForFailure; if( Name *n = lookup( ident, reasonForFailure ) ) { // 查找成功则执行的例程 } else { // 如果查找失败,那么由 reasonForFailure 的值返回错误代号 第一章 基础问题 12 } 在对象身上实施目标型别为引用型别的强制型别转换操作的话,其效果与用非引用的相同型 别进行的强制型别转换有殊为不同的效果: char *cp = reinterpret_cast(a); reinterpret_cast(a) = cp; 在上述代码的第一行里,我们对一个 int 型变量实施了到指针型别强制型别转换。(我们在 这里使用了 reinterpret_cast 运算符,这好过使用形如“(char *) a”的旧式强制 型别转换操作。要想知道这是出于何种考量,请看常见错误 40。)这个操作的详细情况分解 如下:一个 int 型变量的值被存储于一个副本中,并随即被(按位)当作指针型别来解释。 (译注:作者想强调的是,a 的值首先被复制到一个临时对象中,reinterpret_cast 的 操作数并非 a 本身,而是 a 的这个副本,也就这个临时对象。) 而第二个强制型别转换操作则是完全另一番景象。转换成引用型别的强制型别转换操作的意 义是把 int 型变量本身解释成指针型别,成为左值的是这个变量本身(译注:而不是什么 复制而成的临时对象了),我们继而可以对它赋值。也许这个操作会引发一次核心转储(dump core 俗称“吐核”,也就是操作系统级的崩溃),不过那不是我们现在谈论的主题,再说,使 用 reinterpret_cast 本身也就暗示着该操作没把可移植性纳入考量。和上述形式差不 多的、没有转换成引用型别的强制型别转换操作的一次赋值尝试则会无可挽回地失败,因为 这样的强制型别转换操作的结果是右值而不是左值。(译注:这一段非常重要,请仔细阅读 以真正领会它的意思。它主要说了这么一件事:转换成引用型别的强制型别转换操作的操作 数是对象本身,因而它是个左值。否则它的操作数就是一个临时对象,而对临时对象赋值是 没有任何意义的,只能引起概念混乱,所以 C++语言把这样的结果规定为右值,禁止进行赋 值。这就像 int 型变量 a、b 相加的表达式 a+b 的结果也是一个临时对象,因而不能对它 赋值是同一个道理。) reinterpret_cast(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 ); 常见错误 6:对常量(性)的认识误区 13 int (* const pf)(double) = f; // pf 是指涉到函数 f()的常量指针 int (&rf)(double) = f; // rf 是函数 f()的引用 指涉到函数的常量指针和函数的引用从编码实践角度来看,并无很大不同。除了一点,那就 是指针可以显式地使用提领语法,而对引用是不能使用显式提领语法的,除非它被隐式转换 成指涉到函数的指针。(译注:以下这六行代码主要想说明,C++的函数调用语法很灵活, 无论是通过使用函数名本身、指涉到函数的指针还是函数的引用来调用函数,都既可以用名 字本身,也可以使用提领语法。尽管后两行在语法上其实是经过了一个隐式型别转换,因而 会带来效率上的损失。) 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; // 错误! 第一章 基础问题 14 最好仔细考虑一下上面 ip2 的声明中出现的 const,这个饰词描述了对我们通过 ip2 对 ci 的操作的约束,而不是对于 ci 的一般操作的约束。(译注:也就是说,变量本身不具常 量性,具有常量性的是通过指针提领的那个能够作为左值的表达式。虽然这两者从观念上来 看,是同一个对象。这就让我们理解,C++的常量性不是根据低级地址绑定的,而是富有高 级的对象观念的。)如果我们想声明指涉到常量的指针,我们应该这么办: 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 的值肯定还是会通过其它途径发生变更。(译注: 这个例子说明了 C++里的常量性的观念只是限制了在代码中对 const 修饰的变量显式的直 接修改,对于其它方式的修改,并不是 C++语言中的常量性所要求的。总体来看,本文指出 了常量性是高级的操作。) 常见错误 7:无视基础语言的精妙之处 大多数 C++软件工程师都自信满满地认为自己对所谓 C++的“基础语言”,也就是 C++继承 自 C 语言的那部分了如指掌。实际情况是,即使经验丰富的 C++软件工程师有时也会对最 基础的 C/C++语句和运算符的某些妙用一无所知。 逻辑运算符不能算难懂,对吗?但刚入行的 C++软件工程师却总是不能让它们物尽其用。你 看到下面的代码时是不是会怒从胆边生? bool r = false; if( a < b ) r = true; 正解如下: bool r = a12),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: ; } 这样的代码看起来有点傻(容我直言,确实很傻),但是这种对于基础语言边角部分的理解, 第一章 基础问题 18 有时候相当有用。比如利用上述的 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 访问层级并非不可见,只是不能访问罢了。如同一切可见而不可 及的事物一样(经理的形象跃入脑海),他们总是惹出各种麻烦。 常见错误 8:未能区分可访问性和可见性 19 最显而易见的问题就是即使是 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_的指涉 到数据成员的指针(译注:若不重新编译的话)将统统失效。顺便说一句,该 class 对象 之成员初始化列表的行为是未可预期的,b_被初始化成了一个未有定义的值。(欲知详情, 请参见常见错误 52。) 而最主要的不可见变化,在于编译器隐式提供的复制构造函数和赋值运算符的语义。默认地, 这些函数被定义成 inline 的。是故,它们编译后的代码就会被插入任何使用一个 C 对象 来初始化另一个 C 对象、或是使用一个 C 对象给另一个 C 对象赋值的代码中。(常见错误 49 里提及了有关这些函数的更多信息。) 这个对 class C 简单的修改带来的最主要的结果(让我们把上面提到的一个引入的缺陷暂 时搁下不提),就是几乎所有用到过 class C 的代码统统需要重新编译过。如果是大项目, 这种重新编译可能会旷日持久。如果 class C 是在一个头文件里定义的,所有包含了这个 源文件的代码都(连带地)需要重新编译过。有一个办法能够缓解此一境况,那就是使用 class C 的前置声明。具体做法倒也简明,就是当不需要除名字以外的其它信息时,像下 面这样写一句非完整的 class 声明: class C; 就是这么一句平凡的、非完整的声明语句,使得我们仍然可以声明基于 class C 的指针和 引用,前提是我们不进行任何需要 class C 的尺寸和成员的名称的操作(译注:如指针算 术),包括那些继承自 class C 的派生类初始化 class C 部分子对象的操作。(可是你看 第一章 基础问题 20 看常见错误 39,凡事皆有例外。) 这种手段可谓行之有效,不过要想免吃维护阶段的苦头,还要谨记严格区分“仅提供非完整 的 class 声明”和“提供完整 class 定义”的代码,不要把它们写到同一个源文件中去。 也就是说,想为复杂冗长的 class 定义提供上述轻量级替身的软件工程师,请不要忘记提 供一个放置各种适当前置声明的(译注:而不放置 class 定义的)专用头文件。 比如上例中,如果 class C 的完整定义是放在 c.h 这个头文件中的,我们就会考虑提供一 个 cfwd.h,里面只放置非完整的 class 声明。如果所有的应用都用不着 C 的完整定义,那 末包含 c.h 就不如包含 cfwd.h。这样做有什么好处呢?因为 C 这个名字的涵义在未来可能会 发生变化,使得一个简单的前置声明不容于新环境。比如,C 可能会在未来的实现中成为 typedef: template class Cbase{ // ... }; typedef Cbase C; 很清楚,那个头文件 c.h 的作者是在尽力避免当前 class C 的用户去修改他们的源代码(译 注:这个可怜的作者是想造出一种假象,使得像“C c;”这样的语句能够继续合法,遗憾 的是任何假象都会在某些情况下被揭穿),不过,任何在包含了 c.h 以后还想继续使用“C 的不完整声明”的企图都会触发毫不留情的编译期错误: #include "c.h" // ... class C; // 错误!C 现在不再是 class 的名字,而是 typedef。 因而,如果提供了一个前置声明专用头文件 cfwd.h 的话,这样问题就根本不会出现了。(译 注:那些根本用不着 C 的完整定义的代码才不管 C 是 class 还是 typedef。)所以,这个 锦囊妙计就在标准库中催生了 iosfwd,它就是(人尽皆知的)iostream 头文件对应的前置声 明专用头文件。 更为常见的是,由于必须对使用了 class C 的代码进行重新编译,结果就使得对已经部署 了软件打补丁这件事很难做。这么一来,也许最管用的解决方案就是把 class 的接口与其 实现分离,从而要达到真正的数据隐藏之境,而其不二法门则是运用桥接设计模式(Bridge Pattern)。 桥接设计模式需要把目标型别分为两个部分,也就是接口部分和实现部分: gotcha08/cbridge.h class C { public: 常见错误 8:未能区分可访问性和可见性 21 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 的代码不可见了。(译注:使用了桥接 设计模式以后,有关 class C 的代码就不再修改了,修改的只是实现类,也就是 class Cimpl 的代码。这样,使用了 class C 的代码就不必重新编译,因为对于 C 来说,内存 布局没有发生任何变化。这也就是 Herb Sutter 所谓的“编译防火墙”,参见(Sutter, 2003), §4。)任何对于 class C 实现的修改(译注:亦即对于 class Cimpl 的修改),只要不 改变 class C 的接口(译注:亦即具有 public 访问层级成员函数),影响就会被牢牢地 箝制在一个单独的实现文件里了。(译注:这就通过修改了实现的可见性,给软件工程师带 来了可贵的最小编译代价和变化影响的可控性,也就是让使用了 class C 的代码“眼不见, 心不烦”。) 运用桥接模式显然要付出一些运行时的成本,因为一个 class C 对象现在需要用两个对象, 而不是一个对象来表示了,而且调用所有的成员函数的动作现在由于是间接调用,也做不成 inline 的了。无论如何,它带来的好处是大幅节省了编译时间,而且不必重新编译就能发 布使用了 class C 的代码的更新。这在大多数情况下,可谓物美价廉。 此项技术已经被广泛应用多年,因而也被冠以数种趣名,如“pimpl 习惯用法”和“柴郡猫 第一章 基础问题 22 技术”(Cheshire Cat technique)(译注:“柴郡猫”大致相当于“神龙见首不见尾”的涵义) 之美誉。(译注:关于此主题的更多信息,参见(Meyers, 2006),条款 31。) 不可访问的成员在通过继承接口访问时,会造成派生类成员和基类成员的语义发生变化。考 虑如下的基类和派生类: class B { public: void g(); private: virtual void f(); // 新添加的代码 }; class D : public B { public: void f(); private: double g; // 新添加的代码 }; 在 class B 这个基类中添加了一个私有访问的虚函数,导致了原先派生类中的非虚函数变 成了虚函数;添加在 class D 中的私有访问的数据成员则遮掩了 B 中的一个函数。这就是 为什么继承常常被视为“白盒”复用(译注:亦即源代码必须可见的情况下才能进行的复用), 因为对 class 的任何修改都在非常基本的层面同时影响着基类和派生类的语义。 一种能够削弱此类问题的方法,是采用一种简明的、根据功能划分名字的命名规范。典型 的办法是为型别的名字、私有访问的数据成员的名字或其它什么东西的名字使用不同的规范 以示区分。在本书中,我们的规范是使用全大写的型别名字,并在数据成员的后面附加一个 下划线(它们应该都只有 private 访问层级成员函数!),而对于其它的名字(除一些特例 外)我们用小写字母打头的名字。如果遵守这样的规范,我们在上面的例子中就不会意外地 遮掩基类中的成员函数了。不过,最要紧的是不要建立极复杂的命名规范,因为如此规范往 往形同具文。 此外,绝对不要让变量的型别成为其名字的一部分。比如,把一个整型变量 index 命名为 iIndex 是对代码的理解和维护主动搞破坏。首先,名字应该描述实体的抽象意义,而不是 它的实现细节(抽象性甚至在内建型别中就已经发挥了它的影响)。再有,大多数的情况下, 变量的型别改变的时候,它的名字不会同步地跟着其型别变化。这么一来,变量的名字就成 了迷惑维护工程师有关其型别信息的利器。 其它方法在一些别的地方时有讨论,特别在常见错误 70、73、74 和 77 中为最多。 常见错误 9:使用糟糕的语言 当一个更大的世界入侵了 C++社群原本悠然自得的乐土之时,它们带来了一些足堪天谴的语 常见错误 9:使用糟糕的语言 23 言和编码实践。此错误乃是为了厘清返璞归真的 C++语言所使用的正确适当、堪称典范之用 语和行为。 用语 表 1-1 列出了最常见的用语错误,以及它们对应的正确形式。 没有什么所谓“纯虚基类”。纯虚函数是有的,而包含有、或是未能改写(override)此种函 数的类,我们(并不叫它“纯虚基类”,而是)叫它“抽象类”。 C++语言中是没有“方法”的。Java 和 Smalltalk 里才有方法一说。当你颇带着一丝自命不 凡地就面向对象的话题侃侃而谈之时,你可能使用像“消息”和“方法”这种用语。但如果 你开始脚踏实地,开始讨论你的设计对应的 C++实现时,最好还是使用“函数调用”或“成 员函数”来表达。 一些不足为信的 C++专家(是在说你吗?)使用“destructed”作为“constructed”的对应词。 这明显是英语没学好(译注:destruct 是非及物动词,不可能有-ed 分词形式)。正确的对应 词是“destroyed”。 C++语言中确实有强制型别转换(或曰型别转换)运算符——事实上(只)有四个 (static_cast、dynamic_cast、const_cast 以及 reinterpret_cast)。遗憾的 是,“强制型别转换运算符”常常被不正确地用于表达“成员型别转换运算符”,而后者指定 了某种对象何以被隐式地转换到另外的型别。 class C { operator int *()const; // (成员)型别转换运算符 //... }; 表 1-1|常见用语错误及其对应正确用语 错误用法 正确用法 (译注: 错误用法中译) (译注: 对应正确中文用法) Pure virtual base class Abstract class 纯虚基类 抽象类 Method Member function 方法 成员函数 Virtual method ??? 虚方法 ??? Destructed Destroyed ??? ??? Cast operator Conversion operator 强制型别转换运 算符 (成员)型别转换运算符 当然用强制转换运算符来完成型别转换的工作也是允许的,只要你不把用语搞混就成。 第一章 基础问题 24 请参见常见错误 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++语言保证可移植性和正 确性的用法。(译注:请参见(Stroustrup, 2002),§11.2.3。)现在,C++标准规定像(void *)0 这样的定义是不允许的(译注:请参见(Koenig, 1996),§16.3.5),可见这是个和 NULL 的使 用并无多大干系的技术问题(如若不然,NULL 岂不是成了格外受人青睐的预处理符号?其 实它是普通不过的)。可是,真正领会了 C++语言精神的软件工程师仍然使用(字面常量) 0。任何其它用法都会使你显得相当非主流。 缩略词 C++软件工程师都有缩略词强迫症,不过与管理层相比,可谓小巫见大巫。表 1-2 在你的同 事给你来上一句“RVO 将不会应用到 POD 上,所以你最好自己写个自定义的复制 ctor”时 能派上用场。 表 1-2|常用缩略词的意思 缩略词 完整形式 (译注:完整形式中译) POD Plain old data, a C struct 和 C 语言连接兼容的结构 常见错误 10:无视(久经考验的)习惯用法 25 POF Plain old function, a C function 和 C 语言连接兼容的函数 RVO Return value optimization 返回值优化 NRV Named RVO 具名的返回值优化 Ctor Constructor 构造函数 Dtor Destructor 析构函数 常见错误 10:无视(久经考验的)习惯用法 “很早就有人发现,最杰出的作家有时对修辞学的条条框框置若罔顾。0 不过,每当他们这么 做的时候,读者总能在这样的语句中找到一些补偿的闪光点,以抵消违反规则带来的代价。 也只有他们确信有这样的效果存在他们才这么做,否则他们会尽一切可能去因循那些既成的 规则。”( Strunk 和 White,The Elements of Style)1 以上这条被经常引用的、对于英语散文写作的经典导引,却常常在指导软件开发中的文本书 写风格方面时也屡试不爽。我对本条金科玉律以及背后的深邃思想心悦诚服。不过,我对它 还有不甚满意的方面,那就是它在脱离了上下文的前提下,并未揭示为何通常情况下因循修 辞学的既成规则是事半功倍的,也未有阐明这些既成规则究竟是怎么来的。相比 Strunk 高 高在上的圣断,我倒更对 White 的朴实无华的“牛道”之比喻情有独衷。 仍在使用中的语言就像是奶牛群行经的道路:它的缔造者即奶牛群本身(世上本没有路,走的 牛多了,也就成了路。),而这些缔造道路的奶牛们在踏出这条道路以后,或是一时兴起, 或是实际有需,有时继续沿着它走,有时则大大偏离。日复一日,这道路也历经沧桑变迁。对 于一头特定的奶牛而言,它没有义务非得沿着它亲身参与缔造的羊肠小道框出的轮廓行走不可。 不过它如果因循而行,常常会因此得益,而若非如此,则不免会因为不知身处何处和不知行往 何方而给自己平添障碍。(E. B. White,The New Yorker 刊载文章) 软件开发的程序语言并不像自然语言那般复杂,因而我们 “撰写清晰代码” 的这一目标肯 定比“书写漂亮的(自然语言的)句子”要容易企及。当然,像 C++那样的语言的复杂性已 经使得其开发软件时的效能与一套标准用法和习惯用法紧密相依。C++语言的设计大可不拘 小节,它给予软件工程师足够的弹性。但是,那些久经考验的习惯用法为开发的效率和清晰 的交流开启了方便之门。对于这些习惯用法的无意忽视甚至有意违背,无异是对误解和误用 的开门揖盗。 很多本书中的建议都包括了发掘和运用 C++编码和设计中的习惯用法。很多这里列举的常见 错误都可以直接视作对某个 C++习惯用法的背离。而针对它们提出的正解,则又经常可以看 成是向相应习惯用法的归顺。出现这种情况有个好理由:有关 C++编码和设计中的习惯用法 乃是 C++软件工程师社群的所有人一起总结并不断地加以完善的。那些不管用的或是已经过 气的方法会逐渐失宠直至被抛弃。能够得以流传的习惯用法,都是那些持续演化以适应它们 的应用环境的。意识到,并积极运用 C++编码和设计中的习惯用法是产出清晰、有效和可维 护的 C++代码和设计的最确信无疑的坦途之一。 1 这段引言的实际作者是 William Strunk,在本书的原始卷宗里就已经有这段话了。但是, 这本书的再次风靡是 1959 年 White 的功劳。 第一章 基础问题 26 作为称职的专业软件工程师,我们可要时时刻刻告诫自己我们平常撰写的代码和设计都是被 涵盖在某个习惯用法的上下文里了。(译注:这是真正的大师经验之谈,这种时时刻刻准备 套用习惯用法的良好实践,不仅可以使我们摆脱从头再造轮子的重复之累,更能使我们从别 人擅长的技术中受益,并集中精力在自己擅长的问题上,十分符合分工互惠的经济学原理。 有无这种职业敏感是专业软件工程师和业余代码写手的极大不同,后者比较热衷于自己写点 新鲜的东西,自诩“创新”,但是专业性却不敢恭维。)一旦我们识别出了某种编码和设计中 的习惯用法,我们既可以选择呆在它为我们营造的安乐小窝里,也可以选择在理性思考后为 自己的特殊需要而暂时背离它。无论如何,大多数情况下我们还是因循守旧一点好。有一点 可以肯定的是,如果我们连半点儿也没有意识到什么习惯用法的存在,我们的半路迷途就是 注定的了。 我并不想在不经意间给你留下“C++软件开发中的习惯用法就像讨厌的紧身衣一样把设计流 程的方方面面绑得死死的”这么个印象。远非如此。恰当运用习惯用法会让你的设计流程和 有关设计的交流变得极其简化,给设计师们留下了发挥其创作天赋的无尽空间。也有这样的 时候,哪怕是最合理的、最司空见惯的软件开发中的习惯用法都会在碰到某种设计时不合用。 遇到这种情况,设计师就不得不另辟蹊径了。 最常用,也是最有用的 C++语言的习惯用法之一就是复制操作的习惯用法。所有 C++里的 抽象数据型别都需要做出有关它的赋值运算符和复制构造函数的决定,那就是是允许编译器 自行生成它们、还是软件工程师自己手写它们,还是干脆禁止对它们的访问。(参见常见错 误 49。) 如果软件工程师打算写这些操作,我们很清楚应该怎么写。当然了,编写这些操作的“标准” 方法在过去的很多年里是不断演化的。这是习惯用法并非恣意妄为的好处之一:它们总是朝 着适应当下用法趋势的方向演化。 class X { public: X( const X & ); X &operator =( const X & ); // ... }; 虽然 C++语言在如何定义复制操作这方面留下了很大的发挥空间,但是把它们像上面几行代 码展示的那样声明却几乎肯定是个好主意:两个操作(译注:完成复制的操作是由两个函数 来完成的,一个是复制构造函数,还有一个是赋值运算符)都以一个指涉到常量的引用为引 数,赋值运算符不是虚函数(译注:而复制构造函数不允许声明为虚函数),返回值是指涉 到非常量的引用型别(译注:应该返回指涉到*this 的引用,请参考(Meyers, 2006),条款 10)。显然,这些操作中的任何一个都不应该修改其操作数。如果它们修改了,这是让人莫 名其妙的。 X a; X b( a ); // a 不会被修改 a = b; // b 不会被修改 常见错误 10:无视(久经考验的)习惯用法 27 „„除了某些情况。 C++标准库里的 auto_ptr 模板就比较特立独行。这是个资源句柄, 它能够在从堆上分配的存储不再有用时,把这些存储的善后清理工作做好。 void f() { auto_ptr blob( new Blob ); // ... // 在此处把分配给 Blob 型别对象的存储自动清除 } 好极了。不过如果那些还在念书的实习生们写下这样大大咧咧的代码,可如何是好? void g( auto_ptr arg ) { // ... // 在此处把分配给 Blob 型别对象的存储自动清除 } void f() { auto_ptr blob( new Blob ); g( blob ); // 哎呀,在此处把分配给 Blob 型别对象的存储又清除了一遍! } 一种解决之道是把 auto_ptr 的复制操作彻底禁止,但这么一来就会严重限制它的用途, 也使得好多 auto_ptr 的习惯用法化为泡影。另一种是为 auto_ptr 装备引用计数,但那 么一来,使用资源句柄的代价就将膨胀。所以,标准的 auto_ptr 采取的做法是故意地背 离了复制操作的习惯用法: template class auto_ptr { public: auto_ptr( auto_ptr & ); auto_ptr &operator =( auto_ptr & ); // ... private: T *object_; }; (标准的 auto_ptr 还实现了这些非模板复制操作对应的模板成员函数,但是经验是相似 的,参见常见错误 88。)这里,操作符右边的操作数引数并不具有常量性!当一个 auto_ptr 使用另一个 auto_ptr 对象初始化或被赋值为另一个 auto_ptr 对象时,这个用于初始化 或赋值的源对象便中止了对它指涉的从堆上分配的对象的所有权,具体做法是把它内部原 本指涉到对象的指针置空。 就像背离了习惯用法通常所发生的那样,对于如何用好 auto_ptr 对象从一开始就引起了 第一章 基础问题 28 不少困惑。当然,这个对已经存在的习惯用法的背离也搞出了不少多产的、围绕着所用权议 题的新用法,而将 auto_ptr 对象用作数据的“源”和“汇”看起来成为了获利颇丰的新 的设计领域。从效果上说,对已经存在的、业已成功的习惯用法采取了深思熟虑的背离,反 而产生了一系列新的习惯用法。 常见错误 11:聪明反被聪明误 C++语言和 C 语言看起来会吸引相当多的人去张扬个性(你有没有听说过一个叫“Obfuscated Eiffel”的比赛?)(译注:这是一个以恶搞为能事的比赛,缘起是 Perl 语言,后发展至 C 语 言。参赛者比的是谁能把合法的代码写得最难看懂、或是排版成各种花样。)。在这些软件工 程师的思维里,两点间的最短距离是普通欧氏空间之球面扭曲上的大圆。 试举一例:在 C++语言的圈子里(且不论这个圈子是不是普通欧氏空间里的),代码的排版 格式纯粹是为了方便解读代码的人类的,而对于代码(译注:对机器而言)的意义,只要语 汇块的次序还是按原先的次序的依次出现,就怎么都无所谓。这最后一个附加条款殊为重要, 比如,以下这两行表示的是非常不同的意思(译注:这两行的“字面字符”是完全相同的, 但这并不意味着它们表示着同样的“语汇块”)(但是请看常见错误 87)。 a+++++b; // 错误!(译注:本行等价于 a++ ++ + b,而 a++不是一个左值。) a+++ ++b; // 没问题 以下两行也是同出一辙(参见常见错误 17): ptr->*m; // 没问题 ptr-> *m; // 错误!(译注:->*合起来才是一个运算符。) 上面的例子容易让大多数 C++软件工程师同意,只要注意不去趟语汇块划分错误的浑水,代 码的排版格式就再次高枕无忧地和代码的意义无关了。因此,把一个声明变量的语句写在一 行里还是分成两行写,结果别无二致。(有一些软件开发环境的调试器以及其它工具组件是 依据代码的行数,而不是其它更精确的定位逻辑来实现的。这样的工具经常强迫软件工程师 去把本来可以写在一行里的代码硬分成既不自然也不方便的数行来写,以得到更精准的错误 提示错误,或是设置更精准的断点,等等。这不是 C++语言的毛病,而是 C++软件开发环 境的作者的毛病。) long curLine = __LINE__; // 取得当前行数值 long curLine = __LINE__ ; // 同样的声明(译注:但是,结果变得毫无意义了。) 绝大多数的 C++软件工程师们(译注:在这一点上)都会犯错。让我们看一个平凡的用模板 元编程实现的可以在编译期遴选一种型别的设施: 常见错误 11:聪明反被聪明误 29 gotcha11/select.h template struct Select { typedef A Result; }; template struct Select { 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)的那一行里,以上的声明会导致什么结果? 第一章 基础问题 30 curLine 的型别会被声明为 char,并被初始化为 char 型别的最大值。随着我们把初始化 源放到了下一行,我们就会把 curLine 的值初始化为 char 型别的最大值还要大 1 的数。 这个结果很可能会指出,当前行数是一个负数(比如-128)(译注:在硬件采用补码编码的 机器上就会如此,比如 IBM PC 架构的机器)。多么聪明啊! 聪明反被聪明误在 C++软件工程师身上算一个常见的问题。请时刻牢记,几乎在所有的场合 下,遵循习惯用法、清晰的表达和一点点效率的折损都好过小聪明、模棱两可和维护便利 的丧失。 常见错误 12:嘴上无毛,办事不牢 我们软件工程师在提出建议方面是巨人,但一到行动的时候就成了矮子。我们不懈地奉劝人 家不要使用全局变量、不好的变量名称、幻数等等,但在自己的代码里却常常放入这些东西。 这种现象使我困惑多年,直到有一次偶然读到一本描写青少年行为学的杂志时才豁然开朗。 对于青少年来说,指责别人的冒险行为是常事,但是他们常常有一种“个人幻想”,相信他 们自己对相同行为的一切负面效应都具有免疫力。那末我可以说,作为一个群体来说,软件 工程师看来是深受情商欠佳之苦的。 我曾经带过这么一些项目。在这些项目里有些软件工程师不仅拒绝服从编码规范,甚至会因 为被要求缩进四个空格而不是两个这样的小事而威胁要退出团队。我曾经面临过这样的境遇: 在软件开发会议上,只要有一个派系的人参加,另一个就不参加。我曾经见过这样的软件工 程师:他们故意地写没有文档的、令人费解的代码,这样其它人就没法去动这些代码了。我 曾经见过这样根本不合格的软件工程师:他们拒绝接受比他们年长——或比他们年幼、或说 话太直、或太吹毛求疵——的同事的任何意见,并引起灾难性的结果。 无论在情商意义上是年少轻狂亦或成熟稳重,作为一个专业的软件工程师,我们都有一些数 量的成人的——或至少是专业的——责任。(参见美国计算机器协会在 ACM Code of Ethics and Professional Conduct 和 Software Engineering Code of Ethics and Professional Practice 对此 类问题所持观点。) 首先,我们对我们自己选择的专业负有责任。从而我们应该做出有质量的工作,并在我们的 能力范围内做到最高的标准。 其次,我们对身处的社会和居住的星球负有责任。我们选择的专业在科学研究和实际服务 的方面都是平等一员。如果我们的工作不是为将身处的世界变得更好而作出贡献,我们就是 在浪费我们的才智和时间,而最终浪费的,是自己的生命。 第三,我们对参与的社群负有责任。所以我们应该共享我们的长处,来影响公共政策。在我 们这个愈来愈技术化的社会里,最重要的决策都是那些受法学或政治学教育的人作出的,但 那些人在技术方面一窍不通。比如,某个州曾经一度把 π 的近似值以法律形式规定为 3。这 很滑稽(当然,那些以轮胎为基础的交通工具在这条法律寿终正寝之前只能颠簸不已),但 我们看到的许多秘而不宣的政策就不那么好玩了。我们有义务告知那些政坛精英们作为政策 基础的理性、技术和在数目字上的来龙去脉。 常见错误 12:嘴上无毛,办事不牢 31 第四,我们对同事负有责任。所以我们应该有大度风范。这就包括我们应该遵守编码和设计 的“地方政策”(如果这些“地方政策”不好,我们应该变更它们而不是无视它们),写出易 于维护的代码,在表达我们自己意见的同时,也倾听别人怎么说。 这绝对不是让你随波逐流、装老好人,或是为鼓励屈从团队权威和沉湎市井俗见的愚见而摇 旗呐喊。我的一些最满意的专业协作就是和一些离经叛道、身居要职、行事诡异的独行侠们 共同完成的。但是这些值得珍惜的同事中的每一个都既尊重我,也尊重我的想法(他们在我 理应受嘉奖时不吝其辞,也在我犯错时直言不讳),在和我一起工作时努力完成那些我们商 议好了要完成的东西。 第五,我们对同行负有责任。从而,我们应该共享知识和经验。 最后,我们对自己负有责任。我们的工作和思想起码应该让我们自己感到满意,并让我们自 己觉得选择这一行情有可原。如果我们对我们从事的工作富有激情,如果我们从事的工作已 经融入成为我们自身的一部分,这些责任就不再是一种负担,而是一种快乐了。 第二章 语法问题 C++语言的语法和词法结构博大精深。此复杂性的一部分是从 C 语言那里继承而来的,另一 部分则是为支撑某些特定的语言特性所要求的。 本章中我们将考察一组语法相关的头疼问题。其中有些属于常见的手误,但是错误的代码仍 然能够通过编译,只不过会以出人意料的方式运行罢了。另外一些则是由于一段代码的语法 结构及它们的运行期行为不再互为表里。其余的部分,我们主要研究语法层面的灵活余地带 来的问题:明明是一字不差的代码,不同的软件工程师能从中得出大相径庭的结论来。 第二章 语法问题 34 常见错误 13:数组定义和值初始化的语法形式混淆 我们从堆上申请创建一个包含 12 个整数的数组,怎么样呀?没问题: int *ip = new int(12); 到目前为止似乎一切正常,那末让我们在数组上耍些花样。耍完以后,再把分配的内存予以 回收。 for (int i = 0; i < 12; ++i) ip[i] = i; delete [] ip; 注意我们用的那对空的中括号,它告知编译器 ip 指涉到的是一个包含一堆整数的数组,而 不是单个的一个整数。等等,事实真的是这样吗? 其实,ip 指涉到的恰恰是单个的一个整数,被初始化成了 12。我们犯了一个常见的手误, 把一对中括号打成了一对小括号。这么一来,循环体里就会出现非法访问(索引值大于 0 的部分统统如此),内存回收这句也行不通了。可是,没有几个编译器能在编译期就把这个 错误给逮出来。因为一个指针既可以指涉到单个的一个对象,也可以指涉到包含一堆对象的 数组,而且循环体内的索引语句和数组的内存回收语句在语法意义上可谓无懈可击。这么一 来,我们直到运行期才会意识到犯了错误。 也许连运行期都安然无恙。没错,访问对象数组所占空间结束之后的位置是非法的(虽然标 准保证了访问对象数组结束之后的一个对象元素是可以的)(译注:这是一个很容易被忽略 的重要补充说明。为什么要保证访问数组后的一个对象元素是能够做到的呢?这实际上是为 了和 for 语句的迭代算子习惯用法相一致,也是 STL 的“前闭后开区间”习惯用法相一致。 但是,如果用指针指涉到了这样的一个位置,它是不能被提领的。有关这个问题更详细的说 明,参见(Stroustrup, 2001),§5.3),把应用于数组的内存回收语法应用到并非数组的纯量 上也是非法的。但做了非法的事,并不意味着你就没有机会逍遥法外(想想华尔街操盘手们 干的勾当)。以上的代码在一些平台能够完美运行,但在另一些平台上则会在运行时崩溃, 在某些特定平台上还会玩出别的古怪花样来,到底会如何,就全看特定的线程或进程在运行 时是怎么操作堆内存了。正确的内存申请语法,当然如下所示: int *ip = new int[12]; 说不定,最好的内存申请形式就是根本不去自己做这个内存申请:直接用标准库中的组件 就好: std::vector iv(12); for (int i = 0; i < iv.size(); ++i) iv[i] = i; // 不用显式地回收内存(译注:也不用显式地申请内存) 常见错误 14:捉摸不定的评估求值次序 35 标准的 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(译注:i 先评估求值),也可能是 24、24(译注:i *= 2 先评估求值)。仔细点的软件工程师可能 会保证凡是在引数表里出现一次以上的变量,在传递时不改变其值。但是即使如此也并非万 无一失: int result2 = f(i, ri *= 2); // 不可移植 int result3 = f(p(), q()); // 危险„„ 在第一种情况下,ri 是 i 的别名。所以,result2 的值和 result1 一样徘徊于两可之间。 在第二种情况下,我们实际上假设了 p 和 q 以什么次序来评估求值是无关紧要的。即使当 前情况下这是成立的,我们也不能保证以后这就成立。问题在于,对于这个“p 和 q 以什么 次序来调用决定于编译器实现”的约束,我们在任何地方也没有文档说明。(译注:这里提 到的问题是特别值得国内的软件开发从业人员思考的,把即使是看起来不相关的两个函数的 返回值作为另一个函数的引数的值这样司空见惯的事都存在着一个隐含着的评估求值次序 问题,从而会给未来带来隐患。无论这种隐患是不是会实际发生,都必须在文档中写清楚做 了这样的一个假设,这 样才能说是专业的行为。这也才是国外的软件工业真正值得我们花大 力气去学习的地方——主要地不是学习如何用代码实现什么功能,而是在无数的细节方面保 证代码的质量,也正是这些方面拉开了我们的差距,要迎头赶上就必须从这些方面做起。) 第二章 语法问题 36 最好的做法是手动消除函数引数评估求值过程中的副作用: result1 = f(i, i * 2); result2 = f(i, ri*2); // 译注:如果需要把 i 的值乘以 2,可以在前或后插入 i*=2; int a = p(); result3 = f(a, q()); // 译注:这样就保证了 p 在 q 之前被调用,隐患被消除了 子表达式的评估求值次序 子表达式的评估求值次序也一样不固定: a = p() + q(); 函数 p 可能在 q 之前调用,也可能正相反。运算符的优先级和结合性对评估求值次序没有 影响。 a = p() + q() * r(); 三个函数 p、q 和 r 可能以 6 种(译注:푃3)次序中的任何一种被评估求值。乘法运算符相 对于加法运算符的高优先级只能保证 q 和 r 的返回值的积在被加到 p 的返回值上之前被评 估求值。同样的道理,加法运算符的左结合性也不决定下式中的 p、q 和 r 以何种次序被评 估求值,这个结合性只是保证了先对 p、q 的返回值之和评估求值,再把这个和与 r 的返回 值相加: a = p() + q() + r(); 加括号也无济于事: a = (p() + q()) * r(); p 和 q 返回值之和会先被计算出来,但 r 可能是(也可能不是)第一个被评估求值的函数。 唯一能保证固定的子表达式评估求值次序的做法就是使用显式的、软件工程师手工指定的中 间变量(译注:原文是 temporaries,为了不和 C++中的临时变量概念冲突,此处译为中间变 量,意为软件工程师而不是编译器指定的具有临时作用的变量。在此处,显然它有改变语义 的语法作用): a = p(); int b = q(); a = (a + b) * r(); 这样的问题出现的频率有多高呢?反正足以每年让一两个周末泡汤就是了。考虑图 2-1,一 个表示语法树的继承谱系片断,它被用来实现一个做算术运算的计算器。 常见错误 14:捉摸不定的评估求值次序 37 以下实现代码是不可移植的: 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 // 译注:测试用例可谓用心良苦!把可能的路径都考虑到了。 根据在 Plus::eval 中左子树和右子树谁先进行评估求值的次序之异,以上表达式的值既 可能是 24,也可能是 a 原先的值加上 12。如果我们规定该计算器的算术规则里,赋值运算 比加法运算优先,那末 Plus::eval 的实现必须用一个显式的中间变量来把评估求值次序 固定下来: gotcha14/e.cpp int Plus::eval() const { int lft = l_eval(); return lft + r_eval(); } 图 2-1 一个表示语法树的继承谱系图(简化版本),用于实现一个平凡的计算 器。一个加法结果有左子树和右子树,一个赋值结果只有单个的子树以表示 赋值语句的右手边操作数 Node eval() Assign id eval() Plus eval() 2 第二章 语法问题 38 定位 new 的评估求值次序 实话实说,这个问题倒并不是那么常出现的。new 运算符的定位语法允许不仅向申请内存的 对象的初始化函数(一般来说就是某个构造函数)传递引数,同时也向实际执行这个内存分 配的函数 operator new 传递引数。(译注:一般而言这是传递了一个地址,这时 operator new 不去做申请内存的工作,而是假定内存已经分配好了。不过,这只是标准的定位 new 的定义,它的函数声明大致形如“ void* operator new(std::size_t, void *pMemory) throw();”。定位 new 的一般定义里,这个 operator new 的额外引数并 不一定是地址,而可能是任何东西,也可能不止一个引数,本例就是如此。有关定位 new 的更多信息,参见(Meyers, 2006),条款 52 和(Sutter, 2003),条款 36。后一篇参考文献非常 晦涩难懂,但仔细读过一定受益匪浅。) Thing *pThing = new (getHeap(), getConstraint()) Thing(initval()); 第一个引数列表(译注:请特别注意,这是一个引数列表,而不是一个逗号表达式)被传递 给一个能够接受这样一些引数的 operator new。第二个引数列表被传递给了一个 Thing 型别的构造函数。注意,函数的评估求值次序问题在两个函数引数列表里都存在:我们不知 道 getHeap 和 getConstraint 中的哪一个函数会被先评估求值。犹有进者,我们连 operator new 和 Thing 型别的构造函数这两个函数引数列表中的哪一个列表会被先评估 求值(译注:甚至有可能交叉进行评估求值)都不得而知。当然,我们有把握的是 operator new 会比 Thing 型别的构造函数先调用(我们需要先为对象拿到存储,然后再去在这个存 储上初始化它)。 将评估求值次序固定下来的运算符 有些运算符有着与众不同的可靠性——如果把他们单独拿出来说的话,比如逗号运算符确实 能把其子表达式的评估求值次序给固定下来: result = expr1, expr2; 这个语句肯定先对 expr1 评估求值,再对 expr2 评估求值。然后把的评估求值结果赋给 result。逗号运算符会被滥用,导致一些诡异的代码: return f(), g(), h(); 上面这段代码的作者之情商有待提高。使用更加符合习惯的代码风格,除非你有意想要使维 护工程师陷入困惑: f(); 常见错误 14:捉摸不定的评估求值次序 39 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 就停下来。 如果三个结果都是 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()); 第二章 语法问题 40 不当的运算符重载 既然内建的运算符有着这么有用的语义,我们就不该试图去重载它们。对于 C++语言来说, 运算符重载只是“语法糖”,换言之,我们只是用了一种更易为人接受的语法形式来书写函 数调用。举个例子来说,我们可以重载运算符&&来接受两个 Thing 型别的引数: bool operator &&(const Thing&, const Thing&); 当我们以中置运算符的形式来调用它的时候,维护工程师很有可能认为它和内建运算符一样 具有短路算法的语义,可是这样认为就错了: Thing &tf1(); Thing &tf2(); // ... if (tf1() && tf2()) // ... 上面这段代码和以下这个函数调用具有一模一样的语义: if (operator &&(tf1(), tf2())) // ... 正如我们所见,tf1 和 tf2 无论如何都会被评估求值,而且次序还不固定。这个问题在重 载运算符||和逗号运算符时都同出一辙。三目条件运算符禁止被重载,也算不幸中的万幸。 (译注:有关这个问题的更深入讨论,参见(Meyers, 2007),条款 7 和(Sutter, et al., 2005), 条款 30) 常见错误 15:(运算符)优先级问题 本条款不讨论到底是伯爵夫人还是男爵夫人该在晚宴时坐在大使的旁座(此问题无解)。不, 我们要讨论的是在 C++语言中的多层级化的运算符优先级如何带来一些令人困扰的问题。 优先级和结合性 在一种程序设计语言中引入不同层级的运算符优先级通常来说是好事一桩,因为这样就可以 不必使用多余的、分散注意力的括号而能把复杂表达式简化。(但是请注意,在复杂的或是 比较晦涩的,亦即并非所有代码读者都能很好理解的表达式中显式地加上括号以表明意义, 这是正确的想法。当然了,在那些平凡的、众人皆知的情况下一般来说还是不加不必要的括 号反而最让人觉得清楚。) a = a + b * c; 常见错误 15:(运算符)优先级问题 41 在上面的表达式中,我们知道乘法运算符具有最高的优先级,或者说最高的绑定强度,所以 我们先执行那个乘法操作。赋值运算符的优先级是最低的,所以我们最后做赋值操作。 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。)(译注: 这被称为 Schwarz 问题,参见(Lippman, 2001),§2)这是一个我们非加括号不可的情况: cout << (a? f() :g()); 如果你想被别人觉得精神方面无懈可击,你还可以再进一步: if (a) cout << f(); else cout << g(); 第二章 语法问题 42 这种写法也许不如前一种写法那么令人浮想联翩,但是它的确有着又清楚、又容易维护的优 点。 很少有采用 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; // 译注:这些是 C++语言里不常用的声明语法,牢记 C *cp = new C; // ... cp->*pfmem(12); // 错误! 我们的代码通不过编译,因为函数调用运算符(译注: operator())的优先级高于 operator ->*。问题在于,将函数提领之前(译注:此时其地址尚未决议),我们无法调 用它。这里,加括号是必须的: (cp->*pfmem)(12); // 译注:指涉到成员的指针除了包括一个运算符,还有一个名字 指涉到数据成员的指针相对来说更容易出问题,考虑以下的表达式: a = ++cp->*pdmem 变量 cp 和上面那个是同一个指涉到 class 对象的指针,pdmem 不是一个 class 成员的名 字,而是一个指涉到成员的指针的名字。在这种情况下,由于 operator ->*的优先级不 如运算符++高,cp 会在指涉到成员的指针被提领前实施自增。除非 cp 指涉到的是一个 class 对象的数组,否则这个提领动作肯定不知道会得到什么结果。(译注:此错误发生在 运行期,编译期校验不出来,所以更不好。有关这个议题,请参见(Meyers, 2001),条款 46。) 常见错误 16:for 语句引发的理解障碍 43 指涉到 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) ){ // 做一些有关 theName 的操作 } // 这里就越过了 theName 的辖域(译注:theName 不再有效) 以前的岁月里,此种变量很可能在 if 语句之外声明,在我们已经不需要它的时候仍然赖着 不走,并带来麻烦。 char *theName = lookup(name); if (theName){ 第二章 语法问题 44 // 做一些有关 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 语句的那个闭合语句区块的结束位置(译注:即右半边的大括号处)。在 C++语言新规定的语义中,迭代变量的辖域被限定到了 for 语句本身的结束位置。尽管大 多数 C++软件工程师都觉得这个调整于情于理皆无可指摘——它和语言的其余部分更加正 交(译注:此处意为更具一致性,比如和 if 语句的情况就一致了,语言不应该允许“语法 天窗”),也使得循环更加容易得以优化,诸如此类——但是不得不去收拾 for 语句旧用法 的烂摊子这一事实也实实在在地给一些维护工程师造成了一些落枕般的痛苦。 有时候这个痛苦就不止像落枕那个程度了,考虑下面的代码片段中悄然变化的变量涵义: int i = 0; void f(){ for (int i = 0; i < bufSize; i++){ if (!buffer[i]) break; } if (i == bufSize) // 这个 i 是整个文件辖域里的 i // ... } 幸运的是,犯这种错误的机会毕竟不多,何况任何一个有质量可言的编译器都会就此大声警 告。严肃对待编译器警告(不要关闭编译器警告)(译注:编译器的警告表示被校验的代码 虽然语法上是语言允许的,但是往往反映了语法或语义上违反了公认的习惯用法,或是落入 了常见错误的圈套,很可能包含着语义错误——语法错误给出的就不是警告,而是产生会中 常见错误 16:for 语句引发的理解障碍 45 止编译的错误了。警告往往意味着:“软件工程师这么写往往表示他没有意识到他在犯错, 你最好回头检查一下你的代码确保你真的就是想表达你现在的代码将要产生的语义——它 确实合法并且有一个语义,但大多数情况下这个语义不是人们想要的。你如果看也不看,你 将来很可能吃苦头”。一句话,编译错误反映了语法层面上的错误,编译警告反映了语义层 上的潜在错误。后者更微妙,也更难调试,所以一定要重视起来。有关这个问题,参见(Meyers, 2006),条款 53),避免让外层辖域的变量遮掩了内层辖域里的同名变量。还有,坚决让全 局变量下岗。(参见常见错误 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 语句结束时仍然保持 有效(译注:这是作者想要强调的重点,这也是为什么他说这是 C 代码——C 语言里不允 许在普通语句后声明变量,只能在代码起始处声明);其次,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 语句,整个 程序就有了一个难以察觉的语义改变。本例中,会引起死循环,它提醒我们哪里肯定出了什 第二章 语法问题 46 么毛病。(译注:continue 语句使循环语句执行流直接跳回了循环体的第一句重新开始执 行,对于 for 语句而言 i 会自增,而 while 语句中 i 的自增就被跳过了,所以循环就始终 不能结束。这种微妙的区别可能是 for 语句和 while 语句唯一的区别,软件工程师要特别 留心。有关 continue 语句的进一步说明参见(Eckel, 2002),§3.2.6 或(Pohl, 2003),§9.7。) 我们并非总是幸运儿。 如果你很走运地工作在一个支持 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 语句的 新语义,并给这段代码的维护工程师省却了不少额外的麻烦。 常见错误 17:取大优先解析原则带来的问题 当面对如下表达式时,你何以措手足? ++++p->*mp; 你可曾有幸和“中士运算符”打过交道?(译注:中士军衔的肩章是“三道杠”,此处为不 合习惯用法写码风格的讽刺说法。) template class R{ // ... 常见错误 17:取大优先解析原则带来的问题 47 friend ostream &operator <<< // 一个“中士运算符”? T>(ostream &, const R&); }; 你可曾为“下面的表达式是否合法”的问题迟疑过? a+++++b 欢迎进入取大优先解析原则的世界!在 C++源代码被编译的早期阶段,编译器中负责“词法 分析”的部分有一项任务,就是把源码输入流打碎成一个个地“单词”,或曰“词法单位”。 当词法分析过程遇到一个形如“->*”的字符序列时,它可以同样合理地把它解释成三个词 法单位(“-”、“>”和“*”)、两个词法单位(“->”和“*”)或是单个一个的词法单位(“->*”)。 为了摆脱此类多义性的局面,词法分析引入了取大优先解析原则,也就是总是能取多长的可 以作为词法单位的字符序列就取多长:取大优先嘛。 表达式“a+++++b”是非法的,因为它被解析成了“a++ ++ +b”,但对像“a++”这样的 右值应用后置自增运算符是非法的。如果你想把一个后置自增的 a 和一个前置自增 b 的相 加,你至少要加一个空格:“a+++ ++b”。如果你哪怕考虑到了你的维护工程师一点点,你 就会再加一个空格,尽管严格说来不是必要的:“a++ + ++b”。多加几个括号的话,也实 在不会有谁抱怨你什么:“(a++) + (++b)”。 取大优先解析原则除了在两种常见情况下,多数都是作为问题解决者而不是制造者的形象出 现。不过在这两种情况下,的确令人生厌。第一种情况是用一个模板具现化的结果型别来具 现化另一个模板。举例来说,采用标准库里的元素的话,某软件工程师打算声明一个 list, 其元素是以 string 对象为元素的 vector 容器: list> lovos; // 错误! 倒霉的是,在具现语法里两个相毗邻的右半个尖括号被解释成了一个右移位运算符,于是我 们就犯了一个语法错误。空格(在这种情况下)是非加不可的: list > lovos; 另一种情况是为指针型别的形式引数给予默认初始化值的时候: void process(const char*=0); // 错误! 这个声明企图在形式引数列表里使用运算符*=。语法错误。这种错误属于“自作孽,不可 活”——如果代码作者记得给形式引数一个名字,就根本不会犯这种错误。现在你明白了, 给予形式引数名字不仅起了“自注释”的作用,同时也让取大优先解析原则带来的问题消 弭于未现: void process(const char *processId = 0); 第二章 语法问题 48 常见错误 18:声明饰词次序的小聪明 就语言本身所限,声明饰词孰先孰后纯属无关紧要的形而上之争: int const extern size = 1024; // 合法,但有离奇不经之嫌 无论如何,如果没有令人信服的理由去背离习惯用法,那末顶好还是接受有关声明饰词次序 事实上的标准:先写连接饰词,再写量化饰词,再写型别。 extern const int size = 1024; // 正常 下面这个指针的型别是什么呀? int const *ptr = &size; 对,这是一个指涉到常量整数型别的指针。但你根本难以置信有多少软件工程师会把它误读 成一个指涉到一般整数型别的常量指针(译注:其实判定一个指针是指涉到常量还是本身是 常量,唯一要看的就是 const 关键字位于声明语句的星号前面还是后面——若是在其前, 就说明它指涉到常量,否则说明它本身是常量。有关这一点,参见(Meyers, 2006),条款 3): int * const ptr2 = &size; // 错误! 以上是两种完全不同的型别。当然了,第一种指针型别可以指涉到一个常量整数型别,第二 种不行。(译注:初学者一定不能跳过这句话,而要问自己一句:为什么第二种指针型别不 能指涉到一个常量整数型别?经过观察和思考,就会得出结论:尽管指针本身是常量,它被 初始化以后再不能被修改了,但它指涉到的内容却是允许修改的,而这就违反了它预备指涉 到的内容原有的常量性,因而会被语言拒绝。如果还不清楚,建议阅读(Stroustrup, 2001), §5.4.1。本书中对此问题亦有展开,参见常见错误 31。)很多软件工程师会随口把“指涉到 常量型别的指针”念成“常量指针”,这不是一个好习惯,它只会把你要表达的真实意思(“指 涉到常量型别的指针”)传达给那些粗枝大叶之徒,而真正字斟句酌的称职后生则会被你的 言辞误导(而理解成“指涉到一般型别的常量指针”)。 当然需要承认的是,标准库里有一个(字面上表示“常量迭代器”之意的)const_iterator 概念,它无可救药地实际上表示一个“指涉到常量元素的迭代器”,而这些迭代器自身却并 不具常量性。(标准委员会的家伙们某天吃错了药不是你要向他们学坏的理由。)仔细区分“指 涉到常量型别的指针”和“常量指针”(参见常见错误 31)。 由于声明饰词次序在技术层面上无关紧要,一个指涉到常量的指针可以以两种形式声明: const int *pci1; int const *pci2; 有些 C++专家比较提倡第二种书写形式,因为他们宣称对于更复杂的指针型别声明来说,这 常见错误 19:“函数还是对象”的多义性 49 种写法更容易读: int const * const * pp1; 把量化饰词 const 置于若干声明饰词的最后,这样我们就可以倒过来读所有的指针型别的 饰词。从右到左,pp1 的指涉物是一个常量(const)指针,后者指涉到一个整数常量(const int)。而习惯的饰词次序则不支持这样的平凡规则: const int * const * pp2; // pp2 的型别和 pp1 完全相同 (译注:本书作者显然在后来的岁月里改变了他的有关饰词次序的看法,变得更加包容。他 在本书里把两种饰词的次序中的一个打上了“不推荐”的烙印,显然觉得两种用法里一种优 于另一种。但他在另一本比较晚近出版的书中谈到这个问题时,就表示两种用法选择哪一种 “无关紧要”了。参见(Dewhurst, 2006),条款 7。而 Scott Meyers 在谈到量化饰词应该放在 型别前还是后时,则更直截了当地说“你应该同时适应两者”,参见(Meyers, 2006),条款 3。 根据这些材料及其变迁的历史轨迹,我们可以说,读别人的代码时,不应该误读;而自己在 撰写代码时则纯属风格问题,可以根据自己的理解方向和喜好来选择一种,并固定下来,在 编码实践中沉淀为自己的代码风格的一部分。) 前一种饰词次序的安排也没有带来太多复杂性,一个 C++维护工程师若是在要阅读和维护的 代码里存在这样的片段,他也应该是有能力搞掂的。更重要的是,指涉到指针的指针或是其 它类似的声明是较少见的,尤其少见于交由 C++新手打理的接口代码里。典型情况是,它 们藏匿于基础实现码的深处。平凡的、直截了当的指涉到常量的指针就常见得多。所以,还 是遵循习惯用法以避免误解较佳: const int *pci1; // 正确:指涉到常量的指针 常见错误 19:“函数还是对象”的多义性 对象的默认初始化语句不应该写成一个空的初始化引数列表的形式,因为它会被解释成一 个函数声明。 String s(“Semantics, not Syntax!”); // 显式指定了用以初始化的引数 String t; // 默认的对象初始化语句(译注:对象名后不带括号) String x(); // 一个函数声明 //(译注:上句声明了一个不带引数并返回 String 型别的函数,可能违反代码作者本意) 这是一个 C++语言内廪的多义性。实践角度来说,语言“掷硬币”决定了 x 是一个函数声 明。(译注:这里作者没有展开说,其实想避免这样的多义性,即确定 x 是一个函数声明而 不是一个默认的对象初始化语句的方法是显式地在引数列表里写一个“void”,即把最后一 句写成“String x(void);”即可。参见(Lippman, et al., 2006),§7.1.2。另外,这个多 义性问题也不仅仅表现于默认的对象初始化语句和空函数形式引数表这种情况,只要是函数 形式引数的名字被省略的情况都有可能引起这种多义性。一个例子可以参见(Meyers, 2003), 条款 6。这也从另一个侧面让我们认识到函数形式引数的名字不被省略的重要性,参见常见 第二章 语法问题 50 错误 1 和 17。)请注意,该多义性问题在 new 表达式中并不发作: String *sp1 = new String(); // 这里没有多义性(译注:出于一致性考量不推荐) 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 的型别是一样的。 (译注:碰到上面这个多维数组的例子,是比较典型的。人去读,可以比较清楚地了解到: AP 是一个指涉到一般整数型别的指针构成的数组,所以数组的元素型别是指针型别,后来 的volatile饰词效果是加到了指针型别上的,相当于“int * volatile vm[12][12]”。 但对编译器的实现而言,在巨大的工作量下,这一个语言细节很有可能被误解。有关复杂指 针、数组和函数交织在一起的声明,比较简略的说明参见(Dewhurst, 2006),条款 17;一个 完整的说明和手工分析的方法参见(Kernighan, et al., 1997),§5.12,但后者并未涉及量化饰 词的讨论。) 对函数声明的处理方式比较含糊。过去,一般的 C++语言实现也允许相同的量化饰词效果漂 移: typedef int FUN(char *); typedef const FUN PF; // 原先的情况:PF 指涉到一个返回 const int 的函数 // 现在:非法 现在标准却说,应用于函数声明量化饰词只能用于声明一个“顶级”的 typedef,并且这 个 typedef 还只能用于声明一个非静态的成员函数(译注:参见(Koenig, 1996),§8.3.5, 实际上这就禁止了对 typedef 声明的函数型别再使用任何量化饰词): typedef int MF() const; MF nonmemfunc; // 错误! class C{ 常见错误 21:自反初始化 51 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{ 第二章 语法问题 52 char a, b, c, d; }; // ... void aFunc{ char *buf = new char[sizeof(buf)]; // ... } 那个局域里的 buf 很可能会获取 4 字节的内存,足够放置一个 char *。这个错误可能会 很久都校验不出来,尤其在型别 struct buf 和指针型别变量 buf 具有相同大小的时候。 (译注:但是移植时一定会出问题。)遵守一个把型别和非型名的名字区分清楚的命名约定 就可以在这个问题上防患于未然(参见常见错误 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}; 常见错误 22:静态连接型别和外部连接型别 53 // ... } 谢天谢地,这个枚举定义是非法的。其枚举量的初始化对象不是一个整数型别的常量,因为 在以上情况下,编译器无法在编译期获知外层辖域中的那个 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)以外的地方是不可访问的。 (译注:相对于本书作者对匿名空间中的对象连接类型为外部连接的斩钉截铁,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,就可以设 第二章 语法问题 54 定其连接类型为内部连接”,还是“不需要设定其连接类型为内部连接,亦即它可能是外部 连接也可能是内部连接,但无论如何它都像内部连接一样工作”。后一种理解很可能是更符 合标准的,参见(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 中倒是已经明确地把它标为“受贬斥的, 不再推荐使用的”语言特性了。) 常见错误 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*(); // 和上一个语句意义相同 如果要评选“最佳清晰奖”,那么中序记法必可荣膺。典型情况下,我们要使用一个被重载 的运算符时都是用中序记法的(即“左手边操作数 运算符 右手边操作数”的写法)。毕竟 我们之所以要重载运算符最原始的出发点不就是这个么? 一般地,当我们不用中序记法时,函数调用语法比对应的中序记法更清晰。一个教科书般 常见错误 23:运算符函数名字查找的反常行为 55 的例子就是基类的复制赋值运算符在派生类的复制赋值运算符实现中被调用的场合: class B : public A { public: B &operator =(const B&); }; B &B::operator =(const B&b){ if (&b != this){ A::operator =(b); // 好过“(*static_cast(this))=b” // 为 B 的其它局部变量赋值 } return *this; // 译注:返回*this 是一个习惯用法,支持连续赋值 } 还有一些场合我们使用函数调用语法而不用中序记法——尽管中序记法在这些场合的使用 完全正确合理——中序记法在这些场合显得太怪异丑陋,会让一个维护工程师花几分钟才能 回过神来: 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 的情况下,编 第二章 语法问题 56 译器首先在 class X 里找一个名字叫“operator %”的函数。只要找到了,它就不会在 更外层的辖域里继续找其它同名的函数了。 不幸的是,我们企图向一个二元运算符传递三个引数。因为成员函数 operator %有一个 隐式的引数 this,我们显式向它传递的两个引数会让编译器误以为我们想要把一个二元运 算符以(不正确的)三元形式调用。一个正确的调用或者显式地识别出非成员版本的 operator %(::operator %(anX, 12)),或者向成员函数 operator %传递正确数 量的引数(operator %(anX))。 使用中序记法驱使编译器搜索了左操作数指定的辖域(那就是在 class X 里搜索,原因是 anX 具有 X 型别),于是找出了一个成员函数 operator %;然后又找出了一个非成员版本 的 operator %(译注:使用中序记法会搜索包括成员与非成员的对应运算符重载,这是 两种记法会引起的名字查找过程的不同,也是它们唯一的本质差异。这个问题的更深入讨 论参见(Lippman, et al., 2006),§14.9.5),于是编译器找到两个候选函数,并正确地匹配到 了其中的非成员版本。 常见错误 24:晦涩难懂的 operator -> 内建的operator ->是二元的,左手边的操作数是一个指针,右手边的操作数是一个class 成员的名字。而重载版本的 operator ->则是一元的! gotcha24/ptr.h class Ptr{ public: Ptr( T *init); T *operator ->(); // ... private: T *tp_; }; 对重载版本的 operator ->的调用,必须返回一个可以用(直接或间接地调用内建的) operator ->访问其成员的对象。(译注:如果不是这样,岂非调用该运算符的形式就成 了“Ptr p(new T); p->;”?) gotcha24/ptr.cpp Ptr p( new T ); p->f(); // 表示“p.operator ->()->f()”! 用某种视角来看,我们可以把实际发生的事理解成词法单位->没有被“吃掉”,而是保留下 常见错误 24:晦涩难懂的 operator -> 57 来 “派真正的用场”,如同内建的 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’<f(); // 实际上是“ap.operator ->().operator ->()->f()”! 请注意,operator ->的调用序列的触发(activation)总是由包含 operator ->之定义 第二章 语法问题 58 的对象(译注:不一定非得是 class 对象,也可以是一个内建指针)静态决定的,而且该 调用序列总是终结于返回指涉到 class 对象的内建指针的调用。举个例子,对 AugPtr 调 用 operator ->总是会触发以下调用序列:先是调用 AugPtr::operator ->,接着调 用 Ptr::operator ->,再接着调用 T *型别内建指针上的 Ptr::operator ->。(若 要检视一个更具实践参考意义的例子,请参见常见错误 83。) 第三章 预处理器问题 预处理可能是 C++代码编译过程中最为危机四伏的阶段(phase)。预处理器只扫语汇块(token, 构造出 C++源代码的“单词”)的门前雪,对于 C++语言其余部分的精巧结构却不闻不问, 无论在词法还是语义的意义上讲都如出一辙。事实上,预处理器对它自身的蛮力并无清醒意 识,如同其它大而无脑的东西一样,它能造成可怕的破坏。 本章中所提的建议欲让预处理器去执行那些只需蛮力而与 C++语言没有太大干系的任务,而 若是要完成的工作是个细活,还是免用为佳。 第三章 预处理器问题 60 常见错误 25:使用#define 定义的字面量 使用 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 { // 译注:该名字空间的意思是“会施加影响的” # define MAX 1<<16 // ... } namespace Facility { const int max = 512; // ... } // ... int a[MAX]; // 哎呀,糟糕! 这个软件工程师忘记把名字 max 汇入,而且把它误拼成 MAX 了。不管怎样,预处理器把 MAX 替换成了 1<<16,于是代码稀里糊涂地通过了编译。“我咋会用了这么多内存呢„„” 对于此类问题的一个必杀技,当然,是使用一个初始化了的常量: const int max = 1<<9; 常见错误 25:使用#define 定义的字面量 61 这么一来,max 的型别就在所有的平台上全都一样了,而且名字 max 也遵循着惯常的辖域 规则。请注意,使用 max 很可能和使用#define 同样高效。因为编译器被赋予了自主权, 能够在 max 被用作右值时使用其初始化值代替它本身,以将其占用的存储优化掉(译注编 译器的这种称为常量折叠的优化能够让一些不良代码的问题暴露,参见常见错误 32)。但无 论如何,max 是一个左值(它只是碰巧是一个不可修改的左值,参见常见错误 6),它有一 个地址,而且我们能够指涉到它。而这对于字面量来说是不可能的: const int *pmax = &Facility::max; const int *pMAX = &MAX; // 错误!(译注:&1<<16 是非法的) 另一个#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; } 第三章 预处理器问题 62 在 class 内部声明静态整型常量数据成员,并以整型表达式初始化之也是合法做法,但还 没有被广泛支持(译注:现在所有的主流编译器都已经支持了这一做法)(参见常见错误 59): class Name { // ... static const int nameLen_ = 32; }; // ... const int Name::nameLen_; // 这里不能写成初始化语句! 但是静态常量数据成员所占用的存储可能不会被优化掉,对于平凡整型常量来说,(使用) 传统枚举量(的手法)是较佳的选项。 常见错误 26:使用#define 定义的伪函数(函数宏) C 语言中,#define 经常被用来定义伪函数——当避免函数调用的开销带来的效率的重要 性被置于安全性之上时: #define repeated(b, m) (b & m & (b & m)-1) 无须多说,所有使用预处理器时的警告在这里都仍然有效。具体而言,上面这个定义是颇有 瑕疵的: typedef unsigned short Bits; 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 ) ) // 祸不单行 // ... 该伪函数的第一个引数有副作用(译注:即在调用函数前对的 a 第二位做了带进位加一的 操作)。如果 repeated 是一个真正的函数,在它被调用前,该副作用会准确地只发生一次。 但对于 repeated 现在这个特定的定义来说,副作用被以未定次序执行了两次(参见常见 常见错误 26:使用#define 定义的伪函数(函数宏) 63 错误 14)(译注:有关#define 和函数副作用互动带来的负面影响的更深入讨论,参见 (Kernighan, et al., 2002),§1.4)。伪函数的特别危险之处在于它们的使用和真正的函数别无 二致,但其语义却有天壤之别。正因为它和真正的函数的这种形似,使得 C++高手有时也会 在伪函数前马失前蹄,因为他们不假思索地认为自己在调用真正的函数。 在 C++语言中,inline 函数几乎在任何时候都是相对于伪函数而言更佳的选择。因为它 才展现了函数调用的适当语义,它和非 inline 函数(non-inline function)(在语义上) 具有相同的涵义: inline Bits repeated( Bits b, Bits m ) { return b & m & (b & m)-1; } 宏被用作伪函数时,仍然饱受将其用于显式常量时所遭遇的辖域问题之苦(参见常见错误 25)(译注:看来作者还是把伪函数和宏看成不同的概念的,宏的意义在于它也可以被展开 成为非函数形式的结果,其实宏的概念是包含了伪函数的): 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 的变量(译注:纯粹是字符串替换,和辖域没有任何关系。不是因为是 在全局辖域的,就只引用到全局辖域中的那个 kount),结果一不小心就修改了局部变量 kount 的值,而不是全局的那个。更好的做法肯定是使用(真正的)函数: gotcha26/execbump.cpp int kount = 0; inline void execBump( void (*func)() ) { func(); ++kount; } 通过使用 inline 函数,在函数体被编译的时刻,标识符 kount 就和那个全局变量(而不 是局部变量)kount 绑定了。在函数被调用时,这个名字不会和另一个 kount 变量再绑定 一次。(不过,我们还是用了全局变量唷,想知道这么做会带来的问题,参见常见错误 3。) 第三章 预处理器问题 64 更好的解决方案也许是使用函数对象来为计数过程做更漂亮的封装: 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; // ... void aFunc() { extern void g(); 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。 常见错误 27:#if 的滥用 #if 用于调试 我们怎么向程序中插入一些调试代码呀?谁都知道要用预处理器: 常见错误 27:#if 的滥用 65 void buggy() { #ifndef NDEBUG // 一些调试用的代码 #endif // 一些生产代码 #ifndef NDEBUG // 另一些调试用的代码 #endif } 所有人都错了。软件工程战线上的老兵油子们都会滔滔不绝地翻炒一些当年勇,说什么调试 版本的程序运行得毫无问题,但是“顺手”定义一个符号 NDEBUG 就会使生产环境的代码 莫名其妙地罢工。 这一点都不奇怪。我们事实上是在讨论两个毫不相干的程序,只不过它们刚巧是由同一个 源代码文件生成的罢了。就算只是为了看看有没有句法错误,你就得把同一份源代码编译两 次才成。写代码的正道是把调试版本的想法老老实实地融入到你写出来的东西里,而且要写 就只写一个单独的程序: void buggy() { if( debug ) { // 一些调试用的代码 } // 一些生产代码 if( debug ) { // 另一些调试用的代码 } } 那,把调试代码留在由生产环境的代码最终生成的可执行文件里也不要紧吗?那样不会浪费 空间吗?这些非必要的条件分支不会带来时间开销吗?不会的——如果调试代码在最终生 成的可执行文件里根本就不存在的话。编译器的拿手好戏就是识别和剔除无用的代码。它们 在这方面能做得可比我们可怜巴巴地想用#ifndef 实现的好太多了。我们要做的一切不过 是让编译器别无选择: const bool debug = false; 表达式 debug 是 C++标准中所谓整型常量表达式(integer constant-expression)。任何 C++ 编译器都必须在编译期对整型常量表达式做好评估求值,以期用于数组尺寸界定表达式 (array bound expression)、 case 标签(译注:用于 switch 表达式,参见常见错误 7)和 位域长度(bitfield length)等。任何合格的编译器都具备将如下不可达代码(unreachable code) (从最终生成的可执行文件里)剔除的能力: if( false ) { 第三章 预处理器问题 66 // 不可达代码 } 没错,就连五年前你向你老板抱怨过的那个老掉牙的编译器搞掂这个都不成问题。尽管编译 器会剔除这些不可达的代码,但它还是会(对这些代码)做一次完整的语法分析和静态语 义校验。 根据标准中有关常量表达式的定义,编译器甚至可以把使用了更复杂的表达式来做判断的代 码都消灭干净: 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 = #ifndef NDEBUG false #else true #endif 常见错误 27:#if 的滥用 67 ; 连这样最小化的条件编译代码其实也并无必要。通常来说,更佳的做法是通过 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 的正确实现,那 肯定是通过一个平台相依的接口来为不同的平台提供不同的实现: 第三章 预处理器问题 68 void 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 的定义分散在不同的编译单元, 尔后再把它们无误地链接起来才是普遍情况。(译注:作者的意思是说,这里只要有无定义 的情况不一,就会引发编译期的错误,这反而有利于调试。)运行时的错误通常是让人费解 的,也很难跟踪。 幸甚至哉,这种会引起软件缺陷的技术现在已不像过去那么大行其道。最明显的用于表达此 类灵活性的做法就是使用多态: 常见错误 28:断言(assert 宏)的副作用 69 class Doer { // 平台无关 public: virtual ~Doer(); virtual void doit() = 0; }; class ServerDoer : public Doer { // 平台相依 void doit(); ServerData x; }; class ClientDoer : public Doer { // 平台相依 void doit(); ClientData x; }; 现实世界的考察 我们考察了数个相当平凡的使用同一份源代码来表示不同程序的尝试。通过这些平凡的例子 我们了解到,使用习惯用法和设计模式来重构源代码,使之具有更好的可维护性才是最直 截了当的有效做法。 不幸的是,现实情况经常更糟糕、更远为复杂。典型情况是,源代码并不是由独一个符号(如 NDEBUG)作为(改变行为的)引数,而是被若干符号共同控制的。这些符号中的每一个都 还有若干个不同的可能取值,它们还有可能被用作组合。一如前示,每个符号及其取值的组 合都会向程序中添加必要的抽象行为,而且一个都不能少。从实践视角来看,就算有可能把 程序受控于这些符号的行为进行分解,这样的源代码重构也不可避免地至少会在一个平台上 带来软件行为的改变。 无论如何,这种重构迟早会变得非做不可,因为程序的抽象意义无法很容易地通过上百个符 号的及其值的组合来判定,更不用说如果连句法正确性都不能一目了然的情况下会怎么样了。 避免使用#if 作为源代码的版本控制才是正道啊。 常见错误 28:断言(assert 宏)的副作用 我的确对#define 的很多种用法都深恶痛绝,唯对定义在中的 assert 宏情有 独锺。说句实话,我鼓励大家多多使用它——前提是用好它。但问题就在于能不能用好它。 实现的方式固然百家争鸣,不过 assert 宏多数情况下和下面的定义相差不远: 第三章 预处理器问题 70 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 void doit( Cont &c, int index ) { assert( index >= 0 && index < c.size() ); // #1 assert( process( c[index] ) ); // #2 // ... } 在上面这段代码中,我们演示了几个使用 assert 宏的过程中犯下的用法错误。标了#2 的 那行代码是明显的误用,因为我们在调用一个函数,而这个函数被放到 assert 宏中去以 后可能会有其副作用。这段代码的行为会随着 NDEBUG 符号有否被定义而有本质的不同。(译 注:如果 NDEBUG 宏未被定义,这段代码就等于完全把调用 process 函数的语句从代码中 屏蔽掉了。有没有调用 process 函数,可能会对代码的行为产生重大影响。作者想强调的 就是使用 assert 宏和使用普通的 if 语句的重大区别就在于前者在非调试模式下把条件语 句本身完全屏蔽而不执行了,后者则无论如何都会执行它,而只不过是根据计算结果来决定 其它的语句是否执行罢了。由此我们可以得出结论:如果条件语句本身会影响代码的行为, 就不应该使用把它当作 assert 宏的引数。否则很有可能会出现下文所描述的“先有鸡还 是先有蛋”的佯谬。)这种 assert 宏的用法会导致在调试模式下代码行为完全正确,而把 调试模式关掉后原有的软件缺陷就复现了。于是你会又打开调试模式,缺陷又消失了。然后 你再关掉调试模式,结果„„(死循环!) 常见错误 28:断言(assert 宏)的副作用 71 标了#1 的那行代码错得更微妙。Cont class 的成员函数 size 很有可能是一个常量成员 函数,是故,它不会有副作用,对吗?错!除了 size 这个名字的习惯意义之外,我们找不 到任何理由来保证该成员函数具有常量语义。就算它真的是常量成员函数,也不能保证对它 的调用就(对代码的行为)没有副作用。(再退一步讲)就算(执行完 size 函数后)c 的 逻辑状态没有改变,它的物理状态仍然可能发生了变化(参见常见错误 82)。 最后,我们可 不要忘了使用 assert 宏就是为了找出代码缺陷来。即使调用 size 函数的本意并非要向代 码行为中引入什么可觉察的(变化)效应,它的实现仍然可能包含缺陷(使得这种效应出现)。 我们当然希望对 assert 宏的使用会将代码缺陷暴露于光天化日,而不是将它们藏匿起来。 正确的 assert 宏的用法会避免其条件语句带有任何潜在的副作用: template void doit( Cont &c, int index ) { const int size = c.size(); // 译注:避免了 size 未被调用的潜在副作用 assert( index >= 0 && index < size ); // 正确 // ... } 显然,assert 宏并非万金油,但它的确在位于注释和异常之间的某个位置扮演了代码文档 化及捕捉非法行为的适当角色。其最大问题在于它到底是一个伪函数,是故它也(无可避免 地)带着前面的条款中描述的有关伪函数的种种先天不足(参见常见错误 26)。好在它是一 个标准化了的伪函数,这也就暗示着其不足之处已为世人熟知。只要使用时多长个心眼, assert 宏就能为我们造福。 第四章 型别转换问题 C++语言的型别系统相对于其包罗万象而言,其复杂程度可谓相得。这种与生俱来的复杂性 由于以下的事实而变本加厉:在编译代码的过程中,用户自定义的型别转换运算符一旦存在, 就有可能被隐式调用。以成效论,通过为 C++语言增加抽象数据型别的手法来延拓其本身的 手法意味着软件设计工程师被赋予了设计有效的、安全的和互洽的(coherent)型别系统之 职责。C++语言很大程度上是静态型别主导的(largely statically typed),是故,卓有成效的 设计就能够化此复杂性于初现。 不幸的是,糟糕的编码能让最互洽的型别设计也无法如期运作。在本章里,我们就将考察一 些常见的能够击溃静态型别安全性(static type safety)的编码实践。我们也将考察对于 C++ 语言的一些能够导致静态型别安全性隐患的常见误解。 第四章 型别转换问题 74 常见错误 29:以 void *为型别转换的中介型别 连 C 语言的软件工程师都明白,使用型别转换时,以 void *为中介型别是个次等选项,能 不用就别用。因为在强制型别转换中,转换到 void *型别的结果会将带型别的指针(typed pointer)的型别信息悉数抹除。在典型情况下,只要以 void *为型别转换的中介型别,则 (软件工程师)必须“牢记”这(已经被抹除了型别信息的)指针的原始型别信息,并(适 时地再通过一次强制型别转换)恢复它。如果(在后来的这次强制型别转换中)供应了正确 的指针原始型别信息,那末天下太平(只是,当然,必须得由人来记住上次转制型别转换时 抹掉的型别信息的话,这昭示了这份软件设计亟需改进)。 void *vp = new int(12); // ... int *ip = static_cast(vp); // 能挣扎着运作一阵子 不幸的是,连这种貌似平凡的 void *用法也会为可移植性缺陷大开方便之门。记住,我们 (如果不得不进行强制型别转换时)使用 static_cast 来进行相对安全和可移植的型别 转换。比如,我们会使用 static_cast 把一个基类型别的指针转化成一个使用 public 方式派生于之的型别的指针。对于非安全的、平台相依的型别转换,我们除了使用 reinterpret_cast 之外别无选择。举例来说,我们可能使用 reinterpret_cast 把一 个整数型别的变量转化成一个指针,或是把指涉到一种型别的指针转化成一个指涉到一种不 相干的型别的指针。 char *cp = static_cast(ip); // 错误! char *cp = reinterpret_cast(ip); // 合法 在代码中使用 reinterpret_cast 是对使用和维护它的软件工程师发出的一个明确信号, 那就是这段代码不仅在进行一个强制型别转换,而且这个转换未有可移植性方面的深思熟虑。 而若是以 void *作为型别转换的中介型别,这个至关重要的警告(译注:事实上,很多设 计良好的编译器会将其标识为错误)就被扼杀了。 char *cp = static_cast(vp); // 把指涉到 int 的指针的地址放入了一个指涉到 char 的指针! 还有更糟的呢。考虑某用户界面系统的实现,它允许把某种“窗口组件”(Widget)的地址 先存储起来以备后用: typedef void *Widget; void setWidget( Widget ); Widget getWidget(); 使用这个接口的软件工程师会发现他们要想使用这个库的话,就必须用脑子记住存储的 Widget(指涉到的对象)的真实型别,这样他们才能在想要取用该 Widget(指涉到的对 象)时恢复其型别。 常见错误 29:以 void *为型别转换的中介型别 75 // 某个头文件里定义的接口 class Button { // ... }; class MyButton : public Button { // 译注:注意,MyButton 型别以 public 方式继承于 Button 型别 // ... }; // 另一个文件里的代码 MyButton *mb = new MyButton; setWidget( mb ); // 完全不相干的另一段代码 Button *b = static_cast