Python, Classes, variáveis do usuário

Programação Orientada a Objetos

Embora sendo uma linguagem multiparadigma Python tem suporte para a maioria das técnicas empregadas na Programação Orientada a Objetos, POO. Os tipos de dados que encontramos no Python, tais como strings, números inteiros, listas e dicionários são objetos. Eles possuem propriedades e métodos próprios, pré-programados. É frequente, no entanto, que coisas da vida real, sobre as quais queremos usar métodos de computação e análise, exijam uma modelagem mais complexa. Para isso podemos criar objetos que são os tipos definidos pelo usuário, denominados classes. Além de ser uma ferramenta útil para quem cria um programa, essa possibilidade foi explorada por outros programadores que disponibilizam seu código através de módulos disponíveis em um grande número de bibliotecas que expandem o poder do Python.

A programação orientada a objetos faz uso das seguintes técnicas:

  • Encapsulamento de dados : a restrição de que dados e métodos só possam ser acessados de dentro do objeto, com acesso vedado à chamadas externas.
  • Herança : a possibilidade de reutilizar e estender a definição de uma classe, alterando-a para especializações.
  • Polimorfismo : a possibilidade de que várias classes usem os mesmos nomes de métodos gerais.

No Python o encapsulamento de dados não é obrigatório nem automático, mas pode ser implementado. Herança e polimorfismo são partes naturais de sua sintaxe.

Assim como em outras partes desse texto, os exemplos dados são simples e pouco realistas. Casos complexos são compostos de um grande número de partes simples, portanto entender o simples é um grande passo para o gerenciamento dos casos gerais. Além disso nossas classes não são devidamente documentadas para diminuir o tamanho do texto e facilitar a leitura, o que deve ser evitado na prática.

Classes: Tipos de dados criados pelo usuário

Suponha que desejamos elaborar um programa para controle de uma escola. Um elemento básico desse programa seria, por ex., a descrição dos alunos. Um aluno pode ser descrito por uma série de dados de tipos diversos, como uma string para armazenar seu nome, inteiros para sua idade, floats para suas notas, datas para a data de nascimento, etc. É claro que podemos configurar listas ou dicionários complexos que contenham toda essa informação. No entanto temos uma ferramenta mais sofisticada e poderosa: as classes.

Definições:

Uma classe é uma abstração de alguma entidade que se deseja modelar. Um objeto é um caso particular da entidade representada pela classe. Dizemos que o objeto é uma instância da classe. A classe possui atributos que podem ser propriedades ou métodos. Propriedades são o conjunto de valores (dados) que descrevem a entidade. Métodos são funções definidas na classe. Esses métodos contém instruções para as tarefas que esperamos que sejam executadas por aquela entidade.

Os métodos mais frequentes são aqueles que realizam operações CRUD (Create, Read, Update e Delete, ou seja, criar, ler, atualizar e apagar). Mas eles não se retringem a isso e podem fazer operações com os dados armazenados em propriedades, retornar resultados, imprimí-los ou executar qualquer tarefa disponível para a máquina que executa o código. Um objeto instanciado de uma classe herda suas propriedades e métodos.

Uma classe é definida com a palavra chave class seguida de seu nome (por convenção iniciado por maiúscula). A partir da classe objetos são instanciados, ou seja, criados sob o molde da classe. Esses objetos possuem as mesmas propriedades e métodos da classe.

A classe mais simples é aquela que contém sua definição, sem nenhuma propriedade nem método. A palavra chave pass marca a posição, sem executar nenhuma tarefa. Propriedades podem ser inseridas, alteradas e lidas com a notação de ponto, objeto.propridade.

# Uma classe simples
» class Simples:
»     pass

# instanciando um objeto da classe
» s1 = Simples()
# inserindo 2 propriedades
» s1.propriedade1 = 'A solução para a questão da vida, do universo e tudo mais'
» s1.propriedade2 = 42

# examinando o estado das propriedades
» print(s1.propriedade1, s1.propriedade2)
↳ A solução para a questão da vida, do universo e tudo mais 42

# atributos são listados com dir *
» print(dir(s1))
↳ ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', ↳ '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
↳ '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__',
↳ 'propriedade1', 'propriedade2']

# uma propriedade pode ser removida
» del s1.propriedade2

# a classe (modelo para os objetos) pode ser alterada após sua definição
» Simples.valor = 9
» s2 = Simples()
» s2.valor
↳ 9
» s1.valor
↳ 9

Vemos acima que uma alteração na classe gera alterações nas instâncias. *A função dir lista os atributos da classe ou dos objetos, entre elas as que definimos acrescidas de muitas outras geradas automaticamente. Voltaremos a isso mais tarde.

Propriedades podem ser definidas na classe, sendo herdadas por suas instâncias.

» class Aluno():
»     nome = ''
»     nota = 0

# instanciamos 2 objetos da classe
» aluno1 = Aluno()
» aluno2 = Aluno()

# as propriedades pode ser acessadas para leitura e alteração
» aluno1.nome = 'Ana Anésia'
» aluno1.nota = 79

» print(aluno1.nome, aluno1.nota) 
↳ Ana Anésia  79

# a propridade __class__ indice a que classe pertence esse objeto
» aluno1.__class__
» __main__.Aluno

» aluno2.nome = 'Pedro Paulo Passos'
» aluno2.nota = 45

# propriedades podem ser usadas como variáveis
» print('Média dos alunos: ',  (aluno1.nota + aluno2.nota)/2)
↳ Média dos alunos:  62.0

Uma alteração na classe Aluno altera as propriedades dos objetos que ainda não foram modificados e das novas instâncias. Propriedades nas instâncias alteradas após a inicialização não são afetadas.

# uma alteração na classe    
» Aluno.nota = 50
» aluno3 = Aluno()
» print(aluno3.nota)
↳ 50

» Aluno.nota = 65
» print(aluno3.nota)
↳ 65

# propriedades já alteradas não são afetadas
» print(aluno1.nota)
↳ 79


O parâmetro self serve como uma referência para o próprio objeto (como veremos, para cada uma das instâncias da classe) e é usado para acessar os atributos daquele objeto. Qualquer nome de variável pode ser usado, embora seja uma convenção chamá-lo de self. Não é recomendado usar outro nome se queremos que nosso código seja de fácil leitura para outros programadores. Ele deve ser sempre o primeiro parâmetro de todas as funções e mesmo os métodos que não recebem outros parâmetros devem conter self.

Além de propriedades uma classe pode ter métodos que são funções executadas dentro da classe. A classe Calculadora que se segue contém apenas um método e nenhuma propriedade acessada pelo usuário. Ela recebe dois números e o tipo de operação desejada, + – * / e retorna uma string com a operação e seu resultado.

» class Calculadora:
»     def calcular(self, x, y, operacao = '+'):
»         if operacao == '+':   resultado = '%.1f + %.1f = %.1f' % (x, y, x + y)
»         elif operacao == '-': resultado = '%.1f - %.1f = %.1f' % (x, y, x - y)
»         elif operacao == '*': resultado = '%.1f x %.1f = %.1f' % (x, y, x * y)
»         elif operacao == '/':
»             if y == 0:  resultado = 'Erro: divisão por zero!'
»             else:       resultado = '%.1f / %.1f = %.1f' % (x, y, x / y)
»         else:
»             resultado = 'Nenhuma operação conhecida (+ - * /) foi requisitada!'
»         print(resultado)

# instancia um objeto
» calc = Calculadora()
# a operação de soma é default
» calc.calcular(123,123)
↳ 123.0 + 123.0 = 246.0

» calc.calcular(213,432, '-')
↳ 213.0 - 432.0 = -219.0

» calc.calcular(213,43, '*')
↳ 213.0 x 43.0 = 9159.0

» calc.calcular(213,0, '/')
↳ Erro: divisão por zero!

» calc.calcular(213,23, 'r')
↳ Nenhuma operação conhecida foi definida!

# calc é uma variável do tipo
» type(calc)
↳ __main__.Calculadora

Os Métodos __init __() e __str__()

Todas as classes, mesmo que não explicitamente definido, possuem um método chamado __init __ (), que é sempre executado durante sua inicialização. O nome __init__, iniciado e terminado por 2 sublinhados (o que em inglês tem sido chamado de dunder, double underscore), é chamado de construtor da classe.

Ele é usada para fazer as atribuições de valor às propriedades do objeto e executar outras operações que sejam necessárias quando o objeto está sendo criado. É considerada uma boa prática de programação não definir propriedades fora da inicialização, exceto quando propriedades de classe são necessárias. Após a inicialização o método não deve ser usado novamente.

Ao ser instanciado um objeto os parâmetros de __init__ devem ser fornecidos ou um erro é lançado.

» class Aluno:
»     def __init__(self, nome, nota):
»         self.nome = nome
»         self.nota = nota        
    
# Inicializamos um objeto com seus valores iniciais
» aluno1 = Aluno('Joana Paraíba', 67)
» print(a1.nome, a1.nota)
↳ Joana Paraíba  67

# como antes, a propriedade pode ser alterada
» aluno1.nome = 'Joana Pessoa'
» aluno1.nome
↳ 'Joana Pessoa'

# um atributo pode ser removido do objeto
» del aluno1.nome
» print(aluno1.nome)
↳ AttributeError: type object 'a1' has no attribute 'nome'

Após a definição da classe dois objetos foram instanciados: aluno1 e aluno2 têm as mesmas propriedades que a classe Aluno. Essas propriedades podem ser modificadas com a notação de ponto, objeto.propriedade, e acessadas como variáveis comuns, como foi feito no cálculo da média das notas.

Como no caso da calculadora acima, classes contém propriedades e métodos, ou funcionalidades. Métodos do objetos são funções definidas dentro de um objeto. Na nossa classse Aluno, vamos inserir vários métodos: get_sobrenome(), que retorna a última string após partir o nome nos espaços, set_nota() que insere as notas do aluno, get_media() que calcula e retorna a média das 3 notas, e o método especial __str()__ que retorna uma representação de string contendo os dados que julgamos representativos do objeto.

» class Aluno:
»     def __init__(self, nome, nota1=0, nota2=0, nota3=0):
»         self.nome = nome
»         self.nota1 = nota1
»         self.nota2 = nota2
»         self.nota3 = nota3

»     def get_sobrenome(self):
»         return self.nome.split()[-1]

»     def set_nota(self, n, nota):
»         if n==1:
»             self.nota1=nota
»         if n==2:
»             self.nota2=nota
»         if n==3:
»             self.nota3=nota

»     def get_media(self):
»         return (self.nota1 + self.nota2 + self.nota3)/3
        
»     def aprovado(self):
»         return self.get_media() >= 50

»     def __str__(self):
»         txt = 'Nome: ' + self.nome
»         txt += '\nNotas: %2.1f, %2.1f, %2.1f' % (self.nota1, self.nota2, self.nota3) 
»         txt += '\nMédia: %2.1f' % self.get_media()
»         txt +=  '  ** %s **\n' % ('aprovado' if self.aprovado() else 'reprovado')
»         return txt

Acessar um método de instância, objeto.metodo() é o mesmo que chamar o método da classe passando o próprio objeto (a instância) como argumento self.

# instanciando um aluno e suas notas
» a1 = Aluno('Mário Leibniz', 34, 45, 76)
# usar o método de instância
» a1.get_media()
↳ 51.666666666666664

# é o mesmo que chamar o método
» Aluno.get_media(a1)
↳ 51.666666666666664

# 1 aluno inicializado com as notas default    
» pedro = Aluno('Pedro Álvarez')
# pedro tem a propriedade nome
» print(pedro.nome)
↳ Pedro Álvarez

# as notas de pedro podem ser alteradas
» pedro.set_nota(1,84)
» pedro.set_nota(2,89)
» pedro.set_nota(3,97)
# e sua média pode ser calculada
» pedro.get_media()
↳ 90.0

