Funções virtuais, C/C++ e outras linguagens

Aproveitando o artigo do MaRZ, abaixo, quero dizer que existe um “problema” (que é uma “feature”, na realidade!), em C++, com relação à herança e polimorfismo. Se você fizer isso:

#include <iostream>
class X
{
  public:
    void f(void) { std::cout << "X::f()" << std::endl; }
};

class Y : public X
{
  public:
    void f(void) { std::cout << "Y::f()" << std::endl; }
};

int main(void)
{
  X *p = new Y;
  p->f();
  delete p;
  return 0;
}

Você poderia esperar que a string “Y::f()” fosse impressa já que, por polimorfismo, um objeto da classe Y está sendo apontado pelo ponteiro do tipo da classe X, mas verá que aparecerá “X::f()” na tela. Isso acontece justamente porque o tipo do ponteiro p é a classe X, não a classe Y. Para resolver essa situação existe a palavra-chave virtual.

Colocando virtual antes da declaração da função f(), na classe X, dizemos ao compilador que esta e todas as funções de classes derivadas que tenham a mesma “assinatura”, serão virtuais. Isso significa que associada a toda classe que possui funções virtuais existe uma tabela que contém ponteiros para as funções declaradas como virtuais. O ponteiro para essa tabela faz parte do objeto instanciado. Assim, qualquer chamada a uma função declarada como virtual é feita de forma indireta. Se a função f() da classe X (e, por consequência a da classe Y também) for virtual, então o código que o compilador gerará para a função main(), no código acima, será mais ou menos assim:

_main:
  mov eax,sizeof Y
  call _new          /* Aloca espaço para o objeto */
  mov [p],eax

  call [eax+Y::ctor] /* Chama o construtor de Y */

  mov eax,[p]
  mov ebx,[eax]	    /* Pega o ponteiro da tabela VTBL */
  mov edx,[ebx]     /* Pega o ponteiro para a função f(), da VTBL */
  push eax          /* Empilha o ponteiro 'this' */
  call [edx]        /* Chama Y::f() */
  add esp,4         /* Joga 'this' fora */

  mov eax,[p]
  call _delete     /* Delete chamará o destrutor de Y */

  xor eax,eax
  ret

A chamada, num equivalente simplório, em C, seria:

  p->pVtbl->f();

Onde pVtbl é o ponteiro “escondido” para a tabela virtual.

No código em assembly, acima, viram como a manipulação de 3 ponteiros é necessária para lidar com a chamada? O primeiro ponteiro é o do próprio objeto (p, no nosso caso), dai pegamos o ponteiro da tabela virtual (que é a primeira coisa na estrutura do objeto — mas isso pode ser dependente de implementação!), depois pegamos o ponteiro da função f(), na tabela virtual e usamos esse ponteiro para fazer a chamada.

Isso é bem mais complicado do que um código equivalente em C, usando estruturas. Por isso (e outras “artimanhas”) o código em C++ é menos performático do que um código equivalente em C. Eu sinceramente prefiro abrir mão do uso de classes e ganhar em performance…

Se você é fã de outras linguagens como Java e C#, por exemplo, poderá observar que elas implementam um comportamento contrário ao de C++. Nelas as funções são virtuais por default. Ou seja, essas indireções de 3 níveis (no caso de Java e C# são 4 níveis. Explico daqui a pouco!) são a normalidade… Se você quiser “sobrescrever” funções deverá usar palavras-chave como “override”. Ao contrário de Java e C#, C++ é um pouco melhor (na minha opinião) porque permite que você escolha usar indireção como um caso especial.

Agora, para melhorar o seu conceito sobre C/C++ ainda mais, vamos à explicação sobre as 4 indireções do Java e do C#: Essas linguagens não fazem uso do conceito de ponteiros. Ponteiros não existem em Java. Existem em C#, mas o uso é limitado (e não recomendado). A idéia é que objetos são criados no heap e seu tempo de vida é controlado por uma thread de baixa prioridade, executando um sub-sistema conhecido como Garbage Collector. Quando criamos uma instância de um objeto fazemos algo assim:

X p = new X;

Note que p não é um ponteiro, é uma “referência”. Uma referência, nessas linguagens, é um ponteiro para um ponteiro. O compilador Java ou C# aloca um pedaço do heap que contém os endereços das estruturas alocadas em outro pedaço do heap controlado pelo Garbage Collector. Em Java esse pedaço “fixo” é conhecido como heap “permanente” (com o tamanho controlado pela opção -XX:PermSize).

Os objetos alocados no heap “normal” (não permanente) são movidos de tempos em tempos para outros lugares dentro do mesmo heap para evitar um fenômeno conhecido como fragmentação de memória. Se o Garbage Collector livra-se de objetos de tempos em tempos, é de se esperar que apareçam buracos no heap. Para aproveitar esses buracos, o GC move blocos de memória para o início do heap. Isso evita a fragmentação, mas também degrada a performance da aplicação já que a thread do GC tem que travar todas as threads da aplicação para poder mover os blocos de memória e reassinalar os novos endereços no heap permanente… Em java esse procedimento do GC chama-se stop-the-world.

Assim, os endereços dos objetos são constantemente alterados no heap “permanente” porque os objetos são constantemente movidos pelo GC. O ponteiro para o heap permanente  (usado como “referência” para o heap “normal” – entende agora o porque no nome?) não é alterado. No exemplo acima, a variável p é um ponteiro para o ponteiro no heap “permanente”.

Se lembrarmos que todas as funções de uma classe são, por default, virtuais — nessas linguagens — uma simples chamada lidará com 4 níveis de indireção: O primeiro é o ponteiro que aponta para o heap fixo (a referência). O segundo é o ponteiro do heap fixo que aponta para o objeto no heap “variável”, o terceiro é o ponteiro para a tabela virtual, associada ao objeto, e o quarto é o endereço da função obtido da tabela virtual.

Por isso não há comparação de performance entre C/C++ e C# e Java… Essas duas últimas (e outras linguagens interpretadas que usam Garbage Collectors) fazem tanto trabalho “preparatório” para realizar chamadas que a performance relativa pode ser mais que 200% pior do que o equivalente em C.

C rocks!

Anúncios

Um comentário sobre “Funções virtuais, C/C++ e outras linguagens

  1. Segundo um material que estou estudando, uma situação semelhante em C, utiliza vtable para ” métodos virtuais” e métodos estáticos para os desta forma.
    Portanto uma subclasse herda os métodos estaticamente linkados de sua superclasse e pode escolher entre herdar ou sobrepor os métodos linkados dinamicamente.
    O resultado obtido com isto é límpido e elegante e permite a implementação de herança e polimorfismo em C!
    Ressalvando que: a linkagem estática é mais eficiente pois o compilador pode fazer a chamada à subrotina diretamente pelo endereço, entretanto a função, quando linkada dinamicamente (a.k.a virtual) possui a flexibilidade de ser alterada em run-time.

    I agree: C rocks!

Deixe um comentário

Faça o login usando um destes métodos para comentar:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s