2 编写稳的代码——代码的鲁棒性
“没有最稳,只有更稳”,几乎没有一个程序是完全稳定的,简单的HelloWorld在内存吃紧的时候也可能会束手无策。但,我们的目的是让程序更稳定一点……
2.1 小心掉下去——错误陷阱
一切从小事开始,在C++语言里,存在着很多危险的错误陷阱,就像《雷电II》里面的漩涡,一不小心就会掉进去——只有学会正确地避免它们,才能将程序的危险性降低到最低限度。
以下内容介绍常见的错误陷阱:
2.1.1 奇怪的表达式(expression)
嗯,看一看下面的表达式:
这是完全合法的一例表达式,知道该死的i会有什么样的值? 这是常见的面试题中的一种,许多老师和招聘单位总会拿出这样的表达式来整人并藉以炫耀,常常让人想到孔乙己:“你知道茴字有几种写法?”
写出这样的表达式是不负责任的,不同的编译器很有可能会得出不同的结果。不缺德的程序员应该避免这样的写法。否则,会有想象不到的灾难来临。
使用表达式时最好遵循以下规则:
- 不要过分地依赖于各操作符的优先权,在必要的时候使用括号()。如:
- 不要过分地依赖于表达式的赋值顺序,如尽量少用++i,++j等。
- if语句谨防“==”写成“=”。如:
牢记这些原则,否则自己酿造的苦果,还得自己去独享。不负责任的表达式永远不会让编译器弄昏,而作为程序员的你,只会越来越糊涂。
2.1.2 奇怪的指针(pointer)
指针问题,其实就是内存问题。指针是好东西,内存里面的东西,它高兴指哪就指哪,非常方便。然而,指针之于程序员正如手枪之于顽童,使用不当,没准儿,顽童就会沦为凶手。
以下是让令人心惊肉跳的内存错误:

图1 臭名昭著的内存错误窗口
出了这种问题,对于程序来说,基本上是没救了。这种还只是明显的错误,还有更可怕的,以下是个更危害的例子:
以上是个完整的例子,程序没有报错,以下是程序某次运行输出的结果:
还是出了问题。很显然,Bill Gates的身高不会有这么高。问题出在strcpy()这个指针函数,它无视安全规则,在内存里胡写一气,结果,Bill Gates的身高就被改写了(当然,strcpy()是个标准函数,错误仍在于你——分配给CUser::m_sUserName的内存太小)。
指针指来指去不是个好办法,当代码量增大的时候,指针操作引起的错误会越来越不可控制。这些错误往往让程序员非常头疼,同时让用户也很头疼,当他们正在满腹牢骚地向程序言抱怨时,这些潜在的错误没准儿又会偷偷地隐藏起来。
看来必须要采取措施了,以下是一些必要的措施:
- 不要数组越界。数组越界时,C++根本不会提醒你,用好自己的数组是程序员的义务。
- 不要使用“野指针”。
指向无效内存(相对于本进程而言)的指针,即野指针。野指针是“没人管的指针”,然而你还使用它,那程序注定要出错。野指针出现的成因大致如下:
谁知道printf()会输出什么样的内容?!
局部变量的自动销毁往往也可能造成野指针的形成,如:
想都不用想,foo()输出的结果肯定要出错。
怎么办呢?对策是有的,以下为笔者推荐的指针使用原则:
- 使用正确类型的指针。指针就是一段内存的地址,如果将这段内存解释为其他类型(非基类)的内容,结果肯定要出乱子。NULL指针是这种情况的特例,如:
如果pUser指向NULL,或者pUser只是指向一个简单的整数,恭喜你,前面那个恐怖的窗口肯定会来光顾了。
- 勤用const。使用const可以防止程序员自己不能意外修改不应该被修改的指针或内容,如:
- 正确释放内存。C++支持程序员使用new/delete和malloc()/free()来申请/释放内存,“解铃还需系铃人”,切记不要“配错对”,此外,delete既可用于变量亦可用于变量数组,最好不要弄混淆。
- 释放后的内存容易造成野指针,一般推荐的用法是马上将指针赋值NULL,防止再用:
2.1.3 奇怪的析构(destruction)
析构总会不声不响地发生,当你兴致勃勃地准备使用某个内存变量时,却意外地发现这段内存已经释放了,没有比这更懊恼的事情了,以下是个极好的例子:
程序的输出结果是一大堆莫名其妙的字符(倒霉的程序员会经常遇到这些字符,“烫烫烫”或者“葺葺葺”什么的),果然,内存又出错了。
是析构函数捣的鬼。当调用foo()时,C++将s对象完全拷贝了一份作为参数(即临时对象m,注意m.m_pData == s.m_pData,没有谁会为m.m_pData再申请一份内存)传送过去,而在结束foo()函数之前,C++将会调用析构函数销毁掉临时对象m,~CString()释放了m.m_pData指向的内存(也即s.m_pData苦心经营的那份内存),当s对象再来取值的时候,咣当!悲剧发生了。
看来,析构不是什么好玩的东西,为了避免析构产生不必要的麻烦,以下是一些程序员最好做的:
- 对于内存动态分配的类,定义其拷贝函数(copy constructor)。对于以上那个失败的CString,就可以通过以下的拷贝函数来补救:
这样当foo()被调用之前,C++会调用拷贝函数为它准备一份崭新的CString对象,它有自己的内存空间,这样恐怖的错误就避免了。
- 将析构函数定义成virtual(虚函数),以使C++能准确无误地找到派生类的析构函数。
- 注意析构的顺序。在多继承和多层函数调用的时候,理解各对象的析构是很有必要的。此外,在Windows消息编程中,理解析构的正确时机也是至关重要的,不要固执地要求一个早已销毁的窗口再为你显示“Hello World!”。
2.1.4 奇怪的内存泄漏(memory leak)
在大多数人眼中,内存总会象阳光一样应有尽有。不是这么一回事,内存总会有不够用的时候,图2示出了一例惨剧:

图2 内存不足窗口
除了正当的内存消耗,造成内存紧缺的另一个常见原因,就是内存泄漏。“内存泄漏”听起来很玄乎,其实就那么回事。通俗一点讲,内存泄漏(memory leak)就是“你的应用程序借了系统的内存却忘了还”,你的程序退出后,它所申请的内存还没有得到完全释放,这就是“内存泄漏”。很显然,这号事情发生,对那些正在眼巴巴地等着内存资源的应用程序来说,绝对不是好消息。
内存泄漏不是最坏的事,Windows2000据说可以将那些泄漏的内存给找回来。但谁都知道,“光借不还”确实不是个好习惯。
究根求源,程序中的内存泄漏主要有:
- 用new,malloc,GolbalAlloc等函数分配的堆内存没有用delete,free,GlobalFree等释放。记住适时释放堆内存并不仅仅靠好的记忆力就能办到的,旧版的CJLib就存在严重的内存泄漏问题。当申请内存和释放内存的动作不是来自于同一个模块,同一个开发者的时候,内存泄漏更容易发生。以下是个很常见的例子,Windows提供了一个用于选择文件夹的API:
这个函数用于显示“选择文件夹”的窗口(如图3所示)并支持用户选择操作,它的出口参数是一个指向ITEMIDLIST变量的指针(如果用户选择“取消”按钮,则为 NULL),这段内存由SHBrowseForFolder申请,所以程序员有义务释放之。封装后无内存泄漏的BrowseForFolder函数代码如下:

图3 容易造成内存泄漏的浏览文件夹窗口
- 在绘图时创建的GDI对象没有释放。不过,使用MFC的GDI对象类基本上可以高枕无忧,当局部变量失效后对象会自动销毁,并释放对应的GDI对象。如下为CGDIObject的析构函数:
注意,除非你会自觉delete,否则不要去new一个CGdiObject,如:
而最好写成如下形式:
内存泄漏可以通过CMemoryState检测到(参见2.2.2),但问题的解决还需要你的好习惯——“借了就该还”!
2.2 如果掉下去了——调错方法
现在你已经学会如何小心翼翼地编写代码,并且很巧妙地绕开以上列举的种种陷阱。但人总会有很背的时候,某个烦恼的下午,你突然发现,倒霉的你还是掉进了陷阱……天哪,你该怎么办?该怎么去处理去定位这些刁钻的错误?别急,慢慢来……
2.2.1 使用调试器跟踪错误
Visual C++ IDE(Integrated Development Environment,集成开发环境)一般都提供了功能强大的调试器,包括联编(build)工具和调试(debug)工具,图4为Visual C++的联编工具栏:

图4 联编工具栏
从左至右各按钮分别是:
- 编译(compile)
- 联编(build):编译并连接程序;
- 停止联编(stop build)
- 执行(execute)
- 开始调试运行(go)
- 设置断点(breakpoint):图6示出在Damn.cpp中设置的断点,程序在此行将暂停运行,借助监视工具可以查看内存和寄存器的情况;
图5为Visual C++的调试工具栏:

图5 调试工具栏
自上而下,从左到右,各按钮的含义如下:
- 重启程序(restart)
- 终止调试(stop debugging)
- 中止运行(break executing)
- 代码修改生效(apply code changes)
- 显示下一条语句(show next statement)
- 进入模块(step into):进入当前调用的函数体
- 单步执行(step over)
- 跳出模块(step out):跳出当前调用的函数
- 运行至光标处(run to cursor)
- 快速查看(quick watch)
- 监视(watch):用以查看程序中表达式的值
- 变量(variables):显示当前模块的自动变量、局部变量、this变量值
- 寄存器变量(registers):显示各寄存器的值
- 内存(memory):显示内存的内容
- 调用栈(call stack):显示函数调用的栈结构
- 反汇编代码(disassembly)
这些功能都很常用,通过调试器可以快速发现错误发生的地点和观察程序的行为,图6为程序Damn.cpp的调试现场,可以看出,通过调试工具,可以很及时地观察到各内存变量的值变化,内存块和寄存器的内容。
![[原始尺寸:768x537 点击查看大图]](/upload/2004/04/1106.png)
图6 Damn.cpp调试现场窗口
配合使用各调试功能,对于一般明显性的错误,都可以快速地定位到错误源。
2.2.2 使用CMemoryState检测内存泄漏
CMemoryState是Visual C++提供的内存检测类,主要用于检测程序中的内存泄漏。CMemoryState具有以下成员函数:
使用CMemoryState检测内存泄漏的步骤很简单,先准备两个对象分别在程序的首尾位置设置检测点,然后调用Difference()得出它们的差异,并将差异打印出来,程序员即可得知程序中内存泄漏的程度。
以下是使用CMemoryState的例子,注意观察:
其Debug结果如下:
可以从输出数据发现,在delete [] p释放内存之前,系统存在内存泄漏(泄漏值为400 bytes),在释放之后,内存泄漏消失了。
使用CMemoryState,可以有效地检测到程序中的内存泄漏。但至于如何避免内存泄漏的发生,还是参考2.1.4中提出的原则为妙。
2.2.3 使用断言(ASSERT)警告错误
错误往往在灾难真正到来之前就已经发生。在乘客候车室里打上“严禁易燃易爆品”的标语要比准备更多的灭火器和消防员经济得多——程序员完全可以在代码中的某些位置严格防止不法问题的发生,使用ASSERT即可达到这个目的。
在Visual C++中,断言ASSERT 是仅在Debug 版本起作用的宏,它用于检查“不应该”发生的情况。ASSERT的语法如下:
正如ASSERT的含义,这个语句的意思就是“条件bExpression一定得满足”(即条件bExpression为true),当bExpression为false的时候,Visual C++将会给出警告(如图7所示)。
例如,2.1.2中的例子可以改版如下:
当pUser不幸为NULL的时候,Visual C++在执行到ASSERT语句时将会弹出以下的提示窗口:

图7 ASSERT窗口
选择“终止”以中止该程序;选择“忽略”以继续执行后面的语句;如果要查看错误源,则选择“重试”。
对于Release版本,ASSERT没有任何作用。ASSERT仅仅用以辅助程序员在开发重检查错误。
2.2.3 使用异常(exception)机制捕获错误
ASSERT可以警告错误,但并不能解决错误,观察以下的代码:
这是个简单的相除程序,很显然,在除0的时候,它闯祸了:
尽管ASSERT给予警告(也仅仅是警告而已!),但一旦错误发生,应该有其相应的错误处理(handler)。在以上的例子中,发生了除0,仅仅ASSERT是不够的,这时候返回任何一个标志错误的值都不合适,这种情况下,必须使用到异常(exception)。以下是相除程序的鲁棒版:
运行结果如下:
没问题了,最危险的事情都能对付了。异常是C++中的新机制,它能从程序的出错点“噌”地一下子跳转出来,直到遇到合适的错误处理。使用异常机制的步骤如下:
Ø 定义新的异常类型(异常其实就是一个类,如:CDividedByZero)。
Ø 抛出该类型的异常(throw new CDividedByZero)。
Ø 捕获该异常(try…catch…finally)。
异常的语法在此不再展开,有必要提出的一点是,很多程序员都不喜欢使用异常(大名鼎鼎的Microsoft Word就包含一个未捕获的内存异常,如图8所示),他们觉得这样会使程序变得臃肿,而且异常类似于goto跳转的机制让他们很不舒服(谁愿意一上一下地折腾?),他们总是假定没有任何异常的情况发生,这样恰恰是阻碍程序推广和复用的重要原因。不喜欢异常,不是一个好习惯,要改!

图8 Microsoft Word未捕获的异常