コールバック地獄とその問題点
コールバック関数は非同期処理の基本だが、処理が連鎖すると深いネストが生まれる。この状態は「コールバック地獄(Callback Hell)」と呼ばれ、コードの可読性・保守性を著しく損なう。Promise が生まれた最大の理由がここにある。
コールバック地獄の典型例
たとえばユーザー情報を取得し、その結果をもとに注文履歴を取得し、さらに各注文の詳細を取得する、という 3 段階の処理を考えてみよう。
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetail(orders[0].id, function(detail) {
console.log(detail);
}, function(err) {
console.error("詳細取得失敗:", err);
});
}, function(err) {
console.error("注文取得失敗:", err);
});
}, function(err) {
console.error("ユーザー取得失敗:", err);
});ネストが 3 段階になっただけで、どの function がどの処理に対応しているのか追いにくくなる。実際のアプリケーションではさらに多くの非同期処理が連鎖するため、インデントが右にどんどん深くなっていく。その見た目から「破滅のピラミッド(Pyramid of Doom)」とも呼ばれている。
何が問題なのか
コールバック地獄の問題は単なる見た目の悪さだけではない。
ネストが深くなるほど処理の流れを追うのが困難になる。3 段階を超えると、多くの開発者がコードの意図を把握するまでに時間がかかるようになる。
各コールバックに個別のエラー処理を書く必要があり、エラーハンドリングがコード全体に散らばる。ひとつでも書き忘れると、エラーが黙殺されてデバッグが難しくなる。
条件分岐やループと組み合わせると指数関数的に複雑度が増す。「A が成功したら B を実行、B が失敗したら C を実行、ただし A が特定の値なら D を実行」のような分岐をコールバックで表現すると、コードが爆発的に膨らむ。
エラーハンドリングの問題を具体的に見る
コールバック方式では try/catch が使えないという根本的な問題がある。非同期コールバックは呼び出し元のスタックフレームを離れて実行されるため、外側の try/catch では捕捉できない。
// これは動かない
try {
setTimeout(function() {
throw new Error("非同期エラー");
}, 1000);
} catch (e) {
// ここには到達しない
console.error(e);
}そのため、Node.js ではエラーファーストコールバックという規約が生まれた。コールバックの第 1 引数をエラーオブジェクトにする、というパターンだ。
function readFile(path, callback) {
// エラーがあれば第1引数に、なければ null
if (!path) {
callback(new Error("パスが指定されていません"), null);
return;
}
callback(null, "ファイルの内容");
}
readFile("/data.txt", function(err, data) {
if (err) {
console.error(err.message);
return;
}
console.log(data);
});このエラーファーストコールバックは Node.js のコア API 全体で採用されている規約であり、コールバック規約の事実上の標準となった。
コールバックの第 1 引数を err とし、エラーがなければ null を渡す約束。
しかし、エラーファーストコールバックであっても、処理が連鎖するとエラーチェックの繰り返しでコードが冗長になる。
readFile("/config.json", function(err, config) {
if (err) { console.error(err); return; }
parseJSON(config, function(err, parsed) {
if (err) { console.error(err); return; }
connectDB(parsed.dbUrl, function(err, db) {
if (err) { console.error(err); return; }
console.log("接続成功");
});
});
});毎回 if (err) を書く必要があり、本来やりたい処理の流れが if 文の中に埋もれていく。
回避策とその限界
コールバック地獄を軽減するテクニックとして、コールバックを名前付き関数に分離する方法がある。
function handleDetail(detail) {
console.log(detail);
}
function handleOrders(orders) {
getOrderDetail(orders[0].id, handleDetail);
}
function handleUser(user) {
getOrders(user.id, handleOrders);
}
getUser(userId, handleUser);ネストは浅くなるが、処理の順序を把握するには関数の定義を追いかける必要があり、コードの流れが分断される。根本的な解決にはならない。
コールバックの連鎖でネストが深くなる
可読性とエラーハンドリングが悪化する
Promise による解決が求められるようになった
こうした問題を解決するために ES2015 で導入されたのが Promise だ。Promise はコールバックの連鎖を .then() メソッドでフラットに書けるようにし、エラーハンドリングも .catch() で一箇所にまとめられるようにした。コールバック地獄の苦しみが、JavaScript の非同期処理を進化させる原動力となったといえる。