Skip to content

Latest commit

 

History

History
669 lines (496 loc) · 23.8 KB

03.md

File metadata and controls

669 lines (496 loc) · 23.8 KB

繫結與函式

函式是函數式程式設計的核心,雖然各個流派談及函數式程式設計,都有自己的定見和看法,但是不變的核心仍然是函式,函式必須是程式語言的第一級公民 (First-class citizen)。

身爲程式語言第一級公民的函式,必須可以當成參數傳遞給其它函式、可以被作爲函式的返回值、可以把函式繫結給某個名稱、以及可以在動態執行期產生函式。

函式的事暫且按下不表,先讓我們了解 Clojure 中如何將資料給定個名字。

繫結

def

在之前的章節中我們不斷地在 REPL 中輸入資料,如果想要引用資料必須重新輸入才行。如果有一種方法,可以將資料賦予名字,需要使用的時候只要利用名字就可以參考,免去一再重複輸入的不便。

當然有!

Clojure 提供了類似其它程式語言定義變數的功能,透過 def 創建一個符號 (Symbol) 連結到資料,之後只要使用這個符號,Clojure 就會尋找到對應的資料:

(def answer 42)
;; => #'user/answer
answer
;; => 42

透過使用 def 創建了符號之後,其實符號並不直接參考到實際資料,而是參考到 def 所創造的 Vars 物件,Vars 物件則存放了實際的資料。Vars 物件對應了其它程式語言變數的概念,但是不建議在多執行緒環境下使用。如果想要管理不同執行緒之間共同使用的狀態,可以參考後續狀態管理的章節。

Vars 物件被創建時,會賦予它預設的命名空間,之後的運算式可以透過符號取得對應的資料,它是全域的資料繫結 (Binding)。

answer
;; => 42
user/answer
;; => 42

想要單純使用 Vars 物件,而不是它儲存的資料,可以使用 var

(var answer)
;; => #'user/answer

