ネイティブのDelphiとC++Builder向けExcelライブラリであるHotXLSは、クラシックなBIFF8の.xlsワークブックをキャッシュ優先で保存します。TXLSWorksheet.WriteFormulaは、Excelが各数式の隣に格納した値をTXLSWorkbook.TryGetCachedFormulaValueに問い合わせ、そのキャッシュが欠けているか無効化されているときにだけ評価器を呼びます。開いて一度も触っていないワークブックは同じ数値をそのまま保存し戻し、新しい結果が必要ならSaveAsの隠れた副作用ではなく、明示的なRecalculate呼び出し1回で得られます
この契約を表に出さざるを得なくした不具合は、気まずいほど小さなものでした。nested-subtotals.xlsというコーパスファイルには、R2C4にキャッシュ値37を持つ総合計があります。HotXLSで開き、そのセルについてTryGetCachedFormulaValueを問い合わせると37が返ります。セルを1つも変えずに保存し、保存されたコピーを開いて同じ問い合わせをすると67が返ります。APIは何も計算するよう頼まれていませんでしたが、ファイル内の数値はちょうど30だけ動いていました。そして30は、総合計が覆う範囲の中にある2つのグループ小計、10と20の合計にほかなりません
XLSファイルの保存で数式の値が変わる理由
37が67になるには2つの独立した不具合が揃う必要があり、どちらか片方だけ直してももう片方を隠してしまったはずです。1つ目は構造的なもので、クラシックライターが保存のたびにすべての数式を再計算していました。2つ目はディスクから読み込んだ数式では決して真にならない型チェックで、そのせいで評価器がネストしたSUBTOTALセルを二重に数えていました。コーパスファイルは、保存時の再計算がExcelと異なる答えを出し、誰かがその2つを比較した最初の入力にすぎません。構造的な不具合は簡単に言えます。v2.382.3より前は、TXLSWorksheet.WriteFormulaとその共有数式版のWriteFormulaWithTExpが、すべてのFormulaレコードの8バイトのFormulaValueフィールドをTXLSWorkbook.GetFormulaValue、つまり評価器を呼んで取得していました。読み込み時にParseFormulaがソースファイルから丁寧にデコードしたキャッシュは、出力の途中で一度も参照されていませんでした。結果として各保存は、ワークブックレベルの再計算APIを迂回した完全な再計算であり、ワークブックに設定できるものでは止められませんでした。HotXLSの評価器がExcelと食い違う場所——正当に未対応の関数でも単なるバグでも——は、保存時の静かなデータ変更になったのです
2つ目の不具合は、評価器が使うネスト小計のコールバックにありました。ExcelはすべてのSUBTOTAL形式を、自らの数式が別のSUBTOTALであるセルを無視するものとして定義しているため、lxCalc.pasの計算器は集計中にFIgnoreSubtotalCellsを立て、範囲内の各セルがそれに当たるかをワークブックのTXLSWorkbook.GetClassicIsSubtotalCellに問い合わせます。そのコールバックは数式テキストをVariantとして取得し、VarType(f) = varOleStrで判定していました。テキストはGetUnCompiledFormulaからDelphiのStringとして返り、Variantに代入されたStringはvarUStringであり、varOleStrには決してなりません。この述語は読み込んだどのファイルのどのセルでも偽になり、グループ小計が総合計へ二重に繰り入れられ、すべてを再計算する保存では10 + 20 + 7が67になりました
// HotXLS 2.381以前:Stringから作られた数式Variantは
// varUStringなので、この比較は決して成立しなかった
Result := (VarType(f) = varOleStr) and
(SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
SameText(Copy(f, 1, 10), '=SUBTOTAL('));
// HotXLS 2.382.0:VarIsStrはvarString、varOleStr、varUStringを受け付け、
// AGGREGATEもExcelと同様に外側の小計から除外される
if VarIsStr(f) then
Result := SameText(Copy(f, 1, 9), 'SUBTOTAL(') or
SameText(Copy(f, 1, 10), '=SUBTOTAL(') or
SameText(Copy(f, 1, 10), 'AGGREGATE(') or
SameText(Copy(f, 1, 11), '=AGGREGATE(');
v2.382.0はVarIsStrの修正を出荷し、同じ関数にいるついでに、AGGREGATEのセルも外側の小計から除外されることをコールバックに教えました。それだけでコーパスのアサーションは通りました。再計算された37が読み込んだ37と一致するようになったからです。しかしライブラリが正直になったわけではありません。保存は依然として再計算しており、テストが緑だったのは、評価器がたまたまそのファイルでExcelと一致したからにすぎません。SUBTOTALとAGGREGATEがどのセルを飛ばすかというルールは、非表示行を含め、SUBTOTALとAGGREGATEの非表示行の記事で扱っています。ここで大事なのは、計算を頼んでいないファイルについて評価器に発言権を与えるべきではないということです
保存時、Excelはキャッシュ値について何を保証するのか
Excelは保存を計算イベントではなくスナップショットとして扱います。Formulaレコード([MS-XLS] §2.4.127、レイアウトは§2.5.133)のFormulaValueフィールドに書かれる値は、そのセルが現在表示しているものであり、手動計算モードでは何年も古い可能性がありますが、Excelはそれでも忠実に書き込みます。再計算は独自のトリガーを持つ別の操作です。HotXLSもクラシック保存について同じルールに従うようになりました。WriteFormulaとWriteFormulaWithTExpはまずTryGetCachedFormulaValueを呼び、状態がxlfcsLoadedまたはxlfcsCalculatedならCacheInfo.Valueを採用し、xlfcsMissingとxlfcsInvalidatedのときだけGetFormulaValueに落ちます。この契約の読み取り側、各状態の意味、そしてキャッシュされた空白やFalseがなぜ値として数えられるのかは、再計算せずにExcelの数式キャッシュ値をDelphiで読むで説明しています
フォールバック経路は削除されず、意図的に残されています。セッション中にCells[Row, Col].Formulaで代入した数式はキャッシュなしで到着し、読み込んだセルで置き換えた数式は_SetCompiledFormulaによってxlfcsInvalidatedに印を付けられます。どちらも従来どおり保存時に評価されるため、生成したワークブックは今も数値入りでExcelに開きます。評価器でさえ値を出せない場合、ライターはゼロのペイロードを出力し、fAlwaysCalc(§2.4.127のgrbitビット0)を立てます。Excelがプレースホルダーを信用せず、開くときにそのセルを再計算するようにするためです
procedure RoundTripWithoutRecalc(const Source, Target: string);
var
Book: TXLSWorkbook;
Before, After: TXLSFormulaCacheInfo;
begin
Book := TXLSWorkbook.Create;
try
Book.Open(Source);
// シート、行、列は1始まり:最初のシートのR2C4
if not Book.TryGetCachedFormulaValue(1, 2, 4, Before) then
raise Exception.Create('R2C4 carries no usable cache');
Book.SaveAs(Target); // キャッシュ済みセルでは評価器が関与しない
finally
Book.Free;
end;
Book := TXLSWorkbook.Create;
try
Book.Open(Target);
Book.TryGetCachedFormulaValue(1, 2, 4, After);
// nested-subtotals.xlsでは Before.Value = After.Value = 37
// 再計算する保存ならここに67を書いていた
finally
Book.Free;
end;
end;
BIFFの共有数式ルートはキャッシュ値をどこに持つのか
他のすべての数式セルと同じく、自分のFormulaレコードに持っています。そしてまさにそれが、共有グループのルートセルを、キャッシュ優先保存がまだ失っていた唯一の場所にしていました。BIFF8の共有数式は、左上のセルのFormulaレコードに続くShrFmlaレコード([MS-XLS] §2.4.260)として格納され、ルートを含むすべてのメンバーセルが、単一のPtgExpトークン(§2.5.198)からなるrgceを持ちます。解析された式の最初のバイトが$01で、それにルートセルの行と列が続きます。フォロワーのセルは自己完結しており、HotXLSはそれぞれのFormulaValueを読み、ルートのコンパイル済み数式を参照して式を解決します。ルートセルは違います。そのFormulaレコードが解析される時点で式がまだ存在せず、1レコード後にやって来るからです
その1レコード分の隙間がキャッシュの消えた場所です。TXLSReader.ParseFormulaはキャッシュ値をデコードし、座標がそのセル自身と等しいPtgExpを見ると、そのセルをFSharedFormulaRowとFSharedFormulaColに記憶し、キャッシュをセルへ公開します。ShrFmlaレコード($04BC)が到着すると、ParseSharedFormulaが式をコンパイルして_SetCompiledFormulaで組み込み、_SetCompiledFormulaは数式変更に対して必ず行うべきこと、つまりFCachedFormulaValueをクリアし状態をxlfcsMissingに戻すことを行います。そのためルートが読み込んだ37は誰かが読む前に捨てられ、TryGetCachedFormulaValueはルートを未キャッシュとして報告し、キャッシュ優先のライターは、まさに皆が見ているセルについて素直に評価器へフォールバックしていました。Arrayレコード(§2.4.4)も同じ順序で、同じ穴がありました
v2.382.3の修正は、保留中のルート座標の隣に3つ目のフィールドFSharedFormulaCachedValueを追加します。ParseFormulaはルートを認識したときにデコード済みのキャッシュをそこへ退避し、ParseSharedFormulaとParseArrayFormulaの両方が、コンパイル済みの式を組み込んだ直後に_SetCellCachedFormulaValueを通してそれを再生し、その後で退避をUnassignedに戻します。キャッシュのString版はこのすべての影響を受けません。そのペイロードは別のStringレコードで到着し、レコード順ではなくセル座標で振り分けられるからです。同じ概念のOOXML側を扱うなら、XLSXの共有数式si展開の記事で、パッケージ形式に同等の順序問題がない理由と、それ自身の展開の落とし穴を説明しています
共有数式のフォロワーに相対シフトが必要な理由
ShrFmlaに格納される式がルートセルを基準に書かれているため、それをそのまま再利用するフォロワーは自分の参照ではなくルートの参照を評価してしまいます。旧リーダーは各フォロワーにValue.GetCopy()を、変位のない深いコピーを組み込んでいたため、B1をルートとし=A1*3を持つグループでは、すべてのフォロワーも=A1*3になっていました。キャッシュ優先保存は読み込んだファイルではこれを隠していました。フォロワーは自分自身のFormulaValueを持ち、正しく保存するのに式を必要としなかったからです。表面化するのは何かが再計算した瞬間でした。リーダーは今、TXLSCompiledFormula.GetCopy(row - srow, col - scol)を組み込みます。これは構文木をたどり、すべての相対参照をフォロワーのルートからの距離だけオフセットするため、B2のフォロワーは本物の=A2*3を所有します
両方の挙動を固定するリグレッションテストは、偶然の一致を通さないので読む価値があります。入力2と4に対して=A1*3と=A2*3を持つワークブックを組み立て、意図的に誤ったキャッシュ999と888を_SetCellCachedFormulaValueで注入します。UseSharedFormulasをオンにした場合とオフにした場合の2通りです。保存と再読み込みの後、両方のセルは依然として999と888を報告しなければなりません。保存がルートのキャッシュにもフォロワーのキャッシュにも触れていない証明です。それらが6と12になるのは、明示的なRecalculateの後だけです。フォロワーのシフトされた式が正しいことの証明です。真の値を仕込むテストは旧ライターでも通ってしまい、だからこそ誤った値を仕込むことが要点なのです
var
Book: TXLSWorkbook;
Info: TXLSFormulaCacheInfo;
begin
Book := TXLSWorkbook.Create;
try
Book.Open('quarterly-model.xls');
Book.Sheets[1].Cells[1, 1].Value := 5; // 入力を変更する
// 依存する数式の読み込み済みキャッシュはリテラル編集では無効化されない
// ため、素のSaveAsは古い数値をそのまま保ってしまう。
// 本当に新しい結果が欲しいときは再計算を明示的に要求する:
Book.Recalculate;
if Book.TryGetCachedFormulaValue(1, 1, 2, Info) then
Writeln('B1 now ', VarToStr(Info.Value),
', state ordinal ', Ord(Info.State)); // xlfcsCalculated
Book.SaveAs('quarterly-model-updated.xls');
finally
Book.Free;
end;
end;
キャッシュ優先の契約がしてくれないこと
キャッシュ優先保存は読み込んだものを保つだけで、読み込んだものがまだ正しいかは追跡しません。数式が依存するリテラルを変更すると、評価器のために依存関係グラフはダーティになりますが、依存セルのxlfcsLoadedキャッシュはそのまま残り、Recalculateを呼ぶか、そのセルのValueを先に読むかしない限り、クラシックライターはその古い値を平然と書き出します。そのセルを読めば計算が走り、状態はxlfcsCalculatedに移ります。これはExcelが手動計算モードで行うのと同じトレードオフであり、サードパーティのファイルを開いてラベルを少し編集して保存するパイプラインには正しい選択です。ただし、入力を編集するワークブックは再計算の段階を明示的に持たなければならないという意味でもあります。XLSXライターのRecalcBeforeSaveポリシーはこの作業では変わらず、同じ趣旨でキャッシュを保つ独自のマニュアルモードを持っています。ここから2つの小さな境界が従います。キャッシュ優先の経路が助けるのは、状態がxlfcsLoadedかxlfcsCalculatedのセルだけです。数式を書いて一度も評価しないジェネレーターは、従来どおり保存時にセルごとに1回の評価を払います。そしてネスト小計の修正は、評価器がどのセルを飛ばすかを直すものであって、評価器が実装するすべての関数を直すものではありません。HotXLSがExcelと同一に計算できない数式を含むファイルは、手を触れずにラウンドトリップしても安全になりましたが、そのファイルに対する意図的なRecalculateは依然としてライブラリの答えを返します。再計算した保存を信用する前に、両者を比較してください
キャッシュ優先のクラシック保存、復元された共有数式と配列数式のルートキャッシュ、共有フォロワーの相対参照シフト、修正されたSUBTOTALとAGGREGATEのネスト規則は、DelphiとC++Builder向けの標準HotXLS Delphi Spreadsheet Componentにすべて含まれており、ExcelやOLEオートメーションサーバーへの依存はありません。製品ページには、ここで使ったワークブック、キャッシュリーダー、再計算の各エントリポイントの完全なAPIリファレンスが掲載されています