O que C++ (e outras linguagens OO) escondem de você?

A confusão mais recorrente que encontro por ai é o conceito de “orientação à objetos”. O que diabos é um “objeto”, afinal de contas?

Nos início dos anos 90, quando C++ e Java começaram a fazer sucesso entre os desenvolvedores de aplicações e quando ambientes gráficos como Windows 95 e OS/2 Warp apareceram, todo mundo achava que “objeto” era algo visível: Posso ver e manipular com o mouse, então é um “objeto”… Nada mais falso! Objeto é apenas uma estrutura contendo dados e o encapsulamento de funções que os manipulam.

O conceito de “orientação à objetos” é um pouco mais amplo: Dado um objeto (a estrutura de dados e funções “encapsuladas” que os manipulam), outras duas propriedades devem ser observadas: Herança e polimorfismo. Herança significa apenas que um objeto construído à partir de outro deve “herdar” as propriedades (dados e funções) do objeto “pai”… Polimorfismo é mais complicado: Significa que a linguagem deve enxergar um objeto filho como se fosse o objeto pai, se usarmos uma referência para o objeto pai! Assim, uma referência pode assumir “várias formas” (do grego, polys=muitas ou várias; morphes=formas).

Existem outros tipos de polimorfismo e estou lidando aqui apenas com polimorfismo funcional, que é a base do polimorfismo implementado em C++.

Bem… em meados dos anos 80 aparece o C++, baseado em linguagens mais antigas como o Smalltalk. E ele implementa uma série de features na linguagem C. Entre elas: Funções “amigas”, sobrecarga de funções, sobrecarga de operadores, funções virtuais, classes virtuais, herança múltipla (um filho pode ter dois ou mais pais!), novos operadores de gerenciamento de memória (new e delete) e um conjunto de bibliotecas padrão (mais tarde agrupadas no “namespace” std).

Não parece, mais tudo isso é periférico. As sobrecargas são facílimas de emular em C, assim como as funções virtuais (como mostrarei abaixo)… As bibliotecas padrão (e classes) podem ser ignoradas (e, geralmente, são… pelo novato). Mas, para “agregar valor” e acabar com uma bagunça que existia na indústria, alguém criou e padronizou um conjunto de classes (de “gabaritos”, na verdade) com algoritmos muito usados, como filas, listas, árvores (mapas) etc… Eis a “Standard Template Library”. Note que a STL é parte integrante dos compiladores C++ distribuídos por ai, mas NÃO faz parte da linguagem C++…

O que é uma classe?

Uma “classe” é, nada mais, nada menos, que uma “struct” que ainda não foi instanciada. Em C, ao declarar algo assim:

struct vector { float x, y, z; };

Você está declarando uma classe. Uma agrupamento de dados. Nenhuma variável foi declarada aqui, apenas os nomes dos símbolos que representam floats, na sequência, dentro da estrutura (classe). Somente quando você cria uma variável com base nessa estrutura é que temos um “objeto”:

// Define 'v' com base na estrutura 'vector',
// declarada anteriormente.
struct vector v;

O objeto conhecido agora como v está presente na memória. Ou seja, acabamos de criar uma instância (do latim instantia, que significa “presença”) da classe vector e a chamamos de v.

E as funções-membro?

Suponha que a nossa classe tenha uma função membro que obtenha o tamanho de um vetor tridimensional, em C++ poderíamos declarar tal classe assim:

struct vector { 
  float x, y, z;

  float get_length()
  {
    return sqrtf(x*x+y*y+z*z);
  } 
};

Eis o que o compilador faz (desconsiderando o fato de que get_length será codificada como inline):

struct vector { 
  float x, y, z;

  float (*get_length)(struct vector *this);
};

float vector_get_length(struct vector *this)
{
  return sqrtf(this->x*this->x+
               this->y*this->y+
               this->z*this->z);
} 

Em algum momento o compilador atribuirá ao ponteiro get_length do objeto da estrutura vector o endereço da função vector_get_length. Isso será feito quando a classe (estrutura) for instanciada:

struct vector *new_vector(void)
{
  struct vector *obj;

  if (!(obj = (struct vector *)malloc(sizeof(struct vector))))
  {
    error_handling();
    return NULL;
  }

  obj->get_length = vector_get_length;

  //... possivelmente temos uma chamada
  //    a um construtor default aqui.

  return obj;
}

