1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Goのポインタについて改めて理解する

Last updated at Posted at 2024-11-20

はじめに

Go言語を仕事で使うようになってちょうど1年ほど経ちました。

今更ながら分かっているようで分かってないポインタについてきちんと理解しようと思い今回改めて勉強をしました

今回読んだ書籍

Goのversionは1.21を使用しています。

ポインタの挙動

例えば以下のようなコードがあったとします

package main

import "fmt"

type Item struct {
	value string
}

func main() {
	items := []Item{{value: "A"}, {value: "B"}, {value: "C"}}
	pointerList := make([]*Item, 0, len(items))

	for _, i := range items {
		pointerList = append(pointerList, &i)
	}

	for _, p := range pointerList {
		fmt.Println(p.value)
	}
}

この実装の出力結果として下記の結果を期待していますが

A
B
C

実際に出力されるのは、

C
C
C

になります。

なぜこのような挙動になるのか、期待した出力結果にするにはどうすればいいのかがわかるようになるのが今回のゴールです。

※ Goのversion1.22でメモリの再利用に関して変更が加えられているので今回の記事の挙動とは異なるので注意
https://tech.anti-pattern.co.jp/go1-22de/

Goにおける値の代入と参照の代入の挙動の違い

まずはGoにおける変数に値を割り当てる値の代入と参照の代入の挙動の違いついて理解していきます

値の代入

Goでは、基本型(int、stringなど)や 構造体(struct)は値の代入になります

これは、変数に別の変数の値を代入するときに、その値のコピーが作成されるという意味です

以下の例を見てみましょう。

package main

import "fmt"

type Item struct {
    value string
}

func main() {
    a := Item{value: "A"}
    b := a // 値のコピー
    b.value = "B"
		fmt.Println("a.value", a.value) // A
		fmt.Println("b.value", b.value) // B
}

値の代入では、aの値がbにコピーされます。その後、b.valueを変更しても、a.valueには影響を与えません。

これは、baのコピーであり、aとは独立した存在であるためです。

参照の代入

  • Goでは、ポインタを使うことで参照の代入を行うことができます
  • 参照の代入とは、変数が別の変数のアドレス(メモリ位置)を参照することを意味します

次の例を見てみましょう

package main

import "fmt"

type Item struct {
    value string
}

func main() {
    a := Item{value: "A"}
    b := &a // ポインタを代入
    b.value = "B"
		fmt.Println("a.value", a.value) // B
		fmt.Println("b.value", b.value) // B
}

参照の代入では、baのアドレスを参照しています。したがって、b.valueを変更すると、a.valueも変更されます。

これは、baのメモリ位置を指しているため、実際には同じデータを指しているからです。

Goの特定の参照型

Goの特定の型(slice、map)ではポインタ型でなくてもデフォルトで参照渡しされます

例えば、sliceの場合をみていきます

package main

import "fmt"

func main() {
    a := []string{"A", "B", "C"}
    b := a // スライスの参照をコピー
    b[0] = "Z"
    fmt.Println("a:", a) // [Z B C]
    fmt.Println("b:", b) // [Z B C]
}

b := a でスライスの参照をコピーするため、baは同じデータを指すようになります

その後スライスの最初の要素を"Z"に変更し出力してみるとabは同じ結果になっています

sliceでは内部的にポインタを使用しているためbを変更すると、aも変更されるというわけです

メモリとアドレス

ポインタの理解するためにはメモリの構造とそのアドレス指定方法を理解することが不可欠です。

実際にGo言語におけるメモリとアドレスの基礎について解説します。

メモリの基本構造

メモリは、データを格納するための領域です。

メモリはバイト(8ビット)単位で区切られ、それぞれのバイトに一意の数字が割り当てられます。このバイトに割り当てられている数字がアドレスと呼ばれます。

例えば、メモリの最初のバイトのアドレスが 0x00000000 であれば、その隣のバイトのアドレスは 0x00000001 になります。このアドレスを用いることで、プログラムは特定のメモリ位置にアクセスし、データの読み書きを行っています。

イメージとしてはこんな感じです。

スクリーンショット 2024-07-21 17.55.43.png

値とアドレスの違い

普段のプログラミングでは、メモリの構造を意識することはほとんどないですが、ポインタを理解するには意識する必要があります。

なぜなら、ポインタがメモリのアドレスを指し示す特殊な型だからです。

実際にデータがどのようにメモリに格納されているのかを見ていきます。

通常型のメモリ配置

以下のコードでは、int 型の変数aの値とアドレスを出力しています。

Go言語では変数に接頭辞として&(アンパサンド)を付与することでアドレスを取得することができます。

package main

import "fmt"

func main() {
    var a int
    fmt.Printf("value: %d\n", a)
    fmt.Printf("address: %p\n", &a)
}

実行結果

value: 0
address: 0x1400009c018

実行してみるとaの値は0aのアドレスは0x1400009c018 となりました。

以下の図はここで出力したaがどのようにメモリに格納されているかを模式的に表したものです。

スクリーンショット 2024-07-21 17.59.40.png

図にあるint型のメモリサイズとはGoでのint型のメモリサイズは一般的には8バイトになるのでこの変数aはメモリ上の8バイトを専有しています。

次にアドレスである0x1400009c018 は変数aがメモリ上の8バイトを専有しているので8つの番号がつけられた領域にまたがってデータが格納されていてその8つの番号の一番若い番号がaが示す領域の先頭のアドレス0x1400009c018 になります。

つまりaは0x1400009c018 を先頭とする8つの1バイトの領域にデータが格納されているわけです。

最後にaの値をみると図ではこの領域の値と書きました。

このaは8バイトつまり64ビットのデータ領域を専有していて、これは0か1の数字が64個並んでいることを意味しています。

aは8バイトのint型なので、64個の0と1の組み合わせを使って-9,223,372,036,854,775,808 から 9,223,372,036,854,775,807 までの整数値を表すことができます。

なので、aの値が0であるということはこの64個の0と1の組み合わせを使って表された整数値が0であるので、今回の場合だと64個の0がこのaの8バイトの領域に並んでいることになります。

ポインタ型のメモリ配置

Goでは通常型の先頭に「*」(アスタリスク)を付与することでその型をポインタ型と表すことができます。

通常型とポインタ型は全く別のものになります。

int型のポインタ型である*int型を使い詳しく見ていきます。

*int 型の変数b を定義し、その値とアドレスを出力しています。

package main

import "fmt"

func main() {
    var b *int
    fmt.Printf("value: %p\n", b)
    fmt.Printf("address: %p\n", &b)
}

実行結果

value: 0x0
address: 0x140000a2018

bの値は0x0 になりbのアドレスは0x140000a2018になりました。

こちらも図で見ていくと、

スクリーンショット 2024-07-21 18.01.06.png

*int ****型のメモリサイズはGoでは8バイトとなるのでこの変数bはメモリ上の8バイトを専有しています。

次にアドレスである0x140000a2018 は前述の変数aと同様に専有する8バイトの領域のうち先頭のメモリ上の番号を表しています。

そしてbの値を見ていくと、先ほどの変数aでは-9,223,372,036,854,775,808 から 9,223,372,036,854,775,807 までの整数値を格納できましたが変数bではアドレスを値として格納することができます。

どういうことかというと、メモリは1バイトごとに領域が区切られていてそれぞれ番号がつけられていると説明しましたが、その番号そのものをメモリ領域に格納できるということです。

ただし格納できるのはその型に対応するポインタ型の変数のアドレスのみで、*int型の変数にはint型の変数のアドレスしか格納できないということです。

なので今回変数bには値を何も代入していないので、bの値はゼロ値になります。つまり8バイトの64個の全てのビットが0になるのでbの値は0x0 となります。なおGoでは値が0x0 はnilと同義です。

メモリ配置とデータへのアクセス

続いてポインタを理解する上で欠かせない直接参照間接参照について理解していきます。

直接参照間接参照を理解するための前段階として型サイズオフセットコピー代入についてそれぞれ解説していきます。

型サイズ

型サイズとは、データ型ごとにメモリ上で確保される領域の大きさのことです。

Go言語では、型サイズはunsafeパッケージのSizeof関数を使って取得することができます。

型によってどれくらい型サイズが違うのか試しにプリミティブ型のサイズを出力してみます。

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    fmt.Printf("bool: %d\n", unsafe.Sizeof(false))
    fmt.Printf("int: %d\n", unsafe.Sizeof(int(0)))
    fmt.Printf("float64: %d\n", unsafe.Sizeof(float64(0.0)))
    fmt.Printf("string: %d\n", unsafe.Sizeof(""))
}

実行結果

bool: 1
int: 8
float64: 8
string: 16

この型サイズは型によって決まるのであって値によって決まるわけではありません。

例えば、int型の場合値が0でも1でも型サイズは8バイトになります。

次に構造体の型サイズとポインタの型サイズを出力してみます。

package main

import (
	"fmt"
	"unsafe"
)

type X struct {
	a int
	b float64
	c string
}

func main() {
	x := X{}
	fmt.Printf("X: %d\n", unsafe.Sizeof(x))
	fmt.Printf("X pointer: %d\n", unsafe.Sizeof(&x))
}

実行結果

X: 32
X pointer: 8

型X はint, float64, string型で構成されていてフィールドの型サイズはそれぞれ8バイト、8バイト、16バイトになります。

このようにフィールドが定義された順にメモリ上に連続して並んでいるのが構造体のデータ構造です。なのでフィールド型のサイズの合計が構造体の型サイズになります。

今回の場合だと8バイト、8バイト、16バイトを足し合わせた32バイトが型Xの型サイズになります。

一方Xのポインタ型である型*Xのサイズ出力結果を見るとは8バイトになっています。

ポインタは*intなども含め全て同じ8バイトになります。(あくまで64bitマシンの場合)

オフセット

構造体のデータ構造はフィールドの値が連続して並んでいます。

そのフィールドにアクセスするには先頭のアドレスから何バイト目にデータがあるかがわかればアクセスすることができます。

この先頭アドレスからの距離をオフセットと言います。

GoではunsafeパッケージのOffsetof関数を使用することでオフセットを取得することができます。

package main

import (
	"fmt"
	"unsafe"
)

type X struct {
	a int
	b float64
	c string
}

func main() {
	x := X{}
	fmt.Printf("x.a: %d\n", unsafe.Offsetof(x.a))
	fmt.Printf("x.b: %d\n", unsafe.Offsetof(x.b))
	fmt.Printf("x.c: %d\n", unsafe.Offsetof(x.c))
}

実行結果

x.a: 0
x.b: 8
x.c: 16

実行結果から分かるように、

x.aのオフセットは0なのでフィールドaのデータは先頭から0バイト目に格納されていることがわかります。

x.bのオフセットは8バイトで先頭から8バイトのフィールドaの直後からデータが格納されていることがわかります。

そしてx.cのオフセットは16バイトなので先頭から8バイトのフィールドbの直後からデータが格納されていることがわかります。

先頭アドレスからオフセットだけ後ろの場所を見ることでフィールドのメモリ位置を特定することができます。

コピー代入

変数に値を代入する時は必ずコピーが作成されています。

実際に見ていくと

package main

import "fmt"

type X struct {
	s string
}

func main() {
	var x1 X
	x2 := x1
	fmt.Printf("x1: %p\n", &x1)
	fmt.Printf("x2: %p\n", &x2)
}

上記の実装では、var x1 X で型Xの変数x1を定義しその後x2 := x1x2に代入しています。

実際に実行してみると、

x1: 0x14000010070
x2: 0x14000010080

x1x2はそれぞれアドレスが異なります。

これはx1x2はそれぞれ別のメモリ領域にあることを示しています。

図で見ていくと、

var x1 X でx1で使うためにXの型サイズである8バイト分メモリを確保します。

スクリーンショット 2024-07-21 18.03.35.png

その後x2 := x1 での代入でx2で使うためにx1とは別の領域に8バイトを確保しています。

別の領域にデータを確保したのでx2x1の先頭アドレスと異なります。

スクリーンショット 2024-07-21 18.05.33.png

次に、x1の8バイト分の値をx2の8バイト領域にコピーした結果両者の8バイト分のビットの並びは同じになります。

スクリーンショット 2024-07-21 18.07.45.png

この結果、x1x2は同じ値ですがアドレスが違う状態になります。

つまりx2x1のコピーが代入されました。このフローをコピー代入と呼びます。

コピー代入はどの型でも起こります。

直接参照と間接参照

前提知識として型サイズオフセットコピー代入について解説したのでいよいよ本題です。

直接参照と間接参照とはメモリ上のデータにアクセスする方法の違いです。

ポインタ型の指定

Goでは%T を使うことで変数の型が分かります。

以下のコード例ではmainパッケージの型Xと型*Xになる変数を作っています。

package main

import "fmt"

type X struct {
	s string
}

func main() {
	x1 := X{}
	x2 := new(X)
	var x3 X
	var x4 *X
	fmt.Printf("x1: %T\n", x1)
	fmt.Printf("x2: %T\n", x2)
	fmt.Printf("x3: %T\n", x3)
	fmt.Printf("x4: %T\n", x4)
}

実行結果

x1: main.X
x2: *main.X
x3: main.X
x4: *main.X

通常型とポインタ型は全く別の型と説明しましたが、実際は通常型は直接参照ポインタ型は間接参照を使ってデータにアクセスするという決まりがあります。

通常の型かポインタ型かどうかというのはコード上で直接参照を使うのか間接参照を使うのかを明示しているわけです。

ポインタ渡し

続いて関数のポインタ渡しについてです。

関数のポインタ渡しとはポインタ型の引数を受け取る関数を定義してその関数にアドレスを渡すことです。

つまり関数がポインタ型の引数を受け取るのであれば必然的にポインタ渡しになるわけです。

以下のコードは型*Xの引数を受け取る関数testを定義し実引数x1と仮引数x2の値とアドレスを出力しています。

package main

import "fmt"

type X struct {
	s string
}

func test(x2 *X) {
	fmt.Printf("x2 value: %p\n", x2)
	fmt.Printf("x2 address: %p\n", &x2)
}

func main() {
	x1 := new(X)
	fmt.Printf("x1 value: %p\n", x1)
	fmt.Printf("x1 address: %p\n", &x1)
	test(x1)
}

実行結果

x1 value: 0x1400008e020
x1 address: 0x140000a2018
x2 value: 0x1400008e020
x2 address: 0x140000a2028

x1x2の型はどちらも*Xなのでアドレスの値が入ります。また*Xはポインタ型なので構造体Xのサイズに関係なく8バイトです。値(value)は同じですがアドレスは別の値になります。

データへのアクセス

直接参照と間接参照の違いはデータにアクセスするステップが異なることです。

この違いにより値の代入なのか参照の代入なのかを説明することができます。

直接参照の場合

以下のコードは直接参照でデータにアクセスする例です。

package main

import (
	"fmt"
	"unsafe"
)

type X struct {
	i int
	f float64
}

func main() {
	x1 := X{}
	x1.f = 1.5
	fmt.Printf("x1.f: %f\n", x1.f)
	fmt.Printf("x1 address: %p\n", &x1)
	fmt.Printf("x1 size: %d\n", unsafe.Sizeof(x1))
}

intとfloat64のフィールドを持ち型サイズが16バイトの構造体Xを定義しました。

x1 := X{}x1にインスタンスを代入しx1.f = 1.5x1.fを書き換えx1のフィールドfとポインタ型とサイズを出力しています。

実行結果

x1.f: 1.500000
x1 address: 0x140000a4020
x1 size: 16

図を使ってメモリ内の状態を見ていきます。

まずx1 := X{} ではx1のメモリ領域を確保し構造体Xの値を代入します。

アドレス0x140000a4020 を先頭に16バイト分のメモリを使用します。

スクリーンショット 2024-07-21 18.12.26.png

続いてx1.f = 1.5 でオフセットが8バイトのフィールドfの値を書き換えます。

x1の先頭アドレスから8バイトオフセットした位置を起点としてfloat64の型サイズである8バイトの領域にデータを書き込んでいます。

スクリーンショット 2024-07-21 18.15.06.png

そしてfmt.Printf("x1.f: %f\n", x1.f) では上記の書き込みと同様にx1の先頭アドレスから8バイトオフセットした位置を起点として8バイトの領域のデータを読み込んでいます。

間接参照の場合

次に間接参照でデータにアクセスする例です。

package main

import (
	"fmt"
	"unsafe"
)

type X struct {
	i int
	f float64
}

func main() {
	x2 := new(X)
	x2.f = 1.5
	fmt.Printf("x2.f: %f\n", x2.f)
	fmt.Printf("x2 address: %p\n", &x2)
	fmt.Printf("x2 value: %p\n", x2)
	fmt.Printf("x2 size: %d\n", unsafe.Sizeof(*x2))
}

今回は構造体Xのポインタ型である*Xの変数x2を定義しました。

実行結果

x2.f: 1.500000
x2 address: 0x14000116018
x2 value: 0x14000110020
x2 size: 16

先ほど同様に図でメモリ内の状態を見ていきます。

まずx2 := new(X)x2の8バイトのメモリ領域を確保します。

次に構造体Xのサイズの16バイトのメモリ領域を確保し先頭のアドレスの値(0x14000116018)をx2の値に入れます。

スクリーンショット 2024-07-21 18.20.08.png

続いてx2.f = 1.5x2で値として保持しているアドレスを先頭アドレスとしそこから8バイトオフセットしたメモリのアドレスを起点とするfloat型のサイズの8バイトをメモリ領域に書き込みます

スクリーンショット 2024-07-21 18.22.30.png

fmt.Printf("x2.f: %f\n", x2.f)でメモリ上のデータを読み込むときはx2.f = 1.5 と同様にx2で値として保持しているアドレスを先頭にし8バイトオフセットしたメモリのアドレスを起点として8バイトのデータ領域を読み込んでいます。

間接参照の場合は直接参照と違い一度変数に入っているアドレスの値を読み込みそのアドレスを先頭アドレスとする型サイズの領域のデータの読み書きをしています。

つまり直接データを読み書きするのではなく一度アドレスの値を経由して間接的にデータの読み書きを行なっているわけです。

ポインタ型を使うと、変数にアドレスをコピーして同じメモリ領域を共有できます。これは、アドレスの値が同じため、間接参照で同じデータにアクセスするからです。

変数とアドレスの関係

色々ポインタについて説明してきましたがここでようやく最初のコードに戻ります。

package main

import "fmt"

type Item struct {
	value string
}

func main() {
	items := []Item{{value: "A"}, {value: "B"}, {value: "C"}}
	pointerList := make([]*Item, 0, len(items))

	for _, i := range items {
		pointerList = append(pointerList, &i)
	}

	for _, p := range pointerList {
		fmt.Println(p.value)
	}
}
// 期待する実行結果 
A
B
C
// 実際の実行結果 
C
C
C

なぜこのような結果になってしまうのか、まずはpointerListに格納されたアドレスの値を見ていきます。

package main

import "fmt"

type Item struct {
	value string
}

func main() {
	items := []Item{{value: "A"}, {value: "B"}, {value: "C"}}
	pointerList := make([]*Item, 0, len(items))

	for _, i := range items {
		pointerList = append(pointerList, &i)
	}

	for i := range pointerList {
		fmt.Printf("%p\n", pointerList[i])
	}
}

実行結果

0x1400008e040
0x1400008e040
0x1400008e040

実行結果を見るとpointerListの要素にはすべて同じアドレスが入っていることがわかります。

同じアドレスなので同じフィールドの値が出力されていたわけです。

もう少し具体的に見ていくためにfor文を分解してみます。

package main

import "fmt"

type Item struct {
	value string
}

func main() {
	items := []Item{{value: "A"}, {value: "B"}, {value: "C"}}
	pointerList := make([]*Item, 0, len(items))

	var i Item                            // ①

	i = items[0]                                                     // ②
	pointerList = append(pointerList, &i)  // ③

	i = items[1]                          // ④
	pointerList = append(pointerList, &i) // ⑤

	i = items[2]                          // ⑥
	pointerList = append(pointerList, &i) // ⑦

	for _, p := range pointerList {
		fmt.Println(p.value)
	}
}

for 文の変数iは①にあるように一度定義されていて②、④、⑥でpointerListの要素がコピー代入されています。

そして③、⑤、⑦でpointerListiのアドレスが要素として追加されています。

メモリ内の状態を順番に見ていきます。

以下は①が実行された時点でのメモリ内を表した図です。

スクリーンショット 2024-07-21 18.38.41.png

変数iのメモリ領域(図左)とsliceであるitemsのメモリ領域(図右)が確保されています。

itemsの要素数は3なのでサイズ3つ分のメモリが確保されています。

データ領域は各要素ごとに区切られているのでそれぞれに先頭アドレスが存在します。

次に②が実行された時点の図です。

スクリーンショット 2024-07-21 18.38.06.png

iitems[0]の値を代入したのでiitemsは同じ値になっています。

しかし先頭のアドレスは値が異なります。

③が実行された時点の図です。

スクリーンショット 2024-07-21 18.37.34.png

iの領域の先頭アドレスをpointerListの要素に追加します。ここで*item型のpointerList に格納されるのはiの先頭アドレスであってitems[0] の先頭アドレスではありません。

これ以降はitemsの要素に対して同様の処理が繰り返されます。

最後の⑦が実行された時点の図は以下です。

スクリーンショット 2024-07-21 18.41.13.png

⑦が実行されたときiの値はitems[2]の値が代入されています。

pointerListの全ての要素にはiの先頭アドレスが入っていてこの状態で⑧を実行すると実際の実行結果であるすべてCが出力されます。

正しい実装

それでは最後に期待通りの結果を得るための正しい実装をしていきます。

package main

import "fmt"

type Item struct {
	value string
}

func main() {
	items := []Item{{value: "A"}, {value: "B"}, {value: "C"}}
	pointerList := make([]*Item, 0, len(items))

	for i := range items {
		pointerList = append(pointerList, &items[i])
	}

	for _, p := range pointerList {
		fmt.Println(p.value)
	}
}

実行結果

A
B
C

インデックスで要素を指定してそのアドレスを取得するだけで要素の先頭のアドレスをpointerListに格納することができます。

スクリーンショット 2024-07-21 18.47.08.png

まとめ

今まで脳死で使っていたポインタですが今回改めてポインタの概念について深掘りしてみて思ったのはポインタを扱う際はどの領域の先頭アドレスを指しているのかきちんと把握する必要があるということです。

ただ今回のようなシンプルな実装ではまだ意識することができますが実際のサービスに使われているような複雑なコードだとどうしても見落としてしまうなとも思いました。

悩んだら一度立ち止まり今回作った図などを頭の中で作る癖をつけたいなと思いました。

参考

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?