メインコンテンツへ移動

class 継承_ES6 ノート 12

無料2016-09-17#JS#js super#js extend#js new.target#js mixin#ES6继承

もし全文を ES5 から一歩一歩辿ってきたなら、ES6 の class を拒絶する理由はない。なぜなら、それは「どこで何を宣言すべきか」や「より優雅な継承実装」など、長年存在してきた多くの問題を解決したからだ

はじめに

[class_ES6 ノート 10](/articles/class-es6 ノート 10/) で classconstructorstatic などのキーワードがクラスの定義を簡素化すると述べました。よりシンプルな getter/setter や様々な関数属性の簡素化定義方式を含みます。これだけなら、ES6 のクラスはまだ何かが欠けているように思えます

ええ、継承です。ES5 之前には 6 種類の継承方案 があり、ES5 は公式版 beget メソッド(Object.create())を提供し、寄生組合せ継承へのサポートを示しました。ES6 は単にネイティブラベルを貼られたツール関数ではなく、より強力で便利な変革をもたらしたいと考えています

したがって、クラスの定義を簡素化するのはオブジェクト定義方式を簡素化する際に顺便に完了したもので、ES6 が本当に言いたいのは:今、私たちはより便利な継承メカニズムを提供するということです

一.どのように静的属性を継承するか?

よく思い出してください。JS 継承を議論する時、私たちは決して静的属性について言及していません。例えば:

function Super() {}
Super.staticAttr = 'static';
function Sub() {}
Sub.prototype = new Super();
console.log(Sub.staticAttr);  // undefined

このようにすれば当然静的属性を継承できません。これと類似した他の 5 種類の方案も静的属性の感受性を考慮していません。静的属性は JS 中の应用场景が多くなく、継承できなくても何も影響しませんが、継承メカニズムにとって、静的属性を継承できないのは多少欠憾と言えます

もし静的属性を継承する必要があるなら、方法はありますか?分析してみましょう:

// Function インスタンス Type を定義、いわゆる「クラス」
function Type() {}
// Function インスタンスのプロトタイプは当然所属クラス Function の prototype 属性が指すもの
Type.__proto__ === Function.prototype
// 逆に、クラス Type の prototype 属性が指すものは当然该类インスタンスのプロトタイプ
Type.prototype === new Type().__proto__
// したがって、これは 2 つの異なるもの
Type.prototype !== Type.__proto__

最後の行の左側の Type はカスタムクラスと呼ぶべきで、右側の TypeFunction インスタンスと呼ぶべきです。Type.prototype が指すものはカスタムクラス Type のすべてのインスタンスに影響し、それらはすべてこのものにアクセスできます;一方 Type.__proto__ が指すものは Type 所属クラスのすべてのインスタンスに影響し、Type 自体もその一つです。prototype は未来にのみ影響でき、__proto__ は歴史を改変します:

function SubType() {}
var p = new Type();
SubType.prototype = p;
Type.__proto__ = {a: 1};
console.log(new SubType().a);   // undefined
// p.__proto__ = {a: 1};
// console.log(new SubType().a);   // 1

各オブジェクトは内部属性 __proto__ を持ち、属性アクセス時のプロトタイプチェーン検索はこの属性を遡る過程です。上記の差異が存在する理由は、SubType インスタンスのプロトタイプチェーンと SubType 自体のプロトタイプチェーンの唯一の交点は Object.prototype で、その他は無関係だからです:

// SubType インスタンスのプロトタイプチェーン
new SubType().__proto__ === SubType.prototype
new SubType().__proto__.__proto__ === Type.prototype
new SubType().__proto__.__proto__.__proto__ === Object.prototype
new SubType().__proto__.__proto__.__proto__.__proto__ === null
// SubType 自体のプロトタイプチェーン
SubType.__proto__ === Function.prototype
SubType.__proto__.__proto__ === Object.prototype
SubType.__proto__.__proto__.__proto__ === null

new したインスタンスのプロトタイプチェーン上には基本的に Function.prototype この環は存在せず、除非そのある祖先のプロトタイプが function タイプですが、これはあまり可能性がなく、なぜならこれを行う理由が全くないからです:

// 除非このようにするが、この関数はどのように呼び出すのか?
Type.prototype = function() {};

今すべて清楚了。では静的属性を継承したいなら、私たちはもう一本のプロトタイプチェーンを修改すべきです:

Sub.__proto__ = Super;  // 静的属性を継承

最初の例で効果をテスト:

function Super() {}
Super.staticAttr = 'static';
function Sub() {}
Sub.prototype = new Super();    // インスタンス属性とプロトタイプ属性を継承
Sub.__proto__ = Super;          // 静的属性を継承
console.log(Sub.staticAttr);    // static

これは静的属性をサポートするシンプルなプロトタイプチェーン継承ですが、残念ながら、私たちはチートしました__proto__ という内部属性は広く互換ではなく、がっかりして白忙活したことに気づきます。这也是なぜ今まで静的属性の継承という話がなく、なぜなら私たちは直接 Function インスタンスのプロトタイプを修改する必要があり、しかしこれは本当に做不到だからです

ES6 はこの問題を解決しました。なぜなら完全な継承メカニズムを提供したいからです

二.プロトタイプ操作 API

ES6 は内部属性 __proto__ に代えて Object.get/setPrototypeOf() を提供します。例えば:

let obj = {};
Object.setPrototypeOf(obj, Array.prototype);
obj.push(1);
console.log(obj.pop());     // 1
console.log(obj.length);    // 0
console.log(obj instanceof Array);  // true

Object.setPrototypeOf() を通じて普通のオブジェクトのプロトタイプを直接修改し、それを配列のインスタンスに変えます

非常に強力ですが、注意が必要です:

Changing the [[Prototype]] of an object is, by the nature of how modern JavaScript engines optimize property accesses, a very slow operation, in every browser and JavaScript engine. The effects on performance of altering inheritance are subtle and far-flung, and are not limited to simply the time spent in obj.proto = ... statement, but may extend to any code that has access to any object whose [[Prototype]] has been altered. If you care about performance you should avoid setting the [[Prototype]] of an object. Instead, create a new object with the desired [[Prototype]] using Object.create().

Object.setPrototypeOf() - JavaScript | MDN から引用)

大意はこの API の性能が非常に悪いことで、手動でプロトタイプチェーンを篡改すると JS エンジンが属性アクセスを最適化できなくなり、もたらす性能影響は obj.__proto__ を篡改するよりずっと大きくなります。なぜなら篡改されたプロトタイプのオブジェクトにアクセスするすべてのコードが影響を受けるからです。性能を気にするなら、Object.create() で新しいプロトタイプを作成し、これをテンプレートとして必要なオブジェクトを作成することを推奨します

性能が悪いのは __proto__ を篡改するのが歴史を改変する(過去に穿越し、鍵人物を交換し、該点後のすべての歴史を改変する)からで、一方 Type.prototype を修改するのは性能が悪くありません。なぜなら未来を铺垫し、すでに存在するものに影響しないからです。古典的な例:

function Super() {
    this.key = 'value';
}
function Sub() {}
var obj1 = new Sub();
// 未来を铺垫
Sub.prototype = new Super();
var obj2 = new Sub();
console.log(obj1.key);  // undefined
console.log(obj2.key);  // value

prototype を修改するのはすでに存在するもの(obj1)に影響しないため、性能影響は自然に小さくなります。一方 __prototype__ は明らかに異なります:

function Super() {
    this.key = 'value';
}
function Sub() {}
var obj1 = new Sub();
// 歴史を改変
Sub.prototype.__proto__ = new Super();
var obj2 = new Sub();
console.log(obj1.key);  // value
console.log(obj2.key);  // value

2 つの例は対照試験ではありませんが、後者は obj の下下一環を修改しています。しかし私たちは成功して歴史を改変しました。一方 prototype を使うのは確かに做不到です(Object.prototype を修改?確かにできますが、このようにすれば、継承を議論する还有什么意味がありますか?)

