CLOVER🍀

That was when it all began.

Groovy、Clojureでもっと関数合成(オマケでScala)

珍しくトラックバックなどいただいたので、もうちょっとこのネタを書いてみることにしました。

関数合成して何が嬉しいのか…ですが、前の例みたいな足し算、掛け算なんか書いても、確かにあんまりまあ現実味ないですからねぇ。それに、こういう単純なメソッド呼び出しの組み合わせと比べて、何が違う?って話もあると思います。

def f(x) ...
def g(x) ...
g(f(x)) // f >> gと意味的には同じ

考え方としては、「小さな関数を合成して別の大きな関数を作り上げていく」ということでしょうか。これを意識に置いた上で実装するようにしていくと、自然と見通しやすい大きさの関数定義をするようになるんじゃないかなーと思います。

他に便利なところは、関数を合成することで他の「関数を引数に取る関数」に意味的には2つ以上の関数を渡せたりするようになるところでしょうか。
例えば、Collection#collectは引数を1つ取るClosureを引数として受けとりますが、Closureを合成することで2つ以上の変換を行わせることができます。

def add2 = { it + 2 }
def multiply3 = { it * 3 }

[1, 2, 3].collect(add2 >> multiply3)  // [9, 12, 15]
[1, 2, 3].collect(add2 << multiply3)  // [5, 8, 11]

collect内に、Closure#callが明示的に書かれていないところがポイントです。

んで、ここからがちょっとした遊びです。せっかく動的言語でやれるのだから、ClosureをListにでも溜め込んで、あとでまとめて合成・適用なんてプログラムを書いてみようと思います。

元ネタは、この本に登場する「cat -n」の例です。

まず、Closureを溜め込むクラスとしてこういうものを用意しました。

class Pipeline {
    private def closures = []

    def call(Object... args) {
        andThen().call(*args)
    }

    def rightShift(closure) {
        closures << closure
        this
    }

    private def andThen() {
        assert !closures.empty
        closures.tail().inject(closures.head(), { acc, c -> acc >> c })
    }
}

callメソッドを実装しているので、関数オブジェクトっぽい呼び出しができるようになっています。内部にはClosureを溜め込むListを持っており、callメソッドが呼び出されたタイミングでCollection#injectを使って合成します。

使い方としては、こんな感じ。

def pipeline = new Pipeline()
pipeline >> { str -> str.split(' ').toList() }
pipeline >> { xs -> xs.collect { "***${it}***" } }
pipeline >> { xs -> xs.join(' ') }
println(pipeline("Hello World"))  // => 「***Hello*** ***World***」

実際にClosureが合成されるまで、ワンクッション挟んでいるので後でClosureの追加/削除などが一応可能です。今回は、そこまで用意していませんが。

これを使って、「cat -n」コマンドを実装してみます。まずは、こういうClosureを用意します。

def fileContents = { fileName, encoding -> new File(fileName).getText(encoding) }
def toLines = { text -> text.split(/\r?\n/).toList() }
def sizePair = { lines -> [lines.size(), lines] }
def format = { max, lines ->
        def size = "$max".size() + 1
        def zipped = [(1 .. max), lines].transpose()
        zipped.collect { index, line -> String.format("%${size}d: %s", index, line) }
    }
def toUnlines = { lines -> lines.join(System.getProperty('line.separator'))}

これを合成したものを、catnと名付けます。

def catn = new Pipeline() >> fileContents >> toLines >> sizePair >> format >> toUnlines >> this.&println

Closureの合成の流れから、やろうとしていることがわかります…かね?

最後、これを呼び出して標準出力に吐き出します。今回は、このスクリプト自体を表示します。

catn('closure_pipeline.groovy', 'UTF-8')

実行結果。

  1: class Pipeline {
  2:     private def closures = []
  3: 
  4:     def call(Object... args) {
  5:         andThen().call(*args)
  6:     }
  7: 
  8:     def rightShift(closure) {
  9:         closures << closure
 10:         this
 11:     }
 12: 
 13:     private def andThen() {
 14:         assert !closures.empty
 15:         closures.tail().inject(closures.head(), { acc, c -> acc >> c })
 16:     }
 17: }
 18: 
 19: def fileContents = { fileName, encoding -> new File(fileName).getText(encoding) }
 20: def toLines = { text -> text.split(/\r?\n/).toList() }
 21: def sizePair = { lines -> [lines.size(), lines] }
 22: def format = { max, lines ->
 23:         def size = "$max".size() + 1
 24:         def zipped = [(1 .. max), lines].transpose()
 25:         zipped.collect { index, line -> String.format("%${size}d: %s", index, line) }
 26:     }
 27: def toUnlines = { lines -> lines.join(System.getProperty('line.separator'))}
 28: 
 29: def catn = new Pipeline() >> fileContents >> toLines >> sizePair >> format >> toUnlines >> this.&println
 30: catn('closure_pipeline.groovy', 'UTF-8')

作成したPipelineクラスのrightShiftでClosureを追加でき、かつthisを返すようにしているのでそのまま合成していけます。こんな感じで、Closureを動的に追加しておき、Closureの処理結果に次々とClosureを呼び出して適用していく、なんていう仕組みですが、少しは面白くないでしょうか??

