O padrão de projeto Visitor e Vtables em C++

Outro dia estava desenvolvendo um código Java em que precisei usar instanceof, mas como já havia lido que o uso dessa palavra-chave é sinal de design ruim, busquei ajuda e me recomendaram o padrão de projeto Visitor.

Neste post vamos falar sobre esse padrão de projeto em Java, que foi onde minha dúvida se originou e também vamos discuti-lo em C++, porque sua implementação tem relação com a estrutura Vtables que eu queria estudar a algum tempo.

Visitor em Java

Consideremos o seguinte exemplo, onde temos as classes Cachorro e Gato que são especializações de Animal e que possuem métodos para emissão de som, respectivamente latir() e miar(), respectivamente.

Queremos implementar um método emitirSom(), que recebe uma instância de Animal e emite o som de acordo com a classe instanciada. Uma alternativa usando instanceof é a seguinte:

// Listagem 1
interface Animal {    
}
public class Cachorro implements Animal {
	public void latir(){
		System.out.println("Au!");
	}
}
public class Gato implements Animal {
	public void miar(){
		System.out.println("Miau");
	}
}
...
public static void emitirSom(Animal animal){
    if(animal instanceof Cachorro){
        Cachorro cachorro = (Cachorro) animal;
        cachorro.latir();
    }
    else if(animal instanceof Gato){
        Gato gato = (Gato) animal;
        gato.miar();
    }
}

Aqui cabe uma citação a Scott Meyers, no seu livro Effective C++:

Anytime you find yourself writing code of the form “if the object is of type T1, then do something, but if it’s of type T2, then do something else,” slap yourself.

Para nos livrarmos do instanceof, basta adicionarmos um método emitirSom() à interface Animal e substituir latir()/miar(). No método emitirSom() usamos polimorfismo para escolher a implementação correta.

// Listagem 2
interface Animal {
    void emitirSom();
}
public class Cachorro implements Animal {
    @Override
    public void emitirSom(){
        System.out.println("Au!");
    }
}
public class Gato implements Animal {
    @Override
    public void emitirSom(){
        System.out.println("Miau");
    }
}
...
public static void emitirSom(Animal animal){
    animal.emitirSom();
}

Outra possibilidade é delegar a implementação de emitirSom() para uma outra classe através do padrão de projeto Visitor. Esse padrão considera dois tipos de classes: um elemento e um visitante. O elemento implementa um método geralmente chamado de accept() e o visitante o método visit().

O método accept() recebe um visitante e chama o método visit() desse visitante passando a si mesmo como parâmetro. Desta forma, o visitante pode implementar o método visit() para cada tipo.

// Listagem 3
interface Animal {
    void emitirSom();
    void accept(AnimalVisitor visitor);
}
public class Cachorro implements Animal {
    @Override
	public void accept(AnimalVisitor visitor){
        visitor.visit(this);
    }
}
public class Gato implements Animal {
    @Override
	public void accept(AnimalVisitor visitor){
        visitor.visit(this);
    }
}
interface AnimalVisitor {
    void visit(Cachorro cachorro);
    void visit(Gato gato);
}
public class EmissorDeSom implements AnimalVisitor {
    @Override
	public void visit(Gato gato){
        System.out.println("Miau");
    }
    @Override
	public void visit(Cachorro cachorro){
        System.out.println("Au!");
    }	
}

A vantagem dessa abordagem é que a classe EmissorDeSom pode conter membros e métodos comuns ao modo como os animais emitem som, que de outra forma acabaria sendo implementado na classe Animal.

Outra vantagem é que a implementação do visitante pode ser escolhida em tempo de compilação e, sendo uma injeção de dependência, facilita testes.

Visitor em C++

Em C++ não temos a palavra-chave instanceof, mas podemos usar a função typeid().

Segundo [1], se o argumento dessa função for uma referência ou um ponteiro de-referenciado para uma classe polimórfica, ela retornará um type_info correspondendo ao tipo do objeto em tempo de execução.

// Listagem 4
struct Animal {
    virtual void foo(){};
};
struct Cachorro : public Animal {
    void latir(){
        cout << "Au!\n";
    } 
};
struct Gato : public Animal {
    void miar(){
        cout << "Miau\n";
    }
};
void emitirSom(Animal *animal){
    if(typeid(*animal) == typeid(Cachorro)){
        Cachorro *cachorro = dynamic_cast<Cachorro *>(animal);
        cachorro->latir();
    }
    else if(typeid(*animal) == typeid(Gato)){
        Gato *gato = dynamic_cast<Gato *>(animal);
        gato->miar();
    }
}

Novamente, podemos criar um método emitirSom() em uma interface e usar o polimorfismo. Em C++ não temos interface, mas podemos definir um método puramente virtual como no código abaixo.

// Listagem 5
struct Animal {
    virtual void emitirSom() = 0;
};
struct Cachorro : public Animal {
    void emitirSom(){
        cout << "Au!\n";
    }
};
struct Gato : public Animal {
    void emitirSom(){
        cout << "Miau\n";
    }
};
void emitirSom(Animal *animal){
    animal->emitirSom();
}

A implementação do padrão Visitor pode ser feita da seguinte maneira:

// Listagem 6
struct Gato;
struct Cachorro;

