M.Hiroi's Home Page

Clojure Programming

お気楽 Clojure プログラミング超入門


Copyright (C) 2025 Makoto Hiroi
All rights reserved.

関数の合成とトランスデューサー

今回は Clojure の関数型言語らしい機能として、「関数の合成」と「トランスデューサー」について説明します。

●関数の合成

関数 f(x) と g(x) を合成して新しい関数 h(x) を作ることを考えてみましょう。関数 h(x) を次のように定義します。

h(x) = f( g( x ) )

たとえば、f(x) = 2 * x + 1, g(x) = x * x + 3 * x とすると、h(x) は次のようになります。

h(x) = f( g( x ) )
     = 2 * (x * x + 3 * x) + 1
     = 2 * x * x + 6 * x + 1

実際のプログラムは数式を展開するのではなく、g(x) の評価結果を f(x) に渡すだけなので簡単です。第 1 引数と第 2 引数に関数 f と g を受け取り、それらを合成する関数 compose は次のようになります。

user=> (defn compose [f g] #(f (g %)))
#'user/compose

簡単な例を示しましょう。

user=> (defn f [x] (inc (* 2 x)))
#'user/f
user=> (defn g [x] (+ (* x x) (* 3 x)))
#'user/g

user=> (f (g 4))
57
user=> (def h (compose f g))
#'user/h
user=> (h 4)
57

関数 f と g を定義します。f と g の合成は f( g( x ) ) と表すことができます。実際に 4 を計算すると 57 になります。この関数は compose で合成することができます。(compose f g) の返り値を変数 h に束縛すると、h を合成関数として使うことができます。

clojure には compose と同じ働きをする関数 comp が用意されています。

(comp f g)
(comp f g & fs)

comp は 2 個以上の関数を合成することができます。簡単な例を示しましょう。

user=> (def h1 (comp f g))
#'user/h1

user=> (h1 4)
57

●Threading Macros

ところで、Clojure には Threading Macros という便利な機能があり、(f (g x)) という関数呼び出しを次のように表すことができます。

(-> x g f)
user=> (-> 4 g f)
57

-> はマクロ (Threading first Macro) で、次の S 式と同じ働きをします。

(-> item (form1 x1 y1 ...) (form2 x2 y2 ...) (form3 x3 y3 ...))
 ≡(form3 (from2 (form1 item x1 y1 ...) x2 y2 ...) x3 y3 ...)

item の評価結果が、次の form1 の第 1 引数に渡され、その評価結果が次の form2 の第 1 引数に渡されます。これを最後まで続けていき、最後の form の評価結果が -> の返り値になります。関数は左から右へ評価されていくので、関数合成 comp の引数とは逆順になることに注意してください。

-> は reduce の動作と似ています。簡単な例を示しましょう。

user=> (-> 0 (+ 1) (+ 2) (+ 3) (+ 4) (+ 5))
15

user=> (-> 0 inc inc inc inc inc)
5

Clojure には評価結果を form の最後の引数に渡すマクロ ->> (Threading last Macro) や、挿入位置を名前で指定するマクロ as-> もあります。

(as-> expr name (form1 ...) (form2 ...) ...)

as-> の場合、expr の評価結果が name の変数に束縛され、(form1 ...) の中でそれを参照することができます。そして、form1 の評価結果が name の変数に束縛され、次の form2 に渡されます。

簡単な例を示しましょう。

user=> (->> '(5 6 4 7 3 8 2 9 1) (map #(- % 5)) (filter pos?))
(1 2 3 4)

user=> (as-> '(5 6 4 7 3 8 2 9 1) xs (map #(- % 5) xs) (filter pos? xs))
(1 2 3 4)

このほかにも、Clojure には便利な Threading Macros が用意されています。興味のある方は Clojure のリファレンスをお読みくださいませ。

●トランスデューサー (transducer)

トランスデューサー (transducer) には「変換器」という意味があります。Clojure の場合、入力されたデータを変換する特別な関数のことをトランスデューサーといいます。Clojure の場合、コレクションなどを操作する関数において、入力データを表す引数を省略すると、トランスデューサーを返す関数が用意されています。なお、Clojure ではトランスデューサーのことを xf とか xform と記述することがあります。

(map #(* % %)) => 入力データを 2 乗する xform
(filter odd?)  => 入力データから奇数値を取り出す xform
(take 10)      => 入力データから要素を 10 個取り出す xform

Clojure の xform は関数 comp で簡単に合成できるのが特徴です。threading macro で高階関数を並べるよりも、xform を comp で合成したほうが、入力データを効率的に処理できる場合があります。

xform を comp で合成する場合、適用順序は左から右となり、通常の関数とは逆になることに注意してください。つまり、threading macro と同じ順番になります。

xform に入力データを適用するには、以下の関数を使います。

  1. transduce
  2. sequence
  3. into
  4. eduction

1 は畳み込み (reduce) の動作と似ています。

(transduce xform f coll)
(transduce xform f init coll)

引数 f は reduce に渡す関数と同じで、これを reducing 関数といいます。transduce は引数 coll を xform と f を合成した関数で畳み込みを行います。簡単な例を示しましょう。

user=> (def integers (range 1 ##Inf))
#'user/integers
user=> (take 10 integers)
(1 2 3 4 5 6 7 8 9 10)

user=> (reduce + 0 (take 10 integers))
55
user=> (transduce (take 10) + 0 integers)
55

user=> (reduce + 0 (take 10 (map #(* % %) integers)))
385
user=> (transduce (comp (map #(* % %)) (take 10)) + 0 integers)
385

user=> (reduce + 0 (take 10 (map #(* % %) (filter odd? integers))))
1330
user=> (transduce (comp (filter odd?) (map #(* % %)) (take 10)) + 0 integers)
1330

結果をシーケンスとして受け取りたい場合は 2 の sequence を使います。

(sequence xform coll)
(sequence xform coll & colls)

基本的には xform 用のマップ関数みたいな動作になります。簡単な例を示しましょう。

user=> (sequence (take 10) integers)
(1 2 3 4 5 6 7 8 9 10)
user=> (sequence (comp (map #(* % %)) (take 10)) integers)
(1 4 9 16 25 36 49 64 81 100)
user=> (sequence (comp (filter odd?) (map #(* % %)) (take 10)) integers)
(1 9 25 49 81 121 169 225 289 361)

user=> (sequence (map list) (range 1 11) (range 11 21))
((1 11) (2 12) (3 13) (4 14) (5 15) (6 16) (7 17) (8 18) (9 19) (10 20))
user=> (sequence (comp (map list) (take 5)) (range 1 11) (range 11 21))
((1 11) (2 12) (3 13) (4 14) (5 15))

3 の into は xform を使用することができます。

(into to xform from)

into はコレクション from の要素に xform を適用し、その結果をコレクション to に格納して返します。簡単な例を示しましょう。

user=> (into '() (take 10) integers)
(10 9 8 7 6 5 4 3 2 1)
user=> (into '(0 -1 -2) (take 10) integers)
(10 9 8 7 6 5 4 3 2 1 0 -1 -2)

user=> (into [] (take 10) integers)
[1 2 3 4 5 6 7 8 9 10]
user=> (into [0 -1 -2] (take 10) integers)
[0 -1 -2 1 2 3 4 5 6 7 8 9 10]

user=> (into '() (take 10) integers)
(10 9 8 7 6 5 4 3 2 1)
user=> (into [] (comp (map #(* % %)) (take 10)) integers)
[1 4 9 16 25 36 49 64 81 100]

最後の eduction はちょっと変わっています。

(eduction xform ... coll) => clojure.core.Eduction

eduction は Eduction 型のデータを直ぐに返します。Eduction 型は reduce や反復処理で使用することができます。eduction は複数の xform を受け取ることができます。comp で合成する必要はありません。評価する順番は comp と同じく左から右です。

eduction を評価するとき、xform は coll の要素にまだ未適用であることに注意してください。reduce や反復処理などで Eduction の値が必要になったとき、はじめて coll に xform が適用されて値が求まります。また、遅延評価と違って、求めた値はキャッシュされません。値が必要になるたび、その都度計算が行われます。

簡単な例を示しましょう。

user=> (def xf (comp (filter (fn [x] (println "filter" x) (odd? x)))
 (map (fn [x] (println "map" x) x))))
#'user/xf
user=> (def xs (eduction xf (range 10)))
#'user/xs
user=> xs
(filter 0
filter 1
map 1
filter 2
filter 3
map 3
filter 4
filter 5
map 5
filter 6
filter 7
map 7
filter 8
filter 9
map 9
1 3 5 7 9)

user=> (reduce + 0 xs)
filter 0
filter 1
map 1
filter 2
filter 3
map 3
filter 4
filter 5
map 5
filter 6
filter 7
map 7
filter 8
filter 9
map 9
25

user=> (doseq [x xs] (println x))
filter 0
filter 1
map 1
filter 2
filter 3
map 3
filter 4
filter 5
map 5
filter 6
filter 7
map 7
filter 8
filter 9
map 9
1
3
5
7
9
nil

●トランスデューサーの作成

トランスデューサーの実体は、reducing 関数を受け取り、reducing 関数を返す関数となります。ただし、2 引数の関数 (fn [a x] ...) を返すだけでは駄目で、引数なしと引数が 1 つの関数も定義する必要があります。Clojure のドキュメント トランスデューサー(transducer) (翻訳) より引用します。

(fn [rf]
  (fn ([] ...)
      ([result] ...)
      ([result input] ...)))
  1. Init (arity 0) - ネストされた変換rfの init arity を呼び出す必要があります。これは最終的に変換プロセスを呼び出します。
  2. ステップ(アリティ2) - これは標準的なリダクション関数ですが、トランスデューサー内で必要に応じてrfステップアリティを0回以上呼び出すことが想定されています。例えば、filterは述語に基づいてrfを呼び出すかどうかを選択します。mapは常にrfを1回だけ呼び出します。catは入力に応じてrfを複数回呼び出す場合があります。
  3. 完了(アリティ1) - 一部のプロセスは終了しませんが、終了するプロセス(transduceなど)では、完了アリティを使用して最終値またはフラッシュ状態を生成します。このアリティは、 rf完了アリティを1回だけ呼び出す必要があります。

1 は畳み込みで初期値が与えられなかった場合です。(rf) の評価結果が初期値になります。2 は通常の畳み込みで呼び出される処理です。3 は入力データがなくなったときの処理です。xform は連鎖していることがあるので、引数 rf の関数に result を渡して処理することになります。

簡単な例として map のトランスデューサーを作ってみましょう。簡単にするため、コレクションは一つだけ受け取ることにすると、プログラムは次のようになります。

リスト : map (トランスデューサー)

(defn my-map [f]
  (fn [rf]
    (fn ([] (rf))
        ([result] (rf result))
        ([result input] (rf result (f input))))))

引数無しは (rf) を評価し、引数 1 つの場合は (rf result) を評価します。引数 2 つの場合、入力データ input に関数 f を適用し、それを result を rf に渡して呼び出します。これでマッピングの動作になります。簡単な実行例を示します。

user=> (sequence (my-map #(* % %)) (range 1 11))
(1 4 9 16 25 36 49 64 81 100)
user=> (transduce (my-map #(* % %)) + 0 (range 1 11))
385
user=> (transduce (my-map #(* % %)) + (range 1 11))
385

user=> (sequence (comp (filter odd?) (my-map #(* % %))) (range 1 11))
(1 9 25 49 81)
user=> (transduce (comp (filter odd?) (my-map #(* % %))) + 0 (range 1 11))
165

正常に動作していますね。

●畳み込みを途中で終了する

Clojure には畳み込みを途中で終了する機能が用意されています。

関数 reduce で畳み込みの処理を途中で終了するのは簡単です。reducing 関数の中で reduced を呼び出すだけです。簡単な実行例を示します。

user=> (reduce (fn [a x] (if (neg? x) (reduced :neg) (+ a x))) '(1 2 3 4 5))
15

user=> (reduce (fn [a x] (if (neg? x) (reduced :neg) (+ a x))) '(1 2 -3 4 5))
:neg

要素 x が負ならば、reduced で :neg を返します。負の要素が無ければ合計値を返します。

トランスデューサーの場合、halt-when を使うと途中で処理を終了することができます。

halt-when pred
halt-when pred retf

halt-when はトランスデューサー版の reduced と考えてください。halt-when は pred が真を返す要素を見つけると、処理を中断して、その要素をすぐに返します。引数 retf は 2 引数の関数で、最初が今までに計算された値、第 2 引数は pred が偽を返す要素になります。

簡単な実行例を示します。

user=> (transduce (halt-when neg?) + 0 '(1 2 3 4 5))
15
user=> (transduce (halt-when neg?) + 0 '(1 2 -3 4 5))
-3
user=> (transduce (halt-when neg? #(list %1 %2)) + 0 '(1 2 -3 4 5))
(3 -3)
user=> (transduce (halt-when neg? #(list %1 %2)) + 0 '(1 2 3 4 -5))
(10 -5)

●チャネルとトランスデューサー

ライブラリ core.async のチャネルはトランスデューサーを指定することができます。

(chan buf-or-n xform)
(chan buf-or-n xform ex-handler)

xform を使用する場合、チャネルにバッファを設定する必要があります。ex-handlerは 1 つの引数を持つ関数で、xform で例外が送出された場合に呼び出され、ex-handler の返り値に置き換えられます。

簡単な実行例を示します。

user=> (require '[clojure.core.async :as a])
nil
user=> (def c (a/chan 4 (map #(* % %))))
#'user/c

user=> (a/>!! c 1)
true
user=> (a/>!! c 2)
true
user=> (a/>!! c 3)
true
user=> (a/>!! c 4)
true

user=> (a/<!! c)
1
user=> (a/<!! c)
4
user=> (a/<!! c)
9
user=> (a/<!! c)
16

user=> (def c1 (a/chan 1 (map (fn [x] (if (neg? x) 
(throw (Exception. "xform error")) (* x x)))) (fn [e] (.getMessage e))))
#'user/c1
user=> (a/>!! c1 10)
true
user=> (a/<!! c1)
100
user=> (a/>!! c1 -1)
true
user=> (a/<!! c1)
"xform error"

xform に mapcat を指定すると、シーケンスの要素をチャネルに送信することができます。簡単な実行例を示します。

user=> (def c2 (a/chan 4 (mapcat identity)))
#'user/c2
user=> (a/>!! c2 (range 4))
true

user=> (a/<!! c2)
0
user=> (a/<!! c2)
1
user=> (a/<!! c2)
2
user=> (a/<!! c2)
3

トランスデューサーの基本的な機能を簡単に説明しました。トランスデューサーにはもっと高度な使い方もあると思いますが、Clojure 初心者 (M.Hiroi も含む) にはちょっと難しいかもしれません。トランスデューサーはとても興味深い機能なので、高度な使い方は今後の課題にしたいと思います。


初版 2025 年 8 月 31 日