🍭

JavaScript 再入門: JavaScript の class 構文はシンタックスシュガー

2023/08/08に公開

知ってるようで実は知らない JavaScript 。今回は、そんな JavaScript のオブジェクト指向の仕組みについてまとめてみました。


JavaScript のコンソールでの検証には、基本的に Mac 版 Safari 16.4 を使用していますが、適宜 Chrome や Edge のコンソールでも確認しています。

JavaScript の class 構文はシンタックスシュガー

JavaScript はオブジェクト指向言語です。また、モダン JavaScript では、class 構文が使えます。ただし、 JavaScript の class 構文はいわゆるシンタックスシュガーと呼ばれるものです。
シンタックスシュガーとは英語で syntax sugar もしくは syntactic sugar、日本語訳は糖衣構文もしくは構文糖などと訳されます。

糖衣構文(とういこうぶん、英: syntactic sugar あるいは syntax sugar)は、プログラミング言語において、読み書きのしやすさのために導入される書き方であり、複雑でわかりにくい書き方と全く同じ意味になるものを、よりシンプルでわかりやすい書き方で書くことができるもののことである。

糖衣構文 Wikipedia

ということは、 JavaScript ではもともと class 構文を使わなくてもオブジェクト指向プログラミングができるのだけれども、それだと複雑で分かりにくい書き方になるので、後からよりシンプルでわかりやすい書き方として class 構文が導入された、ということになります。実際、 class 構文が導入されたのは、ES2015からだそうです。

オブジェクトリテラル

というわけで、もともと JavaScript では、クラスを宣言する必要なしにオブジェクトを使うことができます。オブジェクトは、名前と値のペアの集合(プロパティの集合)で表されます。JavaScript のオブジェクトは、基本的には「ディクショナリ」や「連想配列」などと呼ばれるもの同じものです。
シンプルなオブジェクトリテラルは以下の通りです。

{x:1, y:2, z:3}
{キー名:値, キー名:値, ...}

のように書きます。このように、classがなくてもオブジェクトを生成することができるので、 JavaScript のオブジェクトは、他言語のような特定のクラスのインスタンスというわけではありません。
以下のように、{x:1, y:2, z:3}の型はオブジェクトです。

typeof({x:1, y:2, z:3})
//"object"

オブジェクトなのでドット記法で要素にアクセスすることができます。

console.log({x:1, y:2, z:3}.x)
//1

ただし、ブラウザのコンソールなどに直接記述する場合は、{}が間違ってブロックと解釈されないように丸括弧で囲みます。

({x:1, y:2, z:3}).x
//1

当然、変数に代入することもできます。

let obj = {x:1, y:2, z:3}
console.log(obj.x)
//1

オブジェクトリテラルでのメソッド

オブジェクトリテラルでメソッドも記述できます。木村さんのお小遣いは10000円ということにします。

let kimura = {
  name: 'Kimura Takumi',
  allowance: 10000,
  increase: function(amount) {
    this.allowance += amount
  }
}

このように、increaseプロパティに、無名関数の Function オブジェクトを割り当ててメソッドを実装しています。メソッド内のthisは、レシーバーオブジェクト=インスタンスのkimuraを指します。
increaseメソッドを呼び出して、お小遣いを100円増額します。

kimura.increase(100)
consple.log(kimura.allowance)
//10100

ちなみに、メソッドのショートカット構文では、下記のようにコロンと function キーワードを省略できます。この書き方は「関数を値とするプロパティ」のシンタックスシュガーです。

let kimura = {
  name: 'Kimura Takumi',
  allowance: 10000,
  increase(amount) {
    this.allowance += amount
  }
}

Object.create メソッドによるオブジェクトの作成

Object.create メソッドでもオブジェクトを作成できます。
Object.create メソッドを利用すると、
・プロパティの詳細情報(読み取り専用か、列挙可能かなど)
・オブジェクトを生成する際に元となる機能(プロトタイプ)
などの詳細な情報を設定できます。

