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

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

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

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

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

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

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

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

Deleteは、要素をその場でシフトするため、新しい配列を割り当てる必要はありません。appendと同様に、新しいスライスを返します。slicesパッケージの他の多くの関数も、CompactCompactFuncDeleteFuncGrowInsert、および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]からポインタp2p3p4を正常に削除しています。しかし、p3p4は、sの新しい長さを超えて、基底配列にまだ存在します。ガベージコレクタはそれらを回収しません。あまり明白ではありませんが、p5は削除された要素の1つではありませんが、配列の灰色の部分に保持されているp5ポインタのために、そのメモリはまだリークする可能性があります。

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

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

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

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

修正

重要な観察は、「古いポインタをnilに設定する」ことが見かけほど簡単ではないということです。実際、このタスクはエラーが発生しやすいため、ユーザーにその負担を負わせるべきではありません。実用主義から、CompactCompactFuncDeleteDeleteFuncReplaceの5つの関数の実装を「末尾をクリアする」ように変更することを選択しました。素晴らしい副次的な効果として、認知負荷が軽減され、ユーザーはこれらのメモリリークについて心配する必要がなくなりました。

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のルーティング強化
ブログインデックス