「for式による関数合成」は、Scalaを学び始めた方にとって難しく感じられるトピックの1つなのではないでしょうか。
この記事では、Scala初学者の方や教育を行う方を対象に、for式による関数合成を「線路」のメタファーを用いて理解・説明するアプローチを紹介します。

for式は「反復処理専用の構文」?

「for文」を持つプログラミング言語の経験がある方にとっては、Scalaの for式 はまず「反復処理のための構文」と捉えられることが多いのではないでしょうか。
例えば「Scala スケーラブルプログラミング」でも、「受け取った複数のコマンドライン引数をfor式で反復的に処理する例」がまず紹介されます。1

1
2
for (arg <- args)
  println(arg)

しかし、for式の役割は反復処理のみに留まりません。for式を反復処理の専用構文と捉えてしまうと、以下のコードは一見意味不明なものに見えるでしょう。
「ここで行っているのはfor式によるEitherの合成だよ」と一言で説明しても、Scalaを学び初めた人にとっては、なかなかピンとこないのではないでしょうか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def placeOrder(unvalidatedOrder: UnvalidatedOrder): Either[OrderError, PricedOrder] =
  for {
    validOrder     <- validateOrder(unvalidatedOrder)
    availableOrder <- checkStock(validOrder)
    pricedOrder    <- priceOrder(availableOrder)
  } yield pricedOrder

def validateOrder(order: UnvalidatedOrder): Either[ValidationError, ValidOrder] = ...
def checkStock(order: ValidOrder): Either[OutOfStockError, AvailableOrder] = ...
def priceOrder(order: AvailableOrder): Either[PricingError, PricedOrder] = ...

sealed trait OrderError
case class ValidationError(...) extends OrderError
case class OutOfStockError(...) extends OrderError
case class PricingError(...)    extends OrderError

ここで行っていることを分かりやすく説明するには、どのようなステップを踏むのがよいでしょうか?
この記事では、Scott Wlaschin による “Domain Modeling Made Functional” を参考に、「線路」のメタファーを用いて説明をするアプローチを紹介します。

for式による関数合成を理解する3ステップ

for式による関数合成を「線路」のメタファーで説明するアプローチでは、以下の3ステップを踏んでいます。

  1. 「2トラックな関数」を理解する
  2. 2トラックな関数同士をどう組み合わせるかを理解する
  3. コードのネストを避けるためにfor式を利用する

それぞれのステップを順に見ていきましょう。

1. 「2トラックな関数」を理解する

現実世界のアプリケーションは Happy path を考慮するだけでは不十分です。たとえばECのアプリケーションであれば、注文された商品の在庫切れなどの理由によって期待されたシナリオを実行できない状態、 “Unhappy path” をハンドリングする必要があります。

「2トラックな関数」

“Unhappy path” は例外(Exception)として表現することも可能ですが、「例外的な状況にだけ例外を使用する」指針に従い、「商品の在庫切れ」のような業務上想定できるエラーは値 (OutOfStockError) としてモデリングすることとします。

例外とは何か例外的なもの(ある意味、あきらめ)を表現するものであるとした場合、失敗は想定できる結果であるため、その失敗を例外としてモデリングすることは意味的におかしいことになってしまいます。

『セキュア・バイ・デザイン』 Dan Bergh Johnsson、 Daniel Deogun、 Daniel Sawano

「Happy / Unhappy な結果のいずれかを返す関数」を表現するために、今回は返り値の型として Either2 を利用します。
例えば「在庫を確認する関数」は以下のようになるでしょう。

1
2
3
4
5
6
7
8
def checkStock(order: ValidOrder): Either[OutOfStockError, AvailableOrder] =
  if (hasStock(order.itemId)) Right(AvailableOrder.create(...))
  else Left(OutOfStockError(...))
  // あるいは Either.cond を用いて1行で
  // Either.cond(hasStock(order.itemId), AvailableOrder.create(...), OutOfStockError(...))

sealed trait OrderError
case class OutOfStockError(...) extends OrderError  

その他の「注文をバリデートする関数」や「価格を確定する関数」も同様にEitherを用いて、Happy / Unhappy な結果をとりうるように表現してみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/** 注文をバリデートする */
def validateOrder(order: UnvalidatedOrder): Either[ValidationError, ValidOrder] = ...
/** 在庫を確認する */
def checkStock(order: ValidOrder): Either[OutOfStockError, AvailableOrder] = ...
/** 注文の価格を確定する */
def priceOrder(order: AvailableOrder): Either[PricingError, PricedOrder] = ...

sealed trait OrderError
case class ValidationError(...) extends OrderError
case class OutOfStockError(...) extends OrderError
case class PricingError(...)    extends OrderError

これで、注文を行うために必要な3つの「2トラックな関数」ができました。
これらの関数を組み合わせ、「いずれかの関数が Unhappy な結果を返した際には、その先の関数をバイパスするように合成したい」とします。
そのような合成はどう実現するのがよいでしょうか?

