TL;DR: В этой статье я хочу показать, почему распространённая фраза "не занимайтесь преждевременной оптимизацией" почти всегда используется неправильно, особенно в современных фронтенд-проектах. Я посмотрю на исторический контекст, разберу, что именно Кнут называл оптимизацией, и почему многие вещи, которые считаются "преждевременной оптимизацией", на деле — нормальная инженерная дисциплина.
Я фронтендер, пишу на React, регулярно делаю код-ревью в проекте, над которым работаю совместно с другими фронтендерами, и иногда заглядываю в другие проекты и в open source репозитории на GitHub. Нередко в пулл-реквестах я замечаю вещи, которые априори работают плохо — лишние ререндеры компонентов на React, неудачные алгоритмы в бизнес-логике, лишние преобразования и перекладывания данных из-за непродуманной структуры. И регулярно в ответ на замечание и варианты улучшения кода вижу одно и то же возражение "Кнут сказал — не занимайтесь преждевременной оптимизацией". Эту фразу часто повторяют, не задумываясь о том, как и когда она возникла, какой смысл вкладывал в неё автор, и насколько она уместна в данном конкретном случае.
Меня всегда это раздражало, и вот я собрался и написал статью в ответ на такие "отписки". Статья состоит из пяти частей:
О статье, в которой Кнут написал своё знаменитое выражение.
Что именно Кнут называет оптимизацией.
Сравнение программ того времени и программ, которые пишут фронтендеры сейчас.
Примеры "преждевременных оптимизаций", которые не являются ни преждевременными, ни оптимизациями.
Заключение.
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;
Но! По мнению Кнута, есть целых два "но":
Здесь на одно сравнение
iсmбольше.Этот вариант менее читабелен.
Обратите внимание, здесь и дальше в статье Кнут скрупулёзно подсчитывает стоимость выполнения, количество сравнений, чтений и записей в память — ему недостаточно простой 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 — достаточно богатый и выразительный язык, чтобы для неоптимальных мест кода чаще всего были варианты ускорения без ухудшения читабельности и сопровождаемости кода.
Если подытожить сказанное выше, то можно выделить три ключевых момента:
Узкое место сейчас может появиться буквально в любом месте фронтенд-приложения.
Обнаружить потенциально неоптимальный код сейчас просто — есть установившиеся практики код-ревью, антипаттерны медленного кода достаточно хорошо известны, опытные код-ревьюеры имеют хорошую насмотренность, да и нейросети уже хорошо обнаруживают антипаттерны в кодовой базе и в пулл-реквестах.
В абсолютном большинстве случаев неоптимальностей хороший программист может найти варианты, которые не ухудшают читаемость и сопровождаемость кода (как и делает Кнут в своей статье).
Напоследок напомню ещё раз — подсчёт стоимости выполнения кода, обсуждение более эффективных вариантов реализации, устранение лишних вычислений, приведение кода к более простому — для Кнута являются не оптимизацией, а нормальной инженерной практикой. То есть замечание в пулл-реквесте с указанием излишней сложности кода и предложением его немного переписать — это не оптимизация, а всего лишь хорошо проведённое код-ревью.
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 — прим. моё).
Нет ничего страшного, что ты выбрал покрасивее, а не побыстрее. И нет трагедии, когда ты просто не знал, что твоё решение — супер-неоптимальное. Проблема начинается тогда, когда ты говоришь, что производительность — это чушь, и пусть этим занимаются отдельные люди. Изучать, как можно ускорить код — это не удел перформанс-задротов, это часть нашей работы. Когда от твоего нежелания изучать сложные вещи твой код становится и медленным, и уродливым — вот тогда у тебя большие проблемы.