VC6下使用STL注意:不要让内存分配失败导致您的旧版STL 应用程序崩溃

大多数 C++ 开发人员在他们的代码中都广泛使用了标准模块库 (STL)。如果您是其中的一员,并且正在直接使用即装即用的 STL 和 Visual C++ 6.0,则在内存不足的条件下,您的应用程序就处于崩溃的高度危险的状况下。产生此问题的原因是,检查运算符 new 是否失败是一种非常少见的做法。更糟糕的是,当 new 确实失败时,响应不是标准的。有些语言编译器返回 NULL,而其他语言则引发异常。

另外,如果您正在 MFC 项目中使用 STL,要注意 MFC 有其自己的规则集。本文将讨论这些问题,说明如何更改 Visual C++ .NET 2003 中的默认行为,并概述了如果使用 Visual C++ 6.0 所必须进行的更改,这样当运算符 new 失败时,您就可以安全地使用 STL 了。

有多少开发人员检查运算符 new 是否失败?有必要总是检查失败吗?我见过大型、复杂的用 Visual C++® 6.0 编写的 C++ 项目,其中在整个代码基中没有一项检查查看 new 是否返回 NULL。注意,我说的是检查 new 是否返回 NULL。在所有版本的 Visual C++(一直到版本 6.0)中,运算符 new 失败时的默认行为都是返回 NULL,而不是引发一个异常。(有关更多信息,请参见知识库文章 167733,但不要实现文中给出的解决方案。在本文后面我将解释为什么不应该实现解决方案)。

Visual C++ .NET 的默认行为已经更改,包括版本 7.0 (Visual C++ .NET 2002) 和 7.1 (Visual C++ .NET 2003),当运算符 new 失败时,该行为会引发一个异常。虽然 Microsoft® .NET Framework 下的这种新行为遵循该 C++ 标准并深受欢迎,但需要注意,它可中断所有移植过来的 Visual C++ 6.0 样式代码的运行时行为,而这些代码不希望运算符 new 引发异常。如果您正在用 Visual C++ .NET 进行开发,您会发现这里产生的问题已经被解决。如果您还未使用某一种版本的 .NET Framework,本文将探究运算符 new 返回 NULL 时的隐含与不兼容等严重问题,这些问题适用于所有版本的 Visual C++ 编译器,包括 6.0 版本以及更高的版本。

背景

当 Microsoft 发布第一版的 Visual C++ 编译器时,其主要作用是支持 MFC 框架。对于所有实际应用来说,Visual C++ 和 MFC 被看作是一种产品。多年来,MFC 和 Visual C++ 编译器都已经成熟。同时,Visual C++ 编译器已经成为拥有其自己权利的产品,不必依赖于 MFC 并支持其他技术,如活动模板库 (ATL)、标准模板库 (STL),以及其他多种技术。现在,MFC 只是 Visual C++ 编译器支持的多种库的一种。因此,现在使用不带 MFC 的 Visual C++ 开发项目的情况是非常普遍的。

我是在发现运算符 new 失败后,我的 STL 代码会出现异常行为时才开始撰写这篇文章的。令我惊讶的是,我发现运算符 new 失败时,Visual C++ 6.0(以及支持 STL 的所有以前版本)与 STL 不兼容。我正在进行的项目没使用 MFC,所以我的观察仅基于非 MFC 代码的情况。当我开始研究基于 MFC 的示例时,我发现 MFC 定义了运算符 new 的很多不同行为。钻研本篇文章之前,我想小结一下内存分配失败时运算符 new 的行为。为了更好地进行比较,我将讲述 Visual C++ .NET 下的行为,因为它与以前的版本不同。

C++ 标准声明运算符 new 在失败时应引发异常。具体地说,引发的异常应该是 std::bad alloc。这是标准行为,但 Visual C++ 6.0 中的行为取决于您如何使用它以及使用什么样的版本。图 1 显示了内存分配失败时运算符 new 的 Visual C++ 行为。

