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 繫結至字元 \C,而 remaining 是字元剩餘清單。

您可以自行決定同時結合這些技術的任何一種或全部。

(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"。當在 client 中找不到類別時,它會在 :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 中,新增了允許傳遞交替的鍵→值、這些相同對應的映射,甚至在傳遞到預期關鍵字引數的函式之前,傳遞鍵→值的映射。因此,除了上面顯示的內容之外,對 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

傳遞到預期關鍵字引數的函式的尾隨映射通常用於覆寫以鍵→值對提供的預設鍵。

命名空間關鍵字

如果你的映射中的鍵是命名空間關鍵字,你也可以使用解構,即使不允許本地繫結符號有命名空間。解構命名空間鍵會將值繫結到鍵的本地名稱部分,並放棄命名空間。(因此你可以使用 :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 等於 this,let y 等於 that,等等…​同樣,這是一個完全有效的程式碼,只是很冗長。

(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