Polimorfismo


Em OOP, o termo polimorfismo diz respeito ao uso de uma interface única para operar sobre objetos de classes distintas.

Existem basicamente dois recursos para se implementar polimorfismo: herança e sobrecarga. A seguir aprenderemos como criar objetos polimorficos em Python usando cada um desses dois recursos.

Herança e Polimorfismo

Para implementar polimorfismo, é essencial que objetos de classes distintas implementem uma mesma interface. Para obter isso usando herança, podemos fazer o seguinte:

  • Criar uma classe base com uma interface específica.
  • Criar classes derivadas que implementam essa interface de formas diferentes.

Vamos usar um exemplo para ilustrar este processo. Suponha que tenhamos uma classe Animal, cuja interface consiste de um único método, chamado locomove, que indica como o animal se move. Podemos implementar essa classe como no exemplo abaixo:

class Animal:
    def __init__(self):
        pass

    def locomove(self):
        pass

Agora, desejamos criar classes derivadas da classe Animal que implementam o método locomove de formas diferentes. Um modo de fazer isso é o mostrado no exemplo abaixo:

class Peixe(Animal):

    def locomove(self):
        print("Um peixe nada.")


class Elefante(Animal):

    def locomove(self):
        print("Um elefante anda.")


class Passaro(Animal):

    def locomove(self):
        print("Um pássaro voa.")

Com isso, dependendo do tipo de animal com o qual estivermos lidando, o método locomove apresentará um comportamento diferente. Por exemplo, considere o código abaixo, no qual invocamos o método locomove para diferentes tipos de animais:

peixe = Peixe()
elefante = Elefante()
passaro = Passaro()

peixe.locomove()
elefante.locomove()
passaro.locomove()
Um peixe nada.
Um elefante anda.
Um pássaro voa.

Note que, ao usar herança da forma mostrada acima, podemos implementar uma interface específica (o método locomove, por exemplo) usando objetos de classes distintas, ou seja, estamos implementando polimorfismo.

Estudo de caso

Até aqui vimos exemplos muito simples sobre herança. Nesta seção desenvolveremos um estudo de caso mais complexo, no qual implementaremos uma classe que representa uma lista circular (também conhecida como ring buffer em inglês).

Neste estudo de caso, mostraremos alguns métodos do código em inglês. Use isso como exercício para praticar sua leitura em inglês. Se não conseguir entender alguma coisa, tente implementar sua própria versão de buffer circular e veja onde estão as nuances da implementação.

Antes de partir para a implementação, precisamos entender como nossa lista circular irá funcionar.

Um buffer circular (ring buffer) é uma lista circular de tamanho fixo onde os dados são escritos (produzidos) em uma extremidade e lidos (consumidos) na outra extremidade. Buffers circulares processam dados da mesma forma que filas (primeiro a entrar, primeiro a sair), então às vezes são chamados de filas produtor-consumidor.

Vamos implementar um buffer circular com a seguinte funcionalidade. Ele receberá como parâmetro um tamanho (o número máximo de elementos que pode armazenar) e fornecerá duas operações principais: push (adiciona dados ao buffer circular) e pop (remove dados do buffer circular). Ele também fornecerá algumas funcionalidades adicionais para verificar se o buffer circular está vazio, cheio, etc.

Como nosso buffer circular tem um tamanho fixo, somos obrigados a tomar algumas decisões de implementação. Os principais problemas com os quais precisamos lidar são os seguintes:

  • O que fazer se tentarmos ler um elemento de um buffer circular vazio? Neste caso, simplesmente retornaremos None para indicar que nada pôde ser lido.
  • O que fazer se tentarmos remover um elemento de um buffer circular vazio? Quando isso acontecer, simplesmente falharemos na forma de uma falha de asserção.
  • O que fazer se tentarmos adicionar um elemento a um buffer circular cheio? Não faremos nada e retornaremos False para indicar que nada foi escrito no buffer circular.

Implementação

