Clojure na prática — parte 1
Conhecendo os "building blocks" da linguagem.
Olá! Seja bem vindo a parte 1 desta série de artigos sobre Clojure na prática. Ao término deste artigo você terá aprendido sobre:
- Quais são as principais estruturas de dados em Clojure;
- O que são symbols e namespaces;
- Como realizar controle de fluxo;
- Como criar suas próprias funções;
- O que são funções puras e porque isto é importante;
- O que são funções de ordem superior;
- A diferença entre programação declarativa vs imperativa;
- Como realizar loops recursivos.
Requisitos
Este artigo assume que você possui algum conhecimento básico sobre programação e que já tenha lido o artigo Por que Clojure?.
Para iniciarmos o nosso aprendizado de Clojure na prática, será necessário fazer a instalação dos seguintes componentes em sua máquina:
Por enquanto, não será necessário configurar nenhum editor de texto, pois vamos apenas utilizar o REPL do Clojure através do Leiningen para executarmos nossos códigos.
Hello World
Após finalizar os passos apresentados nos links anteriores, execute o seguinte comando no seu terminal:
$ lein repl
nREPL server started on port 50370 on host 127.0.0.1 - nrepl://127.0.0.1:50370
REPL-y 0.4.3, nREPL 0.6.0
Clojure 1.10.0
OpenJDK 64-Bit Server VM 1.8.0_232-b09
Docs: (doc function-name-here)
(find-doc "part-of-name-here")
Source: (source function-name-here)
Javadoc: (javadoc java-object-or-class-here)
Exit: Control+D or (exit) or (quit)
Results: Stored in vars *1, *2, *3, an exception in *e
user=>
Caso esteja vendo algo parecido com isto, significa que deu tudo certo com a sua instalação do Clojure e do Leiningen.
Vá em frente e execute o seu primeiro "Hello, World!":
user=> (println “Hello, World!”)
Hello, World!
nil
Estruturas de Dados
Estes são os principais tipos e estruturas de dados em Clojure:
Caso esteja interessado em saber a diferença entre uma linked list e um array, recomendo a leitura deste artigo.
Não se esqueça, todas estas estruturas de dados são imutáveis.
Símbolos
O def
nos permite nomear coisas que podem ser referenciadas posteriormente, assim como o let
em Javascript e var
em Go:
Namespaces
Quando declaramos o symbol my-name
, Clojure nos retornou isto: #'user/my-name
. Isso significa que, no namespace user
, algo de nome my-name
foi criado.
Símbolos, funções e etc, são identificados através dos seus namespaces, que funcionam analogamente como os packages em Go e os modules em Python.
Caso quiséssemos acessar algum símbolo criado em outro namespace, precisaríamos usar a sua referência completa. Ex:
Existem outras operações disponíveis para interagir com namespaces. Por enquanto, os exemplos apresentados nos serão suficientes.
let
Enquanto o def
nos permite criar referências globais através de seus namespaces, a macro let
permite criar referências locais dentro de seu escopo:
Controle de Fluxo
Existem ainda outras opções na biblioteca padrão do Clojure para realizar controle de fluxo, como o when-not
, if-not
, if-let
e etc.
Funções
Para criar uma função, utilizamos a macro defn
:
Vamos entender a sua estrutura:
(defn
: invocação da macro de criação de funções;sum
: nome da nossa função;"Receives..."
: documentação da função (opcional);[a b]
: lista de parâmetros (dois parâmetros nomeados comoa
eb
);(+ a b))
: corpo da função e fechamento da declaração desum
. O retorno desta função é a soma dos dois valores recebidos como parâmetro. Perceba que o retorno da função é implícito, ou seja, não há umreturn
indicando o término da mesma.
Seguem mais alguns exemplos de criação de funções:
Funções anônimas
Em determinadas situações, precisamos utilizar uma função para resolver um caso pontual, sem termos a necessidade de referenciá-la posteriormente em outras partes do nosso código. Para isto, podemos utilizar funções anônimas:
Multi Aridade e Funções Variádicas
Aridade é a quantidade de parâmetros que uma função aceita. A função sum
que declaramos anteriormente possui aridade igual a 2, já que ela aceita dois parâmetros, a
e b
.
Clojure suporta funções de múltiplas aridades, o que permite que uma função possa utilizar um valor default caso algum parâmetro não seja fornecido:
Antes de avançar para os próximos tópicos, tenha certeza de que compreendeu bem o comportamento deste tipo de declaração. Aproveite para praticar um pouco criando as suas próprias funções :)
Desestruturação
Destructuring (particularmente, prefiro este termo em inglês), permite extrair valores internos das estruturas de dados fornecidas como parâmetro, associando esses valores a referências. Ex:
Funções Puras
Uma função é considerada pura quando esta retorna sempre o mesmo resultado, dado os mesmos valores de entrada e não possui nenhum efeito colateral (side effect).
Ou seja, se uma função não altera/lê nenhuma entidade mutável (ex: banco de dados, filesystem, stdout, variável global, etc) e apenas interage com os valores recebidos como parâmetro, então ela é considerada uma função pura.
Algumas funções puras que criamos anteriormente:
sum
: sempre retornará o mesmo resultado, dado os mesmos valores de entrada;hello-map
(exceto a versão comprintln
): sempre retornará a mesma string, dado o mesmo mapa de entrada.
Funções puras são ótimas para manter o seu código previsível, fácil de testar e menos suscetível a comportamentos indesejados.
Funções de Ordem Superior
Uma função de ordem superior (high order function), é uma função que aceita uma função como argumento ou devolve uma função como retorno. Linguagens que seguem o paradigma funcional, devem suportar high order functions.
As high order functions mais "famosas", são:
map
: recebe uma função f e uma coleção coll como parâmetro. Retorna uma nova coleção contendo o resultado da aplicação de f em todos os valores de coll;filter
: recebe um predicado (função que retornatrue
oufalse
) e uma coleção coll como parâmetro. Retorna uma nova coleção com apenas os valores que retornaramtrue
do predicado aplicado em coll;reduce
: recebe uma função f, um valor inicial (opcional) e uma coleção coll. Retorna apenas um valor acumulado, originado da aplicação de f em todos os valores de coll.
Nada como um exemplo prático para entender melhor, não acha?
Programação Declarativa
Perceba o quão expressivo foi este último exemplo onde combinamos a execução das high order functions. Eu basicamente estou “dizendo” o que eu quero, sem descrever como chegar no resultado esperado. Em outras palavras, eu estou declarando o que eu quero, sem dizer imperativamente como chegar lá.
Utilizando uma linguagem imperativa, como Go ou Java, eu teria que descrever os passos necessários para chegar ao resultado que eu quero, utilizando loops, alterações de estado e etc.
Esta é uma das diferenças entre programação declarativa e programação imperativa. Não que uma seja melhor que a outra. São apenas formas diferentes de se programar.
Threading Macros
Expressões aninhadas podem se tornar terrivelmente ilegíveis. Não é natural pensarmos na sequência de avaliação das expressões acontecendo de “dentro pra fora”.
Threading Macros existem justamente para melhorar a legibilidade de expressões aninhadas:
Existem outras Threading Macros, como:
->
: a Threading First, passa o retorno da função anterior, como o primeiro parâmetro para a próxima função;some->
esome->>
: funcionam como as anteriores, porém estas interrompem a execução da cadeia de funções assim que o valornil
for encontrado no retorno de alguma expressão (short-circuit);- Entre outras.
Ficou um pouco mais fácil lidar com os parênteses agora, não acha?
Recursão
Em programação funcional, mudança de estado é algo que deve ser evitado, sendo assim, os loops são realizados através de recursão:
Dependendo da linguagem de programação que você conhece, recursão pode até ser visto como algo à ser evitado, por conta de possíveis Stack Overflow Errors que iterações muito longas podem gerar.
Em Clojure, quando utilizamos loop
e recur
, o compilador "entende" que você deseja criar um loop recursivo e então, ele otimiza o seu código para que Stack Overflow Errors não ocorram.
Em muitos casos, as high order functions apresentadas ou até mesmo outras funções da biblioteca padrão de Clojure para realizar iterações podem ser opções mais eficientes (ex: doseq
, dotimes
, dorun
).
Sintaxe e Concisão
Percebeu que toda declaração ou expressão que vimos ao longo do artigo seguiu exatamente o mesmo padrão?
- Temos uma função, macro, etc, no início de uma abertura de parênteses:
(def...
,(defn...
,(sum...
,(println...
; - e uma lista de argumentos: sejam elas invocações de outras funções (que também vão seguir esse mesmo padrão), ou estruturas de dados.
É isso que significa dizer que Clojure, ou Lisps em geral “não possuem sintaxe”, já que todo código sempre vai seguir este mesmo formato (salvo algumas poucas exceções). São apenas listas dentro de listas. Apenas dados e List Processing :)
Próximos Passos
Para não deixar este artigo muito extenso e apresentar muitos assuntos de uma só vez, vou dividi-lo em mais duas partes:
- Parte 2: Polimorfismo, Multi-methods, Protocols e Records;
- Parte 3: Concorrência, paralelismo e gerenciamento de estado.
A partir de agora, acho interessante realizar o setup da sua IDE/editor de texto para programar em Clojure. Seguem algumas recomendações:
- VS Code + Calva;
- IntelliJ + Cursive;
- Atom + Chlorine;
- Emacs + Cider ("here be dragons");
Até logo!