Disclaimer
-
Os posts dessa série são minhas anotações pessoais dos meus estudos do livro “Aprendendo programação funcional com elixir.
-
Eles não passaram por revisão, possuem typos e alguns erros.
-
Estão mais próximos de pensamentos desconexos do que de um texto estruturado.
-
Dito isso, segue anotações (:
Intro
- Existem funções que podem retornar resultados diferentes com o mesmo input
- Se temos uma função que espera um numero mas recebe um texto? E se temos uma função que busca dados de um banco de dados?
- Algo que se espera de todo programa é ter que lidar com erros.
- Trabalhar sem estratégia nenhuma gera código difícil de manter.
- A melhor estratégia pra se ter uma base de código saudável é identificar e isolar as partes que possuem resultados inexperados
- Em elixir temos 4 formas de lidar com isso (meio que 3.5, na verdade, porque uma das formas envolve instalar bibliotecas externas)
- estruturas condicionais -> case e if
- Try -> Da forma que pessoas familiarizadas com java estão acostumadas
- Monads -> bastante usado em linguagens funcionais que possuem tipagem estática
- With -> Uma diretiva diferenciada que combina pattern match com estrutura condicional
Funções puras vs Impuras
Funções puras
- Quando você não consegue prever o resultado de uma função, ela é impura
- ` def total(a, b), do: a + b
é uma função pura, pois se você passar
total(1, 2)` sempre vai retornar 4. - mas como assim tem caso que você vai ter uma função que você passa um argumento e o resultado vai ser diferente toda vez? existe isso? (foi o que eu pensei a principio, pois não tenho criatividade XD)
Funções Impuras
- Imagina que você está buscando um dado em um banco de dado.
- (vamos usar bolos nesses exemplos? vamos)
- você vai lá, passa o ID 42 e retorna um bolo de limão (
busca_bolo(42)
)%Bolo{sabor: "bolo de limão", preco: 30, cobertura: true, pronto: false, id: 42}
- Agora pensa que em algum momento alguém atualizou o bolo de limão para que ele tivesse cobertura. O que acontece quando você for procurar um bolo de id 42? E se alguém decide que não quer mais vender bolo de limão e apagar do banco de dados? O resultado vai ser diferente da primeira vez.
- Então a função é a mesma, passamos o mesmo argumento, mas o resultado pode vir diferente.
- Uma função que busca no banco de dados é um exemplo de função impura, mas temos outras, como por ex uma função que gere um numero randomico, ler e escrever um arquivo, acessar API, ou até um
DateTime.utc_now()
Identificando funções impuras
- Uma definição de função impura é: Funções que são afetadas por valores fora do escopo.
> multiplicador_cobertura = 1.5 > def valor_do_bolo(valor), do: valor * multiplicador_cobertura > valor_do_bolo(10) 15 > multiplicador_cobertura = 2 > valor_do_bolo(10) 20
- O multiplicador do valor de um bolo com cobertura é um valor externo. Porém, graças a imutabilidade, podemos alterar o multiplicador o quanto quisermos que o resultado vai ser sempre o experado. isso significa que essa função é pura ou impura? É pura :D
- Outra definição de função impura são funções que possuem efeitos colaterais. Efeitos colaterais podem incluir escrever mensagens noterminal, modificando um estado global ou inserindo/buscando items no banco de dados
def valor_do_bolo(valor) do total = valor * multiplicador_cobertura IO.puts(total) end
- O valor do bolo ta ali na primeira linha da função, porém a gente da uma bagunçada no coreto printando essa mensagem. (e se quisermos usar a função do calculo do valor do bolo em outro lugar, sem printar o resultado na tela? não é possível)
- Funções que se comportam dessa forma é uma má prática. I ideal seria ter uma função para calcular o valor do bolo e depois, fora dela, printar o valor
- A melhor prática de desenvovimento é isolar as funções puras das impuras
- O nome “função impura” pode dar a impressão que elas são algo a serem evitadas, mas não é verdade. Um software útl precisa ter funções impuras (se não tiver, nunca poderiamos fazer alterações no banco de dados, por exemplo)
- Porém as funções puras são mais fáceis de manter
- Então em um mundo ideal teriamos um software com mais funções puras com funções impuras isoladas
- Ok! mas como eu isolo uma função impura?
Controlando o fluxo das funções impuras
case/if
- A primeira estratégia coberta no capitulo 3, sobre controle de fluxo usando estruturas condicionais
- É ótimo para lidar com casos simples, mas casos mais complexos podem ficar confusos
defmodule Shop do def checkout(price) do case ask_number("Quantity?") do :error -> IO.puts("It's not a number") {quantity, _} -> quantity * price end end def ask_number(message) do message <> "\n" |> IO.gets |> Integer.parse end end
- No caso a cima estamos lidando com um imput externo, então o resultado pode vir qualquer coisa.
- Como precisamos que o imput seja um numero, precisamos tratar pra no caso de vir outra coisa
- Usar if/case pode virar um grande emaranhado de ifs e cases aninhados.
def checkout() do case ask_number("Quantity?") do :error -> IO.puts("It's not a number") {quantity, _} -> case ask_number("Price?") do :error -> IO.puts("It's not a number") {price, _} -> quantity * price end end end
- Podemos trocar tudo isso por um pattern match pra ficar mais suave ```elixir def checkout() do quantity = ask_number(“Quantity?”) price = ask_number(“Price?”) calculate(quantity * price) end
def calculate(:error, ), do: IO.puts(“It’s not a number”) def calculate(, :error), do: IO.puts(“It’s not a number”) def calculate({quantity, _}, {price, _}), do: quantity * price
* Control flow é uma solução simples, bastante legível e amigável, mas quando seu código se torna complexo, pode ser dificil a manutenção.
* O ideal é optar por ela apenas quando for algo simples. Caso você perceber que está ficando complexo, o ideal é refatorar pra uma das opções abaixo
## Try, rescuing, catching
* Com algumas funções você tem pouco controle do código. As vezes nos deparamos com erros por ai e é interessante capturalos antes que ele chegue no usuário
* Em elixir nós conseguimos facilmente identificar que funções retornam um erro porque elas terminam com `!`
* O que acontece com try/catch/rescue é que nós encapsulamos o código que pode apresentar problema em um bloco `try` e no caso dele der erro, ele cai no `rescue` (ou no `catch`, depende do que você for usar)
* Em funcional não é comum nós jogarmos erros diretamente na cara do usuário, então é incomum usarmos esse artificio no nosso código, porém nada impede de esbarrarmos em libs que explodem erro na cara do usuário, então é interessante aprender como podemos lidar com esse tipo de erro.
### Try/Rescue
* usando o mesmo exemplo acima, podemos mostrar como ele seria usando um bloco de try/rescue
```elixir
def checkout() do
try do # <- Aqui encapsulamos o caminho feliz do código
{quantity, _} = ask_number("Quantity?")
{price, _} = ask_number("Price?")
quantity * price
rescue # <- E se não for feliz, ele retorna o erro abaixo
MatchError -> "It's not a number"
end
end
Try/Trow/Catch
Throw
eCatch
são bem parecidos comraise
erescue
. A diferença é queThrow/Catch
não necesariamente precisa ser um erro gerado pelo sistema.@invalid option {:error, "Invalid Option"} def parse_answer(answer) do case Integer.parse(answer) do :error -> throw @invalid option # <--- Aqui nós estamos criando uma tupla pra representar um erro {option, _} end end ################ def something(pastel) do try pastel |> funcao_marota_1 |> parse_answer # <--- nossa função que não retorna erro, mas uma tupla |> outra_funcao_marota_aqui catch {:error, message} -> # Pattern match maroto na tupla de erro que criamos display_error(message) end end
- A maior vantagem do try/catch é a maior visibilidade do caminho feliz da nossa aplicação
- Mas ela torna nossa função mais dificil de reutilizar por conta das features extras da linguagem. O aumento da complexidade é o que faz com que nós, desenvolvedores elixir, evitemos.
Monads
- Monads são bastante usadas em outras linguagens funcionais, como Haskell
- Em elixir monads não vem naturalmente implementados na liguagem, então é preciso que você instale alguma biblioteca externa e adicione mais essa complexidade ao seu código
- (Não entrei em muito detalhe sobre o assunto porque não é muito comum, mas pra quem tiver curiosidade vale dar uma olhada em libs como MonadEx, Towel)
With
- Em elixir temos a opção de usar
With
que combina várias clausulas + pattern match - Uma coisa que é interessante reparar é o uso do
<-
(normalmente nós usamos->
) - Com with nós usamos pattern match para apontar o que queremos caso o resultado seja positivo, e jogamos um else caso não
def checkout() do with {quantity, _} <- ask_number("Quantity?"), {price, _} <- ask_number("Price?") do quantity * price else :error -> IO.puts("It's not a number") end end
- A maior vantagem do
with
é sua flexibilidade. Da pra combinar pattern match, sem novas lis, conceitos ou estrutura de dados, além da legibilidade - O problema é que não é compativo com pipe operators
- Na maior parte dos casos é a nossa primeira opção