メインコンテンツまでスキップ

ポインタとメモリ管理

.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
構文意味
&xx のアドレスを取る
*pポインタ p が指す値を読み書きする
*TT へのポインタ型
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 / GOMEMLIMITGC モード、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 / NullReferenceExceptionnil / nil ポインタ参照の panic
Nullable<T> (int?)*int またはゼロ値 + ok を使う
new Foo()&Foo{} / new(Foo)
GC.Collect()runtime.GC() (通常は使わない)
IDisposable / usingdefer x.Close()
unsafe / ポインタ演算unsafe.Pointer (通常は使わない)
ArrayPool<T>sync.Pool
GCSettings.LatencyMode / GCHeapHardLimitGOGC / 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 で行うが、まずは割り当てを減らすことを優先する
  • 最適化はプロファイルで根拠を得てから行う

参考資料​