🌀

Stage 2 Decorators の変遷と最新仕様

9 min read

はじめに

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

ESNext の Decorators の提案は何度も改定しています。その割にあまり知れ渡っていません。この記事ではその変遷と2021年7月現在における最新の 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 自体はただの函数で、引数にクラスとプロパティ名そしてプロパティディスクリプタを受け取り、そのプロパティディスクリプタを加工して返すようになっています。こうすることでクラスの prototypeObject.defineProperty される前に割り込むことが出来ます。

実装

TypeScript で experimentalDecorators フラグを付けた場合や、Babel の @babel/plugin-proposal-decoratorslegacy フラグを付けた場合はこの仕様をもとにトランスパイルされます。

問題点

この提案ではクラス自体やクラスのメソッドに Decorators を適用することしか考慮されていません。ESNext の他の提案として進んでいる 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(v7.1+) の @babel/plugin-proposal-decorators ではこの仕様をもとにトランスパイルされます。

問題点

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

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 にこの最新仕様をいれるための道筋は立てられているみたいです。

https://hackmd.io/44ErLPn8Qi6FshyoTcrXcA

【2019/9/23 追記】

Babel への実装が始まりました。

https://github.com/babel/babel/pull/10388

【2020/4/1 追記】

フィードバックを得た結果、問題点が共有されました。

問題点

記述するには十分複雑だし、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;

実装

まだありません。

【2021/7/21 追記】

Playground が作られました。

https://github.com/pabloalmunia/javacriptdecorators

おわりに

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

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

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

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

Discussion

ログインするとコメントできます