😽

Scalaのfor内包表記

2025/03/11に公開

Scalaのfor内包表記

内包表記とは?

内包表記(ないほうひょうき)とは、プログラミングにおいて、集合やリストなどの要素を生成するための簡潔な記法のことです。
数学における集合内包表記に準拠したもので、PythonやHaskelopなどで利用できます。
Scalaでこれに相当するものが for内包表記 です。

例えば、整数の集合 S から 奇数の要素を取り出し、それぞれの要素に 2 を掛けた集合を生成する場合は以下のように記述できます。

  • 集合内包表記
    • \{ 2x \mid x \in S, xは奇数 \}
  • リスト内包表記(Python)
    •  [2 * x for x in S if x % 2 == 1]
      
  • for内包表記(Scala)
    • for(x <- S if x % 2 == 1) yield 2 * x
      

Scalaのfor内包表記の構文は以下のとおりです。

for (enumerators) yield e

enumerators(列挙子) にはジェネレータもしくはガード(フィルタ)を指定し、e にはジェネレータから取り出した要素に対して適用する式を記述します。

Scalaのfor内包表記

式と文

yield を用いたScalaのfor内包表記は式であり、値を返します。そのため、for内包表記を変数に代入することができます。

val doubledOdds = for(x <- S if x % 2 == 1) yield 2 * x

値を返さない文としてのfor内包表記も記述することができますが、この場合は do を用います。
(Scala3の場合。Scala2では単に yield を省略します)
副作用を起こすことが目的の場合、こちらを使う方が意図を明確にしやすいでしょう。

for(x <- S if x % 2 == 1) do println(2 * x)

yield を用いた内包表記はfor式do を用いた内包表記はfor文と呼ばれます。

ジェネレータとガード

Scalaのfor内包表記では、<- を用いてジェネレータを指定します。
ジェネレータはコレクションや範囲を指定することができます。
ジェネレータから抽出する要素に対して条件を指定する場合は、if を用いてガードを指定します。

val S = 1 to 10
for {
  x <- S // この部分がジェネレータ
  if x % 2 == 1 // ガード条件
} yield 2 * x

複数の列挙子

for内包表記は複数のジェネレータやガード条件を指定することもできます。
この場合、それぞれの列挙子は上から順番に評価されます。

val S = 1 to 3
val T = 1 to 3

for {
  x <- S
  if x != 1
  y <- T
  if y < 2
} yield x*y

パターンマッチと分解

ジェネレータの左辺にはパターンマッチを用いることができます。
これにより、コレクションの要素を分解して取り出すことができます。
マッチしなかった場合はフィルタリングされます。

// タプルに分解
for {
  (x, y) <- List((1, 2), (3, 4))}
} yield x * y // List(2, 12)
// Someに分解
for {
  Some(x) <- List(Some(1), None, Some(2))
} yield x // List(1, 2)

糖衣構文 / map,flatMap,withFilter,foreach

Scalaのコンパイル時にfor内包表記は mapflatMapwithFilterforeach に展開されます。 言い換えると、for内包表記はこれらのメソッド呼び出しの糖衣構文です。

// 最後のyieldがmapに展開される
for (i <- 1 to 10) yield i * 2
(1 to 10).map(i => i * 2)
// yieldを使わない場合はforeachになる
for (i <- 1 to 10) do println(i * 2)
(1 to 10).foreach(i => println(i * 2))
// 複数の列挙子がある場合はflatMapに展開される
for {
  x <- 1 to 3
  y <- 1 to 3
} yield x * y
(1 to 3).flatMap(x => (1 to 3).map(y => x * y))
// ガード条件がある場合はwithFilterに展開される
for {
  x <- 1 to 10
  if x % 2 == 0
} yield x
(1 to 10).withFilter(x => x % 2 == 0).map(x => x)
// パターンマッチがある場合はwithFilterに展開される
for (Some(x) <- List(Some(1), None, Some(2))) yield x
List(Some(1), None, Some(2)).withFilter {
  case Some(x) => true
  case _ => false
}.map {
  case Some(x) => x
}

コンパイル時にオプションを指定することで、for内包表記がどのように展開されるかを確認することができます。

scalac -Xprint:typer <Scalaのソースコード>

カスタムデータ型を使った内包表記

for内包表記は mapflatMapwithFilterforeach に展開されるため、これらのメソッドが実装されている型であれば、標準ライブラリに限らず、カスタムデータ型に対しても利用することができます。

class Foo(i: Int) {
  def map(f: Int => Int): Foo = new Foo(f(i))
  def flatMap(f: Int => Foo): Foo = f(i)
}

