Ordersky先生が説明してくれたdottyに入るかもしれないchecked exceptionのモチベ
このドキュメントでordersky先生がscala3に入りそうなchecked exceptionのモチベを説明してくれていて、面白かったので紹介する。
該当のプルリク。
例外か否か
例外使うと正常系と異常系のパス別れてハッピーだよねみたいな話から始まり、ただjavaとかscalaみたいな型あり言語だと例外の存在が型システムから漏れるからツラミというよくある話。
そしてjavaの検査例外のちょっと悪口。
メインで挙げてる検査例外の良くないところはポリモーフィックじゃないよねみたいな話。
仮にjavaモデルで検査例外を実装して、例えばListに生えてる
def map[B](f: A => B): List[B]
みたいなコードがあったときに下のようなことは許されない。
xs.map(x => if x < limit then x * x else throw LimitExceeded())
高階関数にわたす関数で検査例外を投げることはできない。
じゃあどうやるかというと下みたいな感じになって流石にやべえよ。。。的なことを言っている。
try
xs.map(x => if x < limit then x * x else throw Wrapper(LimitExceeded()))
catch case Wrapper(ex) => throw ex
モナドとエラー
エラー処理を型システムに反映するためScalaではEither
みたいなモナド使うけどそれもぶっちゃけ色々大変だよねみたいな話。
複数のモナドを一緒に使うのは結構たいへんでそのためにモナドトランスフォーマーとかfree、tagless-finalとかあるけどめちゃ複雑で〜(わかる)と。
だから最近はZIOみたいなスーパーモナドが流行ってるとのこと。
ここの部分にordersky先生のモチベーションがあるらしく、どういうことかというとライブラリたちが頑張ってエラーを型に落とし込んでいるけど、特定のライブラリ使用者だけがエラーの静的チェック恩恵を受けるだけじゃだめだ、言語が頑張らねば、ユースケースが合わずライブラリ使えない人もいるし、とのことらしい。
dottyでのchecked exceptionモデル
まずエラーに関する発想の転換をする必要がある。
モナドの考え方は「エラーが発生しうるということをEffectとして表そう」だったがこれを「エラーを起こすabilityが必要だ」と捉えなおそうということらしい。
これだけ読むと???な感じだがつまりどういうことかというと、モナドの場合は戻り値がEffectとなってしまいEffectの引き回しが発生するけど、例外を起こすコードがabilityを引数として要求すればシンプルになるじゃんという発想らしい。
仮にEffectとしてエラーを表現すると以下のようなコードが頻出して現実的じゃない。
def map[B, E](f: A => B canThrow E): List[B] canThrow E
ここでCanThrow
という型を導出
する。
erased class CanThrow[-E <: Exception]
これをエラーを起こすためのabilityとして定義して、検査例外が起こる箇所でコンパイラがCanThrowのgivenを要求するようにコードを改変する。
更にこいつは結局型演算でしか使われないのだが、dottyのerasedキーワードを使用することでコンパイル時にしか存在しないオーバーヘッドのない型を生み出している。
また以下のようなtype
infix type canThrow[R, +E <: Exception] = CanThrow[E] ?=> R
を定義しておけばdottyのコンテキスト関数の機能を使用して以下のコードは等価になる。
def m(x: T)(using CanThrow[E]): U
def m(x: T): U canThrow E
これでこの関数はエラーを起こす能力を手に入れた。
そしてgivenの解決が最後に必要であるがそこはコンパイラに助けてもらう。
今までの様に
try
body
catch
case ex1: Ex1 => handler1
...
case exN: ExN => handlerN
と書くとコンパイラが勝手に以下のようなコードを生成する。
try
erased given CanThrow[Ex1] = ???
...
erased given CanThrow[ExN] = ???
body
catch ...
givenの実態が???でオッケーなのは結局CanThrowたちはコンパイルタイムの型演算にしか使用されないためである。
例
まずはimportで検査例外機能をenableに。
import language.experimental.saferExceptions
このもとで仮に以下のようなコードを書くとコンパイルエラー。
val limit = 10e9
class LimitExceeded extends Exception
def f(x: Double): Double =
if x < limit then x * x else throw LimitExceeded()
9 | if x < limit then x * x else throw LimitExceeded()
| ^^^^^^^^^^^^^^^^^^^^^
|The ability to throw exception LimitExceeded is missing.
|The ability can be provided by one of the following:
| - A using clause `(using CanThrow[LimitExceeded])`
| - A `canThrow` clause in a result type such as `X canThrow LimitExceeded`
| - an enclosing `try` that catches LimitExceeded
|
|The following import might fix the problem:
|
| import unsafeExceptions.canThrowAny
これはthrow句が暗黙的にCanThrow[LimitExceeded]のgivenを要求するように変換されるのだが関数のシグネチャにそのabilityが現れていないからである。
以下のようにすればコンパイルが通る。
def f(x: Double): Double canThrow LimitExceeded =
if x < limit then x * x else throw LimitExceeded()
最後にtryでcatchすればコードは実行できる。
これでmapのシグネチャを変更せずに検査例外を実装できた。
@main def test(xs: Double*) =
try println(xs.map(f).sum)
catch case ex: LimitExceeded => println("too large")
コンパイラが生成する実質的なコード。
// compiler-generated code
@main def test(xs: Double*) =
try
erased given ctl: CanThrow[LimitExceeded] = ???
println(xs.map(x => f(x)(using ctl)).sum)
catch case ex: LimitExceeded => println("too large")
その他
- Gradual Typingでの段階的移行
- このモデルでもうまく行かないパターン
- 例外はそれ自身よく出来てるしうまく型システムと調和させよう
所感
うまい具合に課題感とモチベが書かれていて面白い。
特にdottyの機能をうまく使った例として非常にいい例だと思う。
ただTwitterとかを見ると賛否両論感あるので果たして入るのかは謎。
dottyになって更にDSL作成言語っぷりが増している。
最近では広く実用されている型演算の開拓はTypeScriptが頑張っているがScalaも次の時代の言語仕様のパイオニアとしての側面が強いため、dottyの取り組みがどれだけ未来に影響を与えるかは非常に楽しみだ