Edited at

[Go] コレクション操作ライブラリ「Koazee」の仕組みを追ってみた


概要

Go でコレクション操作を可能にする()ライブラリ 「Koazee」 どういう仕組みで動いてるのかな〜と気になり追ってみた記事です

GitHub wesovilabs/koazee


対象読者

go 勉強しはじめたばっかり !!

コレクションなにそれおいしいの?

そういう人を対象に少しでも興味を持ってもらえればいいな〜と思っとります


最初にサンプルから一部抜粋


sample.go

package main

import (
"fmt"
"github.com/wesovilabs/koazee"
)

var animals = []string{"lynx", "dog", "cat", "monkey", "dog", "fox", "tiger", "lion"}

func main() {
stream := koazee.StreamOf(animals)
fmt.Println(stream.
Filter(
func(val string) bool {
return len(val) == 4
}).
Out().Val(),
)
}

/**
go run main.go

stream.Filter(len==4): [lynx lion]
*/


string 配列から文字数が4のやつだけフィルタリングするやでのシンプルなやつ

今回はこれが実現される流れを追います


説明簡易


  • 初期化 -> Stream ができる


  • Add(), Filter(fn...) など Stream のメソッドから operations を足していく


    • 各オペレーションは stream/* に定義されてる

    • 単体の operationladyOp というインターフェースで定義されてる


    • ladyOp の実際の処理は, internal/* に実装されてる

    • キャッシュ化の仕組みとして internal/*/{dispatcher, cache}.go が用意されている




  • operationsOut() により再帰的に実行され, *Output となる

  • 最後に Val() から元の型のスライスとして返る


初期化

すべては初期化から ......

stream := koazee.StreamOf(animals)

StreamOf がどういう実装なのかはこちら

https://github.com/wesovilabs/koazee/blob/5f7f06347feb7697b420b5cc70bd76a67cef5bd4/koazee.go#L16

