.. -*- coding: utf-8 -*-
Programação orientada a objetos - classe Fraction
===================================================
Na aula anterior vimos que a quantização na representação de números
reais causam imprecisões ao calcular números harmônicos e, para
mitigar esse problema, sugerimos o uso de frações.
Observe que, assim como pensamos em manipular inteiros e reais usando
operações como soma, subtração, multiplicação etc., podemos imaginar
essas mesmas operações usando frações. Nessa e nas próximas aulas
vamos ver como podemos "abstrair" frações como se fossem,
simplesmente, mais um **tipo** numérico. Para isso, vamos começar a
introduzir conceitos de orientação à objetos, como os conceitos de
**classes**, **objetos**, **atributos** e **métodos**.
Tópicos
-------
- Classes, objetos, atributos e métodos;
- Métodos especiais (= *mágicos*) do Python: ``__init__()`` e ``__str__()``;
- Métodos comuns como, por exemplo, ``add()``, ``sub()``, ``mul()``, e ``div()``;
Introdução
----------
A capacidade de representar e abstrair conceitos é fundamental para o nosso pensamento, tornando possível a generalização de coisas e a construção de conceitos cada vez mais complexos.
Um **tipo abstrato de dados** (= TAD) é uma descrição de como vemos os dados e as operações permitidas sem a gente se preocupar com a implementação.
Isso permite que nosso pensamento permaneça focado em "o que" os dados representam e "como" usá-los, e podemos ignorar a forma como os dados são armazenados e manipulados pelo computador. Nós já temos feito isso, por exemplo, quando trabalhamos com números inteiros que, internamente, são representados como números binários.
Além de inteiros (= tipo ``int``), há vários outros tipos nativos como ``float``, ``str``, ``bool`` e ``list`` que podemos usar no Python.
Agora veremos como definir **novos tipos**, não nativos. Esses novos tipos definidos pelo programador são criados por meio de **classes**.
Na evolução das linguagens de programação, a necessidade de se representar e manipular informações complexas resultou no conceito de classes e objetos. **Classes** são abstrações que definem como os dados serão representados e manipulados.
Já **objetos** são instâncias de classes que mantém e permitem a manipulação da informação durante a execução de um programa.
Python é uma **linguagem de programação orientada a objetos** (= POO de *object-oriented programming language*).
Uma das característica de linguagens orientadas a objetos é facilitar que programadores criem novas classes para modelar os dados necessários
para resolver seus problemas. Um **objeto** contém as informações (= valores) de um tipo (= classe) definido pelo programador. O uso de abstração de tipos de dados nos permitem fazer uma descrição de um objeto (= seu **estado**) e o que ele pode fazer (= seus **métodos**).
Vamos ilustrar esses conceitos criando a classe ``Fraction`` e depois implementando programas que criam e manipulam objetos desse tipo.
Frações como um tipo de dados
-----------------------------
Em Python, o número ``123`` é representado por um objeto do tipo ``int``,
o número ``1.23`` é representado por um objeto do tipo ``float``,
o texto ``"como é bom estudar programação!"`` é representado por um
objeto do tipo ``str`` (= *string*), e assim por diante.
Quanto mais *coisas* conseguimos abstrair na forma de objetos, ou seja,
que possamos tratar como uma *entidade* que possui um valor ou **estado**
e que pode ser manipulado por meio de algumas funções ou operações
sobre essas entidades, mais poderosa se torna a nossa linguagem.
Por exemplo, vimos anteriormente que uma fração (= número racional) pode ser representada por dois valores inteiros: o numerador (``num``) e o denominador (``den``), como na figura abaixo. Em orientação a objetos, dizemos que as *informações* armazenadas no objeto são seus **atributos**. Portanto os inteiros ``num`` e ``den`` devem ser atributos de objetos do tipo (= classe) ``Fraction``.
.. image:: figuras/poo1.png
:alt: ilustração: Abstração de um número racional que deve possuir 2 valores inteiros.
Dizemos que o **estado** do objeto ``Fraction`` é definido pelos valores de seus atributos, no caso, ``num`` e ``den``.
A figura abaixo mostra um objeto `Fraction` no estado ``num=0`` e ``den=1``.
.. image:: figuras/poo2.png
:alt: ilustração: Objeto ``Fraction`` no estado ``num=0`` e ``den=1``.
Assim como podemos fazer operações com números inteiros e reais e com *strings* que resultam em objetos com outros valores, é conveniente termos funções que manipulam objetos da classe ``Fraction``. Funções associadas a objetos de uma classe são chamadas de **métodos**.
Por exemplo, é frequente termos métodos que nos permitem conhecer o estado do objeto.
Esses métodos muitas vezes recebem o nome de ``get()``.
Também é comum termos métodos para modificar o estado de algum objeto, colocando novos valores em seus atributos, que tipicamente recebem o nome de ``put()``.
É frequente ainda termos métodos que produzem outro objeto da classe.
No casso da nossa classe ``Fraction`` será conveninete termos um método ``add()`` que
recebe dois objetos da classe ``Fraction`` e retornam um novo objeto da classe ``Fraction``
que representa a soma das frações recebidas.
.. image:: figuras/poo3.png
:alt: ilustração: Abstração de uma fração que deve possuir 2 valores inteiros
Veremos agora como definir a classe ``Fraction`` em Python, seus atributos e os métodos para manipular objetos dessa classe. Tudo isso
será feito utilizando como pano de fundo o problema de calcular números harmônicos usando frações, em vez de calcular aproximadamente através do tipo ``float``, nativo do Python.
Classes e objetos
------------------
Assim como precisamos escrever a função antes de usá-la em um programa, precisamos escrever a classe que define como um objeto desse tipo é representado e manipulado antes de usá-lo.
Ao escrever uma função, estamos definindo como a função se comporta, ou seja, o que ela faz com os parâmetros de entrada, descrevendo através de código como os parâmetros são manipulados a fim de obtermos o resultado gerado pela função.
Ao escrever a classe que define os objetos de um tipo necessitamos especificar seus atributos e métodos.
Podemos considerar uma **classe** como um molde usado para criar objetos do tipo, que pode definir os valores iniciais (= estado inicial) e seus comportamentos (= métodos).
Uma classe é portanto um trecho de código que define os atributos e métodos dos objetos do mesmo tipo, como as ``frações``.
Usando uma metáfora de construção civil, uma **classe** seria o equivalente à planta de uma casa. A mesma planta pode ser usada para construir várias casas. Cada casa, depois de construída, seria o **objeto**, uma instância da classe.
Digamos que os **atributos** de uma casa sejam ``cor`` e ``proprietário``. Dessa forma, casas com a mesma planta podem ter
estados diferentes, uma pode ser verde e outra azul, por exemplo.
Podem ter inclusive a mesma cor e proprietário, mas são casas diferentes, se uma muda de cor ou de proprietário, o estado da outra casa permanece inalterado. Podemos definir um método ``venda`` que altera o valor de ``proprietário`` e assim altera o estado de um objeto, mas não do outro.
Essa metáfora é interessante pois basta nos lembramos que a classe é um molde para construirmos um objeto e como ele é manipulado.
Quando um objeto é criado, os atributos são inicializados e, a partir desse estado inicial, fica a disposição do programador para ser manipulado por meio de qualquer dos métodos oferecidos pela classe a qual pertence.
.. _my_ClasseFraction:
Classe ``Fraction``
--------------------
Ao definir uma classe em Python, tradicionalmente usamos um nome com a primeira letra em maiúscula e as demais em minúscula. Nossa classe portanto vai se chamar ``Fraction``.
Já vimos que, como um número racional é definido por seu numerador e denominador, precisamos de ao menos dois atributos inteiros para representar uma fração. Esses atributos podem ser carregados com valores adequados quando um objeto ``Fraction`` é **criado**.
Além disso, gostaríamos de **exibir** uma fração usando uma string contendo o valor do numerador seguido pelo denominador, separados pelo símbolo ``"/"``. Exibir o conteúdo de um objeto na forma de texto é um comportamento bastante útil quando, por exemplo, queremos mostrar o estado de um objeto ``Fraction`` usando a função ``print()`` do Python.
Uma primeira versão da classe ``Fraction`` com a definição desses atributos e métodos pode ser:
.. raw:: html
..
code:: Python
class Fraction:
def __init__(self, n, d):
self.num = n
self.den = d
def __str__(self):
return f"{self.num}/{self.den}"
# testes
a = Fraction(2, 3)
print(a)
b = Fraction(1, 4)
print(b)
Nesse exemplo, a palavra reservada ``class`` indica a definição de uma classe de nome ``Fraction``. Como na definição de funções em Python, o nome deve ser seguido por ``:`` e o corpo da classe com a definição de seus métodos vem logo a seguir deslocado por alguns espaços (ou tab). Nesse livro usamos 4 espaços em branco.
A ``criação`` e ``exibição do conteúdo`` de um objeto são comportamentos básicos necessários para qualquer tipo de objeto. Esses comportamentos precisam ser definidos por meio de **métodos especiais**. Os nomes desses métodos são escritos entre `__` para serem facilmente identificados como **especiais**. Observe que, nesse exemplo, a classe ``Fraction`` define dois métodos especiais, o ``__init__(self)`` e o ``__str__(self)``.
O método especial ``__init__(self)`` é chamado **automaticamente** sempre que um objeto da classe correspondente é criado. Tipicamente, esse método é utilizado para criar os atributos com seus valores iniciais, e por isso esse método é conhecido como **construtor** da classe.
O método especial ``__str__(self)`` deve retornar uma string com a representação textual do objeto. Essa função é utilizada por outras funções do Python, em particular pela função ``print()``, como veremos nos exemplos. Mas antes disso, talvez você esteja curioso para saber o que é esse ``self`` que aparece na definição dos métodos.
O que é esse `self`?
....................
Um dos princípios da linguagem Python é tornar as coisas **explícitas**.
O ``self`` é a forma do Python para permitir que objetos possam usufruir
de seus próprios (``self`` ~ próprio) métodos e atributos.
Para ver como o ``self`` funciona, o exemplo abaixo cria 2 objetos (ou 2 instâncias distintas dessa classe), o objeto ``a`` e o objeto ``b``.
Execute o exemplo passo-a-passo clicando no botão ``Next``.
Essa simulação começa com a seta vermelha na linha 1, que define a classe ``Fraction``. Após o primeiro clique, observe do lado direito ao código que o Python agora tem a sua disposição a "planta" de um objeto ``Fraction`` e portanto sabe criar esses objetos. A simulação pula para a linha 10, que é a primeira linha após a definição da classe.
A linha 10 contém a atribuição ``a = Fraction(2, 3)``. Ao executar essa linha, observe que uma nova instância de ``Fraction`` é criada e a variável ``a`` passa a fazer referência para essa instância.
Veja que, na linha 2, o método construtor ``__init__(self, n, d)`` foi definido para receber 3 argumentos. Quando escrevemos a ``Fraction(2,3)``, o Python chama **automaticamente** o construtor, considerando 2 como valor de ``n`` e 3 como valor de ``d`` na ``__init__``.
Mas e o ``self``?
Em Python, ``self`` é **sempre** o primeiro parâmetro na **definição** de **todos** os métodos e é a forma do Python receber, automaticamente também, uma
**referência** ao próprio objeto. Como, para todos os métodos, ``self`` é sempre o primeiro parâmetro, na **chamada** dos métodos (como em ``Fraction(2,3)`` que chama o construtor) em nossos programas não é necessário passar o valor de ``self``. Além de reduzir a quantidade de texto que precisamos digitar, essa notação melhora a legibilidade do código.
.. admonition:: Nota sobre ``self``
Na verdade, ao escrever as suas classes em Python, você pode
utilizar uma outra palavra além de ``self``, como ``ref``, ou ``prop``, ou outra
qualquer. O que importa é que esse primeiro parâmetro é sempre uma
referência para o próprio objeto.
Para que usamos o ``self``?
.......................................
Você deve usar o ``self`` para criar atributos e chamar os próprios método.
Assim, para tornar claro e explícito que ``num`` e ``den`` são atributos do objeto
e não apenas variáveis auxiliares locais do método, ``num`` e ``den`` precisam ser
**associados** a ``self`` (ou seja, associados ao objeto)
utilizando o ``.`` (ponto) como separador. O trecho abaixo ilustra o caso com a variável ``div``, definida localmente no método ``__init__(self)``, mas por não ficar associada ao objeto, não pode ser usada em ``__str__(self)``.
.. raw:: html
..
code:: python
class Fraction:
def __init__(self, n, d):
self.num = n # use self. para tornar num um atributo
self.den = d # assim num e den ficam associados ao objeto
div = n / d # div não é um atributo pois não tem self.
print(div)
def __str__(self):
print(div)
return f"{self.num}/{self.den}"
# testes
a = Fraction(2, 3)
print(a)
b = Fraction(1, 4)
print(b)
Verifique no entanto que esse código dá erro no método ``__str__(self)`` pois
a ``div`` só foi definida em ``__init__(self)``.
Leia a mensagem de erro e observe que o Python reclama de uma ``variável não definida`` em ``__str__``.
Edite esse programa colando ``self.`` antes de cada ``div`` para tornar
``div`` um atributo como ``self.num`` e ``self.den`` e execute o programa novamente para ver o resultado das divisões.
.. admonition:: Não use ``print()`` dentro de ``__str__(self)``
O ``print()`` nesse exemplo apenas ilustra que podemos usar print em qualquer lugar e mostrar difereças entre variáveis locais e atributos. O método especial ``__str__(self)`` define a string que representa o objeto, como queremos que os dados apareçam quando o objeto for usado em um ``print()``.
..
Objetos criados com valores "default"
-------------------------------------
Em muitos casos, é útil criar um objeto com valores pré-determinados, ou "default". Por exemplo, podemos definir que
o valor default de um Fraction seja ``0/1`` (por que ``0/0`` seria uma má ideia?). Nesse caso, basta modificar
o cabeçalho do método construtor ``__init__`` da seguinte forma:
.. code:: python
class Fraction:
def __init__(self, n=0, d=1):
self.num = n
self.den = d
def __str__(self):
return f"{self.num}/{self.den}"
# testes
r1 = Fraction()
r2 = Fraction(4)
r3 = Fraction(3,2)
print(r1)
print(r2)
print(r3)
Observe que agora o valor default de ``n`` é ``0`` (parâmetro
correspondente ao numerador) e de ``d`` é ``1``. Devido aos valores
default, podemos definir os valores de ``n`` e ``d`` apenas quando são
diferentes de ``0`` e ``1`` respectivamente. Nesse caso, observe que o
valor do Fraction ``r1`` é ``0/1``, o de ``r2`` é ``4/1`` e o de ``r3``, que
recebeu os 2 argumentos, é ``3/2``. Quando apenas 1 argumento é passado,
a ordem dos parâmetros se torna importante. Assim, na criação do
Fraction ``r2``, o segundo parâmetro (correspondente ao denominador) não
foi fornecido e assume portanto o seu valor default ``1``.
Mas e se eu quiser passar apenas o valor do denominador?
---------------------------------------------------------
Nesse caso, o Python permite que você passe os parâmetros usando os
nomes definidos no cabeçalho do método. Assim:
.. code:: python
r4 = Fraction(d=-1)
e até:
.. code:: python
r5 = Fraction(d=3, n=2)
são chamadas válidas.
É possível ainda misturar parâmetros com e sem valores default. Se
você decidir por misturar, procure agrupar os parâmetros com valores
default no final do cabeçalho, para deixar as chamadas mais
consistentes, como:
.. code:: python
def metodo(self, a, b, c = 0, d = 1):
Experimente colocar outros parâmetros no ``__init__()``, e crie outros
métodos, usando o trecho de código abaixo:
.. code:: python
# Fraction_v2
class Fraction:
def __init__(self, a, b, n=0, d=1):
self.num = n
self.den = d
print("Construtor: ", a, b, n, d)
# observe que a e b não se tornam atributos de Fraction
def __str__(self):
return f"{self.num}/{self.den}"
# testes
r1 = Fraction('in', 'out')
r2 = Fraction('a', 'b', 4)
r3 = Fraction('x', 'y', d=5, n=3)
.. admonition:: Polimorfismo
o uso de valores default é permitido também no cabeçalho de funções
comuns, não apenas métodos, do Python.
Esse recurso é conhecido como **polimorfismo**, visto que permite
que uma mesma função seja chamada de **formas diferentes**
(com um número diferente de argumentos).
E para alterar os valores dos atributos?
........................................
Em Python, podemos alterar os atributos diretamente
.. code:: python
r1 = Fraction(0, 1)
r1.num, r1.den = 2, 3
Obviamente nesse caso seria mais simples escrever ``r1 = Fraction(2,3)``. Em orientação à objetos também é comum usar um método para ler e escrever os valores de atributos, como os métodos ``put()`` e ``get()``. Apesar de comuns, observe que ``put()`` e ``get()`` são métodos comuns (sem as ``__``).
.. raw:: html
..
codelens:: Fraction_v3
class Fraction:
def __init__(self, n=0, d=1):
self.put(n, d)
def __str__(self):
return f"{self.num}/{self.den}"
def get(self):
return self.num, self.den
def put(self, n=0, d=1):
self.num, self.den = n, d
# testes
r1 = Fraction()
print(r1)
r1.put(4,2)
print(r1)
n, d = r1.get()
print("n=%d, d=%d"%(n, d))
Na prática de orientação a objetos, o uso de ``put()`` e ``get()`` ajuda a proteger os atributos e organiza a forma de acesso ao conteúdo.
Operações com Frações
-----------------------
Por ser um novo tipo numérico, é natural considerar operações como soma e multiplicação de frações, entre outras. Vamos então incluir na classe ``Fraction`` os métodos para resolver as seguintes operações (você pode incluir outras depois):
- ``add()``: para adição de dois racionais;
- ``sub()``: para subtração de racionais;
- ``mul()``: para multiplicação de dois racionais; e
- ``div()``: para divisão entre racionais.
As operações de multiplicação e divisão são relativamente simples. Na
multiplicação basta multiplicar os numeradores e os denominadores, e
na divisão basta multiplicar o primeiro pelo inverso da segunda fração.
No caso da adição e subtração, é necessário transformar as frações para
um mesmo denominador, antes de somar e subtrair os numeradores.
Exercícios
----------
1. No exercício abaixo, escreva os métodos ``div()``, ``add()`` e ``sub()`` (para divisão, adição e subtração), e alguns testes. Antes de escrever os seus métodos, observe o código do método ``mul()`` que preserva os estados dos objetos ``self`` e ``other``, e retorna um outro (nova instância) Fraction. Faça o mesmo para ``div()``, ``add()`` e ``sub()``. Caso desejar, copie o código abaixo em um arquivo no seu computador para desenvolver sua solução no Spyder.
.. raw:: html
..
activecode:: Fraction_v4
class Fraction:
def __init__(self, n=0, d=1):
self.put(n, d)
def __str__(self):
return f"{self.num}/{self.den}"
def get(self):
return self.num, self.den
def put(self, n=0, d=1):
self.num, self.den = n, d
def mul(self, other):
n = self.num * other.num
d = self.den * other.den
return Fraction(n, d)
# escreva aqui o seu codigo para os metodos
# div
# add
# sub
# e ao menos um teste para cada metodo
# testes
r1 = Fraction(2)
r2 = Fraction(1,5)
print(r1, '*', r2, '=>', r1.mul(r2))
# teste do div:
# print(r1, '/', r2, '=>', r1.div(r2))
#
# outros testes
2. Altere a classe ``Fraction`` para que, quando o denominador for 1, ele imprima apenas o valor do numerador.
..
#. Altere o construtor da classe ``Fraction`` para que, quando o numerador
e o denominador possam ser reduzidos, a fração reduzida seja armazenada.
.. caso o resultado de uma operação (``add``, ``sub``, ``mul`` ou ``div``) puder ser reduzido, a operação retorne a fração reduzida.
Exemplo: para a chamada ``Fraction(12, 20)`` o valor de ``self.num`` e
``self.den`` devem ser, respectivamente, 3 e 5.
**Dica**: crie uma função (ou, se preferir, um método) com ``mdc``
que recebe 2 números inteiros ``a`` e ``b`` e retorna o máximo divisor
comum entre ``a`` e ``b`` usando o algoritmo de Euclides.
Onde estamos e para onde vamos?
-------------------------------
Nessa aula vimos como criar uma classe em Python. Uma classe contém a definição de um novo ``tipo abstrato de dado`` que podemos usar em nossos programas. Ilustramos isso com a criação de uma classe ``Fraction``. Por meio dessa abstração somos capazes de organizar melhor o código para criação e manipulação desses dados. Uma vez definidas as classes, conseguimos focar nosso pensamento no uso desses tipos e não precisamos mais pensar na sua construção.
Nas próximas aulas vamos continuar a introdução de conceitos sobre orientação à objetos, abstração de dados e como esses conceitos pode ser implementados em Python.
Para saber mais
---------------
- `Tutorial de POO em Python `__.
- `Classes e objetos: frações `__;