第6章オブジェクトの秘密の生活
抽象データ型は、特別な種類のプログラムを書くことで実現されます……それは、その型を、それに対して実行できる操作という観点から定義します。

第4章では、JavaScriptのオブジェクトを紹介しました。プログラミング文化では、オブジェクト指向プログラミングと呼ばれるものがあり、オブジェクト(および関連する概念)をプログラム構成の中心原理として使用する一連の手法です。
その正確な定義については誰も本当に一致していませんが、オブジェクト指向プログラミングはJavaScriptを含む多くのプログラミング言語の設計に影響を与えています。この章では、これらのアイデアをJavaScriptでどのように適用できるかを説明します。
カプセル化
オブジェクト指向プログラミングの中心的なアイデアは、プログラムをより小さなピースに分割し、それぞれのピースに独自のステートの管理を任せることです。
このように、プログラムの一部がどのように機能するかについての知識を、そのピースにローカルに保持することができます。プログラムの残りの部分に取り組んでいる人は、その知識を覚える必要も、意識する必要もありません。これらのローカルの詳細が変更される場合、その周囲のコードのみを更新する必要があります。
このようなプログラムの異なる部分は、インターフェースを介して相互に作用します。インターフェースとは、より抽象的なレベルで有用な機能を提供し、その正確な実装を隠す、関数またはバインディングの限定されたセットです。
このようなプログラムのピースは、オブジェクトを使用してモデル化されます。そのインターフェースは、特定のメソッドとプロパティのセットで構成されます。インターフェースの一部であるプロパティはパブリックと呼ばれます。外部コードが触れてはならない他のプロパティはプライベートと呼ばれます。
多くの言語では、パブリックプロパティとプライベートプロパティを区別し、外部コードがプライベートプロパティにアクセスすることを完全に防ぐ方法が提供されています。JavaScriptは、再びミニマリストのアプローチをとって、少なくともまだそうではありません。これを言語に追加するための作業が進められています。
言語にこの区別が組み込まれていなくても、JavaScriptプログラマーは実際にこのアイデアを使用しています。通常、利用可能なインターフェースはドキュメントまたはコメントで説明されています。また、プロパティ名がプライベートであることを示すために、プロパティ名の先頭にアンダースコア(_
)文字を付けることも一般的です。
インターフェースと実装を分離することは素晴らしいアイデアです。通常、カプセル化と呼ばれます。
メソッド
メソッドは、関数値を保持するプロパティにすぎません。これは単純なメソッドです。
let rabbit = {}; rabbit.speak = function(line) { console.log(`The rabbit says '${line}'`); }; rabbit.speak("I'm alive."); // → The rabbit says 'I'm alive.'
通常、メソッドは、呼び出されたオブジェクトに対して何かを行う必要があります。関数がメソッドとして呼び出された場合(プロパティとして検索され、object.method()
のようにすぐに呼び出される場合)、その本体のthis
と呼ばれるバインディングは、自動的に呼び出されたオブジェクトを指します。
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 ears and whiskers, " + "how late it's getting!"); // → The white rabbit says 'Oh my ears and whiskers, how // late it's getting!' hungryRabbit.speak("I could use a carrot right now."); // → The hungry rabbit says 'I could use a carrot right now.'
this
を別の方法で渡される追加のパラメーターと考えることができます。明示的に渡す場合は、関数のcall
メソッドを使用できます。これは、this
値を最初の引数として受け取り、それ以降の引数を通常のパラメーターとして扱います。
speak.call(hungryRabbit, "Burp!"); // → The hungry rabbit says 'Burp!'
各関数は独自のthis
バインディングを持っており、その値は呼び出される方法によって異なります。そのため、function
キーワードで定義された通常の関数では、ラップするスコープのthis
を参照することはできません。
アロー関数は異なります。アロー関数は独自のthis
をバインドしませんが、周囲のスコープのthis
バインディングを参照できます。したがって、ローカル関数の中からthis
を参照する次のコードのようなことができます。
function normalize() { console.log(this.coords.map(n => n / this.length)); } normalize.call({coords: [0, 2, 3], length: 5}); // → [0, 0.4, 0.6]
map
への引数をfunction
キーワードを使用して記述していた場合、コードは機能しません。
プロトタイプ
let empty = {}; console.log(empty.toString); // → function toString(){…} console.log(empty.toString()); // → [object Object]
まあ、実際にはそうではありません。単にJavaScriptオブジェクトの動作方法に関する情報を保留していただけです。オブジェクトのプロパティセットに加えて、ほとんどのオブジェクトにはプロトタイプもあります。プロトタイプは、プロパティのフォールバックソースとして使用される別のオブジェクトです。オブジェクトが持たないプロパティの要求を受け取ると、そのプロトタイプ、次にプロトタイプのプロトタイプなどが検索されます。
では、その空のオブジェクトのプロトタイプは誰ですか?それは偉大な祖先プロトタイプであり、ほとんどすべてのオブジェクトの背後にあるエンティティ、Object.prototype
です。
console.log(Object.getPrototypeOf({}) == Object.prototype); // → true console.log(Object.getPrototypeOf(Object.prototype)); // → null
予想通り、Object.
はオブジェクトのプロトタイプを返します。
JavaScriptオブジェクトのプロトタイプ関係はツリー状の構造を形成し、この構造のルートにはObject.prototype
があります。これは、オブジェクトを文字列表現に変換するtoString
など、すべてのオブジェクトに表示されるいくつかのメソッドを提供します。
多くのオブジェクトは、プロトタイプとして直接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 killerRabbit = Object.create(protoRabbit); killerRabbit.type = "killer"; killerRabbit.speak("SKREEEE!"); // → The killer rabbit says 'SKREEEE!'
オブジェクト式内のspeak(line)
のようなプロパティは、メソッドを定義する簡略化された方法です。これは、speak
という名前のプロパティを作成し、関数値を与えます。
「proto」ウサギは、すべてのウサギで共有されるプロパティのコンテナとして機能します。キラーウサギのような個々のウサギオブジェクトには、自分自身にのみ適用されるプロパティ(この場合はそのタイプ)が含まれており、プロトタイプから共有プロパティを継承します。
クラス
JavaScriptのプロトタイプシステムは、クラスと呼ばれるオブジェクト指向の概念に対するやや非公式な解釈と見なすことができます。クラスは、オブジェクトの種類の形状(どのようなメソッドとプロパティを持っているか)を定義します。そのようなオブジェクトは、クラスのインスタンスと呼ばれます。
プロトタイプは、メソッドなど、クラスのすべてのインスタンスで同じ値を共有するプロパティを定義するのに役立ちます。ウサギのtype
プロパティなど、インスタンスごとに異なるプロパティは、オブジェクト自体に直接格納する必要があります。
したがって、特定のクラスのインスタンスを作成するには、適切なプロトタイプから派生するオブジェクトを作成する必要がありますが、また、このクラスのインスタンスが持つ必要があるプロパティも持っているようにする必要があります。これが、コンストラクター関数が行うことです。
function makeRabbit(type) { let rabbit = Object.create(protoRabbit); rabbit.type = type; return rabbit; }
JavaScriptは、このタイプの関数の定義を容易にする方法を提供します。関数呼び出しの前にキーワードnew
を付けると、関数はコンストラクターとして扱われます。つまり、正しいプロトタイプを持つオブジェクトが自動的に作成され、関数内のthis
にバインドされ、関数の最後に返されます。
オブジェクトの構築時に使用されるプロトタイプオブジェクトは、コンストラクター関数のprototype
プロパティを取得することによって見つかります。
function Rabbit(type) { this.type = type; } Rabbit.prototype.speak = function(line) { console.log(`The ${this.type} rabbit says '${line}'`); }; let weirdRabbit = new Rabbit("weird");
コンストラクター(実際にはすべての関数)は、prototype
という名前のプロパティを自動的に取得します。デフォルトでは、Object.prototype
から派生するプレーンな空のオブジェクトを保持します。必要に応じて、新しいオブジェクトで上書きできます。または、例のように、既存のオブジェクトにプロパティを追加することもできます。
慣例により、コンストラクターの名前は大文字で始まるため、他の関数と簡単に区別できます。
プロトタイプがコンストラクターと関連付けられる方法(そのprototype
プロパティを介して)と、オブジェクトがプロトタイプを持つ方法(Object.
で見つけることができます)との違いを理解することが重要です。コンストラクターの実際のプロトタイプはFunction.
です(コンストラクターは関数であるため)。そのprototype
プロパティは、それによって作成されたインスタンスに使用されるプロトタイプを保持します。
console.log(Object.getPrototypeOf(Rabbit) == Function.prototype); // → true console.log(Object.getPrototypeOf(weirdRabbit) == Rabbit.prototype); // → true
クラス表記
したがって、JavaScriptクラスは、prototypeプロパティを持つコンストラクター関数です。それが動作原理であり、2015年までは、このように記述する必要がありました。現在では、より扱いやすい表記法があります。
class Rabbit { constructor(type) { this.type = type; } speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } } let killerRabbit = new Rabbit("killer"); let blackRabbit = new Rabbit("black");
class
キーワードはクラス宣言を開始し、コンストラクタとメソッドのセットを1つの場所に定義できます。宣言の中括弧内には、任意の数のメソッドを記述できます。constructor
という名前のものは特別に扱われます。これは実際のコンストラクタ関数を提供し、Rabbit
という名前にバインドされます。他のメソッドは、そのコンストラクタのプロトタイプにパッケージ化されます。したがって、前のクラス宣言は前のセクションのコンストラクタ定義と同等です。単に見た目が優れているだけです。
現在のクラス宣言では、プロトタイプに追加できるのはメソッド(関数を持つプロパティ)のみです。関数以外の値をそこに保存したい場合、これはやや不便です。言語の次のバージョンでは、おそらくこれが改善されるでしょう。現時点では、クラスを定義した後にプロトタイプを直接操作することで、そのようなプロパティを作成できます。
function
と同様に、class
はステートメントと式で両方使用できます。式として使用する場合、バインディングを定義するのではなく、コンストラクタを値として生成します。クラス式ではクラス名を省略できます。
let object = new class { getWord() { return "hello"; } }; console.log(object.getWord()); // → hello
派生プロパティのオーバーライド
プロパティをオブジェクトに追加する場合、それがプロトタイプに存在するかどうかとは関係なく、プロパティはオブジェクト自体に追加されます。プロトタイプに既に同じ名前のプロパティがある場合、このプロパティはオブジェクト自身のプロパティによって隠されるため、オブジェクトに影響を与えなくなります。
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(blackRabbit.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]
マップ
前の章では、関数を要素に適用することでデータ構造を変換する操作について「map」という単語を使用しました。混乱を招くかもしれませんが、プログラミングでは同じ単語が、関連しているもののかなり異なるものにも使用されます。
マップ(名詞)は、値(キー)を他の値に関連付けるデータ構造です。たとえば、名前を年齢にマップしたい場合があります。これにはオブジェクトを使用できます。
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
から派生するため、プロパティが存在するように見えます。
そのため、プレーンオブジェクトをマップとして使用するのは危険です。この問題を回避するためのいくつかの方法があります。まず、プロトタイプを持たないオブジェクトを作成できます。Object.create
にnull
を渡すと、結果のオブジェクトは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
演算子の代わりに、オブジェクトのプロトタイプを無視するhasOwnProperty
メソッドを使用できます。
console.log({x: 1}.hasOwnProperty("x")); // → true console.log({x: 1}.hasOwnProperty("toString")); // → false
ポリモーフィズム
オブジェクトに対してString
関数(値を文字列に変換する)を呼び出すと、そのオブジェクトのtoString
メソッドを呼び出して、そこから意味のある文字列を作成しようとします。いくつかの標準プロトタイプは、"[object Object]"
よりも役立つ情報を含む文字列を作成できるように、独自のtoString
バージョンを定義していると述べました。あなた自身もそれを行うことができます。
Rabbit.prototype.toString = function() { return `a ${this.type} rabbit`; }; console.log(String(blackRabbit)); // → a black rabbit
これは強力なアイデアの単純なインスタンスです。コードが特定のインターフェース(この場合はtoString
メソッド)を持つオブジェクトで動作するように記述されている場合、このインターフェースをサポートするあらゆる種類のオブジェクトをコードにプラグインできます。そして、それは単に機能します。
この手法はポリモーフィズムと呼ばれます。ポリモーフィックコードは、期待されるインターフェースをサポートする限り、異なる形状の値で動作できます。
第4章で、for
/of
ループはいくつかの種類のデータ構造をループ処理できることを述べました。これはポリモーフィズムの別のケースです。このようなループは、データ構造が特定のインターフェース(配列と文字列が持つ)を公開することを期待しています。そして、私たち自身のオブジェクトにもこのインターフェースを追加できます!しかし、それを行う前に、シンボルが何であるかを知る必要があります。
シンボル
複数のインターフェースが異なるものに対して同じプロパティ名を使用することは可能です。たとえば、toString
メソッドがオブジェクトを糸に変換することを意図したインターフェースを定義できます。オブジェクトがそのインターフェースとtoString
の標準的な使用方法の両方に準拠することは不可能です。
それは悪い考えであり、この問題はそれほど一般的ではありません。ほとんどのJavaScriptプログラマーは単にそれについて考えていません。しかし、この種のことを考えることが仕事である言語設計者は、それでも私たちに解決策を提供してくれました。
プロパティ名は文字列であると主張したとき、それは完全に正確ではありませんでした。通常はそうであることはありますが、シンボルでもあります。シンボルは、Symbol
関数を使用して作成された値です。文字列とは異なり、新しく作成されたシンボルは一意です。同じシンボルを2回作成することはできません。
let sym = Symbol("name"); console.log(sym == Symbol("name")); // → false Rabbit.prototype[sym] = 55; console.log(blackRabbit[sym]); // → 55
Symbol
に渡す文字列は、文字列に変換したときに含まれ、たとえばコンソールに表示する場合にシンボルを認識しやすくすることができます。しかし、それ以上の意味はありません。複数のシンボルが同じ名前を持つ可能性があります。
一意であり、プロパティ名として使用できるため、シンボルは、名前が何であっても、他のプロパティと平和的に共存できるインターフェースを定義するのに適しています。
const toStringSymbol = Symbol("toString"); Array.prototype[toStringSymbol] = function() { return `${this.length} cm of blue yarn`; }; console.log([1, 2].toString()); // → 1,2 console.log([1, 2][toStringSymbol]()); // → 2 cm of blue yarn
プロパティ名の周囲に角括弧を使用して、オブジェクト式とクラスにシンボルプロパティを含めることができます。これにより、角括弧のプロパティアクセス表記と同様にプロパティ名が評価され、シンボルを保持するバインディングを参照できます。
let stringObject = { [toStringSymbol]() { return "a jute rope"; } }; console.log(stringObject[toStringSymbol]()); // → a jute rope
イテレーターインターフェース
for
/of
ループに渡されるオブジェクトは、反復可能である必要があります。これは、Symbol.iterator
シンボル(言語によって定義され、Symbol
関数のプロパティとして格納されているシンボル値)という名前のメソッドを持つことを意味します。
呼び出されると、そのメソッドは2番目のインターフェースであるイテレーターを提供するオブジェクトを返す必要があります。これは実際に反復処理を行うものです。これは、次の結果を返すnext
メソッドを持っています。その結果は、value
プロパティ(存在する場合の次の値を提供する)とdone
プロパティ(結果がなくなった場合はtrue、そうでない場合はfalse)を持つオブジェクトである必要があります。
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}
反復可能なデータ構造を実装してみましょう。2次元配列として機能するmatrixクラスを作成します。
class Matrix { constructor(width, height, element = (x, y) => undefined) { this.width = width; this.height = height; this.content = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { this.content[y * width + x] = element(x, y); } } } get(x, y) { return this.content[y * this.width + x]; } set(x, y, value) { this.content[y * this.width + x] = value; } }
このクラスは、そのコンテンツをwidth × height要素の単一の配列に格納します。要素は行ごとに格納されるため、たとえば、5行目の3番目の要素は(0ベースのインデックスを使用)、4 × width + 2の位置に格納されます。
コンストラクタ関数は、幅、高さ、および初期値の入力に使用されるオプションのelement
関数を取ります。行列の要素を取得および更新するためのget
メソッドとset
メソッドがあります。
行列をループ処理する場合、要素自体だけでなく、要素の位置にも関心を持つことが一般的です。そのため、イテレータはx
、y
、value
プロパティを持つオブジェクトを生成するようにします。
class MatrixIterator { constructor(matrix) { this.x = 0; this.y = 0; this.matrix = matrix; } next() { if (this.y == this.matrix.height) return {done: true}; let value = {x: this.x, y: this.y, value: this.matrix.get(this.x, this.y)}; this.x++; if (this.x == this.matrix.width) { this.x = 0; this.y++; } return {value, done: false}; } }
このクラスは、x
およびy
プロパティで行列の反復処理の進捗状況を追跡します。next
メソッドはまず、行列の最後尾に到達したかどうかをチェックします。到達していない場合は、*最初に*現在の値を保持するオブジェクトを作成し、*次に*その位置を更新して、必要に応じて次の行に移動します。
Matrix
クラスを反復可能に設定しましょう。本書では、コードの個々の部分を小さく独立したものに保つために、事後的にプロトタイプ操作を使用してメソッドをクラスに追加することが時々あります。コードを小さな断片に分割する必要がない通常のプログラムでは、これらのメソッドをクラス内で直接宣言します。
Matrix.prototype[Symbol.iterator] = function() { return new MatrixIterator(this); };
let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`); for (let {x, y, value} of matrix) { console.log(x, y, value); } // → 0 0 value 0,0 // → 1 0 value 1,0 // → 0 1 value 0,1 // → 1 1 value 1,1
ゲッター、セッター、スタティックメソッド
インターフェースはほとんどメソッドで構成されることがありますが、関数以外の値を保持するプロパティを含めることも可能です。たとえば、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.
と記述して、華氏を使用して温度を作成できます。
継承
行列の中には、対称であることが知られているものがあります。対称行列を左上から右下への対角線を中心に対称移動しても、同じままです。つまり、x,yに格納されている値は、常にy,xと同じです。
行列が対称であるという事実を強制する、Matrix
のようなデータ構造が必要になったと想像してください。ゼロから書くこともできますが、既に書いたものと非常によく似たコードを繰り返すことになります。
JavaScriptのプロトタイプシステムにより、古いクラスと非常によく似た新しいクラスを作成できますが、一部のプロパティの定義は新しくなります。新しいクラスのプロトタイプは古いプロトタイプから派生しますが、たとえばset
メソッドの新しい定義が追加されます。
オブジェクト指向プログラミングの用語では、これを継承といいます。新しいクラスは古いクラスからプロパティと動作を継承します。
class SymmetricMatrix extends Matrix { constructor(size, element = (x, y) => undefined) { super(size, size, (x, y) => { if (x < y) return element(y, x); else return element(x, y); }); } set(x, y, value) { super.set(x, y, value); if (x != y) { super.set(y, x, value); } } } let matrix = new SymmetricMatrix(5, (x, y) => `${x},${y}`); console.log(matrix.get(2, 3)); // → 3,2
extends
という単語の使用は、このクラスがデフォルトのObject
プロトタイプではなく、他のクラスに基づいていることを示しています。これはスーパークラスと呼ばれます。派生クラスはサブクラスです。
SymmetricMatrix
インスタンスを初期化するには、コンストラクタはsuper
キーワードを使用してスーパークラスのコンストラクタを呼び出します。これは、この新しいオブジェクトが(おおよそ)Matrix
のように動作する必要がある場合、行列が持つインスタンスプロパティが必要になるためです。行列が対称であることを保証するために、コンストラクタはelement
関数をラップして、対角線以下の値の座標をスワップします。
set
メソッドは、コンストラクタを呼び出すのではなく、スーパークラスのメソッドセットから特定のメソッドを呼び出すために、もう一度super
を使用します。set
を再定義していますが、元の動作を使用したいと考えています。this.set
は新しいset
メソッドを参照するため、それを呼び出すことは機能しません。クラスメソッド内では、super
はスーパークラスで定義されたメソッドを呼び出す方法を提供します。
継承により、比較的少ない労力で既存のデータ型からわずかに異なるデータ型を構築できます。カプセル化とポリモーフィズムと並んで、オブジェクト指向の伝統の基本的な部分です。しかし、後者2つは現在一般的に素晴らしいアイデアと見なされていますが、継承はより論争の的です。
カプセル化とポリモーフィズムは、コードの断片を互いに分離して、プログラム全体の複雑さを軽減するために使用できますが、継承は基本的にクラスを結び付け、より多くの複雑さを生み出します。クラスから継承する場合、単にそれを使用する場合よりも、その動作についてより多くのことを知る必要があります。継承は便利なツールであり、私自身のプログラムでも時々使用していますが、最初に使用するツールではなく、クラス階層(クラスのファミリーツリー)を構築する機会を積極的に探すべきではありません。
instanceof演算子
オブジェクトが特定のクラスから派生したかどうかを知ることは、時々役立ちます。そのため、JavaScriptはinstanceof
と呼ばれる二項演算子を提供します。
console.log( new SymmetricMatrix(2) instanceof SymmetricMatrix); // → true console.log(new SymmetricMatrix(2) instanceof Matrix); // → true console.log(new Matrix(2, 2) instanceof SymmetricMatrix); // → false console.log([1] instanceof Array); // → true
この演算子は継承された型を透過的に処理するため、SymmetricMatrix
はMatrix
のインスタンスです。この演算子は、Array
などの標準コンストラクタにも適用できます。ほとんどすべてのオブジェクトはObject
のインスタンスです。
まとめ
オブジェクトは、独自の属性を保持するだけではありません。オブジェクトには、他のオブジェクトであるプロトタイプがあります。プロトタイプがそのプロパティを持っている限り、オブジェクトはそれ自身に存在しないプロパティを持っているかのように動作します。単純なオブジェクトは、Object.prototype
をプロトタイプとして持ちます。
通常は大文字で始まる名前の関数であるコンストラクタは、new
演算子を使用して新しいオブジェクトを作成するために使用できます。新しいオブジェクトのプロトタイプは、コンストラクタのprototype
プロパティにあるオブジェクトになります。これを使用して、特定の型のすべての値が共有するプロパティをそのプロトタイプに配置することができます。コンストラクタとそのプロトタイプを明確に定義するclass
表記があります。
ゲッターとセッターを定義して、オブジェクトのプロパティにアクセスするたびにメソッドを秘密裏に呼び出すことができます。スタティックメソッドは、プロトタイプではなく、クラスのコンストラクタに格納されているメソッドです。
instanceof
演算子は、オブジェクトとコンストラクタを指定して、そのオブジェクトがそのコンストラクタのインスタンスかどうかを判断できます。
オブジェクトで実行できる便利なことの1つは、オブジェクトのインターフェースを指定し、そのインターフェースを介してのみオブジェクトとやり取りする必要があることをすべての人に伝えることです。オブジェクトを構成する残りの詳細は、インターフェースの背後に隠されてカプセル化されます。
複数の型が同じインターフェースを実装できます。インターフェースを使用するように記述されたコードは、インターフェースを提供する任意の数の異なるオブジェクトを自動的に処理する方法を知っています。これはポリモーフィズムと呼ばれます。
いくつかの詳細のみが異なる複数のクラスを実装する場合、新しいクラスを既存のクラスのサブクラスとして記述し、その動作の一部を継承すると役立つ場合があります。
練習問題
ベクトル型
2次元空間のベクトルを表すクラスVec
を作成します。これは、x
とy
のパラメータ(数値)を受け取り、同じ名前のプロパティに保存する必要があります。
Vec
プロトタイプに、plus
メソッドとminus
メソッドの2つのメソッドを追加します。これらのメソッドは別のベクトルを引数として受け取り、2つのベクトル(thisとパラメーター)のx値とy値の和または差を持つ新しいベクトルを返します。
ベクトルの長さを計算するlength
というゲッタープロパティをプロトタイプに追加します。つまり、点(x, y)と原点(0, 0)との間の距離を計算します。
// 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
メソッドの借用
この章の前半では、プロトタイプのプロパティを無視する場合、オブジェクトのhasOwnProperty
をin
演算子のより堅牢な代替手段として使用できると述べました。しかし、マップに「hasOwnProperty」という単語を含める必要がある場合はどうでしょうか?オブジェクト自身のプロパティがメソッドの値を隠してしまうため、そのメソッドを呼び出すことができなくなります。
その名前の自身のプロパティを持つオブジェクトでhasOwnProperty
を呼び出す方法を考えられますか?
let map = {one: true, two: true, hasOwnProperty: true}; // Fix this call console.log(map.hasOwnProperty("one")); // → true