こんにちは!Backend Engineerのぺりーです。
tenntenn さん主催の Gopher塾 #4 私も解説できるポインタ DAY1 に学生ブログ枠で参加しました。
Gopher塾は、18歳以上を対象としたtenntennによるGoに関する有料の講義です。第4回目はポインタについて解説します。「理解した=他者に解説ができる」という観点から解説ができるレベルになることを目指して講義を行います。
なお、無償でGopher道場などで学習することも可能です。
* 説明のために一部改変しました
ref: https://tenntenn.connpass.com/event/275805/
目次
- 本ブログのゴール
- ポインタとは
- ポインタの必要性
3.1. 関数
3.2. メソッド - 値レシーバとポインタレシーバそれぞれの利点
- ポインタのポインタ
- 内部でポインタを使っているデータ型
- 最後に
- 本ブログのゴール
突然ですが、下記Goのコードの実行結果は次のうちどれになるでしょうか。
- コンパイルエラー
- パニック
- <nil>が表示される
- その他
答えと解説は 5章のポインタのポインタで!
package main
import "fmt"
func main() {
type T *****************int
var t *******************T
fmt.Println(&t)
}
この実行結果とその理由を自信を持って説明できるようになることを本ブログのゴールに設定します。
2. ポインタとは
ポインタ型は変数の格納されている場所(= アドレス = ポインタ)を表す型であり、型リテラルで記述されます
package main
import "fmt"
func main() {
x := 15
fmt.Printf("%p\n", &x) // 0xc00001c030
s1 := "hogehoge"
fmt.Printf("%p\n", &s1) // 0xc000014270
y := 10
fmt.Printf("%p\n", &y) // 0xc00001c038
s2 := "fugafuga"
fmt.Printf("%p\n", &s2) // 0xc000014280
}
型リテラルとは型の具体的な定義を書き下した型の表現方法で、
コンポジット型やポインタ型などを表現するために使い、
変数定義やユーザー定義型などで使用されます。
リテラルとは識別子(名前)が付与されていないもの
ポインタの取得には &演算子
を使い、ポインタから変数の参照(デリファレンス)には *演算子
を使います
3. ポインタの必要性
他の関数で変数の値を変更する際にポインタが必要になります。
どんな型の値であっても引数は例外なくコピーされます。
3.1. 関数
引数に渡す際にコピーが発生するため、たとえ同じ変数名でも影響は受けません。
package main
import "fmt"
func main() {
x := 100
f(x)
fmt.Println(x) // 100
}
func f(x int) {
x = 200
}
そこで変数の格納されている場所(= アドレス = ポインタ)を伝えることで他の関数で変数の値を間接的に参照し、変更することができます
package main
import "fmt"
func main() {
x := 100
f(&x)
fmt.Println(x) // 200
}
func f(xp *int) {
*xp = 200
}
ただし、ポインタも値であるため、ポインタ自体もコピーされます
3.2. メソッド
メソッドはレシーバと紐づけられた関数で、データとそれに対する操作を紐づけるために使用します。
package main
type MyInt int
func (n MyInt) Inc() { n++ }
func main() {
var n MyInt
println(n) // 0
n.Inc()
println(n) // 0
}
レシーバを変更するにはポインタレシーバにする必要があります
func (n *MyInt) Inc() { *n++ }
4. 値レシーバとポインタレシーバそれぞれの利点
値レシーバにする利点はレシーバに変更を加えないため、副作用を生まないことです。
一方、ポインタレシーバにする利点はレシーバに変更を加えることができることや、メソッド呼び出し時の値のコピーがポインタ分だけで済むことです。
5. ポインタのポインタ
冒頭の答え合わせをしましょう
package main
import "fmt"
func main() {
type T *****************int
var t *******************T
fmt.Println(&t) // 0xc000012028
}
実行結果は 4. その他 です
https://go.dev/play/p/kfPtlLoOvKm
ポインタも型リテラルで表記でき、変数のアドレスを持っていることには変わりません。
ポインタ型は変数に保持されるデータの型がポインタなだけです。
6. 内部でポインタを使っているデータ型
内部でポインタが用いられているデータ型はコンポジット型の一部です
- スライス
- マップ
- チャネル
ここではスライスを取り上げてみます。
スライスはベースとなる配列が存在していて、その一部を切り出したデータ構造です。
要素の型は全て同じで、要素数は型情報に含みません。
type slice struct {
array unsafe.Pointer
len int
cap int
}
https://github.com/golang/go/blob/master/src/runtime/slice.go#L15-L19
引数にスライスを渡すとスライスの構造体のコピーが走りますが、コピーされたポインタからベースの配列を参照するため、参照型のように見えるというわけです。
package main
func main() {
ns := []int{10, 20, 30}
ns2 := ns
ns[1] = 200
println(ns[0], ns[1], ns[2]) // 10 200 30
println(ns2[0], ns2[1], ns2[2]) // 10 200 30
}
https://go.dev/play/p/x7hdjw7sToI
連続した値のコピーが走るわけではなく、スライスのベースとなっている配列のポインタのコピーが走るため、引数にスライスを渡しても問題ないことが多いです。
問題が発生するケースはappendで返り値が返却される理由について考えてみることで理解できます。
appendが引数に渡しているのはあくまで構造体なのでlenやcapを変更しても呼び出し元には何の影響も与えません。
そのため、返り値にスライスを返却する必要があります。
下記の例でappendした際にベースの配列のポインタが変わっていくのをみてみます。
package main
import (
"fmt"
)
func main() {
a := []int{10, 20}
fmt.Println(a, cap(a)) // [10 20] 2
b := append(a, 30)
a[0] = 100
fmt.Println(b, cap(b)) // [10 20 30] 4
c := append(b, 40)
b[1] = 200
fmt.Println(c, cap(c)) // [10 200 30 40] 4
}
https://go.dev/play/p/E6QMaWExqtG
スライスaが定義された時点のスライスのcapは2だったので、ベースになっている配列のlengthは2です。
スライスbが定義されるとき、スライスaに30を追加しようとしています。
ベースの配列の値を更新すれば良いのですが、ベースの配列のlengthが足りません。
配列は拡張できないため、内部で新しい配列を定義し値をコピーする必要があります。
以上を踏まえると、スライスaとはじめのappendで返却されたスライスbのベース配列は異なっていることがわかります。
そのため、スライスaを更新してもbには何の影響も与えません。
スライスはcapが不足した際に一定までは2倍のcapを確保するため、bのスライスのcapは4になります。
https://github.com/golang/go/blob/master/src/runtime/slice.go#L180-L187
スライスcが定義されるとき、ベースの配列のlengthが十分であるため、ベースの配列の値を更新するだけで済みます。
スライスbとスライスcは内部で同じ配列のアドレスを参照しているため、スライスbの値を更新することで、スライスb内部の値が更新され、スライスcの値も更新されることになります。
7. 最後に
今回紹介したGopher塾では今後ジェネリクスや、静的解析、並行処理などの講義が予定されているようです。
有料の講義にはなりますが、質問もできて、とても勉強になるので参加してみてはいかがでしょうか?
(学生であれば抽選枠やブログ枠があり、無料で参加することができます。)