let kimura = Object.create(Object.prototype, {
  name: {
    value: 'Kimura Takumi',
    writable: true,
    configurable: true,
    enumerable: true
  },
  allowance: {
    value: 10000,
    writable: true,
    configurable: true,
    enumerable: true
  },
  increase: {
    value: function(amount) {
      this.allowance += amount
    },
    writable: true,
    configurable: true,
    enumerable: true
  }
});

第一引数にObject.prototypeを設定しているのは、Object オブジェクトを継承すると言う意味です。prototypeについては後の節で説明します。
第二引数には、プロパティとその詳細な設定を記述することができます。この場合、name allowance increaseはプロパティ名で、valueにはプロパティの値を設定します。writableには書き換え可能か、configurableには属性の変更やプロパティの削除が可能か、enumerableにはfor ...inによる列挙が可能かを設定できます。
その他にゲッター/セッター関数を定義することもできます。

コンストラクター関数と new 演算子によるオブジェクトの作成

また、コンストラクター関数と new 演算子を使ってインスタンスオブジェクトを作成することもできます。
まず、コンストラクター関数を記述してオブジェクトの型を定義します。中身のない最もシンプルな定義は、下記のように変数Personに、空の関数リテラルを代入しているだけです。これがいわゆる「クラス」定義に相当します。

let Person = function(){}

コンストラクター関数は、クラス的な意味合いで?慣習的に1文字目は大文字とします。
Personは、new演算子でオブジェクトのインスタンスを作成できます。

let person1 = new Person()

typeof(person1)
//"object"

Personは Function オブジェクトです。

typeof(Person)
//"function"

ということは、一般の関数とコンストラクター関数には実質的な違いがないということですね。
JavaScript では、このように関数にクラスとしての役割を与えています。なのでオブジェクトを生成するのに class 宣言などは必要ありません。
以下のように名前付き関数としてコンストラクター関数を宣言することもできます。new 演算子でオブジェクトを生成しています。

function Person(){}
let person1 = new Person()

typeof(person1)
//"object"
typeof(Person)
//"function"

以下ではオブジェクトリテラルの時と同様に、いくつかのプロパティとメソッドを実装しています。
コンストラクター関数内でのthisは、生成したインスタンスを指します。thisキーワードに対して変数を指定することで、インスタンスのプロパティを設定できます。

function Person(name, allowance) {
  this.name = name
  this.allowance = allowance
  this.increase = function(amount) {
    this.allowance += amount
  }
}

let kimura = new Person('Kimura Takumi', 20000)
typeof(kimura)
//"object"

console.log(kimura.name)
//Kimura Takumi

クラスではなくプロトタイプ

JavaScriptnの 全てのオブジェクトは、「クラス」ではなく、その原型となる「プロトタイプ(雛型)」に紐づいています。そして、そのプロトタイプ自体もオブジェクトです。なので、JavaScript のオブジェクト指向は、プロトタイプベースのオブジェクト指向と呼ばれることもあります。「インスタンス」という概念はありますが、いわゆる「クラス」がなく、「プロトタイプ」だけが存在するということです。
クラスの代わりにプロトタイプを利用して新たなオブジェクトを生成します。つまり、あらかじめ用意されたオブジェクト(プロトタイプ)をもとに、新しいオブジェクトを生成します。このように、オブジェクトの複製を作ることをインスタンス化と呼んでいます。

JavaScript では、各オブジェクトは特別な隠しプロパティ [[Prototype]] を持っており、それは別のオブジェクト(もしくは null )を参照します。その別のオブジェクトがプロトタイプです。スクリプトから [[Prototype]] プロパティ自体は直接参照することはできません。Object.getPrototypeOfメソッドで、 [[Prototype]] プロパティを参照することができます。

また、それとは別にすべての関数はprototypeプロパティを持っています。関数のデフォルトの prototypeconstructorというプロパティだけを持つオブジェクトで、それは関数自体を指します。関数のprototypeプロパティは、以下のように直接参照することができます。

function Person(name, allowance) {
  this.name = name
  this.allowance = allowance
  this.increase = function(amount) {
    this.allowance += amount
  }
}

