Clojure 代码重构实践(一)
Orso Labs 首页 博客
Clojure 代码重构实践 (1)
本文基于 Adam Bard 的 Writing 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>
- < 2> 传递给
reduce
的函数以[[data key] word]
作为参数,其中 vector[data key]
包含实际累加器(hash mapdata
)和单词key
。 - < 3> 一切都在这里完成,使用
update
。 需要if
来区分 hash map 何时没有键key
(因为val
是nil
)。 - < 3>,<4> 构造函数的返回值,它是 vector
[updated-data word]
,其中updated-data
是更新后的 hash map,word
是集合的元素,将在下次调用函数时用作 hash map 的键。 - < 1> 需要
let
来解构函数的最后返回值,提取 hash map,并在 < 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)))
- < 1> 我们在每个句子中添加一个句点字符。 这在 characterization tests 中也很明显。 原因是句点字符是另一个函数
sentence
的终止条件,我们将会看到。 - < 2> 再次,我们确保 hash map 至少包含一个句点字符。
现在轮到函数 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)))))
- < 1> 引入 Markov 过程的随机性。
- < 2> 终止条件。
对于我们的重构,我们用递归函数调用替换 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)))))
我们完成了。 虽然重构后的代码比原始代码稍长,但它具有可读性,因此易于维护。
参考
- Writing Friendlier Clojure
- Adam Bard
- Markov text generator
- Working Effectively with Legacy Code
- characterization tests
- clojure.spec
#clojure #refactoring #markov-chain Made with Hugo ʕ•ᴥ•ʔ Bear