🎄

using宣言 / TypeScript一人カレンダー

2024/12/23に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の21日目です。昨日は『ECMAScript Private Fields』を紹介しました。

ECMAScriptの進化とusing宣言

昨日の記事では、クラス実装時のモダンな記法としてECMAScript Private Fieldsを紹介しました。

ここ数年、エラーハンドリングをめぐる事情や、バックエンド・フロントエンド境界がより曖昧になっていることによるprototype情報の取り扱いの事情など、クラス設計の観点に変化が生じていると言えます。この話題は以前の記事で紹介しました。

とはいえJavaScript / TypeScriptプログラミングにおいてクラス自体は依然として言語の中核であり、未だに進化し続けています。本日はJavaScriptに新たに追加されようとしている、新しい仕様をご紹介します。

それはusing宣言という変数宣言です。

using resource = new Resource();

using宣言は、TypeScript 5.2から利用可能になった新しい変数宣言のための構文です。2023年8月に加わったばかりの、TypeScriptの歴史でも比較的新しい部類の機能です。

using宣言とは?

using宣言は TC39で議論されているExplicit Resource Managementのプロポーザルに基づく構文および機能です。現在Stage 3に入っており、これは全体の議論の段階としては後半であり安定度は高いものの、まだ完全にECMAScript標準仕様(Stage 4)には至っていません。TypeScriptではこのプロポーザルがStage 3に到達したため先行で実装しており、TypeScript 5.2以降では次のような書き方が可能です。

class Resource {
  [Symbol.dispose](): void {
    console.log("disposed");
  }
}

// const resource = ではなく、下記のように書く
using resource = new Resource();

// ここで何かの処理を行う

// スコープを抜けると自動的に resource[Symbol.dispose]() が呼ばれる

resource 変数がブロックスコープを抜ける際、自動的に [Symbol.dispose]() が呼ばれるため、リソース解放のためだけにいちいち finally ブロックを挟む必要がありません。従来であれば次のように書く場面を置き換えることができるということです。

class Resource {
  dispose(): void {
    console.log("disposed");
  }
}

const resource = new Resource();
try {
  // 何かの処理
} finally {
  resource.dispose();
}

Symbol.disposeはこの機能で使うために提供されるシンボルで、他に類似のものとしてはSymbol.asyncDisposeも用意されています。こちらは名前から推察できるように、Promiseを扱うことができます。リソースの解放に外部I/Oやハードウェアが絡む処理の場合は非同期で扱いたいというニーズがあるため、ちゃんとフォローされているのがありがたいです。

finallytryだけの組み合わせをシンプル化

finallyブロックを使いつつcatchブロックを書かないケースにおいては「tryブロックがあるのに catchブロックがない。このコードはエラーハンドリングをしたくないのか、それともハンドリングし忘れなのか」という曖昧さを生んでしまいます。

try-finallyのパターンはtry-catchと比較するとなじみが薄い人もいますし、リソース解放だけを目的にしたい場面で try/finally を書くと、記述量が増えて読み手を戸惑わせます。エラーハンドリングを意図したtryなのか、リソース解放を意図したtryなのか、あるいは両方なのか、tryに対する読み解くべき背景が増えることになります。

using宣言はこの点をシンプルにした新構文です。ただし、プロポーザルがまだ Stage 3 であることから、ECMAScriptの言語仕様へ完全に取り込まれたわけではありません。今後仕様が変更される可能性もないとは言えないため、プロダクションでの積極採用はやや早いかもしれませんが、筆者はデバッグツールなどの範囲で早期導入してみて、便利さを実感しています。

次の節では、筆者がどのようにusing宣言を採り入れたかをご紹介しましょう。

デバッグ用ツールへの導入例

以下は筆者が実際に導入してみた例です。データベースアクセスや任意の処理に対しusing宣言を使うだけで、スコープを抜けるタイミングに合わせてパフォーマンスログを記録する仕掛けを作りました。

import { magenta } from "yoctocolors";

import { logEnd, logStart } from "./log";

export class DbAccess {
  #name: string;
  #timer: ReturnType<typeof logStart>;
  readonly #label = "DB Access";

  constructor(name: string) {
    this.#name = name;
    this.#timer = logStart(this.#decorateStart(` ${this.#name} `), this.#label);
  }

  #decorateStart(v: string): string {
    return magenta(v);
  }

  #decorateEnd(v: string): string {
    return magenta(v);
  }

  [Symbol.dispose]() {
    logEnd(this.#decorateEnd(` ${this.#name} `), this.#label, this.#timer);
  }
}

このDbAccessクラスでは、コンストラクタ内でログ出力を始め、 [Symbol.dispose]() 内で処理が終了したことをログに残すという仕組みを持たせています。例えば SQLite や PostgreSQL などのクエリを実行する箇所で処理時間を計測し、スコープ終了時にログを出力することができます。DbAccess以外にもHandlerProcessなどのクラスを別で実装しmagenta()以外の色でログを残すと、一目見てわかりやすいCLIツールが実装できます。

次のコードは、データベースからUsersを取得する例です。これは業務コードの抜粋ではない架空のコードです。new DbAccess("getUsers")インスタンスの生成によって、この処理にかかる時間を計測します。

async function getUsers(): Promise<Users> {
  // 破棄時の処理にしか使わないため未使用変数のプレースホルダー
  using _ = new DbAccess("getUsers");

  // DBクエリ実行
  const rawUsers = await db.query(sql`SELECT * FROM Users`);

  // バリデーション処理や groupBy 処理など

  return validUsers;
}

従来であれば try/finally ブロックで明示的にタイマーのend()処理を呼び出す必要がありましたが、using 宣言を使うとスコープ終了時に自動で [Symbol.dispose]() が呼び出されるため、先頭に1行置くだけで十分と手軽です。もしDBアクセスがエラーで終了した場合でもリソース解放やログ処理が一貫して行われるため、タイムアウトで終了した場合などに、その計測も漏らさずにできるあたりが非常に便利です。

usingはリソース破棄だけに使うべきか

usingおよび [Symbol.dispose]()という名前や仕様から、変数スコープ終了と同時に明示的リソースを解放・破棄する用途が想定されているのは確かです。

今回の例ではデバッグ・ログ用途に流用してみましたが、このような破棄を伴わない「finallyの代替」を「本来の使い方ではない」と捉える否定的な意見が出ることも予想できます。筆者としては本記事の目的は「こういう挙動から、こういうアイデアが思いつくこともあり得るという示唆」であって、今後すべての計測やログにusing宣言を使うべきという立場ではありません。

現状は提案がStage 3であることもあり、今後どのように広まり、どのようなエコシステムの形成が進むかは注目です。

将来への期待

using宣言は、ECMAScript仕様にまだ正式に取り込まれていないものの、採用以降はコミュニティの動向やエコシステムのサポート次第で、今後の業務コードでも自然に使える時代が来るかもしれません。

他言語では、C#のusingステートメントや、Pythonのwithが同じような仕組みを提供しており、スコープ終了時点でリソースを解放するというパターン自体はかつてから広く採用されています。

また、Goのdeferなども近い考え方を持ち「最後にまとめて実行したい処理」を記述する際に有用です。

今後はJavaScript / TypeScriptの世界でも、そうした他言語のイディオムが徐々に流入してくると予想され、他言語のこの辺りのキャッチアップをしておくと、using宣言についてすぐに実務で導入するアイデアに結び付けられるでしょう。

おまけ:計測処理の全容

最後に、今回例示したログ機構の実装を簡単に示しておきます。実際には yoctocolorstime-span などの軽量ライブラリを活用し、スコープの開始時点と終了時点でログを出力しています。

次のコードは、実際に筆者が使用しているログ計測処理について、前述にて紹介しきれなかった残りのコードです。

import timeSpan from "time-span";
import { dim, gray, yellow } from "yoctocolors";

function removeAnsiCodes(text: string): string {
  return text.replace(/\u001B\[[0-9;]*m/g, "");
}

const width = 90;

function isoString(): string {
  return new Date()
    .toISOString()
    .split("T")
    .map((v, i) => {
      return i === 0 ? dim(v) : v;
    })
    .join("T");
}

export function logEnd(
  name: string,
  context: string,
  end: ReturnType<typeof logStart>,
): void {
  const text = [name, context, "End"].filter((v) => v !== "").join(" ");

  console.info(
    [
      text,
      dim(gray("─".repeat(width - removeAnsiCodes(text).length))),
      isoString(),
      yellow(`(${[end(), "ms"].join(" ")})`),
    ].join(" "),
  );
  console.info("");
}

export function logStart(
  name: string,
  context: string,
): ReturnType<typeof timeSpan> {
  const text = [name, context, "Start"].filter((v) => v !== "").join(" ");

  console.info(
    [
      text,
      dim(gray("─".repeat(width - removeAnsiCodes(text).length))),
      isoString(),
    ].join(" "),
  );
  console.info("");

  return timeSpan();
}

このように、logStart()で計測を開始し、logEnd()で時間を表示したりメッセージを出力しています。using宣言 によってスコープを抜けるときに [Symbol.dispose]() が呼ばれて内部的に logEnd() を実行してくれる、という仕組みです。確実にlogEnd()を呼ぶという挙動は、計測やデバッグ、リソース破棄といった観点で役立ちそうです。

明日は『tsx TypeScript Execute』

本日は『using宣言』を紹介しました。明日は『tsx TypeScript Execute』を紹介します。それではまた。

Discussion