JavaScriptにおける継承のパターン4種類の概要と対比


JavaScriptはとても強力な言語です。強力がゆえ、実はプロトタイプをデザインしたり、オブジェクトのインスタンスを生成したりするのに何種類もやり方があります。それぞれの方法には長所も短所もあります。そこでJavaScript初心者の皆さんのために私がそのあたりを整理して説明したいと思います。今回の投稿は、以前私が書いた「JavaScriptを分類するな」の続編です。前回の投稿でたくさんのコメントをいただき、コードの例を出してほしいをいう反応をいただいたので、今回はそれらにお応えします。

JavaScriptはプロトタイプを使って継承

つまり、JavaScriptでは、オブジェクトは別のオブジェクトを継承することができます。{ }波括弧を使って生成されるJavaScriptの基本的なオブジェクトは、唯一のプロトタイプとしてObject.prototypeをプロトタイプに持ちます。Object.prototypeはそれ自身もオブジェクトであり、Object.prototypeのメンバ全てが、全てのオブジェクトからアクセスが可能です。

[ ] 角括弧を使って生成された、基本的な配列は、Object.prototypeArray.prototypeなどの数種類のプロトタイプを持ちます。つまり、Object.prototypeArray.prototypeの全てのメンバは、配列のメンバ同様にアクセス可能なのです。.valueOftoStringのように、重複するメンバは一番近いプロトタイプによってオーバーライドされます。今回の場合はArray.prototypeです。

プロトタイプの定義とオブジェクトのインスタンス化

メソッド1:コンストラクタパターン

JavaScriptは、コンストラクタ関数という特殊な関数を持っています。これは他の言語のコンストラクタと似た働きをします。コンストラクタは強制的にnewキーワードをつけて呼び出され、コンストラクタ関数によって生成されたオブジェクトにthis キーワードをbindします。典型的なコンストラクタは以下です。

function Animal(type){
  this.type = type;
}
Animal.isAnimal = function(obj, type){
  if(!Animal.prototype.isPrototypeOf(obj)){
    return false;
  }
  return type ? obj.type === type : true;
};
function Dog(name, breed){
  Animal.call(this, "dog");
  this.name = name;
  this.breed = breed;
}
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
Dog.prototype.bark = function(){
  console.log("ruff, ruff");
};
Dog.prototype.print = function(){
  console.log("The dog " + this.name + " is a " + this.breed);
};
Dog.isDog = function(obj){
  return Animal.isAnimal(obj, "dog");
};

このコンストラクタは、別の言語のインスタンス化のやり方に似ています。

var sparkie = new Dog("Sparkie", "Border Collie");
sparkie.name;    // "Sparkie"
sparkie.breed;   // "Border Collie"
sparkie.bark();  // console: "ruff, ruff"
sparkie.print(); // console: "The dog Sparkie is a Border Collie"
Dog.isDog(sparkie); // true

barkprintは全てのDogに適用されるプロトタイプのメソッドです。namebreedのプロパティはコンストラクタによって設定された自身のプロパティです。通常は、全てのメソッドがプロトタイプ内に設定され、全てのプロパティはコンストラクタによって設定されます。

メソッド2:ES2015 (ES6) のclassの定義

classは、初期のころからずっと、将来的に使用されるJavaScriptの予約済みキーワードという位置づけでしたが、今では実際に使われるようになりました。JavaScriptにおけるclassの定義は、他の言語での定義と大変よく似ています。

class Animal {
  constructor(type){
    this.type = type;
  }
  static isAnimal(obj, type){
    if(!Animal.prototype.isPrototypeOf(obj)){
      return false;
    }
    return type ? obj.type === type : true;
  }
}
class Dog extends Animal {
  constructor(name, breed){
    super("dog");
    this.name = name;
    this.breed = breed;
  }
  bark(){
    console.log("ruff, ruff");
  }
  print(){
    console.log("The dog " + this.name + " is a " + this.breed);
  }
  static isDog(obj){
    return Animal.isAnimal(obj, "dog");
  }
}

このシンタックスは多くの人が好んで使います。理由は、このシンタックスがコンストラクタ、スタティック、プロトタイプメソッド宣言を、1つの優れたブロックの中に兼ね備えているからです。使い方はコンストラクタメソッドと全く同じです。

var sparkie = new Dog("Sparkie", "Border Collie");

メソッド3:明示的なプロトタイプの宣言、Object.create、ファクトリメソッド

このメソッドでは、classシンタックスの後に、プロトタイプ継承を実際に表示することができます。また、newキーワードを省くことが可能です。

var Animal = {
  create(type){
    var animal = Object.create(Animal.prototype);
    animal.type = type;
    return animal;
  },
  isAnimal(obj, type){
    if(!Animal.prototype.isPrototypeOf(obj)){
      return false;
    }
    return type ? obj.type === type : true;
  },
  prototype: {}
};
var Dog = {
  create(name, breed){
    var dog = Object.create(Dog.prototype); 
    Object.assign(dog, Animal.create("dog"));
    dog.name = name;
    dog.breed = breed;
    return dog;
  },
  isDog(obj){
    return Animal.isAnimal(obj, "dog");
  },
  prototype: {
    bark(){
      console.log("ruff, ruff");
    },
    print(){
      console.log("The dog " + this.name + " is a " + this.breed);
    }
  }
};
Object.setPrototypeOf(Dog.prototype, Animal.prototype);

このシンタックスは大変優れていて、プロトタイプがとても明示的に定義されます。どちらがプロトタイプのメンバで、どちらがオブジェクトのメンバかがはっきりと分かります。また、Object.createの優れている点は、ある特定のプロトタイプからオブジェクトを生成できるところです。どちらの場合も、.isPrototypeOfによるチェックは可能です。使い方は異なりますが、大きな違いではありません。

var sparkie = Dog.create("Sparkie", "Border Collie");
sparkie.name;    // "Sparkie"
sparkie.breed;   // "Border Collie"
sparkie.bark();  // console: "ruff, ruff"
sparkie.print(); // console: "The dog Sparkie is a Border Collie"
Dog.isDog(sparkie); // true

メソッド4:Object.create、top-levelファクトリ、プロトタイプポスト宣言

これはメソッド3をわずかに変えたメソッドです。このメソッドでは、ファクトリはクラス、対クラスはファクトリメソッドを含むオブジェクトとなっています。コンストラクタの例(メソッド1)に似ていますが、代わりにファクトリとObject.createを使います。

function Animal(type){
  var animal = Object.create(Animal.prototype);
  animal.type = type;
  return animal;
}
Animal.isAnimal = function(obj, type){
  if(!Animal.prototype.isPrototypeOf(obj)){
    return false;
  }
  return type ? obj.type === type : true;
};
Animal.prototype = {};
function Dog(name, breed){
  var dog = Object.create(Dog.prototype);
  Object.assign(dog, Animal("dog"));
  dog.name = name;
  dog.breed = breed;
  return dog;
}
Dog.isDog = function(obj){
  return Animal.isAnimal(obj, "dog");
};
Dog.prototype = {
  bark(){
    console.log("ruff, ruff");
  },
  print(){
    console.log("The dog " + this.name + " is a " + this.breed);
  }
};
Object.setPrototypeOf(Dog.prototype, Animal.prototype);

このメソッドの優れている点は、使い方はメソッド1と似ているのに、newキーワードを要求せず、instanceof.を使うところです。newを用いないで、メソッド1と同じやり方をすれば使えます。

var sparkie = Dog("Sparkie", "Border Collie");
sparkie.name;    // "Sparkie"
sparkie.breed;   // "Border Collie"
sparkie.bark();  // console: "ruff, ruff"
sparkie.print(); // console: "The dog Sparkie is a Border Collie"
Dog.isDog(sparkie); // true

比較

メソッド1 vs メソッド4

メソッド4ではなくメソッド1を使う理由はほとんどありません。メソッド1では,
コード内でnewを使うか、コンストラクタで以下のようにチェックしなければいけません。

if(!(this instanceof Foo)){ 
  return new Foo(a, b, c);
}

この場合も、ファクトリ内でObject.createを使うだけでいいかもしれません。また、thisがめちゃくちゃになってしまうので、コンストラクタ関数上でFunction#callFunction#applyを使うこともできません。上記のチェックをすれば、この問題が是正されますが、可変長の引数を用いたい場合は、ファクトリを使わなければいけません。

メソッド2 vs メソッド3

コンストラクタとnewに関し、上記で適用したのと同じ理由がこの比較にも適用されます。instanceofチェックは、classシンタックスをnewなしで、あるいはFunction#applyFunction#callを用いて使う場合に不可欠です。

私の見解

プログラマは明瞭なコードを追求すべきです。メソッド3の明示的なシンタックスは、非常にはっきりと何が行われているのかを表しています。またそれは容易な複数継承と連結継承を可能にします。newキーワードの使用は、applycallと適合せず、開放/閉鎖原則を破ることになるので、避けましょう。classキーワードは、プロトタイプを使うJavaScriptの継承の性質を、古典的なシステムに見せかけて隠しています。

“シンプルは器用に勝る”、だから、より”洗練されている”とされる古典的なシンタックスを使う、というのは、不必要な技術コストです。

Object.createは、bindされたthis変数とnewよりもさらに直感的で明確です。また、プロトタイプはファクトリそのもののスコープ外にあるオブジェクトの中に格納され得るので、修正と改善がより簡単です。ちょうどES6クラスのようにメソッドと定義シンタックスを使うこともできます。

classキーワードは、おそらくJavaScriptの中で最も有害な特徴ではないか。私は、規格化の作業に携わった、才気に富み、過酷な努力をいとわない人々に多大なる敬意を払うが、たとえ才気に富む人々でも時には過ちを犯すものだ。 - Eric Elliott

不必要な、場合によっては破壊的な何かを追加し、言語の本来の性質に逆らうのは間違った動きです。
あなたがclassの利用を選ぶなら、私は自分がそのコードのメンテナンスに携わらなくて済むように願います。私の考えでは、開発者はコンストラクタ、classnewの利用を避け、より密接に言語の構造に沿った継承メソッドを使うべきです。

用語集

Object.assign(a, b)は、オブジェクトbの全てのenumerableなプロパティをオブジェクトa上にコピーし、オブジェクトaを返します。
Object.create(proto) は、protoをプロトタイプに持つ新規オブジェクトを生成します。
Object.setPrototypeOf(obj, proto) objの内部[[Prototype]]プロパティを、protoに設定します。