Czy w C ++ zwracanie wektora z funkcji jest nadal złą praktyką?

103

Wersja skrócona: w wielu językach programowania często zwraca się duże obiekty - takie jak wektory / tablice -. Czy ten styl jest teraz akceptowalny w C ++ 0x, jeśli klasa ma konstruktor ruchu, czy programiści C ++ uważają go za dziwny / brzydki / obrzydliwy?

Wersja długa: czy w C ++ 0x jest to nadal uważane za złą formę?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

Tradycyjna wersja wyglądałaby tak:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

W nowszej wersji wartością zwracaną z BuildLargeVectorjest rvalue, więc v zostałoby skonstruowane przy użyciu konstruktora przenoszenia std::vector, przy założeniu, że (N) RVO nie ma miejsca.

Nawet przed C ++ 0x pierwsza postać często byłaby „wydajna” z powodu (N) RVO. Jednak (N) RVO zależy od uznania kompilatora. Teraz, gdy mamy odniesienia do rvalue, gwarantujemy, że nie nastąpi głębokie kopiowanie.

Edycja : Pytanie tak naprawdę nie dotyczy optymalizacji. Obie przedstawione formy mają prawie identyczną wydajność w rzeczywistych programach. Podczas gdy w przeszłości pierwsza forma mogła mieć gorsze wyniki o rząd wielkości. W rezultacie pierwsza forma przez długi czas była głównym powodem zapachu kodu w programowaniu w C ++. Już nie, mam nadzieję?

Nate
źródło
18
Kto powiedział, że to zła forma na początku?
Edward Strange
7
Z pewnością był to zły zapach kodu w „dawnych czasach”, stąd pochodzę. :-)
Nate
1
Mam nadzieję, że tak! Chciałbym, aby wartość przekazywana stała się bardziej popularna. :)
sellibitze

Odpowiedzi:

73

Dave Abrahams przeprowadził dość wszechstronną analizę szybkości przekazywania / zwracania wartości .

Krótka odpowiedź, jeśli chcesz zwrócić wartość, zwróć wartość. Nie używaj referencji wyjściowych, ponieważ kompilator i tak to robi. Oczywiście są zastrzeżenia, więc powinieneś przeczytać ten artykuł.

Peter Alexander
źródło
24
„kompilator i tak to robi”: kompilator nie jest do tego zobowiązany == niepewność == zły pomysł (potrzeba 100% pewności). „kompleksowa analiza” Z analizą tą wiąże się ogromny problem - opiera się ona na nieudokumentowanych / niestandardowych funkcjach języka w nieznanym kompilatorze („Chociaż standard nigdy nie wymaga, aby kopiowanie nie było wymagane”). Więc nawet jeśli działa, to nie jest dobrym pomysłem używanie go - nie ma absolutnie żadnej gwarancji, że będzie działał zgodnie z przeznaczeniem i nie ma gwarancji, że każdy kompilator zawsze będzie działał w ten sposób. Opieranie się na tym dokumencie to zła praktyka kodowania, IMO. Nawet jeśli stracisz wydajność.
SigTerm
5
@SigTerm: To doskonały komentarz !!! większość przywoływanego artykułu jest zbyt niejasna, aby nawet rozważyć użycie w produkcji. Ludzie myślą, że wszystko, co autor, który napisał książkę „Red In-Depth”, jest ewangelią i powinno się do niego stosować bez dalszych przemyśleń i analiz. ATM nie ma na rynku kompilatora, który zapewniałby kopiowanie elison tak różnorodnych, jak przykłady, których Abrahams używa w artykule.
Hippicoder
13
@SigTerm, jest wiele rzeczy , których kompilator nie musi robić, ale zakładasz, że tak czy inaczej. Kompilatory nie są „wymagane” do zmiany x / 2na x >> 1for ints, ale zakładasz, że tak. Norma nie mówi również nic o tym, jak kompilatory są wymagane do implementowania referencji, ale zakładasz, że są one obsługiwane wydajnie za pomocą wskaźników. Standard nie mówi również nic o tabelach v, więc nie możesz być również pewien, czy wywołania funkcji wirtualnych są wydajne. Zasadniczo czasami trzeba trochę wierzyć w kompilator.
Peter Alexander
16
@Sig: W rzeczywistości bardzo niewiele jest gwarantowanych, z wyjątkiem rzeczywistego wyniku programu. Jeśli chcesz mieć 100% pewności co do tego, co się wydarzy w 100% przypadków, lepiej od razu przełącz się na inny język.
Dennis Zickefoose
6
@SigTerm: Pracuję nad „scenariuszem faktycznym”. Testuję, co robi kompilator i pracuję z tym. Nie ma „może działać wolniej”. Po prostu nie działa wolniej, ponieważ kompilator implementuje RVO, niezależnie od tego, czy standard tego wymaga, czy nie. Nie ma „jeśli”, „ale” czy „maybes”, to po prostu prosty fakt.
Peter Alexander
37

Przynajmniej IMO, to zwykle kiepski pomysł, ale nie ze względu na wydajność. To kiepski pomysł, ponieważ omawiana funkcja powinna być zwykle zapisana jako ogólny algorytm, który generuje dane wyjściowe za pośrednictwem iteratora. Prawie każdy kod, który akceptuje lub zwraca kontener zamiast działać na iteratorach, powinien zostać uznany za podejrzany.

Nie zrozum mnie źle: czasami ma sens przekazywanie obiektów typu kolekcji (np. Ciągów znaków), ale w przytoczonym przykładzie uważam, że przekazanie lub zwrócenie wektora jest kiepskim pomysłem.

Jerry Coffin
źródło
6
Problem z podejściem iteratorowym polega na tym, że wymaga ono tworzenia funkcji i metod na podstawie szablonu, nawet jeśli znany jest typ elementu kolekcji. To irytujące, a kiedy metoda, o której mowa, jest wirtualna, niemożliwa. Uwaga, nie zgadzam się z twoją odpowiedzią jako taką, ale w praktyce staje się ona trochę kłopotliwa w C ++.
jon-hanson
22
Muszę się nie zgodzić. Używanie iteratorów do wyjścia jest czasami właściwe, ale jeśli nie piszesz ogólnego algorytmu, ogólne rozwiązania często zapewniają nieunikniony narzut, który jest trudny do uzasadnienia. Zarówno pod względem złożoności kodu, jak i rzeczywistej wydajności.
Dennis Zickefoose
1
@Dennis: Muszę powiedzieć, że moje doświadczenie jest zupełnie odwrotne: piszę wiele rzeczy jako szablony, nawet jeśli znam z wyprzedzeniem typy, których to dotyczy, ponieważ jest to prostsze i poprawia wydajność.
Jerry Coffin
9
Osobiście zwracam pojemnik. Zamiar jest jasny, kod jest łatwiejszy, nie przejmuję się zbytnio wykonaniem, kiedy go piszę (po prostu unikam wczesnej pesymizacji). Nie jestem pewien, czy użycie iteratora danych wyjściowych sprawiłoby, że mój zamiar byłby jaśniejszy ... i potrzebuję kodu innego niż szablon tak bardzo, jak to możliwe, ponieważ w dużym projekcie zależności zabijają rozwój.
Matthieu M.
1
@Dennis: Założę, że koncepcyjnie nigdy nie powinieneś „budować kontenera, zamiast pisać do zakresu”. Kontener to po prostu - pojemnik. Twój problem (i problem związany z kodem) powinien dotyczyć zawartości, a nie kontenera.
Jerry Coffin
18

Istota jest taka:

Kopiuj Elision i RVO mogą uniknąć "przerażających kopii" (kompilator nie jest wymagany do implementacji tych optymalizacji, aw niektórych sytuacjach nie można go zastosować)

Odwołania C ++ 0x RValue pozwalają na implementacje ciągów / wektorów, które to gwarantują .

Jeśli możesz porzucić starsze kompilatory / implementacje STL, zwróć wektory swobodnie (i upewnij się, że twoje własne obiekty również je obsługują). Jeśli twój kod musi obsługiwać "mniejsze" kompilatory, trzymaj się starego stylu.

Niestety ma to duży wpływ na twoje interfejsy. Jeśli C ++ 0x nie jest opcją i potrzebujesz gwarancji, w niektórych scenariuszach możesz zamiast tego użyć obiektów liczonych jako odwołania lub kopiowanych przy zapisie. Mają jednak wady związane z wielowątkowością.

(Chciałbym, żeby tylko jedna odpowiedź w C ++ była prosta, nieskomplikowana i bezwarunkowa).

peterchen
źródło
11

Rzeczywiście, od C ++ 11, koszt kopiowaniastd::vector zniknął w większości przypadków.

Należy jednak pamiętać, że koszt skonstruowania nowego wektora (a następnie zniszczenia go) nadal istnieje, a użycie parametrów wyjściowych zamiast zwracania wartości według wartości jest nadal przydatne, gdy chcesz ponownie wykorzystać pojemność wektora. Jest to udokumentowane jako wyjątek w F.20 podstawowych wytycznych C ++.

Porównajmy:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

z:

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

Załóżmy teraz, że musimy wywołać te metody numIterrazy w ścisłej pętli i wykonać jakąś akcję. Na przykład obliczmy sumę wszystkich elementów.

Używając BuildLargeVector1, zrobiłbyś:

size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
    std::vector<int> v = BuildLargeVector1(vecSize);
    sum1 = std::accumulate(v.begin(), v.end(), sum1);
}

Używając BuildLargeVector2, zrobiłbyś:

size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
    BuildLargeVector2(/*out*/ v, vecSize);
    sum2 = std::accumulate(v.begin(), v.end(), sum2);
}

W pierwszym przykładzie występuje wiele niepotrzebnych dynamicznych alokacji / zwalniania alokacji, którym zapobiega się w drugim przykładzie poprzez użycie parametru wyjściowego w stary sposób, ponownie wykorzystując już przydzieloną pamięć. To, czy ta optymalizacja jest warta wykonania, zależy od względnego kosztu alokacji / cofnięcia alokacji w porównaniu z kosztem obliczania / mutowania wartości.

Reper

Pobawmy się wartościami vecSizei numIter. Zachowamy stałą vecSize * numIter, aby "w teorii" zajęło to tyle samo czasu (= jest taka sama liczba przypisań i dodatków, z dokładnie tymi samymi wartościami), a różnica czasu może pochodzić tylko z kosztu alokacje, cofanie przydziałów i lepsze wykorzystanie pamięci podręcznej.

Mówiąc dokładniej, użyjmy vecSize * numIter = 2 ^ 31 = 2147483648, ponieważ mam 16 GB pamięci RAM i ta liczba zapewnia, że ​​przydzielono nie więcej niż 8 GB (sizeof (int) = 4), zapewniając, że nie przełączam się na dysk ( wszystkie inne programy były zamknięte, podczas testu miałem ~ 15 GB wolnego miejsca).

Oto kod:

#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>

class Timer {
    using clock = std::chrono::steady_clock;
    using seconds = std::chrono::duration<double>;
    clock::time_point t_;

public:
    void tic() { t_ = clock::now(); }
    double toc() const { return seconds(clock::now() - t_).count(); }
};

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

int main() {
    Timer t;

    size_t vecSize = size_t(1) << 31;
    size_t numIter = 1;

    std::cout << std::setw(10) << "vecSize" << ", "
              << std::setw(10) << "numIter" << ", "
              << std::setw(10) << "time1" << ", "
              << std::setw(10) << "time2" << ", "
              << std::setw(10) << "sum1" << ", "
              << std::setw(10) << "sum2" << "\n";

    while (vecSize > 0) {

        t.tic();
        size_t sum1 = 0;
        {
            for (int i = 0; i < numIter; ++i) {
                std::vector<int> v = BuildLargeVector1(vecSize);
                sum1 = std::accumulate(v.begin(), v.end(), sum1);
            }
        }
        double time1 = t.toc();

        t.tic();
        size_t sum2 = 0;
        {
            std::vector<int> v;
            for (int i = 0; i < numIter; ++i) {
                BuildLargeVector2(/*out*/ v, vecSize);
                sum2 = std::accumulate(v.begin(), v.end(), sum2);
            }
        } // deallocate v
        double time2 = t.toc();

        std::cout << std::setw(10) << vecSize << ", "
                  << std::setw(10) << numIter << ", "
                  << std::setw(10) << std::fixed << time1 << ", "
                  << std::setw(10) << std::fixed << time2 << ", "
                  << std::setw(10) << sum1 << ", "
                  << std::setw(10) << sum2 << "\n";

        vecSize /= 2;
        numIter *= 2;
    }

    return 0;
}

A oto wynik:

$ g++ -std=c++11 -O3 main.cpp && ./a.out
   vecSize,    numIter,      time1,      time2,       sum1,       sum2
2147483648,          1,   2.360384,   2.356355, 2147483648, 2147483648
1073741824,          2,   2.365807,   1.732609, 2147483648, 2147483648
 536870912,          4,   2.373231,   1.420104, 2147483648, 2147483648
 268435456,          8,   2.383480,   1.261789, 2147483648, 2147483648
 134217728,         16,   2.395904,   1.179340, 2147483648, 2147483648
  67108864,         32,   2.408513,   1.131662, 2147483648, 2147483648
  33554432,         64,   2.416114,   1.097719, 2147483648, 2147483648
  16777216,        128,   2.431061,   1.060238, 2147483648, 2147483648
   8388608,        256,   2.448200,   0.998743, 2147483648, 2147483648
   4194304,        512,   0.884540,   0.875196, 2147483648, 2147483648
   2097152,       1024,   0.712911,   0.716124, 2147483648, 2147483648
   1048576,       2048,   0.552157,   0.603028, 2147483648, 2147483648
    524288,       4096,   0.549749,   0.602881, 2147483648, 2147483648
    262144,       8192,   0.547767,   0.604248, 2147483648, 2147483648
    131072,      16384,   0.537548,   0.603802, 2147483648, 2147483648
     65536,      32768,   0.524037,   0.600768, 2147483648, 2147483648
     32768,      65536,   0.526727,   0.598521, 2147483648, 2147483648
     16384,     131072,   0.515227,   0.599254, 2147483648, 2147483648
      8192,     262144,   0.540541,   0.600642, 2147483648, 2147483648
      4096,     524288,   0.495638,   0.603396, 2147483648, 2147483648
      2048,    1048576,   0.512905,   0.609594, 2147483648, 2147483648
      1024,    2097152,   0.548257,   0.622393, 2147483648, 2147483648
       512,    4194304,   0.616906,   0.647442, 2147483648, 2147483648
       256,    8388608,   0.571628,   0.629563, 2147483648, 2147483648
       128,   16777216,   0.846666,   0.657051, 2147483648, 2147483648
        64,   33554432,   0.853286,   0.724897, 2147483648, 2147483648
        32,   67108864,   1.232520,   0.851337, 2147483648, 2147483648
        16,  134217728,   1.982755,   1.079628, 2147483648, 2147483648
         8,  268435456,   3.483588,   1.673199, 2147483648, 2147483648
         4,  536870912,   5.724022,   2.150334, 2147483648, 2147483648
         2, 1073741824,  10.285453,   3.583777, 2147483648, 2147483648
         1, 2147483648,  20.552860,   6.214054, 2147483648, 2147483648

Wyniki testów porównawczych

(Intel i7-7700K @ 4,20 GHz; 16 GB DDR4 2400 MHz; Kubuntu 18.04)

Notacja: mem (v) = v.size () * sizeof (int) = v.size () * 4 na mojej platformie.

Nic dziwnego, że gdy numIter = 1(tj. Mem (v) = 8 GB), czasy są idealnie identyczne. Rzeczywiście, w obu przypadkach przydzielamy tylko raz ogromny wektor o wielkości 8 GB w pamięci. Dowodzi to również, że żadna kopia się nie wydarzyła podczas korzystania z BuildLargeVector1 (): nie miałbym wystarczająco dużo pamięci RAM, aby wykonać kopię!

Kiedy numIter = 2ponowne wykorzystanie pojemności wektora zamiast ponownego przydzielania drugiego wektora jest 1,37x szybsze.

Kiedy numIter = 256ponowne użycie pojemności wektora (zamiast przydzielania / cofania alokacji wektora w kółko 256 razy ...) jest 2,45x szybsze :)

Możemy zauważyć, że czas1 jest prawie stały od numIter = 1do numIter = 256, co oznacza, że ​​przydzielenie jednego ogromnego wektora o wielkości 8 GB jest prawie tak samo kosztowne, jak przydzielenie 256 wektorów o pojemności 32 MB. Jednak przydzielenie jednego ogromnego wektora o wielkości 8 GB jest zdecydowanie droższe niż przydzielenie jednego wektora o wielkości 32 MB, więc ponowne wykorzystanie pojemności wektora zapewnia wzrost wydajności.

Od numIter = 512(mem (v) = 16MB) do numIter = 8M(mem (v) = 1kB) to najlepszy punkt: obie metody są dokładnie tak samo szybkie i szybsze niż wszystkie inne kombinacje numIter i vecSize. Prawdopodobnie ma to związek z faktem, że rozmiar pamięci podręcznej L3 mojego procesora wynosi 8 MB, więc wektor prawie całkowicie mieści się w pamięci podręcznej. Naprawdę nie wyjaśniam, dlaczego nagły skok time1dotyczy mem (v) = 16 MB, wydaje się, że bardziej logiczne byłoby zdarzenie zaraz po, gdy mem (v) = 8 MB. Zwróć uwagę, że, co zaskakujące, w tym słodkim miejscu, brak możliwości ponownego wykorzystania jest w rzeczywistości nieco szybszy! Naprawdę tego nie wyjaśniam.

Kiedy numIter > 8Mrobi się brzydko. Obie metody działają wolniej, ale zwracanie wektora według wartości jest jeszcze wolniejsze. W najgorszym przypadku, gdy wektor zawiera tylko jeden pojedynczy int, ponowne użycie pojemności zamiast zwracania wartości jest 3,3 razy szybsze. Przypuszczalnie wynika to ze stałych kosztów malloc (), które zaczynają dominować.

Zwróć uwagę, że krzywa dla czasu2 jest gładsza niż krzywa dla czasu1: nie tylko ponowne wykorzystanie pojemności wektorów jest generalnie szybsze, ale co ważniejsze, jest bardziej przewidywalne .

Zwróć również uwagę, że w najlepszym miejscu byliśmy w stanie wykonać 2 miliardy dodań 64-bitowych liczb całkowitych w ~ 0,5 s, co jest całkiem optymalne na 64-bitowym procesorze 4,2 GHz. Moglibyśmy zrobić lepiej, zrównoleglenie obliczeń w celu wykorzystania wszystkich 8 rdzeni (powyższy test wykorzystuje tylko jeden rdzeń na raz, co zweryfikowałem, ponownie uruchamiając test podczas monitorowania użycia procesora). Najlepszą wydajność osiąga się, gdy mem (v) = 16kB, co jest rzędem wielkości pamięci podręcznej L1 (pamięć podręczna danych L1 dla i7-7700K to 4x32kB).

Oczywiście różnice stają się coraz mniej istotne, im więcej obliczeń trzeba wykonać na danych. Poniżej znajdują się wyniki jeśli zastąpimy sum = std::accumulate(v.begin(), v.end(), sum);przez for (int k : v) sum += std::sqrt(2.0*k);:

Benchmark 2

Wnioski

  1. Użycie parametrów wyjściowych zamiast zwracania wartości według wartości może zapewnić wzrost wydajności poprzez ponowne wykorzystanie pojemności.
  2. Na nowoczesnym komputerze stacjonarnym wydaje się to mieć zastosowanie tylko do dużych wektorów (> 16 MB) i małych wektorów (<1 kB).
  3. Unikaj przydzielania milionów / miliardów małych wektorów (<1kB). Jeśli to możliwe, ponownie wykorzystaj pojemność lub jeszcze lepiej zaprojektuj architekturę w inny sposób.

Wyniki mogą się różnić na innych platformach. Jak zwykle, jeśli liczy się wydajność, napisz testy porównawcze dla konkretnego przypadku użycia.

Boris Dalstein
źródło
6

Nadal uważam, że to zła praktyka, ale warto zauważyć, że mój zespół korzysta z MSVC 2008 i GCC 4.1, więc nie używamy najnowszych kompilatorów.

Wcześniej wiele hotspotów wyświetlanych w vtune z MSVC 2008 sprowadzało się do kopiowania ciągów. Mieliśmy taki kod:

String Something::id() const
{
    return valid() ? m_id: "";
}

... zauważ, że użyliśmy naszego własnego typu String (było to wymagane, ponieważ dostarczamy zestaw programistyczny, w którym twórcy wtyczek mogą używać różnych kompilatorów, a tym samym różnych, niekompatybilnych implementacji std :: string / std :: wstring).

Wprowadziłem prostą zmianę w odpowiedzi na sesję profilowania próbkowania wykresu wywołań pokazującą, że String :: String (const String &) zajmuje znaczną ilość czasu. Metody takie jak w powyższym przykładzie były największymi współtwórcami (w rzeczywistości sesja profilowania wykazała, że ​​alokacja i cofanie alokacji pamięci jest jednym z największych hotspotów, a konstruktor kopiujący String był głównym współtwórcą alokacji).

Zmiana, którą wprowadziłem, była prosta:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

Jednak to zrobiło wielką różnicę! Hotspot zniknął w kolejnych sesjach profilera, a oprócz tego wykonujemy wiele dokładnych testów jednostkowych, aby śledzić wydajność naszej aplikacji. Po tych prostych zmianach czasy testów wydajności wszelkiego rodzaju znacznie się skróciły.

Wniosek: nie używamy absolutnie najnowszych kompilatorów, ale nadal nie możemy polegać na kompilatorze optymalizującym kopiowanie w celu niezawodnego zwracania przez wartość (przynajmniej nie we wszystkich przypadkach). Może tak nie być w przypadku tych, którzy używają nowszych kompilatorów, takich jak MSVC 2010. Nie mogę się doczekać, kiedy będziemy mogli użyć C ++ 0x i po prostu użyć referencji rvalue i nigdy nie będziemy musieli się martwić, że pesymizujemy nasz kod, zwracając złożone klasy według wartości.

[Edytuj] Jak zauważył Nate, RVO dotyczy zwracania tymczasowych utworzonych wewnątrz funkcji. W moim przypadku nie było takich tymczasowych (poza nieprawidłową gałęzią, w której konstruujemy pusty ciąg), a zatem RVO nie miałby zastosowania.

śmierdzący472
źródło
3
O to chodzi: RVO jest zależne od kompilatora, ale kompilator C ++ 0x musi używać semantyki przenoszenia, jeśli zdecyduje się nie używać RVO (zakładając, że istnieje konstruktor przenoszenia). Użycie operatora trygrafu pokonuje RVO. Zobacz cpp-next.com/archive/2009/09/move-it-with-rvalue-references, do którego nawiązał Peter. Ale Twój przykład i tak nie kwalifikuje się do semantyki przenoszenia, ponieważ nie zwracasz tymczasowego.
Nate
@ Stinky472: Zwracanie elementu członkowskiego według wartości zawsze będzie wolniejsze niż odniesienie. Odwołania do wartości R byłyby nadal wolniejsze niż zwracanie odwołania do oryginalnego elementu członkowskiego (jeśli wywołujący może pobrać odwołanie zamiast potrzebować kopii). Ponadto nadal wiele razy można zapisać odwołania do rvalue, ponieważ masz kontekst. Na przykład, możesz zrobić String newstring; newstring.resize (string1.size () + string2.size () + ...); nowy ciąg + = ciąg1; nowy ciąg + = ciąg2; itp. Jest to nadal znaczna oszczędność w stosunku do wartości r.
Puppy
@DeadMG to znaczna oszczędność w porównaniu z operatorem binarnym + nawet z kompilatorami C ++ 0x implementującymi RVO? Jeśli tak, to szkoda. Z drugiej strony ma to sens, ponieważ nadal musimy utworzyć tymczasowy, aby obliczyć połączony ciąg, podczas gdy + = może łączyć bezpośrednio z nowym ciągiem.
stinky472
A co z przypadkiem takim jak: string newstr = słowo1 + słowo2; W kompilatorze implementującym semantykę przenoszenia wydaje się, że powinno to być równie szybkie lub nawet szybsze niż: string newstr; newstr + = słowo1; newstr + = słowo2; Bez rezerwy, że tak powiem (zakładam, że miałeś na myśli rezerwę zamiast zmiany rozmiaru).
stinky472
5
@Nate: Myślę, że są mylące trójznaków jak <::lub ??!z operatorem warunkowym ?: (czasami nazywany operatorem potrójny ).
fredoverflow
3

Żeby trochę poszukać: w wielu językach programowania nie jest powszechne zwracanie tablic z funkcji. W większości z nich zwracane jest odwołanie do tablicy. W C ++ powracałaby najbliższa analogiaboost::shared_array

Nemanja Trifunovic
źródło
4
@Billy: std :: vector to typ wartości z semantyką kopiowania. Obecny standard C ++ nie daje żadnych gwarancji, że (N) RVO kiedykolwiek zostanie zastosowany, aw praktyce istnieje wiele rzeczywistych scenariuszy, kiedy tak nie jest.
Nemanja Trifunovic
3
@Billy: Ponownie, istnieją bardzo realne scenariusze, w których nawet najnowsze kompilatory nie stosują NRVO: efnetcpp.org/wiki/Return_value_optimization#Named_RVO
Nemanja Trifunovic
3
@Billy ONeal: 99% to za mało, potrzebujesz 100%. Prawo Murphy'ego - „jeśli coś może pójść nie tak, to się stanie”. Niepewność jest dobra, jeśli masz do czynienia z jakąś logiką rozmytą, ale nie jest dobrym pomysłem przy pisaniu tradycyjnego oprogramowania. Jeśli istnieje choćby 1% prawdopodobieństwa, że ​​kod nie działa tak, jak myślisz, powinieneś spodziewać się, że ten kod wprowadzi krytyczny błąd, który spowoduje zwolnienie. Poza tym nie jest to standardowa funkcja. Używanie nieudokumentowanych funkcji to zły pomysł - jeśli za rok od momentu, w którym znany kompilator przestanie działać (standard nie jest wymagany , prawda?), To będziesz miał kłopoty.
SigTerm
4
@SigTerm: Gdybyśmy mówili o poprawności zachowania, zgodziłbym się z tobą. Jednak mówimy o optymalizacji wydajności. Takie rzeczy są w porządku z mniej niż 100% pewnością.
Billy ONeal
2
@Nemanja: Nie rozumiem, na czym tutaj „polegano”. Twoja aplikacja działa tak samo bez względu na to, czy używasz RVO czy NRVO. Jeśli jednak zostaną użyte, będzie działać szybciej. Jeśli Twoja aplikacja działa zbyt wolno na określonej platformie i prześledziłeś ją z powrotem do kopiowania wartości zwracanej, to z całą pewnością zmień ją, ale nie zmienia to faktu, że najlepszą praktyką jest nadal używanie wartości zwracanej. Jeśli absolutnie musisz się upewnić, że nie nastąpi kopiowanie, zawiń wektor w a shared_ptri nazwij go dniem.
Billy ONeal
2

Jeśli wydajność jest prawdziwym problemem, powinieneś zdać sobie sprawę, że semantyka przenoszenia nie zawsze jest szybsza niż kopiowanie. Na przykład, jeśli masz ciąg, który używa optymalizacji małych ciągów, wówczas dla małych ciągów konstruktor przenoszenia musi wykonać dokładnie taką samą pracę, jak zwykły konstruktor kopiujący.

Motti
źródło
1
NRVO nie znika tylko z powodu dodania konstruktorów przenoszenia.
Billy ONeal
1
@Billy, prawda, ale nieistotne, pytanie brzmiało: C ++ 0x zmienił najlepsze praktyki, a NRVO nie zmienił się z powodu C ++ 0x
Motti