【Go 1.21】愚直なループ処理から脱却できるslices/mapsパッケージ

TL;DR

  • slices・mapsパッケージによって、これまでfor文で実装するしかなかったsliceやmapなどに対する汎用処理が簡単に実装できるようになった
  • これまでGoには継承などの共通の振る舞いを実装する仕組みが存在しなかったが、Go 1.18で導入されたジェネリクスによって今回の汎用処理が実現できている
  • 今後さらに関数の追加や、イテレータ用のパッケージの導入が検討されており、今後更に便利になりそう

slicesパッケージ

pkg.go.dev

従来のsliceに対する汎用処理の実装方法

これまでGoではsliceに対する汎用処理が言語側でサポートされていませんでした。そのためsliceに対する処理は、for文によるループ処理を都度記述する必要がありました。以下はslice内に対象の数値が含まれているかチェックしたい場合のコード例です。

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    target := 3

    // ここから判定ロジック
    hasTarget := false
    for _, v := range numbers {
        if v == target {
            hasTarget = true
            break
        }
    }
    // ここまで7行

    fmt.Println("Has a target:", hasTarget)
}
Has a target: true

こういった汎用的な処理が言語側でサポートされている他言語と比べると、実装の手間や可読性などの観点でかなり見落とりします。以下はRubyで同じ処理を実装した場合のサンプルです。

numbers = [1, 2, 3, 4, 5]
target = 3
hasTarget = numbers.include?(target) # 1行で判定可能
p "Has a target: #{hasTarget}"
Has a target: true

slicesパッケージでの実装例

Go1.21からsliceに対する汎用処理を提供するslicesパッケージが導入されています。以下は先ほどのコードを追加されたslicesパッケージの関数で書き換えたものです。

func main() {
    numbers := []int{1, 2, 3, 4, 5}
    target := 3
    hasTarget := slices.Contains(numbers, target) // 1行で判定可能
    fmt.Println("Has a target:", hasTarget)
}
Has a target: true

ループ処理を自前で実装する必要がなくなったので、格段に見やすくなりましたし、実装しやすくなりました。

ジェネリクスによって様々な型に対応可能

先ほどのサンプルコードではintのsliceを使いましたが、slicesパッケージの関数はジェネリクスを活用して実装されているため、様々な型に対応しています。先ほど使用したContains関数の定義を見てみると以下のようになっています。

func Contains[S ~[]E, E comparable](s S, v E) bool pkg.go.dev

型パラメータが[S ~[]E, E comparable]と定義されています。comparableは言語側で事前定義された型制約で、intstringなどの比較可能な型が受入可能です。なので以下のようにstringfloat64など様々な型の配列を同じ関数で処理できます。

func main() {
    strings := []string{"a", "b", "c", "d", "e"} // stringの場合
    targetStr := "f"
    hasStr := slices.Contains(strings, targetStr)
    fmt.Println("Has a target:", hasStr) // Has a target: false

    numbers := []float64{0.1, 0.5, 1.2, 1.5, 2.0} // float64の場合
    targetNum := 2.0
    hasNum := slices.Contains(numbers, targetNum)
    fmt.Println("Has a target:", hasNum) // Has a target: true
}

コールバック関数によって複雑な仕様にも対応可能

ジェネリクスによって様々な型を受け入れることが可能になりましたが、構造体(sturct)などcomparableを満たしていない型では先ほどの関数を利用できません。そういったケースでも柔軟に対応できるように、コールバック関数を渡せる関数も用意されています。

func ContainsFunc[S ~[]E, E any](s S, f func(E) bool) bool pkg.go.dev

これによって構造体(sturct)の比較も可能になっています。以下はslice内の構造体の一部が対象文字列と一致するかチェックするコードです。

func main() {
    type person struct {
        name string
        age  int
    }
    persons := []person{
        {name: "Tom", age: 20},
        {name: "Bob", age: 30},
    }
    target := "Bob"

    hasTarget := slices.ContainsFunc(persons, func(p person) bool {
        return p.name == target
    })
    fmt.Println("Has a target:", hasTarget) // Has a target: true
}

その他にも単純一致ではなく複雑な条件を指定することも可能です。以下はslice内に奇数が含まれるかチェックするコードです。

func main() {
    numbers := []int{2, 4, 6, 8}

    callbackFunc := func(n int) bool {
        return n%2 != 0
    }
    hasOdd := slices.ContainsFunc(numbers, callbackFunc)
    fmt.Println("Has a odd:", hasOdd) // Has a target: false
}

slicesパッケージの関数一覧

上記で例にあげたContains関数以外にも様々なユースケースごとの関数が用意されています。

  • 検索
    • BinarySearch/BinarySearchFunc:対象要素を二分探索して存在有無(bool)とインデックス(int)を返す
    • Contains/ContainsFunc:対象要素が含まれているか(bool)を返す
    • Equal/EqualFunc:sliceを比較して全ての要素が同じか(bool)を返す
    • Index/IndexFunc:対象要素のインデックス(int)を返す
    • IsSorted/IsSortedFunc:ソート済みか(bool)を返す
    • Max/MaxFunc:最大の要素を返す
    • Min/MinFunc:最小の要素を返す
  • データ操作 ※いずれも元のsliceを破壊的に変更せず、新たなsliceを返却します
    • Clip:未使用容量を削除する
    • Clone:コピーを返す(Shallow Copy
    • Compare/CompareFunc:重複する要素を削除する
    • Delete/DeleteFunc:指定した範囲を削除する
    • Grow:容量を指定した分だけ増やす
    • Insert:指定した箇所に要素を挿入する
    • Replace:指定した範囲を別で指定したsliceに置き換える
    • Sort:昇順で並び替える
    • SortFunc:指定した関数のロジックで並び替える
    • SortStableFunc:指定した関数のロジックで並び替える(安定ソート)
    • Reverse:逆順に並び替える

まだ他言語に比べると少なく感じますが、他にも新たな関数の追加が検討されています。 github.com

mapsパッケージ

pkg.go.dev

mapに対する汎用的な処理については、mapsパッケージが提供しています。以下は削除の実装例です。

func main() {
    origin := map[int]string{
        1:    "one",
        10:   "Ten",
        1000: "THOUSAND",
    }

    // 削除
    maps.DeleteFunc(origin, func(k int, v string) bool {
        return v == "THOUSAND"
    })
    fmt.Println(origin) // map[1:one 10:Ten]
}
  • 検索
    • Equal/EqualFunc:同じkey/valueのペアが含まれているか
  • データ操作
    • DeleteFunc:条件を満たすkey/valueのペアを削除する
    • Clone:コピーを返す(Shallow Copy
    • Copy:対象mapを別に指定したmapへコピーする(keyが重複する場合は上書き)

こちらはslicesパッケージに比べるとボリューム少なめですが、他にも新たな関数の追加が検討されています。 github.com

検討中のイテレータ用のパッケージについて

今回の改修はsliceとmapのそれぞれに対して専用のパッケージを用意して実現していますが、こういったイテレータ全般に対する汎用処理を提供するパッケージの検討も進められています。

github.com

以下はexpパッケージ(実験的なパッケージ)への追加の提案内容ですが、他言語で良く見かけるようなイテレータに対する定番の処理が提案されています。

Package xiter implements basic adapters for composing iterator sequences:

[Concat] and [Concat2] concatenate sequences. [Equal], [Equal2], [EqualFunc], and [EqualFunc2] check whether two sequences contain equal values. [Filter] and [Filter2] filter a sequence according to a function f. [Limit] and [Limit2] truncate a sequence after n items. [Map] and [Map2] apply a function f to a sequence. [Merge], [Merge2], [MergeFunc], and [MergeFunc2] merge two ordered sequences. [Reduce] and [Reduce2] combine the values in a sequence. [Zip] and [Zip2] iterate over two sequences in parallel.

github.com

本ブログ投稿時点ではacceptされていませんが、順調に進めば次のバージョンに組み込まれるかもしれません。

まとめ

イテレータ関連の汎用処理が言語側でサポートされていないのは、Goの使いづらいポイントの一つでしたが、それが大きく改善のはとても嬉しいです(毎回for文を書く作業から解放される...)

今回追加されたslices・mapsパッケージや、新たに検討されているイテレータのパッケージの実装にはジェネリクスが非常に多く活用されていて、ジェネリクスの活用例や実装のお手本を知る良い機会になりました。ジェネリクスの具体的な活用方法を知りたい時は、これらの実装を参考にしてみるのも良いかもしれません。