Clojure

线程宏指南

线程宏,也称为箭头宏,将嵌套函数调用转换为线性函数调用流,从而提高可读性。

线程优先宏 (->)

在惯用的 Clojure 中,纯函数将不可变数据结构转换为所需的输出格式。考虑一个对映射应用两种转换的函数

(defn transform [person]
   (update (assoc person :hair-color :gray) :age inc))

(transform {:name "Socrates", :age 39})
;; => {:name "Socrates", :age 40, :hair-color :gray}

transform 是一个常见模式的示例:它接受一个值并应用多个转换,管道中的每个步骤都将前一个步骤的结果作为其输入。通常可以通过将此类代码重写为使用线程优先宏 -> 来改进代码

(defn transform* [person]
   (-> person
      (assoc :hair-color :gray)
      (update :age inc)))

将初始值作为其第一个参数,-> 将其线程到一个或多个表达式中。

注意:在这种情况下,“线程”一词(指通过函数管道传递值)与并发执行线程的概念无关。

从第二种形式开始,宏将第一个值插入为其第一个参数。在后续的每个步骤中,都会重复此操作,并将前一个计算的结果插入为下一个形式的第一个参数。看似一个带有两个参数的函数调用实际上是一个带有三个参数的调用,因为线程值会插入在函数名称之后。为了说明,可以在插入点用三个逗号标记

(defn transform* [person]
   (-> person
      (assoc ,,, :hair-color :gray)
      (update ,,, :age inc)))

虽然在实践中很少见到,但这种视觉辅助是有效的 Clojure 语法,因为在 Clojure 中逗号是空格。

从语义上讲,transform* 等效于 transform:箭头宏在编译时扩展为原始代码。在每种情况下,函数的返回值都是最后一个计算(对 update 的调用)的结果。重写的函数读取起来就像是对转换的描述:“取一个人,给他灰头发,增加他的年龄,并返回结果”。当然,在不可变值的上下文中,实际上不会发生任何变异。相反,函数只是返回一个具有更新属性的新值。

在语法上,线程宏还允许读者以从左到右的应用顺序读取函数,而不是从最内层的表达式读取。

线程最后 (->>) 和线程为 (as->) 宏

-> 宏遵循一个纯粹的语法转换规则:对于每个表达式,在函数名称和第一个参数之间插入线程值。请注意,线程表达式是 (f arg1 arg2 …​) 形式的函数调用。没有括号的裸符号或关键字被解释为具有单个参数的简单函数调用。这允许简洁的单参数函数链

(-> person :hair-color name clojure.string/upper-case)

;; equivalent to

(-> person (:hair-color) (name) (clojure.string/upper-case))

但是,-> 并非普遍适用,因为我们并不总是希望将线程参数插入初始位置。考虑一个计算十以下所有奇数正整数的平方和的函数

(defn calculate []
   (reduce + (map #(* % %) (filter odd? (range 10)))))

transform 一样,calculate 是一个转换管道,但与前者不同的是,线程值在每个函数调用中的参数列表中的最后位置出现。我们不需要使用线程优先宏,而是需要使用线程最后宏 ->>

(defn calculate* []
   (->> (range 10)
        (filter odd? ,,,)
        (map #(* % %) ,,,)
        (reduce + ,,,)))

同样,虽然通常省略,但三个逗号标记了将插入参数的位置。正如您所见,在使用 ->> 线程的形式中,线程值是插入到参数列表的末尾而不是开头。

线程优先和线程最后在不同的情况下使用。哪个适合取决于转换函数的签名。最终您需要查阅所用函数的文档,但有一些经验法则

  • 按照惯例,操作序列的核心函数期望序列作为其最后一个参数。因此,包含 mapfilterremovereduceinto 等的管道通常需要 ->> 宏。

  • 另一方面,操作数据结构的核心函数期望它们操作的值作为其第一个参数。这些包括 assocupdatedissocget 以及它们的 -in 变体。使用这些函数转换映射的管道通常需要 -> 宏。

  • 通过 Java 交互操作 调用方法时,Java 对象将作为第一个参数传递。在这种情况下,-> 很有用,例如,检查字符串是否存在前缀

    (-> a-string clojure.string/lower-case (.startsWith "prefix"))

    还要注意更专业的交互操作宏 ..doto.

最后,有些情况下 ->->> 都不可用。管道可能包含具有不同插入点的函数调用。在这些情况下,您需要使用 as->,它是更灵活的选择。as-> 预期有两个固定参数和可变数量的表达式。与 -> 一样,第一个参数是将要通过以下形式线程的值。第二个参数是绑定的名称。在每个后续形式中,绑定名称都可以用于先前表达式的结果。这允许一个值线程到任何参数位置,而不仅仅是第一个或最后一个位置。

(as-> [:foo :bar] v
  (map name v)
  (first v)
  (.substring v 1))

;; => "oo"

some->、some->> 和 cond->

Clojure 的两个更专业的线程宏 some->some->> 通常在与 Java 方法交互时使用。some->-> 类似,因为它将一个值线程到多个表达式中。但是,它还会在链中任何地方的表达式计算结果为 nil 时短路执行。在 Java 交互操作 的上下文中,箭头宏的一个常见问题是 Java 方法不希望被传递 nil (null)。在这些情况下,避免 NullPointerException 的一种方法是添加一个显式保护

(when-let [counter (:counter a-map)]
  (inc (Long/parseLong counter)))

some-> 更简洁地实现了相同的效果

(some-> a-map :counter Long/parseLong inc)

如果 a-map 缺少键 :counter,则整个表达式将计算结果为 nil,而不是引发异常。事实上,这种行为非常有用,以至于在不需要线程的情况下使用 some-> 也很常见

(some-> (compute) Long/parseLong)

;; equivalent to

(when-let [a-str (compute)]
  (Long/parseLong a-str))

-> 一样,宏 cond-> 接受一个初始值,但与前者不同的是,它将它的参数列表解释为一系列 test, expr 对。cond-> 将一个值线程到表达式中,但会跳过测试失败的那些表达式。对于每对,test 都会被计算。如果结果为真值,则表达式将使用线程值作为其第一个参数进行计算;否则,计算将继续进行下一对 test, expr。请注意,与它的亲戚 some->cond 不同,cond-> 永远不会短路计算,即使测试计算结果为 falsenil

(defn describe-number [n]
  (cond-> []
    (odd? n) (conj "odd")
    (even? n) (conj "even")
    (zero? n) (conj "zero")
    (pos? n) (conj "positive")))

(describe-number 3) ;; => ["odd" "positive"]
(describe-number 4) ;; => ["even" "positive"]

cond->> 将线程值插入到每个形式的最后一个参数,但其他方面的工作方式类似。

原文作者:Paulus Esterhazy