Paradigma funcional na prática

Paradigma funcional na prática

Se você é da área de tecnologia ou apenas um entusiasta já deve ter ouvido falar de programação funcional em algum momento. Ao tentar buscar sobre você se depara com uma explicação falando ser um paradigma envolvendo funções de primeira classe (First-class citizen), onde as funções são puras e variáveis imutáveis. Mas muitos dos artigo que buscam explicar sobre esse paradigma, muitas vezes acabam ficando muito conceitual e muitos podem acabar saindo do artigo sem entender como a programação funcional é, na prática. Por isso, neste artigo, irei explicar os conceitos desse paradigma demonstrando com exemplos práticos para que o leitor consiga visualizar e também começar a aplicar esse modo de programar.

Há muitas linguagens populares que possibilitam aplicar diferentes paradigmas nelas, como Javascript e Python, porém, neste artigo, escolhi a linguagem Haskell para demonstrar os exemplos práticos, isso porque Haskell é uma linguagem projetada para ser totalmente funcional, ou seja, ela te obriga a programar nesse paradigma, além de sua sintaxe ser bem simples e dispor de diversos recursos que facilitam a aplicação de diversos conceitos da programação funcional. Mas não se preocupe, mesmo você não sabendo nada de Haskell, você verá que a simplicidade da sintaxe dessa linguagem fará com que você entenda muito facilmente as explicações.

Assinatura das funções: notação Haskell

Como nesse paradigma trabalhamos principalmente com funções, é importante termos uma convenção para descrever uma função em si. Para isso usamos a notação Haskell que é usada para que saibamos com que tipo de elementos estamos trabalhando, e que também é usado no código como assinatura de uma função, para que o compilador possa checar a validade do código. Sim, Haskell é uma linguagem com tipagem forte e estática, e você verá que isso traz certas vantagens para nós.

Então vamos criar uma função bem boba e pouco útil que recebe um número e retorna esse valor somado com 1 chamada de sum1.

sum1 :: Int -> Int
sum1 x = x + 1

A primeira linha é a assinatura da função, nela tem o nome da função seguida por dois símbolos de dois pontos (colon), em seguinda tem o Int (inteiro) que representa o tipo do parâmetro seguido por uma flecha que indica o retorno de uma função, dessa forma, você pode ver que a função retorna um valor do tipo Int também. Resumindo, sum1 é uma função que recebe um valor Int e retorna um valor Int.

Na segunda linha temos a implementação, note que não usamos parênteses para declararmos os parâmetros de uma função, apenas colocamos o nome do parametro na frente do nome da função, e então nós escrevemos a soma logo após de do sinal de igual. Para você que está acostumado com outras linguagens, pode até estranhar já que isso parece muito mais que estamos atribuindo o valor para uma variável e não declarando uma função.

No paradigma funcional, nós trabalhamos de forma mais descritiva de como algo se comporta usando notação algébrica de função, note que essa função é bem semelhante quando escrevemos uma função matemática pedido em um exercício de matemática na escola: f(x) = x + 1.

Agora vamos criar uma função com dois parâmetros que recebem dois números Int e retornar novamente um Int.

sum_integers :: Int -> Int -> Int
sum_integers x y = x + y

Não, espera, isso aí tá meio estranho! Você deve estar pensando, essa assinatura está dizendo que sum_integers recebe um Int que retorna uma função que recebe um Int que retorna um Int!? Não se desespere, sua estranheza e sua dedução fazem sentido sim, o que acontece é que em Haskell as funções só recebem um parâmetro. Você deve estar se perguntando como isso seria possível se eu acabei de colocar dois parâmetros x e y em sum_integers. Bom, isso é apenas uma sugar syntax para um currying, caso você não saiba, um currying é uma técnica que transforma uma função com vários parâmetros em uma função de um único parâmetro que retorna uma outra função que recebe outro parâmetro assim por diante até todos os parâmetros ter sido passado. Em resumo, é uma técnica para aplicação parcial de uma função. Em javascript ficaria assim:

const sum_integers = (x, y) => x + y
const curried_sum_integers = x => y => x + y

// sum_integers(1,2) 
// >> 3

