Scalaでのモナドの活用事例について
Scalaのfor式ではmap, flatMap関数を使って処理をつなげていけることが分かったとしても、実際にどのような場面で使われるのかが把握できないとモナドを使うメリットがわかないと思います。なので今回はScalaでのモナドの活用方法をいくつかまとめていきたいと思います。
Optionモナド
まずは基本的なOptionモナドですが、"すごいHaskell楽しく学ぼう"で紹介されている綱渡りをScalaで実装してみます。 実装する内容は以下のようになっています。 - ピエールさんが綱渡りをするときに持っているポールの左右に鳥が止まってくる - ポールに止まっている左右の鳥の差が4羽以内であれば大丈夫だが、4羽より増えると落下する - 最後ポールに止まっている鳥はどうなっているか、落下しているかをわかるようにする
この内容をScalaで実装していきます。最初に鳥とポールを型シノニムで表せるようにします。
type Birds = Int type Pole = (Birds, Birds)
これでBirds型はInt型と同じで、Pole型は(Birds, Birds)のタプルとして左右に止まっている鳥の数を表せるようになりました。
次に鳥がポールに止まる関数を実装します。関数の引数として鳥の数とポールを受け取って左右のポールに止まっている鳥の差が4羽より多くなったら落下で、4羽いないであれば次のポールの状態を返すというものになります。これをScalaのOption型で表すので落下はNoneとし、次のポールの状態はSome(次のポール)といったものを返すようにします。実装は以下のようになりました。
def landLeft(birds: Birds, pole: Pole): Option[Pole] = if (math.abs((pole._1 + birds) - pole._2) < 4) { Some((pole._1 + birds, pole._2)) } else { None } def landRight(birds: Birds, pole: Pole): Option[Pole] = if (math.abs(pole._1 - (pole._2 + birds)) < 4) { Some((pole._1, pole._2 + birds)) } else { None }
後はfor式で処理をつないでいきます。例えば以下は左に鳥2匹止まる -> 右に鳥2匹止まる -> 左の鳥1匹飛び立つ -> 右に鳥4匹止まる -> 左に鳥2匹止まるといった風に処理をつなげています。
for { start <- Option.apply(0, 0) first <- landLeft(2, start) second <- landRight(2, first) third <- landLeft(-1, second) fourth <- landRight(4, third) last <- landLeft(2, fourth) } yield last None
↑は途中で差が4匹より多くなったので落下ということで結果はNoneになっています。 次の例として
for { start <- Option.apply(0, 0) first <- landLeft(2, start) second <- landRight(2, first) third <- landLeft(2, second) fourth <- landRight(4, third) last <- landLeft(-1, fourth) } yield last Some((3,6))
↑の例では差が4匹以内に収まっているので結果がSome*1になっています。 このサンプルでlandRightを呼び出した後にifとかで場合分けする必要がなく、処理をつなげていくだけでよいというのが分かるかと思います。
Eitherモナド
EitherモナドはOptionモナドを拡張したようなものになっていまして、OptionのNoneの部分を値として保持できるようなものになっています。 例えば失敗したときのクラスとして以下を定義しておきます。
case class Failure(message: String)
先ほどのlandLeft、landRightの関数について、どちらで失敗したかを判別できるようにしたい場合は以下の関数を定義します。
def landLeftEither(birds: Birds, pole: Pole): Either[Failure, Pole] = if (math.abs((pole._1 + birds) - pole._2) < 4) { Right((pole._1 + birds, pole._2)) } else { Left(Failure("fail by landLeft")) } def landRightEither(birds: Birds, pole: Pole): Either[Failure, Pole] = if (math.abs(pole._1 - (pole._2 + birds)) < 4) { Right((pole._1, pole._2 + birds)) } else { Left(Failure("fail by landRight")) }
これを使うと以下のように書けます。
for { start <- Right((0, 0)) first <- landLeftEither(2, start) second <- landRightEither(2, first) third <- landLeftEither(-1, second) fourth <- landRightEither(4, third) last <- landLeftEither(2, fourth) } yield last Left(Failure(fail by landRight))
for { start <- Right((0, 0)) first <- landLeftEither(2, start) second <- landRightEither(2, first) third <- landLeftEither(2, second) fourth <- landRightEither(4, third) last <- landLeftEither(-1, fourth) } yield last Right((3,6))
余談ですが Either型
のLeft, RightについてRightは右だけでなくて正しいという意味でも使っているようで、正常系ならRightを返すのが一般的なようです。
継続モナド
次にScalaで実装された継続モナドというものを見ていきたいと思います。こちらの資料に詳しく乗っているので、そのまま使おうと思うのですOptionモナドと比べても慣れるまでの難易度は大分高そうです。
まず継続モナドの定義ですが以下のようになっています。
object Cont { def pure[R, A](a: A): Cont[R, A] = Cont((ar: A => R) => ar(a)) } final case class Cont[R, A](run: (A => R) => R) { def flatMap[B](f: A => Cont[R, B]): Cont[R, B] = Cont(br => run(a => f(a).run(br))) def map[B](f: A => B): Cont[R, B] = flatMap(a => Cont.pure(f(a))) }
Scala自体の構文もややこしかったり、継続モナド自体もイメージを掴みづらいのでこの定義だけを見て理解するのは難しいと思いますので、細かく説明を入れていきたいと思います。 object Cont
と final case class Cont
の2つが出てきますが object Cont
はシングルトンのオブジェクトで final case class Cont
の方はJavaなどのクラスと似たものになっています。 object Cont
で定義されている関数 def pure[R, A]
ではオブジェクト内の変数の参照もないのでjavaのstatic関数のように使用することが出来ます。 クラスの定義 final case class Cont[R, A](run: (A => R) => R)
のメンバ run: (A => R) => R
の部分は A => R
型の(A型を引数を受け取りR型を戻り値として返す)関数を受け取り R型
を返す関数となっています。これだけ見ると関数だけ受け取って結果を返していて、関数の引数として渡す A型
はどこにあるという気がしますが。それは以下のようにクラスの外から受け取れるようになっています。
def makeCont(i: Int):Cont[Int, Int] = Cont { run => run(i) }
run関数は以下のように後から渡して実行します。これを見るとrun関数の定義を後から決めているような動きになっています。
makeCont(3).run(i => i + 10) 13
次にflatMapの関数ですが、内容としては Cont[R, A]型
から Cont[R, B]型
に変換するものとなっています。Cont[R, A]型
, Cont[R, B]型
ともにrun関数を実行することで R型
になることが保証されているのですが、Cont[R, A]型
はCont[R, B]型
を経由して R型
になるといったものになっています。
def flatMap[B](f: A => Cont[R, B]): Cont[R, B] = Cont(br => run(a => f(a).run(br)))
↑の br
はB型からR型へ変換する関数で run(a => f(a).run(br))
の部分はA型からR型に変換する部分で f(a)
関数を実行して変換したCont[R, B]型
のrun関数として br
を渡しています。
map関数はモナドの標準的なものになっています。pure関数はContの外から受け取ったA型の値をContのrun関数の引数として渡しているものになっています。
継続モナドは関数合成に近い動きをしているように感じますが、以下のような関数を実装してみると関数の合成するかどうかの条件分けができるイメージが近いかと思います。
def fizzCont(i: Int): Cont[String, Int] = Cont { cont => if (i % 3 == 0) { "Fizz" } else { cont(i) } }
↑は引数が3の倍数であれば処理終了で、それ以外であればcont(i)
を実行するので関数の合成とはまた異なっています。
関数の合成のような使い方もできて、条件によらず処理を継続させるように定義さします。
def add(i: Int, j: Int): Cont[Int, Int] = Cont {cont => cont(i+j) }
def fizzCont(i: Int): Cont[String, Int] = Cont { cont => if (i % 3 == 0) { "Fizz" } else { cont(i) } } def buzzCont(i: Int): Cont[String, Int] = Cont { cont => if (i % 5 == 0) { "Buzz" } else { cont(i) } } def fizzBuzzCont(i: Int): Cont[String, Int] = Cont { cont => if (i % 15 == 0) { "FizzBuzz" } else { cont(i) } } def fizzBuzz(i: Int): Cont[String, Int] = for { a <- fizzBuzzCont(i) b <- fizzCont(a) c <- buzzCont(b) } yield c fizzBuzz(3).run(_.toString) fizz
fizzBuzz関数について、最初にfizzBuzzContを実行し15の倍数なら"FizzBuzz"に変換し終了、それ以外は継続。次にfizzContで3の倍数であったら"Fizz"を返し終了、それ以外は継続。それからbuzzContでも同様に5なら終了、それ以外は継続で、継続の場合はrun(_.toString)で数値から文字列に変換といったようになっています。
このように継続モナドでは計算結果による分岐を繋げていくこに向いていますが、それ以外にもエラーハンドリングや、リソースの管理などにも向いているようです。
これらのモナドの活用より、実態は関数なのですが言語自体の構文の拡張する使い方ができるように感じます。
*1:3, 6