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

第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]
空のオブジェクトからプロパティを取り出したように見えます。しかし実際には、toString
は Object.prototype
に保存されているメソッドであり、ほとんどのオブジェクトで使用できます。
オブジェクトが持っていないプロパティのリクエストを受け取ると、そのプロトタイプでプロパティが検索されます。それがそれを持っていなければ、プロトタイプのプロトタイプが検索され、プロトタイプを持たないオブジェクトに到達するまで続きます (Object.prototype
はそのようなオブジェクトです)。
console.log(Object.getPrototypeOf({}) == Object.prototype); // → true console.log(Object.getPrototypeOf(Object.prototype)); // → null
推測どおり、Object.
はオブジェクトのプロトタイプを返します。
多くのオブジェクトは、Object.prototype
をプロトタイプとして直接持っているのではなく、代わりに異なるデフォルトのプロパティセットを提供する別のオブジェクトを持っています。関数は Function.
から派生し、配列は 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.
で発見できる)の違いを理解することが重要です。コンストラクタの実際のプロトタイプは、コンストラクタが関数であるため、Function.
です。コンストラクタ関数の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
次の図は、このコードが実行された後の状況をスケッチしたものです。Rabbit
とObject
のプロトタイプは、オブジェクト自体で見つからないプロパティを検索できる一種の背景として、killerRabbit
の背後に存在します。
プロトタイプに存在するプロパティをオーバーライドすることは、便利な場合があります。ウサギの歯の例が示すように、オーバーライドは、より一般的なオブジェクトクラスのインスタンスで例外的なプロパティを表現し、非例外的なオブジェクトがプロトタイプから標準値を取得できるようにするために使用できます。
オーバーライドは、標準の関数および配列のプロトタイプに、基本的なオブジェクトプロトタイプとは異なるtoString
メソッドを与えるためにも使用されます。
console.log(Array.prototype.toString == Object.prototype.toString); // → false console.log([1, 2].toString()); // → 1,2
配列に対してtoString
を呼び出すと、.
を呼び出すのと同様の結果が得られます。つまり、配列内の値の間にコンマが挿入されます。配列でObject.
を直接呼び出すと、異なる文字列が生成されます。この関数は配列について知らないため、単に「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
から派生しているため、プロパティが存在するように見えます。
このため、プレーンオブジェクトをマップとして使用することは危険です。この問題を回避する方法はいくつかあります。まず、プロトタイプがないオブジェクトを作成できます。null
をObject.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
set
、get
、has
メソッドは、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.
も同様です。
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.
を記述できます。
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
プロパティを持つオブジェクトである必要があります。
next
、value
、および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); };
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
この演算子は、継承された型を見抜くため、LengthList
はList
のインスタンスです。この演算子は、Array
のような標準コンストラクタにも適用できます。ほとんどすべてのオブジェクトはObject
のインスタンスです。
まとめ
オブジェクトは、独自のプロパティを保持する以上のことを行います。オブジェクトには、別のオブジェクトであるプロトタイプがあります。プロトタイプがそのプロパティを持っている限り、プロパティを持っていないかのように振る舞います。単純なオブジェクトは、プロトタイプとしてObject.prototype
を持っています。
通常、名前が大文字で始まる関数であるコンストラクタは、new
演算子を使用して新しいオブジェクトを作成するために使用できます。新しいオブジェクトのプロトタイプは、コンストラクタのprototype
プロパティにあるオブジェクトになります。特定の型のすべての値が共有するプロパティをプロトタイプに入れることで、これを有効活用できます。コンストラクタとそのプロトタイプを定義するための明確な方法を提供するclass
表記法があります。
オブジェクトのプロパティがアクセスされるたびにメソッドをひそかに呼び出すために、ゲッターとセッターを定義できます。静的メソッドは、プロトタイプではなく、クラスのコンストラクタに格納されているメソッドです。
instanceof
演算子は、オブジェクトとコンストラクタが与えられた場合、そのオブジェクトがそのコンストラクタのインスタンスであるかどうかを伝えることができます。
オブジェクトで行うと便利なことの1つは、オブジェクトのインターフェースを指定し、そのインターフェースを通じてのみオブジェクトと対話する必要があると皆に伝えることです。オブジェクトを構成する残りの詳細は、インターフェースの背後に隠されたカプセル化になります。プライベートプロパティを使用して、オブジェクトの一部を外部の世界から隠すことができます。
複数の型が同じインターフェースを実装できます。インターフェースを使用するように記述されたコードは、そのインターフェースを提供する任意の数の異なるオブジェクトで動作する方法を自動的に認識します。これは、ポリモーフィズムと呼ばれます。
いくつかの詳細だけが異なる複数のクラスを実装する場合、既存のクラスのサブクラスとして新しいクラスを作成し、その動作の一部を継承すると便利です。
演習
ベクター型
2次元空間のベクトルを表すVec
クラスを作成してください。このクラスは、x
とy
のパラメータ(数値)を受け取り、同じ名前のプロパティに保存します。
Vec
プロトタイプに、別のベクトルをパラメータとして受け取り、2つのベクトル(this
とパラメータ)のx値とy値の和または差を持つ新しいベクトルを返す、plus
とminus
の2つのメソッドを与えてください。
ベクトルの長さ、つまり原点(0, 0)からの点(x、y)の距離を計算する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
ヒントを表示...
グループ
標準のJavaScript環境では、Set
と呼ばれる別のデータ構造が提供されています。Map
のインスタンスと同様に、セットは値のコレクションを保持します。Map
とは異なり、他の値をそれらに関連付けるのではなく、どの値がセットの一部であるかだけを追跡します。値はセットの一部として一度だけ存在でき、もう一度追加しても効果はありません。
(Set
はすでに使用されているため)Group
というクラスを作成してください。Set
と同様に、add
、delete
、has
メソッドがあります。コンストラクタは空のグループを作成し、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