A passagem de argumentos como const faz referência à otimização prematura?

20

"Otimização prematura é a raiz de todo o mal"

Eu acho que todos nós podemos concordar. E eu tento muito evitar evitar isso.

Mas, recentemente, tenho me perguntado sobre a prática de passar parâmetros por const const em vez de por Value . Aprendi / aprendi que argumentos de função não triviais (ou seja, a maioria dos tipos não primitivos) devem ser passados ​​preferencialmente por referência const - muitos livros que li recomendam isso como uma "melhor prática".

Ainda assim, não posso deixar de me perguntar: compiladores modernos e novos recursos de linguagem podem fazer maravilhas; portanto, o conhecimento que aprendi pode muito bem estar desatualizado e nunca me preocupei em criar um perfil se houver alguma diferença de desempenho entre eles.

void fooByValue(SomeDataStruct data);   

e

void fooByReference(const SomeDataStruct& data);

A prática que aprendi - passando referências const (por padrão para tipos não triviais) - é otimização prematura?

CharonX
fonte
1
Consulte também: F.call nas Diretrizes Principais do C ++ para uma discussão sobre várias estratégias de passagem de parâmetros.
amon
1
@DocBrown A resposta aceita para essa pergunta se refere ao princípio de menor espanto , que também pode ser aplicado aqui (por exemplo, o uso de referências const é o padrão da indústria, etc. etc.). Dito isso, discordo que a pergunta seja uma duplicata: a pergunta a que você se refere pergunta se é uma prática recomendada (geralmente) confiar na otimização do compilador. Esta pergunta é inversa: A passagem de referências const é uma otimização (prematura)?
precisa saber é
@CharonX: se alguém pode confiar na otimização do compilador aqui, a resposta para sua pergunta é claramente "sim, a otimização manual não é necessária, é prematura". Se não se pode confiar nele (talvez porque você não saiba de antemão quais compiladores serão usados ​​para o código), a resposta é "para objetos maiores, provavelmente não é prematuro". Portanto, mesmo que essas duas perguntas não sejam literalmente iguais, elas parecem iguais o suficiente para vinculá-las como duplicatas.
Doc Brown
1
@DocBrown: Então, antes que você possa declarar uma bobagem, aponte onde, na pergunta, diz que o compilador terá permissão e poderá "otimizar" isso.
Deduplicator

Respostas:

49

"Otimização prematura" não se trata de usar otimizações antecipadamente . Trata-se de otimizar antes que o problema seja entendido, antes que o tempo de execução seja entendido e, muitas vezes, tornando o código menos legível e menos sustentável para resultados duvidosos.

Usar "const &" em vez de passar um objeto por valor é uma otimização bem compreendida, com efeitos bem entendidos no tempo de execução, praticamente sem esforço e sem efeitos negativos na legibilidade e manutenção. Na verdade, melhora os dois, porque me diz que uma chamada não modifica o objeto transmitido. Portanto, adicionar "const &" logo que você escreve o código NÃO É PREMATURO.

gnasher729
fonte
2
Concordo com a parte "praticamente sem esforço" da sua resposta. Porém, a otimização prematura é antes de mais nada a otimização antes que ela tenha um impacto significativo no desempenho medido. E eu não acho que a maioria dos programadores de C ++ (incluindo eu) faça medições antes de usar const&, então acho que a pergunta é bastante sensata.
Doc Brown
1
Você mede antes de otimizar para saber se vale a pena alguma troca. Com const & o esforço total está digitando sete caracteres, e tem outras vantagens. Quando você não pretende modificar a variável que está sendo transmitida, é vantajoso mesmo que não haja melhora na velocidade.
precisa saber é o seguinte
3
Eu não sou um especialista em C, então uma pergunta :. const& foodiz que a função não modifica foo, portanto o chamador está seguro. Mas um valor copiado diz que nenhum outro thread pode ser alterado, portanto o chamado é seguro. Certo? Portanto, em um aplicativo multithread, a resposta depende da correção, não da otimização.
user949300
1
@DocBrown você pode eventualmente desistir da motivação do desenvolvedor que colocou a const &? Se ele fizesse isso apenas para desempenho sem considerar o restante, isso poderia ser considerado como otimização prematura. Agora, se ele colocar porque sabe que serão parâmetros const, ele estará apenas documentando seu código e dando a oportunidade ao compilador de otimizar, o que é melhor.
Walfrat 06/06/19
1
@ user949300: Poucas funções permitem que seus argumentos sejam modificados simultaneamente ou pelos retornos de chamada usados, e eles dizem isso explicitamente.
Deduplicator
16

