📚 背景知識(読んでから問題へ)
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)=4(aのインデックス1から末尾までの容量)です。この余った容量に対してappend(b, ...)を行うと、新しい配列を確保せずそのままaの基底配列を上書きしてしまいます。
これがGoの実務コードで頻発する「スライスの共有によるバグ」の正体です。関数に渡したスライスの一部を切り出してappendしただけのつもりが、呼び出し元の別のスライスや配列の中身まで書き換えてしまう、というケースが典型例です。
このバグを防ぐ代表的な方法は2つあります。
- フルスライス式
a[low:high:max]で明示的に容量を制限し、capをlenと同じにしておく(容量オーバーで必ず新しい配列が確保されるようにする) 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) // ここで想定外の変化が起きる
}
要件:
- 上記のコードを実際に読んで(あるいは
go runで実行して)、firstへのappendがoriginalにどのような影響を与えるか説明してください。なぜそうなるのか、len・capの観点から理由も述べてください(ヒント:rest側に同じ操作をしても実は今回はたまたま問題が起きません。firstとrestで何が違うのか、cap(first)・cap(rest)をそれぞれ出力して比較してみましょう)。 splitFirstTwoを、firstとrestのどちらもoriginalの基底配列と一切共有されない(どれだけappendしてもoriginalが絶対に変化しない)ように修正してください。- 修正後のコードで
firstとrestの両方にappend操作を行い、originalが変化しないことを確認してください。
go run main.go でそのまま実行できる、完全な1つのプログラムとして提出してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
nums[2:]のようなスライス式は新しい配列を作りません。restはnums(=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 解説
len/capの観点で特定する元のコードの
first = nums[:2]は、nums(=original、len=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 (元の配列の残り容量がそのまま引き継がれる)
make + copyで独立したスライスを作るfirst = make([]int, 2)
copy(first, nums[:2])
make([]int, 2)はoriginalとは完全に別の新しい基底配列を確保します。copyは値を1つずつコピーするだけで、配列そのものは共有しません。これによりfirstへのどんなappendもoriginalに影響を与えなくなります。restも同様の手順で独立させます。
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の方が安全で意図が明確です。
💡 設計思想・なぜこう書くのか
copy関数とフルスライス式という明示的な道具を用意することで、「共有するか独立させるかはプログラマが意識して選ぶべきこと」という立場を取っています。🌐 他言語との比較
| 観点 | Go | Java | Python | JavaScript |
|---|---|---|---|---|
| 部分列の取得 | 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かフルスライス式で明示的に対応する必要がある |
部分スライスのcapがlenと同じだと思い込む | 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イディオム、実装問題)