したがって、継承には代価を払う必要があり、ネイティブクラスを継承する代価はさらに大きくなります(ネイティブクラス内部にはいくつかの盤根錯節したものがあり、しかも配列オブジェクトと普通オブジェクトのメモリ布局も異なります。考えるだけで大変です)

三.最も完璧な継承

ES5 で最も完璧な継承方式は寄生組合せ式で、以下の通り:

function Super(){
    // ここでのみ基本属性と参照属性を宣言
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 静的属性
//  ここで関数を宣言
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1.インスタンス属性 val, arr を継承
    Super.call(this);
    // ...
}
// 2.プロトタイプ属性 fun1, fun2 を継承
var proto = Object.create(Super.prototype);
proto.constructor = Sub;
Sub.prototype = proto;

完璧にインスタンス属性とプロトタイプ属性を継承し、プロトタイプ参照がサブクラスインスタンス間で共有される問題を回避し、余分な那份のインスタンス属性を切り離しました

しかしこれも同様に考慮していません静的属性の継承問題。手動で追加します:

function Super(){
    // ここでのみ基本属性と参照属性を宣言
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 静的属性
//  ここで関数を宣言
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1.インスタンス属性 val, arr を継承
    Super.call(this);
    // ...
}
// 2.プロトタイプ属性 fun1, fun2 を継承
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 3.静的属性を継承
Object.setPrototypeOf(Sub, Super);

ここで継承プロトタイプ属性の 3 句を 1 句に簡素化しました。比較してみましょう:

/* 以前の 3 句 */
// 1.Super.prototype を匿名オブジェクトのプロトタイプに包み、この匿名オブジェクトを返す
var proto = Object.create(Super.prototype);
// 2.constructor 属性を修正
proto.constructor = Sub;
// 3.サブクラスインスタンスに匿名オブジェクトプロトタイプ属性のアクセス権を獲得させる
// new Sub().__proto__ === proto
Sub.prototype = proto;

/* 1 句に簡素化 */
// 効果は上記 3 句と同等(サブクラスインスタンスが親クラスプロトタイプ属性のアクセス権を獲得)、実装方式は「下下一環」に類似
Object.setPrototypeOf(Sub.prototype, Super.prototype);  // Sub.prototype.__proto__ = Super.prototype と同等

このようにするのはコードを精簡する(3 行が 1 行に)以外、大きな意味はありません。性能を考慮すれば、さらにこの種の精簡をする理由がありません

しかし Object.setPrototypeOf(Sub, Super) は避けられません。これは ES6 まで、唯一合法的に真正な意味で「歴史を改変」できる手段です

静的属性継承サポートを追加した後の完全な例は以下の通り:

function Super(){
    // ここでのみ基本属性と参照属性を宣言
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 静的属性
//  ここで関数を宣言
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1.インスタンス属性 val, arr を継承
    Super.call(this);
    // ...
}
// 2.プロトタイプ属性 fun1, fun2 を継承
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 3.静的属性を継承
Object.setPrototypeOf(Sub, Super);

// test
var sub = new Sub();
console.log(sub.val);   // 1
console.log(sub.arr);   // [1]
console.log(Super.staticProp);  // 1
console.log(Sub.staticProp);    // 1

これが最も完璧な継承です。静的属性をサポートする寄生組合せ継承。しかし問題が存在します。「どこで何を宣言すべきか」は単なる道徳的制約で、この弱い制約は車間生産に不利です。私たちはより強い制約を必要とします

四.ES6 継承

私たちはすでに classstatic を学びました([class_ES6 ノート 10](/articles/class-es6 ノート 10/) を参照)。またどのように静的属性を継承するかも知っています。では ES6 の継承はこのようになるはずです:

class Super {
    constructor(sub) {
        console.log(sub);
        this.greeting = 'hello' + (sub && `, ${sub.name}`);
    }
}
class Sub {
    constructor() {
        // インスタンス属性を継承
        this.name = 'sam';
        return new Super(this);
    }
}
// プロトタイプ属性を継承
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 静的属性を継承
Object.setPrototypeOf(Sub, Super);

// test
console.log(new Sub().greeting);    // hello, sam

