Skuteczność przedwczesnego powrotu w funkcji

97

Jest to sytuacja, z którą często się spotykam jako niedoświadczony programista i zastanawiam się nad tym, szczególnie w przypadku mojego ambitnego, intensywnego projektu, który staram się zoptymalizować. W przypadku głównych języków podobnych do języka C (C, objC, C ++, Java, C # itp.) I ich zwykłych kompilatorów, czy te dwie funkcje będą działać równie wydajnie? Czy jest jakaś różnica w skompilowanym kodzie?

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

    //Do different stuff
}

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

Zasadniczo, czy istnieje bezpośredni bonus / kara za wydajność podczas wczesnego breakrobienia lub returnrobienia zdjęć? W jaki sposób działa stackframe? Czy istnieją zoptymalizowane przypadki specjalne? Czy są jakieś czynniki (takie jak inlining lub rozmiar „Do rzeczy”), które mogą na to znacząco wpłynąć?

Zawsze jestem zwolennikiem lepszej czytelności w stosunku do drobnych optymalizacji (często widzę foo1 przy walidacji parametrów), ale pojawia się to tak często, że chciałbym raz na zawsze odłożyć na bok wszelkie zmartwienia.

I zdaję sobie sprawę z pułapek przedwczesnej optymalizacji ... ugh, to są bolesne wspomnienia.

EDYCJA: Przyjąłem odpowiedź, ale odpowiedź EJP dość zwięźle wyjaśnia, dlaczego użycie a returnjest praktycznie pomijalne (w asemblerze returntworzy „gałąź” na końcu funkcji, która jest niezwykle szybka. Gałąź zmienia rejestr komputera i może również wpływać na pamięć podręczną i potok, co jest dość malutkie). W tym przypadku dosłownie nie robi to różnicy, ponieważ zarówno the, jak if/elsei the returntworzą tę samą gałąź na końcu funkcji.

Philip Guin
źródło
22
Nie sądzę, żeby takie rzeczy miały zauważalny wpływ na wydajność. Po prostu napisz mały test i przekonaj się sam. Imo, pierwszy wariant jest lepszy, ponieważ nie dostajesz niepotrzebnego zagnieżdżania, co poprawia czytelność
SirVaulterScoff
10
@SirVaulterScott, chyba że te dwa przypadki są w jakiś sposób symetryczne, w takim przypadku chciałbyś wydobyć symetrię, umieszczając je na tym samym poziomie wcięcia.
luqui,
3
SirVaulterScoff: +1 za zmniejszenie niepotrzebnego zagnieżdżania
fjdumont
11
Czytelność >>> Mikro optymalizacje. Zrób to w dowolny sposób, który ma większy sens dla wetware, który będzie to utrzymywał. Na poziomie kodu maszynowego te dwie struktury są identyczne, gdy zostaną wprowadzone nawet do dość głupiego kompilatora. Kompilator optymalizujący usunie wszelkie pozory przewagi szybkości między nimi.
SplinterReality,
12
Nie optymalizuj swojego projektu wymagającego dużej szybkości, martwiąc się o takie rzeczy. Profiluj swoją aplikację, aby dowiedzieć się, gdzie jest faktycznie wolna - jeśli w rzeczywistości jest zbyt wolna, gdy skończysz działać. Prawie na pewno nie możesz zgadnąć, co tak naprawdę go spowalnia.
blueshift,

Odpowiedzi:

92

Nie ma żadnej różnicy:

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

Oznacza to, że nie ma żadnej różnicy w generowanym kodzie, nawet bez optymalizacji w dwóch kompilatorach

Dani
źródło
59
Albo lepiej: istnieje przynajmniej wersja pewnego kompilatora, który generuje ten sam kod dla obu wersji.
UncleZeiv
11
@UncleZeiv - większość, jeśli nie wszystkie kompilatory, przetłumaczy źródło na model wykresu przepływu wykonania. Trudno sobie wyobrazić rozsądną implementację, która dawałaby znacząco różne wykresy przepływu dla tych dwóch przykładów. Jedyną różnicą, jaką możesz zauważyć, jest to, że dwie różne rzeczy mogą zostać zamienione - a nawet to może zostać cofnięte w wielu implementacjach, aby zoptymalizować przewidywanie gałęzi lub w innym przypadku, w którym platforma określa preferowaną kolejność.
Steve314
6
@ Steve314, jasne, po prostu
szukałem
@UncleZeiv: testowane również na clang i ten sam wynik
Dani,
Nie rozumiem. Wydaje się jasne, że something()zawsze zostanie stracony. W pierwotnym pytaniu OP ma Do stuffi w Do diffferent stuffzależności od flagi. Nie jestem pewien, że wygenerowany kod będzie taki sam.
Luc M,
65

Krótka odpowiedź brzmi: nie ma różnicy. Zrób sobie przysługę i przestań się tym martwić. Optymalizujący kompilator jest prawie zawsze mądrzejszy od Ciebie.

Skoncentruj się na czytelności i łatwości konserwacji.

Jeśli chcesz zobaczyć, co się stanie, zbuduj je z włączonymi optymalizacjami i spójrz na wyjście asemblera.

