Go埋め込み構造体の初期化—1.27の昇格フィールドで入れ子を書かずに済む

2015年2月12日に立った golang/go の issue #9859 が、11年半後の Go 1.27 で入りました。埋め込みフィールドを、外側のリテラルから直接書けます。検証は go1.27.0 darwin/arm64 で行いました。

目次

読むときは昇格するのに、書くときは昇格しなかった

共通カラムを構造体に埋め込む設計は、Goでよく使います。ただ初期化のたびに埋め込み型の名前を書き直す必要がありました。

Go 1.26 までの書き方

type Meta struct {
	Revision int
}

type Base struct {
	ID int
	Meta
}

type User struct {
	Base
	Name string
}

// 読むときは u.ID で届く
// 書くときは入れ子を再現する必要がある
u := User{
	Base: Base{
		ID:   7,
		Meta: Meta{Revision: 3},
	},
	Name: "moha",
}

フィールドを読むときは u.IDu.Revision で届きます。それなのに書くときだけ Base{...}Meta{...} を再現しないといけない。issue #9859 が11年間指摘し続けたのは、この非対称でした。

Go 1.27 の書き方

リリースノートの “Changes to the language” にはこう書かれています。

A key in a struct literal may now be any valid field selector for the struct type, not just a (top-level) field name of the struct.

同じ値を作るリテラルが、これだけになります。

u := User{
	ID:       7,
	Revision: 3,
	Name:     "moha",
}
fmt.Printf("%+v\n", u)
fmt.Println(u.ID, u.Revision, u.Base.Meta.Revision)
{Base:{ID:7 Meta:{CreatedBy: Revision:3}} Name:moha}
7 3 3

RevisionUserBaseMeta と2段たどった先にあるフィールド。それでも単一の識別子として書けています。%+v の出力を見ると BaseMeta の階層はそのまま残っており、コンパイラは糖衣構文として展開しているだけだと分かります。構造体のレイアウトは変わりません。

昇格は何段でも届く。ドット付きのキーは弾かれる

「何段まで昇格するか」を先に確かめました。3段の埋め込みチェーンを作ります。

type L3 struct{ Deep string }
type L2 struct {
	L3
	Mid int
}
type L1 struct {
	L2
	Top bool
}

v := L1{Deep: "d", Mid: 2, Top: true}
fmt.Printf("%+v\n", v)
{L2:{L3:{Deep:d} Mid:2} Top:true}

“field selector” は単一識別子だけ

リリースノートの any valid field selector という表現を読むと、u.Meta.Revision が有効なセレクタなのだから Meta.Revision: もキーに書けそうに見えます。書けません。

// M2 は B2 に埋め込まれている
var _ = U2{M2.Rev: 1}
./sel_test.go:13:12: invalid field name M2.Rev in struct literal

キーに置けるのはドットを含まない識別子だけ。埋め込みチェーンを何段たどっていても、たどり着いた先の名前をそのまま書きます。ドットで経路を明示する書き方は Go 1.27 でも構文エラーになる。仕様が広げたのは「名前の解決範囲」であって、「キーの文法」ではありません。

埋め込みポインタには届かない

埋め込みが値ではなくポインタの場合、昇格フィールドをキーに書けません。

type PBase struct{ Code string }
type PUser struct {
	*PBase
	Name string
}

p := PUser{Code: "x"}
./main.go:36:13: invalid implicit pointer indirection to reach Code

コンパイラは Code の書き込み先を作れません。*PBase は初期状態で nil なので、代入する対象そのものが存在しないためです。ポインタ埋め込みでは従来どおり PUser{PBase: &PBase{Code: "x"}} と書きます。

非公開の埋め込み型が、外のパッケージから埋まる

非公開型を埋め込むライブラリでは、Go 1.26 まで外から初期化する手段そのものがありませんでした。

// internal/model/model.go
package model

type base struct {
	ID        int
	CreatedBy string
}

type Client struct {
	base
	Endpoint string
}
// main.go — 別パッケージから
c := model.Client{
	ID:        42,
	CreatedBy: "moha",
	Endpoint:  "https://example.com",
}
fmt.Printf("%+v\n", c)
{base:{ID:42 CreatedBy:moha} Endpoint:https://example.com}

base が非公開なので、呼び出し側は base{...} と書けません。選択肢はコンストラクタ関数を用意するか、フィールドを公開型へ移すかの二択でした。Go 1.27 では、埋め込み型を隠したままフィールドだけ外に開けます。

コンパイラが弾く書き方

実際に踏んだエラーを並べます。どのメッセージも、弾いた理由と該当フィールド名を名指しします。

書いたものコンパイラの出力
User{Base: Base{ID: 1}, ID: 2}cannot specify promoted field ID and enclosing embedded field Base
User{ID: 1, ID: 2}duplicate field name ID in struct literal
Both{Tag: "t"}LR に同名の Tagunknown field Tag in struct literal of type Both
PUser{Code: "x"}*PBase 埋め込み)invalid implicit pointer indirection to reach Code

3行目の名前衝突は、同じ深さに同名フィールドが2つある場合。深さが違えば浅いほうが勝ち、外側のフィールドが優先されます。この解決規則が、次の落とし穴を作ります。

外側に同名フィールドを足すと、代入先が黙って変わる

昇格フィールドをキーに使ったリテラルは、後から外側の型にフィールドを追加すると、コンパイルエラーを出さずに代入先を変えます

変更前

type Audit struct {
	UpdatedBy string
}

type Order struct {
	Audit
	Total int
}

o := Order{UpdatedBy: "batch", Total: 100}
fmt.Printf("Audit.UpdatedBy=%q\n", o.Audit.UpdatedBy)
Audit.UpdatedBy="batch"

Order に UpdatedBy を追加した後

