続編を書きました
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
このような状態で、DatabaseException
とHttpException
が両方発生するかもしれない処理をEither
を使って次のように実行したいとする。
val result = for {
x <- databaseService(???) // Either[DatabaseException, A]
y <- httpService(???) // Either[HttpException, A]
} yield ()
databaseService
はEither[DatabaseException, A]
を返す関数であり、一方httpService
はEither[HttpException, A]
という型を持つ値を返す関数である。しかし、これらの型をfor
式で合成した結果のresult
はどういう型になるだろうか。
Either
は共変なので、階層のより上位にある型へとキャストしていくから、この場合result
の型はEither[RootException, Unit]
となる。しかし、RootExcepiton
になってしまっては、もはやFileExcepion
と区別することができない。
そこで、新たに次のような例外を表わすケースクラスを作成する。
case class DatabaseAndHttpException(m: String) extends RootException
さて、ではこのDatabaseAndHttpException
を例外の階層に追加しなければならない。そうなると既存にあったDatabaseException
とHttpException
を変更しなければならず、Expression Problemが発生してしまう。Expression Problemを回避して、つまりは既存のデータ型に変更を加えることなく、DatabaseAndHttpException
を挿入することはできないだろうか。
サブタイピングと型の多様性
次のように、例外の階層構造をextends
を用いて作成するが、これは型のサブタイピングを行っている1。
trait RootException extends Throwable
case class DatabaseException(m: String) extends RootException
RootException
|
+---- DatabaseException
このよう場合、DatabaseException
はRootException
のサブタイプであると言い、RootException
はDatabaseException
のSupertypeであると言う。
そもそも、このような例外(型)の階層構造(サブタイプ関係)をどうして作るのかというと、それはサブタイピングに基づく多様性を表現したいからである。サブタイピングの多様性はプログラム言語論の資料にて次のように説明されている。
型Aが型Bのsubtype(部分型)のとき、型Bの式を書くべきところに、型Aの式を書いても良い。
これを今回の例にあてはめると、DatabaseException
はRootException
のサブタイプであるので、RootException
の式を書くべきところに、DatabaseException
を書いてもよいということになる。また、HttpException
もRootException
のサブタイプであるので、RootException
の式を書くべきところに、HttpException
の式を書いてもよいということになる。
Either[DatabaseException, A]
とEither[HttpException, A]
は左側の型が異なり、通常合成することができないが、サブタイプ関係を使いDatabaseException
とHttpException
を共に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
パラメータ)を探索する。
- 現在のスコープ
- 型クラスに投入された型パラメータのコンパニオンオブジェクト
- 型クラスに投入された型パラメータのスーパークラスのコンパニオンオブジェクト
- 型クラスのコンパニオンオブジェクト
Scalaはまず(1)から順番にimplicit
パラメータを探索し、見つかった時点で探索を打ち切る。
例外の拡張
さて、安全なキャストA :-> B
を用いて例外の階層を定義するとはどういうことだろうか。先程の例を再び振り替えると、今、DatabaseException
とHttpException
の二つを抽象化したような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}")
}
}
さて、次はEither
のmap
とflatMap
を改造する。これには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)
}
}
}
このように、map
とflatMap
の定義を変更して、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
は型としてDatabaseException
やHttpException
と階層関係にないので、Scalaの処理系はDatabaseAndHttpException
へimplicit
パラメータの探索を試みない。そこで、先程map
やflatMap
と共に定義したas
メソッドを使って明示的に安全なキャストを行ってやると上手くいく。
val e3 = for {
a <- e1
b <- e2.as[DatabaseAndHttpException]
} yield ()
このようにすると、e2
はEither[HttpException, Unit]
なのでas
はHtttException :-> DatabaseAndHttpException
のimplicit
パラメータを探索する。型クラス:->
の型パラメータに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
これらを持つEither
をfor
で次のようにまとめることはできるだろうか。
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
パラメータを発見できなかったようだ。ただ、このようにサブタイプ関係がある場合はこのWriteException
やFileException
に限らず次のようなimplicit
パラメータを定義することができる。
implicit def superclass[A, B >: A] = new :->[A, B] {
def cast(a: A): B = a
}
この定義をよく見ると、型パラメータB
がA
であった時は、先ほど定義した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 ()
まとめ
安全なキャストを提供する型クラスを用いるようにEither
のmap
やflatMap
を改造することで、例外の階層構造をアドホックに構築することができるようになる。
この方法は下記の論文を読みつつ考えたものなので、既に誰かがよりよい方法を発表している可能性もある。もし情報をご存知の方はQiitaのコメントなどで連絡して欲しい。