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


AliExpress RU&CIS

После того, как я прочитал очередную статью, где говорится о том, что 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 — это, всё же, «сахар», или нет?




Комментарии (16):

  1. gro
    /#22993982

    Мне казалось сей холивар уже некоторое количество лет неактуален.

  2. megahertz
    /#22996262

    Меня забавляет тот факт, что классы в JS появились именно в тот момент, когда общий тренд повернулся к игнорированию ООП.


    А насчет синтаксического сахара, так наверное всегда говорили про новые фишки, начиная с перехода с бинарного кода к ассемблеру.

    • Alexandroppolus
      /#22996892

      тот момент, когда общий тренд повернулся к игнорированию ООП.

      Что характерно, совпавший с моментом, когда в профессию набежали выпускники курсов "по хэтээмэлю за 21 день".

    • DDroll
      /#22997340

      Меня больше забавляет, что в языке огромное количество давно известных тяжелых проблем, но консорциум добавляет такие невероятно важные вещи, как синтаксис классов (в функционально-ориентированном языке, да), потому что людям с джавой головного мозга без оных трудно было его усвоить, а тяжелые проблемы так и остаются нерешенными. Насколько мне известно, до сих пор в стандарте не закреплен синтаксис проверки цепочек свойств (восклицательный знак), хотя реализации в библиотеках были еще начиная с КофеСкрипта. Это как пример элементарной вещи, которая всем нужна и которую было бы проще простого внедрить, но веселее же заниматься ерундой, которую будет использовать 1% разработчиков. Это глобальная проблема современного фронтенда — колоссальная оторванность разработчиков стандартов и фреймворков от реальных кейсов их использования. Консорциум состоит из высококлассных разрабов, которые пишут на чем угодно, но только не на JS; Ден Абрамов с гордостью заявляет, что не умеет верстать и вообще, не знает очень многих элементарных фронтовых вещей. А потом мы удивляемся страданиям.

      • DmitryKoterov
        /#22998948

        Классы в основном в серверном коде используются (Node), там без них очень плохо было бы. На клиенте меньше. Если вы делаете только клиент, то неудивительно, что удивляетесь, зачем нужны классы.

        • Damaskus
          /#23000148

          Не согласен.
          У большой тройки на фронте: Angular, Vue, React есть способы описания через классы.
          У первого причем только через классы. Vue, начал с классов, но в итоге тоже внедряет React-like api.

          А по бэку, у ноды, я за все пакеты не могу сказать, но из того что я видел — везде процедурный стиль: тот же самый express

          • DmitryKoterov
            /#23000340 / +1

            Тут какая штука… не скажу за другие, но в Реакте классы изначально были страшными костылями, и хуки стали реально прорывом. (Хотя хуки сами по себе тоже весьма костыльны и протекающи как абстракция, просто менее, чем классы.)


            Так же, как за PrototypeJS стоял jQuery, за jQuery стоял Реакт с классами, а за классами стояли хуки. Так же, как за callback hell стояли промисы, а за промисами стоял async-await. Точно так же и за хуками стоит что-то еще (скорее всего, нечто напоминающее подход в Svelte, но не сам Svelte; Реакт следующий прорыв уже, думаю, не переживет, слишком много легаси и слишком стар Дэн Абрамов.). Это эволюция парадигм. Как эволюция физики и математики. Чертовски небыстрая штука, но в конце, глядя назад, думаешь «но почему до этого не догадались раньше?».

      • Kassaila
        /#23002264 / +1

        Есть же опциональная последовательность — optional chaining

      • Koluchi
        /#23002266

        Для UI как раз и ближе ООП, а вот бек ближе к процедурным, во всяком случае webapi. И вместо того чтобы немного посидеть и подумать, как раскидать модель, большой процент фронтэнд разработчиков начинают пилить лапшу из if/switch, да ещё и используя any где не попадя. В результате код не поддерживаемый, даже IDE пасует. Убил бы )))

    • essome
      /#22998754

      Кто это игнорирует ооп кроме абрамова и его поклонников?

  3. Alexandroppolus
    /#22996882

    Del

  4. DmitryKoterov
    /#22997176

    Хорошая статья, все по делу.


    Про приватные #-свойства хотелось бы только добавить. С их использованием есть опасность (по крайней мере, с транспиляторами через WeakMap): объект получается принципиально неклонируемым снаружи. Вернее, при попытке склонировать через Object.create (да хоть как), получается новый объект, у которого все приватные свойства «битые», это можно слишком поздно заметить.

  5. Chamie
    /#22997752

    в более современном механизме, в классах
    Классы современнее прототипов? Oh my!..
    А транспилятор при этом, кроме того, должен выполнять проверку instanceof, что приводит к появлению более медленного кода, перегруженного служебными конструкциями.
    Используйте компайл-тайм проверки, и вам не придётся проверять это в рантайме ни быстро, ни медленно. Раз уж всё равно используете транспилятор, то грех не возложить на него максимум проверок.
    Предлагаю не обращать внимания на тот факт, что я даже не использую в конструкторе Array.apply(this, arguments), так как это тоже не приведёт к желаемому эффекту.
    Возможно, потому, что функция-конструктор вообще не обязана вообще обращаться к this? Потому что это не конструктор класса, а функция-конструктор, задача которой — сформировать объект с нужными свойствами. И писать её так, чтобы ей пользовались для посторонних целей, никто не обязан.
    «Да кому вообще может понадобиться расширять String, дружище?». И это — правильный вопрос. Вам, вполне возможно, это и не понадобится. Но смысл тут не в том, надо это кому-то или нет, а в том, что сделать это средствами ES5 просто невозможно. Механизмам прототипного наследования это недоступно, а вот JS-классы способны на такие вещи.
    По этой логике текущие версии языка хуже старых, потому что в них убрали возможность переопределять «undefined» и прочие ключевые слова. Потому что «смысл тут не в том, надо это кому-то или нет, а в том, что сделать это средствами ES7 просто невозможно».
    окажется, что ES5 просто не создан для работы с тем, для чего используется Symbol.species. В ES5 в этом плане всё устроено очень неудобно и ненадёжно.
    Опять же, расширять стандартные классы типы в JS не приветствуется, поэтому оно и неудобно.
    // 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»)?
    Это, блин, вообще, серьёзный вопрос? Мы вызываем функцию, просто фунцию, которая никак не обращается к this, и ожидаем, что она что-то с этим this сделает? Это задачка для вводного урока по программированию для старшеклассников или что?
    А теперь давайте взглянем на то, как это реализовано в JS-классах:
    // ES6+
    class Button {
      constructor() {
        return document.createElement('button');
      }
    }
    class MyButton extends Button {
      constructor(value) {
        super();
        this.textContent = value;
      }
    }
    Поздравляю, ваш «отличный и предсказуемый» пример только что незаметно для вас затёр в конструкторе «унаследованнго» класса всё его содержимое, все его свойства и методы.
    В ES5 в качестве конструктора может быть использована любая функция.
    Может, потому что в 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();
    Во-первых, в ES5 просто не было стрелочных функций. Они появились только в ES6. А во-вторых, объясните, что мешает просто написать конструктор точно так же в одну строчку?
    function WithArrows() {
      this.method1 = () => "arrow 1";
    }

  6. Damaskus
    /#23000174

    По поводу конструкторов без new,
    так сложилось, что иногда их вполне валидно можно вызвать, во всяком случае со стандартными типами

    Number(’10’) === 10, что в будет плюс/минус эквивалентом
    Number.call(undefined, '10’)
    

    И вообще непонятно зачем так делать?
    Мы же не делаем всяких глупостей, просто потому что можем.
    У вас же есть код ревью?

    По поводу приватных полей и методов + WeakMap — жесть какая.
    Инкапсуляция данных и методов, она практически всегда была реализована, как правильно упомянули выше в комментариях, через замыкания.
    И она еще долго таковой и останется много где.

    Посмотрите код любой либы, она с приличной вероятностью будет чем-то похожим на

    (function closure() {…})()
    

    что и есть инкапсуляция по сути своей.

  7. mixir
    /#23000300

    Приватные методы и свойства всегда делались в es5 через замыкания, так что утверждение насчёт того что можно сделать их исключительно с weakmap не верно. Weakmap использовали только тогда когда можно было объявлять классы через директиву class но ещё не была введена поддержка приватных полей через #.