blueshift
źródło
8
@Philip: I wyświadcz wszystkim innym przysługę i przestań się tym martwić. Kod, który napiszesz, będzie czytany i utrzymywany również przez innych (a nawet jeśli napiszesz, że nigdy nie zostanie odczytany przez innych, nadal rozwiniesz nawyki, które wpłyną na inny napisany przez Ciebie kod, który będzie czytany przez innych). Zawsze pisz kod tak, aby był jak najłatwiejszy do zrozumienia.
hlovdal
8
Optymalizatorzy nie są mądrzejsi od Ciebie !!! Szybciej tylko decydują, gdzie wpływ nie ma większego znaczenia. Tam, gdzie to naprawdę ma znaczenie, z pewnością z pewnym doświadczeniem zoptymalizujesz lepiej niż kompilator.
johannes,
10
@johannes Pozwól mi się nie zgodzić. Kompilator nie zmieni twojego algorytmu na lepszy, ale wykonuje niesamowitą pracę przy zmianie kolejności instrukcji, aby osiągnąć maksymalną wydajność potoku i inne nie tak trywialne rzeczy dla pętli (rozszczepienie, fuzja itp.), Które nawet doświadczony programista nie może zdecydować co jest lepsze a priori, jeśli nie ma gruntownej wiedzy na temat architektury procesora.
fortran
3
@johannes - w przypadku tego pytania możesz założyć, że tak. Ogólnie rzecz biorąc, czasami możesz być w stanie zoptymalizować lepiej niż kompilator w kilku specjalnych przypadkach, ale w dzisiejszych czasach wymaga to sporej wiedzy specjalistycznej - normalny przypadek jest taki, że optymalizator stosuje większość optymalizacji, o których możesz pomyśleć i robi to systematycznie, a nie tylko w kilku szczególnych przypadkach. WRT to pytanie, kompilator prawdopodobnie skonstruuje dokładnie ten sam wykres przepływu wykonania dla obu formularzy. Wybór lepszego algorytmu to praca człowieka, ale optymalizacja na poziomie kodu prawie zawsze jest stratą czasu.
Steve314
4
Zgadzam się i nie zgadzam się z tym. Są przypadki, kiedy kompilator nie może wiedzieć, że coś jest równoważne z czymś innym. Czy wiesz, że często jest to o wiele szybsze x = <some number>niż if(<would've changed>) x = <some number>niepotrzebne gałęzie mogą naprawdę boleć. Z drugiej strony, gdyby nie było to w głównej pętli niezwykle intensywnej pracy, też bym się tym nie martwił.
user606723
28

Ciekawe odpowiedzi: Chociaż zgadzam się z nimi wszystkimi (na razie), istnieją możliwe konotacje tego pytania, które do tej pory są całkowicie pomijane.

Jeśli powyższy prosty przykład zostanie rozszerzony o alokację zasobów, a następnie sprawdzanie błędów z potencjalnym wynikającym z tego zwolnieniem zasobów, obraz może się zmienić.

Rozważ naiwne podejście, które mogą przyjąć początkujący:

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

Powyższe reprezentowałoby skrajną wersję stylu przedwczesnego powrotu. Zwróć uwagę, jak kod staje się bardzo powtarzalny i niemożliwy do utrzymania w czasie, gdy rośnie jego złożoność. W dzisiejszych czasach ludzie mogą używać obsługi wyjątków, aby je złapać.

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

Philip zasugerował, po obejrzeniu poniższego przykładu goto, aby użyć bezłamkowego przełącznika / obudowy wewnątrz bloku catch powyżej. Można by się przełączyć (typeof (e)), a następnie upaść przez free_resourcex()wywołania, ale nie jest to trywialne i wymaga rozważenia projektowego . I pamiętaj, że przełącznik / obudowa bez przerw jest dokładnie taki sam, jak goto z połączonymi łańcuchami etykietami poniżej ...

Jak zauważył Mark B, w C ++ za dobry styl uważa się podążanie za zasadą pozyskiwania zasobów to inicjalizacja , w skrócie RAII . Istotą koncepcji jest użycie instancji obiektu do pozyskiwania zasobów. Zasoby są następnie automatycznie zwalniane, gdy tylko obiekty wyjdą poza zasięg i zostaną wywołane ich destruktory. W przypadku współzależnych zasobów należy zwrócić szczególną uwagę, aby zapewnić prawidłową kolejność zwalniania alokacji i zaprojektować takie typy obiektów, aby wymagane dane były dostępne dla wszystkich destruktorów.

Lub w dni przed wyjątkiem może to zrobić:

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

Ale ten nadmiernie uproszczony przykład ma kilka wad: Można go używać tylko wtedy, gdy przydzielone zasoby nie są od siebie zależne (np. Nie można go użyć do alokacji pamięci, a następnie otwarcia uchwytu pliku, a następnie wczytania danych z uchwytu do pamięci ) i nie dostarcza indywidualnych, rozróżnialnych kodów błędów jako wartości zwracanych.

Aby kod był szybki (!), Zwarty, czytelny i rozszerzalny, Linus Torvalds narzucił inny styl kodu jądra, który zajmuje się zasobami, nawet używając niesławnego goto w sposób, który ma absolutnie sens :

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

Istotą dyskusji na listach dyskusyjnych jądra jest to, że większość funkcji językowych, które są „preferowane” w stosunku do instrukcji goto, to niejawne goto, takie jak ogromne, podobne do drzewa if / else, programy obsługi wyjątków, instrukcje pętli / przerwania / kontynuacji itp. .A goto w powyższym przykładzie są uważane za w porządku, ponieważ skaczą tylko na niewielką odległość, mają wyraźne etykiety i uwalniają kod z innych bałaganów do śledzenia warunków błędów. To pytanie zostało również omówione tutaj na temat przepełnienia stosu .

Jednak to, czego brakuje w ostatnim przykładzie, to dobry sposób na zwrócenie kodu błędu. Myślałem o dodaniu result_code++po każdym free_resource_x()wywołaniu i zwróceniu tego kodu, ale to zniwelowało niektóre przyrosty szybkości wynikające z powyższego stylu kodowania. I trudno jest zwrócić 0 w przypadku sukcesu. Może po prostu nie mam wyobraźni ;-)

Więc tak, myślę, że jest duża różnica w kwestii kodowania przedwczesnych zwrotów, czy nie. Ale myślę również, że jest to widoczne tylko w bardziej skomplikowanym kodzie, którego restrukturyzacja i optymalizacja pod kątem kompilatora jest trudniejsza lub niemożliwa. Co zwykle ma miejsce, gdy w grę wchodzi alokacja zasobów.

cfi
źródło
1
Wow, naprawdę interesujące. Zdecydowanie potrafię docenić nieutwardzalność tego naiwnego podejścia. Jak jednak poprawiłaby się obsługa wyjątków w tym konkretnym przypadku? Podobnie jak catchzawierające switchbezbłędne oświadczenie o kodzie błędu?
Philip Guin,
@Philip Dodano podstawowy przykład obsługi wyjątków. Zauważ, że tylko goto ma możliwość awarii. Twój proponowany przełącznik (typeof (e)) pomógłby, ale nie jest trywialny i wymaga rozważenia projektowego . I pamiętaj, że przełącznik / przypadek bez przerw jest dokładnie taki sam, jak goto z etykietami połączonymi łańcuchami ;-)
cfi
+1 to jest poprawna odpowiedź dla C / C ++ (lub dowolnego języka, który wymaga ręcznego zwalniania pamięci). Osobiście nie podoba mi się wersja z wieloma etykietami. W mojej poprzedniej firmie zawsze „goto fin” (była to firma francuska). Ostatecznie cofnęlibyśmy alokację pamięci i było to jedyne użycie goto, które przeszło przegląd kodu.
Kip
1
Zauważ, że w C ++ nie zrobiłbyś żadnego z tych podejść, ale użyłbyś RAII, aby upewnić się, że zasoby są prawidłowo wyczyszczone.
Mark B
12

Chociaż nie jest to zbyt wiele odpowiedzi, kompilator produkcyjny będzie znacznie lepszy w optymalizacji niż ty. Wolałbym czytelność i łatwość konserwacji od tego rodzaju optymalizacji.

Lou
źródło
9

Mówiąc konkretnie, returnzostanie skompilowany do gałęzi do końca metody, gdzie będzie RETinstrukcja lub cokolwiek to może być. Jeśli go pominiesz, koniec bloku przed elsezostanie wkompilowany w gałąź do końca elsebloku. Jak więc widać, w tym konkretnym przypadku nie ma to żadnego znaczenia.

Markiz Lorne
źródło
Mam cię. Myślę, że to odpowiada na moje pytanie dość zwięźle; Wydaje mi się, że to dosłownie tylko dodanie do rejestru, co jest dość pomijalne (chyba że programujesz systemy, a nawet wtedy ...).
Philip Guin
@Philip jakie dodanie rejestru? Na ścieżce nie ma żadnych dodatkowych instrukcji.
Markiz Lorne
Cóż, obaj mieliby dodatki do rejestru. To wszystko, czym jest gałąź montażu, prawda? Dodatek do licznika programów? Mogę się tutaj mylić.
Philip Guin
1
@Philip Nie, gałąź Assembly to gałąź Assembly. Ma to oczywiście wpływ na komputer, ale może to nastąpić przez całkowite przeładowanie go, a także ma efekty uboczne w procesorze w rurociągu, pamięci podręcznej itp.
Marquis of Lorne
4

Jeśli naprawdę chcesz wiedzieć, czy istnieje różnica w skompilowanym kodzie dla twojego konkretnego kompilatora i systemu, będziesz musiał sam skompilować i przyjrzeć się asemblacji.

Jednak w ogólnym rozrachunku jest prawie pewne, że kompilator może zoptymalizować lepiej niż twoje precyzyjne dostrojenie, a nawet jeśli nie jest to możliwe, jest bardzo mało prawdopodobne, aby faktycznie miało to znaczenie dla wydajności twojego programu.

Zamiast tego napisz kod w sposób najbardziej przejrzysty dla ludzi do czytania i utrzymywania, i pozwól kompilatorowi zrobić to, co robi najlepiej: wygeneruj najlepszy asembler, jaki może, ze swojego źródła.

Mark B.
źródło
4

W twoim przykładzie zwrot jest zauważalny. Co dzieje się z osobą debugującą, gdy zwracana jest strona lub dwie powyżej / poniżej, gdzie // występują różne rzeczy? Znacznie trudniej jest znaleźć / zobaczyć, gdy jest więcej kodu.

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
źródło
Oczywiście funkcja nie powinna mieć więcej niż jednej (lub nawet dwóch) stron. Ale aspekt debugowania nie został jeszcze omówiony w żadnej z innych odpowiedzi. Punkt zajęty!
cfi
3

