NOTE: この記事は、弊社内のオンボーディング資料を公開したものです。
- 他言語学習者向け:JavaScript/ECMAScript文法速習リファレンス
- 他言語学習者向け:JavaScript/ECMAScriptプロトタイプ及びクラス入門
- NodeJS入門: イベントループとlibuv編
主に他言語の経験がある人向けのJS/ECMAScriptのプロトタイプに関しての理解を深めるための資料です。
JSに初めて触れる方はこちらを先に読んでください。
JSのグローバル空間
JavaScriptのグローバル空間は、巨大の1つのオブジェクトとようなものとして表現されており、コード内で宣言される変数は、このグローバル空間のメンバ変数(プロパティ、メソッド)の一部とみなされます。
var a = 'my variable!' // constやletではブロック内宣言となるので、ここではvarを使っています
console.log(globalThis.a); // my variable!
このグローバルスコープ自身を表す特殊な変数として、globalThis
が用意されています(ES6以降)。
Object
のようなデータ型やFunction
、Array
のような組み込みオブジェクト、window
のようなスコープもグローバル空間に所属しており、私たちが利用できるようになっています。
プロトタイプベースのJavaScript
プロトタイプベースとは、オブジェクトの生成にあたり、他のオブジェクトの一部を参照して新たなオブジェクトを作成するオブジェクト指向プログラミングの一種です。
ここでいう、「他のオブジェクトの一部」をプロトタイプと呼びます。
現在では、すでにECMAScriptやTypeScriptではクラスの概念が導入されており、JavaやC#のようなクラスベースでのオブジェクト指向プログラミングが可能になっていますが、JavaScriptは元来、プロトタイプベースであり、一部の特殊な振る舞いを理解するには、この仕組みを理解することが欠かせません。
超柔軟なプロトタイプベース
クラスベースの言語では、型であるクラスと値であるインスタンスは厳密に分離していますが、プロトタイプベースであるJavaScriptにおいては、どちらもオブジェクトとして取り扱われます。
これにより、クラスをコードで動的に拡張したり、変更することすら可能です。
この特性は、JavaScriptの際立った柔軟性の1つともいえます。
プロトタイプとクラスの違い
では、一例として、 Array
型のデータを作成する際に、プロトタイプがどのように働くのか、その背景を考えてみましょう。
Arrayは配列を表す組み込みオブジェクトです。次のように利用できます。
const ary = new Array('a','b'); // ary => ['a', 'b']
これは、その他の言語でいえば、Array
クラスから ary
インスタンスを作成したとも言い換えることができます。
ary
には、Arrayが持っていた関数が、プロパティ(メソッド)として利用できるようになっています。
ary.forEach(element => console.log(element));
例えば、上記の forEach
は要素を列挙するメソッドですね。
では、この forEach
の処理が実際に書かれた関数はどこに実装されているのでしょうか?
クラスベースの言語であれば、次のようなものと考えるでしょう。
class Array {
forEach(callback){
/** ここに処理が定義されているはず? **/
}
}
しかし、昔のJavaScriptにはクラスという概念は存在しないはずです。
このnew
キーワードはどのように処理されていたのでしょうか?
プロトタイプの実態
JavaScriptでは長らくクラスという概念が存在せず、クラスからインスタンスを生成するような考え方とは別の方法が用いられてきました。それがプロトタイプベースです。
プロトタイプベースであるJavaScriptでは、Array.prototype
にforEach
が登録されています。
確認してみましょう。
console.log(Array.prototype.forEach); // [Function: forEach]
つまり、Arrayの固有のメソッドは Array.prototype
に格納されており、 new
つまりコンストラクタが動作するタイミングで、aryにも同様のメソッドがセットされたことがわかります。
では、ここで forEach
メソッドを試しに削除してみましょう。
オブジェクトから特定のプロパティやメソッドを削除するには delete
を使います。
delete ary.forEach
console.log(ary.forEach) // あれれ?消せない???
deleteを実行したにも関わらず、forEachが存在しています。これはなぜでしょうか。
インスタンスに存在しないプロパティを格納する内部プロパティ
実は、forEach
は、ary
に直接登録されているのではなく、その内部プロパティ [[Prototype]]
に含まれています。
この内部プロパティの表現方法はJavaScriptの処理系によって異なりますが、ここでは、一般的な __proto__
で見てみましょう。
console.log(ary.__proto__.forEach); // ここに入っていた!
実は、元となったArrayオブジェクトから引き継がれたメソッドは、全て __proto__
に格納されています。
便宜的に、クラスとインスタンスという用語で説明すると、
- Arrayクラスのメソッドは
Array.prototype
に定義されている -
new Array()
で作成されたインスタンスは、内部に__proto__
プロパティがあり、そこにメソッドを格納する - インスタンスに該当するメソッドやプロパティがなければ、
__proto__
プロパティを参照し、そこから呼び出す
という仕組みになっていることがわかります。
なぜ内部プロトタイプを使うのか
ary.forEach
が、実際にはary.__proto__
に登録されていることはわかりました。
しかし、なぜ直接ではなく、ary.__proto__
に登録しているのでしょうか。
実は、ary.__proto__
は、Array.prototype
へのゲッター及びセッターとしての機能を持っています。
これにより、Arrayからnew
によって作成された全ての変数が、同じメソッドを参照している形となります。
もし直接、ary.forEach
にしてしまうと、どのメンバ変数が元のArrayから継承されたメソッドなのかわからなくなってしまいます。
関数が第一級関数であるJavaScriptの世界では、確かに任意の関数を任意の変数に自在に登録することができますが、「何が固有のプロパティで、何が引き継いだプロパティなのか」を明確にするためにプロトタイプが利用されている、と考えてください。
より詳しく知りたい人は、ぜひMDNの記事を読んでください。
クラス登場以前のJavaScriptにおけるクラス表現
ここまでで、prototypeと__proto__の関係について、何となく理解できたかと思います。
さて、先述の通り、JavaScriptにはもともとクラスという概念がありませんでした。
ただ、prototypeを駆使することで、クラスのようにメソッドを定義できたため、クラスベースのオブジェクト指向プログラミングのようにnew
を用いてインスタンスを生成する方法も使うことができました。
では、実際にクラスが導入される前のJavaScriptでは、どのようにクラスを表現していたのでしょうか?
こんな形で定義されていました。
// コンストラクタは関数定義を用いる
var MyClass = function(arg) {
this.arg = arg;
}
// メソッドやプロパティはprototypeに登録する。
MyClass.prototype.getArg = function() {
return this.arg;
}
MyClass.prototype.sayHello = function() {
console.log('hello! ' + this.arg);
}
var myInstance = new MyClass('test');
ES6以降、クラスが導入されたため、この表現は過去のものとなりましが、現在でも古いNPMライブラリや古いブラウザ向けのトランスパイルでこの方式のクラス定義を見ることができます。
クラス
ECMAScriptでは、ES6よりクラス宣言とクラス式が導入され、一般的なカプセル化を実現するためのクラス表現が可能になりました。
次のように記述します。
class Rectangle {
#_height = 0;
#_width = 0;
constructor(height, width) {
this.height = height;
this.width = width;
}
// メソッド
calcArea() {
return this.height * this.width;
}
// アクセサ
get height(){ return this._height; }
set height(num){
this._height = num >= 0 ? num : 0;
}
get width(){ return this._width; }
set width(num){
this._width = num >= 0 ? num : 0;
}
// 静的メソッド
static create(num){
return new Rectangle(num, num)
}
}
ここでは、メンバ変数(フィールド)として height
width
をもち、area
ゲッター、calcArea
メソッドを持つクラスが定義されています。
this
キーワードによってメンバ変数にアクセスできるようになっているのが分かります。
その他の言語のクラスとの違い
アクセス修飾子
フィールド宣言では #
を付与することでプライベート化することができます。
class Rectangle {
#_height = 0;
#_width;
constructor(height, width) {
this.#_height = height;
this.#_width = width;
}
}
プライベートフィールドは事前宣言でないと作成できないことに注意してください。
いわゆる protected
なアクセス修飾子は定義することができません。
インターフェイス・抽象クラス
型を持たないJavaScriptでは、インターフェイスの定義ができません。
同様に抽象クラス(abstract class)も利用できません。ミックスインなどの他の方法を使う必要があります。
これらの問題は、JavaScriptの型付き拡張であるTypeScriptで解決されています。
デストラクタ
デストラクタやファイナライザが存在しません。JavaScriptの処理系はほとんどの場合、自動的にガベージコレクションが行われるため、インスタンスのライフサイクルは不透明です。終了処理が必要な場合には、手動で終了処理を実装し、呼び出す必要があります。
継承
その他の言語と同様、extends
キーワードを用いることで、クラスを継承することができます。
class Animal {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Dog extends Animal {
constructor(name) {
super(name); // 継承元クラスのコンストラクターを呼び出し、name パラメータを渡す
}
speak() {
console.log(`${this.name} barks.`);
}
}
let d = new Dog("Mitzie");
d.speak(); // Mitzie barks.
継承先のクラスのコンストラクタでは、必ず super
をコールする必要があります。
また、super
は、継承元クラスのメソッドへのアクセスにも用いることができます。
class Cat {
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name} makes a noise.`);
}
}
class Lion extends Cat {
speak() {
super.speak(); // 継承元クラスのメソッドを呼び出す
console.log(`${this.name} roars.`);
}
}
let l = new Lion("Fuzzy");
l.speak();
多重継承・ミックスイン・トレイト
多重継承はできません。常に1つのクラスをスーパークラスとして継承元にすることができます。
一方、ミックスインに関しては、技巧的な方法で実現できます。
let calculatorMixin = (Base) =>
class extends Base {
calc() {}
};
let randomizerMixin = (Base) =>
class extends Base {
randomize() {}
};
// 実際に使用する例
class Foo {}
class Bar extends calculatorMixin(randomizerMixin(Foo)) {}
トレイトも存在しないため、ミックスインを併用することになります。
静的メンバ(クラス変数)
static
キーワードを用いることで定義されます。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
static displayName = "Point";
static distance(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.hypot(dx, dy);
}
}
静的メンバの内部におけるthis
は、クラスそのものを指すことを忘れないでください。
TypeScriptによる拡張
TypeScriptのクラスでは、さらにアクセス修飾子、抽象クラス、インターフェイス、ジェネリクスなどを導入することができます。
また、クラスを型として取り扱えるため、さらに安全性が向上します。
詳しくは、TypeScript入門の記事で別に紹介します。