console.log(Person.prototype)
//Person {}

console.log(Person.prototype.constructor)
//function Person(name, allowance) {
//  this.name = name
//  this.allowance = allowance
//  this.increase = function(amount) {
//    this.allowance += amount
//  }
//}

コンストラクター関数と new 演算子でインスタンスが作成されたときに、生成されたインスタンスの隠しプロパティ [[Prototype]] に、コンストラクター関数の prototype プロパティのオブジェクトがセットされます。デフォルトのままであれば、コンストラクター関数自体が設定されます。
インスタンスのプロトタイプを取得するには、Object.getPrototypeOfメソッドを使います。
Personから生成されたオブジェクトのプロトタイプは、以下のようにPersonになっています。

let person1 = new Person("Kimura Takumi", 10000)
let person2 = new Person("Nakai Kiichi", 20000)

Object.getPrototypeOf(person1) === Person.prototype
//true
Object.getPrototypeOf(person2) === Person.prototype
//true
Object.getPrototypeOf(person1)
//Person {}
Object.getPrototypeOf(person2)
//Person {}

上記の場合、Personというコンストラクター関数から、person1person2 という2つのインスタンスオブジェクトを生成しています。つまりperson1person2のプロトタイプ(雛型)は両方とも同じPerson.prototypeを参照していて、Person.prototypeの中身はコンストラクラー関数自身であるPersonということになります。

継承とプロトタイプチェーン

JavaScript では、継承はオブジェクトのプロトタイプチェーンを使って実現されています。
JavaScript の継承はオブジェクトだけで構成されていて、それぞれのオブジェクトは、そのオブジェクトのプロトタイプと呼ばれる別のオブジェクトへのリンクを持つプライベートプロパティ [[Prototype]] を持っています。 [[Prototype]] は、通常はそのオブジェクトを生成したコンストラクターの prototype を参照していて、それがプロトタイプオブジェクトということになります。
そのプロトタイプオブジェクト自身も [[Prototype]] プロパティに自身のコンストラクターの prototype をプロトタイプとして持ち、 null をプロトタイプとするオブジェクトに到達するまでプロトタイプのチェーンは続きます。
null をプロトタイプとするオブジェクトとは、通常はObject.prototypeのことを指し、基本的にはほぼすべてのオブジェクトのルートとなるのは、Objectオブジェクトです。
つまり、ほぼすべてのオブジェクトは、Objectのインスタンスであり、Objectを継承し、最終的にObject.prototypeを参照しています。
簡単な例を見てみます。

const myDate = new Date();

Object.getPrototypeOf(myDate) === Date.prototype
//true
Object.getPrototypeOf(Date.prototype) === Object.prototype
//true
Object.getPrototypeOf(Object.prototype) === null
//true

上記では、Object.getPrototypeOfメソッドで Date 型のmyDateオブジェクトから [[Prototype]] を辿っていくと、 Object.prototypeを経て、最終的にnullに到達します。この場合プロトタイプチェーンは次のようになります。
myDate ---> Date.prototype ---> Object.prototype ---> null

一般的なコンストラクター関数を、例えば Constructor とした場合、 Constructor.prototype プロパティは、コンストラクターから作成されたインスタンスの [[Prototype]] となります。また、Constructor.prototype 自身の [[Prototype]] は、 Object.prototype になります。ただし Object.prototype 自身の [[Prototype]] は null です。
したがって、以下のように典型的なコンストラクターは

function Constructor() {}
const obj = new Constructor();

以下のようなプロトタイプチェーンを構築します。
obj ---> Constructor.prototype ---> Object.prototype ---> null

Object.getPrototypeOf(obj) === Constructor.prototype
//true
Object.getPrototypeOf(Constructor.prototype) === Object.prototype
//true
Object.getPrototypeOf(Object.prototype) === null
//true

