The Go Blog

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

Valentin Deleplace
2024年2月22日

slices パッケージは、あらゆる型ののスライスに対して機能する関数を提供します。このブログ記事では、スライスがメモリ内でどのように表現され、それがガベージコレクタにどのように影響するかを理解することで、これらの関数をより効果的に使用する方法について説明します。また、最近これらの関数を調整して、より分かりやすくした方法についても説明します。

型パラメータを使用すると、比較可能な要素のスライスすべての型に対して、slices.Index のような関数を一度だけ書くことができます。

// Index returns the index of the first occurrence of v in s,
// or -1 if not present.
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

いくつかの新しい関数(`Insert`、`Replace`、`Delete` など)はスライスを変更します。それらがどのように機能し、それらを適切に使用方法を理解するには、スライスの基になる構造を調べる必要があります。

スライスは、配列の一部分のビューです。内部的には、スライスにはポインタ、長さ、および容量が含まれています。2つのスライスは同じ基になる配列を持つことができ、重複する部分をビューできます。

たとえば、このスライス `s` は、サイズ 6 の配列の 4 つの要素のビューです。

関数がパラメータとして渡されたスライスの長さを変更する場合、呼び出し元に新しいスライスを返す必要があります。基になる配列が拡張する必要がない場合、同じままである可能性があります。これは、append と `slices.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` がポインタを含んでいるとします。

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

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

インデックス 2、3、4 のギャップは、要素 `s[5]` を左にシフトし、新しい長さを `3` に設定することによって埋められます。

`Delete` は、要素をインプレースでシフトするため、新しい配列を割り当てる必要はありません。`append` と同様に、新しいスライスを返します。`slices` パッケージの他の多くの関数(`Compact`、`CompactFunc`、`DeleteFunc`、`Grow`、`Insert`、`Replace` など)もこのパターンに従います。

これらの関数を呼び出すときは、基になる配列が変更されているため、元の スライスは無効と見なす必要があります。関数を呼び出して戻り値を無視するのは間違いです。

    slices.Delete(s, 2, 5) // incorrect!
    // s still has the same length, but modified contents

不要な生存期間の問題

Go 1.22 より前では、`slices.Delete` はスライスの新しい長さと元の長さの間の要素を変更しませんでした。返されたスライスにはこれらの要素は含まれませんでしたが、元の(現在は無効になっている)スライスの末尾に作成された「ギャップ」は、それらを保持し続けました。これらの要素は大きなオブジェクト(20MB の画像)へのポインタを含む可能性があり、ガベージコレクタはこれらのオブジェクトに関連付けられたメモリを解放しませんでした。これは、重大なパフォーマンスの問題につながる可能性のあるメモリリークが発生しました。

上記の例では、1つの要素を左にシフトすることにより、`s[2:5]` からポインタ `p2`、`p3`、`p4` を正常に削除しています。しかし、`p3` と `p4` は、`s` の新しい長さよりも後の基になる配列にまだ存在しています。ガベージコレクタはそれらを再利用しません。それほど明白ではありませんが、`p5` は削除された要素の1つではありませんが、配列のグレーの部分に保持されている `p5` ポインタのために、メモリがリークする可能性があります。

これは、開発者が「見えない」要素がまだメモリを使用していることに気づいていない場合、混乱を招く可能性がありました。

そのため、2つの選択肢がありました。

  • `Delete` の効率的な実装を維持する。ユーザーが指し示された値が解放されるようにしたい場合は、古いポインタを `nil` に設定します。
  • または、`Delete` を変更して、常に古い要素をゼロに設定します。これは追加の作業であり、`Delete` の効率がわずかに低下します。ポインタをゼロにする(`nil` に設定する)と、オブジェクトが到達不能になったときにガベージコレクションが可能になります。

どちらのオプションが最適かは明らかではありませんでした。最初のオプションはデフォルトでパフォーマンスを提供し、2番目のオプションはデフォルトでメモリ節約を提供しました。

修正

重要な観察は、「古いポインタを `nil` に設定する」ことは見た目ほど簡単ではないということです。実際、このタスクは非常にエラーが発生しやすいため、ユーザーにそれを書く負担をかけるべきではありません。プラグマティズムから、5つの関数 `Compact`、`CompactFunc`、`Delete`、`DeleteFunc`、`Replace` の実装を変更して「末尾をクリアする」ことにしました。うれしい副作用として、認知負荷が軽減され、ユーザーはこれらのメモリリークについて心配する必要がなくなりました。

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

5つの関数で変更されたコードは、新しい組み込み関数 clear (Go 1.21) を使用して、古い要素を `s` の要素型のゼロ値に設定します。

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

テストの失敗

この変更により、Go 1.21 で合格した一部のテストが、スライス関数が誤って使用された場合、Go 1.22 で失敗するようになりました。これは良いニュースです。バグがある場合、テストはそれを知らせる必要があります。

`Delete` の戻り値を無視した場合、

slices.Delete(s, 2, 3)  // !! INCORRECT !!

`s` に nil ポインタが含まれていないと誤って想定する可能性があります。Go Playground の例

`Compact` の戻り値を無視した場合、

slices.Sort(s) // correct
slices.Compact(s) // !! INCORRECT !!

`s` が正しくソートされ、圧縮されていると誤って想定する可能性があります。

`Delete` の戻り値を別の変数に割り当て、元の スライスを使い続けた場合、

u := slices.Delete(s, 2, 3)  // !! INCORRECT, if you keep using s !!

`s` に nil ポインタが含まれていないと誤って想定する可能性があります。

誤ってスライス変数をシャドウイングし、元の スライスを使い続けた場合、

s := slices.Delete(s, 2, 3)  // !! INCORRECT, using := instead of = !!

`s` に nil ポインタが含まれていないと誤って想定する可能性があります。

結論

`slices` パッケージの API は、要素を削除または挿入するための従来のジェネリクス以前の構文よりも全体的に改善されています。

上記の「落とし穴」を回避しながら、新しい関数を使用することをお勧めします。

実装の最近の変更のおかげで、API を変更することなく、開発者にとって追加の作業を行うことなく、メモリリークのクラスが自動的に回避されます。

参考文献

`slices` パッケージの関数のシグネチャは、メモリ内のスライスの表現の仕様に大きく影響されます。以下をお読みください。

古い要素のゼロ化に関する元の提案には、多くの詳細とコメントが含まれています。

次の記事:より強力な Go 実行トレース
前の記事:Go 1.22 のルーティングの強化
ブログインデックス