クラス

「クラス」と一言にいってもさまざまであるため、ここでは構造動作状態を定義できるものを示すことにします。 また、この章では概念を示す場合はクラスと呼び、クラスに関する構文(記述するコード)のことをclass構文と呼びます。

クラスとは動作状態を定義した構造です。 クラスからはインスタンスと呼ばれるオブジェクトを作成でき、インスタンスはクラスに定義した動作を継承し、状態は動作によって変化します。 とても抽象的なことに見えますが、これは今までオブジェクトや関数を使って表現してきたものにも見えます。 実際にJavaScriptではES2015より前まではclass構文はなく、関数を使いクラスのようなものを表現して扱っていました。

ES2015でクラスを表現するためのclass構文が導入されましたが、このclass構文で定義したクラスは関数オブジェクトの一種です。 class構文ではプロトタイプベースの継承の仕組みの上に関数でクラスを表現しています。 そのため、class構文はクラスを作るための関数定義や継承をパターン化した書き方といえます。1

JavaScriptでは関数で学んだことの多くは、クラスでもそのまま適応されます。 また、関数の定義方法として関数宣言文と関数式があるように、クラスにもクラス宣言文とクラス式があります。 そのため、関数とクラスは似ている部分が多いです。

この章では、class構文でのクラスの定義や継承、クラスの性質について学んでいきます。

クラスの定義

クラスを定義するにはclass構文を使います。 クラスの定義方法にはクラス宣言文とクラス式があります。

まずは、クラス宣言文によるクラスの定義方法を見ていきます。

クラス宣言文ではclassキーワードを使い、class クラス名{ }のようにクラスの構造を定義できます。

クラスは必ずコンストラクタを持ち、constructorという名前のメソッドとして定義します。 コンストラクタとは、そのクラスからインスタンスを作成する際にインスタンスに関する状態の初期化を行うメソッドです。 constructorメソッドに定義した処理は、クラスをインスタンス化したときに自動的に呼び出されます。

class MyClass {
    constructor() {
        // コンストラクタ関数の処理
        // インスタンス化されるときに自動的に呼び出される
    }
}

もうひとつの定義方法であるクラス式は、クラスを値として定義する方法です。 クラス式ではクラス名を省略できます。これは関数式における匿名関数と同じです。

const MyClass = class MyClass {
    constructor() {}
};

const AnonymousClass = class {
    constructor() {}
};

コンストラクタ関数内で、何も処理がない場合はコンストラクタの記述を省略できます。 省略した場合には自動的に空のコンストラクタが定義されるため、クラスにはコンストラクタが必ず存在します。

class MyClassA {
    constructor() {
        // コンストラクタの処理が必要なら書く
    }
}
// コンストラクタの処理が不要な場合は省略できる
class MyClassB {

}

クラスのインスタンス化

クラスはnew演算子でインスタンスであるオブジェクトを作成できます。 class構文で定義したクラスからインスタンスを作成することをインスタンス化と呼びます。 あるインスタンスが指定したクラスから作成されたものかを判定するにはinstanceof演算子が利用できます。

class MyClass {
}
// `MyClass`をインスタンス化する
const myClass = new MyClass();
// 毎回新しいインスタンス(オブジェクト)を作成する
const myClassAnother = new MyClass();
// それぞれのインスタンスは異なるオブジェクト
console.log(myClass === myClassAnother); // => false
// クラスのインスタンスかどうかは`instanceof`演算子で判定できる
console.log(myClass instanceof MyClass); // => true
console.log(myClassAnother instanceof MyClass); // => true

このままでは何も処理がない空のクラスなので、値を持ったクラスを定義してみましょう。

クラスではインスタンスの初期化処理をコンストラクタ関数で行います。 コンストラクタ関数はnew演算子でインスタンス化する際に自動的に呼び出されます。 コンストラクタ関数内でのthisはこれから新しく作るインスタンスオブジェクトとなります。

次のコードでは、x座標とy座標の値をもつPointというクラスを定義しています。 コンストラクタ関数(constructor)の中でインスタンスオブジェクト(this)のxyプロパティに値を代入して初期化しています。

class Point {
    // コンストラクタ関数の仮引数として`x`と`y`を定義
    constructor(x, y) {
        // コンストラクタ関数における`this`はインスタンスを示すオブジェクト
        // インスタンスの`x`と`y`プロパティにそれぞれ値を設定する
        this.x = x;
        this.y = y;
    }
}

このPointクラスのインスタンスを作成するにはnew演算子を使います。 new演算子には関数呼び出しと同じように引数を渡すことができます。 new演算子の引数はクラスのconstructorメソッド(コンストラクタ関数)の仮引数に渡されます。 そして、コンストラクタのなかではインスタンスオブジェクト(this)の初期化処理を行います。

class Point {
    // 2. コンストラクタ関数の仮引数として`x`には`3`、`y`には`4`が渡る
    constructor(x, y) {
        // 3. インスタンス(`this`)の`x`と`y`プロパティにそれぞれ値を設定する
        this.x = x;
        this.y = y;
        // コンストラクタではreturn文は書かない
    }
}

// 1. コンストラクタを`new`演算子で引数とともに呼び出す
const point = new Point(3, 4);
// 4. `Point`のインスタンスである`point`の`x`と`y`プロパティには初期化された値が入る
console.log(point.x); // => 3
console.log(point.y); // => 4

