HomePage RecentChanges

Lesson22

Tal como discutimos em aulas anteriores, quando definimos um tipo abstracto de dados, não é desejável que tenhamos acesso directo à sua representação interna. No entanto, com a abordagem que seguimos antes, não pudemos garantir que tal não acontecia.

Veremos agora como podemos definir tipos abstractos de dados e garantir a inacessibilidade à representação utilizando a noção de objecto.

Ao paradigma de programação baseado em objectos dá-se o nome de programação com objectos.

Objectos

Com a utilização de objectos podemos garantir que as operações sobre um determinado tipo apenas são possíveis através do uso das operações básicas. Isto é conseguido através dos mecanismos de encapsulação e anonimato da representação.

A encapsulação de dados refere-se ao conceito de que a informação referente ao tipo de dados é completamente descrita pelas operações (básicas) sobre esse tipo. A representação interna é protegida de acessos exteriores e apenas algumas operações são exportadas.

O anonimato da representação refere-se ao conceito de que é guardado segredo no que diz respeito à representação interna.

As funções exportadas e que permitem a comunicação com a representação constituem a "interface" do tipo.

Neste paradigma de programação, a manipulação da representação interna é efectivamente proibido.

Um objecto é portanto uma entidade computacional que consiste não só na representação interna, mas também num conjunto de operações que definem o seu comportamento. Neste âmbito a manipulação de objectos não reside no uso de funções que calculam valores, mas sim em funções que recebem solicitações e que recorrem à informação interna, reagindo de forma apropriada.

As funções associadas a um objecto são em geral conhecidas por métodos.

Para definir objectos recorremos em geral ao conceito de classe, i.e., classe de objectos. Neste âmbito um objecto diz-se instância da classe ou do tipo. Em Python podemos definir classes, e desta forma objectos, da seguinte forma (em BNF):

<definição de classe> ::= class <nome> {(<nome>)}: NEWLINE
    INDENT <definição de método> + DEDENT

<definição de método> ::= def <nome> (self{, <parâmetros formais>)}: NEWLINE
    INDENT <corpo> DEDENT

Nesta definição o <nome> imediatamente a seguir a class representa o nome da classe ou tipo. A segunda ocorrência de <nome> entre parêntesis refere-se à possibilidade de uma classe estender outra classe, ou seja herança, algo que veremos na próxima aula.

A definição de métodos permitem-nos definir funções associadas à classe. Seguem a mesma sintaxe que vimos para as funções, mas recebem sempre um primeiro argumento <self> que nos permite referenciar o próprio objecto sobre o qual invocamos o método.

Tipicamente temos pelo menos um método, __init__, que nos permite a representação interna sempre que criamos um novo objecto/instância da classe.

Utilizamos nomes compostos quer para invocar métodos quer para aceder à representação interna dentro dos métodos. Por exemplo, revisitando os números complexos, podemos agora utilizar objectos em que temos duas variáveis (internas) self.real e self.imaginario. Neste caso podemos implementar os números complexos como:

class complexo:
    def __init__(self, x, y):
        if not(isinstance(x, (int, float)) and isinstance(y, (int, float))):
            raise ValueError('complexo: argumentos invalidos, x e y tem de ser numeros')
        self.real = x
        self.imaginario = y
    
    def parte_real(self):
        return self.real
    
    def parte_imaginaria(self):
        return self.imaginario
    
    def e_zero(self):
        return self.__zero(self.parte_real()) and self.__zero(self.parte_imaginaria())
    
    def e_imaginario_puro(self):
        return self.__zero(self.parte_real()) and not self.__zero(self.parte_imaginaria())
    
    def igual(self, w):
        if not isinstance(w, complexo):
            raise ValueError('complexo: igual: z tem de ser complexo')
        return self.__zero(self.parte_real() - w.parte_real()) \
           and self.__zero(self.parte_imaginaria() - w.parte_imaginaria())
    
    def para_string(self):
        return str(self.parte_real()) + '+' + str(self.parte_imaginaria()) + 'i'
    
    def __zero(self, x):
        return abs(x) < 0.0000001

Exemplo:

>>> complexo
<class '__main__.complexo'>
>>> z = complexo(9, 4.5)
>>> z
<__main__.complexo object at 0x2bc779736d8>
>>> z.e_imaginario_puro()
False
>>> z.e_zero()
False
>>> z.parte_imaginaria()
4.5
>>> z.parte_real()
9
>>> w = complexo(9, 4.5)
>>> z == w
False
>>> z.igual(w)
True
>>> z.igual((9, 4.5))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 22, in igual
ValueError: complexo: igual: z tem de ser complexo
>>> print(w.para_string())
9+4.5i
>>>
>>> w.real = 0
>>> w.e_imaginario_puro()
True
>>>

Neste exemplo podemos ver que, uma vez definida uma classe, temos uma função com o nome da classe que nos permite criar objectos/instâncias dessa classe. Esta função, que internamente corresponde ao método __init__, substitui o construtor que definimos numa aula anterior quando estudámos o tipo abstracto para números complexos.

As variáveis definidas dentro dos métodos como self.x estabelecem associações locais ao objeto e que existe enquanto o objecto existir.

Devemos notar que não temos o reconhecedor e_complexo, passámos a ter o tipo complexo em Python e podemos utilizar as operações pré-definidas type e isinstance para reconhecer objectos do tipo complexo.

Na interacção acima é possível verificar que a linguagem Python não respeita a proibição de acesso directo à representação interna e, ainda que forneça mecanismos para trabalhar com objectos, não segue portanto alguns dos princípios inerentes ao paradigma de programação com objectos. Por outro lado a definição dos métodos com recurso ao argumento self pode também parecer estranho. Noutras linguagens que suportam este paradigma, como C++ ou Java, o acesso à representação interna é efectivamente limitado, utilizando diferentes níveis de visibilidade quer para as variáveis quer para os métodos, e em geral a definição dos métodos segue também princípios diferentes. Todavia Python é uma linguagem baseada em objectos, todos os tipos são classes, segue o princípio de acesso uniforme, e em que, como temos vindo a observar, podemos invocar em geral métodos sobre a todos os tipos de Python.

Exercício: Implementar as operações que definimos para o tipo abstracto complexo numa aula anterior utilizando para representação interna um objecto do tipo complexo aqui definido.

Objectos mutáveis

O exemplo que acabámos de ver continua a ser um tipo imutável. Vejamos como definir um tipo mutável, na realidade objectos cuja representação interna pode mudar. Neste caso passamos portanto a ter modificadores que modificam variáveis internas, ou seja, modificam o estado interno do objecto.

Consideremos novamente o tipo complexo. Para podermos modificar a representação interna, neste caso a parte real e a parte imaginária, podemos adicionar o seguintes métodos:

    def actualizar_parte_real(self, x):
        if not isinstance(x, (int, float)):
            raise ValueError('complexo: argumentos invalido, x tem de ser numero')
        self.real = x
    
    def actualizar_parte_imaginaria(self, y):
        if not isinstance(x, (int, float)):
            raise ValueError('complexo: argumentos invalido, x tem de ser numero')
        self.imaginario = y

Exemplo:

>>> z = complexo(9, 4.5)
>>> z.actualizar_parte_real(0)
>>> z.e_imaginario_puro()
True
>>> print(z.para_string())
0+4.5i
>>>

Polimorfismo

Já verificámos em diversas ocasiões que muitas operações em Python são sobrecarregadas. i.e., aplicam-se a vários tipos de dados. Como exemplo temos a operação + que podemos aplicar a inteiros, reais, strings, tuplos, listas, etc.

As operações que podem ser aplicadas sobre diferentes tipos de dados dizem-se polimórficas ou que apresentam a propriedade de polimorfismo.

Vejamos como utilizar polimorfismo para estender a operação + para o tipo complexo, implementando o método __add__:

    def __add__(self, z):
        if not isinstance(z, complexo):
            raise ValueError('complexo: argumentos invalido, z tem de ser complexo')
        return complexo(self.parte_real() + z.parte_real(),\
                        self.parte_imaginaria() + z.parte_imaginaria())        

Exemplo:

>>> z = complexo(9, 4.5)
>>> w = complexo(2, -5)
>>> x = z + w
>>> print(x.para_string())
11+-0.5i
>>>

Na realidade podemos utilizar polimorfismo para estender muitas das operações pré-definidas como __sub__ (-), __mul__ (*), __truediv__ (/), __eq__ (==) e __repr__ (transformação para string, i.e, representação externa).

Exercício: Estender as operações == e representação externa utilizando polimorfismo e substituindo os métodos igual e para_string, respectivamente.