//go:fix inline とソースレベルインライナー

//go:fix inline and the source-level inliner by Alan Donovan

Go 1.26には、Goのコードを最新かつモダンな状態に保つことを目的とした go fix サブコマンドの、全面的に刷新された実装が含まれています。その紹介については、まず直近の記事を読むことから始めてください。この記事では、その中の一つの機能、ソースレベルインライナーについて見ていきます。

go fix には、特定の新しい言語機能やライブラリ機能に対応した専用のモダナイザーがいくつも用意されていますが、ソースレベルインライナーは、「セルフサービス」なモダナイザーやアナライザーを提供しようという取り組みの最初の成果です。これによって、どのパッケージの作者でも、単純なAPIの移行や更新を簡単かつ安全な方法で表現できるようになります。まずソースレベルインライナーとは何か、どのように使うのかを説明し、そのあとで、この問題のいくつかの側面と、その背後にある技術について詳しく見ていきます。

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

2023年、私たちはGoにおける関数呼び出しのソースレベルインライニングのためのアルゴリズムを構築しました。呼び出しを「インライン化する」とは、その呼び出しを、呼び出された関数の本体のコピーで置き換え、引数をパラメータに代入することを意味します。これを「ソースレベル」インライニングと呼ぶのは、ソースコードそのものを恒久的に変更するからです。対照的に、Goを含む一般的なコンパイラに見られるインライニングのアルゴリズムは、同様の変換を行いますが、より効率的なコードを生成するために、コンパイラの一時的な中間表現に対して適用されるものです。

もしgoplsの「Inline call」というインタラクティブなリファクタリングを実行したことがあれば、それはソースレベルインライナーを使ったことになります。(VS Codeでは、このコードアクションは「Source Action…」メニューから見つけられます。)以下の変更前後のスクリーンショットは、 six という関数の中にある sum の呼び出しをインライン化した効果を示しています。

six関数の中でsumを呼び出しているコード(変更前) sumの呼び出しがインライン化されたコード(変更後)

このインライナーは、数多くのソース変換ツールにとって重要な構成要素です。例えば、goplsは「Change signature」や「Remove unused parameter」といったリファクタリングにこのインライナーを使っています。これは、後述するように、関数呼び出しをリファクタリングする際に生じる多くの微妙な正当性の問題をインライナーが引き受けてくれるからです。

この同じインライナーは、全面刷新された go fix コマンドのアナライザーの一つでもあります。 go fix の中では、新しい //go:fix inline というディレクティブコメントを使うことで、セルフサービスのAPI移行やアップグレードが可能になります。それではこれがどのように動作し、何に使えるのか、いくつかの例を見ていきましょう。

ioutil.ReadFile のリネーム例

Go 1.16では、ファイルの内容を読み込む ioutil.ReadFile 関数が非推奨となり、代わりに新しい os.ReadFile 関数を使うことが推奨されました。実質的にはこの関数はリネームされたわけですが、もちろんGoの互換性の約束によって、古い名前を削除することは決してできません。

package ioutil

import "os"

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

理想的には、世界中のすべてのGoプログラムで ioutil.ReadFile の利用をやめ、代わりに os.ReadFile を呼び出すように変更したいところです。インライナーはそれを助けてくれます。まず、古い関数に //go:fix inline という注釈を付けます。このコメントは、ツールに対して、この関数への呼び出しを見つけるたびにその呼び出しをインライン化するように指示します。

package ioutil

import "os"

// ReadFile reads the file named by filename…
// 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)
}

ioutil.ReadFile の呼び出しを含むファイルに対して go fix を実行すると、次のような置き換えが行われます。

$ go fix -diff ./...
-import "io/ioutil"
+import "os"

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

この呼び出しはインライン化され、実質的に一つの関数への呼び出しが別の関数への呼び出しに置き換えられています。

インライナーは、任意の式によってではなく、呼び出された関数の本体のコピーによって関数呼び出しを置き換えるため、原則としてこの変換はプログラムの振る舞いを変えることはありません(もちろん、コールスタックを調べるようなコードは例外です)。これは、 gofmt -r のような任意の書き換えを許す他のツールとは異なる点です。そうしたツールは非常に強力ですが、注意深く見張っておく必要があります。

Google社内でJava、Kotlin、C++をサポートしているチームの同僚たちは、もう何年も前からこのようなソースレベルインライナーツールを使ってきました。これまでに、これらのツールはGoogleのコードベースにある非推奨関数への呼び出しを何百万件も取り除いてきました。ユーザーはディレクティブを追加して待つだけです。夜のあいだにロボットたちが静かに、数十億行規模のモノレポ全体にわたるコード変更のバッチを準備し、テストし、投入します。すべてがうまくいけば、朝までには古いコードはもう使われなくなり、安全に削除できるようになっています。Goのインライナーは比較的新参者ですが、すでにGoogleのモノレポに対して18,000件を超えるチェンジリストを準備するために使われています。

APIの設計上の欠陥を修正する例

少し工夫すれば、さまざまな種類の移行をインライニングとして表現できます。次のような架空の oldmath パッケージを考えてみましょう。

// oldmathパッケージは、出来の悪い古いmathパッケージです。
package oldmath

// Subはx - yを返します。
func Sub(y, x int) int

// Infは正の無限大を返します。
func Inf() float64

// Negは-xを返します。
func Neg(x int) int

このパッケージにはいくつかの設計上の欠陥があります。 Sub 関数はパラメータの順番が間違っており、 Inf 関数は2つある無限大のうち片方を暗黙のうちに優先しており、 Neg 関数は Sub と機能が重複しています。幸い、これらの誤りを避けた newmath パッケージがあるので、ユーザーにはそちらへ切り替えてもらいたいところです。最初のステップは、古いAPIを新しいパッケージを使って実装し直し、古い関数を非推奨にすることです。そして、インライナーのディレクティブを追加します。

// oldmathパッケージは、出来の悪い古いmathパッケージです。
package oldmath

import "newmath"

// Subはx - yを返します。
// Deprecated: パラメータの順番がわかりにくいです。
//go:fix inline
func Sub(y, x int) int {
    return newmath.Sub(x, y)
}

// Infは正の無限大を返します。
// Deprecated: 無限大の値は2つあるので、明示的に指定してください。
//go:fix inline
func Inf() float64 {
    return newmath.Inf(+1)
}

// Negは-xを返します。
// Deprecated: この関数は不要です。
//go:fix inline
func Neg(x int) int {
    return newmath.Sub(0, x)
}

こうしておけば、 oldmath のユーザーが自分のコードに対して go fix コマンドを実行したときに、古い関数へのすべての呼び出しが新しい関数への呼び出しに置き換えられます。ちなみに、goplsはしばらく前から inline をアナライザースイートに含めているので、あなたのエディタがgoplsを使っているなら、 //go:fix inline ディレクティブを追加した瞬間から、各呼び出し箇所で「oldmath.Sub の呼び出しはインライン化すべきです」といった診断が、その呼び出しをインライン化する修正候補とともに表示されるようになるはずです。

例えば、次のような古いコードがあるとします。

import "oldmath"

var nine = oldmath.Sub(1, 10) // 診断: 「oldmath.Subの呼び出しはインライン化すべきです」

次のように変換されます。

import "newmath"

var nine = newmath.Sub(10, 1)

修正後は、 Sub への引数が論理的な順番になっていることに注目してください。これは前進です! 運が良ければ、インライナーは oldmath 内の関数へのすべての呼び出しを取り除くことに成功し、依存関係としてこのパッケージを削除できるようになるかもしれません。

inline アナライザーは型や定数に対しても機能します。もし私たちの oldmath パッケージがもともと有理数を表すデータ型やπを表す定数を宣言していたとしたら、既存のコードの振る舞いを保ったまま、次のような転送宣言を使って newmath パッケージへ移行できます。

package oldmath

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

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

inline アナライザーは、 oldmath.Rationaloldmath.Pi への参照を見つけるたびに、それらを代わりに newmath を参照するように更新します。

インライナーの内部

一見すると、ソースインライニングは単純に見えます。呼び出しを呼び出し先の関数の本体で置き換え、関数のパラメータに対応する変数を導入し、呼び出しの引数をそれらの変数に束縛するだけです。しかし、すべての複雑な事情やコーナーケースを正しく処理しながら、納得のいく結果を生成することは、決して小さくない技術的な課題です。インライナーはおよそ7,000行にも及ぶ、コンパイラのような密度の濃いロジックでできています。この問題をこれほど厄介にしている、6つの側面を見ていきましょう。

1. パラメータの除去

インライナーの最も重要な仕事の一つは、呼び出し先にあるパラメータの出現それぞれを、その呼び出しにおける対応する引数で置き換えようと試みることです。もっとも単純な場合、引数は 0"" のような単純なリテラルであるため、置き換えは簡単で、そのパラメータは除去できます。

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

変更前:

show("", "hello")

変更後:

fmt.Println("", "hello")

404"go.dev" のような、あまり単純ではないリテラルの場合も、そのパラメータが呼び出し先の中でたかだか1回しか出現しないのであれば、置き換えは同じように簡単です。しかし、もし複数回出現するのであれば、そうしたマジックバリューのコピーをコードのあちこちにばらまくのは行儀の良いスタイルとは言えません。それらの間の関係が分かりにくくなりますし、後になって片方だけを変更してしまうと不整合が生じかねないからです。

このような場合、インライナーは慎重に振る舞い、より保守的な結果を出力しなければなりません。一つ以上のパラメータが何らかの理由で完全には置き換えられないときは、インライナーは明示的な「パラメータ束縛」宣言を挿入します。

//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. 副作用

Goでも、他のすべての命令型プログラミング言語と同様に、関数を呼び出すと変数を更新するという副作用が発生することがあり、それが他の関数の振る舞いに影響を与えることがあります。以下の add の呼び出しを考えてみましょう。

func add(x, y int) int { return y + x }

z = add(f(), g())

この呼び出しを単純にインライン化すると、 xf() に、 yg() に置き換えて、次のような結果になります。

z = g() + f()

しかし、この結果は誤りです。なぜなら、 g() の評価が f() よりも先に行われるようになってしまうからです。この2つの関数に副作用があれば、それらの副作用が異なる順序で観測されることになり、式の結果に影響を与えかねません。もちろん、呼び出しの引数の間で副作用の順序に依存するようなコードを書くのは行儀が良いとは言えませんが、だからといって誰もそうしたコードを書かないというわけではなく、私たちのツールはそれを正しく扱わなければなりません。

そこで、インライナーは f()g() が互いに副作用を及ぼさないことを証明しようとしなければなりません。証明に成功すれば、先ほどの結果をそのまま安全に使うことができます。証明できなければ、明示的なパラメータ束縛にフォールバックしなければなりません。

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

副作用を考える際に重要なのは、引数の式だけではありません。パラメータが呼び出し先の他のコードに対してどの順番で評価されるかも重要です。次の add2 への呼び出しを考えてみましょう。

//go:fix inline
func add2(x, y int) int {
    return x + other() + y
}

add2(f(), g())

今回は、パラメータ xy は宣言されたのと同じ順番で使われているので、 f() + other() + g() という置き換えをしても f()g() の副作用の順序は変わりません。しかし、 other()g() の副作用の順序は変わってしまいます。さらに、もし関数本体がループの中でパラメータを使っている場合、置き換えによって副作用の生起回数が変わってしまうこともあります。

インライナーは、各呼び出し先関数における副作用の順序をモデル化するために、独自のハザード解析を用いています。とはいえ、必要な安全性の証明を構築できる能力にはかなりの限界があります。例えば、 f()g() の呼び出しが単純なアクセサであれば、どちらの順番で呼んでも完全に安全なはずです。実際、最適化コンパイラであれば、 fg の内部についての知識を使って、この2つの呼び出しの順番を安全に入れ替えられるかもしれません。しかし、ある特定の瞬間のソースを反映したオブジェクトコードを生成するコンパイラとは違い、インライナーの目的はソースコードそのものに恒久的な変更を加えることなので、一時的な内部の詳細を利用することはできません。極端な例として、次の start 関数を考えてみてください。

func start() { /* TODO: implement */ }

最適化コンパイラは、 start() への呼び出しが現時点では何の効果も持たないので、自由に削除して構いません。しかしインライナーはそうはいきません。明日には重要な処理になっているかもしれないからです。

要するに、インライナーは、事情に通じたプロジェクトのメンテナーの目から見れば明らかに保守的すぎる結果を生成することがあります。そのような場合、修正後のコードは、多少手作業で整理してあげるとスタイル面で改善するでしょう。

3. 「失敗しうる」定数式

パラメータ変数を同じ型の定数引数で置き換えるのは常に安全だと思うかもしれません(私もかつてはそう思っていました)。しかし驚いたことに、これは必ずしも正しくありません。以前は実行時に行われていたチェックが、コンパイル時に行われるようになり、しかも失敗してしまうことがあるからです。次の index 関数への呼び出しを考えてみましょう。

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

index("", 0)

素朴なインライナーであれば、 s"" に、 i0 に置き換えて ""[0] という結果を作ってしまうかもしれませんが、これはこの特定の文字列に対してこの特定のインデックスが範囲外であるため、実際には正しいGoの式ではありません。式 ""[0] は定数だけで構成されているので、コンパイル時に評価され、これを含むプログラムはビルドすらできません。それに対して、元のプログラムは、この index の呼び出しに実行が到達したときにだけ失敗します。正しく動作するプログラムであれば、おそらくそこには到達しないでしょう。

そのため、インライナーは、パラメータの置き換えによって定数になり得るすべての式とそのオペランドを追跡し、追加のコンパイル時チェックが発生しないようにしなければなりません。インライナーは制約系を構築してそれを解こうとします。満たされない制約はそれぞれ、制約のかかったパラメータに対して明示的な束縛を追加することで解決されます。

4. シャドーイング

典型的な引数の式には、呼び出し元のファイルにあるシンボル(変数や関数など)を参照する識別子が1つ以上含まれています。インライナーは、パラメータの置き換えのあとも、引数の式にある各名前が同じシンボルを参照し続けることを保証しなければなりません。つまり、呼び出し元の名前のどれもが呼び出し先でシャドーイングされないようにするということです。これが満たされない場合、インライナーは次の例のように、再びパラメータ束縛を挿入しなければなりません。

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

変更前:

x := "hello"
f(x)

変更後:

x := "hello"
{
    // シャドーイングされる前に呼び出し元のxを読み取るための
    // もう一つの「パラメータ束縛」宣言
    var val string = x
    x := 123
    fmt.Println(val, x)
}

逆に、インライナーは、呼び出し先の関数本体にある各名前が、呼び出し箇所に埋め込まれたあとも同じものを参照し続けることも確認しなければなりません。つまり、呼び出し先の名前のどれもが、呼び出し元でシャドーイングされたり見つからなくなったりしないということです。名前が見つからない場合、インライナーは追加のインポートを挿入する必要があるかもしれません。

5. 未使用の変数

引数の式に副作用がなく、対応するパラメータがまったく使われていない場合、その式は除去してかまいません。しかし、その式が呼び出し元にあるローカル変数への最後の参照を含んでいる場合、その変数が未使用になってしまい、コンパイルエラーが発生することがあります。

//go:fix inline
func f(_ int) { print("hello") }

変更前:

x := 42
f(x)

変更後:

x := 42 // エラー: 未使用の変数: x
print("hello")

そのため、インライナーはローカル変数への参照を把握し、最後の参照を取り除かないようにしなければなりません。(もちろん、2つの異なるインライナーの修正が、それぞれ変数への最後から2番目の参照を取り除いてしまい、その結果、それぞれの修正は単独では正しいのに、両方を適用すると正しくなくなるということも起こり得ます。前回の記事にある意味的な衝突についての議論を参照してください。残念ながら、この場合は手作業での後始末がどうしても必要になります。)

6. Defer

場合によっては、呼び出しをインライン化して消してしまうことがそもそも不可能なこともあります。 defer 文を使っている関数への呼び出しを考えてみましょう。もしこの呼び出しを取り除いてしまうと、deferされた関数は呼び出し元の関数がreturnするときに実行されることになり、それでは遅すぎます。呼び出し先が defer を使っている場合に安全にできることは、呼び出し先の本体を関数リテラルに入れて即座に呼び出すことだけです。この func() { … }() という関数リテラルが、次の例のように defer 文の寿命の範囲を区切ってくれます。

//go:fix inline
func callee() {
    defer f()
    
}

変更前:

callee()

変更後:

func() {
    defer f()
    
}()

goplsでインライナーを実行すると、上記のような変更が行われ、関数リテラルが導入されることがわかります。この結果は、対話的な環境では適切かもしれません。というのも、あなたはすぐに好みに応じてコードを微調整する(あるいは修正を取り消す)でしょうから。しかし、バッチツールにおいてこれが望ましいことはめったにないため、ポリシーとして、 go fix のアナライザーはこのような「リテラル化された」呼び出しをインライン化することを拒否します。

「整頓」のための最適化コンパイラ

ここまでで、インライナーが厄介な意味論的なコーナーケースを正しく扱う例を6つ見てきました。(洞察、議論、レビュー、機能、修正について、Rob Findley、Jonathan Amsterdam、Olena Synenka、Lasse Folgerの各氏に感謝します。)このように賢さのすべてをインライナーに詰め込むことで、ユーザーはIDEで「Inline call」というリファクタリングを適用するだけ、あるいは自分の関数に //go:fix inline ディレクティブを追加するだけで、その結果生じるコード変換をごく表面的なレビューだけで適用できると確信を持てるようになります。

この目標に向けて大きく前進してきましたが、まだ完全には達成できておらず、おそらく今後も完全に達成することはないでしょう。コンパイラについて考えてみてください。健全なコンパイラは、どんな入力に対しても正しい出力を生成し、コードを誤ってコンパイルすることは決してありません。これは、あらゆるユーザーが自分のコンパイラに対して抱くべき基本的な期待です。最適化コンパイラは、安全性を損なうことなく速度のために注意深く選ばれたコードを生成します。同様に、インライナーも一種の最適化コンパイラのようなものですが、その目標は速度ではなく整頓であり、呼び出しのインライン化はプログラムの振る舞いを決して変えてはならず、理想的にはできる限り整然として整頓されたコードを生成します。残念ながら、最適化コンパイラの仕事が終わることは証明できる通り決してありません。2つの異なるプログラムが等価であることを示すのは決定不能な問題であり、専門家であれば安全だとわかっていてもコンパイラには証明できない改善が常に存在します。インライナーについても同じことが言えます。インライナーの出力が、人間の専門家によるものと比べて過度に神経質だったり、スタイル面で劣っていたりする場合は常にあり、追加すべき「整頓の最適化」も常に残り続けます。

試してみよう

このインライナーをめぐる一連の紹介を通じて、そこに関わる課題のいくつかと、健全でセルフサービスなコード変換ツールを提供するにあたっての私たちの優先事項や方向性について、感じ取っていただけたなら幸いです。ぜひ、IDEで対話的に、あるいは //go:fix inline ディレクティブと go fix コマンドを通じて、このインライナーを試してみてください。そして、あなたの経験や、さらなる改善、新しいツールについてのアイデアがあれば、私たちに教えてください。

By Alan Donovan