LoginSignup
8
2

More than 1 year has passed since last update.

【Go】値レシーバとポインタレシーバの違いと使い分けについて考える

Last updated at Posted at 2022-12-01

この記事は「Go Advent Calendar 2022」2日目の記事です。

はじめに

Goでは「型」に対してメソッドを定義します。
こんな風に...

main.go
type MyInt int

func (m MyInt) calculate() int {
	return 1 + 1
}

func main() {
	var m MyInt

	fmt.Println(m.calculate()) // 2
}

Playground

ここではMyIntというint型の「型」に対してcalculate()というメソッドを定義しています。

型であれば何でも(intだろうと構造体だろうと)メソッドを定義できます。

「レシーバ」とはこのメソッドの引数mのことを指します。

main.go
func (m MyInt)

ここで値をそのまま引数に渡している場合(上と同じ)を「値レシーバ」

値レシーバ
func (m MyInt)

ポインタ型を渡している場合を「ポインタレシーバ」と呼びます。

ポインタレシーバ
func (m *MyInt)

このレシーバの使い分けをどうするかで悩む場面があるんじゃないでしょうか。

結論

  • 基本的にはポインタレシーバを使う(たとえば型が巨大な構造体の場合、変数がコピーされることがないので効率的)
  • 値を変更する必要がある場合はやはりポインタレシーバを使う
  • スライスやmapの長さを変更する必要がある場合もやはりポインタレシーバを使う
  • 値型等の単純な基本形の場合は値レシーバを使う
  • 一つの型で値レシーバとポインタレシーバを混在させない(統一性のため)

なおすべてはここ(↓)にあります...

環境

go version
> go version go1.19.2 darwin/amd64

値レシーバとポインタレシーバの挙動の違い

結論は出てますが具体的なところを。

まず値レシーバとポインタレシーバの挙動の違いについて、

  • 値が変更されるかどうか
  • 変数をコピーするかどうか

という2つの面から見ていきます。

値が変更されるかどうか

Coffee構造体のbrewメソッドで、指定したカフェイン量のコーヒーを淹れる状況を考えます。

値レシーバ

main.go
type Coffee struct {
	Caffeine int // カフェイン含有量
}

func (c Coffee) brew(caffeine int) {
	c.Caffeine = caffeine
}

func main() {
	c := Coffee{
		Caffeine: 60,
	}

    // コーヒーを淹れる
	c.brew(0)

	if c.Caffeine > 0 {
		fmt.Printf("このコーヒーには100mgあたり%dmgカフェインが含まれています。", c.Caffeine)
	} else {
		fmt.Println("このコーヒーはノンカフェインです。")
	}
}

go run .
>  このコーヒーには100mgあたり60mgカフェインが含まれています。

PlayGround

以下でカフェインに0を指定していますが、

main.go
// コーヒーを淹れる
c.brew(0)

結果として表示されているのは、Coffeeをインスタンス化したときに初期値として与えられた60です。

このコーヒーには100mgあたり60mgカフェインが含まれています

ポインタレシーバ

一方ポインタレシーバの場合は値が変更されます。

main.go
type Coffee struct {
	Caffeine int // カフェイン含有量
}

