Ефективність передчасного повернення у функції


97

Це ситуація, з якою я часто стикаюся як недосвідчений програміст, і мені цікаво, особливо для мого амбіційного, швидкісного проекту, який я намагаюся оптимізувати. Для основних мов, подібних до С (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початку? Як бере участь стек-фрейм? Чи є оптимізовані особливі випадки? Чи є фактори (наприклад, вбудовування чи розмір "Робити речі"), які могли б суттєво вплинути на це?

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

І я знаю про підводні камені передчасної оптимізації ... так, це болючі спогади.

EDIT: Я прийняв відповідь, але відповідь EJP досить коротко пояснює, чому використання a returnпрактично несуттєве (в зборі returnстворює «гілку» до кінця функції, що надзвичайно швидко. Гілка змінює реєстр ПК і також може вплинути на кеш і конвеєр, що досить мізерно.) Особливо для цього випадку це буквально не має різниці, оскільки і the, if/elseі the returnстворюють одну і ту ж гілку до кінця функції.


22
Я не думаю, що подібні речі матимуть помітний вплив на продуктивність. Просто напишіть невеликий тест і побачите себе. Імо, перший варіант кращий, оскільки ви не отримуєте непотрібного вкладеності, що покращує читання
SirVaulterScoff

10
@SirVaulterScott, якщо ці два випадки якимось чином не симетричні, і в цьому випадку ви хочете виявити симетрію, поставивши їх на одному рівні відступу.
luqui

3
SirVaulterScoff: +1 для зменшення
зайвого

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 - більшість, якщо не всі компілятори, переведуть джерело на модель діаграми потоку виконання. Важко уявити розумну реалізацію, яка б дала змістовно різні графіки потоку для цих двох прикладів. Приблизно єдина різниця, яку ви можете побачити, полягає в тому, що два різні способи щось поміняти місцями - і навіть це цілком може бути скасовано в багатьох реалізаціях для оптимізації передбачення гілок або для якоїсь іншої проблеми, де платформа визначає бажаний порядок.
Стів314

6
@ Steve314, впевнено, я щойно
випив

@UncleZeiv: теж тестували на дзвін і той самий результат
Дані

Я не розумію. Здається зрозумілим, що something()завжди буде виконуватися. В оригінальному питанні ОП має Do stuffі Do diffferent stuffзалежно від прапора. Я не впевнений, що згенерований код буде однаковим.
Luc M

65

Коротка відповідь - різниці немає. Зроби собі послугу і перестань турбуватися з цього приводу. Оптимізуючий компілятор майже завжди розумніший за вас.

Концентруйтесь на читанні та ремонтопридатності.

Якщо ви хочете побачити, що станеться, побудуйте їх з оптимізацією та подивіться на вихід асемблера.


8
@Philip: А також зробіть всім іншим послугу і перестаньте турбуватися про це. Код, який ви пишете, читатимуть і підтримуватимуть інші (і навіть якщо ви пишете, що його ніколи не читатимуть, ви все ще розвиваєте звички, які впливатимуть на інший код, який ви пишете, і читатимуть інші). Завжди пишіть код, щоб його було якомога легше зрозуміти.
hlovdal

8
Оптимізатори не розумніші за вас !!! Вони тільки швидше вирішують, де вплив не має великого значення. Там, де це справді важливо, ви, безумовно, з певним досвідом оптимізуєте краще, ніж компілятор.
johannes

10
@johannes Дозвольте не погодитися. Компілятор не змінить ваш алгоритм на кращий, але він виконує дивовижну роботу при переупорядкуванні інструкцій для досягнення максимальної ефективності конвеєра та інших не настільки тривіальних речей для циклів (поділ, синтез тощо), що навіть досвідчений програміст не може вирішити що краще апріорі, якщо він не має глибоких знань про архітектуру центрального процесора.
fortran

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 нижче, використовувати безперебійний перемикач / корпус всередині блоку catch вище. Можна переключити (typeof (e)), а потім пропустити free_resourcex()дзвінки, але це нетривіально і потребує розгляду проекту . І пам’ятайте, що перемикач / чохол без перерв - це точно як гото з етикетками, пов’язаними з ромашками внизу ...

Як зазначив Марк Б, в 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, є неявними готосами, такими як величезні, деревоподібні if / else, обробники винятків, операції циклу / перерви / продовження тощо І goto у наведеному вище прикладі вважаються нормальними, оскільки вони стрибають лише на невеликій відстані, мають чіткі мітки та звільняють код іншого безладу для відстеження умов помилок. Це питання також обговорювалося тут щодо stackoverflow .

Однак те, чого не вистачає в останньому прикладі, є приємним способом повернути код помилки. Я думав додати result_code++після кожного free_resource_x()дзвінка та повернути цей код, але це компенсує деякі збільшення швидкості вищезазначеного стилю кодування. І важко повернути 0 у разі успіху. Можливо, я просто не уявляю ;-)

Так, так, я думаю, що існує велика різниця у питанні кодування передчасних повернень чи ні. Але я також думаю, що це очевидно лише у складнішому коді, який важче або неможливо переструктурувати та оптимізувати для компілятора. Як правило, це відбувається, коли розподіл ресурсів починає діяти.


