Эффективность преждевременного возврата в функцию

97

Это ситуация, с которой я часто сталкиваюсь как неопытный программист, и меня это интересует, особенно для моего амбициозного, требовательного к скорости проекта, который я пытаюсь оптимизировать. Для основных C-подобных языков (C, objC, C ++, Java, C # и т. Д.) И их обычных компиляторов будут ли эти две функции работать столь же эффективно? Есть ли разница в скомпилированном коде?

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}

В принципе, есть ли когда-либо прямой бонус / штраф за эффективность при breakигре илиreturn ИНГ рано? Как задействован стековый фрейм? Есть ли оптимизированные особые случаи? Существуют ли какие-либо факторы (например, встраивание или размер «Do stuff»), которые могут существенно повлиять на это?

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

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

РЕДАКТИРОВАТЬ: Я принял ответ, но ответ EJP довольно лаконично объясняет, почему использование a returnпрактически незначительно (в сборке returnсоздается «ветвь» до конца функции, что очень быстро. Ветка изменяет регистр ПК и может также повлиять на кеш и конвейер, что довольно незначительно.) В этом случае, в частности, это буквально не имеет значения, потому что оба создают одну if/elseи returnту же ветвь до конца функции.

Филип Гуин
источник
22
Не думаю, что такие вещи заметно повлияют на производительность. Просто напишите небольшой тест и убедитесь сами. Imo, первый вариант лучше, так как вы не получаете ненужного вложения, что улучшает читабельность
SirVaulterScoff
10
@SirVaulterScott, если только два случая каким-либо образом не симметричны, и в этом случае вы захотите выявить симметрию, поместив их на один и тот же уровень отступа.
Луки,
3
SirVaulterScoff: +1 за сокращение ненужного вложения
fjdumont
11
Читаемость >>> Микрооптимизации. Сделайте это тем способом, который более понятен для обслуживающего персонала. На уровне машинного кода эти две структуры идентичны, если их передать даже в довольно глупый компилятор. Оптимизирующий компилятор сотрет любое подобие преимущества в скорости между ними.
SplinterReality
12
Не оптимизируйте свой «требовательный к скорости» проект, беспокоясь о подобных вещах. Профилируйте свое приложение, чтобы узнать, где оно на самом деле медленное - если оно на самом деле слишком медленно, когда вы закончили заставлять его работать. Вы почти наверняка не можете догадаться, что на самом деле его замедляет.
blueshift

Ответы:

92

Нет никакой разницы:

=====> cat test_return.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
    }
    else
        something2();
}
=====> cat test_return2.cpp
extern void something();
extern void something2();

void test(bool b)
{
    if(b)
    {
        something();
        return;
    }
    something2();
}
=====> rm -f test_return.s test_return2.s
=====> g++ -S test_return.cpp 
=====> g++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> rm -f test_return.s test_return2.s
=====> clang++ -S test_return.cpp 
=====> clang++ -S test_return2.cpp 
=====> diff test_return.s test_return2.s
=====> 

Нет никакой разницы в сгенерированном коде даже без оптимизации в двух компиляторах

Дэни
источник
59
Или лучше: существует по крайней мере версия определенного компилятора, который генерирует один и тот же код для двух версий.
UncleZeiv
11
@UncleZeiv - большинство, если не все компиляторы переводят исходный код в модель графа потока выполнения. Трудно представить себе разумную реализацию, которая давала бы существенно разные потоковые графы для этих двух примеров. Единственное различие, которое вы можете увидеть, заключается в том, что два разных действия меняются местами - и даже это может быть отменено во многих реализациях для оптимизации прогнозирования ветвлений или для некоторых других проблем, когда платформа определяет предпочтительный порядок.
Steve314,
6
@ Steve314, конечно, я просто придирчивал :)
UncleZeiv
@UncleZeiv: протестировано и на clang, результат тот же
Дэни
Я не понимаю Вроде понятно, что something()всегда будет выполняться. В исходном вопросе OP имеет Do stuffи в Do diffferent stuffзависимости от флага. Я не уверен, что сгенерированный код будет таким же.
Luc M
65

