Day 6: 配列とスライス - 解説
学習の目的
- 複数データの管理: 配列とスライスの使い分け
- 動的なデータ構造: appendでの要素追加
- 効率的な反復処理: for rangeの活用
- メモリモデルの理解: スライスの内部構造を把握する
- パフォーマンス最適化: 容量を意識したコーディング
---
概念の深掘り
1. 配列の基礎と特性
配列は固定長のデータ構造です。サイズは型の一部として扱われます。
var arr1 [5]int // [0 0 0 0 0]
arr2 := [3]string{"a", "b", "c"}
arr3 := [...]int{1, 2, 3, 4} // 要素数を自動で決定
重要な特性:
- サイズは型の一部(
[5]intと[10]intは異なる型) - 値渡しでコピーされる
- サイズは実行時に変更不可
func modifyArray(arr [3]int) {
arr[0] = 100 // 元の配列は変更されない(コピーが渡される)
}
func main() {
a := [3]int{1, 2, 3}
modifyArray(a)
fmt.Println(a) // [1 2 3] - 変更されていない
}
2. スライスの内部構造
スライスは3つの要素で構成される参照型です:
slice := make([]int, 5, 10)
// ポインタ: 内部配列への参照
// 長さ: 5(現在の要素数)
// 容量: 10(再割り当てなしで格納可能な最大要素数)
視覚的イメージ:
スライス構造:
┌─────────┬─────┬─────┐
│ ポインタ │ len │ cap │
└────┬────┴─────┴─────┘
│
└──> [0][1][2][3][4][_][_][_][_][_]
←----- len=5 ----→
←--------- cap=10 ----------→
3. スライスの作成方法
方法1: リテラル
slice := []int{1, 2, 3, 4, 5}
// len=5, cap=5
方法2: make関数
// 長さと容量を指定
s1 := make([]int, 5, 10) // len=5, cap=10
// 長さのみ指定(容量は長さと同じ)
s2 := make([]int, 5) // len=5, cap=5
方法3: 配列からスライス
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // [2 3 4], len=3, cap=4
4. スライスの操作とメモリ管理
append操作の仕組み
s := make([]int, 0, 3)
fmt.Printf("len=%d cap=%d\n", len(s), cap(s)) // len=0 cap=3
s = append(s, 1) // len=1 cap=3
s = append(s, 2) // len=2 cap=3
s = append(s, 3) // len=3 cap=3
s = append(s, 4) // len=4 cap=6(容量が足りず再割り当て)
容量が不足した場合:
- 新しい配列を割り当て(通常、容量を2倍に)
- 既存要素をコピー
- 新要素を追加
- スライスのポインタを更新
// パフォーマンス最適化の例
// 悪い例:事前に容量を確保しない
badSlice := []int{}
for i := 0; i < 10000; i++ {
badSlice = append(badSlice, i) // 何度も再割り当てが発生
}
// 良い例:事前に容量を確保
goodSlice := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
goodSlice = append(goodSlice, i) // 再割り当てなし
}
5. スライス演算の詳細
スライス式の構文
slice[low:high:max]
// low: 開始位置(含む)
// high: 終了位置(含まない)
// max: 容量の制限
例:
s := []int{0, 1, 2, 3, 4, 5}
s1 := s[1:4] // [1 2 3], len=3, cap=5
s2 := s[1:4:4] // [1 2 3], len=3, cap=3(容量を制限)
copy操作
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
// srcからdstへコピー(コピーされる要素数は短い方)
n := copy(dst, src)
fmt.Println(dst) // [1 2 3]
fmt.Println(n) // 3(コピーされた要素数)
6. 配列 vs スライスの使い分け
| 特性 | 配列 | スライス |
|---|---|---|
| サイズ | 固定(コンパイル時) | 動的(実行時) |
| メモリ | 値型(コピーされる) | 参照型(ポインタ共有) |
| サイズ変更 | 不可 | 可能(append) |
| 使用頻度 | 稀 | 非常に高い |
| 適用場面 | 固定サイズ、メモリ効率重視 | ほぼすべてのケース |
配列を使うべきケース:
// 1. 数学的な固定サイズのデータ
type Matrix [3][3]float64
// 2. 低レベルプログラミング
var buffer [4096]byte
// 3. パフォーマンスクリティカルな場面
func hash(data [32]byte) [32]byte {
// SHA-256ハッシュなど
}
スライスを使うべきケース(ほとんどの場合):
// データのサイズが可変
items := []string{}
items = append(items, "新しいアイテム")
// 関数に渡す場合(コピーを避ける)
func processItems(items []int) {
// 効率的
}
---
アルゴリズムとパターン
1. スライスの反復処理パターン
パターン1: インデックスと値の両方が必要
numbers := []int{10, 20, 30, 40}
for i, value := range numbers {
fmt.Printf("index: %d, value: %d\n", i, value)
}
パターン2: 値のみが必要
for _, value := range numbers {
fmt.Println(value)
}
パターン3: インデックスのみが必要
for i := range numbers {
fmt.Println(i)
}
パターン4: 従来のforループ
for i := 0; i < len(numbers); i++ {
fmt.Println(numbers[i])
}
2. 一般的なスライス操作パターン
フィルタリング
func filter(numbers []int, predicate func(int) bool) []int {
result := make([]int, 0, len(numbers)) // 容量を事前確保
for _, num := range numbers {
if predicate(num) {
result = append(result, num)
}
}
return result
}
// 使用例
evens := filter([]int{1, 2, 3, 4, 5}, func(n int) bool {
return n%2 == 0
})
// evens = [2, 4]
マッピング
func mapInt(numbers []int, transform func(int) int) []int {
result := make([]int, len(numbers))
for i, num := range numbers {
result[i] = transform(num)
}
return result
}
// 使用例
doubled := mapInt([]int{1, 2, 3}, func(n int) int {
return n * 2
})
// doubled = [2, 4, 6]
リダクション(集約)
func reduce(numbers []int, initial int, reducer func(acc, val int) int) int {
result := initial
for _, num := range numbers {
result = reducer(result, num)
}
return result
}
// 使用例:合計
sum := reduce([]int{1, 2, 3, 4}, 0, func(acc, val int) int {
return acc + val
})
// sum = 10
3. スライスの結合と分割
結合
s1 := []int{1, 2, 3}
s2 := []int{4, 5, 6}
// 方法1: append
combined := append(s1, s2...)
// combined = [1, 2, 3, 4, 5, 6]
// 方法2: 事前に容量確保(効率的)
combined2 := make([]int, 0, len(s1)+len(s2))
combined2 = append(combined2, s1...)
combined2 = append(combined2, s2...)
要素の挿入
func insert(slice []int, index, value int) []int {
// 容量を1増やす
slice = append(slice, 0)
// 挿入位置以降を右にシフト
copy(slice[index+1:], slice[index:])
// 値を挿入
slice[index] = value
return slice
}
s := []int{1, 2, 4, 5}
s = insert(s, 2, 3) // [1, 2, 3, 4, 5]
要素の削除
func remove(slice []int, index int) []int {
return append(slice[:index], slice[index+1:]...)
}
s := []int{1, 2, 3, 4, 5}
s = remove(s, 2) // [1, 2, 4, 5]
---
設計原則とベストプラクティス
1. nilスライスと空スライス
var nilSlice []int // nil
emptySlice := []int{} // 空だがnilではない
madeSlice := make([]int, 0) // 空だがnilではない
fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false
fmt.Println(len(nilSlice)) // 0
fmt.Println(len(emptySlice)) // 0
// appendはどちらでも動作する
nilSlice = append(nilSlice, 1) // OK
emptySlice = append(emptySlice, 1) // OK
推奨事項:
- 関数の戻り値として空のスライスを返す場合は
nilを返す - JSONエンコード時の違いに注意(
nilはnull、空スライスは[])
2. スライスの共有に注意
original := []int{1, 2, 3, 4, 5}
slice1 := original[0:3] // [1, 2, 3]
slice2 := original[2:5] // [3, 4, 5]
// slice1を変更するとoriginalとslice2にも影響
slice1[2] = 100
fmt.Println(original) // [1, 2, 100, 4, 5]
fmt.Println(slice2) // [100, 4, 5]
安全なコピー:
original := []int{1, 2, 3, 4, 5}
safeCopy := make([]int, len(original))
copy(safeCopy, original)
safeCopy[0] = 100
// originalは変更されない
3. パフォーマンス最適化
容量の事前確保
// 悪い例
var items []string
for i := 0; i < 1000; i++ {
items = append(items, fmt.Sprintf("item%d", i))
// 多数の再割り当てが発生
}
// 良い例
items := make([]string, 0, 1000)
for i := 0; i < 1000; i++ {
items = append(items, fmt.Sprintf("item%d", i))
// 再割り当てなし
}
大きなスライスの部分使用時の注意
func readFirstLine(filename string) []byte {
data, _ := os.ReadFile(filename) // 1GB
firstLine := data[0:100] // 100バイトだけ使う
return firstLine
// 問題: 1GB全体がメモリに残る
}
// 解決策: コピーを返す
func readFirstLine(filename string) []byte {
data, _ := os.ReadFile(filename)
firstLine := make([]byte, 100)
copy(firstLine, data[0:100])
return firstLine
// dataは関数終了後にGCされる
}
---
メンタルモデル
スライスの理解を深めるイメージ
配列 = 本棚
- 固定数の棚がある
- 各棚には番号が付いている
- 棚の数は変更できない
スライス = 動的な窓
- 本棚(配列)を覗く窓
- 窓の位置とサイズは変更可能
- 複数の窓が同じ本棚を見ることができる
配列: [0][1][2][3][4][5][6][7][8][9]
↑ ↑
スライスA: [1][2][3][4][5] (1:6)
スライスB: [2][3][4] (2:5)
appendのメンタルモデル
初期状態:
slice: [A][B][C][_][_]
len=3, cap=5
append(slice, D):
slice: [A][B][C][D][_]
len=4, cap=5 (容量に余裕あり)
append(slice, E):
slice: [A][B][C][D][E]
len=5, cap=5 (容量いっぱい)
append(slice, F):
新配列確保: [A][B][C][D][E][F][_][_][_][_]
len=6, cap=10 (容量2倍)
---
よくある間違いと対策
間違い1: rangeでのポインタ取得
// 間違い
var pointers []*int
numbers := []int{1, 2, 3}
for _, num := range numbers {
pointers = append(pointers, &num) // 全て同じアドレス
}
// 結果: 全てのポインタが最後の値(3)を指す
// 正しい方法1: インデックスでアクセス
for i := range numbers {
pointers = append(pointers, &numbers[i])
}
// 正しい方法2: ローカル変数にコピー
for _, num := range numbers {
n := num
pointers = append(pointers, &n)
}
間違い2: スライスのappend結果を無視
// 間違い
func addItem(slice []int, item int) {
append(slice, item) // 戻り値を無視
}
s := []int{1, 2}
addItem(s, 3)
fmt.Println(s) // [1, 2] - 変更されていない
// 正しい方法
func addItem(slice []int, item int) []int {
return append(slice, item)
}
s = addItem(s, 3)
fmt.Println(s) // [1, 2, 3]
間違い3: スライスの比較
// コンパイルエラー
s1 := []int{1, 2, 3}
s2 := []int{1, 2, 3}
// if s1 == s2 { } // エラー: スライスは比較できない
// 正しい方法1: 手動で比較
func equal(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// 正しい方法2: reflect.DeepEqualを使用
import "reflect"
if reflect.DeepEqual(s1, s2) {
// 等しい
}
---
実践的な例
例1: 重複削除
func removeDuplicates(slice []int) []int {
seen := make(map[int]bool)
result := make([]int, 0, len(slice))
for _, value := range slice {
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result
}
// 使用例
input := []int{1, 2, 2, 3, 1, 4, 3}
output := removeDuplicates(input)
// output = [1, 2, 3, 4]
例2: スライスの逆順
func reverse(slice []int) {
for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
slice[i], slice[j] = slice[j], slice[i]
}
}
// 使用例
s := []int{1, 2, 3, 4, 5}
reverse(s)
// s = [5, 4, 3, 2, 1]
例3: チャンク分割
func chunk(slice []int, size int) [][]int {
var chunks [][]int
for i := 0; i < len(slice); i += size {
end := i + size
if end > len(slice) {
end = len(slice)
}
chunks = append(chunks, slice[i:end])
}
return chunks
}
// 使用例
numbers := []int{1, 2, 3, 4, 5, 6, 7}
chunks := chunk(numbers, 3)
// chunks = [[1, 2, 3], [4, 5, 6], [7]]
---
セルフチェック問題
基礎レベル
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s))
nilスライスと空スライスの違いは?appendが新しい配列を割り当てるのはどんな時?- このコードの問題点は?
中級レベル
func appendToSlice(s []int) {
append(s, 100)
}
- なぜこのコードは意図通りに動作しないのか?
s := []int{1, 2, 3}
for _, v := range s {
v = v * 2
}
fmt.Println(s) // [1, 2, 3] - なぜ?
上級レベル
- このコードの出力を予測してください:
s := []int{1, 2, 3, 4, 5}
s1 := s[0:2]
s2 := s[1:3]
s1[1] = 100
fmt.Println(s, s1, s2)
- パフォーマンスの観点から、どちらが優れているか説明してください:
// A
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i)
}
// B
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}
---
明日への準備
Day 7は「マップ(map)」を学びます。マップは配列やスライスと異なり、キーと値のペアを格納するデータ構造です。
// スライス: インデックス(整数)で値にアクセス
fruits := []string{"apple", "orange", "banana"}
fmt.Println(fruits[0]) // "apple"
// マップ: キー(任意の型)で値にアクセス
prices := map[string]int{
"apple": 100,
"orange": 80,
"banana": 120,
}
fmt.Println(prices["apple"]) // 100
マップを使うことで:
- データを意味のあるキーで管理できる
- 高速な検索が可能(O(1))
- 動的にキーを追加・削除できる
- スライスは参照型: 同じ基底配列を共有できる
- appendの戻り値を使う: 必ず
s = append(s, value)とする - 容量を意識する: パフォーマンス最適化のために事前確保
- nilと空は違う:
var s []int(nil)とs := []int{}(空) - rangeは値のコピー: ループ変数は元の要素のコピー
- Day 7でマップを学び、データ構造の理解を深める
- スライスとマップを組み合わせた複雑なデータ構造を扱えるようになる
- 実践的なアルゴリズム問題に挑戦する
---
まとめ
| 概念 | 説明 | 重要度 |
|---|---|---|
| 配列 | 固定長のデータ列、値型 | ★★☆ |
| スライス | 可変長のデータ列、参照型 | ★★★ |
| len() | スライスの長さを取得 | ★★★ |
| cap() | スライスの容量を取得 | ★★★ |
| append() | スライスに要素を追加 | ★★★ |
| copy() | スライスをコピー | ★★★ |
| range | 反復処理のための構文 | ★★★ |
| make() | スライスの初期化 | ★★★ |