Site menu Iniciando em Elixir e outras coisinhas

Iniciando em Elixir e outras coisinhas

Como todo mundo, também traço uns objetivos no início de cada ano, uma parte se perde no caminho, mas uma parte vai pra frente, então é um processo válido, apesar do folclore. Para este ano, os objetivos profissionais incluem brincar com Elixir, Go, Kubernetes, Terraform e alguma coisa relacionada a IA.

A linguagem Go já é levemente familiar, até escrevi um pequeno utilitário com ela há alguns anos, mas ainda não fiz nada $profissionalmente$ com ela. A bolha dev tem falado bastante de Go ultimamente, e alguns projetos que admiro como o MediaMTX são escritos em Go.

Kubernetes e Terraform são os queridinhos do mundo devops/SRE, então tenho de aprender. Não sei em que medida vou conseguir usar em projetos pessoais, porque talvez eu seja um pão-duro mas tudo que se refere a nuvem me parece tão caro!!! Cheguei a mover boa parte do tratamento de câmeras de vigilância para a nuvem, mas já trouxe de volta porque a conta AWS assustou; apenas o uso de S3 "sobreviveu". Se for pra fazer computação em nuvem com o dinheiro dos outros, aí acho ótimo.

Quanto ao Elixir, é uma tentativa de sair da zona de conforto, assim como fiz com o LISP.

A única coisa que fiz em LISP foi reescrever do Python um simulador operações com opções utilizado neste livro, mais para comparar a velocidade com outras implementações. LISP não passou a ser minha linguagem de uso diário, porém os conceitos básicos são muito poderosos, fazem ver as limitações e as "bagagens" de outras linguagens. A importância de uma linguagem de programação não é só o potencial mercado de trabalho, mas também o quanto ela modifica sua forma de pensar.

Provavelmente o mesmo vai acontecer com o Elixir: aprender os conceitos e tentar aplicá-los em projetos-baunilha com linguagens-baunilha. Mas nunca se sabe, né? Até tenho um projetinho de automação residencial, que por enquanto é privado (por não estar bom o suficiente para ser publicado).

A pergunta maldosa, pra provocar celeuma, seria: qual a linguagem que proporciona a menor influência na sua forma de pensar enquanto programador? Dizem isso do Java, eu não concordo, acho que ele foi inovador quando lançado nos anos 1990. Para mim, C# é a linguagem que não acrescenta nada, já nasceu velha imitando as concorrentes que pretendia matar (Java, Delphi e VB). Por outro lado, C# é confortável para quem já tem experiência geral, seja em que linguagem for, e isso é uma virtude inegável.

Conceitos do Elixir

Alguns conceitos da linguagem têm um fator "wow" significativo. Muitos são herdados da linguagem Erlang, em cuja VM o Elixir roda. A ideia do Elixir é ser um Erlang com sintaxe mais amigável, inspirada no Ruby.

Para início de conversa, não há comandos para loops. Nem for, nem while, nem nada disso. É bizarro quando se ouve isso pela primeira vez. (Também achei bizarro quando aos 13 descobri que o dBase/Clipper não tinha GOTO como no BASIC; a primeira sensação é que isso tornaria a tarefa de programação impossível, ou pelo menos extremamente barroca.)

Elixir é uma linguagem funcional, embora menos puramente funcional que Haskell. Uma das características de uma linguagem funcional é a imutabilidade de valores e variáveis. Se uma variável não pode mudar de valor, isso dificulta a criação de loops no estilo for i in range(n) porque i teria de ser mutável.

Mas como Elixir controla então o fluxo do programa? Basicamente, usando recursividade e processamento de listas.

O processamento de listas é mais familiar a quem usa Python e a quem usa idiomas funcionais do Javascript estilo forEach(), map(), reduce(), etc. que são transplantados das linguagens funcionais. Uma lista pode ser uma lista "de verdade", de tamanho finito. Mas também pode representar um recurso "infinito" como uma conexão de rede, ou a saída de outro processo, e aí você tem um loop infinito implícito. Uma "lista infinita" pode ser criada mesmo em Python ou Javascript usando generators/yield.

Antes de falar da recursividade, preciso apresentar outra pedra-de-canto do Elixir que é o pattern matching. Não, não se trata das famigeradas expressões regulares do Verde. Trata-se de haver diversas versões de uma função, sendo que a chamada é direcionada a esta ou aquela versão de acordo com os argumentos. Um pseudo-exemplo simples:

def fatorial(n > 0):
    return fatorial(n - 1) * n

def fatorial(n = 0):
    return 1

A função fatorial() tem duas versões: uma para argumento maior que zero, e outra para argumento igual a zero. Se o cliente da função invocá-la com um número negativo, obtém um erro de runtime, pois nenhuma versão aceita essa situação.

Como disse, é um exemplo simples. Fica bem mais interessante quando o argumento é um dicionário. Por exemplo, se o argumento é um JSON, pode-se direcionar a chamada de acordo com o conteúdo desse JSON, o que indiretamente delega parte do parsing desse JSON para a linguagem, em vez de fazer tudo manualmente.

No exemplo do fatorial, vemos que a função é recursiva para argumentos maiores que zero. Na maioria das linguagens, isso causaria um estouro de pilha para valores grandes, mas Elixir implementa tail-call optimization, significando que a chamada recursiva fatorial() reaproveita o stack frame do chamador. É portanto um loop disfarçado.

