JavaScript-классы — это не просто «синтаксический сахар»

После того, как я прочитал очередную статью, где говорится о том, что JS-классы — это всего лишь «синтаксический сахар» для прототипного наследования, я решил написать материал, призванный (в который раз!) прояснить вопрос о том, почему данное утверждение неверно. Тут я, надеюсь, смогу объяснить разницу между JS-классами и прототипным наследованием, и смогу рассказать о том, почему важно понимать эту разницу.



Стандартная защита, сравнимая со строгим режимом


Использование директивы «use strict» в ES5-коде не помешает вызвать конструктор без ключевого слова new:

// ES5
function Test() { "use strict"; }
Test.call({}); // всё нормально
// ES6+
class Test {}
Test.call({}); // будет выдано исключение

Причина этого заключается в том, что в более современном механизме, в классах, есть концепция new.target. Её, без использования транспиляторов, имитирующих подобное поведение, средствами ES5 реализовать невозможно. А транспилятор при этом, кроме того, должен выполнять проверку instanceof, что приводит к появлению более медленного кода, перегруженного служебными конструкциями.

Расширение встроенных типов


Несмотря на то, что я сам, с 2004 года, пытался создавать подклассы Array и других подобных встроенных типов, в ES5, на самом деле, нет адекватного способа этого добиться.

// Эпический фейл ES5
function List() { "use strict"; }
List.prototype = Object.create(Array.prototype);
var list = new List;
list.push(1, 2, 3);
JSON.stringify(list);
// {"0":1,"1":2,"2":3,"length":3}
list.slice(0) instanceof List; // false

Предлагаю не обращать внимания на тот факт, что я даже не использую в конструкторе Array.apply(this, arguments), так как это тоже не приведёт к желаемому эффекту. Попытка расширения возможностей стандартного ES5-типа Array выйдет неуклюжей вне зависимости от того, как её воспринимать. То же самое справедливо и в отношении любых других встроенных в JS сущностей, вроде String и прочего подобного.

// Вторая версия эпического фейла ES5
function Text(value) {
  "use strict";
  String.call(this, value);
}
new Text("does this work?");
// нет, не работает, и никак работать не будет

Я знаю, о чём вы думаете: «Да кому вообще может понадобиться расширять String, дружище?». И это — правильный вопрос. Вам, вполне возможно, это и не понадобится. Но смысл тут не в том, надо это кому-то или нет, а в том, что сделать это средствами ES5 просто невозможно. Механизмам прототипного наследования это недоступно, а вот JS-классы способны на такие вещи.

Известный символ Symbol.species


Если вас когда-нибудь интересовал вопрос о том, как так получается, что list.slice(0) не является экземпляром List, то знайте, что ответом на него является известный символ Symbol.species.

// ES6+
class List extends Array {}
(new List).slice(0) instanceof List; // true
[].slice.call(new List) instanceof List; // true

И, соответственно, если только не следить внимательно за тем, чтобы каждый метод возвращал бы экземпляр исходного объекта, чего все могут ожидать от всех методов Array, окажется, что ES5 просто не создан для работы с тем, для чего используется Symbol.species. В ES5 в этом плане всё устроено очень неудобно и ненадёжно.

Вывод: JS-классы гораздо лучше показывают себя в деле оправдания ожиданий, чем механизмы ES5.

Ключевое слово super


Если вы когда-нибудь задумывались о том, почему в ES5 Array.apply(this, arguments) не работает в конструкторе, то знайте, что это так по двум причинам:

  • Встроенный тип Array отвечает за создание новых массивов, но при этом он совершенно не заботится, как и другие встроенные классы, о контексте.
  • JS-классы позволяют расширять экземпляры этих классов, а в ES5 это, без транспиляторов, невозможно. Да и с применением транспилятора, когда речь идёт о расширении встроенных типов, код оказывается весьма неприглядным.

// ES5
function Button() {
  return document.createElement('button');
}
function MyButton(value) {
  Button.call(this);
  this.textContent = value;
}
Object.setPrototypeOf(MyButton, Button);
Object.setPrototypeOf(MyButton.prototype, Button.prototype);

Как думаете, что случится после вызова new MyButton(«content»)? Вот пара вариантов ответа:

  • Будет возвращена кнопка с текстом value.
  • Будет возвращён экземпляр MyButton со свойством textContent.

Правильным будет второй ответ. И, если только мы не станем описывать все подклассы так, как показано ниже, наши ожидания не оправдаются:

function MySubClass() {
  var self = Class.apply(this, arguments) || this;
  // сделать что угодно с self
  return self;
}

Но что, всё же, не так с этим подходом?

  • Если суперкласс возвращает что-то другое — мы лишаемся наследования.
  • Если суперкласс представлен встроенным классом — то у нас, вместо этого, может иметься переменная self, указывающая на примитивное значение.

Вот другой вариант, в котором исправлена первая проблема, но не вторая:

function MySubClass() {
  var self = Class.apply(this, arguments);
  if (self == null)
    self = this;
  else if (!(self instanceof MySubClass))
    Object.setPrototypeOf(self, MySubClass.prototype);
  // сделать что-нибудь с self
  return self;
}

А теперь давайте взглянем на то, как это реализовано в JS-классах:

// ES6+
class Button {
  constructor() {
    return document.createElement('button');
  }
}
class MyButton extends Button {
  constructor(value) {
    super();
    this.textContent = value;
  }
}
document.body.appendChild(new MyButton("hello"));

Если снова задаться вопросом о том, стоит ли писать подобный код, ответ на него будет зависеть от обстоятельств.

Даёт ли это нам право говорить о том, что JS-классы гораздо лучше и мощнее того, что есть в ES5? Да, даёт!

Методы


То, о чём я тут хочу поговорить, не относится исключительно к JS-классам, но это — то, о чём довольно много разработчиков может и не знать: методы нельзя использовать в роли конструкторов. Это относится и к методам, описываемым при создании объектов с использованием литеральной нотации.

// ES6+
class Test {
  method() {}
}
new Test.prototype.method;
// TypeError: Test.prototype.method is not a constructor

В ES5 в качестве конструктора может быть использована любая функция. Не допустить использования функции в качестве конструктора можно только если каждый раз проверять this:

// ES5
function Test() {}
Test.prototype.method = function () {
  if (!(this instanceof Test))
    throw new TypeError("not a constructor");
};

Может ли тот, кому придётся писать подобный код, считать JS-классы всего лишь «синтаксическим сахаром»?

Перечислимость


В JS-классах и статические и нестатические методы не являются перечислимыми. Да, этого можно добиться и в ES5, но для этого придётся воспользоваться большим объёмом медленного и неудобного вспомогательного кода.

Стрелочные функции


При объявлении JS-классов можно пользоваться стрелочными функциями. То же самое справедливо и для ES5-конструкторов, но тут, чтобы это воспроизвести, как и в прочих подобных случаях, понадобится некоторый объём вспомогательного кода:

// ES5
function WithArrows() {
  Object.defineProperties(this, {
    method1: {
      configurable: true,
      writable: true,
      value: () => "arrow 1"
    }
  });
}
// ES6+
class WithArrows {
  method1 = () => "arrow 1";
}
// (new WithArrows).method1();

Приватные поля класса


JS-классы поддерживают приватные свойства, а, с недавних пор, и приватные методы.

// ES6+
class WithPrivates {
  #value;
  #method(value) {
    this.#value = value;
  }
  constructor(value) {
    this.#method(value);
  }
}

Можно ли сделать то же самое в ES5? На самом деле — нет, если только не прибегнуть к транспилятору и к WeakMap для хранения экземпляров класса с приватными полями, которые ни при каких условиях не должны выйти за пределы экземпляра. А если, всё же, случится их «утечка», мы никак не сможем от неё защититься.

Итоги


В ES5, используя старые механизмы прототипного наследования, безусловно, можно воспроизвести многое из того, что доступно в более современных версиях языка. Но не существуют стандартных реализаций подобного, которые были бы так же быстры или безопасны, как использование соответствующих синтаксических конструкций, имеющих отношение к классам. И, кроме того, некоторые вещи просто невозможно воспроизвести средствами прототипного наследования.

Предлагаю прекратить говорить о том, что JS-классы — это всего лишь «синтаксический сахар». Тот, кто так говорит, упускает массу деталей, на которые просто нельзя закрывать глаза. Нет, на них можно и не обращать внимания, но только если будет решено отказаться от использования современных механизмов JS, основанных на классах. А ведь эти механизмы способны вывести ООП в JS на такой уровень, до которого никто не добирался за последние 20 «прототипных» лет.

Но если предположить, что некто сознательно отказывается от новых возможностей JS, тогда правильнее будет говорить о классах примерно так: «Мне не нравятся JS-классы, так как я думаю, что меня вполне устраивает прототипное наследование».

Сказать так будет честнее и правильнее, чем называть классы простым «синтаксическим сахаром».

Спасибо всем, кто помогает донести идеи, изложенные в этом материале, до всех тех разработчиков, которые не обращают внимания на различия между наследованием, которое основано на классах, и прототипным наследованием.

Как по-вашему, классы в JavaScript — это, всё же, «сахар», или нет?

53 0