Nos últimos posts eu percebi que me empolguei um pouco no assunto Clojure. Então, esse é um post para tentar começar com a linguagem, ao invés de tentar entender detalhes. Vou atualizar os outros posts para indicar que esse é o primeiro da série, apesar de estar por último…
Clojure é uma linguagem baseada em LISP. Isso, pra muita gente, significa parênteses intermináveis e sintaxe horripilante. Mas não é bem assim.
Os parênteses são um desafio, um degrau. Então ignore-os por enquanto. Use um editor com suporte ao parinfer – Atom ou LightTable. Acho que o vim também. Isso vai tratar de manter os parênteses em sincronia, baseado na indentação, e também de forçar você a entender a indentação de Clojure. A partir daí, é entender por que esses parênteses existem. Então vamos lá:
Em LISPs, ou seja, em Clojure, parênteses nunca são opcionais. Nunca. Então nem tente resolver seu código com “vou tentar colocar um parênteses aqui” porque não vai funcionar. Você sempre abre um parênteses quando você vai chamar uma função ou special form, ou macro. A soma, multiplicação, divisão e subtração (+
*
/
e -
, respectivamente) são funções. Concatenação de strings (str
) também, bem como map
, reduce
, split
e join
. Já o if
não é uma função – é uma special form, bem como fn*
(retorna uma nova função) e def
(define novas variáveis, que os LISPs gostam se chamar de símbolos). E o or
, o and
, e o defn
são macros. Para poder usar todos eles, sem exceção, você tem que abrir um parêntese.
Primeiros passos
Para somar 4 números, abrimos um parêntese e o primeiro elemento é a função da soma. Ou seja:
(+ 5 3 9 7)
Isso vai somar os quatro números. Normalmente deixamos grudado ao parêntese a função que vamos rodar.
Rodar um código que eleva ao quadrado um número n
é igualmente fácil – definimos a variável n
primeiro, usando def
, e então rodamos o código que multiplica n
por n
:
(def n 5) (* n n)
Dizemos que def é uma special form porque ele é parte integrante da linguagem, implementada de forma interna. Special forms nunca são simuláveis usando-se outra construção da linguagem – sempre dependem dos internos da mesma. Por exemplo, não dá pra simular o if
usando elementos da linguagem, porque o if só roda um dos ramos – se a condição do if for verdadeira, ele só rodará a primeira parte, senão, só rodará a segunda. Special forms, por definição, sempre tem comportamentos especiais.
No caso do def
, ele cria uma variável (ou símbolo, usando o nome de Clojure para tal). Ele pede dois parâmetros – o primeiro é o nome do símbolo, e o segundo, o valor dele.
Já no caso do if
, ele pede três parâmetros – o primeiro é a condição, o segundo é o ramo se a condição for verdadeira, é o terceiro é o ramo se a condição for falsa. Claro que o if
pode ficar bem difícil de ler, então para condições e ramos não triviais, sempre usamos uma indentação para indicar o que é o que:
(def denominator 0) (if (= denominator 0) "THIS IS INVALID! TRY AGAIN" (/ 1 denominator))
Por fim, temos o fn
, que é uma macro em cima do fn*
. Basicamente, fn
cria uma função e a retorna imediatamente. Podemos criar uma nova função em clojure usando fn
ou usando defn
, ambas macros:
(def median (fn [x y] (/ (+ x y) 2))) ; OU (defn median [x y] (/ (+ x y) 2)) ; Abaixo, uma maneira estranha. Lembre-se, toda abertura ; de parentese é uma chamada de função. Isso significa que ; podemos definir uma função anônima e já chamá-la com os ; parâmetros que queremos: ; Aqui, tiramos a média de 10 e 12. Definimos a função ; e já a chamamos imediatamente. Repare nos dois parênteses. ; Isso, na verdade, é pouco usado, porque é difícil de ler. ((fn [x y] (/ (+ x y) 2)) 10 12)
No post anterior, mencionei o Parinfer, um plugin que infere os parênteses a partir da indentação. Essa é, normalmente, a maneira mais simples e fácil de escrever LISPs, como Clojure. Por enquanto, o Parinfer roda bem no Atom, e estou tentando integrá-lo o mais rápido o possível com o LightTable (hoje ele roda, mas com algumas ressalvas. Não atrapalha muito, mas poderia ficar bem, bem melhor). Abaixo, uma imagem do Parinfer rodando sobre o LightTable:
Por fim, def
só define símbolos globais. Para símbolos locais, usamos o que se chama de let-expression – basicamente uma macro cujo primeiro parâmetro é um vetor (que em Clojure declaramos com [
e ]
– encare como o Array de outras linguagens, exceto que não precisamos de vírgulas entre os elementos, logo um vetor com os valores 1 4 5 seria escrito [1 4 5]
) que precisa ter elementos par – o elemento par é o nome da variável/símbolo, e o elemento ímpar é seu valor. Os outros elementos do let
será o corpo do que será rodado, atribuindo os valores aos símbolos. O retorno de uma let-expression sempre será a última função rodada, logo o código abaixo retornará “Hello, world” (a função str
concatena strings):
(let [variable-1 "Hello" variable-2 "World!"] (str variable-1 ", " variable-2))
Outra coisa interessante é que a linguagem é dinâmica e compilada – sim, geramos arquivos compilados em Clojure. Logo, existe a possibilidade de gerar classes Java a partir de Clojure, e de níveis de otimização diferentes. As bibliotecas são empacotadas em formato .jar, e podem ser publicadas no Clojars ou no Maven. O sistema de build mais usado – lein – é inteligente para resolver dependências e instalá-las de forma global, quase como o Rubygems ou o pip do Python. Além disso, variáveis não definidas, número incorreto de argumentos para funções e outras trivialidades são capturadas no processo de compilação.
Macros
Até agora mencionamos macros mas nada de dar um exemplo prático. Bom, basicamente, macros são elementos de LISP (e Clojure) que criam uma “pseudo-função”. Basicamente é uma forma de meta programação (código que gera código), porém as macros são resolvidas em tempo compilação. Resumindo, macros são códigos que alteram a sintaxe digitada para retornar outra sintaxe. Tudo isso fica mais fácil com um exemplo – falamos que o or
e o and
são macros. Basicamente, os códigos abaixo são equivalentes:
(or (= a 10) (= b 20)) (let [result (= a 10)] (if result result (= b 20)))
Definir macros é uma tarefa extremamente simples: basicamente, você define uma função usando defmacro
, que retorna uma lista que é um código clojure válido. É fácil de fazer isso usando quotes – por exemplo, '(foo bar)
gera uma lista de dois elementos, o primeiro é um símbolo foo
e o segundo, um símbolo bar
. Clojure até tem uma forma fácil de escapar com quotes e des-escapar também, com ~
. Por exemplo, o código abaixo cria a macro unless
, que basicamente é o inverso do if
– repare que precisamos criar com macros porque se criarmos com funções, Clojure tentará rodar os dois ramos – true e false – e não é isso que queremos.
(defmacro unless [condition if-false if-true] `(if ~condition ~if-true ~if-false)) ; usa-se com: (unless true (/ 9 0) (/ 9 3)) ; => retorna 2, ou seja, 9 / 3
ClojureScript
Uma das coisas mais fantásticas é o uso de clojure no browser, com ClojureScript. Como Javascript é meio lento para tal, para ClojureScript certas coisas são meio estranhas – por exemplo, as macros são escritas em Clojure (não em ClojureScript) e devem ser explicitamente requeridas com require-macros
, e não com require
. Outro ponto é que nosso ambiente é JavasScript, logo suporte para expressões regulares, a própria velocidade de execução, etc, são limitadas pelo que JavaScript nos oferece. Logo, (/ 1 0)
não retorna uma exception, e sim retorna js/NaN
, códigos que listam caracteres de uma string não retornam caracteres e sim uma string de um elemento, etc.
Uma vantagem de ClojureScript é que ele gera source-maps, logo o debugger dos navegadores usa uma versão Clojure do código, não uma versão JavaScript. Além disso, o compilador usa o Google closure compiler – são gerados vários arquivos JavaScript que ou são agrupados e minificados, ou são mantidos sem nenhuma alteração e a geração de dependências fica através do require
do Google. Isso garante um código JavaScript pequeno no navegador, já que apenas o que é usado em Clojure é exportado para JavaScript, e otimizado em production (demora mais para compilar) ou não otimizado para desenvolvimento (compilação rápida, mas não otimizado. Porém, ganhamos com source-maps e um debugger mais simples).
Imutabilidade
Por padrão, em Clojure tudo é imutável. Quando queremos mutabilidade, usamos construções especiais, como o atom
ou o agent
.
Porém, Clojure facilita nossa vida nos entregando funções que levam em consideração que os objetos são imutáveis. Por exemplo, atualizar um vector que está dentro de um map que está dentro de outro map é feito facilmente com apenas um comando. Códigos como map
, flat map (mapcat
), filter
, etc. retornam lazy sequences, ou seja, os códigos para gerar a sequência intermediaria não rodam até que seja necessário – ou porque vamos reduzir a sequência a um valor único, ou porque vamos agrupar, ordenar, ou exibir.
A maior parte das abstrações feitas em Clojure é feita a partir ou do map, ou de records. Records são tipos especializados de maps, que geram uma classe Java e, consequentemente, são mais otimizados. Há o uso de protocolos e outras ferramentas para reaproveitar código, mas também há muitas formas de abstrair uma estrutura qualquer. Por ser uma linguagem funcional, prefere-se usar ferramentas que compõem funções, mas ao mesmo tempo Clojure oferece ferramentas para trabalhar melhor com orientação a objetos, principalmente para integrar com Java, JavaScript, ou outras linguagens.
Por exemplo, abaixo uma manipulação típica de uma coleção – digamos que um dado veio do banco de dados, e possivelmente veio sem a idade. Se for esse o caso, atribuímos a idade 0
.
(defn assign-default-age [system] (update-in system [:admin :age] #(or % 0))) (map assign-default-age (get-all-systems))
Para criar elementos mutáveis, usamos a função atom
. Essa função cria uma estrutura que só pode ser atualizada com as funções reset!
e swap!
. A vantagem mais óbvia é que fica muito claro os lugares aonde as variações ocorrem, e isso diminui efeitos colaterais e comportamento inesperado o máximo que conseguimos. Outra vantagem de usar atoms é que, usando swap!, somos forçados a atualizar uma variável usando uma função – o que amarra a alteração a uma ação também.
Por exemplo, o código abaixo serviria para manter, em cache, uma lista de pessoas que temos em nosso banco de dados. Usamos observers – códigos que, quando fazemos alguma alteração no banco, chamam uma função. Vamos pensar que on-delete
espera uma função que recebe um parâmetro, o objeto a ser deletado, e on-create
espera uma função que recebe o objeto a ser inserido. Já on-update
recebe dois parâmetros – os dados antes da alteração, e os dados depois.
Repare que toda alteração tem uma função associada e que não precisamos, obviamente, testar a alteração no BD, apenas garantir que a função associada faz o trabalho certo. Detalhe também é que o swap!
recebe parâmetros arbitrários – o primeiro é o atom que estamos atualizando, o segundo é a função, e o resto são parâmetros que são passados para a função que faz a alteração. Logo, (swap! cache add-person data)
é a mesma coisa que (swap! cache (fn [] (add-person cache data)))
, o que facilita bastante as coisas
(def cache (atom {})) ; valor inicial - map vazio ; retira o id do mapa (defn remove-person [map data] (dissoc map (:id data))) ; adiciona o id ao mapa, associando-o com os dados da pessoa (defn add-person [map data] (assoc map (:id data) data)) ; Remove a pessoa antiga do cache, e adiciona a nova ; Fazemos desse jeito pois pode haver mudança no ID ; (raramente acontece em bancos de dado, mas não queremos arriscar) (defn update-person [map old-data new-data] (-> map (remove-person old-data) (add-person new-data))) (on-delete database (fn [data] (swap! cache remove-person data)) (on-create database (fn [data] (swap! cache add-person data)) (on-update database (fn [old new] (swap! cache update-person old new))
Compilação
Um ponto de atenção é que, embora as coleções são lazy sempre que possível, Clojure é uma linguagem bem, eager. Isso significa que muitas coisas resolvem em tempo de compilação, e que Clojure não permite dependência cíclica (A depende de B e B depende de A, como Scala permite, por exemplo) e também não permite usar um símbolo antes dele ser definido. Logo, o código abaixo não funciona:
(defn foo [x]
(bar 10 x))
(defn bar [x y]
(println (+ x y))
Você é obrigado a definir bar
antes de foo
, ou usar (declare bar)
antes de foo
, para informar que, mais à frente, haverá uma definição de bar
.
Como Clojure compila para a JVM, e para JavaScript, tail-recursion não é bem suportado de forma natural. Para fazer uma recursão de cauda (tail recursion para os íntimos) temos que chamar recur
. Isso até é interessante – sabemos claramente se uma função é recursiva de forma correta ou não só a partir dessa função:
(defn repeat [times f] (when (> times 0) (f times) (recur (dec times) f)))
O problema é recursão de cauda quando são duas funções – A depende de B e B depende de A. Nesse caso, a única forma é usar trampoline
, uma função de Clojure que trabalha da seguinte maneira: ele recebe uma função, e escuta pelo retorno dela. Se o retorno for uma função, ele a chama sem argumentos, até que o retorno não seja mais uma função.
(defn- repeat-helper [times f] (when (> times 0) (f times) #(repeat-helper (dec times) f))) (defn repeat [times f] (trampoline repeat-helper times f))
Onde ir agora?
Ver as postagens anteriores, sobre Clojure e simplicidade. Se tiver curiosidade sobre a forma diferente de se programar, há o post sobre programação com o LightTable. Por fim, um pouco mais sobre programação funcional, e imutabilidade.
E, claro, estudar. Passar os parênteses, entender o modelo da linguagem, resolver exercícios, e estudar como essas coisas integram no ecossistea Clojure. Para o próximo post, provavelmente falarei um pouco mais sobre a integração client + server.
1 Comment
Clojure e simplicidade | Maurício Szabo · 2016-04-19 at 20:35
[…] ← Programação funcional, imutabilidade, e previsibilidade Clojure, gentilmente → […]
Comments are closed.