🌀

ECMAScript Decorators の変遷と最終的な仕様

2021/09/20に公開

はじめに

個人的に TC39 meeting をウォッチしてまとめている @petamoriken です。

ECMAScript の Decorators の提案は何度も改定しています。その割にあまり知れ渡っていません。この記事ではその変遷と最終的な Decorators の仕様について簡単にまとめようと思います。

https://github.com/tc39/proposal-decorators

この発表スライドを見ると流れがわかりやすいかもしれません。

最初の提案(2014年~2015年頃)

最初の提案はこのような形式をしていました。

class Person {
  @nonenumerable
  get kidCount() { return this.children.length; }
}

function nonenumerable(target, name, description) {
  descriptor.enumerable = false;
  return descriptor;
}

Decorators 自体はただの函数で、引数にクラスとプロパティ名そしてプロパティディスクリプタを受け取り、そのプロパティディスクリプタを加工して返すようになっています。こうすることでプロトタイプに Object.defineProperty 相当の処理がなされる前に割り込むことが出来ます。

実装

TypeScript で experimentalDecorators フラグを付けた場合や、Babel の @babel/plugin-proposal-decorators において "legacy" バージョンの場合はこの仕様をもとにトランスパイルされます。

https://www.typescriptlang.org/tsconfig#experimentalDecorators

問題点

この提案ではクラス自体やクラスのメソッドに Decorators を適用することしか考慮されていません。他の提案として進んでいた Public/Private Class Field Declarations や Static Class Features に対応しきれません。

Descriptor-based Decorators の提案(2016年〜2018年頃)

最初の提案は Decorators に渡す引数が複数あったのが問題だったので、ただ一つのオブジェクトを渡すようにしたのがこの提案です。

class Counter extends React.Component {
  state = {
    count: 0
  };

  @bound
  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }

  render() {
    // 本来なら () => this.handleClick() と記述するが @bound の効果で不要になる
    return <div onClick={this.handleClick}>{this.state.count}</div>;
  }
}

// https://github.com/mbrowne/bound-decorator
// あらかじめ bind されたメソッドをインスタンスに直接生やす Decorator
function bound(elementDescriptor) {
  const { kind, key, method, enumerable, configurable, writable } = elementDescriptor;
  if (kind !== "method") {
    throw new Error("Unexpected kind");
  }

  const initialize =
    // private メソッドに対しての場合は key がオブジェクトになる
    typeof key == "object"
        // private メソッドの場合はメソッドが変更されることがないためそのまま bind する
        ? function() { return method.bind(this) }
        // public メソッドの場合は prototype が変更される可能性があるので実行時にメソッドを取得する
        : function() { return this[key].bind(this) };

  // 副作用を起こさないように prototype にはメソッドをそのまま残し extras でインスタンスに bound された函数を追加する
  elementDescriptor.extras = [
    { kind: "field", key, placement: "own", enumerable, configurable, writable, initialize }
  ];
  return elementDescriptor;
}

この例ではクラスのメソッドに Decorators を適用していますが Decorators 函数にやってくるオブジェクトの kind プロパティによって、メソッドなのかプロパティなのかはたまたクラス自身に適用されたのかを識別する事ができます。

それぞれのインターフェースはだいたい以下のようになってます。

interface MethodDescriptor {
  kind: "method";
  key: string | symbol | Object;
  placement: "prototype" | "static";

  method: Function;

  // property descriptor
  writable: boolean;
  configurable: boolean;
  enumerable: boolean;
}

interface AccessorDescriptor {
  kind: "accessor";
  key: string | symbol | Object;
  placement: "prototype" | "static";

  get: () => any;
  set: (val: any) => void;

  // property descriptor
  configurable: boolean;
  enumerable: boolean;
}

interface FieldDescriptor {
  kind: "field";
  key: string | symbol | Object;
  placement: "own" | "static";

  initialize: () => void | undefined;

  // property descriptor
  writable: boolean;
  configurable: boolean;
  enumerable: boolean;
}

interface ClassDescriptor {
  kind: "class";
  elements: Array<MethodDescriptor | ClassDescriptor | FieldDescriptor>;
}

実装

Babel の @babel/plugin-proposal-decorators において "2018-09" のバージョンではこの仕様をもとにトランスパイルされます。

問題点

すべての場合を対応するために仕様がとても複雑になってしまいました。上手く扱うためにはこの複雑な仕様を完全に理解する必要がありますし、特に致命的な問題として実行速度がとても遅くなってしまいました

Static Decorators の提案(2019年3月〜)

今までをふまえてすべての場合に複雑でない形で対応し、なおかつ実行速度を遅くしないような Decorators の仕様を考える必要がありました。この提案ではビルトインな Decorators として @wrap, @register, @initialize, @expose の4つを基本とし、カスタム Decorators を作る場合はそれを組み合わせて作れるようにします。

例えば @wrap をクラスのメソッドに付けると以下のようになります。

class C {
  @wrap(f)
  method() { }
}

// ↓ と等価

class C {
  method() { }
}
C.prototype.method = f(C.prototype.method);

この @wrap を使って、メソッドが呼ばれる度に console.log にログを残すカスタム Decorator の @logged を作るとこうなります。

// decorator @foo { @bar @baz @bing } のような形式でカスタム Decorator を作る
decorator @logged {
  @wrap(method => {
    const name = method.name;
    function wrapped(...args) {
      console.log(`starting ${name} with arguments ${args.join(", ")}`);
      method.call(this, ...args);
      console.log(`ending ${name}`);
    }
    wrapped.name = name;
    return wrapped;
  })
}

class C {
  @logged
  method(arg) {
    this.#x = arg;
  }