「いずれかの関数が Unhappy な結果を返した際には、その先の関数をバイパスするように合成したい」

2. 2トラックな関数同士をどう組み合わせるかを理解する

そのためにあるのが flatMap です。先ほどの3つの関数を、flatMapを用いて組み合わせると以下のようになります。

1
2
3
4
5
6
def placeOrder(unvalidatedOrder: UnvalidatedOrder): Either[OrderError, PricedOrder] =
  validateOrder(unvalidatedOrder).flatMap { validOrder =>
    checkStock(validOrder).flatMap { availableOrder =>
      priceOrder(availableOrder)
    }
  }

validateOrder , checkStock , priceOrder の3つの関数をflatMapで繋いでいます。
こうしてできた placeOrder 関数は、UnvalidatedOrder を受け取り、3つすべての関数が Happy な結果、つまりRightを返せばPricedOrder を、いずれかの関数が Unhappyな結果としてLeftを返せば OrderError を返します。

3つの関数の結果がすべて Happy であれば、通るルートは以下のようになります。注文のバリデート, 在庫確認, 注文金額の確定を無事通過してハッピーエンド、3つの関数を合成した結果としてRightで Priced Order を返します。

すべての関数が Happy な結果を返した場合のフロー

仮に checkStock が「在庫がない」と判断した場合は、その先の関数をバイパスして、OutOfStockError をLeftで返します。

checkStock が「在庫がない」と判断した場合のフロー

flatMapを用いることで Unhappy な際のバイパスを実現しつつ、関数同士を合成することができました。

一方で気になるのが、「インデントの谷」です。合成する関数が増えるほど、谷は深くなっていってしまうのでしょうか?

3. コードのネストを避けるためにfor式を利用する

この谷を平らにならすために使えるのが for式 です。上記のflatMap版のコードは、for式を用いると下記のように表現できます。
こうすると、合成する関数が増えても見通しは良いままですね。

1
2
3
4
5
6
def placeOrder(unvalidatedOrder: UnvalidatedOrder): Either[OrderError, PricedOrder] =
  for {
    validOrder     <- validateOrder(unvalidatedOrder)
    availableOrder <- checkStock(validOrder)
    pricedOrder    <- priceOrder(availableOrder)
  } yield pricedOrder

for式は、flatMapやmapを用いたコードをより端的に書きやすくするためのシンタックスシュガーです。上記のfor式を用いたコードは、コンパイル時にflatMap等の呼び出しに変換されます3

より簡単なコードを用いて、for式がflatMap等の呼び出しに変換されていることを確認してみましょう。このようなコードを用意し、ファイルとして保存します。

1
2
3
4
5
6
object DesugarTest {
  for {
     a <- Right(2)
     b <- Right(3)
  } yield a * b
}

このファイルを scalac-Xprint:namer のオプション付きでコンパイルし、どのように変換されているかを見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ scalac -Xprint:namer ./DesugarTest.scala
[[syntax trees at end of                     namer]] // DesugarTest.scala
package <empty> {
  object DesugarTest extends scala.AnyRef {
    def <init>() = {
      super.<init>();
      ()
    };
    Right(2).flatMap(((a) => Right(3).map(((b) => a.$times(b)))))
  }
}

Right(2).flatMap(((a) => Right(3).map(((b) => a.$times(b))))) を見ると分かるように、for式が flatMap や map の呼び出しに変換されていることが確認できました!

まとめ

この記事では “Domain Modeling Made Functional” を参考に、flatMapやfor式による「2トラック」な関数の合成を「線路」のメタファーを用いて理解するアプローチを紹介しました。
今回の例ではEitherを返す関数の合成を取り上げましたが、線路のメタファーはOptionやTry, Futureなど各種データ型の合成の理解にも同じように役立つのではないでしょうか。
このアプローチが、Scala初学者の方や教育を行う方の一助となれば幸いです。


  1. 後続の第23章「for式再説」では、for式の反復処理以外の機能についても触れられています。 ↩︎

  2. Eitherとは、RightあるいはLeftの2つの値のどちらかを持つ型です。慣例的に、「正しい」(Right)にかけて、正常時の値がRight、エラー時の値がLeftとして表現されることがよくあります。 ↩︎

  3. 厳密には yield 句の有無によって変換のされ方が異なります。Scalaコンパイラは yield句 を含むfor式は mapflatMap, withFilter の呼び出しに、yield句を含まないfor式は withFilterforeach の呼び出しの組み合わせに変換します。 ↩︎

田所 駿佑
田所 駿佑

HRMOS EXのソフトウェアエンジニア & 開発チームマネジャー 。2015年新卒入社。翔泳社「クローリングハック」共著、「Scalaスケーラブルプログラミング 第4版」監訳メンバー。特技はアンコウの吊るし切り。二児の父👶