The Go Blog

後方互換性、Go 1.21、および Go 2

Russ Cox
2023年8月14日

Go 1.21 には、互換性を向上させる新機能が含まれています。読み始める前に、それが退屈な話だと知っていることをお伝えします。しかし、退屈であることは良いことです。Go 1 の初期の頃、Go はエキサイティングで驚きに満ちていました。毎週新しいスナップショットリリースをカットし、誰もがサイコロを振って、何が変更され、プログラムがどのように壊れるかを確認していました。新しい Go のリリースが退屈になるように、Go 1 とその互換性保証をリリースし、興奮を取り除きました。

退屈であることは良いことです。退屈であることは安定していることです。退屈であることは、Go の違いではなく、自分の仕事に集中できることを意味します。この投稿は、Go を退屈に保つために Go 1.21 で出荷した重要な作業についてです。

Go 1 の互換性

私たちは10年以上にわたって互換性に注力してきました。2012年の Go 1 のために、「Go 1 と Go プログラムの未来」というタイトルのドキュメントを公開しました。これは非常に明確な意図を示しています

Go 1 の仕様に従って書かれたプログラムは、その仕様の存続期間中、変更されずに正しくコンパイルおよび実行され続けることを意図しています。…今日動作する Go プログラムは、将来の Go 1 のリリースが発表されても動作し続けるはずです。

それにはいくつかの条件があります。まず、互換性とはソース互換性を意味します。Go の新しいバージョンに更新するときは、コードを再コンパイルする必要があります。次に、新しい API を追加できますが、既存のコードを壊すような方法では追加できません。

ドキュメントの最後には、「将来の変更がいかなるプログラムも壊さないことを保証することは不可能である」と警告されています。そして、プログラムが壊れる可能性のあるいくつかの理由が挙げられています。

たとえば、プログラムがバグのある動作に依存していて、そのバグを修正した場合、プログラムが壊れるのは当然です。しかし、私たちはできる限りプログラムを壊さないように、Go を退屈に保つよう努めています。これまでに使用してきた主なアプローチは、API チェックとテストの2つです。

API の確認

互換性について最も明確な事実はおそらく、API を削除できないことです。そうしないと、それを使用しているプログラムが壊れてしまうからです。

たとえば、誰かが書いた、壊すことのできないプログラムをここに示します

package main

import "os"

func main() {
    os.Stdout.WriteString("hello, world\n")
}

パッケージ `os` を削除することはできません。グローバル変数 `os.Stdout` (これは `*os.File` です) を削除することはできません。また、`os.File` メソッド `WriteString` を削除することもできません。これらのいずれかを削除すると、このプログラムが壊れることは明らかでしょう。

`os.Stdout` の型をまったく変更できないことは、それほど明確ではないかもしれません。同じメソッドを持つインターフェースにしたいとしましょう。先ほど見たプログラムは壊れませんが、次のプログラムは壊れます

package main

import "os"

func main() {
    greet(os.Stdout)
}

func greet(f *os.File) {
    f.WriteString(“hello, world\n”)
}

このプログラムは、`*os.File` 型の引数を必要とする `greet` という関数に `os.Stdout` を渡します。したがって、`os.Stdout` をインターフェースに変更すると、このプログラムは壊れます。

Go の開発を支援するために、各パッケージのエクスポートされた API のリストを実際のパッケージとは別のファイルで管理するツールを使用しています

% cat go/api/go1.21.txt
pkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386
pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685
pkg bytes, method (*Buffer) Available() int #53685
pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488
pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488
pkg cmp, type Ordered interface {} #59488
pkg context, func AfterFunc(Context, func()) func() bool #57928
pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661
pkg context, func WithoutCancel(Context) Context #40221
pkg context, func WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc) #56661

標準テストの1つは、実際のパッケージAPIがそれらのファイルと一致するかどうかをチェックします。パッケージに新しいAPIを追加すると、APIファイルに追加しない限りテストが壊れます。そして、APIを変更または削除すると、テストも壊れます。これは間違いを避けるのに役立ちます。しかし、このようなツールは、APIの変更と削除という特定の種類の問題しか見つけられません。Goに互換性のない変更を加える方法は他にもあります。

それが、Go を退屈に保つための2番目のアプローチ、つまりテストにつながります。

テスト