TL; DR: Passar pela referência const ainda é uma boa idéia em C ++, considerando todas as coisas. Não é uma otimização prematura.

TL; DR2: A maioria dos provérbios não faz sentido, até que eles façam.


Alvo

Esta resposta apenas tenta estender o item vinculado nas Diretrizes Principais do C ++ (mencionadas pela primeira vez no comentário da amon) um pouco.

Esta resposta não tenta abordar a questão de como pensar e aplicar adequadamente os vários provérbios que foram amplamente divulgados nos círculos dos programadores, especialmente a questão da reconciliação entre conclusões ou evidências conflitantes.


Aplicabilidade

Esta resposta se aplica apenas a chamadas de função (escopos aninhados não destacáveis ​​no mesmo encadeamento).

(Nota lateral.) Quando coisas passáveis ​​podem escapar do escopo (ou seja, ter uma vida útil que potencialmente exceda o escopo externo), torna-se mais importante satisfazer a necessidade do aplicativo de gerenciamento da vida útil do objeto antes de qualquer outra coisa. Geralmente, isso requer o uso de referências que também são capazes de gerenciar a vida útil, como ponteiros inteligentes. Uma alternativa pode estar usando um gerente. Observe que lambda é um tipo de escopo destacável; As capturas lambda se comportam como ter escopo de objeto. Portanto, tenha cuidado com capturas lambda. Também tenha cuidado com o modo como o próprio lambda é passado - por cópia ou por referência.


Quando passar por valor

Para valores escalares (primitivas padrão que se ajustam a um registro de máquina e têm valor semântico) para os quais não há necessidade de comunicação por mutabilidade (referência compartilhada), passe por valor.

Para situações em que o chamado exige uma clonagem de um objeto ou agregado, passe por valor, em que a cópia do chamado atende à necessidade de um objeto clonado.


Quando passar por referência, etc.

para todas as outras situações, passe por ponteiros, referências, ponteiros inteligentes, alças (consulte: idioma do corpo da alça), etc. Sempre que este conselho for seguido, aplique o princípio da correção constante como de costume.

Coisas (agregados, objetos, matrizes, estruturas de dados) que são suficientemente grandes no espaço ocupado pela memória devem sempre ser projetadas para facilitar a passagem por referência, por razões de desempenho. Esse conselho se aplica definitivamente quando tem centenas de bytes ou mais. Este conselho é limítrofe quando tem dezenas de bytes.


Paradigmas incomuns

Existem paradigmas de programação para fins especiais, que são pesados ​​por intenção. Por exemplo, processamento de strings, serialização, comunicação de rede, isolamento, quebra de bibliotecas de terceiros, comunicação entre processos de memória compartilhada, etc. Nessas áreas de aplicação ou paradigmas de programação, os dados são copiados de estruturas em estruturas ou, às vezes, reembalados matrizes de bytes.


Como a especificação do idioma afeta essa resposta antes que a otimização seja considerada.

Sub-TL; DR A propagação de uma referência não deve invocar nenhum código; passar por const-reference satisfaz este critério. No entanto, todos os outros idiomas atendem a esse critério sem esforço.

(Programadores iniciantes em C ++ são aconselhados a ignorar esta seção completamente.)

(O início desta seção é parcialmente inspirado na resposta de gnasher729. No entanto, uma conclusão diferente é alcançada.)

O C ++ permite construtores de cópia definidos pelo usuário e operadores de atribuição.

(Esta é (foi) uma escolha ousada que é (foi) incrível e lamentável. É definitivamente uma divergência da norma aceitável de hoje em design de linguagem.)

Mesmo que o programador C ++ não defina um, o compilador C ++ deve gerar esses métodos com base nos princípios da linguagem e determinar se outro código precisa ser executado além de memcpy. Por exemplo, um class/ structque contém um std::vectormembro precisa ter um construtor de cópias e um operador de atribuição que não sejam triviais.

Em outros idiomas, os construtores de cópias e a clonagem de objetos são desencorajados (exceto quando absolutamente necessário e / ou significativo para a semântica do aplicativo), porque os objetos têm semântica de referência, por design da linguagem. Esses idiomas geralmente têm um mecanismo de coleta de lixo baseado na acessibilidade, em vez de propriedade baseada no escopo ou contagem de referência.