Короткий ответ: без разницы. Сделайте себе одолжение и перестаньте об этом беспокоиться. Оптимизирующий компилятор почти всегда умнее вас.

Сконцентрируйтесь на удобочитаемости и ремонтопригодности.

Если вы хотите увидеть, что происходит, создайте их с оптимизацией и посмотрите на вывод ассемблера.

синий сдвиг
источник
8
@Philip: И сделай всем одолжение и перестань беспокоиться об этом. Код, который вы пишете, также будет читать и поддерживать другие (и даже если вы напишете, что никогда не будет прочитан другими, у вас все равно появятся привычки, которые будут влиять на другой код, который вы пишете, который будет прочитан другими). Всегда пишите код, чтобы его было легко понять.
hlovdal
8
Оптимизаторы не умнее вас !!! Они только быстрее решают, где влияние не имеет большого значения. Там, где это действительно важно, вы наверняка с определенным опытом оптимизируете лучше, чем компилятор.
Йоханнес
10
@johannes Позвольте мне не согласиться. Компилятор не изменит ваш алгоритм на лучший, но он отлично справляется с переупорядочиванием инструкций для достижения максимальной эффективности конвейера и других не столь тривиальных вещей для циклов (деление, слияние и т. Д.), Которые не может решить даже опытный программист. что лучше априори, если он не имеет глубоких знаний об архитектуре процессора.
Фортран
3
@johannes - по этому вопросу вы можете предположить, что да. Кроме того, в целом, вы можете иногда оптимизировать лучше, чем компилятор, в некоторых особых случаях, но в наши дни для этого требуется немало специальных знаний - нормальным случаем является то, что оптимизатор применяет большинство оптимизаций, которые вы можете придумать, и делает это. систематически, а не только в некоторых особых случаях. Ответьте на этот вопрос, компилятор, вероятно, построит точно такой же потоковый граф выполнения для обеих форм. Выбор лучшего алгоритма - это человеческая работа, но оптимизация на уровне кода почти всегда пустая трата времени.
Steve314,
4
Я согласен и не согласен с этим. Бывают случаи, когда компилятор не может знать, что что-то эквивалентно чему-то другому. Знаете ли вы, что часто это сделать x = <some number>намного быстрее, чем if(<would've changed>) x = <some number>могут действительно повредить ненужные ветки. С другой стороны, если это не находится внутри основного цикла чрезвычайно интенсивной операции, я бы тоже не беспокоился об этом.
user606723
28

Интересные ответы: хотя я согласен со всеми из них (пока), в этом вопросе есть возможные коннотации, которые до сих пор полностью игнорируются.

Если простой пример, приведенный выше, дополнить выделением ресурсов, а затем проверкой ошибок с возможным высвобождением ресурсов, картина может измениться.

Рассмотрим наивный подход, который могут использовать новички:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  if (!a) {
    return 1;
  }
  res_b b = allocate_resource_b();
  if (!b) {
    free_resource_a(a);
    return 2;
  }
  res_c c = allocate_resource_c();
  if (!c) {
    free_resource_b(b);
    free_resource_a(a);
    return 3;
  }

  do_work();

  free_resource_c(c);
  free_resource_b(b);
  free_resource_a(a);

  return 0;
}

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

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  try {
    a = allocate_resource_a(); # throws ExceptionResA
    b = allocate_resource_b(); # throws ExceptionResB
    c = allocate_resource_c(); # throws ExceptionResC
    do_work();
  }  
  catch (ExceptionBase e) {
    # Could use type of e here to distinguish and
    # use different catch phrases here
    # class ExceptionBase must be base class of ExceptionResA/B/C
    if (c) free_resource_c(c);
    if (b) free_resource_b(b);
    if (a) free_resource_a(a);
    throw e
  }
  return 0;
}

Филипп предложил, посмотрев на приведенный ниже пример goto, использовать переключатель / case без прерывания внутри блока catch выше. Можно переключиться (typeof (e)), а затем пропустить free_resourcex()вызовы, но это нетривиально и требует конструктивного рассмотрения . И помните, что переключатель / корпус без разрывов точно такой же, как goto с гирляндными метками ниже ...