// const sum1 = curry_sum_integers(1)
// sum1(2)
// >> 3

Isso implica que, em Haskell, você pode passar apenas partes dos parâmetros necessários que você obterá uma outra função com os parâmetros faltantes. Assim temos que:

sum_integers :: Int -> Int -> Int
sum_integers x y = x + y

// sum_integers 1 2
// >> 3

// sum1 = sum_integers 1
// sum1 2
// >> 3

Trabalhando com funções mais complexas

Até agora nós criamos apenas funções mais simples, em que fazemos operações básicas de matemática, mas e se quiséssemos trabalhar com operações condicionais ou iterações? No paradigma funcional, o comportamento da função é descrito através de expressões, por exemplo, caso quiséssemos fazer uma função que recebe um número inteiro e que, por algum motivo misterioso, você queira que ele retorne uma string “red” caso seja menor ou igual a 10, “green” caso seja maior que 10 e menor ou igual a 50 e para os demais caso retorne “yellow”, nós poderiamos fazer essa função da seguinte forma.

get_color :: Int -> String
get_color n 
  | n <= 10 = "red"
  | n <= 50 = "green"
  | otherwise  = "yellow"

Um tanto sucinto e elegante, não? Para mim, dessa forma fica melhor do que um monte de chaves, if-elses, parênteses e returns para todo o lado.

E no caso de loops, como ficaria no paradigma funciona? Vamos trabalhar com um exemplo em queremos elevar ao quadrado todos os elementos de uma lista.

square_arr :: [Int] -> [Int]
square_arr [] = []
square_arr (x:xs) = x*x:square_arr(xs)

Temos vários conceitos novos aqui, no paradigma funcional o comportamento é muito mais descritivo, você normalmente declara que, caso algo esteja de um forma específica, faça tal ação. Dessa forma, na segunda linha, nós estamos apenas dizendo que caso a função receba uma array vazio, simplesmente retorne um array vazio. Isso é legal porque um caso em que alguem passa um array vazio, não iremos realizar nenhuma operação, então simplesmente devolvemos um array vazio, porém essa declaração vai ser muito mais importante para a linha seguinte, pois ela será o critério de saída (edge condition) da recursão para que evitemos recursividade infinita. Na terceira linha, nós já vemos que há (x:xs) como noss parâmetro, essa sintaxe é usada para fazer um desestructuring do primeiro elemento e o restante do array, em javascript seria algo semelhante a:

const [x, ...xs] = [1,2,3,4,5]

Dessa forma, o x representa valor do elemento presente no índice 0 do array, e o xs é o restante o array sem o elemento x. Em seguida nós vemos que multiplicamos o elemento por ele mesmo para conseguir o seu valor ao quadrado e ao lado utilizamos dois pontos (colon), que é uma sintaxe que adiciona o elemento no início do array, segue uma exemplificação:

9:[3,8]
// >> [9,3,8]

5:7:1:2:8:[]
// >> [5,7,1,2,8]

Note, no segundo exemplo, que trata-se de uma operação que é lida da direita para esquerda. Com isso, nós podemos realizar o entendimento total da expressão. Logo após de calcular o quadrado, nós adicionamos ao resultado da função com o xs como argumento, perceba que no fim essa expressão ficará igual ao segundo exemplo acima, resultando no array com os valores dos quadrados.

Tipos de dados algébricos

O paradigma funcional trabalha com dados algebricos, você pode entender como um tipo dado que representa alguma coisa, ou melhor, representam um contexto. Esses tipos podem ter ou não uma parâmetro que carrega um valor que está inserido nesse contexto.

Quem trabalha com desenvolvimento de software diariamente se depara com uma situação em que você recebe um dado, e esse dado é passado como parametro para alguma função ou método que retorna um valor que é passado para outras funções, no meio desse processamento de dados caso alguma certa condição seja atendida você pode querer que o fluxo da execução mude ou cesse, algo que geralmente é alcançado trabalhando com exceções ou operações condicionais. Na programação funcional, nós podemos usar esses tipos de dados algebricos para realizar isso de uma forma bem clara, prática e elegante. Por exemplo, imagine que eu quero criar um contexto em que o resultado de um processamento é um sucesso ou uma falha, poderiamos criar algo da seguinte forma.

data Possibility a = Fail | Success a

O tipo declarado acima representa uma possibilidade (Possibility) de resultado bem sucessido (Success) e um mal-sucedido (Fail). Dizemos que Possibility é um construtor do tipo, veja que ele recebe um parametro “a” que representa qualquer tipo, ou seja, pode simbolizar um CharStringIntBool e etc. Entenda que como Possibility é chamado de construtor porque os tipos de fato são Fail ou SuccessPossibility é uma representação. Abaixo está um exemplo de uso para que fique mais claro.

calculateDelta :: Int -> Int -> Int -> Possibility Int
calculateDelta a b c 
  | result >= 0 = Success result
  | result < 0  = Fail
  where result = b^2 - 4*a*c

Não estranhe a ultima linha, é apenas uma sintaxe para guardar o valor de uma operação e ser usada ao longo da função. Nessa função estamos calculando o deltinha da famigerada fórmula de Bhaskara. Estamos considerando que o resultado deve ser do conjunto reais, e portanto o resultado deve ser positivo já que raiz quadrada negativa só é valida ao trabalharmos com números imaginários, assim, caso seu resultado seja positivo será retornado Success com seu resultado como parâmetro, caso contrário, será retornado Fail, que não acompanha parâmetro nenhum.

E por fim poderiamos calcular as raízes de uma equação da seguinte forma.

getDeltaRoot :: Int -> Int
getDeltaRoot delta = round $ sqrt $ fromIntegral delta

getResult :: Int -> Int -> Possibility Int -> Possibility (Int, Int)
getResult a b Fail = Fail
getResult a b (Success delta) =
  let root1 = (b * (-1) + getDeltaRoot delta) `div` (2 * a)
      root2 = (b * (-1) - getDeltaRoot delta) `div` (2 * a)
  in Success (root1, root2)

bhaskara :: Int -> Int -> Int -> Possibility (Int, Int)
bhaskara a b c 
  | delta == Fail = Fail
  | otherwise = getResult a b delta
  where delta = calculateDelta a b c

print (bhaskara 1 5 (-14))
// >> Success (2,-7)

Caso estranhe esse `div` é apenas uma sintaxe para usar uma função que recebe 2 parametros e você deseja aplicar o primeiro parâmetro pelo lado esquerdo e o segundo parâmetro pelo lado direito, para ficar do mesmo jeito em que você usa um operador como o (+) da soma, ou o (-) da subtração, facilitando a leitura da função.

Primeiramente calculamos a raiz de delta, você pode estar estranhando esses cifrões, que nada mais são uma sugar syntax para parênteses, usamos isso para evitar poluir o código com um monte de parênteses, considere que tudo após este sinal está dentro de um parênteses. Assim podemos reescrever substituindo pelos parênteses da forma abaixo:

getDeltaRoot delta = round $ sqrt $ fromIntegral delta
getDeltaRoot delta = round (sqrt $ fromIntegral delta)
getDeltaRoot delta = round (sqrt (fromIntegral delta))

Há uma outra forma de escrever isso através da composição de funções para criar uma outra função. A composição é formada pelo operador ponto/dot (.) que pode ser visto no exemplo abaixo.

# (.) :: (b -> c) -> (a -> b) -> (a -> c)

getDeltaRoot :: Int -> Int
getDeltaRoot delta = (round . sqrt . fromIntegral) delta

Quando declaramos uma assinatura nós usamos parentêses para demarcar um parâmetro que é uma função, assim, na definição deste operador descrito acima quer dizer que ele recebe duas funções e retorna uma nova função. Perceba que a combinação retorna uma uma função que recebe como parâmetro o mesmo tipo da função mais a direita e retorna o retorno da função mais a esquerda. Então em uma cadeia de combinação de funçãos usando esse operador, simplesmente veja a última função (mais a direita) da cadeia para ver qual é o tipo que ele recebe, e a primeira função (mais a esquerda) da cadeia pra saber o tipo que é retornado. Nota-se então, que na programação funcional é muito comum ler as funções do lado direito para esquerda já trabalhamos principalmente com composição de funções. Então explicando a função acima, ele converte o delta que é um RealFrac para o tipo mais genérico Num, e acha a raíz quadrada e em seguida arredonda para transformar em um número inteiro, precisão não é uma prioridade para a gente nesse exemplo.

