65.9K
CodeProject 正在变化。 阅读更多。
Home

防御性编程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (41投票s)

2004年8月6日

CPOL

15分钟阅读

viewsIcon

231042

关于如何编写防御性软件的一些想法

引言

几个月前,我写了一篇关于在C++编程开发中使用ASSERT宏[^]的文章,特别是针对MFC。将这样一篇文章投入CodeProject这个大染缸自然会引发许多关于ASSERT只是一半问题的评论。实际上,我认为ASSERT连一半都算不上,这篇文章是我尝试提出一些其他在软件交付给那些付钱给我们的人之前捕获编程错误的技术。

我假设你已经阅读了我关于ASSERT的上一篇文章,并且我不需要再次赘述。如果你还没有读过,上面就有链接。

尽管如此,简单回顾一下,ASSERT允许你,作为程序员,在调试版本中预先声明,在程序执行继续之前必须满足某些条件。如果这些条件不满足,程序将中止,并且在安装了调试器的机器上,将在失败的ASSERT测试处中断进入调试器。

ASSERT的问题显而易见。ASSERT只存在于你软件的调试版本中,并且其“中断进入调试器”的行为只在安装了调试器的机器上才有效。我们不能(也不应该)假设我们的用户安装了调试器。而在没有程序数据库文件的机器上中断进入调试器有什么用?我为一些公司工作过,没有一家公司分配资源给QA测试人员在调试版本上进行测试。QA测试似乎只在发布版本上进行,所以我们需要一些在发布版本和调试版本上都有效的捕获错误的技术。

范围

在此说明一点。我不会展示能够神奇地使你的程序正确的代码片段。我也不会尝试讨论“正确性”的绝对意义。正确性可以有很多定义,大多数超出了我的意图范围。我关心的是以一种避免崩溃和避免“不正确”的粗略罪过的方式编写代码。我还应该明确说明,接下来的内容是我基于大约十几年C和C++代码编写经验的个人观点。有些人会觉得我过分强调某项技术,或者低估了另一项技术。但对我来说,这是有效的。

我还想说明,我几乎不记得上一次使用非微软编译器是什么时候了。那是在我使用Microsoft C版本2(它是Lattice C的再打包版)的时候。所以显然我在这里的所有评论都与我在Microsoft C/C++上的经验有关。我也没有打算迁移到编译器X,所以请不要在评论区浪费时间进行关于编译器X的宣传!

编写正确的代码需要时间和精力

我怎么强调都不为过。如果你想编写正确的代码,你必须准备好花时间提前规划,思考你要写的东西,并思考如何避免问题。你必须知道你的代码可能在哪里失败,并准备好处理这些失败。

消除程序错误和崩溃的最重要方法

是不要使用指针。C#和Visual Basic等语言不支持指针是有原因的,原因在于指针可以指向任何东西。重要的是要记住,现代操作系统强制执行内存所有权的概念。你的进程拥有一些内存页面,而不是整个地址空间。如果,意外地,一个指针碰巧指向了你的进程不拥有的内存地址,而你试图读写该内存,处理器将抛出一个异常,操作系统可能会终止你的进程。这将赢得你用户的永恒憎恨。

唉,我最喜欢的语言是C++,所以我无法避免指针。该怎么办?你可以做两件事。第一件事是,每当你从某个地方得到一个指针时,**都要测试它**。确保它不是NULL。确保如果你要通过指针写入,在尝试写入**之前**确保它是可写的。确保如果你要从指针读取,在尝试读取**之前**确保它是可读的。如果你的代码返回一个指针,请务必确保你返回的指针与你的代码承诺的一致。如果,出于任何原因,你无法返回一个有效指针,则返回一个NULL指针,并希望你的调用者检查NULL

合同

这给了我们一个关于契约的问题。如果你做过任何COM编程,你应该熟悉编程契约的概念。COM接口被定义、编码和发布,此时“契约”就定义好了。如果我实现了X类型的COM接口,我的接口*必须*实现X COM接口中定义的某些功能。如果我从某个地方请求Y类型的接口,我可以预期它*将会*实现某些功能。

但为什么COM接口要被特殊对待呢?任何API都定义了一个“契约”,并且应该遵守该契约。如果我调用CreateWindow() API,我期望得到两种结果之一:要么是一个有效的窗口句柄,要么是一个错误代码,告诉我无法创建窗口的原因。作为API的调用者,我需要检查它是否成功;作为API的实现者,我需要定义契约,遵守它,并在无法实现时返回准确的错误代码。

