Como passar parâmetros corretamente?

108

Eu sou um iniciante em C ++, mas não um iniciante em programação. Estou tentando aprender C ++ (c ++ 11) e não está claro para mim o mais importante: passar parâmetros.

Considerei estes exemplos simples:

  • Uma classe que tem todos os tipos primitivos de seus membros:
    CreditCard(std::string number, int expMonth, int expYear,int pin):number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • Uma classe que tem como membros tipos primitivos + 1 tipo complexo:
    Account(std::string number, float amount, CreditCard creditCard) : number(number), amount(amount), creditCard(creditCard)

  • Uma classe que tem como membros tipos primitivos + 1 coleção de algum tipo complexo: Client(std::string firstName, std::string lastName, std::vector<Account> accounts):firstName(firstName), lastName(lastName), accounts(accounts)

Quando eu crio uma conta, eu faço o seguinte:

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

Obviamente, o cartão de crédito será copiado duas vezes neste cenário. Se eu reescrever esse construtor como

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

haverá uma cópia. Se eu reescrever como

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

Haverá 2 movimentos e nenhuma cópia.

Eu acho que às vezes você pode querer copiar algum parâmetro, às vezes você não deseja copiar ao criar esse objeto.
Venho do C # e, acostumado a referências, é um pouco estranho para mim e acho que deveria haver 2 sobrecargas para cada parâmetro mas sei que estou errado.
Existem boas práticas de como enviar parâmetros em C ++ porque eu realmente acho isso, digamos, nada trivial. Como você lidaria com meus exemplos apresentados acima?

Jack Willson
fonte
9
Meta: Não acredito que alguém fez uma boa pergunta sobre C ++. +1.
23
FYI, std::stringé uma classe, assim como CreditCard, não um tipo primitivo.
chris
7
Devido à confusão da string, embora não relacionado, você deve saber que uma string literal,, "abc"não é do tipo std::string, não do tipo char */const char *, mas do tipo const char[N](com N = 4 neste caso devido a três caracteres e um nulo). Esse é um equívoco comum e bom para sair do caminho.
Chris
10
@chuex: Jon Skeet trata de questões de C #, muito mais raramente de C ++.
Steve Jessop
Altamente relacionado: Como passar objetos para funções em C ++?
legends2k

Respostas:

158

A PERGUNTA MAIS IMPORTANTE PRIMEIRO:

Existem práticas recomendadas de como enviar parâmetros em C ++ porque eu realmente acho, digamos, nada trivial

Se sua função precisar modificar o objeto original que está sendo passado, de modo que após o retorno da chamada, as modificações nesse objeto ficarão visíveis para o chamador, então você deve passar pela referência lvalue :

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

Se sua função não precisa modificar o objeto original e não precisa criar uma cópia dele (em outras palavras, ela só precisa observar seu estado), você deve passar pela referência lvalue paraconst :

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

Isso permitirá que você chame a função com lvalues ​​(lvalues ​​são objetos com uma identidade estável) e com rvalues ​​(rvalues ​​são, por exemplo , temporários ou objetos dos quais você está prestes a se mover como resultado da chamada std::move()).

Também se poderia argumentar que para tipos fundamentais ou para os quais a cópia é rápida , como int, boolou char, não há necessidade de passar por referência se a função simplesmente precisar observar o valor, e a passagem por valor deve ser favorecida . Isso está correto se a semântica de referência não for necessária, mas e se a função quisesse armazenar um ponteiro para esse mesmo objeto de entrada em algum lugar, de modo que leituras futuras por meio desse ponteiro verão as modificações de valor que foram realizadas em alguma outra parte do código? Nesse caso, passar por referência é a solução correta.

Se sua função não precisa modificar o objeto original, mas precisa armazenar uma cópia desse objeto ( possivelmente para retornar o resultado de uma transformação da entrada sem alterar a entrada ), então você pode considerar tomar por valor :

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

Invocar a função acima sempre resultará em uma cópia ao passar lvalues ​​e em um movimento ao passar rvalues. Se a sua função precisa armazenar este objeto em algum lugar, você poderia realizar um adicional de movimento a partir dele (por exemplo, no caso foo()é uma função membro que as necessidades para armazenar o valor em um membro de dados ).

Caso as movimentações sejam caras para objetos do tipo my_class, você pode considerar sobrecarregar foo()e fornecer uma versão para lvalues ​​(aceitando uma referência lvalue para const) e uma versão para rvalues ​​(aceitando uma referência 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...
}

As funções acima são tão semelhantes, na verdade, que você poderia fazer uma única função com isso: foo()poderia se tornar um modelo de função e você poderia usar o encaminhamento perfeito para determinar se um movimento ou uma cópia do objeto sendo passado será gerado internamente:

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

Você pode querer aprender mais sobre este design assistindo a esta palestra de Scott Meyers (lembre-se do fato de que o termo " Referências Universais " que ele está usando não é padrão).

Uma coisa a ter em mente é que std::forwardgeralmente resultará em um movimento para rvalues, então, embora pareça relativamente inocente, encaminhar o mesmo objeto várias vezes pode ser uma fonte de problemas - por exemplo, mover-se do mesmo objeto duas vezes! Portanto, tome cuidado para não colocar isso em um loop e não encaminhar o mesmo argumento várias vezes em uma chamada de função:

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

Observe também que você normalmente não recorre à solução baseada em modelo, a menos que tenha um bom motivo para isso, pois torna o código mais difícil de ler. Normalmente, você deve se concentrar na clareza e na simplicidade .

Os itens acima são apenas diretrizes simples, mas na maioria das vezes eles irão apontar para boas decisões de design.


SOBRE O RESTO DE SUA POSTAGEM:

Se eu reescrever, [...] haverá 2 movimentos e nenhuma cópia.

Isso não está correto. Para começar, uma referência de rvalue não pode ser associada a um lvalue, então isso só será compilado quando você estiver passando um rvalue do tipo CreditCardpara seu construtor. Por exemplo:

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

Mas não funcionará se você tentar fazer isso:

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

Porque ccé um lvalue e as referências de rvalue não podem ser associadas a lvalues. Além disso, ao vincular uma referência a um objeto, nenhum movimento é executado : é apenas uma vinculação de referência. Portanto, haverá apenas um movimento.


Portanto, com base nas diretrizes fornecidas na primeira parte desta resposta, se você estiver preocupado com o número de movimentos sendo gerados quando você toma um CreditCardvalor por, você poderia definir duas sobrecargas de construtor, uma tomando uma referência de lvalue para const( CreditCard const&) e outra tomando uma referência rvalue ( CreditCard&&).

A resolução de sobrecarga selecionará o primeiro ao passar um lvalue (neste caso, uma cópia será executada) e o último ao passar um rvalue (neste caso, um movimento será executado).

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

O uso de std::forward<>normalmente é visto quando você deseja obter um encaminhamento perfeito . Nesse caso, seu construtor seria, na verdade, um modelo de construtor e seria mais ou menos como segue

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

De certa forma, isso combina as sobrecargas que mostrei anteriormente em uma única função: Cserá deduzido como CreditCard&no caso de você estar passando um lvalue e, devido às regras de colapso de referência, fará com que esta função seja instanciada:

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

Isso causará uma construção de cópia de creditCard, como você deseja. Por outro lado, quando um rvalue é passado, Cserá deduzido ser CreditCard, e esta função será instanciada em seu lugar:

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

Isso causará uma construção de movimentação de creditCard, que é o que você deseja (porque o valor que está sendo passado é um rvalue, e isso significa que estamos autorizados a mover a partir dele).

Andy Prowl
fonte
É errado dizer que, para tipos de parâmetros não primitivos, é normal sempre usar a versão do modelo com o forward?
Jack Willson
1
@JackWillson: Eu diria que você deve recorrer à versão de modelo apenas quando realmente tiver preocupações com o desempenho dos movimentos. Veja esta minha pergunta , que tem uma boa resposta, para mais informações. Em geral, se você não precisa fazer uma cópia e só precisa observar, leve por ref para const. Se você precisar modificar o objeto original, leve por ref para non-`const. Se precisar fazer uma cópia e os movimentos forem baratos, pegue pelo valor e depois mova.
Andy Prowl
2
Acho que não há necessidade de mover no terceiro exemplo de código. Você poderia simplesmente usar em objvez de fazer uma cópia local para mover, não?
juanchopanza
3
@AndyProwl: Você recebeu meu +1 horas atrás, mas relendo sua (excelente) resposta, gostaria de apontar duas coisas: a) por valor nem sempre cria uma cópia (se não for movida). O objeto pode ser construído no local no site do chamador, especialmente com RVO / NRVO, isso funciona com mais frequência do que se poderia imaginar. b) Saliente que no último exemplo, só std::forwardpodem ser chamados uma vez . Já vi pessoas colocando-o em loops, etc. e uma vez que esta resposta será vista por muitos iniciantes, IMHO deve haver um grande "Aviso!" - rótulo para ajudá-los a evitar essa armadilha.
Daniel Frey
1
@SteveJessop: Além disso, existem questões técnicas (não necessariamente problemas) com funções de encaminhamento ( especialmente construtores que usam um argumento ), principalmente o fato de que eles aceitam argumentos de qualquer tipo e podem derrotar o std::is_constructible<>traço de tipo, a menos que sejam apropriadamente SFINAE- restrito - o que pode não ser trivial para alguns.
Andy Prowl,
11

Primeiro, deixe-me corrigir alguns detalhes. Quando você diz o seguinte:

haverá 2 movimentos e nenhuma cópia.

Isso é falso. Vincular a uma referência rvalue não é um movimento. Só existe um movimento.

Além disso, uma vez que CreditCardnão é um parâmetro de modelo, std::forward<CreditCard>(creditCard)é apenas uma forma prolixa de dizer std::move(creditCard).

Agora...

Se seus tipos têm movimentos "baratos", você pode simplesmente tornar sua vida mais fácil e levar tudo por valor e " std::movejunto".

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

Essa abordagem renderá dois movimentos quando poderia render apenas um, mas se os movimentos forem baratos, eles podem ser aceitáveis.

Enquanto estamos neste assunto de "movimentos baratos", devo lembrá-lo que std::stringmuitas vezes é implementado com a chamada otimização de string pequeno, então seus movimentos podem não ser tão baratos quanto copiar alguns ponteiros. Como de costume com questões de otimização, se importa ou não, é algo para perguntar ao seu criador de perfil, não a mim.

O que fazer se você não quiser incorrer nesses movimentos extras? Talvez eles sejam muito caros, ou pior, talvez os tipos não possam realmente ser movidos e você possa gerar cópias extras.

Se houver apenas um parâmetro problemático, você pode fornecer duas sobrecargas, com T const&e T&&. Isso vinculará referências o tempo todo até a inicialização do membro real, onde ocorre uma cópia ou movimentação.

No entanto, se você tiver mais de um parâmetro, isso leva a uma explosão exponencial no número de sobrecargas.

Este é um problema que pode ser resolvido com o encaminhamento perfeito. Isso significa que você escreve um modelo e usa std::forwardpara transportar a categoria de valor dos argumentos para seu destino final como membros.

template <typename TString, typename TCreditCard>
Account(TString&& number, float amount, TCreditCard&& creditCard)
: number(std::forward<TString>(number),
  amount(amount),
  creditCard(std::forward<TCreditCard>(creditCard)) {}
R. Martinho Fernandes
fonte
Há um problema com a versão do modelo: o usuário não consegue Account("",0,{brace, initialisation})mais escrever .
ipc
@ipc ah, é verdade. Isso é realmente irritante e não acho que haja uma solução alternativa escalonável fácil.
R. Martinho Fernandes
6

Em primeiro lugar, std::stringé um tipo de classe bastante robusto std::vector. Certamente não é primitivo.

Se você estiver pegando qualquer tipo móvel grande por valor em um construtor, eu os colocaria std::moveno membro:

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

É exatamente assim que eu recomendaria implementar o construtor. Isso faz com que os membros numbere creditCardpara ser jogada construída, ao invés de cópia construído. Quando você usa este construtor, haverá uma cópia (ou movimento, se temporário) conforme o objeto é passado para o construtor e, em seguida, um movimento ao inicializar o membro.

Agora vamos considerar este construtor:

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

Você está certo, isso envolverá uma cópia de creditCard, porque primeiro é passado para o construtor por referência. Mas agora você não pode passar constobjetos para o construtor (porque a referência não é const) e você não pode passar objetos temporários. Por exemplo, você não poderia fazer isso:

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

Agora vamos considerar:

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

Aqui você mostrou um mal-entendido de referências de rvalue e std::forward. Você só deve realmente usar std::forwardquando o objeto que está encaminhando for declarado como T&&para algum tipo deduzido T . Aqui CreditCardnão é deduzido (estou supondo) e, portanto, o std::forwardestá sendo usado por engano. Procure referências universais .

Joseph Mansfield
fonte
1

Eu uso uma regra bastante simples para o caso geral: usar cópia para POD (int, bool, double, ...) e const & para todo o resto ...

E querer copiar ou não, não é respondido pela assinatura do método, mas mais pelo que você faz com os parâmetros.

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

precisão para ponteiro: quase nunca os usei. A única vantagem sobre & é que eles podem ser nulos ou reatribuídos.

David Fleury
fonte
2
"Eu uso uma regra bastante simples para o caso geral: Use cópia para POD (int, bool, double, ...) e const & para todo o resto." Não. Apenas não.
Sapato
Pode estar adicionando, se você quiser modificar um valor usando um & (sem const). Senão, não vejo ... simplicidade é bom o suficiente. Mas se você disse ...
David Fleury
Algumas vezes você deseja modificar os tipos primitivos de referência, outras vezes você precisa fazer uma cópia dos objetos e outras vezes você precisa mover os objetos. Você não pode simplesmente reduzir tudo para POD> por valor e UDT> por referência.
Sapato
Ok, isso é apenas o que eu adicionei depois. Pode ser uma resposta muito rápida.
David Fleury
1

Não está claro para mim a coisa mais importante: passar parâmetros.

  • Se você deseja modificar a variável passada dentro da função / método
    • você passa por referência
    • você passa como um ponteiro (*)
  • Se você deseja ler o valor / variável passado dentro da função / método
    • você passa por referência const
  • Se você deseja modificar o valor passado dentro da função / método
    • você passa normalmente copiando o objeto (**)

Os ponteiros (*) podem referir-se à memória alocada dinamicamente, portanto, quando possível, você deve preferir referências a ponteiros, mesmo se as referências forem, no final, geralmente implementadas como ponteiros.

(**) "normalmente" significa por construtor de cópia (se passa um objeto do mesmo tipo do parâmetro) ou por construtor normal (se passa um tipo compatível para a classe). Quando você passa um objeto como myMethod(std::string), por exemplo, o construtor de cópia será usado se um std::stringfor passado para ele, portanto, você deve ter certeza de que existe um.

Sapato
fonte