Back-End

27 ago, 2012

Pensamento funcional: acoplamento e composição – Parte 02

Publicidade

Na última parcela, ilustrei diversas formas de reutilização de código. Na versão orientada a objeto, extraí métodos duplicados, movendo-os para uma superclasse juntamente com um campo protegido . Na versão funcional, extraí as funções puras (as que não têm efeitos colaterais) em sua própria classe, chamando-as ao fornecer valores de parâmetros. Alterei o mecanismo de reutilização do campo protegido por herança para parâmetros de método. Os recursos (como herança) que compreendem linguagens orientadas a objetos têm benefícios claros, mas eles também podem ter efeitos colaterais inadvertidos. Como alguns leitores comentarão acertadamente, muitos desenvolvedores de OOP experientes aprenderam a não compartilhar estado por meio de herança por esse exato motivo. Mas se seu paradigma profundamente enraizado é orientação a objeto, algumas vezes é difícil ver alternativas.

Nesse artigo, o acoplamento por meio de mecanismos de linguagem será comparado com composição mais código portátil como forma de extrair código reutilizável — que também serve para expor uma diferença filosófica chave sobre a reutilização de código. Primeiro, um problema clássico será revisitado: como escrever um método equals() adequado na presença de herança.

O método equals() revisitado

O livro de Joshua Bloch, Effective Java, inclui uma seção sobre como criar métodos equals() e hashCode() apropriados (consulte Recursos). A complicação suscita da interação entre a semântica da igualdade e a herança. O método equals() no Java deve obedecer às características especificadas pelo Javadoc para Object.equals():

  • Ele é reflexivo: para qualquer valor x de referência não nulo, x.equals(x) deve retornar verdadeiro.
  • Ele é simétrico: para quaisquer valores x e y de referência não nulos, x.equals(y) deverá retornar verdadeiro se, e somente se, y.equals(x) retornar verdadeiro.
  • Ele é transitivo: para quaisquer valores x, y e z de referência não nulos, se x.equals(y) retornar verdadeiro e y.equals(z) retornar verdadeiro, então x.equals(z) deverá retornar verdadeiro.
  • Ele é consistente: para quaisquer valores x e y de referência não nulos, várias chamadas de x.equals(y) consistentemente retornam verdadeiro ou consistentemente retornam falso, desde que nenhuma informação em comparação de equals nos objetos seja modificada.
  • Para qualquer valor x de referência não nulo, x.equals(null) deverá retornar falso.

Em seu exemplo, Bloch cria duas classes – uma Point e uma ColorPoint -, e tenta criar um método equals() que funcione corretamente para ambas. Tentar ignorar o campo extra na classe herdada quebra a simetria e tentar tratá-la quebra a transitividade. Josh oferece um prognóstico sombrio para esse problema:

Simplesmente não há maneira de estender uma classe instanciável e adicionar um aspecto, ao mesmo tempo em que preserva o contrato de equals.

Implementar igualdade é muito mais simples quando não é preciso se preocupar com campos mutáveis herdados. Adicionar mecanismos de acoplamento como herança cria nuances e armadilhas sutis. (Acontece que há uma maneira de solucionar esse problema que retém herança, mas ao custo de adicionar um método dependente extra. Consulte a seção Herança e canEqual()¹ na barra lateral.)

Lembre-se da citação de Michael Feathers que orienta os dois artigos anteriores dessa série:

A programação orientada a objeto torna o código compreensível ao conter partes móveis.A programação funcional torna o código mais compreensível por meio da minimização de partes móveis.

A dificuldade em implementar equals() ilustra a metáfora de Feathers sobre partes móveis . A herança é um mecanismo de acoplamento: ela vincula duas entidades com regras bem definidas sobre visibilidade, despacho de método e assim por diante. Em linguagens como o Java, o polimorfismo também é vinculado à herança. Esses pontos de acoplamento são o que torna o Java uma linguagem orientada a objeto. Mas permitir partes móveis tem consequências, especialmente no nível da linguagem. Helicópteros são notoriamente difíceis de pilotar porque existe um controle para cada um dos quatro membros do piloto. Mover um controle afeta os outros controles, portanto o piloto deve ser um especialista em lidar com os efeitos colaterais que cada controle tem sobre os outros. Partes de linguagem são como controles do helicóptero: não é possível adicioná-las (ou alterá-las) prontamente sem afetar todas as outras partes.

A herança é uma parte tão natural de linguagens orientadas a objeto que a maioria dos desenvolvedores se esquece do fato de que, em seu núcleo, ela é um mecanismo de acoplamento. Quando coisas estranhas são quebradas ou não funcionam, você simplesmente aprende as regras (algumas vezes arcanas) para minimizar o problema e prosseguir. No entanto, essas regras implícitas de acoplamento afetam a forma de pensar sobre aspectos fundamentais de seu código, como a forma de obter reutilização, extensibilidade e igualdade.

O Effective Java provavelmente não teria sido tão bem-sucedido se Bloch tivesse deixado incerta a questão da igualdade. Em vez disso, ele a usou como uma oportunidade de reapresentar bons conselhos apresentados anteriormente no livro: prefira a composição à herança. A solução de Bloch para o problema de equals() usa composição ao invés de acoplamento. Ela evita completamente a herança, fazendo com que ColorPoint possua uma referência a uma instância de Point em vez de se tornar um tipo de ponto.

Composição e herança

Composição — na forma de parâmetros passados mais funções de primeira classe – aparece frequentemente em bibliotecas de programação funcional como um mecanismo de reutilização. Linguagens funcionais obtêm a reutilização em nível mais grosseiro do que linguagens orientadas a objeto, extraindo mecanismos comuns com comportamento parametrizado. Sistemas orientados a objetos consistem em objetos que se comunicam enviando mensagens para (ou, mais especificamente, executando métodos em) outros objetos. A Figura 1 representa um sistema orientado a objetos:

Ao descobrir uma coleção útil de classes e suas mensagens correspondentes, você extrai aquele gráfico de classes para reutilizar, como mostrado na Figura 2:

De forma não surpreendente, um dos livros mais populares no mundo da engenharia de software é Design Patterns: Elements of Reusable Object-Oriented Software (consulte Recursos), um catálogo exatamente do tipo de extração mostrado na Figura 2. A reutilização por meio de padrões é tão abrangente que vários outros livros também catalogam (e fornecem nomes distintos para) tais extrações. O movimento de padrões de projeto foi um tremendo impulso para o mundo de desenvolvimento de software, pois ele fornece nomenclatura e exemplos. Mas, fundamentalmente, a reutilização por meio de padrões de projeto possui baixa granularidade: uma solução (o Padrão Flyweight, por exemplo) é ortogonal a outro (o padrão Memento). Cada um dos problemas solucionados por padrões de projeto é altamente específico, o que torna padrões úteis, porque frequentemente é possível encontrar um padrão que corresponda a seu problema atual — mas limitadamente úteis, pois são muito específicos aos problemas.

Programadores funcionais também desejam código reutilizável, entretanto eles usam diferentes blocos de criação. Em vez de tentar criar relacionamentos bem conhecidos (acoplamentos) entre estruturas, a programação funcional tenta extrair mecanismos de reutilização de alta granularidade – baseados em parte na teoria da categoria, um ramo da matemática que define relacionamentos (morfismo) entre tipos de objetos (consulte Recursos). A maioria dos aplicativos utiliza listas de elementos, portanto uma abordagem funcional é criar mecanismos de reutilização em torno da ideia de listas mais código contextualizado e portátil. Linguagens funcionais baseiam-se em funções de primeira classe (funções que podem aparecer em qualquer lugar em que uma criação de outra linguagem pode aparecer) como valores de parâmetros e de retorno. A Figura 3 ilustra esse conceito:

Na Figura 3, a caixa de engrenagens representa abstrações que lidam genericamente com alguma estrutura de dados fundamental e a caixa amarela representa código portátil, encapsulando dados em seu interior.

Blocos de criação comum

No segundo artigo dessa série, construí um exemplo de classificador de números usando a biblioteca Functional Java (consulte Recursos). Aquele exemplo usa três blocos de criação diferentes, mas sem explicação. Investigarei aqueles blocos de criação agora.

Folds

Um dos métodos no classificador de números realiza uma soma entre todos os fatores reunidos. Esse método aparece na Listagem 1:

public int sum(List<Integer> factors) {
return factors.foldLeft(fj.function.Integers.add, 0);
}

À primeira vista, não é óbvio como o corpo de uma linha na Listagem 1 realiza uma operação de soma. Esse exemplo é um tipo específico na família geral de transformações de lista chamadas de transformações de catamorfismos — de uma forma para outra (consulte Recursos). Nesse caso, a operação fold refere-se a uma transformação que combina cada elemento da lista com o próximo elemento, acumulando um único resultado para a lista inteira. Um fold left recolhe a lista para o lado esquerdo, começando com um valor inicial e combinando cada elemento da lista para produzir um resultado final. A Figura 4 ilustra uma operação de fold:

Como a adição é comutativa, não importa se for usada a operação foldLeft() ou a operação foldRight(). Mas para algumas operações (incluindo subtração e divisão) a ordem é importante, portanto o método simétrico foldRight() existe para tratar esses casos.

A Listagem 1 usa a enumeração fornecida pelo Functional Java add . Ela inclui as operações matemáticas mais comuns. Mas, e os casos em que são necessários critérios mais refinados? Considere o exemplo na Listagem 2:

static public int addOnlyOddNumbersIn(List<Integer> numbers) {
return numbers.foldLeft(new F2<Integer, Integer, Integer>() {
public Integer f(Integer i1, Integer i2) {
return (!(i2 % 2 == 0)) ? i1 + i2 : i1;
}
}, 0);
}

Como o Java ainda não tem funções de primeira classe na forma de blocos lambda (consulte Recursos), o Functional Java é forçado a improvisar com genéricos. A classe integrada F2 tem a estrutura correta para uma operação de fold: ela cria um método que aceita dois parâmetros inteiros (eles são os dois valores a serem colocados um sobre o outro) e o tipo de retorno. O exemplo na Listagem 2 soma números ímpares retornando a soma de ambos os números somente se o segundo número for ímpar, caso contrário, retorna somente o primeiro número.

Filtragem

Outra operação comum em listas é a filtragem: criar uma lista menor ao filtrar itens na lista com base em algum critério definido pelo usuário. A filtragem é ilustrada na Figura 5:

Ao realizar a filtragem, você produz outra lista (ou coleção) potencialmente menor que a original, dependendo dos critérios de filtragem. No exemplo do classificador de números, foi usada a filtragem para determinar os fatores de um número, como mostra a Listagem 3:

public boolean isFactor(int number, int potential_factor) {
return number % potential_factor == 0;
}

public List<Integer> factorsOf(final int number) {
return range(1, number + 1)
.filter(new F<Integer, Boolean>() {
public Boolean f(final Integer i) {
return isFactor(number, i);
}
});
}

O código na Listagem 3 cria um intervalo de números (como uma Lista) de 1 até o número de destino e, a seguir, aplica o método filter() , usando o método isFactor() (definido na parte superior da listagem) para eliminar números que não sejam fatores do número de destino.

A mesma funcionalidade mostrada na Listagem 3 pode ser obtida de forma muito mais concisa em uma linguagem que tenha fechamentos. Uma versão do Groovy aparece na Listagem 4:

def isFactor(number, potential) {
number % potential == 0;
}

def factorsOf(number) {
(1..number).findAll { i -> isFactor(number, i) }
}

A versão do Groovy de filter() é findAll(), que aceita um bloco de códigos especificando seus critérios de filtro. A última linha do método é o valor de retorno do método, que é a lista de fatores, nesse caso.

Mapeamento

A operação map transforma uma coleção em uma nova coleção aplicando uma função a cada um dos elementos, conforme ilustrado na Figura 6:

No exemplo classificador de números, o mapeamento na versão otimizada do método factorsOf() foi usado, como mostra a Listagem 5:
public List<Integer> factorsOfOptimized(final int number) {
final List<Integer> factors = range(1, (int) round(sqrt(number) + 1))
.filter(new F<Integer, Boolean>() {
public Boolean f(final Integer i) {
return isFactor(number, i);
}
});
return factors.append(factors.map(new F<Integer, Integer>() {
public Integer f(final Integer i) {
return number / i;
}
}))
.nub();
}

O código na Listagem 5 primeiro reúne a lista de fatores até a raiz quadrada do número de destino, salvando-a na variável factors . Em seguida, anexa uma nova coleção a factors – gerada pela função map() na lista factors aplicando – o código para gerar a lista simétrica (o fator correspondente acima da raiz quadrada). O último método nub() assegura que não haja duplicações na lista.

Como é comum, a versão do Groovy é muito mais direta, como mostra a Listagem 6, porque tipos flexíveis e blocos de códigos são cidadãos de primeira classe:
def factorsOfOptimized(number) {
def factors = (1..(Math.sqrt(number))).findAll { i -> isFactor(number, i) }
factors + factors.collect({ i -> number / i})
}

Os nomes dos métodos diferem, mas o código na Listagem 6 executa a mesma tarefa que o código na Listagem 5: obtém um intervalo de números de 1 até a raiz quadrada, filtra-o para somente fatores, anexa uma lista a ele mapeando cada um dos seus valores com a função que produz um fator simétrico.

Perfeição funcional revisitada

Com a disponibilidade de funções de ordem mais alta, todo o problema de determinar se um número é perfeito ou não se resume a algumas linhas de código no Groovy, como mostra a Listagem 7:
def factorsOf(number) {
(1..number).findAll { i -> isFactor(number, i) }
}

def isPerfect(number) {
factorsOf(number).inject(0, {i, j -> i + j}) == 2 * number
}

Esse certamente é um exemplo forçado relacionado à classificação de números, portanto é difícil generalizar para diferentes tipos de código. No entanto, notei uma mudança significativa no estilo de codificação em projetos que usam linguagem que suportam essas abstrações (sejam elas linguagens funcionais ou não). Notei isso pela primeira vez em projetos Ruby on Rails. O Ruby tem esses mesmos métodos de manipulação de listas que usam blocos de fechamento e me impressionou a frequência com que collect(), map() e inject() aparecem. Quando você se acostuma a ter essas ferramentas em sua caixa de ferramentas, verá que as usará repetidamente.

 Conclusão

Um dos desafios de aprender um novo paradigma, como programação funcional, é aprender os novos blocos de criação e “vê-los” sobressair de problemas como uma solução potencial. Na programação funcional, você tem muito menos abstrações, mas cada uma é genérica (com especificidade adicionada por meio de funções de primeira classe). Como a programação funcional baseia-se muito em parâmetros passados e composição, você tem menos regras para aprender sobre interações entre partes móveis, tornando seu trabalho mais fácil.

A programação funcional obtém a reutilização de código abstraindo partes genéricas de mecanismos, personalizáveis por meio de funções de ordem mais alta. Esse artigo destacou algumas das dificuldades introduzidas por mecanismos de acoplamento em linguagens orientadas a objeto, o que levou a uma discussão sobre a forma comum como gráficos de classes são coletados para produzir um código reutilizável. Esse é o domínio dos padrões de projeto. Em seguida, mostrei como mecanismos de alta granularidade, com base na teoria da categoria, permitem aproveitar o código escrito (e depurado) pelo projetista da linguagem para solucionar problemas. Em cada caso, a solução é sucinta e declarativa. Isso ilustra a reutilização de código ao compor parâmetros e funcionalidade para criar comportamento genérico.

No próximo artigo, aprofundarei em recursos funcionais de algumas linguagens dinâmicas na JVM: Groovy e JRuby.

¹Em Programming Scala, os autores fornecem um mecanismo que permite que a igualdade funcione mesmo na presença de herança (consulte Recursos). A raiz do problema que Bloch discute é que classes pai não “conhecem” o suficiente sobre subclasses para determinar se elas deverão participar na comparação de igualdade. Para tratar isso, você adiciona um método canEqual() à classe base e o substitui por classes filhas para as quais deseja comparações de igualdade. Isso permite que a classe atual (por meio de canEqual()) decida se é razoável e sensato igualar dois tipos.

Esse mecanismo soluciona o problema, mas com o custo de adicionar mais um ponto de acoplamento entre as classes pai e filha por meio do método canEqual().

***

Faça o download de uma avaliação grátis do dispositivo de software IBM Protector para Mail Security, que entrega proteção integrada para IBM Quickr®, para WebSphere® Portal e para software IBM Connections com recursos complexos de filtragem de conteúdo. Este dispositivo estende os recursos do IBM Domino e de infraestruturas de e-mail mistas para varrer e-mail em busca de informações de negócios confidenciais ou conteúdo pessoal, tais como números de seguridade social, palavras-chaves customizáveis e conteúdo ofensivo, e para bloqueá-los antes de sair da empresa.

Recursos

Aprender

  • Effective Java (Joshua Bloch, Addison-Wesley, 2001): O livro de Bloch é um trabalho precursor sobre como usar a linguagem Java corretamente.
  • Programming in Scala, 1ª ed. (Martin Odersky, Lex Spoon e Bill Venners): Esse livro está disponível on-line. A excelente segunda edição está disponível nas livrarias.
  • Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma et al., Addison-Wesley, 1994): a obra clássica da Gang of Four sobre padrões de design.
  • Teoria da categoria: Ramo da matemática que aborda de forma abstrata as propriedades de conceitos matemáticos particulares.
  • Catamorfismo: Um catamorfismo denota o mapeamento único de uma álgebra para outra álgebra
  • Caderno do projetista da linguagem: Primeiro, não faça o mal” (Brian Goetz, developerWorks, julho de 2011): Leia sobre as considerações de projeto por trás da expressão lambda, um novo recurso de linguagem em desenvolvimento para o Java SE 8. Expressões lambda são literais de função — que incorporam um cálculo adiado que pode ser tratado como um valor e chamado posteriormente.
  • Navegue pela livraria de tecnologia para ver livros sobre este e outros tópicos técnicos.
  • Zona tecnologia Java do developerWorks: Encontre centenas de artigos sobre quase todos os aspectos da programação Java.

Obter produtos e tecnologias

Discutir

Participe da comunidade do developerWorks.

***

Sobre o autor: Neal Ford é um arquiteto de software e Meme Wrangler, na ThoughtWorks, uma consultoria global de TI. Projeta e desenvolve aplicativos, materiais de instrução, artigos para revistas, treinamentos e apresentações em vídeo/DVD, e é autor ou editor de livros que abordam uma variedade de tecnologias, inclusive The Productive Programmer Seu enfoque é o projeto e construção de aplicativos corporativos de grande porte. Também é orador internacionalmente aclamado nas conferências de desenvolvedores ao redor do mundo. Conheça seu Web site.

***

Artigo original disponível em: http://www.ibm.com/developerworks/br/library/j-ft6/index.html