cv.cm

编码 · 编程语言

Clojure 入门教程

阅读约 8 分钟

简短回答

Clojure 是一门运行在 Java 虚拟机(JVM)上的动态函数式 Lisp(ClojureScript 则面向 JavaScript)。代码写成括号中的列表,数据默认不可变,程序由小函数组合处理向量、映射和集合而成。你需要安装 Clojure CLI,用 clj 启动 REPL,用 clojure -M hello.clj 运行文件。

打开正则测试

Clojure 是什么,谁在用

Clojure 由 Rich Hickey 创建,2007 年首次发布。它是 Lisp 的一种方言:每个表达式都是一个列表,第一个元素是函数或运算符,例如 (+ 1 2)。它编译为 JVM 字节码,因此可以直接调用任何 Java 库。ClojureScript 则把同一门语言编译成 JavaScript,供浏览器和 Node.js 使用。

Clojure 用于后端 Web 服务、数据处理管道和企业内部系统,尤其适合看重不可变数据和交互式开发的团队。开发者通常在与编辑器相连的 REPL(读取-求值-输出循环)中工作,程序运行的同时一次求值一个表达式。

安装 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 绑定局部名字。值在创建后不会改变:没有赋值语句。

类型示例说明
整数4264 位 long;42N 是 BigInt
浮点数3.14Java 的 double
有理数(/ 1 3)精确分数 1/3,不是 0.333
字符串"hello"Java 的 String,只能用双引号
字符\a单个字符
关键字:name快速、可自求值的标签,常用作映射的键
布尔值 / 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

这里没有可以修改计数器的 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,从其他函数中返回,也可以存进映射。

集合:列表、向量、映射与集

Clojure 的四种核心集合都是不可变且持久化的:“修改”一个集合会返回新版本,它与旧版本共享大部分结构,所以更新的开销很小。

集合字面量典型用途
列表'(1 2 3)代码,以及在前端添加的顺序数据
向量[1 2 3]带索引的序列,在末尾追加
映射{:name "Ana" :age 30}键值记录
集#{: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}

标志性特性:线程宏、原子与宏

线程宏让数据管道可以从上到下阅读。-> 把上一步的结果插入为下一个表达式的第一个参数;->> 则把它插入为最后一个参数。

(->> (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(返回一个映射)统计每个单词,再按次数降序排列各条目。

接下来读什么

  • clojure.org 上的官方指南:Getting Started、Learn Clojure 和 deps.edn 指南。
  • Clojure 参考页面,涵盖读取器语法、特殊形式和数据结构。
  • 为编辑器配置连接 REPL 的环境(例如 VS Code 的 Calva、Emacs 的 CIDER 或 IntelliJ 的 Cursive)。
  • 如果想面向浏览器开发,可以试试 ClojureScript。

常见问题

Clojure 是函数式语言吗?

是。Clojure 倾向于纯函数和不可变数据,但很务实:它允许副作用,并通过原子、ref 和 agent 提供受管理的可变状态。

学 Clojure 需要懂 Java 吗?

不需要。不懂 Java 也能学这门语言,但需要安装 Java 运行时,而且了解一点 JVM,对阅读堆栈跟踪或使用 Java 库很有帮助。

Clojure 中列表和向量有什么区别?

列表 '(1 2 3) 在前端添加元素,主要用于代码;向量 [1 2 3] 在末尾添加元素,并支持按索引快速访问。Clojure 程序中的大多数数据存放在向量和映射里。

为什么 Clojure 有这么多括号?

Clojure 代码写成 Lisp 数据结构,所以每次调用都是一个列表。这种统一的语法使宏成为可能,而支持结构化编辑的编辑器让括号很容易管理。

Clojure 和 ClojureScript 有什么区别?

它们是运行在不同宿主上的同一门语言:Clojure 运行在 JVM 上,ClojureScript 编译为 JavaScript。宿主互操作有所不同,但核心函数和数据结构相同。

全部编码教程