C++编程调试秘笈

978-7-115-29695-5
作者: 【美】Vladimir Kushnir
译者: 徐波
编辑: 陈冀康
分类: C++

图书目录:

详情

本书介绍了C++程序员经常犯的一些编程错误,并且给出了可以用来避免这些错误的规则。本书基于C++开发者社群的实践,介绍了如何安全地使用C++库。

图书摘要

版权信息

书名:C++编程调试秘笈

ISBN:978-7-115-29695-5

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    [美] Vladimir Kushnir

  译    徐 波

  责任编辑 陈冀康

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


Copyright©2012 by O’Reilly Media, Inc.

Simplified Chinese Edition, jointly published by O’Reilly Media, Inc. and Posts & Telecom Press, 2012. Authorized translation of the English edition, 2012 O’Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

本书中文简体版由O’Reilly Media, Inc.授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


当你使用C++进行编程时,很容易犯下各种各样的错误。有哪些常见的错误和陷阱?它们有什么一般的规律和特点?如何避免犯这样的错误?本书针对这些问题一一解答。

本书讨论了程序员在C++编程中所犯的一些最为常见的错误,并提供了避免这些错误的方法。C++社区积累了许多优秀的编程实践。在本书中,作者收集了其中的一些实践并对它们进行了调整和优化,此外,作者还增加了自己的一些有益的实践。

全书分为3个部分和10个附录。第一部分是前3章,以概括的方式介绍了C++调试的策略。第二部分包括第4章到第14章,逐个讨论C++代码中最为常见的错误类型,并为每种错误制定一种策略或规则。各章分别介绍了C++编程中数组、指针、引用、迭代器、变量、内存、NULL指针等相关的缺陷以及调试策略。第三部分包括第15章到第17章。第15章和第16章结合工具概括了调试策略,第17章是对全书内容的一个概括和总结。附录部分给出了本书所用到的库和一些程序文件的源代码。

本书要求读者有一定的C++编程基础或经验,适合C++的中级、高级程序员阅读。本书中介绍的缺陷捕捉策略和实践,能够帮助读者在C++编程实践中达到事半功倍的效果。


敏锐的读者可能会根据本书的英文书名《Safe C++》推断出C++编程语言多少是有点儿不安全的。这确实是很灵敏的感觉!并且非常正确。C++语言可能导致程序员出现所有类型的错误,例如访问一个动态分配的数组边界之外的内存,或者从那些从未初始化过的内存进行读取,或者分配了内存但忘了销毁它。简而言之,程序员在使用C++进行编程的时候,会有很大的几率搬起石头砸自己的脚。很可能一切都非常顺利,程序却突然崩溃,或者产生不可理喻的结果,或者出现了计算机术语中称为“不可预料的行为”。因此,从这层意义上说,C++语言在本质上是不安全的。

lowercase_and_glued_together_using_underscore

本书讨论了程序员在C++编程中所犯的一些最为常见的错误,并提供了避免这些错误的方法。在过去的岁月里,C++社区积累了许多优秀的编程实践。在编写本书时,作者收集了其中的一些实践,并对它们进行了稍微的修改,另外增加了作者的一些实践。作者希望这些作为缺陷捕捉策略的规则集能够达到事半功倍的效果。

不可否认的真相是,任何比“Hello, World”复杂得多的程序都可能包含一些错误,或可以充满感情色彩地称之为缺陷(bug)[1]。编程的一个很大课题是怎样减少缺陷的数量,同时又不至于明显延缓开发进程使之陷入停顿。为此,我们需要回答下面这个问题:应该由谁来捕捉这些缺陷?

在软件程序的生命周期中,共涉及4类参与者(见图P-1):

(1)程序员。

(2)编译器(例如Unix/Linux中的g++、Windows中的Microsoft Visual Studio和Mac OS X中的XCode)。

(3)应用程序的运行时代码。

(4)程序的用户。

图P-1 4个参与者(缺陷多多的版本)

当然,我们并不想让用户看到缺陷,甚至不想让他们知道缺陷的存在,因此现在只剩下参与者1到3。和用户一样,程序员也是人,人有可能疲劳、困倦、饥饿,以及由于同事的提问或者接听某位家庭成员或汽车修理工的电话而分心。因此,程序员是容易犯错的,很容易制造缺陷。反之,参与者2和3(编译器和可执行代码)具有某种优势:它们并不会疲劳、困倦或热情消退,也不会参加会议或休假,更不需要用餐。它们只是执行指令,并且通常非常善于做这项工作!

