技術 約7分で読めます

Go 1.26の//go:fix inlineディレクティブとソースレベルインライナー

前回の記事でGo 1.26のgo fixが「コードの自動モダナイゼーションツール」として生まれ変わったことを書いた。interface{}any変換、min/maxの導入、new(expr)対応などの組み込みフィクサーの話だった。

今回はそのgo fixのもう一つの目玉である//go:fix inlineディレクティブについて掘り下げる。組み込みフィクサーがGo標準ライブラリのイディオム更新を担うのに対し、//go:fix inlineはサードパーティを含む任意のパッケージ作者が自分のAPIの移行パスを定義できる仕組みだ。

ソースレベルインライニングとは

インライニングとは、関数呼び出しをその関数の本体で置き換える変換のこと。コンパイラが行うインライニングはランタイムの中間表現を対象とし、実行速度を最適化する。go fixが行うのはその「ソースレベル版」で、.goファイルを直接かつ恒久的に書き換える。

graph LR
    A["ユーザーコード<br/>ioutil.ReadFile()"] --> B["go fix 実行"]
    B --> C["インライナーが<br/>ディレクティブを検出"]
    C --> D["呼び出しを<br/>関数本体で置換"]
    D --> E["書き換え済みコード<br/>os.ReadFile()"]

goplsの「Inline call」リファクタリング(VS Codeなら「Source Action…」メニュー)は以前からこのインライナーを使っていた。go fixでそれがコマンドラインから一括適用できるようになった形だ。

ioutil.ReadFileで見る基本的な動作

Go 1.16でioutil.ReadFileos.ReadFileの薄いラッパーになり、非推奨になった。

package ioutil

import "os"

// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
func ReadFile(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}

ここに//go:fix inlineを追加する。

// Deprecated: As of Go 1.16, this function simply calls [os.ReadFile].
//go:fix inline
func ReadFile(filename string) ([]byte, error) {
    return os.ReadFile(filename)
}

go fix -diff ./...を実行すると、呼び出し元のコードがこう変わる。

-import "io/ioutil"
+import "os"

-    data, err := ioutil.ReadFile("hello.txt")
+    data, err := os.ReadFile("hello.txt")

importまで含めて正しく書き換えてくれる。前回の記事で紹介したinterface{}anyのような組み込みフィクサーとは異なり、パッケージ作者自身がディレクティブを付与する点がポイントだ。

設計ミスの修正にも使える

単なるリネームより複雑なケース、たとえば引数の順序ミスの修正にも対応できる。

package oldmath

// Sub returns x - y.
// Deprecated: the parameter order is confusing.
//go:fix inline
func Sub(y, x int) int {
    return newmath.Sub(x, y)
}

利用側がoldmath.Sub(1, 10)を呼んでいた場合、go fix後はnewmath.Sub(10, 1)に書き換わる。引数の順序が修正された状態で新しい関数に移行できる。

他にも、不要なデフォルト引数の追加や非推奨定数の置き換えなど、関数だけでなく型エイリアスや定数にも対応している。

//go:fix inline
type Rational = newmath.Rational

//go:fix inline
const Pi = newmath.Pi

インライナーが処理する6つのトリッキーなケース

一見シンプルに見えるソースレベルインライニングだが、正確さを保ちながら読みやすいコードを生成するには多くのエッジケースを処理する必要がある。実装は約7,000行に及ぶ。

1. パラメータの除去と保持

引数がリテラルでパラメータが1回しか使われていなければ、単純に置き換えられる。

//go:fix inline
func show(prefix, item string) {
    fmt.Println(prefix, item)
}
show("", "hello")
// → fmt.Println("", "hello")

パラメータが複数回使われる場合は、マジックナンバーをコード中に散在させないために明示的なバインディング宣言を挿入する。

//go:fix inline
func printPair(before, x, y, after string) {
    fmt.Println(before, x, after)
    fmt.Println(before, y, after)
}
printPair("[", "one", "two", "]")
// →
var before, after = "[", "]"
fmt.Println(before, "one", after)
fmt.Println(before, "two", after)

この判断はインライナーが引数ごとに「何回参照されるか」「副作用があるか」を解析して自動で行う。

2. 副作用の評価順序

func add(x, y int) int { return y + x }
z = add(f(), g())

単純に置き換えるとg() + f()になり、副作用の評価順が変わってしまう。インライナーはf()g()が互いに副作用を持たないことを証明できれば直接置き換え、そうでなければパラメータバインディングを挿入する。

var x = f()
z = g() + x

