Go
golang

Go interfaceの理解しにくいところ

※ Retty Advent Calendar 2018 11日目の記事です。
昨日は @shogo-tsutsumi さんの記事で、 広告KPIダッシュボード開発でPDCA回してみた でした。

はじめに

RettyではPHP・Kotlinなど様々な言語を使って開発を行なっています。
その中、今年Goを使ったマイクロサービスの開発も始まりました。
今回、個人的にGoの機能の中でも一番有能なinterfaceについて書きます。

GoのDuck Typingについて

Python, Rubyなどのdynamic言語でよく知られているDuck Typingですが、
Javaみたいにimplementsキーワードを使ってinterfaceの実装を宣言しなくても、
runtime時に、methodが実装されているかどうかをチェックしてくれる便利な仕組みです。

GoではPython, Rubyのような単純なDuck Typingよりもっと便利な実装になっています。
まず、runtime時にdynamicチェックが行われます。

dynamic-check.go
... // import略
type I interface {
    PrintName()
}

type T struct{}

func (*T) PrintName() {
    fmt.Println("T")
}

func p(i interface{}) {
    i.(I).PrintName()
}

func main() {
    var t T
    p(&t)
    p(t) // panic: interface conversion: main.T is not main.I: missing method PrintName
}

playground
そして、staticでチェック可能なものはcompile時staticチェックもしてくれます。

static-check.go
... // import&declare略
func p(i I) { // interface{} -> I に変わったことで、compile時にチェックが可能になる
    i.(I).PrintName()
}

func main() {
    var t T
    p(&t)
    p(t) // cannot use t (type T) as type I in argument to p
}

playground

本題に

compile時とruntime時でどちらもtype checkが行われる、
その便利なGo interfaceですが、割と理解しにくいところもあります。

まず、サンプルコードから

... // import略
type I interface {
    PrintName()
}

type T1 struct{}

func (t *T1) PrintName() { // pointer receiver
    fmt.Println("T1")
}

type T2 struct{}

func (t T2) PrintName() { // value receiver
    fmt.Println("T2")
}

func main() {
    var i I

    var pT1 *T1
    i = pT1
    i.PrintName()

    var t2 T2
    i = t2
    i.PrintName()
}

playground
上のコードは問題なく動きます。
そして、次のコード

... // import&declare略
func main() {
    var i I

    //var t1 T1
    //i = t1 // cannot use t1 (type T1) as type I in assignment
    //i.PrintName()

    var pT2 *T2
    i = pT2
    i.PrintName() // panic: value method main.T2.PrintName called using nil *T2 pointer
}

playground

まず、t1Iを実装していないため、i = t1compileエラー、とても分かりやすいです。
でも、pT2Iを実装してないはずだけど、なんでi = pT2compile通る?とても分かりません。
さらに、compile通ったけど、なんでpT2i.PrintName()がruntimeエラーになる???もっと分かりません。

なんでi = pT2ではcompileが通る?

色々調べた結果、gccgoのソースコードの中から理由を見つけました。
libgo/runtime/interface.h#15
libgo/runtime/go-convert-interface.c#24
gccgoのinterfaceの定義とinterface(gccgoの中ではstructもinterface扱い)間のコンバートを読んで理解しました。

  • interface(__go_interface)がtype descriptor(__go_type_descriptor)で表現される
  • interfaceのtype descriptorが3つの要素を持つ
    • typeのpointer -> Iのpointer
    • function pointerのリスト -> PrintNameのfuntion pointerが入ってるリスト
    • valueのpointer -> 重要: ここはpT2のpointerなはずなんだけど、gccgoはheap領域をケチるため、どうやらpointerのpointerは保存しない。なので、ここinterfaceのvalueがpointerである場合、直接valueそのものを保存する (謎が解けた!!)

なので、pT2iに代入する際に、iのtype descriptorが持つのはpT2そのものであって、pT2のpointerではありません。
だから、i = pT2i = *pT2(compileは通るが、nilをdereferenceしようとするので、ここはruntimeエラーになる)と同じ意味をします。

なんでpT2i.PrintName()がnil pointerエラー?

spec
type T struct {
    a int
}
func (tv  T) Mv(a int) int         { return 0 }  // value receiver
func (tp *T) Mp(f float32) float32 { return 1 }  // pointer receiver

var t T
var pt *T

As with selectors, a reference to a non-interface method with a value receiver using a pointer will automatically dereference that pointer: pt.Mv is equivalent to (*pt).Mv.

As with method calls, a reference to a non-interface method with a pointer receiver using an addressable value will automatically take the address of that value: t.Mp is equivalent to (&t).Mp.

Goのspecに載せてる上の英語を読んでみればすでに分かったと思いますが、一応説明します。

  • methodのreceiverがvalueの場合、pointerが自動的にdereferenceされる
    • pt.Mvは自動的に(*pt).Mvにされる
  • methodのreceiverがpointerの場合、valueのアドレスが自動的に取得される
    • t.Mpは自動的に(&t).Mpにされる

なので、i.PrintName()を呼ぶと、interfaceiが持っているvaluepT2がreceiverとしてfunctionPrintNameを呼び出します。
つまり、pT2.PrintName()、上のルールで自動的に(*pT2).PrintName()にされます。
pT2がnilな値なので、deferenceしようとすると、runtimeエラーが発生します。
(謎が解けた!!)

おわりに

Go interfaceはこんなちゃんと理解しようとすると、わりと理解しにくいところもあるけれど、
普段開発で使う分では全く困らないので、これからもGoをたくさん使って、もっと発信していこうと思います。
最後に、gccgoのソースコードが想定よりもとっても簡単で分かりやすかったので読んでて楽しかったです。
もし興味あれば、ぜひ読んでみてください。