技術記事

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

列全体を参照する定義名は、スカラー位置に現れるとExcelでは単一セルとして読まれます。7行目にある=Vertical+1は「Verticalの7行目のセル」を意味し、領域全体を意味しません。HotXLS Delphi Componentはv2.382.4でこの暗黙の交差を、評価時と依存関係抽出時の2つのレベルで適用しています。4805個の数式を持つローン計算テンプレートが、値を正しく求めるだけでは足りないことを示したからです。依存関係ウォーカーが名前を領域全体に展開すると、その領域のいずれかのセルへ値を渡す下流の数式が、存在しない循環を閉じてしまい、TXLSXWorkbook.Recalculateがワークブック全体を拒否します

問題のテンプレートは、よくあるローン返済スケジュールのワークブックです。キャッシュされた値をすべて777に汚染して完全なRecalculateを実行すると、両方のエンジンアーキテクチャが23、つまり循環参照を表すlxErrorRefを返しました。4805個の数式のうち3842個が独立した期待値と一致せず、B18は#VALUE!、E18は777のまま、J7の支払回数は未完成の残高列にあるプレースホルダーを読んでいました。1つの戻り値の裏に3つの別々の不具合が隠れており、この記事ではそれぞれを、それを修正したソースとともに見ていきます

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

依存関係グラフは辺しか知らないからです。数式から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が記述しているとおりです

HotXLSで列名が偽の循環を閉じてしまう仕組み:VerticalがInputs!$A$1:$A$2として定義されていると、ウォーカーはB1がA1:A2に依存すると記録し、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
      // v2.382.4より前はこの分岐に到達しなかった: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か所に存在します。ワークブック単位のグラフ用がTXLSCalculator.ExtractDependencies、ワークブック間のグラフ用がExtractWorkspaceDependenciesです。v2.382.4は両方のウォーカーに2つのパラメーターを追加しました。AScalarは数式のルートでTrueとして始まり、各関数の子についてFunctionArgumentClassから再計算され、ptg 1、100、255の分岐引数ではそのまま渡されます。ANameRootはウォーカーが名前のコンパイル済み定義へ降りたときだけTrueになり、SA_GROUPノード、つまり括弧を通してのみ生き残るため、=A1:A2+1として定義された名前が単なる領域と誤解されることはありません。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が777を読んだ理由

MATCHの検索配列引数がスキャン参照であり、スキャン参照は評価順序から意図的に除外されていたからです。ルックアップスキャンの記事ではTXLSDepRange.LookupScanを導入し、「スキャン辺を順序付けから除外することで何を諦めるか」という節で締めくくりました。ルックアップ数式は、その範囲のすべてのセルが再計算される前に走り、古い値を読む可能性があります。対話的なセッションでは次のパスで収束します。汚染されたテンプレートのバッチ再計算ではそうならず、=MATCH(0.01,Balances,-1)+1として定義されたPaymentCountは、残高列に残ったままの777のプレースホルダーを読み、正しくあり得ない期数を返しました

TXLSDepGraph.TopoOrderはスキャン辺をソフトな順序付け辺として扱うようになりました。ハードな入次数とは別にScanInDeg配列を保持し、ノードごとにダーティなスキャン先行ノードを数え、それらが出力されるたびに減らします。以前の変更ですでに保存していたScanPrecedents、ScanDependents、ScanPrecedentCountのリストを使います。各反復でKahnキューは準備完了ウィンドウを走査し、ScanInDegがゼロの最初のノードを先頭に交換します。準備完了のノードがすべてスキャン先行ノードを待っている場合は、先頭を安定した順序で取り出します。スキャン辺はハードな入次数には決して入らないため、自分の列に対する自己参照的なVLOOKUPは依然として合法ですが、完了可能な先行ノードを待てるルックアップは待つようになりました。これを固定するリグレッションLookupScan_WaitsForDirtyFormulaValuesは、3つの残高セルを777に汚染してPaymentCountが3を返すことを期待し、次に入力をゼロに切り替えて=IFERROR(PaymentCount,99)が#N/Aを見て99を返すことを期待します

小数4桁への切り捨てはどこから来たのか

DelphiのVariant演算からです。しかもネストした位置でのみ発生しました。TXLSCalculator.GetValueItemの二項演算子は、トップレベルの+や-をすでに2つのDoubleローカル変数へコピーしていたため、=B1-A1は問題ありませんでした。=IF(TRUE,B1-A1,0)の中では同じ減算が2つのVariantに対するValue := Value - SubValueとして走り、一方のオペランドがInt64のセル値でもう一方がDoubleのとき、観測された結果はCurrency、つまり小数点以下4桁の固定小数点型でした。そのため1066.1854641400994から120を引いた値は4桁に切り捨てられて返りました。すべての支払が前行から複利計算されるスケジュールでは、この誤差は合計に届くまでに数百期を歩き続けます

// 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のいずれの前にも走ります。リグレッションArithmetic_MixedInt64AndDoubleKeepsPrecisionは、A1にInt64(120)を、B1に1066.1854641400994を格納し、ネストした差と和を1E-10で、積と商を1E-8と1E-12で検査します。HotXLSは、コンパイラのバージョンによってRTLが混合Variant型に適用するすべての昇格ルールを知っているとは主張しません。主張するのは、表計算の演算はIEEEのdoubleであるということで、演算子が両オペランドを見る前にdoubleにすることで、その問い自体を取り除いています

この修正が保証するもの、しないもの

v2.382.4以降、両方のエンジンアーキテクチャが汚染されたテンプレートに対してlxOkを返し、4805個のキャッシュ値すべてが独立した行単位の期待値と1E-7以内で一致し、キャッシュが本当に汚染されていたこと、ソースのハッシュが変わっていないこと、すべての数式が依然として存在することのアサーションもすべて成立します。そのために反復計算を有効にしたり、エラーコードを握りつぶしたりはしていません。名前を経由する本物の循環、つまりA1の=B1でB1が依然としてVerticalを読んでいる場合は、今もエラーを返します。テストNamedScalarRanges_IntersectWithoutFalseCyclesは、まさにそれをアサートして終わります

境界は率直に述べておく価値があります。暗黙の交差が適用されるのは、コンパイル済み定義が括弧を取り除いた後に1シート上の単一列または単一行の領域である名前だけです。スカラー位置にある2次元の名前は、Excelと同様に#VALUE!になります。また、テーブルが知らない関数はFunctionArgumentClassからクラス0を得るため、その名前引数は依然として完全に展開されます。ソフトな順序付けは保証ではなく優先です。スキャンのみの循環は安定した順序で評価され、キャッシュされているものをそのまま読みます。これはルックアップスキャンの記事が意図的に受け入れた挙動です。そしてテンプレート全体の結果は、別の表計算エンジンではなく独立した期待値スクリプトと比較して検証しています。参照用のオフィススイートが元のテンプレートを60秒の予算内で再計算し終えなかったからです。HotXLSはExcelをインストールせずにXLS、XLSX、ODS、CSVを読み、再計算し、書き出すネイティブのDelphi/C++Builderスプレッドシートコンポーネントです。名前の交差、引数クラステーブル、ソフトなスキャン順序は計算エンジンを共有しているためすべての形式に適用され、現在の関数カバレッジはHotXLS Delphiスプレッドシートコンポーネントの製品ページに掲載されています