func StreamOf(data interface{}) stream.Stream {

if reflect.TypeOf(data).Kind() == reflect.Slice { // Slice 型を保証
return stream.New(data)
}
return stream.Error(errors.InvalidType(":load",
"Unsupported type! Only arrays are permitted"))

Koazee は, go での動的な型付けをサポートする reflect をふんだんに盛り込み実装されています

正直, reflect とかテストの時の DeepEquals 使うぐらいしかないやろ !! なんて思ってました、ごめんなさい

またこの初期化から読み取れることとして Koazee の場合, 扱えるものはスライス(配列)に限定されています

PHPの Illuminate\Support\Collection だと、go でいう map 型(連想配列)も許容してくれてますが, とりあえずは slice のみみたいです :thinking:

Koazee のアピールポイントとして「パフォーマンス重視」というのがありその辺影響してくるのかもしれないです


追記:

あくまでも StreamOf としての生成の制約が Slice 型というだけで構造体の items 自体は interface{} なので Map も許容されているみたいです

issue を見ると key に対する機能を盛り込もうという意図が見て取れるのでその辺落ち着いてから許容するのかもしれないです


StreamOf では steam.Stream 構造体を返します

Stream の定義はこちら

https://github.com/wesovilabs/koazee/blob/5f7f06347feb7697b420b5cc70bd76a67cef5bd4/stream/stream.go#L140

// Stream stream structure

type Stream struct {
items interface{}
itemsValue reflect.Value
itemsType reflect.Type
itemsLen int
err *errors.Error
operations []lazyOp
}

自分でシンプルに実装するとしたら, items だけでいいんじゃね ... ?? とかになりそうですが

これだけの要素があれば短縮できる処理もあります(Len を持ってるので Count では Len を返すだけで済む ... etc)

構造体の定義一つとってもパフォーマンスを優先して定義されていることが見てとれます :thumbsup:

この定義で気になるのが []lazyOp なるもの

lazyOp ... となり気になって追うと Steam の定義の直前に定義してあります

https://github.com/wesovilabs/koazee/blob/5f7f06347feb7697b420b5cc70bd76a67cef5bd4/stream/stream.go#L135

type lazyOp interface {

run(Stream) Stream
}

どうやらインターフェースのようです :raised_hands:

run という Steam を受け取り, Stream を返すシンプルなインターフェースですね

operations という名前であることから連想できるように Add とか Filter とかの実際の操作定義を格納する定義となります

本題の Filter はどう実装されているのかはこちら

https://github.com/wesovilabs/koazee/blob/5f7f06347feb7697b420b5cc70bd76a67cef5bd4/stream/filter.go


filter.go

package stream

import "github.com/wesovilabs/koazee/internal/filter"

type streamFilter struct {
fn interface{}
}

func (m *streamFilter) run(s Stream) Stream {
value, err := (&filter.Filter{ItemsType: s.itemsType, ItemsValue: s.itemsValue, Func: m.fn}).Run()
if err != nil {
s.err = err
return s
}
return s.withItemsValue(value)
}

// Filter discard the elements in the Stream that don't match with the provided filter
func (s Stream) Filter(fn interface{}) Stream {
s.operations = append(s.operations, &streamFilter{fn})
return s
}


Stream 構造体に無名関数を受け取るメソッドとして Filter が生えてます

これにより s.Filter(func () {...) を扱うことができるようになっています

Filter 内での処理はとてもシンプルで streamFilteroperations に追加するのみです

ここで、他の言語から来た Go 初学者はアレ??と思うかもしれません(自分がそうでした)

operations[]lazyOp ですが, streamFilter は... ??となりそうですが

go は明示的に implements 等でインターフェースの実現を明示する必要はありません

インターフェースの実装に必要なのは「同じメソッドを実装する」のみシンプルですね

ここでは lazyOp の run(Stream) Stream を実装した streamFilter は lazyOp を実現したとなります

最初は混乱しましたが, 慣れると楽な点ではあります

実際の filter はどうしてるの?という部分は internal/filter にあります

https://github.com/wesovilabs/koazee/blob/5f7f06347feb7697b420b5cc70bd76a67cef5bd4/internal/filter/filter.go

filter 条件に見合うもののみをいれた新しいスライスを返すというもの

個々の実装はとてもシンプルですね :raised_hands:

ここで dispatch というメソッドに処理を委譲していますが、ここが Koazee のパフォーマンス重視の工夫の最たるところかと思われます

やってることは dispatcher という map にデータがあればそのまま返すというキャッシュ的な仕組みのようです

で、追加されていった operations を実際反映するのはどうするかというと, それが Out() の部分です

Outの実装はこちら

https://github.com/wesovilabs/koazee/blob/5f7f06347feb7697b420b5cc70bd76a67cef5bd4/stream/out.go

package stream

import (
"github.com/wesovilabs/koazee/errors"
"reflect"
)

// OpCodeOut identifier for operation out
const OpCodeOut = "out"

type out struct {
items reflect.Value
}

func (op *out) name() string {
return OpCodeOut
}

func (op *out) run() *Output {
if err := op.validate(); err != nil {
return &Output{reflect.ValueOf(nil), err}
}
return &Output{op.items, nil}
}

func (op *out) validate() *errors.Error {
if op.items == reflect.ValueOf(nil) {
return errors.EmptyStream(op.name(), "It can not be outputted a nil Stream")
}
return nil
}

// Out returns the slice for the stream
func (s Stream) Out() *Output {
current := s.run()
if current.err != nil {
return &Output{reflect.ValueOf(nil), current.err}
}
return (&out{current.itemsValue}).run()
}

stream の run では operations 要素を run してますが, Out により再帰的に operations を実行してます

最後に値の取得 Val は ↑ で返された Output の値を返してくれます


感想

こんなにシンプルそうに見える処理の中にも定義の工夫、処理の工夫が見れて面白いです!!

あとはまだまだ改善できそうな箇所は多そうに思えました

map を許容して sortByKey 的なのを追加するとか他コレクションライブラリと比べてもない機能とかもまだありそうですし、 Higher Order Messages 的なのとか(これは言語的に難しそうですかね...)

もしかしたら PR チャンスかも !?

引き続き追っていこうと思います :raised_hands: