前回はTransducersの概念を理解しようと試みた。
Transducersとは、組み合わせ可能な配列の変換である。一つひとつの要素を訪れて新たな配列へと変換することを組み合わせ可能にした。
Transducerを理解するには、まずはその目的と仕組みを知ることが大切だ。Transducerの目的は、大きな配列を余分な途中の状態抜きに変換することだ。
配列を一から走査したい事がある。何かメモを持ちながら、たくさんのスーパーマーケットをはじめから最後まで買い歩いていくのだ。それが走査という。Reducerを使って行う。
そんな中Reducerだけでは足りない場面が出てきた。複数の走査を同時にしたい時だ。
何回も配列を走査するのが非効率になってしまったときにTransducerを作ろうってなるんだ。つまりTransducerというのはReducerを何重にもカスタマイズしたい時に使う。
Reducerではメモをとりながら配列を歩いていき、最終的にメモを作るだけだった。一方Transducerは、Reducerがメモを取る手順書にどんどん条件を加えてカスタマイズしていく、みたいなイメージを想像している。
なんか変に例えようとしてもっと分かりづらくなって行っている気がしなくもないが、勇気を持ってこのたとえで考えていく。不安だ。
実世界でどのようにTransducersが役に立つのか探究していきたい。開発者人生でドキュメントを読む時に、試しに実装してみて放置が多かったから、これからはもっと実用的な知恵にまで育てていきたいのである。これから仕事が奪われていく状況では、バランスの取れた判断を要求されるようなアーキテクチャーのような思考が不可欠だ。
とてもエキサイティングだ。
まあ、前口上はこれぐらいでいい。
実践的なTransducersの話をしたい。そのためには実践的でなく思えてしまうところをしっかり言語化しよう。まずはじめに挙げられるのが、Transducerは高階関数だから理解しづらいということが挙げられる。
Transducerは変換の派生版を作る
少なくとも私は、Transducerと言われて何をすればいいのか全く分からなかった。それは多分、データを変換するものじゃなくてデータ変換を加工し、アップグレードしたり亜種を作るようなものだからだった。
Transducerと言われたときに、私は要素を変換することで頭がいっぱいだった。どのような要素なのか?そして配列はどうなっているのか?ということを考えていた。
しかし実際のところ、Transducerは変換をアップグレードするだけで変換の対象には興味がない。つまり、配列をtransduceするのか、それともstreamをtransduceするのかには興味がないのだ。
Transducerを実践的に利用するために、どこから考えて行けばいいだろうか。
- Transducerはコードのレベルではどのように使われるか
- Transducerの実践的な例
- Transducerの実装方法
実装されたTransducerの内部や詳細についてもできる限り見ていきたい。でも、まずはより近いところから見ていく必要がある。つまりコードのレベルではtransducerはどうやって使うのか?っていうことだ。それから見ていこう。
Transducerはコードではどう使われるか
(defn double ...)
(transduce (fn [rf] rf) + (range 10)) ;; => 45
(transduce double + (range 10)) ;; => 90
clojureでは、Transducerをリストに適用するためには、transduce
という関数を定義している。とても直感的な名前だな。それから行こう。
まずいちばん最初に抑えておきたいのは、TransducerはReducerを変換するものであるということだ。だから、transduceをする時に(+)みたいなReducerが必要となるのだ。
こういうtransducerが良く例に出てきているのだが、簡単すぎて身にならないように感じる。だから、不満なんだ。どういう例だったら実践的になるだろうか?
多分バックエンドのほうがわかりやすい具体例をだせるんじゃないかなとか思っている。なぜならバックエンドでのほうが大きな配列をマップする可能性が高い。
クライアントサイドでは、transducerを使いたいと思うほど要件が厳しくなるものは無いと思うのだ。つまり、それほど大きな配列を利用しないから、Array.mapを繰り返し適用すればいいやと思っていた!
例えば他には、抽象的に表現すると、ユーザーからのインプット列をアプリケーションイベントへと変換したいというユースケースが想像できる。
だがしかし、I/Oは非同期的なので、Channelのほうが適切なのではないか?とも思ったりした。(Channelについても今後理解したい)そして、何ならre-frameやregentなどフロントエンドのUIフレームワークを利用している限りでは、そのフレームワーク側でユーザーインプットの変換をしてくれているので、Transducersの出番はない。
あ!だから、CUIとかを実装するときとか、ゲームを実装するときとかにいいんじゃないか?(例えばQuitのためにqキーを推したりとか、テトリスで上下に移動させるために矢印キーを推したりとか)そういうインプットを、徐々に水が出てくる蛇口みたいに捉えて、ユーザーのインプットの配列をtransduceすると考えてみたらどうか。
- ビッグデータをtransduceする例
- ユーザーインプットをtransduceすることで作るCUI
の二つの例を書いてみよう。全然完璧じゃないけど、それでも今までより実践的な例なんじゃないか。
ビッグデータをtransduceする例
バックエンド側でのTransducerを使ったビッグデータ処理の例
バックエンド側でTransducerを使ってビッグデータを処理する実践的な例を考えてみる。
ユーザーの行動ログデータを集計する
ユーザーの行動ログデータがあるとします。そのデータには、ユーザーID、アクション、タイムスタンプが含まれていて、それをうまいこと集計していきたい。
例えば、以下のようなデータ形式だ。
;; ユーザーの行動ログデータ
;; user-id, action, timestamp のマップのシーケンス
(def log-data
[{:user-id 1 :action "view" :timestamp "2023-04-01T10:00:00"}
{:user-id 2 :action "view" :timestamp "2023-04-01T10:01:00"}
{:user-id 1 :action "click" :timestamp "2023-04-01T10:02:00"}
{:user-id 3 :action "view" :timestamp "2023-04-01T10:03:00"}
{:user-id 1 :action "purchase" :timestamp "2023-04-01T10:04:00"}
{:user-id 2 :action "view" :timestamp "2023-04-01T10:01:00"}
{:user-id 1 :action "click" :timestamp "2023-04-01T10:02:00"}
{:user-id 3 :action "view" :timestamp "2023-04-01T10:03:00"}
{:user-id 1 :action "purchase" :timestamp "2023-04-01T10:04:00"}
{:user-id 2 :action "view" :timestamp "2023-04-01T10:01:00"}
{:user-id 1 :action "click" :timestamp "2023-04-01T10:02:00"}
{:user-id 3 :action "view" :timestamp "2023-04-01T10:03:00"}
{:user-id 1 :action "purchase" :timestamp "2023-04-01T10:04:00"}
;; 大量のログデータが続く...
])
このデータを、Transducerを使って効率的に処理していきたい。ユーザーごとのアクション数を集計し、アクションが多いユーザートップ10を抽出する。
(def xf
(comp
(map #(select-keys % [:user-id :action]))
(partition-by :user-id)
(map (fn [user-actions]
{:user-id (:user-id (first user-actions))
:action-count (count user-actions)}))
(take 10)))
(def top10 (into [] xf (sort-by :user-id log-data)))
;; => [{:user-id 1, :action-count 7} {:user-id 2, :action-count 3} {:user-id 3, :action-count 3}]
こういうふうに、Transducerを利用すれば中間シーケンスを生成せず(何回も走査する必要なく)データを整形できる。
これを書いているとき何回sortのTransducerがほしいと思ったことか…。Transducerがどんどん好きになっている。(sortは順番が変わってしまうので、状態を中間シーケンス的に保持しておく必要がある。結果、transducerである必要が無いのだと思われる)
ユーザーからの入力を、非同期なストリームとして(こういうふうな表現でいいのだろうか)捉えて、それをTransduceする例
とても簡単なゲームを実装しよう。ユーザーの入力をハンドリングする時に、Transducerが使えると思う。
CUIのフレームワークが存在しないときどうやって実装するか。少なくとも必要なのは、ユーザーのインプットをアプリケーションのイベントへと変換することだ。
中枢部でキューのようなイベントプールを作っておいて、それをハンドリングする。ハンドリングされて状態が変化し、画面表示も変わる。そんな感じでアプリケーションを実装すればよい。
ユーザーインプットをうまく変換するためにTransducerを使ってみよう。
ゲームの簡単な仕様
一番基本的なゲームってなんだろうか?
仮想的に「上下左右にすすむ」だけのゲームを想定してみる。ゲームの場面によって上下左右に進む距離が伸びる(アプリケーションイベントが複数個発行される)
このゲームにモードが存在するとして通常モード、倍速モードがあるとしよう。
通常モードでは1マスだけ進み、倍速モードでは2マス進む。そういうシンプルなゲームを考える。
ユーザーのインプットを、モードに応じてイベントへとハンドリングする。
通常モード
::pressed-up -> [::go-up]
::pressed-down -> [::go-down]
倍速モードのときは、
::pressed-up → [::go-up ::go-up]
::pressed-down →[::go-down ::go-down]
こんなふうに出来るなら、ちょっとおもしろい。
(defn twice-xf
[rf]
(fn
([] (rf))
([result] (rf result))
([result input] (rf (rf result input) input))))
こういうTransducerを使えばインプットからアプリケーションイベントを発行できると思う。これ以降の実装はまたの機会にしよう。
Transducerを実装する
Transducerの目的は、reduce関数の動作をカスタマイズすることだ。具体的には、reduce関数に渡す前に、入力データを変換・フィルタリングしたりすることだ。でも、reduce関数自体は変更しない。
仕組みはこうなっている。
- まず、reducerがある。これは、入力データとアキュムレータを受け取って、新しいアキュムレータを返す関数だ
- 次に、Transducerが登場する。Transducerは、reducerが使う入力データを変換する
- reduce関数は、Transducerが返した新しい入力データを使って処理を進める
だから、Transducerはreduce関数の出力を変更するんじゃなくて、reduce関数に渡す前の入力データを変換することで、結果的にreduce関数の動作に影響を与えているのだ。
だから入力データの変換やフィルタリングを柔軟に行うことができるのである。これは、compによってTransducerを合成するときデータが左から右へ整形されていくことにも説明ができる。
左側からの入力の流れを足しているだけだからだ。
Transducerを構成する部品は以下である。
- 初期化
- 本体
- 早期終了
この中で一番重要なのは2である。
ここで3つに部品を分けているが、それはreducerの引数の数でわけられる。
2番は一番大切で、加工元のreducerに対して、どう言うふうに新たなreducerを作るかを実装する。
inputを加工して渡したり、スキップしたり。前のaccumulatorをそのまま返したりする。実装するところはほぼこれである。
1番の初期化についてはよくわからなかった。使われていないらしい。そもそも、たとえスタートフルなものであってもtransducerの初期化をする必要性がわからない。
色々調べてみたのだけれど、どうやらcoreライブラリでもtransducersのInitは利用されていないように見受けられた。明確な説明もないということのよう。[^1]
早期終了
早期リターンは結構大きな関心事だと思う。Transducerはその実継続なので、当然のように早期リターンができる。
継続モナドとかを思い出し、CPSなども思い出す…このあたりを基礎に立ち返って学習したいな。
Trasducerの早期終了は、早期リターンの書き方に見られるような認知負荷の軽減と言うよりも、もっと実用的なところにある。
必要な分だけというのがスローガンだ。
すべて走査仕切る前に、終わらせてしまうことができるというのが、早期終了の大切なところだと思うのだ。
Additionally, a transducer step function that uses a nested reduce must check for and convey reduced values when they are encountered. (See the implementation of cat for an example.)
Transducerの中で、reducerをネストして使う場合、completionを途中で検知して離脱しなければいけないとのことだ。
と言うのも、今記事のゲーム実装の時に実装した、2回重ねがけをするTransducerがあったと思う。1回目のreduce時点で早期終了する場合に、2回目を実装しないようにする。そうすれば、reduceの早期終了を適切に判断できるとのことらしい。
以上をもって一旦Transducerについては終わりとしたい。educationとsequenceの違い(educationはあとで transduceする一方sequenceは先に適用しちゃう)とか、TransducerとCPSの関係とか、色々掘り下げていきたいものはあるのだが、今回は予定を大きくオーバーしてしまった。
Clojureは勉強すればするほど味が出る。これからも舐め続けていきたい。