Extensible effects は非常に便利なツールですが、検索してもその原理とか実装の詳細しか出てきません。こんなに便利なものがあまり知られていないのも残念なので、紹介記事を書いてみます。
なお、ここでは extensible effects の実装として Scala の atnos-org/eff を利用します。
この記事は初心者向けです。
モナドとかをほとんど知らなくても読めるように配慮しました。
ただし、Scala である程度のプログラミングができることは前提としています。
scalaz や cats を普通に使えるような人は説明文を飛ばしながら読んでください。
Extensible Effects のモチベーション
簡単にまとめると、プログラムの「文脈」を扱うためのものです。
ここで言う「文脈」は、プログラムの上で暗黙的に引き回したい情報のことです。
なんのことかわからないと思うので、まずは「文脈」を使わずに(明示的に記述する)記述したプログラムの例を見せます。
「A してから B して、その結果を利用して C をする」というプログラムを考えましょう。
ここで、A は A1 を計算してそれを利用して A2 を計算するものとします。
また、B も同様に、B1 を計算してそれを利用して B2 を計算するものとします。
このプログラムは以下のように書けます。
def program: C = {
val a = doA
val b = doB
doC(a, b)
}
def doA: A = {
val x = runA1
runA2(x)
}
def doB: B = {
val x = runB1
runB2(x)
}
まあ当たり前ですね。
では A1, B1, B2, C の実行にはオブジェクト config が必要である場合はどうすれば良いでしょうか?
グローバル変数などに config を格納するという手もありますが、今回は引数として引き回しましょう。
すると先ほどのプログラムはこうなります。
def program(config: Config): C = {
val a = doA(config)
val b = doB(config)
doC(a, b, config)
}
def doA(config: Config): A = {
val x = runA1(config)
runA2(x)
}
def doB(config: Config): B = {
val x = runB1(config)
runB2(x, config)
}
ちょっと冗長になってしまいました。
では更に、実は A2 及び B1 は失敗する (nullを返す) かもしれず、失敗した場合は残りの処理をスキップしたい場合はどうすれば良いでしょうか?
Scala なので、こういう時は Option を使いますね。
すると先ほどのプログラムはこうなります。
def program(config: Config): Option[C] = {
doA(config) match {
case Some(a) =>
doB(config) match {
case Some(b) => Some(doC(a, b, config))
case None => None
}
case None => None
}
}
def doA(config: Config): Option[A] = {
val x = runA1(config)
Option(runA2(x)) // Option で包むと null は None になる
}
def doB(config: Config): Option[B] = {
Option(runB1(config)) match {
case Some(x) => Some(runB2(x, config))
case None => None
}
}
いい感じにカオスになってきました。
では、A1 と A2 と B2 が共通の状態 cache を参照・書き換えながら処理をするとするとどうでしょう?
正直もうやりたくないですよね。
def program(config: Config, cache: Cache): (Option[C], Cache) = {
doA(config, cache) match {
case (Some(a), cache2) =>
doB(config, cache2) match {
case (Some(b), cache3) => (Some(doC(a, b, config)), cache3)
case (None, cache3) => (None, cache3)
}
case (None, cache2) => (None, cache2)
}
}
def doA(config: Config, cache: Cache): (Option[A], Cache) = {
val (x, cache2) = runA1(config, cache)
Option(runA2(x, cache2)) match {
case Some((y, cache3)) => (Some(y), cache3)
case None => (None, cache2)
}
}
def doB(config: Config, cache: Cache): (Option[B], Cache) = {
Option(runB1(config)) match {
case Some(x) =>
val (y, cache2) = runB2(x, config, cache)
(Some(y), cache2)
case None => (None, cache)
}
}
立派なクソコードの誕生です。
元のロジックがどうなっていたか、パッと見では分かりません。
Extensible effects を利用すれば、このようなクソコードを生み出すのを防ぐことができます。
最初の例を Eff を使って書き直す
最初のプログラムに一旦戻りましょう。
このプログラムは端的にロジックを表現できていますが、拡張に対して非常に弱いということがわかりました。
なので、拡張する余地を表現するために、何か"文脈がある"型 Eff を使います (import org.atnos.eff._
が必要)。
Eff[R, A] という型は R という文脈のもとで、型 A になることを表します。
型 A の代わりに Eff[R, A] を使うことで、拡張の余地を表現します。
この型を使う際のポイントは、文脈 R の具体的な型をあえて示さずに使うというところです。
Eff[R, A] は A とは違う型なので A と同じようには使えませんが、for-yield 式の中だと A と同じように使うことができます。
pure という関数を使うことで、普通の A を Eff[R, A] に変換することができます (import org.atnos.eff.eff._
が必要)。
import org.atnos.eff._
import org.atnos.eff.eff._
def program[R]: Eff[R, C] = for {
a <- doA
b <- doB
} yield doC(a, b)
def doA[R]: Eff[R, A] = for {
x <- pure(runA1)
} yield runA2(x)
def doB[R]: Eff[R, B] = for {
x <- pure(runB1)
} yield runB2(x)
最初のプログラムはこのように記述することができます。
色々と書き方は違いますが、ロジックは同じであることが見て取れると思います。
このプログラムは以下のように実行して実際の値 C を計算できます。
import org.atnos.eff._
import org.atnos.eff.syntax.eff._
val x: C = program[NoFx].run
...
Eff は run 関数を使うことで普通の値に変換できます(import org.atnos.eff.syntax.eff._
が必要)。NoFx は今のところあまり気にする必要はありません。
doC や runA1, runA2 なども複雑な計算をするので、後の拡張のために Eff を返す関数でラップして使うように変更しましょう。
import org.atnos.eff._
import org.atnos.eff.eff._
def program[R]: Eff[R, C] = for {
a <- doA
b <- doB
c <- doC(a, b)
} yield c
def doA[R]: Eff[R, A] = for {
x <- runA1
y <- runA2(x)
} yield y
def doB[R]: Eff[R, B] = for {
x <- runB1
y <- runB2(x)
} yield y
def doC[R](a: A, b: B): Eff[R, C] = pure(original_doC(a, b))
def runA1[R]: Eff[R, A1] = pure(original_runA1)
def runA2[R](x: A1): Eff[R, A] = pure(original_runA2(x))
def runB1[R]: Eff[R, B1] = pure(original_runB1)
def runB2[R](x: B1): Eff[R, B] = pure(original_runB2(x))
元の doC や runA1, runA2 は original_doC や original_runA1, original_runA2 にリネームしました。
doC や runA2 が Eff を返すので、これも for-yield 式の中(for{...}の部分)に書く必要があります。
なので、 <- で一旦変数に入れて、それからその値を yield するということをしています。
ちょっとコードは長くなっていますが、ロジックは簡単に見て取れると思います。
読み込み専用の値を持つという文脈
ここまででは文脈を示す R はただの置き物になっていましたが、ここからは R を活用していきます。
さて、では A1, B1, B2, C の実行にオブジェクト config が必要であるような場合に対応してみましょう。
これは、Eff が文脈として "read-only な値 config を持つ" ようにすることで対応できます。
"read-only な値 config を持つ文脈" の表現には Reader というものを使います (import cats.data._
が必要)。
まずは、以下のようにして型エイリアス(エイリアスとは別名という意味です)を宣言しておきます。
import cats.data._
import org.atnos.eff._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R // 重要なのはこれ
...
}
_config[R] は "R には文脈 ConfigReader が含まれる" ことを表現しています。
これを利用すると、runA1 は以下のように書き直すことができます。
import cats.data._
import org.atnos.eff._
import org.atnos.eff.reader._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R
...
def runA1[R:_config]: Eff[R, A1] = for {
config <- ask
} yield original_runA1(config)
...
}
ask は for-yield 式の中で使うことができる関数で、"read-only な値を持つ文脈" Reader の持つ値を返す関数として使えます(import org.atnos.eff.reader._
が必要)。
これを利用することで、original_runA1 の計算に必要な config オブジェクトを文脈の中から取り出して使うことができます。
この ask 関数を使うためには、この関数自体が read-only な値 config を持つ文脈の下で計算されることを示す必要があります。これを表している部分が 型引数 R の宣言部分 [R:_config]
です。
[R:_config]
は implicit parameter を持つことのシンタックスシュガー(見た目が違うだけで同じもの)です。runA1 から pure 関数がなくなっていますが、これは for-yield を使うことで自動的に Eff に変換されるためです。分からない場合はとりあえず yield が pure の代わりをしていると考えてください。
さて、runA1 は read-only な値 config を持つ文脈の下で計算されるので、runA1 はそのような文脈の下でしか呼ぶことができません。
そのため、runA を呼び出している doA にも _config という文脈の指定が必要になります。同様にして、program にも同様に文脈の指定が必要となります。
import cats.data._
import org.atnos.eff._
import org.atnos.eff.reader._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R
def program[R:_config]: Eff[R, C] = for {
a <- doA
b <- doB
c <- doC(a, b)
} yield c
def doA[R:_config]: Eff[R, A] = for {
x <- runA1
y <- runA2(x)
} yield y
...
def runA1[R:_config]: Eff[R, A1] = for {
config <- ask
} yield original_runA1(config)
...
}
runB1, runB2, doC も計算に config を利用するので、結果としてプログラムは以下のようになります。
import cats.data._
import org.atnos.eff._
import org.atnos.eff.eff._
import org.atnos.eff.reader._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R
def program[R:_config]: Eff[R, C] = for {
a <- doA
b <- doB
c <- doC(a, b)
} yield c
def doA[R:_config]: Eff[R, A] = for {
x <- runA1
y <- runA2(x)
} yield y
def doB[R:_config]: Eff[R, B] = for {
x <- runB1
y <- runB2(x)
} yield y
def doC[R:_config](a: A, b: B): Eff[R, C] = for {
config <- ask
} yield original_doC(a, b, config)
def runA1[R:_config]: Eff[R, A1] = for {
config <- ask
} yield original_runA1(config)
def runA2[R](x: A1): Eff[R, A] = pure(original_runA2(x))
def runB1[R:_config]: Eff[R, B1] = for {
config <- ask
} yield original_runB1(config)
def runB2[R:_config](x: B1): Eff[R, B] = for {
config <- ask
} yield original_runB2(x, config)
}
このプログラムを見てわかると思いますが、program, doA, doB のコードはほとんど何も変わっていません。config に関係するコードは config を実際に使っている部分以外にはほとんど出てこないようになっています。これが、config を暗黙的な文脈として扱う Eff の効果です。プログラムのロジックが明確だと思いませんか?
このプログラムは当然ですが、config オブジェクトを実際に与えないと実行できません。config オブジェクトを与えて実行するには以下のようにします。
import org.atnos.eff._
import org.atnos.eff.syntax.eff._
import org.atnos.eff.syntax.reader._
val x: C = Program.program[Fx.fx1[ConfigReader]].runReader(config).run
...
runReader は Reader 文脈を持つ Eff を Reader 文脈を1つ剥がした Eff に変換する関数です(import org.atnos.eff.reader._
が必要)。
run 関数で Eff を普通の値に変換するためには、文脈を全て剥がす必要があります。
Fx.fx1[ConfigReader] は、Eff の実際の文脈を指定しており、ここでは ConfigReader ただ1つを文脈として持つことを指定しています。
def program[R:_config]
の方の文脈指定とは意味が異なることに注意してください。[R:_config]
の方は "少なくとも config を文脈に含む" ことを指定しています。
失敗するかもしれないという文脈
では更に、実は A2 及び B1 は失敗する (nullを返す) かもしれず、失敗した場合は残りの処理をスキップしたい場合にも対応しましょう。
失敗するかもしれないという文脈は org.atnos.eff.option._
に用意されている _option を使うことで表現できます。
これを使って runA2 は以下のように書くことができます。
import org.atnos.eff._
import org.atnos.eff.option._
object Program {
...
def runA2[R:_option](x: A1): Eff[R, A] = {
fromOption(Option(original_runA2(x)))
}
...
}
original_runA2 は null を返すかもしれないので、まず Option で包むことで Option 型に変換しています。
これを更に fromOption 関数により、"失敗するかもしれないという文脈を持つEff" に変換します。
こうすることで、"失敗するかもしれない"という情報を文脈として暗黙的に持たせることができます。
"失敗するかもしれないという文脈"の下であることは型情報に明示的に示されます。
それが型引数の宣言部分[R:_option]
です。
これがついている場合、前の計算が失敗すると自動的に後ろの計算がスキップされます。
同様に runB1 も _option を使って書くことができます。
また runA2, runB1 が失敗するかもしれないので、推移的に doA, doB, program にも _option 指定がつきます。
import cats.data._
import org.atnos.eff._
import org.atnos.eff.option._
import org.atnos.eff.reader._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R
def program[R:_config :_option]: Eff[R, C] = for {
a <- doA
b <- doB
c <- doC(a, b)
} yield c
def doA[R:_config :_option]: Eff[R, A] = for {
x <- runA1
y <- runA2(x)
} yield y
def doB[R:_config :_option]: Eff[R, B] = for {
x <- runB1
y <- runB2(x)
} yield y
def doC[R:_config](a: A, b: B): Eff[R, C] = for {
config <- ask
} yield original_doC(a, b, config)
def runA1[R:_config]: Eff[R, A1] = for {
config <- ask
} yield original_runA1(config)
def runA2[R:_option](x: A1): Eff[R, A] = {
fromOption(Option(original_runA2(x)))
}
def runB1[R:_config :_option]: Eff[R, B1] = for {
config <- ask
x <- fromOption(Option(original_runB1(config)))
} yield x
def runB2[R:_config](x: B1): Eff[R, B] = for {
config <- ask
} yield original_runB2(x, config)
}
runA2 と runB1 以外ほとんど変わっていませんね。
A して, B して, それらの結果を使って C するという program 全体が簡単に見て取れると思います。
実行方法は以下のようになります。
import org.atnos.eff._
import org.atnos.eff.syntax.eff._
import org.atnos.eff.syntax.option._
import org.atnos.eff.syntax.reader._
// 長いので2文に分けました
val prog = Program.program[Fx.fx2[ConfigReader, Option]]
val x: Option[C] = prog.runReader(config).runOption.run
...
やっていることは簡単で、実際の文脈を指定して、文脈を1つづつ剥がしているだけです。
runOption は "失敗するかもしれないという文脈" を剥がして Option 型に変換する関数です(import org.atnos.eff.syntax.option._
が必要)。
読み書き可能な状態を持つという文脈
最後に、A1 と A2 と B2 が共通の状態 cache を参照・書き換えながら処理をする場合にも対応してみましょう。
Reader は "read-only な値を持つ文脈" の表現に使いましたが、"read/write 可能な値を持つ文脈" の表現には State を使います (import cats.data._
が必要)。
これを使って runA1 は以下のように書くことができます。
import cats.data._
import org.atnos.eff._
import org.atnos.eff.reader._
import org.atnos.eff.state._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R
type CacheState[T] = State[Cache, T]
type _cache[R] = CacheState |= R
...
def runA1[R:_config :_cache]: Eff[R, A1] = for {
config <- ask
cache <- get
(x, c2) = original_runA1(config, cache) // Eff ではなく普通の値なので <- ではなく =
_ <- put(c2)
} yield x
...
}
get は for-yield 式の中で使うことができる関数で、"read/write 可能な値を持つ文脈" から値を取り出すというものです(import org.atnos.eff.state._
が必要)。
また、put はその逆で、"read/write 可能な値を持つ文脈" に値を書き込むことを表します(同じくimport org.atnos.eff.state._
が必要)。
これらにより、cache を読んで、その値を使った後、cache を更新するという挙動を表現できます。
つまり runA1 のコードのロジックは、read-only な値 config を読み出し、変数 cache を取り出して、それらの値から original_runA1 を計算し、その結果(c2)によってキャッシュを更新するというものになっています。簡単ですよね?
get や put は "read/write 可能な値を持つ文脈" の下でしか使うことができません。この関数では [R:_config :_cache]
とすることで、"read/write 可能な値 cache を持つ文脈" の下で計算することを示しています。
A2 と B2 も同様にして記述できます。
また、runA1, runA2, runB2 は "read/write 可能な値 cache を持つ文脈" の下でしか呼ぶことができないので、推移的に doA, doB, program も同様の文脈の下で計算しなければなりません。
import cats.data._
import org.atnos.eff._
import org.atnos.eff.option._
import org.atnos.eff.reader._
import org.atnos.eff.state._
object Program {
type ConfigReader[T] = Reader[Config, T]
type _config[R] = ConfigReader |= R
type CacheState[T] = State[Cache, T]
type _cache[R] = CacheState |= R
def program[R:_config :_option :_cache]: Eff[R, C] = for {
a <- doA
b <- doB
c <- doC(a, b)
} yield c
def doA[R:_config :_option :_cache]: Eff[R, A] = for {
x <- runA1
y <- runA2(x)
} yield y
def doB[R:_config :_option :_cache]: Eff[R, B] = for {
x <- runB1
y <- runB2(x)
} yield y
def doC[R:_config](a: A, b: B): Eff[R, C] = for {
config <- ask
} yield original_doC(a, b, config)
def runA1[R:_config :_cache]: Eff[R, A1] = for {
config <- ask
cache <- get
(x, c2) = original_runA1(config, cache)
_ <- put(c2)
} yield x
def runA2[R:_option :_cache](x: A1): Eff[R, A] = for {
cache <- get
(y, c2) <- fromOption(Option(original_runA2(x, cache)))
_ <- put(c2)
} yield y
def runB1[R:_config :_option]: Eff[R, B1] = for {
config <- ask
x <- fromOption(Option(original_runB1(config)))
} yield x
def runB2[R:_config :_cache](x: B1): Eff[R, B] = for {
config <- ask
cache <- get
(y, c2) = original_runB2(x, config, cache)
_ <- put(c2)
} yield y
}
runA1, runA2, runB2 以外はほとんど変わっていません。
このように、Eff を使うことで文脈という形でいろいろなものを表現できるので、変数の引き渡しのようなあまり本質的でないコードを除去した綺麗なコードを書くことができるようになります。
このプログラムは以下のように実行します。
import org.atnos.eff._
import org.atnos.eff.syntax.eff._
import org.atnos.eff.syntax.option._
import org.atnos.eff.syntax.reader._
import org.atnos.eff.syntax.state._
// 長いので2文に分けました
val prog = Program.program[Fx.fx3[ConfigReader, Option, CacheState]]
val x: (Option[C], Cache) = prog.runReader(config).runOption.runState(cache).run
...
State ベースの文脈は runState で剥がします (import org.atnos.eff.syntax.state._
が必要)。
少し面白いのは、文脈を剥がす順番によって結果の型が変わることです。
例えば runState してから runOption すると、結果の型は Option[(C, Cache)]
になります。
runState は結果の型と最終的な変数の値のペアを帰し、runOption は結果の型の Option 版を返すため、順番によって型が変わります。
もっと学びたい方へ
eff ライブラリは ドキュメント に例がそこそこ載っているので見てみると良いと思います。
Reader や State といった "文脈" を表現するものに興味がある場合は、モナドについて調べると良いかもしれません。
Extensible effects の実装方法については、日本語資料としては @halcat0x15a さんの ここ または ここ がよくまとまっています(が多くの人には難しいと思います)。
最後に
初心者の方は、ここがわかりづらかったという点があれば言ってください。できる範囲で対応します。
上級者の方は、こうすればもっとわかりやすくなるのでは?といった意見があれば是非教えて下さい。