The Missing Method of Extensible Exception: implicit “transitive”

はじめに

前回の記事、階層構造を容易に拡張できる例外では:->という型クラスを用いて例外(エラー値)1の階層構造を作るという内容を紹介した。しかし、A :-> BB :-> Cという二つのインスタンスがあったとしても、A :-> Cが作られないので不便であった。そこで今回はA :-> BB :-> Cという二つのインスタンスからA :-> Cを作るためのインスタンスtransitiveを作成する。 この記事はまず前回の記事で紹介した方法について述べ、その後前回の手法の課題を説明する。そして、今回作成したtransitiveについて説明する。 なお、この記事で紹介するコードの完全なものは、次のリポジトリにある。

https://github.com/y-yu/ExtensibleException

この記事を読んで分かりにくい部分や改善案を思いついた場合は、コメントなどで気軽に教えて欲しい。

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

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

まず、前回の記事の内容を説明する。一般的な継承を用いた例外を次のように作ったとする。

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の結果はDatabaseExceptionHttpExceptionの共通の親である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
}

この型クラスのインスタンスがある場合はユーザーが定義した“安全な変換”ができるので、このインスタンスを使うようにEithermapflatMapを書き換える。

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 :-> BB :-> 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を定義する。

  1. 既存の型クラス:->の名前を:~>へ変更する
  2. $A \rightarrow B, B \rightarrow C \Rightarrow A \rightarrow C$のような推移を含まないワンステップの変換を表す専用の型クラス:->を用意する
  3. :->を用いて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))
  }
}

型クラスの名前を変えたので、EithermapflatMapも次のように変更が必要である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を用いて、次のような例を考える。先ほど定義したDatabaseAndHttpExceptionReadExceptionをまとめた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 ()
}

さらに、DatabaseAndHttpAndFileReadExceptionWriteExceptionをも加えた例外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など)への応用もできると考えている。

参考文献


  1. この記事では、“エラー値”と“例外”という言葉を特に区別せずに使う。

  2. 前回の記事ではcastというメソッド名を使っていたが、プログラムが冗長になるのでapplyというメソッド名に変更した。

  3. 付け加えて、手動でtransitiveのようなインスタンスを生成するためのメソッドcomposeを用意した。

  4. 前回はL1L2の上限としてRootExceptionを用いていたが、より汎用的にするために今回はThrowableを用いている。

コメント