オブジェクトの秘密の生活

抽象データ型は、型に対して実行できる操作によって型を定義する特別な種類のプログラムを記述することによって実現されます。

バーバラ・リスコフ, 抽象データ型によるプログラミング
Illustration of a rabbit next to its prototype, a schematic representation of a rabbit

第4章では、JavaScriptのオブジェクトを他のデータを保持するコンテナとして紹介しました。プログラミング文化において、オブジェクト指向プログラミングとは、オブジェクトをプログラム組織の中心原理として使用する一連のテクニックです。正確な定義については意見が一致していませんが、オブジェクト指向プログラミングは、JavaScriptを含む多くのプログラミング言語の設計を形作ってきました。この章では、これらのアイデアがJavaScriptでどのように適用できるかを説明します。

抽象データ型

オブジェクト指向プログラミングの主なアイデアは、オブジェクト、あるいはむしろオブジェクトのをプログラム組織の単位として使用することです。プログラムを厳密に分離された複数のオブジェクト型として設定すると、その構造について考える方法が提供され、それによってある種の規律が強制され、すべてが絡み合うのを防ぐことができます。

これを行う方法は、オブジェクトを電気ミキサーやその他の家電製品のように考えることです。ミキサーを設計および組み立てる人は、材料科学と電気の理解を必要とする専門的な作業を行う必要があります。彼らは、パンケーキの生地を混ぜたいだけの人々がそのすべてを心配する必要がないように、すべてを滑らかなプラスチックのシェルで覆います。彼らはミキサーを操作できるいくつかのノブだけを理解する必要があります。

同様に、抽象データ型、またはオブジェクトクラスは、任意に複雑なコードを含むことができるサブプログラムですが、それを使用する人々が使用することになっている限られたメソッドとプロパティのセットを公開します。これにより、多数の家電製品タイプから大規模なプログラムを構築でき、これらの異なる部分が特定の相互作用方法のみを要求することで絡み合う程度を制限できます。

このようなオブジェクトクラスの1つで問題が見つかった場合、プログラムの残りの部分に影響を与えることなく、修復したり、完全に書き換えたりすることもできます。さらに良いことに、オブジェクトクラスを複数の異なるプログラムで使用して、その機能を最初から再作成する必要を回避できる場合があります。配列や文字列などのJavaScriptの組み込みデータ構造を、そのような再利用可能な抽象データ型として考えることができます。

各抽象データ型には、外部コードが実行できる操作のコレクションであるインターフェイスがあります。そのインターフェイスを超える詳細はカプセル化され、型の内部として扱われ、プログラムの残りの部分とは無関係です。

数値のような基本的なものでさえ、それらを加算、乗算、比較などを可能にするインターフェイスを持つ抽象データ型と考えることができます。実際、古典的なオブジェクト指向プログラミングにおける組織の主要な単位としての単一のオブジェクトに固執することは、有用な機能がしばしば密接に連携するさまざまなオブジェクトクラスのグループを含むため、やや残念です。

メソッド

JavaScriptでは、メソッドは関数値を保持するプロパティにすぎません。これは簡単なメソッドです

function speak(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak};
let hungryRabbit = {type: "hungry", speak};

whiteRabbit.speak("Oh my fur and whiskers");
// → The white rabbit says 'Oh my fur and whiskers'
hungryRabbit.speak("Got any carrots?");
// → The hungry rabbit says 'Got any carrots?'

通常、メソッドはそれが呼び出されたオブジェクトを使って何かをする必要があります。関数がメソッドとして呼び出された場合 (つまり、object.method() のようにプロパティとして検索され、すぐに呼び出された場合)、その本体内で this と呼ばれるバインディングは、それが呼び出されたオブジェクトを自動的に指します。

this は、通常のパラメーターとは異なる方法で関数に渡される追加のパラメーターと考えることができます。明示的に提供したい場合は、関数の call メソッドを使用できます。これは、最初の引数として this 値を取り、それ以降の引数を通常のパラメーターとして扱います。

speak.call(whiteRabbit, "Hurry");
// → The white rabbit says 'Hurry'

各関数には、その値が呼び出し方に依存する独自の this バインディングがあるため、function キーワードで定義された通常の関数では、ラップするスコープの this を参照できません。

アロー関数は異なります。アロー関数は独自の this をバインドしませんが、周囲のスコープの this バインディングを見ることができます。したがって、ローカル関数内から this を参照する次のコードのようなことができます

let finder = {
  find(array) {
    return array.some(v => v == this.value);
  },
  value: 5
};
console.log(finder.find([4, 5]));
// → true

