🔩 ねじき教室 Go と Web の教室

slice の中身

読了目安 約4分

slice がポインタと長さと容量の 3 つ組であることを知り、関数に渡したときに中身が共有される罠を、エディタで実際に踏んで確かめます。

この章の目次

Web のコードでは、「関数に渡した slice が、なぜか呼び出し元でも変わっていた」という形で、slice の仕組みが表に出てきます。

値そのものと、値の置き場所

Go の変数は、メモリのどこかに置かれています。 その置き場所を指し示す値をポインタと呼びます。

住所を書いた紙をイメージしてください。 紙をコピーして渡しても家は 1 軒のままなので、受け取った側が家に手を入れると渡した側から見ても変わっています。

intstring は家そのものを丸ごと渡すので、渡した先で変えられても元は無事です。 slice はこの「住所を書いた紙」を含んでいて、そこが罠になります。

slice の正体

slice の実体は、3 つの値を束ねた小さな構造です。

  • 裏にある配列の、slice の始点を指すポインタ
  • 長さ(len):いま入っている要素数
  • 容量(cap):確保済みのメモリに入る要素数

slice の変数そのものは要素を持たず、裏の配列を指しているだけです。 s2 := s1 と代入しても配列は複製されず、住所の紙が 1 枚増えるだけで、これを浅いコピーと呼びます。

append で何が起きるか

Go
package main

import "fmt"

func main() {
	s := []int{}
	for i := 0; i < 10; i++ {
		s = append(s, i)
		fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
	}
}

fmt.Printf% の位置に後ろの引数を埋め込んで表示します。 %d は整数、%s は文字列、%v はどんな型でも埋まり、改行は \n を自分で書きます。

容量に余裕があるうちは、append は同じ配列の次の位置に書くだけです。 容量が尽きると、より大きい配列を確保して全要素をコピーします。 実行結果で cap が 2 倍に跳ねた行が、その瞬間です。

競プロの計算量の直感で言えば、append 1 回はならし O(1) です。

make で先に確保する

確保し直しが起きるのは、容量が足りないときだけです。 要素数の見当が付いているなら、make で最初から容量を渡しておけば起きません。

Go
s := make([]int, 0, 100) // 長さ 0、容量 100 の slice

第 2 引数が長さ、第 3 引数が容量で、これなら 100 件入れても確保し直しは起きません。 第 3 引数は省略でき、そのときは容量が長さと同じになります。

関数に渡したときに共有される

Go
package main

import "fmt"

func double(nums []int) {
	for i := range nums {
		nums[i] *= 2
	}
}

func main() {
	scores := []int{10, 20, 30}
	double(scores)
	fmt.Println(scores)
}

関数に slice を渡すときにコピーされるのは 3 つ組だけで、裏の配列は共有されたままです。 関数の中で nums[i] を書き換えると、呼び出し元の scores も変わります。

データベースから読んだ投稿一覧を「並び替えるだけ」のつもりで渡すと、呼び出し元の一覧も並び替わります。 渡した側は元の順序が残っているつもりなので、原因が別の場所に見えるバグになります。

切り出した slice も同じ配列を指す

共有は、関数に渡さなくても起きます。 次のコードの出力を予想してから、実行してください。

Go
package main

import "fmt"

func main() {
	a := []int{1, 2, 3, 4}
	b := a[:2]        // a の先頭 2 要素を切り出す
	b = append(b, 99) // b に 1 つ追加する

	fmt.Println("a:", a)
	fmt.Println("b:", b)
}

a の 3 番目が 99 に書き換わります。 ba と同じ配列を指していて、長さ 2 に対して容量は 4 あるので、append は確保し直さず共有配列の 3 番目(つまり a[2])に書き込むからです。

たちが悪いのは、容量次第で挙動が変わることです。 容量が尽きていれば新しい配列が確保されて a は無事なので、同じコードが要素数によって壊れたり壊れなかったりします。

共有を切るには copy

copy(dst, src) は、slice の中身を別の slice へ写す組み込み関数です。 短いほうの長さぶんだけコピーします。

Go
package main

import "fmt"

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

	b := make([]int, 2) // 長さ 2 の、まったく別の配列を確保する
	copy(b, a)          // a の先頭 2 要素を写す

	b[0] = 100
	fmt.Println("a:", a)
	fmt.Println("b:", b)
}

b は自前の配列を持つので、どういじっても a は変わりません。 「渡す前に複製する」のが、共有の罠を避ける基本の手です。

試してみよう

さきほど踏んだ「append が a を書き換える」罠を、自分の手で直してください。

Go
package main

import "fmt"

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

	// b を「a と配列を共有しない、独立した slice」に変える
	b := a[:2]

	b = append(b, 99)
	fmt.Println("a:", a) // [1 2 3 4] のままにしたい
	fmt.Println("b:", b) // [1 2 99] にしたい
}

ヒント: さきほどの makecopy の組み合わせです。 直せたら cap(b) も出力して、なぜ共有が切れたのかを自分の言葉で説明できるか確かめてください。