- func (c Coffee) brew(caffeine int) {
+ func (c *Coffee) brew(caffeine int) {
	c.Caffeine = caffeine
}

func main() {
	c := Coffee{
		Caffeine: 60,
	}

    // コーヒーを淹れる
	c.brew(0)

	if c.Caffeine > 0 {
		fmt.Printf("このコーヒーには100mgあたり%dmgカフェインが含まれています。", c.Caffeine)
	} else {
		fmt.Println("このコーヒーはノンカフェインです。")
	}
}
go run .
>  このコーヒーはノンカフェインです。

Playground

つまりこの挙動の違いは「変数そのもの(同じメモリアドレスの参照先)を変更するかどうか」と、「変数のコピー(新しく割り当てられたメモリアドレスの参照先)を変更するかどうか」の違い(以下で詳しく見ます)であり、2つ目の「変数をコピーするかどうか」につながります。

変数をコピーするかどうか

値レシーバはメソッドの呼び出し毎に変数をコピーします。
つまり、コピーした変数に対していくら変更を加えようと呼び出し元の変数には影響がないということです。

main.go
func (c Coffee) brew(caffeine int) {
    // 呼び出し元の変数とは別物
	c.Caffeine = caffeine
}

アドレスの行方を追う(変数をコピーするとは)

単純化のため前後のコードを削ってます。
それぞれの段階のメモリのアドレスが実際にどうなってるのか確認すると、

main.go
func (c Coffee) brew(caffeine int) {
	fmt.Println(&c.Caffeine, "メソッドの中のアドレス")
	c.Caffeine = caffeine
}

func main() {
	c := Coffee{
		Caffeine: 60,
	}

	fmt.Println(&c.Caffeine, "メソッドの前のアドレス")
	c.brew(0)
	fmt.Println(&c.Caffeine, "メソッドの後のアドレス")
}

結果はこうです。

go run .
> 0xc00018c008 メソッドの前のアドレス
> 0xc00018c020 メソッドの中のアドレス ← ここだけ違う
> 0xc00018c008 メソッドの後のアドレス

Playground

という感じでメソッドの中のメモリアドレスだけ他とは違っています。
つまり新しくメモリが割り当てられた(コピーされた) ということになります。

ポインタレシーバの場合はすべて同じアドレスを見ています。

main.go
- func (c Coffee) brew(caffeine int) {
+ func (c *Coffee) brew(caffeine int) {
	fmt.Println(&c.Caffeine, "メソッドの中のアドレス")
	c.Caffeine = caffeine
}

func main() {
	c := Coffee{
		Caffeine: 60,
	}

	fmt.Println(&c.Caffeine, "メソッドの前のアドレス")
	c.brew(0)
	fmt.Println(&c.Caffeine, "メソッドの後のアドレス")
}

↓全部同じアドレス

go run .
> 0xc00018c008 メソッドの前のアドレス
> 0xc00018c008 メソッドの中のアドレス
> 0xc00018c008 メソッドの後のアドレス

Playground

なのでポインタレシーバの場合は値が変わる(参照している先が同じなため)ということですね。

また、この「メソッドの呼び出しごとに変数をコピーする」ということは、たとえばその変数が大きな容量の構造体等であった場合、それだけメモリを割り当てることになるので非効率、といえます。

スライスは値と参照両方の性質を併せ持つ

若干本筋とはずれるかもしれませんが、
「スライスやmapは参照の性質を持つので値レシーバでも問題ないかも、と思いきやそうとも言えない場合がある」
という点に触れて締めたいと思います(mapについては割愛)。

「スライスは値と参照両方の性質を併せ持つ」

というのはつまりこういうことです。

main.go
type MyInt []int

func (m MyInt) change() {
	m[0] = 2
}

func main() {
	var m MyInt
	m = append(m, 1)
	m.change()

	fmt.Println(m) // 2
}

ここでは値レシーバにもかかわらず値が変更(m[0]が1から2に)されています。
つまり、スライス(の中身)は参照の性質(同じメモリアドレスの値を参照している)を持っています

一方スライスに新しい要素を加えてみる場合では

main.go
func (m MyInt) change() {
-   m[0] = 2 
+	m = append(m, 2)
}

func main() {
	var m MyInt
	m = append(m, 1)
	m.change()

	fmt.Println(m) // [1]
}

Playgroud

[1 2]とはならず[1]のみで長さが変わっていません。
つまりスライスは値の性質(新しくメモリが割り当てられている)を持っています
なので、

スライスやmapの長さを変更する必要がある場合もやはりポインタレシーバを使う

必要がある、ということですね。

main.go
- func (m MyInt) change() {
-    m = append(m, 2)
+ func (m *MyInt) change() {
+    *m = append(*m, 2)
}

func main() {
	var m MyInt
	m = append(m, 1)
	m.change()

	fmt.Println(m) // [1 2]
}

Playgroud

Slices and maps act as references, so their story is a little more subtle, but for instance to change the length of a slice in a method the receiver must still be a pointer.
(訳)スライスとmapは参照として動作するので、その話はもう少し微妙ですが、たとえばメソッドでスライスの長さを変更する場合、レシーバはやはりポインタでなければなりません。

引用:Frequently Asked Questions (FAQ)

まとめ

再掲

  • 基本的にはポインタレシーバを使う(たとえば型が巨大な構造体の場合、変数がコピーされることがないので効率的)
  • 値を変更する必要がある場合はやはりポインタレシーバを使う
  • スライスやmapの長さを変更する必要がある場合もやはりポインタレシーバを使う
  • 値型等の単純な基本形の場合は値レシーバを使う
  • 一つの型で値レシーバとポインタレシーバを混在させない(統一性のため)

闇雲にどちらかのレシーバを使うのではなく状況に応じて使い分けられるようにするといいですね。

最後に

GoQSystemでは一緒に働いてくれる仲間を募集中です!

ご興味がある方は以下リンクよりご確認ください。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2