コンパイラのインライニングとの決定的な違いがここにある。コンパイラのインライニングは中間表現レベルで動作するため、元のソースコードに影響を与えない。一方、go fixのインライナーはソースを恒久的に書き換えるため、現時点で副作用のない関数でも将来の変更に備えて安全側に倒す必要がある。

3. コンパイル時定数の罠

定数引数への置き換えが、意図せずコンパイル時評価を引き起こしてビルドエラーになるケースがある。

//go:fix inline
func index(s string, i int) byte { return s[i] }
index("", 0)

""[0]はコンパイル時に評価され、範囲外アクセスでビルドエラーになる。元のコードでは実行時にしか評価されないため、panicするとしてもビルドは通る。テストが通っているプログラムで使われているかもしれない。インライナーはこうした定数化のリスクを追跡し、必要に応じてパラメータバインディングを維持する。

4. 変数のシャドウイング

引数の式に含まれる識別子が、関数本体で同名の変数にシャドウされると、置き換え後に参照先が変わってしまう。

//go:fix inline
func f(val string) {
    x := 123
    fmt.Println(val, x)
}
x := "hello"
f(x)

単純にvalxで置き換えると、関数本体のx := 123によって引数のxがシャドウされる。インライナーはこれを検出し、ブロックスコープで隔離する。

x := "hello"
{
    var val string = x
    x := 123
    fmt.Println(val, x)
}

逆方向のシャドウイング(関数本体の識別子が呼び出し元でシャドウされるケース)も検出する。さらに、必要なimportが存在しない場合は自動的に追加する。

5. 未使用変数エラーの回避

副作用のない引数で、対応するパラメータが使われていない場合、その引数式を除去できる。しかし引数が呼び出し元のローカル変数への最後の参照だった場合、除去するとGoのコンパイルエラー(変数未使用)になる。

//go:fix inline
func f(_ int) { print("hello") }
x := 42
f(x)
// → x := 42  ← error: unused variable: x
//    print("hello")

インライナーはローカル変数への参照カウントを追跡し、最後の参照を誤って削除しないようにする。

6. deferの制約

deferを含む関数はインライン化の対象外だ。deferの実行タイミングは「関数の戻り時」であり、インライン化すると呼び出し元の戻り時に実行されてしまう。セマンティクスが完全に変わってしまうため、go fixのバッチモードではインライン化を拒否するポリシーになっている。

graph TD
    A["//go:fix inline<br/>付き関数を検出"] --> B{"deferを含む?"}
    B -- Yes --> C["インライン化を拒否"]
    B -- No --> D{"副作用の<br/>評価順は安全?"}
    D -- Yes --> E["直接置換"]
    D -- No --> F["パラメータ<br/>バインディング挿入"]
    E --> G{"シャドウイング<br/>の問題は?"}
    F --> G
    G -- Yes --> H["ブロックスコープで隔離"]
    G -- No --> I["変換完了"]
    H --> I

Googleモノレポでの実績

このインライナーはJava、Kotlin、C++での類似ツールの経験を踏まえて設計されている。Google社内のGoコードベースでは、非推奨関数への何百万もの呼び出し削除にすでに活用されてきた。Go版のインライナーは社内モノレポで18,000件以上のchangelistに適用済みだ。

規模感として、Googleのモノレポには数十億行のコードがあり、Goはその中でも主要言語の一つ。手動で非推奨APIを置き換えていくのは現実的ではなく、こうしたソースレベル変換ツールが必須になる。//go:fix inlineはその仕組みを社外にも開放したものだ。

使い方

IDEでインタラクティブに使うなら、goplsが対応しているエディタで「Inline call」のコードアクションを選ぶ(VS Codeでは「Source Action…」メニュー)。

バッチ処理として一括適用するには、自分のパッケージの非推奨関数に//go:fix inlineを付けてから実行する。

go fix -diff ./...    # 変更のプレビュー
go fix ./...          # 実際に適用

前回の記事で紹介した組み込みフィクサー(interface{}anymin/max導入など)と//go:fix inlineは同じgo fixコマンドで実行される。go fix ./...一発で、組み込みフィクサーによるイディオム更新と、パッケージ作者が定義したAPI移行の両方が適用される。


前回は組み込みフィクサーの紹介が中心だったが、//go:fix inlineのほうがインパクトは大きいかもしれない。標準ライブラリだけでなく、自分のライブラリの非推奨APIもユーザーにgo fix一発で移行してもらえるようになる。ライブラリ作者が移行ガイドを書いてユーザーに手動で直してもらう、というのがなくなる。