Day 005 — スライス②(内部構造・容量・共有スライスの罠)

2026-08-02 🟢 初心者 / Phase 1 デバッグ・コードレビュー スライス②(内部構造・容量・共有スライスの罠)

📚 背景知識(読んでから問題へ)

Day 004で学んだ通り、スライスは「ポインタ・len・cap」の3つを持つヘッダです。ここで重要なのは、スライス式(s[low:high])で作った新しいスライスは、元のスライスと同じ基底配列を共有するという点です。

a := []int{1, 2, 3, 4, 5}
b := a[1:3] // b は a の要素1,2番目を指す。len(b)=2, cap(b)=4(元の配列の残り容量)
b[0] = 99   // a[1] も 99 になる! 基底配列が共有されているため

さらに厄介なのは、b容量(cap)が「bの長さ」ではなく「元の配列における残りの容量」になることです。上の例ではb := a[1:3]の時点でlen(b)=2ですがcap(b)=4aのインデックス1から末尾までの容量)です。この余った容量に対してappend(b, ...)を行うと、新しい配列を確保せずそのままaの基底配列を上書きしてしまいます

これがGoの実務コードで頻発する「スライスの共有によるバグ」の正体です。関数に渡したスライスの一部を切り出してappendしただけのつもりが、呼び出し元の別のスライスや配列の中身まで書き換えてしまう、というケースが典型例です。

このバグを防ぐ代表的な方法は2つあります。

  1. フルスライス式 a[low:high:max] で明示的に容量を制限し、caplenと同じにしておく(容量オーバーで必ず新しい配列が確保されるようにする)
  2. appendする前にcopyで独立したスライスを作る

Goが「スライス式は基底配列を共有する」という挙動を採用しているのは、不要なメモリコピーを避けてパフォーマンスを優先するという設計判断です。裏を返せば、共有を意図しない場面では明示的にcopyやフルスライス式を使ってプログラマが責任を持つ必要があります。これは「暗黙の安全策より、明示的な制御をプログラマに委ねる」というGoの一貫した思想の表れです。

📝 問題

以下のコードには、スライスの共有に起因するバグがあります。バグを特定し、修正してください。

バグのあるコード:

package main

import "fmt"

// splitFirstTwo は数値スライスを受け取り、
// 「先頭2件」と「それ以外の全件のコピー(元とは独立していてほしい)」を返す関数のつもり。
func splitFirstTwo(nums []int) (first []int, rest []int) {
	first = nums[:2]
	rest = nums[2:]
	return first, rest
}

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

	first, rest := splitFirstTwo(original)
	fmt.Println("first:", first)
	fmt.Println("rest:", rest)

	// first に新しい値を追加する(firstはoriginalとは無関係な独立したスライスのつもり)
	first = append(first, 999)

	fmt.Println("--- first に append した後 ---")
	fmt.Println("first:", first)
	fmt.Println("original:", original) // ここで想定外の変化が起きる
}

要件:

  1. 上記のコードを実際に読んで(あるいはgo runで実行して)、firstへのappendoriginalにどのような影響を与えるか説明してください。なぜそうなるのか、lencapの観点から理由も述べてください(ヒント: rest側に同じ操作をしても実は今回はたまたま問題が起きません。firstrestで何が違うのか、cap(first)cap(rest)をそれぞれ出力して比較してみましょう)。
  2. splitFirstTwoを、firstrestのどちらもoriginalの基底配列と一切共有されない(どれだけappendしてもoriginalが絶対に変化しない)ように修正してください。
  3. 修正後のコードでfirstrestの両方にappend操作を行い、originalが変化しないことを確認してください。

go run main.go でそのまま実行できる、完全な1つのプログラムとして提出してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • nums[2:]のようなスライス式は新しい配列を作りません。restnums(=original)と同じ基底配列を指したままで、restの容量(cap)は「originalの3番目要素から末尾までの容量」になります
  • appendした際に容量が余っていると何が起きるかを、Day 004の「容量が足りている間はその場で書き込まれる」という挙動と結びつけて考えてください
ヒント2 — アプローチ
  • len(original)は5、rest := original[2:]の時点でlen(rest)=3(30,40,50)、cap(rest)=3です(originalの残り容量ちょうど3のため、実は今回は容量ぴったりで最初のappendで新配列に切り替わるケースです)
  • しかしfirst = nums[:2]の方はどうでしょうか?cap(first)nums[:2]の場合、元の配列全体の容量(5)になります
  • firstに対してappendしたらどうなるか、fmt.Printf("cap(first)=%d\n", cap(first))のように出力して実際の値を確認してください
  • 独立したコピーを作るには、copy関数か、フルスライス式nums[:2:2]を使います
ヒント3 — コード骨格
package main

import "fmt"

func splitFirstTwo(nums []int) (first []int, rest []int) {
	// 方法1: make + copy で独立した新しいスライスを作る
	first = make([]int, 2)
	copy(first, nums[:2])

	// rest も同様に make + copy、あるいはフルスライス式 nums[2:len(nums):len(nums)] で
	// cap を len と同じに制限し、append時に必ず新しい配列が確保されるようにする
	rest = make([]int, len(nums)-2)
	copy(rest, nums[2:])

	return first, rest
}

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

	first, rest := splitFirstTwo(original)
	fmt.Println("first:", first)
	fmt.Println("rest:", rest)

	rest = append(rest, 100)
	rest = append(rest, 200)

	fmt.Println("--- rest に append した後 ---")
	fmt.Println("rest:", rest)
	fmt.Println("original:", original) // 変化しないはず
}

first側のappendでもoriginalが変化しないことを確認するコードも追加してみましょう。

模範解答

package main

import "fmt"

// splitFirstTwo は数値スライスを受け取り、「先頭2件」と「それ以外」を
// original の基底配列と一切共有しない独立したスライスとして返す。
func splitFirstTwo(nums []int) (first []int, rest []int) {
	first = make([]int, 2)
	copy(first, nums[:2])

	rest = make([]int, len(nums)-2)
	copy(rest, nums[2:])

	return first, rest
}

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

	first, rest := splitFirstTwo(original)
	fmt.Println("first:", first)
	fmt.Println("rest:", rest)

	// first, rest どちらに append しても original には一切影響しない
	first = append(first, 999)
	rest = append(rest, 100)
	rest = append(rest, 200)

	fmt.Println("--- append した後 ---")
	fmt.Println("first:", first)
	fmt.Println("rest:", rest)
	fmt.Println("original:", original) // 変化なし
}
▶ 実行結果を見る(go run main.go)
first: [10 20]
rest: [30 40 50]
--- append した後 ---
first: [10 20 999]
rest: [30 40 50 100 200]
original: [10 20 30 40 50]

※ 実際にgo run main.goで実行し、上記の出力を確認済みです。バグありコードでも同様に実行し、original: [10 20 999 40 50]のように破壊される様子を比較確認済みです。

🪜 Step-by-Step 解説

1
バグの原因をlen/capの観点で特定する
元のコードのfirst = nums[:2]は、nums(=originallen=5,cap=5)の先頭2要素を指すスライスです。この時点でlen(first)=2ですがcap(first)=5(元の配列の残り容量そのまま)です。同様にrest = nums[2:]len(rest)=3, cap(rest)=3です。

firstに対してappend(first, x)を行うと、cap(first)=5 > len(first)=2なので容量に余裕があり、新しい配列を確保せずにoriginalのインデックス2(=firstから見て次の要素)へ直接書き込みます。つまりappend(first, 999)を行うとoriginal[2](=30)が999に上書きされてしまいます。これが「想定外の変化」の正体です。
fmt.Println(cap(first)) // 5 (元の配列の残り容量がそのまま引き継がれる)
2
make + copyで独立したスライスを作る
first = make([]int, 2)
copy(first, nums[:2])
make([]int, 2)originalとは完全に別の新しい基底配列を確保します。copyは値を1つずつコピーするだけで、配列そのものは共有しません。これによりfirstへのどんなappendoriginalに影響を与えなくなります。restも同様の手順で独立させます。
3
フルスライス式という代替案も知っておく
make+copyの代わりに、フルスライス式nums[:2:2]low:high:maxの3つ目のmaxで容量を明示的に制限)を使う方法もあります。
first = nums[:2:2] // len=2, cap=2 に制限される
これによりcap(first)=len(first)=2となり、appendした瞬間に容量オーバーで新しい配列が自動的に確保されます。ただし、この方法は「まだ基底配列自体は一時的に共有されている」点に注意が必要です(append前にfirst[0]を書き換えればoriginalにも影響します)。完全に独立させたい場合はmake+copyの方が安全で意図が明確です。

💡 設計思想・なぜこう書くのか

📌
不要なコピーを避けてパフォーマンスを優先する: Goがスライス式で基底配列を共有する挙動をデフォルトにしているのは、「コピーはコストがかかる操作なので、必要なときだけ明示的に行う」という設計判断です。もしスライス式のたびに自動的にディープコピーが行われると、大きなスライスを部分的に扱うだけの処理でも毎回メモリ確保とコピーが発生し、パフォーマンスが悪化します。
📌
賢い暗黙動作より単純で予測可能な挙動を優先する: この設計は「共有されていることを忘れる」というヒューマンエラーを生みやすいトレードオフでもあります。Goはこれを言語機能で自動的に防ぐのではなく、copy関数とフルスライス式という明示的な道具を用意することで、「共有するか独立させるかはプログラマが意識して選ぶべきこと」という立場を取っています。

🌐 他言語との比較

観点GoJavaPythonJavaScript
部分列の取得s[low:high](基底配列を共有、cap引き継ぎに注意)List.subList()(元のリストのビュー、書き換えが伝播)list[low:high](新しいリストとして独立コピーされる)Array.prototype.slice()(新しい配列として独立コピーされる)
独立コピーの取得方法make+copy、またはappend([]T{}, s...)new ArrayList<>(list.subList(...))デフォルトが独立コピーなので追加操作不要デフォルトが独立コピーなので追加操作不要
容量(cap)の概念あり(capが独立して存在し、append時の再確保判定に使われる)なし(ArrayListは内部配列を隠蔽)なし(listは内部実装を隠蔽)なし(Arrayは内部実装を隠蔽)

PythonやJavaScriptのスライス操作は初心者にも直感的に「独立したコピーが返る」ため安全ですが、GoのスライスはJavaのsubListに近い「ビュー(共有)」であり、かつcapという独自概念まで絡むため、他言語経験者ほど誤解しやすいポイントです。「Goのスライス式は常にビューであり、独立させたければ明示的にcopyする」という原則を意識することが重要です。

🏆 実務での使いどころ

  • 関数への部分スライス渡しによるバグ: 「ログの先頭N件だけ処理する」「バッチ処理でチャンクに分割する」といった処理で、切り出したスライスにappendした結果、元のスライスが破損するバグは実務で頻出します。特に並行処理(goroutine間)で同じ基底配列を共有したスライスを扱うと、データ競合の温床にもなります
  • copyを使った安全な部分取得: APIレスポンスの一部だけを別の構造体に詰め替える処理、キャッシュ用に部分データを独立させて保持する処理などで、make+copyパターンは頻繁に使われます
  • フルスライス式によるバグ予防: ライブラリ関数の内部実装で「呼び出し元から渡されたスライスの一部を返すが、内部でappendする可能性がある」場合、s[:len(s):len(s)]のように容量を制限しておくことで、意図しない書き込みを未然に防ぐ防御的プログラミングとして使われます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
s[low:high]は新しい独立したスライスを作ると思い込むPython/JavaScriptのスライスが独立コピーを返すため、同じ感覚で捉えてしまうGoのスライス式は基底配列を共有する「ビュー」。独立させたい場合はcopyかフルスライス式で明示的に対応する必要がある
部分スライスのcaplenと同じだと思い込むlenばかり意識しcapを確認しないs[low:high]capは「元の配列のlowから末尾まで」の容量になる。append時にこの余った容量へ書き込まれ、共有元を破壊することがある
append前に容量を確認せず「新しい配列が作られるはず」と思い込むappendの実装が容量不足時のみ再確保することを知らない容量に余裕がある限り、appendは既存の基底配列にそのまま書き込む。共有元への影響を避けたい場合は事前に容量を制限するか独立コピーを作る
フルスライス式s[:2:2]を使えば完全に独立すると誤解するcapが制限されることと「基底配列そのものが別」であることを混同するフルスライス式はappend時に再確保を強制するだけで、append前に要素を直接書き換えれば依然として共有元に影響する。完全に独立させるにはcopyが必要

🚀 次のステップ

  • 発展: s := make([]int, 0, 10)のように大きめの容量を確保したスライスから複数の部分スライスを切り出し、それぞれにappendしたときにどのタイミングでお互いに影響し合うか、実験して図に書き出してみましょう
  • 次回予告: Day 006 — マップ(作成・操作・存在確認)(map操作・カンマOKイディオム、実装問題)

🎯 自己評価

自分の回答

気づき・メモ