クラス

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

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

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

JavaScriptでは関数で学んだことの多くはクラスでもそのまま適応されます。 また、関数の定義方法として関数宣言文と関数式があるように、クラスにもクラス宣言文とクラス式があります。 この章では、class構文でのクラスの定義や継承、クラスの性質について学んでいきます。

クラスの定義

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

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

クラス宣言文ではclassキーワードを使い、class クラス名{ }のようにクラスの構造を定義できます。 クラスは必ずコンストラクタを持ち、constructorという名前のメソッドとして定義します。 コンストラクタとは、そのクラスからインスタンスを作成する際にインスタンスに関する状態の初期化を行うメソッドです。

class MyClass {
    constructor() {
        // コンストラクタ関数の処理
    }
}

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

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

const AnnonymousClass = 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演算子でインスタンス化して使うものなので、これはクラスの誤用を防ぐ仕様です。 一方、関数でのクラス表現はただの関数なので、当然関数として呼び出せます。

// `class`構文でのクラス
class MyClass {
}
// 関数でのクラス表現
function MyClassLike() {
}
// 関数なので関数として呼び出せる
MyClassLike(); 
// クラスは関数として呼び出すと例外が発生する
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); // => false

また、プロトタイプメソッドとはことなり、インスタンスオブジェクトへのメソッド定義は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;
// ベースオブジェクトに依存しない
method(); // => instance

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

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

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

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

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

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

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

class NumberValue {
    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 number = new NumberValue(1);
// "getter"とコンソールに表示される
console.log(number.value); // => 1
// "setter"とコンソールに表示される
number.value = 42;
// "getter"とコンソールに表示される
console.log(number.value); // => 42

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

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

現時点(ECMAScript 2018)外から原理的に見ることができないプライベートプロパティ(hard private)を定義する構文はありません。 また、現時点でもWeakSetなどを使うことで擬似的なプライベートプロパティ(soft private)を実現できます。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(arrayWrapperA.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つのクラスに同じ名前でメソッドを定義した場合はどうなるでしょうか? 次の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プロパティはクラス自身を参照します。

class MyClass {
    method() { }
}

console.log(typeof MyClass.prototype.method === "function"); // => true
console.log(MyClass.prototype.constructor === MyClass); // => true

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

プロトタイプチェーン

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

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

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

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

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

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

[[Prototype]]内部プロパティは仕様上定められた内部的な表現であるため、通常のプロパティのようにはアクセスできません。 しかし、Object.getPrototypeOf(object)メソッドでobject[[Prototype]]内部プロパティを読み取れます。 次のコードでは、インスタンスの[[Prototype]]内部プロパティがクラスのプロトタイプオブジェクトを参照していることを確認できます。

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

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


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

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

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


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

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

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

  1. instanceオブジェクト自身
  2. instanceオブジェクトの[[Prototype]]の参照先(プロトタイプオブジェクト)

次のコードでは、インスタンスオブジェクト自身は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メソッドを呼び出します。(super()は子クラスのコンストラクタ以外では書くことができません)

// 親クラス
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を省略した場合は次のように書いた場合と同じ意味になります。

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メソッドなどを継承し利用できます。

糖衣構文. class構文でのみしか実現できない機能はなく、読みやすさや分かりやさのために導入された構文という側面もあるためJavaScriptのclass構文は糖衣構文と呼ばれることがあります。