Jak poprawnie przekazać parametry?


108

Jestem początkującym C ++, ale nie jestem początkującym programistą. Próbuję nauczyć się C ++ (c ++ 11) i jest dla mnie niejasne, najważniejsze: przekazywanie parametrów.

Rozważyłem te proste przykłady:

  • Klasa, która ma wszystkie składowe typy pierwotne:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Klasa, która ma jako składowe typy pierwotne + 1 typ złożony:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Klasa, która ma jako składowe typy pierwotne + 1 kolekcja pewnego typu złożonego: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Kiedy tworzę konto, robię to:

    CreditCard cc("12345",2,2015,1001);
    Account acc("asdasd",345, cc);

Oczywiście w tym scenariuszu karta kredytowa zostanie skopiowana dwukrotnie. Jeśli przepiszę ten konstruktor jako

Account(std::string number, float amount, CreditCard& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(creditCard)

będzie jedna kopia. Jeśli przepiszę to jako

Account(std::string number, float amount, CreditCard&& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(std::forward<CreditCard>(creditCard))

Będą 2 ruchy i bez kopii.

Myślę, że czasami możesz chcieć skopiować jakiś parametr, czasami nie chcesz kopiować podczas tworzenia tego obiektu.
Pochodzę z C # i będąc przyzwyczajonym do odwołań, jest to dla mnie trochę dziwne i myślę, że dla każdego parametru powinny być 2 przeciążenia, ale wiem, że się mylę.
Czy są jakieś najlepsze praktyki dotyczące wysyłania parametrów w C ++, ponieważ naprawdę uważam, że nie jest to trywialne. Jak poradzisz sobie z moimi przykładami przedstawionymi powyżej?


9
Meta: Nie mogę uwierzyć, że ktoś właśnie zadał dobre pytanie w C ++. +1.

23
FYI, std::stringto klasa, podobnie jak CreditCard, a nie typ prymitywny.
chris

7
Ze względu na zamieszanie w łańcuchach, chociaż niezwiązane ze sobą, powinieneś wiedzieć, że literał łańcuchowy, "abc"nie jest typu std::string, nie typu char */const char *, ale typu const char[N](z N = 4 w tym przypadku ze względu na trzy znaki i wartość null). To dobre, powszechne nieporozumienie, aby zejść z drogi.
Chris

10
@chuex: Jon Skeet zawiera wiele pytań dotyczących języka C #, znacznie rzadziej C ++.
Steve Jessop

Odpowiedzi:


158

NAJWAŻNIEJSZE PYTANIE PIERWSZE:

Czy są jakieś najlepsze praktyki dotyczące wysyłania parametrów w C ++, ponieważ naprawdę uważam, że nie jest to trywialne

Jeśli twoja funkcja wymaga zmodyfikowania oryginalnego przekazywanego obiektu, aby po powrocie wywołania modyfikacje tego obiektu były widoczne dla wywołującego, to powinieneś przekazać referencję lvalue :

void foo(my_class& obj)
{
    // Modify obj here...
}

Jeśli twoja funkcja nie musi modyfikować oryginalnego obiektu i nie musi tworzyć jego kopii (innymi słowy, musi tylko obserwować jego stan), to powinieneś przekazać referencję lwartości doconst :

void foo(my_class const& obj)
{
    // Observe obj here
}

Umożliwi to wywołanie funkcji zarówno z lvalues ​​(lvalues ​​to obiekty o stabilnej tożsamości), jak iz rvalues ​​(rvalues ​​to na przykład tymczasowe lub obiekty, z których zamierzasz się przenieść w wyniku wywołania std::move()).

Można również argumentować, że w przypadku typów podstawowych lub typów, dla których kopiowanie jest szybkie , takich jak int, boollub char, nie ma potrzeby przekazywania przez referencję, jeśli funkcja musi po prostu obserwować wartość, a przekazywanie przez wartość powinno być preferowane . To prawda, jeśli semantyka referencji nie jest potrzebna, ale co by było, gdyby funkcja chciała gdzieś przechowywać wskaźnik do tego samego obiektu wejściowego, tak aby przyszłe odczyty przez ten wskaźnik zobaczyły modyfikacje wartości, które zostały wykonane w innej części kod? W takim przypadku przekazywanie przez odniesienie jest właściwym rozwiązaniem.

Jeśli twoja funkcja nie musi modyfikować oryginalnego obiektu, ale musi przechowywać kopię tego obiektu ( prawdopodobnie w celu zwrócenia wyniku transformacji danych wejściowych bez zmiany danych wejściowych ), możesz rozważyć wzięcie według wartości :

void foo(my_class obj) // One copy or one move here, but not working on
                       // the original object...
{
    // Working on obj...

    // Possibly move from obj if the result has to be stored somewhere...
}

Wywołanie powyższej funkcji zawsze będzie skutkowało jedną kopią przy przekazywaniu lvalues ​​i jednym ruchem przy przekazywaniu rvalues. Jeśli twoja funkcja musi gdzieś przechowywać ten obiekt, możesz wykonać z niego dodatkowy ruch (na przykład w przypadku foo()jest to funkcja składowa, która musi przechowywać wartość w składniku danych ).

W przypadku, gdy ruchy są drogie dla obiektów typu my_class, możesz rozważyć przeciążenie foo()i podać jedną wersję dla lvalues ​​(akceptując odniesienie do constlvalue) i jedną wersję dla rvalues ​​(akceptując odniesienie rvalue):

// Overload for lvalues
void foo(my_class const& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = obj; // Copy!
    // Working on copyOfObj...
}

// Overload for rvalues
void foo(my_class&& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = std::move(obj); // Move! 
                                         // Notice, that invoking std::move() is 
                                         // necessary here, because obj is an
                                         // *lvalue*, even though its type is 
                                         // "rvalue reference to my_class".
    // Working on copyOfObj...
}

Powyższe funkcje są tak podobne, że można by z nich zrobić jedną funkcję: foo()mogłaby stać się szablonem funkcji i można by użyć doskonałego przekazywania do określenia, czy ruch lub kopia przekazywanego obiektu zostanie wygenerowana wewnętrznie:

template<typename C>
void foo(C&& obj) // No copy, no move (just reference binding)
//       ^^^
//       Beware, this is not always an rvalue reference! This will "magically"
//       resolve into my_class& if an lvalue is passed, and my_class&& if an
//       rvalue is passed
{
    my_class copyOfObj = std::forward<C>(obj); // Copy if lvalue, move if rvalue
    // Working on copyOfObj...
}

Możesz dowiedzieć się więcej o tym projekcie, oglądając wykład Scotta Meyersa (pamiętaj tylko, że termin „ Universal References ”, którego używa, jest niestandardowy).

Jedną rzeczą, o której należy pamiętać, jest to, że std::forwardzwykle kończy się to ruchem dla rwartości, więc nawet jeśli wygląda to stosunkowo niewinnie, wielokrotne przekazywanie tego samego obiektu może być źródłem problemów - na przykład dwukrotne przeniesienie się z tego samego obiektu! Uważaj więc, aby nie umieszczać tego w pętli i nie przekazywać wielokrotnie tego samego argumentu w wywołaniu funkcji:

template<typename C>
void foo(C&& obj)
{
    bar(std::forward<C>(obj), std::forward<C>(obj)); // Dangerous!
}

Zauważ również, że normalnie nie uciekasz się do rozwiązania opartego na szablonach, chyba że masz ku temu dobry powód, ponieważ utrudnia to odczytanie kodu. Zwykle powinieneś skupić się na przejrzystości i prostocie .

Powyższe są tylko prostymi wskazówkami, ale w większości przypadków będą wskazywać na dobre decyzje projektowe.


DOTYCZĄCE POZOSTAŁOŚCI PAŃSTWA:

Jeśli przepiszę to jako [...], będą 2 ruchy i bez kopii.

To nie jest poprawne. Po pierwsze, odwołanie do wartości r nie może wiązać się z lwartością, więc kompiluje się tylko wtedy, gdy przekazujesz konstruktorowi wartość typu r CreditCard. Na przykład:

// Here you are passing a temporary (OK! temporaries are rvalues)
Account acc("asdasd",345, CreditCard("12345",2,2015,1001));

CreditCard cc("12345",2,2015,1001);
// Here you are passing the result of std::move (OK! that's also an rvalue)
Account acc("asdasd",345, std::move(cc));

Ale to nie zadziała, jeśli spróbujesz to zrobić:

CreditCard cc("12345",2,2015,1001);
Account acc("asdasd",345, cc); // ERROR! cc is an lvalue

Ponieważ ccjest to lwartość, a odwołania do rwartości nie mogą wiązać się z lwartościami. Co więcej, podczas wiązania odwołania do obiektu nie jest wykonywany żaden ruch : jest to tylko powiązanie referencji. Tak więc będzie tylko jeden ruch.


Tak więc w oparciu o wytyczne przedstawione w pierwszej części tej odpowiedzi, jeśli interesuje Cię liczba ruchów generowanych podczas przyjmowania CreditCardwartości przez wartość, możesz zdefiniować dwa przeciążenia konstruktora, jeden pobierający odwołanie do lvalue do const( CreditCard const&) i jeden przyjmujący odwołanie do wartości r ( CreditCard&&).

Rozwiązanie przeciążenia wybierze pierwszą przy przekazywaniu lwartości (w tym przypadku zostanie wykonana jedna kopia), a drugą przy przekazaniu rwartości (w tym przypadku jeden ruch zostanie wykonany).

Account(std::string number, float amount, CreditCard const& creditCard) 
: number(number), amount(amount), creditCard(creditCard) // copy here
{ }

Account(std::string number, float amount, CreditCard&& creditCard) 
: number(number), amount(amount), creditCard(std::move(creditCard)) // move here
{ }

Twoje użycie std::forward<>jest zwykle widoczne, gdy chcesz osiągnąć doskonałe przekazywanie . W takim przypadku Twój konstruktor byłby faktycznie szablonem konstruktora i wyglądałby mniej więcej w następujący sposób

template<typename C>
Account(std::string number, float amount, C&& creditCard) 
: number(number), amount(amount), creditCard(std::forward<C>(creditCard)) { }

W pewnym sensie łączy to oba przeciążenia, które pokazałem wcześniej, w jedną funkcję: Czostanie wydedukowana, że ​​jest CreditCard&na wypadek, gdybyś przekazał lwartość, a ze względu na reguły zwijania odwołań spowoduje utworzenie wystąpienia tej funkcji:

Account(std::string number, float amount, CreditCard& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard&>(creditCard)) 
{ }

Spowoduje to copy-construction of creditCard, jak można chcieć. Z drugiej strony, po przekazaniu rvalue Czostanie wydedukowana, że ​​jest CreditCard, a zamiast tego zostanie utworzona instancja tej funkcji:

Account(std::string number, float amount, CreditCard&& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard>(creditCard)) 
{ }

Spowoduje to powstanie konstrukcji ruchu creditCard, która jest tym, czego chcesz (ponieważ przekazywana wartość jest wartością r, a to oznacza, że ​​jesteśmy upoważnieni do przejścia z niej).


Czy błędem jest stwierdzenie, że w przypadku nieprymitywnych typów parametrów można zawsze używać wersji szablonu z forward?
Jack Willson,

1
@JackWillson: Powiedziałbym, że powinieneś uciekać się do wersji szablonu tylko wtedy, gdy naprawdę masz obawy dotyczące wykonywania ruchów. Zobacz moje pytanie , na które jest dobra odpowiedź, aby uzyskać więcej informacji. Ogólnie rzecz biorąc, jeśli nie musisz robić kopii i musisz tylko obserwować, skorzystaj z ref const. Jeśli chcesz zmodyfikować oryginalny obiekt, przejdź przez ref do non-`const. Jeśli chcesz zrobić kopię, a ruchy są tanie, weź według wartości, a następnie przenieś.
Andy Prowl,

2
Myślę, że nie ma potrzeby poruszania się po trzecim przykładzie kodu. Możesz po prostu użyć objzamiast tworzyć lokalną kopię, aby się przenieść, nie?
juanchopanza

3
@AndyProwl: Otrzymałeś moje +1 godziny temu, ale ponownie czytając Twoją (doskonałą) odpowiedź, chciałbym zwrócić uwagę na dwie rzeczy: a) wartość według wartości nie zawsze tworzy kopię (jeśli nie jest przenoszona). Obiekt mógłby zostać zbudowany na miejscu w witrynie dzwoniącego, zwłaszcza w przypadku RVO / NRVO działa to nawet częściej niż mogłoby się wydawać. b) Proszę zaznaczyć, że w ostatnim przykładzie std::forwardmożna wywołać tylko raz . Widziałem ludzi umieszczających to w pętlach itp., A ponieważ ta odpowiedź będzie widziana przez wielu początkujących, powinno IMHO być grubą etykietą „Ostrzeżenie!”, Która pomoże im uniknąć tej pułapki.
Daniel Frey,

1
@SteveJessop: Poza tym są problemy techniczne (niekoniecznie problemy) z funkcjami przekazującymi ( zwłaszcza konstruktory przyjmujące jeden argument ), głównie to, że akceptują argumenty dowolnego typu i mogą pokonać std::is_constructible<>cechę typu, chyba że są odpowiednio SFINAE- ograniczony - co dla niektórych może nie być trywialne.
Andy Prowl

11

Najpierw poprawię kilka szczegółów. Kiedy powiesz:

będą 2 ruchy i bez kopii.

To nieprawda. Wiązanie z odniesieniem do wartości r nie jest ruchem. Jest tylko jeden ruch.

Dodatkowo, ponieważ CreditCardnie jest parametrem szablonu, std::forward<CreditCard>(creditCard)jest po prostu rozwlekłym sposobem powiedzenia std::move(creditCard).

Teraz...

Jeśli twoje typy mają „tanie” ruchy, możesz po prostu ułatwić sobie życie i brać wszystko według wartości i „ std::moverazem”.

Account(std::string number, float amount, CreditCard creditCard)
: number(std::move(number),
  amount(amount),
  creditCard(std::move(creditCard)) {}

Takie podejście da ci dwa ruchy, podczas gdy może przynieść tylko jeden, ale jeśli są one tanie, mogą być do zaakceptowania.

Skoro już jesteśmy przy tej sprawie „tanich ruchów”, powinienem przypomnieć, że std::stringjest ona często implementowana z tak zwaną optymalizacją małych ciągów, więc jej ruchy mogą nie być tak tanie jak kopiowanie niektórych wskaźników. Jak zwykle w przypadku problemów z optymalizacją, czy ma to znaczenie, czy nie, jest coś, o co należy zapytać swojego profilera, a nie mnie.

Co zrobić, jeśli nie chcesz ponosić tych dodatkowych ruchów? Może okażą się zbyt drogie lub, co gorsza, może faktycznie nie da się ich przenieść i może to spowodować naliczenie dodatkowych kopii.

Jeśli występuje tylko jeden problematyczny parametr, możesz podać dwa przeciążenia za pomocą T const&i T&&. Spowoduje to wiązanie odwołań przez cały czas, aż do rzeczywistej inicjalizacji elementu członkowskiego, podczas której nastąpi kopia lub przeniesienie.

Jeśli jednak masz więcej niż jeden parametr, prowadzi to do gwałtownego wzrostu liczby przeciążeń.

Jest to problem, który można rozwiązać dzięki doskonałemu przekazywaniu. Oznacza to, że zamiast tego piszesz szablon i używasz go std::forwarddo przenoszenia kategorii wartości argumentów do ich ostatecznego miejsca przeznaczenia jako członków.

template <typename TString, typename TCreditCard>
Account(TString&& number, float amount, TCreditCard&& creditCard)
: number(std::forward<TString>(number),
  amount(amount),
  creditCard(std::forward<TCreditCard>(creditCard)) {}

Wystąpił problem z wersją szablonu: użytkownik nie może Account("",0,{brace, initialisation})już pisać .
ipc

@ipc ah, prawda. To naprawdę denerwujące i nie sądzę, że istnieje łatwe, skalowalne obejście.
R. Martinho Fernandes

6

Przede wszystkim std::stringjest dość mocną klasą, podobnie jak std::vector. Z pewnością nie jest prymitywny.

Jeśli bierzesz jakieś duże ruchome typy według wartości do konstruktora, chciałbym std::moveje do elementu członkowskiego:

CreditCard(std::string number, float amount, CreditCard creditCard)
  : number(std::move(number)), amount(amount), creditCard(std::move(creditCard))
{ }

Dokładnie tak poleciłbym implementację konstruktora. Powoduje, że elementy numberi creditCardsą konstruowane w ruchu, a nie kopiowane. Gdy użyjesz tego konstruktora, będzie jedna kopia (lub przeniesienie, jeśli jest to tymczasowe), gdy obiekt zostanie przekazany do konstruktora, a następnie jeden ruch podczas inicjalizacji elementu członkowskiego.

Rozważmy teraz ten konstruktor:

Account(std::string number, float amount, CreditCard& creditCard)
  : number(number), amount(amount), creditCard(creditCard)

Masz rację, będzie to wymagało jednej kopii creditCard, ponieważ jest ona najpierw przekazywana do konstruktora przez referencję. Ale teraz nie możesz przekazać constobiektów do konstruktora (ponieważ odwołanie jest inne niż const) i nie możesz przekazać obiektów tymczasowych. Na przykład nie możesz tego zrobić:

Account account("something", 10.0f, CreditCard("12345",2,2015,1001));

Rozważmy teraz:

Account(std::string number, float amount, CreditCard&& creditCard)
  : number(number), amount(amount), creditCard(std::forward<CreditCard>(creditCard))

Tutaj pokazałeś niezrozumienie odniesień rwartości i std::forward. Powinieneś używać tylko std::forwardwtedy, gdy obiekt, który przekazujesz, jest zadeklarowany jako T&&jakiś typ dedukowany T . Tutaj CreditCardnie jest wydedukowane (zakładam), więc std::forwardjest używany błędnie. Wyszukaj uniwersalne referencje .


1

Używam dość prostej reguły dla ogólnego przypadku: użyj copy dla POD (int, bool, double, ...) i const & dla wszystkiego innego ...

A jeśli chcesz skopiować, czy nie, odpowiedź nie jest podpisem metody, ale bardziej tym, co robisz z parametrami.

struct A {
  A(const std::string& aValue, const std::string& another) 
    : copiedValue(aValue), justARef(another) {}
  std::string copiedValue;
  const std::string& justARef; 
};

precyzja dla wskaźnika: prawie nigdy ich nie używałem. Jedyną zaletą w stosunku do & jest to, że mogą być zerowe lub ponownie przypisane.


2
„Używam dość prostej reguły dla ogólnego przypadku: użyj copy dla POD (int, bool, double, ...) i const & dla wszystkiego innego.” Nie. Tylko nie.
But

Może być dodawanie, jeśli chcesz zmodyfikować wartość za pomocą & (bez stałej). W przeciwnym razie nie widzę ... prostota jest wystarczająco dobra. Ale jeśli tak powiedziałeś ...
David Fleury,

Czasami chcesz zmodyfikować przez odniesienie do typów pierwotnych, a czasami musisz wykonać kopię obiektów, a czasami musisz przenieść obiekty. Nie możesz po prostu zredukować wszystkiego do POD> według wartości i UDT> przez odniesienie.
Shoe

Ok, to właśnie dodałem później. Może to być zbyt szybka odpowiedź.
David Fleury,

1

To dla mnie trochę niejasne, najważniejsze: przekazywanie parametrów.

  • Jeśli chcesz zmodyfikować zmienną przekazaną wewnątrz funkcji / metody
    • przekazujesz to przez odniesienie
    • przekazujesz to jako wskaźnik (*)
  • Jeśli chcesz odczytać wartość / zmienną przekazaną wewnątrz funkcji / metody
    • przekazujesz to przez stałe odniesienie
  • Jeśli chcesz zmodyfikować wartość przekazywaną wewnątrz funkcji / metody
    • przekazujesz to normalnie kopiując obiekt (**)

(*) wskaźniki mogą odnosić się do dynamicznie alokowanej pamięci, dlatego jeśli to możliwe, należy preferować odwołania od wskaźników, nawet jeśli ostatecznie odwołania są zwykle implementowane jako wskaźniki.

(**) „normalnie” oznacza konstruktor kopiujący (jeśli przekazujesz obiekt tego samego typu parametru) lub normalny konstruktor (jeśli przekazujesz zgodny typ dla klasy). Kiedy przekazujesz obiekt myMethod(std::string), na przykład, konstruktor kopiujący zostanie użyty, jeśli std::stringzostanie do niego przekazany, dlatego musisz się upewnić, że istnieje.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.