C++深入体验之旅九:程序调试

来源:互联网 发布:宇宙空间的温度知乎 编辑:程序博客网 时间:2024/06/13 19:22

1.头文件的奥秘

如何创建一个头文件

在第二章中,我们看到一个C++的工程里面除了源文件还有头文件。根据以下步骤便能创建一个头文件:
首先要创建或打开一个工程,然后按File菜单中的new。在出现的对话框左边选择C/C++ Header File,在对话框右边的File一栏里填上头文件名,最后点击OK。如下图11.2所示:

这时候点击左侧Workspace框内树状目录里新建的头文件,就能编辑这个文件了。不过在我们进行编译的时候,记得要切换回对应的cpp文件,如果在编辑头文件时编译,就会提示无法编译。

头文件里有些什么?

头文件的使用主要体现在两个方面,一个是重(音chóng)用(即多次使用),另一个是共用。
那些提供标准库函数的头文件就是为了重用。很多程序或工程可能会用到这些标准库函数,把它们写在头文件里面,每次使用的时候只需要包含已经完成的头文件就可以了。
头文件的共用主要体现在C++的多文件结构中。由于目前的程序规模较小,尚不需要用到多文件结构,所以在此对头文件的共用不作展开。有兴趣的读者可以查阅相关书籍。
那么,如果我们要自己编写一个可以重用的头文件,里面应该写些什么呢?
类似于标准库函数,我们在头文件里面应该模块化地给出一些函数或功能。另外还应该包括独立实现这些函数或功能的常量、变量和类型的声明。
下面我们就来看一个头文件应用的实例:(程序11.2)

//shape.h #include "math.h"//在计算三角形面积时要用到正弦函数 const double pi=3.14159265358;//常量定义 struct circle//类型声明 {    double r; }; struct square {    double a; }; struct rectangle {    double a,b; }; struct triangle {    double a,b,c,alpha,beta,gamma; }; double perimeter_of_circle(double r)//函数定义 {    return 2*pi*r; } double area_of_circle(double r) {    return pi*r*r; } double perimeter_of_square(double a) {    return 4*a; } double area_of_square(double a) {    return a*a; } double perimeter_of_rectangle(double a,double b) {    return 2*(a+b); } double area_of_rectangle(double a,double b) {    return a*b; } double perimeter_of_triangle(double a,double b,double c) {    return a+b+c; } double area_of_triangle(double a,double b,double gamma) {    return sin(gamma/180*pi)*a*b/2; } 

//main.cpp #include "iostream.h" #include "shape.h"//包含我们编写好的shape.h int main() {    circle c={2};    square s={1};    rectangle r={2,3};    triangle t={3,4,5,36.86989,53.13011,90};    cout <<"Perimeter of circle " <<perimeter_of_circle(c.r) <<endl;    cout <<"Area of square " <<area_of_square(s.a) <<endl;    cout <<"Perimeter of rectangle " <<perimeter_of_rectangle(r.a,r.b) <<endl;    cout <<"Area of triangle " <<area_of_triangle(t.b,t.c,t.alpha) <<endl;    return 0; } 

运行结果:
Perimeter of circle 12.5664 Area of square 1 Perimeter of rectangle 10 Area of triangle 6 
我们编写好了shape.h头文件,以后用到计算图形周长或面积的时候,就不需要重新编写函数了,只需要包含这个头文件就行了。

2.头文件、源文件、#include

头文件和源文件

由于头文件是为了重用,所以在一个复杂的程序中,头文件可能会被间接地重复包含。如果头文件里面都是函数声明,那问题还不大。如果头文件里面有函数定义(如程序11.2),那么就会出现函数被重复定义的错误,程序将无法运行。我们可以采用函数声明和定义分离的方式:把所有的声明都放在shape.h中,把所有的定义放在shape.cpp中。注意必须在shape.cpp中包含shape.h,否则在编译连接时会发生错误。我们在使用时仍然包含shape.h,但由于函数的定义并不在该头文件中,所以就不会被重复定义了。