Как отметил Марк Б., в C ++ считается хорошим стилем следовать принципу «Получение ресурсов - это инициализация» , короче RAII . Суть концепции заключается в использовании экземпляра объекта для получения ресурсов. Затем ресурсы автоматически освобождаются, как только объекты выходят из области видимости и вызываются их деструкторы. Для взаимозависимых ресурсов необходимо уделять особое внимание обеспечению правильного порядка освобождения и разработке таких типов объектов, чтобы требуемые данные были доступны для всех деструкторов.

Или в дни до исключения:

int func(..some parameters...) {
  res_a a = allocate_resource_a();
  res_b b = allocate_resource_b();
  res_c c = allocate_resource_c();
  if (a && b && c) {   
    do_work();
  }  
  if (c) free_resource_c(c);
  if (b) free_resource_b(b);
  if (a) free_resource_a(a);

  return 0;
}

Но этот чрезмерно упрощенный пример имеет несколько недостатков: его можно использовать, только если выделенные ресурсы не зависят друг от друга (например, его нельзя использовать для выделения памяти, затем открытия дескриптора файла, а затем чтения данных из дескриптора в память. ), и он не предоставляет отдельные, различимые коды ошибок в качестве возвращаемых значений.

Чтобы код оставался быстрым (!), Компактным, легко читаемым и расширяемым, Линус Торвальдс применил другой стиль для кода ядра, который имеет дело с ресурсами, даже используя пресловутый goto таким образом, который имеет абсолютно смысл :

int func(..some parameters...) {
  res_a a;
  res_b b;
  res_c c;

  a = allocate_resource_a() || goto error_a;
  b = allocate_resource_b() || goto error_b;
  c = allocate_resource_c() || goto error_c;

  do_work();

error_c:
  free_resource_c(c);
error_b:
  free_resource_b(b);
error_a:
  free_resource_a(a);

  return 0;
}

Суть обсуждения списков рассылки ядра заключается в том, что большинство языковых функций, которые «предпочтительнее» по сравнению с оператором goto, являются неявными gotos, такими как огромные древовидные if / else, обработчики исключений, операторы loop / break / continue и т. Д. . И goto в приведенном выше примере считаются нормальными, так как они прыгают только на небольшое расстояние, имеют четкие метки и освобождают код от другого беспорядка для отслеживания условий ошибки. Этот вопрос также обсуждался здесь, в stackoverflow .

Однако в последнем примере отсутствует хороший способ вернуть код ошибки. Я думал о добавлении a result_code++после каждого free_resource_x()вызова и возврате этого кода, но это компенсирует некоторый выигрыш в скорости вышеупомянутого стиля кодирования. А в случае успеха вернуть 0 сложно. Может, я просто лишен воображения ;-)

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

CFI
источник
1
Вау, действительно интересно. Я определенно могу оценить недостижимость наивного подхода. Но как бы улучшить обработку исключений в этом конкретном случае? Как, если в коде ошибки catchсодержится безотказная switchинструкция?
Филип Гуин
@Philip Добавлен базовый пример обработки исключений. Обратите внимание, что только goto имеет возможность провала. Предложенный вами переключатель (typeof (e)) может помочь, но он не является тривиальным и требует рассмотрения при разработке . И помните, что переключатель / корпус без разрывов точно такой же, как goto с метками, соединенными
гирляндой
+1 это правильный ответ для C / C ++ (или любого языка, который требует ручного освобождения памяти). Лично мне не нравится версия с несколькими лейблами. В моей предыдущей компании это всегда было «goto fin» (это была французская компания). В fin мы освобождаем любую память, и это было единственное использование goto, которое могло пройти проверку кода.
Кип
1
Обратите внимание, что в C ++ вы бы не использовали ни один из этих подходов, но использовали бы RAII, чтобы убедиться, что ресурсы очищены должным образом.
Mark B
12

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

Лу
источник
9

