🦜

Goの構造体とインターフェースを実践で理解する

に公開

はじめに

こちらの記事でGoの構造体とインターフェースを理解したつもりになったので、実際にコードを作って確認してみることにした。
https://zenn.dev/mirichi/articles/9b1f1f8d8ab96a

構造体の挙動を確認する

レシーバが値型かポインタ型かで、構造体の値がコピーされるか、ポインタがコピーされるかが変わる。呼ぶときの型はコンパイラが自動解決してくれるので気にすることは無いが、そこも含めて確認してみる。こんな感じでいいだろうか。

package main

import (
	"fmt"
)

// 値を1個だけ持つ構造体T
type T struct {
	I int // 値i
}

// 値レシーバのメソッドVMを構造体Tに定義
func (t T) VM() {
	fmt.Printf("値呼んだ I=%d\n", t.I)
	t.I *= 10
}

// ポインタレシーバのメソッドPMを構造体Tに定義
func (t *T) PM() {
	fmt.Printf("ポインタ呼んだ I=%d\n", t.I)
	t.I *= 10
}

func main() {
	var hoge1 T = T{I: 1}               // T型変数に構造体Tを代入

	hoge1.VM()                          // => 値呼んだ I=1
	fmt.Printf("hoge1.I=%d\n", hoge1.I) // => hoge1.I=1

	// 本来であれば(&hoge1).PM()と呼ぶべきだが、コンパイラが自動解決してくれている
	hoge1.PM()                          // => ポインタ呼んだ I=1
	fmt.Printf("hoge1.I=%d\n", hoge1.I) // => hoge1.I=10

	//	var hoge2 T = &T{i:2} // T型変数に構造体Tのポインタを代入(エラー)

	var hoge3 *T = &T{I: 3}             // T型ポインタ変数に構造体Tのポインタを代入

	// 本来であれば(*hoge1).VM()と呼ぶべきだが、コンパイラが自動解決してくれている
	hoge3.VM()                          // => 値呼んだ I=3
	fmt.Printf("hoge3.I=%d\n", hoge3.I) // => hoge3.I=3

	hoge3.PM()                          // => ポインタ呼んだ I=3
	fmt.Printf("hoge3.I=%d\n", hoge3.I) // => hoge3.I=30

	//	var hoge4 *T = T{i:4} // T型ポインタ変数に構造体Tを代入(エラー)
}

値型レシーバのメソッドを呼んだ場合、構造体がコピーされているのでIを変更しても呼び元のレシーバhoge何某には影響しない。ポインタ型レシーバのメソッドを呼んだ場合は呼び元のレシーバのインスタンスを参照するのでIが変更される。把握した通りの挙動が確認できた。
ところでhoge1.PM()のhoge1は値型変数でありレシーバだが、呼んだ先のメソッドのレシーバはポインタ型である。こうした場合に「レシーバ」は値型なのかポインタ型なのかというと、実に微妙な表現の問題があるように思える。呼び分けるGoならではの用語などがあったりするのだろうか。

インターフェースの挙動を確認する

インターフェースは(value, type)の形になっていて、格納した情報はvalueに埋め込まれる。値型を入れれば値がvalueにコピーされ、ポインタ型ならvalueにアドレスがコピーされる。
インターフェースで定義されたメソッドがポインタ型だった場合、このインターフェースのvalueには値を格納することができない。インターフェース内に埋め込まれた値のアドレスは取得できない、からなのか、インターフェース内に埋め込まれた値の書き換えが許されていない、からなのかは不明である。
メソッドが値型レシーバだった場合、ポインタ型を格納したインターフェースでも値を取り出してコピーしてくれる。これは構造体の時と同様の自動解決である。

package main

import (
	"fmt"
)

// 値を1個だけ持つ構造体T
type T struct {
	I int // 値i
}

// 値レシーバのメソッドVMを構造体Tに定義
func (t T) VM() {
	fmt.Printf("値呼んだ I=%d\n", t.I)
	t.I *= 10
}

// ポインタレシーバのメソッドPMを構造体Tに定義
func (t *T) PM() {
	fmt.Printf("ポインタ呼んだ I=%d\n", t.I)
	t.I *= 10
}

// VM()を持つインターフェース
type IFVM interface {
	VM()
}

// VM()を持つインターフェース
type IFPM interface {
	PM()
}

func main() {
	var hoge1 T = T{I: 1}

	// インターフェースIFVMに構造体Tを入れる
	IFVM(hoge1).VM()                    // => 値呼んだ I=1
	fmt.Printf("hoge1.I=%d\n", hoge1.I) // => hoge1.I=1

	// インターフェースIFVMに構造体Tのポインタを入れる
	IFVM(&hoge1).VM()                   // => 値呼んだ I=1
	fmt.Printf("hoge1.I=%d\n", hoge1.I) // => hoge1.I=1

	// インターフェースIFPMに構造体Tを入れる(エラー)
	// IFPM(hoge1).PM()
	// fmt.Printf("hoge1.I=%d\n", hoge1.I)

	// インターフェースIFPMに構造体Tのポインタを入れる
	IFPM(&hoge1).PM()                   // => ポインタ呼んだ I=1
	fmt.Printf("hoge1.I=%d\n", hoge1.I) // => hoge1.I=10
}

ポインタ型レシーバのときだけ値が書き換わることが確認できた。
ちなみに構造体のコピーの挙動として、IFVMにhoge1を入れたときにコピーされて、さらにメソッドVM()を呼んだときにもう一回コピーされていると思われる。これを確認する手段は思いつかないが。

mapの挙動を確認する