Quando uma referência ou ponteiro (incluindo referência const) é passada em C ++ (ou C), o programador tem a garantia de que nenhum código especial (funções definidas pelo usuário ou funções geradas pelo compilador) será executado, além da propagação do valor do endereço (referência ou ponteiro). Essa é uma clareza de comportamento com a qual os programadores de C ++ acham confortável.

No entanto, o pano de fundo é que a linguagem C ++ é desnecessariamente complicada, de modo que essa clareza de comportamento é como um oásis (um habitat sobrevivível) em algum lugar em torno de uma zona de precipitação nuclear.

Para adicionar mais bênçãos (ou insulto), o C ++ introduz referências universais (valores-r) para facilitar os operadores de movimentação definidos pelo usuário (construtores de movimentação e operadores de atribuição de movimentação) com bom desempenho. Isso beneficia um caso de uso altamente relevante (a movimentação (transferência) de objetos de uma instância para outra), por meio da redução da necessidade de cópia e clonagem profunda. No entanto, em outras línguas, é ilógico falar dessa movimentação de objetos.


(Seção fora do tópico) Uma seção dedicada a um artigo, "Quer velocidade? Passe por valor!" escrito por volta de 2009.

Esse artigo foi escrito em 2009 e explica a justificativa de design para o valor r em C ++. Esse artigo apresenta um contra-argumento válido para minha conclusão na seção anterior. No entanto, o exemplo de código do artigo e a declaração de desempenho foram refutados há muito tempo.

Sub-TL; DR O design da semântica de valor-r em C ++ permite uma semântica surpreendentemente elegante do lado do usuário em uma Sortfunção, por exemplo. Este elegante é impossível de modelar (imitar) em outras línguas.

Uma função de classificação é aplicada a toda uma estrutura de dados. Como mencionado acima, seria lento se houver muitas cópias envolvidas. Como uma otimização de desempenho (que é praticamente relevante), uma função de classificação é projetada para ser destrutiva em várias linguagens além do C ++. Destrutivo significa que a estrutura de dados de destino é modificada para atingir a meta de classificação.

No C ++, o usuário pode optar por chamar uma das duas implementações: uma destrutiva com melhor desempenho ou uma normal que não modifica a entrada. (O modelo é omitido por questões de brevidade.)

/*caller specifically passes in input argument destructively*/
std::vector<T> my_sort(std::vector<T>&& input)
{
    std::vector<T> result(std::move(input)); /* destructive move */
    std::sort(result.begin(), result.end()); /* in-place sorting */
    return result; /* return-value optimization (RVO) */
}

/*caller specifically passes in read-only argument*/ 
std::vector<T> my_sort(const std::vector<T>& input)
{
    /* reuse destructive implementation by letting it work on a clone. */
    /* Several things involved; e.g. expiring temporaries as r-value */
    /* return-value optimization, etc. */
    return my_sort(std::vector<T>(input));  
}

/*caller can select which to call, by selecting r-value*/
std::vector<T> v1 = {...};
std::vector<T> v2 = my_sort(v1); /*non-destructive*/
std::vector<T> v3 = my_sort(std::move(v1)); /*v1 is gutted*/    

Além da classificação, essa elegância também é útil na implementação do algoritmo destrutivo de mediana de localização em uma matriz (inicialmente não classificada), por particionamento recursivo.

No entanto, observe que a maioria dos idiomas aplicaria uma abordagem de árvore de pesquisa binária equilibrada à classificação, em vez de aplicar um algoritmo destrutivo de classificação às matrizes. Portanto, a relevância prática dessa técnica não é tão alta quanto parece.


Como a otimização do compilador afeta esta resposta

Quando o inlining (e também a otimização de todo o programa / otimização do tempo do link) é aplicado em vários níveis de chamadas de função, o compilador pode ver (às vezes exaustivamente) o fluxo de dados. Quando isso acontece, o compilador pode aplicar muitas otimizações, algumas das quais podem eliminar a criação de objetos inteiros na memória. Normalmente, quando essa situação se aplica, não importa se os parâmetros são passados ​​por valor ou por referência constante, porque o compilador pode analisar exaustivamente.

No entanto, se a função de nível inferior chamar algo que está além da análise (por exemplo, algo em uma biblioteca diferente fora da compilação ou um gráfico de chamada que seja simplesmente muito complicado), o compilador deverá otimizar defensivamente.

Objetos maiores que um valor de registro de máquina podem ser copiados por instruções explícitas de carregamento / armazenamento de memória ou por uma chamada para a memcpyfunção venerável . Em algumas plataformas, o compilador gera instruções SIMD para mover-se entre dois locais de memória, cada instrução movendo dezenas de bytes (16 ou 32).