Daí por diante, toda chamada a get_length, como membro funcional do objeto, é feita via ponteiro… Isso, por si, não é problemático, já que todo símbolo que referencia funções é, na verdade, um ponteiro… O importante é que, para termos encapsulamento temos que inicializar um membro “escondido” de dados contendo o endereço das funções membro que sempre receberão um ponteiro da instância como parâmetro (o ponteiro this). Só assim uma função membro tem como saber com qual objeto ela está lidando…

// Instanciação e chamada de get_length, em C++.
struct vector v;
x = v.get_length();

// Instanciação e chamada de get_length, em C.
struct vector *v = new_vector();
x = v->get_length(v);

Obviamente que existirá um destrutor… Neste caso, uma simples chamada para a função da libc free.

Veremos, mais adiante, que isso que escrevi ai em cima é apenas um modelo equivalente entre C++ e C. O compilador C++ não cria ponteiros para suas funções membro não virtuais. Ele usa um artifício mais esquisito…

Polimorfismo e as funções membro virtuais

A coisa complica quanto temos funções membro virtuais… O que são elas? O encapsulamento de C++ causa alguns problemas quando mexemos com herança… Se duas classes têm uma função f, ao declararmos um ponteiro para a classe base e atribuirmos a esse ponteiro um ponteiro para o um objeto de classe derivada, a função f chamada será o da classe base… Por exemplo:

class A { 
  ...
public:
  int f() { ... }
};

class B : public A {
  ...
public:
  int f() { ... }
};

A *p = new B;
p->f(); // chamará A::f().

O problema é difícil de entender para o novato. Afinal, uma instância de B está sendo apontada por p, mesmo que este tenha sido declarado como apontando para uma classe A! Não é razoável assumir que o ponteiro para a função f, dentro do objeto, aponte para a função correta com base na instância?

A resposta é um sonoro NÃO! O compilador só sabe que p é do “tipo” A e, portanto, ele chamará a função A::f. É claro que passará o ponteiro para a instância de B no parâmetro this… Para corrigir essa interpretação, C++ permite a declaração de funções-membro virtuais. Se colocarmos, na classe base A, a palavra chave “virtual” antes da declaração da função f, todas as funções f das classes derivadas também serão virtuais. E uma função virtual nada mais é do que adicionar mais um nível de indireção na chamada.

Todas as funções virtuais são colocadas numa tabela à parte, chamada tabela de funções virtuais (o que mais?) e a nossa classe, agora, terá um ponteiro para essa tabela. Em C a nossa classe A com a função f marcada como “virtual” ficaria assim:

struct _vtbl {
  int (*f)(void);
};

struct A {
  struct _vtbl *vtbl;
  ...
};

A tabela virtual geralmente é a primeira coisa que existe numa classe com membros virtuais, mas é comum ser referenciada, internamente, com offsets negativos, para não atrapalhar o acesso aos outros membros (e permitir cópias do objeto sem mexer com a tabela virtual). O detalhe aqui é que, agora, toda chamada à função f é feita com dupla indireção. No caso do nosso ponteiro p anterior, a chamada equivalente, em C, de uma função virtual f, seria:

x = p->vtbl->f();

C++ simplesmente esconde a virtual table de você…

Com isso, já que a virtual table é parte integrande da instância, não importa se chamarmos f a partir de um ponteiro do tipo A ou B. A função f associada à instância é que será chamada, via virtual table e o polimorfismo funcional está garantido!

E a herança?

Herança é mais simples… Tudo o que C++ faz é declarar estruturas com nomes malucos contendo os membros da classe herdada, mas escondendo isso de você… Se uma classe base tem um membro de dados a e uma classe derivada tem um membro b, a classe derivada terá, na verdade, os membros a e b.

Outra maneira de implementar herança é “agregar” a estrutura de uma classe na outra, por exemplo:

struct B {
  struct A *a;
  ...
};

Ao instanciar a estrutura B devemos instanciar a estrutura A em seu interior e toda referência aos membros de A serão feitos de maneira indireta. Isso, é claro, é diferente de “herança”, mas conceitualmente é a mesma coisa…

O que deixará os puristas fulos da vida é que, nestes casos, não estamos observando o “encapsulamento” perfeito. As diretivas sobre a privacidade ou a publicidade dos membros das classes não estão sendo obedecidas. Acontece que isso só existe em tempo de compilação… Um membro privado só o é para o compilador. Repare que a estrutura de uma classe continua sendo armazenada como se fosse uma estrutura tradicional:

