CodeGym /コース /Go SELF /Value vs pointer receiver — 選択基準

Value vs pointer receiver — 選択基準

Go SELF
レベル 22, レッスン 1
使用可能

1. そもそも receiver を選ぶ理由

次のような宣言を初めて見たとき、 func (t Task) Done() bool と func (t *Task) MarkDone()、 つい「えっと……アスタリスクがあるかないかの違いでしょ」と思いがちです。残念ながら違いはあります。しかも実用的です。 receiver は 2 つの問いに答えます。メソッドがコピーを扱うのか、それとも元の値を扱うのか。そしてそのメソッドがオブジェクトの状態を変更できるのか、です。 さらに、呼び出しのしやすさや性能にも関わるので、「とりあえず全部に * を付ける」だけでは最適とは言えません。

実践から切り離して議論しないために、学習用の小さなドメインとして「タスクトラッカー」を続けます。ここではタイトル、完了フラグ、タグの一覧を持つタスク (Task) を扱います。

最も重要なルール: Go ではすべて値渡し

このフレーズは、身につくまでは禅問答のように聞こえます。Go では関数やメソッドを呼ぶとき、引数はコピーされます。 receiver が Task なら、Task 全体がコピーされます。 receiver が *Task なら、コピーされるのは……ポインタ、つまり小さなアドレス値です。 でもそのアドレスは同じオブジェクトを指すので、その経由で元のフィールドを変更できます。

簡単なモデルを作ってみましょう:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) MarkDoneCopy() {
	t.Done = true // change the copy
}

func (t *Task) MarkDone() {
	t.Done = true // change the original via pointer
}

func main() {
	task := Task{Title: "Read Go spec", Done: false}

	task.MarkDoneCopy()
	fmt.Println(task.Done) // false

	task.MarkDone()
	fmt.Println(task.Done) // true
}

MarkDoneCopy は「仕事をしているように見える」のですが、結果は消えてしまいます。 変更が receiver のコピーの中で起きているからです。

2. receiver 選択の基準

Value receiver (T): メソッドはコピーを受け取る

Value receiver は、メソッドがデータを「読むだけ」で何も変えないときに向いています。これは書類のコピーを渡された状況に似ています。 見たり、マーカーで線を引いたり、余白に猫を描いたりはできますが、上司のファイルにある原本は傷つきません (上司にとっては幸いで、あなたにとってはそうでないかもしれません)。

Task の「読むだけ」のメソッドの例です:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) IsDone() bool {
	return t.Done
}

func main() {
	task := Task{Title: "Buy milk", Done: true}
	fmt.Println(task.IsDone()) // true
}

ここでは value receiver が最適です。メソッドはシンプルで、状態を変更せず、小さな構造体のコピーなどまったく問題になりません。

Pointer receiver (*T): アドレスを受け取り、元の値を変更できる

Pointer receiver は、メソッドが 状態を変更しなければならない ときに必要です。これが選択基準の中心で、ほとんどの場合はこれだけで十分です。 意味として「オブジェクトを変更する」メソッドなのに receiver が value だと、「変えたふり」になってしまいます。

タスクに戻って、ちゃんとした完了メソッドを作りましょう:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func main() {
	task := Task{Title: "Write tests", Done: false}
	task.MarkDone()
	fmt.Println(task.Done) // true
}

API 設計の観点では自然に読めます。task.MarkDone() は本当にタスクを完了させるのであって、「そう見えるだけ」ではありません。

基準 1: メソッドが状態を変える → ほぼ常に *T

ここで、初心者がよくやる便利な「実例」を挙げます。たとえばタスク名変更メソッドを追加したのに、うっかり value receiver で書いてしまった場合です。

package main

import "fmt"

type Task struct {
	Title string
}

func (t Task) RenameBad(newTitle string) {
	t.Title = newTitle
}

func main() {
	task := Task{Title: "Old"}
	task.RenameBad("New")
	fmt.Println(task.Title) // Old
}

感覚としては「保存を押したのに、保存されていない」に近いです。正しい版はこちらです:

package main

import "fmt"

type Task struct {
	Title string
}

func (t *Task) Rename(newTitle string) {
	t.Title = newTitle
}

func main() {
	task := Task{Title: "Old"}
	task.Rename("New")
	fmt.Println(task.Title) // New
}

要するに、すべての「setter」「mark」「add」「clear」「フラグ切り替え」は、ほぼ常に *T です。

基準 2: 「重い」構造体はコピーしないほうがよい → しばしば *T

メソッド自体は状態を変えなくても、構造体が大きい場合があります。そのとき value receiver は技術的には正しいものの、割に合わないことがあります。 メソッド呼び出しのたびにオブジェクト全体をコピーするからです。すぐに燃え上がるわけではありませんが、パンを買いにトラックで行くようなものです。 できなくはないですが、妙です。

