Ter pelo menos um método virtual em uma classe C ++ (ou qualquer uma de suas classes pai) significa que a classe terá uma tabela virtual e cada instância terá um ponteiro virtual.
Portanto, o custo da memória é bastante claro. O mais importante é o custo de memória nas instâncias (especialmente se as instâncias são pequenas, por exemplo se elas foram destinadas apenas a conter um inteiro: neste caso, ter um ponteiro virtual em cada instância pode dobrar o tamanho das instâncias. o espaço de memória usado pelas tabelas virtuais, acho que geralmente é insignificante em comparação com o espaço usado pelo código do método real.
Isso me leva à minha pergunta: há um custo de desempenho mensurável (ou seja, impacto na velocidade) para tornar um método virtual? Haverá uma consulta na tabela virtual em tempo de execução, a cada chamada de método, portanto, se houver chamadas muito frequentes para esse método e se este método for muito curto, pode haver um impacto mensurável no desempenho? Acho que depende da plataforma, mas alguém já executou alguns benchmarks?
A razão pela qual estou perguntando é que me deparei com um bug que aconteceu devido ao esquecimento de um programador de definir um método virtual. Esta não é a primeira vez que vejo esse tipo de erro. E eu pensei: por que adicionar a palavra-chave virtual quando necessário, em vez de remover a palavra-chave virtual quando estamos absolutamente certo de que ele é não é necessário? Se o custo de desempenho for baixo, acho que vou simplesmente recomendar o seguinte para minha equipe: simplesmente tornar todos os métodos virtuais por padrão, incluindo o destruidor, em todas as classes e removê-lo apenas quando for necessário. Isso parece loucura para você?
fonte
Respostas:
Eu executei alguns timings em um processador PowerPC de 3 GHz em ordem. Nessa arquitetura, uma chamada de função virtual custa 7 nanossegundos a mais do que uma chamada de função direta (não virtual).
Portanto, não vale a pena se preocupar com o custo, a menos que a função seja algo como um acessador Get () / Set () trivial, no qual qualquer coisa que não seja inline é um desperdício. Uma sobrecarga de 7ns em uma função que alinha a 0,5ns é grave; uma sobrecarga de 7ns em uma função que leva 500 ms para ser executada não faz sentido.
O grande custo das funções virtuais não é realmente a pesquisa de um ponteiro de função na vtable (que geralmente é apenas um único ciclo), mas que o salto indireto geralmente não pode ser previsto por ramo. Isso pode causar uma grande bolha no pipeline, pois o processador não pode buscar nenhuma instrução até que o salto indireto (a chamada por meio do ponteiro de função) seja retirado e um novo ponteiro de instrução seja calculado. Portanto, o custo de uma chamada de função virtual é muito maior do que pode parecer olhando para o assembly ... mas ainda apenas 7 nanossegundos.
Edit: Andrew, Not Sure e outros também levantam o ponto muito bom de que uma chamada de função virtual pode causar uma falha no cache de instrução: se você pular para um endereço de código que não está no cache, todo o programa é paralisado enquanto o as instruções são obtidas na memória principal. Isso é sempre uma parada significativa: no Xenon, cerca de 650 ciclos (pelos meus testes).
No entanto, este não é um problema específico para funções virtuais porque até mesmo uma chamada direta de função causará um erro se você pular para instruções que não estão no cache. O que importa é se a função foi executada recentemente (tornando mais provável que ela esteja no cache) e se sua arquitetura pode prever ramificações estáticas (não virtuais) e buscar essas instruções no cache com antecedência. Meu PPC não, mas talvez o hardware mais recente da Intel sim.
Meus timings controlam a influência das falhas do icache na execução (deliberadamente, já que eu estava tentando examinar o pipeline da CPU isoladamente), então eles descontam esse custo.
fonte
Definitivamente, há sobrecarga mensurável ao chamar uma função virtual - a chamada deve usar o vtable para resolver o endereço da função para esse tipo de objeto. As instruções extras são a menor das suas preocupações. Os vtables não apenas impedem muitas otimizações potenciais do compilador (uma vez que o tipo é polimórfico do compilador), eles também podem destruir seu I-Cache.
Obviamente, se essas penalidades são significativas ou não, depende de seu aplicativo, da frequência com que esses caminhos de código são executados e de seus padrões de herança.
Na minha opinião, porém, ter tudo como virtual por padrão é uma solução geral para um problema que você poderia resolver de outras maneiras.
Talvez você possa ver como as classes são projetadas / documentadas / escritas. Geralmente, o cabeçalho de uma classe deve deixar bem claro quais funções podem ser substituídas por classes derivadas e como elas são chamadas. Fazer com que os programadores escrevam esta documentação é útil para garantir que eles sejam marcados corretamente como virtuais.
Eu também diria que declarar cada função como virtual pode levar a mais bugs do que apenas esquecer de marcar algo como virtual. Se todas as funções forem virtuais, tudo pode ser substituído por classes base - pública, protegida, privada - tudo se torna um jogo justo. Por acidente ou intenção, as subclasses podem então alterar o comportamento das funções que causam problemas quando usadas na implementação básica.
fonte
save
que depende de uma implementação específica de uma funçãowrite
na classe base, então me parece que ousave
está mal codificada ouwrite
deveria ser privada.Depende. :) (Você esperava mais alguma coisa?)
Uma vez que uma classe obtém uma função virtual, ela não pode mais ser um tipo de dados POD, (pode não ter sido antes também, caso em que isso não fará diferença) e isso torna impossível toda uma série de otimizações.
std :: copy () em tipos POD simples podem recorrer a uma rotina memcpy simples, mas tipos não-POD devem ser tratados com mais cuidado.
A construção se torna muito mais lenta porque a vtable deve ser inicializada. No pior caso, a diferença de desempenho entre os tipos de dados POD e não POD pode ser significativa.
No pior caso, você pode ver uma execução 5x mais lenta (esse número é retirado de um projeto universitário que fiz recentemente para reimplementar algumas classes de biblioteca padrão. Nosso contêiner demorou cerca de 5x mais tempo para ser construído assim que o tipo de dados armazenado recebeu um vtable)
Claro, na maioria dos casos, é improvável que você veja qualquer diferença mensurável de desempenho; isso é simplesmente para apontar que, em alguns casos de fronteira, pode ser caro.
No entanto, o desempenho não deve ser sua principal consideração aqui. Tornar tudo virtual não é uma solução perfeita por outros motivos.
Permitir que tudo seja sobrescrito em classes derivadas torna muito mais difícil manter invariantes de classe. Como uma classe garante que permanecerá em um estado consistente quando qualquer um de seus métodos pode ser redefinido a qualquer momento?
Tornar tudo virtual pode eliminar alguns possíveis bugs, mas também introduz novos.
fonte
Se você precisa da funcionalidade de envio virtual, tem que pagar o preço. A vantagem do C ++ é que você pode usar uma implementação muito eficiente de despacho virtual fornecido pelo compilador, em vez de uma versão possivelmente ineficiente que você mesmo implemente.
No entanto, sobrecarregar-se com a sobrecarga, se você não precisar, provavelmente está indo longe demais. E a maioria das classes não foi projetada para ser herdada - criar uma boa classe base requer mais do que tornar suas funções virtuais.
fonte
O despacho virtual é uma ordem de magnitude mais lento do que algumas alternativas - não devido à indireção, mas à prevenção de inlining. Abaixo, eu ilustro isso contrastando o envio virtual com uma implementação que incorpora um "número de tipo (-identificador)" nos objetos e usando uma instrução switch para selecionar o código específico do tipo. Isso evita completamente a sobrecarga da chamada de função - apenas fazendo um salto local. Há um custo potencial de manutenção, dependências de recompilação, etc. por meio da localização forçada (no switch) da funcionalidade específica do tipo.
IMPLEMENTAÇÃO
RESULTADOS DE DESEMPENHO
No meu sistema Linux:
Isso sugere que uma abordagem comutada por número de tipo em linha é de cerca de (1,28 - 0,23) / (0,344 - 0,23) = 9,2 vezes mais rápida. Claro, isso é específico para o sistema exato testado / sinalizadores do compilador e versão etc., mas geralmente indicativo.
COMENTÁRIOS SOBRE ENVIO VIRTUAL
Deve ser dito, entretanto, que sobrecargas de chamada de função virtual são algo que raramente é significativo, e apenas para funções freqüentemente chamadas de triviais (como getters e setters). Mesmo assim, você pode ser capaz de fornecer uma única função para obter e definir várias coisas de uma vez, minimizando o custo. As pessoas se preocupam demais com o envio virtual - então faça o perfil antes de encontrar alternativas estranhas. O principal problema com eles é que executam uma chamada de função fora da linha, embora também desloquem o código executado, o que altera os padrões de utilização do cache (para melhor ou (mais frequentemente) para pior).
fonte
g++
/clang
e-lrt
. Achei que valeria a pena mencionar aqui para futuros leitores.O custo extra é virtualmente nada na maioria dos cenários. (perdoe o torcadilho). ejac já publicou medidas relativas sensatas.
A maior coisa de que você desiste são as possíveis otimizações devido ao inlining. Eles podem ser especialmente bons se a função for chamada com parâmetros constantes. Isso raramente faz uma diferença real, mas em alguns casos, pode ser enorme.
Com relação às otimizações:
É importante conhecer e considerar o custo relativo das construções de sua linguagem. A notação Big O é apenas metade da história - como seu aplicativo é dimensionado . A outra metade é o fator constante à sua frente.
Via de regra, eu não sairia do meu caminho para evitar funções virtuais, a menos que haja indicações claras e específicas de que é um gargalo. Um design limpo sempre vem em primeiro lugar - mas é apenas uma parte interessada que não deve prejudicar outros indevidamente .
Exemplo artificial: um destruidor virtual vazio em uma matriz de um milhão de pequenos elementos pode destruir pelo menos 4 MB de dados, destruindo seu cache. Se esse destruidor puder ser sequenciado, os dados não serão alterados.
Ao escrever o código da biblioteca, tais considerações estão longe de ser prematuras. Você nunca sabe quantos loops serão colocados em torno de sua função.
fonte
Embora todos estejam corretos sobre o desempenho de métodos virtuais e coisas assim, acho que o verdadeiro problema é se a equipe sabe sobre a definição da palavra-chave virtual em C ++.
Considere este código, qual é a saída?
Nada surpreendente aqui:
Como nada é virtual. Se a palavra-chave virtual for adicionada à frente de Foo nas classes A e B, obtemos isso para a saída:
Praticamente o que todo mundo espera.
Agora, você mencionou que há bugs porque alguém se esqueceu de adicionar uma palavra-chave virtual. Portanto, considere este código (onde a palavra-chave virtual é adicionada à classe A, mas não à classe B). Qual é a saída então?
Resposta: O mesmo que se a palavra-chave virtual fosse adicionada a B? O motivo é que a assinatura de B :: Foo corresponde exatamente a A :: Foo () e, como o Foo de A é virtual, o de B também é.
Agora considere o caso em que o Foo de B é virtual e o de A não é. Qual é a saída então? Neste caso, a saída é
A palavra-chave virtual funciona para baixo na hierarquia, não para cima. Isso nunca torna os métodos da classe base virtuais. A primeira vez que um método virtual é encontrado na hierarquia é quando o polimorfismo começa. Não há como as classes posteriores fazerem com que as classes anteriores tenham métodos virtuais.
Não se esqueça de que os métodos virtuais significam que esta classe está dando às classes futuras a capacidade de substituir / alterar alguns de seus comportamentos.
Portanto, se você tiver uma regra para remover a palavra-chave virtual, ela pode não ter o efeito desejado.
A palavra-chave virtual em C ++ é um conceito poderoso. Você deve certificar-se de que cada membro da equipe realmente conhece esse conceito, para que possa ser usado conforme projetado.
fonte
Dependendo da sua plataforma, a sobrecarga de uma chamada virtual pode ser muito indesejável. Ao declarar todas as funções virtuais, você está essencialmente as chamando por meio de um ponteiro de função. No mínimo, essa é uma desreferência extra, mas em algumas plataformas PPC, ele usará instruções microcodificadas ou lentas para fazer isso.
Eu não recomendo sua sugestão por esse motivo, mas se isso ajudar a prevenir bugs, pode valer a pena negociar. Não posso deixar de pensar que deve haver algum meio-termo que vale a pena encontrar, no entanto.
fonte
Exigirá apenas algumas instruções extras de conjunto para chamar o método virtual.
Mas não acho que você se preocupe com o fato de fun (int a, int b) ter algumas instruções extras de 'push' em comparação com fun (). Portanto, não se preocupe com os virtuais também, até que você esteja em uma situação especial e veja que isso realmente causa problemas.
PS Se você tiver um método virtual, certifique-se de ter um destruidor virtual. Desta forma, você evitará possíveis problemas
Em resposta aos comentários de 'xtofl' e 'Tom'. Fiz pequenos testes com 3 funções:
Meu teste foi uma iteração simples:
E aqui estão os resultados:
Ele foi compilado por VC ++ em modo de depuração. Eu fiz apenas 5 testes por método e calculei o valor médio (então os resultados podem ser bastante imprecisos) ... De qualquer forma, os valores são quase iguais assumindo 100 milhões de chamadas. E o método com 3 push / pop extras era mais lento.
O ponto principal é que se você não gosta da analogia com o push / pop, pense em if / else extras em seu código? Você pensa no pipeline da CPU quando adiciona if / else ;-) Além disso, você nunca sabe em qual CPU o código estará rodando ... O compilador normal pode gerar código mais otimizado para uma CPU e menos otimizado para outra ( Intel Compilador C ++ )
fonte
final
em sua substituição e você tem um ponteiro para o tipo derivado, ao invés do tipo base ) Este teste sempre chamou a mesma função virtual, então previu perfeitamente; nenhum pipeline borbulha, exceto pelocall
rendimento limitado . E aquele indiretocall
pode ser mais alguns uops. A previsão de ramificação funciona bem mesmo para ramificações indiretas, especialmente se elas estiverem sempre no mesmo destino.call
que diretacall
. (E sim, ascall
instruções normais também precisam de previsão. O estágio de busca precisa saber o próximo endereço a buscar antes que este bloco seja decodificado, então ele tem que prever o próximo bloco de busca com base no endereço do bloco atual, ao invés do endereço da instrução. como prever onde neste bloco há uma instrução de ramificação ...)