エムスリーエンジニアリンググループの冨岡です。この記事はエムスリーAdvent Calendar及びScala Advent Calendarの20日目の記事です。
はじめに
Scalaでは、作用、特に非同期での作用を扱うためのデータ型として scala.concurrent.Future が提供されています。これは手軽につかえて便利である一方、使いづらい点もいくつかあります。この記事ではそれらFutureの使いづらい点に触れ、それに対する関数型プログラミングでのアプローチと、それを実現する3rd partyライブラリをいくつか紹介します。
なお、記事の性質上Futureの欠点について多く触れますが、Futureを使うべきではないということを主張したいわけではありません。実際の採用技術はチームやエコシステムの状況等から判断されるべきで、Futureも当然選択肢のうちだと筆者は考えます。
Future の特徴
Future の長所
標準ライブラリで提供されている
これがFuture最大の長所でしょう。言語の標準ライブラリとして提供されていることで以下のようなメリットがあります。
- 利用者が多い
- 対応しているライブラリが多い
利用者が多い点はチームによっては無視できそうですが、対応ライブラリについてはそうはいきません。非同期処理としてFutureのみを提供するライブラリは山程あるので、後述する3rd partyライブラリを利用する場合にはFutureとの相互変換をする必要があります。
抽象化の戦略がわかりやすい
手続き型プログラミング的な観点からみると「処理をExecutionContextの実行戦略に従って非同期化する」という点でFutureは非同期処理を素直に抽象化しているように思えます。ExecutionContext周りが少しごちゃついていますが、一度理解してしまえばそこまで大きく躓くことはないでしょう。
JavaScriptのPromiseも同じような使用感ですね。
Future の短所
処理を即時実行する
これがFuture最大の短所でしょう。Futureは作成と同時に、対応する処理を否応なしに実行してしまいます。つまり、作成に副作用を伴います。そのため、意図した処理がプログラム上の順番に暗に依存することになります。
以下の2つのコードは大きく違う挙動で動作します。
// 並列にeffectfulA, effectfulBを実行 val fA: Future[A] = effectfulA() val fB: Future[B] = effectfulB() for { a <- fA b <- fB } yield (a, b)
// 直列にeffectfulA、成功すればeffectfulBの順番で実行、effectulfAが失敗すればeffectfulBは実行されない for { a <- effectfulA() b <- effectfulB() } yield (a, b)
参照透過*1でなく処理の順番に挙動が大きく依存しているため、定数やメソッドへの切り出しや、その逆などのリファクタリングが容易にできません。
結果が否応なしにメモ化される
処理を即時実行することの裏を返すようですが、Futureは作成時に実行される処理の結果をメモ化し、再利用された場合も結果が変わりません。そのため、非同期処理を再利用したい場合は注意が必要です。
// 元のfの結果が使い回されるため、retryしても結果が変わらない。 def uselessRetry[A](f: Future[A], retryCount: Int)(implicit e: ExecutionContext): Future[A] = if (retryCount <= 0) f else f recoverWith { case _ => uselessRetry(f, retryCount - 1) }
正しくretry
するFutureを実装するには、Futureを作成するFunctionの形で受け取るといった工夫が必要な上に、呼び出し側も少し注意しながら利用する必要があります。
ExecutionContextが必要なシーンが多い
FutureはFuture.successful
など一部のpureなメソッドを除き、作成にimplicitなExecutionContextを要求します。非同期処理を意図しないmap
等のメソッドでも必要になるので、使い回すには不便です。
def attempt[A](f: Future[A]): Future[Either[Throwable, A]] = f.transform(t => Success(t.toEither)) // これにExecutionContextが必要なためコンパイルエラー
参照透過な作用
Futureの大きな短所はその生成に副作用を伴うことでした。ExecutionContextを要求するのはその結果といえるでしょう。
一方、関数型プログラミングでは作用を関数に閉じ込めることで参照透過に扱う戦略がよく取られます。大雑把に言うと作用を起こす { println("test"); 1 }
の代わりに () => { println("test"); 1 }
を扱うだけです。扱う対象が作用そのものではなく、いつか起こす作用の設計図*2になることで、実質参照透過に作用を扱うことができるようになります。
// Cats Effect: IOの例. IO(x) は名前渡しで引数を受け取るため、println等は即時実行されない。 def printLine(value: String): IO[Unit] = IO(println(value)) val readLine: IO[String] = IO(scala.io.StdIn.readLine()) // map, flatMapが実装されていれば容易に合成できる。 val echo: IO[Unit] = for { line <- readLine _ <- printLine(line) } yield () // 参照透過なため、式を展開しても挙動が変わらない。 val echo2: IO[Unit] = for { line <- IO(scala.io.StdIn.readLine()) _ <- IO(println(line)) } yield ()
合成してできるのは、言ってしまえば巨大な関数(設計図)です。そのため実行するタイミングや、メモ化する・しないといった選択は利用者が後から明示的にコントロールできます。Futureでは密結合していた、作用の設計図と、その実行が分離されたことで、設計図をいつでも再利用できるになります。
手続き型プログラミングのメンタルモデルでは、プログラムは上から順番に実行する処理を書かれているものと理解することが自然でしょう。関数型プログラミングの考え方で作用を扱う場合は、参照透過な方法で全体の設計図を作成することがプログラムの大部分で、最後の最後*3にその実行を処理系や、薄い実行プログラムに任せるというスタイルでプログラムを構築します。
例えば後で紹介するCats EffectではIOAppというtraitが提供され、これをextendsしたオブジェクトでrun(args: List[String]): IO[ExitCode]
を定義することで、作用を即時発生させるコードを自分では一行も書かないままプログラムを書くことが可能です。
import cats.effect._ import cats.syntax.all._ object Main extends IOApp { def run(args: List[String]): IO[ExitCode] = args.headOption match { case Some(name) => IO(println(s"Hello, $name.")).as(ExitCode.Success) case None => IO(System.err.println("Usage: MyApp name")).as(ExitCode(2)) } }
Cats Effect: IOApp のサンプルコード。runメソッドではIOを返すだけで参照透過なメソッド。end-of-the-worldのIOの起動はIOApp traitがハンドルしてくれる。
(コラム)プログラムをプログラムすることについて
プログラム自体が処理の設計図であるにも関わらず、設計図としてのデータ型を別に作るのはなんだか不自然に思えるかもしれません。しかし、元々はプログラムの構成要素であったサブルーチンも、関数オブジェクトとして他の値と同様に使い回すことは今や一般的であり重要なテクニックとして認知されていることを考えると、そこまで突飛な考えでもないと筆者は考えます。
3rd party の Pure Effect ライブラリの紹介
上記のような作用を参照透過に扱ううえで役に立つ3rd partyのライブラリをいくつか紹介します。
Cats Effect: IO
typelevelプロジェクト郡において作用を担うライブラリです。http4sやFs2、Doobieといった他のtypelvelプロジェクトで利用されているため、一貫してtypelevelのライブラリを利用し全体を関数型プログラミングで作成する際にはまず検討候補になるライブラリでしょう。Cats Effectを直接・もしくは単体で扱うことももちろん可能です。
Monix: Task
Cats Effect: IOに似たAPIを提供しますが、Cats Effectより先発のライブラリです。作者の@alexelcuさんは現在ではCats EffectにもコントリビュートしているためCats EffectにMonixの知見が生かされる形で、お互い似てきているようです。
思想の違いや歴史的な経緯などはこちらにまとまっています。
That said IO is designed to be a simple, reliable, pure reference implementation, whereas Task is more advanced if you care about certain things.
この比較は2018/3/20の記事でありますが、Cats Effectがsimple, pure, explicitといった特徴があり、エコシステムの標準を目指しているのに対し、作者の@alexelcuさんが考える理想の非同期処理ライブラリを追い求めている印象です(詳しくは記事参照)。
Monix Taskについては@OE_uiaさんの以下の資料で、日本語でより詳しく紹介されています。 Monix Taskが便利だという話
ZIO
Cats Effectに似たような並列処理のプリミティブを提供しますが、cake patternに似た発想を用いたDI機能も合わせて提供するユニークなライブラリです。
作用とDIというのは考えてみれば切っても切れない関係です。DIを用いる一番の理由の一つは、「作用を起こすなにか」の実装を差し替えてテストすることでしょう。
テストについてのアプローチがZIOをかなり特徴づけているので、利用する予定がなくても一度見てみると面白いと思います。 zio.dev
まとめ
Scala標準ライブラリに含まれるFutureの欠点と、関数型プログラミングによるアプローチ・いくつかの具体的なライブラリを紹介しました。一貫してプログラムの作用を"設計図"の状態で扱うことで、プログラム全体を通して参照透過で、リファクタリングが容易なコードを書くことができます。
参照透過性について補足
なお、当たり前のように参照透過であることが良いことだとしていますが、参照透過であることのメリットについては、CatsのcontributorでもあるLuka Jacobowitzさんの以下のトークがおすすめです。参照透過であることのメリットについて触れるだけでなく、この記事の主題でもある作用の扱いなどについても触れています。
We're hiring!
エムスリーではプログラムをプログラムしたいエンジニアを募集中です!