階層構造を容易に拡張できる例外

続編を書きました

The Missing Method of Extensible Exception: implicit “transitive”

はじめに

注意: 記事の中にあるコードは読みやすさのためにimportなどを省略しているので、このままでは動かない。動かしたい方はGithubのリポジトリを使うとよい。

Scalaで例外を取り扱う際には、一般的にデータ型を使って次のように例外の階層構造を設計する。

trait RootException extends Throwable

case class DatabaseException(m: String) extends RootException

case class HttpException(m: String) extends RootException

trait FileException extends RootException

case class ReadException(m: String) extends FileException

case class WriteException(m: String) extends FileException

これは次のような階層構造になっている。

RootException
|
+---- DatabaseException
|
+---- HttpException
|
+---- FileException
      |
      +---- ReadException
      |
      +---- WriteException

このような状態で、DatabaseExceptionHttpExceptionが両方発生するかもしれない処理をEitherを使って次のように実行したいとする。

val result = for {
  x <- databaseService(???) // Either[DatabaseException, A]
  y <- httpService(???)     // Either[HttpException, A]
} yield ()

databaseServiceEither[DatabaseException, A]を返す関数であり、一方httpServiceEither[HttpException, A]という型を持つ値を返す関数である。しかし、これらの型をfor式で合成した結果のresultはどういう型になるだろうか。 Either共変なので、階層のより上位にある型へとキャストしていくから、この場合resultの型はEither[RootException, Unit]となる。しかし、RootExcepitonになってしまっては、もはやFileExcepionと区別することができない。 そこで、新たに次のような例外を表わすケースクラスを作成する。

case class DatabaseAndHttpException(m: String) extends RootException

さて、ではこのDatabaseAndHttpExceptionを例外の階層に追加しなければならない。そうなると既存にあったDatabaseExceptionHttpExceptionを変更しなければならず、Expression Problemが発生してしまう。Expression Problemを回避して、つまりは既存のデータ型に変更を加えることなく、DatabaseAndHttpExceptionを挿入することはできないだろうか。

サブタイピングと型の多様性

次のように、例外の階層構造をextendsを用いて作成するが、これは型のサブタイピングを行っている1

trait RootException extends Throwable

case class DatabaseException(m: String) extends RootException
RootException
|
+---- DatabaseException

このよう場合、DatabaseExceptionRootExceptionのサブタイプであると言い、RootExceptionDatabaseExceptionのSupertypeであると言う。 そもそも、このような例外(型)の階層構造(サブタイプ関係)をどうして作るのかというと、それはサブタイピングに基づく多様性を表現したいからである。サブタイピングの多様性はプログラム言語論の資料にて次のように説明されている。

型Aが型Bのsubtype(部分型)のとき、型Bの式を書くべきところに、型Aの式を書いても良い。

これを今回の例にあてはめると、DatabaseExceptionRootExceptionのサブタイプであるので、RootExceptionの式を書くべきところに、DatabaseExceptionを書いてもよいということになる。また、HttpExceptionRootExceptionのサブタイプであるので、RootExceptionの式を書くべきところに、HttpExceptionの式を書いてもよいということになる。 Either[DatabaseException, A]Either[HttpException, A]は左側の型が異なり、通常合成することができないが、サブタイプ関係を使いDatabaseExceptionHttpExceptionを共にRootExceptionの式とみなすことで、Either[RootException, A]として合成が可能になる。 このように、例外の階層構造はサブタイピングという型システムの力を使って行われている。しかし、このままでは最初問題にしたように、階層構造の自由な場所に新たな例外を加えようとすると、型の階層を変更する必要があるのでExpression Problemが発生してしまう。

型クラスによる安全なキャスト

通常、型を強引に変更するasInstanceOfなどを用いた(ダウン)キャストは危険であり、行うべきではない。しかし、安全にある型から別の型へ変換する方法がないかというと、そうでもない。例えばIntからStringへキャストする関数は次のように定義できる。

def string_of_int(i: Int): String = i.toString

このように、ユーザーが定義したキャスト関数ならば、サブタイプ関係がない場合でも安全にキャストを行うことができる。このようなある型Aから型Bへのキャストをユーザーが提供しているという情報を型クラスとして次のように定義する。

trait :->[A, B] {
  def cast(a: A): B
}

例えばInt :-> Floatというインスタンス(impliitパラメータ)があれば、IntからFloatへ安全にキャストするための関数castが存在するということになる。

implicit val float_of_int = new :->[Int, Float] {
  def cast(a: Int): Float = a.toFloat
}

これを用いて例外の階層構造を拡張可能な形で定義することができる。

implicitパラメータの探索順序

本題に入る前に、Scalaのimplicitパラメータがどのように探索されるのか知っておく必要がある。 Scalaは次の順序で型クラスのインスタンス(implicitパラメータ)を探索する。

  1. 現在のスコープ
  2. 型クラスに投入された型パラメータのコンパニオンオブジェクト
  3. 型クラスに投入された型パラメータのスーパークラスのコンパニオンオブジェクト
  4. 型クラスのコンパニオンオブジェクト

Scalaはまず(1)から順番にimplicitパラメータを探索し、見つかった時点で探索を打ち切る。

例外の拡張

さて、安全なキャストA :-> Bを用いて例外の階層を定義するとはどういうことだろうか。先程の例を再び振り替えると、今、DatabaseExceptionHttpExceptionの二つを抽象化したようなDatabaseAndHttpExceptionという例外を作ることで次のfor式の結果をEither[DatabaseAndHttpException, Unit]のようにしたい。

for {
  x <- databaseService(???) // Either[DatabaseException, A]
  y <- httpService(???)     // Either[HttpException, A]
} yield ()

そこでまず、既存の型を変更せずDatabaseAndHttpExceptionを定義する。

case class DatabaseAndHttpException(m: String) extends RootException

型のサブタイプ関係は次のようになっている。

RootException
|
+---- DatabaseException
|
+---- HttpException
|
+---- FileException
|     |
|     +---- ReadException
|     |
|     +---- WriteException
|
+---- DatabaseAndHttpException

そして、DatabaseExceptionからDatabaseAndHttpExceptionへのキャストと、HttpExceptionからDatabaseAndHttpExceptionへのキャストをそれぞれ次のようにDatabaseAndHttpExceptionのコンパニオンオブジェクトに定義する。

object DatabaseAndHttpException {
  implicit val databaseException = new :->[DatabaseException, DatabaseAndHttpException] {
    def cast(a: DatabaseException): DatabaseAndHttpException =
      DatabaseAndHttpException(s"database: ${a.m}")
  }

  implicit val httpException = new :->[HttpException, DatabaseAndHttpException] {
    def cast(a: HttpException): DatabaseAndHttpException =
      DatabaseAndHttpException(s"http: ${a.m}")
  }
}

さて、次はEithermapflatMapを改造する。これにはScalaのPimp my Library Patternを用いる2

object Implicit {
  implicit class ExceptionEither[L <: RootException, R](val ee: Either[L, R]) {
    def map[L2 <: RootException, R2](f: R => R2)(implicit L2: L :-> L2): Either[L2, R2] = ee match {
      case Left(e)  => Left(L2.cast(e))
      case Right(v) => Right(f(v))
    }

    def flatMap[L2 <: RootException, R2](f: R => Either[L2, R2])(implicit L2: L :-> L2): Either[L2, R2] = ee match {
      case Left(e)  => Left(L2.cast(e))
      case Right(v) => f(v)
    }

    def as[L2 <: RootException](implicit L2: L :-> L2): Either[L2, R] = ee match {
      case Left(e)  => Left(L2.cast(e))
      case Right(v) => Right(v)
    }
  }
}

このように、mapflatMapの定義を変更して、Either[L, R]を受け取り、L :-> L2というimplicitパラメータを探索して、存在した場合はimplicitパラメータを用いてEither[L2, R2]を返すという関数に変更している。 さっそくこれを試してみよう。

def left[A](e: A) = Left[A, Unit](e)

val e1 = left(DatabaseException("db error"))
val e2 = left(HttpException("http error"))

val e3 = for {
  a <- e1
  b <- e2
} yield ()

しかし、これは次のようなエラーでコンパイルに失敗してしまう。

Error:(18, 9) could not find implicit value for parameter L2: utils.:->[utils.DatabaseException,utils.HttpException]
      a <- e1
        ^

DatabaseAndHttpExceptionは型としてDatabaseExceptionHttpExceptionと階層関係にないので、Scalaの処理系はDatabaseAndHttpExceptionimplicitパラメータの探索を試みない。そこで、先程mapflatMapと共に定義したasメソッドを使って明示的に安全なキャストを行ってやると上手くいく。

val e3 = for {
  a <- e1
  b <- e2.as[DatabaseAndHttpException]
} yield ()

このようにすると、e2Either[HttpException, Unit]なのでasHtttException :-> DatabaseAndHttpExceptionimplicitパラメータを探索する。型クラス:->の型パラメータにDatabaseAndHttpExceptionがあるので、DatabaseAndHttpExceptionのコンパニオンオブジェクトが探索対象に入り、無事にimplicitパラメータが見つかる。 このように、implicitパラメータによって変換可能な例外同士の有向グラフを作ることで、サブタイプ関係を使わず安全に別の型へ変換して取り扱うことができる。

