Em C ++, ainda é uma prática ruim retornar um vetor de uma função?

103

Versão curta: é comum retornar objetos grandes - como vetores / matrizes - em muitas linguagens de programação. Este estilo agora é aceitável em C ++ 0x se a classe tiver um construtor de movimento, ou os programadores de C ++ o consideram estranho / feio / abominação?

Versão longa: Em C ++ 0x isso ainda é considerado formato incorreto?

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

A versão tradicional seria assim:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

Na versão mais recente, o valor retornado de BuildLargeVectoré um rvalue, então v seria construído usando o construtor de movimento de std::vector, supondo que (N) RVO não ocorra.

Mesmo antes de C ++ 0x, a primeira forma costumava ser "eficiente" devido ao (N) RVO. No entanto, (N) RVO fica a critério do compilador. Agora que temos referências de rvalue, é garantido que nenhuma cópia profunda ocorrerá.

Edit : A questão realmente não é sobre otimização. Ambos os formulários mostrados têm desempenho quase idêntico em programas do mundo real. Considerando que, no passado, a primeira forma poderia ter tido um desempenho pior da ordem de magnitude. Como resultado, a primeira forma foi por muito tempo um grande cheiro de código na programação C ++. Não é mais, espero?

Nate
fonte
18
Quem disse que era uma má forma para começar?
Edward Strange
7
Certamente era um cheiro ruim de código nos “velhos tempos”, que é de onde eu sou. :-)
Nate
1
Eu certamente espero que sim! Eu gostaria de ver a passagem por valor se tornando mais popular. :)
sellibitze

Respostas:

73

Dave Abrahams tem uma análise bastante abrangente da velocidade de passagem / retorno de valores .

Resposta curta, se você precisar retornar um valor, retorne um valor. Não use referências de saída porque o compilador faz isso de qualquer maneira. Claro que existem ressalvas, então você deve ler esse artigo.

Peter Alexander
fonte
24
"o compilador faz de qualquer maneira": o compilador não é necessário para fazer isso == incerteza == má ideia (precisa de 100% de certeza). "análise abrangente" Há um grande problema com essa análise - ela depende de recursos de linguagem não documentados / não padrão em um compilador desconhecido ("Embora a eliminação de cópia nunca seja exigida pelo padrão"). Portanto, mesmo que funcione, não é uma boa ideia usá-lo - não há absolutamente nenhuma garantia de que funcionará conforme o esperado e não há garantia de que todo compilador sempre funcionará dessa maneira. Confiar neste documento é uma má prática de codificação, IMO. Mesmo se você perder desempenho.
SigTerm
5
@SigTerm: Esse é um comentário excelente !!! a maior parte do artigo referenciado é muito vago para sequer considerar para uso na produção. As pessoas pensam que qualquer coisa que um autor que escreveu um livro Red In-Depth é evangelho e deve ser seguido sem qualquer reflexão ou análise adicional. ATM, não há um compilador no mercado que forneça cópia-elison tão variada quanto os exemplos que Abrahams usa no artigo.
Hippicoder
13
@SigTerm, há muitas coisas que o compilador não precisa fazer, mas você presume que faça de qualquer maneira. Os compiladores não são "obrigados" a mudar x / 2para x >> 1for ints, mas você supõe que sim. O padrão também não diz nada sobre como os compiladores são obrigados a implementar referências, mas você assume que eles são tratados com eficiência usando ponteiros. O padrão também não diz nada sobre as tabelas v, então você também não pode ter certeza de que as chamadas de funções virtuais são eficientes. Essencialmente, às vezes você precisa colocar um pouco de fé no compilador.
Peter Alexander
16
@Sig: Muito pouco é realmente garantido, exceto a saída real do seu programa. Se você deseja ter 100% de certeza sobre o que vai acontecer 100% do tempo, é melhor mudar para um idioma diferente imediatamente.
Dennis Zickefoose
6
@SigTerm: Eu trabalho no "cenário de caso real". Eu testo o que o compilador faz e trabalho com isso. Não há "pode ​​trabalhar mais lento". Ele simplesmente não funciona mais devagar porque o compilador implementa RVO, quer o padrão exija ou não. Não há ifs, buts ou maybes, é apenas um fato simples.
Peter Alexander
37