このようにクラスからインスタンスを作成するには必ずnew演算子を使います。

一方、クラスは通常の関数として呼ぶことができません。 これは、クラスのコンストラクタはインスタンス(this)を初期化する場所であり、通常の関数とは役割が異なるためです。

class MyClass {
    constructor() { }
}
// クラスのコンストラクタ関数として呼び出すことはできない
MyClass(); // => TypeError: class constructors must be invoked with |new|

コンストラクタは初期化処理を書く場所であるため、return文で値を返すべきではありません。 JavaScriptでは、コンストラクタ関数が任意のオブジェクトを返すことが可能ですが、行うべきではありません。 なぜなら、コンストラクタはnew演算子で呼び出し、その評価結果はクラスのインスタンスを期待するのが一般的であるためです。

// 非推奨の例: コンストラクタで値を返すべきではない
class Point {
    constructor(x, y) {
        // `this`の代わりにただのオブジェクトを返せる
        return { x, y };
    }
}

// `new`演算子の結果はコンストラクタ関数が返したただのオブジェクト
const point = new Point(3, 4);
console.log(point); // => { x: 3, y: 4 }
// Pointクラスのインスタンスではない
console.log(point instanceof Point); // => false

[Note] クラス名は大文字で始める

JavaScriptでは慣習としてクラス名は大文字で始まる名前を付けます。 これは、変数名にキャメルケースを使う慣習があるのと同じで、名前自体には特別なルールがあるわけではありません。 クラス名を大文字にしておき、そのインスタンスは小文字で開始すれば名前が被らないという合理的な理由で好まれています。

class Thing {}
const thing = new Thing();

[コラム] class構文と関数でのクラスの違い

ES2015より前はこれらのクラスをclass構文ではなく、関数で表現していました。 その表現方法は人によってさまざまで、これもclass構文という統一した表現が導入された理由の1つです。

次のコードでは、class構文でのクラスを簡略化した関数での1つの実装例です。 この関数でのクラス表現は、継承の仕組みなどは省かれていますが、class構文とよく似ています。

// コンストラクタ関数
const Point = function PointConstructor(x, y) {
    // インスタンスの初期化処理
    this.x = x;
    this.y = y;
};

// `new`演算子でコンストラクタ関数から新しいインスタンスを作成
const point = new Point(3, 4);

大きな違いとして、class構文で定義したクラスは関数として呼び出すことができません。 クラスはnew演算子でインスタンス化して使うものなので、これはクラスの誤用を防ぐ仕様です。 一方、関数でのクラス表現はただの関数なので、当然関数として呼び出せます。

// 関数でのクラス表現
function MyClassLike() {
}
// 関数なので関数として呼び出せる
MyClassLike(); 

// `class`構文でのクラス
class MyClass {
}
// クラスは関数として呼び出すと例外が発生する
MyClass(); // => TypeError: class constructors must be invoked with |new|

このように、class構文で定義したクラスは一種の関数ですが、クラス以外の用途には利用できません。

クラスのプロトタイプメソッドの定義

クラスの動作はメソッドによって定義できます。 constructorメソッドは初期化時に呼ばれる特殊なメソッドですが、class構文ではクラスに対して自由にメソッドを定義できます。 このクラスに定義したメソッドは作成したインスタンスがもつ動作となります。

次のようにclass構文ではクラスに対してメソッドを定義できます。 メソッドの中からクラスのインスタンスを参照するには、constructorメソッドと同じくthisを使います。 このクラスのメソッドにおけるthisは「関数とthis」の章で学んだメソッドと同じくベースオブジェクトを参照します。

class クラス {
    メソッド() {
        // ここでの`this`はベースオブジェクトを参照
    }
}

const インスタンス = new クラス();
// メソッド呼び出しのベースオブジェクト(`this`)は`インスタンス`となる
インスタンス.メソッド();

クラスのプロトタイプメソッド定義では、オブジェクトにおけるメソッドとは異なりkey : valueのように:区切りでメソッドを定義できないことに注意してください。 つまり、次のような書き方は構文エラー(SyntaxError)となります。

// クラスでは次のようにメソッドを定義できない
class クラス {
   // SyntaxError
   メソッド: () => {}
   // SyntaxError
   メソッド: function(){}
}

このようにクラスに対して定義したメソッドは、クラスの各インスタンスから共有されるメソッドとなります。 このインスタンス間で共有されるメソッドのことをプロトタイプメソッドと呼びます。 また、プロトタイプメソッドはインスタンスから呼び出せるメソッドであるためインスタンスメソッドとも呼ばれます。

この書籍では、プロトタイプメソッド(インスタンスメソッド)をクラス#メソッド名のように表記します。