別の開発者が Order 側にも UpdatedBy を足したとします。リテラルは1文字も変えていません。

type Order struct {
	Audit
	Total     int
	UpdatedBy string // 後から追加
}

o := Order{UpdatedBy: "batch", Total: 100}
fmt.Printf("Audit.UpdatedBy=%q Order.UpdatedBy=%q\n", o.Audit.UpdatedBy, o.UpdatedBy)
Audit.UpdatedBy="" Order.UpdatedBy="batch"

Audit.UpdatedBy が空文字になりました。値の行き先が Audit から Order へ移っています。ビルドは通るので、テストが中身まで検証していなければ、そのままデプロイまで届く。

go vet も報告しない

$ go vet ./...
$ echo $?
0

浅いフィールドが優先されるのは仕様どおりの解決なので、静的解析が報告する理由はありません。o.UpdatedBy = "batch" という代入文でも同じ移り方をするため、リテラルだけを特別扱いする根拠もない。

線を引くのは運用側です。埋め込み型を共有ライブラリに置いていて、外側の型が別チーム管理なら、共通カラムの初期化を昇格キーで書かない。SystemColumns: SystemColumns{...} と明示しておけば、外側に同名フィールドが増えた瞬間に cannot specify promoted field で気づけます。埋め込み型も外側も同じパッケージで完結しているなら、昇格キーで縮めて構いません。

go fix -embedlit が書き換えない場所

Go 1.27 の go fix には embedlit という modernizer が加わりました。既存の入れ子リテラルを昇格キーへ自動変換します。

$ go fix -diff -embedlit ./...
 func main() {
 	u := User{
-		Base: Base{
-			ID:   1,
-			Meta: Meta{Revision: 3},
-		},
-		Name: "moha",
+		ID:       1,
+		Revision: 3,
+		Name:     "moha",
 	}

7行が3行になりました。ポインタ埋め込みと名前衝突のケースには差分が出ず、embedlit が正しく避けます。Base: Base{} という空リテラルも変換しません。ゼロ値でリセットする意図を消さないためです。

キー付き要素の値は素通りする

縮めたい筆頭はテーブルドリブンテストのフィクスチャ。ところが変換されません。リテラルの置かれる位置ごとに切り分けて測りました。

リテラルの位置書き方embedlit
パッケージレベルの varvar a = User{...}変換する
関数の戻り値・引数return User{...} / take(User{...})変換する
ポインタリテラル&User{...}変換する
キーなしのスライス要素[]User{{...}}変換する
位置指定の構造体要素Wrap{User{...}, "note"}変換する
構造体フィールドの値Wrap{U: User{...}}素通り
マップの値map[string]User{"k": {...}}素通り
インデックスキー付き要素[]User{0: {...}}素通り

境目は入れ子の深さではなく、キー付き要素の値の位置にあるかどうかでした。[]User{{...}} は変換されるのに []User{0: {...}} は素通りする。中身は同じリテラルで、違いはインデックスキーの有無だけです。

この規則をテーブルドリブンテストに当てはめると、{name: "新規登録", user: User{...}}user: がキー付き要素にあたります。41行のフィクスチャに go fix -diff を当てて、差分は0行でした。手で書き換えるなら、素通りする3パターンを検索対象にすると漏れません。go fix の analyzer を個別に切り替える方法はgo fixの使い方—Go 1.27で26個になったmodernizerと適用条件にまとめています。

適用後は1行に詰まる

go fix -embedlit は元の行構造を保ちます。埋め込み型のフィールドが1行に並んでいると、そのまま横に残る。

// 変換前
var u = User{
	SystemColumns: SystemColumns{CreatedAt: time.Now(), CreatedBy: "moha"},
	ID:            1,
	Name:          "moha",
}

// go fix -embedlit 適用後
var u = User{
	CreatedAt: time.Now(), CreatedBy: "moha",
	ID:   1,
	Name: "moha",
}
$ gofmt -l .
$ echo $?
0

gofmt は既存の改行位置を尊重するので、この形を未整形と判定しません。1行に詰まった箇所は手で割ります。まとめて適用したあとは、カンマ区切りで2つ以上のキーが並ぶ行を grep で拾って直す工程を挟んでください。

go.mod の go directive を上げるまで使えない

ツールチェーンを 1.27 にしただけでは有効になりません。コンパイラが言語バージョンで止めます。

./main.go:22:3: use of promoted field Base.ID in struct literal of type User requires go1.27 or later (-lang was set to go1.26; check go.mod)
./main.go:23:3: use of promoted field Base.Meta.Revision in struct literal of type User requires go1.27 or later (-lang was set to go1.26; check go.mod)

エラーが check go.mod まで書いてくれるので、go 1.27 に上げれば済みます。embedlit も同じ directive を見ているため、go.mod が 1.26 のままだと go fix は何も提案しません。先に directive を上げる。同じ Go 1.27 の言語変更であるジェネリックメソッドも、このゲートの対象です。

まとめ

  • Go 1.27 で User{ID: 7, Revision: 3} のように昇格フィールドをキーに書ける。埋め込みチェーンは3段でも届く
  • キーはドットを含まない識別子のみ。Meta.Revision:invalid field name で弾かれる
  • ポインタ埋め込みと、同じ深さの名前衝突は対象外。非公開の埋め込み型は外のパッケージから初期化できるようになった
  • 外側に同名フィールドが増えると、リテラルの代入先が無言で移る。go vet も検出しないので、型の管理主体が分かれているなら埋め込みキーを明示したまま残す
  • go fix -embedlit はキー付き要素の値を素通りする。テーブルドリブンテストのフィクスチャは自動変換されない
  • 有効化の条件は go.mod の go 1.27。ツールチェーンだけ上げてもコンパイラが止める
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次