Go言語を学び始めると、関数の引数設計において「値を直接渡すべきか、それともポインタを渡すべきか」という問題に必ず直面します。
JavaやC++、Pythonといった他のプログラミング言語から移行してきた開発者にとって、Goの挙動は一見すると「参照渡し」が存在するように見えるかもしれません。
しかし、厳密に言えばGo言語には「参照渡し」という概念は存在せず、すべてが「値渡し」で処理されます。
この事実はGoのメモリスキームを理解する上で非常に重要であり、パフォーマンスの最適化やバグの回避に直結します。
この記事では、Go言語における値渡しの本質と、ポインタを扱う際の注意点、そしてスライスやマップが「参照渡し」のように振る舞う裏側の仕組みについて詳しく紐解いていきます。
Go言語における「値渡し」の真実
Go言語の公式仕様において、関数への引数転送はすべてコピーによって行われます。
これが「値渡し(Pass by Value)」と呼ばれる仕組みです。
変数を関数に渡すと、その変数の内容がメモリ上の別の場所に複製され、関数内ではその複製された値が使用されます。
そのため、関数内で引数の値を書き換えたとしても、呼び出し元の元の変数には一切影響を与えません。
まずは、もっとも基本的な整数型の例でこの挙動を確認してみましょう。
package main
import "fmt"
func updateValue(n int) {
// 引数として渡された値を変更する
n = 100
fmt.Printf("関数内での値: %d\n", n)
}
func main() {
num := 10
updateValue(num)
// 呼び出し元の値は変わっていないことを確認
fmt.Printf("メイン関数での値: %d\n", num)
}
関数内での値: 100
メイン関数での値: 10
この実行結果からわかる通り、updateValue関数に渡されたのは num そのものではなく、num の中身である「10」という値のコピーです。
関数内で n を 100 に書き換えても、それはコピーされた別のメモリ領域を書き換えているだけに過ぎません。
このシンプルさがGo言語の安全性を支えていますが、大きな構造体を扱う場合などは「コピーのコスト」を意識する必要があります。
ポインタ渡しの正体:アドレスの値を渡すということ
「値を書き換えたい場合や、巨大なデータを効率よく渡したい場合はポインタを使う」という手法は一般的です。
これを「参照渡し」と呼ぶ人もいますが、Goの定義上はこれも「メモリアドレスという値のコピー」を渡しているに過ぎません。
ポインタ型の引数は、データの格納場所を示す「番地(アドレス)」を値として保持しています。
関数を呼び出す際、その「番地」がコピーされて関数に渡されます。
関数側では、コピーされた番地を辿って元のメモリ領域にアクセスするため、結果として呼び出し元の値を直接操作できるのです。
package main
import "fmt"
func updateByPointer(n *int) {
// ポインタが指す先の値を書き換える
*n = 100
fmt.Printf("関数内でのポインタのアドレス: %p\n", n)
}
func main() {
num := 10
fmt.Printf("元の値のアドレス: %p\n", &num)
updateByPointer(&num)
fmt.Printf("更新後の値: %d\n", num)
}
元の値のアドレス: 0xc0000120a8
関数内でのポインタのアドレス: 0xc0000120a8
更新後の値: 100
ここで重要なのは、&num というアドレス情報そのものがコピーされて関数に渡っている点です。
もし updateByPointer 内で n = nil のようにポインタ変数自体に別の代入を行っても、呼び出し元の num のアドレスが変わることはありません。
これが「ポインタの値渡し」の正体です。
スライスやマップが「参照」のように見える理由
Goを扱っていると、ポインタを使わずにスライス(Slice)やマップ(Map)を関数に渡した際、関数内での変更が呼び出し元に反映される現象に驚くことがあります。
これこそが「Goには参照渡しがある」と誤解される最大の要因です。
しかし、ここでも行われているのは厳密な「値渡し」です。
スライスやマップは内部的に「ディスクリプタ(記述子)」と呼ばれる構造体のようなデータ構造を持っています。
スライスの内部構造
スライスの実体は、以下の3つのフィールドを持つ小さな構造体です。
- バッキング配列(背後にある配列)へのポインタ
- スライスの長さ(len)
- スライスの容量(cap)
スライスを関数に渡すと、この「3つのフィールドを持つ構造体」がコピーされます。
コピーされた構造体の中にある「ポインタ」は、元のスライスと同じバッキング配列を指しています。
そのため、関数内で要素を書き換えると、同じ配列を見ている元のスライスからも変更が見えるのです。
package main
import "fmt"
func modifySlice(s []int) {
// インデックスを指定して要素を書き換える
s[0] = 999
}
func main() {
nums := []int{1, 2, 3}
modifySlice(nums)
fmt.Println(nums) // [999 2 3] と表示される
}
ただし、ここで注意が必要なのは append を行った場合です。
もし関数内で append を行い、スライスの「長さ(len)」が変更されたとしても、それは関数内のコピーされたディスクリプタが書き換わるだけです。
呼び出し元のディスクリプタの len は古いままなので、要素の追加を反映させるには戻り値としてスライスを返すか、スライスのポインタを渡す必要があります。
値渡しとポインタ渡しの使い分け基準
開発を進める上で、どちらを選択すべきか迷う場面は多いでしょう。
基本的には以下の基準で判断することをお勧めします。
| 選択肢 | 主な用途・メリット | デメリット |
|---|---|---|
| 値渡し | 不変性の確保、小規模な構造体(数フィールド)、安全な並行処理 | 巨大なデータのコピーによるメモリ・CPU負荷 |
| ポインタ渡し | 関数内での状態変更、巨大な構造体の転送効率向上 | nilチェックの必要性、エスケープ解析によるヒープ割り当ての増加 |
具体的に、どのようなケースでどちらを選ぶべきか深掘りしていきましょう。
1. 構造体のサイズによる判断
数個のフィールドしか持たない小さな構造体であれば、値渡しの方が効率的な場合があります。
なぜなら、ポインタを渡すとそのデータが「ヒープメモリ」に割り当てられる可能性が高まるからです(エスケープ解析)。
スタックメモリは非常に高速でクリーンアップも自動的ですが、ヒープメモリはガベージコレクション(GC)の対象となるため、過度なポインタの使用は逆にパフォーマンスを低下させることがあります。
目安として、構造体のサイズが 64バイト を超えるあたりから、ポインタ渡しの検討を始めると良いでしょう。
2. 変更の有無(ミュータビリティ)
関数内で受け取ったデータを加工し、その結果を呼び出し元でも保持したい場合は、迷わずポインタを使用します。
Goのメソッド定義において「レシーバ」を値にするかポインタにするかも、この基準に従います。
オブジェクトの状態を更新するメソッド(Setterなど)は、必ずポインタレシーバとして定義する必要があります。
type User struct {
Name string
}
// 値レシーバ(コピーが渡されるため、元のUserは変わらない)
func (u User) SetNameValue(name string) {
u.Name = name
}
// ポインタレシーバ(元のUserが書き換わる)
func (u *User) SetNamePointer(name string) {
u.Name = name
}
パフォーマンスと「エスケープ解析」の関係
「ポインタを使えばコピーが発生しないから常に速い」と考えるのは危険です。
Goのコンパイラは「エスケープ解析」を行い、変数をスタックに置くかヒープに置くかを決定します。
関数内で作成した変数のポインタを戻り値として返したり、ポインタを多用したりすると、データはヒープ領域へ「エスケープ」します。
ヒープへの割り当てはスタックよりも重い処理であり、さらにその後のGCによるスキャンコストが発生します。
「値のコピーコスト」と「GCのコスト」のトレードオフを意識することが、現代的なGoプログラミングには求められます。
ベンチマークテストを行い、実際にどちらが高速かを測定することがもっとも確実な方法です。
まとめ
Go言語において、すべての引数転送は「値渡し」であるという原則を理解することは、言語の習熟度を一段階引き上げます。
たとえポインタを渡している場合でも、それはアドレスという値をコピーして渡しているのだと認識してください。
スライスやマップ、チャンネルといった型が参照のように振る舞うのは、それらが内部にポインタを保持する構造体だからです。
日常的な開発においては、以下の3点を意識すると良いでしょう。
- 基本は「値渡し」で安全性を高め、副作用を限定する。
- 状態を更新する必要がある場合や、巨大な構造体(数百バイト以上)を扱う場合は「ポインタ渡し」を選択する。
- パフォーマンスが極めて重要な箇所では、エスケープ解析を意識し、安易なポインタ化がGCの負担になっていないか確認する。
このシンプルかつ強力なメモリ管理の仕組みこそが、Go言語が高速で信頼性の高いシステム開発に適している理由の一つです。
値渡しとポインタ渡しの特性を正しく理解し、適切に使い分けることで、より洗練されたGoコードを記述していきましょう。
