最近、1人が読めば50人がオブジェクト指向を理解できそうな素晴らしい記事が書かれたのは皆さんの記憶に新しいことと思います。それを読んでオブジェクト指向を理解した皆さんは次はぜひオブジェクト指向を実践してみたいと思ったことでしょう。
この記事ではJavaScriptにスポットを当てて、JavaScriptにおけるオブジェクト指向に入門します。JavaScriptはWebページやWebアプリの制作において未だ必修言語の地位を占めているといっても良い言語ですが、その利用法には人や業種によって温度差があります。何なら、JavaScriptのオブジェクト指向的機能を(明示的には)使わなくてもある程度のものが作れてしまうという人もいることでしょう。そこで、そのような人たちでもJavaScriptでオブジェクト指向的なプログラムを書く基本が分かる記事を目指します。
対象読者はJavaScriptをやっているけどオブジェクト指向的なプログラムをあまり書かない人やJavaScriptをやっていないけどJavaScriptでオブジェクト指向的なプログラムをどう書くのか気になる人などです。この記事ではJavaScriptの言語機能を中心に解説しますので、オブジェクト指向とは何かみたいな情報は他の記事をご参照ください。
クラスベースとプロトタイプベース
言語機能の話に入る前に、少しだけオブジェクト指向の分類の話をします。オブジェクト指向のプログラミング言語がクラスベースとプロトタイプベースに分類されるという説は古くから有名で、JavaScriptは後者のプロトタイプベースに属する言語の代表格となっています。
ですので、JavaScriptには本来(ES2015まで)クラスという用語はありませんでした。JavaScriptが採用していたプロトタイプベースでは、オブジェクトは他のオブジェクト(プロトタイプ)への参照を保持しており、自身で処理できないもの(メソッドなど)はプロトタイプに移譲します。これは、クラスベースの言語においてオブジェクトがクラスへの参照を持っており、自身が行うべき処理がクラスによって決められるのと対照的です。
その一方で、JavaScriptにはnew
のようにクラスベース言語っぽい構文も持ち合わせており、クラスベース的な考え方でコードが書けるようにもなっています。これがJavaScriptの難しいところです。
原始時代のJavaScriptとオブジェクト指向(ES3)
いきなり最新鋭の記法を紹介してもよいのですが、JavaScriptにおけるオブジェクト指向をより理解してもらうために、古い書き方から見ていくことにします。例えば、Animalクラスとそれを継承したCatクラスを作ってみることにしましょう。(先程も説明したようにこの時代のJavaScriptにはクラスという概念は無いですが、便宜上クラスという呼び方をすることにします。)
// Animalクラスの定義
function Animal(name) {
this.name = name;
}
// Animalクラスのspeakメソッドを定義
Animal.prototype.speak = function() {
console.log(this.name + "「鳴き声」");
};
// Animalクラスのsleepメソッドを定義
Animal.prototype.sleep = function() {
console.log(this.name + "「zzz」");
};
// Catクラスの定義
function Cat(name) {
// 親クラスのコンストラクタを実行
Animal.call(this, name);
}
// 親クラスを継承
Cat.prototype = new Animal();
// speakメソッドを定義
Cat.prototype.speak = function() {
console.log(this.name + "「にゃーん」");
};
// Animalのインスタンスを作成
var myAnimal = new Animal("ポチ");
myAnimal.speak(); // "ポチ「鳴き声」"
myAnimal.sleep(); // "ポチ「zzz」"
// Catのインスタンスを作成
var myCat = new Cat("たま");
myCat.speak(); // "たま「にゃーん」"
myCat.sleep(); // "たま「zzz」"
この例ではspeakメソッドとsleepメソッドを持ったAnimalクラスとCatクラスを作り、それぞれのインスタンスを試しに作っています。では、順番に見ていきましょう。
クラスの定義
// Animalクラスの定義
function Animal(name) {
this.name = name;
}
これがAnimalクラスの定義ですが、このfunction
構文はどうみても関数定義ですね。そう、再三述べている通りJavaScriptにはクラスなんてものはなく、代わりに使うのが関数なのです。この関数は、クラスベースでいうところのコンストラクタの役割を果たすものです。(とはいえ、純粋なプロトタイプベース言語ではコンストラクタも必要ないでしょうから、コンストラクタという概念もJavaScriptをクラスベース言語に近づけている要員のひとつです。)
このようにして作ったコンストラクタ(のようなもの)はnew
演算子で呼ぶことができます。上の例のようにnew Animal("ポチ")
とすると、Animal関数がコンストラクタとして呼ばれて、Animalのインスタンスが返ってきます。Animal関数ではthis.name
に代入する処理を行っているので、返ってきたインスタンスはname
プロパティを持っています。
var myAnimal = new Animal("ポチ");
console.log(myAnimal.name); // "ポチ"
面白いのは、クラスにするつもりが無くても関数を作った時点でそれはnew
可能になるということです。言うまでもなく、こんな無意味なことは誰もやらないでしょうが。
// ログを表示するだけの関数
function log(message) {
console.log(message);
}
// logのインスタンスを作れる!!!!!!
var myInstance = new log("foobar");
逆に、クラスにするつもりで作った関数を普通に呼ぶこともできます。この場合も、this
がグローバルオブジェクトだったりundefined
だったりするのであまり意味はありませんが。
メソッドの定義
さて、クラス(のようなもの)なのでやはりメソッドを作りたいですね。メソッドの定義部分は以下のようになっています。
// Animalクラスのspeakメソッドを定義
Animal.prototype.speak = function() {
console.log(this.name + "「鳴き声」");
};
// Animalクラスのsleepメソッドを定義
Animal.prototype.sleep = function() {
console.log(this.name + "「zzz」");
};
このコードを読むと、Animal.prototype
に対してメソッドを生やしています。prototype
という名前からも分かる通り、このAnimal.prototype
はAnimalのインスタンスのプロトタイプとなるオブジェクトです。
実は、関数を定義すると、自動的に付随するprototype
オブジェクトが用意されるのです。今回の場合、Animal
関数を定義したと同時に、Animal.prototype
オブジェクトが空のオブジェクトとして用意されています。このAnimal.prototype
に定義したメソッドがAnimal
インスタンスのメソッドとして使用できる理由は、new Animal("ポチ")
としたときに何が起こるかを理解すれば分かります。
new Animal("ポチ")
は、まずその結果となる新しいオブジェクトを生成し、そのオブジェクトのプロトタイプをAnimal.prototype
に設定します。その後、そのオブジェクトをthis
としてAnimal
関数を引数"ポチ"
で呼び出した後、生成した新しいオブジェクトを返します1。
太字になっている部分がポイントです。この操作によって、生成されるオブジェクトがAnimal
のインスタンスとして特徴づけられます。では、Animal
インスタンスのメソッドを呼び出したときの挙動を確認しましょう。
まず前提の確認ですが、JavaScriptのオブジェクトというのはいわゆる連想配列です。オブジェクトというのは、文字列をキーとして任意の値を格納することができる構造なのです2。また、それに加えて、自分のプロトタイプはどのオブジェクトかという情報を持っています。Animal
インスタンスとして作成されたオブジェクトそれ自身は、実はspeak
メソッドやsleep
メソッドを持っていません。このことはhasOwnPropertyを用いて確かめられます。
var myAnimal = new Animal("ポチ");
console.log(myAnimal.hasOwnProperty("speak")); // false
それにも関わらず、myAnimal.speak
とすれば先ほど定義したspeak
メソッドを呼び出すことができます。これがまさに、プロトタイプのおかげなのです。
myAnimal.speak
という参照が行われた場合、まずmyAnimal
オブジェクト自身がspeak
メソッドを持っているかどうかが調べられます。先程確かめたように、今回のオブジェクトはspeak
メソッドを持っていません。そうなると、プロトタイプへの移譲が行われます。myAnimal
のprototypeはAnimal.prototype
だったので、次にAnimal.prototype
がspeak
メソッドを持っているか確かめられます。今回我々はAnimal.prototype.speak
に関数を代入したので、これは存在しています。よって、この値がmyAnimal.speak
の結果として扱われるのです。
以上が、プロトタイプによってAnimal
インスタンスが特徴づけられる仕組みです。
ところで、今の説明を鑑みるに、myAnimal.speak
に別の関数を代入してやれば挙動を書き換えられることは明らかですね。ちょっとやってみましょう。
var myAnimal = new Animal("ポチ");
console.log(myAnimal.hasOwnProperty("speak")); // false. myAnimal自身はspeakを持たない
// myAnimal.speakに代入
myAnimal.speak = function() {
console.log(this.name + "「わんわん」");
};
console.log(myAnimal.hasOwnProperty("speak")); // true
myAnimal.speak(); // "ポチ「わんわん」"
このように、生成したインスタンスの挙動を後から差し替えることができます。これはプロトタイプベースなオブジェクト指向の特徴が現れていると言えるでしょう。
なお、今さらな補足ですが、obj.foo
はobj
自身のプロパティ(やメソッド)を最初に探してその後プロトタイプを探しますが、obj.foo = ...
という形で代入したプロパティ(やメソッド)は常にobj
自身のプロパティとなります。obj
のプロトタイプにfoo
が存在したとしてもそれが上書きされるわけではなく、obj
自身のプロパティfoo
が新設されます3。上の例ではmyAnimal.speak
に代入しましたが、これは今まで存在しなかったmyAnimal
のspeak
プロパティが新設されたのであって、Animal.prototype.speak
には触れていません。よって、他のAnimal
インスタンスには影響を与えないことになります。
継承
以上の説明でJavaScriptにおけるクラス(のようなもの)をどう作るのか分かりました。次の話題は継承です。継承はやはり(特にクラスベースの)オブジェクト指向には欠かせない概念でしょう。最初の例からCat
の定義を再掲します。
// Catクラスの定義
function Cat(name) {
// 親クラスのコンストラクタを実行
Animal.call(this, name);
}
// 親クラスを継承
Cat.prototype = new Animal();
// speakメソッドを定義
Cat.prototype.speak = function() {
console.log(this.name + "「にゃーん」");
};
Cat
クラスを作りたいのでfunction Cat(name){ ... }
としてCat
関数を作るのはAnimal
の場合と同じです。今回はCat
にAnimal
を継承してほしいわけですが、その際に満たして欲しい要件は2つです。1つは、Cat
のコンストラクタが呼ばれるとAnimal
のコンストラクタが呼ばれること、そしてもう1つはAnimal
のメソッドがCat
のメソッドとしても使用可能なことです。もちろん、Animal
のメソッドをCat
が上書きしたり、Cat
がメソッドを増やしたりしても構いませんが。
まず1つ目はCat
コンストラクタの処理の中で実現されています。Animal.call
というのは関数が持つメソッドで、this
の値と引数を指定して関数を呼び出すというものです。要するにこれは、Cat
が呼び出されたら同じthis
と引数でAnimal
関数を呼び出すということを行っています。これにより1つ目の要件が達成されます。
2つ目はCat.prototype = new Animal();
の行で行われています。これをそのまま読むとCat.prototype
をAnimal
のインスタンスで上書きしているように読めます。(Animal
の引数を渡していないのでname
がundefined
になっていたりしますが、まあエラーは起きないので些細な問題です。)
これの意味を理解するには、Cat
のインスタンスに対するメソッド呼び出しがどうなるのかを考えるのが早いでしょう。
まず、Cat
が再定義したメソッドであるspeak
については簡単です。myCat.speak();
とすると、まずmyCat
自身はspeak
メソッドを持たないので、myCat
のプロトタイプであるCat.prototype
に移譲されます。Cat.prototype.speak
は存在するのでこれが呼び出されます。
では、sleep
を呼び出したらどうなるでしょうか。myCat.sleep();
とした場合、myCat
自身はsleep
メソッドを持たないのでmyCat
のプロトタイプであるCat.prototype
に移譲されます。先ほどとは異なり、Cat.prototype
自身はsleep
メソッドを持っていません。JavaScriptにおいて、自分が持っていないプロパティを自身のプロトタイプに移譲するというのはどのオブジェクトにも共通する挙動です4。なので、次はCat.prototype
のプロトタイプへの移譲が発生します。
ここで、Cat.prototype
はnew Animal()
によって作られたAnimal
インスタンスであったことを思い出してください。そう、Cat.prototype
のプロトタイプはAnimal.prototype
なのです。よって次はAnimal.prototype
に移譲され、これはsleep
プロパティを持つので、結局myCat.sleep
はAnimal.prototype.sleep
に解決されます。
このように、子クラスのprototypeから親クラスのprototypeへ移譲を発生させることによって継承の挙動を実現しているのです。
以上で原始時代(ES3時代)におけるオブジェクト指向の説明は終わりです。以上の機能だけを武器にJavaScriptプログラマーは戦っていました。(__proto__とかいう裏技はありましたが。)
古き良き時代のJavaScriptにおけるオブジェクト指向(ES5)
さて、時代は進み、プロトタイプ周りの言語機能が強化されたES5が登場します。ES5の時代は、次のバージョンであるES2015が登場するまで続きました。
ES5時代においても先ほどのコードは大きく変化することはありませんでしたが、ES5の新機能によっていくつかの改良点が発生しました。
Object.create
先ほどの実装においてまず目につく問題点がどこにあったかというと、Cat.prototype = new Animal();
のところですね。
もしAnimal
に副作用(ログの表示とか)があった場合、Cat
を定義するときにAnimal
が呼ばれるのは本意ではありません。つまり、ここではAnimal
のインスタンス(Animal.prototype
をプロトタイプに持つオブジェクト)が欲しいのであって、Animal
コンストラクタを呼びたいわけではないのです。そこで、この問題の解決策としてES5で導入されたのがObject.create
です。これは、引数で与えられたオブジェクトをプロトタイプとして持つ新しいオブジェクトを作成して返すというものです。つまり、new
の機能のうち前半(特定のプロトタイプを持つ新しいオブジェクトを作成)のみを行い、後半(新しいオブジェクトをthis
としてコンストラクタを呼ぶ)を行わないようなものになっています。これはどちらかと言えば純粋なプロトタイプベースに回帰するような機能ですね。
これを用いることで、問題の部分はこのように書き直せます。
Cat.prototype = Object.create(Animal.prototype);
プロパティの属性
これは本筋とは少し離れますが、これまでのようなメソッド定義方法(Animal.prototype.speak = ...
)は少し問題がありました。それは、そのように定義したメソッドはfor-in文やObject.keys
で列挙されてしまうという問題です。
for-in文は、オブジェクトに存在するキー全てに対してループできる文です。
var obj = {foo: 3, bar: "😇"};
for (var key in obj) {
console.log(key + " : " + obj[key]);
}
/*
* foo : 3
* bar : 😇
*/
これは、オブジェクトに直接存在するキーだけでなく、そのプロトタイプ(やそのまたプロトタイプ)に存在するキーも列挙してくれます。
var base = {hoge: "ほげ"};
var obj = Object.create(base);
obj.foo = 3;
obj.bar = "😇";
for (var key in obj) {
console.log(key + " : " + obj[key]);
}
/*
* foo : 3
* bar : 😇
* hoge : ほげ
*/
しかし、そうなるとおかしいですね。実は普通のオブジェクトはObject
のインスタンスです。すなわち、Object.prototype
をプロトタイプに持ちます。そして、Object.prototype
にはいくつかメソッドが存在します(先程出てきたhasOwnPropertyなど)。それにも関わらずそれらは列挙されませんでした。
これは、それらのメソッドのenumerable属性がfalseであるからです。オブジェクトのプロパティはいくつかの属性を持ち、それによりプロパティの挙動が制御されています。enumerable属性は、それがfor-in文などで列挙されるかどうかを制御する属性です。基本的に組み込みオブジェクトのメソッドはfor-in文の邪魔にならないように、enumerable属性がfalseになっています。
一方で、我々が普通に(Animal.prototype.speak = ...
のように代入して)作成したプロパティはenumrable属性がtrueです。ということは、Animal
のインスタンスに対してfor-in文を使うとspeak
メソッドなどが列挙されてしまうということです。実際、下の例で確かめるとname
, speak
, sleep
の3つが列挙されてます。
var myAnimal = new Animal("ポチ");
for (var key in myAnimal) {
console.log(key); // "name", "speak", "sleep"が表示される
}
name
はまあいいですね(Animal
コンストラクタ内でthis.name = ...
として代入しているので)。しかし、プロトタイプチェーンに存在するメソッドが出てくるのは邪魔なので望ましくありません(本当に邪魔かどうかは意見が分かれるかもしれませんが、少なくとも組み込みのオブジェクトの挙動とは違います)。
実は、ES3では我々が作るプロパティのこうした属性を制御できなかったのです。ES5ではそれを制御する方法が追加されました。例えばenumrable属性がfalseのプロパティ(やメソッド)を作るにはObject.definePropertyを使います。
Object.defineProperty(Animal.prototype, {
configurable: true,
enumrable: false,
writable: false,
value: function() {
console.log(this.name + "「鳴き声」");
},
});
この例ではconfigurable属性とwritable属性も明示的に指定しています。これらの意味はここではやりませんので気になる方は自分で調べてみてください。
また、先ほど出てきたObject.create
には、新しいオブジェクトの作成と同時にこれらの属性を指定しつつ新しいプロパティを生やすことが可能な機能もついています。これを用いると、ES3時代のコードは結局以下のように書き直せます。
// Animalクラスの定義
function Animal(name) {
this.name = name;
}
Animal.prototype = Object.create(Object.prototype, {
speak: {
configurable: true,
enumerable: false,
writable: false,
value: function() {
console.log(this.name + "「鳴き声」");
},
},
sleep: {
configurable: true,
enumerable: false,
writable: false,
value: function() {
console.log(this.name + "「zzz」");
},
},
});
// Catクラスの定義
function Cat(name) {
// 親クラスのコンストラクタを実行
Animal.call(this, name);
}
// 親クラスを継承してspeakメソッドを定義
Cat.prototype = Object.create(Animal.prototype, {
speak: {
configurable: true,
enumerable: false,
writable: false,
value: function() {
console.log(this.name + "「にゃーん」");
},
},
});
その他の便利機能
やや余談ですが、ES5ではプロトタイプ関連の便利な機能がいくつか追加されています。
例えば、Object.getPrototypeOfは、あるオブジェクトのプロトタイプとなっているオブジェクトを返すメソッドです。これを使うと、Animal
インスタンスのプロトタイプが本当にAnimal.prototype
であることを確かめることができます。
var myAnimal = new Animal("ポチ");
console.log(Object.getPrototypeOf(myAnimal) === Animal.prototype); // true
逆にObject.prototype.isPrototypeOf`は、渡されたオブジェクトが自身をプロトタイプとしているかどうかを調べることができます。
var myAnimal = new Animal("ポチ");
console.log(Animal.prototype.isPrototypeOf(myAnimal)); // true
instanceof
ここでinstanceof
演算子に触れておきます。これはES3時代からある演算子で、あるオブジェクトがあるクラス(コンストラクタ)のインスタンスであるかどうか判定する演算子です。次のように使います。
var myAnimal = new Animal("ポチ");
console.log(myAnimal instanceof Animal); // true. myAnimalはAnimalのインスタンスなので
console.log(myAnimal instanceof Cat); // false
var myCat = new Cat("ねこ");
console.log(myCat instanceof Animal); // true. myCatはAnimalを継承しているCatのインスタンスなので
この演算子も、内部的にはオブジェクトのプロトタイプがどれかという情報を使っています。先ほど紹介したisPrototypeOf
との違いは、継承関係を考慮してくれる(ループを回すことによってプロトタイプのプロトタイプに自身があるような場合も発見してくれる)点です5。
プロトタイプの終端
先程、普通のオブジェクトはObject.prototype
を持つと述べました。
var ordinaryObj = {};
console.log(Object.getPrototypeOf(ordinaryObj) === Object.prototype); // true
もちろん、Object.prototype
それ自体も、いくつかのプロパティを持っていることから分かるようにやはりオブジェクトです。では、Object.prototype
のプロトタイプは何でしょうか。
console.log(Object.getPrototypeOf(Object.prototype)); // null
これをやってみると、null
になります。つまり、Object.prototype
はプロトタイプを持たない例外的なオブジェクトなのです。
プロトタイプを持たないオブジェクトに対してプロパティを探してもなお存在しなかった場合、undefined
が返ります。JavaScriptの挙動として有名である「オブジェクトの存在しないプロパティにアクセスするとundefinedが返る」というのは、実は「オブジェクト自身のプロパティを探す→無いのでObject.prototype
のプロパティを探す」→「それでも無いのでundefined
を返す」という段階を踏んでいることが分かりますね。
このようなプロトタイプを持たないオブジェクトはObject.create
を使って自分で作ることもできます。そのためにはObject.create(null)
とします。
var noPrototypeObj = Object.create(null);
noPrototypeObj.foo = 3;
console.log(noPrototypeObj.foo); // 3
console.log(noPrototypeObj.hasOwnProperty); // undefined
このオブジェクトは先ほど説明したとおり、自身が持っているプロパティ以外には全てundefinedを返します。普通のオブジェクトはhasOwnProperty
などのObject.prototype
が持っているプロパティ全てを間接的に持っていましたが、プロトタイプが無いオブジェクトはそのようなプロパティすら持っていません。
このようなオブジェクトは、オブジェクトを本当に連想配列として使いたい場合に適しているという説もあります。
現代のJavaScriptにおけるオブジェクト指向(ES2015~)
ここまで長々と説明してきましたが、今どきのJavaScriptでとりあえず動くものを書きたいならここから上の説明は要りません。残念でしたね。
ES2015からはクラス構文が導入されたことで、JavaScriptにおけるオブジェクト指向のやり方が様変わりしました。とりあえず、今までの例をES2015風に書き直すとこうなります。
// Animalクラスの定義
class Animal {
// コンストラクタの定義
constructor(name) {
this.name = name;
}
// speakメソッドの定義
speak() {
console.log(this.name + "「鳴き声」");
}
// sleepメソッドの定義
sleep() {
console.log(this.name + "「zzz」");
}
}
// Catクラスの定義
class Cat extends Animal {
// コンストラクタの定義
constructor(name) {
// 親クラスのコンストラクタを実行
super(name);
}
// speakメソッドを定義
speak() {
console.log(this.name + "「にゃーん」");
}
}
// もちろんAnimalやCatは今までと同様に使用可能
// Animalのインスタンスを作成
var myAnimal = new Animal("ポチ");
myAnimal.speak(); // "ポチ「鳴き声」"
myAnimal.sleep(); // "ポチ「zzz」"
// Catのインスタンスを作成
var myCat = new Cat("たま");
myCat.speak(); // "たま「にゃーん」"
myCat.sleep(); // "たま「zzz」"
今までの面影は全くなく、どこからどう見てもクラスベース言語ですね。prototypeとかいうよくも分からぬ奇怪な言語仕様は影も形もありません。
まずクラスを定義するには今までのようにfunction
宣言で関数を定義するのではなく、class
宣言という専用の構文を用います。その中身はメソッド定義の羅列になっていて、それに混ざったconstructor
という定義でコンストラクタ(new
時に呼び出される関数)を定義します。なお、constructor
の定義は省略可能であり、その場合はコンストラクタは何もしない関数になります。
今までに比べて記述量が減ってすっきりした見た目ですね。
継承についても、class
宣言時にextends
構文を用いることで指示することができます。継承において特徴的なのは、constructor
内にあるsuper
呼び出しです。コンストラクタ内でsuper
を呼び出すことで、これは親クラスのコンストラクタを呼び出すものとして扱われます。従来のAnimal.call(this, name)
に比べるとだいぶ分かりやすいですね。なお、継承を行う場合、コンストラクタ内におけるsuper
呼び出しは必須です。つまり、子クラスのコンストラクタ内で必ず親クラスのコンストラクタを呼ばないといけないということです。
ES2015クラスの裏側
この機能はES2015で追加されたので「ES2015クラス」などと呼ばれることがあります。たいへん画期的な機能ですが、実際のところはほとんど従来のプロトタイプベースな機構の糖衣構文です。つまり、prototype
が過去のものとして捨て去られて全く新しいクラスベースの機構が導入されたというよりは、プログラマが表面上prototype
を意識しなくてもオブジェクト指向的なコードが書けるように改善されたということです。
せっかくこの記事の前半で長々とprototype
に関する説明をしたところですので、ES2015クラスの裏側がどうなっているのか調べてみましょう。といっても、結局今までと同じですねということを確かめるだけですが。
まずそもそも、従来はクラスはfunction Animal(name) { ... }
のように定義されていました。ということは、Animal
は関数オブジェクトなのです。このことは例えばtypeof
演算子で確かめられます。
console.log(typeof Animal === "function"); // true
ES2015のclass
構文で定義されたクラスも、やはりその実は関数オブジェクトです。ただし、class
で作られた関数オブジェクトは**new
を使わないと呼び出せない**という特徴があります。
というのも、function
宣言で作られる従来方式のクラスは結局ただの関数なので、それが関数なのかクラスなのかはプログラマの意図次第です。ですから、普通の関数としても呼び出せるし、new
を用いてクラス(コンストラクタ)としても呼び出せます。
一方、class
で作られたクラスは、関数オブジェクトであるといえどもクラスとして用いることが意図されているのは明白です。ですから、Animal()
のようにただの関数として呼び出すことはできません。これは、class
が完全な糖衣構文ではない点のひとつです(他の機能ではこのような関数オブジェクトを作ることは(少なくとも現時点では)できません。)6
さて、Animal
がclass
構文を用いて作られた場合であっても、その裏の挙動というのは変わっていません。ソースコードには明示的に現れませんが、裏ではちゃんとAnimal.prototype
が用意されています。
// Animal.prototypeにspeakメソッドが存在するか確かめる
console.log(Animal.prototype.hasOwnProperty("speak")); // true
当然ながら、Animal
のインスタンスは従来どおりAnimal.prototype
をプロトタイプに持ちます。
var myAnimal = new Animal("ポチ");
console.log(Object.getPrototypeOf(myAnimal) === Animal.prototype); // true
継承の仕組みが従来どおりであることも確かめられます。
console.log(Object.getPrototypeOf(Cat.prototype) === Animal.prototype); // true
ちなみに、class
構文で作られたメソッドはちゃんとenumrable属性がfalseに設定されています。さすがですね。
staticメソッド
class
構文にはstaticメソッドを作成する機能もあります。Animal
クラスにstaticなmakeChild
メソッドを作ってみましょう。
class Animal {
// コンストラクタの定義
constructor(name) {
this.name = name;
}
// speakメソッドの定義
speak() {
console.log(this.name + "「鳴き声」");
}
// sleepメソッドの定義
sleep() {
console.log(this.name + "「zzz」");
}
static makeChild(parent) {
return new this(`${parent.name}ジュニア`);
}
}
var parent = new Animal("ポチ");
parent.sleep(); // ポチ「zzz」
var child = Animal.makeChild(parent);
child.sleep(); // ポチジュニア「zzz」
このように、クラスに対して定義されたstaticメソッドはそのクラスオブジェクト自身のメソッドとなります。この例ではAnimal.makeChild
としてこれを呼び出しています。staticメソッドとして定義されているメソッドは組み込みオブジェクトにも結構あります(Array.from
とか)ので、使う機会があることでしょう。
Object.setPrototypeOf
余談ですが、ES2015で追加されたprototype関係メソッドとしてObject.setPrototypeOfがあります。これはたいへんやばいメソッドであり、名前から分かる通り、オブジェクトのプロトタイプを後から書き換えることができるというものです。
var myCat = new Cat("たま");
myCat.speak(); // たま「にゃーん」
// myCatのプロトタイプをAnimal.prototypeに書き換える
Object.setPrototypeOf(myCat, Animal.prototype);
myCat.speak(); // たま「鳴き声」
まあ何かの使い道があるから用意されたのでしょうが、これを使うのはとてもやばいので、本当に必要ない限りは避けたほうがよいでしょう。オブジェクトを作るときにプロトタイプが決まっているならObject.create
でいいですしね。
ちなみに、これを使ってプロトタイプが無限ループするオブジェクトを作成しようとするとエラーになります。偉いですね。
var loopObj = {};
// loopObjのプロトタイプをloopObj自身にセットしようとするとエラー
Object.setPrototypeOf(loopObj, loopObj);
未来のJavaScriptにおけるオブジェクト指向
※ここからの内容はまだ確定していない言語仕様に関する記述です。今後大きく変化したり立ち消えになったりする可能性もありますので、ぜひ鵜呑みにせずにご自分で情報収集を行ってください。
JavaScript(というかECMAScript)の仕様策定においては将来JavaScriptに採用すべき言語機能の提案や議論がプロポーザルという形で公開されています。まだJavaScriptへの採用が決定していないものの、将来的に入りそうな機能というのはいくつかあります。その中でクラス関連のものを紹介します。
プライベートプロパティ・プライベートメソッド (Stage 3)
今後のJavaScriptにおけるオブジェクト指向関連の流れは、最後に紹介したclass
構文を強化する方向で進んでいきそうです。その中で最も実現が近そうなのはプライベートプロパティ・メソッドでしょう。多くのクラスベース言語では、プロパティやメソッドをprivateとすることによってそれらを同クラス内からしか参照できないようにする機能があります。一方、JavaScriptはオブジェクトは所詮ただの連想配列+αであるという立場からか、これまでそのような機能はありませんでした。しかし、ES2015でクラスベースっぽい構文を導入したことをきっかけにそのような機能に目を向けるようになったのでしょう。
プライベートプロパティは以下のように#
を用いて宣言したりアクセスしたりします。
class Animal {
#age = 0;
constructor(name) {
this.name = name;
}
speak() {
console.log(`${this.name}「鳴き声」`);
this.#age++;
}
}
これは1回鳴くごとに1歳年をとる変な動物のオブジェクトです。しかもプロパティ#age
はプライベートプロパティなので、外から年を知ることはできず意味がありません。このobj.#prop
というように#
をつける構文は独特でなかなかびっくりさせられますね。プライベートなプロパティ名は普通のプロパティ名とは独立した名前空間を持っているようです。そのためobj['#age']
のようにしても同じ意味にはなりません。
また、staticフィールドが可能になったりstaticでprivateなメソッドも可能になったりしそうです。
その他の関連しそうなプロポーザルを列挙しますが、Stage 2以下なので何年先か分からないし大きく変わったり消えることも普通にありそうですから、そんなに期待せずにのんびり待ちましょう。競合しているやつもありますしね。
-
First-class protocols:
class Foo implements SomeInterface { ... }
みたいな構文が入るやつです。 -
Maximally minimal mixins:
mixin M { ... }
みたいな感じでmixinを定義してclass Foo with M { ... }
みたいな感じで使うやつです。 -
Class static block:
class
の定義時にstaticなスコープ内で初期化を実行できるやつです。 -
Class access expressions
class
の定義内でclass
と書くとそのclass
にアクセスできるやつです。
まとめ
この記事では言語機能の説明に特化し、原始時代から未来に至るまでのJavaScriptにおけるオブジェクト指向のやり方を概観しました。これで、オブジェクト指向の概念を99%理解してオブジェクト指向をやってみたくなった皆さんも、とりあえずJavaScriptでオブジェクト指向を試すことができると思います。
-
Animal
関数が返り値としてオブジェクトを返した場合は違う挙動をしたりしますが、それは10%の範疇ではないので省略します。 ↩ -
正確には、文字列だけでなくシンボルをキーにすることもできます。 ↩
-
例外はプロトタイプの
foo
がsetterを持つプロパティだった場合です。この場合は即座にobj.foo
が新設されるのではなくプロトタイプのfoo
のsetterプロパティが呼ばれます。 ↩ -
当然ながらと言ってはなんですが、例外はあります。Proxyとかモジュール名前空間とか。しかし今回の話題には関係ないので省いています。 ↩
-
あと、実は
instanceof
の挙動はSymbol.hasInstance
で制御できるという違いもあります。 ↩ -
余談ですが、これと対になる存在として、普通の関数としてしか呼ぶことができず、
new
が使えない関数オブジェクトというのもES2015で導入されました。それはアロー関数です。他にasync関数などもnew
が使えません。 ↩