Zdecydowanie zgadzam się z blueshift: czytelność i łatwość utrzymania przede wszystkim !. Ale jeśli naprawdę się martwisz (lub po prostu chcesz dowiedzieć się, co robi Twój kompilator, co na dłuższą metę jest zdecydowanie dobrym pomysłem), powinieneś poszukać siebie.

Będzie to oznaczać używanie dekompilatora lub patrzenie na wyjście kompilatora niskiego poziomu (np. Język asemblera). W języku C # lub dowolnym języku .Net udokumentowane tutaj narzędzia zapewnią to, czego potrzebujesz.

Ale jak sam zauważyłeś, jest to prawdopodobnie przedwczesna optymalizacja.

Panie Putty
źródło
1

From Clean Code: A Handbook of Agile Software Craftsmanship

Argumenty flagowe są brzydkie. Przekazywanie wartości logicznej do funkcji to naprawdę straszna praktyka. Natychmiast komplikuje podpis metody, głośno ogłaszając, że ta funkcja robi więcej niż jedną rzecz. Robi jedną rzecz, jeśli flaga jest prawdziwa, a inną, jeśli flaga jest fałszywa!

foo(true);

w kodzie sprawi, że czytelnik przejdzie do funkcji i straci czas na czytanie foo (flaga boolowska)

Lepiej ustrukturyzowany kod daje lepsze możliwości optymalizacji kodu.

Yuan
źródło
Używam tego tylko jako przykładu. To, co jest przekazywane do funkcji, może być int, double, class, możesz to nazwać, tak naprawdę nie jest to sedno problemu.
Philip Guin
Pytanie, które zadałeś, dotyczy przełączania wewnątrz funkcji, w większości przypadków jest to zapach kodu. Można to osiągnąć na wiele sposobów i czytelnik nie musi czytać całej funkcji, powiedzieć, co oznacza foo (28)?
Yuan
0

Jedna szkoła myślenia (nie pamięta jajogłowego, który to zaproponował) jest taka, że ​​wszystkie funkcje powinny mieć tylko jeden punkt powrotu ze strukturalnego punktu widzenia, aby kod był łatwiejszy do odczytania i debugowania. To, jak sądzę, bardziej służy programowaniu debat religijnych.

Jednym z technicznych powodów, dla których możesz chcieć kontrolować, kiedy i jak kończy się funkcja, która łamie tę regułę, jest to, że kodujesz aplikacje czasu rzeczywistego i chcesz mieć pewność, że wszystkie ścieżki sterowania przez funkcję zajmują taką samą liczbę cykli zegara.

MartyTPS
źródło
Uh, myślałem, że ma to związek ze sprzątaniem (szczególnie podczas kodowania w C).
Thomas Eding,
nie, bez względu na to, gdzie zostawisz metodę, o ile zwracasz, stos jest z powrotem uderzany (czyli wszystko, co jest „czyszczone”).
MartyTPS
-4

Cieszę się, że zadałeś to pytanie. Zawsze powinieneś używać gałęzi przed wcześniejszym powrotem. Dlaczego na tym poprzestać? Połącz wszystkie swoje funkcje w jedną, jeśli możesz (przynajmniej tak bardzo, jak możesz). Jest to wykonalne, jeśli nie ma rekursji. W końcu będziesz miał jedną ogromną główną funkcję, ale to jest to, czego potrzebujesz / chcesz do tego rodzaju rzeczy. Następnie zmień nazwy swoich identyfikatorów, aby były jak najkrótsze. W ten sposób, gdy kod jest wykonywany, mniej czasu spędza się na czytaniu nazw. Następnie zrób ...

Thomas Eding
źródło
3
Mogę powiedzieć, że żartujesz, ale przerażające jest to, że niektórzy ludzie mogą po prostu poważnie potraktować twoją radę!
Daniel Pryden,
Zgadzam się z Danielem. Chociaż uwielbiam cynizm - nie powinien być używany w dokumentacji technicznej, oficjalnych dokumentach i stronach z pytaniami i odpowiedziami, takich jak SO.
cfi
1
-1 dla cynicznej odpowiedzi, niekoniecznie rozpoznawalnej dla początkujących.
Johan Bezem,