「重いオブジェクト」を模擬してみましょう(説明用です):

package main

import "fmt"

type Big struct {
	Data [1024]byte
	Name string
}

func (b Big) NameLen() int {
	return len(b.Name)
}

func main() {
	x := Big{Name: "abc"}
	fmt.Println(x.NameLen()) // 3
}

Big は NameLen() のたびに丸ごとコピーされます。 実際の「重さ」は [1024]byte のせいだけではなく、 多数のフィールド、入れ子構造などによって生じます。その場合、*T を 「読むだけ」のメソッドに対しても選ぶことがよくあります。余計なコピーを避けるためです。

ただし極端に走ってはいけません。「これからはすべてのメソッドを常に *T にする」 ということです。value receiver のほうが API をシンプルかつ安全にできることもあります。だから基準は併用し、宗教のように 1 つだけを選ばないでください。

基準 3: API の一貫性は局所的な小細工より重要

ある型で半分のメソッドが T、残りが *T だと、 初心者は「このメソッドは呼べたのに、あのメソッドは呼べない」「これは変わったのに、あれは変わっていない」といった驚きの世界に住むことになります。 そのため Go では、明確な理由がない限り、各型について receiver スタイルをそろえるのが実践的なルールです。

よくある折衷案はこうです。状態を変える メソッドはすべて *T、 読むだけ のメソッドは T、ただし構造体が小さくてコピーを避ける必要がない場合に限ります。 構造体が「本格的」なら、多くのチームは読むだけのメソッドも含め、ほぼすべてを *T にします。統一感と性能のためです。

別の観点もあります(後で丁寧に触れます): receiver の選択は interface との互換性に影響します。というのも、pointer receiver のメソッドは 型 T の値からは常に「見える」わけではないからです。これは実コードですぐに表面化します。たとえば「型を契約に差し込もう」として コンパイラエラーが出るような場面です。

基準 4: ときには *T がフィールドのスライスと append のために必要

ここは少し繊細なポイントです。スライスは「ヘッダ」 (len/cap/pointer to array) だと考えると分かりやすいです。 ヘッダ自体は簡単にコピーされますが、そのために value receiver ではスライス長の変更を「失う」ことがあります。

タスクにタグを追加してみましょう:

package main

import "fmt"

type Task struct {
	Title string
	Tags  []string
}

func (t Task) AddTagBad(tag string) {
	t.Tags = append(t.Tags, tag) // change the copy of the slice header
}

func main() {
	task := Task{Title: "Learn Go", Tags: []string{}}
	task.AddTagBad("study")
	fmt.Println(task.Tags) // []
}

タグは追加されていません。t はコピーであり、t.Tags の新しい値はメソッドの中に残ったままだからです。 mutating メソッドは *Task で書くべきです:

package main

import "fmt"

type Task struct {
	Title string
	Tags  []string
}

func (t *Task) AddTag(tag string) {
	t.Tags = append(t.Tags, tag)
}

func main() {
	task := Task{Title: "Learn Go", Tags: []string{}}
	task.AddTag("study")
	fmt.Println(task.Tags) // [study]
}

この例はよく「悟りの瞬間」になります。append が変えるのは要素そのものではなく、潜在的にはヘッダ全体 (場合によっては新しい配列へ移動もする) だからです。pointer receiver がないと、仕事を「コピーの中で」やってしまいがちです。

Addressability: なぜ NewTask().MarkDone() はときどきコンパイルされないのか

もう 1 つ、コンパイラの気まぐれに見えるものがありますが、実際には奇妙な状況からあなたを守っています。 Pointer receiver には receiver が addressable であること、つまり「実際のアドレスを持つ値」であることが必要です。

例を見てください:

package main

type Task struct {
	Done bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func NewTask() Task {
	return Task{Done: false}
}

func main() {
	// NewTask().MarkDone() // does not compile: function result is not addressable
	task := NewTask()
	task.MarkDone()
}

なぜでしょうか。NewTask() は一時的な値を返すからです。コンパイラは、 (そして通常はしたがりませんが) あなたのために「隠れ変数」を作ってそのアドレスを取り、変更してすぐ捨てる、という魔法をする義務はありません。 対処法は単純です。例のように値を変数に保存してください。

「constructor」を pointer メソッドと相性よくしたいなら、次のようにすることがよくあります。 NewTask が *Task を返すようにします。

package main

import "fmt"

type Task struct {
	Done bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func NewTask() *Task {
	return &Task{Done: false}
}

func main() {
	task := NewTask()
	task.MarkDone()
	fmt.Println(task.Done) // true
}

たしかに、型の使い方は少し変わります。これ以降「task」はポインタとして生きることが多くなります。 でも mutating メソッドが多い型なら、それは自然なことです。

4. チートシート: receiver 選択の表とブロック図

メソッドを書くときは、次の簡単な頭の中の表を通してみてください。Go の「法」ではありませんが、習慣としてはとても役立ちます。

メソッドに関する質問 「はい」の場合 「いいえ」の場合
メソッドはフィールドを変更する必要がある?
*T
次へ進む
メソッドはフィールド内のスライスに append する / map やスライスのフィールド全体を変更する?
*T
次へ進む
型が大きくて、コピーがもったいない? 多くの場合
*T
T
でもよい
API のスタイルを統一して予測しやすくしたい? 多くの場合、すべてのメソッドを
*T
にする
混ぜてもよいが、慎重に

さらに視覚的にしたいなら、小さなブロック図があります:

flowchart TD
    A[メソッドを書く] --> B{状態を変える?}
    B -- yes --> P[*T]
    B -- no --> C{中で append / フィールドのスライス全体を変更?}
    C -- yes --> P
    C -- no --> D{型が大きくてコピーがもったいない?}
    D -- yes --> P
    D -- no --> V[T]

この図は常識の代わりにはなりませんが、「ネットでそう言っていたから * を付けた」という状況からは救ってくれます。

なぜ標準ライブラリでもよく *T が選ばれるのか

pointer receiver は初心者の「裏技」みたいに見えることがありますし、「本当の Go 使いはきれいに書く」と思いたくなるかもしれません。 でも実際には pointer receiver はごく普通のもので、標準ライブラリでもよく使われます。特にメソッドがオブジェクトに論理的に結びついていて、 コピーしたくない場合や状態を変えたい場合です。

たとえば、型付きエラーは Error() メソッドを エラー構造体へのポインタに実装することがとても多いです。つまり func (e *NotFoundError) Error() string { ... } のようになります。 エラー設計自体はここでは詳しく扱いませんが、この事実は役に立ちます。 *T は汚い手口ではなく、普通の道具です。

5. よくあるミス

ミス 1: mutating メソッドを value receiver で書いてしまい、変更が「保存されない」。
最もよくあるバグです。メソッドの意味としてはオブジェクトを変えるべきなのに (Rename, MarkDone, AddTag)、receiver が T になっています。 コードはコンパイルされ、メソッドは呼ばれますが、状態はそのままです。対策は簡単です。メソッドがオブジェクトを変更すると約束しているなら、 *T にして、理由もなく return で「保存」しようとしないことです。

ミス 2: 「安全だから全部 *T にする」。
一見もっともらしいです。「ポインタのほうが強いのだから」と。でも別の問題を生みます。あちこちでポインタを運ぶことになり、 nil に出会うことが増え、値として扱えたはずのシンプルな型まで複雑になります。 ルールとしては、*T は mutating が必要なとき、またはコピー削減が必要なときに使い、「デフォルトだから」ではありません。

ミス 3: value receiver でフィールドのスライスに append して予想外の挙動になる。
このバグは特に厄介です。「タグを追加したじゃないか!」に見えるからです。しかし実際にはスライスヘッダのコピーにタグを追加しただけです。 その結果、外からは変更が見えません。メソッドが構造体フィールド内のスライスの長さや容量を変えるなら、ほぼ常に *T が必要です。

ミス 4: 一時的な値(アドレスを取れない値)に pointer メソッドを呼ぼうとする。
定番です。NewTask().MarkDone() に対してコンパイラが文句を言う、というやつです。 これは Go の意地悪ではなく、暗黙の魔法や奇妙な副作用を防ぐための保護です。正しい習慣は、 まず結果を変数に保存すること、または型に mutating メソッドが多いなら「constructor」が *T を返すように設計することです。

ミス 5: 分かりやすいルールのない、無秩序な receiver の混在。
一部のメソッドが T、別の一部が *T だと、数週間後には自分でもどこがどうなっていたか忘れます。 他人がコードを読むならなおさらです。どのメソッドが状態を変え、どれが変えないのかを推測させることになります。 型ごとに単純なルールを選んで、それに従うほうがよいです。「例外」は本当に必要な場面だけにしましょう。

1
タスク
Go SELF, レベル 22, レッスン 1
ロック未解除
支払い確認
支払い確認
1
タスク
Go SELF, レベル 22, レッスン 1
ロック未解除
誕生日
誕生日
1
タスク
Go SELF, レベル 22, レッスン 1
ロック未解除
買い物リスト
買い物リスト
1
タスク
Go SELF, レベル 22, レッスン 1
ロック未解除
一行のメモ
一行のメモ
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION