技術記事

DelphiのHotXLS定義名の暗黙の交差と引数クラス

列全体を指す定義名は、スカラー位置に現れるとExcelに1つのセルとして読まれます。行7の=Vertical+1は「Verticalの行7のセル」の意味であり、領域全体ではありません。HotXLS Delphi Componentは、この暗黙の交差を2つのレベルで適用します。評価のときと、依存関係の抽出のときです。値を正しくするだけでは足りないからです。名前を領域全体へ展開する依存関係ウォーカーは、その領域のセルを食べる下流の数式すべてに、存在しない循環を閉じさせてしまい、TXLSXWorkbook.Recalculateはワークブック全体を循環として拒まざるを得なくなります

4805個の数式を持つ株式ローン返済ワークブックが、これらすべてを働かせるストレスケースです。古いキャッシュから正しく再計算するには、3つの独立した機構に懸かっています。名前の暗黙の交差、ルックアップスキャンの順序、そしてネストした演算に使う数値型です。どれか1つでも間違えると、症状はlxErrorRef(循環参照のコード)か、行ごとの独立計算から徐々にずれていく合計です。この記事は、関係するソースごとに各機構を歩きます

列名へのスカラー参照が偽の循環を生む理由

依存関係グラフは辺しか知らないからです。数式から480行の領域への辺は480本の辺であり、そのうち1本はその数式に依存するセルを経由して戻ってきます。B1に=IF(TRUE,Vertical+1,0)があり、VerticalがInputs!$A$1:$A$2として定義され、A2に=B1+1がある場合を考えてみます。ExcelはB1をA1+1として、A2をB1+1として評価します。まっすぐな連鎖です。B1がA1:A2に依存すると記録するウォーカーでは、A2がB1の先行ノードになり、A2はすでにB1を先行ノードとして挙げているため、HotXLSの増分再計算を駆動するKahnキューは、どちらのノードも入次数がゼロになるのを見ることがありません。これは、ローン計算テンプレートがまさにそうやって作られているパターンです。各期の行が残高・利率・支払回数の名前付き列を参照し、各名前はスケジュール全体にまたがり、各行はそれらの列へ書き込みも行います。名前を展開すればグラフは1つの巨大な強連結成分です。暗黙の交差で評価すればグラフは行ごとの短い連鎖の集合になり、これは単一の値が必要とされる場所で参照オペランドを消費する場合について、ECMA-376 Part 1 §18.17.2が記述しているとおりです

列名の展開が偽の循環を閉じる理由。VerticalがInputs!$A$1:$A$2として定義されているとき、B1がA1:A2に依存すると記録するウォーカーは、A2がすでにB1を先行ノードとして挙げているのにA2をB1の先行ノードにし、Kahnキューは排水されない。一方、交差はB1を行セルA1へ絞り、Recalculateが順序付ける行ごとの連鎖A2、B1、A1を保つ
名前の展開はグラフを1つの巨大な強連結成分に変え、同じ数式を暗黙の交差で評価すると、スケジュールの行ごとの短い連鎖に変わる
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Inputs');
    Book.DefinedNames.Add('Vertical', 'Inputs!$A$1:$A$2');
    Book.DefinedNames.Add('Alias', '=Vertical');
    Sheet.Cells[1, 1].Value := 1;
    // スカラー位置:数式が行1にあるのでVerticalはA1へ潰れる
    Sheet.Cells[1, 2].Formula := '=IF(TRUE,Vertical+1,0)';
    Sheet.Cells[2, 1].Formula := '=B1+1';
    // 定義が別の名前である名前も交差する。だからこれはA2
    Sheet.Cells[2, 2].Formula := '=Alias';
    // 参照クラスの引数:領域全体が合計され、交差は起きない
    Sheet.Cells[3, 2].Formula := '=SUM(Vertical)';
    // 行6はA1:A2の外。交差は空でIFERRORが受け取る
    Sheet.Cells[6, 2].Formula := '=IFERROR(Vertical,42)';

    if Book.Recalculate = lxOk then
    begin
      // B1 = 2, A2 = 3, B2 = 3, B3 = 4, B6 = 42
      // ここでVerticalをA1:A2へ展開すると、B1 -> A2 -> B1が偽の循環になる
    end;
  finally
    Book.Free;
  end;
end;

HotXLSが引数をスカラーと判断する方法

HotXLSは、引数の形ではなく関数テーブルから答えを読みます。TXLSFormula.InitFuncHashのすべてのエントリは、省略可能な引数ごとのクラス文字列付きでTHashFunc.SetValue経由で登録されます。'IF'は'100'を運び、'SUMIF'は'010'、'VLOOKUP'は'1011'、そして'SUM'は何も持たないため、引数はすべて関数レベルのクラス0へフォールバックします。TXLSFormula.FunctionArgumentClass(APtg, AArgument)はこのバイトをTHashFuncEntry.ArgClassとして公開し、結果1が値クラスを意味します。これらは[MS-XLS] §2.2.2がオペランドトークンに割り当てる3つのクラスそのものであり、エンコーダーも同じく頼っています。参照を書くとき、ptgは$24 + $20 * aClassとして計算され、クラス0はPtgRef、1はPtgRefV、2はPtgRefAになります。Excelが書いたBIFFファイルはそのクラスをすべての参照トークンに保存しているため、テーブルが仕様と一致するエンジンは、データを見ずに「この引数はスカラーか」に答えられます。SUMIFの中引数は判定基準であり、値です。第1と第3は領域、つまり参照です。SUMPRODUCTは関数レベルのクラス2、配列で登録されているため、=SUMPRODUCT(Vertical,Vertical)は依然として領域全体を乗算します

