Windows程序调试----第一部分 调试策略----第2章 编写使于调试的C++代码

来源:互联网 发布:算法 第四版 编辑:程序博客网 时间:2024/06/07 04:55

2章编写使于调试的C++代码

    毫无疑问,当你在写C++代码的时候,你的头脑中会考虑很多事情。代码是否正确,是否执行得是够快,是否可靠,是否便于维护,工程是否会按时完成,人们是否会喜欢这个结果?然而,调试这段代码的能力应该也在你的考虑之列。

    C++是一种非同寻常的编程语言,有惊人的产生错误和避免错误的能力。在这一章里,我列举几种技术帮助你从战略上书写便于调试的C++代码。这些技术能使你充分利用C++编译器的潜力,预防和消除错误,或是避免常见的语法错误。它还能帮助你建立一种便于利用C++调试工具调试程序的编程风格。当正确运用Visual C++调试工具时,你的编程语言和它的编译程序就组合成了功能最强大的调试工具。

2.1设计

    由于代码是你的设计的实现,要避免错误首先就要从产生好的设计开始。显然,一个设计良好的程序要比设计得一塌糊涂的程序更容易调试。尽管程序设计有很多重要的特性,简单性(simplicity)和耦合性(coupling)是两种与调试关系最为密切的特性。

    简单性

    为什么要求设计简单的程序似乎是一目了然的,但是我所见到的人多数常见的设计错误都来源于程序设计中不必要的复杂成分。一个好的设计应该反映问题本身的要求,也就是说,解决方案应该与问题相一致,而不要添加不必要的特性。复杂的设计总是用“满足将来的需要”为理由,但是我发现,简单优雅的设计比那些错综复杂的设计更能迎合未来的需求。进一步说,很多对于未来需求的考虑都是错误的。一个很好的例子就是C++编程语言本身的进化过程,C++就是起源于简单的C语言。这就是为什么我们不用Ada++编程的原因。

    耦合性

    耦合性用来衡量不同对象之间的依赖程度。由于‘依赖性越少越好,因此,程序中能独立的对象要尽量独立出来。这种去耦合(decoupling)的程序易于理解,易于实现,易于测试和维护。而且这种程序包含错误的可能性也较小,错误也会比较容易被发观和清除。在面向对象的程序设计里,派生类和基类之间就存在耦合,但是这种“垂直”耦合(vertlca| coupling)是我们所希望的,我们要避免的是“水平”耦合(horizontal coupling)。在后者的情况下,本来独立的对象被强迫相互依赖,只是为了让它们在一起工作。松耦合的程序对于测试尤为重要,因为它允许你在某种测试工具的帮助下独立地对各个对象进行测试。

