Skip to content

Instantly share code, notes, and snippets.

@gakuzzzz
Last active September 10, 2019 09:59
Show Gist options
  • Save gakuzzzz/2f69be614daff2c1d541 to your computer and use it in GitHub Desktop.
Save gakuzzzz/2f69be614daff2c1d541 to your computer and use it in GitHub Desktop.
MonadError の嬉しみ (Scala Advent Calendar 2015 ADVENTAR 19th)

MonadError の嬉しみ

この記事は Scala Advent Calendar 2015 ADVENTAR の 19日目です。

まえがき

@gakuzzzz @kawachi @seratch_ja scalaz.MonadErrorで抽象化しましょう(という記事をがくぞーさんが書きましょう)

— Kenji Yoshida (@xuwei_k) 2015, 10月 27
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

失敗する可能性のある処理の戻り値を何にするか

度々、失敗する可能性のある処理の戻り値を何にするべきか、という相談をされる事があります。

実際のところはやはりそのコードの背景や目的によって適切に選択するべきなのですが、 半分フレームワーク的な汎用部品や、幅広い利用が想定されているライブラリのAPIなどでは、 特定の型に依存させない方が便利になる場合があります。

そういう場合に MonadError を使いましょうというお話です。

というかぶっちゃけ モナドの本当の力を引き出す・・・モナドによる同期/非同期プログラミングの抽象化 で書かれてる事と本質的には同じです。エラーハンドルをするにあたって Monad では不十分な場合に MonadError まで限定すれば対応できることもあるよという話ですね。

コード例

Scalaz のバージョンは 7.2.0 を使用します。

以下のコードは play2-auth一部です。

  def authorized(authority: Authority)(implicit request: RequestHeader, context: ExecutionContext): Future[Either[Result, (User, ResultUpdater)]] = {
    restoreUser collect {
      case (Some(user), resultUpdater) => Right(user -> resultUpdater)
    } recoverWith {
      case _ => authenticationFailed(request).map(Left.apply)
    } flatMap {
      case Right((user, resultUpdater)) => authorize(user, authority) collect {
        case true => Right(user -> resultUpdater)
      } recoverWith {
        case _ => authorizationFailed(request, user, Some(authority)).map(Left.apply)
      }
      case Left(result) => Future.successful(Left(result))
    }
  }

これちょっと非同期にしたいがために Future を使っていてエラーハンドルと話がずれてしまうんですが、他に手ごろなサンプルが思いつかなかったので。

ともあれこのコードを MonadError で抽象化してみましょう。

とりあえず最初はシグネチャに MonadError を導入してみます。

  def authorized[F[_]](authority: Authority)(implicit request: RequestHeader, ME: MonadError[F, Throwable]): F[Either[Result, (User, ResultUpdater)]] = {
     中略
  }

まずメソッド自体を F[_] で抽象化し、戻り値を F[Either[Result, (User, ResultUpdater)]] としました。そしてその F[_]MonadError[F, Throwable] という制約を加えます。ここは元々 Future を使っていたためにエラーの型が Throwable に固定されているからですね。元が仮に Either[Hoge, ...] のような型ならば MonadError[F, Hoge] にすれば良いでしょう。

また ExecutionContextFuture の都合なので削除しました。F[_]Future を適用した場合、MonadError[Future, Throwable]ExecutionContext を保持する形となります。

で、内部のコードを変えていくのですが、authorizedFuture だったのって内部で利用しているコードが Future だったからですね。

内部で利用しているコードのシグネチャは以下の通りです。

def restoreUser(implicit request: RequestHeader, context: ExecutionContext): Future[(Option[User], ResultUpdater)]
def authenticationFailed(request: RequestHeader)(implicit context: ExecutionContext): Future[Result]
def authorize(user: User, authority: Authority)(implicit context: ExecutionContext): Future[Boolean]
def authorizationFailed(request: RequestHeader, user: User, authority: Option[Authority])(implicit context: ExecutionContext): Future[Result]

