作为一名软件工程师,主要精力都集中在「开发」和「维护」上,特别当项目开展时间足够长,维护所花的精力就更多了。 而人的生命是有限的,为了享受人生乐趣,我们应该在有限的时间内完成更多的事情,想办法能更有效率地「维护」项目。

回想一下我们刚接手一个项目的时候是怎么开始「维护」的呢? 大概是看 README、看文档了解使用的技术栈,随后我想一定是离不开阅读代码。 按照经验,我们不难得出一个结论,想要「维护」一个项目,那么一定要「读」懂项目代码。并且读得越快,理解得越好,越容易着手维护项目代码。 所以想要让项目更易于维护,我们首先需要写出可读的代码。

今天我将从 Clojure 这门语言入手,谈谈它的可读性,之所以会选择 Clojure 主要是:

  1. 过程几年都在维护 Clojure 的项目,有一点小心得。
  2. C-like 可读性相关的论述已经足够多,但函数式语言则不多。

(本文假设读者已有一定的 Clojure 基础,了解 Clojure 基本语法)

谈谈命名

关于命名,其他编程语言都会有专门的文章描述如何规范命名。 我们不再赘述相同的规范,这里只针对 Clojure 探讨几点。

使用 - 分隔单词

Clojure 变量名允许包括很多“特殊符号”,例如 -, ?, *, #, <>,都能组成合法的变量名。 在 Clojure 的世界里,不再有驼峰命名或是下划线命名,而是“中横线命名”。

对比一下

(def long-variable-name-which-requires-a-new-line "")

(def longVariableNameWhichRequiresANewLine "")

(def long_variable_name_which_requires_a_new_line "")

当变量名比较长的时候,驼峰命名看起来会显得凌乱一些。使用下划线或中横线,则更符合人类的阅读习惯。 那么为什么我们不采用下划线呢?这是因为在标准键盘布局中,输入 - 会比 _ 更容易(不需要 shift),这样会有一个更流畅的编码体验。

使用 ? 作为断言

问号在自然语言中用来表示疑问,我们可以将这个“共识”移植到 Clojure 代码中来表示断言。

(if (zero? input)
  (ok)
  (fail))

根据直觉我们能很容易地理解 zero? 表示 “是否为 0”。

如果遵循这个约定,那么我们看到 ? 结尾的函数也能很肯定这个函数只返回 truefalse 了。

使用 -> 表示转换

在其他语言中,做类型转换的时,通常会使用 to 或谐音 2 来表示转换,例如 datetime2timestamp。 在 Clojure 中,更习惯使用 -> 表示类型转换。

不使用 userefer :all

在其他语言的 anti-pattern 中,必然会列出 「DO NOT import *」,在 Clojure 中也不例外。 使用 (use ns)(require ns :refer :all) 都不是一个好的实践。

(ns some.module
  (:require [another.module.core :refer :all]
            [another.module.utils :refer :all]))

(def foo (do-some bar))

例如上面的代码,对于不熟悉的维护者而言就很难明白 do-some 这个函数是在哪里定义,需要进入到每个命名空间下找到对应的定义。 所以建议每个 require:as 赋予别名。

当然,有的时候为了简化代码,我们也希望能直接使用某个命名空间下的变量,这时候使用 :refer [var] 将需要的每一个变量都引入。

(ns some.module
  (:require [another.module.core :as core]
            [another.module.utils :refer [bar]]))

(def foo (core/do-something bar))

避免 :as 内置函数名

在我们的项目代码中,常常会遇到这样的代码:

(ns module
  (:require [clojure.string :as str]))

(def foo (str/replace (str 123456789) #"123" "abc"))

这样的代码当然没有错,str 既是命名空间,也是函数。

善用内建函数、宏

Clojure 提供了大量高效强大的函数(或宏),善用它们会让代码更加优雅可读。

when

初学者可能很容易忽略 when,文档是这么描述的:

(when test & bdoy) Evaluates test. If logical true, evaluates body in an implicit do.

如果 test 的值的逻辑真的话,就会执行 body 的部分。 可以理解 when,即是没有 elseif

可以比对以下两段代码。

(when (some? data)
  (let [foo "bar"]
    (do1)
    (do2)
    (do3)
    (do4)
    (do5)))
(let [bar "foo"]
  (do6))
(if (some? data)
  (let [foo "bar"]
    (do1)
    (do2)
    (do3)
    (do4)
    (do5)))
(let [bar "foo"]
  (do6))

当我们看到 when 的时候,就可以很肯定第二个 let 是在 when 外,而使用 if 的时候,还要集中精力去读(shu)代(kuo)码(hao),确保 let 的作用域。

cond

if 嵌套大概是 Clojure 初学者的噩梦,由于 Clojure 没有 return,函数最后执行结果就是函数的返回值。

以下是我刚学 Clojure 写出来的代码,贴出来给大家体会一下。

(defn user-login
  [username password]
  (if (nil? username)
    (if (nil? password)
      (if (valid-password? username password)
        (success)
        (fail))
      (fail))
    (fail)))

上面代码做了一些简化,实际情况还要参入 let 等,代码缩进很深,看起来更混乱。

实际上,clojure 提供的 cond 可以很好的避免 if 嵌套问题:

(defn user-login
  [username password]
  (cond
    (nil? username)
    (fail)

    (nil? password)
    (fail)

    (valid-password? username password)
    (success)

    :else
    (fail)))

cond 相类似的还有 case, condp 等。

->

-> 称为 thread,它的作用类似于 pipeline,将一个变量作为函数的第一个参数传入,函数执行结果作为下一个函数的第一个参数传入,依次类推。

-> 很适合用在 Java 互操作的场景,例如下面的代码。

(first (.split (.replace (.toUpperCase "a b c d") "A" "X") " "))

使用 -> 就可以写得更加清晰明了。

(-> "a b c d"
           .toUpperCase
           (.replace "A" "X")
           (.split " ")
           first)

-> 相类似的还有 ->>, cond->, as-> 等。

使用明确的 pred,而不是数据作为函数

clojure 中的函数都会实现 IFn 的接口,我们会发现一些「数据类型」也实现 IFn 接口。例如 keywordsets等数据类型。这给 clojure 带来了不少的灵活性,但同时也降低了代码的可读性。

contains

使用 contains? 代替集合作为函数。

#{:a :b} 是一个集合类型的数据,它也可以作为一个函数调用 (#{:a :b} :c) 返回集合中存在的数据。

(ns moduleA)
(def focus-types #{:normal :info :news})

(ns moduleB
  (:require [moduleA :refer [focus-types]]))
(defn do-something
  [input-type]
  (if-let [valid-type (focus-types input-type)]    ;;  <--
    (success valid-type)
    (fail)))

可以改成更具可读性的函数 contains?

(ns moduleA)
(def focus-types #{:normal :info :news})

(ns moduleB
  (:require [moduleA :refer [focus-types]]))
(defn do-something
  [input-type]
  (if-let [valid-type (contains? focus-types input-type)]    ;;  <-- 使用 `contains?`
    (success valid-type)
    (fail)))

some?

clojure 和大多数语言一样也具有空值 nil。 当需要判断一个值是否为空值(不是逻辑假)的时候,经常遇到这样的代码:

(if nilable-object
  (success)
  (fail))

这样的写法可能会导致隐形 Bug,例如 objectfalse 时,if 为假,而期望是执行到 (success)。 所以我们建议更明确地写法:

(cond
  (nil? object)
  (println "object is nil")

  (false? object)
  (println "object is false")

  :else
  (println "object neither nil nor false"))

使用函数简化主逻辑

刚开始学习 clojure 的时候,会很自然地将一些参数过滤逻辑写到 let bindings 中,例如下面的代码

(let [fetched-feeds (try (get-feeds url)
                      (catch Exception e
                        (log/err e)))
      latest-feed (when (some? fetched-feeds)
                    (-> fetched-feeds rss/feeds->attachments first))]
  (success {:validate validate?
            :attachments latest-feed})))

let 中,有 try 的逻辑,还有 when 的逻辑,并且还要仔细地去阅读两处的代码才能理解执行的逻辑,当处理的参数更多的时候,这个 let 的代码更会变得很混乱。

我们可以将每个操作逻辑都定义成为一个函数,并且用函数名描述它的功能。

(defn safe-get-feeds
  [url]
  (try (get-feeds url)
    (catch Exception e
      (log/err e))))

(defn fetch-first-rss-attachments
  [fetched-feeds]
  (-> fetched-feed
      rss/feeds->attachments
      first))

(let [fetched-feeds (safe-get-feeds url)
      latest-feed (fetch-first-rss-attachments)]
  (success {:validate validate?
            :attachments latest-feed})))

每行控制在 80 字符内,最多不超过 120

Python PEP8 中规定,Python 代码每行不得超过 80 个字符。 这样的规定看似很奇妙,其实对维护十分有帮助。

之所以规定 80 个字符,是因为上古时代的显示器一行只能显示 80 个字符,为了避免频繁滚动屏幕,所以建议每行不超过 80 个字符。 但是现在显示器已经具有很高的分辨率了,为什么还要要求 80 个字符呢?理由也是一样的。

当我们在写代码的时候,会在同一个显示器中切出两屏显示代码,以便查阅函数实现等等,如果代码能够保持在 80 行内,那么双屏下,代码仍然可以显示完全不需要滚动屏幕。 另外,我们在 GitHub,Gitlab 上进行 Code Review 也可以一口气读完代码不需要拖动滚动条。

那么我们怎么知道 80 个字符有多长,我们当然不可能每行都去数字数,这就需要借助编辑器的功能了。 例如 vim 编辑器可以配置 set colorcolumn=80,而 sublime 可以在配置中加入 "rulers":[79],其他编辑器有有对应的配置。

最后说明

上面所探讨的问题并不是十分高深的经验,仅仅是我在过去一段时间总结的一些开发经验,不一定正确,如果有不同的想法,欢迎提出一起讨探。