细说#include

我们几乎每次编写程序的时候都要用到#include命令,那么这条命令到底是什么意思呢?
#include是一条编译预处理命令。什么叫编译预处理命令呢?我们知道,程序中的每一句语句会在运行的时候能得到体现。比如变量或函数的声明会创建一个变量或者函数,输出语句会在屏幕上输出字符。然而编译预处理命令却不会在运行时体现出来,因为它是写给编译器的信息,而不是程序中需要执行的语句。编译预处理命令不仅仅只有#include一条,在C++中,所有以#开头的命令都是编译预处理命令,比如#if、#else、#endif、#ifdef、#ifndef、#undef和#define等等。
当编译器遇到了#include命令后,就把该命令中的文件插入到当前的文件中。不难想象,程序11.2的main.cpp文件实质上包含了shape.h文件中的所有语句。所以它能够顺利调用shape.h文件中的各个函数。

试试看:
1、把程序11.2的main.cpp中#include "iostream.h"移动到shape.h中,是否会影响程序的运行?为什么?
2、如果有两个头文件a.h和b.h,在a.h中有#include "b.h",在b.h中有#include "a.h",那么在编译包含它们的源文件时,会发生什么错误?
结论:互相包含的两个头文件在编译的时候会导致错误甚至死机。

3.尖括号和双引号的区别

如果你还看一些别的C++教程,那么你可能很早就发现了,有些书上的#include命令写作#include <文件名>,但有时候又会出现#include "文件名"。你会很疑惑,到底哪个是对的呢?为什么要有这两种不同的写法呢?
这两种写法都是正确的写法,但是它们却是有区别的。我们知道C++已经有一些编写好的头文件(比如标准函数库等等),它们存放在VC++的Include文件夹里。当我们使用#include <文件名>命令时,编译器就到这个文件夹里去找对应的文件。显然,用这种写法去包含一个我们自己编写的头文件(不在那个Include文件夹里)就会出错了。所以包含C++提供的头文件时,应该使用尖括号。
相反地,#include "文件名"命令则是先在当前文件所在的目录搜索是否有符合的文件,如果没有再到Include文件夹里去找对应的文件。因此,无论这个文件是C++提供的还是自己编写的,使用#include "文件名"命令一定是正确的。这也正是书中本节之前的程序一律使用#include "文件名"命令的原因。

关于标准的尖括号

最新的C++标准中,包含C++提供的头文件并不是写作#include <文件名>,如#include <iostream.h>的写法是过时的。正确的写法是#include <iostream>,并且要使用std名字空间。有些程序中会有using namespace std;就是按照这种标准书写的。名字空间也称为命名空间,主要是用来避免大型程序开发中的标志符冲突。标准还规定了如何在C++中包含C的头文件,有兴趣的读者可以到网上查阅这些资料。

尽管以上两种#include命令都可以正确地被VC++识别了,但是它们却并不符合C++的标准。标准规定,包含C++提供的标准头文件或系统头文件时应使用尖括号,包含自定义头文件时可使用双引号。 鉴于这里已经交代清楚了如何按照标准来包含一个头文件,在之后的章节中,所有程序的#include命令将按标准来书写。

试试看:
如果包含头文件时写作如#include <iostream>,但是没有using namespace std;,即没有使用std名字空间,能否正常实现输入输出功能?
结论:如果按照这样的写法,必须要使用std名字空间。

4.调试的一般流程

当我们按照自己的思路编写好一个完整的程序,我们就要来对它进行调试(Debug)了。调试过程包括我们熟悉的编译和连接,还包括运行和简单的数据测试等等。下面就是调试的一个流程:

调试主要分四个步骤和两种处理方式。我们把程序的编译和连接统称为编译阶段,把程序的运行和测试统称为运行阶段。在编译阶段发生的错误称为编译错误(Compile Error),在运行阶段发生的错误称为运行时错误(Runtime Error)。对于编译错误,我们通过检查并修正语法错误来解决;对于运行时错误,我们通过检查并修正语意(程序设计思想)错误来解决。

