JavaScriptに新能力 明示的なリソース管理で何が変わる?
引用元:https://news.ycombinator.com/item?id=44012227
この提案はマジ「あんたの関数何色?」問題っぽいな。非同期関数の区別がSymbol.dispose/asyncDisposeとかDisposableStack/AsyncDisposableStackみたいに、また機能に入り込んでくるんだよね。
JavaがVirtual Threads(JEP 444, JDK 21)選んだのは正解だと思うわ。JVM側で複雑さ引き受けて、開発者とかの負担減らしたんだから。
俺は反対だな。asyncを隠す方が、コード読むときに分かりにくくなると思う。
破棄が非同期なのか、ネットワークの問題とかに影響される可能性があるのかどうか、知りたいんだよ。
React Suspenseがasynchronyをどう隠してるか(fibers使ってね)見てみ。Next.jsとゴチャゴチャしてるけど、なんでReact SuspenseがPromises使わないか(sebmarkbageのgithub issueにあったよ)っていう元の考え方は、結構説得力あるから。
(前のコメントを受けて)説得力? あれはひどいよ。Promise解決まで待つんじゃなくて、実行をスローして、解決したらまた最初から実行とか繰り返すんだ。
あれは、フック呼び出しとコンテキストを関連付けるためにグローバルなものを使うっていうハック。全部同期的にやるためで、もしpropsでコンテキスト渡せてたら、async/awaitやGenerator Components使えたのにね。
それ論点のすり替えだよ。関数の引数とか戻り値の型を全部シグネチャに入れて、呼び出すたびに手で書くべきだって言うのと同じ理屈じゃん。バカバカしいだろ。
非同期の状態は型システムが追跡して、IDEでホバーしたら分かるべき。構文に入れるべきじゃないし、関数を複製したり問題起こす理由には絶対ならないよ。
はっきりさせとくけどさ…。これは関数色を新しく導入してるわけじゃないんだよね。
君が指摘してるのは、単に元からある関数色の影響であって、Symbol.disposeとSymbol.asyncDisposeがあるってことだけだよ。
Symbol.iteratorとSymbol.asyncIteratorがあるのと同じさ。
それってasync
への批判であって、using
への批判じゃないよね?
私の理解が正しければ、これって関数を今まで以上に色付けしてるようには見えないんだけど。
これはね、普通の実行と非同期関数が別々の閉じたデカルト圏っていうのを作ってて、それを明示するかどうかの言語設計の話。全ての関数には色(特定の圏で表現できるってこと)があるんだよ。
圏論はスレッド以外にも強力。Javaみたいなスレッドベースだと同期が難しい。JSはモノイド圏、継続渡しで表現できるものに限定されてる感じ。
最近の言語で「同期からでも非同期起こりうるから全部asyncで書け」みたいなのがマジうざい。うまく解決してるのはPurescriptくらい。Eff(同期)とAff(非同期)をターゲットにして呼び出し時に決められるから。
構造化並行性は良いけど、この構文努力はそれより、サーバーで複数のリクエスト捌くみたいなembarassingly parallelな作業のためって印象だな。
マジそれな。Virtual threads、structured concurrency、scoped valuesは良い機能だよ。
素のJavaScriptだと、型がダックタイピングだから、Promiseだろうが結果だろうが関係ないんだよね。この柔軟性で”色問題”もうまく回避できる。問題は、完全にダックタイピングな言語に無理やり型システムを全部追加しようとしたり、async/awaitをコンパイル言語に雑に突っ込んだりした時だけだよ。
TypeScriptなら君のシナリオの型付けも問題ないよ(少なくとも”素の”JavaScriptにない問題はないかな…値がPromiseだったらどうする?)。コンパイル言語もJavaScriptより困ってないし、むしろJavaScriptの方が問題少ないわけじゃない。色問題は構文レベルの問題なんだ!
ただし、Promiseじゃないオブジェクトでもawaitできるんだ。元のオブジェクトがそのまま返ってくる。多くの型付け言語、例えばC#にはこの便利さはない。これがJavaScriptを際立たせてる。Promise.resolve()もPromiseならそのまま返す。実際の型を知らなくても、楽に色付けたり外したりできるよ。
Promiseじゃないものをawaitしても同期実行にはならないんだよ。その後の行はマイクロタスクで実行される。このコードを実行してみて。
const foo = async () => console.log(await ”foo”);
foo();
console.log(”bar”);
async関数は文字列リテラルをawaitしてるだけで、呼び出し元はawaitしてないのに、”bar”が先に表示されて、それから”foo”が表示されるはずだよ。
C#だと、var t = Task.Run(() => ExpensiveButSynchronous());
ってやって、後でそれをawait
するのを止められないわけじゃないよ。既知の長い処理を他のスレッドプールスレッドに任せるのは珍しくない。まさか、awaitできない型を文字通りawaitするって意味じゃないよね…それはどんな静的型付け言語でも意味不明じゃない?
JVMでどう実装されてるか分からないけど、一般的にマルチスレッドってめちゃくちゃ理解するの難しいよね。落とし穴(競合状態、デッドロックとか)について本が書けるくらい。それに比べたら、シングルスレッドのasyncプログラミングなんて楽勝だよ。”関数色”の問題に取り組む方が、マルチスレッドアプリの再現性の低いバグをデバッグするよりずっとマシだね。
ErlangみたいなメッセージパッシングによるShare-nothingなら、マルチスレッドはかなり扱いやすくなる。ぶっちゃけ楽しいとさえ言える。Asyncはシンプルなタスクには良い構文だけど、大きな構造やエラー処理を扱うと辛くなる。明示的なスレッド処理より、何が起きてるか理解するのが難しいと感じるよ。
>Asyncはシンプルなタスクには良い構文だけど、より大きな構造を組んだりエラー処理とかを扱ったりすると、そのシンプルさは崩壊する。
具体的な例ある? async/awaitになってからは、自分にとっては問題になったことないんだよね(まあ、コールバック地獄はひどかったけど)。
Multithreadingってほとんどの言語で似たようなもんだから、これもそうだと思ってたんだよね。あんたのコメントでちょっと不安になって、仕様書見直したわ。そしたら、やっぱり元々思ってた通りの標準的なMultithreadingだったわ。
実装と、実際にどう見えるか( observable behaviour )は別の話だよ。
マジかよ。
const defer = f => ({ [Symbol.dispose]: f })
using defer(() => cleanup())
これ、今気づいたわ。当たり前だろって思った人には”お疲れ様”だけど、それでも言っとく価値ある気がしたんだよね。
ユースケースによっては DisposableStack や AsyncDisposableStack が便利だよ。これらは using proposalの一部で、コールバック登録機能がある。using はブロックスコープだから、条件付きやスコープを超える登録にはスタックが必要なんだ。詳しいコード例付きで解説してるよ。関数の最後にクリーンアップしたい場合も、スタックを使う方法があるんだ。
DisposableStack.move() の目的って何? 収集した .defer() コールバックを現在のスコープから完全に外に出すのに使えるの? 例えばコールスタックを遡るとか。呼び出し元のコンテキストで .defer() コールバックを全部スタックするために DisposableStack を引数で渡す方が簡単じゃない?
そうそう、他のどこにでも移動できるよ。constructor でリソースを確保するクラスでのユースケースはこれ:
class Connector {
constructor() {
using stack = new DisposableStack;
// Foo and Bar are both disposable
this.foo = stack.use(new Foo());
this.bar = stack.use(new Bar());
this.stack = stack.move();
}
Symbol.dispose {
this.stack.dispose();
}
}
この例だと、constructor の途中でエラーが出たら、それまでに確保したリソースがクリーンアップされるようにしたい。でも成功した場合は、インスタンス自体がクリーンアップされるまでリソースはクリーンアップされないようにしたいんだ。
DisposableStackを引数で渡す方法だと、エラー時に解放されない問題があるんだ。だから代わりにローカルスタックを使って、成功時に親や他のオブジェクトに .move() するのが良いよ。こうすると、エラー時はローカルスタックが解放されて、成功時は所有権が移る。これは RAII やエラー時deferみたいな挙動を実現する方法なんだ。具体的なコード例も複数紹介してるよ。
(スニペットの const local は using local であるべきだよ)
これについて去年記事書いたんだ。記事のURLも貼っとくね。 .move() の話は、作業中の DisposableStack をそのまま渡すと関数終了時に勝手に破棄されたり、エラー時にリソースが漏れたりする問題を解決するんだ。 .move() でスタックを”生け贄”みたいに使えるようにして、エラー時は自動破棄、成功時は中身を安全に移動できるようにするんだよ。
golangみたいじゃん。いいね。
これめっちゃ良いアイデアなんだけどさ、> Integration of [Symbol.dispose] and [Symbol.asyncDispose] in web APIs like streams may happen in the future ってあるように、しばらくは一部のAPIしか対応しない状況が続くと思うんだ。
そうなると、”using”とtry/catchを混ぜるか、全部try/catchで書くかってなるけど、後者の方が分かりやすくて「実用的じゃない」って評判になっちゃうのが心配。せっかく良い機能なのに、もったいないよね。
JavaScriptでは新機能が普及するまでいつも時間かかるんだよね。Babelみたいなコンパイラ→言語仕様→NPMやブラウザAPIって流れで、3〜4年かかることもザラ。古いデバイス対応を考えるとさらに待つ必要も。でも開発者はweb API周りに小さいラッパー書くのには慣れてるし、個人的には有用そうな新機能で使いにくいと思ったことはないかな。
もっとコメントを表示(1)
実際、たくさんのライブラリが既にpolyfillを使って対応してるよ。NodeJSのバックエンドとかはかなり前から効果的に使えてるんだ(トランスパイラがあればね)。去年のリサーチでSymbol.dispose対応APIの多さに驚いたよ。フロントエンドでは独自の仕組みがあるから少ないだろうけど、テストライブラリとかで役に立つと思う。バックエンドでの普及が進んでるから、それも時間と共に進むと思うな。
この機能に対応してないAPIでも、DisposableStackを使えば”using”を使えるよ。DisposableStackにリソースを登録すれば自動でクリーンアップしてくれるんだ。try/catchよりまだシンプルだし、特にリソースが複数ある場合はね。だから、既存のリソースがアップデートされるのを待たなくても、ランタイムが新しい構文に対応し次第すぐに採用できるんだ。
これってJavaScriptの世界だとだいたいpolyfillで解決されるんじゃないの?
自分は結構Symbolベースの機能を自分で使ってるJSライブラリに追加してるよ(名前付きメソッドはリスク高いけどね)。クラスを継承してSymbolメソッドを追加するみたいなやり方だね。これでヤラかしたことはまだないけど、まあ保証はできないよ。でも今のところすごくうまくいってる。
元のクラスにSymbolメソッド直接追加するより、SomeStreamClass_にsomeSymbolがもうあるかチェックして、あったら例外出すとか警告ログ出すとかすれば、もっと良くなるかもね。
元のクラスにSymbolメソッド直接追加するより、ずっといいね。:p
だからTC39はプロトコルみたいな根本的な言語機能に取り組む必要があるんだよ。Rustだと、新しいtraitを定義して既存の型にimplできるじゃん。これでも欠点はあるけど(orphan ruleは問題を防ぐけど肥大化を招く)、ユニークなSymbol機能がある動的な言語なら、何かを思いつくのはきっともっと簡単だろうな。
Dynamic languageならプロトコルいらないんじゃね?
既存のオブジェクトを”conform to AsyncDisposable”に合わせるなら、function DisposablImageBitmap(bitmap) { bitmap[Symbol.dispose] ??= () => bitmap.close(); return bitmap }
using bitmap = DisposableObserver(createImageBitmap(image))
みたいにすればいいし、ImageBitmap全部をDisposableに合わせるならImageBitmap.prototype[Symbol.dispose] = function() { this.close() }
ってすればいい。でもこれって”trait conformance”がグローバルに漏れて危ないんだよね。他のコードと衝突したり。プロトコルだとこれどうなるの?
モジュールシステムを使えばプロトコル実装をスコープに入れられるかもね。これでmonkey-patching問題解決できるかもよ。でもかなり新しいアイデアだし、TC39はリスク回避型、ブラウザ側は機能回避型だし、言語も複雑で面白いアイデアのほとんどに問題が出ちゃうんだよね。
resize observerの切断ってこの機能の例として微妙じゃない?
「しばらくは一部のAPIとかライブラリだけ対応する状況になる」って?まぁウェブなんてそんなもんだよ、ようこそ(笑)JavaScript 1.1の頃からずっとこんな感じ。欲しいものにはshims使って、後で言語になったりさ。
C#を思い出すねぇ。C#のIDisposibleとかIAsyncDisposibleって、ロック処理とかキューの仕組み、一時的なスコープとか、本来ならちゃんと抽象化すべきことをうまく書くのにすごく役立つんだよ。
それって提案してる人がMicrosoftの人だからだよ。C#と違う構文にする提案を何度も却下してるらしいよ。https://github.com/tc39/proposal-explicit-resource-managemen… 他にもいくつかリンクあるね。
そしてその人、最近レイオフされちゃったみたいだよ。https://news.ycombinator.com/item?id=43978589
これさ、基本的にC#から持ってきたようなもんだよ。元の提案書でも隠してなくて、Pythonのcontext managersとかJavaのtry with resources、C#のusing statementsとかusing declarationsとか、全部参考にしてるって書いてるんだよね。using
がキーワードでdispose
がフックメソッドなのがもうかなり大きなヒントだろ?
JavaScriptの後方互換性は分かるけど、Symbol.disposeって構文がすごく変に見えるんだよね。これって配列なのに関数みたいに呼ばれてて、配列の中にメソッドのハンドルが入ってるみたい。この構文ってなんて呼ばれるの?もっと詳しく知りたいな。
dynamic keys(オブジェクトリテラルで[ ]を使うやつ)って10年近く前からあるし、メソッドのショートハンドもあるよ。Symbolは文字列でアクセスできないから、これらを組み合わせるんだ。だから、基本的に新しい構文じゃないんだよね。
そうそう、iterableなコレクションを作ってる人にはおなじみだよ。クラス宣言とかオブジェクトリテラルで同じdynamic keyの構文を使うんだけど、そのときはSymbol.iteratorっていうWell-known Symbolをメソッドに使うんだよね。
他の人は「何を」説明してるけど、「なぜ」この構文なのか誰も答えてないみたいだね。メソッド名にSymbolを使うことで、既存のメソッドと明確に区別できるんだ。つまり、文字列じゃなくてSymbolをメソッド名に使うことで、この新しいAPIの名前衝突を防いで、うっかりクラスがdisposeable扱いされちゃうのを避けられるんだよ。
これが一番重要な理由だね!
多分dynamic property accessかな? 基本的にオブジェクトのプロパティにはいつも[ ]を使ったインデックス構文でアクセスできるんだ。だからobject.fooはobject[”foo”]とかobject[”f” + ”o” + ”o”]と同じなんだよ。 普通、[ ]の中のキーは文字列に変換されるんだけど、Symbolだけは例外で、そのままキーとして使えるんだ。 最後のポイントは、特定の”well known symbols”があること。Symbol.disposeもその一つなんだよ。
それは違うと思うな、オブジェクトリテラルでのdynamic keyだよ。 例えばこんな感じ。
const key = ”foo”;
const obj = { [key]: ”bar” };
console.log(obj.foo); // ”bar”が出力される
前の人が言ってるのも確かに可能だしよく使うんだけど、最初の質問で言ってた構文はオブジェクトリテラルの中じゃなくて、オブジェクトのプロパティにアクセスするときの話だと思うな。前のコメントが長くなりすぎそうだったから言わなかったけどね。でも、うん、オブジェクトリテラルでプロパティを設定するときとか、クラスの中でも似た構文があるよ。
詳しい人がもっと教えてくれると思うけど、多分こういうやつ(
const x = { age: 42 };
x[Symbol.name] = ”joe”; // これ
)から派生したんじゃないかな。だから結構理にかなってるんだよね。
この構文って結構前から使われてるんだよ。JavaScriptのイテレーターも同じ構文を使ってて、もう10年近くJavaScriptの一部なんだ。
それって記法の話だよ。
コードが obj.function() なら
function() って書いてるだけ。
objSymbol.dispose なら
Symbol.dispose って書いてるだけだよ。
Symbol.disposeはシンボルキーさ。
objSymbol.dispose を Symbol.dispose って書いてる」
じゃあ objSymbol.dispose と Symbol.dispose は同じってこと?
なんか変じゃない?
obj2とかobj3とかもあるかもしれないじゃん。
Symbol.dispose が特定のオブジェクトを指してるって、JavaScriptはどうやって見分けてるの?
[Symbol.dispose]ってのは Symbol.dispose の値がキーになるプロパティを作るやり方で
obj[Symbol.dispose] はそれにアクセスする方法さ。
カッコはメソッド定義のショートカットだから
[Symbol.dispose]: function()
って書くより短いだけ.
ブラケットはね、元々JSは foo: bar みたいにキーを”foo”って文字列にしてたのを
変数 foo の値をキーにしたい時に [foo]: bar って書くために導入されたんだよ.
const o = {}
o[”foo”] = function(){}
o”foo”
let key = ”foo”
okey
key = Symbol.dispose ?? Symbol.for(’dispose’)
okey
oSymbol.dispose
オブジェクトのプロパティアクセスじゃないかな.
myObj[”myProperty”] みたいに.
もし関数なら呼び出せる、myObj”myProperty”.
キーがシンボルなら、myObjtheSymbol って感じだよ.
もっとコメントを表示(2)
きっとさ、彼らが聞いてたのは
動的なプロパティ名の { [thing]: … } って書き方のことだと思うよ.
リソース管理、特にレキシカルスコープが効く時の管理ってさ、
structured concurrency をJSに持ち込もうとしてる人たちがいる理由なんだよ.
詳細はこちらを見てね → https://bower.sh/why-structured-concurrency
structured concurrency を使ったライブラリもあるよ → https://frontside.com/effection
いやマジで、こんなコード書いてる人が
プログラムの実行をどうやって理解したり制御したりしてるのか
全然わかんないわ
async (() => (e) {
try { await doSomething();
while (!done) { ({ done, value } = await reader.read()); }
promise
.then(goodA, badA)
.then(goodB, badB)
.catch((err) => { console.error(err); }
catch { }
finally { using stack = new DisposableStack();
stack.defer(() => console.log(”done.”)); }
});
JS界隈ときたらさ、変数が数値か判定するだけのパッケージ作った奴がいて
しかもそれが超使われてるんだぜ.
ある面ではここまで進歩してるのに
パラメータ型みたいに基本的なのがまだ無いのって
俺にはマジで信じられないわ!
言語で食っててさ、その言語のキーワードの意味とかに慣れてれば
わかるんじゃないの?
みんな自分の好きな言語を理解してるのと同じだよ.
Haskellで食ってる人もいるんだしね.
JSでまじめに開発してんなら、ほとんどTypeScriptで書いてるっしょ。
Web開発ってさ、誰も望んでない”アップグレード”が9割、そのせいで起きた問題の修正が1割なんだよ。
たまーに昔書いたコードとか理解する必要あるけど、そんなのは新人の”登竜門”としてとっとけばいーの。
それまではIE6とか使って何とかしてもらうのが一番てっとり早いワークアラウンドだねw
分かりにくいんじゃなくて、密度が高いんだよ。
Factorの例で言うと、2 3 + 4 * .
って書かれてると、(2 + 3) * 4
より頭の中で処理するのが大変なんだ。
Rob Pikeがシンタックスハイライトを批判するのと似てるけど、俺にはすっげー便利だよ。
速く読めるようになる。
単語の最初と最後だけ見て推測で速く読むのと同じ原理で、タイポにすら気づかないことあるもんね。
まず、君のコードは構文エラーひどすぎて、ValidなJSからほど遠いよ。
推測で修正してみたのがこれ。javascript<br>(async (e) => {<br> await doSomething()<br> while (!done) {<br> ({ done, value }) = await reader.read()<br> }<br><br> promise<br> .then(goodA, badA)<br> .then(goodB, badB)<br> .catch(err => console.log(err))<br> .finally(() => {<br> using stack = new DisposableStack()<br> stack.defer(() => console.log(’done.’))<br> })<br>})()<br>
でもそれ以前に、まともなJS開発者ならこんなコードは絶対書かない。
1. awaitとwhile(!done)を混ぜるなんて普通じゃない。
2. Async IIFEの中ならPromiseチェーンは不要。
3. よくできたJSライブラリはPromiseハンドラを積まない。
4. Async IIFE自体、もう普通いらないよ。
JSってめちゃくちゃになってるように見えるけど,Pythonだってひどいのあるしね.JSの構文はもともとイケてないし,機能増えるとどんどん複雑になるのはしょうがないよ.標準ライブラリもないから,色々グローバルに散らばるし,PHPみたい.新しいC#も初心者にはさっぱり分からない魔法の呪文みたいで,読むのも追うのも大変.JSは成長してモダンなことできるようになったのは素晴らしいけど,初期の作りがアレだから,新しい機能が取ってつけたみたいで,初心者にはハードル高いんだよね.エンタープライズ言語になっちゃって悪い面も出てきた.もうみんなが簡単に習得できる言語だとは思えないってことだよ.
どうしてそう思う?
前コメント(GP)がJSには型がないって文句言ってたでしょ.俺は,ほとんどのJSはTSで書かれてるから,実際には型を使ってるメリットがあるって言ったんだよ.”真のスコットランド人”論法でもないよ.
Forthって,オペランドが+から何百もの単語を挟んで離れちゃう,devilishな書き方ができちゃうんだよね.例えば「2 .... たくさんの単語 .... +」みたいに.これは「.... たくさんの単語 .... 2 +」みたいにオペランドが近くにある方がずっと読みやすいでしょ!Forth書くなら後者みたいに,計算結果に簡単な操作を適用するスタイルが良いよ.いつもできるか分からないけど「...複雑な分子... ...複雑な分母... /」みたいな場合もあるしね.
それ,型を言語に追加するべきだって,もっと強い理由になるね.
要約するとさ:
>> JSは進歩したけどまだ型がないし,そんな基本的なものがない言語で真面目なプログラミングするのはおかしいみたいだね
>> JSでの真面目なプログラミングはTypeScriptでやるんだよ
> 現代C#の一部も混乱してるメスだ
何か例ある?
> ...複雑な分子... ...複雑な分母... /
そう,だからForthでは短い「単語」を使うべきなんだよ.複雑な部分は,短くて自己完結してる,分かりやすい名前の単語に切り出すべきで,そうするとコードが読みやすくテストしやすくメンテしやすくなる.例えば:
じゃなくて:
: compute-numerator a b + c d + * ;
: compute-denominator e f + g h + * ;
: compute-ratio compute-numerator compute-denominator / ;
ほとんど(全部じゃないけど)のForthの本にもこう書いてあるよ.