【Go】同名フィールドを持つ構造体をEmbeddingするとどうなるのか

この記事はTech KAYAC Advent Calendar 2019の3日目の記事です。

新卒サーバサイドエンジニアの宮村 紅葉です! 普段はGoでゲームサーバをゴリゴリ書いています。

はじめに

GoにはEmbeddingと呼ばれる機能があります(日本語では「埋め込み」と書かれることが多いと思いますが、この記事ではEmbeddingと記述します)。このEmbeddingは便利ですが「複数の構造体を使ってEmbeddingした際に同名のフィールドが存在する」と思わぬ罠にハマります(私は最近ハマった笑)。ハマったからにはネタにせねば!ということで書いていきます!!

Embedding

同名フィールドを明示的に参照

まずEmbeddingして埋め込んだ構造体のフィールドを参照してみましょう。以降は S1 S2 Embedding 構造体を前提に説明します。なお説明のためにあえて DuplicatedKey という同名のフィールドを S1 S2 に持たせて、構造体 Embedding にEmbeddingしています(ダジャレみたいになってしまった笑)。

以下のように参照すると、問題なく参照できていますね。 同名フィールドである DuplicatedKey フィールドを参照する場合でも、 S1 S2 どちらのものかを「明示的に」指定すれば参照できます。

https://play.golang.org/p/9FqLGVPEm-L

type S1 struct {
    DuplicatedKey string
    V1            int
}

type S2 struct {
    DuplicatedKey string
    V2            int
}

type Embedding struct {
    *S1
    *S2
}

func main() {
    s1 := &S1{
        DuplicatedKey: "s1key",
        V1:            1,
    }
    s2 := &S2{
        DuplicatedKey: "s2key",
        V2:            2,
    }
    e := &Embedding{
        s1,
        s2,
    }

    fmt.Println("---s1を明示的に指定---")
    fmt.Printf("e.S1: %+v\n", e.S1)
    fmt.Printf("e.S1.V1: %+v\n", e.S1.V1)
    fmt.Printf("e.S1.duplicatedKey: %+v\n", e.S1.DuplicatedKey)

    fmt.Println("---s2を明示的に指定---")
    fmt.Printf("e.S2: %+v\n", e.S2)
    fmt.Printf("e.S2.V2: %+v\n", e.S2.V2)
    fmt.Printf("e.S2.DuplicatedKey: %+v\n", e.S2.DuplicatedKey)

    fmt.Println("---明示的に指定しない---")
    fmt.Printf("e.V1: %+v\n", e.V1)
    fmt.Printf("e.V2: %+v\n", e.V2)
}
---s1を明示的に指定---
e.S1: &{DuplicatedKey:s1key V1:1}
e.S1.V1: 1
e.S1.duplicatedKey: s1key
---s2を明示的に指定---
e.S2: &{DuplicatedKey:s2key V2:2}
e.S2.V2: 2
e.S2.DuplicatedKey: s2key
---明示的に指定しない---
e.V1: 1
e.V2: 2

同名フィールドを明示的に参照しないとどうなるか

では先ほどの例に対して、以下のように書いてみたらどうなるでしょうか?

https://play.golang.org/p/WNaS_wYmB77

fmt.Println("---名前が衝突しているフィールド---")
fmt.Printf("e.DuplicatedKey: %+v\n", e.DuplicatedKey)

結果はエラーになります。

./prog.go:37:40: ambiguous selector e.DuplicatedKey

考えてみれば当たり前で、プログラム側からすると「どっちの DuplicatedKey フィールドを呼び出したいの?」となるわけです。

公式ドキュメントをみてみる

ちなみにGoの公式でも、同名フィールドが存在する場合の Embedding の挙動について書かれています

Effective Go - The Go Programming Language

Embedding types introduces the problem of name conflicts but the rules to resolve them are simple. First, a field or method X hides any other item X in a more deeply nested part of the type. If log.Logger contained a field or method called Command, the Command field of Job would dominate it.

Second, if the same name appears at the same nesting level, it is usually an error; it would be erroneous to embed log.Logger if the Job struct contained another field or method called Logger. However, if the duplicate name is never mentioned in the program outside the type definition, it is OK. This qualification provides some protection against changes made to types embedded from outside; there is no problem if a field is added that conflicts with another field in another subtype if neither field is ever used.

同名フィールドのネストが異なる場合はよりネストが深い方のフィールドは無視されるみたいです。そして(こちらが今回の場合ですが)同名フィールドのネストが同一である場合、参照時にエラーになるとのこと(これは先ほど確認した挙動と同じですね)。しかし However, if the duplicate name is never mentioned in the program outside the type definition, it is OK と書かれているように「参照されない場合はエラーにならない」という挙動のようです(最初の例でも参照しなければエラーになりませんでしたね)。

json.Marshal するとどうなるか

「参照しなければエラーにならないのだから何も問題ないのでは?」と思われた方も多いと思います。同名フィールドを参照してしまってもエラーになるので気づくだろうと思いますよね?自分が参照するならば・・・ですが。

さて、先ほどの構造体を json.Marshal するとこうなります。

https://play.golang.org/p/DRQBmBTzrom

fmt.Printf("e.S1: %+v\n", e.S1)
fmt.Printf("e.S2: %+v\n", e.S2)

fmt.Println("---json.Marshal -> DuplicatedKeyが消える---")
b, err := json.Marshal(e)
if err != nil {
    log.Println(err)
    return
}
fmt.Printf("json.Marshal(e):%s\n", b)