もっと長いプロトタイプチェーンを構築する場合は、 Constructor.prototype の [[Prototype]] を Object.setPrototypeOfメソッドでObject.prototype以外のオブジェクトに設定することができます。
そのような例として、下記では、Derived.prototype の [[Prototype]] をBase.prototype に設定しています。

function Base() {}
function Derived() {}
Object.setPrototypeOf(Derived.prototype, Base.prototype,);

const obj = new Derived();

この場合は、以下のようなプロトタイプチェーンが構築されています。
obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

console.log(Object.getPrototypeOf(obj) === Derived.prototype)
//true
console.log(Object.getPrototypeOf(Derived.prototype) === Base.prototype)
//true
console.log(Object.getPrototypeOf(Base.prototype) === Object.prototype)
//true
console.log(Object.getPrototypeOf(Object.prototype) === null)
//true

メソッドの共有

コンストラクター内でメソッドを定義すると、インスタンス間で共通のメソッドを持つことができます。しかし、コンストラクターによるメソッドの定義にはメソッドの数とインスタンスの数に比例してメモリを消費するという問題があります。

function Person(name, allowance) {
  this.name = name
  this.allowance = allowance
  this.increase = function(amount) {
    this.allowance += amount
  }
}

let person1 = new Person("Kimura Takumi", 10000)
let person2 = new Person("Nakai Kiichi", 20000)

コンストラクターは、インスタンスを生成するたびに、プロパティやメソッドを各インスタンスにコピーします。しかし、メソッドの中身はどのインスタンスも同じなので、全インスタンスに同じメソッドをコピーするのはメモリの無駄になります。
以下の、Object.getOwnPropertyNamesメソッドは、与えられたオブジェクトで発見されたすべての直接のプロパティを含む配列を返します。

Object.getOwnPropertyNames(person1)
//["name", "allowance", "increase"] (3)
Object.getOwnPropertyNames(person2)
//["name", "allowance", "increase"] (3)

increase メソッドを使用してお小遣いを少し増やしてみます。

person1.increase(100)
console.log(person1.allowance)
//10100
person2.increase(200)
console.log(person2.allowance)
//20200

JavaScript では、全インスタンスに同じメソッドをコピーしなくて済むように、コンストラクター関数のprototypeプロパティを利用することができます。これまでみてきたように、各オブジェクトはそのコンストラクター関数のprototypeに対する暗黙的な参照を持っています。そして、prototypeが参照するオブジェクトには、プロパティやメソッドを追加することができます。

function Person(name, allowance) {
  this.name = name
  this.allowance = allowance
}
//prototypeにincreaseメソッドを追加します
Person.prototype.increase = function(amount) {
  this.allowance += amount
}

let person1 = new Person("Kimura Takumi", 10000)
let person2 = new Person("Nakai Kiichi", 20000)

Object.getOwnPropertyNamesメソッドの出力結果には、プロトタイプチェーン上のプロパティ/メソッドは含まれません。なので、今回はインスタンス上の直接のプロパティにはincreaseが含まれておらず、Person.prototypeのプロパティに含まれています。

Object.getOwnPropertyNames(person1)
//["name", "allowance"] (2)
Object.getOwnPropertyNames(person2)
//["name", "allowance"] (2)
Object.getOwnPropertyNames(Person.prototype)
//["constructor", "increase"] (2)

しかし、コンストラクターに直接定義した時と同じように、increaseメソッドを使用できます。

person1.increase(100)
console.log(person1.allowance)
//10100
person2.increase(200)
console.log(person2.allowance)
//20200

prototypeプロパティに追加されたメンバーは、そのコンストラクターを元に生成された全てのインスタンスで利用できます。このようにしてプロトタイプからインスタンスにメソッドが継承されます。
オブジェクトのプロパティ/メソッドにアクセスしたときに、そのオブジェクト自身にプロパティ/メソッドが見つからない場合は、プロトタイプを検索してプロパティ/メソッドを探します。それでもプロパティ/メソッドが見つからない場合は、プロトタイプのプロトタイプが遡って検索され、プロトタイプチェーンのどこかでプロパティ/メソッドが得られるか、プロトタイプチェーンの終わりのnullに達してundefinedを返すまで繰り返します。JavaScript の継承はこのようにして実現されています。

