LoginSignup
12

More than 5 years have passed since last update.

ECMAScript6(ES6)から使えるクラス構文の仕組みをBabelのES5変換から学んでみる

Last updated at Posted at 2017-10-07

ECMAScript6(ES6)から使える class構文によるクラス定義が使えるようになりましたが、これは普通の関数定義とどう違っているのでしょうか?

Babel - The compiler for writing next generation JavaScript はJavaScript用のコンパイラで、ES6の構文をES5の環境で動作するようにコンバートする機能があります。これを利用して、class構文がどう変換されるのか眺めて、その動作を理解していきましょう。

新しい技術は解説を読んで概要から理解するのが王道というか、まあ確実でお勧めな方法です。でもその後でかまいません、たまには別の視点、コードから眺めるとより理解が深まるかもしれません。特に開発者にとっては!

クラス定義

まずは以下のようにシンプルに、空の AnimalXYZクラスを定義します。変換後のコードで探しやすいよう、XYZをクラス名に含めています。

class AnimalXYZ {}

let animalXYZ = new AnimalXYZ();

BabelによるES5への変換結果がこちら。

var AnimalXYZ = function AnimalXYZ() {
  _classCallCheck(this, AnimalXYZ);
};

var animalXYZ = new AnimalXYZ();

ES5ではクラス定義が関数定義に変換されていますが、_classCallCheck() 関数が追加されていますね。

_classCallCheck関数

_classCallCheck関数の中身がこちら。

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

「クラスを関数として呼び出すことはできない」エラーを生成していますし、これは実行時に関数として実行させないためのチェックのようですね。

ES6に対応したChromeブラウザのコンソールで以下のようにクラスを作成し、関数として実行しようとするとエラーになります。これをES5環境で再現するための関数が _classCallCheck() なのでしょうね。

image.png

クラスの継承

次は継承をみてみましょう。さきほどのAnimalXYZクラスを継承してRatXYZクラスを定義してみます。ただ継承するだけで、なにも追加していません。

class RatXYZ extends AnimalXYZ {}

さきほどと同じシンプルなコードですが、BabelによるES5への変換結果はわりと長め。

var RatXYZ = function (_AnimalXYZ) {
  _inherits(RatXYZ, _AnimalXYZ);

  function RatXYZ() {
    _classCallCheck(this, RatXYZ);

    return _possibleConstructorReturn(this, (RatXYZ.__proto__ || Object.getPrototypeOf(RatXYZ)).apply(this, arguments));
  }

  return RatXYZ;
}(RatXYZ);

複雑にみえますが、エラーチェックやnullチェックをのぞけば、RatXYZ という関数を内部で定義して、親子関係を設定して返しているだけ、ですね。

使用されている2つの関数、_inherits と _possibleConstructorReturn の実装を見ていきましょう。

_inherits関数

_inherits() 関数は以下のようなコードです。

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      enumerable: false,
      writable: true,
      configurable: true
    }
  });
  if (superClass)
    Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

まずは指定された親クラス(superClass)のチェックをしていて、関数かnullでなければエラーを発生させています。

コンソールで以下のように、変なもの(今回は文字列)を親クラスに指定してクラス定義するとエラーになりますが、これを再現しているようです。

image.png

次に、実装するクラスの prototype を設定し、それに constructor として対象クラスを設定しています。以下のようなクラス階層を設定しているようです。

image.png

最後の Object.setPrototypeOf はオブジェクトのプロトタイプを設定する関数で、この関数があれば使用し、無ければ ._proto_ 属性に直接設定していますね。

_possibleConstructorReturn関数

function _possibleConstructorReturn(self, call) {
  if (!self) {
    throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
  }
  return call && (typeof call === "object" || typeof call === "function") ? call : self;
}

これは self として渡された this の null チェックを実施したうえで、call がオブジェクトもしくは関数、つまりプロトタイプが指定されていればそれを返し、指定されてなければ this を返す、というわりとシンプルな関数ですね。

関数名がベタに「使用可能なコンストラクタを返す」なのが少し楽しいです。

インスタンス変数

次はインスタンス変数を定義してみましょう。今度はAnimalXYZクラスを継承してDogXYZクラスを定義し、name という変数を設定しています。

class DogXYZ extends AnimalXYZ {
  constructor(_name) {
    super();
    this.name = _name;
  }
}

BabelによるES5への変換結果がこちら。

