おひとり

できる限りひとりで楽しむための情報やプログラミング情報など。

【Go】スライス変数の仕組み -スライスヘッダについて

TL;DR

  • スライス変数はスライスヘッダと呼ばれるデータ構造になっている。
  • スライスヘッダにはptr(0番目の要素へのポインタ)、length、capacityが含まれている。
  • スライスを関数に渡すとき、スライスヘッダがコピーされて渡される。

スライス変数の中身

スライス変数には以下の3つの値が含まれています。

  • ptr : 0番目の要素へのポインタ
  • len:そのスライスの要素数(長さ)
  • cap : そのスライスの容量(キャパシティ)

これらは言うなればスライスの「メタ情報」。
そのため「スライスヘッダ(slice header)」と呼ばれています。(参考リンクより)

スライスヘッダを図にしてみます。

f:id:hitoridehitode:20200707230722p:plain
スライスヘッダの概念図

ここで注意すべきは、スライス変数そのものは「値」ということ。
つまり、スライスは先頭要素へのポインタ「ではない」。

これはスライス変数を関数に渡す際により意識すべき点ですね。
※特にC言語に慣れている人は注意。

スライス変数を関数に渡す

先ほど、スライス変数はすなわち「スライスヘッダ」であると書きました。

スライスを関数に渡すプログラムを見てみましょう。

package main

import "fmt"

func appendNum(s []int, n int) []int {
    for i := 0; i < n; i++ {
        s = append(s, i)
    }
    fmt.Printf("ptr=%v, len=%v, cap=%v   <--- appendNum()\n", &s[0], len(s), cap(s))
    return s
}

func main() {
    s := make([]int, 1, 16) // ①

    fmt.Printf("ptr=%v, len=%v, cap=%v\n", &s[0], len(s), cap(s))
    appendNum(s, 10) // ②
    fmt.Printf("ptr=%v, len=%v, cap=%v\n", &s[0], len(s), cap(s))
    fmt.Println(s) // ③
}

①スライスを作成します。ここで、0番目の要素のポインタ(アドレス)を確認したいため、要素数を1にしておきます。
②appendNumI()関数を使ってこのスライスに値を追加します。キャパシティである16を超えないようにします。
③スライスの中身がどうなっているか表示してみます。

さて、このプログラムの出力は次のようになります。

ptr=0xc000024080, len=1, cap=16
ptr=0xc000024080, len=11, cap=16   <--- appendNum()
ptr=0xc000024080, len=1, cap=16
[0]

appendNum()で要素を追加したのに、main()側でその追加が反映されていないように見えます。

ここで注目すべきは以下の点。

  • キャパシティの拡張が行われていないため、0番目のポインタ(ptr)は変化していない。(reallocationが発生しない。)
  • main()内のlenは変化していない。(スライスヘッダは値渡しなので)
  • その結果、main()ではスライスに何一つappendされていないように見える。(lenも増えていないし、中身も初期の「0」だけ。)

この原因は、スライスヘッダが値渡しであることですね。

f:id:hitoridehitode:20200707235350p:plain
スライスヘッダは値渡し。

つまり、スライスを関数に渡すor受け取って処理する場合には以下に注意する必要があるということ。

  • スライスのデータ(参照しているメモリ)は参照で渡される。
  • スライスヘッダは値渡しである。

ちなみに、先ほどのプログラムを期待通りに動かすには、appendNum()の戻り値であるスライスヘッダを代入する必要が。

func main() {
    s := make([]int, 1, 16)

    fmt.Printf("ptr=%v, len=%v, cap=%v\n", &s[0], len(s), cap(s))
    s = appendNum(s, 10) // <------------代入しておく
    fmt.Printf("ptr=%v, len=%v, cap=%v\n", &s[0], len(s), cap(s))
    fmt.Println(s)
}

こうすることで、main()のスライスヘッダの値も最新に更新されるということですね。
すなわち、出力結果は以下のようになります。

ptr=0xc0000b6000, len=1, cap=16
ptr=0xc0000b6000, len=11, cap=16   <--- appendNum()
ptr=0xc0000b6000, len=11, cap=16
[0 0 1 2 3 4 5 6 7 8 9]

期待した通り、呼び出し元のスライスからも追加したデータが見えました。

さらに、次のようにスライスヘッダをポインタで渡す方法も。

package main

import "fmt"

func appendNum(s *[]int, n int) { // <----- ポインタで受け取る
    for i := 0; i < n; i++ {
        *s = append(*s, i)
    }
    fmt.Printf("ptr=%v, len=%v, cap=%v   <--- appendNum()\n", &(*s)[0], len(*s), cap(*s))
}

func main() {
    s := make([]int, 1, 16)

    fmt.Printf("ptr=%v, len=%v, cap=%v\n", &s[0], len(s), cap(s))
    appendNum(&s, 10) // <------- ポインタで渡す
    fmt.Printf("ptr=%v, len=%v, cap=%v\n", &s[0], len(s), cap(s))
    fmt.Println(s)
}

しかしこちらはやや難解。
通常は代入を使った方がシンプルで良いですね。

まとめ

  • スライス変数はスライスヘッダである。
  • スライスヘッダにはptr(0番目の要素へのポインタ)、len、capが含まれている。
  • スライスを関数に渡すときはデータは参照で渡されるが、スライスヘッダそのものは値渡しである。

Go言語のスライスは挙動が難解ですね。
1つ1つ理解しながら利用していく必要があります。

参考リンク

f:id:hitoridehitode:20191003205941j:plain:w100