Home About Contact
Kotlin , Functional Programming , Monad

データ変換を Writer Monad 的に処理する その1

このポストはすでに古い。 こちら 改訂版 を参照ください。

たとえば価格改定などで、昨年と今年の価格表がエクセルデータとして支給される。 そして、それを商品ごとにマージして、最新の価格表を提出せよ、 といったタスクがあったとする。

このとき、商品ごとにキーとしての id が設定されているわけでもなく 商品名が一致したら同じ商品として扱うという雑な仕様。 そして、入力ミスにより微妙に商品名が昨年と今年で異なるものが含まれてもいるのだが、 同じ商品として扱ってほしいと言われたりする。

さらに価格が値下がりしている商品があるのだが、それらは価格入力をミスしている恐れがあるので、 そういう場合は警告しなければならない。などなど。 このような状況で、昨年と今年のデータをマージして最新の商品価格情報を作り出す必要がある場合に、 Writer Monad があれば心強い ですよ、という話です。

Writer Monad とは Haskell 界隈の Writer Monad のことですが、Monad がどうとかは、 このエントリーでは関係ないこととします。 あくまで、Writer Monad な考え方、それに近い実装(ここでは kotlin を使います)を使って、 冒頭のようなデータ変換時をするときに、データ欠損とか、処理中に起こる特殊事情をおまけの情報(ログ)として書き出していく、 それでいて本来の変換計算自体は、クリーンに保つことができるということを説明していきます。

環境の確認

$ kotlinc -version
info: kotlinc-jvm 1.8.10 (JRE 17.0.9+9-Ubuntu-122.04)

Writer (Monad)

まず Writer の実装です。

Writer.unit は本当は Writer.return と書きたいところですが、 kotlin では return という関数名が使えないので unit としています。

import java.util.Optional

class Writer<T> private constructor(val valueOpt: Optional<T>, var text: String) {
    fun <R> bind(f: (T)->Writer<R>): Writer<R> {
        return if( this.valueOpt.isPresent() ) {
            val v: T = this.valueOpt.get()
            val w = f(v)
            w.text = "${this.text} / ${w.text}"
            w
        } else {
            Writer.unit(this.text)
        }
    }

    companion object {
        fun <T> unit(value: T, text: String): Writer<T>{
            return Writer(Optional.of(value), text)
        }

        fun <T> unit(text: String): Writer<T>{
            return Writer(Optional.empty(), text)
        }
    }
}

この Writer を使うコードを書きます。

まず、純粋な関数 addOneF , subOneF を定義します。

val addOneF: (Int)->Writer<Int> = { value->
    val newValue = value + 1
    Writer.unit(newValue, "add (${value} +1) = ${newValue}")
}

val subOneF: (Int)->Writer<Int> = { value->
    val newValue = value - 1
    Writer.unit(newValue, "sub (${value} -1) = ${newValue}")
}

それぞれ addOneF が 1 加算, subOneF が 1 減算する関数です。 これらの関数は、普通の値を受け取って、なんらかの計算(ここでは単に 1 を足すか引くだけの計算)をして、そのあと Writer.unit で計算結果を包んで返します。 このとき、計算結果だけでなく、いわゆるおまけの文脈として文字列を渡します。 ここではおまけの文脈は計算内容の説明です。

それでは addOneF, subOneF を使って計算します。

val initValue = 1
val resultWriter =
    Writer.unit(initValue, "init 1")
	.bind( addOneF )
	.bind( subOneF )

普通の値 1 を initValue に入れて、それを Writer で計算していきます。

計算結果を出力します。

println("result: ${resultWriter.valueOpt}")
println("log: ${resultWriter.text}")

実行します。

$ kotlinc -script main.kts
result: Optional[1]
log: init 1 / add (1 +1) = 2 / sub (2 -1) = 1

うまくいきました。 ただ、 初期値が 1 で結果も (Optional の) 1 なので、なんだか嘘くさいので、addOneF をもう一つ追加してみましょう。

val initValue = 1
val resultWriter =
    Writer.unit(initValue, "init 1")
    .bind( addOneF )
    .bind( subOneF )
    .bind( addOneF )

println("result: ${resultWriter.valueOpt}")
println("log: ${resultWriter.text}")

実行してみます。

$ kotlinc -script main.kts
result: Optional[2]
log: init 1 / add (1 +1) = 2 / sub (2 -1) = 1 / add (1 +1) = 2

うまくいきました。

ここまでは、Int(型) だけを対象にして計算をしていました。途中で別の型に変えたいということもあるでしょう。(実践的な状況では通常そうなります、あとで示します。)

Int から Float にキャストする純粋な関数 castIntToFloat を定義します。

val castIntToFloat: (Int)->Writer<Float> = { value->
    val newValue = value.toFloat()
    Writer.unit(newValue, "cast Int ${value} to Float ${newValue}")
}

これを使ってみます。 モナド計算の末尾に bind( castIntToFloat ) を追加します。

val initValue = 1
val resultWriter =
    Writer.unit(initValue, "init 1")
    .bind( addOneF )
    .bind( subOneF )
    .bind( addOneF )
    .bind( castIntToFloat )

println("result: ${resultWriter.valueOpt}")
println("log: ${resultWriter.text}")

実行してみます。

$ kotlinc -script main.kts
result: Optional[2.0]
log: init 1 / add (1 +1) = 2 / sub (2 -1) = 1 / add (1 +1) = 2 / cast Int 2 to Float 2.0

値が (Optional) 2.0 の Float 型になりました。

さらに、定番の説明ですが、途中で計算が失敗した場合にも対応できることを示します。 ここでは必ずに失敗する純粋な関数 makeErrorF を定義します。

val makeErrorF: (Int)->Writer<Int> = { value->
    Writer.unit("error")
}

Writer.unit(文字列) を呼び出すとエラーとして扱われます。 Writer クラスの内部で Optional.empty() が使われる。 詳しくは Writer の実装を参照のこと。

モナド計算途中でこの makeErrorF をはさみ、わざと失敗させてみます。

val initValue = 1
val resultWriter =
    Writer.unit(initValue, "init 1")
    .bind( addOneF )
    .bind( subOneF )
    .bind( makeErrorF )
    .bind( addOneF )
    .bind( castIntToFloat )

println("result: ${resultWriter.valueOpt}")
println("log: ${resultWriter.text}")

実行します。

$ kotlinc -script main.kts
result: Optional.empty
log: init 1 / add (1 +1) = 2 / sub (2 -1) = 1 / error

ログを見ればわかるとおり、 subOneF の次の計算で error が発生し、結果も Optional.empty になっています。

エントリーが長くなってきたので、いったんまとめます。

まとめ

このように、Writer Monad (的)を使うと、次のような利点があります。

ログを出す場合、Javaの世界では なんとか logger などを使って副作用として実装することが多いと思います。 しかし、そのやり方では、そのログがどのデータと結びついているかあとで照合させるために、個々のデータを特定する id をログに書いたり、 またそのログをあとから id で結びつけて整理したり、という本来の計算とは関係ないコードを書く必要が生じます。

Writer Monad のアプローチでは、 副作用を一切発生させないで計算とログを一緒に処理します。 ただし、個々の計算につかう関数は純粋な関数であるため、 それぞれの計算に必要な情報は全部入力と出力に含める必要がある、という面倒さはあります。

しかし、その結果、平行処理が安全にできるようになるとか、 個々の計算は副作用のない純粋な関数になるので、計算内容の変更・計算の追加・削除などが安全かつ簡単に行えるようになります。

次回はこの Writer を使ってより実践的な計算を試します。