Agora, se uma composição de funções é a junção de funções para criar uma nova função, então o que está dentro dos parênteses seria uma função que recebe um parametro do tipo Int, então se nós simplesmente retirarmos o delta explicito da expressão que ficaria a mesma coisa, na prática estariamos nomeando afunção resultante da composição de funções. Assim podemos simplesmente reescrever assim:

getDeltaRoot :: Int -> Int
getDeltaRoot = round . sqrt . fromIntegral 

As demais funções apenas aplica o restante da fórmula, perceba que nós extraímos o valor da parâmetro de Success fazendo um desestruturação no parâmetro entre parênteses, então ao escrever (Success delta) nós podemos nos referir ao valor do parâmetro usando o nome dado, no caso, delta, poderia ser qualquer termo.

Até agora vimos como podemos trabalhar com dados algébricos, mas como usamos até agora, não parece nada tão interessante assim, ou tão útil e prático, isso porque para podermos explorar a praticidade e elegância desses tipos de dados, nós precisamos utilizá-los juntos com outros conceitos que são os chamados Functors e Applicative.

Functors e Applicative Functors

Em Haskell, classe não é a mesma classe de linguagens orientadas a objetos, na progrmação funcional a classe é uma interface que detêm certas operações que devem ser implementadas para que possa ser considerada uma instância dela.

A classe Functor é definida desse jeito:

class Functor f where  
    fmap :: (a -> b) -> f a -> f b  

Veja que na class Functor está definido que fmap recebe uma função que recebe tipo a e retorna um tipo b e como segundo parâmetro recebe o tipo que está sendo instanciando do tipo generico f que deve ter como parâmetro o mesmo tipo que a, e com isso será retornado o nosso tipo f com o parâmetro do tipo b, ou seja, aplicaremos a função no parâmetro a retornando um f com o resultado.

Quando eu declaro isso, eu quero dizer que para usar a operação fmap eu deve primeiro instanciar o meu tipo para ser um Functor, seguindo essa assinatura, com isso eu posso fazer o seguinte:

instance Functor Possibility where
  fmap f (Success x) = Success (f x)
  fmap f Fail = Fail

Fica claro que quando escrevermos fmap e passar como primeiro parâmetro uma função e um Success como segundo parâmetro, deve ser aplicado a função ao parâmetro do tipo Success. E no caso de Fail, simplesmente retorn Fail. Com isso, não precisamos mais nos preocupar em lidar com caso de Fail quando declaramos uma função que recebe um parâmetro do tipo Possibility.

Então com isso poderemos executar a operação da seguinte forma:

sum10 :: Int -> Int
sum10 x = x + 10

fmap sum10 $ Success 5
// >> Success 15

fmap sum10 $ Fail
// >> Fail

Poderíamos reescrever a função que aplica o Bhaskara da seguinte forma:

calculateDelta :: Int -> Int -> Int -> Possibility Int
calculateDelta a b c 
  | result >= 0 = Success result
  | result < 0  = Fail
  where result = b^2 - 4*a*c
  
getDeltaRoot :: Int ->  Int
getDeltaRoot = round . sqrt . fromIntegral 

getResult :: Int -> Int -> Possibility Int -> (Possibility Int, Possibility Int)
getResult a b delta =
  let sumDelta d = b * (- 1) + d
      subDelta d = b * (- 1) - d
      divTotal t = t `div` (2 * a)
      root1 = fmap (divTotal . sumDelta . getDeltaRoot) delta
      root2 = fmap (divTotal . subDelta . getDeltaRoot) delta
  in (root1, root2)

bhaskara :: Int -> Int -> Int -> (Possibility Int, Possibility Int)
bhaskara a b c  = 
  let delta = calculateDelta a b c
  in getResult a b delta

Ou simplesmente.

