プロトコルベースのポリモーフィズム

動的型付けとポリモーフィズム

Javaなどの静的型付け言語では、異なるクラスのオブジェクトを同一視してポリモーフィズム(多態性)を実現するために、共通のインターフェースを定義してそれを継承(実装)する必要がありました。 しかし、JavaScriptは動的型付け言語であるため、インターフェースを明示的に定義する文法はありません。 JavaScriptでは、オブジェクトが「どのようなクラスから生成されたか」ではなく、「どのようなプロパティやメソッドを持っているか」によってその役割が決まります。 このように、オブジェクトが特定のメソッドやプロパティを持っているという暗黙の規約(プロトコル)に基づいてポリモーフィズムを実現することを、プロトコルベース of ポリモーフィズム(ダックタイピング)と呼びます。 共通のメソッドを持つ複数のクラスを同一視して呼び出すプログラムは以下のようになります。

polymorphism.js

class TextMessage {
  constructor(content) {
    this.content = content;
  }

  send() {
    console.log(`テキストメッセージを送信: ${this.content}`);
  }
}

class ImageMessage {
  constructor(imageUrl) {
    this.imageUrl = imageUrl;
  }

  send() {
    console.log(`画像を送信: ${this.imageUrl}`);
  }
}

function sendMessageNotification(message) {
  // messageオブジェクトがsendメソッドを持っていることを前提(プロトコル)として呼び出す
  message.send();
}

const text = new TextMessage("こんにちは");
const image = new ImageMessage("http://example.com/image.png");

sendMessageNotification(text);
sendMessageNotification(image);

実行結果

テキストメッセージを送信: こんにちは
画像を送信: http://example.com/image.png

このプログラムでは、TextMessage クラスと ImageMessage クラスの間に継承関係はありません。 しかし、両者とも send() という同名のメソッドを持っているため、sendMessageNotification 関数はどちらのインスタンスも同じように受け取って処理を呼び出すことができます。 このように、明示的な型定義を用いずに、オブジェクトが持つ共通のインターフェース(メソッド)に基づいてポリモーフィズムを表現するのがJavaScriptの特徴です。

イテレータプロトコル

JavaScriptの標準の言語機能には、オブジェクトが特定のメソッドを実装している場合に、自動的に繰り返し処理を行える仕組みが存在します。 その代表例がイテレータプロトコルです。 オブジェクトが [Symbol.iterator] という特別な名前のメソッドを持ち、そのメソッドが next() というメソッドを持つオブジェクト(イテレータ)を返す場合、そのオブジェクトは for...of ループなどの繰り返し構造に直接適用できます。 イテレータプロトコルに適合するカスタム範囲クラスを定義するプログラムは以下のようになります。

number_range.js

class NumberRange {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  // Symbol.iteratorメソッドを定義する
  [Symbol.iterator]() {
    let current = this.start;
    const limit = this.end;

    // nextメソッドを持つイテレータオブジェクトを返す
    return {
      next() {
        if (current <= limit) {
          const value = current;
          current += 1;
          return { value: value, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
}

const range = new NumberRange(1, 3);
for (const num of range) {
  console.log(num);
}

実行結果

1
2
3

このプログラムでは、NumberRange クラス自体は特別な組み込みクラスを継承していません。 しかし、JavaScriptの標準仕様で定められた [Symbol.iterator] メソッドを実装しているため、for...of 構文の内部処理からポリモーフィズムを介して自動的に next() メソッドが順次呼び出され、繰り返し出力が行われます。


演習

演習1

JavaScriptの標準の非同期処理である Promiseasync/await がオブジェクトを処理する際に自動的に呼び出すThenableプロトコルを実装しなさい。 本演習では、非同期の完了を通知できる SimpleThenable クラスを作成し、以下の仕様を満たすように設計しなさい。

  • then(onFulfilled, onRejected) メソッドを持つこと。
  • then メソッドの内部で setTimeout を使用し、500 ミリ秒経過した後に、第1引数として渡された onFulfilled コールバック関数を "完了データ" という文字列を引数に指定して実行すること。
  • async/await 構文を用い、await new SimpleThenable() として呼び出した際に、非同期に値が返され、その実行結果が出力されることを確認しなさい。

演習2

JavaScriptの標準関数である JSON.stringify() がオブジェクトを変換する際に自動的に呼び出すJSON化プロトコルtoJSON メソッド)を実装しなさい。 本演習では、以下の仕様を満たすプログラムを作成しなさい。

  • ユーザー情報を表す User クラスを作成し、プロパティとして name(名前)と password(パスワード)を持つこと。
  • 商品情報を表す Product クラスを作成し、プロパティとして title(商品名)と price(価格)を持つこと。
  • User クラスには、セキュリティ保護のためパスワードを除外したオブジェクト(名前のみ)を返す toJSON() メソッドを定義しなさい。
  • Product クラスには、価格に消費税(10%)を加算した税込価格を含める形式({ title: 商品名, priceWithTax: 税込価格 })のオブジェクトを返す toJSON() メソッドを定義しなさい。
  • これらのインスタンスを JSON.stringify() に渡した際に、それぞれの toJSON() で定義したルールに従ってポリモーフィックに変換されることを確認しなさい。

演習3

JavaScriptでオブジェクトが数値や文字列などのプリミティブ型に暗黙的に型変換される際に呼び出されるプリミティブ変換プロトコル[Symbol.toPrimitive] メソッド)を実装しなさい。 本演習では、長さを表すクラスである Length クラスを作成し、以下の仕様を満たすように設計しなさい。

  • コンストラクタで、メートル単位の数値である meters を受け取ること。
  • クラスに [Symbol.toPrimitive](hint) メソッドを実装すること。
  • 引数の hint"number" または "default" の場合は、メートル単位の数値(this.meters)を返すこと。
  • 引数の hint"string" の場合は、末尾に "m" を付加した文字列(例: "10m")を返すこと。
  • Length のインスタンス同士を加算した際に数値としての計算が行われ、文字列として出力した際には単位付きの文字列として扱われることを確認しなさい。

演習の解答例

演習1の解答例

標準の非同期処理と互換性を持つ SimpleThenable クラスの実装例は以下の通りである。 Promise オブジェクトでなくても、then メソッドを持つオブジェクト(Thenable)は await キーワードの対象にすることができます。 これを利用して、一定時間後にコールバック関数を起動し、非同期処理のポリモーフィズムを実現している。

simple_thenable.js

class SimpleThenable {
  then(onFulfilled) {
    // 500ミリ秒後に非同期で成功を通知する
    setTimeout(() => {
      onFulfilled("完了データ");
    }, 500);
  }
}

// 動作確認用
async function run() {
  const result = await new SimpleThenable();
  console.log(result);
}

run();

実行結果

完了データ

演習2の解答例

toJSON メソッドを用いてシリアライズの挙動をカスタマイズした User および Product クラスの実装例は以下の通りである。 JSON.stringify() は対象オブジェクトが toJSON() メソッドを持っている場合、それを自動的に呼び出してシリアライズを実行する。 これを利用して、特定の機密情報を除外したり、算出プロパティを追加したりするポリモーフィズムを実現している。

json_serialization.js

class User {
  constructor(name, password) {
    this.name = name;
    this.password = password;
  }

  toJSON() {
    return {
      name: this.name
    };
  }
}

class Product {
  constructor(title, price) {
    this.title = title;
    this.price = price;
  }

  toJSON() {
    return {
      title: this.title,
      priceWithTax: Math.floor(this.price * 1.1)
    };
  }
}

// 動作確認用
const user = new User("Alice", "secret123");
const product = new Product("ノートパソコン", 100000);

console.log(JSON.stringify(user));
console.log(JSON.stringify(product));

実行結果

{"name":"Alice"}
{"title":"ノートパソコン","priceWithTax":110000}

演習3の解答例

暗黙の型変換を制御する [Symbol.toPrimitive] メソッドを実装した Length クラスの実装例は以下の通りである。 JavaScriptがオブジェクトをプリミティブ値に変換しようとする際、変換の文脈(ヒント)を引数として [Symbol.toPrimitive] が呼び出される。 これを利用して、数値演算の文脈では実数値を返し、文字列結合の文脈では単位記号付きの文字列を返すポリモーフィズムを実現している。

primitive_conversion.js

class Length {
  constructor(meters) {
    this.meters = meters;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === "string") {
      return `${this.meters}m`;
    }
    // number または default の場合
    return this.meters;
  }
}

// 動作確認用
const len1 = new Length(5);
const len2 = new Length(10);

// 数値としての加算(hint は number または default となる)
const total = len1 + len2;
console.log(total);

// 文字列としての出力(hint は string となる)
console.log(String(len1));

実行結果

15
5m

results matching ""

    No results matching ""