TL;DR: В этой статье я хочу показать, почему распространённая фраза "не занимайтесь преждевременной оптимизацией" почти всегда используется неправильно, особенно в современных фронтенд-проектах. Я посмотрю на исторический контекст, разберу, что именно Кнут называл оптимизацией, и почему многие вещи, которые считаются "преждевременной оптимизацией", на деле — нормальная инженерная дисциплина.

Я фронтендер, пишу на React, регулярно делаю код-ревью в проекте, над которым работаю совместно с другими фронтендерами, и иногда заглядываю в другие проекты и в open source репозитории на GitHub. Нередко в пулл-реквестах я замечаю вещи, которые априори работают плохо — лишние ререндеры компонентов на React, неудачные алгоритмы в бизнес-логике, лишние преобразования и перекладывания данных из-за непродуманной структуры. И регулярно в ответ на замечание и варианты улучшения кода вижу одно и то же возражение "Кнут сказал — не занимайтесь преждевременной оптимизацией". Эту фразу часто повторяют, не задумываясь о том, как и когда она возникла, какой смысл вкладывал в неё автор, и насколько она уместна в данном конкретном случае.

Меня всегда это раздражало, и вот я собрался и написал статью в ответ на такие "отписки". Статья состоит из пяти частей:

  1. О статье, в которой Кнут написал своё знаменитое выражение.

  2. Что именно Кнут называет оптимизацией.

  3. Сравнение программ того времени и программ, которые пишут фронтендеры сейчас.

  4. Примеры "преждевременных оптимизаций", которые не являются ни преждевременными, ни оптимизациями.

  5. Заключение.

1. О статье, в которой Кнут написал своё знаменитое выражение

Выражение "не занимайтесь преждевременной оптимизацией" впервые появилось в статье Дональда Кнута "Structured Programming with go to Statements" в журнале Computing Surveys в декабре 1974 года. В статье Кнут рассматривает использование оператора goto и показывает, как от него можно отказаться без ущерба для скорости и читабельности в типичных случах передачи управления, таких как выход из цикла, сложные ветвления с пересекающимися вычислениями, рекурсивные вычисления. В современных языках программирования эти идеи развились в break и continue в циклах, break и проваливание в следующую ветку в switch case, хвостовую рекурсию и т.п. — в начале семидесятых годов почти ничего этого не было, эти концепции только придумывались и обсуждались.

Чтобы рассматривать разные варианты кода с goto и без него, в качестве подопытной задачи Кнут берёт поиск значения x в массиве A длиной m. Если значение отсутствует — его надо добавить в A. Дополнительно в массиве B для каждого индекса из A надо подсчитывать количество выполненных поисков.

Решение с goto выглядит так (я немного модернизировал синтаксис, чтобы код был понятнее современному читателю, работающему с C-подобными языками):

// Example 1
for (i = 1; i <= m; i++) { if (A[i] == x) goto found; }
notfound:
i = m + 1; m = i;
A[i] = x; B[i] = 0;
found:
B[i] = B[i] + 1;

Далее Кнут показывает, что вполне можно обойтись без goto:

// Example 1a
i = 1;
while (i < m && A[i] != x) { i = i + 1; }
if (i > m) { m = i; A[i] = x; B[i] = 0; }
B[i] = B[i] + 1;

Но! По мнению Кнута, есть целых два "но":

  1. Здесь на одно сравнение i с m больше.

  2. Этот вариант менее читабелен.

Обратите внимание, здесь и дальше в статье Кнут скрупулёзно подсчитывает стоимость выполнения, количество сравнений, чтений и записей в память — ему недостаточно простой O-нотации.

Дальше Кнут пишет, что если немного подумать, то находится вот такой вариант решения:

// Example 2
A[m + 1] = x; i = 1;
while (A[i] != x) { i = i + 1; }
if (i > m) { m = i; B[i] = 1; }
else { B[i] = B[i] + 1; }