struct AnimalVisitor {
    virtual void visit(Gato *gato) = 0;
    virtual void visit(Cachorro *cachorro) = 0;
};
struct Animal {
    virtual void accept(AnimalVisitor *visitor) = 0;
};
struct EmissorDeSom : public AnimalVisitor {
    void visit(Gato *gato){
        cout << "Miau\n";
    }
    void visit(Cachorro *cachorro){
        cout << "Au!\n";
    }    
};
struct Cachorro : public Animal {
    void accept(AnimalVisitor *visitor){
        visitor->visit(this);
    }
};
struct Gato : public Animal {
    void accept(AnimalVisitor *visitor){
        visitor->visit(this);
    }
};
void emitirSom(Animal *animal){
    AnimalVisitor *visitor = new EmissorDeSom();
    animal->accept(visitor);
}

Despacho único e despacho múltiplo

Vamos agora analisar como C++ implementa funções virtuais. Antes disso, precisamos entender o conceito de despacho dinâmico.

Despacho dinâmico é uma técnica utilizada no caso em que diferentes classes implementam diferentes métodos, mas não se sabe qual o tipo do objeto que chama o método em tempo de compilação, como nos exemplos acima.

Em C++ e Java, esse tipo de despacho é também chamado de despacho único (single dispatch) porque ele só leva em conta o tipo do objeto que chama o método. Em Lisp e algumas outras poucas linguagens, há o chamado despacho múltiplo que também leva em conta o tipo de parâmetro.

Por exemplo, considere o código abaixo:

// Listagem 7
void visitor(Cachorro *cachorro){
    cout << "Au!\n";
}
void visitor(Gato *gato){
    cout << "Miau!\n";
}
void emitirSom(Animal *animal){
    visitor(animal);
}

Teremos um erro de compilação, porque não temos ligação dinâmica a partir dos parâmetros.

Agora veremos como o despacho dinâmico é implementado em C++.

C++ Vtables

Embora a especificação do C++ não diga qual a maneira de implementar o despacho dinâmico, a maioria dos compiladores fazem uso de uma estrutura chamada virtual method table, também conhecida por outros nomes como vtable. Essencialmente, essa tabela é um array de ponteiros para os métodos virtuais.

Cada classe que define ou herda pelo menos um método virtual possui sua vtable. Ela aponta para os métodos virtuais definidos pelo seu ancestral (incluindo a si mesmo) mais próximo que podem ser utilizados por aquela classe. Além disso, cada instância de uma classe com vtable possui um ponteiro para essa tabela. Esse ponteiro é setado quando instanciamos esse objeto.

Considere o trecho da Listagem 5:

Animal *animal = new Cachorro();

Aqui o ponteiro de animal aponta para a vtable de Cachorro, e não de Animal. Então, quando fazemos

animal->emitirSom();

é feita uma consulta para saber exatamente qual implementação emitirSom() chamar. Note que para métodos virtuais existe uma indireção que pode afetar o desempenho. Por isso, por padrão, o despacho dinâmico não é efetuado em C++, a menos que alguma classe adicione a palavra-chave virtual. Em Java o despacho é habilitado por padrão.

Vamos a um exemplo,

struct A {
    virtual void foo(){}
    virtual void bar(){}
    void baz(){}
};
struct B : public A {
    void bar(){}
    virtual void baz(){}
};

Podemos analisar as vtables das classes A e B compilando o código acima com gcc usando a opção -fdump-class-hierarchy. Um arquivo de texto com a extensão .class será gerado.

Vtable for A
A::_ZTV1A: 4u entries
0     (int (*)(...))0
4     (int (*)(...))(& _ZTI1A)
8     (int (*)(...))A::foo
12    (int (*)(...))A::bar

Class A
   size=4 align=4
   base size=4 base align=4
A (0xb71f6038) 0 nearly-empty
    vptr=((& A::_ZTV1A) + 8u)

Vtable for B
B::_ZTV1B: 5u entries
0     (int (*)(...))0
4     (int (*)(...))(& _ZTI1B)
8     (int (*)(...))A::foo
12    (int (*)(...))B::bar
16    (int (*)(...))B::baz

Class B
   size=4 align=4
   base size=4 base align=4
B (0xb7141618) 0 nearly-empty
    vptr=((& B::_ZTV1B) + 8u)
  A (0xb71f61f8) 0 nearly-empty
      primary-for B (0xb7141618)

Aqui podemos ver que a função A::foo e A::bar aparecem na vtable de A, mas a função A::baz não, porque ela não foi declarada virtual. Na vtable de B temos A::foo, porque ela não foi sobrescrita em B. Temos também B::bar, embora ela não tenha sido declarada virtual em B, essa propriedade foi herdada de A::bar. Finalmente, B::baz aparece na vtable porque foi declarada como virtual em B.

Podemos ver também o valor que os ponteiros que as instâncias dessas classes terão: vptr=((& A::_ZTV1A) + 8u) e vptr=((& B::_ZTV1B) + 8u), que é respectivamente onde os ponteiros para as funções começam nas respectivas tabelas.

Uma referência interessante para compreender melhor vtables pode ser vista em [2].

Referências

[1] The typeid operator (C++ only)
[2] LearnCpp.com – The Virtual Table

Os comentários estão fechados.

%d bloggers like this: