この投稿では、JavaScriptでclass
構文を用いる際に、メソッドを「メソッド定義」でプロトタイプメソッドとして生やした場合と、「フィールド宣言」を用いてフィールドとして生やした場合の違いについて説明します。
メソッド定義とフィールド宣言
JavaScriptでは、ECMAScript 2015からクラス宣言の構文が導入されました。
class Foo {
}
これと同時に、プロトタイプメソッドを定義するための「メソッド定義」の構文も追加されました。
class Foo {
method() {
}
}
また、「フィールド宣言」という新しい構文が提案されており、今後ECMAScriptに導入されることが有力視されています。
class Foo {
a = 0 // フィールド宣言
}
// 上は下と同じ意味
class Foo {
constructor() {
this.a = 0
}
}
TypeScriptでは、この新しいフィールド宣言にも対応しており、コンパイルするとコンストラクタによるフィールド初期化に変換してくれます:
class Foo {
a: number = 0 // フィールド宣言
}
// target=ES2020でのコンパイル結果↓
class Foo {
constructor() {
this.a = 0; // フィールド宣言
}
}
ちなみに、TypeScriptでは上のような変形をしますが、Node.js v12.4以降や最近のブラウザでは、フィールド宣言の文法をそのまま解釈できるようになっています。
本稿で比較するもの
インスタンスにメソッドを持たせる際、TypeScriptでもJavaScriptでもメソッド定義とフィールド宣言の2つの書き方がありえます。
メソッドを持たせるには、メソッド定義を使うのが普通です:
class A {
name = 'Aクラス'
x(): void {
console.log(this.name + 'のxメソッドが呼ばれました。')
}
}
const a = new A()
a.x()
//=> Aクラスのxメソッドが呼ばれました。
一方、あまり一般的ではないですが、フィールド宣言を用いてメソッドを生やすこともできるにはできます:
class A {
name = 'Aクラス'
x = (): void => {
console.log(this.name + 'のxメソッドが呼ばれました。')
}
}
const a = new A()
a.x()
//=> Aクラスのxメソッドが呼ばれました。
どちらの方法でメソッド定義しても、a.x()
のように呼び出せます。また、this
で自分のインスタンスにアクセスできる点も同じです。
では、メソッド定義とフィールド宣言の違いは全くないのでしょうか? この2つ事例別に比較していきたいと思います。
相違1: super
まず、大きな違いとして、理由は後述しますが、メソッド定義とフィールド宣言では、super
で継承元のメソッドを呼べる呼べないの違いがあります。
メソッド定義では、メソッドをオーバライドした場合に、super
で親のメソッドを呼び出すことができます:
class A {
x(): void {
console.log('Aクラスのxメソッドが呼ばれました。')
}
}
class B extends A {
x(): void {
console.log('Bクラスのxメソッドが呼ばれました。')
super.x()
}
}
const b = new B()
b.x()
//=> Bクラスのxメソッドが呼ばれました。
//=> Aクラスのxメソッドが呼ばれました。
一方のフィールド宣言では、super
を用いるとコンパイルは通るものの、実行時エラーが発生するケースがあります:
class A {
x = (): void => {
console.log('Aクラスのxメソッドが呼ばれました。')
}
}
class B extends A {
x = (): void => {
console.log('Bクラスのxメソッドが呼ばれました。')
super.x()
}
}
const b = new B()
b.x()
//=> TypeError: (intermediate value).x is not a function
(オーバーライドではなく、親クラスのメソッド定義によるメソッドは、子クラスのフィールド宣言メソッドからでも`super`で呼び出すことはできます)
class A {
x = () => {
console.log('Aクラスのxメソッドが実行されました')
}
y() {
console.log('Aクラスのyメソッドが実行されました')
}
}
class B extends A {
x = () => {
super.y()
}
y() {
throw new Error('これは呼ばれるべきでない')
}
}
const b = new B()
b.x()
//=> "Aクラスのyメソッドが実行されました"
こうした違いがあるのは、メソッド定義で生やしたメソッドが、プロトタイプに入るのに対し、フィールド宣言で生やしたメソッドはインスタンスだけのプロパティになるためです。
class C {
method1() {} // メソッド定義
method2 = () => {} // フィールド宣言
}
console.log(typeof C.prototype.method1) //=> "function"
console.log(typeof C.prototype.method2) //=> "undefined" ← フィールド宣言はプロトタイプにない
相違2: 自分が所有するプロパティ・列挙可能プロパティ
2つ目の違いとして、メソッドが自分が所有するプロパティ、かつ、列挙可能プロパティになるかどうかという違いがあります。
まず、メソッド定義の場合は、自分の所有するプロパティにも、列挙可能プロパティにもなりません:
class A {
x(): void {
}
}
const a = new A()
const 自分のプロパティか = a.hasOwnProperty('x')
console.log(自分のプロパティか) //=> false
const 列挙可能プロパティか = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(a), 'x')?.enumerable
console.log(列挙可能プロパティか) //=> false
一方のフィールド宣言によるメソッドは、自分の所有するプロパティになり、かつ、列挙可能プロパティにもなります:
class A {
x = () => void {
}
}
const a = new A()
const 自分のプロパティか = a.hasOwnProperty('x')
console.log(自分のプロパティか) //=> true
const 列挙可能プロパティか = Object.getOwnPropertyDescriptor(a, 'x')?.enumerable
console.log(列挙可能プロパティか) //=> true
この違いは、Object.keys
やObject.values
、Object.entries
などによる列挙可能プロパティを取り出す操作や、スプレッド演算子による操作に実行結果の違いが現れます。
class C {
method1() {} // メソッド定義
method2 = () => {} // フィールド宣言
}
const c = new C()
// 列挙可能プロパティを取り出す操作
const keys = Object.keys(c)
console.log(keys)
//=> ["method2"] ← フィールド宣言によるメソッドの名前
// スプレッド演算子による操作
const c2 = { ...c }
console.log(typeof c2.method1) //=> "undefined"
console.log(typeof c2.method2) //=> "function" ← フィールド宣言によるメソッド
相違3: メモリ使用量
相違1で、メソッド定義は関数がプロトタイプに入るのに対し、フィールド宣言はそうでないということを説明しましたが、これはメモリ使用量にも差が生まれる原因になります。
メモリ使用量を比べるために、メソッド定義版のクラスと、フィールド宣言版のクラスの2つのJavaScriptファイルを用意して、それぞれ100万インスタンス生成して、比較してみました。使用した実行環境はNode.js 14.4.0です。
まず、メソッド定義版のファイルです:
class A {
x() {}
}
const list = []
for (let i = 0; i < 1_000_000; i++) {
list.push(new A())
}
const used = process.memoryUsage();
for (let key in used) {
console.log(`${key} ${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB`);
}
実行結果は以下のようになりました:
$ node test1.js
Using v14.4.0
rss 76.83 MB
heapTotal 64.46 MB
heapUsed 41.14 MB
external 0.73 MB
arrayBuffers 0.01 MB
次に、フィールド宣言版のファイルです:
class A {
x = () => {}
}
const list = []
for (let i = 0; i < 1_000_000; i++) {
list.push(new A())
}
const used = process.memoryUsage();
for (let key in used) {
console.log(`${key} ${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB`);
}
実行結果は次のようになりました:
$ node test2.js
rss 161.05 MB
heapTotal 136.34 MB
heapUsed 109.49 MB
external 0.73 MB
arrayBuffers 0.01 MB
メモリ使用量をまとめると次の表のとおりになりました:
| メソッド定義版 | フィールド宣言版
---:|---:|---:
rss | 76.83 MB | 161.05 MB
heapTotal | 64.46 MB | 136.34 MB
heapUsed | 41.14 MB | 109.49 MB
external | 0.73 MB | 0.73 MB
arrayBuffers | 0.01 MB | 0.01 MB
以上の結果からも明らかなように、メソッド定義と比べて、フィールド宣言のほうはメモリを多く使うことが分かります。
このひとつの原因は、メソッド定義で生やしたメソッドは、1つしか関数オブジェクトが作られないのに対して、フィールド宣言で生やしたメソッドは、new
されるごとに新しい関数オブジェクトが作られるためと考えられます。新しい関数オブジェクトが生成されているかは、同一性を比較することで分かります:
class C {
method1() {}
method2 = () => {}
}
const c1 = new C()
const c2 = new C()
// メソッド定義によるメソッドの同一性
console.log(c1.method1 === c2.method1) //=> true
// フィールド宣言によるメソッドの同一性
console.log(c1.method2 === c2.method2) //=> false
メソッド定義で生やしたmethod1
はインスタンスが異なっていても、同じ関数オブジェクトになっていますが、フィールド宣言で生やしたmethod2
は、名前と実装が同じでも、インスタンスが異なると、別の関数オブジェクトになっています。
まとめ
本稿で行った比較をまとめると、次のようになります。
フィールド宣言で生やしたメソッドは、メソッド定義と比べて、
- メソッドオーバーライドでは
super
が使えない場合がある。 - プロトタイプに入らない。
- インスタンス自身が所有するプロパティになる。
- 列挙可能なプロパティになる。
- 複数インスタンスを生成する場合、メモリを多く使う。
以上が、メソッドをメソッド定義で生やした場合と、フィールド宣言で生やした場合の違いです。
特段理由がない限り、クラス構文でインスタンスメソッドを生やすときは、メソッド定義の構文を使ったほうが良さそうです。
最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→Twitter@suin