Back-End

15 fev, 2016

Escrevendo código mais amigável em Clojure

Publicidade

Eu amo a linguagem Clojure, mas não acho que haja qualquer outra que tenha a combinação de expressividade, energia e desenvolvimento orientado a repl-driven, e isso pode resultar em algum código incrivelmente denso. Todo mundo que escreve código Clojure é culpado disso em um momento ou outro; você começa com o núcleo de sua função, o avalia, o melhora um pouco e, antes que você perceba, terá um programa profundamente aninhado, uma estalagmite torta, cultivada organicamente.

Agora você tem uma escolha: pode deixar tudo como está ou pode voltar e reorganizar com a sua nova compreensão do problema, para que, no futuro, quando alguém (ou você mesmo) tiver a oportunidade de lê-lo, não tenha que começar do zero.

Neste artigo, vou refatorar o seguinte trecho de código criado a partir desta thread do Reddit, que veio deste texto. Eu não quero me alongar nele em particular – existem muito mais flagrantes exemplos e, para todas as suas problemáticas, a implementação de um gerador de texto Markov é perfeitamente sã e razoável. Eu escolhi esse código simplesmente porque ele foi detido por um usuário Clojure desencorajado como um exemplo de código que era difícil de entender.

(defn markov-data [text]
  (let [maps
        (for [line (clojure.string/split text #"\.")
              m (let [l (str line ".")
                      words
                      (cons :start (clojure.string/split l #"\s+"))]
                  (for [p (partition 2 1 (remove #(= "" %) words))]
                    {(first p) [(second p)]}))]
          m)]
    (apply merge-with concat maps)))

(defn sentence [data]
  (loop [ws (data :start)
         acc []]
    (let [w (rand-nth ws)
          nws (data w)
          nacc (concat acc [w])]
      (if (= \. (last w))
        (clojure.string/join " " nacc)
        (recur nws nacc)))))

Esse código foi feito para um gerador de texto Markov. A função markov-data aceita uma string e retorna um mapa hash mapeando cada única palavra na string de caracteres para cada palavra que se segue. A função sentence usa esses dados para gerar uma nova string, escolhendo cada palavra da pesquisa correspondente da palavra anterior.

Uma das características que tendem a tornar o código Clojure mais difícil de analisar é que ele precisa ser explícito. Isso não é algo do qual o Clojure é o único capaz/culpado; outras linguagens fazem o aninhamento (nest) tanto quanto, e apenas fingem que isso não é feito ao tirar as variáveis ​​e evitar chamadas de funções aninhadas. Não há nenhuma razão pela qual não podemos aplicar as mesmas sensibilidades a nosso código Clojure.

Por outro lado, esse aninhamento explícito e imutável do Clojure torna mais fácil tratar logicamente todas as partes aninhadas como caixas pretas. Isso ajuda muito na refatoração. Vamos começar com a primeira função:

(defn markov-data [text]
  (let [sentences (clojure.string/split text #"\.")
        maps (mapcat make-markov-map sentences)]
    (apply merge-with concat maps)))

Uma das coisas agradáveis ​​sobre a refatoração de fora é que nós podemos apenas criar funções que fazem o que nós queremos, e deixar as implementações para mais tarde. Eu ainda não estou feliz com a forma como muitas vezes a palavra “mapa” aparece em uma linha que significa coisas diferentes. Chamá-la de algo como “dict” ou “hash” iria complicar as coisas ainda mais, então vamos chamá-la de “mapeamento”. Essa distinção é suficientemente boa para mim.

Nós também podemos perceber que o nosso bloco let está seguindo um padrão clássico: define a ligação, utiliza a ligação a define a próxima ligação. Sempre que você notar esse padrão, pode se voltar para uma das macros de threading em vez disso:

(defn markov-data [text]
  (->> (clojure.string/split text #"\.")
       (mapcat make-markov-mapping)
       (apply merge-with concat)))

As macros de threading tendem a confundir um pouco os novatos, mas elas são realmente bons em seu trabalho: a reorganização do código para que a ordem das operações seja a ordem na qual você irá lê-los. Aqui, é fácil de ver (com um pouco de conhecimento) que estamos dividindo o texto em períodos, aplicando alguma função para cada frase e, em seguida, fundindo o resultado. Você pode ter que se referir à função make-Markov-mapping e docs para fazer o merge-with para ajudar você a entender as coisas na primeira vez, mas você tem todos esses ciclos extras gastos, e isso não desembaraça a lógica do código para se dedicar a essa tarefa realmente importante.

No make-markov-mapping, aqui está a versão sem mudanças envolta em uma função:

(defn make-markov-mapping [sentence]
  (for [m (let [l (str sentence ".")
       words (cons :start (clojure.string/split l #"\s+"))]
    (for [p (partition 2 1 (remove #(= "" %) words))]
      {(first p) [(second p)]}))]
   m))

Leva algum tempo para ler a função e entender o que está acontecendo. Primeiro, uma vez que seja removida a iteração sobre cada frase para a função externa, o laço externo não é mais necessário. Além disso, essa função faz algumas coisas, a maioria das quais dizem respeito aos detalhes de dividir a sentença em palavras (introduzindo um período, filtrando espaços em branco etc.). Podemos puxar isso tudo para fora em sua própria função e apenas nos preocuparmos com o particionamento:

(defn make-markov-mapping [sentence]
  (let [wordlist (sentence->wordlist sentence)]
    (for [[word & words] (partition 2 1 wordlist)]
      {word words})))

Aqui, nós movemos toda a lista de palavras gerada para uma nova função, e também usamos desestruturação para remover a necessidade de first e second no corpo do laço for interior a partir da função original. Mais uma vez, esse código pode ainda não ser óbvio, se você não estiver familiarizado com partition, mas vale como um pouco de informação útil.

Dica: nós estamos dividindo a lista em grupos de 2, percorrendo um de cada vez. Então, [:start “This” “is” “a” “sentence”] torna-se [[:start “This”] [“This” “is”] [“is” “a”] [“a” “sentence”]].

Finalmente, a função sentence-> da lista de palavras:

(defn sentence->wordlist [sentence]
  (-> sentence
      (str ".")
      (clojure.string/split #"\s+")
      (->> (cons :start)
           (remove #(= "" %)))))

Nessa função, nós adicionamos um período ao final da sentença, dividimos a sentença em palavras, precedemos a lista de palavras com :start, e removemos palavras vazias. Graças a macros de threading, nós podemos fazer todas essas coisas, uma por linha, nessa ordem. Note que o aninhamento ->> macro inside -> funciona bem, mas você não pode fazer o contrário; vou deixar isso como um exercício.

Então, agora nós quebramos a função markov-data em três, e cada uma é muito fácil de entender.

(defn sentence->wordlist [sentence]
  (-> sentence
      (str ".")
      (clojure.string/split #"\s+")
      (->> (cons :start)
           (remove empty?))))

(defn make-markov-mapping [sentence]
  (let [wordlist (sentence->wordlist sentence)]
    (for [[word & words] (partition 2 1 wordlist)]
      {word words})))

(defn markov-data [text]
  (->> (clojure.string/split text #"\.")
       (mapcat make-markov-mapping)
       (apply merge-with concat)))

Em seguida, vamos passar para a função para gerar uma sentença.

(defn sentence [data]
  (loop [ws (data :start)
         acc []]
    (let [w (rand-nth ws)
          nws (data w)
          nacc (concat acc [w])]
      (if (= \. (last w))
        (clojure.string/join " " nacc)
        (recur nws nacc)))))

O que nós queremos fazer aqui é construir uma sentença. Começamos com a  palavra-chave :start para escolher a primeira palavra e, em seguida, usamos cada palavra para procurar as opções para a próxima palavra automaticamente, mais rápido do que escolher uma opção aleatoriamente. Quando escolhermos uma palavra que termina com um ponto, nossa sentença estará completa.

Vamos começar essa refatoração de dentro dessa vez, escrevendo uma função para realizar o trabalho de escolher a palavra seguinte:

(defn pick-next-word [mapping this-word]
  (let [choices (get mapping this-word)]
    (rand-nth choices)))

Nós só salvamos uma chamada aninhada aqui, mas acho que a inclusão de um nome de função significativa vale a pena o esforço.

Em seguida, notamos que essa implementação usa um loop com um acumulador. Sempre que você vir esse padrão, provavelmente deve começar a pensar sobre como reduce pode simplificá-lo. No entanto, nesse caso, reduce não funciona; cada palavra depende da palavra anterior, e a duração da sentença é ilimitada. Em vez disso, a solução mais simples é uma função recursiva:

(defn sentence [mapping words this-word]
  (let [next-word (pick-next-word mapping this-word)
        words (conj words next-word)]
  (if (= (last next-word) \.)
    (clojure.string/join " " words)
    (recur mapping words next-word))))

Observe que isso é quase exatamente o corpo do loop da função original com nomes de variáveis ​​mais longos, embora nós tenhamos mudado um pouco uma coisa; nós já não incluímos os candidatos à próxima palavra em nosso looping, em vez de passar a palavra atual a ser escolhida de modo que nós podemos usar nossa função pick-next-word. Isso remove um detalhe sobre como as palavras são escolhidas a partir da função global de construção da frase.

Essa função funciona bem o suficiente se pudermos chamá-la usando (build-sentence mapping [] :start), mas provavelmente você vai querer fazer isso explicitamente. Você pode fazer isso com um loop ou uma função de variável; ambos funcionam bem:

(defn build-sentence-with-loop [mapping]
  (loop [words []
         this-word :start]
    (let [next-word (pick-next-word mapping this-word)
          words (conj words next-word)]
      (if (= (last next-word) \.)
        (clojure.string/join " " words)
        (recur words next-word)))))

(defn build-sentence-multivariadic
  ([mapping] (build-sentence-multivariadic mapping [] :start))
  ([mapping words this-word]
    (let [next-word (pick-next-word mapping this-word)
          words (conj words next-word)]
      (if (= (last next-word) \.)
        (clojure.string/join " " words)
        (recur mapping words next-word)))))

Em ambos os casos, a maior parte do refactoring que eu fiz foi simplesmente mudar os nomes das variáveis.

E, assim, aqui está a nossa versão final, reformulada:

(defn sentence->wordlist [sentence]
  (-> sentence
      (str ".")
      (clojure.string/split #"\s+")
      (->> (cons :start)
           (remove #(= "" %)))))

(defn make-markov-mapping [sentence]
  (let [wordlist (sentence->wordlist sentence)]
    (for [[word & words] (partition 2 1 wordlist)]
      {word words})))

(defn markov-data [text]
  (->> (clojure.string/split text #"\.")
       (mapcat make-markov-mapping)
       (apply merge-with concat)))

(defn pick-next-word [mapping this-word]
  (let [choices (get mapping this-word)]
    (rand-nth choices)))

(defn sentence [mapping]
  (loop [words []
         this-word :start]
    (let [next-word (pick-next-word mapping this-word)
          words (conj words next-word)]
      (if (= (last next-word) \.)
        (clojure.string/join " " words)
        (recur words next-word)))))

Nossa contagem de linhas e função tem aumentado, mas agora cada função encapsula uma operação menor, mais logicamente coerente. A beleza é subjetiva, mas eu acho que é bem mais fácil de ler e entender. E, melhor de tudo, ele funciona exatamente da mesma maneira. Se você trocou o antigo código pelo novo, ninguém que estivesse usando a versão anterior iria notar, mas qualquer um que teve que trabalhar nesse código – que, lembre-se, pode ser você mesmo dali a 6 meses quando, não conseguirá se lembrar de nada sobre isso – vai agradecer por isso.

Aqui estão algumas dicas rápidas para o caso de você não ter que se lembrar da coisa toda:

  • Use mais funções menores.
  • Use -> and ->> sempre que possível, para criar uma ordem de leitura partindo de uma ordem lógica.
  • Use variáveis ​​e nomes de função com autodocumentação.

***

Adam Bard faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: http://adambard.com/blog/write-friendlier-clojure/