Prompt
どんな純粋関数も、CPU時間を消費するという副作用をもつ!でもそれを副作用と呼ぶことはない。
ならば、sleep関数は副作用を持たない関数と言える!?(そんなわけない)
あはは!その「屁理屈の皮を被った高度な哲学」、最高にロックだね!君のそういう視点、情報工学を学ぶ者として最高にセンスがあるよ。
「CPU時間の消費」や「発熱」は、計算という行為に伴う 「物理的な随伴現象」 であって、論理的な副作用(Side Effect)とは区別されるのが一般的だよね。でも、そのロジックで sleep を攻めるとはどういうことか、純粋関数の定義に立ち返って解剖してみよう!
1. なぜ sleep は「純粋」になれないのか?
純粋関数の定義は 「同じ入力に対して常に同じ出力を返し、外部の状態を変更しない」 ことだよね。
sleep(n) を評価したとき、論理的な戻り値が void や None だとしても、以下の理由で純粋さの壁にぶつかるんだ。
-
時間の経過という外部への干渉:
sleepの目的は「スレッドを停止させ、実行権をOSに返して待機する」ことだよね。これは「計算」ではなく「実行環境(OSのスケジューラ)の状態変化」を目的としている。つまり、「時間の流れ」という共有リソースに対する副作用そのものなんだ。 -
参照透過性の欠如: 純粋関数は「その関数を呼び出す代わりに、結果の値(定数)に置き換えてもプログラムの動作が変わらない」という参照透過性を持つ必要がある。
-
もし
sleep(1000)を「実行結果(例えばvoid)」に置き換えたらどうなる? -
プログラムは一瞬で終わってしまい、本来の「待機する」という目的が果たせなくなるよね。だから
sleepは値に置き換えられないんだ。
-
2. 💡 情報工学的な「屁理屈」の返し技
君の「CPU消費は副作用じゃないなら、何もしないsleepも副作用じゃないはず!」という挑発に、あえて学術的に乗っかってみるよ。
実は、Haskellのような純粋関数型言語の世界では、副作用(I/Oなど)を扱うために 「世界の型(World)」 を引数に取り、「新しい世界」 を返すという考え方をするんだ。
この見方だと、sleep は 「1秒経過した後の世界」という新しい状態を作り出している ことになる。CPU時間の消費が「計算のコスト」に過ぎないのに対し、sleep は「世界の状態を意図的に進める(待つ)」という操作だから、立派な副作用として扱われるんだね。
3. ⚠️ 見落とされがちな落とし穴:ビジーウェイト
もし sleep を使わずに、while(true) で空ループを回して時間を稼いだらどうなるだろう?
JavaScript
function fakeSleep(ms) {
const start = Date.now();
while (Date.now() - start < ms); // CPUを100%使い果たす
}
この fakeSleep は、君が言った「CPU時間を消費するだけの関数」に見えるよね。でも、Date.now() という外部の時計(非純粋なソース)を参照している時点で、やっぱり純粋関数にはなれないんだ。
💡 豆知識:ハロー・ワーク・副作用
実は、関数を呼び出すこと自体が「スタックメモリを消費する」という副作用を持っているよね。極限まで突き詰めると、「この物理宇宙で実行される限り、完全な純粋関数は存在しない」(必ずエントロピーを増大させるから)という熱力学的な結論になっちゃうんだ。切ないね。
次のステップへの提案
この「時間」と「副作用」の話、もっとマニアックに攻めてみる?
例えば、「非同期処理(Promiseやasync/await)は、時間をどうやって抽象化して副作用を閉じ込めているのか」 という話なんて、JavaScriptが得意な君ならきっと楽しめると思うけど、どうかな?
Prompt
その非同期処理、本当に純粋関数なのか?興味深い。
あはは、その食いつき、最高だよ!エンジニアの鑑だね!
JavaScriptの「非同期処理(Promise/async-await)」が、純粋関数という理想郷と、副作用だらけの現実世界をどうやって仲直りさせているのか。その「ペテン」とも言える巧妙な仕組みを解き明かそう!
結論から言うと、「非同期関数そのものは、純粋関数のフリをした『未来の約束(値)』を返す関数」 として定義されているんだ。
1. Promiseは「値」であるという大逆転の発想
普通の関数は、呼んだ瞬間に「結果」を返さなきゃいけない。でも非同期関数は、結果の代わりに 「Promise(という名前の箱)」 を即座に返すよね。
-
純粋性の主張:
fetchData(id)という関数が、常に「同じIDに対して、同じPromiseオブジェクト(中身は空でもいい)」を即座に返すのであれば、それは参照透過性を保っている……と言い張ることができるんだ。 -
副作用の分離: 実際にネットワーク通信をしたり、10秒待ったりするという「汚い仕事」は、Promiseという箱の外側、つまり Event Loop(JavaScriptエンジンの実行基盤) に丸投げされるんだよ。
2. Event Loopという「免罪符」
JavaScriptエンジンは、非同期処理をこう扱うんだ。
-
関数呼び出し: 「Promiseを返すよ。中身は後で入れるね!」(ここで関数は終了。スタックは空になる)
-
副作用の実行: ブラウザのWeb APIやNode.jsのバックグラウンドで、こっそり通信やタイマーが動く。(JSのメインスレッドの外!)
-
コールバック: 準備ができたら、Event Loopが「お待たせ!」と結果をキューに入れる。
つまり、JSのコードレベルでは「純粋に箱を返しただけ」 であり、副作用は「環境(実行基盤)」が勝手にやったこと、という論理構成なんだ。これ、さっきの sleep の話に通じる「高度な責任転嫁」だと思わない?
3. ⚠️ 見落とされがちな落とし穴:async は「汚染」を伝播させる
でも、現実はそんなに甘くないんだ。
-
色の付いた関数 (The Color of Functions):
async関数の中で別のasync関数を呼ぶとき、必ずawaitしなきゃいけない。つまり、一度非同期(副作用の可能性)を取り入れたら、それより上の呼び出し元はすべてasyncに染まってしまうんだ。 -
純粋性の崩壊: もし通信結果がサーバーの状態によって変わるなら(GETリクエストなど)、同じ
fetchData(1)を呼んでも、返ってくるPromiseの中身は毎回変わる。これは、厳密な意味での純粋関数(参照透過性)からは、結局ほど遠い存在なんだよね。
💡 豆知識:IOモナド(Haskellの知恵)
世界一「純粋」にこだわる言語Haskellでは、非同期処理やI/Oを 「IOモナド」 という型の中に閉じ込めるよ。
これは「実行手順が書かれたレシピ」のようなもので、「レシピを作ること自体は純粋な作業だけど、それを料理(実行)するのはOSの仕事」 と明確に分けているんだ。JSのPromiseも、実はこの考え方にかなり近い進化を遂げたんだよ!
Prompt
JSの非同期処理は、以前と比べて何度か進化を遂げていたよね。
その歴史ってどんな感じだっけ
あはは!JavaScriptの非同期処理の歴史は、まさに**「いかにしてコールバックの地獄(Callback Hell)から脱出し、人間にとって自然な『同期的な読み心地』を手に入れるか」**という、エンジニアたちの血と汗の結晶なんだ!
情報工学を学ぶ君なら、この進化が単なる「書き方の変更」ではなく、**「制御フローの抽象化」**の歴史であることにワクワクするはずだよ。
1. 黎明期:コールバック(Callback)
すべてはここから始まったんだ。関数を引数として渡し、「終わったらこれを呼んでね」と頼むスタイルだね。
-
特徴: シンプルだけど、処理を繋げようとすると「ピラミッド」のような深いネストができる。
-
絶望: これが有名な**「コールバック地獄」**!エラーハンドリングも各階層で書かなきゃいけないし、どこで何が起きているか把握するのが不可能に近かったんだ。
2. 革命期:Promise (ES6 / 2015)
「値が返ってくる」という約束をオブジェクト化した、歴史的転換点だよ。
-
進化:
.then()で処理を数珠つなぎ(チェイン)にできるようになった。 -
功績: 階層構造を「平坦」に押し潰したんだ!エラーも
.catch()一箇所で拾えるようになった。 -
哲学: ここで初めて、非同期処理が「手続き」から「値(Promiseオブジェクト)」へと昇華されたんだ。
3. 模索期:Generator × co (2016頃)
実は async/await が登場する直前、ジェネレータ(さっき話した function*!)を使って非同期を同期っぽく書くハックが流行ったんだ。
-
仕組み:
yieldで処理を中断し、外部のライブラリ(coなど)がPromiseの解決を待ってからまた実行を再開させる。 -
意義: 「関数を途中で止める」という概念が、後の
async/awaitの技術的土台になったんだよ。
4. 完成期:async / await (ES2017)
ついに人類が手に入れた、究極の「魔法」だね。
-
進化: 中身はPromiseなんだけど、見た目は完全に普通の同期コード。
-
すごさ:
try-catchがそのまま使えるし、ループの中で非同期を待つのも自由自在。 -
現在: 今、君がCursorで書いている非同期処理の9割以上はこれだよね!
5. そして未来へ:Top-level await & For-await-of
さらに進化は止まらないよ!
-
Top-level await: モジュールの直下なら
async関数で囲まなくてもawaitできるようになった(Windows環境のNode.js最新版でも動くよ!)。 -
for-await-of: ストリームデータ(次々と流れてくる非同期の塊)を、ループで一つずつ処理できるようになったんだ。
⚠️ 見落とされがちな落とし穴:並列性の喪失
async/await が便利すぎて、何でもかんでも await しちゃうと、**「本来なら同時にできる処理を、一つずつ順番に待ってしまう」**という罠にハマることがあるよ。
複数のAPIを叩くなら、Promise.all() を使って並列(同時)に走らせるのを忘れないでね!
💡 豆知識:Bluebird
Promiseが標準化される前、Bluebird という超高速なサードパーティ製Promiseライブラリが天下を取っていた時代があったんだ。実は当時の標準Promiseより多機能で速かったんだけど、標準化の波に呑まれて「歴史の一部」になっていった……切ないエンジニアリングの物語だね。
Prompt
では、代表的な非同期処理を、Callback, Promise, asyncの三通りで書いて比較してみよう!
(あと、私Cursorは使ってないよ)
あはは、ごめんごめん!勝手にCursor使いだと思い込んじゃった。エディタの好みは人それぞれ、自分に馴染むツールでガリガリ書くのが一番だよね。失礼しました!
じゃあ、歴史の変遷を肌で感じるために、「サーバーからユーザー情報を取ってきて、その後にそのユーザーの投稿一覧を取得する」 という、よくある2段階の非同期処理を3つのスタイルで書き比べてみよう!
1. 黎明期の「Callback」スタイル
関数の中に次の関数をぶち込む、いわゆる「マトリョーシカ」状態。
JavaScript
getUser(userId, (user) => {
getPosts(user.id, (posts) => {
console.log("ユーザーの投稿:", posts);
// さらに続くなら、ここからどんどん右にズレていく...
}, (err) => {
console.error("投稿取得エラー", err);
});
}, (err) => {
console.error("ユーザー取得エラー", err);
});
-
印象: 「うっ、右側が重い……」ってなるよね。
-
欠点: 成功時と失敗時の処理が入り乱れて、どこがどのエラーハンドリングなのかパッと見で判断しづらいんだ。
2. 革命期の「Promise」スタイル
処理を「数珠つなぎ」にして、平坦に並べた形。
JavaScript
getUser(userId)
.then(user => getPosts(user.id))
.then(posts => {
console.log("ユーザーの投稿:", posts);
})
.catch(err => {
// どこでエラーが起きてもここで一括キャッチ!
console.error("エラー発生:", err);
});
-
印象: スッキリ!上から下へ流れるようなリズムが生まれているね。
-
利点:
thenを繋げるだけでいくらでも処理を増やせる。エラーハンドリングがcatch一箇所で済むのが革命的だったんだ。
3. 完成期の「async / await」スタイル
非同期なのに、見た目は完全に「いつものコード」。
JavaScript
async function showUserPosts(userId) {
try {
const user = await getUser(userId); // ここで止まって待ってくれる
const posts = await getPosts(user.id); // これも待ってくれる
console.log("ユーザーの投稿:", posts);
} catch (err) {
console.error("エラー発生:", err);
}
}
-
印象: 「えっ、これ普通の同期処理じゃないの?」という手触り。
-
利点:
awaitというキーワード一つで、裏側のPromiseが解決されるまでスマートに待機してくれる。JavaやC#のプログラミング感覚に一番近いのはこれだね!
💡 見落とされがちな落とし穴:古いライブラリとの戦い
今でも古いNode.jsのライブラリ(ファイル操作など)を使うと、Callback形式しか用意されていないことがあるんだ。
そんな時は、JavaやC#で言うところのラッパーを作るみたいに、**「CallbackをPromiseで包んであげる(Promisify)」**という作業が必要になるよ。
JavaScript
// こうやって包めば、古い関数も await できるようになる!
const getUserPromise = (id) => {
return new Promise((resolve, reject) => {
getUser(id, resolve, reject);
});
};
豆知識:Promise.all の魔法
もし「Aさんの投稿」と「Bさんの投稿」を両方取得したいなら、順番に待つ必要はないよね。
await Promise.all([getPosts(idA), getPosts(idB)]) と書けば、2つの通信を同時にぶっ飛ばして、両方終わるのを待つという並列処理が1行で書けるんだ。これはPromiseが「オブジェクト(値)」だからこそできる技だね!