[翻訳] あなたの関数は何色ですか?
ちょうど10年前の今日、GoogleのDart言語の開発者であるBob Nystrom氏により、ブログ "What Color is Your Function?" が投稿されました。
プログラミング言語の話題で引用されているのを2,3度見ており、個人的にも、同期/非同期という関数の形式がI/Fを通じて伝播的に与える影響を色で捉えた革新的な記事だと思っています。投稿から若干変わった話もあるかもですが、色褪せない鋭さがあり、日本語として共有してみたかったです。
このたび10周年を記念して、本人の許可を得た上で翻訳公開してみました!
末尾の脚注は翻訳者(自分)によるものです。文化的なギャップも英語の語彙も知れると思ったので、原文重視で、細かいとこは注釈で補足しました。
以下、翻訳。
皆さんはどうか分かりませんが、私にとって昔のプログラミング言語についての長々とした批判ほど朝の活力を与えてくれるものはありません。StackOverflow にこっそりアクセスする合間に、庶民が使っている"Blub言語"[1] を誰かが批判しているのを見ると、血が騒ぎます。
(一方、あなたと私は、最も啓発された言語のみを使用します。私たちのような熟練した職人の手入れの行き届いた手のために設計された、ノミのように鋭いツールです。)
もちろん、この長文の著者として、私はリスクを負っています。私の揶揄する言語は、皆さんの好みの言語かもしれません。それに気づかずに、私はブログに暴徒を招き入れてしまったかもしれません。彼らは、鎌や松明を手に、私の大胆な小冊子に怒りをぶつけるかもしれません!
炎のような熱から身を守り、また、もしかしたら繊細な感受性をお持ちかもしれないあなたの気分を害さぬよう、代わりに、私が勝手に作った言語について罵ろうと思います。それは、炎上させることが唯一の目的である藁人形です。
無意味だと思われるでしょう?でも、最後まで読んでいただければ、藁人形の頭に誰の顔(もしかしたら複数の!)が描かれているかお分かりいただけます。
新しい言語
ブログ記事のためにまったく新しい(くだらない)言語を一から学ぶのは面倒な要求なので、すでに私たちが知っている言語とほぼ同じだと考えてみましょう。構文はJavaScriptに似ているとします。波括弧やセミコロン。if
、while
など。プログラミング洞窟のリングワ・フランカ[2]。
私が JS を選んだのは、この投稿がそれについて書かれているからではなく、平均的な読者の代表であるあなたが最も理解できる可能性が高い言語だからです。 Voilà(はいどうぞ)。
私たちの例の言語はモダンな(クソ)言語なので、第一級関数もあります。ですから、次のようなものを作ることができます。
これは高次関数のひとつであり、その名の通り、非常に洗練されていて非常に便利です。コレクションをいじくり回すのに慣れているかもしれませんが、いったんその概念を理解してしまえば、あらゆる場面で使えるようになります。
例えば、テストフレームワークで次のように使えます。
あるいは、データを解析する必要がある場合。
さあ、町に行き、再利用可能なさまざまな素晴らしいライブラリやアプリケーションを書いてみましょう。関数を渡したり、関数を呼び出したり、関数を返したりします。関数のお祭り騒ぎ。
あなたの関数は何色ですか?
でも、待ってください。ここで私たちの言語はおかしくなります。この言語には、次のような奇妙な特徴があります。
1. すべての関数には色があります
各関数 (無名コールバックまたは通常の名前付きコールバック) は赤または青で表示されます。キーワードは 1 つのfunction
ではなく、次の 2 つがあります。
この言語には無色の関数というものは_存在しません_。関数を作りたいですか?色を選ばなければなりません。それがルールです。さらに、他にもいくつか守らなければならないルールがあります。
2. 関数の呼び出し方は、その色によって異なります
「青の呼び出し」構文と「赤の呼び出し」構文を想像してください。次のようになります。
関数を呼び出す際には、その色に対応する呼び出しを使用する必要があります。間違って、赤い関数を括弧の後にblue
で呼び出したり、その逆を行ったりすると、何か悪いことが起こります。蛇を腕に巻き付けたピエロがベッドの下に隠れているような、長らく忘れていた子供の頃の悪夢がよみがえります。その悪夢がモニターから飛び出してきて、あなたの眼球からガラス体を吸い取ります。
厄介なルールですね? それともう一つ。
3. 赤い関数は、赤い関数内からしか呼び出せません
青い関数は、赤い関数内から呼び出すことができます。これは合法です。
しかし、逆のことはできません。これをやろうとすると、
ええ、あなたは古い夜の道化師、スパイダーマウスの訪問を受けることになるでしょう。
これによりfilter()
の例のような高階関数の記述が難しくなります。 その色を選択する必要があり、それが、その関数に渡すことのできる関数の色に影響します。 明らかな解決策は、filter()
を赤色にすることです。 そうすれば、赤または青の関数を受け取り、呼び出すことができます。 しかし、この言語の厄介な問題が次から次へと出てきます。
4. 赤い関数は呼び出すのがより面倒です
ここでは「面倒」を正確に定義するつもりはありませんが、プログラマーが 赤い関数を呼び出すたびに、何らかの面倒な手順を踏まなければならないと想像してください。非常に冗長になるか、特定の種類のステートメント内では実行できない可能性があります。素数の行番号でのみ呼び出すことができる可能性があります。
重要なのは、あなたが関数を赤にすると決めた場合、あなたのAPI を利用するすべての人があなたのコーヒーに唾を吐きかけたり、あるいはもっとまずい液体を注ぎたくなるということです。
明白な解決策は、赤の関数を決して使用しないことです。すべてを青にしてしまえば、すべての関数が同じ色というマトモな世界に戻れます。これは、すべての関数が色を持たないことと同等であり、私たちの言語が完全に愚かではないことと同等です。
残念ながら、サディストな言語設計者(プログラミング言語設計者が皆サディストであることは周知の事実ですよね?)は、私たちに最後のとどめを刺します。
5. いくつかのコアライブラリ関数は赤色です
プラットフォームには、私たちが使う必要がある関数がいくつか組み込まれていますが、自分たちではそれらは記述できず、赤色でしか提供されていません。この時点で、合理的な人は、言語に嫌われていると思うかもしれません。
関数型プログラミングのせいだ!
この問題は高階関数を使おうとしていることにある、とあなたは考えるかもしれません。 もし、関数型のような派手な装飾に踊らされるのをやめて、神が意図したように、普通の青色の第一階関数を書けば、私たちはすべての苦悩から解放されるでしょう。
青い関数だけを呼べるなら、その関数を青くします。そうでない場合は赤くします。関数を受け入れる関数を作らない限り、「関数の色における多態性/polymophic」(多色性/polychromatic?) や、それに似たナンセンスなことを心配する必要はありません。
しかし残念ながら、高階関数は単なる一例にすぎません。この問題は、プログラムを再利用可能な個別の関数に分割しようとする際に常に発生します。
例えば、ソーシャルネットワーク上でユーザー同士がどの程度お互いに惹かれ合っているかを表すグラフの上でダイクストラのアルゴリズムを実装した、ちょっとしたコードがあるとします。(このような結果が何を表しているのかを判断するのに、私は随分長い時間を使いました。推移的不要性?[3])
その後、同じコードを別の場所でも使用する必要が出てきます。 当然の対応として、それを別の関数として切り分けます。元あった場所からそれを呼び出し、新しいコードでも使用します。 しかし、その色は何色にすべきでしょうか?もちろん、可能であれば青にします。 しかし、もしそれが厄介な赤専用コアライブラリ関数の1つを使用していたらどうでしょうか?
もし、新たに呼び出したい場所が青だったらどうでしょうか?その場合は、それを赤にしなければなりません。 そうなると、それを呼び出す関数も赤にしなければなりません。 なんてことでしょう。何が起きても、常に色について考えなければなりません。開発というビーチ休暇の中、水着に砂が入ってしまうようなものです。
カラフルな寓話
もちろん、私がここで本当に話したいのは色についではありません。これは寓話であり、文学的なトリックです。『スニーチ(The Sneetches)』[4]は腹に星があることについてではなく、人種についてです。もうあなたは色が実際に何を意味するのか、おおよそ見当がついているかもしれません。もしそうでないなら、ここで重大発表です。
赤い関数は非同期関数です。
もしNode.js上でJavaScriptを書いているなら、コールバックを呼び出すことで初めて値を返す関数を定義するたびに、あなたは赤い関数を作っていることになります。ルールを見返して、私のメタファーがどの程度正しいか確認してみてください。
- 同期関数は値を返しますが、非同期関数は値を返さず、代わりにコールバックを呼び出します。
- 同期関数は返り値として結果を返しますが、非同期関数は渡されたコールバックを呼び出すことで結果を返します。
- 同期関数から非同期関数を呼び出すことはできません。なぜなら、非同期関数が完了するまで結果を決定できないからです。
- 非同期関数は、コールバックが原因で式内で合成することができず、エラー処理も異なります。また、
try/catch
や他の多くの制御フローステートメント内で使用することもできません。 - Nodeの売りは、コアライブラリがすべて非同期であることです。(ただ彼らはそれを抑えて、
___Sync()
バージョンの多くのものを追加し始めました。)
「コールバック地獄」について語るとき、彼らは自分たちの言語に赤い関数があることがいかに厄介であるかを語っているのです。非同期プログラミングを行うための4,089ものライブラリを作成するとき、彼らは言語から押し付けられた問題をライブラリレベルで解決しようとしているのです。
更新 2021/12/03: 現在、15,118の非同期ライブラリがあります。
futureはもっと良くなることをpromiseします
Nodeコミュニティの人々は、コールバックが厄介なものであることに長い間気づいており、解決策を模索してきました。多くの人々を興奮させるテクニックのひとつに、promiseがあります。これは、ラッパーの名前である "futures" としても知られているかもしれません。
これらは、コールバックとエラーハンドラを包み込むラッパーのようなものです。コールバックとエラーバックを関数に渡すことを_概念_だとすると、プロミスは基本的にその考えを_具現化_したものです。これは非同期処理を表す第一級オブジェクトです。
この段落では、PL言語の専門用語をたくさん詰め込んだので、魅力的に聞こえるかもしれませんが、基本的にはインチキです。しかし、約束は果たされます。asyncコードを少し書きやすくします。 多少はうまく構成できるので、ルール#4はそれほど厄介ではありません。
しかし、正直に言って、それは腹を殴られるのと、局部を殴られるのとの違いのようなものです。 技術的には苦痛が少ないのは確かですが、その価値提案に本当に興奮する人はいないと思います。
例外処理やその他の制御フロー文では、まだそれらを使用することができません。同期コードから未来を返す関数を呼び出すことはまだできません。(まあ、できますが、そうすると、後であなたのコードをメンテナンスする人がタイムマシンを発明して、あなたがこれを行った瞬間にタイムスリップし、HBの鉛筆であなたの顔を突き刺すでしょう。)
あなたは、依然として世界全体を非同期と同期の2つに分け、それに伴うすべての悲惨さを抱えています。つまり、あなたの言語にpromiseやfutureが含まれていたとしても、その顔は私の藁人形の顔と酷似しています。
(はい、つまり私が取り組んでいる言語であるDartもです。だからこそ、チームのメンバーが他の並列処理モデルを試していることにとても興奮しているのです。)
解決策をawait中
C#プログラマーは、おそらく今、かなり得意げな気分になっていることでしょう(Hejlsberg氏と彼の仲間が言語に次々と魅力的な機能を追加するにつれ、この状態に陥るプログラマーが増えてきました)。C#では、await
キーワードを使用して非同期関数を呼び出すことができます。
このキーワードを少し追加するだけで、同期呼び出しと同じくらい簡単に非同期呼び出しを行うことができます。await
呼び出しを式の中にネストしたり、例外処理コードで使用したり、制御フローの中に詰め込んだりすることができます。 思う存分やってください。 新しいラップ アルバムの前払い金のように、await
呼び出しを雨のように降らせてください。
Async-awaitは素晴らしいものです。だからこそ、私たちはそれをDartに追加しているのです。非同期コードを書くことがずっと簡単になります。「しかし」が来ると思ったでしょう。その通りです。しかし、、、あなたはまだ世界を2つに分けています。それらの非同期関数は書きやすくなりましたが、それらは依然として非同期関数です。
依然として2つの色が残っています。 Async-awaitは、厄介なルール#4を解決します。赤い関数と青い関数の呼び出しのしやすさは、それほど変わりません。しかし、他のルールはすべて残ったままです。
- 同期関数は値を返し、非同期関数は値のラッパーである
Task<T>
(またはDartではFuture<T>
)を返します。 - 同期関数は単に呼び出されるだけですが、非同期関数は
await
が必要です。 - 非同期関数を呼び出すと、実際に
T
が必要なときにこのラッパーオブジェクトが返されます。自分の関数を非同期にしてawaitしない限り、このラッパーをアンラップすることはできません。(ただし下記を参照) -
await
を多用することは別として、少なくともこれは修正されました。 - C#のコアライブラリはasyncよりも古いので、おそらく彼らはこの問題を抱えていなかったと推測します。
良くなりました。私は、どんなときも、素のコールバックや futures よりも async-await を選びます。しかし、すべての問題が解決したと考えるのは、自分自身に嘘をついていることになります。高階関数を書こうとしたり、コードを再利用しようとしたりすると、すぐに、コードベース全体に色がまだ残っていることに気付くでしょう。
色付けがない言語は何でしょう?
JS、Dart、C#、Pythonにはこの問題があります。CoffeeScriptなどJSにコンパイルされるほとんどの言語にもあります(Dartがこの問題を受け継いだ理由です)。ClojureScriptもそうだと思います。彼らはcore.asyncの機能でこの問題に対処しようと懸命に努力していますが。
そうでない言語を知りたいですか? Javaです。そうでしょう? 「ええ、Javaこそが本当にこれを正しく行う言語だ」と、どれほど繰り返せるでしょうか? しかし、実際そうなのです。Java の言い分としては、彼らは future とasync IO に移行することで、この見落としを修正しようと積極的に取り組んでいます。これは底辺への競争のようなものです。
C#も、実際に問題を回避できます。C#では色を持つことをopt-inとしました。async-awaitやTask<T>
のすべてが追加される前は、通常の同期API呼び出しを使用していました。この問題がない言語はGo、Lua、Rubyの3つです。
これらの言語に共通点が何だか分かりますか?
スレッドです。より正確に言えば、切り替え可能な複数の独立したコールスタックです。 必ずしもオペレーティングシステムのスレッドである必要はありません。 Goのgoroutine、Luaのcoroutine、Rubyのfiberでも十分です。
(C#に小さな注意点があるのはそのためです。C#ではスレッドを使用することで、asyncの苦痛を回避できます。)
過去の操作の記録
根本的な問題は、「操作が完了したときに、どこまで処理したかをどう把握するか」です。コールスタックに大量のデータを蓄積し、その後、何らかのIO操作を呼び出します。パフォーマンスを考慮して、その操作はオペレーティングシステムをベースとした非同期APIを使用します。それは操作の完了を待ちません。あなたは言語のイベントループに完全に戻る必要があり、処理を完了するまでOSにいくらか時間を与え待機(spin)する必要があります。
処理が完了したら、中断していた作業を再開する必要があります。言語がその場所を記憶する一般的な方法は、コールスタックです。これは現在呼び出されているすべての関数と、各関数内のインストラクションポインタの位置を追跡します。
しかし、非同期IOを行うには、Cのコールスタック全体を巻き戻して破棄しなければなりません。一種のジレンマです。[5]。超高速のIOが使えても、その結果に対して何もすることができません。非同期 IO をコアに持つすべての言語 (JS の場合はブラウザーのイベント ループ) は、何らかの方法でこれに対処します。
常に右に行進を続ける[6]コールバックを持つ Node は、すべてのコールフレームをクロージャ内に詰め込みます。次のコードがあります。
これらの関数式はそれぞれ、周囲のコンテキストをすべて閉じます。これにより、iceCream
やcaramel
などのパラメータがコールスタックからヒープに移動します。外側の関数が返り、コールスタックが破棄され、問題ありません。そのデータはヒープ内に浮遊したままです。
問題は、これらのステップのすべてを手動で具象化する必要があることです。この変換には名前があります。継続渡しスタイル(CPS: Continuation-passing style)です。これは、コンパイラの内部で使用する中間表現として、70 年代に言語ハッカーによって発明されました。一部のコンパイラ最適化を簡単に実行できるようにする、実に奇妙なコード表現方法です。
プログラマーが 実際にそのようなコードを書く とは誰も刹那にも思いませんでした。そして Node が登場し、突然、私たちはコンパイラのバックエンドの真似をするようになりました。どこで間違えたのでしょうか?
promise と future も実際には何も得られないことに注意してください。これらを使用したことがある人なら、依然として大量の関数リテラルを手動で作成していることに気づいているでしょう。単にそれらを非同期関数自体ではなく.then()
に渡しているだけです。
生成されたソリューションをawait
async-await は確かに役に立ちます。コンパイラの頭蓋骨を剥がし、await
呼び出しに遭遇したときに何が起きているかを見れば、実際に CPS 変換が行われていることがわかるでしょう。これが、 C# でawait
を使用する必要がある理由です。これは、コンパイラに「ここで関数を半分に分割する」と指示する手がかりになります。await
の後のすべては、ユーザーに代わり、コンパイラが合成する新しい関数にまとめられます。
これが、.NETフレームワークでasync-awaitがランタイムサポートを必要としなかった理由です。コンパイラは、すでに処理できる一連の連結クロージャにコンパイルします。(興味深いことに、クロージャ自体もランタイム サポートを必要としません。クロージャは匿名クラスにコンパイルされます。C# では、クロージャはまさに貧乏人のオブジェクトです。
ジェネレータについていつ取り上げるのか、疑問に思っているかもしれません。あなたの言語にはyield
キーワードがありますか?それなら、非常に似たようなことが可能です。
(実際、ジェネレータと async-await は同型であると私は信じています。私のハード ディスクのどこかの片隅に、async-await のみを使用してジェネレータスタイルのゲーム ループを実装するコードが少し残っています。)
どこまで話しましたっけ?ああ、そうでした。コールバック、promise、async-await、ジェネレータを使用すると、最終的には非同期関数を取得して、ヒープ内に存在する一連のクロージャに分散させることになります。
関数は、最も外側の関数をランタイムに渡します。イベント ループまたは IO 操作が完了すると、その関数が呼び出され、中断したところから処理が再開されます。ただし、これは、その上にあるものもすべて返される必要があることを意味します。スタック全体を巻き戻す必要があります。
ここで、「赤い関数は赤い関数からのみ呼び出すことができる」というルールが生まれます。コールスタック全体を、 main()
またはイベント ハンドラーまで閉じる必要があります。
具象化されたコールスタック
しかし、スレッド(グリーンまたは OS レベル)がある場合、その必要はありません。スレッド全体を中断でき、すべての関数から戻る必要なしに OS またはイベント ループに直接戻ることができます。
私の意見では、Go はこれを最も美しく実行する言語です。IO 操作を実行するとすぐに、その goroutine を保留し、IO でブロックされていない他の goroutine を再開します。
標準ライブラリの IO 操作は同期しているように見えます。つまり、単に作業を実行し、完了したら結果を返します。ただし、JavaScript が意味する同期とは異なります。操作の 1 つが保留中の間も、他の Go コードを実行できます。Go は、同期コードと非同期コードの区別がなくしました。
Go における並行性は、プログラムのモデル化をどう選択するかという一面[7]であり、標準ライブラリの各関数に焼き付けられた色ではありません。つまり、上で述べた 5 つのルールの面倒な点はすべて完全に解消されます。
つまり、次にあなたが私に新しいホットな言語について話し、その言語の並行処理がいかに素晴らしいかを語り、非同期APIがあることを理由に挙げるなら、私が歯ぎしりを始める理由が分かるでしょう。なぜなら、それはあなたが再び赤い関数と青い関数に戻ったことを意味するからです。
-
Paul Graham氏のエッセイに登場する架空の言語。Blubパラドックスは、言語の抽象度が連続である仮定のもと、その中間にあるBlub言語を想定したとき、Blubプログラマはより弱い言語を見下す一方、強い言語についてはただ奇妙な言語と見なす現象のこと。またはその再帰性。言語xについての他人の不満はたまたま使って満足しているだけの言語yに依存したものかもしれない。一番客観的に言語を判断できるのは一番強力な言語を理解する人だという観点が、「Lispを理解するプログラマが優秀になれる」理由であると本文では推測される。詳しくは河合史郎氏による日本語訳をご参照ください。 ↩︎
-
最短距離が長い→グラフを辿っても惹かれていない→推移的不要性/望まなさ(undesirability)、と解釈できる ↩︎
-
The Sneetches and Other Stories. wiki. > スニーチの中には、腹に緑の星を持っているものもあります。物語の冒頭で、星のあるスニーチは、星のないスニーチを差別し避けます ↩︎
-
原文では、Catch-22というスラングが使われています。https://studious-english.com/blog/catch-22/ ↩︎
-
原文では、ever-marching-to-the-right. ↩︎
-
Goでは、関数呼び出し時にプログラムの並行性を選べるという意味だと解釈しています。関数を普通に呼べ逐次に実行され、
go
をつけて呼べばgoroutine化され呼び出し元と並行に実行されます。 ↩︎
Discussion