LISP 宏:用或不用?

LISP 宏概述

LISP 宏是一种强大的工具,可以用来创建新的语言特性。宏在编译时被执行,这使得它们非常适合创建通用的或重复性的代码,宏最原初的定义方式是通过 con, car, cdr 等源语手工构建 LIST 数据结构,配合 unquote 转义来实现。在 Clojure 中使用 defmacro 定义宏,如下所示:

(defmacro do-until [& clauses]
  (when clauses
    (list 'clojure.core/when (first clauses)
          (if (next clauses)
            (second clauses)
            (throw (IllegalAccessException.
                     "do-until requires an even number of forms")))
            (cons 'do-until (nnext clauses)))))
 
(macroexpand-1 '(do-until true (prn 1) false (prn 2)))
=> (clojure.core/when true (prn 1) (do-until false (prn 2)))

(clojure.walk/macroexpand-all '(do-until true (prn 1) false (prn 2)))
=> (if true (do (prn 1) (if false (do (prn 2) nil))))

为简化宏的编写逻辑,常使用反 quote 和拼接实现基于模板的宏编写,~~@ 会将参数注入到模板指定位置,前者原样放置,后者将序列展开放置。此外,# 会为宏自动生成随机变量名,~' 则为变量转义后引入,用于提供唯一变量名。

(defmacro def-watched [name & value]
  `(do
     (def ~name ~@value)
     (add-watch (var ~name) :re-bind
                (fn [~'key ~'r old# new#]
                  (println old# " -> " new#)))))

(macroexpand '(def-watched a 1))
(def-watched x (* 12 12))
(def x 0) ;144  ->  0

(macroexpand-1 '(def-watched x 0))
=>
(do
 (def x 0)
 (clojure.core/add-watch
  (var x)
  :re-bind
  (clojure.core/fn
   [key r old__1892__auto__ new__1893__auto__]
   (clojure.core/println old__1892__auto__ " -> " new__1893__auto__))))

使用宏有很多优点,包括:

  • 代码复用: 宏可以用来创建通用的或重复性的代码,这可以减少代码的重复和提高代码的可维护性。
  • 代码简洁: 宏可以用来简化代码,使其更易于阅读和理解。
  • 创建新的语言特性: 宏可以用来创建新的语言特性,这可以扩展 Clojure 的功能并使其更适合于特定的任务。

当然,它也有一些缺点,包括:

  • 学习曲线: 宏的学习曲线可能比较陡峭,尤其是对于 Clojure 新手来说。
  • 编译时间: 宏在编译时被执行,这可能会增加编译时间。
  • 调试困难: 宏的调试可能比较困难,因为它们是在编译时而不是在运行时被执行的。

后端:路由库的例子

Clojure 社区针对是否使用宏来提升语言表现力的两个派别,第一派拒绝使用宏,其寄希望通过数据结构组合来实现语言特性,提升特定领域业务描述能力,即所谓的“面向数据编程”。第二派则依托于宏来实现这一目标。

使用 Web 后端的路由定义这一个例子来展示这两派的区别和优缺点是再好不过的了:

reitit 是一个高速、强大的路由库,其路由定义使用自定义数据结构的方式实现,下面是一个真实的例子,其中包含 /story 路径下的两条路由,此路径被归类为 swagger 的 “故事服务” 标签,这两条路由只接受 get 请求,其中第二条路由从路径参数接收 book 和 name 参数,在 get 请求中,summary 定义了 swagger 对此路由的描述,parameters 对查询、正文、路径参数的字段类型进行了定义,其不仅用于 swagger 生成模型,还会在实际运行时校验请求数据是否合法,在 handler 中的是控制器方法,这里的函数入参是请求,从请求中提取参数,然后调用业务返回响应。

reitit 的路由定义没有使用任何“宏”,其完全是通过 Clojure 数据结构 Vector、Map 和 Set 实现领域业务描述的,可以看到,除了需要遵守这一些需要记忆的规则,这种方式对于动态语言而言非常适合,表现力不弱。

["/story"
   {:swagger {:tags ["故事服务"]}}
   ["/all-book-and-story-name"
    {:get         {:summary    "获取所有故事书和其故事名"
                   :parameters {:query (with- :opt [:book string?])}
                   :handler    (fn [{{{may-book :book} :query} :parameters}]
                                 (hr/response (stories/handle-all-story-book-and-index may-book)))}}]]
   ["/read-story/:book/:name"
    {:auth/cache  {:cache-control 3600 :key-fn :get-query}
     :auth/logged "story-of-book-api"
     :get         {:summary    "获取故事书内容"
                   :parameters {:path {:book string? :name string?}}
                   :handler    (fn [{{{book :book name :name} :path} :parameters}]
                                 (hr/response (stories/handle-story-of-name book name)))}}]]]

与之相反,Compojure 库则是一个基于宏实现的路由库,其使用 defroutes 创建路由,对于每一条路由,第一个参数总是其方法 METHOD,之后为匹配路径,然后 Vector 中放置的是需要从路径、请求体或查询参数中解析的变量占位符,最后是一个控制器函数,其使用这些变量并返回响应。

(defroutes app-routes
  (context "/api/:version" [version]
    (GET "/users" [] (get-all-users))
    (GET "/users/:id" [id] (get-user-by-id id))
    (POST "/users" [params] (create-user params))
    (PUT "/users/:id" [id params] (update-user id params))
    (DELETE "/users/:id" [id] (delete-user id))))

直接将这两个例子的代码量比较并不恰当,因为后者只是一个示例,其完成功能和前者并不同:校验、Swagger 完全支持。但不管怎样,能够看到宏对于提高语言领域相关业务表现力的强大促进作用,Compojure 非常适合那些短平快的小项目,使用几乎最少的代码实现一个规范领域的业务描述,实在是让人惊叹。上述代码中,几乎每一个字符都不是冗余的,Vector 用于提供变量提取,METHOD、PATH 以及控制器方法都精简到了不可以再精简的地步,这里甚至没有一个多余的空格和逗号。

当然,对于这些规范领域而言,宏是一个简化代码提升表现力的好帮手,但倘若我们想要扩展路由能力,比如加入 Swagger 支持以及自定义的鉴权、缓存、日志呢?宏作者很少,或者不能为我们提供更多的扩展能力。

如果您熟悉 reitit,上述真实业务中可以看到路由中不同寻常的 :auth/cache 和 :auth/logged 键,这是我用来为路由实现日志和客户端以及服务器缓存的数据描述。事实上,reitit 作者几乎不用考虑任何扩展性问题,其只需要提供获取这些路由数据的一个接口,使用者就可以利用路由上的任何数据实现任何可能的功能,原因很简单 —— 路由定义是单纯的数据,可以经过任何自由变换、处理的数据。我实现 :auth 鉴权和日志时,只是简单的增加了一个 Middleware,从请求中获取当前路由数据,并根据其 :auth 键进行缓存和日志处理,非常简单。后期如果需要加上针对不同用户的权限拦截,同样的方式也很容易。想象一下 Spring MVC 如何处理这件事吧,你需要为控制器方法写一些注解,然后扫描这些注解并做拦截,注解在 Java 中的含义,本质就是方法元数据,也是一种“面向数据编程”的方式。

而考虑上述 (with- :opt [:book string?]) 这个请求入参校验的函数,这仅仅就是一个用来简化编写 data-spec 校验规则的函数,而之所以只能够工作,还要拜 data-spec 本质也是面向数据的校验库,其接收符合格式的数据,因此我们可以自行处理,只要返回目标格式数据即可:

(defn with-
  "基于 data-spec 将格式
  :opt [:k1 string? :k2 boolean?]
  :req [:k3 map? :k4 int?]
  转换为
  {(ds/opt :k1) string?
   (ds/opt :k2) boolean?
   :k3 map?
   :k4 int?}"
  [& args]
  (let [{:keys [opt req] :or {opt [] req []}} (apply hash-map args)]
    (merge (into {} (map (fn [[k v]] [(ds/opt k) v]) (apply hash-map (vec opt))))
           (apply hash-map req))))

总的来说,宏的表现能力更强,但扩展性稍差,适合规范领域,而面向数据的 DSL 表现能力稍差,但扩展性更好,适合多变需求。在各个领域的 Clojure 库中这种区别和优缺点利用都可以看到,另一个典型例子就是 ClojureScript 世界的 React.js Wrapper:reagent 和 rum:

前端:React.js 的例子

reagent 是面相数据编程的范例,其使用 Vector 表示 Component,第一个参数若是 Keyword 则表示 HTML Tag,若是函数就表示是另一个 Component,第二个参数如果是 Map 则表示是 HTML Attribute,比如输入框的类型、值和回调函数,文本的类和内联属性等,其余的参数都被看做是 HTML 子元素。相比较 JSX,这种描述方式并未引入任何需要编译的宏,其本身就是纯粹的 ClojureScript 数据结构。

(defn echo-component []
  (let [text-atom (r/atom "")]
    [:div
     [:h1 "Echo"]
     [:input {:type "text"
              :value @text-atom
              :on-change #(reset! text-atom (.. % -target -value))}]
     [:p "Echo: " @text-atom]]))

(r/render [echo-component] (.getElementById js/document "app"))

而 rum 则采用了宏,defc 用来创建 React.js Component,在内部其使用了 reagent 的数据结构来描述界面。

(rum/defcs echo-component < (rum/local "" ::key)
  [state]
  (let [local-atom (::key state)]
    [:div
      [:h1 "Echo"]
      [:input {:type "text"
               :value @local-atom
               :on-change #(swap! local-atom (.. % -target -value))}]
      [:p "Echo: " @local-atom]]))

你可能想问,reagent 和 rum 都使用 hiccup 数据结构描述界面,为何 rum 要使用宏呢?这里的权衡在于,reagent 的 hiccup 格式数据在状态更新需要创建 React Element 实例时,需要在运行时先将 hiccup 格式数据转换为 React Component,之后再实例化,这个运行时开销是设计注定的,在面对大量 UI 组件更新时,存在效率问题 —— 很多 JavaScript 框架甚至还嫌弃 React 在状态变更时从头到尾计算一次 Element 树的开销,更不用说 reagent 作为 Wrapper,还有 hiccup 转换为 React Component 的开销 —— 尽管这一过程实际上没那么严重,由于 ClojureScript 数据结构是不可变的,因此比较树变更非常便宜,如果状态管理使用 ClojureScript 实现,那么大部分时候都不需要重新 hiccup -> React Component,如果确实发现变更,只有部分变更的 hiccup Tree 存在翻译为 Component Tree 的额外开销。因此真正的问题在于,reagent 没有办法更完整的接入 React.js 生态(虽然可以和第三方组件几乎无缝的互调),尤其是函数式组件和 Hook —— 虽然 ClojureScript reagent atom 和 re-frame 的实现更早且更先进。

而 rum 的 defc 会在编译期就将 hiccup 数据描述翻译为 React Component,这避免了部分需要变更时的运行时 hiccup 数据结构翻译问题,因此其频繁状态变更时的性能更好,且由于不存在 hiccup 数据转换,其运行时就是 React Component,所以可以和 React 中状态管理方式,比如 ref, context, state 以及 redux 各种 hooks 无缝兼容。

(rum/defc component [x]
  (let [on-change (rum/use-callback #(js/console.log % x) [x])]
    [input-field {:on-change on-change}]))

(rum/defc component []
  (let [ref (rum/use-ref nil)]
    (rum/use-effect!
      #(.log js/console (rum/deref ref)))
    [:input {:ref ref}]))

总的来说,ClojureScript 的例子表明,在客户端中,当存在运行时和编译期速度权衡的时候,宏会更有吸引力。而在服务端,用户在大部分时候对这一差异并不敏感。

那么,究竟用不用宏呢?有看法认为,掌握了 Clojure 宏之后,应该直接将其忘记,再不使用。但我并不这么看,况且,假如你同意这句话,在完全掌握并将其应用到生产前,又有什么证据同意这句话呢?不管怎样,掌握和使用 Clojure 宏都是一个必选项,哪怕实际应该保持对“神的语言”和“更懒的自己”保持克制。

我的工程实践

因此在大部分时候,我选择尽量使用函数抽象冗余代码,或者使用数据结构来构建迷你语言,而不使用宏,但在一些特殊情况,比如实在是用的太多太频繁,或者需要运行时性能,并且业务模型稳定且不会变更的时候,宏还是可以拿出来秀一秀的。

比如这个判断是否是国家法定假日的函数就使用了数据结构来构建迷你语言,其缺点在于数据结构解析放在运行时执行,但具有宏所不具备的扩展性:

(defn do-need-work
  "根据国家规定返回当前是否要工作的信息
  Reference: http://www.gov.cn/zhengce/content/2020-11/25/content_5564127.htm
  Reference: http://www.gov.cn/zhengce/content/2021-10/25/content_5644835.htm
  Reference: http://www.gov.cn/zhengce/content/2022-12/08/content_5730844.htm
  Refreence: https://www.gov.cn/zhengce/zhengceku/202310/content_6911528.htm"
  [^LocalDateTime time]
  (let [time (or time (LocalDateTime/now))
        year (.getYear time)
        at-year (fn [year] (fn [month day]
                             (.atStartOfDay (LocalDate/of ^long year ^int month ^int day))))
        in (fn [at-date]
             (fn [time [hint & d]]
               (cond (= hint :each)
                     (some
                       #(.isEqual (.toLocalDate time)
                                  (.toLocalDate (at-date (first %) (second %))))
                       (partition 2 (vec d)))
                     (= hint :range)
                     (and (not (.isBefore time (at-date (first d) (second d))))
                          (.isBefore time (.plusDays (at-date (nth d 2) (last d)) 1)))
                     (= hint :weekend)
                     (let [week (.getDayOfWeek time)]
                       (or (= DayOfWeek/SATURDAY week)
                           (= DayOfWeek/SUNDAY week)))
                     :else (throw (RuntimeException. "错误的匹配")))))]
    (case year
      2024
      (let [when-match (in (at-year 2024))]
        (cond
          (when-match time [:each 1 1]) false
          (when-match time [:range 2 10, 2 17]) false
          (when-match time [:each 2 4, 2 18]) true
          (when-match time [:range 4 4, 4 6]) false
          (when-match time [:each 4 7]) true
          (when-match time [:range 5 1, 5 5]) false
          (when-match time [:each 4 28, 5 11]) true
          (when-match time [:each 6 10]) true
          (when-match time [:range 9 15, 9 17]) false
          (when-match time [:each 9 14]) true
          (when-match time [:range 10 1, 10 7]) true
          (when-match time [:each 9 29, 10 12]) true
          (when-match time [:weekend]) false
          :else true))
      2023
      ...
      2022
      ...
      2021
      ...
      false)))

而下面的 with-esxi 则通过宏来避免 SSH 鉴权样板代码,相似的还有 Reids 函数 (car/wcar! redis-conn REDIS-CMD)(with-transition [t db] SQL-QUERY) 这类带有接受主体的函数,资源的使用和释放,错误的抑制等业务固定的样板代码,都可使用宏来简化。

(defmacro with-esxi [& body]
  `(try
     (let [host# (c/edn-in [:esxi :host])
           pass# (c/edn-in [:esxi :pass])]
       (if-not (and host# pass#)
         {:message "ESXI Config Not Found"
          :status  -1}
         (let [agent# (ssh/ssh-agent {})
               ~'sess  (ssh/session agent# host#
                                    {:strict-host-key-checking :no
                                     :username                 "root"
                                     :password                 pass#})
               ~'cmd!  (fn [cmd1#] (:out (ssh/ssh ~'sess {:cmd cmd1#})))]
           (ssh/with-connection ~'sess (do ~@body)))))
     (catch Exception e#
       (log/error e#)
       {:message (.getMessage e#) :status -1})))

(defn with-esxi-fn [two-arity-fn]
  (try
    ...
    (let [agent (ssh/ssh-agent {})
          sess  (ssh/session agent host
                                {:strict-host-key-checking :no
                                :username                 "root"
                                :password                 pass})
          cmd!  (fn [cmd1#] (:out (ssh/ssh ~'sess {:cmd cmd1#})))]
          (ssh/with-connection ~'sess (two-arity-fn sess cmd!)))
    (catch Exception e
      (log/error e)
      {:message (.getMessage e) :status -1})))

;;example for use macro
(with-esxi
  (if (contains? #{"on" "off" "suspended"} power)
    (let [power (if (= power "suspended") "suspend" power)
          data (cmd! (format "vim-cmd vmsvc/power.%s %s" power vm))
          res (->> (cmd! (format "vim-cmd vmsvc/power.getstat %s" vm))
                    (state->power)
                    (format "VM %s is %s" vm))]
      {:message res :status 1 :data data})
    {:message "Param Power Not Valid" :status  -1}))

(with-esxi
    {:message "请求已执行" :status 1
     :data (cmd! (if reboot? "reboot" "poweroff"))})

;;example for use fn
(with-esxi-fn
  (fn [sess cmd!]
    ...))

(with-esxi-fn
  (fn [sess cmd!]
    {:message "请求已执行" :status 1
     :data (cmd! (if reboot? "reboot" "poweroff"))}))

在上面这个例子中,with-esxi 宏从配置读取 ESXi 用户名和密码,建立 SSH 通道执行命令,如果不使用宏,那么 with-esxi-fn 需要传入一个函数,如果此宏依赖多个变量,比如 Session 和执行命令的 cmd!,那么每写一个方法都要定义这样的一个函数,重复写一遍入参,无疑是一种折磨,而宏则基于约定将我们从无聊代码中拯救了出来。