O pattern matching e a recursividade otimizada são padronagens mais difíceis de reproduzir em outras linguagens que não o suportam nativamente. Um aspecto do pattern matching do Elixir que transparece em outras linguagens é o destructuring assignment. Por exemplo:

# Python
a, b = (1, 2)
a, b = b, a    # lado direito é uma tupla implícita 
head, *tail = [1, 2, 3, 4, 5]

# Javascript
[a, b, ...rest] = [10, 20, 30, 40, 50];

Em Python e JS isto é basicamente açúcar sintático para "desempacotar" listas e dicionários. Em Elixir, a atribuição é tratada mais como uma equação a ser balanceada; o interpretador faz o possível para interpretar os dois lados de uma forma que satisfaça a igualdade, o que abre espaço para expressões muito mais elaboradas à esquerda. É um idioma Elixir usar esse recurso para e.g. desempacotar e conferir mensagens JSON, seja em protótipos de funções como já vimos no exemplo do fatorial, seja no corpo de uma rotina.

A outra pedra-de-canto é o processo. Um processo do Elixir é semelhante a uma goroutine do Go. Possui um alto grau de isolação em relação a outros processos Elixir, mas não é (necessariamente) um subprocesso separado do sistema operacional. Criar um processo Elixir é muito mais barato que criar uma thread ou processo no sistema operacional, e o programador Elixir é encorajado a usar processos liberalmente.

Cada processo tem uma caixa de entrada e uma caixa de saída, através das quais ele se comunica com outros processos, de forma semelhante aos canais do Go. É um mecanismo semelhante ao que usamos em outras linguagens quando precisamos estabelecer comunicação com subtarefas rodando em threads ou subprocessos, com a diferença que em Go e Elixir esse mecanismo está integrado na linguagem.

Qualquer bloco de código Elixir que deva funcionar assincronamente, com autonomia, e/ou que possa falhar, é encorajado a rodar em seu próprio processo. Um exemplo óbvio seria um servidor Web onde cada conexão HTTP seria delegada a um processo. Em Elixir, até abrir um arquivo significa criar um processo, e manipular esse arquivo implica em trocar mensagens com esse processo.

A metáfora de processo enquanto unidade de trabalho assíncrono tem mais algumas vantagens:

a) estabilidade: a quebra de um processo não pára o programa inteiro. O mantra do Erlang é "let it crash", ou seja, aceitar que processos podem e vão falhar por motivos imprevistos, e o sistema deve reagir de acordo supervisionando os processos e implementando uma estratégia de recuperação.

b) atualização em voo: se cada subrotina é um processo, é possível atualizar partes de um sistema sem pará-lo. Processos baseados em versões diferentes do mesmo código podem conviver durante a atualização.

c) uma vez que a única forma de um processo comunicar-se com outros é através das caixas de entrada e saída, não faz diferença onde eles estejam realmente rodando. Pode ser na mesma thread, em outra thread, em outro processo do sistema, em outro computador, em outra zona do AWS... Isto permite escalar processamento paralelo e distribuído sem nenhuma alteração no código.

Isto não é uma possibilidade teórica; o Erlang/OTP realmente embute suporte a processamento distribuído nas modalidades supramencionadas. Este post no Stack Overflow sugere que a aplicação não precisa tomar conhecimento de clusters de até umas 50 máquinas. (Acima disso, a aplicação teria de ser explicitamente arquitetada e.g. em termos de clusters de clusters.)

d) embora um processo Erlang não seja um processo de sistema operacional, ele ainda possui sua própria pilha, seu próprio heap, e sua própria coleta de lixo (GC). Grande é a chance de um processo Erlang terminar antes da coleta de lixo ser necessária, o que elide seu custo.

Os processos Elixir têm um pequeno custo, então é possível abusar no uso deles, tornando o programa ineficiente. Por outro lado, considerando a grande velocidade dos computadores atuais, me parece que é uma padronagem que deveríamos usar mais frequentemente em outras linguagens, na base da força bruta mesmo, usando processos de sistema operacional.

A concorrência massiva via processos é o grande selling point do Elixir. Também é uma régua que mostra para que o Elixir é bom, e para o que não é tão bom. Ninguém quer ficar pensando em eventos assíncronos ao escrever um utilitário de linha de comando; uma linguagem imperativa vai melhor neste caso.

A outra utilidade do processo no Elixir é armazenar estados, seja o estado local de um processo ou o estado global da aplicação. Sendo Elixir uma linguagem funcional, não existe forma simples de armazenar estado. Não há variáveis globais nem estáticas, nem objetos mutáveis. Porém, a API de processos (mais specificamente, GenServer) oferece um mecanismo onde cada chamada à função do processo pode retornar um valor (que pode ser um dicionário), e repassado como argumento na próxima chamada, que então pode retornar outro valor se o estado mudou, e assim por diante.

A última pedra-de-canto do Elixir que vou mencionar é a semelhança com LISP na questão da barreira código-dados e das macros. Um programa Elixir é representado internamente por tuplas de 3 elementos, que podem ser manipulados como dados. Ou seja, a barreira código-dados, que é grossa na maioria das linguagens e inexistente no LISP, é relativamente fina no Elixir.

Assim como em LISP, Elixir tem um sistema poderoso de macros, o que permite estender a linguagem facilmente e criar DSLs.