CLOVER🍀

That was when it all began.

GroovyでScalaのOptionを書いてみる

今日の小ネタ、第2弾。ホント、なんとなく書いてます…。

ScalaにはOptionというクラスがあって、標準ライブラリやら普通のアプリケーションのコードの至る所に出てきます。これは、どこぞのMaybeモナド…にあたるものだと思うのですが、これをGroovyで書いてみようと思います。

まずは、最上位のインターフェース。
Monad.groovy

interface Monad<T> {
    Monad<T> flatMap(Closure closure)
    void foreach(Closure closure)
    Monad<T> filter(Closure closure)
}

Monadインターフェースに要求するものが、どう見てもScalaです…。
*withFilterというのはさておき
あと、Groovyにジェネリクスを書いてもあんまり意味がないのはわかってますが、一応ということで。

続いて、Optionクラスとそのサブクラス。
Option.groovy

abstract class Option<T> implements Monad<T> {
    abstract boolean isEmpty()
    abstract T getValue()
    abstract T getOrElse(Closure closure)

    static <A> Option<A> create(A value) {
        if (value != null) {
            new Some(value)
        } else {
            new None()
        }
    }
}

class Some<T> extends Option<T> {
    private final T value

    Some(T value) {
        this.value = value
    }

    boolean isEmpty() {
        false
    }

    T getValue() { value }

    T getOrElse(Closure closure) { value }

    Option<T> flatMap(Closure closure) {
        closure(value)
    }

    void foreach(Closure closure) {
        closure(value)
    }

    Option<T> filter(Closure closure) {
        if (closure(value)) {
            this
        } else {
            new None<T>()
        }
    }

    String toString() { "Some(${value})" }
}

class None<T> extends Option<T> {
    boolean isEmpty() {
        true
    }

    T getValue() { throw new UnsupportedOperationException("This is None.") }
    T getOrElse(Closure closure) { closure() }

    Option<T> flatMap(Closure closure) { this }
    void foreach(Closure closure) { }
    Option<T> filter(Closure closure) { this }

    String toString() { "None" }
}

はい、極力Scalaっぽいメソッドを付けておきました。あとScalaのOptionコンパニオンオブジェクトはapplyメソッドを持っているのですが、こちらはそのまま再現できないので

    static <A> Option<A> create(A value) {
        if (value != null) {
            new Some(value)
        } else {
            new None()
        }
    }

と、適当なstaticメソッドを付けて逃げてます。

これらは、先にコンパイルしておきます。

$ groovyc Monad.groovy Option.groovy 

では、使う側のコード。

opt = Option.create("Tanaka")

println(
opt
  .flatMap { new Some("[${it}]") }
  .getOrElse { "Empty?" }
)
// => [Tanaka]

一応、うまく動いている感じです。

nullから構築しても、大丈夫です。

opt = Option.create(null)

println(
opt
  .flatMap { new Some("[${it}]") }
  .getOrElse { "Empty?" }
)
// => Empty?

Optionに条件を加えても大丈夫。

opt = Option.create("Tanaka")

println(
opt
  .filter { it.size() > 10 }  // <= 追加分
  .flatMap { new Some("[${it}]") }
  .getOrElse { "Empty?" }
)
// => Empty?

こういう、途中の結果の成功/失敗を判定せずに、ストレートに処理をかけると楽ですよね〜。

では、最後。Mapにカテゴリクラスを足して拡張して、Optionと組み合わせてみます。

@Category(Map)
class MapOptionCategory<K, V> {
    Option<V> getOpt(K key) {
        if (containsKey(key)) {
            new Some(get(key))
        } else {
            new None()
        }
    }
}

Map#getOptメソッドを追加して、Mapに存在しないキーが渡された場合はNoneを、存在する場合はSomeを返すように変更。

これをuseします。

use(MapOptionCategory) {
    map = [name: "Tanaka", age: 25]
    map.getOpt("name").foreach { println it }  // => Tanaka
    map = [:]
    map.getOpt("name").foreach { println it }  // 何も出力されない
}

キーに対応する値がMapに入ってなくても、nullチェックなど不要です。

もうちょっとサンプル。

use(MapOptionCategory) {
    map = [name: "Kazuhira",
           organization: [name: "Little Wings",
                          since: [year: 2005, month: 5, day: 3]]]

    v = map
           .getOpt("organization")
           .flatMap { it.getOpt("since") }
           .flatMap { it.getOpt("year") }
           .getOrElse { 2012 }
    println(v)
}
// => 2005

空のMapに対しても、問題なく動作します。

use(MapOptionCategory) {
    map = [:]

    v = map
           .getOpt("organization")
           .flatMap { it.getOpt("since") }
           .flatMap { it.getOpt("year") }
           .getOrElse { 2012 }
    println(v)
}
// => 2012

今回は最初からキーに対応する値がありませんが、途中のMapに対応するキーがなくても大丈夫です。ひとつひとつの処理の途中結果を判定するコードがないので、すごくスッキリです。

こうやってやりたいことをストレートに表現できるモナドのコードは、ScalaHaskellを勉強していてけっこう衝撃を受けた内容なのですが、実際に使用するにはそれなりに言語のサポートがないとつらいですね…。
モナドのための構文があると、モナドを組み合わせた時にインデントがネストしていくことを避けられるはずなのですが、Groovyだとムリっぽいですよね…。

まあ、さすがにGroovyにはScalaのforやHaskellのdoみたいなものは入らんでしょう。