2.2 C++编程风格

    在这个部分,我们讨论一些编程风格方面的问题。这些问题与代码的表达有关,但是不涉及到具体的C++语言的特性,实际上,你可以将这些表示风格用于任何编程语言中。也许有人会说,编程风格是个人的问题,因此有很大的任意性。通常情况下,这是对的。实际上,越是任意的事情,人们越热衷于采用自己喜欢的方法。但是,当你从调试的角度来看问题的时候,这些任意性就消失了。一个好的编程风格不仅让代码易读易理解,也能使程序员易于使用调试工具对代码进行调试。考虑一下调试的时候你要花费多少时间在一遍又一遍地阅读代码上面,这就不会是一个不重要的问题。

    在调试的时候编程风格很重要。

    清晰地书写代码

    Brian Kemighan RJ, Plauger的经典著作《The Elements of Programming Style》里,提到广两个基本的原则:

    •书写清晰——不要自作聪明。

    •简单并且直接地说出你的意思。

    这些原则也是我们的出发点,任何不易理解的代码都是不易调试的代码。显然,书写清晰的代码产生错误的可能性更小,并且错误也更容易被发现和清除。

    对于不要太聪明这一点,C++提供了大量的特性,包括很多标准的程序员都没有充分理解的那一部分高级特性。一些程序员为了避免错误,总是使用语言中相对简单的成分构成的子集,这一部分是大多数程序员都能理解并且是不容易犯错的。另外一些人相信语言中的所有成分都应该被使用。我的建议是你应该在需要的时候自由支配高级语言特性,并且,使用一些文档来使之清晰易懂。使用这些高级语言成分是因为你需要它们,而不是因为它们存在

    编写结构良好的代码

    当你的程序崩溃时,你得到的最基本的调试信息是源代码文件、问题所在的行号和一个调用栈(call stack)。调用栈是调试程序时最有帮助的部分,因为它提供给你了错误出现的上下文,也就是带参数的函数调用序列。你书写的代码结构越好,调用栈就能给你越多信息。比如说,考虑两段有错误的代码,它们都是对同一复杂算法的实现。一个是结构良好的;另一个就由一个单独的庞大函数组成。前者的调用栈会给你很多有用的信息,但是后者的调用栈实际上什么信息都没有。

    使用良好的标识名字

    给类、函数、变量和常量起一个好名字,能帮助你的代码更容易被理解,因此也越不容易犯错误。在维护的时候,这一点尤为重要,因为不好的名字容易引起浞淆。要考虎的最重要的一点是,读代码的不仅仅是你自己,还有其他的程序员和编译器。编译器能理解各种名字,但是其他的程序员就做不到了。要写出能被“人”理解的代码

    下面是我在选择变量名字的时候考虑的几个原则:

    •简单的描述性的名字。好的名字能简要地概括出这个标识符代表的含义。意思应该一目了然,不言自明。读者能够不看注释就能理解这个标识符是干什么的。也是名字要尽量简单,不必要的过长的名字是一种负担,只有在它们能增加必要的清晰度时,长名字才是可取的。

    •避免简写。很多年前就有人用简写作为变量名字,那时候存储费用昂贵,键盘质量也很差。编程语言中只能使用八个字符的标识符。现在时代变了,但是不幸的是,这种传统延续了下来。尽管截短长的单词并不影响可靠性,使用简写就会影响到可靠性。比如说,pos作为位置的变量很合适,但是pstn(position中的元音去掉)就不合适了。简写使标识符难于阅读和记忆,因此是一种不好的习惯。只省略棹一两个字母的简写尤为有害,建议使用混合大小写的完整的单词。

    •避免相似的名字。在名字之同要有足够的“心理距离”才能避免混淆。心理距离就是两个不同的东西在头脑中的区别。比如说,记忆count—个没有问题,但是如果和Countcnt, count2一起记忆就会引起混淆。加个数字的后缀只能创造很小的心理距离。

    •避免采用一般的名字。编程的教程(例如本书)经常在标识符没有指向特定涵义的东西时,就使用一般的名字。比如说,CmyDocument在示例程序中就是—个很好的类的名字,因为它显然是从类CDocument中派生出来的。但是在创建代码的时候,选择这样的名字就不够明智了。因为对于这个派生类的信息这个名字实际上什么都没说。其他的不好的名字比如说用公司的名字(同样,这样的名字告诉你什么信息了呢),用冠词、代词作为变量名字,如theObjectanObject或者itsObject,都不够合适,还有例如foobar这样的名字,如果是基于foobar的,那就尤为不好。

    •避免随机产生的名字,我记得在一项帮助纠正UNIX中令人恐怖的命名传统的调查中,被测试者被要求用州名去代替命令名,比如说,用California表示显示目录或者用 Mississippi代表拷贝文件。他们发现这些测试者在使用这些州名来完成简单的任务时毫不费力。尽管这场调查本身毫无意义,因为它忽略了短期和长期记忆的差别,并且人们不需要在理解简单事物的时候建立一个精神上的地图。

    •避免开玩笑的名字。很抱歉,写得糟糕的代码一点都不好笑。

    在《Code Complete》一书的第9章“The power of Data Name”里,Steve McConnell 用一整章的篇幅介绍了怎么选择标识符的名字,你可能需要这本书来得到进一步的信息。最后一条就是用平常的语言命名。在《The Elements of Programming Style》这本书里, KernighanPlauger建议使用“电话测试”来检测可靠性。也就是说,当你在电话里大声念出你的代码时,如果电话那端的那个人能理解,就说明你的代码足够清晰。否则,就要重写了。如果你的代码不能通过电话测试,说明你的代码不具备可读性。

    好的名字能够用平常的语言概括出该标识符所代表的实体的含义

    重新考成匈牙利命名法(Hungarian Notation)

    (关于匈牙利命名法,参考:软件随想录:让错的程序看得出错:http://blog.csdn.net/tiewen/article/details/8536773

    到目前为止,你可能己经推测出我没有提及匈牙利命名法不是偶然的。如果你不熟悉它,那么匈牙利命名法实际上是把标识符的意义和表示方法结合起来(加密?)。因此,变量名字不用count而是nCount,其中n代表一个整型数据结构。尽管很多Windows程序员喜欢用匈牙利命名法并且对它极其信赖,我不喜欢这种方式。它比我能想象到的其他任何东西都更加不利于Windows程序的可读性。

    匈牙利命名法在早期的Windows编程中很流行,并且它的确起了很大作用。想想第一个Windows程序是在ANSI C之前写出来的,那时的函数还没有原型。并且这些程序主要是基于16位的存储介质(默认情况下函数指针为长指针而数据指计为短指针),通常只有一个数据段,用户则运行多个程序实例。在这种情况下,如果不用一个显式的LPSTR类型转换就调用下面的语句,就会发生错误:

    MessageBox(hWnd, (LPSTR)"HeIp!My program has bugs!", lpszAppName, MB_OK)

    因为编译器不知道需要的是长指针,因此它会用一个默认的短指针去代替,这样就出现了问题,在这种情况下,程序员必须像一个人工的编译器一样,手工地检查每一行代码,保证所有的函数调用传递的参数都具有正确的类型和正确的指针长度。一旦没有做到这一点就会发生严重的错误。在这种环境下,我也会使用匈牙利命名法的。

    时代发生了变化,现代的C++编译器带有原型和强大的类型检查功能,能够检查出大多数类型不匹配错误(也有例外,比如一般类型中的void, WPARAMLPARAM以及可变参数函数像wsprintf)。車实是代码编译器告诉了你关于代码本身的很多信息。比如说,当编译下面这条语句的时候:

    firstName = name.Left(firstNameSize); list->Add(firstName);

    我们知道firstNamename是字符串,根据上下文list是一个指向list对象的指针。函数的上下文通常能够提供足够的信息让我们根据代码决定变量类型,就像在散文中我们根据句子的上下文来理解文章一样。让编译器来处理类型的检查是一个好主意,在检查类型错误方面,现代的编译器肯定比程序员更合适。

    匈牙利命名法的最大问题在于结果代码难于阅读(有些人也说最大的问题是把表示法与变量名字放在一起严承违反了数据抽象的原则)。尽管用匈牙利表示法前缀并没有强迫你使用不好的变量名字,但是结果却往往是这样的。用匈牙利命名法的程序员似乎把注意力集中到了前缀上面而没有关心这种做法导致的可读性的降低。结果就是这种命名法变得极其难懂。因此尽管可以把指向工具箱的指针命名为IpszToolTipText,匈才利命名法迷们会有种难以抗拒的冲动要把它叫为lpszttt

    匈牙利命名法还有一个严重的问题就是它使程序难以维护。因为它把表示和意义结合起来了。如果表示发生变化,变量名字也要跟着变化。不幸的是,一旦某一个前缀确定下来来(比如说在一个已经公布的API函数里),它就很难再改变了。例如,WPARAMLPARAM变量现在都是32位的,但是它们过时的前缀却说明它们的大小不相同;同样地,lp前缀中的l表示长指针,但是长指针在32位的Wmdows里面是没有意义的。尽管这些前缀是错误和误导性的,但是它们都已经根深蒂固,不易改变了。用匈牙利命名法给函数命名麻烦更大。因为当函数返回值改变的时候,你的选择要么是留着错误的前缀,要么是冒风险去修改一大堆的代码。

    最后一个问题是匈牙利命名法并没有传递多少信息。要想完全理解一个变量,你需要知道的不只是它的类型。变量的作用域是什么?是全局的、局部的,还是成员数据?变量是一个标量(scalar)还是个数组(array)?如果是数组,数组的维数是什么?变量是否恒定(const),是否是静态变量(static),是否是可变的(volatile)?所有这些信息都和数据类型一样重要,但是却不一定总是被编进匈牙利前缀里面。因此,任何只是基于匈牙利前缀得到的结论都可能不正确。注意,除了作用域信息,其他的信息都可以通过Visual C++的源代码编辑器中的数椐提示得到,此时你并不需要运行调试工具。这些数据提示制作得非常棒。

    由不完整的信息带来的问题喑示了解决变量命名的更好办法。一个好的命名传统就是指示出变量的作用域以便你在需要的时候检查它的定义。你的命名方法要明确地指出一个变量是全局的、局部的,还是成员数据。依赖变量的定义比依赖匈牙利前缀更加有用和可靠。

    在有限的场合下,匈牙利命名法是有用的。事实上,有些匈牙利前缀在Windows编程中是非常标准的,以至于如果不用它们反而会引起混淆,例如

    m_    表示成员数据

    p     表示指针

    h     表示句柄

    C     表示类

    I      表示COM接口

    比方说,给设备句柄命名的传统方式是hDC,用其他的名字就会引起混淆,因此不要把它叫做deviceConlextHandle。当你需要在一个相同的作用域下命名一个对象、一个指向对象的指针、一个对象的句柄的时候(这种情况在WimJows编程中间很常见),匈牙利表示法都是很有用的。

    尽管有它的缺点,匈牙利命名法仍然非常流行。当然了,如果你的编程小组使用匈牙利表示法,那么你也应该遵循己经建立的编程标准,因为一致性和清晰性一样重要。我知道如果你己经打定了主意,我就很难改变你的看法了(等一下一一让我再给你最后一击:在匈牙利命名法中整数怎么表示,是n还是i?以£为前缀代表什么,是float还是BOOL flag?如果你不知道,你就要好好思考下了),如果你还没决定,那就别再使用匈牙利命名法了。它不是书写Windows程序的正确方法。你可以采用很多方法来提高你的代码的可靠性和可读性,但是匈牙利命名法不是这些方法之一。

    尽管匈牙利命名法过去很有用,但是时代改变了。用匈牙利命名法书写的程序难以阅读,难以维护,也容易被人误解。

    用简单的语句行

    C++语言中,可以在一行里面书写好几个语句。它对简洁表示法的继承鼓励了这种做法。当你在选择一行中要安排哪些语句的时候,要时刻牢记调试的时候是面向行的,除非你在汇编代码一级进行调试。调试手段比如断点(breakpoint)、命令(如StepInto,StepOverSet Next StatementRun Into Cursor,断言(assertions)trace语句,还有MAP文件都是基于源代码的行的。用过于复杂的行会使得难于设置断点、跟踪程序或是决定出错的代码和错误的断言,从而不能充分利用这些调试工具。从调试的角度出发,每一个语句行都应该作为一个单独的原子单位。

    使用统一的排列

    一些程序员十分仔细地排列他们的代码,然而有一些程序员让代码处于“天然”的状态。毫不奇怪,我觉得统一排列的代码更可取。不仅仅使得代码更好看,而且排列使模式更好识别。如果代码没有遒照模式,就说明出现了错误。比如说,当我们试图标记出下面这段代仍的错误时:

    // save values if change

    if(Location != savedLocation &&

        displayMode = savedDisplayMode &&

        size != savedSize &

        colorScheme != saveColorScheme)

      SaveValues();

    这种没有棑列的代码难以快速扫描,现在试着在这段排列好的代码里面标记错误:

    // save values if change

    if(Location      != savedLocation    &&

        displayMode = savedDisplayMode &&

        size        != savedSize       &

        colorScheme != saveColorScheme)

      SaveValues();

    后者与前者有着明显的区别——错误跳到你面前了(在这个例子里,“=”错误地代替了“!=”,“&”错误地代替了“&&”。注意要求统一排列并没有意味着你要把所有的语句都排列得一丝不乱到了可笑的程度,用两三个不同的排列方式也不错。例

    // save values if change

    if(Location      != savedLocation     &&

        displayMode != savedDisplayMode &&

        size           != savedSize       &&

        colorScheme    != saveColorScheme)

      SaveValues();

    考虑用统一的排列方式能够使类和函数的定义、变景的定义和ifwhileforswitch等语句更加明显。用空格而不是用他键能够使语句的排列独立于tab设置。在Visual C++中,你可以用Tools里面的Option命令来设置TabsInsert space选项(如果你曾经维护用不同的tab设置书写的代码,你就能明白我的意思)

    用括号使书写清晰

    我是通过学习Brian KernighanDennis Ritchie写的《The C programming Language》一书来学习写C程序的。过了—段时间之后,如果你试图把书平放在桌子上或者甚至把它从屋子里的这头扔到那头,它都可能打开到第49页,这里面是C的优先级和结合率表。为了保证书写出正确的程序,我经常査阅这一面,因为这些规则告诉你什么时候必须用括号。

    很多年我都不再看这些表了,并不是说我已经记住了这些表或者是这些规则能够在线获得,相反,是因为我采用了Steve Maguire在《Writing solid Code》一书中的建议:不要査书!为什么这是一条好建议?记住你的代码的读者是你自己、其他的程序员以及编译器。尽管编译器已经牢记了优先级和结合率,大多数程序员却不一定做到了这一点。所以如果你不能确定是否需要用括号,你就需要用括号。继续下去并且使用它们,即使它们并不是必需的。读者并不需要査阅优先级表和结合律表来理解你的代码,也不需要查阅这些表来决定你是否用错了。这种策略帮助你节约了时间,避免了错误,并且使你的代码易于理解。最好的是,使用多余的括号并不影响编译后的代码,因此不会带来性能的损失。当然了,除非没有括号的时候产生了错误。

    使用好的注释

    用好的注释能使你的代码不易出错,有其是那些由于维扩不善所带来的错误。又一次,记住你的源代码的读者是你自己、其他的程序员(包括维护程序员)以及编译器;虽然编译器不懂注释。但是程序员明白。由于将来可能其他的程序员要维护你的代码,因此要保证提供给他们必需的并且是易于理解的注释。记往要给类、结构和函数以及任何你在代码中间的假定做出解释。

    给那写令人困惑的、带点小窍门的或是不常见的代码做出注释尤为重要。有人或许会说,这些代码应该重新书写而不是做出注释,但是有时候这样的代码是必要的,如果你需要解释一些代码中无法说明清楚的东西,那就用注释吧。尤其是当你在开发的时候做出了一个不好的修改,那么你必须做出注释。比如,如果你修改了代码,以为它会改善性能,但是实际上导致了一个错误的发生,那么你一定要在注释里指出这个改变以及问题的本质。否则,其他的人(甚至你白已)就可能在将来做同样的“改进”。想得到更多的信息,请看Steve McConnell的《Code Complete》的第19章“Setf-Documeming Code”。

2.3 C++语言

    这里有几种采用C++语含本身和它的编译器来防止错误和避免隐含的失误的方法。

    C++而不是C

    使用以下的技术来充分利用C++的编译器:

    1.const代替#define来创建常量。

    2.emum代替#define来创建常量集合。

    3.用内联(inline)函数代替#define宏。

    4.newdelete代替mallocfree

    5.用输入输出流(iostreams)代替stdio

    前三种技术用C++语言而不是C预处理。使用预处理的问题在于编译器对于预处理器所作的事情一无所知,因此无法用数据类型检查错误和不一致的地方,预处理的名字不在符号表里,因此也不能用调试工具来检查预处理常量。相似地,预处理宏被编译进去,因此你不能用调试工具跟踪宏。预处理宏由于优先级的问题和传递参数带来的副作用而臭名远扬。相反,编译器能充分了解constenumlinline语句,从而能在编译的时候对出现的问题向你发出警告。由于内联函数在调试的时候是和普通函数一样被编译的,你可以用调试工具跟踪里面的执行,佴是你却不可能跟踪预处理宏。

    选择语言而不是预处现有一个很重要的异常:在很多调试代码里面,预处理起到了很重要的作用。原因是调试代码经常需要从非调试代码里面得到不同的行为,而最有效的办法就是让预处理为调试创建不同的代码。因此,本书中包含了大量的基于预处理的调试代码。

    选择C++语言而不是C预处理,但是要理解调试代码的时候需要使用预处理器。

    在创建对象、类型的安全性和灵活性方面,使用new/deletemalloc/free要好。malloc只是分配内存,new不仅分配内存(而且正好是需要的大小),还调用了对象构造函数。相似地,free只是释放内存,而delete先调用对象析构函数然后才释放内存。实际上,你不能用malloc创建C++对象,因为你不能直接调用构造函数。为了安全起见,你可以通过_set_new_handler的使用,在new出现错误的时候抛出异常,而此时malloc只是在默认的时候返回0——虽然你也可以通过_set_new_mode的使用使malloc在失效的时候和new有一样的行为(见第5章示例)。用new还能带来类型的安全性,你不会把结果指针赋值给不相容的指针类型。相反,malloc返回的是void*。因此总是需要一步强制类型转换。另外,new运算符可以被类重载,因此提供了更大的灵活性。由于Visual C++中的mallocnew使用相同的核心内存管理程序(因此有相同的出错诊断支持),用mallocfree就没有任何优势了。

    最后,使用C++输入输出流(也就是<<>>运算符)而不是使用C标准输入输出库(也就是printf/sprintfscanf/sscanf),这样做有利于安全性和扩展性。从调试的角度来看,标准输入输出函数的最大问题在于编译器不能对控制流参数进行任何类型检测。实际上,编译器对这些函数了解甚少,甚至你没能把指针传递给scanf,它都无法对你做出警告。相反,输入输出流中的任何问题都能在编译时刻检测出来。进一步,你可以把输入输出流的运算符运用到任何C++类中,而标准输入输由库函数只能用在内嵌的数据类型上面。关于这个方面的更多介绍,可以参看Scott MeyersEffectiveC++》,第二版,第123条。

    使用头文件

    把不同文件之间共享的各种定义写在头文件里面,使用头文件保证所有的定义对于各个编译单元来说都是相同的。注意,C++把外部定义的符号隔离开来,以保证类型安全的链接,这一点避免了很多类型的错误匹配。但是,当extern C也包含进来的时候,使用强制类型转换、类型指针或者函数定义都有可能出现类型的错误匹配。另外,数组和指针接受相同的处理,因此下面的这段代码可以链接:

    int Fibonacci[] = {1,1,2,3,4,5,13,21}; // definition in File1.cpp

    extern int * Fibonacci; //declaration in File2.cpp

    但是,注意在File2.cpp中间存取Fibonacci [0]会产生一个存取违例。为什么会这样?把所有的共享定义放在头文件里,不要在你的.cpp文件里面看到extern关键字。

    另外,最好把参数名字放在头文件的函数原型里面。尽管编译器可能不在乎,但是参数名字能使代码更易于阅读和使用。

    初始化变量

    在使用变量之前一定要记住:把它们初始化。在初始化之前就使用变量肯定会产生错误(但是一个未经初始化就作为函数的输出参数的变量应该被认为是初始化了的)。这个观点很简单也很明显,但是在初始化变最后面的细节却是十分复杂的。内嵌(built-in)(或是内在(intrinsic))数据类型的变量(比如charintfloat),如果是全局变量,都是在装载程序的时候自动初始化为0的。但是如果它们是自动(automatic)(栈中的局部变量)或是用new分配的(在堆(heap)里面)就没有被初始化(实际上,如果你选择了编译里的/GZ选项,自动变量就会在调试的时候被初始化,这一点我们在这一章的稍后部分再讨论)。对象不管存储在什么地方,都是通过构造函数来初始化的。由于大多数构造函数都能把它们的对象初始化到一个良好定义的状态(毕竟这就是构造的数的作用),你通常不需要对对象进行初始化。比如说下面的由Visual C++ ClassWizard产生的微软基本类库(MFC)的初始化代码并小需要,因为CString类的对象己经在构造函数里被初始化为空串了。

    CExampleDialog:: CExampleDialog (CWnd* pPerent)

    :CDialog(CExampleDialog::IDD, pPerent)

    {

        //{{AFX_DATA_lNITl(CExampleDialog)

        m_String = _T("")

        //}} AFX_DATA_lNITl

    }

    不幸的是,这也有例外,不是所有的对象都由构造函数初始化的。比如说,MFC中的CPoint,CRectCSize就不是由它们的默认构造函数初始化的,因此你总是要明确地初始化这些别象。虽然这是MFC中的一个缺陷,但我猜想这是刻意安排的,因为这样这些对象就可以和API中对应的RECTPOINTSIZE相同了。在构造函数里面,你总要初始化数据成员。毕竟数据成员都需要在某种程度上被初始化,那就把这个任务交给构造函数来完成好了。

    你必须明确为在栈中和堆里分配的数组和数据结构进行初始化。尽管可以用库函数memset来初始化,API函数里的ZeroMemory对于Windows程序更为方便。但是不要用这些函数来初始化对象,因为这样的初始化会破坏基类和数据成员构造函数的工作。比如说,不要做下面的事情:

    CDisasterApp:: CDisasterApp () {

        ZeroMemory(this, sizeof(this));  //bad idealiving very dangerously

    }

而是应该分别初始化每个需要初始化的数据成员。

    最后,Visual C++编译器可以检测出未经初始化的本地变量,但是这种检测在发布版本(release builds)中比在调试版本中要做得好,因为变量的使用是由优化器来检查的。这个事实说明要经常运行发布版本,尤其是你要跟踪一个复杂的错误的时候。

    经常创建发布版本来帮助检测未初始化的变量

    使用位掩码(Bit Masking)

    如果位掩码不是0或者2的幂次方,那么位掩码就容易出错。如果每个位都是2的幂次方,在整数的表示法里面,一位就代表一个状态。比如说,下面的位掩码就可以正确使用:

    #define WS_EX_DLGMODALFRAME    0x00000001L

    #define WS_EX_NOPARENTNOTIFY     0x00000004L

    #define WS_EX_TOPMOST        0x00000008L

    #define WS_EX_ACCEPTFlLES      0x00000010L

    #define WS_EX_TRANSPAKENT     0x00000020L

 

    if((extendedStyles & WS_EX_TOPMOST) == WS_EX_TOPMOST) ...; // works

    if(extendedStyles & WS_EX_TOPMOST) ...; //works as expected

    if(extendedStyles == WS_EX_TOPMOST) ...; //doesn't work--never use

    不幸的是,Windows程序中使用的位掩码不都是2的非0次方,这能使一个整数中包含更多的信息5考虑下面这种用法:

    #define SS_LEFT        0x00000000L

    #define SS_CENTER     0x00000001L

    #define SS_ RIGHT      0x00000002L

    #define SS_ICON        0x00000003L

    #define SS_BLACKRECT   0x00000004L

 

    // doesn't work--statement is always true

    if((controlStyle & SS_LEFT) == SS_LEFT) ...;

    // doesn't work--it statement is true,style could be SS_CENTER or SS_ICON

    if((controlStyle & SS_CENTER) == SS_CENTER) ...;

    两条位掩码的语句都是不正确的。这种类型的错误很难被追踪到,因为代码看上去很正确。有可能在大多数情况下,代码都会正常工作。比如说,如果你写的代码处理静态控件文本,那么你不会意识到你的代码混淆了居中文本样式和图标样式,直到别人用图标样式测试你的程序一一这也不是一个显而易见的测试。这些位掩码的语句应该正确书写如下:

    #define SS_TYPEMASK   0x0000001FL

    // works as expected

    if ( (controlStyle & SS_TYPEMASK) == SS_LEFT) ...;

    // works as expected

    if ( (controlStyle & SS_TYPEMASK) == SS_CENTER) ...;

    不幸的是,这个问题意味着你不能抽象使用位掩码。检查是否正确书写位掩码语句的唯一方式就是査看位掩码的声明,实际上,这种类型的位掩码错误是无法得知的,除非你知道特定的位掩码值。在书写位掩码代码的时候为了避免出现问题,一定要检查这些声明,并且使用必要的掩码宏(比如说HRESULT_CODE())或者子域掩码(比如说SS_TYPEMASK())

    对于不熟悉的位掩码要检查位掩码值,必要的时候,使用掩码宏或者子域掩   

    使用布尔变量

    尽管C++现在有一个内嵌的布尔类型(即bool,值为truefalse,大小为1个字节),Windows程序通常还是使用BOOL。定义如下:

    typedef int      BOOL;

    #define FALSE    0

    #define TRUE   1

    C++中,一个布尔表达式如果值为0则为假,如果有其他的值则为真。这就意味着下面这段看起来正确的语句实际上有错误:

    if( booleanValue == TRUE) ...;

    if( booleanValue != TRUE) ...;

    当然了,问题就出在当booleanValue的值多于01的时候,这些布尔值的检测就会出现错误。注意到很多Windows API函数的返同值为布尔值的时候,返回的不仅仅是0或者1(IsWindow函数就是一个很好的例子)。实际上,Windows API文档总是用0和非0来代表返回值。如果booleanValue1000次运行中有一次等于2了,你就很难在调试的时候发现错误。

    显然,布尔表达式不应该检査是否为真而应该检查是否为假。刚才的例子正确书写后表示如下:

    if( booleanValue != FALSE) ...; 或使用 if (booleanValue) ...;

    if( booleanValue == FALSE) ...; 或使用 if (!booleanValue) ...;

    布尔表达式应该检查是否为假而不是检查是否为真

    使用整型、字符型和浮点变量

    C++中间使用整型变量通常比较直接,但是有几个典型的错误应该注意:

    ♦减一错误。一个常见的错误发生在一个整型变量做减一运算的时候,尤其是在循环中,整型变量作为循环控制变量时。一个简单的检测方法就是做一个测试,尤其是要覆盖第一个和最后一个索引值,如果这个测试通过了,那么其他的全部实例也就正确了。

    ♦除零。除法运算中用零做除数会导致除零异常。在做除法的时候,如果你不能保证除数不为0,那就要处理可能出现的异常。

    ♦溢出。整型变量大小是有限的,因此如果它们的值过大就会最终导致溢出,尽管32位的Windows程序没有16位的程序那么容易发生溢出,但是当有符号数有正的或者负的过大值,或是无符号数可能出现负数时,都需要检査是否发生了溢出。要么避免要么处理溢出,同时,用Limits.h中定义的最小值和最大值来作为整型数据类型的大小限制。

    ♦用无符号数来检査有符号数。显然,无符号数和有符4数有不同的最大值和最小值。比如说,一个无符号数永远都不会等于-1,因此,下面的语句不会像预期的那样起作用:

    if(unsignedInt == -1) ...;

    Visual C++的编译器善于在比较变量的时候发现无符号数与有符号数之间的不匹配,但是它却不能发现一个变量和一个超出界限的常景进行比较时的问题,所以不要指望编译器会对这类错误提出警告(对于不匹配的比较要十分小心,当你比较一个无符号数和一个有符号数的时候,编译器会把有符号数转换为无符号数,这可能不是你所希望的事情。当unsignedInt等于0xFFFFFFFF时,前一个语句会成真)。编译器给你警告的时候,不要急于用强制类型转换来消除警告。先想想自己要做什么,在这种情况下,一个进行强制类型转换的替代方法就是不再区分无符号数与有符号数,只用有符好数。尽管这种办法孴起来有点严历,但是当无符号数经常与有符号数混淆的时候,使用无符号数的好处就降低了。

    字符型变量的问题与整型变量的问题相同,另外还有两个考虑因素。

    ♦字符类型的数据结构是有符号的,在Visual C++里面,字符型变量是有符号的,这个意料不到的细节经常被忽略,比如说,不要指望一个字符型的变量等于255。符号扩展也会给你带来预期不到的结果。

    ♦溢出。由于字符型变量的取值范围在-128~127之间,如果你不仔细考虑的话,很容易发生溢出。下面这段代码是一个无限循环的例子:

    for(char ch=0; ch<200; ch++) array[ch] = 0

有意思的是,编译器并不报错„

    浮点型的指针变量也有相同的犯错误的可能性。

    ♦被零除。和整数一样,被0.0除也会产生问题。但是不同的是,被0除并不会导致默认的异常发生,而是赋了一个奇怪的值——“1.#INFO”。,如果你原意的话,你出可以用下面的代码来产生浮点指针异常。

    #include <float.h>

    // 看不清......

在做除法的时候,如果你不能保证除数不为0.0,那就要处理可能出现的异常。

    ♦上溢或下溢。很难想象double类型的变量会发生溢出,但是float类型的就相对容易发生上溢或下溢,如果你不能保证不发生上溢或下溢,那就要处理可能出现的异常。同样,可以用Foat.h中定义的最小值和最大值来作为浮点数据类型的大小限制。

    ♦检测浮点指针的值。浮点指针没有精确的二进制表示法,所以不要期望它们会有精确的值。因此,也不应该用比较两个浮点指针的值的方法来判断二者是否相等。比如,要位测一个变量是否为42.0,应该用下面的代码;

    #include <float.h>

    if (fabs(floatValue - 42.0) < FLT_EPSILON) ...;

这里FLT_EPSILON是浮点值的最大表示误差,而DBL_EPSILON是双浮点值的最大表示误差。

    使用指针和句柄

    C++代码中,指针以问题众多而臭名昭著,但是你可以采取一些步骤来消除其中的一部分问题。第一步是你怎么创建指针和用指针来结束一个对象。初始化一个指针的时候,要么让其指向一个有效的内存地址,要么设为0(空指针),这样才避免了指针指向了无效地址。看起来似乎很明显,但是一个常见的错误就是当指向一个对象的指针被释放了以后。重新使用这个指针时就忘了再次初始化,看下面的这段代码:

    CObject*pObject1, *pObject2, *pObject3;

    // initialize pointers to point to valid objccts

    ...

    // destroy the objcects

    delete pObject1;

    delete pObject2;

    pObject2 = 0;

    if(pObject3 == 0) delete pObject3;

    pObject3 = 0;

在这个例子里面,当pObject1指向的对象被释放了后,pObject1就指向了一个无效的内存地址。这个不稳定的指针可能会引起问题,除非是不再使用它,或者这个指针是一个数据成员并且这段代码在一个析构函数中。pObjecr2pObject3在它们所指向的对象被释放之后,都被正确地重新初始化。然而,C++语言保证delete 0是无害的,因此没必要破坏你的代码来避免删除一个空指针,就像pObject3一样。

    消除指针问题的第二步是关于怎样处理指针为空的可能性。考虑下面一段代码:

    // approach1

    if( p1->p2->p3->fn()) ...; // living dangerously

    // approach2

    try {

        if( p1->p2->p3->fn()) ...;

    }

    catch(...) {

        // handle access violation exception, but how?

    }

    // approach3

    if(p1 == 0 || p1->p2 == 0 || p1->p2->p3 == 0)

        ...; // handle error

    else if(p1->p2->p3->fn()) ...;

    第一种方法比较冒险,因为代码假设指针永远都不会为空。但是如果指针为空了,就会引起很大的问题。第二种方法具有健壮性,因为它处理了可能出现的存取异常情况。这种代码书写起来也比较简单,你不用枳心检查所有出错的情况,问题是怎样处理这类异常,尤其是你不知道究竟是哪里出错了。假设在try这个模块里面有大量的语句,都有可能发生异常,那么就很复杂了。第三种方法,虽然也很健壮,需要较多的代码,但是能准确定位出错的代码。你会选择哪种方法?在第5章“使用异常和返回值”中会详细回答这个问题。

    当停止使用一个指针的时候,最终的问题是:你能保证指针指向了一个无效的地址叫?除非你能检查指针的值(在前面的代码里面),否则你的答案就是否定的。要避免出错,就要处理这种情况。

    要避免出错,回收指针所指的对象的时候要重新初始化这个指针,并且要在指针被释放之前为空时就对其进行处理。

    句柄和指针很相似,因此它们也有相同的问题。记住释放句柄指向的对象也要重新初始化这个句柄,并且要处理句柄为空的情况。

    使用数据结构

    很多Windows中的数据结构使用的技术,可以用在你自己的数据结构中。特别是Windows总是把结构的大小作为结构中的第一个数椐成员。下面是一个典型的例子:

    struct OPENFILENAME

    {

        DWORD lStructSize; // size of structure in bytes

    };

    这样做有两个好处。第一个,这个值起到了版本标识的作用。因此,你将来可以在结构中加入新的数据成员,并且很方便区分不同的结构版本。第二个好处,这个值还可以作为结构中的一个信号,这样就很容易判断出结构是否出现了问题,比如说,你希望这个值是20,但是返回了0x4F98D638,不管其他返回值是否正确,你都可以知道结构中肯定出现错误了。你可以在调试代码的时候和把结构存入永久存储之前检査这个值。

    如果你使用这种技术的话,要保证这个值的合理性,使之能够在出现问题的时候,明显地表示出来。不幸的是,使用这种结构的Windows API函数要求这个值必须正确,否则就不能正常工作,并且没有任何声明。忘记初始化这些值是很容易的,这种毫无声息的失败使得我们需要大量时间来调试程序。

    顺便说一下,你能通过定义下面的模板来创建一个结构从而避免初始化时的错误。

    template<class T> class SizeStruct public T {

    public:

        SizeStruct() {

            memset(this, 0, sizeof(T));

            lStructSize = sizsof(T);

        }

    };

    然后使用模板来定义结构变量。如下所示:

    SizeStruct<OPENFILENAME> openFile

    现在就能保证openFile被正确初始化了。

    用引用做参数而不是指针

    C++中,你可以有效地将大的对象传递给函数,也可以传递指针或者引用。每种方法都有自己的优点。看看下面这段函数定义:

    void PointerFunction(CObject* pObject = 0);

    void ConstPointerFunction(const CObject *pObject = 0);

    void ReferenceFunction(CObject& object);

    void ConstReferenceFunction(const CObject& object);

    带指针的函数有两个好处:策一个你可以给函数传递一个空指针,就像默认参数值表示的—样。在PointerFunction这个例子里,另一个好处就是通过使用取地址运算符,可以轻易地说明参数可以被以下函数所改变:

    PointerFunction(&obiect);  // address operator suggest object will be changed

    但是如果你要使用指针,就没有这个好处了。

    PointerFunction(pObiect); 

    上面的例子揭示了依赖取地址运算符来决定一个变量是否能被修改的做法的缺陷。C++中使用的方法是查找函数原型里面是否有const属性。

    相反,引用是对象的别名,因此,它必须和有效的对象相关联。不存在空的引用和没有初始化的引用。相应地,当你在函数中收到一个引用参数时,你可以肯定这是个有效的对象。在你使用之前也没必要检査对象的有效性。从测试的角度来看,这个好处使得用引用作为参数比用指针作为参数更为健壮,虽然可能会丧失一些灵活性,因为你不能有空的引用。

    要想得到进一步的信息,请参看Scott Meyers的《More Effeaive C++>第一条。

    使用强制类型转换

    强制类型转换是C++的一个表达式。它将一种数据类型强制转换成另一种数据类型。实际强制类型转换对于编译后的代码的效果依赖于涉及到的数据类型。进行强制类型转换的时候,将会调用相应的构造函数或是转换函数来创建一个新类型中的临时对象。将一个引用转换到一个对象有相同的效果。但是,把指针从一个类型转换到另一个类型的时候,并没有改变指针,而是消除了一个编译错误(使用不正确时,还会引起新的编译错误)。这里有一些编译器不能做的强制类型转换,比如说在没有所需要的构造函数和转换函数的时候,将一个函数指针转换到数据指针或者反过来。这样的转换总是会引起编译错误。

    下面的代码说明了当你进行强制转换的时候世界会发生什么:

    class CDoubleClass {

    public:

        CDoubleClass(double data): m_Data (data){}

        operator double() const{return m_Data;}

    private:

        double m_Data;

    };

    main()

    {

        double doutoleData = 5.0;

        CDoubleClass doubleObject(6.0)

        int intData = 7;

        // cast calls constructor

        doubleObject = (CDoubleClass)doubleData;

        // cast calls conversion function

        doubleData = (double) doubleObject;

        // cast creates temp double variable

        doubleData = (double) intData/2;

    }

    尽管在理论上强制类型转换并不必要,但在实际中却是十分必要的,这一点在Windows程序中尤其重要,因为Windows程序总是会使用大量多态的数据类型。不幸的是,使用强制类型转换是一种危险的做法,因为很容易产生错误。问题就在于强制类型转换破坏了编译器进行类型检查的功能,而这正是编译器查找错误的最有效的机制。如果编译器能够做强制类型转换,它就会不提出警告就自动完成。为了保证安全性,每一个强制类型转换都需要你手工进行类型检査,从而增加了你的负担。因此,不要匆忙就使用强制类型转换,除非没有别的更好的办法。

    可能最容易发生错误的强制类型转换就是向下强制类型转换(downcast),也就是把一个指向基类的指针强制转换到指向派生类的指针。这里有一个标准的例子:

    class CBase {

    public:

        virtual ~CBase(){} //a virtual function is required for dynamic_cast

    };

    class CDerived1public CBase {

    public:

        double m_Data;

    };

    class CDerived2public CBase {

    public:

        int m_Data;

    };

    CBase * SomeFunction();

    main()

    {

        CBase* pBase = SomeFunction();

        CDerived1* p Derived = (CDerived1*)pBase;

        pDerived->m_Data = 0.0;

    }

    只要SomeFunction返回指向CDerived1对象的指针,这段代码就总是会成功运行的。但是在这段代码中,并不能保证前提总是真,当前提为假,实际上pDerived就会指向失效的内存,因为它的对象己经无效了。这样给m_Data赋值就会引起存取异常。如果在向下转换发生后不久程序就崩溃了,那么很有能是无效的向下转换出了问题。

    强制类型转换会在维护时引发问题。

    普逋的强制类型转换和向下强制类型转换尤其会给维护带来麻烦。在这个例子中,即使你现在能够证明SomeFunction总是会返回指向CDerived1类对象的指针,但是产生这个结果的环境却有可能在将来发生变化。一个小小的改动就会破坏这段代码,甚至编译器都不会给出警告。

    选择C++强制类型转换

    尽管强制类型转换容易出问题,但是使用新的C++中的强制类型转换仍然会减少出错的危险。下面是C++风格的强制类型转换的一个小结:

    static_castC风格的强制类型转换除了语法的区别外,不同的地方在于static_cast不能在指针类型与非指针类型之间进行转换,也不能消除类型中的constvolatile属性。最重要的是,它能在编译时刻就验证被转换的变量与目标类型之间是否相容(用尖括号表示)。如果类型转换不合理,就会导致编译时刻错误。由于它使用起来更加安全并且不进行运行时(runtime)的检查,因此更偏向于使用这种强制类型转换。我们并不推荐用向下强制类型转换,因为它需要进行运行时刻检査来保证安全性。

    dynamic_cast在运行时刻对强制类型转换进行检查。当指针无效时,返同0,若是无效的引用强制类型转换(记住,不存在空引用)就发生bad_cast异常。建议使用这种强制类型转换来进行向下强制类型转换。

    const_cast消除类型中的constvolatile属性。比较典型的是用来消除类型中的const属性,这种参数并末在程序中被修改,但是也没有被声明为const类型。

    reinterpret_cast能转换不相容的数据类型。特别是在指针类型和非指针类型之间进行转换。比如在把一个指针传递给多态LPARAM参数时进行的转换。

    注意dynamic_cast需要C++运行时刻类型信息(RTTI),这就需要一个vtable,也就意味着你不能在没有虚函数的时候使用它进行向下强制类型转换对象(会导致编译时刻错误)。这看上去似乎是个不幸的限制,但是如果你的类中没有任何虚函数,你也许并不需要大量的向下强制类型转换,因为这个类根本就不是一个基类。在上一个例户中,应用dynamic_cast就得到了下面这段代码:

    pDerived = dynainic_casL<CDerived1*>(pBase);

    // need to cherk--can no Ionger assume a valid pointer

    if(pDerived != 0)

        pDerived->m_Data = 5.0;

    C++风格的强制类型转换比C风格的强制类型转换更加安余、明确,也更加容易在源代码中定位。

    使用dynamic_cast使得向下“强制类型转换更加安全,但是带来了一些性能的损失。由于更加清晰地指出了你想做什么,C++风格的强制类型转换使得结果代码易于理解和维护。更加明确的代码也避兔了偶尔发生的错误强制类型转换,从而增加了安全性。比如说,你不会一不小心删除一个类型的const属性。并且,C++风格的强制类型转换看上去和C风格的强制类型转换也很不同,这种差异是故意造成的。C++风格的强制类型转换语法使其更容易在源代码中被发现,你只需要査找所有“_cast”就行了。C风格的强制类型转换看上去都很简单,但是不像C++语言,它们不能用Find in Files命令查找。尽管可搜索性一开始看起来并不重要,但是记住Find in Files命令是一个重要的调试工具,它能帮助你査找所有具有相似错误的代码段。

    C++风格的强制类型转换能消除限多错误,但是经常最好的解决办法是压根儿就不用强制类型转换。这里有一些避免使用强制类型转换的技术。

    避免使用多态数据类型。多态数据类型需要使用强制类型转换。在你使用Windows API的时候,你会经常用到多态数据类型,但是如果你用C++类库的时候会有更多的选择。

    比如说,基于模板能提供类型的安全性,但是基于指针就需要进行强制类型转换。

    PS:使用多态并没有错;发生向下转换往往是设计上的缺陷造成的,或者是历史遗留问题)

    使用更加广泛的基类C++程序通过使用基类中的虚函数从而实现了多态行为。你经常可以把类的行为从基类移至派生类来消除强制类把转换。对于特定数据类型的特殊处理(通常是通过if语句和强制类型转换共同实现的)可以利用在桩类中增加虚函数来消除(尽管这种做法有时候对于一般类并不可行,比如说MFCCObject类或是其它你不能修改的类)。注意对于特殊情况下的类型处理会损害类的可扩展性,因为创建新的派生类需要加入更多的特殊实例。

    •提供特殊的存取函数。你可以通过提供特殊的函数来消除强制类型转换,这种函数就是做必要的强制类型转换的。在MFC中,比方说,CView的派生类总是提供一个特殊的GetDocument函数,它能返回正确的CDocument派生类的对象。

    让编译器隐式处理类型转换C++能隐式处现很多类型转化。从而消除了做强制类型转换的必要。你可以提供合适的类型转换函数来帮助编译器做这种隐式类型转换(不幸的是,这种隐式类型转换有有自己的问题,这点你必须意识到。细节部分参看Scott Meyer的《More Effective C++》第五条)。比如说,下面的类型转换就不需要强制类型转换,其中类的定义同上:

    // compiler calls constructor without a cast

    doubleObject = doubleData;

    // compiler calls conversion function without cast

    doubleDta = doubleObject;

    // can assign derived to base class without a cast

    pBase = pDerived;

    另一种消除强制类型转换的方法是使用C++相关变量(covariant)。相关变量返回类型就是基类虚函数返回基类类型的时候,派生类重载了虚函数,并且返回派生类的类型。不幸的是,Visual C++编译器现在并不支持这种标准特征。因此这种方法不予考虑。

    现在,最后一个问题,如果C++风格的强制类型转换比C风格的强制类型转换好,那为什么Windows代码里面却不能经常看到呢?MFC代码中尤其少见,但是MFC代码中却经常使用向下强制类型转换,比如:

    CEdit* pEdit;

    if( (pEdit = (CEdit*)GetDlgItem(IDC_INPUTBOX)) != 0)

        pEdit->GetLine(lineNumber, lineBuffer);

    习惯的力最是一种解释。事实上,C++强制类型转换在Windows编程书中出现不多的原因并不在此(注意这样的例子很少考虑怎么样避免发生错误,调试也只是“留给读者练习”)。但是最重要的原因是在这种情况下使用dynamic_cast会失败。函数CDialog::GetDlgItem总是返回指向CWnd对象的祀指针,而不是指向派生类如类CEdit的指针。CDialog::GetDlgItem只是简单调用Windows::GetDlgItem这个API函数,返回一个一般的窗口句柄,然后把结果封装在CWnd对象中。由于运行时刻类型信息显示出返同的对象是一个CWnd类的,dynamic_cast就会在强制转化为派生类型的时候返回一个空指针。注意,不像一个普通的错误向下强制转换,当调用者不是CEdit类的对象(CStatic类的对象)的时候,调用成员函数类似于CEdit:GetLine是无害的,因为函数只是一个简单的::SendMessage wrapper,它发送的消息被安全地忽略掉了。尽管static_cast在这种情况下也工作得很好,但是没有提供类型的安全性。

    如果你在Visual C++中使用dynamic_cast,记住要在project设置里面选择Enable Run-time Type Information选项。如果没有这么做,任何你做的dynamic_cast都会引起异常。编译器回给你一个很模糊的警告:“waming C4541: 'dynamic_cast' used on polymorphic typeclass CBase' with /GR-; unpredictable behavior may result。”(警告C4541:‘dynamic_cast’用于多态类型‘dass CBase’上,可能会发生预料不到的结果)所谓的“unpredictable behavior”就是说你的程序就要崩溃了。

    如果你是用MFC,注意从CObject派生的类并不使用运行时刻类型信息,而是用它们自己的运行时刻类信息(RTCI)——虽然在MFC工程中设定了RTTI后,两者你都可以使用。采用RTCIdynamic_castMFC中的版本是一个DYNAMIC_DOWNCAST宏,它对于无效的强制类型转换返回了一个空指针,还有一个STATlC_DOWNCAST宏,它在无效强制类型转换的时候返回空指针,并且对调试版本中的错误强制类型转换显示一个断言,在发布版本里再执行这个强制类型转换。这种奇怪的权衡(tradeoff),使你要么不能拥有强健的代码,要么不能让出错的强制类型转换自己把自已显示由来。你可以用自定义的MFC强制类型转换宏来消除这种权衡,做法如下:

    #define SAFE_DOWNCASE(class_name, object) \

        (class_name *)SafeDownCast(RUN_TIME_CLASS(class_name), object)

    CObject* SafeDownCast(CRuntimeClass* pClas, CObject*pObject) {

        ASSERT(pObject != 0 && pObject->IsKindOf(pClass);

        if(pObject != 0 && p0bject-> IsKind0f(pClass))

            return pObject;

        else

            return 0;

    }

    使用const

    从某种意义上说,使用const与使用强制类型转换正好相反。当强制类型转换消除了编译器通过强大的类型检査来査找错误的能力的时候,使用const能增强这种能力。当你在写下const的时候,就是告诉了编译器:这个变量是不应该发生变化的,因此要保证它不被改变。const属性是最好的类型拓展。比如,char*const char*,相关但不是同一个类型。在程序中仔细地使用const是一种好办法,能帮助编泽器在编译时刻帮你发现错误。这远比在运行时刻发现它们要好。

    如果你在使用const方面经验丰富,你就会知道有一个重要的细节。由于你不能把const类型的变量传递给非const类型的参数,那么如果你的程序没有正确使用const(也就是说没有在所有不能发生改变的变量前面声明为const类型)时,你引入了一个const变量,后果会波及整个程序。你就会不得不改变很多函数的定义,并且在你的类里增如很多const操作符。使用const就是一个不动则已,一动就动全身的问题。这个问题可以通过从开始就正确使用const,并且始终严格遵守这个规则来彻底消除。在程序写成后再加入const实在是个很头疼的问题。

    正确使用循环语句

    任何人都知道什么是for语句,什么是while语句,以及它们之间的区别。尽管如此,还是能够经常发现在应该使用for语句的时候使用了while语句,问题就在于虽然

    £or(int i=0; i<cout; i++) {

        ...

    }

   

    int i = 0;

    while (i < count) {

        ...

        i++;

    }

在计算上面是相同的,但是如果在语句后面在加一句continue后,这种相等关系就会被破坏。因为for语句能够保证加一操作可以被执行,但是while语句就不能了。因此,在while循环后面加上continue语句是一件很危险的事情,因为人们总是忘记执行变量增加的操作。当continue语句很少能够被执行到的时候,这种错误就不容易被检查出来了。依我的经验,while语句经常出错的地方一般是在初始化、检测,或者变量增加操作很复杂,或是增加操作根本就不是什么加一运算符,而是类似于GetNextObject这样的函数。在这些场合下,都可以用for语句来代替while语句。

    这个原则在当for语句中使用多个需要增加的变量的时候也同样适用。例如下面的代码:

    POSITION pos;

    int line;

    for(line=0,pos=lineList.GetHeadPosition();

       pos != 0;

       line++, lineList.GetNext(pos)) {

       ...

    }

    使用构造函数和析构函数

    当给一个对象分配了内存后,调用构造函数就能使对象到达一个良定义的状态(well-defined state)。相似地,调用析构函数正好和构造函数做的相反。一般是在分配给对象的内存被解除分配前,释放任何占用的资源。两种运算如果使用不当的时候邡会引起错误;

    构造函数

    做到以下几点后你就可以在构造函数中避免发生:错误:

    •合并相似的构造函数代码。

    •正确处理出错的构造函数。

    •理解在构造函数中虚函数是怎么工作的。

    一个类通常会有好几个版本的构造函数,比如说默认的(就是没有任何参数)构造函数、拷贝构造函数以及其它有各种各样参数的构造函数。由于这些构造函数的代码都很相似,你可以通过调用一个保护的或者私有的辅助函数来完成绝大部分的工作,这样就能够避免复制以及潜在的维护方面的问题。比如说,MFC CString类有七个构造函数,这些构造函数都调用了保护的CString::Init的数。

    一个更困难的问题是处理可能出错的构造函数。通常,构造函数需要分配内存,创建资源或者打开文件,但是不能保证这些运算都能成功。并且,构造函数没有返回值,因此也没有直接的方法来显示出现了错误。一个常见的解決方法(在很多MFC类中使用)就是把对象创建分为两步:笫一步,让构造函数以一种不会出错的方式初始化对象;第二步是让某些初始化函数(比如Init或者Open)完成工作,这一步可能出错。比如说,MFC中的CFile默认构造函数以是简单地把文件句柄设为0,并且把状态布尔变量设为falseCFiIe::Open函数真正打开文件,但是可能由错,

    另一种方法是使用异常,并且分二个阶段进行初始化过程,这些都是在构造函数中完成的。第一阶段,以不会出错的方式初始化对象到达一个已知的状态。第二阶段,用可能在try段内出错的代码初始化对象。最后一阶段,就是在catch代码里面处理异常。如果出现异常,就会在构造函数里消除分配的资源,并且再次抛出并常。注意,只有在成功构造对象后才能调用析构函数。因此,不要指望在构造函数抛出异常的时候用构函数来清除对象。下面的代码是一个典型的采用异常处理的构造函数代码:

    ......//看不清楚

    另一种方法是使用C++ auto_ptr智能指针模板类或是那些依赖于C++语言来清除对象的手段,而不是程序员的自我约束。这种方法在第9章“内存调试”中讨论,所有这些构造函数都很有效,你可以选择最适合你的一种。但是,如果你的构造函数在程序里可能会出问题,那么最好写得让它易于理解。

    要知道构造函数中的虚函数并不像一般的虚函数,也就是说,如果基类的构造函数调用了一个虚函数,调用的实际是虚函数的基类版本,而不是重载后的派生类版本。否则,如果基类调用了派生类版本的虚函数,就会引起存取异常,因为此时还没有构造基类的数据成员。如果你的构造函数真的需要虚函数,那么就使用单独的个初始化函数好了。

    析构函数

    相似地,你可以通过做到下面的几点来避免在析构函数里出错。

    •正确处理可能出错的析构函数。

    •使基类的析构函数采用虚函数。

    •理解在析构函数中虚函数是怎么工作的。

    异常处理中一个关键的细节就是在栈展开的过程中抛出的异常会终止整个程序。由于在处理异常的时候经常要调用析构函数,因此析构函数尤其容易犯这个错误。如果在处理异常的时候调用某个析构函数,并且这个折构函数中出现的异常没有在这个析构函数中得到处理,程序就会被终止。因此,一定要保证析构函数的异常在析构函数中得到处理。就像下面这段代码中写的那样:

    CMyObject::~ CMyObject() {

        // do safe destruction first

        delete m_pResorce1;

        delete m_pResorce2;

        // now do descrution that can fail

        try {

            FunctionThatCanThrowException();

        }

        catch(...) {}

    }

    注意,你除了catch外,并不需要做其他的工作。关于这个方面的进一步信息,请参看Scott Meyer的《More Effective C++》第11条。

    最后,要保证基类的析构函数是虚函数。这样,就算对象是个指向基类的指针,也会调用派生类的析构函数。否则,如果基类的析构函数不是虚函数,就会引起资源泄漏。在析构函数中,虚函数与构造函数里的虚函数不一样。

    使用拷贝构造函数和赋值运算符

    如果一个基类的构造函数分配了资源,这个类就需要一个虚的析构函数另。一个不太明显的事实就是这个类还需要一个拷贝构造函数和赋值运算符。Marshall Cline把这一条列为“大三法则”(Big Three),即“如果一个类需耍一个析构函数,或者一个拷以构造函数,或者一个复制运算符,那么它就三个都需要”。这条规则的道理显而易见,如果类里面没有提供拷贝构造函数和赋值运算符,C++编译器会自动加上。一旦构造函数分配了资源,编译器对这些函数的实现就肯定会出错。虚析构函数就是一个明显的标志。如果你不希望类中出现这些函数,你可以在类中定义它们为private,并且不予实现,从而避免编译器自动加入。这样,任何对这些函数的使用都会在链接的时候报错。

    关于这个方面的进一步信息,参看Marshall Cline,Greg LomowMike Girou的《The Big Three, in C++ FAQs》的第30章。

2.4 Visual C++编译器

    把错误从程序中消除的最好办法就是让编译器帮你把它们找出来。任何方法都没有这个重要。这一个部分将为你提供几种技术来提高编译器查错的能力。

    尽量釆用编译时刻检查而不是运行时刻检查

    总是使用/W4警告级别

    这一章的很多编程问题都能影响编译器査找错误的能力。比如说,编程风格就是一个因素。考虑下面的语句:

    if (x = 2) ...;

    Visual C++高高兴兴地编译这条语句,不给你任何瞥告。这条语句的问题就在于相等运算符==容易与赋值运算符=混淆。如果不知道程序上下文,最好假设程序员实际意思是

    if (x == 2) ...;

    要避免这个问题,很多程序员都采用下面的风格:

    if (2 == x) ...;

    这种看起来笨拙的风格的好处在于:一于常数是没有左值的,它们不能被赋值。下面的代码就会引起“error C2106'=': left operand must be l_value”编译错误。

    if (2 = x) ...;

    当然,如果左边的操作数不是常量,这种风格就没有作用了。你可能会考虑使用这种风格,但是我推荐另一祌更加优秀的解决方法。这就是使用最高的编译警告级别(/W4)而不是默认的警有级别(/W3)。如果你用/W4,那么语句

    if (x = 2) ...;

    就会导致"warning C4706: assignmen within conditional expression"(警告 C4706:在条作表达式中出现了赋值)警告。如果你真的想写这样的语句,你可以改写成下面这样来避免警告:

    if ((x = 2) != 0) ...;

    当两个操作数都是变量的时候你也会收到警告:

    if (x = y) ...;

    /W4警告级别能给你下面这些/W3警告级别不能给你的警告:

    warning C4100'id': unreferenced formal parameter (警告C4100:未被引用的形参)

    waming C4127conditional expression is constantC (警告C4127:常量条件表达式)

    warning C4189'id'local variable is initialized but not referenced(警告C4189id:局部变量已初始化,但未被引用)

    warning C4245'conversion'conversion from 'type1' to 'type2', signed/unsigned mismatch(警告C4245'convcrsion':从‘type1''type2'的转换,有符号/无符号不匹配)

    warning C4701local variable 'name' maybe used without having being initialized(警告C4701:局部变量'name'可能没有初始化就被使用了)

    waming C4705statement has no effect(警告C4705:语句没舍效果)

    waming C4706: assignment within conditional expression(警告C4706:在条件表达式中出现了赋值)

    waming C4710'function'function not inlined(警告 C4710: 'function':函数没有内联)

    因此,使用/W4警告级别是个好办法。

    在调试版本里总是使用/GZ编译选项

    Visiml C++6.0中介绍了/GZ编译选项,它是用来帮助发现那些在发布版本里才发现的错误,这些错误在调试版本里都无法发现。这个编译选项的作用如下:

    •用0XCC模式初始化自动(局部)变量

    •在通过函数指针调用函数时,检查栈指针确认是否有调用规则不匹配。

    •在函数最后检查栈指针是否被改变

    在笫9章“内存调试”中将进一步讨论/GZ编译选项。

    抑制假的警告消息

    不幸的是,使用/W4层的警告有一个缺点。和那些有用的鳘告一起,你还会收到大量的假警告。很多标准Windows头文件因为使用非标准语言特征导致/W4警告。某些类型的程序,比如说使用标准模板库(STL)的程序,在采用/W4警告时尤其容易导致假警告。不停地筛选这些消息是一件浪费时间的事情,并且这些假警告使真正有用的警告不易被发现。由于你使用最高层的警告来发现代码中的错误,而不是处理那些不重要的细节或是标准Windows头文件(这个你永远都不要随便改变)中问题,因此你需要消除某些警告。如果你使用MFC,那么Afx.h头文件能帮你解决大部分问题。

    第一个处理的警告消息是“warning C4100'id': unreferenced formal parameter(警告C4100:未被引用的形参)。在Witidows程序中,不用到所有的函数参数是一件很普通的事情。比如:

    void CMyView::OnMouseMove(UINT nFlags, CPoint point)

    {

        //nFlags is unused, resulting in a/W4 warning

        ...

    }

    解决这个问题的一个做法是从函数声明中删除参数名字,这样就能减少警告。例如:

    void CMyView::OnMouseMove(UINT, CPoint point)

    void CMyView::OnMouseMove(UINT/* nFlags*/, CPoint point)

    第一个版本只是简单把参数删除了,这使代码难于理解;第二个版本稍好,但是看起来比较笨拙。MFC提供了一种我认为比较好的办法(这祌方法你也可以很容易地使用到非MFC程序中)——宏,如下:

    #ifdef _DEBUG

    #define UNUSED(x)

    #else

    #define UNUSED(x) x

    #endif

    #define UNUSED_ALWAYS(x) x

    当某个变量在发布版本中没有被使用时,就会用到UNUSED宏:如果这个变量压根就没有使用过时,就用UNUSED_ALWAYS宏。这些宏“接触”(touch)到这些变量,使编译器以为它们被使用过了。正如下面的例子:

    void CMyView::OnMouseMove(UINT nFlags, CPoint point)

    {

        //suppresses "unreferenced formal parameter"

        UNUSED_ALWAYS(nFlags);

        ...

    }

    #pragma warning编译器指示

    你可以使用#pragma warning编译器指示来禁止整个程序、特定的头文件、特定的代码文件或是特定的某一行代码的特定警告,这就看你把#pragma指示放在什么地方了。这个指示非常灵活,你可能需要参看Visual C++文档来了解使用这个指示的多种途径。比如说,你可以通过在头文件stdafx.h中间加入#pragma来减少整个工程(project)中的警告。

    #pragma warning(disable: 4511) // private copy constructors are OK

你也可以用下面的技术为某一行代码消除警告:

    "unreachable code caused by optimizacions

    #pragma warning(disable: 4702)

    ...

    #pragma warning(default: 4702) // restore the warning

    最后一个例子。如果你采用了一个没有明确使用/W4的库文件,用下面的方式,你可以临时用/W3对其进行编译:

    #pragma warning(push, 3) // temporarily revert to w3

    #include"Boqur.h" // doesn't cleanly compile using w4

    #pragma warning(pop) // revert back to previous settings

    由于这祌#pragma指示不是非常一目了然,最好你能给出注释,解释一下这个#pragma是干什么的以及为什么这样使用。就像在上面这个例户中一样。

    “没有警告的编译”法则

    很多软件开发小组采用一种“没有警告的编译”(compile without waming)法则,即只有在编译时不出现警告,代码才算是可以掊受的(不存在“没有错误的编译”法则,因为有错误的代码根本就不可能被编译通过)。当然,这条法则只有在假警告被消除了以后才能应用。要求消除所有警告有两点好处;它强迫你检査所有的编译警告,当代码发生改动后引入的警告则很明显。这条法则很有意义,但是你必须意识到两个问题:

    1.消除编译错误和消除代码中的问题并不是完全相同的。

    2.过早地消除合理的编译警告并不是一件好事

    第一个问题是说,通过抑制住警告而不是发现代码中的潜在问题来消除编译警告是一件简单的事情。指针指向了不相容的数据类型?只要再进行一下强制类型转换就可以了。出现了sign/unsign的不匹配?同样,进行一次强制类型转换就行了。强制类型转换是编译警告的排泄管道,你可以通过它解决一切问题。尽管强制类型转换在很多场合下都是正确的,但是你在做强制类型转换之前一定要仔细分析出现问题的特定代码。自动进行强制类型转换能够抑制编译警告,但是有可能掩盖住了重要的问题所在。

    在消除编译警告之前要对它们进行仔细检查

    第二个问题是说,编译警告有可能是合理的。比如说,你接到“warning C4100'id': unreferenced formal parameter(警告C4100:未被引用的形参)或者“warning C4101'id': unreferenced local variable(警告C4101'id':未被引用的局部变量),只是因为你没有把代码写完。如果立即为了抑制警告而修改代码就破坏了警告的价值。

    “没有警告的编译”法则的一个根本问题是,编译警告能帮助你发现错误,因此不要轻易地清除警告。处理编译警告的核心是要发现问题,而不是抑制警告本身。当然,如果你做得到的话,应该立即解决代码中的问题或是抑制没有用的警告,但是如果警告能够发现一个合法的问题,并且需要你在将来解决的话,那就让那个警告留着好了。因此,我并不推荐使用/WX编译选项(它把所有的警告都当成错误来对待),因为它会强迫你不考虑具体情况就抑制所有警告。

    编译警告是一个好东西,在代码中保留一些编译警告比保留错误要好

    最后的一点就是“没有警告的编译”法则对于大的程序开发小组来说很有帮助。但是前提是允许出现异常。最终目标是消除错误,而不是消除警告。

2.5推荐阅读

    Cargill, TomC++ Programming Style, Reading, MA:Addision-Wesley,1992.

    通过检查某些需要改进的程序,Caigill得出了一些C++风格的指导方针。这里的“风格”不是书写代码的风格,而是对语言的最根本的特征的使用风格,比如说,抽象、继承、虚函数以及操作符重载。

    Cline Marshall, Greg Lomow, and Mike Girow.C++ FAQs, 2nd edition. Reading, MA:Addison-Wesley, 1999(PS:本书确实不错,搞管理的人也可以看看。可惜本书没有中文版)

    这本书回答了很多针对C++程序设计和实现方面的问题。这种FAQ方式便于你快速地获取信息。第9章“Error handling strategies”、第10章“Testing Strategies”、第14章“Const Correctness”、第30章“The Big Three”以及第32章“Wild Pointers and Other Devilish Errors”都是与我们这一章密切相关的。

    Kernighan, BrianW., and Rob Pike. The Practice of Programming. Reading, MA:Addison-WesIey, 1999

    1章“Style1,把《The Elements of Programming Style》一书中最重要的思想总结到一章中,并且把它们应用到C/C++中。

    Kernighan, BrianW., and P.J. Plauger, The Elements of Programmng Style, 2nd edition. NewYorkMcGraw-HilL, 1978

    尽管这本书历史悠久,但是关于最基本的编程风格的分析对现代的读者仍然有价值。当然,类似于“Avoid the Fortran arithmetic IF”和“Use statement labels that mean something”这样的小窍门已经没用了。但是我认为多余三分之二的技巧仍然是有用的,尤其是在避免出错这个方面。那些对于为了提高效率的短视行为的警告尤其有竞思。所有的例子都是用FortranPL/1写的。

    McConnell, Steve, Code Complete: A Practical Handbook of Software Construction. Redmond. WA: Microsoft Press, 1996

    这本书涵盖了与编程相关的所有方面,并且全面、细致地分析了编程风格,这是其他任何书都无法比拟的。主题包括函数设计、数据设计、变量命名、基本数据类型的使用、代码组织、条件语句和循环语句的使用、程序结构、程序复杂性、布局和风格以及文档。注意例子都是用C或者Pascal书写的,并没有包括C++

    Meyers, Scott. Effective C++, Second Edition50 Specific Ways to Improve Your Programs and Designs. Reading, MA:Addison-Wesiey, 1998,

    学习怎么正确使用C++编程的最终资源,主要讨论一些基本问题。精华读物。

    Meyers, Scott, More Effective C++: 35 New Ways to Improve Your Programs and Designs. Reading: MA:Addison-Wesley, 1996

    这本书补充了《Effective C++》一书的内容,着重介绍高级C++技术。
原创粉丝点击