Goのarrayとsliceを理解するときがきた

  • 18
    いいね
  • 0
    コメント

対象

sliceしか使わないのでarrayのことは知らなくてもいいと思っているGopher

内容

arrayを理解するとsliceをもっと理解できる

参考

以下のページを主に参考にしたのでちゃんと知りたい場合はこっちを読んだほうがいい

  1. The Go Blog - Go Slices: usage and internals

  2. The Go Blog - Arrays, slices (and strings): The mechanics of 'append'

きっかけ

サイズを指定して宣言したsliceが引数の型チェックに引っかかり悩む、というおそらくは初歩的なつまづきをしてしまった。

func main() {
    s := [3]int{}
    takeSlice(s)
    // Error: cannot use s(type [3]int) as type []int in argument to takeSlice
}

func takeSlice(s []int) {}

解決法はstackoverflowですぐに見つかった

Passing an array as an argument in golang

takeSlice(s[:]) // これで渡せる

が、この一件でGoにおけるsliceとarrayについて自分がちゃんと理解せずやってることに気がついた。
これまで「arrayは固定長。sliceはarrayを使いやすくしたラッパーみたいなもの」くらいの認識しかせず、他の言語における配列と同じようにsliceをこれまで使ってきたが、ちょうど良い機会なので改めて勉強することにした。

「サイズを指定したslice」?

s := [3]int{} のようにサイズを指定して宣言した場合、それはsliceではなくarray型である。「サイズを指定したslice」ではなくarrayだ。

a := [3]int64{1, 2, 3} // sliceではなくarray

理解を進めるにはまず、「arrayもsliceも似たようなものでしょ」という考えを捨て去り両者の実体を知るところから始める必要がある。

arrayの実体

arrayは実体的な値である。
Goにおけるarrayはメモリ上にある値を参照するものではなく値そのものとして取り扱う。値そのもの、というのは例えばint型変数を扱うときにわざわざ参照とか考えないように、arrayも[3]intならintが3つ並んだとして考えるという感じ。

Screen Shot 0029-03-12 at 01.05.38.png

例えばarray変数を別の変数に代入するときは同じデータを持った新たなarrayがallocateされる。結果、それぞれのarrayが持つ値はメモリ上で別の位置に存在し、一方の値を変えてもそれがもう一方に影響することはない。

a := [3]int{1, 2, 3}
b := a

// メモリ上で別の位置にある
fmt.Println(&a[0]) // 0xc8200122e0
fmt.Println(&b[0]) // 0xc820012300

b[0] = 0
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [0 2 3]

Screen Shot 0029-03-12 at 01.10.15.png

もう一つ重要なのは、arrayは「一定のサイズを持った」データ構造であり、「一定のサイズ」まで含め型情報として扱われるということ。

だから[3]intと[4]intは別の型であり互いに代入することはできない。

a := [3]int{}
b := [4]int{}
b = a
// Error: cannot use a(type [3]int) as type [4]int in assignment

sliceの実体

sliceはarrayへの参照を持つデータ構造だ 1
slice変数をつくってもそれ自体が値を保持しているわけではない。
だからGoのコードで何気なくsliceを作ったときもその裏側では実体としてのarrayが作られている。

s := []int{1, 2, 3}

Screen Shot 0029-03-12 at 01.44.53.png

sliceの値を書き換える操作は参照するarrayの値を書き換える操作と言える。

s[0] = 0

Screen Shot 0029-03-12 at 11.40.11.png

このarrayとsliceの関係を理解していればコード上のプレイヤーが増えても迷わなくなる。
例えばあるsliceを別の変数に代入したところで両者が参照している実体は一つのarrayだ。

s1 := []int{1,2,3}
s2 := s1
s2[1] = 0

// 同じarrayを参照しているから当然の結果
fmt.Println(s1) // [1 0 3]
fmt.Println(s2) // [1 0 3] 

Screen Shot 0029-03-12 at 10.22.47.png

引数に渡したsliceへの変更は呼び出し元にも影響する。
takeSliceが受け取るsliceは呼び出し元のsliceと同じarrayを参照しているからだ。

s := []int{1,2,3}
fmt.Println(&s[0]) // 0xc8200122e0
takeSlice(s)
fmt.Println(s) // [1 0 3] 

func takeSlice(s []int) {
    fmt.Println(&s[0]) // 0xc8200122e0
    s[1] = 0
}

arrayを渡した場合はそうではない。
takeSliceが受け取るarrayは呼び出し元のarrayとは別のものだ。

s := [3]int{1, 2, 3}
fmt.Println(&s[0]) // 0xc8200122e0
takeSlice(s)
fmt.Println(s) // [1 2 3]

func takeSlice(s [3]int) {
    fmt.Println(&s[0]) // 0xc820012300
    s[1] = 0
}

slicingでarrayをスライスする

"slicing"でsliceとarrayの理解はより深まる。
slicingというのはslice操作でこう書くやつ s[0:2] のこと。その名の通りarrayやsliceを切り取って別のsliceを作る操作だ。

// arrayをslicing
a := [3]int{1, 2, 3}
s := a[0:2] // [1 2]

// sliceをslicing
s = []int{1, 2, 3}
s = s[0:2] // [1 2]

冒頭のコードで出てきたようにslicingはイディオム的にa[:]と書くこともでき、arrayの「端から端まで」を切り取ったsliceを作ることができる。「切り取った」と書くとわかりづらいがこの場合もsliceがarrayを参照しているのに変わりはない。

a := [3]int{1, 2, 3}
s := a[:]      // [1 2 3]
a[0] = 0
fmt.Println(s) // [0 2 3]

slice変数が裏でarrayを参照することを考えると以下のことが言えるだろう。

// これと
s := []int{1, 2, 3}

// これは同義
a := [3]int{1, 2, 3}
s := a[:]

また、sliceに対するslicingは同じarrayを参照する新しいsliceを作る操作と考えることができる。

s1 := []int{1, 2, 3, 4, 5}
s2 := s1[2:5] // [3 4 5]

Screen Shot 0029-03-12 at 10.40.21.png

だから以下の魔法のようなコードを書くこともできる。
一度目のslicingで元のarrayのインデックス0-1を切り取り、二度目のslicingではsliceからそれ自体よりも大きいsliceを切り取っている(ように見える)。不思議なコードに見えるがarrayの参照範囲を変えただけの話だ。

a := [3]int{1, 2, 3}
s := a[0:2]
fmt.Println(s) // [1 2]
s = s[0:3]
fmt.Println(s) // [1 2 3]

appendの裏側

sliceに要素を追加するときはappendを使用する。そうすることでsliceを「可変長な」配列として扱っているつもりだったが、sliceが「一定のサイズを持った」arrayを参照していることを考えるとおかしな話でもある。
ではどのようにしてsliceを可変長のように扱えるようにしているかと言えば、単にメモリ上で新たなarrayをallocateしているようだ。そうでなければそもそもs = append(s, 1)のようにappendの結果を変数に代入する必要がない。

s := []int{1}
t := append(s, 2)

// sとtは別のarrayを参照する
fmt.Println(&s[0]) // 0xc82000a310
fmt.Println(&t[0]) // 0xc82000a330

// だから一方を変更しても影響しない
t[0] = 0
fmt.Println(s) // [1]
fmt.Println(t) // [0 2]

Go Blogではappendが内部でどのようなことをしているか説明しているので読むとずっと参考になる。

Arrays, slices (and strings): The mechanics of 'append' - Append: An example



  1. sliceは参照そのものではなく、参照とsliceのlengthとcapacityをもったstructのようなものと説明される。詳しいことは参考ページ2を。