📚 背景知識(読んでから問題へ)
Goには「配列(array)」と「スライス(slice)」という似て非なる2つの型があります。
配列は[5]intのようにサイズが型の一部になっている固定長の値型です。配列を関数に渡したり別の変数に代入したりすると、要素が丸ごとコピーされます。C言語の配列に近い挙動です。
スライスは[]intのように長さを型に含めない可変長の型で、内部的には「ポインタ・長さ(len)・容量(cap)」の3つのフィールドを持つ小さな構造体(スライスヘッダ)です。スライスをコピーするとヘッダはコピーされますが、指し示す先の配列(基底配列)は共有されます。
var arr [5]int // 配列: サイズ5固定、値型
s := make([]int, 0, 5) // スライス: 長さ0・容量5の可変長、ヘッダが実体
スライスにappendで要素を追加すると、容量(cap)に余裕があればその場で基底配列に書き込みます。容量が足りなくなると、Goランタイムがより大きな新しい配列を確保し、既存要素をコピーしてから追加します。この「容量が尽きたら再確保する」という挙動により、スライスは動的配列として自然に振る舞います。
Goが配列とスライスを別の型として分けているのは、「固定長で値渡しのコストが明確なデータ」と「可変長でヒープ上の基底配列を共有するデータ」を型レベルで区別し、意図しないコピーや共有を防ぐためです。実務コードのほとんどでは可変長のスライスを使い、配列は固定サイズが保証で重要な場面(暗号ハッシュの[32]byteなど)で使われます。
📝 問題
以下の3つを実装してください。
要件:
- 配列は値型であることを実験で確認する
zeroOutArray(arr [5]int)という関数を実装してください。この関数は受け取った配列の全要素を0にしてからプリントします。mainで[5]int型の配列scoresを作って10, 20, 30, 40, 50を格納し、zeroOutArray(scores)を呼んだ後にscores自体を再度プリントし、値が変化していない(コピーが渡されただけ)ことを確認してください。 - スライスの容量成長を観察する
make([]int, 0, 3)で長さ0・容量3のスライスnumsを作り、1から6までappendで1つずつ追加してください。追加のたびにlen(nums)・cap(nums)・スライスの中身を出力し、容量が足りなくなったタイミングでcapがどう変化するか観察できるようにしてください。 AppendUnique関数を実装する
AppendUnique(s []int, v int) []intを実装してください。vがsに既に含まれていればそのままsを返し、含まれていなければappendで追加した新しいスライスを返します。main内で[]int{1, 2, 3}に対して2(既存)と4(新規)をこの関数で追加し、結果をプリントしてください。
go run main.go でそのまま実行できる、完全な1つのプログラムとして提出してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
- 配列は値型なので、関数に渡すと丸ごとコピーされます。関数内でコピーをいくら書き換えても、呼び出し元の変数には一切影響しません
- スライスは「ポインタ・len・cap」のヘッダがコピーされるだけで、指す先の基底配列は共有されることを思い出してください。
appendは容量が足りなければ新しい配列を確保します
ヒント2 — アプローチ
- 配列の値渡し確認は、
zeroOutArrayの中でループしながらarr[i] = 0を実行し、fmt.Printlnで関数内の様子を出力すればOKです。呼び出し元では関数呼び出しの前後でscoresをそれぞれ出力して比較してください - スライスの容量成長観察は、
for i := 1; i <= 6; i++のループの中でappendした直後にlenとcapをfmt.Printfで出力します AppendUniqueはfor _, x := range sでvと一致する要素がないか調べ、見つかればそのままsを返し、見つからなければappend(s, v)の結果を返します
ヒント3 — コード骨格
package main
import "fmt"
func zeroOutArray(arr [5]int) {
for i := range arr {
arr[i] = 0
}
fmt.Println("関数内(コピー):", arr)
}
func AppendUnique(s []int, v int) []int {
// v が s に含まれていれば s をそのまま返す
// 含まれていなければ append して返す
}
func main() {
// 1. 配列は値型
var scores [5]int
// scoresに10,20,30,40,50を格納
fmt.Println("呼び出し前:", scores)
zeroOutArray(scores)
fmt.Println("呼び出し後:", scores) // 変化していないはず
// 2. スライスの容量成長
nums := make([]int, 0, 3)
for i := 1; i <= 6; i++ {
nums = append(nums, i)
// len, cap, numsを出力
}
// 3. AppendUnique
unique := []int{1, 2, 3}
unique = AppendUnique(unique, 2)
unique = AppendUnique(unique, 4)
fmt.Println("unique:", unique)
}
残りの空欄(配列への値格納・出力処理・AppendUniqueの中身)を埋めてください。
✅ 模範解答
package main
import "fmt"
// zeroOutArray は配列を値渡しで受け取るため、内部での変更は呼び出し元に一切影響しない。
func zeroOutArray(arr [5]int) {
for i := range arr {
arr[i] = 0
}
fmt.Println("関数内(コピー):", arr)
}
// AppendUnique はvがsに含まれていなければ末尾に追加した新しいスライスを返す。
// 含まれていれば何もせずsをそのまま返す。
func AppendUnique(s []int, v int) []int {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}
func main() {
// --- 1. 配列は固定長・値型 ---
var scores [5]int
for i := range scores {
scores[i] = (i + 1) * 10
}
fmt.Println("呼び出し前:", scores)
zeroOutArray(scores)
fmt.Println("呼び出し後:", scores) // 変化なし(コピーが渡されただけ)
// --- 2. スライスは可変長・容量の成長 ---
nums := make([]int, 0, 3)
for i := 1; i <= 6; i++ {
nums = append(nums, i)
fmt.Printf("len=%d cap=%d slice=%v\n", len(nums), cap(nums), nums)
}
// --- 3. AppendUniqueの利用 ---
unique := []int{1, 2, 3}
unique = AppendUnique(unique, 2) // 既存なので追加されない
unique = AppendUnique(unique, 4) // 新規なので追加される
fmt.Println("unique:", unique)
}
▶ 実行結果を見る(go run main.go)
呼び出し前: [10 20 30 40 50]
関数内(コピー): [0 0 0 0 0]
呼び出し後: [10 20 30 40 50]
len=1 cap=3 slice=[1]
len=2 cap=3 slice=[1 2]
len=3 cap=3 slice=[1 2 3]
len=4 cap=6 slice=[1 2 3 4]
len=5 cap=6 slice=[1 2 3 4 5]
len=6 cap=6 slice=[1 2 3 4 5 6]
unique: [1 2 3 4]
※ 実際にgo run main.goで実行し、上記の出力を確認済みです。capの成長率はGoのバージョン・要素数によって変わる場合があります。
🪜 Step-by-Step 解説
func zeroOutArray(arr [5]int) {
for i := range arr {
arr[i] = 0
}
fmt.Println("関数内(コピー):", arr)
}
[5]int型の引数arrは呼び出し元のscoresとは別のメモリ領域にコピーされます。関数内でarr[i] = 0をいくら実行しても、それは「コピー」を書き換えているだけなので、呼び出し元のscoresには一切影響しません。これはGoの配列が値型であることの直接的な証拠です。
nums := make([]int, 0, 3)
for i := 1; i <= 6; i++ {
nums = append(nums, i)
fmt.Printf("len=%d cap=%d slice=%v\n", len(nums), cap(nums), nums)
}
make([]int, 0, 3)は「長さ0・容量3」のスライスを作ります。appendで要素を追加していくと、容量に余裕がある間(1〜3個目)は同じ基底配列に書き込まれますが、4個目を追加しようとした瞬間に容量が足りなくなり、Goランタイムはより大きな新しい配列を確保して中身をコピーし、そこに新要素を追加します。appendの戻り値を必ず変数に代入し直す必要があるのは、この「基底配列が差し替わる可能性がある」ためです。
func AppendUnique(s []int, v int) []int {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}
Goのスライス操作系関数は「スライスを受け取り、スライスを返す」という形が定石です。append自体が新しい基底配列を返す可能性がある以上、呼び出し元は常に戻り値を使う必要があり、それをそのまま関数のシグネチャに反映させています。
💡 設計思想・なぜこう書くのか
[5]intなら「呼んでも呼び出し元は変化しない(コピー渡し)」、[]intなら「基底配列が共有されるかもしれない(ただしappendで再確保される場合もある)」と、コードを読むだけで判断できます。🌐 他言語との比較
| 観点 | Go | Java | Python | JavaScript |
|---|---|---|---|---|
| 固定長コンテナ | [5]int(値型・型にサイズを含む) | 配列(int[]、参照型) | なし(tupleは意味が異なる) | なし(配列は常に動的) |
| 可変長コンテナ | []int(スライス、ヘッダ共有) | ArrayList<Integer> | list(組み込みの動的配列) | Array(常に動的) |
| 関数への値渡し | 配列はコピー、スライスはヘッダのみコピー(基底配列は共有) | 配列・ArrayListともに参照渡し的(同じオブジェクトを指す) | listは常に参照渡し的 | Arrayは常に参照渡し的 |
| 容量超過時の再確保 | appendが新しい基底配列を確保し戻り値として返す | ArrayListが内部的に自動拡張(戻り値の再代入は不要) | list.append()は内部的に自動拡張(戻り値の再代入は不要) | push()は内部的に自動拡張(戻り値の再代入は不要) |
Java/Python/JavaScriptでは可変長コンテナへの追加操作は戻り値を使わずに内部で完結しますが、Goのappendは「戻り値を必ず代入し直す」必要がある点が最大の違いです。これはスライスが「参照型のように見えて実は値型のヘッダを持つ」という独特の設計に由来しており、他言語経験者が最初に引っかかりやすいポイントです。
🏆 実務での使いどころ
- 固定サイズの配列: SHA256ハッシュ値(
[32]byte)やIPv4アドレス([4]byte)など、「サイズが仕様として決まっている」データは配列で表現すると、型レベルでサイズ違反を防げます - 可変長データの標準表現: APIレスポンスのリスト、DBクエリ結果の行集合など、実務コードで扱うコレクションのほとんどはスライスです
- 事前に容量が分かっている場合の
make最適化: 「だいたい何件になるか」が事前にわかる場合(例: DBのCOUNTクエリ結果を先に取得している)、make([]T, 0, n)で容量を確保しておくと、appendのたびに再確保が走るのを防ぎパフォーマンスが向上します - 重複排除ロジック:
AppendUniqueのようなパターンは、タグ一覧・権限リストなど「同じ値を重複して持たせたくないスライス」を扱う実務コードで頻出します(件数が多い場合はmap[T]struct{}を使う方が効率的です)
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
配列とスライスを同じものだと思い込み、[5]intと[]intを区別せず使う | 見た目が似ており、他言語の配列は1種類しかないことが多いため | [5]intは値型で固定長、[]intは可変長でヘッダ(ポインタ・len・cap)が実体。全く別の型として扱われる |
appendの戻り値を代入せず、元のスライス変数をそのまま使い続ける | Java/Python/JSの動的配列の感覚で「内部で勝手に伸びる」と思い込む | appendは容量不足時に新しい基底配列を返すことがあるため、戻り値を必ず変数に代入し直さないと追加した要素が失われる場合がある |
| 配列を関数に渡せば呼び出し元も変更されると思い込む | スライス・マップなど他の型が参照的に振る舞うのを見て混同する | 配列は値型なので関数呼び出しの引数では丸ごとコピーされる。呼び出し元を変更したい場合はポインタ(*[5]int)を渡す必要がある |
make([]int, 3)とmake([]int, 0, 3)を混同する | 第2引数と第3引数の意味の違い(len vs cap)を意識していない | make([]int, 3)は長さ3・容量3で要素がゼロ値で初期化済み。make([]int, 0, 3)は長さ0・容量3で、appendで追加するまで要素は存在しない |
🚀 次のステップ
- 発展: 2つのスライス変数
a := []int{1,2,3}とb := a[0:2]を作り、b[0] = 99と書き換えたときにa[0]がどうなるか確認してみましょう。「スライスは基底配列を共有する」ことの罠に触れる準備になります - 次回予告: Day 005 — スライス②(内部構造・容量・共有スライスの罠)(len/cap・スライスの共有によるバグ、デバッグ・コードレビュー問題)