Czy przekazywanie argumentów jako stałych odwołań do przedwczesnej optymalizacji?

20

„Przedwczesna optymalizacja jest źródłem wszelkiego zła”

Myślę, że wszyscy możemy się zgodzić. I bardzo się staram tego uniknąć.

Ale ostatnio zastanawiałem się nad praktyką przekazywania parametrów przez const Reference zamiast Value . Zostałem nauczony / nauczony, że nietrywialne argumenty funkcji (tj. Większość typów nieprymitywnych) powinny być przekazywane przez odniesienie do stałej - wiele książek, które przeczytałem, poleca to jako „najlepszą praktykę”.

Nadal nie mogę przestać się zastanawiać: nowoczesne kompilatory i nowe funkcje językowe potrafią zdziałać cuda, więc wiedza, której się nauczyłem, może być bardzo przestarzała i nigdy tak naprawdę nie zadałam sobie trudu, aby profilować, jeśli istnieją jakiekolwiek różnice w wydajności między

void fooByValue(SomeDataStruct data);   

i

void fooByReference(const SomeDataStruct& data);

Czy praktyka, której się nauczyłem - przekazywanie stałych odwołań (domyślnie dla typów niebanalnych) - przedwczesna optymalizacja?

CharonX
źródło
1
Zobacz także: F.call w wytycznych C ++ Core, aby omówić różne strategie przekazywania parametrów.
amon
1
@DocBrown Przyjęta odpowiedź na to pytanie odnosi się do zasady najmniejszego zdziwienia , która również może tu obowiązywać (tj. Stosowanie stałych odniesień jest standardem branżowym itp.). To powiedziawszy, nie zgadzam się, że pytanie jest duplikatem: pytanie, na które się powołujesz, pyta, czy złym postępowaniem jest (ogólnie) poleganie na optymalizacji kompilatora. Pytanie to brzmi odwrotnie: czy przekazywanie referencji stałych jest optymalizacją (przedwczesną)?
CharonX
@CharonX: jeśli można polegać na optymalizacji kompilatora, odpowiedź na twoje pytanie brzmi „tak, ręczna optymalizacja nie jest konieczna, jest przedwczesna”. Jeśli nie można na nim polegać (być może dlatego, że nie wiadomo z góry, które kompilatory będą kiedykolwiek używane w kodzie), odpowiedź brzmi: „w przypadku większych obiektów prawdopodobnie nie jest to przedwczesne”. Więc nawet jeśli te dwa pytania nie są dosłownie równe, wydaje się, że IMHO są na tyle podobne, aby połączyć je ze sobą jako duplikaty.
Dok. Brown
1
@DocBrown: Zanim więc zadeklarujesz, że jest duplikatem, wskaż, gdzie w pytaniu jest napisane, że kompilator będzie dozwolony i będzie w stanie „zoptymalizować” to.
Deduplicator

Odpowiedzi:

49

„Optymalizacja przedwczesna” nie polega na wczesnym korzystaniu z optymalizacji . Chodzi o optymalizację przed zrozumieniem problemu, przed zrozumieniem środowiska wykonawczego, a często sprawienie, że kod będzie mniej czytelny i mniej konserwowalny dla wątpliwych wyników.

Używanie „const &” zamiast przekazywania obiektu przez wartość jest dobrze rozumianą optymalizacją, z dobrze zrozumiałym wpływem na środowisko wykonawcze, praktycznie bez wysiłku i bez żadnego złego wpływu na czytelność i łatwość konserwacji. W rzeczywistości poprawia oba, ponieważ mówi mi, że wywołanie nie zmodyfikuje przekazanego obiektu. Zatem dodanie „const &” zaraz po napisaniu kodu NIE JEST PREMATURE.

