はじめに
前回の記事、階層構造を容易に拡張できる例外では:->
という型クラスを用いて例外(エラー値)1の階層構造を作るという内容を紹介した。しかし、A :-> B
とB :-> C
という二つのインスタンスがあったとしても、A :-> C
が作られないので不便であった。そこで今回はA :-> B
とB :-> C
という二つのインスタンスからA :-> C
を作るためのインスタンスtransitive
を作成する。
この記事はまず前回の記事で紹介した方法について述べ、その後前回の手法の課題を説明する。そして、今回作成したtransitive
について説明する。
なお、この記事で紹介するコードの完全なものは、次のリポジトリにある。
この記事を読んで分かりにくい部分や改善案を思いついた場合は、コメントなどで気軽に教えて欲しい。
階層構造を容易に拡張できる例外の課題
階層構造を容易に拡張できる例外
まず、前回の記事の内容を説明する。一般的な継承を用いた例外を次のように作ったとする。
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
ここに、データベースの例外とHTTPの例外をまとめた例外DatabaseAndHttpException
を次のように作るものとする。
case class DatabaseAndHttpException(m: String) extends RootException
すると、継承による階層構造は次のようになる。
RootException
|
+---- DatabaseException
|
+---- HttpException
|
+---- FileException
| |
| +---- ReadException
| |
| +---- WriteException
|
+---- DatabaseAndHttpException
しかし、このままではEither
などで次のように書いた場合に望んだ結果にならない。
val e1 = for {
a <- Left(DatabaseException("db errer"))
b <- Left(HttpException("http errer"))
} yield ???
この時、e
の結果はDatabaseException
とHttpException
の共通の親であるRootException
となってしまい、この時、型の上ではFileException
と区別がつかなくなる。
そこで、次のような型クラス:->
を用いて継承を用いない例外の階層を構築する2。
trait :->[-A, +B] {
def apply(a: A): B
}
この型クラスは関数のような、A
からB
への安全な変換を定義するものであり、:->
のインスタンスを次のように作成する。
object DatabaseAndHttpException {
implicit val databaseException = new (DatabaseException :-> DatabaseAndHttpException) {
def apply(a: DatabaseException): DatabaseAndHttpException =
DatabaseAndHttpException(s"database: ${a.m}")
}
implicit val httpException = new (HttpException :-> DatabaseAndHttpException) {
def apply(a: HttpException): DatabaseAndHttpException =
DatabaseAndHttpException(s"http: ${a.m}")
}
}
そして、自明なインスタンスを:->
のコンパニオンオブジェクトに定義しておく。
implicit def self[A]: A :-> A = new (A :-> A) {
def apply(a: A): A = a
}
implicit def superclass[A, B >: A]: A :-> B = new (A :-> B) {
def apply(a: A): B = a
}
この型クラスのインスタンスがある場合はユーザーが定義した“安全な変換”ができるので、このインスタンスを使うようにEither
のmap
とflatMap
を書き換える。
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(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(e))
case Right(v) => f(v)
}
def as[L2 <: RootException](implicit L2: L :-> L2): Either[L2, R] = ee match {
case Left(e) => Left(L2(e))
case Right(v) => Right(v)
}
}
}
このようにすることで、次のように書くことができる。
val e2 = for {
a <- Left(DatabaseException("db errer"))
b <- Left(HttpException("http errer")).as[DatabaseAndHttpException]
} yield ???
さきほどとは違い、e2
の結果がDatabaseAndHttpException
となり、型の上でもFileException
など他の例外と区別することができる。
課題
この方法では冒頭に述べたように、A :-> B
とB :-> C
というインスタンスがあったとしても、A :-> C
が作られない。これに対しては次のようなインスタンスを定義すればよいと思うかもしれない。
implicit def transitive[A, B, C](implicit F: A :-> B, G: B :-> C): A :-> C = new (A :-> C) {
def apply(a: A): C = G(F(a))
}
しかし、これでは次のようにimplicitパラメータの探索に失敗してしまう。
diverging implicit expansion for type utils.:->[exceptions.HttpException,B]
これは、transitive
のimplicitパラメータF
を検索するときにtransitive
が参照され、という無限ループが発生しているものと思われる。
transitive
の定義
次のような方法でtransitive
を定義する。
- 既存の型クラス
:->
の名前を:~>
へ変更する - $A \rightarrow B, B \rightarrow C \Rightarrow A \rightarrow C$のような推移を含まないワンステップの変換を表す専用の型クラス
:->
を用意する -
:->
を用いてtransitive
を定義する
型クラス:->
の名前を:~>
へ変更
まず、implicitパラメータの発散を防止するために、既存の型クラスの名前を変更し次のようにする3。
trait :~>[-A, +B] { self =>
def apply(a: A): B
def compose[C](that: B :~> C): A :~> C = new :~>[A, C] {
def apply(a: A): C = that(self(a))
}
}
型クラスの名前を変えたので、Either
のmap
とflatMap
も次のように変更が必要である4。
object Implicit {
implicit class ExceptionEither[L1 <: Throwable, R1](val ee: Either[L1, R1]) {
def map[L2 <: Throwable, R2](f: R1 => R2)(implicit F: L1 :~> L2): Either[L2, R2] = ee match {
case Left(e) => Left(F(e))
case Right(v) => Right(f(v))
}
def flatMap[L2 <: Throwable, R2](f: R1 => Either[L2, R2])(implicit F: L1 :~> L2): Either[L2, R2] = ee match {
case Left(e) => Left(F(e))
case Right(v) => f(v)
}
def as[L2 <: Throwable](implicit F: L1 :~> L2): Either[L2, R1] = ee match {
case Left(e) => Left(F(e))
case Right(v) => Right(v)
}
}
}
そして、自明なインスタンスを定義する。
object :~> {
implicit def self[A]: A :~> A = new (A :~> A) {
def apply(a: A): A = a
}
implicit def superclass[A, B >: A]: A :~> B = new (A :~> B) {
def apply(a: A): B = a
}
}
ワンステップの変換を表わす型クラス:->
の定義
そして、ワンステップの変換しか含まない型クラス:->
を定義する。
trait :->[-A, +B] {
def apply(a: A): B
}
そして、例外間の関係(ツリー)はワンステップなので、型クラス:->
のインスタンスとして定義する。
object DatabaseAndHttpException {
implicit val databaseException = new (DatabaseException :-> DatabaseAndHttpException) {
def apply(a: DatabaseException): DatabaseAndHttpException =
DatabaseAndHttpException(s"database: ${a.m}")
}
implicit val httpException = new (HttpException :-> DatabaseAndHttpException) {
def apply(a: HttpException): DatabaseAndHttpException =
DatabaseAndHttpException(s"http: ${a.m}")
}
}
そして、次のように用いる。
def left[A](e: A) = Left[A, Unit](e)
def e1 = left(DatabaseException("db error"))
def e2 = left(HttpException("http error")
{
import DatabaseAndHttpException._
val e6 = for {
a <- e1
b <- e2.as[DatabaseAndHttpException]
} yield ()
}
前回とは違って、import
でインスタンスを明示的に呼び出す必要がある。
型クラス:->
を用いたtransitive
の定義
次のようにする。
object :~> {
implicit def self[A]: A :~> A = new (A :~> A) {
def apply(a: A): A = a
}
implicit def superclass[A, B >: A]: A :~> B = new (A :~> B) {
def apply(a: A): B = a
}
implicit def transitive[A, B, C](implicit F: A :-> B, G: B :~> C): A :~> C = new (A :~> C) {
def apply(a: A): C = G(F(a))
}
}
このように、ワンステップの変換を表わすA :-> B
と、ワンステップ以上の変換を表すB :~> C
を用いてA :~> C
を作成している。
遷移の例
定義したtransitive
を用いて、次のような例を考える。先ほど定義したDatabaseAndHttpException
とReadException
をまとめたDatabaseAndHttpAndFileReadException
を次のように作成する。
case class DatabaseAndHttpAndFileReadException(m: String) extends RootException
この時点で、継承による階層構造は次のようになっている。
RootException
|
+---- DatabaseException
|
+---- HttpException
|
+---- FileException
| |
| +---- ReadException
| |
| +---- WriteException
|
+---- DatabaseAndHttpException
|
+---- DatabaseAndHttpAndFileReadException
そして、階層構造を表すインスタンスを作成する。
object DatabaseAndHttpAndFileReadException {
implicit val databaseAndHttpException = new (DatabaseAndHttpException :-> DatabaseAndHttpAndFileReadException) {
def apply(a: DatabaseAndHttpException): DatabaseAndHttpAndFileReadException =
DatabaseAndHttpAndFileReadException(s"database and http: ${a.m}")
}
implicit val fileReadException = new (ReadException :-> DatabaseAndHttpAndFileReadException) {
def apply(a: ReadException): DatabaseAndHttpAndFileReadException =
DatabaseAndHttpAndFileReadException(s"file read: ${a.m}")
}
}
そして、次のようなfor
式を実行する。
def left[A](e: A) = Left[A, Unit](e)
def e1 = left(DatabaseException("db error"))
def e2 = left(HttpException("http error")
def e3 = left(ReadException("file read error"))
{
import DatabaseAndHttpException._
import DatabaseAndHttpAndFileReadException._
val e9 = for {
a <- e1
b <- e2
c <- e3.as[DatabaseAndHttpAndFileReadException]
} yield ()
}
さらに、DatabaseAndHttpAndFileReadException
にWriteException
をも加えた例外DatabaseAndHttpAndFileException
を作成する。
case class DatabaseAndHttpAndFileException(m: String) extends RootException
まずは同様にインスタンスを定義する。
object DatabaseAndHttpAndFileException {
implicit val databaseAndHttpAndFileReadExcepion = new (DatabaseAndHttpAndFileReadException :-> DatabaseAndHttpAndFileException) {
def apply(a: DatabaseAndHttpAndFileReadException): DatabaseAndHttpAndFileException =
DatabaseAndHttpAndFileException(s"database and http and file read: ${a.m}")
}
implicit val fileWriteException = new (WriteException :-> DatabaseAndHttpAndFileException) {
def apply(a: WriteException): DatabaseAndHttpAndFileException =
DatabaseAndHttpAndFileException(s"file write: ${a.m}")
}
}
そして、次のように実行する。
def left[A](e: A) = Left[A, Unit](e)
def e1 = left(DatabaseException("db error"))
def e2 = left(HttpException("http error")
def e3 = left(ReadException("file read error"))
def e4 = left(WriteException("file write error"))
{
import DatabaseAndHttpException._
import DatabaseAndHttpAndFileReadException._
import DatabaseAndHttpAndFileException._
val e10 = for {
a <- e1
b <- e2
c <- e3
d <- e4.as[DatabaseAndHttpAndFileException]
} yield ()
}
このように、transitive
はどれだけでもインスタンスを繋げることができる。
まとめ
このように、前回の記事では定義されていなかったtransitive
を定義して、ExtensibleExceptionをより使いやすくすることができた。
今回の例では、型クラス:->
のインスタンスを例外を表すクラスのコンパニオンオブジェクトに定義しているため、例外の階層を拡張するたびにimport
が増えているように見えるが、これはどこかにimplicitパラメータの置き場オブジェクトを定義しておいて、全てのimplicitパラメータをひとつの場所に置けば解決できると考えている。
また、このExtensible Exceptionは型クラスによって構成されているため、型クラスを持つ他の言語(たとえばRustなど)への応用もできると考えている。