はじめに
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
には影響を与えません。
これは、b
がa
のコピーであり、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
}
参照の代入では、b
はa
のアドレスを参照しています。したがって、b.value
を変更すると、a.value
も変更されます。
これは、b
がa
のメモリ位置を指しているため、実際には同じデータを指しているからです。
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
でスライスの参照をコピーするため、b
とa
は同じデータを指すようになります
その後スライスの最初の要素を"Z"
に変更し出力してみるとa
とb
は同じ結果になっています
sliceでは内部的にポインタを使用しているためb
を変更すると、a
も変更されるというわけです
メモリとアドレス
ポインタの理解するためにはメモリの構造とそのアドレス指定方法を理解することが不可欠です。
実際にGo言語におけるメモリとアドレスの基礎について解説します。
メモリの基本構造
メモリは、データを格納するための領域です。
メモリはバイト(8ビット)単位で区切られ、それぞれのバイトに一意の数字が割り当てられます。このバイトに割り当てられている数字がアドレスと呼ばれます。
例えば、メモリの最初のバイトのアドレスが 0x00000000
であれば、その隣のバイトのアドレスは 0x00000001
になります。このアドレスを用いることで、プログラムは特定のメモリ位置にアクセスし、データの読み書きを行っています。
イメージとしてはこんな感じです。
値とアドレスの違い
普段のプログラミングでは、メモリの構造を意識することはほとんどないですが、ポインタを理解するには意識する必要があります。
なぜなら、ポインタがメモリのアドレスを指し示す特殊な型だからです。
実際にデータがどのようにメモリに格納されているのかを見ていきます。
通常型のメモリ配置
以下のコードでは、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
の値は0
でa
のアドレスは0x1400009c018
となりました。
以下の図はここで出力したa
がどのようにメモリに格納されているかを模式的に表したものです。
図にある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
になりました。
こちらも図で見ていくと、
*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 := x1
でx2
に代入しています。
実際に実行してみると、
x1: 0x14000010070
x2: 0x14000010080
x1
とx2
はそれぞれアドレスが異なります。
これはx1
とx2
はそれぞれ別のメモリ領域にあることを示しています。
図で見ていくと、
var x1 X
でx1で使うためにXの型サイズである8バイト分メモリを確保します。
その後x2 := x1
での代入でx2
で使うためにx1とは別の領域に8バイトを確保しています。
別の領域にデータを確保したのでx2
はx1
の先頭アドレスと異なります。
次に、x1
の8バイト分の値をx2
の8バイト領域にコピーした結果両者の8バイト分のビットの並びは同じになります。
この結果、x1
とx2
は同じ値ですがアドレスが違う状態になります。
つまりx2
にx1
のコピーが代入されました。このフローをコピー代入と呼びます。
コピー代入はどの型でも起こります。
直接参照と間接参照
前提知識として型サイズ、オフセット、コピー代入について解説したのでいよいよ本題です。
直接参照と間接参照とはメモリ上のデータにアクセスする方法の違いです。
ポインタ型の指定
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
x1
とx2
の型はどちらも*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.5
でx1.f
を書き換えx1
のフィールドf
とポインタ型とサイズを出力しています。
実行結果
x1.f: 1.500000
x1 address: 0x140000a4020
x1 size: 16
図を使ってメモリ内の状態を見ていきます。
まずx1 := X{}
ではx1
のメモリ領域を確保し構造体Xの値を代入します。
アドレス0x140000a4020
を先頭に16バイト分のメモリを使用します。
続いてx1.f = 1.5
でオフセットが8バイトのフィールドfの値を書き換えます。
x1
の先頭アドレスから8バイトオフセットした位置を起点としてfloat64の型サイズである8バイトの領域にデータを書き込んでいます。
そして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
の値に入れます。
続いてx2.f = 1.5
でx2
で値として保持しているアドレスを先頭アドレスとしそこから8バイトオフセットしたメモリのアドレスを起点とするfloat
型のサイズの8バイトをメモリ領域に書き込みます
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
の要素がコピー代入されています。
そして③、⑤、⑦でpointerList
にi
のアドレスが要素として追加されています。
メモリ内の状態を順番に見ていきます。
以下は①が実行された時点でのメモリ内を表した図です。
変数i
のメモリ領域(図左)とsliceであるitems
のメモリ領域(図右)が確保されています。
items
の要素数は3なのでサイズ3つ分のメモリが確保されています。
データ領域は各要素ごとに区切られているのでそれぞれに先頭アドレスが存在します。
次に②が実行された時点の図です。
i
にitems[0]
の値を代入したのでi
とitems
は同じ値になっています。
しかし先頭のアドレスは値が異なります。
③が実行された時点の図です。
i
の領域の先頭アドレスをpointerList
の要素に追加します。ここで*item
型のpointerList
に格納されるのはiの先頭アドレスであってitems[0]
の先頭アドレスではありません。
これ以降はitemsの要素に対して同様の処理が繰り返されます。
最後の⑦が実行された時点の図は以下です。
⑦が実行されたとき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
に格納することができます。
まとめ
今まで脳死で使っていたポインタですが今回改めてポインタの概念について深掘りしてみて思ったのはポインタを扱う際はどの領域の先頭アドレスを指しているのかきちんと把握する必要があるということです。
ただ今回のようなシンプルな実装ではまだ意識することができますが実際のサービスに使われているような複雑なコードだとどうしても見落としてしまうなとも思いました。
悩んだら一度立ち止まり今回作った図などを頭の中で作る癖をつけたいなと思いました。