Python Iluminado

Programação Orientada a Objetos

Introdução

A programação orientada a objetos (OOP) é um paradigma de programação no qual os programas são organizados em torno de dados ou objetos, em vez de funções e lógica. Um objeto pode ser definido como um campo de dados que possui atributos e comportamento exclusivos. Exemplos de um objeto podem variar de entidades físicas, como um ser humano, descritas por propriedades como nome e endereço, até pequenos programas de computador, como widgets.

A programação orientada a objetos tem suas raízes nos anos 1960, mas foi apenas em meados dos anos 80 que se tornou o principal paradigma de programação usado na criação de novos softwares. Foi desenvolvida como uma maneira de lidar com o rápido crescimento em tamanho e complexidade de sistemas de software e para fazer com que seja mais fácil de modificar esses grandes e complexos sistemas ao longo do tempo.

Uma característica de objetos são os procedimentos de um objeto que podem acessar e modificar com freqüência os campos de dados do objeto ao qual estão associados (objetos têm uma noção de "this" ou "self", dependendo da linguagem). Na Programação Orientada a Objetos, os programas de computador são projetados através da construção de objetos que interagem entre si. As linguagens que implementam Orientação a Objetos são diversas, mas as mais populares são baseadas em classes, o que significa que os objetos são instâncias de classes, que também determinam seus tipos.

Princípios da Programação Orientada a Objetos

Os princípios mais importantes que guiam a orientação a objetos são:

  • Encapsulamento: A implementação e o estado de cada objeto são mantidos em particular dentro de um limite ou classe definida. Outros objetos não têm acesso a essa classe ou a autoridade para fazer alterações, mas só podem chamar uma lista de funções ou métodos públicos. Essa característica da ocultação de dados fornece maior segurança do programa e evita a corrupção não intencional de dados.
  • Abstração: Objetos apenas revelam mecanismos internos relevantes para o uso de outros objetos, ocultando qualquer código de implementação desnecessário. Esse conceito ajuda os desenvolvedores a fazer alterações e adições ao longo do tempo com mais facilidade.
  • Herança: Relacionamentos e subclasses entre objetos podem ser atribuídos, permitindo que os desenvolvedores reutilizem uma lógica comum, mantendo uma hierarquia única. Essa propriedade da Orientação a Objetos força uma análise mais completa dos dados, reduz o tempo de desenvolvimento e garante um nível mais alto de precisão.
  • Polimorfismo: Os objetos podem assumir mais de uma forma, dependendo do contexto. O programa determinará qual significado ou uso é necessário para cada execução desse objeto, reduzindo a necessidade de duplicar o código.

Classes em Python

Classes nos possibilitam encorporar dados e funcionalidades juntos. Criar uma nova classe cria um novo tipo de objeto, para isso usamos a palavra-chave class, possibilitando assim que criemos novas instances desse tipo. Cada classe pode ter multiplos atributos acoplados a ela para manter o seu estado. Instances de classe também podem ter métodos (definidos por sua classe) para modificar seu estado, imagine métodos como funções dentro da classe.

Devemos lembrar que em Python tudo é um objeto, como já vimos anteriormente.

nome = 'gabriel'
dir(nome) # ['__add__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isascii', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']
nome.upper() # GABRIEL
type(nome) # <class 'str'>

Ao declararmos uma variável, podemos utilizar a função dir() nela para obtermos os atributos e métodos disponíveis para utilizarmos, nesse caso específico usamos o método upper() para transformar as letras em uppercase. Observe também que ao utilizarmos a função type() na variável nome nos será retornado que ela é um objeto do tipo str, ou seja, uma string.

As classes são usadas para criar novas estruturas de dados definidas pelo usuário que contêm informações arbitrárias sobre algo. No caso de um animal, poderíamos criar uma classe Pessoa() para rastrear propriedades sobre a pessoa, como nome e idade por exemplo.

É importante observar que uma classe apenas fornece estrutura - é um modelo de como algo deve ser definido, mas na verdade não fornece nenhum conteúdo real. A classe Pessoa() pode especificar que o nome e a idade são necessários para definir uma pessoa, mas na verdade não indicará qual é o nome ou a idade de uma pessoa específica.

