Seu código Android à prova de futuro, Parte 1: Fundamentos de programação funcional e reativa

Publicados: 2022-08-31

Escrever código limpo pode ser um desafio: bibliotecas, frameworks e APIs são temporários e se tornam obsoletos rapidamente. Mas os conceitos e paradigmas matemáticos são duradouros; eles exigem anos de pesquisa acadêmica e podem até durar mais do que nós.

Este não é um tutorial para mostrar como fazer X com a Biblioteca Y. Em vez disso, nos concentramos nos princípios duradouros por trás da programação funcional e reativa para que você possa criar uma arquitetura Android confiável e à prova de futuro, e dimensionar e se adaptar às mudanças sem comprometer eficiência.

Este artigo estabelece as bases e, na Parte 2, mergulharemos em uma implementação de programação reativa funcional (FRP), que combina programação funcional e reativa.

Este artigo foi escrito com os desenvolvedores Android em mente, mas os conceitos são relevantes e benéficos para qualquer desenvolvedor com experiência em linguagens de programação gerais.

Programação Funcional 101

A programação funcional (FP) é um padrão no qual você constrói seu programa como uma composição de funções, transformando dados de $A$ para $B$, para $C$, etc., até que a saída desejada seja alcançada. Na programação orientada a objetos (OOP), você diz ao computador o que fazer instrução por instrução. A programação funcional é diferente: você abre mão do fluxo de controle e define uma “receita de funções” para produzir seu resultado.

Um retângulo verde à esquerda com o texto "Input: x" tem uma seta apontando para um retângulo cinza claro rotulado "Function: f". Dentro do retângulo cinza claro, há três cilindros com setas apontando para a direita: o primeiro é azul claro rotulado "A(x)", o segundo é azul escuro rotulado "B(x)" e o terceiro é cinza escuro rotulado "C (x)." À direita do retângulo cinza claro, há um retângulo verde com o texto "Saída: f(x)." A parte inferior do retângulo cinza claro tem uma seta apontando para o texto "Efeitos colaterais".
O padrão de programação funcional

O FP tem origem na matemática, especificamente no cálculo lambda, um sistema lógico de abstração de funções. Em vez de conceitos de POO como loops, classes, polimorfismo ou herança, FP lida estritamente com abstração e funções de ordem superior, funções matemáticas que aceitam outras funções como entrada.

Em poucas palavras, o PF tem dois “jogadores” principais: dados (o modelo ou informação necessária para o seu problema) e funções (representações do comportamento e transformações entre os dados). Por outro lado, as classes OOP vinculam explicitamente uma estrutura de dados específica de domínio particular - e os valores ou estado associados a cada instância de classe - a comportamentos (métodos) que devem ser usados ​​com ela.

Examinaremos três aspectos-chave do PF mais de perto:

  • PF é declarativa.
  • FP usa composição de funções.
  • As funções FP são puras.

Um bom ponto de partida para mergulhar ainda mais no mundo FP é Haskell, uma linguagem puramente funcional e fortemente tipada. Eu recomendo o Learn You a Haskell for Great Good! tutorial interativo como um recurso benéfico.

FP Ingrediente #1: Programação Declarativa

A primeira coisa que você notará sobre um programa FP é que ele é escrito em estilo declarativo, em oposição ao estilo imperativo. Em suma, a programação declarativa diz a um programa o que precisa ser feito em vez de como fazê-lo. Vamos fundamentar esta definição abstrata com um exemplo concreto de programação imperativa versus declarativa para resolver o seguinte problema: Dada uma lista de nomes, retorne uma lista contendo apenas os nomes com pelo menos três vogais e com as vogais mostradas em letras maiúsculas.

Solução Imperativa

Primeiro, vamos examinar a solução imperativa deste problema em Kotlin:

 fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Analisaremos agora nossa solução imperativa com alguns fatores-chave de desenvolvimento em mente:

  • Mais eficiente: esta solução tem um uso de memória ideal e um bom desempenho na análise do Big O (com base em um número mínimo de comparações). Nesse algoritmo, faz sentido analisar o número de comparações entre os caracteres porque essa é a operação predominante em nosso algoritmo. Seja $n$ o número de nomes e $k$ o comprimento médio dos nomes.

    • Pior número de comparações: $n(10k)(10k) = 100nk^2$
    • Explicação: $n$ (loop 1) * $10k$ (para cada caractere, comparamos com 10 vogais possíveis) * $10k$ (executamos a verificação isVowel() novamente para decidir se o caractere deve ser maiúsculo - novamente, no pior caso, isso se compara com 10 vogais).
    • Resultado: como o tamanho médio do nome não será maior que 100 caracteres, podemos dizer que nosso algoritmo é executado em tempo $O(n)$ .
  • Complexo com baixa legibilidade: Em comparação com a solução declarativa que consideraremos a seguir, essa solução é muito mais longa e difícil de seguir.
  • Propenso a erros: o código altera o result , vowelsCount e transformedChar ; essas mutações de estado podem levar a erros sutis, como esquecer de redefinir vowelsCount de volta para 0. O fluxo de execução também pode se tornar complicado e é fácil esquecer de adicionar a instrução break no terceiro loop.
  • Manutenção ruim: Como nosso código é complexo e propenso a erros, refatorar ou alterar o comportamento desse código pode ser difícil. Por exemplo, se o problema fosse modificado para selecionar nomes com três vogais e cinco consoantes, teríamos que introduzir novas variáveis ​​e alterar os loops, deixando muitas oportunidades para bugs.

Nossa solução de exemplo ilustra como o código imperativo complexo pode parecer, embora você possa melhorar o código refatorando-o em funções menores.

Solução declarativa

Agora que entendemos o que não é programação declarativa, vamos revelar nossa solução declarativa em Kotlin:

 fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

Usando os mesmos critérios que usamos para avaliar nossa solução imperativa, vamos ver como o código declarativo se comporta:

  • Eficiente: As implementações imperativas e declarativas são executadas em tempo linear, mas a imperativa é um pouco mais eficiente porque usei name.count() aqui, que continuará a contar vogais até o final do nome (mesmo depois de encontrar três vogais ). Podemos facilmente corrigir esse problema escrevendo uma simples hasThreeVowels(String): Boolean . Esta solução usa o mesmo algoritmo que a solução imperativa, portanto, a mesma análise de complexidade se aplica aqui: Nosso algoritmo é executado em tempo $O(n)$ .
  • Conciso com boa legibilidade: A solução imperativa é de 44 linhas com recuo grande em comparação com o comprimento da nossa solução declarativa de 16 linhas com recuo pequeno. Linhas e tabulações não são tudo, mas fica evidente com uma olhada nos dois arquivos que nossa solução declarativa é muito mais legível.
  • Menos propenso a erros: nesta amostra, tudo é imutável. Transformamos uma List<String> de todos os nomes em uma List<String> de nomes com três ou mais vogais e, em seguida, transformamos cada palavra String em uma palavra String com vogais maiúsculas. No geral, não ter mutação, loops aninhados ou quebras e desistir do fluxo de controle torna o código mais simples com menos espaço para erros.
  • Boa capacidade de manutenção: você pode refatorar facilmente o código declarativo devido à sua legibilidade e robustez. Em nosso exemplo anterior (digamos que o problema foi modificado para selecionar nomes com três vogais e cinco consoantes), uma solução simples seria adicionar as seguintes declarações na condição de filter : val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 .

Como um acréscimo positivo, nossa solução declarativa é puramente funcional: cada função neste exemplo é pura e não tem efeitos colaterais. (Mais sobre pureza mais tarde.)

Solução declarativa de bônus

Vamos dar uma olhada na implementação declarativa do mesmo problema em uma linguagem puramente funcional como Haskell para demonstrar como ela lê. Se você não estiver familiarizado com Haskell, observe que o . operador em Haskell lê como "depois". Por exemplo, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels se traduz em “mapear vogais para maiúsculas após filtrar os nomes que têm três vogais”.

 import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]

Esta solução funciona de forma semelhante à nossa solução declarativa Kotlin, com alguns benefícios adicionais: É legível, simples se você entender a sintaxe do Haskell, puramente funcional e preguiçoso.

Principais conclusões

A programação declarativa é útil tanto para FP quanto para programação reativa (que abordaremos em uma seção posterior).

  • Ele descreve “o que” você deseja alcançar – em vez de “como” alcançá-lo, com a ordem exata de execução das instruções.
  • Ele abstrai o fluxo de controle de um programa e, em vez disso, foca no problema em termos de transformações (ou seja, $A \rightarrow B \rightarrow C \rightarrow D$).
  • Ele encoraja códigos menos complexos, mais concisos e mais legíveis, que são mais fáceis de refatorar e alterar. Se o seu código Android não for lido como uma frase, você provavelmente está fazendo algo errado.

Se o seu código Android não for lido como uma frase, você provavelmente está fazendo algo errado.

Tweet

Ainda assim, a programação declarativa tem certas desvantagens. É possível acabar com um código ineficiente que consome mais RAM e tem um desempenho pior do que uma implementação imperativa. Classificação, retropropagação (em aprendizado de máquina) e outros “algoritmos mutantes” não são adequados para o estilo de programação declarativa e imutável.

FP Ingrediente #2: Composição da Função

A composição de funções é o conceito matemático no coração da programação funcional. Se a função $f$ aceita $A$ como entrada e produz $B$ como saída ($f: A \rightarrow B$), e a função $g$ aceita $B$ e produz $C$ ($g: B \rightarrow C$), então você pode criar uma terceira função, $h$, que aceita $A$ e produz $C$ ($h: A \rightarrow C$). Podemos definir esta terceira função como a composição de $g$ com $f$, também notada como $g \circ f$ ou $g(f())$:

Uma caixa azul rotulada "A" tem uma seta "f", apontando para uma caixa azul rotulada "B" que tem uma seta "g", apontando para uma caixa azul rotulada "C". A caixa "A" também tem uma seta paralela, "g de f", apontando diretamente para a caixa "C".
Funções f, g, eh, a composição de g com f.

Toda solução imperativa pode ser traduzida em uma declarativa, decompondo o problema em problemas menores, resolvendo-os independentemente e recompondo as soluções menores na solução final por meio da composição de funções. Vejamos nosso problema de nomes da seção anterior para ver esse conceito em ação. Nossos problemas menores da solução imperativa são:

  1. isVowel :: Char -> Bool : Dado um Char , retorna se é uma vogal ou não ( Bool ).
  2. countVowels :: String -> Int : Dada uma String , retorna o número de vogais nela ( Int ).
  3. hasThreeVowels :: String -> Bool : Dada uma String , retorna se ela tem pelo menos três vogais ( Bool ).
  4. uppercaseVowels :: String -> String : Dada uma String , retorna uma nova String com vogais maiúsculas.

Nossa solução declarativa, obtida através da composição de funções, é map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels .

Um diagrama superior tem três caixas azuis "[String]" conectadas por setas apontando para a direita. A primeira seta é rotulada "filter has3Vowels" e a segunda é rotulada "map uppercaseVowels". Abaixo, um segundo diagrama tem duas caixas azuis à esquerda, "Char" na parte superior e "String" abaixo, apontando para uma caixa azul à direita, "Bool". A seta de "Char" para "Bool" é rotulada como "isVowel" e a seta de "String" para "Bool" é rotulada como "has3Vowels". A caixa "String" também tem uma seta apontando para si mesma rotulada como "uppercaseVowels".
Um exemplo de composição de funções usando nosso problema de nomes.

Este exemplo é um pouco mais complicado do que uma simples fórmula $A \rightarrow B \rightarrow C$, mas demonstra o princípio por trás da composição de funções.

Principais conclusões

A composição de funções é um conceito simples, mas poderoso.

  • Ele fornece uma estratégia para resolver problemas complexos em que os problemas são divididos em etapas menores e mais simples e combinados em uma solução.
  • Ele fornece blocos de construção, permitindo que você adicione, remova ou altere facilmente partes da solução final sem se preocupar em quebrar algo.
  • Você pode compor $g(f())$ se a saída de $f$ corresponder ao tipo de entrada de $g$.

Ao compor funções, você pode passar não apenas dados, mas também funções como entrada para outras funções – um exemplo de funções de ordem superior.

FP Ingrediente nº 3: Pureza

Há mais um elemento-chave para a composição de funções que devemos abordar: As funções que você compõe devem ser puras , outro conceito derivado da matemática. Em matemática, todas as funções são computações que sempre produzem a mesma saída quando chamadas com a mesma entrada; esta é a base da pureza.

Vejamos um exemplo de pseudocódigo usando funções matemáticas. Suponha que temos uma função, makeEven , que duplica uma entrada inteira para torná-la par, e que nosso código executa a linha makeEven(x) + x usando a entrada x = 2 . Em matemática, esse cálculo sempre se traduziria em um cálculo de $ 2x + x = 3x = 3(2) = 6$ e é uma função pura. No entanto, isso nem sempre é verdade na programação - se a função makeEven(x) x duplicando-o antes que o código retornasse nosso resultado, nossa linha calcularia $2x + (2x) = 4x = 4(2) = 8$ e, pior ainda, o resultado mudaria a cada chamada makeEven .

Vamos explorar alguns tipos de funções que não são puras, mas nos ajudarão a definir pureza mais especificamente:

  • Funções parciais: São funções que não estão definidas para todos os valores de entrada, como divisão. De uma perspectiva de programação, estas são funções que lançam uma exceção: fun divide(a: Int, b: Int): Float lançará uma ArithmeticException para a entrada b = 0 causada pela divisão por zero.
  • Funções totais: Essas funções são definidas para todos os valores de entrada, mas podem produzir uma saída diferente ou efeitos colaterais quando chamadas com a mesma entrada. O mundo Android está cheio de funções totais: Log.d , LocalDateTime.now e Locale.getDefault são apenas alguns exemplos.

Com essas definições em mente, podemos definir funções puras como funções totais sem efeitos colaterais. As composições de funções criadas usando apenas funções puras produzem um código mais confiável, previsível e testável.

Dica: Para tornar uma função total pura, você pode abstrair seus efeitos colaterais passando-os como um parâmetro de função de ordem superior. Dessa forma, você pode facilmente testar funções totais passando uma função de ordem superior simulada. Este exemplo usa a anotação @SideEffect de uma biblioteca que examinamos posteriormente no tutorial, Ivy FRP:

 suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())

Principais conclusões

Pureza é o ingrediente final necessário para o paradigma de programação funcional.

  • Tenha cuidado com funções parciais — elas podem travar seu aplicativo.
  • A composição de funções totais não é determinística; pode produzir um comportamento imprevisível.
  • Sempre que possível, escreva funções puras. Você se beneficiará de maior estabilidade de código.

Com nossa visão geral da programação funcional concluída, vamos examinar o próximo componente do código Android à prova de futuro: programação reativa.

Programação Reativa 101

A programação reativa é um padrão de programação declarativa no qual o programa reage a alterações de dados ou eventos em vez de solicitar informações sobre as alterações.

Duas caixas azuis principais, "Observável" e "Estado", têm dois caminhos principais entre elas. A primeira é via "Observa (ouve as mudanças)." A segunda é por meio de "Notificações (do estado mais recente)" para a caixa azul "UI (API no back-end)", que passa por "Transforma a entrada do usuário em" para a caixa azul "Evento", que passa por "Acionadores" para azul box "Composição de funções" e, finalmente, via "Produz (novo estado)". "Estado" também se conecta de volta a "Composição de funções" por meio de "Age como entrada para".
O ciclo de programação reativa geral.

Os elementos básicos em um ciclo de programação reativa são eventos, o pipeline declarativo, estados e observáveis:

  • Eventos são sinais do mundo externo, normalmente na forma de entrada do usuário ou eventos do sistema, que acionam atualizações. O objetivo de um evento é transformar um sinal em entrada de pipeline.
  • O pipeline declarativo é uma composição de função que aceita (Event, State) como entrada e transforma essa entrada em um novo State (a saída): (Event, State) -> f -> g -> … -> n -> State . Os pipelines devem ser executados de forma assíncrona para lidar com vários eventos sem bloquear outros pipelines ou aguardar a conclusão deles.
  • Os estados são a representação do modelo de dados do aplicativo de software em um determinado momento. A lógica de domínio usa o estado para calcular o próximo estado desejado e fazer as atualizações correspondentes.
  • Os observáveis ​​ouvem as alterações de estado e atualizam os assinantes sobre essas alterações. No Android, os observáveis ​​geralmente são implementados usando Flow , LiveData ou RxJava , e notificam a interface do usuário sobre atualizações de estado para que ela possa reagir de acordo.

Existem muitas definições e implementações de programação reativa. Aqui, adotei uma abordagem pragmática focada na aplicação desses conceitos a projetos reais.

Conectando os pontos: programação funcional reativa

A programação funcional e reativa são dois paradigmas poderosos. Esses conceitos vão além da vida curta das bibliotecas e APIs e aprimorarão suas habilidades de programação nos próximos anos.

Além disso, o poder do FP e da programação reativa se multiplica quando combinados. Agora que temos definições claras de programação funcional e reativa, podemos juntar as peças. Na parte 2 deste tutorial, definimos o paradigma de programação reativa funcional (FRP) e o colocamos em prática com uma implementação de aplicativo de amostra e bibliotecas Android relevantes.

O Toptal Engineering Blog agradece a Tarun Goyal por revisar os exemplos de código apresentados neste artigo.