ひらめの日常

日常のメモをつらつらと

【Scala】Scala Design Patterns - Chapter10 - Monads

はじめに

Scala Design Patterns』 という本を、会社の輪読会で呼んでいるのですが、モナド周りの説明が難しかったので自分なりに日本語で咀嚼してみた内容になります。具体的には、『Scala Design Patterns』の265ページ、Chapter10-Monads の内容です。

ソースコードは出版社のGitHubリポジトリにあります。

github.com

(「モナド何も分からん」から、「モナドちょっと分からん」くらいにはなりました)

Monad の定義

本の中の定義

Monads are functors that have the unit and flatMap methods and follow the monad rules.

ということで、モナドの定義を知るためには Functors とは何かを知る必要がある。

Functorsとは

map メソッドを持ち、以下の決まりに則ったものを呼ぶ。これらを Functor則 と呼ぶことがある。

  • Identity: map(x) (i => i) == x
  • Composition: map(map(x) (i => y(i))) (i => z(i))map(x) (i => z(y(i))) は等しい。
  • map メソッドはデータ構造自体を変更することはない。データの表現を変えるだけ。

実際には以下のようなコードが Functor となる。

trait Functor[F[_]] {
  def map[T, Y](l: F[T])(f: T => Y): F[Y]
}

map メソッドが定義されており、F は保ったまま、その中の TY へと変換している。

これの具体的な実装は、例えば List の場合以下のようになる。

val listFunctor: Functor[List] = new Functor[List] {
  override def map[T, Y](l: List[T])(f: T => Y): List[Y] = l.map(f)
}

Monadとは

Monads are functors that have the unit and flatMap methods and follow the monad rules.

flatMap, unit, map

  • flatMap とは、map した後に flatten すること。
  • unit はコンストラクタと同義。
  • mapflatMapunit を用いて表現することができる。
    • f: T => Y の結果を unit で包んであげて、その後で flatten するイメージ。

前節で取り上げた Functor とは異なり、map などの関数の引数としてデータを受け取るのではなく、Functor自体がデータを保持しているとする。モナドの定義から、flatMapMonad 側に持たせ、map の具体的な実装は Monad 内で flatMapunit を用いて表している。

trait Functor[T] {
  def map[Y](f: T => Y): Functor[Y]
}

trait Monad[T] extends Functor[T] {

  def unit[Y](value: Y): Monad[Y]
  
  def flatMap[Y](f: T => Monad[Y]): Monad[Y]
  
  override def map[Y](f: T => Y): Monad[Y] =
    flatMap(i => unit(f(i)))
}

モナド

  • Identity law: 恒等写像に対して map をしてもデータは変わらない。また、unit メソッドに対して flatMapをしてもデータは変わらない。unit はモノイドで言うところの単位元
    • map(x)(i => i) == x
    • flatMap(x) (i => unit(i)) == x
  • The unit law

    • unit(x).flatMap { y => f(y) } == f(x)
    • unit(x).map { y => f(y) } == unit(f(x))
  • Composition: 複数の mapflatMap は合成することができ、順番によって違いは生まれない。(副作用が生まれる場合はこれが満たされないので注意が必要)

    • x.map(i => y(i)).map(i => z(i))x.map(i => z(y(i))) は等しい。
    • x.flatMap(i => y(i)).flatMap(i => z(i))x.flatMap(i => y(i).flatMap(j => z(j))) は等しい

何が嬉しいのか

状態を隠蔽して、重要な部分のみに注目することができるようになると書いてある。具体例がないと実感できないので、本に載っている具体例を見てみる。

Monads help us hide this state from the user and just expost the important parts as well as abstract the way we deal with errors, and so on.

具体例

Optionモナド

まずは基底クラスである Option を定義する。

sealed trait Option[A] extends Monad[A]

そして、具体的な実装である SomeNone を実装する。

case class Some[A](a: A) extends Option[A] {
  override def unit[Y](value: Y): Monad[Y] = Some(value)
  override def flatMap[Y](f: A => Monad[Y]): Monad[Y] = f(a)
}
case class None[A]() extends Option[A] {
  override def unit[Y](value: Y): Monad[Y] = None()
  override def flatMap[Y](f: (A) => Monad[Y]): Monad[Y] = None()
}

副作用の隠蔽という点では、適していない例のように思う。SomeNone を同じtraitから定義し、map, flatMap が定義してあるおかげで for文で書けるという嬉しさはある。

IOモナド

ファイルから行を読み取り、その全てを大文字にしてあtらしいファイルに書き出すプログラムを考える。「状態」として今回は FileIOState の数値をincrementする。

まずはIOの状態を保持する State を定義する。

sealed trait State {
  def next: State
}

IOAction は、古い State から、新しい State と操作の結果のタプルを返す関数を継承している。unit, map, flatMap を実装しており、モナド則を満たしている。

flatMap では、

  1. 新しい IOAction を返す。
  2. その新しい IOActionapply で古い IOActionapply を呼び出し、実行結果として新しい State と結果のタプルを受け取る。
  3. 受け取った結果を引数とし、 flatMap で受け取った関数 f: T => IOAction[Y] を実行。
  4. 実行した結果の IOAction である action2 に新しい State を渡し、apply を呼び出す。

ここまで全て、flatMap 実行時には呼び出しがされておらず、IOAction#apply が呼び出されて初めて実行されることに注意する。つまり副作用は flatMap 時点では発生していない。

sealed abstract class IOAction[T] extends ((State) => (State, T)) {

  def unit[Y](value: Y): IOAction[Y] = IOAction.unit(value)

  def flatMap[Y](f: (T) => IOAction[Y]): IOAction[Y] = {
    val self = this
    new IOAction[Y] { // 1
      override def apply(state: State): (State, Y) = {
        val (state2, res) = self(state) // 2
        val action2 = f(res) // 3
        action2(state2) // 4
      }
    }
  }

  def map[Y](f: T => Y): IOAction[Y] =
    flatMap(i => unit(f(i)))
}
object IOAction {

  def apply[T](result: => T): IOAction[T] =
    new SimpleAction[T](result)
  
  def unit[T](value: T): IOAction[T] =
    new EmptyAction[T](value)

  // 名前渡しで引数を受け取り、実行時に評価する
  private class SimpleAction[T](result: => T) extends IOAction[T] {
    override def apply(state: State): (State, T) =
      (state.next, result)
  }
  
  // unitを呼び出した時に状態を変えないような実装が必要
  private class EmptyAction[T](value: T) extends IOAction[T] {
    override def apply(state: State): (State, T) = 
      (state, value)
  }
}

具体的な使い方を見る。いずれも IOAction の引数は名前渡しなので、この時点では実行されず、受け取った IOAction に対して apply を呼んだ時に初めて実行される。

package object io {
  def readFile(path: String): IOAction[Iterator[String]] =
    IOAction(Source.fromFile(path).getLines())

  def writeFile(path: String, lines: Iterator[String]): IOAction[Unit] =
    IOAction({
      val file = new File(path)
      printToFile(file) { p => lines.foreach(p.println) }
    })

  private def printToFile(file: File)(writeOp: PrintWriter => Unit): Unit = {
    val writer = new PrintWriter(file)
    try {
      writeOp(writer)
    } finally {
      writer.close()
    }
  }
}

そして、FileIOを実行できるクラスを定義する。State の実装である FileIOState は privateにすることで、外から状態を作れないようにしている。

run でやっていることは、次のような意味になる。

  1. runIOIOAction を定義する。
  2. IOactionapply を、 FileIOStateの初期値を渡して実行する。

action を実行するまで副作用が発生しておらず、副作用を局所化できているのが嬉しい点。

abstract class FileIO {
  
  private class FileIOState(id: Int) extends State { 
    override def next: State = new FileIOState(id + 1)
  }

  def run(args: Array[String]): Unit = {
    val action = runIO(args(0), args(1))
    action(new FileIOState(0))
  }

  def runIO(readPath: String, writePath: String): IOAction[_]
}

実際の使い方としては次の通り。

object FileIOExample extends FileIO {
  
  def main(args: Array[String]): Unit = {
    run(args)
  }
  
  override def runIO(readPath: String, writePath: String): IOAction[_] =
    for {
      lines <- readFile(readPath)
      _ <- writeFile(writePath, lines.map(_.toUpperCase))
    } yield ()
 
  // 以下と同じ
  // override def runIO(readPath: String, writePath: String): IOAction[_] = {
  //   readFile(readPath).flatMap { lines =>
  //     writeFile(writePath, lines.map(_.toUpperCase))
  // }
}