Vantagens de passagem por valor e std :: mover passagem por referência

105

Estou aprendendo C ++ no momento e tento evitar os maus hábitos. Pelo que entendi, o clang-tidy contém muitas "práticas recomendadas" e tento segui-las da melhor maneira possível (embora ainda não entenda necessariamente por que são consideradas boas), mas não tenho certeza se estou entenda o que é recomendado aqui.

Usei esta aula do tutorial:

class Creature
{
private:
    std::string m_name;

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

Isso leva a uma sugestão do clang-tidy de que eu deveria passar por valor em vez de referência e uso std::move. Se o fizer, recebo a sugestão de fazer nameuma referência (para garantir que não seja copiado todas as vezes) e o aviso de que std::movenão terá qualquer efeito por nameser um, constportanto, devo removê-lo.

A única maneira de não receber um aviso é removendo por constcompleto:

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

O que parece lógico, já que o único benefício de constera evitar mexer na string original (o que não acontece porque passei por valor). Mas eu li no CPlusPlus.com :

Embora, observe que, na biblioteca padrão, a movimentação implica que o objeto movido é deixado em um estado válido, mas não especificado. O que significa que, após tal operação, o valor do objeto movido só deve ser destruído ou atribuído a um novo valor; acessá-lo de outra forma produz um valor não especificado.

Agora imagine este código:

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

Como nameStringé passado por valor, std::movesó vai invalidar namedentro do construtor e não tocar na string original. Mas quais são as vantagens disso? Parece que o conteúdo é copiado apenas uma vez - se eu passar por referência quando chamo m_name{name}, se eu passar por valor quando eu o passar (e então ele é movido). Eu entendo que isso é melhor do que passar por valor e não usar std::move(porque ele é copiado duas vezes).

Portanto, duas questões:

  1. Eu entendi corretamente o que está acontecendo aqui?
  2. Existe alguma vantagem em usar std::movepassar por referência e apenas ligar m_name{name}?
Blackbot
fonte
3
Com passagem por referência, Creature c("John");faz uma cópia extra
user253751
1
Este link pode ser uma leitura valiosa, ele cobre a aprovação std::string_viewe o SSO também.
lubgr
Descobri que clang-tidyé uma ótima maneira de ficar obcecado por microotimizações desnecessárias em detrimento da legibilidade. A questão a fazer aqui, antes de mais nada, é quantas vezes realmente chamamos o Creatureconstrutor.
cz

Respostas:

36
  1. Eu entendi corretamente o que está acontecendo aqui?

Sim.

  1. Existe alguma vantagem em usar std::movepassar por referência e apenas ligar m_name{name}?

Uma assinatura de função fácil de entender sem sobrecargas adicionais. A assinatura revela imediatamente que o argumento será copiado - isso evita que os chamadores se perguntem se uma const std::string&referência pode ser armazenada como um membro de dados, possivelmente se tornando uma referência pendente mais tarde. E não há necessidade de sobrecarregar os argumentos std::string&& namee const std::string&para evitar cópias desnecessárias quando rvalues ​​são passados ​​para a função. Passando um lvalue

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

para a função que recebe seu argumento por valor causa uma cópia e uma construção de movimento. Passando um rvalue para a mesma função

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

causa duas construções de movimento. Em contraste, quando o parâmetro da função é const std::string&, sempre haverá uma cópia, mesmo ao passar um argumento rvalue. Isso é claramente uma vantagem, desde que o tipo de argumento seja barato para mover-construir (este é o caso std::string).

Mas há uma desvantagem a considerar: o raciocínio não funciona para funções que atribuem o argumento da função a outra variável (em vez de inicializá-lo):

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

causará uma desalocação do recurso ao qual m_namese refere antes de ser reatribuído. Recomendo a leitura do item 41 em Effective Modern C ++ e também esta questão .

lubgr
fonte
Isso faz sentido, especialmente porque torna a declaração mais intuitiva de ler. Não tenho certeza se entendi totalmente a parte de desalocação de sua resposta (e entendo o encadeamento vinculado), então, apenas para verificar Se eu usar move, o espaço será desalocado. Se eu não usar move, ele só será desalocado se o espaço alocado for muito pequeno para conter a nova string, levando a um melhor desempenho. Isso é correto?
Blackbot
1
Sim, é exatamente isso. Ao atribuir a m_namepartir de um const std::string&parâmetro, a memória interna é reutilizada enquanto m_namese ajusta. Ao atribuir um movimento m_name, a memória deve ser desalocada de antemão. Caso contrário, seria impossível "roubar" os recursos do lado direito da atribuição.
Lubgr
Quando isso se torna uma referência pendente? Acho que a lista de inicialização usa cópia profunda.
Li Taiji,
102
/* (0) */ 
Creature(const std::string &name) : m_name{name} { }
  • Um lvalue passado vincula-se a namee depois é copiado para m_name.

  • Um rvalue passado vincula-se a namee depois é copiado para m_name.


/* (1) */ 
Creature(std::string name) : m_name{std::move(name)} { }
  • Um lvalue passado é copiado para e name, em seguida, movido para m_name.

  • Um passou rvalue é movida para name, em seguida, é transferida para m_name.


/* (2) */ 
Creature(const std::string &name) : m_name{name} { }
Creature(std::string &&rname) : m_name{std::move(rname)} { }
  • Um lvalue passado vincula-se a namee depois é copiado para m_name.

  • Um rvalue passado liga-se a rnamee depois é movido para m_name.


Como as operações de movimentação são geralmente mais rápidas do que as cópias, (1) é melhor do que (0) se você passar muitos temporários. (2) é ideal em termos de cópias / movimentos, mas requer repetição de código.

A repetição do código pode ser evitada com encaminhamento perfeito :

/* (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)} { }

Você pode opcionalmente querer restringir a Tfim de restringir o domínio de tipos com os quais este construtor pode ser instanciado (como mostrado acima). C ++ 20 visa simplificar isso com Conceitos .


Em C ++ 17, os prvalues são afetados pela eliminação de cópia garantida , que - quando aplicável - reduzirá o número de cópias / movimentos ao passar argumentos para funções.

Vittorio Romeo
fonte
Para (1) o caso do valor pr e do valor x não são idênticos, pois c ++ 17 não?
Oliv
1
Observe que você não precisa do SFINAE para aperfeiçoar o avanço neste caso. É apenas necessário eliminar a ambigüidade. É plausivelmente útil para as mensagens de erro em potencial ao passar argumentos ruins
Caleth
@Oliv Sim. xvalues ​​precisam ser movidos, enquanto prvalues ​​podem ser eliminados :)
Rakete1111
1
Podemos escrever: Creature(const std::string &name) : m_name{std::move(name)} { }no (2) ?
skytree
4
@skytree: você não pode mover de um objeto const, já que mover altera a fonte. Isso irá compilar, mas fará uma cópia.
Vittorio Romeo
1

Como você passa não é a única variável aqui, o que você passa faz a grande diferença entre os dois.

Em C ++, nós temos todos os tipos de categorias de valor e este "idioma" existe para os casos em que você passa em um rvalue (como "Alex-string-literal-that-constructs-temporary-std::string"ou std::move(nameString)), o que resulta em 0 cópias de std::stringser feito (o tipo nem sequer tem que ser copy-constructible para argumentos rvalue) e usa apenas std::stringo construtor de movimento.

Perguntas e respostas relacionadas .

LogicStuff
fonte
1

Existem várias desvantagens da abordagem de passagem por valor e movimento sobre a referência de passagem por (rv):

  • faz com que 3 objetos sejam gerados em vez de 2;
  • passar um objeto por valor pode levar a uma sobrecarga de pilha extra, porque mesmo a classe de string regular é tipicamente pelo menos 3 ou 4 vezes maior do que um ponteiro;
  • a construção de objetos de argumento será feita no lado do chamador, causando inchaço do código;
user7860670
fonte
Você poderia esclarecer por que causaria a geração de 3 objetos? Pelo que entendi, posso apenas passar "Peter" como uma string. Isso seria gerado, copiado e movido, não é? E a pilha não seria usada em algum ponto, independentemente? Não no ponto da chamada do construtor, mas na m_name{name}parte em que ele é copiado?
Blackbot
@Blackbot Eu estava me referindo ao seu exemplo: std::string nameString("Alex"); Creature c(nameString);um objeto é nameString, outro é um argumento de função e o terceiro é um campo de classe.
user7860670