あと、Closureを追加する時に、ClosureそのものではなくてMapで加えるようにすると、後で削除したりするのが楽になるかもしれませんね。そうするとクラス名といい、どんどんNettyのパクりになっていきますが…。

ただ、面白いことは面白いのですが、関数の引数間違いとかでコケた場合のデバッグはかなり面倒です。できることなら、静的に定義しておくにこしたことはありません。こういう使い方は、一部の機能とかにとどめておいた方が無難でしょうね。

一応、作成したスクリプトを載せておきます。
closure_pipeline.groovy

class Pipeline {
    private def closures = []

    def call(Object... args) {
        andThen().call(*args)
    }

    def rightShift(closure) {
        closures << closure
        this
    }

    private def andThen() {
        assert !closures.empty
        closures.tail().inject(closures.head(), { acc, c -> acc >> c })
    }
}

def fileContents = { fileName, encoding -> new File(fileName).getText(encoding) }
def toLines = { text -> text.split(/\r?\n/).toList() }
def sizePair = { lines -> [lines.size(), lines] }
def format = { max, lines ->
        def size = "$max".size() + 1
        def zipped = [(1 .. max), lines].transpose()
        zipped.collect { index, line -> String.format("%${size}d: %s", index, line) }
    }
def toUnlines = { lines -> lines.join(System.getProperty('line.separator'))}

def catn = new Pipeline() >> fileContents >> toLines >> sizePair >> format >> toUnlines >> this.&println
catn('closure_pipeline.groovy', 'UTF-8')

この仕組みはScalaみたいな静的言語でやろうとすると、けっこうしんどいことになるんじゃないかなぁと思います…。合成する関数の引数は1つだけ、とか制限が置けるなら少しはマシ?それでも、相当つらそうですが。よって、リライトはClojureでやってみました。

こちら、Clojure版です。
function_pipeline.clj

(defn and-then
  ([f] f)
  ([f g] (comp g f))
  ([f g & fs] (apply comp (reverse (cons f (cons g (vec fs)))))))

(defn >> [pipeline f]
  (dosync (alter pipeline conj f)))
(defn apply-pipeline [pipeline & args]
  (apply (apply and-then @pipeline) args))

(import '(java.io BufferedReader InputStreamReader FileInputStream))
(defn lines [file-name encoding]
  (line-seq (BufferedReader. (InputStreamReader. (FileInputStream. file-name) encoding))))
(defn size-pair [lines] [(.size lines) lines])
(defn n-format [max-lines]
  (let [max (+ (first max-lines) 1)
        lines (second max-lines)
        size (.. max toString length)
        zipped (map (fn [s line] [s line]) (range 1 max) lines)]
    (map (fn [size-line]
           (let [s (first size-line) line (second size-line)]
             (format (str "%" size "d: %s") s line))) zipped)))
(defn unlines [lines]
  (let [line-separator (System/getProperty "line.separator")]
    (reduce (fn [acc line] (str acc line-separator line)) lines)))

(defn cat-n [file-name encoding]
  (def pipeline (ref []))
  (>> pipeline lines)
  (>> pipeline size-pair)
  (>> pipeline n-format)
  (>> pipeline unlines)
  (>> pipeline println)
  (apply-pipeline pipeline file-name encoding))

(cat-n "function_pipeline.clj" "UTF-8")

Clojureには関数を前から合成していく関数(GroovyのClosure#>>、ScalaのFunctionN#andThen)が無いので、自前で実装しました。今回のコレクションの変更には、素直にSTMを使用しています。

動作自体はGroovy版と同じですが、これはこれでけっこうてこずりました…。

(追記)
一応、Scalaでも書いてみました。が、予想通りタイプセーフじゃなくなっちゃうので、合成は静的にやるのがよさそうですね。

trait Pipeline {
  var functions: List[Any => Any] = Nil

  def apply[A, B](arg: A): B =
    compose(arg).asInstanceOf[B]

  def >>[A, B](f: A => B): Pipeline = {
    functions = f.asInstanceOf[Any => Any] :: functions
    this
  }

  private def compose: Any => Any =
    functions.reduceLeft((acc, f) => acc compose f)
}

import scala.io.Source
class Catn(encoding: String = System.getProperty("file.encoding")) extends Pipeline {
  {
    val fc = fileContents(_: String, encoding)
    this >> fc >> lines >> sizePair >> nFormat >> unlines >> println
  }

  def fileContents(fileName: String, encoding: String): String = {
    val source = Source.fromFile(fileName, encoding)
    try {
      source.mkString
    } finally {
      source.close()
    }
  }

  def lines(contents: String): List[String] = contents.lines.toList
  def sizePair(contents: List[String]): (Int, List[String]) = (contents.size, contents)
  def nFormat(pair: (Int, List[String])): List[String] = {
    val (max, lines) = pair
    val size = max.toString.size
    lines zip (1 to max) map { case (line, index) => ("%" + size + "d: %s").format(index, line) }
  }
  def unlines(contents: List[String]): String = contents mkString System.getProperty("line.separator")
}

val catn = new Catn("UTF-8")
catn("function_pipeline.scala")

Scalaの場合、関数合成はFunction1トレイトのインスタンスでしかできないので、Function2などの場合は部分適用を使ってFunction1に変換してあげる必要があります。

…まあ、やっぱりいろいろ微妙ですね。