Scalaのfor式で失敗ハンドリング

更新: 2023-03-24公開: 2023-03-22
blog image

シェアダインではサーバーサイドの開発言語としてScalaを使っており、安全かつ高速な開発を実現しています。

今回はその中でも特に強力な機能であるfor式による失敗ハンドリングの話をします。

処理の失敗と例外

Web開発に限らずプログラムの処理に失敗は付き物です。

  • idを指定してリクエストが来たけど、そのidがDBにない
  • jsonの投稿でフォーマットが間違ってる
  • ファイルを保存しようとしたら権限が足りなかった
  • メモリ確保しようとしたらメモリが足りない

等々、様々なレベルの失敗があります。

Scalaにおいては、処理の失敗を表現する方法として、返り値型によるものと例外の2つに大別できます。

例外は伝統的に使われてきた失敗の表現方法ですが、コードの順序を無視して呼び出し元にワープしていってしまう挙動は、時にひどく困難なバグを生み出し、プログラマーの手に負えなくなることがあります。本当にどうしようもないエラーでしか使わないようにしましょう。

先程の例でいえばメモリ不足の場合や、設定ファイルに記述が足りなくてそもそも起動できない場合など、そも起きたら処理の続行が困難な失敗は例外が適切な処理です。

Option[T]

Scalaにおいて、型で失敗を表記する方法としては、メジャーな物としてOption/Either/Try/Futureがあります。

Option[T]は単に失敗したときにNoneを、成功したときにSome(value)を返す方法です。失敗パターンが1つしかないので、想定される失敗が明確に1つしかない場合にはこれで十分で、かなり取り回しが楽な方法です。

今までの言語では、このような場合にnullが使われることが多かったのですが、nullは値を参照してはじめて例外が返るため、さきほどの「例外は避けよう」の問題や、どこの処理が失敗してnullになっていたのか、が分かりづらい問題があるため、Scalaでnullはほぼ使われません。

// 0除算をしたらNoneが返る割り算関数
def div(divisor: Int, dividend: Int): Int = if (dividend == 0) None else Some(divisor / dividend)

Optionはほぼ「長さが0か1のSeq」として扱うことができ、Seqで使うmap/flatMap/filterなどのメソッドがそのまま使えます。

注意事項として、Option#getはNoneに対して実行すると例外が送出されます。Someであることが型以外の方法で保証されている場合を除いて使うべきではありません。

Either[A, B]

例外にはどこでどのような失敗したのか、のような情報が記述されています。そのような情報が必要な場合はOptionでは不足であり、Either[A, B]を使います。成功したときにRight(value)、失敗したときはLeft(errorValue)を返すようにします。

// 失敗する可能性が2箇所ある関数
def fab[A](): Either[Fail, A] = {
  val resultA = fa
  val resultB = fb
  if (resultA.isFail) Left("fail A")
  else if(resultB.isFail) Left("fail B")
  else Right(resultA.join(resultB))
}

Option/Tryで十分な場合にEitherを使うのは、プロジェクトの方針にも依りますが、避けるべきです。errorValueが返ることによって不必要に複雑になってしまうことがあります。

Try[T]

Scalaで例外は避けるべきなのですが、様々な理由により例外が送出される処理を実行する必要があったり、そこから回復する処理を記述することはあります。そのような場合はTryが便利です。成功した場合はSuccess(value)、失敗した場合はFailure(exception)が返ります。

// Java製の例外が飛ぶメソッドを実行し、例外時にはTryで包む関数
def exceptionWrapper[A](): Try[A] = Try { javaExceptionFunction }

失敗の型がExceptionしか選べないので若干不便なのですが、Option以外の失敗をExceptionで統一してTryに纏めるのも割とアリだと思います。

Future[A, B]

他言語だとPromiseとか呼ばれたりするものです。Eitherに「未処理の状態」を追加したような型になります。注意事項もEitherと同じで、また「Eitherで十分なのにFutureを使うのは不必要に複雑になるので避けるべき」です。Scalaは全ての処理が非同期で返ってくる言語ではないですし、同期的な処理を後から全体をThreadなどで覆って非同期っぽく処理することは可能なので、JavaScript等と違い非同期でないと困ることはさほど多くはないと思います。ただしライブラリによっては非同期前提に書かれているものもあります。

