はじめに
この記事はScala Advent Calendar 2018の12月6日の記事です。
戻り値の型がTryとなるメソッドや関数をいくつか呼んで処理を記述する場合に、for式を使うことが多いと思います。
その場合のfor式は、for式自体も戻り値の型がTryとなるので、for式の中で起きたnon-fatalな例外は例外なく捕捉され、SuccessかFailureでパターンマッチできると思いがちですが、実際はそうではないというお話です。
例外が捕捉できるケース
まずは例外が捕捉できるケースについてみてみます。
例外が捕捉できるケース1
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v / 0)
lazy val b: Int => Try[Int] = v => Try(v)
lazy val c: Int => Try[Int] = v => Try(v)
for {
x <- a(1)
y <- b(2)
z <- c(3)
} yield (x + y + z)
a(Int)
を評価するとFailureが返るので、値はFailure(java.lang.ArithmeticException: / by zero): scala.util.Try
となります。
例外が捕捉できるケース2
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v)
lazy val b: Int => Try[Int] = v => Try(v / 0)
lazy val c: Int => Try[Int] = v => Try(v)
for {
x <- a(1)
y <- b(2)
z <- c(3)
} yield (x + y + z)
1と同様、b(Int)
を評価するとFailureが返るので、値はFailure(java.lang.ArithmeticException: / by zero): scala.util.Try
となります。
例外が捕捉できるケース3
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v)
lazy val b: Int => Int = v => v / 0
lazy val c: Int => Try[Int] = v => Try(v)
for {
x <- a(1)
y = b(2)
z <- c(3)
} yield (x + y + z)
b(Int)
の戻り値の型はTryではありませんが、for式の途中の例外はTry内の例外として捕捉され、値はFailure(java.lang.ArithmeticException: / by zero): scala.util.Try
となります。
例外が捕捉できるケース4
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v)
lazy val b: Int => Try[Int] = v => Try(v)
lazy val c: Int => Try[Int] = v => Try(v)
for {
x <- a(1)
y <- b(2 / 0)
z <- c(3)
} yield (x + y + z)
b(Int)
を評価する際の引数の評価の時点でExceptionが発生しますが、for式の途中の例外はTry内の例外として捕捉され、値はFailure(java.lang.ArithmeticException: / by zero): scala.util.Try
となります。
例外が捕捉できるケース5
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v)
lazy val b: Int => Try[Int] = v => { v / 0; Try(v) }
lazy val c: Int => Try[Int] = v => Try(v)
for {
x <- a(1)
y <- b(2)
z <- c(3)
} yield (x + y + z)
b(Int)
の戻り値の型はTryですが、実際には関数内でExceptionが発生します。ただ、for式の途中の例外はTry内の例外として捕捉され、やはり値はFailure(java.lang.ArithmeticException: / by zero): scala.util.Try
となります。
例外が捕捉できないケース
次に例外が捕捉できないケースです。
例外が捕捉できないケース1
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v)
lazy val b: Int => Try[Int] = v => Try(v)
lazy val c: Int => Try[Int] = v => Try(v)
for {
y <- b(2 / 0)
x <- a(1)
z <- c(3)
} yield (x + y + z)
b(Int)
を評価する際の引数の評価の時点でExceptionが発生します。for式の途中の例外であればTry内の例外として捕捉されるのですが、for式の最初の戻り値がTryで返される前に起きた例外なので、java.lang.ArithmeticException: / by zero
がthrowされます。
例外が捕捉できないケース2
import scala.util._
lazy val a: Int => Try[Int] = v => Try(v)
lazy val b: Int => Try[Int] = v => { v / 0; Try(v) }
lazy val c: Int => Try[Int] = v => Try(v)
for {
y <- b(2)
x <- a(1)
z <- c(3)
} yield (x + y + z)
b(Int)
の戻り値の型はTryですが、実際は関数内でExceptionが発生します。for式の途中の例外であればTry内の例外として捕捉されるのですが、for式の最初の戻り値がTryで返される前に起きた例外なので、java.lang.ArithmeticException: / by zero
がthrowされます。
まとめ
「例外が捕捉できるケース4・5」と「例外が捕捉できないケース1・2」の違いは例外が起きる式の位置にあり、for式の中の途中の式で例外が発生すると捕捉でき、最初の式で例外が発生すると捕捉できないということになります。
for式はflatMapやmapで記述された式の糖衣構文でしかないので、要はTryのflatMapやmapの実装が、non-fatalな例外を捕捉しFailureとして返すためこのような挙動となります。
for式の中の最初の式はflatMapやmapの中の式ではなくジェネレーターでしかないので、Tryを返すfor式の中でnon-fatalな例外を確実に捕捉するためには、SuccessだろうがFailureだろうが最初の式が例外をthrowせずにTry型を返すことが必要条件で、それさえ守れていれば、for式の途中の式が捕捉されない例外をthrowしたとしても、Failureに変換してくれることになります。
Try型を返すメソッドや関数がnon-fatalな例外をthrowしないかどうかについては、使用するメソッドや関数の実装に依存することになり、また、引数の評価が先行評価である場合には、その評価時にnon-fatalな例外を起こす可能性もあるので、今回の例のように、呼び出しの順序は特に関係がなく3つのリソースにアクセスしてyieldで処理をするような記述を行う場合、記述の順序により意図しない例外が発生することになるため、注意が必要です。
自分で書いたTry型の戻り値を返す関数やメソッドを呼び出す場合は、関数やメソッドの実装で全体をTryで囲むことにより確実にTry型を返すことができ、呼び出す際に引数で例外を発生させないよう注意することで上記は回避できますが、他の人が作成したライブラリなどでTry型の戻り値を返す関数やメソッドを呼び出す場合で、確実にnon-fatalな例外を捕捉したいときには、下記のようにfor式内の最初の式が確実にTry型を返すようにしてあげればいいことになります。
import scala.util._
lazy val a: Int => Try[Int] = v => { v / 0; Try(v) }
lazy val b: Int => Try[Int] = v => Try(v)
lazy val c: Int => Try[Int] = v => Try(v)
for {
x <- Try(a(1)).flatten
y <- b(2)
z <- c(3)
} yield (x + y + z)
上記の記述であればfor式の先頭は確実にTryが返ってきますが、これでは意図が伝わり辛く、呼び出し順の変更などで機能しなくなるかもしれないので、実際は下記のような感じでしょうか。
import scala.util._
lazy val a: Int => Try[Int] = v => { v / 0; Try(v) }
lazy val b: Int => Try[Int] = v => Try(v)
lazy val c: Int => Try[Int] = v => Try(v)
Try(for {
x <- a(1)
y <- b(2)
z <- c(3)
} yield (x + y + z)).flatten
ただ、これでも意図が伝わり辛く、後から冗長だと勘違いされて外側のTry().flattenが消されてしまうかもしれませんので、それよりは下記のように、より明確に意思を示す方がいいのかも。
import scala.util._
lazy val a: Int => Try[Int] = v => { v / 0; Try(v) }
lazy val b: Int => Try[Int] = v => Try(v)
lazy val c: Int => Try[Int] = v => Try(v)
for {
_ <- Try(())
x <- a(1)
y <- b(2)
z <- c(3)
} yield (x + y + z)
こちらの方が何かしらの明確な意図を感じることができますが、それでも最初の_ <- Try(())
って何なの?って言われそうですね。
いずれにしても、コードでは表現しにくい振る舞いだと思うので、チームで開発している場合は、別の言葉にしてでもきちんと意図を伝えておかないといけないかも。
そもそも、Try型を返すメソッドや関数が普通にnon-fatalな例外をthrowできる中で、TryのflatMapやmapがnon-fatalな例外をFailureに変換するという実装自体が中途半端な気もするのですが、どうなのでしょうか。
おしまい