Programação funcional e o mundo externo
Anteriormente no artigo Paradigma funcional na prática vimos como trabalhar com functors, mas até então não vimos como fazemos para que em uma linguagem que segue o paradigma funcional, que tem como base funções sem efeitos colaterais, possamos interagir com o mundo exterior, seja através de inputs de usuários ou através de interação com banco de dados. Neste artigo trataremos desse assunto de forma prática, até o final entenderemos o que são Monads e seu papel nesta integração com o mundo além das funções puras.
Começaremos a partir de onde paramos no artigo anterior. Em resumo, criamos um tipo algébrico, e aplicamos o conceito de Functor para aplicar funções aos valores dos tipos algébricos, em seguida utilizamos o conceito de Applicative Functors para usarmos funções como valores para esses tipos algébricos, o que nos possiblita operarmos apenas com esses tipos algébricos. Agora nós apresentaremos os Monoids.
Associatividade e Monoids
Para definir monoids, precisaremos listar certas características que queremos que um certo tipo de operação deve ter:
- Deve receber dois parâmetros do mesmo tipos que resultará um valor do mesmo tipo dos parâmetros.
// Como em uma operação de adição.
3 + 5
- Deve haver um valor neutro que, combinado com qualquer outro valor do mesmo tipo, resultará no valor do segundo elemento. Trata-se de uma identidade.
// Como na multiplicação, o 1 é uma identidade.
1 x 5
// E como na adição, o 0 é uma identidade
3 + 0
// Ou na concatenação de array
[] ++ [1, 2 ,3]
- Em ao combinar diversas operações, não impora a ordem que se realize as operaçãos, o valor final não se alterará. O famoso “a ordem dos fatores não altera o produto”. Essa propriedade é chamada de associatividade.
// Como na multiplicação
5 * 3 * 4 * 7 * 8
5 * (3 * (4 * 7)) * 8
Tendo essas propriedades em mente, iremos agora analisar a definição da classe Monoid
.
class Monoid m where
mempty :: m
mappend :: m -> m -> m
mconcat :: [m] -> m
mconcat = foldr mappend mempty
A classe Monoid tem as 4 operações, sendo a mempty a identidade que falamos no item 2. A mappend a operação que caracteriza o que foi citado no item 1. E mconcat é a operação que ao receber uma lista de monoids, ele irá aplicar o operação mappend em todos eles, resultando em um unico monoid.
Uma lista pode ser um monoid se levarmos em consideração a operação de concatenação (++). O elemento neutro é a lista vazia, se você aplicar o operador em duas listas, o resultado será uma única lista e não importa a ordem que você aplicar, o resultará na mesma lista. Segue o uso em haskell.
instance Monoid [a] where
mempty = []
mappend = (++)
// Exemplo
[1,2,3] `mappend'` [9,8,7] `mappend'` [3,5,7] `mappend'` [2,4,6] `mappend'` [0,0,0]
[1,2,3] `mappend'` ([9,8,7] `mappend'` [3,5,7)] `mappend'` ([2,4,6] `mappend'` [0,0,0])
[1,2,3] `mappend'` [9,8,7] `mappend'` [3,5,7] `mappend'` [2,4,6] `mappend'` [0,0,0]
[1,2,3] `mappend'` ([9,8,7] `mappend'` [3,5,7] `mappend'` [2,4,6]) `mappend'` [0,0,0]
// O resultado será sempre [1,2,3,9,8,7,3,5,7,2,4,6,0,0,0]
mconcat [[3,5,7], [2,4,6], [9,8,7]]
// > [3,5,7,2,4,6,9,8,7]
Números podem ser monoid quando falamos de soma e multiplicação, porém não com subtração, já que não atende a propriedade de associatividade.
(5 - 7) - 9
// resulta em -11 que é diferente de
5 - (7 - 9)
// que resulta em 7
Monads
Você deve se lembrar como Applicative Functors são úteis, agora imagine como seria prático se pudessemos passar um valor com um contexto, em outras palavras, um tipo algébrico, para uma função trabalha o valor concreto mantendo o contexto do qual ele está inserido, e podendo fazer isso consecutivamente. Podemos fazer isso com Monads. Sua classe pode ser definida da seguinte maneira.
data Maybe a = Just a | Nothing
class (Applicative m) => Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
x >> y = x >>= \_ -> y
fail :: String -> m a
fail msg = error msg
instance Monad Maybe where
return x = Just x
Nothing >>= f = Nothing
Just x >>= f = f x
fail _ = Nothing
O return é como o pure do Functor, ele apenas aplica o valor ao contexto, o operador (>> =) recebe o valor e aplica na função, considerando o contexto, portanto retornará Nothing diretamente caso ele seja passado para a função. Temos também o operado (>>) que serve para quando recebemos diversos valores do nosso tipo algebrico e queremos manter o ultimo valor, considerando o caso de falha, que é representado com o Nothing, isso é obtido passando uma função que sempre retorna o segundo parametro, como é visto na definição da classe. Com isso podemos fazer operações da seguinte forma.
divide :: Int -> Int -> Maybe Int
divide x y
| x > 0 = Just(y `div` x)
| otherwise = Nothing
return 100 >>= divide 5 >>= divide 2 >>= divide 1
//> Just 10
return 100 >>= divide 5 >>= divide 0 >>= divide 1 >>= divide 5
//> Nothing
Just 1 >> Just 3 >> Just 5 >> Just 8 >> Just 6
//> Just 6
Just 1 >> Just 3 >> Just 5 >> Nothing >> Just 6
//> Nothing
Veja como isso possiblita realizarmos um tipo de pipe para processarmos alguma informação aplicando funções consecutivamente sem se preocupar em sair do contexto em que os dados estão inseridos.
Agora, imagine que queremos trabalhar com monads dentro de uma função que já está dentro desse pipe, ou seja, monads aninhadas, como no exemplo a seguir:
calc :: Maybe Int
calc = Just 3 >>= (\x -> Just 5 >>= (\y -> Just (x * y)))
Que negócio feio, não acha? Pois eu acho, e fica menos legível, você bate o olho e demora um tempinho até começar a entender. Bom, para resolver isso, temos uma notação do (do notation), que nos permite reescrever da seguinte forma.
// Quebrando a função acima para ajuda visualmente na comparação
calc = Just 3 >>= (\x ->
Just 5 >>= (\y ->
Just (x * y)))
// Reescrevendo com a notação do
calc = do
x <- Just 3
y <- Just 5
Just (x * y)
//> Just 15
// E com Nothing
calc = do
x <- Just 3
y <- Just 5
Nothing
Just (x * y)
//> Nothing
Visualmente muito mais claro, perceba que estamos apontando o valor para o variável que será usado dentro dessa declaração, e caso ocorra algum Nothing dentro dessa cadeia, ele irá se comportar como esperado, passando o Nothing para frente.
Se você se lembra da definição da classe Monad, deve estar em dúvida sobre o que seria o caso do fail. Esse, como você deve presumir, representa o caso em que algum erro acontece, e ele protege o nosso código de efeitos colaterais. No caso do nosso tipo Maybe, simplesmente jogamos um Nothing.
crashit :: Maybe Char
crashit = do
(x:xs) <- Just ""
return x
//> Nothing
Leis dos Monads
Vimos como usar monads e alguma aplicação prática, mas Monads assim como Functors, são um conceito que seguem uma definição estrita, não é simplesmente uma classe em Haskell, e que se você aplicar essa classe em algum tipo, ele vai automaticamente se tornar um Monad. Portanto para algo ser um Monad ele precisará apresentar as seguintes características:
- Identidade à esquerda: um valor aplicado a um contexto passado como argumento para uma função com o operador >> = é a mesma coisa de aplicar o valor diretamente na função.
Resumindo: return x >>= f é a mesma coisa que f x
power2 :: Int -> Maybe Int
power2 x = Just (x * x)
return 5 >>= power2
// é a mesma coisa que
power2 5
- Identidade à direita: se um valor monádico for aplicado com >> = a um return, o resultado é o próprio valor monádico original.
Ou seja: m >>= return é o mesmo que m.
Just 10 >>= (\x -> return x)
//> Just 10
- Associatividade: estamos falando da mesma propriedade que vimos ao estudar Monoid.
Você deve ter notado uma certa semelhança entre Monoid e Monad em Haskell, apesar de serem estruturas diferentes. No entanto, eles possibilitam trabalhar com dados de maneiras que compartilham algumas características fundamentais. Enquanto Monoid oferece uma abordagem para combinar valores de uma maneira associativa e com um elemento neutro, Monad oferece uma abstração para encadear computações sequenciais com efeitos, mantendo o contexto em cada etapa do processo. Essas estruturas distintas, mas relacionadas, enriquecem a expressividade e a composição de programas funcionais em Haskell, permitindo aos programadores lidar de forma elegante com diferentes tipos de computações e manipulações de dados.
Além do mundo puro
Vimos como os Monads podem ser úteis para processarmos nosso dado de forma prática e legível, mas além disso, nós usamos também para trabalharmos com o mundo externo, como, por exemplo, recebendo inputs de usuários. E como seria isso? Digamos que queremos inserir texto como input, e mostrar a primeira letra desse input.
getLetter :: String -> Maybe Char
getLetter (x : _) = Just x
getLetter _ = Nothing
getResult :: Maybe Char -> String
getResult (Just letter) = "First Letter: " ++ [letter]
getResult Nothing = "Empty string or string too short"
processResult :: String -> IO ()
processResult name = do
let result = fmap (\letter -> "First Letter: " ++ [letter]) (getLetter name)
putStrLn $ maybe (getResult Nothing) id result
main :: IO ()
main = do
putStrLn "Hello, what's your name?"
name <- getLine
processResult name
Introduzimos agora o IO, que é um tipo de dados especial usado para representar ações que interagem com o ambiente externo, como ler de um arquivo, escrever na tela ou receber entrada do usuário. O tipo IO a representa uma ação que, quando executada, produz um valor do tipo a enquanto realiza efeitos colaterais no ambiente externo.
O tipo () em Haskell é conhecido como unit ou void em outras linguagens de programação. Ele representa um tipo que contém apenas um valor, também chamado de vazio ou sem valor. Portanto, IO () indica uma ação que não retorna um valor útil (como uma operação de impressão na tela) mas pode realizar efeitos colaterais no ambiente externo.
A função putStrLn simplesmente mostra o texto passado como parâmetro no console do usuário.
A função getLine é usada para ler uma linha de entrada do usuário a partir do console, essa função retorna um monad IO string, e extraímos o valor para vaŕiavel name, com isso poderíamos utilizar esse valor em funções puras.
No exempo, utilizamos funções simples, mas todo o código anterior a proccessResult são funções puras, então em programas mais complexos o desenvolvedor terá todas as vantagens que o paradigma funcional proporciona, e isolamos a parte impura na execução de main, onde lidamos com o mundo exterior, então isso deixa claro como programas feitos com linguagens funcionais lidam com efeitos colaterais, já que a primeira coisa que você lê quando pesquisa programação funcional é sobre a pureza das funções.
Com isso, acredito que foi passado tudo o que um iniciante em programação funcional deve saber para ter uma compreensão mais geral sobre esse paradigma, finalizando o que inciamos no artigo Paradigma funcional na prática.
Comments ()