effect-tsのチュートリアルをやっていく
Importing Effect
Functions vs Methods
関数を使った方が、Tree Shakingの面と拡張性の面で有利である、とのこと。
- メソッドを使った場合には、利用しないメソッドまで読み込まれてしまう
拡張性の面は、今一つ記述が簡潔すぎて、厳密な理解ができないが、
おそらくは、メソッドの場合、暗黙的にそのクラスのメンバ変数を操作する場合が多いので拡張したくなった場合に、色々考えなければならいことが多くなりがちだし、理解や方法を間違うと後々面倒なことになる、ということだと考えられる。
これはあくまでも、effectの設計方針なのだが、一般的な視点として参考になる。
The Effect Type
Effect型は、イミュータブルな値で遅延評価される。
type Effect<Success, Error, Requirements> =
(context: Context<Requirements>) => Error | Success
- Successをvoidにすると、値が何も生成されない、neverにすると終わらない
- Errorをneverにするとエラーにならない
- Requirementsをneverにすると、Context コレクションが空になる
理想的には、単一のエントリポイントで行うようにする。
副作用を実行する箇所を1カ所にまとめる。
Creating Effects
Effectは、副作用をカプセル化する計算単位である"エフェクト"を作成するための様々な方法を提供するもの。
全体的に関数型プログラミングの用語に馴染みがないと何を言っているのかなりわかりづらいかもしれない。
Why Not Throw Errors?
一般的なプログラムだとエラーがあったら、例外を投げるが、なぜ例外を投げないのか?
端的に表現すると、
- 関数の型シグネチャと違う動きをすることになるから
関数の型シグネチャが、numberである場合、その関数のクライアントはnumber型の結果が返ってくることを期待している。しかし、その関数の中で例外を投げると、その関数は結果を返さない。それだけでなく、その例外をキャッチしている部分に処理が飛んで行ってしまう。
これだと、プログラムを読んだときに、プログラムがどう動くのかを理解しづらいので、関数シグネチャ通りに動くようにしましょう、ということ。
sync,try
同期の副作用を遅延実行できるようにする。
promise, tryPromise
非同期の副作用(promise)を扱いやすくする。
promise自体が非同期の副作用を扱いやすくしたものであるが、エラーが発生する場合、必ずしも扱いやすいわけではないため、それを改善する。
From a callback
コールバックが必要なタイプの古い?APIには、asyncを利用する
Suspended Effects
Lazy Evaluation
import { Effect } from "effect"
let i = 0
const bad = Effect.succeed(i++)
const good = Effect.suspend(() => Effect.succeed(i++))
console.log(Effect.runSync(bad)) // Output: 0
console.log(Effect.runSync(bad)) // Output: 0
console.log(Effect.runSync(good)) // Output: 1
console.log(Effect.runSync(good)) // Output: 2
badは、以下と等価で、紐づけした後にiの値が増える
const bad = Effect.succeed(0)
goodは、クロージャになっていて、変数が関数の内部に抱えられているので値がインクリメントされる。
Handling Circular Dependencies
import { Effect } from "effect"
const blowsUp = (n: number): Effect.Effect<number> =>
n < 2
? Effect.succeed(1)
: Effect.zipWith(blowsUp(n - 1), blowsUp(n - 2), (a, b) => a + b)
// console.log(Effect.runSync(blowsUp(32))) // crash: JavaScript heap out of memory
const allGood = (n: number): Effect.Effect<number> =>
n < 2
? Effect.succeed(1)
: Effect.zipWith(
Effect.suspend(() => allGood(n - 1)),
Effect.suspend(() => allGood(n - 2)),
(a, b) => a + b
)
console.log(Effect.runSync(allGood(32))) // Output: 3524578
- blowsUpの方は、再帰を展開している最中にすべてがヒープに展開されるのでメモリが不足する
- allGoodの方は、実際に1つ1つの計算行う段階で初めて展開されるので、メモリを圧迫しない
ということのようです。
Unifying Return Type
import { Effect } from "effect"
const ugly = (a: number, b: number) =>
b === 0
? Effect.fail(new Error("Cannot divide by zero"))
: Effect.succeed(a / b)
const nice = (a: number, b: number) =>
Effect.suspend(() =>
b === 0
? Effect.fail(new Error("Cannot divide by zero"))
: Effect.succeed(a / b)
)
- ugly:union型が返されてしまう
- nice:2つの型が合成された型が返される
関数型初心者の自分は、これが一番使いそう。
Cheatsheet
Running Effects
runSync
非同期の処理は実行できないので注意!(例外をスローする)
runSyncExit
同じく同期実行を行うが、ワークフローの実行結果を返す。
ワークフローの結果情報のことを一般にExitというらしい…。
初めて知りました。
runPromise
Effectを実行し、結果をPromiseとして受け取る。
runPromiseExit
runSyncExitの結果の値がPromiseのもの
runFork
同期実行やPromiseを使うなどがない場合、これを使うことが推奨される。
必要に応じて、観察、中断できるfiberが返される。
現時点では、いまいち何がうれしいのかよくわからない。
Cheatsheet
Using Generators in Effect
HaskellのDoブロックみたいなものが、Effect.gen
とJavaScriptのジェネレータの構文を使って実現できるよう。
Understanding Effect.gen
Effect.gen
を使うときに従うべき要点
- ロジックを
Effect.gen
で包む - effectを扱うために、
yield*
を使う - 最終結果を返す
Comparing Effect.gen with async/await
Promiseとasync/awaitを使った場合とコードが似ている、という話。
ただ、Promiseとasync/awaitを使った場合だと、エラーハンドリングを組み込むのが面倒そう。
Embracing Control Flow
Effect.gen
を使うことの大きなメリットの1つは、制御文を使えること。ということだが、関数型プログラミングの観点からは、文ではなく、式で物事を表現したい気が。
Raising Errors
制御の中でエラーを扱うことができる、という説明。エラーを扱うために、Effectを使っているのだから、当たり前な気がする。
The Role of Short-Circuiting
エラーが発生した場合には、その場で即時に処理が中断される。
なんかこの説明だと、例外を発生させるように思えるけど、内部的には、Railway Oriented Programmingだと思う。
Passing this
自分自身への参照(this)を第一引数として渡すこともできる。
※できれば、クラスは使いたくない(個人の感想です)
Building Pipelines
関数型プログラミングではおなじみのパイプラインやmapなど
Why Pipelines are Good for Structuring Your Application
なぜパイプラインを使った方がよいか
- 可読性
- コードの構成
- 再利用性
- 型安全性
が向上するから。
全くその通りなのだが、なぜそうなるのかは、Railway Oriented Programmingを理解しておいた方がよいと思う。
pipe
ここには書かれていないのでわからないが、エラーハンドリングもやってくれるものと思われる。つまり、パイプラインの先頭や中ごろにある関数がエラーを出した場合、処理は中断され結果はエラーとなる(パイプライン内のすべての関数の入出力がEffect型で行われている必要があるが)。
この前提がないと、今一つpipeの良さが伝わらない気がする。これができるので、関数1つずつの後に、エラーが起きたかどうかのif文を書かなくてよく、ただ関数をならべればよい。
処理の見通しがよくなり、パイプライン内の追加、変更、削除が容易になる。
map
不親切というか、このくらいわかれ、ということなのでしょう(サンプルをしっかり理解すればわかる)。
関数をとって、関数を適用する、という説明だけれども、これだと普通に関数を適用すればよく、なぜわざわざmapを使うのかがわからない。
このmapはいわゆる関数型プログラミングのEither型のmapに相当するもの、ということ。
例えば、Effect<Success, Error>
の値があるけど、Success
型の引数を取り、Effect
型でない通常の値型を返す関数を適用したい、というような場合にmapを使うと便利、ということ。
処理が成功していれば、普通に関数を適用し、結果を再度Effect
型へ詰めて返してくれるし、処理が失敗していた場合には、関数は適用されず、スルーされる。
as
mapの特殊なもの、と考えればよいと思います。
flatMap
mapで適用したい関数が、Effect
型を返す場合、結果がEffect<Effect<>>
型になってしまってこまるので、利用する。
本当はもっといろいろあるのだろうが、ここではこれだけ。
andThen
mapとflatMapの使い分けが面倒なので、与えられた関数に応じていい感じにしてくれる関数。
可読性という点で、どうなんだろう。
tap
パイプラインに戻り値がvoidの関数を挟んで、次の関数にはtap
の前の値を連携する。
ログやその他の副作用や別の処理をパイプラインの途中に挟みたいときに利用する。
デバッグの時に便利そう。
all
複数のEffectをtupleやiterableやstructやrecordにできるらしい。
The pipe method
あらゆるEffectからメソッド形式で、pipeが呼び出せる。
関数形式でいいかな、と思う。