なんとなく使っている配列(スライス)とポインタとは?
Goスライス定義パターンと性能比較
背景
コードレビューをしていると、Goで配列(正確にはスライス)を定義する際に書き方がバラバラなケースをよく見かけます。
今回のドキュメントでは、代表的な3パターンの違いと、実際の挙動・性能差を確認するベンチマーク結果をまとめます。
サンプルコード一式は GitHubリポジトリ を参照してください。
基本構造と3パターン
パターンA: []User(値スライス / ValueSlice)
- Goのデフォルトで最も一般的な形
- スライスは参照型なので、関数に渡しても参照先は共有されるが、スライス自体のコピーは軽量
- nilスライスと空スライスを区別する必要がない場合に推奨
type Hoge struct {
Users []User
}
パターンB: *[]User(スライスポインタ / SlicePointer)
- スライス全体へのポインタを持つ
- nilスライス / 空スライス / 要素あり を厳密に区別したいときに有効
- 性能メリットは薄く、可読性を落とす可能性あり
type Fuga struct {
Users *[]User
}
パターンC: []*User(要素がポインタ / ElementPointerSlice)
- 各要素をポインタで保持
- 大きな構造体のコピーを避けたいときや共有更新が必要な場合に有効
- 副作用管理が難しくなるため、設計時に注意が必要
type Piyo struct {
Users []*User
}
比較表
| パターン | メリット | デメリット | 主な用途 |
|---|---|---|---|
A: []User(値スライス) |
可読性高い / デフォルトの選択肢 | 大きな構造体コピーはコスト増 | 一般的なコレクション保持 |
B: *[]User(スライスポインタ) |
nil/空/要素ありの明確区別 | 性能面の恩恵少 / 可読性低下 | 厳密な状態管理が必要な場合 |
C: []*User(要素がポインタ) |
大きな構造体コピー回避 / 共有更新可能 | 副作用管理が複雑 / nil管理が必要 | 大規模データ共有 / 更新処理 |
実行例
※ main.go の出力例
% go run .
=== 挙動デモ ===
[A] 値スライス
nilスライス (omitempty適用) => {}
空スライス ([]) => {}
要素あり => {"users":[{"ID":1,"Name":"Alice","Age":20,"Email":"a@example.com","City":"Sendai"}]}
a.Users[0].Name="Alice", a2.Users[0].Name="Changed in a2"
[B] スライスポインタ
nilポインタ(キー省略) => {}
空スライス ([]) => {"users":[]}
要素あり => {"users":[{"ID":2,"Name":"Bob","Age":30,"Email":"b@example.com","City":"Kanazawa"}]}
B共有性確認: Bob-Updated
[C] 要素がポインタ
c.Users[0].Name="Carol-Shared", other.Users[0].Name="Carol-Shared"
Cの2要素目はnil: true
ベンチマーク
ベンチマーク項目
※ bench_test.go より
- Iterate(値スライス / 要素がポインタ)
- Copy(値スライス / 要素がポインタ)
- Update(値スライス / 要素がポインタ)
- JSONマーシャル(JSON Marshal)(値スライス / 要素がポインタ)
- DTO変換(DTO Transform)(値スライス / 要素がポインタ)
- Filter(値スライス / 要素がポインタ)
- Sort(値スライス / 要素がポインタ)
- GroupByCity(値スライス / 要素がポインタ)
- JSON Lines(行区切りJSON)(値スライス / 要素がポインタ)
ベンチマーク総評
-
走査(Iterate)
要素がポインタの方が約20%高速(37.4µs → 29.6µs)
要素がポインタなのでループ時のコピーコストが低い
構造体が小さい場合は差が縮まる -
コピー(Copy)
要素がポインタの方が圧倒的に速く、アロケーションも大幅減(6.4MB → 0.8MB)
値スライスは全構造体を複製、要素がポインタの場合は参照だけ移すため軽量 -
更新(Update)
双方ほぼ同等(ns/opレベル)
値スライスは独立性あり、副作用なし
要素がポインタの場合は共有更新となり副作用に注意 -
JSONマーシャル(Marshal)
両者ほぼ同等
エンコード処理が支配的で差は小さい -
DTO変換(DTO Transform)
要素がポインタの方が若干速い(743µs → 699µs)
メモリ確保量は同じ(変換先スライスの新規確保) -
フィルタ(Filter)
要素がポインタの方が約3倍高速&メモリ消費も大幅減(1.16MB → 0.15MB)
値スライスは一致要素をコピーするためコスト増 -
ソート(Sort)
双方ほぼ同等(要素がポインタの方がわずかに軽量) -
グルーピング(GroupByCity)
要素がポインタの方が約2.3倍高速&メモリ大幅減(11.6MB → 1.5MB)
値スライスは要素コピー、要素がポインタの場合は参照追加だけ -
JSON Lines(行区切りJSON)
要素がポインタの方がわずかに速く、メモリも減少
I/Oバウンド寄りで差は限定的
判断フロー(おすすめ)
- 特別な理由がなければ A: 値スライス(
[]User) - 要素の共有更新や大きな構造体コピー回避が必要 → C: 要素がポインタ(
[]*User) - nil/空/要素ありを厳密に区別する必要がある → B: スライスポインタ(
*[]User)
付録A:なぜスライスポインタのベンチを入れていないのか
スライスポインタ(*[]User)は内部的にスライス構造体(ヘッダ部分)をポインタで参照するだけで、要素アクセスやコピー時の挙動は値スライス([]User)とほぼ同一です。
そのため、ベンチマーク項目を増やしても数値的な傾向は値スライスと同じになるため、比較対象からは除外しました。
付録B:副作用管理とnil管理のサンプル(要素がポインタ)
対象: 「要素がポインタ([]*User)」で起こりやすい副作用とnil混入の落とし穴、および安全策。
実行: リポジトリに examples/side_effects_and_nil/main.go を配置して、以下を実行します。
go run ./examples/side_effects_and_nil
何が分かるか
- 共有参照の副作用:フィルタ後の更新が元スライスへ波及(浅いコピーの罠)。
-
nil要素の危険:アクセス/ソート/JSON出力でのクラッシュやnull混入。 -
安全策:防衛的ディープコピー、
nil除去(compact)、JSON直前での値スライス化、レイヤ境界での独立化。
主要抜粋(要点のみ)
// 浅いコピーだと共有参照のまま(副作用が伝播)
shallow := append([]*User(nil), ptrs...)
shallow[1].City = "Tokyo" // -> ptrs[1].City も "Tokyo" になる
// nil混入に配慮したソート(nilは末尾へ)
sort.Slice(ptrs, func(i, j int) bool {
ui, uj := ptrs[i], ptrs[j]
switch {
case ui == nil && uj == nil:
return false
case ui == nil:
return false
case uj == nil:
return true
default:
return ui.Name < uj.Name
}
})
// 防衛的ディープコピーで独立させる
func filterPtrDeepCopy(ps []*User, pred func(*User) bool) []*User {
out := make([]*User, 0, len(ps))
for _, p := range ps {
if p == nil || !pred(p) { continue }
cp := *p
out = append(out, &cp)
}
return out
}
完全版ソース: examples/side_effects_and_nil/main.go を参照してください。
Discussion