Zalety pass-by-value i std :: move w porównaniu z pass-by-reference

108

W tej chwili uczę się C ++ i staram się unikać złych nawyków. Z tego, co rozumiem, clang-tidy zawiera wiele „najlepszych praktyk” i staram się ich trzymać jak najlepiej (chociaż niekoniecznie rozumiem, dlaczego są jeszcze uważane za dobre), ale nie jestem pewien, czy zrozumieć, co jest tutaj zalecane.

Użyłem tej klasy z samouczka:

class Creature
{
private:
    std::string m_name;

public:
    Creature(const std::string &name)
            :  m_name{name}
    {
    }
};

Prowadzi to do sugestii ze strony clang-tidy, że powinienem przekazywać wartości zamiast odniesienia i używania std::move. Jeśli to zrobię, namepojawia się sugestia, aby zrobić odniesienie (aby upewnić się, że nie zostanie skopiowana za każdym razem) i ostrzeżenie, std::movektóre nie będzie miało żadnego efektu, ponieważ namejest consttak, więc powinienem je usunąć.

Jedynym sposobem, w jaki nie otrzymuję ostrzeżenia, jest constcałkowite usunięcie :

Creature(std::string name)
        :  m_name{std::move(name)}
{
}

Wydaje się to logiczne, ponieważ jedyną korzyścią constbyło zapobieganie pomieszaniu z oryginalnym ciągiem (co nie ma miejsca, ponieważ przekazałem wartość). Ale czytałem na CPlusPlus.com :

Chociaż należy zauważyć, że -w standardowej bibliotece-przeniesienie oznacza, że ​​obiekt przeniesiony-z pozostaje w prawidłowym, ale nieokreślonym stanie. Co oznacza, że ​​po takiej operacji wartość przenoszonego obiektu powinna zostać jedynie zniszczona lub przypisana nowa wartość; dostęp do niego w przeciwnym razie daje nieokreśloną wartość.

Teraz wyobraź sobie ten kod:

std::string nameString("Alex");
Creature c(nameString);

Ponieważ nameStringzostanie przekazany przez wartość, std::moveunieważni tylko namewewnątrz konstruktora i nie dotknie oryginalnego ciągu. Ale jakie są z tego zalety? Wygląda na to, że treść jest kopiowana tylko raz - jeśli przekażę przez odwołanie, gdy wywołuję m_name{name}, jeśli przekażę wartość, gdy ją przekażę (a następnie zostanie przeniesiona). Rozumiem, że jest to lepsze niż przekazywanie wartości i nie używanie std::move(ponieważ jest dwukrotnie kopiowane).

Więc dwa pytania:

  1. Czy dobrze zrozumiałem, co się tutaj dzieje?
  2. Czy jest jakaś korzyść z używania std::moveprzekazywania przez referencję i po prostu dzwoniąc m_name{name}?
Blackbot
źródło
3
Z przekazaniem przez odniesienie Creature c("John");tworzy dodatkową kopię
user253751
1
Ten link może być cenną lekturą, obejmuje również przekazywanie std::string_viewi logowanie jednokrotne.
lubgr
Odkryłem, że clang-tidyto świetny sposób na obsesję na punkcie niepotrzebnych mikrooptymizacji kosztem czytelności. Pytanie, które należy tutaj zadać, przede wszystkim, brzmi: ile razy faktycznie wywołujemy Creaturekonstruktora.
cz

Odpowiedzi:

39
  1. Czy dobrze zrozumiałem, co się tutaj dzieje?

Tak.

  1. Czy jest jakaś korzyść z używania std::moveprzekazywania przez referencję i po prostu dzwoniąc m_name{name}?

Łatwy do uchwycenia podpis funkcji bez dodatkowych przeciążeń. Podpis natychmiast ujawnia, że ​​argument zostanie skopiowany - dzięki temu wywołujący nie zastanawiają się, czy const std::string&referencja może być przechowywana jako element członkowski danych, a być może później stanie się referencją wiszącą. I nie ma potrzeby przeciążania argumentów std::string&& namei przeciążania, const std::string&aby uniknąć niepotrzebnych kopii, gdy rvalues ​​są przekazywane do funkcji. Przekazywanie lwartości

std::string nameString("Alex");
Creature c(nameString);

do funkcji, która przyjmuje argument przez wartość, powoduje jedną kopię i jeden ruch. Przekazywanie wartości r do tej samej funkcji

std::string nameString("Alex");
Creature c(std::move(nameString));

powoduje dwie konstrukcje ruchu. W przeciwieństwie do tego, gdy parametr funkcji ma wartość const std::string&, zawsze będzie kopia, nawet w przypadku przekazania argumentu rvalue. Jest to niewątpliwie zaleta, o ile typ argumentu jest tani w konstrukcji przenoszenia (tak jest w przypadku std::string).

Ale jest wada do rozważenia: rozumowanie nie działa dla funkcji, które przypisują argument funkcji do innej zmiennej (zamiast ją inicjować):

void setName(std::string name)
{
    m_name = std::move(name);
}

spowoduje cofnięcie alokacji zasobu, m_namedo którego się odwołuje, przed ponownym przypisaniem. Polecam przeczytanie pozycji 41 w Effective Modern C ++, a także to pytanie .

lubgr
źródło
Ma to sens, zwłaszcza że dzięki temu deklaracja jest bardziej intuicyjna w czytaniu. Nie jestem pewien, czy w pełni rozumiem część dotyczącą cofnięcia alokacji Twojej odpowiedzi (i rozumiem powiązany wątek), więc po prostu sprawdź, czy używam move, miejsce zostanie zwolnione. Jeśli nie używam move, zostaje zwolniony tylko wtedy, gdy przydzielone miejsce jest zbyt małe, aby pomieścić nowy ciąg, co prowadzi do poprawy wydajności. Czy to jest poprawne?
Blackbot
1
Tak, dokładnie to. Podczas przypisywania do m_namez const std::string&parametrem, pamięć wewnętrzna jest ponownie wykorzystywane tak długo, jak m_namepasuje. Kiedy ruch-przypisując m_name, pamięć musi być zwalniane wcześniej. W przeciwnym razie niemożliwe było „ukraść” zasoby z prawej strony zadania.
lubgr
Kiedy staje się wiszącym odniesieniem? Myślę, że lista inicjalizacyjna używa głębokiej kopii.
Li Taiji
108
/* (0) */ 
Creature(const std::string &name) : m_name{name} { }
  • Przekazana lwartość wiąże się z name, a następnie jest kopiowana do m_name.

  • Przekazana wartość r wiąże się z name, a następnie jest kopiowana do m_name.


/* (1) */ 
Creature(std::string name) : m_name{std::move(name)} { }
  • Przekazana lwartość jest kopiowana do name, a następnie przenoszona do m_name.

  • Przekazana wartość r jest przenoszona do name, a następnie przenoszona do m_name.


/* (2) */ 
Creature(const std::string &name) : m_name{name} { }
Creature(std::string &&rname) : m_name{std::move(rname)} { }
  • Przekazana lwartość wiąże się z name, a następnie jest kopiowana do m_name.

  • Przekazana wartość r wiąże się z rname, a następnie jest przenoszona do m_name.


Ponieważ operacje przenoszenia są zwykle szybsze niż kopiowanie, (1) jest lepsze niż (0), jeśli podajesz wiele tymczasowych. (2) jest optymalny pod względem kopii / ruchów, ale wymaga powtórzenia kodu.

Powtarzania kodu można uniknąć dzięki doskonałemu przekazywaniu :

/* (3) */
template <typename T,
          std::enable_if_t<
              std::is_convertible_v<std::remove_cvref_t<T>, std::string>, 
          int> = 0
         >
Creature(T&& name) : m_name{std::forward<T>(name)} { }

Opcjonalnie możesz chcieć ograniczyć T, aby ograniczyć domenę typów, z których można utworzyć wystąpienie tego konstruktora (jak pokazano powyżej). C ++ 20 ma na celu uproszczenie tego za pomocą Concepts .


W C ++ 17 na prvalues wpływa gwarantowana elisja kopiowania , która - w stosownych przypadkach - zmniejszy liczbę kopii / ruchów podczas przekazywania argumentów do funkcji.

Vittorio Romeo
źródło
Dla (1) wartość pr i wielkość xvalue nie są identyczne, ponieważ c ++ 17 nie?
Oliv
1
Zwróć uwagę, że nie potrzebujesz SFINAE, aby doskonalić naprzód w tym przypadku. Trzeba tylko ujednoznacznić. Jest to prawdopodobnie pomocne w przypadku potencjalnych komunikatów o błędach podczas przekazywania złych argumentów
Caleth
@Oliv Yes. xvalues ​​należy przenieść, podczas gdy prvalues ​​można usunąć :)
Rakete1111
1
Czy możemy napisać: Creature(const std::string &name) : m_name{std::move(name)} { }w (2) ?
SkyTree
4
@skytree: nie możesz przenieść się z obiektu const, ponieważ przeniesienie powoduje mutację źródła. To się skompiluje, ale utworzy kopię.
Vittorio Romeo
1

To , jak mijasz, nie jest jedyną zmienną tutaj, to , co mijasz, stanowi dużą różnicę między nimi.

W C ++ mamy wszystkie rodzaje kategoriach wartości i istnieje ta „idiom” dla przypadków, gdzie przechodzą w rvalue (takiego jak "Alex-string-literal-that-constructs-temporary-std::string"lub std::move(nameString)), co skutkuje 0 egzemplarzach z std::stringodniesienia (typ nawet nie muszą być kopiowaniem constructible dla argumentów rvalue) i używa tylko std::stringkonstruktora przenoszenia.

Dość powiązane pytania i odpowiedzi .

LogicStuff
źródło
1

Istnieje kilka wad podejścia pass-by-value-and-move względem odniesienia pass-by- (rv):

  • powoduje, że pojawiają się 3 obiekty zamiast 2;
  • przekazywanie obiektu przez wartość może prowadzić do dodatkowego obciążenia stosu, ponieważ nawet zwykła klasa łańcuchowa jest zwykle co najmniej 3 lub 4 razy większa niż wskaźnik;
  • konstrukcja obiektów argumentowych będzie wykonywana po stronie wywołującego, powodując rozdęcie kodu;
user7860670
źródło
Czy możesz wyjaśnić, dlaczego spowodowałoby to pojawienie się 3 obiektów? Z tego, co rozumiem, mogę po prostu podać „Peter” jako ciąg. To by się pojawiło, skopiowane, a następnie przeniesione, prawda? I czy mimo to stos nie zostanie kiedyś użyty? Nie w momencie wywołania konstruktora, ale w m_name{name}części, w której jest kopiowany?
Blackbot
@Blackbot Odnosiłem się do twojego przykładu, std::string nameString("Alex"); Creature c(nameString);jeden obiekt to nameString, inny to argument funkcji, a trzeci to pole klasy.
user7860670