calculateDelta :: Int -> Int -> Int -> Possibility Int
calculateDelta a b c 
  | result >= 0 = Success result
  | result < 0  = Fail
  where result = b^2 - 4*a*c

getDeltaRoot :: Int ->  Int
getDeltaRoot = round . sqrt . fromIntegral

bhaskara :: Int -> Int -> Int -> (Possibility Int, Possibility Int)
bhaskara a b c = 
  let root1 = fmap ((\x -> (-b + x) `div` (2 * a)) . getDeltaRoot) $ calculateDelta a b c
      root2 = fmap ((\x -> (-b - x) `div` (2 * a)) . getDeltaRoot) $ calculateDelta a b c
  in (root1, root2)

Caso você estranhe a expressão (\x -> (-b + x) `div` (2 * a), ela é somente uma função anônima como uma arrow function do javascript ou uma lambda function do python. Um Functor permite obter o valor do contexto para ser aplicado a uma função retornando o resultado e considerando o caso de falha, possibilitando em usar funções baseado no tipo do parâmetro, podendo usar a função com tipos de dados algebricos diferentes sem precisar se preocupar com casos em que se possa quebrar nosso código ao receber um Fail. Isso possiblita a escrita de funções de forma mais clara e sucinta. O que temos agora, possibilita aplicar uma função no parâmetro de um tipo algébrico. Mas e se quiséssemos trabalhar apenas com tipo algébrico, passando funções como parâmetro e trabalhando inteiramente com isso? Para isso temos o Applicative Functors!

class (Functor f) => Applicative f where  
    pure :: a -> f a  
    (<*>) :: f (a -> b) -> f a -> f b  

instance Applicative Possibility where  
    pure = Success
    Fail <*> _ = Fail  
    (Success f) <*> something = fmap f something  

A classe dessa vez tem um (Functor f) => que usamos para o tipo do parâmetro que será usado do aldo direito da seta, então nesse caso queremos dizer que a classe Applicative é instanciado por um Functor, ou seja, seu tipo algébrico deve ter os operadores de um Functor definidos. Os operadores de Applicative são pure que embrulha o valor em um tipo algébrico, e o <*> que aplica a função do parâmetro do elemento do seu lado esquerdo ao elemento do seu lado direito. O que torna ser possível realizar as seguintes operações.

Success (+5) <*> Success 10 
// >> Success 15

pure (+25) <*> Success 10  
// >> Success 35

pure (+25) <*> Fail
// >> Fail

sum3 :: Int -> Int -> Int -> Int
sum3 a b c = a + b + c

pure sum3 <*> Success 1 <*> Success 3 <*> Success 5
// >> Success 9

pure sum3 <*> Success 1 <*> Fail <*> Success 5
// >> Fail

# Que é a mesma coisa que:

fmap sum3 (Success 1) <*> Success 3 <*> Success 5

Para deixar a expressão mais visualmente agradável podemos fazer o seguinte:

(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x
sum3 <$> Success 1 <> Success 3 <> Success 5
// >> Success 9
sum3 <$> Success 100 <> Success 50 <> (sum3 <$> Success 1 <> Success 3 <> Success 5)
// >> Success 159
sum3 <$> Success 100 <> Success 50 <> (sum3 <$> Fail <> Success 3 <> Success 5)
// >> Fail

Com isso nós temos a base para começar a ter uma visão de como se trabalha com o paradigma funcional, a maneira de pensar, os conceitos e as práticas e o que o faz se diferir dos demais paradigmas.

Nós vimos como é trabalhar com funções puras, sem efeitos colaterais, mas não só de códigos escritos em um arquivo isolado, sem iteração com o meio externo como usuário e banco de dados vive o desenvolvedor, então como funcionaria nesse caso? Não teria como? Claro que é possível, geralmente quando encontramos artigos apresentando a o paradigma funcional, sempre batem bastante na ideia de pureza da função, mas na verdade os programas são formados por uma camada pura, onde estão o core de nossa aplicação, e uma camada impura, onde estão funções que se comunicam com o mundo externo, nos lidamos com a iteração das duas usando o conceito de Monads, mas esse tema merece um artigo inteiro para se aprofundar e entender melhor seu funcionamento.