LoginSignup
4
4

ポインタでつまづいたあなたに向けたアドレス空間の世界【Go言語】

Last updated at Posted at 2024-03-01

メモリ空間なんて意識してこなかった

私はこれまでPython、JavaScript/TypeScript、PHPといった言語を中心にコーディングしてきました。しかし、仕事の都合上、Go言語の学習が必要になり、少しずつ学び始めてから2週間が経ちました。

その過程で、ポインタの概念に何度も躓いています。私のように他の言語からGoに移行し、ポインタに頭を悩ませる人は少なくないようです。実際、QiitaなどのプラットフォームではGoのポインタに関する記事がたくさん投稿されています。

ポインタに関する説明を読むと、最初は理解できた気になるものの、すぐにそのイメージが掴めなくなってしまいます。これまで高級言語のぬるま湯にどっぷり浸かっていたため、メモリに関連する話題が出ると、認知の負担が爆増し、一度理解したこともすぐに忘れてしまうのです。

そんなわけで、ポインタに限らず、メモリ空間内で何が起こっているのかをしっかりとイメージしながらGoを学んでみたところ、徐々に霧が晴れてきました。付随的にポインタ以外の多くの疑問も解決できたため、その過程の一部を備忘録としてここに記録しておきます。

素人が自分なりに調べてまとめた内容になりますので、不正確な記述や誤りもあるかもしれません。間違いはあたたかくご指摘いただけるととっても嬉しいです。

目次

メモリ空間なんて意識してこなかった
1. アドレスのイメージをつかむ
2. 変数宣言はアドレスの確保である
3. ポインタはアドレスを管理する変数
4. 配列は同じ型のデータを連続したメモリ領域に格納する
5. 構造体は異なる型のデータのメモリ領域をまとめて確保する
6. スライスはどうやって可変長機能を実現してるのか
7. 文字列はバイト配列のポインタを持った構造体
8. 変数を代入したり、関数の引数に渡すとき、メモリ空間では何が起こっているか
おわりに

1. アドレスのイメージをつかむ

コンピュータは何かの処理を行うときにメモリを作業場所として使います。メモリは実行中のプログラムや、そのプログラムで扱うデータなど、いろいろな情報を入れる場所です。値を入れるための小さな箱が集まったもの、という認識で構いません。その箱にはメモリ上の場所を表すための「番地」が付けられています。この番地をアドレスと呼びます。

address.jpg

2. 変数宣言はアドレスの確保である

変数を宣言すると、その変数用のスペースがメモリ内に確保されます。実際に、次のようにint型の変数xを宣言すると、コンピュータ内部では、

  1. int型のデータを入れるために$n$番地から$n+k$番地までメモリ領域を確保する
  2. $n$番地から$n+k$番地までにxという名前を付ける

という処理が行われます。

var x int

int_memory_base (2).jpg

変数を宣言する際、具体的なメモリの番地を私たちが指定することはありませんし、その必要もありません。コンピュータが自動的に適切なメモリ領域を選択し、変数に割り当てます。変数は、プログラマーがメモリのアドレスを意識することなくデータを扱えるようにするための仕組みです。

変数に割り当てられたメモリアドレスを知りたい場合、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を取りにいけるという仕組みです。

int_memory_concreate.jpg

ところで、アドレスは先頭ものしかわからないので、末尾がどこなのかわからないのでは?という疑問が生じます。心配ご無用です。確保するメモリ領域のサイズは変数xのデータ型(この場合はint)によって決定されます。例えば、64ビットシステムの場合、int型は8バイトのメモリ領域を確保します。1バイトごとに1つのアドレスが割り振られているので、先頭から8つ分のアドレスが割り当てられるということになります。

つまり、データ型によってメモリ領域のサイズは自動的に決定されるので、先頭アドレスを知っていれば、変数が占めるメモリ領域の全範囲を把握できることになります。

3. ポインタはアドレスを管理する変数

ポインタはポインタ変数と呼ぶのが正確です。つまり、「ポインタは変数」なのです。そしてどんな変数かというと、「アドレスを管理するための変数」となります。変数なので、それを使う前には必ず変数の宣言を行う必要があります。Go言語では、次のように宣言をします。

ポインタ変数の宣言
var p *int

*intは「int型の値を格納するメモリ領域の先頭アドレスを格納する型」を意味します。なぜ先頭アドレスなのかというと先述した通り、先頭アドレスと型情報があればメモリ領域を一意に特定できるからです。

point_memory_base.jpg

実際に、ポインタ変数にアドレスを代入してみます。

ポインタ変数の宣言とアドレスの代入
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にアクセスしています。

point_memory_concreate.jpg

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]のメモリ領域として利用します。

arr_memory.jpg

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

図からわかるように、stst.field1st.field2の両方の値を確保するためのメモリ領域を確保します。Go言語のstring型は(64ビットシステムの場合)16バイトのメモリ領域が割り当てられるので、下図のようにstring型のst.field1int型のst.field2で異なる大きさのメモリ領域が割り当てられます。

struct_memory.jpg

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]のメモリ領域は連続していますが、sliceslice[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引数の[]intint型のデータを保持するという宣言です。
第2引数の2はデータの初期要素数を指定します。int型の場合は値0で初期化されます。
第3引数の3は確保しておくメモリ領域のサイズを指定しています。int型のデータが"3"つ入るスペースを確保するので、8×3=24バイト分の領域を確保することになります。

実際にLenとCapの値を確認してみます。それぞれ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])
}
スライスのLenとCap: 出力
slice      → [0 0] 
len(slice) → 2 
cap(slice) → 3 
&slice     → 0x40000ae000 
&slice[0]  → 0x40000b0000 
&slice[1]  → 0x40000b0008

つまり、このスライスはメモリ上でこんな感じになっています。

slice.jpg

ここで、このスライスに要素を追加してみましょう。

スライスに要素を追加
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

つまり、要素を追加したことで、メモリ上では次のような変化が起きたことになります。

  1. まず、未使用だったメモリ領域にappend関数で追加した10が格納される
  2. これにより要素数が3に増えるため、slice構造体のLenの値が2から3に置き換わる

slice_append (1).jpg

さて、上記のケースの要素の追加では、未使用のメモリ領域が残っていたので、そこに要素が追加されました。では、容量に空きがないスライスに対して、要素を追加するとどうなるでしょうか?試してみましょう。初期要素数と容量をとも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 

つまり、次のようなことが起こっています。

  1. まず、スライスには空き容量がないため、現在の容量の2倍の容量をもったメモリ領域を別の場所に新たに確保される
  2. その新たな領域に既存要素と追加要素を併せて格納される
  3. そして、スライス構造体のDataフィールドに格納しているアドレスの値は、旧メモリ領域の先頭アドレスから新メモリ領域の先頭アドレスに更新される

slice_append_no_cap.jpg

このようにして、スライスは可変長の機能が実現されているわけです。

7. 文字列はバイト配列のポインタを持った構造体

string型もスライス同様、実体は次のような構造体です。

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の値が共有されるプロセスはこうです。

  1. 変数xと同じ容量のメモリ領域が確保される
  2. 新たなメモリ領域にxのメモリ領域に格納されている値がコピーされる
  3. 新たなメモリ領域と変数(または引数)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

assign.jpg

💡 関数やメソッド内で構造体のデータの値を変えたいときは、ポインタを渡す

構造体を関数の引数やメソッドのレシーバーに渡しても、もれなく値がコピーされるので、関数やメソッド内で値を変更しても元の構造体の値は変化しません。渡した構造体のデータを変更したい場合は、その構造体のポインタを渡せばいいわけです。そうすれば、渡した構造体のメモリ領域に間接的にアクセスできます。

構造体自身を渡すかポインタを渡すか
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

struct_args (1).jpg

ところで、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になってしまいました。これは、スライスがデータ自体を持たず、データ配列へのポインタを保持していることに由来します。スライスの代入時にコピーされるのは、DataLenCapのフィールドを持ったスライス構造体だけで、実際のデータ配列はコピーされないのです。

スライス構造体: 再掲
type Slice struct {
	Data unsafe.Pointer
	Len  int
	Cap  int
}

要するにメモリ空間はこんな感じになっています。

slice_assign (1).jpg

スライス構造体自体の値はコピーされていますが、その向き先のデータ配列は同じなので、slice2[0]の値を変更するとすると同じデータ配列を参照しているslice1[0]の値も変更されてしまったわけです。

おわりに

私がGo言語を学んで、つまづいたポイントはまだまだありますが、この記事に書かれた基本的な内容を理解して、メモリの世界に少しでも親しんだことで、多くの問題が理解しやすくなりました。もし、あなたも私と同じように、これまでメモリについて深く考えたことがなかったのなら、この記事が少しでも役立てば嬉しいです。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4