オブジェクト式における find(array) のようなプロパティは、メソッドを定義する短縮形です。これは、find という名前のプロパティを作成し、それに値として関数を与えます。

some への引数を function キーワードを使用して記述した場合、このコードは機能しませんでした。

プロトタイプ

speak メソッドを持つウサギオブジェクト型を作成する1つの方法は、パラメーターとしてウサギ型を持ち、その type プロパティとしてそれを保持し、その speak プロパティにspeak関数を持つオブジェクトを返すヘルパー関数を作成することです。

すべてのウサギが同じメソッドを共有します。特に多数のメソッドを持つ型の場合、各オブジェクトに個別にそれらを追加するのではなく、型のメソッドを1つの場所に保持する方法があれば便利です。

JavaScriptでは、プロトタイプがそれを行う方法です。オブジェクトは他のオブジェクトにリンクして、他のオブジェクトが持つすべてのプロパティを魔法のように取得できます。{} 表記で作成されたプレーンな古いオブジェクトは、Object.prototype と呼ばれるオブジェクトにリンクされます。

let empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]

空のオブジェクトからプロパティを取り出したように見えます。しかし実際には、toStringObject.prototype に保存されているメソッドであり、ほとんどのオブジェクトで使用できます。

オブジェクトが持っていないプロパティのリクエストを受け取ると、そのプロトタイプでプロパティが検索されます。それがそれを持っていなければ、プロトタイプのプロトタイプが検索され、プロトタイプを持たないオブジェクトに到達するまで続きます (Object.prototype はそのようなオブジェクトです)。

console.log(Object.getPrototypeOf({}) == Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null

推測どおり、Object.getPrototypeOf はオブジェクトのプロトタイプを返します。

多くのオブジェクトは、Object.prototype をプロトタイプとして直接持っているのではなく、代わりに異なるデフォルトのプロパティセットを提供する別のオブジェクトを持っています。関数は Function.prototype から派生し、配列は Array.prototype から派生します。

console.log(Object.getPrototypeOf(Math.max) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) == Array.prototype);
// → true

そのようなプロトタイプオブジェクト自体もプロトタイプ (多くの場合 Object.prototype) を持つため、間接的に toString のようなメソッドを提供します。

Object.create を使用して、特定のプロトタイプを持つオブジェクトを作成できます。

let protoRabbit = {
  speak(line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
  }
};
let blackRabbit = Object.create(protoRabbit);
blackRabbit.type = "black";
blackRabbit.speak("I am fear and darkness");
// → The black rabbit says 'I am fear and darkness'

「プロト」ウサギは、すべてのウサギが共有するプロパティのコンテナとして機能します。黒ウサギのような個々のウサギオブジェクトは、それ自身にのみ適用されるプロパティ (この場合はその型) を含み、そのプロトタイプから共有プロパティを派生させます。

クラス

JavaScriptのプロトタイプシステムは、抽象データ型またはクラスに対するやや自由形式の解釈として解釈できます。クラスは、オブジェクトの型 (持つメソッドとプロパティ) の形状を定義します。そのようなオブジェクトは、クラスのインスタンスと呼ばれます。

プロトタイプは、クラスのすべてのインスタンスが同じ値を共有するプロパティを定義するのに役立ちます。ウサギの type プロパティなど、インスタンスごとに異なるプロパティは、オブジェクト自体に直接保存する必要があります。

特定のクラスのインスタンスを作成するには、適切なプロトタイプから派生したオブジェクトを作成する必要がありますが、また、このクラスのインスタンスが持つべきプロパティ自体も持っていることを確認する必要があります。これがコンストラクター関数が行うことです。

function makeRabbit(type) {
  let rabbit = Object.create(protoRabbit);
  rabbit.type = type;
  return rabbit;
}

JavaScriptのクラス表記を使用すると、このタイプの関数をプロトタイプオブジェクトとともに簡単に定義できます。

class Rabbit {
  constructor(type) {
    this.type = type;
  }
  speak(line) {
    console.log(`The ${this.type} rabbit says '${line}'`);
  }
}

class キーワードはクラス宣言を開始し、コンストラクターとメソッドのセットを一緒に定義できます。宣言の中括弧内には任意の数のメソッドを記述できます。このコードには、constructor のコードを実行し、speak メソッドを保持する prototype プロパティを持つ関数を保持する Rabbit という名前のバインディングを定義する効果があります。

この関数は、通常の関数のように呼び出すことはできません。JavaScriptでは、コンストラクターはキーワード new を前に置くことによって呼び出されます。そうすることで、プロトタイプが関数の prototype プロパティからのオブジェクトである新しいインスタンスオブジェクトが作成され、次に this が新しいオブジェクトにバインドされた状態で関数が実行され、最後にオブジェクトが返されます。

let killerRabbit = new Rabbit("killer");

実際、class が導入されたのは JavaScript の 2015 年版のみです。任意の関数をコンストラクターとして使用でき、2015 年以前は、クラスを定義する方法は、通常の関数を記述してからその prototype プロパティを操作することでした。

function ArchaicRabbit(type) {
  this.type = type;
}
ArchaicRabbit.prototype.speak = function(line) {
  console.log(`The ${this.type} rabbit says '${line}'`);
};
let oldSchoolRabbit = new ArchaicRabbit("old school");

このため、アロー関数ではないすべての関数は、空のオブジェクトを保持する prototype プロパティから始まります。

慣例により、コンストラクターの名前は、他の関数と簡単に区別できるように大文字で始まります。

プロトタイプがコンストラクタ(そのprototypeプロパティを通じて)とどのように関連付けられているか、そしてオブジェクトがどのようにプロトタイプを持っているか(Object.getPrototypeOfで発見できる)の違いを理解することが重要です。コンストラクタの実際のプロトタイプは、コンストラクタが関数であるため、Function.prototypeです。コンストラクタ関数のprototypeプロパティには、それを通じて作成されたインスタンスに使用されるプロトタイプが保持されます。

console.log(Object.getPrototypeOf(Rabbit) ==
            Function.prototype);
// → true
console.log(Object.getPrototypeOf(killerRabbit) ==
            Rabbit.prototype);
// → true

コンストラクタは通常、インスタンスごとのプロパティをthisに追加します。クラス宣言で直接プロパティを宣言することも可能です。メソッドとは異なり、このようなプロパティはインスタンスオブジェクトに追加され、プロトタイプには追加されません。

class Particle {
  speed = 0;
  constructor(position) {
    this.position = position;
  }
}

functionと同様に、classは文と式の両方で使用できます。式として使用する場合、束縛を定義するのではなく、コンストラクタを値として生成するだけです。クラス式ではクラス名を省略することができます。

let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello

プライベートプロパティ

クラスが内部使用のために、そのインターフェースの一部ではないいくつかのプロパティとメソッドを定義することは一般的です。これらは、オブジェクトの外部インターフェースの一部であるパブリックなプロパティとは対照的に、プライベートなプロパティと呼ばれます。

プライベートメソッドを宣言するには、名前の前に#記号を付けます。このようなメソッドは、それらを定義するclass宣言の中からのみ呼び出すことができます。

class SecretiveObject {
  #getSecret() {
    return "I ate all the plums";
  }
  interrogate() {
    let shallISayIt = this.#getSecret();
    return "never";
  }
}

クラスがコンストラクタを宣言しない場合、自動的に空のコンストラクタが与えられます。

クラスの外から#getSecretを呼び出そうとすると、エラーが発生します。その存在は、クラス宣言の内部に完全に隠されています。

プライベートインスタンスプロパティを使用するには、それらを宣言する必要があります。通常のプロパティは値を代入するだけで作成できますが、プライベートプロパティは、利用可能にするためにはクラス宣言で必ず宣言する必要があります。

このクラスは、指定された最大数未満のランダムな整数を取得するための器具を実装します。パブリックプロパティは1つだけです:getNumber