かなり簡潔になり、とても完璧に見えます。しかし:

console.log(new Sub() instanceof Super);    // true
console.log(new Sub() instanceof Sub);      // false

タイプが果然凌乱しました。手動で constructor を修改?あまりにも面倒です。ES6 もこの点に気づき、したがって extends キーワードを提供しました:

class Super {
    constructor() {
        // インスタンス属性
        this.val = 1;
        this.arr = [1];
    }
    // 静的属性
    static get staticProp() {
        return this._staticProp || 1;
    }
    static set staticProp(val) {
        this._staticProp = val;
    }
    // プロトタイプ属性
    fun1() {}
    fun2() {}
}
class Sub extends Super {
    // ...
}

// test
var obj = new Sub();
console.log(obj.val);    // 1
console.log(obj.arr);    // [1]
console.log(Super.staticProp);    // 1
console.log(Sub.staticProp);      // 1
console.log(obj instanceof Super);  // true
console.log(obj instanceof Sub);    // true

これでいくつかの新增キーワードの真正な作用が見えました。以下の通り:

  • 「どこで何を宣言すべきか」に対して強い制約を提供

  • 静的属性継承をサポート

  • 自動的にタイプを維持

class Sub extends Super 構文で、Super は他のクラス、プロトタイプ継承に基づく関数、一般関数、関数またはクラスを含む変数、オブジェクト上の某个属性、関数呼び出し可以是。さらに、Object.prototype から継承したくないなら extends null もできます。これは十分に大きな柔軟性を提供し、新しい構文を使って既存のクラスおよび第三者クラスを拡張することを簡単にします

継承メカニズムの基本要素が有了、自然にいくつかのより高級な需要が生まれます。例えば、どのように親クラス属性にアクセスするか?どのように親クラスコンストラクタにパラメータを渡すか?

祖先クラス属性にアクセス

super キーワードを通じて祖先クラス属性にアクセスし、親クラスコンストラクタを呼び出せます。以下の通り:

class A {
    constructor(name) {
        // インスタンス属性
        this.name = name;
    }
    // プロトタイプ属性
    fn() {
        console.log('fn at A');
    }
    // 静的属性
    static get staticProp() {
        return this._staticProp || 1;
    }
    static set staticProp(val) {
        this._staticProp = val;
    }
}
class B extends A {
    constructor(name) {
        super(name.toUpperCase());
        super.fn();
    }
    fn() {
        super.fn();
    }
}
var b = new B('BextendsfromA'); // fn at A
console.log(b.name);    // BEXTENDSFROMA

super はサブクラス中で定義された属性をスキップし、直接サブクラスプロトタイプから查找を開始します

内部は依然としてプロトタイプチェーン查找なので、super がアクセスできる祖先クラス属性はプロトタイプ属性に限定され、祖先クラスの静的属性とインスタンス属性にアクセスできません。例えば:

class B extends A {
    constructor(name) {
        super(name.toUpperCase());
        super.fn();
        // サブクラスインスタンスのプロトタイプからプロトタイプチェーン查找を行うので、当然もう一本のプロトタイプチェーン上にある祖先クラス静的属性は見つからない
        console.log(super.staticProp);  // undefined
        // プロトタイプチェーンを通じて祖先クラスのインスタンス属性を見つけようとするのはさらに不可能で、明らかに `this.key` で探す必要がある(インスタンス属性の継承方式は値コピーで、属性アクセス権を持有するのではない)
        console.log(super.key);         // undefined
    }
    ...
}

注意superthis に很像に見えます。this は内置の変数名のようで、異なる作用域で異なる値を指す可能性があります。しかし super は異なります。superキーワードです:

typeof super;   // Uncaught SyntaxError: 'super' keyword unexpected here
super instanceof SuperType; // 同上

super.xxx/super['xxx'] で親クラス属性にアクセスするか、super() で親クラスコンストラクタを呼び出すかのいずれかで、他の形式はすべて非法です

ネイティブタイプの拡張をサポート

