程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> VC >> 關於VC++ >> VC6下使用STL注意:不要讓內存分配失敗導致您的舊版STL 應用程序崩潰

VC6下使用STL注意:不要讓內存分配失敗導致您的舊版STL 應用程序崩潰

編輯:關於VC++

大多數 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 行為之外。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved