Goのslice: len、capとappendの落とし穴

1. 概要

sliceはGoで最も頻繁に使用されるデータ型ですが、最も検出しにくいバグの原因にもなります。理由は、複数のsliceが背後にある同じ配列を共有できるからです。その場合、appendは別のsliceのデータを上書きしてもエラーを報告しません。

この記事では、lenとcapについて説明し、appendがどのようにして新しい配列を確保するか既存の配列に書き込むかを判断するのかを解説します。またバグを避けるテクニック、すなわちfull slice expression、copy、slices.Cloneについて説明します。この記事を読み終える頃には、appendを含むコードの結果を正確に予測でき、aliasing(別名参照)のバグをキャッチするテストの書き方が分かるようになります。

例はGo 1.21以上を使用しています。このバージョン以降でslicesパッケージが利用可能になったからです。

2. sliceの実体は何か

sliceは3つのフィールドからなる小さなstructです:

  • pointer: 背後にある配列の最初の要素を指します。
  • len: アクセスできる要素の数です。
  • cap: pointerから背後にある配列の末尾までの要素の数です。

sliceを関数に渡すか別の変数に割り当てるとき、Goはこれら3つのフィールドのstructだけをコピーします。背後にある配列はコピーされません。

2.1. lenとcapを観察する

package main

import "fmt"

func main() {
	orders := []string{"A01", "A02", "A03", "A04", "A05"}
	recent := orders[1:3]

	fmt.Println(recent, len(recent), cap(recent))
}
[A02 A03] 2 4

orders[1:3]はインデックス1から3の直前までの要素を取得するので、lenは2です。capは4です。これはorders[1]の位置から配列の末尾までが4つあるからです: A02、A03、A04、A05。

2.2. 2つのslice、1つの配列

recentはordersと同じ配列を指しているため、このsliceの要素を修正すると、もう一方のsliceにも即座に表れます:

recent[0] = "CANCELLED"
fmt.Println(orders)
[A01 CANCELLED A03 A04 A05]

これは意図的な動作です。sliceをコストをかけずに切り分けるのに役立ちます。しかし、それは後のセクションの落とし穴の根源でもあります。

3. appendの仕組み

append(s, x)は次の2つのいずれかを行います:

  1. len(s) < cap(s)の場合: xを現在の配列内のs[len(s)]番目の位置に書き込み、その後len + 1を持つ新しいsliceを返します。
  2. len(s) == cap(s)の場合: より大きな新しい配列を確保し、古いデータをそこにコピーし、xを書き込み、新しい配列を指すsliceを返します。

ケース2が発生する可能性があるため、常に結果を再割り当てする必要があります: s = append(s, x)。

3.1. capが段階的に増加するのを観察する

package main

import "fmt"

func main() {
	var ids []int
	for i := 0; i < 10; i++ {
		ids = append(ids, i)
		fmt.Printf("len=%d cap=%d\n", len(ids), cap(ids))
	}
}
len=1 cap=1
len=2 cap=2
len=3 cap=4
len=4 cap=4
len=5 cap=8
len=6 cap=8
len=7 cap=8
len=8 cap=8
len=9 cap=16
len=10 cap=16

小さいsliceでは、capは満杯になるたびに通常2倍になります。大きいsliceでは、runtimeはより徐々に増加し、結果はメモリアロケータのサイズクラスに応じて丸められます。

注意: capの増加公式はruntimeの実装詳細であり、Goのバージョンを通じて変更されています。appendの後の特定のcap値に依存するコードを書かないでください。

4. 落とし穴1: appendが元のsliceのデータを上書きする

これは最も一般的なエラーです:

package main

import "fmt"

func main() {
	prices := []int{10, 20, 30, 40, 50}
	firstTwo := prices[:2]

	firstTwo = append(firstTwo, 99)

	fmt.Println("firstTwo:", firstTwo)
	fmt.Println("prices:  ", prices)
}
firstTwo: [10 20 99]
prices:   [10 20 30 40 50]

firstTwoはlen = 2ですが、cap = 5です。配列にはまだ領域があるため、appendは新しい配列を確保せず、共有配列の3番目の位置に99を書き込みます。その結果、prices[2]は30から99に変わります。

このバグは非常に見つけにくいものです。コードはpanic(パニック)せず、上書きされた値は別のモジュールでようやく現れるため、実に後になって判明することがあります。

5. 落とし穴2: 同じsliceから2回appendする

package main

import "fmt"

func main() {
	base := make([]string, 3, 4)
	base[0], base[1], base[2] = "pen", "book", "bag"

	cartA := append(base, "laptop")
	cartB := append(base, "phone")

	fmt.Println("cartA:", cartA)
	fmt.Println("cartB:", cartB)
}
cartA: [pen book bag phone]
cartB: [pen book bag phone]

baseにはちょうど1つの空きスロットがあります。最初のappendは"laptop"をそこに書き込みます。2番目は"phone"を同じスロットに書き込みます。cartAとcartBは両方とも同じ配列を指すため、"laptop"は消えました。

baseがcap = 3を持っていれば、両方のappendは独立した配列を確保し、期待通りの結果が得られるでしょう。同じコードでも、capによって動作が変わります。これが、このバグが時々だけ現れる理由です。

6. 落とし穴3: 関数内のappendがcallerのsliceを変更しない

package main

import "fmt"

func addItem(items []string, item string) {
	items = append(items, item)
}

func main() {
	cart := make([]string, 0, 10)
	addItem(cart, "pen")

	fmt.Println(len(cart), cart)
	fmt.Println(cart[:1])
}
0 []
[pen]

関数addItemはslice structのコピーを受け取ります。appendは共有配列に"pen"を書き込みました。まだcapが残っているからです。しかし新しいlenは局所変数itemsにだけ割り当てられました。mainの変数cartはlen = 0のままです。データは配列に存在していますが、意図的にcart[:1]に再スライスするときだけそれが見えます。

修正方法は新しいsliceを返すことです:

func addItem(items []string, item string) []string {
	return append(items, item)
}

// 使用方法
cart = addItem(cart, "pen")

これが標準ライブラリがappendをin-placeでsliceを修正する関数ではなく、値を返す関数として設計した理由でもあります。

7. 落とし穴4: makeでlenとcapを混同する

package main

import "fmt"

func main() {
	names := []string{"An", "Binh", "Chi"}

	upper := make([]string, len(names)) // 意図違い
	for _, n := range names {
		upper = append(upper, n+"!")
	}

	fmt.Println(len(upper), upper)
}
6 [   An! Binh! Chi!]

make([]string, 3)は容量3のsliceではなく、3つの空の要素を持つ sliceを作成します。appendは3つの空の文字列の後に追加します。

修正する方法は2つあります。どちらか一つを選んでください。混ぜないでください:

// 方法1: len = 0, cap = n, その後append
upper := make([]string, 0, len(names))
for _, n := range names {
	upper = append(upper, n+"!")
}

// 方法2: len = n, インデックスで割り当てる
upper := make([]string, len(names))
for i, n := range names {
	upper[i] = n + "!"
}

どちらも1回のメモリ確保で済み、appendがcapを複数回増やすより高速です。

8. 防止方法

8.1. Full slice expression: capを制限する

構文s[low:high:max]はcap = max - lowを設定します。capがlenと等しい場合、次のappendは新しい配列の確保を強制します:

prices := []int{10, 20, 30, 40, 50}
firstTwo := prices[:2:2] // len=2, cap=2

firstTwo = append(firstTwo, 99)

fmt.Println("firstTwo:", firstTwo)
fmt.Println("prices:  ", prices)
firstTwo: [10 20 99]
prices:   [10 20 30 40 50]

pricesは影響を受けません。このテクニックは、内部のsliceの一部をcallerに返し、callerが安全にappendできるようにしたいとき、かつあなたのデータを破壊しないようにしたいときに有用です。

8.2. slices.CloneとCopy: 配列を完全に分離する

完全に独立したコピーが必要な場合、cloneを使用してください:

import "slices"

orders := []string{"A01", "A02", "A03"}
snapshot := slices.Clone(orders)

snapshot[0] = "X"
fmt.Println(orders[0], snapshot[0]) // A01 X

Go 1.21より古い場合は、copyを使用してください:

snapshot := make([]string, len(orders))
copy(snapshot, orders)