3つの関数は、最初の引数より後について自分のテーブルエントリを参照しません。IF(ptg 1)、CHOOSE(ptg 100)、IFERROR(ptg 255)は選択したものをそのまま通すため、分岐の引数は関数自身が占める位置のクラスを継承します。この1つのルールのおかげで、G2の=CHOOSE(1,Vertical,0)はA2に解決し、その隣の=SUMIF(Vertical,">0",Vertical)は依然として両方の行を合計します。そしてこれは元利均等返済スケジュールが最も多く行使するルールです。各期のセルが、ローンがまだ継続中かどうかをIFで判定しているからです

HotXLSが暗黙の交差のために引数クラスを読む場所:IFは100、SUMIFは010、VLOOKUPは1011を登録し、SUMは何も登録しないため引数はクラス0にフォールバックします。エンコーダーは参照トークンをptg $24 + $20 × クラスとして書き出し、PtgRef、PtgRefV、PtgRefAを生成します。パススルー関数のIF、CHOOSE、IFERRORは自分が占める位置のクラスを継承します
クラステーブルが仕様と一致しているため、エンジンはデータを見ずに引数がスカラーかどうかを答えられます。CHOOSEがA2に解決し、隣のSUMIFが両方の行を合計するという挙動も、1つのルールから導かれます

依存関係ウォークを通してクラスを運ぶ

lxCalc.pasの依存関係抽出器は、コンパイル済み構文木の上を再帰的に歩くWalkであり、2回存在します。1つはワークブックごとのグラフのためのTXLSCalculator.ExtractDependencies、もう1つはクロスワークブックのグラフのためのExtractWorkspaceDependenciesです。どちらのウォーカーも2つの追加パラメータを運びます。AScalarは数式のルートでTrueから始まり、関数の子ごとにFunctionArgumentClassから再計算され、ptg 1、100、255の分岐引数にはそのまま渡ります。ANameRootは、ウォーカーが名前のコンパイル済み定義へ降りるときにだけTrueになり、SA_GROUPノード、つまり括弧を通ってのみ生き残ります。したがって=A1:A2+1と定義された名前が、素の領域と誤認されることはありません。2つのフラグがSA_RANGEノードでともにTrueのとき、AddResolvedRangeは依存関係を記録する前に、評価器が使うのと同じヘルパーで領域を絞ります。ヘルパーは全文を引用できるほど短いものです

HotXLSで名前の依存関係を守るIntersectNamedScalarRangeの判定:すでに1セルの領域はそのまま通り、単一列はCurRowが範囲内にあるとき数式の行に絞られ、単一の行は数式の列に絞られ、それ以外(2次元の領域や範囲外の行)は評価時に#VALUE!となり依存関係を一切記録しません
2つの依存関係ウォーカーと評価器が同じヘルパーを呼ぶため、数式が読む値とグラフが記録する辺が、交差した名前について食い違うことはありません
function IntersectNamedScalarRange(CurRow, CurCol: Integer;
  var Row1, Row2, Col1, Col2: Integer): Boolean;
begin
  Result := False;
  if (Row1 = Row2) and (Col1 = Col2) then Exit(True);   // すでに1セル
  if (Col1 = Col2) and (CurRow >= Row1) and (CurRow <= Row2) then
  begin
    Row1 := CurRow; Row2 := CurRow;                     // 単一列:この行を取る
    Exit(True);
  end;
  if (Row1 = Row2) and (CurCol >= Col1) and (CurCol <= Col2) then
  begin
    Col1 := CurCol; Col2 := CurCol;                     // 単一行:この列を取る
    Result := True;
  end;
end;

ヘルパーが拒むもの、つまり2次元の領域、複数シートの参照、名前付き列の外の行に住む数式は、評価側では#VALUE!を生み、グラフ側では依存関係をまったく記録しません。Excelが空の交差に対してすることです。評価側はTXLSCalculator.GetValueItemNameに住んでいます。コンパイル済み定義からSA_GROUPの包装を剥がし、ルートがSA_RANGEならGetRangeInfoを呼び、交差し、定義全体を評価する代わりにFGetValueでその1セルを取ってきます。外部参照は通常の経路を通ります。交差させるべきローカルな行がないからです。名前の保存とスコープがそもそもどこから来るかは、定義名とクロスシート数式の記事が扱います。ここでの要点は、名前が解決されたあとエンジンが何をするかだけです

MATCHは半分しか計算されていない列を待たねばならないのか

MATCHのルックアップ配列引数はスキャン参照であり、スキャン参照は意図的にハードな評価順序の外に置かれているからです。ルックアップスキャンの記事がTXLSDepRange.LookupScanを導入し、スキャン辺を順序から外すことで何を犠牲にするかを明示しています。ルックアップ数式は、範囲内のすべてのセルが再計算される前に走り、古い値を読むかもしれない。対話セッションでは次のパスで収束します。キャッシュが古いテンプレートの単発バッチ再計算では収束しません。=MATCH(0.01,Balances,-1)+1と定義されたPaymentCountは、残高列にまだ座っている古い値を読み、正しいはずのない支払回数を返します

そこでTXLSDepGraph.TopoOrderはスキャン辺をソフトな順序辺として扱います。ハードな入次数のほかにScanInDeg配列を保ち、ノードごとの汚れたスキャン先行を数え、それらが吐き出されるたびに減らします。グラフがすでに保存しているScanPrecedents、ScanDependents、ScanPrecedentCountの各リストを使います。各反復でKahnキューは準備完了ウィンドウを走査し、ScanInDegがゼロの最初のノードを先頭へ入れ替えます。準備完了ノードすべてがまだスキャン先行を待っているなら、先頭は安定順のまま抜けます。スキャン辺はハードな入次数に決して入らないため、自分の列の上の自己参照VLOOKUPも依然として合法ですが、終わらせられる先行を待てるルックアップは待ちます。古い値を持つ残高セル3つの状態ではPaymentCountは3で返り、入力をゼロに振ると=IFERROR(PaymentCount,99)は#N/Aを見て99を返します

ネストした演算がDoubleへ強制される理由

DelphiのVariant演算には、ネストした位置でだけ顔を出す罠があるからです。トップレベルの+や-を2つのDoubleローカルへ写せば、=B1-A1は正確です。=IF(TRUE,B1-A1,0)の内側では、素直な実装は同じ減算を2つのVariantでValue := Value - SubValueとして走らせ、片方がInt64のセル値、もう片方がDoubleのとき、私たちが観察した結果はCurrency、小数4桁の固定小数点型でした。1066.1854641400994引く120は、小数4桁に切り捨てて返ってきます。すべての支払が前の行から複利されるスケジュールでは、この誤差は合計に届くまで何百もの期間を歩きます。だからこそTXLSCalculator.GetValueItemは、演算子が走る前に両オペランドを変換するのです

// TXLSCalculator.GetValueItem、二項演算の分岐(lxCalc.pas)
if VarIsNull(Value) then Value := 0;
if VarIsNull(SubValue) then SubValue := 0;
// Int64とDoubleが混在するVariant演算はCurrencyへ昇格することがある。
// 表計算の演算は浮動小数点精度を保たなければならない。
if VarIsNumeric(Value) then Value := Double(Value);
if VarIsNumeric(SubValue) then SubValue := Double(SubValue);

このガードはSA_ADD、SA_SUB、SA_MUL、SA_DIVのどれにも先立って走るため、A1にInt64(120)、B1に1066.1854641400994があれば、ネストした差、和、積、商はすべて倍精度を保ちます。HotXLSは、RTLがコンパイラのバージョンをまたいで混在Variant型に適用する昇格規則をすべて知しているとは主張しません。表計算の演算をIEEE doubleとして扱い、演算子が見る前に両オペランドをdoubleへ変えます。これで問い自体が消えます

名前付き列についてエンジンが保証するもの

3つの機構が揃った状態で、両方のエンジンアーキテクチャは、返済テンプレートを古いキャッシュからlxOkまで再計算し、4805個すべての値が、行ごとの独立した期待値に1E-7以内で一致します。そこへ行くのに、反復は有効化されず、エラーコードは1つも握り潰されていません。名前を通る本物の循環、B1が依然としてVerticalを読んでいるA1の=B1は、あるべき姿のとおり、今もエラーを返します

規則はExcelに従います。暗黙の交差は、括弧を剥いだあとのコンパイル済み定義が、単一シート上の単一列か単一行の領域である名前に適用されます。スカラー位置の2次元名は、Excelと同じく#VALUE!です。そしてテーブルが知らない関数はFunctionArgumentClassからクラス0を受け取るため、その名前引数は丸ごと展開されます。ソフトな順序付けは選好であって保証ではありません。スキャンのみの循環は安定順で評価され、キャッシュにあるものを読みます。これはルックアップスキャンの記事が意図的に受け入れたトレードオフです。HotXLSは、ExcelなしでXLS、XLSX、ODS、CSVを読み、再計算し、書き出すネイティブのDelphiおよびC++Builderスプレッドシートコンポーネントです。名前の交差、引数クラステーブル、ソフトなスキャン順序は、計算エンジンが共有されているため、どの形式にも当てはまり、現在の関数カバレッジはHotXLS Delphiスプレッドシートコンポーネントの製品ページに一覧があります