Vamos começar decidindo quais membros de dados nosso buffer circular precisa ter:

  • O tamanho do buffer circular.
  • Uma lista de elementos.
  • Um índice que nos diz qual é o próximo elemento que devemos ler ou remover de nossa estrutura de dados.
  • Um índice que nos diz onde devemos adicionar um elemento ao nosso buffer circular.

Com as informações acima, podemos começar a implementar nossa classe.

class BufferCircular():
    def __init__(self, capacidade):
        assert capacidade >= 2
        self.capacidade = capacidade + 1
        self.buffer = [None] * self.capacidade
        self.indice_leitura = 0
        self.indice_escrita = 0

Antes de prosseguirmos, é importante mencionar como usaremos os índices de leitura e escrita para obter informações úteis sobre nossa fila:

  • A fila estará vazia quando os índices de leitura e escrita estiverem na mesma posição, ou seja, read_index == write_index implica em uma fila vazia.
  • Deixaremos um slot não utilizado para indicar que nossa fila está cheia, ou seja, write_index == read_index + 1 significa que a fila está cheia.

Agora estamos prontos para passar para funções mais interessantes, como push e pop.

Vamos começar com push. A ideia é primeiro obter o índice onde precisamos inserir o elemento, depois fazer algumas verificações para ver se precisamos retornar (lembre-se, este é um array circular) ou se a fila está cheia, e então inserimos o elemento e atualizamos os índices. Aqui está uma possível implementação.

    def push(self, val):
        prox_indice_escrita = self.indice_escrita + 1

        # Dá a volta se chegamos ao fim da fila
        if prox_indice_escrita == self.capacidade:
            prox_indice_escrita = 0

        if prox_indice_escrita != self.indice_leitura:
            self.buffer[self.indice_escrita] = val
            self.indice_escrita = prox_indice_escrita
            return True

        # Fila cheia
        return False

A implementação da função pop é parecida, como mostramos abaixo.

    def pop(self):
        assert self.indice_leitura != self.indice_escrita, "Impossível remover de fila vazia"

        prox_indice_leitura = self.indice_leitura + 1
        # Dá a volta se chegamos ao fim da fila
        if prox_indice_leitura == self.capacidade:
            prox_indice_leitura = 0

        self.buffer[self.indice_leitura] = None
        self.indice_leitura = prox_indice_leitura

A ideia principal no código acima é que ocorre uma falha (crash) ao tentar ler de uma fila vazia. Se a fila não estiver vazia, apagamos o elemento em read_index e atualizamos os índices após isso.

Também podemos implementar uma função mais simples que apenas lê um elemento da fila sem removê-lo de fato.

    def front(self):
        if self.indice_leitura == self.indice_escrita:
            return None

        return self.buffer[self.indice_leitura]

A seguir, mostramos como implementar outras funcões para checar se o buffer circular está vazio, cheio, e para imprimir seus elementos:

    def empty(self):
        return self.indice_leitura == self.indice_escrita

    def full(self):
        prox_indice_escrita = self.indice_escrita + 1
        if prox_indice_escrita == self.capacidade:
            prox_indice_escrita = 0

        if prox_indice_escrita != self.indice_leitura:
            return False

        return True

    def __str__(self):
        """Returna a string formatada representando esta lista circular."""
        items = [f"{item!r}" for item in self.buffer if item is not None]
        return '[' + ', '.join(items) + ']'

As funções acima são suficientes como uma primeira versão do nosso buffer circular. Vejamos alguns testes da implementação:

# Exemplo de funcionamento do nosso buffer circular

buffer_circular = BufferCircular(5)
assert buffer_circular.empty()

assert buffer_circular.push("um")
assert buffer_circular.push("dois")
assert buffer_circular.push("três")
assert buffer_circular.push("quatro")
print(str(buffer_circular))

buffer_circular.pop()
buffer_circular.pop()
print(str(buffer_circular))

assert buffer_circular.push("cinco")
print(str(buffer_circular))

assert buffer_circular.push("seis")
print(str(buffer_circular))