Чтобы быть конкретным, returnбудет скомпилирован в ветку до конца метода, где будет RETинструкция или что-то еще. Если вы его не укажете, конец блока перед блоком elseбудет скомпилирован в ветвь до конца elseблока. Итак, вы можете видеть, что в данном конкретном случае это не имеет никакого значения.

Маркиз Лорн
источник
Попался. Я действительно думаю, что это довольно лаконично отвечает на мой вопрос; Я предполагаю, что это буквально просто добавление регистра, что довольно незначительно (если, может быть, вы не занимаетесь системным программированием, и даже тогда ...) Я хочу отметить это с почетом.
Филип Гуин,
@Philip какое добавление реестра? В пути вообще нет дополнительных инструкций.
Marquis of Lorne
Хорошо бы у обоих регистрировать дополнения. Это все, что есть сборочная ветка, не так ли? Дополнение к счетчику программ? Я могу ошибаться здесь.
Филип Гуин
1
@Philip Нет, сборочная ветка - это сборочная ветка. Это, конечно, влияет на ПК, но это может быть связано с его полной перезагрузкой, а также имеет побочные эффекты в процессоре относительно конвейера, кешей и т. Д.
Marquis of Lorne
4

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

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

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

Марк Б
источник
4

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

void foo1(bool flag)
{
    if (flag)
    {
        //Do stuff
        return;
    }

    //Do different stuff
}

void foo2(bool flag)
{
    if (flag)
    {
        //Do stuff
    }
    else
    {
        //Do different stuff
    }
}
PCPGMR
источник
Конечно, функция не должна быть длиннее одной (или даже двух) страниц. Но аспект отладки еще не рассматривался ни в одном из других ответов. Дело принято!
cfi
3

Я полностью согласен с blueshift: удобство чтения и ремонтопригодность в первую очередь !. Но если вы действительно обеспокоены (или просто хотите узнать, что делает ваш компилятор, что определенно является хорошей идеей в долгосрочной перспективе), вам следует поискать себя.

Это будет означать использование декомпилятора или просмотр вывода компилятора низкого уровня (например, язык сборки). На C # или любом другом языке .Net описанные здесь инструменты предоставят вам то, что вам нужно.

Но, как вы сами заметили, это, вероятно, преждевременная оптимизация.

Мистер замазка
источник
1

From Clean Code: A Handbook of Agile Software Craftsmanship

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

foo(true);

в коде просто заставит читателя перейти к функции и тратить время на чтение foo (логический флаг)

Лучше структурированная база кода даст вам больше возможностей для оптимизации кода.

Юань
источник
Я просто использую это как пример. То, что передается в функцию, может быть int, double, классом, вы называете это, но это не совсем суть проблемы.
Филип Гуин,
Вопрос, который вы задали, касается переключения внутри вашей функции, в большинстве случаев это запах кода. Этого можно добиться разными способами, и читателю не нужно читать всю функцию, скажите, что означает foo (28)?
Юань
0

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

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

MartyTPS
источник
Я думал, что это связано с очисткой (особенно при кодировании на C).
Томас Эдинг
нет, независимо от того, где вы оставите метод, пока вы возвращаете, стек возвращается вниз (это все, что «очищается»).
MartyTPS
-4

Я рад, что вы подняли этот вопрос. Вы всегда должны использовать ветки при досрочном возврате. Зачем останавливаться на достигнутом? Объедините все свои функции в одну, если можете (по крайней мере, столько, сколько сможете). Это выполнимо, если нет рекурсии. В конце концов, у вас будет одна огромная основная функция, но это то, что вам нужно / нужно для такого рода вещей. После этого переименуйте свои идентификаторы, чтобы они были как можно короче. Таким образом, когда ваш код выполняется, меньше времени тратится на чтение имен. Дальше делаем ...

Томас Эдинг
источник
3
Я могу сказать, что вы шутите, но страшно то, что некоторые люди могут просто серьезно отнестись к вашему совету!
Дэниел Прайден
Согласен с Дэниелом. Насколько я люблю цинизм - его не следует использовать в технической документации, официальных документах и ​​на сайтах вопросов и ответов, таких как SO.
cfi
1
-1 за циничный ответ, не всегда узнаваемый новичками.
Johan Bezem