もし CharArray extends Array なら、Array.isArray() 検測は true を返すべきで、instanceof 検測は true を返すべきで、しかも slice などのメソッドも CharArray を返すべきです(Chrome47 は Array を返しますが、現在 53 はすでに正しく CharArray を返せます)。例は以下の通り:

class CharArray extends Array {
    constructor(str) {
        console.log(typeof str, str);
        if (str.length > 1) {
            // まずインスタンス [] を作成、否则 this がエラー
            // Uncaught ReferenceError: this is not defined
            super();
            // 次に push
            super.push.apply(this, str.split(''));
        }
        else {
            super(str);
        }
    }
    toUpperCase() {
        return this.map(function(c) {
            return c.toUpperCase();
        });
    }
}
var ca1 = new CharArray('abcde');   // string abcde
var ca2 = new CharArray('c');       // string c
console.log(ca1);   // ["a", "b", "c", "d", "e"]
console.log(ca2);   // ["c"]
console.log(ca1.slice(1));  // ["b", "c", "d", "e"] number 4
console.log(ca1.toUpperCase());  // ["A", "B", "C", "D", "E"] number 5
console.log(Array.isArray(ca1));    // true
console.log(ca1 instanceof CharArray);  // true
//! 理論上 true を返すべき
console.log(ca1.slice(2) instanceof CharArray);  // true number 3
console.log(ca1 instanceof Array);      // true

ネイティブクラスの拡張クラスは本物のように既存のすべてのタイプ検測に通過できる以外、継承したすべてのネイティブメソッドも同様の動作を持つべきです。上記コード中の一つの鍵となる点は console.log(typeof str, str); で、私たちが予想する最初のパラメータは string であるべきですが、実際には時々このパラメータは number です(slice() を呼び出す時)。これはネイティブ slice() はおそらくこのようであることを示しています:

Array.prototype.slice = function(start) {
    // start が負数の情況は暫く考慮しない
    let size = this.length - start;
    let res = new Array(size);
    for (let i = 0; i < size; i++) {
        res[i] = this[start + i];
    }
    return res;
}

new Array(size) が時々コンストラクタが number を受け取る理由です。これはカスタムの偽物と本物が内部メカニズムが完全に一致していることを示しています

サブクラスインスタンスの作成過程

サブクラス constructor 中で、thissuper() を呼び出して獲得する必要があります。super() の前に this を使用すると ReferenceError がエラーになります(この制約は Java クラスのコンストラクタ中で、super() が必ず第一行に位置するのと類似の道理)。例えば:

class C {}
class D extends C {
    constructor() {
        // this.a = 1; // Uncaught ReferenceError: this is not defined
        super();    // C のデフォルトの空コンストラクタを呼び出す
        // 他のオブジェクトを直接 return することも可能。this を使わない
        // return {a: 1};
    }
}
// test
console.log(new D());

サブクラス constructor を定義するのは JS に「サブクラスインスタンスを作成することは私たちに任せて、あなたに管理してもらう必要はない」と伝えることです

したがってサブクラスコンストラクタ中で、私たちは親クラスコンストラクタを呼び出して適切なインスタンスを作成し、祖先属性を借用してインスタンスを初期化できます。さらに乱暴には、this を放棄し、手動で無関係な他のオブジェクトを返して、new 演算の結果とすることもできます

もちろん、constructor を定義しないこともできます。サブクラスインスタンスの作成過程を気にしないことを示し、その場合该类の空白インスタンスが作成されます

this にアクセスする前に必ず super() を呼び出すのは理所当然です。否则 this の構造が不確定です(Array か普通オブジェクトか?)

new.target

祖先クラスコンストラクタ中で new.target の値を検測して誰が該コンストラクタを呼び出しているかを知ることができます

サブクラスコンストラクタは必ず先に super() を実行してから this を獲得できるため、某些兄弟サブクラスは本質的な差異が存在します(例えば Array と一般オブジェクトのメモリ布局が異なります)。したがって親類はどの種類のオブジェクトを呼び出し者の this として返すべきかを知る必要があり、new.target はこの問題を解決するためにあります。例えば:

class E {
    constructor() {
        switch(new.target) {
            case E:
                console.log('call from E');
                break;
            case F:
                console.log('call from F');
                break;
            case G:
                console.log('call from G');
                break;
            case H:
                console.log('call from H');
                break;
            default: break;
        }
    }
}
// 継承樹を建立
//     E
//    / \
//   F   G
//       |
//       H
class F extends E {}    // デフォルトで親クラスコンストラクタを呼び出す
class G extends E {}
class H extends G {}
// test
new E();    // call from E
new F();    // call from F
new G();    // call from G
new H();    // call from H

P.S.new.target は任意の関数中で合法です。もし関数が new を通じて呼び出されない場合、new.targetundefined に賦値されます

基類がサブクラス情報を知る必要があるのは理解しにくく、抽象が逆に具体に依存しているように感じます。では基類は何を new したいサブクラスを知る必要があるのか?

上記の通り、なぜなら

某些兄弟サブクラスは本質的な差異が存在する

差異がこんなに大きいなら、なぜ 2 つの基類として分離しないのか?Java でこのようにすれば確かに問題を解決できますが、JS はできません。なぜならJS の継承樹は只有一个根ノード——Object だからです。したがって Object は私たちが普通 Objectnew したいのか Arraynew したいのかを知る必要があります

说白了就是因为 JS がすべてのものを Object この根ノードの下に掛けたため、「兄弟サブクラスの差異が巨大」という問題が発生し、そして new.target の解決方案が生まれました。一方Java の継承樹は不止一个根ノードなので、この問題は存在しません

五.多重継承

class extends Mixin という方式があります:

function mix(...mixins) {
    class Mix {}
    function copy(target, source) {
        // 命名関数には name 属性がある
        let filterSet = new Set(['constructor', 'prototype', 'name']);
        // for (let key of Reflect.ownKeys(source)) {
        for (let key in source) {
            // if (!filterSet.has(key)) {
            if (source.hasOwnProperty(key) && !filterSet.has(key)) {
                let desc = Object.getOwnPropertyDescriptor(source, key);
                Object.defineProperty(target, key, desc);
            }
        }
    }

    for (let mixin of mixins) {
        // もし mixin が「クラス」なら、その��的属性とプロトタイプ属性を「継承」
        // もし mixin が普通オブジェクトなら、そのインスタンス属性を「継承」
        copy(Mix, mixin);
        copy(Mix.prototype, mixin.prototype);
    }

    return Mix;
}

以前の mix/extends 方案は複数のオブジェクトを揉み合わせて一つのオブジェクトにするもので、今は一歩進んで、複数の mixin を揉み合わせて一つのクラスにします。このクラスのインスタンスはテンプレート*「クラス」*の静的属性とプロトタイプ属性およびテンプレートオブジェクトのインスタンス属性を「継承」できます

注意:「クラス」に二重引用符が付いているのはです。なぜならここでのクラスは class キーワードで定義されたクラスを指すのではなく、function キーワードで定義されたタイプを指すからです。違いは前者は列挙不可(这也是 class封裝性の体现)で、copy 方案が失效し、那就什么都「継承」できなくなります

効果をテストしてみましょう:

let obj = {key: 'value'};
function Type() {}
Type.staticProp = 'static value from Type';
Type.prototype.fn = function() {
    return 'proto fn from Type';
};
let M = mix(obj, Type);
console.log(M.key);         // value
console.log(M.staticProp);  // static value from Type
console.log(new M().fn());  // proto fn from Type

怎么说、効果が有限に感じます。以前の mix/extends 方案より少し強力ですが、効果が比較的に有限です。特に mixinclass キーワードで定義されたクラスをサポートしないため、この方案はさらに発展するのが困難です

したがって、JS は依然として多重継承がありません。

六.まとめ

もし全文を ES5 から一歩一歩辿ってきたなら、ES6 の class を拒絶する理由はありません。なぜなら、それは「どこで何を宣言すべきか」や「より優雅な継承実装」など、長年存在してきた多くの問題を解決したからです

参考資料

  • 『ES6 in Depth』:InfoQ 中文站が提供する無料電子書籍

コメント

コメントはまだありません

コメントを書く