Iago Effting
Posted on November 3, 2022
Última atualização: 01/10/2022
Elixir: 1.14
Uma rápida e importante introdução
Esse artigo está vivo! Vou revisita-lo no momento onde achar conveniente, seja quando precisar atualizar ou corrigir algo. Se algo mudar na linguagem onde possamos tirar um maior proveito, eu irei reescrever parte do artigo e não criarei um segundo, utilizarei sempre esse =D
Bora lá!
Dicas&Truques
Pattern match e Funções anónimas
Quando seu código precisa se mover livremente, de acordo com o tipo e/ou valor de dados de entrada, a primeira coisa que brota em nossas cabeças são condicionais, certo? O que faz total sentido. Por exemplo:
# usando condicional case
users = [
%{role: :admin},
%{role: :customer},
%{role: :member}
]
Enum.map(users, fn user ->
case user do
%{role: :admin} -> :admin,
%{role: :customer} -> :customer,
_user -> :unknown
end
end)
Já utilizamos pattern match ali em cima na condição de cada case, mas podemos extrair um pouco mais dele quando utilizamos funções anónimas.
Temos um truque que deixa as coisas mais emocionante e conciso. Você não precisa do escopo da função definido, basta resolver tudo diretamente nos parâmetros dela:
# usando pattern match na função anónima
users = [
%{role: :admin},
%{role: :customer},
%{role: :member}
]
Enum.map(users, fn
%{role: :admin} -> :admin
%{role: :customer} -> :customer
_user -> :unknown
end)
⚠ Lembrando ⚠: Isso só funciona com funções anónimas. Funções nomeadas precisam da definição de seu escopo.
Declaração With e legibilidade
Quando encontramos algo novo que gostamos, usamos em todos os lugares ou mostramos para todas as pessoas.
Pipes foram assim para mim. Achei eles tão bonitos que queria usar para tudo. Mas ai coisas assim aconteciam:
# usando pipes
def update(id, params) do
id
|> get_user()
|> validate_user(params)
|> update_user()
|> handle_response()
end
A primeira vista é bonito, passa rapidamente o propósito dos pipes e o que queremos atingir, mas nos trás algumas complicações.
- O dado que passa por todos os pipes devem ser o mesmo ou a próxima função do pipe deve saber qual é a resposta da anterior. Então Pablito sabe demais.
- O tratamento de erro vai para dentro da próxima função ou o pipe quebra. Caso
get_user
retorne um{:error, reason}
quem irá tratar vai ser ovalidate_user
o que é estranho.
Caso sintam algum desses problemas, o elixir consegue nos ajudar. Existe uma função chamada with que irá nos dar uma mão.
A mesma lógica, mas feita com with
:
# usando with com tratamento de erros
def update(id, params) do
with {:ok, user} <- get_user(id),
{:ok, user_validate} <- validate_user(user, params),
{:ok, user_updated} <- update_user(user_validated) do
{:ok, user_updated} # <-- aqui está nossa resposta do with
else
{:error, :not_found} -> {:error, "User not found"}
{:error, :unprocessable_entity} -> {:error, "Params are wrong"}
{:error, :duplicated_user} -> {:error, "User need to be unique"}
error -> error
end
end
O código se torna maior, mas ganhamos algumas coisas:
- as funções subsequentes não precisam entender nada das anteriores e vice-versa.
- O tratamento de erro sai da função. Uma vez que a pattern match não se satisfazer pela definição do
with
como no exemplowith {:ok, user} <- get_user(id)
owith
vai parar e cair no escopoelse
onde podemos fazer tratamentos utilizando diretamente o pattern match.
Também podemos isolar os erros, onde o tratamento passa a ser feito por outro modulo. O phoenix faz isso com os controllers, chamando a estratégia de fallback_controller deixando o código limpo e claro.
Resultado:
# usando with simplificado
def update(id, params) do
with {:ok, user} <- get_user(id),
{:ok, user_validate} <- validate_user(user, params),
{:ok, user_updated} <- update_user(user_validated) do
{:ok, user_updated} # <-- aqui está nossa resposta do with
end
end
Obs.: Gosto de pipes, de verdade. Mas utilizo eles onde tenho um controle mais restrito, isso é um gosto meu. Não seria um problema para mim, por exemplo, utilizar o pipe dentro de
user_validate
onde eu tenho controle de tudo. Mas uma vez que eu pegue funções de módulos diferente, acabo tentando tentando desacoplar lógica de dentro das funções que não a pertencem. É uma escolha minha e que me ajudou até então. Existem outras formas, essa é apenas uma delas.
Guard clause
Eu particularmente gosto dessa. Vamos supor que precisamos que um dado dentro de um map
e esse dado esteja bem dentro, uns 3 níveis. Com esse dado eu tenho que saber o que irei fazer em seguida. O fluxo se alteraria dependendo dele. Como podemos resolver isso? A forma mais clássica é utilizando cond
.
Vamos a um exemplo.
# usando condicional cond
def func(data) do
cond do
is_integer(data.inside.far.away) -> IO.inspect("Integer: #{data.inside.far.away}")
is_bitstring(data.inside.far.away) -> IO.inspect("String: #{data.inside.far.away}")
_ -> IO.inspect("I don't know what hack is that: #{data.inside.far.away}")
end
end
Relativamente elegante, porém meio grande certo? Utiliza boas funções para fazer o desvio de fluxo. Porém, alguns problemas aparecem:
- Essa função vai crescer com a quantidade de N tratamentos que precisarmos (se fosse por aqui o SOLID iria ferir o open/close principle, mas falaremos disso talvez uma outra hora).
- Fazer dessa forma pode dificultar a criação de tests devido a dificuldade de gerar o cenário (ainda esta simples ali, mas pense nas infinitas possibilidades)
- Também teremos que nos certificar de que quando essa função mudar, todos os cenários podem mudar junto, mesmo não tendo uma relação.
- em alguma hora a leitura será prejudicada também. Facilmente vejo essa função crescer pra mais de 200 linhas and beyond.
Podemos resolver isso usando algo chamado guard clause
diretamente na definição da função. E a leitura fica linda:
# utilizando guard clause e pattern match
def func(%{data: %{inside: value}}) when is_integer(value) do
IO.inspect("Integer: #{value}")
end
def func(%{data: %{inside: value}}) when is_bitstring(value) do
IO.inspect("String: #{value}")
end
def func(data) do
IO.inspect("I don't know what the hack is that: #{value}")
end
o interessante disso é:
- A leitura fica simples
- Podemos facilmente trocar de módulos cada um deles, caso a implementação cresça
- Mantemos um contrato claro do que precisamos para a função funcionar.
⚠ Lembrando ⚠: Guard clause so funcionam com tipos primitivos e não funcionam com funções de módules, como por exemplo:
# exemplos de utilização de guard clause
def display(a) when is_integer(a), do: # ... ✅
def display(a) when is_integer(a), do: # ... ✅
def display(a) when a > 10, do: # ... ✅
def display(a) when MyModule.check(a), do: # ... ❌
Aqui esta uma lista do que ele permite.
Conclusão
Todas essas dicas devem ser pensadas e levado em consideração o cenário que será utilizado. O importante é facilitar a vida do programador ao realizar alguma manutenção (não so você, como todo o seu time e futuro membros). Se a escolha for consciente, você estará bem para ir, caso não, você vai se transformar em um desenvolvedor que deseja fazer tudo inline ou com menos código, e isso é muito sério, você mata a facilidade de entendimento e leitura de código por causa de ego. Não seja assim.
Caso você esteja consciente disso, divirta-se refatornando trechos de códigos difíceis de ler e veja se alguma dessas dicas lhe foi útil, caso tenha sido, conte para nós nos comentários.
Já que você chegou até aqui, se inscreva no Café com Elixir uma newsletter semanal de Elixir focado em conteúdos da comunidade brasileira.
Me sigam também no twitter @iagoEffting lá sou mais ativo =D
E se inscrevam no canal no youtube
Posted on November 3, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.