LoginSignup
134
121

More than 5 years have passed since last update.

日本語のコードで理解するPromise

Last updated at Posted at 2016-07-29

コンセプト

  • 実際に動かせる,日本語を使ったコードを例示する
  • スガキヤのラーメンはうまい

Promiseって何?

ラーメンができたときに鳴るスガキヤの呼び出しベルです.

maxresdefault.jpg

  1. ラーメンのお会計をする
  2. 呼び出しベルを受け取る
    • この呼び出しベルは 客に出来上がったラーメンを渡す約束 (Promise) そのものを表す
    • この約束があってはじめて客はその後で (then) ラーメンを受け取るという予定を立てることができる
    • 約束の後に予定を生やすというイメージ
  3. この後,ラーメンが準備できしだい,以下のイベントが実行される
    • 店は客に出来上がったラーメンを渡す
    • 客はラーメンを受け取る

いろいろなスガキヤと客

上でも書いたとおり

  • Promise「呼び出しベル」または「客に出来上がったラーメンを渡す約束」
  • then「約束した内容であるラーメンが準備できたその後で」

という日本語に読み替えられます.これを踏まえて,以下に示すコードを読みましょう.

一瞬でラーメンが完成するスガキヤ

ラーメンを注文して普通に食べます.console.log関数を利用して,食べ終わったことを「Webブラウザのコンソール」あるいは「Node.jsを起動しているターミナル」に出力してみましょう.しかしこの例では,呼び出しベルを渡されたと思ったら席に戻る間も無く一瞬で回収されてしまいます.

function 食べる(食べ物) {
    console.log('食べた: ' + 食べ物);
}

function お会計(商品名) {
    console.log('お会計: ' + 商品名);
    return new Promise(function (客を呼び出して渡す) {
        console.log('できた: ' + 商品名);
        客を呼び出して渡す(商品名);
    });
}

お会計('ラーメン').then(function (渡されたもの) {
    食べる(渡されたもの);
});

ラーメンが完成するまでに2秒かかるスガキヤ

ただ,実際はそんなに手際のよいスガキヤは存在しません.せいぜい3分ぐらいはかかるでしょう.しかし3分も待っていたら時間がもったいないので,ここでは最新の技術を駆使して2秒で作らせることにします.一定時間後にある処理を実行させるためにはsetTimeout関数を使います.

function 試験勉強する(科目名) {
    if (0 > 1) {
        console.log('勉強した: ' + 科目名);
    } else {
        console.log('Twitterをした');        
    }
}

function 食べる(食べ物) {
    console.log('食べた: ' + 食べ物);
}

function お会計(商品名) {
    console.log('お会計: ' + 商品名);
    return new Promise(function (客を呼び出して渡す) {
        setTimeout(function () {
            console.log('できた: ' + 商品名);
            客を呼び出して渡す(商品名);
        }, 2000);
    });
}

お会計('ラーメン').then(function (渡されたもの) {
    食べる(渡されたもの);
});

試験勉強する('英語');

呼び出しベルを使っているので,どれだけかかろうがなんだろうが,レジの前で延々と待たされることはありません.準備が出来次第いい感じに呼び出してくれるので,その間の時間をたった2秒でも有効活用することができます.

ラーメンの後にソフトクリームを貪るデブ

また,ソフトクリームをラーメンと一緒に注文すると溶けてしまうので,ラーメンを食べ終わってからソフトクリームを注文するという処理も可能です.更にソフトクリームを食べ終わったらもう1回おかわりするとしましょう.

お会計('ラーメン').then(function (渡されたもの) {
    食べる(渡されたもの);
    お会計('ソフトクリーム').then(function (渡されたもの) {
        食べる(渡されたもの);
        お会計('ソフトクリーム(おかわり)').then(function (渡されたもの) {
            食べる(渡されたもの);
        });
    });
});

これは,以下のように書くこともできます.こちらのほうがコードが縦に続く感じで読みやすいんじゃないでしょうか?

