- Go 1.21 は、新しいツールチェーンが過去の Go バージョンの動作まで可能な限り安定して実装できるよう GODEBUG ベースの互換性を拡張し、アップグレードの負担を下げることに重点を置いている
- Go は 2012 年の Go 1 から ソース互換性を約束しており、公開 API の検査と大規模な内部テストによって、削除や変更による破壊を減らしてきた
time.Now の精度向上、sort 実装の変更、compress/flate の出力変化、strconv.ParseInt の入力拡大、net.ParseIP のパース変更のように、文書上は許容される改善でも既存プログラムを壊すことがある
- Go 1.21 から、互換性用の GODEBUG 設定は少なくとも 2 年または 4 つの Go リリースの間維持され、
go.mod の go バージョンに応じて以前の動作をデフォルト値として保持できる
- Go 2 は Go 1 のプログラムを壊す新しい仕様として登場することはなく、Go は新機能を追加しても 互換性を優先し、ツールチェーンのアップグレードを安定して維持する
Go 1 互換性の基本原則
- Go は 2012 年の Go 1 で「Go 1 and the Future of Go Programs」文書を通じて、Go 1 仕様に従ったプログラムがその仕様の存続期間中、変更なしで継続してコンパイルされ、正しく実行されるようにするという目標を掲げた
- この約束の中心は ソース互換性である
- 新しい Go バージョンに更新するとき、コードは再コンパイルする必要がある
- 新しい API は追加できるが、既存コードを壊す形での追加は避けるべきである
- どの将来の変更も、すべてのプログラムを絶対に壊さないと保証することはできない
- プログラムがバグのある挙動に依存しているなら、そのバグが修正されたときにプログラムが壊れる可能性がある
- Go は破壊を可能な限り減らしつつ、安定したアップグレードを維持しようとしている
公開 API 検査で防ぐ互換性破壊
- Go の開発プロセスでは、各パッケージの 公開 API の一覧を実際のパッケージとは別ファイルで管理している
- 例として
go/api/go1.21.txt には、bytes、cmp、context などの関数・メソッド・型の項目が記録されている
- 標準テストは、実際のパッケージ API がこれらのファイルと一致するかを確認する
- 新しい API を追加した場合、API ファイルにも追加しなければテストは通らない
- 既存 API を変更または削除してもテストは失敗する
- API の削除だけでなく、型の変更も互換性を壊しうる
os.Stdout は *os.File 型のグローバル変数である
- これを同じメソッドを持つインターフェースに変えると、
greet(f *os.File) のように *os.File を要求するコードが壊れる
- API 検査は API の変更・削除を検出するのに有用だが、Go で起こりうるすべての 非互換な変更を防ぐわけではない
テストが明らかにする微妙な破壊
- 新しい Go リリースの開発版は、Google 内部の Go コード全体を対象に継続的にテストされる
- テストが通ると、そのコミットが Google の本番 Go ツールチェーンに導入される
- 内部テストが壊れた場合、外部コードも壊れる可能性があると見て影響を減らす方法を探す
- 多くの場合、変更を元に戻すか、プログラムを壊さないように書き直す
- 一部の変更は、プログラムを壊しても重要であり、文書上 互換性のある変更として残ることがある
- この場合でも影響範囲を減らし、リリースノートに潜在的な問題を記載する
Go 1.1 で明らかになった 2 つの事例
-
構造体リテラルと新しいフィールド
- Go 1 の
net.TCPAddr は IP と Port の 2 フィールドを持つ構造体で、フィールド名のない複合リテラルもコンパイルできた
- Go 1.1 で
net.TCPAddr に Zone フィールドが追加されると、既存コードは “too few initializers in struct literal” エラーでコンパイルできなくなった
- 互換性のある書き方は 名前付きリテラルを使うことである
var myAddr = &net.TCPAddr{
IP: net.IPv4(18, 26, 4, 9),
Port: 80,
}
Zone を指定しなければ、そのフィールドには zero value である空文字列が使われる
- 標準ライブラリの構造体には名前付き複合リテラルを使うべきだという要件が互換性文書に含まれており、
go vet は将来のバージョンとの互換性に必要な、名前のないリテラルを報告する
-
時刻精度
- Go 1 の後、
time.Now はマイクロ秒精度ではなくナノ秒精度を返すように変更された
- この変更は、
time.Now の値を save と load で往復したあと同一性を期待するテストを壊しうるものだった
- 保存表現がマイクロ秒精度しか保持しない場合、Go 1 では成功しても Go 1.1 では失敗する可能性がある
- Go はこうしたテストの修正を助けるために
Round と Truncate メソッドを追加し、リリースノートに潜在的な問題と新しいメソッドを整理した
- 精度向上はより良い挙動であり、関数の文書化された範囲内で許容されていたため、一部のプログラムを壊してもリリースされた
互換性を壊しうる 3 種類の変更
-
出力変更
- 出力変更は、関数が以前と異なる出力を返すが、新しい出力も以前の出力と同じくらい正しいか、より正しい場合に起こる
time.Now のナノ秒精度追加が代表例である
- Go 1.6 では
sort 実装を約 10% 高速化した結果、同じ値と見なされる要素の順序が変わった
- 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.8 では
compress/flate が、近い CPU・メモリオーバーヘッドでより小さい出力を生成するよう改善された
- このため、Google 内部の再現可能なアーカイブビルドでは、既存アーカイブをそのまま再現できなくなった
- そのプロジェクトは以前のアルゴリズムを維持するため、
compress/flate と compress/gzip をフォークした
- 出力変更に備えるには、プログラムとテストをすべての有効な出力を許容するように書くのが望ましい
- 真に再現可能な出力が必要ならコードをフォークできるが、その分だけバグ修正からも離れることになる
-
入力変更
- 入力変更は、関数が受け入れる入力や処理方法を変更するときに起こる
- Go 1.13 は数値の可読性のためアンダースコア構文を追加し、
strconv.ParseInt もこの新しい構文を受け入れるようになった
- アンダースコア区切りの数値を別のデータ形式として使っていた外部ユーザーのコードは壊れた
- そのコードはまず
ParseInt を試し、失敗したときだけアンダースコア処理をしていたが、ParseInt がもはや失敗しなくなったためである
net.ParseIP は初期の IP RFC 例に従って、先頭に 0 のある 10 進 IP アドレスを許容していた
- Go は
18.032.4.011 を 18.32.4.11 として読んでいた
- BSD 系 C ライブラリは先頭の 0 を 8 進数の開始と解釈し、同じ文字列を
18.26.4.9 として読んでいた
- Go 1.17 では
net.ParseIP が先頭の 0 を完全に拒否するよう変更された
- これは Go と C の両方で IP アドレスのパースに成功する場合、同じ意味になるようにするための選択である
- Kubernetes は既存の保存済み設定が Go 1.17 でパースできなくなる可能性を懸念し、元の
net.ParseIP のフォークを使い始めた
- ユーザー入力は値をパースする前に、まず受け入れる構文を検証する方式が望ましいが、場合によってはコードをフォークする必要がある
-
プロトコル変更
- プロトコル変更は、パッケージ変更が外界と通信するプロトコル上で観測可能な形で現れる場合に起こる
- Go 1.6 は HTTP/2 の自動サポートを追加した
- Go 1.5 クライアントは HTTP/1.1 だけを使うため、特定のネットワーク中間装置環境では正常動作することがある
- Go 1.6 に更新すると HTTP/2 が使われ、その環境で HTTP/2 が動作せずプログラムが壊れる可能性がある
- Go は現代的なプロトコルを標準でサポートしようとしているが、HTTP/2 の有効化はプログラムや Go 自体の不具合がなくてもプログラムを壊しうる
- Go 1.6 はリリースノートに変更をまとめ、HTTP/2 を無効化する方法を提供した
TLSNextProto フィールドを明示的に設定する
GODEBUG=http2client=0、GODEBUG=http2server=0、またはその両方を設定する
- SHA1 ベースの HTTPS 証明書サポートも、より微妙なプロトコル変更の例である
- 認証局は 2015 年に SHA1 証明書の発行を停止し、主要ブラウザは 2017 年にそれを受け入れなくなった
- Go 1.18 は SHA1 証明書サポートをデフォルトで無効化し、GODEBUG で回避できるようにした
- 一部の Kubernetes 導入環境が private SHA1 証明書を使い続けているため、Go は予定より長く回避設定を維持することにした
Go 1.21 の拡張された GODEBUG サポート
- Go 1.21 は微妙な互換性問題まで減らすため、GODEBUG の利用を拡張して正式化した
- Go 1 互換性ルール上は許容されるが既存プログラムを壊しうる変更については、個々のプログラムが新しい挙動を拒否できる GODEBUG 設定を定義する
- 設定追加が不可能な場合もありうるが、非常にまれなケースとして扱われる
- 互換性用 GODEBUG 設定は少なくとも 2 年、つまり 4 つの Go リリースの間維持される
http2client、http2server のような設定はもっと長く、場合によっては無期限に維持されることもある
- 可能な場合、各 GODEBUG 設定には
runtime/metrics カウンタが関連付けられる
- カウンタ名は
/godebug/non-default-behavior/<name>:events 形式である
- たとえば
GODEBUG=http2client=0 が設定されると、/godebug/non-default-behavior/http2client:events が HTTP/2 なしで構成された HTTP transport の数を数える
- プログラムの GODEBUG デフォルト値は、メインパッケージの
go.mod に書かれた Go バージョンに合わせられる
go.mod が go 1.20 で Go 1.21 ツールチェーンに更新した場合、Go 1.21 で変更された GODEBUG 制御動作は go.mod を go 1.21 に変更するまで Go 1.20 の動作を維持する
- 個別の GODEBUG 設定は
package main の //go:debug 行で変更できる
- すべての GODEBUG 設定は中央一覧にまとめられている
panic(nil) の事例
- Go 1.21 では
panic(nil) はもはや nil ではないランタイム panic を起こす
- この変更により、
recover の結果が現在の goroutine が panic 中かどうかを安定して示せるようになる
- 新しい挙動は GODEBUG 設定で制御され、メインパッケージの
go.mod の go 行に応じて変わる
go 1.20 以下なら panic(nil) は引き続き許容される
go 1.21 以上なら panic(nil) は runtime.PanicNilError を伴う panic に変わる
- バージョンベースのデフォルト値は、
package main に次の行を追加して明示的に上書きできる
//go:debug panicnil=1
Go 2 は Go 1 を壊さない
- 「Go 1 and the Future of Go Programs」文書には、いつか Go 2 仕様が出るかもしれないという但し書きが含まれていた
- Go 1 プログラムをもはやコンパイルしないという意味での Go 2 は登場しない
- 2017 年に始まった、Go 1 の大きな改訂という意味での Go 2 はすでに起こった
- Go は過去との断絶よりも互換性の方がはるかに価値が高いと考え、互換性をさらに強化する方向を選んだ
- 今後も新しく興味深い作業は続くが、ツールチェーン間のアップグレードが可能な限り安定するよう、慎重で互換性のある形で進められる
1件のコメント
Hacker News の意見
互換性で重要な問いは「やるかどうか」ではなく「どうやるか」である。実際には過去互換というより、今後も自分のコードがそのまま動き続けてほしい、ということに近い。
Go 1.21 は、ほかの言語エコシステムでは同時にはなかなか見られない 2 つの核心を提供している。変更ごとに GODEBUG 設定があり、変更単位で元に戻せ、以前の実装を使っているかどうかを検知する指標もある。さらにモジュールごとの ツールチェーンバージョンがあり、より古い Go やより新しい Go のツールチェーンを、モジュールのように安全に自動取得できる。
おまけに
go 1.21.2のように特定バージョンを指定すると、より新しい Go で実行しても、新しい動作を明示的に要求するまでは関連する opt-out 設定を自動適用する。コード、go.mod、環境変数のどこにでも宣言でき、開発者から配布者まで、互換性のユースケースをほぼすべてカバーするシンプルで美しい方法だ。use v5.24と書けば Perl 5.24 のように動作させることができ、モジュール単位ではなくファイル単位で適用される。新バージョンをサポートするかどうかだけでも、すでに途方もなく複雑な問題が生じるのに、「こちらは新バージョンをサポートしていると思い、新バージョンもこちらをサポートしていると思っているが、互いに見落としがある」という深い谷ができる。
こういう方向性は本当に良い。Go のコードベースに入って、Go のバージョンを上げるだけで全部うまく動くはずだと期待できることほど良いものはない。
ただし型システムは、「これは不正なコードなので、もうコンパイルされない」というような破壊的変更なしには大きく改善しにくい点が気がかりだ。Go チームがこうした点に関心を持っているかは分からないが、言語機能を追加しなくてもコンパイル時の堅牢性を大きく高められる、手の届きやすい改善は多い。
例えば、チェックされていない
nilの報告、配列アクセスの検査、ネストした構造体リテラルの型推論、列挙型の網羅性チェックなどだ。特にネストした gRPC 呼び出しを書くときは型推論が 1 段階あるだけでよいのに、呼び出す関数シグネチャにはすでに型があるため、Go では非常につらい。Go の型システムが改善されるなら、こうしたシンプルで美しい 代数的データ型が追加されるとよい。Go にも本当に合うはずなので、自分たちへの贈り物にしてほしい。
完全に同意する。Go のリリースが楽しみなのは、有用なものが追加されながら、何も壊れないことが多いからだ。
言語のどの部分も、過去互換な変更では直せないほど壊れているようには見えない。ループ変数の代入のような数少ない落とし穴にも、概ね互換性を維持する提案がある。
個人的には Go のリリースに期待する見方に共感するが、2 つのエコシステムの態度の違いは興味深い。
以前に経験した破壊の一部をまとめたことがある: https://news.ycombinator.com/item?id=29763324
Rust や gcc では、コンパイラを上げて言語機能を得る一方で、複雑なライブラリの大半はそのままで、別途アップグレードできる。例えば Rust の HTTP は
hyper、C はlibcurlのように分離されている。一方 Go では、コンパイラを上げるとジェネリクス、
embed、ツールチェーン機能だけでなく、tls、セキュリティライブラリ、HTTP クライアント/サーバーの変更まで一緒についてくる。http、tls、cryptoのような大きな標準ライブラリの塊は、別ライブラリであるべきだった。そのほうが、コンパイラは恐れずに上げ、ライブラリは自分のペースで上げられたはずだからだ。asyncキーワードを導入したが、2015 エディションではasyncという変数や関数を作ることができたため、破壊的変更だった。過去互換性は良いものだが、Go が互換性を壊すことになるために何らかのイノベーション X ができない未来でよいのかは、確信しにくい。
今後の Go 2 が Go 1 との互換性を絶対に壊さないという立場を強く支持する。言語をそこまで大きく変える必要があるなら、単にフォークして名前を変えたほうがいいと思う。
ただ、なぜさらに踏み込んで「Go 2 は存在しない」と言って曖昧さをなくさないのかは気になる。理論上の Go 2 がすべての Go 1 プログラムを実行するのだとしたら、Go 1.xx リリースと何が違うのだろうか。記事は「Go 1 プログラムを壊す Go 2 は存在しない」と言っているが、それ以上のことまでは言っていないように見える。
https://github.com/golang/proposal/blob/d661ed19a203000b7c54...
上のプロセスが計画どおりに機能するなら、重要な意味で Go 2 は存在せず、新しい言語機能とライブラリ機能へゆっくり移行していくことになる、と説明している。どこかの時点でマーケティング上「これからは Go 2」と呼ぶかもしれないが、そのまま飛ばすこともあり得る。C 2.0 はなかったのに、なぜ Go 2.0 が必要なのか、という理屈だ。
C、C++、Java のような人気言語も実質的には常に 1.N バージョンであり、Go もそれに従うほうがよいと思う。互換性のない新言語や中核ライブラリという意味での本当の Go 2 は、ユーザーにとって良い選択肢ではなく、有害になり得る。
結局、そのやり方は筋が通っていると思う。
コミュニティが明らかに望んでいる新しい言語機能があり、その機能を入れる正しい方法、または唯一の方法が新しいキーワードだとしたら、ソース互換性を絶対に壊してはならないという規則のせいで、その機能は永遠に追加できない。言語セマンティクスを変える大きな変更については同意するが、過去との非互換変更が常に巨大な変更とは限らない。
理論的には後方互換性が絶対に変わらないという考え方はよいが、現実には意味のある破壊的変更もある。最初に設計したときに X や Y を考慮できていなかった言語機能を永久に抱え込むことが、必ずしも得だとは感じない。
Go をよく使っているが、こういう方向性は本当に心が温まる。
互換性は言語チームにとってはあまり面白くないかもしれない。常に片足を「遠い過去」にしっかり置いておかなければならないからだ。だが大きな Go システムを保守する立場からすると、本当に大きな贈り物だ。
うまくはめ込めないなら、まだ正しいものではないので再挑戦し、それでも合わなければ、おそらく問題を十分に理解していないということなので、もっと長く熟成させるべきだった。
慎重に考え、正しいアイデアが入るよう努める人たちと働けたことは本当に良かった。Russ が BDFL の役割を担ってくれたこと、Ian、Rob、Rob と働けたことに感謝しており、そのおかげではるかに良いエンジニアになれた。
関連記事: Forward Compatibility and Toolchain Management in Go 1.21 - https://news.ycombinator.com/item?id=37122932
「退屈なのは良いことだ。退屈なものは安定している。退屈であるということは、Go で何が変わったかを気にせず、自分の仕事に集中できるという意味だ」という一文が本当に刺さる。
本業では NodeJS と JS エコシステム全般を使っているが、本当に苦労が大きい。エコシステムが断片化していて、皆がそれぞれのやり方でやるため、安定させるのが難しい。それでもこの仕事は今も楽しんでいるが、JS エコシステムにも信頼して依存できる 安定したモダンな基盤 があればいいのにと思う。
ツールや API は Go で多く書かれており、対象システムに別途ランタイムが不要な点は大きな利点としてよく挙げられる。言語も学びやすく使いやすいように見え、VSC のサポートや GoLand も良く、エラー処理のようなよくある不満も致命的な欠陥には見えない。
今後数十年にわたって開発の主流になる、あるいは少なくとも採用市場の大きな部分を占めるには、Go にさらに何が必要なのか気になる。場所によってはいまだにニッチな言語と見なされているからだ。
問題は、全員をこのベースラインまで引き上げることだ。そこまで行けば、ずっと良くなると思う。
また JS エコシステムはフロントエンド UI 作業まで含み、適用分野が非常に多いため、複数の実装が生まれるのは避けられない。むしろ望ましくもある。
Python から Golang へ一部のコードを移したことが、スケールさせるうえで大いに役立った。Go が中核的な宣言である 後方互換性 を守り続けるという知らせを見て、本当にうれしい。
IP パースの話とは。もともと BSD の
inet_ntoaはいったいどう書かれていたのか気になるatoi/atolと%d/%uを使うsscanfは常に厳密に 10 進整数としてパースするので、こういう妙な効果を出すには%iか、0 基準のstrtouを使う必要があったはずinet_atonのマニュアルページには 4.3BSD 由来だと書いてある: https://github.com/dank101/4.3BSD-Reno/blob/master/lib/libc/...さらに前の 4.2BSD の
inet_addrも同じロジック: https://github.com/dank101/4.2BSD/blob/master/lib/libc/inet/...inet_atonとinet_addrはアドレスをもっとも自然な方法でパースしている。strtoulや、とくにsscanfのようなものを使うと不自然だったはず。C のポインタの美しさは、簡単なパース作業を非常に容易にするところにあり、もしかすると容易にしすぎるところにある/etc/hostsを「整理」しようとして、各オクテットを 0 でパディングしたことがある結局、戻って 0 を取り除く羽目になった
言語設計者として、本当の Go 2 を作らないという判断も含め、ここで下された選択を尊重する
私も互換性を保証するための 手法 を取り入れるつもりだ