# 1周学会Clojure语言

# 前言

Clojure (opens new window) 是一门动态编程语言,它是运行在 JVM 上的一种 LISP 方言。这是一门纯函数式编程(Functional Programming)语言,和常见的面向对象编程(Object Oriented Programming)语言(如 Java)有显著的不同。

注意: LISP 是由来自麻省理工学院的人工智能研究先驱约翰·麦卡锡(John McCarthy)在1958年基于 λ 演算所创造,采用抽象数据列表与递归作符号演算来衍生人工智能。Clojure 是 LISP 基于 JVM 的一种方言,它是一门通用型高级语言,它的定位和人工智能没有直接关系。

Clojure 在2022年 TIOBE (opens new window) 编程语言受欢迎程度排行榜上排名在 50 开外,属于在“其他”中提及的语言,也即没能上榜。而排名前3位是 Python、C 和 Java,可以想象它的确是一门小众语言。

想要学习 Clojure 编程语言,您必须先掌握 Java,或曾经作为职业开发者在生产环境中用到过 Java 和 Python。学习一门编程语言投入的代价(主要是时间)是巨大的,能下决心来学习这门编程语言,需要花了不少时间调研来说服自己。

互联网上有几个英文视频,值得推荐观看,或许能帮助您回答心里这个大问号: Why Clojure?

Clojure 虽然小众,但却是一门实用的工程语言,下面是两个用 Clojure 语言开发的软件:

作为参考,这里还有一些 Clojure 比较推荐的入门学习资料:

笔者对 Clojure 语言的简单总结,仅代表个人意见,并且尽量中立地评价它。

  • 作者 Rich Hikey 于2005年发明了 Clojure,至今已有10多年历史了。软件业界一直说“没有银弹”,任何编程语言都有它的适应面,Clojure 也不可能例外。这门语言没有大公司背书和资金的支持,在可以预见的未来几年,它还将继续是一门“小众语言”。

  • Clojure 是一个在 JVM 上运行的动态函数式编程语言,其语法源于 LISP 语言,在 JVM 上运行时会编译成字节码运行。得益于 LISP ,Clojure 是一门“寄生“(Hosted)语言,它有三个官方的分支:Clojure (基于 JVM)、ClojureScript(基于 JavaScript)和 Clojure CLR (基于 .NET CLR)。

  • 《The joy of clojure》概括 Clojure 的设计原则:简单、专注、实用、一致和清晰。而在 Java 或 Python 世界里,众多的不同语法和适应场景,增加了不必要的复杂性。Clojure 让人集中注意力在业务领域,而不是过多受限于面向对象编程世界里的设计模式、类型继承抽象、数据类型、或众多不同适应场景下的不同代码语法等实现细节上。

  • 函数式语言(如 Clojure)比命令式语言(如 Java)更抽象。命令式的语言要告诉电脑一步一步具体怎么做,这最琐碎,但效率也最高。例如对于单选下拉框,我们要用循环遍历全部选项,打开其中一项并关闭其它选项。而函数式语言会让你通过函数进行一层抽象,用函数嵌套来进行逻辑上的解释,抽象层级更高一层。因此,Clojure 更偏向于数学和思维,适宜用来做 DSL (Domain Specific Language)。抽象层级再高,则如 SQL 或 Prolog 语言,只描述模型和约束,而不需要告知电脑怎么做。

Clojure 是一门精心设计的、完全融入作者对编程的思考的、富有生产力的现代编程语言。值得每个对生产效率、函数式编程、并发编程有兴趣的朋友深入了解下。若您已经决定继续学习,那我们现在就开始吧!

本书尝试以最少的文字,带您快速入门这门神秘而又难学的编程语言。每一个主题点到为止,让您先能够上手,而后有必要再深入学习。您可以参考其他免费电子书:

# 第1章 开发环境

安装开发环境应该是最简单的事情了,这里会比较简要地带过,只在需要注意的地方强调一下。

使用 Windows 作为开发客户端还是占多数,因此本文以 Windows 10 操作系统作为开发环境。得益于跨平台的 JVM,程序可以运行在其他操作系统上。

# 1.1 安装 JDK

JDK 有2个推荐,这里以开源免费的 OpenJDK 为例。