gnasher729
źródło
2
Zgadzam się z częścią odpowiedzi „praktycznie bez wysiłku”. Ale przedwczesna optymalizacja polega przede wszystkim na optymalizacji, zanim będzie to zauważalny, zmierzony wpływ na wydajność. I nie sądzę, że większość programistów C ++ (w tym ja) dokonuje jakichkolwiek pomiarów przed użyciem const&, więc myślę, że pytanie jest całkiem rozsądne.
Doc Brown
1
Mierzysz przed optymalizacją, aby wiedzieć, czy jakieś kompromisy są tego warte. Z const i całkowity wysiłek polega na wpisaniu siedmiu znaków i ma to również inne zalety. Gdy nie zamierzasz modyfikować przekazywanej zmiennej, jest to korzystne, nawet jeśli nie ma poprawy prędkości.
gnasher729
3
Nie jestem ekspertem od języka C, więc pytanie :. const& foomówi, że funkcja nie zmodyfikuje foo, więc osoba dzwoniąca jest bezpieczna. Ale skopiowana wartość mówi, że żaden inny wątek nie może zmienić foo, więc odbiorca jest bezpieczny. Dobrze? Tak więc w aplikacji wielowątkowej odpowiedź zależy od poprawności, a nie od optymalizacji.
user949300,
1
@DocBrown możesz w końcu podważyć motywację programisty, który umieścił const &? Jeśli zrobił to tylko dla wydajności, nie biorąc pod uwagę reszty, można by to uznać za przedwczesną optymalizację. Teraz, jeśli umieści to, ponieważ wie, że będą to stałe parametry, wówczas sam dokumentuje swój kod i daje kompilatorowi możliwość optymalizacji, co jest lepsze.
Walfrat
1
@ user949300: Niewiele funkcji pozwala na jednoczesną modyfikację ich argumentów lub za pomocą wywołań zwrotnych, i wyraźnie to mówią.
Deduplicator
16

TL; DR: Pomijając odniesienie do stałej jest nadal dobrym pomysłem w C ++, biorąc pod uwagę wszystko. Nie przedwczesna optymalizacja.

TL; DR2: Większość reklam nie ma sensu, dopóki nie zrobią tego.


Cel

Ta odpowiedź próbuje jedynie trochę rozszerzyć linkowany element w Wytycznych C ++ Core (po raz pierwszy wspomniany w komentarzu amona).

Ta odpowiedź nie próbuje rozwiązać problemu właściwego myślenia i stosowania różnych reklam szeroko rozpowszechnionych w kręgach programistów, szczególnie kwestii pogodzenia sprzecznych wniosków lub dowodów.


Możliwość zastosowania

Ta odpowiedź dotyczy tylko wywołań funkcji (nieusuwalnych zasięgów zagnieżdżonych w tym samym wątku).

(Uwaga dodatkowa). Gdy rzeczy przejezdne mogą wymknąć się z zakresu (tj. Mają okres życia, który potencjalnie przekracza zakres zewnętrzny), ważniejsze staje się zaspokojenie potrzeby aplikacji w zakresie zarządzania czasem życia obiektu, zanim cokolwiek innego. Zwykle wymaga to użycia odniesień, które są również w stanie zarządzać przez całe życie, takich jak inteligentne wskaźniki. Alternatywą może być użycie menedżera. Zauważ, że lambda jest rodzajem odłączalnego lunety; Przechwyty lambda zachowują się tak, jakby miały zasięg obiektu. Dlatego uważaj na zrzuty lambda. Uważaj również na to, jak przekazywana jest sama lambda - przez kopię lub przez odniesienie.


Kiedy przekazać wartość

W przypadku wartości skalarnych (standardowe operacje podstawowe, które mieszczą się w rejestrze maszyny i mają wartość semantyczną), dla których nie ma potrzeby komunikacji po mutacji (wspólne odwołanie), należy przekazać wartość.

W sytuacjach, w których odbiorca wymaga klonowania obiektu lub agregatu, należy przekazać wartość, w której kopia odbiorcy spełnia potrzebę sklonowanego obiektu.


Kiedy przekazywać przez odniesienie itp.

we wszystkich innych sytuacjach, mijaj wskaźniki, referencje, inteligentne wskaźniki, uchwyty (patrz: idiom uchwyt-ciało) itp. Ilekroć ta rada jest przestrzegana, stosuj zasadę stałej poprawności jak zwykle.

Rzeczy (agregaty, obiekty, tablice, struktury danych), które mają wystarczająco dużo miejsca w pamięci, zawsze powinny być zaprojektowane w celu ułatwienia przekazywania przez odniesienie, ze względu na wydajność. Ta rada zdecydowanie obowiązuje, gdy ma ona setki bajtów lub więcej. Ta rada ma granicę, gdy ma kilkadziesiąt bajtów.


Niezwykłe paradygmaty

Istnieją specjalne paradygmaty programowania, które z założenia są obficie kopiowane. Na przykład przetwarzanie ciągów, serializacja, komunikacja sieciowa, izolacja, zawijanie bibliotek stron trzecich, komunikacja między procesami w pamięci współużytkowanej itp. W tych obszarach aplikacji lub paradygmatach programowania dane są kopiowane ze struktur do struktur, a czasem ponownie pakowane w tablice bajtów.


Jak specyfikacja języka wpływa na tę odpowiedź przed rozważeniem optymalizacji.

Sub-TL; DR Propagowanie referencji nie powinno wywoływać żadnego kodu; przejście przez stałą referencyjną spełnia to kryterium. Jednak wszystkie pozostałe języki spełniają to kryterium bez wysiłku.

(Początkującym programistom C ++ zaleca się całkowite pominięcie tej sekcji).

(Początek tego rozdziału jest częściowo inspirowany odpowiedzią gnasher729. Jednak dochodzi się do innego wniosku.)

C ++ pozwala konstruktorom kopii i operatorom przypisania zdefiniowanym przez użytkownika.

(To był (był) odważny wybór, który był (był) zarówno niesamowity, jak i godny ubolewania. Jest to zdecydowanie odchylenie od dzisiejszej akceptowalnej normy w projektowaniu języka.)

Nawet jeśli programista C ++ tego nie zdefiniuje, kompilator C ++ musi wygenerować takie metody w oparciu o zasady językowe, a następnie ustalić, czy należy wykonać dodatkowy kod inny niż memcpy. Na przykład, a class/ structzawierający element std::vectorczłonkowski musi mieć konstruktor kopii i operator przypisania, który nie jest trywialny.

W innych językach odradzane są konstruktory kopiowania i klonowanie obiektów (z wyjątkiem przypadków, gdy jest to absolutnie konieczne i / lub znaczące dla semantyki aplikacji), ponieważ obiekty mają semantykę odniesienia, według projektu językowego. Te języki będą zazwyczaj miały mechanizm usuwania śmieci, który jest oparty na osiągalności zamiast własności opartej na zakresie lub liczenia referencji.

Gdy w C ++ (lub C) przekazywana jest referencja lub wskaźnik (w tym const referen), programista ma pewność, że nie zostanie wykonany żaden specjalny kod (funkcje zdefiniowane przez użytkownika lub wygenerowane przez kompilator), poza propagacją wartości adresu (odniesienie lub wskaźnik). Jest to przejrzystość zachowania, z której programiści C ++ czują się swobodnie.

Jednak tło jest takie, że język C ++ jest niepotrzebnie skomplikowany, tak że ta przejrzystość zachowania jest jak oaza (siedlisko, które można przeżyć) gdzieś w pobliżu strefy opadu jądrowego.

Aby dodać więcej błogosławieństw (lub obelg), C ++ wprowadza uniwersalne odwołania (wartości r) w celu ułatwienia operatorom ruchów zdefiniowanym przez użytkownika (konstruktory ruchów i operatory przypisania ruchów) dobrej wydajności. Jest to korzystne dla bardzo istotnego przypadku użycia (przenoszenia (przenoszenia) obiektów z jednej instancji do drugiej), poprzez zmniejszenie potrzeby kopiowania i głębokiego klonowania. Jednak w innych językach nielogiczne jest mówienie o takim ruchu obiektów.


(Sekcja nie na temat) Sekcja poświęcona artykułowi „Chcesz prędkości? Przekaż wartość!” napisany około 2009 roku.

Ten artykuł został napisany w 2009 roku i wyjaśnia uzasadnienie projektu dla wartości rw C ++. Artykuł ten stanowi ważny kontrargument do mojego wniosku w poprzedniej sekcji. Jednak przykład kodu tego artykułu i oświadczenie o wydajności od dawna zostało obalone.

Sub-TL; DR Projektowanie semantyki wartości r w C ++ pozwala na przykład na zaskakująco elegancką semantykę po stronie użytkownika dla Sortfunkcji. Tego eleganckiego nie można modelować (naśladować) w innych językach.

