goの勉強をしていて、メモリ、アドレス、ポインタ周りがなかなか理解できなかった時に、スタックとヒープを知り理解が深まったので、同じような悩みを持っている方向けにスタックとヒープを解説します。
メモリとアドレスについて
変数を作ると、値はPCのメモリ(RAM)の領域のどこかに置かれます。
この領域は、番号のついた箱がずらっと並んでいると思ってください。
x := 10と書くと、空いている箱が1つ確保さえ、そこに10が入ります。
箱の下についている番号をアドレスといい、箱の番号だと思ってください。

Goには、アドレスを扱うための記号が2つあります。
- &x:xが入っている箱の番号を取り出す。
- *p:番号pの箱の中身を見る
x := 10
p := &x
fmt.Println(p) // 0x05 箱の番号
fmt.Println(*p) // 10 その箱の中身
そして、箱の番号を持ち歩くための方がポインタです。
※ 先ほどの図のアドレスは桁数を簡略化しています。
なぜポインタが必要なのか
ここから本題で、まずは以下のプログラム
package main
import "fmt"
type User struct {
Name string
}
func rename(u User) {
u.Name = "sato"
}
func main() {
u := User{Name: "kaito"}
rename(u)
fmt.Println(u.Name)
}
このプログラムは、renameの中でNameを書き換えているので、satoが出そうですが、実行結果はkaitoです。
値渡しの状態です。
理由は、rename(u)と書いた時点でu、つまり構造体Userのコピーが渡されているだけなので、書き換えたのはコピー側で、実際の構造体Userの値は書き変わっていません。
では、実際の構造体Userの値を書き換えるにはどうすれば良いか。
そんなときに使うのがポインタです。
func rename(u *User) {
u.Name = "sato"
}
func main() {
u := User{Name: "kaito"}
rename(&u)
fmt.Println(u.Name)
}
&uで構造体Userの値の場所、いわゆるアドレスを取り出し、*User型の引数で受け取ります。
これによって、構造体Userの値を直接書き換えられます。
これをポインタ渡しと言います。
値渡しとポインタ渡しで何が変わるのか
ここで重要なのが、ポインタ渡しでもコピーは起きています。
rename(&u)でコピーされるのは構造体Userの中身では無く、&uというアドレスです。
ここは個人的に難しいと思うので、2つの例を作ります。
1. 家で例えると
- 値渡し :同じ家をもう一軒建てる
- ポインタ渡し:住所を書いたメモを渡す
メモはコピーですが、メモが指す家は1件しかありません。
2. アドレスを表示してみる
func byValue(u User) {
fmt.Printf("byValue: %p\n", &u)
}
func byPointer(u *User) {
fmt.Printf("byPointer: %p\n", u)
}
func main() {
u := User{Name: "kaito"}
fmt.Printf("main: %p\n", &u)
byValue(u)
byPointer(&u)
}
main: 0xc000010030
byValue: 0xc000010048 ← 別の場所
byPointer: 0xc000010030 ← main と同じ場所
byValueが受け取ったuは、mainのuとは別の箱です。
一方byPointerは、mainと同じ箱を指しています。
スタックとヒープの違い
メモリには、4つの領域が存在します。
今回はその中の2つ、スタックとヒープを取り上げます。
スタック
関数を呼び出すときに使う領域。
関数を呼ぶと、その関数専用の作業スペースが1つ積まれます。
関数の中で作った変数は、この作業スペースの中に置かれます。
この作業スペースをフレームと呼びます。
関数の処理が終わると、フレームは中身ごとまとめて捨てられます。
関数の中で作った変数が、関数を抜けた後に使えないのはこのためです。
積むのも捨てるのも一番上からだけなので、管理がとても速いのが特徴です。
ヒープ
順番の決まっていない領域。
好きなタイミングで確保でき、関数を抜けても消えません
そのかわり、いつ捨てていいのかが分かりません。
スタックは一番上から順に捨てればいいだけですが、ヒープはどれが使用中でどれが不要なのかを誰かが調べる必要があります。
それをやるのが、後で出てくるGCです。
なぜヒープが必要なのか
func newUser() *User {
u := User{Name: "kaito"}
return &u
}
func main() {
p := newUser()
fmt.Println(p.Name) // kaito
}
このプログラムは単純で、
uはnewUserのローカル変数です。
スタックの説明どおりなら、newUserを抜けた時点でフレームごと消えるので、返したアドレスは何も無い場所を指すことになります。
C言語ではこれは書いてはいけないコードです。
しかしGoでは問題なく動きます。
uはスタックではなくヒープに置かれているからです。
左がnewUserを実行している最中、右がnewUserから抜けた後です。
newUserのフレームは消えますが、mainのpがまだ指しているので、ヒープ上のUserは残ります。
これがヒープの役割です
フレームより長く生きる必要がある値の置き場所、と考えてください。
スタックとヒープのどちらに置かれるかは、コンパイラが判断します。
ガベージコレクション(GC)
ヒープに置かれた値は、関数を抜けても消えません。
しかし、消えないとメモリを圧迫してパフォーマンスに影響を及ぼします。
そのため、Goなど多くの言語ではGCが片付けを行なってくれます。
GCの考えはシンプルで、どこからも参照されなくなった値を回収するというものです。
大まかに以下の様に動きます。
- まだ生きている変数を出発点にする
- そこから辿れるヒープ上の値に印を付ける
- 印のつかなかったものを片付ける
まとめ
メモリは番号のついた箱がずらっと並んでいるもので、その番号がアドレスでした。
&で番号を取り出して、*でその番号の箱を開ける。
ポインタはその番号を持ち歩くための型です。
Goの引数は常にコピーが渡されます。
ポインタ渡しでもコピーは起きていて、コピーされるのが中身かアドレスかが違うだけ。
スタックは関数のための領域で、抜けるとフレームごと消えます。ヒープは指されている限り残ります。
どちらに置かれるかは&を書いたかどうかではなく、その値が関数より長生きするかどうかで、コンパイラが決めていました。
ヒープに残った値を片付けるのがGCですが、GCは誰からも指されていないものしか回収しません。
参照が残っていれば、使っていないつもりでも解放されません。
同じところで止まっている方の助けになれば嬉しいです。
