2.1.2. Herança e Polimorfismo

Herança é um mecanismo que permite basear uma classe em outra, mantendo uma implementação similar e formando uma hierarquia de classes. A classe derivada é chamada de subclasse enqunto a classe base é chamada de super classe. Um objeto de uma subclasse mantém todos os atributos e métodos definidos na super classe. O mecanismo de herança é útil quando certos comportamentos (métodos) iguai são esperados para objetos de diferentes tipos ou para facilitar o reuso de código. É importante diferenciar herança de composição de objetos. A composição se dá quando um objeto contém outro(s) objeto(s), ou seja, há uma relação de posse de um objeto para outro.

Vejamos o exemplo abaixo:

import math

class DiscreteDistribution:
    
    def __init__(self, params):
        self.params = params
        
    def pmf(self, x):
        pass
    
    def cdf(self, x):
        total = 0
        for v in range(x + 1):
            total += self.pmf(v)
        return total
    
    def mean(self):
        pass
    
    def variance(self):
        pass
    
class BinomialDistribution(DiscreteDistribution):
    
    def pmf(self, x):
        
        n, p = self.params['n'], self.params['p']
        
        def factorial(n):
            prod = 1
            for i in range(1, n + 1):
                prod *= i
            return prod

        def combination(n, x):
            return factorial(n) / (factorial(x) * factorial(n - x))

        return combination(n, x) * p ** x * (1.0 - p) ** (n - x) 
    
    def mean(self):
        return self.params['n'] * self.params['p']
    
    def variance(self):
        return self.params['n'] * self.params['p'] * (1 - self.params['p'])

class PoissonDistribution(DiscreteDistribution):
    
    def pmf(self, x):
        
        l = self.params['lambda']
        
        def factorial(n):
            prod = 1
            for i in range(1, n + 1):
                prod *= i
            return prod

        return (l ** x * math.e ** (-l)) / factorial(x)
    
    def mean(self):
        return self.params['lambda']
    
    def variance(self):
        return self.params['lambda']

binomial = BinomialDistribution(params={'n': 5, 'p': 0.7})
print('################## Binomial ##################')
print('Média: {}'.format(binomial.mean()))
print('Variância: {}'.format(binomial.variance()))
print('PMF de 2: {}'.format(binomial.pmf(2)))
print('CDF de 2: {}'.format(binomial.cdf(2)))

print()

poisson = PoissonDistribution(params={'lambda': 5})
print('################## Poisson ##################')
print('Média: {}'.format(poisson.mean()))
print('Variância: {}'.format(poisson.variance()))
print('PMF de 2: {}'.format(poisson.pmf(2)))
print('CDF de 2: {}'.format(poisson.cdf(2)))
################## Binomial ##################
Média: 3.5
Variância: 1.0500000000000003
PMF de 2: 0.13230000000000006
CDF de 2: 0.16308000000000009

################## Poisson ##################
Média: 5
Variância: 5
PMF de 2: 0.08422433748856836
CDF de 2: 0.12465201948308118

O código acima define uma classe base, chamada DiscreteDistribution, cujo único atributo se chama params (uma coleção de parâmetros) e cujos métodos incluem o cálculo da função massa de probabilidade, da função distribuição acumulada, da média e da variância. O único método implementado nesta classe é o cálculo da função distribuição e o construtor. Os outros métodos possuem apenas a sua assinatura.

Como subclasses dessa classe base (note o nome da super classe entre parênteses após o nome da subclasse), temos BinomialDistribution e PoissonDistribution. Ambas implementam os métodos pmf, mean e variance de acordo com a distribuição de probabilidade representada. Após declarar as subclasses, o código cria um objeto de cada uma delas e executa seus métodos. Note que, apesar de as subclases não declarararem o método cdf ambos, os objetos podem chamá-lo. Isto ocorre porque as subclasses herdam este método da sua superclasse. Note que, internamente, o método cdf chama o método pmf, cuja implementação ficou sob responsabilidade das subclasses. Isso significa que parte do comportamento do método cdf é modificado pelas implementações das subclasses. Em programação orientada a objetos, esse conceito (comportamentos parcialmente diferentes entre subclasses) é chamado de polimorfismo.

Uma subclasse pode modificar a implementação de um método da superclasse total ou parcialmente. No exemplo abaixo, a subclasse PoissonDistribution irá modificar o método cdf para avisar que o cálculo foi feito usando a função massa da Poisson.

class PoissonDistribution(DiscreteDistribution):
    
    def pmf(self, x):
        
        l = self.params['lambda']
        
        def factorial(n):
            prod = 1
            for i in range(1, n + 1):
                prod *= i
            return prod

        return (l ** x * math.e ** (-l)) / factorial(x)
    
    def cdf(self, x):
        print('Calculado usando pmf da Poisson')
        total = 0
        for v in range(x + 1):
            total += self.pmf(v)
        return total
    
    def mean(self):
        return self.params['lambda']
    
    def variance(self):
        return self.params['lambda']

poisson = PoissonDistribution(params={'lambda': 5})
print('CDF de 2: {}'.format(poisson.cdf(2)))
Calculado usando pmf da Poisson
CDF de 2: 0.12465201948308118

Note que todo o código da implementação do método pmf da superclasse foi copiado e apenas a linha print(‘Calculado usando pmf da Poisson’) foi adicionada. Essa abordagem de codificação resultaria em muitos códigos copiados, o que é uma má prática. Para evitar isso, Python fornece um atalho para reutilizar a implementação de um método da superclasse: o operador super. Veja abaixo:

class PoissonDistribution(DiscreteDistribution):
    
    def pmf(self, x):
        
        l = self.params['lambda']
        
        def factorial(n):
            prod = 1
            for i in range(1, n + 1):
                prod *= i
            return prod

        return (l ** x * math.e ** (-l)) / factorial(x)
    
    def cdf(self, x):
        print('Calculado usando pmf da Poisson')
        return super().cdf(x)
    
    def mean(self):
        return self.params['lambda']
    
    def variance(self):
        return self.params['lambda']

poisson = PoissonDistribution(params={'lambda': 5})
print('CDF de 2: {}'.format(poisson.cdf(2)))
Calculado usando pmf da Poisson
CDF de 2: 0.12465201948308118

Note que a função factorial foi definida duas vezes no nosso código: dentro do método pmf das duas subclasses. Para evitar essa cópia de código, podemos definí-la no escopo global:

def factorial(n):
    prod = 1
    for i in range(1, n + 1):
        prod *= i
    return prod
        
class BinomialDistribution(DiscreteDistribution):
    
    def pmf(self, x):
        
        n, p = self.params['n'], self.params['p']        

        def combination(n, x):
            return factorial(n) / (factorial(x) * factorial(n - x))

        return combination(n, x) * p ** x * (1.0 - p) ** (n - x) 
    
    
    def cdf(self, x):
        print('Calculado usando pmf da Poisson')
        return super().cdf(x)
    
    def mean(self):
        return self.params['n'] * self.params['p']
    
    def variance(self):
        return self.params['n'] * self.params['p'] * (1 - self.params['p'])

class PoissonDistribution(DiscreteDistribution):
    
    def pmf(self, x):
        
        l = self.params['lambda']

        return (l ** x * math.e ** (-l)) / factorial(x)
    
    def mean(self):
        return self.params['lambda']
    
    def variance(self):
        return self.params['lambda']

binomial = BinomialDistribution(params={'n': 5, 'p': 0.7})
print('################## Binomial ##################')
print('Média: {}'.format(binomial.mean()))
print('Variância: {}'.format(binomial.variance()))
print('PMF de 2: {}'.format(binomial.pmf(2)))
print('CDF de 2: {}'.format(binomial.cdf(2)))

print()

poisson = PoissonDistribution(params={'lambda': 5})
print('################## Poisson ##################')
print('Média: {}'.format(poisson.mean()))
print('Variância: {}'.format(poisson.variance()))
print('PMF de 2: {}'.format(poisson.pmf(2)))
print('CDF de 2: {}'.format(poisson.cdf(2)))
################## Binomial ##################
Média: 3.5
Variância: 1.0500000000000003
PMF de 2: 0.13230000000000006
Calculado usando pmf da Poisson
CDF de 2: 0.16308000000000009

################## Poisson ##################
Média: 5
Variância: 5
PMF de 2: 0.08422433748856836
CDF de 2: 0.12465201948308118

2.1.2.1. Herança múltipla

Uma classe pode ser subclasse de várias classes ao mesmo tempo. Para isso, basta declarar os nomes das super classes separados por vírgulas. O código abaixo declara uma classe, chamada Printable, que possui apenas um método str, que retorna os parâmetros do objeto em uma string formatada. O método str é chamado por Python quando um objeto é passado como parâmetro para a função print. Após declarar a classe Printable, as classes das distribuições são declaradas novamente, dessa vez herdando de DiscreteDistribution e de Printable. Note que ambas as classes agora redefinem o construtor, adicionando o atributo name.

class Printable:
    def __str__(self):
        s = self.name + ' com parâmetros '
        for key in self.params:
            s += '{0}: {1} '.format(key, self.params[key])
        return s

class BinomialDistribution(DiscreteDistribution, Printable):
    
    def __init__(self, params):
        self.name = 'Binomial'
        super().__init__(params)
        
    def pmf(self, x):
        
        n, p = self.params['n'], self.params['p']        

        def combination(n, x):
            return factorial(n) / (factorial(x) * factorial(n - x))

        return combination(n, x) * p ** x * (1.0 - p) ** (n - x) 
    
    
    def cdf(self, x):
        print('Calculado usando pmf da Poisson')
        return super().cdf(x)
    
    def mean(self):
        return self.params['n'] * self.params['p']
    
    def variance(self):
        return self.params['n'] * self.params['p'] * (1 - self.params['p'])

class PoissonDistribution(DiscreteDistribution, Printable):
    
    def __init__(self, params):
        self.name = 'Poisson'
        super().__init__(params)        
    
    def pmf(self, x):
        
        l = self.params['lambda']

        return (l ** x * math.e ** (-l)) / factorial(x)
    
    def mean(self):
        return self.params['lambda']
    
    def variance(self):
        return self.params['lambda']

binomial = BinomialDistribution(params={'n': 5, 'p': 0.7})
print(binomial)
poisson = PoissonDistribution(params={'lambda': 5})
print(poisson)
Binomial com parâmetros n: 5 p: 0.7 
Poisson com parâmetros lambda: 5