これらも MonadError に抽象化しましょう。

def restoreUser[F[_]](implicit request: RequestHeader, ME: MonadError[F, Throwable]): F[(Option[User], ResultUpdater)]
def authenticationFailed[F[_]](request: RequestHeader)(implicit ME: MonadError[F, Throwable]): F[Result]
def authorize[F[_]](user: User, authority: Authority)(implicit ME: MonadError[F, Throwable]): F[Boolean]
def authorizationFailed[F[_]](request: RequestHeader, user: User, authority: Option[Authority])(implicit ME: MonadError[F, Throwable]): F[Result]

これで準備が整った、と思ったら collect メソッドに行き当たりました。 これ Future には存在していましたが、F[_] に抽象化した今、使えるのは制約であるところの MonadError のメソッドだけなので存在しません。

しょうがないので定義してしまいましょう。元々の Future#collect の挙動としては、パターンにマッチしたらそのまま変換を行い、マッチしなかったら NoSuchElementException にして Failure にしている感じです。

  implicit class MonadErrorOps[F[_], A](val self: F[A])(implicit val ME: MonadError[F, Throwable]) {
    def collect[B](pf: PartialFunction[A, B]): F[B] = self.flatMap { a =>
      pf.andThen(b => ME.point(b))
        .applyOrElse[A, F[B]](a, _ => ME.raiseError(new NoSuchElementException(s"MonadError.collect partial function is not defined at: $a")))
    }
  }

PartialFunction が例外投げたときにcatchするかどうかは悩ましいですね。Futureの場合は flatMap で吸収しますが。もしここで Either などの場合でも pf が例外投げたらエラーにしたい、という場合は以下のようにすると良いでしょう。

  implicit class MonadErrorOps[F[_], A](val self: F[A])(implicit val ME: MonadError[F, Throwable]) {
    def collect[B](pf: PartialFunction[A, B]): F[B] = self.flatMap { a =>
      try {
        pf.andThen(b => ME.point(b))
          .applyOrElse[A, F[B]](a, _ => ME.raiseError(new NoSuchElementException(s"MonadError.collect partial function is not defined at: $a")))
      } catch {
        case NonFatal(e) => ME.raiseError(e)
      }
    }
  }

こんな感じで collect を定義すると、元の authorized は以下の様にかけます。

  def authorized[F[_]](authority: Authority)(implicit request: RequestHeader, ME: MonadError[F, Throwable]): F[Either[Result, (User, ResultUpdater)]] = {
    restoreUser collect[Either[Result, (User, ResultUpdater)]] {
      case (Some(user), resultUpdater) => Right(user -> resultUpdater)
    } handleError {
      _ => authenticationFailed[F](request).map(Left.apply)
    } flatMap {
      case Right((user, resultUpdater)) => authorize[F](user, authority) collect[Either[Result, (User, ResultUpdater)]] {
        case true => Right(user -> resultUpdater)
      } handleError {
        _ => authorizationFailed[F](request, user, Some(authority)).map(Left.apply)
      }
      case Left(result) => ME.point(Left(result))
    }
  }

recoverWithhandleError に変わったのと、Future.successfulME.point に変わりました。

あとは型推論の関係で collect に型アノテーションが付いただけですね。

こうするとクライアント側は以下の様に自分の好きな型で authorize の結果を受け取ることが可能です。

    // kind-projector 使ってます
    import scalaz.std.scalaFuture._
    import scalaz.std.either._

    val a1 = authorized[Future](authority)
    val a2 = authorized[Either[Throwable, ?]](authority)

実際のアプリでは Future を使って非同期にしつつ、テストコードでは Either を使って同期的に処理する、みたいな事も可能ですね。

もちろん FutureEither だけでなく、MonadError のインスタンスであれば何でもいいわけです。

まとめ

簡単ではありますが MonadError を使って処理を抽象化する方法をご紹介しました。

抽象化の力の一旦を感じて貰えれば幸いです。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment