Goライブラリ”samber/lo”を使ってLodash/jQueryのようにスライス/マップを操作する

はじめに

Go 言語を使った開発をしている方にオススメのライブラリをご紹介します。

皆さん、Go 言語で Lodash や jQuery のようにスライス ( 配列 ) やマップを操作できたら便利だと思いませんか?
今回の記事では、samber/loという Go 言語のライブラリとよく利用する関数をご紹介します。

samber/loとはどのようなライブラリで、どういう使い方ができるのでしょうか?
また、面倒くさがり屋なエンジニアに向けた Tips もありますので、ぜひ最後までご覧ください。
FilterUniqなど、個人的に使いやすい関数についても解説しています。
この記事を読んで、samber/loを使って開発効率をアップさせましょう!

検証環境

$ go version
go version go1.20.2 darwin/arm64

samber/lo とは?

Lodash と呼ばれ得る JavaScript のライブラリのインターフェイスを模倣して作られています。

Reflection を使って同様の機能を提供するライブラリ ( ex: go-funk ) もありますが、こちらは Go1.18 以上から利用可能となった Generics の仕組みを導入しています。
それにより、型チェックが行われコンパイルのタイミングで不正な代入を検出できます。

また、Generics を使用した場合、Reflection を使用した場合よりも高速に動作します。
実際、ベンチマークテストでは for 構文を使ったときと同様の性能がでたそうです。

将来的には slicesmaps といった組み込みライブラリで提供される機能となるかもしれないとのことです。

なぜ lo というパッケージ名?

Lodash に似ているが短い名前で、 Go で使われているパッケージ名と重複しないものにしたかったそうです。

インストール方法

サイトには以下のコマンドを使うように書かれていました。

$ go get github.com/samber/lo@v1

2023-04-07 現在、Github のページを見ても v2 タグは存在していませんが、今後 v2 の機能が登場したときには後方互換を担保しない可能性があるため、 v1 を明示してほしいのでしょう。

使い方

利用を始めるにはlog をインポートするだけです。

import "github.com/samber/lo"

例えばスライスから重複要素を除外するための関数 Uniq を呼び出すには以下のように記述します。

package main

import (
    "fmt"

    "github.com/samber/lo"
)

func main() {
    duplicatedNames := []string{
        "John",
        "Jane",
        "Jack",
        "John",
        "Jane",
    }

    var uniqNames []string

    uniqNames = lo.Uniq[string](duplicatedNames)

    fmt.Printf("%v\n", uniqNames)
}

実行結果は以下のようになります。
名前の重複が解消されていることがわかります。

$ go run main.go
[John Jane Jack]

Generics の明示している uniqNames = lo.Uniq[string](duplicatedNames) の部分ですが、多くの場合型が推測可能なため、以下のようにシンプルに記述できます。

    uniqNames = lo.Uniq(duplicatedNames)

ここで、先ほどの uniqName の型を []string から []int に変更してみます。

    var uniqNames []int

    uniqNames = lo.Uniq(duplicatedNames)

実行しようとすると、コンパイルエラーとなります。
関数の戻り値の型が不正となりました。

$ go run main.go
# command-line-arguments
./main.go:20:14: cannot use lo.Uniq(duplicatedNames) (value of type []string) as []int value in assignment

面倒くさがり屋な開発者向けの Tips

こちらも Github ページに書かれていますが、毎回 lo.というプレフィックスを付けて関数を呼び出すのが面倒な「怠惰」なエンジニア向けの Tips として次のようなものがあります。

import (
    . "github.com/samber/lo"
)

このように記述することで、先程の lo.Uniq 呼び出しが更に簡潔に記述できます。

    uniqNames = Uniq(duplicatedNames)

Go のソースコード1ファイルだけで提供されるようなシンプルな「スクリプト」であれば、この記述で完結に書くのもありでしょう。

個人的によく使う関数

ここからは個人的によく使う関数を 1 つ1つ見ていきます。

Filter

スライスの要素を1つずつ関数で処理します。
関数が true を返した結果のみ残し、新たなスライスを生成し返却します。

    even := lo.Filter([]int{1, 4, 5, 3, 2, 6}, func(n int, index int) bool {
        return n%2 == 0
    })

    fmt.Println("even:", even) // even: [4 2 6]

Map

スライスを受け取り、別のスライスに変換します。

型の変更も可能です。

    months := []string{"January", "February", "March", "April"}

    nums := lo.Map(months, func(month string, index int) int {
        return len(month)
    })

    fmt.Println(nums) // [7 8 5 5]

FilterMap

Filter 関数と Map 関数の処理を同時に行う関数です。

無名関数は 2 つの値を返すようにします。

1つ目の値は Map 処理結果(値の変換結果)、2つ目の値はFilter 処理結果(値を返却対象に含めるか捨てるか)。

  # 偶数だけを3倍して返す
    even := lo.FilterMap([]int{1, 4, 5, 3, 2, 6}, func(n int, index int) (int, bool) {
        return n * 3, n%2 == 0
    })

    fmt.Println("even:", even) // even: [12 18 6]

Reduce

スライスの値を単一の値に集約します。

無名関数の第一引数は前回の関数呼び出し時に返却した値 ( 例外的にが、第二引数には各スライスの要素がセットされます。

    sum := lo.Reduce([]int{1, 4, 5, 3, 2, 6}, func(agg int, n int, index int) int {
        return agg + n
    })

    fmt.Println("sum:", sum) // sum: 21

ForEach

for ... range ループと同様です。

    lo.ForEach([]int{1, 4, 5, 3, 2, 6}, func(n int, index int) {
        fmt.Println("n:", n, "index:", index)
    })

実行結果は以下のようになります。

n: 1 index: 0
n: 4 index: 1
n: 5 index: 2
n: 3 index: 3
n: 2 index: 4
n: 6 index: 5

これだけだとさほど嬉しくないのですが、gorutine を使った並行実行機能も提供されています。

import lop "github.com/samber/lo/parallel"

lop.ForEach([]string{"hello", "world"}, func(x string, _ int) {
    println(x)
})
// prints "hello\nworld\n" or "world\nhello\n"

コンピューターリソースが十分にあれば、性能改善できそうです。

Times

無名関数を指定された回数実行し、戻り値を配列にして返します。

    words := lo.Times(5, func(i int) string {
        return "yes"
    })
    fmt.Println(words) // [yes yes yes yes yes]

こちらもやはり平行実行可能な関数が提供されています。

import lop "github.com/samber/lo/parallel"

lop.Times(3, func(i int) string {
    return strconv.FormatInt(int64(i), 10)
})
// []string{"0", "1", "2"}

Uniq

重複なしのユニークなスライスを返却します。

    duplicatedNames := []string{
        "John",
        "Jane",
        "Jack",
        "John",
        "Jane",
    }

    uniqNames := lo.Uniq(duplicatedNames)

    fmt.Printf("%v\n", uniqNames) // [John Jane Jack]

UniqBy

Uniq 関数に無名関数で「ユニークである」という条件をロジックで記述できます。

無名関数の実行結果が重複した場合、その要素は捨てられます。

すべての要素を集約し、新たなスライスとして返却します。

    uniqValues := lo.UniqBy([]int{1, 4, 5, 3, 2, 6}, func(n int) int {
        return n % 3
    })

    fmt.Println(uniqValues) // [1 5 3]

GroupBy

それぞれの要素を引数に無名関数が実行され、結果の値でグルーピングします。

グルーピングした結果、キーでグルーピングされたマップが返却されます。

  // 余りが等しい者同士グルーピングされる
    groups := lo.GroupBy([]int{1, 4, 5, 3, 2, 6}, func(n int) int {
        return n % 3
    })

    fmt.Println(groups) // map[0:[3 6] 1:[1 4] 2:[5 2]]

こちらも同様に並行実行用の関数が提供されています。
グルーピング処理は重たいので、平行実行可能な関数の効果は期待できそうです。

import lop "github.com/samber/lo/parallel"

lop.GroupBy([]int{0, 1, 2, 3, 4, 5}, func(i int) int {
    return i%3
})
// map[int][]int{0: []int{0, 3}, 1: []int{1, 4}, 2: []int{2, 5}}

Chunk

スライスを第二引数で指定された数の単位でグルーピングします。

グルーピングした結果のこった要素は最後のチャンクにまとめられます。

lo.Chunk([]int{0, 1, 2, 3, 4, 5}, 2)
// [][]int{{0, 1}, {2, 3}, {4, 5}}

lo.Chunk([]int{0, 1, 2, 3, 4, 5, 6}, 2)
// [][]int{{0, 1}, {2, 3}, {4, 5}, {6}}

lo.Chunk([]int{}, 2)
// [][]int{}

lo.Chunk([]int{0}, 2)
// [][]int{{0}}

Shuffle

スライスの要素をランダムに入れ替え、新しいスライスを生成します。

便利そうではあるのですが、気をつけないといけない点としてシャッフルに使用した元のスライスを書き換えてしまいます。

    source := []int{1, 4, 5, 3, 2, 6}
    list1 := lo.Shuffle(source)
    list2 := lo.Shuffle(source)

    fmt.Println(source) // [1 4 5 3 2 6]
    fmt.Println(list1)  // [1 4 5 3 2 6]
    fmt.Println(list2)  // [1 4 5 3 2 6]

そうなると戻り値がいらないような。
リテラルで渡したときのため?

Reverse

スライスの順序を逆転します。

こちらも Shuffle 同様、引数に指定したスライスまで順序を逆転させてしまう点に注意が必要です。

    source := []int{1, 4, 5, 3, 2, 6}
    reverseOrder := lo.Reverse(source)

    fmt.Println(source)       // [6 2 3 5 4 1]
    fmt.Println(reverseOrder) // [6 2 3 5 4 1]

Drop

スライスの開始要素を切り捨て、新しいスライスを返します。

個人的には地味に便利です。

    source := []int{1, 4, 5, 3, 2, 6}
    l := lo.Drop(source, 2)

    fmt.Println(source) // [1 4 5 3 2 6]
    fmt.Println(l)      // [5 3 2 6]

    // 試しに書き換えてみる
    l[0] = 100

    fmt.Println(source) // [1 4 5 3 2 6]
    fmt.Println(l)      // [100 3 2 6]

以下のように、サブスライスを取得する記法 [n:m] を使うと、元のスライスとデータを共有してしまうため想定しない変更をしてしまう可能性があります。

    source := []int{1, 4, 5, 3, 2, 6}
    // lo.Dropを使わない
    l := source[2:]

    fmt.Println(source) // [1 4 5 3 2 6]
    fmt.Println(l)      // [5 3 2 6]

    // 試しに書き換えてみる
    l[0] = 100

    fmt.Println(source) // [1 4 100 3 2 6]
    fmt.Println(l)      // [100 3 2 6]

DropRight

Drop の後ろから要素数を指定するバージョンです。

l := lo.DropRight([]int{0, 1, 2, 3, 4, 5}, 2)
// []int{0, 1, 2, 3}

start / end なのか left / right なのでしょうか。

CountBy

無名関数に指定されたロジックに合致する要素数を数え上げます。

    // 偶数の数を算出
    count := lo.CountBy([]int{1, 4, 5, 3, 2, 6}, func(i int) bool {
        return i%2 == 0
    })

    fmt.Println(count) // 3

Slice

slice[start:end] の記法を使った結果と全く同じです。
じゃあ不要なのでは?とも思いますが、 startend の部分に要素を超えた値( -1math.MaxInt64 ) を指定してもパニックが起きません。

生成されたサブスライスはやはり元のスライスとメモリ領域を共有している点に注意が必要です。

    in := []int{0, 1, 2, 3, 4}

    sub := lo.Slice(in, 2, 4)
    fmt.Println(sub) // [2 3]

    sub = lo.Slice(in, 1, 3)
    fmt.Println(sub) // [1 2]

    // 試しに書き換えてみる
    sub[0] = 42
    fmt.Println(in)  // [0 42 2 3 4]
    fmt.Println(sub) // [42 2]

Keys

マップのキーのみをスライスとして抽出します。

    m := map[string]int{"a": 1, "b": 2, "c": 3}
    keys := lo.Keys(m)
    fmt.Println(keys) // [a b c]

Values

マップの値のみをスライスとして抽出します。

    m := map[string]int{"a": 1, "b": 2, "c": 3}
    values := lo.Values(m)
    fmt.Println(values) // [1 2 3]

Range

様々な方法で数値が格納されたスライスを生成します。

    var result []int

    result = lo.Range(4)
    fmt.Println(result) // [0 1 2 3]

    result = lo.Range(-4)
    fmt.Println(result) // [0 -1 -2 -3]

    result = lo.RangeFrom(1, 5)
    fmt.Println(result) // [1 2 3 4 5]

    result = lo.RangeWithSteps(0, 20, 5)
    fmt.Println(result) // [0 5 10 15]

    result = lo.RangeWithSteps(4, 1, -1)
    fmt.Println(result) // [4 3 2]

    result = lo.Range(0)
    fmt.Println(result) // []

Sum

合計値を算出します。

    sum := lo.Sum([]int{1, 2, 3, 4, 5})
    fmt.Println(sum) // 15

SumBy

スライスの各要素に独自の算術を行い、合計値を返します。

    sum := lo.SumBy([]int{1, 2, 3, 4, 5}, func(i int) int {
        return i * i
    })
    fmt.Println(sum) // 55

Find

スライスから条件に合致する要素を検索し、最初に見つかったものを返却します。

返却値の2つ目の値は、検索条件に合致したものが見つかった場合は true、見つからなかった場合は false となります。

    names := []string{"John", "Paul", "George", "Ringo"}

    // "g" が含まれる名前のうち、最初の1件を返します
    name, ok := lo.Find(names, func(n string) bool {
        return strings.Contains(n, "g")
    })

    fmt.Printf("%v, %v\n", name, ok) // George, true

Contains

スライスに指定した要素が含まれているかを確認します。
もし含まれている場合は true を、含まれていない場合は false を返します。

以下のコードは、スライスに指定した要素が含まれているかを確認する例です。

package main

import (
    "fmt"

    "github.com/samber/lo"
)

func main() {
    fmt.Println(
        lo.Contains([]int{0, 1, 2, 3, 4, 5}, 5),
    ) // true

    fmt.Println(
        lo.Contains([]int{0, 1, 2, 3, 4, 5}, 6),
    ) // false
}

ひとこと

スライス、マップを制すものはプログラミングを制す!

Posted by genzouw