Clojure 提供了簡易的寫法,在名稱前加上井號 (#) 以及單引號 ('):

#'answer
;; => #'user/answer

let

除了使用 def 建立全域的資料繫結 (Binding) 之外,Clojure 還提供 let 讓我們建立區域的資料繫結。使用 let 建立的繫結只在創建的區域內可見,出了區域之後就船過水無痕:

(def y 5)
;; => #'user/y
(let [x 1
      y (+ x 2)]
  y)
;; => 3
y
;; => 5

let 運算式接受一個向量,成對地擺放了名字與資料的對應,之後的運算式便可以使用剛剛設定好的繫結,一旦離開 let 運算式,原本在運算式中的繫結就消失不見了。

函式

談完了全域與區域的繫結,現在回來談談函數式程式設計的核心:函式。以下將介紹如何在 Clojure 建立函式,以及函式的應用。

建立函式

在 Clojure 中建立函式最簡單的方式是透過 fn 這個特殊形式 (Special form),建立的函式沒有名字,稱作匿名函式 (Anonymous function)。以下示範接受兩個參數,返回兩個參數相加的函式:

(fn [x y] (+ x y))
;; => #function[user/eval11037/fn--11038]

以上範例示範了接受 x 與 y 兩個參數的函式,將兩個參數相加之後返回。

fn 接受 let 風格的繫結方式,將參數寫在向量中指定名稱與順序,向量之後是函式的本體 (Body),函式的返回值爲函式本體最後一個運算式求得的值,不需明確指定返回值。

呼叫函式時,參數的擺放位置依據定義的順序依序擺放:

((fn [x y] (+ x y)) 3 5)
;; => 8

Clojure 提供了簡單的表示法可以快速地建立匿名函式 (Anonymous function),只要在括號前加上井號即可:

(#(str "Hello World"))
;; => "Hello World"

如果使用這種簡明表示法創建只接受一個參數的函式,可以在函式中使用百分比符號 (%) 表示參數:

(#(str "Hello " %) "Mike")
;; => "Hello Mike"

若是兩個參數以上,則在百分比符號之後分別加上 1, 2, 3 等數字,表明參數的個別順序:

(#(str "Hello " %1 ", " %2 ) "Mike" "Andy")
;; => "Hello Mike, Andy"

搭配 def 可以將一個匿名函式 (Anonymous function) 配上名字,之後只要以名稱便可以呼叫函式:

(def adder (fn [x y] (+ x y)))
;; => #'user/adder
(adder 3 5)
;; => 8

fn 搭配 def 雖然可以建立具有名稱的函式,但是太繁瑣了,Clojure 傾向提供簡潔方式解決問題。因此於 fndef 的基礎上,建立了 defn 用來建立函式:

(defn adder [x y] (+ x y))
;; => #'user/adder
(adder 2 6)
;; => 8

區域繫結

在函式本體之中,可以透過 let 建立只存在於函式之中的區域符號 (Symbol)。範例中區域符號 axbxy 的和、c 則是 y,返回值是三個區域符號的值加總:

(defn adder [x y]
  (let [a x
        b (+ x y)
        c y]
    (+ a b c)))
;; => #'user/adder
(adder 1 2)
;; => 6

說明文字

defn 內部以 def 建立了 Vars 物件,指向以 fn 產生的函式,除了函式的參數與本體之外,你還可以加上函式的說明文字 (Docstring),向使用者說明使用方法或設計理念:

(defn adder
  "Sum of two variables"
  [x y]
  (+ x y))
;; => #'user/adder
(doc adder)
;; => -------------------------
;; => user/adder
;; => ([x y])
;; =>   Sum of two variables

說明文字 (Docstring) 加在名稱之後、參數之前,可以寫上函式的說明、參數的意義或是使用函式需要注意的地方。函式中的說明文字,內部使用 def 將文字加入 Vars 物件的詮釋資料中 (Metadata):

(def a "Simple value" 5)
;; => #'user/a
(doc a)
;; => -------------------------
;; => user/a
;; =>   Simple value

多載

Java 中將類別裡擁有數個同樣名字的方法 (Method),參數個數卻不同稱爲多載 (Overloading),Clojure 也支援這種設計方法,同一個函式根據參數個數的不同,可以有不同的運算式,也可以呼叫同一個函式下不同參數個數的運算式:

(defn adder
  ([x] (adder x 10))
  ([x y] (+ x y)))
;; => #’user/adder
(adder 3)
;; => 13
(adder 3 2)
;; => 5

以上範例示範以一個參數和以兩個參數呼叫函式,由於根據參數而分開不同實作,所以結果並不相同。其中一個參數的版本還呼叫了自己的兩個參數的版本。

不定長度參數

有時候在定義函式的時候並不清楚參數的個數,或是想要接受不定個數的參數,Clojure 提供方法在定義函式時,聲明接受的是不定長度的參數。

在函式定義的參數向量中,在參數名稱前加上 & 符號與空白,則之後的參數都會包裝至序列之中:

(defn str-all-numbers [x & rest]
  (apply str "Hi " rest))
;; => #'user/str-all-numbers
(str-all-numbers 0 1 2 3 4)
;; => “Hi 1234”
(str-all-numbers 0)
;; => “Hi ”

在這個範例中,第一個呼叫 str-all-numbers 函式時代入了五個參數,x 爲第一個參數,其他參數則被裝進以 rest 命名的列表 (List) 中,由於只用到了後續的參數,所以第一個參數並不會被印出。

不定長度參數在 Clojure 中還具備了可選的屬性,亦即呼叫有不定長度參數的函式時,參數可以提供也可以不提供。第二次呼叫 str-all-numbers 函式時只給了第一個參數,後續的參數便不會被印出。

範例使用到的 apply 函式,第一個參數是函式,其後的參數將會被依序套用到第一個參數的函式中。

以下範例示範結合多載與不定長度參數的函式定義方法:

(defn overloading-variadic
  ([] 0)
  ([x] 1)
  ([x y] 2)
  ([x y & rest] "many arguments"))
;; => #'user/overloading-variadic
(overloading-variadic)
;; => 0
(overloading-variadic "one")
;; => 1
(overloading-variadic "one" "two")
;; => 2
(overloading-variadic "one" "two" "three")
;; => "many arguments"

解構

這裡有一個函式,接受一個列表當作參數,參數的作用是將列表中的第二與第四個位置相加:

(defn useless-adder [lst]
  (let [x (first (rest lst))
        y (first (rest (rest (rest lst))))]
    (+ x y)))
;; => #'user/useless-adder
(useless-adder [1 3 5 7 9])
;; => 10

看到層層 firstrest 是不是看到頭暈了呢?Clojure 提供了簡便的語法,可以更快速地取得參數的內容,稱爲解構 (Destructuring)。

向量解構

向量解構可以使用在任何序列型的群集,例如列表、向量、字串或序列。如同前一個例子,使用向量解構取得第二與第三個元素,會變得非常簡單:

(let [[_ x _ y] [1 3 5 7 9]]
  (+ x y))
;; => 10

這裡把不需要理會的元素以底線 (_) 來表示,需要取得的元素則賦予名字,根據擺放的位置匹配適當的元素。

巢狀向量中的元素也可以匹配:

(let [[_ _ [x y]] [1 2 [3 4] 5]]
  (* x y))
;; => 12

可以在解構式最後加上 :as 繫結整個待解構的向量:

(let [[x y :as original] [1 2 3 4 5]]
  (conj original (+ x y)))
;; => [1 2 3 4 5 3]

除了使用 :as 之外,還可以使用 & 來匹配其他未匹配的剩餘元素:

(let [[x & rest] [10 20 30 40 50]]
  rest)
;; => (20 30 40 50)

當然也可以將 :as& 兩個結合起來:

(let [[x & rest :as original] [2 4 6 8 10]]
  (println "x:" x ", rest:" rest ", original:" original))
;; => x: 2 , rest: (4 6 8 10) , original: [2 4 6 8 10]

以上的範例使用了 println 將資料輸出到螢幕,並加上換行。

有了解構之後,函式的參數就可以輕鬆地取得對應的內容:

(defn useful-adder [[_ x _ y]]
  (+ x y))
;; => #'user/useful-adder
(useful-adder [1 3 5 7 9])
;; => 10

跟一開始複雜的範例相比,是不是簡單很多呢。

映射解構

解構映射也跟解構向量一樣,根據擺放的位置與索引鍵,匹配對應的資料:

(def m {:a 5 :b 10 "c" 15})
;; => #'user/m
(let [{a :a b :b} m]
  (+ a b))
;; => 15

a 匹配索引鍵 :a 對應的資料、b 匹配索引鍵 :b 對應的資料。找不到對應的資料,會得到預設的 nil,如果不想使用預設的 nil,亦可以透過 :or 指定當某索引鍵找不到資料時,預設取得的資料:

(def m {:a 5 :b 10 "c" 15})
;; => #'user/m
(let [{a :a b :b d :d :or {d "OH"}} m]
  (println a b d))
;; => 5 10 OH

以上範例試圖取用索引鍵 :d 對應的資料,並在 :or 提供一個映射,指定了當找不到對應的資料時,應該選用的預設資料。

在向量解構時使用到的 :as 也可以使用在這裏,唯一不同的是不需要擺放在最後位置 (但建議還是放在最後):

(let [{a :a b :b :as whole} m]
  (println a b whole))
;; => 5 10 {:a 5, :b 10, c 15}

如果打算匹配的映射,其中的索引鍵都是由關鍵字組成的,Clojure 提供了 :keys 用來匹配映射中的關鍵字索引鍵。:keys 後加上一個向量,其中寫下打算匹配的關鍵字名稱,匹配後就可以透過名稱取得資料:

(def m {:a 10 :b 20 :c 15})
;; => #'user/m
(let [{:keys [a b]} m]
  (println a b))
;; => 10 20

如果映射中的索引鍵都是使用字串則使用 :strs、都是使用符號則用 :syms

(let [{:strs [a d]} {"a" "A", "b" "B", "c" "C", "d" "D"}]
  (println a d))
;; => A D
(let [{:syms [a d]} {'a "A", 'b "B", 'c "C", 'd "D"}]
  (println a d))
;; => A D

使用了映射解構的函式,就可以輕鬆地取出索引鍵代表的資料了:

(defn greet-user [{:keys [first-name last-name]}]
  (println "Welcome," first-name last-name))
;; => #'user/greet-user
(def catherine {:first-name "Catherine", :last-name "Chen", :age 40})
;; => #'user/catherine
(greet-user catherine)
;; => Welcome, Catherine Chen

高階函式

之前提到,函式在 Clojure 中是一等公民,像資料一樣,可以當成參數傳遞給其它函式,或可以被當成返回值傳遞。而可以做到其中之一功能的函式便稱作高階函式 (Higher-order Function)。

Clojure 之中有許多函式都可以接受函式當作參數,例如 map 接受一個函式以及群集當作參數,它會遍歷群集中的各個元素,把每個元素套用到當作參數的函式中,套用後的各個返回值再放到新的序列裡。這種功能稱作「映射」。

以下的例子示範了利用 map 函式,將向量中的各個字串,利用 clojure.string/lower-case 函式轉成小寫:

(map clojure.string/lower-case ["White" "Black" "Red"])
;; => ("white" "black" "red")

以上的範例相當於對每個元素呼叫 clojure.string/lower-case

[(clojure.string/lower-case "White") (clojure.string/lower-case "Black") (clojure.string/lower-case "Red")]

除了「映射」之外還有「化約」功能的 reduce 函式。reduce 如同 map 一樣接受函式與群集當作參數,它會遍歷群集中每個元素,套用到當作參數的函式。每次一個元素套用函式之後的結果,將會與下一個元素一起套用到當作參數的函式中。

以下範例示範如何使用 reduce 計算出群集中所有元素的和:

(reduce + [1 2 3 4 5])
;; => 15

以上的範例相當於先計算出 1 + 2 的結果,再將結果加上 3、加上 4、最後加上 5:

(+ (+ (+ (+ 1 2) 3) 4) 5)
;; => 15

利用 filter 函式則可以依據當作參數的函式其中的條件,來決定新的序列中究竟要放上什麼元素:

(filter #(> % 5) [2 3 5 10 15])
;; => (10 15)

filter 遍歷群集中的元素,將每個元素各別代入到 #(> % 5) 匿名函式中,匿名函式中判斷是否大於 5。只要函式返回值是真,filter 便將元素保留,否則剔除。因此新的序列裡只留下大於 5 的元素。

filter 接受的函式返回布林值,這種函式被稱爲「述詞函式」(Predicate),命名習慣上會在名稱後加上問號 (?),以表明它的返回值不是真便是假。even? 函式如果接受到偶數則返回真,反之則否,以下範例將奇數剔除,只留下偶數:

(filter even? [2 3 4 5 6])
;; => (2 4 6)

some 則是接受述詞函式與一個群集,遍歷群集中的元素並逐個丟給述詞函式,只要遇到元素讓述詞函式返回真,some 則返回真,反之則返回 nil

(some #(> % 5) [1 3 5 7 9])
;; => true
(some nil? [1 3 5 7 9])
;; => nil

以上範例分別示範了檢查群集中是否有大於 5 的元素,以及是否有 nil 元素在其中。

every? 函式接受一個述詞函式和群集,只有群集中的每個元素都讓述詞函式返回真,every? 函式才會返回真,反之則否。以下範例示範群集中的各個元素是否皆爲偶數:

(every? even? [1 2 3 4 5])
;; => false
(every? even? [2 4 6 8 10])
;; => true

組合函式

高階函式的另一個特色是可以返回一個函式當作結果,Clojure 提供了一些函式協助將一群函式組合成另一個函式返回。其中 comp 接受一群函式作爲參數,並返回新的函式,由右至左地呼叫傳入的函式。以下範例示範以組合的方式實作將字串中的空白去除,並將第一個字母改成大寫:

(def cap-without-space (comp clojure.string/capitalize clojure.string/trim))
(cap-without-space " clojure ")
;; => "Clojure"

或是定義一個取出序列中第四個元素的函式:

(def fourth (comp first rest rest rest))
(fourth [1 2 3 4 5])
;; => 4

partial 函式則是建立一個缺少的函式,缺少的是剩下的參數,通常使用在剩下的參數並不清楚的時候。以下的範例使用了 partial 建立了會加 5 的函式,由於產生的函式尚未完備,必須等剩下的參數補齊才會產生結果:

(def plus5 (partial + 5))
(plus5 2)
;; => 7
(plus5 10)
;; => 15

最後要討論的是 complement 函式,這個函式接受一個返回值是布林的函式,返回它相反的布林值:

((complement even?) 2)
;; => false
((complement true?) false)
;; => true

除了使用 comppartialcomplement 生成新函式的函式之外,當然也可以寫自己的函式來生成新函式。以下的範例演示了一個生成函式的函式,它接受一個參數後返回一個函式,以此參數來相加後續代入的參數:

(defn adder [x]
  (fn [y] (+ x y)))
(def adder5 (adder 5))
(adder5 3)
;; => 8
(adder5 10)
;; => 15

以上的範例除了示範了返回函式,返回的函式還將創建時帶入到父函式的參數記住,供以後使用。這種函式被稱作閉包 (Closure)。

講個祕訣

有個與函式相關的祕訣:向量、映射與集合也可以當作函式來使用:

([1 3 5 7] 2)
;; => 2
(#{1 2 3} 1)
;; => 1
({:a 1 :b 2 :c 3} :c)
;; => 3

向量當成函式時,參數就是索引值;映射當成函式時,參數就是索引鍵;集合當成函式時,參數就是集合中的內容,當參數並不在集合中則回傳 nil

若是將它們與高階函式一起使用,就可以產生簡潔的應用。以下範例使用 remove 函式,第一個參數是述語函式,用集合來當作述語函式。範例中,集合的內容是不受歡迎的賓客名字,第二個參數則是賓客名單,運算之後產生去除不受歡迎的賓客名單:

(def banned #{"Steve" "Michael"})
(def guest-list ["Brian" "Josh" "Steve"])
(remove banned guest-list)
;; => ("Brian" "Josh")

或是使用 map 函式將向量中的特定元素抽取出來:

(map [:a :b :c :d :e] #{0 3})
;; => (:a :d)

遞迴

一般遞迴

遞迴是函式透過不斷呼叫自己,將問題切割成數個細小問題逐個解決之後,把結果統整起來的問題解決方式。函數式程式設計語言透過遞迴達成迴圈可以做到的事。

如果想要用遞迴來解決問題,首先必須要先將問題切割成有限的小問題,再來則要確定解決最小問題的方法。只要完成這兩件事,問題便可以順利解決。

舉例來說階乘函數的定義是:n! = n * (n - 1) * (n - 2) · · · 3 * 2 * 1,可以把 n 的階乘看成 n 乘上 (n - 1) 的階乘,而 (n - 1) 的階乘則是 (n - 1) 乘上 (n - 2) 的階乘。因此計算階乘只要不斷計算下一個階乘的值,直到 1 爲止將它們全部相乘:

(defn factorial [n]
  (if (= n 1)
    1
    (*' n (factorial (- n 1)))))
(factorial 10)
;; => 3628800

以上的範例中使用了會自動轉換成大數的乘法符號 *',因爲產生的結果可能會超過一般整數的大小。

雖然這樣的表現非常自然直覺,但是缺點是因爲不斷呼叫自己,每次呼叫函式時會配置記憶體,其中存放參數資訊與到時返回的資訊,在最後一個函式返回之前,記憶體都不會釋放收回。一旦遞迴的次數過多,就會用光記憶體而無法正常運行:

(factorial 10000)
;; => StackOverflowError

尾遞迴

因此爲了解決一般遞迴會發生的記憶體不足的問題,可以使用尾遞迴 (Tail Recursion) 的方式解決。尾遞迴仍然是遞迴,但是將遞迴呼叫的位置擺放在函式的尾端,並且在遞迴呼叫函式之前,已完成呼叫之前必要的計算。以下是改成尾遞迴的範例:

(defn tail-factorial
  ([n]
   (tail-factorial 1 1 n))
  ([product counter max-count]
   (if (> counter max-count)
     product
     (tail-factorial (*' counter product)
                     (+ counter 1)
                     max-count))))
(tail-factorial 10000)
;; => StackOverflowError

在一些程式語言例如 Scheme 會將尾遞迴的函式進行效能改進,但是在 Clojure 寄宿的 JVM 中並不會對尾遞迴實行改進,因此建議的做法是改用 Clojure 提供的 loop/recur:

(defn recur-factorial [n]
  (loop [product 1
         counter 1
         max-count n]
    (if (> counter max-count)
      product
      (recur (*' counter product)
             (+ counter 1)
             max-count))))
(recur-factorial 10000)
;; => 28462596809170545189….0000N

惰性序列

將運算或求值延遲到必要的時候才進行稱作惰性求值 (Lazy evaluation),Clojure 提供了惰性序列 (Lazy sequence) 將計算序列內容延遲到真正需要的時候。Lisp 家族中的 Scheme 程式語言提供了類似的功能稱爲流 (Stream),Haskell 程式語言則是全面支援惰性求值。

因爲惰性序列延遲計算的特色,可以用來表現無限的概念,例如無限列表、或是讀取非常龐大的資料,在必要的時候才讀取資料至記憶體、或是將 IO 讀取延遲到真正需要的時候。內部使用的型態爲 clojure.lang.LazySeq。

(class (take 10 (range)))
;; => clojure.lang.LazySeq

創建一個惰性序列最簡單的方式是呼叫 range 函式,它會根據傳遞的參數創建漸進的惰性序列,搭配 take 函式後,可以依據需要的個數取得序列的內容:

(range 10)
;; => (0 1 2 3 4 5 6 7 8 9)
(range 1 11)
;; => (1 2 3 4 5 6 7 8 9 10)
(range 1 11 2)
;; => (1 3 5 7 9)
(take 5 (range))
;; => (0 1 2 3 4)

請記得千萬不要在 REPL 中直接使用 range 函式,會迫使 REPL 一直爲了顯示序列的內容而不斷求值,造成 REPL 停滯不動:

;; 危險!不要這樣做!!
(range)

repeat 創建惰性序列,內容為不斷重複的參數:

(take 3 (repeat "Hello"))
;; => ("Hello" "Hello" "Hello")

iterate 函式接受兩個參數,第一個參數爲一個函式,這個函式將會不斷地被運算,求值後的結果成爲惰性序列的內容;第二個參數則爲初始值:

(take 5 (iterate #(+ % 0.5) 1))
;; => (1 1.5 2.0 2.5 3.0)

以上範例中,iterate 第一個參數使用到了匿名函式 (Anonymous Function)。

cycle 函式接受一個群集,群集的內容將會交錯反覆地作爲惰性序列的元素:

(take 3 (cycle ["ping" "pong"]))
;; => ("ping" "pong" "ping")
(take 5 (cycle ["ping" "pong"]))
;; => ("ping" "pong" "ping" "pong" "ping")

mapfilter 函式可以應用在惰性序列上,產生的結果也是惰性序列。以下範例示範了利用 filtertake 取得 0 到 100 中頭十個偶數:

(take 10 (filter even? (range 0 100)))
;; => (0 2 4 6 8 10 12 14 16 18)

當利用內建的函式產生的惰性序列不符合需求時,還可以利用 lazy-seq 依據自己的需求打造惰性序列。以下利用 lazy-seq 與遞迴,建立惰性的費式序列:

(defn fib-seq
  "Returns a lazy sequence of Fibonacci numbers"
  ([]
     (fib-seq 0 1))
  ([a b]
     (lazy-seq
        (cons b (fib-seq b (+ a b))))))
(take 10 (fib-seq))
;; => (1 1 2 3 5 8 13 21 34 55)

以上範例首先使用 lazy-seq 創建惰性序列,其中使用 cons 函式創建序列,序列的尾部遞迴地呼叫 fib-seq 繼續生成序列。

回顧

通過本篇文章,你知道了如何建立 Vars 物件儲存資料,也了解如何建立函式。還了解了函式的各方面特色,如多載以及不定引數等。知道了使用解構手法,可以更方便快速取得需要的資料;當然還有像堆積木一樣地任意組合函式。除此之外,還知道了遞迴以及可以表達無限概念的惰性序列。

還不賴吧?今天就先到這裡,下一篇文章再見囉!