LoginSignup
5
8

More than 1 year has passed since last update.

もう怖くない!Goのポインタを理解しよう!

Last updated at Posted at 2023-02-12

はじめに

僕がGoを触っていてまず最初に躓いたのがポインタでした。
「なんか値が上手く変わんない?」「あれ、今回って&だっけ?それとも*だっけ?」「ん?なんかsliceが変な挙動を示す?」
気づいたら色々な壁にぶつかって、ポインタはよく分からないしなんか怖いものと思っていました。
Goを初めて学ぶ人でそう思う人は僕以外にもいるはず!笑

ということで今回はそんな恐怖とおさらばしようという記事です!
Goのポインタで苦しんでる方、一緒にポインタを理解していきましょう!

目次

そもそもポインタって何?

A tour of Goには以下のように記載してあります。

Go has pointers. A pointer holds the memory address of a value.
The type *T is a pointer to a T value. Its zero value is nil.
(訳)
Goにはポインタがあります。ポインタは、値のメモリアドレスを保持します。
*T型はT型の値へのポインタです。そのゼロ値はnilである。

要は、「この値のメモリアドレスはこれだよ!」と指し示してくれるものがポインタです。
T型の値があったら、*TにはTのメモリアドレスが格納されています。

少しコードで例を見ていきます。
int型のiという変数に1を代入し、その後*int型(intのポインタ型)のpという変数を用意します。
pにはiのメモリアドレスを代入して、その値を出力してみます。

main.go
package main

import (
	"fmt"
)

func main() {
	var i int = 1
	var p *int

    // &iと書くことで、その変数のメモリアドレスを返してくれます(&はアドレス演算子と呼んだりします)。
	p = &i
	fmt.Printf("iのメモリアドレスは%v、iのポインタ型は%T\n", p, p)
	fmt.Printf("ちなみにpのメモリアドレスは%v、pのポインタ型は%T", &p, &p)
}
result
iのメモリアドレスは0xc000018040、iのポインタ型は*int
ちなみにpのメモリアドレスは0xc00000e028、pのポインタ型は**int

変数とポインタ型の関係を少し図で確認してみましょう。

変数pには変数iのメモリアドレスが格納されています。
p=&iが実行される前は、変数pには何も格納されていません(pはnilです)。

ちなみに上記の例で、p(メモリアドレス)を元にiの値を取得したい場合は、*pと書きます。
参照先を見るという意味で、このことをデリファレンスと呼びます。

補足:アドレスとサイズ
メモリアドレスの値は16進数で表されます。
メモリサイズについては、各型によって変わってきます。
あまり詳細には記載しませんが、こんな感じです。

メモリサイズ
int 8
string 16
bool 1
空のstruct 0
main.go
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var i int
	var s string
	var b bool
	var st struct{}
	fmt.Printf("intのメモリサイズ:%v\n stringのメモリサイズ:%v\n boolのメモリサイズ:%v\n 空のstructのメモリサイズ:%v \n", unsafe.Sizeof(i), unsafe.Sizeof(s), unsafe.Sizeof(b), unsafe.Sizeof(st))
}
result
intのメモリサイズ:8 
stringのメモリサイズ:16 
boolのメモリサイズ:1 
空のstructのメモリサイズ:0 

関数とポインタ

ざっくりポインタの概念がわかったところで、関数を使ってポインタの理解を深めていきましょう。

関数が色々出てきてややこしいので、func main()を『main関数』、その他を『サブ関数』を表記します。
関数という概念を言及したい際はそのまま『関数』と表記します。

Goの関数は、値渡しとなります。引数の値をコピーして、新しい変数として関数内で使うイメージですね。
まずはコードで確認してみましょう。

main.go
package main

import (
	"fmt"
)

func main() {
	i := 1
	fmt.Printf("main関数のiのメモリアドレスは%v\n", &i)
	confirmAddress(i)
}

func confirmAddress(i int) {
	fmt.Printf("サブ関数のiのメモリアドレスは%v\n", &i)
}
result
main関数のiのメモリアドレスは0xc0000b8000
サブ関数のiのメモリアドレスは0xc0000b8008

main関数で宣言した時のiとサブ関数の引数で受け取ったiではメモリのアドレスが少し違いますね!
このことから関数の引数は、受け取った値のコピーであることがわかります。

あくまで値をコピーしただけなので、例えばサブ関数内で値を更新してもmain関数の値は更新されません。
ただポインタ型の変数を渡すと、挙動が変わってきます。
どう変わるのか含めて、コードで確認していきましょう。

main.go
package main

import (
	"fmt"
)

func main() {
	i := 1
	updateArgInt(i)
	fmt.Printf("引数がint型の時、サブ関数の処理後、iの値は%v\n", i)
	updateArgPointer(&i)
	fmt.Printf("引数が*int型の時、サブ関数の処理後、iの値は%v\n", i)
}

func updateArgInt(i int) {
	i += 1
}

func updateArgPointer(i *int) {
	*i += 1
}
result
引数がint型の時、サブ関数の処理後、iの値は1
引数が*int型の時、サブ関数の処理後、iの値は2

int型を引数にした時は、iの値は変わりませんでしたが、*int型の時はサブ関数内の処理が実行されて値が変わりました。
何が起きているか図で確認してみましょう。

まずは、引数を渡す部分を見ていきます。
intが引数の時は、1という値が*intが引数の場合はメモリアドレスの値がコピーされます。

次にi += 1を実行した時の挙動を見てきます。
まずはintが引数の時の挙動を確認します。

intが引数の場合は、サブ関数内の値が変更されるだけです。

まずは*intが引数の時の挙動を確認します。

*int(ポインタ型)を渡すと、その参照先の値(main関数内の変数)を変更することができます。

補足:メソッドレシーバの場合
メソッドのレシーバについても引数同様値渡しとなっております。
以下のコードで確認できます。

main.go
package main

import (
	"fmt"
)

type origInt int

func main() {
	oi := origInt(1)
	fmt.Printf("main関数のoiのメモリアドレスは%v\n", &oi)
	oi.confirmAdress()
}

func (oi origInt) confirmAdress() {
	fmt.Printf("レシーバのoiのメモリアドレスは%v\n", &oi)
}

result
main関数のoiのメモリアドレスは0xc0000b8000
レシーバのoiのメモリアドレスは0xc0000b8008

以上、サブ関数を使った挙動を確認しました!
ポインタの理解も深まってポインタはもう怖くないとは思うのですが、もう少しだけ話を広げてみます。
これまではintで動作確認してきましたが、実はsliceやmapでは異なる挙動を示すんですよね。。
ここが結構分かりにくい。ということで、次の章からはsliceやmapの挙動を見ていきます!

sliceとポインタ

sliceの構造

まずは、コードで動きを確認しましょう。

main.go
package main

import (
	"fmt"
)

func main() {
	s := []int{1, 2, 3}

	fmt.Printf("最初のslice:%v\n", s)
	updateArgSlice(s)
	fmt.Printf("updateArgSlice後のslice:%v\n", s)
}

func updateArgSlice(s []int) {
	for k, v := range s {
		s[k] = v * 10
	}
}
result
最初のslice:[1 2 3]
updateArgSlice後のslice:[10 20 30]

sliceについては、サブ関数の引数がポインタ型でないのに値が変わりましたね(やっぱりポインタ怖い笑)。

これを理解するためにsliceについて、少し理解を深めようと思います。
sliceは配列のアドレス配列の長さ配列のキャパシティの情報をもっています。
ここがslice特有の挙動を示す、ポイントになってきます。

ちょっと捉えづらいですね。。詳しく見ていきます。

sliceのもつ情報は基本的に配列が元になっているため、配列についても少し言及しようと思います。
配列とsliceは羅列したデータを保持するという点で似ていますが、可変かどうかという点で異なります。

配列はその大きさも型として考えます。例えば、[3]int(長さ3の配列)と[4]int(長さ4の配列)は型が異なります。
ちなみに長さが異なる配列への型変換はできません。
一方sliceの長さは可変です。appendを使えばsliceに要素を追加できます。

sliceと配列の違いがわかったところで、sliceの構造に話を戻します。sliceは配列のアドレス、長さ、キャパシティをもっているというお話でした。
図で表すとこんな感じです。

まあそのままですね笑

sliceの構造について、理解ができたところでこの章の冒頭の関数に話を戻します。
サブ関数の引数がsliceでもポインタ型でも値が変更されました。
この挙動の理由はsliceが配列のポインタを保持しているためです。

図で見るとこんな感じです。

sliceの要素を変更すると、参照先である配列の要素が変更されます。
main関数とサブ関数で参照している配列は同じなので、結果としてサブ関数の値の更新がmain関数にも適用されていたんですね!

これでsliceは終わり。。としたいところですが、もう少し続きます。
次にsliceの長さを変えた時のことを考えてみます。
実はsliceの長さを変えると、main関数にその更新が適用されなくなります。
理由は、、少し長くなってきたので、次の章で確認してきましょう。

append時のslice

まずは、コードで確認していきます。

main.go
package main

import (
	"fmt"
)

func main() {
	s := []int{1, 2, 3}

	fmt.Printf("最初の変数s:%v\n", s)
	updateArgSlice(s)
	fmt.Printf("サブ関数処理後の変数s:%v\n", s)
}

func updateArgSlice(s []int) {
	s = append(s, 4)
	s[0] = 10
}
result
最初の変数s:[1 2 3]
サブ関数処理後の変数s:[1 2 3]

今回はサブ関数内での変更は、main関数内のsliceに適用されませんでした。
sliceの長さを変えると、何が起きるのでしょうか?
図で確認してみましょう!

配列は長さも型に含まれるというお話がありました。
そのため、slice長さが変わると新しい配列を作らざるを得ません。
結果的にサブ関数内のsliceの参照先が変化し、サブ関数内での変更がmain関数に適用されませんでした!

以上、sliceとポインタについて見ていきました!
挙動だけ見ると複雑そうですが、内部で起きていることは割とシンプルでした!

補足:sliceの定義
goのruntimeにて定義されているsliceを少し見てみましょう。
深堀はしませんが、配列のポインタ、長さ、キャパシティを保持していることがわかります。

slice.go
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

mapとポインタ

mapについてはもコードで動きを確認してみましょう。

main.go
package main

import (
	"fmt"
)

func main() {
	m := map[string]int{
		"a": 1,
		"b": 2,
		"c": 3,
	}

	fmt.Printf("最初のmap:%v\n", m)
	updateArgMap(m)
	fmt.Printf("updateArgMap後のmap:%v\n", m)
}

func updateArgMap(m map[string]string) {
	m["a"] *= 100
}
result
最初のmap:map[a:1 b:2 c:3]
updateArgMap後のmap:map[a:100 b:2 c:3]

関数内でmapを更新すると、main関数内で値が更新されています。
slice同様mapも値を格納している対象のポインタをもっています。

Goのソースコード内でのmapの説明を見てみましょう。

A map is just a hash table. The data is arranged into an array of buckets.
(訳)
mapは単なるハッシュテーブルです。データはバケットの配列です。

mapの場合はバケットのメモリアドレスを保持しています。
key-valueというデータの特性はありますが、基本的にはsliceと考え方は同様です。
ただし、マップは長さの制限等ないため、sliceと異なり関数内で要素を追加すると、main関数にて値が更新されます。

補足:mapの定義
goのruntimeにて定義されているmapを少し見てみましょう。
slice同様深堀はしませんが、mapの長さ、バケットのアドレス等を保持していることがわかります。

map.go
type hmap struct {
	// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
	// Make sure this stays in sync with the compiler's definition.
	count     int // # live cells == size of map.  Must be first (used by len() builtin)
	flags     uint8
	B         uint8  // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
	noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
	hash0     uint32 // hash seed

	buckets    unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
	oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
	nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

	extra *mapextra // optional fields
}

おわりに

最後まで読んでいただきありがとうございました!
僕のように「ポインタがよくわからん!」となっている方の参考になれば幸いです。

参考文献

書籍

記事

5
8
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
5
8