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.