DUICUO

TypeScript ソースコード公開: 驚異の 52,000 行のコード。

著者 | ecznlai

負荷の高いJSプロジェクトにおけるパフォーマンスの問題は、常に解決が困難です。様々なオープンソースJSリポジトリのパフォーマンスプラクティスをレビューしたところ、TypeScriptソースコード内のchecker.tsファイルがかなり強引な手法であることに気付きました。このファイルには、TypeScript型システム全体のロジック52,000行すべてが1つのTypeScriptファイルに含まれており、ファイルサイズはなんと2.92MBにも達します。これは非常に興味深い点です。なぜでしょうか?

有名な checker.ts ファイルについては長い間知っていましたが、GitHub で直接開くことができません: GitHub - microsfot/GitHub: ./src/compiler/checker.ts

さて、VSCodeを起動します。

checker.ts ファイルには 50,000 行のオールインワン コードが含まれています。

このファイルは非常に複雑で、5万行に及ぶオールインワンファイルに型システムのロジック全体が詰まっています。これは、TypeScriptのソースコードメンテナーがコードを書けないことを意味するのでしょうか?もちろんそうではありません。いくつかの資料をレビューし、実装を読んだ後、私は非常に感銘を受け、その詳細について私の考えをこの記事にまとめました。

低仕様の名前付きパラメータ

