SODA Engineering Blog
🕵️

なぜ `T` と `*T` のメソッドセットが違うのか?~Goの仕様を考える~

2023/06/02に公開

導入

Goのコードを書いていると以下のようなコードに出くわしました

package main

type A struct {
	id int
}

func (a A) ID() int {
	return a.id
}

type B struct {
	id int
}

func (b *B) ID() int {
	return b.id
}

type I interface {
	ID() int
}

func main() {
	var i I
	_ = i.(A)
	_ = i.(*A)

	_ = i.(B) // B does not implement I (method ID has pointer receiver)
	_ = i.(*B)
}

GoPlaygroundで動かす

どうやら構造体 B はインターフェイス I実装していないようです。
構造体 B はメソッド ID() int を持っているので、インターフェイス I を実装しているように直感的には見えますが、なぜ実装していないことになっているのかを調べてみました。

この記事で知れること

  • T に定義するメソッドのレシーバを T*T で定義することの違い
  • なぜそのような違いがあるのか

結論

  1. T に定義するメソッドのレシーバを T*T で定義することの違いはインターフェイスを実装するかどうかに関わってくる (Goの仕様)
  2. インターフェイスを経由したメソッドの呼び出しにおいて、値を更新するなどの処理がある場合、型 T のメソッドセットが *T のレシーバのメソッドを含んでしまうと、インターフェイスを経由するメソッドの呼び出し元とデータの整合性が取れなくなるバグが起こりうるため1のような仕様になっている

実際に調べてみた

結論はGoの仕様書にあった

GoSpecに回答が書いてありました。

仕様1

The method set of a defined type T consists of all methods declared with receiver type T.

(意訳) ある定義された型 T のメソッドセットはレシーバが T で定義されたすべてのメソッドである。

仕様2

The method set of a pointer to a defined type T (where T is neither a pointer nor an interface) is the set of all methods declared with receiver *T or T.

(意訳) ある定義された型 T がポインタ型でもインターフェイスでもない時、その型のポインタに対するメソッドセットはレシーバが *TT で定義されているすべてのメソッドの集合である。

つまりどういうことかというと...

仕様書通りの挙動になっているか先ほどのコードを詳しくみていきます (不必要な部分は削ってます)

最初の i.(A) に仕様1を当てはめてみます。

type A struct {
	id int
}

func (a A) ID() int {
	return a.id
}

func main() {
	// 型Aに定義されたメソッドセットはレシーバがAのものである
	_ = i.(A) // AはID()を実装している = インターフェイスIを実装している
}

次に i.(*A) に仕様2を当てはめてみます。

type A struct {
	id int
}

func (a A) ID() int {
	return a.id
}

func main() {
	// 型Aのポインタに対するメソッドセットはレシーバがAと*Aのものである
	_ = i.(*A) // *AはID()を実装している = インターフェイスIを実装している
}

次に i.(B) に仕様1を当てはめてみます。

type B struct {
	id int
}

func (b *B) ID() int {
	return b.id
}

func main() {
	// 型Bに定義されたメソッドセットはレシーバがBのものである
	_ = i.(B) // BはID()を実装していない = インターフェイスIを実装していない
}

最後に i.(*B) に仕様2を当てはめてみます。

type B struct {
	id int
}

func (b *B) ID() int {
	return b.id
}

func main() {
	// 型Bのポインタに対するメソッドセットはレシーバがBと*Bのものである
	_ = i.(*B) // *BはID()を実装している = インターフェイスIを実装している
}

確かに仕様書通りの挙動になっているようです。

なぜ、このような仕様になっているのか?

前置きがだいぶ長くなりましたが、なぜこのような仕様になっているのでしょうか?
こちらに関しても、GoのFAQに回答がありました。

This distinction arises because if an interface value contains a pointer *T, a method call can obtain a value by dereferencing the pointer, but if an interface value contains a value T, there is no safe way for a method call to obtain a pointer.

(意訳) この違い (メソッドセットの定義が T*T で異なるという違いのことだと思います) はインターフェイスの値がポインタ *T を含む場合、メソッドコールはポインタを参照することで値を得ることができるが、インターフェイスの値が T を含む場合、メソッドコールがポインタを得るための安全な方法はないためです。

正直、自分には何を言っているのかさっぱりでした。

具体例で考える

幸いなことに、もう少し下に具体例が記載されていたのでそれを元に考えてみます。

As an example, if the Write method of bytes.Buffer used a value receiver rather than a pointer, this code:

	var buf bytes.Buffer
	io.Copy(buf, os.Stdin)

would copy standard input into a copy of buf, not into buf itself. This is almost never the desired behavior.

(意訳)
例として、もし bytes.BufferWrite メソッドがポインタレシーバではなく、値レシーバを持っていたとしたら、以下のコード

	var buf bytes.Buffer
	io.Copy(buf, os.Stdin)

は標準入力を buf それ自体ではなく buf のコピーに書き込むでしょう。これは全くもって意図する振る舞いではないです。


実際には上記のコードはコンパイルエラーになります。

	var buf bytes.Buffer
	// cannot use buf (variable of type bytes.Buffer) as io.Writer value in argument to io.Copy: bytes.Buffer does not implement io.Writer (method Write has pointer receiver)
	io.Copy(buf, os.Stdin)

なぜコンパイルエラーになっているか、はもちろん先ほどのメソッドセットに関する仕様1と仕様2が関係しています。

念のために調べてみましょう。

  • io.Copy のシグネチャ
    • 第一引数は Writer
> go doc io.Copy
package io // import "io"

func Copy(dst Writer, src Reader) (written int64, err error)
    Copy copies from src to dst until either EOF is reached on src or an
    error occurs. It returns the number of bytes copied and the first error
    encountered while copying, if any.

    A successful Copy returns err == nil, not err == EOF. Because Copy is
    defined to read from src until EOF, it does not treat an EOF from Read as an
    error to be reported.

    If src implements the WriterTo interface, the copy is implemented by calling
    src.WriteTo(dst). Otherwise, if dst implements the ReaderFrom interface,
    then copy is implemented by calling dst.ReadFrom(src).
  • io.Writer の定義
    • インターフェイスで Writer(p []byte) (n int, err error) のメソッドを実装していれば io.Writer を実装していることになる
> go doc io.Writer
package io // import "io"

type Writer interface {
	Write(p []byte) (n int, err error)
}
    Writer is the interface that wraps the basic Write method.

    Write writes len(p) bytes from p to the underlying data stream. It returns
    the number of bytes written from p (0 <= n <= len(p)) and any error
    encountered that caused the write to stop early. Write must return a non-nil
    error if it returns n < len(p). Write must not modify the slice data,
    even temporarily.

    Implementations must not retain p.

var Discard Writer = discard{}
func MultiWriter(writers ...Writer) Writer
  • bytes.Buffer が持つ Writer メソッド
    • Writer(p []byte) (n int, err error) のメソッドを実装している
> go doc bytes.Buffer | grep "Write(p"
func (b *Buffer) Write(p []byte) (n int, err error)

仕様1を元に先ほどのコードに戻ります。

  • buf のメソッドセットはレシーバが buf のもの (仕様1より)
  • つまり、ポインタレシーバを持つ Writer(p []byte) (n int, err error) のメソッドは実装していないことになる
  • bufio.Writer を実装していないので io.Copy(buf, os.Stdin) がコンパイルエラーになる
	var buf bytes.Buffer
	// cannot use buf (variable of type bytes.Buffer) as io.Writer value in argument to io.Copy: bytes.Buffer does not implement io.Writer (method Write has pointer receiver)
	io.Copy(buf, os.Stdin)

だいぶ長くなってきましたが、もう少し調べてみます

標準入力を buf それ自体ではなく buf のコピーに書き込むでしょう。

この記述についてもう少し調べてみます。

似たようなコードを書いてみて、dlvで調べてみましょう

package main

import "fmt"

type A struct {
	id int
}

func (a A) ID() int {
	return a.id
}

func (a A) Add(n int) {
	a.id += n
}

type I interface {
	ID() int
	Add(n int)
}

func main() {
	var a A

	fmt.Printf("Before add: %+v\n", a.ID())

	add(a, 1)

	fmt.Printf("After add: %+v\n", a.ID())
}

func add(i I, n int) {
	fmt.Printf("Before add inside: %+v\n", i.ID())

	i.Add(n)

	fmt.Printf("After add inside: %+v\n", i.ID())
}

GoPlaygroundで動かしてみてもわかるように id は更新されません。

# output
Before add: 0
Before add inside: 0
After add inside: 0
After add: 0

ai のアドレスを調べてみます。

dlv debug main.go
Type 'help' for list of commands.
(dlv) b main.go:22
Breakpoint 1 set at 0x10b6fe6 for main.main() ./main.go:22
(dlv) b main.go:34
Breakpoint 2 set at 0x10b728b for main.add() ./main.go:34
(dlv) c
> main.main() ./main.go:22 (hits goroutine(1):1 total:1) (PC: 0x10b6fe6)
    17:		ID() int
    18:		Add(n int)
    19:	}
    20:	
    21:	func main() {
=>  22:		var a A
    23:	
    24:		fmt.Printf("Before add: %+v\n", a.ID())
    25:	
    26:		add(a, 1)
    27:	
(dlv) n
> main.main() ./main.go:24 (PC: 0x10b6fef)
    19:	}
    20:	
    21:	func main() {
    22:		var a A
    23:	
=>  24:		fmt.Printf("Before add: %+v\n", a.ID())
    25:	
    26:		add(a, 1)
    27:	
    28:		fmt.Printf("After add: %+v\n", a.ID())
    29:	}
(dlv) p a
main.A {id: 0}
(dlv) p &a
(*main.A)(0xc00010dee8)
(dlv) c
Before add: 0
Before add inside : 0
> main.add() ./main.go:34 (hits goroutine(1):1 total:1) (PC: 0x10b728b)
    29:	}
    30:	
    31:	func add(i I, n int) {
    32:		fmt.Printf("Before add inside : %+v\n", i.ID())
    33:	
=>  34:		i.Add(n)
    35:	
    36:		fmt.Printf("After add inside: %+v\n", i.ID())
    37:	}
(dlv) n
> main.add() ./main.go:36 (PC: 0x10b72ab)
    31:	func add(i I, n int) {
    32:		fmt.Printf("Before add inside : %+v\n", i.ID())
    33:	
    34:		i.Add(n)
    35:	
=>  36:		fmt.Printf("After add inside: %+v\n", i.ID())
    37:	}
(dlv) p i
main.I(main.A) {id: 0}
(dlv) p &i
(*main.I)(0xc00010dec0)
  • a のアドレスは 0xc00010dee8
  • i のアドレスは 0xc00010dec0

a がコピーされて関数に渡されている上に id は更新されません。

ここで id が更新されるようにメソッドを書き換えてみます。

- func (a A) Add(n int) {
+ func (a *A) Add(n int) {

さらにメソッドセットに関する仕様1がないと仮定しましょう。
そうすると以下のようになるはずです (なるはずと言っているのは実際にはコンパイルエラーになって実行できないからです)。

# output
Before add: 0
Before add inside: 0
After add inside: 1
After add: 0

本来なら After add: 1 となってほしいので、これは意図した挙動ではないです。先ほどの io.Copy を使った例が言っていることが少しは分かるのではないかと思います。

bufos.Stdin の内容をコピーするには、io.Copy(&buf, os.Stdin)とする必要があるということですね。

つまり、インターフェイスを経由したメソッドの呼び出しにおいて、値を更新するなどの処理がある場合、型 T のメソッドセットが *T のレシーバのメソッドを含んでしまうと、インターフェイスを経由するメソッドの呼び出し元とデータの整合性が取れなくなる、ということが起こりうるために仕様1、2のようになっているのだと理解できます。

結論

かなり長くなってしまいましたが、まとめると

  1. Goのメソッドセットの仕様は以下のようになっている
    a. 型 T のメソッドセットはレシーバが T で定義されたすべてのメソッド
    b. 型 T がポインタ型でもインターフェイスでもない時、その型のポインタに対するメソッドセットはレシーバが *TT で定義されているすべてのメソッドの集合
  2. 1の仕様の差は T がインターフェイスを実装しているかどうかに関わってくる
  3. インターフェイスを経由したメソッドの呼び出しにおいて、値を更新するなどの処理がある場合、型 T のメソッドセットが *T のレシーバのメソッドを含んでしまうと、インターフェイスを経由するメソッドの呼び出し元とデータの整合性が取れなくなる、ということが起こりうるために1.a, 1.bのような仕様になっている

ということでした。

SODA Engineering Blog
SODA Engineering Blog

Discussion