技術記事

Delphi のコンストラクタ例外と nil 安全なデストラクタ

Delphi のコンストラクタが例外を投げると、RTL は直ちに半完成のインスタンスに対してデストラクタを呼びます。つまりすべてのデストラクタは、代入されなかったフィールドに対応できなければなりません。HotXLS はこの規則を自前のデストラクタに適用しています。TXLSXRichText と TXLSXCells を含めてです。これらのコンストラクタ内部での割り当て失敗は、後片付けの最中に上がる二次的なアクセス違反としてではなく、元の EOutOfMemory のままコードに届きます

このバグの類型は古く、見逃しやすいものです。ビジーなサーバーでサービスが 300 MB のブックを変換し、メモリが足りなくなり、ログにはデストラクタ内部のアクセス違反が残ります。そのメッセージのどこにも「メモリ不足」とは書かれず、後でリトライすれば成功する示唆もなく、スタックトレースは完璧に正しそうに見えるデストラクタを指します。元の例外は数マイクロ秒だけ存在し、そして置き換えられました。この記事では、なぜそれが起きるのか、HotXLS がそれを避けるためにデストラクタをどう組み立てているのか、自分のクラスでもそれを決して起こさないための書き方を順に見ていきます

Delphi のコンストラクタが例外を投げると何が起きるのか

オブジェクトは例外が呼び出し元へ届く前に破棄され、自前のデストラクタはコンストラクタが到達した状態に対して走ります。順序はコンパイラと RTL で固定されています。NewInstance がインスタンスを割り当て、InitInstance がゼロ埋めするため、すべてのオブジェクト参照は nil から、すべての整数は 0 から、すべての文字列は空から始まります。コンストラクタ本体は暗黙の例外ハンドラの中で走ります。本体の何かが投げると、生成コードは仮想デストラクタを呼びます。BeforeDestruction が走り、続いて自前の Destroy、続いて FreeInstance がメモリを解放し、最後に例外が再送出されます。AfterConstruction は決して走らず、X := TFoo.Create の左辺の変数は代入されません

コンストラクタが例外を投げたとき、元の例外が呼び出し元へ届く前に RTL が半完成の HotXLS インスタンスに Destroy を呼ぶ、Delphi のオブジェクトライフサイクル
Delphi のコンストラクタが例外を投げると、RTL は半完成のインスタンスを先に破棄し、それから元の例外を再送出します。Destroy は構築が到達した状態に対して走るのです

つまりデストラクタが目にするのは、自前のコンストラクタの任意の行で停止したオブジェクトです。失敗より前に代入されたフィールドは本物のオブジェクトを持ち、その後のフィールドは依然として nil です。構築が完了した前提で書かれたデストラクタは、nil フィールドのどれかを参照外しし、自前のアクセス違反を投げます

この 2 つ目の例外こそ本当の被害です。Delphi はこの後片付けの最中に上がった例外を連鎖させません。アクセス違反が EOutOfMemory を置き換え、元の例外は破棄され、呼び出し元が目にするのは AV だけです。「割り当てが 1 つ失敗した。メモリを増やして再試行せよ」という有用な診断は消え、代わりにメモリ破壊に見える何かが残ります

二次的なアクセス違反はどこから来るのか

出所は、自前のコンストラクタが完了したものと仮定し、最初の内部割り当てが失敗したときに nil であるリストフィールドに触れるデストラクタです。HotXLS の 2 つのクラスがこの形をはっきり示します。スタンドアロンの TXLSXRichText.Create も、コメント所有のオーバーロード TXLSXRichText.Create(AOwner: TXLSXComment) も、inherited Create を呼んだ後に FRuns := TList.Create を割り当てます。TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) も同じことを FList := TList.Create で行い、それからパック文字列テーブルとパックセルストレージを組み立てます。Lib/lxHandleX.pas では、2 つのデストラクタがこれらのフィールドを防護しています

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
  // ... パックストレージ、文字列テーブル、FList は Free で解放する
  inherited Destroy;
end;

失敗点は正確に特定できます。構築中のヒープ割り当てを数えると、最初はインスタンス自身です。NewInstance が失敗すればコンストラクタ本体は走らずデストラクタも呼ばれないため、このケースは常にきれいです。2 つ目の割り当てが TList です。ここが失敗すると、FRuns か FList は nil のままになり、RTL が Destroy を呼び、防護されていない Destroy は nil 参照を通じて Count を読みます。まさに二次的なアクセス違反です。TXLSXCells.Create 内のそれ以降の失敗は無害です。FList が存在すれば、Clear はパックストレージを使う前に FPackedStorage <> nil を検査するからです

Delphi の HotXLS コンストラクタにおける割り当て順序マップ。NewInstance の失敗はきれい、TList.Create の失敗はフィールドを nil のままにし、それ以降の失敗は防護される
意味を持つのは 2 つ目の割り当てだけです。TList の失敗がフィールドを nil に置き、防護されていない Destroy が EOutOfMemory をアクセス違反へ変えます

この 2 つのクラスが問題になるのは、大量に生成されるからです。ロードされたワークシートごとに TXLSXCells コレクションが組み立てられ、整形済み実行を持つセルやコメントごとに 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 で行い、nil 検査をスキップする Destroy の直接呼び出しは決してしない
  • inherited Destroy は最後の文に据えます。自分のフィールドがなくなった後に祖先が自分のフィールドを解体するためです
  • コンストラクタ内のフィールド順序から推論しない。次の保守担当者は自分の割り当ての上へ新しい割り当てを挿入し、失敗点を黙って移動させます

inherited Create の置き場所

inherited Create は最初に呼びます。HotXLS の 2 つのコンストラクタがそうしている通りです。祖先のフィールドは自前のコードが走る前に存在し、デストラクタの鏡像である最後の inherited Destroy は逆順で解きほぐします。祖先のコンストラクタ自体が投げた場合でも、自前のデストラクタは全フィールドが nil の状態で走りますが、nil 安全なデストラクタなら特別扱いなしで処理できます。コンストラクタ内の FOwner := nil のような明示的代入は InitInstance の後では冗長ですが、無害であり、文書として残す価値はあります

コンストラクタ内で捕まえて解放しない

善意から出た 2 つのパターンが事態を悪化させます。1 つ目はコンストラクタ本体を try ... except Free; raise; end でくるむやり方です。RTL がすでにデストラクタを呼ぶため、オブジェクトは 2 度破棄されます。2 つ目は「続行するために」例外を飲み込むやり方です。呼び出し元は nil フィールドを持つオブジェクトへの、正当そうに見える参照を受け取り、失敗はずっと後の無関係なメソッド呼び出しへ移動します。コンストラクタが保持しない一時オブジェクトを作るなら、局所的な try ... finally でその一時オブジェクトを解放し、例外は手を加えず上へ続行させてください

コンストラクタの失敗経路はどうテストするのか

コンストラクタ内の各割り当て点を意図的に、1 つずつ失敗させ、毎回 EOutOfMemory を要求し、その後に成功する通常の構築を要求します。道具になるのは、GetMem、AllocMem、ReallocMem が正のサイズの要求を数え、指定された序数で nil を返す置換メモリマネージャです。まずターゲットビルドでコンストラクタが行う割り当て数を数え、序数ごとに 1 つの失敗注入を実行します

失敗注入のたびに、アロケータを元に戻して同じクラスを再構築し、動作を確認します。リッチテキストクラスなら、実行を 1 つ追加し、テキストを設定し、素のテキストが期待通り戻ることを確かめます。通常のライフサイクル検査も並行して行います。整形済み実行とコメント付きのブックをファイルとストリームへ保存し、元のブックを解放し、両複製を開き直して比較する、といった具合です

割り当て数は 1 つのコンパイラとプラットフォームに固有です。別のコンパイラや RTL は別の割り当てをするため、出荷するビルドで測定してください。HotXLS はライブラリ全体で同じ nil 安全規則を適用します。スタイル、チャート、ピボットのコレクションから、ハッシュテーブル、zip ライタ、PDF エクスポーターまでです。ハッシュテーブルには固有の注意があります。その後で失敗する再割り当ての前にマスクを変えてしまうと、デストラクタが古いテーブルの向こうのメモリを歩くことになるため、マスクは新しいストレージが存在した後にだけ変えなければなりません。1 つのケースは RTL 自身の中にあります。TInterfaceList は内部割り当てが失敗するとデストラクタでフォールトします。これらのどれも、中途半端に失敗した任意の操作のロールバックではなく、一般的なメモリ不足復旧の保証でもありません

大きなブックを開くコードにとって、これは何を変えるのか

実務では、これらの構築経路からの 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 は決してリトライすべきではありません。資源不足ではなく欠陥を示すものだからです。そしてリトライが意味を持つのは、試行の間で何かが変わるときだけなので、ハンドラはまずメモリを解放します。失敗した試行の診断の跡が必要なら、finally ブロックが解放する前に Book.Diagnostics を読んでください

Delphi と HotXLS におけるリトライの判断マトリクス。EOutOfMemory は新しい TXLSXWorkbook でリトライ、他の失敗は即時に伝播、アクセス違反は決してリトライしない
各試行は新しいブックを組み立てます。半分だけ済んだ Open は再開できず、2 度目の試みに値するのは EOutOfMemory だけだからです

リトライは緩和策であって容量計画ではありません。大きなファイルで EOutOfMemory に繰り返す 32 ビットプロセスは、たいてい物理メモリではなく連続アドレス空間が不足しており、恒久的な答えは Win64 ビルドです。同じ考えは書き込み側にも当てはまります。HotXLS の段階的テンポラリファイルによるクラッシュセーフな保存は、失敗した保存が既存ファイルを壊さないようにします。複数スレッドが 1 つのブックを共有するなら、HotXLS のリードリースとライトガードがそのインスタンスへのアクセス調整を説明します

自分の Delphi クラスのためのチェックリスト

防護そのものは各行 1 つで済み、同じ監査は自分のコードでも数分で終わります。フィールドを解放する前に Clear、Reset、ClearCore メソッドを呼ぶデストラクタを探し、そのメソッドがコンストラクタが届いていないかもしれないフィールドに触れていないか確認します。nil 検査なしに所有リストを反復するループを探します。例外を捕まえるコンストラクタを探し、飲み込みも手動の Free も取り除きます。検査より証明が欲しいなら、上述のカウントメモリマネージャは 1 ページのコードに収まり、コンストラクタ内のすべての割り当てをテストケースへ変えます

機能一式、対応する Delphi と C++Builder のバージョン、ライセンスについては、HotXLS Delphi Excel component の製品ページを参照してください