Clojure-高性能-JVM-编程-全-

Clojure 高性能 JVM 编程(全)

原文:zh.annas-archive.org/md5/30898d6f47b23409d6f79c6d6b893e9a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在过去几年中,我们看到了在 JVM 上创建新语言的普遍趋势。有各种不同范式和工作方式的新语言。Clojure 就是其中之一,我们认为它值得学习。

Java 虚拟机在 Clojure 程序的性能中扮演着至关重要的角色。Clojure 将函数式编程引入 Java 虚拟机(JVM),并通过 ClojureScript 引入到网页浏览器中。

随着并行数据处理和多核架构的最近兴起,函数式编程语言在创建既可证明又高效软件方面变得更加流行。与其他函数式编程语言一样,Clojure 专注于使用函数和不可变数据结构来编写程序。Clojure 还通过使用符号表达式和动态类型系统添加了 Lisp 的痕迹。

本学习路径涵盖的内容

模块 1,面向 Java 开发者的 Clojure,向您介绍 Clojure 及其观点。您将了解为什么不可变对象不仅可行,而且使用它们是一个好主意。您还将了解函数式编程,并了解它如何适应不可变程序的概念。您将理解将代码表示为相同语言的数据结构的强大理念。此外,我们将找出与您已知的 Java 语言的相似之处和不同之处,以便您理解 Clojure 世界是如何运作的。

模块 2,《Clojure 高性能编程(第 2 版),增加了对性能管理 JVM 工具的关注,并探讨了如何使用这些工具。本模块将为您提供性能测量和性能分析工具,以及分析并调整 Clojure 代码性能特性的知识。

模块 3,精通 Clojure,将带您了解 Clojure 语言的有趣特性。我们还将讨论 Clojure 中一些更高级和不太为人所知的编程结构。我们还将描述一些 Clojure 生态系统中的库,这些库可以在我们的程序中实际使用。完成本模块后,您将不再需要被说服 Clojure 语言的优雅和强大。

您需要本学习路径的内容

您需要 Java SDK。您应该能够在任何操作系统上运行示例;在我们的示例中,如果环境中有一个 shell,应该更容易理解。(我们主要关注 Mac OS X。)

您应该获取 Java 开发工具包(JDK)版本 8 或更高版本(您可以从www.oracle.com/technetwork/java/javase/downloads/为您的操作系统获取,以便处理所有示例)。本书讨论了 Oracle HotSpot JVM,因此如果您可能的话,您可能希望获取 Oracle JDK 或 OpenJDK(或 Zulu)。您还应该从leiningen.org/获取最新的 Leiningen 版本,以及从jd.benow.ca/获取 JD-GUI。

您还需要一个文本编辑器或集成开发环境(IDE)。如果您已经有一个偏好的文本编辑器,您可能可以使用它。前往dev.clojure.org/display/doc/Getting+Started获取适用于特定环境的插件列表,以编写 Clojure 代码。如果您没有偏好,建议您使用 Eclipse 与 Counterclockwise (doc.ccw-ide.org/)或 Light Table (lighttable.com/)。本书中的一些示例也可能需要网络浏览器,例如 Chrome(42 或以上)、Firefox(38 或以上)或 Microsoft Internet Explorer(9 或以上)。

这条学习路径面向的对象

这条学习路径针对的是熟悉构建应用程序的 Java 开发者,他们现在想利用 Clojure 的强大功能。

读者反馈

我们欢迎读者的反馈。请告诉我们您对这门课程的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件的主题中提及课程的标题。

如果你在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请参阅我们的作者指南,网址为www.packtpub.com/authors

客户支持

现在你已经是 Packt 课程的骄傲所有者,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com的账户下载此课程的示例代码文件。如果您在其他地方购买了此课程,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入课程的名称。

  5. 选择您想要下载代码文件的课程。

  6. 从下拉菜单中选择您购买此课程的来源。

  7. 点击代码下载

您还可以通过点击 Packt Publishing 网站课程网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入课程名称来访问此页面。请注意,您需要登录到您的 Packt 账户。

文件下载完成后,请确保您使用最新版本解压或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该课程的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Clojure-High-Performance-JVM-Programming。我们还有其他来自我们丰富图书、视频和课程目录的代码包,可在github.com/PacktPublishing/找到。请查看它们!

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的课程中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进课程的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的课程,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入课程名称。所需信息将出现在勘误部分下。

侵权

在所有媒体中,互联网上对版权材料的侵权都是一个持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送电子邮件到<copyright@packtpub.com>并附上疑似侵权材料的链接与我们联系。

我们感谢您的帮助,保护我们的作者和提供有价值内容的能力。

询问

如果您在课程的任何方面遇到问题,您可以通过发送电子邮件到<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一部分. 模块 1

面向 Java 开发者的 Clojure

平滑地从 Java 过渡到最广泛使用的基于 JVM 的函数式语言 – Clojure

第一章. Clojure 入门

欢迎来到 Clojure 的世界!如果你在这里,你可能对 Lisp 或 Clojure 有点了解,但你并不真正了解这个世界的运作方式。

我们将通过将每个功能与您从 Java 已知的内容进行比较来了解 Clojure。你会发现有列表、映射和集合,就像在 Java 中一样,但它们是不可变的。要处理这些类型的集合,你需要不同的方法;不同的范式。

这就是我们在这本书中试图达成的目标,给你提供一种不同的方法来解决问题。我们希望你在日常生活中使用 Clojure,但如果你不这样做,我们希望你在解决问题的方法上采用一种新的方式。

在本章中,我们将涵盖以下主题:

  • 了解 Clojure

  • 安装 Leiningen

  • 使用 读取-评估-打印循环 (REPL)

  • 安装和使用 Cursive Clojure

  • Clojure 的简单语法

  • Clojure 的数据类型及其与 JVM 数据类型的关系

  • 函数的特殊语法

了解 Clojure

在开始学习 Clojure 之前,你应该了解一些它的特性和它与 Java 共同之处。

Clojure 是一种从 Lisp 继承了许多特性的编程语言。你可能会把 Lisp 视为那种充满括号的奇怪编程语言。你需要记住的是,Clojure 选择拥抱函数式编程。这使得它与当前的主流编程语言非常不同。你将了解不可变数据结构以及如何编写不改变变量值的程序。

你还会发现 Clojure 是一种动态编程语言,这使得它比使用静态类型语言更容易、更快地编写程序。还有使用 REPL 的概念,这是一个允许你连接到正在运行的环境并动态更改代码的工具。这是一个非常强大的工具。

最后,你会发现你可以将 Clojure 转换为你喜欢的任何东西。你可以创建或使用静态类型系统,并将语言弯曲成你喜欢的样子。一个很好的例子是 core.typed 库,它允许你指定类型信息,而无需向编译器添加支持。

安装 Leiningen

我们习惯于拥有某些工具来帮助我们构建代码,例如 Ant、Maven 和 Gradle。

在 Clojure 生态系统内,依赖和构建管理的既定标准是 Leiningen(亲切地以短篇小说《莱宁根对抗蚂蚁》命名,我推荐阅读en.wikipedia.org/wiki/Leiningen_Versus_the_Ants);Leiningen 力求让 Java 开发者感到熟悉,它从 Maven 中汲取了最佳想法,如:约定优于配置。它还从 Ant 中汲取了灵感,如自定义脚本和插件。

安装它非常简单,让我们看看如何在 Mac OS X 上使用 bash 作为默认 shell 进行安装(在 Linux 上的安装应该相同)。

你也应该已经安装并配置了 Java 7 或 8 在你的路径中。

你可以在 Leiningen 项目的页面上查看详细的说明leiningen.org/。如果你想将 Leiningen 安装起来并运行,你需要做以下事情:

curl -O https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein
# The next step just set up the lein script in your path, you can do it any way you wish
mv lein ~/bin
echo "export PATH=$PATH:~/bin/">> ~/.bashrc
source ~/.bashrc
# Everything should be running now, let's test it
lein help

第一次运行lein命令时,它会从互联网下载所有需要的文件。这使得分发你的代码变得非常容易,你甚至可以将lein脚本包含在你的项目中,使其他开发者更容易上手,唯一真正的要求是 JDK。

使用 REPL

Clojure(以及 Lisp)的主要优势之一是交互式开发,REPL 是交互式编程可以实现的基石,它允许你连接到一个正在运行的运行 Clojure 的 VM,并实时执行或修改代码。

有一个关于 NASA 如何能够在 100 亿美元硬件上调试和纠正一个 100 亿英里外的错误的故事(www.flownet.com/gat/jpl-lisp.html)。

我们在 Clojure 和 Leiningen 中也有同样的能力,调用它非常简单,你只需要一个命令:

lein repl

运行前面的命令后,你会得到以下结果:

使用 REPL

让我们更详细地了解一下,正如我们所见,我们正在运行以下程序:

  • Java 8

  • Clojure 1.6.0

我们还可以获得一些关于如何查看文档、sourceJavadoc和之前错误的良好建议。

nREPL 协议

有一个特别需要注意的事情是 nREPL 协议;有一天它可能会赋予我们进入运行在 100 亿英里外的机器的能力。

当你启动你的 REPL 时,你看到的第一件事是:

nREPL server started on port 55995 on host 127.0.0.1 - nrepl://127.0.0.1:55995
REPL-y 0.3.5, nREPL 0.2.6

它所表达的意思是有一个 Clojure 进程在端口55995上运行 nREPL 服务器。我们通过一个非常简单的客户端连接到它,这个客户端允许我们与 Clojure 进程进行交互。

真正有趣的部分是你可以像连接本地主机一样轻松地连接到远程主机;让我们尝试通过简单地输入以下命令将 REPL 附加到同一个进程:

lein repl :connect localhost:55995

大多数 IDE 都与 Clojure 有很好的集成,其中大多数都使用这个确切的机制,作为稍微智能一点的客户端。

Hello world

现在我们已经进入了 REPL 内部,(任意一个),让我们尝试编写我们的第一个表达式,继续并输入:

"Hello world"

你应该从 REPL 得到一个返回值,显示Hello world,这实际上不是一个程序,而是 REPL 打印阶段的Hello world值。

现在我们尝试编写我们的第一个 Lisp 形式:

(println "Hello world")

这个第一个表达式看起来与我们习惯的不同,它被称为 S 表达式,这是 Lisp 的标准方式。

在 S 表达式中,有几个需要注意的事项:

  • 它们是列表(因此得名,Lisp)

  • 列表中的第一个元素是我们想要执行的操作,其余的是该操作的参数(一个、两个、三个)。

因此,我们要求打印字符串Hello world,但如果我们仔细观察输出,如以下截图所示,有一个我们未预期的nil

Hello world

原因是println函数在打印Hello world后返回值nil(Clojure 的 null 等价物)。

注意

在 Clojure 中,一切都有值,REPL 会始终为你打印它。

REPL 实用工具和约定

正如我们所见,Leiningen nREPL 客户端打印帮助文本;但它是如何工作的?让我们探索一些其他实用工具。

尝试使用以下表格中的每个来了解它们的功能:

函数 描述 示例
doc 打印函数的docstring (doc println)
source 打印函数的源代码,它必须用 Clojure 编写 (source println)
javadoc 在浏览器中打开类的javadoc (javadoc java.lang.Integer)

让我们看看这些函数是如何工作的:

user=> (javadoc java.util.List)
;; Should open the javadoc for java.util.List

user=> (doc doc)
-------------------------
clojure.repl/doc
([name])
Macro
  Prints documentation for a var or special form given its name
nil

user=> (source doc)
(defmacro doc
"Prints documentation for a var or special form given its name"
  {:added "1.0"}
  [name]
  (if-let [special-name ('{& fn catch try finally try} name)]
    (#'print-doc (#'special-doc special-name))
    (cond
      (special-doc-map name) `(#'print-doc (#'special-doc '~name))
      (find-ns name) `(#'print-doc (#'namespace-doc (find-ns '~name)))
      (resolve name) `(#'print-doc (meta (var ~name))))))
nil

你在这里看到的是与doc函数相关的元数据;Clojure 具有存储每个函数或var使用的元数据的能力。大多数 Clojure 核心函数都包括文档字符串和函数的源代码,这在你的日常工作中将非常有用。

除了这些函数外,我们还可以轻松访问 REPL 中最近三个值和最近发生的异常,让我们来看看:

user=> 2
2
user=> 3
3
user=> 4
4
user=> (* *1 *2 *3) ;; We are multiplying over here the last three values
24 ;;We get 24!
user=> (/ 1 0) ;; Let's try dividing by zero
ArithmeticException Divide by zero clojure.lang.Numbers.divide (Numbers.java:156)
user=> *e
#<ArithmeticException java.lang.ArithmeticException: Divide by zero>

user=> (.getMessage *e)
"Divide by zero"

注意

*e让你访问实际的 Java 异常对象,因此你可以在运行时分析和内省它。

你可以想象使用这种方式执行和内省代码的可能性,但我们的工具呢?我们如何使用它与集成开发环境(IDE)结合使用?

让我们看看如何创建一个新的 Clojure 项目,我们将使用命令行中的 Leiningen 来理解发生了什么。

创建新项目

Leiningen 可以帮助我们使用模板创建新项目,有各种各样的模板可供选择,你可以在 Maven 中构建和分发自己的模板。

最常见的模板类型包括:

  • 创建jar库(默认模板)

  • 创建命令行应用程序

  • 创建 Clojure 网络应用程序

让我们创建一个新的 Clojure 命令行应用程序并运行它:

lein new app getting-started
cd getting-started
lein run
# Hello, world!

项目结构

Leiningen 与其他 Java 开发工具类似;它使用类似的约定,并允许在project.clj文件中进行大量自定义。

如果你熟悉 Maven 或 Gradle,你可以将其视为pom.xmlbuild.gradle

以下截图显示了项目结构:

项目结构

如前一个截图所示,有四个主要文件夹:

  • resources: 它包含所有应该在类路径中的内容,例如文件、图像、配置文件、属性文件和其他在运行时需要的资源。

  • src: 您的 Clojure 源文件;它们的排序方式与 classpath 非常相似。

  • dev-resources: 在开发中应该在 classpath 中的所有内容(当您运行 Leiningen 时)。您可以在这里覆盖您的“生产”文件,并添加测试运行所需的文件。

  • test: 您的测试;这段代码不会被打包,但每次您执行 Leiningen 测试时都会运行。

创建独立应用程序

一旦您的项目创建完成,您就可以轻松地构建和运行一个 Java 独立命令行应用程序,现在让我们试试:

lein uberjar
java -jar target/uberjar/getting-started-0.1.0-SNAPSHOT-standalone.jar
# Hello, World!

如您所见,创建独立应用程序非常简单,并且与使用 Maven 或 Gradle 非常相似。

使用 Cursive Clojure

Java 已经有一些很棒的工具可以帮助我们提高生产力和编写更高质量的代码,我们不应该忘记这些工具。根据您的 IDE,有几个 Clojure 插件。请从以下表格中查看它们:

IDE Plugins
IntelliJ Cursive Clojure, La Clojure
NetBeans NetBeans Clojure(与 NetBeans 7.4 兼容)
Eclipse CounterClockwise
Emacs Cider
VIM vim-fireplace, vim-leiningen

许多编写真实 Clojure 代码的人使用 Emacs,我实际上喜欢使用 vim 作为我的主要开发工具,但请放心,本书中我们将主要使用 IntelliJ + Cursive Clojure。

安装 Cursive Clojure

您可以在他们的网站上查看 Cursive 的完整文档(cursiveclojure.com/),它仍在开发中,但相当稳定,并且在编写 Clojure 代码时非常有帮助。

我们将使用最新的 IntelliJ Community Edition 版本,在撰写本文时是版本 14。

您可以从这里下载 IntelliJ:www.jetbrains.com/idea/download/

安装 Cursive Clojure 非常简单,您需要为 IntelliJ 添加一个仓库。您可以在以下位置找到您特定 IntelliJ 版本的说明:cursiveclojure.com/userguide/

在您安装了 Cursive Clojure 之后,我们就准备出发了。

现在,我们准备将入门项目导入到 Cursive Clojure 中。

注意

Cursive Clojure 目前不支持在 IDE 内创建 Leiningen 项目;然而,导入它们的支持非常好。

这是您将如何操作:

  1. 点击文件

  2. 导入项目。

  3. 查找您的项目。

  4. 打开文件夹或 project.clj 文件。

  5. 在 IDE 中遵循下一步操作。

现在,我们准备出发了,您可以使用 Cursive Clojure 作为您的主要开发工具。在您的 IDE 中还有更多的事情要做,但我建议您自己查找它们;它们很重要,并且会很有用:

  • 了解如何执行项目

  • 了解如何执行测试

  • 打开一个连接到某个项目的 REPL。

  • 执行一些给定代码的关键绑定(在 REPL 中运行光标前的形式)

  • 执行给定文件的关键绑定(在 REPL 中加载文件)

Clojure 编程的一个重要部分是它可以在运行时修改和重新评估代码。检查你当前版本 Clojure 的手册,并查看结构编辑部分 (cursiveclojure.com/userguide/paredit.html)。这是 Clojure IDE 中的最有用功能之一,并且是 Clojure 语法的一个直接后果。

我建议你检查手册中的其他功能。我强烈建议检查 Cursive Clojure 手册,它包括每个功能如何工作的动画。

你会经常使用最后两个键绑定,所以正确设置它们很重要。有关键绑定的更多信息,请参阅 cursiveclojure.com/userguide/keybindings.html

开始使用 Clojure 代码和数据

现在我们深入探讨 Clojure 的语法,它与其他语言非常不同,但实际上要简单得多。Lisps 有一个非常规则的语法,规则很少。正如我们之前所说的,Clojure 代码由 S-表达式组成,而 S-表达式只是列表。让我们看看一些列表的示例,以便熟悉 Lisp 中的列表。

(1 2 3 4)
(println "Hello world")
(one two three)
("one" two three)

所有的这些都是列表,但并不是所有的都是有效代码。记住,只有第一个元素是函数的列表才能被认为是有效表达式。所以,这里只有以下可以被认为是有效表达式:

(println "Hello world")
(one two three)

如果 printlnone 被定义为函数。

让我们看看一段 Clojure 代码,以最终解释一切是如何工作的。

(defn some-function [times parameter]
"Prints a string certain number of times"
  (dotimes [x times]
    (println parameter)))

Clojure 中的列表

Clojure 是基于“形式”或列表的。在 Clojure 中,就像每个 Lisp 一样,表示列表的方式是使用括号,所以这里有一些在上一个代码中的列表示例:

(println parameter)
(dotimes [x times] (println parameter))
(defn some-function [times parameter] (dotimes [x times] (println parameter)))

列表是 Clojure 中的一个数据类型,它们也是表达代码的方式;你将在以后了解将代码表达为数据的所有好处。第一个好处是它非常简单,你可以做的任何事都必须以列表的形式表达!让我们看看一些其他可执行代码的示例:

(* 1 2 3)
(+ 5 9 7)
(/ 4 5)
(- 2 3 4)
(map inc [1 2 3 4 5 6])

我鼓励你将所有内容都写入 REPL,这样你就能很好地理解正在发生的事情。

Clojure 中的操作

在 Clojure 中,MOST 的可执行形式都有这种结构:

(op parameter-1parameter-2 ….)

op 是要执行的操作,后面跟着所有需要的参数,让我们用这种新的视角分析我们之前的形式:

(+ 1 2 3)

我们要求使用参数 123 执行 +(加法)操作。预期结果是 6

让我们分析一些更复杂的东西:

(map inc [1 2 3 4 5 6])

在这里,我们要求使用两个参数执行 clojure.core/map 函数:

  • inc 是一个函数名,它接受一个数字并将其增加

  • [1 2 3 4 5 6] 是一个数字集合

Map 将inc函数应用于传递的集合的每个成员,并返回一个新的集合,我们期望的是一个包含[2 3 4 5 6 7]的集合。

Clojure 中的函数

现在让我们检查一下函数定义本质上与前面两种形式是如何相同的:

(defn some-function [times parameter]
"Prints a string certain number of times"
  (dotimes [x times]
    (println parameter)))

defn是我们所请求的操作。它有几个参数,例如:

  • some-function是我们正在定义的函数的名称

  • [times parameter]是参数的集合

  • "打印指定次数的字符串"是文档字符串,它实际上是一个可选参数

  • (dotimes [x times] (println parameter))是当调用some-function时执行的函数体

defn调用一个函数以使其存在。在这段代码执行后,some-function存在于当前命名空间中,你可以使用定义的参数来使用它。

defn实际上是用 Clojure 编写的,并支持一些不错的东西。现在让我们定义一个multi-arity函数:

(defn hello
  ([] (hello "Clojure"))
  ([name] (str "Hello " name)))

在这里,我们定义了一个有两个主体的函数,其中一个没有参数,另一个有一个参数。实际上,理解正在发生的事情相当简单。

尝试更改项目中core.clj文件的源代码,类似于以下示例:

(ns getting-started.core
  (:gen-class))

(defn hello
  ([] (hello "Clojure"))
  ([name] (str "Hello " name)))

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

现在运行它,你会得到三个不同的 Hello 输出。

如你所见,Clojure 有一个非常规则的语法,即使对于新手来说有点奇怪,但实际上相当简单。

这里,我们使用了一些尚未适当介绍的数据类型;在下一节中,我们将查看它们。

Clojure 的数据类型

现在是你所知道的关于 Java 的一切发挥作用的时候了;甚至你之前看到的列表形式也实现了java.util.List接口。Clojure 被设计成可嵌入的,并且与宿主平台有很好的集成,因此你可以使用你已知的所有关于 Java 类型和对象的知识是自然而然的事情。

Clojure 中有两种数据类型:标量和集合。

标量

在每种语言中,你都需要原始类型;你在日常生活中使用它们,因为它们代表数字、字符串和布尔值。在 Clojure 世界中,这些原始类型被称为标量。

Clojure 有几个非常有趣的数据类型,如比例和关键字。在下面的表中,你可以了解不同的标量类型,它们与 Java 的比较以及如何使用每个类型的简单示例。

Clojure 数据类型 Java 数据类型 示例 描述
字符串 字符串 "This is a string""This is a multiline string" 字符串;在 Clojure 中,你可以使用多行字符串而不会出现问题
布尔 布尔 truefalse 文字布尔值
字符 字符 \c``\u0045 ;; Unicode char 45 E 字符值,它们是java.lang.Character实例,你可以定义 Unicode 字符
关键字 Java 中不存在 :key``:sample``:some-keyword 它们会自我评估,通常用作键。它们也是查找自己在映射中的函数。
数字 数字会自动处理为 BigDecimalBigInteger 或根据需要使用较低精度 42N ;;大整数``42 ;;long``0.1M ;;BigDecimal 记住 Java 数字权衡很重要,如果精度很重要,你应该始终使用大数和 bigintegers
比率 Java 中不存在 22/7 Clojure 提供了出色的数值精度;如果需要,它可以保留比率并执行精确操作。使用比率时的权衡是速度。
符号 Java 中不存在 some-name 符号是 Clojure 中的标识符,与 Java 中的变量名非常相似。
nil null nil 空值
正则表达式 java.util.regex.Pattern #"\d" 正则表达式,在 Clojure 中,你可以免费使用语法来定义正则表达式,但最终它是一个普通的 Java 正则表达式模式

集合数据类型

在 Clojure 中有两种类型的集合:顺序集合和关联集合。顺序集合是可以迭代的,例如列表。关联集合是映射、集合以及可以通过特定索引访问的内容。Clojure 的集合与 Java 完全兼容,甚至可以实现 java.util 接口,如 java.util.Listjava.util.Map

Clojure 中集合的一个主要特征是它们是不可变的;它有很多好处,我们稍后会看到。

让我们看看 Clojure 中可用的每种集合数据类型的特征,并借助一个示例(在 Clojure 中)及其描述与 Java 进行比较。

Clojure 数据类型 Java 数据类型 示例 描述
List List (1 2 3 4 5) 一个简单的列表,注意列表前的引号字符,如果你没有指定它,Clojure 会尝试将形式评估为指令
Vector Array [1 2 3 4 5] 它是 Clojure 中的主要工作马,它类似于数组,因为你可以以随机顺序访问元素
Set HashSet #{1 2 3 4} 一个普通的 Java 哈希集
Map HashMap {:key 5 :key-2 "red"} Clojure 的映射

摘要

如您所见,Clojure 拥有一个成熟且不断发展的开发环境。您可以以与在正常 Java 开发中相似的方式设置命令行工具和您的 IDE。

我们还了解了一些关于 Clojure 的常规语法、其数据类型以及它们与 Java 自身数据类型之间关系的内容。

总体来说,你现在应该对以下内容感到舒适:

  • Lisp 语法

  • 从头创建 Leiningen 项目

  • 运行和打包您的代码

  • 将 Leiningen 项目导入 IntelliJ

  • 使用 REPL

  • 了解 Clojure 类型与 Java 类型之间的关系

在下一章中,我们将了解如何组织我们的代码以及这种组织如何利用 Java 包的优势。

第二章:命名空间、包和测试

现在我们已经安装了 Clojure 和 IntelliJ。

作为一名 Java 开发者,您习惯于以类作为组织的最小单元进行工作。Clojure 有着非常不同的感觉,并为您提供不同的工具来组织您的代码。

首先,您应该记住,代码和数据是分开的;您没有一个具有属性和在这些属性上工作的函数的最小单元。您的函数可以作用于您希望使用的任何数据结构,只要您遵循函数的工作规则。

在本章中,我们将开始编写一些简单的函数来展示函数和数据分离的工作方式,并查看 Clojure 为我们提供的工具来实现这种分离。

在本章中,我们将涵盖以下主题:

  • 与类路径和 Java 包相比,命名空间是如何工作的

  • 单元测试

  • 更多 Clojure 示例和语法

Clojure 中的命名空间

Clojure 命名空间可能对您来说很熟悉,作为一名 Java 开发者,这有一个非常好的原因,它们与 Java 的包和类路径有着非常深的关系。

首先,让我们回顾一下我们从 Java 中学到的知识。

Clojure 中的包

Java 代码是按包组织的,Java 中的一个包是一个命名空间,它允许您将一组类似类和接口分组在一起。

您可以将包想象成您电脑中的一个文件夹。

以下是一些在 Java 编程中经常使用的常见包:

  • java.lang:Java 的本地内容,包括基本类型(整数、长整型、字节、布尔型、字符、字符串、数字、短整型、浮点型、void 和类),基本线程原语(可运行、线程),异常的基本原语(可抛出、错误、异常),基本异常和错误(NoSuchMethodErrorOutOfMemoryErrorStackOverflowError等)以及运行时访问类,如 runtime 和 system。

  • java.io:这个包包括输入和输出的原语,如控制台、文件、读取器、输入流和写入器。

  • java.util:这是除了java.lang之外最常用的包之一。它包括经典的数据结构(映射、集合、列表)以及这些数据结构的常见实现。此包还包括属性工具、从各种输入资源读取的扫描仪、ServiceLoaderclassloader加载自定义服务、UUID 生成器、计时器等实用工具。

  • java.util.logging:日志实用工具,您通常使用它们来提供不同级别的警报,从调试到严重情况。

  • java.text:这些是管理文本、日期和数字的语言无关的实用工具。

  • javax.servlet:这包括创建 Web 应用和在标准 Web 容器中部署的原语。

这些包中的每一个都包含几个相关的功能,特别是java.lang包尤为重要,因为它包含了所有的 Java 核心类型,如字符串、长整型和整型。java.lang包中的所有内容都可以在所有地方自动使用。

java.lang包不仅提供了代码组织,还提供了访问安全性。如果你还记得 Java,有三个安全访问级别:

  • 私有的

  • 公共的

  • 受保护的

在包的情况下,我们关注的是受保护的访问级别。同一包中的类允许同一包中的其他类访问其受保护的属性和方法。

在运行时分析包也有方法,但它们很复杂,而且能做的事情非常有限。

包是在 Java 的类路径和类加载器的顶部实现的。

类路径和类加载器

Java 被设计成模块化的,为此它需要一种轻松加载你的代码的方法。这个答案就是类加载器,类加载器允许你从类路径的每个条目中读取资源;你可以将类路径资源视为类似于文件系统的一个分层结构。

类加载器只是一个条目列表;每个条目可以是文件系统中的一个目录或一个 JAR 文件。此时,你也应该知道 JAR 文件只是 zip 文件。

类加载器将每个条目视为一个目录(JAR 文件只是压缩的目录),并且它会在每个目录中查找文件。

这里有很多概念需要记住,让我们尝试总结一下:

  • JAR 文件是 ZIP 文件;它们可能包含多个类、属性、文件等。

  • 类路径是一个条目列表;每个条目都是一个 JAR 文件或系统目录。

  • 类加载器在类路径的每个条目中查找资源,所以你可以将类路径资源视为类路径中所有目录的组合(重复的资源不会被覆盖)

如果你还不熟悉类加载器如何在类路径条目中查找资源,这是一个一般的过程;让我们想象一下,你想要加载一个类:test.Test,接下来会发生什么?

  1. 你告诉 JVM 你想加载test.Test

  2. JVM 知道要查找test/Test.class文件。

  3. 它开始在类路径的每个条目中查找它。

  4. 如果资源是一个 ZIP 文件,它“解压”目录。

  5. 它在代表条目的目录中查找资源。

如果你看到默认的类路径资源,你可能会看到一些东西,例如:

java:
    lang:
        String.class
        ….
    io:
        IOException.class
        …
    util:
        List.class

重要的是要注意,类路径中的每个条目不仅存储类文件,实际上可以存储任何类型的资源,存储配置文件,如.properties.xml是很常见的。

没有任何规定你不能在类路径资源中存储其他东西,比如图片、mp3 甚至代码!你可以像从文件系统运行时一样读取和访问类路径资源中的任何内容。唯一不能做的是修改类路径资源的内 容(至少不是通过一些神秘的魔法)。

回到 Clojure 的命名空间

现在我们已经简要回顾了 Java 中包和类路径的工作方式,是时候回到 Clojure 了。你应该明白,Clojure 试图使托管平台透明;这意味着几件非常重要的事情:

  • 你可以用 Java 的类路径做的任何事情,你同样可以用 Clojure 来做(你可以读取配置文件、图片等)。

  • 命名空间使用类路径的方式与 Java 使用包的方式相同,这使得它们容易理解。尽管如此,不要低估它们,Clojure 的命名空间声明可能更加复杂。

让我们实际操作一下,玩一玩命名空间。

玩转命名空间

让我们创建一个新的 Playground,为了创建它,请使用以下命令:

lein new app ns-playground

你可以用 IntelliJ 打开这个项目,就像我们在第一章中做的那样,Clojure 入门

让我们详细看看为我们创建的内容:

玩转命名空间

这个项目结构看起来与 Java 项目类似,我们有:

  • resources: 这些是非源文件,它们被添加到类路径中

  • src: 我们的源代码

  • test: 我们的测试代码

srctest 中的代码已经结构化为命名空间:通过快速查看,我们可以说命名空间的名称是 ns_playground。让我们检查源代码:

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

(defn -main
"I don't do a whole lot ... yet."
  [& args]
  (println "Hello, World!"))
;; Code for src/ns_playground/core.clj

小贴士

:gen-class 被添加在这里是为了创建一个 Java 类,并允许 Java 解释器启动静态 main 方法。如果你不打算创建一个独立程序,则不需要它。

我们可以看到,在顶部使用了 (ns ns-playground.core) 这种形式,正如你可能猜到的,这就是在 Clojure 中声明命名空间的方式。

如果你足够细心,你会注意到一些奇怪的地方;命名空间有一个破折号,而不是像文件夹那样的下划线。

有一些原因导致了这种情况:

  • Clojure 像大多数 Lisp 变量名一样,可以包含破折号(实际上,这是命名变量的首选风格,与 Java 中的驼峰式命名相反)。

  • Clojure 中的每个命名空间都表示为一个包含多个 Java 类的包。命名空间用作 Java 包的名称,正如你所知,破折号在类或包名称中是不被接受的;因此,每个文件名和文件夹名都必须使用短破折号。

小贴士

由于 Lisp 的特性,你可以在变量名中使用破折号(它们将在编译时转换为下划线)。实际上,这是命名变量的推荐方式。在 Clojure 中(以及大多数 Lisp),some-variable-namesomeVariableName 更符合习惯用法。

创建一个新的命名空间

让我们创建一个新的命名空间;在 Cursive Clojure 中,这样做很容易,只需右键点击ns_playground包,然后转到新建 | Clojure 命名空间,它会要求你输入一个名称,我们可以将其命名为hello

这将创建一个包含以下内容的hello.clj文件:

(ns ns-playground.hello)

如你所见,命名空间创建相当简单;你可以通过两个简单的步骤手动完成:

  1. 创建一个新文件;它不必遵循包命名规范,但这有助于保持你的代码顺序,并且是一种事实上的做法。

  2. 添加你的命名空间声明。

就这些!虽然命名空间定义可能会变得相当复杂,因为它是你定义你希望导入的 Java 包、命名空间或你打算使用的这些命名空间中的函数的地方。但通常你只会使用这些功能的一个子集。

小贴士

请记住,Clojure 中的命名空间通常由一个单独的文件表示。

对于你的初始命名空间,我建议你考虑以下两种能力:

:import 允许你从你希望使用的包中导入 Java 类
:require 允许你引入你希望使用的任何 Clojure 命名空间

requireimport的语法都很简单,在我们实际使用它们之前,让我们看看几个例子。

让我们从import选项开始:

(:import java.util.List)

你会注意到这和你在 Java 中能做的是相似的,我们在这里导入的是List接口。

Clojure 的好之处在于它允许你做一些更具体的事情。让我们看看如何一次性导入两个类:

(:import [java.util ArrayList HashMap])

你可以将这个扩展到你想要使用的类的数量。

require选项使用类似的语法,并在其基础上构建更多。让我们检查从一个命名空间中引入单个函数:

(:require [some.package :refer [a-function another-function]])

如你所见,这是熟悉的,有趣的部分在于当你开始导入所有内容时:

(:require [some.package :refer [:all]])

你也可以为你的包内的所有内容使用自定义名称:

(:require [some.package :as s])

;; And then use everything in the package like this:

(s/a-function 5)

或者,你甚至可以组合不同的关键字:

(:require [some.package :as s :refer [a-function]])

让我们尝试一下我们刚刚学到的东西,使用以下代码:

(ns ns-playground.hello
  (:import [java.util Date]))

(def addition +)

(defn current-date []
"Returns the current date"
  (new Date))

(defn <3 [love & loved-ones]
"Creates a sequence of all the {loved-ones} {loved} loves"
  (for [loved-one loved-ones]
    (str love " love " loved-one)))

(defn sum-something [something & nums]
"Adds something to all the remaining parameters"
  (apply addition something nums))

(def sum-one (partial sum-something 1))

注意

你必须已经注意到了<3sum-something函数的参数中的&运算符;这允许这些函数接收任意数量的参数,我们可以像这样调用它们:(sum-something 1 2 3 4 5 6 7 8)或(sum-something)。它们被称为可变参数函数。在 Java 中,你会把这个特性称为varargs

一切看起来都很不错,但我们还没有看到如何从其他包中引入和使用这些函数。让我们写一个测试来看看这是如何完成的。

在 REPL 上使用命名空间

通过使用 REPL 来玩转命名空间是一个很好的方法,我们也会从中获得更好地了解它的好处。

由于我们将要玩转命名空间,我们需要了解一些函数,这些函数将帮助我们在不同命名空间之间移动并引入其他命名空间。函数如下所示:

函数 描述 示例用法
in-ns *ns*设置为名为符号的命名空间,如果需要则创建它。 (in-ns 'ns-playground.core)
require 加载libs,跳过任何已加载的。 (require '[clojure.java.io :as io])
import 对于class-name-symbols中的每个名称,将名称到由package.name命名的类的映射添加到当前命名空间。 (import java.util.Date)
refer 指向ns的所有公共vars,受过滤器约束。 (refer 'clojure.string :only '[capitalize trim])

让我们进入我们的 IntelliJ 的 REPL 窗口。我们可以使用*ns*指令检查我们所在的命名空间。现在让我们试试:

*ns*
=> #<Namespace ns-playground.core>

假设我们需要在ns-playground.hello命名空间内执行代码并测试代码,我们可以使用in-ns函数来完成:

(in-ns 'ns-playground.hello)
=> #<Namespace ns-playground.hello>

我们想知道str做了什么,它似乎接收了三个字符串:

(str "Hello""""world")
=>"Hello world"

现在我们尝试一下for形式:

(for [el ["element1""element2""element3"]] el)
=> ("element1""element2""element3")

(for [el ["element1""element2""element3"]]
  (str "Hello " el))
=> ("Hello element1""Hello element2""Hello element3")

for宏接受一个项目集合,并返回一个新的惰性序列,将for的主体应用于每个元素。

了解这一点后,理解<3函数很容易,让我们试试:

(<3 "They""tea")
=> ("They love tea")

(clojure.repl/doc <3)
ns-playground.hello/<3
([& loved-ones])
  Creates a sequence of all the {loved-ones} {loved} loves

我们已经使用 REPL 测试了一些简单的函数,但现在让我们尝试测试其他一些东西,比如从类路径中读取属性文件。

我们可以在资源文件夹中添加一个test.properties文件,内容如下:

user=user
test=password
sample=5

记住要重启 REPL,因为类路径中某些部分内容的变化对正在运行的 REPL 是不可见的。

让我们尝试以输入流的形式读取我们的属性文件,我们可以使用clojure.java.io命名空间来完成它,并且我们可以像下面这样进行检查:

(require '[clojure.java.io :as io])
(io/resource "test.properties")
=> #<URL file:/Users/iamedu/Clojure4Java/ns-playground/resources/test.properties>
(io/input-stream (io/resource "test.properties"))
=> #<BufferedInputStream java.io.BufferedInputStream@2f584e71>
;; Let's now load it into a properties object
(import [java.util Properties])
=> java.util.Properties
(def props (Properties.)) ;; Don't worry about the weird syntax, we will look it soon.
=> #'ns-playground.core/props
(.load props (io/input-stream (io/resource "test.properties")))
props
=> {"user""user", "sample""5", "test""password"}

我们现在可以定义我们的读取属性函数,我们可以将其输入到 REPL 中:

(defn read-properties [path]
  (let [resource (io/resource path)
        is (io/input-stream resource)
        props (Properties.)]
    (.load props is)
    (.close is)
    props))
=> #'ns-playground.core/read-properties
(read-properties "test.properties")
=> {"user""user", "sample""5", "test""password"}

注意

let形式允许我们创建局部'变量',而不是直接在代码中使用(io/resource path)。我们可以创建一个引用一次并在代码中使用它。它允许我们使用更简单的代码,并且对对象有一个单一的引用。

最后,我们可以重新定义hello命名空间以包含我们检查的所有内容,例如:

(ns ns-playground.hello
  (:require [clojure.java.io :as io])
  (:import [java.util Date Properties]))

(def addition +)

(defn current-date []
"Returns the current date"
  (new Date))

(defn <3 [love & loved-ones]
"Creates a sequence of all the {loved-ones} {loved} loves"
  (for [loved-one loved-ones]
    (str love " love " loved-one)))

(defn sum-something [something & nums]
"Adds something to all the remaining parameters"
  (apply addition something nums))

(defn read-properties [path]
  (let [resource (io/resource path)
        is (io/input-stream resource)
        props (Properties.)]
    (.load props is)
    props))

(def sum-one (partial sum-something 1))

记住要在import中包含Properties类,并为clojure.java.io定义:require关键字。

Clojure 中的测试

Clojure 已经内置了单元测试支持,实际上 Leiningen 已经为我们创建了一个测试;让我们现在看看它。

打开test/ns_playground/core_test.clj文件,你应该能看到以下代码:

(ns ns-playground.core-test
  (:require [clojure.test :refer :all]
            [ns-playground.core :refer :all]))
(deftest a-test
  (testing "FIXME, I fail."
(is (= 0 1))))

再次,正如你所看到的,我们正在使用:require来包含clojure.testns-playground.core包中的函数。

注意

记住,:refer :all的工作方式与 Java 中的char import static clojure.test.*类似。

命令行测试

让我们首先学习如何运行这些测试。从命令行,你可以运行:

lein test

你应该得到以下输出:

lein test ns-playground.core-test

lein test :only ns-playground.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.
Tests failed.

我们看到有一个测试失败,我们稍后会回到这个问题;现在,让我们看看如何在 IntelliJ 中测试。

IntelliJ 中的测试

首先,我们需要一个新的 REPL 配置。您可以像在上一章中学到的那样做。您只需要遵循以下步骤:

  1. 右键单击 project.clj 文件并选择 为 ns-playground 创建 REPL,如图所示:IntelliJ 中的测试

  2. 然后在下一个对话框中点击 确定

  3. 之后,您应该通过右键单击 project.clj 文件并选择 为 ns-playground 运行 REPL 来再次运行 REPL。

  4. 之后,您可以运行任何测试,只需打开您的测试文件,然后在 REPL 中的当前 NS 中选择 工具 | 运行测试。您应该会看到以下截图类似的内容:IntelliJ 中的测试

  5. 如您所见,它表示您的测试目前正在失败。让我们修复它并再次运行我们的测试。将 (is (= 0 1)) 行更改为 (is (= 1 1))

  6. 现在,让我们尝试对我们之前定义的函数进行一些实际的测试;如果您现在不能理解所有代码,请不要担心,您不需要这样做:

    (ns ns-playground.hello-test
      (:import [java.util Date])
      (:require [clojure.test :refer :all]
                [ns-playground.hello :as hello :refer [<3]]
                [ns-playground.core :refer :all]))
    
    (defn- lazy-contains? [col element]
      (not (empty? (filter #(= element %) col))))
    
    (deftest a-test
      (testing "DONT FIXME, I don't fail."
        (is (= 42 42))))
    
    (deftest current-date-is-date
      (testing "Test that the current date is a date"
        (is (instance? Date (hello/current-date)))))
    
    (deftest check-loving-collection
      (testing "Check that I love clojure and you"
        (let [loving-seq (<3 "I""Clojure""you""doggies""chocolate")]
          (is (not (lazy-contains? loving-seq "I love Vogons")))
          (is (lazy-contains? loving-seq "I love Clojure"))
          (is (lazy-contains? loving-seq "I love doggies"))
          (is (lazy-contains? loving-seq "I love chocolate"))
          (is (lazy-contains? loving-seq "I love you")))))
    

注意

我们不能在这里使用 Clojure 的内容函数,因为它具有不同的功能。它会在映射中查找键。

运行测试,您会看到所有内容都正确通过,但这里发生了很多事情,让我们一点一点地过一遍:

(ns ns-playground.core-test
  (:import [java.util Date])
  (:require [clojure.test :refer :all]
            [ns-playground.hello :as hello :refer [<3]]
            [ns-playground.core :refer :all]))

这是命名空间声明,让我们列出它所做的一切:

  • 它声明了 ns-playground.core-test 包。

  • 它导入了 java.util.Date 类。

  • 它使得 clojure.test 命名空间中的所有内容在当前命名空间中可用,如果我们处于 Java 中,我们可能会使用 import static clojure.test.* 来获得类似的效果。我们可以通过使用 :refer :all 关键字来实现这一点。

  • 它使得 ns-playground.hello 命名空间中的所有内容都可以通过 hello 快捷方式访问,但我们需要在 ns-playground.hello 中定义的每个函数或值前加上 hello 前缀,并且它还使得 <3 函数可以在没有前缀的情况下使用。为了生成别名并使所有内容都可以通过 hello 别名访问,我们使用 :as 关键字,然后传递一个向量给 :refer 以包含某些元素。

  • 它使得 ns-playground.core 命名空间中的所有内容在当前命名空间中可用。我们通过使用 :refer :all 关键字来实现这一点。

    (defn- lazy-contains? [col element]
      (not (empty? (filter #(= element %) col))))
    

这是名为 lazy-contains? 的函数的声明,它是一个 boolean 函数,在 Clojure 中通常将其称为谓词。

注意

包含问号的函数名称可能对您来说看起来有些不自然。在 Clojure 和 Lisp 中,您可以在函数名称中使用问号,并且对于返回布尔值的函数来说,这样做是常见的。

它接收两个参数:colelement

函数的实际主体看起来有点复杂,但实际上非常简单。每次您遇到一个看起来与上一节中提到的类似的函数时,请尝试从内向外阅读它。最内层部分如下:

#(= element %)

这是编写只有一个参数的匿名函数的简短方式。如果我们想写另一个函数,该函数将其参数与element比较,而不使用语法糖,我们可以用以下方法实现:

(fn [e1]
  (= element e1))

这是一个匿名函数,换句话说,它是一个没有名字的函数,但它像其他任何函数一样工作;当我们回到函数式编程时,我们将了解更多关于匿名函数的内容。

我们的匿名函数是以下形式的参数:

(filter #(= element %) col)

这个新形式过滤了集合col,并返回一个只包含通过测试的元素的新集合。让我们看看我们使用了预定义的 Clojure 函数even?的例子:

;; This returns only the even numbers in the collection
(filter even? [1 2 3 4])
;;=> (2 4)

我们现在的过滤器函数返回集合中通过#(= element %)测试的每个元素。因此,我们得到与传递给lazy-contains?的元素相等的每个元素。

我们接着询问是否没有任何元素等于col中的element,其形式如下:

(empty? (filter #(= element %) col))

但我们想知道是否有某个元素等于element,所以最后我们否定前面的形式:

(not (empty? (filter #(= element %) col)))

想象一下,如果你必须用 Java 编写这个(并且我要求将匹配元素的每个元素添加到列表中),你将得到类似的东西:

List<T> filteredElements = new ArrayList<T>();
for(T e1 : col) {
    if(e1 == element) {
        filteredElements.add(e1);
    }
}
return !filteredElements.isEmpty();

有一个很大的区别,它更冗长,要理解它我们需要在脑海中“运行”算法。这被称为命令式编程,Clojure 允许我们进行命令式编程以及函数式编程,这是一种声明式编程。当你习惯了,你会发现它比循环更容易推理。

注意

交互式编程意味着向计算机描述每一步应该如何执行。声明式编程只是要求一个结果,而不提供如何实现它的细节。

实际测试很简单理解:

(deftest current-date-is-date
  (testing "Test that the current date is a date"
    (is (instance? Date (hello/current-date)))))

这个测试检查当前日期返回一个java.util.Date实例,is形式像 Java 断言指令一样工作:

(deftest check-loving-collection
  (testing "Check that I love clojure and you"
    (let [loving-seq (<3 "I""Clojure""you""doggies""chocolate")]
      (is (not (lazy-contains? loving-seq "I love Vogons")))
      (is (lazy-contains? loving-seq "I love Clojure"))
      (is (lazy-contains? loving-seq "I love doggies"))
      (is (lazy-contains? loving-seq "I love chocolate"))
      (is (lazy-contains? loving-seq "I love you")))))

这个测试检查<3函数,它检查返回的集合包含I love ClojureI love doggiesI love chocolateI love you,并且不应该包含I love Vogons

这个测试很容易理解。可能不太容易理解的是<3函数,我们将使用 REPL 来探讨它。

摘要

在这一章中,我们了解了一些我们可以用来更好地管理我们的代码的实用工具,并且有一些 Clojure 日常代码的更多示例。特别是:

  • Clojure 命名空间的工作原理及其与 Java 包的关系

  • 使用 Leiningen 和 Cursive Clojure 编写和执行离线单元测试

  • 深入 Clojure 交互式开发工作流程和一点 Clojure 思维模式

  • 编写非常简单的函数并测试它们

在下一章中,我们将学习 Java 互操作性,这样我们就可以开始在 Clojure 代码中使用我们已知的熟悉类和库。

我们还将学习如何从 Java 中使用 Clojure,这样你就可以开始在日常 Java 项目中使用它。

第三章。与 Java 交互

我们对如何组织代码以及它与 Java 中的包的关系有一些了解。现在,你当然需要使用你已有的旧 Java 代码和所有已知的库;Clojure 鼓励一种新的编程思考方式,它还允许你使用所有已经生成的依赖和代码。

Clojure 是一种Java 虚拟机JVM)语言,因此它与大多数 Java 依赖项和库兼容;你应该能够使用所有工具。你也应该能够使用 Java-only 程序与 Clojure 程序一起使用,这需要一些自定义编码,但最终你可以在项目的正确位置使用 Clojure。

要能够做到这一点,我们得学习:

  • 使用 Maven 依赖项

  • 在 Clojure 代码库中使用普通的 Java 类

  • 关于 Clojure 语言的一些更多信息,特别是let语句和解构

  • 为你的 Clojure 代码创建 Java 接口

  • 从其他 Java 项目中使用 Java 接口

使用 Maven 依赖项

假设我们想要编写一个图像处理程序;这是一个非常简单的程序,应该能够创建缩略图。我们的代码库大部分是用 Clojure 编写的,所以我们希望也在 Clojure 中编写这个程序。

有许多 Java 库旨在处理图像,我们决定使用 imgscalr,它非常易于使用,看起来它也存在于 Maven Central (search.maven.org/)。

让我们创建一个新的 Leiningen 项目,如下所示:

lein new thumbnails

现在,我们需要编辑缩略图项目的project.clj文件:

(defproject thumbnails "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.6.0"]])

你可以添加类似于以下代码的imgscalr依赖项:

(defproject thumbnails "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.6.0"]
                 [org.imgscalr/imgscalr-lib "4.2"]])

如你所见,你只需要将依赖项添加到:dependencies向量中,依赖项会自动从以下位置解析:

  • Maven Local

  • Maven Central

  • Clojars

注意

Maven Local 指向你的本地 Maven 仓库,该仓库位于~/.m2文件夹中。如果你愿意,你可以使用 Leiningen 的:local-repo键来更改它。

你可以添加自己的仓库,比如说你需要添加jcenter(Bintray 的 Java 仓库),你可以这样做,如下所示:

(defproject thumbnails "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.6.0"]
                 [org.imgscalr/imgscalr-lib "4.2"]]
  :repositories [["jcenter" "http://jcenter.bintray.com/"]])

注意

Leiningen 支持一系列选项来配置你的项目,更多详细信息,你可以查看 Leiningen 官方仓库中的示例:github.com/technomancy/leiningen/blob/master/sample.project.clj

为了下载依赖项,你必须执行以下代码:

lein deps

小贴士

你不需要每次想要下载依赖项时都执行lein deps,你可以这样做来强制下载,但 Leiningen 会在需要时自动下载它们。

你可以通过运行以下命令来检查当前的依赖项:

lein deps :tree

你会得到类似以下的内容:

 [clojure-complete "0.2.3" :scope "test" :exclusions [[org.clojure/clojure]]]
 [org.clojure/clojure "1.6.0"]
 [org.clojure/tools.nrepl "0.2.6" :scope "test" :exclusions [[org.clojure/clojure]]]
 [org.imgscalr/imgscalr-lib "4.2"]

这列出了你的当前依赖树。

Clojure 互操作语法

Clojure 被设计成一种托管语言,这意味着它可以在不同的环境或运行时中运行。一个重要的哲学方面是 Clojure 不会试图阻碍你的原始宿主;这允许你利用你对底层平台的了解来获得优势。

在这个例子中,我们正在使用 Java 平台。让我们看看我们需要了解的基本中断语法。

创建对象

在 Clojure 中创建对象有两种方式;例如,让我们看看如何创建java.util.ArrayList的一个实例。

(def a (new java.util.ArrayList 20))

在这里,我们使用new特殊形式,正如你所见,它接收一个符号(类的名称java.util.ArrayList)并且在这种情况下它是一个整数。

符号java.util.ArrayList代表classname,任何 Java 类名都可以在这里使用。

接下来,你可以传递任意数量的参数(包括0个参数)。下一个参数是构造函数的参数。

让我们看看可用于创建对象的另一种特殊语法:

(def a (ArrayList.))

这里的不同之处在于我们有一个尾随的点;我们更喜欢看到这种语法,因为它更短。

调用实例方法

一旦我们创建了对象,我们就可以调用实例方法。这类似于我们调用 Clojure 函数的方式,使用特殊的点形式。

如果我们想在新建的列表中添加一个元素,我们必须这样做,如下所示:

(. add a 5)

这种语法可能看起来有点奇怪;以下是这种语法是如何形成的:

(. instance method-name args*)

与创建对象时我们拥有的两种不同选项类似,我们还有另一种方法来做这件事:

(.method-name instance args*)

你可能会认为这更熟悉,因为以点开头的方法名类似于我们编写 Java 方法调用的方式。

调用静态方法或函数

能够调用方法和创建对象给我们带来了巨大的力量,通过这个简单的结构,我们获得了大量的力量;现在我们可以使用大多数 Java 标准库以及自定义库。

然而,我们仍然需要一些其他的东西;其中最重要的一项是调用静态方法。静态方法的感觉类似于 Clojure 函数,没有this实例,你可以像调用正常的 Clojure 函数一样调用它们。

例如,如果我们想从Collections类中获取一个emptyMap,我们可以这样做,如下所示:

(java.util.Collections/emptyMap)

你可以将静态方法视为函数,将类视为命名空间。这并不完全正确,但这个心理模型将帮助你轻松理解它。

访问内部类

在使用 Java-Clojure 互操作时,另一个常见的疑问是如何访问内部类。

假设你想使用java.util.AbstractMap.SimpleEntry类来表示映射中的一个条目。

你可能会认为我们必须做一些类似这样的事情:

(java.util.AbstractMap.SimpleEntry. "key" "value")

这就是你通常在编写 Java 时所做的,但在 Clojure 中,你可能需要做一些类似这样的事情:

(java.util.AbstractMap$SimpleEntry. "key" "value")

我们在这里看到的是实际上是一个暴露的实现细节;如果你查看 JAR 文件中的类或你的类路径中的类,你会看到精确的文件名AbstractMap$SimpleEntry,如下面的截图所示:

访问内部类

这是你需要记住的,总是用父类(或更准确地说,包含类)的前缀和美元符号来前缀内部类(在这种情况下为java.util.AbstractMap)。

编写简单的图像命名空间

现在,让我们编写一些 Clojure 代码并在src/thumbnails/image.clj中创建一个文件。

让我们尝试用 Clojure 的方式来做这件事。首先,写下命名空间声明并评估它:

(ns thumbnails.image
  (:require [clojure.java.io :as io])
  (:import [javax.imageio ImageIO]
           [java.awt.image BufferedImageOp]
           [org.imgscalr Scalr Scalr$Mode]))

现在,打开一个 REPL 并编写以下代码:

(def image-stream (io/input-stream "http://imgs.xkcd.com/comics/angular_momentum.jpg"))(def image (ImageIO/read image-stream))
image
(.getWidth image)

现在我们有一个图像实例,你可以在 REPL 中调用所有的 Java 方法。这是 Clojure 的核心概念之一,你可以在真正编写代码之前通过 REPL 来玩耍并检查你的代码,你可以以交互式的方式进行,如下所示:

编写简单的图像命名空间

最后,我们希望坚持以下内容:

(ns thumbnails.image
  (:require [clojure.java.io :as io])
  (:import [javax.imageio ImageIO]
           [java.awt.image BufferedImageOp]
           [org.imgscalr Scalr Scalr$Mode]))

(defn load-image [image-stream]
  (ImageIO/read image-stream))

(defn save-image [image path]
  (ImageIO/write image "PNG" (io/output-stream path)))

(defn image-size [image]
  [(.getWidth image) (.getHeight image)])

(defn generate-thumbnail [image size]
  (Scalr/resize image Scalr$Mode/FIT_TO_WIDTH size (into-array BufferedImageOp [])))

(defn get-image-width [image-path]
  (let [image (load-image image-path)
        [w _] (image-size image)]
    w))

小贴士

你可以看到,在这段代码中,我们使用了内部类语法,Scalr$ModeMode实际上不是一个类,而是一个enum,你可以为所有其他内部类型使用相同的语法。

代码很简单,它与你已经看到的非常相似;我们将通过两种方式来探讨这些差异。

你可以导入以下类:

  • javax.imageio.ImageIO

  • java.awt.image.BufferedImageOp

  • org.imgscalr.Scalr

  • org.imgscalr.Scalr.Mode

你必须小心处理Mode类,因为它是一个内部类(它位于另一个类中),Clojure 使用特殊的名称Scalr$Mode

小贴士

当导入内部类时,你必须小心命名过程,在 Java 中,你将使用名称:org.imgscalr.Scalr.Mode;在 Clojure 中,你使用名称:org.imgscalr.Scalr$Modeload-imagesave-imageimage-size函数是自我解释的,而generate-thumbnail函数也很简单;然而,它有一个特殊的细节,它将以下内容作为最后一个参数调用:

(into-array BufferedImageOp [])

如果你查看 ImageScalr javadoc(javadox.com/org.imgscalr/imgscalr-lib/4.2/org/imgscalr/Scalr.Mode.html),你可以看到resize方法有几个重载实现;其中大多数都有varargs参数作为它们的最后一个参数。在 Clojure 中,你必须将这些varargs参数声明为数组。

编写测试

现在你已经编写了图像处理代码,现在是编写测试的好时机。

让我们检查一下是否可以生成缩略图。在测试中创建一个新的thumbnails.thumbnail-test命名空间。

记住,如果你创建了文件,它必须命名为test/thumbnails/thumbnail_test.clj

向其中添加以下内容:

(ns thumbnails.thumbnail-test
  (:require [clojure.test :refer :all]
            [clojure.java.io :as io]
            [thumbnails.image :refer :all]))

(deftest test-image-width
  (testing "We should be able to get the image with"
    (let [image-stream (io/input-stream "http://imgs.xkcd.com/comics/angular_momentum.jpg")
          image (load-image image-stream)]
      (save-image image "xkcd-width.png")
      (is (= 600 (get-image-width (io/input-stream "xkcd-width.png")))))))

(deftest test-load-image
  (testing "We should be able to generate thumbnails"
    (let [image-stream (io/input-stream "http://imgs.xkcd.com/comics/angular_momentum.jpg")
          image (load-image image-stream)
          thumbnail-image (generate-thumbnail image 50)]
      (save-image thumbnail-image "xkcd.png")
      (is (= 50 (get-image-width (io/input-stream "xkcd.png")))))))

在这里,我们使用了一些未知的功能,例如let形式和结构化。我们将在下一节中更详细地了解这些内容。

let 语句

Clojure 给我们一个 let 语句来命名事物;它允许我们做类似于其他语言中变量声明的类似事情。

请记住,我们实际上并没有以 Java 中相同的方式创建变量。在 Java 中,每次我们声明变量时,我们都会声明我们想要为后期存储某些内容保留一定量的内存;它可以是一个原始值的值或对象的内存位置。我们在这里所做的只是命名一个值。这是一个有用的局部作用域,可以编写更干净、更容易理解的代码。让我们看看它是如何工作的:

(let [x 42] x)

这是我们能写的最简单的 let 语句,它和直接写 42 完全一样。然而,我们可以写一些更复杂的,比如这个:

(let [x 42
      y (* x x)]
  (println "x is " x " and y " y))

这看起来很直观;为了给 42y 赋值,我们正在将 42 乘以 42 的值赋给它们。最后,我们打印 x is 42 and y 1764。这里需要注意的是两件事:

  • 我们可以在 let 语句中使用之前定义的值;例如,我们在定义 y 时使用 x

  • let 语句创建了一个作用域,我们无法在 let 语句之外使用 xy

let 语句甚至可以嵌套,我们可以做类似于以下示例的事情:

(let [x 42]
  (let [y (* x x)]
    (println "x is " x " and y " y)))

这有点复杂,因为我们打开了一个不必要的括号组,并且写了更多的代码;然而,它允许我们看到词法作用域是如何工作的。

让我们看看另一个有趣的例子:

(let [x 42]
  (let [y (* x x)]
    (let [x 41]
      (println "x is " x " and y " y))))

在这里,我们用 41 隐藏了 x 的值,而且这些不是变量。我们并没有改变内存区域,我们只是在新的 X 值上创建了一个新的作用域。

回到我们的测试,let 语句以以下代码开始:

image (load-image image-path)

这一点很容易理解,但下一行可能有点困难:

[w _] (image-size image)

这看起来相当奇怪;我们将 (image-size image) 的值赋给 [w _],但 [w _] 不是一个符号名!

这里发生的事情是,我们正在使用一个称为解构的机制来分解 (image-size image) 的结果,并只使用我们感兴趣的信息,在这种情况下是图像的宽度。

解构是 Clojure 的一个关键特性,它几乎可以在符号绑定发生的任何地方使用,例如:

  • let 表达式

  • 函数参数列表

解构有助于编写更简洁的代码,但如果你不习惯这样做,可能会觉得有点奇怪。让我们在下一节深入探讨这个问题。

Clojure 中的解构

解构是 Clojure 中一个在其他 Lisp 语言中不常见的特性;其想法是允许你在代码实际上没有增加价值的情况下编写更简洁的代码(例如,从列表中获取第一个元素或从函数中获取第二个参数),并只关注对你重要的事情。

为了更好地理解这一点,让我们看看解构如何帮助你的一个示例:

(let [v [1 2 3]] [(first v) (nth v 2)]) ;; [1 3]

前面的代码有什么问题?实际上没有什么问题,但你需要开始思考 v 是什么,v 的第一个值是什么,nth 函数做什么,以及 v 从哪个索引开始。

我们可以这样做:

(let [[f s t] [1 2 3]] [f t]) ;; [1 3]

一旦你习惯了解构,你就会发现你不需要考虑如何获取你需要的元素。在这种情况下,我们直接从我们的向量中访问第一个、第二个和第三个元素,并使用这三个元素中的第一个和第三个。通过良好的命名,这可以变得更加简单。

让我们现在深入探讨解构是什么。

有两种类型的解构:

  • 按顺序解构:它允许我们将顺序数据结构拆分,并将你感兴趣的值直接绑定到符号上

  • 关联解构:它允许我们将映射拆分,并将你感兴趣的键引用值直接绑定到符号上

按顺序解构

通过一些示例,按顺序解构应该很容易理解;让我们看看:

(let [[f s] [1 2]] f) ;; 1
(let [[f s t] [1 2 3]] [f t]) ;; [1 3]
(let [[f] [1 2]] f);; 1
(let [[f s t] [1 2]] t);; nil
(let [[f & t [1 2]] t);; (2)
(let [[f & t [1 2 3]] t);; (2 3)
(let [[f & t [1 2 3]] t);; (2 3)
(let [[f & [_ t]] [1 2 3]] [f t])

在这些示例中,按照惯例,我们用 f 表示第一个,s 表示第二个,t 表示第三个,而 a 表示其他所有。

同样的解构思想和语法也可以用于函数参数,如下一个示例所示:

(defn func [[f _ t]]
  (+ f t))
(func [1 2 3]) ;; 4

注意

在这里我们使用符号 _,在 Clojure 中有一个惯例,即当你对某个值不感兴趣且未来不需要使用它时,可以使用 _ 符号。在上一个示例中,我们对 func 函数的第二个参数不感兴趣。

如您所见,它让我们能够编写更简洁的代码,只关注重要的部分,即算法或业务。

关联解构

我们已经看到了按顺序解构,它允许通过索引获取序列的某些元素。在 Clojure 中,还有关联解构,它允许你仅获取你感兴趣的映射中的键。

再次,一个例子胜过千言万语:

(let [{a-value a} {: a-value  5}] a-value) ;; 5
(let [{a-value :a c-value :c} {:a 5 :b 6 :c 7}] c-value) ;; 7
(let [{:keys [a c]} {:a 5 :b 6 :c 7}] c) ;; 7
(let [{:syms [a c]} {'a 5 :b 6 'c 7}] c) ;; 7
(let [{:strs [a c]} {:a 5 :b 6 :c 7 "a" 9}] [a c]) ;; [9 nil]
(let [{:strs [a c] :or {c 42}} {:a 5 :b 6 :c 7 "a" 9}] [a c]) ;; [9 42]

小贴士

将符号视为映射的键可能感觉有些奇怪,但重要的是要记住这个特性;它可能在某个时候派上用场。

如您所见,这也很简单,但我们还有更多选项:

  • 我们可以引用一些键并将它们分配一个名称,就像第一个和第二个示例中那样。

  • 我们可以引用关键字键,就像第三个示例中那样

  • 我们可以引用字符串键,就像第四个示例中那样。

  • 我们可以使用 :or 关键字定义默认值!

解构是 Clojure 中最常用的特性之一,它允许你编写非常简洁的代码。

回到我们的测试代码,现在应该很容易理解 get-image-width 函数:

(defn get-image-width [image-path]
  (let [image (load-image image-path)
        [w _] (image-size image)]
    w))

如您所见,它将图像值设置为加载的图像,然后计算宽度,只获取宽度并返回该值。

我们现在可以理解 test-load-image 测试:

 (deftest test-load-image
  (testing "We should be able to generate thumbnails"
    (let [image-stream    (io/input-stream "http://imgs.xkcd.com/comics/angular_momentum.jpg")
          image           (load-image image-stream)
          thumbnail-image (generate-thumbnail image 50)]
      (save-image thumbnail-image "xkcd.png")
      (is (= 50 (get-image-width (io/input-stream "xkcd.png")))))))

它只是初始化一个image-stream值,然后从这个流中加载一个图像并生成缩略图。最后,它加载生成的缩略图并检查图像宽度为 50px。

现在我们已经编写了测试,并且我们确信一切正常工作,我们可以从 Clojure 项目使用我们的小型库,但如果我们想从一个纯 Java(或 groovy,或 scala)项目中使用它会发生什么呢?

将你的代码暴露给 Java

如果你希望能够从其他 JVM 语言中使用 Clojure 代码,在 Clojure 中,你可以通过几种方式做到这一点:

  • 你可以生成新的 Java 类,并像通常一样使用它们;它可以实现某些接口或从某些其他类扩展

  • 你可以即时生成一个代理,这样你可以用很少的代码和努力实现一些框架要求的契约(以类或接口的形式)

  • 你可以使用clojure.java.api包直接从 Java 调用 Clojure 函数

注意

你可以在以下位置找到更多关于如何工作的信息:www.falkoriemenschneider.de/a__2014-03-22__Add-Awesomeness-to-your-Legacy-Java.html

让我们看看我们如何定义一个 Java 类。

创建一个名为thumbnails.image-java的新命名空间,并编写以下代码:

(ns thumbnails.image-java
  (:require [thumbnails.image :as img])
  (:gen-class
    :methods [[loadImage [java.io.InputStream] java.awt.image.BufferedImage]
              [saveImage [java.awt.image.BufferedImage String] void]
              [generateThumbnail [java.awt.image.BufferedImage int] java.awt.image.BufferedImage]]
    :main false
    :name thumbnails.ImageProcessor))

(defn -loadImage [this image-stream]
  (img/load-image image-stream))

(defn -saveImage [this image path]
  (img/save-image image path))

(defn -generateThumbnail [this image size]
  (img/generate-thumbnail image size))

这段代码与我们之前看到的 Clojure 代码非常相似,只是多了gen-class指令和以减号开始的函数名。

让我们更详细地回顾一下gem-class

(:gen-class
    :methods [[loadImage [java.io.InputStream] java.awt.image.BufferedImage]
              [saveImage [java.awt.image.BufferedImage String] void]
              [generateThumbnail [java.awt.image.BufferedImage int] java.awt.image.BufferedImage]]
    :main false
    :name thumbnails.ImageProcessor)

当 Clojure 编译器看到这个时,它会生成类的字节码,但它需要从关键字那里得到一点帮助来知道如何生成类。

  • 名称键定义了类的名称,它是一个符号

  • 主要键定义了此类是否应该有一个主方法

  • 方法键定义了所有方法和它们的签名,它是一个包含三个部分的向量:[methodName [parameterTypes] returnType]

方法被实现为以(字符开始的函数,前缀可以通过前缀键更改。

你还需要告诉 Clojure 预先编译这个类,在 Leiningen 中可以通过:aot实现,转到你的project.clj文件,并添加一个:aot键,以向量形式指定要编译的命名空间或命名空间;如果你希望所有内容都预先编译,可以使用特殊的:all值。

最后,你应该有一个类似这样的结果:

将你的代码暴露给 Java

小贴士

如果你希望所有代码都预先编译,你可以在project.clj中使用:aot :all

现在,我们可以将我们的库安装到我们的 Maven 本地仓库中。转到命令行并运行:

$ lein install

你将得到类似于以下截图的输出:

将你的代码暴露给 Java

现在,你已经准备好了;你应该在你的 Maven 本地仓库中有一个thumbnails:thumbnails:0.1.0-SNAPSHOT依赖项。

从 Groovy 进行测试

为了看到这如何与几种 JVM 语言一起工作,我们将使用 Groovy 和 Gradle 进行测试。我们同样可以轻松地使用 Java 和 Maven。记住,你可以从代码包中获取源代码,这样你就不需要了解这里发生的所有事情。

这里有两个文件;在 build.gradle 文件中,我们指定我们想要使用我们的本地 Maven 仓库,并指定我们的依赖项,如下所示:

apply plugin: 'java'
apply plugin: 'groovy'

repositories {
  jcenter()
  mavenLocal()
}

dependencies {
  compile "thumbnails:thumbnails:0.1.0-SNAPSHOT"
  testCompile "org.spockframework:spock-core:0.7-groovy-2.0"
}

然后,我们可以编写我们的测试,如下面的代码所示:

package imaging.java

import thumbnails.ImageProcessor
import spock.lang.*

class ImageSpec extends Specification {
  def "Test we can use imaging tools"() {
    setup:
      def processor = new ImageProcessor()
      def imageStream = getClass().getResourceAsStream("/test.png")

    when:
      def image = processor.loadImage(imageStream)
      def thumbnail = processor.generateThumbnail(image, 100)

    then:
      thumbnail.getWidth() == 100
  }
}

你可以运行测试:

gradle test

如你所见,从 Java、Groovy 或甚至 Scala 运行你的代码非常简单。还有其他方法可以将 Clojure 与 Java 一起使用,特别是如果你想要实现一个接口或动态生成一个类的话。

代理和 reify

有时候,当你与 Java 库交互时,你必须向某个方法发送特定 Java 类的实例;编写一个类不是最佳选择,你最好在运行时创建一个符合某些框架期望的合约的实例。我们有两个选项来做这件事:

  • 代理:它允许你实现一个 Java 接口或从某个超类扩展。实际上,它创建了一个新对象,当需要时调用你的 Clojure 函数

  • Reify:Reify 允许你实现接口和 Clojure 协议(我们稍后会看到)。它不能扩展类。它的性能比代理更好,应该尽可能使用。

让我们看看一个最小示例:

(import '(javax.swing JFrame JLabel JTextField JButton)
        '(java.awt.event ActionListener)
        '(java.awt GridLayout))
(defn sample []
  (let [frame (JFrame. "Simple Java Integration")
        sample-button (JButton. "Hello")]
    (.addActionListener
     sample-button
     (reify ActionListener
            (actionPerformed
             [_ evt]
             (println "Hello world"))))
    (doto frame
      (.add sample-button)
      (.setSize 100 40)
      (.setVisible true))))
(sample)

提示

doto 是一个宏,允许我们在一个实例上调用多个方法;你可以将其视为分别调用所有方法的一种方式。它与 Java Beans!配合得很好。

打开一个 REPL 并编写代码;它应该显示一个带有按钮的窗口,当点击按钮时(在终端中)会打印 Hello world

代理和 reify

如果你熟悉 Swing,那么你知道 JButtonaddActionListener 需要一个回调,即 ActionListener 的实例,我们使用 reify 函数创建这个实例。

在 Java 代码中,你可能通常会做类似于以下代码的事情:

button.addActionListener(new ActionListener() {
  public void actionPerformed(ActionEvent e) {
    System.out.println("Hello world")'
  }
})

我们称这为匿名类,它本质上与函数式语言中的闭包相同。在先前的例子中,代码被替换为 reify:

  (reify ActionListener
            (actionPerformed
             [_ evt]
             (println "Hello world")))

reify 语句接收你正在实现的接口以及你按列表出的所有方法。在这种情况下,我们只实现了 actionPerformed 以接收动作事件。

这是结构:

(reify InterfaceOrProtocol
  (method [self parameter-list]
    method-body)
  (method2 [self parameter-list]
    method-body))

这创建了一个 ActionListener 的实例,你可以用相同的方式与 servlets、threads、collections、lists 或任何其他由任何人定义的 Java 接口一起使用。

在这里,你需要记住的一件特别的事情是,你需要在方法实现中始终将 self 作为第一个参数添加;它取代了在 Java 中工作的 this 关键字。

概述

在本章中,你通过一些新的原语获得了 Clojure 的很多功能。

如你所见,有 plenty of ways to interact with your current codebase;特别是,你现在可以:

  • 从 Clojure 使用 Java 代码

  • 从 Java 使用 Clojure 代码

  • 通过创建遵守其契约的对象来重用 Java 框架

考虑到我们所有的这些新工具,我们现在准备好处理更多概念和稍微复杂一些的集合和数据结构。

第四章:集合和函数式编程

我们现在已经习惯了在 Clojure 程序中使用 Java 代码,我们也知道如何通过 Java API 公开我们的 Clojure 程序。然而,我们需要更深入地了解 Clojure 及其真正的本质,即函数式编程。

在本章中,我们将涵盖以下主题:

  • 函数式编程基础

  • 持久性集合

  • 顺序和关联集合

  • 序列抽象

  • 集合类型

  • 将函数式编程应用于集合

函数式编程基础

这是一个你可以在很多不同地方读到的话题,似乎每个人都有自己关于函数式编程是什么的看法。然而,在几乎每个定义中,你都会找到一个共同点,这与你从函数式编程中获得的好处相关,例如:

  • 更容易的重用代码

  • 函数更容易测试

  • 函数更容易推理

为了获得这些好处,你需要考虑以下事项:

  • 你应该将函数视为一等公民

  • 函数应该最小化副作用(它们不应该改变任何状态)

  • 函数应该只依赖于它们的参数(这被称为引用透明性)

让我们看看 Java 中两个函数(或方法)的例子,以说明即使在 Java 中,你也可以从编写无副作用和上下文依赖的函数中获得好处。

public void payRent(BigDecimal amount) {
  User user = getCurrentUser();
  if(user.payAmount != amount) {
    System.out.println("Cannot pay");
  } else {
    user.money -= amount;
  }
}

假设你必须测试前面的函数;你可能会遇到一些问题:

  1. 你需要知道如何获取当前用户;你可能需要模拟数据库或会话存储。或者在最坏的情况下,你可能需要一个真实的会话存储服务。

  2. 你如何知道某物是否已支付?

现在,看看这个其他例子:

public boolean payRent(User user, BigDecimal amount, ValidateStrategy strategy) {
  if(strategy.validatePayment(user, amount)) {
    user.money -= amount;
    return true;
  } else {
    return false;
  }
}

上述代码更容易测试;你可以以任何方式创建用户实例,并且使用ValidateStrategy类(或接口),你可以做你需要做的任何事情。

最后,你得到的不是副作用,而是一个返回值,表示操作是否可行。这样你就不需要模拟,并且可以在不同的上下文中重用它。

既然我们已经看到了函数式编程的一些共同点,让我们来看看 Clojure 在函数式编程方面的价值主张:

  • 函数是一等公民或值。就像整数或字符串一样,你可以在运行时创建它们,传递它们,并在其他函数中接收它们。

  • 函数是值,数据结构也是值;它们不能像 Java 中那样被修改,它们是一个固定值,就像整数是一个固定值一样。

  • 不可变数据结构非常重要,它们允许编写安全和简单的多线程代码。

  • 惰性(数据结构的惰性)允许延迟评估直到需要时,只执行你必须执行的操作。

持久集合

Clojure 最重要的特性之一是集合是持久的。这并不意味着它们是持久到磁盘的,这意味着你可以有多个集合的历史版本,并且有保证,更新或在这些版本中查找任何内容都将具有相同的努力(复杂度)。你只需很少的额外内存就能得到所有这些。

为什么?实际上很简单。Clojure 在几个不同的数据结构之间共享一个公共结构。如果你向一个数据结构添加一个元素,Clojure 会共享两个结构之间的公共部分,并跟踪差异。

让我们用一个例子来看看我们的意思:

(def sample-coll [:one :two :three])
(def second-sample-coll (conj sample-coll :four))
(def third-sample-coll (replace {:one 1} sample-coll))

sample-coll ;; [:one :two :three]
second-sample-coll ;; [:one :two :three :four]
third-sample-coll ;; [1 :two :three :four]

正如你所见,当你将新项目conj到一个集合中,或者甚至替换它的一些元素时,你并没有改变原始集合,你只是在生成一个新的版本。

注意

在 Clojure 中,你可以使用conj(conjoin)作为一个动词。这意味着以高效的方式将新元素添加到集合中。

这个新版本不会以任何方式修改你之前拥有的任何集合。

这与常见的命令式语言的工作方式有很大不同,乍一看可能像是一个坏主意,但 Clojure 使用高效的算法,给我们带来了一些优势,特别是:

  • 相同集合的不同版本共享公共部分,使我们能够使用很少的内存。

  • 当集合的一部分不可见时,它会被垃圾回收。

你从这得到的是与可变集合相似的内存使用量。记住,在空间和时间上都有成本,但对于大多数用例来说可以忽略不计。

你为什么要有一个不可变的数据集合?主要优势是它们很容易理解;将它们传递给函数不会改变它们,当你编写并发代码时,没有其他线程会修改你的集合,你也不需要担心显式处理锁。

Clojure 中的集合类型

Clojure 中有三种类型的集合:计数集合、顺序集合和关联集合。它们不是互斥的,这意味着一个集合可能是任何一种。

让我们看看每种类型:

  • 计数集合:计数集合是一个知道其大小在常数时间内的集合。它不需要遍历其元素来获取计数。

  • 顺序集合:顺序集合可以顺序遍历;这是您在处理列表时最常用的方法。最容易想到的类比是 Java 的列表,您可以使用 for 循环或迭代器遍历它。在 Clojure 中,向量、列表和惰性序列都是顺序集合。

  • 关联集合:关联集合可以通过键来访问;映射是这里的首选。我们说过一个集合可以是任何类型;Clojure 的向量也可以用作关联集合,每个元素索引都可以用作键。您可以将其视为一个键为 0、1、2、3 等的映射。

Clojure 有一些函数可以告诉我们给定的集合是否为每种类型,顺序的还是关联的。正如您所猜想的,向量对两者都返回 true。以下是一些函数:

函数名称 列表 向量 映射 惰性序列 集合
counted? true true true false true
sequential? true true false true false
associative? false true true false false

Clojure 中的集合类型

在之前的表格和图中,您可以看到我们考虑了集合,如您所见,它既不是顺序的也不是关联的。

我们应该考虑另一个属性;一个集合是否计数。这意味着一个集合知道它有多少个元素。列表、向量、映射和集合都是计数的;惰性序列不是计数的,因为它们是即时生成的,甚至可能是无限序列。

我们将在本章后面的部分学习更多关于所有这些序列的内容。

序列抽象

Clojure 有一些独特的特性使其与其他 Lisp 不同;其中之一就是序列抽象。您可以将其视为集合遵守的接口。Clojure 有一个标准的函数 API,您可以使用序列。以下是一些这些函数的示例:

  • distinct函数:此函数返回一个序列,只包含原始序列中的每个元素一次:

    (def c [1 1 2 2 3 3 4 4 1 1])
    (distinct c) ;; (1 2 3 4)
    
  • take函数:此函数从原始序列中取出一定数量的元素:

    (take 5 c) ;; (1 1 2 2 3)
    
  • map函数:此函数将函数应用于序列中的每个元素,并创建一个包含这些元素的新序列:

    (map #(+ % 1) c) ;; (2 2 3 3 4 4 5 5 2 2)
    

这里有趣的部分是,这些函数接收并返回序列,并且您可以组合它们。这可以在以下代码中看到:

 (->> c
  (distinct)
  (take 5)
  (reverse)) ;; (4 3 2 1)

;; This is known as a threading macro, it applies distinct, then take 5 then reverse to the
;; collection c so this is the same as writing:
;; (reverse (take 5 (distinct c))) but much more readable

这些只是接受并返回序列的一些函数,但还有更多您可以直接使用。唯一的假设是您的序列参数可以响应三个函数:

  • first:此函数返回序列的第一个元素

  • rest:此函数返回另一个序列,包含除了第一个元素之外的所有元素

  • cons:这个函数接收两个参数,一个项和另一个seq,然后返回一个新的seq,其中包含第二个参数中的所有项

注意

你会发现你更频繁使用的函数之一是seq函数,它可以转换任何集合为序列,甚至 Java 原生的数组和实现了java.util.Iterable接口的对象。其主要用途之一是测试集合是否为空。

Clojure 中的特定集合类型

现在你已经了解了 Clojure 的一般集合特性和序列抽象,现在是了解 Clojure 的具体集合实现的好时机。

向量

向量是 Clojure 的工作马;与 map 一起,它是使用最多的集合。不要害怕它们;它们与 Java 的java.util.Vector无关。它们只是一系列有序值,如列表或数组。

它们具有以下特性:

  • 它们是不可变的

  • 它们可以按顺序访问

  • 它们是结合的(它们是它们索引的映射,这意味着它们的键是 0、1、2 等等)

  • 它们是计数的,意味着它们具有有限的大小

  • 它们具有随机访问,因此你可以几乎以恒定的时间访问任何元素(使用 nth 函数)

  • conj函数将给定元素附加到它们上

小贴士

nth 函数允许我们获取任何seq的第 n 个元素,但你不应不加考虑地使用它。它没有问题处理向量,并且以恒定时间返回,但与列表一起使用时,它需要线性时间,因为它必须遍历所有集合以找到你请求的元素。尽量只与向量一起使用它。

它们具有字面语法;你可以使用方括号定义一个向量,如下所示:

[42 4 2 3 4 4 5 5]

除了字面语法之外,还有一个你可以用来构建向量的函数。vec函数可以从传递给它的任何序列中构建一个向量:

(vec (range 4)) ;; [0 1 2 3]

向量另一个重要的好处是它们被用于函数参数声明和let绑定。

看一下以下示例:

(def f [some-param & some-other-params] …)

(let [one 1 two (f p p-2 p-3)] …)

正如你所见,函数中的参数被定义为向量,与let绑定相同。

关于 Lisp 的主要抱怨之一是它们使用太多的括号,Clojure 在这些结构中使用向量而不是括号的决策受到欢迎,这使得代码更容易阅读。

有几种方法可以访问向量的某个元素:

  • 将向量用作函数:向量可以用作其键的函数;我们还没有讨论 map,但你会看到这是因为它们是结合的:

    (def v [42 24 13 2 11 "a"])
    (v 0) ;; 42
    (v 99) ;; java.lang.IndexOutOfBoundsException
    
  • nth 函数nth函数可以接收一个额外的参数来指示当索引未找到时,它可以像下面这样使用:

    (nth v 0) ;; 42
    (nth v 99 :not-found) ;; :not-found
    (nth v 99) ;; java.lang.IndexOutOfBoundsException
    
  • get 函数get函数可以接收一个额外的参数来指示当索引未找到时,它像下面这样使用。需要注意的是,与 nth 不同,get不能用于序列:

    (get v 0) ;; 42
    (get v 99 :not-found) ;; :not-found
    (get v 99) ;; nil
    

你几乎总是应该使用向量;特别是,如果你想做以下任何一项,没有其他方法可行:

  • 你需要随机访问一个集合(无论是修改还是访问它)

  • 你需要向集合的尾部添加元素

列表

列表在其他 Lisp 中是最重要的集合类型。在 Clojure 中,它们用于表示代码,但它们的功能几乎仅限于这一点。

Clojure 中的列表是单链表;正如你所想象的那样,这意味着它们不适合随机访问(你需要遍历列表直到到达想要的索引)。话虽如此,你仍然可以使用列表作为 API 中每个函数的序列。

让我们列出它们的属性:

  • 它们是不可变的

  • 它们可以按顺序访问

  • 它们不是关联的

  • 它们是有计数的,意味着它们有有限的大小

  • 它们不应该以随机顺序访问。如果你想得到第 99 个元素,那么 Clojure 将不得不访问前 98 个元素才能得到第 99 个。

  • conj 函数将给定元素添加到其前面

你可以使用与上一章中看到的一样解构列表。你不应该害怕使用第一个函数(甚至可以使用带有小索引的 nth)。

小贴士

列表有其用例,随着你学习的深入,你可能会在某些地方(如宏)感到舒适地使用它们,但作为一个经验法则,尽量使用向量。

映射

地图可能是所有语言中最重要的一种集合类型。在 Clojure 中,它们也非常重要。

映射是键值对的集合,这意味着你可以通过键访问或存储一个元素。我们一直称这种类型的集合为关联集合。在 Clojure 中,键可以是任何类型的值,甚至是函数、列表、集合、向量或其他映射。

排序映射和哈希映射

Clojure 中有两种映射类型,每种类型都有其自身的优点。

  • 哈希映射:它们是 Clojure 中最常用的映射形式;映射的文本语法创建了这种类型的映射。它们具有几乎恒定的查找时间,这使得它们在大多数场景中都非常快且可用。它们的缺点是你不能按顺序访问它们。你可以创建它们,如下所示:

    {:one 1 :two 2}
    (hash-map :one 1 :two 2)
    
  • 排序映射:如果你需要能够以特定顺序访问映射的键值对,那么你必须使用排序映射。排序映射的缺点是查找时间不是常数,这意味着它们通过键访问会稍微慢一些。然而,当你需要按键的顺序遍历映射时,这是唯一的方法。这里的一个强约束是键之间必须是可比较的。排序映射可以创建,如下所示:

    (sorted-map :sample 5 :map :6) ;; {:sample 5, :map 6}
    (sorted-map-by > 1 :one 5 :five 3 :three) ;; {5 :five, 3 :three, 1 :one}
    

小贴士

可比较的对象是实现了compareTo接口的对象。

常见属性

关联对象,包括映射具有以下属性:

  • 它们是它们键的函数:

    (def m #{:one 1 :two 2 :three 3})
    (m :one) ;; 1
    (m 1) ;; nil
    
  • 它们可以与关联解构一起使用:

    (let [{:keys [a b c d]} #{:a 5}]
      [a b]) ;
    ; [:a nil]
    
  • 你可以使用get函数访问:

    (get m :one) ;; 1
    (get m 1) ;; nil
    (get m 1 :not-found) ;; :not-found
    

你可以使用seq函数将一个映射转换为seq;你将得到一个序列,其中每个元素都是一个表示映射中键值对的向量:

(seq {:one 1 42 :forty-two :6 6}) ;; ([:one 1] [:6 6] [42 :forty-two])
(doseq [[k v] (seq {:one 1 42 :forty-two :6 6})]
  (println k v))
;; :one 1
;; :6 6
;; 42 :forty-two

Doseq类似于 Java 的 for-each 循环。它对序列中的每个元素执行主体。

它的工作方式如下:(doseq [x sequence] ;;。这与 let 语句的工作方式相同,如果需要,你可以使用解构:

    (body-that-uses x))

集合

Clojure 集合是唯一元素的集合。你可以把它们看作是数学集合,因此 Clojure 有并集、交集和差集等操作。

让我们看看集合的性质:

  • 它们是不可变的

  • 它们是关联的(它们的键是它们的元素)

  • 它们是计数的,这意味着它们有有限的大小

  • 它们的元素是唯一的(最多包含一次)

有序集合和哈希集合

集合有两种类型:哈希集合和有序集合。

  • 哈希集合:除了我们之前看到的属性外,哈希集合是无序的。它们使用哈希映射作为后端实现。

  • 有序集合:除了我们之前看到的属性外,有序集合是有序的。它们可以用作所有期望有序seq的函数的参数。它们可以按顺序访问:

    (doseq [x (->> (sorted-set :b :c :d)
                            (map name))]
        (println x))
    ;; b
    ;; c
    ;; d
    

你也可以无问题地反转它们,过滤它们,或者像向量或列表一样映射它们。

常见属性

集合是关联的,这给了它们一些映射的性质:

  • 它们是它们元素的函数:

    (#{:a :b :c :d} :a);; :a
    (#{:a :b :c :d} :e);; nil
    
  • 它们可以与映射解构一起使用:

    (let [{:keys [b]} #{:b}] b);; :b
    (let [{:keys [c]} #{:b}] b);; nil
    (let [{:keys [c]} (sorted-set :b)] c);; nil
    (let [{:keys [b]} (sorted-set :b)] b);; :b
    
  • 可以使用get函数来访问它们的元素:

    (get #{:a :b :c :d} :e :not-found) ;; :not-found
    (get #{:a :b :c :d} :a) ;; :a
    (get #{:a :b :c :d} :e) ;; nil
    

并集、差集和交集

如果你记得数学集合,你就会知道你可以对它们执行以下三个主要操作:

  • 并集union a b):并集包括ab中的所有元素

  • 差集difference a b):差集是a中所有不在b中的元素

  • 交集intersection a b):它只包括ab中都有的元素

这里有一些例子:

(def a #{:a :b :c :d :e})
(def b #{:a :d :h :i :j :k})

(require '[clojure.set :as s])

(s/union a b) ;; #{:e :k :c :j :h :b :d :i :a}
(s/difference a b) ;; #{:e :c :b}
(s/intersection a b) ;; #{:d :a}

将函数式编程应用于集合

现在我们对集合的工作方式有了更好的理解,我们就有了更好的基础来理解函数式编程以及如何充分利用它。

这需要一种不同的思维方式来解决问题,你应该保持开放的心态。

你可能发现所有这些集合中有一个非常奇怪的特性:它们是不可变的

这确实是一件相当奇怪的事情;如果你习惯了 Java,你怎么可能不添加或从列表或集合中删除元素来编写程序?

这怎么可能呢?在 Java 中,我们习惯于编写forwhile循环。我们习惯于每一步都修改变量。

我们如何处理不可变的数据结构?让我们在接下来的章节中找出答案。

命令式编程模型

软件行业长期以来一直使用单一软件范式;这个范式是命令式编程模型。

在命令式范式中,你必须告诉计算机在每一步要做什么。你负责内存的工作方式,是运行在单核还是多核上,如果你想使用多核,你需要确保正确地改变程序状态并避免并发问题。

让我们看看你将如何以命令式风格计算阶乘:

int factorial(int n) {
    int result = 1;
    for (int i = 1; i <= n; i++) {
        result *= i;
    }
    return result;
}

你正在创建一个可变的结果和一个可变的变量i。每次你通过将其赋值为i + 1来改变变量i的值。你可以通过乘以i来改变结果。计算机只是执行你的命令,比较、加法和乘法。这就是我们所说的命令式编程模型,因为你需要告诉计算机它需要执行的精确命令。

由于各种原因,这在过去一直工作得很好,例如:

  • 内存大小的严格限制迫使程序员尽可能高效地使用内存

  • 考虑单个执行线程及其逐步执行的方式更容易

当然,也有一些缺点。代码很容易变得复杂,世界已经改变;多年前存在的约束已经消失。此外,今天的大多数计算机都有多个 CPU。使用共享可变状态的多线程是负担。

这使得思考变得复杂。即使在单线程程序中我们也会遇到麻烦;想想看,以下代码的结果会是什么?

List l = new ArrayList();
doSomething(l);
System.out.println(l.size());

0吗?你不可能知道,因为doSomething方法通过引用获取列表,并且可以在你不知道的情况下添加或删除东西。

现在,想象一下你有一个多线程程序和一个可以被任何线程修改的单个List。在 Java 世界中,你必须了解java.util.concurrent.CopyOnWriteArrayList,并且你需要了解其实现细节,以便知道何时使用它以及何时不使用它。

即使有了这些结构,思考多线程代码仍然困难。你仍然需要考虑信号量、锁、同步器等等。

在简单情况下,命令式世界可能容易,但它并不简单。整个行业已经意识到这一点,并且有许多新的语言和技术从其他地方汲取了灵感。Java 8 有流式 API,它包括 lambda 块,本质上就是函数。所有这些想法都是从函数式世界借鉴来的。

函数式范式

解决问题的思考方式有很多种;特别是,函数式范式最近变得很重要。这并不是什么新鲜事;Lisp 自从 1958 年构思以来就支持这种编程方式。它可能直到最近才变得强大,因为它需要一种更抽象的思维方式。

为了让你更好地理解,让我们看看几个函数式编程看起来与以下代码相似示例:

(map str [1 2 3 4 5 6]) ;; ("1" "2" "3" "4" "5" "6")

(defn factorial [n]
  (reduce * (range 1 (inc n))))

(factorial 5) ;; 120

如你所见,它看起来相当不同;在第一种情况下,我们将str函数传递给另一个名为map的函数。

在第二种情况下,我们将*函数传递给另一个名为reduce的函数。在这两种情况下,我们使用函数,就像传递一个列表或一个数字一样,它们是第一级公民

函数式编程中的一个重要区别是你不需要告诉机器如何做事。在第一种情况下,map 遍历向量并将str函数应用于每个元素,将其转换为字符串的seq。你不需要递增索引,你只需要告诉 map 你想要应用于每个元素的函数。

在阶乘的情况下,有一个接收*和一个从1nseqreduce函数。

它只是工作,你不需要告诉它如何做任何事情,只需要告诉它你想要做什么。

mapreduce都是高阶函数,因为它们接受函数作为参数;它们也是高级抽象。

注意

高阶函数是那些接受函数作为参数、返回函数作为结果,或者两者都做的函数。

你需要在一个更高级的抽象层面上思考,你不在乎事情是如何真正完成的,只在乎它完成了工作。

这带来了一些好处,如果某个时候映射的实现变成了多线程,你只需要更新版本,就可以准备出发了!

函数式编程和不可变性

你可能也注意到了,函数式编程使得使用不可变结构成为必要,因为你不能在每一步中更改某些或所有状态;你只能描述你想要基于某个其他集合创建新集合的方式,然后获取它。Clojure 的高效集合使得在最小内存使用的情况下共享集合的片段成为可能。

不可变性还有一些其他好处:

  • 你可以与任何你想分享的数据结构,因为你确定没有人会更改你的副本。

  • 调试更简单,因为你可以用一些不可变值而不是可变状态来测试程序。当你得到一个值时,你可以找出哪个函数返回了你得到的值;没有多个地方会修改集合供你检查。

  • 并发编程更简单;再次确定没有人可以更改你的副本,即使在其他并发运行的线程中,这也使得对程序的推理更简单。

懒惰

Clojure 还支持序列转换的惰性求值。让我们看看range函数:

(def r (range))

当运行此函数不带参数时,你正在创建一个从0开始的无限序列。

这是一个无限序列;那么为什么 Clojure 的 REPL 会自动返回?

Clojure 不会在需要之前计算集合值,因此为了获取一个值,你必须做些什么,比如这样:

(take 1 r);; (0)
(take 5 r);; (0 1 2 3 4)

小贴士

如果你尝试在 REPL 中打印一个无限序列,它将会冻结。

在这里,Clojure 首先解析集合 r 中的一个元素,然后解析五个元素,因为它需要在 REPL 中打印它们。

小贴士

惰性评估仅适用于集合和序列处理。其他操作(如加法、方法调用等)是立即执行的。

有趣的是,你可以通过将函数如 filter 和 map 应用到某个集合上来定义一个新的惰性集合。

例如,让我们获取一个包含所有奇数的新的集合:

(def odd-numbers (filter odd? r))
(take 1 odd-numbers)  ;; (1)
(take 2 odd-numbers)  ;; (1 3)
(take 3 odd-numbers)  ;; (1 3 5)

现在,odd-numbers 是一个奇数的无限序列,我们刚刚请求了其中的三个。每当一个数字已经被计算,它就不会再次被计算。让我们稍微改变一下我们的集合,以便理解它是如何工作的:

(defn logging-odd? [number]
    (println number) ;; This is terrible, it is a side effect and a source for problems
                     ;; Clojure encourages you to avoid side effects, but it is pragmatic
                     ;; and relies on you knowing what you are doing
    (odd? number))

(def odd-numbers (filter logging-odd? r))

(take 1 odd-numbers)
;; 0
;; 1
;; 2
;; 3
;; 4
;; 5
;; 6
;; 7
;; 8
;; 9
;; 10
;; 11
;; 12
;; 13
;; 14
;; 15
;; 16
;; 17
;; 18
;; 19
;; 20
;; 21
;; 22
;; 23
;; 24
;; 25
;; 26
;; 27
;; 28
;; 29
;; 30
;; 31
;; => (1)

(take 1 odd-numbers)
;; => (1)

(take 2 odd-numbers)
;; => (1 3)

(take 3 odd-numbers)
;; => (1 3 5)

(take 4 odd-numbers)
;; => (1 3 5 7)

(take 10 odd-numbers)
;; => (1 3 5 7 9 11 13 15 17 19)

正如你所见,一些数字首先被计算;你不应该期望或依赖于在特定时间预先计算特定数量的元素。

此外,请注意,当我们请求相同数量的元素时,计算不会再次执行,因为它已经被缓存。

摘要

在 Clojure 中,集合和函数式编程是非常强大的工具,允许我们使用完全不同的编程范式。

到目前为止,我们已经学到了以下内容:

  • 不可变集合的机制以及 Clojure 中每种集合类型最适合做什么

  • 序列抽象以及许多 Clojure 函数如何使用这种抽象在集合上工作

  • 函数式编程如何使我们能够编写更简单的程序,这些程序在并行环境中表现更好,并帮助我们通过惰性节省资源

在随后的章节中,我们将学习其他新的 Clojure 功能,这些功能为我们提供了比 Java 提供的更新、更强大的实现多态的方法。

第五章。多态方法和协议

现在我们对 Clojure 的工作方式有了更好的理解;我们理解了如何使用不可变数据结构执行简单操作,但我们缺少一些可以使我们的生活更加容易的功能。

如果你已经是一名 Java 程序员一段时间了,你可能会想到多态及其在 Java 中的特定风味。

多态是我们能够重用代码的概念之一。它赋予我们与不同对象使用相同 API 交互的能力。

Clojure 有一个强大的多态范式,它允许我们编写简单的代码,创建与尚未存在的类型交互的代码,并在程序员编写代码时以它未设计的方式扩展代码。

为了帮助我们在 Clojure 中实现多态,我们有两个重要的概念,我们将在本章中介绍:

  • 多态方法

  • 协议

每个都有其自己的用例和它最擅长的事情;我们将在下一节中探讨它们。

我们将通过回顾我们从 Java 中已经了解的知识来学习这些不同的概念,然后我们将从 Clojure 中学习类似的概念,这些概念给我们带来了更多的力量。

Java 中的多态

Java 严重使用多态,其集合 API 基于它。Java 中多态的最好例子可能是以下这些类:

  • java.util.List

  • java.util.Map

  • java.util.Set

我们知道,根据我们的用例,我们应该使用这些数据结构的一个特定实现。

如果我们更喜欢使用有序集,我们可能会使用 TreeSet。

如果我们需要在并发环境中使用 Map,我们将使用 java.util.concurrent.ConcurrentHashMap

美妙的是,你可以使用 java.util.Mapjava.util.Set 接口来编写你的代码,如果你需要更改到另一种类型的 Set 或 Map,因为条件发生了变化或有人为你创建了一个更好的集合版本,你不需要更改任何代码!

让我们看看 Java 中多态的一个非常简单的例子。

假设你有一个形状层次结构;它可能看起来像以下代码:

package shapes;

public interface Shape {
  public double getArea();
}

public class Square implements Shape {
  private double side;
  public Square(double side) {
this.side = side;
  }

  public double getArea() {
    return side * side;
  }

}

public class Circle implements Shape {
  private double radius;
  public Circle(double radius) {
this.radius = radius;
  }

  public double getArea() {
    return Math.PI * radius * radius;
  }

}

你肯定已经意识到了这个概念的力量,你现在可以计算一组图形的所有面积的总和,如下所示:

Java 中的多态

totalArea 方法不关心你传递给它的具体形状类型,你可以添加新的形状类型,例如矩形或梯形。现在,你的相同代码将能够处理新的数据类型。

现在,使用相同的 Java 代码库,假设你想要向你的形状接口添加一个新函数,比如一个简单的 getPerimeter 方法。

这看起来相当简单;你将不得不修改实现 Shape 接口的所有类。我确信你多次遇到过当你无法访问基源时的问题。解决方案是围绕你的 Shape 对象编写包装器,但这引入了更多的类和偶然的复杂性。

Clojure 有它自己对多态的想法,它更简单但也很强大;实际上,你可以用一个非常简单的方法解决周长问题。

解决这个问题的方法之一是使用多方法;让我们看看它们是如何工作的。

Clojure 中的多方法

多方法类似于接口,它们允许你编写一个通用合同,然后一个函数族可以用特定的实现来满足该接口。

它们非常灵活,正如你将看到的,它们让你能够非常精细地控制对特定数据对象将要调用哪个函数。

多方法由三个部分组成:

  • 函数(或方法)声明

  • 分派函数

  • 函数的每种可能实现

多方法最有趣的特点之一是,你可以在不围绕现有对象编写包装器的情况下为现有类型实现新函数。

多方法声明与接口的工作方式相同;您为多态函数定义一个公共契约,如下所示:

(defmulti name docstring? attr-map? dispatch-fn& options)

defmulti 宏定义了您的多方法的契约,它由以下部分组成:

  • 多方法的名称

  • 可选的 doctstring(这是文档字符串)

  • 属性映射

  • dispatch-fn 函数

注意

对于每块内容,都会调用 dispatch 函数;它生成一个分派键,稍后将其与函数实现中的键进行比较。当分派键与函数实现中的键匹配时,调用该函数。

dispatch 函数接收与您正在调用的函数相同的参数,并返回一个用于确定应分派请求的函数的分派键。

每个实现函数都必须定义一个分派键,如果它与 dispatch 函数的结果匹配,则执行此函数。

一个例子应该可以澄清:

(defmultiarea :shape)

(defmethodarea :square [{:keys [side]}] (* side side))

(area {:shape :square :side 5})
;;=> 25

在这里,我们定义了一个名为 area 的多方法;defmulti 语句具有以下结构:

(defmultifunction-name dispatch-function)

在这种情况下,多方法被命名为 area,而 dispatch 函数是 :shape 关键字。

注意

记住,关键字可以用作在映射中查找自己的函数。所以,例如,(:shape {:shape :square}) 的结果是 :square

之后,我们定义一个方法,如下所示:

(defmethodfunction-name dispatch-key [params] function-body)

注意,dispatch-key 总是调用 dispatch-function 并以 params 作为参数的结果。

最后,让我们看看调用,(area {:shape :square :side 5}),这是在调用一个多方法。首先发生的事情是我们调用分派函数 :shape,如下所示:

(:shape {:shape :square :side 5})
;; :square

现在 :square 函数是分派键,我们需要寻找具有该分派键的方法;在这种情况下,我们定义的唯一方法就是它。因此,函数被执行,我们得到 25 的结果。

为正方形和圆形添加面积和周长非常简单,让我们检查一下实现:

(defmethodarea :circle [{:keys [radius]}]
(* Math/PI radius radius))

(defmultiperimeter :shape)

(defmethodperimeter :square [{:keys [side]}] (* side 4))

(defmethodperimeter :circle [{:keys [radius]}] (* 2 Math/PI radius))

现在,我们用很少的努力就定义了如何计算圆和正方形的周长和面积,而且无需定义一个非常严格的对象层次结构。然而,我们才刚刚开始揭示多方法的力量。

注意

关键字可以有命名空间,这有助于您更好地组织代码。有两种方式可以定义命名空间关键字,例如 :namespace/keyword::keyword。当使用 :: 符号时,使用的命名空间是当前命名空间。所以如果您在 REPL 中写 ::test,您将定义 :user/test

让我们尝试另一个例子,将以下代码复制到您的 REPL 中:

Clojure 中的多方法

如您所见,它的工作方式正如您所期望的那样。然而,让我们看看如何创建一个关键字层次结构,使其比这更灵活一些。

关键字层次结构

您可以声明一个关键字从另一个关键字派生,然后响应其他分派键,为此您可以使用 derive 函数:

(derive ::hominid ::primate)

提示

在定义关键字层次结构时,你必须使用命名空间关键字。

在这里,你声明::hominid键是从::animal键派生出来的,你现在可以使用::hominid作为::animal;让我们看看现在的情况:

(walk {:type ::hominid})
;; Primate Walk

在定义层次结构时,我们确实会遇到一些问题,例如,如果相同的关键字从两个冲突的关键字派生出来会发生什么?让我们试一试:

(derive ::hominid ::animal)

(walk {:type ::hominid})
;;java.lang.IllegalArgumentException: Multiple methods in multimethod 'walk' match dispatch value: :boot.user/hominid -> :boot.user/animal and :boot.user/primate, and neither is preferred

我们得到一个错误,说有两个方法匹配分发值。由于我们的 hominid 既从 animal 派生,又从 primate 派生,它不知道该解析哪个。

我们可以用以下方式明确地解决这个问题:

(prefer-method walk ::hominid ::primate)
(walk {:type ::hominid})
; Primate walk

现在,一切工作正常。我们知道在调用带有 hominid 键的 walk 多方法时,我们更喜欢解析为灵长类动物。

你也可以定义一个更具体的方法,仅针对hominid键:

(defmethodwalk ::hominid [_] "Walk in two legs")

(walk {:type ::hominid})
;; Walk in two legs

派生层次结构可能会变得有些复杂,我们可能需要一些函数来检查关系。Clojure 有以下函数来处理类型层次结构。

  • isa?

  • parents

  • descendants

  • underive

isa?

isa函数检查一个类型是否从另一个类型派生,它既与 Java 类一起工作,也与 Clojure 关键字一起工作。

用例子来说明很简单:

(isa? java.util.ArrayListjava.util.List)
;;=> true

(isa? ::hominid ::animal)
;;=> true

(isa? ::animal ::primate)
;;=> false

parents

parent函数返回一个类型的父集,它们可能是 Java 或 Clojure 关键字:

(parents java.util.ArrayList)
;;=> #{java.io.Serializablejava.util.Listjava.lang.Cloneablejava.util.RandomAccessjava.util.AbstractList}

(parents ::hominid)
#{:user/primate :user/animal}

descendants

如你所想,descendants函数返回passd关键字的子集;重要的是要记住,在这种情况下,只允许 Clojure 关键字:

(descendants ::animal)
;;=> #{:boot.user/hominid}

underive

如你所想,underive函数打破了两个类型之间的关系,它只与 Clojure 关键字一起工作:

(underive ::hominid ::animal)
;;=> (isa? ::hominid ::animal)

这个函数通常在开发时间使用,并且允许你以非常简单和动态的方式玩转你的类型层次结构。

按需分发函数

到目前为止,我们使用关键字作为分发函数,但你可以使用任何你喜欢的函数,并且可以传递任意数量的参数。让我们看看一些例子:

(defn dispatch-func [arg1 arg2]
  [arg2 arg1])

这是一个简单的函数,但它展示了两个重要的事实:

  • dispatch函数可以接收多个参数

  • dispatch键可以是任何东西,不仅仅是关键字

让我们看看我们如何使用这个dispatch函数:

(defmulti sample-multimethod dispatch-func)
;; Here we are saying that we want to use dispatch-func to calculate the dispatch-key

(defmethod sample-multimethod [:second :first] [first second] [:normal-params first second])
(defmethod sample-multimethod [:first :second] [first second] [:switch-params second first])

(sample-multimethod :first :second)
;;=> [:normal-params :first: second]

(sample-multimethod :second :first)
;; =>[:switch-params :first: second]

我们对dispatch函数有了更深入的了解;现在你知道你可以实现任何dispatch函数,你就可以非常精细地控制哪个函数会被调用以及何时调用。

让我们再看一个例子,这样我们就可以最终完全理解这个概念:

按需分发函数

现在,多方法的真正力量变得明显。你现在有一种定义多态函数的临时方法,它有定义类型层次结构和执行自己的逻辑以确定最终要调用的函数的可能性。

Clojure 中的协议

多方法只是 Clojure 中实现多态性的一个选项,还有其他方法可以实现多态函数。

协议更容易理解,它们可能感觉更类似于 Java 接口。

让我们尝试使用协议定义我们的形状程序:

(defprotocol Shape
  "This is a protocol for shapes"
  (perimeter [this] "Calculates the perimeter of this shape")
  (area [this] "Calculates the area of this shape"))

在这里,我们定义了一个协议,它被称为 shaped,并且实现此协议的所有内容都必须实现以下两个函数:perimeterarea

实现协议有多种方式;一个有趣的特点是,你甚至可以在没有访问 Java 源代码的情况下,无需重新编译任何内容,将 Java 类扩展为实现协议。

让我们从创建一个实现该类型的记录开始。

Clojure 中的记录

记录的工作方式与映射完全一样,但如果你坚持使用预定义的键,它们会更快。定义一个记录类似于定义一个类,Clojure 事先知道记录将有哪些字段,因此它可以即时生成字节码,使用记录的代码会更快。

让我们定义一个Square记录,如下所示:

(defrecord Square [side]
  Shape
  (perimeter [{:keys [side]}] (* 4 side))
  (area [{:keys [side]}] (* side side)))

在这里,我们正在定义Square记录,并且它具有以下属性:

  • 它只有一个字段,size;这将作为一个只有边键的映射来工作。

  • 它实现了Shape协议

让我们看看如何实例化一个记录以及我们如何使用它:

(Square. 5)
;;=> #user/Square{:size 5}

(def square (Square. 5))

(let [{side :side} square] side)
;;=> 5

(let [{:keys [side]} square] side)
;;=> 5

(doseq [[k v] (Square. 5)] (println k v))
;; :side 5

正如你所见,它的工作方式与映射完全一样,你甚至可以将其与事物关联:

(assoc (Square. 5) :hello :world)

做这件事的缺点是,我们不再有定义记录字段时拥有的性能保证;尽管如此,这仍然是一种为我们的代码提供一些结构的好方法。

我们仍然需要检查我们如何使用我们的周长和面积函数,这很简单。让我们看看:

(perimeter square)
;;=> 20

(area square)
;;=> 25

只是为了继续这个例子,让我们定义Circle记录:

(defrecord Circle [radius]
  Shape
  (perimeter [{:keys [radius]}] (* Math/PI 2 radius))
  (area [{:keys [radius]}] (* Math/PI radius radius)))

(def circle (Circle. 5))

(perimeter circle)
;;=> 31.41592653589793

(area circle)
;;=> 78.53981633974483

其中一个承诺是我们将能够扩展我们的现有记录和类型,而无需触及当前代码。好吧,让我们遵守这个承诺,并检查如何扩展我们的记录而无需触及现有代码。

假设我们需要添加一个谓词来告诉我们一个形状是否有面积;然后我们可以定义下一个协议,如下所示:

(defprotocolShapeProperties
  (num-sides [this] "How many sides a shape has"))

让我们直接进入扩展类型,这将帮助我们为我们的旧协议定义num-sides函数。注意,使用extend-type,我们甚至可以为现有的 Java 类型定义函数:

(extend-type Square
ShapeProperties
  (num-sides [this] 4))

(extend-type Circle
ShapeProperties
  (num-sides [this] Double/POSITIVE_INFINITY))

(num-sides square)
;;=> 4

(num-sides circle)
;;=> Infinity

当你为 Java 类型扩展协议时,协议变得更加有趣。让我们创建一个包括一些列表结构函数的协议:

(defprotocolListOps
  (positive-values [list])
  (negative-values [list])
  (non-zero-values [list]))

(extend-type java.util.List
ListOps
  (positive-values [list]
    (filter #(> % 0) list))
  (negative-values [list]
    (filter #(< % 0) list))
  (non-zero-values [list]
    (filter #(not= % 0) list)))

现在你可以使用正数、负数和non-zero-values与从java.util.List扩展的任何东西一起使用,包括 Clojure 的向量:

(positive-values [-1 0 1])
;;=> (1)

(negative-values [-1 0 1])
;;=> (-1)

(no-zero-values [-1 0 1])
;;=> (-1 1)

扩展java.util.List可能并不那么令人兴奋,因为你可以将这三个定义为函数,并且它们以相同的方式工作,但你可以用这种机制扩展任何自定义的 Java 类型。

摘要

现在我们对 Clojure 的方式有了更好的理解,并且当我们需要多态性时,我们有了更好的把握。我们了解到,当需要多态函数时,我们有几个选项:

  • 如果我们需要一个高度定制的分派机制,我们可以实现多方法

  • 如果我们需要定义复杂的继承结构,我们可以实现多方法

  • 我们可以实现一个协议并定义一个实现该协议的自定义类型

  • 我们可以定义一个协议并使用我们的自定义函数扩展现有的 Java 或 Clojure 类型

Clojure 的多态非常强大。它允许您扩展已存在的 Clojure 或 Java 类型的函数;感觉就像向接口添加方法。最好的事情是您不需要重新定义或重新编译任何东西。

在下一章中,我们将讨论并发——Clojure 的关键特性之一。我们将了解身份和值的理念以及这些关键概念如何使编写并发程序变得更容易。

第六章. 并发

编程已经发生了变化,在过去,我们只需依赖计算机每年变得更快。这变得越来越困难;因此,硬件制造商正在采取不同的方法。现在,他们正在将更多的处理器嵌入到计算机中。如今,看到只有或四个核心的手机并不罕见。

这需要一种不同的软件编写方式,其中我们能够明确地在其他进程中执行一些任务。现代语言正在努力使这项任务对现代开发者来说可行且更容易,Clojure 也不例外。

在本章中,我们将通过回顾 Clojure 的核心概念和原语来了解 Clojure 如何使您能够编写简单的并发程序;特别是,我们需要理解 Clojure 嵌入到语言中的身份和值的概念。在本章中,我们将涵盖以下主题:

  • 使用您的 Java 知识

  • Clojure 的状态和身份模型

  • 承诺

  • 期货

  • 软件事务内存和 refs

  • 原子

  • 代理

  • 验证器

  • 观察者

使用您的 Java 知识

了解 Java 以及熟悉 Java 的线程 API 给您带来了很大的优势,因为 Clojure 依赖于您已经知道的工具。

在这里,您将了解如何使用线程,并且可以将这里看到的一切扩展到执行其他服务。

在继续之前,让我们创建一个新的项目,我们将将其用作所有测试的沙盒。

如下截图所示创建它:

使用您的 Java 知识

修改clojure-concurrency.core命名空间,使其看起来类似于以下代码片段:

(ns clojure-concurrency.core)

(defn start-thread [func]
  (.start (Thread. func)))

这里很容易理解正在发生的事情。我们正在创建一个带有我们的函数的线程,然后启动它;这样我们就可以在 REPL 中使用它,如下所示:

使用您的 Java 知识

小贴士

java.lang.Thread有一个构造函数,它接收一个实现可运行接口的对象。您可以直接传递一个 Clojure 函数,因为 Clojure 中的每个函数都实现了可运行和可调用接口。这意味着您也可以在 Clojure 中透明地使用执行器!

我们将看到nilHello threaded world的值以任意顺序出现。nil值是启动线程返回的值。

Hello threaded world是来自另一个线程的消息。有了这个,我们现在有了了解和理解 Clojure 中线程工作方式的基本工具。

Clojure 的状态和身份模型

Clojure 对并发有非常强烈的看法,为了以更简单的方式理解它,它重新定义了状态和身份的含义。让我们来探讨 Clojure 中这些概念的含义。

当谈论 Java 中的状态时,你可能想到的是你的 Java 类属性值。Clojure 中的状态与 Java 类似,它指的是对象的值,但有一些非常重要的区别,这允许更简单的并发。

在 Clojure 中,身份是一个可能在时间上具有不同值的实体。考虑以下例子:

  • 我有一个身份;我会继续是这一特定个体,我的观点、想法和外观可能会随时间而改变,但我始终是同一个具有相同身份的个体。

  • 你有一个银行账户;它有一个特定的号码,由特定的银行运营。你账户中的金额会随时间变化,但它始终是同一个银行账户。

  • 考虑一个股票代码(例如 GOOG),它在股市中标识一支股票;与其相关的价值会随时间变化,但其身份不会改变。

状态是身份在某个时间点所采取的值。它的一个重要特征是不可变性。状态是身份在某个给定时间点的值的快照。

因此,在先前的例子中:

  • 你现在的身份、你的感受、你的外表以及你的想法是你的当前状态

  • 你目前在银行账户中的钱是其当前状态

  • GOOG 股票的价值是其当前状态

所有这些状态都是不可变的;无论你明天是谁,无论你赢了多少或花了多少,这个事实都是真实的,并且始终是真实的,即在特定的时刻,你处于某种状态。

小贴士

Clojure 的作者 Rich Hickey 是一位出色的演讲者,他有很多演讲,其中解释了 Clojure 背后的思想和哲学。在其中一个演讲(Are We There Yet?)中,他非常详细地解释了状态、身份和时间这个概念。

现在我们来解释 Clojure 中的两个关键概念:

  • 身份:在你的一生中,你有一个单一的身份;你永远不会停止成为你自己,即使你在整个一生中都在不断变化。

  • 状态:在生活的任何时刻,你都是一个特定的人,有喜好、厌恶和某些信念。我们称这种在生活某一时刻的存在方式为状态。如果你看生活中的一个特定时刻,你会看到一个固定的值。没有任何事情会改变你在那个时刻的样子。那个特定的状态是不可变的;随着时间的推移,你会有不同的状态或值,但每个状态都是不可变的。

我们利用这一事实来编写更简单的并发程序。每当你想与一个身份进行交互时,你查看它并获取其当前值(一个时间快照),然后使用你所拥有的进行操作。

在命令式编程中,你通常有一个保证你拥有最新的值,但很难保持其一致性状态。原因在于你依赖于共享的可变状态。

共享的可变状态是为什么你需要使用同步代码、锁和互斥锁的原因。它也是复杂程序和难以追踪的 bug 的原因。

现在,Java 正在从其他编程语言中吸取教训,现在它有了允许更简单并发编程的原语。这些想法来自其他语言和新的想法,所以你有一天可能会在其他主流编程语言中看到与这里所学相似的概念。

没有保证你总能获得最新的值,但不用担心,你只需换一种思维方式,并使用 Clojure 提供的并发原语即可。

这与你在现实生活中工作的方式类似,当你为朋友或同事做某事时,你不知道他们具体发生了什么;你与他们交谈,获取当前事实,然后去工作。在你做这件事的时候,有些事情需要改变;在这种情况下,我们需要一个协调机制。

Clojure 有各种这样的协调机制,让我们来看看它们。

承诺

如果你是一名全栈 Java 开发者,你很可能在 JavaScript 中遇到过承诺。

承诺是简单的抽象,不会对你提出严格的要求;你可以使用它们在另一个线程、轻量级进程或任何你喜欢的地方计算结果。

在 Java 中,有几种方法可以实现这一点;其中之一是使用未来(java.util.concurrentFuture),如果你想得到更类似于 JavaScript 的承诺的实现,有一个叫做jdeferredgithub.com/jdeferred/jdeferred)的很好的实现,你可能之前已经使用过。

从本质上讲,承诺只是你可以提供给调用者的一个承诺,调用者可以在任何给定时间使用它。有两种可能性:

  • 如果承诺已经得到履行,调用将立即返回

  • 如果不是,调用者将阻塞,直到承诺得到履行

让我们看看一个例子;记得使用clojure-concurrency.core包中的start-thread函数:

承诺

小贴士

承诺只计算一次并缓存。所以一旦计算完成,你就可以随意多次使用它们,没有运行时成本!

让我们在这里停下来分析代码,我们正在创建一个名为p的承诺,然后我们启动一个线程执行两件事。

它试图从 pderef 函数试图从承诺中读取值)获取一个值,然后打印 Hello world

我们现在还看不到 Hello world 消息;相反,我们会看到一个 nil 值。那是什么原因呢?

启动线程返回 nil 值,现在发生的事情正是我们最初描述的;p 是承诺,我们的新线程将阻塞它,直到它获得一个值。

为了看到 Hello world 消息,我们需要交付承诺。现在让我们这么做:

承诺

如您所见,我们现在得到了 Hello world 消息!

正如我说的,没有必要使用另一个线程。现在让我们在 REPL 中看看另一个例子:

承诺

小贴士

您可以使用 @p 而不是 deref p,这对本章中的每个身份也适用。

在这个例子中,我们不会创建单独的线程;我们创建承诺,交付它,然后在同一线程中使用它。

如您所见,承诺是一种极其简单的同步机制,您可以选择是否使用线程、执行器服务(这实际上是线程池)或某些其他机制,例如轻量级线程。

让我们看看 Pulsar 库是如何创建轻量级线程的。

Pulsar 和轻量级线程

创建线程是一个昂贵的操作,并且它会消耗 RAM 内存。为了知道在 Mac OS X 或 Linux 上创建线程消耗了多少内存,请运行以下命令:

java -XX:+PrintFlagsFinal -version | grep ThreadStackSize

您在这里看到的内容将取决于您使用的操作系统和 JVM 版本,在 Mac OS X 上使用 Java 1.8u45,我们得到以下输出:

Pulsar 和轻量级线程

我正在为每个线程获取 1024 千字节的堆栈大小。我们能做些什么来提高这些数字?其他语言,如 Erlang 和 Go,一开始就创建几个线程,然后使用这些线程执行任务。能够挂起特定任务并在同一线程中运行另一个任务变得很重要。

在 Clojure 中有一个名为 Pulsar 的库(github.com/puniverse/pulsar),它是一个名为 Quasar 的 Java API 的接口(github.com/puniverse/quasar)。

为了支持 Pulsar,从版本 0.6.2 开始,您需要做两件事。

  • [co.paralleluniverse/pulsar "0.6.2"] 依赖项添加到您的项目中

  • 将一个仪器代理添加到您的 JVM 中(在 project.clj 中添加 adding :java-agents [[co.paralleluniverse/quasar-core "0.6.2"]]

仪器代理应该能够挂起线程中的函数,然后将其更改为其他函数。最后,您的 project.clj 文件应该看起来类似于:

 (defproject clojure-concurrency "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
            :dependencies [[org.clojure/clojure "1.6.0"]
            [co.paralleluniverse/pulsar "0.6.2"]]
  :java-agents [[co.paralleluniverse/quasar-core "0.6.2"]])

让我们用 Pulsar 的轻量级线程(称为 fibers)编写我们的最后一个使用承诺的例子。

Pulsar 在 co.paralleluniverse.pulsar.core 包中提供了自己的承诺,并且可以用作 clojure.core 中承诺的直接替换:

 (clojure.core/use 'co.paralleluniverse.pulsar.core)
(def p1 (promise))
(def p2 (promise))
(def p3 (promise))
(spawn-fiber #(clojure.core/deliver p2 (clojure.core/+ @p1 5)))
(spawn-fiber #(clojure.core/deliver p3 (clojure.core/+ @p1 @p2)))
(spawn-thread #(println @p3))
(clojure.core/deliver p1 99)
;; 203

这个例子更有趣一些,我们使用了 Pulsar 的两个函数:

  • spawn-fiber:这个函数创建一个轻量级线程,如果你想在单个程序中创建数千个纤程,你可以这样做。它们创建成本低,只要你小心编程,不应该会有太多问题。

  • span-thread:这是 Pulsar 的start-thread版本,它创建一个真实线程并运行它。

在这个特定的例子中,我们在两个纤程中计算p2p3,然后在线程中计算p3。此时,所有东西都在等待我们提供p1的值;我们使用deliver函数来完成它。

Pulsar 有其他非常有趣的功能,允许更简单的并行编程,如果你感兴趣,请查看文档。在本章的最后部分,我们将探讨core.async。Pulsar 有一个基于core.async的接口,如果你喜欢,可以使用它。

期货

如果你已经使用 Java 一段时间了,你可能知道java.util.concurrent.Future类,这是 Clojure 对期货的实现,它与 Java 非常相似,只是稍微简单一些。它的接口和用法几乎与承诺相同,但有一个非常重要的区别,当使用期货时,所有操作都会自动在不同的线程中运行。

让我们看看使用期货的一个简单示例,在任何 REPL 中执行以下操作:

(def f (future (Thread/sleep 20000) "Hello world"))
(println @f)

你的 REPL 应该冻结 20 秒,然后打印Hello world

提示

期货也是缓存的,所以你只需要为计算成本付费一次,然后你可以根据需要多次使用它们。

初看之下,期货似乎比承诺要简单得多。你不需要担心创建线程或纤程,但这种方法也有其缺点:

  • 你的灵活性较低;你只能在预定义的线程池中运行期货。

  • 如果你的期货运行时间过长,你需要小心,因为隐含的线程池有可用的线程数量。如果它们都忙碌,一些任务将最终排队等待。

Futures有其用例,如果你有非常少的处理器密集型任务,如果你有 I/O 密集型任务,也许使用带有纤程的承诺是个好主意,因为它们允许你保持处理器空闲,以便并发运行更多任务。

软件事务内存和 refs

Clojure 最有趣的功能之一是软件事务内存STM)。它使用多版本并发控制MVCC),其工作方式与数据库非常相似,实现了一种乐观并发控制的形式。

注意

MVCC 是数据库用于事务的;这意味着事务内的每个操作都有自己的变量副本。在执行其操作后,它检查在事务期间是否有任何使用的变量发生了变化,如果有,则事务失败。这被称为乐观并发控制,因为我们持乐观态度,我们不锁定任何变量;我们让每个线程做其工作,认为它会正确工作,然后检查它是否正确。在实践中,这允许更高的并发性。

让我们从最明显的例子开始,一个银行账户。

现在让我们编写一些代码,进入 REPL 并输入:

(def account (ref 20000))
(dosync (ref-set account 10))
(deref account)

(defn test []
  (dotimes [n 5]
    (println n @account)
    (Thread/sleep 2000))
  (ref-set account 90))

(future (dosync (test)))
(Thread/sleep 1000)
(dosync (ref-set account 5))

尝试同时编写futuredosync函数,以确保得到相同的结果。

这里只有三行代码,但发生了很多事情。

首先,我们定义一个ref (account);refs 是事务中的管理变量。它们也是我们看到的 Clojure 身份概念的第一个实现。请注意,账户现在是一个身份,它在其生命周期中可能具有多个值。

我们现在修改其值,我们在事务内进行此操作,因为 refs 不能在事务之外修改;因此,dosync块。

最后,我们打印账户,我们可以使用(deref account)或@account,就像我们对承诺和未来所做的那样。

Refs 可以从任何地方读取,不需要它在事务内。

现在我们来看一个更有趣的东西,将下面的代码写入或复制到 REPL 中:

(def account (ref 20000))

(defn test []
  (println "Transaction started")
  (dotimes [n 5]
    (println n @account)
    (Thread/sleep 2000))
  (ref-set account 90))

(future (dosync (test)))
(future (dosync (Thread/sleep 4000) (ref-set account 5)))

如果一切顺利,你应该得到一个类似于以下屏幕截图的输出:

软件事务内存和 refs

这可能看起来有点奇怪,发生了什么?

第一笔交易使用账户的当前值开始其过程,而另一笔交易在第一笔交易完成之前修改了账户的值;Clojure 意识到这一点,然后重新启动第一笔交易。

小贴士

你不应该在事务中执行有副作用的函数,因为没有保证它们只会执行一次。如果你需要做类似的事情,你应该使用代理。

这是事务工作的第一个例子,但使用ref-set通常不是一个好主意。

让我们看看另一个例子,将资金从账户A转移到账户B的经典例子:

(def account-a (ref 10000))
(def account-b (ref 2000))
(def started (clojure.core/promise))

(defn move [acc1 acc2 amount]
  (dosync
    (let [balance1 @acc1
           balance2 @acc2]
      (println "Transaction started")
      (clojure.core/deliver started true)
      (Thread/sleep 5000)
      (when (> balance1 amount)
        (alter acc1 - amount)
        (alter acc2 + amount))
      (println "Transaction finished"))))

(future (move account-a account-b 50))
@started
(dosync (ref-set account-a 20))

这是事务工作方式的更好例子;你可能看到类似于以下屏幕截图的内容:

软件事务内存和 refs

首先,你需要理解alter函数的工作方式;它很简单,它接收:

  • 需要修改的 ref

  • 需要应用的功能

  • 额外的参数

因此,这个函数:

(alter ref fun arg1 arg2)

转换为类似以下内容:

(ref-set ref (fun @ref arg1 arg2))

这是修改当前值的推荐方式。

让我们一步一步地描述一下这里正在发生的事情:

  1. 我们定义了两个账户,余额分别为 10000 和 2000。

  2. 我们尝试将 500 个单位从第一个账户移动到第二个账户,但首先我们休眠 5 秒钟。

  3. 我们通过承诺宣布我们已经开始交易。当前线程继续运行,因为它正在等待已启动的值。

  4. 我们将账户-a 的余额设置为 20。

  5. 第一笔交易意识到某些东西已经改变并重新启动。

  6. 交易正在进行并已在此完成。

  7. 没有发生任何事情,因为新的余额不足以移动 50 个单位。

最后,如果你检查余额,比如[@account-a @account-b],你会看到第一个账户有 20,第二个账户有 2000。

有另一个你应该考虑的使用案例;让我们检查以下代码:

(def account (ref 1000))
(def secured (ref false))
(def started (promise))

(defn withdraw [account amount secured]
  (dosync
    (let [secured-value @secured]
      (deliver started true)
      (Thread/sleep 5000)
      (println :started)
      (when-not secured-value
        (alter account - amount))
      (println :finished))))

(future (withdraw account 500 secured))
@started
(dosync (ref-set secured true))

想法是,如果secured设置为 true,你不应该能够提取任何钱。

如果你运行它并检查@account的值,你会发现即使在将secured的值更改为 true 之后,仍然发生了提款。为什么会有这种情况?

原因是,交易只检查你在交易中修改的值或你读取的值;在这里,我们在修改之前读取了受保护的值,所以交易没有失败。我们可以通过以下代码让交易更加小心:

 (ensure secured)
;; instead of
@secured

(def account (ref 1000))
(def secured (ref false))
(def started (promise))

(defn withdraw [account amount secured]
  (dosync
    (let [secured-value (ensure secured)]
      (deliver started true)
      (Thread/sleep 5000)
      (println :started)
      (when-not secured-value
        (alter account - amount))
      (println :finished))))

(future (withdraw account 500 secured))
@started
(dosync (ref-set secured true))

几乎发生了同样的事情。有什么区别?

有一个细微的差别,第二笔交易必须等到第一笔交易完成后才能完成。如果你仔细观察,你会注意到你无法在另一个交易运行之后修改受保护的值。

这类似于一个锁;虽然不是最好的主意,但在某些情况下很有用。

原子

我们现在已经看到了在 Clojure 中如何使用承诺、未来和交易。现在我们将看到原子。

尽管 STM 非常有用且强大,但你会发现实际上它并不常用。

原子是 Clojure 在并发编程中的一个重要工具。

你可以把原子看作是修改单个值的交易。你可能想知道,那有什么好处?想象一下,你有很多事件想要存储在一个单一的向量中。如果你习惯于 Java,你可能知道使用java.util.ArrayList包肯定会出问题;因为你几乎肯定会丢失数据。

在那种情况下,你可能想使用java.util.concurrent包中的一个类,你如何在 Clojure 中保证没有数据丢失?

这很简单,原子来拯救!让我们尝试以下代码:

(clojure.core/use 'co.paralleluniverse.pulsar.core)
(def events (atom []))
(defn log-events [count event-id]
  (dotimes [_ count]
    (swap! events conj event-id)))
(dotimes [n 5]
  (spawn-fiber #(log-events 500 n)))

我们再次使用 Pulsar 及其轻量级线程。在这里,我们定义了一个事件原子和一个log-events函数。

log-events执行了给定次数的swap!

Swap!与它接收到的alter函数类似:

  • 它应该修改的原子

  • 它应用于原子的函数

  • 额外的参数

在这种情况下,它给原子提供了来自以下内容的新值:

(conj events event-id)

然后,我们创建了五个纤维,每个纤维向事件原子添加 500 个事件。

运行此代码后,我们可以检查每个纤维的事件数量如下:

(count (filter #(= 0 %) @events))
;; 500
(count (filter #(= 1 %) @events))
;; 500
(count (filter #(= 2 %) @events))
;; 500
(count (filter #(= 3 %) @events))
;; 500
(count (filter #(= 4 %) @events))
;; 500

如您所见,我们每个纤维有 500 个元素,没有数据丢失,并且使用 Clojure 的默认数据结构。不需要为每个用例使用特殊的数据结构,锁或互斥量。这允许有更高的并发性。

当你修改一个原子时,你需要等待操作完成,这意味着它是同步的。

代理

如果你不在乎某些操作的结果?你只需要触发某物然后忘记它。在这种情况下,代理是你需要的。

代理也在单独的线程池中运行,有两个函数可以用来触发代理:

  • send:这将在隐式线程池中执行你的函数

  • send-off:这试图在一个新线程中执行你的函数,但有一个变化,它将重用另一个线程

如果你想在事务中引起副作用,代理是最佳选择;因为,它们只会在事务成功完成后执行。

它们以非常简单的方式工作,以下是一个示例用法:

(def agt (agent 0))
(defn sum [& nums]
  (Thread/sleep 5000)
  (println :done)
  (apply + nums))
(send agt sum 10) ;; You can replace send with send-off
                  ;; if you want this to be executed in a different thread
@agt

如果你复制并粘贴确切的上一段代码,你会看到一个0然后是一个:done消息,如果你检查@agt的值,那么你会看到值10

代理是执行给定任务并在不同线程中修改一些值的良好方式,其语义比未来或手动修改另一个线程中的值更简单。

验证器

我们已经看到了主要的并发原语,现在让我们看看一些适用于所有这些的实用工具。

我们可以定义一个验证器来检查某个函数的新值是否可取;你可以为 refs、atoms、agents 甚至 vars 使用它们。

validator函数必须接收一个值,如果新值有效则返回true,否则返回false

让我们创建一个简单的validator来检查新值是否小于5

(def v (atom 0))
(set-validator! v #(< % 5))
(swap! v + 10)

;; IllegalStateException Invalid reference state  clojure.lang.ARef.validate (ARef.java:33)

我们遇到了一个异常。原因是新值(10)无效。

你可以无问题地添加4

(swap! v + 4)
;; 4

小心使用验证器和代理,因为你可能不知道何时发生异常:

(def v (agent 0))
(set-validator! v #(< % 5))
(swap! v + 10)
;; THERE IS NO EXCEPTION

监视器

与验证器类似,也存在监视器。监视器是当 Clojure 的身份获得新值时执行的函数。一个重要的问题是监视器运行的线程。监视器在监视实体相同的线程中运行(如果你向代理添加监视器,它将在代理的线程中运行),它将在代理代码执行之前运行,所以你应该小心,并使用旧值和新值而不是使用deref读取值:

(def v (atom 0))
(add-watch v :sample (fn [k i old-value new-value] (println (= i v) k old-value new-value)))
(reset! v 5)

add-watch函数接收:

  • 应该监视的 ref、atom、agent 或 var

  • 将要传递给监视函数的键

  • 一个有四个参数的函数:键、引用本身、旧值和新值

执行前面的代码后,我们得到:

true :sample 0 5

core.async

core.async 是另一种并发编程的方式;它使用轻量级线程和通道的概念在它们之间进行通信。

为什么使用轻量级线程?

轻量级线程被用于像 go 和 Erlang 这样的语言中。它们擅长在单个进程中运行成千上万的线程。

轻量级线程与传统线程之间有什么区别?

传统线程需要预留内存。这也需要一些时间。如果你想要创建几千个线程,你将为每个线程使用相当数量的内存;请求内核这样做也需要时间。

轻量级线程与它们的区别是什么?要拥有几百个轻量级线程,你只需要创建几个线程。不需要预留内存,轻量级线程只是一个软件概念。

这可以通过大多数语言实现,Clojure 通过使用 core.async 添加了一级支持(不改变语言本身,这是 Lisp 力量的体现)!让我们看看它是如何工作的。

有两个概念你需要记住:

  • Goblocks:它们是轻量级线程。

  • 通道:通道是 goblocks 之间通信的一种方式,你可以把它们看作是队列。Goblocks 可以向通道发布消息,其他 goblocks 可以从它们那里获取消息。就像有队列的集成模式一样,也有通道的集成模式,你将发现类似广播、过滤和映射的概念。

现在,让我们分别玩一玩它们,这样你可以更好地理解如何为我们的程序使用它们。

Goblocks

你将在 clojure.core.async 命名空间中找到 goblocks。

Goblocks 非常容易使用,你需要 go 宏,然后你会做类似这样的事情:

(ns test
  (:require [clojure.core.async :refer [go]]))

(go
  (println "Running in a goblock!"))

它们类似于线程;你只需要记住你可以自由地创建 goblocks。在单个 JVM 中可以有成千上万的运行中的 goblocks。

通道

你实际上可以使用任何你喜欢的东西在 goblocks 之间进行通信,但建议你使用通道。

通道有两个主要操作:放置和获取。让我们看看如何做:

 (ns test
  (:require [clojure.core.async :refer [go chan >! <!]]))

(let [c (chan)]
  (go (println (str "The data in the channel is" (<! c))))
  (go (>! c 6)))

就这些!!看起来很简单,正如你所见,我们使用通道时主要有三个主要功能:

  • chan:这个函数创建一个通道,通道可以在缓冲区中存储一些消息。如果你想使用这个功能,你应该只将缓冲区的大小传递给 chan 函数。如果没有指定大小,通道只能存储一条消息。

  • >!:put 函数必须在 goblock 中使用;它接收一个通道和你要发布到它的值。如果通道的缓冲区已满,这个函数会阻塞。它将阻塞,直到从通道中消耗掉一些内容。

  • <!: 这个取函数;这个函数必须在 goblock 内部使用。它接收你从中取通道。它是阻塞的,如果你在通道中没有发布任何东西,它将停车直到有数据可用。

你可以用很多其他函数与通道一起使用,现在让我们添加两个你可能会很快用到的相关函数:

  • >!!: 阻塞 put 操作与put函数工作方式完全相同;除了它可以从任何地方使用。注意,如果通道无法接收更多数据,此函数将阻塞从它运行的那个线程。

  • <!!: 阻塞工作方式与take函数完全相同,除了你可以从任何地方使用它,而不仅仅是 goblocks 内部。只需记住,它会在有数据可用之前阻塞运行它的线程。

如果你查看core.async API 文档(clojure.github.io/core.async/),你会找到相当多的函数。

其中一些看起来与给你类似队列功能的函数相似,让我们来看看broadcast函数:

(ns test
  (:require [clojure.core.async.lab :refer [broadcast]]
            [clojure.core.async :refer [chan <! >!! go-loop]])

(let [c1 (chan 5)
      c2 (chan 5)
      bc (broadcast c1 c2)]
  (go-loop []
    (println "Getting from the first channel" (<! c1))
    (recur))
  (go-loop []
    (println "Getting from the second channel" (<! C2))
    (recur))
  (>!! bc 5)
  (>!! bc 9))

使用这种方式,你可以同时发布到多个通道,如果你想要将多个进程订阅到单个事件源,并且有大量的关注点分离,这会很有帮助。

如果你仔细观察,你也会在那里找到熟悉的函数:mapfilterreduce

备注

根据core.async的版本,其中一些函数可能已经不再存在。

为什么这些函数在这里?这些函数是用来修改数据集合的,对吧?

原因是已经投入了大量努力来使用通道作为高级抽象。

想法是将通道视为事件集合,如果你这样想,就很容易看出你可以通过映射旧通道的每个元素来创建一个新的通道,或者你可以通过过滤掉一些元素来创建一个新的通道。

在 Clojure 的最近版本中,通过转换器,抽象变得更加明显。

转换器

转换器是一种将计算与输入源分离的方法。简单来说,它们是将一系列步骤应用于序列或通道的方法。

让我们看看一个序列的例子:

(let [odd-counts (comp (map count)
                       (filter odd?))
      vs [[1 2 3 4 5 6]
          [:a :c :d :e]
          [:test]]]
  (sequence odd-counts vs))

comp感觉与线程宏类似,它组合函数并存储计算的步骤。

有趣的部分是我们可以使用相同的奇数转换与通道,例如:

(let [odd-counts (comp (map count)
                       (filter odd?))
      input (chan)
      output (chan 5 odd-counts)]
  (go-loop []
    (let [x (<! output)]
      (println x))
      (recur))
  (>!! input [1 2 3 4 5 6])
  (>!! input [:a :c :d :e])
  (>!! input [:test]))

摘要

我们已经检查了核心 Clojure 的并发编程机制,如你所见,它们感觉很自然,并且建立在已经存在的范式之上,如不可变性。最重要的想法是一个身份和值是什么;我们现在知道我们可以有以下的值作为标识符:

  • 引用

  • 原子

  • 代理

我们也可以使用 defer 函数或@快捷键获取它们的值快照。

如果我们想要使用一些更原始的东西,我们可以使用 promises 或 futures。

我们也看到了如何使用线程或 Pulsar 的纤维。Clojure 的许多原语并不特定于某种并发机制,因此我们可以使用任何并行编程机制与任何类型的 Clojure 原语一起使用。

第七章。Clojure 中的宏

在本章中,我们将了解 Clojure 最复杂的设施之一:宏。我们将学习它们的作用、如何编写它们以及如何使用它们。这可能会有些挑战,但也有一些好消息。你应该意识到一些来自你 Java 语言知识的工具可以帮助你更好地理解宏。我们将通过与其他 JVM 语言的比较逐步进行,最终我们将编写一些宏并理解我们已经在使用它们了。

我们将学习以下主题:

  • 理解 Lisp 的基础思想

  • 宏作为代码修改工具

  • 修改 Groovy 代码

  • 编写你的第一个宏

  • 调试你的第一个宏

  • 宏在现实世界中的应用

Lisp 的基础思想

Lisp 与你过去所知的大相径庭。根据保罗·格雷厄姆的说法,有九个想法使 Lisp 与众不同(这些想法自 1950 年代后期以来一直存在),它们是:

  1. 条件语句(记住,我们谈论的是 1950 年代至 1960 年代)

  2. 函数作为一等公民

  3. 递归

  4. 动态类型

  5. 垃圾回收

  6. 程序作为表达式序列

  7. 符号类型

  8. Lisp 的语法

  9. 整个语言始终都在那里:在编译时,在运行时——始终如此!

注意

如果可能的话,阅读保罗·格雷厄姆的论文《书呆子的复仇》(Revenge of the Nerds (www.paulgraham.com/icad.html)),其中他谈论了 Lisp,它为什么与众不同,以及为什么这种语言很重要。

这些想法即使在 Lisp 时代之后也依然繁荣;其中大部分现在都很常见(你能想象一个没有条件语句的语言吗?)。但最后几个想法正是让我们这些 Lisp 爱好者喜欢其语法的理由(我们将在本章中完全理解它们的含义)。

常见语言正在尝试以略有不同的方法实现相同的目标,而你作为一个 Java 开发者,可能已经看到了这一点。

宏作为代码修改工具

宏的第一个也是最常见用途之一是能够修改代码;它们在代码级别上工作,正如你将看到的。我们为什么要这样做呢?让我们通过一些你更熟悉的东西来尝试理解这个问题——Java。

修改 Java 代码

你是否曾经使用过 AspectJ 或 Spring AOP?你是否曾经遇到过使用 ASM 或 Javassist 等工具的问题?

你可能已经在 Java 中使用过代码修改。这在 Java EE 应用程序中很常见,但并不明显。(你有没有想过 @Transactional 注解在 Java EE 或 Spring 应用程序中做什么?)

作为开发者,我们试图自动化我们能做的一切,那么我们怎么能忽略我们自己的开发工具呢?

我们已经尝试创建在运行时修改字节码的方法,这样我们就不必记得打开和关闭资源,或者我们可以解耦依赖关系并实现依赖注入。

如果你使用 Spring,你可能知道以下用例:

  • @Transactional 注解修改注解的方法以确保你的代码被数据库事务包裹

  • @Autowired 注解寻找所需的 bean 并将其注入到注解的属性或方法中

  • @Value 注解寻找配置值并将其注入

你可能还能想到其他几个修改类工作方式的注解。

这里重要的是你要理解我们为什么要修改代码,你可能已经知道一些实现它的机制,包括 AspectJ 和 Spring AOP。

让我们看看在 Java 世界中是如何实现的;这就是 Java 中一个方面的样子:

package macros.java;

public aspect SampleJavaAspect {
pointcutanyOperation() : execution(public * *.*(..));

    Object around() : anyOperation() {
System.out.println("We are about to execute this " + thisJoinPointStaticPart.getSignature());
       Object ret = proceed();
       return ret;
    }
}

Aspect 具有优势,你可以修改任何你喜欢的代码而不必触及它。这也存在一些缺点,因为你可以以原始作者没有预料到的方式修改代码,从而引发错误。

另一个缺点是你有一个极其有限的行动范围;你可以在某些代码周围包装你的修改或在之前或之后执行某些操作。

生成此代码的库非常复杂,它们可以在运行时或编译时创建你的对象的代理或修改字节码。

如你所想,有很多你必须注意的事情,任何事都可能出错。因此,调试可能很复杂。

在 Groovy 中修改代码

Groovy 已经走得更远,它为我们提供了更多解决方案和更多类似宏的功能。

自从 Groovy 1.8 以来,我们已经得到了很多 AST 转换。AST 代表什么?它代表抽象语法树——听起来很复杂,对吧?

在解释所有这些之前,让我们看看它们中的一些功能。

@ToString 注解

@ToString 注解生成一个简单的toString方法,其中包含有关对象类及其属性值的信息。

@TupleConstructor 注解

@TupleConstructor 创建了一个构造函数,能够一次性接受你类中的所有值。以下是一个示例:

@TupleConstructor
class SampleData {
int size
  String color
boolean big
}

new SampleData(5, "red", false") // We didn't write this constructor

@Slf4j 注解

@Slf4j 注解将一个名为 log 的日志实例添加到你的类中,默认情况下,你可以这样做:

log.info'hello world'

这可以在不手动声明日志实例、类名等的情况下完成。你可以用这种类型的注解做很多事情,但它们是如何工作的呢?

现在,AST 是什么,它与 Clojure 宏有什么关系?想想看,它实际上与它们有很大关系。

要回答最后一个问题,你必须稍微了解一些编译器的工作原理。

我们都知道机器(您的机器、JVM、Erlang BEAM 机器)无法理解人类代码,因此我们需要一个过程将开发者编写的内容转换为机器能理解的内容。

这个过程最重要的步骤之一是创建一个语法树,类似于以下图示:

The @Slf4j annotation

这是以下表达式的一个非常简单的例子:

3 + 5

这棵树就是我们所说的抽象语法树。让我们看看一个稍微复杂一点的树的例子,比如下面的这段代码:

if(a > 120) {
  a = a / 5
} else {
  a = 1200 
}

因此,这棵树看起来会像以下图示:

The @Slf4j annotation

如您所见,这个图示仍然相当直接,您可能能理解如何从这样的结构中执行代码。

Groovy 的 AST 转换是一种干预这种生成代码的方法。

如您所想象,这是一个更强大的方法,但现在您正在与编译器生成的代码打交道;这种可能的缺点是代码的复杂性。

让我们以 @Slf4j AST 的代码为例进行检查。它应该相当简单,对吧?它只是添加了一个日志属性:

            private Expression transformMethodCallExpression(Expression exp) {
MethodCallExpressionmce = (MethodCallExpression) exp;
                if (!(mce.getObjectExpression() instanceofVariableExpression)) {
                    return exp;
                }
VariableExpressionvariableExpression = (VariableExpression) mce.getObjectExpression();
                if (!variableExpression.getName().equals(logFieldName)
                        || !(variableExpression.getAccessedVariable() instanceofDynamicVariable)) {
                    return exp;
                }
                String methodName = mce.getMethodAsString();
                if (methodName == null) return exp;
                if (usesSimpleMethodArgumentsOnly(mce)) return exp;

variableExpression.setAccessedVariable(logNode);

                if (!loggingStrategy.isLoggingMethod(methodName)) return exp;

                return loggingStrategy.wrapLoggingMethodCall(variableExpression, methodName, exp);
            }

注意

您可以在 github.com/groovy/groovy-core/blob/master/src/main/org/codehaus/groovy/transform/LogASTTransformation.java 检查完整的代码,它也包含在本章的代码包中。

这看起来一点也不简单。这只是一个片段,看起来仍然非常复杂。这里发生的事情是,您必须处理 Java 字节码格式和编译器复杂性。

这里,我们应该记住保罗·格雷厄姆关于 Lisp 语法提出的第 8 点。

让我们在 Clojure 中编写最后一个代码示例:

(if (> a 120)
  (/ a 5)
  1200)

这段代码有点特别:它感觉非常类似于 AST!这不是巧合。实际上,在 Clojure 和 Lisp 中,您直接编写 AST。这是使 Lisp 成为一个非常简单的语言的特征之一;您直接编写计算机能理解的内容。这可能会帮助您更好地理解代码是数据,数据是代码的原因。

想象一下,如果您能够像修改程序中的任何其他数据结构一样修改 AST 会怎样。但是您可以,这正是宏的作用!

编写您的第一个宏

现在您已经清楚地理解了宏的工作原理和它们的作用,让我们开始使用 Clojure 进行操作。

让我给您出一个挑战:在 Clojure 中编写一个 unless 函数,它的工作方式如下:

(def a 150)

(my-if (> a 200)
  (println"Bigger than 200")
  (println"Smaller than 200"))

让我们试一试;也许可以用以下这样的语法:

(defn my-if [cond positive negative]
  (if cond
    positive
    negative))

您知道如果您编写了这段代码然后运行会发生什么吗?如果您测试它,您将得到以下结果:

Bigger than 200
Smaller than 200
Nil

这里发生了什么?让我们稍作修改,以便我们得到一个值,我们可以理解正在发生的事情。让我们以不同的方式定义它,并返回一个值,以便我们看到一些不同:

      (def a 500)
(my-if (> a 200)
  (do
    (println"Bigger than 200")
    :bigger)
  (do
    (println"Smaller than 200")
    :smaller))

我们将得到以下输出:

Bigger than 200
Smaller than 200
:bigger

这里发生了什么?

当你向函数传递参数时,在函数的实际代码运行之前,所有内容都会被评估,所以在这里,在你函数的主体运行之前,你执行了两个println方法。之后,if运行正确,你得到了:bigger,但我们仍然得到了if的正负情况输出。看起来我们的代码没有正常工作!

我们该如何解决这个问题?使用我们当前的工具,我们可能需要编写闭包并将my-if代码修改为接受函数作为参数:

(defn my-if [cond positive negative]
  (if cond
    (positive)
    (negative)))

      (def a 500)
(my-if (> a 200)
  #(do
    (println"Bigger than 200")
    :bigger)
  #(do
    (println"Smaller than 200")
    :smaller))

这可行,但有几个缺点:

  • 现在代码有很多限制(两个子句现在都应该作为函数)

  • 它不适用于每个单一的情况

  • 这非常复杂

为了解决这个问题,Clojure 给了我们宏。让我们看看它们是如何工作的:

(defmacro my-if [test positive negative]
  (list 'if test positive negative))

(my-if (> a 200)
  (do
    (println"Bigger than 200")
    :bigger)
  (do
    (println"Smaller than 200")
    :smaller))

输出将是这样的:

;; Bigger than 200
;; :bigger

这很棒!它工作了,但发生了什么?为什么我们使用宏,为什么它有效?

注意

宏不是正常的 Clojure 函数;它们应该生成代码,并应该返回一个 Clojure 形式。这意味着它们应该返回一个我们可以用作正常 Clojure 代码的列表。

宏返回将在以后执行的代码。这就是保罗·格雷厄姆列表中的第九点发挥作用的地方:你始终拥有整个语言。

在 C++中,你有一个称为宏的机制;当你使用它时,与实际的 C++代码相比,你可以做的非常有限的事情。

在 Clojure 中,你可以按任何你想要的方式操作 Clojure 代码,你在这里也可以使用完整的语言!由于 Clojure 代码是数据,操作代码就像操作任何其他数据结构一样简单。

注意

宏在编译时运行,这意味着在运行代码时,没有宏的痕迹;每个宏调用都被替换为生成的代码。

调试你的第一个宏

现在,正如你所想象的,由于使用宏时事情可能会变得复杂,应该有一种方法可以调试它们。我们有两个函数来完成这个任务:

  • macroexpand

  • macroexpand-1

它们之间的区别与递归宏有关。没有规则告诉你你不能从宏中使用宏(整个语言始终都在那里,记得吗?)。如果你想完全遍历任何宏,你可以使用macroexpand;如果你想向前迈出一小步,你可以使用macroexpand-1

它们都显示了宏调用生成的代码;这就是当你编译 Clojure 代码时发生的事情。

尝试这个:

(macroexpand-1
'(my-if (> a 200)
    (do
      (println"Bigger than 200")
      :bigger)
    (do
      (println"Smaller than 200")
      :smaller)))

;; (if (> a 200) (do (println"Bigger than 200") :bigger) (do (println"Smaller than 200") :smaller))

宏的内容不外乎如此;你现在对它们有了很好的理解。

然而,你将遇到许多常见问题,以及解决这些问题的工具,你应该了解它们。让我们看看。

引用、语法引号和 unquote

正如你所见,my-if宏在其中使用了 quote:

(defmacro my-if [test positive negative]
  (list 'if test positive negative))

这是因为你需要if符号作为结果形式的第一个元素。

在宏中,引用非常常见,因为我们需要构建代码而不是即时评估它。

在宏中还有一种非常常见的引用类型——语法引用,它使得编写与最终要生成的代码类似的代码变得更容易。让我们改变我们宏的实现,如下所示:

(defmacro my-if [test positive negative]
  '(if test positive negative))

(macroexpand-1
'(my-if (> a 200)
    (do
      (println"Bigger than 200")
      :bigger)
    (do
      (println"Smaller than 200")
      :smaller)))

;; (if clojure.core/test user/positive user/negative)

让我们看看这里会发生什么。首先,(if test positive negative)看起来比我们之前使用的list函数要漂亮得多,但使用macroexpand-1生成的代码看起来相当奇怪。发生了什么?

我们刚刚使用了一种不同的引用形式,它允许我们引用完整的表达式。它做了一些有趣的事情。正如你所见,它将参数更改为完全限定的var名称(clojure.core/testuser/positiveuser/negative)。这是你将来会感激的事情,但现在你不需要这个。

你需要的是 test、positive 和 negative 的值。你如何在宏中获取它们?

使用语法引号,你可以使用 unquote 操作符来请求对某些内容进行内联评估,如下所示:

(defmacro my-if [test positive negative]
(if ~test ~positive ~negative))

让我们再次尝试宏展开并看看我们得到什么:

引用、语法引号和 unquote

Unquote splicing

在宏中还有一些其他情况变得很常见。让我们想象我们想要重新实现>函数作为宏,并保留比较多个数字的能力;那会是什么样子?

可能的第一次尝试可能如下所示:

(defmacro>-macro [&params]
  '(> ~params))

(macroexpand'(>-macro 5 4 3))

上述代码的输出如下:

Unquote splicing

你在这里看到问题了吗?

问题在于我们试图将值列表传递给clojure.core/>,而不是传递值本身。

这可以通过一种称为unquote splicing的方法轻松解决。Unquote splicing 接受一个向量或参数列表,并像使用函数或宏上的as参数一样展开它。

它是这样工作的:

(defmacro>-macro [&params]
  '(> ~@params)) ;; In the end this works as if you had written
                 ;; (> 5 4 3)

(macroexpand'(>-macro 5 4 3))

上述代码的输出如下:

Unquote splicing

你几乎每次在宏的参数数量可变时都会使用 unquote splicing。

gensym

生成代码可能会很麻烦,我们最终会发现一些常见问题。

看看你是否能在以下代码中找到问题:

(def a-var"hello world")

(defmacro error-macro [&params]
  '(let [a-var"bye world"]
     (println a-var)))

;; (macroexpand-1 '(error-macro))
;; (clojure.core/let [user/a-var user/"bye user/world"] (clojure.core/println user/a-var))

这是在生成代码时常见的问题。你覆盖了另一个值,Clojure 甚至不允许你运行这个,并显示如下截图:

gensym

但别担心;还有另一种确保你没有破坏你的环境的方法,那就是gensym函数:

(defmacro error-macro [&params]
  (let [a-var-name (gensym'a-var)]
    `(let [~a-var-name "bye world"]
       (println ~a-var-name))))

gensym函数在宏每次运行时都会创建一个新的var-name,这保证了没有其他var-name会被它覆盖。如果你现在尝试宏展开,你会得到以下结果:

(clojure.core/let [a-var922"bye world"] (clojure.core/println a-var922))

以下截图是前面代码的结果:

gensym

现实世界中的宏

你想知道宏被广泛使用的时候吗?想想 defn;更重要的是,这样做:

(macroexpand-1 '(defn sample [a] (println a)))

;; (def sample (clojure.core/fn ([a] (println a))))

你知道吗,defnclojure.core 中的一个宏,它创建一个函数并将其绑定到当前命名空间中的 var 吗?

Clojure 中充满了宏;如果你想看看一些示例,你可以查看 Clojure 核心,但你可以用宏做什么呢?

让我们看看一些有趣的库:

  • yesql: yesql 库是代码生成的一个非常有趣的示例。它从 SQL 文件中读取 SQL 代码并相应地生成 Clojure 函数。在 GitHub 上的 yesql 项目中查找 defquerydefqueries 宏;这可能会非常有启发性。

  • core.async: 如果你熟悉 go 语言和 goroutines,你可能会希望在 Clojure 语言中也有相同的功能。这并不是必要的,因为你完全可以自己提供它们!core.async 库就是 Clojure 中的 goroutines,它作为一个库提供(不需要进行神秘的语言更改)。这是一个宏强大功能的绝佳例子。

  • core.typed: 使用宏,你甚至可以改变 Lisp 的动态特性。core.typed 库是一个允许你为 Clojure 代码定义类型约束的努力;在这里宏被广泛使用以生成样板代码和检查。这可能是更复杂的事情。

参考文献

如果你需要进一步参考,你可以查看以下列表。有整本书致力于宏这个主题。我特别推荐两本:

摘要

你现在已经理解了宏的强大功能,并且对它们的工作方式有了非常强的掌握,但当我们谈到宏时,我们只是触及了冰山一角。

在本章中,我们学习了以下内容:

  • 宏的工作原理基础

  • 在 Groovy 中修改你的代码

  • 宏与 Java 世界中其他工具的关系

  • 编写你自己的宏

我相信你到目前为止已经享受了使用 Clojure 的过程,并且向前看,我建议你继续阅读和探索这个惊人的语言。

第二部分。模块 2

《Clojure 高性能编程,第二版》

成为在 Clojure 1.7.0 中编写快速且高性能代码的专家

第一章。设计性能

Clojure 是一种安全、函数式编程语言,它为用户带来了巨大的力量和简洁性。Clojure 也是动态和强类型化的,并且具有非常好的性能特性。自然地,计算机上进行的每一项活动都有相应的成本。构成可接受性能的因素因用例和工作负载而异。在当今世界,性能甚至成为几种类型应用程序的决定性因素。我们将从性能的角度讨论 Clojure(它运行在JVMJava 虚拟机)上),以及其运行环境,这正是本书的目标。

Clojure 应用程序的性能取决于各种因素。对于给定的应用程序,理解其用例、设计、实现、算法、资源需求和与硬件的匹配,以及底层软件能力是至关重要的。在本章中,我们将研究性能分析的基础,包括以下内容:

  • 通过用例类型对性能预期进行分类

  • 概述分析性能的结构化方法

  • 一份术语表,通常用于讨论性能方面

  • 每个程序员都应该知道的性能数字

用例分类

不同类型的用例的性能需求和优先级各不相同。我们需要确定各种类型用例的可接受性能构成。因此,我们将它们分类以识别其性能模型。在细节上,对于任何类型的用例,都没有一成不变的性能秘方,但研究它们的普遍性质肯定是有帮助的。请注意,在现实生活中,本节中列出的用例可能相互重叠。

面向用户的软件

面向用户的软件性能与用户的预期紧密相关。差异可能有好几毫秒,对用户来说可能不明显,但与此同时,等待几秒钟以上可能不会受到欢迎。在正常化预期的一个重要元素是通过提供基于持续时间的反馈来吸引用户。处理此类场景的一个好主意是在后台异步启动任务,并从 UI 层轮询它以生成基于持续时间的用户反馈。另一种方法是对用户逐步渲染结果,以平衡预期。

预期并非用户界面性能的唯一因素。常见的技巧,如数据分阶段或预计算,以及其他一般优化技术,可以在很大程度上改善性能方面的用户体验。请记住,所有类型的用户界面都归属于此类用例范畴——网页、移动网页、图形用户界面、命令行、触摸、语音控制、手势……无论你叫它什么。

计算和数据处理任务

非平凡的密集型计算任务需要相应数量的计算资源。所有 CPU、缓存、内存、计算算法的效率和并行化都会涉及到性能的确定。当计算与网络分布或从磁盘读取/分阶段到磁盘结合时,I/O 密集型因素就会发挥作用。这类工作负载可以进一步细分为更具体的用例。

CPU 密集型计算

CPU 密集型计算受限于执行它所花费的 CPU 周期。循环中的算术处理、小矩阵乘法、判断一个数是否为梅森素数等,都会被认为是 CPU 密集型工作。如果算法复杂度与迭代/操作次数N相关,例如O(N)O(N²)等,那么性能取决于N的大小以及每一步所需的 CPU 周期数。对于可并行化的算法,可以通过分配多个 CPU 核心给任务来提高此类任务的性能。在虚拟硬件上,如果 CPU 周期是突发性的,性能可能会受到影响。

内存密集型任务

内存密集型任务受限于内存的可用性和带宽。例如,大文本处理、列表处理等。例如,在 Clojure 中,如果coll是一个由大映射组成的大序列,那么(reduce f (pmap g coll))操作将是内存密集型的,即使我们在这里使用pmap并行化操作。请注意,当内存成为瓶颈时,更高的 CPU 资源无法帮助,反之亦然。内存不可用可能迫使你一次处理更小的数据块,即使你有足够的 CPU 资源可用。如果你的内存最大速度是X,而你的算法在单个核心上以速度X/3访问内存,那么你的算法的多核性能不能超过当前性能的三倍,无论你分配多少 CPU 核心给它。内存架构(例如,SMP 和 NUMA)对多核计算机的内存带宽有贡献。与内存相关的性能也受页面错误的影响。

缓存密集型任务

当一个任务的速度受可用缓存量限制时,它就是缓存受限的。当一个任务从少量重复的内存位置检索值时,例如一个小矩阵乘法,这些值可能会被缓存并从那里获取。请注意,CPU(通常是)有多个缓存层,当处理的数据适合缓存时,性能将达到最佳,但当数据不适合缓存时,处理仍然会发生,但速度会慢一些。可以使用缓存无关算法最大限度地利用缓存。当并发缓存/内存受限线程的数量高于 CPU 核心数时,很可能会在上下文切换时清空指令流水线和缓存,这可能导致性能严重下降。

输入/输出边界任务

如果依赖的 I/O 子系统运行得更快,那么输入/输出I/O)边界任务会运行得更快。磁盘/存储和网络是数据处理中最常用的 I/O 子系统,但它可以是串行端口、USB 连接的卡片阅读器或任何 I/O 设备。I/O 边界任务可能消耗很少的 CPU 周期。根据设备速度、连接池、数据压缩、异步处理、应用缓存等,可能会有助于性能。I/O 边界任务的一个显著方面是,性能通常取决于等待连接/查找的时间以及我们进行的序列化程度,而与其他资源关系不大。

在实践中,许多数据处理工作负载通常是 CPU 受限、内存受限、缓存受限和 I/O 受限任务的组合。这种混合工作负载的性能实际上取决于在整个操作期间 CPU、缓存、内存和 I/O 资源的均匀分布。只有当某个资源变得过于繁忙,以至于无法为另一个资源让路时,才会出现瓶颈情况。

在线事务处理

在线事务处理OLTP)系统按需处理业务交易。它们可以位于用户界面 ATM 机、销售点终端、网络连接的票务柜台、ERP 系统等系统之后。OLTP 系统以低延迟、可用性和数据完整性为特征。它们运行日常业务交易。任何中断或故障都可能对销售或服务产生直接和立即的影响。这些系统预计将被设计为具有弹性,而不是从故障中延迟恢复。当性能目标未指定时,您可能希望考虑优雅降级作为策略。

要求 OLTP 系统回答分析查询是一个常见的错误,它们并不是为此优化的。一个有经验的程序员了解系统的能力,并根据需求提出设计更改是可取的。

在线分析处理

在线分析处理OLAP)系统旨在短时间内回答分析查询。它们通常从 OLTP 操作中获取数据,并且其数据模型针对查询进行了优化。它们基本上提供数据合并(汇总)、钻取和切片切块,以用于分析目的。它们通常使用可以即时优化即席分析查询的特殊数据存储。对于此类数据库来说,提供类似交叉表的功能非常重要。通常,OLAP 立方体用于快速访问分析数据。

将 OLTP 数据输入到 OLAP 系统中可能涉及工作流和多阶段批量处理。这些系统的性能关注点是高效处理大量数据,同时处理不可避免的故障和恢复。

批量处理

批量处理是指预定义任务的自动化执行。这些通常是批量作业,在非高峰时段执行。批量处理可能涉及一个或多个作业处理阶段。通常,批量处理与工作流自动化结合使用,其中一些工作流步骤是在线执行的。许多批量处理作业处理数据阶段,并为下一阶段的处理准备数据。

批量作业通常针对最佳计算资源利用率进行优化。由于对降低某些特定子任务的延迟需求很少或适中,这些系统倾向于优化吞吐量。许多批量作业涉及大量的 I/O 处理,并且通常分布在集群上。由于分布,处理作业时优先考虑数据局部性;也就是说,数据和处理应该是本地的,以避免在读写数据时的网络延迟。

性能的有序方法

实际上,非平凡应用程序的性能很少是巧合或预测的结果。对于许多项目来说,性能不是一种选择(它更是一种必需品),这就是为什么今天这更加重要。容量规划、确定性能目标、性能建模、测量和监控是关键。

调整设计不良的系统以实现性能,如果不说实际上不可能,那么至少比从一开始就设计良好的系统要困难得多。为了达到性能目标,在应用程序设计之前应该知道性能目标。性能目标用延迟、吞吐量、资源利用率和工作负载等术语来表述。这些术语将在本章下一节中讨论。

资源成本可以根据应用场景来识别,例如浏览产品、将产品添加到购物车、结账等。创建代表用户执行各种操作的工作负载配置文件通常是有帮助的。

性能建模是检查应用设计是否支持性能目标的一种现实检验。它包括性能目标、应用场景、约束、测量(基准结果)、工作负载目标,如果有的话,还有性能基线。它不是测量和负载测试的替代品,而是使用这些来验证模型。性能模型可能包括性能测试用例,以断言应用场景的性能特征。

将应用程序部署到生产环境中几乎总是需要某种形式的容量规划。它必须考虑今天的性能目标和可预见的未来的性能目标。它需要了解应用程序架构,以及外部因素如何转化为内部工作负载。它还需要对系统提供的响应性和服务水平的了解。通常,容量规划在项目早期进行,以减轻配置延迟的风险。

性能词汇表

在性能工程中,有几个术语被广泛使用。理解这些术语非常重要,因为它们构成了性能相关讨论的基础。这些术语共同构成了一个性能词汇表。性能通常通过几个参数来衡量,每个参数都有其作用——这样的参数是词汇表的一部分。

延迟

延迟是指单个工作单元完成任务所需的时间。它并不表示任务的顺利完成。延迟不是集体的,它与特定的任务相关联。如果两个类似的工作j1j2分别耗时 3 毫秒和 5 毫秒,它们的延迟将如此处理。如果j1j2是不同的任务,这就没有区别。在许多情况下,类似工作的平均延迟被用于性能目标、测量和监控结果。

延迟是衡量系统健康状况的重要指标。高性能系统通常依赖于低延迟。高于正常水平的延迟可能由负载或瓶颈引起。在负载测试期间测量延迟分布很有帮助。例如,如果超过 25%的类似工作,在相似负载下,其延迟显著高于其他工作,那么这可能是值得调查的瓶颈场景的指标。

当一个名为j1的任务由名为j2j3j4的较小任务组成时,j1的延迟不一定是j2j3j4各自延迟的总和。如果j1的任何子任务与另一个任务并发,j1的延迟将小于j2j3j4延迟的总和。I/O 受限的任务通常更容易出现更高的延迟。在网络系统中,延迟通常基于往返到另一个主机,包括从源到目的地的延迟,然后返回源。

Throughput(吞吐量)

吞吐量是在单位时间内完成的成功任务或操作的数量。在单位时间内执行的最顶层操作通常属于同一类型,但延迟可能不同。那么,吞吐量告诉我们关于系统的什么信息?它是系统执行的速度。当你进行负载测试时,你可以确定特定系统可以执行的最大速率。然而,这并不能保证结论性的、整体的和最大性能速率。

吞吐量是决定系统可扩展性的因素之一。较高层次任务的吞吐量取决于并行生成多个此类任务的能力,以及这些任务的平均延迟。吞吐量应在负载测试和性能监控期间进行测量,以确定峰值吞吐量和最大持续吞吐量。这些因素有助于系统的规模和性能。

Bandwidth(带宽)

带宽是指通信通道上的原始数据速率,以每秒一定数量的比特来衡量。这包括不仅包括有效载荷,还包括执行通信所需的所有开销。一些例子包括:Kbits/sec,Mbits/sec,等等。大写 B,如 KB/sec 表示字节,即每秒千字节。带宽通常与吞吐量进行比较。虽然带宽是原始容量,但对于同一系统,吞吐量是成功任务完成率,这通常涉及往返。请注意,吞吐量是指涉及延迟的操作。为了在给定的带宽下实现最大吞吐量,通信/协议开销和操作延迟应尽可能小。

对于存储系统(如硬盘、固态硬盘等),衡量性能的主要方式是IOPS(每秒输入输出),它是通过传输大小乘以的,表示为每秒字节数,或者进一步表示为 MB/sec,GB/sec 等等。IOPS 通常用于顺序和随机工作负载的读写操作。

将一个系统的吞吐量映射到另一个系统的带宽可能会导致处理两个系统之间的阻抗不匹配。例如,一个订单处理系统可能执行以下任务:

  • 与磁盘上的数据库进行交易

  • 将结果通过网络发送到外部系统

根据磁盘子系统的带宽、网络的带宽以及订单处理的执行模型,吞吐量可能不仅取决于磁盘子系统和网络的带宽,还取决于它们当前的负载情况。并行性和流水线是增加给定带宽吞吐量的常见方法。

基准和基准测试

性能基准,或简称为基准,是参考点,包括对已知配置中良好定义和理解的性能参数的测量。基准用于收集我们可能稍后为另一个配置基准测试的相同参数的性能测量。例如,收集“在 50 个并发线程负载下 10 分钟内的吞吐量分布”是我们可以使用作基准和基准测试的这样一个性能参数。基准与硬件、网络、操作系统和 JVM 配置一起记录。

性能基准测试,或简称为基准测试,是在各种测试条件下记录性能参数测量的过程。基准测试可以由性能测试套件组成。基准测试可能收集从小到大的数据量,并且可能根据用例、场景和环境特性而持续不同的时间。

基准是某个时间点进行的基准测试的结果。然而,基准与基准无关。

性能分析

性能分析,或简称为分析,是对程序在运行时执行的分析。程序可能由于各种原因表现不佳。分析器可以分析和找出程序各部分的执行时间。可以在程序中手动放置语句以打印代码块的执行时间,但随着您尝试迭代地改进代码,这会变得非常繁琐。

分析器对开发者非常有帮助。根据分析器的工作原理,主要有三种类型——仪器化、采样和基于事件的。

  • 基于事件的性能分析器:这些分析器仅适用于选定的语言平台,并在开销和结果之间提供良好的平衡;Java 通过 JVMTI 接口支持基于事件的性能分析。

  • 仪器化分析器:这些分析器在编译时或运行时修改代码以注入性能计数器。它们本质上是侵入性的,并增加了显著的性能开销。然而,您可以使用仪器化分析器非常选择性地分析代码区域。

  • 采样分析器:这些分析器在“采样间隔”暂停运行时并收集其状态。通过收集足够的样本,它们可以了解程序大部分时间花在了哪里。例如,在 1 毫秒的采样间隔下,分析器在一秒钟内会收集 1000 个样本。采样分析器也适用于执行速度超过采样间隔的代码(即,代码可能在两次采样事件之间执行几个工作迭代),因为暂停和采样的频率与任何代码的整体执行时间成比例。

分析的目的不仅仅是测量执行时间。有能力的分析器可以提供内存分析、垃圾回收、线程等方面的视图。这些工具的组合有助于找到内存泄漏、垃圾回收问题等。

性能优化

简而言之,优化是在性能分析之后增强程序资源消耗的过程。性能不佳的程序的症状可以从高延迟、低吞吐量、无响应、不稳定、高内存消耗、高 CPU 消耗等方面观察到。在性能分析期间,一个人可以对程序进行性能分析,以确定瓶颈并通过观察性能参数逐步调整性能。

更好和合适的算法是优化代码的全方面好方法。CPU 密集型代码可以通过计算成本更低的操作进行优化。缓存密集型代码可以尝试使用更少的内存查找来保持良好的命中率。内存密集型代码可以使用自适应内存使用和保守的数据表示来存储在内存中,以进行优化。I/O 密集型代码可以尝试尽可能少地序列化数据,并且操作批处理将使操作更少地聊天,从而提高性能。并行性和分布式是其他,整体上好的提高性能的方法。

并发与并行性

我们今天使用的绝大多数计算机硬件和操作系统都提供了并发性。在 x86 架构中,对并发的硬件支持可以追溯到 80286 芯片。并发是指在同一台计算机上同时执行多个进程。在较老的处理器中,并发是通过操作系统内核的上下文切换来实现的。当并发部分由硬件并行执行而不是仅仅切换上下文时,这被称为并行性。并行性是硬件的特性,尽管软件堆栈必须支持它,你才能在你的程序中利用它。我们必须以并发的方式编写你的程序,以利用硬件的并行性特性。

虽然并发是利用硬件并行性和加快操作的自然方式,但值得记住的是,如果并发显著高于硬件支持的并行度,可能会将任务调度到不同的处理器核心,从而降低分支预测并增加缓存未命中。

在较低级别,使用进程/线程、互斥锁、信号量、锁定、共享内存和进程间通信来实现并发。JVM 对这些并发原语和线程间通信有出色的支持。Clojure 既有低级也有高级并发原语,我们将在并发章节中讨论。

资源利用率

资源利用率是指应用程序消耗的服务器、网络和存储资源。资源包括 CPU、内存、磁盘 I/O、网络 I/O 等。可以从 CPU 密集型、内存密集型、缓存密集型和 I/O 密集型任务的角度分析应用程序。资源利用率可以通过基准测试和测量特定吞吐量下的利用率来得出。

工作负载

工作负载是指应用程序需要完成的工作量量化。它包括总用户数、并发活跃用户数、交易量、数据量等。处理工作负载时应考虑负载条件,例如数据库当前持有的数据量、消息队列的填充程度、I/O 任务的积压情况以及更多。

每个程序员都应该知道的延迟数字

随着时间的推移,硬件和软件都取得了进步。各种操作的延迟使事情变得有对比性。以下表格展示了 2015 年的延迟数字,经加州大学伯克利分校的 Aurojit Panda 和 Colin Scott 许可复制(www.eecs.berkeley.edu/~rcs/research/interactive_latency.html)。每个程序员都应该知道的延迟数字如下所示:

操作 2015 年所需时间
L1 缓存引用 1ns (纳秒)
分支预测错误 3 ns
L2 缓存引用 4 ns
互斥锁锁定/解锁 17 ns
使用 Zippy(Zippy/Snappy: code.google.com/p/snappy/)压缩 1KB 2μs (1000 ns = 1μs: 微秒)
在商品网络上发送 2000 字节 200ns(即 0.2μs)
SSD 随机读取 16 μs
同一数据中心内的往返 500 μs
从 SSD 顺序读取 1,000,000 字节 200 μs
磁盘寻道 4 ms (1000 μs = 1 ms)
从磁盘顺序读取 1,000,000 字节 2 ms
数据包往返 CA 到荷兰 150 ms

前面的表格显示了计算机中的操作及其因操作而产生的延迟。当 CPU 核心在 CPU 寄存器中处理一些数据时,它可能需要几个 CPU 周期(以 3 GHz CPU 为例,每纳秒运行 3000 个周期),但一旦它必须回退到 L1 或 L2 缓存,延迟就会慢数千倍。前面的表格没有显示主内存访问延迟,大约为 100 纳秒(根据访问模式而变化)——大约是 L2 缓存的 25 倍。

概述

我们学习了深入思考性能的基础知识。我们了解了常见的性能词汇,以及性能方面可能变化的用例。通过查看不同硬件组件的性能数据,我们得出了性能优势如何达到应用中的结论。在下一章中,我们将深入探讨各种 Clojure 抽象的性能方面。

第二章:Clojure 抽象

Clojure 有四个基本理念。首先,它被建立为一个函数式语言。它不是纯函数式(如纯粹函数式),但强调不可变性。其次,它是一种 Lisp 方言;Clojure 足够灵活,用户可以在不等待语言实现者添加新特性和结构的情况下扩展语言。第三,它是为了利用并发来应对新一代挑战而构建的。最后,它被设计为托管语言。截至目前,Clojure 的实现存在于 JVM、CLR、JavaScript、Python、Ruby 和 Scheme 上。Clojure 与宿主语言无缝融合。

Clojure 在抽象方面非常丰富。尽管语法本身非常简洁,但抽象是细粒度的,大多数是可组合的,并且旨在以最简单的方式解决广泛的问题。在本章中,我们将讨论以下主题:

  • 非数值标量的性能特性

  • 不可变性和纪元时间模型通过隔离铺平了性能的道路

  • 持久数据结构和它们的性能特性

  • 惰性及其对性能的影响

  • 临时数据结构作为高性能、短期逃生通道

  • 其他抽象,如尾递归、协议/类型、多方法等

非数值标量和内联

Clojure 中的字符串和字符与 Java 中的相同。字符串字面量是隐式内联的。内联是一种只存储唯一值在堆中并在需要的地方共享引用的方法。根据 JVM 供应商和您使用的 Java 版本,内联数据可能存储在字符串池、Permgen、普通堆或堆中标记为内联数据特殊区域。当不使用时,内联数据会像普通对象一样受到垃圾回收。请看以下代码:

user=> (identical? "foo" "foo")  ; literals are automatically interned
true
user=> (identical? (String. "foo") (String. "foo"))  ; created string is not interned
false
user=> (identical? (.intern (String. "foo")) (.intern (String. "foo")))
true
user=> (identical? (str "f" "oo") (str "f" "oo"))  ; str creates string
false
user=> (identical? (str "foo") (str "foo"))  ; str does not create string for 1 arg
true
user=> (identical? (read-string "\"foo\"") (read-string "\"foo\""))  ; not interned
false
user=> (require '[clojure.edn :as edn])  ; introduced in Clojure 1.5
nil
user=> (identical? (edn/read-string "\"foo\"") (edn/read-string "\"foo\""))
false

注意,Clojure 中的 identical? 与 Java 中的 == 是相同的。字符串池化的好处是对于重复的字符串没有内存分配的开销。通常,运行在 JVM 上的应用程序在字符串处理上花费相当多的时间。因此,当有机会同时处理重复字符串时,将它们池化是有意义的。如今,大多数 JVM 实现都有一个非常快的池化操作;然而,如果你使用的是较旧版本,你应该测量 JVM 的开销。

字符串池化的另一个好处是,当你知道两个字符串标记被池化时,你可以使用 identical? 比较它们以进行相等性检查,比非池化字符串标记更快。等价函数 = 首先检查相同的引用,然后再进行内容检查。

Clojure 中的符号总是包含池化字符串引用,因此从给定字符串生成符号几乎与池化字符串一样快。然而,从同一字符串创建的两个符号不会是相同的:

user=> (identical? (.intern "foo") (.intern "foo"))
true
user=> (identical? (symbol "foo") (symbol "foo"))
false
user=> (identical? (symbol (.intern "foo")) (symbol (.intern "foo")))
false

关键字基于其实现建立在符号之上,并设计为与 identical? 函数一起用于等价性。因此,使用 identical? 比较关键字进行相等性检查会更快,就像与池化字符串标记一样。

Clojure 越来越多地被用于大量数据处理,这包括文本和复合数据结构。在许多情况下,数据要么以 JSON 或 EDN(edn-format.org)格式存储。在处理此类数据时,你可以通过池化字符串或使用符号/关键字来节省内存。记住,从此类数据中读取的字符串标记不会自动池化,而从 EDN 数据中读取的符号和关键字则会不可避免地池化。当你处理关系型数据库或 NoSQL 数据库、Web 服务、CSV 或 XML 文件、日志解析等情况时,可能会遇到这种情况。

池化与 JVM 的垃圾回收(GC)相关联,而垃圾回收又与性能密切相关。当你不池化字符串数据并允许重复存在时,它们最终会在堆上分配。更多的堆使用会导致 GC 开销。池化字符串有一个微小但可测量且即时的性能开销,而 GC 往往是不可预测且不清晰的。在大多数 JVM 实现中,GC 性能并没有像硬件性能提升那样以相似的比例增长。因此,通常,有效的性能取决于防止 GC 成为瓶颈,这在大多数情况下意味着最小化它。

身份、值和历法时间模型

Clojure 的一个主要优点是其简单的设计,这导致了可塑性和美丽的可组合性。用符号代替指针是一种存在了几十年的编程实践。它已经在几个命令式语言中得到广泛应用。Clojure 剖析了这个概念,以揭示需要解决的核心问题。以下小节将说明 Clojure 的这一方面。

我们使用逻辑实体来表示值。例如,30这个值如果没有与逻辑实体关联,比如age,就没有意义。逻辑实体age在这里是身份。现在,尽管age代表一个值,但这个值可能会随时间改变;这引出了状态的概念,它代表某个时间点的身份值。因此,状态是时间的函数,并且与我们在程序中执行的操作有因果关系。Clojure 的力量在于将身份与其在特定时间保持为真的值绑定在一起,并且身份与其后来可能代表的任何新值保持隔离。我们将在第五章 并发 中讨论状态管理。

变量和修改

如果你之前使用过命令式语言(C/C++、Java 等),你可能熟悉变量的概念。变量是对内存块的一个引用。当我们更新其值时,我们实际上是在更新存储值的内存位置。变量继续指向存储旧版本值的那个位置。所以,本质上,变量是存储值位置的别名。

一点分析就能揭示变量与读取或修改其值的进程之间有着强烈的联系。每一次修改都是一个状态转换。读取/更新变量的进程应该了解变量的可能状态,以便理解该状态。你在这里看到问题了吗?它混淆了身份和状态!在处理变量时,在时间上引用一个值或状态是不可能的——除非你完全控制访问它的进程,否则值可能会随时改变。可变模型无法容纳导致其状态转换的时间概念。

可变性的问题并不止于此。当你有一个包含可变变量的复合数据结构时,整个数据结构就变得可变了。我们如何在不破坏可能正在观察它的其他进程的情况下修改它?我们如何与并发进程共享这个数据结构?我们如何将这个数据结构用作哈希表中的键?这个数据结构什么也没传达。它的意义可能会随着修改而改变!我们如何将这样的事物发送给另一个进程,而不补偿可能以不同方式修改它的时间?

不可变性是函数式编程的一个重要原则。它不仅简化了编程模型,而且为安全和并发铺平了道路。Clojure 在整个语言中支持不可变性。Clojure 还支持通过并发原语实现快速、面向变动的数据结构以及线程安全的状态管理。我们将在接下来的章节中讨论这些主题。

集合类型

Clojure 中有一些集合类型,它们根据其属性进行分类。以下维恩图根据集合是否计数(counted? 返回 true)、是否关联(associative? 返回 true)或是否顺序(sequential? 返回 true)来描述这种分类:

集合类型

之前的图示展示了不同类型的数据结构所共有的特性。顺序结构允许我们对集合中的项目进行迭代,计数结构的项数可以随时间保持恒定,而关联结构可以通过键来查找相应的值。CharSequence 框展示了可以转换为 Clojure 序列的 Java 字符序列类型(使用 seq charseq)。

持久数据结构

正如我们在上一节中注意到的,Clojure 的数据结构不仅不可变,而且可以在不影响旧版本的情况下产生新值。操作以这种方式产生新值,使得旧值仍然可访问;新版本的产生符合该数据结构的复杂度保证,并且旧版本和新版本都继续满足复杂度保证。这些操作可以递归地应用,并且仍然可以满足复杂度保证。Clojure 提供的这种不可变数据结构被称为 持久数据结构。它们是“持久”的,即当创建新版本时,旧版本和新版本在值和复杂度保证方面都“持续”存在。这与数据的存储或持久性无关。对旧版本的更改不会妨碍与新版本一起工作,反之亦然。两个版本都以类似的方式持续存在。

在启发 Clojure 持久数据结构实现的出版物中,其中两种是众所周知的。Chris Okasaki 的 Purely Functional Data Structures 对持久数据结构和惰性序列/操作的实施产生了影响。Clojure 的持久队列实现是从 Okasaki 的 Batched Queues 中改编的。Phil Bagwell 的 Ideal Hash Tries,尽管是为可变和命令式数据结构设计的,但被改编用于实现 Clojure 的持久映射/向量/集合。

构建较少使用的数据结构

Clojure 支持列表、向量、集合和映射的知名字面语法。以下列表展示了创建其他数据结构的较少使用的方法:

  • 映射 (PersistentArrayMapPersistentHashMap):

    {:a 10 :b 20}  ; array-map up to 8 pairs
    {:a 1 :b 2 :c 3 :d 4 :e 5 :f 6 :g 7 :h 8 :i 9}  ; hash-map for 9 or more pairs
    
  • 排序映射 (PersistentTreeMap):

    (sorted-map :a 10 :b 20 :c 30)  ; (keys ..) should return sorted
    
  • 排序集合 (PersistentTreeSet):

    (sorted-set :a :b :c)
    
  • 队列 (PersistentQueue):

    (import 'clojure.lang.PersistentQueue)
    (reduce conj PersistentQueue/EMPTY [:a :b :c :d])  ; add to queue
    (peek queue)  ; read from queue
    (pop queue)  ; remove from queue
    

如您所见,诸如 TreeMap(按键排序)、TreeSet(按元素排序)和 Queue 这样的抽象应该通过调用它们各自的 API 来实例化。

复杂度保证

以下表格给出了 Clojure 中各种持久数据结构复杂度保证(使用大 O 符号)的摘要:

操作 持久列表 持久哈希映射 持久数组映射 持久向量 持久队列 持久树映射
count O(1) O(1) O(1) O(1) O(1) O(1)
conj O(1) O(1) O(1)
first O(1) O(<7) O(<7)
rest O(1) O(<7) O(<7)
doseq O(n) O(n) O(n) O(n) O(n)
nth O(n) O(<7) O(<7)
last O(n) O(n) O(n)
get O(<7) O(1) O(<7) O(<7) O(log n)
assoc O(<7) O(1) O(<7) O(log n)
dissoc O(<7) O(1) O(<7) O(log n)
peek O(1) O(1)
pop O(<7) O(1)

列表是一种顺序数据结构。它为计数和与第一个元素相关的内容提供常数时间访问。例如,conj 将元素添加到头部并保证 O(1) 复杂度。同样,firstrest 也提供 O(1) 保证。其他所有内容都提供 O(n) 复杂度保证。

持久哈希映射和向量在底层使用 32 个分支因子的 trie 数据结构。因此,尽管复杂度是 O(log [32] n),但只有 2³²个哈希码可以放入 trie 节点中。因此,log[32] 2³²,结果是 6.4 并且小于 7,是最坏情况下的复杂度,可以认为是接近常数时间。随着 trie 的增长,由于结构共享,要复制的部分成比例地变得很小。持久哈希集实现也是基于哈希映射的;因此,哈希集与哈希映射具有相同的特征。在持久向量中,最后一个不完整的节点放置在尾部,这总是可以从根直接访问。这使得使用 conj 到末尾的操作是常数时间操作。

持久性树映射和树集基本上是分别按顺序排列的映射和集合。它们的实现使用红黑树,通常比哈希映射和哈希集更昂贵。持久性队列在底层使用持久性向量来添加新元素。从持久性队列中删除一个元素需要从向量中移除头部 seq,该向量是从添加新元素的位置创建的。

算法在数据结构上的复杂度并不是其性能的绝对度量。例如,使用哈希表涉及计算 hashCode,这并不包括在复杂度保证中。我们应该根据实际用例来选择数据结构。例如,我们应该在什么情况下使用列表而不是向量?可能是在我们需要顺序或 后进先出 (LIFO) 访问时,或者当为函数调用构造 抽象语法树 (AST) 时。

O(<7) 表示接近常数时间

你可能知道,大 O 表示法用于表达任何算法效率的上限(最坏情况)。变量 n 用于表示算法中的元素数量。例如,在一个排序的关联集合上的二分搜索,如排序向量,是对数时间,即 O(log [2] n) 或简单地 O(log n) 算法。由于 Java 集合中最多可以有 2³²(技术上由于有符号正整数,为 2³¹)个元素,且 log[2] 2³² 是 32,因此二分搜索在最坏情况下可以是 O(≤32)。同样,尽管持久集合的操作是 O(log[32] n),但在最坏情况下实际上最多是 O(log[32] 2³²),即 O(<7)。请注意,这比对数时间低得多,接近常数时间。这意味着即使在最坏的情况下,持久集合的性能也不是很糟糕。

持久数据结构的连接

尽管持久数据结构具有出色的性能特性,但两个持久数据结构的连接一直是一个线性时间 O(N) 操作,除了最近的一些发展。截至 Clojure 1.7,concat 函数仍然提供线性时间连接。在 core.rrb-vector 贡献项目中正在进行对 Relaxed Radix Balanced (RRB) 树的实验工作 (github.com/clojure/core.rrb-vector),这可能提供对数时间 O(log N) 连接。对细节感兴趣的读者应参考以下链接:

序列和惰性

*"一个序列就像一个逻辑游标。"
--Rich Hickey

序列(通常称为seqs)是按顺序消费一系列数据的一种方式。与迭代器类似,它们允许用户从头部开始消费元素,并逐个实现一个元素。然而,与迭代器不同,序列是不可变的。此外,由于序列只是底层数据的视图,它们不会修改数据的存储结构。

使序列与众不同的地方在于,它们本身不是数据结构;相反,它们是对数据流的数据抽象。数据可能由算法或与 I/O 操作连接的数据源产生。例如,resultset-seq函数接受一个java.sql.ResultSet JDBC 实例作为参数,并以seq的形式产生惰性实现的数据行。

Clojure 数据结构可以通过seq函数转换为序列。例如,(seq [:a :b :c :d])返回一个序列。对空集合调用seq返回 nil。

序列可以通过以下函数进行消费:

  • first:这个函数返回序列的头部。

  • rest:这个函数返回移除头部后的剩余序列,即使它是空的。

  • next:这个函数返回移除头部后的剩余序列或空,如果它是空的。

惰性

Clojure 是一种严格的(即“惰性”的对立面)语言,可以在需要时显式地利用惰性。任何人都可以使用lazy-seq宏创建一个惰性评估的序列。一些 Clojure 对集合的操作,如mapfilter等,都是有意为之的惰性操作。

惰性简单地说,就是值在真正需要时才计算。一旦值被计算,它就会被缓存,以便任何未来的值引用都不需要重新计算。值的缓存称为记忆化。惰性和记忆化常常是相辅相成的。

数据结构操作中的惰性

惰性和记忆化结合使用,可以形成一个极其有用的组合,以保持函数式算法的单线程性能与其命令式对应物相当。例如,考虑以下 Java 代码:

List<String> titles = getTitles();
int goodCount = 0;
for (String each: titles) {
  String checksum = computeChecksum(each);
  if (verifyOK(checksum)) {
    goodCount++;
  }
}

从前面的代码片段中可以看出,它具有线性时间复杂度,即O(n),整个操作都在单次遍历中完成。与之相当的 Clojure 代码如下:

(->> (get-titles)
  (map compute-checksum)
  (filter verify-ok?)
  count)

现在,既然我们知道mapfilter是惰性的,我们可以推断 Clojure 版本也具有线性时间复杂度,即O(n),并且在一个遍历中完成任务,没有显著的内存开销。想象一下,如果mapfilter不是惰性的,那么复杂度会是什么?需要多少次遍历?不仅仅是 map 和 filter 各自都只进行了一次遍历,即O(n),每个;在最坏的情况下,它们各自会占用与原始集合一样多的内存,因为需要存储中间结果。

在强调不可变性的函数式语言 Clojure 中,了解惰性和缓存的重要性非常重要。它们是持久数据结构中摊销的基础,这涉及到关注复合操作的整体性能,而不是微观分析其中每个操作的性能;操作被调整以在最重要的操作中更快地执行。

另一个重要的细节是,当惰性序列被实现时,数据会被缓存并存储。在 JVM 上,所有以某种方式可达的堆引用都不会被垃圾回收。因此,结果就是,整个数据结构除非你丢失序列的头部,否则会一直保留在内存中。当使用局部绑定处理惰性序列时,确保你不会从任何局部变量中持续引用惰性序列。当编写可能接受惰性序列(s)的函数时,注意任何对惰性seq的引用都不应该超出函数执行的寿命,无论是以闭包或其他形式。

构建惰性序列

现在我们已经了解了惰性序列是什么,让我们尝试创建一个重试计数器,它应该只返回重试可以执行次数的次数。这在上面的代码中有所展示:

(defn retry? [n]
  (if (<= n 0)
    (cons false (lazy-seq (retry? 0)))
    (cons true (lazy-seq (retry? (dec n))))))

lazy-seq宏确保栈不被用于递归。我们可以看到这个函数会返回无限值。因此,为了检查它返回的内容,我们应该限制元素的数量,如下面的代码所示:

user=> (take 7 (retry? 5))
(true true true true true false false)

现在,让我们尝试以模拟的方式使用它:

(loop [r (retry? 5)]
  (if-not (first r)
    (println "No more retries")
    (do
      (println "Retrying")
      (recur (rest r)))))

如预期,输出应该打印Retrying五次,然后打印No more retries并退出,如下所示:

Retrying
Retrying
Retrying
Retrying
Retrying
No more retries
nil

让我们再举一个更简单的例子来构建一个惰性序列,它从指定的数字倒数到零:

(defn count-down [n]
  (if (<= n 0)
    '(0)
    (cons n (lazy-seq (count-down (dec n))))))

我们可以如下检查它返回的值:

user=> (count-down 8)
(8 7 6 5 4 3 2 1 0)

惰性序列可以无限循环而不会耗尽栈,当与其他惰性操作一起工作时可能会很有用。为了在节省空间和性能之间保持平衡,消费惰性序列会导致元素以 32 的倍数分块。这意味着即使它们是顺序消费的,惰性序列也是以 32 的块大小实现的。

自定义分块

默认的块大小 32 可能不是所有惰性序列的最佳选择——当你需要时可以覆盖分块行为。考虑以下片段(改编自 Kevin Downey 在gist.github.com/hiredman/324145的公开 gist):

(defn chunked-line-seq
  "Returns the lines of text from rdr as a chunked[size] sequence of strings.
  rdr must implement java.io.BufferedReader."
  [^java.io.BufferedReader rdr size]
  (lazy-seq
    (when-let [line (.readLine rdr)]
      (chunk-cons
        (let [buffer (chunk-buffer size)]
          (chunk-append buffer line)
          (dotimes [i (dec size)]
            (when-let [line (.readLine rdr)]
              (chunk-append buffer line)))
  (chunk buffer))
(chunked-line-seq rdr size)))))

根据前面的片段,用户可以传递一个块大小,该大小用于生成惰性序列。较大的块大小在处理大型文本文件时可能很有用,例如在处理 CSV 或日志文件时。你会在片段中注意到以下四个不太为人所知的函数:

  • clojure.core/chunk-cons

  • clojure.core/chunk-buffer

  • clojure.core/chunk-append

  • clojure.core/chunk

虽然 chunk-cons 是分块序列中 clojure.core/cons 的等价物,但 chunk-buffer 创建一个可变的分块缓冲区(控制分块大小),chunk-append 将一个项目追加到可变分块缓冲区的末尾,而分块将可变分块缓冲区转换为不可变分块。

clojure.core 命名空间中列出了与分块序列相关的几个函数,如下所示:

  • chunk

  • chunk-rest

  • chunk-cons

  • chunk-next

  • chunk-first

  • chunk-append

  • chunked-seq?

  • chunk-buffer

这些函数没有文档说明,所以我鼓励你研究它们的源代码以了解它们的功能,但我建议你不要对未来 Clojure 版本中它们的支持做出任何假设。

宏和闭包

通常,我们定义一个宏,以便将代码参数体转换为闭包并将其委托给函数。请看以下示例:

(defmacro do-something
  [& body]
  `(do-something* (fn [] ~@body)))

当使用此类代码时,如果主体将局部变量绑定到惰性序列,它可能比必要的保留时间更长,这可能会对内存消耗和性能产生不良影响。幸运的是,这可以很容易地修复:

(defmacro do-something
  [& body]
  `(do-something* (^:once fn* [] ~@body)))

注意 ^:once 提示和 fn* 宏,这使得 Clojure 编译器清除闭包引用,从而避免问题。让我们看看这个例子(来自 Alan Malloy 的 groups.google.com/d/msg/clojure/Ys3kEz5c_eE/3St2AbIc3zMJ):

user> (let [x (for [n (range)] (make-array Object 10000))
      f (^:once fn* [] (nth x 1e6))]  ; using ^:once
        (f))
#<Object[] [Ljava.lang.Object;@402d3105>
user> (let [x (for [n (range)] (make-array Object 10000))
            f (fn* [] (nth x 1e6))]         ; not using ^:once
        (f))
OutOfMemoryError GC overhead limit exceeded

前述条件的体现取决于可用的堆空间。这个问题很难检测,因为它只会引发 OutOfMemoryError,这很容易被误解为堆空间问题而不是内存泄漏。作为预防措施,我建议在所有关闭任何可能惰性序列的情况下使用 ^:oncefn*

转换器

Clojure 1.7 引入了一个名为转换器的新抽象,用于“可组合的算法转换”,通常用于在集合上应用一系列转换。转换器的想法源于减少函数,它接受形式为 (result, input) 的参数并返回 result。减少函数是我们通常与 reduce 一起使用的。转换器接受一个减少函数,将其功能包装/组合以提供额外的功能,并返回另一个减少函数。

clojure.core 中处理集合的函数已经获得了一个 arity-1 变体,它返回一个转换器,即 mapcatmapcatfilterremovetaketake-whiletake-nthdropdrop-whilereplacepartition-bypartition-allkeepkeep-indexeddeduperandom-sample

考虑以下几个示例,它们都做同样的事情:

user=> (reduce ((filter odd?) +) [1 2 3 4 5])
9
user=> (transduce (filter odd?) + [1 2 3 4 5])
9
user=> (defn filter-odd? [xf]
         (fn
           ([] (xf))
           ([result] (xf result))
           ([result input] (if (odd? input)
                               (xf result input)
                               result))))
#'user/filter-odd?
user=> (reduce (filter-odd? +) [1 2 3 4 5])
9

在这里,(filter odd?)返回一个 transducer——在第一个例子中,transducer 包装了 reducer 函数 +,以返回另一个组合的减少函数。虽然我们在第一个例子中使用普通的 reduce 函数,但在第二个例子中,我们使用接受 transducer 作为参数的 transduce 函数。在第三个例子中,我们编写了一个 transducer filter-odd?,它模拟了(filter odd?)的行为。让我们看看传统版本和 transducer 版本之间的性能差异:

;; traditional way
user=> (time (dotimes [_ 10000] (reduce + (filter odd? (range 10000)))))
"Elapsed time: 2746.782033 msecs"
nil
;; using transducer
(def fodd? (filter odd?))
user=> (time (dotimes [_ 10000] (transduce fodd? + (range 10000))))
"Elapsed time: 1998.566463 msecs"
nil

性能特征

transducers 的关键点在于每个转换可以允许多么正交,同时又是高度可组合的。同时,转换可以在整个序列上同步进行,而不是每个操作都产生懒加载的块序列。这通常会导致 transducers 有显著的性能优势。当最终结果太大而无法一次性实现时,懒加载序列仍然会很有用——对于其他用例,transducers 应该能够适当地满足需求并提高性能。由于核心函数已经被彻底改造以与 transducers 一起工作,因此,在大多数情况下,用 transducers 来建模转换是有意义的。

Transients

在本章早期,我们讨论了不可变性的优点和可变性的陷阱。然而,尽管可变性在本质上是不安全的,但它也具有非常好的单线程性能。现在,如果有一种方法可以限制局部上下文中的可变操作,以提供安全性保证,那将等同于结合性能优势和局部安全性保证。这正是 Clojure 提供的称为 transients 的抽象。

首先,让我们验证它是安全的(仅限于 Clojure 1.6):

user=> (let [t (transient [:a])]
  @(future (conj! t :b)))
IllegalAccessError Transient used by non-owner thread  clojure.lang.PersistentVector$TransientVector.ensureEditable (PersistentVector.java:463)

如前所述,在 Clojure 1.6 之前,一个线程中创建的 transient 不能被另一个线程访问。然而,在 Clojure 1.7 中允许这种操作,以便 transducers 能够与 core.async (github.com/clojure/core.async) 库良好地协同工作——开发者应在跨线程的 transient 上保持操作一致性:

user=> (let [t (transient [:a])] (seq t))

IllegalArgumentException Don't know how to create ISeq from: clojure.lang.PersistentVector$TransientVector  clojure.lang.RT.seqFrom (RT.java:505)

因此,transients 不能转换为 seqs。因此,它们不能参与新持久数据结构的生成,也不能从执行范围中泄漏出来。考虑以下代码:

(let [t (transient [])]
  (conj! t :a)
  (persistent! t)
  (conj! t :b))
IllegalAccessError Transient used after persistent! call  clojure.lang.PersistentVector$TransientVector.ensureEditable (PersistentVector.java:464)

persistent! 函数将 transient 永久转换为等效的持久数据结构。实际上,transients 只能用于一次性使用。

persistenttransient数据结构之间(transientpersistent!函数)的转换是常数时间,即它是一个O(1)操作。可以从未排序的映射、向量和集合中创建瞬态。修改瞬态的函数有:conj!disj!pop!assoc!dissoc!。只读操作,如getnthcount等,在瞬态上按常规工作,但像contains?这样的函数以及暗示序列的函数,如firstrestnext,则不行。

快速重复

函数clojure.core/repeatedly允许我们多次执行一个函数,并产生一个结果序列的懒序列。Peter Taoussanis 在他的开源序列化库Nippygithub.com/ptaoussanis/nippy)中编写了一个瞬态感知的变体,它性能显著更好。它在他的许可下被复制,如下所示(注意,函数的 arity 与repeatedly不同):

(defn repeatedly*
  "Like `repeatedly` but faster and returns given collection type."
  [coll n f]
  (if-not (instance? clojure.lang.IEditableCollection coll)
    (loop [v coll idx 0]
      (if (>= idx n)
        v
        (recur (conj v (f)) (inc idx))))
    (loop [v (transient coll) idx 0]
      (if (>= idx n)
        (persistent! v)
        (recur (conj! v (f)) (inc idx))))))

性能杂项

除了我们在本章前面看到的重大抽象之外,Clojure 还有一些其他较小但同样非常关键的性能部分,我们将在本节中看到。

禁用生产环境中的断言

断言在开发过程中非常有用,可以捕获代码中的逻辑错误,但它们在运行时会产生开销,你可能希望在生产环境中避免。由于assert是一个编译时变量,断言可以通过将assert绑定到false或在使用代码之前使用alter-var-root来静默。不幸的是,这两种技术都使用起来很麻烦。Paul Stadig 的名为assertions的库(github.com/pjstadig/assertions)通过通过命令行参数-ea到 Java 运行时启用或禁用断言,帮助解决这个特定用例。

要使用它,你必须将其包含在你的 Leiningen project.clj文件中作为依赖项:

:dependencies [;; other dependencies…
                            [pjstadig/assertions "0.1.0"]]

你必须使用这个库的assert宏而不是 Clojure 自己的,因此应用程序中的每个ns块都应该看起来像这样:

(ns example.core

  (:refer-clojure :exclude [assert])

  (:require [pjstadig.assertions :refer [assert]]))

在运行应用程序时,你应该将-ea参数包含到 JRE 中,以启用断言,而排除它则意味着在运行时不进行断言:

$ JVM_OPTS=-ea lein run -m example.core
$ java -ea -jar example.jar

注意,这种用法不会自动避免依赖库中的断言。

解构

解构是 Clojure 的内置迷你语言之一,并且可以说是开发期间的一个顶级生产力提升器。这个特性导致将值解析以匹配绑定形式的左侧。绑定形式越复杂,需要完成的工作就越多。不出所料,这会有一点性能开销。

通过使用显式函数在紧密循环和其他性能关键代码中展开数据,可以轻松避免这种开销。毕竟,这一切都归结于让程序工作得少,做得多。

递归和尾调用优化(TCO)

函数式语言有与递归相关的尾调用优化概念。因此,当递归调用处于尾位置时,它不会占用递归的栈空间。Clojure 支持一种用户辅助的递归调用形式,以确保递归调用不会耗尽栈空间。这有点像命令式循环,但速度极快。

在执行计算时,在紧密循环中使用 loop-recur 而不是迭代合成数字可能非常有意义。例如,我们想要将 0 到 1,000,000 之间的所有奇数相加。让我们比较一下代码:

(defn oddsum-1 [n]  ; using iteration
  (->> (range (inc n))
    (filter odd?)
    (reduce +)))
(defn oddsum-2 [n]  ; using loop-recur
  (loop [i 1 s 0]
    (if (> i n)
      s
      (recur (+ i 2) (+ s i)))))

当我们运行代码时,我们得到了有趣的结果:

user=> (time (oddsum-1 1000000))
"Elapsed time: 109.314908 msecs"

250000000000
user=> (time (oddsum-2 1000000))
"Elapsed time: 42.18116 msecs"

250000000000

time 宏作为性能基准工具远非完美,但相对数值表明了一种趋势——在随后的章节中,我们将探讨 Criterium 库以进行更科学的基准测试。在这里,我们使用 loop-recur 不仅是为了更快地迭代,而且我们还能通过只迭代大约其他示例一半的次数来改变算法本身。

迭代提前结束

在对集合进行累积时,在某些情况下,我们可能希望提前结束。在 Clojure 1.5 之前,loop-recur 是唯一的方法。当使用 reduce 时,我们可以使用 Clojure 1.5 中引入的 reduced 函数做到这一点,如下所示:

;; let coll be a collection of numbers
(reduce (fn ([x] x) ([x y] (if (or (zero? x) (zero? y)) (reduced 0) (* x y))))
             coll)

在这里,我们乘以集合中的所有数字,并在发现任何数字为零时,立即返回结果零,而不是继续到最后一个元素。

函数 reduced? 帮助检测何时返回了已减少(reduced)的值。Clojure 1.7 引入了 ensure-reduced 函数,将非减少值装箱为减少值。

多方法与协议的比较

多方法 是一个针对分派函数返回值的泛型分派(polymorphic dispatch)的出色表达抽象。与多方法关联的分派函数在运行时维护,并在调用多方法时被查找。虽然多方法在确定分派时提供了很多灵活性,但与协议实现相比,性能开销实在太高。

协议(defprotocol)在 Clojure 中使用 reify、记录(defrecord)和类型(deftypeextend-type)实现。这是一个大讨论话题——既然我们在讨论性能特性,那么只需说协议实现基于多态类型进行分派,并且比多方法快得多就足够了。协议和类型通常是 API 的实现细节,因此它们通常由函数来呈现。

由于多方法(multimethods)的灵活性,它们仍然有其位置。然而,在性能关键代码中,建议使用协议(protocols)、记录(records)和类型(types)。

内联

众所周知,宏在调用位置处内联展开,避免了函数调用。因此,这带来了一点点性能上的好处。还有一个definline宏,允许你像写正常宏一样编写一个函数。它创建了一个实际函数,该函数在调用位置处被内联:

(def PI Math/PI)
(definline circumference [radius]
  `(* 2 PI ~radius))

注意

注意,JVM 也会分析其运行的代码,并在运行时进行自己的代码内联。虽然你可以选择内联热函数,但这种技术已知只能提供适度的性能提升。

当我们定义一个var对象时,每次使用它时都会查找其值。当我们使用指向longdouble值的:const元数据定义var对象时,它从调用位置处内联:

(def ^:const PI Math/PI)

当适用时,这已知可以提供相当的性能提升。请看以下示例:

user=> (def a 10)
user=> (def ^:const b 10)
user=> (def ^:dynamic c 10)
user=> (time (dotimes [_ 100000000] (inc a)))
"Elapsed time: 1023.745014 msecs"
nil
user=> (time (dotimes [_ 100000000] (inc b)))
"Elapsed time: 226.732942 msecs"
nil
user=> (time (dotimes [_ 100000000] (inc c)))
"Elapsed time: 1094.527193 msecs"
nil

摘要

性能是 Clojure 设计的基础之一。Clojure 中的抽象设计用于简单性、强大性和安全性,同时牢记性能。我们看到了各种抽象的性能特征,以及如何根据性能用例做出抽象决策。

在下一章中,我们将看到 Clojure 如何与 Java 互操作,以及我们如何提取 Java 的力量以获得最佳性能。

第三章。依赖 Java

由于 Clojure 托管在 JVM 上,Clojure 的几个方面确实有助于理解 Java 语言和平台。这种需求不仅是因为与 Java 的互操作性或理解其实现,还因为性能原因。在某些情况下,Clojure 默认可能不会生成优化的 JVM 字节码;在另一些情况下,你可能希望超越 Clojure 数据结构提供的性能——你可以通过 Clojure 使用 Java 替代方案来获得更好的性能。本章讨论了 Clojure 的这些方面。在本章中,我们将讨论:

  • 检查从 Clojure 源生成的 Java 和字节码

  • 数值和原始类型

  • 与数组一起工作

  • 反射和类型提示

检查 Clojure 代码的等效 Java 源代码

检查给定 Clojure 代码的等效 Java 源代码可以提供对它可能如何影响性能的深入了解。然而,除非我们将命名空间编译到磁盘上,否则 Clojure 在运行时只生成 Java 字节码。当使用 Leiningen 进行开发时,只有project.clj文件中:aot向量下的选定命名空间被输出为包含字节码的编译.class文件。幸运的是,有一种简单快捷的方法可以知道 Clojure 代码的等效 Java 源代码,那就是通过 AOT 编译命名空间,然后使用 Java 字节码反编译器将字节码反编译成等效的 Java 源代码。

有几个商业和开源的 Java 字节码反编译器可用。我们将在这里讨论的一个开源反编译器是 JD-GUI,您可以从其网站下载(jd.benow.ca/#jd-gui)。请使用适合您操作系统的版本。

创建一个新的项目

让我们看看如何从 Clojure 生成等效的 Java 源代码。使用 Leiningen 创建一个新的项目:lein new foo。然后编辑 src/foo/core.clj 文件,添加一个 mul 函数来找出两个数字的乘积:

(ns foo.core)

(defn mul [x y]
  (* x y))

将 Clojure 源代码编译成 Java 字节码

现在,要将 Clojure 源代码编译成字节码并以 .class 文件的形式输出,请运行 lein compile :all 命令。它将在项目的 target/classes 目录中创建 .class 文件,如下所示:

target/classes/
`-- foo
    |-- core$fn__18.class
    |-- core__init.class
    |-- core$loading__4910__auto__.class
    `-- core$mul.class

您可以看到 foo.core 命名空间已被编译成四个 .class 文件。

将 .class 文件反编译成 Java 源代码

假设您已经安装了 JD-GUI,反编译 .class 文件就像使用 JD-GUI 应用程序打开它们一样简单。

将 .class 文件反编译成 Java 源代码

检查 foo.core/mul 函数的代码如下:

package foo;

import clojure.lang.AFunction;
import clojure.lang.Numbers;
import clojure.lang.RT;
import clojure.lang.Var;

public final class core$mul extends AFunction
{
  public static final Var const__0 = (Var)RT.var("clojure.core", "*");

  public Object invoke(Object x, Object y) { x = null; y = null; return Numbers.multiply(x, y);
  }
}

从反编译的 Java 源代码中很容易理解,foo.core/mul 函数是 foo 包中 core$mul 类的一个实例,它扩展了 clojure.lang.AFunction 类。我们还可以看到,方法调用(Object, Object)中的参数类型是 Object 类型,这意味着数字将被装箱。以类似的方式,您可以反编译任何 Clojure 代码的类文件来检查等效的 Java 代码。如果您能结合对 Java 类型以及可能的反射和装箱的了解,您就可以找到代码中的次优位置,并专注于要改进的地方。

不进行局部变量清除的 Clojure 源代码编译

注意方法调用中的 Java 代码,其中说 x = null; y = null; ——代码是如何丢弃参数,将它们设置为 null,并实际上将两个 null 对象相乘的呢?这种误导性的反编译是由于局部变量清除引起的,这是 Clojure JVM 字节码实现的一个特性,在 Java 语言中没有等效功能。

从 Clojure 1.4 开始,编译器支持 :disable-locals-clearing 键,这是在 project.clj 文件中无法配置的 clojure.core/*compiler-options* 动态变量。因此,我们无法使用 lein compile 命令,但我们可以使用 lein repl 命令启动一个 REPL 来编译类:

user=> (binding [*compiler-options* {:disable-locals-clearing true}] (compile 'foo.core))
foo.core

这将在本节前面看到的相同位置生成类文件,但不会出现 x = null; y = null;,因为省略了局部变量清除。

数字、装箱和原始类型

数值是标量。关于数值的讨论被推迟到本章,唯一的原因是 Clojure 中数值实现的强大 Java 基础。自 1.3 版本以来,Clojure 已经确定使用 64 位数值作为默认值。现在,longdouble 是惯用的默认数值类型。请注意,这些是原始 Java 类型,而不是对象。Java 中的原始类型导致高性能,并在编译器和运行时级别具有多个优化。局部原始类型在栈上创建(因此不会对堆分配和 GC 贡献),可以直接访问而无需任何类型的解引用。在 Java 中,也存在数值原始类型的对象等价物,称为 包装数值——这些是分配在堆上的常规对象。包装数值也是不可变对象,这意味着不仅 JVM 在读取存储的值时需要解引用,而且在需要创建新值时还需要创建一个新的包装对象。

显然,包装数值比它们的原始等价类型要慢。当使用 -server 选项启动 Oracle HotSpot JVM 时,它会积极内联那些包含对原始操作调用的函数(在频繁调用时)。Clojure 在多个级别自动使用 原始数值。在 let 块、loop 块、数组以及算术运算(+-*/incdec<<=>>=)中,会检测并保留原始数值。以下表格描述了原始数值及其包装等价类型:

原始数值类型 包装等价类型
byte(1 字节) java.lang.Byte
short(2 字节) java.lang.Short
int(4 字节) java.lang.Integer
float(4 字节) java.lang.Float
long(8 字节) java.lang.Long
double(8 字节) java.lang.Double

在 Clojure 中,有时你可能会发现数值作为包装对象传递或从函数返回,这是由于运行时缺乏类型信息所致。即使你无法控制此类函数,你也可以强制转换值以将其视为原始类型。byteshortintfloatlongdouble 函数从给定的包装数值值创建原始等价类型。

Lisp 传统之一是提供正确的 (en.wikipedia.org/wiki/Numerical_tower) 算术实现。当发生溢出或下溢时,低类型不应截断值,而应提升到更高类型以保持正确性。Clojure 遵循此约束,并通过素数 (en.wikipedia.org/wiki/Prime_(symbol)) 函数:+'-'*'inc'dec' 提供自动提升。自动提升以牺牲一些性能为代价提供正确性。

Clojure 中也有任意长度或精度的数值类型,允许我们存储无界数值,但与原始类型相比性能较差。bigintbigdec函数允许我们创建任意长度和精度的数值。

如果我们尝试执行任何可能产生超出其最大容量的原始数值的操作,该操作将通过抛出异常来保持正确性。另一方面,当我们使用素数函数时,它们会自动提升以提供正确性。还有另一组称为未检查操作的操作,这些操作不检查溢出或下溢,可能返回不正确的结果。

在某些情况下,它们可能比常规和素数函数更快。这些函数包括unchecked-addunchecked-subtractunchecked-multiplyunchecked-divideunchecked-incunchecked-dec。我们还可以通过使用*unchecked-math*变量来启用常规算术函数的未检查数学行为;只需在您的源代码文件中包含以下内容:

(set! *unchecked-math* true)

在算术中,一个常见的需求是用于在自然数除法后找到商和余数的除法。Clojure 的/函数提供有理数除法,产生一个比例,而mod函数提供真正的模除法。这些函数比计算除法商和余数的quotrem函数要慢。

数组

除了对象和原始类型之外,Java 还有一种特殊的集合存储结构类型,称为数组。一旦创建,数组在无需复制数据的情况下不能增长或缩小,需要创建另一个数组来存储结果。数组元素在类型上始终是同质的。数组元素类似于可以对其进行修改以保存新值的位置。与列表和向量等集合不同,数组可以包含原始元素,这使得它们成为一种非常快速的存储机制,没有 GC 开销。

数组通常构成可变数据结构的基础。例如,Java 的java.lang.ArrayList实现内部使用数组。在 Clojure 中,数组可用于快速数值存储和处理、高效算法等。与集合不同,数组可以有一个或多个维度。因此,您可以在数组中布局数据,如矩阵或立方体。让我们看看 Clojure 对数组的支持:

描述 示例 备注
创建数组 (make-array Integer 20) 类型为(装箱)整数的数组
(make-array Integer/TYPE 20) 基本类型整数的数组
(make-array Long/TYPE 20 10) 基本类型长整数的二维数组
创建原始数组 (int-array 20) 大小为 20 的原始整数数组
(int-array [10 20 30 40]) 由向量创建的基本整数数组
从集合创建数组 (to-array [10 20 30 40]) 可序列的数组
(to-array-2d [[10 20 30][40 50 60]]) 从集合中创建二维数组
克隆数组 (aclone (to-array [:a :b :c]))
获取数组元素 (aget array-object 0 3) 获取二维数组中索引 [0][3] 的元素
修改数组元素 (aset array-object 0 3 :foo) 在一个二维数组中设置 obj :foo 在索引 [0][3]
修改原始数组元素 (aset-int int-array-object 2 6 89) 在二维数组中索引 [2][6] 设置值为 89
获取数组长度 (alength array-object) alength 比 count 快得多
遍历数组 (def a (int-array [10 20 30 40 50 60]))``(seq``(amap a idx ret``(do (println idx (seq ret))``(inc (aget a idx))))) 与 map 不同,amap 返回一个非惰性数组,在遍历数组元素时速度更快。注意,amap 只有在正确类型提示的情况下才更快。有关类型提示,请参阅下一节。
遍历数组 (def a (int-array [10 20 30 40 50 60]))``(areduce a idx ret 0``(do (println idx ret)``(+ ret idx))) 与 reduce 不同,areduce 在遍历数组元素时速度更快。注意,reduce 只有在正确类型提示的情况下才更快。有关类型提示,请参阅下一节。
转换为原始数组 (ints int-array-object) 与类型提示一起使用(见下一节)

int-arrayints 一样,也有其他类型的函数:

数组构造函数 原始数组转换函数 类型提示(不适用于 vars) 通用数组类型提示
boolean-array booleans ^booleans ^"[Z"
byte-array bytes ^bytes ^"[B"
short-array shorts ^shorts ^"[S"
char-array chars ^chars ^"[C"
int-array ints ^ints ^"[I"
long-array longs ^longs ^"[J"
float-array floats ^floats ^"[F"
double-array doubles ^doubles ^"[D"
对象数组 –– ^objects ^"[Ljava.lang.Object"

数组之所以受到青睐,主要是因为性能,有时也因为互操作性。在为数组添加类型提示和使用适当的函数处理它们时,应特别小心。

反射和类型提示

有时,由于 Clojure 是动态类型的,Clojure 编译器无法确定要调用某个方法的对象类型。在这种情况下,Clojure 使用 反射,这比直接方法分派慢得多。Clojure 的解决方案是称为 类型提示 的东西。类型提示是一种用静态类型注解参数和对象的方法,以便 Clojure 编译器可以生成用于高效分派的字节码。

知道在哪里放置类型提示的最简单方法是打开代码中的反射警告。考虑以下确定字符串长度的代码:

user=> (set! *warn-on-reflection* true)
true
user=> (def s "Hello, there")
#'user/s
user=> (.length s)
Reflection warning, NO_SOURCE_PATH:1 - reference to field length can't be resolved.
12
user=> (defn str-len [^String s] (.length s))
#'user/str-len
user=> (str-len s)
12
user=> (.length ^String s)  ; type hint when passing argument
12
user=> (def ^String t "Hello, there")  ; type hint at var level
#'user/t
user=> (.length t)  ; no more reflection warning
12
user=> (time (dotimes [_ 1000000] (.length s)))
Reflection warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init6904047906685577265.clj:1:28 - reference to field length can't be resolved.
"Elapsed time: 2409.155848 msecs"
nil
user=> (time (dotimes [_ 1000000] (.length t)))
"Elapsed time: 12.991328 msecs"
nil

在前面的代码片段中,我们可以清楚地看到,使用反射的代码与不使用反射的代码在性能上有很大的差异。在处理项目时,你可能希望所有文件都开启反射警告。在 Leiningen 中可以轻松实现。只需在你的 project.clj 文件中添加以下条目:

:profiles {:dev {:global-vars {*warn-on-reflection* true}}}

这将在你通过 Leiningen 在开发工作流程中开始任何类型的调用时自动开启反射警告,例如 REPL 和测试。

原始类型数组

回忆一下上一节中关于 amapareduce 的例子。如果我们开启反射警告运行它们,我们会收到警告说它们使用了反射。让我们给它们添加类型提示:

(def a (int-array [10 20 30 40 50 60]))
;; amap example
(seq
 (amap ^ints a idx ret
    (do (println idx (seq ret))
      (inc (aget ^ints a idx)))))
;; areduce example
(areduce ^ints a idx ret 0
  (do (println idx ret)
    (+ ret idx)))

注意,原始数组提示 ^ints 在变量级别上不起作用。因此,如果你定义了变量 a,如下所示,它将不起作用:

(def ^ints a (int-array [10 20 30 40 50 60]))  ; wrong, will complain later
(def ^"[I" a (int-array [10 20 30 40 50 60]))  ; correct
(def ^{:tag 'ints} a (int-array [10 20 30 40 50 60])) ; correct

这个符号表示整数数组。其他原始数组类型有类似的类型提示。请参考前面的章节了解各种原始数组类型的类型提示。

基本类型

原始局部变量的类型提示既不是必需的,也不允许。然而,你可以将函数参数作为原始类型进行类型提示。Clojure 允许在函数中最多有四个参数可以进行类型提示:

(defn do-something
  [^long a ^long b ^long c ^long d]
  ..)

注意

封装箱可能会导致某些情况下的对象不是基本类型。在这种情况下,你可以使用相应的原始类型强制转换它们。

宏和元数据

在宏中,类型提示的工作方式与其他代码部分不同。由于宏是关于转换抽象语法树AST),我们需要有一个心理图来表示转换,并且我们应该在代码中添加类型提示作为元数据。例如,如果 str-len 是一个用于查找字符串长度的宏,我们使用以下代码:

(defmacro str-len
  [s]
  `(.length ~(with-meta s {:tag String})))
;; below is another way to write the same macro
(defmacro str-len
  [s]
  `(.length ~(vary-meta s assoc :tag `String)))

在前面的代码中,我们通过将类型 String 标记到符号 s 上来改变其元数据,在这种情况下,它恰好是 java.lang.String 类。对于数组类型,我们可以使用 [Ljava.lang.String 来表示字符串对象的数组,以及其他类似情况。如果你尝试使用之前列出的 str-len,你可能会注意到这仅在将字符串绑定到本地变量或变量时才有效,而不是作为字符串字面量。为了减轻这种情况,我们可以将宏编写如下:

(defmacro str-len
  [s]
  `(let [^String s# ~s] (.length s#)))

在这里,我们将参数绑定到一个带有类型提示的 gensym 本地变量上,因此在对它调用 .length 时不会使用反射,并且不会发出任何反射警告。

通过元数据进行的类型提示也适用于函数,尽管符号不同:

(defn foo [] "Hello")
(defn foo ^String [] "Hello")
(defn foo (^String [] "Hello") (^String [x] (str "Hello, " x)))

除了前面代码片段中的第一个例子外,它们都被类型提示为返回 java.lang.String 类型。

字符串连接

Clojure 中的str函数用于连接和转换为字符串标记。在 Java 中,当我们编写"hello" + e时,Java 编译器将其转换为使用StringBuilder的等效代码,在微基准测试中比str函数快得多。为了获得接近 Java 的性能,在 Clojure 中我们可以使用一个类似的机制,通过宏直接使用 Java 互操作来避免通过str函数的间接操作。Stringer (github.com/kumarshantanu/stringer)库采用了相同的技巧,在 Clojure 中实现快速字符串连接:

(require '[stringer.core :as s])
user=> (time (dotimes [_ 10000000] (str "foo" :bar 707 nil 'baz)))
"Elapsed time: 2044.284333 msecs"
nil
user=> (time (dotimes [_ 10000000] (s/strcat "foo" :bar 707 nil 'baz)))
"Elapsed time: 555.843271 msecs"
nil

这里,Stringer 在编译阶段也积极地连接了字面量。

杂项

在类型(如deftype)中,可变实例变量可以可选地注解为^:volatile-mutable^:unsynchronized-mutable。例如:

(deftype Counter [^:volatile-mutable ^long now]
  ..)

defprotocol不同,definterface宏允许我们为方法提供返回类型提示:

(definterface Foo
  (^long doSomething [^long a ^double b]))

proxy-super宏(在proxy宏内部使用)是一个特殊情况,你不能直接应用类型提示。原因是它依赖于proxy宏自动创建的隐式this对象。在这种情况下,你必须显式地将this绑定到一个类型:

(proxy [Object][]
  (equals [other]
    (let [^Object this this]
      (proxy-super equals other))))

类型提示对于 Clojure 的性能非常重要。幸运的是,我们只需要在需要时进行类型提示,而且很容易找出何时需要。在许多情况下,类型提示带来的收益会超过代码内联的收益。

使用数组/数值库以提高效率

你可能已经注意到在前面的章节中,当处理数值时,性能很大程度上取决于数据是否基于数组和原始类型。为了实现最佳效率,程序员可能需要在计算的各个阶段都非常细致地将数据正确地强制转换为原始类型和数组。幸运的是,Clojure 社区的高性能爱好者们很早就意识到了这个问题,并创建了一些专门的开源库来减轻这个问题。

HipHip

HipHip是一个 Clojure 库,用于处理原始类型数组。它提供了一个安全网,即它严格只接受原始数组参数来工作。因此,静默传递装箱的原始数组作为参数总是会导致异常。HipHip 宏和函数很少需要在操作期间进行类型提示。它支持原始类型的数组,如intlongfloatdouble

HipHip 项目可在github.com/Prismatic/hiphip找到。

在撰写本文时,HipHip 的最新版本是 0.2.0,支持 Clojure 1.5.x 或更高版本,并标记为 Alpha 版本。HipHip 为所有四种原始数据类型的数组提供了一套标准的操作:整数数组操作在命名空间 hiphip.int 中;双精度数组操作在 hiphip.double 中;等等。所有操作都为相应类型提供了类型提示。在相应命名空间中,intlongfloatdouble 的所有操作基本上是相同的,除了数组类型:

类别 函数/宏 描述
核心函数 aclone 类似于 clojure.core/aclone,用于原始数据类型
alength 类似于 clojure.core/alength,用于原始数据类型
aget 类似于 clojure.core/aget,用于原始数据类型
aset 类似于 clojure.core/aset,用于原始数据类型
ainc 将数组元素按指定值增加
等价的 hiphip.array 操作 amake 创建新数组并用表达式计算出的值填充
areduce 类似于 clojure.core/areduce,带有 HipHip 数组绑定
doarr 类似于 clojure.core/doseq,带有 HipHip 数组绑定
amap 类似于 clojure.core/for,创建新数组
afill! 类似于前面的 amap,但覆盖数组参数
数学运算 asum 使用表达式计算数组元素的总和
aproduct 使用表达式计算数组元素乘积
amean 计算数组元素的平均值
dot-product 计算两个数组的点积
查找最小/最大值,排序 amax-index 在数组中找到最大值并返回索引
amax 在数组中找到最大值并返回它
amin-index 在数组中找到最小值并返回索引
amin 在数组中找到最小值并返回它
apartition! 数组的三向划分:小于、等于、大于枢轴
aselect! 将最小的 k 个元素收集到数组的开头
asort! 使用 Java 内置实现就地排序数组
asort-max! 部分就地排序,将前 k 个元素收集到末尾
asort-min! 部分就地排序,将最小的 k 个元素收集到顶部
apartition-indices! 类似于 apartition!,但修改索引数组而不是值
aselect-indices! 类似于 aselect!,但修改索引数组而不是值
asort-indices! 类似于 asort!,但修改索引数组而不是值
amax-indices 获取索引数组;最后 k 个索引指向最大的 k 个值
amin-indices 获取索引数组;前 k 个索引指向最小的 k 个值

要在 Leiningen 项目中包含 HipHip 作为依赖项,请在 project.clj 中指定:

:dependencies [;; other dependencies
               [prismatic/hiphip "0.2.0"]]

以下是如何使用 HipHip 的一个示例,让我们看看如何计算数组的归一化值:

(require '[hiphip.double :as hd])

(def xs (double-array [12.3 23.4 34.5 45.6 56.7 67.8]))

(let [s (hd/asum xs)] (hd/amap [x xs] (/ x s)))

除非我们确保 xs 是原始双精度浮点数数组,否则 HipHip 在类型不正确时会抛出 ClassCastException,在其他情况下会抛出 IllegalArgumentException。我建议您探索 HipHip 项目,以获得更深入的使用见解。

基础数学运算

我们可以将 *warn-on-reflection* 设置为 true,让 Clojure 在调用边界处使用反射时警告我们。然而,当 Clojure 必须隐式使用反射进行数学运算时,唯一的办法是使用分析器或将 Clojure 源代码编译成字节码,然后使用反编译器分析装箱和反射。这就是 primitive-math 库发挥作用的地方,它通过产生额外的警告和抛出异常来帮助。

primitive-math 库可在 github.com/ztellman/primitive-math 找到。

截至撰写本文时,primitive-math 的版本为 0.1.4;您可以通过编辑 project.clj 文件将其作为依赖项包含到您的 Leiningen 项目中,具体方法如下:

:dependencies [;; other dependencies
               [primitive-math "0.1.4"]]

以下是如何使用它的代码示例(回想一下 将 .class 文件反编译成 Java 源代码 部分的示例):

;; must enable reflection warnings for extra warnings from primitive-math
(set! *warn-on-reflection* true)
(require '[primitive-math :as pm])
(defn mul [x y] (pm/* x y))  ; primitive-math produces reflection warning
(mul 10.3 2)                        ; throws exception
(defn mul [^long x ^long y] (pm/* x y))  ; no warning after type hinting
(mul 10.3 2)  ; returns 20

虽然 primitive-math 是一个有用的库,但它解决的问题大多已由 Clojure 1.7 的装箱检测功能处理(参见下一节 检测装箱数学)。然而,如果您无法使用 Clojure 1.7 或更高版本,此库仍然很有用。

检测装箱数学

装箱数学难以检测,是性能问题的来源。Clojure 1.7 引入了一种方法,当发生装箱数学时警告用户。这可以通过以下方式进行配置:

(set! *unchecked-math* :warn-on-boxed)

(defn sum-till [n] (/ (* n (inc n)) 2))  ; causes warning
Boxed math warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init3701519533014890866.clj:1:28 - call: public static java.lang.Number clojure.lang.Numbers.unchecked_inc(java.lang.Object).
Boxed math warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init3701519533014890866.clj:1:23 - call: public static java.lang.Number clojure.lang.Numbers.unchecked_multiply(java.lang.Object,java.lang.Object).
Boxed math warning, /private/var/folders/cv/myzdv_vd675g4l7y92jx9bm5lflvxq/T/form-init3701519533014890866.clj:1:20 - call: public static java.lang.Number clojure.lang.Numbers.divide(java.lang.Object,long).

;; now we define again with type hint
(defn sum-till [^long n] (/ (* n (inc n)) 2))

当使用 Leiningen 时,您可以通过在 project.clj 文件中添加以下条目来启用装箱数学警告:

:global-vars {*unchecked-math* :warn-on-boxed}

primitive-math 中的数学运算(如 HipHip)是通过宏实现的。因此,它们不能用作高阶函数,并且因此可能与其他代码组合不佳。我建议您探索该项目,看看哪些适合您的程序用例。采用 Clojure 1.7 通过装箱警告功能消除了装箱发现问题。

依赖 Java 和本地代码

在少数情况下,由于 Clojure 缺乏命令式、基于栈的、可变变量,这可能导致代码的性能不如 Java,我们可能需要评估替代方案以提高其性能。我建议您考虑直接用 Java 编写此类代码以获得更好的性能。

另一个考虑因素是使用原生操作系统功能,例如内存映射缓冲区(docs.oracle.com/javase/7/docs/api/java/nio/MappedByteBuffer.html)或文件和不受保护的操作(highlyscalable.wordpress.com/2012/02/02/direct-memory-access-in-java/)。请注意,不受保护的操作可能具有潜在风险,通常不建议使用。这些时刻也是考虑将性能关键代码用 C 或 C++ 编写,然后通过 Java 本地接口JNI)访问它们的时机。

Proteus – Clojure 中的可变局部变量

Proteus 是一个开源的 Clojure 库,允许您将局部变量视为局部变量,从而使其在局部作用域内仅允许非同步修改。请注意,此库依赖于 Clojure 1.5.1 的内部实现结构。Proteus 项目可在 github.com/ztellman/proteus 找到。

您可以通过编辑 project.clj 将 Proteus 包含为 Leiningen 项目的依赖项:

:dependencies [;;other dependencies
               [proteus "0.1.4"]]

在代码中使用 Proteus 很简单,如下面的代码片段所示:

(require '[proteus :as p])
(p/let-mutable [a 10]
  (println a)
  (set! a 20)
  (println a))
;; Output below:
;; 10
;; 20

由于 Proteus 只允许在局部作用域中进行可变操作,以下代码会抛出异常:

(p/let-mutable [a 10 add2! (fn [x] (set! x (+ 2 x)))]
  (add2! a)
  (println a))

可变局部变量非常快,在紧密循环中可能非常有用。Proteus 在 Clojure 习惯用法上是非传统的,但它可能在不编写 Java 代码的情况下提供所需的性能提升。

摘要

由于 Clojure 具有强大的 Java 互操作性和基础,程序员可以利用接近 Java 的性能优势。对于性能关键代码,有时有必要了解 Clojure 如何与 Java 交互以及如何调整正确的旋钮。数值是一个需要 Java 互操作以获得最佳性能的关键领域。类型提示是另一个重要的性能技巧,通常非常有用。有几个开源的 Clojure 库使程序员更容易进行此类活动。

在下一章中,我们将深入探讨 Java 之下,看看硬件和 JVM 堆栈如何在我们获得性能中发挥关键作用,它们的限制是什么,以及如何利用对这些理解的使用来获得更好的性能。

第四章。主机性能

在前面的章节中,我们注意到了 Clojure 与 Java 的互操作性。在本章中,我们将更深入地探讨,以更好地理解内部结构。我们将触及整个堆栈的几个层次,但我们的主要焦点将是 JVM,特别是 Oracle HotSpot JVM,尽管有多个 JVM 供应商可供选择(en.wikipedia.org/wiki/List_of_Java_virtual_machines)。在撰写本文时,Oracle JDK 1.8 是最新的稳定版本,早期 OpenJDK 1.9 构建也已可用。在本章中,我们将讨论:

  • 从性能角度来看,硬件子系统是如何工作的

  • JVM 内部结构的组织以及它与性能的关系

  • 如何测量堆中各种对象占用的空间量

  • 使用 Criterium 对 Clojure 代码进行延迟分析

硬件

有各种硬件组件可能会以不同的方式影响软件的性能。处理器、缓存、内存子系统、I/O 子系统等,根据用例的不同,都有不同程度的影响。在接下来的章节中,我们将探讨这些方面的每一个。

处理器

自 1980 年代末以来,微处理器一直采用流水线和指令级并行性来提高其性能。在 CPU 级别处理指令通常包括四个周期:取指令解码执行写回。现代处理器通过并行运行这些周期来优化它们——当一条指令正在执行时,下一条指令正在被解码,再下一条正在被取,依此类推。这种风格被称为指令流水线

实际上,为了进一步加快执行速度,阶段被细分为许多更短的阶段,从而导致了更深的超级流水线架构。流水线中最长阶段的长度限制了 CPU 的时钟速度。通过将阶段细分为子阶段,处理器可以以更高的时钟速度运行,每个指令需要更多的周期,但处理器仍然在每个周期内完成一条指令。由于现在每秒有更多的周期,尽管每个指令的延迟现在更高,但我们仍然在每秒吞吐量方面获得了更好的性能。

分支预测

即使处理器遇到条件if-then形式的指令,也必须提前取指令和解码。考虑一个 Clojure 表达式(if (test a) (foo a) (bar a))的等价物。处理器必须选择一个分支来取指令和解码,问题是它应该取if分支还是else分支?在这里,处理器对要取/解码的指令进行猜测。如果猜测是正确的,就像往常一样,这是一个性能提升;否则,处理器必须丢弃取/解码过程的结果,并从另一个分支重新开始。

处理器使用片上分支预测表来处理分支预测。它包含最近的代码分支和每个分支两个比特,指示分支是否被采取,同时也容纳了单次未采取的情况。

今天,分支预测对于处理器的性能至关重要,因此现代处理器专门分配硬件资源和特殊的预测指令来提高预测准确性并降低误预测的代价。

指令调度

高延迟指令和分支通常会导致指令流水线中的空周期,称为停顿气泡。这些周期通常通过指令重排来完成其他工作。指令重排通过乱序执行在硬件级别实现,通过编译时指令调度(也称为静态指令调度)在编译器级别实现。

处理器在执行乱序执行时需要记住指令之间的依赖关系。这种成本可以通过使用重命名寄存器来在一定程度上减轻,其中寄存器值存储到/从内存位置加载,可能在不同物理寄存器上,这样它们可以并行执行。这需要乱序处理器始终维护指令及其使用的寄存器的映射,这使得它们的设计复杂且功耗大。除了一些例外,今天几乎所有高性能 CPU 都具有乱序设计。

良好的编译器通常对处理器有极高的了解,并且能够通过重新排列处理器指令来优化代码,从而在处理器指令流水线中减少气泡。一些高性能 CPU 仍然只依赖于静态指令重排而不是乱序指令重排,从而节省芯片面积——节省的面积用于容纳额外的缓存或 CPU 核心。低功耗处理器,如 ARM 和 Atom 系列,使用顺序设计。与大多数 CPU 不同,现代 GPU 使用具有深流水线的顺序设计,这通过非常快的上下文切换得到补偿。这导致 GPU 具有高延迟和高吞吐量。

线程和核心

通过上下文切换、硬件线程和核心实现并发性和并行性在当今非常普遍,并且我们已经将它们视为实现我们程序的规范。然而,我们应该理解为什么我们最初需要这样的设计。我们今天编写的绝大多数现实世界代码在指令级并行性方面没有超过适度范围。即使基于硬件的乱序执行和静态指令重排,每个周期也真正并行执行的指令不超过两个。因此,另一个潜在的指令来源是除了当前运行的程序之外的程序,这些程序可以被流水线和并行执行。

管道中的空闲周期可以被分配给其他正在运行的程序,这些程序假设还有其他当前正在运行且需要处理器注意力的程序。同时多线程SMT)是一种硬件设计,它使得这种并行成为可能。英特尔在其某些处理器中实现了名为HyperThreading的 SMT。虽然 SMT 将单个物理处理器呈现为两个或更多逻辑处理器,但真正的多处理器系统每个处理器执行一个线程,从而实现同时执行。多核处理器每个芯片包含两个或更多处理器,但具有多处理器系统的特性。

通常,多核处理器的性能显著优于 SMT 处理器。SMT 处理器的性能可能会根据使用案例而变化。在代码高度可变或线程不竞争相同硬件资源的情况下,性能达到峰值,而当线程在相同处理器上缓存绑定时,性能则会下降。同样重要的是,有些程序本身并不是天生并行的。在这种情况下,如果没有在程序中显式使用线程,可能很难使它们运行得更快。

内存系统

理解内存性能特性对于了解我们编写的程序可能产生的影响非常重要。数据密集型且天生并行的程序,如音频/视频处理和科学计算,主要受限于内存带宽,而不是处理器。除非增加内存带宽,否则增加处理器不会使它们更快。考虑另一类程序,如 3D 图形渲染或主要受限于内存延迟但不是内存带宽的数据库系统。SMT 对于这类程序非常适用,在这些程序中,线程不竞争相同的硬件资源。

内存访问大致占处理器执行的所有指令的四分之一。代码块通常以内存加载指令开始,其余部分取决于加载的数据。这会导致指令停滞,并防止大规模的指令级并行。更糟糕的是,即使是超标量处理器(每时钟周期可以发出多个指令)也最多只能在每个周期发出两个内存指令。构建快速内存系统受限于自然因素,如光速。它影响信号往返到 RAM。这是一个自然的硬限制,任何优化都只能绕过它。

处理器和主板芯片组之间的数据传输是导致内存延迟的因素之一。这个问题通过使用更快的前端总线FSB)来抵消。如今,大多数现代处理器通过在芯片级别直接集成内存控制器来解决这个问题。处理器与内存延迟之间的显著差异被称为内存墙。由于处理器时钟速度达到功率和热量限制,近年来这一现象已经趋于平稳,但尽管如此,内存延迟仍然是一个重大问题。

与 CPU 不同,GPU 通常实现持续的高内存带宽。由于延迟隐藏,它们在高数值计算工作负载期间也利用带宽。

缓存

为了克服内存延迟,现代处理器在处理器芯片上或靠近芯片的地方放置了一种非常快速的内存。缓存的目的就是存储最近使用过的内存数据。缓存有不同的级别:L1缓存位于处理器芯片上;L2缓存比 L1 大,且比 L1 远离处理器。通常还有一个L3缓存,它比 L2 更大,且比 L2 更远离处理器。在英特尔的 Haswell 处理器中,L1 缓存的大小通常是 64 千字节(32 KB 指令加 32 KB 数据),L2 每个核心 256 KB,L3 是 8 MB。

虽然内存延迟非常糟糕,幸运的是缓存似乎工作得非常好。L1 缓存比访问主内存要快得多。在现实世界的程序中报告的缓存命中率是 90%,这为缓存提供了强有力的论据。缓存就像是一个内存地址到数据值块的字典。由于值是一块内存,因此相邻内存位置的缓存几乎没有额外的开销。请注意,L2 比 L1 慢且大,L3 比 L2 慢且大。在英特尔的 Sandybridge 处理器上,寄存器查找是瞬时的;L1 缓存查找需要三个时钟周期,L2 需要九个,L3 需要 21 个,而主内存访问需要 150 到 400 个时钟周期。

互连

处理器通过两种类型的架构的互连与内存和其他处理器进行通信:对称多处理SMP)和非一致性内存访问NUMA)。在 SMP 中,总线通过总线控制器将处理器和内存互连。总线充当广播设备。当处理器和内存银行数量较多时,总线往往会成为瓶颈。与 NUMA 相比,SMP 系统在构建成本上更低,但扩展到大量核心更困难。在 NUMA 系统中,处理器和内存的集合通过点到点的方式连接到其他类似的处理器和内存组。每个这样的组被称为一个节点。节点的本地内存可以被其他节点访问,反之亦然。英特尔公司的HyperTransportQuickPath互连技术支持 NUMA。

存储和网络

存储和网络是除了处理器、缓存和内存之外最常用的硬件组件。许多现实世界应用程序往往是 I/O 密集型而不是执行密集型。这样的 I/O 技术正在不断进步,市场上可供选择的组件种类繁多。考虑这些设备应基于具体的使用案例的性能和可靠性特征。另一个重要标准是了解它们在目标操作系统驱动程序中的支持情况。当前存储技术大多基于硬盘和固态硬盘。网络设备和协议的适用性根据业务用例而大相径庭。I/O 硬件的详细讨论超出了本书的范围。

Java 虚拟机

Java 虚拟机是一个以字节码为导向、具有垃圾回收功能的虚拟机,它指定了自己的指令集。指令有等效的字节码,由 Java 运行时环境JRE)解释和编译为底层操作系统和硬件。对象通过符号引用来引用。JVM 中的数据类型在所有平台和架构上的 JVM 实现中都是完全标准化的,作为一个单一的规范。JVM 还遵循网络字节序,这意味着在不同架构上的 Java 程序之间的通信可以使用大端字节序进行。Jvmtopcode.google.com/p/jvmtop/) 是一个方便的 JVM 监控工具,类似于 Unix-like 系统中的 top 命令。

即时编译器

即时编译器JIT)是 JVM 的一部分。当 JVM 启动时,JIT 编译器对正在运行的代码几乎一无所知,因此它只是简单地解释 JVM 字节码。随着程序的持续运行,JIT 编译器开始通过收集统计数据和分析调用和字节码模式来分析代码。当方法调用次数超过某个阈值时,JIT 编译器会对代码应用一系列优化。最常见的优化是内联和本地代码生成。最终和静态方法和类是内联的绝佳候选者。JIT 编译并非没有代价;它占用内存来存储分析过的代码,有时它还必须撤销错误的推测性优化。然而,JIT 编译几乎总是被长期代码执行所摊销。在罕见的情况下,如果代码太大或者由于执行频率低而没有热点,关闭 JIT 编译可能是有用的。

JRE 通常有两种类型的 JIT 编译器:客户端和服务器。默认使用哪种 JIT 编译器取决于硬件和平台类型。客户端 JIT 编译器是为客户端程序(如命令行和桌面应用程序)设计的。我们可以通过使用 -server 选项启动 JRE 来调用服务器 JIT 编译器,它实际上是为服务器上运行的长运行程序设计的。服务器中 JIT 编译的阈值高于客户端。两种类型 JIT 编译器的区别在于,客户端针对的是低延迟,而服务器假设运行在高资源硬件上,并试图优化吞吐量。

Oracle HotSpot JVM 中的 JIT 编译器会观察代码执行以确定最频繁调用的方法,这些方法是热点。这些热点通常只是整个代码的一小部分,可以低成本地关注和优化。HotSpot JIT 编译器是懒惰和自适应的。它是懒惰的,因为它只编译那些超过一定阈值的、被调用的方法到原生代码,而不是它遇到的全部代码。编译到原生代码是一个耗时的过程,编译所有代码将是浪费的。它是自适应的,因为它会逐渐增加对频繁调用代码编译的积极性,这意味着代码不是只优化一次,而是在代码重复执行的过程中多次优化。当一个方法调用超过第一个 JIT 编译器的阈值后,它就会被优化,计数器重置为零。同时,代码的优化计数设置为 1。当调用再次超过阈值时,计数器重置为零,优化计数增加;这次应用更积极的优化。这个过程会一直持续到代码不能再被优化为止。

HotSpot JIT 编译器执行了许多优化。其中一些最显著的优化如下:

  • 内联:方法内联——非常小的方法、静态和最终方法、最终类中的方法以及只涉及原始数值的小方法是最适合内联的候选者。

  • 锁消除:锁定是一个性能开销。幸运的是,如果锁对象监视器无法从其他线程访问,则可以消除锁。

  • 虚拟调用消除:通常,程序中一个接口只有一个实现。JIT 编译器消除虚拟调用,并用类实现对象上的直接方法调用替换它。

  • 非易失性内存写入消除:对象中的非易失性数据成员和引用不一定能被除当前线程外的其他线程看到。这个标准被用来不在内存中更新这样的引用,而是通过原生代码使用硬件寄存器或栈。

  • 原生代码生成:JIT 编译器为频繁调用的方法及其参数生成原生代码。生成的原生代码存储在代码缓存中。

  • 控制流和局部优化:JIT 编译器经常重新排序和拆分代码以提高性能。它还分析控制流的分支,并根据这些分析优化代码。

很少有理由禁用 JIT 编译,但可以通过在启动 JRE 时传递-Djava.compiler=NONE参数来实现。默认的编译阈值可以通过传递-XX:CompileThreshold=9800到 JRE 可执行文件来更改,其中9800是示例阈值。XX:+PrintCompilation-XX:-CITime选项使 JIT 编译器打印 JIT 统计信息和 JIT 花费的时间。

内存组织

JVM 使用的内存被划分为几个部分。作为基于栈的执行模型,JVM 的一个内存段是栈区。每个线程都有一个栈,其中栈帧以后进先出LIFO)的顺序存储。栈包括一个程序计数器PC),它指向 JVM 内存中当前正在执行的指令。当调用一个方法时,会创建一个新的栈帧,其中包含局部变量数组和操作数栈。与传统栈不同,操作数栈包含加载局部变量/字段值和计算结果的指令——这种机制也用于在调用之前准备方法参数以及存储返回值。栈帧本身可能分配在堆上。要检查当前线程中栈帧的顺序,最简单的方法是执行以下代码:

(require 'clojure.repl)
(clojure.repl/pst (Throwable.))

当线程需要的栈空间超过 JVM 能提供的空间时,会抛出StackOverflowError

堆是对象和数组分配的主要内存区域,它被所有 JVM 线程共享。堆的大小可能是固定的或可扩展的,这取决于启动 JRE 时传递的参数。尝试分配比 JVM 能提供的空间更多的堆空间会导致抛出OutOfMemoryError。堆上的分配受垃圾回收的影响。当一个对象不再通过任何引用可达时,它将被垃圾回收,值得注意的是,弱、软和虚引用除外。由非强引用指向的对象在 GC(垃圾回收)中花费的时间更长。

方法区在逻辑上是堆内存的一部分,包含诸如字段和方法信息、运行时常量池、方法代码和构造函数体等类结构。它是所有 JVM 线程共享的。在 Oracle HotSpot JVM(至版本 7)中,方法区位于一个称为永久代的内存区域。在 HotSpot Java 8 中,永久代被一个称为元空间的本地内存区域所取代。

内存组织

JVM 包含了提供给 Java API 实现和 JVM 实现的本地代码和 Java 字节码。每个线程堆栈维护一个独立的本地代码调用栈。JVM 堆栈包含 Java 方法调用。请注意,Java SE 7 和 8 的 JVM 规范并不暗示存在本地方法栈,但对于 Java SE 5 和 6,则存在。

HotSpot 堆和垃圾回收

Oracle HotSpot JVM 使用代际堆。三个主要代是 年轻持久(旧)和 永久(仅限于 HotSpot JDK 1.7)。随着对象在垃圾回收中存活,它们从 伊甸园移动到 幸存者,再从 幸存者移动到 持久空间。新实例在 伊甸园段分配,这是一个非常便宜的操作(和指针增加一样便宜,比 C 的 malloc 调用更快),如果它已经有足够的空闲空间。当伊甸园区域没有足够的空闲空间时,会触发一次小垃圾回收。这次操作会将 伊甸园中的活动对象复制到 幸存者空间。在相同操作中,活动对象在 幸存者-1中检查,并复制到 幸存者-2,从而只保留 幸存者-2中的活动对象。这种方案保持 伊甸园幸存者-1为空且无碎片,以便进行新的分配,这被称为 复制收集

HotSpot 堆和垃圾回收

在年轻代达到一定的存活阈值后,对象会被移动到持久/旧代。如果无法进行小垃圾回收,则会尝试进行大垃圾回收。大垃圾回收不使用复制,而是依赖于标记-清除算法。我们可以使用吞吐量收集器(序列并行并行老)或低延迟收集器(并发G1)来处理旧代。以下表格显示了一个非详尽的选项列表,用于每个收集器类型:

收集器名称 JVM 标志
序列 -XX:+UseSerialGC
并行 -XX:+UseParallelGC
并行压缩 -XX:+UseParallelOldGC
并发 -XX:+UseConcMarkSweepGC-XX:+UseParNewGC-XX:+CMSParallelRemarkEnabled
G1 -XX:+UseG1GC

之前提到的标志可以用来启动 Java 运行时。例如,在以下命令中,我们使用并行压缩垃圾回收器启动具有 4 GB 堆的服务器 JVM:

java \
  -server \
  -Xms4096m -Xmx4096m \
  -XX:+UseParallelOldGC XX:ParallelGCThreads=4 \
  -jar application-standalone.jar

有时,由于多次运行完全垃圾回收,持久空间可能变得非常碎片化,以至于可能无法将对象从幸存者空间移动到持久空间。在这些情况下,会触发带有压缩的完全垃圾回收。在此期间,由于完全垃圾回收正在进行,应用程序可能看起来没有响应。

测量内存(堆/栈)使用情况

JVM 性能下降的一个主要原因是垃圾回收。当然,了解我们创建的对象如何使用堆内存以及如何通过降低足迹来减少对垃圾回收的影响是很有帮助的。让我们检查对象表示如何导致堆空间。

在 64 位 JVM 上,每个(未压缩的)对象或数组引用都是 16 字节长。在 32 位 JVM 上,每个引用都是 8 字节长。由于 64 位架构现在越来越普遍,64 位 JVM 更有可能在服务器上使用。幸运的是,对于高达 32GB 的堆大小,JVM(Java 7)可以使用压缩指针(默认行为),其大小仅为 4 字节。Java 8 虚拟机可以通过压缩指针访问高达 64GB 的堆大小,如下表所示:

未压缩 压缩 32 位
引用(指针) 8 4 4
对象头部 16 12 8
数组头部 24 16 12
超类填充 8 4 4

此表说明了不同模式下的指针大小(经 Attila Szegedi 许可复制:www.slideshare.net/aszegedi/everything-i-ever-learned-about-jvm-performance-tuning-twitter/20)。

在前一章中,我们看到了每种原始类型占用多少字节。让我们看看在压缩指针(常见情况)下,64 位 JVM(堆大小小于 32GB)中复合类型的内存消耗情况:

Java 表达式 64 位内存使用 描述(b = 字节,内存字大小的 8 的倍数近似值)
new Object() 16 字节 12 字节头部 + 4 字节填充
new byte[0] 16 字节 12 字节 obj 头部 + 4 字节 int 长度 = 16 字节数组头部
new String("foo") 40 字节(内部化字面量) 12 字节头部 + (12 字节数组头部 + 6 字节字符数组内容 + 4 字节长度 + 2 字节填充 = 24 字节) + 4 字节哈希
new Integer(3) 16 字节(装箱的整型) 12 字节头部 + 4 字节 int
new Long(4) 24 字节(装箱的长整型) 12 字节头部 + 8 字节 long 值 + 4 字节填充
class A { byte x; }``new A(); 16 字节 12 字节头部 + 1 字节值 + 3 字节填充
class B extends A {byte y;}``new B(); 24 字节(子类填充) 12 字节引用 + (1 字节值 + 7 字节填充 = 8 字节) 用于 A + 1 字节用于 y 的值 + 3 字节填充
clojure.lang.Symbol.intern("foo")``// clojure 'foo 104 字节(40 字节内部化) 12 字节头部 + 12 字节命名空间引用 + (12 字节名称引用 + 40 字节内部化字符) + 4 字节 int 哈希 + 12 字节元数据引用 + (12 字节 _str 引用 + 40 字节内部化字符) - 40 字节内部化 str
clojure.lang.Keyword.intern("foo")``// clojure :foo 184 字节(由工厂方法完全内部化) 12 字节引用 + (12 字节符号引用 + 104 字节内部化值) + 4 字节 int 哈希 + (12 字节 _str 引用 + 40 字节内部化 char)

通过比较由同一给定字符串创建的符号和关键字所占用的空间,可以证明尽管关键字相对于符号有轻微的开销,但关键字是完全内部化的,这将提供更好的内存消耗和随时间进行的垃圾回收保护。此外,关键字作为弱引用进行内部化,这确保了当内存中没有关键字指向内部化值时,它将被垃圾回收。

确定程序工作负载类型

我们经常需要确定程序是 CPU/缓存绑定、内存绑定、I/O 绑定还是竞争绑定。当一个程序是 I/O 或竞争绑定时,CPU 使用率通常较低。你可能需要使用分析器(我们将在第七章性能优化中看到,性能优化)来确定线程是否因为资源竞争而陷入停滞。当一个程序是 CPU/缓存或内存绑定时,CPU 使用率可能不是瓶颈来源的明确指标。在这种情况下,你可能想要通过检查程序中的缓存缺失来进行有根据的猜测。在 Linux 系统上,工具如perf (perf.wiki.kernel.org/)、cachegrind (valgrind.org/info/tools.html#cachegrind)和oprofile (oprofile.sourceforge.net/)可以帮助确定缓存缺失的数量——更高的阈值可能意味着程序是内存绑定的。然而,由于 Java 的 JIT 编译器需要预热才能观察到有意义的行为,因此使用这些工具与 Java 结合并不简单。项目perf-map-agent (github.com/jrudolph/perf-map-agent)可以帮助生成你可以使用perf实用程序关联的方法映射。

解决内存效率低下问题

在本章前面的部分,我们讨论了未经检查的内存访问可能成为瓶颈。截至 Java 8,由于堆和对象引用的工作方式,我们无法完全控制对象布局和内存访问模式。然而,我们可以关注频繁执行的代码块,以减少内存消耗,并尝试在运行时使它们成为缓存绑定而不是内存绑定。我们可以考虑一些降低内存消耗和访问随机性的技术:

  • JVM 中的原始本地变量(如 long、double、boolean、char 等)是在栈上创建的。其余的对象是在堆上创建的,并且只有它们的引用存储在栈上。原始变量具有较低的开销,并且不需要内存间接访问来访问,因此推荐使用。

  • 以顺序方式在主内存中布局的数据比随机布局的数据访问更快。当我们使用一个大的(比如说超过八个元素)持久映射时,存储在 tries 中的数据可能不会在内存中顺序布局,而是在堆中随机布局。此外,键和值都会被存储和访问。当你使用记录(defrecord)和类型(deftype)时,它们不仅为它们内部的字段布局提供数组/类语义,而且它们不存储键,与常规映射相比,这非常高效。

  • 从磁盘或网络读取大量内容可能会由于随机内存往返而对性能产生不利影响。在第三章《依赖 Java》中,我们简要讨论了内存映射字节数组缓冲区。你可以利用内存映射缓冲区来最小化堆上的碎片化对象分配/访问。虽然像nio(github.com/pjstadig/nio/)和clj-mmap(github.com/thebusby/clj-mmap)这样的库帮助我们处理内存映射缓冲区,bytebuffer(github.com/geoffsalmon/bytebuffer)和gloss(github.com/ztellman/gloss)则让我们能够处理字节数组缓冲区。还有其他抽象,如 iota(github.com/thebusby/iota),它帮助我们以集合的形式处理大文件。

由于内存瓶颈是数据密集型程序中潜在的性能问题,降低内存开销在很大程度上有助于避免性能风险。了解硬件、JVM 和 Clojure 实现的低级细节有助于我们选择适当的技巧来解决内存瓶颈问题。

使用 Criterium 测量延迟

Clojure 有一个叫做time的小巧的宏,它会评估传递给它的代码的主体,然后打印出所花费的时间,并简单地返回值。然而,我们可以注意到,执行代码所需的时间在不同的运行中变化很大:

user=> (time (reduce + (range 100000)))
"Elapsed time: 112.480752 msecs"
4999950000
user=> (time (reduce + (range 1000000)))
"Elapsed time: 387.974799 msecs"
499999500000

与此行为差异相关的有几个原因。当 JVM 冷启动时,其堆段为空,并且对代码路径一无所知。随着 JVM 的持续运行,堆开始填满,GC 模式开始变得明显。JIT 编译器有机会分析不同的代码路径并进行优化。只有在经过相当多的 GC 和 JIT 编译轮次之后,JVM 的性能才变得不那么不可预测。

Criterium (github.com/hugoduncan/criterium) 是一个 Clojure 库,用于在机器上科学地测量 Clojure 表达式的延迟。关于其工作原理的摘要可以在 Criterium 项目页面上找到。使用 Criterium 最简单的方法是使用 Leiningen。如果你只想在 REPL 中使用 Criterium,而不是将其作为项目依赖项,请将以下条目添加到~/.lein/profiles.clj文件中:

{:user {:plugins [[criterium "0.4.3"]]}}

另一种方法是在project.clj文件中包含criterium

:dependencies [[org.clojure/clojure "1.7.0"]
               [criterium "0.4.3"]]

完成文件编辑后,使用lein repl启动 REPL:

user=> (require '[criterium.core :as c])
nil
user=> (c/bench (reduce + (range 100000)))
Evaluation count : 1980 in 60 samples of 33 calls.
             Execution time mean : 31.627742 ms
    Execution time std-deviation : 431.917981 us
   Execution time lower quantile : 30.884211 ms ( 2.5%)
   Execution time upper quantile : 32.129534 ms (97.5%)
nil

现在,我们可以看到,在某个测试机器上,平均而言,该表达式花费了 31.6 毫秒。

Criterium 和 Leiningen

默认情况下,Leiningen 以低级编译模式启动 JVM,这导致它启动更快,但会影响 JRE 在运行时可以执行的优化。为了在服务器端用例中使用 Criterium 和 Leiningen 进行测试时获得最佳效果,请确保在project.clj中覆盖默认设置,如下所示:

:jvm-opts ^:replace ["-server"]

^:replace提示使 Leiningen 用:jvm-opts键下提供的值替换其默认设置。你可能需要根据需要添加更多参数,例如运行测试的最小和最大堆大小。

摘要

软件系统的性能直接受其硬件组件的影响,因此了解硬件的工作原理至关重要。处理器、缓存、内存和 I/O 子系统具有不同的性能行为。Clojure 作为一种托管语言,理解宿主(即 JVM)的性能特性同样重要。Criterium 库用于测量 Clojure 代码的延迟——我们将在第六章测量性能中再次讨论 Criterium。在下一章中,我们将探讨 Clojure 中的并发原语及其性能特性。

第五章. 并发

并发是 Clojure 的主要设计目标之一。考虑到 Java 的并发编程模型(与 Java 的比较是因为它是 JVM 上的主导语言),它不仅太低级,而且如果不严格遵循模式,很容易出错,甚至可能自食其果。锁、同步和无保护变异是并发陷阱的配方,除非极端谨慎地使用。Clojure 的设计选择深刻影响了以安全和函数式方式实现并发模式的方式。在本章中,我们将讨论:

  • 硬件和 JVM 级别的低级并发支持

  • Clojure 的并发原语——原子、代理、refs 和 vars

  • Java 内置的并发特性是安全的,并且与 Clojure 一起使用很有用

  • 使用 Clojure 特性和 reducers 进行并行化

低级并发

没有显式的硬件支持,无法实现并发。我们在前几章讨论了 SMT 和多核处理器。回想一下,每个处理器核心都有自己的 L1 缓存,几个核心共享 L2 缓存。共享的 L2 缓存为处理器核心提供了一个快速机制来协调它们的缓存访问,消除了相对昂贵的内存访问。此外,处理器将写入内存的操作缓冲到一个称为脏写缓冲区的东西中。这有助于处理器发布一系列内存更新请求,重新排序指令,并确定写入内存的最终值,称为写吸收

硬件内存屏障(fence)指令

内存访问重排序对于顺序(单线程)程序性能很有用,但对于并发程序来说却很危险,因为一个线程中内存访问的顺序可能会破坏另一个线程的预期。处理器需要同步访问的手段,以便内存重排序要么被限制在代码段中,这些代码段不关心,要么在可能产生不良后果的地方被阻止。硬件通过“内存屏障”(也称为“fence”)提供这种安全措施。

在不同的架构中发现了多种内存屏障指令,具有不同的性能特征。编译器(或 JVM 中的 JIT 编译器)通常了解它在上面运行的架构上的 fence 指令。常见的 fence 指令有读、写、获取和释放屏障等。屏障不保证最新的数据,而是只控制内存访问的相对顺序。屏障导致在屏障对发出它的处理器可见之前,将写缓冲区刷新完毕。

读写屏障分别控制读和写的顺序。写操作通过写缓冲区进行;但读操作可能发生顺序混乱,或者来自写缓冲区。为了保证正确的顺序,使用获取和释放块/屏障。获取和释放被认为是“半屏障”;两者结合(获取和释放)形成一个“全屏障”。全屏障比半屏障更昂贵。

Java 支持和 Clojure 的等效功能

在 Java 中,内存屏障指令是通过高级协调原语插入的。尽管 fence 指令的运行成本很高(数百个周期),但它们提供了一个安全网,使得在关键部分内访问共享变量是安全的。在 Java 中,synchronized关键字标记一个“关键部分”,一次只能由一个线程执行,因此它是一个“互斥”工具。在 Clojure 中,Java 的synchronized的等价物是locking宏:

// Java example
synchronized (someObject) {
    // do something
}
;; Clojure example
(locking some-object
  ;; do something
  )

locking 宏建立在两个特殊形式 monitor-entermonitor-exit 之上。请注意,locking 宏是一个低级和命令式的解决方案,就像 Java 的 synchronized 一样——它们的使用不被认为是 Clojure 的惯用法。特殊形式 monitor-entermonitor-exit 分别进入和退出锁对象的“监视器”——它们甚至更低级,不建议直接使用。

测量使用此类锁定代码的性能的人应该意识到其单线程与多线程延迟之间的差异。在单个线程中锁定是廉价的。然而,当有两个或更多线程争夺同一对象监视器的锁时,性能惩罚就开始显现。锁是在称为“内在”或“监视器”锁的对象监视器上获得的。对象等价性(即当 = 函数返回 true 时)永远不会用于锁定目的。确保从不同线程锁定时对象引用是相同的(即当 identical? 返回 true 时)。

通过线程获取监视器锁涉及一个读屏障,这会使得线程局部缓存的、相应的处理器寄存器和缓存行无效。这迫使从内存中重新读取。另一方面,释放监视器锁会导致一个写屏障,将所有更改刷新到内存中。这些操作成本高昂,会影响并行性,但它们确保了所有线程的数据一致性。

Java 支持一个 volatile 关键字,用于类中的数据成员,它保证了在同步块之外对属性的读写不会发生重排序。值得注意的是,除非一个属性被声明为 volatile,否则它不能保证在所有访问它的线程中都是可见的。Clojure 中 Java 的 volatile 对应的元数据是我们在 第三章 中讨论的 ^:volatile-mutable,即 依赖 Java。以下是一个 Java 和 Clojure 中 volatile 的示例:

// Java example
public class Person {
    volatile long age;
}
;; Clojure example
(deftype Person [^:volatile-mutable ^long age])

读取和写入 volatile 数据需要分别使用读获取或写释放,这意味着我们只需要一个半屏障来单独读取或写入值。请注意,由于半屏障,读取后跟写入的操作不保证是原子的。例如,age++ 表达式首先读取值,然后增加并设置它。这使得两个内存操作,这不再是半屏障。

Clojure 1.7 引入了一套新的函数来支持可变数据,这些函数是:volatile!vswap!vreset!volatile?。这些函数定义了可变(可变)数据并与之交互。然而,请注意,这些函数不与 deftype 中的可变字段一起工作。以下是如何使用它们的示例:

user=> (def a (volatile! 10))
#'user/a
user=> (vswap! a inc)
11
user=> @a
11
user=> (vreset! a 20)
20
user=> (volatile? a)
true

对 volatile 数据的操作不是原子的,这就是为什么即使创建 volatile(使用 volatile!)也被认为可能是危险的。一般来说,volatile 可能适用于读取一致性不是高优先级但写入必须快速的场景,例如实时趋势分析或其他此类分析报告。volatile 在编写有状态转换器(参考第二章, Clojure 抽象)时也非常有用,作为非常快速的状态容器。在下一小节中,我们将看到其他比 volatile 更安全(但大多数情况下更慢)的状态抽象。

原子更新和状态

读取数据元素,执行一些逻辑,然后用新值更新,这是一个常见的用例。对于单线程程序,这没有任何后果;但对于并发场景,整个操作必须作为一个原子操作同步执行。这种情况如此常见,以至于许多处理器在硬件级别使用特殊的比较和交换(CAS)指令来支持它,这比锁定要便宜得多。在 x86/x64 架构上,这个指令被称为 CompareExchange (CMPXCHG)。

不幸的是,可能存在另一个线程更新了变量,其值与正在执行原子更新的线程将要比较的旧值相同。这被称为“ABA”问题。一些其他架构中发现的“Load-linked”(LL)和“Store-conditional”(SC)等指令集合提供了没有 ABA 问题的 CAS 的替代方案。在 LL 指令从地址读取值之后,SC 指令将更新地址为新值,只有当地址自 LL 指令成功以来未被更新时,SC 指令才会执行。

Java 中的原子更新

Java 有许多内置的无锁、原子、线程安全的比较和交换抽象用于状态管理。它们位于 java.util.concurrent.atomic 包中。对于布尔型、整型和长整型等原始类型,分别有 AtomicBooleanAtomicIntegerAtomicLong 类。后两个类支持额外的原子加减操作。对于原子引用更新,有 AtomicReferenceAtomicMarkableReferenceAtomicStampedReference 类用于任意对象。还有对数组的支持,其中数组元素可以原子性地更新——AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray。它们易于使用;以下是一个示例:

(import 'java.util.concurrent.atomic.AtomicReference)
(def ^AtomicReference x (AtomicReference. "foo"))
(.compareAndSet x "foo" "bar")
(import 'java.util.concurrent.atomic.AtomicInteger)
(def ^AtomicInteger y (AtomicInteger. 10))
(.getAndAdd y 5)

然而,在哪里以及如何使用它则取决于更新点和代码中的逻辑。原子更新并不保证是非阻塞的。原子更新不是 Java 中锁的替代品,而是一种便利,仅当范围限制为对一个可变变量的比较和交换操作时。

Clojure 对原子更新的支持

Clojure 的原子更新抽象称为 "atom"。它底层使用 AtomicReference。对 AtomicIntegerAtomicLong 的操作可能比 Clojure 的 atom 略快,因为前者使用原语。但由于它们在 CPU 中使用的比较和交换指令,它们都不太便宜。速度实际上取决于突变发生的频率以及 JIT 编译器对代码的优化程度。速度的好处可能不会在代码运行了几十万次之后显现出来,并且原子频繁突变会增加重试的延迟。在实际(或类似实际)负载下测量延迟可以提供更好的信息。以下是一个使用原子的示例:

user=> (def a (atom 0))
#'user/a
user=> (swap! a inc)
1
user=> @a
1
user=> (compare-and-set! a 1 5)
true
user=> (reset! a 20)
20

swap! 函数在执行原子更新方面提供了一种与 compareAndSwap(oldval, newval) 方法明显不同的风格。当 compareAndSwap() 比较并设置值时,如果成功则返回 true,如果失败则返回 false,而 swap! 则会持续在一个无限循环中尝试更新,直到成功。这种风格是 Java 开发者中流行的模式。然而,与更新循环风格相关的潜在陷阱也存在。随着更新者的并发性提高,更新的性能可能会逐渐下降。再次,原子更新的高并发性引发了一个问题:对于使用案例来说,是否真的有必要进行无协调的更新。《compare-and-set!reset!` 则相当直观。

传递给 swap! 的函数必须是无副作用的(即无副作用),因为在竞争期间它会在循环中多次重试。如果函数不是无副作用的,副作用可能会在重试的次数内发生。

值得注意的是,原子不是“协调”的,这意味着当原子被不同的线程并发使用时,我们无法预测操作它的顺序,也无法保证最终结果。围绕原子的代码应该考虑到这种约束来设计。在许多场景中,由于缺乏协调,原子可能不是最佳选择——在设计程序时要注意这一点。原子通过额外的参数支持元数据和基本验证机制。以下示例说明了这些功能:

user=> (def a (atom 0 :meta {:foo :bar}))
user=> (meta a)
{:foo :bar}
user=> (def age (atom 0 :validator (fn [x] (if (> x 200) false true))))
user=> (reset! age 200)
200
user=> (swap! age inc)
IllegalStateException Invalid reference state  clojure.lang.ARef.validate (ARef.java:33)

第二个重要的事情是,原子支持对其添加和删除监视。我们将在本章后面讨论监视。

使用原子条带化进行更快的写入

我们知道,当多个线程试图同时更新状态时,原子会出现竞争。这意味着当写入操作不频繁时,原子具有很高的性能。有些用例,例如度量计数器,需要快速且频繁的写入,但读取较少,可以容忍一些不一致性。对于此类用例,我们不必将所有更新都指向单个原子,而可以维护一组原子,其中每个线程更新不同的原子,从而减少竞争。从这些原子中读取的数据不能保证一致性。让我们开发一个这样的计数器示例:

(def ^:const n-cpu (.availableProcessors (Runtime/getRuntime)))
(def counters (vec (repeatedly n-cpu #(atom 0))))
(defn inc! []
  ;; consider java.util.concurrent.ThreadLocalRandom in Java 7+
  ;; which is faster than Math/random that rand-int is based on
  (let [i (rand-int n-cpu)]
    (swap! (get counters i) inc)))
(defn value []
  (transduce (map deref) + counters))

在前面的示例中,我们创建了一个名为 counters 的向量,其大小与计算机中 CPU 核心的数量相同,并将每个元素初始化为初始值为 0 的原子。名为 inc! 的函数通过从 counters 中随机选择一个原子并增加 1 来更新计数器。我们还假设 rand-int 在所有处理器核心之间均匀分布地选择原子,因此我们几乎零竞争。value 函数简单地遍历所有原子,将它们的 deref'ed 值相加以返回计数器值。此示例使用 clojure.core/rand-int,它依赖于 java.lang.Math/random(由于 Java 6 支持)来随机找到下一个计数器原子。让我们看看在使用 Java 7 或更高版本时如何优化:

(import 'java.util.concurrent.ThreadLocalRandom)
(defn inc! []
  (let [i (.nextLong (ThreadLocalRandom/current) n-cpu)]
    (swap! (get counters i) inc)))

在这里,我们 import java.util.concurrent.ThreadLocalRandom 类,并定义 inc! 函数使用 ThreadLocalRandom 来选择下一个随机原子。其他一切保持不变。

异步代理和状态

虽然原子是同步的,但代理是 Clojure 中实现状态变化的异步机制。每个代理都与一个可变状态相关联。我们向代理传递一个函数(称为“操作”),并可选地传递额外的参数。此函数由代理排队在另一个线程中处理。所有代理共享两个公共线程池——一个用于低延迟(可能为 CPU 密集型、缓存密集型或内存密集型)作业,另一个用于阻塞(可能为 I/O 相关或长时间处理)作业。Clojure 提供了 send 函数用于低延迟操作,send-off 用于阻塞操作,send-via 用于在用户指定的线程池上执行操作,而不是预配置的线程池之一。所有 sendsend-offsend-via 都立即返回。以下是它们的用法:

(def a (agent 0))
;; invoke (inc 0) in another thread and set state of a to result
(send a inc)
@a  ; returns 1
;; invoke (+ 1 2 3) in another thread and set state of a to result
(send a + 2 3)
@a  ; returns 6

(shutdown-agents)  ; shuts down the thread-pools
;; no execution of action anymore, hence no result update either
(send a inc)
@a  ; returns 6

当我们检查 Clojure(截至版本 1.7.0)的源代码时,我们可以发现低延迟动作的线程池被命名为 pooledExecutor(一个有界线程池,初始化为最大 '2 + 硬件处理器数量' 线程),而高延迟动作的线程池被命名为 soloExecutor(一个无界线程池)。这种默认配置的前提是,CPU/缓存/内存绑定动作在默认线程数的有界线程池上运行最优化。I/O 绑定任务不消耗 CPU 资源。因此,可以同时执行相对较多的此类任务,而不会显著影响 CPU/缓存/内存绑定作业的性能。以下是访问和覆盖线程池的方法:

(import 'clojure.lang.Agent)
Agent/pooledExecutor  ; thread-pool for low latency actions
Agent/soloExecutor  ; thread-pool for I/O actions
(import 'java.util.concurrent.Executors)
(def a-pool (Executors/newFixedThreadPool 10))  ; thread-pool with 10 threads
(def b-pool (Executors/newFixedThreadPool 100)) ; 100 threads pool
(def a (agent 0))
(send-via a-pool a inc)  ; use 'a-pool' for the action
(set-agent-send-executor! a-pool)  ; override default thread-pool
(set-agent-send-off-executor! b-pool)  ; override default pool

如果一个程序通过代理执行大量 I/O 或阻塞操作,限制为这些动作分配的线程数量可能是有意义的。使用 set-agent-send-off-executor! 覆盖 send-off 线程池是限制线程池大小的最简单方法。为了隔离和限制代理上的 I/O 动作,可以使用 send-via 与各种 I/O 和阻塞操作适当大小的线程池。

异步、排队和错误处理

向代理发送动作会立即返回,而不会阻塞。如果代理尚未忙于执行任何动作,它会通过将触发动作执行的队列操作入队,在相应的线程池中“反应”。如果代理正在忙于执行另一个动作,新动作将简单地入队。一旦从动作队列中执行了动作,队列将检查是否有更多条目,并触发下一个动作(如果找到)。这个触发动作的整个“反应”机制消除了需要消息循环和轮询队列的需要。这之所以可能,是因为控制了指向代理队列的入口点。

动作在代理上异步执行,这引发了如何处理错误的疑问。错误情况需要通过一个显式、预定义的函数来处理。当使用默认的代理构造,如 (agent :foo) 时,代理在没有错误处理程序的情况下创建,并在发生任何异常时挂起。它缓存异常,并拒绝接受更多动作。在代理重启之前,它会在发送任何动作时抛出缓存的异常。可以使用 restart-agent 函数重置挂起的代理。这种挂起的目的是安全和监督。当异步动作在代理上执行并突然发生错误时,它将需要关注。查看以下代码:

(def g (agent 0))
(send g (partial / 10))  ; ArithmeticException due to divide-by-zero
@g  ; returns 0, because the error did not change the old state
(send g inc)  ; throws the cached ArithmeticException
(agent-error g)  ; returns (doesn't throw) the exception object
(restart-agent g @g)  ; clears the suspension of the agent
(agent-error g)  ; returns nil
(send g inc)  ; works now because we cleared the cached error
@g  ; returns 1
(dotimes [_ 1000] (send-off g long-task))
;; block for 100ms or until all actions over (whichever earlier)
(await-for 100 g)
(await g)  ; block until all actions dispatched till now are over

有两个可选参数 :error-handler:error-mode,我们可以在代理上配置这些参数以获得对错误处理和挂起的更精细控制,如下代码片段所示:

(def g (agent 0 :error-handler (fn [x] (println "Found:" x))))  ; incorrect arity
(send g (partial / 10))  ; no error encountered because error-handler arity is wrong
(def g (agent 0 :error-handler (fn [ag x] (println "Found:" x))))  ; correct arity
(send g (partial / 10))  ; prints the message
(set-error-handler! g (fn [ag x] (println "Found:" x)))  ; equiv of :error-handler arg
(def h (agent 0 :error-mode :continue))
(send h (partial / 10))  ; error encountered, but agent not suspended
(send h inc)
@h  ; returns 1
(set-error-mode! h :continue)  ; equiv of :error-mode arg, other possible value :fail

为什么你应该使用代理

正如“原子”实现只使用比较和交换而不是锁定一样,底层的“代理”特定实现主要使用比较和交换操作。代理实现仅在事务中调度动作(将在下一节中讨论)或重新启动代理时使用锁。所有动作都在代理中以串行方式排队和调度,无论并发级别如何。串行性质使得可以独立且无竞争地执行动作。对于同一代理,永远不会同时执行多个动作。由于没有锁定,对代理的读取(deref@)永远不会因为写入而被阻塞。然而,所有动作都是相互独立的——它们的执行没有重叠。

实现甚至确保了执行一个动作会阻塞队列中后续的动作。尽管动作是在线程池中执行的,但同一代理的动作永远不会并发执行。这是一个出色的排序保证,也由于其串行性质而扩展了自然的协调机制。然而,请注意,这种排序协调仅限于单个代理。如果一个代理的动作发送动作给两个其他代理,它们不会自动协调。在这种情况下,你可能想使用事务(将在下一节中介绍)。

由于代理区分低延迟和阻塞作业,作业将在适当的线程池中执行。不同代理上的动作可以并发执行,从而最大限度地利用线程资源。与原子不同,代理的性能不会因高竞争而受阻。事实上,对于许多情况,由于动作的串行缓冲,代理非常有意义。一般来说,代理非常适合高容量 I/O 任务,或者在操作顺序在高竞争场景中提供优势的情况下。

嵌套

当一个代理动作向同一代理发送另一个动作时,这就是嵌套的情况。如果代理没有参与 STM 事务(将在下一节中介绍),这本来就不会有什么特别之处。然而,代理确实参与了 STM 事务,这给代理的实现带来了一定的约束,需要对动作进行第二层缓冲。目前,可以说嵌套发送被排队在代理的线程局部队列中,而不是常规队列。线程局部队列仅对执行动作的线程可见。在执行动作时,除非出现错误,否则代理会隐式调用相当于release-pending-sends函数的功能,该函数将动作从第二级线程局部队列转移到正常动作队列。请注意,嵌套只是代理的实现细节,没有其他影响。

协调的事务引用和状态

在前面的部分中,我们看到了原子提供了原子读取和更新操作。如果我们需要在两个或更多原子之间执行原子读取和更新操作怎么办?这显然提出了一个协调问题。某个实体必须监视读取和更新的过程,以确保值不被破坏。这就是 ref 提供的——一个基于 软件事务内存STM)的系统,它负责在多个 refs 之间执行并发原子读取和更新操作,以确保要么所有更新都通过,要么在失败的情况下,没有任何更新。像原子一样,在失败的情况下,refs 会从零开始重试整个操作,使用新的值。

Clojure 的 STM 实现是粗粒度的。它在应用级对象和聚合(即聚合的引用)上工作,范围仅限于程序中的所有 refs,构成了“Ref 世界”。对 ref 的任何更新都只能以同步方式发生,在事务中,在 dosync 代码块内,在同一线程中。它不能跨越当前线程。实现细节揭示了在事务的生命周期内维护了一个线程局部事务上下文。一旦控制达到另一个线程,相同的上下文就不再可用。

与 Clojure 中的其他引用类型一样,对 ref 的读取永远不会因为更新而被阻塞,反之亦然。然而,与其他引用类型不同,ref 的实现不依赖于无锁自旋,而是内部使用锁、低级等待/通知、死锁检测和基于年龄的抢占。

alter 函数用于读取和更新一个 ref 的值,而 ref-set 用于重置值。大致来说,对于 refs 来说,alterref-set 类似于原子操作中的 swap!reset!。就像 swap! 一样,alter 接受一个无副作用的函数(和参数),并且可能在竞争期间重试多次。然而,与原子不同,不仅 alter,而且 ref-set 和简单的 deref 也可能在竞争期间导致事务重试。以下是一个如何使用事务的非常简单的例子:

(def r1 (ref [:a :b :c]))
(def r2 (ref [1 2 3]))
(alter r1 conj :d)  ; IllegalStateException No transaction running...
(dosync (let [v (last @r1)] (alter r1 pop) (alter r2 conj v)))
@r1  ; returns [:a :b]
@r2  ; returns [1 2 3 :c]
(dosync (ref-set r1 (conj @r1 (last @r2))) (ref-set r2 (pop @r2)))
@r1  ; returns [:a :b :c]
@r2  ; returns [1 2 3]

Ref 特性

Clojure 在事务中维护了 原子性一致性隔离性ACI)。这与许多数据库提供的 ACID 保证中的 A、C 和 I 相重叠。原子性意味着事务中的所有更新要么全部成功完成,要么全部不完成。一致性意味着事务必须保持一般正确性,并应遵守验证设置的约束——任何异常或验证错误都应回滚事务。除非共享状态受到保护,否则对它的并发更新可能导致多步事务在不同步骤中看到不同的值。隔离性意味着无论更新多么并发,事务中的所有步骤都将看到相同的值。

Clojure 引用使用一种称为多版本并发控制(MVCC)的技术来为事务提供快照隔离。在 MVCC 中,不是锁定(可能会阻塞事务),而是维护队列,以便每个事务都可以使用自己的快照副本进行,这个副本是在其“读点”拍摄的,独立于其他事务。这种方法的主要好处是,只读事务外的操作可以无冲突地通过。没有引用冲突的事务可以并发进行。在与数据库系统的粗略比较中,Clojure 引用隔离级别在事务外读取引用时为“读取已提交”,而在事务内默认为“可重复读”。

引用历史和事务中解引用操作

我们之前讨论过,对一个引用的读和更新操作都可能导致事务重试。事务中的读操作可以配置为使用引用历史,这样快照隔离实例就被存储在历史队列中,并由事务中的读操作使用。默认情况下,不使用历史队列,这可以节省堆空间,并在事务中提供强一致性(避免数据陈旧)。

使用引用历史可以降低由于读冲突导致的交易重试的可能性,从而提供弱一致性。因此,它是一种性能优化的工具,但以一致性为代价。在许多场景中,程序不需要强一致性——如果我们知道权衡利弊以及我们的需求,我们可以适当选择。Clojure 引用实现中的快照隔离机制由自适应历史队列支持。历史队列会动态增长以满足读请求,并且不会超过为引用设置的极限。默认情况下,历史记录是禁用的,因此我们需要在初始化时指定它,或者稍后设置。以下是如何使用历史的示例:

(def r (ref 0 :min-history 5 :max-history 10))
(ref-history-count r)  ; returns 0, because no snapshot instances are queued so far
(ref-min-history r)  ; returns 5
(ref-max-history r)  ; returns 10
(future (dosync (println "Sleeping 20 sec") (Thread/sleep 20000) (ref-set r 10)))
(dosync (alter r inc))  ; enter this within few seconds after the previous expression
;; The message "Sleeping 20 sec" should appear twice due to transaction-retry
(ref-history-count r)  ; returns 2, the number of snapshot history elements
(.trimHistory ^clojure.lang.Ref r)
(ref-history-count r)  ; returns 0 because we wiped the history
(ref-min-history r 10)  ; reset the min history
(ref-max-history r 20)  ; reset the max history count

最小/最大历史限制与数据陈旧窗口的长度成比例。它还取决于更新和读操作之间的相对延迟差异,以确定在给定主机系统上最小历史和最大历史的工作范围。可能需要一些尝试和错误才能得到正确的范围。作为一个粗略的估计,读操作只需要足够的 min-history 元素来避免事务重试,因为在一次读操作期间可以有那么多更新。max-history 元素可以是 min-history 的倍数,以覆盖任何历史超限或欠限。如果相对延迟差异不可预测,那么我们必须为最坏情况规划 min-history,或者考虑其他方法。

事务重试和闯入

事务可以在以下五种不同的状态之一内部运行——运行中、提交中、重试、已杀死和已提交。事务可能因各种原因而被杀死。异常是杀死事务的常见原因。但是,让我们考虑一个特殊情况,即事务多次重试,但似乎没有成功提交——解决方案是什么?Clojure 支持基于年龄的抢占,其中较旧的事务会自动尝试中止较新的事务,以便较新的事务稍后重试。如果抢占仍然不起作用,作为最后的手段,在达到 10,000 次重试尝试的硬性限制后,事务将被杀死,然后抛出异常。

使用 ensure 提高事务一致性

Clojure 的事务一致性在性能和安全之间取得了良好的平衡。然而,有时我们可能需要 可序列化 的一致性来保留事务的正确性。具体来说,在面临事务重试的情况下,当事务的正确性依赖于 ref 的状态,在事务中,ref 在另一个事务中同时被更新时,我们有一个称为“写偏斜”的条件。维基百科上关于写偏斜的条目 en.wikipedia.org/wiki/Snapshot_isolation 描述得很好,但让我们看看一个更具体的例子。假设我们想要设计一个具有两个引擎的飞行模拟系统,并且系统级约束之一是不要同时关闭两个引擎。如果我们将每个引擎建模为一个 ref,并且某些机动确实需要我们关闭一个引擎,我们必须确保另一个引擎是开启的。我们可以使用 ensure 来做到这一点。通常,当需要维护跨 ref 的一致性关系(不变性)时,需要 ensure。这不能通过验证函数来确保,因为它们只有在事务提交时才会发挥作用。验证函数将看到相同的值,因此无法提供帮助。

可以使用同名的 ensure 函数来解决写偏斜问题,该函数本质上防止其他事务修改 ref。它类似于锁定操作,但在实践中,当重试代价高昂时,它提供了比显式的读取和更新操作更好的并发性。使用 ensure 非常简单——(ensure ref-object)。然而,由于它在事务期间持有的锁,它可能在性能上代价高昂。使用 ensure 管理性能需要在重试延迟和由于确保状态而丢失的吞吐量之间进行权衡。

使用交换操作减少事务重试次数

交换操作与它们应用顺序无关。例如,从事务 t1 和 t2 中递增计数器引用 c1 将产生相同的效果,无论 t1 和 t2 提交它们的变化的顺序如何。引用对事务中交换的函数有特殊的优化——commute函数,它与alter(相同的语法)类似,但具有不同的语义。像alter一样,commute函数在事务提交期间原子性地应用。然而,与alter不同,commute不会因为竞争而导致事务重试,并且没有关于commute函数应用顺序的保证。这实际上使得commute在作为操作结果返回有意义值时几乎无用。在事务中,所有的commute函数都会在事务提交时使用事务引用的最终值重新应用。

事务只确保发送给代理的变化在事务提交发生前被排队。正如我们所见,交换操作(commute)减少了竞争,从而优化了整体事务吞吐量的性能。一旦我们知道一个操作是交换的,并且我们不会以有意义的方式使用它的返回值,那么在决定是否使用它时几乎没有什么权衡——我们只需继续使用它。实际上,考虑到引用事务,以交换为设计考虑的程序设计不是一个坏主意。

代理可以参与交易

在上一节关于代理的部分,我们讨论了代理如何与排队的变化函数协同工作。代理还可以参与引用事务(ref transactions),从而使得在事务中结合使用引用和代理成为可能。然而,代理并不包含在“引用世界”中,因此事务作用域不会扩展到代理中变化函数的执行。

在早期关于代理的部分的嵌套子节中,讨论了第二层线程局部队列。这个线程局部队列在事务期间用于持有发送给代理的变化,直到提交。线程局部队列不会阻塞发送给代理的其他变化。事务之外的变化永远不会在线程局部队列中缓冲;相反,它们被添加到代理中的常规队列中。

代理参与交易提供了一个有趣的设计角度,其中协调的以及独立/顺序的操作可以被作为工作流程进行流水线处理,以实现更好的吞吐量和性能。

嵌套事务

Clojure 事务具有嵌套意识,并且组合良好。但是,为什么需要嵌套事务呢?通常,独立的代码单元可能有自己的低粒度事务,而高级别代码可以利用这些事务。当高级别调用者本身需要将操作包装在事务中时,就会发生嵌套事务。嵌套事务有自己的生命周期和运行状态。然而,外部事务可以在检测到失败时取消内部事务。

“引用世界”快照的ensurecommute在嵌套事务的所有(即外部和内部)级别之间共享。因此,内部事务被视为在外部事务内部进行的任何其他引用更改操作(类似于alterref-set等)。监视和内部锁定实现由各自的嵌套级别处理。内部事务中的竞争检测会导致内部事务以及外部事务的重新启动。所有级别的提交最终都会在最外层事务提交时作为一个全局状态生效。尽管监视在每个单独的事务级别上都有跟踪,但最终在提交时生效。仔细观察嵌套事务实现可以看出,嵌套对事务性能的影响很小或没有。

性能考虑

Clojure 引用可能是迄今为止实现的最复杂的引用类型。由于其特性,特别是其事务重试机制,在高度竞争的场景中,这种系统可能会有良好的性能可能并不立即明显。

理解其细微差别和最佳使用方式将有所帮助:

  • 我们在事务中不使用具有副作用的变化,除非可能将 I/O 变化发送到代理,其中变化被缓冲直到提交。因此,根据定义,我们不会在事务中执行任何昂贵的 I/O 工作。因此,这项工作的重试成本也会很低。

  • 事务的更改函数应该尽可能小。这降低了延迟,因此重试的成本也会更低。

  • 任何没有至少与另一个引用同时更新的引用不需要是引用——在这种情况下原子就足够好了。现在,引用只有在组中才有意义,它们的竞争直接与组大小成正比。在事务中使用的小型引用组会导致低竞争、低延迟和高吞吐量。

  • 交换函数提供了在不产生任何惩罚的情况下提高事务吞吐量的好机会。识别这些情况并考虑交换进行设计可以帮助显著提高性能。

  • 引用非常粗粒度——它们在应用聚合级别工作。通常,程序可能需要更细粒度地控制事务资源。这可以通过引用条带化来实现,例如 Megaref (github.com/cgrand/megaref),通过提供关联引用的受限视图,从而允许更高的并发性。

  • 在高竞争场景中,如果事务中的引用组大小不能小,考虑使用代理,因为它们由于序列性质而没有竞争。代理可能不是事务的替代品,但我们可以使用由原子、引用和代理组成的管道来减轻竞争与延迟的担忧。

引用和事务具有复杂的实现。幸运的是,我们可以检查源代码,并浏览可用的在线和离线资源。

动态变量绑定和状态

在 Clojure 的引用类型中,第四种是动态变量。自从 Clojure 1.3 以来,所有变量默认都是静态的。必须显式声明变量以使其成为动态的。一旦声明,动态变量就可以在每线程的基础上绑定到新的值。不同线程上的绑定不会相互阻塞。以下是一个示例:

(def ^:dynamic *foo* "bar")
(println *foo*)  ; prints bar
(binding [*foo* "baz"] (println *foo*))  ; prints baz
(binding [*foo* "bar"] (set! *foo* "quux") (println *foo*))  ; prints quux

由于动态绑定是线程局部的,因此在多线程场景中使用可能会很棘手。动态变量长期以来一直被库和应用作为传递给多个函数使用的通用参数的手段所滥用。然而,这种风格被认为是一种反模式,并受到谴责。通常,在反模式动态中,变量会被宏包装以在词法作用域中包含动态线程局部绑定,这会导致多线程和懒序列出现问题。

那么,如何有效地使用动态变量呢?动态变量查找比查找静态变量更昂贵。即使是传递函数参数,在性能上也要比查找动态变量便宜得多。绑定动态变量会产生额外的开销。显然,在性能敏感的代码中,最好根本不使用动态变量。然而,在复杂或递归调用图场景中,动态变量可能非常有用,以持有临时的线程局部状态,在这些场景中,性能并不重要,而且不会被宣传或泄露到公共 API 中。动态变量绑定可以像堆栈一样嵌套和展开,这使得它们既吸引人又适合此类任务。

验证和监视引用类型

变量(静态和动态)、原子、引用和代理提供了一种验证作为状态设置值的途径——一个接受新值作为参数的validator函数,如果成功则返回逻辑值为真,如果出错则抛出异常/返回逻辑值为假(假和 nil 值)。它们都尊重验证函数返回的结果。如果成功,更新将通过,如果出错,则抛出异常。以下是声明验证器并将其与引用类型关联的语法:

(def t (atom 1 :validator pos?))
(def g (agent 1 :validator pos?))
(def r (ref 1 :validator pos?))
(swap! t inc)  ; goes through, because value after increment (2) is positive
(swap! t (constantly -3))  ; throws exception
(def v 10)
(set-validator! (var v) pos?)
(set-validator! t (partial < 10)) ; throws exception
(set-validator! g (partial < 10)) ; throws exception
(set-validator! r #(< % 10)) ; works

验证器在更新引用类型时会导致实际失败。对于变量和原子,它们通过抛出异常简单地阻止更新。在代理中,验证失败会导致代理失败,需要代理重启。在引用内部,验证失败会导致事务回滚并重新抛出异常。

观察引用类型变化的另一种机制是“观察者”。与验证器不同,观察者是被动的——它在更新发生后才会被通知。因此,观察者不能阻止更新通过,因为它只是一个通知机制。对于事务,观察者仅在事务提交后调用。虽然只能对一个引用类型设置一个验证器,但另一方面,可以将多个观察者与一个引用类型关联。其次,在添加观察时,我们可以指定一个键,这样通知就可以通过键来识别,并由观察者相应处理。以下是使用观察者的语法:

(def t (atom 1))
(defn w [key iref oldv newv] (println "Key:" key "Old:" oldv "New:" newv))
(add-watch t :foo w)
(swap! t inc)  ; prints "Key: :foo Old: 1 New: 2"

与验证器一样,观察者是在引用类型的线程中同步执行的。对于原子和引用,这可能没问题,因为通知观察者的同时,其他线程可以继续进行它们的更新。然而,在代理中,通知发生在更新发生的同一线程中——这使得更新延迟更高,吞吐量可能更低。

Java 并发数据结构

Java 有几个可变数据结构,旨在用于并发和线程安全,这意味着多个调用者可以同时安全地访问这些数据结构,而不会相互阻塞。当我们只需要高度并发访问而不需要状态管理时,这些数据结构可能非常适合。其中一些采用了无锁算法。我们已经在原子更新和状态部分讨论了 Java 原子状态类,所以这里不再重复。相反,我们只讨论并发队列和其他集合。

所有这些数据结构都位于java.util.concurrent包中。这些并发数据结构是根据 JSR 133 "Java 内存模型和线程规范修订"(gee.cs.oswego.edu/dl/jmm/cookbook.html)的实现定制的,该实现首次出现在 Java 5 中。

并发映射

Java 拥有一个可变的并发哈希表——java.util.concurrent.ConcurrentHashMap(简称 CHM)。在实例化类时,可以可选地指定并发级别,默认为 16。CHM 内部将映射条目分区到哈希桶中,并使用多个锁来减少每个桶的竞争。读取操作永远不会被写入操作阻塞,因此它们可能过时或不一致——这种情况通过内置的检测机制得到解决,并发出锁以再次以同步方式读取数据。这是针对读取操作远多于写入操作的场景的优化。在 CHM 中,所有单个操作几乎都是常数时间,除非由于锁竞争陷入重试循环。

与 Clojure 的持久映射相比,CHM 不能接受nullnil)作为键或值。Clojure 的不可变标量和集合自动适合与 CHM 一起使用。需要注意的是,只有 CHM 中的单个操作是原子的,并表现出强一致性。由于 CHM 操作是并发的,聚合操作提供的致性比真正的操作级致性要弱。以下是我们可以使用 CHM 的方式。CHM 中的单个操作提供了更好的致性,因此是安全的。聚合操作应保留在我们知道其致性特征和相关权衡时使用:

(import 'java.util.concurrent.ConcurrentHashMap)
(def ^ConcurrentHashMap m (ConcurrentHashMap.))
(.put m :english "hi")                    ; individual operation
(.get m :english)                           ; individual operation
(.putIfAbsent m :spanish "alo")    ; individual operation
(.replace m :spanish "hola")         ; individual operation
(.replace m :english "hi" "hello")  ; individual compare-and-swap atomic operation
(.remove m :english)                     ; individual operation
(.clear m)    ; aggregate operation
(.size m)      ; aggregate operation
(count m)    ; internally uses the .size() method
;; aggregate operation
(.putAll m {:french "bonjour" :italian "buon giorno"})
(.keySet m)  ; aggregate operation
(keys m)      ; calls CHM.entrySet() and on each pair java.util.Map.Entry.getKey()
(vals m)       ; calls CHM.entrySet() and on each pair java.util.Map.Entry.getValue()

java.util.concurrent.ConcurrentSkipListMap类(简称 CSLM)是 Java 中另一种并发可变映射数据结构。CHM 和 CSLM 之间的区别在于 CSLM 始终提供具有 O(log N)时间复杂度的排序视图。默认情况下,排序视图具有键的自然顺序,可以通过在实例化 CSLM 时指定 Comparator 实现来覆盖。CSLM 的实现基于跳表,并提供导航操作。

java.util.concurrent.ConcurrentSkipListSet类(简称 CSLS)是基于 CSLM 实现的并发可变集合。虽然 CSLM 提供映射 API,但 CSLS 在行为上类似于集合数据结构,同时借鉴了 CSLM 的功能。

并发队列

Java 内置了多种可变和并发内存队列的实现。队列数据结构是用于缓冲、生产者-消费者风格实现以及将这些单元管道化以形成高性能工作流程的有用工具。我们不应将它们与用于批量作业中类似目的的持久队列混淆,这些队列用于高吞吐量。Java 的内存队列不是事务性的,但它们只为单个队列操作提供原子性和强一致性保证。聚合操作提供较弱的致性。

java.util.concurrent.ConcurrentLinkedQueue (CLQ) 是一个无锁、无等待的无界“先进先出” (FIFO) 队列。FIFO 表示一旦元素被添加到队列中,队列元素的顺序将不会改变。CLQ 的 size() 方法不是一个常数时间操作;它取决于并发级别。以下是一些使用 CLQ 的示例:

(import 'java.util.concurrent.ConcurrentLinkedQueue)
(def ^ConcurrentLinkedQueue q (ConcurrentLinkedQueue.))
(.add q :foo)
(.add q :bar)
(.poll q)  ; returns :foo
(.poll q)  ; returns :bar
队列 阻塞? 有界? FIFO? 公平性? 备注
CLQ 无等待,但 size() 不是常数时间
ABQ 可选 容量在实例化时固定
DQ 元素实现了 Delayed 接口
LBQ 可选 容量是灵活的,但没有公平性选项
PBQ 元素按优先级顺序消费
SQ 可选 它没有容量;它充当一个通道

java.util.concurrent 包中,ArrayBlockingQueue (ABQ),DelayQueue (DQ),LinkedBlockingQueue (LBQ),PriorityBlockingQueue (PBQ) 和 SynchronousQueue (SQ) 实现了 BlockingQueue (BQ) 接口。它的 Javadoc 描述了其方法调用的特性。ABQ 是一个固定容量的 FIFO 队列,由数组支持。LBQ 也是一个 FIFO 队列,由链表节点支持,并且可以选择性地有界(默认为 Integer.MAX_VALUE)。ABQ 和 LBQ 通过阻塞在满容量时的入队操作来生成“背压”。ABQ 支持可选的公平性(但会有性能开销),按照访问它的线程的顺序。

DQ 是一个无界队列,接受与延迟相关的元素。队列元素不能为 null,并且必须实现 java.util.concurrent.Delayed 接口。元素只有在延迟过期后才能从队列中移除。DQ 可以非常有助于在不同时间安排元素的处理。

PBQ 是无界且阻塞的,同时允许根据优先级从队列中消费元素。元素默认具有自然排序,可以通过在实例化队列时指定 Comparator 实现来覆盖。

SQ 实际上根本不是一个队列。相反,它只是生产者或消费者线程的一个屏障。生产者会阻塞,直到消费者移除元素,反之亦然。SQ 没有容量。然而,SQ 支持可选的公平性(但会有性能开销),按照线程访问它的顺序。

Java 5 之后引入了一些新的并发队列类型。自从 JDK 1.6 以来,在 java.util.concurrent 包中,Java 有 BlockingDequeBD)和 LinkedBlockingDequeLBD)作为唯一可用的实现。BD 通过添加 Deque双端队列)操作来构建在 BQ 之上,即从队列两端添加元素和消费元素的能力。LBD 可以用一个可选的容量(有限制)来实例化,以阻塞溢出。JDK 1.7 引入了 TransferQueueTQ)和 LinkedTransferQueueLTQ)作为唯一实现。TQ 以一种方式扩展了 SQ 的概念,即生产者和消费者阻塞一个元素队列。这将通过保持它们忙碌来更好地利用生产者和消费者线程。LTQ 是 TQ 的无限制实现,其中 size() 方法不是一个常数时间操作。

Clojure 对并发队列的支持

我们在 第二章 Clojure 抽象 中较早地介绍了持久队列。Clojure 有一个内置的 seque 函数,它基于 BQ 实现(默认为 LBQ)来暴露预写序列。这个序列可能是惰性的,预写缓冲区控制要实现多少个元素。与块序列(块大小为 32)不同,预写缓冲区的大小是可控制的,并且可能在所有时间都充满,直到源序列耗尽。与块序列不同,32 个元素的块不会突然实现。它是逐渐和平稳地实现的。

在底层,Clojure 的 seque 使用代理在预写缓冲区中填充数据。在 seque 的 2 参数版本中,第一个参数应该是正整数,或者是一个 BQ(ABQ、LBQ 等)的实例,最好是有限制的。

线程的并发

在 JVM 上,线程是事实上的基本并发工具。多个线程生活在同一个 JVM 中;它们共享堆空间,并竞争资源。

JVM 对线程的支持

JVM 线程是操作系统线程。Java 将底层 OS 线程包装为 java.lang.Thread 类的实例,并在其周围构建一个 API 来处理线程。JVM 上的线程有多个状态:新建、可运行、阻塞、等待、定时等待和终止。线程通过覆盖 Thread 类的 run() 方法或通过将 java.lang.Runnable 接口的实例传递给 Thread 类的构造函数来实例化。

调用一个 Thread 实例的 start() 方法将在新线程中启动其执行。即使 JVM 中只有一个线程运行,JVM 也不会关闭。调用带有参数 truesetDaemon(boolean) 方法将线程标记为守护线程,如果没有其他非守护线程正在运行,则可以自动关闭。

所有的 Clojure 函数都实现了java.lang.Runnable接口。因此,在新的线程中调用函数非常简单:

(defn foo5 [] (dotimes [_ 5] (println "Foo")))
(defn barN [n] (dotimes [_ n] (println "Bar")))
(.start (Thread. foo5))  ; prints "Foo" 5 times
(.start (Thread. (partial barN 3)))  ; prints "Bar" 3 times

run()方法不接受任何参数。我们可以通过创建一个不需要参数的高阶函数来解决这个问题,但内部应用参数3

JVM 中的线程池

创建线程会导致操作系统 API 调用,这并不总是个低成本的操作。通常的做法是创建一个线程池,这些线程可以被回收用于不同的任务。Java 内置了对线程池的支持。名为java.util.concurrent.ExecutorService的接口代表了线程池的 API。创建线程池最常见的方式是使用java.util.concurrent.Executors类中的工厂方法:

(import 'java.util.concurrent.Executors)
(import 'java.util.concurrent.ExecutorService)
(def ^ExecutorService a (Executors/newSingleThreadExecutor))  ; bounded pool
(def ^ExecutorService b (Executors/newCachedThreadPool))  ; unbounded pool
(def ^ExecutorService c (Executors/newFixedThreadPool 5))  ; bounded pool
(.execute b #(dotimes [_ 5] (println "Foo")))  ; prints "Foo" 5 times

之前的例子等同于我们在前一小节中看到的原始线程的例子。线程池也能够帮助跟踪新线程中执行函数的完成情况和返回值。ExecutorService 接受一个java.util.concurrent.Callable实例作为参数,用于启动任务的几个方法,并返回java.util.concurrent.Future以跟踪最终结果。

所有的 Clojure 函数也实现了Callable接口,因此我们可以像下面这样使用它们:

(import 'java.util.concurrent.Callable)
(import 'java.util.concurrent.Future)
(def ^ExecutorService e (Executors/newSingleThreadExecutor))
(def ^Future f (.submit e (cast Callable #(reduce + (range 10000000)))))
(.get f)  ; blocks until result is processed, then returns it

这里描述的线程池与我们在前面的代理部分简要看到的线程池相同。当不再需要时,线程池需要通过调用shutdown()方法来关闭。

Clojure 并发支持

Clojure 有一些巧妙的内置功能来处理并发。我们已经在前面的小节中讨论了代理,以及它们如何使用线程池。Clojure 还有一些其他并发特性来处理各种用例。

未来

在本节前面,我们看到了如何使用 Java API 启动新线程,执行函数,以及如何获取结果。Clojure 有一个内置的支持称为“futures”,可以以更平滑和集成的方式完成这些事情。futures 的基础是future-call函数(它接受一个无参数函数作为参数),以及基于前者的宏future(它接受代码体)。两者都会立即启动一个线程来执行提供的代码。以下代码片段说明了与 future 一起工作的函数以及如何使用它们:

;; runs body in new thread
(def f (future (println "Calculating") (reduce + (range 1e7))))
(def g (future-call #(do (println "Calculating") (reduce + (range 1e7)))))  ; takes no-arg fn
(future? f)                  ; returns true
(future-cancel g)        ; cancels execution unless already over (can stop mid-way)
(future-cancelled? g) ; returns true if canceled due to request
(future-done? f)         ; returns true if terminated successfully, or canceled
(realized? f)               ; same as future-done? for futures
@f                              ; blocks if computation not yet over (use deref for timeout)

future-cancel的一个有趣方面是,它有时不仅可以取消尚未开始的任务,还可以中止那些正在执行中的任务:

(let [f (future (println "[f] Before sleep")
                (Thread/sleep 2000)
                (println "[f] After sleep")
                2000)]
  (Thread/sleep 1000)
  (future-cancel f)
  (future-cancelled? f))
;; [f] Before sleep  ← printed message (second message is never printed)
;; true  ← returned value (due to future-cancelled?)

之前的情况发生是因为 Clojure 的future-cancel以这种方式取消 future:如果执行已经启动,它可能会被中断,导致InterruptedException,如果没有显式捕获,则简单地终止代码块。注意来自 future 中执行代码的异常,因为默认情况下,它们不会被详细报告!Clojure 的 future 使用“solo”线程池(用于执行可能阻塞的操作),这是我们之前在讨论 agents 时提到的。

Promise

Promise 是计算结果的占位符,该计算可能已经发生或尚未发生。Promise 不直接与任何计算相关联。根据定义,promise 不暗示计算何时可能发生,因此实现 promise。

通常,promise 起源于代码的一个地方,由知道何时以及如何实现 promise 的代码的其他部分实现。这种情况通常发生在多线程代码中。如果 promise 尚未实现,任何尝试读取值的操作都会阻塞所有调用者。如果 promise 已经实现,那么所有调用者都可以读取值而不会被阻塞。与 future 一样,可以使用deref带有超时地读取 promise。

这里有一个非常简单的例子,展示了如何使用 promise:

(def p (promise))
(realized? p)  ; returns false
@p  ; at this point, this will block until another thread delivers the promise
(deliver p :foo)
@p  ; returns :foo (for timeout use deref)

Promise 是一个非常强大的工具,可以作为函数参数传递。它可以存储在引用类型中,或者简单地用于高级协调。

Clojure 并行化和 JVM

我们在第一章 设计性能 中观察到,并行性是硬件的函数,而并发性是软件的函数,由硬件支持辅助。除了本质上纯粹是顺序的算法之外,并发性是促进并行性和提高性能的首选方法。不可变和无状态数据是并发的催化剂,因为没有可变数据,线程之间没有竞争。

Moore's law

在 1965 年,英特尔联合创始人戈登·摩尔观察到,集成电路每平方英寸的晶体管数量每 24 个月翻一番。他还预测这种趋势将持续 10 年,但实际上,它一直持续到现在,几乎半个世纪。更多的晶体管导致了更多的计算能力。在相同面积内晶体管数量更多,我们需要更高的时钟速度来传输所有晶体管的信号。其次,晶体管需要变得更小以适应。大约在 2006-2007 年,电路能够工作的时钟速度达到了大约 2.8GHz,这是由于散热问题和物理定律。然后,多核处理器诞生了。

Amdahl's law

多核处理器自然需要分割计算以实现并行化。从这里开始出现冲突——原本设计为顺序运行的程序无法利用多核处理器的并行化特性。程序必须进行修改,以便在每一步找到分割计算的机会,同时考虑到协调成本。这导致了一个限制,即程序的速度不能超过其最长的顺序部分(竞争,或串行性),以及协调开销。这一特性被 Amdahl 定律所描述。

通用可扩展性定律

尼尔·冈瑟博士的通用可扩展性定律(USL)是 Amdahl 定律的超集,它将竞争(α)一致性(β)作为量化可扩展性的第一类关注点,使其非常接近现实并行系统的可扩展性。一致性意味着协调开销(延迟)在使并行程序的一部分结果可供另一部分使用时的协调。虽然 Amdahl 定律表明竞争(串行性)会导致性能水平化,但 USL 表明,性能实际上随着过度并行化而下降。USL 用以下公式描述:

C(N) = N / (1 + α ((N – 1) + β N (N – 1)))

在这里,C(N)表示根据并发源(如物理处理器或驱动软件应用的用户)的相对容量或吞吐量。α表示由于共享数据或顺序代码引起的竞争程度,而β表示维护共享数据一致性所承受的惩罚。我鼓励您进一步研究 USL(www.perfdynamics.com/Manifesto/USLscalability.html),因为这是研究并发对可扩展性和系统性能影响的重要资源。

Clojure 对并行化的支持

依赖于变异的程序不能在不创建可变状态竞争的情况下并行化其部分。它需要协调开销,这使情况变得更糟。Clojure 的不可变特性更适合并行化程序的部分。Clojure 还有一些结构,由于 Clojure 考虑了可用的硬件资源,因此适合并行化。结果是,操作针对某些用例场景进行了优化。

pmap

pmap函数(类似于map)接受一个函数和一个或多个数据元素集合作为参数。该函数应用于数据元素中的每一个,使得一些元素由函数并行处理。并行化因子由pmap实现选择,在运行时选择大于可用处理器总数的值。它仍然以惰性方式处理元素,但实现因子与并行化因子相同。

查看以下代码:

(pmap (partial reduce +)
        [(range 1000000)
         (range 1000001 2000000)
         (range 2000001 3000000)])

要有效地使用 pmap,我们必须了解它的用途。正如文档所述,它适用于计算密集型函数。它针对 CPU 密集型和缓存密集型工作进行了优化。高延迟和低 CPU 任务,如阻塞 I/O,与 pmap 不太匹配。另一个需要注意的陷阱是 pmap 中使用的函数是否执行了大量的内存操作。由于相同的函数将在所有线程中应用,所有处理器(或核心)可能会竞争内存互连和子系统带宽。如果并行内存访问成为瓶颈,由于内存访问的竞争,pmap 无法真正实现并行操作。

另一个关注点是当多个 pmap 操作并发运行时会发生什么?Clojure 不会尝试检测并发运行的多 pmap。对于每个新的 pmap 操作,都会重新启动相同数量的线程。开发者负责确保并发 pmap 执行产生的程序的性能特征和响应时间。通常,当延迟原因是首要考虑时,建议限制程序中运行的并发 pmap 实例数量。

pcalls

pcalls 函数是用 pmap 构建的,因此它借鉴了后者的属性。然而,pcalls 函数接受零个或多个函数作为参数,并并行执行它们,将调用结果作为列表返回。

pvalues

pvalues 宏是用 pcalls 构建的,因此它间接共享 pmap 的属性。它的行为类似于 pcalls,但它接受零个或多个在并行中使用 pmap 评估的 S-表达式,而不是函数。

Java 7 的 fork/join 框架

Java 7 引入了一个名为 "fork/join" 的新并行框架,该框架基于分治和窃取工作调度算法。使用 fork/join 框架的基本思路相当简单——如果工作足够小,则直接在同一线程中执行;否则,将工作分成两部分,在 fork/join 线程池中调用它们,并等待结果合并。

这样,工作会递归地分成更小的部分,例如倒置树,直到最小部分可以仅用单个线程执行。当叶/子树工作返回时,父工作将所有子工作的结果合并,并返回结果。

Fork/join 框架是用 Java 7 实现的,它是一种特殊的线程池;请查看 java.util.concurrent.ForkJoinPool。这个线程池的特殊之处在于它接受 java.util.concurrent.ForkJoinTask 类型的作业,并且每当这些作业阻塞,等待子作业完成时,等待作业使用的线程被分配给子作业。当子作业完成其工作后,线程被分配回阻塞的父作业以继续。这种动态线程分配的方式被称为“工作窃取”。Fork/join 框架可以从 Clojure 内部使用。ForkJoinTask 接口有两个实现:java.util.concurrent 包中的 RecursiveActionRecursiveTask。具体来说,RecursiveTask 可能对 Clojure 更有用,因为 RecursiveAction 是为与可变数据一起工作而设计的,并且其操作不返回任何值。

使用 fork-join 框架意味着选择将作业拆分成批次的批次大小,这是并行化长时间作业的一个关键因素。批次大小过大可能无法充分利用所有 CPU 核心;另一方面,批次大小过小可能会导致更长的开销,在父/子批次之间进行协调。正如我们将在下一节中看到的,Clojure 集成了 Fork/join 框架以并行化 Reducer 的实现。

使用 Reducer 的并行性

Reducer 是 Clojure 1.5 中引入的一种新抽象,未来版本中可能会对 Clojure 的其余实现产生更广泛的影响。它们描绘了在 Clojure 中处理集合的另一种思考方式——关键概念是打破集合只能顺序、惰性或产生序列处理的观念,以及更多。摆脱这种行为的保证一方面提高了进行贪婪和并行操作的可能性,另一方面则带来了约束。Reducer 与现有集合兼容。

例如,对常规的 map 函数的敏锐观察揭示,其经典定义与产生结果时的机制(递归)、顺序(顺序)、惰性(通常)和表示(列表/序列/其他)方面紧密相关。实际上,这大部分定义了“如何”执行操作,而不是“需要做什么”。在 map 的情况下,“需要做什么”就是将函数应用于其集合参数的每个元素。但由于集合类型可以是各种类型(树状结构、序列、迭代器等),操作函数无法知道如何遍历集合。Reducer 将操作中的“需要做什么”和“如何做”部分解耦。

可约性、Reducer 函数、缩减变换

集合种类繁多,因此只有集合本身知道如何导航自己。在 reducers 模型的基本层面上,每个集合类型的内部“reduce”操作可以访问其属性和行为,以及返回的内容。这使得所有集合类型本质上都是“可还原”的。所有与集合一起工作的操作都可以用内部“reduce”操作来建模。这种操作的新建模形式是一个“reducing 函数”,通常是一个接受两个参数的函数,第一个参数是累加器,第二个是新输入。

当我们需要在集合的元素上叠加多个函数时,它是如何工作的?例如,假设我们首先需要“过滤”、“映射”,然后“减少”。在这种情况下,使用“转换函数”来建模 reducer 函数(例如,对于“过滤”),作为另一个 reducer 函数(对于“映射”)的建模方式,这样在转换过程中添加功能。这被称为“减少转换”。

实现可还原集合

虽然 reducer 函数保留了抽象的纯洁性,但它们本身并没有什么用处。在名为clojure.core.reducers的命名空间中的 reducer 操作,类似于mapfilter等,基本上返回一个包含 reducer 函数在内的可还原集合。可还原集合并不是实际实现的,甚至不是懒实现的——而只是一个准备好的实现方案。为了实现一个可还原集合,我们必须使用reducefold操作之一。

实现可还原集合的reduce操作是严格顺序的,尽管与clojure.core/reduce相比有性能提升,这是因为减少了堆上的对象分配。实现可还原集合的fold操作可能是并行的,并使用“reduce-combine”方法在 fork-join 框架上。与传统的“map-reduce”风格不同,使用 fork/join 的 reduce-combine 方法在底层进行减少,然后通过减少的方式再次合并。这使得fold实现更加节省资源,性能更优。

可折叠集合与并行性

通过fold进行的并行减少对集合和操作施加了某些约束。基于树的集合类型(持久化映射、持久化向量和持久化集合)适合并行化。同时,序列可能无法通过fold并行化。其次,fold要求单个 reducer 函数应该是“结合律”,即应用于 reducer 函数的输入参数的顺序不应影响结果。原因是fold可以将集合的元素分割成可以并行处理的段,而它们可能合并的顺序是事先不知道的。

fold 函数接受一些额外的参数,例如“组合函数”和用于并行处理的分区批量大小(默认为 512)。选择最佳分区大小取决于工作负载、主机能力和性能基准测试。某些函数是可折叠的(即可以通过 fold 并行化),而其他则不是,如下所示。它们位于 clojure.core.reducers 命名空间中:

  • 可折叠mapmapcatfilterremoveflatten

  • 不可折叠take-whiletakedrop

  • 组合函数catfoldcatmonoid

Reducers 的一个显著特点是,只有在集合是树类型时才能并行折叠。这意味着在折叠它们时,整个数据集必须加载到堆内存中。这导致在系统高负载期间内存消耗增加。另一方面,对于此类场景,一个惰性序列是一个完全合理的解决方案。在处理大量数据时,使用惰性序列和 Reducers 的组合来提高性能可能是有意义的。

概述

并发和并行性在多核时代对性能至关重要。有效利用并发需要深入了解其底层原理和细节。幸运的是,Clojure 提供了安全且优雅的方式来处理并发和状态。Clojure 的新特性“reducers”提供了一种实现细粒度并行化的方法。在未来的几年里,我们可能会看到越来越多的处理器核心,以及编写利用这些核心的代码的需求不断增加。Clojure 将我们置于应对这些挑战的正确位置。

在下一章中,我们将探讨性能测量、分析和监控。

第六章。测量性能

根据预期的和实际的表现,以及是否存在测量系统,性能分析和调整可能是一个相当复杂的过程。现在我们将讨论性能特性的分析以及测量和监控它们的方法。在本章中,我们将涵盖以下主题:

  • 测量性能和理解结果

  • 根据不同目的进行性能测试

  • 监控性能和获取指标

  • 分析 Clojure 代码以识别性能瓶颈

性能测量和统计

测量性能是性能分析的基础。正如我们在这本书中之前提到的,我们需要根据各种场景来测量几个性能参数。Clojure 内置的 time 宏是一个用于测量执行代码体所花费时间的工具。测量性能因素是一个更为复杂的过程。测量的性能数值之间可能存在关联,我们需要进行分析。使用统计概念来建立关联因素是一种常见的做法。在本节中,我们将讨论一些基本的统计概念,并使用这些概念来解释如何通过测量的数据获得更全面的视角。

一份微小的统计学术语入门指南

当我们有一系列定量数据,例如相同操作的延迟(在多次执行中测量)时,我们可以观察到许多事情。首先,也是最明显的,是数据中的最小值和最大值。让我们用一个示例数据集来进一步分析:

23 19 21 24 26 20 22 21 25 168 23 20 29 172 22 24 26

中位数,第一四分位数,第三四分位数

我们可以看到,这里的最低延迟是 19 毫秒,而最高延迟是 172 毫秒。我们还可以观察到这里的平均延迟大约是 40 毫秒。让我们按升序排序这些数据:

19 20 20 21 21 22 22 23 23 24 24 25 26 26 29 168 172

前一个数据集的中心元素,即第九个元素(值为 23),被认为是数据集的中位数。值得注意的是,中位数比平均数均值更能代表数据的中心。左半部分的中心元素,即第五个元素(值为 21),被认为是第一四分位数。同样,右半部分的中心值,即第 13 个元素(值为 26),被认为是数据集的第三四分位数。第三四分位数与第一四分位数之间的差称为四分位距(IQR),在本例中为 5。这可以通过以下箱线图来表示:

中位数,第一四分位数,第三四分位数

箱线图突出了数据集的第一四分位数、中位数和第三四分位数。如您所见,两个“异常值”延迟数(168 和 172)异常高于其他数值。中位数在数据集中不表示异常值,而均值则表示。

中位数,第一四分位数,第三四分位数

直方图(之前显示的图表)是另一种显示数据集的方式,我们将数据元素分批处理在时间段内,并展示这些时间段的频率。一个时间段包含一定范围内的元素。直方图中的所有时间段通常大小相同;然而,当没有数据时,省略某些时间段并不罕见。

百分位数

百分位数用参数表示,例如 99 百分位数,或 95 百分位数等。百分位数是指所有指定百分比的数值都存在的值。例如,95 百分位数意味着数据集中第 N 个值,使得数据集中 95%的元素值低于 N。作为一个具体的例子,我们之前在本节中讨论的延迟数据集的 85 百分位数是 29,因为总共有 17 个元素,其中 14 个(即 17 的 85%)其他元素在数据集中的值低于 29。四分位数将数据集分成每个 25%元素的块。因此,第一个四分位数实际上是 25 百分位数,中位数是 50 百分位数,第三个四分位数是 75 百分位数。

方差和标准差

数据的分布范围,即数据元素与中心值的距离,为我们提供了对数据的进一步洞察。考虑第 i 个偏差作为第 i 个数据集元素值(在统计学中称为“变量”值)与其均值的差;我们可以将其表示为方差和标准差。我们可以用以下方式表示其“方差”和“标准差”:

方差 = 方差和标准差,标准差(σ)= 方差和标准差 = 方差和标准差

标准差用希腊字母“sigma”表示,或简单地表示为“s”。考虑以下 Clojure 代码来确定方差和标准差:

(def tdata [23 19 21 24 26 20 22 21 25 168 23 20 29 172 22 24 26])

(defn var-std-dev
  "Return variance and standard deviation in a vector"
  [data]
  (let [size (count data)
        mean (/ (reduce + data) size)
        sum (->> data
                 (map #(let [v (- % mean)] (* v v)))                 (reduce +))
        variance (double (/ sum (dec size)))]
    [variance (Math/sqrt variance)]))

user=> (println (var-std-dev tdata))
[2390.345588235294 48.89116063497873]

您可以使用基于 Clojure 的平台 Incanter(incanter.org/)进行统计分析。例如,您可以在 Incanter 中使用(incanter.stats/sd tdata)来找到标准差。

经验法则说明了数据集元素与标准差(SD)之间的关系。它指出,数据集中所有元素的 68.3%位于均值一个(正或负)标准差的范围内,95.5%的所有元素位于均值的两个标准差范围内,而 99.7%的所有数据元素位于均值的三个标准差范围内。

观察我们最初开始讨论的延迟数据集,从均值的一个标准差是方差和标准差(方差和标准差范围 -9 到 89)包含 88%的所有元素。从均值的两个标准差是方差和标准差范围 -58 到 138)包含相同 88%的所有元素。然而,从均值的三个标准差是(方差和标准差范围 -107 到 187)包含 100%的所有元素。经验法则所陈述的内容与我们的结果之间存在不匹配,因为经验法则通常适用于具有大量元素的均匀分布数据集。

理解 Criterium 输出

在第四章中,我们介绍了 Clojure 库Criterium来测量 Clojure 表达式的延迟。以下是一个基准测试结果的示例:

user=> (require '[criterium.core :refer [bench]])
nil
user=> (bench (reduce + (range 1000)))
Evaluation count : 162600 in 60 samples of 2710 calls.
             Execution time mean : 376.756518 us
    Execution time std-deviation : 3.083305 us
   Execution time lower quantile : 373.021354 us ( 2.5%)
   Execution time upper quantile : 381.687904 us (97.5%)

Found 3 outliers in 60 samples (5.0000 %)
low-severe 2 (3.3333 %)
low-mild 1 (1.6667 %)
 Variance from outliers : 1.6389 % Variance is slightly inflated by outliers

我们可以看到,结果中包含了一些我们在本节前面讨论过的熟悉术语。高平均值和低标准差表明执行时间的变化不大。同样,下四分位数(第一个)和上四分位数(第三个)表明它们与平均值并不太远。这一结果意味着代码在响应时间方面相对稳定。Criterium 重复执行多次以收集延迟数值。

然而,为什么 Criterium 试图对执行时间进行统计分析?如果我们简单地计算平均值,会有什么遗漏呢?实际上,所有执行的响应时间并不总是稳定的,响应时间的显示往往存在差异。只有在正确模拟负载下运行足够的时间,我们才能获得关于延迟的完整数据和其它指标。统计分析有助于了解基准测试是否存在问题。

指导性能目标

我们在第一章中仅简要讨论了性能目标,设计性能,因为那次讨论需要参考统计概念。假设我们确定了功能场景和相应的响应时间。响应时间应该保持固定吗?我们可以限制吞吐量以优先考虑规定的响应时间吗?

性能目标应指定最坏情况的响应时间,即最大延迟、平均响应时间和最大标准差。同样,性能目标还应提及最坏情况的吞吐量、维护窗口吞吐量、平均吞吐量和最大标准差。

性能测试

性能测试需要我们知道我们将要测试什么,我们想要如何测试,以及为测试执行设置什么环境。有几个需要注意的陷阱,例如缺乏接近真实硬件和生产使用的资源、类似的操作系统和软件环境、测试用例的代表性数据的多样性等等。测试输入的多样性不足可能导致单调分支预测,从而在测试结果中引入“偏差”。

测试环境

对测试环境的担忧始于生产环境的硬件代表。传统上,测试环境硬件是生产环境的缩小版。在非代表性硬件上进行的性能分析几乎肯定会歪曲结果。幸运的是,近年来,得益于通用硬件和云系统,提供与生产环境相似的测试环境硬件并不太难。

网络和存储带宽、用于性能测试的操作系统和软件当然应该与生产环境相同。同样重要的是要有一个能代表测试场景的“负载”。负载可以有多种组合,包括请求的并发性、请求的吞吐量和标准偏差、数据库或消息队列中的当前人口水平、CPU 和堆使用情况等。模拟一个代表性的负载是很重要的。

测试通常需要对执行测试的代码部分进行相当多的工作。务必将其开销降至最低,以免影响基准测试结果。在可能的情况下,使用除测试目标之外的系统来生成请求。

要测试的内容

任何非平凡系统的实现通常涉及许多硬件和软件组件。对整个系统中的某个特定功能或服务进行性能测试时,需要考虑它与各个子系统的交互方式。例如,一个 Web 服务调用可能会触及多个层次,如 Web 服务器(请求/响应打包和解包)、基于 URI 的路由、服务处理程序、应用程序-数据库连接器、数据库层、日志组件等。仅测试服务处理程序将是一个严重的错误,因为这并不是 Web 客户端将体验到的性能。性能测试应该在系统的外围进行,以保持结果的现实性,最好有第三方观察者。

性能目标规定了测试的标准。测试不需要的内容可能是有用的,尤其是在测试并发运行时。运行有意义的性能测试可能需要一定程度的隔离。

测量延迟

执行一段代码所获得的延迟可能因每次运行而略有不同。这需要我们多次执行代码并收集样本。延迟数字可能会受到 JVM 预热时间、垃圾收集和 JIT 编译器启动的影响。因此,测试和样本收集应确保这些条件不会影响结果。Criterium 遵循这种方法来产生结果。当我们以这种方式测试非常小的代码片段时,它被称为微基准测试

由于某些操作的延迟在运行期间可能会变化,因此收集所有样本并将它们分成出现频率的时期和频率,形成直方图是很重要的。在测量延迟时,最大延迟是一个重要的指标——它表示最坏情况的延迟。除了最大值之外,99 百分位和 95 百分位的延迟数字也很重要,以便从不同角度看待问题。实际上收集延迟数字比从标准偏差中推断它们更重要,正如我们之前提到的,经验法则仅适用于没有显著异常值的高斯分布。

异常值在测量延迟时是一个重要的数据点。异常值的比例越高,表明服务可能存在退化的可能性。

比较延迟测量

当评估用于项目的库或提出针对某些基线的替代解决方案时,比较延迟基准测试有助于确定性能权衡。我们将检查基于 Criterium 的两个比较基准测试工具,称为 Perforate 和 Citius。两者都使得按上下文分组运行 Criterium 基准测试变得容易,并且可以轻松查看基准测试结果。

Perforate (github.com/davidsantiago/perforate) 是一个 Leiningen 插件,允许定义目标;一个目标(使用 perforate.core/defgoal 定义)是一个具有一个或多个基准的常见任务或上下文。每个基准都是使用 perforate.core/defcase 定义的。截至版本 0.3.4,一个示例基准代码可能看起来像以下代码片段:

(ns foo.bench
  (:require [perforate.core :as p]))

(p/defgoal str-concat "String concat")
(p/defcase str-concat :apply
  [] (apply str ["foo" "bar" "baz"]))
(p/defcase str-concat :reduce
  [] (reduce str ["foo" "bar" "baz"]))

(p/defgoal sum-numbers "Sum numbers")
(p/defcase sum-numbers :apply
  [] (apply + [1 2 3 4 5 6 7 8 9 0]))
(p/defcase sum-numbers :reduce
  [] (reduce + [1 2 3 4 5 6 7 8 9 0]))

你可以在 project.clj 中声明测试环境,并在定义目标时提供设置/清理代码。Perforate 提供了从命令行运行基准测试的方法。

Citius (github.com/kumarshantanu/citius) 是一个提供对 clojure.test 和其他调用形式的集成钩子的库。它比 Perforate 施加更严格的约束,并为基准测试提供额外的比较信息。它假设每个测试套件中有一个固定数量的目标(案例),其中可能有多个目标。

截至 0.2.1 版本,一个示例基准代码可能看起来像以下代码片段:

(ns foo.bench
  (:require [citius.core :as c]))

(c/with-bench-context ["Apply" "Reduce"]
  {:chart-title "Apply vs Reduce"
   :chart-filename "bench-simple.png"}
  (c/compare-perf
    "concat strs"
    (apply str ["foo" "bar" "baz"])
    (reduce str ["foo" "bar" "baz"]))
  (c/compare-perf
    "sum numbers"
    (apply + [1 2 3 4 5 6 7 8 9 0])
    (reduce + [1 2 3 4 5 6 7 8 9 0])))

在前一个例子中,代码运行基准测试,报告比较摘要,并绘制平均延迟的条形图。

并发情况下的延迟测量

当我们使用 Criterium 对一段代码进行基准测试时,它仅使用单个线程来确定结果。这为我们提供了关于单线程结果的公平输出,但在许多基准测试场景中,单线程延迟远非我们所需要的。在并发情况下,延迟往往与单线程延迟有很大差异。特别是当我们处理有状态对象(例如,从 JDBC 连接池中绘制连接,更新共享内存状态等)时,延迟很可能会随着竞争程度成比例变化。在这种情况下,了解代码在不同并发级别下的延迟模式是有用的。

在前一个子节中讨论的 Citius 库支持可调的并发级别。考虑以下共享计数器实现的基准测试:

(ns foo.bench
  (:require
    [clojure.test :refer [deftest]]
    [citius.core :as c])
  (:import [java.util.concurrent.atomic AtomicLong]))

(def a (atom 0))
(def ^AtomicLong b (AtomicLong. 0))

(deftest test-counter
  (c/with-bench-context ["Atom" "AtomicLong"] {}
    (c/compare-perf "counter"
      (swap! a unchecked-inc) (.incrementAndGet b))))

;; Under Unix-like systems run the following command in terminal:
;; CITIUS_CONCURRENCY=4,4 lein test

当我在第四代四核英特尔酷睿 i7 处理器(Mac OSX 10.10)上运行这个基准测试时,在并发级别 04 的平均延迟是并发级别 01 平均延迟的 38 到 42 倍。由于在许多情况下,JVM 用于运行服务器端应用程序,因此在并发下的基准测试变得尤为重要。

测量吞吐量

吞吐量以时间单位表示。粗粒度吞吐量,即收集了很长时间的吞吐量数字,可能隐藏了吞吐量实际上是分批而不是均匀分布的事实。吞吐量测试的粒度取决于操作的特性。批量处理可能处理数据爆发,而网络服务可能提供均匀分布的吞吐量。

平均吞吐量测试

尽管 Citius(截至版本 0.2.1)在基准测试结果中显示了外推的吞吐量(每秒,每线程),但由于各种原因,这个吞吐量数字可能并不能很好地代表实际的吞吐量。以下是一个简单的吞吐量基准测试工具的构建方法,从辅助函数开始:

(import '[java.util.concurrent ExecutorService Executors Future])
(defn concurrently
  ([n f]
    (concurrently n f #(mapv deref %)))
  ([n f g]
    (let [^ExecutorService
          thread-pool (Executors/newFixedThreadPool n)
          future-vals (transient [])]
      (dotimes [i n]
        (let [^Callable task (if (coll? f) (nth f i) f)
              ^Future each-future (.submit thread-pool task)]
          (conj! future-vals each-future)))
      (try
        (g (persistent! future-vals))
        (finally
          (.shutdown thread-pool))))))

(defn call-count
  []
  (let [stats (atom 0)]
    (fn
      ([] (deref stats))
      ([k] (if (identical? :reset k)
             (reset! stats 0)
             (swap! stats unchecked-inc))))))

(defn wrap-call-stats
  [stats f]
  (fn [& args]
    (try
      (let [result (apply f args)]
        (stats :count)
        result))))

(defn wait-until-millis
  ([^long timeout-millis]
    (wait-until-millis timeout-millis 100))
  ([^long timeout-millis ^long progress-millis]
    (while (< (System/currentTimeMillis) timeout-millis)
      (let [millis (min progress-millis
                     (- timeout-millis (System/currentTimeMillis)))]
        (when (pos? millis)
          (try
            (Thread/sleep millis)
            (catch InterruptedException e
              (.interrupt ^Thread (Thread/currentThread))))
          (print \.)
          (flush))))))

现在我们已经定义了辅助函数,让我们看看基准测试代码:

(defn benchmark-throughput*
  [^long concurrency ^long warmup-millis ^long bench-millis f]
  (let [now        #(System/currentTimeMillis)
        exit?      (atom false)
        stats-coll (repeatedly concurrency call-count)
        g-coll     (->> (repeat f)
                     (map wrap-call-stats stats-coll)
                     (map-indexed (fn [i g]
                                    (fn []
                                      (let [r (nth stats-coll i)]
                                        (while (not (deref exit?))
                                          (g))
                                        (r)))))
                     vec)
        call-count (->> (fn [future-vals]
                          (print "\nWarming up")
                          (wait-until-millis (+ (now) warmup-millis))
                          (mapv #(% :reset) stats-coll) ; reset count
                          (print "\nBenchmarking")
                          (wait-until-millis (+ (now) bench-millis))
                          (println)
                          (swap! exit? not)
                          (mapv deref future-vals))
                     (concurrently concurrency g-coll)
                     (apply +))]
    {:concurrency concurrency
     :calls-count call-count
     :duration-millis bench-millis
     :calls-per-second (->> (/ bench-millis 1000)
                         double
                         (/ ^long call-count)
                         long)}))

(defmacro benchmark-throughput
  "Benchmark a body of code for average throughput."
  [concurrency warmup-millis bench-millis & body]
  `(benchmark-throughput*
    ~concurrency ~warmup-millis ~bench-millis (fn [] ~@body)))

现在我们来看看如何使用工具测试代码的吞吐量:

(def a (atom 0))
(println
  (benchmark-throughput
    4 20000 40000 (swap! a inc)))

此工具仅提供简单的吞吐量测试。要检查吞吐量模式,您可能希望将吞吐量分配到滚动固定持续时间窗口中(例如,每秒吞吐量)。然而,这个主题超出了本文的范围,尽管我们将在本章后面的性能监控部分中提及它。

负载、压力和耐久性测试

测试的一个特点是每次运行只代表执行过程中的一个时间片段。重复运行建立它们的总体行为。但应该运行多少次才算足够?一个操作可能有几个预期的负载场景。因此,需要在各种负载场景下重复测试。简单的测试运行可能并不总是表现出操作的长期行为和响应。在变化的高负载下长时间运行测试,使我们能够观察任何可能不会在短期测试周期中出现的异常行为。

当我们在远超预期延迟和吞吐量目标负载下测试一个操作时,这就是压力测试。压力测试的目的是确定操作在超出其开发的最大负载后所表现出的合理行为。观察操作行为的另一种方法是观察它在非常长时间内运行的情况,通常为几天或几周。这种长时间的测试被称为耐久性测试。虽然压力测试检查操作的良好行为,但耐久性测试检查操作在长时间内的持续行为。

有几种工具可以帮助进行负载和压力测试。Engulf (engulf-project.org/) 是一个基于 Clojure 的分布式 HTTP-based 负载生成工具。Apache JMeter 和 Grinder 是基于 Java 的负载生成工具。Grinder 可以使用 Clojure 脚本化。Apache Bench 是用于 Web 系统的负载测试工具。Tsung 是一个可扩展的、高性能的、基于 Erlang 的负载测试工具。

性能监控

在长时间测试期间或应用程序上线后,我们需要监控其性能,以确保应用程序继续满足性能目标。可能存在影响应用程序性能或可用性的基础设施或运营问题,或者偶尔的延迟峰值或吞吐量下降。通常,监控通过生成连续的反馈流来减轻这种风险。

大概有三种组件用于构建监控堆栈。一个 收集器 从每个需要监控的主机发送数字。收集器获取主机信息和性能数字,并将它们发送到一个 聚合器。聚合器接收收集器发送的数据,并在用户代表请求时将它们持久化。

项目 metrics-clojure (github.com/sjl/metrics-clojure) 是一个 Clojure 包装器,覆盖了 Metrics (github.com/dropwizard/metrics) Java 框架,它充当收集器。Statsd 是一个知名的聚合器,它本身不持久化数据,而是将其传递给各种服务器。其中一个流行的可视化项目是 Graphite,它不仅存储数据,还为请求的时段生成图表。还有其他几种替代方案,值得注意的是 Riemann (riemann.io/),它是用 Clojure 和 Ruby 编写的。Riemann 是一个基于事件处理的聚合器。

通过日志进行监控

近年来出现的一种流行的性能监控方法是通过日志。这个想法很简单——应用程序以日志的形式发出指标数据,这些数据从单个机器发送到中央日志聚合服务。然后,这些指标数据在每个时间窗口内进行聚合,并进一步移动以进行归档和可视化。

作为这样一个监控系统的高级示例,你可能想使用 Logstash-forwarder (github.com/elastic/logstash-forwarder) 从本地文件系统抓取应用程序日志并将其发送到 Logstash (www.elastic.co/products/logstash),在那里它将指标日志转发到 StatsD (github.com/etsy/statsd) 进行指标聚合或转发到 Riemann (riemann.io/) 进行事件分析和监控警报。StatsD 和/或 Riemann 可以将指标数据转发到 Graphite (graphite.wikidot.com/) 以归档和绘制时间序列指标数据。通常,人们希望将非默认的时间序列数据存储(例如 InfluxDBinfluxdb.com/) 或可视化层(例如 Grafanagrafana.org/) 与 Graphite 连接起来。

关于这个话题的详细讨论超出了本文的范围,但我认为探索这个领域对你大有裨益。

Ring(Web)监控

如果你使用 Ring (github.com/ring-clojure/ring) 开发 Web 软件,那么 metrics-clojure 库的 Ring 扩展可能对你很有用:metrics-clojure.readthedocs.org/en/latest/ring.html ——它跟踪了许多有用的指标,这些指标可以以 JSON 格式查询,并通过网页浏览器进行可视化集成。

要从网络层发出连续的指标数据流,服务器端事件SSE)可能是一个不错的选择,因为它开销较低。http-kit (www.http-kit.org/) 和 Aleph (aleph.io/),它们与 Ring 一起工作,今天都支持 SSE。

内省

Oracle JDK 和 OpenJDK 都提供了两个名为 JConsole(可执行名称 jconsole)和 JVisualVM(可执行名称 jvisualvm)的 GUI 工具,我们可以使用它们来对正在运行的 JVM 进行内省以获取仪表数据。JDK 中还有一些命令行工具([docs.oracle.com/javase/8/docs/technotes/tools/](http://docs.oracle.com/javase/8/docs/technotes/tools/)),可以用来窥探正在运行的 JVM 的内部细节。

内省一个正在运行的 Clojure 应用程序的一个常见方法是运行一个 nREPL (https://github.com/clojure/tools.nrepl) 服务,这样我们就可以稍后使用 nREPL 客户端连接到它。使用 Emacs 编辑器(内嵌 nREPL 客户端)进行 nREPL 的交互式内省在一些人中很受欢迎,而其他人则更喜欢编写 nREPL 客户端脚本来执行任务。

通过 JMX 进行 JVM 仪表化

JVM 有一个内置机制,通过可扩展的Java 管理扩展(JMX) API 来检查管理资源。它为应用程序维护者提供了一种将可管理资源作为“MBeans”公开的方法。Clojure 有一个易于使用的contrib库,名为java.jmx(github.com/clojure/java.jmx),用于访问 JMX。还有相当数量的开源工具,可以通过 JMX 可视化 JVM 仪器数据,例如jmxtransjmxetric,它们与 Ganglia 和 Graphite 集成。

使用 Clojure 获取 JVM 的快速内存统计信息相当简单:

(let [^Runtime r (Runtime/getRuntime)]
  (println "Maximum memory" (.maxMemory r))
  (println "Total memory" (.totalMemory r))
  (println "Free memory" (.freeMemory r)))
Output:
Maximum memory 704643072
Total memory 291373056
Free memory 160529752

分析

我们在第一章设计性能中简要讨论了分析器类型。在前一节中我们讨论的 JVisualVM 工具也是一个与 JDK 捆绑的 CPU 和内存分析器。让我们看看它们在实际中的应用——考虑以下两个 Clojure 函数,它们分别对 CPU 和内存进行压力测试:

(defn cpu-work []
  (reduce + (range 100000000)))

(defn mem-work []
  (->> (range 1000000)
       (map str)
       vec
       (map keyword)
       count))

使用 JVisualVM 相当简单——从左侧面板打开 Clojure JVM 进程。它具有采样和常规分析器风格的剖析。当代码运行时,开始对 CPU 或内存使用进行剖析,并等待收集足够的数据以在屏幕上绘制。

分析

下面的示例展示了内存分析的实际应用:

分析

注意,JVisualVM 是一个非常简单、入门级分析器。市场上有多款商业 JVM 分析器,用于更复杂的需求。

操作系统和 CPU/缓存级别分析

仅对 JVM 进行剖析可能并不总是能说明全部情况。深入到操作系统和硬件级别的剖析通常能更好地了解应用程序的情况。在类 Unix 操作系统中,如tophtopperfiotanetstatvistaupstatepidstat等命令行工具可以帮助进行剖析。对 CPU 进行缓存缺失和其他信息的剖析是捕捉性能问题的有用来源。在 Linux 的开源工具中,Likwid(code.google.com/p/likwid/github.com/rrze-likwid/likwid)是一个针对 Intel 和 AMD 处理器的轻量级但有效的工具;i7z(code.google.com/p/i7z/github.com/ajaiantilal/i7z)是专门针对 Intel 处理器的。还有针对更复杂需求的专用商业工具,如Intel VTune Analyzer

I/O 分析

分析 I/O 可能也需要特殊的工具。除了iotablktrace之外,iopingcode.google.com/p/ioping/github.com/koct9i/ioping)对于测量 Linux/Unix 系统上的实时 I/O 延迟很有用。vnStat工具对于监控和记录 Linux 上的网络流量很有用。存储设备的 IOPS 可能无法完全反映真相,除非它伴随着不同操作的延迟信息,以及可以同时发生的读取和写入次数。

在 I/O 密集型的工作负载中,必须随着时间的推移寻找读取和写入 IOPS,并设置一个阈值以实现最佳性能。应用程序应限制 I/O 访问,以确保不会超过阈值。

摘要

提供高性能应用程序不仅需要关注性能,还需要系统地测量、测试、监控和优化各种组件和子系统的性能。这些活动通常需要正确的技能和经验。有时,性能考虑甚至可能将系统设计和架构带回设计图板。为实现性能而采取的早期结构化步骤对于确保持续满足性能目标至关重要。

在下一章中,我们将探讨性能优化工具和技术。

第七章. 性能优化

性能优化本质上具有累加性,因为它通过将性能调优添加到对底层系统如何工作的了解以及性能测量结果中来实现。本章建立在之前涵盖“底层系统如何工作”和“性能测量”的章节之上。尽管你会在本章中注意到一些类似食谱的部分,但你已经知道为了充分利用这些内容所必需的先决条件。性能调优是一个测量性能、确定瓶颈、应用知识以尝试调整代码,并重复这一过程直到性能提高的迭代过程。在本章中,我们将涵盖:

  • 设置项目以获得更好的性能

  • 识别代码中的性能瓶颈

  • 使用 VisualVM 分析代码

  • Clojure 代码的性能调优

  • JVM 性能调优

项目设置

在寻找瓶颈以修复代码中的性能问题至关重要时,从一开始就可以做一些事情来确保更好的性能。

软件版本

通常,新的软件版本包括错误修复、新功能和性能改进。除非有相反的建议,最好使用较新版本。对于使用 Clojure 的开发,请考虑以下软件版本:

  • JVM 版本:截至本文撰写时,Java 8(Oracle JDK,OpenJDK,Zulu)已发布为最新的稳定生产就绪版本。它不仅稳定,而且在多个领域(特别是并发)的性能也比早期版本更好。如果你有选择,请选择 Java 8 而不是 Java 的旧版本。

  • Clojure 版本:截至本文撰写时,Clojure 1.7.0 是最新的稳定版本,它在性能上比旧版本有多个改进。还有一些新特性(transducers,volatile)可以使你的代码性能更好。除非你别无选择,否则请选择 Clojure 1.7 而不是旧版本。

Leiningen project.clj 配置

截至 2.5.1 版本,默认的 Leiningen 模板(lein new foolein new app foo)需要一些调整以使项目适应性能。确保你的 Leiningen project.clj 文件包含以下条目,根据需要。

启用反射警告

在 Clojure 编程中最常见的陷阱之一是无意中让代码依赖反射。回想一下,我们在 第三章 中讨论了这一点,依赖 Java。启用 反射警告非常简单,让我们通过在 project.clj 中添加以下条目来修复它:

:global-vars {*unchecked-math* :warn-on-boxed ; in Clojure 1.7+
              *warn-on-reflection* true}

在之前的配置中,第一个设置 *unchecked-math* :warn-on-boxed 只在 Clojure 1.7 中有效——它会发出数字装箱警告。第二个设置 *warn-on-reflection* true 适用于更早的 Clojure 版本以及 Clojure 1.7,并在代码中发出反射警告信息。

然而,将这些设置包含在 project.clj 文件中可能还不够。只有当命名空间被加载时,才会发出反射警告。你需要确保所有命名空间都被加载,以便在整个项目中搜索反射警告。这可以通过编写引用所有命名空间的测试或通过执行此类操作的脚本来实现。

在基准测试时启用优化 JVM 选项

在 第四章 主机性能 中,我们讨论了 Leiningen 默认启用分层编译,这以牺牲 JIT 编译器的优化为代价提供了较低的启动时间。默认设置对于性能基准测试来说相当具有误导性,因此你应该启用代表你在生产中使用的 JVM 选项:

:profiles {:perf {:test-paths ^:replace ["perf-test"]
                  :jvm-opts ^:replace ["-server"
                                       "-Xms2048m" "-Xmx2048m"]}}

例如,之前的设置定义了一个 Leiningen 配置文件,该配置文件覆盖了默认 JVM 选项,以配置具有 2 GB 固定大小的堆空间的 server Java 运行时。它还将测试路径设置为目录 perf-test。现在你可以按照以下方式运行性能测试:

lein with-profile perf test

如果你的项目有需要不同 JVM 选项的性能测试套件,你应该为运行测试定义多个配置文件。

区分初始化和运行时

大多数非平凡项目在它们能够运行之前都需要设置大量的上下文。这些上下文的例子可能包括应用程序配置、内存状态、I/O 资源、线程池、缓存等等。虽然许多项目从临时的配置和初始化开始,但最终项目需要将初始化阶段与运行时分离。这种区分的目的不仅是为了净化代码的组织结构,而且是为了在运行时接管之前尽可能多地预先计算。这种区分还允许初始化阶段轻松(并且根据配置条件)为初始化的代码进行性能日志记录和监控。

非平凡程序通常被划分为层,例如业务逻辑、缓存、消息传递、数据库访问等等。每一层都与一个或多个其他层有依赖关系。通过使用第一性原理编写代码,可以实施初始化阶段的隔离,许多项目实际上就是这样做的。然而,有一些库通过允许你声明层之间的依赖关系来简化这个过程。Component (github.com/stuartsierra/component) 和 Prismatic Graph (github.com/Prismatic/plumbing) 是这类库的显著例子。

组件库的文档非常完善。可能不太容易明显地看出如何使用 Prismatic Graph 进行依赖关系解析;以下是一个为了说明而构造的示例:

(require '[plumbing.core :refer [fnk]])
(require '[plumbing.graph :as g])

(def layers
  {:db      (fnk [config]    (let [pool (db-pool config)]
                               (reify IDatabase ...)))
   :cache   (fnk [config db] (let [cache-obj (mk-cache config)]
                               (reify ICache    ...)))
   :service (fnk [config db cache] (reify IService  ...))
   :web     (fnk [config service]  (reify IWeb      ...))})

(defn resolve-layers
  "Return a map of reified layers"
  [app-config]
  (let [compiled (g/compile layers)]
    (compiled {:config app-config})))

这个例子仅仅展示了层依赖图的构建,但通常你可能需要不同的构建范围和顺序来进行测试。在这种情况下,你可以定义不同的图并在适当的时候解决它们。如果你需要测试的拆解逻辑,可以为每个拆解步骤添加额外的 fnk 条目,并使用这些条目进行拆解。

识别性能瓶颈

我们在前面章节中讨论过,随机调整代码的性能很少有效,因为我们可能没有在正确的位置进行调整。在我们可以调整代码中的这些区域之前,找到性能瓶颈至关重要。找到瓶颈后,我们可以围绕它进行替代解决方案的实验。在本节中,我们将探讨如何找到瓶颈。

Clojure 代码中的延迟瓶颈

延迟是深入挖掘以找到瓶颈的最基本、最明显的指标。对于 Clojure 代码,我们在第六章中观察到,代码分析工具可以帮助我们找到瓶颈区域。当然,分析器非常有用。一旦通过分析器发现热点,你可能会找到一些方法来在一定程度上调整这些热点的延迟。

大多数分析器都在聚合上工作,一批运行,按资源消耗对代码中的热点进行排名。然而,调整延迟的机会往往在于长尾,这可能不会被分析器突出显示。在这种情况下,我们可能需要采用直接钻取技术。让我们看看如何使用 Espejito (github.com/kumarshantanu/espejito) 进行此类钻取,这是一个用于在单线程执行路径中的测量点测量延迟的 Clojure 库(截至版本 0.1.0)。使用 Espejito 有两个部分,都需要修改您的代码——一个用于包装要测量的代码,另一个用于报告收集到的测量数据。以下代码演示了一个虚构的电子商务用例,即向购物车添加商品:

(require '[espejito.core :as e])

;; in the top level handler (entry point to the use case)
(e/report e/print-table
  ...)

;; in web.clj
(e/measure "web/add-cart-item"
  (biz/add-item-to-cart (resolve-cart request) item-code qty)
  ...)

;; in service.clj (biz)
(defn add-item-to-cart
  [cart item qty]
  (e/measure "biz/add-cart-item"
    (db/save-cart-item (:id cart) (:id item) qty)
    ...))

;; in db.clj (db)
(defn save-cart-item
  [cart-id item-id qty]
  (e/measure "db/save-cart-item"
    ...))

报告调用只需要在代码的最外层(顶级)层进行一次。测量调用可以在调用路径中的任何位置进行。请注意,不要在紧密循环中放置测量调用,这可能会使内存消耗激增。当此执行路径被触发时,功能按常规工作,同时延迟被透明地测量和记录在内存中。e/report 调用会打印出记录的指标表。一个示例输出(编辑以适应)可能如下所示:

|                 :name|:cumulat|:cumul%|:indiv |:indiv%|:thrown?|
|----------------------+--------+-------+-------+-------+--------|
|    web/add-cart-item |11.175ms|100.00%|2.476ms|22.16% |        |
| biz/add-item-to-cart | 8.699ms| 77.84%|1.705ms|15.26% |        |
|    db/save-cart-item | 6.994ms| 62.59%|6.994ms|62.59% |        |

在这里,我们可以观察到数据库调用是最昂贵的(单个延迟),其次是网络层。我们的调整偏好可能由测量点的昂贵程度顺序来指导。

只在热时进行测量

在钻取测量中,我们没有涵盖的一个重要方面是环境是否已准备好进行测量。e/report 调用每次都会无条件地调用,这不仅会有自己的开销(打印表),而且 JVM 可能还没有预热,JIT 编译器可能还没有启动以正确报告延迟。为了确保我们只报告有意义的延迟,让我们在以下示例条件下触发 e/report 调用:

(defmacro report-when
  [test & body]
  `(if ~test
    (e/report e/print-table
      ~@body)
    ~@body))

现在,假设它是一个基于 Ring (github.com/ring-clojure/ring) 的 Web 应用程序,并且您只想在 Web 请求包含参数 report 且值为 true 时触发报告。在这种情况下,您的调用可能如下所示:

(report-when (= "true" (get-in request [:params "report"]))
  ...)

基于条件的调用期望 JVM 在多个调用中保持运行,因此它可能不适用于命令行应用程序。

此技术也可以用于性能测试,在某个预热期间可能进行非报告调用,然后进行报告调用,该调用提供自己的报告函数而不是e/print-table。您甚至可以编写一个采样报告函数,该函数在一段时间内汇总样本,并最终报告延迟指标。不仅限于性能测试,您还可以使用此方法进行延迟监控,其中报告函数记录指标而不是打印表格,或将延迟分解发送到指标聚合系统。

垃圾回收瓶颈

由于 Clojure 运行在 JVM 上,因此必须了解应用程序中的 GC 行为。您可以通过在project.clj或 Java 命令行中指定相应的 JVM 选项来在运行时打印 GC 详细信息:

:jvm-options ^:replace [..other options..
 "-verbose:gc" "-XX:+PrintGCDetails"
 "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
                        ..other options..]

这将在应用程序运行时打印 GC 事件的详细摘要。为了将输出捕获到文件中,您可以指定以下参数:

:jvm-options ^:replace [..other options..
                        "-verbose:gc" "-XX:+PrintGCDetails"
                        "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
 "-Xloggc:./memory.log"
                        ..other options..]

看到完全 GC 事件之间和期间的时间也很有用:

:jvm-options ^:replace [..other options..
                        "-verbose:gc" "-XX:+PrintGCDetails"
                        "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
 "-XX:+PrintGCApplicationStoppedTime"
 "-XX:+PrintGCApplicationConcurrentTime"
                        ..other options..]

解决 GC 问题的其他有用选项如下:

  • -XX:+HeapDumpOnOutOfMemoryError

  • -XX:+PrintTenuringDistribution

  • -XX:+PrintHeapAtGC

之前选项的输出可以帮助您识别可以尝试通过选择合适的垃圾回收器、其他代际堆选项和代码更改来修复的 GC 瓶颈。为了方便查看 GC 日志,您可能喜欢使用 GUI 工具,如GCViewer (github.com/chewiebug/GCViewer)来实现此目的。

等待在 GC 安全点的线程

当代码中存在一个长时间紧循环(没有任何 I/O 操作)时,如果在循环结束时或内存不足时发生 GC,执行该循环的线程无法达到安全点(例如,无法分配)。这可能会在 GC 期间对其他关键线程产生灾难性的影响。您可以通过启用以下 JVM 选项来识别此类瓶颈:

:jvm-options ^:replace [..other options..
                        "-verbose:gc" "-XX:+PrintGCDetails"
                        "-XX:+PrintGC" "-XX:+PrintGCTimeStamps"
 "-XX:+PrintSafepointStatistics"
                        ..other options..]

之前选项发出的安全点日志可以帮助您识别在 GC 期间紧循环线程对其他线程的影响。

使用 jstat 探测 GC 细节

Oracle JDK(也包括 OpenJDK、Azul 的 Zulu)附带一个名为jstat的实用工具,可以方便地检查 GC 细节。您可以在docs.oracle.com/javase/8/docs/technotes/tools/unix/jstat.html上找到有关此实用工具的详细信息——以下示例显示了如何使用它:

jstat -gc -t <process-id> 10000
jstat -gccause -t <process-id> 10000

之前提到的第一个命令监控了各种堆代中的对象分配和释放,以及其他 GC 统计信息,每 10 秒输出一次。第二个命令也会打印 GC 的原因,以及其他详细信息。

检查 Clojure 源生成的字节码

我们在第三章中讨论了依赖 Java,介绍了如何查看任何 Clojure 代码生成的等效 Java 代码。有时,生成的字节码与 Java 之间可能没有直接关联,这时检查生成的字节码就非常有用。当然,这要求读者至少对 JVM 指令集(docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html)有一定的了解。这个工具可以让你非常有效地分析生成的字节码指令的成本。

项目no.disassemble(github.com/gtrak/no.disassemble)是一个非常有用的工具,可以用来发现生成的字节码。将其包含在project.clj文件中作为 Leiningen 插件:

:plugins [[lein-nodisassemble "0.1.3"]]

然后,在 REPL 中,你可以逐个检查生成的字节码:

(require '[no.disassemble :as n])
(println (n/disassemble (map inc (range 10))))

之前的代码片段输出了在那里输入的 Clojure 表达式的字节码。

吞吐量瓶颈

吞吐量瓶颈通常源于共享资源,这些资源可能是 CPU、缓存、内存、互斥锁、GC、磁盘和其他 I/O 设备。每种资源都有不同的方法来查找利用率、饱和度和负载水平。这也很大程度上取决于所使用的操作系统,因为它管理这些资源。深入探讨特定于操作系统的确定这些因素的方法超出了本文的范围。然而,我们将在下一节中查看一些这些资源的分析,以确定瓶颈。

吞吐量的净效应表现为与延迟的倒数关系。根据 Little 定律,这是自然的——我们将在下一章中看到。在第六章测量性能中,我们讨论了并发下的吞吐量测试和延迟测试。这应该是一个大致的吞吐量趋势的良好指标。

使用 VisualVM 分析代码

Oracle JDK(也称为 OpenJDK)附带了一个强大的分析器,称为VisualVM;与 JDK 一起分发的版本被称为 Java VisualVM,可以通过二进制可执行文件来调用:

jvisualvm

这将启动 GUI 分析器应用程序,你可以连接到正在运行的 JVM 实例。分析器具有强大的功能(visualvm.java.net/features.html),这些功能对于查找代码中的各种瓶颈非常有用。除了分析堆转储和线程转储外,VisualVM 还可以实时交互式地绘制 CPU 和堆消耗,以及线程状态。它还具有 CPU 和内存的采样和跟踪分析器。

监控标签页

监控标签页显示了运行时的图形概览,包括 CPU、堆、线程和加载的类:

监控标签页

此标签页用于提供“一目了然”的信息,将更深入的挖掘留给其他标签页。

线程标签页

在下面的屏幕截图中,线程标签页显示了所有线程的状态:

线程选项卡

查找是否有任何线程正在竞争、进入死锁、利用率低或占用更多 CPU 是非常有用的。特别是在具有内存状态的并发应用程序中,以及在由线程共享的有限 I/O 资源(如连接池或对其他主机的网络调用)的应用程序中,如果您设置了线程名,此功能提供了极大的洞察力。

注意名为citius-RollingStore-store-1citius-RollingStore-store-4的线程。在理想的无竞争场景中,这些线程将具有绿色的运行状态。请参阅图像右下角的图例,它解释了线程状态:

  • 运行:一个线程正在运行,这是理想的状态。

  • 睡眠:一个线程暂时放弃了控制权。

  • 等待:一个线程正在临界区等待通知。调用了Object.wait(),现在正在等待Object.notify()Object.notifyAll()将其唤醒。

  • 挂起:一个线程挂载在许可(二进制信号量)上,等待某些条件。通常在java.util.concurrent API 中的并发阻塞调用中看到。

  • 监视器:一个线程已达到对象监视器,正在等待某些锁,可能是等待进入或退出临界区。

您可以为感兴趣的线程安装“线程检查器”插件以获取详细信息。要从命令行检查线程转储,您可以使用jstackkill -3命令。

在“采样器”选项卡

“采样器”选项卡是轻量级的采样分析器选项卡,可以采样 CPU 和内存消耗。您可以轻松地找到代码中的热点,这些热点可能受益于调整。然而,采样分析受采样周期和频率的限制,无法检测内联代码等。它是瓶颈的良好一般指标,看起来与我们看到的第六章中的截图相似,测量性能。您一次可以分析 CPU 或内存。

“CPU”选项卡显示整体 CPU 时间分布和每个线程的 CPU 消耗。在采样进行时,您可以获取线程转储并分析转储。有几个 VisualVM 插件可用于进一步分析。

“内存”选项卡显示堆直方图指标,包括对象的分布和实例计数。它还显示 PermGen 直方图和每个线程的分配数据。在您的项目中设置线程名是一个非常好的主意,并且强烈推荐这样做,这样就可以轻松地在这些工具中定位这些名称。在此选项卡中,您可以强制进行 GC、为分析创建堆转储,并以多种方式查看内存指标数据。

设置线程名

在 Clojure 中设置线程名使用 Java 互操作非常简单:

(.setName ^Thread (Thread/currentThread) "service-thread-12")

然而,由于线程通常跨越多个上下文,在大多数情况下,您应该像以下这样在有限的范围内进行:

(defmacro with-thread-name
  "Set current thread name; execute body of code in that context."
  [new-name & body]
  `(let [^Thread thread# (Thread/currentThread)
         ^String t-name# thread#]
     (.setName thread# ~new-name)
     (try
       ~@body
       (finally
         (.setName thread# t-name#)))

现在您可以使用此宏来执行任何具有指定线程名的代码体:

(with-thread-name (str "process-order-" order-id)
  ;; business code
  )

这种设置线程名称的风格确保在离开线程局部作用域之前恢复原始名称。如果你的代码有多个部分,并且你为每个部分设置不同的线程名称,你可以在分析监控工具上出现任何竞争时通过查看名称来检测哪些代码部分导致了竞争。

分析器标签页

分析器标签页允许你在 JVM 中对运行中的代码进行检测,并分析 CPU 和内存消耗。这个选项比 采样器 标签页增加了更大的开销,并且在 JIT 编译、内联和准确性方面提出了不同的权衡。与 采样器 标签页相比,这个标签页在可视化方面的多样性较少。这个标签页与 采样器 标签页的主要区别在于它会改变运行中代码的字节码以进行准确测量。当你选择 CPU 分析时,它开始对代码进行 CPU 分析的检测。如果你从 CPU 切换到内存分析,它会重新对运行中的代码进行内存分析的检测,并且每次你想要进行不同的分析时都会重新检测。这种检测的一个缺点是,如果你的代码部署在应用程序容器中,如 Tomcat,它可能会大幅减慢一切。

虽然你可以从 采样器 获取大多数常见的 CPU 瓶颈信息,但你可能需要 分析器 来调查 采样器 和其他分析技术已经发现的热点。你可以使用检测分析器有选择性地分析并深入已知瓶颈,从而将其不良影响限制在代码的小部分。

Visual GC 标签页

Visual GC 是一个 VisualVM 插件,可以近乎实时地以图形方式展示垃圾回收(GC)的状态。

Visual GC 标签页

如果你的应用程序使用大量内存并且可能存在 GC 瓶颈,这个插件可能对各种故障排除目的非常有用。

替代分析器

除了 VisualVM 之外,还有几个针对 Java 平台的第三方分析器和性能监控工具。在开源工具中,Prometheus (prometheus.io/) 和 Moskito (www.moskito.org/) 相对流行。开源性能工具的不完全列表在这里:java-source.net/open-source/profilers

有几个商业专有分析器你可能想了解一下。YourKit (www.yourkit.com/) Java 分析器可能是许多人发现对分析 Clojure 代码非常成功的最著名分析器。还有其他针对 JVM 的分析工具,如 JProfiler (www.ej-technologies.com/products/jprofiler/overview.html),这是一个基于桌面的分析器,以及基于网络的托管解决方案,如 New Relic (newrelic.com/) 和 AppDynamics (www.appdynamics.com/)。

性能调整

一旦通过测试和性能分析结果对代码有了深入了解,我们就需要分析值得考虑优化的瓶颈。更好的方法是找到表现最差的片段并对其进行优化,从而消除最薄弱的环节。我们在前面的章节中讨论了硬件和 JVM/Clojure 的性能方面。优化和调整需要根据这些方面重新思考设计和代码,然后为了性能目标进行重构。

一旦确定了性能瓶颈,我们必须找出根本原因,并逐步尝试改进,以查看哪些有效。性能调整是一个基于测量、监控和实验的迭代过程。

调整 Clojure 代码

识别性能瓶颈的本质对于在代码的正确方面进行实验非常有帮助。关键是确定成本的来源以及成本是否合理。

CPU/缓存绑定

正如我们在本章开头所指出的,设置具有正确 JVM 选项和项目设置的项目使我们了解反射和装箱,这是在设计和算法选择不佳后 CPU 性能问题的常见来源。一般来说,我们必须看看我们是否在进行不必要的或次优的操作,尤其是在循环内部。例如,transducers 在 CPU 密集型操作中比懒序列更适合更好的性能。

虽然建议公共函数使用不可变数据结构,但在性能必要时,实现细节可以使用 transients 和 arrays。在适当的情况下,记录是 map 的绝佳替代品,因为前者有类型提示和紧凑的字段布局。对原始数据类型的操作比它们的包装类型更快(因此推荐)。

在紧密循环中,除了 transients 和 arrays,你可能更喜欢使用带有未检查数学的 loop-recur 以提高性能。你也可能喜欢避免在紧密循环中使用多方法和动态变量,而不是传递参数。使用 Java 和宏可能是最后的手段,但在需要性能的情况下仍然是一个选项。

内存绑定

在代码中分配更少的内存总是能减少与内存相关的性能问题。优化内存密集型代码不仅关乎减少内存消耗,还关乎内存布局以及如何有效地利用 CPU 和缓存。我们必须检查我们是否使用了适合 CPU 寄存器和缓存行的数据类型。对于缓存和内存密集型代码,我们必须了解是否存在缓存未命中以及原因——通常数据可能太大,无法适应缓存行。对于内存密集型代码,我们必须关注数据局部性,代码是否过于频繁地访问互连,以及数据在内存中的表示是否可以简化。

多线程

具有副作用共享资源是多线程代码中竞争和性能瓶颈的主要来源。正如我们在本章的“VisualVM 代码分析”部分所看到的,更好地分析线程可以让我们更清楚地了解瓶颈。提高多线程代码性能的最好方法是减少竞争。减少竞争的一个简单方法是增加资源并减少并发性,尽管只有最优的资源水平和并发性对性能才是有益的。在设计并发时,仅追加、单写者和无共享数据方法都表现得很好。

另一种减少竞争的方法可能是利用线程局部队列直到资源可用。这种技术与 Clojure 代理所使用的技术类似,尽管它是一个复杂的技术。第五章 并发 对代理进行了详细说明。我鼓励您研究代理源代码以更好地理解。当使用 CPU 密集型资源(例如java.util.concurrent.atomic.AtomicLong)时,您可以使用一些 Java 8 类(如java.util.concurrent.atomic.LongAdder,它也在处理器之间平衡内存消耗和竞争分割)使用的竞争分割技术。这种技术也很复杂,通用的竞争分割解决方案可能需要牺牲读一致性以允许快速更新。

I/O 密集型

I/O 密集型任务可能受到带宽或 IOPS/延迟的限制。任何 I/O 瓶颈通常表现为频繁的 I/O 调用或未受约束的数据序列化。将 I/O 限制在仅所需的最小数据上是一种常见的最小化序列化和减少延迟的机会。I/O 操作通常可以批量处理以提高吞吐量,例如SpyMemcached库使用异步批量操作以实现高吞吐量。

I/O 瓶颈通常与多线程场景相关联。当 I/O 调用是同步的(例如,JDBC API),自然需要依赖多个线程在有限资源池上工作。异步 I/O 可以缓解线程的阻塞,让线程在 I/O 响应到达之前做其他有用的工作。在同步 I/O 中,我们付出了线程(每个线程分配了内存)在 I/O 调用上阻塞的成本,而内核则安排这些调用。

JVM 调整

常常 Clojure 应用程序可能会从 Clojure/Java 库或框架中继承膨胀,这会导致性能不佳。追踪不必要的抽象和不必要的代码层可能会带来可观的性能提升。在将依赖库/框架包含到项目中之前,考虑这些库/框架的性能是一个好的方法。

JIT 编译器、垃圾回收器和安全点(在 Oracle HotSpot JVM 中)对应用程序的性能有重大影响。我们在第四章主机性能中讨论了 JIT 编译器和垃圾回收器。当 HotSpot JVM 达到无法再执行并发增量 GC 的点时,它需要安全地挂起 JVM 以执行完全 GC。这也被称为停止世界的 GC 暂停,可能持续几分钟,而 JVM 看起来是冻结的。

Oracle 和 OpenJDK JVM 在调用时接受许多命令行选项,以调整和监控 JVM 中组件的行为。对于想要从 JVM 中提取最佳性能的人来说,调整 GC 是很常见的。

您可能想尝试以下 JVM 选项(Oracle JVM 或 OpenJDK)以提升性能:

JVM 选项 描述
-XX:+AggressiveOpts 启用压缩堆指针的激进选项
-server 服务器类 JIT 阈值(用于 GUI 应用程序请使用-client)
-XX:+UseParNewGC 使用并行 GC
-Xms3g 指定最小堆大小(在桌面应用程序上保持较小)
-Xmx3g 指定最大堆大小(在服务器上保持最小/最大相同)
-XX:+UseLargePages 减少转换查找缓冲区丢失(如果操作系统支持),有关详细信息请参阅www.oracle.com/technetwork/java/javase/tech/largememory-jsp-137182.html

在 Java 6 HotSpot JVM 上,并发标记清除CMS)垃圾回收器因其 GC 性能而备受好评。在 Java 7 和 Java 8 HotSpot JVM 上,默认的 GC 是一个并行收集器(以提高吞吐量),而撰写本文时,有一个提议在即将到来的 Java 9 中默认使用 G1 收集器(以降低暂停时间)。请注意,JVM GC 可以根据不同的目标进行调整,因此同一应用程序的配置可能对另一个应用程序不起作用。请参阅 Oracle 发布的以下链接中的文档,了解如何调整 JVM:

背压

在负载下看到应用程序表现不佳并不罕见。通常,应用程序服务器简单地看起来无响应,这通常是高资源利用率、GC 压力、更多线程导致更繁忙的线程调度和缓存未命中等多种因素的综合结果。如果已知系统的容量,解决方案是在达到容量后拒绝服务以应用背压。请注意,只有在系统经过负载测试以确定最佳容量后,才能最优地应用背压。触发背压的容量阈值可能与单个服务直接相关,也可能不直接相关,而是可以定义为负载标准。

摘要

值得重申的是,性能优化始于了解底层系统的工作原理,并在代表性的硬件和负载下测量我们构建的系统的性能。性能优化的主要组成部分是使用各种类型的测量和剖析来识别瓶颈。之后,我们可以应用实验来调整代码的性能,并再次进行测量/剖析以验证。调整机制取决于瓶颈的类型。

在下一章中,我们将看到如何在构建应用程序时解决性能问题。我们的重点将是影响性能的几个常见模式。

第八章:应用性能

最早的计算设备是为了执行自动计算而建造的,随着计算机性能的提升,它们越来越受欢迎,因为它们能够进行大量且快速的运算。即使今天,这种本质仍然体现在我们期待通过在计算机上运行的应用程序来使计算机比以前更快地执行我们的商业计算。

与我们在前几章中看到的较小组件级别的性能分析和优化相比,它需要一种整体的方法来提高应用层的性能。更高层次的关注,如每天服务一定数量的用户,或者通过多层系统处理已识别的负载量,需要我们思考组件如何配合以及负载是如何设计通过它们的。在本章中,我们将讨论这些高层次的关注。与上一章类似,总体而言,本章适用于任何 JVM 语言编写的应用程序,但重点在于 Clojure。在本章中,我们将讨论适用于代码所有层的通用性能技术:

  • 选择库

  • 记录日志

  • 数据大小

  • 资源池化

  • 提前获取和计算

  • 阶段化和批量处理

  • 利特尔定律

选择库

大多数非平凡应用程序在很大程度上依赖于第三方库来实现各种功能,例如日志记录、处理网络请求、连接到数据库、写入消息队列等。许多这些库不仅执行关键业务功能的一部分,而且出现在性能敏感的代码区域,影响整体性能。在充分进行性能分析后,我们明智地选择库(在功能与性能权衡方面)是至关重要的。

选择库的关键因素不是确定使用哪个库,而是拥有我们应用程序的性能模型,并在代表性负载下对用例进行基准测试。只有基准测试才能告诉我们性能是否存在问题或可接受。如果性能低于预期,深入分析可以显示第三方库是否导致了性能问题。在第六章 性能测量 和 第七章 性能优化 中,我们讨论了如何测量性能和识别瓶颈。您可以为性能敏感的用例评估多个库,并选择适合的库。

库通常在新版本中提高(或偶尔降低)性能,因此测量和配置文件(比较,跨版本)应该是我们应用程序的开发和维护生命周期中的持续实践。另一个需要注意的因素是,库可能根据用例、负载和基准表现出不同的性能特征。魔鬼在于基准细节。确保您的基准测试尽可能接近您应用程序的代表性场景。

通过基准测试进行选择

让我们简要看看一些通用用例,在这些用例中,第三方库的性能通过基准测试暴露出来。

网络服务器

由于网络服务器具有通用性和广泛的应用范围,它们通常会受到大量的性能基准测试。Clojure 网络服务器的一个基准测试示例如下:

github.com/ptaoussanis/clojure-web-server-benchmarks

网络服务器是复杂的软件组件,它们可能在各种条件下表现出不同的特性。如您所注意到的,性能数字根据长连接与短连接模式以及请求量而变化——在撰写本文时,Immutant-2 在长连接模式下表现较好,但在短连接基准测试中表现不佳。在生产环境中,人们通常使用反向代理服务器(例如 Nginx 或 HAProxy)作为应用程序服务器的前端,这些代理服务器与应用程序服务器建立长连接。

网络路由库

Clojure 有多个网络路由库,如下所示:

github.com/juxt/bidi#comparison-with-other-routing-libraries

同样的文档还显示了一个以 Compojure 为基准的性能基准测试,在撰写本文时,Compojure 的表现优于 Bidi。然而,另一个基准测试比较了 Compojure、Clout(Compojure 内部使用的库)和 CalfPath 路由:

github.com/kumarshantanu/calfpath#development

在这个基准测试中,截至撰写本文时,Clout 的表现优于 Compojure,而 CalfPath 的表现优于 Clout。然而,你应该注意快速库中的任何注意事项。

数据序列化

在 Clojure 中有几种序列化数据的方法,例如 EDN 和 Fressian。Nippy 是另一个序列化库,它包含基准测试来展示它在 EDN 和 Fressian 上的性能:

github.com/ptaoussanis/nippy#performance

我们在 第二章 Clojure 抽象 中介绍了 Nippy,以展示它如何使用瞬态来加速其内部计算。即使在 Nippy 中,也有几种序列化方式,它们具有不同的特性/性能权衡。

JSON 序列化

解析和生成 JSON 是 RESTful 服务和 Web 应用程序中非常常见的用例。Clojure contrib 库 clojure/data.json (github.com/clojure/data.json) 提供了这项功能。然而,许多人发现 Cheshire 库 github.com/dakrone/cheshire 的性能比前者要好得多。Cheshire 包含的基准测试可以通过以下命令运行:

lein with-profile dev,benchmark test

Cheshire 内部使用 Jackson Java 库 github.com/FasterXML/jackson,该库以其良好的性能而闻名。

JDBC

JDBC 访问是使用关系型数据库的应用程序中另一个非常常见的用例。Clojure contrib 库 clojure/java.jdbc github.com/clojure/java.jdbc 提供了 Clojure JDBC API。Asphalt github.com/kumarshantanu/asphalt 是一个替代的 JDBC 库,其中比较基准测试可以按照以下方式运行:

lein with-profile dev,c17,perf test

截至撰写本文时,Asphalt 的性能优于 clojure/java.jdbc 几微秒,这在低延迟应用中可能很有用。然而,请注意,JDBC 性能通常受 SQL 查询/连接、数据库延迟、连接池参数等因素的影响。我们将在后面的章节中讨论更多关于 JDBC 的内容。

记录

记录日志是一种普遍的活动,几乎所有非平凡的应用程序都会进行。日志调用非常频繁,因此确保我们的日志配置针对性能进行了优化非常重要。如果您对日志系统(尤其是在 JVM 上)不熟悉,您可能需要花些时间先熟悉这些内容。我们将介绍如何使用 clojure/tools.loggingSLF4JLogBack 库(作为一个组合)进行日志记录,并探讨如何使它们表现良好:

为什么选择 SLF4J/LogBack?

除了 SLF4J/LogBack 之外,Clojure 应用程序中还有几个日志库可供选择,例如 Timbre、Log4j 和 java.util.logging。虽然这些库没有问题,但我们通常被迫选择一个可以覆盖我们应用程序中大多数其他第三方库(包括 Java 库)的库用于日志记录。SLF4J 是一个 Java 日志门面,它可以检测任何可用的实现(LogBack、Log4j 等)——我们选择 LogBack 只是因为它性能良好且高度可配置。库 clojure/tools.logging 提供了一个 Clojure 日志 API,它按顺序在类路径中检测 SLF4J、Log4j 或 java.util.logging,并使用找到的第一个实现。

设置

让我们通过使用 LogBack、SLF4J 和 clojure/tools.logging 为使用 Leiningen 构建的项目设置日志系统来逐步说明。

依赖项

您的 project.clj 文件应该在 :dependencies 键下包含 LogBack、SLF4J 和 clojure/tools.logging 依赖项:

[ch.qos.logback/logback-classic "1.1.2"]
[ch.qos.logback/logback-core    "1.1.2"]
[org.slf4j/slf4j-api            "1.7.9"]
[org.codehaus.janino/janino     "2.6.1"]  ; for Logback-config
[org.clojure/tools.logging      "0.3.1"]

之前提到的版本是当前的,并且在写作时是有效的。如果您有更新的版本,您可能想使用它们。

LogBack 配置文件

您需要在 resources 目录中创建一个 logback.xml 文件:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

  <appender name="FILE"
            class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${logfile.general.name:-logs/application.log}</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
      <!-- daily rollover -->
      <fileNamePattern>${logfile.general.name:-logs/application.log}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
      <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
        <!-- or whenever the file size reaches 100MB -->
        <maxFileSize>100MB</maxFileSize>
      </timeBasedFileNamingAndTriggeringPolicy>
      <!-- keep 30 days worth of history -->
      <maxHistory>30</maxHistory>
    </rollingPolicy>
    <append>true</append>
    <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
      <layout class="ch.qos.logback.classic.PatternLayout">
        <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
      </layout>
 <immediateFlush>false</immediateFlush>
    </encoder>
  </appender>

 <appender name="AsyncFile" class="ch.qos.logback.classic.AsyncAppender">
 <queueSize>500</queueSize>
 <discardingThreshold>0</discardingThreshold>
 <appender-ref ref="FILE" />
 </appender>

  <!-- You may want to set the level to DEBUG in development -->
  <root level="ERROR">
 <appender-ref ref="AsyncFile" />
  </root>

  <!-- Replace com.example with base namespace of your app -->
  <logger name="com.example" additivity="false">
    <!-- You may want to set the level to DEBUG in development -->
    <level value="INFO"/>
 <appender-ref ref="AsyncFile" />
  </logger>

</configuration>

之前的 logback.xml 文件故意很简单(用于说明),只包含足够的配置来让您开始使用 LogBack 进行日志记录。

优化

优化点在我们在本节前面看到的 logback.xml 文件中被突出显示。我们将 immediateFlush 属性设置为 false,这样消息在刷新到追加器之前会被缓冲。我们还用异步追加器包装了常规文件追加器,并编辑了 queueSizediscardingThreshold 属性,这比默认设置带来了更好的结果。

除非进行优化,否则日志配置通常是许多应用程序性能不佳的常见原因。通常,性能问题只有在高负载且日志量非常大时才会显现。之前讨论的优化只是众多可能的优化中的一部分。在 LogBack 文档中,例如编码器(logback.qos.ch/manual/encoders.html)、追加器(logback.qos.ch/manual/appenders.html)和配置(logback.qos.ch/manual/configuration.html)部分提供了有用的信息。互联网上也有关于如何通过 7 个 Logback 调整来即时提高 Java 日志性能的技巧blog.takipi.com/how-to-instantly-improve-your-java-logging-with-7-logback-tweaks/,可能提供有用的指导。

数据大小

在数据大小方面,抽象的成本起着重要作用。例如,一个数据元素是否可以适应处理器缓存行直接取决于其大小。在 Linux 系统中,我们可以通过检查/sys/devices/system/cpu/cpu0/cache/目录下的文件值来找出缓存行大小和其他参数。参考第四章 Chapter 4,主机性能,其中我们讨论了如何计算原语、对象和数据元素的大小。

我们在数据大小方面通常遇到的一个问题是,在任何时候我们在堆中保留多少数据。正如我们在前面的章节中提到的,垃圾收集(GC)对应用程序性能有直接影响。在处理数据时,我们通常并不真的需要我们持有的所有数据。以生成一定时期(如几个月)内售出商品的总结报告为例。在计算了子时期(按月)的总结数据后,我们不再需要项目详情,因此在我们添加总结的同时删除不需要的数据会更好。请看以下示例:

(defn summarize [daily-data]  ; daily-data is a map
  (let [s (items-summary (:items daily-data))]
    (-> daily-data
      (select-keys [:digest :invoices])  ; keep required k/v pairs
      (assoc :summary s))))

;; now inside report generation code
(-> (fetch-items period-from period-to :interval-day)
  (map summarize)
  generate-report)

如果我们在之前的summarize函数中没有使用select-keys,它将返回一个包含额外:summary数据和地图中所有其他现有键的映射。现在,这种事情通常与懒序列结合使用,因此对于这个方案能够工作,重要的是不要保留懒序列的头部。回想一下,在第二章 Chapter 2,Clojure 抽象中,我们讨论了保留懒序列头部所带来的风险。

减少序列化

在前面的章节中,我们讨论了通过 I/O 通道进行序列化是延迟的常见来源。过度序列化的危险不容忽视。无论我们是通过 I/O 通道从数据源读取还是写入数据,所有这些数据都需要准备、编码、序列化、反序列化和解析,然后才能进行处理。涉及的数据越少,对每一步来说越好,以便降低开销。在没有 I/O 操作的情况下(例如进程内通信),通常没有必要进行序列化。

当与 SQL 数据库一起工作时,过度序列化的一个常见例子是。通常,有一些常见的 SQL 查询函数可以检索表或关系的所有列——它们被各种实现业务逻辑的函数调用。检索我们不需要的数据是浪费的,并且与上一段中讨论的原因一样,对性能有害。虽然为每个用例编写一个 SQL 语句和一个数据库查询函数可能看起来工作量更大,但这样做会带来更好的性能。使用 NoSQL 数据库的代码也容易受到这种反模式的影响——我们必须小心只获取我们需要的,即使这可能导致额外的代码。

在减少序列化时,有一个需要注意的陷阱。通常,在没有序列化数据的情况下,需要推断一些信息。在这种情况下,如果我们删除了一些序列化以便推断其他信息,我们必须比较推断成本与序列化开销。这种比较可能不一定只针对每个操作,而可能是整体上的,这样我们就可以考虑我们可以分配的资源,以便为我们的系统各个部分实现能力。

分块以减少内存压力

当我们不管文件大小如何就吞噬一个文本文件时,会发生什么?整个文件的内容将驻留在 JVM 堆中。如果文件大于 JVM 堆容量,JVM 将终止,抛出OutOfMemoryError。如果文件很大,但不足以迫使 JVM 进入 OOM 错误,它将为其他操作在应用程序中继续执行留下相对较少的 JVM 堆空间。当我们执行任何不考虑 JVM 堆容量的操作时,也会发生类似的情况。幸运的是,可以通过分块读取数据并在读取更多之前进行处理来解决这个问题。在第三章《依赖 Java》中,我们简要讨论了内存映射缓冲区,这是另一种你可能想探索的补充解决方案。

文件和网络操作的大小调整

让我们以一个数据摄取过程为例,其中半自动作业通过文件传输协议(FTP)将大型逗号分隔文件(CSV)上传到文件服务器,另一个自动作业(用 Clojure 编写)定期运行以通过网络文件系统(NFS)检测文件的到达。检测到新文件后,Clojure 程序处理文件,更新数据库中的结果,并归档文件。程序并发检测和处理多个文件。CSV 文件的大小事先未知,但格式是预定义的。

根据之前的描述,一个潜在的问题是,由于可能存在多个并发处理的文件,我们如何分配 JVM 堆给并发文件处理作业?另一个问题是操作系统对一次可以打开的文件数量有限制;在类 Unix 系统中,你可以使用ulimit命令来扩展限制。我们不能随意地读取 CSV 文件的内容——我们必须限制每个作业的内存量,并限制可以并发运行的作业数量。同时,我们也不能一次只读取文件中的一小部分行,因为这可能会影响性能:

(def ^:const K 1024)

;; create the buffered reader using custom 128K buffer-size
(-> filename
  java.io.FileInputStream.
  java.io.InputStreamReader.
  (java.io.BufferedReader. (* K 128)))

幸运的是,我们在从文件(或甚至从网络流)读取时可以指定缓冲区大小,以便根据需要调整内存使用和性能。在之前的代码示例中,我们明确设置了读取器的缓冲区大小以方便这样做。

JDBC 查询结果的大小

Java 的 SQL 数据库接口标准 JDBC(技术上不是一个缩写),支持通过 JDBC 驱动程序获取查询结果的获取大小。默认获取大小取决于 JDBC 驱动程序。大多数 JDBC 驱动程序保持一个较低的默认值,以避免高内存使用和内部性能优化原因。一个值得注意的例外是 MySQL JDBC 驱动程序,它默认完全获取并存储所有行在内存中:

(require '[clojure.java.jdbc :as jdbc])

;; using prepare-statement directly
(with-open
  [stmt (jdbc/prepare-statement
          conn sql :fetch-size 1000 :max-rows 9000)
   rset (resultset-seq (.executeQuery stmt))]
  (vec rset))

;; using query
(jdbc/query db [{:fetch-size 1000}
           "SELECT empno FROM emp WHERE country=?" 1])

当使用 Clojure contrib 库java.jdbcgithub.com/clojure/java.jdbc 0.3.7 版本)时,可以在准备语句时设置获取大小,如前例所示。请注意,获取大小并不能保证成比例的延迟;然而,它可以安全地用于内存大小调整。我们必须测试由于获取大小在不同负载和使用情况下对特定数据库和 JDBC 驱动程序的性能影响。另一个需要注意的重要因素是,:fetch-size的好处只有在查询结果集是增量且惰性消费的情况下才有用——如果函数从结果集中提取所有行以创建一个向量,那么从内存节省的角度来看,:fetch-size的好处就消失了。除了获取大小之外,我们还可以传递:max-rows参数来限制查询返回的最大行数——然而,这意味着额外的行将从结果中截断,而不是数据库是否内部限制行数以实现这一点。

资源池

在 JVM 上,有一些资源类型初始化成本较高。例如,HTTP 连接、执行线程、JDBC 连接等。Java API 识别这些资源,并内置了对创建某些资源池的支持,这样当消费者代码需要时可以从池中借用资源,并在工作结束时简单地将它返回到池中。Java 的线程池(在第五章“并发”中讨论)和 JDBC 数据源是突出的例子。其理念是保留已初始化的对象以供重用。尽管 Java 不支持直接对资源类型进行池化,但总可以在自定义昂贵资源周围创建一个池抽象。请注意,池化技术在 I/O 活动中很常见,但也可以同样适用于初始化成本高的非 I/O 目的。

JDBC 资源池

Java 支持通过javax.sql.DataSource接口获取 JDBC 连接,该接口可以池化。JDBC 连接池实现了此接口。通常,JDBC 连接池由第三方库或 JDBC 驱动程序本身实现。一般来说,很少有 JDBC 驱动程序实现连接池,因此开源的第三方 JDBC 资源池库,如 Apache DBCP、c3p0、BoneCP、HikariCP 等,非常受欢迎。它们还支持验证查询,用于驱逐可能由网络超时和防火墙引起的陈旧连接,并防止连接泄漏。Apache DBCP 和 HikariCP 可以通过各自的 Clojure 包装库 Clj-DBCP (github.com/kumarshantanu/clj-dbcp) 和 HikariCP (github.com/tomekw/hikari-cp) 从 Clojure 访问,并且有一些 Clojure 示例描述了如何构建 C3P0 和 BoneCP 池 (clojure-doc.org/articles/ecosystem/java_jdbc/connection_pooling.html)。

连接不是唯一需要池化的 JDBC 资源。每次我们创建一个新的 JDBC 预编译语句时,根据 JDBC 驱动程序的实现,通常整个语句模板都会发送到数据库服务器以获取预编译语句的引用。由于数据库服务器通常部署在不同的硬件上,可能存在网络延迟。因此,预编译语句的池化是 JDBC 资源池库的一个非常理想特性。Apache DBCP、C3P0 和 BoneCP 都支持语句池化,Clj-DBCP 包装器可以开箱即用地实现预编译语句的池化,以获得更好的性能。HikariCP 认为,如今,语句池化已经由 JDBC 驱动程序内部完成,因此不需要显式池化。我强烈建议您使用连接池库进行基准测试,以确定它是否真的适用于您的 JDBC 驱动程序和应用程序。

I/O 批量处理和节流

众所周知,频繁的 I/O 调用通常会导致性能不佳。一般来说,解决方案是将几条消息批量在一起,然后一次性发送。在数据库和网络调用中,批量处理是一种常见且有效的技术,可以提高吞吐量。另一方面,较大的批量大小实际上可能会损害吞吐量,因为它们往往会增加内存开销,并且组件可能无法一次性处理大量批量。因此,确定批量大小和节流与批量处理一样重要。我强烈建议您进行自己的测试,以确定在代表性负载下的最佳批量大小。

JDBC 批量操作

JDBC 长期以来在其 API 中支持批量更新操作,包括INSERTUPDATEDELETE语句。Clojure contrib 库java.jdbc通过其自己的 API 支持 JDBC 批量操作,如下所示:

(require '[clojure.java.jdbc :as jdbc])

;; multiple SQL statements
(jdbc/db-do-commands
  db true
  ["INSERT INTO emp (name, countrycode) VALUES ('John Smith', 3)"
   "UPDATE emp SET countrycode=4 WHERE empid=1379"])

;; similar statements with only different parametrs
(jdbc/db-do-prepared
  db true
  "UPDATE emp SET countrycode=? WHERE empid=?"
  [4 1642]
  [9 1186]
  [2 1437])

除了批量更新支持外,我们还可以批量执行 JDBC 查询。一种常见的技术是使用 SQL WHERE子句来避免N+1选择问题。N+1问题表示当我们对主表的一个行集中的每一行在从表中执行一个查询时的情况。可以采用类似的技术将同一表上的几个相似查询合并为一个,然后在程序中分离数据。

考虑以下使用 clojure.java.jdbc 0.3.7 和 MySQL 数据库的示例:

(require '[clojure.java.jdbc :as j])

(def db {:subprotocol "mysql"
         :subname "//127.0.0.1:3306/clojure_test"
         :user "clojure_test" :password "clojure_test"})

;; the snippet below uses N+1 selects
;; (typically characterized by SELECT in a loop)
(def rq "select order_id from orders where status=?")
(def tq "select * from items where fk_order_id=?")
(doseq [order (j/query db [rq "pending"])]
  (let [items (j/query db [tq (:order_id order)])]
    ;; do something with items
    …))

;; the snippet below avoids N+1 selects,
;; but requires fk_order_id to be indexed
(def jq "select t.* from orders r, items t
  where t.fk_order_id=r.order_id and r.status=? order by t.fk_order_id")
(let [all-items (group-by :fk_order_id (j/query db [jq "pending"]))]
  (doseq [[order-id items] all-items]
    ;; do something with items
    ...))

在前面的例子中,有两个表:ordersitems。第一个片段从orders表中读取所有订单 ID,然后通过循环查询items表中的相应条目。这是你应该注意的N+1选择性能反模式。第二个片段通过发出单个 SQL 查询来避免N+1选择,但除非列fk_order_id被索引,否则可能不会表现得很出色。

在 API 级别支持批量操作

在设计任何服务时,提供一个用于批量操作的 API 非常有用。这为 API 构建了灵活性,使得批量大小和节流可以以细粒度方式控制。不出所料,这也是构建高性能服务的一个有效方法。在实现批量操作时,我们遇到的一个常见开销是识别批量中的每个项以及它们在请求和响应之间的关联。当请求是异步的,这个问题变得更加突出。

解决项标识问题的方案是通过为请求(批量)中的每个项分配一个规范 ID 或全局 ID,或者为每个请求(批量)分配一个唯一的 ID,并为请求中的每个项分配一个在批量中是局部的 ID。

确切解决方案的选择通常取决于实现细节。当请求是同步的,你可以省去对每个请求项的标识(参考 Facebook API:developers.facebook.com/docs/reference/api/batch/,其中响应中的项遵循与请求相同的顺序)。然而,在异步请求中,可能需要通过状态检查调用或回调来跟踪项。通常,所需的跟踪粒度指导着适当的项标识策略。

例如,如果我们有一个用于订单处理的批量 API,每个订单都会有一个唯一的订单 ID,可以在后续的状态检查调用中使用。在另一个例子中,假设有一个用于创建物联网(IoT)设备 API 密钥的批量 API——在这里,API 密钥事先并不知道,但它们可以在同步响应中生成和返回。然而,如果这必须是一个异步批量 API,服务应该响应一个批量请求 ID,稍后可以使用该 ID 来查找请求的状态。在请求 ID 的批量响应中,服务器可以包括请求项目 ID(例如设备 ID,对于客户端可能是唯一的,但不是所有客户端都是唯一的)及其相应的状态。

限制对服务的请求

由于每个服务只能处理一定的容量,因此我们向服务发送请求的速率很重要。对服务行为的期望通常涉及吞吐量和延迟两个方面。这要求我们以指定的速率发送请求,因为低于该速率可能会导致服务利用率不足,而高于该速率可能会使服务过载或导致失败,从而引起客户端利用率不足。

假设第三方服务每秒可以接受 100 个请求。然而,我们可能不知道该服务的实现有多稳健。尽管有时并没有明确指定,但在每秒内一次性发送 100 个请求(例如在 20 毫秒内),可能会低于预期的吞吐量。例如,将请求均匀地分布在 1 秒的时间内,例如每 10 毫秒发送一个请求(1000 毫秒 / 100 = 10 毫秒),可能会增加达到最佳吞吐量的机会。

对于限制,令牌桶 (zh.wikipedia.org/wiki/Token_bucket) 和 漏桶 (zh.wikipedia.org/wiki/Leaky_bucket) 算法可能很有用。在非常细粒度的级别上进行限制需要我们缓冲项目,以便我们可以保持均匀的速率。缓冲会消耗内存,并且通常需要排序;队列(在第五章中介绍,并发),管道和持久存储通常很好地服务于这个目的。再次强调,由于系统限制,缓冲和排队可能会受到背压的影响。我们将在本章后面的部分讨论管道、背压和缓冲。

预计算和缓存

在处理数据时,我们通常会遇到一些情况,其中一些常见的计算步骤先于几种后续步骤。也就是说,一部分计算是通用的,而剩余的是不同的。对于高延迟的通用计算(如 I/O 访问数据以及内存/CPU 处理它),将它们一次性计算并存储为摘要形式是非常有意义的,这样后续步骤就可以简单地使用摘要数据并从该点继续进行,从而降低整体延迟。这也被称为半计算数据的分阶段处理,是优化非平凡数据处理的一种常见技术。

Clojure 对缓存有良好的支持。内置的clojure.core/memoize函数执行基本的计算结果缓存,但在使用特定的缓存策略和可插拔后端方面没有灵活性。Clojure contrib 库core.memoize通过提供几个配置选项来弥补memoize缺乏灵活性的不足。有趣的是,core.memoize中的功能也作为单独的缓存库很有用,因此公共部分被提取出来作为一个名为core.cache的 Clojure contrib 库,core.memoize是在其上实现的。

由于许多应用程序出于可用性、扩展性和维护的原因部署在多个服务器上,它们需要快速且空间高效的分布式缓存。开源的 memcached 项目是一个流行的内存分布式键值/对象存储,可以作为 Web 应用的缓存服务器。它通过散列键来识别存储值的服务器,并且没有现成的复制或持久化功能。它用于缓存数据库查询结果、计算结果等。对于 Clojure,有一个名为 SpyGlass 的 memcached 客户端库(github.com/clojurewerkz/spyglass)。当然,memcached 不仅限于 Web 应用;它也可以用于其他目的。

并发管道

想象一下这样的情况,我们必须以一定的吞吐量执行工作,每个工作包括相同序列的不同大小的 I/O 任务(任务 A),一个内存受限的任务(任务 B),以及再次,一个 I/O 任务(任务 C)。一个简单的方法是创建一个线程池并在其上运行每个工作,但很快我们会意识到这不是最佳方案,因为我们无法确定每个 I/O 资源的利用率,因为操作系统调度线程的不确定性。我们还观察到,尽管几个并发的工作有相似的 I/O 任务,但我们无法在我们的第一种方法中批量处理它们。

作为下一个迭代,我们将每个作业分成阶段(A、B、C),使得每个阶段对应一个任务。由于任务都是已知的,我们为每个阶段创建一个适当大小的线程池并执行其中的任务。任务 A 的结果需要由任务 B 使用,B 的结果需要由任务 C 使用——我们通过队列启用这种通信。现在,我们可以调整每个阶段的线程池大小,批量处理 I/O 任务,并调整它们以实现最佳吞吐量。这种安排是一种并发管道。一些读者可能会觉得这种安排与演员模型或阶段事件驱动架构SEDA)模型有微弱的相似之处,这些是针对此类方法更精细的模型。回想一下,我们在第五章并发中讨论了几种进程内队列。

分布式管道

采用这种方法,可以使用网络队列将作业执行扩展到集群中的多个主机,从而减轻内存消耗、持久性和交付到队列基础设施的负担。例如,在特定场景中,集群中可能有多个节点,它们都在运行相同的代码,并通过网络队列交换消息(请求和中间结果数据)。

下面的图示展示了简单的发票生成系统如何连接到网络队列:

分布式管道

RabbitMQ、HornetQ、ActiveMQ、Kestrel 和 Kafka 是一些知名的开放式队列系统。偶尔,工作可能需要分布式状态和协调。Avout (avout.io/) 项目实现了 Clojure 的原子和 ref 的分布式版本,可用于此目的。Tesser (github.com/aphyr/tesser) 是另一个用于本地和分布式并行的 Clojure 库。Storm (storm-project.net/) 和 Onyx (www.onyxplatform.org/) 项目是使用 Clojure 实现的分布式、实时流处理系统。

应用背压

我们在上一个章节中简要讨论了背压。没有背压,我们无法构建一个具有可预测稳定性和性能的合理负载容忍系统。在本节中,我们将看到如何在应用程序的不同场景中应用背压。在基本层面上,我们应该有一个系统最大并发作业数的阈值,并根据该阈值,拒绝超过一定到达率的新的请求。被拒绝的消息可能由客户端重试,如果没有对客户端的控制,则可能被忽略。在应用背压到面向用户的服务时,检测系统负载并首先拒绝辅助服务可能是有用的,以保存容量并在高负载面前优雅降级。

线程池队列

JVM 线程池由队列支持,这意味着当我们向已经运行最大作业的线程池提交作业时,新作业将进入队列。默认情况下,队列是无界的,这不适合应用背压。因此,我们必须创建由有界队列支持的线程池:

(import 'java.util.concurrent.LinkedBlockingDeque)
(import 'java.util.concurrent.TimeUnit)
(import 'java.util.concurrent.ThreadPoolExecutor)
(import 'java.util.concurrent.ThreadPoolExecutor$AbortPolicy)
(def tpool
  (let [q (LinkedBlockingDeque. 100)
        p (ThreadPoolExecutor$AbortPolicy.)]
    (ThreadPoolExecutor. 1 10 30 TimeUnit/SECONDS q p)))

现在,在这个线程池中,每当尝试添加的作业数量超过队列容量时,它将抛出一个异常。调用者应将异常视为缓冲区满的条件,并通过定期调用java.util.concurrent.BlockingQueue.remainingCapacity()方法等待直到缓冲区再次有空闲容量。

Servlet 容器,如 Tomcat 和 Jetty

在同步的TomcatJetty版本中,每个 HTTP 请求都会从用户可以配置的公共线程池中分配一个专用线程。正在服务的并发请求数量受线程池大小的限制。控制到达率的一种常见方法是为服务器设置线程池大小。在开发模式下,Ring库默认使用嵌入的 Jetty 服务器。在 Ring 中,可以通过编程方式配置嵌入的 Jetty 适配器的线程池大小。

在 Tomcat 和 Jetty 的异步(Async Servlet 3.0)版本中,除了线程池大小外,还可以指定处理每个请求的超时时间。然而,请注意,线程池大小在异步版本中限制请求数量的方式与同步版本不同。请求处理被转移到 ExecutorService(线程池),它可能会缓冲请求,直到有可用的线程。这种缓冲行为很棘手,因为这可能会导致系统过载——你可以通过定义自己的线程池来覆盖默认行为,而不是使用 servlet 容器的线程池,在等待请求达到一定阈值时返回 HTTP 错误。

HTTP Kit

HTTP Kit (http-kit.org/) 是一个高性能的异步(基于 Java NIO 实现)Web 服务器,用于 Clojure。它内置了对通过指定队列长度应用背压的新请求的支持。截至 HTTP Kit 2.1.19,请看以下代码片段:

(require '[org.httpkit.server :as hk])

;; handler is a typical Ring handler
(hk/run-server handler {:port 3000 :thread 32 :queue-size 600})

在前面的代码片段中,工作线程池的大小是 32,最大队列长度指定为 600。如果没有指定,默认的最大队列长度为 20480,用于应用背压。

Aleph

Aleph (aleph.io/) 是另一个基于 Java Netty (netty.io/)库的高性能异步 Web 服务器,而 Java Netty 又是基于 Java NIO 的。Aleph 通过自己的与 Netty 兼容的原语扩展了 Netty。在 Aleph 中,工作线程池的大小通过一个选项指定,如下面的代码片段所示(截至 Aleph 0.4.0):

(require '[aleph.http :as a])

;; handler is a typical Ring handler
(a/start-server handler {:executor tpool})

在这里,tpool 指的是在子节 线程池队列 中讨论的有界线程池。默认情况下,Aleph 使用一个动态线程池,最大限制为 512 个线程,通过 Dirigiste (github.com/ztellman/dirigiste) 库实现,旨在达到 90% 的系统利用率。

反压不仅涉及入队有限数量的作业,当对等方速度较慢时,还会减慢作业的处理速度。Aleph 通过“在内存耗尽之前不接受数据”来处理每个请求的反压(例如,在流式传输响应数据时)——它回退到阻塞而不是丢弃数据,或者引发异常并关闭连接。

性能和排队理论

如果我们观察多次运行中的性能基准数字,即使硬件、负载和操作系统保持不变,这些数字也很少完全相同。每次运行之间的差异可能高达 -8% 到 8%,原因不明。这可能会让人感到惊讶,但深层次的原因是计算机系统的性能本质上具有随机性。计算机系统中存在许多小因素,使得在任何给定时间点的性能难以预测。在最好的情况下,性能变化可以通过一系列随机变量的概率来解释。

基本前提是每个子系统或多或少像是一个队列,其中请求等待它们的轮次来提供服务。CPU 有一个指令队列,其取指/解码/分支预测的时序不可预测,内存访问再次取决于缓存命中率以及是否需要通过互连进行调度,而 I/O 子系统使用中断来工作,这些中断可能又依赖于 I/O 设备的机械因素。操作系统调度等待而不执行线程。构建在所有这些之上的软件基本上在各个队列中等待以完成任务。

Little's 定律

Little's 定律指出,在稳态下,以下情况成立:

Little's 定律Little's 定律

这是一个相当重要的定律,它使我们能够了解系统容量,因为它独立于其他因素。例如,如果满足请求的平均时间是 200 毫秒,而服务率约为每秒 70 次,那么正在服务的请求的平均数量是 70 请求/秒 x 0.2 秒 = 14 请求

注意,Little's 定律并没有讨论请求到达率或延迟(由于 GC 和/或其他瓶颈)的峰值,或者系统对这些因素的响应行为。当到达率在某一点出现峰值时,您的系统必须拥有足够的资源来处理服务请求所需的并发任务数量。我们可以推断出,Little's 定律有助于测量和调整平均系统行为,但仅凭这一点无法规划容量。

基于 Little's 定律的性能调整

为了保持良好的吞吐量,我们应该努力保持系统中总任务数的上限。由于系统中可能有多种任务,并且许多任务在没有瓶颈的情况下可以愉快地共存,所以更好的说法是确保系统利用率和瓶颈保持在限制之内。

通常情况下,系统的到达率可能无法完全控制。对于这种情况,唯一的选择是尽可能减少延迟,并在系统中总作业量达到一定阈值后拒绝新的请求。你可能只能通过性能和负载测试来了解正确的阈值。如果你可以控制到达率,你可以根据性能和负载测试来调节到达速率(即节流),以保持稳定的流量。

摘要

为了性能而设计应用程序应基于预期的系统负载和使用案例的模式。在优化过程中,衡量性能至关重要。幸运的是,有一些著名的优化模式可以利用,例如资源池、数据大小、预取和预计算、分阶段、批量处理等。实际上,应用程序的性能不仅取决于使用案例和模式——整个系统是一个连续的随机事件,可以通过统计方法进行评估,并由概率指导。Clojure 是一种进行高性能编程的有趣语言。这本书提供了许多关于性能的指针和实践,但没有一个咒语可以解决所有问题。魔鬼藏在细节中。了解惯用和模式,通过实验看看哪些适用于你的应用程序,并了解哪些规则你可以为了性能而弯曲。

第三部分第 3 模块

精通 Clojure

理解 Clojure 语言的哲学,深入其内部工作原理,以解锁其高级功能、方法和结构

第一章:处理序列和模式

在本章中,我们将回顾一些基本的编程技术,例如递归和序列,使用 Clojure。正如我们将看到的,Clojure 侧重于使用高阶函数来抽象计算,就像任何其他函数式编程语言一样。这种设计可以在 Clojure 标准库的大部分甚至所有部分中观察到。在本章中,我们将涵盖以下主题:

  • 探索递归

  • 了解序列和惰性

  • 检查 zippers

  • 简要研究模式匹配

定义递归函数

递归是计算机科学的核心方法之一。它使我们能够优雅地解决那些具有繁琐的非递归解决方案的问题。然而,在许多命令式编程语言中,递归函数是不被鼓励的,而是倾向于使用非递归函数。Clojure 并没有这样做,而是完全拥抱递归及其所有优缺点。在本节中,我们将探讨如何定义递归函数。

注意

以下示例可以在书籍源代码的src/m_clj/c1/recur.clj中找到。

通常,一个函数可以通过在函数体内再次调用自身来使其成为递归函数。我们可以定义一个简单的函数来返回斐波那契数列的前n个数字,如示例 1.1所示:

(defn fibo
  ([n]
   (fibo [0N 1N] n))
  ([xs n]
   (if (<= n (count xs))
     xs
     (let [x' (+ (last xs)
                 (nth xs (- (count xs) 2)))
           xs' (conj xs x')]
       (fibo xs' n)))))

示例 1.1:一个简单的递归函数

注意

斐波那契数列是一系列可以定义为以下形式的数字:

第一个元素F[0]0,第二个元素F[1]1

其余的数字是前两个数字的和,即第 n 个斐波那契数F[n] = F[n-1] + F[n-2]

在之前定义的fibo函数中,列表的最后两个元素是通过使用nthlast函数确定的,然后使用conj函数将这两个元素的和附加到列表中。这是以递归方式完成的,当由count函数确定的列表长度等于提供的值n时,函数终止。此外,使用代表BigInteger类型的值0N1N代替值01。这样做是因为使用长整型或整型值进行此类计算可能会导致算术溢出错误。我们可以在以下 REPL 中尝试这个函数:

user> (fibo 10)
[0N 1N 1N 2N 3N 5N 8N 13N 21N 34N]
user> (last (fibo 100))
218922995834555169026N

fibo函数返回一个包含前n个斐波那契数的向量,正如预期的那样。然而,对于较大的n值,此函数将导致栈溢出:

user> (last (fibo 10000))
StackOverflowError   clojure.lang.Numbers.lt (Numbers.java:219)

这种错误的原因是函数调用嵌套太多。对任何函数的调用都需要额外的调用栈。在递归中,我们会达到一个点,此时程序中所有可用的栈空间都被消耗,不能再执行更多的函数调用。通过使用现有的调用栈进行递归调用,尾调用可以克服这一限制,从而不需要分配新的调用栈。这只有在函数的返回值是函数执行的递归调用的返回值时才可能,在这种情况下,不需要额外的调用栈来存储执行递归调用的函数的状态。这种技术被称为尾调用消除。实际上,尾调用优化的函数消耗的栈空间是恒定的。

事实上,fibo函数确实执行了尾调用,因为函数体中的最后一个表达式是一个递归调用。尽管如此,它仍然为每个递归调用消耗栈空间。这是因为底层虚拟机,即 JVM,不执行尾调用消除。在 Clojure 中,必须显式地使用recur形式来执行递归调用以进行尾调用消除。我们之前定义的fibo函数可以通过使用recur形式来改进,使其成为尾递归,如示例 1.2所示:

(defn fibo-recur
  ([n]
   (fibo-recur [0N 1N] n))
  ([xs n]
   (if (<= n (count xs))
     xs
     (let [x' (+ (last xs)
                 (nth xs (- (count xs) 2)))
           xs' (conj xs x')]
       (recur xs' n)))))

实际上,fibo-recur函数可以执行无限次的嵌套递归调用。我们可以观察到,对于大的n值,该函数不会使栈爆炸,如下所示:

user> (fibo-recur 10)
[0N 1N 1N 2N 3N 5N 8N 13N 21N 34N]
user> (last (fibo-recur 10000))
207936...230626N

我们应该注意,对于大的n值,fibo-recur的调用可能需要相当长的时间才能终止。我们可以使用time宏来测量fibo-recur调用完成并返回值所需的时间,如下所示:

user> (time (last (fibo-recur 10000)))
"Elapsed time: 1320.050942 msecs"
207936...230626N

fibo-recur函数也可以使用looprecur形式来表示。这消除了使用第二个函数参数传递[0N 1N]值的需求,如示例 1.3中定义的fibo-loop函数所示:

(defn fibo-loop [n]
  (loop [xs [0N 1N]
         n n]
    (if (<= n (count xs))
      xs
      (let [x' (+ (last xs)
                  (nth xs (- (count xs) 2)))
            xs' (conj xs x')]
        (recur xs' n)))))

示例 1.3:使用循环和递归定义的递归函数

注意,loop宏需要将绑定(名称和值的对)的向量作为其第一个参数传递。loop形式的第二个参数必须是一个使用recur形式的表达式。这个嵌套的recur形式通过在loop形式中传递声明的绑定的新值来递归地调用周围的表达式。fibo-loop函数返回的值与示例 1.2fibo-recur函数返回的值相等,如下所示:

user> (fibo-loop 10)
[0N 1N 1N 2N 3N 5N 8N 13N 21N 34N]
user> (last (fibo-loop 10000))
207936...230626N

处理递归的另一种方式是使用trampoline函数。trampoline函数将其第一个参数作为函数,然后是传递给该函数的参数值。trampoline形式期望提供的函数返回另一个函数,在这种情况下,将调用返回的函数。因此,trampoline形式通过获取返回值并再次调用该返回值(如果它是一个函数)来管理递归。因此,trampoline函数避免了使用任何栈空间。每次调用提供的函数时,它都会返回并将结果存储在进程堆中。例如,考虑示例 1.4中的函数,该函数使用trampoline计算斐波那契数列的前n个数字:

(defn fibo-trampoline [n]
  (letfn [(fibo-fn [xs n]
            (if (<= n (count xs))
              xs
              (let [x' (+ (last xs)
                          (nth xs (- (count xs) 2)))
                    xs' (conj xs x')]
                #(fibo-fn xs' n))))]
    (trampoline fibo-fn [0N 1N] n)))

示例 1.4:使用 trampoline 定义的递归函数

fib-trampoline函数中,内部fibo-fn函数返回一个序列,表示为xs,或者一个不接受任何参数的闭包,表示为#(fibo-fn xs' n)。这个函数在性能上等同于我们之前定义的fibo-recur函数,如下所示:

user> (fibo-trampoline 10)
[0N 1N 1N 2N 3N 5N 8N 13N 21N 34N]
user> (time (last (fibo-trampoline 10000)))
"Elapsed time: 1346.629108 msecs"
207936...230626N

相互递归也可以通过使用trampoline函数有效地处理。在相互递归中,两个函数以递归方式相互调用。例如,考虑在示例 1.5中利用两个相互递归函数的函数:

(defn sqrt-div2-recur [n]
  (letfn [(sqrt [n]
            (if (< n 1)
              n
              (div2 (Math/sqrt n))))
          (div2 [n]
            (if (< n 1)
              n
              (sqrt (/ n 2))))]
    (sqrt n)))

示例 1.5:使用相互递归的简单函数

示例 1.5中的sqrt-div2-recur函数在内部定义了两个相互递归的函数,即sqrtdiv2,这两个函数会重复平方根和除以 2 给定的值n,直到计算出的值小于 1。sqrt-div2-recur函数使用letfn形式声明这两个函数并调用sqrt函数。我们可以将其转换为使用示例 1.6中所示的trampoline形式:

(defn sqrt-div2-trampoline [n]
  (letfn [(sqrt [n]
            (if (< n 1)
              n
              #(div2 (Math/sqrt n))))
          (div2 [n]
            (if (< n 1)
              n
              #(sqrt (/ n 2))))]
    (trampoline sqrt n)))

示例 1.6:使用 trampoline 进行相互递归的函数

在之前显示的sqrt-div2-trampoline函数中,函数sqrtdiv2返回闭包而不是直接调用函数。函数体内的trampoline形式在提供值n的情况下调用sqrt函数。sqrt-div2-recursqrt-div2-trampoline函数在给定n的值时返回值所需的时间大致相同。因此,使用trampoline形式不会产生任何额外的性能开销,如下所示:

user> (time (sqrt-div2-recur 10000000000N))
"Elapsed time: 0.327439 msecs"
0.5361105866719398
user> (time (sqrt-div2-trampoline 10000000000N))
"Elapsed time: 0.326081 msecs"
0.5361105866719398

如前述示例所示,在 Clojure 中定义递归函数有多种方式。递归函数可以通过使用recur、尾调用消除以及相互递归(通过使用trampoline函数实现)进行优化。

思考序列

序列,简称为seq,本质上是对列表的抽象。这种抽象提供了一个统一的模型或接口,用于与一系列项目进行交互。在 Clojure 中,所有原始数据结构,即字符串、列表、向量、映射和集合,都可以被视为序列。在实践中,几乎所有涉及迭代的操作都可以转换为计算序列。如果一个集合实现了序列的抽象,则该集合被称为seqable。在本节中,我们将学习有关序列的所有知识。

序列也可以是惰性的。一个惰性序列可以被视为一系列可能无限的计算值。每个值的计算被推迟到实际需要时。我们应该注意,递归函数的计算可以很容易地表示为惰性序列。例如,斐波那契序列可以通过惰性地添加先前计算序列中的最后两个元素来计算。这可以像示例 1.7中所示的那样实现。

注意

以下示例可以在书籍源代码的src/m_clj/c1/seq.clj中找到。

(defn fibo-lazy [n]
  (->> [0N 1N]
       (iterate (fn [[a b]] [b (+ a b)]))
       (map first)
       (take n)))

示例 1.7: 惰性斐波那契序列

注意

线程宏->>用于将给定表达式的结果作为下一个表达式的最后一个参数传递,以重复的方式对其主体中的所有表达式进行传递。同样,线程宏->用于将给定表达式的结果作为后续表达式的第一个参数传递。

示例 1.7中的fibo-lazy函数使用iteratemaptake函数来创建一个惰性序列。我们将在本节的后面部分更详细地研究这些函数。fibo-lazy函数接受一个参数n,该参数表示函数返回的项目数量。在fibo-lazy函数中,值0N1N被作为向量传递给iterate函数,该函数生成一个惰性序列。用于此迭代的函数从初始值ab创建一个新的值对b(+ a b)

接下来,map函数应用first函数以获取每个结果向量的第一个元素。最后,对map函数返回的序列应用take形式,以检索序列中的前n个值。即使传递相对较大的n值,fibo-lazy函数也不会引起任何错误,如下所示:

user> (fibo-lazy 10)
(0N 1N 1N 2N 3N 5N 8N 13N 21N 34N)
user> (last (fibo-lazy 10000))
207936...230626N

有趣的是,示例 1.7中的fibo-lazy函数在性能上显著优于示例 1.2示例 1.3中的递归函数,如下所示:

user> (time (last (fibo-lazy 10000)))
"Elapsed time: 18.593018 msecs"
207936...230626N

此外,将fibo-lazy函数返回的值绑定到变量实际上并不消耗任何时间。这是因为这个返回值是惰性的,尚未被评估。此外,返回值的类型是clojure.lang.LazySeq,如下所示:

user> (time (def fibo-xs (fibo-lazy 10000)))
"Elapsed time: 0.191981 msecs"
#'user/fibo-xs
user> (type fibo-xs)
clojure.lang.LazySeq

我们可以通过使用记忆化来进一步优化fibo-lazy函数,这本质上是为给定的一组输入缓存函数返回的值。这可以通过使用memoize函数来完成,如下所示:

(def fibo-mem (memoize fibo-lazy))

fibo-mem函数是fibo-lazy函数的记忆化版本。因此,对于相同的一组输入,对fibo-mem函数的后续调用将返回值更快,如下所示:

user> (time (last (fibo-mem 10000)))
"Elapsed time: 19.776527 msecs"
207936...230626N
user> (time (last (fibo-mem 10000)))
"Elapsed time: 2.82709 msecs"
207936...230626N

注意,memoize函数可以应用于任何函数,并且实际上与序列无关。传递给memoize的函数必须没有副作用,否则任何副作用都只会在使用给定一组输入调用记忆化函数时触发。

使用 seq 库

序列在 Clojure 中是一种真正无处不在的抽象。使用序列的主要动机是,任何包含类似序列数据的领域都可以通过操作序列的标准函数轻松建模。以下来自 Lisp 世界的著名引言反映了这种设计:

"与其有 10 个函数操作 10 个数据结构,不如有 100 个函数操作一个数据抽象。"

可以使用cons函数构建一个序列。我们必须向cons函数提供元素和另一个序列作为参数。first函数用于访问序列中的第一个元素,同样地,rest函数用于获取序列中的其他元素,如下所示:

user> (def xs (cons 0 '(1 2 3)))
#'user/xs
user> (first xs)
0
user> (rest xs)
(1 2 3)

注意

Clojure 中的firstrest函数分别等同于传统 Lisp 中的carcdr函数,cons函数保留了其传统名称。

在 Clojure 中,空列表由字面量()表示。空列表被视为真值,并且不等于nil。这条规则适用于任何空集合。空列表确实有类型——它是一个列表。另一方面,nil字面量表示任何类型的值的缺失,并且不是一个真值。传递给cons的第二个参数可以是空的,在这种情况下,生成的序列将包含单个元素:

user> (cons 0 ())
(0)
user> (cons 0 nil)
(0)
user> (rest (cons 0 nil))
()

一个有趣的特性是nil可以被当作一个空集合处理,但反过来则不成立。我们可以使用empty?nil?函数分别测试空集合和nil值。注意(empty? nil)返回true,如下所示:

user> (empty? ())
true
user> (empty? nil)
true
user> (nil? ())
false
user> (nil? nil)
true

注意

通过真值,我们指的是在条件表达式(如ifwhen形式)中测试为正的值。

当向 rest 函数提供一个空列表时,它将返回一个空列表。因此,rest 函数返回的值始终为真值。seq 函数可以用来从一个给定的集合中获取序列。对于空列表或集合,它将返回 nil。因此,headrestseq 函数可以用来遍历序列。next 函数也可以用来进行迭代,表达式 (seq (rest coll)) 等价于 (next coll),如下所示:

user> (= (rest ()) nil)
false
user> (= (seq ()) nil)
true
user> (= (next ()) nil)
true

sequence 函数可以用来从一个序列创建一个列表。例如,可以使用表达式 (sequence nil)nil 转换为空列表。在 Clojure 中,seq? 函数用来检查一个值是否实现了序列接口,即 clojure.lang.ISeq。只有列表实现了这个接口,而其他数据结构如向量、集合和映射必须通过使用 seq 函数转换为序列。因此,seq? 只在列表上返回 true。请注意,list?vector?map?set? 函数可以用来检查给定集合的具体类型。seq? 函数在列表和向量上的行为可以描述如下:

user> (seq? '(1 2 3))
true
user> (seq? [1 2 3])
false
user> (seq? (seq [1 2 3]))
true

只有列表和向量提供元素之间的顺序保证。换句话说,列表和向量将按照它们创建的顺序或序列存储它们的元素。这与映射和集合形成对比,映射和集合可以根据需要重新排序它们的元素。我们可以使用 sequential? 函数来检查一个集合是否提供顺序:

user> (sequential? '(1 2 3))
true
user> (sequential? [1 2 3])
true
user> (sequential? {:a 1 :b 2})
false
user> (sequential? #{:a :b})
false

associative? 函数可以用来确定一个集合或序列是否将一个键与特定的值关联起来。请注意,此函数仅在映射和向量上返回 true

user> (associative? '(1 2 3))
false
user> (associative? [1 2 3])
true
user> (associative? {:a 1 :b 2})
true
user> (associative? #{:a :b})
false

对于映射来说,associative? 函数的行为相当明显,因为映射本质上是一系列键值对。同样,向量也是关联的,这一点也得到了很好的证明,因为向量对于给定元素有一个隐含的键,即元素在向量中的索引。例如,[:a :b] 向量有两个隐含的键,分别是 01,分别对应元素 :a:b。这引出了一个有趣的后果——向量与映射可以被当作接受单个参数(即键)并返回相关值的函数,如下所示:

user> ([:a :b] 1)
:b
user> ({:a 1 :b 2} :a)
1

虽然它们本质上不是关联的,但集合也是函数。集合根据传递给它们的参数返回集合中包含的值或 nil,如下所示:

user> (#{1 2 3} 1)
1
user> (#{1 2 3} 0)
nil

现在我们已经熟悉了序列的基础知识,让我们来看看操作序列的许多函数。

创建序列

除了使用 cons 函数之外,还有几种创建序列的方法。我们在本章前面的例子中已经遇到了 conj 函数。conj 函数将其第一个参数作为集合,然后是任何要添加到集合中的参数。我们必须注意,conj 对于列表和向量有不同的行为。当提供一个列表时,conj 函数将其他参数添加到列表的头部或开始处。在向量的情况下,conj 函数将其他参数插入到向量的尾部或结束处:

user> (conj [1 2 3] 4 5 6)
[1 2 3 4 5 6]
user> (conj '(1 2 3) 4 5 6)
(6 5 4 1 2 3)

concat 函数可以用来按提供的顺序连接或 连接 任意数量的序列,如下所示:

user> (concat [1 2 3] [])
(1 2 3)
user> (concat [] [1 2 3])
(1 2 3)
user> (concat [1 2 3] [4 5 6] [7 8 9])
(1 2 3 4 5 6 7 8 9)

可以使用 reverse 函数来反转给定的序列,如下所示:

user> (reverse [1 2 3 4 5 6])
(6 5 4 3 2 1)
user> (reverse (reverse [1 2 3 4 5 6]))
(1 2 3 4 5 6)

range 函数可以用来生成给定整数范围内的值序列。range 函数最一般的形式接受三个参数——第一个参数是范围的开始,第二个参数是范围的结束,第三个参数是范围的步长。范围的步长默认为 1,范围的开始默认为 0,如下所示:

user> (range 5)
(0 1 2 3 4)
user> (range 0 10 3)
(0 3 6 9)
user> (range 15 10 -1)
(15 14 13 12 11)

我们必须注意,range 函数期望范围的开始小于范围的结束。如果范围的开始大于范围的结束,并且范围的步长是正数,range 函数将返回一个空列表。例如,(range 15 10) 将返回 ()。此外,range 函数可以不带参数调用,在这种情况下,它返回一个从 0 开始的懒加载和无限序列。

takedrop 函数可以用来在序列中取或丢弃元素。这两个函数都接受两个参数,表示从序列中取或丢弃的元素数量,以及序列本身,如下所示:

user> (take 5 (range 10))
(0 1 2 3 4)
user> (drop 5 (range 10))
(5 6 7 8 9)

要获取序列中特定位置的项,我们应该使用 nth 函数。这个函数将其第一个参数作为序列,然后是第二个参数,即从序列中检索项的位置:

user> (nth (range 10) 0)
0
user> (nth (range 10) 9)
9

要重复一个给定的值,我们可以使用 repeat 函数。这个函数接受两个参数,并按照第一个参数指示的次数重复第二个参数:

user> (repeat 10 0)
(0 0 0 0 0 0 0 0 0 0)
user> (repeat 5 :x)
(:x :x :x :x :x)

repeat 函数将评估第二个参数的表达式并重复它。要多次调用一个函数,我们可以使用 repeatedly 函数,如下所示:

user> (repeat 5 (rand-int 100))
(75 75 75 75 75)
user> (repeatedly 5 #(rand-int 100))
(88 80 17 52 32)

在这个例子中,repeat 表达式首先评估 (rand-int 100) 表达式,然后再重复它。因此,一个值将被重复多次。请注意,rand-int 函数简单地返回一个介于 0 和提供的值之间的随机整数。另一方面,repeatedly 函数调用提供的函数多次,因此每次调用 rand-int 函数时都会产生一个新的值。

可以使用 cycle 函数无限次地重复一个序列。正如你可能已经猜到的,这个函数返回一个惰性序列来表示一系列无限值。可以使用 take 函数从结果无限序列中获取有限数量的值,如下所示:

user> (take 5 (cycle [0]))
(0 0 0 0 0)
user> (take 5 (cycle (range 3)))
(0 1 2 0 1)

interleave 函数可以用来组合任意数量的序列。这个函数返回一个序列,其中包含每个集合的第一个元素,然后是第二个元素,依此类推。这种组合重复进行,直到最短的序列耗尽值。因此,我们可以很容易地使用 interleave 函数将有限序列与无限序列组合起来,以产生另一个有限序列:

user> (interleave [0 1 2] [3 4 5 6] [7 8])
(0 3 7 1 4 8)
user> (interleave [1 2 3] (cycle [0]))
(1 0 2 0 3 0)

另一个执行类似操作的功能是 interpose 函数。interpose 函数在给定序列的相邻元素之间插入一个给定的元素:

user> (interpose 0 [1 2 3])
(1 0 2 0 3)

iterate 函数也可以用来创建一个无限序列。请注意,我们已经在 示例 1.7 中使用 iterate 函数创建了一个惰性序列。这个函数接受一个函数 f 和一个初始值 x 作为其参数。iterate 函数返回的值将以 (f x) 作为第一个元素,(f (f x)) 作为第二个元素,依此类推。我们可以使用 iterate 函数与任何接受单个参数的其他函数一起使用,如下所示:

user> (take 5 (iterate inc 5))
(5 6 7 8 9)
user> (take 5 (iterate #(+ 2 %) 0))
(0 2 4 6 8)

序列转换

还有几个函数可以将序列转换为不同的表示或值。其中最灵活的函数之一是 map 函数。这个函数 映射 给定函数到给定序列上,即它将函数应用于序列中的每个元素。此外,map 返回的值是隐式惰性的。要应用于每个元素的函数必须是 map 的第一个参数,而函数必须应用的序列是下一个参数:

user> (map inc [0 1 2 3])
(1 2 3 4)
user> (map #(* 2 %) [0 1 2 3])
(0 2 4 6)

注意,map 可以接受任意数量的集合或序列作为其参数。在这种情况下,结果序列是通过将序列的第一个元素作为参数传递给给定函数,然后将序列的第二个元素传递给给定函数,依此类推,直到任何提供的序列耗尽。例如,我们可以使用 map+ 函数对两个序列的对应元素求和,如下所示:

user> (map + [0 1 2 3] [4 5 6])
(4 6 8)

mapv 函数与 map 函数具有相同的语义,但返回一个向量而不是序列,如下所示:

user> (mapv inc [0 1 2 3])
[1 2 3 4]

map 函数的另一个变体是 map-indexed 函数。这个函数期望提供的函数将接受两个参数——一个用于给定元素的索引,另一个用于列表中的实际元素:

user> (map-indexed (fn [i x] [i x]) "Hello")
([0 \H] [1 \e] [2 \l] [3 \l] [4 \o])

在这个例子中,提供给 map-indexed 的函数简单地将其参数作为向量返回。从前面的例子中,我们可以观察到的一个有趣点是字符串可以被看作是一系列字符。

mapcat函数是mapconcat函数的组合。这个函数将给定的函数映射到序列上,并对结果序列应用concat函数:

user> (require '[clojure.string :as cs])
nil
user> (map #(cs/split % #"\d") ["aa1bb" "cc2dd" "ee3ff"])
(["aa" "bb"] ["cc" "dd"] ["ee" "ff"])
user> (mapcat #(cs/split % #"\d") ["aa1bb" "cc2dd" "ee3ff"])
("aa" "bb" "cc" "dd" "ee" "ff")

在这个例子中,我们使用clojure.string命名空间中的split函数,通过正则表达式#"\d"来分割字符串。split函数将返回一个字符串向量,因此mapcat函数返回的是一个字符串序列,而不是像map函数那样返回向量序列。

reduce函数用于将一系列项组合或归约为一个单一值。reduce函数需要将其第一个参数作为函数,第二个参数作为序列。提供给reduce的函数必须接受两个参数。该函数首先应用于给定序列中的前两个元素,然后应用于前一个结果和序列中的第三个元素,依此类推,直到序列耗尽。reduce函数还有一个接受初始值的第二个参数,在这种情况下,提供的函数首先应用于初始值和序列中的第一个元素。reduce函数可以被认为是命令式编程语言中基于循环的迭代的等价物。例如,我们可以使用reduce来计算序列中所有元素的总和,如下所示:

user> (reduce + [1 2 3 4 5])
15
user> (reduce + [])
0
user> (reduce + 1 [])
1

在这个例子中,当reduce函数被提供一个空集合时,它返回0,因为(+)计算结果为0。当向reduce函数提供一个初始值1时,它返回1,因为(+ 1)返回1

可以使用for宏创建一个列表推导式。请注意,for形式将被转换为一个使用map函数的表达式。for宏需要提供一个绑定到任意数量集合的绑定向量以及体中的表达式。这个宏将提供的符号绑定到其对应集合中的每个元素,并为每个元素评估体。请注意,for宏还支持一个:let子句来分配一个值给变量,以及一个:when子句来过滤值:

user> (for [x (range 3 7)]
 (* x x))
(9 16 25 36)
user> (for [x [0 1 2 3 4 5]
 :let [y (* x 3)]
 :when (even? y)]
 y)
(0 6 12)

for宏也可以用于多个集合,如下所示:

user> (for [x ['a 'b 'c]
 y [1 2 3]]
 [x y])
([a 1] [a 2] [a 3] [b 1] [b 2] [b 3] [c 1] [c 2] [c 3])

doseq宏的语义与for类似,除了它总是返回一个nil值。这个宏简单地评估给定绑定中所有项的体表达式。这在强制对给定集合中的所有项进行表达式求值时很有用,以便产生副作用:

user> (doseq [x (range 3 7)]
 (* x x))
nil
user> (doseq [x (range 3 7)]
 (println (* x x)))
9
16
25
36
nil

如前例所示,第一个和第二个doseq形式都返回nil。然而,第二个形式打印了表达式(* x x)的值,这是一个副作用,对于序列(range 3 7)中的所有项。

into 函数可以用来轻松地在集合类型之间进行转换。此函数需要提供两个集合作为参数,并返回第一个集合,其中包含第二个集合中的所有项。例如,我们可以使用 into 函数将向量序列转换为映射,反之亦然,如下所示:

user> (into {} [[:a 1] [:c 3] [:b 2]])
{:a 1, :c 3, :b 2}
user> (into [] {1 2 3 4})
[[1 2] [3 4]]

我们应该注意,into 函数实际上是 reduceconj 函数的组合。由于 conj 用于填充第一个集合,into 函数返回的值将取决于第一个集合的类型。into 函数在列表和向量方面将表现得类似于 conj,如下所示:

user> (into [1 2 3] '(4 5 6))
[1 2 3 4 5 6]
user> (into '(1 2 3) '(4 5 6))
(6 5 4 1 2 3)

可以使用 partitionpartition-allpartition-by 函数将序列分割成更小的部分。partitionpartition-all 函数都接受两个参数——一个用于分割序列中项目数量 n 的参数,另一个用于要分割的序列。然而,partition-all 函数还将返回未分割的序列中的项,如下所示:

user> (partition 2 (range 11))
((0 1) (2 3) (4 5) (6 7) (8 9))
user> (partition-all 2 (range 11))
((0 1) (2 3) (4 5) (6 7) (8 9) (10))

partitionpartition-all 函数也接受一个步长参数,该参数默认为分割序列中提供的项目数量,如下所示:

user> (partition 3 2 (range 11))
((0 1 2) (2 3 4) (4 5 6) (6 7 8) (8 9 10))
user> (partition-all 3 2 (range 11))
((0 1 2) (2 3 4) (4 5 6) (6 7 8) (8 9 10) (10))

partition 函数还接受一个可选的第二个序列作为参数,该序列用于填充要分割的序列,以防有未分割的项。这个第二个序列必须在 partition 函数的步骤参数之后提供。请注意,填充序列仅用于创建一个包含未分割项的单个分区,其余的填充序列将被丢弃。此外,只有当存在未分割的项时,才会使用填充序列。以下是一个示例来说明这一点:

user> (partition 3 (range 11))
((0 1 2) (3 4 5) (6 7 8))
user> (partition 3 3 (range 11 12) (range 11))
((0 1 2) (3 4 5) (6 7 8) (9 10 11))
user> (partition 3 3 (range 11 15) (range 11))
((0 1 2) (3 4 5) (6 7 8) (9 10 11))
user> (partition 3 4 (range 11 12) (range 11))
((0 1 2) (4 5 6) (8 9 10))

在这个示例中,我们首先在第二个语句中提供了一个填充序列 (range 11 12),它只包含一个元素。在下一个语句中,我们提供了一个更大的填充序列 (range 11 15),但实际使用的只是填充序列中的第一个元素 11。在最后一个语句中,我们也提供了一个填充序列,但它从未被使用,因为 (range 11) 序列被分割成每个序列包含 3 个元素,步长为 4,这将没有剩余的项。

partition-by 函数需要提供一个高阶函数作为第一个参数,并将根据对序列中的每个元素应用给定函数的返回值来根据提供的序列分割项。每当给定函数返回一个新值时,序列实际上就会被 partition-by 分割,如下所示:

user> (partition-by #(= 0 %) [-2 -1 0 1 2])
((-2 -1) (0) (1 2))
user> (partition-by identity [-2 -1 0 1 2])
((-2) (-1) (0) (1) (2))

在这个例子中,第二条语句将给定的序列分割成包含单个项目的序列,因为我们使用了identity函数,它只是简单地返回其参数。对于[-2 -1 0 1 2]序列,identity函数为序列中的每个项目返回一个新值,因此分割后的序列都只有一个元素。

sort函数可以用来改变序列中元素的顺序。这个函数的一般形式需要一个比较项的函数和一个要排序的项的序列。提供的函数默认为compare函数,其行为取决于正在比较的项的实际类型:

user> (sort [3 1 2 0])
(0 1 2 3)
user> (sort > [3 1 2 0])
(3 2 1 0)
user> (sort ["Carol" "Alice" "Bob"])
("Alice" "Bob" "Carol")

如果我们打算在以sort形式进行排序比较之前,对序列中的每个项目应用一个特定的函数,我们应该考虑使用sort-by函数以获得更简洁的表达式。sort-by函数也接受一个用于实际比较的函数,类似于sort函数。以下是如何演示sort-by函数的示例:

user> (sort #(compare (first %1) (first %2)) [[1 1] [2 2] [3 3]])
([1 1] [2 2] [3 3])
user> (sort-by first [[1 1] [2 2] [3 3]])
([1 1] [2 2] [3 3])
user> (sort-by first > [[1 1] [2 2] [3 3]])
([3 3] [2 2] [1 1])

在这个例子中,第一条和第二条语句都在对给定序列中的每个项目应用first函数之后进行比较。最后一条语句将>函数传递给sort-by函数,它返回第一条和第二条语句返回的序列的逆序。

过滤序列

序列也可以被过滤,即通过从序列中移除一些元素来转换。有几个标准函数可以执行此任务。keep函数可以用来移除序列中产生给定函数nil值的值。keep函数需要一个函数和一个要传递给它的序列。keep函数将对序列中的每个项目应用给定的函数,并移除所有产生nil的值,如下所示:

user> (keep #(if (odd? %) %) (range 10))
(1 3 5 7 9)
user> (keep seq [() [] '(1 2 3) [:a :b] nil])
((1 2 3) (:a :b))

在这个例子中,第一条语句从给定的序列中移除了所有偶数。在第二条语句中,使用了seq函数来移除给定序列中的所有空集合。

由于它们可以被视为函数,因此可以将映射或集合作为keep函数的第一个参数传递,如下所示:

user> (keep {:a 1, :b 2, :c 3} [:a :b :d])
(1 2)
user> (keep #{0 1 2 3} #{2 3 4 5})
(3 2)

filter函数也可以用来从给定的序列中移除一些元素。filter函数期望传递给它一个谓词函数以及要过滤的序列。那些谓词函数不返回真值(truthy value)的项目将从结果中移除。filterv函数与filter函数相同,只是它返回一个向量而不是列表:

user> (filter even? (range 10))
(0 2 4 6 8)
user> (filterv even? (range 10))
[0 2 4 6 8]

filterkeep函数具有相似的语义。然而,主要区别在于filter函数返回原始元素的一个子集,而keep返回一个非nil值的序列,这些值是由提供给它的函数返回的,如下面的示例所示:

user> (keep #(if (odd? %) %) (range 10))
(1 3 5 7 9)
user> (filter odd? (range 10))
(1 3 5 7 9)

注意,在这个例子中,如果我们将 odd? 函数传递给 keep 形式,它将返回一个包含 truefalse 值的列表,因为这些值是由 odd? 函数返回的。

此外,带有 :when 子句的 for 宏被翻译成使用 filter 函数的表达式,因此 for 形式也可以用来从序列中删除元素:

user> (for [x (range 10) :when (odd? x)] x)
(1 3 5 7 9)

可以使用 subvec 函数来 切片 向量。所谓切片,就是根据传递给 subvec 函数的值从原始向量中选择一个较小的向量。subvec 函数将其第一个参数作为向量,然后是表示切片向量起始索引的索引,最后是表示切片向量结束索引的可选索引,如下所示:

user> (subvec [0 1 2 3 4 5] 3)
[3 4 5]
user> (subvec [0 1 2 3 4 5] 3 5)
[3 4]

可以使用 select-keys 函数通过其键来过滤映射。此函数需要一个映射作为第一个参数,以及一个向量作为第二个参数传递给它。传递给此函数的键向量表示要包含在结果映射中的键值对,如下所示:

user> (select-keys {:a 1 :b 2} [:a])
{:a 1}
user> (select-keys {:a 1 :b 2 :c 3} [:a :c])
{:c 3, :a 1}

从映射中选择键值对的另一种方法是使用 find 函数,如下所示:

user> (find {:a 1 :b 2} :a)
[:a 1]

take-whiledrop-whiletakedrop 函数类似,需要传递一个谓词给它们,而不是要取或删除的元素数量。take-while 函数在谓词函数返回真值时取元素,同样地,drop-while 函数将根据相同的条件删除元素:

user> (take-while neg? [-2 -1 0 1 2])
(-2 -1)
user> (drop-while neg? [-2 -1 0 1 2])
(0 1 2)

懒序列

lazy-seqlazy-cat 是创建懒序列的最基本构造。这些函数返回的值类型总是 clojure.lang.LazySeqlazy-seq 函数用于将懒计算的表达式包装在 cons 形式中。这意味着 cons 形式创建的其余序列是懒计算的。例如,lazy-seq 函数可以用来构建表示斐波那契序列的懒序列,如 示例 1.8 所示:

(defn fibo-cons [a b]
  (cons a (lazy-seq (fibo-cons b (+ a b)))))

示例 1.8:使用 lazy-seq 创建的懒序列

fibo-cons 函数需要两个初始值,ab,作为初始值传递给它,并返回一个包含第一个值 a 和一个使用序列中的下一个两个值(即 b(+ a b))进行懒计算的懒序列。在这种情况下,cons 形式将返回一个懒序列,可以使用 takelast 函数来处理,如下所示:

user> (def fibo (fibo-cons 0N 1N))
#'user/fibo
user> (take 2 fibo)
(0N 1N)
user> (take 11 fibo)
(0N 1N 1N 2N 3N 5N 8N 13N 21N 34N 55N)
user> (last (take 10000 fibo))
207936...230626N

注意,在 示例 1.8 中,fibo-cons 函数递归地调用自身而没有显式的 recur 形式,但它并没有消耗任何栈空间。这是因为懒序列中存在的值不是存储在调用栈中,所有值都是在进程堆上分配的。

另一种定义惰性斐波那契数列的方法是使用 lazy-cat 函数。这个函数本质上是以惰性方式连接它所提供的所有序列。例如,考虑 示例 1.9 中斐波那契数列的定义:

(def fibo-seq
  (lazy-cat [0N 1N] (map + fibo-seq (rest fibo-seq))))

示例 1.9:使用 lazy-cat 创建的惰性序列

示例 1.9 中的 fibo-seq 变量本质上使用 maprest+ 函数的惰性组合来计算斐波那契数列。此外,需要一个序列作为初始值,而不是像我们在 示例 1.8 中定义 fibo-cons 时所看到的函数。我们可以使用 nth 函数从这个序列中获取一个数字,如下所示:

user> (first fibo-seq)
0N
user> (nth fibo-seq 1)
1N
user> (nth fibo-seq 10)
55N
user> (nth fibo-seq 9999)
207936...230626N

如前所述,fibo-consfibo-seq 是斐波那契数列中无限数列的简洁和惯用表示。这两个定义返回相同的值,并且不会因为栈消耗而导致错误。

一个有趣的事实是,大多数返回序列的标准函数,如 mapfilter,本质上都是惰性的。使用这些函数构建的任何表达式都是惰性的,因此只有在需要时才会进行评估。例如,考虑以下使用 map 函数的表达式:

user> (def xs (map println (range 3)))
#'user/xs
user> xs
0
1
2
(nil nil nil)

在这个例子中,当我们定义 xs 变量时,并没有调用 println 函数。然而,一旦我们尝试在 REPL 中打印它,序列就会被评估,并通过调用 println 函数打印出数字。请注意,xs 评估为 (nil nil nil),因为 println 函数总是返回 nil

有时候,有必要急切地评估一个惰性序列。doalldorun 函数正是为此目的而设计的。doall 函数本质上强制评估一个惰性序列及其评估的任何副作用。doall 返回的是给定惰性序列中所有元素的列表。例如,让我们将上一个示例中的 map 表达式用 doall 形式包装,如下所示:

user> (def xs (doall (map println (range 3))))
0
1
2
#'user/xs
user> xs
(nil nil nil)

现在,一旦定义了 xs,数字就会立即打印出来,因为我们使用了 doall 函数强制评估。dorun 函数与 doall 函数具有类似的语义,但它总是返回 nil。因此,当我们只对评估惰性序列的副作用感兴趣,而不是其中的实际值时,我们可以使用 dorun 函数代替 doall。另一种在集合中的所有值上调用具有一些副作用的功能的方法是使用 run! 函数,它必须传递一个要调用的函数和一个集合。run! 函数总是返回 nil,就像 dorun 形式一样。

使用 zippers

既然我们已经熟悉了序列,让我们简要地考察一下zippers。Zippers 本质上是一种数据结构,有助于遍历和操作。在 Clojure 中,任何包含嵌套集合的集合都被称为树。可以将 zippers 视为包含有关树的位置信息的结构。Zippers 不是树的扩展,而是可以用来遍历和实现树。

注意

在接下来的示例中,必须在您的命名空间声明中包含以下命名空间:

(ns my-namespace
  (:require [clojure.zip :as z]
            [clojure.xml :as xml]))

以下示例可以在书籍源代码的src/m_clj/c1/zippers.clj中找到。

我们可以使用向量字面量定义一个简单的树,如下所示:

(def tree [:a [1 2 3] :b :c])

向量tree是一个树,由节点:a[1 2 3]:b:c组成。我们可以使用vector-zip函数从向量tree创建一个 zippers,如下所示:

(def root (z/vector-zip tree))

之前定义的变量root是一个 zippers,包含遍历给定树的位置信息。请注意,vector-zip函数仅仅是标准seq函数和clojure.zip命名空间中的seq-zip函数的组合。因此,对于表示为序列的树,我们应该使用seq-zip函数。此外,clojure.zip命名空间中的所有其他函数都期望它们的第一个参数是一个 zippers。

要遍历 zippers,我们必须使用clojure.zip/next函数,它返回 zippers 中的下一个节点。我们可以通过组合iterateclojure.zip/next函数轻松地遍历 zippers 中的所有节点,如下所示:

user> (def tree-nodes (iterate z/next root))
#'user/tree-nodes
user> (nth tree-nodes 0)
[[:a [1 2 3] :b :c] nil]
user> (nth tree-nodes 1)
[:a {:l [], :pnodes ... }]
user> (nth tree-nodes 2)
[[1 2 3] {:l [:a], :pnodes ... }]
user> (nth tree-nodes 3)
[1 {:l [], :pnodes ... }]

如前所述,zippers 的第一个节点代表原始树本身。此外,zippers 将包含一些额外的信息,除了当前节点包含的值之外,这些信息在导航给定树时很有用。实际上,next函数的返回值也是一个 zippers。一旦我们完全遍历了给定的树,next函数将返回指向树根的 zippers。请注意,为了可读性,zippers 中的某些信息已被从先前的 REPL 输出中截断。

要导航到给定 zippers 中的相邻节点,我们可以使用downupleftright函数。所有这些函数都返回一个 zippers,如下所示:

user> (-> root z/down)
[:a {:l [], :pnodes ... }]
user> (-> root z/down z/right)
[[1 2 3] {:l [:a], :pnodes ... }]
user> (-> root z/down z/right z/up)
[[:a [1 2 3] :b :c] nil]
user> (-> root z/down z/right z/right)
[:b {:l [:a [1 2 3]], :pnodes ... }]
user> (-> root z/down z/right z/left)
[:a {:l [], :pnodes ... }]

downupleftright函数会改变[:a [1 2 3] :b :c]树中root zippers 的位置,如下面的插图所示:

使用 zippers

上述图示显示了 zipper 在给定树中的三个不同位置。最初,zipper 的位置在树的根处,即整个向量。down 函数将位置移动到树中的第一个子节点。leftright 函数将 zipper 的位置移动到树中同一级别或深度的其他节点。up 函数将 zipper 移动到当前 zipper 指向节点的父节点。

要获取表示 zipper 在树中当前位置的节点,我们必须使用 node 函数,如下所示:

user> (-> root z/down z/right z/right z/node)
:b
user> (-> root z/down z/right z/left z/node)
:a

要导航到树的极端左侧或右侧,我们可以分别使用 leftmostrightmost 函数,如下所示:

user> (-> root z/down z/rightmost z/node)
:c
user> (-> root z/down z/rightmost z/leftmost z/node)
:a

leftsrights 函数分别返回给定 zipper 左侧和右侧存在的节点,如下所示:

user> (-> root z/down z/rights)
([1 2 3] :b :c)
user> (-> root z/down z/lefts)
nil

由于 :a 节点是树中的最左侧元素,当传递一个当前位置为 :a 的 zipper 时,rights 函数将返回树中的所有其他节点。同样,:a 节点的 lefts 函数将返回一个空值,即 nil

root 函数可以用来获取给定 zipper 的根。它将返回用于构建 zipper 的原始树,如下所示:

user> (-> root z/down z/right z/root)
[:a [1 2 3] :b :c]
user> (-> root z/down z/right r/left z/root)
[:a [1 2 3] :b :c]

path 函数可以用来获取从树的根元素到给定 zipper 当前位置的路径,如下所示:

user> (def e (-> root z/down z/right z/down))
#'user/e
user> (z/node e)
1
user> (z/path e)
[[:a [1 2 3] :b :c]
 [1 2 3]]

在上述示例中,tree 中的 1 节点的路径由包含整个树和子树 [1 2 3] 的向量表示。这意味着要到达 1 节点,我们必须通过根和子树 [1 2 3]

现在我们已经介绍了在树之间导航的基础知识,让我们看看我们如何修改原始树。insert-child 函数可以用来将给定的元素插入到树中,如下所示:

user> (-> root (z/insert-child :d) z/root)
[:d :a [1 2 3] :b :c]
user> (-> root z/down z/right (z/insert-child 0) z/root)
[:a [0 1 2 3] :b :c]

我们还可以使用 remove 函数从 zipper 中删除节点。同样,可以使用 replace 函数在 zipper 中替换给定的节点:

user> (-> root z/down z/remove z/root)
[[1 2 3] :b :c]
user> (-> root z/down (z/replace :d) z/root)
[:d [1 2 3] :b :c]

树形数据中最值得注意的例子之一是 XML。由于 zipper 在处理树形数据方面非常出色,它还允许我们轻松地遍历和修改 XML 内容。请注意,Clojure 已经提供了 xml-seq 函数来将 XML 数据转换为序列。然而,将 XML 文档视为序列有许多奇怪的含义。

使用 xml-seq 的主要缺点之一是,如果我们正在遍历序列,则没有简单的方法从节点到达文档的根。此外,xml-seq 只帮助我们遍历 XML 内容;它不处理修改。这些限制可以通过使用 zipper 来克服,正如我们将在下面的示例中看到的那样。

例如,考虑以下 XML 文档:

<countries>
  <country name="England">
    <city>Birmingham</city>
    <city>Leeds</city>
    <city capital="true">London</city>
  </country>
  <country name="Germany">
    <city capital="true">Berlin</city>
    <city>Frankfurt</city>
    <city>Munich</city>
  </country>
  <country name="France">
    <city>Cannes</city>
    <city>Lyon</city>
    <city capital="true">Paris</city>
  </country>
</countries>

上面的文档包含表示为 XML 节点的国家和城市。每个国家都有若干个城市,一个城市作为其首都。一些信息,如国家名称和一个表示城市是否为首都的旗帜,被编码在节点的 XML 属性中。

注意

以下示例期望之前显示的 XML 内容存在于相对于你的 Leiningen 项目根目录的resources/data/sample.xml文件中。

让我们定义一个函数来找出文档中的所有首都,如下所示 示例 1.10

(defn is-capital-city? [n]
  (and (= (:tag n) :city)
       (= "true" (:capital (:attrs n)))))

(defn find-capitals [file-path]
  (let [xml-root (z/xml-zip (xml/parse file-path))
        xml-seq (iterate z/next (z/next xml-root))]
    (->> xml-seq
         (take-while #(not= (z/root xml-root) (z/node %)))
         (map z/node)
         (filter is-capital-city?)
         (mapcat :content))))

示例 1.10:使用 zippers 查询 XML

首先,我们必须注意,来自clojure.xml命名空间的parse函数读取一个 XML 文档,并返回一个表示文档的映射。这个映射中的每个节点都是另一个映射,具有与 XML 节点的标签名、属性和内容相关的:tag:attrs:content键。

示例 1.10 中,我们首先定义了一个简单的函数,is-capital-city?,用于确定给定的 XML 节点是否具有city标签,表示为:cityis-capital-city? 函数还检查 XML 节点是否包含capital属性,表示为:capital。如果给定节点的capital属性值是字符串"true",则is-capital-city? 函数返回true

在这个例子中,find-capitals 函数执行了大部分繁重的工作。这个函数首先使用xml-zip函数解析提供的路径file-path中的 XML 文档,并将其转换为 zippers。然后我们使用next函数遍历 zippers,直到回到根节点,由take-while函数进行检查。然后我们使用map函数将node函数映射到结果序列的 zippers 上,并使用filter函数在所有节点中找到首都城市。最后,我们使用mapcat函数获取过滤节点的 XML 内容,并将结果序列的向量扁平化成一个单一列表。

当提供包含我们之前描述的 XML 内容的文件时,find-capitals 函数返回文档中所有首都的名称:

user> (find-capitals "resources/data/sample.xml")
("London" "Berlin" "Paris")

如前所示,zippers 非常适合处理树形结构和如 XML 这样的分层数据。更普遍地说,序列是集合和数据多种形式的优秀抽象,Clojure 为我们提供了处理序列的巨大工具集。Clojure 语言中还有几个处理序列的函数,鼓励你自己去探索它们。

使用模式匹配

在本节中,我们将探讨 Clojure 中的模式匹配。通常,使用条件逻辑的函数可以使用ifwhencond形式定义。模式匹配允许我们通过声明参数的文本值模式来定义这样的函数。虽然这个想法可能看起来相当基础,但它是一个非常有用且强大的概念,正如我们将在接下来的示例中看到的那样。模式匹配也是其他函数式编程语言中的基础编程结构。

在 Clojure 中,核心语言中没有对函数和形式的模式匹配支持。然而,在 Lisp 程序员中有一个常见的观点,即我们可以通过宏轻松地修改或扩展语言。Clojure 也采取了这种方法,因此模式匹配是通过matchdefun宏实现的。这些宏在core.match (github.com/clojure/core.match) 和defun (github.com/killme2008/defun) 社区库中实现。这两个库也支持 ClojureScript。

注意

下面的库依赖项对于即将到来的示例是必需的:

[org.clojure/core.match "0.2.2"
 :exclusions [org.clojure/tools.analyzer.jvm]]
[defun "0.2.0-RC"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [clojure.core.match :as m]
            [defun :as f]))

以下示例可以在书籍源代码的src/m_clj/c1/match.clj中找到。

让我们考虑一个简单的例子,我们可以使用模式匹配来建模。XOR 逻辑函数仅在它的参数互斥时返回 true 值,也就是说,当它们具有不同的值时。换句话说,当两个参数的值都相同时,XOR 函数将返回 false。我们可以很容易地使用match宏定义这样的函数,如示例 1.11所示:

(defn xor [x y]
  (m/match [x y]
           [true true] false
           [false true] true
           [true false] true
           [false false] false))

示例 1.11:使用 match 宏进行模式匹配

示例 1.11中的xor函数简单地将其参数xy与给定的模式集(如[true true][true false])进行匹配。如果两个参数都是truefalse,则函数返回false,否则返回true。这是一个简洁的定义,它依赖于提供的参数的值,而不是使用ifwhen之类的条件形式。xor函数可以通过defun宏以不同的方式定义,甚至更加简洁,如示例 1.12所示:

(f/defun xor
  ([true true] false)
  ([false true] true)
  ([true false] true)
  ([false false] false))

示例 1.12:使用 defun 宏进行模式匹配

使用defun宏定义的xor函数简单地声明实际值作为其参数。因此,要返回的表达式由其输入值确定。请注意,defun宏重写了xor函数的定义,以使用match宏。因此,所有由match宏支持的模式也可以与defun宏一起使用。示例 1.11示例 1.12xor函数的定义都按预期工作,如下所示:

user> (xor true true)
false
user> (xor true false)
true
user> (xor false true)
true
user> (xor false false)
false

如果我们尝试传递未声明为模式的值给 xor 函数,它将抛出异常:

user> (xor 0 0)
IllegalArgumentException No matching clause: [0 0] user/xor ...

我们可以使用 defun 宏定义一个简单的函数来计算斐波那契序列的第 n 个数,如下所示 示例 1.13

(f/defun fibo
  ([0] 0N)
  ([1] 1N)
  ([n] (+ (fibo (- n 1))
          (fibo (- n 2)))))

注意函数模式规则中变量 n 的使用。这表示除了 01 以外的任何值都将与使用 n 的模式定义相匹配。示例 1.13 中定义的 fibo 函数确实计算了第 n 个斐波那契序列,如下所示:

user> (fibo 0)
0N
user> (fibo 1)
1N
user> (fibo 10)
55N

然而,示例 1.13 中显示的 fibo 的定义不能通过尾调用消除来优化。这是因为 fibo 的定义是树递归的。换句话说,表达式 (+ (fibo ...) (fibo ...)) 需要两个递归调用才能完全评估。实际上,如果我们用 recur 表达式替换 fibo 函数的递归调用,得到的函数将无法编译。将树递归转换为线性递归相当简单,如 示例 1.14 所示:

(f/defun fibo-recur
  ([a b 0] a)
  ([a b n] (recur b (+ a b) (dec n)))
  ([n] (recur 0N 1N n)))

示例 1.14:具有模式匹配的尾递归函数

fibo-recur 函数的定义,从 示例 1.14 中可以看出,它确实是尾递归的。这个函数不消耗任何栈空间,可以安全地用大的 n 值调用,如下所示:

user> (fibo-recur 0)
0N
user> (fibo-recur 1)
1N
user> (fibo-recur 10)
55N
user> (fibo-recur 9999)
207936...230626N

如前述示例所示,模式匹配是函数式编程中的一个强大工具。使用模式匹配定义的函数不仅正确且具有表现力,而且可以取得良好的性能。在这方面,core.matchdefun 库是 Clojure 生态系统中的不可或缺的工具。

概述

在本章中,我们介绍了一些可以在 Clojure 语言中使用的编程结构。我们探讨了使用 recurlooptrampoline 形式进行递归。我们还研究了序列和惰性的基础知识,同时描述了在 Clojure 语言中用于创建、转换和过滤序列的各种函数。接下来,我们查看了一下 zippers,以及它们如何被用来习惯性地处理树和如 XML 这样的层次数据。最后,我们简要探讨了使用 core.matchdefun 库进行模式匹配。

在下一章中,我们将探讨并发与并行。我们将详细研究各种数据结构和函数,这些函数允许我们在 Clojure 中充分利用这些概念。

第二章 编排并发与并行

现在我们来探讨 Clojure 如何支持并发和并行编程。术语并发编程指的是同时管理多个任务。另一方面,并行编程并行性涉及同时执行多个任务。这两个术语的区别在于,并发是关于我们如何构建和同步多个任务,而并行性更多地涉及在多个核心上并行运行多个任务。使用并发和并行性的主要优势可以详细阐述如下:

  • 并发程序可以同时执行多个任务。例如,桌面应用程序可以有一个处理用户交互的单个任务,另一个处理 I/O 和网络通信的任务。单个处理器可以由多个任务共享。因此,在并发程序中,处理器利用率更为有效。

  • 并行程序可以利用多个处理器核心的优势。这意味着,通过在具有更多处理器核心的系统上执行这些程序,可以使此类程序运行得更快。此外,计算密集型任务可以通过并行化以更短的时间完成。

在本章中,我们将:

  • 研究如何创建和同步并发运行的任务

  • 了解如何处理并发任务之间的共享状态

  • 探讨如何并行化计算以及如何控制用于这些计算的并行程度

管理并发任务

Clojure 有几个实用的构造,允许我们定义并发任务。线程是运行在后台的任务的最基本抽象。在正式意义上,线程只是一系列可以调度执行的指令。在程序的后台运行的任务被称为在单独的线程上执行。底层操作系统将负责将线程调度到特定的处理器上。大多数现代操作系统允许一个进程有多个执行线程。在单个进程中管理多个线程的技术被称为多线程

虽然 Clojure 支持线程的使用,但可以使用其他构造以更优雅的方式对并发任务进行建模。让我们探索我们可以定义并发任务的不同方式。

备注

以下示例可以在书籍源代码的src/m_clj/c2/concurrent.clj中找到。

使用延迟

可以使用延迟来定义一个执行被延迟,或推迟,直到必要时才执行的任务。延迟只运行一次,并且其结果被缓存。我们只需将给定任务的指令包裹在delay形式中,就可以定义一个延迟,如示例 2.1所示:

(def delayed-1
  (delay
   (Thread/sleep 3000)
   (println "3 seconds later ...")
   1))

示例 2.1:延迟值

备注

静态 Thread/sleep 方法暂停当前执行线程的执行,持续给定数量的毫秒,该数量作为此方法的第一个参数传递。我们可以选择性地指定当前线程必须暂停的纳秒数,作为 Thread/sleep 方法的第二个参数。

示例 2.1 中的 delay 形式简单地暂停 3000 毫秒,打印一个字符串并返回值 1。然而,它尚未 实现,也就是说,它尚未被执行。可以使用 realized? 谓词来检查延迟是否已执行,如下所示:

user> (realized? delayed-1)
false
user> (realized? delayed-1)           ; after 3 seconds
false

注意

我们可以使用 delay? 谓词来检查一个值是否是延迟。

delay 形式中的主体表达式将在其实际使用的值返回之前不会执行。我们可以通过使用 at-the-rate 符号 (@) 解引用延迟来获取延迟中包含的值:

user> @delayed-1
3 seconds later ...
1
user> (realized? delayed-1)
true

注意

使用 at-the-rate 符号 (@) 来解引用一个值与使用 deref 函数相同。例如,表达式 @x 等价于 (deref x)

deref 函数还有一个接受三个参数的变体形式——一个要解引用的值、在超时前等待的毫秒数,以及在超时情况下返回的值。

如前所述,表达式 @delayed-1 在暂停 3 秒后返回值 1。现在,对 realized? 的调用返回 true。此外,表达式 @delayed-1 返回的值将被缓存,如下所示:

user> @delayed-1
1

因此,很明显,表达式 @delayed-1 将被阻塞 3 秒,将打印一个字符串,并仅返回一个值。

注意

执行延迟的另一种方式是使用 force 函数,该函数接受一个延迟作为参数。如果需要,此函数将执行给定的延迟,并返回延迟内部表达式的值。

延迟对于表示不需要立即执行直到需要执行的价值或任务非常有用。然而,延迟将始终在它被解引用的同一线程中执行。换句话说,延迟是 同步的。因此,延迟并不是表示在后台运行的任务的真正解决方案。

使用未来和承诺

正如我们之前提到的,线程是处理后台任务的最基本方式。在 Clojure 中,所有函数都实现了 clojure.lang.IFn 接口,该接口反过来扩展了 java.lang.Runnable 接口。这意味着任何 Clojure 函数都可以在单独的执行线程中调用。例如,考虑 示例 2.2 中的函数:

(defn wait-3-seconds []
  (Thread/sleep 3000)
  (println)
  (println "3 seconds later ..."))

示例 2.2:等待 3 秒的函数

示例 2.2 中的 wait-3-seconds 函数等待 3000 毫秒并打印一个新行和一个字符串。我们可以通过使用 Thread. 构造函数从它构造一个 java.lang.Thread 对象来在单独的线程中执行此函数。然后,可以通过调用其 .start 方法来安排该对象在后台执行,如下所示:

user> (.start (Thread. wait-3-seconds))
nil
user>
3 seconds later ...

user>

调用 .start 方法会立即返回到 REPL 提示符。wait-3-seconds 函数在后台执行,并在 3 秒后在 REPL 的标准输出中打印。虽然使用线程确实允许在后台执行任务,但它们有几个缺点:

  • 从在单独线程上执行的功能中获取返回值没有明显的方法。

  • 此外,使用 Thread..start 函数基本上是与底层 JVM 的互操作。因此,在程序代码中使用这些函数意味着程序只能在 JVM 上运行。我们实际上将程序锁定在单个平台上,程序不能在 Clojure 支持的其他任何平台上运行。

future 是表示在单独线程上执行的任务的更习惯用法。未来可以被简洁地定义为将在未来实现的值。未来表示执行特定计算并返回计算结果的任务。我们可以使用 future 形式创建一个未来,如 示例 2.3 所示:

(defn val-as-future [n secs]
  (future
    (Thread/sleep (* secs 1000))
    (println)
    (println (str secs " seconds later ..."))
    n))

示例 2.3:一个等待一段时间并返回值的未来

示例 2.3 中定义的 val-as-future 函数调用一个等待由参数 secs 指定秒数的未来,打印一个新行和一个字符串,并最终返回提供的值 n。调用 val-as-future 函数将立即返回一个未来,并在指定秒数后打印一个字符串,如下所示:

user> (def future-1 (val-as-future 1 3))
#'user/future-1
user>
3 seconds later ...

user>

realized?future-done? 谓词可以用来检查一个未来是否已完成,如下所示:

user> (realized? future-1)
true
user> (future-done? future-1)
true

注意

我们可以使用 future? 谓词来检查一个值是否是未来。

可以使用 future-cancel 函数停止正在执行的未来,该函数只接受一个未来作为其唯一参数,并返回一个布尔值,指示提供的未来是否被取消,如下所示:

user> (def future-10 (val-as-future 10 10))
#'user/future-10
user> (future-cancel future-10)
true

我们可以使用 future-cancelled? 函数来检查一个未来是否已被取消。此外,在取消一个未来之后对其进行解引用将引发异常,如下所示:

user> (future-cancelled? future-10)
true
user> @future-10
CancellationException   java.util.concurrent.FutureTask.report (FutureTask.java:121)

既然我们已经熟悉了将任务表示为未来的概念,让我们来谈谈如何同步多个未来。首先,我们可以使用 promises 来同步两个或多个未来。一个通过 promise 函数创建的 promise,简单地说,是一个只能设置一次的值。使用 deliver 形式来设置或 交付 一个 promise。对一个已经交付的 promise 再次调用 deliver 形式将不会有任何效果,并返回 nil。当一个 promise 没有交付时,使用 @ 符号或 deref 形式对其进行解引用将阻塞当前执行线程。因此,一个 promise 可以与一个未来一起使用,以便在某个值可用之前暂停未来的执行。以下是如何快速演示 promisedeliver 形式的示例:

user> (def p (promise))
#'user/p
user> (deliver p 100)
#<core$promise$reify__6363@1792b00: 100>
user> (deliver p 200)
nil
user> @p
100

如前所述的输出所示,使用承诺 p 调用 deliver 形式的第一次调用将承诺的值设置为 100,而第二次调用 deliver 形式没有效果。

注意

可以使用 realized? 断言来检查一个承诺实例是否已被交付。

另一种同步并发任务的方法是使用 locking 形式。locking 形式允许在任何给定时间点只有一个任务持有锁变量,或监视器。任何值都可以被视为监视器。当某个任务持有或锁定监视器时,任何其他尝试获取监视器的并发任务都将被阻塞,直到监视器可用。因此,我们可以使用 locking 形式来同步两个或多个并发未来,如 示例 2.4 所示:

(defn lock-for-2-seconds []
  (let [lock (Object.)
        task-1 (fn []
                 (future
                   (locking lock
                     (Thread/sleep 2000)
                     (println "Task 1 completed"))))
        task-2 (fn []
                 (future
                   (locking lock
                     (Thread/sleep 1000)
                     (println "Task 2 completed"))))]
    (task-1)
    (task-2)))

示例 2.4:使用锁定形式

示例 2.4 中的 lock-for-2-seconds 函数创建了两个函数,task-1task-2,这两个函数都调用了尝试获取一个由变量 lock 表示的监视器的未来。在这个例子中,我们使用一个无聊的 java.lang.Object 实例作为同步两个未来的监视器。由 task-1 函数调用的未来将睡眠两秒钟,而由 task-2 函数调用的未来将睡眠一秒钟。观察到由 task-1 函数调用的未来首先完成,因为由 task-2 函数调用的未来将不会执行,直到未来的 locking 形式获得监视器 lock,如下面的输出所示:

user> (lock-for-2-seconds)
[#<core$future_call$reify__6320@19ed4e9: :pending>
 #<core$future_call$reify__6320@ac35d5: :pending>]
user>
Task 1 completed
Task 2 completed

我们可以使用 locking 形式来同步多个未来。然而,必须谨慎使用 locking 形式,因为其不当使用可能导致并发任务之间的死锁。并发任务通常同步以传递共享状态。Clojure 允许我们通过使用表示共享状态的引用类型来避免使用 locking 形式和任何可能的死锁,我们将在下一节中探讨这一点。

状态管理

一个程序可以被分成几个部分,这些部分可以并发执行。通常需要在这些并发运行的任务之间共享数据或状态。因此,我们得到了对某些数据有多个观察者的概念。如果数据被修改,我们必须确保更改对所有观察者都是可见的。例如,假设有两个线程从公共变量中读取数据。这个数据被一个线程修改,并且更改必须尽快传播到另一个线程,以避免不一致性。

支持可变性的编程语言通过在监视器上锁定,正如我们通过locking形式所展示的,并维护数据的本地副本来处理这个问题。在这样的语言中,变量只是一个数据容器。每当并发任务访问与其他任务共享的变量时,它会从变量中复制数据。这是为了防止其他任务在任务对其执行计算时意外覆盖变量。如果变量实际上被修改,给定任务仍将拥有共享数据的副本。如果有两个并发任务访问给定的变量,它们可以同时修改该变量,因此这两个任务都会对给定变量中的数据有一个不一致的视图。这个问题被称为竞争条件,在处理并发任务时必须避免。因此,使用监视器来同步对共享数据的访问。然而,这种方法在本质上并不是确定性的,这意味着我们无法轻易地推理出在特定时间点变量中实际包含的数据。这使得在使用可变性的编程语言中开发并发程序变得相当繁琐。

与其他函数式编程语言一样,Clojure 通过使用不可变性来解决这个问题——所有值默认都是不可变的,不能更改。为了模拟可变状态,有身份状态时间的概念:

  • 身份是与变化状态相关联的任何事物。在特定时间点,一个身份具有单一的状态。

  • 状态是在特定时间点与身份相关联的值。

  • 时间定义了身份状态之间的顺序。

实际使用状态的程序因此可以分为两层。一层是纯函数性的,与状态无关。另一层构成了程序中实际需要使用可变状态的部分。这种分解使我们能够隔离程序中实际需要使用可变状态的部分。

在 Clojure 中定义可变状态有几种方式,用于此目的的数据结构被称为引用类型。引用类型本质上是对不可变值的可变引用。因此,必须显式地更改引用,并且引用类型中包含的实际值不能以任何方式修改。引用类型可以以下列方式描述:

  • 某些引用类型的状态变化可以是同步的或异步的。例如,假设我们正在向文件写入数据。同步写入操作会阻塞调用者,直到所有数据都写入文件。另一方面,异步写入操作会启动一个后台任务将所有数据写入文件,并立即返回调用者。

  • 引用类型的修改可以以 协调独立 的方式进行。通过协调,我们指的是状态只能在由某些底层系统管理的交易中修改,这与数据库工作的方式非常相似。然而,独立修改引用类型的引用类型可以不使用交易显式地更改。

  • 某些状态的变化可能只能对发生变化的线程可见,或者它们可能对所有当前进程中的线程可见。

我们现在将探讨在 Clojure 中可以用来表示可变状态的多种引用类型。

使用变量

变量 用于管理在线程作用域内发生变化的州。我们本质上定义了可以具有状态的变量,并将它们绑定到不同的值。变量的修改值仅对当前执行线程可见。因此,变量是一种 线程局部 状态。

注意

以下示例可以在书籍源代码的 src/m_clj/c2/vars.clj 中找到。

动态变量使用带有 :dynamic 元关键字的 def 形式定义。如果我们省略 :dynamic 元数据,那么它将等同于使用 def 形式定义一个普通变量或静态变量。一个约定是所有动态变量名必须以星号字符 (*) 开头和结尾,但这不是强制性的。例如,让我们定义一个如下所示的动态变量:

(def ^:dynamic *thread-local-state* [1 2 3])

示例 2.5 中定义的 *thread-local-state* 变量代表一个可以动态变化的线程局部变量。我们已将变量 *thread-local-state* 初始化为向量 [1 2 3],但这并不是必需的。如果未向 def 形式提供初始值,则生成的变量被称为 未绑定 变量。虽然变量的状态仅限于当前线程,但其声明对当前命名空间是全局的。换句话说,使用 def 形式定义的变量将对从当前命名空间调用的所有线程可见,但变量的状态仅限于更改它的线程。因此,使用 def 形式的变量也被称为 全局变量

通常,def 形式创建一个静态变量,只能通过使用另一个 def 形式来重新定义。静态变量也可以在作用域或上下文中使用 with-redefswith-redefs-fn 形式重新定义。然而,动态变量可以通过使用 binding 形式在定义后将其设置为新的值,如下所示:

user> (binding [*thread-local-state* [10 20]]
 (map #(* % %) *thread-local-state*))
(100 400)
user> (map #(* % %) *thread-local-state*)
(1 4 9)

在此示例中,binding 形式将 *thread-local-state* 变量中包含的值更改为向量 [10 20]。这导致在未使用 binding 形式包围的情况下调用示例中的 map 形式时返回不同的值。因此,binding 形式可以用来临时更改提供给它的变量的状态。

Clojure 的命名空间系统会将自由符号,或者说变量名解析为其值。将变量名解析为命名空间限定符号的过程称为 内联。此外,def 形式将首先根据传递给它的符号查找现有的全局变量,如果尚未定义,则创建一个。var 形式可以用来获取变量的完全限定名,而不是其当前值,如下所示:

user> *thread-local-state*
[1 2 3]
user> (var *thread-local-state*)
#'user/*thread-local-state*

注意

使用 #' 符号与使用 var 形式相同。例如,#'x 等同于 (var x)

with-bindings 形式是重新绑定变量的另一种方式。此形式接受一个包含变量和值对的映射作为其第一个参数,然后是表单的主体,如下所示:

user> (with-bindings {#'*thread-local-state* [10 20]}
 (map #(* % %) *thread-local-state*))
(100 400)
user> (with-bindings {(var *thread-local-state*) [10 20]}
 (map #(* % %) *thread-local-state*))
(100 400)

我们可以使用 thread-bound? 断言来检查变量是否绑定到当前执行线程中的任何值,这需要一个变量作为其唯一参数:

user> (def ^:dynamic *unbound-var*)
#'user/*unbound-var*
user> (thread-bound? (var *unbound-var*))
false
user> (binding [*unbound-var* 1]
 (thread-bound? (var *unbound-var*)))
true

我们也可以使用 with-local-vars 形式定义非内联变量,或称为 局部变量。这些变量不会通过命名空间系统解析,必须使用 var-getvar-set 函数手动访问。因此,这些函数可以用来创建和访问可变变量,如 示例 2.5 所示。

注意

使用非内联变量与 @ 符号相同,与使用 var-get 函数相同。例如,如果 x 是一个非内联变量,则 @x 等同于 (var-get x)

(defn factorial [n]
  (with-local-vars [i n acc 1]
    (while (> @i 0)
      (var-set acc (* @acc @i))
      (var-set i (dec @i)))
    (var-get acc)))

示例 2.5:使用 with-local-vars 形式创建的可变变量

示例 2.5 中定义的 factorial 函数使用两个可变局部变量 iacc 来计算 n 的阶乘,分别初始化为 n1。请注意,此函数中的代码展示了命令式编程风格,其中使用 var-getvar-set 函数操纵变量 iacc 的状态。

注意

我们可以使用 var? 断言检查是否通过 with-local-vars 形式创建了值。

使用引用

软件事务内存 (STM) 系统也可以用来模拟可变状态。STM 实质上将可变状态视为一个位于程序内存中的小型数据库。Clojure 通过 refs 提供了 STM 实现,并且它们只能在事务中更改。Refs 是一种表示 同步协调 状态的引用类型。

注意

以下示例可以在书籍源代码的 src/m_clj/c2/refs.clj 文件中找到。

我们可以使用 ref 函数创建一个引用,该函数需要一个参数来指示引用的初始状态。例如,我们可以创建一个引用如下:

(def state (ref 0))

这里定义的变量 state 代表一个初始值为 0 的引用。我们可以使用 @deref 来解引用 state,以获取其中包含的值。

为了修改一个引用(ref),我们必须通过使用dosync形式启动一个事务。如果两个并发任务同时使用dosync形式调用事务,那么首先完成的事务将成功更新引用。较晚完成的事务将重试,直到成功完成。因此,在dosync形式内必须避免 I/O 和其他副作用,因为它可以被重试。在事务中,我们可以使用ref-set函数来修改引用的值。此函数接受两个参数——一个引用和表示引用新状态的值。ref-set函数可以用来修改引用,如下所示:

user> @state
0
user> (dosync (ref-set state 1))
1
user> @state
1

初始时,表达式@state返回0,这是引用state的初始状态。在dosync形式内的ref-set调用之后,此表达式的返回值将发生变化。

我们可以通过使用ensure函数来获取引用中包含的最新值。此函数返回引用的最新值,必须在事务中调用。例如,当在由dosync形式启动的事务中调用表达式(ensure state)时,将返回事务中引用state的最新值。

修改给定引用的一个更自然的做法是使用altercommute函数。这两个函数都需要一个引用和一个作为参数传递给它的函数。altercommute函数将应用提供的函数到给定引用中包含的值,并将结果值保存到引用中。我们还可以指定传递给提供的函数的额外参数。例如,我们可以使用altercommute修改state引用的状态,如下所示:

user> @state
1
user> (dosync (alter state + 2))
3
user> (dosync (commute state + 2))
5

使用altercommute形式的前一个事务将值(+ @state 2)保存到引用state中。altercommute之间的主要区别在于,当提供的函数是交换律时,必须优先选择commute形式。这意味着对commute形式提供的函数的连续两次调用必须产生相同的结果,无论这两次调用的顺序如何。使用commute形式被认为是相对于我们不在意给定引用上并发事务顺序的alter形式的优化。

注意

ref-setaltercommute函数都返回提供的引用中包含的新值。此外,如果它们不在dosync形式内调用,这些函数将抛出错误。

altercommute形式执行的变异也可以进行验证。这是通过在创建引用时使用:validator键选项来实现的,如下所示:

user> (def r (ref 1 :validator pos?))
#'user/r
user> (dosync (alter r (fn [_] -1)))
IllegalStateException Invalid reference state  clojure.lang.ARef.validate (ARef.java:33)
user> (dosync (alter r (fn [_] 2)))
2

如前所述,当我们尝试将引用r的状态更改为负值时,它会抛出异常。这是因为使用了pos?函数来验证引用的新状态。请注意,:validator键选项也可以用于其他引用类型。我们还可以使用set-validator!函数设置未使用:validator键选项创建的引用的验证函数。

注意

:validator键选项和set-validator!函数可以与所有引用类型一起使用。提供的验证函数必须返回false或抛出异常以指示验证错误。

用餐哲学家问题描述了使用同步原语来共享资源的使用。这个问题可以这样定义:五位哲学家围坐在一张圆桌旁吃意大利面,每位哲学家需要两根叉子才能从他的盘子里吃面。桌上有五根叉子,放在五位哲学家之间。哲学家必须首先从他的左边和右边各拿起一根叉子,然后才能开始吃饭。当一个哲学家无法获得他左右两侧的两个叉子时,他必须等待直到两个叉子都可用。当一个哲学家吃完他的意大利面后,他会思考一段时间,从而允许其他哲学家使用他使用的叉子。这个问题的解决方案要求所有哲学家共享叉子,并且没有哲学家因为无法获得两个叉子而饿死。五位哲学家的盘子和叉子按照以下图示放置在桌子上:

使用引用

哲学家在开始吃饭之前必须获得他左右两侧叉子的独家访问权。如果两个叉子都不可用,哲学家必须等待一段时间,直到其中一个叉子变得空闲,然后重试获取叉子。这样,每个哲学家都可以与其他哲学家协同访问叉子,避免饿死。

通常,这个解决方案可以通过使用同步原语来访问可用的叉子来实现。引用允许我们实现一个无需任何同步原语的用餐哲学家问题的解决方案。现在,我们将展示如何在 Clojure 中实现并模拟这个问题的解决方案。首先,我们必须定义叉子和哲学家的状态为引用,如示例 2.6所示:

(defn make-fork []
  (ref true))

(defn make-philosopher [name forks food]
  (ref {:name name
        :forks forks
        :eating? false
        :food food}))

示例 2.6:使用引用的用餐哲学家问题

make-forkmake-philosopher函数创建引用来表示叉子和哲学家的状态。一个叉子简单地表示一个布尔值的状态,指示它是否可用。而由make-philosopher函数创建的哲学家是一个封装为状态的映射,它具有以下键:

  • :name键包含一个哲学家的名字,它是一个字符串值。

  • :forks 键指向哲学家左右两侧的叉子。每个叉子都将由 make-fork 函数创建的引用(ref)表示。

  • :eating? 键表示哲学家是否正在进食。它是一个布尔值。

  • :food 键表示哲学家可用的食物数量。为了简单起见,我们将此值视为一个整数。

现在,让我们定义一些原始操作来帮助处理叉子,如 示例 2.7 所示:

(defn has-forks? [p]
  (every? true? (map ensure (:forks @p))))

(defn update-forks [p]
  (doseq [f (:forks @p)]
    (commute f not))
  p)

示例 2.7: 使用引用解决就餐哲学家问题(继续)

之前定义的 has-forks? 函数检查放置在给定哲学家引用 p 左右两侧的叉子是否可用。update-forks 函数将使用 commute 形式修改哲学家引用 p 相关的叉子的状态,并返回引用 p。显然,这些函数只能在由 dosync 形式创建的事务中调用,因为它们使用了 ensurecommute 函数。接下来,我们将不得不定义一些函数来启动事务,并为给定的哲学家调用 has-forks?update-forks 函数,如 示例 2.8 所示:

(defn start-eating [p]
  (dosync
   (when (has-forks? p)
     (update-forks p)
     (commute p assoc :eating? true)
     (commute p update-in [:food] dec))))

(defn stop-eating [p]
  (dosync
   (when (:eating? @p)
     (commute p assoc :eating? false)
     (update-forks p))))

(defn dine [p retry-ms max-eat-ms max-think-ms]
  (while (pos? (:food @p))
    (if (start-eating p)
      (do
        (Thread/sleep (rand-int max-eat-ms))
        (stop-eating p)
        (Thread/sleep (rand-int max-think-ms)))
      (Thread/sleep retry-ms))))

示例 2.8: 使用引用解决就餐哲学家问题(继续)

解决就餐哲学家问题的核心是 示例 2.8 中的 start-eating 函数。此函数将使用 has-forks? 函数检查哲学家两侧的叉子是否可用。然后,start-eating 函数将调用 update-forks 函数来更新这些叉子的状态。start-eating 函数还将通过调用 assocupdate-in 函数(这两个函数都返回一个新的映射)来改变哲学家引用 p 的状态,从而使用 commute。由于 start-eating 函数使用了 when 形式,当任何哲学家的叉子不可用时,它将返回 nil。这些步骤就是解决方案;简而言之,哲学家只有在他的两个叉子都可用时才会进食。

示例 2.8 中的 stop-eating 函数在 start-eating 函数被调用后反转给定哲学家引用的状态。此函数基本上使用 commute 形式将提供的哲学家引用 p 中包含的映射的 :eating 键设置为 false,然后调用 update-forks 来重置哲学家引用 p 相关的叉子的状态。

start-eatingstop-eating 函数可以通过 while 形式在循环中重复调用,只要哲学家的引用 p:food 键(或者说是可用的食物量)是一个正数。这是通过 示例 2.8 中的 dine 函数来执行的。该函数将在哲学家引用 p 上调用 start-eating 函数,如果哲学家的叉子被其他哲学家使用,则等待一段时间。哲学家等待的时间由传递给 dine 函数的 retry-ms 参数指示。如果哲学家的叉子可用,他将吃一个随机的时间,如表达式 (rand-int max-eat-ms) 所示。然后,调用 stop-eating 函数来重置哲学家引用 p 和它包含的叉子的状态。最后,dine 函数等待一个随机的时间,这由 (rand-int max-think-ms) 表达式表示,以表示哲学家正在思考。

现在,让我们定义一些函数,并实际创建一些代表哲学家和相关叉子的引用,如 示例 2.9 所示:

(defn init-forks [nf]
  (repeatedly nf #(make-fork)))

(defn init-philosophers [np food forks init-fn]
  (let [p-range (range np)
        p-names (map #(str "Philosopher " (inc %))
                     p-range)
        p-forks (map #(vector (nth forks %)
                              (nth forks (-> % inc (mod np))))
                     p-range)
        p-food (cycle [food])]
    (map init-fn p-names p-forks p-food)))

示例 2.9:使用引用的用餐哲学家问题(继续)

示例 2.9 中的 init-forks 函数将简单地根据其参数 nf 调用 make-fork 函数多次。init-philosophers 函数将创建 np 个哲学家,并将每个哲学家与一个包含两个叉子和一定量食物的向量相关联。这是通过将函数 init-fn(该函数与 示例 2.6 中的 make-philosopher 函数的阶数相匹配)映射到哲学家名称 p-names 和叉子 p-forks 的范围以及值 food 的无限范围 p-food 来实现的。

我们现在定义一个函数来打印一系列哲学家的集体状态。这可以通过使用 doseq 函数以相当简单的方式进行,如 示例 2.10 所示:

(defn check-philosophers [philosophers forks]
  (doseq [i (range (count philosophers))]
    (println (str "Fork:\t\t\t available=" @(nth forks i)))
    (if-let [p @(nth philosophers i)]
      (println (str (:name p)
                    ":\t\t eating=" (:eating? p)
                    " food=" (:food p))))))

示例 2.10:使用引用的用餐哲学家问题(继续)

示例 2.10 中的 check-philosophers 函数遍历其提供的所有哲学家引用,由 philosophers 表示,以及相关的叉子,由 forks 表示,并打印它们的状态。这里使用 if-let 形式来检查集合 philosophers 中的解引用引用是否不是 nil

现在,让我们定义一个函数来并发调用哲学家集合上的 dine 函数。这个函数也可以传递 dine 函数的 retry-msmax-eat-msmax-think-ms 参数值。这在 示例 2.11 中的 dine-philosophers 函数中实现:

(defn dine-philosophers [philosophers]
  (doall (for [p philosophers]
           (future (dine p 10 100 100))))

示例 2.11:使用引用的用餐哲学家问题(继续)

最后,让我们使用 init-forksinit-philosophersmake-philosopher 函数定义五个哲学家的实例和五个相关的叉子,如 示例 2.12 所示如下:

(def all-forks (init-forks 5))

(def all-philosophers
  (init-philosophers 5 1000 all-forks make-philosopher))

示例 2.12:使用 refs 解决就餐哲学家问题(续)

现在我们可以使用 check-philosopher 函数来打印在 示例 2.12 中创建的哲学家状态和叉子引用,如下所示:

user> (check-philosophers all-philosophers all-forks)
Fork:                         available=true
Philosopher 1:                eating=false food=1000
Fork:                         available=true
Philosopher 2:                eating=false food=1000
Fork:                         available=true
Philosopher 3:                eating=false food=1000
Fork:                         available=true
Philosopher 4:                eating=false food=1000
Fork:                         available=true
Philosopher 5:                eating=false food=1000
nil

初始时,所有叉子都是可用的,没有哲学家在吃饭。为了开始模拟,我们必须在 all-philosophers 哲学家引用和 all-forks 叉子引用上调用 dine-philosophers 函数,如下所示:

user> (def philosophers-futures (dine-philosophers all-philosophers))
#'user/philosophers-futures
user> (check-philosophers all-philosophers all-forks)
Fork:                         available=false
Philosopher 1:                eating=true food=978
Fork:                         available=false
Philosopher 2:                eating=false food=979
Fork:                         available=false
Philosopher 3:                eating=true food=977
Fork:                         available=false
Philosopher 4:                eating=false food=980
Fork:                         available=true
Philosopher 5:                eating=false food=980
nil

在调用 dine-philosophers 函数后,每个哲学家都会观察到他们消费分配的食物,如前一个 check-philosophers 函数的输出所示。在任何给定的时间点,都会观察到一位或两位哲学家在吃饭,其他哲学家将等待他们完成使用可用的叉子。随后的 check-philosophers 函数调用也指示相同的输出,哲学家最终会消费所有分配的食物:

user> (check-philosophers all-philosophers all-forks)
Fork:                         available=true
Philosopher 1:                eating=false food=932
Fork:                         available=true
Philosopher 2:                eating=false food=935
Fork:                         available=true
Philosopher 3:                eating=false food=933
Fork:                         available=true
Philosopher 4:                eating=false food=942
Fork:                         available=true
Philosopher 5:                eating=false food=935
nil

我们可以通过调用 future-cancel 函数来暂停模拟,如下所示。一旦模拟被暂停,可以通过再次调用 dine-philosophers 函数来恢复,如 (dine-philosophers all-philosophers)

user> (map future-cancel philosophers-futures)
(true true true true true)

总结来说,前面的示例是使用 Clojure 的 futures 和 refs 解决就餐哲学家问题的简洁且可行的实现。

使用原子

原子 用于处理原子性变化的州。一旦原子被修改,其新值将在所有并发线程中反映出来。这样,原子代表 同步独立 的状态。让我们快速探索可以用来处理原子的函数。

注意

以下示例可以在书籍源代码的 src/m_clj/c2/atoms.clj 中找到。

我们可以使用 atom 函数定义一个原子,该函数需要将原子的初始状态作为第一个参数传递给它,如下所示:

(def state (atom 0))

reset!swap! 函数可以用来修改原子的状态。reset! 函数用于直接设置原子的状态。此函数接受两个参数——一个原子和表示原子新状态的值,如下所示:

user> @state
0
user> (reset! state 1)
1
user> @state
1

swap! 函数需要一个函数以及传递给该函数的额外参数。提供的函数将应用于原子中包含的值,以及传递给 swap! 函数的其他额外参数。因此,可以使用提供的函数来使用原子进行变异,如下所示:

user> @state
1
user> (swap! state + 2)
3

前面的 swap! 函数调用将原子的状态设置为表达式 (+ @state 2) 的结果。由于对原子 stateswap! 函数的并发调用,swap! 函数可能会多次调用函数 +。因此,传递给 swap! 函数的函数必须是无 I/O 和其他副作用。

注意

reset!swap! 函数都返回所提供原子中包含的新值。

我们可以使用 add-watch 函数监视原子以及其他引用类型的任何变化。此函数会在原子状态改变时调用给定的函数。add-watch 函数接受三个参数——一个引用、一个键和一个 监视函数,即每当提供的引用类型的状态改变时必须调用的函数。传递给 add-watch 函数的函数必须接受四个参数——一个键、被更改的引用、引用的旧值和引用的新值。传递给 add-watch 函数的键的值作为第一个参数传递给 watch 函数。watch 函数还可以使用 remove-watch 函数从给定的引用类型中解除链接。remove-watch 函数接受两个参数——一个引用和一个在向引用添加 watch 函数时指定的键。示例 2.13 展示了我们可以如何使用 watch 函数跟踪原子的状态:

(defn make-state-with-watch []
  (let [state (atom 0)
        state-is-changed? (atom false)
        watch-fn (fn [key r old-value new-value]
                   (swap! state-is-changed? (fn [_] true))]
    (add-watch state nil watch-fn)
    [state
     state-is-changed?]))

示例 2.13:使用 add-watch 函数

示例 2.13 中定义的 make-state-with-watch 函数返回一个包含两个原子的向量。在这个向量中,第二个原子最初包含的值是 false。每当 make-state-with-watch 函数返回的向量中第一个原子的状态发生变化时,这个向量中第二个原子的状态就会变为 true。这可以在 REPL 中验证,如下所示:

user> (def s (make-state-with-watch))
#'user/s
user> @(nth s 1)
false
user> (swap! (nth s 0) inc)
1
user> @(nth s 1)
true

因此,可以使用 add-watch 函数与 watch 函数一起使用,以跟踪原子和其他引用类型的状态。

注意

add-watch 函数可以与 所有 引用类型一起使用。

使用代理

代理 用于表示与动作队列和工作者线程池相关联的状态。任何修改代理状态的动作都必须发送到其队列中,并且提供的函数将由从代理的工作者线程池中选择的线程调用。我们也可以异步地向代理发送动作。因此,代理表示 异步独立 的状态。

注意

以下示例可以在书籍源代码的 src/m_clj/c2/agents.clj 中找到。

使用 agent 函数创建代理。例如,我们可以创建一个代理,其初始值为空映射,如下所示:

(def state (agent {}))

我们可以通过使用 sendsend-off 函数来修改代理的状态。sendsend-off 函数将以异步方式将提供的动作及其附加参数发送到代理的队列中。这两个函数都会立即返回传递给它们的代理。

sendsend-off 函数之间的主要区别在于,send 函数将动作分配给从工作线程池中选择的一个线程,而 send-off 函数为每个动作创建一个新的专用线程来执行。使用 send 函数发送到代理的阻塞动作可能会耗尽代理的线程池。因此,对于发送阻塞动作到代理,send-off 函数是首选。

为了演示 sendsend-off 函数,我们首先定义一个返回闭包的函数,该闭包会休眠一定的时间,然后调用 assoc 函数,如下所示 示例 2.14

(defn set-value-in-ms [n ms]
  (fn [a]
    (Thread/sleep ms)
    (assoc a :value n)))

示例 2.14:一个返回闭包的函数,该闭包会休眠并调用 assoc

示例 2.14set-value-in-ms 函数返回的闭包可以作为动作传递给 sendsend-off 函数,如下所示:

user> (send state (set-value-in-ms 5 5000))
#<Agent@7fce18: {}>
user> (send-off state (set-value-in-ms 10 5000))
#<Agent@7fce18: {}>
user> @state
{}
user> @state ; after 5 seconds
{:value 5}
user> @state ; after another 5 seconds
{:value 10}

对前面的 sendsend-off 函数的调用将异步地通过代理 state 调用 set-value-in-ms 函数(示例 2.14)返回的闭包。代理的状态在 10 秒内发生变化,这是执行 set-value-in-ms 函数返回的闭包所必需的。观察到新的键值对 {:value 5} 在五秒后被保存到代理 state 中,然后在另一个五秒后,代理的状态再次变为 {:value 10}

传递给 sendsend-off 函数的任何动作都可以使用 *agent* 变量通过执行动作的代理来访问。

可以使用 await 函数等待代理队列中的所有动作完成,如下所示:

user> (send-off state (set-value-in-ms 100 3000))
#<Agent@af9ac: {:value 10}>
user> (await state)  ; will block
nil
user> @state
{:value 100}

观察到表达式 (await state) 会在使用 send-off 函数将动作发送到代理 state 的上一个动作完成之前被阻塞。await-for 函数是 await 的一个变体,它等待由其第一个参数指定的一定数量的毫秒数,以便完成代理(其第二个参数)上的所有动作。

代理还会保存它在执行队列中的动作时遇到的任何错误。代理会在对 sendsend-off 函数的任何后续调用中抛出它遇到的错误。代理保存的错误可以通过 agent-error 函数访问,并且可以使用 clear-agent-errors 函数清除,如下所示:

user> (def a (agent 1))
#'user/a
user> (send a / 0)
#<Agent@5d29f1: 1>
user> (agent-error a)
#<ArithmeticException java.lang.ArithmeticException: Divide by zero>
user> (clear-agent-errors a)
1
user> (agent-error a)
nil
user> @a
1

遇到错误的代理也可以使用 restart-agent 函数重新启动。此函数将其第一个参数作为代理,第二个参数作为代理的新状态。一旦在代理上调用 restart-agent,之前发送到代理的所有动作都将执行。我们可以通过将 :clear-actions true 可选参数传递给 restart-agent 函数来避免这种行为。在这种情况下,在重新启动之前,代理队列中持有的任何动作都将被丢弃。

要创建一个用于代理的线程池或threadpool,我们必须通过传递池中期望的线程数作为参数来调用java.util.concurrent.Executors类的静态newFixedThreadPool方法,如下所示:

(def pool (java.util.concurrent.Executors/newFixedThreadPool 10))

可以使用之前定义的线程池通过send-via函数来执行代理的动作。这个函数是send函数的一个变体,它接受一个线程池作为其第一个参数,如以下所示:

user> (send-via pool state assoc :value 1000)
#<Agent@8efada: {:value 100}>
user> @state
{:value 1000}

我们还可以使用set-agent-send-executor!set-agent-send-off-executor!函数分别指定所有代理执行发送给它们的动作所使用的线程池。这两个函数都接受一个表示线程池的单个参数。

可以通过调用(shutdown-agents)来停止当前进程中的所有代理。shutdown-agents函数应该在退出进程之前调用,因为在此函数调用之后无法重新启动进程中的代理。

现在,让我们尝试使用代理实现 dining philosophers problem。我们可以重用之前基于 refs 的 dining philosophers problem 实现中的大多数函数。让我们定义一些函数来使用代理模拟这个问题,如示例 2.15所示:

(defn make-philosopher-agent [name forks food]
  (agent {:name name
          :forks forks
          :eating? false
          :food food}))

(defn start-eating [max-eat-ms]
  (dosync (if (has-forks? *agent*)
            (do
              (-> *agent*
                  update-forks
                  (send assoc :eating? true)
                  (send update-in [:food] dec))
              (Thread/sleep (rand-int max-eat-ms))))))

(defn stop-eating [max-think-ms]
  (dosync (-> *agent*
              (send assoc :eating? false)
              update-forks))
  (Thread/sleep (rand-int max-think-ms)))

(def running? (atom true))

(defn dine [p max-eat-ms max-think-ms]
  (when (and p (pos? (:food p)))
    (if-not (:eating? p)
      (start-eating max-eat-ms)
      (stop-eating max-think-ms))
    (if-not @running?
      @*agent*
      @(send-off *agent* dine max-eat-ms max-think-ms))))

(defn dine-philosophers [philosophers]
  (swap! running? (fn [_] true))
  (doall (for [p philosophers]
           (send-off p dine 100 100))))

(defn stop-philosophers []
  (swap! running? (fn [_] false)))

示例 2.15:使用代理的 dining philosophers problem

示例 2.15中,make-philosopher-agent函数将创建一个代表哲学家的代理。该代理的初始状态是一个包含键:name:forks:eating?:food的映射,正如之前 dining philosophers problem 的实现所描述。请注意,在这个实现中,叉子仍然由 refs 表示。

示例 2.15中的start-eating函数将启动一个事务,检查哲学家左右两侧放置的叉子是否可用,相应地更改叉子和哲学家代理的状态,然后暂停当前线程一段时间以表示哲学家正在吃饭。示例 2.15中的stop-eating函数将类似地更新哲学家的状态和他所使用的叉子的状态,然后暂停当前线程一段时间以表示哲学家正在思考。请注意,start-eatingstop-eating函数都重用了之前 dining philosophers problem 实现的示例 2.7中的has-forks?update-forks函数。

start-eatingstop-eating 函数由 Example 2.15 中的 dine 函数调用。我们可以假设这个函数将被传递给一个哲学家代理作为动作。这个函数检查哲学家代理中包含的 :eating? 键的值,以决定是否必须在当前调用中调用 start-eatingstop-eating 函数。接下来,dine 函数再次使用 send-off 函数调用自身,并取消引用 send-off 函数返回的代理。dine 函数还检查 running? 原子的状态,如果 @running 返回 false,则不会通过 send-off 函数调用自身。

Example 2.15 中的 dine-philosophers 函数通过将 running? 原子的值设置为 true 来启动模拟,然后通过 send-off 函数异步调用 dine 函数,该函数为传递给它的所有哲学家代理执行,这些代理由 philosophers 表示。stop-philosophers 函数简单地将 running? 原子的值设置为 false,从而停止模拟。

最后,让我们使用 Example 2.9 中的 init-forksinit-philosophers 函数定义五个叉子和哲学家实例,如 Example 2.16 中所示,如下所示:

(def all-forks (init-forks 5))

(def all-philosophers
  (init-philosophers 5 1000 all-forks make-philosopher-agent))

Example 2.16: 使用代理解决就餐哲学家问题(继续)

我们现在可以通过调用 dine-philosophers 函数来启动模拟。此外,我们可以使用在 Example 2.10 中定义的 check-philosophers 函数来打印模拟中叉子和哲学家实例的集体状态,如下所示:

user> (def philosophers-agents (dine-philosophers all-philosophers))
#'user/philosophers-agents
user> (check-philosophers all-philosophers all-forks)
Fork:                    available=false
Philosopher 1:           eating=false food=936
Fork:                    available=false
Philosopher 2:           eating=false food=942
Fork:                    available=true
Philosopher 3:           eating=true food=942
Fork:                    available=true
Philosopher 4:           eating=false food=935
Fork:                    available=true
Philosopher 5:           eating=true food=943
nil
user> (check-philosophers all-philosophers all-forks)
Fork:                    available=false
Philosopher 1:           eating=true food=743
Fork:                    available=false
Philosopher 2:           eating=false food=747
Fork:                    available=true
Philosopher 3:           eating=false food=751
Fork:                    available=true
Philosopher 4:           eating=false food=741
Fork:                    available=true
Philosopher 5:           eating=false food=760
nil

如前所述的输出所示,所有哲学家代理在彼此之间共享叉子实例。实际上,他们协同工作以确保每个哲学家最终消费掉分配给他们的所有食物。

总结来说,变量、引用、原子和代理可以用来表示在并发执行的任务之间共享的可变状态。

并行执行任务

同时执行多个计算被称为 并行性。使用并行性往往可以提高计算的总体性能,因为计算可以被分割以在多个核心或处理器上执行。Clojure 有几个函数可以用于特定计算或任务的并行化,我们将在本节中简要介绍它们。

注意

以下示例可以在书籍源代码的 src/m_clj/c2/parallel.clj 中找到。

假设我们有一个函数,该函数暂停当前线程一段时间,然后返回一个计算值,如 Example 2.17 所示:

(defn square-slowly [x]
  (Thread/sleep 2000)
  (* x x))

Example 2.17: 暂停当前线程的函数

示例 2.17中的square-slowly函数需要一个单个参数x。这个函数暂停当前线程两秒钟,并返回其参数x的平方。如果使用map函数在三个值的集合上调用square-slowly函数,它完成所需的时间是三倍,如下所示:

user> (time (doall (map square-slowly (repeat 3 10))))
"Elapsed time: 6000.329702 msecs"
(100 100 100)

之前展示的map形式返回一个惰性序列,因此需要使用doall形式来实现由map形式返回的值。我们也可以使用dorun形式来实现这个惰性序列的实例化。整个表达式大约在六秒内完成评估,这是square-slowly函数完成所需时间的三倍。我们可以使用pmap函数而不是map函数来并行化square-slowly函数的应用,如下所示:

user> (time (doall (pmap square-slowly (repeat 3 10))))
"Elapsed time: 2001.543439 msecs"
(100 100 100)

整个表达式现在评估所需的时间与对square-slowly函数的单次调用所需的时间相同。这是由于square-slowly函数通过pmap形式在提供的集合上并行调用。因此,pmap形式与map形式具有相同的语义,除了它并行应用提供的函数。

pvaluespcalls形式也可以用来并行化计算。pvalues形式并行评估传递给它的表达式,并返回一个包含结果的惰性序列。同样,pcalls形式并行调用传递给它的所有函数,这些函数必须不接受任何参数,并返回一个包含这些函数返回值的惰性序列:

user> (time (doall (pvalues (square-slowly 10)
 (square-slowly 10)
 (square-slowly 10))))
"Elapsed time: 2007.702703 msecs"
(100 100 100)
user> (time (doall (pcalls #(square-slowly 10)
 #(square-slowly 10)
 #(square-slowly 10))))
"Elapsed time: 2005.683279 msecs"
(100 100 100)

如前所述的输出所示,使用pvaluespcalls形式的两个表达式评估所需的时间与对square-slowly函数的单次调用相同。

注意

pmappvaluespcalls形式返回需要使用doalldorun形式来实现的惰性序列。

使用线程池控制并行性

pmap形式在默认线程池上调度提供的函数的并行执行。如果我们希望配置或调整pmap使用的线程池,claypoole库(github.com/TheClimateCorporation/claypoole)是一个不错的选择。这个库提供了一个必须传递可配置线程池的pmap形式的实现。我们现在将展示如何使用这个库来并行化给定函数。

注意

下面的库依赖项对于即将到来的示例是必需的:

[com.climate/claypoole "1.0.0"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [com.climate.claypoole :as cp]
            [com.climate.claypoole.lazy :as cpl]))

来自com.climate.claypoole命名空间的pmap函数本质上是我们提供的用于并行化给定函数的线程池实例的标准pmap函数的一个变体。我们还可以为这个pmap函数的变体提供要使用的线程数,以便并行化给定函数,如下所示:

user> (time (doall (cpl/pmap 2 square-slowly [10 10 10])))
"Elapsed time: 4004.029789 msecs"
(100 100 100)

如前所示,claypoole库中的pmap函数可以用来并行化我们在示例 2.17中定义的square-slowly函数,在三个值的集合上。这三个元素在两个批次中计算,每个批次将并行地在两个不同的线程中应用square-slowly函数。由于square-slowly函数需要两秒钟才能完成,计算三个元素集合所需的总时间大约是四秒钟。

我们可以使用claypoole库中的threadpool函数创建一个线程池的实例。然后,这个线程池实例可以传递给claypoole库中的pmap函数。com.climate.claypoole命名空间还提供了一个ncpus函数,该函数返回当前进程可用的物理处理器数量。我们可以创建一个线程池实例并将其传递给这个pmap函数的变体,如下所示:

user> (def pool (cp/threadpool (cp/ncpus)))
#'user/pool
user> (time (doall (cpl/pmap pool square-slowly [10 10 10])))
"Elapsed time: 4002.05885 msecs"
(100 100 100)

假设我们正在一个具有两个物理处理器的计算机系统上运行前面的代码,前面显示的threadpool函数调用将创建一个包含两个线程的线程池。然后,这个线程池实例可以像前面示例中那样传递给pmap函数。

注意

我们可以通过将:builtin关键字作为com.climate.claypoole/pmap函数的第一个参数传递,来回退到pmap函数的标准行为。同样,如果将:serial关键字作为claypoole版本的pmap函数的第一个参数传递,该函数的行为将类似于标准的map函数。

threadpool函数还支持一些有用的关键选项。首先,我们可以使用:daemon false可选参数创建一个非守护线程池。守护线程在进程退出时会被终止,并且threadpool函数默认创建的线程池都是守护线程池。我们还可以使用threadpool函数的:name关键选项来命名一个线程池。:thread-priority关键选项可以用来指定新线程池中线程的优先级。

使用claypoole库中的pmappriority-threadpoolwith-priority形式也可以对任务进行优先级排序。使用priority-threadpool函数创建一个优先级线程池,然后可以使用with-priority函数与这个新的线程池一起使用,为必须使用pmap并行化的任务分配一个优先级,如下所示:

user> (def pool (cp/priority-threadpool (cp/ncpus))
#'user/pool
user> (def task-1 (cp/pmap (cp/with-priority pool 1000)
 square-slowly [10 10 10]))
#'user/task-1
user> (def task-2 (cp/pmap (cp/with-priority pool 0)
 square-slowly [5 5 5]))
#'user/task-2

优先级较高的任务会首先分配给线程。因此,在先前的输出中,代表task-1的任务将先于代表task-2的任务分配给一个执行线程。

为了优雅地释放给定的线程池,我们可以从com.climate.claypoole命名空间调用shutdown函数,该函数接受一个线程池实例作为其唯一参数。来自同一命名空间的shutdown!函数将强制关闭线程池中的线程。shutdown!函数也可以使用with-shutdown!宏来调用。我们指定用于一系列计算的线程池作为绑定到with-shutdown!宏的绑定向量。此宏将在此宏体中的所有计算完成后,隐式调用它所创建的所有线程池的shutdown!函数。例如,我们可以定义一个创建线程池的函数,用它进行计算,并最终使用with-shutdown!函数关闭线程池,如示例 2.18所示:

(defn square-slowly-with-pool [v]
  (cp/with-shutdown! [pool (cp/threadpool (cp/ncpus))]
    (doall (cp/pmap pool square-slowly v))))

示例 2.18:使用优先级线程池

示例 2.18中定义的square-slowly-with-pool函数将创建一个新的线程池,表示为pool,然后使用它来调用pmap函数。一旦doall形式完全评估了pmap函数返回的惰性序列,就会隐式调用shutdown!函数。

claypoole库还支持无序并行性,其中计算线程的结果一旦可用就被用来最小化延迟。com.climate.claypoole/upmap函数是pmap函数的无序并行版本。

com.climate.claypoole命名空间还提供了其他一些使用线程池的函数,如这里所述:

  • com.climate.claypoole/pvalues函数是pvalues函数的基于线程池的实现。它将使用提供的线程池并行评估其参数,并返回一个惰性序列。

  • com.climate.claypoole/pcalls函数是pcalls函数的基于线程池的版本,它调用几个无参数函数以返回一个惰性序列。

  • 可以使用com.climate.claypoole/future函数创建使用给定线程池的未来。

  • 我们可以使用com.climate.claypoole/pfor函数在给定集合的项上以并行方式评估一个表达式。

  • com.climate.claypoole命名空间中的upvaluesupcallsupfor函数分别是来自同一命名空间的pvaluespcallspfor函数的无序并行版本。

很明显,来自com.climate.claypoole命名空间的pmap函数会急切地评估它提供的集合。当我们打算在无限序列上调用pmap时,这可能是不可取的。com.climate.claypoole.lazy命名空间提供了pmap和其他来自com.climate.claypoole命名空间的函数的版本,这些版本保留了提供的集合的惰性。pmap函数的惰性版本可以如下演示:

user> (def lazy-pmap (cpl/pmap pool square-slowly (range)))
#'user/lazy-pmap
user> (time (doall (take 4 lazy-pmap)))
"Elapsed time: 4002.556548 msecs"
(0 1 4 9)

之前定义的lazy-pmap序列是通过将square-slowly函数映射到无限序列(range)创建的惰性序列。如前所述,对pmap函数的调用会立即返回,并且结果惰性序列的前四个元素是通过doalltake函数并行实现的。

总结来说,Clojure 有pmappvaluespcalls原语来处理并行计算。如果我们打算控制这些函数使用的并行程度,我们可以使用claypoole库对这些原语的实现。claypoole库还支持其他有用的功能,如优先级线程池和无序并行性。

摘要

我们已经探讨了可以用来在 Clojure 中创建并发和并行任务的多种结构。你学习了如何通过使用引用类型,即 vars、refs、atoms 和 agents 来处理共享可变状态。正如我们之前所描述的,可以使用 refs 和 agents 轻松实现就餐哲学家问题。你还学习了如何并行执行任务。最后,我们探讨了claypoole库,它允许我们控制给定计算使用的并行程度。

在下一章中,我们将继续通过使用归约器来探索 Clojure 中的并行性。

第三章. 使用归约器并行化

归约器是另一种在 Clojure 中看待集合的方法。在本章中,我们将研究这种特定的集合抽象,以及它与将集合视为序列的视角是如何正交的。归约器的动机是提高集合计算的性能。这种性能提升主要是通过并行化这些计算来实现的。

如我们在第一章中看到的,使用序列和模式,序列和惰性是处理集合的绝佳方式。Clojure 标准库提供了几个函数来处理和操作序列。然而,将集合抽象为序列有一个不幸的后果;对序列所有元素执行的计算本质上都是顺序的。此外,所有标准序列函数都会创建一个新的集合,它与传递给这些函数的集合相似。有趣的是,在没有创建类似集合的情况下,即使作为中间结果,对集合执行计算也是非常有用的。例如,通常需要通过一系列迭代转换将给定的集合缩减为一个单一值。这种计算不一定需要保存每个转换的中间结果。

从集合迭代计算值的一个后果是我们不能直接并行化它。现代MapReduce框架通过并行地将集合的元素通过几个转换进行管道传输来处理这类计算,最后将结果归约为一个单一结果。当然,结果也可以是一个新的集合。这种方法的一个缺点是它产生了每个转换的中间结果的实体集合,这相当浪费。例如,如果我们想从集合中过滤掉值,MapReduce 策略将需要创建空集合来表示在归约步骤中未被包含的值,以产生最终结果。

这会产生不必要的内存分配,并为生成最终结果的归约步骤创建额外的工作。因此,优化这类计算是有空间的。

这引出了将集合上的计算视为归约器以获得更好性能的概念。当然,这并不意味着归约器可以替代序列。序列和惰性对于抽象创建和操作集合的计算非常有用,而归约器是集合的专门高性能抽象,其中集合需要通过几个转换进行管道传输,最后合并以产生最终结果。归约器通过以下方式实现性能提升:

  • 减少分配给产生所需结果的内存量

  • 并行化将集合归约为一个单一结果的过程,这个结果可能是一个全新的集合

clojure.core.reducers命名空间提供了几个函数,用于使用归约器处理集合。现在让我们来检查归约器的实现方式,以及一些演示如何使用归约器的示例。

使用归约来转换集合

序列以及操作序列的函数会保留元素之间的顺序。惰性序列在需要计算时才避免集合中元素的无效实现,但这些值的实现仍然以顺序方式进行。然而,这种顺序特性可能不是所有在它上执行的计算都希望拥有的。例如,不可能在向量上应用函数并按顺序惰性实现结果集合中的值;因为map函数将提供的集合转换为序列。此外,mapfilter等函数虽然是惰性的,但本质上仍然是顺序的。

序列有什么问题?

序列的一个局限性是它们在 中实现。让我们通过一个简单的例子来研究这意味着什么。考虑一个一元函数,如 示例 3.1 所示,我们打算将其映射到给定的向量上。该函数必须从它提供的值中计算出一个值,并执行一个副作用,以便我们可以观察它在集合元素中的应用。

注意

以下示例可以在书籍源代码的 src/m_clj/c3/reducers.clj 中找到。

(defn square-with-side-effect [x]
  (do
    (println (str "Side-effect: " x))
    (* x x)))

示例 3.1:一个简单的一元函数

square-with-side-effect 函数简单地使用 * 函数返回数字 x 的平方。每当这个函数被调用时,它也会使用 println 形式打印 x 的值。假设这个函数被映射到给定的向量上。如果必须对它进行计算,结果集合必须完全实现,即使结果向量中的所有元素都不需要。这可以通过以下方式演示:

user> (def mapped (map square-with-side-effect [0 1 2 3 4 5]))
#'user/mapped
user> (reduce + (take 3 mapped))
Side-effect: 0
Side-effect: 1
Side-effect: 2
Side-effect: 3
Side-effect: 4
Side-effect: 5
5

如前所述,mapped 变量包含了对 square-with-side-effect 函数在向量上映射的结果。如果我们尝试使用 reducetake+ 函数对结果集合中的前三个值求和,[0 1 2 3 4 5] 向量中的所有值都会作为副作用打印出来,如前面的输出所示。这意味着 square-with-side-effect 函数被应用于初始向量的所有元素,尽管实际上 reduce 形式只需要前三个元素。当然,这可以通过使用 seq 函数在映射 square-with-side-effect 函数之前将向量转换为序列来解决。但这样,我们就失去了在结果集合中以随机顺序高效访问元素的能力。

要理解为什么这实际上会发生,我们首先需要了解标准 map 函数是如何实际实现的。map 函数的简化定义如下所示 示例 3.2

(defn map [f coll]
  (cons (f (first coll))
        (lazy-seq (map f (rest coll)))))

示例 3.2:map 函数的简化定义

示例 3.2map 的定义是一个简化和相当不完整的定义,因为它没有检查空集合,并且不能用于多个集合。抛开这一点,这个 map 的定义确实将函数 f 应用到集合 coll 中的所有元素。这是通过 consfirstrestlazy-seq 形式的组合来实现的。

这种实现可以解释为“将函数 f 应用于集合 coll 中的第一个元素,然后以懒方式将 f 映射到集合的其余部分”。这种实现的一个有趣后果是 map 函数具有以下特性:

  • 集合 coll 中元素的顺序被保留。

  • 这种计算是递归执行的。

  • 使用 lazy-seq 形式以懒方式执行计算。

  • 使用firstrest形式表明coll必须是一个序列,而cons形式也会产生一个序列的结果。因此,map函数接受一个序列并构建一个新的序列。

然而,将这些序列的性质转换为非序列的结果并不需要。懒序列的另一个特点是它们的实现方式。当我们说实现时,我们的意思是给定懒序列被评估以产生具体值。懒序列以的形式实现。每个块包含 32 个元素,这是作为一个优化措施。以这种方式行为的序列被称为块序列。当然,并非所有序列都是块序列,我们可以使用chunked-seq?谓词来检查给定的序列是否是块序列。range函数返回一个块序列,如下所示:

user> (first (map #(do (print \!) %) (range 70)))
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
0
user> (nth (map #(do (print \!) %) (range 70)) 32)
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
32

前面的输出中的两个语句都从map函数返回的序列中选择单个元素。在前面两个语句中传递给map函数的函数打印!字符并返回其提供的值。在第一个语句中,即使只需要第一个元素,结果序列的前 32 个元素也会被实现。同样,当使用nth函数获取第 32 个位置的元素时,第二个语句观察到结果序列的前 64 个元素被实现。但再次强调,在块中实现集合并不需要执行集合中元素的运算。

注意

块序列自 Clojure 1.1 版本以来一直是其核心部分。

如果我们要高效地处理此类计算,我们不能依赖于返回序列的函数,如mapfilter。顺便提一下,reduce函数不一定产生序列。它还具有一些其他有趣的特性:

  • reduce函数实际上允许提供的集合定义它是如何被计算或归约的。因此,reduce集合无关的。

  • 此外,reduce函数足够灵活,可以构建单个值或全新的集合。例如,使用*+函数与reduce一起使用将创建一个单值结果,而使用consconcat函数可以创建一个新集合作为结果。因此,reduce可以构建任何东西

总结来说,reduce函数可以用作前提来泛化任何必须应用于集合的计算或转换。

引入 reducers

当一个集合使用reduce函数定义其行为时,我们说这个集合是可减少的reduce函数使用的二元函数以及集合也被称为减少函数。减少函数需要两个参数——一个用于表示减少的累积结果,另一个用于表示必须组合到结果中的输入值。可以将几个减少函数组合成一个,这实际上改变了reduce函数处理给定集合的方式。这种组合是通过减少函数转换器或简单地reducer来完成的。

使用序列和惰性可以与 Rich Hickey 著名的派制作类比中使用的 reducer 执行给定计算进行比较。假设一个派制作商已经得到了一袋苹果,目的是将苹果减少成派。需要几个转换来完成这个任务。首先,所有苹果上的标签都必须去掉,就像我们对集合中的苹果应用一个函数来“撕掉标签”。此外,所有坏苹果都必须去掉,这类似于使用filter函数从集合中删除元素。派制作商不会亲自做这项工作,而是委托给她的助手。助手可以先撕掉所有苹果上的标签,从而产生一个新的集合,然后取出坏苹果以产生另一个新的集合,这说明了惰性序列的使用。但是,助手通过从坏苹果上撕掉标签而做了不必要的工,这些苹果最终会被丢弃。

另一方面,助手可以将这项工作推迟到实际将处理过的苹果减少成派的时候。一旦实际需要执行这项工作,助手将组合两个任务:对苹果集合进行映射过滤,从而避免任何不必要的工。这种情况描述了使用 reducer 来组合和转换将苹果集合有效减少成派所需的任务。因此,避免了在每个转换之间使用中间集合,这在内存分配方面是一个优化。

当然,一个智能助手会首先丢弃坏苹果,这本质上是在映射之前过滤苹果。然而,并非所有食谱都是那么简单,而且,我们可以通过使用 reducer(减少器)实现更有趣的优化——并行化。通过使用 reducer,我们创建了一个食谱,将苹果集合减少成一个可以并行化的派。此外,所有处理都延迟到最终减少,而不是将集合作为每个任务的中间结果来处理。这就是 reducer 通过函数组合和并行化实现性能的精髓。

注意

在接下来的示例中,必须将以下命名空间包含在您的命名空间声明中:

(ns my-namespace
  (:require [clojure.core.reducers :as r]))

clojure.core.reducers 命名空间需要 Java 6 以及 jsr166y.jar JAR 或 Java 7+ 以支持 fork/join。

现在让我们简要探讨一下 reducers 是如何实际实现的。在序列上操作的函数使用 clojure.lang.ISeq 接口来抽象集合的行为。在 reducers 的情况下,我们必须构建的通用接口是归约函数的接口。正如我们之前提到的,归约函数是一个双参数函数,其中第一个参数是到目前为止的累积结果,第二个参数是要与第一个参数结合的当前输入。在集合上执行计算并产生一些结果的过程可以概括为三个不同的案例。它们可以描述如下:

  • 需要生成一个与提供的集合具有相同元素数量的新集合。这种 一对一 的情况类似于使用 map 函数。

  • 计算通过从其中移除元素来 缩小 提供的集合。这可以使用 filter 函数来完成。

  • 计算也可能是 扩展的,在这种情况下,它会产生一个包含更多元素的新集合。这就像 mapcat 函数所做的那样。

这些案例描述了集合可以转换成所需结果的不同方式。任何在集合上进行的计算或归约都可以被视为一系列这样的变换的任意序列。这些变换由 转换器 表示,它们本质上是将归约函数进行转换的函数。它们可以像 Example 3.3 中所示的那样实现:

(defn mapping [f]
  (fn [rf]
    (fn [result input]
      (rf result (f input)))))

(defn filtering [p?]
  (fn [rf]
    (fn [result input]
      (if (p? input)
        (rf result input)
        result))))

(defn mapcatting [f]
  (fn [rf]
    (fn [result input]
      (reduce rf result (f input)))))

Example 3.3: 转换器

Example 3.3 中的 mappingfilteringmapcatting 函数分别代表了 mapfiltermapcat 函数的核心逻辑。所有这些函数都是接受单个参数并返回一个新函数的转换器。返回的函数将一个表示为 rf 的提供的归约函数进行转换,并返回一个新的归约函数,该函数使用表达式 (fn [result input] ... ) 创建。由 mappingfilteringmapcatting 函数返回的函数被称为 归约函数转换器

mapping 函数将 f 函数应用于当前输入,由 input 变量表示。然后,函数 f 返回的值与累积结果 result 结合,使用归约函数 rf。这个转换器是对标准 map 函数应用函数 f 到集合的令人敬畏的纯抽象。mapping 函数对其提供的集合的结构或函数 f 返回的值如何结合以产生最终结果没有任何假设。

同样,filtering 函数使用谓词 p? 来检查当前输入的归约函数 rf 是否必须结合到最终结果中,该结果由 result 表示。如果谓词不为真,则归约函数将简单地返回值 result 而不进行任何修改。mapcatting 函数使用 reduce 函数将值 result 与表达式 (f input) 的结果组合。在这个转换器中,我们可以假设函数 f 将返回一个新的集合,而归约函数 rf 将以某种方式组合两个集合。

reducers 库的一个基础是定义在 clojure.core.protocols 命名空间中的 CollReduce 协议。此协议抽象了集合在作为 reduce 函数的参数传递时的行为,并声明如下 示例 3.4

(defprotocol CollReduce
  (coll-reduce [coll rf init]))

示例 3.4:CollReduce 协议

clojure.core.reducers 命名空间定义了一个 reducer 函数,该函数通过动态扩展 CollReduce 协议来创建一个可归约集合,如 示例 3.5 所示:

(defn reducer
  ([coll xf]
   (reify
     CollReduce
     (coll-reduce [_ rf init]
       (coll-reduce coll (xf rf) init)))))

示例 3.5:归约函数

reducer 函数将集合 coll 和归约函数转换器 xf(由 mappingfilteringmapcatting 函数返回)组合起来,以生成一个新的可归约集合。当在可归约集合上调用 reduce 时,它最终会要求集合使用由表达式 (xf rf) 返回的归约函数来归约自身。使用这种机制,可以将多个归约函数组合成单个计算,在给定的集合上执行。此外,reducer 函数只需要定义一次,而 coll-reduce 的实际实现由提供给 reducer 函数的集合提供。

现在,我们可以重新定义 reduce 函数,使其简单地调用由给定集合实现的 coll-reduce 函数,如 示例 3.6 所示:

(defn reduce
  ([rf coll]
   (reduce rf (rf) coll))
  ([rf init coll]
   (coll-reduce coll rf init)))

示例 3.6:重新定义 reduce 函数

示例 3.6 所示,reduce 函数将归约集合的任务委托给集合本身,使用 coll-reduce 函数。此外,reduce 函数还将使用归约函数 rf 来提供 init 参数,如果未指定。reduce 的这种定义的一个有趣后果是,当没有提供参数时,函数 rf 必须产生一个 恒等值。标准的 reduce 函数也使用 CollReduce 协议将归约集合的任务委托给集合本身,但如果提供的集合没有实现 CollReduce 协议,它将回退到 reduce 的默认定义。

注意

自 Clojure 1.4 以来,reduce 函数允许集合通过 clojure.core.CollReduce 协议定义其归约方式。Clojure 1.5 引入了 clojure.core.reducers 命名空间,该命名空间扩展了此协议的使用。

所有标准的 Clojure 集合,即列表、向量、集合和映射,都实现了 CollReduce 协议。reducer 函数可用于构建在将集合作为参数传递给 reduce 函数时应用于集合的转换序列。以下是如何演示:

user> (r/reduce + 0 (r/reducer [1 2 3 4] (mapping inc)))
14
user> (reduce + 0 (r/reducer [1 2 3 4] (mapping inc)))
14

在前面的输出中,mapping 函数与 inc 函数一起使用,创建了一个用于增加给定集合中所有元素的减少函数转换器。然后,使用 reducer 函数将该转换器与一个向量组合,以产生一个可减少的集合。在前面的两个语句中的 reduce 调用被转换成表达式 (reduce + [2 3 4 5]),从而产生结果 14。现在,我们可以使用 reducer 函数重新定义 mapfiltermapcat 函数,如 示例 3.7 所示:

(defn map [f coll]
  (reducer coll (mapping f)))

(defn filter [p? coll]
  (reducer coll (filtering p?)))

(defn mapcat [f coll]
  (reducer coll (mapcatting f)))

示例 3.7:使用 reducer 形式重新定义 map、filter 和 mapcat 函数

示例 3.7 所示,mapfiltermapcat 函数现在是 reducer 形式与 mappingfilteringmapcatting 转换器分别的组合。

注意

本节中所示 CollReducereducerreducemapfiltermapcat 的定义是它们在 clojure.core.reducers 命名空间中实际定义的简化版本。

示例 3.7 中所示的 mapfiltermapcat 函数的定义与这些函数的标准版本具有相同的结构,如下所示:

user> (r/reduce + (r/map inc [1 2 3 4]))
14
user> (r/reduce + (r/filter even? [1 2 3 4]))
6
user> (r/reduce + (r/mapcat range [1 2 3 4]))
10

因此,来自 clojure.core.reducers 命名空间的 mapfiltermapcat 函数可以像这些函数的标准版本一样使用。reducers 库还提供了一个 take 函数,它可以作为标准 take 函数的替代品。我们可以使用这个函数来减少在映射给定向量时对 square-with-side-effect 函数(来自 示例 3.1)的调用次数,如下所示:

user> (def mapped (r/map square-with-side-effect [0 1 2 3 4 5]))
#'user/mapped
user> (reduce + (r/take 3 mapped))
Side-effect: 0
Side-effect: 1
Side-effect: 2
Side-effect: 3
5

因此,使用 clojure.core.reducers 命名空间中的 maptake 函数,如这里所示,可以避免将 square-with-side-effect 函数应用于向量 [0 1 2 3 4 5] 中的所有五个元素,因为只需要前三个。

reducers 库还提供了基于 reducers 的标准 take-whiledropflattenremove 函数的变体。实际上,基于 reducers 的函数将需要比基于序列的函数更少的分配,从而提高性能。例如,考虑 示例 3.8 中所示的 processprocess-with-reducer 函数:

(defn process [nums]
  (reduce + (map inc (map inc (map inc nums)))))

(defn process-with-reducer [nums]
  (reduce + (r/map inc (r/map inc (r/map inc nums)))))

示例 3.8:使用序列和 reducers 处理数字集合的函数

示例 3.8 中的 process 函数使用 map 函数在由 nums 表示的数字集合上应用 inc 函数。process-with-reducer 函数执行相同的操作,但使用 map 函数的归约器变体。与 process 函数相比,process-with-reducer 函数从大向量生成结果所需的时间会更少,如下所示:

user> (def nums (vec (range 1000000)))
#'user/nums
user> (time (process nums))
"Elapsed time: 471.217086 msecs"
500002500000
user> (time (process-with-reducer nums))
"Elapsed time: 356.767024 msecs"
500002500000

process-with-reducer 函数由于需要的内存分配比 process 函数少而获得轻微的性能提升。我们应该注意,可用的内存应该足够大,能够加载整个文件,否则我们可能会耗尽内存。如果我们能够以某种方式并行化它,那么我们可以通过更大的规模来提高这种计算的性能,我们将在下一节中探讨如何实现这一点。

使用折叠并行化集合

实现了 CollReduce 协议的集合本质上仍然是顺序的。使用 reduce 函数与 CollReduce 结合确实有一定的性能提升,但它仍然按顺序处理集合中的元素。提高在集合上执行的计算性能的最明显方法是将计算并行化。如果忽略给定集合中元素的顺序以生成计算结果,则可以并行化此类计算。在 reducers 库中,这是基于 java.util.concurrent 命名空间的 fork/join 并行化模型实现的。fork/join 模型本质上将需要执行计算的计算集合分成两半,并并行处理每个分区。这种集合的分割是以递归方式进行的。分区的粒度会影响使用 fork/join 模型模拟的计算的整体性能。这意味着,如果使用 fork/join 策略递归地将集合分割成包含单个元素的更小的集合,那么 fork/join 机制的开销实际上会降低计算的整体性能。

注意

基于 fork/join 的并行化方法实际上是在 Java 7 的clojure.core.reducers命名空间中实现的,使用了来自java.util.concurrent命名空间的ForkJoinTaskForkJoinPool类。在 Java 6 中,它在jsr166y命名空间的ForkJoinTaskForkJoinPool类中实现。有关 Java fork/join 框架的更多信息,请访问docs.oracle.com/javase/tutorial/essential/concurrency/forkjoin.html

使用 reducers 进行此类计算的并行化与在基于 MapReduce 的库中处理的方式大不相同。在 reducers 的情况下,元素首先通过一系列转换减少到更少的元素数量,然后最终组合起来以创建结果。这与 MapReduce 策略如何模拟此类计算形成对比,其中集合的元素通过几个转换进行映射,并使用最终减少步骤来生成最终结果。这区分了 MapReduce 并行计算模型与reducers库使用的reduce-combine模型。这种使用 reduce-combine 策略的并行化方法是通过clojure.core.reducers命名空间中的fold函数实现的。

注意

在 Clojure 中,fold函数指的是一个可并行化的计算,这与 Haskell 和 Erlang 等其他函数式编程语言中的传统 fold left (foldl) 和 fold right (foldr) 函数非常不同。Clojure 中的reduce函数实际上与其他语言中的foldl函数具有相同的顺序性质和语义。

fold函数通过基于 fork/join 的线程在集合上并行化给定的计算。它实现了我们之前描述的 reduce-combine 策略,并在给定集合的等分段上并行执行reduce函数。这些reduce函数的并行执行产生的结果最终通过一个组合函数进行组合。当然,如果提供的集合太小,实际上无法通过基于 fork/join 的并行化获得任何性能提升,fold形式将简单地在一个执行线程上调用reduce函数。因此,fold函数代表了一个集合上的可能并行化的计算。由于fold的这种性质,我们应该避免在使用fold形式时基于顺序执行进行 IO 和其他副作用。

fold函数允许集合定义如何将其折叠到结果中,这与reduce函数的语义类似。如果一个集合实现了来自clojure.core.reducers命名空间的CollFold协议,则称该集合为可折叠reducers库扩展了标准向量映射集合类型的CollFold协议。这些CollFold实现的并行化是通过基于 fork/join 的并行性完成的。CollFold协议的定义在示例 3.9中展示:

(defprotocol CollFold
  (coll-fold [coll n cf rf]))

示例 3.9:CollFold 协议

CollFold 协议定义了一个 coll-fold 函数,它需要四个参数——一个集合 coll,集合中每个段或分区中的元素数量 n,一个组合函数 cf,以及一个归约函数 rf。一个可折叠的集合必须实现此协议,以及 clojure.core.protocols.CollReduce 协议,因为对给定集合的 fold 调用可能会回退到 reduce 函数的单线程执行。

要从集合和归约函数转换器创建一个可折叠集合,reducers 库定义了一个与 reducer 函数具有相似语义的 folder 函数。此函数的实现方式如 示例 3.10 所示:

(defn folder
  ([coll xf]
   (reify
     CollReduce
     (coll-reduce [_ rf init]
       (coll-reduce coll (xf rf) init))
     CollFold
     (coll-fold [_ n cf rf]
       (coll-fold coll n cf (xf rf))))))

示例 3.10:folder 函数

folder 函数从集合 coll 和归约函数转换器 xf 创建一个新的可折叠和可归约集合。这种 xfrf 函数的组合类似于在 示例 3.5 中描述的 reducer 函数所执行的操作。除了 xfrf 函数外,coll-fold 函数还需要一个组合函数 cf,用于组合 reduce 函数可能并行执行的结果。与 reduce 函数类似,fold 函数将折叠给定集合的实际责任传递给 coll-fold 函数的集合实现。fold 函数的实现已在 示例 3.11 中描述:

(defn fold
  ([rf coll]
   (fold rf rf coll))
  ([cf rf coll]
   (fold 512 cf rf coll))
  ([n cf rf coll]
   (coll-fold coll n cf rf)))

示例 3.11:折叠函数

示例 3.11 所示,fold 函数使用归约函数 rf 和组合函数 cf 调用集合 collcoll-fold 函数。fold 函数还可以指定 reduce 函数处理的每个段中的元素数量 n,默认为 512 个元素。我们还可以避免将组合函数 cf 指定给 fold 函数,在这种情况下,归约函数 rf 本身将用作组合函数。

fold 形式中使用的组合和归约函数的一个有趣方面是,它们在本质上必须是 结合律 的。这保证了 fold 函数的结果将独立于给定集合中元素组合的顺序。这允许我们在给定集合的段上并行化 fold 函数的执行。同样,类似于 reduce 形式所需的归约函数,fold 函数要求组合和归约函数在无参数调用时产生一个 恒等值。在函数式编程中,既是结合律又是提供恒等值的函数被称为 单例clojure.core.reducers 命名空间提供了在 示例 3.12 中描述的 monoid 函数,以创建可以用于 fold 形式的组合函数或归约函数的函数:

(defn monoid
  [op ctor]
  (fn
    ([] (ctor))
    ([a b] (op a b))))

示例 3.12:单例函数

示例 3.12 中显示的 monoid 函数产生一个函数,当提供两个参数 ab 时,它会调用函数 op。当使用 monoid 函数返回的函数不带参数调用时,它将通过不带参数调用 ctor 函数来简单地产生操作的身份值。这个函数使我们能够轻松地创建一个组合函数,用于与 fold 函数一起使用,该函数可以是任何任意的 ctorop 函数。

我们现在可以将 mapfiltermapcat 操作重新定义为 folder 函数和 示例 3.3 中定义的 mappingfilteringmapcatting 转换器的组合,如 示例 3.13 所示:

(defn map [f coll]
  (folder coll (mapping f)))

(defn filter [p? coll]
  (folder coll (filtering p?)))

(defn mapcat [f coll]
  (folder coll (mapcatting f)))

示例 3.13:使用 folder 形式重新定义 map、filter 和 mapcat 函数

注意

本节中显示的 folderfoldmonoidmapfiltermapcat 的定义是它们在 clojure.core.reducers 命名空间中实际定义的简化版本。

reducers 库还定义了 foldcat 函数。这个函数是 reduceconj 函数的高性能变体。换句话说,表达式 (foldcat coll) 的评估将比表达式 (reduce conj [] coll)(其中 coll 是可还原或可折叠的集合)快得多。此外,foldcat 函数返回的集合也将是一个可折叠的集合。

让我们现在使用 foldmap 函数来提高 示例 3.8 中的 processprocess-with-reducer 函数的性能。我们可以像 示例 3.14 中所示那样实现这一点:

(defn process-with-folder [nums]
  (r/fold + (r/map inc (r/map inc (r/map inc nums)))))

示例 3.14:使用 fold 形式处理数字集合的函数

如下所示,process-with-folder 函数在大向量上的性能可以与 processprocess-with-reducer 函数进行比较:

user> (def nums (vec (range 1000000)))
#'user/nums
user> (time (process nums))
"Elapsed time: 474.240782 msecs"
500002500000
user> (time (process-with-reducer nums))
"Elapsed time: 364.945748 msecs"
500002500000
user> (time (process-with-folder nums))
"Elapsed time: 241.057025 msecs"
500002500000

从前面的输出中观察到,process-with-folder 函数由于其固有的并行性使用,其性能显著优于 processprocess-with-reducer 函数。总之,reducer 通过基于 fork/join 的并行性提高了必须对集合执行的计算性能。

使用 reducer 处理数据

现在我们将研究一个简单示例,该示例描述了在高效处理大型集合中使用 reducer 的方法。为此示例,我们将使用 iota 库 (github.com/thebusby/iota) 来处理大型内存映射文件。鼓励使用 iota 库来处理大型文件,作为使用具体集合的高效替代方案。例如,将 1 GB 的 TSV 文件中的记录作为字符串加载到 Clojure 向量中,由于 Java 字符串的低效存储,将消耗超过 10 GB 的内存。iota 库通过有效地索引和缓存大文件的 内容来避免这种情况,与使用具体集合相比,这需要更低的内存开销。

注意

以下示例需要以下库依赖项:

[iota "1.1.2"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [iota :as i]
            [clojure.string :as cs]
            [clojure.core.reducers :as r]))

以下示例可以在书籍源代码的 src/m_clj/c3/io.clj 中找到。

假设我们有一个包含数千条记录的大型 TSV 文件。每条记录代表一个人,可以假设它有五个字段,如下所示的数据:

brown  brian  :m  :child    :east
smith  bill   :f  :child    :south
jones  jill   :f  :parent   :west

每条记录包含两个字符串和三个关键词。记录的前两个字符串字段代表一个人的姓和名,第三列是一个表示一个人性别的关键词,第四列是一个标识一个人为父母或孩子的关键词。最后,第五列是一个表示任意方向的关键词。

注意

以下示例期望之前显示的内容存在于相对于您的 Leiningen 项目的根目录的 resources/data/sample.tsv 文件中。

可以使用 iota 库中的 seqvec 函数来创建内存映射文件的序列和向量表示。这些对象可以用来以高效的方式访问文件。seqvec 函数都需要将文件路径作为第一个参数传递给它们。vec 函数将以 的形式索引提供的文件,我们可以将每个块的大小作为 vec 函数的第二个参数指定。seq 函数按需执行缓冲读取提供的文件,类似于实现懒序列的方式。该序列使用的缓冲区大小可以作为 seq 函数的第二个参数指定。seqvec 函数将文件内容按预定义的字节分隔符分割成以字符串表示的记录。这些函数还接受一个可选的第三个参数,以指示提供的文件中记录之间的字节分隔符。由于 vec 函数必须索引文件中的记录,因此它比 seq 函数慢,如下所示:

user> (time (def file-as-seq (i/seq "resources/data/sample.tsv")))
"Elapsed time: 0.905326 msecs"
#'user/file-as-seq
user> (time (def file-as-vec (i/vec "resources/data/sample.tsv")))
"Elapsed time: 4.95506 msecs"
#'user/file-as-vec

这里显示的两个语句都将 sample.tsv 文件加载到 Clojure 数据结构中。正如预期的那样,vec 函数返回值所需的时间比 seq 函数多。seqvec 返回的值可以像任何其他集合一样处理。自然地,迭代由 vec 函数返回的向量比使用序列要快,如下所示:

user> (time (def first-100-lines (doall (take 100 file-as-seq))))
"Elapsed time: 63.470598 msecs"
#'user/first-100-lines
user> (time (def first-100-lines (doall (take 100 file-as-vec))))
"Elapsed time: 0.984128 msecs"
#'user/first-100-lines

现在我们将展示几种使用 reducers 和 iota 库查询 sample.tsv 文件数据的方法。我们首先需要定义一个函数,该函数将记录集合转换为基于字符串表示的列值集合。这可以通过基于 reducer 的 mapfilter 函数实现,如 示例 3.15 中的 into-records 函数所示:

(defn into-records [file]
  (->> file
       (r/filter identity)
       (r/map #(cs/split % #"[\t]"))))

示例 3.15:一个将内存映射文件转换为可减少集合的函数

现在,假设我们需要从 sample.tsv 文件中的记录中计算女性的总数。我们可以通过使用 mapfold 函数来实现一个执行此计算的函数,如下所示在 示例 3.16 中的 count-females 函数:

(defn count-females [coll]
  (->> coll
       (r/map #(-> (nth % 2)
                   ({":m" 0 ":f" 1})))
       (r/fold +)))

我们可以通过组合 into-recordscount-females 函数来查询 file-as-seqfile-as-vec 集合中女性的总数。这可以使用 -> 线程形式来完成,如下所示:

user> (-> file-as-seq into-records count-females)
10090
user> (-> file-as-vec into-records count-females)
10090

同样,基于解决器的 mapfilter 函数可以用来获取给定集合中具有相同姓氏或家族的所有孩子的名字,如 示例 3.17 中的 get-children-names-in-family 函数所实现的那样:

(defn get-children-names-in-family [coll family]
  (->> coll
       (r/filter #(and (= (nth % 0) family)
                       (= (nth % 3) ":child")))
       (r/map #(nth % 1))
       (into [])))

示例 3.17:一个获取人员集合中所有孩子名字的函数

into-recordsget-children-names-in-family 函数可以组合在一起,查询所有姓氏为 "brown" 的孩子的名字,如下所示:

user> (-> file-as-seq into-records
 (get-children-names-in-family "brown"))
["sue" "walter" ... "jill"]
user> (-> file-as-vec into-records
 (get-children-names-in-family "brown"))
["sue" "walter" ... "jill"]

iota 库提供了一些更有用的函数来处理大型文本文件:

  • numbered-vec 函数将创建一个表示内存映射文件的向量,其中每个表示记录的字符串都将在其给定文件中的位置前添加。

  • iota 库的 subvec 函数可以用来从 vecnumbered-vec 函数返回的内存映射文件中 切片 记录。其语义与在向量上操作的标准 subvec 函数相同。

解决器和 iota 库使我们能够以惯用和高效的方式处理包含大量字节分隔记录的文本文件。Clojure 生态系统还有其他几个库和框架使用解决器来处理大量数据,鼓励读者自行探索这些库和框架。

摘要

在本章中,我们详细探讨了 clojure.core.reducers 库。我们查看了解决器的实现方式,以及如何高效地使用解决器处理大量数据。我们还简要研究了 iota 库,该库可以与解决器一起使用,以处理存储在文本文件中的大量数据。

在下一章中,我们将探讨 Clojure 宏。

第四章。使用宏进行元编程

程序员经常会遇到想要向他们选择的编程语言中添加功能或结构的情况。通常,如果必须向语言中添加功能,则语言的编译器或解释器需要进行一些修改。或者,Clojure(以及其他 Lisp)使用 来解决这个问题。术语 元编程 用于描述通过使用另一个程序生成或操作程序源代码的能力。宏是元编程工具,允许程序员轻松地向他们的编程语言添加新功能。

Lisp 语言并非唯一支持基于宏的元编程的语言。例如,在 C 和 C++ 语言中,宏由编译器的预处理器处理。在这些语言中,在程序编译之前,程序源代码中的所有宏调用都被它们的定义所替换。从这个意义上讲,宏在程序的编译阶段通过一种文本替换的形式生成代码。另一方面,Lisp 允许程序员在宏被解释或编译时转换或重写代码。因此,宏可以用来简洁地封装代码中的重复模式。当然,在没有宏的语言中也可以这样做,而且不会太麻烦。但宏允许我们以干净和简洁的方式封装代码中的模式。正如我们将在本章后面看到的那样,在其他编程语言中,没有与 Lisp 宏相当的东西,无论是从清晰度、灵活性还是功能上讲。在元编程能力方面,Lisp 真正领先于其他编程语言。

Lisp 中的宏有很深的“兔子洞”,以至于有整本书都在讨论它们。"Mastering Clojure Macros"(精通 Clojure 宏)由 "Colin Jones" 编著,这本书详细描述了宏可以使用的各种模式。在本章中,我们将探讨宏背后的基础概念及其用法。我们将:

  • 首先,让我们看看 Clojure 中读取、评估和转换代码的基础知识。

  • 之后,我们将研究如何定义和使用宏,并基于宏研究几个示例。我们还将描述如何使用 reader conditionals 来处理特定平台的代码。

理解读者

读者负责解释 Clojure 代码。它执行多个步骤将源代码在文本表示形式转换为可执行机器代码。在本节中,我们将简要描述读者执行的这些步骤,以说明读者是如何工作的。

Clojure 和其他 Lisp 家族的语言是同构的。在同构语言中,程序的源代码被表示为普通的数据结构。这意味着用 Lisp 语言编写的所有代码只是一系列嵌套的列表。因此,我们可以像操作任何其他值列表一样操作程序的代码。Clojure 有几种更多的数据结构,如其语法中的向量和映射,但它们可以同样容易地处理。在非同构语言中,程序中的任何表达式或语句在编译或解释程序时都必须被转换为一个称为解析树语法树的内部数据结构。然而,在 Lisp 中,一个表达式已经是以语法树的形式存在的,因为树实际上只是嵌套列表的另一个名称。换句话说,表达式和它产生的语法树之间没有区别。有人也可能认为这种设计让程序员直接以语法树的形式编写代码。Lisp 的这个独特方面被以下公理简洁地概括:代码即数据

让我们先看看 Lisp 中代码和数据的最基本表示——s 表达式。任何表达式都由符号组成,其中符号代表正在使用的变量。符号的嵌套列表被称为符号表达式s 表达式sexp。Clojure 中的所有源代码都表示为 s 表达式。符号表达式正式定义为:

  • 原子,指的是单个符号或字面值。

  • 两个 s 表达式xy的组合,表示为(x . y)。在这里,点(.)用来表示cons操作。

使用这种递归定义,符号列表(x y z)被表示为 s 表达式(x . (y . (z . nil)))(x . (y . z))。当 s 表达式用来表示源代码时,表达式的第一个元素代表使用的函数,其余元素是该函数的参数。当然,这只是一个理论上的表示,并不是真正的 Clojure 代码。这种表示也称为前缀表示法。s 表达式的这种递归结构足够灵活,可以表示代码和数据。事实上,s 表达式几乎是 Clojure(和其他 Lisp)中唯一的语法形式。例如,如果我们想加两个数,我们会使用一个以+函数作为第一个符号的表达式,后面跟着要加的值。同样,如果我们想定义一个函数,我们必须编写一个以defndef作为表达式中第一个符号的表达式。在 Clojure 和其他 Lisp 中,我们使用 s 表达式表示数据,如列表、向量和映射。

让我们来看一个简单的例子,说明 Clojure 代码是如何被解释的。使用线程宏 (->) 的表达式 (-> [0 1 2] first inc) 将被解释为三个不同的步骤。这个表达式将被读取,宏展开,并计算为值 1,如下所示:

理解读取器

读取器首先从 Clojure 程序的源代码中解析 s-表达式的文本表示。一旦程序源代码被读取为 s-表达式,代码中的所有宏调用都将被它们的定义所替换。程序中宏调用的这种转换称为 宏展开。最后,宏展开阶段产生的 s-表达式将由 Clojure 运行时进行评估。在评估阶段,从提供的表达式生成字节码,加载到内存中,并执行。简而言之,程序源代码中的代码被读取,通过宏进行转换,最终被评估。此外,宏展开在程序源代码解析后立即发生,因此允许程序在评估之前内部转换自身。这种代码的转换正是宏被用来实现的目的。

注意

在 Clojure 中,读取器只读取代码并执行宏展开。字节码的生成由分析器和生成器完成,并且这个生成的字节码由 JVM 进行评估。

所有 Clojure 代码在评估之前都被转换为 读取形式特殊形式。特殊形式是直接作为底层运行时(如 Clojure 的 JVM 或 ClojureScript 的 Rhino JavaScript 运行时)的字节码实现的构造,例如 quotelet*。有趣的是,Clojure 源代码主要由读取形式组成,这些读取形式在 Clojure 本身中得到实现。读取器还会在读取时立即转换某些字符和称为 读取宏 的形式。Clojure 语言中有几个读取宏,如下表所述:

读取宏 用法
\x 这是一个字符字面量。
; 这用于注释。它忽略该行的其余部分。
(.method o) 这是一个本地方法调用。它被重写为点 (.) 形式,作为 (. o method)。此外,o 必须是一个本地对象。
@x@( ... ) 这是解引用运算符。它与引用类型一起使用,并被重写为 deref 形式。
^{ ... } 这是与形式一起使用的元数据映射。它被重写为 with-meta 形式。
'x'( ... ) 这是一个引用。
`x`( ... ) 这是一个语法引用。
~x~( ... ) 这用于非引用。
~@x~@( ... ) 这是一个切片非引用。
#_x#_( ... ) 这忽略下一个形式。#_ 应该比 comment 形式更优先用于注释代码,因为注释实际上返回 nil
#'x 这是一个变量引用。它等同于 (var x)
#=x#=( ... ) 这将读取并评估一个表达式。
#?( ... ) 这是一种读者条件形式。
#?@( ... ) 这是一种读者条件拼接形式。

在前面的章节中,我们已经遇到了很多前面的读取器宏。我们将在本章中演示与宏一起使用的几个读取形式的用法。

注意

在撰写本书时,Clojure 不支持用户定义的读取器宏。

现在我们已经熟悉了 Clojure 的读取器和代码的解析方式,让我们探索各种元编程结构,这些结构帮助我们读取和评估代码。

阅读和评估代码

让我们看看在 Clojure 中代码是如何被解析和评估的。将文本转换为表达式的最基本方法是通过使用read函数。该函数将其第一个参数作为java.io.PushbackReader实例接受,如下所示:

user> (read (-> "(list 1 2 3)"
 .toCharArray
 java.io.CharArrayReader.
 java.io.PushbackReader.))
(list 1 2 3)

注意

这些示例可以在书籍源代码的src/m_clj/c4/read_and_eval.clj中找到。

在这个例子中,一个包含有效表达式的字符串首先被转换为java.io.PushbackReader的实例,然后传递给read函数。读取字符串似乎是一个很多不必要的步骤,但这是因为read函数处理流和读取器,而不是字符串。如果没有向read函数传递任何参数,它将创建一个从标准输入创建的读取器,并提示用户输入要解析的表达式。read函数还有其他几个选项,鼓励您在 REPL 上自行探索这些选项。

从字符串中读取表达式的更简单的方法是使用read-string函数。该函数接受一个字符串作为其唯一参数,并将提供的字符串转换为表达式,如下所示:

user> (read-string "(list 1 2 3)")
(list 1 2 3)

readread-string形式只能将字符串转换为有效的表达式。如果我们必须评估一个表达式,我们必须使用eval函数,如下所示:

user> (eval '(list 1 2 3))
(1 2 3)
user> (eval (list + 1 2 3))
6
user> (eval (read-string "(+ 1 2 3)"))
6

在前面的输出中的第一行语句中,我们使用引号运算符(')防止在传递给eval函数之前评估表达式(list 1 2 3)。这种技术被称为引用,我们将在本章后面进一步探讨。eval函数将表达式(list 1 2 3)评估为列表(1 2 3)。同样,在第二行中,表达式(list + 1 2 3)首先被读取器评估为(+ 1 2 3),然后eval函数将这个列表评估为值6。在第三行中,字符串"(+ 1 2 3)"首先被read-string函数解析,然后由eval函数评估。

read-evaluate宏(#=)可以用来强制readread-string函数在解析时评估一个表达式,如下所示:

user> (read (-> "#=(list 1 2 3)"
 .toCharArray
 java.io.CharArrayReader.
 java.io.PushbackReader.))
(1 2 3)
user> (read-string "#=(list 1 2 3)")
(1 2 3)

在前面的输出中,#= 读取宏在由 readread-string 函数读取时评估表达式 (list 1 2 3)。如果没有使用 #= 宏,两个语句都会返回表达式 (list 1 2 3) 的字面值。我们也可以在不使用 readread-string 的情况下使用 #= 宏,在这种情况下,它将等同于调用 eval 函数。此外,对 #= 宏的调用可以嵌套任意次数,如下所示:

user> #=(list + 1 2 3)
6
user> (read-string "#=(list + 1 2 3)")
(+ 1 2 3)
user> (read-string "#=#=(list + 1 2 3)")
6

#= 宏使得在读取表达式时评估它们变得容易。哦,等等!这是一个潜在的安全隐患,因为 readread-string 函数正在评估任意字符串,即使它们包含任何恶意代码。因此,在解析时评估代码被认为是不安全的。为了解决这个问题,可以将 *read-eval* 变量设置为 false 以防止使用 #= 宏,如下所示:

user> (binding [*read-eval* false]
 (read-string (read-string "#=(list 1 2 3)")))
RuntimeException EvalReader not allowed when *read-eval* is false. clojure.lang.Util.runtimeException (Util.java:221)

因此,当 *read-eval* 设置为 false 时,在传递给 readread-string 函数的字符串中使用 #= 宏将会引发错误。显然,这个变量的默认值是 true。因此,我们必须避免使用 #= 宏,或者在处理用户输入时将 *read-eval* 变量设置为 false

另一种读取和评估任意字符串的方法是使用 load-string 函数。这个函数与 read-string 函数具有相同的 arity,并且与调用 evalread-string 形式等价,如下所示:

user> (load-string "(+ 1 2 3)")
6

使用 load-string 形式与 evalread-string 形式的组合之间存在一些语义上的差异。首先,load-string 函数的行为不受 *read-eval* 变量的变化影响,因此对于任意用户输入的使用是不安全的。

一个更重要的区别是,read-string 函数只解析它传递的字符串中遇到的第一个表达式。load-string 函数将解析并评估传递给它的所有表达式,如下所示:

user> (eval (read-string "(println 1) (println 2)"))
1
nil
user> (load-string "(println 1) (println 2)")
1
2
nil

在前面的输出中,read-string 形式跳过了它传递的字符串中的第二个 println 形式,因此只打印了值 1。然而,load-string 形式会解析并评估它作为字符串传递的 println 形式,并打印出值 12

load-reader 函数类似于 read 函数,因为它接受一个 java.io.PushbackReader 实例作为参数,必须从这个实例中读取和评估形式。load-string 的另一个变体是 load-file 函数,我们可以传递包含源代码的文件路径给它。load-file 函数将解析它传递的路径中的文件,并评估其中所有形式。

注意

注意,可以使用 *file* 变量来获取正在执行文件的路径。

到目前为止,我们已经看到了 Clojure 读取器如何解析和评估代码。有几个结构可以用来执行这些任务。然而,评估任意字符串并不是一个好主意,因为被评估的代码是不安全的,可能是恶意的。在实践中,我们应该始终将*read-eval*变量设置为false,以防止readread-string等函数评估任意代码。接下来,我们将探讨如何使用引用取消引用来转换表达式。

引用和取消引用代码

我们现在将探讨引用取消引用,这些是用于根据表达式的预定义模板生成表达式的技术。这些技术在创建宏时是基础性的,并且有助于使宏的代码看起来更像其宏展开形式。

注意

以下示例可以在书的源代码的src/m_clj/c4/目录下的quoting.clj文件中找到。

quote形式简单地返回一个表达式而不对其进行评估。这看起来可能微不足道,但防止表达式评估实际上并不是所有编程语言都可能做到的。quote形式使用撇号字符(')缩写。如果我们引用一个表达式,它将按原样返回,如下所示:

user> 'x
x
user> (quote x)
x

quote形式在 Lisp 中相当历史悠久。它是原始 Lisp 语言中的七个原始运算符之一,如约翰·麦卡锡的论文中所述。顺便提一下,quote是少数在 Java 中实现而在 Clojure 自身中未实现的特殊形式之一。quote形式用于处理变量名,或符号,作为值。简而言之,使用quote形式,我们可以将给定的表达式视为符号和值的列表。毕竟,代码即数据

注意

撇号(')仅当它作为表达式的第一个字符出现时才表示一个引用表达式。例如,x'只是一个变量名。

语法引号,写作反引号字符(`),将引用一个表达式并允许在内部执行取消引用。这个构造允许我们创建与引用类似的表达式,同时还有额外的优势,即允许我们在引用形式中插入值和执行任意代码。这相当于将预定义的表达式视为模板,其中一些部分留空以供以后填充。在语法引用形式中的表达式可以使用潮汐字符 (~) 进行取消引用。取消引用表达式将评估它并将结果插入到周围的语法引用形式中。切片取消引用,写作 ~@,可以用来评估返回列表的表达式,并使用返回的值列表作为形式的参数。这类似于 apply 形式的功能,但它是语法引号上下文中的。我们必须注意,这两种取消引用操作(~~@)只能在语法引号形式中使用。我们可以在 REPL 中尝试这些操作,如下所示:

user> (def a 1)
#'user/a
user> `(list ~a 2 3)
(clojure.core/list 1 2 3)
user> `(list ~@[1 2 3])
(clojure.core/list 1 2 3)

如此所示,在先前的语法引用 list 形式中取消引用变量 a 返回表达式 (list 1 2 3)。同样,使用切片取消引用向量 [1 2 3] 也返回相同的列表。另一方面,在引用形式中取消引用变量将展开取消引用读取宏 (~) 为 clojure.core/unquote 形式,如下所示:

user> (def a 1)
#'user/a
user> `(list ~a 2 3)
(clojure.core/list 1 2 3)
user> '(list ~a 2 3)
(list (clojure.core/unquote a) 2 3)

使用引号和语法引号之间更有趣的区别是,后者将解析所有变量名到具有命名空间限定名的名称。这也适用于函数名。例如,让我们看看以下表达式:

user> `(vector x y z)
(clojure.core/vector user/x user/y user/z)
user> `(vector ~'x ~'y ~'z)
(clojure.core/vector x y z)

如前所示输出,变量 xyz 通过引用语法被解析为 user/xuser/yuser/z,因为 user 是当前命名空间。此外,vector 函数被转换为具有命名空间限定名的名称,显示为 clojure.core/vector。连续使用 ~' 操作可以用来绕过将符号解析为具有命名空间限定名的名称。

引用不仅支持列表,还支持其他数据结构,如向量、集合和映射。语法引号对所有数据结构的效果相同;它允许在内部取消引用表达式,从而转换引用形式。此外,引用形式可以嵌套,例如,一个引用形式可以包含其他引用形式。在这种情况下,最深的引用形式首先被处理。考虑以下引用向量:

user> `[1 :b ~(+ 1 2)]
[1 :b 3]
user> `[1 :b '~(+ 1 2)]
[1 :b (quote 3)]
user> `[1 ~'b ~(+ 1 2)]
[1 b 3]

从前面的输出中可以推断出很多有趣的地方。首先,关键字显然没有被内部化到命名空间限定名称,如符号。实际上,任何评估为自身的值,如关键字、niltruefalse,在语法引号形式中使用时都会表现出这种行为。除此之外,在语法引号中先 unquote 然后 quote 一个表达式,如'~(+ 1 2),将评估该表达式并将其用 quote 包裹。相反,unquote 一个 quote 过的符号,如~'b,将防止它像我们之前提到的那样解析为命名空间限定名称。让我们看看另一个使用嵌套 quote 的例子,如下所示:

user> (def ops ['first 'second])
#'user/ops
user> `{:a (~(nth ops 0) ~'xs)
 :b (~(nth ops 1) ~'xs)}
{:b (second xs),
 :a (first xs)}

在前面的输出中,变量firstsecondxs通过结合使用 quote(')和 unquote(~)操作来防止被内部化到命名空间中。任何使用过较老 Lisp 的人可能在这个时候都会感到不舒服。实际上,应该避免使用~'操作。这是因为防止变量解析为命名空间限定名称并不是一个好主意。实际上,与 Clojure 不同,一些 Lisp 完全不允许这样做。这会导致一个称为符号捕获的特殊问题,我们将在探索宏的时候看到。

代码转换

如本章之前所述,使用readeval函数及其变体在 Clojure 中读取和评估代码是微不足道的。我们可以在解析代码后立即评估它,而不是使用宏首先通过 quote 和 unquote 程序性地转换代码,然后再评估它。因此,宏帮助我们定义自己的结构,这些结构可以重写和转换传递给它们的表达式。在本节中,我们将探讨创建和使用宏的基本知识。

展开宏

宏在调用时需要被展开。所有 Clojure 代码都是按照我们之前描述的方式,由读取器读取、宏展开和评估的。现在让我们看看宏展开是如何进行的。正如你可能已经猜到的,这是通过普通的 Clojure 函数来完成的。

有趣的是,Clojure 运行时的读取器也使用这些函数来处理程序的源代码。作为一个例子,我们将检查->连接宏是如何宏展开的。->宏可以像下面这样使用:

user> (-> [0 1 2] first inc)
1
user> (-> [0 1 2] (-> first inc))
1
user> (-> (-> [0 1 2] first) inc)
1

注意

这些例子可以在书籍源代码的src/m_clj/c4/macroexpand.clj中找到。

前面的输出中使用->宏的所有三个表达式都将被评估为值1。这是因为它们都被宏展开为相同的最终表达式。我们如何证明这一点呢?嗯,我们可以使用macroexpand-1macroexpandclojure.walk/macroexpand-all函数来证明。macroexpand函数返回一个形式的完整宏展开,如下所示:

user> (macroexpand '(-> [0 1 2] first inc))
(inc (first [0 1 2]))

使用 -> 连接宏的表达式因此被转换为 (inc (first [0 1 2])) 表达式,该表达式计算出的值为 1。这样,macroexpand 函数允许我们检查表达式的宏展开形式。

macroexpand-1 函数返回宏的第一个展开。实际上,macroexpand 函数只是重复应用 macroexpand-1 函数,直到无法再进行宏展开。我们可以使用这些函数检查 (-> [0 1 2] (-> first inc)) 表达式的宏展开过程:

user> (macroexpand-1 '(-> [0 1 2] (-> first inc)))
(-> [0 1 2] first inc)
user> (macroexpand '(-> [0 1 2] (-> first inc)))
(inc (first [0 1 2]))

macroexpand 函数有一个小的限制。它只重复宏展开一个表达式,直到表达式的第一个形式是宏。因此,macroexpand 函数不会完全宏展开 (-> (-> [0 1 2] first) inc) 表达式,如下所示:

user> (macroexpand-1 '(-> (-> [0 1 2] first) inc))
(inc (-> [0 1 2] first))
user> (macroexpand '(-> (-> [0 1 2] first) inc))
(inc (-> [0 1 2] first))

如前例所示,macroexpand 函数将返回与 macroexpand-1 相同的宏展开结果。这是因为对 -> 宏的第二次调用不是前一个表达式的第一次宏展开结果中的第一个形式。在这种情况下,我们可以使用 clojure.walk 命名空间中的 macroexpand-all 函数来宏展开给定表达式,无论宏调用在其中的位置如何,如下所示:

user> (clojure.walk/macroexpand-all '(-> (-> [0 1 2] first) inc))
(inc (first [0 1 2]))

因此,使用 -> 宏作为示例的所有三个表达式都被宏展开为相同的表达式 (inc (first [0 1 2])),该表达式计算出的值为 1

注意

macroexpand-1macroexpandclojure.walk/macroexpand-all 函数对不包含任何宏的表达式没有任何影响。

macroexpand-1macroexpand 函数是调试用户定义宏的不可或缺的工具。此外,clojure.walk/macroexpand-all 函数可以在 macroexpand 函数无法完全宏展开给定表达式的情况下使用。Clojure 读取器也使用这些函数来宏展开程序的源代码。

创建宏

宏是通过 defmacro 形式定义的。宏名、宏的参数向量、可选的文档字符串和宏的主体必须传递给此形式。我们还可以指定宏的多个可变性。它与 defn 形式的相似性非常明显。然而,与 defn 形式不同的是,使用 defmacro 形式定义的宏不会评估传递给它的参数。换句话说,传递给宏的参数是隐式引用的。例如,我们可以创建几个宏来重写中缀和后缀表示法中的 s 表达式,如 示例 4.1 所示。

(defmacro to-infix [expr]
  (interpose (first expr) (rest expr)))

(defmacro to-postfix [expr]
  (concat (rest expr) [(first expr)]))

示例 4.1:将前缀表达式转换的宏

注意

这些示例可以在书籍源代码的 src/m_clj/c4/defmacro.clj 中找到。

示例 4.1中的每个宏都描述了一种优雅的重写表达式expr的方法,将其视为一个通用序列。在表达式expr中调用的函数使用first形式提取,其参数使用rest形式获得。要将表达式转换为其中缀形式,我们使用interpose函数。同样,表达式expr的后缀形式使用concat形式生成。我们可以使用macroexpand函数来检查to-infixto-postfix宏生成的表达式,如下所示:

user> (macroexpand '(to-infix (+ 0 1 2)))
(0 + 1 + 2)
user> (macroexpand '(to-postfix (+ 0 1 2)))
(0 1 2 +)

注意

表达式x + y被称为是使用中缀表示法书写的。这个表达式的前缀表示法是+ x y,而其后缀表示法是x y +

通过转换表达式,我们可以有效地修改语言。就这么简单!示例 4.1to-infixto-postfix宏的基础是,我们可以将表达式的项视为一个元素序列,并使用诸如interposeconcat之类的序列函数来操作它们。当然,前面的例子足够简单,以至于我们可以完全避免使用引号。defmacro形式也可以与引号结合使用,以便轻松重写更复杂的表达式。同样的规则可以应用于 Clojure 代码的任何形式。

有趣的是,宏在内部表示为函数,这可以通过取消引用宏的完全限定名称并使用fn?函数来验证,如下所示:

user> (fn? @#'to-infix)
true
user> (fn? @#'to-postfix)
true

注意

在编写这本书的时候,ClojureScript 只支持用 Clojure 编写的宏。宏必须使用:require-macros关键字在 ClojureScript 命名空间声明中引用,如下所示:

(ns my-cljs-namespace
  (:require-macros [my-clj-macro-namespace :as macro]))

symbolgensym函数可以用来在宏体内部创建临时变量。symbol函数从一个名称和一个可选的命名空间返回一个符号,如下所示:

user> (symbol 'x)
x
user> (symbol "x")
x
user> (symbol "my-namespace" "x")
my-namespace/x

注意

我们可以使用symbol?谓词来检查一个值是否是符号。

gensym 函数可以用来创建一个唯一的符号名称。我们可以指定一个前缀,用于gensym函数返回的符号名称。默认的前缀是一个大写字母G后跟两个下划线(G__)。gensym函数还可以用来创建一个新的唯一关键字。我们可以在 REPL 中尝试gensym函数,如下所示:

user> (gensym)
G__8090
user> (gensym 'x)
x8081
user> (gensym "x")
x8084
user> (gensym :x)
:x8087

如下所示,每次调用gensym函数时,它都会创建一个新的符号。在语法引号形式中,我们可以使用由前缀名称和gensym函数创建的自动符号名称,如下所示:

user> `(let [x# 10] x#)
(clojure.core/let [x__8561__auto__ 10]
  x__8561__auto__)
user> (macroexpand `(let [x# 10] x#))
(let* [x__8910__auto__ 10]
  x__8910__auto__)

注意

let形式实际上是一个使用let*特殊形式定义的宏。

如前所述的表达式所示,语法引号形式中所有出现的 自动生成符号 变量 x# 都被替换为自动生成的符号名称。我们应该注意,只有符号,而不是字符串或关键字,可以用作自动生成符号的前缀。

通过这种方式生成唯一的符号,我们可以创建 卫生宏,这些宏避免了 符号捕获变量捕获 的可能性,这是一个在使用动态作用域变量和宏时出现的有趣问题。为了说明这个问题,考虑 示例 4.2 中定义的宏:

(defmacro to-list [x]
  `(list ~x))

(defmacro to-list-with-capture [x]
  `(list ~'x))

示例 4.2:表示符号捕获的宏

示例 4.2 中的宏使用 list 形式和值 x 创建一个新的列表。当然,我们在这里实际上并不需要使用宏,但这样做只是为了演示符号捕获。to-list-with-capture 宏通过使用 ~' 操作从周围作用域 捕获 变量 x。如果我们使用 let 形式将变量名 x 绑定到一个值,那么在调用 to-listto-list-with-capture 宏时将得到不同的结果,如下所示:

user> (let [x 10]
 (to-list 20))
(20)
user> (let [x 10]
 (to-list-with-capture 20))
(10)

to-list-with-capture 函数似乎会从周围的作用域动态获取 x 的值,而不是从传递给它的参数中获取。正如你可能猜到的,这可能会导致许多微妙且奇怪的错误。在 Clojure 中,这个问题的解决方案很简单;一个语法引号形式会将所有自由符号解析为命名空间限定名称。这可以通过宏展开前一个示例中使用 to-list 函数的表达式来验证。

假设我们想使用一个宏来执行与 示例 4.2 中的 to-list 宏相同任务的 let 形式来使用一个临时变量。这看起来可能相当没有必要,但这样做只是为了演示语法引号如何解析符号。这样的宏可以像 示例 4.3 中所示那样实现:

(defmacro to-list-with-error [x]
  `(let [y ~x]
     (list y)))

调用 to-list-with-error 宏会导致错误,因为使用了自由符号 y,如下所示:

user> (to-list-with-error 10)
CompilerException java.lang.RuntimeException:
Can't let qualified name: user/y

这个错误可能相当令人烦恼,因为我们只是想在 to-list-with-error 宏的主体中使用一个临时变量。这个错误发生是因为不清楚变量 y 是从哪里解析出来的。为了绕过这个错误,我们可以将变量 y 声明为自动生成符号变量,如 示例 4.4 中所示:

(defmacro to-list-with-gensym [x]
  `(let [y# ~x]
     (list y#)))

示例 4.4:使用 let 形式和自动生成符号变量的宏

to-list-with-gensym 宏按预期工作,没有任何错误,如下所示:

user> (to-list-with-gensym 10)
(10)

我们还可以使用 macroexpandmacroexpand-1 形式来检查 to-list-with-gensym 宏生成的表达式,鼓励读者在 REPL 中尝试这样做。

总结来说,使用 defmacro 形式定义的宏可以用来重写和转换代码。语法引号和自动生成符号变量可以用来编写卫生宏,从而避免由于动态作用域的使用而可能出现的某些问题。

注意

语法引用实际上可以作为一个用户定义的宏来实现。例如,syntax-quote (github.com/hiredman/syntax-quote) 和 backtick (github.com/brandonbloom/backtick) 库展示了语法引用是如何通过宏来实现的。

在宏中封装模式

在 Clojure 中,可以使用宏来用函数和特殊形式重写表达式。然而,在 Java 和 C#等语言中,为了处理特殊形式,语言中添加了大量的额外语法。例如,考虑这些语言中的if构造,它用于检查表达式是否为真。这个构造确实有一些特殊的语法。如果在用这些语言编写的程序中发现了if构造的重复使用模式,就没有明显的方法来自动化这个模式。Java 和 C#等语言有设计模式的概念,可以封装这类模式。但是,如果没有重写表达式的功能,在这些语言中封装模式可能会变得有些不完整和繁琐。我们向语言中添加的特别形式和语法越多,程序化生成代码就越困难。另一方面,Clojure 和其他 Lisp 中的宏可以轻松地重写表达式,以自动化代码中的重复模式。此外,在 Lisp 中,代码和数据是一致的,因此几乎没有代码的特殊语法。从某种意义上说,Lispy 语言中的宏通过扩展语言以我们自己的手工构造来允许我们简洁地封装设计模式。

让我们探索一些示例,以展示宏如何被用来封装模式。Clojure 中的->->>线程宏通过传递一个初始值来组合几个函数。换句话说,初始值是通过->->>宏的参数传递的各种形式进行线程化的。这些宏作为 Clojure 语言的一部分定义在clojure.core命名空间中,如示例 4.5所示。

注意

以下示例可以在书籍源代码的src/m_clj/c4/threading.clj中找到。

(defmacro -> [x & forms]
  (loop [x x
         forms forms]
    (if forms
      (let [form (first forms)
            threaded (if (seq? form)
                       (with-meta
                         `(~(first form) ~x ~@(next form))
                         (meta form))
                       (list form x))]
        (recur threaded (next forms)))
      x)))

(defmacro ->> [x & forms]
  (loop [x x
         forms forms]
    (if forms
      (let [form (first forms)
            threaded (if (seq? form)
                       (with-meta
                         `(~(first form) ~@(next form) ~x)
                         (meta form))
                       (list form x))]
        (recur threaded (next forms)))
      x)))

示例 4.5:->和->>线程宏

示例 4.5 中的 ->->> 宏使用 loop 形式递归地将值 x 线程通过由 forms 表示的表达式。一个形式中的第一个符号,即被调用的函数,是通过使用 first 函数确定的。除了 x 之外,要传递给此函数的参数是通过 next 函数提取的。如果一个形式只是一个不带任何额外参数的函数名,我们则使用表达式 (list form x) 创建一个新的形式。with-meta 形式用于保留使用 form 指定的任何元数据。-> 宏将 x 作为第一个参数传递,而 ->> 宏将 x 作为最后一个参数传递。这是以递归方式对所有传递给这些宏的形式进行的。有趣的是,->->> 宏都很少使用语法引用形式。我们实际上可以将这些宏的一些部分重构为函数。这带来了一点点优势,因为与宏相比,函数可以很容易地进行测试。->->> 线程宏可以重构如 示例 4.6示例 4.7 所示:

(defn thread-form [first? x form]
  (if (seq? form)
    (let [[f & xs] form
          xs (conj (if first? xs (vec xs)) x)]
      (apply list f xs))
    (list form x)))

(defn threading [first? x forms]
  (reduce #(thread-form first? %1 %2)
          x forms))

示例 4.6: 重构 -> 和 ->> 线程宏

示例 4.6 中的 thread-form 函数使用 conj 函数在表达式形式中定位值 x。这里的假设是 conj 函数将在列表的头部添加一个元素,或在向量的末尾或尾部添加一个元素。first? 参数用于指示值 x 是否需要作为 form 的第一个参数传递。threading 函数简单地将 thread-form 函数应用于它接收到的所有表达式,即 forms。现在可以使用 threading 函数实现 ->->> 宏,如 示例 4.7 所示:

(defmacro -> [x & forms]
  (threading true x forms))

(defmacro ->> [x & forms]
  (threading false x forms))

示例 4.7: 重构 -> 和 ->> 线程宏(续)

示例 4.7 中定义的线程宏与 示例 4.5 中的宏效果一样好,我们可以在 REPL 中验证这一点。这被留作读者的练习。

let 表达式的常见用法是通过将变量传递给多个函数来反复重新绑定其值。这种模式可以使用 as-> 线程宏进行封装,该宏的定义如 示例 4.8 所示。

(defmacro as-> [expr name & forms]
  `(let [~name ~expr
         ~@(interleave (repeat name) forms)]
     ~name))

示例 4.8: 重构 -> 和 ->> 线程宏

让我们跳过用文字解释 as-> 宏的细节,而直接使用 macroexpand 函数描述它生成的代码,如下所示:

user> (macroexpand '(as-> 1 x (+ 1 x) (+ x 1)))
(let* [x 1
       x (+ 1 x)
       x (+ x 1)]
      x)
user> (as-> 1 x (+ 1 x) (+ x 1))
3

as-> 宏将其第一个参数绑定到由其第二个参数表示的符号,并生成一个 let* 形式作为结果。这允许我们以显式符号的形式定义必须在线程中传递的表达式。甚至可以说,与使用 ->->> 宏相比,这是一种更灵活的方式来执行通过多个表达式传递值的线程操作。

注意

as-> 形式是在 Clojure 1.5 中引入的,与其他几个线程宏一起。

因此,宏是自动化或封装代码中模式的强大工具。Clojure 语言中一些常用的形式实际上被定义为宏,我们也可以轻松地定义自己的宏。

使用读取条件

在 Clojure 及其方言(如 ClojureScript)中,经常需要与原生对象进行交互。我们可以使用读取条件来定义特定平台的代码。现在让我们简要地看看我们如何使用读取条件。

注意

读取条件在 Clojure 1.7 版本中被引入。在版本 1.7 之前,特定平台的 Clojure/ClojureScript 代码必须使用cljx库(github.com/lynaghk/cljx)来管理。

读取条件形式,写作#?( ... ),允许我们使用:cljs:clj:clr:default关键字来定义特定平台的代码。读取条件拼接形式,写作#?@( ... ),具有与读取条件形式类似的语义。它可以用来将特定平台的值或表达式列表拼接到一个形式中。这两个条件形式在读取代码时被处理,而不是在宏展开时。

自 Clojure 1.7 以来,read-string函数有一个第二个参数,我们可以指定一个映射作为参数。这个映射可以有两个键,:read-cond:features。当包含条件形式的字符串传递给read-string函数时,可以通过在映射的:features键中指定平台作为一组关键字(由:cljs:clj:clr表示)来生成特定平台的代码。在这种情况下,必须指定关键字:allow作为传递给read-string函数的映射中:read_cond键的值,否则将抛出异常。我们可以在 REPL 中使用read-string函数尝试读取条件形式,如下所示:

user> (read-string {:read-cond :allow :features #{:clj}}
 "#?(:cljs \"ClojureScript\" :clj \"Clojure\")")
"Clojure"
user> (read-string {:read-cond :allow :features #{:cljs}}
 "#?(:cljs \"ClojureScript\" :clj \"Clojure\")")
"ClojureScript"

注意

这些示例可以在书籍源代码的src/m_clj/c4/reader_conditionals.cljc中找到。

类似地,我们可以使用read-string函数将条件拼接形式读取到一个表达式中,如下所示:

user> (read-string {:read-cond :allow :features #{:clr}}
 "[1 2 #?@(:cljs [3 4] :default [5 6])]")
[1 2 5 6]
user> (read-string {:read-cond :allow :features #{:clj}}
 "[1 2 #?@(:cljs [3 4] :default [5 6])]")
[1 2 5 6]
user> (read-string {:read-cond :allow :features #{:cljs}}
 "[1 2 #?@(:cljs [3 4] :default [5 6])]")
[1 2 3 4]

我们还可以通过在传递给read-string函数的可选映射中指定:preserve关键字和:read-cond键来防止条件形式的转换,如下所示:

user> (read-string {:read-cond :preserve}
 "[1 2 #?@(:cljs [3 4] :clj [5 6])]")
[1 2 #?@(:cljs [3 4] :clj [5 6])]

然而,在实际操作中,将条件形式包裹在字符串中并不是我们应该做的事情。通常,我们应该将所有特定平台的代码作为读取条件形式写入具有.cljc扩展名的源文件中。一旦.cljc文件中定义的顶层形式被 Clojure 读取器处理,我们就可以像使用任何其他读取形式一样使用它们。例如,考虑在示例 4.9中使用读取条件形式编写的宏:

(defmacro get-milliseconds-since-epoch []
  `(.getTime #?(:cljs (js/Date.)
                :clj (java.util.Date.))))

示例 4.9:使用读取条件的宏

示例 4.9中,get-milliseconds-since-epoch宏在从 Clojure 代码调用时,会在新的java.util.Date实例上调用.getTime方法。此外,当在 ClojureScript 代码中使用时,此宏也会在新的 JavaScriptDate对象上调用.getTime方法。我们可以从 Clojure REPL 中宏展开对get-milliseconds-since-epoch宏的调用,以生成特定于 JVM 的代码,如下所示:

user> (macroexpand '(get-milliseconds-since-epoch))
(. (java.util.Date.) getTime)

因此,读取条件有助于封装特定于平台的代码,以便在不受底层平台影响的代码中使用。

避免使用宏

宏是定义 Clojure 中我们自己的结构的一种极其灵活的方式。然而,在程序中不小心使用宏可能会变得复杂,并导致许多隐藏在视线之外的奇怪错误。正如Stuart HallowayAaron Bedra在《Programming Clojure》一书中所描述的,Clojure 中宏的使用有两个经验法则:

  • 不要编写宏:每次我们尝试使用宏时,我们必须三思是否可以使用函数来完成相同的任务。

  • 如果它是封装模式的唯一方法,则编写宏:只有当宏比调用函数更容易或更方便时,才应使用宏。

宏的问题是什么?嗯,宏以几种方式使程序的代码变得复杂:

  • 宏不能像函数那样组合,因为它们实际上不是值。例如,不可能将宏作为参数传递给mapapply形式。

  • 宏不像函数那样容易测试。虽然可以通过编程方式完成,但测试宏的唯一方法是通过使用宏展开函数和引用。

  • 在某些情况下,调用宏的代码可能本身就是作为宏编写的,从而增加了我们代码的复杂性。

  • 由符号捕获等问题引起的隐藏错误使宏变得有些棘手。在大型代码库中调试宏也不是很容易。

由于这些原因,宏必须谨慎和负责任地使用。实际上,如果我们可以使用宏和函数解决同一个问题,我们应该始终优先选择使用函数的解决方案。如果确实需要使用宏,我们应该始终努力将尽可能多的代码从宏中重构到函数中。

除了这些,宏使编程变得很有趣,因为它们允许我们定义自己的结构。它们提供了一种在其他语言中实际上不可能的自由和灵活性。你可能经常听到经验丰富的 Clojure 程序员告诉你宏是邪恶的,你不应该使用它们,但不要让这一点阻止你探索宏所能实现的可能性。一旦你遇到并解决了使用宏时出现的一些问题,你将拥有足够的经验来判断何时适当地使用宏。

摘要

我们在本章中探讨了如何在 Clojure 中使用元编程。我们讨论了代码是如何被读取、宏展开和评估的,以及实现这些操作的各个原始构造。宏可以用来封装代码中的模式,正如我们在本章的各个示例中所展示的那样。在章节的结尾,我们还讨论了读取条件,并指出了使用宏时出现的各种复杂情况。

在下一章中,我们将探讨如何使用转换器处理任何数据,无论数据源是什么。

第五章。组合转换器

让我们回到在 Clojure 中执行数据计算之旅。我们已经在第三章中讨论了如何使用归约器来处理集合,即并行化使用归约器。实际上,转换器是归约器的一种推广,它独立于数据源。此外,归约器更多地关于并行化,而转换器则更专注于泛化数据转换,而不限制我们使用任何特定的数据源。转换器捕捉了在序列上操作的标准函数(如mapfilter)的本质,适用于多个数据源。它们允许我们定义和组合数据转换,而不管数据是如何提供给我们的。

顺便提一下,在物理学的背景下,转换器是一种将一种形式的能量转换为另一种形式的设备。从某种意义上说,Clojure 转换器可以被看作是捕获函数中的能量(如mapfilter),并在不同数据源之间进行转换的方法。这些数据源包括集合、流和异步通道。转换器也可以扩展到其他数据源。在本章中,我们将重点关注如何使用转换器处理序列和集合,并将讨论与异步通道相关的转换器留到我们讨论第八章的core.async库时再进行。稍后在本章中,我们将研究转换器在 Clojure 中的实现方式。

理解转换器

转换器本质上是一系列可以组合并应用于任何数据表示的转换。它们允许我们定义与数据源实现特定细节无关的转换。转换器还有显著的性能优势。这归因于避免了在转换之间存储中间结果时对任意容器(如序列或其他集合)进行不必要的内存分配。

注意

转换器是在 Clojure 1.7 中引入的。

可以在不使用 transducer 的情况下组合转换。这可以通过使用comppartial形式来完成。我们可以将任意数量的转换传递给comp函数,comp函数返回的转换将是按照从右到左的顺序提供的转换的组合。在 Clojure 中,转换传统上表示为xfxform

注意

以下示例可以在书籍源代码的src/m_clj/c5/transduce.clj中找到。

例如,表达式(comp f g)将返回一个函数,该函数首先将函数g应用于其输入,然后将函数f应用于结果。partial函数可以将函数绑定到任意数量的参数并返回一个新的函数。comp函数可以与partial形式一起使用来组合mapfilter函数,如下所示:

user> (def xf-using-partial (comp
 (partial filter even?)
 (partial map inc)))
#'user/xf-using-partial
user> (xf-using-partial (vec (range 10)))
(2 4 6 8 10)

在前面的输出中,partial函数被用来将inceven?函数分别绑定到mapfilter函数上。上面显示的两种partial形式返回的函数都将期望传递给它们的集合。因此,它们代表了可以应用于给定集合的转换。这两个转换随后与comp函数组合,创建一个新的函数xf-using-partial。然后,这个函数被应用于一个数字向量,以返回一个偶数序列。这段代码存在一些问题:

  • 使用even?函数过滤偶数是在应用inc函数之后进行的。这证明了传递给comp函数的转换是按照从右到左的顺序应用的,这与它们指定的顺序相反。有时这可能会有些不方便。

  • xf-using-partial函数返回的值是一个列表而不是向量。这是因为mapfilter函数都返回惰性序列,最终会被转换成列表。因此,使用vec函数对xf-using-partial函数返回的集合类型没有影响。

  • 此外,xf-using-partial函数应用的转换(partial map inc)将创建一个新的序列。这个生成的序列随后被传递给转换(partial filter even?)。如果我们有多个必须组合的转换,那么在内存方面,中间使用序列既不必要也是浪费的。

这引出了转换器,它解决了使用 comppartial 形式组合转换所涉及的前述问题。在正式意义上,转换器是一个修改 步骤函数 的函数。这个步骤函数在 reducer 的上下文中类似于一个归约函数。步骤函数将输入值与给定计算的累积结果相结合。转换器接受一个步骤函数作为参数,并产生其修改后的版本。实际上,xfxform 语法也用来表示转换器;因为转换器也是一种转换,它转换步骤函数。虽然没有代码可能难以说明,但转换器执行的步骤函数的这种修改实际上描绘了某些输入数据是如何被给定计算消耗以产生结果的。几个转换器也可以组合在一起。这样,转换器可以被视为处理数据的一个统一模型。

一些标准的 Clojure 函数在用单个参数调用时返回转换器。这些函数要么:

  • 接受一个函数以及一个集合作为参数。此类函数的例子有 mapfiltermapcatpartition-by

  • 接受一个表示元素数量的值,通常指定为 n,以及一个集合。这个类别包括 takedroppartition-all 等函数。

    注意

    访问 clojure.org/transducers 获取实现转换器的完整标准函数列表。

Rich Hickey 的行李装载示例很好地描述了转换器的使用。假设我们打算将几个行李装入飞机。这些行李将以托盘的形式提供,可以将其视为行李的集合。要将行李装入飞机,必须执行几个步骤。首先,必须将行李从提供的托盘上解开。接下来,我们必须检查一个行李是否包含任何食物,如果不包含,则不再进一步处理。最后,所有行李都必须称重并贴上标签,以防它们很重。请注意,这些将行李装入飞机的步骤并没有指定托盘是如何提供给我们,或者标签过的行李是如何从最后一步运输到飞机上的。

我们可以将将行李装入飞机的过程建模为 示例 5.1 中的 process-bags 函数所示,如下所示:

(declare unbundle-pallet)
(declare non-food?)
(declare label-heavy)

(def process-bags
  (comp
   (partial map label-heavy)
   (partial filter non-food?)
   (partial mapcat unbundle-pallet)))

示例 5.1:将行李装入飞机

示例 5.1 中的 unbundle-palletnon-food?label-heavy 函数代表了将包裹装入飞机的三个步骤。这些函数使用 mapfiltermapcat 函数应用于包裹集合。此外,它们还可以使用 comppartial 函数以从右到左的顺序进行组合。正如我们之前所描述的,mapfiltermapcat 函数在被调用时都会生成序列,因此在三个转换之间创建了包裹的中间集合。这种中间使用序列的方式类似于在步骤执行后将所有包裹放在手推车上。提供的输入和最终结果都会是一个手推车包裹。使用手推车不仅会在我们过程的步骤之间增加额外的工作,而且步骤现在与手推车的使用变得复杂。因此,如果我们必须使用,比如说,传送带而不是手推车来运输行李,我们就必须重新定义这些步骤。这意味着如果我们要生成不同类型的最终结果集合,我们就必须重新实现 mapfiltermapcat 函数。或者,我们可以使用转换器来实现 process-bags 函数,而不指定输入或结果的集合类型,如 示例 5.2 所示:

(def process-bags
  (comp
   (mapcat unbundle-pallet)
   (filter non-food?)
   (map label-heavy)))

示例 5.2:使用转换器将包裹装入飞机

示例 5.2 中的 process-bags 函数展示了如何以从左到右的顺序使用转换器来组合 unbundle-palletnon-food?label-heavy 函数。在 示例 5.2 中传递给 comp 函数的每个表达式都返回一个转换器。这个 process-bags 函数的实现执行时不会创建任何中间集合。

从转换器生成结果

转换器只是计算过程的配方,本身不能执行任何实际工作。当与数据源结合时,转换器可以生成结果。还有一个至关重要的组成部分,那就是步进函数。为了组合转换器、步进函数和数据源,我们必须使用 tranduce 函数。

传递给 transduce 的步进函数也用于生成要生成的结果的初始值。这个结果的初始值也可以作为 transduce 函数的参数指定。例如,transduce 函数可以与以下所示的 conj 形式一起使用:

user> (def xf (map inc))
#'user/xf
user> (transduce xf conj [0 1 2])
[1 2 3]
user> (transduce xf conj () [0 1 2])
(3 2 1)

inc 函数与 map 函数结合,创建了一个转换器 xf,如前所述。可以使用 conj 函数从转换器 xf 中生成列表或向量。之前展示的 transduce 函数两种形式的元素顺序不同,这是因为 conj 函数会将元素添加到列表的头部,而不是向向量的末尾添加。

我们也可以使用comp函数将几个转换器组合在一起,如下所示:

user> (def xf (comp
 (map inc)
 (filter even?)))
#'user/xf
user> (transduce xf conj (range 10))
[2 4 6 8 10]

前面的输出中的转换器xf封装了使用mapfilter形式分别应用inceven?函数。当与transduceconj形式一起使用时,此转换器将生成一个偶数向量。请注意,inc函数确实应用于提供的集合(range 10),否则10的值不会出现在最终结果中。使用转换器xf进行的这种计算可以表示如下:

从转换器生成结果

前面的图示说明了转换(map inc)(filter even?)conj如何在xf转换中组合。map形式首先应用,然后是filter形式,最后是conj形式。这样,转换器可以用于对任何数据源进行一系列转换。

从转换器生成集合的另一种方法是使用into函数。此函数的结果取决于它作为第一个参数提供的初始集合,如下所示:

user> (into [] xf (range 10))
[2 4 6 8 10]
user> (into () xf (range 10))
(10 8 6 4 2)

标准的sequence函数也可以从转换器生成惰性序列。当然,在 REPL 中返回的惰性序列将被转换为列表,如下所示:

user> (sequence xf (range 10))
(2 4 6 8 10)

到目前为止,我们已经组合了转换器来生成有限数量的元素集合。当与sequence函数一起使用时,转换器也可以生成无限值的序列。eduction函数可以用来表示这种计算。此函数将转换其最后一个参数指定的集合,并将其转换为任何以从右到左的顺序传递给它的转换。与使用序列相比,eduction形式可能需要更少的分配。

例如,如果我们使用nth函数检索序列中的第 100 个元素,前 99 个元素就必须实现,并在之后被丢弃,因为它们不再需要。另一方面,eduction形式可以避免这种开销。考虑示例 5.3simple-eduction的声明:

(def simple-eduction (eduction (map inc)
                               (filter even?)
                               (range)))

示例 5.3:使用eduction函数

示例 5.3中显示的集合simple-eduction将首先使用even?谓词从无限范围(range)中过滤出偶数值,然后使用inc函数增加这些值。我们可以使用nth函数从simple-eduction集合中检索元素。相同的计算也可以使用惰性序列来建模,但转换器表现得更好,如下所示:

user> (time (nth simple-eduction 100000))
"Elapsed time: 65.904434 msecs"
200001
user> (time (nth (map inc (filter even? (range))) 100000))
"Elapsed time: 159.039363 msecs"
200001

使用转换器的eduction形式比序列快两倍!从前面的输出中可以看出,转换器在组合多个转换方面比惰性序列表现显著更好。总之,使用如mapfilter之类的函数创建的转换器可以很容易地组合起来,使用如transduceintoeduction之类的函数生成集合。我们还可以使用转换器与其他数据源,如流、异步通道和可观察对象。

比较转换器和归约器

在第三章“使用归约器进行并行化”中讨论的转换器和归约器,都是提高集合上计算性能的方法。虽然转换器是针对多个数据源的数据处理的泛化,但转换器和归约器之间还有一些细微的区别,如下所述:

  • 转换器作为 Clojure 语言的一部分在clojure.core命名空间中实现。然而,归约器必须显式包含在程序中,因为它们在clojure.core.reducers命名空间中实现。

  • 转换器仅在生成一系列转换的最终结果时创建集合。不需要中间集合来存储构成转换器的转换结果。另一方面,归约器生成中间集合来存储结果,并且仅避免创建不必要的空集合。

  • 转换器处理一系列转换的高效组合。这与归约器通过并行化使用集合上的计算来挤压性能的方式是正交的。转换器的性能显著优于clojure.coreclojure.core.reducers命名空间中的reduce函数。当然,使用clojure.core.reducers/fold函数仍然是实现可并行化计算的好方法。

这些转换器和归约器之间的对比描述了这两种数据处理方法的不同之处。在实践中,这些技术的性能取决于实际实现的计算。一般来说,如果我们打算以高效的方式实现数据处理算法,我们应该使用转换器。另一方面,如果我们处理的是内存中的大量数据,且不需要 I/O 和惰性,我们应该使用归约器。鼓励读者比较transduce函数与clojure.core.reducers库中的reducefold函数在不同计算和数据源上的性能。

行动中的转换器

在本节中,我们将探讨转换器的实现方式。我们还将了解如何实现我们自己的可转换数据源的基本概念。

管理易变引用

一些转换器可以在内部使用状态。结果证明,现有的引用类型,如原子和 refs,对于转换器的实现来说不够快。为了解决这个问题,转换器还引入了一种新的易失性引用类型。易失性引用表示一个可变的变量,它不会被复制到线程局部缓存中。此外,易失性引用不是原子的。在 Java 中,它们使用volatile关键字与java.lang.Object类型实现。

注意

以下示例可以在书籍源代码的src/m_clj/c5/volatile.clj中找到。

我们可以使用volatile!函数创建一个新的易失性引用。然后可以使用@读取宏或deref形式检索易失性状态中包含的值。vreset!函数可以用来设置易失性引用的状态,如下所示:

user> (def v (volatile! 0))
#'user/v
user> @v
0
user> (vreset! v 1)
1

在前面的输出中,我们将值0封装在一个易失性状态中,然后使用vreset!函数将其状态设置为1。我们还可以使用vswap!函数来突变易失性引用中包含的状态。我们必须将易失性引用和要应用于引用中值的函数传递给此函数。我们还可以将提供的函数的任何其他参数作为vswap!函数的附加参数指定。vswap!函数可以用来改变我们之前定义的易失性引用v的状态,如下所示:

user> (vswap! v inc)
2
user> (vswap! v + 3)
5

在前面的输出中,vswap!函数的第一个调用使用inc函数增加存储在引用v中的值。同样,随后的vswap!函数调用将值3添加到易失性引用v中的新值,从而产生最终的值5

注意

我们可以使用volatile?谓词来检查一个值是否是易失性的。

有些人可能会争论,易失性引用类型与原子的语义相同。vreset!vswap!函数与用于原子的reset!swap!函数具有完全相同的形状。然而,易失性引用与原子的一个重要区别在于,与原子不同,易失性引用不保证对其执行的操作的原子性。因此,建议在单个线程中使用易失性引用。

创建转换器

由于转换器会修改提供的步骤函数,让我们首先定义一下步骤函数实际上做什么。以下方面需要考虑:

  • 步骤函数必须能够为其所建模的转换提供初始值。换句话说,步骤函数必须有一个恒等形式,它不接受任何参数。

  • 输入必须与计算到目前为止累积的结果相结合。这与减少函数将输入值与累积结果结合以产生新结果的方式类似。这种形式的 arity 与减少函数的 arity 相同;它需要两个参数来表示当前输入和累积结果。

  • 步函数还必须能够完成建模过程的计算,以返回某些内容。这可以通过一个接受单个参数的函数来实现,该参数表示累积的结果。

因此,步函数被表示为一个具有三个 arity 的函数,就像之前描述的那样。某些转换器可能还需要 早期终止,以便根据某些条件突然停止计算过程。

现在,让我们看看 clojure.core 命名空间中的一些标准函数是如何使用转换器实现的。当使用单个参数调用时,map 函数返回一个转换器。

注意

以下示例可以在书的源代码 src/m_clj/c5/implementing_transducers.clj 中找到。

以下 示例 5.4 描述了 map 函数是如何实现的:

(defn map
  ([f]
   (fn [step]
     (fn
       ([] (step))
       ([result] (step result))
       ([result input]
        (step result (f input))))))
  ([f coll]
   (sequence (map f) coll)))

示例 5.4:map 函数

map 函数的 1-arity 形式返回一个接受步函数的函数,步函数由 step 表示,并返回另一个步函数。返回的步函数有三个不同的 arity,就像我们在本节前面描述的那样。map 函数的精髓可以用表达式 (step result (f input)) 来描述,这可以翻译为“将函数 f 应用于当前输入 input,并使用函数 step 将其与累积结果 result 结合”。返回的步函数还有另外两个 arity——一个不接受任何参数,另一个接受一个参数。这些 arity 对应于我们之前描述的步函数的另外两种情况。

map 函数的第二个 arity,它返回一个集合而不是转换器,仅仅是 sequence 函数和由表达式 (map f) 返回的转换器的组合。集合的实际创建是由 sequence 函数完成的。map 函数的 1-arity 形式只描述了函数 f 如何在可转换的上下文(如集合)上应用。

类似地,filter 函数可以使用转换器来实现,如 示例 5.5 所示,如下所示:

(defn filter
  ([p?]
   (fn [step]
     (fn
       ([] (step))
       ([result] (step result))
       ([result input]
        (if (p? input)
          (step result input)
          result)))))
  ([p? coll]
   (sequence (filter p?) coll)))

示例 5.5:filter 函数

filter 函数的实现前提是使用谓词 p? 来有条件地将累积结果和当前输入(分别表示为 resultinput)结合起来。如果表达式 (p? input) 不返回一个真值,则累积结果将返回而不做任何修改。与 示例 5.4 中的 map 函数类似,filter 函数的 2-arity 形式使用 sequence 形式和转换器来实现。

要处理转换器中的早期终止,我们必须使用reducedreduced?函数。对已包裹在reduced形式的值调用 reduce 或步进函数将简单地返回包含的值。reduced?函数检查一个值是否已经被reduced,即包裹在reduced形式中。reducedreduced?形式都接受一个参数,如下所示:

user> (def r (reduced 0))
#'user/r
user> (reduced? r)
true

考虑以下在示例 5.6中使用的函数rf,它使用reduced形式确保累积结果永远不会超过 100 个元素:

(defn rf [result input]
  (if (< result 100)
    (+ result input)
    (reduced :too-big)))

示例 5.6:使用 reduced 函数

函数rf只是将所有输入相加以产生一个结果。如果将rf函数与一个足够大的集合一起传递给reduce函数,那么将返回:too-big值作为最终结果,如下所示:

user> (reduce rf (range 3))
3
user> (reduce rf (range 100))
:too-big

可以使用unreduced函数或@读取宏从reduced形式中提取值。此外,可以使用ensure-reduced函数代替reduced,以避免将reduced形式重新应用于已经 reduced 的值。

标准的take-while函数可以使用reduced形式和转换器来实现,如下面的示例 5.7所示:

(defn take-while [p?]
  (fn [step]
    (fn
      ([] (step))
      ([result] (step result))
      ([result input]
       (if (p? input)
         (step result input)
         (reduced result))))))

示例 5.7:take-while 函数

注意,仅在示例 5.7中描述了take-while函数的 1-arity 形式。take-while函数返回的步进函数使用表达式(p? input)来检查累积的结果是否需要与当前输入结合。如果p?谓词不返回一个真值,则通过将其包裹在reduced形式中来返回累积的结果。这防止了任何其他可能与take-while函数返回的转换组合的转换修改累积结果。这样,reduced形式可以用来包裹转换的结果,并根据某些条件逻辑执行早期终止。

让我们看看状态化转换器的实现方式。take函数返回一个维护内部状态的转换器。这个状态用于跟踪到目前为止已处理的项目数量,因为根据定义,take函数必须只从集合或其他可转换上下文中返回一定数量的项目。示例 5.8描述了如何使用一个易失性引用来维护状态实现take函数:

(defn take [n]
  (fn [step]
    (let [nv (volatile! n)]
      (fn
        ([] (step))
        ([result] (step result))
        ([result input]
         (let [n @nv
               nn (vswap! nv dec)
               result (if (pos? n)
                        (step result input)
                        result)]
           (if (not (pos? nn))
             (ensure-reduced result)
             result)))))))

示例 5.8:take 函数

take函数返回的转换器首先从提供的值n创建一个易失性引用nv来跟踪要处理的项目数量。然后返回的步骤函数会递减易失性引用nv,并使用step函数将结果与输入结合。这会一直重复,直到引用nv中包含的值是正数。一旦处理完所有n个项目,结果会被包裹在ensure-reduced形式中,以表示提前终止。在这里,ensure-reduced函数被用来防止将值result包裹在另一个reduced形式中,因为(step result input)可能返回一个已经减少的值。

最后,让我们快速看一下transduce函数的实现方式,如示例 5.9所示:

(defn transduce
  ([xform f coll] (transduce xform f (f) coll))
  ([xform f init coll]
   (let [xf (xform f)
         ret (if (instance? clojure.lang.IReduceInit coll)
               (.reduce ^clojure.lang.IReduceInit coll xf init)
               (clojure.core.protocols/coll-reduce coll xf init))]
     (xf ret))))

示例 5.9:transduce 函数

transduce函数有两种形式。transduce函数的 4 参数形式如果collclojure.lang.IReduceInit接口的实例,则会调用其.reduce方法。此接口定义了一个名为reduce的单个方法,它表示如何使用给定的函数和初始值来减少数据源。如果变量coll没有实现此接口,transduce函数将回退到coll-reduce函数来处理由coll表示的数据源。简而言之,transduce函数将尝试以最快的方式处理可转换上下文。所有必须支持transduce使用的数据源都必须实现clojure.lang.IReduceInit接口。

transduce函数的 3 参数形式通过调用不带任何参数的提供的函数f来生成转换的初始值。因此,这个transduce函数的参数形式只能与提供恒等值的函数一起使用。

注释

本节中所示mapfiltertaketake-while函数的定义是它们实际定义的简化版本。然而,transduce函数在clojure.core命名空间中的实现方式是准确的。

这描绘了转换器和transduce函数的实现方式。如果我们需要实现自己的可转换数据源,本节中描述的实现可以作为指南。

摘要

到目前为止,我们已经看到了如何使用序列、归约器和转换器来处理数据。在本章中,我们描述了如何使用转换器进行高效的计算。我们还简要研究了转换器在 Clojure 语言中的实现方式。

在下一章中,我们将探索 Clojure 中的代数数据结构,如函子、应用和单子,这些概念将加深我们对函数组合的理解,这是函数式编程的基石。

第六章:探索范畴论

在探索函数式编程的旅途中,程序员最终会遇到 范畴论。首先,我们可以这样说,研究范畴论并不是编写更好代码的必要条件。它在纯函数式编程语言的内部更为普遍,如 Haskell 和 Idris,在这些语言中,函数是 的,更类似于没有隐式副作用(如 I/O 和修改)的数学函数。然而,范畴论帮助我们推理计算的非常基础和实用的一个方面:组合。与纯函数式编程语言中的函数不同,Clojure 中的函数可以执行 I/O 和其他副作用。当然,在特定情况下,它们可以是纯的,因此范畴论的概念对于 Clojure 来说仍然很有用,可以帮助我们基于纯函数编写可重用和可组合的代码。

范畴论可以被视为一个用于建模组合的数学框架。在本章中,我们将使用 Clojure 讨论范畴论的一些概念。我们还将研究一些代数类型,如函子、幺半群和单子。

揭开范畴论的面纱

范畴论有其独特的符号和约定。让我们从探索范畴论中使用的术语开始,用我们这些凡人程序员能理解的语言。

一个 范畴 正式定义为对象和 态射 的集合。简单来说,对象代表抽象类型,态射代表在这些类型之间进行转换的函数。因此,范畴类似于一种编程语言,它只有几种类型和函数,并且有两个基本属性:

  • 对于范畴中的每个对象,都存在一个 恒等态射。在实践中,可以使用单个恒等函数来表示所有给定对象的恒等态射,但这不是强制性的。

  • 范畴中的态射可以组合成一个新的态射。实际上,两个或更多态射的组合是逐个应用单个态射的优化。这样,几个态射的组合被认为是与构成态射的应用 交换 的。

范畴中的态射可以按照以下图示进行组合:

揭开范畴论的面纱

在前面的图中,顶点ABC是对象,箭头是这些对象之间的形态。形态I[A]I[B]I[C]是恒等形态,将对象ABC映射到自身。形态fA映射到B,同样地,形态gB映射到C。这两个形态可以组合在一起,如形态范畴论揭秘所示,该形态将A直接映射到C,因此形态范畴论揭秘与形态fg交换。因此,前面的图被称为交换图。请注意,与前面的图不同,交换图中的恒等形态通常不会显示。

注意

以下示例可以在书籍源代码的src/m_clj/c6/ demystifying_cat_theory.clj中找到。

现在,让我们将之前的图转换为 Clojure 代码。我们将使用内置的字符串、符号和关键字类型来展示如何使用comp函数将这些类型之间的形态(或函数)组合在一起:

范畴论揭秘

如前图所示,name函数将关键字转换为字符串,而symbol函数将字符串转换为符号。这两个函数可以组合成一个函数,该函数将关键字直接转换为符号,由(comp symbol name)函数表示。此外,每个范畴的恒等形态对应于identity函数。

注意

在内部,字符串、符号和关键字类型分别由java.lang.Stringclojure.lang.Symbolclojure.lang.Keyword类表示。

我们可以验证namesymbol函数可以使用comp形式组合在一起,如下面的 REPL 输出所示:

user> (name :x)
"x"
user> (symbol "x")
x
user> ((comp symbol name) :x)
x

这确立了这样一个事实:范畴论中的概念在 Clojure 以及其他编程语言中都有等价的表达形式。虽然将范畴中的对象视为我们刚刚描述的具体类型是完全可以接受的,但代数结构是对象的一个更实用的替代品。代数结构描述了类型的抽象属性,而不是类型中包含的数据或类型如何组织数据,它们更像是抽象类型。因此,范畴论就是关于组合具有特定属性的抽象类型上操作的函数。

在 Clojure 中,代数结构可以被视为协议。具体类型可以实现协议,因此一个类型可以代表多个代数结构。cats库(github.com/funcool/cats)采用这种方法,并提供了一些有趣的代数结构的基于协议的定义。cats库还提供了实现这些协议的类型。此外,这个库通过这些协议扩展了一些内置类型,使我们能够将它们视为代数结构。尽管有几种替代方案,但cats是唯一兼容 ClojureScript 的库。

注意

以下库依赖项对于即将到来的示例是必需的:

[funcool/cats "1.0.0"]

此外,以下命名空间必须包含在您的命名空间声明中:

(ns my-namespace
  (:require [cats.core :as cc]
            [cats.builtin :as cb]
            [cats.applicative.validation :as cav]
            [cats.monad.maybe :as cmm]
            [cats.monad.identity :as cmi]
            [cats.monad.exception :as cme]))

现在,让我们研究cats库中的一些代数结构。

使用幺半群

让我们从探索幺半群开始。为了定义一个幺半群,我们首先必须理解什么是半群。

注意

以下示例可以在书籍源代码的src/m_clj/c6/ monoids.clj中找到。

半群是一种支持结合二元运算的代数结构。如果一个二元运算,比如使用幺半群,运算使用幺半群产生与运算使用幺半群相同的结果,那么这个二元运算被称为结合。实际上,幺半群是一个具有额外属性的半群,我们将在下面看到这一点。

来自cats.core命名空间的mappend函数可以结合相同类型的一组实例,并返回给定类型的新实例。如果我们处理的是字符串或向量,mappend操作由标准的concat函数实现。因此,字符串和向量可以使用mappend函数进行组合,如下所示:

user> (cc/mappend "12" "34" "56")
"123456"
user> (cc/mappend [1 2] [3 4] [5 6])
[1 2 3 4 5 6]

由于字符串和向量支持结合的mappend操作,它们是半群。它们也是幺半群,它们只是具有单位元素的半群。很明显,字符串的单位元素是一个空字符串,而向量的单位元素是一个空向量。

这是个介绍功能编程世界中一个多才多艺的具体类型的好时机——Maybe类型。Maybe类型表示一个可选值,可以是空的或包含一个值。它可以被视为一个值在上下文或容器中。cats.monads.maybe命名空间中的justnothing函数可以用来构建Maybe类型的一个实例。just函数构建一个包含值的实例,而nothing函数创建一个空的Maybe值。可以通过将其传递给cats.monads.maybe/from-maybe函数或使用deref形式或@读取宏来解引用Maybe实例中的值。

顺便提一下,Maybe 类型也是一个幺半群,因为使用 nothing 函数创建的空 Maybe 值类似于一个单位元素。我们可以使用 mappend 函数来组合 Maybe 类型的值,就像任何其他幺半群一样,如下所示:

user> @(cc/mappend (cmm/just "123")
 (cmm/just "456"))
"123456"
user> @(cc/mappend (cmm/just "123")
 (cmm/nothing)
 (cmm/just "456"))
"123456"

因此,mappend 函数可以用来关联组合任何是幺半群的值。

使用函子

接下来,让我们看看 函子。函子本质上是一个容器或计算上下文中的值。函子必须实现 fmap 函数。这个函数将提供的函数应用到函子包含的值上。在面向对象术语中,函子可以被视为一个具有单个抽象方法 fmap 的通用类型。从某种意义上说,引用类型,如 refs 和 atoms,可以被视为保存结果的函子,因为引用类型将其包含的值应用于函数以获得应存储在其内的新值。

注意

以下示例可以在书籍源代码的 src/m_clj/c6/ functors.clj 中找到。

来自 cats.core 命名空间的 fmap 函数接受两个参数:一个函数和一个函子。函子本身定义了当函子的一个实例传递给 fmap 函数时会发生什么。cats 库将向量扩展为函子。当一个向量与一个函数一起传递给 fmap 函数时,提供的函数会被应用到向量中的所有元素上。等等!这不是 map 函数所做的吗?嗯,是的,但 map 函数总是返回一个惰性序列。另一方面,fmap 函数将返回一个与传递的函子具有相同具体类型的值。mapfmap 函数的行为可以比较如下:

user> (map inc [0 1 2])
(1 2 3)
user> (cc/fmap inc [0 1 2])
[1 2 3]

如上图所示,map 函数生成一个惰性序列,当它传递一个向量以及 inc 函数到 REPL 时,这个惰性序列会被实现为一个列表。然而,fmap 函数在传递相同的参数时会产生一个向量。我们应该注意,fmap 函数也被称为 <$>。惰性序列和集合也可以被视为函子,如下所示:

user> (cc/<$> inc (lazy-seq '(1)))
(2)
user> (cc/<$> inc #{1})
#{2}

Maybe 类型也是一个函子。当 fmap 函数传递一个 Maybe 时,它返回一个 maybe,如下所示:

user> (cc/fmap inc (cmm/just 1))
#<Just@ff5df0: 2>
user> (cc/fmap inc (cmm/nothing))
#<Nothing@d4fb58: nil>

fmap 函数对一个包含值的 Maybe 值应用时,它只对 inc 函数应用。这种 fmap 函数的行为可以通过以下图表来展示:

使用函子

上述图表描述了 fmap 函数如何传递 inc 函数和表达式 (cmm/just 1),并返回一个新的函子实例。fmap 函数从这个 Maybe 值中提取值,将 inc 函数应用到该值上,并创建一个新的包含结果的 Maybe 值。另一方面,fmap 函数将简单地返回一个使用 nothing 函数创建的空 Maybe 实例,而不对其进行任何操作,如下面的图表所示:

使用函子

fmap函数的这种行为由Maybe类型的实现定义。这是因为函子本身可以定义fmap函数如何与之交互。当然,实现fmap函数并不足以使类型成为函子。还有函子定律,任何可能的函子实现都必须满足这些定律。函子定律可以描述如下:

  1. 将恒等函子和函子F传递给fmap必须返回未经修改的函子F。我们可以使用identity函数将其翻译成 Clojure,如下所示:

    user> (cc/<$> identity [0 1 2])
    [0 1 2]
    
  2. 将函子F和函子同态f传递给fmap,然后传递结果和另一个函子同态gfmap,必须与调用fmap时使用函子F和复合使用函子等效。我们可以使用comp函数来验证这一点,如下所示:

    user> (->> [0 1 2]
     (cc/<$> inc)
     (cc/<$> (partial + 2)))
    [3 4 5]
    user> (cc/<$> (comp (partial + 2) inc) [0 1 2])
    [3 4 5]
    

第一定律描述了恒等函子,第二定律保持了函子复合。这些定律可以被视为fmap函数在有效函子中使用时可以执行的优化。

使用应用函子

应用函子是具有一些额外要求的函子子集,因此它们更有用。与应用函子类似,应用函子是能够将函数应用于其中包含的值的计算上下文。唯一的区别是,应用于应用函子的函数本身必须被包裹在应用函子的上下文中。应用函子还有与之关联的不同函数接口。在cats中,应用函子使用两个函数fapplypure进行操作。

注意

以下示例可以在书籍源代码的src/m_clj/c6/ applicatives.clj中找到。

来自cats.core命名空间的fapply函数可以用应用函子调用,如下所示:

user> @(cc/fapply (cmm/just inc)
 (cmm/just 1))
2

在这里,我们再次使用Maybe类型,这次作为应用函子。fapply函数将inc函数和值1Maybe值中解包,将它们组合并返回一个新的Maybe实例中的结果2。这可以用以下图表来说明:

使用应用函子

cats.core/pure函数用于创建应用函子的新实例。我们必须将实现特定的上下文,例如cats.monads.maybe/context,和一个值传递给pure函数,如下所示:

user> (cc/pure cmm/context 1)
#<Just@cefb4d: 1>

cats库提供了一个alet形式来轻松组合应用函子。其语法类似于let形式,如下所示:

user> @(cc/alet [a (cmm/just [1 2 3])
 b (cmm/just [4 5 6])]
 (cc/mappend a b))
[1 2 3 4 5 6]

如前所述的alet形式的主体返回的值被包裹在一个新的应用函子实例中并返回。周围的alet形式被取消引用,因此整个表达式返回一个向量。

来自cats.core命名空间的<*>函数是fapply函数的变长形式。它接受一个表示应用函子的值,后跟任意数量的产生应用函子的函数。cats库还提供了用于验证给定对象属性的Validation应用函子类型。此类型可以使用cats.applicative.validation命名空间中的okfail形式来构造。假设我们想要验证一个表示带有一些文本内容的页面的映射。页面必须有一个页码和一个作者。这种验证可以像示例 6.1中所示那样实现:

(defn validate-page-author [page]
  (if (nil? (:author page))
    (cav/fail {:author "No author"})
    (cav/ok page)))

(defn validate-page-number [page]
  (if (nil? (:number page))
    (cav/fail {:number "No page number"})
    (cav/ok page)))

(defn validate-page [page]
  (cc/alet [a (validate-page-author page)
            b (validate-page-number page)]
    (cc/<*> (cc/pure cav/context page) 
            a b)))

示例 6.1: cats.applicative.validation 类型

示例 6.1中的validate-page-authorvalidate-page-number函数检查映射是否包含:author:number键。这些函数使用ok函数创建Validation类型的实例,并类似地使用fail函数创建表示验证失败的Validation实例。validate-page-authorvalidate-page-number函数都通过<*>函数组合在一起。传递给<*>的第一个参数必须是使用pure函数创建的Validation类型的实例。因此,validate-page函数可以验证表示页面的映射,如下所示:

user> (validate-page {:text "Some text"})
#<Fail@1203b6a: {:author "No author", :number "No page number"}>
user> (validate-page {:text "Some text" :author "John" :number 1})
#<Ok@161b2f8: {:text "Some text", :author "John", :number 1}>

成功的验证将返回包含页面对象的Validation实例,而不成功的验证将返回包含适当的验证消息映射的Validation类型实例。这两种情况的具体类型是OkFail,如前面的输出所示。

应用函子必须自己定义与fapplypure函数一起的行为。当然,应用函子也必须遵守一些法则。除了函子的恒等性和组合性法则外,应用函子还必须遵守同态交换法则。鼓励读者在实现自己的应用函子之前了解更多关于这些法则的信息。

使用单子

最后,让我们看看一种代数结构,它帮助我们构建和组合一系列计算:单子。网上有无数教程和文章解释单子以及它们如何被使用。在本节中,我们将以我们独特且 Clojure 风格的方式来探讨单子。

在范畴论中,单子是函子之间的一个形态。这意味着单子将包含值的上下文转换为另一个上下文。在纯函数式编程语言中,单子是用于表示按步骤定义的计算的数据结构。每个步骤由单子上的一个操作表示,并且可以链式连接多个这些步骤。本质上,单子是任何计算步骤的可组合抽象。单子的一个独特特征是它们允许我们使用纯函数来模拟在给定计算的各种步骤中可能执行的纯副作用。

单子抽象了函数将值绑定到参数并返回值的方式。形式上,单子是一个实现两个函数的代数结构:bindreturnbind函数用于将函数应用于单子中包含的值,而return函数可以被视为将值包裹在新的单子实例中的构造。bindreturn函数的类型签名可以用以下伪代码描述:

bind : (Monad A a, [A -> Monad B] f) -> Monad B
return : (A a) -> Monad A

bind函数的类型签名表明它接受一个类型为Monad A的值和一个将类型A的值转换为另一个类型Monad B的函数,这只是一个包含类型B的值的单子。此外,bind函数返回类型Monad Breturn函数的类型签名显示它接受一个类型为A的值并返回类型Monad A。实现这两个函数允许单子在其bind实现中定义的任何代码在将提供的函数f应用于单子中包含的值之前执行。单子还可以定义当提供的函数f返回值时执行的代码,这是由单子的return函数实现定义的。

由于在传递给bind函数时,单子不仅可以对其包含的值调用函数,还可以做更多的事情,因此在纯函数式编程语言中,单子被用来表示副作用。比如说,我们有一个将类型A映射到B的函数。一个将类型A映射到Monad B的函数可以用来模拟当类型A的值转换为另一个类型B的值时可能发生的副作用。这样,单子可以用来表示副作用,如 IO、状态改变、异常和事务。

一些程序员甚至可能会争论,在具有宏的语言中,单子是不必要的。在某种程度上这是正确的,因为宏可以封装它们中的副作用。然而,单子帮助我们明确任何副作用,这非常有用。实际上,在纯函数式编程语言中,单子是唯一可以用来模拟副作用的方法。因为单子可以表示副作用,它们允许我们在纯函数式编程语言中编写命令式风格的代码,这完全是关于状态的修改。

注意

以下示例可以在书籍源代码的 src/m_clj/c6/ monads.clj 中找到。

现在,让我们看看 cats 库中的 Maybe 类型如何以单子的形式出现。我们可以将 Maybe 值和函数传递给 cats.core/bind 函数,以在单子中包含的值上调用提供的函数。此函数别名为 >>=bind 函数与 Maybe 类型的行为如下所示:

user> (cc/bind (cmm/just 1) inc)
2
user> (cc/bind (cmm/nothing) inc)
#<Nothing@24e44b: nil>

以这种方式,我们可以将 inc 函数绑定到 Maybe 单子上。前述输出中的表达式可以用以下图表示:

使用单子

inc 函数仅在 Maybe 单子包含值时应用。当一个 Maybe 单子确实包含值时,使用 bind 函数将其应用于 inc 函数将简单地返回 2,而不是包含 2 的单子。这是因为标准的 inc 函数不返回单子。另一方面,一个空的 Maybe 值将保持不变。为了在这两种情况下都返回单子,我们可以使用 cats.core 命名空间中的 return 函数,如下所示:

user> (cc/bind (cmm/just 1) #(-> % inc cc/return))
#<Just@208e3: 1>
user> (cc/bind (cmm/nothing) #(-> % inc cc/return))
#<Nothing@1e7075b: nil>

lift-m 形式可以用来将返回类型 A 的函数提升为返回包含类型 A 的单子。提升函数的返回值的具体类型取决于传递给它的单子上下文。如果我们将 Maybe 单子传递给 inc 的提升版本,它将返回一个新的 Maybe 单子实例,如下所示:

user> ((cc/lift-m inc) (cmm/just 1))
#<Just@1eaaab: 2>

我们还可以组合多个对 bind 函数的调用,只要传递给 bind 函数的函数产生单子,如下所示:

user> (cc/>>= (cc/>>= (cmm/just 1)
 #(-> % inc cmm/just))
 #(-> % dec cmm/just))
#<Just@91ea3c: 1>

当然,我们也可以组合对 bind 函数的调用以更改单子的类型。例如,我们可以将 Maybe 单子映射到 Identity 单子,该单子使用 cats.monads.identity/identity 函数构建。我们可以修改前面的表达式以返回一个 Identity 单子,如下所示:

user> (cc/>>= (cc/>>= (cmm/just 1)
 #(-> % inc cmm/just))
 #(-> % dec cmi/identity))
#<Identity@dd6793: 1>

如前所述的输出所示,多次调用 bind 函数可能会有些繁琐。mlet 形式允许我们组合返回单子的表达式,如 示例 6.2 所示:

(defn process-with-maybe [x]
  (cc/mlet [a (if (even? x)
                (cmm/just x)
                (cmm/nothing))
            b (do
                (println (str "Incrementing " a))
                (-> a inc cmm/just))]
    b))

示例 6.2. mlet 形式

简而言之,示例 6.2 中定义的 process-with-maybe 函数检查一个数字是否为偶数,然后打印一行并增加该数字。由于我们使用了 Maybe 类型,打印一行和增加值的最后两个步骤仅在输入 x 为偶数时执行。这样,使用 nothing 函数创建的空 Maybe 单子可以用来短路单子的组合。我们可以在 REPL 中验证 process-with-maybe 函数的此行为,如下所示:

user> (process-with-maybe 2)
Incrementing 2
3
user> (process-with-maybe 3)
#<Nothing@1ebd3fe: nil>

如此,process-with-maybe 函数仅在提供的值 x 是偶数时打印一行。如果不是,则返回一个空的 Maybe 单子实例。

之前的例子描述了我们可以如何使用 Maybe 单子。cats 库还提供了 EitherException 单子的实现,分别在 cats.monads.eithercats.monads.exception 命名空间中。让我们探索 cats.monads.exception 命名空间中的几个构造。

我们可以使用 successfailure 函数创建一个新的 Exception 单子实例。success 形式可以传入任何值,并返回表示计算中成功步骤的单子。另一方面,failure 函数必须传入一个包含指向异常的 :error 键的映射,并返回表示计算中失败的单子。可以通过解引用(使用 deref 形式或 @ 读取宏)来获取 Exception 单子中包含的值或异常。另一种创建 Exception 单子实例的方法是使用 try-on 宏。以下输出描述了如何使用这些构造来创建 Exception 单子的实例:

user> (cme/success 1)
#<Success@441a312 [1]>
user> (cme/failure {:error (Exception.)})
#<Failure@4812b43 [#<java.lang.Exception>]>
user> (cme/try-on 1)
#<Success@5141a5 [1]>
user> @(cme/try-on 1)
1

try-on 宏会在传入的表达式抛出错误时返回一个 Exception 单子失败实例,如下所示:

user> (cme/try-on (/ 1 0))
#<Failure@bc1115 [#<java.lang.ArithmeticException>]>
user> (cme/try-on (-> 1 (/ 0) inc))
#<Failure@f2d11a [#<java.lang.ArithmeticException>]>

一个 Exception 单子的失败实例可以用来短路单子的组合。这意味着如果单子包含错误,将 Exception 单子绑定到函数将不会调用提供的函数。这与异常用于停止计算的方式类似。我们可以使用 bind 函数来验证这一点,如下所示:

user> (cc/bind (cme/try-on (/ 1 1)) #(-> % inc cc/return))
#<Success@116ea43 [2]>
user> (cc/bind (cme/try-on (/ 1 0)) #(-> % inc cc/return))
#<Failure@0x1c90acb [#<java.lang.ArithmeticException>]>

可以使用 cats.monads.exception 命名空间中的 try-or-elsetry-or-recover 宏来创建 Exception 单子的实例。try-or-else 形式必须传入一个表达式和一个默认值。如果传入此形式的表达式抛出异常,则默认值会被包裹在一个 Exception 单子实例中并返回。try-or-recover 形式必须传入一个代替默认值的 1-arity 函数。在遇到错误的情况下,try-or-recover 宏将调用提供的函数并转达其返回的值。以下是如何演示 try-or-elsetry-or-recover 形式的:

user> (cme/try-or-else (/ 1 0) 0)
#<Success@bd15e6 [0]>
user> (cme/try-or-recover (/ 1 0)
 (fn [e]
 (if (instance? ArithmeticException e)
 0
 :error)))
0

以这种方式,单子可以用纯函数来模拟副作用。我们已经展示了如何使用 MaybeException 单子类型。cats 库还实现了其他有趣的单子类型。还有单子定律,我们实现的任何单子都必须遵守这些定律。我们鼓励你自己学习更多关于单子定律的知识。

摘要

在本章中,我们讨论了范畴论中使用的符号和术语。我们还讨论了几种来自范畴论代数类型。这些抽象都有必须由其实现满足的定律,这些定律可以被视为使用这些代数类型的计算优化。

在下一章中,我们将探讨一种完全不同的编程范式——逻辑编程。

第七章。使用逻辑编程

现在,我们将从函数式编程的领域退一步,探索一个完全不同的范式——逻辑编程。逻辑编程有其独特的解决计算问题的方法。当然,逻辑编程不是解决问题的唯一方法,但看到哪些问题可以轻松地用逻辑编程解决是很有趣的。

虽然逻辑编程和函数式编程是两种完全不同的范式,但它们确实有一些共同点。首先,这两种范式都是声明式编程的形式。研究和论文也表明,在函数式编程语言中可以实现逻辑编程的语义。因此,逻辑编程在抽象程度上比函数式编程要高得多。逻辑编程更适合于那些我们有规则集的问题,并且我们希望找到所有符合这些规则的可能值。

在本章中,我们将通过core.logic库来探讨 Clojure 中的逻辑编程。我们还将研究一些计算问题,以及如何使用逻辑编程以简洁优雅的方式解决这些问题。

深入逻辑编程

在 Clojure 中,可以使用core.logic库进行逻辑编程(github.com/clojure/core.logic/)。这个库是miniKanren的移植,它是一种用于逻辑编程的领域特定语言。miniKanren 定义了一套简单的构造,用于创建逻辑关系并从中生成结果。

注意

miniKanren 最初是在 Scheme 编程语言中实现的。您可以在minikanren.org/了解更多关于 miniKanren 的信息。

使用逻辑编程编写的程序可以被视为一组逻辑关系。逻辑关系是逻辑编程的基本构建块,就像函数是函数式编程的基本构建块一样。术语关系约束可以互换用来指代逻辑关系。《core.logic》库实际上是基于约束的逻辑编程的实现。

一个关系可以被认为是一个返回目标的函数,而一个目标可以是成功或失败。在core.logic库中,目标由succeedfail常量表示。关系的另一个有趣方面是它们可以返回多个结果,甚至没有结果。这类似于产生一系列值的函数,其结果可以是空的。例如keepfilter这样的函数完美地符合这一描述。

注意

下面的库依赖项对于即将到来的示例是必需的:

[org.clojure/core.logic "0.8.10"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [clojure.core.logic :as l]
            [clojure.core.logic.fd :as fd]))

以下示例可以在本书源代码的src/m_clj/c7/diving_into_logic.clj中找到。

解决逻辑关系

按照惯例,关系名称后缀为“o”。例如,来自clojure.core.logic命名空间中的conso构造是一个表示cons函数行为的关系。使用多个逻辑关系(如condematche)的逻辑编程构造以“e”结尾。我们将在本章后面探讨这些构造。现在让我们专注于如何使用逻辑关系解决问题。

来自clojure.core.logic命名空间的run*宏处理多个目标以生成所有可能的结果。run*形式的语义允许我们在关系中声明多个逻辑变量,这些变量可以用来返回目标。run*形式返回它定义的逻辑变量的可能值列表。使用run*形式和一组关系表达式本质上是一种向计算机提问“为了使这些关系成立,宇宙必须是什么样子?”并要求它找到答案的方式。

可以使用run*宏与clojure.core.logic/==形式结合来执行等式测试,如下所示:

user> (l/run* [x]
 (l/== x 1))
(1)
user> (l/run* [x]
 (l/== 1 0))
()

前面的输出中使用run*形式的语句都找到了逻辑变量x的所有可能值。关系(l/== x 1)x的值为1时返回一个成功的目标。显然,对于这个关系成立,x只能有1这一个值。run*形式评估这个关系,以返回列表中的1。另一方面,关系(l/== 1 0)在逻辑上是假的,因此当传递给run*形式时不会产生任何结果。这意味着没有x的值使得1等于0

使用clojure.core.logic命名空间中的==形式构建的关系称为统一。统一在逻辑编程中经常被用作从其他范式中的变量赋值,因为它用于给变量赋值。相反,不等式表示一个逻辑变量不能等于给定的值。clojure.core.logic/!=形式用于构建不等式关系,如下所示:

user> (l/run* [x]
 (l/!= 1 1))
()
user> (l/run* [x]
 (l/== 1 1))
(_0)

前面的输出中的第一个语句没有产生结果,因为关系(l/!= 1 1)在逻辑上是假的。一个有趣的特点是,第二个语句有一个总是成功的目标,它产生单个结果_0,这代表一个未绑定的逻辑变量。由于我们没有通过统一为x分配值,因此它的值被认为是未绑定的。符号_0_1_2等(也写作_.0_.1_.2等)在run*形式的上下文中代表未绑定的逻辑变量。

clojure.core.logic/conso形式在模拟标准cons函数作为关系的行为时很有用。它接受三个参数,其中两个与cons函数相同。传递给conso形式的第一个两个参数代表序列的头部和尾部。

第三个参数是一个在应用cons函数到前两个参数时预期返回的序列。conso关系可以如下演示:

user> (l/run* [x]
 (l/conso 1 [2 x]
 [1 2 3]))
(3)

在前面的输出中使用conso关系表达式,将解决x的值,当对1[2 x]应用cons形式时,将产生值[1 2 3]。显然,x必须是3,这样这个关系才成立,因此产生了结果3

可以使用来自clojure.core.logic命名空间的lvar函数在不使用run*形式的情况下创建逻辑变量。在run*形式中,我们可以使用clojure.core.logic/fresh宏创建局部逻辑变量。使用fresh形式声明的变量将不会是周围run*形式产生的最终结果的一部分。例如,考虑以下输出中使用run*形式的表达式:

user> (l/run* [x y]
 (l/== x y)
 (l/== y 1))
([1 1])
user> (l/run* [x]
 (l/fresh [y]
 (l/== x y)
 (l/== y 1)))
(1)

之前显示的第一个表达式产生了结果向量[1 1],而第二个表达式产生了结果1。这是因为我们在第二个表达式中指定了一个单个逻辑变量x,并使用fresh形式在内部声明了逻辑变量y

run*形式在其提供的关系的集合中全面搜索结果。如果我们打算找到有限数量的结果并避免执行任何额外的计算来找到更多结果,我们应该使用来自clojure.core.logic命名空间的run宏。run形式具有与run*形式相同的语义,但还需要将所需的结果数量作为第一个参数传递给它。

clojure.core.logic.fd命名空间为我们提供了几个结构来处理在有限值范围内受约束的关系。例如,假设我们想要找到在0100范围内的值,这些值大于10。我们可以很容易地使用来自clojure.core.logic.fd命名空间的>, in, 和 interval形式来表达这个关系,并使用run形式从中提取前五个值,如下所示:

user> (l/run 5 [x]
 (fd/in x (fd/interval 0 100))
 (fd/> x 10))
(11 12 13 14 15)

前面的表达式使用了ininterval形式来约束变量x的值。使用这两个形式的表达式确保x0100的范围内。此外,clojure.core.logic.fd/>函数定义了一个关系,其中x必须大于10。周围的run形式简单地从它提供的关系中提取x的前五个可能值。在clojure.core.logic.fd命名空间中还实现了几个其他的算术比较运算符,即<<=>=。我们不仅可以通过指定in宏的值范围,还可以通过使用clojure.core.logic.fd/domain形式来枚举变量的可能值。

firsto形式可以用来描述一个关系,其中给定变量的值必须是集合中的第一个元素。我们可以像下面这样在 REPL 中尝试domainfirsto形式:

user> (l/run 1 [v a b x]
 (l/== v [a b])
 (fd/in a b x (fd/domain 0 1 2))
 (fd/< a b)
 (l/firsto v x))
([[0 1] 0 1 0])

在前面的表达式中,我们求解满足以下关系的vabx的第一组值。a的值必须小于b的值,这通过<形式表示,并且ab都必须是向量v的元素,这通过==形式表示。此外,abx必须等于012,这是通过indomain形式的组合来描述的。最后,向量v的第一个元素必须等于值x。这些关系生成了向量[0 1]以及abx分别为010的值。注意前述表达式中in形式的阶数,它允许将多个逻辑变量及其约束传递给它。

组合逻辑关系

clojure.core.logic/conde形式允许我们指定多个关系,并且与标准的cond形式有点相似。例如,考虑以下使用conde形式的表达式:

user> (l/run* [x]
 (l/conde
 ((l/== 'A x) l/succeed)
 ((l/== 'B x) l/succeed)
 ((l/== 'C x) l/fail)))
(A B)

使用conde形式的上述表达式对符号ABC与逻辑变量x进行了等值检查。只有其中两个检查产生了成功的目标,这在使用conde形式子句中的succeedfail常量进行了描述。前述表达式中的conde形式通过以下图表说明了这种逻辑分支:

组合逻辑关系

在我们之前的例子中,conde形式创建了一个针对三个子句的条件检查。在这三个子句中,只有两个成功,因此返回了符号AB作为结果。我们应该注意,在conde形式中定义的子句可以包含任意数量的关系。此外,l/succeed常量的使用是隐式的,我们只需要使用l/fail常量来表示一个失败的目标。

另一种执行相等性检查的方法是通过模式匹配。这可以通过使用clojure.core.logic/matche形式来完成。因此,matche形式是定义涉及逻辑变量的条件分支的更自然的方式,如下所示:

user> (l/run* [x]
 (l/conde
 ((l/== 'A x) l/succeed)
 ((l/== 'B x) l/succeed)))
(A B)
user> (l/run* [x]
 (l/matche [x]
 (['A] l/succeed)
 (['B] l/succeed)))
(A B)

两个先前的表达式产生相同的结果。这两个表达式之间的唯一区别是,第一个使用了一个conde形式,而第二个使用了一个matche形式来进行模式匹配。此外,l/succeed常量是隐式的,不需要指定,类似于conde形式。_通配符也由matche形式支持,如下所示:

user> (l/run* [x]
 (l/matche [x]
 (['A])
 ([_] l/fail)))
(A)

在上述表达式中,我们求解了所有与模式'A'匹配的x的值。所有其他情况都失败,这使用_通配符和l/fail常量来描述。当然,使用_通配符的模式是隐式的,这里只是为了说明如何在matche形式中使用它。

matche构造也支持序列的解构。一个序列可以通过matche形式使用点(.)来界定序列的头部和尾部进行解构,如下所示:

user> (l/run* [x]
 (l/fresh [y]
 (l/== y [1 2 3])
 (l/matche [y]
 ([[1 . x]]))))
((2 3))

在上述表达式中,逻辑变量x必须为(2 3),才能使使用matche形式定义的关系成功。我们可以使用与defn形式类似的语法,通过clojure.core.logic命名空间中的defne宏来定义关系。defne形式允许我们以模式匹配风格定义关系。顺便提一下,core.logic库中的许多结构都是使用defne形式定义的。例如,考虑示例 7.1membero关系的定义:

(l/defne membero [x xs]
  ([_ [x . ys]])
  ([_ [y . ys]]
   (membero x ys)))

示例 7.1:使用 defne 宏定义 membero 关系

membero关系用于确保值x是集合xs的成员。此关系的实现将集合xs解构为其头部和尾部部分。如果值x是集合xs的头部,则关系成功,否则关系将以值x和解构列表的尾部ys递归调用。我们可以在 REPL 中使用run*形式尝试此关系,如下所示:

user> (l/run* [x]
 (membero x (range 5))
 (membero x (range 3 10)))
(3 4)

上述表达式求解了包含在范围05以及范围310内的x的值。结果34是由使用membero形式的这两个关系产生的。

在处理逻辑变量时,需要注意的是,我们不能使用标准函数对它们进行任何计算。为了从一组逻辑变量中提取值,我们必须使用clojure.core.logic/project形式。例如,考虑以下语句:

user> (l/run 2 [x y]
 (l/membero x (range 1 10))
 (l/membero y (range 1 10))
 (l/project [x y]
 (l/== (+ x y) 5)))
([1 4] [2 3])

前面的陈述求解了两个 xy 的值,它们都在 110 的范围内,并且它们的和等于 5。返回的结果是 [1 4][2 3]。使用 project 形式来提取 xy 的值,否则 + 函数会抛出异常。

因此,core.logic 库为我们提供了一系列构造,可以用来定义逻辑关系、组合它们并从中生成结果。

以逻辑关系思考

现在我们已经熟悉了 core.logic 库中的各种构造,让我们看看一些可以通过逻辑编程解决的问题。

解决 n-皇后问题

n-皇后问题是一个有趣的问题,可以使用逻辑关系来实现。n-皇后问题的目标是放置 n 个女王在一个 n x n 大小的棋盘上,使得没有两个女王会相互构成威胁。这个问题是 1848 年由 Max Bezzel 发布的 八皇后问题 的一般化,涉及八个皇后。实际上,我们可以解决任何数量的 n-皇后问题,只要我们处理的是四个或更多的皇后。传统上,这个问题可以使用一种称为 回溯算法 的算法技术来解决,它本质上是对给定问题所有可能解决方案的穷举搜索。然而,在本节中,我们将使用逻辑关系来解决它。

让我们先定义一下如何使用女王。众所周知,女王可以随心所欲地移动!女王可以在棋盘上水平、垂直或对角线移动。如果任何其他棋子在女王可以移动的同一路径上,那么女王就会对其构成威胁。棋子在棋盘上的位置可以使用一对整数来指定,就像如何使用笛卡尔坐标来表示平面上点的位置一样。假设 (x[1], y[1]) 和 (x[2], y[2]) 代表棋盘上两个女王的坐标。由于它们可以在水平、垂直或对角线上相互威胁,我们必须避免以下三种不同的情况:

  • 女王不能位于相同的垂直路径上,也就是说,x[1] 等于 x[2]

  • 同样,女王不能位于相同的水平路径上,也就是说,y[1] 等于 y[2]

  • 女王不能位于相同的对角路径上,在这种情况下,它们之间的垂直和水平距离的比率要么是 1,要么是 -1。这实际上是坐标几何中的一个技巧,其证明超出了我们讨论的范围。这种情况可以用以下方程简洁地表示:解决 n-皇后问题

这些是确定两个皇后是否相互威胁的唯一规则。然而,如果你从程序或面向对象的视角来思考,实现它们可能需要大量的代码。相反,如果我们从关系的角度思考,我们可以使用 core.logic 库相对容易地实现这三个规则,如以下 示例 7.2 所示:

注意

以下示例可以在书籍源代码的 src/m_clj/c7/nqueens.clj 中找到。此示例基于 Martin Trojer 的代码 使用 core.logic 的 n-皇后问题(martinsprogrammingblog.blogspot.in/2012/07/n-queens-with-corelogic-take-2.html)。

(l/defne safeo [q qs]
  ([_ ()])
  ([[x1 y1] [[x2 y2] . t]]
     (l/!= x1 x2)
     (l/!= y1 y2)
     (l/project [x1 x2 y1 y2]
       (l/!= (- x2 x1) (- y2 y1))
       (l/!= (- x1 x2) (- y2 y1)))
     (safeo [x1 y1] t)))

(l/defne nqueenso [n qs]
  ([_ ()])
  ([n [[x y] . t]]
     (nqueenso n t)
     (l/membero x (range n))
     (safeo [x y] t)))

(defn solve-nqueens [n]
  (l/run* [qs]
    (l/== qs (map vector (repeatedly l/lvar) (range n)))
    (nqueenso n qs)))

示例 7.2:n-皇后问题

示例 7.2 中,我们定义了两个关系,即 safeonqueenso,来描述 n-皇后问题。这两个关系都必须传递一个列表 qs 作为参数,其中 qs 包含坐标对,代表放置在棋盘上的皇后位置。它们是递归关系,终止条件是 qs 为空。

safeo 关系是实现确定两个皇后是否相互威胁的三个规则的实现。注意这个关系如何使用 project 形式提取 x1y1x2y2 的值来处理两个皇后位于同一对角线路径上的情况。nqueenso 关系处理 qs 列表中的所有皇后位置,并确保每个皇后都是安全的。solve-queens 函数使用 clojure.core.logic/lvar 形式初始化 n 个逻辑变量。

qs 的值被初始化为一个向量对列表,每个向量对包含一个逻辑变量和范围在 0n 之间的一个数字。实际上,我们初始化所有向量对的 y 坐标,并求解 x 坐标。这样做的原因是,当我们在一个有 n 列和 n 行的棋盘上求解 n 后问题时,每一行都会放置一个皇后。

solve-nqueens 函数返回一个包含坐标对列表的解决方案列表。我们可以通过使用 partitionclojure.pprint/pprint 函数以更直观的方式打印这些数据,如 示例 7.3 所示:

(defn print-nqueens-solution [solution n]
  (let [solution-set (set solution)
        positions (for [x (range n)
                        y (range n)]
                    (if (contains? solution-set [x y]) 1 0))]
    (binding [clojure.pprint/*print-right-margin* (* n n)]
      (clojure.pprint/pprint
       (partition n positions)))))

(defn print-all-nqueens-solutions [solutions n]
  (dorun (for [i (-> solutions count range)
               :let [s (nth solutions i)]]
           (do
             (println (str "\nSolution " (inc i) ":"))
             (print-nqueens-solution s n)))))

(defn solve-and-print-nqueens [n]
  (-> (solve-nqueens n)
      (print-all-nqueens-solutions n)))

示例 7.3:n-皇后问题(继续)

现在,我们只需要通过传递皇后数量来调用 solve-and-print-nqueens 函数。让我们尝试使用此函数来解决四个皇后的 n-皇后问题,如下所示:

user> (solve-and-print-nqueens 4)

Solution 1:
((0 1 0 0)
 (0 0 0 1)
 (1 0 0 0)
 (0 0 1 0))

Solution 2:
((0 0 1 0)
 (1 0 0 0)
 (0 0 0 1)
 (0 1 0 0))
nil

solve-and-print-nqueens 函数打印了四个皇后的两个解决方案。每个解决方案都打印为一组嵌套列表,其中每个内部列表代表棋盘上的一行。值 1 表示在该位置上放置了一个皇后。正如你所见,在这两个解决方案中,四个皇后都没有相互威胁。

以这种方式,solve-nqueens 函数使用关系来解决八皇后问题。我们之前提到,八皇后问题最初涉及八个皇后。总共有 92 个不同的八皇后解决方案,solve-nqueens 函数可以找到每一个。我们鼓励你通过将值 8 传递给 solve-and-print-nqueens 函数并验证它打印的解决方案来尝试这一点。

解决数独谜题

我们中的一些人可能已经深深地爱上了我们在报纸和杂志上找到的直观迷人的数独谜题。这是一个涉及逻辑规则的问题。数独板是一个 9 x 9 的网格,我们可以在其上放置数字。网格被分成九个更小的网格,每个更小的网格进一步被分成包含数字的 3 x 3 网格。这些更小的网格也被称为 方格盒子。一些方格将被填充。目标是放置数字在网格的所有位置上,使得每一行、每一列以及每个更小的网格都包含 1 到 9 范围内的不同数字。

让我们以这种方式实现数独谜题的规则。我们将为数独板上每个可能的数字位置创建一个逻辑变量,并使用谜题的规则求解它们的值。数独板上数字的初始值可以作为一个包含 81 个数字的单个向量提供。在这个实现中,我们引入了一些新的结构,这些结构在简洁地描述数独谜题的规则时非常有用。clojure.core.logic 命名空间中的 everyg 函数可以用来在逻辑变量的列表上应用一个关系,从而确保该关系对所有提供的逻辑变量都为真。我们还必须确保数独谜题中行、列和 3 x 3 大小的网格中的逻辑变量是不同的。这可以通过使用 clojure.core.logic.fd/distinct 函数来完成。这种数独求解器设计的实现示例如 示例 7.4 所示。

注意

以下示例可以在书籍源代码的 src/m_clj/c7/sudoku.clj 中找到。

(l/defne init-sudoku-board [vars puzzle]
  ([[] []])
  ([[_ . vs] [0 . ps]] (init-sudoku-board vs ps))
  ([[n . vs] [n . ps]] (init-sudoku-board vs ps)))

(defn solve-sudoku [puzzle]
  (let [board (repeatedly 81 l/lvar)
        rows (into [] (map vec (partition 9 board)))
        cols (apply map vector rows)
        val-range (range 1 10)
        in-range (fn [x]
                   (fd/in x (apply fd/domain val-range)))
        get-square (fn [x y]
                     (for [x (range x (+ x 3))
                           y (range y (+ y 3))]
                       (get-in rows [x y])))
        squares (for [x (range 0 9 3)
                      y (range 0 9 3)]
                  (get-square x y))]
    (l/run* [q]
      (l/== q board)
      (l/everyg in-range board)
      (init-sudoku-board board puzzle)
      (l/everyg fd/distinct rows)
      (l/everyg fd/distinct cols)
      (l/everyg fd/distinct squares))))

示例 7.4:数独求解器

示例 7.4 中,init-sudoku-board 关系从谜题 puzzle 中初始化逻辑变量 vars,而 solve-sudoku 函数找到给定谜题的所有可能解。solve-sudoku 函数通过 repeatedlyclojure.core.logic/lvar 表达式的组合创建逻辑变量。然后,这些变量被划分为行、列和方块,分别由变量 rowscolssquares 表示。solve-sudoku 函数随后使用 init-sudoku-board 表达式初始化逻辑变量,并使用 everygdistinct 表达式的组合来确保解的行、列和方块包含不同的值。所有逻辑变量也通过内部定义的 in-range 函数绑定到范围 19

示例 7.4 中定义的 solve-sudoku 函数接受一个表示数独棋盘初始状态的值向量作为参数,并返回一个列表,其中每个解都是一个向量。由于一个普通的向量并不是一个直观的数独棋盘表示,让我们定义一个简单的函数来找到给定谜题的所有解并打印出来,如 示例 7.5 所示:

(defn solve-and-print-sudoku [puzzle]
  (let [solutions (solve-sudoku puzzle)]
    (dorun (for [i (-> solutions count range)
                 :let [s (nth solutions i)]]
             (do
               (println (str "\nSolution " (inc i) ":"))
               (clojure.pprint/pprint
                (partition 9 s)))))))

示例 7.5:数独求解器(继续)

示例 7.5 中的 solve-and-print-sudoku 函数调用 solve-sudoku 函数来确定给定数独谜题的所有可能解,并使用 partitionclojure.pprint/pprint 函数打印结果。现在,让我们定义一个简单的数独谜题来求解,如 示例 7.6 所示。

(def puzzle-1
  [0 9 0 0 0 0 0 5 0
   6 0 0 0 5 0 0 0 2
   1 0 0 8 0 4 0 0 6
   0 7 0 0 8 0 0 3 0
   8 0 3 0 0 0 2 0 9
   0 5 0 0 3 0 0 7 0
   7 0 0 3 0 2 0 0 5
   3 0 0 0 6 0 0 0 7
   0 1 0 0 0 0 0 4 0])

示例 7.6:数独求解器(继续)

现在,让我们将向量 puzzle-1 传递给 solve-and-print-sudoku 函数,以打印出所有可能的解,如下所示:

user> (solve-and-print-sudoku puzzle-1)

Solution 1:
((4 9 8 6 2 3 7 5 1)
 (6 3 7 9 5 1 4 8 2)
 (1 2 5 8 7 4 3 9 6)
 (9 7 1 2 8 6 5 3 4)
 (8 4 3 5 1 7 2 6 9)
 (2 5 6 4 3 9 1 7 8)
 (7 6 9 3 4 2 8 1 5)
 (3 8 4 1 6 5 9 2 7)
 (5 1 2 7 9 8 6 4 3))
nil

solve-sudoku 函数找到了之前表示的由向量 puzzle-1 表示的数独谜题的单个解。由 puzzle-1 表示的谜题及其解在以下插图中的数独棋盘上显示:

求解数独谜题

示例 7.7:数独求解器(继续)

很可能一个数独谜题有多个解。例如,示例 7.7 中的 puzzle-2 表示的数独谜题有八个不同的解。你完全可以使用 solve-and-print-sudoku 函数来找到这个谜题的解:

(def puzzle-2
  [0 8 0 0 0 9 7 4 3
   0 5 0 0 0 8 0 1 0
   0 1 0 0 0 0 0 0 0
   8 0 0 0 0 5 0 0 0
   0 0 0 8 0 4 0 0 0
   0 0 0 3 0 0 0 0 6
   0 0 0 0 0 0 0 7 0
   0 3 0 5 0 0 0 8 0
   9 7 2 4 0 0 0 5 0])

示例 7.7:数独求解器(继续)

总之,我们可以使用 core.logic 库将数独谜题的规则实现为逻辑关系。

摘要

在本章中,我们探讨了如何使用 Clojure 进行逻辑编程。我们通过探索这个库提供的各种构造来介绍了 core.logic 库。我们还研究了如何使用 core.logic 库实现 n-皇后问题和数独谜题的解决方案。

在下一章中,我们将继续我们的函数式编程之旅,并讨论在 Clojure 中处理异步任务。

第八章。利用异步任务

术语异步编程指的是定义在不同执行线程上异步执行的任务。虽然这与多线程类似,但也有一些细微的差别。首先,一个线程或一个未来将保留分配给单个操作系统线程,直到完成。这导致了一个事实,即可以同时执行的未来数量是有限的,这取决于可用的处理核心数量。另一方面,异步任务被安排在线程池的线程上执行。这样,一个程序可以有数千个,甚至数百万个异步任务同时运行。异步任务可以在任何时候暂停,或者停放,并且执行线程可以被重新分配给另一个任务。异步编程结构还允许定义异步任务看起来像一系列同步调用,但每个调用可能都是异步执行的。

在本章中,我们将探讨在 Clojure 中进行异步编程时可以使用的各种库和结构。首先,我们将查看core.async库中的进程通道,用于异步编程。稍后,我们将探索来自Pulsar库的actor。进程和通道是与 Go 编程语言中的go-routines类似的构造。另一方面,actor 最初在 Erlang 编程语言中流行起来。所有这些技术都是结构异步执行代码的不同方式。我们必须理解,这些概念背后的理论并不是真正新颖的,自从分布式和多核架构兴起以来,这些理论的更多实现已经层出不穷。有了这个想法,让我们开始我们的异步编程之旅。

使用通道

core.async库(github.com/clojure/core.async)促进了 Clojure 中的异步编程。通过这个库,我们可以在 JVM 和网页浏览器上使用异步构造,而无需处理它们在低级线程上的执行调度。这个库是实现 C. A. R. Hoare 在 70 年代末发表的论文中的理论,即通信顺序进程CSP)。CSP 的基本观点是,任何处理某些输入并提供输出的系统都可以由更小的子系统组成,每个子系统都可以用进程队列来定义。队列简单地缓冲数据,进程可以从多个队列中读取和写入。在这里,我们不应该将术语进程与操作系统进程混淆。在 CSP 的上下文中,进程只是一系列与存储在队列中的某些数据交互的指令。一个系统中可能存在多个进程,队列是它们之间传递数据的一种方式。一个从单个队列中获取数据并将数据输出到另一个队列的进程可以表示如下:

使用通道

如前图所示,输入数据进入队列,进程通过队列操作这些数据,最后将输出数据写入另一个队列。core.async库本质上提供了创建进程和队列的一等支持。在core.async库中,队列被称为通道,可以使用chan函数创建。进程可以使用gothread宏创建。让我们更深入地探讨一下细节。

注意

以下库依赖对于即将到来的示例是必需的:

[org.clojure/core.async " 0.1.346.0-17112a-alpha"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [clojure.core.async :as a]))

使用threadgo形式创建的进程将在线程池上执行。实际上,我们可以在程序中创建数千个这样的进程,因为它们不需要自己的专用线程。另一方面,创建大量线程或未来会导致过多的作业排队等待执行。这实际上限制了我们可以同时运行的线程或未来的数量。因此,core.async库以及一般的 CSP(通信顺序进程)允许我们将系统建模为大量轻量级和并发的进程。

可以将通道视为管理进程之间状态的数据结构。core.async命名空间中的chan函数返回一个可以由多个并发进程读取和写入的通道。默认情况下,通道是无缓冲的,这意味着对通道的写入操作将不会完成,直到并发地对其调用读取操作,反之亦然。我们还可以通过指定一个数字给chan函数来指定缓冲区大小来创建一个缓冲通道。缓冲通道将允许写入一定数量的值而不阻塞,然后可以读取这些缓冲值。可以使用core.async命名空间中的close!函数关闭通道。

我们也可以通过将缓冲区对象传递给chan函数来创建一个缓冲通道。可以使用bufferdropping-buffersliding-buffer函数来创建缓冲区对象,这些函数需要一个数字作为参数,表示缓冲区的大小。可以使用(a/chan (a/buffer n))(a/chan n)中的任何一个表达式来创建一个可以缓冲n个值的通道,一旦通道被填充了n个值,它将阻止对该通道的写入操作。dropping-buffer函数创建的缓冲区在满载后会丢弃新添加的值。相反,使用sliding-buffer函数创建的缓冲区在完全填满后会丢弃最老的值。

core.async库提供了一些用于从通道读取和写入的构造,传递给这些构造的值遵循一些简单的规则。对通道的读取操作会从通道返回一个值,如果通道已关闭,则返回nil。如果写入操作成功,则返回true,如果通道已关闭且写入操作无法完成,则返回false。我们可以从已关闭的通道中读取缓冲数据,但一旦通道中的数据耗尽,对该通道的读取操作将返回nil。通道的读取和写入操作的参数遵循以下模式:

  1. 任何操作的第一个参数都是一个通道。

  2. 除了通道本身外,写入操作还必须传递一个值以放入通道。

在通道的上下文中,我们应该注意,“写入”和“放置”这两个术语可以互换使用,同样,“读取”和“获取”这两个术语也指代相同的操作。take!put! 函数从队列中获取数据和放置数据。这两个函数都会立即返回,并且除了常规参数外,还可以传递一个回调函数作为参数。同样,<!!>!! 函数可以分别用于从通道读取和写入。然而,如果提供的通道中没有数据,<!! 操作会阻塞调用线程,而如果没有更多缓冲空间可用,>!! 操作将被阻塞。这两个操作旨在在 thread 形式中使用。最后,暂停读写函数,即 <!>!,可以在 go 形式中用于与通道交互。<!>! 操作都会暂停任务状态,并在无法立即完成操作时释放底层执行线程。

让我们继续探讨使用 core.async 库创建过程的细节。core.async/thread 宏用于创建单线程过程。在意义上,它与 future 形式相似,即 thread 形式的主体在新的线程上执行,并且对 thread 形式的调用会立即返回。thread 形式返回一个通道,可以从其中读取其主体的输出。这使得 thread 形式在处理通道方面比标准 future 形式更方便,因此更受欢迎。<!!>!! 函数可以在 thread 形式中用于与通道交互。

要创建一个可以暂停并调度执行的非阻塞异步过程,我们必须使用 core.async 命名空间中的 go 宏。与 thread 形式类似,它返回一个通道,可以从其中读取其主体的输出。go 形式主体内的所有通道操作都将暂停,而不是阻塞执行线程。这意味着执行线程不会被阻塞,可以被重新分配到另一个异步过程。因此,多个 go 形式的执行可以交织在更少的实际线程上。我们必须确保在 go 形式中不执行任何特定于线程的操作,如 Thread/sleep,因为这些操作会影响执行线程。在 go 形式中,我们必须始终使用 <!>! 暂停形式来从通道读取和写入。

注意

访问 clojure.github.io/core.async/ 获取关于 core.async 库中所有函数和宏的完整文档。

go-loop 宏是 loop 形式的异步版本,它接受一个绑定向量作为其第一个参数,后面跟着任何必须执行的形式。go-loop 形式的主体将在 go 形式内部执行。go-loop 构造通常用于创建具有自己局部状态的异步事件循环。作为一个例子,让我们考虑一个简单的 wait-and-print 函数,该函数启动一个从给定通道读取的过程,如 示例 8.1 所示。

注意

以下示例可以在书籍源代码的 src/m_clj/c8/async.clj 中找到。

(defn wait-and-print [c]
  (a/go-loop [n 1]
    (let [v (a/<! c)]
      (when v
        (println (str "Got a message: " v))
        (println (str "Got " n " messages so far!"))
        (recur (inc n)))))
  (println "Waiting..."))

示例 8.1:一个异步从通道读取的函数

之前展示的 wait-and-print 函数会反复从传递给它的通道 c 中读取。使用 when 形式是为了检查从通道读取的值,用 v 表示,是否不是 nil,因为如果通道 c 被关闭,<! 形式可能会返回 nil。之前示例中的 go-loop 形式也使用变量 n 来计算从通道读取的值的数量。在从通道接收到值时,会打印一些信息,并使用 recur 形式循环体。我们可以创建一个通道,将其传递给 wait-and-print 函数,并观察向通道发送值的输出,如下所示:

user> (def c (a/chan))
#'user/c
user> (wait-and-print c)
Waiting...
nil
user> (a/>!! c :foo)
true
Got a message: :foo
Got 1 messages so far!
user> (a/>!! c :bar)
true
Got a message: :bar
Got 2 messages so far!

如前所述,调用 wait-and-print 函数启动一个异步事件循环,该循环从通道 c 中读取。使用 >!! 形式向通道 c 发送值时,该值会与发送到通道的总值数一起打印出来。此外,>!! 形式的调用会立即返回值 true。现在,让我们看看当我们使用 close! 函数关闭通道 c 时会发生什么,如下所示:

user> (a/close! c)
nil
user> (a/>!! c :foo)
false

在关闭通道 c 后,当 >!! 形式应用于通道时,它返回 false,这意味着通道 c 不再允许放入任何值。而且,没有任何内容被打印出来,这意味着尝试从通道 c 中获取值的异步例程已经终止。

向通道发送值的另一种方式是使用 core.async 命名空间中的 onto-chan 函数。这个函数必须传递一个通道和一个要放入通道的值集合,如下所示:

user> (def c (a/chan 4))
#'user/c
user> (a/onto-chan c (range 4))
#<ManyToManyChannel@0x86f03a>
user> (repeatedly 4 #(-> c a/<!!))
(0 1 2 3)

当提供的值集合完全放入通道后,onto-chan 函数会关闭它所接收的通道。为了避免关闭通道,我们可以在 onto-chan 函数中指定 false 作为额外的参数。

来自 core.async 命名空间的 alts!alts!! 函数可用于等待多个通道操作之一完成。这些函数之间的主要区别在于,alts! 函数旨在在 go 形式中使用,并将暂停当前线程,而 alts!! 函数会阻塞当前线程,必须在 thread 形式中使用。这两个函数都必须传递一个通道向量,并返回一个包含两个元素的向量。返回向量中的第一个元素代表取操作的价值或放入操作的布尔值,第二个元素表示操作完成的通道。我们还可以通过将 :default 作为关键字参数传递给 alts!alts!! 函数来指定默认值。如果没有提供给 alts!alts!! 形式的操作完成,则将返回默认值。

core.async 库提供了两个多功能的宏,即 alt!alt!!,用于等待多个通道操作中之一完成。正如你可能已经猜到的,alt! 形式会暂停当前任务,而 alt!! 形式会阻塞当前线程。这两个形式在使用带有关键字参数 :default 时也可以返回默认值。我们可以向 alt!alt!! 形式传递多个子句,用于从多个通道读取和写入。示例 8.2 中的 alt! 形式描述了 alt!alt!! 宏支持的子句:

(defn process-channels [c0 c1 c2 c3 c4 c5]
  (a/go
    (a/alt!
      ;; read from c0, c1, c2, c3
      c0 :r
      c1 ([v] (str v))
      [c2 c3] ([v c] (str v))
      ;; write to c4, c5
      [[c4 :v1] [c5 :v2]] :w)))

示例 8.2:使用 alt! 形式实现的异步过程

前面的 process-channels 函数接受六个通道作为其参数,并在 go 形式中使用 alt! 形式对这些通道执行异步操作。通道 c0c1c2c3 被读取,而通道 c4c5 被写入。alt! 形式尝试从通道 c0 读取,如果操作首先完成,则返回关键字 :r。通道 c1 也被读取,但其子句的右侧包含一个带有参数 v 的参数化表达式,其中 v 是从通道读取的值。通道 c2c3 作为向量传递给前面显示的 alt! 形式的一个子句,该子句使用带有参数 vc 的参数化表达式,其中 c 是首先完成读取操作的通道,而 v 是从通道读取的值。写入操作在 alt! 形式中指定为嵌套向量,其中每个内部向量包含一个通道和一个要放入通道的值。在之前的 alt! 形式中写入通道 c4c5,如果两个写入操作中的任何一个首先完成,则返回值 :w。通过这种方式,我们可以指定给 alt!alt!! 形式的子句,以读取和写入多个通道,并根据哪个通道操作首先完成返回一个值。

在异步编程中,经常需要的一个功能是能够为给定的操作指定一个超时。通过术语超时,我们指的是在指定时间后当前操作被取消。core.async有一个直观的方法来指定带有超时的操作。这是通过使用core.async/timeout函数来完成的,它必须提供一个时间间隔(以毫秒为单位),并在指定的时间后关闭返回的通道。如果我们打算执行带有超时的操作,我们使用alt*形式之一,该形式使用由timeout函数返回的通道。

这样,由timeout形式返回的通道进行的操作将在指定的时间后肯定完成。timeout形式在暂停或阻塞当前执行线程一段时间也是很有用的。例如,从timeout形式返回的通道进行的阻塞读取操作将阻塞当前线程指定的时间间隔,如下所示:

user> (time  (a/<!! (a/timeout 1000)))
"Elapsed time: 1029.502223 msecs"
nil

我们现在已经涵盖了core.async库中进程和通道的基本知识。

自定义通道

通道也可以被编程来修改或计算放入其中的值。例如,对通道的读取操作可以调用使用同一通道中缓存的值或甚至其他通道的值的计算。core.async命名空间中的reduce函数可以用来从通道中计算值,并且与标准reduce函数有更多或更少的相同语义。这个reduce函数的变体需要一个减少操作、减少操作的初始值以及要传递给它的通道,并且它将返回一个可以从中读取结果的通道。此外,此函数仅在传递给它的通道关闭后才会产生值。例如,考虑以下使用core.async/reduce函数从通道中的值计算字符串的代码:

user> (def c (a/chan 5))
#'user/c
user> (a/onto-chan c (range 5))
#<ManyToManyChannel@0x4adadd>
user> (def rc (a/reduce #(str %1 %2 " ") "" c))
#'user/rc
user> (a/<!! rc)
"0 1 2 3 4 "

在前面的例子中,由(range 5)表达式生成的序列被使用onto-chan形式放入通道c中,然后使用基于通道的reduce函数的变体计算通道中的值。从结果通道rc中读取单个值,从而生成包含通道c中所有值的字符串。请注意,在这个例子中,reduce形式产生了结果,而没有显式调用close!函数,因为onto-chan函数在将值放入通道后关闭了提供的通道。

从通道计算值的一种更强大、更直观的方法是使用转换器。我们已经在 第五章 组合转换器 中详细讨论了转换器,现在我们将看看如何使用通道与转换器一起使用。本质上,可以通过将转换器指定为 core.async/chan 函数的第二个参数来将通道与转换器关联。让我们考虑 示例 8.3 中显示的简单转换器 xform

(def xform
  (comp
   (map inc)
   (map #(* % 2))))

示例 8.3:与通道一起使用的简单转换器

展示的转换器 xform 是将函数 inc#(* % 2) 映射的简单组合。它将简单地增加数据源中(或更确切地说,是一个通道)的所有值,然后加倍前一步骤的所有结果。让我们使用这个转换器创建一个通道,并观察其行为,如下所示:

user> (def xc (a/chan 10 xform))
#'user/xc
user> (a/onto-chan xc (range 10) false)
#<ManyToManyChannel@0x17d6a37>
user> (repeatedly 10 #(-> xc a/<!!))
(2 4 6 8 10 12 14 16 18 20)

通道 xc 将将转换器 xform 应用到它包含的每个值。因此,从通道 xc 重复取值的结果是一个偶数序列,这是通过将函数 inc#(* % 2) 应用到 (range 10) 范围内的每个数字产生的。请注意,在前面的例子中,onto-chan 形式并没有关闭通道 xc,因为我们将其最后一个参数传递为 false

与一个通道关联的转换器可能会遇到异常。为了处理错误,我们可以将一个函数作为额外的参数传递给 chan 形式。这个函数必须恰好接受一个参数,并将传递给转换器在转换通道中的值时遇到的任何异常。例如,表达式 (a/chan 10 xform ex-handler) 创建了一个带有转换器 xform 和异常处理器 ex-handler 的通道。

以这种方式,core.async/reduce 形式和转换器可以用来对通道中包含的值进行计算。

连接通道

现在我们已经熟悉了 core.async 库中通道和进程的基本知识,让我们探索通道可以连接在一起的不同方式。连接两个或更多通道对于在它们之间聚合和分配数据是有用的。两个或更多通道之间的连接称为 联合适配,或简单地称为 联合。在本节中,我们将使用图表来描述一些更复杂的联合适配。请注意,这些图表中的箭头指示了给定通道中数据流的流向。

连接两个通道最简单的方式是使用一个管道,该管道通过core.async/pipe函数实现。这个函数将从它提供的第一个通道中获取值,并将这些值提供给传递给它的第二个通道。通过这种方式,通道之间的管道类似于 UNIX 风格的流管道。例如,表达式(a/pipe from to)将从from通道中获取值并将它们放入to通道。pipe函数还接受一个可选的第三个参数,该参数指示当源通道关闭时,目标通道是否会关闭,并且此参数默认为true。我们还可以使用core.async命名空间中的pipeline函数通过管道连接两个通道。pipeline函数将基本上在将值放入另一个通道之前,将这些值应用于通道中的转换器。提供的转换器也将由pipeline函数并行地对提供的通道中的每个元素调用。

core.async命名空间中的merge函数可以用来合并多个通道。此函数必须传递一个通道向量,并返回一个可以从所有提供的通道中读取值的通道。默认情况下,返回的通道是非缓冲的,我们可以通过将一个数字作为额外的参数传递给merge函数来指定通道的缓冲区大小。此外,一旦所有源通道都已关闭,由merge形式返回的通道将被关闭。两个通道的merge函数的操作可以用以下插图表示:

连接通道

可以使用core.async/split函数将一个通道拆分为两个通道。split函数必须传递一个谓词p?和一个通道c,并返回一个包含两个通道的向量。谓词p?用于决定将c通道中的值放入哪个通道。所有通过谓词p?返回真值的c通道中的值都将放入split函数返回的向量中的第一个通道。

相反,返回向量中的第二个通道将包含所有在应用p?时返回falsenil的值。此函数返回的两个通道默认都是非缓冲的,并且可以通过将额外的参数传递给split形式来指定这两个通道的缓冲区大小。split函数可以用以下插图表示:

连接通道

merge函数相比,更动态地组合几个通道的方法是使用core.async命名空间中的mixadmixunmix函数。mix函数创建一个混合,可以使用admix函数将具有传入数据的通道连接到该混合。mix函数接受一个通道作为参数,提供的通道将包含由admix函数添加的所有源通道的值。可以使用unmix函数从混合器中移除源通道。admixunmix函数都接受一个混合,这是由mix函数返回的,以及一个源通道作为参数。要从一个混合中移除所有通道,我们只需将混合作为参数传递给unmix-all函数。混合的精髓在于它允许我们动态地添加和移除向给定输出通道发送数据的源通道。混合、其输出通道和源通道可以如下所示:

连接通道

在前面的示意图中,通道c被用作混合m的输出通道,通道c0c1使用admix函数添加为混合m的源通道。

core.async/mult 函数创建给定通道的多个副本。可以通过tap函数从另一个通道中提取多个通道的数据。提供给tap函数的通道将接收所有发送到多个通道源通道的数据副本。untap函数用于从多个通道断开一个通道,而untap-all函数将断开所有通道与多个通道的连接。多个通道本质上允许我们动态地添加和移除从给定源通道读取值的输出通道。multtap函数可以用以下图表来描述:

连接通道

在前面的示意图中,通道c被多个m用作源通道,通道c0c1传递给tap函数,以便它们有效地接收发送到通道c的数据副本。

core.async 库还支持一种 发布-订阅 的数据传输模型。这可以通过使用 发布 来完成,该发布是通过 core.async/pub 函数创建的。这个函数必须提供一个源通道和一个函数来决定发布中给定值的主题。在这里,主题可以是任何字面量,如字符串或关键字,该字面量由提供给 pub 形式的函数返回。通道可以通过 sub 函数订阅一个发布和一个主题,一个通道可以使用 unsub 函数从发布中取消订阅。subunsub 函数必须传递一个发布、一个主题值和一个通道。此外,可以使用 unsub-all 函数来断开所有已订阅发布的通道。此函数可以可选地传递一个主题值,并将断开所有已订阅给定主题的通道。以下图中展示了已订阅两个通道的发布:

连接通道

在前面的示例中,使用通道 c 和函数 topic-fn 创建了发布 p。通道 c0 订阅了发布 p 和主题 :a,而通道 c1 订阅了相同的发布,但针对主题 :b。当通道 c 接收到一个值时,如果函数 topic-fn 对于该值返回 :a,则该值将被发送到通道 c0;如果函数 topic-fn 对于该值返回 :b,则该值将被发送到通道 c1。请注意,前面图中的值 :a:b 只是任意字面量,我们同样可以轻松地使用其他字面量。

总结来说,core.async 库提供了几个构造来在通道之间创建连接。这些构造有助于模拟数据从任意数量的源通道流向任意数量的输出通道的不同方式。

再次探讨就餐哲学家问题

现在,让我们尝试使用 core.async 库来实现就餐哲学家问题的解决方案。我们已经在第二章中实现了就餐哲学家问题的两个解决方案,其中一种解决方案使用了 refs,另一种使用了 agents。在本节中,我们将使用通道来实现就餐哲学家问题的解决方案。

就餐哲学家问题可以简洁地描述如下。五位哲学家坐在一张桌子旁,桌子上有五把叉子放在他们之间。每位哲学家需要两把叉子才能吃饭。哲学家们必须以某种方式共享他们之间放置的叉子的访问权限,以消费分配给他们的食物,并且没有任何哲学家因为无法获得两把叉子而饿死。在这个实现中,我们将使用通道来维护桌子上的叉子和哲学家的状态。

注意

在接下来的示例中,必须在您的命名空间声明中包含以下命名空间:

(ns my-namespace
  (:require [clojure.core.async :as a]
            [m-clj.c2.refs :as c2]))

以下示例可以在书籍源代码的src/m_clj/c8/ dining_philosophers_async.clj中找到。其中一些示例基于 Pepijn de Vos 的《进餐哲学家求解器》中的代码(pepijndevos.nl/2013/07/11/dining-philosophers-in-coreasync.html)。

首先,让我们定义几个函数来初始化我们正在处理的所有哲学家和叉子,如示例 8.4所示:

(defn make-philosopher [name forks food]
  {:name name
   :forks forks
   :food food})

(defn make-forks [nf]
  (let [forks (repeatedly nf #(a/chan 1))]
    (doseq [f forks]
      (a/>!! f :fork))
    forks))

示例 8.4: 进餐哲学家问题

示例 8.4中定义的make-philosopher函数创建了一个表示哲学家状态的映射。参数name将是一个字符串,参数forks将是一个包含两个叉子通道的向量,参数food将是一个表示提供给哲学家的食物数量的数字。这两个叉子代表哲学家左右两侧的叉子。这些叉子将由我们之前在第二章中定义的init-philosophers函数分配并传递给make-philosopher函数。之前显示的make-forks函数创建指定数量的通道,将值:fork放入每个通道中,最后返回新的通道。

接下来,让我们将哲学家的常规定义为一种进程。哲学家必须尝试获取他左右两侧的叉子,如果获取到两个叉子,就吃他的食物,最后释放他成功获取的任何叉子。由于我们模拟中所有哲学家的状态都由一个通道捕获,因此我们必须将一个哲学家从通道中取出,执行哲学家的常规,然后将哲学家的状态放回通道。这个常规由示例 8.5中的philosopher-process函数实现:

(defn philosopher-process [p-chan max-eat-ms max-think-ms]
  (a/go-loop []
    (let [p (a/<! p-chan)
          food (:food p)
          fork-1 ((:forks p) 0)
          fork-2 ((:forks p) 1)
          ;; take forks
          fork-1-result (a/alt!
                          (a/timeout 100) :timeout
                          fork-1 :fork-1)
          fork-2-result (a/alt!
                          (a/timeout 100) :timeout
                          fork-2 :fork-2)]
      (if (and (= fork-1-result :fork-1)
               (= fork-2-result :fork-2))
        (do
          ;; eat
          (a/<! (a/timeout (rand-int max-eat-ms)))
          ;; put down both acquired forks
          (a/>! fork-1 :fork)
          (a/>! fork-2 :fork)
          ;; think
          (a/<! (a/timeout (rand-int max-think-ms)))
          (a/>! p-chan (assoc p :food (dec food))))
        (do
          ;; put down any acquired forks
          (if (= fork-1-result :fork-1)
            (a/>! fork-1 :fork))
          (if (= fork-2-result :fork-2)
            (a/>! fork-2 :fork))
          (a/>! p-chan p)))
      ;; recur
      (when (pos? (dec food)) (recur)))))

示例 8.5: 进餐哲学家问题(续)

前面的philosopher-process函数使用go-loop宏启动一个异步进程。参数p-chanmax-eat-msmax-think-ms分别代表包含所有哲学家状态的通道、哲学家可以花费的最大吃饭时间和哲学家可以思考的最大时间。philosopher-process函数启动的异步任务将尝试从哲学家的fork-1fork-2叉子中获取值,超时时间为100毫秒。这是通过alt!timeout函数的组合来完成的。如果哲学家能够获取两个叉子,他将吃一段时间,放下或释放两个叉子,花一些时间思考,然后重复相同的过程。如果他无法获取两个叉子,哲学家将释放任何获取到的叉子并重新启动相同的过程。哲学家的状态始终被放回到通道p-chan。这个异步进程会一直重复,直到哲学家有任何剩余的食物。接下来,让我们定义一些函数来启动和打印我们模拟中的哲学家,如下所示示例 8.6

(defn start-philosophers [p-chan philosophers]
  (a/onto-chan p-chan philosophers false)
  (dorun (repeatedly (count philosophers)
                     #(philosopher-process p-chan 100 100)))) 

(defn print-philosophers [p-chan n]
  (let [philosophers (repeatedly n #(a/<!! p-chan))]
    (doseq [p philosophers]
      (println (str (:name p) ":\t food=" (:food p)))
      (a/>!! p-chan p))))

示例 8.6:就餐哲学家问题(继续)

前面的start-philosophers函数将哲学家序列(由参数philosophers表示)放入通道p-chan,然后对序列philosophers中的每个哲学家调用philosopher-process函数。print-philosophers函数使用阻塞通道读写函数,即<!!>!!,从通道p-chan读取n个哲学家并打印每个哲学家盘子上的剩余食物量。

最后,让我们使用make-philosophermake-forks函数创建一些哲学家的实例及其相关的叉子。我们还将使用来自第二章的init-philosophers函数,编排并发与并行,使用make-philosopher函数创建哲学家对象,并将两个叉子分配给每个哲学家。我们模拟中哲学家和叉子的这些顶级定义在示例 8.7中展示。

(def all-forks (make-forks 5))
(def all-philosophers
  (c2/init-philosophers 5 1000 all-forks make-philosopher))

(def philosopher-chan (a/chan 5))

示例 8.7:就餐哲学家问题(继续)

如此所示,我们定义了五个叉子和哲学家,并创建了一个通道来表示我们创建的所有哲学家的状态。请注意,我们用于哲学家的通道具有5的缓冲区大小。可以通过调用start-philosophers函数来启动模拟,并使用print-philosophers函数打印哲学家的状态,如下所示:

user> (start-philosophers philosopher-chan all-philosophers)
nil
user> (print-philosophers philosopher-chan 5)
Philosopher 3:   food=937
Philosopher 2:   food=938
Philosopher 1:   food=938
Philosopher 5:   food=938
Philosopher 4:   food=937
nil
user> (print-philosophers philosopher-chan 5)
Philosopher 4:   food=729
Philosopher 1:   food=729
Philosopher 2:   food=729
Philosopher 5:   food=730
Philosopher 3:   food=728
nil

如前述输出所示,五位哲学家在他们之间共享叉子,并以相同的速度消耗食物。所有哲学家都有机会吃到食物,因此没有人会饿死。请注意,print-philosophers函数打印的哲学家的顺序可能随时变化,某些哲学家也可能被此函数打印两次。

以这种方式,我们可以使用core.async库中的通道和进程来解决给定的问题。此外,我们可以创建任意数量的此类进程,而无需担心操作系统级别的线程数量。

使用 actor

Actor是另一种将系统建模为大量并发运行进程的方法。在actor 模型中,每个进程被称为 actor,此模型基于这样的哲学:系统中的每一块逻辑都可以表示为一个 actor。actor 背后的理论最早由 Carl Hewitt 在 20 世纪 70 年代初发表。在我们探索 actor 之前,我们必须注意,核心 Clojure 语言和库并没有提供 actor 模型的实现。事实上,在 Clojure 社区中,普遍认为进程和通道是比 actor 更好的并发运行进程的建模方法。除此之外,actor 可以用来提供更健壮的错误处理和恢复,并且可以通过 Pulsar 库在 Clojure 中使用 actor(github.com/puniverse/pulsar)。

注意

要了解更多关于为什么在 Clojure 中进程和通道比 actor 更受欢迎的原因,请参阅 Rich Hickey 的《Clojure core.async Channels》(Clojure core.async Channels) (clojure.com/blog/2013/06/28/clojure-core-async-channels)。

Actor 模型描述 actor 为接收消息时执行某些计算的并发进程。actor 还可以向其他 actor 发送消息,创建更多 actor,并根据接收到的消息改变自己的行为。actor 也可以有自己的内部状态。实际上,actor 最初被描述为具有自己的本地内存的独立处理器,它们通过高速通信网络相互交互。每个 actor 都有自己的邮箱来接收消息,而消息是 actor 之间传递数据的唯一手段。以下图展示了 actor 作为一个接收某些输入作为消息并执行计算以产生某些输出的实体:

使用 actor

Pulsar 库提供了演员模型的全面实现。在这个库中,演员被安排在 fibers 上执行,这些 fibers 与使用 core.async 库的 go 表达式创建的异步任务类似。fibers 被安排在分叉-合并线程池上运行,这与在 core.async 库中使用的常规线程池不同。由于这种设计,Pulsar 库仅在 JVM 上可用,而不是通过 ClojureScript 在浏览器中使用。

Fibers 通过 Pulsar 库自己实现的 promiseschannels 进行通信。有趣的是,Pulsar 库在其通道实现周围还有几个薄薄的包装器,以提供一个与 core.async 库完全兼容的 API。尽管我们不会在本节中进一步讨论 Pulsar 库的 fibers、promises 和 channels,但我们必须理解通道与演员非常相关,因为演员的邮箱是用通道实现的。现在,让我们探索 Pulsar 库中演员的基本知识。

创建演员

来自 co.paralleluniverse.pulsar.actors 命名空间的 spawn 宏创建一个新的演员,并且必须传递一个不接受任何参数的函数。我们可以使用 spawn 宏的 :mailbox-size 关键字参数来指定演员邮箱的缓冲区大小。还有其他几个有趣的关键字参数可以传递给 spawn 表达式,您被鼓励自行了解更多关于它们的信息。

注意

下面的库依赖项对于即将到来的示例是必需的:

[co.paralleluniverse/quasar-core "0.7.3"]
[co.paralleluniverse/pulsar "0.7.3"]

您的 project.clj 文件还必须包含以下条目:

:java-agents
[[co.paralleluniverse/quasar-core "0.7.3"]]
:jvm-opts
["-Dco.paralleluniverse.pulsar.instrument.auto=all"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [co.paralleluniverse.pulsar.core :as pc]
            [co.paralleluniverse.pulsar.actors :as pa]))

传递给 spawn 宏的函数必须使用来自 co.paralleluniverse.pulsar.actors 命名空间的 receive 宏来处理演员接收到的消息。在这个提供的函数中,我们可以使用表达式 @self 来引用执行它的演员。receive 表达式还支持模式匹配,这是通过 core.match 库实现的。我们还可以不带参数调用 receive 宏,在这种情况下,它将返回演员邮箱中的消息。receive 表达式还将暂停执行它的 fiber。

要向演员发送消息,我们可以使用 co.paralleluniverse.pulsar.actors 命名空间中的 !!! 宏。这两个宏都必须传递一个演员和一个返回值的表达式,并且这两种形式都返回 nil。这两种形式之间的唯一区别是 ! 是异步的,而 !! 是同步的,并且如果演员的邮箱已满,它可能会阻塞当前执行的线程。演员在收到特定消息时可能会终止,我们可以使用 co.paralleluniverse.pulsar.actors 命名空间中的 done? 函数来检查演员是否仍然活跃。一旦演员终止,我们可以使用 co.paralleluniverse.pulsar.core 命名空间中的 join 函数来获取演员返回的最终值。例如,考虑使用 spawnreceive 形式在 示例 8.8 中创建的演员。

注意

以下示例可以在书籍源代码的 src/m_clj/c8/actors.clj 中找到。其中一些示例基于官方 Pulsar 文档中的代码(http://docs.paralleluniverse.co/pulsar/)。

(def actor (pa/spawn
            #(pa/receive
              :finish (println "Finished")
              m (do
                  (println (str "Received: " m))
                  (recur)))))

示例 8.8:使用 spawn 宏创建的演员

由前一个变量 actor 表示的演员将接收一条消息,打印它并使用 recur 形式循环。如果收到消息 :finish,它将打印一个字符串并终止。以下代码演示了我们可以如何向演员发送消息:

user> (pa/! actor :foo)
nil
Received: :foo
user> (pa/done? actor)
false

如此所示,向演员发送值 :foo 会立即返回 nil,并且消息会从另一个线程中打印出来。由于 done? 函数在传递变量 actor 时返回 false,因此很明显,演员在收到值 :foo 作为消息时不会终止。另一方面,如果我们向演员发送值 :finish,它将终止,如下所示:

user> (pa/! actor :finish)
nil
Finished
user> (pa/done? actor)
true

在发送值 :finish 后,当将 done? 函数应用于演员时,它返回 true,这意味着演员已经终止。演员在终止前返回的值可以使用 co.paralleluniverse.pulsar.core 命名空间中的 join 函数获取。我们必须注意,join 函数实际上返回任何纤维的结果,并且将阻塞调用线程的执行,直到纤维完成或终止。例如,考虑 示例 8.9 中除以另一个数字的演员:

(def divide-actor
  (pa/spawn
   #(loop [c 0]
      (pa/receive
       :result c
       [a b] (recur (/ a b))))))

示例 8.9:执行数字除法的演员

我们可以向 示例 8.9 中定义的演员 divide-actor 发送消息,并使用 join 函数从它那里获取最终结果,如下所示:

user> (pa/! divide-actor 30 10)
nil
user> (pa/! divide-actor :result)
nil
user> (pc/join divide-actor)
3

前面的代码显示我们可以向演员 divide-actor 发送两个数字,并发送值 :result 来终止它。终止后,我们可以通过将演员传递给 join 函数来获取演员的结果,即 3

演员可以用有意义的名称注册,这些名称可以用来定位它们。这是通过co.paralleluniverse.pulsar.actors命名空间中的register!函数完成的,该函数必须传递一个演员实例和一个要为该演员注册的名称。然后我们可以通过指定演员的名称到!!!函数来向注册的演员发送消息。例如,假设变量actor代表使用spawn宏创建的演员实例。通过调用(pa/register! actor :my-actor)将演员以名称:my-actor注册后,我们可以通过调用(pa/! :my-actor :foo)将值:foo发送到该演员。

传递演员之间的消息

现在,让我们构建一个简单的由两个演员组成的乒乓游戏的模拟。这两个演员将互相发送指定次数的:ping:pong消息。这个模拟的代码在示例 8.10中如下所示:

(defn ping-fn [n pong]
  (if (= n 0)
    (do
      (pa/! pong :finished)
      (println "Ping finished"))
    (do
      (pa/! pong [:ping @pa/self])
      (pa/receive
       :pong (println "Ping received pong"))
      (recur (dec n) pong))))

(defn pong-fn []
  (pa/receive
   :finished (println "Pong finished")
   [:ping ping] (do
                  (println "Pong received ping")
                  (pa/! ping :pong)
                  (recur))))

(defn start-ping-pong [n]
  (let [pong (pa/spawn pong-fn)
        ping (pa/spawn ping-fn n pong)]
    (pc/join pong)
    (pc/join ping)
    :finished))

示例 8.10:两个演员玩乒乓球的例子

示例 8.10中展示的ping-fnpong-fn函数实现了两个演员玩乒乓球的逻辑。ping-fn将基本上发送一个包含关键字:ping和当前演员实例的向量到由参数pong表示的演员。这会进行n次,最后将消息:finished发送到演员pongpong-fn函数将接收向量[:ping ping],其中ping将是发送消息的演员。使用pong-fn创建的演员一旦收到消息:finished就会终止。start-ping-pong函数简单地使用ping-fnpong-fn函数创建两个演员,并使用join函数等待它们都完成。我们可以通过传递每个演员必须互相发送消息的次数来调用start-ping-pong函数,如下所示:

user> (start-ping-pong 3)
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Ping finished
Pong finished
:finished

start-ping-pong函数创建的两个演员通过互相传递消息来模拟乒乓球游戏,如前面的输出所示。总之,Pulsar 库中的演员可以用来实现并发执行的过程。

使用演员处理错误

演员支持一些有趣的错误处理方法。如果一个演员在处理接收到的消息时遇到错误,它将终止。在执行演员的纤维中抛出的异常将被保存,并在我们将演员传递给join函数时再次抛出。实际上,我们不需要在传递给spawn宏的函数中处理异常,相反,我们必须在调用join函数时捕获异常。

这引出了演员的一个有趣后果。如果一个演员可能遇到错误并失败,我们可以有一个监视第一个演员的另一个演员,并在失败的情况下重新启动它。因此,演员可以在系统中的另一个演员终止时得到通知。这个原则允许演员以自动化的方式从错误中恢复。在 Pulsar 库中,这种错误处理是通过 co.paralleluniverse.pulsar.actors 命名空间中的 watch!link! 函数来完成的。

演员可以通过在其体内调用 watch! 函数来监视监控另一个演员。例如,我们必须在演员的体内调用 (watch! A) 来监视演员 A。如果被监视的演员遇到异常,监视演员的 receive 形式将抛出相同的异常。监视演员必须捕获异常,否则它将与产生异常的演员一起终止。此外,监视演员可以通过调用 spawn 宏来重新启动已终止的演员。要停止监视一个演员,我们必须在监视演员的体内将监视的演员传递给 unwatch! 函数。

两个演员也可以通过将它们传递给 link! 函数来链接。如果两个演员被链接在一起,那么两个演员中的任何一个遇到的异常都将被另一个捕获。通过这种方式,链接两个演员是对它们进行错误监视的对称方式。link! 函数也可以在传递给 spawn 形式的函数中调用,在这种情况下,必须传递要链接的演员。要解除两个演员的链接,我们可以使用 unlink! 函数。

因此,Pulsar 库提供了一些有趣的方法来监视和链接演员以执行错误处理和恢复。

使用演员管理状态

如我们之前提到的,演员可以拥有自己的内部可变状态。当然,不允许从其他演员访问这个状态,不可变消息是演员与其他演员通信的唯一方式。演员维持或管理其状态的另一种方式是根据接收到的消息改变其行为,这种技术称为选择性接收

使用 spawn 函数创建的每个演员都可以使用表达式 @state 读取其内部状态,也可以使用 set-state! 函数写入此状态。set-state! 函数还将返回演员的新状态,正如表达式 @state 返回的那样。请注意,这两个形式都是在 co.paralleluniverse.pulsar.actors 命名空间中实现的。

考虑 示例 8.11 中的 add-using-state 函数,该函数使用演员来添加两个数字。当然,在现实世界中我们永远不会真正需要这样的函数,这里只是演示了演员如何改变其内部状态。

(defn add-using-state [a b]
  (let [actor (pa/spawn
               #(do
                  (pa/set-state! 0)
                  (pa/set-state! (+ @pa/state (pa/receive)))
                  (pa/set-state! (+ @pa/state (pa/receive)))))]
    (pa/! actor a)
    (pa/! actor b)
    (pc/join actor)))

示例 8.11:使用演员添加两个数字的函数

示例 8.11中所示的add-using-state函数创建了一个 actor,将其状态设置为0,并将它接收到的前两个消息添加到其状态中。actor 将返回 actor 的最新状态,这是通过spawn宏传递给函数的set-state!的最后一次调用返回的状态。在调用带有两个数字的add-using-state函数时,它产生它们的和作为其输出,如下所示:

user> (add-using-state 10 20)
30

另一种 actor 可以修改其状态的方式是通过选择性接收,在 actor 接收到特定消息时修改其行为。这是通过在另一个receive形式的体内调用receive形式来完成的,如示例 8.12所示:

(defn add-using-selective-receive [a b]
  (let [actor (pa/spawn
               #(do
                  (pa/set-state! 0)
                  (pa/receive
                   m (pa/receive
                      n (pa/set-state! (+ n m))))))]
    (pa/! actor a)
    (pa/! actor b)
    (pc/join actor)))

示例 8.12:使用具有选择性接收的 actor 添加两个数字的函数

如前所述的add-using-selective-receive函数将设置其状态为0,通过选择性接收接收消息mn,并将这些消息相加。此函数产生的结果与示例 8.11中的add-using-state函数相同,如下所示:

user> (add-using-selective-receive 10 20)
30

以这种方式,actors 可以根据发送给它们的消息改变其内部状态和行为。

比较进程和 actors

CSPs 和 actors 是两种将系统建模为大量并发进程的方法,这些进程异步执行和交互。异步任务逻辑可以位于使用go块创建的进程内,或者位于创建 actor 的spawn宏传递的函数内。然而,这两种方法之间存在一些细微的对比:

  • 使用gothread形式创建的进程鼓励我们将所有状态放入通道中。另一方面,actors 可以有自己的内部状态,除了以发送给它们的消息形式的状态之外。因此,actors 更像是具有封装状态的对象,而进程更像是操作存储在通道中的状态的函数。

  • 使用gothread宏创建的任务没有隐式的错误处理,我们必须在gothread宏的体内使用trycatch形式来处理异常。当然,通道支持错误处理,但仅当与转换器结合使用时。然而,actors 会在我们对其应用join函数之前保存它们遇到的任何异常。此外,actors 可以被链接和监控,以提供一种自动的错误恢复形式。以这种方式,actors 更专注于构建容错系统。

CSPs(通信顺序进程)与 actor 模型之间的这些区别因素,让我们对在特定问题中实现异步任务哪种方法更合适有了概念。

摘要

在本章中,我们探讨了如何使用core.async和 Pulsar 库创建并发和异步任务。core.async库提供了一个 CSP 的实现,并在 Clojure 和 ClojureScript 中都有支持。我们研究了core.async库中的各种构造,并展示了如何使用这个库实现就餐哲学家问题的解决方案。后来,我们通过 Pulsar 库探索了 actor。

在下一章中,我们将探讨响应式编程。正如我们之前所看到的,响应式编程可以被视为异步编程的扩展,用于处理数据和事件。

第九章。响应式编程

使用异步任务进行编程的许多有趣应用之一是响应式编程。这种编程方法完全是关于异步响应状态的变化。在响应式编程中,代码以这样的方式组织,它可以响应变化。通常,这是通过异步数据流实现的,其中数据和事件在程序中异步传播。实际上,响应式编程有许多有趣的变体。

响应式编程在设计和开发前端图形用户界面时特别有用,因为应用程序内部状态的变化必须异步地逐渐传递到用户界面。因此,程序被分割成事件和在这些事件上执行的逻辑。对于习惯于命令式和面向对象编程技术的程序员来说,响应式编程中最困难的部分是思考响应式抽象,并放弃使用可变状态等旧习惯。然而,如果你一直保持关注并开始用不可变性和函数进行思考,你会发现响应式编程非常自然。在 JavaScript 世界中,使用观察者进行响应式编程可以被视为使用承诺管理异步事件和动作的对比替代方案。

在本章中,我们将通过 Clojure 和 ClojureScript 库探索一些有趣的响应式编程形式。稍后,我们还将展示如何使用响应式编程构建动态用户界面。

使用纤维和数据流变量进行响应式编程

数据流编程 是反应式编程中最简单的一种形式。在数据流编程中,计算是通过组合变量来描述的,而不必关心这些变量何时被设置为某个值。这样的变量也被称为 数据流变量,一旦它们被设置,就会触发引用它们的计算。Pulsar 库(github.com/puniverse/pulsar)为数据流编程提供了一些有用的构造。这些构造也可以与 Pulsar 纤维 一起使用,我们曾在第八章 利用异步任务 中简要讨论过。在本节中,我们将从 Pulsar 库中探索纤维和数据流变量的基础知识。

注意

以下示例所需的库依赖项:

[co.paralleluniverse/quasar-core "0.7.3"]
[co.paralleluniverse/pulsar "0.7.3"]

您的 project.clj 文件还必须包含以下条目:

:java-agents
[[co.paralleluniverse/quasar-core "0.7.3"]]
:jvm-opts
["-Dco.paralleluniverse.pulsar.instrument.auto=all"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [co.paralleluniverse.pulsar.core :as pc]
            [co.paralleluniverse.pulsar.dataflow
             :as pd]))

Pulsar 库中异步任务的基本抽象是纤维。纤维被安排在基于 fork-join 的线程池上执行,我们可以创建大量纤维,而无需担心可用的处理核心数量。可以使用来自 co.paralleluniverse.pulsar.core 命名空间的 spawn-fiberfiber 宏来创建纤维。spawn-fiber 宏必须传递一个不接受任何参数的函数,而 fiber 形式必须传递一个表达式体。这两个形式的体将在新的纤维上执行。可以使用来自 co.paralleluniverse.pulsar.core 命名空间的 join 函数来检索纤维返回的值。

在处理纤维时,我们必须牢记的一个重要规则是,我们绝不能在纤维内部调用操作当前执行线程的方法或函数。相反,我们必须使用来自 co.paralleluniverse.pulsar.core 命名空间的特殊纤维函数来执行这些操作。例如,在纤维中调用 java.lang.Thread/sleep 方法必须避免。相反,可以使用来自 co.paralleluniverse.pulsar.core 命名空间的 sleep 函数来暂停当前纤维一段时间,以毫秒为单位。

注意

以下示例可以在书籍源代码的 src/m_clj/c9/fibers.clj 中找到。其中一些示例基于官方 Pulsar 文档中的代码(docs.paralleluniverse.co/pulsar/)。

例如,我们可以使用纤维来添加两个数字,如 示例 9.1 所示。当然,使用纤维进行这种微不足道的操作没有实际用途,这里仅展示如何创建纤维并获取其返回值:

(defn add-with-fiber [a b]
  (let [f (pc/spawn-fiber
               (fn []
                 (pc/sleep 100)
                 (+ a b)))]
    (pc/join f)))

示例 9.1:使用纤维添加两个数字

前面的 add-with-fiber 函数使用 spawn-fiber 宏创建了一个纤维 f,并使用 join 函数获取纤维的返回值。纤维 f 将使用 sleep 函数暂停 100 毫秒,然后返回 ab 的和。

让我们简单谈谈数据流变量。我们可以使用 co.paralleluniverse.pulsar.dataflow 命名空间中的 df-valdf-var 函数来创建数据流变量。使用这些函数创建的数据流变量可以通过像调用函数一样调用它并传递一个值来设置。此外,可以通过使用 @ 操作符或 deref 形式解引用来获取数据流变量的值。使用 df-val 函数声明的数据流变量只能设置一次,而使用 df-var 函数创建的可以设置多次。

df-var 函数也可以传递一个不带参数的函数,该函数引用当前作用域中的其他数据流变量。这样,当引用变量的值发生变化时,此类数据流变量的值将重新计算。例如,可以使用数据流变量将两个数字相加,如 示例 9.2 中定义的 df-add 函数所示:

(defn df-add [a b]
  (let [x (pd/df-val)
        y (pd/df-val)
        sum (pd/df-var #(+ @x @y))]
    (x a)
    (y b)
    @sum))

示例 9.2:使用数据流变量添加两个数字

在前面的 df-add 函数中声明的数据流变量 sum 的值将在引用的数据流变量 xy 被设置为值时重新计算。变量 xy 通过像函数一样调用它们来设置。同样,我们可以使用 df-valdf-var 函数,如以下 示例 9.3 所示,向数字范围中的每个元素添加一个数字:

(defn df-add-to-range [a r]
  (let [x (pd/df-val)
        y (pd/df-var)
        sum (pd/df-var #(+ @x @y))
        f (pc/fiber
           (for [i r]
             (do
               (y i)
               (pc/sleep 10)
               @sum)))]
    (x a)
    (pc/join f)))

示例 9.3:使用数据流变量向数字范围添加一个数字

之前展示的 df-add-to-range 函数定义了数据流变量 xysum,其中 sum 依赖于 xy。该函数随后创建了一个使用 for 宏返回一系列值的纤维 f。在 for 宏的体内,数据流变量 y 被设置为范围 r 中的一个值,并返回 @sum 的值。因此,纤维返回了将 a 添加到范围 r 中所有元素的结果,如下面的输出所示:

user> (df-add-to-range 2 (range 10))
(2 3 4 5 6 7 8 9 10 11)

总之,我们可以使用 df-valdf-var 函数来定义数据流变量,当其引用的变量发生变化时,其值可以重新计算。实际上,更改数据流变量的状态可能会使其他数据流变量对变化做出 反应

我们应该注意,Pulsar 库也实现了通道,这些通道与core.async库中的通道类似。简而言之,通道可以用于与纤维交换数据。Pulsar 库还通过co.paralleluniverse.pulsar.rx命名空间提供了具有通道的响应式编程结构。这些结构被称为响应式扩展,它们在通道中的值上执行某些计算,与转换器非常相似。响应式扩展也由RxClojure库实现。我们应该注意,Pulsar 和 RxClojure 库的一个局限性是它们仅在 JVM 上可用,不能用于 ClojureScript 程序。因此,在 ClojureScript 中使用带有转换器的core.async通道是一个更可行的选项。尽管如此,我们将在下一节简要探讨通过 RxClojure 库的响应式扩展。

使用响应式扩展

响应式扩展(缩写为Rx)是响应式编程的通用实现,可用于建模事件和数据流。在 Rx 中,可以将事件流视为具有某些方法和属性的对象。在 Rx 中,异步事件流被称为可观察量。订阅来自可观察量事件的实体或对象被称为观察者。响应式扩展本质上是一个函数库或方法库,用于操作可观察量并创建符合观察者-可观察量模式的对象。例如,可以使用 Rx 的mapfilter函数变体来转换可观察量,如下面的插图所示:

使用响应式扩展

如前所述,一个可观察量可以被描述为随时间变化的一系列值。很明显,可观察量可以用mapfilter函数的 Rx 风格变体来处理,将其视为值序列。一个可观察量也可以被观察者订阅,观察者将对可观察量产生的任何值进行异步调用。

我们现在将讨论 RxClojure 库的各种结构(github.com/ReactiveX/RxClojure)。Rx 在多种语言中都有几个实现,例如 C#、Java 和 PHP。响应式扩展的 Java 库是 RxJava,RxClojure 库为 RxJava 提供了 Clojure 绑定。如我们之前提到的,需要注意的是,RxClojure 只能在 JVM 上使用。此外,RxClojure 库在 Clojure 实现转换器之前就已经存在,因此通道和转换器是响应式编程的更便携和更通用的方法。

注意

以下库依赖项对于即将到来的示例是必需的:

[io.reactivex/rxclojure "1.0.0"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [rx.lang.clojure.core :as rx]
            [rx.lang.clojure.blocking :as rxb]
            [rx.lang.clojure.interop :as rxj]))

rx.lang.clojure.core命名空间包含用于创建和操作可观察对象的函数。可观察对象在内部表示为值的集合。要从可观察对象中提取值,我们可以使用rx.lang.clojure.blocking命名空间中的函数。然而,我们必须注意,rx.lang.clojure.blocking命名空间中的函数应避免在程序中使用,仅用于测试目的。rx.lang.clojure.interop命名空间包含用于与底层 RxJava 库进行 Java 互操作的函数。

注意

以下示例可以在书籍源代码的src/m_clj/c9/rx.clj中找到。

可以使用rx.lang.clojure.core命名空间中的return函数将值转换为可观察对象。可以使用rx.lang.clojure.blocking/into函数将可观察对象转换为值的向量,同样,我们可以使用rx.lang.clojure.blocking/first函数获取可观察对象的第一值。这些函数在以下输出中得到了演示:

user> (def o (rx/return 0))
#'user/o
user> (rxb/into [] o)
[0]
user> (rxb/first o)
0

可以使用rx.lang.clojure.core命名空间中的seq->o函数将值序列转换为可观察对象。要将可观察对象转换回序列,我们将它传递给rx.lang.clojure.blocking命名空间中的o->seq函数。例如,我们可以将向量[1 2 3]转换为可观察对象,然后再将其转换回序列,如下所示:

user> (def o (rx/seq->o [1 2 3]))
#'user/o
user> (rxb/o->seq o)
(1 2 3)

创建可观察对象的另一种方法是使用rx.lang.clojure.core命名空间中的consempty函数。empty函数创建一个没有值的可观察对象,而cons函数将一个值和一个可观察对象添加或组合到一个新的、可观察的对象中,类似于标准的cons函数。我们可以使用consempty函数创建包含值0的可观察对象,如下所示:

user> (def o (rx/cons 0 (rx/empty)))
#'user/o
user> (rxb/first o)
0

如我们之前提到的,观察者可以订阅可观察对象的事件。观察者可以通过实现rx.lang.clojure.Observer接口来定义。该接口定义了三个方法,即onNextonErroronCompleted。每当可观察对象产生新值时,都会调用onNext方法,而当可观察对象完成产生值时,会调用onCompleted方法。如果遇到异常,将调用onError方法。有趣的是,所有这些方法都将从可观察对象异步调用。例如,我们可以使用reify形式创建一个观察者来实现Observer接口,如示例 9.4所示:

(def observer
  (reify rx.Observer
    (onNext [this v] (println (str "Got value: " v "!")))
    (onError [this e] (println e))
    (onCompleted [this] (println "Done!"))))

示例 9.4:实现 rx.lang.clojure.Observer 接口

可观察对象可以使用 rx.lang.clojure.core 命名空间中的 on-nexton-erroron-completed 函数调用其所有已订阅观察者的方法。我们还可以使用这些函数和 rx.lang.clojure.core 命名空间中的 observable* 形式定义可观察对象。observable* 形式必须传递一个接受单个参数的函数,该参数表示观察者。例如,我们可以使用 observable* 形式定义一个创建两个值可观察对象的函数,如 示例 9.5 所示:

(defn make-observable []
  (rx/observable* (fn [s]
                    (-> s
                        (rx/on-next :a)
                        (rx/on-next :b)
                        rx/on-completed))))

示例 9.5:使用 observable 形式创建可观察对象

之前展示的 observable* 形式的函数调用 on-nexton-completed 函数以产生两个值的可观察对象。我们可以使用 rx.lang.clojure.blocking 命名空间中的 into 函数将此可观察对象转换为向量,如下所示:

user> (def o (make-observable))
#'user/o
user> (rxb/into [] o)
[:a :b]

观察者也可以使用 rx.lang.clojure.core 命名空间中的 subscribe 函数创建。这个函数必须传递一个接受单个值的函数,并通过实现提供的函数使用 onNext 方法创建观察者。我们还可以将表示 onError 方法的第二个参数以及表示 onCompleted 方法的第三个参数传递给 subscribe 函数。例如,我们可以使用 subscribe 函数订阅可观察对象,并使用 rx.lang.clojure.core/map 函数对所有可观察对象中的值应用一个函数,如 示例 9.6 所示:

(defn rx-inc [o]
  (rx/subscribe o (fn [v] (println (str "Got value: " v "!"))))
  (rx/map inc o))

示例 9.6:使用 subscribe 函数订阅可观察对象

我们可以创建一个可观察对象并将其传递给 示例 9.6 中定义的 rx-inc 函数,如下所示:

user> (def o (rx/seq->o [0 1 2]))
#'user/o
user> (rx-inc o)
Got value: 0!
Got value: 1!
Got value: 2!
#<rx.Observable 0xc3fae8>

示例 9.6 中传递给 subscribe 形式的函数会在每次将 inc 函数应用于可观察对象 o 中的值时执行。我们同样可以使用 RxJava 和 Java 互操作定义 rx-inc 函数,如 示例 9.7 所示:

(defn rxj-inc [o]
  (.subscribe o (rxj/action [v]
                  (println (str "Got value: " v "!"))))
  (.map o (rxj/fn [v] (inc v))))

示例 9.7:使用 Java 互操作订阅可观察对象

很明显,通过 Java 互操作使用 RxJava 库并不美观,因为我们不得不将 rx.lang.clojure.interop 命名空间中的 actionfn 形式的所有函数包装起来。action 宏用于表示执行副作用的功能,而 fn 宏用于包装返回值的函数。可观察对象也可以使用 Java 互操作创建。这是通过 rx.lang.clojure.core.Observable 类的 from 静态方法完成的。以下输出展示了此方法以及定义在 示例 9.7 中的 rxj-inc 函数:

user> (def o (rx.Observable/from [0 1 2]))
#'user/o
user> (rxj-inc o)
Got value: 0!
Got value: 1!
Got value: 2!
#<rx.Observable 0x16459ef>

当然,我们应该优先使用来自 rx.lang.clojure.core 命名空间的功能,我们在这里使用 Java 互操作只是为了说明这是可能的。类似于在 示例 9.6 中使用的 map 函数,rx.lang.clojure.core 命名空间中还有其他几个函数允许我们将可观察对象视为序列。因此,mapfiltermapcat 等函数构成了可观察对象的接口,并描述了我们与之交互的多种方式。例如,以下输出演示了 takecyclerange 函数的 Rx 变体:

user> (rxb/into [] (->> (rx/range)
 (rx/take 10)))
[0 1 2 3 4 5 6 7 8 9]
user> (rxb/into [] (->> (rx/cycle (rx/return 1))
 (rx/take 5)))
[1 1 1 1 1]

rx.lang.clojure.core 命名空间还提供了一个 filter 函数,可以与可观察对象和谓词一起使用,如下所示:

user> (rxb/into [] (->> (rx/seq->o [:a :b :c :d :e])
 (rx/filter #{:b :c})))
[:b :c]

来自 rx.lang.clojure.core 命名空间的 group-bymapcat 函数与这些函数的标准版本具有相同的语义。例如,让我们定义一个使用 group-bymapcat 函数的函数,如 示例 9.8 所示:

(defn group-maps [ms]
  (->> ms
       (rx/seq->o)
       (rx/group-by :k)
       (rx/mapcat (fn [[k vs :as me]]
                    (rx/map #(vector k %) vs)))
       (rxb/into [])))

示例 9.8:使用 group-by 和 mapcat 函数

之前定义的 group-maps 函数将多个映射转换为可观察对象,按键 :k 的值对这些映射进行分组,并使用 mapcatmap 函数创建多个向量。当然,在实践中我们可能并不真的需要这样的函数,这里只展示如何使用 group-bymapcat 函数。我们可以将映射的向量传递给 group-maps 函数以生成一个向量的序列,如下所示:

user> (group-maps [{:k :a :v 1}
 {:k :b :v 2}
 {:k :a :v 3}
 {:k :c :v 4}])
[[:a {:k :a, :v 1}]
 [:a {:k :a, :v 3}]
 [:b {:k :b, :v 2}]
 [:c {:k :c, :v 4}]]

可以使用 rx.lang.clojure.core 命名空间的 merge 函数将多个可观察对象组合起来。merge 函数可以传递任意数量的可观察对象,如下所示:

user> (let [o1 (rx/seq->o (range 5))
 o2 (rx/seq->o (range 5 10))
 o (rx/merge o1 o2)]
 (rxb/into [] o))
[0 1 2 3 4 5 6 7 8 9]

可观察对象也可以使用 rx.lang.clojure.core 命名空间的 split-with 函数拆分为两个可观察对象。这个函数必须传递一个可观察对象和一个谓词函数,如下所示:

user> (->> (range 6)
 rx/seq->o
 (rx/split-with (partial >= 3))
 rxb/first
 (map (partial rxb/into [])))
([0 1 2 3] [4 5])

总结来说,RxClojure 库为我们提供了创建和操作可观察对象的几个构造。我们还可以使用这个库中的 subscribe 函数轻松创建异步 响应 可观察对象的观察者。此外,rx.lang.clojure.core 命名空间中的构造与标准函数(如 mapfiltermapcat)的语义相似。在本节中,我们还没有讨论 rx.lang.clojure.core 命名空间中的几个函数,我们鼓励你自己去探索它们。

使用函数式响应式编程

更功能化的响应式编程风味是函数式响应式编程(简称FRP)。FRP 首次在 20 世纪 90 年代末由 Conal Elliott 描述,当时他是微软图形研究小组的成员,同时也是 Haskell 编程语言的主要贡献者。FRP 最初被描述为一系列用于与事件行为交互的函数。事件和行为都代表随时间变化的值。这两者之间的主要区别在于,事件是随时间离散变化的值,而行为是持续变化的值。在 FRP 中没有提到观察者可观察的模式。此外,FRP 中的程序被编写为事件和行为的可组合转换,也被称作组合事件系统CESs)。

FRP 的现代实现提供了创建和转换异步事件流的构造。任何形式的状态变化都表示为事件流。从这个角度来看,按钮的点击、对服务器的请求以及变量的修改都可以被视为事件流。Bacon.js库([github.com/baconjs/bacon.js/](https://github.com/baconjs/bacon.js/))是 FRP 的 JavaScript 实现,Yolk库(https://github.com/Cicayda/yolk)提供了对 Bacon.js 库的 ClojureScript 绑定。在本节中,我们将简要研究 Yolk 库提供的构造。

注意

以下示例需要以下库依赖:

[yolk "0.9.0"]

此外,以下命名空间必须包含在您的命名空间声明中:

(ns my-namespace
  (:require [yolk.bacon :as y]))

除了前面的依赖项之外,以下示例还使用了来自src/m_clj/c9/common.cljsset-html!by-id函数。这些函数定义如下:

(defn ^:export by-id [id]
  (.getElementById js/document id))

(defn ^:export set-html! [el s]
  (set! (.-innerHTML el) s))

确保以下 ClojureScript 示例中的代码使用以下命令编译:

$ lein cljsbuild once

yolk.bacon命名空间提供了创建事件流的几个函数,例如laterinterval函数。later函数创建一个事件流,在给定延迟后产生一个单一值。interval函数可以在给定的时间间隔内无限重复一个值。这两个函数都必须传递一个表示毫秒数的第一个参数和一个作为第二个参数产生的值。

Yolk 库中的事件流可能会产生无限数量的值。我们可以通过使用yolk.bacon/sliding-window函数来限制事件流产生的值的数量,该函数创建一个事件流,一旦填满就会丢弃旧值。此函数必须传递一个事件流和一个表示返回的事件流容量的数字。

我们还可以使用 yolk.bacon 命名空间中的 bus 函数创建一个 事件总线,我们可以任意地向其推送值。push 函数将一个值推送到事件总线,而 plug 函数将事件总线连接到另一个事件流。

要监听事件流产生的值,我们可以使用 on-valueon-erroron-end 函数。on-valueon-error 函数将在给定的事件流产生值或错误时分别调用提供的 1-arity 函数。on-end 函数将在流结束时调用一个不带参数的函数。此函数通常与 yolk.bacon/never 函数一起使用,该函数创建一个立即结束而不产生值的流。

事件流也可以以几种方式组合。merge-all 函数将多个事件流组合成一个单一的流。另一种以这种方式从多个事件流中收集值的函数是 flat-map 函数。或者,可以使用 combine-array 函数创建一个生成从提供的流中值的数组的单一事件流。yolk.bacon/when 函数可以用来条件性地组合多个通道。此函数必须传递多个子句,类似于 cond 形式。每个子句必须有两个部分——事件流的向量和一个表达式,当所有提供的事件流产生值时将调用该表达式。

yolk.bacon 命名空间还提供了基于事件流的 mapfiltertake 函数的标准变体。这些函数将事件流作为第一个参数,这与这些函数标准版本的语义略有不同。

使用 Yolk 库中的这些函数,我们可以实现一个简化的基于 ClojureScript 的 dining philosophers 问题解决方案,我们已在之前的章节中描述过。有关 dining philosophers 问题及其解决方案的详细解释,请参阅第二章 Orchestrating Concurrency and Parallelism 和第八章 Leveraging Asynchronous Tasks。

注意

以下示例可以在书的源代码的 src/m_clj/c9/yolk/core.cljs 中找到。此外,以下 ClojureScript 示例的 HTML 页面可以在 resources/html/yolk.html 中找到。以下脚本将包含在这个页面上:

<script type="text/javascript" src="img/bacon.js">
</script>
<script type="text/javascript" src="img/yolk.js">
</script>

在这个 dining philosophers 问题的实现中,我们将使用事件总线来表示哲学家和桌子上的叉子的状态。然后可以使用 Yolk 库中的 when 函数将这些事件总线组合起来。为了简化,我们不会维护太多关于哲学家的状态。让我们首先定义打印哲学家和表示哲学家日常生活的函数,如下面的 示例 9.9 所示:

(defn render-philosophers [philosophers]
  (apply str
         (for [p (reverse philosophers)]
           (str "<div>" p "</div>"))))

(defn philosopher-fn [i n forks philosophers wait-ms]
  (let [p (nth philosophers i)
        fork-1 (nth forks i)
        fork-2 (nth forks (-> i inc (mod n)))]
    (fn []
      (js/setTimeout
       (fn []
         (y/push fork-1 :fork)
         (y/push fork-2 :fork)
         (y/push p {}))
       wait-ms)
      (str "Philosopher " (inc i) " ate!"))))

示例 9.9:使用事件流解决就餐哲学家问题

前面的render-philosophers函数将每个哲学家包裹在一个div标签中,该标签将在网页上显示。philosopher-fn函数返回一个表示哲学家日常生活的函数。该函数通过使用setTimeout JavaScript 函数启动一个任务,将代表特定哲学家及其相关叉子的值推送到事件总线上。这个函数最终将返回一个字符串,表明给定的哲学家能够吃到提供的食物。使用这些函数,我们可以在网页上创建就餐哲学家问题的模拟,如下面的示例 9.10所示:

(let [out (by-id "ex-9-10-out")
      n 5
      [f1 f2 f3 f4 f5 :as forks] (repeatedly n #(y/bus))
      [p1 p2 p3 p4 p5 :as philosophers] (repeatedly n #(y/bus))
      eat #(philosopher-fn % n forks philosophers 1000)
      events (y/when [p1 f1 f2] (eat 0)
                     [p2 f2 f3] (eat 1)
                     [p3 f3 f4] (eat 2)
                     [p4 f4 f5] (eat 3)
                     [p5 f5 f1] (eat 4))]
  (-> events
      (y/sliding-window n)
      (y/on-value
       #(set-html! out (render-philosophers %))))
  (doseq [f forks]
    (y/push f :fork))
  (doseq [p philosophers]
    (y/push p {})))

示例 9.10:使用事件流解决就餐哲学家问题(继续)

示例 9.10中显示的let形式中,我们使用 Yolk 库的bus函数在我们的模拟中创建了哲学家和叉子。这些事件总线产生的值随后通过when形式进行组合。前述代码中的when函数将检查来自哲学家及其左右手边的叉子的事件。实际上,哲学家和叉子的组合是硬编码在when形式的子句中的。当然,我们必须理解,前面显示的when形式的子句可以很容易地通过宏生成。然后使用push函数将这些值放置到代表哲学家和叉子的事件总线上,以启动模拟。最后五个能够就餐的哲学家将在网页上显示,如下所示:

使用函数式响应式编程

总结来说,Yolk 库提供了处理事件流的几个构造。该库中还有一些我们尚未讨论的函数,您应该自己探索它们。在下一节中,我们将提供一些示例,展示 Yolk 库的其他函数。

注意

一些先前的示例基于 Wilkes Joiner 的Yolk 示例代码(github.com/Cicayda/yolk-examples)。

构建响应式用户界面

响应式编程的主要应用之一是前端开发,我们必须创建对状态变化异步响应的用户界面组件。在本节中,我们将描述一些使用core.async库和 Yolk 库实现的示例。这是为了向您展示通道和事件流的比较,并展示我们如何使用这两个概念设计解决方案。请注意,这里将仅描述这些示例的整体设计和代码,您应该能够自己填充细节。

注意

以下示例所需的库依赖项如下:

[yolk "0.9.0"]
[org.clojure/core.async "0.1.346.0-17112a-alpha"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [goog.events :as events]
            [goog.events.EventType]
            [goog.style :as style]
            [cljs.core.async :as a]
            [yolk.bacon :as y])
  (:require-macros [cljs.core.async.macros
                    :refer [go go-loop alt!]]))

除了前面的依赖关系外,以下示例还使用了来自 src/m_clj/c9/common.cljsset-html!by-id 函数。请确保以下 ClojureScript 示例中的代码使用以下命令进行编译:

$ lein cljsbuild once

作为第一个示例,让我们创建三个异步任务,每个任务在不同的时间间隔产生值。我们必须获取这些任务产生的所有值,并以相同的顺序在网页上渲染它们。

注意

以下示例可以在书籍源代码的 src/m_clj/c9/reactive/core.cljs 中找到。此外,以下 ClojureScript 示例的 HTML 页面可以在 resources/html/reactive.html 中找到。以下脚本将包含在此页面上:

<script type="text/javascript" src="img/bacon.js">
</script>
<script type="text/javascript" src="img/reactive.js">
</script>

我们可以使用 core.async 库中的进程和通道来实现这一点。在这种情况下,通道将传递三个进程产生的值,我们将使用 merge 操作来组合这些通道,如下所示 示例 9.11

(defn render-div [q]
  (apply str
         (for [p (reverse q)]
           (str "<div class='proc-" p "'>Process " p "</div>"))))

(defn start-process [v t]
  (let [c (a/chan)]
    (go (while true
          (a/<! (a/timeout t))
          (a/>! c v)))
    c))

(let [out (by-id "ex-9-11-out")
      c1 (start-process 1 250)
      c2 (start-process 2 1000)
      c3 (start-process 3 1500)
      c (a/merge [c1 c2 c3])
      firstn (fn [v n]
               (if (<= (count v) n)
                 v
                 (subvec v (- (count v) n))))]
  (go-loop [q []]
    (set-html! out (render-div q))
    (recur (-> (conj q (a/<! c))
               (firstn 10)))))

示例 9.11:使用通道的三个异步任务

前面的 start-process 函数将创建一个进程,该进程会定期使用 go 形式产生值,并返回一个可以从中读取值的通道。render-div 函数将为三个任务产生的值生成 HTML。只会显示最近的十个值。此代码将产生以下输出:

构建响应式用户界面

我们也可以使用 FRP(函数式响应式编程)来实现前面的示例,其中每个任务产生的值都表示为事件流。yolk.bacon 命名空间中的 merge-all 函数可以用来组合这些事件流,而 sliding-window 函数可以获取结果流产生的十个最新值。示例 9.11 中的 render-div 函数可以重用来渲染值。这已在 示例 9.12 中实现,并产生与 示例 9.11 相同的输出:

(let [out (by-id "ex-9-12-out")
      events [(y/interval 250 1)
              (y/interval 1000 2)
              (y/interval 1500 3)]]
  (-> events
      y/merge-all
      (y/sliding-window 10)
      (y/on-value
       #(set-html! out (render-div %)))))

示例 9.12:使用 FRP 的三个异步任务

接下来,让我们尝试捕获特定 div 标签的鼠标事件,并显示这些事件位置的页面偏移值。我们可以使用通道来完成此操作,但首先需要一个将 DOM 事件传递到通道的函数。我们可以使用 goog.events/listencljs.core.async/put! 函数来实现这一点,如下所示 示例 9.13

(defn listen
  ([el type] (listen el type nil))
  ([el type f] (listen el type f (a/chan)))
  ([el type f out]
   (events/listen el type
                  (fn [e] (when f (f e)) (a/put! out e)))
   out))

示例 9.13:一个将事件传递到通道的函数

我们现在可以使用之前定义的 listen 函数来监听来自特定 div 标签的 goog.events.EventType.MOUSEMOVE 事件类型。值必须转换为页面偏移,这可以使用 goog.style 命名空间中的 getPageOffsetLeftgetPageOffsetTop 函数来完成。此实现已在 示例 9.14 中描述:

(defn offset [el]
  [(style/getPageOffsetLeft el) (style/getPageOffsetTop el)])

(let [el (by-id "ex-9-14")
      out (by-id "ex-9-14-out")
      events-chan (listen el goog.events.EventType.MOUSEMOVE)
      [left top] (offset el)
      location (fn [e]
                 {:x (+ (.-offsetX e) (int left))
                  :y (+ (.-offsetY e) (int top))})]
  (go-loop []
    (if-let [e (a/<! events-chan)]
      (let [loc (location e)]
        (set-html! out (str (:x loc) ", " (:y loc)))
        (recur)))))

示例 9.14:使用通道的鼠标事件

我们也可以使用 Yolk 库中的 from-event-streammap 函数来实现这个问题的解决方案。有趣的是,由 from-event-target 函数返回的流产生的事件将具有事件存储为 pageXpageY 属性的页面偏移量。这使得我们可以实现一个更简单的实现,如 示例 9.15 所示:

(let [el (by-id "ex-9-15")
      out (by-id "ex-9-15-out")
      events (y/from-event-target el "mousemove")]
  (-> events
      (y/map (juxt (fn [e] (.-pageX e))
                   (fn [e] (.-pageY e))))
      (y/map (fn [[x y]] (str x ", " y)))
      (y/on-value
       #(set-html! out %))))

示例 9.15:使用 FRP 的鼠标事件

示例 9.14示例 9.15 中展示的两个实现都按预期工作,并产生以下输出:

构建响应式用户界面

作为最后的例子,我们将模拟执行几个搜索查询并显示前三个返回结果的查询结果。查询可以描述为:两个针对网页结果的查询,两个针对图片结果的查询,以及两个针对视频结果的查询。我们可以将这些模拟查询实现如 示例 9.16 所示:

(defn chan-search [kind]
  (fn [query]
    (go
      (a/<! (a/timeout (rand-int 100)))
      [kind query])))

(def chan-web1 (chan-search :web1))
(def chan-web2 (chan-search :web2))
(def chan-image1 (chan-search :image1))
(def chan-image2 (chan-search :image2))
(def chan-video1 (chan-search :video1))
(def chan-video2 (chan-search :video2))

示例 9.16:使用通道模拟搜索查询

chan-search 函数返回一个函数,该函数使用 cljs.core.async/timeout 函数通过暂停当前任务一定数量的毫秒来模拟搜索查询。使用 chan-search 函数,我们可以为不同类型的感兴趣结果创建多个查询。使用这些函数,我们可以实现一个执行所有查询并返回前三个结果的函数,如 示例 9.17 所示:

(defn chan-search-all [query & searches]
  (let [cs (for [s searches]
             (s query))]
    (-> cs vec a/merge)))

(defn chan-search-fastest [query]
  (let [t (a/timeout 80)
        c1 (chan-search-all query chan-web1 chan-web2)
        c2 (chan-search-all query chan-image1 chan-image2)
        c3 (chan-search-all query chan-video1 chan-video2)
        c (a/merge [c1 c2 c3])]
    (go (loop [i 0
               ret []]
          (if (= i 3)
            ret
            (recur (inc i)
                   (conj ret (alt!
                               [c t] ([v] v)))))))))

示例 9.17:使用通道模拟搜索查询(继续)

如前例所示,可以使用 merge 函数将产生搜索查询结果的通道合并。请注意,针对所有三种类型的结果(即网页、图片和视频)的查询在 80 毫秒后超时。我们可以使用之前定义的 listen 函数将 chan-search-fastest 函数绑定到鼠标按钮的点击上,如 示例 9.18 所示:

(let [out (by-id "ex-9-18-out")
      button (by-id "search-1")
      c (listen button goog.events.EventType.CLICK)]
  (go (while true
        (let [e (a/<! c)
              result (a/<! (chan-search-fastest "channels"))
              s (str result)]
          (set-html! out s)))))

示例 9.18:使用通道模拟搜索查询(继续)

点击绑定到 chan-search-fastest 函数的按钮将显示以下输出。注意,以下输出中的 nil 值表示特定搜索结果类型的所有查询都超时了。

构建响应式用户界面

我们可以同样容易地实现之前描述的搜索查询模拟的 FRP 版本。针对各种数据源的查询定义如以下 示例 9.19 所示:

(defn frp-search [kind]
  (fn [query]
    (y/later (rand-int 100) [kind query])))

(def frp-web1 (frp-search :web1))
(def frp-web2 (frp-search :web2))
(def frp-image1 (frp-search :image1))
(def frp-image2 (frp-search :image2))
(def frp-video1 (frp-search :video1))
(def frp-video2 (frp-search :video2))

示例 9.19:使用 FRP 模拟搜索查询

前面的函数都返回搜索结果的事件流。产生的搜索结果可以使用 yolk.bacon 命名空间中的 latermergecombine-as-array 函数与超时结合,如 示例 9.20 所示:

(defn frp-search-all [query & searches]
  (let [results (map #(% query) searches)
        events (cons (y/later 80 "nil") results)]
    (-> (apply y/merge events)
        (y/take 1))))

(defn frp-search-fastest [query]
  (y/combine-as-array
   (frp-search-all query frp-web1 frp-web2)
   (frp-search-all query frp-image1 frp-image2)
   (frp-search-all query frp-video1 frp-video2)))

示例 9.20:使用 FRP 模拟搜索查询(继续)

可以在点击按钮时调用 frp-search-fastest 函数,如 示例 9.21 所示:

(let [out (by-id "ex-9-21-out")
      button (by-id "search-2")
      events (y/from-event-target button "click")]
  (-> events
      (y/flat-map-latest #(frp-search-fastest "events"))
      (y/on-value
       #(set-html! out %))))

示例 9.21:使用 FRP 模拟搜索查询(继续)

当点击搜索按钮时,前面的示例会产生以下输出:

构建响应式用户界面

总之,我们可以在网页中同时使用通道和事件流来实现交互式界面。尽管前面示例的 FRP 实现略短,但我们可以说 core.async 和 Yolk 库都有自己的优雅之处。

注意

前面的示例基于 David Nolen 的 Communicating Sequential Processes (swannodette.github.io/2013/07/12/communicating-sequential-processes/) 和 Draco Dormiens 的 CSP vs. FRP (potetm.github.io/2014/01/07/frp.html) 中的代码。

介绍 Om

Om 库 (github.com/omcljs/om) 是在 ClojureScript 中构建动态用户界面的优秀工具。实际上,它是对 React.js (facebook.github.io/react/) 的接口,这是一个用于创建交互式用户界面组件的 JavaScript 库。Om 允许我们将用户界面定义为组件的层次结构,并且每个组件根据组件状态的变化反应性地修改其外观。通过这种方式,Om 组件 对状态的变化做出反应

注意

以下示例需要以下库依赖项:

[org.omcljs/om "0.8.8"]

此外,以下命名空间必须包含在你的命名空间声明中:

(ns my-namespace
  (:require [om.core :as om :include-macros true]
            [om.dom :as dom :include-macros true]))

除了前面的依赖关系之外,以下示例还使用了来自 src/m_clj/c9/common.cljsby-id 函数。请确保以下 ClojureScript 示例代码已编译,使用以下命令:

$ lein cljsbuild once

Om 组件通常通过实现 om.core 命名空间中的 IRenderIRenderState 协议来定义。IRender 协议声明了一个名为 render 的单个函数,同样地,IRenderState 协议声明了 render-state 函数。renderrender-state 函数定义了实现这些协议之一的组件如何转换为 DOM,这可以通过网络浏览器进行渲染。这些函数的实现必须返回使用 om.dom 命名空间中的函数构建的 DOM 对象。om.core 命名空间中还有其他几个协议,允许我们定义组件的行为。内部,Om 使用 React.js 进行批处理更新 DOM 以提高性能,并使用 虚拟 DOM 来维护要渲染的 DOM 的状态。

注意

以下示例可以在书籍源代码的 src/m_clj/c9/om/core.cljs 中找到。此外,以下 ClojureScript 示例的 HTML 页面可以在 resources/html/om.html 中找到。以下脚本将包含在这个页面中:

<script type="text/javascript" src="img/om.js">
</script>

现在我们使用 Om 构建一个简单的组件。假设我们想要构建一个网络应用程序。这样做的一个第一步是为我们的应用程序创建一个登录页面。作为一个例子,让我们使用 Om 创建一个简单的登录表单。用户将在这个表单中输入他们的用户名和密码。唯一的要求是这个表单的提交按钮只有在用户输入了用户名和密码时才启用。让我们首先定义一些函数来创建表单的输入字段,如 示例 9.22 所示:

(defn update-input-value-fn [owner]
  (fn [e]
    (let [target (.-target e)
          val (.-value target)
          id (keyword (.-id target))]
      (om/set-state! owner id val))))

(defn input-field [text owner attrs]
  (let [handler (update-input-value-fn owner)
        event-attr {:onChange handler}
        js-attrs (-> attrs (merge event-attr) clj->js)]
    (dom/div
     nil
     (dom/div nil text)
     (dom/input js-attrs))))

示例 9.22:使用 Om 的登录表单

示例 9.22 中定义的 update-input-value-fn 函数接受一个组件 owner 作为参数,并返回一个我们可以绑定到 DOM 事件的函数。返回的函数使用 om.core 命名空间中的 set-state! 函数更新组件的状态,该状态使用 .-value 属性的值。input-field 函数返回一个具有一些相关属性的输入字段的 DOM 对象。input-field 函数还使用 update-input-value-fn 函数创建一个事件处理器,并将其绑定到输入字段的 onChange 事件。

注意

注意,组件可以通过使用 om.core 命名空间中的 set-state!update-state!update!transact! 函数来改变其状态或全局应用程序状态。

接下来,让我们使用 om.core/IRenderState 协议和 input-field 函数将表单定义为组件,如 示例 9.23 所示:

(defn form [data owner]
  (reify
    om/IInitState
    (init-state [_]
      {:username "" :password ""})
    om/IRenderState
    (render-state [_ state]
      (dom/form
       nil
       (input-field "Username" owner
                    {:type "text"
                     :id "username"
                     :value (:username state)})
       (input-field "Password" owner
                    {:type "password"
                     :id "password"
                     :value (:password state)})
       (dom/br nil)
       (dom/input
        #js {:type "submit"
             :value "Login"
             :disabled (or (-> state :username empty?)
                           (-> state :password empty?))})))))

(om/root form nil {:target (by-id "ex-9-23")})

示例 9.23:使用 Om 的登录表单(续)

之前的 form 函数通过实现 IRenderState 协议的 render-state 函数来创建一个组件。此组件还实现了 IInitState 协议以定义组件的初始状态。form 函数将渲染一个包含两个输入字段(用于用户名和密码)以及一个登录按钮的登录表单。按钮仅在用户名和密码输入后才会启用。此外,该组件使用 om.core/root 函数挂载到一个 div 元素上。以下网页中的输出描述了由 form 函数定义的组件的行为:

介绍 Om

上述输出描述了由 form 函数定义的登录表单组件的两个状态。观察到当用户名或密码字段为空时,登录按钮处于禁用状态,并且只有在用户在这两个输入字段中输入值时才会启用。通过这种方式,登录表单 响应 输入字段状态的变化。

注意

访问 github.com/omcljs/om/wiki/Documentation 获取 Om 库中所有协议、函数和宏的完整文档。

因此,Om 库为我们提供了创建交互式和有状态组件的几个构造。

概述

到目前为止,我们通过 Pulsar、RxClojure 和 Yolk 库讨论了响应式编程。我们还描述了几个 ClojureScript 示例,比较了 core.async 库中的通道与 Yolk 库中的响应式事件流。我们还演示了如何利用 Om 库构建动态用户界面。

在下一章中,我们将探讨如何测试我们的 Clojure 程序。

第十章:测试您的代码

测试是软件开发的一个组成部分。在实现软件功能的同时,我们必须同时定义测试来验证其多个方面。Clojure 标准库提供了几个构造来定义测试和模拟数据。还有几个社区库允许我们验证正在测试的代码的不同方面。

使用测试的主要优势在于,它们使我们能够识别程序代码中特定更改的整体影响。如果我们有测试来检查程序的功能,我们就可以有信心地对程序进行重构,而无需担心丢失任何功能。如果在重构程序时不可避免地遗漏了某些内容,那么在运行程序测试时,这些内容肯定会引起我们的注意。因此,测试是保持代码可维护性的不可或缺的工具。

在本章中,我们将研究在 Clojure 中编写测试的不同方法。我们还将讨论如何在 Clojure 中执行类型检查。尽管在本章中我们描述了几个用于编写测试的库,但我们必须注意,Clojure 生态系统中有更多可用的库。除此之外,本章中描述的库是测试我们代码最成熟和经验丰富的工具。

编写测试

作为一种精心设计的语言,Clojure 内置了单元测试库,即 clojure.test。除此之外,核心语言中还有一些有助于测试的构造。当然,这些构造并不能让我们在正式意义上定义和运行任何测试,对于这个目的,我们应优先使用 clojure.test 命名空间中的构造。

让我们先简要讨论一下核心语言中可用于单元测试的构造。assert 函数检查表达式在运行时是否评估为真值。如果传递给该函数的表达式没有评估为真值,则该函数将抛出异常,并且可以可选地指定异常的消息作为 assert 表达式的第二个参数。我们可以通过使用全局的 *assert* 编译时 var 来有效地禁用给定程序中的所有 assert 表达式。这个变量只能通过给定程序或命名空间中的顶层 set! 表达式来更改。

测试的另一个有趣方面,可以通过核心语言轻松解决,是模拟存根。简而言之,这些技术允许我们在测试用例的上下文中重新定义某些函数的行为。这在防止函数执行不想要的副作用或使用不可用的资源时非常有用。在 Clojure 语言中,这可以使用with-redefs函数来完成。这个形式可以在测试以及普通函数中使用,但鼓励不要在测试范围之外使用它。它的语义与标准let形式相似,鼓励您查阅 Clojure 文档以获取with-redefs形式的示例。

现在,让我们探索如何使用clojure.test命名空间中的构造来实际定义测试。

定义单元测试

Clojure 内置了对定义单元测试的支持。clojure.test命名空间不需要任何额外的依赖,提供了几个用于测试我们代码的构造。让我们探索其中的一些。

注意

在接下来的示例中,你必须将以下命名空间包含在你的命名空间声明中:

(ns my-namespace
  (:require [clojure.test :refer :all]))

以下示例可以在书籍源代码的test/m_clj/c10/test.clj中找到。

测试可以使用deftest宏来定义。这个形式必须传递一个符号,表示定义的测试的名称,以及任意数量的表达式。通常,在deftest宏中使用isare形式。is形式必须传递一个表达式,如果提供的表达式不返回一个真值,则测试将失败。are形式必须传递一个变量名向量、一个要测试的条件以及为定义的变量提供的值。例如,标准的*函数可以像示例 10.1中所示的那样进行测试:

(deftest test-*
  (is (= 6 (* 2 3)))
  (is (= 4 (* 1 4)))
  (is (= 6 (* 3 2))))

(deftest test-*-with-are
  (are [x y] (= 6 (* x y))
    2 3
    1 6
    3 2))

示例 10.1:使用 clojure.test 命名空间定义测试

前面的代码使用isare形式定义了两个测试。我们可以使用clojure.test命名空间中的run-testsrun-all-tests函数来运行测试。run-tests函数可以传递任意数量的命名空间,并将运行其中定义的所有测试。此外,这个形式可以不传递任何参数来调用,在这种情况下,它将运行当前命名空间中的所有测试。run-all-tests函数将运行当前项目中所有命名空间中的所有测试。它可以可选地传递一个正则表达式,如果提供了这个参数,它将只运行匹配命名空间中的测试。实际上,具有集成测试运行支持的 IDE 会调用这些函数。例如,我们可以使用这里显示的run-tests函数来运行我们在示例 10.1中定义的测试:

user> (run-tests)

Testing ...

Ran 2 tests containing 6 assertions.
0 failures, 0 errors.
{:test 2, :pass 6, :fail 0, :error 0, :type :summary}

如前所述的输出所示,run-tests函数执行了这两个测试,并且它们都通过了。现在,让我们定义一个会失败的测试,尽管我们实际上不应该这样做,除非我们有充分的理由:

(deftest test-*-fails
  (is (= 5 (* 2 3))))

示例 10.2:一个失败的测试

示例 10.2 中显示的 test-*-fails 测试在运行时会失败,如下所示:

user> (run-tests)

Testing ...

FAIL in (test-*-fails) (test.clj:24)
expected: (= 5 (* 2 3))
  actual: (not (= 5 6))

Ran 3 tests containing 7 assertions.
1 failures, 0 errors.
{:test 3, :pass 6, :fail 1, :error 0, :type :summary}

事实上,定义失败的测试应该被视为程序开发的一个组成部分。为了启动一个功能或修复程序中的错误,我们首先必须定义一个验证此更改的测试(通过失败!)然后继续实现功能或修复,以确保所有新定义的测试都通过。这两个步骤然后重复进行,直到满足我们功能或修复的所有要求。这就是测试驱动开发TDD)的精髓。

注意

我们也可以使用以下命令来运行给定命名空间中定义的测试:

$ lein test my-namespace

对于严格使用 Clojure 编写的程序,必须使用 clojure.test 命名空间进行测试。为了以相同的方式测试 ClojureScript 程序,我们可以使用 doo 库(github.com/bensu/doo),它提供了 deftestisare 构造的 ClojureScript 实现。

使用自顶向下测试

在 Clojure 中定义测试的一个更强大的方法是使用 Midje 库(github.com/marick/Midje)。这个库提供了几个构造,允许我们通过描述几个函数之间的关系来轻松定义单元测试,而不是描述函数的实现本身。这种方法也称为 自顶向下测试,Midje 推崇这种测试方法。让我们深入了解 Midje 库的细节。

注意

以下库依赖对于即将到来的示例是必需的:

[midje "1.8.2"]

我们还必须在 project.clj 文件的 :plugins 部分包含以下依赖项:

[lein-midje "3.1.3"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [midje.sweet :refer :all]
            [midje.repl :as mr]))

以下示例可以在书的源代码的 test/m_clj/c10/midje.clj 中找到。

首先,让我们定义一个我们打算测试的简单函数,如 示例 10.3 所示:

(defn first-element [sequence default]
  (if (empty? sequence)
    default
    (first sequence)))

示例 10.3:一个简单的测试函数

我们可以使用 midje.sweet 命名空间中的 factsfact 构造来定义 first-element 函数的测试,如 示例 10.4 所示。

(facts "about first-element"
  (fact "it returns the first element of a collection"
        (first-element [1 2 3] :default) => 1
        (first-element '(1 2 3) :default) => 1)

  (fact "it returns the default value for empty collections"
        (first-element [] :default) => :default
        (first-element '() :default) => :default
        (first-element nil :default) => :default
        (first-element
         (filter even? [1 3 5])
         :default) => :default))

示例 10.4:第一个元素函数的测试

如前述代码所示,fact 形式描述了一个测试,可以传递任意数量的子句。每个子句由一个表达式、一个 => 符号和所提供表达式的预期返回值组成。facts 形式简单用于将多个 fact 形式组合在一起。很明显,我们不是检查逻辑条件,而是使用 fact 形式来检查表达式及其返回的值。

可以使用 provided 形式来模拟函数调用。Midje 库允许我们在测试中使用 元常量,它们通常与 provided 形式一起使用。元常量可以被视为值和函数的通用占位符。所有元常量都应该以两个或更多点(.)或连字符(-)开始和结束;连字符更适合表示函数的元常量。例如,我们可以使用元常量和 provided 形式测试我们之前定义的 first-element 函数,如 示例 10.5 所示:

(fact "first-element returns the first element of a collection"
      (first-element ..seq.. :default) => :default
      (provided
       (empty? ..seq..) => true))

示例 10.5:使用提供的形式和元常量

在前面的测试中,元常量 ..seq.. 用于指示传递给 first-element 函数的第一个参数,而 provided 形式模拟了对 empty? 函数的调用。这样,我们可以在不完整实现被测试函数的情况下实现测试。当然,我们应该避免在 provided 形式中模拟或重新定义标准函数。例如,假设我们有三个部分实现的功能,如 示例 10.6 所示。

(defn is-diesel? [car])

(defn cost-of-car [car])

(defn overall-cost-of-car [car]
  (if (is-diesel? car)
    (* (cost-of-car car) 1.4)
    (cost-of-car car)))

示例 10.6:部分实现的功能以进行测试

注意,在前面代码中,只有 overall-cost-of-car 函数是完全实现的。尽管如此,我们仍然可以使用 Midje 库测试这三个函数之间的关系,如 示例 10.7 所示。

(fact
  (overall-cost-of-car ..car..) => (* 5000 1.4)
  (provided
    (cost-of-car ..car..) => 5000
    (is-diesel? ..car..) => true))

示例 10.7:测试 is-diesel?、cost-of-car 和 overall-cost-of-car 函数

在前面的测试中,使用 provided 形式和 ..car.. 元常量模拟了 cost-of-caris-diesel? 函数,并检查了 overall-cost-of-car 函数返回的值。我们可以使用 midje.repl 命名空间中的 autotest 函数运行我们迄今为止定义的所有测试,如下所示:

user> (mr/autotest :files "test")

====================================================================
Loading ( ... )
>>> Output from clojure.test tests:

0 failures, 0 errors.
>>> Midje summary:
All checks (8) succeeded.
[Completed at ... ]

注意

我们还可以使用以下命令运行给定命名空间中定义的测试。请注意,以下命令将监视您的项目文件更改,并在文件更改后运行任何文件的测试:

$ lein midje :autotest test

以这种方式,我们可以使用 Midje 库来编写测试,即使是尚未完全实现的功能也可以。Midje 允许我们使用元常量来描述测试,这些测试是函数之间的关系。总之,clojure.test 和 Midje 库是定义单元测试的出色工具。

使用 specs 进行测试

现在,我们将查看 Speclj 库,发音为 speckle (github.com/slagyr/speclj),它用于编写 specs。Specs 与单元测试类似,但专注于被测试函数的行为,而不是其内部实现。实际上,行为驱动开发BDD)的核心是编写 specs。

TDD 和 BDD 之间的主要区别在于 BDD 专注于函数的行为或规范,而不是它们的实现。从这个角度来看,如果我们更改已经测试过的函数的内部实现,我们修改与该函数关联的测试或规范的可能性较小。BDD 也可以被视为对 TDD 的改进方法,其中函数的接口和行为比其内部实现更重要。现在,让我们研究 Speclj 库的各种构造。

注意

下面的库依赖项对于即将到来的示例是必需的。我们还必须在 project.clj 文件的 :plugins 部分包含以下依赖项:

[speclj "3.3.1"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [speclj.core :refer :all]))

来自 speclj.core 命名空间的 describeitshould 形式用于定义给定函数的规范。it 形式代表对正在测试的函数的单个规范,而 describe 形式用于将多个规范组合在一起。it 形式内的断言可以使用 should 形式及其变体来表示。例如,我们可以为标准 * 函数的行为编写规范,如下面的 示例 10.8 所示。

注意

以下示例可以在书籍源代码的 spec/m_clj/c10/speclj.clj 中找到。

(describe "*"
  (it "2 times 3 is 6"
    (should (= 6 (* 2 3)))))

示例 10.8: * 函数的规范

之前显示的规范使用 should= 形式检查单个条件。should 形式有几个变体,如 should=, should-not, should-failshould-throw。这些形式基本上是自我解释的,我们鼓励您查阅 Speclj 文档以获取更多详细信息。我们可以为标准的 / 函数描述一些规范,如下面的 示例 10.9 所示。

(describe "/"
  (it "5 divided by 5 is 1"
    (should= 1 (/ 5 5)))
  (it "5 divided by 5 is not 0"
    (should-not= 0 (/ 5 5)))
  (it "fail if 5 divided by 5 is not 1"
    (if (not= 1 (/ 5 5))
      (should-fail "divide not working")))
  (it "throw an error if 5 is divided by 0"
    (should-throw ArithmeticException
      (/ 5 0))))

示例 10.9: 使用多个 it 形式对 / 函数进行规范

describe 形式中,我们可以使用 beforeafter 形式在检查每个 it 形式之前或之后执行任意代码。同样,before-allafter-all 形式可以指定在 describe 形式中检查所有规范之前和之后要执行的操作。

某个函数执行输入和输出可以使用规范来描述。这是通过使用 with-out-strwith-in-str 形式来完成的。with-out-str 形式返回由给定表达式发送到标准输出的任何数据。相反,with-in-str 形式必须传递一个字符串和一个表达式,并且提供的字符串将在提供的表达式被调用后发送到标准输入。例如,假设我们有一个简单的读取字符串并打印它的函数。我们可以使用 with-out-strwith-in-str 形式编写这样的函数的规范,如下面的 示例 10.10 所示:

(defn echo []
  (let [s (read-line)]
    (println (str "Echo: " s))))

(describe "echo"
  (it "reads a line and prints it"
    (should= "Echo: Hello!\r\n"
      (with-out-str
        (with-in-str "Hello!"
          (echo))))))

示例 10.10: 对读取字符串并打印它的函数进行规范

我们还可以使用前面描述的标准 with-redefs 宏在 it 表达式中模拟函数调用。例如,我们可以通过模拟 示例 10.11 中的 read-lineprintln 函数来编写 示例 10.10 中描述的 echo 函数的 spec。显然,模拟标准函数是不推荐的,这里仅为了展示在 spec 中使用 with-redefs 宏的用法。

(describe "echo"
  (it "reads a line and prints it"
    (with-redefs [read-line (fn [] "Hello!")
                  println (fn [x] x)]
      (should= "Echo: Hello!" (echo)))))

示例 10.11:在 spec 中使用 with-redefs 宏

要运行给定项目中定义的所有 spec,我们可以调用 run-specs 宏,如下所示:

user> (run-specs)
...

Finished in 0.00547 seconds
7 examples, 0 failures
#<speclj.run.standard.StandardRunner 0x10999>

注意

我们还可以使用以下命令运行给定命名空间中定义的 spec。请注意,以下命令将监视您的项目文件更改,并在文件更改后运行 spec:

$ lein spec -a

总结来说,Speclj 库为我们提供了几个结构来定义 BDD 的 spec。对于给定函数的 spec 应该只在需要更改函数所需的功能或行为时进行修改。有了 spec,修改函数的底层实现需要更改其相关 spec 的可能性就小得多。当然,是否应该在项目中使用 spec 或测试是一个主观问题。有些项目使用简单的测试就很好,而有些则更喜欢使用 spec。

生成测试

另一种测试形式是生成测试,在这种测试中,我们定义函数的属性,这些属性必须对所有输入都成立。这与列举函数的预期输入和输出有很大不同,而这正是单元测试和 spec 所做的。在 Clojure 中,可以使用 test.check 库进行生成测试 (github.com/clojure/test.check)。这个库受到了 Haskell 的 QuickCheck 库的启发,并为测试函数的属性提供了类似的构造。

注意

以下库依赖对于即将到来的示例是必需的:

[org.clojure/test.check "0.9.0"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [clojure.test.check :as tc]
            [clojure.test.check.generators :as gen]
            [clojure.test.check.properties :as prop]
            [clojure.test.check.clojure-test 
             :refer [defspec]]))

以下示例可以在本书源代码的 src/m_clj/c10/check.clj 中找到。

要定义一个要检查的属性,我们可以使用 clojure.test.check.properties 命名空间中的 for-all 宏。此宏必须传递一个生成器绑定向量,这些绑定可以使用 clojure.test.check.generators 命名空间中的构造创建,并附带一个要验证的属性。例如,考虑 示例 10.12 中定义的属性:

(def commutative-mult-prop
  (prop/for-all [a gen/int
                 b gen/int]
    (= (* a b)
       (* b a))))

(def first-is-min-after-sort-prop
  (prop/for-all [v (gen/not-empty (gen/vector gen/int))]
    (= (apply min v)
       (first (sort v)))))

示例 10.12:使用 test.check 库定义的简单属性

在前面的代码中,我们定义了两个属性,即 commutative-mult-propfirst-is-min-after-sort-propcommutative-mult-prop 属性断言使用 * 函数的乘法操作是交换律的,而 first-is-min-after-sort-prop 函数检查使用 sort 函数排序的整数向量的第一个元素是否是向量中的最小值。注意使用了来自 clojure.test.check.generators 命名空间的 intvectornon-empty 函数。我们可以使用 clojure.test.check 命名空间中的 quick-check 函数来验证这些属性是否成立,如下所示:

user> (tc/quick-check 100 commutative-mult-prop)
{:result true, :num-tests 100, :seed 1449998010193}
user> (tc/quick-check 100 first-is-min-after-sort-prop)
{:result true, :num-tests 100, :seed 1449998014634}

如前所述,quick-check 函数必须传递要运行的检查次数和一个要验证的性质。此函数返回一个描述对提供的属性执行的检查的映射,其中 :result 键的值表示测试的结果。很明显,对于指定的输入类型,两个属性 commutative-mult-propfirst-is-min-after-sort-prop 都是成立的。现在,让我们定义一个不成立的属性,如 示例 10.13 所示:

(def commutative-minus-prop
  (prop/for-all [a gen/int
                 b gen/int]
    (= (- a b)
       (- b a))))

示例 10.13:使用 test.check 库定义的一个不会成立的属性

运行前面的检查将显然失败,如下面的输出所示:

user> (tc/quick-check 100 commutative-minus-prop)
{:result false, :seed 1449998165908,
 :failing-size 1, :num-tests 2, :fail [0 -1],
 :shrunk {:total-nodes-visited 1, :depth 0, :result false,
               :smallest [0 -1]}}

我们还可以使用 clojure.test.check.clojure-test 命名空间中的 defspec 宏根据生成性测试来定义规范。这个形式必须传递要执行的检查次数和一个性质,这与 quick-check 函数类似。使用 defspec 形式定义的规范将由标准的 clojure.test 运行器进行检查。例如,我们可以将 commutative-mult-prop 属性定义为规范,如 示例 10.14 所示:

(defspec commutative-mult 100
  (prop/for-all [a gen/int
                 b gen/int]
    (= (* a b)
       (* b a))))

示例 10.14:使用 defspec 宏定义的规范

在前面的代码中定义的规范可以通过从 clojure.test 命名空间调用 run-testsrun-all-tests 函数,或者通过运行 lein test Leiningen 命令来验证。总之,通过 test.check 库进行生成性测试是我们测试代码的另一种方式。它侧重于指定函数的性质,而不是描述函数对于某些输入的预期输出。

使用类型进行测试

类型检查 在静态类型语言中通常是理所当然的。有了类型检查,类型错误可以在编译时而不是在运行时被发现。在一些动态语言,如 Clojure 中,类型签名可以在需要时和任何地方声明,这种技术被称为 可选类型。类型检查可以使用 core.typed 库(github.com/clojure/core.typed)来完成。使用 core.typed,可以使用 类型注解 检查变量的类型签名。可以为任何变量声明类型注解,包括使用 def 形式、binding 形式或任何其他创建变量的构造创建的值。在本节中,我们将探讨这个库的细节。

注意

下面的库依赖对于即将到来的示例是必需的。

[org.clojure/core.typed "0.3.0"]

此外,以下命名空间必须包含在你的命名空间声明中。

(ns my-namespace
  (:require [clojure.core.typed :as t]))

以下示例可以在书籍源代码的 src/m_clj/c10/typed.clj 中找到。

使用 clojure.core.typed 命名空间中的 ann 宏声明变量的类型注解。这个形式必须传递一个要注解的表达式和一个类型向量。例如,一个接受两个数字作为参数并返回数字的函数的类型注解如 示例 10.15 所示。

(t/ann add [Number Number -> Number])
(defn add [a b]
  (+ a b))

示例 10.15:一个接受两个数字并返回数字的函数的类型注解

要检查给定命名空间中的所有类型注解,我们必须通过传递要检查的命名空间来调用 clojure.core.typed/check-ns 函数,如下所示:

user> (t/check-ns 'my-namespace)
Start collecting my-namespace
Finished collecting my-namespace
Collected 2 namespaces in 200.965982 msecs
Start checking my-namespace
Checked my-namespace in 447.580402 msecs
Checked 2 namespaces  in 650.979682 msecs
:ok

如前所述,check-ns 函数会打印出正在检查的命名空间的一些信息,如果指定命名空间中的所有类型检查都通过,则返回关键字 :ok。现在,让我们改变之前定义的 add 函数,如下所示 示例 10.16

(defn add [a b]
  (str (+ a b)))

示例 10.16:重新定义 add 函数

虽然前面的定义是有效的,但它不会被类型检查器通过,如下所示:

user> (t/check-ns 'my-namespace)
Start collecting my-namespace
Finished collecting my-namespace
Collected 2 namespaces in 215.705251 msecs
Start checking my-namespace
Checked my-namespace in 493.669488 msecs
Checked 2 namespaces  in 711.644548 msecs
Type Error (m_clj/c1/typed.clj:23:3) Type mismatch:

Expected:   Number

Actual:   String
in: (str (clojure.lang.Numbers/add a b))
ExceptionInfo Type Checker: Found 1 error  clojure.core/ex-info (core.clj:4403)

check-ns 函数抛出一个错误,指出在期望 Number 类型的地方找到了 String 类型。这样,check-ns 函数就可以在用 ann 宏注解的函数中找到类型错误。可以使用 clojure.core.typed 命名空间中的 IFn 构造来注解具有多个 arity 的函数,如 示例 10.17 所示:

(t/ann add-abc
       (t/IFn [Number Number -> Number]
              [Number Number Number -> Number]))
(defn add-abc
  ([a b]
   (+ a b))
  ([a b c]
   (+ a b c)))

示例 10.17:使用多个 arity 注解函数

我们还可以使用传递给 ann 宏的类型向量中的 * 符号来注解具有可变参数的函数,如 示例 10.18 所示。

(t/ann add-xs [Number * -> Number])
(defn add-xs [& xs]
  (apply + xs))

示例 10.18:使用可变参数注解函数

在 REPL 中,我们可以使用clojure.core.typed命名空间中的cf宏来确定表达式或值的推断类型。此宏也可以将预期类型作为第二个参数传递。请注意,cf形式仅用于实验,不应用于类型注解。cf形式返回一个推断类型,以及一个称为过滤器集的结构,它表示为映射。例如,可以使用cf形式推断niltruefalse值的类型,如下所示:

user> (t/cf nil)
[nil {:then ff, :else tt}]
user> (t/cf true)
[true {:then tt, :else ff}]
user> (t/cf false)
[false {:then ff, :else tt}]

在前面的输出中,由cf宏返回的每个向量中的第二个值代表从提供的表达式派生的过滤器集。过滤器集可以描述为两个过滤器的集合:

  • 如果表达式是一个真值,则:then过滤器为真

  • 如果表达式不是真值,则:else过滤器为真

在过滤器集的上下文中,存在两个平凡过滤器,即ttff,可以描述如下:

  • tt,它翻译为显然为真,意味着值是真值。

  • ff,它翻译为永远为假,意味着值不是真值。此过滤器也称为不可能过滤器

从这个角度来看,过滤器集{:then tt, :else ff}翻译为“表达式可能是一个真值,但它不可能是一个非真值”。因此,nilfalse等假值永远不会被cf形式推断为真,这与 Clojure 中这些值的语义一致。真值将始终有tt作为:then过滤器,如下面的输出所示:

user> (t/cf "Hello")
[(t/Val "Hello") {:then tt, :else ff}]
user> (t/cf 1)
[(t/Val 1) {:then tt, :else ff}]
user> (t/cf :key)
[(t/Val :key) {:then tt, :else ff}]

cf宏也可以用来检查函数的类型签名,如下所示:

user> (t/cf str)
[t/Any * -> String]
user> (t/cf +)
(t/IFn [Long * -> Long]
       [(t/U Double Long) * -> Double]
       [t/AnyInteger * -> t/AnyInteger]
       [Number * -> Number])

可以使用ann-form宏对形式或表达式进行预期类型的注解,如下所示:

user> (t/cf (t/ann-form #(inc %) [Number -> Number]))
[[Number -> Number] {:then tt, :else ff}]
user> (t/cf (t/ann-form #(str %) [t/Any -> String]))
[[t/Any -> String] {:then tt, :else ff}]

列表和向量等聚合类型在clojure.core.typed命名空间中也有为它们定义的类型。我们可以使用cf宏推断这些数据结构的类型,如下所示:

user> (t/cf (list 0 1 2))
(PersistentList (t/U (t/Val 1) (t/Val 0) (t/Val 2)))
user> (t/cf [0 1 2])
[(t/HVec [(t/Val 0) (t/Val 1) (t/Val 2)]) {:then tt, :else ff}]

前面的输出中的类型PersistentListHVec分别是列表和向量的具体类型。我们还可以将预期类型作为额外的参数传递给cf形式,如下所示:

user> (t/cf (list 0 1 2) (t/List t/Num))
(t/List t/Num)
user> (t/cf [0 1 2] (t/Vec t/Num))
(t/Vec t/Num)
user> (t/cf {:a 1 :b 2} (t/Map t/Keyword t/Int))
(t/Map t/Keyword t/Int)
user> (t/cf #{0 1 2} (t/Set t/Int))
(t/Set t/Int)

core.typed 库也支持参数化类型联合类型交集类型。联合类型使用 U 构造来声明,交集类型使用 I 构造来声明。交集类型旨在与协议一起使用,这意味着交集类型 (I A B) 必须实现协议 AB。另一方面,联合类型可以使用具体类型来定义。例如,clojure.core.typed 命名空间定义了一个参数化的 Option 类型,它只是 nil 和参数化类型的联合。换句话说,类型 (Option x) 被定义为联合类型 (U x nil)。另一个联合类型的良好例子是 AnyInteger 类型,它表示一个整数,并在 clojure.core.typed 命名空间中定义,如 示例 10.19 所示。

(defalias AnyInteger
  (U Integer Long clojure.lang.BigInt BigInteger Short Byte))

示例 10.19:AnyInteger 联合类型

core.typed 库也支持多态类型,这允许我们指定泛化类型。例如,identityiterate 函数具有多态类型签名,如下所示:

user> (t/cf identity)
(t/All [x] [x -> x :filters ... ])
user> (t/cf iterate)
(t/All [x] [[x -> x] x -> (t/ASeq x)])

我们可以使用来自 clojure.core.typed 命名空间的 All 构造来为函数添加多态类型签名,如 示例 10.20 所示。

(t/ann make-map (t/All [x] [x -> (t/Map t/Keyword x)]))
(defn make-map [a]
  {:x a})

示例 10.20:定义多态类型签名

总之,core.typed 库提供了几个构造来定义和验证变量的类型签名。还有几个构造用于确定给定表达式的类型签名。使用 core.typed,你可以在代码在运行时执行之前找到逻辑类型错误。类型注解也可以被视为一种文档形式,它简洁地描述了函数和变量的类型。因此,通过 core.typed 库在 Clojure 中使用类型有多个好处。

摘要

到目前为止,我们已经讨论了几个可以帮助我们测试和验证代码的库。我们讨论了用于定义测试的 clojure.test 和 Midje 库。我们还探讨了如何使用 Speclj 库在 BDD 精神中定义规范。生成测试是另一种测试方法,我们展示了如何使用 test.check 库来实现。最后,我们讨论了如何使用 core.typed 库在 Clojure 中执行类型检查。因此,在 Clojure 中测试我们的代码有广泛的选项。

在下一章和最后一章中,我们将讨论如何调试我们的代码,以及一些在 Clojure 中开发应用程序的良好实践。

第十一章。故障排除和最佳实践

到现在为止,你必须已经了解了 Clojure 语言的所有特性和大多数构造。在你开始使用 Clojure 构建自己的应用程序和库之前,我们将简要讨论一些调试代码的技术以及你应该在你的项目中采用的一些实践。

调试你的代码

在您使用 Clojure 构建应用程序和库的过程中,您肯定会遇到需要调试代码的情况。对此类情况的通常反应是使用带有调试器的 集成开发环境IDE)。虽然 Clojure IDE,如 CIDER (github.com/clojure-emacs/cider) 和 Counterclockwise (doc.ccw-ide.org) 支持调试,但我们还可以使用一些更简单的构造和工具来排除代码中的故障。让我们看看其中的一些。

调试代码的最简单方法之一是打印函数内部使用的某些变量的值。我们可以使用标准的 println 函数来完成这个任务,但它并不总是为复杂的数据类型产生最可读的输出。作为一种惯例,我们应该使用 clojure.pprint/pprint 函数将变量打印到控制台。这个函数是 Clojure 语言的标准化格式化打印器。

注意

宏在调试时可能会让人感到困惑。正如我们在 第四章 中提到的 使用宏进行元编程,宏应该谨慎使用,我们可以使用宏展开构造,如 macroexpandmacroexpand-all 来调试宏。

除了这些内置构造之外,还有一些有用的库我们可以添加到我们的调试工具包中。

注意

以下示例可以在书籍源代码的 test/m_clj/c11/ debugging.clj 中找到。

使用追踪

追踪 可以用来确定何时以及如何调用一个形式。tools.trace 贡献库 (github.com/clojure/tools.trace) 为我们的代码追踪提供了一些实用的构造。

注意

以下库依赖项对于即将到来的示例是必需的:

[org.clojure/tools.trace "0.7.9"]

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [clojure.tools.trace :as tr]))

来自 clojure.tools.trace 命名空间的 trace 函数是追踪表达式的最基本方法。它将简单地打印传递给它的表达式的返回值。trace 构造也可以传递一个字符串,作为附加参数,以便对追踪进行标记。例如,假设我们需要追踪定义在 示例 11.1 中的函数:

(defn make-vector [x]
  [x])

示例 11.1:一个简单的追踪函数

我们可以使用这里显示的 trace 函数来追踪表达式 (make-vector 0)

user> (tr/trace (make-vector 0))
TRACE: [0]
[0]
user> (tr/trace "my-tag" (make-vector 0))
TRACE my-tag: [0]
[0]

我们可以通过将命名空间传递给定义在 clojure.tools.trace 命名空间中的 trace-ns 宏来追踪一个命名空间中定义的所有函数。同样,可以使用 trace-vars 宏来追踪命名空间中的特定函数或变量。使用这些形式添加的追踪可以通过 untrace-nsuntrace-vars 宏来移除。如果我们想确定几个表达式中的哪一个失败了,我们可以将表达式传递给这里显示的 trace-forms 宏:

user> (tr/trace-forms (+ 10 20) (* 2 3) (/ 10 0))
ArithmeticException Divide by zero
  Form failed: (/ 10 0)
clojure.lang.Numbers.divide (Numbers.java:158)

如前述输出所示,trace-forms 宏将打印出导致错误的表达式。通过将定义中的 defn 符号替换为 clojure.tools.trace/deftrace,可以更详细地追踪一个函数。deftrace 简单地定义了一个函数,其参数和返回值将被追踪。例如,考虑以下 示例 11.2 中定义的函数:

(tr/deftrace add-into-vector [& xs]
  (let [sum (apply + xs)]
    [sum]))

示例 11.2:使用 deftrace 宏追踪函数

在调用之前定义的 add-into-vector 函数时,将打印以下追踪信息:

user> (add-into-vector 10 20)
TRACE t9083: (add-into-vector 10 20)
TRACE t9083: => [30]
[30]

这样,追踪可以用来在程序执行过程中找到表达式的返回值。tools.trace 命名空间中的追踪结构允许我们确定函数何时被调用,以及它的返回值和参数是什么。

使用 Spyscope

正如您可能已经想到的,您可以使用宏轻松实现自己的调试结构。Spyscope 库(github.com/dgrnbrg/spyscope)采用这种方法,并实现了一些用于调试代码的读取宏。对于具有 Lisp 风格括号语法的语言,使用读取宏进行调试是一种更受欢迎的方法。这是因为在这些语言中,与 tracedeftrace 这样的形式相比,可以更容易地将打印调试信息的读取宏添加到现有程序中。让我们探索 Spyscope 库的结构,以更清楚地了解使用读取宏进行调试代码的优势。

Spyscope 库提供了 #spy/p#spy/d#spy/t 读取宏,这些都可以通过在需要调试的表达式之前立即写入它们来使用。在 Leiningen 项目中,使用 project.clj 文件的 :injections 部分使这些形式可用是一种常见做法。

注意

以下库依赖关系对于即将到来的示例是必需的:

[spyscope "0.1.5"]

我们还必须在 project.clj 文件的 :injections 部分包含以下形式作为向量:

(require 'spyscope.core)

此外,以下命名空间必须在您的命名空间声明中包含:

(ns my-namespace
  (:require [spyscope.repl :as sr]))

#spy/p 读取宏可以用来打印表达式内部使用的值。关于这个结构的有趣之处在于它是使用 clojure.pprint/pprint 函数实现的。例如,我们可以打印出这里显示的 take 形式产生的中间值:

user> (take 5 (repeatedly
 #(let [r (rand-int 100)]
 #spy/p r)))
95
36
61
99
73
(95 36 61 99 73)

要生成更详细的信息,例如调用堆栈和返回值的表达式,我们可以使用 #spy/d 读取宏。例如,我们可以使用这个结构生成以下信息:

user> (take 5 (repeatedly
 #(let [r (rand-int 100)]
 #spy/d (/ r 10.0))))
user$eval9408$fn__9409.invoke(form-init1..0.clj:2) (/ r 10.0) => 4.6
user$eval9408$fn__9409.invoke(form-init1..0.clj:2) (/ r 10.0) => 4.4
user$eval9408$fn__9409.invoke(form-init1..0.clj:2) (/ r 10.0) => 5.0
user$eval9408$fn__9409.invoke(form-init1..0.clj:2) (/ r 10.0) => 7.8
user$eval9408$fn__9409.invoke(form-init1..0.clj:2) (/ r 10.0) => 3.1
(4.6 4.4 5.0 7.8 3.1)

#spy/d 读取宏还支持几个选项,可以作为元数据传递给它。这个元数据映射的 :fs 键指定要显示的堆栈帧数。此外,可以使用 :marker 键为表达式声明一个字符串标签。我们可以使用这些选项来显示来自表达式调用堆栈的信息,如下所示:

user> (take 5 (repeat #spy/d ^{:fs 3 :marker "add"}
 (+ 0 1 2)))
----------------------------------------
clojure.lang.Compiler.eval(Compiler.java:6745)
clojure.lang.Compiler.eval(Compiler.java:6782)
user$eval9476.invoke(form-init1..0.clj:1) add (+ 0 1 2) => 3
(3 3 3 3 3)

上述输出显示了+表单调用的前三个堆栈帧。我们还可以使用正则表达式通过:nses键从调用堆栈信息中过滤出堆栈帧,如下所示:

user> (take 5 (repeat #spy/d ^{:fs 3 :nses #"core|user"}
 (+ 0 1 2)))
----------------------------------------
clojure.core$apply.invoke(core.clj:630)
clojure.core$eval.invoke(core.clj:3081)
user$eval9509.invoke(form-init1..0.clj:1) (+ 0 1 2) => 3
(3 3 3 3 3)

要跳过打印正在调试的表单,我们可以在指定的元数据映射中指定:form键为false值给#spy/d读取宏,并且此键默认为true。我们还可以使用:time键打印出表单被调用的时间。此键的值可以是true,在这种情况下,使用默认的时间格式,或者是一个如"hh:mm:ss"的字符串,它表示必须显示的时间戳格式。

#spy/t读取宏用于跟踪一个表单,这个构造支持与#spy/d读取宏相同的选项。跟踪不会立即打印,可以使用spyscope.repl命名空间中的trace-query函数显示。例如,考虑示例 11.3中在未来的值中添加数字的函数:

(defn add-in-future [& xs]
  (future
    #spy/t (apply + xs)))

示例 11.3:跟踪在未来的加法函数

一旦调用add-in-future函数,我们可以使用trace-query函数显示调用跟踪,如下所示:

user> (sr/trace-next)
nil
user> (def f1 (add-in-future 10 20))
#'user/f1
user> (def f2 (add-in-future 20 30))
#'user/f2
user> (sr/trace-query)
user$add_in_future$fn_..7.invoke(debugging.clj:66) (apply + xs) => 30
----------------------------------------
user$add_in_future$fn_..7.invoke(debugging.clj:66) (apply + xs) => 50
nil

在上述输出中,使用trace-next函数启动一个新的生成跟踪。Spyscope 库中的跟踪被分组到生成中,可以使用spyscope.repl/trace-next函数启动一个新的生成。可以使用spyscope.repl命名空间中的trace-clear函数清除所有生成中的跟踪信息。我们还可以向trace-query函数传递一个参数来过滤结果。此参数可以是表示要显示的最近生成数量的数字,或者是一个正则表达式,用于通过命名空间过滤跟踪。

总结来说,在 Clojure 中,不使用调试器也可以有几种方法来调试你的代码。tools.trace和 Spyscope 库提供了几个有用的简单构造,用于调试和跟踪 Clojure 代码的执行。

在你的应用程序中记录错误

分析应用程序中发生错误的另一种方法是使用日志。可以使用tools.logging贡献库进行日志记录。这个库允许我们通过一个无差别的接口使用多个日志实现,可以选择的实现包括slf4jlog4jlogback。让我们快速浏览一下如何使用tools.logging库和logback(可以说是最最新和可配置的实现)将日志添加到任何 Clojure 程序中。

注意

以下库依赖关系对于即将到来的示例是必需的:

[org.clojure/tools.logging "0.3.1"]
[ch.qos.logback/logback-classic "1.1.3"]

此外,以下命名空间必须在你的命名空间声明中包含:

(ns my-namespace
  (:require [clojure.tools.logging :as log]))

以下示例可以在书籍源代码的test/m_clj/c11/ logging.clj中找到。

clojure.tools.logging 命名空间中实现的全部日志宏分为两类。第一类宏需要像传递给 println 形式的参数一样传递参数。所有这些参数都将连接并写入日志。另一类宏必须传递一个格式字符串和要插入到指定格式的值。这类宏通常以 f 字符结尾,例如 debugfinfoftools.logging 库中的日志宏可以传递一个异常,然后是其他常规参数。

tools.logging 库中的宏以不同的日志级别写入日志消息。例如,debugdebugf 形式在 DEBUG 级别写入日志消息,同样,errorerrorf 宏在 ERROR 级别进行日志记录。此外,spyspyf 宏将评估并返回表达式的值,如果当前日志级别等于或低于指定的日志级别(默认为 DEBUG),则可能记录结果。

例如,以下 示例 11.4 中所示的 divide 函数,在执行整数除法的同时,使用 infospyferror 宏记录一些信息:

(defn divide [a b]
  (log/info "Dividing" a "by" b)
  (try
    (log/spyf "Result: %s" (/ a b))
    (catch Exception e
      (log/error e "There was an error!"))))

示例 11.4:使用 tools.logging 库记录信息的函数

当调用 divide 函数时,将写入以下日志消息:

user> (divide 10 1)
INFO  - Dividing 10 by 1
DEBUG - Result: 10
10
user> (divide 10 0)
INFO  - Dividing 10 by 0
ERROR - There was an error!
java.lang.ArithmeticException: Divide by zero
at clojure.lang.Numbers.divide(Numbers.java:158) ~[clojure-1.7.0.jar:na]
...
at java.lang.Thread.run(Thread.java:744) [na:1.7.0_45]
nil

如前所述,当调用 divide 函数时,它会以不同的日志级别写入多个日志消息。logback 的日志配置必须保存在一个名为 logback.xml 的文件中,该文件可以位于 Leiningen 项目的 src/resources/ 目录中。我们可以在该文件中指定 logback 的默认日志级别和其他几个选项。

注意

如果您对前例的日志配置感兴趣,请查看书中源代码的 src/logback.xml 文件。有关详细的配置选项,请访问 logback.qos.ch/manual/configuration.html

拥有一个全局异常处理器,记录程序中所有线程的异常,也非常方便。这特别有助于检查在执行 core.async 库中的 gothread 宏时遇到的错误。可以使用 java.lang.Thread 类的 setDefaultUncaughtExceptionHandler 方法定义这样的全局异常处理器,如 示例 11.5 所示:

(Thread/setDefaultUncaughtExceptionHandler
 (reify Thread$UncaughtExceptionHandler
   (uncaughtException [_ thread ex]
     (log/error ex "Uncaught exception on" (.getName thread)))))

示例 11.5:一个全局异常处理器,记录所有错误

注意

您还可以使用 Timbre (github.com/ptaoussanis/timbre) 进行日志记录,它可以在不使用 XML 的情况下进行配置,并且也支持 ClojureScript。

总之,通过 tools.logging 库,我们有几种日志记录选项可用。此库还支持几种日志实现,每种实现都有自己的配置选项集。

在 Clojure 中思考

让我们简要讨论一些在 Clojure 中构建现实世界应用程序的良好实践。当然,这些实践只是指南,你最终应该尝试建立自己的一套规则和实践来编写 Clojure 代码:

  • 最小化状态并使用纯函数:大多数应用程序不可避免地必须使用某种形式的状态。你必须始终努力减少你处理的状态量,并在纯函数中实现大部分繁重的工作。在 Clojure 中,可以使用引用类型、通道甚至单子来管理状态,这为我们提供了很多经过验证的选项。这样,我们可以减少可能导致程序中任何意外行为的条件数量。纯函数也更容易组合和测试。

  • 不要忘记懒惰:懒惰可以用作解决基于递归的解决方案的替代方案。虽然懒惰确实倾向于简化函数式编程的几个方面,但在某些情况下,如保留懒惰序列的头部,它也会导致额外的内存使用。查看clojure.org/reference/lazy#_don_t_hang_onto_your_head了解更多关于懒惰如何增加程序内存使用的信息。Clojure 中的大多数标准函数都返回懒惰序列作为结果,因此在使用它们时,你必须始终考虑懒惰。

  • 将你的程序建模为数据的转换:作为人类,按步骤思考是不可避免的,你必须始终尝试将你的代码建模为转换数据的步骤。尽量避免按步骤思考会改变状态,而应该是在数据的转换上。这导致了一种更可组合的设计,使得组合几个转换变得非常容易。

  • 使用线程宏->和->>来避免嵌套表达式:你一定在这本书中看到了很多使用这些宏的例子,可能已经开始享受它们在你自己的代码中的存在了。->->>宏极大地提高了可读性,应该在可能的情况下使用。即使它避免了几个嵌套级别,也不要犹豫使用这些宏。还有一些其他的线程宏,如cond->as->,通常非常有用。

  • 并行性触手可及:在 Clojure 中,有几种编写能够通过并行使用受益的程序的方法。你可以选择使用 futures、reducers、core.async进程以及几种其他结构来模拟并发和并行操作。此外,大多数状态管理结构,如 atoms、agents 和 channels,都是考虑到并发而设计的。所以,在处理并发任务和状态时,不要犹豫使用它们。

  • 生活在 REPL 中:这是实验代码和原型化程序不可或缺的工具。在编写函数或宏之后,你应该首先在 REPL 中对其进行实验。你可以使用load-file函数快速重新加载源文件中的更改,而无需重新启动 REPL。请记住,使用load-file形式重新加载源文件将擦除通过 REPL 在源文件命名空间中通过 REPL 所做的任何修改或重新定义。

  • 在应用程序中嵌入 Clojure REPL:可以将 REPL 嵌入到应用程序中,这样我们就可以连接到它,并根据我们的需求在运行时修改其行为。有关如何做到这一点的更多信息,请参阅clojure.core.server.repl命名空间中的结构或tools.nrepl库(github.com/clojure/tools.nrepl)。但是,这可能会带来潜在的安全风险,因此应谨慎使用。

  • 使用一致的标准化编码风格:在任何项目或编程语言中,保持良好的编码风格都很重要。本书中的所有示例都按照 Clojure 风格指南(github.com/bbatsov/clojure-style-guide)定义的标准方式格式化。

摘要

到目前为止,我们讨论了几种调试代码的方法。tools.trace和 Spyscope 库在交互式调试中很有用,而tools.logging库可以用于在运行中的应用程序中记录信息。我们还讨论了在 Clojure 中开发应用程序和库的一些良好实践。

到现在为止,你可能已经迫不及待地想要用 Clojure 编写自己的应用程序了。如果你一直很关注,你一定已经注意到 Clojure 确实是一种简单的语言。然而,正是通过其简单性,我们能够创造出优雅且可扩展的解决方案来解决许多有趣的问题。在你继续使用 Clojure 的旅程中,始终努力使事物变得更简单,如果它们还不够简单的话。我们将给你留下一些引人深思的名言,以便你继续探索这个优雅、强大且简单的编程语言的无限可能。

*"通过组合简单的组件,我们编写出健壮的软件。"
--理查德·赫基
*"简洁是可靠性的前提。"
--埃德加·W·迪杰斯特拉
*"简洁是最高级的复杂。"
--列奥纳多·达·芬奇

附录 A. 参考文献

第一章:参考书目

本学习路径是为您准备的,以便探索 Clojure 的功能并学习如何在现有项目中使用它们。它包括以下 Packt 产品:

面向 Java 开发者的 Clojure,作者:爱德华多·迪亚兹

Clojure 高性能编程,第二版,沙坦努·库马尔

精通 Clojure,作者:阿基尔·瓦利

posted @ 2025-09-10 14:11  绝不原创的飞龙  阅读(17)  评论(0)    收藏  举报