当然,如果我定义了一系列API,这也可以扩展。假设我正在编写一个创建和操作窗口的整个子系统。我期望你调用CreateWindow()来创建一个窗口,我将返回一个代表该窗口的对象句柄。如果由于某种原因我无法创建窗口,我会返回一个错误代码。稍后,当你以某种方式操作该窗口时,你会将句柄传递给我,期望我做正确的事情。在我看来,正确的事情是验证你传递给我的句柄,以确保它*确实*是一个句柄。是我的API创建的吗?它代表的是有效内存吗?它代表的东西通过了内部一致性检查吗?如果它包含指向其他东西的指针,那些指针是否指向有效的东西?如果传递给我的API的东西不是句柄,或者你没有权限操作它,或者它在内部不一致,我应该做的正确的事情是*不*执行操作,而是返回一个说明原因的错误代码。

刚才我谈到了创建窗口,我们很清楚窗口是由我们的宿主操作系统创建的。这是一个简单的例子。关键在于,任何时候你编写包含一个以上函数的软件,你都在定义自己的API。这个API可能永远不会发布给更广泛的世界,它可能永远不会在程序本身之外使用,但它仍然是一个API,并且需要定义它与你其余代码的契约并遵守该契约。同样,你的代码需要意识到契约并执行适当的测试。

让编译器来工作

C++是一种强类型语言。在C++中,你不能定义一个接受char指针并传入一个int指针的函数,除非你准备做以下两件事之一。要么在编译时关闭错误检查,要么将指针转换为正确的类型。如果你准备关闭编译器的错误检查,请立即点击浏览器的后退按钮,阅读其他文章。

还在读?很好!我们稍后会回到类型转换。

我总是覆盖默认的编译器设置,并在我的项目中指定最高的*警告级别*和*将警告视为错误*。编译器会进行大量的连贯性检查,并且比我更准确地记住它所看到的内容。它会记住我已经定义了SomeFunc(int value)来接受一个int,并且会注意到我犯了个错误并用unsigned short调用SomeFunc()。总之(双关语),它会注意到我没有遵守契约并抛出一个错误消息。看到错误消息后,我就知道我传递了错误的数据类型给函数,现在需要我检查调用代码和被调用代码,并协调差异。 

满足编译器可能很麻烦。你沉浸在编码的狂热中,进入了状态。你按下编译按钮,却出现了一百个警告,却没有可执行文件。但是,每一个警告都可能是一个bug!每一个你当时可以修复的潜在bug,都会少一个在发布软件时逃逸出去的bug。而且,现在修复这个bug比软件发布后修复要便宜得多。

类型转换

有时你就是无法避免类型转换。但我认为,那些时候是你与你无法控制的代码进行交互的时候。最好的例子是Windows消息处理程序。你得到一个窗口句柄,一个消息标识符,一个WPARAM和一个LPARAM。我们先忽略16/32位问题,只考虑WPARAMLPARAM的值。它们可以代表任何你喜欢的数据类型,只要该数据类型适合为该参数定义的sizeof

因此,一个LPARAM可能是一个int,或者可能是NULL,或者可能是一个指向某些数据的指针。你的消息处理程序必须“知道”该类型是什么,并且该类型总是取决于你正在处理的消息。在这些情况下,如果你坚持我前面建议的让编译器工作的原则,那么类型转换是强制性的。

但是,如果你调用的是你自己写的代码,并且需要进行类型转换,我建议你需要非常仔细地检查你的代码。要么调用代码不理解你API中的编程契约,要么被调用的代码的实现方式与你想象的不一样。我写了数千次类型转换,但在调用自己编写的其他代码时,我不得不写类型转换的唯一一次就是当调用是间接的时,比如将线程过程的void参数转换回我自己的数据类型。

使用const

使用const不会神奇地让你的软件更可靠。但它*会*让你更仔细地思考你在做什么。确保const的正确性需要时间,并且一开始会有很多哀嚎和咬牙切齿。说实话,我最近才开始密切关注const的正确性。但我发现,所需的时间在减少意外方面得到了回报。而且*确实*随着练习会变得更容易。养成将参数声明为const的习惯很容易。我们现在每次这样做,每次我们将参数声明为LPCTSTR类型时。

更重要的是,可以将成员函数指定为const。这意味着你在函数中不能更改任何成员数据,除非该成员数据被标记为mutable。好处是你知道调用一个标记为const的成员函数不会有任何副作用。好吧,这几乎是真的。调用一个标记为const的成员函数意味着你不能更改该对象实例的任何非可变数据成员。这并不意味着它不能更改全局变量或调用可能产生意外副作用的操作系统API。

使用枚举

在另一篇文章[^]中,我讨论了在适当的时候使用enums作为“魔法”值。有些人误解了这篇文章,认为它应该是一篇关于面向对象设计的论文。所以我将重申我试图表达的观点。

enum应该在任何有离散的、可能不相关的数值集合时使用。枚举本身定义了可能值的列表,以及代表这些值集合的“伞形”数据类型。例如,

