プログラミング
TypeScriptコンパイラにGoのDeferを追加する
Adding Go's Defer to the TypeScript Compiler (healeycodes.com)
要約
Go言語のdefer文をTypeScriptコンパイラに追加する試みについて解説しています。deferは関数の終了時に遅延実行される機能で、リソースの確保と解放をペアにするのに便利です。TypeScriptには直接の代替がなく、try/finallyは冗長になりがちです。この記事では、TypeScriptコンパイラのAST変換機能を利用してdefer構文を導入し、Goのようなセマンティクスを実現する過程と、その実装における設計上の考慮事項、特にエラー処理や非同期処理との兼ね合いについて掘り下げています。
全文翻訳
Goのdefer文をTypeScriptコンパイラに追加するのがどれほど難しいか知りたかったのですが、完了した時にはおそらく存在するべきではないと確信しました。
Goでは、defer文は周囲の関数が終了するまで関数の実行を遅延させます。これは、セマフォの取得のように、リソースの取得とクリーンアップを一緒にしておくために最も一般的に使用されます。
```go
func withSemaphore(ctx context.Context, sem *semaphore.Weighted) error {
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
defer sem.Release(1) // リソース解放を遅延
// ... 保護された作業
return nil
}
```
TypeScriptにはdeferの厳密な同等物はありません。try/finallyを使用するかもしれません。
```typescript
async function readFile(path: string) {
await sema.acquire();
try {
// ... リソースを使用
} finally {
sema.release();
}
}
```
しかし、それは少し見栄えが悪いです。
楽しみのために、TypeScriptコンパイラにdefer文をハックして、Goのようなセマンティクスを取得できます。deferは既存のJavaScript機能にマッピングされないため、実行時にGoと同じように機能するJavaScriptコードを出力する必要があります。
したがって、目標は次のようなTypeScriptコードを書けるようにすることです。
```typescript
async function readFile(path: string) {
await sema.acquire();
defer sema.release(); // 新しい!
// ... リソースを使用
}
```
TypeScriptコンパイラ
TypeScriptコンパイラ(tsc)は、主に静的解析エンジンです。その複雑さは、根本的に動的な言語の型チェックと、IDEでのレイテンシ要件を満たすための非常に増分的なコンパイルのサポートにあります。
幸いなことに、defer文を追加するために型やその他の分析についてそれほど心配する必要はありません。tscはすでに「構文Xを認識し、同等の構文Yに置き換える」という仕組みを持っています。
例えば、ES5用にコンパイルする場合:
```typescript
class Foo {
x = 1;
}
```
次のように変換される可能性があります。
```javascript
function Foo() {
this.x = 1;
}
```
概念的には、deferを追加することは、別のツリー書き換えを実行することを意味します。tscはすでに多数のASTからASTへの変換(例:オプショナルチェイニング `?.` は条件付き式に変換される)を実行しているため、新しいツールを追加する必要はありません。
掘り下げるべき複雑さはいくつかありますが、大まかに言うと、deferを含むASTを次のように変換します。
```typescript
function f() {
defer cleanup();
work();
}
```
次のようなものに変換します。
```javascript
function f() {
const __defers = [];
try {
__defers.push(() => cleanup());
work();
} finally {
// ポップして呼び出す
}
}
```
まず、tscのパーサーにdeferがステートメントであることを教える必要があります。DeferStatementを追加する構文種類のリストがあり、単一の式オペランドを取るものとして定義します。
実行する必要のあるチェックがいくつかあります。たとえば、deferステートメントが関数本体内に存在することを確認し、式が呼び出し可能であることを確認し、tscが通常の再帰チェックを実行することを確認します。
```go
func (c *Checker) checkDeferStatement(node *ast.Node) {
c.checkGrammarStatementInAmbientContext(node)
// deferは、それが含まれる関数のライフタイムに結び付けられています
fn := ast.GetContainingFunction(node)
if fn == nil || fn.Body() == nil || !ast.IsBlock(fn.Body()) {
c.grammarErrorOnNode(node, diagnostics.Defer_statements_can_only_be_used_inside_function_bodies)
} else if ast.GetFunctionFlags(fn)&ast.FunctionFlagsGenerator != 0 {
c.grammarErrorOnNode(node, diagnostics.Defer_statements_cannot_be_used_in_generators)
}
// 呼び出しのみがサポートされており、キャプチャ/低下を明確に保ちます
expression := ast.SkipParentheses(node.Expression())
if !ast.IsCallExpression(expression) {
c.grammarErrorOnNode(node.Expression(), diagnostics.The_operand_of_a_defer_statement_must_be_a_call_expression)
c.checkExpression(node.Expression())
return
}
// 通常の呼び出しチェックを再利用します(呼び出し可能なカルー、引数の型など)
c.checkExpression(expression)
}
```
実際の変換コードは非常に冗長なので、ここで再現する代わりに、私が下した設計上の決定を掘り下げ、変換がどのように機能するかをさらに詳しく説明します。
deferがどのように機能すべきか
Goの動作に合わせるには、カルー、レシーバー、および引数の値は直ちにキャプチャされます。
```javascript
let x = 1;
defer console.log(x);
x = 2;
```
これは1を出力する必要があります。
私たちが生き残る必要がある極端なケースは、呼び出し可能なメソッドが再定義される場合です。
```javascript
const logger = {
log(message: string) {
console.log("old:", message);
},
};
defer logger.log("hello"); // deferの後、すべてが変わります
logger.log = (message) => {
console.log("new:", message);
};
```
logger.logが後で再割り当てされても、遅延された呼び出しは元のメソッドを呼び出します。これは、実行がdeferステートメントに達したときに、関数値、レシーバー、および引数がすべて評価されるGoのセマンティクスと一致します。
deferを1つ以上含む関数には小さなスタックが用意され、各deferステートメントに到達すると、そのスタックにクロージャがプッシュされます。関数が終了すると、スタックは逆順(後入れ先出し)にドレインされます。
登録は、関数が開始したときではなく、実行がdeferに達したときに発生します。したがって、if内のdeferは、そのブランチが実行された場合にのみ実行され、ループ内のdeferはイテレーションごとに1回登録されます。
したがって、ユーザーは次のように記述します。
```typescript
async function readFile(path: string) {
await sema.acquire();
defer sema.release();
return await fs.readFile(path, "utf8");
}
```
次のように変換されます。
```javascript
async function readFile(path) {
const stack = [];
try {
await sema.acquire();
const receiver = sema;
const method = receiver.release;
// 実行がdeferステートメントに達した場合にのみクリーンアップを登録します。
stack.push(() => method.call(receiver));
return await fs.readFile(path, "utf8");
} catch (error) {
// 元のエラーを保存して、クリーンアップを実行できるようにします。
} finally {
// 登録されたコールバックを逆順に実行します。
// 非同期関数では、各クリーンアップを次のものに進む前にawaitします。
// クリーンアップもスローする場合、失敗を集計します。
}
}
```
defer awaitのセマンティクスを発明する代わりに、単にエラーとして却下します。ユーザーが関数本体の残りの部分が実行される前にawaitが解決されると想定することを心配しました。さらに、包含関数が非同期の場合、すべての遅延呼び出しはクリーンアップ中に順番にawaitされます。
エラーに関する追加情報
クリーンアップコードも失敗する可能性があるため、変換は3つのルールに従います。
1. 前のものがスローしても、すべての遅延呼び出しが実行されます。
2. 関数本体からの元のエラーは保持されます。
3. 複数のエラーが発生した場合、AggregateErrorで報告されます。
Goはこのような集計ポリシーを必要としません。なぜなら、通常の誤差は値だからです。遅延呼び出しによって返された誤差は、ユーザーが明示的にそれに対処することを選択しない限り処理されません。JavaScriptの例外は制御フローです。したがって、コンパイルされたdeferコードがエラーをスローする場合、それは元の失敗に置き換えるか、組み合わせるか、無視するかを決定する必要があります。
非同期関数は、スローと拒否されたawaitの両方をPromiseの拒否に変換するため、変換には同期スローと非同期クリーンアップ拒否の両方に対して1つの明確なルールが必要です。
```javascript
async function f() {
defer asyncCleanup();
throw new Error("body");
}
```
asyncCleanup() も拒否/スローする場合、f() は AggregateError で拒否されます。
```javascript
AggregateError([
Error("body"),
cleanupError,
]);
```
では、出荷しましょうか?
皮肉なことに、deferの実装は、それがTypeScriptに属さないと確信させてくれました。
エッジケースを実装するほど、deferがTypeScriptに属するという確信が薄れていきました。Goのdeferは、エラーが制御フローではなく値であるため、はるかに自然に感じられます。TypeScriptでは、クリーンアップがスローまたは拒否できる場合、Goには存在しない集計、優先順位、および非同期実行のポリシーが必要になります(パニックはGoの個別のpanicとrecoverセマンティクスを通じて処理されます)。
しかし、希望は失われていません。ECMAScript Explicit Resource Management提案は、異なる方向から同じ問題に取り組んでいます。
上記のasync-semaの例を見てみましょう。deferの代わりに:
```typescript
async function readFile(path: string) {
await sema.acquire();
defer sema.release();
return await fs.readFile(path, "utf8");
}
```
Disposableを使用できます。
```typescript
async function readFile(path: string) {
using _ = await acquirePermit(sema);
return await fsReadFile(path, "utf8");
}
// 上記は次のようなヘルパーを想定しています。
// これはライブラリに埋め込むことができます
async function acquirePermit(sema: Sema): Promise<Disposable> {
await sema.acquire();
return {
[Symbol.dispose]() {
sema.relea