1
Нічого собі, справді цікаво. Я точно можу оцінити непорушність наївного підходу. Як би покращилася обробка винятків у цьому конкретному випадку? Подобається, що catchмістить беззбитковий switchвираз про код помилки?
Philip Guin

@Philip Додано базовий приклад обробки винятків. Зверніть увагу, що лише goto має можливість проникнення. Запропонований вам комутатор (typeof (e)) допоможе, але не є тривіальним та потребує розгляду дизайну . І пам’ятайте, що перемикач / кейс без перерв - це точно як
гото з

+1 це правильна відповідь для C / C ++ (або будь-якої мови, яка вимагає звільнення пам'яті вручну). Особисто мені не подобається версія з декількома ярликами. У моїй попередній компанії це завжди було "гото фіном" (це була французька компанія). Нарешті, ми виділимо будь-яку пам'ять, і це було єдиним використанням goto, яке пройшло перевірку коду.
Кіп

1
Зауважте, що в C ++ ви б не зробили жодного з цих підходів, але використовували б RAII, щоб переконатися, що ресурси очищені належним чином.
Mark B

12

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


9

Щоб бути конкретнішим щодо цього, returnфайл буде скомпільований у гілку до кінця методу, де буде RETінструкція або будь-яка інша. Якщо залишити це, кінець блоку до elseбуде скомпільований у гілку до кінця elseблоку. Тож ви бачите, що в цьому конкретному випадку це не має ніякої різниці.


Маю Я насправді думаю, що це відповідає на моє питання досить лаконічно; Я здогадуюсь, що це буквально просто додаток до реєстру, що є досить незначним (якщо, можливо, ви не займаєтесь системним програмуванням, а то й тоді ...), я буду згадувати це почесно.
Philip Guin,

@Philip яке додавання реєстру? Додаткової інструкції на шляху зовсім немає.
Маркіз Лорнський

Ну, обидва мали б доповнення до реєстру. Це все, що є гілкою збірки, чи не так? Доповнення до лічильника програм? Я можу помилитися тут.
Philip Guin

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
    }
}

Звичайно, функція не повинна мати більше однієї (або навіть двох) сторінок. Але аспект налагодження ще не висвітлений в жодній іншій відповіді. Очко взяте!
cfi

3

Я рішуче погоджуюсь із блюзшифтом: читабельність та ремонтопридатність насамперед !. Але якщо ви дійсно переживаєте (або просто хочете дізнатися, що робить ваш компілятор, що, безумовно, є гарною ідеєю в довгостроковій перспективі), вам слід шукати себе.

Це буде означати використання декомпілятора або перегляд вихідних даних компілятора низького рівня (наприклад, ануляція збірки). На C # або будь-якій іншій мові .Net інструменти, описані тут , дадуть вам те, що вам потрібно.

Але, як ви самі помітили, це, мабуть, передчасна оптимізація.


1

З чистого коду: Підручник з гнучкої майстерності програмного забезпечення

Аргументи прапора потворні. Перехід булевої функції у функцію - це справді жахлива практика. Це негайно ускладнює підпис методу, голосно заявляючи, що ця функція виконує більше ніж одне. Це робить одне, якщо прапор є істинним, а інше, якщо прапор неправдивий!

foo(true);

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

Краще структурована база коду дасть вам кращу можливість оптимізувати код.


Я просто використовую це як приклад. Те, що передається у функцію, може бути int, double, класом, як ви його називаєте, його насправді не в основі проблеми.
Philip Guin,

Питання, яке ви задали, стосується перемикання всередині вашої функції, в більшості випадків це запах коду. Це можна досягти багатьма способами, і читачеві не потрібно читати всю цю функцію, сказати, що означає foo (28)?
Юань

0

Одна з думок школи (не можу згадати яєчник, який запропонував це на даний момент) - це те, що всі функції повинні мати лише одну точку повернення з структурної точки зору, щоб полегшити читання та налагодження коду. Це, мабуть, більше для програмування релігійних дебатів.

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


Е-е, я думав, це пов’язано з прибиранням (особливо при кодуванні на С).
Томас Едінг

ні, незалежно від того, де ви залишаєте метод, доки повертаєте стек, повернувши його назад (це все, що "очищено").
MartyTPS

-4

Я радий, що Ви підняли це питання. Завжди слід користуватися гілками за раннє повернення. Навіщо зупинятися на цьому? Об’єднайте всі свої функції в одну, якщо зможете (принаймні стільки, скільки зможете). Це можливо, якщо немає рекурсії. Зрештою, у вас буде одна велика основна функція, але це те, що вам потрібно / потрібно для такого роду речей. Після цього перейменуйте свої ідентифікатори, щоб вони були якомога коротшими. Таким чином, коли ваш код виконується, менше часу витрачається на читання імен. Далі зробіть ...


3
Я можу сказати, що ви жартуєте, але страшно те, що деякі люди можуть просто сприймати вашу пораду серйозно!
Даніель Приден

Погодьтеся з Даніелем. Наскільки я люблю цинізм - його не слід використовувати в технічній документації, газетах та на сайтах Q&A, таких як SO.
cfi

1
-1 для цинічної відповіді, не обов’язково розпізнаваної початківцями.
Йохан Безем
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.