Funkcja sortowania jest stosowana do całej struktury danych. Jak wspomniano powyżej, powielanie byłoby dużo. Jako optymalizacja wydajności (która jest praktycznie istotna), funkcja sortowania ma być destrukcyjna w wielu językach innych niż C ++. Niszczycielski oznacza, że ​​docelowa struktura danych jest modyfikowana, aby osiągnąć cel sortowania.

W C ++ użytkownik może wybrać jedną z dwóch implementacji: destrukcyjną z lepszą wydajnością lub normalną, która nie modyfikuje danych wejściowych. (Szablon jest pominięty ze względu na zwięzłość).

/*caller specifically passes in input argument destructively*/
std::vector<T> my_sort(std::vector<T>&& input)
{
    std::vector<T> result(std::move(input)); /* destructive move */
    std::sort(result.begin(), result.end()); /* in-place sorting */
    return result; /* return-value optimization (RVO) */
}

/*caller specifically passes in read-only argument*/ 
std::vector<T> my_sort(const std::vector<T>& input)
{
    /* reuse destructive implementation by letting it work on a clone. */
    /* Several things involved; e.g. expiring temporaries as r-value */
    /* return-value optimization, etc. */
    return my_sort(std::vector<T>(input));  
}

/*caller can select which to call, by selecting r-value*/
std::vector<T> v1 = {...};
std::vector<T> v2 = my_sort(v1); /*non-destructive*/
std::vector<T> v3 = my_sort(std::move(v1)); /*v1 is gutted*/    

Oprócz sortowania, ta elegancja jest również przydatna w implementacji destrukcyjnego algorytmu znajdowania mediany w tablicy (początkowo nieposortowanej), poprzez partycjonowanie rekurencyjne.

Zauważ jednak, że większość języków zastosowałaby zrównoważone podejście do sortowania binarnego do sortowania zamiast destrukcyjnego algorytmu sortowania do tablic. Dlatego praktyczne znaczenie tej techniki nie jest tak wysokie, jak się wydaje.


Jak optymalizacja kompilatora wpływa na tę odpowiedź

Kiedy inliniowanie (a także optymalizacja całego programu / optymalizacja czasu łącza) jest stosowane na kilku poziomach wywołań funkcji, kompilator jest w stanie zobaczyć (czasem wyczerpująco) przepływ danych. Gdy tak się dzieje, kompilator może zastosować wiele optymalizacji, z których niektóre mogą wyeliminować tworzenie całych obiektów w pamięci. Zazwyczaj, gdy taka sytuacja ma zastosowanie, nie ma znaczenia, czy parametry są przekazywane przez wartość, czy przez stałą odniesienia, ponieważ kompilator może analizować wyczerpująco.

Jeśli jednak funkcja niższego poziomu wywołuje coś, co jest poza analizą (np. Coś w innej bibliotece poza kompilacją lub wykres wywołania, który jest po prostu zbyt skomplikowany), to kompilator musi zoptymalizować defensywnie.

Obiekty większe niż wartość rejestru maszyny mogą być kopiowane przez jawne instrukcje ładowania / przechowywania pamięci lub przez wywołanie czcigodnej memcpyfunkcji. Na niektórych platformach kompilator generuje instrukcje SIMD w celu przemieszczania się między dwiema lokalizacjami pamięci, a każda instrukcja przenosi dziesiątki bajtów (16 lub 32).


Dyskusja na temat gadatliwości lub bałaganu wizualnego

Programiści C ++ są do tego przyzwyczajeni, tzn. Dopóki programista nie nienawidzi C ++, narzut związany z pisaniem lub odczytywaniem stałych odniesień w kodzie źródłowym nie jest straszny.

Analizy kosztów i korzyści mogły być wykonywane wiele razy wcześniej. Nie wiem, czy są jakieś naukowe, które powinny być cytowane. Sądzę, że większość analiz byłaby nienaukowa lub odtwarzalna.

Oto, co sobie wyobrażam (bez dowodów i wiarygodnych referencji) ...

  • Tak, wpływa to na wydajność oprogramowania napisanego w tym języku.
  • Jeśli kompilatory mogą zrozumieć cel kodu, potencjalnie może być wystarczająco inteligentny, aby to zautomatyzować
  • Niestety, w językach, które sprzyjają zmienności (w przeciwieństwie do czystości funkcjonalnej), kompilator klasyfikuje większość rzeczy jako zmutowane, w związku z czym automatyczne odliczanie stałości odrzuca większość rzeczy jako niestałe
  • Koszty ogólne zależą od ludzi; ludzie, którzy uznają to za duże obciążenie umysłowe, odrzuciliby C ++ jako realny język programowania.
rwong
źródło
Jest to jedna z sytuacji, w której chciałbym zaakceptować dwie odpowiedzi zamiast wybierać tylko jedną ... westchnienie
CharonX
8

W artykule Donalda Knutha „StructuredProgrammingWithGoToStatements” napisał: „Programiści marnują mnóstwo czasu na myślenie lub martwienie się o szybkość niekrytycznych części swoich programów, a te próby wydajności mają silny negatywny wpływ, gdy rozważa się debugowanie i konserwację Powinniśmy zapomnieć o małej wydajności, powiedzmy około 97% czasu: przedwczesna optymalizacja jest źródłem wszelkiego zła. Jednak nie powinniśmy tracić naszych możliwości w tak krytycznych 3%. ” - Przedwczesna optymalizacja

Nie zaleca się programistom korzystania z najwolniejszych dostępnych technik. Chodzi o skupienie się na przejrzystości podczas pisania programów. Często klarowność i wydajność stanowią kompromis: jeśli musisz wybrać tylko jeden, wybierz klarowność. Ale jeśli uda ci się osiągnąć oba z łatwością, nie ma potrzeby osłabiania jasności (np. Sygnalizowania, że ​​coś jest stałe), aby uniknąć wydajności.

Lawrence
źródło
3
„jeśli musisz wybrać tylko jeden, wybierz jasność”. Drugi powinien być preferowany , ponieważ możesz zostać zmuszony do wybrania drugiego.
Deduplicator
@Deduplicator Dziękujemy. Jednak w kontekście PO programista ma swobodę wyboru.
Lawrence
Twoja odpowiedź brzmi jednak trochę bardziej ogólnie ...
Deduplicator
@Deduplicator Ah, ale kontekst mojej odpowiedzi jest (również) wybrany przez programistę . Gdyby wybór został wymuszony na programiście, wybieranie nie byłoby „ty” :). Rozważyłem zasugerowaną przez ciebie zmianę i nie sprzeciwiłbym się odpowiednio zmodyfikowanej odpowiedzi, ale wolę istniejące sformułowanie ze względu na jego przejrzystość.
Lawrence
7

Przekazywanie przez ([const] [rvalue] reference) | (wartość) powinno dotyczyć intencji i obietnic złożonych przez interfejs. Nie ma to nic wspólnego z wydajnością.

Zasada kciuka Richya:

void foo(X x);          // I intend to own the x you gave me, whether by copy, move or direct initialisation on the call stack.     

void foo(X&& x);        // I intend to steal x from you. Do not use it other than to re-assign to it after calling me.

void foo(X const& x);   // I guarantee not to change your x

void foo(X& x);         // I may modify your x and I will leave it in a defined state
Richard Hodges
źródło
3

Teoretycznie odpowiedź powinna brzmieć „tak”. I w rzeczywistości tak jest przez pewien czas - w rzeczywistości przekazywanie przez stałą referencyjną zamiast tylko przekazywania wartości może być pesymizacją, nawet w przypadkach, gdy przekazywana wartość jest zbyt duża, aby zmieścić się w jednym zarejestrować (lub większość innych heurystyk, których ludzie próbują użyć, aby określić, kiedy przekazać wartość, czy nie). Wiele lat temu David Abrahams napisał artykuł zatytułowany „Chcesz prędkości? Przekazać wartość!” obejmujący niektóre z tych przypadków. Nie jest już łatwo go znaleźć, ale jeśli możesz wykopać kopię, warto ją przeczytać (IMO).

Jednak w konkretnym przypadku przekazywania przez odniesienie do stałej powiedziałbym, że ten idiom jest tak dobrze ustalony, że sytuacja jest mniej lub bardziej odwrócona: chyba że wiesz, że typ będzie char/ short/ int/ long, ludzie oczekują, że obejmie go const referencja domyślnie, więc prawdopodobnie najlepiej się z tym pogodzić, chyba że masz dość konkretny powód, aby zrobić inaczej.

Jerry Coffin
źródło