怎样编写易于调试的VC代码.docx
《怎样编写易于调试的VC代码.docx》由会员分享,可在线阅读,更多相关《怎样编写易于调试的VC代码.docx(24页珍藏版)》请在冰豆网上搜索。
怎样编写易于调试的VC代码
编写易于调试的VC代码
一程序的设计
要避免错误,首先要从好的设计开始。
对于程序的设计,需考虑到程序的两个特性:
1简单性
大多数常见的错误来源于程序设计中不必要的复杂成分。
一个好的设计应该反映问题本身的要求,而不必为了刻意追求“满足将来的需要”而添加不必要的特性。
实际上,简单优雅的设计比那些复杂的设计更能迎合未来的需求。
2耦合性
耦合(decoupling)性用来衡量不同对象之间的依赖程度。
松耦合的程序易于理解和实现,易于测试和维护,且这种程序包含错误的可能性小,错误也较容易发现和清除。
二编程风格
编程风格是个人问题,有很大的随意性。
一个好的编程风格不仅让代码易理解,也易于调试。
好的编程风格包括:
1清晰地书写代码
如果没有必要,尽量不要使用语言中的高级特性,因为这些特性不易于理解和调试。
使用大多数程序员都能理解的语言成分来书写代码不易犯错且易于理解和维护。
2编写结构良好的代码
当程序崩溃时所能得到的最基本的调试信息是源代码文件、问题所在行的行号和一个调用栈(callstack)。
调用栈是调试程序时最有帮助的部分,它提供错误出现的上下文,也就是带参数的函数调用序列。
你书写的代码结构越好,调用栈就能给你越多信息。
3使用良好的标识符
一个好名字能使你的代码更容易被理解和维护。
流行的匈牙利命名法(HungarianNotation)实际上是把标识符的意义和表示方法结合起来。
现在,匈牙利命名法表现出不少的局限性,匈牙利命名法过于看重前缀的作用,对一个变量的表达信息不完整,实际上并没有传递多少有用信息,它使代码难于阅读,难以维护。
一个好的命名传统是指示出变量的作用域以便在需要的时候检查它的定义,并明确地指出一个变量是全局的、局部的还是成员数据。
依赖变量的定义比依赖匈牙利前缀更加有用和可靠。
好的名字能够用平常的语言概括出该标识符所代表的实体的含义。
在选择类、函数、变量的名字时可以考虑以下几个原则:
取简单的描述性名字,好的名字能简要地概括出这个标识符代表的含义。
避免简写,简写使标识符难于阅读和记忆,尽量使用混合大小写的完整的单词。
避免相似性的文字,避免混淆。
避免采用一般的或随机产生的名字,而应采用有实际意义的名字。
如欲从按钮类派生位图按钮,取一个CBitmapButton,而不是CMyButton。
4用简单的语句行
在VC中,一行可写多个语句。
但调试是面向行的,过于复杂的行难于调试。
因此,从调试的角度出发,每一个语句都应独自成行。
5使用统一的排列
统一的排列方式使类、变量的定义和语句更加明显。
6用括号使书写清晰
你不一定能都记住各种运算符的优先级和结合律,而使用多余的括号并不影响编译后的代码。
因此,如果你不能确定是否需要括号时,请加上它。
7使用好的注释
用好的注释能使你的代码不易出错,而且便于其他程序员阅读,便于理解和维护。
三编写程序时应注意的问题
1充分利用VC++的特性
可用下列技术来充分利用VC++的编译器的特性:
(1)用const代替#define来创建常量;
(2)用enum代替#define来创建常量集合;
(3)用内联(inline)函数代替#define;
这三种技术用C++而不是C预处理。
使用预处理的问题在于编译器对于预处理器所作的事情一无所知,因此无法用数据类型检查错误和不一致的地方。
预处理的名字不在符号表里,因此也不能用调试工具来检查预处理常量。
相似地,预处理宏被编译进去,不能用调试工具跟踪。
编译器能充分了解const、enum和inline语句,从而能在编译的时候对出现的问题发出警告。
但预处理在很多调试代码中起重要作用。
调试代码经常需要从非调试代码里面得到不同的行为,而最有效的办法就是让预处理为调试创建不同的代码。
(4)用new和delete代替malloc和free;
在创建对象、类型的安全性和灵活性方面。
使用new/delete比malloc/free要好。
另外,new可被重载,提供了更大的灵活性。
(5)用输入输出流(iostreams)代替stdio。
使用C++输入输出流(<<和>>)而不使用C标准输入输出库(printf/sprintf和scanf/sscanf),有利于安全性和扩展性。
从调试的角度来看,标准输入输出函数的最大问题在于编译器不能对控制流参数进行任何类型检测,而输入输出流的任何问题都能在编译时检测出来。
2使用头文件
要在头文件中声明所有共享的外部符号,而且保留函数原型中的参数名。
把所有的共享定义放在头文件中,不要在.cpp文件里面看到extern关键字。
3初始化变量
在使用变量之前一定要把它们初始化。
在初始化之前就使用变量肯定会产生错误。
通常不需对对象进行初始化,对对数据成员应在构造函数中初始化。
必须明确地为在栈中和堆中分配的数组和数据结构进行初始化。
对于对象,应该初始化每个需要初始化的数据成员。
因为变量的使用是由优化器来检查的,所以检测未初始化的本地变量,发布版本要比调试版本要做得好。
4使用布尔表达式
C++的布尔类型:
bool,值为true和false,大小为一个字节。
Windows程序通常用BOOL类型。
定义如下:
TypedefintBOOL;
#defineFALSE0
#defineTRUE1
在C++中,一个布尔表达式如果为0则为假,其他则为真。
因此,对布尔表达式应该检查是否问假而不是检查是否为真。
5使用句柄和指针
初始化一个指针时,要么让其指向一个有效的内存地址,要么设为0(空指针),避免指针指向无效地址。
回收指针所指对象时要重新初始化这个指针,并且在指针被释放前为空时就对其进行处理。
对句柄的处理跟指针一样。
6用引用而不是指针做参数
用指针做函数的参数可传递一个空指针,很灵活,但也很容易忘了对指针进行初始化。
而引用是对象的别名,它必须和有效的对象相关联,不存在空的和没有初始化的引用。
当在函数中收到一个引用参数时,可以肯定这是一个有效的对象。
程序用引用做参数比用指针做参数更为健壮。
7强制类型转换(cast)
进行数据类型的强制类型转换时,将会调用相应的构造函数或转换函数来创建一个新类型的临时对象。
对指针的正确类型转换可消除一个编译错误,但并没改变指针。
强制类型转换破坏了编译器进行类型检查的功能,而这正是编译器查找错误的最有效的机制。
为了保证安全性,每一个强制类型转换都需要手工进行类型检查。
为尽量避免强制类型转换,你可以:
避免使用多态数据类型;使用更加广泛的基类;提供特殊的存取函数;让编译器隐式处理类型转换等措施。
8使用构造函数和析构函数
构造函数需要分配内存,创建资源或者打开文件,这些运算并不总是成功。
构造函数没有返回值,没有直接显示错误的方法。
一个常见的方法(在很多MFC类中使用)是把对象创建分为两步:
第一步,让构造函数以一种不会出错的方式初始化对象;第二步,让某些初始化函数(如Init或Open)完成工作,这一步可能出错。
另一种方法是在构造函数中使用异常:
第一步,以不会出错的方式初始化对象;第二步,用可能在try段内出错的代码初始化对象;第三步,在catch代码里面处理异常。
如果出现异常,就会在构造函数里清除分配的资源,并且再次抛出异常。
异常处理的一个关键细节就是在栈展开的过程中抛出的异常会终止整个应用程序。
在处理异常时经常要调用析构函数,因此析构函数很容易出错,一定要保证析构函数的异常在析构函数中得到处理。
要保证基类的析构函数是虚函数。
这样,就算对象是一个指向基类的指针,也会调用派生类的析构函数。
否则,就会引起资源泄漏(resourceleak)。
在VC程序中使用调试语句
为了更好地对程序调试,可以使用如下方法:
使用断言、使用跟踪语句、使用异常和返回值。
一、断言
1、基本概念
断言是一种让错误在运行时候自我暴露的简单有效实用的技术。
它们帮助你较早较轻易地发现错误,使得整个调试过程效率更高。
断言是布尔调试语句,用来检测在程序正常运行的时候某一个条件的值是否总为真,它能让错误在运行时刻暴露在程序员面前。
使用断言的最大好处在于,能在更解决错误的发源地的地方发现错误。
断言具有以下特征:
.断言是用来发现运行时刻错误的,发现的错误是关于程序实现方面的。
.断言中的布尔表达式显示的是某个对象或者状态的有效性而不是正确性。
.断言在条件编译后只存在于调试版本中,而不是发布版本里。
.断言不能包含程序代码。
.断言是为了给程序员而不是用户提供信息。
使用断言最根本的好处是自动发现许多运行时产生的错误,但断言不能发现所有错误。
断言检查的是程序的有效性而不是正确性,可通过断言把错误限制在一个有限的范围内。
当断言为假,激活调试器显示出错代码时,可用CallStack命令,通过检查栈里的调用上下文、少量相关参数的值以及输出窗口中Debug表的内容,通常能检查出导致断言失败的原因。
_ASSERTE宏(属于C运行时间库)还能在断言失败时显示出失效断言。
下面我们讨论一下MFC库中的断言。
2、MFC库中的断言
(1)ASSERT(布尔表达式)
用MFC时最好选择ASSERT宏,它的优点是即使出现了WM_QUIT消息也能显示断言失效消息框。
(2)VERIFY(布尔表达式)
VERIFY宏中的布尔表达式在发布版本中被保留下来。
VERIFY宏简化了对函数返回值的检查,一般用来检查WindowsAPI的返回值。
由于VERIFY宏里的布尔表达式在发布版本里保留了下来,因此最好尽量不要使用这个宏以实现程序代码和调试代码的完全分离。
(3)ASSERT_VALID(指向CObject派生类对象的指针)
ASSERT_VALID宏通过调用重载的AssertValid函数来确定指向CObject派生类对象的指针是否有效。
无论你什么时候从CObject派生类中得到一个对象,在对这个对象做任何操作之前都应该调用ASSERT_VALID宏。
(4)ASSERT_KINDOF(类名,指向CObject派生类对象的指针)
这个宏用来验证指向CObject派生类对象的指针是否从某个特殊类中派生,在调用它之前先调用ASSERT_VALID宏。
只有在很特殊的场合下才用得到,如检测编译器可能错过的对象类型问题。
此外,还有两个没有正式文件的ASSERT宏的变种:
ASSERT_POINTER(指针,指针类型),ASSERT_NULL_OR_POINTER(指针,指针类型)。
3、什么时候使用断言
把断言看作一种简单的制造栅栏的方法,这种栅栏能使错误在穿过自己时暴露。
.检查函数的输入
.检查函数的输出
.检查对象的当前状态
.坚持逻辑变量的合理性和一致性
.检查类中的不变量
公有成员函数比私有和保护的成员函数需要更全面的断言。
不正确地使用断言会导致错误。
断言应该检测那些在程序正常运行的时候永远都不可能出现的状态。
断言是用来揭示错误的,而不是用来纠正运行时刻错误的。
4、断言与防御性编程(DefensiveProgramming)
断言在调试的时候向程序员揭示运行时刻错误(调试版本里),而防御性编程使用户在运行程序(发布版本里)时,当出现意外情况时程序仍能继续工作。
实际上,防御性的编程要求程序在检测到意外时返回一个“安全”的值(比如布尔函数返回false,指针和句柄返回空值),一个错误代码或者抛出一个异常来解决问题。
特定的防御性编程技术包括:
处理无效函数参数和数据、出现问题的时候程序失败、检查临界函数返回的错误代码以及处理异常。
需要防御性编程的标准问题包括:
错误的输入数据、内存或者硬盘空间不够、不能打开一个文件、外部设备不能访问、网络连接不上或者甚至在程序中还有错误,目的是保持程序的运行状态。
如果你的程序是防御性的,别忘了使用断言。
如果你使用断言,也别忘了防御性编程。
这两种技术最好结合在一起使用。
二、跟踪语句
1、基本概念
跟踪语句(tracestatements)可使程序执行,并使程序员可对可变值进行查看。
它们提供了一个用于观察的程序,并且独立于一个交互式的调试器,但是最具有特色的是它们常用于对调试器提供的信息进行补充。
在VC中,跟踪消息通常输出到输出窗口中的Debug标签,也可以重新输出到一个文件中。
跟踪语句的特性如下:
.跟踪语句用于报告代码中重要的运行事件。
.跟踪语句的编译通常是有条件的,并只存在于调试版本中,而在发布版本中不被编译。
.跟踪语句不能包含程序代码或对程序代码有间接的影响作用。
.跟踪语句的目的是向程序员提供信息,而不是向用户。
跟踪语句也是调试语句,它可以执行程序,并且在运行中程序员可以查看变量。
跟踪语句对于那些使用交互式调试器很难调试的程序是很有效的。
跟踪语句和断言的区别如下:
.跟踪语句是无条件的,断言是有条件的布尔语句。
.跟踪语句用于显示程序执行和变量值,不直接显示bug,断言用于显示出bug。
.跟踪语句将信息输出到调试窗口或文件中,可被随意地忽略,断言打断程序的执行。
2、MFC中的跟踪语句
在MFC中,你可以使用TRACE和AfxOutputDebugString宏、CObject:
:
Dump虚拟函数和AfxDumpStack函数。
TRACE宏由AfxDump实现,AfxDump由AfxOutputDebugString实现。
AfxOutputDebugString宏和AfxDumpStack函数可以在所有版本中编译,其他只能在调试版本中编译。
(1)TRACE宏有以下形式:
_TRACE(reportType,format);
_TRACE0(reportType,format,arg1);
_TRACE1(reportType,format,arg1,arg2);
_TRACE2(reportType,format,arg1,arg2,arg3);
_TRACE3(reportType,format,arg1,arg2,arg3,arg4);
在MFC中,推荐使用TRACEn宏,当使用TRACE宏时需要使用_T宏来格式化参数以正确解决Unicode的校正,而TRACEn不需要。
MFCTRACE宏中的一个缺点是AfxTrace函数使用一个512字符固定大小的缓冲区,这使得它在跟踪长字符串时是无用的。
(2)CObject:
:
Dump
CObject类有一个转储(dump)虚拟函数,所有继承CObject的类都可以通过重载这个函数,输出它们的值。
3、VisualC++消息Pragma
消息Pragma实际上是一个编译时的跟踪语句,你可以使用它来警告在预处理过程中发现的潜在的编连(build)问题。
典型的例子:
#if(WINVER>=0x0500)
#pragmamessage(“NOTE:
WINVERhasbeendefinedas0x0500orgreater.”)
#endif
消息Pragma是非常有用的,尤其是在复杂编连中。
然而,如果你要检测一种特定的问题,而不是潜在的问题,使用#error预处理来代替打断编译会更直接一些。
每当你的程序中有错误而你想得到更多信息的时候,你应该去查看一下跟踪消息。
由于VC输出窗口的缓冲区是有大小限制的,因此如果跟踪消息数据产生的速度超过输出窗口处理的速度,那么消息会塞满缓冲区,导致数据丢失。
避免这个问题的简单方法是在输出大量数据的代码段如转储对象时,调用SleepAPI函数。
三、异常
1、基本概念
错误是一种条件,在这种条件下,如果不执行额外的处理,线程就不能正常地执行下去。
异常是用于处理错误的。
使用异常的一个很明显的好处就是它们通过发出错误信号,可以让程序代码和错误处理代码分开,而且不会让程序忽略错误,你不用不断地检查函数的返回值,因此它们将程序代码简单化。
另一个好处是它们不需要严格的编程作风。
异常的基本特性:
.异常是基于每个进程而提出并处理的。
.异常不能被线程忽略,必须被处理。
.未处理的异常会使进程结束,而不仅仅是结束线程。
.异常出来在释放栈时会释放所有的栈对象,避免了资源的漏洞。
.异常处理需要大量的额外操作,使得它不适于经常运行的代码。
.可以抛出任何类型的异常对象,除了整数。
如果正确执行,异常处理有下面的特性:
.异常是不是正常的运行结果,是特殊情况。
.异常在返回值无效的情况下使用。
.异常是可靠的,不可能被忽略。
.异常简化了错误处理,简化了程序代码,使错误处理更加方便。
VisualC++的默认情况下,在调试版本中处理异常,而在发布版本中并不进行处理。
由于异常也是错误,Windows异常码采用了同Windows错误码一样的位映射模式,为一个32位的值,这些码由Microsoft定义,任何异常码的最高四位总是1100(二进制),即十六进制里的0xC。
2、Windows结构异常和C++异常
Windows结构异常作为硬件异常(如访问非法或被零除)或操作系统异常的结果被抛出,C++异常只能由throw语句抛出。
Windows结构异常处理不能处理对象的解析,因此你应该在C++程序中一直使用C++异常。
然而,C++异常不能处理硬件和操作系统异常,你的程序需要将结构异常转化为C++异常。
C++异常并不直接从你的程序代码中抛出而是从C++运行库中抛出,因此你需要调用栈窗口来返回你的代码。
为了正确处理硬件和操作系统异常,你可以创建自己的异常类并使用_set_se_translator函数安装一个结构异常向C++异常的转化器,但不要捕获那些不能恢复所产生问题的转化后的结构异常。
3、MFC中的异常
在MFC中,所有的异常对象都是从CException基类(它有使用起来非常方便的GetErrorMessage和ReportError成员函数)中派生来的。
大多数的MFC异常对象都是动态分配的,而且当它们被捕获时,必须被删除,而没有被捕获的MFC异常由MFC本身在AfxCallWndProc函数中捕获并删除。
4、异常的开销
当抛出C++异常时,函数调用链将从此回溯搜索,寻找可以处理抛出这类异常的处理器。
若没找到,进程结束。
如果找到,调用栈将被释放,所有的自动(局部)变量也将释放,然后栈将被整理为异常处理器的上下文相关设备。
因此异常开销由一个异常处理器目录和一个活动的自动变量表(它需要额外的代码、内存,而且不论异常是否抛出,都会运行),还得加上函数调用链的搜索、自动变量的解析和栈的调整(它只在抛出异常的时候需要执行)组成。
5、异常策略
(1)抛出时机
抛出异常的时机应该是一个函数发现一个错误,如果没有一些特殊的操作,该错误能阻止程序正常的运行,而这种操作它自己不能完成,或是在函数不可能有返回值的时候。
使用异常处理更简单,更可靠,更有效,可以创建更健壮的代码。
然而,应该只在意外的情况下使用异常处理。
如果你认为一个指针应该是空值,这种条件下就直接在代码中检查这个值,而不要使用异常。
(2)何时捕获
对于这个问题,有一些可能的标准:
.当函数知道如何处理这个异常时。
.当这个函数可以合理地处理这个异常而高级的函数不知道如何处理时。
.当抛出异常可能使进程崩溃时。
.当函数可以继续执行它的任务时。
.当需要整理分配好的资源时。
异常处理的一个缺点是它可能导致资源的泄露。
因此,防止资源泄露更应该是保持程序异常安全的一部分。
栈释放时会自动整理局部变量,但不包括动态分配的变量。
可以使用智能(smart)指针来保护你的代码在存在异常的情况下不会产生资源泄漏。
(3)怎样捕获
.非MFC的C++异常应该通过引用来捕获。
使用引用捕获异常不需要删除异常对象(因为使用引用捕获的异常会在栈中传送),而且它保留了多态性(因此你捕获的异常对象正是你抛出的异常对象)。
.MFC异常应该通过指针来捕获。
使用指针捕获异常需要你删除对象。
因为它们通常从堆中分配,当你处理完异常之后,需要调用Delete成员函数来删除。
你不可以使用省略捕获处理器捕获MFC异常,这会导致一个内存泄露。
必须使用Delete成员函数删除MFC异常,而不用delete,因为一些MFC异常为静态对象创建。
在释放栈的过程中抛出异常会导致进程的终止。
释放栈涉及到调用析构函数,异常可以阻止调用delete操作符,这样会有资源泄漏,因此异常最好不要从析构函数中抛出。
如果非要在析构函数里抛出异常,必须妥善处理,避免资源泄漏。
6、异常与防御性编程
在异常发生时继续执行程序,远比执行一个正常的关闭动作要重要。
如果可能,应该将精力集中在继续执行程序,并在必须的情况下才正常地关闭程序。
可能最根本的正常关闭是一个在崩溃时可以重新启动自己的进程,这是Windows资源管理器使用的一种技术。
如果一个与错误相关的C++异常是可预料的,如果它发生在非关键性的代码中,如果它不是发生在程序启动或结束过程中或一个不可恢复的结构异常的结果中,这个程序就可以从其中恢复。
一旦你的程序可以从与错误相关的异常中恢复,应该先检查程序的状态和它的文档。
如果程序和文档已经被破坏了,进程也应该终止运行。
否则,程序需要通知客户机确定动作的过程。
如果客户机同意执行下去,程序应该恢复错误并继续执行。
四、返回值
并不是在所以场合下都能使用异常,如在使用WindowsAPI编程或带有COM编程时并不使用异常。
在异常不适合的时候,使用返回值是一个好的办法。
返回值的基本特性:
.返回值可以指示正常和不正常的函数运行,但不能阻止线程的继续运行。
.返回值很容易被忽略。
.返回值在典型情况下是一个整数,通常映射符合于一个预定义的值。
.返回值能高效地传递和接收。
因此,返回值最适合用于以下的情形:
.用于非错误的状态信息
.用于大多数情况下可以随意忽略而不会出问题的错误。
.用于更易于出现在循环中的错误。
.用于中间语言模块如COM组件中的错误。
使用VisualC++调试器调试
一、调试版本与发布版本
有时程序能在调试版本运行但不能运行于发布版本,反之也有可能。
一般说来,一个发布版本意味着某些类型的优化,而一个调试版本则没有优化。
下面我们来看看它们的区别:
1、特别针对调试版本的编译选项
(1)/MDd,/MLd或者/MTd
调试版本的运行时刻库有调试符号,使用了调试堆,调试堆的目的是发现内存破坏和内存泄漏,并且向用户报告源代码的哪个地方出了问题。
特性:
.调试版本的运行时刻库对内存的分配作了跟踪,允许用户检查内存泄漏。
.在刚分配的内存里写上0xCD的字节模式,用0xCD来填充刚分配的内存,有助于发现数据未被初始化的错误。
.在被释放的内存写上0xDD的字节模式,有助于发现已被释放的内存。
.在缓冲区的两边分配了四字节的保护数据,并用0xFD的字节模式作初始化,来检查写内存的上溢出和下溢出。
.在每个内存分配的地方对源代码文件名和行号作了记录,有助于用户在源代码中对内存分配进行定位。
(2)/Od
这个选项用来关闭优化开关。
因为未被优化的代码直接对应于源代码,所以比优化后的代码更容易读懂。
未被优化的代码编译和链接会更快,会有更短的调试周期。
而由于优化,发布版本不见得会比调试版本运行得好,优化代码要求编译器做一些假设,去除冗余,但有时这个假设是错误的,并且去掉的冗余也有可能隐藏错误。
如发布版本的帧指针(EBP寄存器)省略(FPO)隐藏了函数原型不匹配的错误;在同步异常模式(只能由throw语句抛出,编译器默认,由/G