ポインタとメモリ管理
.NET (C#) の経験がある開発者向けに、ポインタ、値渡し、エスケープ解析、GCの基本を解説します。
1. ポインタの基本
Go のポインタは C/C++ のものと似ていますが、ポインタ演算はできません。C# の unsafe ポインタではなく、参照に近い安全なものとして使えます。
x := 10
p := &x // x のアドレスを取得 (型は *int)
fmt.Println(*p) // 10: デリファレンス
*p = 20
fmt.Println(x) // 20
| 構文 | 意味 |
|---|---|
&x | x のアドレスを取る |
*p | ポインタ p が指す値を読み書きする |
*T | T へのポインタ型 |
new(T) | T のゼロ値を確保してポインタを返す |
構造体のポインタ
構造体のポインタでは、(*p).Field と書かずに p.Field で自動的にデリファレンスされます。
type User struct{ Name string }
u := &User{Name: "alice"}
u.Name = "bob" // (*u).Name と同じ
nil
ポインタ、スライス、マップ、チャネル、関数、インターフェースのゼロ値は nil です。nil ポインタをデリファレンスすると panic になります (C# の NullReferenceException 相当)。
var u *User
fmt.Println(u.Name) // panic: runtime error: invalid memory address or nil pointer dereference
nil になりうるポインタは、使う前に if u == nil で確認します。
2. 値渡しの原則
Go はすべて値渡しです。関数に引数を渡すとき、常にコピーが作られます。C# のように class (参照型) か struct (値型) かで挙動が変わることはありません。
type Point struct{ X, Y int }
func moveByValue(p Point) { p.X++ } // コピーを変更するだけ
func moveByPointer(p *Point) { p.X++ } // 元の値を変更する
p := Point{1, 2}
moveByValue(p)
fmt.Println(p.X) // 1
moveByPointer(&p)
fmt.Println(p.X) // 2
「参照のように見える」型
スライス、マップ、チャネル、関数、インターフェースは、内部にポインタを持つ小さなヘッダです。コピーしてもヘッダのコピーなので、指す先のデータは共有されます。
func appendOne(s []int) { s[0] = 99 } // 要素の変更は呼び出し元にも見える
func replace(s []int) { s = []int{1, 2} } // 変数への再代入は見えない
m := map[string]int{}
func set(m map[string]int) { m["a"] = 1 } // マップへの書き込みは見える
詳細は スライス・マップ・配列 を参照してください。
ポインタを使うべき場面
- 状態を変更する必要がある (メソッドのポインタレシーバなど)
- 大きな構造体のコピーを避けたい
sync.Mutexなどのコピーしてはいけない型を含む- 「値がない」を表したい (nil。ただし多用は避ける)
逆に、小さな構造体や基本型は値渡しで十分です。ポインタにすると GC の負荷が増え、かえって遅くなることがあります。
3. スタックとヒープ・エスケープ解析
C# では、class はヒープ、struct は (多くの場合) スタックに置かれるなど、型の種類で配置が決まります。Go では型ではなく、コンパイラのエスケープ解析が配置を決めます。
- 関数の外から参照されない値 → スタック (高速、GC 不要)
- 関数の外へ「逃げる」値 → ヒープ (GC の対象)
func onStack() int {
x := 42
return x // 値のコピーを返すのでスタックのまま
}
func onHeap() *int {
x := 42
return &x // ポインタが関数外へ逃げるのでヒープに確保される
}
Go では、ローカル変数のアドレスを返しても安全です (C/C++ のような dangling pointer にはなりません)。
エスケープ解析の確認
go build -gcflags="-m" ./...
./main.go:10:2: moved to heap: x
./main.go:15:13: ... argument does not escape
主にヒープへ逃げる原因:
- ポインタを関数の戻り値にする
- インターフェース型 (
anyなど) に代入する - クロージャが変数をキャプチャして外へ渡す
- サイズがコンパイル時に決まらないスライス / 大きすぎるオブジェクト
- チャネルへポインタを送る
ヒープ割り当てを最小にすることが目的ではありません。まず正しく書き、ベンチマークとプロファイル (パフォーマンスとプロファイリング) で問題があるときだけ最適化します。
4. GC の基本と特性
Go の GC は並行 (concurrent)・三色マーク&スイープ方式で、停止時間 (STW) を非常に短く抑えることを重視しています。
| 項目 | Go | .NET |
|---|---|---|
| 世代別 | なし (非世代別) | あり (Gen 0/1/2) |
| コンパクション | なし (移動しない) | あり |
| 設計の重点 | 低レイテンシ | スループットと柔軟なチューニング |
| チューニング | GOGC / GOMEMLIMIT | GC モード、GCSettings など |
GOGC
GOGC は、前回の GC 後のライブヒープに対し、何 % 増えたら次の GC を始めるかを表します。デフォルトは 100 です。
GOGC=200 ./app # GC の頻度を下げる (メモリ使用量は増える)
GOGC=50 ./app # GC の頻度を上げる (メモリ使用量は減る)
GOGC=off ./app # GC を無効化
GOMEMLIMIT (Go 1.19+)
メモリ使用量のソフトリミットを設定します。コンテナのメモリ上限があるときに有用です。上限に近づくと GC が積極的に動きます。
GOMEMLIMIT=450MiB ./app # コンテナ上限が 512MiB のときの目安
実務での指針
- ほとんどのアプリはデフォルトのままで問題ありません
- メモリ上限のあるコンテナでは
GOMEMLIMITを設定すると OOM を避けやすい - GC 負荷が高い場合は、割り当て数を減らすのが最も効果的 (
sync.Pool、スライスの事前確保make([]T, 0, n)、不要なポインタ・文字列変換の削減) - GC の状況は
GODEBUG=gctrace=1やruntime/metrics、pprofで確認する
5. .NET との対応表
| .NET (C#) | Go |
|---|---|
| 参照型 (class) / 値型 (struct) | すべて値渡し。参照が必要ならポインタ |
ref / in / out | *T / &x |
Span<T> / stackalloc | スライス / エスケープ解析による自動配置 |
null / NullReferenceException | nil / nil ポインタ参照の panic |
Nullable<T> (int?) | *int またはゼロ値 + ok を使う |
new Foo() | &Foo{} / new(Foo) |
GC.Collect() | runtime.GC() (通常は使わない) |
IDisposable / using | defer x.Close() |
unsafe / ポインタ演算 | unsafe.Pointer (通常は使わない) |
ArrayPool<T> | sync.Pool |
GCSettings.LatencyMode / GCHeapHardLimit | GOGC / GOMEMLIMIT |
6. C# 開発者がつまずきやすいポイント
「class か struct か」を考えない
Go の struct は、値としてもポインタとしても使えます。C# の class のつもりで構造体を値で持ち回ると、意図せずコピーされ、変更が反映されません。
users := []User{{Name: "a"}}
for _, u := range users {
u.Name = "changed" // コピーを変更しているだけ
}
fmt.Println(users[0].Name) // a
for i := range users {
users[i].Name = "changed" // インデックスで要素を直接変更
}
ループ変数のポインタ (Go 1.22 で解消)
Go 1.21 以前は、ループ変数がイテレーション間で共有されるため、そのポインタを保存すると全部同じ値を指すバグがありました。Go 1.22 以降は、イテレーションごとに新しい変数が作られるため解消されています。go.mod の go ディレクティブが 1.22 未満だと旧挙動になる点に注意してください。
ポインタ vs 値のどちらを返すか
- 小さな値や不変データ → 値を返す
- 状態を持つ・大きい・nil で「なし」を表したい → ポインタを返す
- 必須値の取得失敗は、
(*T, error)や(T, bool)で表す
その他のポイント
- ポインタのポインタ (
**T) は通常不要。まず設計を見直す *[]T(スライスへのポインタ) はほぼ不要。スライスは既にヘッダで、戻り値で返すのが慣習- ポインタを含む構造体は GC スキャン対象が増える。大量のデータでは、値の配列の方が有利なことが多い
- ゴルーチン間で共有するポインタはデータ競合の原因になる。
go test -raceで検出する - スライス内の要素のアドレスを保持して
appendすると、再確保で古い領域を指し続けることがある - メモリリークは起こる: 無限に伸びるマップ/スライス、止まらない goroutine、
time.TickerのStop忘れなど
7. コード例
C#
class Point { public int X, Y; }
struct Vec { public int X, Y; }
static void MoveClass(Point p) => p.X++; // 参照が渡る: 呼び出し元に反映
static void MoveStruct(Vec v) => v.X++; // コピー: 反映されない
static void MoveRef(ref Vec v) => v.X++; // ref: 反映される
Go
package main
import "fmt"
type Point struct{ X, Y int }
// すべて値渡し: 型によらずコピーされる
func moveValue(p Point) { p.X++ }
// 反映したいときはポインタを渡す (C# の class / ref に相当)
func movePointer(p *Point) { p.X++ }
// ローカル変数のアドレスを返しても安全 (ヒープに逃げる)
func newPoint(x, y int) *Point {
p := Point{X: x, Y: y}
return &p
}
func main() {
p := Point{1, 2}
moveValue(p)
fmt.Println(p.X) // 1
movePointer(&p)
fmt.Println(p.X) // 2
q := newPoint(3, 4)
q.X = 10
fmt.Println(*q) // {10 4}
// nil チェック
var np *Point
if np == nil {
fmt.Println("nil なのでデリファレンスしない")
}
}
8. まとめ
- Go のポインタは
&/*で扱い、ポインタ演算はできない。nil のデリファレンスは panic - すべて値渡し。変更を反映したいとき・大きな構造体・コピー不可の型ではポインタを使う
- スライス・マップ・チャネルは内部にポインタを持つため、コピーしてもデータが共有される
- 値がスタックかヒープかはエスケープ解析が決める。確認は
go build -gcflags="-m" - GC は並行・非世代別・低レイテンシ重視。調整は
GOGCとGOMEMLIMITで行うが、まずは割り当てを減らすことを優先する - 最適化はプロファイルで根拠を得てから行う