fmt.Println("---json.Unmarshal -> 消えたまま---")
e2 := &Embedding{}
if err := json.Unmarshal(b, e2); err != nil {
    log.Println(err)
    return
}
fmt.Printf("e2.S1:%+v\n", e2.S1)
fmt.Printf("e2.S2:%+v\n", e2.S2)
e.S1: &{DuplicatedKey:s1key V1:1}
e.S2: &{DuplicatedKey:s2key V2:2}
---json.Marshal -> DuplicatedKeyが消える---
json.Marshal(e):{"V1":1,"V2":2}
---json.Unmarshal -> 消えたまま---
e2.S1:&{DuplicatedKey: V1:1}
e2.S2:&{DuplicatedKey: V2:2}

おわかりいただけただろうか・・・。そうですね。 json.Marshal した時点で同名のフィールドである DuplicatedKey の情報がゴッソリ抜け落ちています。おそらく json.Marshal 時に同名フィールドを参照して、エラーになった際にそのフィールドを無視する挙動になっているのではないかと思います。しかも json.Marshal メソッド自体は エラーを返していません。テストで気づくだろうと思われるかもしれませんが、「構造体を json.Marshal した後に json.Unmarshal して元に戻るかのテスト」ってあまり書かないと思います。

対策

S1 S2 にタグをつける

何かしら区別できればいいので、json:"" タグを使って json.Marshal メソッドが識別できるようにしてあげれば良さそうです。試してみるとうまくいっていますね。

https://play.golang.org/p/llq-zwdfWqw

type Embedding struct {
    *S1 `json:"s1"` // タグをつける
    *S2 `json:"s2"` // タグをつける
}
fmt.Printf("e.S1: %+v\n", e.S1)
fmt.Printf("e.S2: %+v\n", e.S2)

fmt.Println("---json.Marshal---")
b, err := json.Marshal(e)
if err != nil {
    log.Println(err)
    return
}
fmt.Printf("json.Marshal(e):%s\n", b)

fmt.Println("---json.Unmarshal---")
e2 := &Embedding{}
if err := json.Unmarshal(b, e2); err != nil {
    log.Println(err)
    return
}
fmt.Printf("e2.S1:%+v\n", e2.S1)
fmt.Printf("e2.S2:%+v\n", e2.S2)
e.S1: &{DuplicatedKey:s1key V1:1}
e.S2: &{DuplicatedKey:s2key V2:2}
---json.Marshal---
json.Marshal(e):{"s1":{"DuplicatedKey":"s1key","V1":1},"s2":{"DuplicatedKey":"s2key","V2":2}}
---json.Unmarshal---
e2.S1:&{DuplicatedKey:s1key V1:1}
e2.S2:&{DuplicatedKey:s2key V2:2}

json.Marshal(e):{"s1":{"DuplicatedKey":"s1key","V1":1},"s2":{"DuplicatedKey":"s2key","V2":2}} という風に Marshal してくれていて、 DuplicatedKey が消えずに残っていますね!

DuplicatedKey に別名でタグをつける

S1 S2 にタグをつけるとjsonの構造が変化します。すでに運用しているコードで「これだと困る! 」という場合には DuplicatedKey にそれぞれ「別の名前」でタグをつけてあげるという手があります。

https://play.golang.org/p/I1iVoS6ZjwJ

type S1 struct {
    DuplicatedKey string `json:"duplicatedKey1"` // タグ名を変える
    V1            int    `json:"v1"`
}

type S2 struct {
    DuplicatedKey string `json:"duplicatedKey2"` // タグ名を変える
    V2            int    `json:"v2"`
}
fmt.Printf("e.S1: %+v\n", e.S1)
fmt.Printf("e.S2: %+v\n", e.S2)

fmt.Println("---json.Marshal---")
b, err := json.Marshal(e)
if err != nil {
    log.Println(err)
    return
}
fmt.Printf("json.Marshal(e):%s\n", b)

fmt.Println("---json.Unmarshal---")
e2 := &Embedding{}
if err := json.Unmarshal(b, e2); err != nil {
    log.Println(err)
    return
}
fmt.Printf("e2.S1:%+v\n", e2.S1)
fmt.Printf("e2.S2:%+v\n", e2.S2)
e.S1: &{DuplicatedKey:s1key V1:1}
e.S2: &{DuplicatedKey:s2key V2:2}
---json.Marshal---
json.Marshal(e):{"duplicatedKey1":"s1key","v1":1,"duplicatedKey2":"s2key","v2":2}
---json.Unmarshal---
e2.S1:&{DuplicatedKey:s1key V1:1}
e2.S2:&{DuplicatedKey:s2key V2:2}

S1json:"duplicatedKey1"S2json:"duplicatedKey2" と別名のタグをつけました。こうすると json.Marshal(e):{"duplicatedKey1":"s1key","v1":1,"duplicatedKey2":"s2key","v2":2} となって、jsonの構造を崩すことなく対処することができるのでオススメです。ただし同じ名前のタグをつけても意味がないので気をつけましょう(自動生成してるとやりがち)。

おわりに

まとめると「同名フィールドを持つ構造体をEmbeddingすると json.Marshal 時に消える。対策としては適切にタグをつけること。」となります。みなさんも2019年締めくくる前に、プロジェクトでEmbeddingを行なっている箇所を見直してみると、事故の原因を未然に防いで安心して新年を迎えられるかもしれません笑

ちなみに今回は確認していませんが、他の Marshal 系メソッドでも同様の挙動になるかもしれないので気になる方は試してみてくださいね。

私の記事は以上です!テックブログ初めて書いたのでドキドキですが楽しんでもらえたらとっても嬉しいです!

次回は(次回も?笑)テックブログでおなじみ!平山さんの記事です。こちらもよろしくお願いします!