Uczyłem się trochę C ++ i często muszę zwracać duże obiekty z funkcji tworzonych w ramach funkcji. Wiem, że istnieje przejście przez referencję, zwrócenie wskaźnika i zwrócenie rozwiązań typu referencyjnego, ale przeczytałem również, że kompilatory C ++ (i standard C ++) pozwalają na optymalizację zwracanych wartości, co pozwala uniknąć kopiowania dużych obiektów przez pamięć, a tym samym oszczędzając czas i pamięć tego wszystkiego.
Teraz wydaje mi się, że składnia jest o wiele jaśniejsza, gdy obiekt jest jawnie zwracany przez wartość, a kompilator zazwyczaj wykorzystuje RVO i czyni proces bardziej wydajnym. Czy poleganie na tej optymalizacji jest złą praktyką? To sprawia, że kod jest bardziej przejrzysty i czytelny dla użytkownika, co jest niezwykle ważne, ale czy powinienem być ostrożny, zakładając, że kompilator złapie okazję RVO?
Czy to mikrooptymalizacja, czy coś, o czym powinienem pamiętać podczas projektowania mojego kodu?
źródło
Odpowiedzi:
Stosuj zasadę najmniejszego zdziwienia .
Czy to ty i tylko ty będziesz używał tego kodu i czy jesteś pewien, że za 3 lata nie będziesz zaskoczony tym, co robisz?
Wtedy idź przed siebie.
We wszystkich innych przypadkach użyj standardowego sposobu; w przeciwnym razie ty i twoi koledzy będziecie mieli trudności ze znalezieniem błędów.
Na przykład mój kolega skarżył się na mój kod powodujący błędy. Okazuje się, że w ustawieniach kompilatora wyłączył ocenę zwarcia Boolean. Prawie go spoliczkowałem.
źródło
W tym konkretnym przypadku zdecydowanie warto wrócić po wartości.
RVO i NRVO są dobrze znanymi i solidnymi optymalizacjami, które naprawdę powinny być wykonane przez dowolny porządny kompilator, nawet w trybie C ++ 03.
Przestaw semantykę, aby obiekty zostały przeniesione z funkcji, jeśli (N) RVO nie miało miejsca. Przydaje się to tylko wtedy, gdy Twój obiekt korzysta z dynamicznych danych wewnętrznie (jak
std::vector
robi to), ale tak powinno być, jeśli jest tak duży - przepełnienie stosu jest ryzykowne w przypadku dużych automatycznych obiektów.C ++ 17 wymusza RVO. Nie martw się, nie zniknie ono na tobie i zakończy się całkowicie dopiero wtedy, gdy kompilatory będą aktualne.
I na koniec, wymuszanie dodatkowej alokacji dynamicznej w celu zwrócenia wskaźnika lub wymuszanie domyślnej konstrukcji typu wyniku, aby można go było przekazać jako parametr wyjściowy, zarówno brzydkie, jak i nie idiomatyczne rozwiązania problemu, którego prawdopodobnie nigdy nie będziesz mieć, posiadać.
Wystarczy napisać kod, który ma sens, i podziękować autorom kompilatora za poprawną optymalizację kodu, który ma sens.
źródło
Nie jest to żadna mało znana, urocza, mikrooptymalizacja, o której czytasz na jakimś małym blogu o małym natężeniu ruchu, a potem możesz czuć się sprytnie i lepiej korzystać z niej.
Po C ++ 11 RVO jest standardowym sposobem pisania tego kodu. Jest to powszechne, oczekiwane, nauczane, wspomniane w rozmowach, wspomniane w blogach, wspomniane w standardzie, będą zgłaszane jako błędy kompilatora, jeśli nie zostaną zaimplementowane. W C ++ 17 język idzie o krok dalej i nakazuje kopiowanie elision w niektórych scenariuszach.
Powinieneś absolutnie polegać na tej optymalizacji.
Co więcej, wartość zwracana przez wartość prowadzi do znacznie łatwiejszego do odczytania i zarządzania kodem niż kod zwracany przez referencję. Semantyka wartości jest potężną rzeczą, która sama w sobie może prowadzić do większych możliwości optymalizacji.
źródło
Poprawność pisanego kodu nigdy nie powinna zależeć od optymalizacji. Powinien wyświetlać poprawny wynik po uruchomieniu na „maszynie wirtualnej” C ++, której używają w specyfikacji.
Jednak to, o czym mówisz, jest raczej pytaniem o wydajność. Twój kod działa lepiej, jeśli jest zoptymalizowany za pomocą kompilatora optymalizującego RVO. W porządku, z wszystkich powodów wskazanych w innych odpowiedziach.
Jeśli jednak potrzebujesz tej optymalizacji (na przykład, jeśli konstruktor kopiowania spowodowałby awarię kodu), teraz masz kaprysy kompilatora.
Myślę, że najlepszym przykładem tego w mojej własnej praktyce jest optymalizacja wezwania ogona:
To głupi przykład, ale pokazuje wywołanie ogonowe, w którym funkcja jest wywoływana rekurencyjnie na końcu funkcji. Maszyna wirtualna C ++ pokaże, że ten kod działa poprawnie, chociaż mogę sprawić trochę zamieszania, dlaczego w ogóle pisałem taką procedurę dodawania. Jednak w praktycznych implementacjach C ++ mamy stos i ma on ograniczoną przestrzeń. Jeśli zostanie to zrobione pedantycznie, funkcja ta musiałaby wcisnąć przynajmniej
b + 1
stos ramek na stos, podobnie jak jego dodawanie. Jeśli chcę obliczyćsillyAdd(5, 7)
, to nie jest wielka sprawa. Jeśli chcę obliczyćsillyAdd(0, 1000000000)
, mogę mieć poważne kłopoty z spowodowaniem przepływu StackOverflow (a nie tego dobrego ).Widzimy jednak, że kiedy osiągniemy ostatnią linię powrotną, naprawdę skończyliśmy ze wszystkim w bieżącej ramce stosu. Tak naprawdę nie musimy tego robić. Optymalizacja wywołania ogona pozwala „ponownie wykorzystać” istniejącą ramkę stosu do następnej funkcji. W ten sposób potrzebujemy tylko 1 ramki stosu
b+1
. (Nadal musimy robić te wszystkie głupie dodawania i odejmowania, ale nie zajmują więcej miejsca.) W efekcie optymalizacja zmienia kod w:W niektórych językach specyfikacja wyraźnie wymaga optymalizacji ogona. C ++ nie jest jednym z nich. Nie mogę polegać na kompilatorach C ++ w rozpoznawaniu możliwości optymalizacji wywołania ogona, chyba że pójdę indywidualnie. W mojej wersji programu Visual Studio wersja wydania wykonuje optymalizację wywołania ogona, ale wersja debugowania nie (zgodnie z projektem).
Dlatego źle byłoby dla mnie polegać na umiejętności obliczania
sillyAdd(0, 1000000000)
.źródło
#ifdef
blokach i mają dostępne obejście zgodne ze standardami.b = b + 1
?W praktyce programy C ++ oczekują pewnych optymalizacji kompilatora.
Przyjrzyj się zwłaszcza standardowym nagłówkom standardowych implementacji kontenerów . Dzięki GCC możesz poprosić o wstępnie przetworzony formularz (
g++ -C -E
) i wewnętrzną reprezentacjęg++ -fdump-tree-gimple
GIMPLE ( lub Gimple SSA z-fdump-tree-ssa
) większości plików źródłowych (technicznie jednostek tłumaczeniowych) przy użyciu kontenerów. Zaskoczy Cię ilość optymalizacji, która jest wykonana (zg++ -O2
). Dlatego implementatorzy kontenerów polegają na optymalizacjach (i przez większość czasu implementator standardowej biblioteki C ++ wie, co by się wydarzyła optymalizacja i zapisuje implementację kontenera z myślą o nich; czasami pisałby również zapis optymalizacyjny w kompilatorze do zajmować się funkcjami wymaganymi przez standardową bibliotekę C ++).W praktyce optymalizacje kompilatora sprawiają, że C ++ i jego standardowe kontenery są wystarczająco wydajne. Możesz więc na nich polegać.
Podobnie jest w przypadku sprawy RVO wspomnianej w pytaniu.
Standard C ++ został zaprojektowany wspólnie (w szczególności poprzez eksperymentowanie z wystarczająco dobrymi optymalizacjami przy jednoczesnym proponowaniu nowych funkcji), aby działał dobrze z możliwymi optymalizacjami.
Rozważmy na przykład poniższy program:
skompiluj to z
g++ -O3 -fverbose-asm -S
. Dowiesz się, że wygenerowana funkcja nie uruchamia żadnejCALL
instrukcji maszyny. Tak więc większość kroki C ++ (budowa zamknięcia lambda, jego wielokrotnego stosowania, zdobyciebegin
iend
iteratory itd ...) zostały zoptymalizowane. Kod maszynowy zawiera tylko pętlę (która nie pojawia się jawnie w kodzie źródłowym). Bez takich optymalizacji C ++ 11 nie odniesie sukcesu.addenda
(dodany 31 grudnia st 2017)
Zobacz CppCon 2017: Matt Godbolt „Co ostatnio zrobił dla mnie mój kompilator? Unbolting the Compiler's Lid ” .
źródło
Ilekroć korzystasz z kompilatora, rozumiesz, że wygeneruje on kod maszynowy lub bajtowy. Nie gwarantuje to niczego takiego jak ten wygenerowany kod, poza tym, że zaimplementuje kod źródłowy zgodnie ze specyfikacją języka. Należy pamiętać, że ta gwarancja jest taka sama niezależnie od zastosowanego poziomu optymalizacji, a zatem ogólnie nie ma powodu, aby uważać jedno wyjście za bardziej „właściwe” niż drugie.
Co więcej, w takich przypadkach, jak RVO, gdzie jest to określone w języku, wydaje się, że nie ma sensu starać się go unikać, zwłaszcza jeśli upraszcza to kod źródłowy.
Dużo wysiłku włożono w to, aby kompilatory generowały wydajną moc wyjściową, i oczywiście celem jest wykorzystanie tych możliwości.
Mogą istnieć powody używania niezoptymalizowanego kodu (na przykład do debugowania), ale przypadek wymieniony w tym pytaniu nie wydaje się być jednym (a jeśli Twój kod zawiedzie tylko po zoptymalizowaniu, i nie jest to konsekwencją pewnej osobliwości urządzenie, na którym go uruchomiłeś, to gdzieś jest błąd i jest mało prawdopodobne, że znajdzie się w kompilatorze.)
źródło
Myślę, że inni dobrze opisywali konkretny kąt dotyczący C ++ i RVO. Oto bardziej ogólna odpowiedź:
Jeśli chodzi o poprawność, nie powinieneś polegać na optymalizacjach kompilatora ani ogólnie na specyficznych zachowaniach kompilatora. Na szczęście nie robisz tego.
Jeśli chodzi o wydajność, to trzeba liczyć na zachowanie kompilatora specyficzne w ogóle, a optymalizacje kompilatora w szczególności. Kompilator zgodny ze standardami może dowolnie kompilować kod w dowolny sposób, o ile skompilowany kod zachowuje się zgodnie ze specyfikacją języka. Nie znam żadnej specyfikacji głównego języka, która określa szybkość każdej operacji.
źródło
Optymalizacje kompilatora powinny wpływać tylko na wydajność, a nie na wyniki. Poleganie na optymalizacjach kompilatora w celu spełnienia niefunkcjonalnych wymagań jest nie tylko uzasadnione, ale często jest przyczyną wyboru jednego kompilatora.
Flagi, które określają sposób wykonywania poszczególnych operacji (na przykład warunki indeksowania lub przepełnienia), są często łączone z optymalizacjami kompilatora, ale nie powinny. Wyraźnie wpływają na wyniki obliczeń.
Jeśli optymalizacja kompilatora powoduje różne wyniki, jest to błąd - błąd w kompilatorze. Opieranie się na błędzie w kompilatorze jest na dłuższą metę błędem - co się stanie, gdy zostanie naprawione?
Używanie flag kompilatora, które zmieniają sposób wykonywania obliczeń, powinno być dobrze udokumentowane, ale używane w razie potrzeby.
źródło
x*y>z
arbitralnie dawał 0 lub 1 w przypadku przepełnienia, pod warunkiem, że nie ma innych skutków ubocznych , wymagając, aby programista albo zapobiegał przepełnieniu za wszelką cenę, albo zmusił kompilator do oceny wyrażenia w określony sposób. niepotrzebnie zaburzają optymalizacje vs. mówienie, że ...x*y
dowolnym momencie zachowywać się tak, jakby promował swoje operandy do dowolnego dowolnego dłuższego typu (umożliwiając w ten sposób formy podnoszenia i zmniejszania siły, które zmieniłyby zachowanie niektórych przypadków przepełnienia). Jednak wiele kompilatorów wymaga, aby programiści albo zapobiegali przepełnieniu za wszelką cenę, albo zmuszali kompilatory do obcinania wszystkich wartości pośrednich w przypadku przepełnienia.Nie.
To właśnie robię cały czas. Jeśli potrzebuję uzyskać dostęp do dowolnego 16-bitowego bloku w pamięci, robię to
... i polegaj na kompilatorze, który robi wszystko, aby zoptymalizować ten fragment kodu. Kod działa na ARM, i386, AMD64 i praktycznie na każdej architekturze. Teoretycznie nieoptymalizowany kompilator mógłby wywołać
memcpy
, co skutkuje całkowitą złą wydajnością, ale nie stanowi to dla mnie problemu, ponieważ korzystam z optymalizacji kompilatora.Rozważ alternatywę:
Ten alternatywny kod nie działa na komputerach, które wymagają odpowiedniego wyrównania, jeśli
get_pointer()
zwróci nie wyrównany wskaźnik. Alternatywnie mogą występować problemy z aliasingiem.Różnica między -O2 i -O0 podczas korzystania z
memcpy
lewy jest ogromna: 3,2 Gb / s wydajności sumy kontrolnej IP w porównaniu z 67 Gb / s wydajności sumy kontrolnej IP. Ponad różnica wielkości rzędu!Czasami możesz potrzebować pomocy kompilatorowi. Na przykład zamiast polegać na kompilatorze do rozwijania pętli, możesz to zrobić samodzielnie. Albo przez wdrożenie słynnego urządzenia Duffa , albo w bardziej czysty sposób.
Wadą polegającą na optymalizacjach kompilatora jest to, że jeśli uruchomisz gdb w celu debugowania kodu, możesz odkryć, że wiele zostało zoptymalizowanych. Może być więc konieczna ponowna kompilacja z opcją -O0, co oznacza, że wydajność całkowicie wysysa podczas debugowania. Myślę, że jest to wada, którą warto wziąć pod uwagę, biorąc pod uwagę zalety optymalizacji kompilatorów.
Cokolwiek zrobisz, upewnij się, że twoja droga nie jest niezdefiniowanym zachowaniem. Z pewnością dostęp do losowego bloku pamięci jako 16-bitowej liczby całkowitej jest niezdefiniowanym zachowaniem z powodu problemów z aliasingiem i wyrównaniem.
źródło
Wszystkie próby wydajnego kodu napisanego w czymkolwiek poza złożeniem opierają się bardzo, bardzo w dużej mierze na optymalizacjach kompilatora, poczynając od najbardziej podstawowych, takich jak wydajne przydzielanie rejestrów, aby uniknąć zbędnych rozlewów stosów w każdym miejscu i co najmniej rozsądnie, jeśli nie doskonale, wybór instrukcji. W przeciwnym razie wrócilibyśmy do lat 80., gdzie musieliśmy wszędzie
register
podpowiedzieć i użyć minimalnej liczby zmiennych w funkcji, aby pomóc archaicznym kompilatorom języka C lub nawet wcześniej, gdygoto
była to przydatna optymalizacja rozgałęziania.Gdybyśmy nie czuli, że moglibyśmy polegać na zdolności naszego optymalizatora do optymalizacji naszego kodu, wszyscy nadal kodowalibyśmy ścieżki wykonywania krytyczne pod względem wydajności w asemblerze.
To naprawdę zależy od tego, jak niezawodnie czujesz, że można przeprowadzić optymalizację. Najlepszym rozwiązaniem jest profilowanie i analizowanie możliwości posiadanych kompilatorów, a nawet dezasemblacja, jeśli istnieje punkt dostępu, którego nie można ustalić, gdzie wydaje się kompilator. nie udało się dokonać oczywistej optymalizacji.
RVO jest czymś, co istnieje od wieków, a przynajmniej wykluczając bardzo złożone przypadki, jest to coś, co kompilatory niezawodnie stosują się od wieków. Zdecydowanie nie warto obejść problemu, który nie istnieje.
Błąd po stronie polegania na optymalizatorze, bez obaw
Wręcz przeciwnie, powiedziałbym, że błąd polega na tym, że zbyt wiele polega na optymalizacjach kompilatora, a za mało, a ta sugestia pochodzi od faceta, który pracuje w obszarach krytycznych pod względem wydajności, w których wydajność, łatwość konserwacji i postrzegana jakość wśród klientów jest wszystkie olbrzymie rozmycie. Wolałbym, abyś zbyt pewnie polegał na swoim optymalizatorze i znalazł jakieś niejasne przypadki, w których polegałeś zbyt wiele, zamiast polegać zbyt mało i po prostu wyrzucać z siebie przesądne lęki przez resztę życia. To przynajmniej pozwoli ci sięgnąć po profilera i właściwie zbadać, czy rzeczy nie działają tak szybko, jak powinny, i zdobyć po drodze cenną wiedzę, a nie przesądy.
Dobrze się opierasz na optymalizatorze. Tak trzymaj. Nie bądź taki, jak ten facet, który zaczyna jawnie prosić o wstawienie każdej funkcji wywoływanej w pętli, a nawet profilować z powodu błędnego strachu przed niedociągnięciami optymalizatora.
Profilowy
Profilowanie to tak naprawdę rondo, ale ostateczna odpowiedź na twoje pytanie. Problemem dla początkujących, którzy chcą pisać efektywny kod, z którym często się zmagają, jest nie to, co należy zoptymalizować, ale czego nie należy optymalizować, ponieważ rozwijają oni wszelkiego rodzaju błędne przeczucia dotyczące nieefektywności, które - choć ludzko intuicyjne - są błędne obliczeniowo. Rozwijanie doświadczenia z profilerem zacznie naprawdę zapewniać odpowiednią ocenę nie tylko możliwości optymalizacji kompilatorów, na których można śmiało polegać, ale także możliwości (i ograniczeń) sprzętu. Prawdopodobnie jeszcze więcej wartości ma profilowanie w uczeniu się, co nie było warte optymalizacji, niż uczenie się, co było.
źródło
Oprogramowanie może być napisane w C ++ na bardzo różnych platformach i do wielu różnych celów.
Zależy to całkowicie od celu oprogramowania. Czy powinien być łatwy w utrzymaniu, rozszerzaniu, łataniu, refaktoryzacji itp. lub inne rzeczy są ważniejsze, takie jak wydajność, koszt lub kompatybilność z określonym sprzętem lub czas potrzebny na opracowanie.
źródło
Myślę, że nudna odpowiedź brzmi: „to zależy”.
Czy pisanie kodu opartego na optymalizacji kompilatora, który prawdopodobnie zostanie wyłączony i gdzie luka nie jest udokumentowana oraz gdzie dany kod nie jest testowany jednostkowo, nie jest testowane, aby wiedzieć, że gdyby się zepsuło, jest złą praktyką ? Prawdopodobnie.
Czy pisanie kodu opartego na optymalizacji kompilatora, którego prawdopodobnie nie da się wyłączyć , jest udokumentowane i testowane jednostkowo, jest złą praktyką ? Może nie.
źródło
Jeśli nie powiesz nam więcej, jest to zła praktyka, ale nie z powodu, który sugerujesz.
Możliwe, że w przeciwieństwie do innych języków, których używałeś wcześniej, zwrócenie wartości obiektu w C ++ daje kopię obiektu. Jeśli następnie zmodyfikujesz obiekt, modyfikujesz inny obiekt . To znaczy, jeśli mam,
Obj a; a.x=1;
aObj b = a;
potem mamb.x += 2; b.f();
, toa.x
wciąż równa się 1, a nie 3.Tak więc nie, używanie obiektu jako wartości zamiast odniesienia lub wskaźnika nie zapewnia takiej samej funkcjonalności i może dojść do błędów w oprogramowaniu.
Być może wiesz o tym i nie wpływa to negatywnie na konkretny przypadek użycia. Jednak na podstawie sformułowania zawartego w pytaniu wydaje się, że możesz nie być świadomy tego rozróżnienia; sformułowanie takie jak „utwórz obiekt w funkcji”.
„stwórz obiekt w funkcji” brzmi tak, jak brzmi
new Obj;
„zwróć obiekt według wartości”Obj a; return a;
Obj a;
iObj* a = new Obj;
są bardzo, bardzo różne rzeczy; te pierwsze mogą spowodować uszkodzenie pamięci, jeśli nie zostaną właściwie wykorzystane i zrozumiane, a drugie mogą spowodować wycieki pamięci, jeśli nie zostaną właściwie wykorzystane i zrozumiane.źródło
return
instrukcji, która jest wymagana dla RVO. Co więcej, następnie mówisz o słowach kluczowychnew
i wskaźnikach, a nie o to chodzi w RVO. Wierzę, że albo nie rozumiesz pytania, albo RVO, albo być może jedno i drugie.Pieter B ma absolutną rację zalecając najmniejsze zdziwienie.
Aby odpowiedzieć na konkretne pytanie, co (najprawdopodobniej) oznacza to w C ++, należy zwrócić a
std::unique_ptr
do skonstruowanego obiektu.Powodem jest to, że dla programisty C ++ jest to bardziej zrozumiałe, co się dzieje.
Chociaż twoje podejście najprawdopodobniej zadziałałoby, skutecznie sygnalizujesz, że obiekt ma niewielki typ wartości, podczas gdy w rzeczywistości tak nie jest. Ponadto odrzucasz wszelkie możliwości abstrakcji interfejsu. Może to być odpowiednie dla twoich bieżących celów, ale często jest bardzo przydatne w przypadku macierzy.
Rozumiem, że jeśli pochodzisz z innych języków, początkowo wszystkie znaki mogą być mylące. Uważaj jednak, aby nie zakładać, że nieużywanie ich spowoduje, że kod będzie wyraźniejszy. W praktyce może być odwrotnie.
źródło
std::make_unique
, a niestd::unique_ptr
bezpośrednio. Po drugie, RVO nie jest jakąś ezoteryczną, specyficzną dla dostawcy optymalizacją: wpisuje się w standard. Nawet wtedy, gdy nie było, było szeroko wspierane i oczekiwane zachowanie. Nie ma sensu zwracanie a,std::unique_ptr
gdy wskaźnik nie jest potrzebny.