Linguagens e Ambientes de Programação (2022/2023)



Teórica 22 (24/mai/2023)

A extensibilidade como ideia orientadora da escrita de programas orientados pelos objetos.

O uso de testes explícitos de tipo compromete a extensibilidade dos programas. A falta de modularidade compromete a extensibilidade dos programas.

Ser minimalista.

Fatorizar na medida certa.



Extensibilidade e abstração

O que é um sistema extensível?

Um sistema extensível é um sistema desenhado para simplificar a sua evolução futura, com novas funcionalidades e novos tipos de objetos.

Para começar, parte do código é concebido para ser reutilizado em mais do que um contexto dentro do próprio programa, porventura também no futuro.

Outro objetivo é que a evolução do programa deve ser conseguida sem se alterar o que já foi escrito e verificado. Nós não gostamos de alterar código já escrito e verificado, porque isso obriga a retomar o contexto mental da escrita original do código, porventura alguns anos mais tarde. É grande a probabilidade de nos esquecermos de alguns invariantes e de introduzir erros.

Quanto precisamos de adicionar a um programa funcionalidade nova que ele não está preparado para receber, então temos de refatorizar o programa (isto é restruturar os conceitos e adaptar o código). Isso costuma ser trabalhoso e introduzir bugs. A grande vantagem da extensibilidade é que o programa aguenta mais tempo antes de precisar de ser refatorizado.

Como se obtém extensibilidade?

A extensibilidade obtém-se pela via da abstração:

Não é obrigatório escrever código extensível, mas muitas vezes desejamos escrever software extensível. As principais razões são as seguintes:

Curiosamente, muitas vezes escreve-se código extensível, mesmo quando há convencimento de que não haverá extensões futuras do programa. Porquê? Por causa dos três últimos pontos referidos atrás.


Extensibilidade obtida através do uso de abstrações naturais

Muitas vezes, basta organizar um sistema em torno de abstrações naturais, para ele ficar automaticamente extensível. No caso duma linguagem orientada pelos objetos estaticamente tipificada, como o Java, usam-se supertipos, subtipos e fatorização.

O sistema organizado em torno de abstrações naturais fica em princípio extensível assumindo que não há interação entre objetos, ou seja as operações disponíveis numa classe aplicam-se a this, sem envolver outros objetos. Esta é a situação que apareceu nos exemplos finais da aula teórica 21 (exemplo das classes Point1, Point2, Point3 e exemplo das Expressões Algébricas). Também irá aparecer nos exercícios da aula prática 12 (exercícios sobre sucessões matemáticas).

Eis agora um exemplo dum pequeno programa em Java que se considera extensível. Em linguagens como o OCaml ou o C, não existe suporte assumido para extensibilidade. Contudo podemos tentar organizar o mesmo programa em torno de abstrações naturais e obter algo que se pode chamar de extensibilidade limitada. Eis um exemplo do programa reescrito em C usando extensíbilidade limitada. Repare que existiu o cuidade de definir o tipo Shape, que representa um conceito abstrato.

Agorta atençãpo: quando as operações envolvem o objeto this mais outros objetos de tipo abstrato então a fatorização, só por si, não é suficiente para obter extensibilidade. Provavelmente, esta será a primeira vez que você vai ver este problema a ser discutido com detalhe.


Interação entre objetos de tipos diferentes

A partir de agora, assumimos que desejamos escrever código extensível.

A evitar: Testes explícitos de tipo concreto

Existe uma questão que, de forma dramática, dá origem a código não extensível. Trata-se do uso de testes para determinar o tipo concreto dum objeto. Esse teste costuma ser feito de forma direta, usando instanceof, mas por vezes também é feito de forma indireta, testando algum atributo do objeto, por exemplo o seu nome, a sua cor, etc.

Tal código nunca pode ser extensível, pois a sua lógica está comprometida com os tipos concretos existentes: ou seja, esse código não conseguirá lidar com novos tipos concretos a criar no futuro. Nessa situação, para incorporar novos tipos, seria necessário reescrever o código para tratar mais casos concretos, o que significa que o código não seria extensível.

A evitar: Quebra de modularidade

Uma classe deve implementar os seus próprios objetos e não executar tarefas de outros objetos. A tarefas dos outros objetos serão para implementar nas respetivas classes.

Por outras palavras: cada objeto trata de si próprio e respeita o que compete aos outros objetos.

Código duma classe A que está escrito com uma lógica que implica impor decisões aos objetos duma classe B, se calhar vai ter de ser reescrito quando se adicionar uma nova classe C, pois o mesmo tipo de decisões poderá precisar de se estender aos objetos da classe C.


Técnicas para evitar testes explícitos de tipo concreto

Geralmente, nas funções que, para além do this, recebem outro objeto como argumento, surge a tentação de testar diretamente o tipo do argumento. Isso tem de ser evitado a todo o custo, se tivermos como objetivo a escrita de código extensível.

Vejamos algumas técnicas que permitem evitar os testes explícitos de tipo concreto.

Técnica da chamada de método booleano

Em vez de testar diretamente o tipo dum objeto, podemos enviar-lhe uma mensagem a perguntar alguma coisa. Tal código já é extensível pois funciona com quaisquer objetos que suportem uma dada função. Repare, funciona mesmo com objetos de tipos a criar futuramente.

Por exemplo, num jogo baseado numa matriz bidimensional, em que vários monstros perseguem um herói, o que é que um monstro deverá fazer quando se cruza com outra personagem? Imagine que se trata duma função meets(vizinho) da classe Monstro.

Em conclusão, conseguimos escrever código extensível introduzindo o conceito abstrato de apetitoso.

Relativamente a detalhes de implementação, tipicamente define-se o método isTasty na raiz da hierarquia de classes retornando o valor false. Desta forma, por omissão, as personagens não são apetitosas. As personagens que forem apetitosas redefinem o método para retornar true.

Há uma grande tendência natural para programar de acordo com a visão do mundo fechado, porque geralmente o enunciado descreve personagens concretas e nós deixamo-nos levar por isso. É preciso fazer um esforço para programar o que o enunciado pede, mas usando conceitos abstratos inventados por nós, tais como o conceito de apetitoso.

Técnica das interfaces

Em Java podemos definir uma interface vazia, chamada Tasty e definir a classe do herói da seguinte forma: Depois para testar se o vizinho é apetitoso escreve-se: Repare que não há nada de errado em usar instanceof, neste caso. Aqui não estamos a testar o tipo concreto dum objeto. Aqui estamos a testar uma propriedade abstrata. Estas interfaces vazias especiais costumam ser designadas de marker interfaces.

Esta técnica não funciona em JavaScript. Podíamos tentar usar classes abstratas, mas uma classe não pode herdar de duas classes e nunca poderíamos ter um objeto com duas propriedades simultâneas e autónomas.

Técnica dos níveis

Suponha que queremos fazer uma simulação do mundo natural com grande variedade de animais. Entre essas classes de animais estabelecem-se regras de alimentação complexas, de tipo cadeia alimentar. Neste caso, uma simples função booleana, digamos chamada isEdible, não chega para capturar tal riqueza de relações (e, em todo o caso, todos os animais são comestíveis).

Neste caso, convém associar um nível alimentar a cada tipo de personagem e estabelecer a seguinte regra: uma personagem pode comer outra só no caso do nível alimentar da primeira ser superior ao da segunda. Concretamente, um objeto pode comer o seu vizinho se:

Métodos binários

Chama-se método binário a um método com um argumento, em que se espera que o valor do argumento tenha exatamente o mesmo tipo concreto de this. [Paper sobre métodos binários].

Por exemplo, para efeitos de reprodução, um animal pode precisar de saber se um outro animal, seu vizinho, é do mesmo tipo. Como fazer isso de forma geral, sem ter de referir o tipo concreto?

Faz-se assim em JavaScript:

Em Java: Em C++: Em C:

Técnica para evitar erros de modularidade

O uso de métodos, geralmente booleanos, pode ajudar-nos aqui.

Por exemplo, no contexto dum jogo, uma personagem P, controlada pelo teclado, empurra outra personagem Q.

Com a abordem correta, estamos preparados para criar novas personagens com comportamentos diferentes no método push, sem ser preciso estar a reescrever o código do objeto P.


Exceção: criação dos dados iniciais

Imagine um jogo em que os personagens iniciais são gerados dinamicamente e colocados em posições aleatórias da matriz bidimensional. Geralmente este código não pode ser extensível porque os personagens são concretos. Se mais tarde resolvermos mudar os personagens iniciais, será necessário voltar ao código de inicialização e rescrevê-lo.

Na inicialização, temos de abrir uma exceção no requisito da extensibilidade.


Ser minimalista

Escrever código extensível significa escrever código preparado para crescer com novas funcionalidades futuras.

Por vezes há a tentação de começar a introduzir funcionalidades adicionais "porque poderão ser precisas no futuro", apesar de não serem precisas imediatamente. A partir duma certa altura, já não se está a programar, digamos, um jogo concreto, mas sim uma framework genérica para um determinado tipo de jogos. Isso é uma perda de tempo e só vai complicar o programa desnecessariamente.

Recomenda-se a escrita dum programa que faça apenas aquilo que o enunciado pede. Recorre-se a um pequeno conjunto conceitos abstratos, inventados por nós, mas sem introduzir nenhum conceito abstrato desnecessário. O programa fica preparado para crescer. Mas o futuro é que dirá a forma como vai crescer.


Fatorizar na medida certa

Quando se fatoriza o código, é preciso cuidado para não exagerar a ponto de começar a trocar herança por generalidade. O que descreve aqui é um erro que alguns alunos fazem, ao entusiasmar-se demais com a fatorização.

No limite da fatorização, pode fazer-se migrar todo o código para a classe na raiz da hierarquia, deixando apenas os construtores nas outras classes. A classe de raiz fica muito geral e muito complexa, cheia de variáveis que servem para representar as diferentes propriedades de diversos tipos de entidades ao mesmo tempo. Os métodos também ficam muito complexos e cheios de ifs porque têm de lidar com muitos casos.

Este código é uma confusão por não ter uma estratificação lógica dos conceitos. Também não é extensível, pois para incorporar um novo tipo de entidade, seria preciso alterar o código da raiz para tratar mais um caso.



Vídeos antigos

Estes vídeos poderão estar um pouco desatualizados, pois foram feitos no contexto duma edição anterior do LAP. Contudo, partes dos vídeos poderão continuar a ter alguma utilidade.

#--- 20 40