assert buffer_circular.push("sete")
assert buffer_circular.full()
print(str(buffer_circular))

# Não pode inserir "oito" pois o buffer está cheio
assert buffer_circular.push("oito") == False
assert buffer_circular.full()
print(str(buffer_circular))
['um', 'dois', 'três', 'quatro']
['três', 'quatro']
['três', 'quatro', 'cinco']
['três', 'quatro', 'cinco', 'seis']
['sete', 'três', 'quatro', 'cinco', 'seis']
['sete', 'três', 'quatro', 'cinco', 'seis']

Uma coisa que está faltando na nossa implementação é uma função que retorna o tamanho do buffer circular. Seria interessante sermos capazes de usar a função len() do Python para retornar o tamanho do buffer, mas veja o que acontece ao tentarmos fazer isso:

len(buffer_circular)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-12-78f4a204c99e> in <cell line: 1>()
----> 1 len(buffer_circular)

TypeError: object of type 'BufferCircular' has no len()

Isso ocorre porque a função len() é uma função nativa do Python. Se quisermos usá-la, precisamos fazer duas coisas:

  • Herdar de uma classe que define essa função
  • Implementar nossa versão da função

Em Python, se desejamos fazer com que uma classe se comporte de forma parecida a uma das classes que implementam coleções de elementos (list, set, dict, etc), precisamos herdar de alguma classe definida em collections.abc. Esse módulo define várias classes abstratas que implementam métodos nativos do Python, como o método len. A documetação sobre esse módulo pode ser vista aqui: https://docs.python.org/pt-br/3/library/collections.abc.html.

No nosso caso, como desejamos simplesmente implementar a função len, precisamos herder de Sized. Para fazer isso, podemos modificar nossa classe como mostrado abaixo:

from collections.abc import Sized

class BufferCircular(Sized):
    # Outros métodos

    def __len__(self):
        tamanho = self.indice_escrita - self.indice_leitura
        if tamanho < 0:
            tamanho += self.capacidade
        return tamanho

Ao fazer as modificações acima, podemos invocar o método len() em nosso buffer circular.

Vejamos agora a implementação completa da classe bem como alguns testes validando seu funcionamento.

from collections.abc import Sized

class BufferCircular(Sized):
    def __init__(self, capacidade):
        assert capacidade >= 2
        self.capacidade = capacidade + 1
        self.buffer = [None] * self.capacidade
        self.indice_leitura = 0
        self.indice_escrita = 0

    def push(self, val):
        prox_indice_escrita = self.indice_escrita + 1

        # Dá a volta se chegamos ao fim da fila
        if prox_indice_escrita == self.capacidade:
            prox_indice_escrita = 0

        if prox_indice_escrita != self.indice_leitura:
            self.buffer[self.indice_escrita] = val
            self.indice_escrita = prox_indice_escrita
            return True

        # Fila cheia
        return False

    def pop(self):
        assert self.indice_leitura != self.indice_escrita, "Impossível remover de fila vazia"

        prox_indice_leitura = self.indice_leitura + 1
        # Dá a volta se chegamos ao fim da fila
        if prox_indice_leitura == self.capacidade:
            prox_indice_leitura = 0

        self.buffer[self.indice_leitura] = None
        self.indice_leitura = prox_indice_leitura

    def front(self):
        if self.indice_leitura == self.indice_escrita:
            return None

        return self.buffer[self.indice_leitura]

    def empty(self):
        return self.indice_leitura == self.indice_escrita

    def full(self):
        prox_indice_escrita = self.indice_escrita + 1
        if prox_indice_escrita == self.capacidade:
            prox_indice_escrita = 0

        if prox_indice_escrita != self.indice_leitura:
            return False

        return True

    def __len__(self):
        tamanho = self.indice_escrita - self.indice_leitura
        if tamanho < 0:
            tamanho += self.capacidade
        return tamanho

    def __str__(self):
        """Returna a string formatada representando esta lista circular."""
        items = [f"{item!r}" for item in self.buffer if item is not None]
        return '[' + ', '.join(items) + ']'


# Exemplo de funcionamento do nosso buffer circular

buffer_circular = BufferCircular(5)

assert buffer_circular.empty()
print(str(buffer_circular))

assert buffer_circular.push("um")
assert len(buffer_circular) == 1
print(str(buffer_circular))

assert buffer_circular.push("dois")
assert len(buffer_circular) == 2
print(str(buffer_circular))

assert buffer_circular.push("três")
assert len(buffer_circular) == 3
print(str(buffer_circular))

assert buffer_circular.push("quatro")
assert len(buffer_circular) == 4
print(str(buffer_circular))

buffer_circular.pop()
assert len(buffer_circular) == 3

buffer_circular.pop()
assert len(buffer_circular) == 2
print(str(buffer_circular))

assert buffer_circular.push("cinco")
assert len(buffer_circular) == 3
print(str(buffer_circular))

assert buffer_circular.push("seis")
assert len(buffer_circular) == 4
print(str(buffer_circular))

assert buffer_circular.push("sete")
assert len(buffer_circular) == 5
assert buffer_circular.full()
print(str(buffer_circular))

assert buffer_circular.push("oito") == False
assert len(buffer_circular) == 5
assert buffer_circular.full()
print(str(buffer_circular))
[]
['um']
['um', 'dois']
['um', 'dois', 'três']
['um', 'dois', 'três', 'quatro']
['três', 'quatro']
['três', 'quatro', 'cinco']
['três', 'quatro', 'cinco', 'seis']
['sete', 'três', 'quatro', 'cinco', 'seis']
['sete', 'três', 'quatro', 'cinco', 'seis']

Esse foi um exemplo mais longo, mas ilustra aspectos importantes de como implementar classes que se comportam como classes existentes em Python. Ele também provê um exemplo mais real de uma classe com funcionalidades interessantes e que pode ser usada em situações reais.

A seguir, mostraremos como implementar polimorfismo usando sobrecarga de operadores.

Sobrecarga de Operadores e Polimorfismo

Nesta seção iremos implementar uma classe para representar frações, ou números racionais. Gostaríamos que nossa classe se comportasse como as classes numéricas de Python. Por exemplo, se tivermos dois objetos da classe int em Python, podemos somá-los usando o operador . Da mesma forma, gostaríamos de somar duas frações usando o operador . A capacidade de definir um operador já existente para uma nova classe é chamada de sobrecarga de operadores.

À essa altura do campeonato, você já deve ter percebido que Python possui vários métodos para implementar operações nativas à linguagem. Por exemplo, se quisermos imprimir um objeto ao passá-lo como parâmetro para a função print, podemos implementar o método __str__. Grande parte dos métodos que permitem que nossas classes se comportem como objetos nativos do Python possuem nomes que começam e terminam com dois underscores. Você verá pessoas se referindo a esses métodos como “dunder methods” (“dunder” é uma abreviação para “double underscore”).

Nesta seção, precisaremos implementar vários dunder methods para que nossa classe se comporte como uma classe numérica em Python.

Representaremos um número racional (uma fração) armazenando o numerador e o denominador. Para tornar nossa fração mais parecida com as da matemática, armazenaremos a fração em sua forma simplificada, ou seja, dividiremos tanto o numerador quanto o denominador pelo máximo divisor comum entre eles.

Além disso, nossa classe disponibilizará operações para somar, subtrair, multiplicar, dividir, inverter e comparar duas frações. Também implementaremos um método para imprimir uma fração como numerador / denominador. Com exceção do método para inverter a fração, todos os métodos acima serão implementados como dunder methods. Mais detalhes sobre esses métodos podem ser encontrados aqui: https://docs.python.org/pt-br/3/library/operator.html#mapping-operators-to-functions

Vejamos como implementar esta classe para representar frações.

import math

class Rational:
    def __init__(self, numerador, denominador):
        # Nossas frações só funcionam com números inteiros
        if not isinstance(numerador, int):
            raise ValueError(f"Numerador {numerador} não é int")
        if not isinstance(denominador, int):
            raise ValueError(f"Denominador {denominador} não é int")

        if denominador == 0:
            raise ZeroDivisionError("Denominador não pode ser zero")

        # Encontra o fator comum para armazenar a fração simplificada
        fator_comum = math.gcd(numerador, denominador)
        if denominador < 0:
            fator_comum = -fator_comum
        numerador //= fator_comum
        denominador //= fator_comum
        self.numer = numerador
        self.denom = denominador

    def __add__(self, x):
        return Rational(self.numer * x.denom + x.numer * self.denom, self.denom * x.denom)

    def __sub__(self, x):
        return Rational(self.numer * x.denom - self.denom * x.numer, self.denom * x.denom)

    def __mul__(self, x):
        return Rational(self.numer * x.numer, self.denom * x.denom)

    def inversa(self):
        return Rational(self.denom, self.numer)

    def __floordiv__(self, x):
        return self * x.inversa()

    def __eq__(self, x):
        return self.numer == x.numer and self.denom == x.denom

    def __str__(self):
        return f'{self.numer}/{self.denom}'

Na classe acima, armazenamos a fração em sua forma simplificada. Assim, se a fração for 2/10, iremos armazená-la como 1/5. Para fazer isso, precisamos tirar o máximo divisor comum (MDC) do numerador e do denominador e dividir ambos pelo MDC dos dois. No código acima, fazemos isso por meio da função math.gcd, para não reinventar a roda. Mas poderíamos facilmente implementar nossa própria função para fazer isso, como mostrado abaixo:

def mdc(x, y):
    """Retorna o máximo divisor comum de x e y"""
    while y != 0:
        (x, y) = (y, x % y)
    return x

Vejamos agora alguns testes e exemplos de uso da classe acima.

print("Frações com o mesmo denominador")
a = Rational(3, 20)
b = Rational(1, 20)
assert a + b == Rational(1, 10)
assert a - b == Rational(1, 10)
assert a * b == Rational(3, 400)
assert a == Rational(6, 40)
assert b == Rational(10, 200)
assert a.inversa() == Rational(20, 3)
assert b.inversa() == Rational(20, 1)
print("Testes passaram com sucesso!")

print()

print("Frações com denominadores diferentes")
x = Rational(30, 40)
y = Rational(1, 3)
print(f"{x} + {y} = {x + y}")
print(f"{x} - {y} = {x - y}")
print(f"{x} * {y} = {x * y}")
print(f"{x} // {y} = {x // y}")
print(f"{x} == {y} ? {x == y}")
Frações com o mesmo denominador
Testes passaram com sucesso!

Frações com denominadores diferentes
3/4 + 1/3 = 13/12
3/4 - 1/3 = 5/12
3/4 * 1/3 = 1/4
3/4 // 1/3 = 9/4
3/4 == 1/3 ? False

Note que objetos da nossa classe se comportam como tipos numéricos do Python! Isso só é possível porque sobrecarregamos os operadores de soma, subtração, multiplicação, e divisão.

Conclusão

Nesta seção vimos duas formas de implementar polimorfismo em Python: herança e sobrecarga de operadores. Em cada um desses tópicos, vimos exemplos práticos de classes relativamente complexas e aprendemos a tornar nossos objetos mais parecidos com os objetos nativos do Python.

Com isso encerramos nosso curso conteúdo sobre Programação Orientada a Objetos em Python.

Existem muitas outras nuances de Programação Orientada a Objetos em Python, mas o conteúdo visto até aqui te ajudará a entender essas nuances mais facilmente. A melhor forma de exercitar os conceitos visto aqui é praticando. Quando estiver programando, pense em como representar entidades de seu programa por meio de classes e aplique os conceitos vistos aqui. Se você precisar fazer algo mais avançado, consulte a documentação do Python e pratique os novos conceitos que aprender. Assim seu aprendizado será contínuo.