class RandomSource {
  #max;
  constructor(max) {
    this.#max = max;
  }
  getNumber() {
    return Math.floor(Math.random() * this.#max);
  }
}

派生プロパティのオーバーライド

プロトタイプに存在するかどうかに関わらず、オブジェクトにプロパティを追加すると、そのプロパティはオブジェクト自体に追加されます。プロトタイプに同じ名前のプロパティが既に存在していた場合、このプロパティはオブジェクト自体のプロパティの背後に隠されているため、オブジェクトには影響しなくなります。

Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log((new Rabbit("basic")).teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small

次の図は、このコードが実行された後の状況をスケッチしたものです。RabbitObjectのプロトタイプは、オブジェクト自体で見つからないプロパティを検索できる一種の背景として、killerRabbitの背後に存在します。

A diagram showing the object structure of rabbits and their prototypes. There is a box for the 'killerRabbit' instance (holding instance properties like 'type'), with its two prototypes, 'Rabbit.prototype' (holding the 'speak' method) and 'Object.prototype' (holding methods like 'toString') stacked behind it.

プロトタイプに存在するプロパティをオーバーライドすることは、便利な場合があります。ウサギの歯の例が示すように、オーバーライドは、より一般的なオブジェクトクラスのインスタンスで例外的なプロパティを表現し、非例外的なオブジェクトがプロトタイプから標準値を取得できるようにするために使用できます。

オーバーライドは、標準の関数および配列のプロトタイプに、基本的なオブジェクトプロトタイプとは異なるtoStringメソッドを与えるためにも使用されます。

console.log(Array.prototype.toString ==
            Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2

配列に対してtoStringを呼び出すと、.join(",")を呼び出すのと同様の結果が得られます。つまり、配列内の値の間にコンマが挿入されます。配列でObject.prototype.toStringを直接呼び出すと、異なる文字列が生成されます。この関数は配列について知らないため、単に「object」という単語と型の名前を角括弧で囲みます。

console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]

マップ

前の章では、関数の要素に適用してデータ構造を変換する操作でマップという単語を使用しました。紛らわしいことに、プログラミングでは、同じ単語が関連しているがかなり異なるものにも使用されます。

マップ(名詞)は、値(キー)を別の値に関連付けるデータ構造です。たとえば、名前を年齢にマップしたい場合があります。これにはオブジェクトを使用できます。

let ages = {
  Boris: 39,
  Liang: 22,
  Júlia: 62
};

console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true

ここでは、オブジェクトのプロパティ名が人の名前であり、プロパティの値が年齢です。しかし、マップにtoStringという名前の人は誰もリストしていません。しかし、プレーンオブジェクトはObject.prototypeから派生しているため、プロパティが存在するように見えます。

このため、プレーンオブジェクトをマップとして使用することは危険です。この問題を回避する方法はいくつかあります。まず、プロトタイプがないオブジェクトを作成できます。nullObject.createに渡すと、結果のオブジェクトはObject.prototypeから派生せず、マップとして安全に使用できます。

console.log("toString" in Object.create(null));
// → false

オブジェクトのプロパティ名は文字列でなければなりません。キーを文字列に簡単に変換できないマップ(オブジェクトなど)が必要な場合は、オブジェクトをマップとして使用することはできません。

幸いなことに、JavaScriptにはこの目的に合わせて記述されたMapというクラスが付属しています。これはマッピングを格納し、任意の型のキーを許可します。

let ages = new Map();
ages.set("Boris", 39);
ages.set("Liang", 22);
ages.set("Júlia", 62);

console.log(`Júlia is ${ages.get("Júlia")}`);
// → Júlia is 62
console.log("Is Jack's age known?", ages.has("Jack"));
// → Is Jack's age known? false
console.log(ages.has("toString"));
// → false

setgethasメソッドは、Mapオブジェクトのインターフェースの一部です。多数の値のセットを迅速に更新および検索できるデータ構造を作成するのは簡単ではありませんが、それについて心配する必要はありません。誰かが代わりにやってくれたので、この簡単なインターフェースを使用して彼らの仕事を利用できます。

何らかの理由でプレーンオブジェクトをマップとして扱う必要がある場合は、Object.keysがオブジェクトの自身のキーのみを返し、プロトタイプ内のキーは返さないことを知っておくと便利です。in演算子の代わりに、オブジェクトのプロトタイプを無視するObject.hasOwn関数を使用できます。

console.log(Object.hasOwn({x: 1}, "x"));
// → true
console.log(Object.hasOwn({x: 1}, "toString"));
// → false

ポリモーフィズム

オブジェクトに対してString関数(値を文字列に変換する)を呼び出すと、そのオブジェクトのtoStringメソッドを呼び出して、そこから意味のある文字列を作成しようとします。標準のプロトタイプの一部が、"[object Object]"よりも便利な情報を含む文字列を作成できるように、独自のtoStringのバージョンを定義していることを述べました。自分でもそれができます。

Rabbit.prototype.toString = function() {
  return `a ${this.type} rabbit`;
};

console.log(String(killerRabbit));
// → a killer rabbit

これは強力なアイデアの単純な例です。特定のインターフェース(この場合はtoStringメソッド)を持つオブジェクトを操作するように記述されたコードがある場合、このインターフェースをサポートする種類のオブジェクトはコードにプラグインでき、それを使用して操作できます。

この手法はポリモーフィズムと呼ばれます。ポリモーフィックコードは、期待するインターフェースをサポートしている限り、異なる形状の値で動作できます。

広く使用されているインターフェースの例は、数値を持つlengthプロパティと各要素の番号付きプロパティを持つ配列のようなオブジェクトです。配列と文字列の両方がこのインターフェースをサポートしており、ブラウザに関する章で後述するいくつかの他のオブジェクトも同様です。第5章forEachの実装は、このインターフェースを提供するものなら何でも機能します。実際、Array.prototype.forEachも同様です。

Array.prototype.forEach.call({
  length: 2,
  0: "A",
  1: "B"
}, elt => console.log(elt));
// → A
// → B

ゲッター、セッター、静的

インターフェースには、メソッドだけでなく、プレーンプロパティが含まれていることがよくあります。たとえば、Mapオブジェクトには、格納されているキーの数を知らせるsizeプロパティがあります。

このようなオブジェクトが、このようなプロパティをインスタンスに直接計算して格納する必要はありません。直接アクセスされるプロパティでさえ、メソッドの呼び出しを隠している場合があります。このようなメソッドはゲッターと呼ばれ、オブジェクト式またはクラス宣言でメソッド名の前にgetを記述することで定義されます。

let varyingSize = {
  get size() {
    return Math.floor(Math.random() * 100);
  }
};

console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49

このオブジェクトのsizeプロパティから誰かが読み取るたびに、関連付けられたメソッドが呼び出されます。セッターを使用して、プロパティが書き込まれたときに同様の処理を行うことができます。

class Temperature {
  constructor(celsius) {
    this.celsius = celsius;
  }
  get fahrenheit() {
    return this.celsius * 1.8 + 32;
  }
  set fahrenheit(value) {
    this.celsius = (value - 32) / 1.8;
  }

  static fromFahrenheit(value) {
    return new Temperature((value - 32) / 1.8);
  }
}

let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30

Temperatureクラスでは、摂氏または華氏のいずれかで温度を読み書きできますが、内部的には摂氏のみを格納し、fahrenheitゲッターとセッターで自動的に摂氏と華氏の間で変換します。

プロトタイプではなく、コンストラクタ関数に直接プロパティをアタッチしたい場合があります。このようなメソッドはクラスインスタンスにアクセスできませんが、たとえば、インスタンスを作成する追加の方法を提供するために使用できます。

クラス宣言内では、名前の前にstaticが記述されたメソッドまたはプロパティは、コンストラクタに格納されます。たとえば、Temperatureクラスでは、華氏で温度を作成するためにTemperature.fromFahrenheit(100)を記述できます。

let boil = Temperature.fromFahrenheit(212);
console.log(boil.celsius);
// → 100

シンボル

第4章で、for/ofループがいくつかの種類のデータ構造をループ処理できることを述べました。これはポリモーフィズムの別の例です。このようなループは、データ構造が配列と文字列が行う特定のインターフェースを公開することを期待しています。そして、このインターフェースを独自のオブジェクトに追加することもできます!しかし、それを行う前に、シンボル型について簡単に見てみましょう。

複数のインターフェースが異なる目的で同じプロパティ名を使用することが可能です。たとえば、配列のようなオブジェクトでは、lengthはコレクション内の要素の数を指します。ただし、ハイキングルートを記述するオブジェクトインターフェースでは、lengthを使用してルートの長さをメートル単位で提供できます。オブジェクトが両方のインターフェースに準拠することはできません。

ルートでありながら配列のように振る舞う(おそらくウェイポイントを列挙するため)オブジェクトというのは、やや無理があり、実際にはあまり見られない問題です。しかし、イテレーションプロトコルのようなものについては、言語設計者は、他のプロパティと本当に競合しないプロパティの型を必要としていました。そこで2015年に、シンボルが言語に追加されました。

これまで見てきたものを含め、ほとんどのプロパティは文字列で名前が付けられています。しかし、シンボルをプロパティ名として使用することも可能です。シンボルは、Symbol関数で作成される値です。文字列とは異なり、新しく作成されたシンボルは一意であり、同じシンボルを2回作成することはできません。

let sym = Symbol("name");
console.log(sym == Symbol("name"));
// → false
Rabbit.prototype[sym] = 55;
console.log(killerRabbit[sym]);
// → 55

Symbolに渡す文字列は、文字列に変換する際に含まれ、例えばコンソールに表示するときにシンボルを認識しやすくすることができます。しかし、それ以上の意味はありません。複数のシンボルが同じ名前を持つ可能性があります。

シンボルは、一意であり、かつプロパティ名として使用できるため、名前が何であれ、他のプロパティと共存できるインターフェースを定義するのに適しています。

const length = Symbol("length");
Array.prototype[length] = 0;

console.log([1, 2].length);
// → 2
console.log([1, 2][length]);
// → 0

オブジェクト式やクラスにシンボルプロパティを含めるには、プロパティ名を角括弧で囲みます。これにより、角括弧内の式が評価され、プロパティ名が生成されます。これは、角括弧によるプロパティアクセス表記法と似ています。

let myTrip = {
  length: 2,
  0: "Lankwitz",
  1: "Babelsberg",
  [length]: 21500
};
console.log(myTrip[length], myTrip.length);
// → 21500 2

イテレータインターフェース

for/ofループに渡されるオブジェクトは、イテラブルであることが期待されます。これは、Symbol.iteratorシンボル(言語によって定義され、Symbol関数のプロパティとして格納されるシンボル値)で名前が付けられたメソッドを持っていることを意味します。

そのメソッドが呼び出されると、2番目のインターフェースであるイテレータを提供するオブジェクトを返す必要があります。これが実際にイテレートするものです。これは、次の結果を返すnextメソッドを持ちます。その結果は、もしあれば次の値を提供するvalueプロパティと、結果がなくなったときにtrue、それ以外の場合はfalseになるはずのdoneプロパティを持つオブジェクトである必要があります。

nextvalue、およびdoneプロパティ名は、シンボルではなく、プレーンな文字列であることに注意してください。多くの異なるオブジェクトに追加される可能性の高いSymbol.iteratorのみが、実際のシンボルです。

このインターフェースを直接使用することもできます。

let okIterator = "OK"[Symbol.iterator]();
console.log(okIterator.next());
// → {value: "O", done: false}
console.log(okIterator.next());
// → {value: "K", done: false}
console.log(okIterator.next());
// → {value: undefined, done: true}

第4章の演習のリンクリストと同様のイテラブルなデータ構造を実装しましょう。今回は、リストをクラスとして記述します。

class List {
  constructor(value, rest) {
    this.value = value;
    this.rest = rest;
  }

  get length() {
    return 1 + (this.rest ? this.rest.length : 0);
  }

  static fromArray(array) {
    let result = null;
    for (let i = array.length - 1; i >= 0; i--) {
      result = new this(array[i], result);
    }
    return result;
  }
}

静的メソッド内のthisは、インスタンスではなく、クラスのコンストラクタを指すことに注意してください。静的メソッドが呼び出されるときにはインスタンスは存在しません。

リストを反復処理すると、リストのすべての要素が先頭から末尾まで返される必要があります。イテレータ用に別のクラスを作成します。

class ListIterator {
  constructor(list) {
    this.list = list;
  }

  next() {
    if (this.list == null) {
      return {done: true};
    }
    let value = this.list.value;
    this.list = this.list.rest;
    return {value, done: false};
  }
}

このクラスは、値を返すたびに次のリストオブジェクトに移動するようにlistプロパティを更新することで、リストの反復処理の進行状況を追跡し、リストが空(null)になったときに完了したことを報告します。

Listクラスをイテラブルになるように設定しましょう。この本全体を通して、コードの個々の部分を小さく自己完結させるために、クラスにメソッドを追加するために事後的にプロトタイプ操作を使用することがあります。コードを小さな部分に分割する必要がない通常のプログラムでは、これらのメソッドをクラスで直接宣言します。

List.prototype[Symbol.iterator] = function() {
  return new ListIterator(this);
};

これで、for/ofでリストをループ処理できます。

let list = List.fromArray([1, 2, 3]);
for (let element of list) {
  console.log(element);
}
// → 1
// → 2
// → 3

配列表記や関数呼び出しの...構文も、同様に任意のイテラブルオブジェクトで機能します。たとえば、[...value]を使用して、任意のイテラブルオブジェクト内の要素を含む配列を作成できます。

console.log([..."PCI"]);
// → ["P", "C", "I"]

継承

前に見たListクラスとよく似たリスト型が必要だとしましょう。しかし、その長さを常に問い合わせるため、毎回restをスキャンする必要はありません。代わりに、効率的なアクセスのために、各インスタンスに長さを保存したいとします。

JavaScriptのプロトタイプシステムにより、古いクラスとよく似ていますが、そのプロパティの一部の新しい定義を持つ新しいクラスを作成できます。新しいクラスのプロトタイプは、古いプロトタイプから派生しますが、例えば、lengthゲッターの新しい定義を追加します。

オブジェクト指向プログラミングの用語では、これは継承と呼ばれます。新しいクラスは、古いクラスからプロパティと動作を継承します。

class LengthList extends List {
  #length;

  constructor(value, rest) {
    super(value, rest);
    this.#length = super.length;
  }

  get length() {
    return this.#length;
  }
}

console.log(LengthList.fromArray([1, 2, 3]).length);
// → 3

extendsという単語の使用は、このクラスがデフォルトのObjectプロトタイプではなく、他のクラスに基づいて直接作成されるべきであることを示しています。これは、スーパークラスと呼ばれます。派生クラスは、サブクラスです。

LengthListインスタンスを初期化するには、コンストラクタはsuperキーワードを介してスーパークラスのコンストラクタを呼び出します。この新しいオブジェクトが(おおよそ)Listのように振る舞うには、リストが持つインスタンスプロパティが必要になるため、これは必要です。

次に、コンストラクタはリストの長さをプライベートプロパティに格納します。ここでthis.lengthと記述した場合、クラス独自のゲッターが呼び出されますが、#lengthがまだ設定されていないため、これはまだ機能しません。super.somethingを使用して、スーパークラスのプロトタイプ上のメソッドとゲッターを呼び出すことができます。これは多くの場合役立ちます。

継承により、既存のデータ型から少し異なるデータ型を比較的少ない作業で構築できます。これは、カプセル化やポリモーフィズムと並んで、オブジェクト指向の伝統の基本的な部分です。しかし、後者の2つは現在、一般的に素晴らしいアイデアと見なされていますが、継承はより論争の的となっています。

カプセル化とポリモーフィズムは、コードの断片を互いに分離し、プログラム全体の複雑さを軽減するために使用できますが、継承は基本的にクラスを結合し、より多くの複雑さを生み出します。クラスから継承する場合、通常は単に使用する場合よりも、そのクラスの仕組みについて詳しく知っておく必要があります。継承は、特定の種類のプログラムをより簡潔にするための便利なツールですが、最初に手を出すべきツールではなく、クラス階層(クラスの家系図)を構築する機会を積極的に探すべきではありません。

instanceof演算子

オブジェクトが特定のクラスから派生したものかどうかを知ることが役立つ場合があります。このために、JavaScriptはinstanceofと呼ばれる二項演算子を提供しています。

console.log(
  new LengthList(1, null) instanceof LengthList);
// → true
console.log(new LengthList(2, null) instanceof List);
// → true
console.log(new List(3, null) instanceof LengthList);
// → false
console.log([1] instanceof Array);
// → true

この演算子は、継承された型を見抜くため、LengthListListのインスタンスです。この演算子は、Arrayのような標準コンストラクタにも適用できます。ほとんどすべてのオブジェクトはObjectのインスタンスです。

まとめ

オブジェクトは、独自のプロパティを保持する以上のことを行います。オブジェクトには、別のオブジェクトであるプロトタイプがあります。プロトタイプがそのプロパティを持っている限り、プロパティを持っていないかのように振る舞います。単純なオブジェクトは、プロトタイプとしてObject.prototypeを持っています。

通常、名前が大文字で始まる関数であるコンストラクタは、new演算子を使用して新しいオブジェクトを作成するために使用できます。新しいオブジェクトのプロトタイプは、コンストラクタのprototypeプロパティにあるオブジェクトになります。特定の型のすべての値が共有するプロパティをプロトタイプに入れることで、これを有効活用できます。コンストラクタとそのプロトタイプを定義するための明確な方法を提供するclass表記法があります。

オブジェクトのプロパティがアクセスされるたびにメソッドをひそかに呼び出すために、ゲッターとセッターを定義できます。静的メソッドは、プロトタイプではなく、クラスのコンストラクタに格納されているメソッドです。

instanceof演算子は、オブジェクトとコンストラクタが与えられた場合、そのオブジェクトがそのコンストラクタのインスタンスであるかどうかを伝えることができます。

オブジェクトで行うと便利なことの1つは、オブジェクトのインターフェースを指定し、そのインターフェースを通じてのみオブジェクトと対話する必要があると皆に伝えることです。オブジェクトを構成する残りの詳細は、インターフェースの背後に隠されたカプセル化になります。プライベートプロパティを使用して、オブジェクトの一部を外部の世界から隠すことができます。

複数の型が同じインターフェースを実装できます。インターフェースを使用するように記述されたコードは、そのインターフェースを提供する任意の数の異なるオブジェクトで動作する方法を自動的に認識します。これは、ポリモーフィズムと呼ばれます。

いくつかの詳細だけが異なる複数のクラスを実装する場合、既存のクラスのサブクラスとして新しいクラスを作成し、その動作の一部を継承すると便利です。

演習

ベクター型

2次元空間のベクトルを表すVecクラスを作成してください。このクラスは、xyのパラメータ(数値)を受け取り、同じ名前のプロパティに保存します。

Vecプロトタイプに、別のベクトルをパラメータとして受け取り、2つのベクトル(thisとパラメータ)のx値とy値の和または差を持つ新しいベクトルを返す、plusminusの2つのメソッドを与えてください。

ベクトルの長さ、つまり原点(0, 0)からの点(xy)の距離を計算するgetterプロパティlengthをプロトタイプに追加してください。

// Your code here.

console.log(new Vec(1, 2).plus(new Vec(2, 3)));
// → Vec{x: 3, y: 5}
console.log(new Vec(1, 2).minus(new Vec(2, 3)));
// → Vec{x: -1, y: -1}
console.log(new Vec(3, 4).length);
// → 5
ヒントを表示...

class宣言がどのように見えるか不明な場合は、Rabbitクラスの例を振り返ってください。

コンストラクタにgetterプロパティを追加するには、メソッド名の前にgetという単語を置きます。(0, 0)から(x, y)までの距離を計算するには、ピタゴラスの定理を使用できます。これは、求める距離の二乗は、x座標の二乗とy座標の二乗の和に等しいというものです。したがって、√(x2 + y2)が求める数値です。Math.sqrtはJavaScriptで平方根を計算する方法であり、x ** 2は数を二乗するために使用できます。

グループ

標準のJavaScript環境では、Setと呼ばれる別のデータ構造が提供されています。Mapのインスタンスと同様に、セットは値のコレクションを保持します。Mapとは異なり、他の値をそれらに関連付けるのではなく、どの値がセットの一部であるかだけを追跡します。値はセットの一部として一度だけ存在でき、もう一度追加しても効果はありません。

Setはすでに使用されているため)Groupというクラスを作成してください。Setと同様に、adddeletehasメソッドがあります。コンストラクタは空のグループを作成し、addは値をグループに追加し(まだメンバーでない場合に限る)、deleteは引数をグループから削除し(メンバーだった場合)、hasは引数がグループのメンバーであるかどうかを示すブール値を返します。

2つの値が同じかどうかを判断するには、===演算子、またはindexOfのような同等のものを使用してください。

クラスに、反復可能オブジェクトを引数として受け取り、それを反復処理して生成されたすべての値を含むグループを作成する、静的なfromメソッドを与えてください。

class Group {
  // Your code here.
}

let group = Group.from([10, 20]);
console.log(group.has(10));
// → true
console.log(group.has(30));
// → false
group.add(10);
group.delete(10);
console.log(group.has(10));
// → false
ヒントを表示...

これを行う最も簡単な方法は、インスタンスプロパティにグループメンバーの配列を保存することです。includesまたはindexOfメソッドを使用して、特定の値が配列内にあるかどうかを確認できます。

クラスのコンストラクタは、メンバーコレクションを空の配列に設定できます。addが呼び出された場合、指定された値が配列内にあるかどうかを確認するか、そうでない場合はpushを使用して追加する必要があります。

deleteで配列から要素を削除するのはそれほど簡単ではありませんが、filterを使用して値を削除した新しい配列を作成できます。メンバーを保持するプロパティを、新しくフィルタリングされたバージョンの配列で上書きすることを忘れないでください。

fromメソッドは、for/ofループを使用して、反復可能オブジェクトから値を取得し、addを呼び出して、新しく作成されたグループに入れます。

反復可能なグループ

前の演習のGroupクラスを反復可能にしてください。インターフェースの正確な形式が不明確な場合は、章の前半にあるイテレータインターフェースに関するセクションを参照してください。

グループのメンバーを表すために配列を使用した場合は、配列でSymbol.iteratorメソッドを呼び出して作成されたイテレータを返すだけではいけません。それは機能しますが、この演習の目的を損なうことになります。

イテレーション中にグループが変更された場合、イテレータが奇妙に動作してもかまいません。

// Your code here (and the code from the previous exercise)

for (let value of Group.from(["a", "b", "c"])) {
  console.log(value);
}
// → a
// → b
// → c
ヒントを表示...

おそらく、新しいクラスGroupIteratorを定義する価値があります。イテレータインスタンスは、グループ内の現在の位置を追跡するプロパティを持つ必要があります。nextが呼び出されるたびに、完了したかどうかを確認し、完了していない場合は、現在の値を過ぎて移動して返します。

Groupクラス自体には、Symbol.iteratorで名前が付けられたメソッドがあり、呼び出されると、そのグループのイテレータクラスの新しいインスタンスを返します。