enum verycontrivedexample
{
    Bob,
    Rob,
    Chris,
    Roger
};
这定义了一个名为verycontrivedexample的数据类型和一组值。现在假设我们写一个函数,像这样,
void MyFunction(verycontrivedexample data)
{
    //  do something with the data passed...

}
我们调用这个函数并期望编译器实际编译它的唯一方法是传递verycontrivedexample enum中定义的一个值。我可以给这个函数传递一个Bob或一个Rob,但我不能传递42并期望它编译。还记得“让编译器来工作”吗?如果你将你的“魔法”值定义为enum,并使用enum标签作为数据类型,那么当你试图传递一个不在enum中的值时,编译器会报错。

const一样,这需要一些时间来适应,但回报是值得的。

缓冲区检查

Sasser、Blaster、Nimda、Code Red,利用缓冲区溢出的病毒列表还在继续。虽然我们的应用程序不太可能像操作系统那样成为病毒的目标,但确保我们不滥用我们的环境仍然很重要。例如,我可能会写这段代码

void MyFunc(LPCTSTR szString)
{
    TCHAR buffer[10];

    _tcscpy(buffer, szString);
}
        
这*可能*是无害的。在我设计程序时,我“知道”传递给MyFunc的字符串长度永远不会超过9个字节。谁知道呢,也许在我的程序生命周期中情况确实如此。但更安全的方法是这样写:

#define countof(x) (sizeof(x) / sizeof(x[0]))   // Defined in some global 

                                                // header


void MyFunc(LPCTSTR szString)
{
    TCHAR buffer[10];

    memset(buffer, 0, sizeof(buffer));
    _tcsncpy(buffer, szString, countof(buffer) - 1);
}
        

这会清空缓冲区,然后最多复制9个字节(在此示例中)到缓冲区。请注意,这两个步骤都是必需的。第一步是清空缓冲区。我这样做是因为我使用了TCHAR,这意味着这是一个字符串,很可能会传递给其他期望字符串语义的API。通过清空缓冲区,我确保即使LPCTSTR参数指向的字符串比缓冲区短,也能遵守字符串语义。第二步是实际复制。它最多复制countof(buffer) - 1个字符到缓冲区。这确保我不会将比缓冲区能容纳的更多内容复制进去。-1部分也很重要。这确保了缓冲区中的最后一个字符是零,从而确保了字符串语义。请注意,我使用了countof()宏,它会考虑这是一个ANSI/MBCS还是UNICODE构建。(感谢Michael Dunn的提示)。

检查函数返回值以防错误

我们都犯过这个错误。你调用了一个函数,却没有检查返回值是否为错误,有多少次了?我曾犯过无数次。但随着经验的增加,这种错误会越来越少。在大多数情况下,如果一个函数可能会失败,它会返回一个错误指示符。你的代码不应该检查那个错误指示符,并在检测到错误时尝试优雅地失败吗?一个简单的例子是尝试写入一个只读文件。假设写入文件对你的程序的功能很重要,所以如果写入失败,后续的代码不应该简单地假设写入成功,特别是如果它涉及到丢弃状态。由你来决定对错误情况应该做什么——但无论你做什么,都应该优雅地降级。在尝试写入只读文件的例子中,一种方法可能是通知用户该文件是只读的,并给他们一个将其更改为可写的选项。另一种方法可能是警告用户写入失败是因为文件是只读的,并给他们一个保存到另一个文件名的选项。具体怎么做取决于你的用户——但重要的是要告知用户出现了问题,重要的是你的代码知道并处理这个错误。

记录你做了什么,以及为什么这样做!

在本文开头,我曾说过,确保软件正确行为的最重要的事情是避免使用指针。我撒谎了。实际上,确保正确行为的最重要的事情是*记录*其行为。还记得契约吗?契约的好坏取决于它记录的媒介。如果你半年后需要重新阅读源代码来弄清楚一些晦涩的副作用,你就会错过那个副作用,你的软件质量就会受到影响。副作用应该被记录下来,最好是在源代码本身中。

永远不要假设你的用户不会做某事!

他们会的。 periodo。 如果我每看到一次以下场景就有一美元,我将拥有大约20美元。

用户:当我按下Ctrl+Alt+Print Screen+Pause时,程序就崩溃了。

程序员:那他妈的你为什么要按那些键?

我的问题?那他妈的为什么不行?用户按下组合键导致软件崩溃,*不是*用户的错。再读一遍。用户按下组合键导致软件崩溃,*不是*用户的错。任何时候你写的代码都假设你的用户对你软件的了解和你一样多,你就是在通往灾难的道路上。他们不是。而且,更重要的是,他们*不想*!如果你甚至费心读了这篇文章这么远,说明你关心代码,而且,我希望,表明你关心你的最终用户体验。

结论

我在这里分享了一些我在20多年专业软件开发中遇到的陷阱和要注意的地方。这绝不是一个让你的软件坚不可摧的详尽列表。不存在这样的列表。但正如我在范围中所说的,这些是我认为有效的方法!

历史

2004年8月5日 - 初始版本。

© . All rights reserved.