次のコードでは、Counterクラスにincrementメソッド(Counter#incrementメソッド)を定義しています。 Counterクラスのインスタンスはそれぞれ別々の状態(countプロパティ)を持ちます。

class Counter {
    constructor() {
        this.count = 0;
    }
    // `increment`メソッドをクラスに定義する
    increment() {
        // `this`は`Counter`のインスタンスを参照する
        this.count++;
    }
}
const counterA = new Counter();
const counterB = new Counter();
// `counterA.increment()`のベースオブジェクトは`counterA`インスタンス
counterA.increment();
// 各インスタンスのもつプロパティ(状態)は異なる
console.log(counterA.count); // => 1
console.log(counterB.count); // => 0

またincrementメソッドはプロトタイプメソッドとして定義されています。 プロトタイプメソッドは各インスタンス間で共有されます。 そのため、次のように各インスタンスのincrementメソッドの参照先は同じとなっていることが分かります。

class Counter {
    constructor() {
        this.count = 0;
    }
    increment() {
        this.count++;
    }
}
const counterA = new Counter();
const counterB = new Counter();
// 各インスタンスオブジェクトのメソッドは共有されている(同じ関数を参照している)
console.log(counterA.increment === counterB.increment); // => true

プロトタイプメソッドがなぜインスタンス間で共有されているのかは、クラスの継承の仕組みと密接に関係しています。 プロトタイプメソッドの仕組みについては後ほど解説します。

クラスのインスタンスに対してメソッドを定義する

class構文でのメソッド定義はプロトタイプメソッドとなり、インスタンス間で共有されます。

一方、クラスのインスタンスに対して、直接メソッドを定義する方法もあります。 これは、コンストラクタ関数内でインスタンスオブジェクトに対してメソッドを定義するだけです。

次のコードでは、Counterクラスのコンストラクタ関数で、インスタンスオブジェクトにincrementメソッドを定義しています。 コンストラクタ関数内でthisはインスタンスオブジェクトを示すため、thisに対してメソッドを定義しています。

class Counter {
    constructor() {
        this.count = 0;
        this.increment = () => {
            // `this`は`constructor`メソッドにおける`this`(インスタンスオブジェクト)を参照する
            this.count++;
        };
    }
}
const counterA = new Counter();
const counterB = new Counter();
// `counterA.increment()`のベースオブジェクトは`counterA`インスタンス
counterA.increment();
// 各インスタンスのもつプロパティ(状態)は異なる
console.log(counterA.count); // => 1
console.log(counterB.count); // => 0

この方法で定義したincrementメソッドはインスタンスから呼び出せるため、インスタンスメソッドです。 しかし、インスタンスオブジェクトに定義したincrementメソッドはプロトタイプメソッドではありません。 インスタンスオブジェクトのメソッドとプロトタイプメソッドには、いくつか異なる点があります。

プロトタイプメソッドは各インスタンスから共有されているため、各インスタンスからのメソッドの参照先が同じでした。 しかし、インスタンスオブジェクトのメソッドは、コンストラクタで毎回同じ挙動の関数(オブジェクト)を新しく定義しています。 そのため、次のように各インスタンスからのメソッドの参照先も異なります。

class Counter {
    constructor() {
        this.count = 0;
        this.increment = () => {
            this.count++;
        };
    }
}
const counterA = new Counter();
const counterB = new Counter();
// 各インスタンスオブジェクトのメソッドの参照先は異なる
console.log(counterA.increment !== counterB.increment); // => true

また、プロトタイプメソッドとは異なり、インスタンスオブジェクトへのメソッド定義はArrow Functionが利用できます。 Arrow Functionにはthisが静的に決まるという性質があるため、メソッドにおけるthisの参照先をインスタンスに固定できます。 なぜならArrow Functionで定義したincrementメソッドはどのような呼び出し方をしても、必ずconstructorにおけるthisとなるためです。(「Arrow Functionでコールバック関数を扱う」を参照)

"use strict";
class ArrowClass {
    constructor() {
        // コンストラクタでの`this`は常にインスタンス
        this.method = () => {
            // Arrow Functionにおける`this`は静的に決まる
            // そのため`this`は常にインスタンスを参照する
            return this;
        };
    }
}
const instance = new ArrowClass();
const method = instance.method;
// 呼び出し方法(ベースオブジェクト)に依存しないため、`this`がインスタンスを参照する
console.log(method()); // => instance

一方、プロトタイプメソッドにおけるthisはメソッド呼び出し時のベースオブジェクトを参照します。 そのためプロトタイプメソッドは呼び出し方によってthisの参照先が異なります。(「関数とthis」の章の「問題: thisを含むメソッドを変数に代入した場合」を参照)

"use strict";
class PrototypeClass {
    method() {
        // `this`はベースオブジェクトを参照する
        return this;
    };
}
const instance = new PrototypeClass();
const method = instance.method;
// ベースオブジェクトはundefined
method(); // => undefined

このように、インスタンスに対してArrow Functionでメソッドを定義することでthisの参照先を固定化できます。

クラスのアクセッサプロパティの定義

クラスに対してメソッドを定義できますが、メソッドはメソッド名()のように呼び出す必要があります。 クラスでは、プロパティの参照(getter)、プロパティへの代入(setter)時に呼び出される特殊なメソッドを定義できます。 このメソッドはプロパティのように振る舞うためアクセッサプロパティと呼ばれます。

次のコードでは、プロパティの参照(getter)、プロパティへの代入(setter)に対するアクセッサプロパティを定義しています。 アクセッサプロパティはメソッド名(プロパティ名)の前にgetまたはsetをつけるだけです。 getter(get)には仮引数はありませんが、必ず値を返す必要があります。 setter(set)の仮引数にはプロパティへ代入された値が入りますが、値を返す必要はありません。

class クラス {
    // getter
    get プロパティ名() {
        return 値;
    }
    // setter
    set プロパティ名(仮引数) {
        // setterの処理
    }
}
const インスタンス = new クラス();
インスタンス.プロパティ名; // getterが呼び出される
インスタンス.プロパティ名 = 値; // setterが呼び出される

次のコードでは、NumberWrapperクラスのvalueプロパティをアクセッサプロパティとして定義しています。 valueプロパティへアクセスした際にそれぞれ定義したgetterとsetterが呼ばれていることが分かります。 このアクセッサプロパティで実際に読み書きされているのは、NumberWrapperインスタンスの_valueプロパティとなります。

class NumberWrapper {
    constructor(value) {
        this._value = value;
    }
    // `_value`プロパティの値を返すgetter
    get value() {
        console.log("getter");
        return this._value;
    }
    // `_value`プロパティに値を代入するsetter
    set value(newValue) {
        console.log("setter");
        this._value = newValue;
    }
}

const numberWrapper = new NumberWrapper(1);
// "getter"とコンソールに表示される
console.log(numberWrapper.value); // => 1
// "setter"とコンソールに表示される
numberWrapper.value = 42;
// "getter"とコンソールに表示される
console.log(numberWrapper.value); // => 42

[コラム] プライベートプロパティ

NumberWrapper#valueのアクセッサプロパティで実際に読み書きしているのは、_valueプロパティです。 このように、外から直接読み書きしてほしくないプロパティを_(アンダーバー)で開始するのはただの習慣であるため、構文としての意味はありません。

現時点(ECMAScript 2018)では、外から原理的に参照できないプライベートプロパティ(hard private)を定義する構文はありません。 しかし、現時点でもWeakSetなどを使うことで擬似的なプライベートプロパティを実現できます。 WeakSetについては「Map/Set」の章で解説します。

Array#lengthをアクセッサプロパティで再現する

getterやsetterを利用しないと実現が難しいものとしてArray#lengthプロパティがあります。 Array#lengthプロパティへ値を代入すると、そのインデックス以降の要素は自動的に削除される仕様があります。

次のコードでは、配列の要素数(lengthプロパティ)を小さくすると配列の要素が削除されています。

const array = [1, 2, 3, 4, 5];
// 要素数を減らすと、インデックス以降の要素が削除される
array.length = 2;
console.log(array.join(", ")); // => "1, 2"
// 要素数だけを増やしても、配列の中身は空要素が増えるだけ
array.length = 5;
console.log(array.join(", ")); // => "1, 2, , , "

このlengthプロパティの挙動を再現するArrayLikeクラスを実装してみます。 Array#lengthプロパティは、lengthプロパティへ値を代入した際に次のようなことを行っています。

  • 現在要素数より小さな要素数が指定された場合、その要素数を変更し、配列の末尾の要素を削除する
  • 現在要素数より大きな要素数が指定された場合、その要素数だけを変更し、配列の実際の要素はそのままにする

つまり、ArrayLike#lengthのsetterで要素の追加や削除を実装することで、配列のようなlengthプロパティを実装できます。

/**
 * 配列のようなlengthを持つクラス
 */
class ArrayLike {
    constructor(items = []) {
        this._items = items;
    }

    get items() {
        return this._items;
    }

    get length() {
        return this._items.length;
    }

    set length(newLength) {
        const currentItemLength = this.items.length;
        // 現在要素数より小さな`newLength`が指定された場合、指定した要素数となるように末尾を削除する
        if (newLength < currentItemLength) {
            this._items = this.items.slice(0, newLength);
        } else if (newLength > currentItemLength) {
            // 現在要素数より大きな`newLength`が指定された場合、指定した要素数となるように末尾に空要素を追加する
            this._items = this.items.concat(new Array(newLength - currentItemLength));
        }
    }
}

const arrayLike = new ArrayLike([1, 2, 3, 4, 5]);
// 要素数を減らすとインデックス以降の要素が削除される
arrayLike.length = 2;
console.log(arrayLike.items.join(", ")); // => "1, 2"
// 要素数を増やすと末尾に空要素が追加される
arrayLike.length = 5;
console.log(arrayLike.items.join(", ")); // => "1, 2, , , "

このようにアクセッサプロパティは、プロパティのようでありながら実際にアクセスした際には他のプロパティと連動する動作を実現できます。

静的メソッド

インスタンスメソッドは、クラスをインスタンス化して利用します。 一方、クラスをインスタンス化せずに利用できる静的メソッド(クラスメソッド)もあります。

静的メソッドの定義方法はメソッド名の前に、staticをつけるだけです。

class クラス {
    static メソッド() {
        // 静的メソッドの処理
    }
}
// 静的メソッドの呼び出し
クラス.メソッド();

次のコードでは、配列をラップするArrayWrapperというクラスを定義しています。 ArrayWrapperはコンストラクタの引数として配列を受け取り初期化しています。 このクラスに配列ではなく要素そのものを引数に受け取りインスタンス化できるArrayWrapper.ofという静的メソッドを定義しています。

class ArrayWrapper {
    constructor(array = []) {
        this.array = array;
    }

    // rest parametersとして要素を受け付ける
    static of(...items) {
        return new ArrayWrapper(items);
    }

    get length() {
        return this.array.length;
    }
}

// 配列を引数として渡している
const arrayWrapperA = new ArrayWrapper([1, 2, 3]);
// 要素を引数として渡している
const arrayWrapperB = ArrayWrapper.of(1, 2, 3);
console.log(arrayWrapperA.length); // => 3
console.log(arrayWrapperB.length); // => 3

クラスの静的メソッドにおけるthisは、そのクラス自身を参照します。 そのため、先ほどのコードはnew ArrayWrapperの代わりにnew thisと書くこともできます。

class ArrayWrapper {
    constructor(array = []) {
        this.array = array;
    }

    static of(...items) {
        // `this`は`ArrayWrapper`を参照する
        return new this(items);
    }

    get length() {
        return this.array.length;
    }
}

const arrayWrapper = ArrayWrapper.of(1, 2, 3);
console.log(arrayWrapper.length); // => 3

このように静的メソッドでのthisはクラス自身を参照するため、クラスのインスタンスは参照できません。 そのため静的メソッドは、クラスのインスタンスを作成する処理やクラスに関係する処理を書くために利用されます。

2種類のインスタンスメソッドの定義

クラスでは、2種類のインスタンスメソッドの定義方法があります。 class構文を使ったインスタンス間で共有されるプロトタイプメソッドの定義と、 インスタンスオブジェクトに対するメソッドの定義です。

これらの2つの方法を同時に使い、1つのクラスに同じ名前でメソッドを2つ定義した場合はどうなるでしょうか?

次のコードでは、ConflictClassクラスにプロトタイプメソッドとインスタンスに対して同じmethodという名前のメソッドを定義しています。

class ConflictClass {
    constructor() {
        // インスタンスオブジェクトに`method`を定義
        this.method = () => {
            console.log("インスタンスオブジェクトのメソッド");
        };
    }

    // クラスのプロトタイプメソッドとして`method`を定義
    method() {
        console.log("プロトタイプのメソッド");
    }
}

const conflict = new ConflictClass();
conflict.method(); // どちらの`method`が呼び出される?

結論から述べるとこの場合はインスタンスオブジェクトに定義したmethodが呼び出されます。 このとき、インスタンスのmethodプロパティをdelete演算子で削除すると、今度はプロトタイプメソッドのmethodが呼び出されます。

class ConflictClass {
    constructor() {
        this.method = () => {
            console.log("インスタンスオブジェクトのメソッド");
        };
    }

    method() {
        console.log("プロトタイプメソッド");
    }
}

const conflict = new ConflictClass();
conflict.method(); // "インスタンスオブジェクトのメソッド"
// インスタンスの`method`プロパティを削除
delete conflict.method;
conflict.method(); // "プロトタイプのメソッド"

この実行結果から次のことが分かります。

  • プロトタイプメソッドとインスタンスオブジェクトのメソッドは上書きされずにどちらも定義されている
  • インスタンスオブジェクトのメソッドがプロトタイプオブジェクトのメソッドよりも優先して呼ばれている

どちらも注意深く意識しないと気づきにくいですが、この挙動はJavaScriptの重要な仕組みであるため理解することは重要です。

この挙動はプロトタイプオブジェクトと呼ばれる特殊なオブジェクトとプロトタイプチェーンと呼ばれる仕組みで成り立っています。 どちらもプロトタイプとついていることからも分かるように、2つで1組のような仕組みです。

このセクションでは、プロトタイプオブジェクトプロトタイプチェーンとはどのような仕組みなのかを見ていきます。

プロトタイプオブジェクト

プロトタイプメソッドインスタンスオブジェクトのメソッドを同時に定義しても、互いのメソッドは上書きされるわけでありません。 なぜなら、プロトタイプメソッドはプロトタイプオブジェクトへ、インスタンスオブジェクトのメソッドはインスタンスオブジェクトへそれぞれ定義されるためです。

プロトタイプオブジェクトとは、JavaScriptの関数オブジェクトのprototypeプロパティに自動的に作成される特殊なオブジェクトです。 クラスも一種の関数オブジェクトであるため、自動的にprototypeプロパティにプロトタイプオブジェクトが作成されています。

次のコードでは、関数やクラス自身のprototypeプロパティに、プロトタイプオブジェクトが自動的に作成されていることが分かります。

function fn() {
}
// `prototype`プロパティにプロトタイプオブジェクトが存在する
console.log(typeof fn.prototype === "object"); // => true

class MyClass {
}
// `prototype`プロパティにプロトタイプオブジェクトが存在する
console.log(typeof MyClass.prototype === "object"); // => true

class構文のメソッド定義は、このプロトタイプオブジェクトのプロパティとして定義されます。

次のコードでは、クラスのメソッドがプロトタイプオブジェクトに定義されていることを確認できます。 また、クラスにはconstructorメソッド(コンストラクタ)が必ず定義されます。 このconstructorメソッドもプロトタイプオブジェクトに定義されており、このconstructorプロパティはクラス自身を参照します。

class MyClass {
    method() { }
}

console.log(typeof MyClass.prototype.method === "function"); // => true
// クラス#constructorはクラス自身を参照する
console.log(MyClass.prototype.constructor === MyClass); // => true

このように、プロトタイプメソッドはプロトタイプオブジェクトに定義され、インスタンスオブジェクトのメソッドとは異なるオブジェクトに定義されています。そのため、それぞれの方法でメソッドを定義しても、上書きされずにそれぞれ異なるオブジェクトへ定義されます。

プロトタイプチェーン

class構文で定義したプロトタイプメソッドはプロトタイプオブジェクトに定義されます。 しかし、インスタンス(オブジェクト)にはメソッドが定義されていないのに、インスタンスからクラスのプロトタイプメソッドを呼び出すことができます。

class MyClass {
    method() {
        console.log("プロトタイプのメソッド");
    }
}
const instance = new MyClass();
instance.method(); // "プロトタイプのメソッド"

このインスタンスからプロトタイプメソッドを呼び出せるのはプロトタイプチェーンと呼ばれる仕組みによるものです。 プロトタイプチェーンは2つの処理から成り立ちます。

  • インスタンス作成時に、インスタンスの[[Prototype]]内部プロパティへプロトタイプオブジェクトの参照を保存する処理
  • インスタンスからプロパティ(またはメソッド)を参照する時に、[[Prototype]]内部プロパティまで探索する処理

インスタンス作成とプロトタイプチェーン

クラスからnew演算子によってインスタンスを作成する際に、インスタンスにはクラスのプロトタイプオブジェクトの参照が保存されます。 このとき、インスタンスからクラスのプロトタイプオブジェクトへの参照は、インスタンスオブジェクトの[[Prototype]]という内部プロパティに保存されます。

[[Prototype]]内部プロパティはECMAScriptの仕様で定められた内部的な表現であるため、通常のプロパティのようにはアクセスできません。 しかし、Object.getPrototypeOf(オブジェクト)メソッドでオブジェクト[[Prototype]]内部プロパティを読み取れます。

次のコードでは、インスタンスの[[Prototype]]内部プロパティを取得しています。 その取得した結果がクラスのプロトタイプオブジェクトを参照していることを確認できます。

class MyClass {
    method() {
        console.log("プロトタイプのメソッド");
    }
}
const instance = new MyClass();
// `instance`の`[[Prototype]]`内部プロパティは`MyClass.prototype`と一致する
const MyClassPrototype = Object.getPrototypeOf(instance);
console.log(MyClassPrototype === MyClass.prototype); // => true

ここで重要なのは、インスタンスはどのクラスから作られたかやそのクラスのプロトタイプオブジェクトを知っているということです。


Note: [[Prototype]]内部プロパティを読み書きする

Object.getPrototypeOf(オブジェクト)オブジェクト[[Prototype]]を読み取ることができます。 一方、Object.setPrototypeOf(オブジェクト, プロトタイプオブジェクト)オブジェクト[[Prototype]]プロトタイプオブジェクトを設定できます。 また、[[Prototype]]内部プロパティを通常のプロパティのように扱える__proto__という特殊なアクセッサプロパティが存在します。

しかし、これらの[[Prototype]]内部プロパティを直接読み書きすることは通常の用途では行いません。 また、既存のビルトインオブジェクトの動作なども変更できるため、不用意に扱うべきではないでしょう。


プロパティの参照とプロトタイプチェーン

プロトタイプオブジェクトのプロパティがどのようにインスタンスから参照されるかを見ていきます。

オブジェクトのプロパティを参照するときに、オブジェクト自身がプロパティを持っていない場合でも、そこで探索が終わるわけではありません。 オブジェクトの[[Prototype]]内部プロパティの参照先であるプロトタイプオブジェクトに対しても探索を続けます。 これは、スコープに指定した識別子の変数がなかった場合に外側のスコープへと探索するスコープチェーンと良く似た仕組みです。

つまり、オブジェクトがプロパティを探索するときは次のような順番で、それぞれのオブジェクトを調べます。 すべてのオブジェクトにおいて見つからなかった場合の結果はundefinedを返します。

  1. instanceオブジェクト自身
  2. instanceオブジェクトの[[Prototype]]の参照先(プロトタイプオブジェクト)
  3. どこにもなかった場合はundefined

次のコードでは、インスタンスオブジェクト自身はmethodプロパティを持っていません。 そのため、実際参照してるのはクラスのプロトタイプオブジェクトのmethodプロパティです。

class MyClass {
    method() {
        console.log("プロトタイプのメソッド");
    }
}
const instance = new MyClass();
// インスタンスには`method`プロパティがないため、プロトタイプオブジェクトの`method`が参照される
instance.method(); // "プロトタイプのメソッド"
// `instance.method`の参照はプロトタイプオブジェクトの`method`と一致する
const Prototype = Object.getPrototypeOf(instance);
console.log(instance.method === Prototype.method); // => true

このように、インスタンス(オブジェクト)にmethodが定義されていなくても、クラスのプロトタイプオブジェクトのmethodを呼び出すことができます。 このプロパティを参照する際に、オブジェクト自身から[[Prototype]]内部プロパティへと順番に探す仕組みのことをプロトタイプチェーンと呼びます。

プロトタイプチェーンの仕組みを擬似的なコードとして表現すると次のような動きをしています。

// プロトタイプチェーンの擬似的な動作の擬似的なコード
class MyClass {
    method() {
        console.log("プロトタイプのメソッド");
    }
}
const instance = new MyClass();
// `instance.method()`を実行する場合
// 次のような呼び出し処理が行われている
// インスタンス自身が`method`プロパティを持っている場合
if (instance.hasOwnProperty("method")) {
    instance.method();
} else {
    // インスタンスの`[[Prototype]]`の参照先(`MyClass`のプロトタイプオブジェクト)を取り出す
    const prototypeObject = Object.getPrototypeOf(instance);
    // プロトタイプオブジェクトが`method`プロパティを持っている場合
    if (prototypeObject.hasOwnProperty("method")) {
        // `this`はインスタンス自身を指定して呼び出す
        prototypeObject.method.call(instance);
    }
}

プロトタイプチェーンの仕組みによって、プロトタイプオブジェクトに定義したプロトタイプメソッドがインスタンスから呼び出すことができています。

普段は、プロトタイプオブジェクトやプロトタイプチェーンといった仕組みを意識する必要はありません。 class構文はこのようなプロトタイプを意識せずにクラスを利用できるように導入された構文です。 しかし、プロトタイプベースである言語のJavaScriptではクラスをこのようなプロトタイプを使い表現していることは知っておくとよいでしょう。

継承

extendsキーワードを使うことで既存のクラスを継承できます。 継承とは、クラスの構造機能を引き継いだ新しいクラスを定義することです。

継承したクラスの定義

extendsキーワードを使って既存のクラスを継承した新しいクラスを定義してみます。 class構文の右辺にextendsキーワードで継承元となる親クラス(基底クラス)を指定することで、 親クラスを継承した子クラス(派生クラス)を定義できます。

class 子クラス extends 親クラス {

}

次のコードでは、Parentクラスを継承したChildクラスを定義しています。 子クラスであるChildクラスのインスタンス化は通常のクラスと同じくnew演算子を使って行います。

class Parent {

}
class Child extends Parent {

}
const instance = new Child();

super

extendsを使って定義した子クラスから親クラスを参照するにはsuperというキーワードを利用します。 もっともシンプルなsuperを使う例としてコンストラクタの処理を見ていきます。

class構文でも紹介しましたが、クラスは必ずconstructorメソッド(コンストラクタ)をもちます。 これは、継承した子クラスでも同じです。

次のコードでは、Parentクラスを継承したChildクラスのコンストラクタで、super()を呼び出しています。 super()は子クラスから親クラスのconstructorメソッドを呼び出します。

// 親クラス
class Parent {
    constructor(...args) {
        console.log("Parentコンストラクタの処理", ...args);
    }
}
// Parentを継承したChildクラスの定義
class Child extends Parent {
    constructor(...args) {
        // Parentのコンストラクタ処理を呼びだす
        super(...args);
        console.log("Childコンストラクタの処理", ...args);
    }
}
const child = new Child("引数1", "引数2");
// "Parentコンストラクタの処理", "引数1", "引数2"
// "Childコンストラクタの処理", "引数1", "引数2"

class構文でのクラス定義では、constructorメソッド(コンストラクタ)で何も処理しない場合は省略できることを紹介しました。 これは、継承した子クラスでも同じです。

次のコードのChildクラスのコンストラクタでは、何も処理を行っていません。 そのため、Childクラスのconstructorメソッドの定義を省略できます。

class Parent {}
class Child extends Parent {}

このように子クラスでconstructorを省略した場合は次のように書いた場合と同じ意味になります。 constructorメソッドの引数をすべて受け取り、そのままsuperへ引数の順番を維持して渡します。

class Parent {}
class Child extends Parent {
    constructor(...args) {
        super(...args); // 親クラスに引数をそのまま渡す
    }
}

コンストラクタの処理順は親クラスから子クラスへ

コンストラクタの処理順は、親クラスから子クラスへと順番が決まっています。

class構文ではかならず親クラスのコンストラクタ処理(super()の呼び出し)を先に行い、その次に子クラスのコンストラクタ処理を行います。 子クラスのコンストラクタでは、thisを触る前にsuper()で親クラスのコンストラクタ処理を呼び出さないとSyntaxErrorとなるためです。

次のコードでは、ParentChildでそれぞれインスタンス(this)のnameプロパティに値を書き込んでいます。 子クラスでは先にsuper()を呼び出してからでないとthisを参照できません。 そのため、コンストラクタの処理順はParentからChildという順番に限定されます。

class Parent {
    constructor() {
        this.name = "Parent";
    }
}
class Child extends Parent {
    constructor() {
        // 子クラスでは`super()`を`this`に触る前に呼び出さなければならない
        super();
        // 子クラスのコンストラクタ処理
        // 親クラスで書き込まれた`name`は上書きされる
        this.name = "Child";
    }
}
const parent = new Parent();
console.log(parent.name); // => "Parent"
const child = new Child();
console.log(child.name); // => "Child"

プロトタイプ継承

次のコードではextendsキーワードを使いParentクラスを継承したChildクラスを定義しています。 Parentクラスではmethodを定義しているため、これを継承しているChildクラスのインスタンスからも呼び出せます。

class Parent {
    method() {
        console.log("Parent#method");
    }
}
// `Parent`を継承した`Child`を定義
class Child extends Parent {
    // methodの定義はない
}
// `Child`のインスタンスは`Parent`のプロトタイプメソッドを継承している
const instance = new Child();
instance.method(); // "Parent#method"

このように、子クラスのインスタンスから親クラスのプロトタイプメソッドもプロトタイプチェーンの仕組みによって呼びだせます。

extendsによって継承した場合、子クラスのプロトタイプオブジェクトの[[Prototype]]内部プロパティには親クラスのプロトタイプオブジェクトが設定されます。 このコードでは、Child.prototypeオブジェクトの[[Prototype]]内部プロパティにはParent.prototypeが設定されます。

これにより、プロパティを参照する場合には次のような順番でオブジェクトを探索しています。

  1. instanceオブジェクト自身
  2. Child.prototypeinstanceオブジェクトの[[Prototype]]の参照先)
  3. Parent.prototypeChild.prototypeオブジェクトの[[Prototype]]の参照先)

