クラス

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

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

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

また、関数の定義方法として関数宣言文と関数式があるように、クラスにもクラス宣言文とクラス式があります。 このように関数とクラスは似ている部分が多いです。

この章では、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文で任意のオブジェクトを返すことが可能ですが、行うべきではありません。 なぜなら、クラスをnew演算子で呼び出し、その評価結果はクラスのインスタンスを期待するのが一般的であるためです。

次のコードのようにコンストラクタで返した値が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つです。

次のコードは、関数でクラスを実装した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クラスのインスタンスは、それぞれ別々の状態(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メソッドはプロトタイプメソッドとして定義されています。 プロトタイプメソッドは各インスタンス間(counterAcounterB)で共有されます。 そのため、次のように各インスタンスの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 クラス {
    メソッド() {
        // このメソッドはプロトタイプメソッドとして定義される
    }
}

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

クラスに対してメソッドを定義できますが、メソッドはインスタンス名.メソッド名()のように呼び出す必要があります。 クラスでは、プロパティの参照(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 2022から、外から直接読み書きしてほしくないプライベートなプロパティを定義するPrivateクラスフィールド構文が追加されました。 Privateクラスフィールド構文では#(ハッシュ)記号をプロパティ名の前につけます。 そのため、外から直接読み書きしてほしくないプロパティを_からはじめるという慣習は、Privateクラスフィールド構文の利用が進むにつれて使われなくなっていくと考えています。

Privateクラスフィールド構文については、この後に解説します。

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

getterやsetterを利用しないと実現が難しいものとして、Array.prototype.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, , , "

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

[ES2022] Publicクラスフィールド

クラスでは、constructorメソッドの中でクラスの状態であるインスタンスのプロパティを初期化することを紹介しました。 先ほども紹介したCounterクラスでは、constructorメソッドの中でcountプロパティの初期値を0として定義しています。

class Counter {
    constructor() {
        this.count = 0;
    }
    increment() {
        this.count++;
    }
}

このCounterではnew演算子で何も引数を渡すことなく初期化するため、constructorメソッドには仮引数を定義していません。 このような場合でも、constructorメソッドを書かないとプロパティの初期化ができないためわずらわしいという問題がありました。

ES2022で、クラスのインスタンスが持つプロパティの初期化をわかりやすく宣言的にする構文として、クラスフィールド構文が追加されました。

クラスフィールドは、クラスのインスタンスが持つプロパティを定義する次のような構文です。

class クラス {
    プロパティ名 = プロパティの初期値;
}

クラスフィールドを使って先ほどのCounterクラスを書き直してみると次のようになります。 countプロパティをクラスフィールドとして定義して、その初期値は0としています。

class Counter {
    count = 0;
    increment() {
        this.count++;
    }
}
const counter = new Counter();
counter.increment();
console.log(counter.count); // => 1

クラスフィールドで定義するのは、クラスのインスタンスが持つプロパティです。 そのため、constructorメソッドの中でthis.count = 0のように定義した場合と結果的にはほとんど同じ意味となります。 クラスフィールドで定義したプロパティは、クラス内から他のプロパティと同じようにthis.プロパティ名で参照できます。

クラスフィールドはconstructorメソッドでの初期化と併用が可能です。 次のコードでは、クラスフィールドとconstructorメソッドでそれぞれインスタンスのプロパティを定義しています。

// 別々のプロパティ名がそれぞれ定義される
class MyClass {
    publicField = 1;
    constructor(arg) {
        this.property = arg;
    }
}
const myClass = new MyClass(2);
console.log(myClass.publicField); // => 1
console.log(myClass.property); // => 2

また、クラスフィールドでの初期化処理が行われ、そのあとconstructorでのプロパティの定義という処理順となります。 そのため、同じプロパティ名への定義がある場合は、constructorメソッド内での定義でプロパティは上書きされます。

// 同じプロパティ名の場合は、constructorでの代入が後となる
class OwnClass {
    publicField = 1;
    constructor(arg) {
        this.publicField = arg;
    }
}
const ownClass = new OwnClass(2);
console.log(ownClass.publicField); // => 2

このpublicFieldプロパティのように、クラスの外からアクセスできるプロパティを定義するクラスフィールドをPublicクラスフィールドと呼びます。

クラスフィールドを使ってプロパティの存在を宣言する

クラスフィールドでは、プロパティの初期値は省略可能となっています。 そのため、次のように初期値を省略したPublicクラスフィールドも定義できます。

class MyClass {
    // myPropertyはundefinedで初期化される
    myProperty;
}

このときのmyPropertyundefinedで初期化されます。 この初期値を省略したクラスフィールドの定義は、クラスのインスタンスが持つプロパティを明示するために利用できます。

次のLoaderクラスは、loadメソッドを呼び出すまでは、loadedContentプロパティの値はundefinedです。 クラスフィールドを使えば、Loaderクラスのインスタンスは、loadedContentというプロパティを持っていることを宣言的に表現できます。

class Loader {
    loadedContent;
    load() {
        this.loadedContent = "読み込んだコンテンツ内容";
    }
}

JavaScriptでは、オブジェクトのプロパティは初期化時に存在していなくても、後から代入すれば作成できてしまいます。 そのため、次のようにLoaderクラスを実装しても意味は同じです。

class Loader {
    load() {
        this.loadedContent = "読み込んだコンテンツ内容";
    }
}

しかし、このように実装してしまうとLoaderクラスを利用する側は、loadedContentプロパティの存在をloadメソッドの中まで読まないとわからないという問題があります。 これに対して、クラスフィールドを使って「LoaderクラスはloadedContentというプロパティを持っている」ということを宣言的に表現できます。 宣言的にプロパティを定義することで、エディターでのコード補完が可能になったり、コードを読む人に優しいというメリットがあります。

クラスフィールドでのthisはクラスのインスタンスを示す

クラスフィールドの初期値には任意の式が書け、thisも利用できます。 クラスフィールドでのthisは、そのクラスのインスタンスを参照します。

次のコードでは、upフィールドの初期値にincrementメソッドを指定しています。 JavaScriptでは関数も値として扱えるため、upメソッドを呼び出すとincrementメソッドが呼び出されます。

class Counter {
    count = 0;
    // upはincrementメソッドを参照している
    up = this.increment;
    increment() {
        this.count++;
    }
}
const counter = new Counter();
counter.up(); // 結果的にはincrementメソッドが呼び出される
console.log(counter.count); // => 1

クラスフィールドでのthisは、Arrow Functionと組み合わせると強力です。

次のコードでは、upメソッドをArrow Functionとして定義し、関数内ではthis.incrementメソッドを呼び出しています。 Arrow Functionで定義した関数におけるthisは、どのような呼び出し方をしても変化しません(「Arrow Functionでコールバック関数を扱う」を参照)。 そのため、upメソッドはどのような呼び方をした場合でもthisがクラスのインスタンスとなるため、確実にincrementメソッドを呼び出せます。

class Counter {
    count = 0;
    // クラスフィールドでの`this`はクラスのインスタンスとなる
    // upメソッドは、クラスのインスタンスに定義される
    up = () => {
        this.increment();
    };
    increment() {
        this.count++;
    }
}
const counter = new Counter();
// Arrow Functionなので、thisはクラスのインスタンスに固定されている
const up = counter.up;
up();
console.log(counter.count); // => 1
// 通常のメソッド定義では、`this`が`undefined`となってしまうため例外が発生する
const increment = counter.increment;
increment(); // Error: Uncaught TypeError: this is undefined

[コラム] クラスフィールドとインスタンスのプロパティの違い

クラスフィールドで定義したプロパティやメソッドは、クラスのインスタンスにプロパティとして定義されます。 そのため、クラスフィールドは、constructorの中でthisに対してプロパティを追加するのと意味的にはほぼ同じで、見た目がわかりやすくなった構文と捉えることができます。

class ExampleClass {
    fieldMethod = () => {
        console.log("クラスフィールドで定義されたメソッド");
    };
    constructor() {
        this.propertyMethod = () => {
            console.log("インスタンスにプロパティとして定義されたメソッド");
        };
    }
}

しかし、厳密にはこのふたつのプロパティ定義には異なる点はあります。 次のように、クラスフィールドとconstructorの中でthisに追加するプロパティ名に対するsetterを定義してみるとこの違いがわかります。

class ExampleClass {
    field = "フィールド";
    constructor() {
        this.property = "コンストラクタ";
    }
    // クラスフィールド名に対応するsetter
    set field(value) {
        console.log("fieldで定義された値", value);
    }
    // thisのプロパティ名に対応するsetter
    set property(value) {
        console.log("consctrutorで代入された値", value);
    }
}
// set fieldは呼び出されない
// 一方で、set propertyは呼び出される
const example = new ExampleClass();

クラスフィールド名に対するsetterは呼び出されないのに対して、this.propertyへの代入に対するsetterは呼び出されています。 これは、クラスフィールドは=を使った代入で定義されるのではなく、Object.definePropertyメソッドを使ってプロパティが定義されるという違いがあります。 Object.definePropertyを使ったプロパティの定義では、setterは無視してプロパティが定義されます。 setterは=での代入に反応します。そのため、constructorの中でのthis.propertyへの代入に対してはsetterが呼び出されます。

同じプロパティの定義であっても、プロパティの定義の仕組みが微妙に異なる点から、このような挙動の違いが存在しています。 しかし、この違いを意識するようなコードを書くことは避けたほうが安全です。 実際に見た目からこの違いを意識するのは難しく、それを意識させるようなコードは複雑性が高いためです。

[ES2022] Privateクラスフィールド

クラスフィールド構文で次のように書くと、定義したプロパティはクラスをインスタンス化した後に外からも参照できます。 そのため、Publicクラスフィールドと呼ばれます。

class クラス {
    プロパティ名 = プロパティの初期値;
}

一方で外からアクセスされたくないインスタンスのプロパティも存在します。 そのようなプライベートなプロパティを定義する構文もES2022で追加されています。

Privateクラスフィールドは、次のように#をフィールド名の前につけたクラスフィールドを定義します。

class クラス {
    // プライベートなプロパティは#をつける
    #フィールド名 = プロパティの初期値;
}

定義したPrivateクラスフィールドは、this.#フィールド名で参照できます。

class PrivateExampleClass {
    #privateField = 42;
    dump() {
        // Privateクラスフィールドはクラス内からのみ参照できる
        console.log(this.#privateField); // => 42
    }
}
const privateExample = new PrivateExampleClass();
privateExample.dump();

もう少し具体的なPrivateクラスフィールドの使い方を見ていきます。 アクセッサプロパティの例でも登場したNumberWrapperをPrivateクラスフィールドを使って書き直してみます。 元々のNumberWrapperクラスでは、_valueプロパティに実際の値を読み書きしていました。 この場合、_valueプロパティは、外からもアクセスできてしまうため、定義したgetterとsetterが無視できてしまいます。

class NumberWrapper {
    // Publicクラスフィールドなのでクラスの外からアクセスができる
    _value;
    constructor(value) {
        this._value = value;
    }
    // `_value`プロパティの値を返すgetter
    get value() {
        return this._value;
    }
    // `_value`プロパティに値を代入するsetter
    set value(newValue) {
        this._value = newValue;
    }
}
const numberWrapper = new NumberWrapper(1);
// _valueプロパティは外からもアクセスできる
console.log(numberWrapper._value); // => 1

Privateクラスフィールドでは、外からアクセスされたくないプロパティを#をつけてクラスフィールドとして定義します。 次のコードでは、#valueはプライベートプロパティとなっているため、構文エラーが発生し外からアクセスできなくなることが確認できます。 Privateクラスフィールドを使うことで、クラスを利用する際はgetterとsetterを経由しないと#valueを参照できなくなりました。

class NumberWrapper {
    // valueはPrivateクラスフィールドとして定義
    #value;
    constructor(value) {
        this.#value = value;
    }
    // `#value`フィールドの値を返すgetter
    get value() {
        return this.#value;
    }
    // `#value`フィールドに値を代入するsetter
    set value(newValue) {
        this.#value = newValue;
    }
}

const numberWrapper = new NumberWrapper(1);
// クラスの外からPrivateクラスフィールドには直接はアクセスできない
console.log(numberWrapper.#value); // => SyntaxError: reference to undeclared private field or method #value

Privateクラスフィールドを使うことで、クラスの外からアクセスさせたくないプロパティを宣言できます。 これは、実装したクラスの意図しない使われ方を防いだり、クラスの外からプロパティの状態を直接書き換えるといった行為を防げます。

また、Privateクラスフィールドでは、途中から値が入る場合でもフィールドの宣言が必須となっています。 次のコードでは、#loadedContentに実際に値が入るのはloadメソッドが呼び出されたときです。 Publicクラスフィールドではフィールドの定義は省略可能でしたが、Privateクラスフィールドでは#loadedContentフィールドの定義が必須となっています。 言い換えると、Privateクラスフィールドでは、クラスを定義した段階でクラスに存在するすべてのPrivateクラスフィールドを明示する必要があります。

class PrivateLoader {
    // 途中で値が入る場合でも最初に`undefined`で初期化されるフィールドの定義が必須
    #loadedContent;
    load() {
        this.#loadedContent = "読み込んだコンテンツ内容";
    }
}

静的メソッド

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

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

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

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

class ArrayWrapper {
    // new演算子で引数が渡されたなかった場合の初期値は空配列
    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はクラス自身を参照するため、クラスのインスタンスは参照できません。 そのため静的メソッドは、クラスのインスタンスを作成する処理やクラスに関係する処理を書くために利用されます。

[ES2022] 静的クラスフィールド

ES2022で追加されたクラスフィールドでは、インスタンスではなくクラス自体に定義する静的クラスフィールドも利用できます。

静的クラスフィールドは、フィールドの前にstaticをつけるだけです。 静的クラスフィールドで定義したプロパティは、クラス自体のプロパティとして定義されます。 次のコードでは、Public静的クラスフィールドを使ってColorsクラス自体にプロパティを定義しています。

class Colors {
    static GREEN = "緑";
    static RED = "赤";
    static BLUE = "青";
}
// クラスのプロパティとして参照できる
console.log(Colors.GREEN); // => "緑"

また、Privateクラスフィールドも静的に利用できます。 Private静的クラスフィールドは、クラス自体にプロパティを定義したいが、そのプロパティを外から参照されたくない場合に利用します。 Private静的クラスフィールドはフィールドの前に、staticをつけるだけです。

class MyClass {
    static #privateClassProp = "This is private";
    static outputPrivate() {
        // クラス内からはPrivate静的クラスフィールドで定義したプロパティを参照できる
        console.log(this.#privateClassProp);
    }
}
MyClass.outputPrivate();

プロトタイプに定義したメソッドとインスタンスに定義したメソッドの違い

ここまでで、プロトタイプメソッドの定義とクラスフィールドを使ったインスタンスに対するメソッドの定義の2種類を見てきました。 プロトタイプメソッドの定義方法は、メソッドをプロトタイプオブジェクトという特殊なオブジェクトに定義します。 一方で、クラスフィールドで定義したメソッドは、クラスのインスタンスに対してメソッドを定義します。

どちらのメソッド定義方法でも、new演算子でインスタンス化したオブジェクトからメソッドを呼び出すことができる点は同じです。

class ExampleClass {
    // クラスフィールドを使い、インスタンスにメソッドを定義
    instanceMethod = () => {
        console.log("インスタンスメソッド");
    };
    // メソッド構文を使い、プロトタイプオブジェクトにメソッドを定義
    prototypeMethod() {
        console.log("プロトタイプメソッド");
    }
}
const example = new ExampleClass();
// どちらのメソッドもインスタンスから呼び出せる
example.instanceMethod();
example.prototypeMethod();

しかしこの2つのメソッドの定義方法は、メソッドの定義先となるオブジェクトが異なります。

まず、この2種類のメソッドがそれぞれ別の場所へと定義されていることを見ていきます。 次のコードでは、ConflictClassクラスにmethodという同じ名前のメソッドをプロトタイプメソッドとインスタンスに対してそれぞれ定義しています。

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

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

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

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

class ConflictClass {
    // インスタンスオブジェクトに`method`を定義
    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の仕様で定められた内部的な表現であるため、通常のプロパティのようにはアクセスできません。 ここでは説明のために、[[プロパティ名]]という書式でECMAScriptの仕様上に存在する内部プロパティを表現しています。

[[Prototype]]内部プロパティへプロパティのようにはアクセスできませんが、Object.getPrototypeOfメソッドで[[Prototype]]内部プロパティを参照できます。

次のコードでは、instanceオブジェクトの[[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 (Object.hasOwn(instance, "method")) {
    instance.method();
} else {
    // インスタンスの`[[Prototype]]`の参照先(`MyClass`のプロトタイプオブジェクト)を取り出す
    const prototypeObject = Object.getPrototypeOf(instance);
    // プロトタイプオブジェクトが`method`プロパティを持っている場合
    if (Object.hasOwn(prototypeObject, "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()で親クラスのコンストラクタ処理を呼び出さないとReferenceErrorとなるためです。

次のコードでは、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"

クラスフィールドの継承

Publicクラスフィールドもコンストラクタの処理順と同じく親クラスのフィールドが初期化された後に子クラスのフィールドが初期化されます。 Publicクラスフィールドは、インスタンスオブジェクトに対してプロパティを定義する構文でした。 そのため、親クラスで定義されていたフィールドも、実際にインスタンス化したオブジェクトのプロパティとして定義されます。

class Parent {
    parentField = "親クラスで定義したフィールド";
}
// `Parent`を継承した`Child`を定義
class Child extends Parent {
    childField = "子クラスで定義したフィールド";
}
const instance = new Child();
console.log(instance.parentField); // => "親クラスで定義したフィールド"
console.log(instance.childField); // => "子クラスで定義したフィールド"

同じ名前のフィールドが定義されている場合は、子クラスのフィールド定義で上書きされます。

class Parent {
    field = "親クラスで定義したフィールド";
}
// `Parent`を継承した`Child`を定義
class Child extends Parent {
    field = "子クラスで定義したフィールド";
}
const instance = new Child();
console.log(instance.field); // => "子クラスで定義したフィールド"

Publicクラスフィールドは、このように親クラスで定義したフィールドも子クラスに定義されます。 一方で、Privateクラスフィールドは、このように親クラスで定義したフィールドは子クラスに定義されません。

次のコードでは、親クラスで定義したPrivateクラスフィールドを子クラスから参照しようとしています。 しかし、#parentFieldは参照できずに構文エラーとなることがわかります。

class Parent {
    #parentField = "親クラスで定義したPrivateフィールド";
}
// `Parent`を継承した`Child`を定義
class Child extends Parent {
    dump() {
        console.log(this.#parentField); // => SyntaxError: reference to undeclared private field or method #parentFeild
    }
}
const instance = new Child();
instance.dump();

これは、PrivateクラスフィールドのPrivateとは各クラスごとのPrivateを守る目的であるためです。 継承したクラスからPrivateクラスフィールドが利用できてしまうと、Privateな情報が子クラスに漏れてしまうためです。 JavaScriptでは、クラスの外に公開したくないが、子クラスからは利用できるようにしたいというような中間の制限を持ったプロパティを定義する構文はありません。

このように子クラスも含むクラスの外からアクセスを厳密に拒否するPrivateをhard privateと呼びます。 JavaScriptでのPrivateクラスフィールドはhard privateとなっています。

一方で、子クラスからのアクセスは許可したり、クラス外からのアクセスが可能となるような特例を持つようなPrivateをsoft privateと呼びます。 JavaScriptでのsoft privateは、WeakMapやWeakSetを使ってユーザー自身で実装する必要があります(「Map/Set」の章を参照)。

プロトタイプ継承

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

class Parent {
    method() {
        console.log("Parent.prototype.method");
    }
}
// `Parent`を継承した`Child`を定義
class Child extends Parent {
    // methodの定義はない
}
// `Child`のインスタンスは`Parent`のプロトタイプメソッドを継承している
const instance = new Child();
instance.method(); // "Parent.prototype.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.prototype.methodの中でsuper.method()と書くことでParent.prototype.methodを呼び出しています。 このように、子クラスから継承元の親クラスのプロトタイプメソッドはsuper.プロパティ名で参照できます。

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

プロトタイプチェーンでは、インスタンスからクラス、さらに親のクラスと継承関係をさかのぼるようにメソッドを探索すると紹介しました。 このコードではChild.prototype.methodが定義されているため、child.methodChild.prototype.methodを呼び出します。 そしてChild.prototype.methodsuper.methodを呼び出しているため、Parent.prototype.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クラスへfirstlastといったアクセッサプロパティを追加しています。

class MyArray extends Array {
    get first() {
        return this.at(0);
    }

    get last() {
        return this.at(-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メソッドなどを継承しているので利用できます。

まとめ

この章ではクラスについて学びました。

  • JavaScriptのクラスはプロトタイプベース
  • クラスはclass構文で定義できる
  • クラスで定義したメソッドはプロトタイプオブジェクトとプロトタイプチェーンの仕組みで呼び出せる
  • クラスのインスタンスに対するプロパティの定義にはクラスフィールドが利用できる
  • クラスの外からアクセスさせたくないプロパティの定義にはPrivateクラスフィールドを使う
  • アクセッサプロパティはgetterとsetterのメソッドを定義することでプロパティのように振る舞う
  • クラスはextendsで継承できる
  • クラスのプロトタイプメソッドと静的メソッドはどちらも継承される