=VLOOKUP(A1,B:B,1) を列 B のセルへ置くと、Excel は文句なしに計算します。同じワークブックを依存グラフの再計算エンジンへ与えると、循環参照エラーになりがちです。数式が、その数式自身を含む範囲に依存しているからです。HotXLS は v2.361.98 までまさにそれを報告していました。修正は列全体の範囲向けの特別扱いではありません。スプレッドシートエンジンが必要とし、単純な有向グラフが持たない、2 種類の依存エッジの区別です
Lookup ファミリー、すなわち LOOKUP、MATCH、HLOOKUP、VLOOKUP、XLOOKUP、XMATCH の検索配列引数は、スキャン参照として印が付けられるようになりました。スキャン参照はダーティの種まきは今も行うため、範囲内のセルを編集すると数式は再計算されます。しかし循環検出や評価順序付けには決して寄与しません。本当の循環は今も見つかります。偽のものはなくなりました
Excel は検索範囲が数式自身を含むことをなぜ許すのか
この引数は、算術オペランドが消費されるのと同じようには消費されないからです。Lookup ファミリーは、キャッシュされた値を求めて範囲をスキャンし、一致を返します。範囲が先に完全に評価済みであることを要求しません。Excel は自己重複する検索範囲を、それらのセルが今保持しているものを読むものとして扱います。非反復ワークブックへ適用するのと同じセマンティクスです。このパスで再計算されていないセルは、最後に計算された値を提供します
列全体の参照が、これを珍しいケースではなくよくあるケースにします。B:B は、行が追記されていくシートで「検索テーブル全体」を書く慣用的な方法であり、列 B に住むすべての数式は、そのとき自分自身の検索範囲の内側にあります。財務モデル、照合シート、監査ワークブックはこれを絶えず行っており、通常は誰も範囲が重なっていることに気づきません
同じ数式に依存グラフが何をするか
HotXLS はインクリメンタルに再計算します。これには本物の依存グラフが必要です。セルのノード、参照のエッジ、評価のためのトポロジカル順序、循環を分類する強連結成分のパスです。その仕組みはインクリメンタル再計算の記事に述べており、偽陽性が現れたのはまさにそのためです
セル B7 の =VLOOKUP(A1,B:B,1) から依存を抽出すると、第 2 引数は B7 自身を含む範囲を生みます。グラフには自己ループができます。そのノードの入次数は決してゼロに達しないため、トポロジカルパスは決してスケジュールできず、成分パスはそれを循環として分類します。エンジンは与えられたグラフについて正しく推論しています。グラフが誤ったモデルです。スプレッドシートには 2 つのエッジ型があるのに、1 つをエンコードしているからです
2 つのエッジクラス、1 つのグラフ
この変更は、解決済み参照レコード TXLSDepRange.LookupScan へフラグを追加します。依存抽出器が 6 つの関数のうちの 1 つの検索配列引数を歩くときに設定します。下流では、それらの参照に由来するエッジは、普通のエッジとは別に格納されます。グラフノードは、通常の依存元と依存先のリストに加えて、ScanDependents と ScanPrecedents のリストを保持します
この分離こそがセマンティクスを正しいものにします。スキャンエッジはダーティ伝播によって歩かれるため、B:B のどこかの編集は今も B7 をダーティと印付けし、B7 は再計算します。スキャンエッジは入次数へ数えられることも、成分ビルダーへ入ることもないため、トポロジカルなデッドロックを作れず、循環として分類されません。ライブラリ内の両方のグラフ実装、すなわちクラシックなワークブックごとのグラフと、成分分析を運ぶワークブック横断のワークスペースグラフは、一緒に変更されました。ずれを許すと、単独で開いたかワークスペースの一部として開いたかに応じて、再計算が異なるワークブックが生まれます
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Ledger');
Sheet.Cells[1, 1].Value := 'ACC-4471';
Sheet.Cells[1, 2].Value := 1200.00;
// 検索範囲は列 B をカバーし、この数式はその中に住む
Sheet.Cells[7, 2].Formula := 'VLOOKUP(A1,B:B,1)';
case Book.Recalculate of
lxOk:
// v2.361.98 より前、この分岐はこのシートでは到達不能だった
SaveReport(Book);
lxErrorRef:
LogWarning('Genuine circular reference - review model inputs');
end;
finally
Book.Free;
end;
end;
スキャンエッジを順序付けから外すことで手放すもの
正確に 1 つあり、隠さず率直に述べる価値があります。スキャンエッジはトポロジカル順序に参加しないため、Lookup 数式は同じパスの中で、検索範囲の一部のセルが再計算されるより先に評価され得ます。そのとき以前の値を読みます。結果は次の再計算で収束します
許容できるのは、それが Excel のしていることだからです。反復計算が有効でないワークブックに対して、現在のパスでまだ再計算されていない値への Excel 自身の答えは、最後に計算された値です。したがってこの振る舞いを再現するエンジンは、参照実装に近似するのではなく一致しています。自己参照モデルの上で本当に収束した答えが必要なら、そのための仕組みは明示的な反復上限を持つ反復計算であり、反復計算の記事で扱っています。これはスキャンの重複ではなく、本当の循環に適用されるものです
修正の内側に隠れるリグレッションの危険
TXLSDepRange へ LookupScan を追加したことは、Lookup とは無関係で、Pascal こそに関係するリスクを持ち込みました。TXLSDepRange は非マネージレコードであるため、その型のローカル変数はゼロ初期化されません。データテーブル依存ブロックといくつかのテストヘルパーを含む、コードベース内の手で構築するすべての場所は、したがって新しいフィールドを明示的に設定するよう更新する必要がありました。1 か所でも欠けると、スタックにたまたま居合わせたバイトが、その参照がスキャンエッジとして扱われるかどうかを決めます。無関係なコード変更とともに現れたり消えたりする再計算バグになります
// 非マネージレコードの新しい Boolean フィールドは、手動の構築
// 箇所すべてを潜在バグにする。安全な慣用句は 2 つある
var
R: TXLSDepRange;
begin
FillChar(R, SizeOf(R), 0); // すべてゼロにしてから埋める
R.Sheet1 := SheetIndex;
R.Sheet2 := SheetIndex;
R.Row1 := Row; R.Col1 := Col;
R.Row2 := Row; R.Col2 := Col;
// またはすべての箇所で、新しいものを含む全フィールドを設定する
R.LookupScan := False;
end;
ここから得られた一般的な規則です。多数の場所でスタック上に構築されるレコードへのフィールド追加は、見た目より高リスクな変更であり、コンパイラは箇所を見つける助けになりません。レコードがホットパスから到達可能なら、すべての呼び出し箇所が更新されることを信頼するよりも、完全に初期化するヘルパーを選んでください
本当の循環とスキャンの重複の見分け方
この変更は循環検出を弱めるものではありません。B7 の =B7+1 は今も循環であり、自分自身で閉じる 3 つの数式の連鎖も今も循環であり、どちらも今も再計算結果を通じて報告されます。循環のメンバーは以前のキャッシュ値を保持し、循環の外はすべて最新のままです。変わったのは、検索配列引数が Excel の見えない循環を捏造しなくなっただけです
ワークブックを監査していて、エンジンが実際にどの参照をどの順序で解決したかを知りたいなら、評価トレーサーがその道具です。数式評価トレーサーの記事がその出力の読み方を扱っています。HotXLS は、Excel をインストールせずに XLS、XLSX、ODS、CSV を読み書きするネイティブの Delphi および C++Builder スプレッドシートコンポーネントであり、再計算エンジンはすべてのフォーマットで同じです。現在の関数とエンジンの対応状況は、HotXLS Delphi spreadsheet component の製品ページに一覧があります