Этот вариант для Кнута лучше, чем предыдущие, потому что цикл быстрее. Example 1 и Example 1a на каждой итерации выполняют два сравнения — i с m и A[i] с x. Example 2 выполняет только одно сравнение A[i] != x (использованный для этого приём — маркерный элемент, он же sentinel — описан в главах 9.2 и 13.2 книги Джона Бентли "Жемчужины программирования").

Цитирую:

Example 2 beats Example 1 because it makes the inner loop considerably faster. If we assume that the programs have been handcoded in assembly language, so that the values of i, m, and x are kept in registers, and if we let n be the final value of i at the end of the program, Example 1 will make 6n + 10 (+3 if not found) references to memory for data and instructions on a typical computer, while the second program will make only 4n + 14 (+6 if not found). ... we save about 33% of the run-time.

То есть второй вариант (Example 2) быстрее первого на 33% в случае ручного ассемблирования, и на 21% в случае использования компилятора (цитату не привожу, чтобы не раздувать текст статьи).

Разобрав эти варианты, Кнут приходит к выводу, что вполне возможно отказаться от использования goto без потерь в читабельности и эффективности. И дальше он полностью переключается на тему эффективности и оптимизации кода. Следующая глава в статье так и называется — "Efficiency".

2. Что именно Кнут называет оптимизацией

В главе "Efficiency" Кнут показывает, как можно оптимизировать код уже найденного удачного варианта решения — Example 2, сократить стоимость с 4n + 14 до 3.5n + 14.5 и получить ещё 12% прироста производительности:

// Example 2a
       A[m + 1] = x; i = 1; goto test;
loop:  i = i + 2;
test:  if (A[i] == x) goto found;
       if (A[i + 1] != x) goto loop;
       i = i + 1;
found: if (i > m) { m = i; B[i] = 1; }
       else { B[i] = B[i] + 1; }

Здесь в качестве оптимизации он использует разворачивание цикла ("loop unrolling"), то есть обрабатывает по два элемента за итерацию, тогда итераций и накладных расходов на их организацию становится меньше.

После этого он замечает, что эти 12% прироста производительности не стоят того, что оптимизированный таким образом код теперь сложно отлаживать и сопровождать.

И здесь важно понимать: под "оптимизацией" в семидесятых годах подразумевалось совсем не то, что мы имеем в виду сегодня. Перечитав статью, я составил список приёмов, которые Кнут называет оптимизациями или упоминает в контексте оптимизаций:

  • Изменение направления цикла чтения массива с for (i = 1; i <= m; i++) на обратное for (i = m; i > 0; i++), так как сравнение с константой 0 дешевле, чем сравнение с переменной m. Сейчас обратное направление чаще всего менее эффективно из-за предвыборки данных, которая хорошо работает при увеличении индексов в массиве и плохо работает при уменьшении индексов. И это заметно даже в JavaScript.

  • Точный подсчёт количества инструкций и стремление сократить его хоть на одну. Тогда это было возможно благодаря прозрачной модели вычислений; сегодня в высокоуровневых языках чаще всего можно только приблизительно оценить сложность кода в O-нотации.

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

  • Использование языков низкого уровня и ассемблера, ручное ассемблирование, ручное размещение переменных в регистрах. Сейчас в JavaScript и других языках высокого уровня это сложно, но аналогом можно считать вынесение тяжёлых участков в WASM, а для Node.js — использование аддонов на C++.

  • Ручное разворачивание внутреннего цикла (loop unrolling). Современные компиляторы умеют это делать самостоятельно, в простейших случаях умеет даже V8 Turboshaft.

  • Компиляция без проверок на выход за границы массива. В C++ это можно сделать и сегодня (но это неточно — я не настоящий сварщик и могу ошибаться), а в Java и JavaScript — нет.

  • Использование goto, когда оно позволяет сократить количество команд, даже если переход осуществляется снаружи внутрь цикла.

Из этого списка хорошо видно, что под оптимизациями Кнут понимал ручную работу, которая приносит в жертву читаемость кода ради эффективности, и поэтому нужно "оптимизировать" не весь код подряд, а только отдельные участки, которые в этом нуждаются. Поэтому он и делает знаменитый вывод:

Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs, and these attempts at efficiency actually have a strong negative impact when debugging and maintenance are considered. We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.

Не менее интересно, что Кнут НЕ считает оптимизацией вообще и преждевременной оптимизацией в частности. На протяжении десятка страниц он придумывает семь вариантов решения одной задачи поиска элемента в массиве (Example 1, Example 1a, Example 2, Example 2a, Example 3, Example 3a, Example 3b) и отбирает самые читабельные и эффективные.

Ключевой момент: перебор алгоритмов — не оптимизация. Поиск более удобного, более читаемого или просто более корректного решения — не оптимизация. Это обычная инженерная работа, которую, по мнению Кнута, делать нужно. Для Кнута "оптимизация" начинается только там, где вы идёте против читаемости ради выигрыша в инструкциях.

3. Сравнение программ того времени и программ, которые пишут фронтендеры сейчас

Ещё один важный момент, который часто забывают — с момента написания статьи программы сильно изменились.

Практически все программы, которые и о которых писал Кнут — реализация алгоритма, в котором присутствует "горячий код", он же bottle neck или узкое место (по опыту Кнута чаще всего это самый глубоко вложенный цикл в расчётах):

Experience has shown that most of the running time in non-IO-bound programs is concentrated in about 3% of the source text.

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

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

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

В реальных приложениях на React, которые мне доводилось профилировать, до 70% времени может уходить на перекладывания пропсов из одного объекта в другой и на ререндеры компонентов — зачастую бесполезные, когда после ререндера reconciliation не находит отличий и DOM в итоге не меняется.

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

Следующее отличие — проблема идентификации узких мест:

A good programmer ... will be wise to look carefully at the critical code; but only after that code has been identified.

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

С другой стороны, сейчас ядро кода (библиотеки, фреймворк) берётся в готовом виде, оно уже оптимизировано в рамках своей парадигмы. А буквально всё, что написано поверх ядра — бизнес-логика, редьюсеры, компоненты — может уронить FPS и превратить приложение в черепаху. Поэтому с идентификацией узкого места во время код-ревью нет проблемы — потенциально узким местом является весь рассматриваемый код.

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

these attempts at efficiency actually have a strong negative impact when debugging and maintenance are considered

И ещё в одном месте он повторяет, что оптимизация жертвует ясностью кода:

[...] when it is desirable to sacrifice clarity for efficiency, [...]

Для современного фронтенда — наоборот. JavaScript — достаточно богатый и выразительный язык, чтобы для неоптимальных мест кода чаще всего были варианты ускорения без ухудшения читабельности и сопровождаемости кода.

Если подытожить сказанное выше, то можно выделить три ключевых момента:

  1. Узкое место сейчас может появиться буквально в любом месте фронтенд-приложения.

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

  3. В абсолютном большинстве случаев неоптимальностей хороший программист может найти варианты, которые не ухудшают читаемость и сопровождаемость кода (как и делает Кнут в своей статье).

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

4. Примеры "преждевременных оптимизаций", которые не являются ни преждевременными, ни оптимизациями

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

4.1. Работа с DOM

Код в пулл-реквесте:

Array
    .from(document.querySelectorAll('li'))
    .forEach(e => {
        if (e.querySelectorAll('img').length) {
            const span = e.querySelector('span');
            // Делаем что-то с текстом
        }
    });

Здесь автор вручную с помощью if и нескольких querySelector делает то, что механизм селекторов уже давно умеет "из коробки". Пример кода можно сильно упростить (если нужны более старые браузеры, которые не понимают псевдокласс :has — всё равно можно упростить, но не так сильно):

document.querySelectorAll('li:has(img) span')
    .forEach(span => {
        // Делаем что-то с текстом
    });

У современных фронтендеров слабое владение браузерными API и "ванильным" JavaScript (то есть без использования библиотек и фреймворков) встречается всё чаще. Шаг в сторону от любимого фреймворка — и начинается неизведанная территория. Ещё один пример неоптимального использования селекторов:

document.querySelectorAll('.main p')[document.querySelectorAll('.main p').length - 1].style;

Очень обидно показать автору такого кода, как сделать его быстрее без утраты читабельности и получить в ответ "не занимайтесь преждевременной оптимизацией, как писал — так и буду писать". Изучение и использование возможностей среды выполнения (браузеры и Node.js) делает код яснее и часто быстрее — это не оптимизация, а нормальное, идиоматическое использование API.

4.2. Boolean short circuit evaluation

Большинство молодых фронтендеров (мидлов, джунов и стажёров) вообще не знает, что такое boolean short circuit evaluation, и систематически игнорирует его в своём коде. Приведу вот такой утрированный пример:

async function getData() {
    const fromStore = getDataFromReduxStore();
    const fromLS = getDataFromLocalStorage();
    const fromBack = await fetchDataFromBackend();
    return fromStore || fromLS || fromBack;
}

Надеюсь, читателям очевидно, что при наличии данных в сторе совсем не нужно читать их из local storage и тем более делать запрос в бэкенд. Используя short circuit evaluation, пример можно переписать, не теряя ясности и компактности:

async function getData() {
    return getDataFromReduxStore() ||
        getDataFromLocalStorage() ||
        (await fetchDataFromBackend());
}

Аналогичных не столь очевидных примеров я видел предостаточно в самых разных проектах. Short circuit evaluation — часть семантики логических операторов. Игнорирование этого механизма не делает код чище и читабельнее, а заставляет выполнять ненужную работу. И если систематически писать всю логику в таком "игнорирующем" стиле, то можно заметно просадить производительность приложения, не создав ни одного узкого места — тормозить будет весь код, в котором есть вычисления и логические операторы.

4.3. Повторяющиеся вычисления

Один фронтендер начал внедрять формат webp, когда ещё не все браузеры клиентов его поддерживали. Фронтендер не стал "заниматься преждевременной оптимизацией" и написал вот такой код:

function checkWebPSupport(): boolean {
    const e = document.createElement('canvas');
    if (e.getContext && e.getContext('2d')) {
        return e.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    }
    return false;
}

function getImageUrl(imageName: string): string {
    return `/images/${imageName}.${checkWebPSupport() ? 'webp' : 'png'}`;
}

На локальном dev-сервере с несколькими картинками оно работало приемлемо (особенно если учесть, что у фронтенд-разработчиков обычно очень производительные компьютеры), но даже без профилирования кода можно построить несложную цепочку умозаключений:

  • в продакшене могут быть (и по закону Мёрфи обязательно будут) десятки и сотни картинок

  • десятки и сотни вызовов checkWebPSupport() будут тормозить в браузере

  • результат проверки checkWebPSupport() в одном отдельно взятом браузере не меняется

Вывод — надо определять подержку webp только один раз при открытии страницы и потом переиспользовать:

let hasWebPSupport: boolean | undefined = undefined;

function checkWebPSupport(): boolean {
    if (hasWebPSupport !== undefined) {
        return hasWebPSupport;
    }

    const e = document.createElement('canvas');
    if (e.getContext && e.getContext('2d')) {
        hasWebPSupport = e.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    } else {
        hasWebPSupport = false;
    }

    return hasWebPSupport;
}

При желании можно убрать проверку переменной, используя самомодифицирующийся код:

let checkWebPSupport: () => boolean = () => {
    let hasWebPSupport = false;
    const e = document.createElement('canvas');

    if (e.getContext && e.getContext('2d')) {
        hasWebPSupport = e.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    }

    checkWebPSupport = hasWebPSupport ? () => true : () => false;

    return hasWebPSupport;
};

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

4.4. Ненужная мемоизация в React

Иногда разработчиков заносит в противоположную сторону, они увлекаются мемоизацией и заворачивают в useMemo даже очевидно простые выражения:

const firstScreen = useMemo(
    () => (props.firstScreen ? 1 : 0),
    [props.firstScreen],
);
const image = useMemo(() => `/images/${imageName}.png`, [imageName]);
const number = useMemo(() => index + 1, [index]);

Этот код можно сделать и короче и быстрее:

const firstScreen = props.firstScreen ? 1 : 0;
const image = `/images/${imageName}.png`;
const number = index + 1;

Если результат выражения — примитивный тип (boolean, string, number и так далее), то useMemo надо использовать только для тяжёлых вычислений.

4.5. Accidentally quadratic

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

Фронтендеры не отстают и тоже периодически выкатывают в продакшен код, который без нужды имеет завышенную сложность. Частый виновник — оператор spread:

const tagIds: Record<string, string> = tags.reduce(
    (acc, tag) => ({ ...acc, [tag.id]: tag.name }),
    {},
);

const tagNames: string[] = tags.reduce((acc, tag) => [...acc, tag.name], []);

Простота оператора spread вырабатывает привычку не задумываясь использовать его везде, даже в обработке коллекций. Но spread каждый раз создаёт новый объект/массив, копируя все предыдущие элементы. В результате на коллекции из N элементов получаем N копирований по (N-1)/2 элементов в среднем — отсюда квадратичная сложность. Ожидаемое поведение на код-ревью: ревьюер пишет про квадратичную сложность, автор исправляет, запоминает и дальше так не делает. Реальное поведение: "не занимайтесь преждевременной оптимизацией", релиз, пользователи жалуются на тормоза приложения, кто-то другой чинит баг, автор продолжает писать в том же духе.

5. Заключение

Я пытался сам написать заключение, но @fillpackart уже всё прекрасно сформулировал, так что закончу цитатой из его статьи:

Идея, что ты не должен оптимизировать свой код заранее, понятна и справедлива. Но тут есть одна проблема. Иногда ты пишешь тормозной код не потому, что всё обдумал и принял решение — иногда ты понятия не имеешь, как его ускорить. Одно дело, когда сознательно выбираешь эстетику вместо производительности, другое — когда ты ничего не выбираешь, потому что не умеешь делать производительно. Это очень, очень разные вещи.

Все эти понятия — "преждевременные оптимизации", "экономия на спичках" — создают иллюзию, что оптимизации — штука неважная, и поэтому разработчики могут их не изучать. Такая иллюзия опасна, ведь изучать тонкости рантаймов, устройства языков программирования, алгоритмы и структуры данных — долго и сложно. И если ты выращиваешь поколение разработчиков, которые это не делали — ты получаешь отвратительную инженерную культуру, ужасные, супермедленные инструменты и приложения. И людей, которые в сто раз опаснее спичечников. За примерами далеко ходить не надо — достаточно просто посмотреть на современных фронтендеров. Они делают самые тормозные вещи в мире, у них в коде всего два вида коллекций — они про другие и знать не знают. Их инструменты для билдов и управления пакетами работают так медленно, что после ввода "yarn start" можно смело идти смотреть сериал. Итоговые файлы, которые получаются после компиляции, весят раз в сто больше, чем должны были. Создается эффект снежного кома. Как теперь ни переучивай их, какую культуру ни прививай — с большинством проблем фронтенд-инфраструктуры особо ничего и не сделаешь — если только не переписать её с нуля (да здравствуют bun, swc, esbuild и oxlint — прим. моё).

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

Комментарии (0)