Herança e Polimorfismo

Chama-se herança o mecanismo que permite a uma classe herdar membros (métodos e atributos) de outra classe. Tal mecanismo permite reaproveitar código comum entre classes filhas. Porém, uma vantagem ainda mais importante é a possibilidade de criar interfaces.

A sintaxe para a criação de uma classe base é mostrada no exemplo abaixo. A classe ClasseFilha estende a classe ClasseMae. Em outras palavras, a classe ClasseFilha é formada por todos os seus atributos e métodos mais todos os da classe ClasseMae.

#include <iostream>
#include <string>

class ClasseMae {
public:
    ClasseMae(int atributo_1)
        : atributo_1(atributo_1)
        , atributo_privado('z')
    {}

    void metodo_1()
    {
        std::cout << "O metodo 1 foi invocado!\n";
    }

protected:
    int atributo_1;
private:
    char atributo_privado;
};

class ClasseFilha : public ClasseMae {
public:
    ClasseFilha(int atributo_1, double atributo_2)
        : ClasseMae(atributo_1)
        , atributo_2(atributo_2)
    {}

    void metodo_2()
    {
        std::cout << "O metodo 2 foi invocado!";
    }

private:
    double atributo_2;
};

int main()
{
    ClasseMae objeto_1{1};
    objeto_1.metodo_1();

    ClasseFilha objeto_2{1, 2.2};
    objeto_2.metodo_1();
    objeto_2.metodo_2();

    return 0;
}

O exemplo é extremamente simples, e serve apenas para ilustrar a sintaxe de herança em C++ e iniciar um contato do leitor sobre esses aspectos da linguagem. Note que ambas as instâncias objeto_1 e objeto_2 podem invocar o método metodo_1, e apenas a instância da classe ClasseFilha pode invocar o método metodo_2. Isso ocorre porque objeto_2 é uma instância da classe ClasseFilha, que possui tanto os métodos e atributos de ClasseMae quanto aqueles referentes à própria classe ClasseFilha.

O construtor da classe ClasseFilha precisa invocar o construtor da classe mãe (ClasseMae) de forma a construir corretamente a mesma. A sintaxe para a construção da classe mãe é mostrada no exemplo acima. A classe ClasseFilha possui um construtor que contém uma lista de inicialização onde o construtor da classe mãe é invocado. Caso a classe mãe não tivesse um construtor definido (se estivesse sendo utilizado o construtor padrão), não seria necessário invoca-lo explicitamente como no exemplo acima.

Além disso, é possível reescrever um método da classe mãe, de forma a alterar seu funcionamento, na classe filha. Considere o exemplo abaixo. A classe ClasseMae possui agora um método publico virtual chamado metodo_virtual, e a classe ClasseFilha possui um método de mesmo nome com o modificador override. Esse modificador é opcional e está disponível apenas em versões mais atuais da linguagem. É uma boa prática de programação adiciona-lo, a fim de manter uma consistência entre classe base e classe filha.

#include <iostream>

class ClasseMae {
public:
    virtual void metodo_virtual()
    {
        std::cout << "Comportamento da classe mae.\n";
    }
};

class ClasseFilha : public ClasseMae {
public:
    void metodo_virtual() override
    {
        std::cout << "Outro comportamento!!\n";
    }
};

void executar_algoritmo(ClasseMae& objeto)
{
    // ... Imagine um algoritmo aqui ...
    objeto.metodo_virtual();
}

int main()
{
    ClasseMae objeto_1;
    executar_algoritmo(objeto_1);

    ClasseFilha objeto_2;
    executar_algoritmo(objeto_2);

    return 0;
}

Note que a função executar_algoritmo recebe como parâmetro uma referência para um objeto da classe ClasseMae. Ainda assim, na função main, uma instância da classe ClasseFilha é enviada como parâmetro. Sempre é possível utilizar instâncias de classes filhas como parâmetros para referências ou ponteiros para sua classe mãe. O método executar_algoritmo vai se comportar de forma diferente, dependendo do tipo de objeto sendo recebido como parâmetro. Sendo mais específico, para o caso do exemplo acima a primeira chamada de executar_algoritmo(objeto_1) resultará no texto escrito Comportamento da classe mae., enquanto a segunda chamada executar_algoritmo(objeto_2) resultará no texto Outro comportamento!!. Essa característica se chama Polimorfismo.

Um aspecto muito importante de se ter em mente é que no caso da ausência do virtual na classe mãe, o polimorfismo do exemplo não acontecerá conforme esperado. Basicamente a ausência de virtual ignora a funcionalidade que possibilita a invocação correta do método da classe filha, quando a mesma é passada por parâmetro como uma classe base, como no exemplo. Em outras palavras, a remoção de virtual (e por consequência a remoção de override) no exemplo resultaria que as duas chamadas retornarem o texto Comportamento da classe mae..

Uma classe pode possuir métodos virtuais sem implementação, como no próximo exemplo. Classes com pelo menos um método virtual sem implementação não podem ser instanciadas. Ou seja, não é possível construir um objeto cujo tipo seja uma classe com métodos sem implementação. Classes cujos métodos são todos virtuais e sem implementação são chamadas interfaces. Classes que contenham pelo menos um método com implementação são chamadas classes abstratas. Portanto, no exemplo abaixo, a classe ClasseInterface é uma interface.

#include <iostream>

class ClasseInterface {
public:
    virtual void metodo_a() = 0;
};

class ClasseConcreta_A : public ClasseInterface {
public:
    void metodo_a() override {
        std::cout << "Metodo A invocado pela classe concreta A!\n";
    }
};

class ClasseConcreta_B : public ClasseInterface {
public:
    void metodo_a() override {
        std::cout << "Metodo A invocado pela classe concreta B!\n";
    }
};

void executar_algoritmo(ClasseInterface& objeto)
{
    objeto.metodo_a();
}

int main()
{
    ClasseConcreta_A objeto_1;
    executar_algoritmo(objeto_1);

    ClasseConcreta_B objeto_2;
    executar_algoritmo(objeto_2);

    return 0;
}

Um exemplo prático do uso de polimorfismo é mostrado a seguir. Imagine que tenhamos um sistema com vários elementos gráficos: retangulos, circulos, triangulos... Cada estrutura é representada por uma classe com as informações pertinentes. Por exemplo:

class Retangulo {
private:
    Vetor posicao;
    double largura;
    double altura;
};

class Circulo {
private:
    Vetor posicao;
    double raio;
};

Para desenhar um conjunto de elementos gráficos na tela poderíamos ter uma interface em comum para eles, e determinar um método para cada classe desenhar-se:

class ElementoGrafico {
public:
    virtual void desenhar() const = 0;
};

class Retangulo : ElementoGrafico {
public:
    void desenhar() const override { /*...*/ }
/*...*/
};

Por fim, no programa principal é possível ter um vetor de ElementoGrafico, e considerando que cada um deles sabe se desenhar, o código final fica:

void desenhar_tela(std::vector<std::unique_ptr<ElementoGrafico>> const& elementos)
{
    for (auto const& elemento : elementos) {
        elemento->desenhar();
    }
}

O código completo é apresentado abaixo. O código apresentado é apenas ilustrativo, visto que ele não desenha nada de fato na tela, ele apenas escreve textos no terminal.

#include <iostream>
#include <vector>
#include <memory>

struct Vetor {
    double x;
    double y;
};

class ElementoGrafico {
public:
    virtual void desenhar() const = 0;
};

class Retangulo : public ElementoGrafico {
public:
    void desenhar() const override
    {
        std::cout << "Desenhando o retangulo!\n";
    }

private:
    Vetor posicao;
    double largura;
    double altura;
};

class Circulo : public ElementoGrafico {
public:
    void desenhar() const override
    {
        std::cout << "Desenhando o circulo!\n";
    }

private:
    Vetor posicao;
    double raio;
};

void desenhar_tela(std::vector<std::unique_ptr<ElementoGrafico>> const& elementos)
{
    for (auto const& elemento : elementos) {
        elemento->desenhar();
    }
}

int main()
{
    auto elementos = std::vector<std::unique_ptr<ElementoGrafico>>{};
    elementos.push_back(std::make_unique<Circulo>());
    elementos.push_back(std::make_unique<Retangulo>());

    desenhar_tela(elementos);    
}

Utiliza-se um std::vector de unique_ptr neste exemplo pois não é possível criar um std::vector<ElementoGrafico> (já que trata-se de uma interface) e nem um std::vector<ElementoGrafico&> (já que não é possível criar um vector de referências). A última alternativa seria std::vector<ElementoGrafico*>, que funcionaria normalmente, exceto pelo fato de que seria necessário fazer o gerenciamento da memória de alguma forma. Para delegar esse gerenciamento, decide-se utilizar os smart pointers (nesse caso unique_ptr) de forma a simplificar o código final.