Pode nos ajudar pensarmos em uma classe como uma idéia de como algo deve ser definido.

Objetos em Python

Os objetos (Instances) podem armazenar dados usando variáveis comuns que pertencem ao objeto. Variáveis que pertencem a um objeto ou classe são chamadas de campos. Os objetos também podem ter funcionalidade usando funções que pertencem a uma classe. Tais funções são chamadas métodos da classe. Essa terminologia é importante porque nos ajuda a diferenciar funções e variáveis independentes e aquelas que pertencem a uma classe ou objeto. Coletivamente, os campos e métodos podem ser chamados de atributos dessa classe.

Os campos são de dois tipos - eles podem pertencer a cada instância / objeto da classe ou podem pertencer à própria classe. Eles são chamados de variáveis de instância e variáveis de classe, respectivamente.

Uma classe é criada usando a palavra-chave class. Os campos e métodos da classe estão listados em um bloco indentado.

O self em Python

Os métodos de classe têm apenas uma diferença específica das funções comuns - eles devem ter um primeiro nome extra que deve ser adicionado ao início da lista de parâmetros, mas você não atribui um valor para esse parâmetro ao chamar o método, o Python fornecerá isto. Essa variável em particular se refere ao próprio objeto e, por convenção, recebe o nome de self.

Embora você possa fornecer qualquer nome para esse parâmetro, é altamente recomendável usar o nome self - qualquer outro nome é definitivamente desaprovado.

Para compreendermos melhor o self, imagine que nós temos uma classe chamada Pessoa e uma instance dessa classe chamada de pessoa. Quando você chama um método desse objeto como pessoa.metodo(argumento1, argumento2), isso é automaticamente convertido pelo Python como Pessoa.metodo(pessoa, argumento1, argumento2). Isso significa que se você tiver um método que não recebe nenhum argumento, você ainda sim terá de ter o argumento self.

Definindo uma Classe em Python

Imagine que desejamos criar um jogo com Python e nesse jogo teremos personagens, uma alternativa muito interessante para a solução desse problema seria a definição de uma class que irá representar um modelo para criarmos vários personagens.

Começaremos criando um novo arquivo, chamaremos ele de personagem.py

class Personagem:
"""
A class Personagem representa as características de uma identidade personagem em um jogo
"""
def __init__(self):
"""
Inicializa as propriedades de um personagem
"""
self.nome = 'Alucard'
self.idade = 120
self.vida = 100

As definições de class podem aparecer em qualquer lugar do programa, mas geralmente estão no início (após as instruções de importação). Alguns programadores e linguagens preferem colocar cada classe em um módulo próprio - não faremos isso aqui. As regras de sintaxe para uma definição de class são as mesmas que para outras instruções compostas. Há um cabeçalho que começa com a palavra-chave, class, seguido pelo nome da class e termina com dois pontos. Os níveis de recuo nos dizem onde a class termina.

Se a primeira linha após o cabeçalho da class for uma string, ela se tornará a string de documentação (docstring) da classe e será reconhecida por várias ferramentas. (Também é assim que os documentos funcionam em funções.)

Toda classe deve ter um método com o nome especial init. Esse método inicializador é chamado automaticamente sempre que uma nova instance do Personagem é criada. Isso dá ao programador a oportunidade de configurar os atributos necessários na nova instância, fornecendo a eles seu estado / valores iniciais. O parâmetro self (poderíamos escolher qualquer outro nome, mas self é a convenção) é automaticamente definido para referenciar o objeto recém-criado que precisa ser inicializado.

p1 = Personagem() # Instanciamos um objeto do tipo Personagem
p2 = Personagem() # Instanciamos outro objeto do tipo Personagem
print(p1.nome, p1.idade, p1.vida) # Alucard 120 100
print(p2.nome, p2.idade, p2.vida) # Alucard 120 100

Veja que o programa nos imprime Alucard 120 100 duas vezes, isso porque durante a inicialização dos objetos, criamos três atributos chamados nome, idade e vida para cada instance e atribuímos para ambos os valores Alucard, 120 e 100.

As variáveis p1 e p2 são atribuídas referências a dois novos objetos Personagem.

Pode ser útil pensar em uma classe como uma fábrica para fazer objetos. A class em si não é uma instance de um personagem, mas contém o mecanismo para criar instances de personagens. Toda vez que chamamos o construtor, pedimos à fábrica que nos torne um novo objeto. À medida que o objeto sai da linha de produção, seu método de inicialização é executado para obter o objeto corretamente configurado com as configurações padrão de fábrica.

Atributos

Como os objetos do mundo real, as instances de objetos tem atributos e métodos. Podemos modificar os atributos de uma instance utilizando a notação de ponto:

p1.nome = 'Mario'
p1.idade = 35
p1.vida = 3

Os módulos e as instances criam seus próprios namespaces, e a sintaxe para acessar os nomes contidos em cada um, chamados atributos, é a mesma. Nesse caso, o atributo que estamos selecionando é um item de dados de uma instância.

A variável p1 refere-se a um objeto Personagem, que contém três atributos.

Podemos acessar o valor de um atributo usando a mesma sintaxe:

print(p1.nome, p1.idade, p1.vida) # Mario 35 3
nome = p1.nome
print(nome) # Mario

A expressão p1.nome significa: "Através do objeto p1, referindo-se a ele, obtenha o valor de nome". Nesse caso, atribuímos esse valor a uma variável chamada nome. Não há conflito entre a variável nome (no espaço para nome global aqui) e o atributo nome (no espaço para nome pertencente à instância). O objetivo da notação de ponto é qualificar totalmente a qual variável estamos nos referindo sem ambiguidade.

Podemos usar a notação de ponto como parte de qualquer expressão, por exemplo:

expressao = f'Meu nome é {p1.nome} e eu possuo {p1.vida} ponto(s) de vida'
print(expressao) # Meu nome é Mario e eu possuo 3 ponto(s) de vida

Aprimorando nosso Inicializador

Atualmente para criarmos um novo Personagem precisamos utilizar diversas linhas de código:

p3 = Personagem()
p3.nome = 'Arthas'
p3.idade = 60
p3.vida = 150

Podemos tornar nosso construtor de classe mais geral, colocando parâmetros extras no método init, conforme mostrado neste exemplo:

class Personagem:
"""
A class Personagem representa as características de uma identidade personagem em um jogo
"""
def __init__(self, nome, idade, vida):
"""
Inicializa as propriedades de um personagem
"""
self.nome = nome
self.idade = idade
self.vida = vida

Vejamos agora como podemos utilizar a nossa class:

p4 = Personagem('Zeus', 1000, 1000)
print(p4.nome, p4.idade, p4.vida) # Zeus 1000 1000

Adicionando Novos Métodos à nossa Classe

Um método se comporta como uma função, mas é invocado em uma instance específica. Como um atributo de dados, os métodos são acessados usando a notação de ponto. Vamos agora adicionar outro método a nossa class, chamaremos ele de upgrade_vida:

class Personagem:
"""
A class Personagem representa as características de uma identidade personagem em um jogo
"""
def __init__(self, nome, idade, vida):
"""
Inicializa as propriedades de um personagem
"""
self.nome = nome
self.idade = idade
self.vida = vida
def upgrade_vida(self):
self.vida += 10

Vamos agora criar uma nova instance de Personagem, examinar seus atributos e testar nosso novo método.

p5 = Personagem('Samus', 30, 100)
print(p5.nome, p5.idade, p5.vida) # Samus 30 100
p5.upgrade_vida()
print(p5.vida) # 110

Ao definir um método, o primeiro parâmetro se refere à instance que está sendo manipulada. Como já discutimos, é uma boa prática nomear esse parâmetro como self.

Instances como Argumentos e Parâmetros

Podemos, de maneira usual, passar um objeto como parâmetro. Esteja ciente de que nossa variável contém apenas uma referência a um objeto. Vejamos um exemplo:

def imprimir_personagem(p):
print(f'Nome: {p5.nome}, Idade: {p5.idade}, Vida: {p5.vida}')

imprimir_personagem recebe um personagem como argumento e formata a saída da maneira que definimos. Para utilizarmos é muito simples:

imprimir_personagem(p5) # Nome: Samus, Idade: 30, Vida: 110

Convertendo uma Instance em uma String

O que acabamos de fazer com a função não é uma prática interessante de seguirmos, porém serve como um exemplo de como podemos passar nossos objetos como parâmetros para outras funções. Python fornece uma técnica interessante para transformarmos nossa Instance em uma String customizada por nós, porém antes vejamos o que acontece quando imprimimos nosso objeto p5:

print(p5) # <__main__.Personagem object at 0x7f7256a234d0>

Se chamarmos um novo método str, o interpretador Python usará nosso código sempre que precisar converter um Personagem em uma string. Vamos então alterar nosso código:

class Personagem:
"""
A class Personagem representa as características de uma identidade personagem em um jogo
"""
def __init__(self, nome, idade, vida):
"""
Inicializa as propriedades de um personagem
"""
self.nome = nome
self.idade = idade
self.vida = vida
def upgrade_vida(self):
self.vida += 10
def __str__(self):
return f'Nome: {self.nome}, Idade: {self.idade}, Vida: {self.vida}'

Vejamos agora como será o comportamento:

print(str(p5)) # Nome: Samus, Idade: 30, Vida: 110
print(p5) # Nome: Samus, Idade: 30, Vida: 110

Nosso objeto Personagem agora tem um método str personalizado por nós.

Herança

Herança é o processo pelo qual uma classe assume os atributos e métodos de outra. As classes recém-formadas são chamadas de classes filho, e as classes das quais as classes filho são derivadas são chamadas de classes pai.

É importante observar que as classes filho substituem ou ampliam a funcionalidade (por exemplo, atributos e comportamentos) das classes pai. Em outras palavras, as classes filho herdam todos os atributos e comportamentos dos pais, mas também podem especificar comportamentos diferentes a serem seguidos. O tipo mais básico de classe é um object, que geralmente todas as outras classes herdam como pai.

Quando você define uma nova classe, o Python usa implicitamente o object como a classe pai. Portanto, as duas definições a seguir são equivalentes:

class Pessoa(object):
pass
class Pessoa:
pass

Vamos agora criar uma nova class que irá extender a funcionalidade de nosso Personagem.

class Mago(Personagem):
def conjurar_magia():
print(f'{self.nome} lança uma magia aleatória')

Para testarmos nossa nova class, vamos definir um novo Mago:

p6 = Mago('Gandalf', 1000, 1000)
print(p6) # Nome: Gandalf, Idade: 1000, Vida: 1000
p6.conjurar_magia() # Gandalf lança uma magia aleatória

Classes Pai vs Classes Filho

A função isinstance() é usada para determinar se uma instance também é considerada uma instance de uma certa class pai. Por exemplo:

print(isinstance(p6, Mago)) # True
print(isinstance(p6, Personagem)) # True

Para ambas os testes nos é retornado True, e faz todo sentido, uma vez que nosso objeto p6 é uma instance de Mago (class filha) e Personagem (class pai).

Substituição de Método

A Substituição de Método, também conhecida como Method Overriding refere-se a ter um método com o mesmo nome na classe filho e na classe pai. A definição do método difere nas classes pai e filho, mas o nome permanece o mesmo. Vamos dar um exemplo simples de substituição de método, para isso vamos alterar nosso Mago e diminuir sua capacidade de aumentar sua vida, uma vez que ele é um Mago frágil:

class Mago(Personagem):
def conjurar_magia(self):
print(f'{self.nome} lança uma magia aleatória')
def upgrade_vida(self):
self.vida += 5

Agora podemos utilizar o método upgrade_vida através do nosso objeto p6:

p6.upgrade_vida()
print(p6) # Nome: Gandalf, Idade: 1000, Vida: 1005

Observe que Python inteligentemente substituiu o método, uma vez que p6 é uma instance de um Mago, ele executará o método upgrade_vida (que foi substituído) do Mago.

Encapsulamento

Python possui encapsulamento, O que Python não tem é controle de acesso, como atributos privados e protegidos. No entanto, no Python, existe uma convenção de nomenclatura de atributos para denotar atributos privados, prefixando o atributo com um ou dois underscores (_ ou __).

Um único underscore indica ao usuário de uma class que um atributo deve ser considerado privado para a class e não deve ser acessado diretamente.

Um sublinhado duplo indica o mesmo, no entanto, o Python irá alterar um pouco o nome do atributo para tentar ocultá-lo. Vejamos um exemplo para compreendermos melhor:

class Objeto(object):
def __init__(self):
self.x = 1 # Permitido o acesso direto
self._x = 1 # Deve ser considerado privado
self.__x = 1 # Considerado privado

Vamos agora criar uma instance do Objeto e acessar seus atributos

obj = Objeto()
print(obj.x) # 1
print(obj._x) # 1
print(obj.__x) # Nós é retornado um erro

Python não nos permitiu acessar o valor do atributo __x, para podermos acessá-lo é necessário chamarmos ele através do Objeto diretamente:

print(obj._Objeto__x) # 1

Encapsulamento é o processo de usar variáveis privadas nas classes para impedir modificações não intencionais ou potencialmente maliciosas dos dados. Ao conter e proteger variáveis dentro de uma classe, ele permite que a classe e os objetos criados por ela funcionem como partes independentes e auto-contidas, funcionando dentro da máquina do próprio programa.

Por meio de variáveis de encapsulamento e determinados métodos, somente é possível interagir com as interfaces designadas pela própria classe.

Polimorfismo

Outro conceito de extrema relevância na programação orientada a objetos é o polimorfismo, que permite usarmos uma interface comum para multiplas formas (tipos de dados).

Mais especificamente, polimorfismo é a capacidade de redefinir métodos para classes derivadas. Por exemplo, dada uma classe base forma geométrica, o polimorfismo permite ao programador definir diferentes métodos de área para qualquer número de classes derivadas, como círculos, retângulos e triângulos. Não importa a forma de um objeto, aplicar o método de área a ele retornará os resultados corretos para aquela forma.

Para visualizarmos melhor a ideia, vamos criar novas classes, uma chamada Criatura, um Orc e um Minotauro.

img

class Criatura:
def __init__(self, nome):
self.nome = nome
def informacao(self):
raise NotImplementedError("Subclasse deve Implementar o método Abstrato")
class Orc(Criatura):
def informacao(self):
return 'Criatura tribal shamânica misteriosa'
class Minotauro(Criatura):
def informacao(self):
return 'Criatura poderosa que vive nos labirintos'

A class Criatura é o pai das classes Orc e Minotauro, com ela inicializamos o objeto com um atributo nome que será definido quando instanciarmos a class, ela também define um método informacao que deve ser implementado pelas classes filhas, vamos agora testar nossas classes criando algumas criaturas:

criaturas = [Orc('Orc 1'), Orc('Orc 2'), Minotauro('Minotauro')]
for criatura in criaturas:
print(f'Nome: {criatura.nome}, Informações: {criatura.informacao()}')

Nos é retornado:

Nome: Orc 1, Informações: Criatura tribal shamânica misteriosa
Nome: Orc 2, Informações: Criatura tribal shamânica misteriosa
Nome: Minotauro, Informações: Criatura poderosa que vive nos labirintos

Temos então um ponto de acesso abstrato (Criatura) para muitos tipos de objetos (orc, minotauro) que seguem a mesma estrutura.

Vantanges da Orientação a Objetos

  • Agrupar dados em pacotes juntamente de procedimentos que operam neles através de interfaces bem-definidas
  • Desenvolvimento através de Dividir para Conquistar - Implementa e testa comportamento de cada classe separadamente - Aumenta a modularidade e diminui a complexidade
  • Classes torna mais fácil para reutilizar código - Muitos módulos de Python definem novas classes - Cada classe tem um ambiente separado (sem colisão com nomes de função) - Herança permite subclasses redefinirem ou extenderem um selecionado subconjunto de comportamento da superclasse

Conclusão

A Programação Orientada a Objetos é muito poderosa e pode nos auxiliar no desenvolvimento de softwares modulares e concisos, com ela podemos:

  • Criar nossas próprias coleções de dados
  • Organizar informação
  • Divisão de trabalho
  • Acessar informação de uma forma consistente
  • Adicionar camadas de complexidade
  • Como funções, classes são um mecanismo para decomposição e abstração em Programação