Discussão sobre a questão da verbosidade ou desordem visual

Os programadores de C ++ estão acostumados a isso, ou seja, desde que um programador não odeie C ++, a sobrecarga de escrever ou ler referências constantes no código fonte não é horrível.

As análises de custo-benefício podem ter sido feitas muitas vezes antes. Não sei se há algum científico que deva ser citado. Eu acho que a maioria das análises seria não científica ou não reproduzível.

Aqui está o que eu imagino (sem provas ou referências credíveis) ...

  • Sim, isso afeta o desempenho do software escrito neste idioma.
  • Se os compiladores puderem entender o objetivo do código, ele poderá ser inteligente o suficiente para automatizar esse código.
  • Infelizmente, em linguagens que favorecem a mutabilidade (em oposição à pureza funcional), o compilador classificaria a maioria das coisas como sendo mutadas; portanto, a dedução automática da constância rejeitaria a maioria das coisas como não-const
  • A sobrecarga mental depende das pessoas; as pessoas que consideram isso uma sobrecarga mental alta teriam rejeitado o C ++ como uma linguagem de programação viável.
rwong
fonte
Esta é uma das situações em que eu gostaria de poder aceitar duas respostas em vez de ter de escolher apenas um ... suspiro
CharonX
8

No artigo de DonaldKnuth "StructuredProgrammingWithGoToStatements", ele escreveu: "Os programadores perdem muito tempo pensando ou se preocupando com a velocidade das partes não críticas de seus programas, e essas tentativas de eficiência realmente têm um forte impacto negativo quando a depuração e a manutenção são consideradas. Devemos esquecer pequenas eficiências, digamos, 97% das vezes: a otimização prematura é a raiz de todo mal. No entanto, não devemos desperdiçar nossas oportunidades nesses 3% críticos. " - Otimização prematura

Isso não está aconselhando os programadores a usar as técnicas mais lentas disponíveis. Trata-se de focar na clareza ao escrever programas. Freqüentemente, clareza e eficiência são uma troca: se você deve escolher apenas um, escolha clareza. Mas se você pode conseguir os dois com facilidade, não há necessidade de prejudicar a clareza (como sinalizar que algo é uma constante) apenas para evitar a eficiência.

Lawrence
fonte
3
"se você deve escolher apenas um, escolha clareza." O segundo deve ser preferível , pois você pode ser forçado a escolher o outro.
Deduplicator
@Duplicator Obrigado. No contexto do OP, porém, o programador tem a liberdade de escolher.
Lawrence
Sua resposta lê um pouco mais geral do que embora ...
Deduplicator
@ Reduplicador Ah, mas o contexto da minha resposta é (também) que o programador escolhe. Se a escolha foi forçada ao programador, não seria "você" quem escolheria :). Eu considerei a alteração que você sugeriu e não me opôs a editar minha resposta de acordo, mas prefiro a redação existente por sua clareza.
Lawrence
7

Passar por ([const] [rvalue] reference) | (value) deve ser sobre a intenção e as promessas feitas pela interface. Não tem nada a ver com desempenho.

A regra de ouro de Richy:

void foo(X x);          // I intend to own the x you gave me, whether by copy, move or direct initialisation on the call stack.     

void foo(X&& x);        // I intend to steal x from you. Do not use it other than to re-assign to it after calling me.

void foo(X const& x);   // I guarantee not to change your x

void foo(X& x);         // I may modify your x and I will leave it in a defined state
Richard Hodges
fonte
3

Teoricamente, a resposta deve ser sim. E, de fato, é sim algumas vezes - na verdade, passar por referência const em vez de apenas passar um valor pode ser uma pessimização, mesmo nos casos em que o valor passado é muito grande para caber em um único registrar (ou a maioria das outras pessoas que a heurística tenta usar para determinar quando passar por valor ou não). Anos atrás, David Abrahams escreveu um artigo chamado "Quer velocidade? Passe por valor!" cobrindo alguns desses casos. Não é mais fácil encontrar, mas se você puder desenterrar uma cópia, vale a pena ler (IMO).

No caso específico de passar por referência const, no entanto, eu diria que o idioma está tão bem estabelecido que a situação é mais ou menos invertida: a menos que você saiba que o tipo será char/ short/ int/ long, as pessoas esperam vê-lo passar por const referência por padrão, então provavelmente é melhor concordar com isso, a menos que você tenha um motivo bastante específico para fazer o contrário.

Jerry Coffin
fonte