また、プロトタイプオブジェクトへの変更を、インスタンス側で動的に認識することができます。

function Person(name, allowance) {
  this.name = name
  this.allowance = allowance
}

let person1 = new Person("Kimura Takumi", 10000)
let person2 = new Person("Nakai Kiichi", 20000)

//インスタンス化の後にメソッドを追加
Person.prototype.increase = function(amount) {
  this.allowance += amount
}

インスタンス化の後にメソッドを追加していますが、以下のようにメソッドを認識できています。

person1.increase(100)
console.log(person1.allowance)
//10100
person2.increase(200)
console.log(person2.allowance)
//20200

つまり、すでにインスタンスが生成されていても、共有するプロパティ/メソッドを事後的に追加できるといことです。

プロトタイプは動的に変更可能

プロトタイプチェーンのどのメンバーも、実行時に変更することが可能です。
コンストラクター関数のprototypeプロパティは書き換え可能なので、インスタンス作成後にコンストラクター関数のprototypeプロパティが別のオブジェクトに変更された場合、その後 new 演算子によって生成された新しいインスタンスオブジェクトは [[Prototype]] として別のオブジェクトを持ちます。しかし、変更前に生成されたオブジェクトは [[Prototype]] として古い変更前のオブジェクトを保持したままになります。

let Human = function(){}
let Animal = function(){}

let person1 = new Human()
//Human.prototypeをAnimal.prototypeに変更してインスタンスを作成します。
Human.prototype = Animal.prototype
let person2 = new Human()

console.log(Object.getPrototypeOf(person1))
//Human {}
console.log(Object.getPrototypeOf(person2))
//Animal {}

Human.prototype変更前にインスタンス化したものは、変わらず Human 型のオブジェクトのままです。JavaScript のプロトタイプチェーンは、インスタンスが生成された時点で固定され、その後の変更に関わらず保存されるということです。

また、Object.setPrototypeOfメソッドを使えばオブジェクトの [[Prototype]] を直接変更することもできます。

let Human = function(){}
let Animal = function(){}

let person1 = new Human()
console.log(Object.getPrototypeOf(person1))
//Human {}

//person1の[[Prototype]]をAnimal.prototypeに変更します。
Object.setPrototypeOf(person1, Animal.prototype)
console.log(Object.getPrototypeOf(person1))
//Animal {}

Java や C# における継承とはあくまでも静的なものですが、JavaScript での継承はこのように動的なものです。同一のオブジェクトが、ある時点ではオブジェクト X を継承しており、別の時点ではオブジェクト Y を継承しているということも可能です。
しかし、Humanコンストラクタで生成したオブジェクトのプロトタイプが実はAnimalであるというのは非常にわかりづらいので、そのような実装をした場合は大きな混乱を招きかねません。
また、プロトタイプチェーンの途中でコンストラクターのprototypeの変更をすると、その影響は広範囲に及ぶ可能性があり、思わぬ副作用が生じる恐れがあります。
このようにコンストラクターのprototypeやオブジェクトの [[Prototype]] を上書きすると、オブジェクトのコンストラクターが入れ替わってしまうので、あまりよい方法とは思えません。
正しいconstructorを維持するためにはprototypeや [[Prototype]] の全体を上書きするのではなくて、上記の「継承とメソッドの共有」でみたように、デフォルトのprototypeに対してプロパティ/メソッドの追加/削除を行うようにしたほうがよいでしょう。

古典的な継承の構文

以下は JavaScript の古典的な継承の方法です。

let Person = function(personName) {
  this.personName = personName;
  this.speakable = true;
};

Person.prototype.speak = function() {
  if (this.speakable) {
    console.log('Hi, I am ' + this.personName);
  }
};

let Passenger = function(personName, nationality) {
  Person.call(this, personName);
  this.nationality = nationality;
};

Object.setPrototypeOf(Passenger.prototype, Person.prototype);
//もしくは、setPrototypeOfメソッドを使う代わりに以下のようにも書ける
//Passenger.prototype = Object.create(Person.prototype);
//Passenger.prototype.constructor = Passenger;

Passenger.prototype.speak = function() {
  if (this.speakable) {
    console.log('Hi, I am ' + this.personName + ' from ' + this.nationality);
  }
};

let passenger1 = new Passenger('Takumi', 'Japan');
passenger1.speak()
//Hi, I am Takumi from Japan

PassengerPersonを継承しています。Passengerコンストラクター内では、まずスーパークラスのコンストラクターを呼び出します。Person.call(this, name)Personコンストラクターを実行時のthisで呼び出します。引数nameも同時に渡します。
そして、Object.setPrototypeOf(Passenger.prototype, Person.prototype);で、Passenger.prototypeの [[Prototype]] にPerson.prototypeをセットします。
プロトタイプをセットするもう一つの方法は、(コメントアウトしている部分ですが、)
Object.create(Person.prototype)で、Person.prototypeをプロトタイプに持つオブジェクトを生成して、Passenger.prototype にセットする方法です。この方法では、Passenger.prototype.constructor が、親のPerson.prototype.constructor (つまりPerson)になってしまいますので、それを避けるために Passenger.prototype.constructorPassenger (子のコンストラクター) を再設定します。

プロトタイプチェーンを確認してみます。

Object.getPrototypeOf(passenger1) === Passenger.prototype
//true
Object.getPrototypeOf(Passenger.prototype) === Person.prototype
//true
Object.getPrototypeOf(Person.prototype) === Object.prototype
//true
Object.getPrototypeOf(Object.prototype) === null
//true

この場合は、以下のようなプロトタイプチェーンが構築されています。
passenger1 ---> Passenger.prototype ---> Person.prototype ---> Object.prototype ---> null

プロパティを確認してみます。

Object.getOwnPropertyNames(passenger1)
//["personName", "speakable", "nationality"] (3)
Object.getOwnPropertyNames(Passenger.prototype)
//["constructor", "speak"] (2)
Object.getOwnPropertyNames(Person.prototype)
//["constructor", "speak"] (2)

Object.getOwnPropertyNames(Passenger)
//["length", "prototype", "name"] (3)
Object.getOwnPropertyNames(Person)
//["length", "prototype", "name"] (3)

passenger1 インスタンスオブジェクトには、["personName", "speakable", "nationality"] の3つのプロパティがあります。
passenger1の [[Prototype]] のPassenger.prototypeには、["constructor", "speak"] の2つのプロパティがあり、speakメソッドはオーバーライドしたメソッドです。
Passenger.prototypeの [[Prototype]] のPerson.prototypeにも["constructor", "speak"] の2つのプロパティがあり、speakメソッドはオリジナルのメソッドです。

Passenger コンストラクターと Person コンストラクターには、Function オブジェクトに特有のプロパティがあるのが確認できます。

クラス構文

JavaScript の class 構文は、(Wikipedia的に言うと)これまでみてきたように複雑で分かりにくい書き方であるコンストラクタ関数やプロトタイプによる継承など、JavaScript 特有のオブジェクト指向の構文と全く同じ意味になるものを、よりシンプルでわかりやすい書き方で書くことができるようにしたもの、と言うことになります。
上記「古典的な継承の構文」場合と同じクラスを定義してみます。

class Person {
  constructor (personName) {
    this.personName = personName;
    this.speakable = true;
  }
  
  speak() {
    if (this.speakable) {
      console.log('Hi, I am ' + this.personName);
    }
  }
};

class Passenger extends Person {
  constructor (personName, nationality) {
    super(personName);
    this.nationality = nationality;
  }

  speak() {
    if (this.speakable) {
      console.log('Hi, I am ' + this.personName + ' from ' + this.nationality);
    }
  }
};

var passenger1 = new Passenger('Takumi', 'Japan');
passenger1.speak()
//Hi, I am Takumi from Japan

