JavaScript
オブジェクト指向

JavaScriptのオブジェクト指向が10%理解できる記事(実践編)

最近、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.prototypespeakメソッドを持っているか確かめられます。今回我々は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.fooobj自身のプロパティ(やメソッド)を最初に探してその後プロトタイプを探しますが、obj.foo = ...という形で代入したプロパティ(やメソッド)は常にobj自身のプロパティとなります。objのプロトタイプにfooが存在したとしてもそれが上書きされるわけではなく、obj自身のプロパティfooが新設されます3。上の例ではmyAnimal.speak に代入しましたが、これは今まで存在しなかったmyAnimalspeakプロパティが新設されたのであって、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の場合と同じです。今回はCatAnimalを継承してほしいわけですが、その際に満たして欲しい要件は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.prototypeAnimalのインスタンスで上書きしているように読めます。(Animalの引数を渡していないのでnameundefinedになっていたりしますが、まあエラーは起きないので些細な問題です。)

これの意味を理解するには、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.prototypenew Animal()によって作られたAnimalインスタンスであったことを思い出してください。そう、Cat.prototypeのプロトタイプはAnimal.prototypeなのです。よって次はAnimal.prototypeに移譲され、これはsleepプロパティを持つので、結局myCat.sleepAnimal.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

さて、Animalclass構文を用いて作られた場合であっても、その裏の挙動というのは変わっていません。ソースコードには明示的に現れませんが、裏ではちゃんと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でオブジェクト指向を試すことができると思います。


  1. Animal関数が返り値としてオブジェクトを返した場合は違う挙動をしたりしますが、それは10%の範疇ではないので省略します。 

  2. 正確には、文字列だけでなくシンボルをキーにすることもできます。 

  3. 例外はプロトタイプのfooがsetterを持つプロパティだった場合です。この場合は即座にobj.fooが新設されるのではなくプロトタイプのfooのsetterプロパティが呼ばれます。 

  4. 当然ながらと言ってはなんですが、例外はあります。Proxyとかモジュール名前空間とか。しかし今回の話題には関係ないので省いています。 

  5. あと、実はinstanceofの挙動はSymbol.hasInstanceで制御できるという違いもあります。 

  6. 余談ですが、これと対になる存在として、普通の関数としてしか呼ぶことができず、newが使えない関数オブジェクトというのもES2015で導入されました。それはアロー関数です。他にasync関数などもnewが使えません。