最近Goを使い始めたのですが、スライスの要素削除(フィルタリング)について調べていたところ、アロケーション(メモリ確保)を発生させずに高速に処理する書き方を知りました。
なぜその書き方で動くのか、スライスの挙動を含めて調べてみたので備忘録としてまとめます。
やりたかったこと
既存のスライスから、特定の条件の要素だけを残して他を削除したい。
単純に make で新しいスライスを作るとメモリの確保が起きてしまうので、既存の領域をうまく使い回したい。
結論:こう書く
以下のように書くことで、メモリ割り当てを行わずにフィルタリングできました。
func filterMembers(members []int, removeID int) []int {
// 1. 元の配列を共有する長さ0のスライスを作る
next := members[:0]
for _, id := range members {
if id != removeID {
// 2. appendで条件に合うものだけ詰める
// (実体の配列が上書きされる)
next = append(next, id)
}
}
// 3. 結果を返す
return next
}
ポイントは next := members[:0] です。
一見不思議な書き方ですが、これがどう動いているのかを整理します。
仕組みの理解
スライスと「実体の配列」
Goのスライスについて調べてみると、スライス自体はデータそのものを持っているわけではないことが分かりました。
スライスは、メモリ上のどこかにある 「実体の配列」 を参照しているだけのようです。
具体的には、スライス変数は以下の3つの情報を持っています。
- ptr: 実体の配列のどこから見るか(開始位置)
- len: 実体の配列をいくつ見るか(長さ)
- cap: 実体の配列の容量限界
イメージとしては以下のような感じです。
[ スライス変数 members ] [ 実体の配列 ]
+-----+-----+-----+ +----+----+----+----+
| ptr | len | cap | -------> | 10 | 20 | 30 | 40 |
+-----+-----+-----+ +----+----+----+----+
| 4 4 ^
| |
+---------------------------+
members[:0] で起きること
ここで next := members[:0] を実行すると、「同じ実体の配列を指しつつ、lenだけを0にした新しいスライス」 が作られます。
[ スライス変数 next ]
+-----+-----+-----+
| ptr | len | cap | --+ (同じ実体を共有!)
+-----+-----+-----+ |
| 0 4 |
+------------------+
|
v
[ スライス変数 members ] [ 実体の配列 ]
+-----+-----+-----+ +----+----+----+----+
| ptr | len | cap | -------> | 10 | 20 | 30 | 40 |
+-----+-----+-----+ +----+----+----+----+
4 4
重要なのは、新しい実体を作るのではなく、既存の実体を使い回している という点です。
そのため、メモリのアロケーションが発生しません。
append での上書き
この next(長さ0)に対して append を行うと、共有している 実体の配列 の先頭から値が上書きされていきます。
例えば 20 を削除(スキップ)して 10, 30, 40 を残す場合、ループが進むにつれて実体の中身は以下のように変化します。
-
10を append → 実体の[0]に10を上書き(変化なし) -
20はスキップ -
30を append → 実体の[1]に30を上書き -
40を append → 実体の[2]に40を上書き
ループ終了後の状態:
実体の配列: [ 10, 30, 40, 40 ]
^ ^ ^ ^
| | | └- (ゴミとして残る古い値)
| | └----- nextの3つ目として上書き
| └--------- nextの2つ目として上書き
└------------- nextの1つ目として上書き
最後に変数の更新が必要
上記の図の通り、実体の中身は書き換わりましたが、元の変数 members はまだ len=4(4つ全部見る設定)のままです。
このままだと、末尾に残った古い 40 まで見えてしまいます。
そのため、最後に return next で新しいスライスを返すか、members = next と代入してスライスの情報を更新する必要があるわけです。
まとめ
-
s[:0]は、実体の配列 を共有したまま長さを0にするテクニック。 - これを利用すると、
append時に新しいメモリ確保を行わず、実体の中身を直接上書きできる。 - スライスはあくまで「実体を見るための窓口」であり、実体そのものではない。
この挙動を理解しておくと、パフォーマンスを意識したコードが書けそうです。