他の言語の class 構文と同様の書き方になっているので、他言語の習得者には馴染みやすい構文となっていますね。それぞれの class 内に必要なものが収まっているので、わかりやすいと言えます。
この class 構文でも、これまでみてきたことと全く同じことが実行されています。クラス宣言は、実際にはコンストラクター関数Person, Passengerを宣言します。constractorキーワードはそれぞれPerson, Passengerコンストラクターの関数本体を宣言します。そして、各speakメソッドはそれぞれPerson.prototype, Passenger.prototypeに追加され、Passenger側でオーバーライドされます。
オブジェクトを生成するには、new 演算子でコンストラクター関数を呼び出します。

プロトタイプチェーンを確認してみます。

Object.getPrototypeOf(passenger1) === Passenger.prototype
//true
Object.getPrototypeOf(Passenger.prototype) === Person.prototype
//true
Object.getPrototypeOf(Person.prototype) === Object.prototype
//true
Object.getPrototypeOf(Object.prototype) === null
//true

この場合も、古典的な継承の時と同じように以下のようなプロトタイプチェーンが構築されています。
passenger1 ---> Passenger.prototype ---> Person.prototype ---> Object.prototype ---> null

PersonクラスとPassengerクラスの型を確認します。

typeof(Person)
//"function"
typeof(Passenger)
//"function"

このように、 class 構文で定義していても、PersonPassengerはFunctionオブジェクトです。
また、 class 構文でも、「古典的な継承の構文」と同様のプロパティを確認することができます。

Object.getOwnPropertyNames(passenger1)
//["personName", "speakable", "nationality"] (3)
Object.getOwnPropertyNames(Passenger.prototype)
//["constructor", "speak"] (2)
Object.getOwnPropertyNames(Person.prototype)
//["constructor", "speak"] (2)

Object.getOwnPropertyNames(Passenger)
//["length", "prototype", "name"] (3)
Object.getOwnPropertyNames(Person)
//["length", "prototype", "name"] (3)

このように、確かに class 構文は従来と比較してシンプルで分かり易く書きやすい構文で、しかも裏側で必要な処理を自動的に実行してくれます。ミスも減ると思われるので、今後は class 構文を使用すべきだと思います。
ただし、JavaScript のオブジェクト指向の挙動がなぜそうなるかを理解して、間違いのない実装をするには、実際には古典的な構文で書いているような仕組みで動いていることは、理解しておくことが大切だと思います。

プロトタイプ関連の表記方法等の整理

最後に、プロトタイプ関連の表記方法を整理しておきます。

1.オブジェクトのプロトタイプの隠しプロパティ obj.[[Prototype]]

JavaScript では、オブジェクトは特別な隠しプロパティ [[Prototype]] を持っており、それは別のオブジェクト(もしくは null )を参照します。その別のオブジェクトが当該オブジェクトのプロトタイプなのですが、デフォルトではコンストラクタ関数のprototypeプロパティを参照しています。
JavaScript での「継承」は、この [[Prototype]] プロパティを利用して実現されています。
ただし、スクリプト内でobj.[[Prototype]]としてもエラーになり参照したり変更したりすることはできません。
Chrome や Edge の開発者コンソールでは、コンソールのログでオブジェクトのプロパティの一部として、 [[Prototype]] プロパティが他のプロパティと同様に表示されます。

2.オブジェクトプロトタイプのアクセサ(非推奨)obj.__proto__

obj.__proto__は、オブジェクトの隠しプロパティ [[Prototype]] を読み書きできるアクセサです。現在多くのブラウザでサポートされてはいますが、非推奨になっていますので、いつサポートされなくなってもおかしくありません。コンソールで確認したりするのに使うのには便利ですが、プログラム内での使用は控えた方がよいでしょう。

3.Object.getPrototypeOfメソッドとObject.setPrototypeOfメソッド

オブジェクトのプロトタイプを取得するのがObject.getPrototypeOfメソッドで、設定するのがObject.setPrototypeOfメソッドです。今後は、非推奨となったobj.__proto__アクセサーの代わりにこれらのメソッドを使用します。以下のように使います。

let obj = {a: 1}
let someObj = {b: 2}
Object.getPrototypeOf(obj) //objのプロトタイプを取得
//{}
Object.setPrototypeOf(obj, someObj) //objのプロトタイプをsomeObjに変更
Object.getPrototypeOf(obj)
//{b: 2}

4.オブジェクト初期化子 { __proto__: ... }

オブジェクトのリテラル表記の際に、初期化子としてプロトタイプ [[Prototype]] を指定する際に __proto__: を使用します。(上記 obj.__proto__ アクセサーとは違い、非推奨ではありません。)

const obj = {
  a: 1,
  b: 2,
  //この場合プロトタイプをリテラルで指定しています。
  __proto__: {
    b: 3,
    c: 4,
  },
};

obj.a
//1

//objのプロトタイプチェーンを確認します。
//まず、objのプロトタイプを取得します。
Object.getPrototypeOf(obj) 
//{b: 3, c: 4}
//直接{b: 3, c: 4}を操作できないので、間接的に取得します。
Object.getPrototypeOf(Object.getPrototypeOf(obj)) === Object.prototype
//true
Object.getPrototypeOf(Object.prototype) === null
//true

上記の場合、いわゆる obj.[[Prototype]] は {b: 3, c: 4} です。obj.[[Prototype]].[[Prototype]] は Object.prototype です。そして、 obj.[[Prototype]].[[Prototype]].[[Prototype]] は null で、プロトタイプチェーンは終了です。したがって、この場合プロトタイプチェーンは次のようになります。
{ a: 1, b: 2 } ---> { b: 3, c: 4 } ---> Object.prototype ---> null
この場合に、2番目のプロトタイプ{b: 3, c: 4}はコンストラクター関数ではないオブジェクトですので、prototypeプロパティはありません。

以下のような継承のパターンも書けます。

let animal = {
  eats: true,
  walk() {
    console.log("Animal can walk")
  }
}

let human = {
  speak: true,
  __proto__: animal
}

let japanese = {
  lunguage: 'Japanese',
  __proto__: human
}

japanese.walk()
//Animal can walk
console.log(japanese.speak)
//true

walkspeak は プロトタイプチェーンから取得されました。
ただし、aminal human japaneseはコンストラクター関数ではないので、prototypeプロパティは持ちません。

Object.getPrototypeOf(japanese) === human
//true
Object.getPrototypeOf(human) === animal
//true
Object.getPrototypeOf(animal) === Object.prototype 
//true
Object.getPrototypeOf(Object.prototype) === null 
//true

この場合プロトタイプチェーンは次のようになります。
japanese ---> human ---> animal ---> Object.prototype ---> null

5.関数オブジェクトのプロトタイププロパティ Function.prototype

すべての関数は、prototypeプロパティを持っていますが、これはオブジェクトの [[Prototype]] ではありません。関数のデフォルトのprototypeconstructorというプロパティだけを持つオブジェクトで、それは関数自体を指します。関数のprototypeプロパティは、直接参照することができます。
コンストラクタ関数と new 演算子でインスタンスが作成されたときに、生成されたインスタンスの隠しプロパティ [[prototype]] に、コンストラクタ関数のprototypeプロパティのオブジェクトの参照がセットされます。デフォルトのままであれば、コンストラクタ関数自体が設定されます。
Objectもコンストラクタ関数なので、Object.prototype.constructorObject自身です。

参考文献

Web sites:

https://developer.mozilla.org/ja/docs/Web/JavaScript
https://ja.javascript.info

Books:

・改訂新版JavaScript本格入門 〜モダンスタイルによる基礎から現場での応用まで 山田祥寛 技術評論社
・改訂3版JavaScript本格入門 〜モダンスタイルによる基礎から現場での応用まで 山田祥寛 技術評論社
・JavaScriptモダンプログラミング完全ガイド 堅牢なコードを効率的に開発できる! Cay S Horstman インプレス
・JavaScript 第7版 David Flanagan オライリージャパン

Discussion