Uma classe pode ter em suas propriedades dados de qualquer tipo. No exemplo seguinte criamos a classe Escola contendo apenas nome e uma lista de alunos, inicialmente por uma lista vazia e depois preenchida com objetos da classe Aluno.

# usando 2 alunos inicializados
» a1 = Aluno('Mário Leibniz', 34, 45, 76)
» a2 = Aluno('Joana Frida', 10, 28, 16)    
    
# definimos a class Escola
» class Escola:
»     def __init__(self, nome):
»         self.nome = nome
»         self.alunos = []

»     def insere_aluno(self, aluno):
»         self.alunos.append(aluno)

»     def __str__(self):        
»         txt = 'Nome: ' + self.nome
»         txt += '\nTem %d alunos' % len(self.alunos)
»         txt += '\nAlunos:\n'
»         for a in self.alunos:
»             txt += '-' * 38 + '\n'
»             txt += str(a)
»         return txt    

# criamos uma instância da classe
» escola1 = Escola('Caminhos da Luz Albert Einstein')

# inserimos os dois alunos já definidos
» escola1.insere_aluno(a1)
» escola1.insere_aluno(a2)

# usamos o método Escola.__str__ para listar o estado de escola1
» print(escola1.__str__())
↳ Nome: Caminhos da Luz Albert Einstein
↳ Tem 2 alunos
↳ Alunos:
↳ --------------------------------------
↳ Nome: Mário Leibniz
↳ Notas: 34.0, 45.0, 76.0
↳ Média: 51.7   **aprovado**
↳ --------------------------------------
↳ Nome: Joana Frida
↳ Notas: 10.0, 28.0, 16.0
↳ Média: 18.0   ** reprovado**

Embora não obrigatório, é uma convenção útil usar o nome da classe com primeira letra maiúscula. Esse código define a classe Aluno que tem as propriedades nome, nota1, nota2 e nota3, e os métodos set_nota(), que atribue valor a uma das 3 notas; get_sobrenome(), que retorna o último nome registrado; get_media(), que retorna a média das 3 notas; aprovado() que retorna um booleano (False ou True) informando se a aluno foi aprovado. O conjunto das propriedades de um objeto consistem em seu estado. Métodos são as funções que o objeto pode executar, em geral associadas ao seu estado.

Um método retorna um valor de tipo especificado ou None se é terminado por return vazio ou mesmo sem nenhum return.

Nas classes Aluno e Escola usamos também o método especial __str__(self) que retorna uma representação de string do objeto. Ele definido internamente de modo a ser acionado quando fazemos uma chamada a print(objeto). São equivalentes as instruções:
print(objeto.__str__()), print(str(objeto))print(objeto).

» print(pedro)        # ou print(str(pedro)) ou  print(pedro.__str__())
↳ Nome: Pedro Álvarez
↳ Notas: 84.0, 89.0, 97.0
↳ Média: 90.0

# idem
» print(escola1)
↳ Nome: Caminhos da Luz Albert Einstein
↳ Tem 2 alunos
↳ Alunos:
↳ --------------------------------------
↳ Nome: Mário Leibniz
↳ Notas: 34.0, 45.0, 76.0
↳ Média: 51.7   **aprovado**
↳ --------------------------------------
↳ Nome: Joana Frida
↳ Notas: 10.0, 28.0, 16.0
↳ Média: 18.0   ** reprovado**

Vemos, dessa forma, que uma classe é um modelo geral que pode ser usado para a criação de várias casos particulares dos objetos que ela modela. Uma instância da classe, como pedro, tem todas as propriedades e métodos da classe.

Encapsulamento

Uma discussão mais completa de escopo e namespaces pode ser lida na seção Variavéis de Classe e de Instância e no artigo seguinte, neste site, Escopos e namespaces.

Encapsulamento é uma forma de trabalhar com uma variável dentro de um bloco, como um objeto ou função, sem tornar aquela variável disponível para o restante do código. Isso pode ser necessário por motivos de segurança ou para realizar uma verificação antes que uma alteração seja feita a essa variável. Por ex., se um campo de CPF vai ser modificado é sempre bom testar se um número válido está sendo inserido.

Se uma propriedade é definida no método de inicialização com dois sublinhados como __variavel ela não pode ser acessada fora do objeto diretamente por objeto.__variavel. Nesse caso métodos devem ser definidos para o seu acesso. Métodos privados, construídos com a mesma técnica, também só podem ser acessados de dentro da classe.


A classe seguinte representa uma porta que pode estar fechada e trancada. Métodos são definidos para abrir, fechar, trancar, destrancar a porta. Todos eles verificam o parâmetro fornecido em relação ao estado da porta para decidir qual é procedimento correto. Por ex., uma porta trancada não pode ser aberta. (Claro que uma classe mais enxuta poderia ser escrita mas seria, provavelmente, um pouco mais díficil de ler. Da mesmo forma o método __geraTexto() está aí apenas para demonstrar o funcionamento da função privada, pois poderia ser incorporado ao __str__().)

» class Porta:
»     def __init__(self, fechada, trancada):
»         self.__fechada = trancada or fechada
»         self.__trancada = trancada
 
»     def abre(self):
»         if self.__trancada:
»             print('A porta está trancada e não pode ser aberta!')
»         else:
»             print('A porta foi aberta!' if self.__fechada else 'A porta já está aberta!')
»             self.__fechada = False
 
»     def fecha(self):
»         print('A porta já está fechada!' if self.__fechada else 'A porta foi fechada!')
»         self.__fechada = True
 
»     def tranca(self):
»         if not self.__fechada:
»             print('A porta está aberta, não pode ser trancada!')
»         else:
»             print('A porta já está trancada!' if self.__trancada else 'A porta foi trancada!')
»             self.__trancada = True        
 
»     def destranca(self):
»         print('A porta foi destrancada!' if self.__trancada else 'A porta já está destrancada!')
»         self.__trancada = False
 
»     def __geraTexto(self):
»         txt = 'A porta está ' + ('fechada' if self.__fechada else 'aberta')
»         txt += (' e trancada' if self.__trancada else ' mas destrancada') if self.__fechada else ''
»         return txt
     
»     def __str__(self):
»         return self.__geraTexto()


Na inicialização a linha self.__fechada = trancada or fechada impede que o estado da porta seja definida como aberta e trancada simultaneamente. Lembrando, foi usada a forma de if else ternário: variavel = valor1 if condicao else valor2, que significa que variavel assume o valor1 se condicao for verdadeira, caso contrário, o valor2.

Observe também que nos métodos, como em destranca(), a mensagem foi gerada antes da troca de valor do estado, pois depende do valor antigo e não no novo.

Agora podemos inicializar um objeto porta e interagir com seus métodos (mas não com seus atributos diretamente). Os atributos privados não podem ser acessados diretamente, nem o método __geraTexto() que só pode ser chamado de dentro da classe, no caso de __str__.

# um atributo privado não pode ser acessado diretamente, nem o método __geraTexto()
» print(porta.__fechada)
↳ AttributeError: 'Porta' object has no attribute '__fechada'

» porta = Porta(True, True)
» print(porta)
↳ A porta está fechada e trancada

» porta.abre()
↳ A porta está trancada e não pode ser aberta!

» porta.destranca()
↳ A porta foi destrancada!

» porta.abre()
↳ A porta foi aberta!

» print(porta)
↳ A porta está aberta

» porta.tranca()
↳ A porta está aberta, não pode ser trancada!

» print(porta)
↳ A porta está aberta

Com encapsulamento conseguimos fazer com que o efeito de um método ou a atribuição de valores às propriedades seja dependente do estado do objeto. É o caso do método porta.abre() que impede que a porta seja aberta se estiver trancada.

A interface de uma classe ou de um módulo são as partes expostas ao usuário do objeto, sejam dados ou métodos.

O encapsulamento favorece a criação de classes que expõe uma interface simples com o código externo (e portanto para o programador), sem a necessidade de exibir possíveis complicações internas. Isso torna esse código mais fácil de reutilizar, em acordo com o princípio DRY do Python (don’t repeat yourself). Ele contribui para a legibilidade do código, pois expõe com clareza o que deve ser fornecido e o que pode ser extraído na classe, impedindo o acesso acidental à informações que não se pretendia tornar acessíveis.

Herdando propriedades e métodos

Além de permitir a criação de objetos como instâncias dessa classe, também é possível construir outras classes herdando e modificando suas propriedades e métodos. Isso é útil quando se quer escrever uma classe que é uma especialização de outra já definida. Quando uma classe herda de outra ela contém todos os métodos e propriedades da primeira classe e pode ter seus atributos alterados e ampliados.

A sintaxe para criar classes derivadas de outra já existente é a seguinte:

# ex. 1: herdando de classe base
» class NomeDaClasseFilha(NomeDaClasseBase):
»     <propriedades e métodos modificados>

# ex. 2: Classe base em outro módulo        
» class NomeDaClasseFilha(Modulo.NomeDaClasseBase):
»     <propriedades e métodos modificados>

Todas as classes herdam de alguma outra. Se NomeDaClasseBase não é especificado a classe herda de object. A classe básica deve estar no mesmo escopo que sua derivada ou, caso contrário, seu módulo deve ser citado, como no exemplo 2.

Na modelagem das pessoas associadas à uma escola encontramos funcionários, professores e alunos. Todos eles partilham características que reuniremos na classe Pessoa.

» class Pessoa(object):
»     def __init__(self, nome, cpf):
»         self.nome=nome
»         self.cpf=cpf
         
»     def __str__(self):
»         return 'Nome: %s; CPF: %s' %(self.nome, self.cpf)
 
» p1 = Pessoa('Ricardo Dalquins','123-456-789-00')
» print(p1)
↳ Nome: Ricardo Dalquins; CPF: 123-456-789-00

As declarações class Pessoa(object):, class Pessoa(): e class Pessoa: são equivalentes.

Os alunos, além de serem pessoas, possuem características especificas como número de matrícula, notas, situação de regularidade quanto às mensalidades, etc. Para demonstrar a criação de uma subclasse usaremos apenas a matrícula.

# definindo uma subclasse    
» class Aluno(Pessoa):
»     def __init__(self, nome, cpf, matricula):
»         super().__init__(nome, cpf)
»         self.matricula = matricula
 
»     def __str__(self):
»         txt =  'Nome: %s \nCPF: %s' %(self.nome, self.cpf)
»         txt += '\nMatricula ' + self.matricula
»         return txt

# inicializando instância da subclasse Aluno
» p2 = Aluno('Uiliam Cheiquispir', '321-654-987-55', '321.654')
 
» print(p2)
↳ Nome: Uiliam Cheiquispir 
↳ CPF: 321-654-987-55
↳ Matricula 321.654

super() é uma função especial que faz a conexão entre a classe mãe e a filha. Ela representa a classe mãe, de onde a classe atual é derivada. Desta forma super().__init__() chama a inicialização de Pessoa e preenche os atribulos nela existentes. O nome super vem da convenção muito usada de chamar a classe mãe de superclasse e a filha de subclasse. A subclasse, por sua vez, pode servir de base para a geração de outra subclasse, abaixo dela. Encontramos também a nomeclatura classe base e classe derivada.

Vamos lembrar que print(p2) aciona o método __str()__ que existe na super e na subclasse. Como ele foi definido na subclasse Aluno este método é executado e o método na superclasse é ignorado. Esse processo, de subreescrever o método da superclasse se chama overriding.

Na prática, quando tentamos modelar um objeto de uso do mundo real, é muito possível que o número de atributos se torne muito grande gerando classes longas e de difícil manuseio. Nesses casos pode ser interessante a criação de classes auxiliares que são usadas como variáveis dentro da classe principal. No caso dos alunos o tratamento de suas notas pode ser gerenciado à parte.

# define uma classe para as notas de um aluno    
» class Nota:
»     def __init__(self, matricula):
»         self.notas = []
»         self.matricula = matricula
»         
»     def insereNota(self,nota):
»         self.notas.append(nota)
»         
»     def getMedia(self):
»         return sum(self.notas)/len(self.notas)
» 
# instancia objeto
» n2 = Nota('321.654')
# insere 3 notas
» n2.insereNota(89)
» n2.insereNota(78)
» n2.insereNota(90)

# acessa propriedade de n2
» n2.getMedia()
↳ 85.66666666666667

É claro que na caso real deve haver um tratamento para evitar erros e conflitos. Se o método Nota.getMedia() for chamado antes da inserção de uma nota um erro será lançado. Isso pode ser contornado capturando essa exceção ou com uma modificação do código.

# modificando o método getMedia()
»     def getMedia(self):
»         if not self.notas:
»             return 0
»         else:
»             return sum(self.notas)/len(self.notas) 

if not self.notas retorna True se a lista estiver vazia. É o mesmo que if len(self.notas)==0 ou simplesmente if len(self.notas) uma vez que if 0 retorna False.

Modificamos agora a classe Aluno para conter uma propriedade Aluno.notas que armazena um objeto tipo Nota que contém a matricula do alunos e uma lista com suas notas.

# Altera a classe aluno para usar a classe Nota
» class Aluno(Pessoa):
»     def __init__(self, nome, cpf, matricula):
»         super().__init__(nome, cpf)
»         self.matricula = matricula
»         self.notas = Nota(matricula)

»     def __str__(self):
»         txt =  'Nome: %s \nCPF: %s' %(self.nome, self.cpf)
»         txt += '\nMatricula ' + self.matricula
»         txt += '\nMédia de Notas: %.1f' % self.notas.getMedia()
»         return txt

# cria um aluno específico
» bob = Aluno('Roberto Gepeto', '741-852-963-55', '654.987')
# insere suas notas
» bob.notas.insereNota(86)
» bob.notas.insereNota(88)
» bob.notas.insereNota(87)

# acessa bob.__str__() 
» print(bob)
↳ Nome: Roberto Gepeto 
↳ CPF: 741-852-963-55
↳ Matricula 654.987
↳ Média de Notas: 87.0

Na linha bob.notas.insereNota(86) estamos acessando o objeto bob.notas que é uma instância de Nota e que, portanto, possui o método insereNota().

Polimorfismo e Overloading

A palavra polimorfismo significa literalmente “muitas formas”. No Python polimorfismo significa que uma função pode receber parâmetros de tipos diferentes e ainda assim agir como esperado com esses tipos. A função len() por ex. pode receber parâmetros diversos desde que sejam sequências (string, bytes, tupla, lista or range) ou a coleções (dicionário, conjunto, …). Idem, o operador + pode agir sobre objetos de tipos diferentes, gerando resultados diferentes.

# string
» len('Entrou mudo e saiu calado.')
↳ 26
# lista
» len([12,23,34,45,56,67])
↳ 6
# dicionário
» len({1:'1', 2:'2'})
↳ 2

# operador "+"
» 345 + 543
↳ 888
» 'ama' + 'ciante'
↳ 'amaciante'

Classes diferentes podem ter métodos com o mesmo nome. Ao ser chamado em um objeto o método específico de sua classe é executado.

» class Cavalo:
»     def som(self):
»         print('relincha')
 
» class Galinha:
»     def som(self):
»         print('cacareja')
 
» giselda = Galinha()
» corisco = Cavalo()

» print(giselda.som())
↳ cacareja

» print(corisco.som())
↳ relincha       

Overloading de operadores

Operadores pré-definidos em tipos criados pelo usuário, tal como o operador de soma + podem ser sobrescritos, overloaded (ou sobrecarregados).

No exemplo seguinte, definimos uma classe para um “vetor” com dois componentes, (x, y) e a soma de dois vetores, compatível com a soma matemática de vetores, que consiste em somar os componentes x, y, separadamente de cada vetor, e retornar outro vetor (que é instanciado dentro do método __add__().
Para definir essa soma sobreescrevemos o método __add__() que é internamente usado quando se opera com +. Observe que na definição de __add__() nos referimos ao segundo operando da soma como other (outro). Se esta definição não for feita explicitamente a soma entre dois vetores não estará definida.

Também inserimos o overloading do método __eq__ que define como se testa dois objetos para a igualdade, de forma que dois vetores sejam considerados iguais se seus dois componentes são iguais.

# nova definição de vetor
» class Vetor:
»     def __init__(self, x, y):
»         self.x = x
»         self.y = y
     
»     def __add__(self, other):
»         return Vetor(self.x + other.x, self.y + other.y)
 
»     def __eq__(self, other):
»         return self.x == other.x and self.y == other.y
 
»     def __str__(self):
»         return 'Objeto da Classe Vetor: (%d, %d)' % (self.x, self.y)
 
# instanciamos 2 vetores
» v1 = Vetor(34,74)
» v2 = Vetor(16, 26)

# somamos e exibimos a soma
» v3 = v1 + v2
» print(v3)
↳ Objeto da Classe Vetor: (50, 100)

# v3 é uma instância de Vetor
» isinstance(v3, Vetor)
↳ True

# para verificar o teste de igualdade
» v3 = Vetor(3,7)
» v4 = Vetor(3,7)
» print(v3 == v4)
↳ True

A classe Vetor, definida dessa forma, mostra que um objeto pode retornar outro objeto de seu próprio tipo. De fato funções e métodos podem receber e retornar parâmetros que são objetos de qualquer tipo, inclusive os tipos de usuário. A função interna isinstance(objeto, classe) retorna True se objeto é membro da classe, caso contrário retorna False.

Um outro exemplo mostra o overloading de len().

# controle de compras online
» class Compra:
»     def __init__(self, cesta, usuario):
»         self.cesta = cesta
»         self.usuario = usuario
        
»     def __len__(self):
»         return len(self.cesta)

» compra = Compra(['sapato', 'camisa', 'gravata'], 'Pedro Paulo Pizo')
» print('%s possui %d itens em sua cesta' % (compra.usuario, len(compra)))
↳ Pedro Paulo Pizo possui 3 itens em sua cesta

Observe que o “comprimento” de um objeto de Compra não estaria definido se não inseríssemos a definição de __len__() na classe.

Para fazer overloading em uma classe (ou função) definida pelo usuário é necessário testar que tipo de argumento foi passado para a função. No caso de class Ola o método trata diferentemente argumentos passados como None (o que ocorre se o parâmtro for omitido) ou passados como string.

# overloading na classe
» class Ola:
»     def digaOla(self, nome=None):
»         if nome is None:
»             print('Insira o seu nome ')
»         else:
»             print('Olá ' + nome)

» o1 = Ola()
» o1.digaOla()
↳ Insira o seu nome 

» o1.digaOla('Olavo')
↳ Olá Olavo

isinstance() foi usada na classe MontaTexto que espera receber uma string com itens separados por ; ou uma lista. Ela trata cada uma delas de modo diferente. Naturalmente este requisito deveria estar documentado junto à definição da classe.

» class MontaTexto:
»     def montaTexto(self, obj):
»         conta = 0
»         txt = ''
»         if isinstance(obj, str):
»             partes = obj.split(';')
»         elif isinstance(obj, list):
»             partes = obj
»         for item in partes:
»             conta +=1
»             txt += 'Item %d: %s\n' % (conta, item)
»         print(txt)            
    
» txt = ['abóbora', 'brócolis', 'couve', 'alface']
» texto = MontaTexto()
» texto.montaTexto(txt)
↳ Item 1: abóbora
↳ Item 2: brócolis
↳ Item 3: couve
↳ Item 4: alface

» txt = 'lâmpada;fio;alicate;parafuso'
» texto.montaTexto(txt)
↳ Item 1: lâmpada
↳ Item 2: fio
↳ Item 3: alicate
↳ Item 4: parafuso

Polimorfismo


Em termos de classes, polimorfismo signica que podemos, em uma subclasse, usar o mesmo nome de método que já existe na superclasse, alterando seu comportamento. Uma subclasse é sempre um caso particular da superclasse e, portanto, possui características comuns com ela (ou não seria boa ideia herdar da classe mãe). No entanto ela pode modificar as faixas válidas de valores das propriedades e o resultado da atuação de métodos, além de inserir novos atributos. Essa tipo de herança com customização favorece a reutilização de código pronto, alterando classes prontas para especializá-las ao caso desejado.

Considerando as classes herdadas de superclasses, polimorfismo signica que ao chamar um atributo em um objeto da classe 3 (na figura x) esse atributo será procurado primeiro em (3), depois em (2) e finalmente em (1), e ler ou executar o que encontrar primeiro.

Uma classe pode herdar de mais de uma classe, carregando os atributos de ambas.

# define classes C1 e C2
» class C1:
»     c0='C1.c0'
»     c1='C1.c1'
    
» class C2:
»     c0='C2.c0'
»     c2='C2.c2'
»     c3='C2.c3'
   
# define classe C3 que herda de C1 e C2
» class C3(C1, C2):    
»     c3='C3.c3'
 
# instancia objeto de C3 e imprime suas propriedades
» obj3 = C3()
» print('%s - %s - %s - %s' % (obj3.c0, obj3.c1, obj3.c2, obj3.c3))
↳ C1.c0 - C1.c1 - C2.c2 - C3.c3

Como se vê no resultado impresso, a variável c0, que não existe em C3 foi lida em C1 pois essa classe está inserida antes (à esquerda) de C2.

No código abaixo queremos modelar as pessoas que trabalham em uma empresa. A classe mais geral, Pessoa, possui atributos que todas as pessoas na empresa (ou fora dela) possuem. O único método de nosso exemplo é __str__().

Dessa classe se deriva Funcionario que tem a propriedade extra, salario, e um método, dar_aumento(porcento). Funcionario.__str__() busca a representação de string de super() e acrescenta a informação do salário.

A última classe é um caso especial de funcionário que são os gerentes. A classe Gerente, além de receber os aumentos regulares de todos os funcionários recebe um bônus. O método dar_aumento(porcento, bonus) sobreescreve o método de mesmo nome na superclasse para adicionar um bônus. Para isso ele primeiro dá o aumento regular de todos os funcionários, super().dar_aumento(porcento) (que altera o self.salario) para depois somar a ele o bônus.

# define uma classe geral
» class Pessoa:
»     def __init__(self, nome, cpf):
»         self.nome = nome
»         self.cpf = cpf
»     def __str__(self):
»         return 'Nome: %s\nCPF: %s' % (self.nome, self.cpf)

» p1 = Pessoa('Maria Quiri', '555-444-888-99')

» print(p1)
↳ Nome: Maria Quiri
↳ CPF: 555-444-888-99
 
# define classe especializada de Pessoa
» class Funcionario(Pessoa):
»     def __init__(self, nome, cpf, salario):
»         super().__init__(nome, cpf)
»         self.salario = salario
»     def dar_aumento(self, porcento):
»         self.salario *= (1 + porcento/100)

»     def __str__(self):
»         return '%s \nSalário: %.2f '  % (super().__str__(), self.salario)

» f1 = Funcionario('Joana Darcos', '111-222-333-44', 1000)

» print(f1)
↳ Nome: Joana Darcos
↳ CPF: 111-222-333-44 
↳ Salário: 1000.00

» f1.dar_aumento(10)
» print(f1)
↳ Nome: Joana Darcos
↳ CPF: 111-222-333-44 
↳ Salário: 1100.00 

# define um tipo especial de Funcionario
» class Gerente(Funcionario):
»     def __init__(self, nome, cpf, salario):
»         super().__init__(nome, cpf, salario)
     
»     def dar_aumento(self, porcento, bonus):
»         super().dar_aumento(porcento)
»         self.salario += bonus

» g1 = Gerente('Isaque Nilton','999-888-777-22',2000)

» print(g1)
↳ Nome: Isaque Nilton
↳ CPF: 999-888-777-22 
↳ Salário: 2000.00 

» g1.dar_aumento(10, 800)
» print(g1)
↳ Nome: Isaque Nilton
↳ CPF: 999-888-777-22 
↳ Salário: 3000.00 

Como vemos, classes podem herdar atributos de uma superclasse, alterá-los para seu funcionamento específico ou inserir novos atributos.

Frequentemente esses dados, no caso de uma empresa real, estão gravados em bancos de dados que são lidos e armazenados nas propriedades dessas classes. Também existem formas de gravar o estado de um objeto em disco para reutilização posterior, como veremos.

Importando Classes

Uma vez que as classes estão bem definidas e funcionando adequadamente, temos a opção de armazená-las em módulos e utilizá-las como fazemos com qualquer outra classe importada do Python. Podemos gravar um arquivo com o nome escola.py que contém as definições das classes Pessoa, Aluno e Nota já definidas.

# arquivo escola.py    
» class Pessoa:
»     def __init__(self, nome, cpf):
»         self.nome=nome
»         self.cpf=cpf
        
»     def __str__(self):
»         return 'Nome: %s; CPF: %s' %(self.nome, self.cpf)

# classe aluno
» class Aluno(Pessoa):
»     def __init__(self, nome, cpf, matricula):
»         super().__init__(nome, cpf)
»         self.matricula = matricula
»         self.notas = Nota(matricula)
        
»     def __str__(self):
»         txt =  'Nome: %s \nCPF: %s' %(self.nome, self.cpf)
»         txt += '\nMatricula ' + self.matricula
»         txt += '\nMédia de Notas: %.1f' % self.notas.getMedia()
»         return txt

# classe nota
» class Nota:
»     def __init__(self, matricula):
»         self.notas = []
»         self.matricula = matricula
        
»     def insereNota(self,nota):
»         self.notas.append(nota)

»     def getMedia(self):    
»         if not self.notas:
»             return 0
»         else:
»             return sum(self.notas)/len(self.notas)         

Para usar essas classes temos que importar o módulo e as classes necessárias. Para a importação temos que fornecer o caminho completo de onde está o módulo, caso ele não esteja na pasta em uso ou no PATH do Python.

# importa módulo e classes
from escola import Aluno
# inicializa aluno
» novoAluno = Aluno('Homero Poeta', '234-456-656-56', '346.345')
#insere suas notas
» novoAluno.notas.insereNota(34)
» novoAluno.notas.insereNota(45)
» novoAluno.notas.insereNota(41)

» print(novoAluno)
↳ Nome: Homero Poeta 
↳ CPF: 234-456-656-56
↳ Matricula 346.345
↳ Média de Notas: 40.0

A importação de Aluno tornou disponível Pessoa e Nota que são por ela utilizados, uma vez que estão todas no mesmo escopo, de modo que não é necessário importar as 3 classes.

Você pode importar todos as classes de um módulo simultaneamente de duas formas.

» from escola import *
» import escola
» novoAluno = escola.Aluno('Homero Poeta', '234-456-656-56', '346.345')

O primeiro método apenas estabelece um caminho de busca para as classes usadas, e não onera o código em termos de memória, durante sua execução. No entanto ele não é recomendado pois é útil poder ver nas primeiras linhas do código as classes que serão usadas. Também, ele pode gerar confusão com os nomes do código e os do módulo caso você importe outro módulo com nomes coincidentes. Isso pode gerar erros difíceis de serem encontrados e corrigidos.

A segunda abordagem é mais interessante: o módulo é importado sem menção às classes ou funções usadas. Em seguida classes e funções são acessadas como componentes desse módulo.

# importamos o modulo que contém classe nomeDaClasse e função fc()
» import modulo

# classes e funções são chamadas
» objeto = modulo.nomeDaClasse
» h = modulo.fc(parametro)


Desta forma os nomes de classes não estarão listados no topo do código mas aparecem claramente no corpo do programa, onde as classes e funções são usadas.

Se o número de classes for muito grande elas podem ser gravadas em arquivos diferentes e importadas dentro do módulo que necessita de outro módulo. Suponha que gravamos Aluno e Pessoa nos arquivos aluno.py e pessoa.py. Como a classe Aluno necessita de Pessoa para sua definição ela deve conter em seu cabeçalho a linha import pessoa from Pessoa.

Gravar os módulos e suas classes em arquivos separados é uma maneira eficaz de manter um código limpo e mais fácil de gerenciar. Cada classe carrega a lógica relativa aquele objeto e o programa central fica encarregado de juntar as partes e executar a lógica principal.


No Jupyter Notebook podemos reinicializar o kernel limpando todas as definições prévias. Para isso selecione menu | Kernel | Restart ou pressione 0, 0 na célula, modo de controle.Além disso podemos escrever o nome na variável seguido de um ponto, e apertar tab. Isso faz com com métodos e propriedades sejam exibidos, como mostrado na figura.

Variavéis de Classe e de Instância

Getters e Setters

Métodos que visam ler o estado de alguma propriedade do objeto, sem realizar alterações, são denominados getters (assessors ou leitores). Já os que alteram propriedades são setters, (mutators ou definidores).

Diferente de outras linguagens POO, variáveis ou métodos no Python não podem ser declaradas como públicas ou privadas, que são aqueles que só podem ser acessados de dentro da classe. Depois de definido em objeto instância de Aluno (da classe já definida) podemos acessar suas propriedades e métodos livremente no objeto. Podemos definir ana.nome='Ana Marta' e recuperar esse nome através de print(ana.nome).

Apesar disso há uma convenção seguida pela maioria dos programadores: um nome (de variável ou método) prefixado com um sublinhado (como _variavel) deve ser tratado como parte não pública daquele bloco de código que pode ser uma função, um método ou uma classe. Além disso um duplo sublinhado (como __variavel) tem o efeito de tornar privada aquela variável. Sublinhados possuem signficados especiais no Python, que já exploraremos.

Como vimos, existe no Python o chamado ocultamento de nome (name muting) que permite a definição de nomes válidos para membros de uma classe privada, o que é útil para evitar conflitos de nomes com outros nomes definidos em subclasses (classes que se baseiam nesta para sua construção). Um identificador (nome de variável ou função) com dois sublinhados iniciais (pode ter um sublinhado final), como por ex. __nome é substituído por _nomeDaClasse.__nome, onde nomeDaClasse é o nome da classe onde está o objeto, com um sublinhado removido. Esse ocultamento é útil para permitir que as subclasses sobrescrevam (override) os métodos sem impedir que os métodos da classe mãe continuem funcionando.

Vejamos o exemplo seguinte para entender como esse duplo sublinhado funciona:

» class Publico:
»     __privadaDeClasse = 66
»     def __init__(self):
»         self.__privadaDeInstancia = 17;

»     def __metodoPrivado(self):
»         print('Dentro do método privado')

»     def metodoPublico(self):
»         print('Essa classe tem uma variável privada com valor: ', self.__privadaDeInstancia)

»     def getPrivada(self):
»         print(self.__privadaDeClasse)

»     def setPrivada(self, k):
»         self.__privadaDeClasse = k

# instancia um objeto da classe
» caso = Publico()

# usando um método público
» caso.metodoPublico()
↳ Essa classe tem uma variável privada com valor:  17

# tentativa de usar um método privado
» caso.metodoPrivado()
↳ AttributeError: 'Publico' object has no attribute 'metodoPrivado'

# tentativa de ler diretamente uma varável privada
» caso.__privadaDeInstancia
↳ AttributeError: 'Publico' object has no attribute '__privadaDeInstancia'

# a variável privada só pode ser acessada de dentro do objeto
» caso.getPrivada()
↳ 66

# para alterar a variável privada usamos método público
» caso.setPrivada(9)
» caso.getPrivada()
↳ 9

A propriedade __privadaDeClasse, além se ser privada, é chamada de variável de classe. Embora ela possa ser alterada após a criação todos os objetos instanciados terão esse valor inicial. Da mesma forma métodos privados só podem ser chamados de dentro do objeto. Dessa forma se impede que partes do código não sejam expostas ao módulo mais geral. Essas partes não aparecem nos chamados à help(), nem nas caixas dropdown do Jupyter Notebook. Ao atribuir um valor a uma variável se pode, por ex., fazer testes de verificação se um tipo correto foi enviado e se o valor está dentro da faixa esperada, enviando mensagens apropriados de erro, quando for o caso.

A classe a seguir define uma variável quantos que pertence à definição da classe. Ela pode ser alterada e todo objeto instanciado a partir dela terá essa mesma propriedade. Já a variável id pertence à cada objeto individual e não é compartilhada com outros membros da classe.

Essa classe está definida da seguinte forma: a cada nova inicialização, que corresponde à inserção de um novo funcionário, a variável quantos é incrementada de 1, e a variável id copia esse valor. Na criação de novo Funcionario o atributo de classe quantos é alterado para todos os funcionários, enquanto seu id permanece o mesmo.

Observe que, dentro de __init__, a referência foi feita à Funcionario.quantos e self.id respectivamente. Lembrando, self é uma forma de referenciar o objeto instância da classe.

# declare uma classe
» class Funcionario:
»     # atributo de classe
»     quantos = 0
    
»     def __init__(self):
»         Funcionario.quantos += 1
»         self.id = Funcionario.quantos
    
»     def __str__(self):
»         return 'Funcionário %d de %d' % (self.id, Funcionario.quantos) 

A variável quantos é de classe, enquanto id é uma variável de instância.

O método __self__() retorna uma string com o id do funcionário e o número de cadastrados.

# cria um Funcionario
» f1 = Funcionario()
# usa a função exibir() para ver atributos de instância e de classe
» print(f1) 
↳ Funcionário  1 de 1

# novos funcionários
» f2 = Funcionario()
» f3 = Funcionario()

# agora existem 3 funcionários
» print(f2) 
↳ Funcionário  2 de 3

» print(f3) 
↳ Funcionário  3 de 3

# o estado de f1 foi alterado
» print(f1)
↳ Funcionário  1 de 3

# o estado da classe também foi alterado
» Funcionario.quantos
↳ 3

Esse exemplo também ilustra o fato de que uma variável de classe pode ser acessada e modificada de dentro de cada instância. A modificação da classe geradora modifica suas instâncias, o que mostra que a execução de cada instância acessa o codigo da classe.

Iteradores

Já vimos que objetos que são sequências e coleções podem ser lidos iterativamente com o uso do operador for.

» lista = ['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff']
» for t in lista:
»     print(t, end=' < ')
↳ Aa < Bb < Cc < Dd < Ee < Ff <

Laços desse tipo são claros e concisos. Por trás desse resultado simples, a instrução for chama a função iter() na sequência (ou coleção) que retorna um objeto iterador. Dentro do iterador existe o método __next__() que acessa os elementos no contêiner um de cada vez. Ao final da iteração, quando se extinguem os elementos, __next__() levanta uma exceção StopIteration que é reconhecida pelo laço como o fim da iteração. O método __next__() pode ser chamado através da função interna next(), como mostra o exemplo:

» iterador = iter('OMS')
» print(next(iterador))
» print(next(iterador))
» print(next(iterador))
» print(next(iterador))
↳ O
↳ M
↳ S
↳ StopIteration:

Podemos tornar uma classe iterável inserindo nela os métodos __iter__() e next(). No caso abaixo a classe simplesmente retorna a sequência invertida, do último elemento para o primeiro

» class Inverter:
»     def __init__(self, data):
»         self.data = data
»         self.index = len(data)

»     def __iter__(self):
»         return self

»     def __next__(self):
»         if self.index == 0:
»             raise StopIteration
»         self.index -= 1
»         return self.data[self.index]

# inv é Inverter usando uma lista como argumento
» inv = Inverter(['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff'])

» for t in inv:
»     print(t, end=' < ')
↳ Ff < Ee < Dd < Cc < Bb < Aa <

# s é Inverter usando uma string como argumento
» s = 'Joazeiro'
» seq = Inverter(s)
» for t in seq:
»     print(t, end=' < ')
↳ o < r < i < e < z < a < o < J <     
🔺Início do artigo

Bibliografia

Consulte a bibliografia no final do primeiro artigo dessa série.

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *