Esse artigo faz parte da revista Java Magazine edição 56. Clique aqui para ler todos os artigos desta edição

img

Saiba como – e porquê – adotar técnicas de Programação Funcional na plataforma Java

Aplicando o estilo de programação funcional em Java, e examinando a linguagem Scala

O leitor que acompanha esta coluna já deve conhecer meu apreço por técnicas de programação funcional, que tenho comentado em diversos artigos, por exemplo, ao falar de concorrência e outras situações beneficiadas por esse paradigma. Muitos leitores devem imaginar: esse Osvaldo deve ser um veterano de LISP ou Scheme, que nunca se conformou com linguagens imperativas... longe da verdade. É fato que experimentei algumas linguagens funcionais, quase sempre em cursos universitários. Não cheguei a dominar nenhuma linguagem dessas, o que é impossível sem uma boa experiência prática, desenvolvendo aplicações reais. Sendo que o mercado de desenvolvimento de software no qual atuo dá pouca oportunidade para aventuras com linguagens alternativas.

O que acontece é o oposto – sou um especialista em linguagens OO; antes do Java programei muito em C++, também estudei outras como Eiffel e Smalltalk. Durante anos, acreditei que a POO era o Alfa e o Ômega, a solução suficiente para todos os problemas da programação. Com o tempo, fui vendo que não era bem assim; fui me inteirando de uma realidade mais rica. E tentando integrá-la à minha prática profissional – independente da linguagem utilizada.

Como já gastei algumas páginas desta revista com propaganda do paradigma funcional, resolvi dar o serviço completo e explicar o estilo de programação Java funcional “light” que tenho aplicado cada vez mais aos meus projetos. Pra completar, termino com uma olhada preliminar de Scala, uma nova linguagem de programação para a plataforma Java SE que combina as vantagens de OO e programação funcional.

Por quê a programação funcional importa?

Para começar fundamentando minha tese, não há palavras melhores do que as escritas por John Hughes em seu célebre paper de 1984, Why Functional Programming Matters. Resumo aqui somente o essencial. Na verdade, vou resumir tanto que só apresentarei um único argumento.

A maior vantagem da programação funcional é sua modularidade. Em toda a evolução das LPs, vemos que essa qualidade é fundamental. Na evolução de linguagens “espaguete” como Fortran para as estruturadas como C e Pascal, as novas linguagens permitiam organizar um programa em procedimentos e estruturas de dados customizadas. Estes artefatos bem definidos e isolados ficavam mais simples de entender, reusáveis, fáceis de dar manutenção, etc.

No próximo passo evolutivo, as linguagens OO investiram novamente na modularidade. Uma classe permite unificar, de forma muito poderosa, algoritmos e dados relacionados. O polimorfismo potencializa essa modularização, permitindo que o cliente de uma classe manipule de maneira uniforme toda uma hierarquia de classes derivadas. Por exemplo, um método iterator() pode ser usado de forma homogênea para centenas de classes compatíveis com Collection.

Estes avanços foram importantes, mas agem numa única dimensão: a estrutural. Substituímos variáveis globais por parâmetros, GOTO por WHILE, cascatas de ifs ou switch por polimorfismo, etc. Mas no fundo, estamos programando do mesmo jeito que antes, só com mais ordem e facilidade.

Estado mutável

Há outra dimensão na qual podemos avançar na modularidade. Começaremos definindo o conceito de estado. Um processo possui um estado, que podemos definir, informalmente, como o conjunto de valores de todas as variáveis existentes a qualquer momento. Mais precisamente: o conteúdo total do heap, e dos stacks de todos os threads. Em linguagens OO este estado é tipicamente encapsulado em muitas pequenas partes; em especial, cada objeto mutável (que possui atributos cujos valores podem ser alterados) possui seu próprio estado, que constitui um pedacinho minúsculo do estado do programa inteiro.

Um efeito colateral é toda ação do programa que produz alteração do seu estado. Num código como x = 10; ++x, a segunda instrução, o ++x, é um efeito colateral. Também chamamos isso de atribuição destrutiva, pois “destrói” o valor anterior (10) que estava associado à variável x.

O leitor atento pode imaginar que o x = 10 também altera o estado do programa, pois cria uma variável que não existia antes. Mas isso não é verdade, de um ponto de vista abstrato, matemático. Considere esta instrução como um simples binding: uma associação nome?valor. Imagine um heap infinito, onde todos os objetos possíveis existem o tempo todo: por exemplo, todos os 232 inteiros possíveis, todas as Strings com todas as infinitas combinações de caracteres, etc. Neste modelo teórico, a única coisa que um binding faz é dar nome aos bois; associar a certo objeto um identificador que permite referenciá-lo. É claro que no mundo real da implementação de qualquer linguagem, essa abstração é quebrada, pois qualquer valor tem que ser alocado na memória e inicializado num determinado instante, e pode deixar de existir no instante seguinte. Porém, isso é apenas um detalhe de implementação.

Para a questão de modularidade, somente as atribuições destrutivas são realmente danosas, vejamos o porquê. Quando você pensa na interface pública de um método ou objeto, parece que esta é definida somente pelos seus parâmetros, tipo de retorno, e no caso do Java suas exceções. Mas na verdade, o método pode ter também dependências do estado do programa. E pode modificar esse estado, alterando atributos de objetos. Assim, a interface real dos seus métodos e objetos acaba sendo bem maior do que parece, o que aumenta o acoplamento do código e reduz a modularidade.

Modularidade é praticamente sinônimo de baixo acoplamento: dividir o programa em pedaços pequenos, independentes, fáceis de combinar e reusar em vários contextos.

O termo “efeito colateral” é utilizado porque as alterações de estado permitem interferências indiretas entre diversos componentes de um programa. Se você tem um objeto A cujo comportamento depende do estado de um objeto B, e um objeto C que invoca métodos de B que alteram esse estado, então isso induz uma alteração de comportamento em Asem que C jamais tenha se comunicado diretamente com A.

Uma conseqüência notável da programação baseada em efeitos colaterais é que somos obrigados a micro-gerenciar o tempo e a ordem das operações. Temos que determinar o momento exato em que qualquer mudança de estado acontece, o que complica a programação desnecessariamente e bloqueia técnicas importantes, como veremos.

Outro aspecto da modularidade de LFs, discutido no paper de Hughes, mas não aqui, é a composição de funções. Linguagens funcionais facilitam a criação de funções compostas a partir de funções preexistentes, explorando avaliação lazy, computação “high-order”, e outras técnicas.

Estado Gerenciado

Para não dizerem que me limitei a papagaiar um paper escrito há um quarto de século, proponho outro ponto de vista. O conceito de “memória gerenciada” é bem conhecido, graças às plataformas Java e .NET. Nestas plataformas, não se pode fazer coisas como acessar a memória arbitrariamente, ou realizar operações inválidas sobre um objeto. Por exemplo, não existem operações como delete ou free() para desalocar objetos; mas temos o Garbage Collector, que faz isso automaticamente (e sem risco de erros). Da mesma forma, é impossível realizar typecasts ilegais.

Após décadas de competição entre as linguagens de baixo nível com gerenciamento de memória manual e livre, e as de alto nível com memória automática e type-safe, a opção gerenciada venceu. Não há quase nenhuma linguagem moderna que não seja assim. Todas as linguagens de que você ouve falar ultimamente – Java, C#, Ruby, Python, Groovy, Javascript, Scala, Fortress, Haskell, etc., todas são gerenciadas. A memória manual é cada vez mais uma técnica de nicho, para programar sistemas operacionais, device drivers, firmware e coisas desse nível.

O exemplo da memória gerenciada é ideal, pois tem a ver com o estado do programa. Afinal, 99% do que chamamos “estado do programa” é o conteúdo do heap[1]. Se já temos memória automática do ponto de vista do gerenciamento de espaço (deleção de objetos inatingíveis) e segurança (operações type-safe), por que não dar um passo além e automatizar completamente as transições de estado?

Vamos tornar as coisas mais concretas com um exemplo. Desejamos um método reverse(), que recebendo uma lista, retorna outra lista com os mesmos elementos numa ordem inversa; ex.: reverse([a,b,c]) = [c,b,a]. Um programador Java provavelmente escreveria este método assim:

 

List reverse (List list) {

  List ret = new ArrayList(list.length);

  for (int i = list.size() - 1; i >= 0; --i)

    ret.add(list.get(i));

  return list;

}

 

Note que este código faz uso amplo de atribuições destrutivas. Primeiro no array ret, que é inicialmente alocado com null em todas as posições, sendo que estes null são posteriormente substituídos por valores. Segundo, na variável i, que controla o loop for.

Veja, agora, a versão típica de uma linguagem funcional, no caso Haskell:

 

reverse []       = []

reverse (a:x) = reverse x ++ [a]

 

Nesta versão, a iteração é substituída por recursão (na segunda linha). Por exemplo, reverse [a,b,c] resultaria em reverse [b,c] ++ [a], e assim por diante. O objetivo aqui não é mostrar que o código Haskell é mais simples ou mais curto – isso é em parte devido a características secundárias, como uma sintaxe mais enxuta para definir funções ou para manipular listas. Chamo a atenção do leitor para dois fatos mais importantes:

·       A ausência total de atribuição destrutiva no código;

·       O uso de recursão no lugar de loops.

Essas duas características tornam o código funcional tipicamente mais simples, e superior em outros aspectos, que código imperativo equivalente, mesmo para cenários mais realistas que um algoritmo “alô mundo” como reverse(). Note, no entanto, que não há nada de tão especial no exemplo em Haskell. Poderíamos ter feito uma implementação parecida em Java; vamos tentar:

 

List reverse (List list) {

  return list.isEmpty() ? Collections.emptyList()

                                   : reverse(list.subList(1, list.size())).concat(list.get(0));

}

 

Essa nova versão é praticamente igual ao código Haskell. O leitor atento notará que utilizei um método concat() que não existe na interface java.util.List – tal método retornaria uma nova lista, sem alterar a original – mas isso é apenas uma lacuna da API, que poderíamos corrigir.

A programação funcional é um estilo, um paradigma, em princípio aplicável a qualquer linguagem. A vantagem das linguagens funcionais é de facilitar esse estilo, seja através de recursos sintáticos, bibliotecas e runtimes que favorecem técnicas funcionais; seja obrigando o uso dessas técnicas, por exemplo, proibindo atribuições destrutivas e iteração não-recursiva (como Haskell faz).

 

Observando a equivalência de todas as versões de reverse(), não será surpresa para o leitor se eu disser que os programas funcionais possuem tanto estado mutável quanto os imperativos. A diferença é que numa linguagem funcional, quem manipula esse estado é o compilador. Por exemplo, o código recursivo não possui nenhuma variável de controle de loop, mas a recursão utiliza o stack de invocações para isso: a “altura” do stack determina a profundidade de recursão. Todavia, diferente de variáveis declaradas e instanciadas pelo programa, o stack é uma estrutura de dados mutável gerenciada pelo runtime (compilador, bibliotecas, VM). Então, qualquer “sujeira” que o runtime tenha que fazer não nos afeta, pois será feita sempre de forma correta e sem nenhum impacto negativo sobre o programa – a filosofia da execução “gerenciada”.

Um exemplo concreto: como as linguagens funcionais obrigam ao uso intenso de recursão, é preciso evitar o risco óbvio de gerar stack overflow, por exemplo, numa chamada a reverse() com uma lista de milhões de elementos. Mas os runtimes de linguagens funcionais possuem otimizações que eliminam o custo da maioria das invocações recursivas; resumindo, o compilador transforma a recursão num loop, com atribuições destrutivas. No entanto, isso é um detalhe de implementação e não afeta de forma alguma as qualidades do programa que se beneficiam com o estilo funcional.

Podemos concluir, então, duas coisas:

·       A alteração de estado explícito que existe em linguagens imperativas como Java não é essencial. É possível programar em Java em “estilo funcional”, ainda que o resultado possa não ser tão elegante ou eficiente, devido à falta de suporte para esse estilo pela linguagem e runtime;

·       A ausência de estado mutável de linguagens funcionais – que confere uma pureza matemática à sua sintaxe – é apenas aparente. Os programas possuem estado, e este muda o tempo todo; só que estas mudanças são gerenciadas: totalmente controladas pelo compilador.

Concorrência

Um dos benefícios mais gritantes do estilo funcional – especificamente, a ausência de estado mutável explícito – é a eliminação de todos os seus problemas de concorrência. Esqueça deadlocks, livelocks... esqueça até mesmo locks.

Imagine um método mais realista, por exemplo, double calculaSaldo(), que inspeciona uma List de uma conta. Essa lista não pode ser alterada durante o cálculo do saldo, senão poderemos ter erros como IndexOutOfBoundsException ou ConcurrentModificationException – ou ainda pior, não ter exceções... e realizar um cálculo incorreto. Pra evitar isso, há várias opções:

·       Usar blocos ou métodos synchronized (ou equivalentes, como Lock da java.util.concurrent), não só em calculaSaldo(), mas em todo e qualquer método que manipule a lista de saldos;

·       Passar para calculaSaldo() uma cópia da lista original. (Mesmo assim, precisamos fazer lock no código que faz esta cópia, e em outros lugares que manipulam a lista original.);

·       ...

Quer ler esse conteúdo completo? Tenha acesso completo