可以看到,只有 Visual C++ .NET 中的非 MFC 代码才遵循该标准。如果您使用 MFC,那么 new 将引发一个异常,但其类型不正确。如果您使用的 STL 的实现包含 catch (std::bad alloc) 语句,用该语句来处理内存失败的情况,那么起作用的即装即用的唯一组合方式就是无 MFC 的 Visual C++ .NET。Visual C++ 6.0 随附的 STL 实现使用 catch(...) 来处理运算符 new 失败,因此如果您使用 MFC,当运算符 new 失败时,Visual C++ 6.0 随附的 STL 实现将正确操作。

假定 MFC 提供的运算符 new 实现引发异常 (CMemoryException),并且在 Visual C++ .NET 中非 MFC 的运算符 new 也引发异常 (std::bad::alloc),那么我认为在所有实际应用中,这些情况将不会产生问题。那么,在基于 Visual C++ 6.0 的项目中不使用 MFC 而使用 STL,这种常见方案又会怎样?这是本文剩余部分将讨论的重点。

运算符 New 返回 NULL

回到本文开始部分的问题,一般来说,不检查运算符 new 返回的指针值是否为 NULL 有两个原因,其一为:运算符 new 从来就不会失败,或者运算符 new 会引发异常。

即使您认为运算符 new 从来就不会失败,不检查其返回值仍不是良好的编码习惯。虽然桌面应用程序很少会遇到内存不足的情况,但是用户在其 100MB 的 Excel 电子表格上按下 F9 时,仍可能导致应用程序内存不足。对于希望每天 24 小时都在运行和处理数据的基于服务器的应用程序而言,特别是在共享的应用程序服务器上的应用程序,内存不足的情况是非常可能发生的。如果不能保证应用程序在一段时间内不会泄漏一个字节,那么内存失败的几率将会增大。有多少应用程序(特别是那些内部开发的应用程序)能够提供这种保证呢?

如果您不检查该返回指针值是否为 NULL 的理由是运算符 new 将会引发一个异常,那么还有情可原。毕竟,C++ 标准规定 new 在失败时应引发异常。这不是直到 6.0 的所有版本 Visual C++(不使用 MFC 时)的默认实现,该实现在失败时将返回 NULL。这在 Visual C++ .NET 中得到了解决,但先前的实现(特别是使用 STL 时)可能会出现问题。STL 实现假定 new 失败时将引发异常,不管使用什么编译器。事实上,如果 new 没有出现这种行为并且内存分配失败,返回 NULL ,那么就没有定义 STL 行为,并且很可能将导致应用程序崩溃。我马上将为您展示一个具体的示例。

标准模板库

越来越多的开发人员依赖 STL 进行 C++ 开发。STL 提供了丰富的基于 C++ 模板的类和函数。在应用程序中使用 STL 有几个好处。首先,该库为多种通用任务提供了一致的接口。第二个好处是,该代码已经被广泛测试,并有理由认为代码中没有错误。最后,其算法是最佳的。

为了使 STL 工作,宿主编译器必须实现 STL 所写入到的 C++ 标准。Visual C++ 编译器预包装有 STL 实现,还可使用很多其他供应商的实现。

如果您正在使用 Visual C++ 编译器(最高至 6.0 版本),则 STL 将按照期望进行工作,但有一点显著不同 — 内存不足的情况会导致运算符 new 失败。

Visual C++ 6.0 和运算符 New

当运算符 new 失败返回 NULL 时,可以认为这是个错误,因为这与声明在失败时运算符 new 应当引发异常的标准背道而驰。我所了解的所有 STL 实现,包括 Visual C++ 随附的版本,都期望运算符 new 在失败时会引发异常。

