JavaScriptには、オブジェクト指向プログラミング(OOP)のための機能が備わっています。OOPでは、「テンプレート」(クラス)からオブジェクト(インスタンス)を作り、特定のデータと機能を持たせます。OOPの文脈では、データのプロパティは_フィールド_、関数のプロパティは_メソッド_と呼ばれます。
JavaScriptには、2015年にクラスが言語仕様に追加されるまでクラスそのものが存在しませんでしたが、プロトタイプベースの継承を使ったオブジェクト指向プログラミングは可能でした。そして、今日ではclassキーワードが利用できるようになっても、JavaScriptは依然として_プロトタイプベース_の言語です。
プロトタイプベースの言語であるとはどういうことか、そしてJavaScriptが実際にどう動くのかを理解するために、クラスがなかった時代にさかのぼってみましょう。
JavaScriptでは、テンプレート(クラス)は通常の関数で実現します。ある関数をそのようなテンプレートとして使うとき、その関数は_コンストラクター関数_と呼ばれ、関数名を大文字で始めるのが慣習です。インスタンス(オブジェクト)は、コンストラクター関数を呼び出すときにnewキーワードを使ってテンプレートから作られます。
function Car() {
// ...
}
const myCar = new Car();
const yourCar = new Car();
ここで重要なのは、JavaScriptではインスタンスを作成したあとも、インスタンスとコンストラクター関数のあいだに関係が保たれるという点です。すべてのインスタンスオブジェクトは、言語仕様で[[prototype]]と呼ばれる、隠された内部プロパティを持ちます。これは、コンストラクター関数のprototypeキーの値への参照を保持しています。そうです、そのとおりです。JavaScriptの関数は、裏ではオブジェクトでもあるため、キーと値のペアを持てるのです。
2015年以降、[[prototype]]にはObject.getPrototypeOf()でアクセスできます。それ以前は、多くの環境で__proto__というキーを通じてアクセスできました。
オブジェクトのプロトタイプ([[prototype]])と、コンストラクター関数のprototypeプロパティを混同しないでください。
まとめると、次のとおりです。
prototypeプロパティが、インスタンスの_プロトタイプ_になります。多くの場合、生成されるすべてのオブジェクト(インスタンス)にいくつかのフィールドを持たせ、オブジェクトを構築するときにその初期値を渡したいことがあります。これはthisキーワードで実現できます。コンストラクター関数の中で、thisはnewによってこれから作られる新しいオブジェクトを表します。newを付けて呼び出したとき、thisはコンストラクター関数から自動的に返されます。
つまり、コンストラクター関数の中でthisにフィールドを追加すれば、新しいインスタンスにフィールドを追加できるということです。
function Car(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
const myCar = new Car('red', '2mt');
myCar.color;
// => 'red'
myCar.engineRunning;
// => false
メソッドは、コンストラクター関数のprototypeプロパティを通じて追加します。メソッドの中では、thisを通じてインスタンスのフィールドにアクセスできます。これが成り立つのは、次の一般的なルールがあるからです。
関数がオブジェクトのメソッドとして呼び出されると、その
thisはメソッドが呼び出されたオブジェクトに設定されます。1
function Car() {
this.engineRunning = false;
// ...
}
Car.prototype.startEngine = function () {
this.engineRunning = true;
};
Car.prototype.addGas = function (litre) {
// ...
};
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
上の例のmyCarは通常のJavaScriptオブジェクトなので、(たとえばブラウザーのコンソールで)調べてみても、myCarオブジェクトの直下に値が関数であるstartEngineプロパティは見つかりません。では、上のコードはどうして動くのでしょうか。
ここでの秘密は、_プロトタイプチェーン_と呼ばれる仕組みです。オブジェクトの任意のプロパティ(フィールドまたはメソッド)にアクセスしようとすると、JavaScriptはまず、そのキーがオブジェクト自身の直下に見つかるかどうかを調べます。見つからなければ、元のオブジェクトの[[prototype]]プロパティが参照するオブジェクトで、そのキーを探し続けます。すでに述べたように、この例の[[prototype]]はコンストラクター関数のprototypeプロパティを指しています。startEngine関数はそこに追加したので、JavaScriptはそこに見つけることができるのです。
function Car() {
// ...
}
Car.prototype.startEngine = function () {
// ...
};
そして、チェーンはそこで終わりません。Car.prototypeの[[prototype]]プロパティ(myCar.[[prototype]].[[prototype]])は、Object.prototype(Objectコンストラクター関数のprototypeプロパティ)を参照します。そこには、すべてのJavaScriptオブジェクトで利用できる汎用的なメソッド(たとえばtoString())が含まれています。Objectの[[prototype]]は通常nullなので、プロトタイプチェーンはそこで終わります。結論として、myCar.toString()を呼び出せば、そのメソッドは存在します。JavaScriptがプロトタイプチェーン全体をたどってそのメソッドを探すからです。詳しい例は、MDNの記事「継承とプロトタイプチェーン」にあります。
プロトタイプチェーンをたどるのは、値を取得するときだけです。インスタンスオブジェクトのプロパティを直接設定したり削除したりしても、その特定のインスタンスだけが対象になります。クラスベースの継承を持つ言語に慣れていると、意外に感じるかもしれません。
JavaScriptでは、作成済みのすべてのインスタンスにメソッドを追加できます。
すべてのインスタンスが、コンストラクター関数のprototypeプロパティへの参照を保っていることは学びました。つまり、そのprototypeオブジェクトに項目を追加すると、新しい項目(たとえば新しいメソッド)は、そのコンストラクター関数から作られたすべてのインスタンスですぐに利用できるようになります。
function Car() {
this.engineRunning = false;
}
const myCar = new Car();
// Calling myCar.startEngine() here would result in "TypeError:
// myCar.startEngine is not a function".
Car.prototype.startEngine = function () {
this.engineRunning = true;
};
myCar.startEngine();
// This works, even though myCar was created before the method
// was added.
理論上は、動的メソッドを使って、ObjectやArrayのような組み込みオブジェクトのプロトタイプを変更し、機能を拡張することもできます。これは_モンキーパッチ_と呼ばれます。この変更はアプリケーション全体に影響するため、意図しない副作用を防ぐために避けるべきです。唯一合理的な使い道は、古い環境で欠けているメソッドのポリフィルを提供する場合です。
今日のJavaScriptは、classキーワードでクラスを定義できます。これは2015年に言語仕様に追加されました。一方では、クラスを読み書きしやすくする糖衣構文を提供します。新しい構文は、C++やJavaのような言語でのクラスの書き方により近くなっており、それらの言語から移ってきたエンジニアは順応しやすくなります。もう一方では、クラスの構文は、プロトタイプの構文では利用できない新しい言語機能への道を開きます。
新しい構文では、クラスはclassキーワードに続けてクラス名、そして波括弧で囲んだクラス本体で定義します。本体にはコンストラクター関数の定義、つまりconstructorという名前の特別なメソッドが含まれます。この関数は、プロトタイプの構文におけるコンストラクター関数とまったく同じように働きます。クラス本体には、すべてのメソッド定義も含まれます。メソッドの構文は、オブジェクトの中に関数を値として追加するときに見た省略記法に似ています。オブジェクトの概念を参照してください。
class Car {
constructor(color, weight) {
this.color = color;
this.weight = weight;
this.engineRunning = false;
}
startEngine() {
this.engineRunning = true;
}
addGas(litre) {
// ...
}
}
const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true
関数宣言や関数式と同様に、JavaScriptでは上に示した_クラス宣言_のほかに、クラス式もサポートしています。
JavaScriptは裏では依然としてプロトタイプベースの言語であることを覚えておいてください。上の「プロトタイプの構文」の節で学んだ仕組みは、すべてそのまま当てはまります。
既定では、JavaScriptのすべてのインスタンスフィールドは公開されています。直接アクセスしたり代入したりできます。
実際のプライベートフィールドを言語仕様に追加する作業は進行中です。詳しくは提案文書を参照してください。
それまでのあいだは、アンダースコアで始まるフィールドやメソッドはプライベートとして扱うという、定着した慣習を利用できます。それらはクラスの外から直接アクセスすべきではありません。
プライベートフィールドには、ゲッターとセッターが添えられることがあります。getとsetのキーワードを使うと、関数と同じ名前のプロパティにアクセスしたり代入したりしたときに実行される関数を定義できます。
class Car {
constructor() {
this._mileage = 0;
}
get mileage() {
return this._mileage;
}
set mileage(value) {
throw new Error(`Mileage cannot be manipulated, ${value} is ignored.`);
// Just an example, usually you would not provide a setter in this case.
}
}
const myCar = new Car();
myCar.mileage;
// => 0
myCar.mileage = 100;
// => Error: Mileage cannot be manipulated, 100 is ignored.
OOPでは、特定のインスタンスに依存しないユーティリティ的なフィールドやメソッドを用意したいことがあります。その場合は、クラス自体に対して定義します。これはstaticキーワードで実現できます。
class Car {
static type = 'vehicle';
static isType(targetType) {
return targetType === 'vehicle';
}
}
Car.type;
// => 'vehicle'
Car.isType('road sign');
// => false
先に見たプロトタイプチェーンに沿った継承のほかに、JavaScriptではクラス間の継承も表現できます。これについては継承の概念で扱います。
thisの例:オブジェクトのメソッドとして、MDN. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#as_an_object_method(2021年12月3日参照) ↩