Go のポインタレシーバとバリューレシーバ - メソッド定義の使い分け

Go のメソッドにはレシーバという仕組みがあり、構造体に振る舞いを持たせることができます。レシーバには「バリューレシーバ」と「ポインタレシーバ」の 2 種類があり、どちらを選ぶかによってメソッド内の変更が呼び出し元に反映されるかどうかが変わります。

バリューレシーバ

バリューレシーバは、レシーバを値として受け取ります。メソッドが呼ばれるたびに構造体のコピーが作られるため、メソッド内でフィールドを変更しても元の構造体には影響がありません。

package main

import "fmt"

type Counter struct {
    Count int
}

func (c Counter) Increment() {
    c.Count++
    fmt.Println("メソッド内:", c.Count)
}

func main() {
    ctr := Counter{Count: 0}
    ctr.Increment()
    fmt.Println("呼び出し元:", ctr.Count)
}

出力は「メソッド内: 1」「呼び出し元: 0」になります。Increment の中で操作しているのは ctr のコピーであり、元の ctr は変わりません。

ポインタレシーバ

ポインタレシーバは、レシーバを *T 型として受け取ります。メソッド内でフィールドを変更すると、呼び出し元の構造体にも反映されます。

package main

import "fmt"

type Counter struct {
    Count int
}

func (c *Counter) Increment() {
    c.Count++
}

func main() {
    ctr := Counter{Count: 0}
    ctr.Increment()
    fmt.Println(ctr.Count) // 1
}

レシーバの型が *Counter になっただけで、呼び出し元の ctr が直接変更されるようになりました。

バリューレシーバ (c Counter)

構造体のコピーを受け取る。メソッド内の変更は呼び出し元に反映されない。

ポインタレシーバ (c *Counter)

構造体のアドレスを受け取る。メソッド内の変更が呼び出し元に反映される。

自動アドレス取得と自動デリファレンス

Go のコンパイラは、レシーバの型と実際の値の型が一致しない場合に自動変換を行います。

ctr := Counter{Count: 0}
ctr.Increment() // ctr は値だが、ポインタレシーバのメソッドを呼べる

ctrCounter 型(値)ですが、ポインタレシーバの Increment を呼び出せています。コンパイラが内部的に (&ctr).Increment() へ変換しているためです。

逆方向も同様に動作します。

p := &Counter{Count: 0}
p.Increment() // p は *Counter なのでそのまま呼べる
func (c Counter) Value() int {
    return c.Count
}

p := &Counter{Count: 5}
fmt.Println(p.Value()) // ポインタからバリューレシーバのメソッドも呼べる

ポインタからバリューレシーバのメソッドを呼ぶ場合は、コンパイラが (*p).Value() に変換します。この自動変換のおかげで、呼び出し側はレシーバの種類をあまり意識せずにメソッドを使えます。

どちらを選ぶべきか

メソッドがフィールドを変更するなら、ポインタレシーバ一択です。迷うのは「読み取り専用のメソッド」の場合ですが、実務的には以下の基準で判断します。

ポインタレシーバを選ぶ場面

フィールドを変更する場合。構造体が大きくコピーコストを避けたい場合。同じ型の他のメソッドがポインタレシーバなら、一貫性のためにすべてポインタレシーバに揃える場合。

バリューレシーバを選ぶ場面

構造体が小さく、変更の必要がない場合。int や string のような基本型のラッパーで、イミュータブルな振る舞いを明示したい場合。

レシーバの一貫性

Go の慣習として、ひとつの型に対してバリューレシーバとポインタレシーバを混在させることは推奨されていません。

// 非推奨: レシーバが混在している
func (c Counter) Value() int    { return c.Count }
func (c *Counter) Increment()   { c.Count++ }
func (c Counter) String() string { return fmt.Sprintf("%d", c.Count) }

このコードは動作しますが、ValueString がバリューレシーバ、Increment がポインタレシーバという混在状態になっています。型のメソッドセットに一貫性がなくなると、インターフェースの実装時に予期しない挙動を引き起こすことがあります。

ひとつでもポインタレシーバが必要なメソッドがあるなら、その型のメソッドはすべてポインタレシーバに統一するのが一般的です。

func (c *Counter) Value() int     { return c.Count }
func (c *Counter) Increment()     { c.Count++ }
func (c *Counter) String() string { return fmt.Sprintf("%d", c.Count) }

インターフェースとレシーバの関係

レシーバの種類はインターフェースの充足判定にも影響します。

type Stringer interface {
    String() string
}

type Name struct {
    First, Last string
}

func (n *Name) String() string {
    return n.First + " " + n.Last
}

String がポインタレシーバで定義されている場合、*NameStringer を満たしますが、Name(値)は満たしません。

var s Stringer

s = &Name{First: "Alice", Last: "Smith"} // OK
// s = Name{First: "Alice", Last: "Smith"} // コンパイルエラー

バリューレシーバで定義していれば、値でもポインタでもインターフェースを満たします。ポインタレシーバの場合はポインタでしか満たせないという非対称性があり、これがレシーバの一貫性を保つもうひとつの理由です。

レシーバ値で充足ポインタで充足
バリューレシーバ
ポインタレシーバ不可

メソッドが増えてくると、どのレシーバで定義したかを逐一確認するのは手間がかかります。最初からポインタレシーバで統一しておけば、インターフェースの充足で悩むことも少なくなります。