Clojure是一种在JVM上运行的LISP风格的语言. 由于其函数式编程的风格和强大的宏系统, Clojure在并发编程的理念上非常先进, 不仅支持常规的函数式并发模式, 也支持Go语言的并发风格以及其他的并发模式. 其中设计的许多概念在Vue框架中也有类似的表达.
虽然从实践角度来说, 由于Clojure的运行依赖JVM, 导致将其作为脚本语言使用时显得过于笨重. 在日常使用中, 启动速度更快, 语法负担更小的Python依然是第一选择. 但是Clojure语言中涉及的编程范式依然值得学习.
Clojure环境构建
Clojure常见的开发IDE是vscode和IDEA. 两者都可以免费使用, 考虑到没有代码补全的情况下调用Java方法过于折磨, 因此推荐在编写简单脚本时使用vscode, 开发项目时使用IDEA.
vscode不需要安装插件即可支持基本的Clojure开发, 体验类似于写简单的Python脚本, 提供简单的语法高亮和代码补全.
IDEA需要安装Cursive插件, 安装完毕后重启IDEA即可选择Clojure项目. 在IDEA中, 依然支持Clojure语言的代码补全, 自动导入包等能力.
注意: Cursive是付费插件
脚本方式运行Clojure
当Clojure作为脚本语言执行时, 只需要使用clojure -M xx.clj
即可运行该脚本. 由于并没有代码补全能力, 因此如果使用了非默认导入的包, 则需要手动导入包名.
由于执行Clojure脚本需要启动JVM, 因此Clojure脚本具有一个较大的启动时间, 与Python相比这个启动时间尤为明显.
项目方式运行Clojure
安装leiningen
leiningen是一个用于生成和管理Clojure项目的工具, 提供了项目初始化, 依赖导入, 项目编译和打包等能力, 基本上等于Java中的Maven.
可参考官网指引安装leiningen, 也就是将对应的脚本下载到本地, 放入一个PATH变量中存在的路径.
保存后执行lein self-install
安装此工具需要的依赖. 此操作需要环境中能执行java命令.
创建项目
执行lein new xx
命令, 创建一个Clojure项目, 执行完毕后, 在IDEA中打开该项目.
lein
创建的项目结构与标准的maven项目结构没有明显区别, 在src路径下正常编写代码即可. 对于main函数所在文件需要进行如下的处理
1 | (ns demo3.core |
- 在
ns
中需要使用:gen-class
表明该文件需要生成一个Java的类, 否则无法在IDEA中运行程序 - 声明一个
-main
函数, 该函数就相当于Java中的main函数
默认情况下IDEA会下载一些依赖, 但由于网络原因可能会下载失败, 使得源码中产生许多告警. 此时可执行
lein run
命令运行当前项目, 从而强制执行一次依赖下载操作.
项目级配置
在project.clj
中还需要增加如下的配置
1 | :main demo3.core |
:main
用于指定入口的main函数位置, 在生成jar时需要该属性.
:aot
指定预先编译(Ahead-Of-Time, AOT)所有的命名空间, 该操作有助于提升jar的运行速度, 并提前发现一些类型问题. 但是在开发过程中, 启用预先编译会消耗更多时间, 因此通常仅在生成uberjar时启用该特性.
打包
当Clojure作为项目执行时, 最后可使用lein进行打包, 执行
1 | lein uberjar |
生成一个包含了所有依赖的JAR文件, 之后可使用java -jar
指令运行对应的jar.
数据类型
布尔类型有三种, 除了false和nil其他任何值均可视为true(当然也包括数字0)
1 | (= true false nil) |
字符串就是Java的String类
1 | (.contains "hello" "he") |
整数默认为long类型, 使用N后缀创建BigInt, 支持分数
1 | (+ 41 21N 2/3) |
使用单引号创建一个符号, 使用冒号创建一个关键字, 关键字就是一个指向自身的符号
1 | 'Hello |
列表
列表类型与Schema的列表类型对应, 类似于链表实现. 可以使用list关键字显式创建一个列表, 也可以通过引用的方式使用字面量的形式创建列表
1 | user=> (list 1 2 3) |
1 | ; 根据输入的数据类型不同, 会在不同的配置插入数据 |
向量
向量是Coljure新加入的数据结构, 类似于数组的实现. 可以直接使用方括号定义向量, 也可以调用vector函数显式的创建向量
1 | user=> [1 2 3] |
实际上由于向量不可变, 其底层实现并非一个数组, 而是类似于二叉树的结构, 具体可阅读Understanding Clojure’s Persistent Vectors
哈希表与集合
使用大括号创建哈希表, 其中的逗号在任何位置都会视为空格, 仅用于增加可读性.
1 | user=> {:a 1, :b 2} |
对于嵌套多层的哈希结构, Clojure提供了一组方法来简化操作
1 | (def users {:ggboy { |
1 | ; 设置嵌套层次的数据, 并返回新的结构 |
列表操作
Clojure支持函数式语言中经典的列表类操作
1 | (every? number? [1 2 3 :four]) |
Clojure中使用for关键词实现类似Python的列表推导功能, 即根据表达式生成列表. for仅可实现列表生成, 而不具备其他语言中循环的能力.
1 | (def color ["red" "blue"]) |
语言结构
函数与绑定
1 | ; 创建匿名函数并调用 |
流程控制
1 | (def x 42) |
逻辑运算
逻辑函数, 支持 and, or, not. Clojure中仅false与nil视为逻辑假, 其余值均视为逻辑真.
逻辑函数具有短路特性并返回最后一个处理的值, 例如对于and, 要么返回最后一个值, 要么中途遇到nil或者false, 从而返回nil或false.
1 | (and :a :b :c) ; => :c |
递归循环
使用loop与recur实现经典的函数式递归循环. loop的第一个参数是绑定列表, 提供偶数个参数, 将符号与值绑定.
因此在fact-loop
方法中, 首先将current绑定到n, fact绑定到1. 后续在recur中再次进行绑定后递归的进行计算.
由于Clojure不能自动优化尾递归, 因此只能采取这种方式实现尾递归.
1 | (defn fact-loop [n] |
由于递归方式实现循环时, 代码比较复杂, 因此Clojure中还有两个简化循环的操作
1 | ; 简化循环操作, 遍历指定的列表 |
串行宏
对于复杂表达式, 需要多层嵌套, 因此书写不方便, 可使用串行宏.
->
将上一个表达式放到下一个表达式的第一个参数的位置.
->>
将上一个表达式放到下一个表达式最后一个参数的位置.
此外还支持更复杂的任意位置串行as->
和条件串行cond->
1 | (defn final-amount-> [principle rate time] |
1 | (= |
异常处理
CLojure不要求强制处理任何异常, 但依然可以使用try
和throw
等语句处理异常.
1 | (defn safe-average [numbers] |
在没有代码补全的情况下, 应该并没有人愿意写这些代码, 所以脚本环境就随便写吧
注意(apply + numbers)
与(+ numbers)
的区别. 对于前者, 相当于将numbers
的内容展开后调用+
函数, 而对于后者, 相当于直接对numbers
本身进行操作.
例如当number
为[1 2 3]
时, 两者相当于(+ 1 2 3)
与(+ [1 2 3])
元数据
可以对任意对象附加元数据. 元数据不会改变该对象的任何特性(包括相等比较), 可以使用特定的函数提取对象的元数据
1 | (def untrusted (with-meta {:a 123} {:safe false :io true})) |
函数
重载
Clojure支持在一个函数中提供多个实现, 根据参数的数量实现重载
1 | (defn func-m |
可变参数
使用&
符号声明可变参数, 剩余的所有参数打包到&
符号后面的变量之中
1 | (defn func-print-more [name & more] |
常用高阶函数
函数名 | 效果 |
---|---|
every? | 对列表中每个元素执行判断, 判断是否均满足条件 |
constantly | 返回一个函数, 该函数无论输入什么, 均返回给定的值 |
complement | 对一个函数取反 |
comp | 将一组函数组合为一个函数 |
partial | 将一个函数的前k个参数赋予默认值后返回一个新函数 |
memoize | 对函数执行内存化 |
1 | (every? number? [1 2 3 :four]) |
匿名函数
在前面已经看到了使用fn
定义匿名函数. Clojure也提供了一个宏实现匿名函数, 即#()
1 | (def users [{:name "alice", :age 12} {:name "bob", :age 24}]) |
操作Java对象
使用.
操作符调用Java提供的库. Clojure默认导入了一些Java的包, 可以直接使用
1 | (. Math PI) |
以上的调用方式由于比较常用, 因此可以使用简写方式
1 | Math/PI |
导入Java包
在REPL中, 可以使用import语句进行导入, 在程序项目中, 可以在ns语句中导入
1 | (import 'java.util.Date) |
1 | (ns test5 (:import java.util.Date)) |
对于多层次的链式调用, 可以使用..
符号进行简化
1 | (ns test6 (:import java.util.Calendar)) |
辅助Java调用的宏
对于如下的Clojure代码, 必须定义一个匿名函数编译器才可以确定getBytes函数具体是哪一个(Java对应的类上存在多个函数重载, 无参数调用返回默认编码格式, 有参数调用可额外指令编码的字符集名称)
1 | (map (fn [x] (.getBytes x)) ["alice", "bob"]) |
可以使用memfn
将一个成员函数调用转换为一个Clojure函数, memfn
在运行时通过反射确定具体应该调用的函数
1 | (map (memfn getBytes) ["alice", "bob"]) |
可以使用bean
宏, 将一个JavaBean对象映射为Clojure的map结构, 例如
1 | (ns test6 (:import java.util.Calendar)) |
跳出Java思维
需要注意, 虽然Clojure提供了直接无缝操作Java类的方法, 但不要尝试在Clojure中硬写Java代码. 例如, 对于从一个文件中读取所有行并放入一个向量中的操作, 使用Java类强行实现的代码和使用Clojure实现的代码分别如下所示:
1 | (ns demo3.input |
1 | (ns demo3.core |
使用Java的类实现该操作, 就如同在用牙签吃饭, 不仅繁琐, 还很难保证正确的实现(例如上述代码未关闭文件流). 而使用Clojure提供的库来实现此功能就非常的简单且符合逻辑.
在使用Clojure的过程中需要始终记住不要用Java思维写代码, 也不要把Clojure当做一个在JVM上的LISP封装. Clojure作为一门生态成熟的语言, 常见的操作都有自己的解决方案, 不必强行使用Java实现.
写代码之前多问一下GPT如何实现
状态与并发
基本概念
不可变量: Clojure与其他函数式语言类似, 除了极少数情况下, 大部分时候的创建的变量实际上是不可变的.
持久化: 在Clojure中, 持久化并非指保存到硬盘, 而是指变量再线程内具有一致性, 其他线程对数据的修改本质上是创建了一个新的对象并共享其中不变的部分.
软件事务内存: Clojure支持软件事务内存, 即提供一种机制可以类似于事务的模式下更新多个变量(具有原子性和一致性).
事务的副作用: 软件事务内存会自动重试失败的事务, 因此事务中的函数可能重复执行多次, 这些函数不应该具有副作用
事务安全标记: Clojure中以!
结尾的函数表明不均被事务安全性, 即不建议在事务中调用. 可参考swap!
和send
ref
在Clojure中使用标识与值分离的思想解决并发问题. 引用相当于一个指针, 指针可以指向不同的值, 而每个值本身不会变化.
使用ref创建引用, 使用deref解除引用(或者使用@宏)
1 | (def user (ref {:name "alice", :age 12})) |
Clojure提供了多种方式修改引用的值, 这些方法都需要在dosync
函数内执行. ref-set
直接修改引用的指向
1 | (def user (ref {:name "alice", :age 12})) |
alter
将读取引用的值, 修改值, 写入修改值三个操作合并到一起. alter
接受一个引用和一个函数, 将函数应用到应用的值上, 并将操作后的结果重新写入引用.
1 | (def all-users (ref {})) |
1 | (add-new-user "alice", 120) |
1 | (add-new-user "bob", 240) |
commute
与alter
的输入是一样的, 但与alter
不同的地方在于:
当多个线程同时修改引用时, alter
会检查是否发生冲突, 并最终导致只有1个线程修改成功, 其余线程修改失败.
但如果两次修改可交换(即两者的先后顺序不重要, 例如两次计数器累加操作), 则可以改为使用commute
agent
Clojure提供一种称为代理(agent)的特殊结构,可以对共享可变数据进行异步和独立更改。
使用agent
创建代理, 使用deref解除引用(或者使用@宏)
1 | (def cpu-time (agent 0)) |
代理在对特定状态的更改必须以异步方式进行时很有用。这些更改通过发送一个动作(常规的Clojure函数)给代理进行,这个动作将在以后于单独的线程上运行。
1 | (send cpu-time + 700) |
send操作将请求提交到一个固定大小的线程池中. 如果线程池未满, 则函数立即返回. 在之后的一段时间, Clojure会调度执行对应的函数, 在执行完毕之前, 解引用依然返回旧的值.
如果提交时线程池已满, 则会阻塞send函数. 如果希望不被阻塞, 可使用send-off操作. send-off将函数提交到一个无界的线程池中, 因此永远不会阻塞.
向代理提交操作后, 可使用await或者await-for等待代理执行完毕.
如果代理执行错误, 可以使用agent-error
获取错误的原因. 一旦代理出现执行错误, 则后续所有的操作都是错误状态, 且代理的值也不会变换. 使用clear-agent-errors
可以清除代理的错误状态.
注意send函数并未以
!
结尾, 因此如果事务回滚, 则send对应的操作也不会生效. 因此send具有事务安全性.
atom
原子是Clojure中另一种可变状态管理机制。与引用不同,原子不支持事务性更新,也不支持乐观并发控制。原子提供了一种简单的方式来管理可变状态,它使用CAS(Compare-and-Swap)操作来确保更新的原子性。原子适用于那些不需要事务性保证,但需要保证状态更新原子性的场景。
1 | (def total-rows (atom 42)) |
validator
在创建引用, 代理或原子变量时支持添加一个校验器, 当条件不满足时抛出异常
1 | user=> (def non-negative (atom 0 :validator #(>= % 0))) |
1 | ``` |
watch
在创建引用, 代理或原子变量时支持添加监视器, 使得一个变量发生变更时, 调用指定的函数
1 | (def adi (atom 0)) |
future
future是代表在不同线程上执行的函数结果的一个对象.
1 | (defn slow-c [M N] |
1 | (defn fast-run [] |
使用future可以创建一个独立的线程运行给定的函数并返回一个future对象. 该操作会立刻返回.
当后续对future进行解引用时, 会阻塞线程, 直到对应的操作执行完毕.
可以使用如下的一些方法对future进行控制
函数 | 效果 |
---|---|
future? | 判断一个对象是否是future对象 |
future-done? | 判断是否计算结束 |
future-cancel? | 如果future尚未开始则撤销操作, 否则不进行任何操作 |
promise
promise是代表将在未来某个时点交付的一个值的对象. 可以创建一个promise对象后, 在一个线程中提交值, 在另一个线程中读取, 从而实现线程间通信.
使用deliver
函数投递值. 使用解引用读取值. 如果promise还未被投递值, 则当前线程阻塞.
不要在REPL上解引用promise, 会导致阻塞
并行计算
Clojure的函数式模式天然的适合并行计算, 许多代码仅需要简单替换即可实现并行, 例如
1 | (ns sum.core |
1 | (time (sum numbers)) |
注意: 由于并发模式具有一定的固定成本, 因此在不同设备上的执行情况有显著差异. 以上结果来自于一台12核心的PC机. 如果在2核心设备上执行, 则通常非并发模式更快
宏系统
Clojure中宏的概念与C语言中宏的概念是类似的, 即一种代码的模板, 根据输入的参数替换为对应的代码.
1 | ; 定义宏, 使用`开始一个模板, 使用~解引用, 即在模板的对应位置使用变量实际的值 |
1 | ; 宏展开时, 如果不对now进行特殊处理, 会导致解析失败, 加入#使Clojure为now生成一个随机名称, 从而避免冲突 |
最后更新: 2025年05月05日 17:37
版权声明:本文为原创文章,转载请注明出处
原始链接: https://lizec.top/2024/09/28/Clojure%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/