Treating Excel as the Spec
Excel互換の計算エンジンを2週間で書いた。=VLOOKUP や =SUMPRODUCT が Microsoft Excel と同じ結果を返すように、ASTを組み、関数を実装し、テストを書く。formulon という小さなプロジェクトだ。
ASTとは
Abstract Syntax Tree、抽象構文木の略。プログラムや式を木構造で表現したデータ。たとえば =A1+B1*2 という式は「+ の左に A1、右に * のノード(* の左に B1、右に 2)」のような木に展開される。計算エンジンは元の文字列ではなく、このASTを使って評価する。
書き始めて気づいたのは、仕様書がないということだった。
正確に言えば、ファイル形式の仕様書(ECMA-376 / OOXML)はある。XMLのスキーマも、セルの座標表現も、スタイル情報の格納形式も、全部書いてある。しかし「=SUMPRODUCT(A1:A10, B1:B10) が引数の片方にエラー値を含むときに何を返すか」「=PERCENTILE.EXC(data, 0.95) の境界での丸めはどうなるか」「=VLOOKUP で半角カナの濁点はマッチ対象になるか」——こうした計算セマンティクスは、どこにも書かれていない。
ECMA-376 / OOXMLとは
Office Open XML の仕様書。Microsoft Office のファイル形式(.xlsx, .docx, .pptx)が ZIP の中の XML 群でどう表現されるかを定義している。ECMA International が標準化し、ISO/IEC 29500 としても採択されている。ファイルの構造は規定するが、Excelが計算するときの挙動は規定しない。たとえば「セルにこの式が入っている」とは書けるが、「この式を評価したらこの値になる」とは書かれていない。
書かれていないものをどう実装するか。最初は MSDN を漁った。だが MSDN に書いてある内容と実物の Excel の挙動が食い違うことが、すぐに分かった。さらに悪いことに、Excel 365 と Excel 2019 で同じ関数が違う結果を返す。OFFSET の負の次元、SHEETS の引数型エラー、半角カナの濁点合成——どれもバージョン依存だ。
2週間で書き切るには、仕様書を読み解いてゼロから実装する余裕などない。仕様書を信じる発想を捨て、実物を「正解」として扱う方が、圧倒的に安い。
これは、その方法論についてのメモだ。
golden file の限界
最初に試したのは、golden file 方式だった。
golden file / スナップショットテストとは
あらかじめ「正解の出力」をファイルに保存しておき(これを golden file と呼ぶ)、テスト実行時の出力と一致するかを確認する手法。React コンポーネントの jest スナップショットテストや、CLIツールの出力比較などで使われる。前提は「同じ入力からは常に同じ出力が出る」こと。
Excel に何百個か式を入れて評価させ、結果を JSON にダンプして、テストでそれと付き合わせる。Web エンジニアにはおなじみのスナップショットテストだ。最初の数百ケースまでは順調だった。
ところが、Excel のアップデートが入ると goldens が腐る。
ある日 =ARRAYTOTEXT("") の出力が変わった。"" だったのが [] に変わったのか、空文字に戻ったのか、定かではない。golden が一致しないという報告が出たとき、それが自分のリグレッションなのか、Excel側の挙動変更なのかを切り分けられない。
ARRAYTOTEXT とは
Excel 365 で追加された関数。配列を文字列に変換する。=ARRAYTOTEXT({1,2,3}) で "1, 2, 3" のような結果を返す。Excel 2019 以前ではそもそも関数として認識されず #NAME? エラーになる。この記事の後半で「相手が Excel 365 か 2019 か」を判定するための sentinel(番兵)式として使われる。
リグレッションとは
過去に動いていたコードが、何らかの変更で動かなくなる現象。新機能の追加や修正の副作用で、それまで通っていたテストが落ちる状況を指す。「回帰バグ」とも呼ぶ。
スナップショットテストの根本的な前提——「相手は不動である」——が、Excel には通用しない。Excel はライブな製品で、毎月ビルドが上がる。365 と 2019 と 2016 で挙動が違う。ja-JP と en-US と de-DE で挙動が違う。さらに macOS 版と Windows 版でも違う。
ja-JP / en-US / de-DE
ロケール(言語と地域)の識別コード。BCP 47 という規格に従う。ja-JP は日本語の日本、en-US は英語のアメリカ、de-DE はドイツ語のドイツ。Excel のエラーメッセージ(#VALUE! がドイツ語版では #WERT!、フランス語版では #VALEUR!)や、数値書式(小数点が . か , か)、関数引数の区切り文字(, か ; か)が、このロケールに応じて変わる。
静的な goldens を維持し続けるコストが、テストが守るべき価値を上回り始めた。
ライブオラクル
そこで方針を変えた。Excel 自身を、テスト実行時に動かして、結果を取り出す。
テストハーネスは Linux 上(具体的には CI と開発機が WSL2)にある。Excel は Windows でしか動かない。COM 自動化(xlwings 経由)で Windows 側の Python から Excel を叩き、結果を Linux 側のテストハーネスに返す。各テストケースは、自分の計算結果と Excel の計算結果を毎回その場で突き合わせる。
WSL2 とは
Windows Subsystem for Linux 2 の略。Windows 上で本物の Linux カーネルを軽量 VM で動かす仕組み。/mnt/c/... で Windows のファイルシステムにアクセスでき、Windows 側の .exe を WSL2 のシェルから呼び出すこともできる。「開発環境は Linux、ただし Excel など Windows ネイティブのアプリと連携したい」という需要に応える。
COM 自動化 / xlwings とは
COM (Component Object Model) は Windows のプロセス間通信の仕組み。Excel や Word などの Office アプリは、外部プログラムから操作するための API を COM で提供している。xlwings は Python から COM 経由で Excel を操作するライブラリで、app.books.add() や sheet.range('A1').value = 1 のような直感的な書き方ができる。VBA でできることはだいたい xlwings からもできる。
これで「Excel が変わった」のか「自分が壊した」のかが切り分けられる。Excel が変わったなら、Excel 側の出力も変わる。両者が同じだけ変われば乖離(divergence)は出ない。一方だけ変わったら、変わったほうが原因と特定できる。
divergence(乖離)
formulon では、自分の実装と Excel の挙動が一致しないことを「divergence」と呼ぶ。tests/divergence.yaml という台帳ファイルに、どのケースで・どんな理由で乖離が起きているかを記録する。記事の後半でこの台帳の使い方を詳しく説明する。
ただし、この方式には2つの大きな落とし穴がある。
92スイートをコールドスタートしない
最初の素朴な実装では、run_suite() を呼ぶたびに Excel のプロセスを起動していた。各スイートの開始時に xlwings.App(visible=False) が走り、Excel が立ち上がり、テンポラリのワークブックが作られる。コールドスタートで5〜15秒。
スイートは92個あった。
92 × 10秒 = 約15分。これがテスト実行の度に上乗せされる。Excel が起動する間、Linux 側の CPU は遊んでいる。Windows 側の CPU は Excel のスプラッシュ画面を描画している。誰の役にも立っていない時間だ。
解決は persistent stdio server。Windows 側の Python を --serve モードで起動し、stdin から JSON のリクエストを読み、stdout に結果を返し続ける。Excel のプロセスは1度だけ起動して、全スイートで共有する。
これで92スイートが1コールドスタートで済む。実時間で15分が30秒になった。
実装中に1つトラップを踏んだ。Windows の Python はデフォルトで標準入出力の文字コードがロケールに依存する。ja-JP の Windows なら CP932 になる。Excel の COM がエラーを返すと "例外が発生しました。" のような日本語メッセージが返ってくるが、これを CP932 でエンコードされたまま WSL2 側で UTF-8 として読むと文字化けする。
CP932 / WSLENV / PYTHONUTF8
CP932: Windows の日本語ロケール(ja-JP)で使われる文字コード。Shift_JIS をベースに Microsoft が拡張したもの。
WSLENV: WSL2 が Windows 側のプロセスに引き継ぐ環境変数を列挙するための変数。たとえば WSLENV=PATH/l:FOO/u のように書く。デフォルトでは Linux 側の環境変数はほとんど Windows 側に伝わらない。
PYTHONUTF8: Python の「UTF-8 モード」を有効化する環境変数。Python 3.7 以降で使える。PYTHONUTF8=1 にすると、ロケール設定を無視して標準入出力やファイル読み書きが UTF-8 になる。
PYTHONUTF8=1 を環境変数で渡せばいい——と思うが、ダメだった。WSL2 は WSLENV に列挙された環境変数しか Windows 側に転送しない。PYTHONUTF8 をデフォルトで転送する仕組みはない。
代わりに使ったのが python.exe -X utf8=1。コマンドライン引数なら WSLENV の制約をすり抜けて、確実に Python の UTF-8 モードを有効化できる。formulon の wsl_bridge.py には、この発見と検証の経緯がコメントとして残っている。
もう1つトラップを踏んだ。COM 経由でセルのテキスト表示を取るために cell.api.Text を呼んでいる。エラーセルは .value が None になる場合があるので、表示テキストから #DIV/0! などを判定する必要がある。
防御的に「Text がダメだったら text (小文字)も試そう」と書いてしまった瞬間、地雷を踏んだ。
Excel の IDispatch のレベルでは、大文字小文字を区別せず Text と text は同じ DISPID に解決される。問題はその上の win32com 側にあった。COMRetryObjectWrapper には、未知の小文字属性を見つけると無限リトライするパスがある。=ARRAYTOTEXT("") のように正規の結果が空文字になるケースで、Text が "" を返したあと text も試され、リトライループに入る。テストランは30分以上ハングし、Excel の CPU 使用率が60%に張り付いた。
IDispatch / DISPID / COMRetryObjectWrapper
IDispatch: COM のレイトバインディング(実行時に名前で関数を解決する)の仕組み。属性名を GetIDsOfNames で DISPID という整数 ID に変換し、Invoke で呼び出す。Excel の自動化 API はこの IDispatch を経由してアクセスされる。
COMRetryObjectWrapper: Python の win32com ライブラリが提供する、COM 呼び出しに自動リトライを挟むラッパー。Excel が一時的にビジー("call was rejected by callee")を返すケースに備えて、属性アクセスを内部でリトライする。便利だが、未知の属性名や挙動の境界条件で抜け出せないループに入ることがある。
原因にたどり着くまで、まる1日かかった。修正は単純で、Text だけを試して fallback をやめた。教訓は「COM 経由で何が未定義の挙動かは読めない」。動いているように見えても、防御的なコードが地雷だったりする。
Application state leakage
二つ目の落とし穴は、もっと厄介だった。
COM オブジェクトは、プロセスローカルなグローバル状態を持つ。xlwings.App を介して見える Application インスタンスは、ウィンドウのズーム、表示モード、計算モード、印刷プレビューの状態、すべてを内部に抱える。これらはワークブックを閉じても消えない。次のワークブックに引き継がれる。
具体的にハマったのが PageBreakPreview だった。
PageBreakPreview とは
Excel の画面表示モードのひとつ。「改ページプレビュー」と呼ばれる日本語版の機能。通常の編集ビューでは、画面の物理サイズに合わせた目安の改ページ線が表示されるだけで、印刷時の実際のページ区切りとは一致しない。PageBreakPreview に切り替えると、Excel が本物のページネーション(印刷時の改ページ計算)を走らせて、青い実線で実際のページ境界を表示する。プログラムから印刷レイアウトを正しく読み取るには、このモードに切り替えてから HPageBreaks / VPageBreaks / Pages.Count を読む必要がある。
ページ区切りを取得するには、ExcelをPageBreakPreviewモード(略してPBP)に切り替える必要がある。通常の編集ビューでは Pages.Count がディスプレイのズームに引きずられて不正確な値を返すからだ。PBPに切り替えると、本物のページネーションが走って、HPageBreaks(水平改ページ)と VPageBreaks(垂直改ページ)の数が物理印刷と一致する値を返してくれる。
最初は素直に「ケースごとに PBP に切り替えて、読んで、戻す」と書いた。
しばらく安定して動いていたが、ある日、同じケースが、スイート内の位置によって違う結果を返すことに気づいた。zoom_50_dense_fit_off を単独で走らせると pages: 2、別のケースの後で走らせると pages: 3。再現性100%。
調べてみると、PBP のウィンドウズームは Application が抱えているグローバル状態だった。前のケースが PBP のズームを変えていると、次のケースの読み取りに影響する。「ケースごとに状態をリセット」しているつもりだったが、リセットが浅かった。
直したのは、PBP ズームを毎回明示的に60(デフォルト値)に固定する処理を入れることだった。これは仕様書には書かれていない。Excel の COM の暗黙の仮定だ。
Webサーバの世界に喩えるなら、これは「リクエストごとに新しいリクエストオブジェクトを作っているのに、なぜか前のリクエストの env が一部残っている」状態だ。プロセスローカルなグローバル変数を、暗黙のうちに踏んでいるイメージ。マルチテナントが前提のWebでは見ない種類のバグだが、デスクトップアプリの自動化では避けられない。
| 場面 | Webの感覚 | Excel COMの実態 |
|---|---|---|
| プロセス | リクエスト単位で破棄 | 起動から終了まで永続 |
| グローバル状態 | リクエスト間で原則ゼロ | Application オブジェクトが大量に保持 |
| リセット境界 | 明示的(new request) | 暗黙(明示しないと残る) |
相手が誰かを毎回確認する
ライブオラクル方式の隠れた美点は、「相手が誰か」を起動時に確認できることだ。
Microsoft 365 と Excel 2019 の違い
Microsoft 365 (M365): サブスクリプション型で提供される Excel のエディション。月次でアップデートが入り、新しい関数(ARRAYTOTEXT、FILTER、LET、LAMBDA など)や機能(動的配列、SPILL、3D参照の拡張)が継続的に追加される。
Excel 2019 / 2016: 買い切り版(永続ライセンス)。リリース時点の関数セットで固定される。365 にある新しい関数の多くは存在せず、入力すると #NAME? エラーになる。
formulon は M365 を前提とする。Excel 2019 を相手にオラクル生成を走らせると、新関数が存在しないため大量のテストケースが意味のないエラーで埋まってしまう。
Excel 365 と Excel 2019 では、使える関数も、同じ関数の挙動も違う。ARRAYTOTEXT は 365 にあって 2019 にない。SHEETS(text-arg) は 2019 では #VALUE! を返すが、365 では別の挙動をする。OFFSET の負の次元、半角カナ濁点の VLOOKUP 照合、3D参照のサポート——どれも 365 と 2019 で違う。
これらをまとめて「Excel」として扱うと、乖離の原因が「自分の実装が間違っている」のか「相手のバージョンが違う」のかが混ざる。
formulon では、オラクル生成の起点で sentinel(番兵)式を投げる。
sht.range("A1").formula2 = "=ARRAYTOTEXT(1)"
app.calculate()
v = sht.range("A1").value
if isinstance(v, str) and v == "#NAME?":
raise RuntimeError(
"Excel does not recognise ARRAYTOTEXT — this Excel install is "
"pre-M365 (Office 2019 or earlier). Formulon's oracle requires "
"Microsoft 365."
)たった1行の式で、相手が M365 か Office 2019 以前かが判定できる。#NAME? が返ってきたら中断、それ以外なら通常進行。
判定結果は goldens のディレクトリ名にも反映される。tests/oracle/variants/win-365-ja_JP/ と tests/oracle/variants/win-2019-ja_JP/ のように、バージョン × ロケールの2軸で goldens を分けている。同じ式でも、相手が違えば期待値も違う。
これがないと、乖離台帳は破綻する。バージョン違いの挙動が同じ「Excel と違う」という報告で上がってくると、優先順位がつけられない。
経験則発見 — axis を1本ずつ振る
ここからが本題だ。
Excel の挙動のうち、仕様書にもMSDNにも書かれていない領域——たとえば印刷時のページネーション——のルールを、どう見つけるか。
素朴な方法は、設定の組み合わせを100通りくらい用意してgoldensを撮り、自分の実装と差分を取ることだ。これは機能しない。100ケースが100通りに違うので、何が原因で何が結果か分からない。
formulon の print_matrix スイートは、別のアプローチを取った。1ケースごとに1軸だけを動かす。
| 軸 | 動かす変数 | 固定する変数 | わかること |
|---|---|---|---|
| Axis A | Zoom ∈ | FitToPages OFF, 高密度データ | Zoom 単独が break数に与える影響 |
| Axis B | FitToPages ∈ | Zoom 100, 高密度データ | Fit と Zoom の優先順位 |
| Axis C | データ密度 ∈ | 固定の geometry | 空列が page break をどう抑制するか |
| Axis D | PrintTitleRows 深さ ∈ | 40行の高密度グリッド | ヘッダ行が本文容量を何ポイント削るか |
| Axis E | Scale × 垂直extent の組み合わせ | 他は固定 | Scale が縦方向の break にどう効くか |
各軸の中で、変数を変えた結果の差分を見る。Axis A なら4ケース、Axis D なら3ケース。その軸を変えたことの効果が、他の変数のノイズなしに見える。
これは実験計画法の発想だ。多変数のシステムで各変数の効果を分離するには、1変数ずつ動かす。教科書的だが、ソフトウェアテストの現場で意識的にやっている例は少ない。
実験計画法とは
多変数システムの挙動を、効率よく実験で解明するための方法論。「変数を1本ずつ振る (One-Factor-At-a-Time)」「直交表 (orthogonal array) で組み合わせを刈り込む」などの手法がある。製造業の品質管理や農業の収穫量研究から発展した。ソフトウェアテストではペアワイズテスト・組合せテストとして応用例がある。
Axis D で実際に何が分かったか。PrintTitleRows = 1, 3, 5 の3ケースを、A1:D40 のグリッドに対して走らせた。「ヘッダ行を増やすと本文容量が減る」のは直感的に分かる。問題はどれだけ減るかだ。
素朴な仮説は「title rows の物理的な高さ(ポイント)を本文の高さから引く」。1行 15pt なら、5行で 75pt 引かれる。
実測すると、3ケースとも h_breaks=[39] で同じ結果だった。1行でも5行でも、break 位置が変わらない。これは「物理高さを引く」モデルでは説明できない。
何が起きているか。Excel は title rows に対して最低5行ぶんの reserveを持っているらしい。1行しか指定しなくても、内部的には5行ぶんの高さを引く。3行でも5行ぶんを引く。だから3ケースとも結果が同じになる。
これをコードに落としたのが kMinTitleReserveRows = 5.0 という定数だ。本文高さから引く title 高さは、max(実際のtitle_height, 5 × default_row_height) で計算する。実測から導いた数式が、そのまま実装に入る。
このルールは MSDN にも ECMA-376 にも書いていない。Excel の COM の挙動から逆算した。1軸ずつ振ったからこそ抽出できた。
ルール vs Quirk
経験則発見の結果は、2つに分かれる。
ひとつは、コードに落とせる規則。Axis D の min-title-reserve のように、「N行のtitleなら本文から M ポイント引く」という決定的な式に書けるもの。これは src/print/pagination.cpp に実装する。
もうひとつは、規則化できない quirk。Axis A の Zoom = 50, 25 のような低スケール領域では、Excel の VPageBreaks が幾何学的に説明できない位置に column auto-break を入れる。同じ印刷領域、同じスケール、同じデータでも、PageBreakPreview の状態によって break 数が変わる。formulon の幾何学モデルでは、どうやっても再現できない。
print/pagination 系は、Excel 互換ツール群(LibreOffice、OnlyOffice、Aspose 等)の互換性議論でも長年取り上げられている領域だ。完全な互換は誰も達成していない。COM 経由で読める値そのものが、Excel 内部のキャッシュやウィンドウ状態に依存して揺らぐ。これは「実装の問題」ではなく、「相手が決定的でない」という性質の問題に近い。
ではどうするか。formulon は tests/divergence.yaml に理由付きで宣言する。
- id: zoom_50_dense_fit_off
mode: skip-oracle
applies_to: [win-365-ja_JP]
reason: "Excel PBP emits scale<=50 column auto-breaks that don't follow geometric pagination (community-documented COM quirk)."
prefer: formulon
first_noted: 2026-05-17
last_verified_excel_version: "16.0"ポイントは3つ。
mode: skip-oracle: このケースは oracle-gen からも oracle-verify からも除外する。「相手が決定的でないので比較しない」と明示するreason: なぜスキップするのかを自然言語で書く。引用元(コミット、issue、ドキュメント)を必ず添えるprefer: formulon: 「これは自分の実装の方が正しい」と宣言する。prefer: win-excel-365なら相手が正しい
これは「TODO コメント」ではない。機械可読な互換性台帳だ。各エントリは id で oracle harness と紐づき、applies_to でバージョン×ロケールを限定する。oracle_gen.py が読んで挙動を変える。
短期間で書いている最中こそ、この差は効く。「いま実装が間違っているのか」「相手が決定的でないのか」を即座に分類できるかどうかで、デバッグの効率が桁で変わる。TODO コメントだと、書いた本人が翌日には何を保留したのか思い出せない。台帳なら、reason と prefer を読めば一発で分かる。
ライブオラクル方式の隠れた美点は、境界線が常に最新で、自然言語ではなく機械可読だということだ。実装と並行して、互換性の知識が形式化されていく。
境界線を引いて手を止める
正直に書くと、formulon は print/pagination の問題を完全には解決していない。tests/divergence.yaml には現在も zoom_50_dense_fit_off、zoom_25_dense_fit_off、scale_50_short_row など、複数の skip-oracle エントリが残っている。Axis A の低スケール領域、Axis E の scale × 垂直extent の一部組み合わせ。
2週間という持ち時間で、これらを「解決した」と言える状態に持っていくのは無理だった。Excel の挙動を実装側で再現するには、再現する規則性が必要だ。今のところ、COM 経由で見える値そのものが、再現性が怪しい状態でやってくる。
その代わり、Axis D の min-title-reserve のように、規則が見えた領域はコードに落とした。Axis B の FitToPages 優先順位ルールも、Axis C の sparse データの break 抑制も、ほとんどはコード化できた。
つまり2週間で出せた成果は「全部解決した」ことではなく、「規則化できる領域とできない領域の境界線を、機械可読な形で引いた」ことだ。コード化できる箇所はコード化し、できない箇所は台帳に宣言する。できない箇所を宣言できることが、できる箇所に集中するための条件だった。
印刷だけが難所ではない
print/pagination は分かりやすい例だったが、ライブオラクル方式が役立った領域は他にもある。
再計算 (recalc) と依存関係
Excel は、あるセルの値が変わったら、それに依存する全セルを再計算する。B1 = A1 * 2 で A1 の値を変えたら、B1 も自動的に更新される。さらに C1 = B1 + 1 なら、C1 も連鎖して更新される。
この「依存グラフを辿って必要な範囲だけ再計算する」処理が recalc だ。formulon でも当然これを実装する必要があった。
依存グラフ (dependency graph) とは
セル同士の参照関係を、有向グラフとして表現したもの。B1 = A1 * 2 なら「A1 → B1」というエッジが張られる。あるセルが変更されたとき、そのセルを起点に下流を辿って、影響範囲を特定する。ビルドシステム (make, bazel) や React の reactive system と同じ考え方。
苦労したのは並列 recalc とミューテーション(書き込み)の競合だった。テスト中、ある式評価が走っている最中に、別の経路から workbook が書き換えられると、依存グラフが不整合な状態で参照される。Web の世界でいうと、リクエスト処理中にデータベースが他のリクエストから更新されるような状況だ。
修正は LockedMutator という facade パターン。Workbook への書き込み API をすべてこのファサード経由にして、並列 recalc が走っている間は書き込みをシリアライズする。仕様書には「Excel が内部でどうロックを取っているか」など書かれていない。これも実装側で発明するしかなかった。
もう1つ厄介だったのが反復計算 (iterative calculation) だ。Excel は循環参照を許す設定がある。A1 = B1 + 1、B1 = A1 * 0.5 のような相互参照を、デフォルトでは #REF! 系のエラーで拒否するが、設定で「反復計算を有効化」すると、固定小数点までの繰り返し評価モードに切り替わる。
反復計算 (iterative calculation) とは
循環参照を許可する Excel の機能。max_iterations(最大繰り返し回数)と max_change(収束判定の閾値)を指定し、値の変化が閾値未満になるまで全セルを繰り返し評価する。財務モデリングで「目標値から逆算する」(例: ローンの返済額を、元本と利息のバランスから求める)、エンジニアリングシミュレーション、確率モデルなどで使われる。
formulon の実装では evaluate() で固定小数点ループを駆動する。前回パスの値をシード値にして、|delta| < max_change または max_iterations 到達まで再評価を繰り返す。難しかったのは、何が「収束した」と見なせるかの判定。1セル単位か、ワークブック全体か、Excel のドキュメントには書かれていない。ここもライブオラクルで逆引きした。
スピル (SPILL) と動的配列
Excel 365 で導入された動的配列 (Dynamic Arrays) は、計算エンジンの設計を根底から変えた機能だ。=FILTER(A:A, A:A>10) のような式が、1セルから複数セルにわたる結果を返す。結果が下方向・右方向に「こぼれる (spill)」。
スピル (SPILL) とは
Excel 365 で導入された機能。式を入力したセル(アンカーセル)が、配列を返すと、その配列の各要素が周辺のセルに自動的に展開される。たとえば =SEQUENCE(5) を A1 に入力すると、A1〜A5 に 1〜5 が「こぼれる」。アンカーセルだけが式を持ち、こぼれた先のセルは「ゴーストセル」として扱われる。こぼれた先に他の値があると、#SPILL! エラーになる。
スピルの何が難しいか。#SPILL! を判定するタイミングと範囲だ。
アンカーセルを評価して結果の形(何行 × 何列)が分かったら、その範囲を仮想的にスキャンして、すでに値があるセルがないか確認する必要がある。ところが、評価モードによってこの判定の意味が変わる。
| モード | 衝突判定 |
|---|---|
| 通常モード(書き込み可) | 実際に周辺セルを上書きできるかチェックする |
| 読み取り専用モード(オラクル評価) | 上書きできない前提で、衝突を仮想的に検出する |
オラクル評価では spill_would_collide() という関数を実装した。アンカーセルの「予定スピル領域」を、実際には書き込まずにスキャンする。これがないと、=FILTER(...) が常に「衝突なし」と判定されて、本来は #SPILL! を返すべきケースで素のアンカー値が返ってしまう。
スピルの実装は、formulon 全体のセル評価モデルに影響を及ぼした。1つのセルが1つの値を返すという単純な前提が壊れる。アンカー評価とゴーストセル参照を区別する仕組みを、評価エンジンの中核に組み込む必要があった。
ピボットテーブル
ピボットテーブルは、Excel の中でもっとも仕様書のカバレッジが薄い領域のひとつだ。
formulon は v1.0 で評価面のフィーチャーセットを揃えた。標準偏差・分散の集計 (StdDev / Var)、日付グルーピング(年・四半期・月・日でデータを束ねる)、軸フィルタ、show-values-as(合計に対する割合、累積、親行に対する割合、など10種類以上の後段変換)、いずれも実装した。
show-values-as (showDataAs) とは
ピボットテーブルの集計値に対して、後段で適用する変換。% of grand total(合計に対する割合)、running total(累積合計)、% of parent row(親行に対する割合)、difference from(基準値との差分)、など10種類以上ある。集計後の数値を、ユーザーが直感的に読めるよう加工する機能。ECMA-376 では showDataAs="percentOfTotal" のような属性名だけが定義されていて、実際の計算ルールは書かれていない。
これらの挙動は ECMA-376 にはほぼ書かれていない。showDataAs="percentOfTotal" という属性名が定義されているだけで、どう計算するかは実装依存。「親行に対する割合」とは何か、複数階層の入れ子グループでどう振る舞うか——全部実装側で逆引きする必要がある。
さらに厄介だったのが、OOXML 拡張要素だ。ピボットテーブルの XML には、Microsoft 独自の拡張が <extLst> という袋に入って混入する。<extLst> の中に未定義の要素が来ても、ファイルを読み書きするときにそのまま保持しないと壊れる。formulon は「未知の拡張は不透明な XML として保持し、書き出すときにそのまま吐く」方針を取った。スルーパスを設けないと、Excel で開き直したときに「ファイルが破損しています」と言われる。
ピボットには locale 依存もある。日本語版で表示される「総計」「(空白)」のような文字列は、locale layer で出し分ける。これがないと、ja-JP の goldens が en-US の formulon 実装と一致しない。
recalc・スピル・ピボット——これらに共通するのは、ECMA-376 のカバレッジが薄く、単一セルの評価で閉じず、状態が静的でないという3点だ。ライブオラクル方式が効くのは、こうした「単一の関数を検証する」のとは異なる、システム横断的な振る舞いの正解を Excel に問い合わせられる点だ。仕様が薄い領域で、「正解はあちら」と決めてしまえる。そのぶん、自分の中で「どうあるべきか」の議論に時間を溶かさずに済む。
短期開発における示唆
仕様書と実装が乖離する世界では、実物を仕様として扱う方が安いことがある。Excel に限らず、ブラウザの古い CSS の挙動、OS のシステムコール、レガシーAPIの暗黙挙動——「ドキュメントは古い、コードが真実」という領域はどこにでもある。
時間が限られているほど、この発想は効く。仕様書を信じてゼロから書き始めると、最初の =SUMPRODUCT 1つで数日溶ける。実物を叩いて golden と比較する方が、構築は重いが、ケースごとの実装は速い。
ライブオラクル方式は、その種の問題に対する一つの解だ。ただし、コールドスタートを殺さないと回らない(永続 stdio)、グローバル状態の引き継ぎを潰さないと安定しない(state leakage)、相手のバージョンを毎回確認しないと混乱する(sentinel)——この3点セットは外せない。これらを最初に組んでおかないと、テスト実行の遅さや不安定さが開発のペースを律速する。
経験則の発見は、1軸ずつ振る。多軸同時に振ると、何が原因か永久に分からない。短期開発では、迷っている時間がそのままスケジュールを食う。
そして、ルールにならない quirk(クセ)は、divergence ledger(乖離台帳)に宣言する。宣言できると、実装の対象範囲が明確になる。「ここはやらない」と書けることが、「ここはやる」に集中する条件になる。
Excel の挙動を完璧に再現するのは、たぶん誰にもできない。2週間でできることは限られているし、もっと時間をかけても限界はある。けれど、「どこまで再現できて、どこから諦めるか」を明示的に書き残すことはできる。実物が仕様である世界では、それが結果的に一番速い。