Eu tenho aprendido um pouco de C ++ e geralmente tenho que retornar objetos grandes de funções criadas dentro da função. Sei que há a passagem por referência, retorno um ponteiro e retorno de soluções de tipo de referência, mas também li que os compiladores C ++ (e o padrão C ++) permitem otimização do valor de retorno, o que evita a cópia desses grandes objetos na memória, assim economizando tempo e memória de tudo isso.
Agora, sinto que a sintaxe é muito mais clara quando o objeto é explicitamente retornado por valor, e o compilador geralmente empregará o RVO e tornará o processo mais eficiente. É uma má prática confiar nessa otimização? Isso torna o código mais claro e legível para o usuário, o que é extremamente importante, mas devo tomar cuidado ao assumir que o compilador capturará a oportunidade do RVO?
Isso é uma micro-otimização ou algo que eu deveria ter em mente ao criar meu código?
fonte
Respostas:
Empregue o princípio de menor espanto .
É você e somente sempre você que usará esse código e tem certeza de que em 3 anos não ficará surpreso com o que faz?
Então vá em frente.
Em todos os outros casos, use o caminho padrão; caso contrário, você e seus colegas terão dificuldades em encontrar bugs.
Por exemplo, meu colega estava reclamando que meu código estava causando erros. Acontece que ele havia desativado a avaliação booleana de curto-circuito nas configurações do compilador. Eu quase dei um tapa nele.
fonte
Para esse caso em particular, definitivamente retorne pelo valor.
RVO e NRVO são otimizações conhecidas e robustas que realmente devem ser feitas por qualquer compilador decente, mesmo no modo C ++ 03.
A semântica de movimentação garante que os objetos sejam removidos das funções se (N) RVO não ocorrer. Isso é útil apenas se o seu objeto usa dados dinâmicos internamente (como
std::vector
faz), mas que deve realmente ser o caso, se é que grande - transbordando a pilha é um risco com grandes objetos automáticas.O C ++ 17 impõe o RVO. Portanto, não se preocupe, ele não desaparecerá e só terminará de se estabelecer completamente quando os compiladores estiverem atualizados.
E, no final, forçar uma alocação dinâmica adicional para retornar um ponteiro ou forçar o tipo de resultado a ser construtível por padrão, apenas para que você possa passá-lo como parâmetro de saída são soluções feias e não-idiomáticas para um problema que você provavelmente nunca ter.
Basta escrever um código que faça sentido e agradecer aos escritores do compilador por otimizar corretamente o código que faz sentido.
fonte
Essa não é uma micro-otimização pouco conhecida, fofa, sobre a qual você lê em algum blog pequeno e pouco trafegado, e então você se sente inteligente e superior em usar.
Após o C ++ 11, o RVO é a maneira padrão de escrever esse código de código. É comum, esperado, ensinado, mencionado em conversas, mencionado em blogs, mencionados no padrão, que será relatado como um bug do compilador, se não implementado. No C ++ 17, a linguagem vai um passo além e exige a eliminação de cópias em determinados cenários.
Você deve confiar absolutamente nessa otimização.
Além disso, o retorno por valor apenas leva a códigos muito mais fáceis de ler e gerenciar do que o código retornado por referência. A semântica de valores é uma coisa poderosa, que por si só pode levar a mais oportunidades de otimização.
fonte
A correção do código que você escreve nunca deve depender de uma otimização. Ele deve gerar o resultado correto quando executado na "máquina virtual" C ++ que eles usam na especificação.
No entanto, o que você fala é mais um tipo de pergunta sobre eficiência. Seu código funciona melhor se otimizado com um compilador de otimização de RVO. Tudo bem, por todos os motivos apontados nas outras respostas.
No entanto, se você precisar dessa otimização (como se o construtor de cópias realmente causasse falha no seu código), agora você está nos caprichos do compilador.
Eu acho que o melhor exemplo disso em minha própria prática é a otimização de chamada de cauda:
É um exemplo bobo, mas mostra uma chamada de cauda, onde uma função é chamada recursivamente no final de uma função. A máquina virtual C ++ mostrará que esse código funciona corretamente, embora eu possa causar um pouco de confusão sobre o motivo de eu ter me preocupado em escrever essa rotina de adição em primeiro lugar. No entanto, nas implementações práticas do C ++, temos uma pilha e esse espaço é limitado. Se executada de forma pedântica, essa função teria que empurrar pelo menos
b + 1
quadros de pilha para a pilha, como faz sua adição. Se eu quiser calcularsillyAdd(5, 7)
, isso não é grande coisa. Se eu quiser calcularsillyAdd(0, 1000000000)
, posso ter um problema real de causar um StackOverflow (e não o bom ).No entanto, podemos ver que, quando alcançamos a última linha de retorno, realmente terminamos com tudo no quadro de pilha atual. Nós realmente não precisamos mantê-lo por perto. A otimização da chamada de cauda permite "reutilizar" o quadro de pilha existente para a próxima função. Dessa forma, precisamos apenas de 1 quadro de pilha, em vez de
b+1
. (Ainda precisamos fazer todas essas adições e subtrações tolas, mas elas não ocupam mais espaço.) Com efeito, a otimização transforma o código em:Em alguns idiomas, a otimização da chamada de cauda é explicitamente exigida pela especificação. C ++ não é um desses. Não posso confiar nos compiladores C ++ para reconhecer esta oportunidade de otimização de chamada final, a menos que eu vá caso a caso. Com a minha versão do Visual Studio, a versão de lançamento faz a otimização de chamada de cauda, mas a versão de depuração não (por design).
Assim, seria ruim para mim depender de poder calcular
sillyAdd(0, 1000000000)
.fonte
#ifdef
blocos e têm uma solução compatível com os padrões disponíveis.b = b + 1
?Na prática, os programas C ++ esperam algumas otimizações do compilador.
Observe os cabeçalhos padrão das implementações de contêineres padrão . Com o GCC , você pode solicitar o formulário pré-processado (
g++ -C -E
) e a representação interna do GIMPLE (g++ -fdump-tree-gimple
ou Gimple SSA com-fdump-tree-ssa
) da maioria dos arquivos de origem (tecnicamente unidades de tradução) usando contêineres. Você ficará surpreso com a quantidade de otimização realizada (comg++ -O2
). Portanto, os implementadores de contêineres confiam nas otimizações (e na maioria das vezes, o implementador de uma biblioteca padrão C ++ sabe qual otimização aconteceria e grava a implementação do contêiner com isso em mente; às vezes ele também escreve a passagem de otimização no compilador para lidar com os recursos exigidos pela biblioteca C ++ padrão).Na prática, são as otimizações do compilador que tornam o C ++ e seus contêineres padrão suficientemente eficientes. Então você pode confiar neles.
E da mesma forma para o caso RVO mencionado em sua pergunta.
O padrão C ++ foi co-projetado (principalmente experimentando otimizações boas o suficiente ao propor novos recursos) para funcionar bem com as possíveis otimizações.
Por exemplo, considere o programa abaixo:
compile com
g++ -O3 -fverbose-asm -S
. Você descobrirá que a função gerada não executa nenhumaCALL
instrução da máquina. Assim, a maioria passos C ++ (construção de um fecho de lambda, a sua aplicação repetida, recebendo obegin
eend
iteradores, etc ...) foram otimizados. O código da máquina contém apenas um loop (que não aparece explicitamente no código-fonte). Sem essas otimizações, o C ++ 11 não será bem-sucedido.adendas
(dezembro adicionado 31 r 2017)
Veja CppCon 2017: Matt Godbolt “O que meu compilador fez por mim ultimamente? Abrir a tampa do compilador ” .
fonte
Sempre que você usa um compilador, o entendimento é que ele produzirá código de máquina ou de byte para você. Ele não garante nada sobre como é esse código gerado, exceto que implementará o código-fonte de acordo com a especificação da linguagem. Observe que essa garantia é a mesma, independentemente do nível de otimização usado e, portanto, em geral, não há razão para considerar uma saída como mais 'correta' que a outra.
Além disso, nesses casos, como o RVO, onde é especificado no idioma, parece inútil evitar o uso, principalmente se isso simplificar o código-fonte.
Muito esforço é feito para fazer os compiladores produzirem resultados eficientes, e claramente a intenção é que esses recursos sejam usados.
Pode haver razões para o uso de código não otimizado (para depuração, por exemplo), mas o caso mencionado nesta pergunta não parece ser um (e se o seu código falhar apenas quando otimizado, isso não é uma consequência de alguma peculiaridade do dispositivo em que está sendo executado, existe um erro em algum lugar e é improvável que esteja no compilador.)
fonte
Acho que outros abordaram bem o ângulo específico sobre C ++ e RVO. Aqui está uma resposta mais geral:
Quando se trata de correção, você não deve confiar nas otimizações do compilador ou no comportamento específico do compilador em geral. Felizmente, você parece não estar fazendo isso.
Quando se trata de desempenho, você precisa confiar no comportamento específico do compilador em geral e nas otimizações do compilador em particular. Um compilador compatível com o padrão é livre para compilar seu código da maneira que desejar, desde que o código compilado se comporte de acordo com a especificação da linguagem. E não conheço nenhuma especificação para uma linguagem convencional que especifique a rapidez com que cada operação deve ser.
fonte
As otimizações do compilador devem afetar apenas o desempenho, não os resultados. Confiar nas otimizações do compilador para atender a requisitos não funcionais não é apenas razoável, é frequentemente a razão pela qual um compilador é escolhido em detrimento de outro.
Os sinalizadores que determinam como determinadas operações são executadas (condições de índice ou estouro, por exemplo), são frequentemente agrupados com otimizações do compilador, mas não devem ser. Eles explicitamente efetuam os resultados dos cálculos.
Se uma otimização do compilador causar resultados diferentes, isso é um erro - um erro no compilador. Confiar em um bug no compilador é, a longo prazo, um erro - o que acontece quando ele é corrigido?
O uso de sinalizadores do compilador que alteram o funcionamento dos cálculos deve ser bem documentado, mas usado conforme necessário.
fonte
x*y>z
arbitrariamente, produzir 0 ou 1 em caso de estouro, desde que não tenha outros efeitos colaterais , exigindo que um programador evite estouros a todo custo ou force o compilador a avaliar a expressão de uma maneira específica. desnecessário prejudicar otimizações vs. dizer que ... #x*y
promove seus operandos para alguns mais tipo arbitrário (permitindo assim formas de elevação e redução da força que iria mudar o comportamento de alguns casos de excesso). Muitos compiladores, no entanto, exigem que os programadores impeçam o estouro a todo custo ou forçam os compiladores a truncar todos os valores intermediários em caso de estouro.Não.
É o que faço o tempo todo. Se eu precisar acessar um bloco arbitrário de 16 bits na memória, faço isso
... e confie no compilador fazendo o possível para otimizar esse pedaço de código. O código funciona em ARM, i386, AMD64 e praticamente em todas as arquiteturas existentes. Em teoria, um compilador não otimizador poderia realmente chamar
memcpy
, resultando em um desempenho totalmente ruim, mas isso não é problema para mim, pois uso otimizações do compilador.Considere a alternativa:
Esse código alternativo falha ao trabalhar em máquinas que exigem alinhamento adequado, se
get_pointer()
retornar um ponteiro não alinhado. Além disso, pode haver problemas de alias na alternativa.A diferença entre -O2 e -O0 ao usar o
memcpy
truque é grande: 3,2 Gbps de desempenho de soma de verificação IP versus 67 Gbps de desempenho de soma de verificação IP. Sobre uma diferença de ordem de magnitude!Às vezes você pode precisar ajudar o compilador. Portanto, por exemplo, em vez de confiar no compilador para desenrolar loops, você pode fazer isso sozinho. Ou implementando o famoso dispositivo de Duff , ou de uma maneira mais limpa.
A desvantagem de confiar nas otimizações do compilador é que, se você executar o gdb para depurar seu código, poderá descobrir que muito foi otimizado. Portanto, pode ser necessário recompilar com -O0, o que significa que o desempenho será totalmente ruim ao depurar. Eu acho que isso é uma desvantagem, considerando os benefícios de otimizar compiladores.
Faça o que fizer, verifique se o seu caminho não é realmente um comportamento indefinido. Certamente, acessar algum bloco aleatório de memória como número inteiro de 16 bits é um comportamento indefinido devido a problemas de alias e alinhamento.
fonte
Todas as tentativas de código eficiente escritas em qualquer coisa, exceto em montagem, dependem muito, muito intensamente, das otimizações do compilador, começando com a alocação de registro mais básica, como eficiente, para evitar derramamentos de pilha supérfluos por todo o lado e pelo menos razoavelmente boa, se não excelente, seleção de instruções. Caso contrário, estaríamos de volta aos anos 80, onde tínhamos que colocar
register
sugestões em todo o lugar e usar o número mínimo de variáveis em uma função para ajudar os compiladores C arcaicos ou ainda mais cedo quandogoto
era uma otimização de ramificação útil.Se não sentíssemos que poderíamos confiar na capacidade do otimizador de otimizar nosso código, todos nós ainda estaríamos codificando caminhos de execução críticos de desempenho na montagem.
É realmente uma questão de quão confiável você sente que a otimização pode ser feita, o que é melhor resolvido através da criação de perfil e olhando para os recursos dos compiladores que você possui e, possivelmente, até desmontando se houver um ponto de acesso que você não consegue descobrir onde o compilador parece. falharam ao fazer uma otimização óbvia.
O RVO é algo que existe há muito tempo e, pelo menos excluindo casos muito complexos, é algo que os compiladores têm se aplicado de maneira confiável há muito tempo. Definitivamente, não vale a pena solucionar um problema que não existe.
Errar do lado de confiar no otimizador, sem medo dele
Pelo contrário, eu diria que o erro é confiar demais nas otimizações do compilador, e essa sugestão vem de um profissional que trabalha em campos muito críticos para o desempenho, nos quais a eficiência, a capacidade de manutenção e a qualidade percebida entre os clientes é muito alta. todo um borrão gigante. Prefiro que você confie com muita confiança no seu otimizador e encontre alguns casos obscuros em que confiava demais do que confiava muito pouco e apenas codificasse medos supersticiosos o tempo todo pelo resto de sua vida. Pelo menos, você terá que procurar um criador de perfil e investigar adequadamente se as coisas não forem executadas tão rapidamente quanto deveriam e obter um conhecimento valioso, não superstições, ao longo do caminho.
Você está se saindo bem com o otimizador. Mantem. Não se torne o cara que começa a solicitar explicitamente todas as funções chamadas em loop antes mesmo de criar um perfil de um medo equivocado das falhas do otimizador.
Criação de perfil
A criação de perfil é realmente a resposta indireta, mas definitiva, à sua pergunta. O problema que os iniciantes que desejam escrever código eficiente frequentemente enfrenta não é o que otimizar, é o que não otimizar, porque eles desenvolvem todo tipo de palpites equivocados sobre ineficiências que, embora humanamente intuitivas, são computacionalmente erradas. O desenvolvimento da experiência com um criador de perfil começará realmente a dar uma apreciação adequada não apenas dos recursos de otimização de seus compiladores, nos quais você pode confiar com confiança, mas também dos recursos (e também das limitações) do seu hardware. É indiscutível ainda mais valor na criação de perfis para aprender o que não valia a pena otimizar do que aprender o que era.
fonte
O software pode ser escrito em C ++ em plataformas muito diferentes e para diversos fins.
Depende completamente da finalidade do software. Deve ser fácil manter, expandir, corrigir, refatorar etc. ou outras coisas mais importantes, como desempenho, custo ou compatibilidade com algum hardware específico ou o tempo necessário para o desenvolvimento.
fonte
Eu acho que a resposta chata para isso é: 'depende'.
É uma prática recomendada escrever código que se baseia em uma otimização de compilador que provavelmente será desativada e onde a vulnerabilidade não está documentada e onde o código em questão não é testado por unidade para que, se ele quebrasse, você o conhecesse ? Provavelmente.
É uma prática ruim escrever código que se baseia em uma otimização de compilador que provavelmente não será desativada , documentada e testada por unidade ? Talvez não.
fonte
A menos que haja mais que você não esteja nos dizendo, isso é uma prática ruim, mas não pelo motivo que você sugere.
Possivelmente ao contrário de outras linguagens que você usou anteriormente, retornar o valor de um objeto em C ++ produz uma cópia do objeto. Se você modificar o objeto, estará modificando um objeto diferente . Ou seja, se eu tenho
Obj a; a.x=1;
eObj b = a;
, então simb.x += 2; b.f();
,a.x
ainda é igual a 1, não a 3.Portanto, não, o uso de um objeto como valor e não como referência ou ponteiro não fornece a mesma funcionalidade e você pode acabar com bugs no seu software.
Talvez você saiba disso e isso não afeta negativamente o seu caso de uso específico. No entanto, com base no texto da sua pergunta, parece que você pode não estar ciente da distinção; palavras como "criar um objeto na função".
"criar um objeto na função" soa como
new Obj;
onde "retornar o objeto por valor" soa comoObj a; return a;
Obj a;
eObj* a = new Obj;
são coisas muito, muito diferentes; o primeiro pode resultar em corrupção de memória se não for usado e entendido adequadamente, e o segundo pode resultar em vazamento de memória se não for usado e entendido corretamente.fonte
return
instrução que é um requisito para o RVO. Além disso, você passa a falar sobre palavrasnew
- chave e ponteiros, o que não é o objetivo do RVO. Eu acredito que você não entende a pergunta, ou RVO, ou possivelmente ambos.Pieter B está absolutamente correto ao recomendar menos espanto.
Para responder sua pergunta específica, o que isso (provavelmente) significa em C ++ é que você deve retornar
std::unique_ptr
a ao objeto construído.A razão é que isso é mais claro para um desenvolvedor de C ++ quanto ao que está acontecendo.
Embora sua abordagem provavelmente funcione, você está sinalizando efetivamente que o objeto é um tipo de valor pequeno quando, na verdade, não é. Além disso, você está descartando qualquer possibilidade de abstração de interface. Isso pode ser bom para seus propósitos atuais, mas geralmente é muito útil ao lidar com matrizes.
Compreendo que, se você veio de outras línguas, todos os sigilos podem ser confusos inicialmente. Mas tome cuidado para não supor que, ao não usá-los, você torna seu código mais claro. Na prática, é provável que o oposto seja verdadeiro.
fonte
std::make_unique
, nãostd::unique_ptr
diretamente. Segundo, o RVO não é uma otimização esotérica específica do fornecedor: ele é incorporado ao padrão. Mesmo quando não era, era um comportamento amplamente suportado e esperado. Não faz sentido retornar astd::unique_ptr
quando um ponteiro não é necessário.