Clojure

Clojure 中的解构

什么是解构?

解构是一种简洁地将名称绑定到数据结构中值的方。解构使我们能够编写更简洁、更易读的代码。

考虑以下从向量中提取和命名值的示例。

(def my-line [[5 10] [10 20]])

(let [p1 (first my-line)
      p2 (second my-line)
      x1 (first p1)
      y1 (second p1)
      x2 (first p2)
      y2 (second p2)]
  (println "Line from (" x1 "," y1 ") to (" x2 ", " y2 ")"))
;= "Line from ( 5 , 10 ) to ( 10 , 20 )"

这完全有效,但提取和命名向量中值的代码掩盖了我们的意图。解构使我们能够更简洁地提取和命名复杂数据结构的重要部分,从而使我们的代码更清晰。

;= Using the same vector as above
(let [[p1 p2] my-line
      [x1 y1] p1
      [x2 y2] p2]
 (println "Line from (" x1 "," y1 ") to (" x2 ", " y2 ")"))
;= "Line from ( 5 , 10 ) to ( 10 , 20 )"

与其显式绑定每个变量,我们根据它们在序列中的顺序来描述绑定。这是一个很奇怪的表述,“描述绑定”,所以让我们再看一遍。

我们有一个数据结构 my-line,它看起来像这样,[[5 10] [10 20]]。在我们的解构形式中,我们将创建一个包含两个元素的向量,p1p2,它们本身都是向量。这将把向量 [5 10] 绑定到符号 p1,并将向量 [10 20] 绑定到符号 p2。由于我们想使用 p1p2 的元素而不是结构本身,所以我们在同一个 let 语句中对 p1p2 进行解构。向量 p1 看起来像这样,[5 10],所以要对其进行解构,我们将创建一个包含两个元素的向量,x1y1。这将 5 绑定到符号 x1,并将 10 绑定到符号 y1。对于 p2,将 10 绑定到 x2,将 20 绑定到 y2,也是如此。此时,我们拥有了处理数据的全部信息。

顺序解构

Clojure 解构分为两类:顺序解构和关联解构。顺序解构将一个顺序数据结构表示为 let 绑定中的 Clojure 向量。

这种类型的解构可用于任何可以以线性时间遍历的数据结构,包括列表、向量、序列、字符串、数组以及支持 nth 的任何数据结构。

(def my-vector [1 2 3])
(def my-list '(1 2 3))
(def my-string "abc")

;= It should come as no surprise that this will print out 1 2 3
(let [[x y z] my-vector]
  (println x y z))
;= 1 2 3

;= We can also use a similar technique to destructure a list
(let [[x y z] my-list]
  (println x y z))
;= 1 2 3

;= For strings, the elements are destructured by character.
(let [[x y z] my-string]
  (println x y z)
  (map type [x y z]))
;= a b c
;= (java.lang.Character java.lang.Character java.lang.Character)

顺序解构的关键是,你将值逐个绑定到向量中的符号。例如,向量 [x y z] 将逐个与列表 '(1 2 3) 中的每个元素匹配。

在某些情况下,你正在解构的集合的大小与解构绑定的大小并不完全相同。如果向量太小,额外的符号将被绑定到 nil。

(def small-list '(1 2 3))
(let [[a b c d e f g] small-list]
  (println a b c d e f g))
;= 1 2 3 nil nil nil nil

另一方面,如果集合太大,额外的值将被简单地忽略。

(def large-list '(1 2 3 4 5 6 7 8 9 10))
(let [[a b c] large-list]
  (println a b c))
;= 1 2 3

解构使你能够完全控制你选择绑定(或不绑定)的元素以及如何绑定它们。

很多时候,你并不需要访问集合中的所有元素,只需要访问其中的一些元素。

(def names ["Michael" "Amber" "Aaron" "Nick" "Earl" "Joe"])

假设你想在一行中打印第一个元素,在另一行中打印剩余的元素。

(let [[item1 item2 item3 item4 item5 item6] names]
  (println item1)
  (println item2 item3 item4 item5 item6))
;= Michael
;= Amber Aaron Nick Earl Joe

此绑定有效,但即使使用解构,它也相当笨拙。相反,我们可以使用 & 将尾部元素组合成一个序列。

(let [[item1 & remaining] names]
  (println item1)
  (apply println remaining))
;= Michael
;= Amber Aaron Nick Earl Joe

你可以通过将不打算使用的绑定绑定到任何你选择的符号来忽略它们。

(let [[item1 _ item3 _ item5 _] names]
  (println "Odd names:" item1 item3 item5))
;= Odd names: Michael Aaron Earl

对此的约定是使用上面所示的下划线。

你可以使用 :as all 将整个向量绑定到符号 all

(let [[item1 :as all] names]
  (println "The first name from" all "is" item1))
;= The first name from [Michael Amber Aaron Nick Earl Joe] is Michael

让我们停下来,进一步了解 :as& 的类型。

(def numbers [1 2 3 4 5])
(let [[x & remaining :as all] numbers]
  (apply prn [remaining all]))
;= (2 3 4 5) [1 2 3 4 5]

这里 remaining 被绑定到一个包含 numbers 向量中剩余元素的序列,而 all 被绑定到原始 vector。当我们解构字符串时会发生什么?

(def word "Clojure")
(let [[x & remaining :as all] word]
  (apply prn [x remaining all]))
;= \C (\l \o \j \u \r \e) "Clojure"

这里 all 被绑定到原始结构(字符串、向量、列表,无论是什么),x 被绑定到字符 \Cremaining 是字符的剩余列表。

你可以根据自己的意愿同时组合所有这些技术。

(def fruits ["apple" "orange" "strawberry" "peach" "pear" "lemon"])
(let [[item1 _ item3 & remaining :as all-fruits] fruits]
  (println "The first and third fruits are" item1 "and" item3)
  (println "These were taken from" all-fruits)
  (println "The fruits after them are" remaining))
;= The first and third fruits are apple and strawberry
;= These were taken from [apple orange strawberry peach pear lemon]
;= The fruits after them are (peach pear lemon)

解构也可以嵌套,以访问任意级别的顺序结构。让我们回到我们一开始的向量 my-line

(def my-line [[5 10] [10 20]])

此向量由嵌套的向量组成,我们可以直接访问它们。

(let [[[x1 y1][x2 y2]] my-line]
  (println "Line from (" x1 "," y1 ") to (" x2 ", " y2 ")"))
;= "Line from ( 5 , 10 ) to ( 10 , 20 )"

当你有嵌套的向量时,你也可以在任何级别使用 :as&

(let [[[a b :as group1] [c d :as group2]] my-line]
  (println a b group1)
  (println c d group2))
;= 5 10 [5 10]
;= 10 20 [10 20]

关联解构

关联解构类似于顺序解构,但应用于关联(键值)结构(包括映射、记录、向量等)。关联绑定与通过键简洁地提取映射的值有关。

让我们首先考虑一个在没有解构的情况下从映射中提取值的示例

(def client {:name "Super Co."
             :location "Philadelphia"
             :description "The worldwide leader in plastic tableware."})

(let [name (:name client)
      location (:location client)
      description (:description client)]
  (println name location "-" description))
;= Super Co. Philadelphia - The worldwide leader in plastic tableware.

请注意,let 绑定的每一行本质上都是相同的——它从映射中提取一个键值,然后将其绑定到具有相同名称的局部变量。

下面是使用关联解构执行相同操作的第一个示例

(let [{name :name
       location :location
       description :description} client]
  (println name location "-" description))
;= Super Co. Philadelphia - The worldwide leader in plastic tableware.

解构形式现在是映射而不是向量,并且 let 左边不再是符号,而是映射。映射的键是我们想要在 let 中绑定的符号。解构映射的值是我们将在关联值中查找的键。这里它们是关键字(最常见的情况),但它们可以是任何键值——数字、字符串、符号等。

与顺序解构类似,如果你尝试绑定映射中不存在的键,绑定值将为 nil。

(let [{category :category} client]
  (println category))
;= nil

但是,关联解构还允许你使用 :or 键在键不存在于关联值中时提供默认值。

(let [{category :category, :or {category "Category not found"}} client]
  (println category))
;= Category not found

:or 的值为一个映射,其中绑定的符号(这里为 category)被绑定到表达式 "Category not found"。当 categoryclient 中找不到时,它将在 :or 映射中找到并绑定到该值。

在顺序解构中,你通常使用 _ 绑定不需要的值。由于关联解构不需要遍历整个结构,你只需从解构形式中省略任何你不想使用的键即可。

如果你需要访问整个映射,你可以使用 :as 键绑定整个传入的值,就像顺序解构一样。

(let [{name :name :as all} client]
  (println "The name from" all "is" name))
;= The name from {:name Super Co., :location Philadelphia, :description The world wide leader in plastic table-ware.} is Super Co.

:as:or 关键字可以组合在一个解构中。

(def my-map {:a "A" :b "B" :c 3 :d 4})
(let [{a :a, x :x, :or {x "Not found!"}, :as all} my-map]
  (println "I got" a "from" all)
  (println "Where is x?" x))
;= I got A from {:a "A" :b "B" :c 3 :d 4}
;= Where is x? Not found!

你可能已经注意到,我们最初的示例在关联解构形式中仍然包含冗余信息(局部绑定名称和键名)。:keys 键可用于进一步消除重复

(let [{:keys [name location description]} client]
  (println name location "-" description))
;= Super Co. Philadelphia - The worldwide leader in plastic tableware.

此示例与前一个版本完全相同——它将 name 绑定到 (:name client),将 location 绑定到 (:location client),并将 description 绑定到 (:description client)

:keys 键用于具有关键字键的关联值,但也有 :strs:syms 分别用于字符串和符号键。在所有这些情况下,向量包含符号,这些符号是局部绑定名称。

(def string-keys {"first-name" "Joe" "last-name" "Smith"})

(let [{:strs [first-name last-name]} string-keys]
  (println first-name last-name))
;= Joe Smith

(def symbol-keys {'first-name "Jane" 'last-name "Doe"})

(let [{:syms [first-name last-name]} symbol-keys]
  (println first-name last-name))
;= Jane Doe

关联解构可以嵌套并根据需要与顺序解构组合。

(def multiplayer-game-state
  {:joe {:class "Ranger"
         :weapon "Longbow"
         :score 100}
   :jane {:class "Knight"
          :weapon "Greatsword"
          :score 140}
   :ryan {:class "Wizard"
          :weapon "Mystic Staff"
          :score 150}})

(let [{{:keys [class weapon]} :joe} multiplayer-game-state]
  (println "Joe is a" class "wielding a" weapon))
;= Joe is a Ranger wielding a Longbow

关键字参数

一种特殊情况是使用关联解构进行关键字参数解析。考虑一个接受选项 :debug:verbose 的函数。这些选项可以在选项映射中指定

(defn configure [val options]
  (let [{:keys [debug verbose] :or {debug false, verbose false}} options]
    (println "val =" val " debug =" debug " verbose =" verbose)))

(configure 12 {:debug true})
;;val = 12  debug = true  verbose = false

但是,如果我们可以像这样将这些可选参数作为额外的“关键字”参数传递,则键入会更方便

(configure 12 :debug true)

为了支持这种调用风格,关联解构也适用于键值对列表或序列,用于关键字参数解析。该序列来自变参数函数的 rest 参数,但不是用顺序解构来解构,而是用关联解构来解构(因此,该序列被解构成映射中的键值对)。

(defn configure [val & {:keys [debug verbose]
                        :or {debug false, verbose false}}]
  (println "val =" val " debug =" debug " verbose =" verbose))

(configure 10)
;;val = 10  debug = false  verbose = false

(configure 5 :debug true)
;;val = 5  debug = true  verbose = false

;; Note that any order is ok for the kwargs
 (configure 12 :verbose true :debug true)
;;val = 12  debug = true  verbose = true

关键字参数的使用在 Clojure 社区中几年来一直在流行和不再流行。它们现在主要用于当人们期望在 REPL 或 API 的最外层键入时呈现接口。通常,代码的内部层发现将选项作为显式映射传递更容易。但是,在 Clojure 1.11 中添加了功能,允许将交替的 key→values 或相同映射的映射,甚至在它之前具有 key→values 的映射传递给期望关键字参数的函数。因此,上面对 configure 的调用除了上面显示的调用方式之外,还可以采用以下任何形式

 (configure 12 {:verbose true :debug true})
;;val = 12  debug = true  verbose = true

 (configure 12 :debug true {:verbose true})
;;val = 12  debug = true  verbose = true

传递给期望关键字参数的函数的尾部映射通常在覆盖作为 key→value 对提供的默认键时很有用。

命名空间关键字

如果映射中的键是命名空间关键字,你也可以使用解构,即使局部绑定符号不允许使用命名空间。解构一个命名空间键会将一个值绑定到键的局部名称部分,并删除命名空间。(因此,你可以像使用非命名空间键一样使用 :or。)

(def human {:person/name "Franklin"
            :person/age 25
            :hobby/hobbies "running"})
(let [{:keys [hobby/hobbies]
       :person/keys [name age]
       :or {age 0}} human]
  (println name "is" age "and likes" hobbies))
;= Franklin is 25 and likes running

仅使用:keys解构命名空间关键字可能会导致局部绑定冲突。因为所有映射解构选项都可以组合使用,所以任何局部绑定形式都可以单独定义。

(def human {:person/name "Franklin"
            :person/age 25
            :hobby/name "running"})
(let [{:person/keys [age]
       hobby-name :hobby/name
       person-name :person/name} human]
  (println person-name "is" age "and likes" hobby-name))
;= Franklin is 25 and likes running

你甚至可以使用自动解析的关键字进行解构,它们将被绑定到键的名称部分。

;; this assumes you have a person.clj namespace in your project
;; if not do the following at your repl instead: (create-ns 'person) (alias 'p 'person)
(require '[person :as p])

(let [person {::p/name "Franklin", ::p/age 25}
      {:keys [::p/name ::p/age]} person]
  (println name "is" age))

;= Franklin is 25

使用自动解析的关键字创建和解构映射允许我们使用命名空间别名(这里为p)编写代码,该别名由当前命名空间中的require定义,这为我们提供了一种可以在代码的单一位置更改的命名空间间接访问方式。

解构上下文中绑定的所有符号都可以进一步解构——这允许以嵌套方式对顺序和关联解构进行解构。这对于&之后定义的符号也是有效的,尽管不太明显。

此示例对& seq进行就地解构,以将剩余的参数解码为选项(注意,因此我们对两个参数进行顺序解构,对其余参数进行关联解构)。

(defn f-with-options
  [a b & {:keys [opt1]}]
  (println "Got" a b opt1))

(f-with-options 1 2 :opt1 true)
;= Got 1 2 true

在哪里解构

你可以在存在显式或隐式let绑定的任何地方使用解构。

解构最常见的用法之一是从传递给函数的参数中提取数据。

这里我们有标准的 let x 等于这个,let y 等于那个,等等。同样,这段代码完全有效,只是冗长。

(defn print-coordinates-1 [point]
  (let [x (first point)
        y (second point)
        z (last point)]
    (println "x:" x ", y:" y ", z:" z)))

无论何时看到使用firstsecondnthget来分解数据结构的代码,都可以使用解构来简化代码。我们可以从重写let开始。

(defn print-coordinates-2 [point]
  (let [[x y z] point]
    (println "x:" x ", y:" y ", z:" z)))

在 Clojure 中定义函数时,可以对传入参数应用解构,就像在 let 中一样。

(defn print-coordinates-3 [[x y z]]
  (println "x:" x ", y:" y ", z:" z))

我们用一个简洁的语句替换了几行用来分解传入点数据的代码,这个语句描述了该数据的结构,并将数据绑定到局部变量。

为了更真实的示例,让我们创建一个包含臭名昭著的 John Smith 的一些基本联系信息的映射。

(def john-smith {:f-name "John"
                 :l-name "Smith"
                 :phone "555-555-5555"
                 :company "Functional Industries"
                 :title "Sith Lord of Git"})

现在我们有了 John 的个人信息,我们需要访问此映射中的值。

(defn print-contact-info [{:keys [f-name l-name phone company title]}]
  (println f-name l-name "is the" title "at" company)
  (println "You can reach him at" phone))

(print-contact-info john-smith)
;= John Smith is the Sith Lord of Git at Functional Industries
;= You can reach him at 555-555-5555

此函数将使用:keys快捷方式对输入进行关联解构,然后打印出我们提供的联系信息。

但是,当我们想给 John 写一封信的时候怎么办呢?

(def john-smith {:f-name "John"
                 :l-name "Smith"
                 :phone "555-555-5555"
                 :address {:street "452 Lisp Ln."
                           :city "Macroville"
                           :state "Kentucky"
                           :zip "81321"}
                 :hobbies ["running" "hiking" "basketball"]
                 :company "Functional Industries"
                 :title "Sith Lord of Git"})

我们现在有了一个地址,但是我们需要将一个映射嵌套到我们的原始结构中才能实现这一点。

(defn print-contact-info
  [{:keys [f-name l-name phone company title]
    {:keys [street city state zip]} :address
    [fav-hobby second-hobby] :hobbies}]
  (println f-name l-name "is the" title "at" company)
  (println "You can reach him at" phone)
  (println "He lives at" street city state zip)
  (println "Maybe you can write to him about" fav-hobby "or" second-hobby))

(print-contact-info john-smith)
;= John Smith is the Sith Lord of Git at Functional Industries
;= You can reach him at 555-555-5555
;= He lives at 452 Lisp Ln. Macroville Kentucky 81321
;= Maybe you can write to him about running or hiking

宏编写者可能需要编写一个包含解构的宏。最常见的方法是产生对已执行解构的函数(如letloopfn等)的调用。clojure.core 中的if-letwhen-letwhen-some等就是这种示例。

但是,在极少数情况下,你可能希望在宏中自己解析解构。在这种情况下,使用(未记录的)clojure.core/destructure 函数,它实现了解构逻辑,也是letloop实际调用的函数。destructure 函数旨在在宏中调用,并期望接收一个表单并返回一个表单。

(destructure '[[x & remaining :as all] numbers])
;= [vec__1 numbers
;=  x (clojure.core/nth vec__1 0 nil)
;=  remaining (clojure.core/nthnext vec__1 1)
;=  all vec__1]

结果在这里被格式化以提高清晰度。此示例还应该让你了解解构在幕后的工作原理。

原始作者:Michael Zavarella