var DogXYZ = function (_AnimalXYZ2) {
  _inherits(DogXYZ, _AnimalXYZ2);

  function DogXYZ(_name) {
    _classCallCheck(this, DogXYZ);

    var _this3 = _possibleConstructorReturn(this, (DogXYZ.__proto__ || Object.getPrototypeOf(DogXYZ)).call(this));

    _this3.name = _name;
    return _this3;
  }

  return DogXYZ;
}(AnimalXYZ);

さきほどの RatXYZ とほぼ同じコードで、name変数への代入文が増えただけですね。

HumanXYZ の場合は初期化をする内部の関数では _possibleConstructorReturn関数の結果を、そのまま return で戻しているだけでした。

DogXYZ ではそれをいったん _this3 という変数に受け、_this3.name = _name; という行で処理をしてから、やはり return で戻しています。

※ HumanXYZ の変換のときに安易に _this3 という変数を使わない、つまり最適化して無駄なコードを生成しないのは、Babelのすごいとこですね。

クラス関数(クラスメソッド)

次はクラス関数を定義してみましょう。AnimalXYZクラスを継承してCatXYZクラスを定義し、crow() というクラス関数(static関数)を設定しています。

class CatXYZ extends AnimalXYZ {
  static crow() {
    return "myau";
  }
}

BabelによるES5への変換結果がこちら。

var CatXYZ = function (_AnimalXYZ) {
  _inherits(CatXYZ, _AnimalXYZ);

  function CatXYZ() {
    _classCallCheck(this, CatXYZ);

    return _possibleConstructorReturn(this, (CatXYZ.__proto__ || Object.getPrototypeOf(CatXYZ)).apply(this, arguments));
  }

  _createClass(CatXYZ, null, [{
    key: 'crow',
    value: function crow() {
      return "myau";
    }
  }]);

  return CatXYZ;
}(AnimalXYZ);

さきほどの RatXYZ とほぼ同じコードで、_createClass関数による処理が増えているだけですね。_createClass関数について見ていきましょう。

_createClass関数

_createClass関数のコードがこちら。

var _createClass = function () {
  function defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i];
      descriptor.enumerable = descriptor.enumerable || false;
      descriptor.configurable = true;
      if ("value" in descriptor)
        descriptor.writable = true;
      Object.defineProperty(target, descriptor.key, descriptor);
    }
  }
  return function (Constructor, protoProps, staticProps) {
    if (protoProps)
      defineProperties(Constructor.prototype, protoProps);
    if (staticProps)
      defineProperties(Constructor, staticProps);
    return Constructor;
  };
}();

まず内部で defineProperties() という関数が定義されているのが目をひきますが、これはオブジェクトに要素をセットする Object.defineProperty をまとめて実行してくれる便利関数ですね。

そして後半が実際の処理ですが、これは定義した defineProperties() を使って、指定された要素を定義しているだけですね。

ただ注意点として、第2引数である protoProps にリストされた要素は、第1引数のConstructor つまり今回は CatXYZ という内部関数に対して prototype を対象に設定を実施しています。

それに対して、第3引数である staticProps にリストされた要素は、第1引数のConstructor つまり今回は CatXYZ という内部関数に対して直接、設定を実施しています。

ここまで読んで疑問をもった方、私のお仲間です。最後の 補足の章 にもう少し情報がありますので、ぜひ読んでください。

インスタンス関数(メソッド)

さて最後は、次は通常のメソッド(インスタンス関数)も定義してみましょう。

AnimalXYZクラスを継承してHumanXYZクラスを定義し、nameという変数を定義し、それを使用したtalk() というメソッド関数(staticではない関数)を設定しています。

Dog の名前(name)を覚えることと、Car の鳴く(crow)こと、それを合わせた技が Human の話す(talk)動作である、って感じで。いや、単なるこじつけですが。

コードはこんな感じです。

class HumanXYZ extends AnimalXYZ {
  constructor(_name) {
    super();
    this.name = _name;
  }
  talk() {
    return "I am " + this.name;
  }
}

実行の様子はこんな感じ。

image.png

そして、BabelによるES5への変換結果がこちら。

var HumanXYZ = function (_AnimalXYZ4) {
  _inherits(HumanXYZ, _AnimalXYZ4);

  function HumanXYZ(_name) {
    _classCallCheck(this, HumanXYZ);

    var _this5 = _possibleConstructorReturn(this, (HumanXYZ.__proto__ || Object.getPrototypeOf(HumanXYZ)).call(this));

    _this5.name = _name;
    return _this5;
  }

  _createClass(HumanXYZ, [{
    key: 'talk',
    value: function talk() {
      return "I am " + this.name;
    }
  }]);

  return HumanXYZ;
}(AnimalXYZ);

DogXYZ と CatXYZ の変換結果を見てきた我々にとって、これは非常に見慣れたコードです。これまでとの違いを探すのが難しいくらいに…

注目する違いは一か所だけ、_createClass関数の引数です。CatXYZのときは _createClass(CatXYZ, null, [{...}]); だったコードが、今回は _createClass(HumanXYZ, [{...}]); と引数の数が違います。

_createClass関数の第2引数と第3引数は、設定する先の違い。今回は static ではない通常のメソッド関数ですから、第2引数のほうに指定してあるって訳です。

このあたりはちょっと難しいので、次の章で補足します。

補足:メソッドとクラスメソッドについて

_createClass関数のところで、メソッドとクラスメソッドについて実装の違いを見ましたが、皆さんはスッと理解できましたか?私は駄目でした。

「え?クラス関数のstaticPropsをConstructorにコピーするの?Constructor.prototypeじゃなくて?逆じゃないの???」

って、しばらく悩みました(笑)

これはES6に対応したChromeのコンソールで以下のように実行して、やっと理解できました。まずは赤枠に注意して見てください。

image.png

クラスメソッドの位置

まずクラスメソッドである staticFunction関数ですが、これは "sampleインスタンス"の __proto__ に指定されている "Sampleクラス" に定義されています。sampleインスタンスに対して sample.staticFunction() と呼び出すと、この関数定義が使用されます。

Sampleクラスのインスタンスを幾つ作成しても、それらのインスタンスの__proto__ に指定されている "Sampleクラス"は同じオブジェクトです。なのでstaticFunction関数のコードもこれ1つだけある状態です。

うん、これまさにクラスメソッド(クラス関数)ですよね。

通常メソッドの位置

さてそれに対して、通常のメソッド(インスタンスメソッド)である instanceFunction 関数ですが、2ヵ所に存在しているのがわかりますでしょうか?これがモヤモヤの原因です。

2ヵ所あっても実際に実行されるのはひとつ。sample.instanceFunction() と実行して使用されるのは下のほう、つまり"sampleインスタンス"に直接指定されているほうです。

じゃあ上のほうにあるやつ、"sampleインスタンス"の __proto__ に指定されている "Sampleクラス"の prototype に指定されているほう、は何でしょうか?これは「コピー元のオリジナル」なんですよね。

そして実際に呼び出される上のほう、"sampleインスタンス"に直接指定されているほうはインスタンス生成時にこのオリジナルから(同じ関数への参照を)コピーされた、その「インスタンス専用のメソッド関数」なんです。

インスタンス関数は、インスタンス生成時にインスタンスごとにコピーして生成されます。たった1つしかないクラス関数と違い、インスタンスの数だけコードが生成されています。これが大きな違いです。

※ 厳密に言えばコピー元がクラス定義のほうにあるので、インスタンスの数+1のコードが重複して存在しますね

例としてはあまり適切ではないのですが、わかりやすいので、以下のコンソール実行例をみてください。

image.png

まずSampleクラスのインスタンスを2つ、sample1 と sample2 を生成します。そして sample2 の instanceFunction だけ、別のコードで置き換えてしまいます。どうなるでしょう?

sample2 の実行結果は当然のながら "2" に置き換わります。しかし sample1は自分用の instanceFunction関数のコピーをそのまま維持していて、結果は "0" のまま変わりません。

まあ関数そのものではなく、関数への参照をコピーしているだけなので「関数のコードがインスタンスの数だけ増えてメモリを圧迫する」わけではありません。それは以下のように関数を比較してみるとわかります。

image.png

以上、クラス関数とインスタンス関数の違いについて理解し、_createClass関数のコードに関するモヤモヤが晴れてくれれば嬉しいです。

ライセンス

この投稿に含まれる私の作成したコード・画像・文章などは全て Creative Commons Zero ライセンスとします。自由にお使いください。

Enjoy!

以上、思ったより長くなりましたが… たまには概念からのトップダウンではなく、実際のコードからのボトムアップ的な理解があっても楽しいとおもいます。そんな楽しさの一部でも、お伝えできていると嬉しいのですが!

ではまた!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12