2.3 调试技术
最好的调试习惯是使用本章开始的时候所述的断言;使用断言可以在程序代码真正出现问题之前,帮助程序设计人员找到其中的逻辑错误。这部分内容介绍的一些技巧和技术可以在程序调试过程中给予一定的帮助。
2.3.1 用于代码跟踪的宏
某些情况下,在程序执行过程中将执行到的每一条语句行代码打印到cout或一个跟踪文件中是很有用处的。下面是一个能够完成这种功能的预处理宏:
现在可以用这个宏来处理跟踪语句代码了,然而这可能会导致一些问题。例如,如果采用下面的语句:
将这两行程序都放在TRACE()宏中,就会得到如下代码:
预处理之后,代码会变成这样:
这并不是我们想要的。因此,使用这种技术时必须格外细心。
下面是TRACE()宏的一个变种:
如果要想显示一个表达式,只需用它作为参数调用D()。程序执行时会显示这个表达式,接着显示它的值(假设这个表达式的结果类型重载了运算符<<)。例如,可以这样写D(a+b)。并可以在任何时间使用这个宏来检查中间结果的值。
这两个宏能够完成调试器所能实现的两个最基本功能:跟踪代码的执行过程并显示表达式的值。好的调试器是个杰出且高效的工具,但是在某些时候,可能找不到可以使用的调试器,或者找到的调试器不好用。但是不管在任何情况下,读者都能使用上述两种技术。
2.3.2 跟踪文件
免责声明:这部分和下面部分内容所包含的代码还尚未被C++标准正式接受。特别是,这里使用宏重定义了cout和new,如果不仔细的话可能会造成奇怪的结果。这里提供的例子可以在作者使用的所有编译器中正常工作,并提供有用的信息。这是本教材惟一一处偏离编码实践兼容性标准的地方。是否使用在于读者自己!注意,为了使这个例子能够工作,必须使用using声明,这样可以去掉cout前面的名字空间前缀,例如,在这段代码中不能使用std:cout。
下面的代码简单地创建了一个跟踪文件,并把所有本来应被送到cout的输出送到了这个跟踪文件。现在必须做的所有事情就是#define TRACEON并且包含相关的头文件(当然,仅仅将两行关键代码正确地写到文件中是相当的容易)。
下面这段代码是对上述头文件的简单测试:
因为cout已经被Trace.h中的宏修改成了其他东西,所以程序中所有的cout语句现在都把信息送到了跟踪文件。当读者所使用的操作系统不能简便的进行输出重定向时,这种方式能够方便地将输出保存到文件中。
2.3.3 发现内存泄漏
第1卷中讲解过下列直观的调试技术:
1)为了对数组边界进行检查,可以使用第1卷C16:Array3.cpp中实现的Array模板来定义所有数组。当准备发行代码的时候,可以关闭边界检查以提高性能。(尽管这种方法对于指针数组不管用。)
2)检查基类中的非虚析构函数。
跟踪new/delete和malloc/free语句
通常的内存分配问题包括:对不是在动态存储区(free store)上分配的内存误使用delete,多次重复释放在动态存储区上分配的一个内存,最常见的情况是忘记删除一个指针。这一节讨论了一种能够帮助跟踪这类问题的系统。
上一小节所述免责声明的附加条款:因为这种方法重载了运算符new,所以下述技术在某些平台上可能无法使用,而且只能用于不直接调用operator new()函数的程序。在这本教材中,我们一直非常小心,希望只介绍完全符合C++标准的代码,但是这是一个特例,主要基于如下原因:
1)尽管这种技术是不合标准的,但是它能用于很多编译器。[1]
2)我们希望利用这种方法来阐述某些有用的思想。
为了使用这种内存检查系统,在这里只需包含头文件Mem Check.h,并链接MemCheck.obj到应用程序中,这个系统能够截获所有对new和delete的调用,并且通过在程序中调用宏MEMON()(在本章的后面解释)来初始化内存跟踪。所有有关内存分配和释放的踪迹都被打印在标准输出上(通过stdout)。当使用这种系统的时候,new运算符所在文件的文件名和new运算符所在行的行号被保存了下来。这是用operator new的定位语法(placement syntax)来完成的。[2]虽然在典型的情况下,只有当需要将一个对象放到内存中的指定位置时才使用定位语法。这种内存检查方法也可以创建带有多个参数的operator new()来达到目的。下面的例子就是用有多个参数的operator new()来实现的,当new被调用的时候,用_FILE和LINE宏来获得其所在的文件名和行号并存储:
重要的是,当读者想跟踪动态存储区的活动时,可以在任何源文件中包含这个文件,但是它必须是所有被包含文件的最后一个(在其他#include之后)。标准库中大部分头文件定义的是模板类,并且大多数编译器使用模板编译的包含模型(inclusion model)(这句话的意思是说,所有源代码都包含在头文件中),MemCheck.h中替换new的宏将会篡改库中源代码所使用的所有new运算符的实例(并且可能造成编译错误)。另外,读者大概只想跟踪存在于自己编写的代码中的内存错误,而不会理会库中的代码是不是有错。
下面的文件包含内存跟踪的实现,所有的输出都是通过C的标准输入/输出来完成的,而没有使用C++的输入输出流。这样做没有什么差别,虽然对动态存储区使用输入输出流也不会受到干扰,但是当尝试使用输入输出流时,有些编译器会报错。而所有编译器都能接受<cstdio>版本的输入输出。
布尔型标志traceFlag和activeFlag是全局变量,可以在代码中用宏TRACE_ON()、TRACE_OFF()、MEM_ON()和MEM_OFF()来修改它们。一般来说,可以用MEM_ON()和MEM_OFF()这对宏将main()函数中的所有代码包围起来,这样内存的分配和释放就会一直被跟踪。内存跟踪显示了函数operator new()和operator delete()的活动。这种跟踪在默认情况下是打开的,可以用TRACE_OFF()来关闭它。任何情况下,最终结果都会打印出来(参考本章后面的测试运行)。
MemCheck工具在Info结构类型的数组中保存全部内存地址、文件名和行号:内存地址是使用operator new()分配内存时得到的,文件名是new运算符所在文件的文件名,而行号是new运算符所在行的行号。为了避免与放入全局名字空间中的其他名字冲突,应该把尽可能多的内容放在匿名名字空间中。当程序停止的时候,单独存在的Sentinel类调用一个静态对象的析构函数。这个析构函数检查memMap,看看是否有等待删除的指针(表明程序中存在内存泄漏)。
在程序中,operator new()使用malloc()来获取内存,然后把指针和相关的文件信息保存到memMap中。operator delete()函数做相反的工作,它调用free()释放内存并将nptrs的值减1,但是它首先会检查传送过来的指针参数是否在映射表(map)中。如果这个指针不在映射表中,就说明程序员正在试图释放的不是在动态存储区上分配的内存,或者已经释放了这段内存,并把这段内存的地址从映射表中删除了。activeFlag变量在这里非常重要,因为不想对系统关闭过程中所做的内存释放活动进行处置。通过在程序代码的最后调用MEM_OFF()可以将activeFlag设为false,这样,随后的delete调用将会被忽略。(在实际的程序中,这样做是不好的,但是,在这里这样做的目的是发现内存泄漏,而不是调试库。)简单地说,现在做的所有工作就是排列new和delete,将它们进行匹配。
下面是一个使用MemCheck工具进行测试的简单例子:
这个例子证实了,可以在如下场合中使用MemCheck:代码中使用了流,代码中使用了标准容器(standard containers),以及代码中某个类的构造函数分配了内存。指针p和q的内存分配和释放没有问题,但是指针r不是指向在堆上分配的内存的指针,所以程序的输出显示了一个错误,报告程序试图删除一个未知的指针。
因为调用了MEM_OFF(),所以后面vector和ostream对operator delete()的调用过程并没有进行。读者仍然可能会见到容器重新分配内存时调用delete所产生的输出结果。
如果在程序的开始就调用TRACE_OFF(),那么输出结果将是:
[1]本书主要的技术审阅者,Dinkumware公司的Pete Becker,提醒读者使用宏来替换C++关键字是不符合规定的。他对这种技术的评价是:“这是一种旁门左道(dirty trick)。有时候必须利用旁门左道来找出代码不能正确运行的原因,所以可以把它放到我们的工具箱中,但是不要把它留在发行版本中。”这是对程序员的告诫。
[2]感谢C++标准委员会的成员Reg Charney提出这种诀窍。