调试技术与异常(错误)处理(1)
跟踪与中间过程输出
也许一个开发人员一半以上的时间都是在面对错误,所以好的调试/查错方法(工具)会减轻我们工作的负担,也可以让枯燥的DEBUG过程得以缩短。
VC开发环境所提供的调试环境是很优秀的,我们可以运用单步运行,设置断点的方法来查找问题所在。但是这种跟踪是非常耗时的,所以我们需要采用一些策略来让我们更容易的发现错误并对错误进行定位,所幸的是VC在这方面提供了强大的支持。在本节中我们先看看如何利用设置断点和利用TRACE宏来输出运行情况。
在VC开发环境中按下F9就可以在光标所在行设置断点,再按一次就可以取消该处断点。设置断点的意义在于在调试过程当运行到该行时回产生一个中断并返回到VC开发环境中,在开发环境中你可以查看各个变量的值。下面是我们用于测试的代码,前面有红色圆形的行表示该行设置有断点:
在调试过程中到达断点处你可以通过上下文变量窗口(Variables)观察该函数中的变量的值,如果需要观察未在该函数出现的全局变量或者类成员变量这需要将变量名添加到观察窗口(Watch)中输入变量名称。但程序编译完成后请按下F5键以调试的方式执行程序,当进入断点时VC开发环境会被自动激活,然后我们可以可以观察程序的运行情况。在调试过程中也可以添加和删除断点。如下图:
如果在运行过程中被观察的变量的值发生了变化则该变量在观察窗中会变为红色。
一般来讲设置断点有下面的技巧:
- 设置在进行判断的代码处,这样可以在运行时可以观察判断所依赖的条件是否正确。
- 设置函数开始处,观察该函数所依赖的变量是否都设置正确。
- 设置函数结束处,观察该函数对变量的改变是否正确。
- 设置进入其他函数前/后,通过黑盒法检查该函数功能是否正确。
- 对于循环体,应该先测试一个循环次数小的条件来检查循环逻辑是否正确,或者在循环的前几次设置断点,在运行几次后取消断点。
MFC中提供的TRACE宏可以帮助我们在程序调试运行过程中方便的输出调试信息。TRACE宏的定义为:TRACE(exp),其中的表达式使用与printf相同的表达方法。例如下面的代码:
void CSam_sp_31Dlg::OnTest2() { static int i=5,j=50; char szDeb[]="debug string"; TRACE("trace i=%d j=%d/nstring=%s/n",i,j,szDeb); i+=1; j+=5; }
在以调试方式运行程序是,当你点击TRACE按钮时会看到在调试窗口中输出了调试信息。
当程序在调试过程中执行到此处时会在输出窗口输入"trace i=5 j=50/n,string=debug string/n"。使用TRACE宏可以让我们随时掌握程序运行过程中变量的变化情况,因为大多数情况下我们都不希望使用断点进入到程序内部,而只是注意运行中数据的值。
注意:不要采用TRACE宏一次性输出大批量数据或不间断输出数据,因为这样有可能会时程序运行变得非常缓慢,如:
void test_trace_e(void)
{
char *pszDeb=new char[1024*1024];
TRACE("%s/n",pszDeb);
//或者
for(int i=0;i<sizeof(pszDeb);i++)
{
TRACE("%c/n",pszDeb[i]);
}
}
有一点需要注意的是,TRACE宏在只在调试(DEBUG)版本中起作用,而在发行(RELEASE)版本无效,所以不要在TRACE宏中进行对程序状态进行改变的计算或是调用对状态有改变的函数,例如:
void yourClass::fun1()
{
TRACE("%d",++m_iTick); //m_iTick状态改变
TRACE("return value = %d",DoSomething());
}
void yourClass:DoSomething()
{
if(m_szOut == "No")
{
return FALSE;
}
else
{
m_szOut="Yes"; //状态改变
reutrn TRUE;
}
}
在调试中还有一种方法可以将对象内部内容输出到调试窗口中,这就是使用转储(Dump)。转储的实现要通过对象自身实现,在通过对象自身实现时有一个好处就在于可以输入内部受保护层成员。首先CObject类定义了虚函数:virtual void Dump( CDumpContext& dc ) const;当你从CObject中派生新类时你需要重载该函数,例如下面是个很简单的例子:
class CMyButton : public CButton { public: CMyButton(); ~CMyButton(){}; public: #ifdef _DEBUG //由于转储只在调试版本中实现,所以使用条件编译 virtual void Dump( CDumpContext& dc ) const; #endif protected: CString m_szHotText;//当鼠标移动过显示的文字 }; CMyButton::CMyButton():CButton() { } #ifdef _DEBUG void CMyButton::Dump( CDumpContext& dc ) const { dc<<"/n"; CButton::Dump(dc); dc<<"/ndump of CMyButton /ntext is "<<m_szHotText; dc<<"/n"; } #endif
我们看到Dump函数接受一个参数为CDumpContext,通过该类可以将数据输出到调试窗口或是文件。CDumpContext重载了<<操作符,利用<<可以输出各种类型的数据。下面的代码示范了调用方法:
void CSam_sp_31Dlg::OnDump()
{
CButton bu1;
CMyButton bu2;
#ifdef _DEBUG
//由于转储只在调试版本中实现,所以使用条件编译
bu1.Dump(afxDump); //afxDump是一个CDumpContext类型的全局变量。
bu2.Dump(afxDump);
#endif
}
//输出的调试信息为:
a CButton at $64F538
m_hWnd = 0x0
a CButton at $64F4F4
m_hWnd = 0x0
dump of CMyButton
text is not init
//上面两行输出了CButton的转储信息,后面四行输出了CMyButton的转储信息。
设置断点进行跟踪和输出中间结果是最基本的调试方法,也是必须掌握的技巧。
调试技术与异常(错误)处理(2)
变量/对象合法性检查
在VC中检查变量合法性一般利用ASSERT(x)宏,ASSERT的作用在于检查表达式是否为假或为NULL,如果为假则会引发异常。在MFC中ASSERT宏被大量使用,例如:
BOOLCWnd::Attach(HWNDhWndNew)
{
ASSERT(m_hWnd==NULL);//onlyattachonce,detachon
destroy
//…
returnTRUE;
}
voidCString::AllocBuffer(intnLen)
{
ASSERT(nLen>=0);
ASSERT(nLen<=INT_MAX-1);//
}
voidCDocument::AddView(CView*pView)
{
//othercode…
ASSERT(pView->m_pDocument==NULL);
ASSERT(m_viewList.Find(pView,NULL)==NULL);
}
当ASSERT失败并引发异常时会有对话框谈出并报告发生该ASSERT失败位置。报错信息如:assertionfailedinfile<THIS_FILE>inline<__LINE__>。
并允许你选择继续运行(Ignore)或是终止(Abort)程序。(当然选择继续运行是很危险的)选择Retry将会启动调试软件对程序进行调试。
此外我们时常可以看到下面的用法:
ASSERT(pWnd);//检查指针是否已经赋值
if(condition)
{
ASSERT(FALSE);//强制抛出一个ASSERT异常
}
此外还有一点,ASSERT宏只在调试版本中才会有作用,在调试版本中ASSERT(f)宏被展开为
do
{
if(!(f)&&AfxAssertFailedLine(THIS_FILE,__LINE__))
AfxDebugBreak();
}while(0)
//while(0)用来保证ASSERT宏后面可以不跟随“;”如ASSERT(f)与ASSERT(f);都合法
//THIS_FILE表示当前当前文件文件名,__LINE__为当前代码所在的行数
而在发行版本中会被展开为:
((void)0)
所以对程序内部状态改变的代码不能够放置在ASSERT宏中否则在发行版中会出现不正常的现象,例如下面的代码:
voidyourClass::fun1()
{
ASSERT(++m_iTick>5);
ASSERT(DoSomething()==TRUE);
}
voidyourClass:DoSomething()
{
if(m_szOut=="No")
{
returnFALSE;
}
else
{
m_szOut="Yes";//状态改变
reutrnTRUE;
}
}
如果希望合法检查在发行版本中同样起作用则可以利用VERIFY宏,VERIFY宏与ASSERT宏的VERIFY的不同在与VERIFY在发行版本中同样会起作用,但是使用VERIFY会导致非常不友好的用户界面。
对象的合法性检查需要根据对象自身的状态和一些对象自己的逻辑来作出判断,因此在对象外部就无法正确判断,一个省时有效的办法是在对象内部进行检查,有对象自己负责合法性检查,例如下面的代码:
voidCObList::AssertValid()const
{
CObject::AssertValid();
if(m_nCount==0)
{
//emptylist
ASSERT(m_pNodeHead==NULL);
ASSERT(m_pNodeTail==NULL);
}
else
{
//non-emptylist
ASSERT(AfxIsValidAddress(m_pNodeHead,sizeof(CNode)));
ASSERT(AfxIsValidAddress(m_pNodeTail,sizeof(CNode)));
}
}
MFC利用成员函数voidCObject::AssertValid()const来实现对象的合法性检查,所以新的类必须是CObject的派生类,(在MFC中几乎所有的类都由CObject派生)由于C++的多态性派生类的AssertValid函数会被正确的调用。函数定义中的const表示该函数体中不能改变成员变量的值。
我们所需要做的就是重载AssertValid,并实现对象状态合法性的检查。在AssertValid我们不但可以检查数据的正确性,也可以对数据的逻辑性进行检查。例如一个盒子中的白球不能多于黑球,而且总数不能多于100:
classCBox:publicCObject
{
...
voidAssertValid()const;
intm_iWhiteBall,m_iBlackBall;
}
voidCBox::AssertValid()const
{
CObject::AssertValid();//先调用父类的检查函数
ASSERT(m_iWhiteBall<=m_iBlackBall);
ASSERT(m_iWhiteBall+m_iBlackBall<=100);
}
到这里你会问什么时候调用AssertValid函数?在MFC中对象的合法性检查都依赖AssertValid,比如在销毁窗口对象时会首先检查该窗口对象是否合法,而你自己也可以手工调用AssertValid来检查对象的合法性,例如下面的代码:
voidCDocument::AssertValid()const
{
CObject::AssertValid();
POSITIONpos=GetFirstViewPosition();
while(pos!=NULL)
{
CView*pView=GetNextView(pos);
ASSERT_VALID(pView);
}
}
而当你对自己的CView派生类CYourView重载AssertValid后,CYourView的AssertValid就会在文档类检查视类合法性时调用。此外MFC中定义了ASSERT_VALID宏来执行安全的对象检查,ASSERT_VALID宏会展开AfxAssertValidObject,并先检查指针的合法性。这样避免了下面的错误:
CView*pV=NULL;
pV->AssertValid();
//安全的方法是利用
ASSERT_VALID(pView);
与ASSERT宏一样,ASSERT_VALID宏只在调试版本中起作用。
利用合法性检查可以帮助我们在由于变量非法而引发异常方便的定位错误,所以在开发程序时多利用合法性检查并在必要的地方使用检查宏会帮助我们更有效的进行调试。
调试技术与异常(错误)处理(3)
内存泄露检查在VC中提供内存检查的机制是跟踪new操作,也就是说所有的new操作都会被记录,如果通过new操作所分配的内存未被正常delete将会在程序退出时在调试窗口中显示出具体的内存泄露信息。
同样通过malloc分配的内存也会被跟踪,但是在显示时就不会知道实在程序中何处进行了malloc操作。先看一下下面的例子:
void_tmain()
{
...
char*pszNew=(char*)malloc(200);
char*pszNew2=newchar[100];
CString*pszNew3=newCString("test");
...
}
//通过调试方式运行后并退出,可以看到调试信息中关于内存泄露的信息如下:
Detectedmemoryleaks!
Dumpingobjects->
strcore.cpp(118):{37}normalblockat0x007702E0,17byteslong.
Data:<test>01000000040000000400000074657374
G:/temp2/sam_sp_33/sam_sp_33.cpp(42):{36}normalblockat0x00770520,4byteslong.
Data:<w>EC027700
//对于CString*pszNew3=newCString("test");产生的信息
G:/temp2/sam_sp_33/sam_sp_33.cpp(41):{35}normalblockat0x00770320,100byteslong.
Data:<>CDCDCDCDCDCDCDCDCDCDCDCDCDCDCDCD
//对于char*pszNew2=newchar[100];产生的信息
{34}normalblockat0x007703B0,200byteslong.
Data:<>CDCDCDCDCDCDCDCDCDCDCDCDCDCDCDCD
//对于char*pszNew=(char*)malloc(200);产生的信息
Objectdumpcomplete.
可以看到通过new分配的内存在显示信息时会报告出在那一个文件的那一行进行的new操作,而通过malloc分配的内存则仅仅是显示出内存泄露的信息而无法定位分配内存的程序位置。
此外如果需要在文件头部定义DEBUG_NEW宏才可以正确的跟踪new操作。具体代码如下:
#ifdef_DEBUG
#definenewDEBUG_NEW
#endif
由于对new操作的跟踪只需要在调试版本中出现所以使用了条件编译。
我们可以看到VC所提供的检查内存泄露的方式是非常易于使用的,我们在开发程序时一定要注意内存的分配问题,特别是对于一些长时间运行的程序
调试技术与异常(错误)处理(4)
异常捕捉与处理在软件开发的过程中错误捕捉显得尤为重要,因为有的错误会导致软件功能失常,而有的却会造成破坏性损失。世上没有不出错的软件。软件的逻辑错误,人为操作的失误,运行条件的改变等等因素都会导致异常的出现。下面的代码是一个例子:
char*pszData=NULL;//假设为全局变量
BOOLReadData(void)
{
FILE*pFile=fopen("c://data.dat","r");//假设c:/data.dat文件长度为1024BYTE
if(pFile!=NULL)
{
if(pszData)deletepszData;
pszData=newchar[1024];
if(1024==fread(pszData,1024,1,pFile))
returnTRUE;
}
//打开文件失败错误,或文件长度不够
returnFALSE;
}
voidPrintData()
{
for(inti=0;i<1024;i++)
{
printf("%x",pszData);
}
}
粗看这段代码应该是没有问题的,因为该段代码进行了错误处理,在操作没成功时返回了错误。但是在PrintData中就有一个隐患,如果pszData为NULL时怎么办,毫无疑问,此时会导致异常情况发生。也许在软件流程中如果ReadData返回错误后根本就无法进入PrintData,但是在一个十万行以上的程序中这种错误随时会存在。
另一个例子是关于内存分配的,如果你现在分配10K的内存出现失败,你的程序会如何反应,是退出还是继续。更令人沮丧的是很多开发人员在开发过程中对与某些可能出现的错误情况都未加以考虑,这使得出现错误时对错误的跟踪和定位成为极大的困难。
所以使用一种强制的机制保证一些致命错误能够被处理是一个明智的选择。比如说内存错,文件错等等。
在C++中引入了一种在C语言中不存在的特性,错误捕捉机制(try/catch),这是一种强制性的机制,如果程序中抛出的异常未被成功捕捉,该异常将一直会沿着函数调用的顺序上升,直到被捕捉到为止。而默认的main函数之外存在有异常捕捉代码,这段默认的异常捕捉代码将会终止程序并报告异常的发生。
下面我们先看看try/catch的语法的一个例子:
voiddo_something()
{//循环产生各种异常
staticintiTime=0;
switch(iTime++%3)
{
case(0):throw(int)1;
break;
case(1):throw"error";
break;
case(2):throw(double)1.1;
break;
}
}
voidCSam_sp_34Dlg::OnTc()
{
try
{
do_something();
}catch(inte)
{
AfxMessageBox("errorhandler1/n");
}catch(char*sz)
{
AfxMessageBox("errorhandler2/n");
}
}
当你第三次执行OnTc时,由于产生的异常没有被成功捕捉所以将由默认的捕捉代码捕捉并终止程序。
这时候我们可以写另外一段代码来捕捉我们未能够估计到的异常。
voidCSam_sp_34Dlg::OnTcE()
{
try
{
do_something();
}catch(inte)
{
AfxMessageBox("errorhandler1/n");
}catch(char*sz)
{AfxMessageBox("errorhandler2/n");
}catch(...)
{
AfxMessageBox("catchall/n");
}
}
catch(...)将会捕捉所有未指明类型的异常。在这里我们可以看到异常是可以分为很多类的,而分类的依据就是抛出异常时候所使用的数据类型。
在上面的例子中我们看到抛出异常的语法很简单,使用关键字throw就可以了,后面跟异常常的类型。如果单独使用throw则表示继续抛出当前异常,这种用法表明在处理当前异常后继续将该异常传递给其他的异常处理块进行处理。
voiddo_something_2()
{
try
{
do_something();
}catch(...)
{
AfxMessageBox("catchedandthrow");
throw;//继续传递该异常
}
}
voidCSam_sp_34Dlg::OnJt()
{
try
{
do_something_2();
}catch(inte)
{
AfxMessageBox("errorhandler1/n");
}catch(char*sz)
{
AfxMessageBox("errorhandler2/n");
}catch(...)
{
AfxMessageBox("catchall/n");
}
}
最后我们来看看异常的处理顺序,异常首先会被距离try块最近的catch块捕捉到。看下面的例子:
voiddo_something_3()
{
try
{
do_something();
}catch(inte)
{
AfxMessageBox("catchedint");
}
}
voidCSam_sp_34Dlg::OnCp()
{
try
{
do_something_3();
}catch(inte)
{//这段代码是无意义的,因为do_something_3已经捕捉这种类型的异常
AfxMessageBox("errorhandler1/n");
}catch(char*sz)
{
AfxMessageBox("errorhandler2/n");
}catch(...)
{
AfxMessageBox("catchall/n");
}
}
在MFC中定义了一些专门用于处理异常的类,所有这些类都由CException派生,并各自负责不同的异常情况,在MFC内部出现异常并抛出异常时将会抛出以下异常类:类用途CException异常基类CNotSupportedException进行系统不支持的操作时抛出的异常类CMemoryException内存分配失败时抛出的异常类CArchiveException文件串行化失败时抛出的异常类CFileException文件读写错误时抛出的异常类CResourceException资源无法装入时抛出的异常类COleExceptionOLE发生异常时抛出的异常类CInternetException使用WinInet功能时抛出的异常类CUserException用户定义的异常类
下面的代码演示了如何捕捉异常,我们需要注意到MFC抛出异常类的指针,但是我们不需要手工删除该指针,MFC在空闲时会自动删除,此外也不要调用delete对指针进行删除因为有些被抛出的异常指针可能是全局变量:
voidCSam_sp_34Dlg::OnMfcF()
{
try
{
CFilefileTest("c://not_exist.txt",CFile::modeRead);
}catch(CException*e)
{//使用基类进行捕捉
e->ReportError();
}
try
{
CFilefileTest2("c://autoexec.bat",CFile::modeRead);
charszLine[100]="remtestline/n";
fileTest2.Write(szLine,strlen(szLine));
}catch(CFileException*e)
{//使用文件异常类进行捕捉
e->ReportError();//不需要调用e->Delete();进行删除
}
}
MFC中的异常类提供了简便的获取错误信息的手段,但你捕捉到异常后可以通过检查异常类中的成员变量来检查错误原因。
掌握好异常处理对于编写无错代码帮助很大,你也应该在自己的代码中添加进异常抛出代码,这样可以提醒开发人员在开发过程加强对运行时错误的处理。