for {
  x <- new Foo(1)
  y <- new Foo(2)
} yield x * y

また、カスタムデータ型にこれらのメソッドが直接実装されていない場合でも、暗黙の型変換を利用して内包表記を利用することができます。

class Foo(val i: Int)
implicit class FooOps(foo: Foo) {
  def map(f: Int => Int): Foo = new Foo(f(foo.i))
  def flatMap(f: Int => Foo): Foo = f(foo.i)
}

for {
  x <- new Foo(1)
  y <- new Foo(2)
} yield x * y

型クラスによる内包表記

暗黙の型変換を利用することで、カスタムデータ型に対して内包表記を利用することができるので、型クラスを使って内包表記を拡張することも可能です。

class Foo[A](val i: A)

/** mapとflatMapを定義した型クラス */
trait MapAndFlatMap[A] {
  def map[B](fa: F[A])(f: A => B): Foo[B]
  def flatMap[B](fa: Foo[A])(f: A => Foo[B]): Foo[B]
}

object MapAndFlatMap {
  /** 型クラスのインスタンス */
  given intInstance: MapAndFlatMap[Int] = new MapAndFlatMap[Int] {
    def map[Int, B](fa: Foo[A])(f: Int => B): Foo[B] = new Foo(f(foo.i))
    def flatMap(f: Int => Foo[B]): Foo[B] = f(foo.i)
  }
}

for {
  x <- new Foo(1)
  y <- new Foo(2)
} yield x * y

mapflatMapwithFilter などを実装すれば、for内包表記を使ったプラグラムを記述することができるのですが、これらの独自実装を都度用意をすると、意味論的な統一性が失われてしまいます。
そのため、cats のような関数型プログラミングに定義された型クラスを利用することで、一般的な mapflatMap などのメソッドを意味の統一性を失うことなく利用することができます。
このような関数型プログラミングライブラリでは、いわゆるモナド (Monad) と呼ばれる型クラスのインスタンスを実装することで、for内包表記を使ったプログラムを記述することができ、標準的なコレクションや OptionFuture などの型に対応した型クラスのインスタンスはライブラリが提供しています。

for内包表記の使い所

逐次処理の簡潔化

例えば、複数のコレクションを使ってループをコントロールするようなプログラムを書く場合、命令的な構文で書くと、管理する変数やループの制御が複雑になり、ネストも深くなりがちです。
以下は Java で3つのリストを使ってループを回して、条件に合致する要素を取り出す例です。
(それぞれ偶数の要素を取り出して、それらの和を計算する)

List<Integer> list1 = List.of(1, 2, 3);
List<Integer> list2 = List.of(4, 5, 6);
List<Integer> list3 = List.of(7, 8, 9);

List<Integer> result = new ArrayList<>();

void sample() {
  for (int i = 0; i < list1.size(); i++) {
      Integer e1 = list1.get(i);
      if (e1 % 2 == 1) continue;
      for (int j = 0; j < list2.size(); j++) {
          Integer e2 = list2.get(j);
          if (e2 % 2 == 1) continue;
          for (int k = 0; list3.size(); k++) {
              Integer e3 = list3.get(k);
              if (e3 % 2 == 1) continue;
              result.add(e1 + e2 + e3);
          }
      }
  }
}

Scalaのfor内包表記を使った場合、このプログラムは全くネストを使う必要がなく、制御のための変数(i,j,k)も必要ありません。

val list1 = List(1, 2, 3)
val list2 = List(4, 5, 6)
val list3 = List(7, 8, 9)

val result = for {
  e1 <- list1 if e1 % 2 == 0
  e2 <- list2 if e2 % 2 == 0
  e3 <- list3 if e3 % 2 == 0
} yield e1 + e2 + e3

このように実行順序が決まっていて、条件によっては一部の処理をスキップするような処理は、for内包表記を使うことでコードを簡潔に書くことができます。
この例はJavaからScalaへの書き換えでしたが、以下のように糖衣構文を使わずにScalaで書いた場合よりもfor内包表記を用いいた方が簡潔だと感じる方も多いでしょう。

val list1 = List(1, 2, 3)
val list2 = List(4, 5, 6)
val list3 = List(7, 8, 9)

val result = list1.withFilter(_ % 2 == 0).flatMap { e1 =>
  list2.withFilter(_ % 2 == 0).flatMap { e2 =>
    list3.withFilter(_ % 2 == 0).map { e3 =>
      e1 + e2 + e3
    }
  }
}