Pelo menos IMO, geralmente é uma ideia ruim, mas não por razões de eficiência. É uma ideia ruim porque a função em questão geralmente deve ser escrita como um algoritmo genérico que produz sua saída por meio de um iterador. Quase todo código que aceita ou retorna um contêiner em vez de operar em iteradores deve ser considerado suspeito.

Não me interpretem mal: há momentos em que faz sentido passar objetos semelhantes a coleções (por exemplo, strings), mas para o exemplo citado, consideraria passar ou retornar o vetor uma ideia ruim.

Jerry Coffin
fonte
6
O problema com a abordagem do iterador é que ela requer que você crie funções e métodos modelados, mesmo quando o tipo de elemento da coleção é conhecido. Isso é irritante e, quando o método em questão é virtual, impossível. Observe, não estou discordando da sua resposta em si, mas na prática ela se torna um pouco complicada em C ++.
Jon-hanson
22
Eu tenho que discordar. Usar iteradores para saída às vezes é apropriado, mas se você não estiver escrevendo um algoritmo genérico, as soluções genéricas geralmente fornecem sobrecarga inevitável que é difícil de justificar. Tanto em termos de complexidade de código quanto em desempenho real.
Dennis Zickefoose
1
@Dennis: Devo dizer que minha experiência tem sido exatamente o oposto: eu escrevo um bom número de coisas como modelos, mesmo quando conheço os tipos envolvidos com antecedência, porque fazer isso é mais simples e melhora o desempenho.
Jerry Coffin
9
Eu pessoalmente devolvo um contêiner. A intenção é clara, o código é mais fácil, não me importo muito com o desempenho quando o escrevo (apenas evito a pessimização precoce). Não tenho certeza se usar um iterador de saída tornaria minha intenção mais clara ... e preciso de código não-modelo tanto quanto possível, porque em um grande projeto as dependências matam o desenvolvimento.
Matthieu M.
1
@Dennis: Vou postular conceitualmente, você nunca deve "construir um contêiner em vez de escrever para um intervalo". Um contêiner é apenas isso - um contêiner. Sua preocupação (e a preocupação de seu código) deve ser com o conteúdo, não com o contêiner.
Jerry Coffin
18

A essência é:

Copy Elision e RVO podem evitar as "cópias assustadoras" (o compilador não é necessário para implementar essas otimizações e, em algumas situações, não pode ser aplicado)

As referências C ++ 0x RValue permitem implementações de string / vetor que garantem isso.

Se você pode abandonar compiladores / implementações STL mais antigos, retorne vetores livremente (e certifique-se de que seus próprios objetos também os suportem). Se sua base de código precisa suportar compiladores "menores", mantenha o estilo antigo.

Infelizmente, isso tem grande influência em suas interfaces. Se C ++ 0x não for uma opção e você precisar de garantias, poderá usar, em vez disso, objetos de contagem de referência ou de cópia na gravação em alguns cenários. Eles têm desvantagens com multithreading, no entanto.

(Eu gostaria que apenas uma resposta em C ++ fosse simples, direta e sem condições).

Peterchen
fonte
11

Na verdade, desde o C ++ 11, o custo de cópia do C ++ std::vectoracabou na maioria dos casos.

No entanto, deve-se ter em mente que o custo de construir o novo vetor (e então destruí- lo) ainda existe, e usar parâmetros de saída em vez de retornar por valor ainda é útil quando você deseja reutilizar a capacidade do vetor. Isso é documentado como uma exceção em F.20 das Diretrizes Básicas C ++.

Vamos comparar:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

com:

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

Agora, suponha que precisemos chamar esses métodos numIterem um loop apertado e executar alguma ação. Por exemplo, vamos calcular a soma de todos os elementos.

Usando BuildLargeVector1, você faria:

size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
    std::vector<int> v = BuildLargeVector1(vecSize);
    sum1 = std::accumulate(v.begin(), v.end(), sum1);
}

Usando BuildLargeVector2, você faria:

size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
    BuildLargeVector2(/*out*/ v, vecSize);
    sum2 = std::accumulate(v.begin(), v.end(), sum2);
}

No primeiro exemplo, há muitas alocações / desalocações dinâmicas desnecessárias acontecendo, que são evitadas no segundo exemplo usando um parâmetro de saída da maneira antiga, reutilizando a memória já alocada. Se essa otimização vale ou não a pena, depende do custo relativo da alocação / desalocação em comparação com o custo de calcular / alterar os valores.

Benchmark

Vamos brincar com os valores de vecSizee numIter. Manteremos vecSize * numIter constante para que "em teoria" demore o mesmo tempo (= há o mesmo número de atribuições e adições, com exatamente os mesmos valores), e a diferença de tempo só pode vir do custo de alocações, desalocações e melhor uso do cache.

Mais especificamente, vamos usar vecSize * numIter = 2 ^ 31 = 2147483648, porque eu tenho 16 GB de RAM e esse número garante que não mais do que 8 GB sejam alocados (sizeof (int) = 4), garantindo que não estou trocando para o disco ( todos os outros programas foram fechados, eu tinha ~ 15 GB disponíveis ao executar o teste).

Aqui está o código:

#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>

class Timer {
    using clock = std::chrono::steady_clock;
    using seconds = std::chrono::duration<double>;
    clock::time_point t_;

public:
    void tic() { t_ = clock::now(); }
    double toc() const { return seconds(clock::now() - t_).count(); }
};

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

int main() {
    Timer t;

    size_t vecSize = size_t(1) << 31;
    size_t numIter = 1;

    std::cout << std::setw(10) << "vecSize" << ", "
              << std::setw(10) << "numIter" << ", "
              << std::setw(10) << "time1" << ", "
              << std::setw(10) << "time2" << ", "
              << std::setw(10) << "sum1" << ", "
              << std::setw(10) << "sum2" << "\n";

    while (vecSize > 0) {

        t.tic();
        size_t sum1 = 0;
        {
            for (int i = 0; i < numIter; ++i) {
                std::vector<int> v = BuildLargeVector1(vecSize);
                sum1 = std::accumulate(v.begin(), v.end(), sum1);
            }
        }
        double time1 = t.toc();

        t.tic();
        size_t sum2 = 0;
        {
            std::vector<int> v;
            for (int i = 0; i < numIter; ++i) {
                BuildLargeVector2(/*out*/ v, vecSize);
                sum2 = std::accumulate(v.begin(), v.end(), sum2);
            }
        } // deallocate v
        double time2 = t.toc();

        std::cout << std::setw(10) << vecSize << ", "
                  << std::setw(10) << numIter << ", "
                  << std::setw(10) << std::fixed << time1 << ", "
                  << std::setw(10) << std::fixed << time2 << ", "
                  << std::setw(10) << sum1 << ", "
                  << std::setw(10) << sum2 << "\n";

        vecSize /= 2;
        numIter *= 2;
    }

    return 0;
}

E aqui está o resultado:

$ g++ -std=c++11 -O3 main.cpp && ./a.out
   vecSize,    numIter,      time1,      time2,       sum1,       sum2
2147483648,          1,   2.360384,   2.356355, 2147483648, 2147483648
1073741824,          2,   2.365807,   1.732609, 2147483648, 2147483648
 536870912,          4,   2.373231,   1.420104, 2147483648, 2147483648
 268435456,          8,   2.383480,   1.261789, 2147483648, 2147483648
 134217728,         16,   2.395904,   1.179340, 2147483648, 2147483648
  67108864,         32,   2.408513,   1.131662, 2147483648, 2147483648
  33554432,         64,   2.416114,   1.097719, 2147483648, 2147483648
  16777216,        128,   2.431061,   1.060238, 2147483648, 2147483648
   8388608,        256,   2.448200,   0.998743, 2147483648, 2147483648
   4194304,        512,   0.884540,   0.875196, 2147483648, 2147483648
   2097152,       1024,   0.712911,   0.716124, 2147483648, 2147483648
   1048576,       2048,   0.552157,   0.603028, 2147483648, 2147483648
    524288,       4096,   0.549749,   0.602881, 2147483648, 2147483648
    262144,       8192,   0.547767,   0.604248, 2147483648, 2147483648
    131072,      16384,   0.537548,   0.603802, 2147483648, 2147483648
     65536,      32768,   0.524037,   0.600768, 2147483648, 2147483648
     32768,      65536,   0.526727,   0.598521, 2147483648, 2147483648
     16384,     131072,   0.515227,   0.599254, 2147483648, 2147483648
      8192,     262144,   0.540541,   0.600642, 2147483648, 2147483648
      4096,     524288,   0.495638,   0.603396, 2147483648, 2147483648
      2048,    1048576,   0.512905,   0.609594, 2147483648, 2147483648
      1024,    2097152,   0.548257,   0.622393, 2147483648, 2147483648
       512,    4194304,   0.616906,   0.647442, 2147483648, 2147483648
       256,    8388608,   0.571628,   0.629563, 2147483648, 2147483648
       128,   16777216,   0.846666,   0.657051, 2147483648, 2147483648
        64,   33554432,   0.853286,   0.724897, 2147483648, 2147483648
        32,   67108864,   1.232520,   0.851337, 2147483648, 2147483648
        16,  134217728,   1.982755,   1.079628, 2147483648, 2147483648
         8,  268435456,   3.483588,   1.673199, 2147483648, 2147483648
         4,  536870912,   5.724022,   2.150334, 2147483648, 2147483648
         2, 1073741824,  10.285453,   3.583777, 2147483648, 2147483648
         1, 2147483648,  20.552860,   6.214054, 2147483648, 2147483648

Resultados de referência

(Intel i7-7700K @ 4,20 GHz; 16 GB DDR4 2400 MHz; Kubuntu 18,04)

Notação: mem (v) = v.size () * sizeof (int) = v.size () * 4 na minha plataforma.

Não surpreendentemente, quando numIter = 1(isto é, mem (v) = 8GB), os tempos são perfeitamente idênticos. De fato, em ambos os casos, estamos alocando apenas uma vez um grande vetor de 8 GB na memória. Isso também prova que nenhuma cópia aconteceu ao usar BuildLargeVector1 (): Eu não teria RAM suficiente para fazer a cópia!

Quando numIter = 2, reutilizar a capacidade do vetor em vez de realocar um segundo vetor é 1,37x mais rápido.

Quando numIter = 256, reutilizar a capacidade do vetor (em vez de alocar / desalocar um vetor repetidamente 256 vezes ...) é 2,45x mais rápido :)

Podemos notar que time1 é praticamente constante de numIter = 1a numIter = 256, o que significa que alocar um grande vetor de 8 GB é quase tão caro quanto alocar 256 vetores de 32 MB. No entanto, alocar um vetor enorme de 8 GB é definitivamente mais caro do que alocar um vetor de 32 MB, portanto, reutilizar a capacidade do vetor fornece ganhos de desempenho.

De numIter = 512(mem (v) = 16 MB) a numIter = 8M(mem (v) = 1kB) é o ponto ideal: ambos os métodos são exatamente tão rápidos e mais rápidos do que todas as outras combinações de numIter e vecSize. Isso provavelmente tem a ver com o fato de que o tamanho do cache L3 do meu processador é de 8 MB, de modo que o vetor cabe quase completamente no cache. Eu realmente não explico por que o salto repentino de time1é para mem (v) = 16 MB, pareceria mais lógico acontecer logo depois, quando mem (v) = 8 MB. Observe que, surpreendentemente, neste ponto ideal, não reutilizar a capacidade é, na verdade, um pouco mais rápido! Eu realmente não explico isso.

Quando as numIter > 8Mcoisas começam a ficar feias. Ambos os métodos ficam mais lentos, mas o retorno do vetor por valor fica ainda mais lento. No pior caso, com um vetor contendo apenas um int, reutilizar a capacidade em vez de retornar por valor é 3,3x mais rápido. Presumivelmente, isso se deve aos custos fixos de malloc () que começam a dominar.

Observe como a curva para o tempo2 é mais suave do que a curva para o tempo1: não apenas reutilizar a capacidade do vetor é geralmente mais rápido, mas talvez o mais importante, é mais previsível .

Observe também que, no ponto ideal, fomos capazes de realizar 2 bilhões de adições de inteiros de 64 bits em ~ 0,5s, o que é bastante ideal em um processador de 64 bits de 4,2 GHz. Poderíamos fazer melhor paralelizando a computação para usar todos os 8 núcleos (o teste acima usa apenas um núcleo por vez, o que eu verifiquei reexecutando o teste enquanto monitora o uso da CPU). O melhor desempenho é obtido quando mem (v) = 16kB, que é a ordem de magnitude do cache L1 (o cache de dados L1 para o i7-7700K é 4x32kB).

É claro que as diferenças se tornam cada vez menos relevantes quanto mais cálculos você realmente precisa fazer nos dados. Abaixo estão os resultados se substituirmos sum = std::accumulate(v.begin(), v.end(), sum);por for (int k : v) sum += std::sqrt(2.0*k);:

Referência 2

Conclusões

  1. Usar parâmetros de saída em vez de retornar por valor pode fornecer ganhos de desempenho ao reutilizar a capacidade.
  2. Em um computador desktop moderno, isso parece aplicável apenas a vetores grandes (> 16 MB) e vetores pequenos (<1 kB).
  3. Evite alocar milhões / bilhões de vetores pequenos (<1kB). Se possível, reutilize a capacidade ou, melhor ainda, projete sua arquitetura de maneira diferente.

Os resultados podem ser diferentes em outras plataformas. Como de costume, se o desempenho for importante, escreva benchmarks para seu caso de uso específico.

Boris Dalstein
fonte
6

Ainda acho que é uma prática ruim, mas é importante notar que minha equipe usa MSVC 2008 e GCC 4.1, portanto, não estamos usando os compiladores mais recentes.

Anteriormente, muitos dos pontos de acesso mostrados no vtune com MSVC 2008 se resumiam à cópia de strings. Tínhamos um código como este:

String Something::id() const
{
    return valid() ? m_id: "";
}

... observe que usamos nosso próprio tipo String (isso foi necessário porque estamos fornecendo um kit de desenvolvimento de software em que os criadores de plug-ins podem estar usando compiladores diferentes e, portanto, implementações diferentes e incompatíveis de std :: string / std :: wstring).

Fiz uma mudança simples em resposta à sessão de criação de perfil de amostragem de gráfico de chamada mostrando que String :: String (const String &) estava ocupando uma quantidade significativa de tempo. Métodos como no exemplo acima foram os maiores contribuidores (na verdade, a sessão de criação de perfil mostrou a alocação e desalocação de memória como um dos maiores pontos de acesso, com o construtor de cópia String sendo o principal contribuidor para as alocações).

A mudança que fiz foi simples:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

No entanto, isso fez uma grande diferença! O ponto de acesso foi embora nas sessões subsequentes do criador de perfil e, além disso, fazemos muitos testes de unidade completos para acompanhar o desempenho do nosso aplicativo. Todos os tipos de tempos de teste de desempenho caíram significativamente após essas mudanças simples.

Conclusão: não estamos usando os compiladores mais recentes absolutos, mas ainda não podemos depender do compilador otimizando a cópia para retornar por valor de forma confiável (pelo menos não em todos os casos). Esse pode não ser o caso para aqueles que usam compiladores mais novos como o MSVC 2010. Estou ansioso para ver quando poderemos usar C ++ 0x e simplesmente usar referências rvalue e nunca teremos que nos preocupar se estamos pessimizando nosso código retornando complex classes por valor.

[Editar] Como Nate apontou, RVO se aplica ao retorno de temporários criados dentro de uma função. No meu caso, não havia tais temporários (exceto para o ramo inválido onde construímos uma string vazia) e, portanto, RVO não teria sido aplicável.

fedido 472
fonte
3
É isso: RVO é dependente do compilador, mas um compilador C ++ 0x deve usar a semântica de movimento se decidir não usar RVO (assumindo que haja um construtor de movimento). Usar o operador trigraph anula o RVO. Consulte cpp-next.com/archive/2009/09/move-it-with-rvalue-references a que Peter se referiu. Mas seu exemplo não é elegível para semântica de movimento de qualquer maneira porque você não está retornando um temporário.
Nate
@ Stinky472: Retornar um membro por valor sempre será mais lento do que a referência. As referências de Rvalue ainda seriam mais lentas do que retornar uma referência ao membro original (se o chamador puder obter uma referência em vez de precisar de uma cópia). Além disso, ainda existem muitas vezes que você pode salvar, sobre referências de rvalue, porque você tem contexto. Por exemplo, você pode fazer String newstring; novastring.resize (string1.size () + string2.size () + ...); newstring + = string1; newstring + = string2; etc. Esta ainda é uma economia substancial em relação aos valores.
Filhote de cachorro de
@DeadMG uma economia substancial em relação ao operador binário + mesmo com compiladores C ++ 0x implementando RVO? Se for assim, é uma pena. Então, novamente aquele faz sentido, já que ainda acabamos tendo que criar um temporário para calcular a string concatenada, enquanto + = pode concatenar diretamente para newstring.
fedido472
Que tal um caso como: string newstr = str1 + str2; Em um compilador que implementa a semântica de movimento, parece que deve ser tão ou até mais rápido do que: string newstr; newstr + = str1; newstr + = str2; Sem reserva, por assim dizer (presumo que você quis dizer reservar em vez de redimensionar).
fedido472
5
@Nate: Acho que você está confundindo trígrafos como <::ou ??!com o operador condicional ?: (às vezes chamado de operador ternário ).
fredoverflow
3

Apenas para criticar um pouco: não é comum em muitas linguagens de programação retornar arrays de funções. Na maioria deles, uma referência a array é retornada. Em C ++, a analogia mais próxima seria retornarboost::shared_array

Nemanja Trifunovic
fonte
4
@Billy: std :: vector é um tipo de valor com semântica de cópia. O padrão C ++ atual não oferece garantias de que (N) RVO algum dia seja aplicado e, na prática, há muitos cenários da vida real quando não é.
Nemanja Trifunovic
3
@Billy: Novamente, existem alguns cenários muito reais onde mesmo os compiladores mais recentes não aplicam NRVO: efnetcpp.org/wiki/Return_value_optimization#Named_RVO
Nemanja Trifunovic
3
@Billy ONeal: 99% não é suficiente, você precisa de 100%. Lei de Murphy - “se algo pode dar errado, vai dar”. A incerteza é boa se você estiver lidando com algum tipo de lógica difusa, mas não é uma boa ideia para escrever software tradicional. Se houver pelo menos 1% de possibilidade de que o código não funcione da maneira que você pensa, então você deve esperar que este código introduza um bug crítico que o levará à demissão. Além disso, não é um recurso padrão. Usar recursos não documentados é uma má ideia - se em um ano o compilador descartar o recurso (não é exigido pelo padrão, certo?), Você será o único em apuros.
SigTerm
4
@SigTerm: Se estivéssemos falando sobre correção de comportamento, eu concordaria com você. No entanto, estamos falando sobre uma otimização de desempenho. Essas coisas estão bem com menos de 100% de certeza.
Billy ONeal
2
@Nemanja: Eu não vejo o que está sendo "confiável" aqui. Seu aplicativo é executado da mesma forma, não importa se RVO ou NRVO é usado. Se eles forem usados, porém, será executado mais rápido. Se seu aplicativo é muito lento em uma plataforma específica e você o rastreou para retornar a cópia do valor, então mude-o, mas isso não muda o fato de que a prática recomendada ainda é usar o valor de retorno. Se você absolutamente precisa garantir que nenhuma cópia ocorra, envolva o vetor em um shared_ptre dê um basta no dia.
Billy ONeal
2

Se o desempenho é um problema real, você deve perceber que a semântica de movimentação nem sempre é mais rápida do que copiar. Por exemplo, se você tem uma string que usa a otimização de string pequena , para strings pequenas, um construtor de movimento deve fazer exatamente a mesma quantidade de trabalho que um construtor de cópia regular.

Motti
fonte
1
NRVO não desaparece apenas porque os construtores de movimento foram adicionados.
Billy ONeal
1
@Billy, verdade, mas irrelevante, a questão era se C ++ 0x mudou as práticas recomendadas e NRVO não mudou devido a C ++ 0x
Motti