技术文章

Delphi 构造函数异常与 nil 安全析构

Delphi 构造函数抛出异常时,RTL 会立刻对构造到一半的实例调用析构函数,所以每个析构函数都必须应对从未赋值的字段。HotXLS 把这条规则落实到了自己的析构函数上,包括 TXLSXRichText 和 TXLSXCells:这些构造函数内部的分配失败,会以原始的 EOutOfMemory 到达你的代码,而不是清理过程中抛出的次生访问违例

这类 bug 又老又容易漏。一个服务在繁忙的服务器上转换 300 MB 工作簿,内存吃紧,日志里出现析构函数内部的访问违例。这条消息毫无“内存不足”的迹象,没有任何线索暗示稍后重试就能成功,栈跟踪指向一个看起来完全正确的析构函数。原始异常存在了几微秒,然后就被顶掉了。本文讲清楚:为什么会这样、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。两个 HotXLS 类把形状展示得很清楚。独立版 TXLSXRichText.Create 和注释持有的重载 TXLSXRichText.Create(AOwner: TXLSXComment) 都先调 inherited Create 再分配 FRuns := TList.Create;TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) 在构建 packed-string 表和 packed cell 存储之前同样先来一句 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。富文本很重的庞大工作簿会创建成千上万个它们,所以内存吃紧时,最终失败的那次分配确实可能落在这些行上

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;

由此得出的规则短到可以进代码评审清单:

  • 析构函数里对自有字段的每次使用,除 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,随后紧跟一次成功的正常构造。工具是一个替换内存管理器,它的 GetMem、AllocMem 和 ReallocMem 给正尺寸请求计数,并在指定序号处返回 nil。先在目标构建上数清构造函数做的分配次数,然后对每个序号注入一次失败

每次注入失败后,恢复分配器再构造同一个类并使用它:对富文本类,加一个 run、设置其文本,确认纯文本如期返回。常规生命周期检查也要跟上,比如把带格式 run 和批注的工作簿分别存到文件和流、释放源工作簿,再重新打开并比对两份副本

分配次数属于特定的编译器和平台;换一个编译器或 RTL 分配可能就不同,所以请在你发布的构建上测量。HotXLS 把同一条 nil 安全规则贯穿整个库:从样式、图表和透视集合,到哈希表、zip 写入器和 PDF 导出器。哈希表值得单独提醒:一张表若在随后失败的重分配之前就改了掩码,它的析构函数会走过旧表之后的内存,所以掩码必须等新存储存在之后才能改。有一例就在 RTL 自己身上:TInterfaceList 内部分配失败时会在析构函数里崩。以上种种都不等于对任意中途失败操作的回滚,也不是通用的内存不足恢复保证

这对打开大型工作簿的代码意味着什么?

实践中,来自这些构造路径的 EOutOfMemory 是一个你能捕获并采取行动的信号,而不是只能当作崩溃处理的访问违例。TXLSXWorkbook.Create 不带参数,TXLSXWorkbook.Open(const FileName: WideString): Integer 成功返回 1、失败返回 -1。加载期间抛出的异常会以 xlsDiagnosticUnhandledException 记入工作簿的 Diagnostics 集合然后重新抛出,这正是 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 则永远不该重试,它信号的是缺陷而不是资源短缺。重试只有在各次尝试之间有东西变化时才有意义,所以处理器先释放内存。如果需要失败尝试的诊断轨迹,在 finally 块释放它之前读 Book.Diagnostics

Delphi 中 HotXLS 的重试决策矩阵:EOutOfMemory 换新 TXLSXWorkbook 重试,其他失败立刻向上传播,访问违例永不重试
每次尝试都新建工作簿,因为半途而废的 Open 无法续跑,也只有 EOutOfMemory 配得上第二次机会

重试是缓解手段,不是容量规划。32 位进程在大文件上反复撞 EOutOfMemory,缺的通常是连续地址空间而不是物理内存,持久的答案是出 Win64 版。同样的思路也适用于写入侧:HotXLS 用分阶段临时文件实现崩溃安全保存让失败的保存不会损坏现有文件。多个线程共享一个工作簿时,HotXLS 读租约与写守护说明了对该实例的访问如何协调

给你自己的 Delphi 类一份检查清单

护栏本身每处一行,同样的审计在你自己的代码上花不了几分钟。找那些在释放字段之前先调 Clear、Reset 或 ClearCore 的析构函数,然后确认那个方法没碰构造函数可能没走到的字段。搜没有 nil 检查在前的自有列表循环。找捕获异常的构造函数,把吞异常和手工 Free 都去掉。要证据而不要目测的话,上面那个计数内存管理器一页代码就能装下,把构造函数里的每次分配都变成一个测试用例

完整功能集、支持的 Delphi 与 C++Builder 版本以及授权信息,见 HotXLS Delphi Excel component 产品页