/*--- test.cxx --- */
#include <stdio.h>

class A {
private:
  int a;
public:
  int b;

  A() : a(1),b(2) {}
};

extern "C" void show_members(A *); /* Definido em show.asm */
extern "C" void showint(int x) { printf("%d\n", x); }

int main(void)
{
  A x;

  show_members(&x);
}

A função show_members lerá os membros de dados a e b e chamará showint para mostrá-los:

;--- show.asm ---
  bits 64

  section .text

  extern showint
  global show_members

show_members:
  push  rdi
  mov   edi,[rdi]    ; Lê 'a' (private).
  call  showint      ; Mostra 'a'.
  pop   rdi
  mov   edi,[rdi+4]  ; Lê 'b' (public).
  call  showint      ; Mostra 'b'.
  ret

Ao compilar os dois códigos e executar:

$ g++ -c -o test.o test.cxx
$ nasm -f elf64 -o show.o show.asm
$ gcc -o test test.o show.o
$ ./test
1
2

Isso demonstra, sem sombra de dúvidas, que o membro a não é tão privado assim. O código final, executável, só causaria erro se a arquitetura de seu processador permitisse mudar o atributo de um endereço de memória de acordo com algum contexto esquisito (ou se armazenasse a estrutura em lugares diferentes, mas o contexto esquisito ainda teria que existir). Não é o caso! O membro a ainda é acessível de fora do seu programa em C++, facilmente!

Herança, polimorfismo e a “mutilação” dos nomes de funções membro

É absolutamente necessário que os membros de dados de uma classe base apareçam na mesma sequência numa classe derivada. Somente assim o polimorfismo pode dar certo… Mas, eu disse lá atrás que as funções membro não virtuais não são implementadas através de indireções, na estrutura da classe… De fato, C++ usa o artifício de “mutilar” (mangle) o nome das funções, acrescentando informações sobre a classe e os parâmetros da função para torná-la diferente das funções com mesmo nome e tornar possível a sobrecarga de funções. Por exemplo:

class A {
public:
  int f();
};

class B : public A {
public:
  int f();    
};

int A::f() { return 1; }
int B::f() { return 2; }

Eis o código gerado para as duas funções f:

_ZN1A1fEv:
  mov eax,1
  ret

_ZN1B1fEv:
  mov eax,2
  ret

Se essas funções acessassem quaisquer membros de dados de suas classes conteriam um parâmetro adicional (o ponteiro this). Repare que as funções acima podem ser chamadas diretamente, mas apenas o compilador sabe como resolver esses nomes malucos, que estão relacionados com as classes que deram origem a eles.

Essa é outra evidência de que o compilador usará a referência à classe declarada, ao invés da classe instanciada, numa chamada polimórfica (ponteiro de objeto da classe A que contém referência para classe B usa membros da classe A!), a não ser que a indireção da tabela virtual seja usada.

Ahhh… e também foi por isso que declarei as funções showintshow_members, no sub-tópico anterior, como extern “C”. Declarar funções extern desse jeito faz com que o compilador não mutile o nome do símbolo (mas também não permite sobrecarga!).

Abstrações são problemáticas para performance, mas nem sempre

Já falei isso antes aqui: Meu grande problema com C++ é, justamente, a abstração oferecida pela linguagem. Quanto mais “facilidades”, mais lento tende a ser o código final. Isso acontece porque não há informações suficientes para que o compilador saiba como o código será usado… E quanto mais abstrato, pior… Este é o problema de linguagens como Java e C#, por exemplo. A tradução do código fonte para o código intermediário (ou bytecode, no caso do java) tem que ser genérica o suficiente para embarcar todos os casos possíveis. A tradução adicional, do código intermediário para código executável pelo processador (quanto acontece, via HotSpot, por exemplo, no caso do Java) é ainda mais precária.

Que fique claro: Minha única birra com C++ e com essas outras linguagens é relacionada à performance e a qualidade do código gerado. Não estou descendo o cacete nas linguagens em si mesmas. Mas, admito minha preferência por C e Assembly… Dito isso, é sempre importante que você saiba o que seu compilador ou linguagem preferida está fazendo “por baixo dos panos”. Somente assim você poderá criar códigos que explorem o que o compilador pode fazer de melhor!

Anúncios