5.如何检查语法错误

所谓语法错误是指在书写语句时没有按照相应的语法格式。常见的语法错误有变量未定义、括号不匹配、遗漏了分号等等。大多数的语法错误都是能够被编译器发现的。因此相比于语意错误,语法错误更容易被发现,更容易被解决。
语法检查的工作由编译器完成,很多情况下编译器无法智能地报告出真正的语法错误数和错误位置。比如缺少一个变量的定义,而该变量在程序中被使用了6次,则编译器可能会报告6个甚至更多的语法错误,而实际上错误只有一个。所以,对编译器来说,任何一个语法错误都可能是“牵一发而动全身”的。
那么在这种可能发生“误报”的情况下,我们如何快速、正确地找到错误的位置呢?
由于编译器是按顺序查找语法错误的,所以它所找到的第一个错误的位置往往是正确的。如果程序规模不大,编译一次的时间不是很长,我们可以每次只修正编译器报告的第一个错误以及由此可以发现的连带错误,直到整个程序没有任何错误为止。
下面我们就用这种方法来检查一个程序的语法错误:(程序11.3.1)

#include <iostream> mian() {    int a,b;    for (i=0,i<3,i++)    {       cin >>a >>b;       c=a+b;       cout <<c <<endl;    }    return 0; } 
第一次编译的第一个错误:
I:\program\vc\book\11_3_1\main.cpp(5) : error C2065: 'i' : undeclared identifier//未声明的标识符 …… main.obj - 7 error(s), 3 warning(s)//一共还有7个错误和3个警告 第一次修改: for (int i=0,i<3,i++) 第二次编译的第一个错误: I:\program\vc\book\11_3_1\main.cpp(5) : error C2143: syntax error : missing ',' before '<'//逗号语法错误 …… main.obj - 8 error(s), 3 warning(s) //一共还有8个错误和3个警告 第二次修改: for (int i=0;i<3;i++) 第三次编译的第一个错误: I:\program\vc\book\11_3_1\main.cpp(7) : error C2065: 'cin' : undeclared identifier//未声明的标识符 …… main.obj - 4 error(s), 3 warning(s) //一共还有4个错误和3个警告 第三次修改: 添加using namespace std; 第四次编译的第一个错误: I:\program\vc\book\11_3_1\main.cpp(9) : error C2065: 'c' : undeclared identifier//未声明的标识符 …… main.obj - 1 error(s), 1 warning(s)//一共还有1个错误和1个警告 第四次修改: int c=a+b; 第五次编译的第一个错误: I:\program\vc\book\11_3_1\main.cpp(12) : warning C4508: 'mian' : function should return a value; 'void' return type assumed//函数需要返回一个值 …… main.obj - 0 error(s), 1 warning(s) //一共还有1个警告 第五次修改: int mian() 第六次编译: main.obj - 0 error(s), 0 warning(s)//编译正确 第一次连接的第一个错误: LIBCD.lib(crt0.obj) : error LNK2001: unresolved external symbol _main//没有main函数 …… 11_3_1.exe - 2 error(s), 0 warning(s)//一共还有2个错误 第六次修改: int main() 第七次编译: main.obj - 0 error(s), 0 warning(s)//编译正确 第二次连接: 11_3_1.exe - 0 error(s), 0 warning(s)//连接正确 

