概要
Goの関数の長さと、明示的に指定した関数の純粋性に関する制約を検査するmikotoを作った。関数宣言へ//mikoto:pureを付けると、値型だけを用いた入出力、パッケージ変数へのアクセス、参照先の変更、ポインタの間接参照、呼び出し先などを検査する。実装にはGoのASTと型情報を使い、検査対象を同一パッケージ内の関数宣言に限定している。
背景にあるのは、コードの量が増えたとき、設計上の意図を毎回人間が読み直して確認することの難しさだ。「この関数は入力から結果を計算するだけ」という意図があっても、後から時刻の参照や共有状態の更新が加われば、その前提は変わる。そこで、守りたい制約をソースへ明示し、実装がその制約から外れたときに診断する形にした。
初期実装を検証すると、型パラメータを介したmapの走査や、配列ポインタの暗黙の間接参照を見逃す一方、正当なジェネリック呼び出しや括弧付き操作を拒否する問題が見つかった。PR #6ではこれらと行数計算の問題を修正した。この記事では、検査モデル、実装、修正例、検証結果を順に示し、診断がないことから何を言えて、何を言えないかを考える。
以下は学術論文のように問題設定と評価を分けた開発記録であり、一般的なGoプログラムの参照透過性を証明した報告ではない。対象は2026年9月30日のmain、commit 2235f226aaである。提案する使い方と、実装で確認できる動作も区別して書く。
1. 背景と問題設定
1.1 実装量と確認量は同じ速度で増えない
AI Codingとコードレビューについての記事では、人間がすべてのコードを同じ密度で読み続けることに依存せず、仕様、テスト、静的解析、観測性を強くするという考え方を書いた。mikotoは、そのうち機械的に確認できる設計上の制約を、小さい範囲から検査する試みになる。
コードレビューでは、個々の式の正しさだけでなく、関数の役割が変わっていないかも確認する。料金を計算する関数がデータベースを更新していないか。入力を分類する関数が現在時刻を直接読んでいないか。小さな計算のための関数に、通知、ログ出力、共有キャッシュの更新が追加されていないか。どれもコンパイラの型検査だけでは、設計上の問題としては扱われない。
こうした変更は必ずしもバグではない。時刻を読むことも、状態を更新することも、システム全体では必要だ。ただ、入力から結果を計算する部分と、外界を読み書きする部分を分けていたなら、その境界を変える判断が必要になる。コードが動くことと、意図していた責務に収まることは、別々に確かめたい。
そこで「副作用がありそうか」を都度LLMへ尋ねる前に、明確に禁止したい構文や依存先を解析ツールで調べることを考えた。診断の根拠がソース上の操作に対応していれば、変更した人は、その操作を計算部分から外すのか、関数の責務を変えるのかを判断できる。問題を見つける仕組みと、その問題をどう扱うか決める仕事を分けられる。
1.2 二つの制約を一つのツールで扱う
mikotoには、関数の長さの検査と、指定した関数の純粋性制約の検査がある。前者はすべての対象関数に適用し、後者はマーカーを付けた関数だけに適用する。長さの上限は既定で80行、-max-lines=0を指定すると行数検査を無効にできる。
両者は関連するが、同じ性質ではない。短い関数でも外部状態を複雑に変更できるし、長い関数でも値の変換だけを行える。関数を短く分割しただけで、依存先が明確になるとも限らない。行数は読む範囲を見直すための指標、純粋性制約は外部状態との関係を制限するルールとして、独立に扱っている。
一つの関数を何行までにするかは、プロジェクトの方針で変わる。80行という既定値に品質の普遍的な境界があるわけではない。同様に、すべての関数を純粋にすることも目的ではない。ファイルを開く関数、HTTP要求を送る関数、トランザクションを開始する関数には、外界とのやり取りが本来の責務としてある。
この記事で扱う問いは、「Goの任意の関数が純粋かを完全に判定できるか」より狭い。入力に対する計算へ役割を限定したい関数を明示したとき、その境界を破る操作を、どの程度小さく、説明可能なルールで検査できるか。そして、そのルールをGoの構文と型の仕組みに合わせて実装すると、どこで単純な判定が破綻するか、という問いである。
2. 純粋性をどのような性質として扱うか
2.1 外部への変更と外部への依存
純粋関数を説明するとき、「副作用がない」という言葉だけでは範囲が曖昧になる。外部状態を書き換えなくても、それを読み取って結果を変える関数は、入力だけから結果が定まらない。現在時刻を返す関数は、自分で時計を変更していなくても、同じ引数で別の値を返せる。
例えば次の二つには、異なる問題がある。
var rate = 2
func Multiply(n int) int {
return n * rate
}
func Count(n int) int {
rate++
return n * 2
}
Multiplyはパッケージ変数の現在値に依存する。Countは戻り値だけ見れば同じ入力で同じ結果になるが、呼び出すことで外部状態を変更する。戻り値の決定性と、外部状態を変えないことは、分けて考える必要がある。mikotoは、指定した関数からパッケージ変数へアクセスすることを、読み書きの両方で診断する。
概念的には、引数をx、呼び出し前の外部状態をs、戻り値をy、呼び出し後の外部状態をs'として、関数の実行を次のように書ける。
execute(f, x, s) = (y, s')
外部状態を変えない: s' = s
結果が外部状態に依存しない: y = g(x)
これは説明のためのモデルで、mikotoが実際に状態空間を列挙して確認しているわけではない。また、何を外部状態と観測に含めるかで性質の意味が変わる。メモリ割り当てやGCまで含め、機械の状態が一切変わらないことを要求したら、普通の計算もほとんど成立しない。ここで関心があるのは、計算の結果と、呼び出し元や外界から観測する依存・変更の関係だ。
2.2 参照透過性との距離
参照透過性は、式をその値で置き換えても観測される振る舞いが変わらない、という考え方で説明できる。ある計算を一度行って結果を再利用することと、必要な場所で毎回実行することが同じなら、計算の置き場所や回数を考えやすくなる。
ただし、プログラムでは戻り値だけが観測対象ではない。panic、無限ループ、資源消費、浮動小数点の扱い、実行環境の違いも関わる。さらに、戻り値をGoの==で比較すればよいとも限らない。NaNのように、自分自身との比較が真にならない値もある。意味上の同じ結果と、特定の比較演算の結果を混同したくない。
mikotoのREADMEでも、検査を参照透過性の数学的な証明とはしていない。診断がなかったという結果は、現在のルールに抵触する操作を検出しなかったという結果だ。メモ化や実行順の変更を正当化する証明書が生成されるわけではない。
それでも、外部状態を読む経路と変更する経路を減らすことには意味がある。原因が入力へ寄れば、テストの条件を作りやすくなり、失敗したときに再現すべき情報も整理しやすい。普遍的な保証を得る前に、調べる範囲を小さくする効果がある。
2.3 ローカル変数の変更は許可する
外部状態を変更しないことと、関数の内部で一切代入しないことは違う。例えば整数の和を計算するとき、ローカル変数を更新して結果を蓄積しても、その変数が外へ共有されていなければ、呼び出し元の状態を変更したことにはならない。
//mikoto:pure
func Sum(n int) int {
total := 0
for i := range n {
total += i
}
return total
}
現在のmikotoはこの関数を受理する。totalとiへの代入があるから拒否する、というルールではない。通常の代入では左辺の括弧を外し、識別子なら許可する。もしその識別子がパッケージ変数なら、別の名前参照の検査で診断する。
一方、配列要素や構造体のフィールドへの代入は、ローカルな値に対するものでも拒否する。この非対称性は実装の制約で、意味上の純粋性そのものの定義ではない。ローカル配列が外へ共有されていないことを追跡する解析を入れていないため、代入先が単なる識別子でない操作をまとめて制限している。この点は後で評価と限界の両方に使う。
3. 検査モデルと適用範囲
3.1 明示的なマーカーを採用する
検査対象は、関数宣言に付いたコメントの//mikoto:pureで指定する。すべての関数から純粋なものを自動抽出する方式ではない。マーカーは「この関数に制約を適用したい」という設計上の意思を表す。
//mikoto:pure
func Double(n int) int {
return n * 2
}
マーカーはGoコンパイラに新しい意味を与えない。通常のコンパイルではコメントのままだ。mikotoを実行して初めて、その関数が検査される。したがって、マーカーを付けることと、検査を開発手順に組み込むことはセットで考える必要がある。
現在の実装は関数のDocコメントを見て、行コメントの接頭辞と前後の空白を除去した結果がmikoto:pureと一致するかを調べる。検査対象を名前の命名規則やディレクトリ名から推定せず、宣言のそばへ明示的に置く。コードを読む人も、その関数に制約があることを確認できる。
もちろん、マーカーを消せば純粋性の検査対象から外れる。これを防ぐ変更承認ルールはmikotoに実装していない。意図的に責務を変える操作と、検査を回避する操作のどちらかは、変更の文脈を見て判断する必要がある。ツールの診断だけでは、設計上の意思決定までは分からない。
3.2 値型だけの入出力を要求する
指定した関数の引数、戻り値、メソッドのレシーバーには、現在の実装が値型として認める型を要求する。基本型はunsafe.Pointerを除いて許可し、配列と構造体は要素やフィールドを再帰的に確認する。ポインタ、slice、map、channel、interface、関数型などは、この署名の条件を通らない。
ここで「値型」はmikotoのルールを説明するための呼び方で、Goの型体系に新しい分類を追加したものではない。Goではsliceやmapも変数へ代入できる値だ。ただ、その値をコピーしても参照するデータを共有し得るため、mikotoでは入力と出力の境界を狭くする目的で除外する。
| 署名に現れる型 | 現在の扱い | 確認する内容 |
|---|---|---|
| 整数、bool、string、浮動小数点など | 許可 | 基本型であること |
unsafe.Pointer | 拒否 | 基本型でも明示的に除外 |
| 配列 | 条件付きで許可 | 要素型も条件を満たすこと |
| 構造体 | 条件付きで許可 | 全フィールドの型が条件を満たすこと |
| ポインタ、slice、map、channel | 拒否 | 署名の値型制約に含めない |
| interface、関数型、型パラメータ | 拒否 | 現行の署名検査では扱わない |
定義済みの型名を持つ型も、Underlying()で基底の型へ進んで判定する。例えばtype Amount int64なら基本型に、値型のフィールドだけを持つtype Result struct {...}なら構造体に到達する。名前の文字列が許可リストにあるかを見る方式ではない。
一方で、関数本体のローカル変数まで、すべて値型に限定しているわけではない。ローカル配列からsliceを作り、その要素を読んで整数を返すようなコードは、回帰テストにも受理例として存在する。署名の制約と、本体に現れる個々の操作の制約は別々の検査だ。
3.3 呼び出し先を限定する
指定した関数から呼べるのは、同じパッケージでマーカーを付けた関数・メソッド、型変換、および許可した組み込み関数に限定する。組み込み関数はlen、cap、complex、real、imag、min、maxを許可している。
これは、呼び出し先が意味上純粋かをすべて解析して推論する方式ではない。未指定の関数は、実際には単純な計算でも呼べない。標準ライブラリにある数値関数も、現在のルールでは同一パッケージの指定済み関数に入らないため、一般に許可されない。
制約を狭くすると実用上の不便は増える。ただ、外部の関数を一つ許可したとき、その関数が何を呼び、どの状態へ依存するかを追う範囲も増える。現行のmikotoは、その範囲を小さく保つ方を選んでいる。外部パッケージへの純粋性情報の共有や、関数の別名を通した呼び出しの解析は、実装済みの機能としては含めていない。
4. 実装: ASTと型情報を組み合わせる
4.1 解析の土台
CLIはsinglechecker.Main(checker.Analyzer)で起動する。解析器はgolang.org/x/tools/go/analysisの枠組みに載せ、対象の構文木と型情報を受け取る。ソースの文字列から*やgoを探すだけの実装ではない。
ASTは、そのソースがどういう構文で書かれたかを表す。型情報は、その識別子がどの宣言を指すか、式が値か型か、selectorが暗黙の間接参照を伴うかなどを表す。mikotoは、この二つを使い分けている。構文だけで判定できる操作もあれば、型を見ないと分からない操作もある。
例えばp[0]というASTだけでは、pが配列、slice、配列ポインタのどれかは分からない。Double[int]()という構文も、単純な配列の添字参照と同じような形のノードを含む。名前が同じrateでも、ローカル変数とパッケージ変数では、検査したい意味が違う。
flowchart TD
S["Goソース"] --> A["ASTと型情報"]
A --> M["指定済み関数を収集"]
M --> L["各関数の物理行数を検査"]
L --> P{"pure指定があるか"}
P -->|ある| V["署名の値型制約"]
V --> B["本体の操作・名前・呼び出し先"]
B --> D["違反位置を診断"]
P -->|ない| E["純粋性検査の対象外"]
実装上、診断を一つ見つけても、すべての検査を即座に打ち切るわけではない。署名が条件を満たさない関数でも本体の検査を進め、複数の診断を出し得る。一方、クロージャなど禁止したまとまりへ入った場合、その内側の走査を止める箇所もある。診断の個数をそのまま独立したバグの個数と読むべきではない。
4.2 先にマーカーを集める理由
runは最初に対象ファイルの関数宣言を見て、マーカーがある関数のtypes.Objectを集合へ保存する。その後、もう一度関数宣言を回り、行数と本体を検査する。
この二段階にすることで、ソース上で後に宣言された関数も、先に宣言された関数から呼べる。ファイル順や宣言順によって、同じパッケージの呼び出しが許可されたり拒否されたりすることを避けられる。Goが解決した宣言の同一性を使うため、単に関数名を文字列の集合へ入れるより、レシーバーや名前の隠蔽を扱いやすい。
ただし、この集合は「検査で純粋と証明された関数」の集合ではなく、「指定された関数」の集合だ。指定済みの関数が内部でルールに違反していれば、その関数自身に診断を出す。呼び出す側ではマーカーを根拠に呼び出しを許可する。純粋性の成否を呼び出しグラフ全体へ伝搬し、固定点を計算する仕組みではない。
したがって、指定済み関数群に診断がないことをまとめて確認する運用が重要になる。呼び出し側の一行に警告が出なかったことだけを取り出して、その先も検証済みだとは扱えない。この区別は、小さな実装を正確に説明するうえで外せない。
5. 関数の長さ: 何を数えるか
5.1 物理行数という指標
行数の検査は、関数宣言の先頭から終端までの物理的な行番号の差に1を加える。本文中の空行やコメントも含め、宣言が占める行を数える。文の個数や分岐の個数、トークン数は数えない。複数行に分けた引数宣言も範囲に入る。
function_lines = physical_end_line - physical_start_line + 1
物理行数を選ぶと、同じ意味のコードでも整形で値が変わる。長い式を一行へ押し込めば短く見せられるし、説明コメントを加えれば長くなる。したがって、これは複雑さの数学的な尺度ではない。通常の書式で書いた関数が、読む人にどの程度の範囲を要求するかを見るための粗い指標になる。
実装がASTの関数宣言単位で数えることにも範囲の限界がある。関数本体を持つFuncDeclが対象で、関数リテラルを独立した関数として同じ行数検査に掛ける実装ではない。外側の関数に含まれる関数リテラルの行は外側の範囲に入るが、パッケージ変数の初期化に書いた関数リテラルなどへ、同じ制約が自動的に適用されるわけではない。
5.2 //lineが指標を変えてしまった
初期実装ではFset.Positionから行番号を得ていた。Goでは//lineで論理的なソース位置を補正できるため、この方法だと、実際にファイルに並んだ行数とは異なる差を取ることがある。Issue #5は、物理的には12行ある関数が、上限8行でも検出されない例を示している。
func long() int {
n := 0
n++
n++
n++
n++
n++
n++
n++
//line imaginary.go:1
return n
}
この例では、関数の途中で表示用の行番号が巻き戻る。開始と終了に補正済みの番号を使えば、関数の長さが0行のように算出され得る。逆に、大きな行番号へ進めれば、本当は短い関数が極端に長く見える。ソースの意味を変えるバグではないが、行数という測定値が、別の目的のメタデータに引きずられていた。
修正はPositionFor(pos, false)で、補正を適用しない位置を取ることだった。FileSet.PositionForの文書と照らすと、行数計算で必要なのはこの物理位置だと分かる。診断の表示位置と、指標を計算する位置は、同じ目的で使うとは限らない。
回帰テストには、行番号を小さくして長さを隠す例と、大きくして短い関数を長く見せる例を入れている。検出漏れだけを直すと、反対方向の誤検出が残ることがある。測定の定義を決めて、補正の有無に依存しないことを両方向で確認した。
6. 名前と代入から外部状態を検査する
6.1 パッケージ変数と同名のローカル変数
パッケージ変数へのアクセスを検出するとき、名前の綴りだけでは足りない。例えばstateというパッケージ変数があっても、同じ名前の引数やローカル変数が、その関数の中で別の値を指すことがある。
var state = 10
//mikoto:pure
func Local(state int) int {
state++
return state
}
この引数のstateはパッケージ変数ではない。現在の実装はTypesInfo.Usesで識別子が参照するオブジェクトを取得し、それがtypes.Varで、所属するスコープがパッケージのスコープかを調べる。名前の一致ではなく、解決済みの束縛を根拠に判定する。
パッケージ定数は変数ではないため、このルールの診断対象にならない。計算に使う定数と、実行中に変更され得るパッケージ変数を同じ扱いにしない。一方、初期化後は変更しないつもりのパッケージ変数でも、mikotoはその意図を推定せず診断する。必要なら定数へ変えるか、必要な値を引数へ渡すことを考える。
「実際には今のコードでは変化しない」と「型と宣言から変更されないことを判断できる」は違う。現行のルールは後者に寄せている。ただし、全プログラムを調べて不変性を推論しているわけでもない。変数であることを根拠に、一律に外部状態へのアクセスとして制限する。
6.2 代入先の識別子に限定する
通常の代入は、左辺をast.Unparenで正規化し、Identなら許可する。それ以外は参照を通した代入として診断する。増減演算も同様に、括弧を除去した対象が識別子かを確認する。
このためn = 4、n++、(n) = 4、(n)++は、対象がローカルな整数なら同じ扱いになる。括弧は構文木の形を変えるが、ここで検査したい代入先の意味を変えない。Issue #4では、初期実装がParenExprをそのまま見て、通常の代入まで拒否していた。
一方で、次のコードは現在も拒否する。
//mikoto:pure
func SetLocal(n int) [1]int {
a := [1]int{}
a[0] = n
return a
}
入力と出力は値型で、aもローカル配列だ。この例に、呼び出し元の配列を書き換える操作はない。しかし左辺がIndexExprなので、pure function cannot assign through a referenceと診断する。記事を書く際にも現行CLIでこの動作を確認した。
この診断文から「Goの配列は常に外部参照である」と解釈するのは間違いだ。実装が単純な代入先ルールを採用しているため、その形の操作をまとめて拒否している。構造体のローカルなフィールド代入にも同じ制約がある。外部状態へ影響しない代入を精密に許可するには、別の解析が必要になる。
例えば各フィールドを設定してから返す代わりに、構造体リテラルを作って返す書き方なら、この制約には抵触しない。ただ、ツールを通すために不自然な書き方を大量に増やすことが望ましいとも限らない。診断が設計の境界を明確にしているか、それとも書き方だけを窮屈にしているかは、適用する関数群を見て評価したい。
7. 非決定的な走査とジェネリクスの型集合
7.1 ローカルmapでも順序へ依存できる
外部状態へのアクセスを禁止しても、同じ入力から異なる結果が出る経路は残る。分かりやすい例が、ローカルに作ったmapの走査だ。mapを外へ共有しなくても、最初に見つけた要素を返すと、走査順が結果へ現れる。
//mikoto:pure
func First() int {
m := map[int]int{1: 1, 2: 2}
for k := range m {
return k
}
return 0
}
Goのrangeの仕様は、mapの走査順を保証しない。同じmapでも、反復の順序が結果の根拠になるコードでは決定性を期待できない。mapが関数の外にあるかどうかとは、別の問題である。
mikotoはmap、channel、iteratorとして使う関数型へのrangeを診断する。配列、slice、整数への走査は、その操作だけを理由には拒否しない。もちろん、走査対象の取得やループ本体に別の禁止操作があれば、そちらの検査が働く。
mapの全要素を足すコードなら、順序によらない結果になる場合もある。ただ、演算の性質や順序に依存しないことを証明して許可する実装にはしていない。現行のルールは、mapへのrangeという形を一律に拒否する。浮動小数点の和なら順序が丸め結果へ影響し得ることもあり、単に「全部足すから安全」と一般化することも難しい。
channelのrangeには通信があり、iteratorのrangeには呼び出される関数の振る舞いがある。これらがすべて同じ理由で不純なのではない。診断ではまとめているが、制限する背景には、順序、通信、実行するコードなど異なる依存がある。iteratorが実際には純粋でも、その効果を推論して例外的に許可する機能はない。
7.2 型パラメータを見落とす
Issue #1では、mapの型が型パラメータを介して現れると、この診断が抜けていた。
//mikoto:pure
func Nondeterministic[T ~map[int]int]() int {
m := T{1: 1, 2: 2}
for k := range m {
return k
}
return 0
}
初期実装は走査対象の型のUnderlying()がMap、Chan、Signatureかを見ていた。しかしmの型はTypeParamで、その基底には制約を表すinterfaceがある。mapを使えることは制約に書かれているのに、直近の型の分類だけを見ていたため、禁止対象へ到達しなかった。
この例の関数には型パラメータを使った引数や戻り値がない。したがって、署名の値型制約だけでは止められない。署名を狭くしていても、関数本体で型パラメータを利用する経路まで消えるわけではない。複数の検査を組み合わせる場合、一つがあるから他方の穴は問題にならない、と決めつけない方がよい。
Issueの再現では、同じ関数を繰り返すと1と2の両方が観測された。ただし、回帰テストをその出現回数へ依存させる必要はない。mapの走査順が毎回違うことを要求するのでなく、mapへの走査を禁止するルールが、この構文にも適用されるかを確かめればよい。仕様上保証されないことと、偶然の実行で変動を観測することは分けられる。
7.3 unionだけでなく交差を計算する
修正ではtypeTermsを追加し、型パラメータの制約に含まれる型の項を展開する。型のaliasを外し、型パラメータなら制約へ進む。unionは項を合わせ、interfaceの埋め込みは制約の交差として扱う。
ここで重要なのは、制約の中にmapという単語が一度でも出たら拒否する、という方法にしなかったことだ。複数の制約を同時に満たすと、mapが最終的な型集合から外れる場合がある。回帰テストには次のような例がある。
type First interface {
~[]int | ~map[int]int
}
type Second interface {
~[]int | ~map[string]int
}
type SliceIntersection interface {
First
Second
}
Firstでは整数キーのmapか整数slice、Secondでは文字列キーのmapか整数sliceを許す。両方を満たす型を考えると、共通するのは整数sliceの側になる。mapの項を単純に集めてしまえば、本来mapを選べない制約にまで診断を出してしまう。
First = slice または map[int]int
Second = slice または map[string]int
First ∩ Second = slice
現行実装では、nilの項集合を構造的な制限なし、空だがnilでない集合を空の交差として扱う。~付きの項と正確な型の項の交差では、基底型が一致するかも確認する。この区別をせずに「項が取れなかった」とまとめると、制限がない状態と、どの型も満たせない状態を混同する。
ただし、この補助関数をGoの型集合全般の完全な証明器として紹介するのは適切ではない。実装はrangeや配列ポインタの判定に必要な構造的制約を展開し、メソッド要求による追加の絞り込みまで完全に解決するものではない。ここでも、補助的な型情報の処理と、一般的な型集合の意味論は区別する。
8. 明示的な*pがなくても間接参照は起きる
8.1 配列ポインタの添字参照
初期実装のポインタ検査は、主にStarExprを見ていた。だがGoでは、ポインタを通したアクセスを常に*pと書くわけではない。Issue #2では、配列ポインタへの添字参照によって、外部の可変メモリを読み出す例が通っていた。
//mikoto:pure
func Read(addr uintptr) int {
p := (*[1]int)(unsafe.Pointer(addr))
return p[0]
}
この例にはunsafeのimportが必要で、通常のアプリケーションで勧めたいコードではない。ここでは検査の穴を説明するために使う。addrは整数として署名の条件を満たし、型変換も呼び出し検査では許可する。最後のp[0]で配列ポインタを通して読むのに、見た目のASTは添字式である。
入力が同じ整数のアドレスでも、その先のメモリが変われば結果が変わる。署名が値型だけであることは、関数本体が外部メモリへ到達しないことの証明にはならない。この例は、ルールを一つずつ確認するだけでなく、型変換と暗黙の操作をつなげて読む必要があることを示している。
修正後は、添字式の対象型が配列ポインタかを確認し、値としてのアクセスなら診断する。配列ポインタをsliceにするp[:]も同様に検査する。配列ポインタかどうかの判定には先ほどの型の項の展開を使うため、名前付きのポインタ型や、型パラメータを介した場合も対象になる。
8.2 フィールドとメソッドの選択
構造体ポインタのフィールドは、(*p).Nと明示しなくてもp.Nで読める。値レシーバーのメソッドをポインタから呼ぶときにも、値を得るための間接参照が関わる場合がある。StarExprだけを探す方法では、こうした操作が見えない。
現行の検査はSelectorExprに対する型情報のselectionを取り、Indirect()が真なら診断する。何というメソッド名か、関数のコメントに何と書いてあるかだけでなく、その呼び出しに至るまでにポインタを通して値を取り出すかを確認する。
回帰テストでは、uintptrから構造体ポインタへ変換してフィールドを読む例と、指定済みの値レシーバーメソッドをそのポインタから呼ぶ例を含めている。メソッド自身にマーカーがあっても、レシーバーを外部メモリから取得する操作まで許可されるわけではない。呼び出し先の許可と、その呼び出しへ渡す値の取得は別の検査になる。
これは静的検査の単位を考えるうえでも重要だ。許可した関数を呼んでいるから、呼び出し式全体が安全とは限らない。引数やレシーバーを作る途中に禁止操作があれば、それを別に見つける必要がある。ASTを本体全体で走査する構成には、こうした周辺の式も確認する役割がある。
8.3 配列ポインタへのrangeは場合分けが要る
配列ポインタを走査するとき、要素の値を取得する場合と、添字だけを取得する場合では操作が違う。回帰テストでは、要素を受け取るrangeは診断する一方、固定長配列の添字だけを使うrangeは受理する。
//mikoto:pure
func RangeIndices() int {
var p *[2]int
n := 0
for i, _ := range p {
n += i
}
return n
}
この場合、必要なのは配列型の定数の長さであり、各要素の読み出しではない。実装はrangeの値側に変数があり、それがblank identifierでない場合に、配列ポインタの間接参照として診断する。型にポインタが現れることだけで拒否せず、その操作が何を取得するかも区別する。
逆に、for _, v := range pなら、要素の値を得るために参照先を読む。この違いは、ポインタという単語を検出するだけでは表せない。型を介した操作の意味を少しずつ確かめる必要がある。
8.4 rangeの代入先も検査する
rangeには新しい変数を宣言する形だけでなく、既存の代入先を更新する形もある。for p[0] = range nのように書けば、rangeの進行に伴って参照先へ書き込む。これは通常のAssignStmtとしては現れない。
初期実装ではrangeの対象だけを検査し、その代入先を見ていなかった。修正後は、トークンがASSIGNの場合、KeyとValueの両方へ通常の代入と同じ検査を適用する。pointeraccessの回帰テストには、両方の代入先が配列要素になる例もある。
外部状態の変更という同じ意味の操作が、言語上の複数の構文に分かれている。代入文を検査したから代入全般を検査した、とは言えない。今回の不具合は、ASTのノードの分類と、設計として禁止したい操作の分類が、一対一ではないことを示していた。
9. 呼び出し先の同一性と構文の正規化
9.1 型引数を持つ呼び出し
Issue #3は、指定済みのジェネリック関数でも、型引数を書いて呼ぶと未指定の関数として拒否される問題だった。初期の呼び出し先解決が、IdentとSelectorExprだけを処理していたためだ。
//mikoto:pure
func Constant[T any]() int {
return 1
}
//mikoto:pure
func Read() int {
return Constant[int]()
}
Constant[int]は型引数を伴う構文なので、単なる識別子としては現れない。型引数が複数なら別のノードになる。Goが呼び出し先を解決できていても、解析器が構文の一部しか扱わなければ、その結果を取り出せない。
修正ではtypeutil.Calleeを使い、型情報から呼び出し先のオブジェクトを得る。自前で構文を順番に剥がす処理より、Goの解析用の補助APIへ任せる範囲を増やした。ジェネリックな関数やメソッドについても、元の宣言と対応させる。
9.2 インスタンス化されたメソッド
ジェネリックな型のメソッドでは、型引数を与えて具体化したメソッドと、宣言時にマーカーを登録したメソッドのtypes.Funcを、同じものとして扱う必要がある。単純なオブジェクトの比較だけでは、その対応が崩れていた。
現在の実装は呼び出し先がtypes.FuncならOrigin()で元の関数へ正規化し、マーカーを集めた集合と照合する。実行時に別の関数へ飛び得ることを推論するのではなく、型引数による具体化と元の宣言の関係を整理する処理だ。
ただし、ジェネリック関数に対応したという説明を、任意の型パラメータを使った署名も許可するという意味に広げてはいけない。例えばfunc Double[T ~int](n T) Tは、現行の値型判定がTypeParamを許可しないため、署名に対する診断が出る。この記事用の補助例でも確認した。
回帰テストのジェネリックメソッドは、型パラメータを持つ型でも、実際のフィールドがintである例を使っている。型名に型パラメータがあることと、署名の型を再帰的に調べたときに型パラメータが残ることは違う。「ジェネリクス対応」と一語でまとめると、こうした適用範囲が分からなくなる。
9.3 括弧と添字を区別する
括弧付きの呼び出しも、呼び出し先を解決するうえで重要だった。Double(n)と(Double)(n)、len("abc")と(len)("abc")は、この検査で違う結果になってほしくない。現在は型情報に基づく呼び出し先の解決と、代入・増減でのast.Unparenを使っている。
一方で、添字を見つけたらすべて剥がしてよいわけではない。Constant[int]()の[int]は型引数だが、f[0](2)の[0]は関数値を持つ配列から要素を取得している。後者を雑に剥がして配列に入っている関数を許可すると、動的な呼び出し先まで指定済みと見なしてしまう可能性がある。
回帰テストには、指定済みの関数を配列へ入れて、その配列要素を呼ぶ例を拒否することも含めた。また、括弧を付けた未指定関数や禁止builtinが、正規化によって許可されないことも確認している。誤検出を減らす修正では、検出漏れが増えないかを同時に見る必要がある。
10. 検証方法と観測結果
10.1 診断そのものをテストする
mikotoのテストは、analysistestを使っている。テスト用のGoソースにwantコメントを置き、どの位置にどの診断が出るかを指定する。単に解析がクラッシュしなかったかを見るテストではない。
診断されるべきソースを入れるだけでなく、受理されるべきソースも同じfixtureに置く。誤ったコードを検出できても、正当なコードを全部拒否してしまうなら、実用上の検査器としては使いにくい。検出漏れと誤検出を、両方から確認する構成になっている。
例えばジェネリックrangeのfixtureには、map、channel、iteratorの拒否例に加えて、配列、整数、制約の交差でsliceだけが残る受理例がある。ポインタのfixtureにも、暗黙の読み出しを拒否する例と、定数の長さを使う添字のみの走査を受理する例がある。禁止したい構文に近い正当な構文を併せて置くと、判定の境界を確認しやすい。
10.2 修正前後を比べる
PR #6の検証記録では、追加した回帰テストが修正前に失敗し、修正後に成功したことを確認している。また、各Issueの再現コードをCLIで解析し、mapの走査や外部メモリへのアクセスには診断が出て、正当なジェネリック呼び出しと括弧付き操作には診断が出ないことを確認している。
この比較の意味は、追加したテストが今回直した不具合を実際に踏んでいると確かめることだ。修正後だけで通るテストを見ても、修正前も通っていたのかは分からない。特に検出漏れの修正では、「診断が必要な位置を検査していたつもり」になりやすい。
この記事の準備では、公開された現行revisionを一時ディレクトリへ取得し、Go 1.25.0、macOS ARM64で次の検証を行った。PRに記録された修正前後の試験と、今回手元で実行した現行版の試験は、同じ実験として混ぜていない。
| 検証 | 現行revisionでの結果 | 確かめる範囲 |
|---|---|---|
go test ./... | 成功 | 既存の診断テストと回帰テスト |
go vet ./... | 成功 | ツール自身の通常の静的検査 |
| CLIのbuild | 成功 | コマンドを生成できること |
| 記事用の受理例 | 診断なし | 値型の計算、ローカル変数更新、指定済みの呼び出し |
| 記事用の拒否例 | 想定した診断あり | ローカル配列要素の代入、型パラメータ署名、未指定呼び出し |
受理例には、ゼロ除算し得るfunc Divide(n int) int { return 10 / n }も入れた。この関数は診断なしで通る。これは、ツールの有効性を示す成功例というより、検査が扱っていない性質を実際に確かめる例である。純粋性に関するルールを通ったことから、すべての入力で正常終了すると推論してはいけない。
10.3 今回の修正をどう評価するか
今回閉じた五つのIssueを、問題の性質ごとに整理すると次のようになる。
| Issue | 問題 | 修正の要点 |
|---|---|---|
| #1 | 型パラメータ経由のmap rangeを見逃す | 制約の型の項を展開する |
| #2 | 暗黙の間接参照とrange代入を見逃す | 式の型、selection、代入先を検査する |
| #3 | 指定済みのジェネリック呼び出しを拒否する | 呼び出し先と元の宣言を対応させる |
| #4 | 括弧だけで正当な操作を拒否する | 構文を正規化する |
| #5 | 論理行番号で物理行数を誤る | 補正しない位置を使う |
ここから言えるのは、具体的な構文と型の組み合わせについて、初期実装の判定を修正し、その境界を回帰テストで確認したことだ。大規模なGoコード群に対する適合率、検出率、解析時間、開発者のレビュー時間の削減量を測定したわけではない。Issueの件数を、そのままツール全体の精度へ置き換えることはできない。
11. 適用例: 入出力を外へ出して計算を残す
11.1 料金計算を値へ閉じる
具体的な使い方として、単価と数量から金額を計算する関数を考える。ネットワークから単価を取得する処理や、計算結果を保存する処理まで一つの関数へ入れると、検証に外部環境が必要になる。一方、値を受け取り、値を返す計算へ分ければ、その部分だけを対象にできる。
type Order struct {
UnitPrice int64
Quantity int64
}
type Result struct {
Amount int64
Valid bool
}
//mikoto:pure
func Quote(order Order) Result {
if order.UnitPrice < 0 || order.UnitPrice > 1_000_000_000 {
return Result{}
}
if order.Quantity < 0 || order.Quantity > 1_000_000 {
return Result{}
}
return Result{
Amount: order.UnitPrice * order.Quantity,
Valid: true,
}
}
この例は現行CLIで受理されることを確認した。上限を設けているのは、積がint64の範囲へ収まるよう、例として入力の定義域をはっきりさせるためだ。単価と数量の現実的な妥当性、税や丸めのルール、金額が0の場合の意味は、mikotoが判定する内容ではない。
戻り値をerrorにしていないのは、現在の署名制約ではinterface型のerrorが通らないためだ。これは、Goでエラーを返す一般的な書き方が悪いという意味ではない。mikotoの現行ルールに合わせた小さな例として、値だけで表せる結果を使っている。実際のAPI設計でこの制約が適切かは、利用する側の要求も含めて判断する必要がある。
11.2 外界の情報を引数にする
計算が現在時刻に依存するなら、その関数の中で時計を読むのではなく、計算に必要な時刻を値として引数へ渡す方法がある。設定に依存するなら、パッケージ変数を直接読む代わりに、必要な設定を値へまとめて渡す。外界への依存がなくなるわけではないが、どの情報が結果を変えるかが、関数の入力へ現れる。
例えば期限判定で必要なのが整数の時刻だけなら、呼び出し元が時刻を取得し、その値を判定関数へ渡せる。日時型をそのまま使う場合は、その内部構造やメソッド呼び出しが現行ルールを満たすか別に確認しなければならない。「時刻を引数にすれば何でも通る」という説明にはしない。
flowchart LR
I["時計・設定・DBから取得"] --> V["必要な情報を値にする"]
V --> C["指定した計算関数"]
C --> R["結果を値で返す"]
R --> O["保存・通知・応答"]
この構成はmikotoが自動的に作るものではない。人間が責務を分け、その計算部分にマーカーを付ける。ツールは、その後に外部状態の参照や更新が入り込んだときに、実装したルールの範囲で診断する。
外側の処理には、タイムアウト、再試行、トランザクション、重複実行への対応などが残る。計算部分が制約を満たしても、システム全体の正しさはそれだけでは決まらない。ただ、入力の取得、計算、結果の反映を分けて検査できれば、失敗したときに、どの前提を疑うか整理しやすい。
11.3 境界を増やす費用
分割には費用もある。値をまとめる型が増え、変換処理が必要になり、呼び出しの層が増える。すべての関数にこの形を強制すると、外部状態を扱うことが本来の責務である処理まで、無理に迂回させることになる。
適用先として考えやすいのは、料金やスコアの計算、条件の分類、プロトコルの値の変換、小さな状態遷移の判断など、入出力を値で表せる部分だ。ただし、文字列処理でも標準ライブラリ呼び出しが必要なら現行ルールで制限されるし、状態遷移でも参照型のデータ構造を扱えば署名の条件を満たさないことがある。
したがって「こういう分野なら使える」と一括りにせず、具体的な関数が何を受け取り、何を返し、何を呼ぶかを見る方がよい。小さな対象から適用し、必要な制約と、単に実装上の都合で拒否される操作を分けて評価するのが現実的だと考えている。
12. 限界: 診断がないことは何を保証しないか
12.1 停止性と例外
先ほどのゼロ除算の例は、引数が0なら正常に値を返さない。再帰する関数や無限ループも、現在のmikotoは停止性を解析しない。指定済みの関数を再帰的に呼ぶことは、呼び出し先のマーカーだけを根拠に許可され得る。
つまり、入力から結果を計算する意図に近づけても、その計算が必ず終わることは別の問題だ。関数の契約には、どの入力を有効とするか、どの失敗を許すか、どの程度の時間と資源を使うかも関わる。これらをすべてpureという短いマーカーへ押し込めることはできない。
panicするbuiltinを禁止しても、演算や添字アクセスの実行時の失敗がすべてなくなるわけではない。ツールの許可した操作だけを使っていても、入力に対する前提をテストや別の検証で確認する必要がある。
12.2 浮動小数点と環境
基本型として浮動小数点を許可しているが、数値計算の精度や安定性は検査しない。NaN、無限大、丸め誤差、桁落ちが業務上許容できるかは、計算の要求による。同じ式でも、何を等価な結果とみなすかを決めないと、決定性の評価自体が曖昧になる。
また、intやuintptrの幅など、実行環境に依存する条件がある。入力だけから計算する関数でも、異なる環境で同じ数値結果が出ることを、mikotoが保証しているわけではない。対象となるGoの言語仕様、型、実行環境の条件を、別に揃える必要がある。
前に書いたFP6と量子化の記事でも、有限の数値表現と、保存したい性質は分けて考えた。ここでも、外部状態を参照しないことと、計算の結果が要求した精度を満たすことは別の性質になる。数値誤差へ対処するテストを、純粋性の診断で代替することはできない。
12.3 パッケージを越える検証
指定済み関数の集合は、その解析で受け取った同一パッケージの宣言から作る。別パッケージの関数が同じマーカーを持っていることを、解析結果として取り込み、呼び出しを許可する仕組みは実装していない。
go/analysisには解析情報をパッケージ間で伝える仕組みがあるが、枠組みに機能があることと、mikotoがそれを使用していることは違う。現行のAnalyzerに純粋性のFactを定義しているわけではない。将来の候補として挙げることはできても、現在の保証へ含めない。
外部関数を許可するなら、ライブラリのversionが変わったときに保証をどう維持するか、検査対象外のコードを何に基づいて信頼するか、動的な呼び出し先をどう扱うかも決める必要がある。便利な許可リストを増やすだけでは、その判断の根拠が見えなくなることがある。
12.4 到達不能なコードも検査する
現行の本体検査はASTの走査であり、各分岐へ実行が到達するかを細かく証明していない。到達不能な分岐に禁止した呼び出しがあっても、その構文を見つければ診断し得る。実際の実行では副作用が起こらないことと、ソース上のルールを満たすことは同じではない。
これは、制約の意図によって評価が変わる。将来条件が変わったときにも外部操作が入らないようにしたいなら、到達不能な場所でも診断することに意味がある。一方、実行時の振る舞いだけを精密に判定したいなら、誤検出として不便になる。mikotoの現在の設計は、後者の完全な推論を目指すものではない。
13. 考察: 保守的なルールと解析の精度
13.1 拒否範囲が広いことと健全性は別
静的解析では、意味上は安全なコードを拒否することと、本当に危険なコードを見逃すことが、どちらも問題になる。mikotoでも、ローカル配列への代入を拒否するような広い制約がある一方、初期実装では暗黙の間接参照を見逃していた。
「保守的」と書けば、すべての検出漏れがなくなるわけではない。健全性を主張するなら、対象となる言語と観測モデルを定義し、許可した各操作と、その組み合わせで要求した性質が保たれることを示す必要がある。禁止項目が多いことだけは、その証明にならない。
今回のPRで示したのは、具体的な検出漏れを修正し、それに近い受理例を誤って拒否しないことをテストした結果だ。全構文に対する形式的な健全性は証明していない。実装が小さいから完全に理解できる、ということと、その実装が言語上のすべての経路を扱えている、ということも別だ。
13.2 精密な解析を入れるなら何が必要か
ローカル配列の変更を許可するには、その配列が外部と共有されていないことや、参照が逃げないことを追いたくなる。関数値の呼び出しを許可するには、その値がどの関数を指し得るかを調べたくなる。分岐ごとに違う代入があれば、その情報を結合する処理も必要になる。
こうして制約を緩めようとすると、ASTの形の検査から、データフロー、別名、到達可能性、呼び出し関係の解析へ範囲が広がる。SSAなど別の表現を使う選択肢もあるが、現行mikotoはそのような解析を実装していない。単純なルールを小さく維持することと、より多くの正当なコードを受理することの間には、実装と説明の費用がある。
解析が精密になれば、利用者の書き方の自由度は上がり得る。ただ、その代わり診断が出た理由や出なかった理由を理解するための前提も増える。小さい関数へ明示的に適用する道具として、どこまで複雑さを持ち込む価値があるかを考えたい。
13.3 構文の正規化と意味の確認
今回の修正には、括弧の正規化、ジェネリックな宣言の同一性、制約のunionと交差、暗黙の間接参照という、異なる種類の問題が含まれていた。それぞれに必要な情報が違う。すべてを「ASTをもっと詳しく見る」という方法だけで解けるわけではない。
括弧は、検査する操作の意味を保ったまま取り除ける。型引数を伴う呼び出しは、型情報から元の関数を解決する。mapの禁止判定は、型パラメータの制約まで進む。ポインタを通したアクセスは、見た目の*の有無ではなく、型とselectionの情報を使う。
ここから得られた実装上の方針は、禁止したい意味を先に定義し、その意味が現れる構文と型情報を対応させることだ。対応が一箇所でも抜けると検出漏れになる。逆に、見た目が似ている構文をまとめ過ぎれば誤検出になる。この対応関係を回帰テストへ落とすことで、変更のたびに確認できる。
14. 運用と今後の評価
14.1 小さい対象から導入する
現行の使い方は次のようになる。必要なGoのversionは1.24以降で、CLIはGoのパッケージ指定を受け取る。
go install github.com/ieee0824/mikoto/cmd/mikoto@latest
mikoto -max-lines=80 ./...
関数の長さを検査せず、マーカーを付けた関数の検査だけを使うなら、次の指定にする。
mikoto -max-lines=0 ./...
CIで使う場合は、どのrevisionの解析器を使うかを固定した方が、診断の変化を追いやすい。検査器を更新すると、今回のように以前見逃したコードへ診断が出たり、誤って拒否していたコードが通ったりする。latestによる導入例と、繰り返し同じ条件を確認する運用は、目的が違う。
最初は小さな計算関数へマーカーを付け、その診断が役割の境界を説明しているかを見る。大量の関数へ一度に指定して、通らない構文を機械的に書き換えるだけでは、何を守るための検査か分からなくなる。行数の上限も、全体の責務の切り方を見直す契機として使いたい。
14.2 テストとの役割分担
mikotoが静的に検査するのは、指定した構文・型・依存先の制約だ。計算結果が期待した値かは単体テストで確認する。広い入力集合に対して保つべき関係があるなら、Property-Based Testingを使う余地がある。外部の取得や保存までつないだ結果は、結合テストやE2Eで確認する。
例えば料金計算なら、外部状態へ触らないことをmikotoで確認しても、単価と数量の掛け算を間違えていたら金額は誤る。無効な入力を拒否すべきか、丸めをどこで行うか、税率をどの時点のものとして扱うかは、仕様とテストで定める必要がある。静的な境界の検査と、求める振る舞いの検証は、相互に補う関係になる。
AIがコードを書く場合にも同じだ。マーカーと解析器によって、守るべき条件の一部を明確に伝え、生成されたコードがその条件から外れたことを機械的に検出できる。ただし、マーカーを外すべきか、条件を緩めるべきか、そもそも機能が必要かを決めるところには、設計上の判断が残る。
14.3 測定したいもの
今後、道具としての有用性を評価するなら、診断の数だけでなく、その診断によって何を変えたかを記録したい。外部状態の依存を発見した件数、意図した責務の違反として修正した件数、ローカルな安全な操作を拒否した件数、適用をやめた関数とその理由などが候補になる。
解析時間も別に測る必要がある。今回の手元のテストが成功したことは、大規模なコードベースでも十分に速いという性能評価ではない。パッケージ読み込みと型検査、mikoto自身の走査を分け、対象規模、Goのversion、キャッシュ条件を揃えて測る方が判断しやすい。
ただし、これらの評価や改善は、現時点で実施済みの成果ではない。クロスパッケージの情報共有、ローカルな変更の精密な許可、動的な呼び出し先の追跡も、必要性が確認できたときに検討する候補になる。機能の一覧を増やすより、現在のルールがどの設計上の問題を扱えているかを確かめる方を先にしたい。
15. 結論
mikotoでは、Goの関数へ明示的な制約を置き、関数の物理行数と、指定した関数の値型の境界・操作・依存先を静的に検査する形にした。純粋な関数を自動で見つけるのではなく、純粋な計算として扱いたい部分を指定し、その意図から外れた実装を診断する。
初期実装の修正を通じて、単純な構文の分類だけでは、禁止したい操作と許可したい操作を分けられないことが分かった。型パラメータのmap、配列ポインタの添字参照、rangeの代入先は、見た目の構文だけを追うと取りこぼす。一方、括弧や型引数は、意味が変わらなくても構文木や宣言の対応を変える。型情報と正規化を組み合わせ、両側の境界をテストする必要があった。
今回確認したのは、その具体的な修正と回帰テストの結果である。参照透過性、停止性、数値計算の正しさをまとめて証明したわけではない。それでも、守るべき境界をコメントだけにせず、実装したルールとして繰り返し確認できることには意味がある。
人間が決めるのは、どの関数へその境界を置くか、診断が出た変更をどう扱うか、そして返ってくる計算結果が本当に必要なものかということだ。mikotoは、その判断に使う条件の一部を、コードが増えても同じ形で確認するための道具として作っている。