copyは複写されたエレメント数を返し、len(dst)とlen(src)の小さい方に等しいです。したがってdstには十分なlenが必要で、単にcapだけでは不十分です。よくあるエラーはcopy(make([]string, 0, n), src)と書いて空のsliceを得ることです。

8.3. メモリ保持の落とし穴

大きな配列から小さなsliceを切り出すと、全体の大きな配列をメモリに保持します:

func firstLine(data []byte) []byte {
	for i, b := range data {
		if b == '\n' {
			return slices.Clone(data[:i]) // データ全体を保持しない
		}
	}
	return slices.Clone(data)
}

dataが100 MBのファイルで、data[:i]をcloneなしで返した場合、返されたsliceが参照されている限り、ガベージコレクタは100 MBを解放できません。

9. aliasing(別名参照)のバグをキャッチするためのテスト作成

Inventoryという型があり、製品のリストを返すとします。callerが結果にappendしてもデータが破損しないことを確認したいとします:

// inventory.go
package inventory

type Inventory struct {
	items []string
}

func New(items ...string) *Inventory {
	return &Inventory{items: items}
}

func (inv *Inventory) Items() []string {
	return inv.items[:len(inv.items):len(inv.items)]
}
// inventory_test.go
package inventory

import (
	"slices"
	"testing"
)

func TestItems_AppendDoesNotCorruptInternalState(t *testing.T) {
	inv := New("pen", "book")
	inv.items = append(inv.items[:0:0], "pen", "book", "bag")[:2] // 意図的にcapに余裕を持たせる

	got := inv.Items()
	got = append(got, "laptop")

	want := []string{"pen", "book"}
	if !slices.Equal(inv.Items(), want) {
		t.Fatalf("internal items = %v, want %v", inv.Items(), want)
	}
	if inv.items[:3][2] != "bag" {
		t.Fatalf("backing arrayが上書きされました: %v", inv.items[:3])
	}
	if !slices.Equal(got, []string{"pen", "book", "laptop"}) {
		t.Fatalf("got = %v", got)
	}
}

テストは故意に内部のsliceをlenより大きいcapで作成します。バグが現れる条件だからです。Items()をreturn inv.itemsに変更すると、append(got, "laptop")は"bag"を上書きし、2番目のアサーションでテストが失敗します。

go test ./...
ok  	example.com/inventory	0.002s

注意: aliasing(別名参照)のテストは常にlen < capの状況を作る必要があります。[]string{"pen", "book"}のようなリテラルで作成されたテスト内のsliceの場合、len == capであり、appendは常に新しい配列を確保するため、コードにバグがあってもテストはpassしてしまいます。

10. 心配する必要がない時

全てのsliceをcloneする必要はありません。余分なコピーはメモリとCPUを無駄にします。以下の場合は安全に配列を共有できます:

  • sliceは読み取り専用で、誰もappendしたり要素を修正したりしません。
  • sliceは局所変数で、関数外に共有されません。
  • あなたが意図的にin-placeで修正したい場合。例えば、s[:0]を使って要素をフィルタリングし配列を再利用する:
active := orders[:0]
for _, o := range orders {
	if o != "CANCELLED" {
		active = append(active, o)
	}
}

このテクニックは正しいです。なぜならactiveは決してordersの読み取り位置を超えて書き込まないからです。ループ後、activeだけを使用し、ordersは無視するべきです。

11. 結論

sliceはpointer、len、capからなるstructで、共有できる配列を指します。appendはlen == capの時にのみ新しい配列を確保します。それ以外の場合は、共有配列に直接書き込みます。これが今までに見た4つの落とし穴の根源です: 元のsliceの上書き、2つのappendによる相互上書き、関数内のappendがcallerを変更しない、makeでのlenとcapの混同です。

安全にするために、いくつかのルールを覚えてください:

  • 常にs = append(s, ...)と書く。
  • 要素を追加する関数は新しいsliceを返す。
  • s[low:high:high]を内部のsliceを外に返すときに使う。
  • 独立したコピーが必要な場合、または大きな配列を解放したい場合はslices.Cloneを使う。

テスト作成時は、バグが露呈する条件であるlen < capを作ることを忘れずに。

golangsliceappendメモリ