golang の「埋め込み」を利用した実装アンチパターン

最近、time.Time を独自の struct で埋め込んだ実装をみかけたので書いておこうと思った。

結論から言うと、
標準パッケージやライブラリで定義されている型に対して「埋め込み」を使うと、
コードが正常に動かなくなる可能性があるので、
注意した方がいいというお話。

自分は、普段 GAE/Go で開発をしていて、
ストレージとして Datastore を利用しているが、
time.Time を独自の struct で埋め込んだ実装でハマった経験がある。

問題の説明

time.Time に限らず、
埋め込みによって、既存の struct を拡張するというアプローチはよくあるものだと思う。


以下の実装は既存の time.Time 型の機能に Hello() というメソッドを追加している。

package pospome_time

type Time struct {
	time.Time
}

func (t *Time) Hello() {
	fmt.Println("pospome time hello");
}

こうすることで、
time.Time のメソッドはそのまま利用できるし、
Hello() というアプリケーションコードに必要な振る舞いも定義できる。

ここで気をつけなければならないのは、
埋め込みは既存の struct の型を持たない という点。

上記の例で言うと、pospome_time.Time は time.Time 型ではない

なので、time.Time に依存する処理がある場合、上手く動かないことがある。

最初にサラッと説明した GAE/Go の Datastore に例で言うと、
以下のように time.Time の型を判定する処理がある。
*とりあえず、grep したパッと出てきたやつです。
https://github.com/GoogleCloudPlatform/google-cloud-go/blob/fdbfa8004f3a20b8b375215c71577a2d6ec48ab3/datastore/save.go#L72

pospome_time.Time は time.Time ではないので、
time.Time に対応した処理は実行されない。

これは pospome_time.Time の利用者からすると期待通りの動作ではない可能性がある。
なぜなら、pospome_time は time.Time を埋め込んでいるので、
あたかも time.Time を扱っているように思えてしまうから。

そして、これらのハマりどころは、
引数が interface{} になっている関数やメソッド が原因であることが多い。

以下のように関数の引数が time.Time だった場合、
pospome_time.Time は time.Time 型ではないので、
そもそも引数に指定できない。
このケースで事故ることはない。

// pospome_time.Time は指定できない。
// これは事故らない
func Xxx(t time.Time) {
}



一方、引数が interface{} である場合、
pospome_time.Time も time.Time も指定できてしまう。

// pospome_time.Time が指定できてしまう
// これは事故るかも?
func Xxx(src interface{}) {
	//ここの中で time.Time かどうかを判定している。
}

そして、こーゆー関数に限って関数内で型判定が存在する。
関数を実装する側は、具体的な型を知らなければならないので、当然といえば当然。

そして、以下のような type による独自型定義も time.Time としては扱われない。
埋め込みだけではなく、こちらも注意。

type PospomeTime time.Time



どう解決すればいいのか?

自分は、埋め込みが難しそうなときは、
以下のようにそれ用の関数を作っている。

func Hello(t time.Time) {
	//なんかする
}



以下のように、
埋め込みをした struct から、
元の struct へ変換するようなものも考えたが、
変換を忘れて、そのまま interface{} の引数に突っ込んでしまいそうなので避けている

package pospome_time

type Time struct {
	time.Time
}

func (t *Time) NewTime() time.Time {
	//レシーバの t を元に time.Time を作る。
}



interface{} の引数を持つ関数を利用する箇所が限られているのであれば、
上記の実装でも問題ないかもしれない。
ここは既存のアーキテクチャとチームの方針次第になる。


標準パッケージやライブラリに対する埋め込みはNGなのか

標準パッケージやライブラリに対する埋め込みはNGなのか?
というと、そんなことはない。

アプリケーションコードでしか利用しないような struct の拡張であれば問題はない。

time.Time は、
Datastore や RDB のようなストレージでよく利用される特性を持ち、
それらが提供している関数の引数が interface{} であることが多いので、
ハマったが、
それ以外のケースで利用する場合、
埋め込みでもいけると思う。

例えば、複雑な日時計算をするような場合、
time.Time を埋め込んだ struct を作成することで、
いい感じのコードになることは十分に考えられる。

ちなみに、型判定は reflect パッケージを利用する。
int, string のようなプリミティブ型であれば、
独自の型を定義しても reflect で判定可能なので、問題にならなかったりする。
https://github.com/GoogleCloudPlatform/google-cloud-go/blob/fdbfa8004f3a20b8b375215c71577a2d6ec48ab3/datastore/save.go#L76

//こーゆーのは問題ない
type MyInt int



しかし、struct に関しては、
reflect.Struct というザックリした型しか存在しない。

なので、
以下のように、それっぽい struct を想定したコードを書くのが限界。
https://github.com/GoogleCloudPlatform/google-cloud-go/blob/fdbfa8004f3a20b8b375215c71577a2d6ec48ab3/datastore/save.go#L71-L72

当然、Datastore の作者は pospome_time.Time を知るはずもないので、
ここに pospome_time.Time が追加されることはない。

今回の件は
interface{} の引数に渡されやすく、
複雑な計算が要求されやすい time.Time ならではのハマりどころなのかもしれない。


まとめ

特に無いです。

time.Time + 埋め込みの件については、
自分を含めてハマった人を複数人見たので書き残しておこうと思いました。
これから GAE/Go やる人増えてくると、そこそこハマるんじゃないかなーと思います・・・。

アサーション、reflect 周りの知識が弱いので、
間違いや補足があれば、教えてください。
修正させていただきます。