ご存知の通り、JavaScriptの様々な仕様では、複数のパラメータを渡す際にオブジェクトを使用し、関数内でそれらをデストラクチャリングすることが推奨されています。ほとんどの場合、これは問題ありませんが、TypeScriptコンパイラでは無駄が極限まで増幅されてしまいます。そこで、コメントで名前付きパラメータを表すという低コスト版を採用しました(この行は、C#の父であり、プログラミング界のレジェンドであるAnders氏によって書かれました。C#とTypeScriptの父であり、世界トップクラスのプログラマーの一人です。 - Tencent Cloud 開発者コミュニティ - Tencent Cloud)

名前付きパラメータとは何でしょうか?基本的には、名前タグが付いた関数です。関数を呼び出す際に、タグを指定してパラメータを渡すことができます。これは、MoonbitやSwiftのタグ付き関数など、他の言語における基本的な操作です。

 fn add(~left: Int, ~right: Int) -> Int { return left + right; } add(left: 1, right: 44); // 🉑 add(right: 44, left: 1); // 🉑 add(1, 2); // 🉑此时会自动匹配到left 和right

TypeScriptに名前付きパラメータが必要な理由:TypeScriptのような高頻度呼び出しのシナリオでは、`options`オブジェクトの構造化分解によってパラメータを渡すと、不要なメモリオーバーヘッドが大幅に増加します。これにより、型チェック中にメモリスパイクが発生し、ガベージコレクションとmem_copyが頻繁に実行されます。さらに重要なのは、リテラルキーの順序がV8のインラインキャッシュ最適化に影響を与える可能性があることです。不適切に記述されたリテラルは、関数呼び出しのフィードバックに深刻な悪影響を及ぼし、TurboFanによるさらなる最適化を妨げ、最終的には大幅なパフォーマンス低下を引き起こす可能性があります。

V8関数呼び出しのフィードバックスロットがSMIからAnyに変更されると、TurboFanコード生成のアセンブリ速度が3倍遅くなります。この問題に関する詳細な議論と実例については、こちらをご覧ください。

可能な限り数字を使用してください。

オブジェクトと文字列のオーバーヘッドが高すぎるため、switch、const enum、およびさまざまな enum ビットマップ フラグなどの設計が使用されます。一方、V8 の小さな整数はオーバーヘッドがありません (SMI タグ付きポインターの値自体がオーバーヘッドと見なされない場合)。

const enumの無制限の使用

`const enum` の特徴の 1 つは、列挙値を関数内に直接インライン化して即値にすることができるため、最大限の最適化が可能になることです。

しかし、コミュニティ内では `const enum` の使用は推奨されないというのが一般的な意見であり、TypeScript のメンテナーの中にはそれを間違いだと考える人もいます。

しかし、この記述は実際にはかなり厄介です。確かにこれは間違いであり、使用はお勧めしませんが、TypeScript ソース コードには定数列挙型が飛び交っています... (800 個を超える定数列挙型。この機能がなければ、TSC はおそらくはるかに遅くなるでしょう)。

ESM/CJS のパフォーマンスの問題: 特にエクスポートが多い場合。

`export` がメンバーを過度にエクスポートすると、V8 は内部的にこれらのオブジェクトを Slow Properties 辞書として処理します。ほとんどの場合、これは無害ですが、頻繁にアクセスされるモジュール内の定数が数百万回参照される場合、`export.xxxxx` 内のポイントごとの参照のオーバーヘッドが顕著になり、特にエクスポートが数百ある場合は顕著になります。このような場合、ポイントごとの参照のオーバーヘッドは相当なものになります。

 const constant = require(`./constant`); module.export = function getXXConfig() { return constant.xxx + constant.bbb; } // 由于constant 上有几百个常量, // 即使是constant.xxx 这样简单的语句// 在百万次调用的时候,其耗时将不可忽略( 几百ms 以上)

一方、checker.tsクラスはすべてを1つにまとめているため、この問題は発生しません。すべてが関数スコープ内にあり、クエリ時間はO(1)です。

ESM にはプライベートエクスポートはありません。

プロジェクト内では制限なく使用したいが、そのエクスポートを外部の npm に表示したくないというタイプのエクスポートがあります。つまり、ESM ではこのプライベート エクスポートの機能が提供されません。

 import D from '@tencent/xxx/a/b/c/d'; // ⬆️ 我不期望别人能这样import 我内部的东西

TypeScript ではこの機能が必須ですが、どのように実装されているのでしょうか? `/** @internal */` アノテーションを通じて実装します。例:

@internal でマークされた項目は、d.ts の生成時に消去されます。これにより、間接的に ts リポジトリの外部からはインポートできなくなりますが、ts リポジトリ内では自由にインポートできるようになります。

TypeScript では、`let` や `const` の代わりに `var` を多用します。

例えば、一部の関数ではパフォーマンス上の理由から `const` や `let` を省略し、`var` のみを使用しています。TypeScript では次のように記述されます。

詳細については、github.com/microsoft... をご覧ください。

要点は、TypeScript のシナリオでは、V8 などの JavaScript ランタイムによる TDZ チェックがパフォーマンスに大きな影響を与える可能性があるということです...結局のところ、それは 50,000 行のコードです... (製品ビルドが開発ビルドよりもはるかに高速である理由の 1 つです)。

String.prototype.xxx に何かを注入する

このような操作は、一般的な JS/TS プロジェクトでは間違いなく軽視されるでしょうが、静的型付け言語では独自の基本型を拡張して利用できないのはなぜでしょうか。(Swift/Go などの言語では、string/int をベースに新しい型を作成するのは基本的な操作です。)

クラスレスプログラミング、コンポジションプログラミングの提唱

checker.ts ファイルには、クラスや継承がほとんどない、数万行に及ぶコアロジックが含まれています。関数合成によって完全に構造化されており、Rust の `impl` キーワードを使った TypeScript 実装のように見えます。

コード内のほとんどの関数はこのスタイルに従っています。最初のパラメータは「コアインターフェース」で、他のパラメータは対応するパラメータです。もちろん、継承よりもコンポジションの方が優れているという点は、近年業界で合意されています。

もちろん、アーキテクチャ自体よりも、クラスの継承に関連する潜在的なパフォーマンスの問題を考慮して TypeScript が設計されたと私は信じる傾向があります。

例えば、V8エンジンのシナリオでAがBを継承し、Bにメソッドfnが定義されているとします。A.fn(); B.fn(); が呼び出された後、AとBの継承関係が異なる場合、fnが呼び出すフィードバックスロットはモノモーフィックからポリモーフィックに変化します。継承が3つを超えると、メガモーフィックになります。これはエンジンICの最適化効果に影響を与え、パフォーマンスの低下につながります。

「テーブル駆動型」のような、いわゆる一般的な「フロントエンドデザインパターン」がなぜ使用されなかったのでしょうか?

ソース コードには、AST ノードの種類に基づいて実行されるさまざまなロジックのインスタンスが多数含まれており、これらのロジックはすべて if else if else または switch ステートメントとして記述されています。テーブルからプロセスを駆動するために Record<Kind, Fn> アプローチを使用しないのはなぜでしょうか。

理由は簡単です。テーブル駆動型コードは V8 のような実行時静的解析では最適化できず、テーブル駆動型コードが数十倍も遅いという事実はインフラストラクチャにとって受け入れがたいからです。(悪意はありませんが、JavaScript のテーブル駆動型コードはシナリオに依存します。高頻度の呼び出しには使用せず、イベントセレクターを記述する方がより適切なシナリオです)。

言語機能の観点から見ると、TypeScriptには本格的なパターンマッチングと列挙型ADTが不可欠ですが、原則として、TypeScriptは現時点では新しいランタイム機能を組み込む予定はありません。これは非常に厄介な問題です。テーブル駆動やパターンマッチングが不可能なため、C言語のようなコードになり、x is X述語を多用することになります。

基本的にtry-catchはありません

Goと同様に、checker.tsは戻り値とcontext.xxxへの書き込みによって例外を通知します。これはパフォーマンス上の理由もありますが、チェック例外が存在しないことから、このメソッドでチェック例外を型指定する必要があると合理的に推測できるからです…(もちろん、AndersはC#の設計を参考にして、おそらく非チェック例外の支持者だったのでしょう)。

ファイルの数が多すぎることが本当の問題です。TypeScript 名前空間が未完成なのは残念です。

大規模な JS/TS プロジェクトに取り組んだことがある人なら誰でも、ファイル数が多いと何かを見つけるのが難しくなり、インポート ステートメントを見つけるために 12 個以上のファイルを検索しなければならない場合もあることを知っています。

— これは、なぜ使うためにインポートする必要があるのか​​を示しています。MoonbitやRustのようにユーザーフレンドリーなモジュールシステムは実現可能でしょうか?⬅️ しかし、これは依然として実行時の変更を伴うため、現段階ではTypeScriptでは対応できません。もちろん、TC39でもこの機能は今後検討されません。TypeScript Pro Maxの登場を待ちましょう。

名前空間について: Go、Rust、C++ に精通している人は、名前空間がパッケージと言語シンボルを管理するために使用される機能であり、業界で一般的に使用されているソリューションであることを知っているはずです。

ESMが実装される以前、TypeScriptは名前空間機能の本格的なバージョンを実装しようと試みました。しかし、ランタイム実装を実装しないという新たな決定により、この機能は成熟する前に放棄され、TypeScriptはESMに完全に移行しました。今日でも、TypeScriptのソースコードは名前空間を多用したり、名前空間機能をシミュレートするためにESMを使用したりしています。

最後に、衝撃的な発言があります。JavaScript は TypeScript の進化を深刻に妨げてきました。

TypeScript が JavaScript/TC39 にこだわり続けてランタイム機能を放棄し続けるのであれば、おそらくすでに最終形態に達していると言わざるを得ません...。TypeScript の型システムはすでにかなり完成度が高く、Union Types や競合他社をリードする制御フロー解析技術など、一部の機能は他の言語の追随を許さないものもあるため、これ以上の進化はないでしょう (ただし、2024 年現在、TypeScript にはまだ ADT + パターン マッチングの本格的なバージョンがありません。これはランタイム機能であるため、型を消すだけでは解決できないためです)。

もちろん、TC39では最近多くの新機能が導入されましたが、静的型システムが欠如しているため、これらの機能はむしろ役に立たないように見え、TS39に似ていると言えるほどです。例えば、待望のRecordとTuple機能はステージ2に到達しましたが、事情通ならこれらの機能が明らかにTypeScript向けに設計されていることを理解しています。JavaScriptでこの機能を使用することは、実行時に強く型付けされるため、どこにでもvoid*を渡すのと変わりません。つまり、one_record.xにアクセスしても実際にはxが定義されていない場合、undefinedを返すのではなく、直接エラーが発生します。

さらに、これは非常に強力で、匿名構造体定義オブジェクトとメモリ構造を組み合わせたソリューションのC言語版と言えるでしょう。主要ブラウザはおそらくこれを実装したくないでしょう。エンジン内のJavaScriptオブジェクトモデルを大幅に見直す必要があるからです。もし実装できれば、そのパフォーマンスに非常に期待しています。

要するに、現在のTypeScriptのソースコードリポジトリから判断すると、JavaScript固有の言語特性がTypeScript自身の実装を著しく制限していることがわかります。しかし、TypeScriptは新しいランタイム機能を追加せず、型システムのみを追加すると約束しています。これは、特にTypeScriptのソースコードに反映されているように、非常に矛盾しています。もしこれが企業内だったら、CRに昇格するのはおそらく大惨事でしょう(悲しいことですが)。

終了

checker.ts ファイルにはすでに数万行のコードと、制御フローが極めて複雑な多数の if-else 文が含まれています。名前付き para コメントも手書きで記述されており、const、let、class 文も使用されていません。さらに、コードから明らかなように、TypeScript は ESM や CJS のようなモジュールソリューションを非効率的だと軽視し、結果として未完成の名前空間モジュールソリューションを生み出しています。

要するに、JavaScript の機能が限られていたため、ソースコード実装はかなり複雑になっていました。しかし、TypeScript のコンパイラパイプラインアーキテクチャ全体は非常にエレガントで簡潔です。特に、Transformers と Anders が提唱した LSP によってもたらされた IDE 革命は顕著です。これについては、機会があれば別の記事で書きたいと思います。