Эта статья — краткая заметка о двух связанных друг с другом эмпирических правилах.

Поднимайте 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)


  1. redf1sh
    22.05.2025 10:03

    Современные компиляторы умеют это делать. Оптимизация называется loop unswitching


    1. martin_wanderer
      22.05.2025 10:03

      Хотя автор поставил на первое место оптимизацию, вы правы - это за нас зачастую сделает компилятор. Но штука в том, что именно выразительность повышается: цикл однотипных действий можно воспринимать как одно действие над всем списком. И, скажем в Java, заменить на стрим


      1. aamonster
        22.05.2025 10:03

        Когда так можно переписать (идеальный случай – перейти к векторным операциям) – прекрасно. Когда от этого код становится труднее читать (бывает и такое) – лучше лишний раз подумать, стоит ли овчинка выделки.


  1. plFlok
    22.05.2025 10:03

    эмпирических

    Ну как эмпирических. Всполне себе формальных.
    O(1) vs O(N) в лучших случаях.


  1. dedmagic
    22.05.2025 10:03

    Может, я неправильно понял.
    Есть функция с одним if, которая вызывается 15 раз.
    Предлагается этот if вынести в вызывающий код, и у нас станет 15 if вместо одного?


    1. domix32
      22.05.2025 10:03

      На 15 штуках не сильно принципиально, но если штук заметно больше, то выгоднее сначала отфильтровать/поделить штуки по if и уже после вызывать обработку штук. Это во первых открывает путь к применению автовекторизации (то бишь когда в регистр процессора кладётся несколько штук сразу и все они обрабатываются за один такт), во вторых становится cache friendly - инвалидацию кэшей процессора можно будет делать реже, потому что предсказание веток уже сделано фильтром.


      1. dedmagic
        22.05.2025 10:03

        От силы 1% кода, который пишется, требует оптимизации на уровне регистров и кеша процессора. При этом 100% кода читается и модифицируется.

        Так что критерии читабельности и поддерживаемости значительно превалируют.

        И часто критична (ну или хотя бы очень важна) скорость написания кода. Что быстрее – воткнуть один if или анализировать вызовы функции на предмет "отфильтровать/поделить" и затем заниматься изменением дизайна кода?


        1. slonopotamus
          22.05.2025 10:03

          От силы 1% кода, который пишется, требует оптимизации на уровне регистров и кеша процессора.

          Citation needed.

          И вопрос: если я пишу игру на C++ вместо Python'а, это я какой процент кода оптимизировал на уровне регистров и кеша процессора?


          1. dedmagic
            22.05.2025 10:03

            Вопрос: если я пишу бекенд на Python, я какой процент кода оптимизирую на уровне регистров и кеша процессора?

            Вы резко сузили кейс (от "весь код" до "игра на C++") - и что этим пытаетесь доказать?


            1. slonopotamus
              22.05.2025 10:03

              если я пишу бекенд на Python, я какой процент кода оптимизирую на уровне регистров и кеша процессора?

              Не знаю, это вы меряете проценты как-то.

              Вы резко сузили кейс (от "весь код" до "игра на C++") - и что этим пытаетесь доказать?

              Я ничего не пытаюсь доказать, я вопрос задаю, как вы проценты считаете.

              Ну давайте ещё более упростим, напишем программу, которая выполняет цикл от нуля до arvg[1]. На питоне и на C++. Алгоритмически абсолютно одно и то же. Что у них с процентами оптимизации на уровне регистра и кеша? Тоже одинаково?


              1. dedmagic
                22.05.2025 10:03

                Я не знаю. Именно потому, что задачи, которые я решаю, не требуют ручной оптимизации.

                И когда я написал "требует оптимизации на уровне регистров и кеша процессора", я имел в виду усилия со стороны программиста. Если компилятор там чего-то оптимизирует автоматически, без моего участия - да пожалуйста.

                Я под него подстраиваться и писать код так, чтоб он соптимизировал, не буду. Пока, конечно, не придут такие требования со стороны бизнеса.

                До сих пор это не требовалось никогда, несмотря на то, что я в профессии не один год. И очень сильно подозреваю, что таких задач - подавляющее большинство. Именно поэтому я и написал про "1%".
                А требовать от меня каких-то формул или методики подсчёта процентов может только человек, который никогда не сталкивался с понятием "образное выражение".

                P.S. На самом деле я подозреваю, что таких задач сильно меньше одного процента. Но Вы, конечно, имеете полное право придерживаться иной точки зрения.


      1. Naf2000
        22.05.2025 10:03

        и получаем кучу копи-паста (мы же вынесли условие из функции в места ее вызовов) и удачи при изменении условия

        и вот еще - в функции стоит условие проверки аргументов на валидность - тоже условие поднимать выше - выносить из функции?


        1. 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, но там и циклической обработки обычно нет, так что не знаю где вы там копипаст плодите.

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


          1. Naf2000
            22.05.2025 10:03

            Вы как-то пример приводите... Сравнивая теплое с мягким. У вас в filter передается функциональный параметр (но я не спец по языку). Возможно я ошибаюсь, не понимая глубину показываемой вами абстракции.

            В примерах в статье предлагалось выносить if за пределы метода. Опять же внутри filter я так понимаю сидит оператор условия, который и предлагается вынести.

            Лично я ничего не имею против вашего примера и подхода. Более того, я целиком его разделяю, используя linq в c#, но мы же обсуждаем статью.


            1. domix32
              22.05.2025 10:03

              В примерах в статье предлагалось выносить if за пределы метода. Опять же внутри filter я так понимаю сидит оператор условия, который и предлагается вынести.

              собственно использование filter и есть вынос - условие оказывается выше, а обработка в цикле ниже.


              1. Naf2000
                22.05.2025 10:03

                Предикат вынесли, а оператор условия, который будет использовать этот предикат - остался внутри filter


    1. aamonster
      22.05.2025 10:03

      Я так понимаю, не 15 раз из разных мест, а 15 раз подряд из одного места, в цикле. Это материал для новичков же, тут про простые вещи.


  1. 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% кода читается и модифицируется (...).

    Вот именно что ;-)


  1. fugasio
    22.05.2025 10:03

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


  1. Naf2000
    22.05.2025 10:03

    По поводу примера с условием в цикле. Создать функциональную переменную, которой по условию присвоить необходимую рутину. В цикле вызывать - в результате нет дублирования цикла


  1. Zara6502
    22.05.2025 10:03

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


  1. nv13
    22.05.2025 10:03

    На древних tms320c40 длина конвеера команд была 4, и любой if при обработке массива его сшибал. Тем самым скорость вычислений могла быть легко уменьшена в 4 раза) в следующих процессорах TI создали аж оптимизирующий ассемблер для перетасовки команд для VLIW, и количество упоминаний про использование if уменьшилось.

    Для современных процессоров общего назначения с их предвыборками инструкций и данных, эта проблема не очень актуальна, имхо. Если какое то dsp на каких то простых архитектурах, то жизнь заставит поразбираться)


  1. yaroslavp
    22.05.2025 10:03

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

    Также вызов функции в цикле компилятор сам оптимизирует. Функция для одного элемента позволяет вызвать себя как для одного элемента, так и во время итерации по списку, вектору, мапе и тд. А так получается надо n разных функций писать/использовать шаблоны, если такие есть в языке


    1. Cykooz
      22.05.2025 10:03

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


  1. kain64b
    22.05.2025 10:03

    Похоже на танцы вокруг цикломатической сложности(вы же запускаете статический код анализатор). Кстати первый совет может породить мега монстра+оркестратора, которого будет не реально обложить тестами)


  1. AKozhuhov
    22.05.2025 10:03

    Господи, как мы оказались в этой точке?


  1. AbitLogic
    22.05.2025 10:03

    Что характерно, если писать в функциональном стиле с итераторами и комбинаторами, оно примерно так само собой получается)