  @logged
  set #x(value) { }
}

これらの基本の4つのビルトイン Decorators ではそれぞれが一つの函数を受け取る形になっています。そしてその Decorators に渡した函数が受け取る引数と返り値をそれぞれの場合で独立させて意味を定義することで仕様を単純化しています。

またこれらの Decorators は静的解析のみで既存の ECMAScript のコードに変換できるようになっています。ディスクリプタベースのときのように Decorators 函数の返り値によってクラスのメソッドの定義を削除したり変更したり別のものを追加したり……みたいなことが起きず、単に函数を通すだけです。これによって実行時に遅くなることを防いでいます。

実装

Babel にこの最新仕様をいれるための道筋が立てられ、実際に実装が進められていましたが、中断されました。

問題点

記述するには十分複雑だし、V8 チームによるとそこまで静的ではなく、モジュールの読み込み分のコストが大きくなってしまうとのこと。十分速さに寄与しませんでした。

Property Read/Write Trapping Decorators の提案(2019年12月〜)

Static Decorators でも十分な速さを得ることができなかったため、的を絞って getter/setter のみトラップすることができるようにしたのがこの提案です。Proxy を使った場合と似ているため速く実行できると見積もられています。

今までの Decorators の提案みたいに enumerable を変えるようなことができないため、出来ることは減りますが 95% のユースケースはカバーできるだろうと考えられています。

function logged() {
  return {
    get(target, instance, property, value) {
        console.log(`GET`, target, instance, property, value);
        return value;
    },
    set(target, instance, property, value) {
        console.log(`SET`, target, instance, property, value);
        return value;
    },
  };
}

class C {
  @logged x = 3;
}

この提案は Field/Method Decorators をターゲットにしており、それ以外の Decorators は既存の提案のものを使うことにしています。

実装

ありません。

最終的な提案(2020年8月〜)

Property Read/Write Trapping Decorators を元に改良を加えたのがこの提案です。前回同様に 95% のユースケースを対象としており、トランスパイルやエンジンの実装を容易にし、最適化しやすいようになっています。

function logged(value, { kind, name }) {
  if (kind === "method") {
    return function (...args) {
      console.log(`starting ${name} with arguments ${args.join(", ")}`);
      const ret = value.call(this, ...args);
      console.log(`ending ${name}`);
      return ret;
    };
  }
}

class C {
  @logged
  m(arg) {}
}

Decorators に渡ってくる引数だけを見ると Descriptor-based Decorators に似ています。Decorators をどこに適用するかインターフェースが変わるところも同じです。

違うところは Decorators の返り値が定められていることによって Decorators ができることが制限されているところです。もちろん最適化のためです。

type MethodDecorator = (value: Function, context: {
  kind: "method";
  name?: string | symbol;
  access?: { get(): unknown };
  isStatic: boolean;
  isPrivate: boolean;
  defineMetadata(key: string | symbol | number, value: unknown);
}) => Function | void;

type GetterDecorator = (value: Function, context: {
  kind: "getter";
  name?: string | symbol;
  access?: { get(): unknown };
  isStatic: boolean;
  isPrivate: boolean;
  defineMetadata(key: string | symbol | number, value: unknown);
}) => Function | void;

type SetterDecorator = (value: Function, context: {
  kind: "setter";
  name?: string | symbol;
  access?: { set(value: unknown): void };
  isStatic: boolean;
  isPrivate: boolean;
  defineMetadata(key: string | symbol | number, value: unknown);
}) => Function | void;

type FieldDecorator = (value: undefined, context: {
  kind: "field";
  name?: string | symbol;
  access?: { get(): unknown, set(value: unknown): void };
  isStatic: boolean;
  isPrivate: boolean;
  defineMetadata(key: string | symbol | number, value: unknown);
}) => (initialValue: unknown) => unknown | void;

type ClassDecorator = (value: Function, context: {
  kind: "class";
  name: string | undefined;
  defineMetadata(key: string | symbol | number, value: unknown);
}) => Function | void;

仕様の確定

2022年3月の TC39 meeting で Metadata の提案が別の Stage 2 の提案としてスプリットされ、コアが Stage 3 となり、仕様が確定されました。

https://twitter.com/lcasdev/status/1508534760095813639

実装

Playground が用意されています。

https://github.com/pabloalmunia/javacriptdecorators

TypeScript 5.0 以降で experimentalDecorators フラグを付けない場合や、Babel の @babel/plugin-proposal-decorators において "2021-12" 以降のバージョンでこの仕様をもとにトランスパイルされます。それぞれざっくり違いをまとめると

  • "2021-12": この案の最初のバージョン
  • "2022-03": Metadata が取り除かれたバージョン
  • "2023-01": 色々な Normative Changes が取り込まれたバージョン。特に TypeScript の要望で export の前に Decorators を書けるようになった。

おわりに

Decorators の仕様はどんどん移り変わっていきました。

Angular や NestJS といった TypeScript を使ったフレームワークでは一番古い Decorators がそのまま使われています。

ESNext の Decorators の仕様が変わるとフレームワークやライブラリの恩恵を受けている人たち……というよりかはフレームワークやライブラリを制作している人たちの対応が迫られますが、たとえ利用者であっでも今 Decorators を採用する場合、新しい Decorators の実装が広まっていったときにちゃんと追っていける覚悟が必要になるのかなと思います。個人的にはお勧めしません。

ESNext の特に Stage 1, 2 についてはこのように仕様が大きく変更されることがあります。みなさんも2ヶ月ごとに開催される TC39 meeting をチェックしてみてはいかがでしょうか。

Discussion