Come passare correttamente i parametri?


108

Sono un principiante C ++ ma non un principiante di programmazione. Sto cercando di imparare il C ++ (c ++ 11) e non è abbastanza chiaro per me la cosa più importante: passare i parametri.

Ho considerato questi semplici esempi:

  • Una classe che ha tutti i suoi membri tipi primitivi:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Una classe che ha come membri tipi primitivi + 1 tipo complesso:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Una classe che ha come membri tipi primitivi + 1 raccolta di un tipo complesso: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Quando creo un account, faccio questo:

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

Ovviamente la carta di credito verrà copiata due volte in questo scenario. Se riscrivo quel costruttore come

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

ci sarà una copia. Se lo riscrivo come

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

Ci saranno 2 mosse e nessuna copia.

Penso che a volte potresti voler copiare alcuni parametri, a volte non vuoi copiare quando crei quell'oggetto.
Vengo da C # e, essendo abituato ai riferimenti, è un po 'strano per me e penso che dovrebbero esserci 2 sovraccarichi per ogni parametro ma so che mi sbaglio.
Esistono buone pratiche su come inviare parametri in C ++ perché lo trovo davvero, diciamo, non banale. Come gestireste i miei esempi presentati sopra?


9
Meta: Non riesco a credere che qualcuno abbia appena fatto una buona domanda in C ++. +1.

23
Cordiali saluti, std::stringè una classe, proprio come CreditCard, non un tipo primitivo.
chris

7
A causa della confusione delle stringhe, sebbene non correlata, dovresti sapere che una stringa letterale,, "abc"non è di tipo std::string, non di tipo char */const char *, ma di tipo const char[N](con N = 4 in questo caso a causa di tre caratteri e un null). Questo è un buon malinteso comune per togliersi di mezzo.
chris

10
@chuex: Jon Skeet è tutto su domande C #, molto più raramente C ++.
Steve Jessop

Risposte:


158

LA DOMANDA PIÙ IMPORTANTE PRIMA:

Esistono buone pratiche su come inviare parametri in C ++ perché lo trovo davvero, diciamo, non banale

Se la tua funzione deve modificare l'oggetto originale passato, in modo che dopo il ritorno della chiamata, le modifiche a quell'oggetto saranno visibili al chiamante, dovresti passare per riferimento lvalue :

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

Se la tua funzione non ha bisogno di modificare l'oggetto originale e non ha bisogno di crearne una copia (in altre parole, deve solo osservare il suo stato), allora dovresti passare per riferimento lvalue aconst :

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

Ciò ti consentirà di chiamare la funzione sia con lvalues ​​(lvalues ​​sono oggetti con un'identità stabile) che con rvalues ​​(rvalues ​​sono, ad esempio , temporanei , o oggetti da cui stai per spostarti come risultato della chiamata std::move()).

Si potrebbe anche sostenere che per tipi fondamentali o tipi per i quali la copia è veloce , come int, boolo char, non è necessario passare per riferimento se la funzione deve semplicemente osservare il valore e il passaggio per valore dovrebbe essere favorito . Questo è corretto se la semantica di riferimento non è necessaria, ma cosa succede se la funzione volesse memorizzare un puntatore a quello stesso oggetto di input da qualche parte, in modo che le future letture attraverso quel puntatore vedranno le modifiche al valore che sono state eseguite in qualche altra parte del codice? In questo caso, passare per riferimento è la soluzione corretta.

Se la tua funzione non ha bisogno di modificare l'oggetto originale, ma ha bisogno di memorizzare una copia di quell'oggetto ( possibilmente per restituire il risultato di una trasformazione dell'input senza alterare l'input ), allora potresti considerare di prendere per valore :

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...
}

Invocare la funzione di cui sopra risulterà sempre in una copia quando si passano i valori l e in una si sposta quando si passano i valori. Se la tua funzione ha bisogno di memorizzare questo oggetto da qualche parte, puoi eseguire uno spostamento aggiuntivo da esso (ad esempio, nel caso in cui foo()è una funzione membro che deve memorizzare il valore in un membro dati ).

Nel caso in cui le mosse siano costose per oggetti di tipo my_class, puoi prendere in considerazione il sovraccarico foo()e fornire una versione per lvalues ​​(accettando un riferimento lvalue a const) e una versione per rvalues ​​(accettando un riferimento 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...
}

Le funzioni di cui sopra sono così simili, infatti, che potresti ricavarne una singola funzione: foo()potrebbe diventare un modello di funzione e potresti utilizzare l' inoltro perfetto per determinare se verrà generata internamente una mossa o una copia dell'oggetto passato:

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...
}

Potresti voler saperne di più su questo progetto guardando questo discorso di Scott Meyers ( fai attenzione al fatto che il termine " Riferimenti universali " che sta usando non è standard).

Una cosa da tenere a mente è che di std::forwardsolito finirà in una mossa per rvalues, quindi anche se sembra relativamente innocente, inoltrare lo stesso oggetto più volte può essere fonte di problemi, ad esempio spostarsi dallo stesso oggetto due volte! Quindi fai attenzione a non metterlo in un ciclo e non inoltrare lo stesso argomento più volte in una chiamata di funzione:

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

Si noti inoltre che normalmente non si ricorre alla soluzione basata su modelli a meno che non si abbia una buona ragione per farlo, poiché rende il codice più difficile da leggere. Normalmente, dovresti concentrarti sulla chiarezza e sulla semplicità .

Quanto sopra sono solo semplici linee guida, ma la maggior parte delle volte ti indirizzeranno verso buone decisioni di progettazione.


RIGUARDO AL RESTO DEL TUO POST:

Se lo riscrivo come [...] ci saranno 2 mosse e nessuna copia.

Questo non è corretto. Per cominciare, un riferimento rvalue non può legarsi a un lvalue, quindi verrà compilato solo quando si passa un rvalue di tipo CreditCardal costruttore. Per esempio:

// 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));

Ma non funzionerà se provi a farlo:

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

Perché ccè un lvalue e i riferimenti rvalue non possono legarsi a lvalues. Inoltre, quando si associa un riferimento a un oggetto, non viene eseguita alcuna mossa : è solo un'associazione di riferimento. Quindi, ci sarà solo una mossa.


Quindi, in base alle linee guida fornite nella prima parte di questa risposta, se sei interessato al numero di mosse generate quando prendi un CreditCardvalore per, puoi definire due overload del costruttore, uno che prende un riferimento lvalue a const( CreditCard const&) e uno che prende un riferimento rvalue ( CreditCard&&).

La risoluzione del sovraccarico selezionerà il primo al passaggio di un valore (in questo caso verrà eseguita una copia) e il secondo al passaggio di un valore (in questo caso verrà eseguita una mossa).

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
{ }

L'utilizzo di std::forward<>viene normalmente visualizzato quando si desidera ottenere un inoltro perfetto . In tal caso, il tuo costruttore sarebbe effettivamente un modello di costruttore e apparirebbe più o meno come segue

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

In un certo senso, questo combina entrambi i sovraccarichi che ho mostrato in precedenza in un'unica funzione: Cverrà dedotto come CreditCard&nel caso in cui stai passando un lvalue e, a causa delle regole di collassamento dei riferimenti, causerà l'istanza di questa funzione:

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

Ciò causerà una copia-costruzione di creditCard, come vorresti. D'altra parte, quando viene passato un rvalue, Cverrà dedotto essere CreditCard, e questa funzione verrà invece istanziata:

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

Ciò causerà una costruzione del movimento di creditCard, che è ciò che vuoi (perché il valore passato è un rvalue, e questo significa che siamo autorizzati a muoverci da esso).


È sbagliato affermare che per i tipi di parametri non primitivi è corretto utilizzare sempre la versione del modello con forward?
Jack Willson

1
@JackWillson: direi che dovresti ricorrere alla versione del modello solo quando hai davvero dubbi sull'esecuzione delle mosse. Vedi questa mia domanda , che ha una buona risposta, per maggiori informazioni. In generale, se non devi fare una copia e devi solo osservare, prendi per riferimento a const. Se hai bisogno di modificare l'oggetto originale, prendi per ref su non-`const. Se hai bisogno di fare una copia e le mosse sono economiche, prendi per valore e poi sposta.
Andy Prowl

2
Penso che non sia necessario passare al terzo esempio di codice. Potresti semplicemente usare objinvece di fare una copia locale in cui trasferirti, no?
juanchopanza

3
@ AndyProwl: hai ricevuto il mio +1 ore fa, ma rileggendo la tua (eccellente) risposta, vorrei sottolineare due cose: a) per valore non sempre crea una copia (se non viene spostata). L'oggetto potrebbe essere costruito sul posto sul sito del chiamante, specialmente con RVO / NRVO questo funziona anche più spesso di quanto si potrebbe pensare. b) Si precisa che nell'ultimo esempio, std::forwardpuò essere richiamato una sola volta . Ho visto persone metterlo in loop, ecc. E poiché questa risposta sarà vista da molti principianti, IMHO dovrebbe esserci un grasso "Avvertimento!" - etichetta per aiutarli a evitare questa trappola.
Daniel Frey

1
@SteveJessop: E a parte questo, ci sono problemi tecnici (non necessariamente problemi) con le funzioni di inoltro ( specialmente i costruttori che accettano un argomento ), principalmente il fatto che accettano argomenti di qualsiasi tipo e possono std::is_constructible<>annullare il tratto del tipo a meno che non siano propriamente SFINAE- vincolato - che potrebbe non essere banale per alcuni.
Andy Prowl

11

Per prima cosa, permettimi di correggere alcuni dettagli. Quando dici quanto segue:

ci saranno 2 mosse e nessuna copia.

Questo è falso. Il legame a un riferimento rvalue non è una mossa. C'è solo una mossa.

Inoltre, poiché CreditCardnon è un parametro del modello, std::forward<CreditCard>(creditCard)è solo un modo prolisso per dire std::move(creditCard).

Adesso...

Se i tuoi tipi hanno mosse "economiche", potresti voler semplicemente semplificarti la vita e prendere tutto per valore e " std::moveinsieme".

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

Questo approccio ti darà due mosse quando potrebbe produrne solo una, ma se le mosse sono economiche, potrebbero essere accettabili.

Mentre siamo su questo argomento "mosse economiche", dovrei ricordarti che std::stringè spesso implementato con la cosiddetta ottimizzazione di piccole stringhe, quindi le sue mosse potrebbero non essere così economiche come copiare alcuni puntatori. Come al solito con i problemi di ottimizzazione, che sia importante o meno è qualcosa da chiedere al tuo profiler, non a me.

Cosa fare se non vuoi incorrere in quelle mosse extra? Forse si dimostrano troppo costosi, o peggio, forse i tipi non possono essere effettivamente spostati e potresti incorrere in copie extra.

Se è presente un solo parametro problematico, è possibile fornire due overload, con T const&e T&&. Ciò vincolerà i riferimenti tutto il tempo fino all'effettiva inizializzazione del membro, dove avviene una copia o uno spostamento.

Tuttavia, se hai più di un parametro, questo porta a un'esplosione esponenziale nel numero di sovraccarichi.

Questo è un problema che può essere risolto con un inoltro perfetto. Ciò significa che si scrive invece un modello e che si utilizza std::forwardper portare con sé la categoria di valore degli argomenti alla loro destinazione finale come membri.

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

C'è un problema con la versione del modello: l'utente non può Account("",0,{brace, initialisation})più scrivere .
ipc

@ipc ah, vero. Questo è davvero fastidioso e non credo che ci sia una soluzione semplice e scalabile.
R. Martinho Fernandes

6

Prima di tutto, std::stringè un tipo di classe piuttosto pesante proprio come std::vector. Non è certo primitivo.

Se stai prendendo qualsiasi tipo mobile di grandi dimensioni per valore in un costruttore, std::moveli inserirò nel membro:

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

Questo è esattamente il modo in cui consiglierei di implementare il costruttore. Fa sì che i membri numbere creditCardvengano costruiti in movimento, piuttosto che costruiti in copia. Quando si utilizza questo costruttore, ci sarà una copia (o mossa, se temporanea) quando l'oggetto viene passato al costruttore e poi una mossa durante l'inizializzazione del membro.

Consideriamo ora questo costruttore:

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

Hai ragione, questo comporterà una copia di creditCard, perché viene prima passato al costruttore per riferimento. Ma ora non puoi passare constoggetti al costruttore (perché il riferimento non è- const) e non puoi passare oggetti temporanei. Ad esempio, non potresti farlo:

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

Consideriamo ora:

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

Qui hai mostrato un malinteso di riferimenti rvalue e std::forward. Dovresti davvero usare solo std::forwardquando l'oggetto che stai inoltrando è dichiarato come T&&per un tipo dedotto T . Qui CreditCardnon è dedotto (presumo), quindi std::forwardviene utilizzato per errore. Cerca riferimenti universali .


1

Uso una regola abbastanza semplice per il caso generale: usa copy per POD (int, bool, double, ...) e const & per tutto il resto ...

E volendo copiare o meno, non si risponde con la firma del metodo ma più con ciò che si fa con i parametri.

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

precisione per il puntatore: non li ho quasi mai usati. L'unico vantaggio rispetto a & è che possono essere nulli o riassegnati.


2
"Uso una regola abbastanza semplice per il caso generale: usa copy per POD (int, bool, double, ...) e const & per tutto il resto." No. Solo No.
Scarpa

Potrebbe essere l'aggiunta, se si desidera modificare un valore utilizzando un & (no const). Altrimenti non vedo ... la semplicità è abbastanza buona. Ma se lo hai detto tu ...
David Fleury

Alcune volte si desidera modificare facendo riferimento a tipi primitivi, a volte è necessario creare una copia di oggetti ea volte è necessario spostare gli oggetti. Non puoi semplicemente ridurre tutto a POD> per valore e UDT> per riferimento.
Scarpa

Ok, questo è solo quello che ho aggiunto dopo. Potrebbe essere una risposta troppo veloce.
David Fleury

1

Non è abbastanza chiaro per me la cosa più importante: passare i parametri.

  • Se vuoi modificare la variabile passata all'interno della funzione / metodo
    • lo passi per riferimento
    • lo passi come un puntatore (*)
  • Se vuoi leggere il valore / variabile passato all'interno della funzione / metodo
    • lo passi per riferimento const
  • Se vuoi modificare il valore passato all'interno della funzione / metodo
    • lo si passa normalmente copiando l'oggetto (**)

(*) i puntatori possono fare riferimento alla memoria allocata dinamicamente, quindi quando possibile dovresti preferire i riferimenti ai puntatori anche se i riferimenti sono, alla fine, normalmente implementati come puntatori.

(**) "normalmente" significa da costruttore di copia (se si passa un oggetto dello stesso tipo del parametro) o da costruttore normale (se si passa un tipo compatibile per la classe). Quando si passa un oggetto come myMethod(std::string), ad esempio, verrà utilizzato il costruttore di copia se gli std::stringviene passato un, quindi è necessario assicurarsi che ne esista uno.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.