图P-2 4个参与者(愉快/没有缺陷的版本)

考虑我们必须面对的资源:一方面是程序员,另一方面是编译器和程序。我们可以采取两种策略之一来减少缺陷的数量。

1号策略:说服程序员不许犯错。密切注视程序员,威胁每发现一处缺陷就要从他的奖金里扣除10美元,或者想方设法让他紧张起来以提高他的工作效率。例如,告诉他“每次当你分配内存时,不要忘了销毁它!”,诸如此类。

2号策略:根据一种现实的推断(即使怀着最热切的意愿并保持激光一样的专注力,程序员仍然会在代码中制造缺陷),对编程和测试的整个过程进行组织。因此,不要对程序员说“每次当你做A时,不要忘了做B”,而是制定一些规则,在用户运行应用程序之前,使大多数缺陷被编译器和运行时代码所捕捉,如图P-2所示。

当我们编写C++代码时,应该追求以下3个目标。

(1)程序应该执行它的预定任务。例如,计算每月的银行票据、播放音乐或编辑视频等。

(2)程序应该容易被人理解。也就是说,源代码不仅是为编译器写的,而且要能够被人理解。

(3)程序应该具有自我诊断功能。也就是说,能够寻找它所包含的缺陷。

这3个目标是按照它们在现实的编程世界中的关注度从高到低排列的。第一个目标对于每个人都是不言而喻的,第二个目标是部分人所追求的,第三个目标就是本书的主题。我们不应该自己捕捉缺陷,而是由编译器和可执行代码为我们做这些事情。它们负责这些乏味的工作,我们的精力放在算法和设计上,也就是编程中有趣的那部分内容。

如果读者从来没有用过C++进行编程,那么本书并不适合您。本书并不是为C++初学者准备的,它假设读者已经熟悉C++的语法,并且能够理解像构造函数、拷贝构造函数、赋值操作符、析构函数、操作符重载、虚拟函数和异常等概念。本书是为具有一定水准的C++程序员所准备的,包括初级和中级水平的程序员。

在本书的第一部分,我们将讨论下面这3个问题。

在第1章中,我们将讨论书名的问题。注意,这个问题涵盖了所有问题。

在第2章中,我们将讨论为什么最好尽量在编译时捕捉缺陷。第2章的剩余部分将描述怎样实现这个目标。

在第3章中,我们将讨论在运行时发现缺陷时应该怎么做。为了捕捉错误,我们尽力使安全检查(一段为了诊断错误这个特定目标而编写的代码)的编写变得轻松。实际上,这项工作已经完成了,附录A包含了编写安全检查所需要的宏的代码,包括能够产生与发生了什么?在哪里发生?为什么发生等问题有关的详细信息,同时并不需要程序员做太多的事情。在本书的第二部分,我们将讨论不同类型的错误,一次讨论一种错误,并制定让这些错误不再发生(或至少很容易捕捉)的规则。在本书的第三部分,我们应用了第二部分介绍的安全C++库的所有规则和代码,并讨论了以最高效的方式捕捉缺陷的测试策略。

我们还讨论了怎样使程序“可调试”。在编写程序时,其中一个目标就是使它很容易被调试,我们将介绍怎样在两个得力的助手(编译器和运行时代码)中添加第三个助手,即调试器,特别是当我们处理那些以“调试器友好”的方式所编写的代码时。

现在,我们准备捕捉实际的缺陷了。在本书的第二部分,我们逐个讨论C++代码中最为常见的错误类型,并为每种错误制定一种策略(或者简单地确定一个规则),使之不可能发生或者很容易在运行时被捕捉。接着,我们讨论了每个特定规则的长短优劣以及它所存在的限制。在每章的最后,用这个规则的简短表述形式进行总结,这样当读者想跳过具体的讨论内容直接观察结论时,就知道可以在哪里找到它们。第17章对所有的规则进行了总结。附录部分包含了本书所使用的所有必要的C++文件的源代码。

