リモート開発メインのソフトウェア開発企業のエンジニアブログです

Scala の Option, Either とエラー処理

Scala ではエラー処理に使えるクラス・仕組みが沢山ありますが、今回は Option, Either を使った方法を色々紹介します。

前提知識として、Scala の Option, Either を触ったことがあり、基本的な Scala の文法を理解しているものとします。

共通で使うコード

本題に入る前に、コード例で共通で使うクラスなどを定義します。

基本的な処理はこの辺を使います。

// ユーザー情報
class User {
  def id: Int
  def getFather: Option[User]
  def getEmailAddress: Option[String]
  // Error型は後の方で定義しています
  def getEmailAddressEither: Either[Error, String]
}

// ユーザー情報を取得する(Option版)
object UserRepository {
  def getUserById(id: Int): Option[User]
}

// ユーザー情報を取得する(Either版)
object UserRepositoryEither {
  def getUserById(id: Int): Either[Error, User]
}

エラー内容などを表す以下のようなクラスも定義します。

object HttpStatus {
  val Ok = 200
  val NotFound = 404
  val BadRequest = 403
  val InternalServerError = 500
}

// 独自エラーの基底クラス
trait Error {
  val internalErrorCode: Int
  val httpResponse: Int
  def writeToLog: Unit
}

// ユーザーが存在しない場合のエラー
object UserNotFoundError extends Error {
  override val internalErrorCode: Int = 1
  override val httpResponse: Int = HttpStatus.NotFound
  override def writeToLog: Unit = ???
}

// メアドが無い人にメールを送ろうとした場合のエラー
object EmailingUserWithNoEmailAddressError extends Error {
  override val internalErrorCode: Int = 2
  override val httpResponse: Int = HttpStatus.InternalServerError
  override def writeToLog: Unit = ???
}

独自の例外も定義しておきます。

class UserNotFoundException(userId: Int) extends Exception(s"ユーザー $userId は存在しません")
class EmailingUserWithNoEmailAddressException(userId: Int)
  extends Exception(s"メールアドレスを登録していないユーザー $userId にメールを送ろうとしました")

Option を使った例

パターンマッチを使う

これはみんな知っていると思いますので簡単に流しますが、以下のような例です。exampleOption1 の戻り値ですが、OkNotFound が共に Int なので、全体として Int 型となります。

import HttpStatus._

def exampleOption1: Int = {
  UserRepository.getUserById(1) match {
    case Some(user) =>
      doSomethingForUser(user)
      Ok
    case None =>
      NotFound
  }
}

パターンマッチを使った方法だと、以下のように複数の Option を扱う場合にネストが深くなってしまうのが難点です。

def exampleOption2: Int = {
  UserRepository.getUserById(1) match {
    case Some(user) =>
      user.getFather match {
        case Some(father) =>
          doSomethingForUser(father)
          Ok
        case None =>
          NotFound
      }
    case None =>
      NotFound
  }
}

次項以降で、もう少し綺麗に書く方法について考えていきます。

getOrElse を使う

Option には getOrElse というメソッドがありますが、それを使うと少しネストを浅く出来ます。少しはマシですが、処理がもう少し増えてくると、やはり綺麗じゃ無いなと思ってしまいます。

def exampleOption3: Int = {
  UserRepository.getUserById(1).map { user =>
    user.getFather.map { father =>
      doSomethingForUser(father)
      Ok
    }.getOrElse(NotFound)
  }.getOrElse(NotFound)
}

ちなみに x.getOrElse(y) という形の場合、xNone だった場合のデフォルト値を y として渡す事が多いと思います。例えば以下のようなパターンです。

val user: User = UserRepository.getUserById(1)
  .getOrElse(new GuestUser)
doSomethingForUser(user)

ただ getOrElse の中で例外を投げるというのも、よく使うパターンです。以下のような例です。この場合も、user の型は User となります。今回は初心者向けの記事なので、なぜそうなるかの理由は省略します。

val user: User = UserRepository.getUserById(1)
  .getOrElse(throw new UserNotFoundException(1))
doSomethingForUser(user)

for を使った方法

もう少し綺麗に書きたい場合、for が使えます。

Scala の for は、実態は withFilter, map, flatMap を組み合わせたものですが、これもここでは詳しく説明しません。

for を使った例としては以下の通りです。

ID=1の user がいて、かつ、その user の父親がいれば、その father に対して処理をし Ok を返します。一方、user がいないか、その user の父親がいない場合には NotFound を返します。大分スッキリしました。

  def exampleOption4: Int = {
    (for {
      user <- UserRepository.getUserById(1)
      father <- user.getFather
    } yield {
      doSomethingForUser(father)
      Ok
    }).getOrElse(NotFound)
  }

ただ、以下のような処理の場合には、for を使って書き換えることができません。ユーザーが存在しない場合とメアドが登録されていない場合で処理を変えたい場合です。

def exampleOption5: Int = {
  UserRepository.getUserById(1) match {
    case Some(user) =>
      user.getEmailAddress match {
        case Some(emailAddress) =>
          sendEmail(emailAddress)
          Ok
        case None =>
          logger.error(s"メールアドレスを登録していないユーザー ${user.id} にメールを送ろうとした")
          InternalServerError
      }
    case None =>
      NotFound
  }
}

どうすれば良いのかは、いくつかの案を後の方で紹介します。

Option に関してはここまでにして、次に Either について書いていきます。

Either を使った例

パターンマッチを使う

まずは基本的な形から紹介します。これの難点は Option を使った例と同様で、処理が増えるとネストが深くなる点です。

def exampleEither1 = {
  UserRepositoryEither.getUserById(1) match {
    case Left(error) =>
      error.writeToLog
      error.httpResponse
    case Right(user) =>
      user.getEmailAddressEither match {
        case Left(error) =>
          error.writeToLog
          error.httpResponse
        case Right(emailAddress) =>
          sendEmail(emailAddress)
          Ok
      }
  }
}

for を使うとスッキリ書ける

Either の場合に for を使うと、以下のようになります。

def exampleEither2: Int = {
  (for {
    user <- UserRepositoryEither.getUserById(1)
    emailAddress <- user.getEmailAddressEither
  } yield {
    sendEmail(emailAddress)
    Ok
  }).left.map { err =>
    err.writeToLog
    err.httpResponse
  }.merge
}

今までより少し複雑なので、以下に解説します。

まずは以下のブロックですが、Scala の Either は(2.12以降で)right-biased となっていて、map, flatMap などは値が Right だった場合に適用されます。つまり、以下の部分は、ユーザーが存在し、かつ、そのユーザーのメアドが登録されている場合、yield の中が実行され、その戻り値( Ok = Int 型)が EitherRight 側になります。従って、全体の戻り値としては Either[Error, Int] となります。

(for {
    user <- UserRepositoryEither.getUserById(1)
    emailAddress <- user.getEmailAddressEither
  } yield {
    sendEmail(emailAddress)
    Ok
  }) // Either[Error, Int]

次に以下の部分ですが、Either[Error, Int]Left だった場合にこちらの処理が実行されます。エラーのログを出力し、Left 側は err.httpResponse = Int 型に変換されま、merge の前までの部分の型は Either[Int, Int] となります。そして、最後の merge で、LeftRight が合体して、全体として Int 型となります。

  }).left.map { err =>
    err.writeToLog
    err.httpResponse
  }.merge

Option を Either に変換する

上の方の exampleOption5 は、少し読みづらいコードで、for 文を使う事も出来ませんでしたが、OptionEither に変換することで、すぐ前に説明した exampleEither2 のように書くことが出来ます。

具体的には、以下のようにかけます。

def exampleEither3 = {
  (for {
    user <- UserRepository.getUserById(1).toRight(UserNotFoundError)
    emailAddress <- user.getEmailAddress.toRight(EmailingUserWithNoEmailAddressError)
  } yield {
    sendEmail(emailAddress)
    Ok
  }).left.map { err =>
    err.writeToLog
    err.httpResponse
  }.merge
}

OptionEither に変換するには、上に書いた通り toRight メソッドを使います。toRight に渡す値は、EitherLeft 側になります。

getOrElse を使う

Either にも getOrElse メソッドが存在します。前述の通り、 Either は right-biased なので、値が Left だった場合に getOrElse の中身が使われます。

あまり無いかもしれませんが、EitherLeft だった場合に適切な例外を投げたい場合などは、以下のように書けます。

def exampleEither4 = {
  val user = UserRepositoryEither.getUserById(1).getOrElse(throw new UserNotFoundException(1))
  val email = user.getEmailAddressEither.getOrElse(throw new EmailingUserWithNoEmailAddressException(1))
  sendEmail(email)
  Ok
}

まとめ

Scala の Option は強力で、Scala を使っている人であれば日常的に使っていることと思います。一方、Either も強力ですが、Option とどう使い分けたら良いのかなどが分からず、Option ほど使っていない人も多いと思います。

本記事では、 Option, Either を使ったエラー処理の方法を、色んなパターンで説明してきました。

  • for をうまく使う
  • Option と独自の例外を使う
  • Either と独自のエラークラスを使う

といった方法で、エラー処理を読みやすくできる可能性があるので、自分のコードを一度見直してみてはどうでしょうか。

なお、Scala でエラー処理に使える仕組みとしては scala.util.Try などもあるので、これに関しては機会があれば別途説明します。

← 前の投稿

IAM で MFA を強制する方法と CLI での認証方法

次の投稿 →

静的データベースと動的データベース(Spark SQLの小ネタ)

コメントを残す