一般社団法人 全国個人事業主支援協会

COLUMN コラム

  • Scalaで学ぶ関数型デザインパターン:Either・Option・Futureの活用

なぜScalaで関数型デザインパターンを学ぶのか

Scalaは、オブジェクト指向と関数型プログラミングを融合した言語として、関数型デザインパターンを学ぶのに最適な環境を提供しています。筆者は金融系システムのバックエンド開発でScalaを5年以上使用してきましたが、Either、Option、Futureといったモナド的なデータ型を適切に使いこなすことが、堅牢なシステムを構築する鍵だと確信しています。

本記事では、これらの型を使った実践的なデザインパターンを、実務での経験を交えて解説します。

Option:nullの撲滅と安全なデータアクセス

JavaでのNullPointerExceptionに悩まされた経験がある方は多いでしょう。ScalaのOption型は、値の存在・不在を型レベルで表現します。

// データベースからユーザーを検索する関数
def findUser(id: Long): Option[User] = {
userRepository.findById(id) // Some(user) or None
}

// パターンマッチでの処理
findUser(42) match {
case Some(user) => println(s"見つかりました: ${user.name}")
case None => println("ユーザーが見つかりません")
}

// より関数型的なアプローチ:map/flatMap
val greeting: Option[String] = findUser(42).map(u => s"こんにちは、${u.name}さん")

// getOrElseでデフォルト値
val name: String = findUser(42).map(_.name).getOrElse("ゲスト")

Optionの連鎖とfor式

複数のOption値を組み合わせる場合、for式(for comprehension)が非常に読みやすいコードを生み出します。

case class Address(city: String, zip: String)
case class User(name: String, address: Option[Address])
case class Order(userId: Long, amount: Double)

def findUser(id: Long): Option[User] = ???
def findLatestOrder(userId: Long): Option[Order] = ???

// for式による安全な連鎖
val summary: Option[String] = for {
user <- findUser(42)
address <- user.address
order <- findLatestOrder(42)
} yield s"${user.name}(${address.city}): ¥${order.amount}"

println(summary.getOrElse("情報を取得できませんでした"))

for式は内部的にflatMapとmapのチェーンに変換されます。途中でNoneが返された場合、以降の処理は自動的にスキップされ、最終結果もNoneになります。

Either:エラーハンドリングの型安全な設計

Either[L, R]は「失敗(Left)」または「成功(Right)」のどちらかを表す型です。例外を投げるのではなく、エラーを値として扱うことで、型安全なエラーハンドリングが可能になります。

// ドメインエラーの定義
sealed trait AppError
case class ValidationError(msg: String) extends AppError
case class NotFoundError(resource: String, id: Long) extends AppError
case class AuthError(reason: String) extends AppError

// Either を使ったサービス層
def validateAge(age: Int): Either[AppError, Int] = {
if (age >= 0 && age <= 150) Right(age)
else Left(ValidationError(s"不正な年齢: $age"))
}

def findUser(id: Long): Either[AppError, User] = {
userRepo.findById(id)
.toRight(NotFoundError("User", id))
}

def updateUserAge(userId: Long, newAge: Int): Either[AppError, User] = {
for {
age <- validateAge(newAge)
user <- findUser(userId)
updated = user.copy(age = age)
_ <- userRepo.save(updated)
} yield updated
}

sealed traitでエラー型を定義することで、パターンマッチの網羅性チェックがコンパイラにより保証されます。

EitherとOptionの相互変換

実務では、OptionとEitherを相互に変換する場面が頻繁に発生します。

// Option → Either
val maybeUser: Option[User] = findUserOption(42)
val eitherUser: Either[AppError, User] =
maybeUser.toRight(NotFoundError("User", 42))

// Either → Option(エラー情報は失われる)
val optUser: Option[User] = eitherUser.toOption

Future:非同期処理の合成

Futureは非同期計算を表すモナドです。Either同様にfor式で合成でき、非同期処理のパイプラインを宣言的に記述できます。

import scala.concurrent.{Future, ExecutionContext}
import scala.concurrent.ExecutionContext.Implicits.global

def fetchUserAsync(id: Long): Future[User] = Future {
// DBアクセスなどの重い処理
Thread.sleep(100)
User("田中", Some(Address("東京", "100-0001")))
}

def fetchOrdersAsync(userId: Long): Future[List[Order]] = Future {
Thread.sleep(50)
List(Order(userId, 5000.0), Order(userId, 3000.0))
}

// 直列実行(for式)
val result: Future[String] = for {
user <- fetchUserAsync(42)
orders <- fetchOrdersAsync(42)
} yield s"${user.name}: ${orders.size}件の注文"

// 並列実行(先にFutureを生成)
val userFuture = fetchUserAsync(42)
val ordersFuture = fetchOrdersAsync(42)
val parallel: Future[String] = for {
user <- userFuture
orders <- ordersFuture
} yield s"${user.name}: ${orders.size}件の注文"

重要なポイントは、Futureの生成タイミングです。for式の中で生成すると直列実行になりますが、事前に生成しておけば並列実行されます。この違いを理解していないと、パフォーマンスに大きな差が出ます。

EitherTモナドトランスフォーマー

実務ではFuture[Either[Error, A]]という型が頻出します。catsライブラリのEitherTを使うと、このネストを扱いやすくなります。

import cats.data.EitherT
import cats.implicits._

type Result[A] = EitherT[Future, AppError, A]

def validateAsync(age: Int): Result[Int] =
EitherT.fromEither[Future](validateAge(age))

def findUserAsync(id: Long): Result[User] =
EitherT(fetchUserAsync(id).map(u => Right(u): Either[AppError, User]))

def updateUserAgeAsync(userId: Long, newAge: Int): Result[User] = for {
age <- validateAsync(newAge)
user <- findUserAsync(userId)
} yield user.copy(age = age)

実務で得た教訓とベストプラクティス

  • Option.getは使わない:NoSuchElementExceptionのリスクがあります。map、flatMap、getOrElseを使う
  • Eitherは右バイアス:Scala 2.12以降、Eitherは右バイアスなのでRightProjectionは不要
  • 例外はシステム境界で変換:外部ライブラリの例外はTryでキャッチし、速やかにEitherに変換する
  • 型エイリアスで可読性向上type Result[A] = Either[AppError, A]のように定義すると読みやすい
  • エラー型はsealed trait:網羅性チェックの恩恵を最大限に受ける

まとめ

ScalaのOption、Either、Futureは単なるユーティリティではなく、ソフトウェア設計の根幹を成す重要なパターンです。これらを適切に使いこなすことで、nullチェックの漏れ、例外の握りつぶし、非同期処理のコールバック地獄といった問題から解放されます。まずはOptionから始めて、徐々にEither、そしてモナドトランスフォーマーへとステップアップしていくことをお勧めします。

この記事をシェアする

  • Twitterでシェア
  • Facebookでシェア
  • LINEでシェア