コールバック地獄から async/await に至るまでと, 非同期処理以外への応用

継続渡しスタイル (CPS: Continuation-assing style)

例えば以下は引数として与えられた数に 1 を加えるだけの関数と, それを呼び出すプログラム.

function succ(x) {
    return x + 1;
}

console.log(succ(2)); // -> 3

CPS では関数がその継続 (callback) を受け取り, 内部でその継続を呼び出す. 上の例と同じものを CPS で書くと次のようになる.

function succCPS(x, callback) {
    callback(x + 1);
}

succCPS(2, x => console.log(x)); // -> 3

この例だとほぼ何の得もないが, JavaScript において CPS は, ファイルの読み込みやサーバーへのリクエストの送信, ユーザーの入力待ちなど, 非同期処理の際に頻繁に登場する, というかせざるを得ない (JavaScript が基本的にシングルスレッドで実行されるので, これらの処理の完了を待機すると全ての処理が止まってしまうため). たとえばブラウザ上では XMLHttpRequest#send(), Node.js では fs.readFile() などがこういった API を採用している. 以下の例では read() と書いているが, send() でも wait() でもなんでも好きなように読み替えて良い (簡単のためエラー処理は省略している).

// ファイル読み込みなど
function read(file, callback) {
    // 何か処理系でネイティブに記述された処理
}

read("a.txt", res => console.log(res));

コールバック地獄

上の read() を使って, もう少し複雑な処理を書いてみる. たとえば, a.txt の中にはファイル名が書かれていて, a.txt を読み込んだ後にそのファイルを読み込み, さらにその中に書かれた名前のファイルを読み込み, ……という処理は次のように書ける.

read("a.txt", resA =>
    read(resA, resB =>
        read(resB, resC =>
            console.log(resC)
        )
    )
);

これを繰り返しているとネストがどんどん深くなってしまい, コードの見通しがひどく悪くなってしまう (所謂コールバック地獄).

Promise

この非同期処理における コールバック地獄を解決すべく (?) 現れたのが ES2015 で導入された Promise である. Promise を使用したバージョンの read() は次のように書ける.

function readPromise(file) {
    return new Promise(resolve =>
        read(file, resolve)
    );
}

これを使って先程のファイルを次々に読む例を書き直すと以下のようになる.

readPromise("a.txt").then(resA =>
    readPromise(resA).then(resB =>
        readPromise(resB).then(resC => 
            console.log(resC)
        )
    )
);

これだけではネストが深くなったままだが, p.then(x => q.then(y => r))p.then(x => q).then(y => r) と書き換えることが可能 (式 p, q, r の値は Promiseインスタンスで, r は変数 x を含まないとする) なので, 結局次のようになり, 無事ネストは浅くなった.

readPromise("a.txt")
    .then(resA => readPromise(resA))
    .then(resB => readPromise(resB))
    .then(resC => console.log(resC));

続いて今度はファイルを次々に読み込み, それらの内容をすべて連結した内容を出力してみる. まず Promise を使わない場合はこう書ける. 最後の出力の部分以外は特に変わったことはない.

read("a.txt", resA =>
    read("b.txt", resB =>
        read("c.txt", resC =>
            console.log([resA, resB, resC].join(""))
        )
    )
);

Promise を使って書くとこうなる. この場合は前の例で書き換えた時の条件 (rx を含まない) が成り立たないので, ネストをそのまま浅くすることができない.

readPromise("a.txt").then(resA =>
    readPromise("b.txt").then(resB =>
        readPromise("c.txt").then(resC =>
            console.log([resA, resB, resC].join(""))
        )
    )
);

ネストを浅くするためには, 各時点での Promise の結果に, それまでの結果 (状態) を含ませるようにすれば良い.

readPromise("a.txt")
    .then(resA => Promise.all([Promise.resolve(resA), readPromise("b.txt")]))
    .then(resB => Promise.all([Promise.resolve(resB[0]), Promise.resolve(resB[1]), readPromise("c.txt")]))
    .then(resC => console.log(resC.join("")));

ここでは愚直に書いたのであまりに見た目が悪くなっていて, もう少し綺麗に書くことも出来るが, いずれにせよ, 状態を持ち回すようにしなければならない (もちろん結果を何か外側で定義した変数に代入しても良いかもしれないが, それは個人的に好みでない). 一度 resA などに結果を代入をしているのに, さらにもう一手間かけなければならず, 少し面倒である.

ジェネレータ (async/await)

こういった問題を解決するために使えるのが, 同じく ES2015 で導入されたジェネレータと, co などのライブラリである. これらを使うと, 同じ処理は次のように書ける.

let co = require("co");

co(function * () {
    let resA = yield readPromise("a.txt");
    let resB = yield readPromise("b.txt");
    let resC = yield readPromise("c.txt");
    console.log([resA, resB, resC].join(""));
});

これがどのように動作するのかは, ジェネレータを使わない場合のこの例と比較してみるとよい.

readPromise("a.txt").then(resA =>
    readPromise("b.txt").then(resB =>
        readPromise("c.txt").then(resC =>
            console.log([resA, resB, resC].join(""))
        )
    )
);

co() は与えたジェネレータ関数を呼び出してジェネレータオブジェクトを作成し, .next() メソッドを呼び出す. するとジェネレータは Promise を返して停止する (yield) ので, co() はその Promise の完了を待ち, 結果を引数として再度 .next() メソッドを呼び出す. 後はこれを繰り返し行うだけである. この時, 二回目の .next() メソッドは,

x => {
    let resA = x;
    let resB = yield readPromise("b.txt");
    let resC = yield readPromise("c.txt");
    console.log([resA, resB, resC].join(""));
}

のような関数と思って良い (もちろんこれ自体は JavaScript としては不正な関数だが). これはジェネレータを使わない場合の

resA =>
    readPromise("b.txt").then(resB =>
        readPromise("c.txt").then(resC =>
            console.log([resA, resB, resC].join(""))
        )
    )

に対応する. 同様に, 各時点で呼び出される .next() メソッドの内容は, 各 Promise.then() に与えた関数に対応する.

ES2016 では, asyncawait というキーワードを用いて, co などの外部ライブラリに頼ったりすることなく, 同様の処理を書くことが可能になる (予定).

async function concatFiles() {
    let resA = await readPromise("a.txt");
    let resB = await readPromise("b.txt");
    let resC = await readPromise("c.txt");
    console.log([resA, resB, resC].join(""));
}

concatFiles();

非同期処理以外への適用

ここまでは主に非同期処理について, ジェネレータ (または async/await) を用いることで, コードが煩雑になるのをいかに避けるかを考えてきたが, 今度は非同期処理に限らない (今までの Promise の部分が Promise とは限らない) 場合について, 同じような方法でコードの見通しを良くすることができないかを考える.

ここまでを簡単にまとめると, 次のような Promise を用いたコード

readPromise("a.txt").then(resA =>
    readPromise("b.txt").then(resB =>
        readPromise("c.txt").then(resC =>
            console.log([resA, resB, resC].join(""))
        )
    )
);

は, ジェネレータを用いて次のようにも書けるということであった.

co(function * () {
    let resA = yield readPromise("a.txt");
    let resB = yield readPromise("b.txt");
    let resC = yield readPromise("c.txt");
    console.log([resA, resB, resC].join(""));
});

ところで, たとえば Haskell における以下のコード

getChar >>= \x ->
    getChar >>= \y ->
        getChar >>= \z ->
            putStrLn [x, y, z]

と, 等価な do を用いたコード (ここでの「等価」は動作が同じという意味ではなく, do はただのシンタックスシュガーなので, 式が厳密に同じということ)

do
    x <- getChar
    y <- getChar
    z <- getChar
    putStrLn [x, y, z]

を見ていると, なんとなく Haskell における Monad のようなものならば, JavaScript でも同じように記述を簡潔にできるのではないかという気がしてくる. もちろん, これはあくまでどのような場合に適用できるかを考えるためのヒントであって, JavaScript での書き換えは式が等価というわけではないし, Monad であるからといって書き換えが不可能なものもあれば, 逆に書き換えの内容によっては Monad でなくても良さそうである. ここでは, どのような Monad であれば書き換えが可能なのかを考える.

今考えている書き換えの肝は, 本来関数として書いていた継続を, ジェネレータオブジェクトの .next() メソッドで取り出すことである. ところがジェネレータオブジェクトの .next() メソッドは, 呼び出す度に指している継続が変化してしまうため, 同じ継続は一度しか呼び出せない. このため, ジェネレータを用いた書き換えは, .then() メソッドに与えた継続が複数回呼び出される可能性があるような場合にはできず, 一回しか呼び出されない場合にのみ可能である.

例えば, Haskell のリスト [] のようなモナドは, >>= に与えた関数を複数回呼び出し得るため, ジェネレータを用いた書き換えは不可能である. また, ストリーム (Twitter の user stream のようなものを想像してもらえれば良い) に対して .then() のようなメソッドを定義し, ストリームからデータが送出されたタイミングで継続が呼び出されるようにした場合も, 同じ理由で書き換えは不可能である.

不可能な場合ばかりを考えてもしかたがないので, 以下では書き換えが可能なものについて, 例を挙げて説明する.

Identity

Haskell での Identity を表したものが以下のクラス Id で, .then() メソッド (>>= に対応) は単に値を継続に渡すだけである.

class Id {
    constructor(val) {
        this.val = val;
    }

    then(cont) {
        return cont(this.val);
    }

    static return(val) {
        return new Id(val);
    }
}

正直何の有り難みもないが, 以下のように計算ができる.

let res = Id.return(1).then(x =>
    Id.return(2).then(y =>
        Id.return(3).then(z =>
            Id.return(x + y + z)
        )
    )
);
console.log(res.val); // -> 6

これを, Promise に対する co() と同じようにはたらく関数 gen() を定義すれば, Promise の場合と同じようにジェネレータを使って処理を書き直すことが出来る.

function gen(func) {
    let itr = func();
    return next(itr.next());
    function next(ret) {
        if (ret.done) {
            return ret.value;
        }
        else {
            return ret.value.then(x => next(itr.next(x)));
        }
    }
}

let res = gen(function * () {
    let x = yield Id.return(1);
    let y = yield Id.return(2);
    let z = yield Id.return(3);
    return Id.return(x + y + z);
});
console.log(res.val); // -> 6

State

今度は HaskellState モナドに対応するものを見てみる. 以下は State クラスの定義である. コンストラクタが期待する関数は, 状態を引数に取り, 結果と次の状態を返す関数で, .run() は単にコンストラクタに与えた関数を実行するだけ, .eval() はほぼ同じだが結果のみを返すようなメソッドである.

class State {
    constructor(func) {
        this.func = func;
    }

    run(state) {
        return this.func(state);
    }

    eval(state) {
        return this.func(state)[0];
    }

    then(cont) {
        return new State(init => {
            let [x, state] = this.run(init);
            return cont(x).run(state);
        });
    }

    static return(x) {
        return new State(state => [x, state]);
    }
}

以下では状態が配列である場合を考える. 次のように head を定義してやれば, 配列の先頭を結果, 残りの部分を次の状態とする State オブジェクトとなる.

let head = new State(state => [state[0], state.slice(1)]);

これを用いると, 途中で状態をあらわに書くことなく, 以下の様な処理を書くことが出来る.

let take3sum = head.then(x =>
    head.then(y =>
        head.then(z =>
            State.return(x + y + z)
        )
    )
);
console.log(take3sum.eval([4, 1, 2, 6])); // -> 7

やはり, これでは見通しが悪いので書き換えを行うために, 関数 gen() を定義する. 先ほどの Id の場合とは違い, 計算はすぐに実行されるのではないので, .run() などが呼び出された時にその都度ジェネレータオブジェクトを作成するよう形が変わっているが, 基本的には同じである.

function gen(genFunc) {
    return new State(init => {
        let itr = genFunc();
        return next(init, itr.next());
        function next(state, ret) {
            if (ret.done) {
                return ret.value.run(state);
            }
            else {
                let [x, s] = ret.value.run(state);
                return next(s, itr.next(x));
            }
        }
    });
}

これを用いれば, 同じ処理は

let take3sum_ = gen(function * () {
    let x = yield head;
    let y = yield head;
    let z = yield head;
    return State.return(x + y + z);
});
console.log(take3sum_.eval([4, 1, 2, 6])); // -> 7

のように書ける.

これは非同期処理ではなく, かつ途中の状態をあらわに書かなくても良いという利点がある例である. 拙作のパーサコンビネータライブラリ でも, このような記法を可能にする関数を用意している *1.

まとめ

コールバック地獄を解消するための手段として, 非同期処理における Promise やジェネレータを用いた方法を解説した. また, 非同期処理以外においても, 継続が一度しか呼び出されないよう場合には, 同じ方法が適用できることを示した.

というか現在の私の理解をまとめた.