Пару недель назад (статья написана в августе — прим. перев.) мы описывали новую систему классов в ES6 в тривиальных случаях создания конструктора объекта. Мы показали, как можно писать код типа такого:

class Circle {
    constructor(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    };

    static draw(circle, canvas) {
        // Canvas drawing code
    };

    static get circlesMade() {
        return !this._count ? 0 : this._count;
    };
    static set circlesMade(val) {
        this._count = val;
    };

    area() {
        return Math.pow(this.radius, 2) * Math.PI;
    };

    get radius() {
        return this._radius;
    };
    set radius(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Circle radius must be an integer.");
        this._radius = radius;
    };
}

К сожалению (как некоторые заметили), у нас не было тогда времени чтобы поговорить о всей мощи ES6 классов. Как и в традиционных системах классов (а-ля Java и С++), в ES6 возможно наследование, когда один класс берет за базу другой и расширяет его.

Давайте посмотрим поближе на возможности этой фичи.

До того, как мы пойдем дальше, будет полезно потратить немного времени на рассмотрение особенностей наследования свойств и dynamic prototype chain (динамической цепочки прототипов).

Наследование в Javascript


Когда мы создаем объект, мы можем положить в него свойства (properties), также он наследует свойства своих прототипов. Javascript-программисты знакомы с существующим Object.create API, который позволяет легко делать что-нибудь вроде такого:

var proto = {
    value: 4,
    method() { return 14; }
}

var obj = Object.create(proto);

obj.value; // 4
obj.method(); // 14


Далее, когда мы добавляем свойства в obj с таким же именем как и в proto, они перекрывают (shadow) изначальные.

obj.value = 5;
obj.value; // 5
proto.value; // 4


Основы наследования


Памятуя об этом, мы можем увидеть, как соединить цепь прототипов объекта, созданного классом. Помните, что когда мы создаем класс, мы создаем новую функцию, соответствующую constructor методу в дефиниции (определении) класса, содержащую все статические методы. Также мы создаем объект, который будет свойством property у созданной нами функции, и который будет содержать все методы инстанса (экземпляра) класса. Чтобы создать новый класс, который наследует все статические свойства мы должны заставить новый объект-функцию наследоваться от объекта-функции суперкласса. Похожим образом, мы должны заставить объект prototype новой функции наследоваться от prototype суперкласса, чтобы обеспечить наследование методов инстанции класса.

Это достаточно дремучее объяснение. Давайте попробуем показать на примере, как можно связать объекты без нового синтаксиса, а потом сделаем тривиальное дополнение, чтобы сделать вещи эстетически приятнее.

Продолжая предыдущий пример, допустим что у нас есть класс Shape, для которого мы хотим создать подкласс (subclass):

class Shape {
    get color() {
        return this._color;
    }
    set color(c) {
        this._color = parseColorAsRGB(c);
        this.markChanged();  // перерисуем canvas позже
    }
}

Когда мы пишем код таким образом, мы сталкиваемся с той же проблемой что и раньше со static свойствами: у нас нет синтаксического способа изменить прототип функции при дефиницировании (определении). Хотя это можно обойти с помощью Object.setPrototypeOf, этот подход в целом менее быстродейственный и имеет меньшие возможности по оптимизации для JS-движков, нежели когда у нас есть возможность задать прототип функции при ее создании.

class Circle {
    // См. выше
}

// Связываем свойства инстанса
Object.setPrototypeOf(Circle.prototype, Shape.prototype);

// Связываем статические свойства
Object.setPrototypeOf(Circle, Shape);

Это достаточно уродливо. Мы добавили синтакс классов для инкапсуляции всей логики в одном месте, чтобы не нужно было делать никакого «связывания» после. Java, Ruby и остальные ОО-языки имеют синтаксис для декларирования одного класса как подкласса другого, поэтому должен и Javascript. Мы используем ключевое слово extends чтобы мы могли писать вроде:

class Circle extends Shape {
    // См. выше
}

Вы можете поставить любое выражение после extends, пока у него есть валидный конструктор со свойством prototype. Например, подойдут:
  • Другой класс
  • Классоподобные функции с различными механизмами наследования
  • Обычная функция
  • Переменная, содержащая функцию или класс
  • Доступ к свойству объекта (A property access on an object)
  • Вызов функции