虽然改变运算符 new在失败时引发异常的行为是有可能的,我将在后面说明这一点,但这本身将导致进一步的异常。首先,我将说明在默认环境下运算符 new 失败时会发生什么情况。我的所有测试都是利用运行于 Windows NT®4.0 Service Pack 6a 和 Windows®2000 SP2 下的 Visual C++ 6.0, SP4 和 SP5 进行的。据我所知,这种行为将会影响用任何版本的 Visual C++ 编译器(6.0 SP5 版本或更高版本)在所有操作系统上构建的代码。利用 Visual C++ 版本 7.0 和 7.1 已经测试了同样的代码,这两种版本都显示了运算符 new 失败时符合标准的行为。使用的 STL 库是 Visual C++ 和 STLPort (http://www.stlport.org) 实现随附的版本。

为了解释 STL,我将使用最常用的 STL 类 std::string,尽管描述的行为适用于任何为运算符 new 分配堆内存的 STL 函数或类。在该示例中,我们假定试图用一些数据构造一个新的字符串对象,并且堆分配将会失败。下列代码片断将可以满足要求:

#include <string>
void Foo()
{
std::string str("A very big string");
}

图 2 中的代码摘自 Visual C++ 6.0 随附的 STL 字符串类代码。构造函数最终引起 std::basic_string<>::_Copy。 图 2 中显示的是其中的一部分,删除了部分代码。在 try 块中,局部变量 _S 被赋给 allocator.allocate 的返回值,反过来,它调用运算符 new。对于默认的 Visual C++ 6.0 行为,在失败时运算符 new 将返回 NULL 值,这导致 NULL 值被赋给局部变量 _S。关键是没有引发异常。

要执行的下一行代码是将 _S + 1 赋值给成员变量 _Ptr。因为 _S 的值为 NULL,值 0x00000001 将被赋给变量 _Ptr。接下来的一行,_Refcnt( Ptr) = 0,有效地返回 _Ptr - 1(实际为 _Ptr[-1]),它根据由运算符 new 返回的原始 NULL 指针值进行求值。_Refcnt 成员函数返回对 NULL 指针第一个元素的引用,而随后将 0 赋给该引用(本质是 *NULL= 0),由于即时访问冲突该引用将失败。除了安装结构化异常处理程序来捕捉该错误这样极其苛刻的措施外,并没有办法制止由于访问冲突而终止应用程序。虽然这种行为好像是由错误引起的,STL 代码实际上也是正确的,但为了得到正确的行为,它要求运算符 new 在失败时引发异常。

现在让我们看一下在运算符 new 失败引发异常时的执行流程。如前面所示,将执行对 allocator.allocate 的初始调用。当运算符 new 失败时,引发 std::bad alloc 异常,代码引起 catch(...) ( CATCH ALL) 处理程序,该处理程序重新尝试分配,请求的内存可能会较小。如果第二次分配失败,将进一步引发 std::bad alloc 异常,这将传播回代码,使 std::string 对象为已定义的空状态。

应注意该重载的字符串构造函数可能会引发异常。如果构建的类有 std::string 成员变量,在构造函数的初始化部分将调用该类的重载构造函数,应仔细阅读 Robert Schmidt 的“Handling Exceptions, Part 10”的 Deep C++ 专栏。

修复运算符 New

前面我提到不应按照知识库文章 167733 中给出的实现来修复引发异常的运算符 new。本文给出两个代码示例,如图 3 和图 4 所示。第一个示例正确地安装了一个新的(用于运算符 new 失败)处理程序,我将改进该处理程序以给出一个自动安装的处理程序。第二个示例是我为什么给出警告。该示例安装了一个新的处理程序,并调用 _set_new_mode(1) 以表明 malloc 应在失败时引发异常。

如果设置 malloc 在失败时引发异常,则使用 malloc 的所有代码都会对此行为“感到惊讶”。运算符 new(std::nothrow) 也可以按照 malloc 来实现(至少对于调试版本是这样),这种变化将导致运算符 new(std::nothrow) 在失败时引发异常。这肯定不是您想要的行为。

图 5 显示了自动安装的新处理程序的代码,这些代码包含在随本文一起提供的 NewHandler.cpp 源文件中(参见本文顶部的链接)。本质上,它与知识库文章(如图 4 所示)中列出的第二个示例代码相同,语法已修复,并且删除了 _set_new_mode 调用。通过将 NewHandler.cpp 文件添加到您的项目中可使用这些代码。

该处理程序的测试工具程序非常简短,如图 6 所示。在大多数计算机上,该测试代码的分配将会引起即时失败。示例代码导致非法的堆分配大小,无需实际执行分配即导致立即失败。遗憾的是,如果您持续分配了大块的内存,直到实际堆分配发生失败,您将不能对代码进行调试,因为 Visual C++ IDE 在内存不足的情况下总是要崩溃的。为了构建该测试程序,下载示例文件。在 Visual C++ 6.0 中打开 Testnew_throw.cpp 文件,从 Build 菜单选择 Build。接受创建一个默认工作区的提示。如果逐句调试代码,您会验证现在在失败时运算符 new 将引发 std::bad_allocc 类型的异常。

不管怎样,当运算符 new(std::nothrow) 引发异常时

在知识库文章的结尾,关于运算符 new(std::nothrow) 和 Visual C++ 5.0 的注意事项中指出:如果安装新的处理程序,则 new(std::nothrow) 将引发异常。在 Visual C++ 6.0 中仍然存在这个问题,但其行为更微妙。利用 Visual C++ 6.0,当只与 Debug 运行库链接时,运算符 new(std::nothrow) 在失败时的行为才和期望的一样,并返回 NULL。如果您链接运行库的 Release 版,那么运算符 new(std::nothrow) 总会引发异常。这当然不是应用程序所想要的行为。运算符 new(std::nothrow) 的测试工具程序非常简短,但由于另外的突发事件(这里是优化的编译器),对全部行为的演示并不是那么简单明了。该测试程序最主要的部分是 try 块中仅有的调用运算符 new(std::nothrow) 的代码,如图 7 所示。

利用与 Testnew_throw.cpp 相同的方法,构建该测试程序的 Win32® Debug 配置。运行产生的可执行文件,得到下面的期望结果:

p= 00000000

现在构建 Win32 Release 配置,运行产生的可执行文件。输出结果可能会出人意料:

abnormal program termination

这里有一件事是确定的:运算符 new(std::nothrow) 肯定不返回 NULL。究竟发生什么不是那么清楚。试着将该行移到 try 块内:

std::cout << "p= " << p << "\n";

结果发生了变化:

Error bad allocation

现在调用 catch 处理程序,来证实我前面提到的发布构建行为。问题仍然存在:为什么以前得到一个异常的程序终止?为了看清楚正在发生的一切,首先恢复到如图 7 所示的原始代码(只有对 try 块中运算符 new(std::nothrow) 的调用)。接着,更改 Win32 Release 配置的项目设置以禁用编译器优化操作 (Project | Settings | C/C++ | General | Optimizations = Disable (Debug))。构建并运行该可执行文件。程序的输出结果是预期的,但仍不是您想要的:

Error bad allocation

该行为可归于优化的编译器,它实际上生成有效的编译代码。Visual C++ 6.0 文档规定:尽管编译器支持异常规范的语法,它仍将忽略它们。因此,这不严格为真。要看清楚正在发生的一切,必须深入研究生成的程序集代码(非常少)。保留禁用优化设置,并确保 try 块中的唯一一行代码是对运算符 new(std::nothrow) 的调用,打开混合的程序集代码 (Project | Settings | C/C++ | Listing Files | Listing file type = Assembly with Source Code)。构建可执行文件,打开位于 Release 文件夹中的生成文件 Testnew_nothrow.asm。在该文件中,搜索字符串“try”,确保选中了“Find whole word only”复选框,避免与其他实例的部分匹配。应该能够查看 try 块的混合源程序/程序集,包括一个对名称为 __$EHRec$ 变量的引用。这是为 try/catch 异常机制生成的部分代码。

接下来,重新打开优化设置,重新生成并定位 Testnew_nothrow.asm 文件中的“try”源程序行。对 __$EHRec$ 变量的引用没有了。已经发生的事情是优化编译器检测到运算符 new(std::nothrow) 被声明为不引发异常,并正确地推断出整个 try/catch 块是冗余代码。结果是整个 try/catch 块被优化,使得由 try 块包装的代码无需多余的异常处理支持就可运行。虽然这在技术上是正确的,但会与随后编译器允许非引发函数引发一个异常相矛盾,而非引发函数是不可能捕获异常的。

已经发现了运算符 new(std::nothrow) 所进行的操作,现在我以用户提供的运算符 new(std::nothrow) 版本来给出该问题的修复方法。该方法取自知识库文章 167733,并包括在 NewNoThrow.cpp 源文件中,该文件可从本文下载的代码得到。该版本在失败时将正确地返回 NULL:

void *__cdecl operator new(size_t cb, const std::nothrow_t&) throw()
{
  char *p;
  try
  {
    p = new char[cb];
  }
  catch (std::bad_alloc)
  {
    p = 0;
  }
  return p;
}

但这需要警告语句。如果链接 DLL 版本的运行库(调试多线程 DLL 或多线程 DLL),则新的处理程序会有效地安装到运行库 DLL 中。这意味着加载到进程地址空间并与匹配版本的运行库 DLL 链接的任一 DLL 文件,都将受到该处理程序的影响(new 在失败时将引发异常)。这其中的含意完全取决于客户端 DLL 是否希望 new 返回 NULL 或认为它将引发异常(ATL 对两种模式都支持)。

只有在 new 更改为引发异常时才需要修复运算符 new(std:nothrow),这样的更改对正确使用 STL 是强制性的。但是,这种修复对于源文件要插入到的项目来说是局部的。在这种情况下,任何使用运算符 new(std::nothrow) 并构建兼容版本的运行库 DLL 的第三方 DLL(如我前面所显示的)都将存在失败时 new(std::nothrow) 引发异常的危险。这之所以发生是因为全局范围的新处理程序与局部范围的替换运算符 new(std::nothrow) 不匹配。唯一可行的解决方案是链接一个静态运行时库,或者验证第三方代码不会调用运算符 new(std::nothrow),或者不链接 DLL 版本的运行库。如果说这种不幸的事件状态有可能补救,那一定是运算符 new(std::nothrow) 很少重载使用。

最后的补救就是求助于 Visual C++ 6.0 提供的 STL,实际上只在一个地方使用这种重载,也就是 get_temporary_buffer 模板函数。您的代码不太可能直接调用该函数。但是,通过对 STL 源代码进行的搜索显示,internal_Temp_iterator 模板类调用 get_temporary_buffer 模板函数。下列在算法中定义的公共函数间接调用 _Temp_iterator 模板类自身:stable_partition、stable_sort 和 inplace_merge。如果运算符 new(std::nothrow) 确实引发了异常,则这些函数的行为没有被定义。如果使用这些函数并且安装了新处理程序,那么可以考虑使用不同的 STL 实现,其中有很多实现都可以使用。我在工作中成功地使用了 STLPort (http://www.stlport.org)。据我所知,该实现在其实现中的任何地方都没有调用运算符 new(std::nothrow)。

小结

我已经说明了如果您正在非 MFC 项目中使用 Visual C++(最高至 6.0 版本)中的 STL,其即装即用的行为可能导致在内存不足的情况下 STL 使您的应用程序崩溃。Visual C++ 6.0 提供的运算符 new 版本与 STL 不兼容。即使这里提供了修复方法,但当使用第三方代码或者 Visual C++ 6.0 提供的 STL 版本中的某些函数时,仍有可能出现麻烦。目前 Visual C++ 6.0 中运算符 new、运算符 new(std::nothrow) 和 STL 之间的不匹配不能完全被修复。但是,如果您在代码中使用 STL,而且没有包含我在本文中推荐的修复方法,您的应用程序在内存不足的情况下就会处于 STL 代码崩溃的真实危险中。

对于基于 MFC 的项目,STL 是否会在运算符 new 内幸免于难,完全取决于您使用的 STL 实现如何处理该运算符的异常。在处理失败的分配时,大多数实现好像都使用 catch(...),而并不使用 catch(std::bad alloc),但并不是必需这样。

最后,正如我在本文开始部分所述,两个目前使用的 Visual C++ .NET 版本都已解决了我所提到的所有问题,除了 MFC 行为之外。