[JavaScript講座] メタプログラミング

当ページのリンクには広告が含まれています。
目次

メタプログラミングとは?

簡単に言うと、「言語やランタイムそのものの挙動を、コードでカスタマイズ・拡張するプログラミング手法」です。

普通のコード:

  • 「データ」を処理するコードを書く

メタプログラミング:

  • 「コードや言語の振る舞い」を処理するコードを書く(= コードを“対象”にする)

JavaScriptでの代表的な手段:

  • Symbol(特に well-known symbol)
  • ProxyReflect
  • タグ付きテンプレート
  • (他にも eval, Function コンストラクタ、decorator 提案など)

この記事では「安全&実用寄りの範囲」に絞って解説します。

Symbolの概観

Symbolとは?

Symbol は ES2015 で導入された 「一意な識別子」 を表すプリミティブ型です。

const s1 = Symbol("id");
const s2 = Symbol("id");

console.log(s1 === s2); // false(description が同じでも別物)

特徴:

  • 文字列や数値とは別の 新しいプリミティブ型
  • 同じ description を渡しても、毎回異なる値
  • オブジェクトの「衝突しない秘密プロパティキー」としてよく使われる
const ID = Symbol("id");

const user = {
  name: "Taro",
  [ID]: 123
};

console.log(user[ID]);        // 123
console.log(Object.keys(user)); // ["name"] → Symbolキーは列挙されない

well-known symbol とは?

Symbol.iterator のような あらかじめ仕様で予約されている特殊な symbol をwell-known symbol と呼びます。

well-known symbolは、ある Symbol をプロパティキーとして実装すると「言語の機能」がそのオブジェクトを特別扱いしてくれます。

この記事では、次の4つを解説します。

  • Symbol.iterator
  • Symbol.toStringTag
  • Symbol.toPrimitive
  • Symbol.hasInstance

Symbol.iterator

const range = {
  from: 1,
  to: 3,
  *[Symbol.iterator]() {
    for (let n = this.from; n <= this.to; n++) {
      yield n;
    }
  }
};

for (const n of range) {
  console.log(n); // 1 2 3
}

ここで起きていること:

  • for...of は、オブジェクトに Symbol.iterator プロパティがあれば、それを呼び出してイテレータを得る(言語仕様)
  • [Symbol.iterator] を実装することで、for...of に“自作型の振る舞い”を教えている

つまり、「イテレーションという言語機能に、自分の型を正式参加させるフック」が Symbol.iterator です。

Symbol.toStringTag:Object.prototype.toString をカスタマイズ

Object.prototype.toString.call(obj) は、
オブジェクトの「内部クラス」のようなものを文字列で返します。

console.log(Object.prototype.toString.call({}));        // [object Object]
console.log(Object.prototype.toString.call([]));        // [object Array]
console.log(Object.prototype.toString.call(new Date));  // [object Date]

これを 自作オブジェクトでカスタマイズ できるのが Symbol.toStringTag

class MyCollection {
  get [Symbol.toStringTag]() {
    return "MyCollection";
  }
}

const c = new MyCollection();

console.log(Object.prototype.toString.call(c));
// [object MyCollection]

使い所:

  • ライブラリで「型名っぽいもの」を表現したいとき
  • デバッグ・ログ出力で、オブジェクトの種類を判別しやすくする

実務ではそんなに頻繁ではないですが、「言語標準の Object.prototype.toString 振る舞いをフックできる」というメタ的なポイントが重要です。

Symbol.toPrimitive:プリミティブ変換の制御

オブジェクトが「数値や文字列に変換される」場面は多くあります:

  • + 演算(数値加算 or 文字列結合)
  • Number(obj) / String(obj) / テンプレートリテラル ${obj}
  • 比較演算 > < など

デフォルトでは、

  1. obj.valueOf() を試す
  2. ダメなら obj.toString() を試す

…という挙動ですが、
Symbol.toPrimitive を実装すると、ここを完全にカスタマイズできます。

class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === "number") {
      return this.celsius;
    }
    if (hint === "string") {
      return `${this.celsius}°C`;
    }
    // "default" のときなど
    return this.celsius;
  }
}

const t = new Temperature(25);

console.log(String(t));   // "25°C"
console.log(+t);          // 25(numberコンテキスト)
console.log(`Now: ${t}`); // "Now: 25°C"
console.log(t > 20);      // true

hint には "number" | "string" | "default" が入ります。

使い所:

  • 金額クラス・ベクトルクラス・日付ラッパなどで、「算術演算や比較で直感的に動いてほしい」場合
  • ログ・テンプレート文字列で、読みやすい表現を返したい場合

注意点:

やりすぎると「何がどう変換されているのか分かりにくくなる」ので、チーム開発では慎重に使いましょう。

Symbol.hasInstance:instanceof の挙動をカスタマイズ

instanceof 演算子は、通常は「右辺の prototype チェーンを辿って判定」しますが、
右辺に Symbol.hasInstance が実装されていると、そのメソッドが呼ばれます。

class EvenNumber {
  static [Symbol.hasInstance](instance) {
    return typeof instance === "number" && instance % 2 === 0;
  }
}

console.log(2 instanceof EvenNumber);   // true
console.log(3 instanceof EvenNumber);   // false
console.log("2" instanceof EvenNumber); // false

ここで起きていること:

  • x instanceof EvenNumber を評価すると、エンジンは EvenNumber[Symbol.hasInstance](x) を呼ぶ

つまり、「instanceof という言語演算子の意味を、自分で定義し直せる」というかなり強力なフックです。

実務での使い所は限定的ですが:

  • ライブラリで「このオブジェクトは自分のクラスの一種とみなせるか?」を柔軟に定義
  • インターフェース的な判定(duck typing っぽい)に使う

など、言語レベルの仕組みに直結しています。

Proxy と Reflect:オブジェクト操作のフック

Proxy とは?

Proxy とは、オブジェクトへの操作(プロパティ取得・代入・innew など)を、ハンドラ(trap)で横取りできるラッパーのことです。

基本形:

const target = { x: 1 };

const handler = {
  get(target, prop, receiver) {
    console.log(`get: ${String(prop)}`);
    return target[prop];
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.x); // "get: x" とログが出てから 1 を返す

ここでやっていること:

  • proxy.x と書くと、直接 target.x にはアクセスせず、 handler.get(target, "x", proxy) が呼ばれる
  • その中で target[prop] を返している

対象オブジェクト(target) とハンドラ(handler) を分けるのがデザイン上のポイントです。

よく使う trap

代表的な trap(ハンドラメソッド):

  • get(target, prop, receiver):プロパティ取得(obj.prop / obj["prop"]
  • set(target, prop, value, receiver):プロパティ代入
  • has(target, prop)prop in obj
  • deleteProperty(target, prop)delete obj[prop]
  • ownKeys(target)Object.keys, Object.getOwnPropertyNames, Object.getOwnPropertySymbols
  • apply(target, thisArg, args):関数として呼び出されたとき
  • construct(target, args, newTarget)new されたとき

すべて実装する必要はなく、必要なものだけ書けばOK です。

Reflect とは?

Reflect は、オブジェクトに対する“元々の動作”を関数として提供するユーティリティです。

例:

  • Reflect.get(target, prop, receiver) → 通常のプロパティ取得
  • Reflect.set(target, prop, value, receiver) → 通常の代入
  • Reflect.has(target, prop)prop in target と同等
  • Reflect.ownKeys(target)Object.getOwnPropertyNames + getOwnPropertySymbols

Proxy の trap の中で、「標準どおりに動かしたい部分は Reflect.xxx に委譲する」というのが定番パターンです。

パターン1:ログ付きオブジェクト

function createLoggingProxy(obj) {
  return new Proxy(obj, {
    get(target, prop, receiver) {
      console.log(`GET ${String(prop)}`);
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      console.log(`SET ${String(prop)} =`, value);
      return Reflect.set(target, prop, value, receiver);
    }
  });
}

const user = { name: "Taro", age: 20 };
const p = createLoggingProxy(user);

p.name;        // GET name
p.age = 21;    // SET age = 21
  • こういう「アクセスログ」「デバッグ」系は Proxy の分かりやすい用途です。
  • 元の挙動はすべて Reflect に任せているので、動きも素直。

パターン2:バリデーション・読み取り専用

年齢プロパティを 0〜150 の範囲に制約する例:

function createUser(user) {
  return new Proxy(user, {
    set(target, prop, value, receiver) {
      if (prop === "age") {
        if (typeof value !== "number" || value < 0 || value > 150) {
          throw new RangeError("age は 0〜150 の数値である必要があります");
        }
      }
      return Reflect.set(target, prop, value, receiver);
    }
  });
}

const user = createUser({ name: "Taro", age: 20 });

user.age = 30;   // OK
// user.age = -5; // RangeError

完全読み取り専用オブジェクトにする例:

function readonly(obj) {
  return new Proxy(obj, {
    set(target, prop, value) {
      console.warn(`プロパティ ${String(prop)} は読み取り専用です`);
      return false; // strict mode では TypeError になる
    },
    deleteProperty(target, prop) {
      console.warn(`プロパティ ${String(prop)} は削除できません`);
      return false;
    }
  });
}

const config = readonly({ apiUrl: "https://example.com" });

// config.apiUrl = "x";  // 警告 & 失敗
// delete config.apiUrl; // 警告 & 失敗

パターン3:配列の負インデックス

const array = [10, 20, 30];

const indexed = new Proxy(array, {
  get(target, prop, receiver) {
    if (typeof prop === "string") {
      const index = Number(prop);
      if (Number.isInteger(index) && index < 0) {
        // 負のインデックスなら末尾から
        return target[target.length + index];
      }
    }
    return Reflect.get(target, prop, receiver);
  }
});

console.log(indexed[-1]); // 30
console.log(indexed[-2]); // 20
  • 「言語にない構文(負インデックス)を、Proxy を使って“擬似的に”追加する」イメージ
  • こういった DSL っぽい拡張も、メタプログラミングです。

タグ付きテンプレート

普通のテンプレートリテラル

const name = "Taro";
const msg = `Hello, ${name}!`;

タグ付きテンプレートは、テンプレートの前に関数名を書く構文です。

const result = tag`Hello, ${name}!`;

ここで呼ばれるのは:

function tag(strings, ...values) { ... }
  • strings:リテラル部分の配列
  • values${...} で埋め込まれた値の配列

例:

const name = "Taro";
const count = 3;

function debugTag(strings, ...values) {
  console.log(strings); // ["Hello, ", "! You have ", " messages.", ""]
  console.log(values);  // ["Taro", 3]
  return "任意の戻り値";
}

const res = debugTag`Hello, ${name}! You have ${count} messages.`;

つまり、テンプレートリテラルを 「パース済みの形」で関数に渡せる のがタグ付きテンプレートです。

例:HTMLエスケープ関数

XSS対策などでよく出てくるパターン:

function escapeHtml(str) {
  return str
    .replace(/&/g, "&")
    .replace(/</g, "<")
    .replace(/>/g, ">")
    .replace(/"/g, """)
    .replace(/'/g, "'");
}

function html(strings, ...values) {
  return strings.reduce((result, s, i) => {
    const v = values[i] !== undefined ? escapeHtml(String(values[i])) : "";
    return result + s + v;
  }, "");
}

const userInput = '<script>alert("XSS")</script>';
const safeHtml = html`<p>${userInput}</p>`;

console.log(safeHtml);
// => "<p><script>alert("XSS")</script></p>"
  • html タグ付きテンプレートを通すだけで、${...} で埋め込まれた部分をすべてエスケープ
  • 「テンプレート文字列をパーサの材料にする」というメタっぽい使い方

例:i18n(多言語化)っぽい使い方

const dict = {
  ja: {
    greet: (name) => `こんにちは、${name} さん`,
  },
  en: {
    greet: (name) => `Hello, ${name}!`,
  }
};

let lang = "ja";

function i18n(strings, ...values) {
  // ここでは単純化して、「キーを1個埋め込む」だけの例
  const key = values[0]; // `${"greet"}`
  return dict[lang][key]("Taro");
}

console.log(i18n`${"greet"}`); // こんにちは、Taro さん

lang = "en";
console.log(i18n`${"greet"}`); // Hello, Taro!

実務ではもっと複雑な仕組みになりますが、

「テンプレート → 解析 → 好きなロジックに流す」

というパイプラインを組めるのがタグ付きテンプレートの本質です。


<<前へ(イテレータ・ジェネレータ)

>>次へ()

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次