お会計('ラーメン').then(function (渡されたもの) {
    食べる(渡されたもの);
    return お会計('ソフトクリーム');
}).then(function (渡されたもの) {
    食べる(渡されたもの);
    return お会計('ソフトクリーム(おかわり)');
}).then(function (渡されたもの) {
    食べる(渡されたもの);
});

「その後で」の中で新たな約束returnして次に繋げることができるんです!

(番外編) ムーディ勝山 その1

目的がパッと思いつきませんが,今後のための説明も兼ねて,ただ右からきたものを左へ受け流すだけのPromiseを使った処理を書いてみます.

new Promise を律儀に書くパターン
new Promise(function (左へ受け流す) {
    左へ受け流す('何か');
})
.then(function (右からきたもの) {
    return new Promise(function (左へ受け流す) { 左へ受け流す(右からきたもの); });
})
.then(function (右からきたもの) {
    return new Promise(function (左へ受け流す) { 左へ受け流す(右からきたもの); });
})
.then(function (右からきたもの) {
    console.log('右から 右から ' + 右からきたもの + 'がきてる');
});

「形式的にPromiseを使うが,すぐに受け流すだけで時間のかかる処理をしない」というときのために,より簡単な書き方が用意されています.Promise.resolveという関数を使うパターン,そしてそれすら省略できてしまうパターンがあります.上に書いたものは下に書いたものと完全に等価です.

Promise.resolve で省略するパターン
Promise.resolve('何か')
.then(function (右からきたもの) {
    return Promise.resolve(右からきたもの);
})
.then(function (右からきたもの) {
    return Promise.resolve(右からきたもの);
})
.then(function (右からきたもの) {
    console.log('右から 右から ' + 右からきたもの + 'がきてる');
});
then の中は Promise.resolve すら省略して return だけにするパターン
Promise.resolve('何か')
.then(function (右からきたもの) {
    return 右からきたもの;
})
.then(function (右からきたもの) {
    return 右からきたもの;
})
.then(function (右からきたもの) {
    console.log('右から 右から ' + 右からきたもの + 'がきてる');
});

returnを書かないと何も受け流せませんが,thenの返り値は常にPromiseであることが保証されているので,以下のように書いても最後のconsole.logは実行されます.ただし,undefinedが受け流されたことになってしまいます.

何かに完全敗北したムーディ勝山君UC
Promise.resolve('何か')
.then(function (右からきたもの) {
})
.then(function (右からきたもの) {
})
.then(function (右からきたもの) {
    console.log('右から 右から ' + 右からきたもの + 'がきてる');
});

ラーメンとソフトクリームを別々に注文した挙句後から「2つともできたときに一緒に渡して!」とか言ってくるめんどくさい客

そんな面倒くさい注文に対応する方法もあります.Promise.allという関数を使います.これは

  1. 呼び出しベルの配列をまとめて受け付ける
  2. 全部が準備できるまで待つ
  3. 呼び出しベルの配列食べ物の配列に入れ替えて客に渡す

という動きをします.

Promise.all([
    お会計('ラーメン'),
    お会計('ソフトクリーム'),
]).then(function (渡されたもの一覧) {
    console.log('両方一気にいただきまーす');
    console.log(渡されたもの一覧);
});

注文を受理して2秒後に品切れに気づいたスガキヤ店員

さて,ここまで順調にラーメンを提供できていましたが,半額キャンペーンなんかやってる日には品切れになってしまうこともあります.店員が後で気づいたとき,どうすればいいでしょうか?

ここまで,new Promise(...)の部分に書いている関数は,「客を呼び出して渡す」という引数しかとっていませんでした.実はもう1つ「客を呼び出して言い訳する」という引数をとることができるんです!

function 食べる(食べ物) {
    console.log('食べた: ' + 食べ物);
}

