Orso Labs 首页 博客

Clojure 代码重构实践 (1)

本文基于 Adam BardWriting Friendlier Clojure,他在文章中展示了他重构实现 order-1 word-level Markov text generator 的一些 Clojure 代码的方法。

我们的目标是改进这段代码,使其更具可读性:

(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)))))

在 REPL 中试用了一段时间,了解其工作原理和涉及的数据结构之后,我们就可以开始考虑如何重构了。

请记住,重构 是指在_不改变_代码行为的前提下修改代码,因此,为了确保我们没有改变代码的行为,我们需要测试。 由于代码已经存在,我们需要characterization tests,Michael Feathers 在 Working Effectively with Legacy Code 一书中对此进行了很好的解释。

以下测试描述了 markov-data 函数:

(deftest markov-of-empty-string
 (is (= {:start ["."]} (markov/markov-data ""))))
(deftest markov-of-one-word-with-stop
 (is (= {:start ["A."]} (markov/markov-data "A."))))
(deftest markov-of-one-word-without-stop
 (is (= {:start ["A."]} (markov/markov-data "A"))))
(deftest markov-of-two-words
 (is (= {:start ["A"], "A" ["B."]} (markov/markov-data "A B."))))
(deftest markov-of-two-1-word-sentences
 (is (= {:start ["A." "B."]} (markov/markov-data "A. B."))))
(deftest markov-of-two-2-word-sentences
 (is (= {:start ["A" "C"], "A" ["B."], "C" ["D."]}
     (markov/markov-data "A B. C D."))))
(deftest markov-of-two-sentences-with-repetition
 (is (= {:start ["A" "A"], "A" ["B." "B"], "B" ["C."]}
     (markov/markov-data "A B. A B C"))))
(deftest markov-of-four-words-with-repetition
 (is (= {:start ["A"], "A" ["B" "B."], "B" ["A"]}
     (markov/markov-data "A B A B."))))

从 REPL 和测试中,我们看到 markov-data 函数接收一个字符串,并返回一个 hash map,其键是字符串中的单词,其值是紧随其键之后的单词序列,从而实现了一个 order-1 word-level Markov process。 此外,特殊的键 :start 表示所有句子的开头单词。

让我们再次看一下原始代码:

(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)))

从头开始编写似乎比重构它更容易。 上面的 characterization tests 将有助于确保返回的 hash map 保持不变。

我们注意到输入字符串 text 由一个或多个_句子_组成,其中句子是由句点字符 (.) 结尾的单词序列。 因此,我们可以从编写一个仅处理一个句子的函数开始。

我们获取句子,将其拆分为单词,然后一个接一个地使用单词,并随着我们的进行更新 hash map。 这需要一个 reduce。 诀窍是要意识到传递给 reduce 的函数实际上并不限于两个参数(第一个是累加器,第二个是集合的元素),因为累加器可以是一个 vector,我们可以进行解构:

(defn process-sentence [data sentence]
 (let [[data _]                                  ; <1>
    (reduce (fn [[data key] word]                       ; <2>
          [(update data key (fn [val] (if val (conj val word) [word]))) ; <3>
           word])                            ; <4>
        [data :start]
        (string/split (string/trim sentence) #"\s+"))]
  data))                                     ; <5>

现在我们有了一个能够处理单个句子的函数,我们可以回到 markov-data 并意识到我们可以再次使用 reduce,这次将 process-sentence 传递给它,并在句点字符的边界上拆分输入字符串 text

(defn markov-data [text]
 (let [data (->> (string/split text #"\.")
         (filter (complement string/blank?))
         (map #(str % "."))            ; <1>
         (reduce process-sentence {}))]
  (if (empty? data)
   {:start ["."]}                    ; <2>
   data)))

现在轮到函数 sentence 了,我们可以预期在测试中会遇到困难,因为它涉及随机性。

前 3 个 characterization tests 很简单,不涉及随机性:

(deftest sentence-of-empty-string-is-the-dot
 (is (= "." (markov/sentence (markov/markov-data "")))))
(deftest sentence-of-one-word-is-itself
 (is (= "A." (markov/sentence (markov/markov-data "A.")))))
(deftest sentence-of-one-sentence-is-itself
 (is (= "A B C." (markov/sentence (markov/markov-data "A B C.")))))

接下来的 2 个测试更复杂,因为它们必须考虑 Markov 过程的随机性:

(deftest sentence-of-non-repeating-words-is-one-of-the-original-sentences
 (let [out (markov/sentence (markov/markov-data "A B C D. E F G. H I."))]
  (is (some #{out} ["A B C D." "E F G." "H I."]))))
(deftest sentence-of-simple-repetition
 (let [out (markov/sentence (markov/markov-data "A B C. B D."))]
  (is (some #{out} ["B C." "A B D." "B D." "A B C."]))))

这里的技巧是使用一个小的输入并枚举所有可能的输出,使用以下构造

(some #{"A"} ["A" "B" "C"])

这是 Clojure 测试元素 "A" 是否包含在集合 ["A" "B" "C"] 中的惯用方式。

现在我们可以进行重构了。 再次查看原始代码:

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

对于我们的重构,我们用递归函数调用替换 loop,并且我们使用 Clojure 函数可以具有不同数量参数这一事实。 具有参数 1 的版本(尊重 API)调用具有参数 3 的版本,即递归点。

我们还使用稍微更长的变量名来提高可读性,并使用惯用方式添加到 vector:

(defn sentence
 ([data]
  (sentence data (:start data) []))
 ([data words acc]
  (let [word   (rand-nth words)
     acc-next (conj acc word)]
   (if (string/ends-with? word ".")
    (string/join " " acc-next)
    (recur data (get data word) acc-next)))))

我们完成了。 虽然重构后的代码比原始代码稍长,但它具有可读性,因此易于维护。

参考

#clojure #refactoring #markov-chain Made with Hugo ʕ•ᴥ•ʔ Bear