スライスに対する堅牢なジェネリック関数

Robust generic functions on slices by Valentin Deleplace

slices パッケージは、あらゆる型のスライスに対して動作する関数を提供します。この記事では、スライスがメモリ上でどのように表現され、それがガベージコレクタにどう影響するかを理解することで、これらの関数をより効果的に使う方法を解説します。また、最近、これらの関数の挙動をより驚きの少ないものにするために行った調整についても紹介します。

型パラメータを使えば、slices.Index のような関数を、比較可能な要素を持つあらゆる型のスライスに対して一度だけ書けば済むようになります。

// Index は s の中で最初に v が出現するインデックスを返す。
// 見つからない場合は -1 を返す。
func Index[S ~[]E, E comparable](s S, v E) int {
    for i := range s {
        if v == s[i] {
            return i
        }
    }
    return -1
}

これにより、要素の型が変わるたびに Index を実装し直す必要がなくなりました。

slices パッケージには、スライスに対するよくある操作を行うためのこうしたヘルパー関数が数多く含まれています。

    s := []string{"Bat", "Fox", "Owl", "Fox"}
    s2 := slices.Clone(s)
    slices.Sort(s2)
    fmt.Println(s2) // [Bat Fox Fox Owl]
    s2 = slices.Compact(s2)
    fmt.Println(s2)                  // [Bat Fox Owl]
    fmt.Println(slices.Equal(s, s2)) // false

InsertReplaceDelete など、いくつかの新しい関数はスライスを変更します。これらがどう動作し、どう正しく使うべきかを理解するには、スライスの内部構造を確認する必要があります。

スライスとは配列の一部分に対するビューです。内部的には、スライスはポインタ、長さ、容量を持っています。2つのスライスが同じ基底配列を持ち、重なり合う部分を参照することもあります。

例えば、次のスライス s は、サイズ6の配列のうち4つの要素に対するビューです。

サイズ6の配列のうち4要素を参照するスライスsの図

ある関数が、引数として渡されたスライスの長さを変更する場合、その関数は呼び出し元に新しいスライスを返す必要があります。基底配列が拡張を必要としなければ、そのまま同じ配列が使われることもあります。これが、appendslices.Compact が値を返すのに対して、要素を並べ替えるだけの slices.Sort が値を返さない理由です。

スライスの一部を削除する処理を考えてみましょう。ジェネリクスが登場する以前、スライス s から部分 s[2:5] を削除する標準的な方法は、末尾部分を中間部分の上にコピーするために append 関数を呼び出すというものでした。

s = append(s[:2], s[5:]...)

この構文は複雑でミスを起こしやすく、サブスライスと可変長引数が絡んでいました。要素の削除をより簡単にするために、私たちは slices.Delete を追加しました。

func Delete[S ~[]E, E any](s S, i, j int) S {
       return append(s[:i], s[j:]...)
}

1行で書けるこの Delete 関数は、プログラマの意図をより明確に表現しています。長さ6、容量8で、ポインタを格納しているスライス s を考えてみましょう。

長さ6、容量8で、ポインタを保持しているスライスsの図

次の呼び出しは、スライス s から s[2]s[3]s[4] の要素を削除します。

s = slices.Delete(s, 2, 5)

slices.Delete(s, 2, 5) 実行後のメモリ状態を示す図

インデックス2、3、4にできた隙間は、要素 s[5] を左にシフトすることで埋められ、新しい長さは3に設定されます。

Delete は要素をその場でシフトするだけなので、新しい配列を確保する必要はありません。append と同様に、新しいスライスを返します。slices パッケージ内の他の多くの関数、たとえば CompactCompactFuncDeleteFuncGrowInsertReplace なども同じパターンに従っています。

これらの関数を呼び出す際には、基底配列が変更されているため、元のスライスはもはや無効なものとみなさなければなりません。関数を呼び出した際に戻り値を無視してしまうのは間違いです。

    slices.Delete(s, 2, 5) // 誤り!
    // sの長さは変わらないが、中身は変更されている

意図しない生存の問題

Go 1.22より前は、slices.Delete は新しい長さと元の長さの間にある要素を変更していませんでした。戻り値のスライスにはそれらの要素は含まれませんが、無効化された元のスライスの末尾にできた「隙間」は、それらの要素を保持し続けていました。これらの要素が大きなオブジェクト(20MBの画像など)へのポインタを含んでいる場合、ガベージコレクタはそのオブジェクトに関連するメモリを解放しません。これにより、深刻なパフォーマンス上の問題につながりかねないメモリリークが発生していました。

先ほどの例では、要素を1つ左にシフトすることで、s[2:5] からポインタ p2p3p4 の削除には成功しています。しかし p3p4 は、s の新しい長さを超えた部分の基底配列に依然として残っています。ガベージコレクタはこれらを回収しません。より気づきにくい点として、p5 は削除対象の要素ではないにもかかわらず、配列のグレーの部分に残された p5 へのポインタのせいで、そのメモリがリークする可能性があります。

「見えなくなった」要素が依然としてメモリを使用し続けていることに気づいていなければ、開発者にとって混乱の元になりかねません。

そこで、私たちには2つの選択肢がありました。

  • Delete の効率的な実装をそのまま維持し、参照先の値を確実に解放したいユーザー自身に、不要になったポインタを nil に設定してもらう。
  • あるいは、Delete を変更し、不要になった要素を常にゼロ値に設定するようにする。これは余分な作業であり、Delete の効率をわずかに低下させます。ポインタをゼロ値にする(nil に設定する)ことで、他から到達不可能になったオブジェクトをガベージコレクションできるようになります。

どちらの選択肢が最善かは自明ではありませんでした。前者はデフォルトでパフォーマンスを優先し、後者はデフォルトでメモリの節約を優先するものでした。

修正内容

「不要になったポインタを nil に設定する」という作業は、見た目ほど簡単ではありません。実際、この作業はミスを起こしやすく、ユーザーにその負担を強いるべきではありません。実用性を重視し、私たちは CompactCompactFuncDeleteDeleteFuncReplace という5つの関数の実装を変更し、「末尾をクリアする」ようにしました。この変更には嬉しい副作用もあり、認知負荷が下がって、ユーザーはこうしたメモリリークをもはや気にする必要がなくなりました。

Go 1.22では、Delete を呼び出した後のメモリは次のようになります。

Delete呼び出し後、不要な要素がnilでクリアされたメモリ状態を示す図

この5つの関数で変更されたコードは、新しい組み込み関数である clear(Go 1.21で追加)を使い、不要になった要素を s の要素型のゼロ値に設定しています。

Delete関数の実装差分。clear組み込み関数を使って不要な要素をゼロ値にする変更を示す

E がポインタ型、スライス型、マップ型、チャネル型、あるいはインターフェース型の場合、E のゼロ値は nil です。

テストの失敗

この変更によって、Go 1.21では通っていたテストの一部が、slices パッケージの関数を誤った使い方をしていた場合にGo 1.22では失敗するようになりました。これは良い知らせです。バグがあるなら、テストがそれを教えてくれるべきだからです。

Delete の戻り値を無視すると、

slices.Delete(s, 2, 3)  // !! 誤り !!

snil ポインタが含まれていないと誤って思い込んでしまうかもしれません。Go Playgroundでの実行例

Compact の戻り値を無視すると、

slices.Sort(s) // 正しい
slices.Compact(s) // !! 誤り !!

s が正しくソートされ、圧縮されていると誤って思い込んでしまうかもしれません。実行例

Delete の戻り値を別の変数に代入し、元のスライスを使い続けると、

u := slices.Delete(s, 2, 3)  // !! 誤り。sを使い続ける場合 !!

snil ポインタが含まれていないと誤って思い込んでしまうかもしれません。実行例

誤ってスライス変数をシャドーイングしてしまい、元のスライスを使い続けると、

s := slices.Delete(s, 2, 3)  // !! 誤り。=ではなく:=を使っている !!

snil ポインタが含まれていないと誤って思い込んでしまうかもしれません。実行例

まとめ

slices パッケージのAPIは、要素を削除したり挿入したりするための従来のジェネリクス以前の構文と比べて、正味の改善となっています。

開発者の皆さんには、上で挙げたような「落とし穴」を避けつつ、これらの新しい関数を使うことをお勧めします。

最近の実装変更のおかげで、APIを変更することなく、また開発者に余分な作業を強いることもなく、あるクラスのメモリリークが自動的に回避されるようになりました。

参考記事

slices パッケージ内の関数のシグネチャは、メモリ上でのスライスの表現方法の詳細に大きく影響を受けています。以下もあわせてお読みになることをお勧めします。

不要になった要素をゼロ値にすることについての元のプロポーザルには、詳細な議論とコメントが数多く含まれています。

By Valentin Deleplace