第4版が利用可能です。こちらで読んでください

第6章オブジェクトの秘密の生活

抽象データ型は、特別な種類のプログラムを書くことで実現されます……それは、その型を、それに対して実行できる操作という観点から定義します。

Barbara Liskov, Programming with Abstract Data Types
Picture of a rabbit with its proto-rabbit

第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.getPrototypeOfはオブジェクトのプロトタイプを返します。

JavaScriptオブジェクトのプロトタイプ関係はツリー状の構造を形成し、この構造のルートにはObject.prototypeがあります。これは、オブジェクトを文字列表現に変換するtoStringなど、すべてのオブジェクトに表示されるいくつかのメソッドを提供します。

多くのオブジェクトは、プロトタイプとして直接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 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.getPrototypeOfで見つけることができます)との違いを理解することが重要です。コンストラクターの実際のプロトタイプはFunction.prototypeです(コンストラクターは関数であるため)。その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

次の図は、このコードの実行後に状況を概説しています。RabbitObjectのプロトタイプは、オブジェクト自体に見つからないプロパティを検索できる一種の背景としてkillerRabbitの背後にあります。

Rabbit object prototype schema

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

オーバーライドは、標準の関数と配列のプロトタイプに、基本的なオブジェクトプロトタイプとは異なる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]

マップ

前の章では、関数を要素に適用することでデータ構造を変換する操作について「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.createnullを渡すと、結果のオブジェクトは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演算子の代わりに、オブジェクトのプロトタイプを無視する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)を持つオブジェクトである必要があります。

nextvaluedoneプロパティ名はプレーンな文字列であり、シンボルではないことに注意してください。多くの異なるオブジェクトに追加される可能性が高い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メソッドがあります。

行列をループ処理する場合、要素自体だけでなく、要素の位置にも関心を持つことが一般的です。そのため、イテレータはxyvalueプロパティを持つオブジェクトを生成するようにします。

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);
};

これで、for/ofを使用して行列をループ処理できます。

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.fromFahrenheit(100)と記述して、華氏を使用して温度を作成できます。

継承

行列の中には、対称であることが知られているものがあります。対称行列を左上から右下への対角線を中心に対称移動しても、同じままです。つまり、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

この演算子は継承された型を透過的に処理するため、SymmetricMatrixMatrixのインスタンスです。この演算子は、Arrayなどの標準コンストラクタにも適用できます。ほとんどすべてのオブジェクトはObjectのインスタンスです。

まとめ

オブジェクトは、独自の属性を保持するだけではありません。オブジェクトには、他のオブジェクトであるプロトタイプがあります。プロトタイプがそのプロパティを持っている限り、オブジェクトはそれ自身に存在しないプロパティを持っているかのように動作します。単純なオブジェクトは、Object.prototypeをプロトタイプとして持ちます。

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

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

instanceof演算子は、オブジェクトとコンストラクタを指定して、そのオブジェクトがそのコンストラクタのインスタンスかどうかを判断できます。

オブジェクトで実行できる便利なことの1つは、オブジェクトのインターフェースを指定し、そのインターフェースを介してのみオブジェクトとやり取りする必要があることをすべての人に伝えることです。オブジェクトを構成する残りの詳細は、インターフェースの背後に隠されてカプセル化されます。

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

いくつかの詳細のみが異なる複数のクラスを実装する場合、新しいクラスを既存のクラスのサブクラスとして記述し、その動作の一部を継承すると役立つ場合があります。

練習問題

ベクトル型

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

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

class宣言の方法が不明な場合は、Rabbitクラスの例を参照してください。

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

グループ

標準的な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という名前のメソッドが追加されます。

メソッドの借用

この章の前半では、プロトタイプのプロパティを無視する場合、オブジェクトのhasOwnPropertyin演算子のより堅牢な代替手段として使用できると述べました。しかし、マップに「hasOwnProperty」という単語を含める必要がある場合はどうでしょうか?オブジェクト自身のプロパティがメソッドの値を隠してしまうため、そのメソッドを呼び出すことができなくなります。

その名前の自身のプロパティを持つオブジェクトでhasOwnPropertyを呼び出す方法を考えられますか?

let map = {one: true, two: true, hasOwnProperty: true};

// Fix this call
console.log(map.hasOwnProperty("one"));
// → true

プレーンオブジェクトに存在するメソッドはObject.prototypeから来ていることを思い出してください。

また、特定のthisバインディングを使用して関数を呼び出すには、そのcallメソッドを使用できることも思い出してください。