フューチャー技術ブログ

Go1.21のgo/ast、go/buildあたりのマイナーチェンジ

はじめに

Go1.21連載の2本目です。

取り上げるのは次のgo/ast、go/buildあたりの更新についてです。

  1. go/ast
  2. go/build
    • 関連してToolの互換性の話も含みます
  3. go/build/constraint
  4. go/token
  5. go/types

go/ast はGoコードの抽象構文木を表現するパッケージで、go/parser パッケージでコード解析をすると取得できます。 go/token とか go/types は概ね解析した結果の型やトークンを表現するもので比較的セットで利用することが多いかと思います。

go/build パッケージはGoのビルド周りの情報を集めるパッケージです。今回のアップデートは go/ast と関連が強かったので、この記事でまとめて紹介します。

バージョンは Go 1.21rc4 であり、リリースまでに多少変更があるかも知れません。ご了承ください。

go/ast の IsGenerated()

go/ast パッケージに IsGenerated() というそのコードが自動生成されたかどうかを判定するためのヘルパー関数が追加されました。

go/astパッケージに追加されたヘルパー関数
func IsGenerated(file *File) bool

あるGoコードが自動生成されたかどうかは、cmd/goパッケージのドキュメントに記載がある通り、以下の行を含んでいる場合(ファイルの行頭じゃなくてもOK)に、判断できるとあります。 IsGenerated() でこの行を含んでいるかが判断できるようになりました。

自動生成を示すための一文
^// Code generated .* DO NOT EDIT\.$

go/ast: add func IsGenerated(*File) bool #28089 で議論されており、上記の自動生成の判定条件が現在広く受け入れられているのであれば、公式でAPIを提供したら便利ではという提案でした。

これ以前は起票者が開発した shurcooL/go/generated というパッケージを利用したり、regexp パッケージを用いた正規表現でファイルの判定や、人によってはシェルスクリプトで判定していたようです。

自動生成を判定用するシェルスクリプト
grep -Exq '^// Code generated .* DO NOT EDIT\.$' "$file"

IsGenerated() の関数を新規に追加実装する箇所は、 ast.Filebuild.Package なども考えられたかと思いますが、互換性やフィールド追加は神経質に行いたいということで、ヘルパー関数となったようです。次の章で説明するGoVersionは ast.File へのフィールド追加なので、塩梅は分かるような分からないような。GoVersionは論理式が入って計算されるのでそれなりのロジックだけど、自動生成判定はシンプルなのでヘルパー関数になったんですかね、多分。

追加された IsGenerated()を試してみます。

package main

import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)

func main() {

const s = `// Code generated by My-Tool; DO NOT EDIT.
package go121blog

import(
"fmt"
)

func Show() {
fmt.Println("hello generated")
}
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", s, parser.ParseComments)
if err != nil {
panic(err)
}

fmt.Println(ast.IsGenerated(f)) // true
}

注意としては ParseFile() の引数に parser.ParseComments を渡さないと、コメントがフィールドに格納されないため、結果が false になります。ここは注意です。

-	f, err := parser.ParseFile(fset, "", s, parser.ParseComments)
+ f, err := parser.ParseFile(fset, "", s, 0) // ast.IsGenerated(f) が falseになる

上手く利用すれば、自動生成されたコードを除去してリントのルールを追加するなどが行いやすくなると感じました。

ちなみに実装は strings.CutPrefix()strings.CutSuffix() で判定しており、正規表現は利用していない愚直なコードでした。

https://github.com/golang/go/blob/go1.21rc4/src/go/ast/ast.go#L1092

go/ast の File.GoVersion

go/ast パッケージの FileGoVersion というフィールドが追加されました。

追加されたフィールド
type File struct {
Doc *CommentGroup // associated documentation; or nil
Package token.Pos // position of "package" keyword
Name *Ident // package name
Decls []Decl // top-level declarations; or nil

FileStart, FileEnd token.Pos // start and end of entire file
Scope *Scope // package scope (this file only)
Imports []*ImportSpec // imports in this file
Unresolved []*Ident // unresolved identifiers in this file
Comments []*CommentGroup // list of all comments in the source file
+ GoVersion string // minimum Go version required by //go:build or // +build directives
}

GoVersion が示す意味は、 //go:build// +build のディレクティブで示した最小のGoバージョンが入るそうです。

go/ast, go/build/constraint: support for per-file Go versions using //go:build lines #59033 によると、Goの互換性は以下の方針です。直接的には2の対応のために追加されたようです。

  1. Go 1.21から go.mod にかかれた go のバージョンが、最小バージョン要件となる。例えば、Go 1.21がGo 1.22とかかれたgo.modファイルをビルドできない
  2. Goの最小バージョンが低くても、 //go:build タグに新しいバージョンを指定することを可能とする。また、逆に //go:build に古いバージョンを指定することも可能として、新しい機能をロックアウト(無効化)できるようにする。互換性のためダウングレードはGo 1.21以降のgo.mod に適用される

//go:build ディレクティブはGo1.17から導入された書き方で、従来は// +build でした。Build Constraintsと呼ばれ、例えば以下のように記載するとWindowsとLinux用のバイナリに対してビルド対象となります。

//go:build windows linux

OS名(windows, linux, solaris, darwinなど)やアーキテクチャ名(386, arm, ard64など)だけではなく、バージョンも指定できるようになったようですね。

//go:build ディレクティブに (linux && go1.20) || (windows && go1.21) を追加して動かしてみます。実行環境はWindowsなので、 go1.20 が出力されます。

GoVersionの表示
package main

import (
"fmt"
"go/parser"
"go/token"
)

func main() {

const s = `//go:build (linux && go1.20) || (windows && go1.21)
package go121blog

import(
"fmt"
)

func Show() {
fmt.Println("hello goversion")
}
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", s, parser.ParseComments)
if err != nil {
panic(err)
}

fmt.Println(f.GoVersion) // go1.20
}

ディレクティブで指定されたGoバージョンも取得できるのはマニアックに感じます。それなりの規模と歴史を持つ、コードベースを解析するような上級者向けのアップデートかなと思いました。

Tools

Goは下位互換性を重視していて強みの1つとしています。Go 1の互換性ポリシーに従って破壊的変更は抑えられていますが(例えば公開されたインターフェースにメソッドを追加しないなど)、既存の実装がバグであり(既存のコードが古いアルゴリズムやバグのある動作に依存している場合)、それを修正すると、バグである挙動に依存したプログラムは壊れる可能性があるとのこと。対応としては前章にあるような go.mod などのgoバージョンを読み取るようになったり、GODEBUG環境変数の存在があります。

具体的に互換性でどういう課題が出たんだろうと思いましたが、Proposal: Extended backwards compatibility for Go にいくつか例が書いてありました。Kubernetesチームでは平均で年1回ペースで問題が出たそうです。

例えば以下のような事象があったそうです。

  • Go 1.17 では、0127.0000.0000.0001 など、先頭にゼロが付いているアドレスを拒否するように net.ParseIP が変更(BSD派生システムでは8進数として解釈するが、Goでは複数のRFCに従い10進数として解釈)
    • Kubernetesクラスタは先頭ゼロのアドレスを保存している可能性があり、この変更によりノードアクセスができなくなる可能性があり、Go 1.17へのアップデートはブロックされた

上記のようなケースでもGODEBUG環境変数で過去の挙動になるように上書きしたり、 go:debug ディレクティブで個別に指定することを検討中だそうです。互換性にかける熱意が常に素晴らしいなと感じました。

go/build

//go: で始まるビルドディレクティブを Package構造体の Directives, TestDirectives, XTestDirectives で参照できるようになりました。

go/build/constraint

ビルド時に指定された、Goの最小要件バージョンを返す、GoVersion() が新しく追加されました。ドキュメントに書かれた例がわかりやすいです。

GoVersion(linux && go1.22) = "go1.22"
GoVersion((linux && go1.22) || (windows && go1.20)) = "go1.20" => go1.20
GoVersion(linux) = ""
GoVersion(linux || (windows && go1.22)) = ""
GoVersion(!go1.22) = ""

go/token

ファイルの行番号を返す、 File.Lines() のメソッドが追加されました。

go/token: add a (*File).Lines method #57708で提案された内容です。中身を読むと、すでに SetLines() メソッドは存在しており、getter が存在しなかったとのこと。

例としては、internal/gcimporter というパッケージで利用したいということで、おそらく goplsとの連携でコード補完などで利用したいのかなと思います。

go/types

Package.GoVersion() のメソッドが追加されました。中身は go/ast.File.GoVersion() と同じ。

go/types: add Package.GoVersion method #61175 によると、ここのチェッカーが特定のパッケージに紐づいたGoバージョンを取得できると便利なため、追加する。厳密に言うとGo1.21には不要だが、Go 1.22では必要になるため(おそらくforループの変数の件だと思われる)、ツール作成者のためにも追加するとのこと。

まとめ

  • Go 1.21では自動生成したコードかどうかを判定するヘルパー関数が標準で追加されたので、自前で正規表現を書いたり、サードパーティのパッケージをインポートする必要がなくなったよ
  • Go 1.21でビルド時のGoバージョンを //go:build ディレクティブで指定できるようになりました。
  • ディレクティブで指定した内容は、 go/astgo/types パッケージで合わせて取得できるようにあっているため、コード解析やツール作成者にも優しい変更です

個人的には普段余り利用しない、意識しないパッケージだったので調べていて勉強になりました。最後まで読んでいただきありがとうございました。