Можно использовать null, чтобы не наследоваться от Object.prototype.

Super-свойства


Итак, мы можем создавать подклассы, наследовать свойства и иногда наши методы перекрывают (shadow) методы родителя. А что если мы хотим обойти механизм перекрывания?
Допустим, мы хотим написать подкласс Circle который хэндлит (обрабатывает) скалирование круга на заданное число. Чтобы достигнуть этого, мы можем написать достаточно неестественный класс:

class ScalableCircle extends Circle {
    get radius() {
        return this.scalingFactor * super.radius;
    }
    set radius() {
        throw new Error("ScalableCircle radius is constant." +
                        "Set scaling factor instead.");
    }

    // Код, где хэндлится scalingFactor
}

Заметьте, что геттер свойства radius использует super.radius. Это новое ключевое слово super позволяет нам искать свойство начиная с прототипа, игнорируя любое перекрытие (shadowing), которое у нас может быть.

Доступы к super-свойствам (super[expr], кстати, тоже работает) могут быть использованы в любой функции, которая объявлена с помощью синтаксиса дефинирования (определения) методов. Хотя эти функции могут быть вытащены из изначального объекта, доступы привязаны к тому объекту, где метод был дефинирован (определен) первый раз. Это значит, что «вытаскивая» метод в локальную переменную (ох уж этот Javascript — прим. перев.), мы не изменим поведение super-доступа.

var obj = {
    toString() {
        return "MyObject: " + super.toString();
    }
}

obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]


От переводчика: для удобства перевода (и чтобы посмотреть, насколько тема актуальна для сообщества) опять разделю статью на 2 примерно равные части.

Комментарии (6)


  1. Arilas
    02.11.2015 20:36

    По поводу последнего(сохранение привязки), откуда оригинальный автор это взял? В стандарте я чего-то этого не нашел, и если бы это было, то это потеря обратной совместимости. В том же Babel принудительно методы из инстанса отвязывают от контекста с помощью (0, improtedObject.method)(...args). Всегда контекст теряется, не важно, из класса или из объекта.

    var obj = {
        toString() {
            return "MyObject: " + super.toString();
        }
    }
    
    console.log(obj.toString()); // MyObject: [object Object]
    var a = obj.toString;
    console.log(a()); // MyObject: [object Undefined]
    

    P.S. Да, там Undefined с большой.


    1. Ununtrium
      02.11.2015 23:07

      Только что в хроме попробовал, MyObject: [object Object] и MyObject: [object global] соответственно. А в FF super не работает :(


      1. rock
        02.11.2015 23:30

        Оба результата верны, разгадка в strict mode.


        1. Arilas
          02.11.2015 23:49

          Не совсем, this — теряется 100%(и с 'use strict' и без), сам прототип нет, вот пример:

          class Some {
            toString() {
              return "MyObject: " + super.toString()
            }
          }
          class Another extends Some {
            toString() {
              return "Another: " + super.toString()
            }
          }
          var obj1 = new Another();
          console.log(obj.toString()); // Another: MyObject: [object Object]
          var a = obj1.toString;
          console.log(a()); // Another: MyObject: [object Undefined]
          

          То-есть оно оставляет привязанным прототип, но никак не this.
          Если бы он оставлял одинаковый this — это было бы ошибкой, так как теряется совместимость со старыми версиями. (Если нужно оставить this — юзайте bind)

          В стандарте я такой вещи не видел, хотя просматривал много ее (особенно когда еще стандарт был в драфте)


          1. rock
            02.11.2015 23:58

            Я что-то говорил про привязанный this? :) Смотрите комментарий ниже. Babel эмулирует окружение модулей, у него строгий режим исполнения по умолчанию. Топикостартер же запустил код без 'use strict'.


      1. rock
        02.11.2015 23:55

        Домашний объект биндится при создании метода, super получается как его прототип на рантайме, а контекстом получения свойства (возможный вызов геттеров) и исполнения метода становится текущий this. То есть для super.toString(); в данном случае полноценным дешугарингом является:

        Reflect.apply(Reflect.get(Reflect.getPrototypeOf(obj), 'toString', this), this, []);