mapはキーに入れたものを使ってハッシュ値算出、キー比較をするので、値型を入れた場合は値の一致、ポインタ型を入れた場合はアドレスの一致をみることになる。ここは自動解決みたいなことはしてくれないので、キーの型を何にするかが重要だ。ちょっと長くなってきた。

package main

import (
	"fmt"
)

// 値を1個だけ持つ構造体T
type T struct {
	I int // 値i
}

// インターフェース
type IF interface {
	PM()
}

// Iを更新するレシーバ型メソッド
func (t *T) PM() {
	t.I *= 10
}

// 空のインターフェース
type IF2 interface{}

func main() {
	// 値をキーにしたmap
	var m1 map[T]int = map[T]int{}
	var hoge1 *T = &T{I: 1}

	// hoge1はポインタ型だが値を取り出してキーにした
	m1[*hoge1] = 5

	// ということは値がコピーされているので、hoge1を書き換えて検索しても値が違って取り出せない
	// 値がキーになったmapは値同士の比較で検索が行われている
	hoge1.I = 2
	fmt.Printf("m1[*hoge1]=%d\n", m1[*hoge1]) // => m1[*hoge1]=0

	// 値が同じであれば違うインスタンスでも取り出せる
	fmt.Printf("m1[T{I: 1}]=%d\n", m1[T{I: 1}]) // => m1[T{I: 1}]=5

	// ポインタをキーにしたmap
	var m2 map[*T]int = map[*T]int{}
	var hoge2 *T = &T{I: 1}

	// hoge2はポインタ型でそのままポインタをキーにする
	m2[hoge2] = 5

	// ポインタ型をキーにするとアドレスでの比較になるため、hoge2を書き換えてもアドレスが一致していれば取り出せる
	hoge2.I = 2
	fmt.Printf("m2[hoge2]=%d\n", m2[hoge2]) // => m2[hoge2]=5

	// 値が同じでもアドレスが違うと一致しなくて取り出せない
	fmt.Printf("m2[hoge1]=%d\n", m2[hoge1]) // => m2[hoge1]=0

	// インターフェースをキーにしたmap
	var m3 map[IF]int = map[IF]int{}
	var hoge3 IF = &T{I: 1}

	// hoge3はインターフェースなのでそのままキーにする
	m3[hoge3] = 5

	// インターフェースをキーにすると内容(=構造体のアドレス)での比較になるため値を書き換えても取り出せる
	hoge3.PM()                              // 値の書き換え
	fmt.Printf("m3[hoge3]=%d\n", m3[hoge3]) // => m3[hoge3]=5

	// 空のインターフェースをキーにしたmap
	var m4 map[IF2]int = map[IF2]int{}
	// 値型とポインタ型を入れたインターフェースを用意。構造体の値は同じ。
	var hoge4 IF2 = T{I: 1}
	var hoge5 IF2 = &T{I: 1}

	// 値型を入れたインターフェースをキーにする
	m4[hoge4] = 5

	// ポインタ型を入れたインターフェースでmapを検索しても一致しない
	fmt.Printf("m4[hoge5]=%d\n", m4[hoge5]) // => m4[hoge5]=0

	// 値型を入れたインターフェースなら新しく作っても一致する
	fmt.Printf("m4[IF2(T{I:1})]=%d\n", m4[IF2(T{I: 1})]) // => m4[IF2(T{I:1})]=5
}

ポインタ型をキーにしていると、mapに中身の情報を持たないから、中身を書き換えても変わらず参照することができる。値型をキーにしていると値の一致をみるから実体が違っても参照できる。これはインターフェースだったとしても同様。

インターフェースの挙動の謎

ここまで調べてきて、インターフェースがやっぱりよくわからん気がする。
インターフェースはvalueにポインタ型を格納することができ、ポインタ型の場合はメソッドのレシーバは値型でもポインタ型でもどっちでもよい。Goのインターフェースの定義はメソッド名のみであり、メソッドの型は無いので、インターフェースに格納する構造体によりレシーバが値型だったりポインタ型だったりするはずだ。
つまり、格納されている構造体に定義されたメソッドが値型なのかポインタ型なのかで、実行時に値を取り出してコピーするか、ポインタをコピーするかが切り替わるような挙動をする、ということになる。
試してみよう。

package main

import (
	"fmt"
)

// 構造体T1
type T1 struct {
	I int
}

// レシーバ型メソッド
func (t *T1) M() {
	fmt.Printf("レシーバ型M %d\n", t.I)
}

// 構造体T2
type T2 struct {
	I int
}

// 値型メソッド
func (t T2) M() {
	fmt.Printf("値型M %d\n", t.I)
}

// インターフェース
type IF interface {
	M()
}

func main() {
	callM(&T1{I: 1}) // => レシーバ型M 1  valueをそのままM()に渡せばいい
	callM(T2{I: 2})  // => 値型M 2        valueをそのままM()に渡せばいい
	callM(&T2{I: 3}) // => 値型M 3        valueの参照先である値を取り出してM()に渡しているはず…
}

func callM(i IF) {
	i.M() // なんか動的に処理してるっぽい
}

そもそも考えたきっかけは、インターフェースで引数を受け取ったときに、メソッド側はどのようにコンパイルされるんだ、という話だったので、そのように試してみた。どうやら動的に処理されているようだ。
Goは静的型付け言語っぽい雰囲気ではあるが、なんだかObjective-Cほどではないにせよそこそこ動的な振る舞いをする言語ということらしい。

おしまい

ここまでやればだいたい挙動と仕組みは理解できたはず。
なお、ちょっと前に作ったEbiten用サンプルプログラムはやっぱりというかなんというかバグっているような気がしてきた。

Discussion