函式是函數式程式設計的核心,雖然各個流派談及函數式程式設計,都有自己的定見和看法,但是不變的核心仍然是函式,函式必須是程式語言的第一級公民 (First-class citizen)。
身爲程式語言第一級公民的函式,必須可以當成參數傳遞給其它函式、可以被作爲函式的返回值、可以把函式繫結給某個名稱、以及可以在動態執行期產生函式。
函式的事暫且按下不表,先讓我們了解 Clojure 中如何將資料給定個名字。
在之前的章節中我們不斷地在 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
除了使用 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 傾向提供簡潔方式解決問題。因此於 fn
與 def
的基礎上,建立了 defn
用來建立函式:
(defn adder [x y] (+ x y))
;; => #'user/adder
(adder 2 6)
;; => 8
在函式本體之中,可以透過 let
建立只存在於函式之中的區域符號 (Symbol)。範例中區域符號 a
是 x
、b
是 x
與 y
的和、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
看到層層 first
與 rest
是不是看到頭暈了呢?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
除了使用 comp
、partial
、complement
生成新函式的函式之外,當然也可以寫自己的函式來生成新函式。以下的範例演示了一個生成函式的函式,它接受一個參數後返回一個函式,以此參數來相加後續代入的參數:
(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")
map
與 filter
函式可以應用在惰性序列上,產生的結果也是惰性序列。以下範例示範了利用 filter
與 take
取得 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 物件儲存資料,也了解如何建立函式。還了解了函式的各方面特色,如多載以及不定引數等。知道了使用解構手法,可以更方便快速取得需要的資料;當然還有像堆積木一樣地任意組合函式。除此之外,還知道了遞迴以及可以表達無限概念的惰性序列。
還不賴吧?今天就先到這裡,下一篇文章再見囉!