技術文章

Delphi 建構子例外與 nil 安全解構子

當 Delphi 建構子擲出例外,RTL 會立即對蓋到一半的實體呼叫解構子,所以每個解構子都必須應付從未被指定的欄位。HotXLS 把這條規則套在自己的解構子上,TXLSXRichText 與 TXLSXCells 也在內:那些建構子內部的配置失敗,會以原本的 EOutOfMemory 傳到你的程式碼,而不是清除過程中引發的二次存取違例

這類 bug 既老舊又容易漏看。某個服務在忙碌的伺服器上轉換 300 MB 的活頁簿,記憶體吃緊,log 裡出現解構子內的存取違例;訊息裡沒有任何東西說「記憶體不足」,沒有任何提示晚點再試就會成功,stack trace 指向一段看起來完全正確的解構子。原本的例外存在了幾微秒,然後就被換掉了。本文會走一遍這件事為什麼發生、HotXLS 怎麼組織解構子來避開它,以及怎麼寫自己的類別才不會犯

Delphi 建構子擲出例外時會發生什麼?

物件會在例外抵達呼叫端之前被代為銷毀,而你的解構子面對的是建構子走到哪算哪的狀態。這個順序由編譯器與 RTL 定死:NewInstance 配置實體、InitInstance 把它清成零,所以每個物件參照起步都是 nil、每個整數是 0、每個字串是空字串。建構子本體接著在一個隱含的例外處理器裡執行;本體裡任何東西擲出例外,產生的程式碼就呼叫虛擬解構子——BeforeDestruction 先跑、然後是你的 Destroy、接著 FreeInstance 釋放記憶體,最後例外重新擲出。AfterConstruction 永遠不會跑,X := TFoo.Create 左邊的變數也永遠不會被指定

Delphi 物件生命週期:建構子擲出例外時,RTL 對蓋到一半的 HotXLS 實體呼叫 Destroy,之後原例外才抵達呼叫端
Delphi 建構子擲出例外時,RTL 先銷毀蓋到一半的實體,然後才重新擲出原例外,所以 Destroy 面對的是建構走到哪算哪的狀態

於是解構子看到的是一個停在建構子任意一行的物件:失敗點之前指定的欄位握有真物件,之後的欄位還是 nil。一個按「建構一定跑完」假設寫的解構子,會解參考其中某個 nil 欄位,並引發自己的存取違例

那個第二個例外才是真正的傷害。Delphi 不會把清除期間擲出的例外串接起來:存取違例取代了 EOutOfMemory,原例外被丟棄,呼叫端永遠只看到 AV。「某次配置失敗了,用多一點記憶體再試」這個有用的診斷消失了,取而代之的是看起來像記憶體損毀的東西

二次存取違例從哪裡來?

它來自一個假定建構子跑完、卻在第一次內部配置失敗時碰到 nil list 欄位的解構子。兩個 HotXLS 類別把這個形狀展示得很清楚:獨立的 TXLSXRichText.Create 和註解擁有的 overload TXLSXRichText.Create(AOwner: TXLSXComment) 都先呼叫 inherited Create,然後配置 FRuns := TList.Create;TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) 在建立 packed-string 表與 packed 儲存格之前也一樣做了 FList := TList.Create。在 Lib/lxHandleX.pas 裡,兩個解構子都守住了這些欄位:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // 建構子可能在 FRuns 之前就停了
  for i:= 0 to FRuns.Count- 1 do
    TXLSXRichTextRun(FRuns[i]).Free;
  FRuns.Clear;
end;

destructor TXLSXCells.Destroy;
begin
  FPackedStorageEnabled:= False;
  if FList<>nil then Clear;          // TList.Create 失敗時 FList 為 nil
  // ... packed storage、字串表與 FList 都以 Free 釋放
  inherited Destroy;
end;

失敗點很精確。把建構期間的堆積配置數一遍:第一次是實體本身;若 NewInstance 失敗,建構子本體不會跑、解構子也不會被呼叫,這個情況永遠乾淨。第二次配置是那個 TList;它失敗時 FRuns 或 FList 留在 nil,RTL 呼叫 Destroy,而沒有設防的 Destroy 會透過 nil 參照去讀 Count——正是那個二次存取違例。TXLSXCells.Create 裡更晚的失敗則無害,因為 FList 一旦存在,Clear 會在使用 packed storage 前先檢查 FPackedStorage <> nil

Delphi HotXLS 建構子的配置順序圖:NewInstance 失敗是乾淨的,TList.Create 失敗留下 nil 欄位,更晚的失敗都有設防
只有第二次配置要緊:TList 失敗留下 nil 欄位,沒設防的 Destroy 正是把 EOutOfMemory 變成存取違例的那一步

這兩個類別之所以要緊,是因為它們被大量建立。每張載入的工作表都建一個 TXLSXCells 集合,每個帶格式 run 的儲存格或註解都建一個 TXLSXRichText;重 rich text 的大活頁簿會造出上千個,所以在記憶體壓力下,最終失敗的那次配置有實際機率落在這幾行上

Free 是 nil 安全的,欄位存取不是

TObject.Free 呼叫 Destroy 前先檢查 Self <> nil,所以對 nil 欄位做 FRuns.Free 是無操作;讀 FRuns.Count 則不是——Count 從實體指標的偏移讀欄位,而 FRuns.Clear 這種虛擬呼叫會先從實體讀 VMT 指標,兩者對 nil 都會出事。這個不對稱就是整個陷阱:典型解構子裡的釋放呼叫本來就安全,麻煩住在放在它們之前的那些迴圈與清理呼叫。這是小型自有類別裡的樣子:

type
  TStyledText = class
  private
    FRuns: TList;
    FIndex: TStringList;
  public
    constructor Create;
    destructor Destroy; override;
  end;

constructor TStyledText.Create;
begin
  inherited Create;
  FRuns := TList.Create;          // 這裡 EOutOfMemory 會留下 FRuns = nil
  FIndex := TStringList.Create;   // ... 這裡則留下 FIndex = nil
end;

destructor TStyledText.Destroy;   // 錯誤示範:假定建構子跑完了
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // FRuns = nil 時引發存取違例
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

修正後的解構子守住每個解參考,並只透過 nil 安全的呼叫釋放欄位:

destructor TStyledText.Destroy;
var
  I: Integer;
begin
  if FRuns <> nil then
    for I := 0 to FRuns.Count - 1 do
      TObject(FRuns[I]).Free;
  FreeAndNil(FIndex);
  FreeAndNil(FRuns);
  inherited Destroy;
end;

由此推出的規則,短到可以帶進 code review:

  • 解構子裡對自有欄位的每次使用——Free 或 FreeAndNil 以外——都需要 nil 或 Assigned 檢查,包括呼叫 Clear、ClearCore 這類會迭代欄位的幫手
  • 用 Free 或 FreeAndNil 釋放自有物件,絕不直接呼叫 Destroy,後者跳過了 nil 測試
  • 把 inherited Destroy 留在最後一句,讓祖先在你的欄位消失之後拆掉它自己的
  • 不要按建構子裡的欄位順序做推理;下一位維護者會在你的上方插入一次配置,悄悄把失敗點搬走

inherited Create 該放哪裡

先呼叫 inherited Create,兩個 HotXLS 建構子都是這麼做的。祖先的欄位因此在你的程式碼執行前就存在,解構子的鏡像——inherited Destroy 放最後——則以相反順序拆解。若祖先建構子自己擲出例外,你的解構子照樣執行,而且你的每個欄位都是 nil,nil 安全的解構子不需要特例就能處理。建構子裡 FOwner := nil 這類明確指定在 InitInstance 之後是多餘的,但無害,有時留著當文件也值得

不要在建構子裡接住並釋放

兩種好意的模式會讓事情更糟。第一種是把建構子本體包進 try ... except Free; raise; end:RTL 本來就會呼叫解構子,物件於是被銷毀兩次。第二種是把例外吞掉「繼續跑」:呼叫端隨後拿到一個看似有效、欄位卻是 nil 的物件參照,失敗轉移到很晚以後某個不相干的方法呼叫上。建構子若建了不保留的暫時物件,就在局部 try ... finally 裡釋放它,讓例外原樣往上傳

建構子失敗路徑怎麼測?

刻意讓建構子的每個配置點逐一失敗,每次都要求收到 EOutOfMemory,接著一次成功的正常建構。工具是替換掉的 memory manager,其 GetMem、AllocMem 與 ReallocMem 會計數正尺寸請求,並在指定序號回傳 nil;先在你的目標建置上數出建構子的配置數,再對每個序號注入一次失敗

每次注入失敗之後,把 allocator 還原、重建同一個類別並操作它:對 rich text 類別,加一個 run、設它的文字、確認純文字如預期回來。正常的生命週期檢查也要並行:把帶格式 run 與註解的活頁簿存到檔案與串流、釋放來源活頁簿、重新開啟並比對兩份副本

配置數屬於特定編譯器與平台;另一個編譯器或 RTL 可能配置得不一樣,所以請在你出貨的建置上量測。HotXLS 把同一條 nil 安全規則套滿整個函式庫,從樣式、圖表與樞紐集合到雜湊表、zip 寫出器與 PDF 匯出器。雜湊表值得單獨一提:在隨後失敗的重新配置之前先改 mask 的表,會讓解構子走過舊表之後的記憶體,所以 mask 必須等新儲存存在之後才改。還有一個案例在 RTL 本身:TInterfaceList 在內部配置失敗時會在解構子裡出事。以上都不是對任意半途失敗操作的 rollback,也不是通用性的記憶體不足復原保證

這對開啟大型活頁簿的程式碼意味著什麼?

實務上,這些建構路徑的 EOutOfMemory 是一個你能接住並處理的訊號,而不是只能當崩潰看待的存取違例。TXLSXWorkbook.Create 不收參數,TXLSXWorkbook.Open(const FileName: WideString): Integer 成功回傳 1、失敗回傳 -1;載入期間擲出的例外會記進活頁簿的 Diagnostics 集合成為 xlsDiagnosticUnhandledException,然後重新擲出,行為如HotXLS 結構化診斷一文所述。重試包裝長這樣:

type
  EWorkbookOpenFailed = class(Exception);

function OpenWorkbookWithRetry(const FileName: WideString;
  MaxAttempts: Integer): TXLSXWorkbook;
var
  Attempt: Integer;
  Book: TXLSXWorkbook;
begin
  Result := nil;
  for Attempt := 1 to MaxAttempts do
  begin
    Book := nil;
    try
      try
        Book := TXLSXWorkbook.Create;          // 沒有建構子參數
        if Book.Open(FileName) <> 1 then        // 1 = 成功,-1 = 失敗
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // 所有權移交給呼叫端
        Exit;
      finally
        Book.Free;                             // 交接之後 nil 安全
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // 你的程式碼:丟掉快取與其他活頁簿
      end;
    end;
  end;
end;

包裝裡有幾個細節是刻意的。每次嘗試都造全新的 TXLSXWorkbook,因為 nil 安全解構子讓建構失敗乾淨,卻救不了做到一半的 Open 使其可續行,所以失敗的實體就乾脆釋放。只有 EOutOfMemory 會重試;EWorkbookOpenFailed、密碼錯誤與其他一切在第一次嘗試就往外傳。EAccessViolation 根本不該重試,它代表缺陷而非資源短缺。而且重試只在嘗試之間有東西改變時才有意義,這正是 handler 先釋放記憶體的原因;需要失敗那次嘗試的診斷軌跡,就在 finally 釋放之前讀 Book.Diagnostics

Delphi 搭配 HotXLS 的重試決策矩陣:EOutOfMemory 以全新 TXLSXWorkbook 重試,其他失敗立刻上拋,存取違例永不重試
每次嘗試都造全新活頁簿,因為做到一半的 Open 無法續行,也只有 EOutOfMemory 配得上第二次機會

重試是緩解,不是產能規劃。在大型檔案上不斷撞 EOutOfMemory 的 32 位元行程,缺的通常是連續位址空間而不是實體記憶體,治本的答案是 Win64 建置。同樣的思路也適用寫入側,HotXLS 以階段暫存檔做的當機安全存檔讓失敗的存檔不會傷到既有檔案;多執行緒共用一個活頁簿時,HotXLS 讀取租約與寫入守衛描述了對該實體的存取怎麼協調

給你自己 Delphi 類別的檢查清單

防護本身每條一行,同樣的稽核在你自己的程式碼上只要幾分鐘:找在釋放欄位之前呼叫 Clear、Reset 或 ClearCore 的解構子,然後確認那個方法碰的欄位建構子是否可能還沒走到;搜尋沒有 nil 檢查在前的自有 list 迴圈;找會接例外的建構子,把吞例外與手動 Free 都拿掉;想要證據而不只靠目視,前面那個計數 memory manager 一頁程式碼就寫得下,把建構子裡每次配置都變成測試案例

完整功能、支援的 Delphi 與 C++Builder 版本與授權,請見HotXLS Delphi Excel component 產品頁