
Эта статья — краткая заметка о двух связанных друг с другом эмпирических правилах.
Поднимайте If вверх
Если внутри функции есть условие if
, то подумайте, нельзя ли его переместить в вызывающую сторону:
// ХОРОШО
fn frobnicate(walrus: Walrus) {
...
}
// ПЛОХО
fn frobnicate(walrus: Option<Walrus>) {
let walrus = match walrus {
Some(it) => it,
None => return,
};
...
}
В подобных примерах часто существуют предварительные условия: функция может проверять предусловие внутри и «ничего не делать», если оно не выполняется, или же может передать задачу проверки предварительного условия вызывающей её стороне, а при помощи типов (или assert) принудительно удовлетворить этому условию. Подъём проверок вверх, особенно в случае предварительных условий, может иметь лавинообразный эффект и привести к уменьшению общего количества проверок. Именно поэтому и возникло это правило.
Ещё одна причина заключается в том, что поток управления и if
сложны, к тому же оказываются источниками багов. Поднимая if
наверх, мы часто приходим к централизации потока управления в одной функции, имеющей сложную логику ветвления, но сама работа при этом делегируется к простым линейным подпрограммам.
Если у вас есть сложный поток управления, то лучше перенести его в одну функцию, умещающуюся на одном экране, а не разбрасывать его по файлу. Более того, если весь поток находится в одном месте, то часто можно заменить избыточность и невыполняющиеся условия. Сравните:
fn f() {
if foo && bar {
if foo {
} else {
}
}
}
fn g() {
if foo && bar {
h()
}
}
fn h() {
if foo {
} else {
}
}
В случае f
гораздо проще заметить «мёртвое» ветвление, чем в последовательности g
и h
!
Есть и другой схожий паттерн, который я называю рефакторингом «растворяющихся enum». Иногда код начинает выглядеть так:
enum E {
Foo(i32),
Bar(String),
}
fn main() {
let e = f();
g(e)
}
fn f() -> E {
if condition {
E::Foo(x)
} else {
E::Bar(y)
}
}
fn g(e: E) {
match e {
E::Foo(x) => foo(x),
E::Bar(y) => bar(y)
}
}
Здесь две команды ветвления; если поднять их наверх, то становится очевидно, что это одно и то же условие, которое повторяется трижды (в третий раз оно превращается в структуру данных):
fn main() {
if condition {
foo(x)
} else {
bar(y)
}
}
Опускайте For вниз
Этот совет взят из подхода, ориентированного на обработку данных. Программы обычно работают с сериями объектов, или, по крайней мере, горячий путь выполнения обычно связан с обработкой множества сущностей. Именно из-за количества сущностей путь и становится в первую очередь горячим. Поэтому часто бывает разумно ввести концепцию «группы» объектов и в базовом случае выполнять операции с группами, а скалярная версия при этом будет особым случаем групповой обработки:
// ХОРОШО
frobnicate_batch(walruses)
// ПЛОХО
for walrus in walruses {
frobnicate(walrus)
}
Основное преимущество здесь — производительность. А крайних случаях — огромный рост производительности.
Если у вас есть целая группа объектов для обработки, можно амортизировать затраты на запуск и более гибко определять порядок обработки. На самом деле, вам даже не нужно обрабатывать сущности в каком-либо конкретном порядке: можно реализовать трюк с векторизацией/массивом структур, чтобы сначала обрабатывать одно поле всех сущностей, а затем переходить к другим полям.
Наверно, самый забавный пример здесь — это умножение многочленов на основании быстрого преобразования Фурье: оказывается, одновременное вычисление многочлена в куче точек можно выполнять быстрее, чем кучу вычислений отдельных точек!
Два эти совета про for
и if
даже можно комбинировать!
// ХОРОШО
if condition {
for walrus in walruses {
walrus.frobnicate()
}
} else {
for walrus in walruses {
walrus.transmogrify()
}
}
// ПЛОХО
for walrus in walruses {
if condition {
walrus.frobnicate()
} else {
walrus.transmogrify()
}
}
Версия ХОРОШО
хороша тем, что ей не приходится многократно проверять condition
, она избавляется от ветвления в горячем цикле, а потенциально и обеспечивает возможность векторизации. Этот паттерн работает и на микро-, и на макроуровне — хорошей версией архитектуры можно считать TigerBeetle, в которой в плоскости данных мы одновременно работаем с группами объектов, чтобы амортизировать стоимость принятия решений в плоскости управления.
Хотя совет про for
в первую очередь связан с повышением производительности, иногда он и улучшает выразительность. Когда-то был довольно успешен jQuery
, работавший с коллекциями элементов. Язык абстрактных векторных пространств чаще оказывается более удобным инструментом, чем куча уравнений с координатами.
Подведём итог: поднимайте if
наверх и опускайте for
вниз!
Комментарии (27)
plFlok
22.05.2025 10:03эмпирических
Ну как эмпирических. Всполне себе формальных.
O(1) vs O(N) в лучших случаях.
dedmagic
22.05.2025 10:03Может, я неправильно понял.
Есть функция с однимif
, которая вызывается 15 раз.
Предлагается этотif
вынести в вызывающий код, и у нас станет 15if
вместо одного?domix32
22.05.2025 10:03На 15 штуках не сильно принципиально, но если штук заметно больше, то выгоднее сначала отфильтровать/поделить штуки по if и уже после вызывать обработку штук. Это во первых открывает путь к применению автовекторизации (то бишь когда в регистр процессора кладётся несколько штук сразу и все они обрабатываются за один такт), во вторых становится cache friendly - инвалидацию кэшей процессора можно будет делать реже, потому что предсказание веток уже сделано фильтром.
dedmagic
22.05.2025 10:03От силы 1% кода, который пишется, требует оптимизации на уровне регистров и кеша процессора. При этом 100% кода читается и модифицируется.
Так что критерии читабельности и поддерживаемости значительно превалируют.
И часто критична (ну или хотя бы очень важна) скорость написания кода. Что быстрее – воткнуть один
if
или анализировать вызовы функции на предмет "отфильтровать/поделить" и затем заниматься изменением дизайна кода?slonopotamus
22.05.2025 10:03От силы 1% кода, который пишется, требует оптимизации на уровне регистров и кеша процессора.
Citation needed.
И вопрос: если я пишу игру на C++ вместо Python'а, это я какой процент кода оптимизировал на уровне регистров и кеша процессора?
dedmagic
22.05.2025 10:03Вопрос: если я пишу бекенд на Python, я какой процент кода оптимизирую на уровне регистров и кеша процессора?
Вы резко сузили кейс (от "весь код" до "игра на C++") - и что этим пытаетесь доказать?
slonopotamus
22.05.2025 10:03если я пишу бекенд на Python, я какой процент кода оптимизирую на уровне регистров и кеша процессора?
Не знаю, это вы меряете проценты как-то.
Вы резко сузили кейс (от "весь код" до "игра на C++") - и что этим пытаетесь доказать?
Я ничего не пытаюсь доказать, я вопрос задаю, как вы проценты считаете.
Ну давайте ещё более упростим, напишем программу, которая выполняет цикл от нуля до
arvg[1]
. На питоне и на C++. Алгоритмически абсолютно одно и то же. Что у них с процентами оптимизации на уровне регистра и кеша? Тоже одинаково?dedmagic
22.05.2025 10:03Я не знаю. Именно потому, что задачи, которые я решаю, не требуют ручной оптимизации.
И когда я написал "требует оптимизации на уровне регистров и кеша процессора", я имел в виду усилия со стороны программиста. Если компилятор там чего-то оптимизирует автоматически, без моего участия - да пожалуйста.
Я под него подстраиваться и писать код так, чтоб он соптимизировал, не буду. Пока, конечно, не придут такие требования со стороны бизнеса.
До сих пор это не требовалось никогда, несмотря на то, что я в профессии не один год. И очень сильно подозреваю, что таких задач - подавляющее большинство. Именно поэтому я и написал про "1%".
А требовать от меня каких-то формул или методики подсчёта процентов может только человек, который никогда не сталкивался с понятием "образное выражение".P.S. На самом деле я подозреваю, что таких задач сильно меньше одного процента. Но Вы, конечно, имеете полное право придерживаться иной точки зрения.
Naf2000
22.05.2025 10:03и получаем кучу копи-паста (мы же вынесли условие из функции в места ее вызовов) и удачи при изменении условия
и вот еще - в функции стоит условие проверки аргументов на валидность - тоже условие поднимать выше - выносить из функции?
domix32
22.05.2025 10:03Ну фиг знает. Иметь что-то вроде
shtuki .filter(is_valid) .filter(some_other_condition) .split_by(process_condition) .process_yes(/* обработка для if (true)*/) .process_no(/* обработка для if (false)*/)
вроде выглядит не сказать чтобы нечитаемо даже с точки зрения адептов чистого кода. прочая обвязка в том или ином виде все равно будет иметь дикие if, но там и циклической обработки обычно нет, так что не знаю где вы там копипаст плодите.
Отдельно стоит помнить про количество данных - на малых размерах какой-нибудь вектор неотличим от хэшмапы и полтора десятка условий погоды не сделают даже при безумных способах их индексирования, так что перед оптимизациями необходимо иметь замеры, что данный участок кода действительно узкое горлышко. И иметь примерное представление о доступной производительности/пропускной способности.
Naf2000
22.05.2025 10:03Вы как-то пример приводите... Сравнивая теплое с мягким. У вас в filter передается функциональный параметр (но я не спец по языку). Возможно я ошибаюсь, не понимая глубину показываемой вами абстракции.
В примерах в статье предлагалось выносить if за пределы метода. Опять же внутри filter я так понимаю сидит оператор условия, который и предлагается вынести.
Лично я ничего не имею против вашего примера и подхода. Более того, я целиком его разделяю, используя linq в c#, но мы же обсуждаем статью.
domix32
22.05.2025 10:03В примерах в статье предлагалось выносить if за пределы метода. Опять же внутри filter я так понимаю сидит оператор условия, который и предлагается вынести.
собственно использование filter и есть вынос - условие оказывается выше, а обработка в цикле ниже.
Naf2000
22.05.2025 10:03Предикат вынесли, а оператор условия, который будет использовать этот предикат - остался внутри filter
aamonster
22.05.2025 10:03Я так понимаю, не 15 раз из разных мест, а 15 раз подряд из одного места, в цикле. Это материал для новичков же, тут про простые вещи.
adeshere
22.05.2025 10:03Основное преимущество здесь — производительность. А крайних случаях — огромный рост производительности.
Я, конечно, не настоящий программист (пишу на фортране). Но невольно возникает вопрос: а так ли уж часто сверхсложная логика и требование высокой производительности переплетены настолько, что их не разделишь? Лично я просто стараюсь
писать понятно
то есть читабельно человеком, и чтобы не ставить подножек оптимизатору
а дальше пусть голова болит у оптимизатора. То же перечисление - это же по сути массив, а массивы, по идее, любой компилятор должен распараллеливать на ура?
Как выше заметил redf1sh2, современные компиляторы умеют многое делать. Особенно если им не мешать. Может, у меня однобокий взгляд, но у нас в фортране простые for-циклы обычно пишутся в виде массивного оператора, например:
if (condition) A=1/B,
(тут A и B - массивы). Такой код оборачивать в функцию часто не нужно вообще. Соответственно, рекомендация автора статьи выполнена автоматически ;-). Но, вовсе не ради какой-то оптимизации, а просто мне так запись понятнее.
При этом, однако, никто не мешает вставить if внутрь цикла:
where (B > 1) A=1./B
Получается поэлементная операция (делим на себя те элементы массива, которые больше 1), которая столь же прекрасно распараллеливается. Было бы просто неуважением к компилятору подозревать его в неспособности справиться задачей такого рода. Поэтому и в этом случае я пишу так, как понятнее и удобнее человеку (читателю).
Я понимаю, что массивные операции есть не во всех языках, и что у компилятора могут возникнуть трудности, если, например, мы работаем не с нативным массивом, а с массивом структур, и там надо обработать какое-то поле. Но, если речь идет о
второйдревнейшой профессиивтором древнейшем языке, то ведь обычно справляется же?! Значит, и в современных языках тоже должен справлятьсяну или вот-вот научится
Конечно, из любых правил есть исключения. Но строить всю парадигму на базе тех нескольких процентов случаев, где
1) эффективность критична, но
2) компилятор не умеет превратить хорошо структурированный (=читаемый) код в быстродействующую программу -это как то странно, не правда ли?
Поэтому я согласен с автором статьи в том, что логику обычно стоит поднять повыше (в разумных пределах, конечно). Но, глядя со своей колокольни, не очень согласен в том, что
во главе угла при этом должна стоять именно эффективность, а не читаемость кода
Может, конечно, это специфика моей предметной области - я обрабатываю научные временные ряды. Но вот в моем случае программы, написанные простым языком, почти всегда получаются достаточно эффективными, чтобы можно было не париться о каких-то особенных ухищрениях. Правда, ряды данных у нас не очень большие (обычно десятки-сотни миллионов значений, хотя изредка бывают и миллиарды). При этом каждая запись - это просто одно или несколько чисел. Без сложных текстовых полей и структур. Короче, далеко не "биг дата". Там мой "наивный подход" уже не факт, что прокатит.
> dedmagic1> От силы 1% кода, который пишется, требует оптимизации на уровне регистров и кеша процессора. При этом 100% кода читается и модифицируется (...).
Вот именно что ;-)
fugasio
22.05.2025 10:03Это вроде как логически понятно, если условие не индивидуально для каждого walrus, зачем проверять его каждый раз?
Naf2000
22.05.2025 10:03По поводу примера с условием в цикле. Создать функциональную переменную, которой по условию присвоить необходимую рутину. В цикле вызывать - в результате нет дублирования цикла
Zara6502
22.05.2025 10:03функция используется в том числе чтобы сократить основной код, ну а через оптимизации компилятора можно попросить его развернуть её во время сборки, так что не вижу причин не делать функцию из if.
nv13
22.05.2025 10:03На древних tms320c40 длина конвеера команд была 4, и любой if при обработке массива его сшибал. Тем самым скорость вычислений могла быть легко уменьшена в 4 раза) в следующих процессорах TI создали аж оптимизирующий ассемблер для перетасовки команд для VLIW, и количество упоминаний про использование if уменьшилось.
Для современных процессоров общего назначения с их предвыборками инструкций и данных, эта проблема не очень актуальна, имхо. Если какое то dsp на каких то простых архитектурах, то жизнь заставит поразбираться)
yaroslavp
22.05.2025 10:03if внутри функции это удобнее тем, что вся ответственность лежит на самой функции, а не на программисте, который её вызывает. Допустим я беру некую библиотечную функцию, откуда я знаю, что туда можно подать вектор, который содержит 3 или 5 элементов, а если я подам вектор, который содержит 4 элемента то она ничего не будет делать. Поэтому вот тебе, функция, какой-то вектор, делай с ним, что хочешь
Также вызов функции в цикле компилятор сам оптимизирует. Функция для одного элемента позволяет вызвать себя как для одного элемента, так и во время итерации по списку, вектору, мапе и тд. А так получается надо n разных функций писать/использовать шаблоны, если такие есть в языке
Cykooz
22.05.2025 10:03Что бы вы могли понять, что нужен вектор из 3-ёх элементов, а не любой, существует система типов. Через неё можно указать чего ждёт функция на вход.
Например сделать тип Vec3, который в своих конструкторах выполняет все нужные проверки.
В итоге у вас появляется возможность доп. оптимизаций, если одни и те же данные используются несколько раз. Например сразу хранить все нужные данные в виде Vec3, что бы, обрабатывая их повторно, не выполнять лишние проверки.
kain64b
22.05.2025 10:03Похоже на танцы вокруг цикломатической сложности(вы же запускаете статический код анализатор). Кстати первый совет может породить мега монстра+оркестратора, которого будет не реально обложить тестами)
AbitLogic
22.05.2025 10:03Что характерно, если писать в функциональном стиле с итераторами и комбинаторами, оно примерно так само собой получается)
redf1sh
Современные компиляторы умеют это делать. Оптимизация называется loop unswitching
martin_wanderer
Хотя автор поставил на первое место оптимизацию, вы правы - это за нас зачастую сделает компилятор. Но штука в том, что именно выразительность повышается: цикл однотипных действий можно воспринимать как одно действие над всем списком. И, скажем в Java, заменить на стрим
aamonster
Когда так можно переписать (идеальный случай – перейти к векторным операциям) – прекрасно. Когда от этого код становится труднее читать (бывает и такое) – лучше лишний раз подумать, стоит ли овчинка выделки.