function お会計(商品名) {
    console.log('お会計: ' + 商品名);
    return new Promise(function (客を呼び出して渡す, 客を呼び出して言い訳する) {
        setTimeout(function () {
            console.log('やばい!品切れ: ' + 商品名);
            客を呼び出して言い訳する(new Error(商品名 + 'は品切れでした…'));
        }, 2000);
    });
}

では客の方はどうでしょうか?そのままでは,注文が失敗したときには「シーン」として何も起こりません.店員の言い訳を聞くためには,以下のどちらかの書き方をする必要があります.

thenに含める
お会計('ラーメン').then(
    function (渡されたもの) {
        食べる(渡されたもの);
    },
    function (言い訳) {
        console.error('店員の言い訳: ' + 言い訳.message);
    }
);
catchを使う
お会計('ラーメン')
.then(function (渡されたもの) {
    食べる(渡されたもの);
})
.catch(function (言い訳) {
    console.error('店員の言い訳: ' + 言い訳.message);
});

catchを使う方法は,以下と等価です.

お会計('ラーメン')
.then(function (渡されたもの) {
    食べる(渡されたもの);
})
.then(undefined, function (言い訳) {
    console.error('店員の言い訳: ' + 言い訳.message);
});

但し,このcatchメソッドはtry~catch構文で用いられるcatchキーワードとは無関係なので注意してください.

try {
    throw new Error('ラーメンは品切れでした…');
} catch (言い訳) {
    console.error('店員の言い訳: ' + 言い訳.message);
}

(番外編) ムーディ勝山 その2

ムーディ勝山は,正しく実行された結果をreturnして左へ受け流さないと次に繋がりませんでした.では失敗した結果に関してはどうでしょうか?

new Promise(function (左へ受け流す, 左奥へぶっ飛ばす) {
    左奥へぶっ飛ばす('何か');
})
.then(function (右からきたもの) {
})
.then(function (右からきたもの) {
})
.then(
    function (右からきたもの) {
        console.log('右から 右から ' + 右からきたもの + 'がきてる');
    },
    function (右奥からぶっ飛んできたもの) {
        console.error('右奥から 右奥から ' + 右奥からぶっ飛んできたもの + 'がぶっ飛んできた');
    }
);

無事,「何か」が飛んできたと思います.失敗時の処理の流れは以下のようになります.

  • thenの繋がりを伝って,最初に「右奥から」として処理される場所に流れ着く
    • 途中過程に存在する「右から」はすべて無視される
    • それ以降のthenはまた「右から」の処理になる
      (「右奥から」としてぶっ飛んできたものを片付けたPromise実行が成功しているため)

このように失敗は伝播していくので,先ほどの catch のような書き方ができるわけです.また,もう1点の補足として,Promise.resolveと対を為す関数としてPromise.rejectを紹介しておきます.

new Promise を律儀に書くパターン
new Promise(function (左へ受け流す, 左奥へぶっ飛ばす) {
    左奥へぶっ飛ばす('何か');
})
.catch(function (右奥からきたもの) {
    return new Promise(function (左へ受け流す, 左奥へぶっ飛ばす) { 左奥へぶっ飛ばす(右奥からきたもの); });
})
.catch(function (右奥からきたもの) {
    return new Promise(function (左へ受け流す, 左奥へぶっ飛ばす) { 左奥へぶっ飛ばす(右奥からきたもの); });
})
.catch(function (右奥からきたもの) {
    console.log('右奥から 右奥から ' + 右奥からきたもの + 'がぶっ飛んできた');
});

上に書いたものは下に書いたものと等価です.但し,throwに関してはどこでも認められるわけではないということを明記しておきます.これについては,余力のある人向けに最後に説明を入れます.

Promise.reject で省略するパターン
Promise.reject('何か')
.catch(function (右奥からぶっ飛んできたもの) {
    return Promise.reject(右奥からぶっ飛んできたもの);
})
.catch(function (右奥からぶっ飛んできたもの) {
    return Promise.reject(右奥からぶっ飛んできたもの);
})
.catch(function (右奥からぶっ飛んできたもの) {
    console.log('右奥から 右奥から ' + 右からぶっ飛んできたきたもの + 'がぶっ飛んできた');
});
throw で省略するパターン (※いつでも使えるわけではない)
Promise.reject('何か')
.catch(function (右奥からぶっ飛んできたもの) {
    throw 右奥からぶっ飛んできたもの;
})
.catch(function (右奥からぶっ飛んできたもの) {
    throw 右奥からぶっ飛んできたもの;
})
.catch(function (右奥からぶっ飛んできたもの) {
    console.log('右奥から 右奥から ' + 右からぶっ飛んできたきたもの + 'がぶっ飛んできた');
});

本気でPromiseを理解したい人向けの説明

setTimeoutの有無による実行タイミングの違い(?) [修正済み]

実は,ここまでで,ある重大なことを1つ誤魔化しています.これを最初から説明していると初心者門前払いになってしまうので,敢えて間違った説明をしてきたのですが,気になる方・余力のある方は是非ここを読んでください.

一番最初の「一瞬でラーメンが完成するスガキヤ」の例について考察しましょう.

function 食べる(食べ物) {
    console.log('食べた: ' + 食べ物);
}

function お会計(商品名) {
    console.log('お会計: ' + 商品名);
    return new Promise(function (客を呼び出して渡す) {
        console.log('できた: ' + 商品名);
        客を呼び出して渡す(商品名);
    });
}

お会計('ラーメン').then(function (渡されたもの) {
    食べる(渡されたもの);
});

半ば強引に進めてきたと思いますが,本気で理解しようとすると,以下のような疑問が自然とわくはずです.

Promiseを作るときに渡してる関数は何?

A
function (客を呼び出して渡す) {
    console.log('できた: ' + 商品名);
    客を呼び出して渡す(商品名);
}

これはPromise側によって直ちに実行されます.いったい何故このコードは関数で書く必要があるのでしょうか?それは, 客を呼び出して渡す という引数を受け取るためです.

客を呼び出して渡す はどこからきてるの?

B
function (渡されたもの) {
    食べる(渡されたもの);
}

この関数がまるごと引数 客を呼び出して渡す に代入され,ABを呼び出す形になります…と,言いたいのですが,実際は違います.客を呼び出して渡すの正体は,Promise側で適当に用意してくれる関数です.

なぜこんなことをするか?理由の1つは「お会計」を定義する側からは,thenが後ろに続いているかどうかを知るすべはないからです. 後ろにthenがある場合とない場合でif分岐を書かせるのも最悪なので,この理由は納得できると思います.

setTimeoutなし
実行開始: お会計('ラーメン')
実行開始: new Promise(A)  
実行開始: A(客を呼び出して渡す)
実行開始: 客を呼び出して渡す(商品名) … Promiseが用意した仮の関数

実行終了: 客を呼び出して渡す(商品名) … 渡された「商品名」を覚えておく (もし後ろにthenが無ければこいつの努力は無駄になる)
実行終了: A(客を呼び出して渡す)
実行終了: new Promise(A)
実行終了: お会計('ラーメン')

実行開始: .then(B)

実行終了: .then(B)

-------- 0ミリ秒の間 (setTimeoutで0ミリ秒待つよりも更に極めて短い遅延) --------

実行開始: ☆thenで予約された処理を実際に呼び出す関数☆
実行開始: B(渡されたもの)           … ここで覚えておいた「商品名」を「渡されたもの」として流す
実行開始: 食べる(渡されたもの)

実行終了: 食べる(渡されたもの)
実行終了: B(渡されたもの)
実行終了: ☆thenで予約された処理を実際に呼び出す関数☆

比較のために,setTimeoutありの場合も入れてみます.