既存の例外階層との互換性

今の時点で、サブタイプ関係による例外の階層はこのようになっている。

RootException
|
+---- DatabaseException
|
+---- HttpException
|
+---- FileException
|     |
|     +---- ReadException
|     |
|     +---- WriteException
|
+---- DatabaseAndHttpException

この階層を今から:->によって全部定義する必要があるとしたら、それは大変である。ここからはサブタイプ関係を用いて構築した例外の階層構造と、今回導入した階層構造の互換性を見ていく。

自分自身との互換性

ところで、現状のプログラムはサブタイプ関係を全く無視しているので、例えば次のようなfor式がエラーになってしまう。

val e4 = for {
  a <- e1
} yield ()
Error:(20, 9) could not find implicit value for parameter L2: utils.:->[utils.DatabaseException,L2]
      a <- e1
        ^

なぜこのようなエラーが発生するかというと、map行うためには例え何か適当な型LによるEither[L, ?]からEither[L, ?]へのmapであってもL :-> Lとなるimplicitパラメータが必要であり、それがないのでエラーになってしまう。このような適当な型LからLへキャストするのは、Lがどのような型であったとしても次のように書ける3

implicit def self[A]= new :->[A, A] {
  def cast(a: A): A = a
}

さて、このimplicitパラメータを置くのに適した場所はどこかというと、それはimplicitパラメータの探索順位が低い:->のコンパニオンオブジェクトの中だろう。

object :-> {
  implicit def self[A] = new :->[A, A] {
    def cast(a: A): A = a
  }
}

このようにすることでコンパイルを通すことができる。

サブタイプ関係による階層との互換性

FileExceptionは次のようにサブタイプ関係を利用した階層を持つ例外である。

trait FileException extends RootException

case class ReadException(m: String) extends FileException

case class WriteException(m: String) extends FileException
RootException
|
+---- FileException
      |
      +---- ReadException
      |
      +---- WriteException

これらを持つEitherforで次のようにまとめることはできるだろうか。

val e5 = left(ReadException("file read error"))
val e6 = left(WriteException("file read error"))

val e7 = for {
  a <- e5
  b <- e6
} yield ()

次のようなエラーが発生してしまう。

Error:(29, 9) could not find implicit value for parameter L2: utils.:->[utils.ReadException,utils.WriteException]
      a <- e5
        ^

これは先ほど、DatabaseAndHttpExceptionで出現したエラーと同じなので、asメソッドで次のようにすれば解決できそうに思える。

val e7 = for {
  a <- e5
  b <- e6.as[FileException]
} yield ()

しかし、これも次のようなコンパイルエラーとなる。

Error:(30, 17) could not find implicit value for parameter L2: utils.:->[utils.WriteException,utils.FileException]
      b <- e6.as[FileException]
                ^

どうやら、WriteException :-> FileExceptionというimplicitパラメータを発見できなかったようだ。ただ、このようにサブタイプ関係がある場合はこのWriteExceptionFileExceptionに限らず次のようなimplicitパラメータを定義することができる。

implicit def superclass[A, B >: A] = new :->[A, B] {
  def cast(a: A): B = a
}

この定義をよく見ると、型パラメータBAであった時は、先ほど定義したimplicitパラメータselfと同じ振る舞いをするということが明らかである4。よって:->のコンパニオンオブジェクトにはこのsuperclassだけを設置する。

object :-> {
  implicit def superclass[A, B >: A] = new :->[A, B] {
    def cast(a: A): B = a
  }
}

このようにすれば、次のコードを実行することができる。

val e7 = for {
  a <- e5
  b <- e6.as[FileException]
} yield ()

まとめ

安全なキャストを提供する型クラスを用いるようにEithermapflatMapを改造することで、例外の階層構造をアドホックに構築することができるようになる。 この方法は下記の論文を読みつつ考えたものなので、既に誰かがよりよい方法を発表している可能性もある。もし情報をご存知の方はQiitaのコメントなどで連絡して欲しい。

参考文献


  1. サブタイピングと継承の違いについては割愛するが、一般的に必ずしも一致しないので、ここではサブタイピングという型の関係にのみ注目する。

  2. ここで定義されている謎のメソッドasについては後述する。

  3. このメソッドselfでは型パラメータをLではなくAとしているが、意味的には変わりない。

  4. この時、任意の型AA >: Aを満す。

コメント