此时读者可能会问:“可不可以这样说,现在不再像以前的说法‘当你做A时,不要忘了做B’,而是变成了‘当你做A时,要遵循规则C’?这又有什么区别呢?是不是存在更加确定的方式来摆脱缺陷呢?”好问题!首先,有些问题(例如内存的销毁)可以在语言层次上得到解决。实际上,这个目标已经实现,它们就是Java或C#。但是对于本书而言,我们假设出于某些原因(例如存在大量的遗留代码,对性能有着极为严格的要求,或者对C++语言有着非比寻常的感情),还是坚持使用C++。

在这个前提下,遵循这些规则为什么要比原来的“不要忘记”是更好的答案呢?这是因为在许多情况下,规则的实际表述形式像下面所述。

我们显然更加同意第二种方式,它更简单并且更可靠。当然,我们并不能百分之百地保证程序员不会忘了把内存赋值给一个智能指针,但是它比原来的表述形式要容易实现得多,并且要可靠很多。

需要注意,本书并没有涵盖多线程。为了准确起见,本书在讨论内存泄漏时简单地提到了多线程,但也就到此为止了。多线程是非常复杂的,它向程序员提供了许多机会制造许多微妙的、难以复制并且很难寻找的错误。但是,这应该是另一本更为高阶的书籍的主题。

当然,作者并不是声称本书所建议的规则是唯一正确的规则。相反,程序员们可以充满热情地主张其他的实践,只要适合他们就是最好的。编写良好的C++代码的方法有很多种。但是,下面所述是作者的主张。

那么可执行代码的效率会不会受到影响呢?读者可能会担心寻找缺陷需要付出代价。不必担心,在本书的第三部分“捕捉缺陷的乐趣:从测试到调试到产品”,我们将讨论怎样保证产品代码保持应有的效率。

下面是本书所使用的字体约定。

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽

用于程序清单以及在段落中表示程序元素的片段,例如变量或函数名、数据库、数据类型、环境变量、语句和关键字等。

等宽粗体

显示程序所产生的输出。

 

提示

这个图标用来强调一个提示、建议或一般说明。*

 

警告

这个图标用来表示一个警告或注意事项。

作者非常看重命名约定的作用。读者可以使用自己所喜欢的任何约定,不过下面是作者为本书所选择的约定。

这是因为在C++中,构造函数的名称必须与类名相同(析构函数也类似)。因为它们同时也是类的函数,所以我们把所有函数名的约定都设置为与类名相同。

这些规则的唯一例外是在使用像 std::vector这样的STL(标准模板库)容器的时候。在这种情况下,我们使用STL的命名约定,这样当读者决定用scpp::vector(本书定义的所有类都位于scpp名字空间)代替std::vector的时候,基本上不需要对代码进行什么修改。像scpp::array和scpp::matrix这样的类采用与scpp::vector相同的命名约定,因为它们是与vector相似的容器。

在我们开始学习之前最后需要注意的一点是,本书的所有代码例子都是在一台运行Max OS X 10.6.8(Snow Leopard)的Mac计算机上使用g++编译器或XCode进行编译并测试的。作者尽量避免依赖平台的特性,但是读者所面临的环境可能是各不相同的。作者尽最大的努力保证本书的附录部分所提供的安全C++库的代码是正确的,在自己的知识能力的范围内保证它们是没有缺陷的。同样,并不能保证读者在使用它们的时候万无一失。书中所讨论的所有C++代码和头文件都在书末的附录中列出,读者也可以从https://github.com/vladimir-kushnir/SafeCPlusPlus下载这些代码。

我们已经概括了本书的学习路线。最终的目标是产生包含更少缺陷的优质代码,能够提高程序员的工作效率并减少头疼的问题,缩短开发周期,并且更能保证代码正确地工作。

本书是为了帮助读者更好地完成自己的工作。一般而言,读者可以在自己的程序和文档中使用本书的代码。读者并不需要联系我们以获得许可,除非读者复制了书中相当大部分的代码。例如,在编写程序时使用本书的几段代码,并不需要获得许可。但是,销售或发布O’Reilly书籍的实例CD-ROM则要求获得许可。引用本书的内容或实例代码回答问题并不需要获得许可。把本书中相当数量的实例代码复制到读者的产品文档中则需要获得许可。

我们赞赏但并不要求读者注明引用。如果要注明引用,它一般包含标题、作者、出版商和ISBN。例如“《Safe C++》,作者Vladimir Kushnir,版权所有2012 Vladimir Kushnir, 978-1-449-32093-5”。

如果读者觉得对代码例子的使用超出了正常范围或上面所提到的许可,可以通过permissions@oreilly.com与我们联系。

Safari® Books Online(www.safaribooksonline.com)是一种按需求定制的数字图书馆,同时以书籍和视频的形式提供来自全球的技术和商业领域前沿作者的专家内容。

技术专业人员、软件开发人员、网页设计人员以及业务或创新专业人员可以使用Safari. Books Online作为探索、解决问题、学习和认证培训的主要资源。

Safari. Books Online为各家公司、政府机构和个人提供了广泛的产品结构和定价程序。订阅者可以通过一个可完整搜索的数据库访问数以千计的书籍、培训视频和预出版手稿,包括O’Reilly Media、Prentice Hall Professional、Addison-Wesley Professional、Microsoft Press、Sams、Que、Peachpit Press、Focal Press、Cisco Press、John Wiley & Sons、Syngress、Morgan Kaufmann、IBM Redbooks、Packt、Adobe Press、FT Press、Apress、Manning、New Riders、 McGraw-Hill、Jones & Bartlett和Course Technology等出版权的作品。关于Safari® Books Online的详细信息,可以在线访问我们。

读者可以把与本书有关的评论和问题发给出版商:

O’Reilly Media, Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

800-998-9938(美国或加拿大)

707-829-0515(国际或本地)

707-829-0104(传真)

我们为本书提供了一个网页,列出了勘误表、实例以及所有的额外信息。读者可以通过下面的地址访问这一网页:

http://oreil.ly/SafeCPP

如果想对本书进行评价或提出技术问题,可以发邮件到:

bookquestions@oreilly.com

关于我们的书籍、课程、会议和新闻的更多信息,可以访问我们的网站:http://www.oreilly.com

我们的Facebook:http://facebook.com/oreilly

我们的Twitter:http://twitter.com/oreillymedia

在YouTube关注我们:http://www.youtube.com/oreillymedia

首先,我想感谢O’Reilly出版社的Mike Hendrickson,他认识到了本书的价值,并鼓励我编写本书。

我非常感谢我的编辑Andy Oram,他所接受的任务非同寻常,因为这是我第一次写作,并且英语并非我的母语。Andy的编辑工作大大增强了本书的可读性。我还非常欣赏他与作者的良好合作方式,并对我们之间的合作感到非常愉快。我尤其要感谢Emily Quill对本书的风格和文本的清晰作出的重要贡献。如果发现书中的错误,都是由我引起的。

我还想利用这个机会感谢Valery Fradkov博士,他在很早以前曾经教我编程,并为我们的第一个程序提供了许多思路。

我还想感谢我的儿子Misha,他帮助我了解最新版本的Microsoft Visual Studio的动态。最后,我必须对我的妻子Daria表达永恒的感谢,感谢她在进行此项目期间对我的支持。

[1] 译注:在程序员社区中,一般都是直接引用原文bug,并不进行翻译。出于出版物的严谨与规范,本书还是把bug翻译为“缺陷”。


本书第一部分对可能潜入到C++程序中的各种错误进行了分类。我们描述了在编译时而不是在测试时捕捉错误的价值所在,并提出了一些基本原则。当我们寻找特定的技巧以防止或捕捉后面章节所讨论的缺陷时,就需要记住这些原则。


C++语言是非常独特的。虽然实际上所有的编程语言都从其他语言中吸收了一些思路、语法元素和关键字,C++却是吸收了另一种完整的语言,即C语言。事实上,C++语言的创建者Bjarne Stroustrup原先把他的新语言命名为“带类的C”。这意味着如果我们已经使用了一些C代码,并且由于某种原因(例如科研或贸易)切换到一种面向对象的语言,就不需要在移植代码方面采取任何措施,只要安装新的C++编译器,就可以对旧的C代码进行编译了,并且效果和原先的一模一样。我们甚至会觉得已经完成了从C到C++的转换。最后这种想法虽然距离真相还很远,用真正的C++所编写的代码与C代码看上去存在很明显的区别,但它还是提供了一个逐渐过渡的选项。也就是说,我们可以从现在编译运行的C代码出发,逐渐引入用C++所编写的新代码段,慢慢与它们混合在一起,最终实现到纯C++的切换。因此,C++的层次式设计具有它独特的市场推动力。

但是,其中还是存在一些复杂的地方:随着C的完整语法被新语言完整地吸收,它的设计哲学和存在的问题也同样被吸收。C语言是在1969年~1973年期间由Dennis Rithie在贝尔实验室创建的,其出发点是为了编写Unix操作系统。这项工作的一个伴随成果是诞生了一种高效的高级编程语言(与需要编写每条计算机指令的汇编语言相比)。也就是说,它所产生的编译后的代码应该具有尽可能快的速度。这种新的C语言的其中一项公开原则是,用户不应该为他没有使用到的特性而受到拖累。因此,为了追求高效的编译代码,对于程序员没有提出明确的要求,C就绝对不会加以考虑。C语言是为了速度而不是为了舒适而创建的,这就产生了一些问题。

首先,程序员可以创建一个某种长度的数组,并用一个超出该数组边界的索引值访问一个元素。更容易被滥用的是C的指针运算,程序员可以把指针运算所产生的任何值作为内存地址并对它进行访问,不管这块内存是否应该被访问。(实际上,这两个问题其实是同一个,只不过使用了不同的语法。)

程序员还可以在运行时使用calloc()和malloc()函数动态分配内存,并使用free()函数负责动态内存的销毁。但是,如果忘了销毁或者不小心销毁了多次,其结果可能是灾难性的。

我们将在本书的第二部分深入讨论这些问题中的每一个。需要重视的是,C++在继承整个C时,除了传承它的高效原则,还继承了它的所有问题。因此,C++代码中的部分缺陷就来源于C。

但是,故事并没有结束。除了来自于C的问题,C++自身也存在一些问题。例如,大多数人认为友函数和多重继承并不是良好的编程思路。C++具有自己分配内存的方法,它并不是调用像calloc()或malloc()这样的函数,而是使用操作符new。new操作符并不仅仅分配内存,它还创建对象,即调用它们的构造函数。与C的精神相同,使用delete操作符删除动态分配的内存是程序员的责任。现在的情况看起来与C相同:我们分配了一些内存,然后删除它。但是,复杂之处在于C++具有两种不同的new操作符:

在第一种情况下,new操作符创建了一个MyClass类型的对象。在第二种情况下,它创建了一个相同类型的对象数组。与之对应的是,C++具有两种不同的delete操作符:

当然,一旦使用了“带方括号的new”创建对象,就需要使用“带方括号的delete”删除它们。这样就可能导致一种新的错误:混用new和delete,其中一个带了方括号而另一个没有带方括号。如果出现了这种错误,就会对内存堆产生巨大的破坏。因此,我们可以总结如下:C++的缺陷大部分来源于C,但C++也引入了一些自讨苦吃的新方法。我们将在本书的第二部分讨论这些话题。


如果在编译时捕捉缺陷与在运行时捕捉缺陷之间进行选择,只要有可能,都应该在编译时捕捉缺陷。这样做的理由有很多。首先,如果一个缺陷是被编译器所检测到的,我们将看到一条文本信息,准确描述了所发生的错误是什么,它是在哪里发生的,发生在哪个文件以及发生在哪一行。(作者在这里可能稍微有点乐观,因为在有些情况下,尤其是在涉及STL时,编译器所产生的错误信息是相当含糊的,需要花费精力才能推断出编译器实际所描述的含义。但是,编译器总是在不断地完善中,大多数情况下它们对问题的描述是相当清晰的。)

另一个理由是完整的编译(进行了最终链接)覆盖了程序中的所有代码。如果编译器没有返回错误或警告,就可以百分之百地确信程序中不存在编译时可以检测到的错误。但对于运行时测试,就不能做出这样的保证。当代码相当庞大时,很难保证所有可能的分枝都被测试到,也无法保证每一行代码都至少执行1次。

即使我们能够保证这一点,仍然不够。同一段代码对于一组输入可能正确地完成任务,但对于另一组输入可能无法正确地工作。因此,通过运行时测试,我们永远无法完全保证对所有东西都进行了测试。

最后还存在时间因素:我们在运行代码之前执行编译,因此如果在编译时捕捉到了错误,就可以节省时间。有些运行时错误是在程序的后期出现的,因此可能要等几分钟甚至几小时的运行之后才会发现一个错误。更糟的是,这种错误很可能是无法复制的,它可能以一种看上去随机的方式,在连续运行时出现并消失。相比之下,在编译时捕捉错误就简单得多!

现在我们应该已经坚信,只要有可能,就尽量在编译时捕捉错误。但是,怎样才能实现这个目标呢?让我们观察一对例子。

第一个例子是一个Variant类的故事。曾几何时,一家软件公司编写了一个Excel插件。这是一个文件,被Microsoft Excel打开之后向它添加了一些新功能,可以在Excel单元格中被调用。由于Excel单元格可以包含不同类型的数据,包括整数(例如1)、浮点数(例如3.1415926535)、日期(例如1/1/2000)甚至是字符串(“This is the house that Jack built”),因此这家公司开发了一个Variant类,它的行为类似于变色龙,可以包含任意上述数据类型。但是,随后有人提出了一个思路,就是一个Variant对象可以包含另一个Variant对象,甚至可以包含一个Variant类型的vector(即std:: vector<Variant>)。这些开始被使用的Variant对象并不仅仅与Excel进行通信,还与内部代码进行通信。因此,当我们观察函数的签名时:

很显然,完全没有办法理解这个函数期望接受什么类型的数据,以及它将返回什么类型的数据。因此,如果它期望接受一个日期数据,而我们向它传递了一个无法组成日期的字符串,这个错误只能在运行时才能被检测到。正如我们刚才讨论的那样,应该尽量在编译时发现错误。因此,这种方法使我们无法使用编译器通过类型安全轻松地捕捉到错误。这个问题的解决方案将在后面讨论,不过简洁的答案就是用不同的C++类表示不同的数据类型。

上面这个例子是真实的,但有些极端。下面是一个更加典型的情况。假设我们正在处理一些金融数据(例如股票的价格),并且为每个值加上对应的时间戳,即这个价格被观察时的日期和时间。那么我们应该怎样对时间进行测量呢?最简单的解决方案是对过去某个时间(例如1/1/1970)以来的秒数进行计数。

有人突然意识到实现了这项功能的函数库所提供的是32位的整数,最大值约为20亿左右。如果超过了这个最大值,就会发生溢出而成为负数。在距离时间轴的起点大约68年之后(即2038年)就会发生这种情况。它所导致的问题与著名的“千年虫”问题相似。为了修正这个问题,可能需要检查相当数量的文件,找到所有这些变量,并把它们的类型更改为int64。后者的长度是64位,能够表示的时间长度是32位整数的40亿倍左右,对于再小心谨慎的人来说都是足够的了。

但是现在又出现了另一个问题。有些程序员使用了int64 num_of_seconds形式,另一些人则使用了int64 num_of_millisec形式,还有一些人使用了int64 num_of_microsec形式。编译器绝对没有办法判断出一个接受毫秒时间的函数实际所传递的是表示微秒的时间,反之亦然。当然,我们可以对需要分析的股票价格所处的时间间隔预设一些条件,例如从1990年直到未来的某个时刻(例如3000年),然后在运行时增加一项安全检查,确保传递给函数的值必须位于这个时间间隔之内。但是,这将导致许多函数都需要配备这种安全检查,可能需要花费大量的人力。如果有人在将来决定回过头来分析20世纪期间的股票价格又会怎么样呢?

现在,如果我们创建一个Time类,在内部实现中隐藏了从什么时间开始,以及用什么时间单位(秒、毫秒等)进行测量等细节,上面这些杂七杂八的问题就可以轻松得以避免。这种方法的一个优点是如果我们错误地传递了其他日期数据,而不是传递了时间(现在用Time类型表示),编译器马上就能捕捉到这种错误。这种方法的另一个优点是,如果Time类当前是用毫秒实现的,并且以后为了提高精度用微秒表示,我们只需要编辑一个类,修改内部实现的细节,而不会影响其余的代码。

因此,我们怎样才能在编译时而不是在运行时捕捉类型错误呢?我们首先可以用一个单独的类表示每种类型的数据。我们用int表示整数,用double表示浮点数,用std::string表示文本,用Date表示日期,用Time表示时间,对于其他类型的数据也都用一个单独的类表示。但是,只采用这种做法仍然是不够的。假设我们有两个类Apple和Orange,并有一个期望接受一个Orange类型的参数的函数:

但是,我们可能不小心向它提供了Apple类型的对象:

在有些情况下,这样的代码可以通过编译,因为C++编译器试图向我们提供帮助。只要可能,它会把Apple平静地转换为Orange。这可能通过以下两种方式发生。

(1)如果Orange类具有一个只接受一个Apple类型的参数的构造函数。

(2)如果Apple类具有一个可以把它转换为Orange的操作符。

当Orange类具有下面这样的定义时,就会发生第一种情况:

它甚至可以像下面这样:

即使在最后这个例子中,构造函数看上去像是具有两个输入,但它也可以只用一个参数就可以被调用,因此它也可以隐式地把Apple转换为Orange。这个问题的解决方案是用关键字explicit声明这类构造函数。这种做法可以防止编译器执行自动(隐式)转换,这样我们就可以迫使程序员在期望接受Orange的地方必须使用Orange:

第二个例子需要对应地修改为:

另一种让编译器知道怎么把Apple转换为Orange的方法是提供一个转换操作符:

这个操作符在此处的出现是非同寻常的,说明程序员用一种明确的方式向编译器提供了一种把Apple转换为Orange的方法,它并不是什么错误。因此,对所有接受一个参数的构造函数用关键字explicit进行声明,这是值得推荐的做法。一般而言,隐式转换的所有可能性都是不好的思路。因此,如果想按照上面这个例子一样在Apple类中提供一种把Apple转换为Orange的方法,下面是一种更好的方法:

在这个例子中,为了把Apple转换为Orange,需要采用下面的方式:

另外还有一种方法可以混合不同的数据类型,即使用枚举(enum)。考虑下面这个例子:假设我们定义了下面这两个枚举,分别表示一周中的某天以及月份。

这些常量实际上都是整数(例如,C内置的int类型)。如果我们有一个期望接受一周中的某天作为参数的函数:

下面这个调用将会在不产生任何警告的情况下通过编译:

在运行时,我们能够采取的措施不多,因为JAN和MON都是与1相等的整数。捕捉这类缺陷的方法是不使用创建整数的“单纯功能”枚举,而是使用创建新类型的枚举:

在这种情况下,期望接受一周中的某天为参数的函数将被声明为:

像下面这样试图用一个Month值调用这个函数:

将会产生编译错误:

这正是我们在这个例子中期待产生的效果。

但是,这种方法具有一个消极因素。在这个例子中,用枚举创建整型常量时,我们可以编写如下的代码:

但是当我们使用枚举创建新类型时,如下面的写法:

就无法通过编译。因此,如果我们需要迭代枚举的值,可以像原来一样使用整数。

当然,任何规则都有例外,有时候程序员有理由编写像Variant这样的类,允许进行隐式类型转换,以满足特定的需要。但是,绝大多数时候应该完全避免隐式类型转换,这就允许我们充分利用编译器检查不同变量类型的功能,早期(即在编译时)捕捉潜在的错误。

现在,假设我们已经尽自己所能使用了类型安全。遗憾的是,除了bool和char类型之外,每种类型可能包含的值的数量都是天文数字,通常只有一小部分值是合理的。例如,如果我们使用double类型表示股票的价格,可以很合理地确定股票的价格将在0到10 000之间波动(唯一的例外是Berkshire Hathaway公司的股票,它的主人Warren Buffet显然并不相信把股票价格保持在合理范围内是个好主意,因此他从不对股票进行除权,在本书写作之时这个股票的价格是每股10万美元)。但即使是Berkshire Hathaway这样的股票,它的价格仍然只使用了double类型的很小一部分,因为double的范围高达10308,并且还包含了完全不适合表示股票价格的负数。由于大多数类型只有一小部分值是合理的,因此总是存在一些只能在运行时才能诊断的错误。

事实上,C语言的大多数问题,例如指定了越界索引,或通过指针运算不恰当地访问内容,只能在运行时才能得到诊断。由于这个原因,本书的剩余部分主要专注于讨论捕捉运行时错误。

本章所讨论的在编译时诊断错误的规则如下。


相关图书

代码审计——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++开发指南

相关文章

相关课程