setTimeoutあり
実行開始: お会計('ラーメン')
実行開始: new Promise(A)
実行開始: A(客を呼び出して渡す)
実行開始: setTimeout(★遅延実行させる関数★, 2000)

実行終了: setTimeout(★遅延実行させる関数★, 2000) … 予約が終わっただけで中身は実行されていない
実行終了: A(客を呼び出して渡す)
実行終了: new Promise(A)
実行終了: お会計('ラーメン')

実行開始: .then(B)

実行終了: .then(B)

-------- 2000ミリ秒の間 --------

実行開始: ★遅延実行させる関数★()
実行開始: 客を呼び出して渡す(商品名) … Promiseが用意した仮の関数

実行終了: 客を呼び出して渡す(商品名) … 渡された「商品名」を覚えておく (もし後ろにthenが無ければこいつの努力は無駄になる)
実行終了: ★遅延実行させる関数★()

-------- 0ミリ秒の間 (setTimeoutで0ミリ秒待つよりも更に極めて短い遅延) --------

実行開始: ☆thenで予約された処理を実際に呼び出す関数☆
実行開始: B(渡されたもの) … ここで覚えておいた「商品名」を「渡されたもの」として流す
実行開始: 食べる(渡されたもの)

実行終了: 食べる(渡されたもの)
実行終了: B(渡されたもの)
実行終了: ☆thenで予約された処理を実際に呼び出す関数☆

thenに渡したBは,thenで予約された処理を実際に呼び出す関数によって後から呼び出されることが決まっているため,setTimeoutの有無に関わらず,実行順序は保証されています.

throw が認められるとき,認められないとき

どうやって説明しようか苦悶していたのですが,これを正しく説明するためにはPromiseのインスタンスが持つメソッド,および先程も登場したthenで予約された処理を実際に呼び出す関数についての特徴を明らかにする必要があります.

分かりやすいように,JavaScriptの標準規格である ECMAScript 2015 (ES2015, ES6) のクラス構文に近い擬似コードとしました.

class Promise
{
    constructor(A)
    {
        try {
            A(客を呼び出して渡すの正体, 客を呼び出して言い訳するの正体);
        } catch (言い訳) {
            客を呼び出して言い訳するの正体(言い訳);
        }
    }

    thenで予約された処理を実際に呼び出す関数()
    {
        try {
            ...
        } catch () {
            ...
        }
    }

    then(B) { ... }

    catch(B) { ... }
}

要点は以下の通りです.

  • コンストラクタ実行中にthrowされた言い訳は自動的にcatchされ,客を呼び出して言い訳するが実行される
  • thenで予約された処理を実際に呼び出す関数についても, throwされたものをcatchしてくれる点では共通.

要するに…

実行開始: new Promise(...)

実行終了: new Promise(...)
実行開始: ☆thenで予約された処理を実際に呼び出す関数☆

実行終了: ☆thenで予約された処理を実際に呼び出す関数☆

このいずれかに挟まれている場所なら大丈夫だということになります.それでは,ダメなケースの具体例を考えてみましょう.

new Promise(function (客を呼び出して渡す, 客を呼び出して言い訳する) {
    setTimeout(function () {
        console.log('やばい!品切れ: ' + 商品名);
        throw new Error(商品名 + 'は品切れでした…');
    }, 2000);
});

もしこれを呼び出すと,throwはどこに入るでしょうか?

実行開始: new Promise(A)
実行開始: A(客を呼び出して渡す, 客を呼び出して言い訳する)
実行開始: setTimeout(★遅延実行させる関数★, 2000)

実行終了: setTimeout(★遅延実行させる関数★, 2000) … 予約が終わっただけで中身は実行されていない
実行終了: A(客を呼び出して渡す, 客を呼び出して言い訳する)
実行終了: new Promise(A)

-------- 2000ミリ秒の間 --------

実行開始: ★遅延実行させる関数★()
throw

もうこれでお分かりですね.

134
121
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
134
121