Quando eu estudei o conceito de polimorfismo pela primeira vez, lembro de não ter entendido claramente o que significada. Principalmente, porque se confundia com o meu entendimento de herança. O objetivo deste texto é caracterizar o termo polimorfismo e trazer alguns exemplos que eu espero que ajudem na compreensão.

Nenhuma das discussões apresentadas se propões a ser exaustiva, apenas suficiente para ilustrar diferentes situações.


Em um contexto geral, “polimorfismo” é definido como aquilo que pode assumir múltiplas formas. Na programação, polimorfismo é a possibilidade de definir uma mesma interface a entidades de diferentes tipos. A linguagem de programação C++ tem recursos que possibilitam polimorfismo tanto em tempo de execução quanto em tempo de compilação. A seguir, serão apresentados exemplos de diferentes situações em que se tem polimorfismo, usando a linguagem C++.

Polimorfismo em tempo de compilação

Polimorfismo em tempo de compilação, também denominado polimorfismo estático, ocorre em situações em que o compilador atribui diferentes características a símbolos ou nomes dependendo do contexto em que aparecem. As seguintes subseções discutem alguns casos particulares.

Operadores aritméticos

Uma forma de polimorfismo estático ocorre com os operadores aritméticos.

Considerando operadores binários (que envolvem dois operandos), é costume chamá-los de lhs e rhs. São acrônimos para left-hand side e right-hand side, usados para denominar o operador da esquerda e da direita, respectivamente. Essa nomenclatura será usada nos exemplos.

É importante ressaltar que os exemplos que eu trago não abrangem todas as possibilidades.

Operador /

O operador de divisão / aceita dois operandos numéricos, dividindo lhs por rhs. Dependendo do tipo dos argumentos, o valor retornado será diferente tanto no seu tipo quanto no valor numérico.

Em uma expressão em que ambos os operandos são do tipo int, o resultado da expressão será um valor do tipo int truncado em direção ao zero. Em contrapartida, se os operandos forem valores de ponto flutuante, como float ou double, não haverá truncamento e o valor retornado terá o mesmo tipo dos argumentos.

Operador -

Além de atuarem sobre operandos numéricos, o operador de subtração pode envolver ponteiros, fazendo com que as operações tenham significados diferentes de uma subtração numérica.

Caso 1: ambos os operandos são numéricos

Quando ambos os operandos são numéricos, o resultado será a subtração matemática convencional: o operando da direita subtraído do operando da esquerda.

Caso 2: lhs é ponteiro, rhs é inteiro

Neste caso, o resultado será um ponteiro do mesmo tipo de lhs, deslocado rhs posições à esquerda, também levando em conta o tipo do ponteiro.

Caso 3: ambos os operandos são ponteiros para elementos de um mesmo array

O resultado será do tipo std::ptrdiff_t, um tipo que representa a distância entre dois elementos de um mesmo array. Esse tipo é definido no header cstddef, como um tipo inteiro com sinal dependente da implementação. Mais informações no cppreference. Supor que o resultado dessa operação seja um int, por exemplo, faz com que o código não seja portável para outras plataformas, podendo introduzir bugs.

Vejamos o seguinte exemplo, em que são realizadas subtrações entre ponteiros int * e, em sequência, char *.

int main()
{
    int arr1[10];
    char arr2[10];

    int pos1 = 2; // posições dos elementos das arrays que serão acessados
    int pos2 = 5;

    int *pi1 = &arr0[pos1]; // pi -> pointer-to-int
    int *pi2 = &arr0[pos2];

    char *pc1 = &arr1[pos1]; // pc -> pointer-to-char
    char *pc2 = &arr2[pos2];

    std::ptrdiff_t diff1 = pi2 - pi1; // Resulta em 3.
    std::ptrdiff_t diff2 = pc2 - pc1; // Também resulta em 3
}

Nota-se que o resultado corresponde à distância entre os elementos do tipo armazenado pela array. Ou seja, a distância é medida em ints para a primeira array, e em chars para a segunda. Dessa forma, não importa que ints e chars ocupem espaços diferentes na memória.

Operador +

Além de ser usado para somar dois valores numéricos, também é possível somar um valor inteiro a um ponteiro, analogamente ao Caso 3 do operador de subtração.

Curiosamente, a operação + não está definida quando ambos os operadores são ponteiros. Sabe dizer o por quê?

Sobrecarga de funções e métodos

A sobrecarga é aquilo que ocorre quando são definidas duas funções (ou métodos) com mesmo nome, no mesmo escopo, mas que diferem na quantidade, tipo, ou ordem de seus parâmetros. É uma forma de polimorfismo em tempo de compilação porque um mesmo nome pode se referir a implementações diferentes, e quem decide qual será utilizada é o compilador.

No trecho de código a seguir, são definidas duas funções que compartilham o nome função. Uma delas recebe um int, e a outra recebe um float

#include <iostream>

void funcao(int arg)
{
    std::cout << "Argumento int\n";
}

void funcao(bool arg)
{
    std::cout << "Argumento float\n";
}

Quando a chamada de uma delas aparecer no código, o compilador decidirá qual implementação será utilizada baseado nas regras de resolução de overload. Sendo o argumento int ou bool, a função chamada será aquela com assinatura diretamente correspondente. É possível usar outros argumentos de outros tipos devido às conversões implícitas, mas foge do escopo deste artigo.

Templates

Templates em C++ são um tópico bastante complexo. Aqui, vou abordar apenas aspectos que relacionam-se a polimorfismo estático.

Templates possibilitam que funções e classes sejam geradas em tempo de compilação. Para exemplificar, suponha a necessidade de ter uma função chamada menor que retorne o menor entre dois argumentos do mesmo tipo. Uma solução é usar a sobrecarga de funções, definindo uma implementação para cada tipo de dado desejado:

/* Exemplo: de sobrecarga de funcoes */

int menor(int a, int b)
{
    return a < b ? a : b;
}

float menor(float a, float  b)
{
    return a < b ? a : b;
}

char menor(char a, char b)
{
    return a < b ? a : b;
}

// ... mais definições ...

Repare que todas as definições são equivalentes, e por isso podem ser facilmente substituídas por um template de função. Um template age como uma receita para se construir funções conforme a necessidade, e são definidos da seguinte forma:

/* Exemplo: definição de template de funcao */

template <typename T>
T menor(T a, T b)
{
    return a < b ? a : b;
}

Desse modo, o símbolo T assume o papel de um tipo genérico que pode ser usado na declaração e implementação da função que o segue, possibilitando que seja invocada uma função menor em que T assume qualquer tipo, cuja implementação será gerada pelo compilador, por exemplo:

/* Exemplo: chamada de funcoes definidas por template */

int main()
{
    int menor1 = menor<int>(1, 2);  // O tipo específico pode ser informado explicitamente...
    char menor2 = menor('a', 'A');  // ... ou pode ser inferido automaticamente pelo compilador
}

Nesse programa, serão geradas implicitamente duas implementações a partir do template, para int e para char. É como se tivéssemos implementado essas funções em vez do template, mas tendo a flexibilidade de usá-lo para quaisquer outros tipos.

Essa técnica é amplamente utilizada pela Standard Template Library (STL) da linguagem C++, uma biblioteca padrão da linguagem que traz implementações de estruturas de dados e algoritmos baseada em templates. Inclusive, essa biblioteca traz uma função chamada min, que retorna o menor entre dois valores. Uma de suas possíveis implementações é muito próxima da que foi apresentada, sendo a única diferença a utilização de parâmetros pass-by-reference-to-const em vez de pass-by-value, discussão que está além do escopo deste artigo.


/* Possível definição da função std::min */

template<class T>
const T& min(const T& a, const T& b)
{
    return (b < a) ? b : a;
}

Polimorfismo em tempo de execução

Comumente relacionado à Programação Orientada a Objetos (POO), o polimorfismo dinâmico é aquele em que objetos de diferentes tipos podem ser tratados de uma mesma forma por possuírem uma interface em comum. Esse tipo de comportamento é comumente obtido través de herança e sobrescrita de métodos.

Considere o exemplo a seguir, em que a classe abstrata Base declara um método virtual puro metodoBase, que é sobrescrito pelas classes derivadas Derivada1 e Derivada2. As classes derivadas também declaram seus próprios métodos, que não serão acessíveis através de referências à classe base.

/* Exemplo: Hierarquia de classes */

#include <iostream>

class Base
{
public:
    virtual void metodoBase() = 0;
};

class Derivada1 : public Base
{
public:
    void metodoBase() override
    {
        std::cout << "Derivada1\n";
    }

    void metodoDerivada1()
    {
    }
};

class Derivada2 : public Base
{
public:
    void metodoBase() override
    {
        std::cout << "Derivada2\n";
    }

    void metodoDerivada2()
    {
    }
};

Por haver uma relação de herança, é possível referir-se a instâncias de Derivada1 e Derivada2 a partir de ponteiros ou referências a objetos da classe base, tendo todos os métodos declarados na classe Base acessíveis. O polimorfismo é caracterizado porque as diferentes implementações Derivada1 e Derivada2 são tratadas da mesma forma, através da interface definida por Base.

/* Exemplo: Polimorfismo dinâmico */

int main()
{
    // Instaciamento das classes derivadas
    Derivada1 d1;
    Derivada2 d2;

    // Referências aos objetos instanciados
    Base& refd1 = d1;
    Base& refd2 = d2;

    // Referências à classe base podem ser usadas para chamar implementações das classes derivadas
    d1.metodoBase();  // OK! `métodoBase` é definido na classe base
    d2.metodoBase();  // OK! `métodoBase` é definido na classe base

    // Referências à classe base não conseguem acessar métodos declarados nas classes derivadas
    d1.metodoDerivada1();  // erro de compilação! `metodoDerivada1` não foi definido na classe base
    d2.metodoDerivada2();  // erro de compilação! `metodoDerivada2` não foi definido na classe base
}

O uso de polimorfismo dinâmico facilita a implementação de diversos padrões de projeto. Por exemplo o padrão observer, que notifica diferentes objetos da ocorrência de um evento. No caso, os objetos a serem notificados implementam possuem uma interface em comum, possibilitando que ponteiros a eles sejam armazenados em uma estrutura homogênea, como um array.