プロトタイプベースオブジェクト指向
プロトタイプベースとは
Javaなどの言語はクラスに基づいてオブジェクトを生成するクラスベース言語ですが、JavaScriptは本質的にオブジェクトを元にして別のオブジェクトを作るプロトタイプベースの言語です。
以前の章で学習した class 構文は、JavaScriptの内部で動作しているプロトタイプベースの仕組みを他の言語に似た書き方で扱えるようにした糖衣構文(シンタックスシュガー)にすぎません。
JavaScriptにおけるすべてのオブジェクトは、自身の「ひな形」となる別のオブジェクトへの参照を内部に持っています。
この参照先のオブジェクトのことをプロトタイプと呼びます。
オブジェクトのプロパティを探索する際、もし自身がそのプロパティを持っていない場合は、プロトタイプオブジェクトへと探索が引き継がれます。
このプロトタイプの参照リンクをたどって順次プロパティを探していく仕組みを、プロトタイプチェーンと呼びます。
コンストラクタ関数とprototypeプロパティ
class 構文が導入される以前のJavaScriptでは、大文字で始まる通常の関数をコンストラクタ関数として定義し、new キーワードを付けて呼び出すことでオブジェクトを生成していました。
すべての関数オブジェクトは、標準で prototype という名前のオブジェクト(プロパティ)を保持しています。
new を用いて生成されたインスタンスは、内部のプロトタイプリンク(__proto__)が、コンストラクタ関数の prototype オブジェクトを指すように自動的に設定されます。
これによって、インスタンス間でメソッドなどのデータを共有することができます。
従来のコンストラクタ関数と prototype を用いたオブジェクト生成とメソッドの共有プログラムは以下のようになります。
prototype_basic.js
// コンストラクタ関数の定義
function Point(x, y) {
this.x = x;
this.y = y;
}
// prototypeオブジェクトにメソッドを追加して共有する
Point.prototype.printPosition = function() {
console.log(`位置: (${this.x}, ${this.y})`);
};
const point = new Point(10, 20);
point.printPosition();
実行結果
位置: (10, 20)
このプログラムでは、point インスタンス自身は printPosition メソッドを直接持っていません。
しかし、point.printPosition() を呼び出した際、プロトタイプチェーンによって自動的に Point.prototype 内のメソッドが探索され、実行されます。
プロトタイプ継承
従来のプロトタイプベースの記述でクラスの継承に相当する処理を行うには、子クラスに相当するコンストラクタ関数の prototype オブジェクトを、親クラスに相当するコンストラクタ関数の prototype オブジェクトを継承した新しいオブジェクトに差し替える必要があります。
これを行うには、Object.create() メソッドを使用します。
従来のプロトタイプベースで継承を表現するプログラムは以下のようになります。
prototype_inheritance.js
function Animal(name) {
this.name = name;
}
Animal.prototype.makeSound = function() {
console.log("音を出します");
};
function Dog(name) {
// 親クラスのコンストラクタを現在のインスタンスで実行する
Animal.call(this, name);
}
// Animal.prototypeを継承したオブジェクトをDog.prototypeに設定する
Dog.prototype = Object.create(Animal.prototype);
// constructorプロパティをDog自身に再設定する
Dog.prototype.constructor = Dog;
Dog.prototype.makeSound = function() {
console.log(`${this.name}がワンワンと鳴きます`);
};
const dog = new Dog("ポチ");
dog.makeSound();
実行結果
ポチがワンワンと鳴きます
このように、プロトタイプオブジェクトのリンクを Object.create(Animal.prototype) によって明示的に結び直すことで、親クラスのメソッドを継承しつつ独自のメソッドでオーバーライドする構造を実現していました。
class 構文の extends は、この複雑な一連の処理を内部で自動的に行うための書き方です。
thisの挙動と制御
JavaScriptにおける this の値は、関数が定義された場所ではなく「関数がどのように呼び出されたか」によって動的に決定されるという性質があります。
メソッド呼び出しとthisの消失
オブジェクトのメソッドとして関数が呼び出された場合、メソッド内の this はそのメソッドを持つオブジェクトを指します。
しかし、メソッドを別の変数に代入して通常の関数として呼び出したり、コールバック関数として他の関数に渡したりした場合は、呼び出し時のオブジェクトとの結びつきが失われます。
これによって、this の値が undefined(またはグローバルオブジェクト)になってしまう thisの消失 と呼ばれる問題が発生します。
メソッドをコールバック関数として渡した際に this が消失するプログラムは以下のようになります。
this_loss.js
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count += 1;
console.log(this.count);
}
}
const counter = new Counter();
// 1秒後に実行するコールバックとしてメソッドを渡す
setTimeout(counter.increment, 1000);
実行結果
NaN
このプログラムを実行すると、エラーが発生するか NaN(Not a Number)が出力されます。
setTimeout の内部から counter.increment が呼び出される際、オブジェクトのコンテキストが失われて単なる関数として呼び出されるため、メソッド内の this が undefined になり、this.count へのアクセスが失敗するためです。
アロー関数とbindメソッドによる解決
この this の消失問題を解決するために、以下の2つの手法が広く用いられます。
- アロー関数: アロー関数(
() => {})は、自身で独自のthisを持たない特殊な関数です。アロー関数の内部におけるthisは、関数が定義された時点の周囲のスコープ(レキシカルスコープ)のthisを常に指し示し、呼び出し方によって変化しません。 bindメソッド: すべての関数オブジェクトが持つbind()メソッドを使用すると、第1引数に指定したオブジェクトをthisに固定した新しい関数を生成することができます。また、call()やapply()メソッドを使用すると、その場でthisを指定のオブジェクトに指定して関数を実行できます。
アロー関数または bind メソッドを用いて this を固定するプログラムは以下のようになります。
this_solve.js
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count += 1;
console.log(this.count);
}
}
const counter = new Counter();
// 解決策1: アロー関数でラッピングして呼び出す
setTimeout(() => {
counter.increment();
}, 1000);
// 解決策2: bindメソッドでthisをcounterに固定した関数を渡す
setTimeout(counter.increment.bind(counter), 2000);
実行結果
1
2
アロー関数を用いた方法では、アロー関数自体が定義されたスコープにおける counter インスタンスを参照して呼び出すため、increment メソッドは正しく counter.increment() の形で実行されます。
bind(counter) を用いた方法では、あらかじめ this が counter に固定された新しい関数を作成して渡しているため、呼び出しコンテキストが変化しても正しく動作します。
演習
演習1
Object.create() メソッドを使用して、class キーワードを使用せずにオブジェクト間のプロトタイプリンクを直接結び、プロパティやメソッドの継承構造を実装しなさい。
本演習では、以下の仕様を満たすプログラムを作成しなさい。
- 共通のメソッド
greet()を持つプロトタイプオブジェクトであるpersonPrototypeを作成すること。greet()メソッドは、自身のnameプロパティを用いて"私の名前は [name] です。"と出力すること。
personPrototypeを直接継承(プロトタイプリンク)した新しいオブジェクトであるaliceを作成すること。aliceオブジェクトに、自身のプロパティとしてname: "Alice"を設定し、alice.greet()が正しく動作することを確認しなさい。
演習2
従来のコンストラクタ関数と .prototype オブジェクトへの操作を明示的に行う記述スタイルで、基底となる User と、それを継承した Admin の関係を再現しなさい。
本クラス群は、以下の仕様を満たすように設計しなさい。
User(username)コンストラクタ関数を定義し、自身のプロパティとしてusernameを設定すること。User.prototype.loginメソッドを定義し、"[username] がログインしました。"と出力すること。Admin(username, role)コンストラクタ関数を定義し、内部でUser.call(this, username)を呼び出すこと。Admin.prototypeをObject.create(User.prototype)を用いて初期化し、constructorプロパティをAdmin自身に設定すること。Admin.prototype.loginメソッドをオーバーライドし、親のloginメソッドを呼び出した後に、"権限: [role] として操作します。"と追加で出力すること。
演習3
コールバック関数としてメソッドを引き渡した際に生じる this の消失問題を解決しなさい。
以下の仕様を満たすようにプログラムを修正・実装しなさい。
- 商品名と価格を保持する
Itemクラスを作成しなさい。 Itemクラスは、指定された値引き額を価格から差し引いて出力するshowDiscountPrice(discount)メソッドを持つこと。- 配列のメソッドである
forEachを用いて、複数の値引き額の配列[100, 200, 300]をループ処理しなさい。 - ループの内部で
showDiscountPriceメソッドをコールバック関数として直接渡した場合に発生するthisの消失問題を、アロー関数またはbind()メソッドのいずれかを使用して解決しなさい。
演習の解答例
演習1の解答例
Object.create() を用いて明示的にプロトタイプオブジェクトをリンクさせる実装例は以下の通りである。
クラスを定義することなく、既存のオブジェクトをプロトタイプ(ひな形)として再利用し、新しいオブジェクトに振る舞い(メソッド)を引き継がせている。
object_create.js
const personPrototype = {
greet() {
console.log(`私の名前は ${this.name} です。`);
}
};
// personPrototypeをプロトタイプとする空のオブジェクトを作成する
const alice = Object.create(personPrototype);
alice.name = "Alice";
// プロトタイプチェーンを介してgreetメソッドが実行される
alice.greet();
実行結果
私の名前は Alice です。
演習2の解答例
従来のコンストラクタ関数とプロトタイプオブジェクトの設定による継承の実装例は以下の通りである。
Object.create でプロトタイプチェーンのリンクを設定するとともに、call を用いて親クラスのコンストラクタ初期化処理を自身の文脈で実行することで、メンバとメソッドの双方の継承を実現している。
prototype_inheritance_detail.js
function User(username) {
this.username = username;
}
User.prototype.login = function() {
console.log(`${this.username} がログインしました。`);
};
function Admin(username, role) {
// 親のコンストラクタ処理を実行する
User.call(this, username);
this.role = role;
}
// プロトタイプチェーンの構築
Admin.prototype = Object.create(User.prototype);
Admin.prototype.constructor = Admin;
// メソッドのオーバーライド
Admin.prototype.login = function() {
// 親のプロトタイプメソッドを明示的に呼び出す
User.prototype.login.call(this);
console.log(`権限: ${this.role} として操作します。`);
};
// 動作確認用
const admin = new Admin("管理者A", "システム管理者");
admin.login();
実行結果
管理者A がログインしました。
権限: システム管理者 として操作します。
演習3の解答例
コールバック関数引き渡し時の this 消失問題を解決する実装例は以下の通りである。
forEach などのコールバックとしてメソッドをそのまま渡すと、メソッド内の this が Item インスタンスではなくグローバルコンテキストになってしまう。
これを、メソッド内の this をインスタンス自身に明示的に束縛(バインド)した新しい関数を作ることで解決している。
this_callback_solve.js
class Item {
constructor(name, price) {
this.name = name;
this.price = price;
}
showDiscountPrice(discount) {
const finalPrice = this.price - discount;
console.log(`${this.name}の値引き後の価格: ${finalPrice}円`);
}
}
const item = new Item("高級ペン", 1000);
const discounts = [100, 200, 300];
// 解決策: bindメソッドを用いてthisが常にitemを指すように固定する
discounts.forEach(item.showDiscountPrice.bind(item));
実行結果
高級ペンの値引き後の価格: 900円
高級ペンの値引き後の価格: 800円
高級ペンの値引き後の価格: 700円