このプロトタイプチェーンの仕組みより、methodプロパティはParent.prototypeオブジェクトに定義されたものを参照します。

このようにJavaScriptではclass構文とextendsキーワードを使うことでクラスの機能を継承できます。 class構文ではプロトタイプオブジェクトと参照する仕組みによって継承が行われています。 そのため、この継承の仕組みをプロトタイプ継承と呼びます。

静的メソッドの継承

インスタンスはクラスのプロトタイプオブジェクトとの間にプロトタイプチェーンがあります。 クラス自身(クラスのコンストラクタ)も親クラス自身(親クラスのコンストラクタ)との間にプロトタイプチェーンがあります。

これは簡単にいえば、静的メソッドも継承されるということです。

class Parent {
    static hello() {
        return "Hello";
    }
}
class Child extends Parent {}
console.log(Child.hello()); // => "Hello"

extendsによって継承した場合、子クラスのコンストラクタの[[Prototype]]内部プロパティには親クラスのコンストラクタが設定されます。 このコードでは、Childコンストラクタの[[Prototype]]内部プロパティにParentコンストラクタが設定されます。

つまり、先ほどのコードではChild.helloプロパティを参照した場合には、次のような順番でオブジェクトを探索しています。

  1. Childコンストラクタ
  2. Parentコンストラクタ(Childコンストラクタの[[Prototype]]の参照先)

クラスのコンストラクタ同士にもプロトタイプチェーンの仕組みがあるため、子クラスは親クラスの静的メソッドを呼び出せます。

superプロパティ

子クラスから親クラスのコンストラクタ処理を呼び出すにはsuper()を使います。 同じように、子クラスのプロトタイプメソッドからは、super.プロパティ名で親クラスのプロトタイプメソッドを参照できます。

次のコードでは、Child#methodの中でsuper.method()と書くことでParent#methodを呼び出しています。 このように、子クラスから継承元の親クラスのプロトタイプメソッドはsuper.プロパティ名で参照できます。

class Parent {
    method() {
        console.log("Parent#method");
    }
}
class Child extends Parent {
    method() {
        console.log("Child#method");
        // `this.method()`だと自分(`this`)のmethodを呼び出して無限ループする
        // そのため明示的に`super.method()`とParent#methodを呼びだす
        super.method();
    }
}
const child = new Child();
child.method(); 
// コンソールには次のように出力される
// "Child#method"
// "Parent#method"

プロトタイプチェーンでは、インスタンスからクラス、さらに親のクラスと継承関係をさかのぼるようにメソッドを探索すると紹介しました。 このコードではChild#methodが定義されているため、child.methodChild#methodを呼び出します。 そしてChild#methodsuper.methodを呼び出しているため、Parent#methodが呼び出されます。

クラスの静的メソッド同士も同じようにsuper.method()と書くことで呼び出せます。 次のコードでは、Parentを継承したChildから親クラスの静的メソッドを呼び出しています。

class Parent {
    static method() {
        console.log("Parent.method");
    }
}
class Child extends Parent {
    static method() {
        console.log("Child.method");
        // `super.method()`で`Parent.method`を呼びだす
        super.method();
    }
}
Child.method(); 
// コンソールには次のように出力される
// "Child.method"
// "Parent.method"

継承の判定

あるクラスが指定したクラスをプロトタイプ継承しているかはinstanceof演算子を使って判定できます。

次のコードでは、ChildのインスタンスはChildクラスとParentクラスを継承したオブジェクトであることを確認しています。

class Parent {}
class Child extends Parent {}

const parent = new Parent();
const child = new Child();
// `Parent`のインスタンスは`Parent`のみを継承したインスタンス
console.log(parent instanceof Parent); // => true
console.log(parent instanceof Child); // => false
// `Child`のインスタンスは`Child`と`Parent`を継承したインスタンス
console.log(child instanceof Parent); // => true
console.log(child instanceof Child); // => true

より具体的な継承の使い方については「ユースケース:Todoアプリ」の章で見ていきます。

ビルトインオブジェクトの継承

ここまで自身が定義したクラスを継承してきましたが、ビルトインオブジェクトのコンストラクタも継承できます。 ビルトインオブジェクトにはArrayStringObjectNumberErrorDateなどのコンストラクタがあります。 class構文ではこれらのビルトインオブジェクトを継承できます。

次のコードでは、ビルトインオブジェクトであるArrayを継承して独自のメソッドを加えたMyArrayクラスを定義しています。 継承したMyArrayArrayの性質であるメソッドや状態管理についての仕組みを継承しています。 継承した性質に加えて、MyArray#firstMyArray#lastといったアクセッサプロパティを追加しています。

class MyArray extends Array {
    get first() {
        if (this.length === 0) {
            return undefined;
        } else {
            return this[0];
        }
    }

    get last() {
        if (this.length === 0) {
            return undefined;
        } else {
            return this[this.length - 1];
        }
    }
}

// Arrayを継承しているのでArray.fromも継承している
// Array.fromはIterableなオブジェクトから配列インスタンスを作成する
const array = MyArray.from([1, 2, 3, 4, 5]);
console.log(array.length); // => 5
console.log(array.first); // => 1
console.log(array.last); // => 5

Arrayを継承したMyArrayは、Arrayが元々もつlengthプロパティやArray.fromメソッドなどを継承し利用できます。

1. class構文でのみしか実現できない機能はなく、読みやすさや分かりやさのために導入された構文という側面もあるため、JavaScriptのclass構文は糖衣構文(シンタックスシュガー)と呼ばれることがあります。