Monkey-Patching или Расширение Встроенных Типов: религия или осознанный выбор?

Прошу прощения за «жёлтый» заголовок, но надо же как-то привлечь.

В статье о moment.js, в первой же ветке, разгорелся горячий спор, в котором участвовали даже мэтры JS на хабре. Это очень странно для меня, мне всё казалось таким очевидным, а уж тем более для мэтров.

И я считаю, это достаточно серьёзный спор для того, чтобы у него была отдельная статья. Очень важно с этим моментом наконец разобраться. Если мы придём к обратному заявленому в статье решению – это, в конце концов, тоже будет важный ответ.

Так расширять встроенные типы (String, Array, Number, …) в JS или не расширять?

Однозначно и безоговорочно нет. Под катом – нерелигиозные аргументы, пояснения, когда всё-таки можно, и дискуссия в комментариях.


Исторический взгляд

Что мы получим, если рассмотрим исторический процесс становления JavaScript? (это мой вольный взгляд по памяти на историю JS, вполне возможно в нём есть реальные отклонения от настоящего).

  • Появляется JavaScript и в нём прототипное наследование. И встроенные типы в нём тоже построены на прототипном наследовании. И ещё в нём есть неудобные операции с DOM. Это безэмоциальные факты.
  • JS используют для мелких скриптов, в которых не задумываются о наследовании и никаких вопросов соответственно не стоит
  • Браузеры развиваются, постепенно JS всё плотнее входит в веб-разработку и становится нужным писать на JavaScript более крупные вещи, чем раньше. Динамические языки ещё не так популярны, популярны: C++, PHP, Java
  • Из-за необходимости написания крупных вещей (и неудобства DOM в JS) появляются библиотеки с функциями-помощниками. Одна из них - Prototype.js. Поскольку о наследовании вопрос ещё не стоял и не задавался, авторы библиотеки по привычке подсовывают нужные методы в прототипы встроенных объектов и чувствуют себя совершенно спокойно. И разработчики тоже. Где-то в это же время разработчики начинают хотеть классы в JS (и ваш покорный слуга из их числа) и, откопав где-то копипасту с функцией extend или чем-то подобным, начинают её активно использовать. Прототипы это сложно, а классы – это знакомо и проверено временем. Так что это тоже происходит довольно безболезненно, поскольку классическое ООП нормально ложится на прототипное наследование
  • Незаметное событие: Те, кто использует Prototype.js в крупных проектах неожиданно замечают вкусный на вид оператор for-in, прикручивают его, но чуть позже начинают обнаруживать странные ошибки при проходах по массивам и объектам
  • Вина за эти ошибки интуитивно падает на JavaScript, эта информация расходится по интернетам, цикл for-in становится нерекомендуемым, все спокойны и рады. На фоне появляется Ajax, после нескольких попыток для него появляется работающая на тот момент кроссбраузерная копипаста, все вроде тоже довольны, но всё же как-то не хватает стандартизации.
  • Неожиданно появляется чуть больше людей, которые разобрались в прототипном наследовании и понимают, что то что происходит – не очень хорошо. Появляется и тут же популяризуется jQuery (к коду которого у меня никаких претензий, кроме нынешнего веса библиотеки - но так тоже сложилось исторически) (с удобным методом ajax, btw)
  • Адепты прототипного наследования выходят из нор и пытаются раскрыть другим программистам глаза на возможности, которые уже давно им доступны, но они их упускают. Библиотеки ещё весят мало, поэтому их иногда смешивают. Где-то здесь появляется конфликт JQuery и Prototype.js, выросший из проблемы с document.getElementByClassName, причина которой в расширении встроенного типа
  • Время идёт, разобравшихся в прототипном наследовании всё больше. С другой стороны подступает функциональщина, которой тоже удобно писать на JS, используя созданные in-the-place объекты
  • Появляется node.js, в котором отказываются от классического ООП и используют паттерн “модуль” из CommonJS, где отдельный файл содержит неймспейс с функциональщиной (или, нежелательно, ООП) внутри. Это шаг к отказу от ООП
  • Войны ООП vs прототипы/функциональщина
  • Сейчас мы здесь

Видно, что ошибки научили ещё не всех.

Логический взгляд

  • Почему в JS не рекомендуется использовать оператор for-in?

  • Неправильный ответ: потому что в перечисление могут попасть ненужные свойства и методы объекта

  • Сомнительный ответ: не использовать для массивов, потому что для массивов не гарантировано сохранение порядка перебора (это правда, но это провис языка и/или движков браузеров и сейчас это меняется)

  • Сомнительный ответ: в массив можно записать свойство не по индексу через a["woo"] = 23; и тогда оно попадёт в цикл. Да, можно, но зачем осознанно так делать?

  • Правильный ответ: в перечисление могут попасть все не-enumerable свойства объекта, а в том числе все свойства из цепочки прототипов. Если у объекта длинная цепочка прототипов, то это будет происходить долго из-за её перебора. Для отделения свойств на верхнем уровне цепочки можно использовать `hasOwnProperty``, для определения, принадлежит ли свойство объекту.

  • Да, но что если у нас есть простой объект без сложной цепочки, созданный прямо на месте? Вот так: {'a': 2, 'b': 3}? Тоже нельзя использовать for-in?

  • Неправильный ответ: Да, нельзя. Это не рекомендуется.

  • Сомнительный ответ: Эммм….

  • Почти правильный ответ: Можно, он для этого и был прездназначен. Но есть одно «но». Программисты библиотеки, которую ты используешь, подсовывают в прототипы встроенных объектов свои методы и естественно не заботятся об enumerable: false. И эти методы могут попасть в перечисление. Безопаснее этим не пользоваться.

Эй, а почему вдруг безопаснее отгородить себя? Значит так и будет продолжаться? Это разве решение проблемы?

  • Правильный ответ: Давно пора образумить этих программистов.

Практический взгляд


var a = [12, 14, 13, 6];
for (var i in a) { console.log(i, a[i]); }
> 0 12
> 1 14
> 2 13
> 3 6

Array.prototype.foo = function() { console.log('bar'); }
for (var i in a) { console.log(i, a[i]); }
> 0 12
> 1 14
> 2 13
> 3 6
> foo bar

for (var a in { 'a': 2, 'b': 3 }) { console.log(a); }
> a
> b
Object.prototype.foo = function() { };
for (var a in { 'a': 2, 'b': 3 }) { console.log(a); }
> a
> b
> foo

Array.prototype.forEach = function(...) { ... };
var matches = 'test'.match(/t/);
console.log( matches instanceof Array );
> true
for (var i in matches) console.log(i, matches[i]);
> 0 t
> index 0
> input test
> forEach function() { }

(за обнаружение последнего примера спасибо TheShock)

Где-то здесь появляется конфликт JQuery и Prototype.js, выросший из проблемы с document.getElementByClassName

JavaScript Гарден, глава «Великий прототип», последний раздел «Расширение встроенных прототипов». Мой там только перевод, этот документ писали опытные JS-программисты (хотя и там есть косяки. но не в этом абзаце).

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

…По-моему как раз «соответствует всей идеологии JS» и «все JS фреймворки» – это религия. В языке есть дырки и одна из них – то, что можно расширять встроенные типы.

JQuery пользуется расширением прототипа собственного объекта – это вполне себе ок и как раз соответствует идеологии. А мы говорим о расширении встроенных типов — это две разные вещи. Встроенные типы по правилам any-типизированных языков должны быть закрыты для расширения. В JS у вас есть возможность их расширить и это сработало как неизвестное медленно-текущее вирусное заболевание. Заразились одни, не ощутили последствий, а через месяцы оказались заражены все вокруг.

Разумный взгляд

Вы помните чем кончается переопределение операторов в C++? Вы знаете, что не можете расширить встроенные классы Java? Вы пытаетесь отнаследоваться от str в Python? Нет. Так почему же вы это делаете в JS?

Сейчас мы живём в эру быстро сменяющихся версий браузеров, избавления от старых, и перехода на HTML5, так может и в JS стоит забыть некоторые первобытные страхи?

Когда можно

  • Если вам нужно в своём личном скрипте обеспечить наличие метода, который будет в будущем имплементирован. При этом нужно, чтобы интерфейс который он возвращает также соответствовал спецификации
  • Всё

Альтернативы

Да какие хотите (ок, все приведённые функции – в видимости какого-то своего объекта, не в глобальной):

  • function trim(str) { return str.replace(...); } trim(" string to trim ");
  • function trim() { return this.replace(...); } trim.call(" string to trim ");
  • utils.trim(" string to trim ");
  • var a = new ExtendedString(" string to trim "); a.trim();
  • $.each([ 1, 2, 6, 6], …);

Статьи по теме

Тесты

В этом комментарии Zibx провёл тест по скорости двух способов: через расширение прототипа и отдельную функцию и работа через прототип чуть не показалась нам действительно значительно быстрее. Но, к счастью, Markel опроверг этот факт, создав соответствующий тест на jsperf.

#javascript