Future, Option, Either, Try

コレクションだけでなく、FutureOptionEither などの型も mapflatMap などのメソッドを実装しているため、これらの型に対してもfor内包表記を使うことができます。
このような型は一般的にモナドと呼ばれます。

これらのモナドに対してfor内包表記を使う目的も、順序のある処理(依存関係がある処理)を簡潔に書くことです。

Futureによる非同期処理

例えば、以下のような仕様のJavaの並行処理のプログラムを考えます。

  1. スレッド1ではある数値のコレクションを全て足し合わせる
  2. スレッド2では別の数値のコレクションを全て畳み込んで掛け合わせた結果とスレッド1の結果を足す
  3. スレッド3ではランダムで適当な数値を生成する
  4. 最終的に2と3の結果を足す
void run() {
    List list1 = List.of(1, 2, 3);
    List list2 = List.of(4, 5, 6);
    CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> list1.stream().mapToInt(Integer::intValue).sum());
    CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> list2.stream().reduce(1, (a, b) -> a * b)).thenCombine(future1, Integer::sum);
    CompletableFuture<Integer> future3 = CompletableFuture.supplyAsync(() -> new Random().nextInt());
    CompletableFuture<Integer> resultFuture = future2.thenCombine(future3, Integer::sum);
}

ScalaのFutureを使った場合、for内包表記を使うことでスコープの変数をあまり増やさずに同等の処理を書くことができます。

val list1 = List(1, 2, 3)
val list2 = List(4, 5, 6)
val resultFuture =
  for {
    r1 <- Future(list1.sum)
    r2 <- Future(list2.reduce(_ * _) + r1)
    r3 <- Future(new Random().nextInt() + r2)
  } yiled r3

Futureを使う場合の注意点として、独立した計算を内包表記の中でFutureで囲むと、それらの計算は逐次的に実行されることになります。
互いに依存関係のない独立した計算を並列に実行して計算結果だけを合成したい場合は、あらかじめ内包表記の外でFutureを作成しておく必要があります。

// このように書くと逐次実行される
val resultFuture =
  for {
    r1 <- Future(list1.sum)
    r2 <- Future(list2.reduce(_ * _))
  } yiled r1 + r2

// こう書くと並列実行される
val r1Future = Future(list1.sum)
val r2Future = Future(list2.reduce(_ * _))
val resultFuture = for {
  r1 <- r1Future
  r2 <- r2Future
} yield r1 + r2

Optionによる値の存在チェック

Optionは値が存在するかどうかを表すモナドで、SomeNone という2つのサブクラスを持ちます。
nullを使って同じことを表現することもできるので、ひとまずnullを使って書いた場合を考えます。

// 住所から郵便番号を取得し、その郵便番号から都道府県名を取得する
val address = getAddress()
if (address != null) {
  val postalCode = address.getPostalCode()
  if (postalCode != null) {
    val prefecture = postalCode.getPrefecture()
    if (prefecture != null) {
      val name = prefecture.getName()
      if (name != null) {
        println(name)
      }
    }
  }
}

ifによってネストが深くなり、波動拳が出かかっている気配を感じます。
Optionを使うと、このようなネストを減らすことができます。

val name = for {
  address <- Option(getAddress())
  postalCode <- Option(address.getPostalCode())
  prefecture <- Option(postalCode.getPrefecture())
  name <- Option(prefecture.getName())
} yield name

ただし、この場合の nameOption[String] 型になることには注意が必要です。
Optionから値を取り出す場合は、getOrElsefold などを使ってデフォルト値を指定するか、mapflatMap などを使って処理を続ける必要があります。

// 値をコンソール出力する
name.foreach(println)
// 値が存在しない場合は "Unknown" を指定する (getOrElse)
val nameOrDefault1 = name.getOrElse("Unknown")
// 値が存在しない場合は "Unknown" を指定する (fold)
val nameOrDefault2 = name.fold("Unknown")(identity)
// 値が存在する場合は名前を大文字にして出力し、存在しない場合は "Unknown" を出力する (map + getOrElse)
println(name.map(_.toUpperCase).getOrElse("Unknown"))
// 値が存在する場合は名前を大文字にして出力し、存在しない場合は "Unknown" を出力する (fold)
println(name.fold("Unknown")(_.toUpperCase))

いずれにせよ、ifやelseのようなコントロールのためにネストや括弧を使うことなく、
逐次処理(後段の処理が前段の処理結果に依存する処理)を簡潔に書くことができます。

Eitherによるエラーハンドリング

Eitherは取りうる値が2つあり、どちらか一方であることを表すモナドです。
通常、左側にエラーを表す値、右側に正常な値を表す値を格納します。
先ほどの住所の例では、都道府県名を取得できない場合は全てNoneを返していましたが、Eitherを使うことでエラーを明示的に表現することができます。

// Option#toRightはOptionがNoneの場合に指定した値をLeftに変換する
val name = for {
  address <- getAddress().toRight("Address not found")
  postalCode <- Option(address.getPostalCode()).toRight("Postal code not found")
  prefecture <- Option(postalCode.getPrefecture()).toRight("Prefecture not found")
  name <- Option(prefecture.getName()).toRight("Prefecture Name not found")
} yield name

Eitherから値を取り出す場合は、Optionの時と同じように foreachfold を利用します。

// 成功した場合のみ値をコンソール出力する
name.foreach(println) 
// 失敗した場合もエラーとしてコンソール出力する
// Right(name)はnameをそのまま出力し、Left(failure)は"Error: failure"を出力する
name.fold(
 success => println(success),
 failure => prinyln(s"Error: $failure")
)
// コンソール出力ではなく、文字列として取得する場合
val result = name.fold(identity, failure => s"Error: $failure")

Tryによる例外処理

Tryは処理が成功するか失敗するかを表し、失敗時に例外が発生していることを表すモナドです。
例外が発生した場合はFailure、成功した場合はSuccessを返します。

都道府県名取得の例で、住所取得時に例外が発生すると仮定した場合、try-catchブロックを使ったコードは以下のようになります。


try {
  val address = getAddress()
  if (address != null) {
    val postalCode = address.getPostalCode()
    if (postalCode != null) {
      val prefecture = postalCode.getPrefecture()
      if (prefecture != null) {
        val name = prefecture.getName()
        if (name != null) {
          println(name)
        }
      }
    }
  }
} catch {
  case e: Exception => println(s"Error: ${e.getMessage}")
}

Tryを使うと、例外が発生した場合はFailure、成功した場合はSuccessを返すので、for内包表記を使って以下のように書くことができます。
getAddressの例外以外のエラーを独自例外に変換するとした場合、以下のように書くことができます。

class BusinessException(message: String) extends Exception(message) with NoStackTrace

val name = for {
  address <- Try(getAddress())
  postalCode <- Option(address.getPostalCode()).toRight(new BusinessException("Postal code not found")).toTry
  prefecture <- Option(postalCode.getPrefecture()).toRight(new BusinessException("Prefecture not found")).toTry
  name <- Option(prefecture.getName()).toRight(new BusinessException("Prefecture Name not found")).toTry
} yield name

これまでと同様、ネストが深くなることなく、逐次処理を簡潔に書くことができます。
OptionからEitherを経由してTryへの変換を行う部分は、toRighttoTry などのメソッドを使って変換を行っています。 このためにやや横長なコードにはなっていますが、Addressのモデリングを工夫することによって、より簡潔に書くことも可能です。 (nullではなく、最初からOptionやEitherを使うなど)

Tryから値を取り出す場合は、OptionやEitherと同様に foreachfold を利用します。

// 成功した場合のみ値をコンソール出力する
name.foreach(println)
// 失敗した場合もエラーとしてコンソール出力する
// Success(name)はnameをそのまま出力し、Failure(exception)は"Error: exception"を出力する
name.fold(
  success => println(success),
  {
    case e: BusinessException => println(s"Error: ${e.getMessage}")
    case t: Throwable => println(s"Unexpected Error: ${e.getMessage}")
  }
)
// コンソール出力ではなく、文字列として取得する場合
val result = name.fold(
  identity,
  {
    case e: BusinessException => s"Error: ${e.getMessage}"
    case t: Throwable => s"Unexpected Error: ${e.getMessage}"
  }
)

まとめ

  • 内包表記は集合やリストなどの要素を生成するための簡潔な記法
    • Scalaでは、for内包表記がこれに相当するもの
  • 内包表記は mapflatMapwithFilterforeach に展開される
    • これらの関数をサポートする抽象はモナドと呼ばれる
  • モナドにはコレクション以外にも FutureOptionEitherTry といったものがある
    • これらのモナドを使うと、集合やリストのようなコレクションだけではなく、非同期処理やエラーハンドリングなどもfor内包表記を使って書くことができる
    • for内包表記を使うことで、前段の処理結果が後段の処理に依存する逐次処理の記述方法を一般化し、簡潔に書くことができる

参考資料

Discussion