メモリ空間なんて意識してこなかった
私はこれまでPython、JavaScript/TypeScript、PHPといった言語を中心にコーディングしてきました。しかし、仕事の都合上、Go言語の学習が必要になり、少しずつ学び始めてから2週間が経ちました。
その過程で、ポインタの概念に何度も躓いています。私のように他の言語からGoに移行し、ポインタに頭を悩ませる人は少なくないようです。実際、QiitaなどのプラットフォームではGoのポインタに関する記事がたくさん投稿されています。
ポインタに関する説明を読むと、最初は理解できた気になるものの、すぐにそのイメージが掴めなくなってしまいます。これまで高級言語のぬるま湯にどっぷり浸かっていたため、メモリに関連する話題が出ると、認知の負担が爆増し、一度理解したこともすぐに忘れてしまうのです。
そんなわけで、ポインタに限らず、メモリ空間内で何が起こっているのかをしっかりとイメージしながらGoを学んでみたところ、徐々に霧が晴れてきました。付随的にポインタ以外の多くの疑問も解決できたため、その過程の一部を備忘録としてここに記録しておきます。
素人が自分なりに調べてまとめた内容になりますので、不正確な記述や誤りもあるかもしれません。間違いはあたたかくご指摘いただけるととっても嬉しいです。
目次
メモリ空間なんて意識してこなかった
1. アドレスのイメージをつかむ
2. 変数宣言はアドレスの確保である
3. ポインタはアドレスを管理する変数
4. 配列は同じ型のデータを連続したメモリ領域に格納する
5. 構造体は異なる型のデータのメモリ領域をまとめて確保する
6. スライスはどうやって可変長機能を実現してるのか
7. 文字列はバイト配列のポインタを持った構造体
8. 変数を代入したり、関数の引数に渡すとき、メモリ空間では何が起こっているか
おわりに
1. アドレスのイメージをつかむ
コンピュータは何かの処理を行うときにメモリを作業場所として使います。メモリは実行中のプログラムや、そのプログラムで扱うデータなど、いろいろな情報を入れる場所です。値を入れるための小さな箱が集まったもの、という認識で構いません。その箱にはメモリ上の場所を表すための「番地」が付けられています。この番地をアドレスと呼びます。
2. 変数宣言はアドレスの確保である
変数を宣言すると、その変数用のスペースがメモリ内に確保されます。実際に、次のようにint
型の変数x
を宣言すると、コンピュータ内部では、
-
int
型のデータを入れるために$n$番地から$n+k$番地までメモリ領域を確保する - $n$番地から$n+k$番地までに
x
という名前を付ける
という処理が行われます。
var x int
変数を宣言する際、具体的なメモリの番地を私たちが指定することはありませんし、その必要もありません。コンピュータが自動的に適切なメモリ領域を選択し、変数に割り当てます。変数は、プログラマーがメモリのアドレスを意識することなくデータを扱えるようにするための仕組みです。
変数に割り当てられたメモリアドレスを知りたい場合、Go言語では、変数の前に&
記号を付けることでその"先頭"のアドレスを取得できます。この操作により、変数のメモリ上の位置を確認することが可能です。
package main
import "fmt"
func main() {
var x int = 10
fmt.Printf("x → %d \n", x)
fmt.Printf("&x → %p \n", &x) // %pは、アドレス値を16進数表記で出力するために使用されます
}
x → 10
&x → 0x400000e080
つまり、変数x
が宣言されると、アドレス0x400000e080
を先頭とするいくつかのアドレスが確保され、そのメモリ領域にx
という名前が割り当てられます。また、変数宣言と同時に値の代入も行われているため、そのメモリ領域に10
という値が格納されます。そして、以降、変数x
を使って、メモリスペースに格納されている値10
を取りにいけるという仕組みです。
ところで、アドレスは先頭ものしかわからないので、末尾がどこなのかわからないのでは?という疑問が生じます。心配ご無用です。確保するメモリ領域のサイズは変数x
のデータ型(この場合はint
)によって決定されます。例えば、64ビットシステムの場合、int
型は8バイトのメモリ領域を確保します。1バイトごとに1つのアドレスが割り振られているので、先頭から8つ分のアドレスが割り当てられるということになります。
つまり、データ型によってメモリ領域のサイズは自動的に決定されるので、先頭アドレスを知っていれば、変数が占めるメモリ領域の全範囲を把握できることになります。
3. ポインタはアドレスを管理する変数
ポインタはポインタ変数と呼ぶのが正確です。つまり、「ポインタは変数」なのです。そしてどんな変数かというと、「アドレスを管理するための変数」となります。変数なので、それを使う前には必ず変数の宣言を行う必要があります。Go言語では、次のように宣言をします。
var p *int
*int
は「int
型の値を格納するメモリ領域の先頭アドレスを格納する型」を意味します。なぜ先頭アドレスなのかというと先述した通り、先頭アドレスと型情報があればメモリ領域を一意に特定できるからです。
実際に、ポインタ変数にアドレスを代入してみます。
package main
import "fmt"
func main() {
var x int = 10
fmt.Printf("x → %d \n" , x)
fmt.Printf("&x → %p \n" , &x)
var p *int = &x
fmt.Printf("p → %p \n" , p)
fmt.Printf("&p → %p \n" , &p)
fmt.Printf("*p → %d \n" , *p)
}
x → 10
&x → 0x40000a4010
p → 0x40000a4010
&p → 0x40000a6020
*p → 10
ポインタ変数p
自身の先頭アドレスが0x40000a6020
であり、そのメモリ領域に変数x
の先頭アドレス0x40000a4010
が格納されています。
ポインタ変数の前に*
を付けることで、そのポインタが指すメモリ領域(←先頭アドレスと型情報で一意に定められる)に格納されている実際の値にアクセスすることができます。この例では、*p
を使ってp
が指すx
の値10にアクセスしています。
4. 配列は同じ型のデータを連続したメモリ領域に格納する
配列は、「同じ型のデータを連続したメモリ領域に格納するデータ構造」です。Go言語のコード例を見てみましょう。
package main
import "fmt"
func main() {
arr := [2]int{}
fmt.Printf("&arr → %p \n", &arr)
fmt.Printf("&arr[0] → %p \n", &arr[0])
fmt.Printf("&arr[1] → %p \n", &arr[1])
}
&arr → 0x400000e080
&arr[0] → 0x400000e080
&arr[1] → 0x400000e088
出力結果をわかりやすく図にすると、以下のようになります。図からわかるように、この配列はint
型のメモリ領域を連続で2つ確保します。そして、それぞれのメモリ領域をarr[0]
とarr[1]
のメモリ領域として利用します。
5. 構造体は異なる型のデータのメモリ領域をまとめて確保する
次に、構造体のメモリ配置を見てみましょう。構造体は「異なる型のデータを一つの単位で扱うためのデータ構造」です。
package main
import "fmt"
func main() {
var st struct {
field1 string
field2 int
}
fmt.Printf("&st → %p \n", &st)
fmt.Printf("&st.field1 → %p \n", &st.key1)
fmt.Printf("&st.field2 → %p \n", &st.key2)
}
&st → 0x400000c018
&st.field1 → 0x400000c018
&st.field2 → 0x400000c028
図からわかるように、st
はst.field1
とst.field2
の両方の値を確保するためのメモリ領域を確保します。Go言語のstring
型は(64ビットシステムの場合)16バイトのメモリ領域が割り当てられるので、下図のようにstring
型のst.field1
とint
型のst.field2
で異なる大きさのメモリ領域が割り当てられます。
6. スライスはどうやって可変長機能を実現してるのか
配列は長さが不変でしたが、スライスの長さは可変です。どうやって可変長の機能を実現しているのでしょうか。スライスのメモリ上の姿を確認することで、可変長を実現している仕組みを解き明かしていきましょう。
まずは、配列のときと同じようにスライス自身とスライスの要素の先頭アドレスを確認してみます。
make
関数を使用してスライスを定義し、先頭アドレスを確認します。
(※ make
関数の第2, 3引数については後述)
package main
import "fmt"
func main() {
slice := make([]int, 2, 3)
fmt.Printf("slice → %d \n", slice)
fmt.Printf("&slice → %p \n", &slice)
fmt.Printf("&slice[0] → %p \n", &slice[0])
fmt.Printf("&slice[1] → %p \n", &slice[1])
}
slice → [0 0]
&slice → 0x400000c018
&slice[0] → 0x4000014048
&slice[1] → 0x4000014050
配列と同様に、slice[0]
とslice[1]
のメモリ領域は連続していますが、slice
とslice[0]
の先頭アドレスが一致しません。(0x400000c018
$\not =$ 0x4000014048
)
実は、Go言語のスライスの実体は次のような構造体です。
type Slice struct {
Data unsafe.Pointer
Len int
Cap int
}
それぞれのフィールドが保持している情報は次のようなものです。
Data
フィールド
👉 配列と異なり、スライス自体はデータを直接保持しません。このData
フィールドでは、実際にデータを格納しているメモリ領域の先頭アドレスを保持しています。この格納している先頭アドレスを変更することで、動的なサイズ変更を実現しています。詳しくは後述します。
Len
フィールド
👉 Len
フィールドは、スライスが現在保持している要素の数を表します。
Cap
フィールド
👉 Cap
フィールドは、何個分のデータのメモリ領域を確保しているか、を表します。
ここで、make
関数によるスライス作成のコードを見返してみます。
slice := make([]int, 2, 3)
第1引数の[]int
はint
型のデータを保持するという宣言です。
第2引数の2
はデータの初期要素数を指定します。int
型の場合は値0
で初期化されます。
第3引数の3
は確保しておくメモリ領域のサイズを指定しています。int
型のデータが"3"つ入るスペースを確保するので、8×3=24バイト分の領域を確保することになります。
実際にLenとCapの値を確認してみます。それぞれlen
関数、cap
関数を利用して値を取得できます。
package main
import "fmt"
func main() {
slice := make([]int, 2, 3)
fmt.Printf("slice → %d \n", slice)
fmt.Printf("len(slice) → %d \n", len(slice))
fmt.Printf("cap(slice) → %d \n", cap(slice))
fmt.Printf("&slice → %p \n", &(slice))
fmt.Printf("&slice[0] → %p \n", &slice[0])
fmt.Printf("&slice[1] → %p \n", &slice[1])
}
slice → [0 0]
len(slice) → 2
cap(slice) → 3
&slice → 0x40000ae000
&slice[0] → 0x40000b0000
&slice[1] → 0x40000b0008
つまり、このスライスはメモリ上でこんな感じになっています。
ここで、このスライスに要素を追加してみましょう。
package main
import "fmt"
func main() {
slice := make([]int, 2, 3)
fmt.Printf("slice → %d \n", slice)
fmt.Printf("len(slice) → %d \n", len(slice))
fmt.Printf("cap(slice) → %d \n", cap(slice))
fmt.Printf("&slice → %p \n", &(slice))
fmt.Printf("&slice[0] → %p \n", &slice[0])
fmt.Printf("&slice[1] → %p \n", &slice[1])
fmt.Printf("--- sliceに要素を追加 --- \n")
slice = append(slice, 10)
fmt.Printf("slice → %d \n", slice)
fmt.Printf("len(slice) → %d \n", len(slice))
fmt.Printf("cap(slice) → %d \n", cap(slice))
fmt.Printf("&slice → %p \n", &(slice))
fmt.Printf("&slice[0] → %p \n", &slice[0])
fmt.Printf("&slice[1] → %p \n", &slice[1])
fmt.Printf("&slice[2] → %p \n", &slice[2])
}
slice → [0 0]
len(slice) → 2
cap(slice) → 3
&slice → 0x400000c018
&slice[0] → 0x4000014048
&slice[1] → 0x4000014050
--- sliceに要素を追加 ---
slice → [0 0 10]
len(slice) → 3
cap(slice) → 3
&slice → 0x400000c018
&slice[0] → 0x4000014048
&slice[1] → 0x4000014050
&slice[2] → 0x4000014058
つまり、要素を追加したことで、メモリ上では次のような変化が起きたことになります。
- まず、未使用だったメモリ領域に
append
関数で追加した10
が格納される - これにより要素数が3に増えるため、slice構造体の
Len
の値が2
から3
に置き換わる
さて、上記のケースの要素の追加では、未使用のメモリ領域が残っていたので、そこに要素が追加されました。では、容量に空きがないスライスに対して、要素を追加するとどうなるでしょうか?試してみましょう。初期要素数と容量をとも2
にすることで、空き容量がないスライスを作成し、そこに要素を追加してみます。
package main
import "fmt"
func main() {
slice := make([]int, 2, 2)
fmt.Printf("slice → %d \n", slice)
fmt.Printf("len(slice) → %d \n", len(slice))
fmt.Printf("cap(slice) → %d \n", cap(slice))
fmt.Printf("&slice → %p \n", &(slice))
fmt.Printf("&slice[0] → %p \n", &slice[0])
fmt.Printf("&slice[1] → %p \n", &slice[1])
fmt.Printf("--- sliceに要素を追加 ---\n")
slice = append(slice, 10)
fmt.Printf("slice → %d \n", slice)
fmt.Printf("len(slice) → %d \n", len(slice))
fmt.Printf("cap(slice) → %d \n", cap(slice))
fmt.Printf("&slice → %p \n", &(slice))
fmt.Printf("&slice[0] → %p \n", &slice[0])
fmt.Printf("&slice[1] → %p \n", &slice[1])
fmt.Printf("&slice[2] → %p \n", &slice[2])
}
slice → [0 0]
len(slice) → 2
cap(slice) → 2
&slice → 0x40000ae000
&slice[0] → 0x40000a4010
&slice[1] → 0x40000a4018
--- sliceに要素を追加 ---
slice → [0 0 10]
len(slice) → 3
cap(slice) → 4
&slice → 0x40000ae000
&slice[0] → 0x4000100000
&slice[1] → 0x4000100008
&slice[2] → 0x4000100010
つまり、次のようなことが起こっています。
- まず、スライスには空き容量がないため、現在の容量の2倍の容量をもったメモリ領域を別の場所に新たに確保される
- その新たな領域に既存要素と追加要素を併せて格納される
- そして、スライス構造体の
Data
フィールドに格納しているアドレスの値は、旧メモリ領域の先頭アドレスから新メモリ領域の先頭アドレスに更新される
このようにして、スライスは可変長の機能が実現されているわけです。
7. 文字列はバイト配列のポインタを持った構造体
string
型もスライス同様、実体は次のような構造体です。
type String struct {
Data unsafe.Pointer
Len int
}
Go言語では、文字はUTF-8に基づくバイト列の形式で保持されており、文字列構造体はそのバイト配列へのポインター(Data
フィールド)とバイト配列の長さ(Len
フィールド)を保持しています。
次のようにして、バイト列の要素や長さを取得できます。
package main
import "fmt"
func main() {
str := "a¥阿😄"
fmt.Printf("len(str) → %d \n", len(str))
fmt.Printf("a \n")
fmt.Printf("str[0] → %x \n", str[0])
fmt.Printf("¥ \n")
fmt.Printf("str[1] → %x \n", str[1])
fmt.Printf("str[2] → %x \n", str[2])
fmt.Printf("阿 \n")
fmt.Printf("str[3] → %x \n", str[3])
fmt.Printf("str[4] → %x \n", str[4])
fmt.Printf("str[5] → %x \n", str[5])
fmt.Printf("😄 \n")
fmt.Printf("str[6] → %x \n", str[6])
fmt.Printf("str[7] → %x \n", str[7])
fmt.Printf("str[8] → %x \n", str[8])
fmt.Printf("str[9] → %x \n", str[9])
}
len(str) → 10
a
str[0] → 61
¥
str[1] → c2
str[2] → a5
阿
str[3] → e9
str[4] → 98
str[5] → bf
😄
str[6] → f0
str[7] → 9f
str[8] → 98
str[9] → 84
他の言語を書いてきた身からすると、str[0]
としたら文字列の最初の文字a
が取得されたり、len(str)
としたら文字数4
が取得されると思ってしまいますが、あくまで文字をUTF-8形式のバイト列で表現した場合の要素や長さが取得されます。
UTF-8において、
-
a
は、1バイト(0x61)で表現されるので、0番目の要素str[0]
-
¥
は、2バイト(0xc2a5)で表現されるので、1, 2番目の要素str[1]
, `str[2] -
阿
は、3バイト(0xe998bf)で表現されるので、3, 4, 5番目の要素str[3]
,str[4]
, `str[5] -
😄
は、4バイト(0xf09f9884)で表現されるので、6, 7, 8, 9番目の要素str[6]
,str[7]
,str[8]
,str[9]
がそれぞれの文字に対応するバイト列になります。そして、合計10バイトになるので、len(str)
は10
になっています。このようにUTF-8は文字を1〜4バイトで表現されます。
💡 +=
による文字列結合のコストが高い理由
ところで、string
型はスライスと異なり、Cap
フィールドがありません。ここからもわかるように、string
型は初期化時に余分に容量を確保していません。そのため、次のように+=
で文字列結合を行うと、新たにメモリ領域を確保しなおして、元の文字列をそこにコピーし、結合する文字列を追加するという処理が実行されます。これでは、使用しないメモリ領域が一時的に残ってしまったり、文字列のコピーのコストがかかったりと、効率のよい処理とは言えません。
text := "Go言語"
text += "楽しい"
このコストを下げるには、内部的に余分な容量をいい感じで確保してくれて、新しい領域を確保することなく、元の領域に連続する形で新たな文字列を追加してくれる機能があればよいわけです。
そして、もちろんそのような機能は提供されています。詳しくは下記の記事などを参考にしていただければと思います。
8. 変数を代入したり、関数の引数に渡すとき、メモリ空間では何が起こっているか
変数を別の変数に代入したり、関数の引数に渡すことで、変数が保持している値の情報を共有することができますが、このときメモリ空間上では何が起こっているでしょうか?
変数(または引数)y
に変数x
の値が共有されるプロセスはこうです。
- 変数
x
と同じ容量のメモリ領域が確保される - 新たなメモリ領域に
x
のメモリ領域に格納されている値がコピーされる - 新たなメモリ領域と変数(または引数)
y
が紐づけられる
package main
import "fmt"
func main() {
x := 10
fmt.Printf("x → %d \n", x)
fmt.Printf("&x → %p \n", &x)
y := x
fmt.Printf("y → %d \n", y)
fmt.Printf("&y → %p \n", &y)
}
x → 10
&x → 0x400000e080
y → 10
&y → 0x400000e0a0
💡 関数やメソッド内で構造体のデータの値を変えたいときは、ポインタを渡す
構造体を関数の引数やメソッドのレシーバーに渡しても、もれなく値がコピーされるので、関数やメソッド内で値を変更しても元の構造体の値は変化しません。渡した構造体のデータを変更したい場合は、その構造体のポインタを渡せばいいわけです。そうすれば、渡した構造体のメモリ領域に間接的にアクセスできます。
package main
import "fmt"
type Struct struct {
field1 int;
field2 int;
}
func func1(y Struct) {
y.field1 = 30
}
func func2(p *Struct) {
p.field2 = 40
}
func main() {
x := Struct{10, 20}
func1(x)
func2(&x)
fmt.Printf("x.field1 → %d \n", x.field1)
fmt.Printf("x.field2 → %d \n", x.field2)
}
x.field1 → 10
x.field2 → 40
ところで、func2
内のコードですが、p
はポインタ変数だから、実体であるx
にアクセスするには、次のように書くのが正しいのではないかと思われます。
func func2(p *Struct) {
// p.field2 = 40 ではなく
(*p).field2 = 40
}
もちろん、これは正しい記法です。実は構造体のポインタはその実体へアクセスする際に*
を省略できるのです。毎回*
を書かずに済むので便利です。
💡 ポインタを持った構造体には要注意
ポインタを持った構造体、例えばスライスなどを代入したり、引数として渡す場合は少し注意が必要です。
次のような例を見てみましょう。
package main
import "fmt"
func main() {
slice1 := []int{10, 20}
slice2 := slice1
slice2[0] = 100
fmt.Printf("slice1[0] → %d \n", slice1[0])
fmt.Printf("slice2[0] → %d \n", slice2[0])
}
slice1[0] → 100
slice2[0] → 100
slice2[0]
の値を100
に変更したのに、slice1[0]
の値も100
になってしまいました。これは、スライスがデータ自体を持たず、データ配列へのポインタを保持していることに由来します。スライスの代入時にコピーされるのは、Data
・Len
・Cap
のフィールドを持ったスライス構造体だけで、実際のデータ配列はコピーされないのです。
type Slice struct {
Data unsafe.Pointer
Len int
Cap int
}
要するにメモリ空間はこんな感じになっています。
スライス構造体自体の値はコピーされていますが、その向き先のデータ配列は同じなので、slice2[0]
の値を変更するとすると同じデータ配列を参照しているslice1[0]
の値も変更されてしまったわけです。
おわりに
私がGo言語を学んで、つまづいたポイントはまだまだありますが、この記事に書かれた基本的な内容を理解して、メモリの世界に少しでも親しんだことで、多くの問題が理解しやすくなりました。もし、あなたも私と同じように、これまでメモリについて深く考えたことがなかったのなら、この記事が少しでも役立てば嬉しいです。