cv.cm

編碼 · 程式語言

Clojure 入門教學

閱讀約 9 分鐘

簡答

Clojure 是一種動態、函數式的 Lisp,運行在 Java 虛擬機(JVM)上(另有編譯成 JavaScript 的 ClojureScript)。程式碼以括號內的串列寫成,資料預設不可變,你透過組合作用於 vector、map 與 set 的小型函式來建構程式。安裝 Clojure CLI 後,用 clj 啟動 REPL,並以 clojure -M hello.clj 執行檔案。

開啟正規測試

Clojure 是什麼、誰在使用

Clojure 由 Rich Hickey 創造,2007 年首度發布。它是 Lisp 的方言:每個運算式都是一個串列,第一個元素是函式或運算子,例如 (+ 1 2)。它會編譯成 JVM 位元組碼,所以可以直接呼叫任何 Java 函式庫。ClojureScript 則把同一種語言編譯成 JavaScript,供瀏覽器與 Node.js 使用。

Clojure 用於後端網頁服務、資料處理管線與企業內部系統,特別適合重視不可變資料與互動式開發的團隊。開發者通常在連接編輯器的 REPL(read-eval-print loop)中工作,在程式執行的同時,一次求值一個運算式。

安裝 Clojure 並執行 Hello, world

先安裝 Java 執行環境(JDK 17 或更新版本是穩當的選擇),再從 clojure.org 安裝 Clojure CLI 工具(macOS 用 Homebrew,Linux 用安裝腳本,Windows 用安裝程式)。CLI 提供兩個指令:clj(支援行編輯的 REPL)與 clojure(用來執行程式)。

;; hello.clj
(println "Hello, world!")
clojure -M hello.clj
# Hello, world!
clj
# user=> (+ 1 2)
# 3

實際的專案會把相依套件寫在 deps.edn 檔案中,程式碼放在 src/ 底下,每個檔案一個命名空間。Leiningen(lein)是較早出現但仍很受歡迎的建置工具,使用 project.clj 檔案。

值、型別與名稱

Clojure 是動態型別。def 綁定全域名稱(var),let 綁定區域名稱。值在建立後不會改變:Clojure 沒有賦值陳述式。

型別範例說明
Integer4264 位元的 long;42N 是 BigInt
Floating point3.14Java 的 double
Ratio(/ 1 3)精確分數 1/3,而不是 0.333
String"hello"Java 的 String,只能用雙引號
Character\a單一字元
Keyword:name快速、自我求值的標籤,常用作 map 的鍵
Boolean / niltrue, false, nil只有 false 與 nil 為假值
(def pi-approx 3.14159)
(let [r 2
      area (* pi-approx r r)]
  (println "Area:" area))
;; Area: 12.56636
(str "a" 1 :b)   ;; => "a1:b"

流程控制

在 Clojure 中,一切都是會回傳值的運算式,包括 if。if 恰好接收一個「then」與一個「else」形式;若有多個副作用且不需要 else 分支,請用 when;多個條件則用 cond。

(defn grade [score]
  (cond
    (>= score 90) "A"
    (>= score 80) "B"
    :else         "C"))
(println (grade 85))            ;; B
(when (pos? 5) (println "positive") :done)
(doseq [x [1 2 3]] (println (* x x)))   ;; 1 4 9
(for [x (range 5) :when (odd? x)] (* x 10))   ;; => (10 30)
(loop [i 0 acc 0]
  (if (< i 5) (recur (inc i) (+ acc i)) acc))   ;; => 10

Clojure 沒有那種會修改計數器的 for 迴圈。doseq 對每個元素執行副作用,for 建立惰性序列(類似串列生成式),loop/recur 則在不增加呼叫堆疊的情況下表達迭代。

函式

用 defn 定義具名函式。一個函式可以有多種元數(不同的引數個數),而 #(...) 是小型匿名函式的簡寫,其中 % 代表引數。

(defn greet
  ([] (greet "world"))
  ([name] (str "Hello, " name "!")))
(greet)          ;; => "Hello, world!"
(greet "Ana")    ;; => "Hello, Ana!"
(defn sum-all [& nums] (reduce + nums))
(sum-all 1 2 3)  ;; => 6
(map #(* % %) [1 2 3])        ;; => (1 4 9)
(filter even? (range 10))     ;; => (0 2 4 6 8)

函式也是值:你可以把它們傳給 map、filter 與 reduce,從其他函式回傳,或存放在 map 中。

集合:list、vector、map 與 set

Clojure 的四種核心集合都是不可變且持久化的:「修改」其中一個會回傳新版本,並與舊版本共用大部分結構,所以更新的成本很低。

集合字面值典型用途
List'(1 2 3)程式碼與在前端新增元素的循序資料
Vector[1 2 3]可用索引存取的序列,在尾端新增元素
Map{:name "Ana" :age 30}鍵值紀錄
Set#{:red :green}不重複的值、成員判斷
(def user {:name "Ana" :age 30})
(:name user)                   ;; => "Ana"
(assoc user :email "[email protected]")   ;; new map with :email
(update user :age inc)         ;; => {:name "Ana", :age 31}
(conj [1 2] 3)                 ;; => [1 2 3]
(contains? #{:red :green} :red) ;; => true
(get-in {:a {:b 1}} [:a :b])   ;; => 1
user                           ;; unchanged: {:name "Ana", :age 30}

招牌特色:threading 巨集、atom 與巨集

Threading 巨集讓資料處理管線可以由上往下閱讀。-> 把前一個結果插入下一個形式的第一個引數;->> 則插入為最後一個引數。

(->> (range 1 11)
     (filter odd?)
     (map #(* % %))
     (reduce +))   ;; => 165
(def counter (atom 0))
(swap! counter inc)
(swap! counter + 10)
@counter           ;; => 11
(defmacro unless [test & body]
  `(if ~test nil (do ~@body)))
(unless false (println "runs"))

Atom 存放可變的狀態:swap! 以原子方式套用函式,因此可以安全地在多個執行緒中使用。Ref 與 agent 則處理其他並行情境。由於程式碼就是資料(串列),巨集可以在編譯前轉換程式碼;when、cond 與 -> 本身就是巨集。

錯誤處理

Clojure 使用 JVM 的例外,搭配 try、catch 與 finally。若要在錯誤中附帶資料,請丟出 ex-info,再用 ex-data 讀回資料。

(defn parse-age [s]
  (let [n (Long/parseLong s)]
    (when (neg? n)
      (throw (ex-info "Age must not be negative" {:value n})))
    n))
(try
  (parse-age "-5")
  (catch clojure.lang.ExceptionInfo e
    (println (ex-message e) (ex-data e)))
  (catch NumberFormatException _
    (println "not a number")))
;; Age must not be negative {:value -5}

完整的小程式:字詞頻率

(ns wordcount
  (:require [clojure.string :as str]))
(defn word-counts [text]
  (->> (str/split (str/lower-case text) #"\W+")
       (remove str/blank?)
       frequencies
       (sort-by val >)))
(doseq [[word n] (take 2 (word-counts "The cat and the hat and the bat"))]
  (println word n))
;; the 3
;; and 2

這個程式先把文字轉成小寫,再用正規表示式字面值 #"\W+" 以非單字字元切開,用 frequencies(回傳一個 map)統計每個字詞,最後依次數由大到小排序各項目。

接下來可以學什麼

  • clojure.org 上的官方指南:Getting Started、Learn Clojure 與 deps.edn 指南。
  • Clojure 參考頁面,涵蓋 reader 語法、special form 與資料結構。
  • 設定一個可連接 REPL 的編輯器(例如 VS Code 的 Calva、Emacs 的 CIDER 或 IntelliJ 的 Cursive)。
  • 如果想以瀏覽器為目標,可以試試 ClojureScript。

常見問題

Clojure 是函數式語言嗎?

是的。Clojure 偏好純函式與不可變資料,但也很務實:它允許副作用,並透過 atom、ref 與 agent 提供受管理的可變狀態。

學 Clojure 需要先會 Java 嗎?

不需要。不懂 Java 也能學這門語言,但必須安裝 Java 執行環境,而且對 JVM 有些了解,在閱讀堆疊追蹤或使用 Java 函式庫時會很有幫助。

Clojure 的 list 與 vector 有什麼差別?

List '(1 2 3) 在前端新增元素,主要用來表示程式碼;vector [1 2 3] 在尾端新增元素,並支援以索引快速存取。Clojure 程式中的大部分資料都存放在 vector 與 map 中。

為什麼 Clojure 有這麼多括號?

Clojure 程式碼是以 Lisp 的資料結構寫成的,所以每次呼叫都是一個串列。這種一致的語法讓巨集成為可能,而支援結構化編輯的編輯器也讓括號容易管理。

Clojure 與 ClojureScript 有什麼差別?

它們是同一種語言,只是運行在不同的宿主上:Clojure 運行在 JVM 上,ClojureScript 則編譯成 JavaScript。宿主互通的方式不同,但核心函式與資料結構是一樣的。

全部編碼教學