本文选择开源免费的 Adoptium OpenJDK (官网是: https://adoptium.net/temurin/releases/ (opens new window) )。因国内网络的原因,建议从清华的镜像网站下载。网址是: https://mirrors.tuna.tsinghua.edu.cn/Adoptium/ (opens new window)

安装步骤如下:

  1. 下载。 本文这里选择 OpenJDK-17, Windows 64 位的压缩包:OpenJDK17U-jdk_x64_windows_hotspot_17.0.5_8.zip, 将它下载到本地 PC 机上。
  2. 解压缩。 这里将它解压缩到 C:\opt\app\JDK\Adoptium\ 文件夹里。
  3. 设置环境变量。 添加系统环境变量 JAVA_HOME=C:\opt\app\JDK\Adoptium\jdk-17.0.5+8,并将 %JAVA_HOME%\bin 添加到 PATH 系统环境变量中。

至此安装完成。打开一个命令行窗口,检查一下:

java -version

它应该能够正确显示版本。

补充说明一下 JDK 的版权问题。OpenJDK 是开源的,肯定可以免费商用,这也是在 Linux 发行版软件仓库中可以找到的原因。而 Oracle JDK 是 Oracle 公司的二进制发行版,它的授权协议可以在下载页面找到相关说明。

JDK 19 and JDK 17 binaries are free to use in production and free to redistribute, at no cost, under the Oracle No-Fee Terms and Conditions. JDK 19 will receive updates under these terms, until March 2023 when it will be superseded by JDK 20. JDK 17 will receive updates under these terms, until at least September 2024.

从上面可以看到,Oracle JDK 17 可以免费商用,它和未来的 JDK 版本是在免费使用许可下提供的,直到下一个 LTS (Long-Term-Support,长期支持)版本发布整一年。Oracle JDK 17 最多可以支持到 2029 年 9 月份。

# 1.2 安装 Maven

Apache Maven 是 Java 的打包工具。

安装步骤如下:

  1. 下载。 Maven 的二进制发行版压缩包。 下载链接是: https://maven.apache.org/download.cgi (opens new window) 本文这里选择:apache-maven-3.8.6-bin.zip, 将它下载到本地 PC 机上。
  2. 解压缩。 这里将它解压缩到 C:\opt\app\maven\ 文件夹里。
  3. 设置环境变量。 添加系统环境变量 M2_HOME=C:\opt\app\maven\apache-maven-3.8.6,并将 %M2_HOME%\bin 添加到 PATH 系统环境变量中。

至此安装完成。打开一个命令行窗口,检查一下:

mvn --version

它应该能够正确显示版本。

如果对这个工具不熟悉,建议看一下官方的“Maven in 5 Minutes”文档,熟悉基本的使用: https://maven.apache.org/guides/getting-started/maven-in-five-minutes.html (opens new window)

在 Windows 操作系统中,Maven 默认将用户文件保存在 %USERPROFILE%\.m2 文件夹里面,这里是该用户的全局设置。

因国内网络的原因,可选但推荐,需要设定 Maven 使用国内的镜像网站。将 conf\settings.xml 文件复制到这里,编辑它,在里面添加国内的镜像网站。内容如下:

  <mirrors>

    <!-- 阿里云云效 Maven
    https://developer.aliyun.com/mvn/guide
    -->
    <mirror>
      <id>aliyunmaven</id>
      <mirrorOf>*</mirrorOf>
      <name>阿里云公共仓库</name>
      <url>https://maven.aliyun.com/repository/public</url>
    </mirror>

    <!-- 腾讯云软件源
    https://cloud.tencent.com/document/product/213/8623
    -->
    <mirror>
        <id>nexus-tencentyun</id>
        <mirrorOf>*</mirrorOf>
        <name>Nexus tencentyun</name>
        <url>http://mirrors.tencent.com/nexus/repository/maven-public/</url>
    </mirror>

  </mirrors>

# 1.3 安装 Leiningen

Leiningen 是 Clojure 的打包工具。

安装步骤如下:

  1. 下载。 下载 Leiningen 的 Windows 批处理脚本 lein.bat。 官网链接上找: https://leiningen.org/ (opens new window) 因国内的网络原因,上面的批处理脚本需要科学上网才能下载。
  2. 安装。 将下载的 lein.bat 放到 C:\opt\app\Leiningen\ 文件夹里面。
  3. 设置环境变量。C:\opt\app\Leiningen 添加到 PATH 系统环境变量中。

至此安装完成。打开一个命令行窗口,检查一下:

lein --version

它应该能够正确显示版本。

如果对这个工具不熟悉,建议看一下官方的“tutorial”文档,熟悉基本的使用: https://codeberg.org/leiningen/leiningen/src/stable/doc/TUTORIAL.md (opens new window)

在 Windows 操作系统中,Leiningen 默认将用户文件保存在 %USERPROFILE%\.lein 文件夹里面,这里是该用户的全局设置。首次运行 lein 命令时,它会下载一个 jar 包(和 lein 的版本对应), 如 leiningen-2.9.10-standalone.jar ,文件大概有13MB 左右,保存在 %USERPROFILE%\.lein\self-installs 文件夹中。注意,下载可能需要科学上网。

因国内网络的原因,可选但推荐,需要设定 Leiningen 使用国内的镜像网站。在 %USERPROFILE%\.lein 文件夹中创建一个文本文件 profiles.clj,内容如下:

{:user 
 {:mirrors {"central" {:name "ali" :url "https://maven.aliyun.com/repository/public/"}
            #"clojars" {:name "qinghua" :url "https://mirrors.tuna.tsinghua.edu.cn/clojars/" :repo-manager true}
           }
  :plugins []
  :repl-options {:init (use 'midje.repl)}
  :dependencies [[midje "1.10.9"]]
  }
}

注意: 上面配置文件中,还给当前用户引入了全局设置。添加了 midje (网址: https://github.com/marick/Midje (opens new window) )依赖 。它是一个单元测试的第三方包。它不是必须的,但您可以参照此例,以便给自己添加全局设置。

# 1.4 安装 VSCode

Visual Studio Code 是微软的编辑器。这里简称为 VSCode。

安装步骤如下:

  1. 下载。 下载压缩包。官网页面可以下载: https://code.visualstudio.com/ (opens new window) 本文选择 zip 压缩包。只是个人偏好的原因,并未选择可执行的安装文件。
  2. 解压缩。 将下载的压缩包 VSCode-win32-x64-1.73.1.zip 解压到 C:\opt\app\VSCode\ 文件夹里面。
  3. 设置。 创建快捷方式。在 C:\opt\app\VSCode\VSCode-win32-x64-1.73.1\ 文件夹中创建一个空文件夹 data,这将让 VSCode 以 Portable 的方式运行(用户插件和偏好设置,都将保存到这里)。到此文件夹中找到 Code.exe 并创建它的快捷方式放到桌面上。
  4. 安装插件。 为了 Clojure 开发,需要安装插件 Calva。

至此安装完成。

如果对这个工具不熟悉,建议看一下官方的“Getting Started”文档,熟悉基本的使用: https://code.visualstudio.com/docs/introvideos/basics (opens new window)

补充说明一下微软的 Visual Studio Code 的版权问题。VSCode 的源代码是开放的(基于MIT 版权):

微软在此开源的代码上,添加了自己的定制(如图库,图标 Logo,远程数据采集 Telemetry 等)编译成二进制发行版。而这个二进制发行版只是授权免费使用。关于这点,StackExchange 上有相关讨论。

微软的回答是,他们的做法等同于 Google Chrome 浏览器之于开源的 Chromium。

您可以在微软的 VSCode 中禁用远程数据采集,但是您能完全信任微软吗?如果不能,您还可以从源代码构建它,但是从源代码自行构建和安装并不总是最好的选择。有兴趣的可以看看 Github 上的这个项目,这是纯开源形式的 VSCode,它的取名也仿照了 Chromium,叫做 VSCodium (网址: https://github.com/VSCodium/vscodium (opens new window) )。

由于 VSCodium 是 VS Code 的一个分支,它的外观和功能与 VS Code 完全相同,仅图标等有少量区别。

注意: 在实际工作中,为了效率起见,还是推荐使用微软的 VSCode,而不是 VSCodium。

# 1.5 HelloWorld

打开一个命令行窗口,用 lein 创建一个新项目,取名叫 my-stuff

lein new app my-stuff

新创建的 my-stuff 文件夹结构如下:

my-tuff
    │  .gitignore
    │  .hgignore
    │  CHANGELOG.md
    │  LICENSE
    │  project.clj
    │  README.md
    │
    ├─doc
    │      intro.md
    │
    ├─resources
    ├─src
    │  └─my_stuff
    │          core.clj
    │
    └─test
        └─my_stuff
                core_test.clj

其中,项目的根路径下 project.clj 是项目的描述文档。它类似于 pom.xml 之于 Maven。这个文件指明了项目的依赖包,以及程序的入口点 my-stuff.core

:dependencies [[org.clojure/clojure "1.11.1"]]
:main ^:skip-aot my-stuff.core

源程序在 src 文件夹下,单元测试的源程序在 test 文件夹下。

其中,在 src 文件夹下的程序文件 my-stuff\core.clj 的内容如下:

(ns my-stuff.core
  (:gen-class))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (println "Hello, World!"))

这里文件夹 my-stuff 是名字空间(避免函数名发生冲突),类似于 Java 的包(package)。源程序文件 core.clj 中定义了 -main 函数,它即是程序的入口点,这类似于 Java 或 C 语言里的 main() 函数。

在项目的根路径下,可以用 lein run 来运行它。屏幕输出示例:

C:\Users\bobyuan\my-stuff>lein run
Hello, World!

我们也可以将它打成 jar 包后运行,这更适合于生产环境。屏幕输出示例:

C:\Users\bobyuan\my-stuff>lein uberjar
Compiling my-stuff.core
Created C:\Users\bobyuan\my-stuff\target\default+uberjar\my-stuff-0.1.0-SNAPSHOT.jar
Created C:\Users\bobyuan\my-stuff\target\default+uberjar\my-stuff-0.1.0-SNAPSHOT-standalone.jar

C:\Users\bobyuan\my-stuff>java -jar target\default+uberjar\my-stuff-0.1.0-SNAPSHOT-standalone.jar
Hello, World!

运行单元测试。屏幕输出示例:

C:\Users\bobyuan\my-stuff>lein test

lein test my-stuff.core-test

lein test :only my-stuff.core-test/a-test

FAIL in (a-test) (core_test.clj:7)
FIXME, I fail.
expected: (= 0 1)
  actual: (not (= 0 1))

Ran 1 tests containing 1 assertions.
1 failures, 0 errors.
Subprocess failed (exit code: 1)

完成后,清理(删除)target 文件夹。

lein clean

# 第2章 编程基础

# 2.1 REPL

Clojure 中 REPL 指的是 read-evaluate-print loop。在开发过程中,随时将代码在 REPL 中进行实验,以交互式的方式编程。这与传统的 “编码-编译-运行”的循环对比,反馈回路更短。我们可以在 REPL 中载入基础数据,再编写函数实现代码,每一步都可以看到操作的中间结果,直到最终完成。其间可以很容易进行一些“尝试”,因此,在编写代码的时候即可进行重构,有助于产出高质量的代码。

借用前面 HelloWorld 的示例代码,在命令行窗口运行 lein repl 来开启一个 REPL 交互式编程环境,屏幕输出示例:

C:\Users\bobyuan\my-stuff>lein repl
nREPL server started on port 50191 on host 127.0.0.1 - nrepl://127.0.0.1:50191
REPL-y 0.5.1, nREPL 0.9.0
Clojure 1.11.1
OpenJDK 64-Bit Server VM 17.0.5+8
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

my-stuff.core=>

在输出信息中,我们看到在本机的50191端口启动了 nREPL server。注意这个端口可能会变化。

在 REPL 中,常见的操作有:

  1. 载入名字空间:

    my-stuff.core=> (require 'my-stuff.core)
    nil
    

    这将载入源码(在 src 文件夹)中的 my-stuff.core 名字空间。

  2. 查看 -main 函数的注释:

    my-stuff.core=> (doc -main)
    -------------------------
    my-stuff.core/-main
    ([& args])
      I don't do a whole lot ... yet.
    nil
    
  3. 查看 -main 函数的源码:

    my-stuff.core=> (source -main)
    (defn -main
      "I don't do a whole lot ... yet."
      [& args]
      (println "Hello, World!"))
    nil
    
  4. 查看 Java 类 java.time.LocalDateTime 的在线文档:

    my-stuff.core=> (javadoc java.time.LocalDateTime)
    "https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/LocalDateTime.html"
    
  5. 运行 -main 函数:

    my-stuff.core=> (my-stuff.core/-main)
    Hello, World!
    nil
    

我们可以打开另外一个命令行窗口,运行 lein 并且连接到这个 nREPL server,这在代码调试时非常有用。注,下面的链接和端口号50191来自之前 nREPL server 启动时的提示信息。

lein repl :connect nrepl://127.0.0.1:50191

为了简便,也可以只给端口号(其他取默认值):

lein repl :connect 50191

在 REPL 中,可以用 Ctrl+D 组合键,或者输入 (exit)(quit) 退出。

如果用图形界面的 VSCode 来编写和调试程序:

  1. 用 VSCode 打开项目的文件夹。
  2. 启动项目的 REPL 并且连接进去: Ctrl+Alt+C 再 Ctrl+Alt+J
  3. 回答 Calva 弹出的问题。 注:本例中,选择 Leiningen,后面不选,直接按“OK”按钮。
  4. 打开源程序文件,试试 Calva 的常用功能。
    • Ctrl+Enter: 计算当前表达式。
    • Alt+Enter: 计算当前顶层表达式。
    • Ctrl+Alt+Enter: 载入当前文件和依赖。
    • Ctrl+Alt+C, Shift+T: 运行全部测试案例。
    • Ctrl+Alt+C, Ctrl+Alt+T: 运行当前鼠标所在的那个测试案例。
    • Ctrl+Alt+C, T: 运行当前打开文件的全部测试案例。
    • Esc: 退出当前的显示。

如果对 Calva 不熟悉,建议看一下官方的“Getting Started”文档,熟悉基本的使用: https://calva.io/getting-started/ (opens new window)

# 2.2 数据类型

Clojure 提供了下列基础数据类型:

  • 变量(Var): 它提供了一个可变的存储,可以绑定和解绑到具体的某个值。

  • 布尔值: 真(true) 或假(false),且 nil 也是 false。换句话说,只有 nil 和 false 为假。

  • 数值: 可以是整型(integer)、浮点型(float)、双精度(double),或分数(fraction)。例如:

    (double 1/3)                    ;;=> 0.3333333333333333
    (Double/parseDouble "123.456")  ;;=> 123.456
    
  • 标识符(Symbol): 它用来指代某个值。

  • 关键字(Keyword): 它以冒号作为前缀,它作为标识符指代自己,但通常是在映射(map)中作为键(key)。例如: :foobar

  • 字符串: 以双引号包裹的字符串,可以跨多行。例如: "你好"

  • 字符: 以“\”开始的字符。例如: \a 代表字符 a

  • 正则表达式: 以”#“开始的字符串。例如: #"regular_expression"

此外,Clojure 提供了丰富的标准集合,包括:

  • 列表(List): (1 2 3)
  • 向量(Vector): [1 2 3]
  • 映射(Map): {:foo "a" :bar "b"}
  • 集合(Set): #{"a" "b" "c"}

集合的元素一般以空格分隔。在 Clojure 中,集合是不变的(immutable),对集合内的元素进行操作将产生一个新的集合。

注意: 在集合中的元素,为了便于人阅读,也可以用逗号分隔。例如可以像 Python 那样写做 [1, 2, 3]。但是,这在 Clojure 中不推荐,因为它不符合 LISP 的编码风格。只有一个地方例外,即在映射里面,例如可以写作:{:foo "a", :bar "b"},用逗号分隔了键值对,也许是这样显著增强了可读性,这样的写法也常见。

Clojure 是一门多用途的编程语言,并且也是一门实用性很强的语言。Clojure 语言的代码由运算表达式构成,它的语法实际上非常简单,都是遵从一个固定的格式,即用列表(list)来表示,第一个元素是函数(用它来执行某个操作),后面跟上此函数所需的可选参数(也可以没有)。这也是 LISP 名字(list processing)的由来。例如:

5                          ; 这是一个数值 5
"hi"                       ; 这是一个字符串 "hi"
[1 2 3]                    ; 这是一个向量 [1 2 3]
(+ 1 2)                    ; 列表,表达式求值 1+2 得到数值 3
(if true "yes" "no")       ; 逻辑表达式求值得到字符串 "yes"
(println "hello world")    ; 在控制台打印字符串 "hello world",表达式求值得到 nil

注意: 在 Clojure 中,分号“;”开头是表示行注释。

在 Clojure 中,数据即是代码,代码即是数据。这使得它可以用强大的元编程(meta programming)来生成代码。这是 Clojure 语言区别于其他语言如 Java、Python 的最重要的特征。正因为如此,它可以自我扩展,带来其他语言无法企及的动态和灵活性。

注意: 元编程即是用宏——用代码来生成代码,是高级的编程话题。它功能强大,但也带来了复杂性。从工程实际应用的角度,我们应该尽量使用普通函数,只有在函数无法做到的时候,考虑用宏。

如果您的背景来自面向对象编程世界(如 Java),Clojure 是纯函数式编程,可能一开始难以适应。请暂且放下之前的所学,一起从零开始。

# 2.3 函数

函数(function)在 Clojure 中和其他语言(如 Python)类似,代表一个操作或者计算返回一个值。主要的区别是,在 Clojure 中用列表(list)来写,左边小括号后跟的第一个是函数名,后面跟着参数。参数可以零个(不需要),一个或者多个。

  • 在 Python 中的函数调用:functionName(param1, param2)
  • 在 Clojure 中的函数调用: (functionName param1 param2)

例如,下面的例子计算 1 + 2 + 3 返回结果值 6。

(+ 1 2 3)
;;=> 6

函数可以嵌套,例如下例计算 1 + (2*3) + (10/2) ,返回结果值 12。

(+ 1 (* 2 3) (/ 10 2))
;;=> 12

加减乘除操作符 +、-、*、/ 都可以在这里作为函数名。当然,我们也可以自定义函数。

在 Clojure 中,一个表达式用一个列表(里面是函数和可选的参数)来表示,程序就是由多个表达式组合构成。就像乐高玩具那样,我们用这些函数来构造一个数据处理机,从一端送入原始数据,从另一端获得我们想要的结果。

从软件架构上来说,这是管道架构风格。管道架构模式出现在各种应用程序中,尤其是便于简单、单向处理的任务。管道架构的拓扑结构由管道和过滤器组成。总体成本低和简单性以及模块化是管道架构风格的主要优势。管道架构本质上是单体的,它不具有与分布式架构风格相关联的复杂性,它简单易懂,并且建设和维护的成本相对较低。架构模块化是通过不同类型的过滤器和转换器的关注点分离来实现的。这些过滤器中的任何一个都可以在不影响其他过滤器的情况下进行修改或更换。

# 2.4 匿名函数

匿名函数是没有取名字的函数,通常用来做表达式计算,代表用完即弃的一个操作。它用法对应于 Java 的 lambda 表达式。

例如下面定义了一个匿名函数,它将参数 arg 在控制台上打印出来:

(fn [arg] (println arg))

我们于是可以调用它:

((fn [arg] (println arg)) "hello")
;;=> "hello"

Clojure 提供了语法糖,让上面的匿名函数可以简写成:

#(println %)

其中 #( ) 代表匿名函数,里面 % 代表它唯一的参数。若它有多个参数,可以用 %1,%2 依次来表示。例如下面定义的匿名函数,它有3个参数:

#(println %1 %2 %3)

# 2.5 命名函数

大多数情况下,我们需要编写命名函数,从而能再次使用它,让代码可以复用。给匿名函数取一个名字,即是命名函数。例如,这里定义一个匿名函数,它计算 x 的平方:

(fn [x] (* x x))

注意: Clojure 的函数最后一行的值,即是函数的返回结果。它不像 Java 语言那样有 return 语句,而是以上述默认的方式来表示函数返回值。若没有返回结果,它会有默认的 nil 作为返回结果。

下面我们给这个匿名函数,用 def 来绑定名字 square :

(def square (fn [x] (* x x)))

这样,我们就可以用名字 square 来使用它:

(square 2)  ;;=> 4

因定义函数用得非常多,Clojure 提供了 defn 来定义函数,这也是比较规范的写法:

(defn square [x] 
    (* x x))

下面来看一个真实的函数示例,计算 BMI (body mess index) :

(defn bmi [height weight]
  (println "height:" height)
  (println "weight:" weight)
  (/ weight (* height height)))

代码中定义 bmi 函数有2个参数,分别是 height 和 weight,代表身高和体重。运行它会执行3步:

  1. 打印出身高;
  2. 再打印出体重;
  3. 最后用身高的平方除以体重,作为函数返回值。

Clojure 使用一遍扫描编译,因而必须先定义才能使用函数。您也可以事先申明函数,再在后面定义。例如:

;; 事先申明down函数
(declare down)

(defn up []
    (if (< n 10)
        (down (+ 2 n))
        n))

;; 在后面定义down函数
(def down [n]
    (up (dec n)))

在 Clojure 中,函数可以当作参数传递,它和传值没有任何区别。接受函数作为参数的函数,被称为高阶函数(high-order function)。我们可以将函数灵活地嵌套,像乐高玩具那样,组合起来实现任何想要的功能。

您可能注意到,Clojure 的代码结构是抽象语法树(AST, abstract syntax tree),因此我们可以很清晰地看见它的逻辑组织关系。理论上它不需要缩进,上面的缩进写法只是为了让人容易读懂。LISP 被人诟病的括号,在熟悉后并不那么可怕。当然,为了易读性,作为一个最佳实践,单个函数遵循单一职责原则,一般建议函数体不要超过5行。

注意: 在计算机科学中,抽象语法树是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

——维基百科

函数可以接受不定个数的输入参数,例如,数据库里面查询出来一条地址记录:

unit country city address postal_code
"" "Canada" "Toronto" "1 Main Street" nil

我们要把它拼接成为完整的地址: 1 Main Street, Toronto, Canada

编程如下:

(defn concat-fields [& fields]
    (clojure.string/join "," (remove empty? fields)))

(concat-fields "" "1 Main Street" "Toronto" nil "Canada")
;;=> "1 Main Street, Toronto, Canada"

以 & 开头标记的输入参数 fields,代表它可以接受多个参数。形参 fields 在这里等于实参 ("" "1 Main Street" "Toronto" nil "Canada")。注意,这里实参是一个列表,即便它只有一个元素或者为空。

函数的实现逻辑是,先用 (remove empty? fields) 将空元素剔除(包括空字符串和 nil),然后用逗号“,”来分隔拼接成字符串,作为函数最终的返回值。

需要补充说明的是,即便输入参数为一些特殊边界值,它也能很好的处理:

(concat-fields)   	;;=> ""
(concat-fields nil)	;;=> ""
(concat-fields "")  ;;=> ""

在 Clojure 编程中看不到循环,它通常会用尾递归和高阶函数来实现。

再举一个例子。我们编写一个函数 parse-bool ,将输入字符串 "true" 或 "false" (大小写不敏感)解析成为布尔值。

(defn parse-bool 
    "Parse a boolean value (ignoring case): true or false."
    [text] 
    (case (clojure.string/lower-case text)
        "true" true
        "false" false 
        (throw (java.lang.IllegalArgumentException. (str "Invalid boolean string: " text)))))

我们来试试,它可以如预期正常工作。

user=> (parse-bool "true")
true

user=> (parse-bool "FALSE")
false

user=> (parse-bool "yes")
Execution error (IllegalArgumentException) at user/parse-bool (REPL:7).
Invalid boolean string: yes

上面我们定义函数时,给它提供了一行可选的字符串作为函数说明文档。我们可以用 (doc functionName) 来查看说明文档,例如:

user=> (doc parse-bool)
-------------------------
user/parse-bool
([text])
  Parse a boolean value (ignoring case): true or false.

更进一步,我们希望它在解析失败的情况下,能够以指定的默认值返回,于是修改代码如下:

(defn parse-bool 
    "Parse a boolean value (ignoring case): true or false.
     You can give an optional default value if parse failed."
    ([text]
     (case (clojure.string/lower-case text) 
         "true" true
         "false" false
         (throw (java.lang.IllegalArgumentException. (str "Invalid boolean string: " text)))))
    ([text default-value]
     (try
         (parse-bool text)
         (catch java.lang.IllegalArgumentException ne default-value))))

这里,我们提供了函数额外的一种调用模式,提供 text 和 default-value 共2个参数,在解析失败的时候返回 default-value。函数本次的实现,将能够在提供1个或2个输入参数的时候,使用上面的不同实现分支。再有,细心的读者可以发现,在后面提供2个参数的实现中还复用了第一个实现,仅当捕获异常时返回默认值。实际调用如下:

;; 使用1个参数的调用模式
user=> (parse-bool "fALse")
false

;; 使用1个参数的调用模式
user=> (parse-bool "no")
Execution error (IllegalArgumentException) at user/parse-bool (REPL:8).
Invalid boolean string: no

;; 使用2个参数的调用模式,提供默认值(用到了)
user=> (parse-bool "no" true)
true

;; 使用2个参数的调用模式,提供默认值(没有用到)
user=> (parse-bool "tRuE" false)
true

在上面的代码示例中,我们还看到了 Clojure 无缝调用 Java 的例子:

(java.lang.IllegalArgumentException. (str "Invalid boolean string: " text))

注意: 上面我们看到 Exception 尾部有一个句点!它代表着新建一个 Java 对象。还有另一种写法:

(new java.lang.IllegalArgumentException (str "Invalid boolean string: " text))

一般用前面一种“句点”的方式较常见。

上述尾部句点和用 new 作为函数的两种写法,都等同于 Java 语言中新建一个对象实例:

new java.lang.IllegalArgumentException("Invalid boolean string: " + text);

再例如,下面是在 Clojure 中新建一个 java.util.Date 对象实例:

(java.util.Date.)  ;; 等同于Java语句: new java.util.Date()

我们可以用这个对象,调用它的 getTime() 方法:

(.getTime (java.util.Date.))	;; 等同于Java语句: new java.util.Date().getTime()

若是访问 Java 类的静态变量或调用静态方法,可以这样写:

(java.lang.Double/MAX_VALUE)	 ;; 等同于Java语句: java.lang.Double.MAX_VALUE
(java.time.LocalDateTime/now)  ;; 等同于Java语句: java.time.LocalDateTime.now()

对于 java.lang 的包,默认可以去掉包名,简写成 (Double/MAX_VALUE) ,而其他的包,则需要包的全名。这也更容易让人识别它是一个 Java 对象。再例如:

(Math/abs -1)  ;; 等同于Java语句: java.lang.Math.abs(-1);
(.getID (java.util.TimeZone/getDefault))  ;; 等同于Java语句: java.util.TimeZone.getDefault().getID();

# 2.6 高阶函数

可以接受函数作为参数的函数,就叫做高阶函数(high-order function)。例如:

(map #(* % %) [1 2 3 4 5])  ;;=> (1 4 9 16 25)

上面的 map 就是一个高阶函数,它的第一个参数 #(* % %) 是一个匿名函数,它定义为返回输入参数的平方值。第二个参数 [1 2 3 4 5],它是一个向量。因此,map 函数将用这个匿名函数,将向量中每一个元素套用了匿名函数求其平方,返回得到结果。最后返回一个列表 (1 4 9 16 25)

再比如:

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

这里 filter 是一个高阶函数,它的第一个参数是 even? 函数,它用来判断某个数是否为偶数,从而可以用来过滤,得到一个偶数列表。

注意: 在 Clojure 中函数名可以带问号。通常这样写是用来做判断,返回真或者假。

我们可以把函数级联起来:

(filter even? (map #(* 3 %) [1 2 3 4 5]))  ;;=> (6 12)

上面的代码中,我们先用 map 对向量 [1 2 3 4 5] 中每一个元素应用匿名函数 #(* 3 %),即每一个元素乘以3,得到结果是一个列表 (3 6 9 12 15),然后过滤出其中的偶数,得到最终结果 (6 12)

Clojure 中提供了丰富的高阶函数,我们可以通过它们的组合(像数学公式那样)来求解一个问题。

# 2.7 闭包

闭包(Closure)指一个函数,它的返回值是另外一个函数。

例如下面的代码中,greeting 函数使用了参数 greeting-string 来构造一个匿名函数,并将此匿名函数作为结果返回。后续使用 let 将 greet 绑定到 (greeting "Welcome to the wonderful world of Clojure"),然后使用它来执行 (greet "Jane")(greet "John") 调用。

(defn greeting [greeting-string]
    (fn [guest]
        (println greeting-string guest)))

(let [greet (greeting "Welcome to the wonderful world of Clojure")]
    (greet "Jane")
    (greet "John"))

注意: 在 Clojure 语言中,let 是在方括号里面进行绑定(可以有多个),然后是顺序执行的一到多个表达式。它的语法是: (let [bindings*] exprs*)

这个闭包的例子实现起来有点复杂,不容易理解。闭包其实只是把函数的几个入参在运行时固定,生成一个新的精简版的函数,它能够提供一定程度的灵活性。

不使用闭包,我们可以换一种更直观的方式来重写。先定义 greeting-guest 函数,它需要2个参数,先是问候语,然后是客人名字。它的逻辑很简单,即在控制台打印出用这个问候语和客人名字组成的字符串。

(defn greeting-guest [greeting-string guest]
    (println greeting-string guest))

再用它来定义一个 greet 函数,这里仅确定了 greeting-string 的值,而 guest 仍是未确定的参数。

(defn greet [guest]
    (greeting-guest "Welcome to the wonderful world of Clojure" guest))

至此,我们也可以实现同样的 greet 行为。

(do (greet "Jane")
    (greet "John"))

注意: 在 Clojure 语言中,do 是顺序执行的一到多个表达式。它的语法是: (do exprs*)

我们也可以用 let 来让 greet 绑定到匿名函数,实现同样的行为。

(let [greet #(greeting-guest "Welcome to the wonderful world of Clojure" %)]
    (greet "Jane")
    (greet "John"))

# 2.8 表达式级联

Clojure 中表达式级联是从内到外,不符合人类阅读习惯,带来代码可读性降低。例如:

(reduce + (interpose 5 (map inc (range 10))))

这里面的计算顺序是:

  1. 先计算 (range 10),它得到列表 (0 1 2 3 4 5 6 7 8 9)
  2. 然后 map 函数将每个元素加一,得到列表 (1 2 3 4 5 6 7 8 9 10)
  3. 然后 interpose 将 5 每隔一个元素插入到列表中,得到列表 (1 5 2 5 3 5 4 5 5 5 6 5 7 5 8 5 9 5 10)
  4. 最后 reduce 将列表中的元素求总和,得到值 100

为了让人更易读,符合人类从左到右的阅读习惯,Clojure 提供了 ->> 来级联它们。可以写作:

(->> (range 10) (map inc) (interpose 5) (reduce +))

在 Clojure 中,->> 的意思是,前一个表达式的结果,作为后一个表达式的最后一个参数,依次从左到右进行计算。

与之相对应,还有一个 -> (注意它是单个大于号),它的意思是,前一个表达式的结果,作为后一个表达式的首个参数,依次从左到右进行级联计算。

# 2.9 代码结构

下面时一个 Python 代码,我们要显式的用循环,在列表中遍历和存取数值。

l = list(range(1, 6))
for i, val in enumerate(l):
    l[i] = val * val
for i in l:
    if i % 2 == 0:
        print(i)

在 Clojure 中,我们是将函数作为一种变换操作,将它们级联,然后将原始数据送入管道,到另一端获得我们想要的结果。

(run! println
      (filter #(= (mod % 2) 0)
              (map #(* % %) 
                   (range 1 6))))

使用 ->> 来加强可读性,上面的代码也可以改写作:

(->> (range 1 6)
     (map #(* % %))
     (filter #(= (mod % 2) 0))
     (run! println))

每一个表达式得到新的一组数据,而不是在数据集上进行修改。

Clojure 的数据结构是不变的,每一次数据集的修改都会得到一个“新版的”数据集。

# 2.10 数据析构

Clojure 有一个强大的数据析构机制。例如下面的代码:

(let [[small big] (split-with #(< % 5) (range 10))]
    (println small big))
;;=> (0 1 2 3 4) (5 6 7 8 9)

我们在 let 函数中,用 [small big] 来析构右边的 (split-with #(< % 5) (range 10)) 表达式。右边表达式的计算结果是 [(0 1 2 3 4) (5 6 7 8 9)]。因此 Clojure 将析构得到: small = (0 1 2 3 4), 而 big = (5 6 7 8 9)

再比如下面的代码:

(defn print-user [[name address phone]]
    (println name address phone))

(print-user ["Bob" "12 Jarvis street, Toronto" "416-987-3417"])
;;=> Bob 12 Jarvis street, Toronto 416-987-3417

函数 print-user 的参数写成了一个向量 [name address phone]。它在后面被调用时,也提供了一个向量作为输入参数 ["Bob" "12 Jarvis street, Toronto" "416-987-3417"]。 因此 Clojure 将析构得到: name="Bob"address="12 Jarvis street, Toronto",和 phone="416-987-3417"

对于不确定参数个数的情况下,我们可以用 & 符合,将入参 args 匹配到列表。如下例:

(defn foo [& args]
    (println args))

(foo "a" "b" "c")
;;=> (a b c)

这里 foo 函数中的 args 将被解析为列表: ("a" "b" "c")

再例如,下面的函数中,第一个输入参数是必须有的,第二个输入参数是可选的。

(defn foo [first-arg & [second-arg]]
    (println (if second-arg
                 "two arguments were passed in"
                 "one argument was passed in"))
    (println "first-arg =" first-arg)
    (println "second-arg =" second-arg))

(foo "bar")
;one argument was passed in
;first-arg = bar
;second-arg = nil

(foo "bar" "baz")
;two arguments were passed in
;first-arg = bar
;second-arg = baz

这里 foo 函数的 first-arg 将匹配第一个参数(这个参数必须有,否则会报错),而 & [second-arg] 将匹配剩余的参数(可选的)。如果调用时没有第二个参数,second-arg 将是空(nil);如有第二个参数, 则 second-arg 将匹配到这个参数。

若是再多给一个参数,如 (foo "bar" "baz" "cat"), 将会得到什么?

(foo "bar" "baz" "cat")
;two arguments were passed in
;first-arg = bar
;second-arg = baz

从上面的运行结果看,多余的参数将会被丢弃。

若将代码改成这样:

(defn foo [first-arg & second-arg]
    (println "first-arg =" first-arg)
    (println "second-arg =" second-arg))

(foo "bar" "baz" "cat" "mouse")
;first-arg = bar
;second-arg = (baz cat mouse)

作为一个练习,读者可以结合之前的知识,来解释一下上面运行的结果。

数据析构可用于映射(map),例如:

(let [{foo :foo bar :bar} {:foo "fooVal" :bar "barVal"}]
    (println foo bar))

;;=> fooVal barVal

在 let 函数中,用 foo 来取右边 map 的 :foo 键对应的值,用 bar 来取右边 map 的 :foo 键对应的值。

对于嵌套的映射,也可以析构,例如:

(let [{[a b c] :items id :id} {:id "foo" :items [1 2 3]}]
    (println id " has the following items " a b c))

;;=> foo  has the following items  1 2 3

因为从映射进行数据析构很常用,而上面的写法有点复杂,可以改为另一种写法:

(defn login [{:keys [user pass]}]
    (and (= user "bob") (= pass "secret")))

(login {:user "bob" :pass "secret"})

这里在参数里给出要从映射中取得键,简洁了许多。

若要保留原来映射,可以参照下例:

(defn register [{:keys [id pass repeat-pass] :as user}]
    (cond (nil? id) "user id is required"
          (not= pass repeat-pass) "re-entered password doesn't match"
          :else user))

这里用 :as 使得 user 来指代原本传入的映射。

# 2.11 名字空间

为了避免函数命名冲突,引入了名字空间(namespace)的概念,用 ns 表示。

例如,在 myapp 项目的源程序文件夹 src 中,有文件夹 myapp,里面有一个源程序文件 core.clj,内容如下:

(ns myapp.core
  (:require [clojure.string :as str] 
            [clj-time.core :as ct])
  (:import (java.util Calendar TimeZone)
           (org.apache.commons.lang3 SystemUtils)))

(defn say-hello [name]
    (println "Hello" name))

第一行 (ns myapp.core ... ) 设定了本源程序文件的名字空间,后面是依赖:

  1. (:require ...) 引入依赖的 Clojure 包

    • 上例中引入了 clojure.string 第三方包。可选的,还用 :as 取了个别名 str,方便在后面引用。于是 (clojure.string/lower-case ...) 可以简写作: (str/lower-case ...)

    • 对于熟悉的包,比如项目里面自己写的,还可以 (:require [colors :refer :all]),引入 colors 包里面的全部函数。当然,这样做有发生函数命名冲突的风险。为了安全,可以写明要引入哪几个函数,例如写作 (:require [colors :refer [rgb->hex hex->rgb]),它用 :refer 来表示只引入 colors 包里面指定的 rgb->hexhex->rgb 这两个函数。

    • 另一种写法,可以用 :use 来替代 :refer 。例如 (:use colors) 表示引入 colors 包里面的全部函数。对于指定引入函数的情况,可以写作 (:use [colors :only [rgb->hex hex->rgb]]),用 :only 表示只引入 colors 包里面指定的 rgb->hexhex->rgb 这两个函数。

  2. (:import ...) 引入依赖的 Java 类

  • 上例中引入了 java.util 包里面的 Calendar 和 TimeZone 两个类,还引入了 org.apache.commons.lang3 包里面的 SystemUtils 类。当没有合适的 Clojure 包时,我们就可以引入并使用 Java 类,充分利用 Java 世界里丰富的资源。使用的时候,需要用包的全名,例如: (org.apache.commons.lang3.SystemUtils/IS_OS_LINUX)

当我们如上定义好了在名字空间 myapp.core 里面的 say-hello 函数,要在另一个名字空间 myotherns 中用到它时,可以:

(ns anotherns
    (:require [myapp.core :as c]))

(c/say-hello "Bob")

# 2.12 动态变量

在 Clojure 中可以申明动态变量。例如:

(declare ^:dynamic *foo*)

首次申明后,变量 *foo* 并未绑定到某个具体的值,因此,这时候访问它会出错:

(println *foo*)

;;=> #object[clojure.lang.Var$Unbound 0x390e71f1 Unbound: #'anotherns/*foo*]

必须先绑定,再使用。例如:

(binding [*foo* "I exist!"]
    (println *foo*))

;;=> I exist!

这项技术可以用在数据库连接时,或者有范围的变量时。不变性是 Clojure 的主要特性之一,因此,一般情况下 Clojure 不鼓励使用动态变量。

# 2.13 多态

多态(polymorphism)是面向对象编程的概念。Clojure 提供两种方式来实现多态。

# 多方法

我们用 defmulti 来定义多方法(Multimethods),用 defmethod 来实现每一个方法。在下例中,我们定义了一个面积 area 多方法,它可以根据不同形状来计算面积:

(defmulti area :shape)

(defmethod area :circle [{:keys [r]}]
    (* Math/PI r r))
(defmethod area :rectangle [{:keys [l w]}]
    (* l w))
(defmethod area :default [shape]
    (throw (Exception. (str "unrecognized shape: " shape))))

(area {:shape :circle :r 10})
;;=> 314.1592653589793
(area {:shape :rectangle :l 5 :w 10})
;;=> 50

代码中可以看到,它使用了关键字来作为键,对应到具体的实现方法。

# 协议

我们用 defprotocol 来定义协议(protocal), 协议定义了一组抽象方法。例如下面定义了 Foo 协议:

(defprotocol Foo
	"Foo doc string"
	(bar [this b] "bar doc string")
	(baz [this] [this b] "baz doc string"))

然后,用 deftype 来实现它们,得到 Bar 类型:

(deftype Bar [data]
    Foo
    (bar [this param]
         (println data param))
	(baz [this]
         (println (class this)))
    (baz [this param]
         (println param)))

注意上面 baz 方法有两个入口,分别是不带参数和带一个参数的。下面是调用它:

(let [b (Bar. "some data")]
    (.bar b "param")
    (.baz b)
    (.baz b "baz with param"))
;;some data param
;;user.Bar
;;baz with param

我们使用协议来扩展 Java 类,例如下面,用 extend-protocal 函数,以 String 类来扩展之前的 Foo 协议:

(extend-protocol Foo String
    (bar [this param] (println this param)))

(bar "hello" "there")
;;=> "hello there"

# 2.14 全局变量

Clojure 主要是不变的。为了支持变量,它提供了 STM (Software Transactional Memory) 库。直译作“软件事务性内存”并不好理解,不如就简称为 STM。英文原文可见: http://java.ociweb.com/mark/stm/article.html (opens new window)

STM 可以自动更新全局变量,避免在多线程环境下并行计算导致冲突。

有两种可变类型: atom 和 ref。前者用于单个全局变量数值的更新,后者用于多个更新当作一个整体事务(Transaction)。

例如下面,我们用 atom 定义了一个全局变量 global-val,它的初始值是 nil:

(def global-val (atom nil))

我们用 deref 来访问它。例如下面打印它的值:

(println (deref global-val))
;;=> nil

deref 函数可以简写作 @global-val

(println @global-val)

要改变这个全局变量的值,要用到 reset!swap! 函数。前者是设置一个指定的值,后者是用指定的函数来更新它的值。例如:

(reset! global-val 10)
(println @global-val)
;;=> 10

(swap! global-val inc)
(println @global-val)
;;=> 11

在 Clojure 中有一个约定,函数名以惊叹号结尾,代表此函数要修改可变量。

下面来看看 ref。使用 ref 和 atom 的方法相同,例如下面的代码:

(def names (ref []))

(dosync (ref-set names ["John"])
        (alter names #(if (not-empty %) 
                          (conj % "Jane") 
                          %)))

先定义了一个 names,它的初始值是空向量,然后在 dosync 函数中,定义了一组事务,对它进行操作。

一开始时 names 是空的,在这组事务操作中,先将 names 置为 ["John"],然后用匿名函数对它进行了操作。此匿名函数是一个判断,当参数不为空的时候,用 conj 给它添加 "Jane",否则还保留之前的值。经过这组事务操作后,它的结果是:

user=> (println @names)
;;=> [John Jane]

上面基于内存的事务,很像数据库里面的交易事务的概念。

# 2.15 宏

Clojure 是 LISP 的方言,代码即数据,数据即代码,因此可以编写代码来生成代码,做到极大的灵活性。Clojure 里提供了功能强大的宏(macro),可以用模板的方式来避免编写重复代码。宏将代码当作数据,而不去执行它,经过变换得到想要的结果代码。

宏是在编译之前执行的,即在编译前,宏将展开成为代码,再执行编译。因为这种间接性导致宏很难调试,因此,若普通函数能做到的事情,就别用宏。

例如,下面的示例代码用于登录:

(def session (atom {:user "Bob"}))

(defn load-content []
    (if (:user @session)
        "Welcome back!"
        "please log in"))

每一个函数都需要先在全局变量 session 中判断一下用户是否登录,然后再执行自己的逻辑,这就带来了重复代码。于是,我们定义了下面的一个宏 defprivate:

(defmacro defprivate [name args & body]
    `(defn ~(symbol name) ~args
         (if (:user @session)
             (do ~@body)
             "please log in")))

宏用 defmacro 来定义,defmacro 和 defn 的区别是,它默认不对里面的表达式求值。要对表达式求值,则需要用波浪符号 ~ ,它代表要用它求值来替代,此操作被称作 unquoting。在 (do ~@body) 中的 ~@ 表示将 body 的内容放到这里,输入参数 body 指的是入参函数的主体列表表达式。

反单引号 ` 在这里表示,不对表达式求值而将它当作数据,此操作被称作 syntax-quoting。它和 ~ 的操作相反。

通过 macroexpand-1 函数将宏展开,例如:

(macroexpand-1 '(defprivate foo [greeting] (println greeting)))

我们得到:

(clojure.core/defn foo [greeting] 
    (if (:user (clojure.core/deref user/session)) 
        (do (println greeting)) 
        "please log in"))

上面可见,宏展开的代码会先检查用户是否登录,然后执行其逻辑 (println greeting)

这是使用宏的例子:

(defprivate foo [message] 
    (println message))

(foo "this message is private")

# 2.16 调用 Java

对于 Clojure 的库,要用 use 或 require 来引入。对于 Java 库,要用 :import 来引入。例如:

(ns myns
    (:import java.io.File))

若要引入多个类:

(ns myns
    (:import [java.io File FileInputStream FileOutputStream]
             [java.util List Set]))

引入类后,用 new 或者句点来创建类的对象实例。例如:

(new File ".")
(File. ".")

在下例中,对象实例 f 创建后,用 (.getAbsolutePath f) 来调用它的方法。这相当于 Java 中的 f.getAbsolutePath() 方法调用。

(let [f (File. ".")]
	(println (.getAbsolutePath f)))

如果是静态方法,使用 / 来调用:

(str File/separator "foo" File/separator "bar")
(Math/sqrt 256)

多个函数级联调用,如:

(.getBytes (.getAbsolutePath (File. ".")))  ;相当于Java代码: new File(".").getAbsolutePath().getBytes()

为了便于人阅读,可以用 .. 函数级联,写作:

(.. (File. ".") getAbsolutePath getBytes)

# 2.17 读取器条件

Clojure 有3个官方的方言,分别是基于 JVM (Java Virtual Machine) 的 Clojure,基于 JavaScript 的 ClojureScript,还有基于 .NET CLR (Common Language Runtime) 的 Clojure CLR。它们中 Clojure 内核功能函数都一样,只在其具体平台调用上有区别。

读取器条件(reader conditionals)于1.7版本后引入,用于编写跨平台的代码。例如下面的代码,它用 #? 来表示这是一个读取器条件,后面 :clj 代表的是 Clojure 代码,:cljs 代表的是 ClojureScript 代码。于是,这段代码就可以在 Clojure 和 ClojureScript 上都能编译了:

(defn current-time []
    #?(:clj (.getTime (java.util.Date.))
       :cljs (.getTime (js/Date.))))

即它代表不同的基于 Java 和 JavaScript 的代码:

;; Clojure
(defn current-time []
    (.getTime (java.util.Date.)))

;; ClojureScript
(defn current-time []
    (.getTime (js/Date.)))

另一个是 #?@ 标记,表示在编译前,将内部的集合拼接到外部再编译。

(:require [clojure.string :as string]
          #?@(:clj [[clojure.pprint :refer [pprint]]
                    [clojure.java.io :as io]]
              :cljs [[cljs.pprint :refer [pprint]]
                     [cljs.reader :as reader]]))

即它代表不同的基于 Java 和 JavaScript 的代码:

;; Clojure
(:require [clojure.string :as string]
          [clojure.pprint :refer [pprint]]
          [clojure.java.io :as io])

;; ClojureScript
(:require [clojure.string :as string]
          [cljs.pprint :refer [pprint]]
          [cljs.reader :as reader]])

# 第3章 编程进阶

# 3.1 cons 和 conj

cons 总是返回 seq,它只接收一个参数 x,总是将元素 x 加到前面。它的文档如下:

clojure.core/cons
([x seq])
  Returns a new seq where x is the first element and seq is the rest.

cons 使用示例:

;; always returns a seq
(cons 1 [2 3 4])
;;=> (1 2 3 4)

;; prepend 1 to a list
(cons 1 '(2 3 4 5 6))
;;=> (1 2 3 4 5 6)

;; notice that the first item is not expanded
(cons [1 2] [4 5 6])
;;=> ([1 2] 4 5 6)

conj 返回一个集合(collection),它将元素加到最优的地方(如果是 seq 就加到最前面,若是向量就加到后面)。它的文档如下:

clojure.core/conj
([] [coll] [coll x] [coll x & xs])
  conj[oin]. Returns a new collection with the xs 'added'. 
  (conj nil item) returns (item).
  (conj coll) returns coll. 
  (conj) returns [].
  The 'addition' may happen at different 'places' depending on the concrete type.

conj 使用示例:

;; notice that conjoining to a vector is done at the end
(conj [1 2 3] 4)
;;=> [1 2 3 4]

;; notice conjoining to a list is done at the beginning
(conj '(1 2 3) 4)
;;=> (4 1 2 3)

(conj ["a" "b" "c"] "d")
;;=> ["a" "b" "c" "d"]

;; conjoining multiple items is done in order
(conj [1 2] 3 4)               
;;=> [1 2 3 4]

在 StackOverflow 上有一篇帖子,对比了 consconj 的异同,建议读一下:https://stackoverflow.com/questions/3008411/clojure-cons-seq-vs-conj-list (opens new window)

另外,Tupelo(网址: https://github.com/cloojure/tupelo )是一个第三方包,它提供了许多让 Clojure 更方便使用的函数,值得推荐使用。例如,这里它提供了 prepend 和 append 函数:

(append [1 2] 3  )   ;;=> [1 2 3  ]
(append [1 2] 3 4)   ;;=> [1 2 3 4]

(prepend   3 [2 1])  ;;=> [  3 2 1]
(prepend 4 3 [2 1])  ;;=> [4 3 2 1]

这两个函数总是返回向量(vector)作为结果。

# 3.2 first、next 和 rest

为了从集合中取数,我们可以用 firstsecond 函数。

first 函数的文档:

clojure.core/first
([coll])
  Returns the first item in the collection. Calls seq on its
    argument. If coll is nil, returns nil.

调用 first 函数的示例:

(first '(:alpha :bravo :charlie))  ;;=> :alpha
(first [1 2])                      ;;=> 1
(first [ [1 2] [3 4] ])            ;;=> [1 2]
(first "hello")                    ;;=> \h

对于边界值的情况:

;; nil is a valid (but empty) collection.
(first nil)    ;;=> nil

;; if collection is empty, returns nil.
(first [])     ;;=> nil

;; if first item in collection is nil, returns nil
(first [nil])  ;;=> nil

接下来,我们看看 next 函数:

clojure.core/next
([coll])
  Returns a seq of the items after the first. Calls seq on its
  argument.  If there are no more items, returns nil.

它返回集合中去掉了第一个元素的余下部分。

调用 next 函数的示例:

(next '(:alpha :bravo :charlie))   ;;=> (:bravo :charlie)
(next (next '(:one :two :three)))  ;;=> (:three)

second 顾名思义是取第二个元素:

clojure.core/second
([x])
  Same as (first (next x))

它太简单,不再赘述。注意,没有 third 函数!

rest 也是顾名思义,即去掉第一个元素后的剩余部分。

clojure.core/rest
([coll])
  Returns a possibly empty seq of the items after the first. 
  Calls seq on its argument.

rest 函数总是返回 seq,下面是调用 rest 函数的示例:

(rest [1 2 3 4 5])            ;;=> (2 3 4 5)
(rest ["a" "b" "c" "d" "e"])  ;;=> ("b" "c" "d" "e")

(rest '(1 2 3 4 5))           ;;=> (2 3 4 5)
(rest [1 2 3 4 5])            ;;=> (2 3 4 5)
(rest #{1 2 3 4 5})           ;;=> (2 3 4 5)

(rest {1 nil 2 nil 3 nil 4 nil 5 nil})  ;;=> ([2 nil] [3 nil] [4 nil] [5 nil])

对于边界值:

(rest '())  ;;=> ()
(rest nil)  ;;=> ()

next 是取元素,而 rest 总是返回 seq。下面是它们俩的区别:

(rest [:a])  ;;=> ()
(next [:a])  ;;=> nil

(rest [])    ;;=> ()
(next [])    ;;=> nil

(rest nil)   ;;=> ()
(next nil)   ;;=> nil

# 3.3 loop 和 recur

在 Clojure 中没有循环。但我们用 looprecur 来实现循环。

例如对于阶乘的计算:

一个正整数的阶乘(factorial)是所有小于及等于该数的正整数的积,并且0的阶乘为1。自然数 n 的阶乘写作 n!。亦即阶乘公式是 n!=1×2×3×...×(n-1)×n。阶乘亦可以递归方式定义:0!=1,n!=(n-1)!×n。例如,5! = 1 * 2 * 3 * 4 * 5 = 120。

用 Java 语言,可以很容易用循环来实现:

public static long computeFactorial(long num) {
    long result = 1;
    for (int i = 1; i <= num; i++) {
        result *= i;
    }
    return result;
}

而用 Clojure 语言,要用 looprecur 来实现循环:

(defn factorial [x]
    (loop [i x
           result 1]
        (if (= 1 i)
            result
            (recur (dec i) (* result i)))))

(factorial 5)
;;=> 120

loop 函数有点像 let 函数,它的第一个参数中,将 n 绑定到入参的值 x,将 result 绑定到 1。接下来执行余下的表达式(可以多个)。这里仅有一个,即 if 判断,当 i=1 时返回 result,否则开始 recur (注意,它在这里是最后一个表达式)。 recur 带了2个参数,它将执行跳回到 loop,且以携带的2个参数重新进行对应的绑定(分别给 x 和 result)。

recur 出现在最后一个表达式,这种被成为“尾递归”,它将被编译器(将 Clojure 代码编译成为 JVM 字节码)优化成为循环来执行,从而效率很高。

若真采用递归来实现:

(defn factorial-recursive [x]
     (if (= 1 x)
         1
         (* x (factorial-recursive (dec x)))))

我们可以对比一下效率:

(time (factorial 20))
;;"Elapsed time: 0.0366 msecs"
;;2432902008176640000

(time (factorial-recursive 20))
;;"Elapsed time: 0.067 msecs"
;;2432902008176640000

采用递归调用的后者,明显慢了许多。按上述耗时来计算,后者约是前者耗时的183%,即1.83倍。

# 3.4 映射

映射(map)表示映射关系,即键值对。注意,这里所说的 map 不是高阶函数 map ,而是 Clojure 提供的一种数据结构。

{"key" "value"}
{"key1" "value1" "key2" "value2"}  ;; 不推荐这种使用空格来间隔的写法,建议如下示例
{:name "bobyuan", :age "50"}       ;; 如果写在一行,每一组键值对之间用逗号隔开(更易读)

;; 或者分行写
{:name "spirit"
 :level 32
 :coins 128
 :items ["stagger" "sword" "bow"]}

可以用 get 函数来根据键查找值:

(def mymap1 {:name "bobyuan", :age "50"})

(get mymap1 :name)           ;;=> "bobyuan"
(get mymap1 :gender)         ;;=> nil
(get mymap1 :gender "male")  ;;=> "male"

我们可以直接用键来当作函数调用,例如:

(:name mymap1)           ;;=> "bobyuan"
(:gender mymap1)         ;;=> nil
(:gender mymap1 "male")  ;;=> "male"

使用 assoc 函数来添加键值对。若是出现同名的键,则会替换掉旧的值。例如:

(assoc mymap1 :gender "male")  
;;=> {:name "bobyuan", :age "50", :gender "male"}

(assoc mymap1 :gender "female")  
;;=> {:name "bobyuan", :age "50", :gender "female"}

需要说明的是,上面的操作是得到了一个新的集合,而原来的 mymap1 还是保存之前的值。这种不变性是 Clojure 追求的特性之一。

使用 dissoc 函数来去掉键值对,例如:

(def mymap2 {:name "bobyuan", :age "50", :gender "male"})

(dissoc mymap2 :gender)
;;=> {:name "bobyuan", :age "50"}

可以用 keys 函数和 vals 函数:

(keys mymap2)  ;;=> (:name :age :gender)
(vals mymap2)  ;;=> ("bobyuan" "50" "male")

可以用 zipmap 函数,将两个列表合并成为一个 map:

(zipmap [:a :b :c :d :e] [1 2 3 4 5])  ;;=> {:a 1, :b 2, :c 3, :d 4, :e 5}

;; 4 is not included in the result
(zipmap [:a :b :c] [1 2 3 4])          ;;=> {:a 1, :b 2, :c 3}

;; :c is not included in the result
(zipmap [:a :b :c] [1 2])              ;;=> {:a 1, :b 2}

# 3.5 字符串

双引号中包含的即时字符串,例如 "Hello World"。

常见的对于字符串的操作有:

(count "hello")            ;;=> 5
(empty? "")                ;;=> true
(empty? "hello")           ;;=> false
(str "hello" " " "world")  ;;=> "hello world"
(seq "hello world")        ;;=> (\h \e \l \l \o \space \w \o \r \l \d)

;; 子字符串
(subs "hello world" 3)     ;;=> "lo world"
(subs "hello world" 1 8)   ;;=> "ello wo"

;; 查找
(.indexOf "hello world" "w")      ;;=> 6
(.indexOf "hello world" "x")      ;;=> -1
(.lastIndexOf "hello world" "o")  ;;=> 7
(.lastIndexOf "hello world" "x")  ;;=> -1

;; 字节数组
(def byte-arr (.getBytes "hello" "UTF-8"))
(String. byte-arr "UTF-8")  ;;=> "hello"

Clojure 有个日期和时间的工具包 clojure.string,使用时需要引入:

;; 在 ns 中引入:
(ns myapp.core
  (:require [clojure.string :as str]))

;; 或在 REPL 中引入:
(require '[clojure.string :as str])

接下来即可以用 str 前缀来使用包里面的函数。

(str/blank? " ")                         ;;=> true
(str/trim " hello ")                     ;;=> "hello"

(str/upper-case "hello")                 ;;=> "HELLO"
(str/lower-case "hElLo")                 ;;=> "hello"
(str/capitalize "hElLo")                 ;;=> "Hello"

(str/join ["hello" " " "world"])         ;;=> "hello world"
(str/join "/" ["C:" "Users" "bobyuan"])  ;;=> "C:/Users/bobyuan"
(str/split "C:/Users/bobyuan" #"/")      ;;=> ["C:" "Users" "bobyuan"]

注意,以 #"reg_exp" 格式来写的是正则表达式,例如上面的 #"/"

可以对字符串进行数值解析和格式化:

;; 数值解析
(Long/parseLong "1234")         ;;=> 1234
(Double/parseDouble "3.14159")  ;;=> 3.14159

;; 数值格式化
(format "Hello there, %s" "bob")             ;;=> "Hello there, bob"
(format "Pad with leading zeros %06d" 1234)  ;;=> "Pad with leading zeros 001234"
(format "Value is: %.3f" 2.0)                ;;=> "Value is: 2.000"
(format "Value is: %.3f" (double (/ 5 2)))   ;;=> "Value is: 2.500"
;; To use the percent sign % you need to escape it with %: %%
(format "%.2f %%" 2.5)                       ;;=> "2.50 %"

# 3.6 日期与时间

在 JDK-8 之前,Java 中日期和时间常用 java.util.Date、java.sql.Date、java.sql.Time 等,但这些类却有诸多的弊端,例如: java.util.Date、SimpleDateFormat 是非线程安全的,所有的日期类都是可变的;日期/时间类的定义并不一致,在 java.util 和 java.sql 的包下都含有 Date 类,在开发过程中极易出错; 日期类并不提供国际化,没有时区支持。因此,第三方库 Joda-Time (网址: https://www.joda.org (opens new window) ) 试图解决这些问题,Joda-Time 与 Java8 之前的时间类库相比,具备了很多优点,所以 Joda-Time 成为了事实上的标准的 Java 日期和时间库。

Oracle 在 JDK-8 的开发中,联合 Joda-Time 的开发人员(Stephen Colebourne)一起,设计并引入了新的 java.time API。 在开发新的 Java 应用程序时,日期和时间的处理都应该使用它。这篇文章讲述了缘由:

# 3.6.1 直接调用

Clojure 可以无缝地和 Java 互操作,因此,在代码中可以直接使用 JDK-8 的新 java.time API,例如:

;; 在 REPL 中引入 java.time 库
(import '[java.time LocalDate LocalDateTime]
        '[java.time.format DateTimeFormatter])

;; 自定义的格式
(def jt-date-format-str-10 "yyyy-MM-dd")
(def jt-datetime-format-str-19 "yyyy-MM-dd HH:mm:ss")

(def jt-date-formatter-10 
    (java.time.format.DateTimeFormatter/ofPattern jt-date-format-str-10))
(def jt-datetime-formatter-19 
    (java.time.format.DateTimeFormatter/ofPattern jt-datetime-format-str-19))

;; 自定义中国时区(GMT+8)
(def jt-zone-offset-china (java.time.ZoneOffset/of "+8"))

;; 创建本地日期或时间
(java.time.LocalDate/now)
;;=> #object[java.time.LocalDate 0x2959040e "2022-12-15"]
(java.time.LocalDateTime/now)
;;=> #object[java.time.LocalDateTime 0x4f8b42e "2022-12-15T08:02:48.786533700"]
(java.time.LocalDateTime/of 2022 12 15 8 23 10)
;;=> #object[java.time.LocalDateTime 0x4b5648aa "2022-12-15T08:23:10"]

;; 以中国时区,与Epoch时间互转
(.toEpochSecond (java.time.LocalDateTime/now) jt-zone-offset-china)  ;;=> 1671062629
(java.time.LocalDateTime/ofEpochSecond 1671062629 0 jt-zone-offset-china)
;;=> #object[java.time.LocalDateTime 0x17540325 "2022-12-15T08:03:49"]

;; 解析
(java.time.LocalDate/parse "2011-12-03" jt-date-formatter-10)
;;=> #object[java.time.LocalDate 0x3f0681dc "2011-12-03"]
(java.time.LocalDateTime/parse "2011-12-03 10:15:30" jt-datetime-formatter-19)
;;=> #object[java.time.LocalDateTime 0x243a6df0 "2011-12-03T10:15:30"]

;; 格式化
(.format (java.time.LocalDate/now) jt-date-formatter-10)          ;;=> "2022-12-15"
(.format (java.time.LocalDateTime/now) jt-datetime-formatter-19)  ;;=> "2022-12-15 08:05:33"

# 3.6.2 Clojure.Java-Time

若您的 JDK 是 JDK-8 或者以上,应该使用新的 java.time API。若您的 Clojure 应用程序只想运行在 JVM 上(不需要在 ClojureScript 环境上运行),可以考虑对 java.time API 封装的第三方包 Clojure.Java-Time (网址: https://github.com/dm3/clojure.java-time (opens new window) )。使用此包可以让您的代码更符合 LISP,以便尽量减少使用 Java 互操作调用 java.time API。

使用 lein 新建一个项目,并进入到此文件夹。

lein new app clj-java-time-example
cd clj-java-time-example

修改项目的配置文件 project.clj,添加对 clojure.java-time 的依赖:

:dependencies [[org.clojure/clojure "1.11.1"]
               [clojure.java-time "1.1.0"]]

下载依赖包,并启动 REPL:

lein deps
lein repl

于是我们可以在这个 REPL 中使用它。屏幕输出示例如下:

clj-java-time-example.core=> (require '[java-time.api :as jt]
                        #_=>          'java-time.repl)
nil
clj-java-time-example.core=> (jt/local-date)
#object[java.time.LocalDate 0x7ef8d6c "2022-12-14"]
clj-java-time-example.core=> (jt/local-date-time)
#object[java.time.LocalDateTime 0x36aeb106 "2022-12-14T22:29:52.262860300"]

下面我们来看看此包里面常见的一些函数使用。

;; 定义今天和现在
(def today (jt/local-date))
(def now (jt/local-date-time))

;; 明天
(jt/plus today (jt/days 1))   ;;=> #object[java.time.LocalDate 0x29a5ea14 "2022-12-15"]

;; 昨天
(jt/minus today (jt/days 1))  ;;=> #object[java.time.LocalDate 0x4166f472 "2022-12-13"]

;; 取值
(jt/as (jt/local-date 2015 9 28) :year)  ;;=> 2015
(jt/as (jt/local-date 2015 9 28) :year :month-of-year :day-of-month)  ;;=> (2015 9 28)

;; 解析
(jt/local-date "yyyy-MM-dd" "2012-02-29")  
;;=> #object[java.time.LocalDate 0x762b85f7 "2012-02-29"]
(jt/local-date-time "yyyy-MM-dd HH:mm:ss" "2012-02-29 19:23:57")  
;;=> #object[java.time.LocalDateTime 0x4cef7883 "2012-02-29T19:23:57"]

;; 格式化
(jt/format "yyyy-MM-dd" today)         ;;=> "2022-12-14"
(jt/format "yyyy-MM-dd HH:mm:ss" now)  ;;=> "2022-12-14 22:55:05"

本例的代码在配套资源的 ClojureDateTime 文件夹中。

# 3.6.3 clj-time

Clojure 有个日期和时间的第三方工具包 clj-time (网址: https://github.com/clj-time/clj-time (opens new window) ),封装了 Joda-Time 库(网址: https://www.joda.org/joda-time/ (opens new window) )。若您使用的是 JDK-8 之前的 JDK,可以考虑使用它。

使用 clj-time 库前需要先引入它:

;; 在 ns 中引入:
(ns myapp.core
  (:require [clj-time.core :as t]
            [clj-time.format :as f]
            [clj-time.local :as l]
            [clj-time.coerce :as c]))

;; 或在 REPL 中引入:
(require '[clj-time.core :as t]
         '[clj-time.format :as f]
         '[clj-time.local :as l]
         '[clj-time.coerce :as c])

接下来即可以用前缀来使用包里面的函数。

;; 定义日期或时间
(def example-date (t/date-time 2016 1 1))
;;=> #clj-time/date-time "2016-01-01T00:00:00.000Z"

;; 语法: (t/date-time year month date hour minute second millisecond)
(def example-time (t/date-time 2012 2 29 19 23 57))
;;=> #clj-time/date-time "2012-02-29T19:23:57.000Z"

;; 加法
(t/plus example-date (t/months 1))    ;;=> #clj-time/date-time "2016-02-01T00:00:00.000Z"
(t/plus example-date (t/years 1))     ;;=> #clj-time/date-time "2017-01-01T00:00:00.000Z"

;; 减法
(t/minus example-date (t/days 1))     ;;=> #clj-time/date-time "2015-12-31T00:00:00.000Z"
(t/minus example-date (t/hours 12))   ;;=> #clj-time/date-time "2015-12-31T12:00:00.000Z"

;; 取值
(t/year example-date)  ;;=> 2016
(t/hour example-time)  ;;=> 19

解析,转换,格式化:

;; 取当前时间
(l/local-now)  ;;=> #clj-time/date-time "2022-12-14T13:19:45.756Z"

;; 解析时间字符串
(l/to-local-date-time "1986-10-14T04:03:27.246Z") 
;;=> #clj-time/date-time "1986-10-14T04:03:27.246Z"

;; Unix 时间的转换
(c/to-long example-time)     ;;=> 1330543437008
(c/from-long 1330543437008)  ;;=> #clj-time/date-time "2012-02-29T19:23:57.008Z"

;; 自定义格式
(def custom-formatter-datetime-19 (f/formatter-local "yyyy-MM-dd HH:mm:ss"))

;; 用自定义格式来解析
(f/parse-local custom-formatter-datetime-19 "2012-02-29 19:23:57")
;;=> #object[org.joda.time.LocalDateTime 0x644360cd "2012-02-29T19:23:57.000"]

;; 用自定义格式来格式化
(f/unparse custom-formatter-datetime-19 (l/local-now))  ;;=> "2022-12-14 21:35:26"

# 3.7 异常

在 Java 的异常继承树中,Throwable 是顶层父类,它的子类 Error 是系统级别的不可恢复的错误,例如 VirtualMachineError 或 OutOfMemoryError,这些错误发生了我们也没法做什么。它的另一个子类 Exception 才是我们经常看到的,应用程序级别的错误。我们经常看到定制的异常,就是以 Exception 作为父类。Exception 里面有自带的一些异常,例如我们常见的 IOException,RuntimeException 等。

Throwable
    ├─Error
    │  ├─AssertionError
    │  ├─OutOfMemoryError
    │  └─VirtualMachineError
    └─Exception
        ├─CloneNotSupportedException
        ├─InterruptedException
        ├─IOException
        │  └─FileNotFoundException
        └─RuntimeException
            ├─ArithmeticException
            ├─NullPointerException
            └─NumberFormatException

在 Clojure 中,使用 try 函数来捕获异常,用 ex-message 来获得异常的信息,例如:

(try
    (/ 1 0)
    (catch ArithmeticException e
        (println (ex-message e))))
;;=> Divide by zero

try 函数下面可以有多条表达式,也可以有多个捕获异常的表达式。例如:

(try
    (/ 1 0)
    (+ 1 nil)
    (catch ArithmeticException e
        (println "Weird arithmetics"))
    (catch NullPointerException e
        (println "You've got a null value"))
    (catch Throwable e
        (println "I catch everything!"))
    (finally (println "This is our final block")))
;;=> Weird arithmetics
;;=> This is our final block

我们可以构建一个异常并抛出:

(let [e (new Exception "Something is wrong!")]
    (throw e))

若希望异常里面包含更多信息,可以使用 ex-info 函数:

(throw (ex-info
        "Cannot fetch user."
        {:user-id 5
         :http-status 404
         :http-method "GET"
         :http-url "https://host.com/users/5"}))

然后捕获它,获取信息:

(try
    (get-user 5)
    (catch clojure.lang.ExceptionInfo e
        (let [{:keys [http-method http-url]} (ex-data e)]
            (format "HTTP error: %s %s" http-method http-url))))
;;=> HTTP error: GET https://host.com/users/5

若想要打印异常信息:

(require '[clojure.stacktrace :as trace])

(trace/print-throwable e)
(trace/print-stack-trace e)

关于 Exception,这里有两篇文章对它进行了详细描述,推荐读者阅读。

# 3.8 日志

在 Clojure 中,可以使用 tools.logging 包(网址: https://github.com/clojure/tools.logging (opens new window) )来写日志。

Clojure 会在依赖包里面找日志模块的实现,依次如 slf4j, commons-logging, log4j 2, log4j, java.util.logging。都找不到则最后会用标准的 java.util.logging。这些第三方日志模块中, Logback 是比较流行的一个,使用它需要在项目的 project.clj 中先引入依赖:

[org.clojure/tools.logging "1.2.4"]
[ch.qos.logback/logback-classic "1.4.5"]

在项目的资源文件夹(resources)中创建配置文件 logback.xml,例如:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <charset>UTF-8</charset>
      <pattern>%date %-5level [%file:%line] - %msg%n%ex{full}</pattern>
    </encoder>
  </appender>
  <root level="DEBUG">
    <appender-ref ref="STDOUT"/>
  </root>
</configuration>

我们先在 REPL 中引入这个包:

(require '[clojure.tools.logging :as log])

日志输出有几个级别,输出错误日志时还可以把异常带进去:

(log/trace "a trace message")
(log/debug "a debug message")
(log/info "an info message")
(log/warn "a warning message")
(log/error "an error message")

(log/error (RuntimeException. "something wrong happend") "HTTP Error")

# 3.9 文件IO

假设我们有一个文本文件 C:\Users\bobyuan\example.txt,它的内容是:

this is an example
这是一个例子

先查看这个文件是否存在:

(.exists (clojure.java.io/file "C:/Users/bobyuan/example.txt"))
;;=> true

要将文本文件的全部内容读取到一个字符串中,可以用 slurp 函数:

(slurp "C:/Users/bobyuan/example.txt")
;;=> "this is an example\r\n这是一个例子\r\n"

注意: 若您看到的中文显示是乱码,则需要将终端窗口的字符集设置为 UTF-8。对于简体中文的 Windows 操作系统,它默认的字符集是 GBK,从而导致了乱码。

若一次只读一行,可以用 clojure.java.io/reader 方法。

(defn read-text-file []
    (with-open [rdr (clojure.java.io/reader "C:/Users/bobyuan/example.txt")]
        (reduce conj [] (line-seq rdr))))

(read-text-file)
;;=> ["this is an example" "这是一个例子"]

若要写文件,可以用 clojure.java.io/writer 方法。例如我们在这个文件尾部增加一行:

(defn write-text-file []
    (with-open [w (clojure.java.io/writer "C:/Users/bobyuan/example.txt" :append true)]
        (.write w (str "hello" "world"))))

(write-text-file)
;;=> nil
(read-text-file)
;;=> ["this is an example" "这是一个例子" "helloworld"]

# 3.10 单元测试

测试是保障软件质量最重要的环节之一。在 lein 生成的项目文件夹结构中,test 文件夹内就是放置单元测试代码的地方。

例如 my-stuff 项目中, test/my_stuff/core_test.clj 文件的内容如下:

(ns my-stuff.core-test
  (:require [clojure.test :refer :all]         ;; 在此引入测试包内的全部函数
            [my-stuff.core :refer :all]))      ;; 在此引入主程序里自己写的全部函数

;; 这便是一个测试示例
(deftest a-test
  (testing "FIXME, I fail."
    (is (= 0 1))))

从上例可以看到,我们用 deftest 函数来定义一个测试,在 testing 中便是一个测试案例,它使用 is 函数来做一个断言,若后面的表达式求值为真,则表示通过,否则表示失败。

上面的示例代码,它给出了一个失败的测试案例,下面到项目的根路径上运行测试:

lein test

屏幕输出示例:

lein test my-stuff.core-test

lein test :only my-stuff.core-test/a-test

FAIL in (a-test) (core_test.clj:7)
FIXME, I fail.
expected: (= 0 1)
  actual: (not (= 0 1))

Ran 1 tests containing 1 assertions.
1 failures, 0 errors.
Subprocess failed (exit code: 1)

从上面的提示可以看到具体的失败信息,我们把测试的代码改成: (is (= 1 1)),这样就能够满足断言。再运行一次测试,屏幕输出示例:

lein test my-stuff.core-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

这样,我们的单元测试就可以通过了。

在 Clojure 中鼓励使用纯函数。纯函数仅依赖于输入参数,返回预期的结果,在运行过程中不会去碰任何其他的东西,例如写日志、屏幕输入输出、访问数据库等。这些纯函数作为基础元件,经过充分测试,确保它能够正常工作,最后在用的时候再把它们组装起来(只在这里产生了依赖、副作用等)。

再例如,下面的一个求均值的函数:

(defn my-average [a b]
    (/ (+ a b) 2.0))

为它编写单元测试如下:

(deftest my-average-test
  (testing "Unit test of my-average function"
    (is (= 4.0 (my-average 3 5)))))

测试通过了,我们再增加一些断言,考虑边界值:

(deftest my-average-test
  (testing "Unit test of my-average function"
    (is (= 4.0 (my-average 3 5)))
    (is (= 0.0 (my-average 0 0)))
    (is (= (* 1.0 Long/MAX_VALUE) (my-average Long/MAX_VALUE Long/MAX_VALUE)))))

测试失败,提示: java.lang.ArithmeticException: long overflow

原因是实现方法中先将计算 a+b 导致了溢出。我们可以修改实现为:

(defn my-average [a b]
    (+ (/ a 2.0) (/ b 2.0)))

从而测试通过。

上述示例从一个侧面说明,简单的代码实现也需要通过单元测试来验证。根据经验判断它能行,而不进行完备的测试,是会带来潜在风险隐患的。

关于两个无符号整数求均值,避免溢出,还真不简单。这里有一篇文章进行了深入的研究,感兴趣的可以读一下。

# 3.11 打包部署

最简单的情况下,我们将 Clojure 项目按下面的方式打包:

lein uberjar

它会得到一个集成了依赖的发行版: target\default+uberjar\my-stuff-0.1.0-SNAPSHOT-standalone.jar

我们可以在生产环境中,这样运行:

java -jar target/default+uberjar/my-stuff-0.1.0-SNAPSHOT-standalone.jar

我们也可以用 Docker 镜像方式来构建,这里不深入讨论它。

# 3.12 编码风格

Clojure 的编码风格,可以参考这篇文章: https://grishaev.me/en/clojure-guide/ (opens new window)

# 第4章 Web应用

Luminus (网址是: https://luminusweb.com/ (opens new window) )是一个 Clojure 编写 Web 应用程序的模板。这个项目的作者是居住在加拿大多伦多的 Dmitri Sotnikov,他的书籍《Web Development with Clojure, Third Edition》就是以 Luminus 为基础,介绍在 Clojure 中构建 Web 应用程序。

注意: Kit (网址: https://kit-clj.github.io/ (opens new window) )是 Luminus 模板的继任者,在本书写作的2022年末,还处于 Alpha 阶段。有兴趣的读者可以关注。

本章的 Web 应用示例都是以 Luminus 为模板建立的。在项目的根路径打开命令行窗口,可以启动 REPL:

lein repl

REPL 启动后,会进入用户的命名空间 env/dev/clj/user.clj,这里提供了一些辅助函数如 start, stop, 和 restart ,可用于启动,停止和重启 Web 服务。例如,启动 HTTP 服务和其他组件(如数据库等):

(start)

您可以看到日志中提示 INFO luminus.http-server - server started on port 3000, 即 HTTP 服务已经启动并侦听在 3000 端口。可以打开浏览器访问: http://localhost:3000/

本章的示例程序都在 ClojureWebApp 文件夹中。

# 4.1 计数器示例

下面我们以 Luminus 来开发一个最简单的 Web 应用。它的名字叫 counterwebapp,它的页面显示一个计数器,当页面刷新一下,就会加一。它的数据存储在数据库中,这里使用 H2 本地文件数据库。

先创建这个项目:

lein new luminus counterwebapp +h2 +immutant

上面的命令是以 luminus 为模板,创建 counterwebapp 应用程序,添加 H2 本地文件型数据库,和 immutant (这是一个类似于 Tomcat 的内置 Web Container)。

项目建好后,即可以运行:

# 初始化数据库
lein run migrate

# 运行
lein run

在日志中,可以看到 INFO luminus.http-server - starting HTTP server on port 3000 的提示,它说明在3000端口启动了一个 HTTP server。您当然也可以指定端口,例如:

lein run --port 8080

在启动日志里面,还可以看到 INFO counterwebapp.nrepl - starting nREPL server on port 7000,它说明在7000端口启动了一个 nREPL server。我们可以连接到里面,进行调试,例如:

lein repl :connect localhost:7000

下面我们来编写应用逻辑。

resources/migrations/ 中有2个 SQL 文件:

  1. 20221215150858-add-users-table.up.sql
  2. 20221215150858-add-users-table.down.sql

这两个文件的命名以时间戳开始,分别以 table.up.sql 和 table.down.sql 结尾。前者代表数据库升级(增加新表),而后者代表数据库降级(删除添加的表)。

20221215150858-add-users-table.up.sql 中,我们创建一个新表 counters:

CREATE TABLE counters (
  counter_id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
  counter_name VARCHAR(100) NOT NULL UNIQUE,
  counter_value BIGINT UNSIGNED NOT NULL DEFAULT 0
);

而在 20221215150858-add-users-table.down.sql中,我们把这个表删除:

DROP TABLE counters;

对数据库表的增删改查,在 resources/sql/queries.sql 里面定义。下面是对于 counters 表的定义:

-- :name create-counter! :! :n
-- :doc creates a new counter record
INSERT INTO counters
(counter_name, counter_value)
VALUES (:counter_name, :counter_value)


-- :name get-counter-by-id :? :1
-- :doc retrieves a counter record given the id
SELECT * FROM counters
WHERE counter_id = :counter_id

-- :name get-counter-by-name :? :1
-- :doc retrieves a counter record given the name
SELECT * FROM counters
WHERE counter_name = :counter_name


-- :name update-counter-by-id! :! :n
-- :doc updates an existing counter record
UPDATE counters
SET counter_value = :counter_value, counter_name = :counter_name
WHERE counter_id = :counter_id

-- :name update-counter-by-name! :! :n
-- :doc updates an existing counter record
UPDATE counters
SET counter_value = :counter_value
WHERE counter_name = :counter_name


-- :name delete-counter-by-id! :! :n
-- :doc deletes a counter record given the id
DELETE FROM counters
WHERE counter_id = :counter_id

-- :name delete-counter-by-name! :! :n
-- :doc deletes a counter record given the name
DELETE FROM counters
WHERE counter_name = :counter_name

在上面我们直接用 SQL 来定义查询函数,不用 ORM (Object Relational Mapping) 框架,因为 Clojure 里认为 SQL 就是最好的也是最直接的 DSL (Domain Specific Language)。对比在 Java 世界里,我们需要用 Hibernate 或 MyBatis 来进行对象和数据库的映射,这产生了更多的学习成本。

上述的 SQL 中,以注释给出了函数的定义。conman 包(网址是: https://github.com/luminus-framework/conman (opens new window) )载入 queries.sql 即可生成对应的查询函数,供 Clojure 上层代码调用。

在 Luminus 中也没有 MVC (Model-View-Controller) 的概念,它并不强制你要遵循某个限定的规范。为了让业务逻辑分层实现,便于组织代码,我创建一个 service 包,类似于 Java 世界里那样,在里面实现业务层的逻辑。

service/counter.clj

(ns counterwebapp.service.counter
  (:require
   [counterwebapp.db.core :refer [*db*] :as db]
   [next.jdbc :as jdbc]))

(def COUNTER_NAME "WebCounter1")

(defn get-next-counter-value
  "Get the next counter value (will increase by 1)."
  []
  (jdbc/with-transaction [t-conn *db*]
    ;; get current counter value from DB, create the counter if needed.
    (let [record-in-db (db/get-counter-by-name t-conn {:counter_name COUNTER_NAME})
          counter-value (if (nil? record-in-db)
                                  (do
                                    (db/create-counter! t-conn {:counter_name COUNTER_NAME, 
                                                                :counter_value 0})
                                    0)
                                  (get record-in-db :counter_value))
          next-counter-value (inc counter-value)]
      ;; update DB and then return the increated new value.
      (db/update-counter-by-name! t-conn {:counter_name COUNTER_NAME, 
                                          :counter_value next-counter-value})
      next-counter-value)))

代码中,先定义了一个常量 COUNTER_NAME,然后在 get-next-counter-value 函数中,实现本应用最核心的业务逻辑。它调用的时候不需要提供输入参数,其执行逻辑是:

  1. 先从数据库中用 db/get-counter-by-name 函数查得结果绑定到 record-in-db,然后判断它是否为空(nil)。若为空,则需要在数据库中调用 db/create-counter! 函数先创建这个计数器,并以 0 作为初始值绑定到 counter-value;若不为空,则从刚才数据库中查询的结果中,取 :counter_value 并绑定到 counter-value。
  2. 让 counter-value 自增1后,绑定到 next-counter-value
  3. 然后调用 db/update-counter-by-name! 函数将 next-counter-value 保存到数据库中。
  4. 最后,以 next-counter-value 作为函数最终的返回值。

接下来,在路由中添加 /counter 。即 Web 应用程序浏览器的 URL 访问路径。

src/clj/counterwebapp/routes/home.clj

(defn counter-page [request]
  (layout/render request "counter.html" 
                 {:counter_value (counter/get-next-counter-value)}))

(defn home-routes []
  [""
   {:middleware [middleware/wrap-csrf
                 middleware/wrap-formats]}
   ["/" {:get home-page}]
   ["/counter" {:get counter-page}]      ;; 这里添加了 /counter 的路由,用上面的函数来响应Web页面
   ["/about" {:get about-page}]])

最后,在 HTML 模板中,以 来引用服务器传递过来的值(见 counter-page 函数中,提供的映射中的 :counter_value,它来自于业务层的 get-next-counter-value 函数调用)。

resources/html/counter.html

{% extends "base.html" %}
{% block content %}
  <div class="content">
  WebCounter: {{counter_value}}
  </div>
{% endblock %}

上面的 HTML 使用了 {% ... %} 的标记,这来自于模板引擎 selmer (网址是: https://github.com/yogthos/Selmer (opens new window) )。这个第三方包借鉴了 Django 中的模板引擎的实现,比较容易学习和使用。

本例提供了完善的单元测试,例如:

test/clj/counterwebapp/db/core_test.clj

(def TEST_COUNTER_NAME "TestCounter1")

(deftest test-counters-create-update
  (jdbc/with-transaction [t-conn *db* {:rollback-only true}]   ;; 这里让它回滚,因此不影响写入到数据库的值。
    ;; create.
    (is (= 1
           (db/create-counter! t-conn {:counter_name TEST_COUNTER_NAME
                                       :counter_value 123})))
    ;; get-by-name
    (let [rec-in-db (db/get-counter-by-name t-conn {:counter_name TEST_COUNTER_NAME})]
      (is (= {:counter_name TEST_COUNTER_NAME
              :counter_value 123}
             (select-keys rec-in-db [:counter_name :counter_value])))
      ;; get-by-id.
      (when-let [rec-id (get rec-in-db :counter_id)]
        (is (= rec-in-db
               (db/get-counter-by-id t-conn {:counter_id rec-id})))))

    ;; update-by-name
    (is (= 1
           (db/update-counter-by-name! t-conn {:counter_name TEST_COUNTER_NAME
                                               :counter_value 456})))
    ;; get-by-name
    (let [rec-in-db (db/get-counter-by-name t-conn {:counter_name TEST_COUNTER_NAME})]
      (is (= {:counter_value 456}
             (select-keys rec-in-db [:counter_value])))
      ;; update-by-id.
      (when-let [rec-id (get rec-in-db :counter_id)]
        (is (= 1
               (db/update-counter-by-id! t-conn {:counter_name TEST_COUNTER_NAME
                                                 :counter_value 789
                                                 :counter_id rec-id})))
        ;; get-by-id.
        (is (= {:counter_value 789}
               (select-keys
                (db/get-counter-by-id t-conn {:counter_id rec-id})
                [:counter_value])))))))

上面的测试中,我们有意让函数在执行完后让数据库回滚,这可以让测试数据不影响到数据库。这里的代码虽然有点长,但每一段都是一个相对独立的断言,用来测试函数的功能是否正常。

使用 lein test 来执行自动化测试,结果测试案例都成功:

lein test counterwebapp.handler-test

Ran 5 tests containing 19 assertions.
0 failures, 0 errors.

至此,我们有信心它能够正常工作,开始打包:

lein clean
lein uberjar

这将生成一个发行版 jar 包: target/default+uberjar/counterwebapp.jar

在生产环境上(假设是 Linux),我们可以这样部署运行。假设我们创建文件夹 /counterwebapp 并把 counterwebapp.jar 复制到文件夹中。

将数据库文件改名并复制到: /counterwebapp/db/h2/counterwebapp_prod.db

/counterwebapp
        │  counterwebapp.jar
        │
        ├─db
        │  └─h2
        │          counterwebapp_prod.db.mv.db
        │          counterwebapp_prod.db.trace.db
        │
        └─log

设置环境变量:

export database-url="jdbc:h2:/counterwebapp/db/h2/counterwebapp_prod.db"

然后运行:

cd /counterwebapp
java -jar counterwebapp.jar

屏幕运行示例如下:

1625  INFO  counterwebapp.env -
-=[counterwebapp started successfully]=-
1912  INFO  luminus.http-server - starting HTTP server on port 3000
1932  INFO  org.xnio - XNIO version 3.3.6.Final
2047  INFO  org.projectodd.wunderboss.web.Web - Registered web context /
2048  INFO  counterwebapp.core - #'counterwebapp.db.core/*db* started
2048  INFO  counterwebapp.core - #'counterwebapp.handler/init-app started
2048  INFO  counterwebapp.core - #'counterwebapp.handler/app-routes started
2049  INFO  counterwebapp.core - #'counterwebapp.core/http-server started
2049  INFO  counterwebapp.core - #'counterwebapp.core/repl-server started

它表示,counterwebapp 应用程序正常运行,HTTP server 侦听在 3000 端口。

打开浏览器,用 http://server_ip:3000/counter 来访问。每次网页刷新,即可以看到此计数器自增。

另一个范例,Luminus 的网站上有一个示例应用 guestbook,网址是: https://luminusweb.com/docs/guestbook (opens new window)

作为 Luminus 官方的示例,值得照着上述网站的说明去走一遍。

# 4.2 使用htmx示例

Htmx (网址: https://htmx.org (opens new window) )是一个第三方 JavaScript 库,用于执行 AJAX 请求,Htmx 可以让后端开发者用简单的标记建立现代的(前后端分离)用户界面。这个库的大小仅为约10KB,而且是无依赖性的,不需要任何其他的 JavaScript 包来运行。

在 Clojure 里有一个第三方 ctmx 库(网址: https://github.com/whamtet/ctmx (opens new window) )对它进行了封装,方便用来开发 Web 应用程序。

下面,我们创建一个新的 Web 应用程序 helloctmx:

lein new luminus helloctmx +http-kit +ctmx

将项目运行起来:

lein run

注意:prject.clj 中,若将 ctmx 的版本从 1.4.3 更新到最新版本 1.4.8,运行此项目会报错:

java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Number (java.lang.String and java.lang.Number are in module java.base of loader 'bootstrap')
     at clojure.lang.Numbers.inc(Numbers.java:139)

此错误我们可以自行修复:打开 helloctmx/routes/home.clj:^int num-clicks 改为 :^long num-clicks 即可。

据 ctmx 的作者 Matthew Molloy 回答,在新版本 1.4.8 中移除了对 ^int 的支持,导致了上述报错。按照业界普遍认同的规则,版本号一般以 major.minor.patch 分3部分构成,最尾部的版本号“patch”更新仅是缺陷修复,必须保证兼容性。作者意识到了这一点,后续应该会有相应的更新。

若读者看到的 ctmx 最新版本不是上述情况,或许不会碰到上述错误。

打开浏览器访问 http://localhost:3000 我们可以看到一行文字 “You have clicked me 0 times.”,用鼠标点击这行文字,我们可以看到计数器在自增(不需要刷新 Web 页面)。

全部的逻辑都在 home.clj 中,这里因为页面简单,都是在代码中用 hiccup 包来生成的 html。代码如下:

helloctmx/routes/home.clj

(ns helloctmx.routes.home
  (:require
    [ctmx.core :as ctmx]                           ;; 引入 ctmx
    [ctmx.render :as render]                       ;; 引入 ctmx.render
    [hiccup.page :refer [html5]]))                 ;; hiccup 包用于生成 html

(defn html-response [body]
  {:status 200
   :headers {"Content-Type" "text/html"}
   :body body})

(defn html5-response
  ([body]
   (html-response
    (html5
     [:head
      [:meta {:name "viewport"
              :content "width=device-width, initial-scale=1, shrink-to-fit=no"}]]
     [:body (render/walk-attrs body)]
     [:script {:src "https://unpkg.com/htmx.org@1.5.0"}]))))

(ctmx/defcomponent ^:endpoint root [req ^:long num-clicks]
  [:div.m-3 {:hx-post "root"
             :hx-swap "outerHTML"
             :hx-vals {:num-clicks (inc num-clicks)}}
   "You have clicked me " num-clicks " times."])

(defn home-routes []
  (ctmx/make-routes
   "/"
   (fn [req]
     (html5-response
      (root req 0)))))

在上面的代码中, ctmx/defcomponent 宏定义了 root 函数, 它展开包含2部分:

  1. /root 为 URL 路径的响应函数,它可以处理 http 请求。在后面行 {:hx-post "root" ... 就是定义要向它提交 POST 请求。
  2. 它也是一个普通函数。在后面 home-routes 函数的尾部,就有当作函数直接调用: (root req 0)

这个 root 内的实现,即是将入参 num-click 自增,并返回一段 div 。在 div 标记中, hx-post 属性表示它的鼠标点击会触发 POST 请求到 /root,得到的 div 片段将替换掉当前的这个 div 片段。从而实现了局部页面更新,因此看到了计数器自增。

下面是从浏览器中查看 HTML 源码可以看到的:

<!DOCTYPE html>
<html>
    <head>
        <meta content="width=device-width, initial-scale=1, shrink-to-fit=no" name="viewport"/>
    </head>
    <body>
        <div class="m-3" hx-post="root" hx-swap="outerHTML" hx-vals="{&quot;num-clicks&quot;:1}">
            You have clicked me 0 times.
        </div>
    </body>
    <script src="https://unpkg.com/htmx.org@1.5.0"/>
</html>

要理解上面的 HTML 页面是怎样实现局部更新的,需要掌握 htmx 的工作原理。这里从略,具体请参考 htmx 的官方文档(网址: https://htmx.org/docs/ (opens new window) )。

# 4.3 增删改查示例

Web 应用最常见的功能就是对数据库表的增删改查(简称 CRUD,Create-Read-Update-Delete)。下面的例子,就将以本地文件型 H2 数据库的增删改查,以 luminus 模板加 ctmx 来实现,供读者开发类似应用程序作为参考。

首先用 lein 创建应用程序 crudwebappv1,然后 cd 进入到这个项目文件夹中:

lein new luminus crudwebappv1 +http-kit +mysql +h2

resources/migrations/ 中的 xxxx-add-users-table.up.sql 中,添加 employees 表:

CREATE TABLE employees (
    emp_id INT(20) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    full_name VARCHAR(50),
    position VARCHAR(50),
    office VARCHAR(50),
    email VARCHAR(30),
    gender VARCHAR(1),
    is_active BOOLEAN,
    salary NUMBER(12,2),
    birthday DATE,
    notes VARCHAR(200),
    time_created DATETIME DEFAULT NOW() NOT NULL,
    time_updated DATETIME DEFAULT NOW() NOT NULL
);

它的增删改查操作在 resources/sql/queries.sql 中定义。例如它的代码片段:

-- :name create-employee! :! :n
-- :doc creates a new employee record by generating the id automatically.
INSERT INTO employees
(full_name, position, office, email, gender, is_active, salary, birthday, notes)
VALUES (:full_name, :position, :office, :email, :gender, :is_active, :salary, :birthday, :notes)

-- :name update-employee! :! :n
-- :doc updates an existing employee record given the emp_id.
UPDATE employees
SET full_name = :full_name, position = :position, office = :office, email = :email, gender = :gender, 
    is_active = :is_active, salary = :salary, birthday = :birthday, notes = :notes, time_updated = NOW()
WHERE emp_id = :emp_id

-- :name get-all-employees :? :*
-- :doc retrieves all employee records.
SELECT * FROM employees
ORDER BY time_updated DESC

...

建立 service/employee.clj 文件,里面编写了一些业务处理的函数,供上层 Web 应用处理时调用。例如下面的代码片段,定义了 get-all-employees 用来查询全部的雇员记录,以及 save-employee! 用来保存雇员记录:

(defn get-all-employees
  "Get all employees from DB, return an empty list if no record found."
  []
  (jdbc/with-transaction [t-conn *db*]
    (if-let [records-in-db (db/get-all-employees t-conn {})]
      records-in-db
      [])))

(defn save-employee!
  "Save employee to DB by given properties (provided in params map).
   Perform update operation if ID is given, or create operation otherwise."
  [params]
  (assert (not (clojure.string/blank? (:full_name params))) "full_name cannot be empty.")
  (jdbc/with-transaction [t-conn *db*]
    (if (nil? (get params :emp_id))
      (db/create-employee! t-conn params)
      (db/update-employee! t-conn params))))

...

我们将以这个数据库表为基础,来建立对它增删改查的 Web 页面。

routes/employee.clj 中,定义了 Web 页面的请求响应处理逻辑。

(defn employee-list-page [request]
  (let [employees (employee/get-all-employees)]
    (layout/render request "employee.html" {:employees employees})))

...

(defn employee-routes []
  [""
   {:middleware [middleware/wrap-csrf
                 middleware/wrap-formats]}
   ["/initdb" {:get initdb-page}]
   ["/employee" {:get employee-list-page}]
   ["/employee_create" {:get employee-create-page
                        :post employee-create-action!}]
   ["/employee_update" {:get employee-update-page
                        :post employee-update-action!}]
   ["/employee_delete" {:get employee-delete-action!}]])

我们可以看到: /initdb 的 GET 请求,将用来做数据初始化(在数据表里面插入一些测试数据),/employee 的 GET 请求,将用来展示全部 employee 的列表。

本例的代码在配套资源的 ClojureWebApp\crudwebappv1 文件夹中。具体的逻辑还请看示例代码。注意,本示例使用了 AdminLTE (网址: https://github.com/ColorlibHQ/AdminLTE/releases (opens new window) ),它是一个基于 Bootstrap 4 的网页模板。因这个模板比较大,您需要按照 resources\addons\adminlte\README.txt 中的描述,自行下载并解包所需要的文件到那里。

要运行本例,先初始化数据库:

lein run migrate

完成后,我们可以在项目文件夹中找到: crudwebappv1_dev.db.mv.dbcrudwebappv1_dev.db.trace.db 两个文件,这是 H2 的数据库文件。

运行它:

lein run

打开浏览器,访问首页 http://localhost:3000

您可以在首页开头找到初始化数据库的链接 (initialize database) ,和访问增删改查页面的链接 (employee CRUD demo) 。

# 4.4 RESTful API示例

当代的 Web 应用一般都是前后端分离,即后端提供 RESTful API 供前端调用,以 Swagger 来作为 API 在线文档。下面的例子,就将以本地文件型 H2 数据库的一个表,以 luminus 模板来实现,供读者开发类似应用程序作为参考。

首先用 lein 创建应用程序 swagv1,然后 cd 进入到这个项目文件夹中:

lein new luminus swagv1 +h2 +postgres +swagger

注意: 上面引入了 "postgres",增加了 PostgreSQL 数据库的驱动,但并未用到。本例仅使用到了 H2 本地文件型数据库,但在生产环境中,应该使用 MySQL 或 PostgreSQL 数据库。

resources/migrations/ 中的 xxxx-add-users-table.up.sql 中,添加 products 表:

CREATE TABLE products (
	prd_id INTEGER PRIMARY KEY AUTO_INCREMENT,
	prd_name VARCHAR(70) UNIQUE NOT NULL,
	prd_desc VARCHAR(200),
	quantity INTEGER NOT NULL DEFAULT 0,
	price REAL NOT NULL DEFAULT 0.0
);

它的增删改查操作在 resources/sql/queries.sql 中定义。例如它的代码片段:

-- :name create-product-with-id! :! :n
-- :doc creates a new product record by given the prd_id.
INSERT INTO products
(prd_id, prd_name, prd_desc, quantity, price)
VALUES (:prd_id, :prd_name, :prd_desc, :quantity, :price)

-- :name create-product! :! :n
-- :doc creates a new product record by generating the prd_id automatically.
INSERT INTO products
(prd_name, prd_desc, quantity, price)
VALUES (:prd_name, :prd_desc, :quantity, :price)

-- :name update-product! :! :n
-- :doc updates an existing product record by given the prd_id.
UPDATE products
SET prd_name = :prd_name, prd_desc = :prd_desc, quantity = :quantity, price = :price
WHERE prd_id = :prd_id

...

建立 service/product.clj 文件,里面编写了一些业务处理的函数,供上层 Web 应用处理时调用。例如下面的代码片段,定义了 get-all-productsget-all-products-limited 用来查询全部的产品记录,以及 save-product! 用来保存产品记录:

(defn get-all-products
  "Get all products from DB, return an empty list if no record found."
  []
  (jdbc/with-transaction [t-conn *db*]
    (if-let [records-in-db (db/get-all-products t-conn {})]
      records-in-db
      [])))

(defn get-all-products-limited
  "Get all products from DB with limit, return an empty list if no record found."
  [limit]
  (jdbc/with-transaction [t-conn *db*]
    (if-let [records-in-db (db/get-all-products-limited t-conn {:limit (max limit 1)})]
      records-in-db
      [])))

(defn save-product!
  "Save product to DB by given properties (provided in params map).
   Perform update operation if ID is given, or create operation otherwise."
  [params]
  (assert (not (clojure.string/blank? (:prd_name params))) "prd_name cannot be empty.")
  (jdbc/with-transaction [t-conn *db*]
    (if (nil? (get params :prd_id))
      (db/create-product! t-conn params)
      (db/update-product! t-conn params))))

...

我们将以这个数据库表为基础,来建立对它的 RESTful API 请求。

routes/services.clj 中,定义了 RESTful API 请求响应处理逻辑。

...
   ["/product"
    {:swagger {:tags ["product"]}}

    ["/"
     {:get {:summary "get all products"
            :parameters {:query {:maxnum int?}}
            :responses {200 {:body vector?}}
            :handler (fn [{{{:keys [maxnum]} :query} :parameters}]
                       {:status 200
                        :body (product/get-all-products-handler maxnum)})}}
     ]]
...

这里使用 Reitit 第三方包(网址: https://github.com/metosin/reitit (opens new window) )以数据的方式来定义 RESTful 请求。我们可以看到: /product 是 API 的根路径,下面 "/" 的 GET 请求,将由 :handler 定义的匿名函数处理,它通过调用 (product/get-all-products-handler maxnum) 来返回查询的结果。

handler/product.clj 中,定义了这个 handler:

(defn get-all-products-handler
  "RESTful API hander of GET request for get-all-products"
  [maxnum]
  (let [records-limit (mycommons/safe-get-int maxnum 100)]
    (product/get-all-products-limited records-limit)))

从代码中可见,当 maxnum 不合法时,将以 100 作为默认的记录条数限制,再调用 service 包中的 get-all-products-limited 函数,查询数据库记录并返回。

本例定义的 RESTful API 请求列表如下:

URL 路径 请求方法 描述 备注
/api/product GET get all products 列出全部 product 记录(为了防止记录太多,调用时需要给出上限 maxnum)。
/api/product/:id GET get one product by its ID 查找指定 ID 的 product 记录信息。若没找到则报错 http_code=404。
/api/product POST create new product (no prd_id), or update existing product (with prd_id) 创建一个新 product(没有 prd_id)或者修改一个现存的 product (有 prd_id)。返回记录的 ID,若返回的ID为0则代表操作失败。
/api/product/:id DELETE delete one product by its ID 删除指定 ID 的 product 记录。

本例的代码在配套资源的 ClojureWebApp\swagv1 文件夹中。具体的逻辑请看示例代码。

要运行本例,先初始化数据库:

lein run migrate

完成后,我们可以在项目文件夹中找到: swagv1_dev.db.mv.dbswagv1_dev.db.trace.db 两个文件,这是 H2 的数据库文件。

运行它:

lein run

打开浏览器,访问首页 http://localhost:3000

您可以在首页开头找到初始化数据库的链接 (initialize database) ,和访问 Swagger API 文档页面的链接 (Swagger API document),您可以点开它,试试去调用 product 的请求。

例如其中创建新 product 的请求,可以输入如下参数(注意 prd_name 必须唯一), 以 POST 的方式提交。

{
  "product-params": {
    "prd_name": "键鼠套装(测试)",
    "prd_desc": "罗技的经典键盘+鼠标套装(测试)",
    "quantity": 30,
    "price": 456.0
  }
}

如果请求成功,可以得到返回结果:

{
    "prd_id": 454
}

这里返回的是新创建的那条记录的 ID。

# 第5章 编程实例

学习编程的最有效的方法是动手实践。读者可以试着先自己编程来实现,然后再对照书上给出的范例学习。俗话说,条条大路通罗马,实现的途径可以不同,这里的范例也未必是最优解,仅供参考。

编程时能用到的资料有:

# 5.1 打印三角形

编程打印出如下的三角形:

    *
   ***
  *****
 *******

例如上面,输入正整数 n=4,即打印出4行,依次类推。

先从数学的角度来分析一下。设此三角形总行数 n=4,首行算作第0行,从上往下数,当前第 i 行的“*”星号个数是2*i+1 个,而左边的前导空格个数是 n-i 个。编程思路是,用前导空格和星号来拼凑出每一行的字符串,从上至下循环一次,即可以打印出这个三角形。

开始编程。先编写两个分别计算当前行的星号个数的函数 get-num-stars 和空格个数的函数 get-num-spaces

(defn get-num-stars 
    "get number of stars at given row index."
    [i]
    (inc (* 2 i)))

(defn get-num-spaces 
    "get number of spaces at given row index."
    [n, i]
    (- n i))

我们需要一个辅助函数,它能按指定的字符和重复个数,拼凑出来一个字符串。

(defn repeated-chars
    "return a string with repeated number of given character."
    [chr num]
    (apply str (take num (repeat chr))))

我们可以试一试,确保它能正常工作:

(repeated-chars "*" 5)
;;=> "*****"

然后,我们要将前导空格和星号字符串拼起来,这就是需要打印的每一行。

(defn get-line-str [n, i]
    (let [num-space (get-num-spaces n i)
          num-star  (get-num-stars i)]
        (apply str [(repeated-chars " " num-space) (repeated-chars "*" num-star)])))

我们可以测试一下,确保它符合预期:

(get-line-str 5 0)
;;=> "     *"
(get-line-str 5 1)
;;=> "    ***"
(get-line-str 5 2)
;;=> "   *****"

接下来,我们编写出打印三角形的函数:

(defn print-triangle [n]
    (loop [i 0]
        (when (< i n)
            (println (get-line-str n i))
            (recur (inc i)))))

上面的代码中用到了循环(loop)。它首先将 i 的值绑定到 0, 然后用 when 函数来查看 i 的值是否小于 n。如果满足条件,则继续执行下面的语句。即打印 (get-line-str n i) 返回的当前行的字符串到控制台,然后用 recur 语句,让 i 的值自增1之后重复循环。

测试一下,确保它符合预期::

(print-triangle 5)

它在控制台打印出如下的三角形:

     *
    ***
   *****
  *******
 *********

最后,我们完善此程序。在程序入口点的 -main 函数中,读取用户输入参数 n,并调用打印函数:

(defn -main
    "print a triangle."
    [& args]
    (println "Please input a number (1~100): ")
    (let [n (Integer/parseInt (read-line))]
        (print-triangle n)))

本例的代码在配套资源的 PrintTriangle 文件夹中。

# 5.2 斐波那契数列

斐波那契数列(Fibonacci sequence),又称黄金分割数列,因数学家莱昂纳多·斐波那契(Leonardo Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波那契数列以如下被以递推的方法定义:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*)。这个数列从第3项开始,每一项都等于前两项之和。

编写程序,当用户输入 n (这里 n 可以是大于等于1的正整数),让程序打印出 n 个斐波那契数列的值。

这里考虑用递归来实现。按上面的数学递推法定义,假设定义一个 fib 函数,让(fib 0)=0,(fib 1)=1, (fib 2)=1,(fib 3) =2 即等于前2个 fib 计算结果的和,依次类推。计算过程可以表示成下面的树形:

                   (fib 5)
                  /      \
            (fib 4)      (fib 3)
            /    \        /    \
      (fib 3) (fib 2)  (fib 2) (fib 1)
      /    \
  (fib 2) (fib 1)

开始编程,先定义这个函数:

(defn fib [index]
    (cond (= index 0) 0
          (= index 1) 1
          (= index 2) 1
          :else (+ (fib (- index 1)) (fib (- index 2)))))

我们可以看到,它用 cond 函数先确定了前面3个返回值,而其他情况,则以递归调用来计算结果。

注意: 递归可以让问题简化为一系列的子问题,可以让数学推导变得更清晰。但递归在计算机实现上会带来性能问题,是需要谨慎使用且尽量避免的。

在 Clojure 中,纯函数的结果只跟输入参数有关,即输入某个数值就会有某个既定的结果。因此,函数作为一个计算过程,可当作是个黑盒。我们只需要通过一张映射表,记录下它被调用时的输入参数和返回结果,则可以快速查找得到结果,不需要通过耗时的计算。以这种方式来优化性能。Clojure 中自带有一个 memoize 函数,可以用于上述优化。

注意: 使用 (doc memoize) 可查阅它的文档:

clojure.core/memoize
([f])
Returns a memoized version of a referentially transparent function. The
memoized version of the function keeps a cache of the mapping from arguments
to results and, when calls with the same arguments are repeated often, has
higher performance at the expense of higher memory use.

修改代码,添加一个 memoize-fib 作为性能优化后的函数。

(def memoize-fib (memoize fib))

我们可以先对比一下这两个函数的性能。两个函数初次运行时间相仿。但再次运行,就发现 memoize-fib 直接得出了结果,从耗时来看,显然是没有经过计算的。

user=> (time (fib 40))
"Elapsed time: 15532.8666 msecs"
102334155
user=> (time (memoize-fib 40))
"Elapsed time: 15636.9521 msecs"
102334155

user=> (time (memoize-fib 40))
"Elapsed time: 0.028 msecs"
102334155

首先用 lein 创建一个项目:

lein new app fibseqv1

在新创建的文件夹结构中,编辑源程序文件 src\fibseqv1\core.clj ,修改内容如下:

(ns fibseqv1.core
  (:gen-class))

(defn fib [index]
  (cond (= index 0) 0
        (= index 1) 1
        (= index 2) 1
        :else (+ (fib (- index 1)) (fib (- index 2)))))

(def memoize-fib (memoize fib))

(defn take-fib-seq [count]
    (take count (map memoize-fib (range 1 (inc count))))

(defn -main
    "Calculate Fibonacci sequence."
    [& args]
    (println "Please input N (1~90): ")
    (let [count (Integer/parseInt (read-line))]
        (println (take-fib-seq count))))

我们在 -main 函数的程序入口点读取用户输入的 count,然后调用 take-fib-seq 函数获得斐波那契数列并打印出来。

lein run 运行项目,屏幕输入示例如下:

Please input N (1~90):
10
(1 1 2 3 5 8 13 21 34)

上面的算法用递归来实现,即便用了 memoize 来优化,它的运算效率也很差。

Clojure 的许多算法都是惰性的。 当操作需要用到这个数值时,才会真正进行计算。惰性让许多算法变得更高效。惰性这一点和 Python 的生成器(generator)概念有点相像。下面我们来看一个惰性的实现:

(defn fib-seq-lazy
  "Returns a lazy sequence of Fibonacci numbers"
  ([]
   (fib-seq-lazy 0 1))
  ([a b]
   (lazy-seq
       (cons b (fib-seq-lazy b (+ a b))))))

可以在 REPL 中这样调用它:

user=> (take 20 (fib-seq))
(1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765)

从代码中可以看到,若是不给参数来调用 fib-seq-lazy,它将以0和1来递归调用自己,这即是斐波那契数列最开始的两个值。在以两个参数的实现代码中,它用 lazy-seq 函数来构建了一个惰性序列。cons 函数将用数值 b 和后面的一个列表来构建序列,而后面这个表达式的返回值,是以 b 和 a+b 作为参数递归调用自己。即第一个是 1,接下来是 1 和 0+1,依次类推的序列。

本例的代码在配套资源的 FibonacciSequence 文件夹中,前一个实现是 fibseqv1,后一个 fibseqv2 是惰性序列实现。读者可以运行并对比一下性能。

作为参考,读者还可以看看互联网上其它的实现:

# 5.3 猜数字游戏

猜数字游戏是一个基于命令行窗口的简单游戏。规则如下:

电脑随机产生一个100以内的正整数,用户输入一个数对其进行猜测,程序对其与被猜数进行比较,并提示大了,还是小了,若相等表示猜到了。如果猜到,则结束程序。

首先用 lein 创建一个项目:

lein new app numguessv1

在新创建的文件夹结构中,编辑源程序文件 src\numguessv1\core.clj ,修改内容如下:

(ns numguessv1.core
    (:gen-class))

(defn print-banner []
    (println "*********************************")
    (println "      NumGuess Game V1")
    (println "*********************************")
    (println "Computer will randomly pick an integer from 0 to 100.")
    (println "Try to guess it out in limited steps to win.")
    (println))

(defn run-game []
    (let [number (rand-int 100)]
        (loop []
            (println "Enter a guess:")
            (let [guess (Integer/parseInt (read-line))]
                (cond (> number guess)
                      (do (println "Too Low!")
                          (recur))
                      (< number guess)
                      (do (println "Too Big!")
                          (recur))
                      :else
                      (println "Yeah!"))))))

(defn -main
    "Number guessing game."
    [& args]
    (print-banner)
    (run-game))

我们可以看到,在程序的入口点 -main 函数中,先调用 print-banner 函数打印游戏标题与玩法提示,再调用 run-game 函数进行游戏。程序的主体逻辑都在 run-game 函数中。

我们试试看运行它。

lein run

屏幕输出结果如下:

*********************************
      NumGuess Game V1
*********************************
Computer will randomly pick an integer from 0 to 100.
Try to guess it out in limited steps to win.

Enter a guess:
50
Too Big!
Enter a guess:
25
Too Big!
Enter a guess:
10
Too Big!
Enter a guess:
5
Too Low!
Enter a guess:
8
Too Big!
Enter a guess:
6
Yeah!

下面来分析一下程序的代码,只看 run-game 函数的代码。

首先在 let 函数中 (let [number (rand-int 100)] ...),表达式 rand-int 100 得到一个随机整数(范围是大于等于 0,小于100),用 number 来对应于这个随机数。

接下来用 loop 函数开始了一个循环。在循环里面先打印出"Enter a guess:"提示信息。然后 read-line 从控制台读取一个用户输入字符串,通过 Java 的静态函数 java.lang.Integer.parseInt() 来解析,用 guess 来绑定到此整型数值。

(loop []
    (println "Enter a guess:")
    (let [guess (Integer/parseInt (read-line))]
        ...))

接下来就应该开始对随机数和用户输入进行比较了。这里使用 cond 函数,它类似与 Java 里面的 switch case 语句,用于多分支判断。

(cond (> number guess)
      (do (println "Too Low!")
          (recur))
      (< number guess)
      (do (println "Too Big!")
          (recur))
      :else
      (println "Yeah!"))

如果随机数 number 大于用户输入,则打印 "Too Low!",然后进行下一次重猜。这里 recur 函数会回到最近的 loop 处。

(cond (> number guess)
      (do (println "Too Low!")
          (recur))
      ...)

而当随机数 number 小于用户输入,则打印 "Too Big!",然后进行下一次重猜。这里 recur 同上,又会回到最近的 loop 处。

(cond ...
      (< number guess)
      (do (println "Too Big!")
          (recur))
      ...)

最后,其他的情况下,肯定是猜中了,打印出 "Yeah!",程序退出 loop,游戏结束。

(cond ...
      :else
      (println "Yeah!"))

本例的代码在配套资源的 NumGuessGame 文件夹中。

作为练习,读者可以试着改进这个游戏,例如,让用户先选择难度级别(难、中、易),不同的级别对应着一个步数限制(例如,易是30步,中是20步,难是10步),然后开始猜数游戏。若在限定步数内没有猜出来,也算失败,结束游戏。

# 5.4 计算圆周率

圆周率用字母 π(Ratio of circumference to diameter;Pi)表示,它是圆的周长与直径的比值,是一个在数学及物理学中普遍存在的数学常数。π 也等于圆形之面积与半径平方之比。它是精确计算圆周长、圆面积、球体积等几何形状的关键值。

圆周率 π 是一个常数,约等于 3.141592654,它是一个无理数,即无限不循环小数。在日常生活中,通常都用3.14代表圆周率去进行近似计算。作为中国人,最引以为豪的就是祖冲之运用割圆术取得的成就:3.1415926 到 3.1415927 之间。采用割圆术计算圆周率,尤其是在现代的计算机出现之前,靠人工计算,可以想象计算量之太。

π 的近似计算公式如下:

pi formula

公式等式右边分子都为1,分母为递增数列,从第一项开始,奇数项符号为正,偶数项符号为负。等式右边的分母越大,越小,圆周率 π 计算的值越精确;换个角度讲,就是等式右边的项越多,计算的值越精确。

下面用不同的语言来实现,并进行性能对比。

用 Python 3 编写的参考代码如下:

def compute_pi(n_terms: int) -> float:
    numerator: float = 4.0
    denominator: float = 1.0
    sign: float = 1.0
    pi: float = 0.0
    for _ in range(n_terms):
        pi += sign * (numerator / denominator)
        denominator += 2.0
        sign = -sign
    return pi

在笔记本电脑(CPU: i5-1135G7, RAM: 16G, OS: Windows 10)上,运行示例如下:

# n_terms = 10000000000
3.141592653488346
Execution Time: 1583.7175 seconds

耗时1583秒(约26分钟)。

用 Java 编程如下:

public double computePi(long n_terms) {
    double numerator = 4.0;
    double denominator = 1.0;
    double sign = 1.0;
    double pi = 0.0;

    for (long i = 0; i < n_terms; i++) {
        pi += sign * (numerator / denominator);
        denominator += 2.0;
        sign = -sign;
    }

    return pi;
}

同样的参数(n_terms=10000000000)和运行环境,耗时约22秒:

mvn package
java -jar target\CalcPieSpringBoot-0.0.1-SNAPSHOT.jar

3.141592653488346
Execution Time: 22.2039878 seconds

用 Clojure 编程如下:

(defn compute_pi [n_terms]
  (let [numerator 4.0]
    (loop [i 0
           pi 0.0
           denominator 1.0
           sign 1.0]
      (if (< i n_terms)
        (recur
         (inc i)
         (+ pi (* sign (/ numerator denominator)))
         (+ 2.0 denominator)
         (* -1 sign))
        pi))))

lein uberjar 打包后运行 jar 包,同样的参数(n_terms=10000000000)和运行环境,耗时约 23 秒:

lein uberjar
java -jar target\default+uberjar\calcpieclj-0.1.0-SNAPSHOT-standalone.jar

"Elapsed time: 23.326 secs"
3.141592653488346

这里看得出,性能上和 Java 相同。

若使用 C# 语言,基于 .NET 6.0,编程如下:

double computePi(long n_terms)
{
    double numerator = 4.0;
    double denominator = 1.0;
    double sign = 1.0;
    double pi = 0.0;

    for (long i = 0; i < n_terms; i++)
    {
        pi += sign * (numerator / denominator);
        denominator += 2.0;
        sign = -sign;
    }

    return pi;
}

同样的参数(n_terms=10000000000)和运行环境,以发行版(Release)编译运行,耗时约11秒:

3.141592653488346
Elapsed time: 11 seconds

若使用 C/C++ 语言,编程如下:

#include <stdio.h> 

double computePi(long n_terms) {
    double numerator = 4.0f;
    double denominator = 1.0f;
    double sign = 1.0;
    double pi = 0.0;

    for (long i = 0; i < n_terms; i++) {
        pi += sign * (numerator / denominator);
        denominator += 2.0;
        sign = -sign;
    }

    return pi;
}

同样的参数(n_terms=10000000000)和运行环境,以发行版(Release)编译运行,耗时约 1 秒:

3.1415926528788373773
Elapsed time: 1 seconds

本例的代码在配套资源的 CalcPi 文件夹中。

若不用 OpenJDK-17,而改用 GraalVM 社区版(网址: https://www.graalvm.org (opens new window) )来运行,对上面 Java (基于 SpringBoot 3.0)和 Clojure 的应用程序进行加速,可让耗时从23秒降到约11秒,性能提升明显。

注意: GraalVM 是一个高性能的 JDK 发行版。它旨在加速用 Java 和其他 JVM 语言编写的应用程序的执行,同时还为 JavaScript、Python、基于 LLVM 的语言(如 C 和 C++)以及许多其他流行编程语言提供运行时。此外,GraalVM 为编程语言之间提供了高效互操作性,并将 Java 应用程序提前编译为本机可执行文件,从而加快启动时间并降低内存开销。

在本机上,使用的 GraalVM 版本是:

java -version
openjdk version "17.0.5" 2022-10-18
OpenJDK Runtime Environment GraalVM CE 22.3.0 (build 17.0.5+8-jvmci-22.3-b08)
OpenJDK 64-Bit Server VM GraalVM CE 22.3.0 (build 17.0.5+8-jvmci-22.3-b08, mixed mode, sharing)

运行结果如下,耗时约11秒:

# 用Maven运行
mvn spring-boot:run
# 或者直接运行jar
mvn clean package
java -jar target\CalcPiSpringBoot-0.0.1-SNAPSHOT.jar

# 结果大致如下:
3.141592653488346
Execution Time: 11.4466468 seconds

对于 Clojure 项目也是如此,耗时约11秒:

# 用lein运行
lein run
# 或者运行jar
lein uberjar
java -jar target\default+uberjar\calcpiclj-0.1.0-SNAPSHOT-standalone.jar

# 结果大致如下:
"Elapsed time: 11.834 secs"
3.141592653488346

由此可见,GraalVM 对于 Java 应用程序的性能提升有显著的改进。

综上,我们可以得出结论:

  1. 从上面的几种语言可以看得出,Java 、C/C++、C#、Python 的语法都在历史上有所借鉴,对于上面的数值计算函数,代码看起来非常相似,容易相互移植。编写也比较符合程序员的思维习惯,编程效率和易读性比较高。Clojure 的语法则是完全不同,小括号的嵌套层数一多,就看不清楚。有些语法如尾递归等,不容易掌握使用。

  2. Python 的性能最差(约26分钟),然后是 Java 和 Clojure(两者相同,约23秒),C#约11秒,最快的是 C/C++ 约1秒。因此,这几种编程语言,业界通常会用 Python 做快速原型开发,Java/Clojure/C# 做应用层的软件开发,而 C/C++ 做系统级的底层程序开发。C# 的编程语法和 Java 很像,且基于 .NET 跨平台,若不介意微软,作为应用开发值得考虑使用。

  3. 使用 GraavlVM 可以显著提高 Java 和 Clojure 程序的运行性能。安装 GraalVM 的方法与安装 JDK 相似。在不使用 GraalVM 的镜像编译功能时,也可以把它当作 JDK 来使用。

  4. SpringBoot 3.0 要求 Java 17 作为最低版本。SpringBoot 3.0 应用程序现在可以转换为 GraalVM native images,这可以提供显著的内存和启动性能改进。

Last Updated: 1/27/2023, 9:22:22 AM