失敗をハンドリングする

失敗する可能性のある関数を作る方法を学んだので、実際に呼び出して適切な処理をしましょう。例えばOptionであれば次のようにします。

// デフォルト値で置き換える
option.getOrElse(defaultValue)
// 別々の処理を実行する
option.fold(execFailure)(value -> execSuccess)
// または
option match {
  case Some(a) => ...
  case None => ...
}
// 成功した場合だけなにか処理する
option.map(value -> execSuccess) // => Option[A]
// 失敗したときのみ処理を加える(それも失敗する可能性がある)
option.orElse(execOptionA).orElse(execOptionB) // => Option[A]
...

そして世の中「失敗する可能性のある処理」というのは非常に多く、結果として呼び出し元の関数は失敗処理で見通しが大変悪くなってしまいます。3つだけでもこんなですよね。

optionA match {
  case Some(a) =>
    optionB match {
      case Some(b) =>
        optionC match {
          case Some(c) =>
            f(a, b, c)
          case None => failC
        }
      case None => failB
    }
  case None => failA
}

for式で簡単ハンドリング

そこでfor式の出番です。for式で書くとこうなります。前半部は<-の右辺にOption/Either/Try/Future、左辺に成功したとき中身を置く変数を置きます。後半部に処理が書けます。

val optionD = for {
  a <- optionA // 右辺にOption/Either/Try/Futureを置くと、左辺のシンボルに中身が返ってきます
  b <- optionB
  c <- optionC
} yield { // 返り値が不要な場合はyieldを外します
  f(a, b, c)
}

上記だとoptionA/optionB/optionCいずれかでNoneが出た場合、そこで処理を打ち切ってoptionDにはNoneが返ります。

簡単ですね!と言いたいところですが、failA/failB/failCが消えてます。デグレしてしまってはいくら簡潔といっても駄目ですね。ところで、右辺にはEitherなどが置けると言いました。なので実はこう書くことができます。

val d = for {
  a <- optionA.toRight(failA)
  b <- optionB.toRight(failB)
  c <- optionC.toRight(failC)
} yield {
  f(a, b, c)
}

Option#toRightはOptionからEitherに変換するメソッドで、Noneのときは引数の値が入る仕組みです。便利ですね。

for式の発展的な使い方

for式の前半部は<-だけでなく、実は色々書けます。まずは=。<-と違って特に変換をしないので、一時的に値を束縛するだけの用途で使います。

for {
  a <- optionA
  b = totemo_fukuzatsu_nanode_ittan_b_ni_oku
  c <- f(b)
}

ifもかけます。ただし詳細は後述しますが、失敗が1パターンしかないOptionでしか使えません(条件を満たさないときに何すればいいのか分からないので!)。

for {
  a <- optionA
  if a != 0
  ...
}

for式の制限

右辺値の型は同じである必要があります! たとえば返り値に1つでもFutureが混じっていた場合、Futureをブロック無しでEither等に変換する方法はないため、強制的に全てFutureで揃える必要があります。

for式の<-は内部的にflatMapによって処理されています。なので、実は右辺にはflatMapができる全ての値を入れることができます。勿論Seqも使えます。

for {
  a <- Seq(1, 2, 3)
  b <- Seq(4, 5)
} yield (a, b)
// => Seq((1, 4), (2, 4), (3, 4), (1, 5), (2, 5), (3, 5))

他の言語でよくみるforも実は書けます。大体foreach/mapで書けよってなるのであまり見ませんが。

for(x <- Seq(1, 2, 3)) println(x)
// 以下と等価
Seq(1, 2, 3).foreach(println)

for式内のifは内部的にはfilterで処理されます。なのでfilterメソッドのある、Optionでのみ処理できる、という書き方をしました。Eitherでifみたいなことをしたい場合は以下のようにEither.condを用いて書けます。成功の場合、値は使用しないので、左辺に_、右辺は()になってます

for {
  a <- eitherA
  _ <- Eithr.cond(a != 0, (), condFail)
  ...
}

なお、理由は不明ですが、ifを最初に書くことはできません。できたらこれはこれで便利なんですけどね。

search-icon
sharedine
spotchef
recruit
arrow-to-top

株式会社シェアダインのTechブログ

©2022 SHARE DINE TECH BLOG