フューチャー技術ブログ

Go 1.27の構造体リテラルのキー拡張で、埋め込みフィールドの初期化が楽になった

The Go gopher was designed by Renee French.

Go 1.27リリース連載の7本目です。

はじめに

TIG真野です。

Go 1.27の言語仕様の変更は3つあり、そのうちの1つである「構造体リテラルのキー拡張」を紹介します。

検証は go1.27rc2 linux/amd64 で行いました。

記事中の記載したコードサンプルのGo Playgroundも用意しました。

何が変わったのか

リリースノートには1文とあっさり、Issueはspec: direct reference to embedded fields in struct literals #9859です。まとめます。

  • 埋め込み構造体のフィールドを、構造体リテラルのキーに直接書けるようになりました。型 E を埋め込んだ型 T について、T{E: E{A: 1}}T{A: 1} で済みます
  • ただし、埋め込み構造体がポインタの場合は、コンパイルエラーとなります

仕様の変更もシンプルで、Composite literalsの節の1単語です。

  • Go 1.26まで: The key is interpreted as a field name for struct literals, …
  • Go 1.27から: The key is interpreted as a field selector for struct literals, …

nameselector になっただけです。これで、キーの解決にセレクタの規則がそのまま適用されるようになります。

セレクタとは u.Version のようなフィールド参照のことです。specを読むと、「埋め込みをたどって最も浅い深さにあるものが選ばれる」「同じ深さに複数あれば不正」「他パッケージからは公開されたものだけ」といった規則が定められています。覚えるルールが少なくて済むのは筋が良さそうです。

具体例:システム共通項目とテーブルドリブンテスト

弊社が扱うような業務システムで埋め込みを使う典型は、全テーブルに付いてくるシステム共通項目でしょう。登録日時・更新日時・バージョンといった項目を、テーブルごとに書きたくはないので、共通の構造体にまとめて埋め込みます。

// SystemColumns は全テーブルに共通のシステム項目(登録者・更新者などは省略)
type SystemColumns struct {
CreatedAt time.Time
UpdatedAt time.Time
Version int
}

// User はユーザーテーブルのレコード
type User struct {
UserID string
Name string
SystemColumns
}

u.Version = 2 のように読み書きは昇格フィールドで直接できるのに、Go 1.26までは初期化のときだけ内側の型名を明示する必要がありました。Go 1.27ではフラットに書けます。

 u := User{
UserID: "U0001",
Name: "mano",
- SystemColumns: SystemColumns{
- CreatedAt: now,
- UpdatedAt: now,
- Version: 1,
- },
+ CreatedAt: now,
+ UpdatedAt: now,
+ Version: 1,
}

Before側は、業務データが UserIDName の2行だけなのに、システム共通項目の入れ子が場所を取ってしまっています。

個人的に一番効くと思ったのはテーブルドリブンテストです。テストケース構造体に共通部分を埋め込むと、ケースを1つ書くたびに caseCommon: caseCommon{...} というノイズが入り、ケースの本体が埋もれていました。

type caseCommon struct {
name string
tenant string
wantErr bool
}

type validateCase struct {
caseCommon
user User
}

cases := []validateCase{
{
name: "ok", // 1.26までは caseCommon: caseCommon{name: "ok", ...} が必要
tenant: "future",
wantErr: false,
user: User{
UserID: "U0001", Name: "mano",
CreatedAt: now, UpdatedAt: now, Version: 1, // 期待値のUserも同じくフラットに書ける
},
},
// ...
}

非公開型を埋め込んだ構造体を、他パッケージから初期化できるようになった

同じパッケージ内なら base: base{ID: 1} と型名を書けるので従来も初期化できましたが、パッケージをまたぐとその型名を書けません。つまり他パッケージからはリテラルでの初期化手段そのものが無かったわけで、そこが解決します。

package lib

type base struct {
ID int
name string
}

type Client struct {
base // 非公開型の埋め込み
Endpoint string
}

これを利用する側のパッケージ(ここでは app とします)から c.ID の読み書きはできます。そのためGo 1.26までは、いったん作ってから別途代入する、という2段構えを強いられていました。

package app // lib をimportしている別パッケージ

c := lib.Client{Endpoint: "https://example.com"}
c.ID = 1 // リテラルには書けないが、代入ならできる

Go 1.27からは、初期化の時点で書けます。

package app // lib をimportしている別パッケージ

c := lib.Client{ID: 1, Endpoint: "https://example.com"} // 1.27: OK
// ※1.26では 「unknown field ID in struct literal of type lib.Client」でコンパイルエラー

非公開型を埋め込んで内部実装を隠しつつ設定値は公開するというライブラリでは、この2段構えから1行で済みます。初期化が楽になるのは嬉しい感じがします。

細かいルールを確認する

先述のセレクタの規則に則るなら、キーの解決もこうなるはずだ、という確認です。

  1. 多段の埋め込み → OK

    type Audit struct{ CreatedBy string }
    type SystemColumns struct {
    Audit
    Version int
    }
    type User struct {
    Name string
    SystemColumns
    }

    u := User{Name: "mano", CreatedBy: "batch", Version: 1} // 2段たどった CreatedBy も書ける
    // {Name:mano SystemColumns:{Audit:{CreatedBy:batch} Version:1}}
  2. 深さが違う同名フィールド→浅い方が利用される

    type A struct{ X int }
    type B struct{ A }
    type C struct {
    B
    X int
    }

    c := C{X: 1} // C.X = 1、C.B.A.X = 0
  3. 同じ深さで曖昧な場合 → エラー

    type C struct{ A; B } // AもBもフィールドXを持つ
    _ = C{X: 1}
    // ./main.go:11:8: unknown field X in struct literal of type C

特段、挙動に違和感はないかと思います。

Issue #9859、11年の議論

起票は2015年。「T{A: 1} と書けないのは冗長だし、フィールドに直接アクセスできる使用時とも非対称だ。許可できないか」という数行の内容でした。

しかし、 T{A: 1} と書けてしまうと、埋め込み先の型に後から A フィールドが追加されたときに、同じリテラルが別のフィールドを指すようになります(浅い方が勝つため)。冗長でも型名を書かせておく方が、そうした変更に対して頑健であるということから、当時のGoチームの温度感は低い雰囲気でした。

その後、「任意の埋め込みを持つ構造体型 T について、var x T; x.f1 = v1; ... が有効なら、T{f1: v1, ...} と書けるべきだし、その逆もまた然り」という、1行ずつ代入できるならリテラルでも書けるべきだよね、という理屈で議論が再開。

次の論点はポインタ埋め込み(type T struct{ *E })でした。値の埋め込みと違い、*E のゼロ値は nil です。つまり書き込む先の E が存在しないので、代入文で書いても実行時にパニックします。

type E struct{ A int }
type T struct{ *E }

var t T
t.A = 1 // panic: runtime error: invalid memory address or nil pointer dereference

T{A: 1} をリテラルで書けるようにするなら、この「書き込み先が無い」状況をどう扱うかを決めなければいけません。提示されたのは3つの選択肢(実質は2択)です。

  1. ポインタを暗黙的に確保する
  2. 実行時にnil参照でパニックする(※これは落選)
  3. コンパイルエラーにする

最終的には、規則がシンプルということで案3(コンパイルエラー)が選ばれました。

案1(暗黙的なアロケーション)も有力でしたが、次の2点がネックで選ばれませんでした。

  • 埋め込みポインタが連鎖していると、x := Foo{Value: v} が裏で Foo{Bar: &Bar{Baz: &Baz{Spam: &Spam{Egg: &Egg{Value: v}}}}} まで確保することになり、アロケーションが非自明になる
  • カプセル化が破れる。type S struct{ *u } の場合、外部パッケージには u をアロケートする手段がこれまでありませんでした。もし案1を採用すると、呼び出し元で lib.S{A: 1} と書いた瞬間にコンパイラが暗黙に u をアロケートするため、コンストラクタを通らない「中身入りの S」を外部から作れてしまいます。これを避けようとすると「公開・非公開で挙動を分ける」という例外規則が必要で…とややこしいことになります

後者の具体的に困るケースは例示がなく、私もよくわかりませんでした。しかし、細かいルールを追加して案1を通すより、シンプルな一律禁止のルールでまず進めることは、Goらしい判断だと思います。

というわけで、以下はコンパイルエラーになります。

type User struct {
Name string
*SystemColumns // ★ポインタ
}
_ = User{Name: "子どもにせっかく甚平を買ったけど着てくれないの悲しい", Version: 1}
// ./main.go:10:25: invalid implicit pointer indirection to reach Version

FAQ

Q. 昇格フィールドをキーに書くと、どの埋め込み由来か分からなくなりませんか?

なります。どの埋め込み由来かを知りたくなったら、型定義に飛ぶしか無い認識です。

Q. 後から外側の型に同名フィールドが追加されたら、リテラルの意味が変わる懸念は解決していないのでは?

はい。 SystemColumns.Version のつもりで User{Version: 1} と書いていたコードに、後から User.Version が追加されると、キーの指す先が変わります。ただし同様のことが u.Version = 1 という従来の代入文でも起きるので、リテラルに固有のリスクではないのです。そのために、今回の拡張が許容されたと理解しています。Go Playgroundにサンプルを置いておきます。

Q. リフレクションや encoding/json の挙動は変わりますか?

変わりません。構造体のメモリレイアウトもフィールドのタグも従来通りで、変わったのはリテラルの書き方だけです。%+v で出力すれば {UserID:U0001 Name:mano SystemColumns:{CreatedAt:... UpdatedAt:... Version:1}} と、これまで通り入れ子で表示されます。json.Marshal / json.Unmarshal も影響を受けません。

さいごに

Go 1.27の構造体リテラルのキー拡張を紹介しました。今回の修正は8/7公開予定の記事で説明する予定の go fix で変換してくれます。

最後まで読んでいただき、ありがとうございました!