Carpe Diem

備忘録

GoのSliceを関数の引数に渡した時の挙動

概要

Sliceの構造を始め、関数で呼び出した場合の挙動やappendなどsliceを操作した場合どうなるかをまとめました。

環境

Sliceの構造

Sliceは以下のような3つの要素でなりたっています。

  • 配列へのポインタ
  • length
  • capacity

図示すると以下です。

f:id:quoll00:20170926151254p:plain

ref: Go Slices: usage and internals - go.dev

関数の引数として渡した時

Goの関数の引数は基本的に値渡しです。これはsliceも同じです。なので渡した変数のポインタは異なります。

func main() {
    s := []int{1, 2, 3}
    fmt.Printf("%p\n", &s)  // 0x1040a0b0

    someFunc(s)
}

func someFunc(s []int) {
    fmt.Printf("%p\n", &s)  // 0x1040a0c0 で異なる
}

https://play.golang.org/p/CELzuPAwna

ではなぜsliceを渡しても同じ要素にアクセスできるのでしょうか?
理由は値渡しされたsliceの持っている配列へのポインタが同じだからです。

func main() {
    s := []int{1, 2, 3}
    fmt.Printf("%p\n", &s)  // 0x1040a0b0
    fmt.Printf("%p\n", &s[0])  // 0x10410020

    someFunc(s)
}

func someFunc(s []int) {
    fmt.Printf("%p\n", &s)  // 0x1040a0c0
    fmt.Printf("%p\n", &s[0])  // 0x10410020 で同じ
}

https://play.golang.org/p/vxHtB9sg9N

これを理解していれば、

  • 配列の要素の値を書き換えると呼び出し元のsliceにも影響する
  • 非常に大きなsliceを引数で渡しても大量のコピーが走ることはない

ということが分かります。
あくまでslice自体が値渡しで、中の配列は同じものを見ています。

appendの挙動

capacityが足りない時

capacityが足りない時のappendでは配列の再配置が生じます。

func main() {
    s := []int{1, 2, 3}
    fmt.Printf("%p\n", &s)  // 0x1040a0b0
    fmt.Printf("%p\n", &s[0])  // 0x10410020

    s = append(s, 4)
    fmt.Printf("%p\n", &s)  // 0x1040a0b0
    fmt.Printf("%p\n", &s[0])  // 0x10454000 新しい配列を指す
}

https://play.golang.org/p/GB9MJvN69T

関数の引数で渡した場合

なので関数内でappendした場合は呼び出し元のsliceが見ている配列とは違うものになります。そのため関数内の配列は増えてますが、呼び出し元のsliceは変わりません。

func main() {
    s := []int{1, 2, 3}
    fmt.Printf("%p\n", &s[0])  // 0x10410020

    add(s)
    fmt.Println(s)  // [1 2 3]
    fmt.Printf("%p\n", &s[0])  // 0x10410020
}

func add(s []int) {
    fmt.Printf("before: %p\n", &s[0])  // 0x10410020

    s = append(s, 4)
    fmt.Println(s)  // [1 2 3 4]
    fmt.Printf("after: %p\n", &s[0])  // 0x10454020 とポインタが変わる
}

https://play.golang.org/p/Q6OuxDTxvG

中身の配列が新しいものになってますね。

capacityが十分なとき

capacityに余裕がある時のappendは再配置をしないので、中身が変わることはありません。

func main() {
    s := make([]int, 3, 4)
    s[0], s[1], s[2] = 1, 2, 3
    fmt.Printf("%p\n", &s)  // 0x1040a0b0
    fmt.Printf("%p\n", &s[0])  // 0x10410020

    s = append(s, 4)
    fmt.Printf("%p\n", &s)  // 0x1040a0b0
    fmt.Printf("%p\n", &s[0])  // 0x10410020 同じ配列を指す
}

https://play.golang.org/p/LLbeLmcB54

関数の引数で渡した場合

では関数内で実行するとどうなるかというと、ちょっと変わった挙動になります。

func main() {
    s := make([]int, 3, 4)
    s[0], s[1], s[2] = 1, 2, 3
    fmt.Printf("%p\n", &s[0])  // 0x10410020

    add(s)
    fmt.Println(s)  // [1 2 3]
    fmt.Printf("%p\n", &s[0])  // 0x10410020 同じ配列を指す
}

func add(s []int) {
    fmt.Printf("before: %p\n", &s[0])  // 0x10410020

    s = append(s, 4)
    fmt.Println(s)  // [1 2 3 4]
    fmt.Printf("after: %p\n", &s[0])  // 0x10410020 同じ配列を指す
}

https://play.golang.org/p/ntq4D75py2

同じ配列を指しているのにも関わらず、printされる値が異なります。
この理由は呼び出し元のsliceのlenが更新されていないためです。
add()関数内ではappendによってlenも4になりますが、呼び出し元のsliceは別オブジェクトであり、3のままなのでこういったことが起きます。

この場合呼び出し元も更新したければ、

func main() {
    s := make([]int, 3, 4)
    s[0], s[1], s[2] = 1, 2, 3
    fmt.Printf("%p\n", &s[0])

    add(s)
    s = s[:cap(s)]  // 追記
    fmt.Println(s)  // [1 2 3 4]
    fmt.Printf("%p\n", &s[0])
}

https://play.golang.org/p/vBwTvpWBdk

のように

s = s[:cap(s)]

を挟むといけます。
s[head:tail]という形でsliceを操作する場合、tailcapまでという言語仕様になっています。
ただし直接s[tail-1]みたいな要素へのアクセスはできません。

まとめ

Sliceの挙動として以下を覚えておくと良いです。

  • sliceは配列へのポインタを持っている
  • 引数で渡したsliceは値渡しなので別物。でも中のポインタが指している配列は同じ
  • appendなどで再配置が起きると指している配列が変わる。

ソース