Почему я с особой осторожностью отношусь к некоторым функциям ООП в JavaScript

В мире нет недостатка в языках программирования. Существуют устаревшие процедурные языки (например, COBOL) и классические объектно-ориентированные языки (например, Java). Существуют функциональные языки, которые сейчас очень популярны, и многопарадигмальные языки, такие как C #, которые дают вам определенную гибкость в выборе.

А еще есть запутанные языки, такие как JavaScript.

Слишком сурово? Как еще описать язык, который просуществовал 15 лет безумного успеха, а затем - всего 5 лет назад - резко добавил к языку классы?

Я знаю, о чем ты думаешь. Классы - это просто синтаксический сахар. JavaScript - это все о скрытых прототипах, а классы - это лишь небольшая декорация, призванная удовлетворить любителей объектов. И это правда, до определенного момента. Но на практике введение классов дало нам две пересекающиеся модели с неравномерной поддержкой. Были ли у вас привычки использовать замыкания для хранения личных данных в функции-конструкторе до ES6? Тогда у вас могут возникнуть проблемы с классами, которые в настоящее время не имеют встроенной поддержки частных членов. Используется для создания процедур свойств с defineProperty()? Что ж, теперь у вас есть процедуры свойств, которые аккуратно обертывают некоторые из этих функций, но не все. Хотите создавать собственные объекты с иерархиями наследования? Обе модели могут это сделать, но ни одна из них в итоге вас не удовлетворит.

Фактически, немногие языки так раскололи модель разработки, как JavaScript. Забудьте о безудержном распространении фреймворков для создания приложений (React, Vue, Ember и т. Д.). Разработчикам JavaScript трудно согласовать методы канонического кодирования даже в самых простых приложениях.

Давай копнем глубже.

Наследование: все плохое, ничего хорошего

Много электронных чернил пролили на пороки наследства. Если отбросить горячие взгляды, то, безусловно, верно, что нет более быстрого способа усложнить вашу объектную модель и ввести ненужные зависимости, чем прибегнуть к наследованию. Но наследование также может стандартизировать и формализовать поведение сложных фреймворков, которым требуются объекты для совместной работы. Так как же JavaScript справляется с этим?

В старые времена JavaScript мы могли моделировать наследование с помощью шаблона прототипа, но это было некрасиво. Со времен классов ES6 жизнь стала проще. Сегодня наследование - это всего лишь extends ключевое слово. Обратите внимание, что этот пример является родительским Shape классом и Triangle, Circle и Square, которые являются производными от него:

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

  • Чтобы повторно использовать код в базовом классе
  • Для стандартизации интерфейса (набора методов и свойств), определенного в базовом классе

Если вы просто хотите повторно использовать код, вам почти всегда лучше использовать композицию. Другими словами, не создавайте Employee объект, производный от объекта Person, а создавайте объект Employee, который удерживается на PersonDetails объектах. (Или просто создайте классы большего размера и не поддавайтесь желанию разбить каждую похожую деталь на отдельную часть. Иногда меньшее повторное использование означает большую гибкость, чтобы иметь дело с будущим. Но это совершенно другая тема.)

Особая часть наследования - то, как оно накладывает какую-то формальную структуру на вашу систему классов - на самом деле не поддерживается в JavaScript. Рассмотрим пример с классами фигур. Поначалу все выглядит хорошо. Вы можете написать такой красивый код:

// Create an array of different shapes
const shapes = [new Triangle(15, 8), new Circle(8), new Square(7)]
// Sort them by area from smallest to largest
// This works because every Shape-derived class support getArea()
shapes.sort( (a,b) => a.getArea()-b.getArea() );
console.log(shapes);
// Here's the new order: Square, Triangle, Circle

Но вот в чем проблема - вам не нужно наследование, чтобы это работало. JavaScript - это язык со слабой типизацией, и вы можете вызывать getArea() для событий Triangle, Circle и Square объектов, если они не совместно используют родительский класс, определяющий метод. Да, формализация этого интерфейса с помощью наследования помогает сделать эти требования явными. Он также позволяет тестировать объекты с помощью instanceof. Но это лишний вес для второсортной попытки самодокументирования кода.

И мы не рассмотрели некоторые из наиболее уродливых частей примера формы. Например, тот факт, что класс Shape сам по себе ничего не значит, но нам все равно пришлось создать его как обычный класс. (JavaScript не поддерживает абстрактные классы.) Точно так же метод Shape.getArea() не имеет смысла, но нам тоже пришлось написать какой-то бессмысленный код. (JavaScript не поддерживает виртуальные методы.) Мы могли бы избежать всей головной боли, если бы вместо этого определили интерфейс и реализовали его. (Нет, JavaScript тоже не поддерживает интерфейсы.)

Вот итог:

Полезны классы в JavasScript. Но поддерживать наследование без других инструментов классических объектно-ориентированных языков - дело обреченное.

Процедуры собственности без инкапсуляции

Кому не нравятся имущественные процедуры? Я до сих пор помню, как было принято создавать стандартные средства получения и установки свойств для каждой общедоступной детали (cough, C #). Почему? Потому что они нужны вам для поддержки других функций (например, привязки данных). А почему нет? Это дало вам дополнительный уровень косвенности, если позже вам понадобится ввести код проверки или отслеживания изменений.

Но в JavaScript все по-другому. У нас есть процедуры собственности, но опытные разработчики советуют не использовать их. Не верите мне? И Руководство по стилю Google JavaScript, и почти каноническое Руководство по стилю JavaScript для Airbnb, написанное гораздо более умными программистами, чем я, запрещают использование процедур свойств.

Одна из причин заключается в том, что новые свойства всегда рядом с опечаткой. Даже в строгом режиме и даже с линтером вы можете сделать тихую, но неприятную ошибку вроде этой:

// This isn't the property you want (that's dateOfBirth)
// but JavaScript creates it anyway, and you won't notice
// the mistake until it's too late
person.DateOfBirth = new Date(2035, 10, 10);

С другой стороны, если вы используете методы setXxx() и getXxx(), вы никогда не испытаете такой же боли:

// You can't call a function that doesn't exist, so this typo
// ("Data" instead of "Date") always fails and won't be ignored
person.setDataOfBirth(new Date(2035, 10, 10));

Достаточно плохо, что люди иногда вызывают Object.seal(), чтобы укрепить свои объекты, поэтому неправильно написанные свойства становятся фактическими ошибками времени выполнения. (Но не делайте этого. Давайте использовать наши встроенные объекты для тех целей, для которых они предназначены.)

Еще более серьезной проблемой является то, что JavaScript добавил свойства без поддержки частных полей. Технически JavaScript не поддерживает поля любого типа. Вы просто создаете общедоступные свойства экземпляра для this (это официальная номенклатура), как и для любого объекта JavaScript. Такое расположение означает, что вам нужно «создать» свои свойства в конструкторе, что определенно не является моим любимым расположением в дизайне.

Это также означает, что если вы хотите использовать процедуру свойств для полезных целей - например, для проверки - вы должны доверять вызывающему коду, чтобы он не вмешивался в ваши данные. У вас есть не просто черный ход для изменения лежащей в основе общественной собственности - у вас есть входная дверь, которая открыта в течение всего дня. Проверьте это в этом Person классе:

Здесь у вас есть три общедоступных свойства экземпляра, firstName, lastName и _dateOfBirth, и одно «виртуальное свойство», dateOfBirth. Когда конструктор устанавливает dateOfBirth, совсем не ясно, что вы используете установщик свойств. И если в вызывающем коде случайно используется _dateOfBirth вместо dateOfBirth, ваш код проверки будет спорным.

Конфликты имен особенно распространены в этой системе. Вы должны различать имена вашего общедоступного свойства экземпляра, вашего установщика свойств и вашего параметра конструктора. По соглашению, когда мы видим общедоступное свойство экземпляра, названное с предшествующим подчеркиванием, мы должны притвориться частным. (Руки прочь от моих данных!) Но хотя конвенция хороша для поддержания минимального уровня здравомыслия, механизм безопасности с реальным применением будет на порядок лучше.

Вы можете заменить свойство dateOfBirth на метод setDateOfBirth(), но даже это не решит проблему конфиденциальности. Будущие версии стандарта JavaScript со временем обещают исправить. Между тем, некоторые люди создают наиболее опасные для творчества обходные пути для частных свойств, например, скрывают их во вложенных методах в конструкторе (неудобно), используют Symbol для создания обфусцированных имен свойств (странно) или используют WeakMap для хранения данных (шутки в сторону?). Вот хорошее практическое правило: если вы используете нестандартный обходной путь для реализации языковой функции в каждом написанном вами классе, вы находитесь на очень сомнительной почве.

Я мог бы продолжить жаловаться на другие части метаданных свойств, которые не попадают в систему классов и устанавливаются только процедурно через defineProperty(). Но настоящая моя жалоба такова:

Если классы предназначены для инкапсулирования пакетов данных и функций, реализация JavaScript не удовлетворяет этому описанию. Он группирует вещи вместе, но не позволяет скрыть детали вне поля зрения. Это нарушает принцип черного ящика, который настолько священен, насколько это возможно.

Где мы?

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

Но если я хочу создать что-то амбициозное в JavaScript сегодня, меня беспокоит разрыв между чисто процедурным программированием и принятием желаемого за действительное. Первый подход ограничен, второй наивен, и кажется, что каждой команде разработчиков остается импровизировать правильный компромисс где-то посередине.

Сегодня я чувствую себя более комфортно в более строгом, формализованном мире TypeScript. В любой день я променяю крутящий момент на безопасность. Тем не менее, есть интересные разработки на горизонте для будущей версии JavaScript. Возможно, это достаточно сократит разрыв, и я когда-нибудь снова смогу продуктивно работать с ванильным JS. А может и нет. Но до тех пор я закончу признанием другого мудрого программиста:

Чтобы проводить больше времени с JavaScript, прочтите Do This Not That, JavaScript Edition. И подпишитесь на Информационный бюллетень Young Coder, чтобы получать ежемесячные электронные письма с нашими лучшими техническими историями.