この記事はちょっと愚痴っぽいです。 ←Promiseが分かりにくいと思った編集者のメモ書きなので、内容が不正確な場合があります。 |
JavaScript(ECMAScript)におけるPromiseとは、非同期処理の約束された未来成功または失敗を表すオブジェクトである。
といわれてもよくわからなかった編集者が、悩んだ点について、にわか仕込みの知識で述べる。分かっている人には当たり前のことかもしれないが、分かっていない人にとって非直感的で躓くのではないかというポイントを重点的に述べるつもりなので、通常とは説明の仕方がずれていたとしてもそれは仕様です。
概要
通常プログラムは上から順番(同期的: synchronous)に実行される。しかし非同期(: asynchronous)処理は上から順番に実行されるとは限らない。
これを無理やり制御しようとするとCallback Hellと呼ばれる深いネストが出来上がるが、ここでは触れない。
Promiseは非同期処理を実現するオブジェクトと紹介されるが、実際には非同期処理を実現するのはsetTimeout()のような処理であって、Promiseは実行順序の制御をしているに過ぎない。
Promiseの生成
オブジェクトインスタンスの生成と言うと、コンストラクタが定番だが、Promiseは
- 他の関数・メソッドの戻り値
- Promise.then()(他にはcatch, all, race)の戻り値
- ファクトリメソッドPromise.resolve()(またはPromise.reject())の戻り値
- コンストラクタによる生成
などにより生成される。コンストラクタによる生成が、thenによる生成と挙動が異なることが、初見では非直感的に思えるかもしれない。
Promiseの時間軸
たいていの説明ではPromiseの内部の時間軸に焦点をあてて解説するのだが、そのせいで外部との関係が曖昧になっているように思う。
↓前の処理
↓→Promiseの処理→
↓後の処理
Promiseのコンストラクタの説明に、new Promise()は内部の関数がresolve()されるまでPromiseの値が返らないというような説明をしていることがあるが、以下で述べる.then()の引数になる処理が呼ばれないという意味であり、new Promise()の値が返ってから「後の処理」が行われるという意味ではない。
new Promise()の内部の関数処理は別の世界線時間軸に移動し、「後の処理」はPromiseの処理の進行状況とは無関係に進んでいく。
Promiseの基本的な使い方
たいていの解説ではここでコンストラクタを出してくるが、Promiseのコンストラクタは、2つの関数を引数に取る関数を引数に取る関数という禍々しいものであるため、後回しにする。例外処理もできるのだが、本質でないので触れない。
Promise.resolve(初期値)
.then(function(初期値を受ける引数){処理1})
.then(function(処理1の戻り値を受ける引数){処理2})
.then(function(処理2の戻り値を受ける引数){処理3});
処理1、処理2、処理3が順番に実行されることが保証される。
then()の挙動
このthen()というメソッドは、引数になる関数がPromiseを返す関数かどうかで挙動が変わるという気持ち悪い動的型付け言語ならではの挙動になっている。本来2種類のメソッドであるべきところを動的型付けであるのをいいことに無理矢理1本化していることが、Promise.then()をさらにわかりにくくしているように思う。
また、コンストラクタとはとる引数の種類も違う。本当はエラー処理させる関数も含めて2つ引数を取ることができるが、本質でないので触れない。
引数となる関数の返す値がPromiseでない場合
引数となる関数の返す値がPromiseでない場合は、コンストラクタではresolveが呼ばれるまで待機させる方法があるが、then()自体は終了を待機させる手段を持たない。
引数となる関数の返す値がPromiseの場合
引数となる関数の返す値がPromiseの場合は、返す値のPromiseが待機状態の間は、then()の戻り値であるPromiseも待機状態となる。
then()は呼び出し元と別のインスタンスを返す
then()が返すPromiseオブジェクトは、then()を呼び出したPromiseとは別のインスタンスである。
then()の後に何種類かの処理を同時にスタートさせたい場面を考えれば、必然的な実装であると言える。同時にスタートさせた処理の両方の値が揃うのを待つ、all()というメソッドがある。ちなみに、いずれかの処理が終わるのを待つメソッドはrace()である。
const p0 = Promise.resolve();
const p1 = p1.then(処理1);
const p2 = p1.then(処理2);
Promise.all([p1, p2]).then(処理1と処理2の結果を必要とする処理);
Promiseは値が取り出せない
Promiseのインスタンスは内部に.thenの引数となる関数に渡されるべき値を持っているが、この値をPromiseの外部で使う手段は提供されていない。値を使いたければ、then()の引数に値を使う関数を入れるしか無い。
内部で外部の変数に代入すれば持ち出せることは持ち出せるが、Promiseの内部と外部で時間の流れを同期させる方法がないため推奨されない?
Promiseは状態を持つ
の3つの状態を取りうるが、コード上にはそれが反映されない。
Promiseのコンストラクタ
Promiseのコンストラクタは、2つの関数を引数に取る関数を引数に取る関数という禍々しい高階関数であるが、あまり問題視されないのは、言語仕様だから仕方ないPromiseは通常コンストラクタで自作するものではなく、他のライブラリ関数から戻り値として渡されるものだかららしい。
new Promise(function(resolve, reject) {
resolve()を呼ぶ処理
});
----------------------------------------------------
// 説明のために以下のように分けて書く。
const executor = function(resolve, reject) {
resolve()を呼ぶ処理
};
new Promise(executor);
- resolveという関数はPromiseのコンストラクタ内でプログラマーからは見えないところに定義されている。
- コンストラクタの引数となる関数executorは、resolveとrejectを引数にとる必要があり、自分のやりたいことだけを記述するという訳にはいかない。(たとえば既に定義したa()という処理をしたい場合はfunction(resolve, reject){a();resolve();} とラップした関数定義をする必要がある。)
- コンストラクタの引数となる関数executorに渡される引数resolveもまた関数である。(resolveは1階関数、executorは2階の高階関数、Promiseのコンストラクタは関数型プログラミングですら馴染みの薄い3階の高階関数である。)
- executorはresolve以外にrejectという関数も引数に取るので、理解しようとする時にrejectのことも同時に考えないといけない。
- 結果としてresolveとrejectの一方しか使われない。
- resolve()を呼んだ後もexecutorは処理を続けることが可能。
- executorがresolveを呼ばずに正常終了したらどうなるのか(: Promiseオブジェクトは実行中のままとなり、then()をつなげても処理は実行されない。これは、executorが関数であるという以外に、内部で必ずresolve()を実行する(もしくは例外を投げる)という制約を負っているということであり、型付けなどでも制御できない契約プログラミングであると言える。)
- executorがresolveを呼んだ後に例外を出したりしたらどうなるのか(: "pending"の状態に戻ることはないと仕様書で規定されているので、例外は無視される。)
- JavaScriptは基本的にはシングルスレッドなので、setTimeout()のようにCPUを占有せずに時間だけが経過するような処理でなければ、また違った挙動を示す。
といったあたりだろうか(多すぎ)。
Promiseの応用的な使い方?
これでは実行順序の制御が出来ないかのように見えるが、以下のようにするとPromiseの外部との間に順序をつけることができる。
const promise1 = new Promise(略);
const promise2 = promise1.then(function(引数){処理1});
処理2
promise2.then(function(引数){処理3});
処理4
このやりかたが公式に推奨されるかどうかは知らない。
ただ、これで処理2が処理3より前に実行されることだけは保証される。
ただし、処理3と処理4の前後関係はthen()の内部はPromiseの時間軸で処理が進んでいくため、やはり前後関係が保証されない。
処理1が終わっていなくてもpromise2に処理3が追加されて処理4に進んでいくため、処理1が処理4よりも先に終了する保証もない。
Promiseはモナドである
Promiseはモナドになっているのだが、あまりそのことについて考えてもメリットはない。モナドについて調べたことがあるのでせっかくだから書いておくが、上述したこととも一部重複しているし、モナドを知らないのならここは読み飛ばしたほうが身のためである。
unit()
unit()の関数の性質についてはモナド(プログラミング)を参照のこと。
多くのモナドではコンストラクタが、unit()に相当することが多いが、Promiseではコンストラクタはunit()ではない。Promiseが非直感的である理由の一つではないかと思う。
unit()に相当するのは(基本的には)Promise.resolve()である。
Promise.reject()もunit()に相当するが、後述のように、これはPromiseが処理が失敗した場合という、通常の値では表現できない状態まで表現対象に含めていることに起因する。
flatMap()
flatMap()の関数の性質についてはモナド(プログラミング)を参照のこと。
flatMap()に相当するのがthen()なのだが、then()が引数に取る関数が、flatMap()は一つだけなのに対し、then()はthen()呼ぶPromiseが成功していた場合と失敗していた場合2つの関数を引数に取る。一つの関数が内部でifを使って分岐していると思えば一つの関数かもしれない。
しかもthen()は、引数となる関数の戻り値がPromiseであるかどうかによってflatMap()になったりmap()になったりと挙動不審である。
失敗を扱うという点がMaybeモナドに似たところがなくもない。
値が取り出せない
Promiseの内部で行った計算結果はPromiseの外には持ち出せない。値を取り出せるモナドも多いが、モナドでは本来値を取り出せることは保証されないものである。
モナドの性質という観点からすると
モナドに意味を求めるべきかどうかという問題はあるが、モナドの性質という点から捉えると、
- Promiseは、「計算・処理が終了しているかいないか」「計算・処理が成功したか失敗したか」ということと「計算結果」を分離する。
- 終了していない可能性を分離したことにより(あるいは分離するために)、flatMap()は前の計算の終了を待って実行されるようになった。
- 成功した可能性と、失敗した可能性を分離したことにより(あるいは分離するために)、flatMap()に必要な引数は、成功した時用と、失敗した時用の2つを用意することになった(Promise.resolve()以外にPromise.reject()があることもこのことによる)。
ということだろうか
Haskellでこんなもの聞かなったんだが
モナドと言えばHaskellだが、Haskellではこの種類のモナドはあまり使用されない。
Haskellはデフォルトで遅延評価な言語なので、もともと実行順序が制御できない。このような方法で実行順序を制御する必要はないのでPromiseに相当するモナドは重視されない。それでも実行順序の制御が必要な場合はdo記法がある。
関連項目
- JavaScript(ECMAScript)
- モナド(プログラミング)
- 並行処理
- コルーチン
- プログラミング関連用語の一覧
- Promise
- 約束された未来
- 約束された勝利の剣
外部リンク
親記事
子記事
- なし
兄弟記事
- 2
- 0pt