予期せぬ非互換性を見つける最も効果的な方法は、既存のテストを次の Go リリースの開発バージョンに対して実行することです。Google のすべての社内 Go コードに対して、Go の開発バージョンを継続的にテストしています。テストが合格したら、そのコミットを Google の本番 Go ツールチェーンとしてインストールします。

変更が Google 社内でテストを壊した場合、Google 社外でもテストを壊すだろうと仮定し、影響を減らす方法を探します。ほとんどの場合、変更を完全にロールバックするか、プログラムを壊さないように書き直す方法を見つけます。しかし、場合によっては、変更が重要であり、一部のプログラムを壊すにもかかわらず「互換性がある」と結論付けることもあります。その場合でも、可能な限り影響を減らすよう努め、リリースノートに潜在的な問題を文書化します。

以下に、Google 社内で Go をテストすることによって発見され、Go 1.1 に含まれた、このような微妙な互換性の問題の2つの例を示します。

構造体リテラルと新しいフィールド

Go 1 で正常に実行されるコードを次に示します

package main

import "net"

var myAddr = &net.TCPAddr{
    net.IPv4(18, 26, 4, 9),
    80,
}

パッケージ `main` は、`net.TCPAddr` 型の複合リテラルであるグローバル変数 `myAddr` を宣言しています。Go 1 では、パッケージ `net` は `TCPAddr` 型を `IP` と `Port` の2つのフィールドを持つ構造体として定義しています。これらは複合リテラルのフィールドと一致するため、プログラムはコンパイルされます。

Go 1.1 では、プログラムは「構造体リテラルの初期化子が少なすぎる」というコンパイラエラーでコンパイルを停止しました。問題は、`net.TCPAddr` に3番目のフィールド `Zone` を追加したため、このプログラムがその3番目のフィールドの値を欠いていることです。解決策は、タグ付きリテラルを使用してプログラムを書き直し、Go の両方のバージョンでビルドできるようにすることです

var myAddr = &net.TCPAddr{
    IP:   net.IPv4(18, 26, 4, 9),
    Port: 80,
}

このリテラルは `Zone` の値を指定しないため、ゼロ値 (この場合は空の文字列) が使用されます。

標準ライブラリの構造体に複合リテラルを使用するというこの要件は、互換性ドキュメントで明示的に言及されており、`go vet` は、後の Go バージョンとの互換性を確保するためにタグが必要なリテラルを報告します。この問題は Go 1.1 では比較的新しいため、リリースノートに短いコメントが記載されることになりました。現在では、新しいフィールドについて言及するだけです。

時間の精度

Go 1.1 のテスト中に発見した2番目の問題は、API とはまったく関係ありませんでした。それは時間に関することでした。

Go 1 がリリースされた直後、誰かが `time.Now` がマイクロ秒単位の精度で時間を返すのに、追加のコードを使えばナノ秒単位の精度で時間を返すことができると指摘しました。それは良いことですよね?精度が高い方が優れています。そこで、その変更を行いました。

これにより、Google 社内で、概ね次のような少数のテストが壊れました

func TestSaveTime(t *testing.T) {
    t1 := time.Now()
    save(t1)
    if t2 := load(); t2 != t1 {
        t.Fatalf("load() = %v, want %v", t1, t2)
    }
}

このコードは `time.Now` を呼び出し、結果を `save` と `load` を通して往復させ、同じ時間が返されることを期待しています。`save` と `load` がマイクロ秒の精度しか格納しない表現を使用している場合、Go 1 では正常に動作しますが、Go 1.1 では失敗します。

このようなテストの修正を支援するため、不要な精度を破棄する `Round` メソッドと `Truncate` メソッドを追加し、リリースノートでは、考えられる問題とそれを修正するための新しいメソッドを文書化しました。

これらの例は、API チェックでは見つからない種類の非互換性をテストがいかにして見つけるかを示しています。もちろん、テストも互換性の完全な保証ではありませんが、API チェックだけよりも完全です。テスト中に発見し、互換性ルールに違反していると判断してリリース前にロールバックした問題の例は多数あります。時間の精度の変更は、プログラムを壊したが、それでもリリースした興味深い例です。関数が文書化されている動作内で許容され、精度の向上が優れていたため、変更を行いました。

この例は、かなりの努力と注意にもかかわらず、Go を変更すると Go プログラムが壊れる場合があることを示しています。これらの変更は、厳密には Go 1 ドキュメントの意味では「互換性がある」ものですが、それでもプログラムを壊します。これらの互換性の問題のほとんどは、出力の変更、入力の変更、プロトコルの変更のいずれかのカテゴリに分類できます。

出力の変更

出力の変更とは、関数が以前とは異なる出力を返すようになるが、新しい出力が古い出力と同じくらい、あるいはそれ以上に正しい場合に発生します。既存のコードが古い出力のみを期待するように書かれている場合、それは壊れます。`time.Now` がナノ秒の精度を追加した例をちょうど見ました。

並べ替え。もう一つの例は Go 1.6 で発生しました。このとき、並べ替えの実装を変更して、約10%高速化しました。ここに、色のリストを名前の長さで並べ替える例のプログラムがあります

colors := strings.Fields(
    `black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)

Go 1.5:  [red blue green white black yellow orange indigo violet]
Go 1.6:  [red blue white green black orange yellow indigo violet]

ソートアルゴリズムを変更すると、等しい要素の順序が変更されることがよくありますが、ここでもそれが起こりました。Go 1.5 は「green、white、black」の順序で返しましたが、Go 1.6 は「white、green、black」の順序で返しました。

ソートは、等しい結果を任意の順序で返すことが明確に許可されており、この変更により10%高速化されたのは素晴らしいことです。しかし、特定の出力を期待するプログラムは壊れてしまいます。これは、互換性が非常に難しい理由の良い例です。プログラムを壊したくありませんが、文書化されていない実装の詳細に縛られたくもありません。

Compress/flate。別の例として、Go 1.8 では `compress/flate` を改良し、CPU とメモリのオーバーヘッドはほぼ同じままで、より小さい出力を生成するようにしました。これは双方にとってメリットがあるように聞こえますが、Google 社内で再現可能なアーカイブビルドを必要とするプロジェクトを壊しました。彼らは古いアーカイブを再現できなくなったのです。彼らは `compress/flate` と `compress/gzip` をフォークして、古いアルゴリズムのコピーを保持しました。

Go コンパイラでも同様の処理を行っており、`sort` パッケージ (およびその他のパッケージ) のフォークを使用することで、コンパイラが以前の Go バージョンでビルドされていても同じ結果を生成するようにしています。

このような出力変更による非互換性に対しては、有効な出力をすべて受け入れるプログラムとテストを記述し、これらの種類の破損を、単に期待される答えを更新するだけでなく、テスト戦略を変更する機会として利用するのが最善の解決策です。真に再現可能な出力が必要な場合は、次に良い解決策として、コードをフォークして変更から身を守ることですが、バグ修正からも身を守ることになることを忘れないでください。

入力の変更

入力の変更は、関数が受け入れる入力、またはその処理方法を変更した場合に発生します。

ParseInt。たとえば、Go 1.13 では、読みやすさのために大きな数値にアンダースコアをサポートしました。言語の変更に伴い、`strconv.ParseInt` が新しい構文を受け入れるようにしました。この変更は Google 社内で何も壊しませんでしたが、はるか後に、コードが壊れた外部ユーザーから連絡がありました。彼らのプログラムは、データ形式としてアンダースコアで区切られた数値を使用していました。最初に `ParseInt` を試行し、`ParseInt` が失敗した場合にのみアンダースコアのチェックにフォールバックしていました。`ParseInt` が失敗しなくなったため、アンダースコア処理コードは実行されなくなりました。

ParseIP。もう一つの例として、Go の `net.ParseIP` は、初期の IP RFC の例に従っており、多くの場合、先行ゼロを持つ10進数 IP アドレスを示していました。IP アドレス 18.032.4.011 は、いくつかの余分なゼロがあるだけで、18.32.4.11 として読み取られました。後になって、BSD 派生の C ライブラリが IP アドレスの先行ゼロを8進数で始まるものとして解釈することを知りました。これらのライブラリでは、18.032.4.011 は 18.26.4.9 を意味します。

これはGoと世界の他の部分との間で深刻な不一致でしたが、Goリリース間で先行ゼロの意味を変更することも深刻な不一致となるでしょう。それは大きな非互換性となるでしょう。最終的に、Go 1.17で`net.ParseIP`を先行ゼロを完全に拒否するように変更することを決定しました。このより厳格な解析により、GoとCが両方ともIPアドレスを正常に解析した場合、または古いGoバージョンと新しいGoバージョンが両方とも解析した場合に、それらがすべてその意味について同意することが保証されます。

この変更は Google 社内では何も壊しませんでしたが、Kubernetes チームは、以前は解析できていたが Go 1.17 では解析できなくなる可能性がある保存された構成について懸念していました。Go は基本的に他のすべての言語とは異なる方法で解釈するため、先行ゼロを持つアドレスはこれらの構成から削除されるべきでしょうが、それは Go のタイムラインではなく Kubernetes のタイムラインで行われるべきです。セマンティックな変更を避けるため、Kubernetes は元の `net.ParseIP` の独自のフォークされたコピーを使用し始めました。

入力の変更への最善の対応策は、値を解析する前に受け入れたい構文を最初に検証してユーザー入力を処理することですが、コードをフォークする必要がある場合もあります。

プロトコル変更

最後の一般的な種類の非互換性は、プロトコルの変更です。プロトコルの変更とは、プログラムが外部世界と通信するために使用するプロトコルにおいて、最終的に外部から見えるようになるパッケージへの変更です。`ParseInt` や `ParseIP` の場合のように、ほとんどすべての変更が特定のプログラムで外部から見えるようになる可能性がありますが、プロトコルの変更は基本的にすべてのプログラムで外部から見えます。

HTTP/2。プロトコル変更の明確な例は、Go 1.6 が HTTP/2 の自動サポートを追加したときです。Go 1.5 クライアントが HTTP/2 対応サーバーに、たまたま HTTP/2 を壊す中間ボックスのあるネットワークを介して接続しているとします。Go 1.5 は HTTP/1.1 のみを使用するため、プログラムは正常に動作します。しかし、Go 1.6 に更新するとプログラムが壊れます。Go 1.6 が HTTP/2 の使用を開始し、このコンテキストでは HTTP/2 が動作しないためです。

Go はデフォルトで最新のプロトコルをサポートすることを目指していますが、この例は、HTTP/2 の有効化が、プログラム自身の(Go のいかなる欠点でもない)責任ではなく、プログラムを壊す可能性があることを示しています。この状況の開発者は Go 1.5 に戻ることもできますが、それはあまり満足のいくものではありません。代わりに、Go 1.6 はリリースノートで変更を文書化し、HTTP/2 を無効にするのを簡単にするようにしました。

実際、Go 1.6 では HTTP/2 を無効にする2つの方法が文書化されています。パッケージ API を使用して `TLSNextProto` フィールドを明示的に構成するか、GODEBUG 環境変数を設定します

GODEBUG=http2client=0 ./myprog
GODEBUG=http2server=0 ./myprog
GODEBUG=http2client=0,http2server=0 ./myprog

後で詳しく説明しますが、Go 1.21 はこの GODEBUG メカニズムを一般化し、潜在的に破壊的なすべての変更の標準とします。

SHA1。プロトコル変更のより微妙な例を挙げます。HTTPS 用の SHA1 ベースの証明書はもう誰も使用すべきではありません。認証局は2015年に発行を停止し、主要なすべてのブラウザは2017年にそれらの受け入れを停止しました。2020年初頭、Go 1.18 はデフォルトでそれらのサポートを無効にし、その変更をオーバーライドする GODEBUG 設定を用意しました。また、Go 1.19 で GODEBUG 設定を削除する意向を発表しました。

Kubernetes チームから、一部のインストールでは依然としてプライベート SHA1 証明書が使用されていることを知らされました。セキュリティ上の問題はさておき、Kubernetes がこれらの企業に証明書インフラストラクチャをアップグレードさせることは適切ではなく、SHA1 サポートを維持するために `crypto/tls` と `net/http` をフォークすることは非常に困難でしょう。代わりに、円滑な移行のための時間を確保するため、予定よりも長くオーバーライドを維持することに同意しました。結局のところ、できるだけ多くのプログラムを壊したくないのです。

Go 1.21 で拡張された GODEBUG サポート

これまで検討してきたこれらの微妙なケースにおいても後方互換性を向上させるため、Go 1.21 では GODEBUG の使用を拡張し、正式化します。

まず、Go 1 の互換性によって許可されているが、既存のプログラムを壊す可能性がある変更については、潜在的な互換性の問題を理解するためにこれまでに見たすべての作業を行い、可能な限り多くの既存のプログラムが動作し続けるように変更を設計します。残りのプログラムについては、新しいアプローチは次のとおりです

  1. 個々のプログラムが新しい動作をオプトアウトできる新しい GODEBUG 設定を定義します。GODEBUG 設定の追加が不可能である場合は追加されない場合がありますが、それは極めて稀なケースであるべきです。

  2. 互換性のために追加された GODEBUG 設定は、最低2年間 (Go の4リリース) 維持されます。`http2client` や `http2server` など、はるかに長く、あるいは無期限に維持されるものもあります。

  3. 可能であれば、各 GODEBUG 設定には、特定のプログラムの動作がその設定のデフォルト以外の値に基づいて変更された回数をカウントする、`/godebug/non-default-behavior/:events` という名前の関連する `runtime/metrics` カウンターがあります。たとえば、`GODEBUG=http2client=0` が設定されている場合、`/godebug/non-default-behavior/http2client:events` は、プログラムが HTTP/2 サポートなしで構成した HTTP トランスポートの数をカウントします。

  4. プログラムの GODEBUG 設定は、メインパッケージの `go.mod` ファイルに記載されている Go バージョンと一致するように構成されます。プログラムの `go.mod` ファイルが `go 1.20` となっていて、Go 1.21 ツールチェーンに更新した場合でも、Go 1.21 で変更された GODEBUG 制御下の動作は、`go.mod` を `go 1.21` に変更するまで、古い Go 1.20 の動作を維持します。

  5. プログラムは、`main` パッケージの `//go:debug` 行を使用して、個々の GODEBUG 設定を変更できます。

  6. すべての GODEBUG 設定は、簡単に参照できるように 1つの中央リスト に文書化されています。

このアプローチは、Go の新しいバージョンが古い Go バージョンの可能な限り最良の実装であるべきであり、古いコードをコンパイルする際に、後のリリースで互換性はあるが破壊的な方法で変更された動作さえも維持することを意味します。

たとえば、Go 1.21 では、`panic(nil)` は (非 nil の) ランタイムパニックを引き起こすようになりました。これにより、`recover` の結果が、現在のゴルーチンがパニック状態にあるかどうかを確実に報告するようになりました。この新しい動作は GODEBUG 設定によって制御され、メインパッケージの `go.mod` の `go` 行に依存します。`go 1.20` またはそれ以前の場合、`panic(nil)` は引き続き許可されます。`go 1.21` またはそれ以降の場合、`panic(nil)` は `runtime.PanicNilError` を伴うパニックに変わります。また、バージョンベースのデフォルトは、パッケージメインに次のような行を追加することで明示的にオーバーライドできます

//go:debug panicnil=1

これらの機能の組み合わせにより、プログラムは以前使用していたツールチェーンの動作を維持したまま新しいツールチェーンに更新でき、必要に応じて特定の GODEBUG 設定をよりきめ細かく制御でき、本番監視を使用して、どのジョブがこれらのデフォルト以外の動作を実際に利用しているかを把握できます。これらを組み合わせることで、新しいツールチェーンの展開がこれまで以上にスムーズになるはずです。

詳細については、「Go、後方互換性、および GODEBUG」を参照してください。

Go 2 のアップデート

この投稿の冒頭にある「Go 1 と Go プログラムの未来」からの引用文では、省略記号が次の修飾語を隠していました

ある不特定の時点で、Go 2 の仕様が登場するかもしれませんが、その時まで、[…すべての互換性の詳細…]。

これにより、Go 1 の古いプログラムを壊す Go 2 の仕様はいつ頃登場するのか、という明白な疑問が生じます。

答えは「決してありません」。過去との決別を意味し、古いプログラムをコンパイルしなくなるような Go 2 は、決して実現しません。2017年に目指し始めた Go 1 の大規模な改訂という意味での Go 2 は、すでに実現しています。

Go 1 プログラムを壊す Go 2 はありません。代わりに、私たちは互換性をさらに重視します。これは、過去とのいかなる断絶よりもはるかに価値があるからです。実際、互換性を優先することが、Go 1 で行った最も重要な設計上の決定だったと信じています。

したがって、今後数年間で見られるのは、多くの新しくエキサイティングな作業ですが、慎重に、互換性のある方法で行われ、あるツールチェーンから次のツールチェーンへのアップグレードが可能な限り退屈なものになるようにします。

次の記事:Go 1.21 における前方互換性とツールチェーン管理
前の記事:Go 1.21 がリリースされました!
ブログインデックス