Хватит быть милым и умным

в 10:21, , рубрики: javascript, мысли вслух, перевод, Программирование, метки: , ,

Этот текст является переводом статьи 'Stop Being Cute and Clever' небезызвестного (по крайней мере, в Python-комьюнити) Армина Ронахера.

Последние дни в свободное время я занимался созданием планировщика. Идея была простой: создать некий клон worldtime buddy c использованием AngularJS и некоторых других JavaScript-библиотек.

И знаете что? Это было отнюдь не весело. Я уже давно так сильно не злился, работая над чем-либо, а это что-то значит, потому что обычно я быстро высказываю своё недовольство (прошу прощения у моих фолловеров в Twitter).

Я регулярно использую JavaScript, но мне редко приходилось сталкиваться с кодом других людей. Обычно я привязан только к jQuery, underscore и иногда AngularJS. Однако в этот раз я пошел ва-банк и решил использовать различные сторонние библиотеки.

Для данного проекта я использовал jQuery, без которого уже нельзя обойтись (да и зачем?), и AngularJS с некоторыми UI-компонентами (angular-ui и биндинги к jQuery UI). Для работы с часовыми поясами использовался moment.js.

Хочу сразу отметить, что я не собираюсь критиковать чей-то конкретный код. Более того, если кто-то заглянет в мои JavaScript-исходники, их код будет немногим лучше, а иногда и хуже, ведь я не тратил на него много времени, да и вообще у меня не слишком много опыта работы с этим языком.

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

У меня было много проблем с js-библиотеками, и все они являлись результатом того, что всем, похоже, наплевать на особенности работы языка.

Причина, по которой я стал активно изучать сторонний JavaScript-код, была в том, что моя наивная попытка отправки 3mb названий городов в библиотеку автодополнения typeahead.js привела к невероятно тормозному UI. Очевидно, что сейчас ни один умный человек будет не отправлять так много данных в поле с автодополнением, а фильтровать их сначала на стороне сервера. Но данная проблема кроется не в медленной загрузке данных, а как раз в медленной фильтрации. Чего я никак не мог понять, ведь даже если происходит линейный поиск по 26 000 элементов, он не должен быть настолько медленным.

Предыстория

Итак, интерфейс тормозил — очевидно, ошибка была в моей попытке передать слишком большое количество данных. Но интересно, что производительность падала именно при использовании typeahead-виджета. Причем иногда весьма своеобразным образом. Чтобы показать, какое сумасшествие происходило, я приведу несколько начальных тестов:

  1. Ищем San Francisco, печатая «san». ~200ms.
  2. Ищем San Francisco, печатая «fran». ~200ms.
  3. Ищем San Francisco, печатая «san fran». Секунда.
  4. Ищем San Francisco, снова печатая «san». Секунда.

Что вообще происходит? Как ломается поиск, если мы ищем что-то более одного раза?

Первое, что я сделал – это использовал новый профайлер Firefox’a, чтобы увидеть, на что тратится так много времени. И очень быстро нашел в typeahead кучу вещей, которые были слишком странными.

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

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

Но из-за того, что мне пришлось отлаживать этот кусок, я наткнулся на самый странный код из всего, что видел ранее. После дальнейшего исследования оказалось, что такие же чудаковатости встречаются не только в typeahead.

Основываясь на этом, я теперь убеждён, что JS представляет из себя своеобразный Дикий Запад разработки софта. В первую очередь потому что он конкурирует с кодом PHP года 2003-го в плане качества, но судя по всему это волнует меньшее количество людей, так как он работает на клиентской стороне, а не на сервере. Вы не должны платить за медленно работающий JavaScript.

«Умный» код

Первая болевая точка – люди, которым JS кажется милым и 'умным' языком. И это делает меня до смешного параноидальным при проведении ревью кода и поиске багов. Даже если вы знаете примененные идиомы, вы не можете быть уверены, будут ли побочные эффекты намеренными, или кто-то просто сделал ошибку.

Для примера я приведу кусок typeahead.js:

_transformDatum: function(datum) {
    var value = utils.isString(datum) ? datum : datum[this.valueKey],
        tokens = datum.tokens || utils.tokenizeText(value), 
        item = {
            value: value,
            tokens: tokens
        };
    if (utils.isString(datum)) {
        item.datum = {};
        item.datum[this.valueKey] = datum;
    } else {
        item.datum = datum;
    }
    item.tokens = utils.filter(item.tokens, function(token) {
        return !utils.isBlankString(token);
    });
    item.tokens = utils.map(item.tokens, function(token) {
        return token.toLowerCase();
    });
    return item;
}

Это всего лишь одна функция, которая тем не менее зацепила меня по многим причинам. Всё, что делает функция – конвертирует объект c данными в элемент списка. Что представляет из себя объект с данными? Где-то здесь и начинается интересное. Похоже, что автор библиотеки в какой-то момент пересмотрел свой подход. Всё должно было начинаться с приёма функцией строки и дальнейшего оборачивания её объектом с value-атрибутом (тоже строкой) и массивом токенов. Однако теперь возвращаемый объект – обёртка над объектом данных (или строкой) с совершенно иным интерфейсом. Копируется куча данных, и затем просто переименовываются некоторые атрибуты.

Предположим, что что на вход поступает объект следующего вида:

{
    "value": "San Francisco",
    "tokens": ["san", "francisco"],
    "extra": {}
}

Тогда он трансформируется в такой:

{
    "value": "San Francisco",
    "tokens": ["san", "francisco"],
    "datum": {
        "value": "San Francisco",
        "tokens": ["san", "francisco"],
        "extra": {}
    }
}

Я могу понять, почему код заканчивает работу именно так, но глядя на совершенно иной участок кода, совершенно неочевидно, почему мой datum-объект стал другим объектом, тем не менее содержащим те же данные. Даже хуже: удваивается используемая объектом память, потому что при операциях с массивами копируются токены. Получается, что я мог просто отправить объекты данных в правильном формате, сократив при этом потребление памяти на 10MB.

А ведь такой код достаточно типичен для JavaScript, и это расстраивает. Он неясен, он странный, ему не хватает информации о типах. И он слишком 'умный'.

Он просто оперирует объектами. Ты не можешь спросить у объекта: datum, в нужном ли ты формате? Это просто объект. При копании в деталях реализации оказалось, что можно отправить целую кучу различных типов данных на вход – и всё бы продолжало работать, просто делая что-то другое по началу и ломаясь намного позже. Впечатляет количество неверной информации, которую JS может обработать, выдав каким-то образом результат.

Мало того, что не хватает типизации, так этот код еще легкомысленно злоупотребляет операторами и функциональным программированием. Не передать словами, насколько недоверчиво я отношусь к такому стилю написания JS-кода, учитывая то, как странно работает функция map. Не многим языкам удается реализовать map таким образом, что ["1", "2", "3"].map(parseInt) выльется в [1, NaN, NaN].

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

_processData: function(data) {
    var that = this, itemHash = {}, adjacencyList = {};
    utils.each(data, function(i, datum) {
        var item = that._transformDatum(datum), id = utils.getUniqueId(item.value);
        itemHash[id] = item;
        utils.each(item.tokens, function(i, token) {
            var character = token.charAt(0), adjacency =
                adjacencyList[character] || (adjacencyList[character] = [ id ]);
            !~utils.indexOf(adjacency, id) && adjacency.push(id);
        });
    });
    return {
        itemHash: itemHash,
        adjacencyList: adjacencyList
    };
}

Для информации: utils.indexOf – простой линейный поиск в массиве, а utils.getUniqueId возвращает постоянно увеличивающееся целое число в качестве идентификатора.

Очевидно, автор этого кода знал о хэш-таблицах со сложностью O(1), иначе он не положил бы эту строку в hashmap. Но всё же несколькими строками ниже происходит линейный поиск перед позиционированием элемента в списке. Если закинуть в этот код 100 000 токенов, он будет работать очень медленно, поверьте.

Также хочется обратить внимание на это цикл:

utils.each(item.tokens, function(i, token) {
    var character = token.charAt(0), adjacency =
        adjacencyList[character] || (adjacencyList[character] = [ id ]);
    !~utils.indexOf(adjacency, id) && adjacency.push(id);
});

Я просто уверен, что автор был очень горд. Для начала, почему именно так? Разве !~utils.indexOf(...) && действительно достойная замена if (utils.indexOf(...) >= 0)? Не говоря уже о том, что hashmap со списками смежности называется adjacencyList… Или то, что список инициализируется ID строки, и потом сразу же проходит линейный поиск по всему списку для поиска этого же элемента еще раз. Или что значение в хэш-таблицу вносится по булевой проверке списка и с использованием оператора ‘или’ для выполнения присваивания.

Еще один распространенный хак – использование унарного оператора + (который в других языках бесполезен, так как это noop) для перевода строки в число. +value – то же самое, что parseInt(value, 10).

У меня есть теория, что всё это операторное безумие пошло из Ruby. Но в Ruby это имеет смысл, так как там только два объекта со значением 'ложь': false и nil. Всё остальное – 'истина'. Весь язык базируется на этой концепции. В JS же многие объекты ложны. А затем иногда – нет.

Например, экземпляр пустой строки "" приравнивается к false. За исключением того случая, когда это объект. А строки иногда становятся объектами по случайности. Функция jQuery each передает текущее значение итератора как this. Но, так как this не может ссылаться на примитивные типы, объект передается как строка, обернутая объектом.

Так что в некоторых ситуациях поведение может сильно отличаться:

> !'';
true

> !new String('');
false

> '' == new String('');
true

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

Использование ~ для проверки возвращаемого функцией indexOf значения, которое может быть равным -1 при отсутствии элемента, просто неразумно. И пожалуйста, не говорите мне, что «так же быстрее».

Работаем «наживую»

Сомнительное использование операторов это одно, но действительно убивает то, что динамическую природу JS возводят в абсолют. Как по мне, даже Python – избыточно динамический язык, но питонисты хотя бы довольно разумно сводят модификации классов и пространств имён в runtime к минимуму. Но в мире JavaScript всё по другому. И особенно в мире AngularJS.

Классы не существуют, в JS используются объекты, которые иногда могут иметь прототипы. Хотя обычно все просто помещают функции в объекты. А иногда и функции в функции. Странное клонирование объектов тоже нормальное явление, ну разве что не в случае, когда состояние объекта часто меняется.

Может показаться, что директивы в Angular неплохи, до тех пор, пока вам не встретится директива, делающая почти то, что вам нужно. В большинстве случаев директива монолитна, и единственный способ изменить её – это добавить еще одну директиву с большим приоритетом, которая пропатчит предыдущую. Я бы не расстроился, если бы наследование классов ушло в прошлое, уступив место совмещению, но такой 'обезьяний патчинг' – не мой стиль.

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

Например, Angular использует систему слежения за изменениями моделей и DOM для автоматической их синхронизации. Мало того, что это чертовски медленно, так народ еще и придумывает всякие обходные пути для предотвращения firing’а обработчиков. Такая логика быстро становится до смешного запутанной.

Неизменяемость

Чем выше уровень языка программирование, тем более неизменяемыми становятся вещи. Но только не в JavaScript. API становятся всё больше засоренными stateful-концепциями. Может быть, неуместно жаловаться на это в плане производительности, но это начинает очень быстро раздражать. Одни из самых неприятных багов в моём планировщике были в изменяемой природе moment-объектов. Вместо того, чтобы вернуть новый объект, foo.add('minutes', 1) изменял исходный. Нет, я знал про это, в документации к API всё описано. Но, к сожалению, случайно передал туда ссылку, и она была изменена.

Правда, в теории JS должен быть отличным инструментом для построения API, использующего неизменяемые объекты при условии возможности их ‘заморозки’ по желанию. Это как раз то, чего не хватает Python. Однако в тоже время Python предоставляет больше инструментов, делающих immutable-объекты более интересными. Например, поддержку перегрузки операторов, и first-классы, которые позволяют использовать такие объекты в качестве ключей хэш-таблиц.

«Полезная магия»

Я люблю Angular, очень. Это одна из разумнейших систем для проектирования UI в JavaScript, но присутствие в ней магии пугает. Начинается всё с простых вещей. Например, библиотека переименовывает директивы. Если вы создадите директиву fooBar, в DOM она попадёт как foo-bar. Почему? Предположим, для однообразия с style DOM API, в котором делалось нечто похожее ранее. Но это делает код запутанным, поскольку вы можете не знать в точности, как точно называется директива. Также всё это полностью игнорирует идею пространств имён. Если у вас есть две директивы с одинаковыми именами в разных Angular-приложениях, они будут конфликтовать.

Внедрение зависимости в Angular происходит по умолчанию через конвертацию JS-функции в строку и последующее использование регулярного выражения для разбора аргументов. Если вы новичок в AnguarJS, для вас это вообще не будет иметь никакого смысла, а мне даже сейчас эта идея кажется плохой. Это конфликтует с тем, что люди делали в течении долгого времени в JS: локальные переменные рассматриваются как анонимные. Имя ни на что не влияет. Именно этим минимизаторы пользовались целую вечность. Однако всё же это не в полной мере относится к Angular, так как есть альтернативная возможность явного объявления зависимостей.

Что за слои?

Одним из неудобств после перехода с Python на клиентский JavaScript было отсутствие абстракций. В качестве примера, Angular позволяет получить доступ к параметрам текущего URL в виде словаря. Что он не позволяет, так это разобрать произвольную строку запроса. Почему? Потому что внутренняя функция парсинга спрятана под многими слоями замыканий, и кто-то просто не подумал, что она может быть полезной.

И такое происходит не только в Angular. JS сам по себе не имеет функции для экранирования HTML. Но DOM, очевидно, нуждается в такой функциональности в некоторых случаях. Из-за чего некоторые на полном серьёзе экранируют HTML так:

function escapeHTML(string) {
    var el = document.createElement('span');
    el.appendChild(document.createTextNode(string));
    return el.innerHTML;
}

А так можно распарсить URL:

function getQueryString(url) {
    var el = document.createElement('a');
    el.href = url;
    return el.search;
}

Это безумие, но оно везде.

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

«Но оно же работает»

PHP настолько популярен, потому что он просто работает, и не требует много времени на обучение. Целое поколение разработчиков начало работать с ним. И этой группе людей пришлось открывать болезненными способами кучу вещей, основанных на опыте предыдущих лет. Сформировался некий групповой менталитет: когда один человек копировал код другого, он особо не задумывался, как он работает. Я помню время, когда система плагинов была бредом сумасшедшего, и основным путём расширения PHP-приложений были mod-файлы. Какой-то заблуждавшийся дурак начал всё это, и все стали так делать. Я почти уверен, что именно так появились register_globals, странное ручное экранирование SQL, да и вся концепция обработки входных данных вместо нормального экранирования.

JS по большей части такой же. Сменилось поколение разработчиков, сменились проблемы, а менталитет остался. Всё так же концепции, увиденные в одной библиотеке, копируются в другую.

Даже хуже: так как всё работает в песочнице на компьютерах пользователей, никто даже не задумывается о безопасности. И в отличие от PHP производительность не имеет значения, поскольку клиентский JS «масштабируется линейно» с ростом числа пользователей, запустивших приложение.

Будущее?

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

Последние несколько лет подобные события происходят в Python-сообществе. Несколько лет назад метаклассы были горячей новинкой, а сейчас, когда приложения стали становится всё больше и больше, многие одумались. Когда Django только появился, разработчикам пришлось отстаивать использование функций вместо классов, сейчас об этом уже почти никто не говорит.

Я просто надеюсь, что JavaScript-комьюнити понадобится меньше времени на подстройку, чем предшественникам.

Armin Ronacher,
09/12/2013

Автор: komissarex

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js