linknameの利用制限はGoの後方互換性に抵触するか

サムネイル

はじめに

24新卒のやびくです。

突然ですが、Goといえば後方互換性ですよね。バージョンアップしても過去のコードをそのまま利用できることが保証されていると、安心感があります。公式ドキュメントにも、Go 1は後方互換性を保ちながら開発されることが明言されています。

ところが、Goを1.23にバージョンアップした途端にコンパイルエラーが発生するようになってしまいました。「これってGoの後方互換性を損なっているんじゃないの?」と思ったので、調べたことをまとめます。

発生した事象

弊チームで開発しているプロダクトでGoを1.21から1.23にバージョンアップしたところ、下記のコンパイルエラーが発生しました。

# command-line-arguments
link: github.com/bytedance/sonic/ast: invalid reference to encoding/json.safeSet

どうやらbytedance/sonicパッケージに関連したエラーのようです。こんな時はエラー文をGoogleの検索窓に貼り付けましょう。

エラーの原因

bytedance/sonicとあるイシューがヒットしました。Go 1.23以降、内部実装に対するlinknameの利用が原則禁止されたことへの対応を実施するようです。つまり、Goは後方互換性を維持するはずであるにも関わらず、bytedance/sonicは突如linknameからの脱却を迫られたことになります。

業務で利用していたbytedance/sonicv1.11.1であり、当時最新のv1.12.3に変更することでコンパイルエラーは解消されました。

bytedance/sonicのPRを確認してみると、Go1.23の発表を受けて、linknameを廃した実装が取り込まれていました。bytedance/sonicの素早い対応には頭が上がりません。

しかし、ここでbytedance/sonicはGoのバージョンアップに合わせてコードの変更を迫られています。内部実装に対するlinknameの利用禁止は、後方互換性に反した変更なのではないでしょうか?

linknameって?

linknameは、//go:linknameディレクティブを記載することによって利用できるようになる機能です。他のパッケージに実装された関数を呼び出す際、本来であれば公開された関数しか呼び出せないはずのところを、非公開の関数の呼び出しが可能になります。

下記のように、通常であれば非公開の関数を呼び出すことはできません。

package sub

import "fmt"

func PublicFunc() {
    fmt.Println("Public Func was called!")
}

func privateFunc() {
    fmt.Println("privateFunc was called!")
}

package main

import "go-linkname/sub"

func main() {
    sub.PublicFunc()  // PublicFunc was called!
    sub.privateFunc() // undefined: sub.privateFunc
}

ところが、//go:linknameディレクティブを利用することで非公開の関数を呼び出すことができるようになります。

package sub

import (
    "fmt"
    _ "unsafe"
)

func PublicFunc() {
    fmt.Println("Public Func was called!")
}

func privateFunc() {
    fmt.Println("privateFunc was called!")
}

package main

import (
    "go-linkname/sub"
    _ "unsafe"
)

//go:linkname privatefunc go-linkname/sub.privateFunc
func privateFunc()

func main() {
    sub.PublicFunc() // PublicFunc was called!
    privateFunc()    // privateFunc was called!
}

compileの公式ドキュメントに記載があるとおり、//go:linknameディレクティブを利用する際にはunsafeパッケージをブランクインポートする必要があります。

もともとlinkenameは後方互換性の対象ではなかった

「unsafeの後方互換性が損なわれているじゃないか!」そう思いながらunsafeパッケージの公式ドキュメントを確認すると、2行目にこんなことが書いてあります。

Packages that import unsafe may be non-portable and are not protected by the Go 1 compatibility guidelines.

文の後半に、unsafeはGo 1の後方互換性ガイドラインによって保護されないと書かれています。そうです、unsafeはそもそも後方互換性の対象ではなかったのです。

知らなかった...。

また、Go 1.23のリリースノートには以下のような記述があります。

For backward compatibility, existing usages of //go:linkname found in a large open-source code corpus remain supported.

著名なOSSに限って、今後もlinknameによる内部実装の読み込みを認めるとのことです。後方互換性に対する執念を感じます。

後方互換性の例外

unsafeが後方互換性の対象ではないように、Go 1の後方互換性には他にも例外があるのでしょうか。実は複数あるようです。

Go 1 and the Future of Go Programsに記載のあるものを、いくつか抜粋します。

1. unsafeパッケージをインポートしたもの

これまでに言及してきたとおりです。unsafeを利用することでinternalの実装を直接読み込めるようになりますが、internalに含まれる実装は破壊的変更が行われるかもしれません。 とはいえ原則としてGo 1.23以降標準パッケージをlinknameで直接読み込むことはできなくなったため、今後は関係なさそうです。

2. 構造体リテラル

公開されている構造体のフィールドが増えるかもしれません。それに伴って、フィールド名を省略した構造体リテラルのコンパイルが将来的に失敗するかもしれません。

package pkg

type T struct {
    A int
    B string
    C string // new field
}

package main

import "pkg"

func main() {
    pkg.T{3, "x"} // too few values in struct literal of type pkg.T
}

このコンパイルエラーは、構造体リテラルにフィールド名を明記することで回避できます。フィールド名は明記しましょう。

3. メソッド

構造体に対して、メソッドが追加されるかもしれません。一見すると後方互換性の問題はなさそうに思えますが、構造体を埋め込んで自前で実装したメソッドと新規メソッドの命名が衝突するかもしれません。

4. ドットインポート

import . "path"のようにパッケージをインポートすることで、インポート元を逐一明記する必要がなくなる記法のことです。メソッドと同様に、パッケージに追加された構造体や関数の命名が、自前の命名と衝突する可能性があります。ドットインポートはなるべく利用しないようにしましょう。

5. Sub-repositories

golang.org/x/配下からインポートするパッケージ達のことで、「準標準パッケージ」のような位置付けになっています。標準パッケージの後方互換性は保証されているものの、Sub-repositoriesは比較的弱い互換性の下で開発されます。

まとめ

Goのバージョンアップに伴って、特定バージョンのOSSをコンパイルできなくなる事象が発生しました。しかしこの破壊的変更はGoの掲げる後方互換性に反するものではなく、特定のケースに限って破壊的変更がなされるとわかりました。

真に厳密な後方互換性ではないものの、今回の例では著名なOSSに限って今後もlinknameによる内部実装への参照を許可するなど、後方互換性への強いこだわりを感じることができました。局所的な破壊的変更に対するOSSの対処も素早く、開発に大きな影響が発生することはありませんでした。