完整的程序:
#include <iostream> using namespace std; int main() {    int a,b;    for (int i=0;i<3;i++)    {       cin >>a >>b;       int c=a+b;       cout <<c <<endl;    }    return 0; } 
至此,整个程序的所有语法错误都被检查出来并且被修正。程序编译阶段没有任何错误了。通过对这个程序的语法检查,我们总结出以下几点:

  1. 编译器所报告的第一个错误位置往往是有效的,但是报告的错误内容未必正确。比如第二次编译时报告的错误是“在小于号之前缺少了逗号”,而事实上问题是for语句中应该使用分号。所以,报告的错误内容只能参考,却不能完全相信。
  2. 编译器报告的错误数目与实际错误数目未必符合。甚至第一次改正一个错误后,错误数反而增加了。所以,报告的错误数目不能正确描述实际的错误规模。
  3. 编译器报告的警告也应当被重视。有些人认为即使程序存在警告,但是它能正常执行,所以警告可以被忽视。这种想法是错误的。如果一个程序是完美的,为什么编译器还要给出警告呢?警告的存在就说明了这个程序有些地方还不符合正确的语法。
  4. I:\program\vc\book\11_3_1\main.cpp(5)括号中的5表示错误在程序的第5行。我们不需要自己去数行数,只需要双击这个错误就能到达对应的行。

6.常见语法错误及解决方法

由于大多数用户使用的是VC++6.0英文版(市场上的中文版实际上是汉化版),在面对编译器报告的各种错误时,可能会觉得茫然。这也是为什么本书总是在一些专业术语后加上对应的英文名称的原因。读者如果能够掌握这些术语的英文名,看懂错误报告也不会很难。
在下表列出了一些常见的语法错误及解决方法。另外在本书的附录中,有更多的语法错误参考信息。如果读者遇到无法解决的语法错误,可以查阅本书附录或其他语法工具书。

 

7.最麻烦的问题--运行时错误

在调试过程中,运行时错误是最麻烦的问题。因为编译错误可以由编译器检查出来,而大多数编译器对运行时错误却无能为力。查错和纠错的工作完全由用户自己来完成。
运行时错误还分为两种:
一种是由于考虑不周或输入错误导致程序异常(Exception),比如数组越界访问,除数为零,堆栈溢出等等。
另一种是由于程序设计思路的错误导致程序异常或难以得到预期的效果。
对于第一类运行时错误,我们不需要重新设计解决问题的思路,认为当前算法是可行的、有效的。我们只需要找出输入的错误或考虑临界情况的处理方法即可。对于第二类运行时错误,不得不遗憾地说,一切都要从头再来。

见识运行时错误

由于编译器无法发现运行时错误,这些错误往往是在程序运行时以五花八门的形式表现出来。下面就是典型的几种因运行时错误引起的问题:
(1)WindowsXP错误报告

(2)内存不能为Read/Written

(3)非法操作

(4)Debug错误

查找错误点

语法错误的位置能很快地被编译器找到,而运行时错误的位置却很难被我们发现。即使我们一条条地检查语句,也未必能检查出什么。所以,在这里要介绍一种查找导致运行时错误的语句的方法。
我们知道,带有运行时错误的程序是可以运行的。当它运行到一个产生错误的语句时,就提示出错了。根据这个特点,我们可以用输出语句来判断程序的运行流程。下面就让我们来看一段有运行时错误的程序:(程序11.4)

#include <iostream> using namespace std; int main() {    char a[5],b[5];    int alen=0,blen=0;//记录字符串a和b的长度    cin >>a >>b;    for (int i=0;a[i]!='\0' && b[i]!='\0';i++)//计算字符串的长度    {       if (a[i]!='\0')          alen++;       if (b[i]!='\0')          blen++;    }    char *c=new char[alen+blen];//申请堆内存,存放连接后的字符串    for (i=0;i<=alen+blen;i++)//把字符串a和b连接复制到字符串c    {       if (i<alen)          c[i]=a[i];       else          c[i]=b[i-alen];    }    cout <<c <<endl;    delete [] c;//释放堆内存    return 0; } 
运行结果:
OOTTMA TomatoStudio udioTomat 葺葺葺葺癅 
在程序运行结束之前,提示Debug Error,它属于一种运行时错误。而且根据输出的一些内容,发现程序也没有达到连接字符串的目的。所以我们让程序输出更多信息,查找错误原因。首先在计算字符串a和b的长度后,输出他们的长度,即在第一个for语句后添加一句cout <<"alen=" <<alen <<"blen=" <<blen <<endl;。
运行结果:
OOTTMA TomatoStudio alen=4blen=4 udioTomat 葺葺葺葺癅 
OOTTMA字符串长为6,TomatoStudio字符串长为12。根据程序运行结果,我们发现计算出的字符串长度有问题。所以我们必须检查实现该功能的语句。另外,由字符串长度我们可以想到申请空间是否足够的问题。发现数组的空间只能存放5个字符,而现在两个字符串都已经超过这个限制。于是把数组空间扩大,该作char a[20],b[20];。
运行结果:
OOTTMA TomatoStudio alen=6blen=6 OOTTMATomatoS 葺葺癅 
发现字符串a的长度已经正确,可是字符串b的长度为什么不对呢?经过多次尝试,我们发现,正确的字符串长度总是较短的字符串。所以我们想到检查循环继续的条件是否正确,如果过早地终止循环,就会导致这种情况。果然,a[i]!='\0' && b[i]!='\0'意味着只要有一个字符串结束,那么长度计算就结束了,故把&&改成||。
运行结果:
OOTTMA TomatoStudio alen=35blen=41 OOTTMA 
这么一改,居然两个长度全都错了。我们不禁要思考为什么会这样了:用一个for语句来计算两个字符串的长度,当循环变量越过任一个字符串的结尾符以后又误认为它没有结束,所以输出的长度远远长于字符串的实际长度。我们把计算字符串长度用两个for语句来实现。即程序被改写成这样:
#include <iostream> using namespace std; int main() {    char a[20],b[20];    cin >>a >>b;    for (int alen=0;a[alen]!='\0';alen++);//计算字符串a的长度       for (int blen=0;b[blen]!='\0';blen++);//计算字符串b的长度          cout <<"alen=" <<alen <<"blen=" <<blen <<endl;    char *c=new char[alen+blen];    for (int i=0;i<=alen+blen;i++)    {      if (i<alen)         c[i]=a[i];       else         c[i]=b[i-alen];    }    cout <<c <<endl;    delete [] c;    return 0; } 
运行结果:
OOTTMA TomatoStudio alen=6blen=12 OOTTMATomatoStudio 
现在两个字符串的长度都正确了,输出的内容也实现了字符串的连接,但是Debug Error仍然存在。继续检查,发现剩下的语句和申请的堆内存空间字符串c有关了。于是先检查c是否有越界访问。根据c申请的空间大小,发现for语句中循环继续的条件有错误,导致越界访问,把它改成i<alen+blen;。
运行结果:
OOTTMA TomatoStudio alen=6blen=12 OOTTMATomatoStudio @ 
Debug Error已经没有了,看来造成这个错误的原因就是越界了。但是现在输出的字符串后面有乱码,可能是结尾符被忽略了。检查程序,发现alen+blen是两字符串长度,但是没有考虑结尾符,所以要给字符串c增加一个字符的空间。程序改写成如下:
#include <iostream> using namespace std; int main() {    char a[20],b[20];    cin >>a >>b;    for (int alen=0;a[alen]!='\0';alen++);       for (int blen=0;b[blen]!='\0';blen++);          //cout <<"alen=" <<alen <<"blen=" <<blen <<endl;          char *c=new char[alen+blen+1];    for (int i=0;i<alen+blen+1;i++)    {       if (i<alen)          c[i]=a[i];       else          c[i]=b[i-alen];    }    cout <<c <<endl;    delete [] c;    return 0; } 
运行结果:
OOTTMA TomatoStudio OOTTMATomatoStudio 
至此,程序修改完成。在目前的测试数据下,不再出现运行时错误,并且也能实现字符串连接的功能。

8.调试工具—Debug

由于引起运行时错误的原因难以被发现,所以我们有时候要利用工具来完成调试工作。Debug就是VC++提供的一种常用调试工具。它能够让语句一句一句或一段一段执行,并且能够观察程序运行过程中各变量的变化情况。
在介绍如何使用Debug工具之前,我们要介绍一下什么是断点(Breakpoint)。当程序运行到断点的时候,它会暂时停止运行后面的语句,供用户观察程序的运行情况,并等待用户发出指令。断点不是语句,而是附在某条语句上的一个标志。

如何设置和移除断点

点击需要设置断点的语句,使光标移动到该语句所在的行。按下F9键或按钮就会发现,在该语句之前出现一个红点,这就是断点标志。如下图11.5.1所示:

如果要移除已经设置好的断点,则同样点击断点所在语句,按下F9键或按钮则断点被移除。我们可以个给一个程序设置多个断点。

Go

设置了断点之后,我们就能开始调试程序了。与以前不同,我们不能按执行按钮,而是要按F5键或按钮,或者选择Build菜单Start Debug中的Go。一旦按下了Go,则程序会正常运行直至遇到断点。
我们以下面这个程序(程序11.5)来演示Debug功能的使用。该程序主要目的是统计一个不多于20项的正整数数列中,有多少对成双倍关系的项,该数列以0结尾。比如数列1 3 4 2 5 6 0中,成双倍关系的项有3对(1和2、2和4、3和6)。

#include <iostream> using namespace std; int main() { ● int a[50],b[50],sum=0;//在此设置断点    for (int i=0;a[i-1]!=0;i++)    {      cin >>a[i];      b[i]=2*a[i];    }    for (i=0;a[i]!=0;i++)    {       for (int j=0;b[j]!=0;j++)       {          if (a[i]==b[j])          {             sum++;             break;          }       }    }    cout <<sum <<endl;    return 0; } 

设置好断点,按下Go按钮以后,我们可以看到如下的界面:

在界面中出现了三个我们不熟悉的窗口。在屏幕中间有着很多按钮的小窗口叫Debug窗口,里面的按钮可以控制程序继续运行的方式。在屏幕左下方的窗口称为Variables(变量)窗口,可以观察每句语句执行后变量变化的情况。在屏幕右下方的窗口称为Watch(监视)窗口,用户可以监视一些变量或简单表达式的变化情况。

Debug窗口

Debug窗口中,第一行按钮是我们常用的。它们依次是:
Restart——重新开始调试。
Stop Debugging——停止当前调试。
Break Execution——停止程序的执行并转回调试状态。
Apply Code Changes——使调试过程中修改的程序代码生效。
ShowNext Statement——显示将要执行的下一条语句的位置。在语句之前用黄箭头表示。
Step Into——进入语句调用的函数,并进行调试。
Step Over——不调试语句调用的函数。
Step Out——从当前调试的位置回到调用该函数的位置。
Run to Cursor——正常运行直到光标所在的语句。
我们在调试的时候,不要总是按“Step Into”,因为它对于一些系统提供的函数也是有效的。也就是说我们能够用它详细地看到系统是如何实现一个输出功能的,甚至可以看到这些语句的汇编语言形式。但是,这却并不是我们调试的主要目标。如果不小心进入了系统函数里,我们要及时按“Step Out”以退回到我们所编写的程序中来。
在调试过程中,对于大多数语句应该按“Step Over”。如果要调试自己编写的函数,则在调用该函数的语句处按“Step Into”。

Watch窗口

在Watch窗口中分为两列,一列为Name,一列为Value。其中Name是可以被编辑的,我们可以在里面输入变量名或简单表达式。如果改变量或表达式是可以被计算的,则会在Value中显示它们的值,如下图11.5.3所示:

如何用Debug找到错误

在Debug中,我们可以让语句一句句地执行。如果执行到某一句语句时发生了运行时错误,那么这个错误一般就是由这个语句引起的。
在Debug中,我们可以观察每一句语句执行的顺序和执行后变量变化的情况。如果发现程序无法实现既定的功能,我们可以将期望的结果和实际的结果作比对,并分析可能引起这些不同的原因。这样一来,大大加快了我们找到问题和解决问题的速度。