Clojure-研讨会-全-
Clojure 研讨会(全)
原文:
zh.annas-archive.org/md5/1fc70eab603e2a6b669895382fc24117译者:飞龙
前言
关于
本节简要介绍了本书的涵盖范围,你开始学习所需的技术技能,以及完成所有包含的活动和练习所需的软件要求。
关于本书
你已经知道你想要学习 Clojure,而一种聪明的学习 Clojure 的方法是通过实践来学习。Clojure Workshop 专注于建立你的实际技能,这样你就可以用一种非常适合并发和与 Java 虚拟机互操作性的语言编写干净、表达力强的代码。你将从真实示例中学习,这些示例将带来真实的结果。
在整个 Clojure Workshop 过程中,你将采取引人入胜的逐步方法来理解 Clojure。你不必忍受任何不必要的理论。如果你时间紧迫,你可以每天跳入一个单独的练习,或者花一个周末的时间学习 Clojure 网络开发与 Ring。由你选择。按照你的方式学习,你将建立起并加强关键技能,这种方式会让人感到很有成就感。
每一本 Clojure Workshop 的实体印刷版都能解锁访问互动版。通过视频详细说明所有练习和活动,你将始终有一个指导性的解决方案。你还可以通过评估来衡量自己,跟踪进度,并接收内容更新。完成学习后,你甚至可以在线分享和验证一个安全的凭证。这是一项包含在印刷版中的高级学习体验。要兑换它,请遵循位于 Clojure 书籍开头的说明。
快速直接,Clojure Workshop 是 Clojure 初学者的理想选择。你将像软件开发者一样构建和迭代代码,在学习过程中不断进步。这个过程意味着你会发现你的新技能会持续存在,作为最佳实践的一部分——为未来的几年打下坚实的基础。
受众
Clojure Workshop 是 Clojure 初学者的理想教程。对 JavaScript 和 Java 有基本的了解会很理想,但并非必需。Clojure Workshop 将很好地引导你讨论这些技术的互操作性。
关于章节
第一章,Hello REPL!,让你立即开始编写代码。你将学习语言的基础,以及如何充分利用 Clojure 的交互式 REPL。
第二章,数据类型和不可变性,提供了更多的构建块,但这些是 Clojure 的构建块,它们让你接触到 Clojure 的一个关键特性:不可变性。
第三章,深入函数,深入探讨了 Clojure 区别于其他语言的一个领域:函数式编程范式。这些工具将帮助你完成本书的其余部分。
第四章,映射和过滤,是你探索 Clojure 集合的第一站。这里的所有模式和技巧都是关于学习如何解决问题的。map 和 filter 函数是 Clojure 最出色的工具之一。
第五章,多对一:减少,将真正开始让你以新的方式思考。本章中的数据塑形技术补充了上一章的内容。
第六章,递归和循环,将你的集合技术提升到下一个层次。本章将让你思考。到本章结束时,你将准备好使用高级函数式模式处理棘手的问题。
第七章,递归 II:惰性序列,通过查看 Clojure 的一个独特特性,完成了 Clojure 集合的全景。如果你能够编写处理复杂树结构的函数,你就准备好使用 Clojure 来解决大问题了。
第八章,命名空间、库和 Leiningen,提供了构建真实世界 Clojure 和 ClojureScript 应用程序所需工具的详细分析。你已经拥有了编写良好 Clojure 代码的技能;现在你需要了解如何组装你的应用程序。
第九章,Java 和 JavaScript 与宿主平台互操作性,让你了解 Clojure 的强大优势之一,但同时也可能令人望而生畏。作为一个托管语言,Clojure 让你能够访问底层平台。了解何时以及如何使用这种力量是 Clojure 的关键技能。
第十章,测试,是严肃、真实世界编程的重要一步。理解 Clojure 和 ClojureScript 测试故事是每位专业程序员都需要掌握的技能。
第十一章,宏,将帮助你理解 Lisp 语言家族的一个独特特性。宏允许丰富的抽象,但在表面之下,有许多重要的实用细节。
第十二章,并发,揭示了 Clojure 的另一个独特优势。本章将让你体验在 Java 虚拟机或事件驱动的 ClojureScript 单页应用程序上构建多线程应用程序。
第十三章,数据库交互和应用层,展示了如何利用 Clojure 的数据库库。许多实际应用都需要数据库,因此这些技能是必不可少的。
第十四章,使用 Ring 的 HTTP,展示了如何设置和运行一个由 Clojure 驱动的网络服务器。Ring 库是 Clojure 世界中应用最广泛的 HTTP 技术。
第十五章,前端:ClojureScript UI,帮助你整理你已经学到的关于 ClojureScript 的许多知识,这是 Clojure 网络堆栈的最后一层。
习惯用法
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“请注意,此函数位于clojure.string命名空间中,默认情况下并未引用。”
你在屏幕上看到的单词,例如在菜单或对话框中,也会以如下方式出现在文本中:“当你点击获取图片按钮时,图片会显示作者的名字。”
代码块设置如下:
(defn remove-large-integers [ints]
(remove #(and (integer? %) (> % 1000)) ints))
在输入和执行一些代码给出即时输出的情况下,这会以如下方式显示:
user=> (sort [3 7 5 1 9])
(1 3 5 7 9)
在上面的例子中,输入的代码是(sort [3 7 5 1 9]),输出是(1 3 5 7 9)。
新术语和重要词汇会以如下方式显示:“欢迎使用 Clojure 读取求值打印循环(REPL),这是一个我们可以用来与运行的 Clojure 程序交互的命令行界面。”
代码片段的关键部分会以如下方式突出显示:
{:deps {compojure {:mvn/version "1.6.1"}
metosin/muuntaja {:mvn/version "0.6.4"}
ring/ring-core {:mvn/version "1.7.1"}
ring/ring-jetty-adapter {:mvn/version "1.7.1"}}
user=> (require '[muuntaja.middleware :as middleware])
=>nil
长代码片段将被截断,并在截断代码的顶部放置 GitHub 上相应代码文件的名称。整个代码的永久链接放置在代码片段下方。它应该看起来如下:
kvitova_matches.clj
1 (def matches
2 [{:winner-name "Kvitova P.",
3 :loser-name "Ostapenko J.",
4 :tournament "US Open",
5 :location "New York",
6 :date "2016-08-29"}
7 {:winner-name "Kvitova P.",
8 :loser-name "Buyukakcay C.",
9 :tournament "US Open",
10 :location "New York",
11 :date "2016-08-31"}
The full code can be found at: https://packt.live/2GcudYj
在开始之前
每一段伟大的旅程都是从一小步开始的。我们即将在 Clojure 领域的冒险也不例外。在我们能够用数据做些酷的事情之前,我们需要准备好一个高效的环境。在本节中,我们将看到如何做到这一点。
安装 Java
在安装 Clojure 之前,你需要确保你的计算机上已安装了Java 开发者工具包(JDK)。对于 Mac 和 Linux 用户,预构建的二进制文件只需几步即可。在 Mac 上,对于 Homebrew 用户,你只需输入:
$ brew install openjdk
在基于 Debian 的 Linux 发行版上,你可以通过输入以下内容来检查哪个版本可用:
$ apt-get search openjdk
根据输出结果,你可以输入类似以下的内容:
$ sudo apt-get install openjdk-11-jdk
Clojure 不需要特别新的 JDK 版本。
对于 Windows 用户,你可以从这里下载 OpenJDK 安装程序:packt.live/3aBu1Qg。一旦你有了安装程序,点击它以运行,然后按照说明操作。
安装 Clojure
一旦在你的系统上安装了有效的 JDK,使用 Leiningen 工具设置 Clojure 就变得简单了。
-
从 Leiningen 主页复制适当的版本(Windows 或 Mac/Linux),这里:
leiningen.org/。 -
将 Leiningen 放置在系统
$PATH的一部分目录中,并使其可执行。 -
在 Mac 或 Linux 上,这意味着将其放在如
~/bin这样的目录中,并调用chmod:$ chmod +x ~/bin/lein -
在 Windows 上,要更改
$PATH变量,请转到控制面板>用户账户>用户账户,然后点击更改我的环境变量。在显示你个人用户账户的用户变量的面板中,点击Path,然后选择编辑。![图 0.1:用户账户]()
图 0.1:用户账户
-
点击
新建以添加一行,然后输入你新bin目录的路径:![图 0.2:在 bin 目录中添加路径]()
图 0.2:在 bin 目录中添加路径
-
现在 Leiningen 已安装并可用,从命令行,只需简单地输入:
$ leinLeiningen 将获取 Clojure 以及它需要管理的所有库。现在,只需简单地输入
lein repl,您就会拥有您的第一个 Clojure REPL:

图 0.3:REPL 启动
编辑器和 IDE
虽然您当然可以在控制台中运行 REPL 做很多事情,但将 Clojure REPL 集成到您最喜欢的编辑器中会方便得多。几乎所有编辑器和环境都有插件,从 Vim 到 Emacs,从 IntelliJ 到 Electron 或 Visual Studio Code。
我们在这里无法涵盖所有可能的环境,但我们建议使用您已经熟悉的编码工具,并添加一个 Clojure 插件。最好的代码编辑器就是您喜欢使用的编辑器。只要它有 Clojure 插件,您应该很快就能开始使用。
安装代码包
从 GitHub 下载代码文件packt.live/2vbksal。请参考这些代码文件以获取完整的代码包。
如果您在安装过程中有任何问题或疑问,请通过电子邮件联系我们的workshops@packt.com。
书中使用的优质彩色图像可以在packt.live/2O5EzNX找到
第一章:1. Hello REPL!
概述
本章中,我们解释了创建 Clojure 程序的基础。我们首先让你熟悉 读取求值打印循环(REPL),在编写代码时,大部分实验都是在 REPL 中进行的。REPL 还允许你自己探索代码和文档,因此它是一个极佳的起点。在快速了解 REPL 之后,我们将更详细地描述如何阅读和理解简单的 Lisp 和 Clojure 代码,其语法有时可能显得令人不安。然后,我们探索 Clojure 中的基本运算符和函数,这些运算符和函数使你能够编写和运行简单的 Clojure 程序或脚本。
在本章结束时,你将能够使用 REPL 并在 Clojure 中处理函数。
简介
你是否曾经陷入面向对象应用程序的“意大利面代码”中?许多经验丰富的程序员会说是的,在他们的人生旅程或职业生涯的某个时刻,他们可能会重新考虑他们程序的基础。他们可能会寻找一个更简单、更好的面向对象编程的替代方案,而 Clojure 就是一个吸引人的选择。它是 Lisp 家族中的一种功能性强、简洁优雅的语言。它的核心很小,语法也很简单。它因其简单性而闪耀,这种简单性需要经过训练的眼睛才能注意到,最终才能理解。使用 Clojure 更为复杂的构建块将允许你设计和构建更坚固的应用程序。
不论你是经验丰富的程序员还是新手,爱好者还是专业人士,C# 大师还是 Haskell 高手,学习一门新的编程语言都是一项挑战。然而,这却是一种高度有益的经历,它将使你成为一个更好的程序员。在这本书中,你将通过实践来学习,并且能够快速提升你的技能。
Clojure 是今天学习编程语言的一个极佳选择。它将允许你使用一种旨在持久的技术来高效工作。Clojure 可以用来编写几乎任何东西:从完整的客户端-服务器应用程序到简单的脚本或大数据处理任务。到这本书结束时,你将使用 Clojure 和 ClojureScript 编写了一个现代网络应用程序,并将拥有所有启动你自己的应用程序所需的牌面!
REPL 基础
欢迎来到 Clojure 的 读取求值打印循环(REPL),这是一个命令行界面,我们可以用它与运行的 Clojure 程序进行交互。REPL 在这个意义上是 读取用户的输入(用户就是你,程序员),通过即时编译和执行代码来评估输入,并将结果打印(即显示)给用户。读取-评估-打印的三步过程会不断重复(循环),直到你退出程序。
REPL 提供的动态性允许你发现和实验一个紧密的反馈循环:你的代码被即时评估,你可以调整它直到正确为止。许多其他编程语言提供了交互式外壳(特别是其他动态语言,如 Ruby 或 Python),但在 Clojure 中,REPL 在开发者的生活中扮演着非凡和至关重要的角色。它通常与代码编辑器集成,编辑、浏览和执行代码之间的界限模糊,类似于 Smalltalk 的可塑开发环境。但让我们从基础知识开始。
在这些练习中,你可能会注意到一些关于 Java 的提及(例如,在第二个练习的堆栈跟踪中)。这是因为 Clojure 是用 Java 实现的,并在 Java 虚拟机(JVM)上运行。因此,Clojure 可以从成熟的生态系统(经过实战检验、广泛部署的执行平台和大量的库)中受益,同时仍然是一个前沿技术。Clojure 被设计为托管语言,另一个名为 ClojureScript 的实现允许你在任何 JavaScript 运行时(例如,网页浏览器或 Node.js)上执行 Clojure 代码。这种托管语言实现的选择允许较小的函数式程序员社区在由 Java、.NET Core 和 JavaScript 技术主导的行业中努力。欢迎来到 Clojure 舞会,在这里我们都在享受我们的蛋糕并吃掉它。
练习 1.01:你的第一次舞蹈
在这个练习中,我们将在 REPL 中执行一些基本操作。让我们开始吧:
-
打开终端并输入
clj。这将启动一个 Clojure REPL:$ clj输出如下:
Clojure 1.10.1 user=>第一行是 Clojure 的版本,在这个例子中是
1.10.1。如果你的版本不同,请不要担心——我们将一起进行的练习应该与任何版本的 Clojure 兼容。第二行显示了当前所在的命名空间(
user)并提示输入。命名空间是一组属于一起的事物(例如函数)。在这里创建的任何内容都将默认位于user命名空间中。user命名空间可以被视为你的游乐场。你的 REPL 已经准备好读取。
-
让我们尝试评估一个表达式:
user=> "Hello REPL!"输出如下:
"Hello REPL!"在 Clojure 中,字面量字符串使用双引号创建,
""。字面量是在源代码中表示固定值的一种表示法。 -
让我们看看如果我们输入多个字符串会发生什么:
user=> "Hello" "Again"输出如下:
"Hello" "Again"我们已经连续评估了两个表达式,并且每个结果都打印到了单独的行上。
-
现在,让我们尝试一些算术运算,例如
1 + 2:user=> 1 + 2输出如下:
1 #object[clojure.core$_PLUS_ 0xe8df99a "clojure.core$_PLUS_@e8df99a"] 2输出并不完全符合我们的预期。Clojure 对三个组件进行了评估,即
1、+和2,分别 进行评估。评估+看起来很奇怪,因为+符号绑定了一个函数。注意
函数是执行特定任务的代码单元。我们现在不需要了解更多,除了函数可以被调用(或调用)并且可以接受一些参数。函数的参数是一个术语,用于设计参数的值,但这些术语通常可以互换使用。
要添加这些数字,我们需要调用
+函数,并传入参数1和2。 -
按如下方式调用
+函数,并传入参数1和2:user=> (+ 1 2)输出如下:
3你很快就会发现,许多通常是编程语言语法一部分的基本操作,比如加法、乘法、比较等,在 Clojure 中只是简单的函数。
-
让我们尝试更多基本算术的示例。你甚至可以尝试向以下函数传递超过两个参数,所以将 1 + 2 + 3 相加将看起来像
(+ 1 2 3):user=> (+ 1 2 3) 6 -
其他基本算术运算符的使用方式类似。尝试输入以下表达式:
user=> (- 3 2) 1 user=> (* 3 4 1) 12 user=> (/ 9 3) 3在输入前面的示例之后,你应该尝试更多自己输入的内容——REPL 就是为了实验而存在的。
-
你现在应该足够熟悉 REPL,可以提出以下问题:
user=> (println "Would you like to dance?") Would you like to dance? nil不要个人化——
nil是println函数返回的值。该函数打印的文本仅仅是这个函数的副作用。nil是 Clojure 中“null”或“nothing”的等价物;也就是说,没有有意义的价值。print(不带换行符)和println(带换行符)用于将对象打印到标准输出,并在完成后返回nil。 -
现在,我们可以组合这些操作并打印简单加法的结果:
user=> (println (+ 1 2)) 3 nil该表达式打印了一个值为
3,并返回了nil值。注意我们是如何嵌套那些形式(或表达式)。这就是我们在 Clojure 中链式调用函数的方式:
user=> (* 2 (+ 1 2)) 6 -
通过按Ctrl + D退出 REPL。退出函数是
System/exit,它接受退出码作为参数。因此,你也可以输入以下内容:user=> (System/exit 0)
在这个练习中,我们发现了 REPL 并调用了 Clojure 函数来打印和执行基本的算术操作。
练习 1.02:在 REPL 中导航
在这个练习中,我们将介绍一些导航快捷键和命令,帮助你使用并生存于 REPL 中。让我们开始吧:
-
首先再次打开 REPL。
-
注意你可以通过按Ctrl + P(或UP箭头)和Ctrl + N(或DOWN箭头)来导航之前输入的内容和之前的会话历史。
-
你也可以搜索(区分大小写)你输入的命令历史:按 Ctrl + R 然后输入
Hello,这应该会带回到我们之前输入的Hello Again表达式。如果你再次按 Ctrl + R,它将遍历搜索的匹配项并带回到第一个命令:Hello REPL!。如果你按 Enter,它将表达式带回到当前提示符。再次按 Enter 将评估它。 -
现在,评估以下表达式,该表达式将数字 10 增加(加 1):
user=> (inc 10) 11返回的值是 11,这确实是 10 + 1。
-
*1是一个特殊变量,它绑定到在 REPL 中评估的最后表达式的结果。你可以通过简单地像这样输入它来评估它的值:user=> *1 11类似地,
*2和*3分别绑定到该 REPL 会话中第二和第三最近的价值。 -
你也可以在其他表达式中重用这些特殊的变量值。看看你是否能跟随并输入以下命令序列:
user=> (inc 10) 11 user=> *1 11 user=> (inc *1) 12 user=> (inc *1) 13 user=> (inc *2) 13 user=> (inc *1) 14注意
*1和*2的值是如何随着新表达式的评估而变化的。当 REPL 中文本很多时,按 Ctrl + L 清屏。 -
在 REPL 中还可用的一个有用变量是
*e,它包含最后一个异常的结果。目前,它应该是nil,除非你之前生成了错误。让我们通过除以零来自愿触发一个异常:user=> (/ 1 0) Execution error (ArithmeticException) at user/eval71 (REPL:1). Divide by zero评估
*e应该包含有关异常的详细信息,包括堆栈跟踪:user=> *e #error { :cause "Divide by zero" :via [{:type java.lang.ArithmeticException :message "Divide by zero" :at [clojure.lang.Numbers divide "Numbers.java" 188]}] :trace [[clojure.lang.Numbers divide "Numbers.java" 188] [clojure.lang.Numbers divide "Numbers.java" 3901] [user$eval1 invokeStatic "NO_SOURCE_FILE" 1] [user$eval1 invoke "NO_SOURCE_FILE" 1] [clojure.lang.Compiler eval "Compiler.java" 7177] [clojure.lang.Compiler eval "Compiler.java" 7132] [clojure.core$eval invokeStatic "core.clj" 3214] [clojure.core$eval invoke "core.clj" 3210] [clojure.main$repl$read_eval_print__9086$fn__9089 invoke "main.clj" 437] [clojure.main$repl$read_eval_print__9086 invoke "main.clj" 437] [clojure.main$repl$fn__9095 invoke "main.clj" 458] [clojure.main$repl invokeStatic "main.clj" 458] [clojure.main$repl_opt invokeStatic "main.clj" 522] [clojure.main$main invokeStatic "main.clj" 667] [clojure.main$main doInvoke "main.clj" 616] [clojure.lang.RestFn invoke "RestFn.java" 397] [clojure.lang.AFn applyToHelper "AFn.java" 152] [clojure.lang.RestFn applyTo "RestFn.java" 132] [clojure.lang.Var applyTo "Var.java" 705] [clojure.main main "main.java" 40]]}注意
不同的 Clojure 实现可能会有略微不同的行为。例如,如果你在一个 ClojureScript REPL 中尝试除以 0,它不会抛出异常,而是返回“无穷大”值:
cljs.user=> (/ 1 0)##Inf这是为了保持与主机平台的一致性:字面数字 0 在 Java(和 Clojure)中实现为整数,但在 JavaScript(和 ClojureScript)中实现为浮点数。IEEE 浮点算术标准(IEEE 754)指定除以 0 应该返回 +/- 无穷大。
-
doc、find-doc和apropos函数是浏览文档的必要 REPL 工具。既然你知道你想使用的函数的名称,你可以使用doc来阅读它的文档。让我们看看它在实际中的应用。首先,输入(doc str)来了解更多关于str函数的信息:user=> (doc str) ------------------------- clojure.core/str ([] [x] [x & ys]) With no args, returns the empty string. With one arg x, returns x.toString(). (str nil) returns the empty string. With more than one arg, returns the concatenation of the str values of the args. nildoc在第一行打印函数的完全限定名称(包括命名空间),在下一行打印可能的参数集(或“arity”),最后是描述。这个函数的完全限定名称是
clojure.core/str,这意味着它在clojure.core命名空间中。在clojure.core中定义的东西默认情况下就可用在你的当前命名空间中,无需你显式地导入它们。这是因为它们是构建程序的基本组件,每次都使用它们的完整名称将会很繁琐。 -
让我们尝试使用
str函数。正如文档所解释的,我们可以传递多个参数:user=> (str "I" "will" "be" "concatenated") (clojure.core/str "This" " works " "too") "Iwillbeconcatenated" "This works too" -
让我们检查
doc函数的文档:user=> (doc doc) ------------------------- clojure.repl/doc ([name]) Macro Prints documentation for a var or special form given its name, or for a spec if given a keyword nil此函数位于
clojure.repl命名空间中,它也在你的 REPL 环境中默认可用。 -
你还可以查看命名空间的文档。正如其文档所建议的,你的最终程序通常不会使用
clojure.repl命名空间中的辅助工具(例如,doc、find-doc和apropos):user=> (doc clojure.repl) ------------------------- clojure.repl Utilities meant to be used interactively at the REPL nil -
当你不知道函数的名称,但你对描述或名称可能包含的内容有一个想法时,你可以使用
find-doc辅助工具来搜索它。让我们尝试搜索modulus运算符:user=> (find-doc "modulus") nil -
没有成功,但有一个转折:
find-doc是区分大小写的,但好消息是我们可以使用带有i修饰符的正则表达式来忽略大小写:user=> (find-doc #"(?i)modulus") ------------------------- clojure.core/mod ([num div]) Modulus of num and div. Truncates toward negative infinity. nil目前你不需要了解更多关于正则表达式的知识——你甚至不需要使用它们,但忽略大小写来搜索函数可能很有用。你可以用
#"(?i)text"语法来写它们,其中text是你想要搜索的任何内容。我们要找的函数是
clojure.core/mod。 -
让我们确保它按照其文档工作:
user=> (mod 7 3) 1 -
使用
apropos函数通过名称搜索函数,从而产生更简洁的输出。比如说,我们正在寻找一个转换给定字符字符串的案例的函数:user=> (apropos "case") (clojure.core/case clojure.string/lower-case clojure.string/upper-case) user=> (clojure.string/upper-case "Shout, shout, let it all out") "SHOUT, SHOUT, LET IT ALL OUT"请注意,此函数位于
clojure.string命名空间中,默认情况下并未引用。在我们学习如何从其他命名空间导入和引用符号之前,您需要使用其全名。
活动一.01:执行基本操作
在这个活动中,我们将在 Clojure REPL 中打印消息并执行一些基本的算术运算。
这些步骤将帮助您完成此活动:
-
打开 REPL。
-
打印消息 "
我不怕括号" 来激励自己。 -
将 1、2 和 3 相加,然后将结果乘以 10 减 3,这对应于以下
中缀表示法:(1 + 2 + 3) * (10 - 3)。你应该得到以下结果:42 -
打印消息 "
做得好!" 来祝贺自己。 -
退出 REPL。
备注
此活动的解决方案可以在第 678 页找到。
Clojure 代码的评估
Clojure 是 Lisp 的一种方言,一种由 John McCarthy 设计的高级编程语言,首次出现在 1958 年。Lisp 及其衍生品或“方言”的最显著特征之一是使用数据结构来编写程序的源代码。我们 Clojure 程序中不寻常的括号数量就是这种特征的体现,因为括号用于创建列表。
在这里,我们将关注 Clojure 程序的构建块,即 形式和表达式,并简要地看看表达式是如何被评估的。
备注
“表达式”和“形式”这两个术语经常可以互换使用;然而,根据 Clojure 文档,表达式是一种形式类型:“每个没有被特殊形式或宏特别处理的表单都被编译器视为表达式,它被评估以产生一个值。”
我们已经看到字面量是有效的语法,并且评估为自身,例如:
user=> "Hello"
"Hello"
user=> 1 2 3
1
2
3
我们还学习了如何通过使用括号来调用函数:
user=> (+ 1 2 3)
6
值得注意的是,在此点,可以在一行的开头使用 ";" 来编写注释。任何以 ";" 开头的行将不会被评估:
user=> ; This is a comment
user=> ; This line is not evaluated
函数的调用遵循以下结构:
; (operator operand-1 operand-2 operand-3 …)
; for example:
user=> (* 2 3 4)
24
注意以下来自前一个示例的内容:
-
列表,由开括号和闭括号
()表示,被评估为函数调用(或调用)。 -
当进行评估时,
*符号解析为执行乘法的函数。 -
2、3和4被评估为自身,并将作为参数传递给函数。
考虑你在 活动 1.01 中编写的表达式,执行基本操作:(* (+ 1 2 3) (- 10 3))。将其视为树形结构也有助于可视化该表达式:
![图 1.1:表达式 (* (+ 1 2 3) (- 10 3)) 的树形表示]
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_01_01.jpg)
图 1.1:表达式 (* (+ 1 2 3) (- 10 3)) 的树形表示
评估这个表达式包括减少树,从分支(最内层的列表)开始:(* (+ 1 2 3) (- 10 3)) 变为 (* 6 7),然后变为 42。
术语 s-expression(或符号表达式)常用来指代这类表达式。你可能会再次遇到它,所以了解 s-expression 是一个使用列表编写数据结构和代码的数据表示法,正如我们之前所展示的那样。
到目前为止,我们只使用字面量标量类型作为运算符的操作数,这些类型只包含一个值,例如数字、字符串、布尔值等。我们只使用列表来调用函数,而不是表示数据。让我们尝试创建一个表示数据但不表示“代码”的列表:
user=> (1 2 3)
Execution error (ClassCastException) at user/eval255 (REPL:1).
java.lang.Long cannot be cast to clojure.lang.IFn
抛出了一个异常,因为列表的第一个元素(运算符)不是一个函数。
有一种特殊的语法可以防止列表被视为函数的调用:引用。创建字面量列表是通过在其前面添加引用 ' 来完成的,所以让我们再试一次:
user=> '(1 2 3)
(1 2 3)
user=> '("a" "b" "c" "d")
("a" "b" "c" "d")
太好了!通过防止表单的评估,我们现在可以编写列表的字面量表示。
这个概念将帮助我们为接下来要介绍的内容做好准备。然而,在这个时候注意到 Clojure 代码由数据结构组成,我们的程序可以生成那些相同的数据结构,这是非常迷人的。"代码是数据" 是 Lisp 世界中的一句名言,这是一个强大的概念,允许你的程序生成代码(称为 元编程)。如果你对这个概念还不熟悉,那么花一分钟时间思考和欣赏它的纯粹之美是值得的。我们将在解释 第十一章 中的 宏 时详细解释元编程技术。
基本特殊形式
到目前为止,我们一直在编写符合 Clojure 代码最简单规则的代码,但有一些行为不能简单地用正常函数来编码。例如,传递给函数的参数将始终被解析或评估,但如果我们不想评估运算符的所有操作数呢?这就是特殊形式发挥作用的时候。它们可以在 Clojure 读取源代码时为函数提供不同的评估规则。例如,特殊形式 if 可能不会评估其参数之一,这取决于第一个参数的结果。
在本节中,我们还将介绍几个其他特殊形式:
-
when可以在只对条件为 真值(在布尔表达式的上下文中,值被认为是 真值)的情况感兴趣时使用。 -
do可以用来执行一系列表达式并返回最后一个表达式的值。 -
def和let,这些是用于创建全局和局部绑定的特殊形式。 -
fn和defn,这些是用于创建函数的特殊形式。
所有这些特殊形式都有特殊的评估规则,所有这些规则我们都会通过以下三个练习来发现。
练习 1.03:使用 if、do 和 when
在这个练习中,我们将使用 if、do 和 when 形式来评估表达式。让我们开始吧:
-
启动你的 REPL 并输入以下表达式:
user=> (if true "Yes" "No") "Yes" -
特殊形式
if,评估其第一个参数。如果其值为真,它将评估参数 2,否则(else),它将评估参数 3。它永远不会同时评估参数 2 和 3。 -
我们可以嵌套表达式,并开始做一些更有趣的事情:
user=> (if false (+ 3 4) (rand)) 0.4833142431072903在这种情况下,计算
(+ 3 4)将不会执行,rand函数只会返回一个随机数(介于 0 和 1 之间)。 -
但如果我们想在条件的分支中做更多的事情呢?我们可以用
do来包裹我们的操作。让我们看看do是如何工作的:user=> (doc do) ------------------------- do (do exprs*) Special Form Evaluates the expressions in order and returns the value of the last. If no expressions are supplied, returns nil. Please see http://clojure.org/special_forms#do Evaluates the expressions in order and returns the value of the last. If no expressions are supplied, returns nil. nil -
要使用特殊形式,请输入以下表达式:
user=> (do (* 3 4) (/ 8 4) (+ 1 1)) 2在最后的
(+ 1 1)表达式之前的所有表达式都被评估了,但只有最后一个表达式的值被返回。对于不改变世界状态的表达式来说,这看起来并不很有用,因此它通常用于副作用,如日志记录或任何其他类型的 I/O(文件系统访问、数据库查询、网络请求等)。你不必相信我的话,所以让我们通过在终端打印副作用来实验:
user=> (do (println "A proof that this is executed") (println "And this too")) A proof that this is executed And this too nil -
最后,我们可以结合使用
if和do来在条件分支中执行多个操作:user=> (if true (do (println "Calculating a random number...") (rand)) (+ 1 2)) Calculating a random number... 0.8340057877906916 -
技术上,你也可以省略第三个参数。在 REPL 中恢复之前的表达式,并移除最后一个表达式,即
(+ 1 2):user=> (if true (do (println "Calculating a random number...") (rand))) Calculating a random number... 0.5451384920081613 user=> (if false (println "Not going to happen")) nil对于这种情况,我们有更好的构造:
when运算符。当你只对条件执行的一个分支中的工作感兴趣时,使用when而不是组合if和do: -
输入以下表达式来使用
when而不是if和do的组合:user=> (when true (println "First argument") (println "Second argument") "And the last is returned") First argument Second argument "And the last is returned"
通过完成这个练习,我们已经展示了特殊形式if、do和when的用法。现在我们可以编写包含多个语句以及条件表达式的表达式。
绑定
在 Clojure 中,我们使用术语绑定而不是变量和赋值,因为我们倾向于只将一个值绑定到一个符号上。在底层,Clojure 创建变量,因此你可能会遇到这个术语,但如果你不把它们视为经典的变量或可以改变的值,那就更好了。在本章中,我们不再使用术语变量,因为它可能会造成混淆。你可以使用def来定义全局绑定,使用let来定义局部绑定。
练习 1.04:使用 def 和 let
在这个练习中,我们将展示def和let关键字的使用,这些关键字用于创建绑定。让我们开始吧:
-
特殊形式
def允许你将值绑定到符号。在 REPL 中,输入以下表达式将值10绑定到x符号:user=> (def x 10) #'user/x注意
当 REPL 返回
#'user/x时,它正在返回你刚刚创建的变量的引用。user部分表示变量定义的命名空间。#'前缀是一种引用变量的方式,这样我们就能看到符号而不是符号的值。 -
评估表达式
x,这将解析x符号到其值:user=> x 10 -
技术上,你可以更改绑定,这在 REPL 中实验时是可行的:
user=> (def x 20) #'user/x user=> x 20然而,在你的程序中并不推荐这样做,因为它可能会使代码难以阅读并复杂化其维护。现在,最好是将这样的绑定视为一个常量。
-
你可以在另一个表达式中使用
x符号:user=> (inc x) 21 user=> x 20 -
无论
def在哪里被调用,它都会将值绑定到当前命名空间中的符号。我们可以在do块中尝试定义一个局部绑定,看看会发生什么:user=> x 20 user=> (do (def x 42)) #'user/x user=> x 42由
def创建的绑定具有不确定的作用域(或动态作用域),可以视为“全局”。它们会自动命名空间,这是一个有用的特性,可以避免与现有名称冲突。 -
如果我们只想让绑定在局部作用域或词法作用域内可用,我们可以使用特殊形式
let。输入以下表达式以创建y符号的词法绑定:user=> (let [y 3] (println y) (* 10 y)) 3 30let以一个“向量”作为参数来创建局部绑定,然后是一系列表达式,这些表达式将被评估,就像它们在一个do块中一样。注意
向量与列表类似,因为它们都是值的顺序集合。它们的底层数据结构不同,我们将在第二章,数据类型和不可变性中详细说明。现在,你只需要知道向量可以用方括号创建,例如,
[1 2 3 4]。 -
评估
y符号:user=> y Syntax error compiling at (REPL:0:0). Unable to resolve symbol: y in this context会抛出一个错误,即“无法在当前上下文中解析符号:y”,因为我们现在处于
let块之外。 -
输入以下表达式以创建
x到值3的词法绑定,并看看它如何影响我们在步骤 4中创建的不确定(全局)绑定x:user=> (let [x 3] (println x)) 3 nil user=> x 42打印
x得到值3,这意味着“全局”的x符号被在println调用时的词法上下文临时覆盖或“阴影”。 -
你可以使用
let一次创建多个局部绑定,通过在向量中传递偶数个项。输入以下表达式以将x绑定到10,将y绑定到20:user=> (let [x 10 y 20] (str "x is " x " and y is " y)) "x is 10 and y is 20" -
结合本节的概念,编写以下表达式:
user=> (def message "Let's add them all!") #'user/message user=> (let [x (* 10 3) y 20 z 100] (println message) (+ x y z)) Let's add them all! 150
表达式跨越多行以提高可读性。
练习 1.05:使用 fn 和 defn 创建简单函数
定义函数的特殊形式是 fn。让我们直接进入正题,通过创建我们的第一个函数来开始:
-
在你的 REPL 中输入以下表达式:
user=> (fn []) #object[user$eval196$fn__197 0x3f0846c6 "user$eval196$fn__197@3f0846c6"]我们刚刚创建了最简单的匿名函数,它不接受任何参数也不做任何事情,并返回了一个对象,即我们的无名称函数。
-
创建一个接受名为
x的参数并返回其平方值的函数(乘以自身):user=> (fn [x] (* x x)) #object[user$eval227$fn__228 0x68b6f0d6 "user$eval227$fn__228@68b6f0d6"] -
记住,在 Clojure 中,表达式的第一个项将被调用,因此我们可以通过将匿名函数用括号括起来并提供作为表达式第二个项的参数来调用我们的匿名函数:
user=> ((fn [x] (* x x)) 2) 4现在这是很好的,但不是很方便。如果我们想让函数可重用或可测试,给它一个名字会更好。我们可以在命名空间中创建一个符号并将其绑定到函数上。
-
使用
def将特殊形式fn返回的函数绑定到square符号:user=> (def square (fn [x] (* x x))) #'user/square -
调用你新创建的函数以确保它正常工作:
user=> (square 2) 4 user=> (square *1) 16 user=> (square *1) 256 -
这种将
def和fn结合的模式非常常见,以至于出于必要性产生了内置的 宏:defn。用defn而不是def和fn重新创建平方函数:user=> (defn square [x] (* x x)) #'user/square user=> (square 10) 100你注意到
x参数是以向量形式传递的吗?我们已经了解到向量是集合,因此我们可以将多个符号添加到参数的向量中。在调用函数时传递的值将在函数定义期间绑定到向量中提供的符号。 -
函数可以接受多个参数,它们的主体可以由多个表达式组成(如隐式
do块)。创建一个名为meditate的函数,它接受两个参数:一个字符串s和一个布尔值calm。该函数将打印一个介绍性消息并根据calm返回s的转换:user=> (defn meditate [s calm] (println "Clojure Meditate v1.0") (if calm (clojure.string/capitalize s) (str (clojure.string/upper-case s) "!")))注意
在 REPL 中编辑多行表达式可能会很麻烦。当我们开始创建更长的函数和跨越多行的表达式时,最好在 REPL 窗口旁边打开你最喜欢的编辑器窗口。保持这些窗口并排打开,在编辑器中编辑代码,将其复制到剪贴板,然后将其粘贴到 REPL 中。
函数体包含两个主要表达式,第一个是一个带有
println的副作用,第二个是一个if块,它将确定返回值。如果calm为true,它将礼貌地返回大写字母开头的字符串(第一个字符转换为大写),否则它将大声喊叫并返回所有字符都大写的字符串,并以感叹号结尾。 -
让我们尝试确保我们的函数按预期工作:
user=> (meditate "in calmness lies true pleasure" true) Clojure Meditate v1.0 "In calmness lies true pleasure" user=> (meditate "in calmness lies true pleasure" false) Clojure Meditate v1.0 "IN CALMNESS LIES TRUE PLEASURE!" -
如果我们只使用第一个参数调用函数,它将抛出异常。这是因为我们定义的参数是必需的:
user=> (meditate "in calmness lies true pleasure") Execution error (ArityException) at user/eval365 (REPL:1). Wrong number of args (1) passed to: user/meditate在结束对这些函数的初步浏览之前,我们再来谈谈
doc-string参数。当提供给defn时,它将允许你添加函数的描述。 -
通过在函数参数之前添加文档字符串来为你的
square函数添加文档:user=> (defn square "Returns the product of the number `x` with itself" [x] (* x x)) #'user/square文档字符串不仅在使用项目源代码时很有用——它还使
doc函数能够访问它。 -
使用
doc查找你的square函数的文档:user=> (doc square) ------------------------- user/square ([x]) Returns the product of the number `x` with itself nil重要的是要记住,文档字符串需要放在函数参数之前。如果它放在后面,字符串将被逐行评估作为函数体的一部分,而不会抛出错误。这是一个有效的语法,但它将无法在
doc辅助程序和其他开发工具中使用。使用反引号
````记录参数是一个好习惯,就像我们用`x` `做的那样,这样开发工具(如 IDE)就可以识别它们。
我们将在第三章“深入函数”中更深入地探讨函数,但这几个基本原理将使你在编写函数方面走得很远。
活动 1.02:预测大气二氧化碳水平
二氧化碳(CO2)是一种重要的温室气体,目前正在上升,威胁着我们星球上我们所知的生活。我们希望根据国家海洋和大气管理局(NOAA)提供的历史数据预测大气中未来 CO2 的水平:
![图 1.2:过去年份的 CO2 百万分之一(ppm)]

图 1.2:过去年份的 CO2 百万分之一(ppm)
注意
前面的图表是从packt.live/35kUI7L获取的,数据来自 NOAA。
我们将以 2006 年为起点,CO2 水平为 382 ppm,使用简化的(且乐观的)线性函数来计算估计值,如下所示:估计值 = 382 + ((年份 - 2006) * 2)。
创建一个名为co2-estimate的函数,它接受一个名为year的整数参数,并返回该年份的估计 CO2 ppm 水平。
这些步骤将帮助你完成这项活动:
-
打开你最喜欢的编辑器和旁边的 REPL 窗口。
-
在你的编辑器中,定义两个常量,
base-co2和base-year,分别赋值为 382 和 2006。 -
在你的编辑器中,编写代码来定义
co2-estimate函数,不要忘记使用 doc-string 参数对其进行文档化。 -
你可能会想将函数体写在一行中,但嵌套过多的函数调用会降低代码的可读性。通过在
let块中分解它们,也更容易推理过程的每一步。使用let来定义局部绑定year-diff,它是从year参数中减去 2006 的结果,来编写函数体。 -
通过评估
(co2-estimate 2050)测试你的函数。你应该得到470作为结果。 -
使用
doc查找你函数的文档,并确保它已被正确定义。
以下为预期输出:
user=> (doc co2-estimate)
user/co2-estimate
([year])
Returns a (conservative) year's estimate of carbon dioxide parts per million in the atmosphere
nil
user=> (co2-estimate 2006)
382
user=> (co2-estimate 2020)
410
user=> (co2-estimate 2050)
470
注意
本活动的解决方案可以在第 679 页找到。
真实感、空值和相等性
到目前为止,我们一直在直观地使用条件表达式,可能基于它们在其他编程语言中通常的工作方式。在本节的最后,我们将详细回顾和解释布尔表达式以及相关的比较函数,从 Clojure 中的nil和真值开始。
nil是一个表示值缺失的值。在其他编程语言中,它也常被称为NULL。表示值的缺失是有用的,因为它意味着某些东西缺失了。
在 Clojure 中,nil是“假值”,这意味着在布尔表达式中评估时,nil的行为就像false。
false和nil是 Clojure 中唯一被视为假值的值;其他所有值都是真值。这个简单的规则是一种祝福(尤其是如果你来自像 JavaScript 这样的语言),它使我们的代码更易于阅读且更少出错。也许这只是因为当奥斯卡·王尔德写下“真理很少是纯粹的,也永远不会简单”时,Clojure 还没有出现。
练习 1.06:真理很简单
在这个练习中,我们将演示如何在条件表达式中处理布尔值。我们还将看到如何在条件表达式中玩转逻辑运算符。让我们开始吧:
-
让我们先验证
nil和false确实是假值:user=> (if nil "Truthy" "Falsey") "Falsey" user=> (if false "Truthy" "Falsey") "Falsey" -
在其他编程语言中,在布尔表达式中,更多的值解析为
false是常见的。但在 Clojure 中,请记住,只有nil和false是假值。让我们尝试几个例子:user=> (if 0 "Truthy" "Falsey") "Truthy" user=> (if -1 "Truthy" "Falsey") "Truthy" user=> (if '() "Truthy" "Falsey") "Truthy" user=> (if [] "Truthy" "Falsey") "Truthy" user=> (if "false" "Truthy" "Falsey") "Truthy" user=> (if "" "Truthy" "Falsey") "Truthy" user=> (if "The truth might not be pure but is simple" "Truthy" "Falsey") "Truthy" -
如果我们想知道某物是否确实是
true或false,而不仅仅是真值或假值,我们可以使用true?和false?函数:user=> (true? 1) false user=> (if (true? 1) "Yes" "No") "No" user=> (true? "true") false user=> (true? true) true user=> (false? nil) false user=> (false? false) true?字符没有特殊的行为——它只是为返回布尔值的函数命名的一种约定。 -
同样,如果我们想知道某物是
nil而不是仅仅是假值,我们可以使用nil?函数:user=> (nil? false) false user=> (nil? nil) true user=> (nil? (println "Hello")) Hello true记住
println返回nil,因此前述代码中的最后一行输出是true。当布尔表达式组合在一起时,它们变得有趣。Clojure 提供了常用的运算符,即
and和or。在这个阶段,我们只对逻辑and和逻辑or感兴趣。如果你想要玩转位运算符,你可以通过(find-doc "bit-")命令轻松找到它们。and返回它遇到的第一个假值(从左到右),并且当这种情况发生时,它不会评估表达式的其余部分。当传递给and的所有值都是真值时,and将返回最后一个值。 -
通过传递真值和假值的混合值来实验
and函数,观察生成的返回值:user=> (and "Hello") "Hello" user=> (and "Hello" "Then" "Goodbye") "Goodbye" user=> (and false "Hello" "Goodbye") false -
让我们使用
println并确保不是所有的表达式都被评估:user=> (and (println "Hello") (println "Goodbye")) Hello niland评估了第一个表达式,它打印了Hello并返回nil,这是假值。因此,第二个表达式没有被评估,Goodbye没有被打印。or的工作方式类似:它会返回遇到的第一个真值,并且当这种情况发生时,它不会评估表达式的其余部分。当传递给or的所有值都是假值时,or将返回最后一个值。 -
通过传递真值和假值的混合值来实验
or函数,观察生成的返回值:user=> (or "Hello") "Hello" user=> (or "Hello" "Then" "Goodbye") "Hello" user=> (or false "Then" "Goodbye") "Then" -
再次使用
println来确保表达式没有被全部评估:user=> (or true (println "Hello")) trueor评估了第一个表达式true并返回它。因此,第二个表达式没有被评估,Hello没有被打印。
相等和比较
在大多数命令式编程语言中,= 符号用于变量赋值。正如我们之前看到的,在 Clojure 中,我们有 def 和 let 来绑定名称和值。= 符号是一个用于相等的函数,如果所有参数都相等,它将返回 true。正如你现在可能已经猜到的,其他常见的比较函数也是作为函数实现的。>, >=, <, <=, 和 = 不是特殊语法,你可能已经对这些用法有了直觉。
练习 1.07:比较值
在这个最后的练习中,我们将探讨在 Clojure 中比较值的不同方法。让我们开始吧:
-
首先,如果你的 REPL 还没有运行,请启动它。
-
输入以下表达式来比较两个数字:
user=> (= 1 1) true user=> (= 1 2) false -
你可以向
=操作符传递多个参数:user=> (= 1 1 1) true user=> (= 1 1 1 -1) false在那种情况下,即使前三个参数相等,最后一个参数不相等,所以
=函数返回false。 -
=操作符不仅用于比较数字,还用于比较其他类型。评估以下一些表达式:user=> (= nil nil) true user=> (= false nil) false user=> (= "hello" "hello" (clojure.string/reverse "olleh")) true user=> (= [1 2 3] [1 2 3]) true注意
在 Java 或其他面向对象的编程语言中,比较事物通常检查它们是否是存储在内存中的对象的相同实例,即它们的身份。然而,Clojure 中的比较是通过相等性而不是身份来进行的。比较值通常更有用,Clojure 也使其变得方便,但如果你想要比较身份,你可以通过使用
identical?函数来实现。 -
更令人惊讶的是,不同类型的序列也可以被认为是相等的:
user=> (= '(1 2 3) [1 2 3]) true列表
123等价于向量123。集合和序列是 Clojure 的强大抽象,将在第二章,数据类型和不可变性中介绍。 -
值得注意的是,
=函数也可以接受一个参数,在这种情况下,它将始终返回true:user=> (= 1) true user=> (= "I will not reason and compare: my business is to create.") true其他比较运算符,即
>,>=,<, 和<=,只能用于数字。让我们从<和>开始。 -
<如果所有参数都是严格递增的,则返回true。尝试评估以下表达式:user=> (< 1 2) true user=> (< 1 10 100 1000) true user=> (< 1 10 10 100) false user=> (< 3 2 3) false user=> (< -1 0 1) true注意,
10后面跟着10并不是严格递增的。 -
<=类似,但相邻参数可以相等:user=> (<= 1 10 10 100) true user=> (<= 1 1 1) true user=> (<= 1 2 3) true -
>和>=有类似的行为,当它们的参数按递减顺序排列时返回true。>=允许相邻参数相等:user=> (> 3 2 1) true user=> (> 3 2 2) false user=> (>= 3 2 2) true -
最后,
not操作符是一个有用的函数,当其参数是假值(nil或false)时返回true,否则返回false。让我们试一个例子:user=> (not true) false user=> (not nil) true user=> (not (< 1 2)) false user=> (not (= 1 1)) false为了将这些内容综合起来,让我们考虑以下 JavaScript 代码:
let x = 50; if (x >= 1 && x <= 100 || x % 100 == 0) { console.log("Valid"); } else { console.log("Invalid"); }这段代码片段当数字
x在 1 到 100 之间或x是 100 的倍数时打印Valid,否则打印Invalid。如果我们要将此转换为 Clojure 代码,我们会写出以下内容:
(let [x 50] (if (or (<= 1 x 100) (= 0 (mod x 100))) (println "Valid") (println "Invalid")))Clojure 代码中可能有一些额外的括号,但你可以争论 Clojure 比命令式 JavaScript 代码更易读。它包含更少的特定语法,我们不需要考虑运算符优先级。
如果我们想使用“内联 if”转换 JavaScript 代码,我们会引入带有
?和:的新语法,如下所示:let x = 50; console.log(x >= 0 && x <= 100 || x % 100 == 0 ? "Valid" : "Invalid");Clojure 代码将变成以下样子:
(let [x 50] (println (if (or (<= 1 x 100) (= 0 (mod x 100))) "Valid" "Invalid")))
注意,这里没有新的语法,也没有什么新的东西要学习。你已经知道如何读取列表,而且几乎永远不需要做其他的事情。
这个简单的例子展示了lists(列表)的巨大灵活性:Clojure 和其他 Lisp 语言的基本构建块。
活动 1.03:meditate 函数 v2.0
在这个活动中,我们将通过用calmness-level替换calm布尔参数来改进我们在练习 1.05,使用 fn 和 defn 创建简单函数中编写的meditate函数。该函数将根据平静度打印作为第二个参数传递的字符串的转换。函数的规范如下:
-
calmness-level是一个介于1和10之间的数字,但我们将不会检查输入错误。 -
如果平静度严格小于
5,我们认为用户很生气。函数应该将s字符串转换为大写并连接字符串", I TELL YA!"。 -
如果平静度在
5和9之间,我们认为用户处于平静和放松的状态。函数应该只将s字符串的第一个字母大写后返回。 -
如果平静度是
10,用户已经达到了涅槃,被 Clojure 神灵附体。在它的迷幻状态中,用户传达了那些神圣实体的难以理解的语言。函数应该返回反转的s字符串。提示
使用
str函数连接字符串和clojure.string/reverse来反转字符串。如果你不确定如何使用它们,可以使用doc(例如,(doc clojure.string/reverse))查找它们的文档。
这些步骤将帮助你完成这个活动:
-
打开你最喜欢的编辑器和旁边的 REPL 窗口。
-
在你的编辑器中,定义一个名为
meditate的函数,它接受两个参数calmness-level和s,不要忘记编写它的文档。 -
在函数体中,首先编写一个打印字符串
Clojure Meditate v2.0的表达式。 -
按照规范,编写第一个条件来测试平静度是否严格小于
5。编写条件表达式的第一个分支(即then)。 -
编写第二个条件,它应该嵌套在第一个条件的第二个分支(即
else)中。 -
编写第三个条件,它应该嵌套在第二个条件的第二个分支中。它将检查
calmness-level是否正好是10,并在这种情况下返回s字符串的反转。 -
通过传递具有不同平静程度的字符串来测试你的函数。输出应该类似于以下内容:
user=> (meditate "what we do now echoes in eternity" 1) Clojure Meditate v2.0 "WHAT WE DO NOW ECHOES IN ETERNITY, I TELL YA!" user=> (meditate "what we do now echoes in eternity" 6) Clojure Meditate v2.0 "What we do now echoes in eternity" user=> (meditate "what we do now echoes in eternity" 10) Clojure Meditate v2.0 "ytinrete ni seohce won od ew tahw" user=> (meditate "what we do now echoes in eternity" 50) Clojure Meditate v2.0 nil -
如果你一直使用
and操作符来查找一个数字是否介于两个其他数字之间,请将你的函数重写,移除它并仅使用<=操作符。记住,<=可以接受超过两个参数。 -
在文档中查找
cond操作符,并将你的函数重写,用cond替换嵌套条件。注意
这个活动的解决方案可以在第 680 页找到。
摘要
在本章中,我们发现了如何使用 REPL 及其辅助工具。你现在能够搜索和发现新的函数,并在 REPL 中交互式地查找它们的文档。我们学习了 Clojure 代码是如何评估的,以及如何使用和创建函数、绑定、条件和比较。这些允许你创建简单的程序和脚本。
在下一章中,我们将探讨数据类型,包括集合,以及不可变性的概念。
第二章:2. 数据类型和不可变性
概述
在本章中,我们首先探讨不可变性的概念及其在现代程序中的相关性。然后我们检查简单的数据类型,如字符串、数字和布尔值,强调在不同环境(如 Clojure 和 ClojureScript)中的细微差异。在完成第一个练习后,我们继续探讨更复杂的数据类型,如列表、向量、映射和集合,并在不同情况下学习如何使用它们。在触及集合和序列抽象之后,我们学习处理嵌套数据结构的新技术,最后转向最终活动:实现我们自己的内存数据库。
到本章结束时,你将能够使用 Clojure 中常用的数据类型。
简介
在过去的几十年里,计算机硬件发生了巨大的变化。在典型的计算机上,存储和内存容量与 20 世纪 80 年代初相比都增加了百万倍。然而,软件开发中的标准行业实践和主流编程方式并没有太大的不同。像 C++、Java、Python 和 Ruby 这样的编程语言仍然通常鼓励你就地更改事物,并使用变量来修改程序的状态,也就是说,就像我们在内存最少的计算机上编程一样。然而,在我们追求效率、更好的语言和更好的工具的过程中,我们转向了高级语言。我们希望离机器代码更远。我们希望编写更少的代码,让计算机做繁琐的工作。
我们不再需要考虑计算机的内存,比如信息存储在哪里,它是否安全且可共享,就像我们不想知道 CPU 中指令的顺序一样。这会分散我们试图解决的问题的注意力,这些问题已经足够复杂。如果你曾经尝试在之前提到的语言中做一些多线程操作,你就会知道线程间共享数据的痛苦。尽管如此,利用多核 CPU 和多线程应用程序是优化现代程序性能的一个关键部分。
在 Clojure 中,我们几乎完全使用不可变数据类型。它们可以安全地共享,易于制造,并提高我们源代码的可读性。Clojure 提供了编写函数式编程范式程序的必要工具:一等公民函数,我们将在下一章中了解到,以及避免使用不可变数据类型来修改和共享应用程序的状态。
让我们翻阅一下词典,查找“不可变”的定义,“不可变:不能改变;永远不会改变”。这并不意味着信息不能随时间改变,但我们把这些修改记录为一系列新的值。“更新”不可变数据结构提供了一个从原始值派生的新值。然而,原始值保持不变——那些保留自身先前版本的数据结构被称为持久数据结构。
直觉上,我们可能会认为这样的持久数据结构会对性能产生负面影响,但事实并非如此。它们针对性能进行了优化,并且像结构共享这样的技术将所有操作的时间复杂度接近于经典的、可变的实现。
从另一个角度来看,除非你正在编写一个需要非凡高性能的应用程序,例如视频游戏,否则使用不可变数据结构的优势远远超过性能上的小损失。
简单数据类型
数据类型指定了数据块所持有的值的类型;它是分类数据的基本方式。不同的类型允许不同的操作:我们可以连接字符串,乘以数字,以及使用布尔值进行逻辑代数运算。由于 Clojure 非常注重实用性,我们在 Clojure 中并不显式地为值分配类型,但这些值仍然有类型。
Clojure 是一种托管语言,在 Java、JavaScript 和.NET 中有三个显著的、主要的实现。作为一个托管语言,这是一个有用的特性,它允许 Clojure 程序在不同的环境中运行并利用其宿主环境的生态系统。关于数据类型,这意味着每个实现都有不同的底层数据类型,但不用担心,这些只是实现细节。作为一个 Clojure 程序员,这并没有太大的区别,如果你知道如何在 Clojure 中做某事,你很可能也知道如何在,比如说,ClojureScript 中做同样的事情。
在这个主题中,我们将介绍 Clojure 的简单数据类型。以下是本节中查看的数据类型列表。请注意,以下类型都是不可变的:
-
字符串
-
数字
-
布尔值
-
关键字
-
空值(nil)
字符串
字符串是表示文本的字符序列。我们从第一章,Hello REPL的第一个练习开始就一直在使用和处理字符串。
你可以通过简单地用双引号(")包裹字符来创建字符串:
user=> "I am a String"
"I am a String"
user=> "I am immutable"
"I am immutable"
字符串字面量只能用双引号创建,如果你需要在字符串中使用双引号,你可以用反斜杠字符(\)来转义它们:
user=> (println "\"The measure of intelligence is the ability to change\" - Albert Einstein")
"The measure of intelligence is the ability to change" - Albert Einstein
nil
字符串不能被改变;它们是不可变的。任何声称可以转换字符串的函数都会产生一个新的值:
user=> (def silly-string "I am Immutable. I am a silly String")
#'user/silly-string
user=> (clojure.string/replace silly-string "silly" "clever")
"I am Immutable. I am a clever String"
user=> silly-string
"I am Immutable. I am a silly String"
在前面的例子中,对 silly-string 调用 clojure.string/replace 返回了一个新的字符串,其中 "silly" 被替换为 "clever"。然而,当再次评估 silly-string 时,我们可以看到值没有改变。函数返回了一个不同的值,并且没有改变原始字符串。
虽然字符串通常是一个表示文本的单个数据单元,但字符串也是字符的集合。在 Clojure 的 JVM 实现中,字符串是 java.lang.String Java 类型,它们被实现为 java.lang.Character Java 类型的集合,如下面的命令所示,它返回一个字符:
user=> (first "a collection of characters")
\a
user=> (type *1)
java.lang.Character
first 返回集合的第一个元素。在这里,字符的文表示法是\a。type 函数返回给定值的类型字符串表示。记住,我们可以使用 *1 来检索 REPL 中最后返回的值,所以 *1 的结果是 \a。
有趣的是,在 ClojureScript 中,字符串是一组单字符字符串的集合,因为 JavaScript 中没有字符类型。以下是一个类似的 ClojureScript REPL 中的例子:
cljs.user=> (last "a collection of 1 character strings")
"s"
cljs.user=> (type *1)
#object[String]
与 Clojure REPL 一样,type 返回数据类型的字符串表示。这次,在 ClojureScript 中,last 函数(返回字符串的最后一个字符)返回的是 #object[String] 类型,这意味着一个 JavaScript 字符串。
您可以在核心命名空间中找到一些用于操作字符串的常见函数,例如 str,我们在 第一章,Hello REPL! 中使用它来连接(将多个字符串组合成一个字符串):
user=> (str "That's the way you " "con" "ca" "te" "nate")
"That's the way you concatenate"
user=> (str *1 " - " silly-string)
"That's the way you concatenate - I am Immutable. I am a silly String"
大多数字符串操作函数都可以在 clojure.string 命名空间中找到。以下是使用 REPL dir 函数列出的一些函数:
user=> (dir clojure.string)
blank?
capitalize
ends-with?
escape
includes?
index-of
join
last-index-of
lower-case
re-quote-replacement
replace
replace-first
reverse
split
split-lines
starts-with?
trim
trim-newline
triml
trimr
upper-case
作为提醒,这是您如何使用特定命名空间中的函数:
user=> (clojure.string/includes? "potatoes" "toes")
true
我们不会涵盖所有的字符串函数,但现在您可以随意尝试它们。您始终可以使用 doc 函数查找前面列表中字符串函数的文档。
数字
Clojure 对数字有很好的支持,您很可能不需要担心底层类型,因为 Clojure 将处理几乎所有事情。然而,重要的是要注意,在 Clojure 和 ClojureScript 之间,在这方面有一些差异。
在 Clojure 中,默认情况下,自然数以 java.lang.Long Java 类型实现,除非数字太大而无法适合 Long。在这种情况下,它被类型化为 clojure.lang.BigInt:
user=> (type 1)
java.lang.Long
user=> (type 1000000000000000000)
java.lang.Long
user=> (type 10000000000000000000)
clojure.lang.BigInt
注意,在前面的例子中,数字太大,无法适合 java.lang.Long Java 类型,因此被隐式地类型化为 clojure.lang.BigInt。
精确比例在 Clojure 中表示为 "Ratio" 类型,它有文表示法。5/4 不是一个精确比例,所以输出是比例本身:
user=> 5/4
5/4
将 3 除以 4 的结果可以用比例 3/4 来表示:
user=> (/ 3 4)
3/4
user=> (type 3/4)
clojure.lang.Ratio
4/4 等于 1,其计算如下:
user=> 4/4
1
小数是“双精度”浮点数:
user=> 1.2
1.2
如果我们再次进行 3 除以 4 的除法,但这次混合使用“Double”类型,我们将不会得到一个比率作为结果:
user=> (/ 3 4.0)
0.75
这是因为在 Clojure 中,浮点数是“传染性”的。任何涉及浮点数的操作都会导致得到一个浮点数或双精度浮点数:
user=> (* 1.0 2)
2.0
user=> (type (* 1.0 2))
java.lang.Double
然而,在 ClojureScript 中,数字只是“JavaScript 数字”,它们都是双精度浮点数。JavaScript 没有定义像 Java 和一些其他编程语言那样的不同数字类型(例如,long、integer和short):
cljs.user=> 1
1
cljs.user=> 1.2
1.2
cljs.user=> (/ 3 4)
0.75
cljs.user=> 3/4
0.75
cljs.user=> (* 1.0 2)
2
注意,这次,任何操作都会返回一个浮点数。1或2没有小数分隔只是格式上的便利。
我们可以通过使用type函数来确保所有这些数字都是 JavaScript 数字(双精度,浮点数):
cljs.user=> (type 1)
#object[Number]
cljs.user=> (type 1.2)
#object[Number]
cljs.user=> (type 3/4)
#object[Number]
如果你需要做比简单算术更复杂的事情,你可以使用 Java 或 JavaScript 的math库,它们非常相似,除了少数例外。
你将在第九章主机平台互操作性(如何与主机平台及其生态系统交互)中了解更多关于主机平台互操作性的内容,但该章节中的示例将帮助你开始做一些更复杂的数学运算,以及使用math库:
从一个常量中读取值可以这样操作:
user=> Math/PI
3.141592653589793
调用一个函数,就像通常的 Clojure 函数一样,可以这样操作:
user=> (Math/random)
0.25127992428738254
user=> (Math/sqrt 9)
3.0
user=> (Math/round 0.7)
1
练习 2.01:混淆机
你被一个秘密政府机构联系,要求开发一个算法,将文本编码成一个只有算法所有者才能解码的秘密字符串。显然,他们不相信其他安全机制,如 SSL,而只愿意用他们自己的专有技术来传输敏感信息。
你需要开发一个encode函数和一个decode函数。encode函数应该用不易猜测的数字替换字母。为此,每个字母将取其在 ASCII 表中的字符数值,然后加上另一个数字(要编码的句子中的单词数),最后计算该数字的平方值。decode函数应该允许用户恢复到原始字符串。一个高级别的机构成员提出了这个算法,所以他们相信它非常安全。
在这个练习中,我们将通过构建一个混淆机来实践我们关于字符串和数字学到的一些知识:
-
启动你的 REPL 并查找
clojure.string/replace函数的文档:user=> (doc clojure.string/replace) ------------------------- clojure.string/replace ([s match replacement]) Replaces all instance of match with replacement in s. match/replacement can be: string / string char / char pattern / (string or function of match). See also replace-first. The replacement is literal (i.e. none of its characters are treated specially) for all cases above except pattern / string. For pattern / string, $1, $2, etc. in the replacement string are substituted with the string that matched the corresponding parenthesized group in the pattern. If you wish your replacement string r to be used literally, use (re-quote-replacement r) as the replacement argument. See also documentation for java.util.regex.Matcher's appendReplacement method. Example: (clojure.string/replace "Almost Pig Latin" #"\b(\w)(\w+)\b" "$2$1ay") -> "lmostAay igPay atinLay"注意,
replace函数可以接受一个模式和匹配结果的函数作为参数。我们目前还不知道如何遍历集合,但使用带有模式和“替换函数”的replace函数应该可以完成任务。 -
尝试使用
#"\w"模式(表示单词字符)的replace函数,将其替换为!字符,并观察结果:user=> (clojure.string/replace "Hello World" #"\w" "!")输出如下:
"!!!!! !!!!!" -
尝试使用相同的模式使用
replace函数,但这次传递一个匿名函数,该函数接受匹配的字母作为参数:user=> (clojure.string/replace "Hello World" #"\w" (fn [letter] (do (println letter) "!")))输出如下:
H e l l o W o r l d "!!!!! !!!!!"观察到函数为每个字母调用了,将匹配打印到控制台,并最终返回用
!字符替换的字符串。看起来我们应该能够在那个替换函数中编写我们的编码逻辑。 -
现在我们来看看如何将一个字符转换为数字。我们可以使用
int函数,它将它的参数强制转换为整数。它可以这样使用:user=> (int \a) 97 -
“替换函数”似乎将接受一个字符串作为参数,所以让我们将我们的字符串转换为字符。使用
char-array函数结合first将我们的字符串转换为字符,如下所示:user=> (first (char-array "a")) \a -
现在,如果我们结合之前的步骤,并计算字符的数字的平方值,我们应该接近我们的混淆目标。将之前编写的代码组合起来,从字符串中获取字符代码并使用
Math/pow函数计算其平方值,如下所示:user=> (Math/pow (int (first (char-array "a"))) 2) 9409.0 -
现在我们将这个结果转换成
replace函数将返回的字符串。首先,通过将结果强制转换为int来删除小数部分,并在encode-letter函数中将这些内容组合起来,如下所示:user=> (defn encode-letter [s] (let [code (Math/pow (int (first (char-array s))) 2)] (str (int code)))) #'user/encode-letter user=> (encode-letter "a") "9409"太好了!它似乎可以工作。现在让我们测试我们的函数作为
replace函数的一部分。 -
创建
encode函数,它使用clojure.string/replace以及我们的encode-letter函数:user=> (defn encode [s] (clojure.string/replace s #"\w" encode-letter)) #'user/encode user=> (encode "Hello World") "518410201116641166412321 756912321129961166410000"它似乎可以工作,但如果没有能够单独识别每个字母,生成的字符串将很难解码。
我们还有另一件事没有考虑到:
encode函数应该在计算平方值之前添加一个任意数到代码中。 -
首先,在
encode-letter函数中添加一个分隔符作为部分,例如#字符,这样我们就可以单独识别每个字母。其次,给encode-letter函数添加一个额外的参数,这个参数需要在计算平方值之前添加:user=> (defn encode-letter [s x] (let [code (Math/pow (+ x (int (first (char-array s)))) 2)] (str "#" (int code)))) #'user/encode-letter -
现在,再次测试
encode函数:user=> (encode "Hello World") Execution error (ArityException) at user/encode (REPL:3). Wrong number of args (1) passed to: user/encode-letter我们的
encode函数现在失败了,因为它期望一个额外的参数。 -
修改
encode函数以计算要混淆的文本中的单词数量,并将其传递给encode-letter函数。你可以使用clojure.string/split函数和空格,如下所示,来计算单词数量:user=> (defn encode [s] (let [number-of-words (count (clojure.string/split s #" "))] (clojure.string/replace s #"\w" (fn [s] (encode-letter s number-of-words))))) #'user/encode -
用几个例子尝试你新创建的函数,并确保它正确地混淆字符串:
user=> (encode "Super secret") "#7225#14161#12996#10609#13456 #13689#10609#10201#13456#10609#13924" user=> (encode "Super secret message") "#7396#14400#13225#10816#13689 #13924#10816#10404#13689#10816#14161 #12544#10816#13924#13924#10000#11236#10816"这是一个多么美丽、难以理解、混淆的字符串——做得好!注意,对于相同的字母,根据要编码的短语中的单词数量,数字是不同的。它似乎按照规范工作!
我们现在可以开始编写
decode函数,我们将需要使用以下函数:Math/sqrt用于获取一个数字的平方根值。char用于从字符代码(一个数字)中检索一个字母。subs作为子字符串,用于获取字符串的一部分(并去除我们的#分隔符)。Integer/parseInt用于将字符串转换为整数。 -
使用前面提到的函数的组合编写
decode函数,以解码一个加密字符:user=> (defn decode-letter [x y] (let [number (Integer/parseInt (subs x 1)) letter (char (- (Math/sqrt number) y))] (str letter))) #'user/decode-letter -
最后,编写
decode函数,它与encode函数类似,但应该使用decode-letter而不是encode-letter:user=> (defn decode [s] (let [number-of-words (count (clojure.string/split s #" "))] (clojure.string/replace s #"\#\d+" (fn [s] (decode-letter s number-of-words))))) #'user/decode -
测试你的函数,确保它们都能正常工作:
user=> (encode "If you want to keep a secret, you must also hide it from yourself.")输出如下:
"#7569#13456 #18225#15625#17161 #17689#12321#15376#16900 #16900#15625 #14641#13225#13225#15876 #12321 #16641#13225#12769#16384#13225#16900, #18225#15625#17161 #15129#17161#16641#16900 #12321#14884#16641#15625 #13924#14161#12996#13225 #14161#16900 #13456#16384#15625#15129 #18225#15625#17161#16384#16641#13225#14884#13456." user=> (decode *1) "If you want to keep a secret, you must also hide it from yourself."
在这个练习中,我们通过创建一个编码系统来实践了处理数字和字符串。现在我们可以继续学习其他数据类型,从布尔值开始。
布尔值
在 Clojure 中,布尔值以 Java 的 java.lang.Boolean 实现,在 ClojureScript 中以 JavaScript 的 "Boolean" 实现。它们的值可以是 true 或 false,它们的字面表示法仅仅是小写的 true 和 false。
符号
符号是标识符,它们指向其他东西。我们已经在使用符号创建绑定或调用函数时使用过符号。例如,当使用 def 时,第一个参数是一个将指向一个值的符号,当调用像 + 这样的函数时,+ 是一个指向实现加法的函数的符号。考虑以下示例:
user=> (def foo "bar")
#'user/foo
user=> foo
"bar"
user=> (defn add-2 [x] (+ x 2))
#'user/add-2
user=> add-2
#object[user$add_2 0x4e858e0a "user$add_2@4e858e0a"]
在这里,我们创建了 user/foo 符号,它指向 "bar" 字符串,以及 add-2 符号,它指向将 2 加到其参数上的函数。我们是在用户命名空间中创建这些符号的,因此有 / 的表示法:user/foo。
如果我们尝试评估一个未定义的符号,我们会得到一个错误:
user=> marmalade
Syntax error compiling at (REPL:0:0).
Unable to resolve symbol: marmalade in this context
在 第一章 的 REPL 基础 主题 Hello REPL! 中,我们能够使用以下函数,因为它们绑定到特定的符号:
user=> str
#object[clojure.core$str 0x7bb6ab3a "clojure.core$str@7bb6ab3a"]
user=> +
#object[clojure.core$_PLUS_ 0x1c3146bc "clojure.core$_PLUS_@1c3146bc"]
user=> clojure.string/replace
#object[clojure.string$replace 0xf478a81 "clojure.string$replace@f478a81"]
这些类似乱码的值是函数的字符串表示,因为我们要求的是符号绑定的值,而不是调用函数(用括号包裹它们)。
关键字
你可以将关键字视为某种特殊的常量字符串。关键字是 Clojure 的一个很好的补充,因为它们轻量级且易于使用和创建。你只需要在单词的开头使用冒号字符 : 来创建一个关键字:
user=> :foo
:foo
user=> :another_keyword
:another_keyword
它们不像符号那样指向其他东西;正如前一个示例所示,当被评估时,它们只是返回自身。关键字通常用作键值关联映射中的键,正如我们将在关于集合的下一个主题中看到的那样。
在本节中,我们介绍了简单的数据类型,如字符串、数字、布尔值、符号和关键字。我们强调了它们的底层实现依赖于宿主平台,因为 Clojure 是一种托管语言。在下一节中,我们将看到这些值如何聚合成为集合。
集合
Clojure 是一种函数式编程语言,我们专注于以函数评估的方式来构建程序的计算,而不是构建自定义数据类型及其相关行为。在另一种主导的编程范式,面向对象编程中,程序员定义数据类型及其上的操作。对象应该封装数据并通过传递消息进行交互。但不幸的是,有一种趋势是创建类和新类型的对象来定制数据形状,而不是使用更通用的数据结构,这导致需要创建特定的方法来访问和修改数据。我们必须想出合适的名字,这很困难,然后我们在程序中传递对象的实例。我们不断创建新的类,但更多的代码意味着更多的错误。这是一场灾难;它是一场代码爆炸,代码非常具体且很少重用。
当然,并非所有地方都是这样,你也可以编写干净的面向对象代码,其中对象是它们设计用于的功能的小黑盒。然而,作为程序员,无论是通过使用其他库还是维护遗留代码库,我们大部分时间都在处理他人的代码。
在函数式编程中,特别是在 Clojure 中,我们倾向于只使用少数几种数据类型。这些类型是通用的且功能强大,是每个其他“Clojurian”(Clojure 程序员)都已经了解并掌握的类型。
集合是能够包含多个元素并描述这些元素之间相互关系的数据类型。你应该了解的四个主要集合数据结构是映射(Maps)、集合(Sets)、向量(Vectors)和列表(Lists)。还有更多可供选择的数据结构,包括由你的宿主平台(例如 Java 或 JavaScript)或其他库提供的数据结构,但那四个是你在 Clojure 中做事的基础。
"数据为王。如果你选择了正确的数据结构并且组织得当,算法几乎总是显而易见的。数据结构,而非算法,是编程的核心。" - Rob Pike 的编程规则第 5 条。
映射(Maps)
映射(Map)是一组键值对。Clojure 以持久和不可变的方式提供了常规的 HashMap,同时也提供了 SortedMap。
HashMap 被称为“Hash”,因为它们会对键创建哈希并映射到给定的值。查找以及其他常见操作(如insert和delete)都很快。
HashMap 在 Clojure 中用得很多,特别是在需要将一些属性关联到一些值来表示实体时。SortedMap 与 HashMap 不同,因为它们保留了键的顺序;否则,它们具有相同的接口,并且以相同的方式使用。SortedMap 并不常见,所以让我们专注于 HashMap。
你可以使用花括号字面量语法来创建 HashMap。以下是一个包含三个键值对的映射,键是:artist、:song和:year关键字:
user=> {:artist "David Bowtie" :song "The Man Who Mapped the World" :year 1970}
{:artist "David Bowtie", :song "The Man Who Mapped the World", :year 1970}
你可能已经注意到在前面的例子中,映射中的键值对是通过空格分隔的,但 Clojure 会对其进行评估,并返回一个用逗号分隔键值对的映射。与其他集合一样,你可以选择使用空格或逗号来分隔每个条目。对于映射,没有最佳实践,如果你认为使用逗号可以提高映射的可读性,那么就使用逗号;否则,简单地省略它们。你也可以使用换行符来分隔条目。
这里还有一个用逗号分隔的条目编写的映射:
user=> {:artist "David Bowtie", :song "Comma Oddity", :year 1969}
{:artist "David Bowtie", :song "Comma Oddity", :year 1969}
注意,值可以是任何类型,而不仅仅是简单的字符串和数字,还可以是向量甚至是其他映射,允许你创建嵌套数据结构并按如下方式组织信息:
user=>
{
"David Bowtie" {
"The Man Who Mapped the World" {:year 1970, :duration "4:01"}
"Comma Oddity" {:year 1969, :duration "5:19"}
}
"Crosby Stills Hash" {
"Helplessly Mapping" {:year 1969, :duration "2:38"}
"Almost Cut My Hair" {:year 1970, :duration "4:29", :featuring ["Neil Young", "Rich Hickey"]}
}
}
{"David Bowtie" {"The Man Who Mapped the World" {:year 1970, :duration "4:01"}, "Comma Oddity" {:year 1969, :duration "5:19"}}, "Crosby Stills Hash" {"Helplessly Mapping" {:year 1969, :duration "2:38"}, "Almost Cut My Hair" {:year 1970, :duration "4:29", :featuring ["Neil Young" "Rich Hickey"]}}}
键也可以是不同类型,所以你可以有字符串、数字,甚至是其他类型的键;然而,我们通常使用关键字。
创建映射的另一种方式是使用hash-map函数,如下传递参数对:
user=> (hash-map :a 1 :b 2 :c 3)
{:c 3, :b 2, :a 1}
当可能时,选择使用花括号字面量语法,但当 HashMap 是程序生成时,hash-map函数会很有用。
映射键是唯一的:
user=> {:name "Lucy" :age 32 :name "Jon"}
Syntax error reading source at (REPL:6:35).
Duplicate key: :name
由于前一个字面量映射中:name键出现了两次,因此抛出了一个异常。
然而,不同的键可以有相同的值:
user=> {:name "Lucy" :age 32 :number-of-teeth 32}
{:name "Lucy", :age 32, :number-of-teeth 32}
注意到age和number-of-teeth的值相同,这至少是有效且方便的。
既然你已经知道了如何创建映射,那么是时候进行一些实践了。
练习 2.02:使用映射
在这个练习中,我们将学习如何访问和修改简单的映射:
-
启动你的 REPL 并创建一个映射:
user=> (def favorite-fruit {:name "Kiwi", :color "Green", :kcal_per_100g 61 :distinguish_mark "Hairy"}) #'user/favorite-fruit -
你可以使用
get函数从映射中读取条目。尝试查找一个或两个键,如下所示:user=> (get favorite-fruit :name) "Kiwi" user=> (get favorite-fruit :color) "Green" -
如果找不到给定键的值,
get会返回nil,但你可以在get的第三个参数中指定一个回退值:user=> (get favorite-fruit :taste) nil user=> (get favorite-fruit :taste "Very good 8/10") "Very good 8/10" user=> (get favorite-fruit :kcal_per_100g 0) 61 -
映射和关键字有特殊的能力可以作为函数使用。当它们位于“操作位置”(列表的第一个项目)时,它们会被调用为一个函数,可以用来在映射中查找值。现在尝试使用
favorite-fruit映射作为函数来试试看:user=> (favorite-fruit :color) "Green" -
尝试使用关键字作为函数在映射中查找值:
user=> (:color favorite-fruit) "Green"与
get函数一样,当找不到键时,这些获取值的方式会返回nil,并且你可以传递一个额外的参数来提供一个回退值。 -
为
favorite-fruit映射中不存在的键提供一个回退值:user=> (:shape favorite-fruit "egg-like") "egg-like" -
我们希望将这个值存储在映射中。使用
assoc将新的键:shape与新的值"egg-like"关联到我们的映射中:user=> (assoc favorite-fruit :shape "egg-like") {:name "Kiwi", :color "Green", :kcal_per_100g 61, :distinguish_mark "Hairy", :shape "egg-like"}assoc操作返回一个新的映射,包含我们之前的关键值对以及我们刚刚添加的新关联。 -
评估
favorite-fruit并注意它保持不变:user=> favorite-fruit {:name "Kiwi", :color "Green", :kcal_per_100g 61, :distinguish_mark "Hairy"}由于映射是不可变的,绑定到
favorite-fruit符号的值没有改变。通过使用assoc,我们创建了一个新的映射版本。现在,F3C(“Funny Fruity Fruits Consortium”)已经撤销了之前的裁决,并在他们对水果规格的季度审查中确定,奇异果的颜色应该是棕色而不是绿色。为了确保您的应用程序符合 F3C 标准,您决定使用新值更新您的系统。
-
通过将新的值关联到
:color键来改变favorite-fruit的颜色:user=> (assoc favorite-fruit :color "Brown") {:name "Kiwi", :color "Brown", :kcal_per_100g 61, :distinguish_mark "Hairy"}当一个键已经存在时,
assoc会替换现有的值,因为 HashMaps 不能有重复的键。 -
如果我们想要添加更多结构化信息,我们可以添加一个映射作为值。在我们的
Kiwi映射中添加生产信息作为嵌套映射:user=> (assoc favorite-fruit :yearly_production_in_tonnes {:china 2025000 :italy 541000 :new_zealand 412000 :iran 311000 :chile 225000}) {:name "Kiwi", :color "Green", :kcal_per_100g 61, :distinguish_mark "Hairy", :yearly_production_in_tonnes {:china 2025000, :italy 541000, :new_zealand 412000, :iran 311000, :chile 225000}}使用嵌套映射或其他数据类型来表示结构化信息是常见的做法。
新的研究发现,奇异果所含的卡路里比之前认为的要少,为了保持合规,F3C 要求组织将每 100 克卡路里的当前值减少 1。
-
使用
assoc函数按如下方式递减kcal_per_100g:user=> (assoc favorite-fruit :kcal_per_100g (- (:kcal_per_100g favorite-fruit) 1)) {:name "Kiwi", :color "Green", :kcal_per_100g 60, :distinguish_mark "Hairy"}太棒了!它工作了,但还有更优雅的方式来处理这类操作。当你需要根据之前的值在映射中更改一个值时,你可以使用
update函数。虽然assoc函数允许你将一个全新的值关联到一个键上,但update允许你根据键的先前值计算一个新的值。update函数接受一个函数作为其第三个参数。 -
使用
update函数和dec递减kcal_per_100g,如下所示:user=> (update favorite-fruit :kcal_per_100g dec) {:name "Kiwi", :color "Green", :kcal_per_100g 60, :distinguish_mark "Hairy"}注意
:kcal_per_100g的值是如何从61变为60的。 -
你也可以向提供的更新函数传递参数;例如,如果我们想将
:kcal_per_100g降低 10 而不是 1,我们可以使用减法函数-,并编写以下内容:user=> (update favorite-fruit :kcal_per_100g - 10) {:name "Kiwi", :color "Green", :kcal_per_100g 51, :distinguish_mark "Hairy"}与
assoc类似,update不会改变不可变的映射;它返回一个新的映射。这个例子说明了函数作为“一等公民”的强大功能:我们像对待典型值一样对待它们;在这种情况下,一个函数被作为参数传递给另一个函数。我们将在下一章中详细介绍这个概念,同时更深入地探讨函数。
-
最后,使用
dissoc(意为“分离”)从映射中删除一个或多个元素:user=> (dissoc favorite-fruit :distinguish_mark) {:name "Kiwi", :color "Green", :kcal_per_100g 61} user=> (dissoc favorite-fruit :kcal_per_100g :color) {:name "Kiwi", :distinguish_mark "Hairy"}
干得好!现在我们知道了如何使用映射,是时候转向下一个数据结构:集合。
集合
集合是一组唯一的值。Clojure 提供了 HashSet 和 SortedSet。HashSet 的实现是作为 HashMap,其中每个条目的键和值是相同的。
哈希集合在 Clojure 中相当常见,有花括号表示的哈希字面表示法,例如:
user=> #{1 2 3 4 5}
#{1 4 3 2 5}
在前面的表达式中,注意当集合被评估时,它不会以在字面表达式中定义的顺序返回集合的元素。这是因为 HashSet 的内部结构。值被转换为一个唯一的哈希,这允许快速访问但不保留插入顺序。如果您关心元素添加的顺序,则需要使用不同的数据结构,例如,一个向量(我们很快就会了解到)。使用 HashSet 来表示逻辑上属于一起的元素,例如,唯一值的枚举。
与映射一样,集合不能有重复条目:
user=> #{:a :a :b :c}
Syntax error reading source at (REPL:135:15).
Duplicate key: :a
可以通过将值传递给 hash-set 函数从值列表创建哈希集合:
user=> (hash-set :a :b :c :d)
#{:c :b :d :a}
哈希集合也可以通过 set 函数从另一个集合创建。让我们从一个向量创建一个 HashSet:
user=> (set [:a :b :c])
#{:c :b :a}
注意向量中定义的顺序丢失了。
set 函数在将一组非唯一值转换为使用 set 函数创建的集合时不会抛出错误,这对于去重值很有用:
user=> (set ["No" "Copy" "Cats" "Cats" "Please"])
#{"Copy" "Please" "Cats" "No"}
注意其中一个重复的字符串 "Cats" 是如何被静默移除以创建集合的。
可以使用 sorted-set 函数创建有序集合,并且没有像哈希集合那样的字面语法:
user=> (sorted-set "No" "Copy" "Cats" "Cats" "Please")
#{"Cats" "Copy" "No" "Please"}
注意它们是以与哈希集合相同的方式打印的,只是顺序看起来不同。有序集合是根据它们包含的元素的自然顺序而不是创建时提供的参数顺序进行排序的。您也可以提供自己的排序函数,但我们将专注于哈希集合,因为它们更常见且更有用。
练习 2.03:使用集合
在这个练习中,我们将使用哈希集合来表示支持的货币集合:
注意
对于货币列表,哈希集合是一个很好的数据结构选择,因为我们通常希望存储一个唯一值的集合并高效地检查包含。此外,货币的顺序可能并不重要。如果您想将更多数据与货币关联(例如 ISO 代码和国家),那么您更有可能使用嵌套映射来表示每个货币作为一个实体,以唯一的 ISO 代码作为键。最终,数据结构的选择取决于您计划如何使用数据。在这个练习中,我们只想读取它,检查包含,并向我们的集合添加项目。
-
启动一个 REPL。创建一个集合并将其绑定到
supported-currencies符号:user=> (def supported-currencies #{"Dollar" "Japanese yen" "Euro" "Indian rupee" "British pound"}) #'user/supported-currencies -
与映射一样,您可以使用
get从集合中检索条目,当条目存在于集合中时返回作为参数传递的条目。使用get来检索现有条目以及缺失条目:user=> (get supported-currencies "Dollar") "Dollar" user=> (get supported-currencies "Swiss franc") nil -
很可能您只想检查包含,因此
contains?在语义上更好。使用contains?而不是get来检查包含:user=> (contains? supported-currencies "Dollar") true user=> (contains? supported-currencies "Swiss franc") false注意到
contains?返回一个布尔值,而get返回查找值或nil(当未找到时)。在集合中查找nil的边缘情况将返回nil,无论是找到还是未找到。在这种情况下,contains?自然更合适。 -
与映射、集合和关键字一样,可以使用它们作为函数来检查包含。使用
supported-currencies集合作为函数在集合中查找值:user=> (supported-currencies "Swiss franc") nil"瑞士法郎"不在supported-currencies集合中;因此,前面的返回值是nil。 -
如果你尝试使用
"Dollar"字符串作为一个函数在集合中查找自身,你会得到以下错误:user=> ("Dollar" supported-currencies) Execution error (ClassCastException) at user/eval7 (REPL:1). java.lang.String cannot be cast to clojure.lang.IFn我们不能使用字符串作为函数在集合或映射中查找值。这是为什么在可能的情况下,关键字在集合和映射中是一个更好的选择之一。
-
要向集合中添加一个条目,请使用
conj函数,就像 "conjoin" 一样:user=> (conj supported-currencies "Monopoly Money") #{"Japanese yen" "Euro" "Dollar" "Monopoly Money" "Indian rupee" "British pound"} -
你可以向
conj函数传递多个项目。尝试向我们的哈希集合添加多个货币:user=> (conj supported-currencies "Monopoly Money" "Gold dragon" "Gil") #{"Japanese yen" "Euro" "Dollar" "Monopoly Money" "Indian rupee" "Gold dragon" "British pound" "Gil"} -
最后,你可以使用
disj函数移除一个或多个项目,就像 "disjoin" 一样:user=> (disj supported-currencies "Dollar" "British pound") #{"Japanese yen" "Euro" "Indian rupee"}
集合的部分就到这里!如果你需要,你可以在 clojure.set 命名空间中找到更多用于处理集合的函数(例如并集和交集),但这是一种更高级的使用方式,所以让我们继续到下一个集合:向量。
向量
向量是 Clojure 中广泛使用的一种集合类型。你可以将向量视为功能强大的不可变数组。它们是可以通过它们的整数索引(从 0 开始)高效访问的值集合,并且它们保持项目插入的顺序以及重复项。
当你需要按顺序存储和读取元素,并且不介意重复元素时,请使用向量。例如,网页浏览器的历史记录可能是一个不错的选择,因为你可能希望轻松地返回到最近的页面,但也可以使用向量的索引删除较旧的元素,并且其中可能存在重复的元素。在这种情况下,映射或集合不会有很大帮助,因为你没有特定的键来通过它查找值。
向量有方括号([])的文本表示法:
user=> [1 2 3]
[1 2 3]
向量也可以通过 vector 函数后跟一个项目列表作为参数来创建:
user=> (vector 10 15 2 15 0)
[10 15 2 15 0]
你可以使用 vec 函数从另一个集合创建一个向量;例如,以下表达式将哈希集合转换为向量:
user=> (vec #{1 2 3})
[1 3 2]
与其他集合一样,向量也可以包含不同类型的值:
user=> [nil :keyword "String" {:answers [:yep :nope]}]
[nil :keyword "String" {:answers [:yep :nope]}]
我们现在可以开始练习了。
练习 2.04:使用向量
在这个练习中,我们将发现访问和交互向量的不同方式:
-
启动一个 REPL。你可以使用
get函数通过它们的索引(即它们在集合中的位置)在向量中查找值。尝试使用get函数与一个字面量向量一起使用:user=> (get [:a :b :c] 0) :a user=> (get [:a :b :c] 2) :c user=> (get [:a :b :c] 10) nil因为向量从 0 索引开始,
:a在索引 0,:c在索引 2。当查找失败时,get返回nil。 -
让我们将一个向量绑定到一个符号上,使练习更加方便:
user=> (def fibonacci [0 1 1 2 3 5 8]) #'user/fibonacci user=> (get fibonacci 6) 8 -
与映射和集合一样,你可以将向量用作函数来查找项,但对于向量,参数是向量中值的索引:
user=> (fibonacci 6) 8 -
使用
conj函数将斐波那契序列的下一个两个值添加到你的向量中:user=> (conj fibonacci 13 21) [0 1 1 2 3 5 8 13 21]注意,项被添加到向量的末尾,序列的顺序保持不变。
-
斐波那契序列中的每个项都对应于前两个项的和。让我们动态地计算序列的下一个项:
user=> (let [size (count fibonacci) last-number (last fibonacci) second-to-last-number (fibonacci (- size 2))] (conj fibonacci (+ last-number second-to-last-number))) [0 1 1 2 3 5 8 13]在前面的例子中,我们使用
let创建了三个局部绑定并提高了可读性。我们使用count来计算向量的大小,last来检索其最后一个元素8,最后,我们使用fibonacci向量作为函数来检索索引 "size - 2" 处的元素(这是索引5处的值5)。
在 let 块的主体中,我们使用局部绑定通过 conj 将斐波那契序列的最后两个项添加到末尾,它返回 13(实际上,这是 5 + 8)。
列表
列表是顺序集合,类似于向量,但项被添加到前面(在开始处)。此外,它们没有相同的性能属性,并且通过索引的随机访问比向量慢。我们主要使用列表来编写代码和宏,或者在需要后进先出(LIFO)类型的数据结构(例如,栈)的情况下,这也可以用向量实现。
我们使用字面语法 () 创建列表,但为了区分代表代码的列表和代表数据的列表,我们需要使用单引号 ':
user=> (1 2 3)
Execution error (ClassCastException) at user/eval211 (REPL:1).
java.lang.Long cannot be cast to clojure.lang.IFn
user=> '(1 2 3)
(1 2 3)
user=> (+ 1 2 3)
6
user=> '(+ 1 2 3)
(+ 1 2 3)
在前面的例子中,我们可以看到,没有用 ' 引用的列表会抛出错误,除非列表的第一个项可以被调用为一个函数。
列表也可以通过 list 函数来创建:
user=> (list :a :b :c)
(:a :b :c)
要读取列表的第一个元素,请使用 first:
user=> (first '(:a :b :c :d))
:a
rest 函数返回不包含第一个元素的列表:
user=> (rest '(:a :b :c :d))
(:b :c :d)
我们现在还不讨论迭代和递归,但你可以想象,first 和 rest 的组合就是你所需要的“遍历”或通过整个列表的方法:只需反复对列表的其余部分调用 first,直到没有剩余部分。
你不能使用 get 函数与列表一起通过索引检索。你可以使用 nth,但它效率不高,因为列表会被迭代或“遍历”直到达到所需的位置:
user=> (nth '(:a :b :c :d) 2)
:c
练习 2.05:使用列表
在这个练习中,我们将通过读取和添加元素到待办事项列表来练习使用列表。
-
启动一个交互式解释器(REPL),并使用
list函数创建一个待办事项列表,列出你需要执行的操作,如下所示:user=> (def my-todo (list "Feed the cat" "Clean the bathroom" "Save the world")) #'user/my-todo -
你可以通过使用
cons函数向你的列表中添加项,该函数作用于序列:user=> (cons "Go to work" my-todo) ("Go to work" "Feed the cat" "Clean the bathroom" "Save the world") -
类似地,你可以使用
conj函数,因为它用于列表,这是一个集合:user=> (conj my-todo "Go to work") ("Go to work" "Feed the cat" "Clean the bathroom" "Save the world")注意参数的顺序是如何不同的。
cons在列表上是可用的,因为列表是一个序列,conj在列表上也是可用的,因为列表是一个集合。因此,conj稍微更“通用”,并且也有接受多个参数作为论据的优势。 -
通过使用
conj函数一次添加多个元素到你的列表中:user=> (conj my-todo "Go to work" "Wash my socks") ("Wash my socks" "Go to work" "Feed the cat" "Clean the bathroom" "Save the world") -
现在是时候赶上你的任务了。使用
first函数检索你的待办列表中的第一个元素:user=> (first my-todo) "Feed the cat" -
完成后,你可以使用
rest函数检索你剩余的任务:user=> (rest my-todo) ("Clean the bathroom" "Save the world")你可以想象,如果你需要调用
first来获取列表的其余部分(如果你需要开发一个完整的待办事项列表应用)。因为列表是不可变的,如果你一直对同一个my-todo列表调用first,你将反复得到相同的元素,即"Feed the cat",并且还会得到一只快乐但非常胖的猫。 -
最后,你也可以使用
nth函数从列表中检索特定元素:user=> (nth my-todo 2) "Save the world"然而,记住,在列表中检索特定位置的元素比在向量中慢,因为列表必须“遍历”直到
nth元素。在这种情况下,你可能更愿意使用向量。关于nth的最后一个注意事项是,当在位置 n 处找不到元素时,它会抛出一个异常。
目前你只需要了解这么多关于列表的知识,我们可以继续到下一节,关于集合和序列抽象。
集合和序列抽象
Clojure 的数据结构是用强大的抽象实现的。你可能已经注意到,我们在集合上使用的操作通常是相似的,但根据集合的类型表现不同。例如,get 使用键从映射中检索项目,但从向量中使用索引;conj 在向量的末尾添加元素,但在列表的开头添加。
序列是元素按特定顺序排列的集合,其中每个项目都跟随另一个项目。映射、集合、向量和列表都是集合,但只有向量和列表是序列,尽管我们可以轻松地从映射或集合中获得序列。
让我们来看一些与集合一起使用的有用函数的例子。考虑以下映射:
user=> (def language {:name "Clojure" :creator "Rich Hickey" :platforms ["Java" "JavaScript" ".NET"]})
#'user/language
使用 count 来获取集合中的元素数量。这个映射的每个元素都是一个键值对;因此,它包含三个元素:
user=> (count language)
3
更明显的是,以下集合不包含任何元素:
user=> (count #{})
0
我们可以使用 empty? 函数来测试集合是否为空:
user=> (empty? language)
false
user=> (empty? [])
true
映射不是顺序的,因为其元素之间没有逻辑顺序。然而,我们可以使用 seq 函数将映射转换为序列:
user=> (seq language)
([:name "Clojure"] [:creator "Rich Hickey"] [:platforms ["Java" "JavaScript" ".NET"]])
它生成了一个向量或 元组 的列表,这意味着现在有一个逻辑顺序,我们可以在这个数据结构上使用序列函数:
user=> (nth (seq language) 1)
[:creator "Rich Hickey"]
许多函数直接在集合上工作,因为它们可以被转换成序列,所以你可以省略 seq 步骤,例如,直接在映射或集合上调用 first、rest 或 last:
user=> (first #{:a :b :c})
:c
user=> (rest #{:a :b :c})
(:b :a)
user=> (last language)
[:platforms ["Java" "JavaScript" ".NET"]]
在映射和集合上使用序列函数,如 first 或 rest 的价值似乎值得怀疑,但将这些集合视为序列意味着它们可以被迭代。还有更多用于处理序列每个项目的函数可用,例如 map、reduce、filter 等。我们在书的第二部分中专门用整个章节来学习这些内容,这样我们就可以现在专注于其他核心函数。
into 是另一个有用的操作符,它将一个集合的元素放入另一个集合中。into 的第一个参数是目标集合:
user=> (into [1 2 3 4] #{5 6 7 8})
[1 2 3 4 7 6 5 8]
在前面的例子中,#{5 6 7 8} 集合的每个元素都被添加到了 [1 2 3 4] 向量中。由于哈希集合未排序,因此得到的向量不是升序的:
user=> (into #{1 2 3 4} [5 6 7 8])
#{7 1 4 6 3 2 5 8}
在前面的例子中,[5 6 7 8] 向量被添加到了 #{1 2 3 4} 集合中。再次强调,哈希集合不保持插入顺序,因此得到的集合仅仅是唯一值的逻辑集合。
一个使用示例,例如,要去除向量的重复项,只需将其放入一个集合中:
user=> (into #{} [1 2 3 3 3 4])
#{1 4 3 2}
要将项目放入映射中,你需要传递一个表示键值对的元组的集合:
user=> (into {} [[:a 1] [:b 2] [:c 3]])
{:a 1, :b 2, :c 3}
每个项目都在集合中“连接”起来,因此它遵循目标集合的语义,使用 conj 插入项目。元素被添加到列表的前端:
user=> (into '() [1 2 3 4])
(4 3 2 1)
为了帮助你理解 (into '() [1 2 3 4]),这里是一个逐步表示发生了什么的示例:
user=> (conj '() 1)
(1)
user=> (conj '(1) 2)
(2 1)
user=> (conj '(2 1) 3)
(3 2 1)
user=> (conj '(3 2 1) 4)
(4 3 2 1)
如果你想要连接集合,concat 可能比 into 更合适。看看它们在这里是如何表现不同的:
user=> (concat '(1 2) '(3 4))
(1 2 3 4)
user=> (into '(1 2) '(3 4))
(4 3 1 2)
许多 Clojure 函数在处理序列时,无论输入类型如何,都会返回序列。concat 是一个例子:
user=> (concat #{1 2 3} #{1 2 3 4})
(1 3 2 1 4 3 2)
user=> (concat {:a 1} ["Hello"])
([:a 1] "Hello")
sort 是另一个例子。sort 可以重新排列一个集合以对元素进行排序。它有一个好处,就是为什么你想要一个序列作为结果的原因稍微明显一些:
user=> (def alphabet #{:a :b :c :d :e :f})
#'user/alphabet
user=> alphabet
#{:e :c :b :d :f :a}
user=> (sort alphabet)
(:a :b :c :d :e :f)
user=> (sort [3 7 5 1 9])
(1 3 5 7 9)
但如果你想要一个向量作为结果呢?嗯,现在你知道你可以使用 into 函数:
user=> (sort [3 7 5 1 9])
(1 3 5 7 9)
user=> (into [] *1)
[1 3 5 7 9]
值得注意的是,conj 也可以用于映射。为了使其参数与其他类型的集合保持一致,新条目由一个元组表示:
user=> (conj language [:created 2007])
{:name "Clojure", :creator "Rich Hickey", :platforms ["Java" "JavaScript" ".NET"], :created 2007}
同样,向量是一个关联的键值对集合,其中键是值的索引:
user=> (assoc [:a :b :c :d] 2 :z)
[:a :b :z :d]
练习 2.06:处理嵌套数据结构
为了这个练习的目的,想象你正在一家名为“Sparkling”的小店里工作,这家店的业务是交易宝石。结果,店主对 Clojure 有点了解,并且一直在使用 Clojure REPL 和一些自制的数据库来管理库存。然而,店主在与嵌套数据结构打交道时一直很吃力,他们需要专业人士的帮助:你。商店不会分享他们的数据库,因为它包含敏感数据——他们只给你提供了一个样本数据集,这样你就知道数据的形状了。
店主在网上读了一篇关于纯函数很棒并且能写出高质量代码的博客文章。因此,他们要求你开发一些纯函数,这些函数将宝石数据库作为每个函数的第一个参数。店主说,只有当你提供纯函数时,你才会得到报酬。在这个练习中,我们将开发几个函数,帮助我们理解和操作嵌套数据结构。
注意
纯函数是一个函数,其返回值仅由其输入值决定。纯函数没有副作用,这意味着它不会改变程序的状态,也不会生成任何类型的 I/O。
-
打开一个 REPL 并创建以下代表样本宝石数据库的哈希表:
repl.clj 1 (def gemstone-db { 2 :ruby { 3 :name "Ruby" 4 :stock 480 5 :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712] 6 :properties { 7 :dispersion 0.018 8 :hardness 9.0 9 :refractive-index [1.77 1.78] 10 :color "Red" 11 } 12 } The complete code for this snippet can be found at https://packt.live/3aD8MgL店铺最常收到顾客询问的问题之一是关于宝石的耐久性。这可以在宝石的属性中找到,在
:hardness键中。我们需要开发的第一个函数是durability,它检索给定宝石的硬度。 -
让我们从使用我们已知的函数
get,以:ruby宝石为例开始:user=> (get (get (get gemstone-db :ruby) :properties) :hardness) 9.0它可以工作,但是嵌套
get并不优雅。我们可以使用 map 或关键字作为函数,看看它如何提高可读性。 -
使用关键字作为函数来查看它如何提高我们代码的可读性:
user=> (:hardness (:properties (:ruby gemstone-db))) 9.0这稍微好一些。但仍然有很多嵌套调用和括号。肯定还有更好的方法!
当你需要从像这样深层嵌套的映射中获取数据时,请使用
get-in函数。它接受一个键的向量作为参数,并通过一个函数调用在映射中挖掘。 -
使用
get-in函数和[:ruby :properties :hardness]参数向量来检索深层嵌套的:hardness键:user=> (get-in gemstone-db [:ruby :properties :hardness]) 9.0太棒了!键的向量是从左到右读取的,并且没有嵌套表达式。这将使我们的函数更加易于阅读。
-
创建一个
durability函数,它接受数据库和gem关键字作为参数,并返回hardness属性的值:user=> (defn durability [db gemstone] (get-in db [gemstone :properties :hardness])) #'user/durability -
测试你新创建的函数以确保它按预期工作:
user=> (durability gemstone-db :ruby) 9.0 user=> (durability gemstone-db :moissanite) 9.5太棒了!让我们继续下一个函数。
显然,红宝石不仅仅是“红色”,而是从“几乎无色到粉红色,再到所有深浅不一的红色,直至深红。”谁能想到呢?现在,宝石的主人要求你创建一个函数来更新宝石的颜色,因为他们可能还想改变其他颜色,以用于市场营销。这个函数需要返回更新后的数据库。
-
让我们尝试编写代码来更改宝石的颜色属性。我们可以尝试使用
assoc:user=> (assoc (:ruby gemstone-db) :properties {:color "Near colorless through pink through all shades of red to a deep crimson"}) {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:color "Near colorless through pink through all shades of red to a deep crimson"}}它似乎工作得很好,但所有其他属性都消失了!我们用只包含一个条目(颜色)的新哈希表替换了现有的键属性哈希表。
-
我们可以采用一个技巧。你还记得
into函数吗?它接受一个集合,并将它的值放入另一个集合中,如下所示:user=> (into {:a 1 :b 2} {:c 3}) {:a 1, :b 2, :c 3}如果我们使用
update函数与into结合,我们可以得到期望的结果。 -
尝试使用
update与into结合来更改红宝石的:color属性:user=> (update (:ruby gemstone-db) :properties into {:color "Near colorless through pink through all shades of red to a deep crimson"}) {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:dispersion 0.018, :hardness 9.0, :refractive-index [1.77 1.78], :color "Near colorless through pink through all shades of red to a deep crimson"}}这很好,但这种方法有两个问题。首先,
update和into的组合不太易于阅读或理解。其次,我们原本想要返回整个数据库,但我们只返回了"Ruby"条目。我们可能需要添加另一个操作来更新主数据库中的这个条目,可能通过嵌套另一个into,这会进一步降低可读性。与
get-in类似,Clojure 提供了一种更简单的方式来处理嵌套映射:assoc-in和update-in。它们的工作方式类似于assoc和update,但它们接受一个键的向量(如get-in)作为参数,而不是单个键。当你想使用一个函数来更新深层嵌套的值时,你会使用
update-in(例如,用前一个值来计算新值)。在这里,我们只是想用完全新的值替换颜色,所以我们应该使用assoc-in。 -
使用
assoc-in来更改红宝石宝石的color属性:user=> (assoc-in gemstone-db [:ruby :properties :color] "Near colorless through pink through all shades of red to a deep crimson") {:ruby {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:dispersion 0.018, :hardness 9.0, :refractive-index [1.77 1.78], :color "Near colorless through pink through all shades of red to a deep crimson"}}, :emerald {:name "Emerald", :stock 85, :sales [6605 2373 104 4764 9023], :properties {:dispersion 0.014, :hardness 7.5, :refractive-index [1.57 1.58], :color "Green shades to colorless"}}, :diamond {:name "Diamond", :stock 10, :sales [8295 329 5960 6118 4189 3436 9833 8870 9700 7182 7061 1579], :properties {:dispersion 0.044, :hardness 10, :refractive-index [2.417 2.419], :color "Typically yellow, brown or gray to colorless"}}, :moissanite {:name "Moissanite", :stock 45, :sales [7761 3220], :properties {:dispersion 0.104, :hardness 9.5, :refractive-index [2.65 2.69], :color "Colorless, green, yellow"}}}注意
gemstone-db是如何完全返回的。你能注意到变化了哪个值吗?因为有大量数据,所以这并不明显。你可以使用pprint函数来“美化打印”这个值。使用
pprint对最后一个返回值进行格式化,以提高可读性并确保我们的assoc-in表达式按预期工作。在 REPL 中,可以使用*1获取最后一个返回值:![图 2.1:将输出打印到 REPL]()
图 2.1:将输出打印到 REPL
这样就更加易于阅读。我们不会在所有地方都使用
pprint,因为它会占用很多额外的空间,但你应该使用它。 -
创建一个
change-color纯函数,它接受三个参数:数据库、宝石关键字和新的颜色。这个函数更新给定数据库中的颜色并返回数据库的新值:user=> (defn change-color [db gemstone new-color] (assoc-in gemstone-db [gemstone :properties :color] new-color)) #'user/change-color -
测试你新创建的函数是否按预期工作:
user=> (change-color gemstone-db :ruby "Some kind of red") {:ruby {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:dispersion 0.018, :hardness 9.0, :refractive-index [1.77 1.78], :color "Some kind of red"}}, :emerald {:name "Emerald", :stock 85, :sales [6605 2373 104 4764 9023], :properties {:dispersion 0.014, :hardness 7.5, :refractive-index [1.57 1.58], :color "Green shades to colorless"}}, :diamond {:name "Diamond", :stock 10, :sales [8295 329 5960 6118 4189 3436 9833 8870 9700 7182 7061 1579], :properties {:dispersion 0.044, :hardness 10, :refractive-index [2.417 2.419], :color "Typically yellow, brown or gray to colorless"}}, :moissanite {:name "Moissanite", :stock 45, :sales [7761 3220], :properties {:dispersion 0.104, :hardness 9.5, :refractive-index [2.65 2.69], :color "Colorless, green, yellow"}}}宝石的主人希望添加一个最后的函数来记录宝石的销售并相应地更新库存。
当发生销售时,店主会希望用以下参数调用
sell函数:数据库、宝石关键字和客户 ID。client-id将被插入到sales向量中,并且该宝石的stock值将减少一个。与其他函数一样,数据库的新值将被返回,以便客户可以自己处理更新。 -
我们可以使用
update-in函数结合dec来减少(减少一个)库存。让我们用钻石宝石试试:user=> (update-in gemstone-db [:diamond :stock] dec) {:ruby {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:dispersion 0.018, :hardness 9.0, :refractive-index [1.77 1.78], :color "Near colorless through pink through all shades of red to a deep crimson"}}, :emerald {:name "Emerald", :stock 85, :sales [6605 2373 104 4764 9023], :properties {:dispersion 0.014, :hardness 7.5, :refractive-index [1.57 1.58], :color "Green shades to colorless"}}, :diamond {:name "Diamond", :stock 9, :sales [8295 329 5960 6118 4189 3436 9833 8870 9700 7182 7061 1579], :properties {:dispersion 0.044, :hardness 10, :refractive-index [2.417 2.419], :color "Typically yellow, brown or gray to colorless"}}, :moissanite {:name "Moissanite", :stock 45, :sales [7761 3220], :properties {:dispersion 0.104, :hardness 9.5, :refractive-index [2.65 2.69], :color "Colorless, green, yellow"}}}输出不太易读,很难验证值是否已正确更新。另一个在 REPL 中提高可读性的有用命令是
*print-level*选项,它可以限制打印到终端的数据结构的深度。 -
使用
*print-level*选项将深度级别设置为2,并观察结果是如何打印的:user=> (set! *print-level* 2) 2 user=> (update-in gemstone-db [:diamond :stock] dec) {:ruby {:name "Ruby", :stock 120, :sales #, :properties #}, :emerald {:name "Emerald", :stock 85, :sales #, :properties #}, :diamond {:name "Diamond", :stock 9, :sales #, :properties #}, :moissanite {:name "Moissanite", :stock 45, :sales #, :properties #}}钻石的库存确实减少了 1,从 10 减少到 9。
-
我们可以再次使用
update-in函数,这次结合conj和client-id来向sales向量中添加内容。让我们用一个例子来试试,使用钻石宝石和client-id 999:user=> (update-in gemstone-db [:diamond :sales] conj 999) {:ruby {:name "Ruby", :stock 120, :sales #, :properties #}, :emerald {:name "Emerald", :stock 85, :sales #, :properties #}, :diamond {:name "Diamond", :stock 10, :sales #, :properties #}, :moissanite {:name "Moissanite", :stock 45, :sales #, :properties #}}可能已经成功了,但我们无法看到
sales向量,因为数据已被*print-level*选项截断。 -
将
*print-level*设置为nil以重置选项,并重新评估之前的表达式:user=> (set! *print-level* nil) nil user=> (update-in gemstone-db [:diamond :sales] conj 999) {:ruby {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:dispersion 0.018, :hardness 9.0, :refractive-index [1.77 1.78], :color "Near colorless through pink through all shades of red to a deep crimson"}}, :emerald {:name "Emerald", :stock 85, :sales [6605 2373 104 4764 9023], :properties {:dispersion 0.014, :hardness 7.5, :refractive-index [1.57 1.58], :color "Green shades to colorless"}}, :diamond {:name "Diamond", :stock 10, :sales [8295 329 5960 6118 4189 3436 9833 8870 9700 7182 7061 1579 999], :properties {:dispersion 0.044, :hardness 10, :refractive-index [2.417 2.419], :color "Typically yellow, brown or gray to colorless"}}, :moissanite {:name "Moissanite", :stock 45, :sales [7761 3220], :properties {:dispersion 0.104, :hardness 9.5, :refractive-index [2.65 2.69], :color "Colorless, green, yellow"}}}注意,我们的钻石
sales向量现在包含了值999。 -
现在让我们编写我们的纯函数,它结合了两个操作(更新库存和客户):
(defn sell [db gemstone client-id] (let [clients-updated-db (update-in db [gemstone :sales] conj client-id)] (update-in clients-updated-db [gemstone :stock] dec))) -
通过将
:moissanite卖给client-id123来测试你新创建的函数:user=> (sell gemstone-db :moissanite 123) {:ruby {:name "Ruby", :stock 120, :sales [1990 3644 6376 4918 7882 6747 7495 8573 5097 1712], :properties {:dispersion 0.018, :hardness 9.0, :refractive-index [1.77 1.78], :color "Near colorless through pink through all shades of red to a deep crimson"}}, :emerald {:name "Emerald", :stock 85, :sales [6605 2373 104 4764 9023], :properties {:dispersion 0.014, :hardness 7.5, :refractive-index [1.57 1.58], :color "Green shades to colorless"}}, :diamond {:name "Diamond", :stock 10, :sales [8295 329 5960 6118 4189 3436 9833 8870 9700 7182 7061 1579], :properties {:dispersion 0.044, :hardness 10, :refractive-index [2.417 2.419], :color "Typically yellow, brown or gray to colorless"}}, :moissanite {:name "Moissanite", :stock 44, :sales [7761 3220 123], :properties {:dispersion 0.104, :hardness 9.5, :refractive-index [2.65 2.69], :color "Colorless, green, yellow"}}}
注意到钻石实体的 sales 向量现在包含了值 123。
在这个练习中,我们并没有真正“更新”数据,而是由于不可变性从其他数据结构中推导出新的数据结构。即使我们主要使用不可变数据类型,Clojure 也提供了简单的机制,允许你持久化信息。在接下来的活动中,你将创建一个可以使用本章获得的技术读取和更新的数据库,我们甚至会提供一个辅助函数来使数据库持久化。
活动 2.01:创建一个简单的内存数据库
在这个活动中,我们将创建我们自己的内存数据库实现。毕竟,如果“Sparkling”商店的老板能够做到,那么这对我们来说就不应该是一个问题!
我们的数据库接口将存在于 Clojure REPL 中。我们将实现创建和删除表,以及插入和读取记录的函数。
在这个活动中,我们将提供一些辅助函数来帮助你在内存中维护数据库的状态:
(def memory-db (atom {}))
(defn read-db [] @memory-db)
(defn write-db [new-db] (reset! memory-db new-db))
我们使用了一个 atom,但你现在不需要理解原子是如何工作的,因为它们将在本书的后面详细解释。你只需要知道它将保留对内存中数据库的引用,并使用两个辅助函数 read-db 和 write-db 来读取和持久化内存中的哈希表。
作为指导,我们希望数据结构具有以下形状:
{:table-1 {:data [] :indexes {}} :table-2 {:data [] :indexes {}}
例如,如果我们在一个杂货店中使用我们的数据库来保存客户、水果和购买信息,我们可以想象它将以这种方式包含数据:
{
:clients {
:data [{:id 1 :name "Bob" :age 30} {:id 2 :name "Alice" :age 24}]
:indexes {:id {1 0, 2 1}}
},
:fruits {
:data [{:name "Lemon" :stock 10} {:name "Coconut" :stock 3}]
:indexes {:name {"Lemon" 0, "Coconut" 1}}
},
:purchases {
:data [{:id 1 :user-id 1 :item "Coconut"} {:id 1 :user-id 2 :item "Lemon"}]
:indexes {:id {1 0, 2 1}}
}
}
将数据和索引分开存储允许在不复制实际数据的情况下创建多个索引。
indexes 映射存储了每个索引键与其在 data 向量中的位置的关联。在水果表中,“Lemon”是 data 向量的第一个记录,所以 :name 索引中的值是 0。
这些步骤将帮助你执行以下活动:
-
创建辅助函数。你可以通过不带参数执行
read-db函数来获取哈希表,并通过带哈希表作为参数执行write-db函数来写入数据库。 -
首先创建
create-table函数。此函数应接受一个参数:表名。它应在我们的哈希表数据库的根处添加一个新的键(表名),其值应是一个包含两个条目的另一个哈希表:data键处的空向量和indexes键处的空哈希表。 -
测试你的
create-table函数是否正常工作。 -
创建一个
drop-table函数,它接受一个参数——表名。它应该从我们的数据库中删除一个表,包括其所有数据和索引。 -
测试你的
drop-table函数是否正常工作。 -
创建一个
insert函数。此函数应接受三个参数:table、record和id-key。record参数是一个哈希表,id-key对应于记录映射中的一个键,该键将用作唯一索引。目前,我们不会处理当表不存在或索引键已经在给定表中存在的情况。尝试使用
let块将insert函数的工作分成多个步骤:在一个
let语句中,为使用read-db获取的数据库值创建一个绑定。在同一个
let语句中,为数据库的新值(在向data向量中添加记录后)创建第二个绑定。在同一个
let语句中,通过计算data向量中的元素数量来检索记录插入的索引。在
let语句的主体中,更新id-key的索引,并使用write-db将结果映射写入数据库。 -
为了验证你的
insert函数是否正常工作,尝试多次使用它来插入新记录。 -
创建一个
select-*函数,该函数将返回作为参数传递的表的全部记录。 -
创建一个
select-*-where函数,该函数接受三个参数:table-name、field和field-value。该函数应使用索引映射来检索数据向量中记录的索引,并返回该元素。 -
修改
insert函数以拒绝任何索引重复。当id-key记录已经在indexes映射中存在时,我们不应修改数据库,并向用户打印错误信息。在完成活动后,输出应该类似于这个:
user=> (create-table :fruits) {:clients {:data [], :indexes {}}, :fruits {:data [], :indexes {}}} user=> (insert :fruits {:name "Pear" :stock 3} :name) Record with :name Pear already exists. Aborting user=> (select-* :fruits) [{:name "Pear", :stock 3} {:name "Apricot", :stock 30} {:name "Grapefruit", :stock 6}] user=> (select-*-where :fruits :name "Apricot") {:name "Apricot", :stock 30}
在这个活动中,我们运用了关于读取和更新简单和深度嵌套数据结构的新知识,实现了一个简单的内存数据库。这并非易事——做得好!
注意
这个活动的解决方案可以在第 682 页找到。
摘要
在本章中,我们发现了不可变性的概念。我们学习了 Clojure 的简单数据类型,以及它们在不同宿主平台上的实现。我们发现了最常见的集合和序列类型:映射、集合、向量和列表。我们看到了如何使用它们进行通用的集合和序列操作。我们学习了如何读取和更新嵌套集合的复杂结构。我们还学习了使用集合数据结构的标准函数,以及深度嵌套数据结构的更高级用法。在下一章中,我们将学习处理函数的高级技术。
第三章:3. 深入了解函数
概述
在本章中,我们将深入探讨 Clojure 的函数。我们发现了解构技术和高级调用签名。我们更深入地研究了函数的一等特性,了解它如何实现函数组合,以及高级多态技术。本章教授的技术将显著提高您代码的简洁性和可读性。它为您准备本书的第二部分,即操作集合的部分奠定了坚实的基础。
到本章结束时,您将能够在编写函数时实现解构、可变参数和多方法等特性。
简介
Clojure 是一种函数式编程语言,对于 Clojure 程序员来说,函数具有根本的重要性。在函数式编程中,我们避免改变程序的状态,正如我们在上一章所看到的,Clojure 的不可变数据结构极大地促进了这一点。我们还倾向于用函数做所有事情,因此我们需要函数能够做几乎所有的事情。我们说 Clojure 函数是一等公民,因为我们可以将它们传递给其他函数,将它们存储在变量中,或者从其他函数中返回:我们也将它们称为一等函数。以一个电子商务应用程序为例,用户可以看到一个包含不同搜索过滤器和排序选项的商品列表。以命令式编程方式使用标志和条件开发这样的过滤引擎可能会迅速变得过于复杂;然而,使用函数式编程可以优雅地表达这一点。函数组合是一种简单而有效地实现此类过滤引擎的绝佳方式,对于每个过滤器(例如,商品的价格、颜色、尺寸等),逻辑可以包含在一个函数中,并且这些函数可以简单地组合或组合,当用户与界面交互时。
在本章中,您将学习如何掌握函数。我们将从解构技术开始,这些技术特别适用于函数参数,然后我们将继续探讨高级调用签名,包括具有多个可变参数和可变数量参数的函数。接着,我们将深入研究函数的一等特性,了解它们如何实现函数组合。最后,我们将解释使用多方法和解派函数的高级多态技术。
解构
解构允许您从结构中移除数据元素或分解结构。这是一种通过提供更好的工具来改进广泛使用模式的可读性和简洁性的技术。解构数据有两种主要方式:顺序性(使用向量)和关联性(使用映射)。
想象我们需要编写一个函数,根据坐标元组打印格式化的字符串,例如,坐标元组 [48.9615, 2.4372]。我们可以编写以下函数:
(defn print-coords [coords]
(let [lat (first coords)
lon (last coords)]
(println (str "Latitude: " lat " - " "Longitude: " lon))))
这个 print-coords 函数接受一个坐标元组作为参数,并以格式化的字符串形式将坐标打印到控制台,例如,纬度: 48.9615 – 经度: 2.4372。
当我们将第一个元素绑定到 lat 并将第二个元素绑定到 lon 时,我们实际上在进行解构:我们正在从它们的顺序数据结构中取出每个元素。这种用法非常常见,Clojure 提供了一种内置语法来解构数据结构并将它们的值绑定到符号。
我们可以用以下方式使用顺序解构技术重写 print-coords 函数:
(defn print-coords [coords]
(let [[lat lon] coords]
(println (str "Latitude: " lat " - " "Longitude: " lon))))
观察前面的例子,它比之前的例子更短、更简洁。我们不需要使用像 first 或 last 这样的函数,我们只需表达我们想要检索的符号。
这两个函数是等价的。lat 被映射到向量的第一个元素,而 lon 被映射到第二个元素。这个其他、更简单的例子可能更直观地有帮助:
user=>(let
;;
;; [1 2 3]
;; | | |
[[a b c] [1 2 3]] (println a b c))
1 2 3
nil
注意绑定是如何根据向量的顺序和定义在向量 [a b c] 中的符号顺序创建的。然后,符号值被打印到控制台。
列表,作为一种顺序数据结构,可以类似地分解:
user=> (let [[a b c] '(1 2 3)] (println a b c))
1 2 3
nil
考虑打印机场坐标的相同例子,但这次我们接收到的数据是一个映射而不是元组。数据具有以下形状:{:lat 48.9615, :lon 2.4372, :code 'LFPB', :name "Paris Le Bourget Airport"}。
我们可以编写以下函数:
(defn print-coords [airport]
(let [lat (:lat airport)
lon (:lon airport)
name (:name airport)]
(println (str name " is located at Latitude: " lat " - " "Longitude: " lon))))
这个函数通过在 let 表达式中使用关键字作为函数来从 airport 映射中检索值。我们可以看到在绑定 lat、lon 和 name 时出现的重复模式。同样,当我们想要分解的数据结构是关联的(一个 map)时,我们可以使用关联解构技术。函数可以用关联解构重写,如下所示:
(defn print-coords [airport]
(let [{lat :lat lon :lon airport-name :name} airport]
(println (str airport-name " is located at Latitude: " lat " - " "Longitude: " lon))))
使用这种技术,我们通过将符号映射到映射中的键来创建绑定。现在 lat 符号包含机场映射中 :lat 键的值,lon 被映射到 :lon 键,最后,airport-name 符号被映射到 :name 键。
当键和符号都可以有相同的名称时,有一个更短的语法可用:
(defn print-coords [airport]
(let [{:keys [lat lon name]} airport]
(println (str name " is located at Latitude: " lat " - " "Longitude: " lon))))
之前提到的解构语法表示在 airport 映射中查找 lat、lon 和 name 键,并将它们绑定到具有相同名称的符号。语法可能看起来有点令人惊讶,但这在 Clojure 中是一种广泛使用的技术。我们将在下一个练习中使用它,以便您可以学习如何使用它。
让我们看看我们的最终函数是如何工作的:
user=> (def airport {:lat 48.9615, :lon 2.4372, :code 'LFPB', :name "Paris Le Bourget Airport"})
#'user/airport
(defn print-coords [airport]
(let [{:keys [lat lon name]} airport]
(println (str name " is located at Latitude: " lat " - " "Longitude: " lon))))
#'user/print-coords
user=> (print-coords airport)
Paris Le Bourget Airport is located at Latitude: 48.9615 - Longitude: 2.4372
nil
在前面的例子中,print-coords 函数在 let 表达式中解构了机场 map,并将值 48.9615、2.4372 和 Paris Le Bourget Airport 分别绑定到符号(分别)lat、lon 和 name。然后,这些值通过 println 函数(它返回 nil)打印到控制台。
现在我们已经发现了解构的基础及其用途,我们可以继续学习到 REPL,开始练习,并学习更多高级的解构技术。
练习 3.01:使用顺序解构解析 Fly Vector 的数据
为了这个练习的目的,假设我们正在构建一个航班预订平台应用程序。对于我们的第一个原型,我们只想解析和打印我们从合作伙伴那里收到的航班数据。我们的第一个合作伙伴 Fly Vector 尚未发现关联数据结构的威力,他们以向量的形式发送给我们所有数据。幸运的是,他们有全面的文档。我已经为你阅读了数百页的数据格式规范,以下是总结:
-
坐标点是由纬度和经度组成的元组,例如:
[48.9615, 2.4372]。 -
航班是由两个坐标点组成的元组,例如:
[[48.9615, 2.4372], [37.742, -25.6976]]。 -
预订由一些信息后跟一个或多个航班(最多三个)组成。第一个项目是 Fly Vector 的预订内部 ID,第二个项目是乘客的名字,第三个是 Fly Vector 要求我们不要解析或甚至查看的敏感信息(他们无法更新他们的系统以删除信息)。最后,向量中的其余部分是航班坐标数据,例如:
[ 1425, "Bob Smith", "Allergic to unsalted peanuts only", [[48.9615, 2.4372], [37.742, -25.6976]], [[37.742, -25.6976], [48.9615, 2.4372]] ]
这应该足够我们开发原型了,所以让我们开始吧:
-
打开一个 REPL,并将样本预订数据绑定到
booking符号:user=> (def booking [1425, "Bob Smith", "Allergic to unsalted peanuts only", [[48.9615, 2.4372], [37.742, -25.6976]], [[37.742, -25.6976], [48.9615, 2.4372]]]) #'user/booking -
通过实验解构来开始开发我们的解析函数。创建一个
let块,并定义如下绑定,使用println打印结果:user=> (let [[id customer-name sensitive-info flight1 flight2 flight3] booking] (println id customer-name flight1 flight2 flight3)) 1425 Bob Smith [[48.9615 2.4372] [37.742 -25.6976]] [[37.742 -25.6976] [48.9615 2.4372]] nil nil注意到
flight3被绑定到值nil。这是因为数据比定义的绑定短,能够只绑定存在的值是既有效又有用的。类似地,如果预订向量包含额外数据,它将被忽略。
-
记住
conj函数接受一个集合和一些元素作为参数,并返回一个新集合,其中包含那些添加到集合中的元素。使用conj在预订向量中添加两个航班,并使用相同的解构表达式解析数据:user=> (let [big-booking (conj booking [[37.742, -25.6976], [51.1537, 0.1821]] [[51.1537, 0.1821], [48.9615, 2.4372]]) [id customer-name sensitive-info flight1 flight2 flight3] big-booking] (println id customer-name flight1 flight2 flight3)) 1425 Bob Smith [[48.9615 2.4372] [37.742 -25.6976]] [[37.742 -25.6976] [48.9615 2.4372]] [[37.742 -25.6976] [51.1537 0.1821]] nil注意到最后一次航班被简单地忽略而没有打印出来。这是解构的另一个有用特性,也是 Clojure 动态性和实用性的另一个标志。
-
在接收到的数据中,我们并不关心 Fly Vector 的内部 ID,我们也不希望解析敏感信息。这可以通过使用下划线
_而不是符号来简单地忽略:user=> (let [[_ customer-name _ flight1 flight2 flight3] booking] (println customer-name flight1 flight2 flight3)) Bob Smith [[48.9615 2.4372] [37.742 -25.6976]] [[37.742 -25.6976] [48.9615 2.4372]] nil nil太好了,我们现在理解了如何使用解构忽略数据的一些部分。
仅打印坐标数组并不是很易读,所以直到我们找到更好的打印航班的方法,我们只想简单地显示预订中的航班数量。当然,我们可以测试
flight1、flight2和flight3是否存在值,但解构还有另一个我们可以使用的方面:序列的“剩余”部分。通过使用&字符后跟一个符号,我们可以将序列的剩余部分绑定到给定的符号。 -
使用
&字符将flights序列绑定到flights符号,然后按以下方式显示航班数量:user=> (let [[_ customer-name _ & flights] booking] (println (str customer-name " booked " (count flights) " flights."))) Bob Smith booked 2 flights. nil注意,
flights现在是一个集合,因此我们可以使用count函数与它一起使用。解构非常强大,也可以解构嵌套的数据结构。为了解析和打印航班详情,让我们创建一个单独的函数来保持代码的清晰和可读性。
-
创建一个
print-flight函数,使用嵌套解构解构航班路径,并打印出一个格式良好的航班行程单:user=> (defn print-flight [flight] (let [[[lat1 lon1] [lat2 lon2]] flight] (println (str "Flying from: Lat " lat1 " Lon " lon1 " Flying to: Lat " lat2 " Lon " lon2)))) #'user/print-flight user=> (print-flight [[48.9615, 2.4372], [37.742 -25.6976]]) Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976注意我们是如何通过简单地使用嵌套向量字面量表示法来挖掘
flight中包含的嵌套向量,以检索坐标元组内的坐标值。然而,let绑定中的嵌套向量稍微有点难以阅读。 -
通过分解多个
let绑定来重写print-flight函数:user=> (defn print-flight [flight] (let [[departure arrival] flight [lat1 lon1] departure [lat2 lon2] arrival] (println (str "Flying from: Lat " lat1 " Lon " lon1 " Flying to: Lat " lat2 " Lon " lon2)))) #'user/print-flight user=> (print-flight [[48.9615, 2.4372], [37.742 -25.6976]]) Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976 nil在前面的例子中,我们使用顺序解构创建了两个中间绑定:
departure和arrival。这两个绑定包含坐标元组,我们可以进一步解构以创建纬度和经度绑定lat1、lon1、lat2和lon2。 -
最后,让我们通过组合我们迄今为止编写的代码来编写
print-booking函数:(defn print-booking [booking] (let [[_ customer-name _ & flights] booking] (println (str customer-name " booked " (count flights) " flights.")) (let [[flight1 flight2 flight3] flights] (when flight1 (print-flight flight1)) (when flight2 (print-flight flight2)) (when flight3 (print-flight flight3))))) #'user/print-booking user=> (print-booking booking) Bob Smith booked 2 flights. Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976 Flying from: Lat 37.742 Lon -25.6976 Flying to: Lat 48.9615 Lon 2.4372 nil
干得好!在这个练习中,我们成功地使用了顺序解构来解析和检索向量中的数据,并提高了我们代码的可读性和简洁性。现在,让我们继续进行下一个练习,我们将使用关联解构。
练习 3.02:使用关联解构解析 MapJet 数据
让我们继续我们的航班预订平台应用程序。现在我们想要为另一个名为 MapJet 的合作伙伴开发相同的解析器。你可能已经猜到了,MapJet 发现了关联数据结构的威力,并正在向我们发送既好又易于理解的数据结构,这些数据结构由地图和向量组成。现在,数据是自我解释的,即使 MapJet 提供了非常详细的文档,我们也不会费心去阅读它。
让我们看看一个样本预订的数据形状:
{
:id 8773
:customer-name "Alice Smith"
:catering-notes "Vegetarian on Sundays"
:flights [
{
:from {:lat 48.9615 :lon 2.4372 :name "Paris Le Bourget Airport"},
:to {:lat 37.742 :lon -25.6976 :name "Ponta Delgada Airport"}},
{
:from {:lat 37.742 :lon -25.6976 :name "Ponta Delgada Airport"},
:to {:lat 48.9615 :lon 2.4372 :name "Paris Le Bourget Airport"}}
]
}
首先,让我们同意地图是交换数据的好方法。对我们人类来说,它们非常易读,并且对我们的程序来说解析起来很简单。现在,让我们回到 REPL,看看关联解构如何帮助我们操作数据:
-
将样本预订映射绑定到
mapjet-booking符号,如下所示:user=> (def mapjet-booking { :id 8773 :customer-name "Alice Smith" :catering-notes "Vegetarian on Sundays" :flights [ { :from {:lat 48.9615 :lon 2.4372 :name "Paris Le Bourget Airport"}, :to {:lat 37.742 :lon -25.6976 :name "Ponta Delgada Airport"}}, { :from {:lat 37.742 :lon -25.6976 :name "Ponta Delgada Airport"}, :to {:lat 48.9615 :lon 2.4372 :name "Paris Le Bourget Airport"}} ] }) #'user/mapjet-booking -
使用关联解构,以与 Fly Vector 相同的方式打印预订摘要(客户名称和航班数量):
user=> (let [{:keys [customer-name flights]} mapjet-booking] (println (str customer-name " booked " (count flights) " flights."))) Alice Smith booked 2 flights. nil通过使用较短的、非重复的
:keys语法,我们能够获取映射中的键并将它们的值绑定到具有相同名称的符号。 -
让我们编写一个
print-mapjet-flight函数来打印航班详情:user=> (defn print-mapjet-flight [flight] (let [{:keys [from to]} flight {lat1 :lat lon1 :lon} from {lat2 :lat lon2 :lon} to] (println (str "Flying from: Lat " lat1 " Lon " lon1 " Flying to: Lat " lat2 " Lon " lon2)))) user=> (print-mapjet-flight (first (:flights mapjet-booking))) Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976 nil注意,我们无法使用较短的语法来提取坐标,因为
lat和lon名称会冲突;因此,我们使用了正常的语法,允许我们显式地声明具有不同名称的新绑定。与向量一样,我们可以嵌套解构表达式,甚至可以将两种技术结合起来。
-
让我们重写
print-mapjet-flight函数,但这次我们将嵌套我们的关联解构表达式:user=>(defn print-mapjet-flight [flight] (let [{{lat1 :lat lon1 :lon} :from, {lat2 :lat lon2 :lon} :to} flight] (println (str "Flying from: Lat " lat1 " Lon " lon1 " Flying to: Lat " lat2 " Lon " lon2)))) #'user/print-mapjet-flight user=> (print-mapjet-flight (first (:flights mapjet-booking))) Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976 nil前面的例子稍微有点复杂,所以如果一开始看起来有点混乱,请不要担心。将解构映射的键视为目标,将源视为值,如下所示:
{target1 source1 target2 source2}。目标可以是符号,也可以是另一个类似这样的解构映射:{{target3 source3} source1 {target4 source4} source2}。注意,在最后一个表达式中,我们是如何仅将值绑定到最内层映射中的符号(target3和target4)。这正是我们在print-mapjet-flight函数中所做的:我们提取了两个坐标点的纬度和经度的嵌套值。 -
编写用于打印 MapJet 预订的最终函数,使用代码将预订摘要打印到控制台。它应该产生与 Fly Vector 的
print-booking函数类似的输出,首先打印航班数量,然后逐个打印每个航班,如下所示:user=> (defn print-mapjet-booking [booking] (let [{:keys [customer-name flights]} booking] (println (str customer-name " booked " (count flights) " flights.")) (let [[flight1 flight2 flight3] flights] (when flight1 (print-mapjet-flight flight1)) flights (when flight2 (print-mapjet-flight flight2)) (when flight3 (print-mapjet-flight flight3))))) user=> (print-mapjet-booking mapjet-booking)输出如下:
Alice Smith booked 2 flights. Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976 Flying from: Lat 37.742 Lon -25.6976 Flying to: Lat 48.9615 Lon 2.4372
它工作了!我们现在已经完成了第一个原型。在这个练习中,我们实现了一个 Map 解析器,将解构后的数据打印到控制台。做得好!
解构技术是必不可少的,因为它们可以使我们的代码更加简洁和易于阅读。此外,我们的程序必须处理的数据通常来自外部数据源,我们并不总是拥有我们需要处理的数据的形状。拥有一个强大的工具来深入各种数据结构,可以显著提高我们作为程序员的生存质量。
然而,我们在上一个练习中编写的代码感觉有点重复;例如,两个 print-booking 函数有很多共同之处。根据我们目前所知,重构此代码会有点困难。但不要担心,我们将在下一主题中学习的技巧将允许你编写更加优雅的代码,更少重复,更少代码,因此更少错误。
高级调用签名
到目前为止,我们一直使用单一 arity(只有固定数量的参数)声明函数,并且简单地将传递给函数的参数绑定到一些参数名上。然而,Clojure 有一些技术可以在调用函数时提供更多的灵活性。
Destructuring Function Parameters
首先,我们刚刚学到的关于解构的知识也适用于函数参数。是的,你读得对——我们可以在函数参数声明中使用解构技术!正如承诺的那样,以下是我们对之前练习中的print-flight函数进行重构的第一个尝试。观察以下示例,看看顺序解构是如何直接在函数参数中使用的:
user=>
(defn print-flight
[[[lat1 lon1] [lat2 lon2]]]
(println (str "Flying from: Lat " lat1 " Lon " lon1 " Flying to: Lat " lat2 " Lon " lon2)))
#'user/print-flight
user=> (print-flight [[48.9615, 2.4372], [37.742 -25.6976]])
Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976
nil
注意我们是如何移除了let表达式的。同样地,我们也可以对print-mapjet-flight做同样的处理,在函数参数中使用关联解构:
user=>
(defn print-mapjet-flight
[{{lat1 :lat lon1 :lon} :from, {lat2 :lat lon2 :lon} :to}]
(println (str "Flying from: Lat " lat1 " Lon " lon1 " Flying to: Lat " lat2 " Lon " lon2)))
#'user/print-mapjet-flight
user=> (print-mapjet-flight { :from {:lat 48.9615 :lon 2.4372}, :to {:lat 37.742 :lon -25.6976} })
Flying from: Lat 48.9615 Lon 2.4372 Flying to: Lat 37.742 Lon -25.6976
再次,我们移除了let表达式,并立即从函数参数中解构参数。太好了——这是定义函数参数和进一步改进我们代码的一种新方法。
Arity Overloading
第二,Clojure 支持“arity overloading”,这意味着我们可以通过指定新函数的额外参数来用另一个具有相同名称的函数来overload该函数。这两个函数具有相同的名称但不同的实现,执行函数体是根据函数调用提供的参数数量来选择的。以下是一个示例:
user=>
(defn no-overloading []
(println "Same old, same old..."))
#'user/no-overloading
user=>
(defn overloading
([] "No argument")
([a] (str "One argument: " a))
([a b] (str "Two arguments: a: " a " b: " b)))
#'user/overloading
注意不同的函数实现是如何定义的。在no-overloading函数中,这是我们习惯创建函数的方式,参数声明(紧随函数名之后)周围没有额外的括号。而overloading函数中,每个实现都由括号包围,从参数声明开始。
让我们看看多 arity 的overloading函数是如何发挥作用的:
user=> (overloading)
"No argument"
在前面的代码中,没有传递任何参数;因此,调用overloading函数的第一个实现。
考虑以下代码:
user=> (overloading 1)
"One argument: 1"
在这种情况下,一个参数被传递给overloading函数,因此调用第二个实现。
user=> (overloading 1 2)
"Two arguments: a: 1 b: 2"
user=> (overloading 1 nil)
"Two arguments: a: 1 b: "
在前面的代码中,传递了两个参数,因此调用overloading函数的第三个实现。
user=> (overloading 1 2 3)
Execution error (ArityException) at user/eval412 (REPL:1).
Wrong number of args (3) passed to: user/overloading
最后,传递错误数量的参数会产生通常的 arity 异常。
你可能会(合理地)想知道这有什么用,为什么不直接声明不同的函数呢?实际上,当为同一函数定义多个 arity 时,你是在说这些函数本质上相同,它们在做相似的工作,但执行略有不同,这取决于参数的数量。提供默认值也可能很有用。
考虑以下代码,这是一个名为strike的新小型幻想游戏函数,用于计算enemy实体的新状态:
user=> (def weapon-damage {:fists 10 :staff 35 :sword 100 :cast-iron-saucepan 150})
#'user/weapon-damage
user=>
(defn strike
([enemy] (strike enemy :fists))
([enemy weapon]
(let [damage (weapon weapon-damage)]
(update enemy :health - damage))))
#'user/strike
在前面的例子中,我们首先定义了一个 HashMap 并将其绑定到 weapon-damage 符号。第二个表达式是 strike 函数的定义,它从 enemy 实体中减去伤害,并在 weapon-damage 映射中检索伤害量。注意 strike 函数有两个实现。第一个实现只包含一个参数 enemy,第二个实现有两个参数:enemy 和 weapon。注意第一个实现是如何通过提供一个额外的参数来调用第二个实现的。因此,当只传递一个参数调用 strike 函数时,将提供默认值 :fists:
user=> (strike {:name "n00b-hunter" :health 100})
{:name "n00b-hunter", :health 90}
注意到函数只使用了一个参数(enemy 实体),因此它通过了函数的单参数实现,使用 :fists 作为默认值,并返回一个剩余 90 点生命值的敌人(因为拳头造成了 10 点伤害):
user=> (strike {:name "n00b-hunter" :health 100} :sword)
{:name "n00b-hunter", :health 0}
user=> (strike {:name "n00b-hunter" :health 100} :cast-iron-saucepan)
{:name "n00b-hunter", :health -50}
在前面的例子中,strike 函数直接使用了双参数实现,因为第二个参数 weapon 被明确提供。
可变参数函数
关于函数参数,还有一个最后的秘密要揭示。根据我们关于函数参数的知识,你将如何定义 str 函数的参数,例如?
它似乎可以接受无限数量的参数。你可能记得这样使用 str:
user=> (str "Concatenating " "is " "difficult " "to " "spell " "but " "easy " "to " "use!")
"Concatenating is difficult to spell but easy to use!"
但当然,它并不是通过这样的重载来实现的 (defn str ([s] ...) ([s1 s2] ...) ([s1 s2 s3] ...) ([s1 s2 s3 s4] …)) 等等……那么,在这种情况下发生了什么?
这就是解构技术再次发挥作用。记住,我们可以使用 & 字符将序列的其余部分绑定到数据结构?这与函数参数的工作方式类似,我们可以使用 & 字符从传递给函数的参数中创建数据结构。这就是如何创建可变参数函数(接受可变数量参数的函数),这也是 str 函数是如何实现的。
看看文档是如何描述 str 函数的:
user=> (doc str)
-------------------------
clojure.core/str
([] [x] [x & ys])
With no args, returns the empty string. With one arg x, returns
x.toString(). (str nil) returns the empty string. With more than
one arg, returns the concatenation of the str values of the args.
nil
注意其不同参数的声明。它接受零个元素 [],一个元素 [x] 或任意数量的元素 [x & ys]。
让我们尝试使用这些新知识来创建一个函数,用于向 Parenthmazes 的新玩家打印欢迎信息:
user=>
(defn welcome
[player & friends]
(println (str "Welcome to the Parenthmazes " player "!"))
(when (seq friends)
(println (str "Sending " (count friends) " friend request(s) to the following players: " (clojure.string/join ", " friends)))))
#'user/welcome
观察我们如何在函数参数中直接使用解构技术,将 player 之后的所有参数绑定到 friends 集合。现在,让我们尝试使用我们的函数,传入一个或多个参数:
user=> (welcome "Jon")
Welcome to the Parenthmazes Jon!
nil
user=> (welcome "Jon" "Arya" "Tyrion" "Petyr")
Welcome to the Parenthmazes Jon!
Sending 3 friend request(s) to the following players: Arya, Tyrion, Petyr
nil
注意当向 welcome 函数传递多个参数时,friends 符号被绑定到一个包含其余参数的序列中。
注意
seq函数可以用来从集合中获取序列。在welcome函数中,我们使用seq函数来测试集合是否包含元素。这是因为seq在作为参数传递的集合为空时返回nil。(if (seq (coll)))是一个常用的模式,你应该用它来代替(if (not (empty? coll)))。
我们可以稍微改进这个函数。我们不仅可以测试friends是否为空,还可以利用多重参数技术:
user=>
(defn welcome
([player] (println (str "Welcome to Parenthmazes (single-player mode), " player "!")))
([player & friends]
(println (str "Welcome to Parenthmazes (multi-player mode), " player "!"))
(println (str "Sending " (count friends) " friend request(s) to the following players: " (clojure.string/join ", " friends)))))
#'user/welcome
注意这次,定义了两个welcome函数,一个只有一个player参数,另一个有无限数量的参数,这些参数将被绑定到friends符号。以这种方式分离函数可以提高代码的清晰度,更明确地表达函数的意图,并移除带有when的条件表达式。
让我们最后一次尝试welcome函数:
user=> (welcome "Jon")
Welcome to Parenthmazes (single-player mode), Jon!
nil
user=> (welcome "Jon" "Arya" "Tyrion" "Petyr")
Welcome to Parenthmazes (multi-player mode), Jon!
Sending 3 friend request(s) to the following players: Arya, Tyrion, Petyr
nil
太好了——根据参数的数量,函数调用被调度到了正确的函数。
练习 3.03:使用括号迷宫的多重参数和结构化
在这个练习中,我们将继续通过添加新功能来完善Parenthmazes游戏,特别是改进我们的strike函数以实现治疗机制。我们还想添加护甲的概念,它可以减少受到的伤害。
准备在这个Parenthmazes新版本中,迎接侏儒和巨魔之间伟大战斗的到来。
-
首先,启动 REPL 和旁边的你最喜欢的代码编辑器,创建
weapon-damage映射,其中包含每种武器的伤害信息:user=> (def weapon-damage {:fists 10.0 :staff 35.0 :sword 100.0 :cast-iron-saucepan 150.0}) #'user/weapon-damage我们需要这个映射来查找玩家打击敌人时所造成的伤害量。
-
现在,让我们创建
strike函数,该函数将处理当敌人与我们处于同一阵营时(现在我们假设我们选择了侏儒阵营)的治疗:user=> (defn strike ([target weapon] (let [points (weapon weapon-damage)] (if (= :gnomes (:camp target)) (update target :health + points) (update target :health - points))))) #'user/strike在前面的函数中,
strike函数的新代码是通过查找target实体的:camp键来检索目标所在的阵营。如果target属于侏儒阵营,我们使用+函数在target实体中增加 x 个points的健康值。否则,我们使用-来减少target实体中的健康点数。 -
创建一个
enemy实体,并按照以下方式测试我们新创建的strike函数:user=> (def enemy {:name "Zulkaz", :health 250, :camp :trolls}) #'user/enemy user=> (strike enemy :sword) {:name "Zulkaz", :health 150.0, :camp :trolls}健康点数已成功扣除。让我们看看友军玩家会发生什么。
-
创建一个属于
:gnomes阵营的ally实体,并按照以下方式测试我们新创建的strike函数:user=> (def ally {:name "Carla", :health 80, :camp :gnomes}) #'user/ally user=> (strike ally :staff) {:name "Carla", :health 115.0, :camp :gnomes}健康点数已成功添加!
现在我们已经得到了
strike函数的框架,让我们修改它以实现护甲功能。target实体可以包含一个:armor键,该键包含一个用于计算最终伤害量的系数。数字越大,护甲越好。例如,100 点打击的护甲值为 0.8 会导致造成 20 点伤害。护甲值为 0.1 会导致造成 90 点伤害,0 表示没有护甲,1 表示无敌。 -
通过计算
target实体中的:armor值来改变对目标造成的伤害量。如果目标没有护甲值,将其设置为0。为了提高可读性,我们将使用let绑定来分解伤害计算:user=> (defn strike ([target weapon] (let [points (weapon weapon-damage)] (if (= :gnomes (:camp target)) (update target :health + points) (let [armor (or (:armor target) 0) damage (* points (- 1 armor))] (update target :health - damage)))))) #'user/strike在
if表达式的第二个分支中,我们使用let表达式通过or为armor分配一个默认值。如果(:armor target)是nil,则护甲的值为 0。第二个绑定包含基于护甲值的减少伤害。 -
测试
strike函数以查看它是否在没有护甲的情况下仍然工作:user=> (strike enemy :cast-iron-saucepan) {:name "Zulkaz", :health 100.0, :camp :trolls}铸铁锅会造成 150 点伤害,250 减去 150 确实等于
100。太好了,它起作用了。我们继续吧。 -
重新定义
enemy绑定以添加护甲值,并再次测试我们的strike函数:user=> (def enemy {:name "Zulkaz", :health 250, :armor 0.8, :camp :trolls}) #'user/enemy user=> (strike enemy :cast-iron-saucepan) {:name "Zulkaz", :health 220.0, :armor 0.8, :camp :trolls}太好了,伤害似乎根据护甲系数减少了。现在我们想使用我们的关联解构技术直接从函数参数中检索
camp和armor值,并减少函数体内的代码量。我们唯一的问题是,我们仍然需要返回一个更新后的target实体,但我们如何同时解构target实体并保持对target参数的引用?Clojure 会支持你——你可以使用特殊键:as将解构的映射绑定到特定的名称。 -
修改
strike函数以在函数参数中使用关联解构。使用特殊键:as将传递给函数的映射绑定到符号target:user=> (defn strike ([{:keys [camp armor] :as target} weapon] (let [points (weapon weapon-damage)] (if (= :gnomes camp) (update target :health + points) (let [damage (* points (- 1 (or armor 0)))] (update target :health - damage)))))) #'user/strike关联解构中还有一个有用的特性我们可以利用:特殊键
:or。它允许我们在想要提取的键找不到时提供一个默认值(而不是绑定到nil)。 -
在解构映射中添加特殊键
:or,为目标映射中的护甲键提供一个默认值,增加一个额外的参数来使weapon参数可选,最后添加一些文档说明,如下。别忘了用自己的一组括号包裹每个函数定义:user=> (defn strike "With one argument, strike a target with a default :fists `weapon`. With two argument, strike a target with `weapon`. Strike will heal a target that belongs to the gnomes camp." ([target] (strike target :fists)) ([{:keys [camp armor], :or {armor 0}, :as target} weapon] (let [points (weapon weapon-damage)] (if (= :gnomes camp) (update target :health + points) (let [damage (* points (- 1 armor))] (update target :health - damage)))))) #'user/strike -
通过以下方式测试不同的场景以确保你的函数仍然按预期工作:
user=> (strike enemy) {:name "Zulkaz", :health 248.0, :armor 0.8, :camp :trolls} user=> (strike enemy :cast-iron-saucepan) {:name "Zulkaz", :health 220.0, :armor 0.8, :camp :trolls} user=> (strike ally :staff) {:name "Carla", :health 115.0, :camp :gnomes}
这就结束了这个练习。如果你再次看看你刚才编写的strike函数,它使用了 Clojure 的一些高级功能,包括解构、多态函数以及读取和更新映射。通过在update函数中传递一个函数作为参数,我们也使用了高阶函数的概念,我们将在下一节中进一步解释。
高阶编程
高阶编程意味着程序,特别是函数,可以操作其他程序或函数,这与一阶编程相反,在一阶编程中,函数操作的是诸如字符串、数字和数据结构这样的数据元素。在实践中,这意味着一个函数可以接受一些编程逻辑(另一个函数)作为参数,并/或返回一些编程逻辑以供最终执行。这是一个强大的功能,它允许我们在程序中组合单个、模块化的逻辑单元,以减少重复并提高代码的可重用性。
编写更简单的函数可以增加它们的模块化。我们希望创建简单的功能单元,可以用作小砖块来构建我们的程序。编写纯函数可以降低这些砖块的复杂性,并允许我们构建更好、更坚固的程序。纯函数是不改变我们程序状态的函数——它们不会产生副作用;纯函数在给出完全相同的参数时也总是返回相同的值。这种组合使得纯函数易于推理、构建和测试。尽管 Clojure 提供了修改我们程序状态的方法,但我们应尽可能多地编写和使用纯函数。
首等函数
让我们演示将函数作为参数的使用。我们在第二章的练习 2.01,混淆机中使用了函数作为参数,这是关于数据类型和不可变性的章节,使用了clojure.string/replace函数,也在前面的练习中使用了update函数。例如,要在HashMap中除以2,你可以传递一个匿名函数作为update函数的参数来进行除法:
user=> (update {:item "Tomato" :price 1.0} :price (fn [x] (/ x 2)))
{:item "Tomato", :price 0.5}
更好的是,你可以简单地传递除法函数/,并带上参数2,如下所示:
user=> (update {:item "Tomato" :price 1.0} :price / 2)
{:item "Tomato", :price 0.5}
注意,update将把旧值作为第一个参数传递给/函数(在这里,旧值是1.0),以及所有额外的参数(在这里,2)。
你可以操作任何类型的值。例如,要反转布尔值的值,使用not函数:
user=> (update {:item "Tomato" :fruit false} :fruit not)
{:item "Tomato", :fruit true}
正如我们刚才看到的,update可以接受一个函数作为参数,但我们也可以定义自己的函数,该函数接受一个函数并将其应用于给定的参数:
user=> (defn operate [f x] (f x))
#'user/operate
user=> (operate inc 2)
3
user=> (operate clojure.string/upper-case "hello.")
"HELLO."
在前面的例子中,operate 函数接受一个函数 f 作为参数,并用第二个参数 x 调用它。虽然不是很实用,但它展示了传递和调用作为参数传递的函数是多么简单。如果我们想传递任意数量的参数,我们可以使用我们在之前关于解构的章节中学到的 & 字符:
user=> (defn operate [f & args] (f args))
#'user/operate
user=> (operate + 1 2 3)
Execution error (ClassCastException) at java.lang.Class/cast (Class.java:3369).
Cannot cast clojure.lang.ArraySeq to java.lang.Number
这次,operate 似乎可以接受任意数量的参数,但函数调用失败,因为 args 是一个序列。这是因为我们直接将 f 函数应用于 args 序列,而我们真正想要的是使用序列的每个元素作为参数来应用 f。有一个特殊函数可以解包序列并将函数应用于该序列的元素——那就是 apply 函数:
user=> (+ [1 2 3])
Execution error (ClassCastException) at java.lang.Class/cast (Class.java:3369).
Cannot cast clojure.lang.PersistentVector to java.lang.Number
user=> (apply + [1 2 3])
6
注意到 + 在向量上不工作,但通过使用 apply,我们调用 +,将向量的每个元素作为参数传递给 +。
因此,我们可以在 operate 函数中使用 apply 来拥有一个完全工作的函数,该函数接受一个函数 f 作为参数,并用剩余的参数 args 调用 f:
user=> (defn operate [f & args] (apply f args))
#'user/operate
user=> (operate str "It " "Should " "Concatenate!")
"It Should Concatenate!"
它成功了!str 函数被应用于传递给 str 的参数。
能够将函数作为参数传递是高阶函数的一个方面,但另一个方面是函数能够 返回 其他函数。考虑以下代码:
user=> (defn random-fn [] (first (shuffle [+ - * /])))
#'user/random-fn
shuffle 函数通过随机排序数组元素来打乱数组,然后我们从其中取出第一个元素。换句话说,random-fn 函数从 [+ - * /] 集合中返回一个随机函数。请注意,random-fn 函数不接收任何参数:
user=> (random-fn 2 3)
Execution error (ArityException) at user/eval277 (REPL:1).
Wrong number of args (2) passed to: user/random-fn
但 random-fn 返回的函数期望参数:
user=> ((random-fn) 2 3)
-1
在前面的代码中,(random-fn) 返回了 -,所以从 2 中减去 3,结果是 -1。
您可以使用 fn? 函数来检查传递给参数的值是否是函数:
user=> (fn? random-fn)
true
user=> (fn? (random-fn))
true
在这种情况下,观察 random-fn 和 random-fn 返回的值都是函数。因此,我们可以调用 random-fn 返回的函数,甚至将其绑定到一个符号上,如下面的例子所示,我们将函数绑定到 mysterious-fn 符号:
user=>
(let [mysterious-fn (random-fn)]
(mysterious-fn 2 3))
6
user=>
(let [mysterious-fn (random-fn)]
(mysterious-fn 2 3))
2/3
user=>
(let [mysterious-fn (random-fn)]
(mysterious-fn 2 3))
6
user=>
(let [mysterious-fn (random-fn)]
(mysterious-fn 2 3))
5
注意到每次调用 random-fn 都返回了一个不同的函数。在每一步中,使用相同的参数调用 mysterious-fn 都由不同的函数处理。根据返回的值,我们可以猜测这些函数分别是 *、/、* 和 +。
虽然可以想象,但并不常见,编写返回其他函数的函数。然而,您将经常使用一些 Clojure 的核心实用函数,这些函数返回其他函数,我们将在下面介绍。这些函数值得探索,因为它们使得函数组合、可重用性和简洁性成为可能。
部分函数
这些核心实用函数中的第一个是 partial,它接受一个函数 f 作为参数和任意数量的参数 args1。它返回一个新的函数 g,该函数可以接受额外的参数 args2。当用 args2 调用 g 时,f 将使用 args1 + args2 被调用。这可能听起来很复杂,但考虑以下示例:
user=> (def marketing-adder (partial + 0.99))
#'user/marketing-adder
user=> (marketing-adder 10 5)
15.99
调用 (partial + 0.99) 返回一个新的函数,我们将它绑定到 marketing-adder 符号。当调用 marketing-adder 时,它将使用 0.99 和传递给函数的任何额外参数调用 +。请注意,我们使用了 def 而不是 defn,因为我们不需要构建一个新的函数——partial 会为我们做这件事。
这里是另一个示例:
user=> (def format-price (partial str "€"))
#'user/format-price
user=> (format-price "100")
"€100"
user=> (format-price 10 50)
"€1050"
调用 format-price 将调用 str 函数的第一个参数 "€",然后是其余的参数。当然,你可以像这样编写相同的函数:(fn [x] (str "€" x)),但使用 partial 是定义函数作为其他函数的函数的一种优雅且表达性强的方法。
函数组合
另一个核心实用函数是 comp,即组合。以我们的 random-fn 函数为例。要从集合中检索一个随机数,我们调用 shuffle 函数然后调用 first 函数。如果我们想实现一个 sample 函数,它正好做这件事,我们可以编写以下函数:
user=> (defn sample [coll] (first (shuffle coll)))
#'user/sample
user=> (sample [1 2 3 4])
2
但更优雅的是,我们可以使用函数组合实用工具 comp 来实现 sample 函数:
user=> (def sample (comp first shuffle))
#'user/sample
user=> (sample [1 2 3 4])
4
comp 是一个实用工具,它接受任意数量的函数作为参数,并返回一个新的函数,该函数按顺序调用这些函数,并将每个函数的结果传递给下一个函数。请注意,函数是从右到左组合的,所以在前面的例子中,shuffle 将在 first 之前被应用。这很重要,因为传递给函数链的参数的数量和类型通常是有意义的。例如,如果你想组合一个乘以数字并将结果增加一的函数,你需要将 inc 函数(增加)作为函数的第一个参数传递,如下所示:
user=> ((comp inc *) 2 2)
5
user=> ((comp * inc) 2 2)
Execution error (ArityException) at user/eval405 (REPL:1).
Wrong number of args (2) passed to: clojure.core/inc
注意,当将 inc 作为 comp 函数的最后一个参数提供时,它调用 (inc 2 2),这不起作用,因为 inc 只接受一个参数。
现在,让我们看看如何结合使用 partial 和 comp 来组合一个 checkout 函数,该函数使用我们之前定义的 format-price 和 marketing-adder 函数。checkout 函数将首先通过重用 marketing-adder 来添加其参数,然后使用 format-price 格式化价格,并返回在前面加上 "Only" 的字符串连接:
user=> (def checkout (comp (partial str "Only ") format-price marketing-adder))
#'user/checkout
user=> (checkout 10 5 15 6 9)
"Only €45.99"
在前面的例子中,我们定义了一个checkout函数,作为marketing-adder、format-price和一个由partial返回的匿名函数的组合,在价格前添加文本"Only"。这个例子展示了 Clojure 中组合相关函数的卓越动态性和表现力。程序员的意图清晰简洁,跳过了定义函数和命名参数的技术细节。
在我们开始练习之前,让我们介绍一种编写匿名函数的新方法:#()字面量。#()是编写匿名函数的一种更简短的方式。参数没有命名,因此可以按顺序使用%1、%2、%3等来访问参数值。当只提供一个参数时,你可以简单地使用%(省略参数编号)来检索参数值。
例如,以下两个表达式是等价的:
(fn [s] (str "Hello" s))
;; is the same as
#(str "Hello" %)
以及以下两个表达式也是等价的:
(fn [x y] (* (+ x 10) (+ y 20)))
;; is the same as
#(* (+ %1 10) (+ %2 20))
#()字面量函数只是函数,调用方式与其他函数相同:
user=> (#(str %1 " " %2 " " %3) "First" "Second" "Third")
"First Second Third"
注意,当提供多个参数时,我们需要使用%1和%2来引用作为参数传递的值。
注意
#()函数的简短字面量表示法很方便,但应该谨慎使用,因为编号参数可能难以阅读。一般来说,只应使用具有单个参数和单个函数调用的简短匿名函数。对于其他任何情况,你应该坚持使用带有命名参数的标准fn表示法以提高可读性。
让我们在Parenthmazes版本 3 的练习中应用这些新技术。
练习 3.04:使用 Parenthmazes 的高阶函数
在这个练习中,我们将展示函数作为一等公民的好处。我们将使用函数作为值并将它们组合在一起。
我们希望进一步提高我们的幻想游戏,Parenthmazes,这次通过改变武器机制,使每种武器都能有不同的行为。例如,我们希望“拳头”只有在敌人虚弱或已被削弱时才造成伤害。而不是在我们的strike函数中实现条件分支,我们将使用weapon-damage HashMap实现一个调度表,我们将它重命名为weapon-fn-map,因为这次每种武器都将有一个相关的函数(而不是一个数值)。调度表是一个指向函数的指针表。我们可以通过一个HashMap来实现它,其中指针是键,函数是值。
为了让我们的武器功能能够很好地组合,它们将接受一个表示健康的数值作为参数,并在扣除伤害后返回一个表示健康的数值。为了简化,这次我们将省略护甲的概念。让我们从拳头武器开始:
-
在您最喜欢的代码编辑器旁边启动一个 REPL。创建一个新的
HashMap,其中包含:fists键及其相关联的函数,该函数仅在敌人的生命值小于100时造成10点伤害,否则返回health参数。将新创建的函数绑定到weapon-fn-map符号,如下所示:user=> (def weapon-fn-map {:fists (fn [health] (if (< health 100) (- health 10) health))}) #'user/weapon-fn-mapHashMap可以作为键或参数具有任何类型的值,因此将函数作为值是完全可以接受的。 -
通过从
weapon-fn-map检索该函数并使用150和50作为参数来尝试该函数:user=> ((weapon-fn-map :fists) 150) 150 user=> ((weapon-fn-map :fists) 50) 40观察到函数正确地返回了新的生命值。当
health参数小于100时,它减去了10。 -
现在转到
staff武器。staff是唯一可以用来恢复生命值(35点生命值)的武器,因此相关联的函数应该简单地调用+而不是-。这似乎是一个使用partial生成这个函数的好机会:(def weapon-fn-map { :fists (fn [health] (if (< health 100) (- health 10) health)) :staff (partial + 35) }):staff键的值现在是一个函数,它将调用+并带有35以及任何额外的参数。 -
尝试使用与
staff相关联的函数,如下所示:user=> ((weapon-fn-map :staff) 150) 185对于
sword武器,我们需要简单地从传入的参数中减去100点生命值。然而,partial不会起作用,因为参数的顺序不正确。例如,((partial - 100) 150)返回-50,因为函数调用相当于(- 100 150),但我们需要(- 150 100)。 -
创建一个匿名函数,从其参数中减去
100,并将其与sword键相关联,如下所示:(def weapon-fn-map { :fists (fn [health] (if (< health 100) (- health 10) health)) :staff (partial + 35) :sword #(- % 100) })注意,我们使用了
%来检索传递给匿名函数的参数,因为我们使用了简短的文字语法#(),期望只有一个参数。 -
按照以下步骤测试您新创建的武器函数:
user=> ((weapon-fn-map :sword) 150) 50它工作得很好!
下一个要添加的武器是
cast-iron-saucepan。为了增加趣味性,让我们在混合中加入一点随机性(毕竟锅不是一把非常精确的武器)。 -
将
cast-iron-saucepan函数添加到HashMap中,从生命值中减去100点以及0到50之间的随机数,如下所示:(def weapon-fn-map { :fists (fn [health] (if (< health 100) (- health 10) health)) :staff (partial + 35) :sword #(- % 100) :cast-iron-saucepan #(- % 100 (rand-int 50)) })在前面的例子中,我们使用了
rand-int函数,它生成一个介于0和提供的参数之间的随机整数。 -
按照以下步骤测试新创建的函数:
user=> ((weapon-fn-map :cast-iron-saucepan) 200) 77 user=> ((weapon-fn-map :cast-iron-saucepan) 200) 90由于
rand-int函数,两次后续调用可能会返回不同的值。 -
最后,我们想介绍一种新的武器(给不幸的冒险者),它不会造成任何伤害:红薯。为此,我们需要一个返回其参数(生命值)的函数。我们不需要实现它,因为它已经存在:
identity。首先,让我们使用source函数查看identity函数的源代码:user=> (source identity) (defn identity "Returns its argument." {:added "1.0" :static true} [x] x) Nil观察到
identity只是返回其参数。source函数是 REPL 中交互式使用的一个很有用的工具,因为它将函数定义打印到控制台,这有时比函数的文档更有帮助。 -
让我们最后一次重新定义我们的
weapon-fn-mapHashMap,通过将函数标识符与:sweet-potato键关联,如下所示:(def weapon-fn-map { :fists (fn [health] (if (< health 100) (- health 10) health)) :staff (partial + 35) :sword #(- % 100) :cast-iron-saucepan #(- % 100 (rand-int 50)) :sweet-potato identity })现在我们已经最终确定了
weapon-fn-map,我们应该修改strike函数以处理存储在HashMap中的武器函数值。记住,strike函数接受一个target实体作为参数,并返回具有新的健康键值的该实体。因此,更新实体的健康值应该像将weapon函数传递给update函数那样简单,因为我们的weapon函数接受健康值作为参数并返回新的健康值。 -
将上一练习中的
strike函数重写,使其使用存储在weapon-fn-map中的武器函数,如下所示:user=> (defn strike "With one argument, strike a target with a default :fists `weapon`. With two argument, strike a target with `weapon` and return the target entity" ([target] (strike target :fists)) ([target weapon] (let [weapon-fn (weapon weapon-fn-map)] (update target :health weapon-fn)))) #'user/strike -
现在通过传递各种武器作为参数来测试你的
strike函数。为了方便,你可能还想创建一个enemy实体:user=> (def enemy {:name "Arnold", :health 250}) #'user/enemy user=> (strike enemy :sweet-potato) {:name "Arnold", :health 250} user=> (strike enemy :sword) {:name "Arnold", :health 150} user=> (strike enemy :cast-iron-saucepan) {:name "Arnold", :health 108}如果我们想同时使用多个武器进行打击,而不是像这样嵌套调用
strike:user=> (strike (strike enemy :sword) :cast-iron-saucepan) {:name "Arnold", :health 42}我们可以简单地组合我们的武器函数,并直接使用核心更新函数。
-
编写一个
update表达式,使用comp函数同时使用两个武器进行打击,如下所示:user=> (update enemy :health (comp (:sword weapon-fn-map) (:cast-iron-saucepan weapon-fn-map))) {:name "Arnold", :health 15}因为我们编写的武器函数具有一致的接口(以
health作为参数并返回健康值),因此使用comp函数组合它们非常简单。为了完成这个练习,让我们创建一个mighty-strike函数,该函数一次性使用所有武器进行打击,也称为组合所有武器函数。keys和vals函数可以用于HashMaps来检索地图的键或值的集合。要检索所有武器函数,我们可以简单地使用vals函数从weapon-fn-map中检索所有值。现在我们有了函数集合,我们如何组合它们呢?我们需要将集合中的每个函数传递给comp函数。记住,为了将集合中的每个元素作为函数的参数传递,我们可以使用apply。 -
编写一个名为
mighty-strike的新函数,该函数接受一个target实体作为参数,并使用所有武器对其发动攻击。它应该将weapon-fn-map的值应用于comp函数,如下所示:user=> (defn mighty-strike "Strike a `target` with all weapons!" [target] (let [weapon-fn (apply comp (vals weapon-fn-map))] (update target :health weapon-fn))) #'user/mighty-strike user=> (mighty-strike enemy) {:name "Arnold", :health 58}
现在,如果我们停下来反思 mighty-strike 函数,并思考在没有高阶函数的情况下如何实现它,我们会意识到函数组合的概念是多么简单和强大。
在本节中,我们学习了如何将函数用作简单的值,以及作为其他函数的参数或返回值,以及如何使用 #() 创建更短的匿名函数。我们还解释了如何使用 partial、comp 和 apply 来生成、组合和发现函数的新用法。
多方法
Clojure 提供了一种使用多方法实现多态的方法。多态是指代码单元(在我们的例子中是函数)在不同上下文中表现不同的能力,例如,根据代码接收到的数据形状。在 Clojure 中,我们也称其为 运行时多态,因为调用方法是在运行时而不是在编译时确定的。多方法是由一个分派函数和一个或多个方法组合而成的。创建这些多方法的两个主要操作符是 defmulti 和 defmethod。defmulti 声明一个多方法并定义了如何通过分派函数选择方法。defmethod 创建了将被分派函数选择的不同的实现。分派函数接收函数调用的参数并返回一个分派值。这个分派值用于确定调用哪个由 defmethod 定义的函数。这些术语很多,但不用担心,下面的示例将帮助您理解这些新概念。
让我们看看我们如何使用多方法实现 Parenthmazes 的 strike 函数。这次,武器是在作为参数传递的 HashMap 中:
user=> (defmulti strike (fn [m] (get m :weapon)))
#'user/strike
在前面的代码中,我们创建了一个名为 strike 的多方法。第二个参数是分派函数,它简单地从作为参数传递的映射中检索武器。记住,关键字可以用作 HashMap 的函数,所以我们可以简单地写出 defmulti 如下:
user=> (defmulti strike :weapon)
nil
注意,这次表达式返回了 nil。这是因为多方法已经被定义了。在这种情况下,我们需要从 user 命名空间中 unmap strike 变量,然后再次评估相同的表达式:
user=> (ns-unmap 'user 'strike)
nil
user=> (defmulti strike :weapon)
#'user/strike
现在我们已经定义了我们的多方法和我们的分派函数(它简单地是 :weapon 关键字),让我们为几种武器创建 strike 函数,以演示 defmethod 的用法:
user=> (defmethod strike :sword
[{{:keys [:health]} :target}]
(- health 100))
#object[clojure.lang.MultiFn 0xaa549e5 "clojure.lang.MultiFn@aa549e5"]
观察我们如何使用名为 strike 的函数调用 defmethod,defmethod 的第二个参数是分派值::sword。当 strike 被调用时,包含武器键的映射中的武器值将被从参数中检索,然后由分派函数(:weapon 关键字)返回。同样,让我们为 :cast-iron-saucepan 分派值创建另一个 strike 实现方案:
user=> (defmethod strike :cast-iron-saucepan
[{{:keys [:health]} :target}]
(- health 100 (rand-int 50)))
#object[clojure.lang.MultiFn 0xaa549e5 "clojure.lang.MultiFn@aa549e5"]
这次,strike 是在包含 :cast-iron-saucepan 在 :weapon 键的映射中调用的。之前定义的函数将被调用。让我们用两种不同的武器测试我们新创建的多方法:
user=> (strike {:weapon :sword :target {:health 200}})
100
user=> (strike {:weapon :cast-iron-saucepan :target {:health 200}})
77
注意,调用 strike 时使用不同的参数可以调用两个不同的函数。
当分派值不映射到任何已注册的函数时,会抛出异常:
user=> (strike {:weapon :spoon :target {:health 200}})
Execution error (IllegalArgumentException) at user/eval217 (REPL:1).
No method in multimethod 'strike' for dispatch value: :spoon
如果我们需要处理这种情况,我们可以添加一个具有 :default 分派值的函数:
user=> (defmethod strike :default [{{:keys [:health]} :target}] health)
#object[clojure.lang.MultiFn 0xaa549e5 "clojure.lang.MultiFn@aa549e5"]
在这种情况下,我们决定通过简单地返回未修改的健康值来处理任何其他武器,不造成伤害:
user=> (strike {:weapon :spoon :target {:health 200}})
200
注意,这次没有抛出异常,并返回了原始的健康值。调度函数可以更加详细。我们可以想象当敌人的健康值低于 50 点时,无论使用什么武器,都会立即将其消除:
user=> (ns-unmap 'user 'strike)
nil
user=> (defmulti strike (fn
[{{:keys [:health]} :target weapon :weapon}]
(if (< health 50) :finisher weapon)))
#'user/strike
user=> (defmethod strike :finisher [_] 0)
#object[clojure.lang.MultiFn 0xf478a81 "clojure.lang.MultiFn@f478a81"]
之前的代码首先从user命名空间中取消映射strike,以便可以重新定义。然后,通过查找参数并当敌人的健康值低于50时调度到:finisher函数来重新定义调度函数。然后定义:finisher函数(具有调度值 finisher 的strike函数)以返回,简单地忽略其参数,并返回0。
由于我们取消了strike,我们必须添加defmethods,因为它们也会被移除。让我们重新添加一把剑和默认方法:
user=> (defmethod strike :sword
[{{:keys [:health]} :target}]
(- health 100))
#object[clojure.lang.MultiFn 0xaa549e5 "clojure.lang.MultiFn@aa549e5"]
user=> (defmethod strike :default [{{:keys [:health]} :target}] health)
#object[clojure.lang.MultiFn 0xaa549e5 "clojure.lang.MultiFn@aa549e5"]
现在让我们看看我们的多方法是如何工作的:
user=> (strike {:weapon :sword :target {:health 200}})
100
太好了——我们的函数仍然按预期工作。现在让我们看看当健康值低于50时会发生什么:
user=> (strike {:weapon :spoon :target {:health 30}})
0
已经调用了finisher函数,并且打击多方法成功返回了0。
多方法可以做更多的事情,例如使用向量作为调度值进行多值调度或根据类型和层次结构进行调度。这很有用,但可能现在承担得有点多。让我们继续练习,通过值调度来练习使用多方法。
练习 3.05:使用多方法
在这个练习中,我们想要扩展我们的小游戏 Parenthmazes,使其具有移动玩家的能力。游戏板是一个简单的二维空间,具有 x 和 y 坐标。我们目前不会实现任何渲染或维护游戏状态,因为我们只想练习使用多方法,所以你必须发挥想象力。
-
玩家的实体现在被赋予了一个额外的键
:position,它包含一个HashMap,其中包含 x 和 y 坐标以及:facing键,它包含玩家面对的方向。以下是一个玩家实体的示例代码:{:name "Lea" :health 200 :position {:x 10 :y 10 :facing :north}}向北或向南应该改变 y 坐标,向东或向西应该改变 x 坐标。我们将通过一个新的
move函数来实现这一点。 -
启动 REPL 并按照以下方式创建玩家实体:
user=> (def player {:name "Lea" :health 200 :position {:x 10 :y 10 :facing :north}}) #'user/player -
创建
move多方法。调度函数应该通过检索玩家实体的:position映射中的:facing值来确定调度值。:facing值可以是以下值之一::north、:south、:west和:east:user=> (defmulti move #(:facing (:position %))) #'user/move你可能已经注意到,连续的两个关键字函数调用可以用函数组合更优雅地表达。
-
通过首先从
user命名空间中取消映射 var 并使用comp来简化其定义来重新定义move多方法:user=> (ns-unmap 'user 'move) nil user=> (defmulti move (comp :facing :position)) #'user/move -
使用
:north调度值创建move函数的第一个实现。它应该在:position映射中增加:y:User=> (defmethod move :north [entity] (update-in entity [:position :y] inc)) #object[clojure.lang.MultiFn 0x1d0d6318 "clojure.lang.MultiFn@1d0d6318"] -
通过使用
player实体调用move并观察结果来尝试你新创建的函数:user=> (move player) {:name "Lea", :health 200, :position {:x 10, :y 11, :facing :north}}注意到
y位置的值成功增加了 1。 -
为其余的分派值
:south、:west和:east创建其他函数:User=> (defmethod move :south [entity] (update-in entity [:position :y] dec)) #object[clojure.lang.MultiFn 0x1d0d6318 "clojure.lang.MultiFn@1d0d6318"] User=> (defmethod move :west [entity] (update-in entity [:position :x] inc)) #object[clojure.lang.MultiFn 0x1d0d6318 "clojure.lang.MultiFn@1d0d6318"] User=> (defmethod move :east [entity] (update-in entity [:position :x] dec)) #object[clojure.lang.MultiFn 0x1d0d6318 "clojure.lang.MultiFn@1d0d6318"] -
通过提供面向不同方向的
player实体来测试你新创建的函数:user=> (move {:position {:x 10 :y 10 :facing :west}}) {:position {:x 11, :y 10, :facing :west}} user=> (move {:position {:x 10 :y 10 :facing :south}}) {:position {:x 10, :y 9, :facing :south}} user=> (move {:position {:x 10 :y 10 :facing :east}}) {:position {:x 9, :y 10, :facing :east}}观察当将玩家向不同方向移动时,坐标是否正确改变。
-
为当
:facing的值不同于:north、:south、:west和:east时创建一个额外的函数,使用:default分派值:user=> (defmethod move :default [entity] entity) #object[clojure.lang.MultiFn 0x1d0d6318 "clojure.lang.MultiFn@1d0d6318"] -
尝试你的函数并确保它能够处理意外值,通过返回原始实体映射来处理:
user=> (move {:position {:x 10 :y 10 :facing :wall}}) {:position {:x 10, :y 10, :facing :wall}}注意到多方法被分配到了默认函数,并且当玩家面对
:wall时,位置保持不变。
在本节中,我们学习了如何使用 Clojure 的多态特性与多方法。
活动 3.01:构建距离和成本计算器
让我们回到我们在 练习 3.01、使用顺序解构解析 Fly Vector 的数据 和 练习 3.02、使用关联解构解析 MapJet 数据 中所工作的飞行预订平台应用程序。在你到达本章末尾所花费的时间内,我们现在已经将公司发展成为一个名为 WingIt 的正规初创公司,拥有严肃的投资者、每周的董事会会议和一张乒乓球桌,这意味着我们现在需要构建应用程序的核心服务:两个地点之间的行程和成本计算。然而,在研究了航线、机场费用和复杂的燃料计算后,我们意识到我们需要开发的算法可能对我们这个阶段来说过于复杂。我们决定,对于我们的 最小可行产品(MVP),我们只是“临时应对”并提供更简单的交通方式,如驾驶甚至步行。然而,我们希望代码易于扩展,因为最终我们需要添加飞行(一些员工甚至无意中听到首席执行官在谈论不久后将在路线图中添加太空旅行!)。
WingIt MVP 的要求如下:
-
对于原型,我们将与 Clojure REPL 交互。界面是一个接受
HashMap作为参数的行程函数。目前,用户必须输入坐标。这可能不是非常用户友好,但用户可以在自己的地球仪或地图上查找坐标! -
行程函数返回一个包含键
:distance、:cost和:duration的HashMap。:distance以公里为单位表示,:cost以欧元表示,:duration以小时表示。 -
行程函数的唯一参数是一个包含
:from、:to、:transport和:vehicle的HashMap。:from和:to包含一个具有:lat和:lon键的HashMap,代表我们星球上某个位置的纬度和经度。 -
:transport可以是:walking或:driving。 -
:vehicle仅在:transport为:driving时有用,可以是:sporche、:sleta或:tayato。
为了计算距离,我们将使用“欧几里得距离”,通常用于计算平面上两点之间的距离。对于飞行,我们可能需要使用至少哈弗辛公式,技术上,对于驾驶,我们需要使用路线,但我们只是想要相对较短距离的大致估计,所以现在使用更简单的欧几里得距离应该足够。计算中唯一复杂的部分是经度的长度取决于纬度,因此我们需要将经度乘以纬度的余弦值。计算两点(lat1, lon1)和(lat2, lon2)之间距离的最终方程如下:


图 3.1:计算欧几里得距离
:driving, 我们将查看用户在 HashMap 参数中选择的 :vehicle。每辆车的成本应该是距离的函数:
-
:sporche平均每公里消耗 0.12 升汽油,每升汽油的价格为 €1.5。 -
:tayato平均每公里消耗 0.07 升汽油,每升汽油的价格为 €1.5。 -
:sleta平均每公里消耗 0.2 千瓦时(kwh)的电力,每千瓦时的价格为 €0.1。 -
当交通方式为
:walking时,成本应为 0。
为了计算持续时间,我们考虑平均驾驶速度为每小时 70 公里,平均步行速度为每小时 5 公里。
这里有几个调用行程函数的示例,以及预期的输出:
user=> (def paris {:lat 48.856483 :lon 2.352413})
#'user/paris
user=> (def bordeaux {:lat 44.834999 :lon -0.575490})
#'user/bordeaux
user=> (itinerary {:from paris :to bordeaux :transport :walking})
{:cost 0, :distance 491.61380776549225, :duration 122.90345194137306}
user=> (itinerary {:from paris :to bordeaux :transport :driving :vehicle :tayato})
{:cost 44.7368565066598, :distance 491.61380776549225, :duration 7.023054396649889}
这些步骤将帮助您完成这项活动:
-
首先定义
walking-speed和driving-speed常量。 -
创建两个其他常量,代表两个具有坐标的地点,
:lat和:lon。您可以使用之前的示例,即巴黎和波尔多,或者查找您自己的地点。您将使用它们来测试您的距离和行程函数。 -
创建
distance函数。它应该接受两个参数,代表我们需要计算距离的两个地点。您可以在函数参数中直接使用顺序和关联解构来分解两个地点的纬度和经度。您可以在let表达式中分解计算的步骤,并使用Math/cos函数来计算一个数字的余弦值,以及Math/sqrt来计算一个数字的平方根;例如,(Math/cos 0),(Math/sqrt 9)。 -
创建一个名为
itinerary的 多态方法。它将提供未来添加更多类型交通的灵活性。它应该使用:transport中的值作为 调度值。 -
创建用于
:walking调度值的行程函数。你可以在函数参数中使用关联解构来从HashMap参数中检索:from和:to键。你可以使用let表达式来分解距离和持续时间的计算。距离应简单地使用你之前创建的distance函数。为了计算持续时间,你应该使用在步骤 1中定义的walking-speed常量。 -
对于
:driving行程函数,你可以使用包含与成本函数关联的车辆的调度表。创建一个vehicle-cost-fns调度表。它应该是一个HashMap,键是车辆类型,值是基于距离的成本计算函数。 -
创建用于
:driving调度值的行程函数。你可以在函数参数中使用关联解构来从HashMap参数中检索:from、:to和:vehicle键。行驶距离和持续时间可以类似于步行距离和持续时间进行计算。成本可以通过使用:vehicle键从调度表中检索成本函数来计算。
预期输出:
user=> (def london {:lat 51.507351, :lon -0.127758})
#'user/london
user=> (def manchester {:lat 53.480759, :lon -2.242631})
#'user/manchester
user=> (itinerary {:from london :to manchester :transport :walking})
{:cost 0, :distance 318.4448148814284, :duration 79.6112037203571}
user=> (itinerary {:from manchester :to london :transport :driving :vehicle :sleta})
{:cost 4.604730845743489, :distance 230.2365422871744, :duration 3.2890934612453484}
注意
本活动的解决方案可以在第 686 页找到。
摘要
在本章中,我们更深入地了解了 Clojure 的强大函数。我们学习了如何通过解构技术简化我们的函数,然后发现了高阶函数的巨大好处:模块化、简单性和可组合性。我们还介绍了一个高级概念,即使用 Clojure 的多态机制编写更可扩展的代码:多方法。现在你已经熟悉了 REPL、数据类型和函数,你可以继续学习关于工具和函数式技术来操作集合。
在下一章中,我们将探讨 Clojure 的顺序集合,并查看两个最有用的模式:映射和过滤。
第四章:4. 映射和过滤
概述
在本章中,我们将通过查看两个最实用的模式:映射和过滤,开始探索如何在 Clojure 中使用顺序集合。我们将使用 map 和 filter 函数处理顺序数据,而不使用 for 循环。我们还将使用 Clojure 集合的常见模式和习惯用法,并利用惰性求值,同时避免陷阱。我们将从逗号分隔值(CSV)文件中加载数据并处理顺序数据集,从中提取和形塑数据。
在本章结束时,你将能够解析数据集并执行各种类型的转换以提取和总结数据。
简介
处理数据集合是编程中最常见且功能强大的部分之一。无论它们被称为列表、数组还是向量,顺序集合几乎存在于每个程序的核心。每种编程语言都提供了创建、访问和修改集合的工具,而且通常,你在一种语言中学到的知识也会适用于其他语言。然而,Clojure 却有所不同。我们习惯于设置一个变量,然后通过改变该变量的值来控制系统的其他部分。
这是在大多数过程式语言中的for循环中发生的情况。假设我们有一个迭代器i,我们通过调用i++来增加它的值。改变迭代器的值控制着循环的流程。通过执行i = i + 3,我们可以使循环跳过两个迭代。i的值就像循环的遥控器。如果我们正在遍历的数组只差一个元素就到末尾,而我们通过增加迭代器三个单位,会发生什么?遥控器并不能阻止我们用错它。
Clojure 有完全不同的方法。根据你之前做过什么样的编程,可能需要一些练习和一些经验才能习惯它。在 Clojure 中,将你编写的函数视为描述我们想要的数据形状的方法是有帮助的。通常,你需要分几个步骤重塑数据,才能使其达到所需的位置。而不是使用像我们之前提到过的迭代器这样的数据来跟踪程序的内部状态,Clojure 鼓励你编写将是你所拥有的数据和所希望的数据之间的桥梁的函数。这至少是一种思考方式。
从 30,000 英尺的高度来看,Clojure 程序的基本模式如下:
-
获取数据。
-
形塑数据。
-
对数据做些处理。
传统的 for 循环往往将这三个阶段合并为一个。一个典型的例子是一个 for 循环,它从数据库或文件中读取一行数据(获取数据),进行一些计算(处理数据),然后将数据写回或发送到其他地方(执行操作),然后从头开始处理下一行。在 Clojure 中,良好的设计通常意味着将这些三个步骤分开,尽可能地将逻辑移动到中间,也就是数据处理步骤。
本章中我们将介绍的技巧将帮助您做到这一点。
注意
Clojure 确实有一个 for 宏,但它用于列表推导式,您可以将其视为本章中许多模式的替代语法。
这种编码方法使得 Clojure 成为一个在尽可能简单的情况下处理复杂事物的优秀语言。学习数据集合的函数式方法不仅适用于处理大型数据集。Clojure 程序通常倾向于以数据为中心,无论它们实际上处理多少数据。在 Clojure 程序中,大部分重要工作是通过处理数据(无论大小)来完成的。在本章中,您将学习的技巧和模式将帮助您编写任何类型的 Clojure 代码。
map 和 filter 函数在本质上非常简单,您很快就会看到。在本章中,我们将重点介绍如何使用它们来解决问题。同时,了解 map 和 filter 以及更一般地如何处理序列数据,意味着您将学习到关于 Clojure 生态系统的大量新知识——例如不可变性、惰性序列或函数式编程的基础。在本章的结尾,我们将开始使用我们在旅途中学到的技术来操作一个更大的数据集,这个数据集由多年专业网球比赛的结果组成。在随后的章节中,我们将继续在这个数据集上构建我们的经验,随着我们对 Clojure 了解得越来越多。
map 和 filter
map 和 filter 函数是处理序列的更大函数组的关键部分。在该组中,map 是您将使用最多的一个,而 filter 则紧随其后。它们的作用是修改序列。
它们接受一个或多个序列作为输入,并返回一个序列:输入序列,输出序列:
![图 4.1:map 和 filter 一起工作的示意图
![图片 B14502_04_01.jpg]
图 4.1:map 和 filter 一起工作的示意图
在前面的图中,我们可以看到 map 和 filter 一起工作,其中 filter 从原始列表中删除项目,而 map 则改变它们。
解决涉及集合的问题时,首先要问的问题是:“我想要获得一个值的列表,还是一个单一值?” 如果答案是列表,那么 map、filter 或类似函数就是你需要的东西。如果你需要其他类型的值,解决方案可能是某种形式的归约,我们将在下一章讨论。但即使在这种情况下,当你分解问题,有很大可能性问题的一些组成部分将需要像 map 和 filter 这样的序列操作函数。例如,如果当前的问题涉及在一个销售物品列表中进行搜索,也许 filter 可以让你将搜索范围缩小到某个类别或价格范围。然后,你可以使用 map 为每个项目计算一个派生值——可能是项目的体积(以立方厘米为单位)或网站上的评论数量。然后,最后,你可以从这个转换后的列表中提取你正在寻找的单个项目,或者你需要的数据摘要。
要开始,让我们先单独看看 map 和 filter,然后再看看我们将如何一起使用它们。
map
与 Clojure 的大多数序列处理函数一样,map 的第一个参数总是函数。我们提供的函数将应用于我们将要迭代的序列中的每个项目。以下是一个使用 Clojure 的 inc 函数将 1 加到输入序列中每个值上的非常简单的 map 使用示例:
user> map inc [1 2 3])
(2 3 4)
每次调用 inc 函数的返回值都成为 map 返回的新序列中的一个值。
注意
map 函数可以接受更多的序列作为参数,我们很快就会看到。像许多序列处理函数一样,map 有一个单参数形式。当 map 只用一个参数调用时,即函数,它返回一个特殊函数,称为 map,Clojure 编译器不会因为参数不足而抱怨。相反,你会看到一个关于你记得没有写过的函数的奇怪错误。那个函数就是你错误产生的转换器。
练习 4.01:使用 map
让我们开始吧!在这个练习中,我们将使用 map 对一个整数列表进行操作,以获得不同类型的结果:
-
在你的 REPL 中尝试以下操作:
user> (map (fn [i] (* i 10)) [1 2 3 4 5])输出如下:
(10 20 30 40 50)这个
map调用简单地应用了名为(fn [i] (* i 10))的匿名函数到整数列表上,将每个数乘以 10。在这个过程中,我们最终得到一个新整数列表,每个整数对应于原始输入中的一个整数:![图 4.2:将一个序列映射到另一个序列]()
图 4.2:将一个序列映射到另一个序列
这种一对一的等价性很明显,但这也是
map的一个关键特性。使用map,结果的序列长度总是与输入序列完全相同,这样输入中的每个值都会映射到结果序列中的对应值,因此得名。 -
让我们现在测量单词长度。当与序列一起工作时,Clojure 的
count函数非常有价值。由于 Clojure 将字符串视为字符序列,因此count也可以用来查找字符串的长度:user> (map count ["Let's" "measure" "word" "length" "now"])你应该看到每个单词的长度:
(5 7 4 6 3) -
为了使输出更容易阅读,我们可以添加单词及其长度:
user> (map (fn [w] (str w ": " (count w))) ["Let's" "measure" "word" "length" "now"])输出如下:
("Let's: 5" "measure: 7" "word: 4" "length: 6" "now: 3")
当然,这个例子只是触及了 map 的不同应用表面。同时,它展示了映射概念的实际简单性:对于列表中的每个值,在新的列表中产生一个新的值。
filter
与 map 不同,filter 可以,并且通常会产生比输入序列更短的序列结果。对 filter 的调用基本上类似于对 map 的调用:
user> filter keyword? ["a" :b "c" :d "e" :f "g"])
(:b :d :f)
与 map 一样,传递给 filter 的第一个参数作为函数对序列中的每个项目进行调用。区别在于,在这种情况下,该函数被用作 true 或 false。当返回一个 真值 时,该项目将被包括在结果序列中。
与 map 的一个关键区别是,提供给 filter 的谓词仅用于决定给定的项目是否应该被包含。它不会以任何方式更改项目。filter 的结果集始终是输入集的子集。
练习 4.02:开始使用 filter
在这个练习中,我们将使用 filter 对整数列表进行操作以获得不同类型的结果。让我们开始吧:
-
odd?函数是一个谓词,如果数字是奇数则返回true。在 REPL 中单独尝试它:user> (odd? 5)输出如下:
true -
现在,尝试传递一个偶数作为输入:
user> (odd? 6)输出如下:
false -
现在,使用
odd?与filter结合使用:user> (filter odd? [1 2 3 4 5])输出如下:
(1 3 5) -
我们还可以使用过滤器的替代品
remove,它执行filter的相反操作。当谓词返回true时,项目将被移除:user> (remove odd? [1 2 3 4 5])输出如下:
(2 4)下面是如何可视化
filter的操作:![图 4.3:过滤器函数使用谓词定义新的序列
![img/B14502_04_03.jpg]
图 4.3:过滤器函数使用谓词定义新的序列
-
使用
filter,我们正在限制原始序列,但结果始终是一个序列。考虑这两种极端情况,其中每个谓词始终返回单个值(Clojure 的constantly函数返回一个函数,该函数不做任何事情,只是返回一个值,无论传递给它多少参数):user> (filter (constantly true) [1 2 3 4 5]) (1 2 3 4 5) user> (filter (constantly false) [1 2 3 4 5]) ()无论我们保留一切还是什么都不保留,
filter总是 返回一个序列。
与 map 一样,filter 在概念上非常简单:使用谓词,保留列表中的某些或所有项目。尽管这种简单性,或者正因为这种简单性,filter 是一个非常有用的函数,可以在无数情况下使用。
过滤器家族的其他成员 – take-while 和 drop-while
take-while和drop-while函数的逻辑与filter和remove相同——至少就它们使用谓词而言。区别在于它们只操作序列的起始位置,就像take和drop一样。take函数返回列表的前n个项,而drop返回原始列表减去前n个项:
user> (take 3 [1 2 3 4 5])
(1 2 3)
user> (drop 3 [1 2 3 4 5])
(4 5)
类似地,take-while从列表的起始位置开始,只要满足谓词就返回所有项目,而drop-while则从序列的起始位置移除这些相同的项:
User> (take-while #(> 10 %) [2 9 4 12 3 99 1])
(2 9 4)
user> (drop-while #(> 10 %) [2 9 4 12 3 99 1])
(12 3 99 1)
take-while和drop-while最明显的应用可能是对排序数据的细分。我们甚至可以将它们一起使用来找到序列中谓词停止返回true并开始返回false的确切点。
练习 4.03:使用take-while和drop-while对序列进行分区
我们有一个按顺序排列的学生列表,我们希望将其分为两组:在 2000 年之前出生的和在之后出生的。让我们开始吧:
-
在你的 REPL 中定义一个
students变量。你可以从这个课程的 GitHub 仓库复制列表:packt.live/2sQyVYz:(def students [{:name "Eliza" :year 1994} {:name "Salma" :year 1995} {:name "Jodie" :year 1997} {:name "Kaitlyn" :year 2000} {:name "Alice" :year 2001} {:name "Pippa" :year 2002} {:name "Fleur" :year 2002}]) -
编写一个谓词来翻译“在 2000 年之前”的概念:
#(< (:year %) 2000)这个匿名函数从学生映射中提取
:year值,并将其与2000进行比较。 -
使用谓词与
take-while一起查找在 2000 年之前出生的学生:user> (take-while #(< (:year %) 2000) students) ({:name "Eliza", :year 1994} {:name "Salma", :year 1995} {:name "Jodie", :year 1997}) -
使用相同的谓词与
drop-while一起查找在 2000 年或之后出生的学生:user> (drop-while #(< (:year %) 2000) students) ({:name "Kaitlyn", :year 2000} {:name "Alice", :year 2001} {:name "Pippa", :year 2002} {:name "Fleur", :year 2002})
你不会像使用filter本身那样经常使用take-while和drop-while函数,但它们在特定情况下可能非常有用。像filter一样,它们是塑造序列的有用工具。
使用 map 和 filter 一起使用
Clojure 序列函数的大部分功能都来自于它们的组合。让我们结合前面的例子。我们如何从 1, 2, 3, 4, 5 得到一个如 10, 30, 50 的序列?这只是一个关于按正确顺序应用我们的函数的问题。如果我们首先乘以 10,所有生成的整数都将为偶数。为了有意义地过滤掉奇数,我们必须先这样做。考虑以下示例:
user> (map (fn [n] (* 10 n))
(filter odd? [1 2 3 4 5]))
输出如下:
(10 30 50)
这有点难以阅读,尤其是如果你不习惯阅读具有嵌套括号的 Lisp 代码。即使map在源代码中排在第一位,评估也是从对filter的调用开始的。然后结果传递给map(我们将在本章后面展示更好的写法)。不过,首先让我们看看发生了什么。
从概念上讲,计算看起来像这样:

图 4.4:结合两个操作 – 过滤后映射
可以将此视为数据通过管道流动。map和filter的功能参数是塑造数据在流动过程中的形状。
线程宏
我们可以以反映我们对数据进行操作逻辑的方式编写相同的表达式。如果我们用非常非习惯性的风格编写,使用def,它会稍微清晰一些:
user> (def filtered (filter odd? [1 2 3 4 5]))
filtered
user> (map (fn [n] (* 10 n)) filtered)
(10 30 50)
或者,更习惯性地,我们可以使用 Clojure 的线程宏来使其更容易阅读:
user> (->> [1 2 3 4 5]
(filter odd?)
(map (fn [n] (* 10 n))))
(10 30 50)
注意
宏是一种在执行之前转换代码的结构。在第十一章,宏中,我们将更深入地探讨宏。现在,你可以将 Clojure 的线程宏视为“语法糖”,它通过避免深层嵌套来允许我们编写更易于阅读的代码。
这就是我们将在本章中编写代码的方式。线程允许我们在不命名返回值的情况下保留执行的逻辑顺序。->>宏重写你的代码,使得每个表达式的结果都插入到下一个表达式的末尾。这样,我们可以编写以下内容:
user> (->> [1 2 3 4 5]
(filter odd?))
编译器实际上“看到”的是这个:
user> (filter odd? [1 2 3 4 5])
这是一个极其常见的模式,在编写易于阅读的代码时非常有帮助,尤其是在对序列应用许多不同操作时。在 Clojure 中,当一个函数接受一个序列作为参数时,该参数通常是最后一个参数。这非常方便,或者更确切地说,是一个伟大的设计决策,因为它允许我们使用->>宏以直观的方式链接转换,这个宏恰好填充了表达式中的最后一个参数。一个复杂的转换可以被分解成更小、可组合的步骤,这使得编写、测试和理解都更容易。
使用惰性序列
在我们继续前进之前,重要的是要更仔细地看看 Clojure 中惰性序列的工作方式。当使用map和filter时,惰性评估通常是一个重要的考虑因素。在我们之前看到的例子中,我们已经使用了一个字面量向量作为输入:[1 2 3 4 5]。我们不必逐个输入每个数字,可以使用range函数并编写(range 1 6)。如果我们将其输入到 REPL 中,我们会得到基本上相同的结果:
user> (range 1 6)
(1 2 3 4 5)
那么,这仅仅是一个避免输入大量整数的快捷方式吗?嗯,是的,但range还有一个有趣的特性:它是惰性的。
在我们继续前进之前,让我们回顾一下(range 100)是一个100:
user> (def our-seq (range 100))
注意
REPL 会导致惰性序列被评估。有时这可能会让人困惑,如果你正在调试的问题是由一个没有完全评估的惰性序列引起的:“这段代码在 REPL 中运行得很好;为什么在我的代码中不能正确运行?”在 REPL 中调试时,如果你想避免强制评估一个惰性序列,可以将其分配给一个变量。
range函数通过多次调用inc来创建一个整数列表。很容易猜测our-seq中的最后一个整数将是99,但计算机不知道这一点,直到它完成了所有的算术。这意味着当我们查看第一个项目时,只有一个项目是已知的:
user> (first our-seq)
0
但如果我们查看最后一个项目,所有中间的计算都将被执行:
user> (last our-seq)
99
现在,整个序列已经被实现,从实际应用的角度来看,它不再与字面序列的整数有任何区别。
map、filter和remove等函数也是惰性的。这意味着当我们对惰性序列调用它们时,它们不会强制计算整个序列。本质上,惰性函数只是将新的计算添加到虚拟的后备计算队列中,当需要时才会实现序列。另一方面,count、sort或last等函数不是惰性的。显然,为了计算列表中的所有项目,我们首先需要整个列表。
练习 4.04:观察惰性求值
我们可以通过做一些在生产代码中永远不会做的事情来观察惰性求值的作用:引入副作用。让我们开始吧:
-
在 REPL 中定义
range的简单版本:user> (defn our-range [limit] (take-while #(< % limit) (iterate inc 0)))这里,
iterate函数通过在0上调用inc来创建一个惰性序列,然后在该结果上调用inc,然后在该结果上调用inc,依此类推。take-while将在匿名函数,即#(< % limit)停止返回true时停止消耗序列。这将停止iterate。 -
在 REPL 中测试该函数:
user> (our-range 5) (0 1 2 3 4) -
使用
map将每个整数乘以 10:user> (map #(* 10 %) (our-range 5)) (0 10 20 30 40) -
现在,我们将使用一个具有副作用的函数来打印每个整数乘法时的
.:user> (map (fn [i] (print ".") (* i 10)) (our-range 5)) .(0\. 10\. 20\. 30\. 40)如预期的那样,每个整数都有一个
.运算符。当你尝试这个时,点的确切位置可能不同:它们可能出现在整数列表之前或之后。它们不是结果序列的一部分;它们是在每次乘法执行之前同时打印的。每个整数都有一个点,因为整个序列已经被实现。 -
这次,使用
def来存储惰性序列,而不是在 REPL 中查看它:user> (def by-ten (map (fn [i] (print ".") (* i 10)) (our-range 5))) #'user/by-tenREPL 返回
by-ten变量,但没有打印任何点,因此我们知道还没有进行任何乘法运算。 -
在 REPL 中评估变量:
user> by-ten .(0\. 10\. 20\. 30\. 40) ;; this looks familiar!这里发生了什么?这与步骤 4中的输出相同。计算直到我们最终决定在 REPL 中消费惰性序列时才执行。这是 Clojure 中惰性的本质:延迟求值直到必要。
惰性求值在简化我们的程序和性能方面都有一些重要的好处。对序列的延迟计算意味着我们有时可以完全避免计算,至少在序列的一部分上。现在,重要的是要理解惰性求值如何改变我们编写和组织代码的方式。考虑这个表达式:
(range)
没有参数时,range 返回从零到系统可以处理的最大整数的所有整数。如果你将这个表达式输入到你的 REPL 中,它将用数字填满整个屏幕。因为 REPL 在显示项目数量方面有一个内置的限制,所以它不会一直递增到无限大,或者直到你的 JVM 崩溃——哪个先到算哪个。现在,想象一下我们写点像这样东西:
(->> (range)
(map #(* 10 %))
(take 5))
这个表达式告诉计算机将 1 到无限大的每个整数乘以 10,然后,当这个计算完成时,保留前五个值。如果没有懒加载,这将非常疯狂。它会在第一行失败。那么,当我们只对前五个值感兴趣时,为什么还要对一大堆数字进行计算呢?然而,在 Clojure 中,这是一种完全合理的写代码的方式。对range和map的调用是我们想要的数据的描述:正整数乘以 10。对take的调用允许我们在无限集合中选择我们实际需要的项目。懒加载意味着只需执行五个计算,因此前面的代码不仅优雅,而且效率极高。
当然,也存在危险。在这个例子中,如果我们用 last 替换 (take 5),这意味着尝试评估整个序列,这将导致灾难性的后果,因为你的机器会尝试计算所有整数到无限大,并最终失败。懒加载非常有用,但了解它是如何工作的重要。
练习 4.05:创建我们自己的懒序列
有时,我们需要创建随机数的序列。这可能是为了模拟或编写测试。在像 JavaScript 这样的过程式语言中,我们可能会写点像这样东西:
var ints = [];
for (var i = 0; i < 20; i++) {
ints.push(Math.round(Math.random() * 100));
}
我们可以将这个功能封装在一个函数中,并参数化我们想要的数组长度。
我们如何在 Clojure 中没有使用 for 循环来做这件事?Clojure 有一个 rand-int 函数,它返回一个随机的单个整数。我们可以使用 repeatedly 函数,它返回一个由我们传递给它的函数的调用组成的懒序列。让我们开始吧:
-
使用
repeatedly和一个调用rand-int并带有固定参数的匿名函数来创建一个懒序列:user> (def our-randoms (repeatedly (partial rand-int 100)))parse-int函数只接受一个参数,该参数定义了要返回值的上限。在这种情况下使用partial是常见的,但我们可以同样简单地写一个字面匿名函数:#(rand-int 100)。 -
使用
take限制返回的整数数量:user> (take 20 our-randoms) -
将其封装成一个函数:
user> (defn some-random-integers [size] (take size (repeatedly (fn [] (rand-int 100))))) -
如此使用它:
user> (some-random-integers 12)输出如下:
(32 0 26 61 10 96 38 38 93 26 68 81)
当从过程式方法转向 Clojure 的函数式方法时,这种模式可能很有用。首先,描述你想要的数据,然后根据需要对其进行界定或转换。
懒惰序列一开始可能看起来没有必要地令人困惑:为什么要使用一个引入了是否已经计算过某物的不确定性的数据结构呢?确实有一些边缘情况可能会成为问题,我们将在本章后面看到其中之一。然而,大多数时候,通过编写不产生或依赖副作用的代码,可以避免这些问题。然后你将开始享受到懒惰序列的好处,它允许以声明性方式定义序列,这最终将简化你的代码。
常见惯用和模式
map和filter等函数是 Clojure 中用于提取、选择和转换数据的最强大的工具之一。有效使用它们的关键当然是知道与它们一起使用哪种类型的函数。Clojure 试图使编写函数变得容易,并为一些最常见的情况提供了许多快捷方式。这些技术将帮助你更快地编写代码,并且它们还将为你提供一些函数式编程的宝贵实践。
匿名函数
到目前为止,我们一直使用标准的fn形式或使用像odd?这样的命名函数来编写函数式参数作为匿名函数。由于 Clojure 中的序列处理函数通常使用作为参数传递的函数,因此编写(和阅读)匿名函数是一项极其常见的任务。这就是为什么了解编写它们的不同的快捷方式是很好的。
将函数传递给map或filter的最常见方式之一是使用所谓的fn形式。fn符号和参数列表消失,只留下函数的核心和开括号前的#运算符。
在练习 4.01 使用 map中,我们本可以写成#(* 10 %)而不是(fn [n] (* 10 n))。前导的#运算符标识了其后的形式为一个函数。尽管如此,我们仍然没有参数列表,这在函数式语言中将是灾难性的遗漏!然而,我们得到了一个模式:与大多数函数中自由命名参数不同,函数字面量中的参数会自动按照一个简单的模式命名。第一个参数被命名为%,所有其他参数被命名为%2、%3、%4等等。
函数字面量的表现力有限。参数名称的模式不允许像解构或 Clojure 参数列表的其他有趣特性。当然,不能命名参数会使代码缺乏表现力。如果你有超过两个参数,可能该回到使用fn了。你今天早上写的复杂函数字面量中的%4可能看起来很明显,但一周后你可能会忘记。函数字面量应该是简短和简单的。
函数字面量的最终限制是它们不能嵌套。换句话说,如果你有一个调用map的函数字面量,那么提供给map的函数也不能是函数字面量。为什么嵌套不可能的原因非常简单。编译器如何知道哪个%与哪个函数相对应?
一旦函数字面量开始变得稍微复杂一些,就肯定到了切换到fn形式的时候了。函数字面量应该用作设置对现有函数调用的简单包装器,而不是编写复杂代码的地方。
随着你对 Clojure 中一些更高级的函数技术越来越熟悉,你将开始使用现在可供你使用的其他一些选项。对于乘以 10,我们也可以写成以下这样:
(map (partial * 10) [1 2 3 4 5])
注意,这个表单前面没有#。在这里,partial函数返回一个新的匿名函数,即*,其第一个参数“预加载”。这在 REPL 中更容易理解:
user> (def apart (partial * 10))
#'user/apart
user> (apart 5)
50
在这里,我们定义了apart,这是一个对*的部分评估调用。通过调用该函数,它表现得就像*一样,除了*的第一个参数已经填满。编写(partial * 10)本质上等同于编写(fn [x] (* 10 x))。
注意
任何使用partial创建的函数都可以总是重写为一个函数。函数如partial的力量在于它们能够以编程方式创建新的函数。这正是函数式编程的许多力量所在。
这实际上是我们一直在编写的完全相同的函数。目前,你不需要担心partial。像这样编写函数的其他方式是等效且足够的。
关键字作为函数
到目前为止,你已经熟悉了使用关键字在映射中获取值,如下所示:
user> (:my-field {:my-field 42})
42
这之所以有效,是因为 Clojure 关键字可以用作函数,其参数是一个映射。当从映射列表中提取单个字段时,它也非常有用,正如我们将在下一个练习中看到的那样。
练习 4.06:从映射列表中提取数据
从更复杂的数据结构中提取信息是一个常见的任务。我们经常遇到一系列 Clojure 映射,其中每个映射都有多个关键字键。我们需要一个只包含一个键的值的序列,每个映射一个。假设我们有一个包含游戏玩家的向量。每个玩家可能由一个像这样的映射表示:
{:id 9342
:username "speedy"
:current-points 45
:remaining-lives 2
:experience-level 5
:status :active }
想象一下,游戏要求我们获取所有玩家的当前分数,可能用于计算平均值或找到最大和最小值。
将game-users向量从packt.live/36tHiI3复制到你的 REPL 中。它包含一个映射列表,每个映射包含有关用户的一些信息:
{:id 9342
:username "speedy"
:current-points 45
:remaining-lives 2s
:experience-level 5
:status :active}
让我们开始吧:
-
使用
map返回每个用户的:current-points向量。为此,我们可以写点像这样:user> (map (fn [player] (:current-points player)) game-users) (45 67 33 59 12 0…) -
使用关键字重写这个表达式:
user> (map :current-points game-usersplayers) (45 67 33 59 12 0…)
能够用如此少的代码提取数据列表是 Clojure 关键字也可以作为函数的事实的一个好处。这样的快捷方式不仅方便或更快地输入:它们帮助我们编写表达性代码,确切地表达我们的意图。
集合作为谓词
另一个常见的任务是根据一个项目是否是集合的成员来过滤。Clojure 集合是模仿数学集合逻辑的另一种集合形式。作为编程工具,它们有两个重要的特性:
-
一个项目要么是集合的一部分,要么不是。这意味着集合中永远不会出现重复。将一个项目添加到集合中多次没有任何效果。
-
集合的主要任务是告诉你某物是否属于集合。这就是为什么集合可以用作函数,并且为什么当与
filter结合使用时非常有用。在这里,我们使用
set函数定义了一个集合,该函数接受一个列表作为参数。它可以用作一个函数,如果调用它并传入的值已经是集合的一部分,则返回true,否则返回false。在这个例子中,(alpha-set :z)返回false,因为alpha-set不包含:z:user> (def alpha-set (set [:a :b :c])) #'user/alpha-set user> (alpha-set :z) nil user> (alpha-set :a) :a
hash-set也产生一个集合。与序列不同,hash-set接受零个或多个单独的项目作为其参数:
(hash-set :a :b :c)
选择其中一个还是另一个将取决于你拥有的数据形式。生成的集合是相同的。
注意
clojure.set库是 Clojure 内置的。这个极其有用的库包含执行集合算术的函数,如交集。
假设我们有一个包含动物名称的字符串列表:
user> (def animal-names ["turtle" "horse" "cat" "frog" "hawk" "worm"])
假设我们想要从列表中移除所有哺乳动物。一个解决方案是简单地测试不同的值:
(remove (fn [animal-name]
(or (= animal-name "horse")
(= animal-name "cat")))
animal-names)
这可以工作,但有很多重复的文本。让我们尝试使用一个集合:
user> (remove #{"horse" "cat"} animal-names)
输出如下:
("turtle" "frog" "hawk" "worm")
这非常简洁、清晰,读起来几乎像一句英文句子:从animal-names中移除horse、cat[的名称]。还有另一个优点。我们排除哺乳动物的列表相当有限。如果我们需要更新它怎么办?我们必须修改源代码以添加更多测试。另一方面,集合是数据,因此它可以在我们命名空间的最顶部定义一次,或者从程序可用的其他数据中在运行时计算。
使用 comp 和集合进行关键字过滤
组合函数意味着从一个或多个现有函数中创建一个新的函数。这就是comp所做的。像partial一样,它是一个高阶函数,返回一个新的函数。函数式的comp意味着在返回的函数中,提供给comp的每个函数都将调用前一个函数的结果。
假设我们想要通过以下两种方式规范化一个输入字符串:从字符串的两端去除空白字符,并将字符串转换为小写。clojure.string库提供了执行这两项任务的函数:trim和lower-case。当然,我们可以编写一个执行所需功能的函数:
(require '[clojure.string :as string])
(defn normalize [s] (string/trim (string/lower-case s)))
注意
clojure.string 库是一个标准库,它提供了许多熟悉的字符串操作函数,例如 split 和 join。字符串的行为取决于底层平台中字符串的实现方式,因此 clojure.string 提供了一个统一的接口,这个接口对 Clojure 和 ClojureScript 都是相同的。
使用 comp,我们可以用更少的括号做同样的事情:
user> (def normalizer (comp string/trim string/lower-case))
user> (normalizer " Some Information ")
"some information"
comp 的函数参数是从右到左调用的。这可能会听起来有些令人困惑。这意味着函数的调用顺序与它们被写成正常函数调用时的顺序相同:
(comp function-c function-b function-a)
之前的代码等同于以下代码:
(fn [x] (function c (function-b (function-a x))))
在这两个函数中,function-a 将首先被调用,然后是 function-b 在该结果上,最后是 function-c 在那个结果上。
在需要即时定义函数的情况下,即时组合函数非常有用。使用 filter,我们可以使用 comp 快速构建一个谓词。假设我们需要从列表中移除任何存在于规范化形式集合中的字符串:
user> (def remove-words #{"and" "an" "a" "the" "of" "is"})
#'user/remove-words
user> (remove (comp remove-words string/lower-case string/trim) ["February" " THE " "4th"])
("February" "4th")
使用 comp,我们能够构建一个结合三个单独步骤的函数。
练习 4.07:使用 comp 和集合对关键字进行过滤
让我们回到之前的练习中的 game-users。这次,我们需要缩小我们想要计算的用户的范围。在我们的想象游戏中,用户可以有几种状态之一::active、:imprisoned、:speed-boost、:pending 或 :terminated。我们只想为具有 :pending 或 :terminated 状态的玩家计算分数。为此,我们将结合我们迄今为止使用的技术。
映射部分将保持不变:
(map :current-score game-users)
让我们开始吧:
-
与之前的练习一样,将
game-users向量从packt.live/36tHiI3复制到你的 REPL 中。 -
定义一个包含我们想要保留的状态的集合:
(def keep-statuses #{:active :imprisoned :speed-boost}) -
编写一个函数来提取每个映射的
:status字段并对它进行谓词调用。我们可以以几种方式来做这件事。我们首先想到的可能就是写点这样的东西:
(filter (fn [player] (keep-statuses (:status player))) game-users)与我们之前的例子相比,这里的困难在于我们需要同时做两件事:获取字段,然后对其进行测试。在这种情况下,我们也可以使用
comp函数,它接受两个函数并返回一个新的函数,该函数是调用第二个函数的结果上的第一个函数的结果。因此,我们不需要写(fn [player] (statuses (:status player))),我们可以写以下内容:(comp keep-statuses :status) -
使用
->>连接宏将不同的部分重新组合起来:user> (->> game-users (filter (comp #{:active :imprisoned :speed-boost} :status)) (map :current-points))结果清晰易读。稍加想象,它几乎就像一个英文句子:从
game-users开始,过滤掉那些:status不是:active、:imprisoned或:speed-boost的用户,然后返回这些用户的:current-points。
使用 mapcat 返回比输入更长的列表
正如我们已经说过的,map 总是返回与原始输入序列相同数量的项目。有时,你可能需要创建一个包含更多项目的新列表,例如,如果可以从单个输入项中推导出多个项目。这就是 mapcat 的用途。
这里有一个简单的例子。假设我们有一个包含字母表字母的列表,全部都是小写:
user> (def alpha-lc [ "a" "b" "c" "d" "e" "f" "g" "h" "i" "j"])
#'user/alpha-lc
我们将停止在 "j" 以节省空间,但你的想法应该很清楚。我们想要获得的新列表是每个小写字母后面跟着其对应的大写字母。mapcat 允许我们做到这一点:
user> (mapcat (fn [letter] [letter (clojure.string/upper-case letter)]) alpha-lc)
("a" "A" "b" "B" "c" "C" "d" "D" "e" "E" "f" "F" "g" "G" "h" "H" "i" "I" "j" "J")
在这里,我们提供的函数返回一个包含两个元素的向量,例如 ["a" "A"]。mapcat 简单地展开这些向量,最终你将得到一个单一的、扁平的列表。
注意
Clojure 有许多有趣、更专业的序列操作函数,你可能想探索。我们也可以用 interleave 解决这个问题。
在输入列表的每个项目与输出列表的项目之间存在一对一或多对多关系的情况下,你可能首先考虑使用 mapcat。在本书的后面部分,我们将使用 mapcat 在需要将包含单个项目和子列表混合的列表展平时。
基于多个输入的映射
在 Clojure 的序列函数中,map 是独一无二的,因为它可以接受多个序列作为输入。当你需要以某种方式拼接序列或推导某种类型的组合值时,这可能很有用。
当 map 有多个序列作为输入时,映射函数必须接受与序列数量相等的参数。基本模式如下:
user> (map (fn [a b] (+ a b)) [5 8 3 1 2] [5 2 7 9 8])
(10 10 10 10 10)
Clojure 已经有一个 zipmap 函数,它接受两个序列并将它们组合成一个映射:
user> (defn our-zipmap [xs ys]
(->> (map (fn [x y] [x y]) xs ys)
(into {})))
user> (our-zipmap [:a :b :c] [1 2 3])
{:a 1, :b 2, :c 3}
注意
当需要将数据从一个集合类型移动到另一个集合类型时,into 函数非常有用。它还可以将映射转换为键值对的向量,将任何序列转换为集合,或者反过来。
这里使用的映射函数 (fn [x y] [x y]) 简单地将值对包裹在一个向量中,然后作为 into 函数工作,将两个元素的元组序列转换为 Clojure 映射。
注意
元组不是 Clojure 的特殊数据结构。通常作为短向量实现,元组是具有命名字段的映射的替代品。而不是写 {:x 5 :y 9},有时简单地写 [5 9] 会更简单。当列表中项目的位置决定了它们的意义时,列表就是一个元组。理论上,元组可以是任何长度;在实践中,如果它们包含三个或四个以上的项目,就很难记住列表中每个位置的意义。
使用多个输入的另一个原因可能是如果我们需要知道当前正在处理的项目偏移量。比如说,我们有一个包含我们每日菜单字符串的列表:
user> (def meals ["breakfast" "lunch" "dinner" "midnight snack"])
在向用户展示列表时,我们可能想在每个项目前添加一个数字。我们可以使用 range 来提供无限供应的整数以匹配每一项:
user> (map (fn [idx meal] (str (inc idx) ". " meal)) (range) meals)
("1\. breakfast" "2\. lunch" "3\. dinner" "4\. midnight snack")
由range产生的懒序列从零开始,这在计算机内部是很好的,但人类更喜欢以1开始的列表,所以我们将写(inc idx)。这个模式实际上非常有用,因此已经有一个方便的map-indexed函数,它正好做同样的事情。最后,我们只需要写以下内容:
(map-indexed (fn [idx meal] (str (inc idx) ". " meal)) meals)
在之前的例子中,提供给map或filter的函数一次只查看一个项目。即使在使用map和多个序列时,我们仍然分别查看每个项目。然而,使用传统的for循环,我们可以向前或向后查看。例如,如果我们有一个值数组(ourValues)和一个我们想要对每个值及其下一个值调用的函数,我们可能会在 JavaScript 中这样写:
for (var i = 0; i++; i < ourValues.length) {
if (ourValues[i + 1]) {
myFunction(ourValues[i], ourValues[i + 1]);
}
}
我们检查确保我们不在数组的最后一个值,然后调用myFunction。虽然我们可以在 Clojure 中写类似的东西(我们将在第七章,递归 II:懒序列中做一些循环),但有一种更优雅的方法来做这件事,那就是使用map和多个序列。
这里的技巧是使用相同的序列多次,但带有偏移量,使得映射函数中的第二个参数包含第一个参数之后的项:
(map (fn [x next-x] (do-something x next-x))
my-sequence
(rest my-sequence))
这里的第二个序列当然与第一个序列相同,只是我们从第二个元素开始。匿名函数第一次被调用时,其参数将是my-sequence中的第一个和第二个项目;第二次调用时,参数将是第二个和第三个项目,依此类推。
以下图表表示了这一点。从0到5的列表以偏移量重复。每一对垂直对应于映射函数将接收的两个参数:

图 4.5:使用 map 的窗口或前瞻效果
当使用map与多个集合时,map在达到最短序列的末尾时停止迭代它们。这确保了映射函数将始终有足够的参数提供。根据上下文,当使用这种偏移量时,可能需要在缩短的函数的另一端附加数据,如果重要的话,确保序列的最后一个项目包含在结果中。
如果你解决的问题需要更远的前瞻,你可以添加更多的偏移序列,从而为映射函数提供更多的参数。各种创造性的可能性都是可用的。只需记住,最短的序列将限制映射的项目数量。
注意
如果你只需要将序列拆分成多个部分,然后对那些部分进行映射,你只需使用partition。(partition 2 my-sequence)将你的输入拆分成两项子列表。
练习 4.08:识别天气趋势
在这个练习中,我们将假装我们有一系列天数的户外温度列表。我们想要确定每一天是否比前一天温暖、寒冷还是相同。然后,可以使用这些信息向可视化添加上下箭头。让我们开始吧:
-
在你的 REPL 中,定义一个变量,包含在
packt.live/2tBRrnK中找到的整数向量:(def temperature-by-day [18 23 24 23 27 24 22 21 21 20 32 33 30 29 35 28 25 24 28 29 30]) -
编写一个映射这个向量的表达式,从第二个项目
23开始,输出:warmer、:colder或:unchanged:(map (fn [today yesterday] (cond (> today yesterday) :warmer (< today yesterday) :colder (= today yesterday) :unchanged)) (rest temperature-by-day) temperature-by-day)这次我们不是向前看,而是真正地回顾,因此为了清晰起见,我们在匿名函数的参数中将
(rest temperature-by-day)放在temperature-by-day和yesterday之前,将today放在yesterday之前。只要你能理解哪个序列对应哪个参数,顺序实际上并不重要。由于同一序列的两个版本之间的偏移,我们可以比较yesterday和today来定义趋势。表达式应该返回以下内容:
(:warmer :warmer :colder :warmer :colder :colder :colder :unchanged :colder :warmer :warmer :colder :colder :warmer :colder :colder :colder :warmer :warmer :warmer)记住,我们的第一天对应的是第二个温度。23 比较温暖,所以得到
:warmer。
通过将序列与其自身偏移创建的“窗口”可以比两个项目更宽:map 可以接受任何数量的列表。
窗口模式使 map 更加强大,因为它克服了其固有的限制之一。在其最简单形式中,map 限于输入列表和输出列表之间的一对一关系。有了窗口,列表中的更多项目可以参与每个计算。
使用 apply 消耗提取的数据
通常,当使用 map 和 filter 来针对和提取所需形式的数据时,下一步就是生成某种类型的摘要。在 第七章,递归 II:懒序列 中,我们将更深入地探讨这一点。然而,现在,我们可以通过调用单个函数(如 min 或 max)并使用 apply 在列表上构建一些简单的摘要数据。
函数如 min、max 和 + 可以接受无限数量的参数。另一方面,它们不接受列表。如果你有一系列数字并想找到最高值,你可能正在想:“如果我能把这些数字从列表中提取出来,直接插入到我的 max 调用中该多好!”好吧,有了 apply,你就可以做到这一点。
在其最简单形式中,对 apply 的调用接受一个函数和一个列表,并将函数应用于列表,就像它是一个简单的多参数函数调用一样:
user> (apply max [3 9 6])
9
这将等同于以下内容:
user> (max 3 9 6)
9
在许多情况下,这很有用,即使你知道你有多少个参数:
user> (let [a 5
b nil
c 18]
(+ a b c))
Execution error (NullPointerException) at user/eval12115 (REPL:46).
null
+ 函数并不欣赏绑定到 b 的 nil。使用 apply,我们可以用 filter 移除任何不想要的参数:
user> (let [a 5
b nil
c 18]
(apply + (filter integer? [a b c])))
23
apply 函数是任何函数式编程工具箱的重要组成部分。当处理可以用 map 和 filter 进行塑形的顺序数据时,它尤其有用。
然而,这种技术有一个危险。许多接受多个参数的函数不接受零个参数。+ 函数实际上可以在没有任何参数的情况下调用。如果你调用 (+),它将返回 0。另一方面,如果我们把 min 和 max 应用到一个空列表上,它们将失败:
(apply min [])
Execution error (ArityException) at chapterfive/eval13541 (REPL:103).
Wrong number of args (0) passed to: clojure.core/min
在这种情况下,确保空列表不可能发生是很重要的。做到这一点的一种方法是通过提供一个至少一个值。由于 apply 可以在列表参数之前接受非列表项,这使得这一点变得更容易。这样,对 min 的调用至少会有一个 0 的值:
user> (apply min 0 [])
0
练习 4.09:寻找平均天气温度
使用与上一个练习相同的天气数据,我们将计算列表中列出的日期的平均温度。让我们开始吧:
-
继续使用上一个练习中的 REPL 或启动一个新的 REPL,并从
packt.live/2tBRrnK复制数据:user> (def temperature-by-day [18 23 24 23 27 24 22 21 21 20 32 33 30 29 35 28 25 24 28 29 30]) #'user/temperature-by-day -
使用
(apply +…)和count来计算温度的总和并计数,然后找出平均值:user> (let [total (apply + temperature-by-day) c (count temperature-by-day)] (/ total c)) 26
活动 4.01:使用 map 和 filter 报告摘要信息
在我们的想象游戏中,我们想要创建一个仪表板,用户可以在相同情况下与其他用户进行比较:活跃用户应该只能看到其他活跃用户,等等。我们想要知道每种情况下的最大值和最小值,以便当前用户可以看到他们相对于极端情况的位置。
这意味着我们需要一个接受字段名称(我们想要显示的任何字段)、状态(当前用户的状态)和 game-users 列表的功能。
编写两个函数,分别报告 game-users 中所有用户以及每个状态类别的数值字段的最低值和最高值。我们希望能够询问:对于所有 :active 用户,:current-points 的最高值是多少?
函数应该接受三个参数:我们想要的字段、我们想要的状况和用户列表。它们应该这样调用:
(max-value-by-status :current-points :active game-users)
(min-value-by-status :remaining-lives :imprisoned game-users)
函数的调用签名将看起来像这样:
(defn max-value-by-status [field status users]
;; TODO: write code
)
为了简单起见,你可能应该以我们之前做过的相同方式来结构化你的代码,即使用线程宏 ->>,它可以链式调用多个函数。
这些步骤将帮助你完成这项活动:
-
使用
filter筛选出具有我们寻找的状态的用户。 -
使用
map提取你想要的价值。 -
使用
min和max函数与apply一起找到最小值和最大值。确保你的代码即使在没有你寻找的状态的用户时也能正常工作。
以下是预期结果:

图 4.6:预期结果
注意
本活动的解决方案可以在第 689 页找到。
从 CSV 文件导入数据集
现在我们已经看到了一些处理数据的基本模式,是时候更有雄心壮志了!我们将开始使用一个数据集,我们将在构建 Clojure 知识的过程中在许多后续章节中使用它:ATP 世界巡回赛网球数据,这是一个 CSV 文件,包括从 1871 年开始的专业网球比赛信息。除了学习新的概念和技术,我们还将看到 Clojure 可以是探索和操作大型数据集的一个有趣选择。而且,自然地,我们可用的大多数数据集都是 CSV 文件。
注意
这个数据集是在 packt.live/2Fq30kk 创建和维护的,并且可在 Creative Commons 4.0 国际许可下使用。我们在这里将使用的文件也可在 packt.live/37DCkZn 找到。
在本章的剩余部分,我们将从 CSV 文件中导入网球比赛数据,并使用我们的映射和过滤技术从中提取有趣的数据。
练习 4.10:从 CSV 文件导入数据
是时候想象你为一家专注于数据驱动的报道的体育新闻网站工作。你的角色是帮助记者分析数据并为网站制作有趣的视觉化。在你的新工作中,你需要能够导入通常以 CSV 格式发布的的大型数据集。让我们开始吧:
-
在你的电脑上某个方便的地方创建一个文件夹。
-
将
match_scores_1991-2016_UNINDEXED.csv文件下载到您创建的文件夹中。 -
在您的编辑器中,在同一文件夹内创建一个
deps.edn文件,其内容如下:{:deps {org.clojure/data.csv {:mvn/version "0.1.4"} semantic-csv {:mvn/version "0.2.1-alpha1"}}}这些是我们将广泛使用的两个库的引用。第一个库是
clojure.data.csv,这是一个低级工具,用于处理从.csv文件中获取数据的技术。另一方面,semantic-csv是一个高级工具,它使得处理.csv数据变得更加容易。仍然在同一文件夹中,使用你的编辑器或 IDE,启动一个 Clojure REPL。
-
通过在您的 REPL 中评估以下表达式来验证一切是否正常工作:
user> (require '[clojure.data.csv :as csv]) nil user> (require '[clojure.java.io :as io]) nil user> (with-open [r (io/reader "match_scores_1991-2016_unindexed_csv.csv")] (first (csv/read-csv r))) ["tourney_year_id" "tourney_order" "tourney_slug" "tourney_url_suffix" "tourney_round_name" "round_order" "match_order" "winner_name" "winner_player_id" "winner_slug" "loser_name" "loser_player_id" "loser_slug" "winner_seed" "loser_seed" "match_score_tiebreaks" "winner_sets_won" "loser_sets_won" "winner_games_won" "loser_games_won" "winner_tiebreaks_won" "loser_tiebreaks_won" "match_id" "match_stats_url_suffix"]clojure.data.csv为文件中的每一行返回一个字符串向量。这是文件的第一行,当然,是列标题。 -
在探索新的数据集时,了解数据的大小总是好的。我们可以在这里使用
count来检查:user> (with-open [r (io/reader "match_scores_1991-2016_unindexed_csv.csv")] (count (csv/read-csv r))) 91957
这些是 Clojure 中打开和读取 CSV 文件的基本方法。我们将检查的所有其他数据分析技术都将使用这种相同的基本模式。但在我们继续之前,我们需要看看懒加载。
真实世界的懒加载
你注意到你在上一个练习中评估的代码相当快吗?数据文件相当长,看起来解析它需要很长时间。但 parse-csv 是懒加载的。因为我们只请求了第一个结果,所以评估在读取一行后停止。这很方便,因为它允许我们处理大文件而无需将其加载到内存中。
因为我们在处理文件,我们必须小心。记住,惰性求值意味着求值被延迟到需要的时候。这对于整数序列来说是没问题的,但是对于像文件读取器这样的外部资源,如果我们等待太久,资源可能已经不再可用。为了避免问题,我们将做两件事:
-
我们的大部分数据工作将发生在
with-open宏的作用域内。 -
我们不会从
with-open宏返回任何惰性序列。
换句话说,在 with-open 的作用域内,我们可以使用和组合我们喜欢的所有惰性求值技术。当我们返回序列时,我们将确保它们被完全实现,通常使用 doall 函数。立即调用 doall 以避免担心惰性求值可能很有诱惑力。虽然在某些情况下这可能合适,但最好尝试抵制这种诱惑。读取大型 CSV 文件是保留序列惰性重要性的一个很好的例子。过早地调用 doall 会强制将所有数据行加载到内存中,而可能我们只需要几行。
让我们看看实际的情况。
练习 4.11:使用文件避免惰性求值陷阱
在上一个练习中,你成功打开了一个 CSV 文件并开始对其进行一些操作。你说:“现在是时候看看一些真实的数据了。”进入的数据看起来是一个序列,因为我们可以在其上使用 first 和 count。让我们尝试提取更多数据:
-
在之前的文件夹中,再次打开你的 REPL。
-
如果需要,再次加载依赖项:
user> (require '[clojure.data.csv :as csv]) nil user> (require '[clojure.java.io :as io]) nil -
让我们尝试提取前五场比赛获胜者的名字。从 0 开始,
winner_name字段是第 7 个,所以我们要在最初的标题行之后的五行(每行)上调用#(nth % 7)。也许这会起作用:(with-open [r (io/reader "match_scores_1991-2016_unindexed_csv.csv")] (->> (csv/read-csv r) (map #(nth % 7)) (take 6)))输出如下:
Error printing return value (IOException) at java.io.BufferedReader/ensureOpen (BufferedReader.java:122). Stream closed发生了什么?正如错误信息所暗示的,当 REPL 尝试打印结果时,文件流被关闭了。
map和take都是惰性的。但是为什么?我们在with-open的作用域内调用map和take。文件应该还是打开的吗?问题在于,因为求值被延迟,在我们退出with-open宏的作用域时,求值还没有发生。在那个点上,我们只有未求值的序列。当 REPL 尝试求值take返回的惰性表达式时,文件读取器可用的作用域已经消失了。惰性序列只有在被消费时才会被求值,在这种情况下,序列是在你的 REPL 中被消费的。 -
现在,再次尝试使用
doall:user> (with-open [r (io/reader "match_scores_1991-2016_unindexed_csv.csv")] (->> (csv/read-csv r) (map #(nth % 7)) (take 6) doall))这次,你应该看到如下内容:
("winner_name" "Nicklas Kulti" "Michael Stich" "Nicklas Kulti" "Jim Courier" "Michael Stich")如你所见,
doall强制惰性序列的求值。关闭的流不再是问题。表达式返回一个简单的列表。
在处理这个数据集时,我们将多次使用这个基本模式,通常以doall结束。你还可以看到它如何重现一个熟悉的模式:获取信息(csv/read-csv)、塑造信息(map, take)和传递信息(doall)。在这个情况下,惰性评估与有限的资源(如文件 I/O)相结合确实增加了额外的挑战。在数据获取、数据操作和数据输出之间保持清晰的分离不仅是一种良好的设计或良好的实践——在这种情况下,它将真正帮助避免错误和错误。然而,即使在这种情况下,惰性评估也有一个优点:某些文件可能太大而无法放入内存。通过“惰性”评估文件输出,整个文件不需要同时加载到内存中。
你现在已经从.csv文件中提取了一些真实数据。
注意
Clojure 的mapv函数是map的替代品,有一个关键的区别:它返回一个向量而不是一个惰性序列。因为向量不是惰性的,这可以避免调用doall。然而,为了清晰起见,通常更倾向于显式使用doall。
方便的 CSV 解析
如前一个示例所示,clojure.data.csv返回一个向量序列,其中每个向量包含原始文件中单行 CSV 文件的各个字符串。然而,在我们真正使用这些数据之前,还有一些工作要做。因为.csv是一个如此常见的格式,所以有一些库可以使事情变得稍微方便一些。我们将使用semantic-csv,它已经包含在我们的deps.edn文件中。
我们将从semantic-csv中使用的最主要函数是mappify和cast-with。使用mappify,每一行都变成一个 Clojure 映射,其中键是 CSV 文件中的列名,而使用cast-with,我们可以将源文件中包含数值的字符串转换为正确的类型。
让我们看看这是如何工作的。之后,我们将准备好对.csv文件中的数据进行一些更有趣的操作。
练习 4.12:使用 semantic-csv 解析 CSV
在这个练习中,我们将首先将我们的工作保存到文件中,这样我们就可以稍后回来继续。让我们开始吧:
-
在之前的文件夹中,使用你的文本编辑器创建一个新的文件,名为
tennis.clj。 -
设置你的命名空间并使用
:require以便你可以访问必要的库:(ns packt-clj.tennis (:require [clojure.data.csv :as csv] [clojure.java.io :as io] [semantic-csv.core :as sc])) -
编写一个返回数据第一行的函数:
(defn first-match [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify first))) -
评估你的文件,移动到你的 REPL 中的
packt-clj.tennis命名空间,并调用该函数:(in-ns 'packt-clj.tennis) (first-match "match_scores_1991-2016_unindexed_csv.csv")你应该看到一个包含第一场比赛数据的映射:
{:tourney_slug "adelaide", :loser_slug "michael-stich", :winner_sets_won "2", :match_score_tiebreaks "63 16 62", :loser_sets_won "1", :loser_games_won "11", :tourney_year_id "1991-7308", :tourney_order "1", :winner_seed "", :loser_seed "6", :winner_slug "nicklas-kulti", :match_order "1", :loser_name "Michael Stich", :winner_player_id "k181", :match_stats_url_suffix "/en/scores/1991/7308/MS001/match-stats", :tourney_url_suffix "/en/scores/archive/adelaide/7308/1991/results", :loser_player_id "s351", :loser_tiebreaks_won "0", :round_order "1", :tourney_round_name "Finals", :match_id "1991-7308-k181-s351", :winner_name "Nicklas Kulti", :winner_games_won "13", :winner_tiebreaks_won "0"} -
这里有这么多信息。我们不需要所有这些字段,所以我们将对每个
map调用select-keys来仅保留我们感兴趣的价值。这次,我们将保留前五行:(defn five-matches [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (map #(select-keys % [:tourney_year_id :winner_name :loser_name :winner_sets_won :loser_sets_won])) (take 5) doall)))再次评估你的文件,然后在 REPL 中调用
five-matches:(five-matches "match_scores_1991-2016_unindexed_csv.csv")你应该看到一个映射列表:
({:tourney_year_id "1991-7308", :winner_name "Nicklas Kulti", :loser_name "Michael Stich", :winner_sets_won "2", :loser_sets_won "1"} {:tourney_year_id "1991-7308", :winner_name "Michael Stich", :loser_name "Jim Courier", :winner_sets_won "2", :loser_sets_won "0"} ...etc. -
要在某种计算中使用
:winner_sets_won和:loser_sets_won字段,我们首先需要将它们转换为整数。使用semantic-csv的cast-with函数:(defn five-matches-int-sets [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (map #(select-keys % [:tourney_year_id :winner_name :loser_name :winner_sets_won :loser_sets_won])) (sc/cast-with {:winner_sets_won sc/->int :loser_sets_won sc/->int}) (take 5) doall)))这将返回与上一个函数相同的数据,但
:winner_sets_won和:loser_sets_won的值将不再带有引号:{:tourney_year_id "1991-7308", :winner_name "Nicklas Kulti", :loser_name "Michael Stich", :winner_sets_won 2, ;; <----- Real integer! :loser_sets_won 1}
现在,我们有了足够的工具来开始对我们数据集执行一些有趣的查询。有了 map、filter 和一些简单的工具,我们就可以编写简单而复杂的查询了。在下一节中,我们将探讨一些有用的技术,这些技术本身就有用,并帮助您思考如何使用函数来描述您想要的数据。
练习 4.13:使用 filter 查询数据
如果我们将此 CSV 数据视为数据库,那么编写查询就是编写和组合谓词的问题。在这个练习中,我们将使用 filter 将我们的数据集缩小到我们想要的精确信息。想象一下,您的团队中的记者正在从事一个专门针对著名网球对抗的新项目。作为第一步,他们要求您生成一个罗杰·费德勒赢得的所有网球比赛的列表。让我们开始吧:
-
确保您的项目设置与之前的练习相同。
-
创建一个名为
federer-wins的函数,该函数提供我们之前使用的 CSV 处理步骤。添加对select-keys和doall的调用,这些调用将在数据被缩小后应用于数据:(defn federer-wins [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify ;; TODO: keep writing code (map #(select-keys % [:winner_name :loser_name :winner_sets_won :loser_sets_won :winner_games_won :loser_games_won :tourney_year_id :tourney_slug])) doall))) -
编写一个谓词,以决定罗杰·费德勒是否赢得了一场比赛。使用与之前示例相同的模式,我们只需要“插入”一个带有正确谓词的
filter调用。谓词本身相当简单。这只是匹配每个映射中的一个字段的问题:
#(= "Roger Federer" (:winner_name %)) -
在函数中使用
filter谓词:(defn federer-wins [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (filter #(= "Roger Federer" (:winner_name %))) (map #(select-keys % [:winner_name :loser_name :winner_sets_won :loser_sets_won :winner_games_won :loser_games_won :tourney_year_id :tourney_slug])) doall)))尝试调用
federer-wins。您将收到以下输出:
![图 4.7:打印详细信息]
![图片 B14502_04_07.jpg]
图 4.7:打印详细信息
这看起来似乎有效!
这个练习展示了编写查询就像编写一个新的谓词一样简单。您手头有 Clojure 语言的所有功能和灵活性,可以精确地描述您需要的 exactly what results。在下一个练习中,我们将使用高阶函数技术来编写一个专门的查询函数。
练习 4.14:编写专门的查询函数
您的团队对初始结果感到满意,现在他们一直在要求您运行新的查询。您厌倦了每次都编写相同的代码,因此您决定编写一个可以接受任何谓词的函数。让我们开始吧:
-
使用与之前练习相同的相同环境。
-
将
federer-wins函数重写为match-query,它接受第二个参数,即pred:(defn match-query [csv pred] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (filter pred) (map #(select-keys % [:winner_name :loser_name :winner_sets_won :loser_sets_won :winner_games_won :loser_games_won :tourney_year_id :tourney_slug])) doall))) -
编写一个谓词以搜索费德勒的所有比赛、胜利和失利。
一种可能性是简单地添加一个
or操作符:#(or (= "Roger Federer" (:winner_name %)) (= "Roger Federer" (:loser_name %)))我们也可以像之前看到的那样使用集合作为谓词:
#((hash-set (:winner_name %) (:loser_name %)) "Roger Federer")首先,我们定义一个包含
:winner_name和:loser_name字段集合,然后我们询问:罗杰·费德勒是这个集合的成员吗?注意
我们在这里使用
hash-set而不是使用字面符号#{…},以避免与匿名函数中的#(…)混淆。hash-set和set做同样的事情,即它们创建 Clojure 集合,但set只接受一个集合作为参数,而hash-set可以接受任意数量的项目。 -
通过使用上一个练习中的谓词来计算比赛的次数和赢得的比赛次数来测试这个谓词:

图 4.8:费德勒的比赛和赢得的比赛次数
现在,我们知道在我们的数据集中,费德勒打了 1,290 场比赛,赢得了 1,050 次!
将谓词作为参数提供使得编写此类查询非常方便。编写越来越复杂的查询变得更容易。在下一个练习中,我们将继续构建这个。
练习 4.15:使用过滤器查找网球对抗
随着你们团队截止日期的临近,记者们要求越来越多的特定查询。他们决定写一篇关于现代网球中最著名对抗之一的文章,即罗杰·费德勒和拉斐尔·纳达尔的对抗。他们希望你们编写两个查询:第一个应该返回两名选手之间的所有比赛,而第二个应该只返回比分非常接近的比赛。让我们开始吧:
-
使用与之前练习相同的 环境。
-
编写一个谓词以选择两名选手之间的所有匹配项。一个可能的方法是使用
or,如下所示:#(and (or (= (:winner_name %) "Roger Federer") (= (:winner_name %) "Rafael Nadal")) (or (= (:loser_name %) "Roger Federer") (= (:loser_name %) "Rafael Nadal")))这将有效,但你可以看到逻辑变得越来越复杂,因为我们必须考虑两种不同的可能性。这正是集合非常有用的地方:
#(= (hash-set (:winner_name %) (:loser_name %)) #{"Roger Federer" "Rafael Nadal"})集合非常适合这类情况。我们不在乎顺序,或者,与选手本身不同,我们不在乎谁是赢家或输家。
使用之前练习中的
match-query函数测试这个谓词:![图 4.9:测试谓词]()
图 4.9:测试谓词
这似乎有效。现在,我们需要将结果缩小到最接近的比赛。
-
更新
match-query以便我们可以对字段进行算术运算。为了知道一场比赛是否接近,我们需要能够从:winner_sets_won减去:loser_sets_won。然而,我们有一个问题:这里的值是字符串而不是整数,所以我们不能进行减法。为了解决这个问题,我们需要回到match-query并重新引入我们在之前的练习中使用的类型转换。这是match-query的新版本:(defn match-query [csv pred] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (sc/cast-with {:winner_sets_won sc/->int :loser_sets_won sc/->int :winner_games_won sc/->int :loser_games_won sc/->int}) (filter pred) (map #(select-keys % [:winner_name :loser_name :winner_sets_won :loser_sets_won :winner_games_won :loser_games_won :tourney_year_id :tourney_slug])) doall))) -
编写一个用于判断接近比赛的谓词。
我们将使用
and来结合费德勒-纳达尔谓词以及关于赢得的盘数的测试:#(and (= (hash-set (:winner_name %) (:loser_name %)) #{"Roger Federer" "Rafael Nadal"}) (= 1 (- (:winner_sets_won %) (:loser_sets_won %)))) -
让我们测试我们新的谓词:

图 4.10:打印结果
结果讲述了体育界一场伟大的竞争故事!
只要我们在寻找结果列表,map、filter 和一些精心设计的谓词就可以完成很多工作:map 准备数据,而 filter 则找到我们正在寻找的项目。
活动 4.02:任意网球竞争
你的数据咨询团队鼓励他们通过查看费德勒-纳达尔竞争数据所能学到的东西,他们希望能够扩展这种方法。他们要求你编写一个函数,该函数将找到任何两位玩家之间所有比赛的某些总结统计数据。他们还希望有一个玩家之间最具竞争力的比赛列表。
使用网球数据集,编写一个提供网球竞争信息的函数。调用签名应如下所示:
(defn rivalry-data [csv player-1 player-2])
函数应该返回一个包含以下字段的映射:
:first-victory-player-1
:first-victory-player-2
:total-matches
:total-victories-player-1
:total-victories-player-2
:most-competitive-matches
:total-* 字段中的值应该是整数。其他字段应该是(可能为空)的匹配比赛列表。在显示结果中的比赛时,限制字段为之前示例中显示的字段,即 :winner_name、:loser_name、:winner_sets_won、:loser_sets_won、:winner_games_won、:loser_games_won、:tourney_year_id 和 :tourney_slug。
将你的函数保存在与 Exercise 4.12、Parsing CSV with semantic-csv 相同的文件中。
这些步骤将帮助你完成此活动:
-
不要忘记使用相同的
sc/mappify和sc/cast-with调用来使你的数据易于处理。 -
在
with-open宏的作用域内,使用let将两位玩家之间所有比赛的懒序列绑定到一个局部符号。当你需要匹配数据时,使用该绑定,而不是重新从.csv文件中读取。 -
为了计算每个结果字段,你需要以不同的方式过滤比赛列表。
-
对于
:total-*字段,获取正确的序列,然后使用count。 -
对于显示匹配的字段,使用
select-keys仅保留我们感兴趣的字段。
以下为预期结果:
对于任何两位实际对弈过的玩家,你的函数应该生成以下总结数据:
packt-clj.tennis> (rivalry-data "/Users/joseph/Documents/Packt/data/atp-world-tour-tennis-data_zip/data/match_scores_1968-1990_unindexed_csv.csv" "Boris Becker" "Jimmy Connors" )
{:first-victory-player-1
{:winner_name "Boris Becker",
:loser_name "Jimmy Connors",
:winner_sets_won 2,
:loser_sets_won 1,
:winner_games_won 17,
:loser_games_won 16,
:tourney_year_id "1986-411",
:tourney_slug "chicago"},
:first-victory-player-2 nil,
:total-matches 5,
:total-victories-player-1 5,
:total-victories-player-2 0,
:most-competitive-matches
({:winner_name "Boris Becker",
:loser_name "Jimmy Connors",
:winner_sets_won 2,
:loser_sets_won 1,
:winner_games_won 17,
:loser_games_won 16,
:tourney_year_id "1986-411",
:tourney_slug "chicago"}
{:winner_name "Boris Becker",
:loser_name "Jimmy Connors",
:winner_sets_won 2,
:loser_sets_won 1,
:winner_games_won 15,
:loser_games_won 15,
:tourney_year_id "1986-428",
:tourney_slug "bolton"}
{:winner_name "Boris Becker",
:loser_name "Jimmy Connors",
:winner_sets_won 2,
:loser_sets_won 1,
:winner_games_won 18,
:loser_games_won 14,
:tourney_year_id "1987-311",
:tourney_slug "london"}
{:winner_name "Boris Becker",
:loser_name "Jimmy Connors",
:winner_sets_won 2,
:loser_sets_won 1,
:winner_games_won 15,
:loser_games_won 14,
:tourney_year_id "1987-605",
:tourney_slug "nitto-atp-finals"})}
注意
本活动的解决方案可以在第 691 页找到。
总结
在本章中,我们探讨了如何使用 Clojure 中处理序列数据的最重要和最有用的两个函数。从实际的角度来看,你已经看到了如何使用 map 和 filter,以及一些用于完成常见任务和避免一些常见问题的模式和习惯用法。你开始构建用于处理集合的心理工具箱。
使用 map 和 filter 意味着我们正在处理懒序列,因此本章探讨了懒加载评估的一些细节,这是 Clojure 的基本构建块之一。
阅读和解析文件、提取、查询和操作数据的技术,在我们继续在下一章构建这些数据处理技术时,也将立即变得非常有用。
第五章:5. 多对一:减少
概述
在本章中,你将学习处理序列数据的新技术。你将学习如何使用reduce函数,以及其他提供更多灵活性的减少技术,以便从序列中转换或提取数据。我们将使用reduce的简单形式,使用带有初始化器和累加器的reduce,并解决需要序列可变长度“窗口”的问题。我们还将使用除reduce以外的函数来减少序列。
到本章结束时,你将能够使用具有复杂累加器的reduce。
简介
本章是关于使用 Clojure 的reduce函数,以及一般意义上的减少。通过这一点,我们是指从一个序列开始,将其简化为一个单一的事物。(“减少”也是一个烹饪术语,毕竟。)map和filter是关于将你拥有的序列转换为你想要的序列:输入序列,输出序列。但那并不总是我们想要的。即使是序列上的简单操作,如计算平均值、总和或最大值,也无法直接这样计算。这就是reduce以及更广泛的函数和模式发挥作用的地方:输入序列,其他输出。这是因为结果可能是一个数字、一个字符串、一个映射,甚至另一个序列。
在上一章中,我们看到了map和filter之类的函数一次只查看一个元素:我们应该如何转换这个项目?我们应该丢弃这个项目,还是保留它?这是一种强大的方法,因为它为我们的函数操作创建了一个清晰的范围,这有助于我们编写更简单的代码,并允许惰性求值。然而,当我们需要将序列作为一个整体查看,或者至少是查看比当前项目更多的内容时,这种方法是有局限性的。这就是为什么map和filter只是for循环的部分替代品。
在 Clojure 中,reduce函数并不是从整个序列生成结果的唯一方法。即使是简单的count函数,也是将序列减少到单个值的一种方式。一些核心函数实际上会使用reduce,这样我们就不必自己做了。我们将在下一章讨论的递归和循环技术也可以做到这一点。这里将要探讨的模式,在一般情况下,应该是你在决定map和filter不足以解决问题后首先考虑的。reduce和其他类似函数提供了一种清晰解决问题的方法,这也是为什么它们是你心智中的 Clojure 工具箱的重要组成部分。
reduce的基本原理
要理解reduce是如何工作的,最好的起点是一个简单的例子。让我们尝试计算一个整数列表的总和。在像 JavaScript 这样的命令式语言中,我们可能会这样做:
var integers = [8, 4000, 10, 300];
var sum = 0;
for (var i = 0; i < integers.length; i++) {
sum = sum + integers[i];
}
console.log(sum);
这里的 sum 变量累积了循环前几次迭代中找到的信息。这正是 reduce 所做的。以下是一个 Clojure 版本的相同内容:
user> (reduce (fn [sum-so-far item] (+ sum-so-far item)) [8 4000 10 300])
4318
注意
在第一次迭代中,sum-so-far 指的是 0,而 item 指的是 8。在后续的迭代中,sum-so-far 指的是使用集合中前一个元素评估函数的结果,而 item 指的是集合的当前元素。
这是如何工作的?这个表达式看起来很像前一章中 map 或 filter 的一些用法。s-表达式的布局现在应该非常熟悉了:
-
一个函数
-
一个匿名函数
-
一个整数向量
然而,你可能已经能看出这个表达式与使用 map 或 filter 的表达式相当不同。首先,只有一个序列被提供,但匿名函数接受两个参数,sum-so-far 和 item。当然,最大的惊喜是结果,它根本不是一个序列,而是一个单一的整数。这不再是 map 的领域了。
显然,这个表达式只是将序列中提供的整数相加。为了做到这一点,它遍历序列中的整数,看起来就像 map 会做的那样。关键的区别在于,使用 reduce 时,函数会“记住”评估前一次计算的结果。
让我们分析这里的操作。
第一次 reduce 调用我们提供的函数 (fn [sum-so-far item] (+ sum-so-far item)) 时,参数是列表中的前两个元素:

图 5.1:sum-so-far 和 item 是列表中的前两个元素(调用 1)
对于接下来的每次调用,sum-so-far 是前一次计算的结果,而 item 是列表中的下一个整数:

图 5.2:调用 2 和 3:每个调用都基于前一个调用的结果
我们可以通过用 Clojure 的 + 函数替换匿名函数来简化这个表达式:
user> (reduce + [8 4000 10 300])
4318
我们甚至可以使用 apply 并完全避免使用 reduce:
user> (apply + [8 4000 10 300])
4318
然而,使用 (apply + …),我们并没有真正摆脱 reduce:内部,当 + 函数被调用时,如果参数超过两个,它将使用 reduce 的一个版本来遍历列表。
练习 5.01:寻找最高温度的日期
Clojure 的 max 函数在处理数字列表时很有用,但当你想比较的数字是更复杂的数据结构的一部分时,你该怎么办?假设我们有以下天气数据:
(def weather-days
[{:max 31
:min 27
:description :sunny
:date "2019-09-24"}
{:max 28
:min 25
:description :cloudy
:date "2019-09-25"}
{:max 22
:min 18
:description :rainy
:date "2019-09-26"}
{:max 23
:min 16
:description :stormy
:date "2019-09-27"}
{:max 35
:min 19
:description :sunny
:date "2019-09-28"}])
我们需要能够编写返回最高温度日期的整个映射、最低温度等函数:
-
启动一个 REPL 并将书 GitHub 存储库中的
weather-days变量复制并粘贴到你的 REPL 中。你可以在以下位置找到文件:packt.live/2SXw372。 -
使用
map和max找到最高温度:user> (apply max (map :max weather-days)) 35这可能很有用,但它没有告诉我们哪一天有这个温度,或者那天是晴天还是多云,或者那天的最低温度是多少。
-
使用
reduce找到最高的:max温度:user> (reduce (fn [max-day-so-far this-day] (if (> (:max this-day) (:max max-day-so-far)) this-day max-day-so-far)) weather-days) {:max 35, :min 19, :description :sunny, :date "2019-09-28"}如果某天的最高温度高于
max-day-so-far,那么那一天就会取代max-day-so-far,直到有更高温度的一天将其推翻。 -
找到具有最低最高温度的那一天:
user> (reduce (fn [min-max-day-so-far this-day] (if (< (:max this-day) (:max min-max-day-so-far)) this-day min-max-day-so-far)) weather-days) {:max 22, :min 18, :description :rainy, :date "2019-09-26"}
返回具有最大值的项,而不是返回最大值本身,当与复杂的数据结构一起工作时可能很有用。你可能永远不会遇到这个确切的问题。重要的是能够快速编写一个专门版本的max、min、比较器或任何其他适应你特定数据需要的函数。由于它的强大和灵活性,了解如何使用reduce在这些情况下可能非常有用。这也并非巧合,许多核心 Clojure 函数在内部也使用reduce。例如,take-while、set、into和map都是如此。
初始化 reduce
添加整数或查找最大值等任务有一个共同点:输入值和累积值是同一类型。当两个数字相加时,结果是数字;当在两个数字之间选择最大值或最小值时,结果仍然是数字。当我们使用reduce将数字相加时,累积总和就像所有其他输入一样是数字。在迄今为止的例子中,reduce第一次调用的函数取序列中的前两个项目。我们可以将reduce调用分解为其连续的函数调用:
(reduce + [1 2 3 5 7])
(+ 1 2)
(+ 3 3)
(+ 6 5)
(+ 11 7)
实际上我们不需要在之前的例子中使用的匿名函数,因为+接受数字作为参数,并返回一个数字:


图 5.3:参数和返回值都是同一类型
在迄今为止的每个例子中,三件事都是同一类型的:
-
序列中的值
-
+的两个参数 -
+的返回值
在之前的练习中,我们返回了一个映射而不是单个数字。这是可能的,因为这三个地方都使用了相同类型的映射:我们迭代的映射以及我们正在比较的当前“最佳”映射。
然而,如果reduce只能做到这些,那么它的限制会相当大。并不是所有问题都可以用这种函数表达。通常,我们想要计算和累积其他类型的值。我们可能需要更复杂的汇总统计,或者以特定方式合并单个值,或者根据特殊标准将序列分成段。仅仅因为你有matches序列,以我们的网球例子来说,并不意味着你想要的结果也可以用网球match表达。也许我们想要遍历一个matches列表并累积其他类型的信息。实际上,在本书的结尾,我们将做这件事。在本章的开头,我们说reduce可以将序列转换为任何其他东西,但到目前为止,这并不完全正确。
这就是为什么存在第二种形式的reduce,它接受一个额外的参数。当存在额外参数时,它成为归约函数的第一个参数。在初始函数调用中,序列中的第一个元素是第二个参数。这是一个关键改进,因为归约函数的返回值不再必须是序列中相同类型的对象。
考虑以下代码片段,其中我们为归约提供一个空的映射作为初始值。随着它通过序列,当发现新值时,归约函数会更新:maximum和:minimum字段。表达式最终返回一个函数:
user> (reduce (fn [{:keys [minimum maximum]} new-number]
{:minimum (if (and minimum (> new-number minimum))
minimum
new-number)
:maximum (if (and maximum (< new-number maximum))
maximum
new-number)})
{} ;; <---- The new argument!
[5 23 5004 845 22])
{:minimum 5, :maximum 5004}
前面的表达式在一个序列中找到最小值和最大值。以这种方式调用reduce可能很有用,如果由于某种原因,很难对列表进行两次循环,如果列表非常非常长,或者可能有一个没有被保留的流。为了返回两个值,我们将它们放在一个映射中。如果没有reduce的初始化参数,这已经是不可能的了。这是一个“输入数字,输出数字”不足以应对的情况。
在这里,我们提供一个空的映射作为我们归约的初始值。随着它通过序列,当发现新值时,归约函数会更新:maximum和:minimum字段。表达式最终返回一个函数。
注意
在这种情况下,另一种常见的模式是返回一个包含两个元素的向量(一个元组),而不是映射:[minimum maximum]。
在归约函数的每次调用中,第一个参数始终是一个映射,第二个参数始终是序列中的一个整数。
这种差异使得reduce更加有用。现在,我们可以从序列中的每个元素中提取我们喜欢的数据,并将其作为后续迭代的上下文插入和传递。大多数时候,我们可以将这个上下文视为一个acc。稍后,当我们查看 Clojure 的其他循环结构时,显式上下文的概念将再次出现。
使用reduce进行分区
将序列分割成更小的序列是一个常见问题,有多种解决方法。当简单的解决方案不够用时,reduce可以是一个有用的替代方案。
首先,让我们看看其他一些可能性。如果需要固定长度的子序列,那么有partition或partition-all:
user> (partition 3 [1 2 3 4 5 6 7 8 9 10])
((1 2 3) (4 5 6) (7 8 9))
user> (partition-all 3 [1 2 3 4 5 6 7 8 9 10])
((1 2 3) (4 5 6) (7 8 9) (10))
这两个函数的区别在于,partition在填满最后一个组时停止,而partition-all即使这意味着最终的子序列不会包含相同数量的项目也会继续。
此外,还有partition-by,它提供了更多的灵活性。除了要拆分的序列外,partition-by还接受一个将在每个项目上调用的函数。partition-by将在返回值改变时开始一个新的子序列。
在这里,我们根据整数是大于还是小于 10 来将序列分割成子序列:
user> (partition-by #(> % 10) [5 33 18 0 23 2 9 4 3 99])
((5) (33 18) (0) (23) (2 9 4 3) (99))
由于partition-by允许你编写自己的分区函数,因此当创造性地使用时,这可以是一个相当有用的函数。
然而,就像map和filter本身一样,这些函数都不能一次查看超过一个项目。例如,如果我们想将整数序列分割成总和小于 20 的序列,该怎么办?为了解决这类问题,我们需要能够一次考虑多个项目。
当使用reduce进行此操作时,关键是使用映射作为初始化器和累加器,至少有两个不同的字段:一个用于累积的序列,一个用于当前序列。累加器可能如下所示,在减少过程中间,如果我们试图创建总和小于 20 的序列:
{:current [5 10]
:segments [[3 7 8]
[17]
[4 1 1 5 3 2]]}
segments中的向量是完整的:如果再添加一个项目,它们的总和将超过 20。当前的:current向量目前的总和是 15。如果主序列中的下一个项目是 4 或更多,我们就无法将它添加到这个向量中,并将[5 10]移动到segments中。
这就是它在实际中的工作方式:
user> (reduce (fn [{:keys [segments current] :as accum} n]
(let [current-with-n (conj current n)
total-with-n (apply + current-with-n)]
(if (> total-with-n 20)
(assoc accum
:segments (conj segments current)
:current [n])
(assoc accum :current current-with-n))))
{:segments [] :current []}
[4 19 4 9 5 12 5 3 4 1 1 9 5 18])
{:segments [[4] [19] [4 9 5] [12 5 3] [4 1 1 9 5]], :current [18]}
让我们仔细看看。为了方便起见,我们首先从累加器中提取段和当前绑定。然后,我们设置几个有用的绑定:current-with-n是当前序列加上当前项目n。在这个时候,我们还不知道这是一个有效的序列。它的总和可能会超过 20 的限制。为了检查这一点,我们分配另一个绑定(为了清晰起见),total-with-n,并将其与 20 进行比较。
如果current-with-n的总和超过 20,这意味着current是一个有效的子序列。在这种情况下,我们将其(不带n)添加到我们累积的段列表中,并将n作为新:current向量的第一个项目。另一方面,如果current-with-n的总和还没有达到 20,我们只需将n追加到current中并继续进行。
你会注意到最终的结果并不完全符合我们的预期:最后一个元素[18]仍然卡在:current中。为了呈现一个干净的结果,我们可能需要将我们的reduce调用包裹在一个函数中,该函数将处理这个最后的清理工作:
user> (defn segment-by-sum [limit ns]
(let [result (reduce (fn [{:keys [segments current] :as accum} n]
(let [current-with-n (conj current n)
total-with-n (apply + current-with-n)]
(if (> total-with-n limit)
(assoc accum
:segments (conj segments current)
:current [n])
(assoc accum :current current-with-n))))
{:segments [] :current []}
ns)]
(conj (:segments result) (:current result))))
#'user/segment-by-sum
在这里,我们通过添加一个limit参数使我们的函数更加灵活,这样我们就可以选择除了 20 以外的其他值。我们还为reduce调用的结果创建了一个绑定,然后在函数的最后几行中使用它来将:current的最终值追加到累积段中。现在我们得到了我们想要的结果:
user> (segment-by-sum 20 [4 19 4 9 5 12 5 3 4 1 1 9 5 18])
[[4] [19] [4 9 5] [12 5 3] [4 1 1 9 5] [18]]
这种常见的模式将允许你使用reduce做很多有趣的事情。在接下来的两个练习中,我们将使用它的变体来解决两种相当不同的问题。
回顾使用 reduce
正如我们在上一章中看到的,Clojure 的map函数非常有用且灵活。理解map的关键在于输入序列中的每个元素与输出序列中的每个元素之间的一对一映射(又是这个词!)的想法。有时,这并不是我们需要的。我们在第四章中使用的窗口模式是解决这个问题的一种方法,但它也有自己的局限性。通常,我们不知道窗口需要有多“宽”。它可能取决于数据本身,并且在我们通过输入序列移动时变化。
我们可以用reduce和一个保留一定数量项的累加器轻松解决这个问题。为了从一个简单的例子开始,让我们假设我们有一个整数列表:
(def numbers [4 9 2 3 7 9 5 2 6 1 4 6 2 3 3 6 1])
对于列表中的每个整数,我们希望返回一个包含两个元素的元组:
-
整数本身
-
如果整数是奇数,则它之前连续奇数的和;如果是偶数,则它之前连续偶数的和
按照这个逻辑,列表中的第一个9应该被替换为[9 0],因为它之前是一个偶数。另一方面,第二个9应该被替换为[9 10],因为它之前是3和7。
这里有一个使用reduce解决这个问题的函数:
(defn parity-totals [ns]
(:ret
(reduce (fn [{:keys [current] :as acc} n]
(if (and (seq current)
(or (and (odd? (last current)) (odd? n))
(and (even? (last current)) (even? n))))
(-> acc
(update :ret conj [n (apply + current)])
(update :current conj n))
(-> acc
(update :ret conj [n 0])
(assoc :current [n]))))
{:current [] :ret []}
ns)))
让我们仔细看看,从累加器开始,这是一个包含两个键的映射,这两个键引用空向量::current,用于当前具有相同奇偶性的整数序列;:ret,用于将要返回的值列表。(整个reduce表达式被一个(:ret…)表达式包裹,以提取这个值。)
减少函数开始时进行一些解构,以便我们能够轻松访问:current;现在,n当然是我们列表中的当前整数。在函数内部,结构相当简单。if表达式有一组相当复杂的嵌套逻辑运算符。首先,我们使用(seq current)来检查current是否为空,这在第一次迭代时是这种情况。如果向量或列表为空,seq函数返回false。然后,由于我们知道(last current)将返回一个整数,我们可以测试n和列表中的前一个值是否都是奇数或都是偶数。
注意
由于我们在这里使用向量,conj将新项目追加到向量的末尾。要获取最新项目,我们使用last。如果我们使用列表而不是向量,conj将追加到列表的前面,并且我们必须使用first来获取最新项目。当使用conj时,重要的是要确保底层数据结构是您所认为的那样。否则,您的结果可能会很容易出错。
根据我们最终进入的if语句的哪个分支,我们更新acc的方式不同。在第一种情况下,当前整数与current的内容具有相同的奇偶性。我们通过两次调用update将acc传递下去。如您在第二章,数据类型和不可变性中记得的,update将其第二个参数(函数)作为其第二个参数,在这种情况下是conj,因为我们是在向向量中添加,它将应用于作为第一个值提供的键关联的值。我们添加一个额外的参数,[n (apply + current)]。这将作为conj的第二个参数。总的来说,这就像我们这样调用conj:(conj (:ret acc) [n (apply + current)])。第二次调用update将n添加到我们的整数运行列表中。
在另一种情况下,当我们处于列表的起始位置或由于奇数变为偶数或偶数变为奇数而发生变化时,我们知道当前总和为零。在这种情况下,我们可以使用assoc而不是update,因为我们正在从头开始使用一个全新的列表。
在我们的整数序列上运行该函数,我们得到以下结果:

图 5.4:原始输入后面跟着相同奇偶性的前一个连续整数的和
使用map是不可能的,因为与我们所使用的窗口技术不同,累加器中的:current向量可以包含所需数量的项目,这取决于输入。这也显示了使用累加器时reduce的灵活性。现在,我们可以练习在真实问题上使用它。
练习 5.02:在斜坡上测量高程差异
一个山地地区的自行车赛的组织者希望改善他们在路边放置的标志。目前,每个标志只是指示从比赛开始处的距离。比赛组织者希望添加两个额外的数字:
-
到达当前斜坡顶部或底部的距离,这取决于比赛场地的这部分是上升还是下降海拔
-
到达当前斜坡末尾之前剩余的海拔上升或下降
这里有一个例子:

图 5.5:赛马场标志,指示到达当前小山顶部的剩余距离和海拔
你拥有的数据是一个元组的列表:第一个值是比赛起点的距离,第二个值是该点的海拔。你可以从packt.live/38IcEvx复制数据:
(def distance-elevation [[0 400] [12.5 457] [19 622] [21.5 592] [29 615] …)
我们将使用reduce和“回顾”模式来解决此问题。然而,我们首先需要解决一个困难。如果我们正在“回顾”,我们如何知道我们离下一个山峰或下一个山谷有多远?简单:我们将赛马场数据反转,这样当我们回顾时,我们实际上是在向前看!
在以下图中,当我们下坡时,我们可以“看到”前方,并将我们的当前位置与山峰进行比较:

图 5.6:下坡道
通过反转方向,我们遍历数据;当我们“回顾”时,我们在地理上是向前看的。
现在我们可以开始编写一些代码:
-
在一个空目录中启动一个新的 REPL,并打开一个新文件,
bike_race.clj。添加相应的命名空间声明:(ns bike-race)注意
Clojure 命名空间在单词之间使用连字符(有时称为“kebab case”),但由于 Clojure 的 Java 起源,相应的文件名使用下划线(或“snake case”)。这就是为什么
bike-race命名空间在一个名为bike_race.clj的文件中。在第八章,命名空间、库和 Leiningen中,你将了解更多关于命名空间的信息。 -
从书籍的 GitHub 仓库
packt.live/38IcEvx复制distance-elevation变量。 -
为此函数设置骨架:
(defn distances-elevation-to-next-peak-or-valley [data] (-> (reduce (fn [{:keys [current] :as acc} [distance elevation :as this-position]] ) {:current [] :calculated []} (reverse data)) :calculated reverse))与上面描述的基本“回顾”模式相比,只有几个显著的不同。首先,有更多的解构,以便轻松访问元组内的
distance和elevation值。其次,整个reduce调用都被->线程宏包裹。这当然等同于(reverse (:calculated (reduce…))),但优点是按照数据通过函数的方式组织代码。这是一个相当常见的累加器习语,当最终只返回一个字段时。否则,一般方法相同:
:current字段将包含到达上一个(但在地理上下一个)山峰或山谷的所有路径点。:calculated字段将存储计算值,以便在最后返回。 -
我们需要知道新的位置是否与存储在
current中的位置处于同一斜坡上。我们还在上升,还是在下降,或者我们已经越过了山顶,或者穿过了山谷的最低点?为了简化我们的代码,我们将编写一个辅助函数,该函数接受current和新的海拔。这将返回true或false:(defn same-slope-as-current? [current elevation] (or (= 1 (count current)) (let [[[_ next-to-last] [_ the-last]] (take-last 2 current)] (or (>= next-to-last the-last elevation) (<= next-to-last the-last elevation)))))首先,我们检查
current中是否只有一个值。如果是这样,我们就知道我们的问题的答案,因为只有两个点,我们知道我们处于同一斜坡上。这也保护了我们的下一个测试免受错误的影响,因为我们现在可以确信current中至少有两个项目。(我们仍然必须小心不要用空列表调用这个函数。)既然我们知道我们至少有两个项目,我们就可以进行一些解构。这种解构是双层嵌套的:首先,我们使用
take-last函数取出current中的最后两个元素,然后我们提取并命名这些元组的第二部分。为了解构元组,我们使用下划线_作为占位符,表示我们对该第一个值不感兴趣。这里使用下划线的做法仅仅是 Clojure 的惯例,其含义基本上是“不要关注这个值。”注意
我们将绑定命名为
the-last而不是简单地命名为last。这是因为 Clojure 的last函数。由于我们在这个作用域中不使用last,我们可以没有问题地将绑定命名为last。然而,避免使用与标准 Clojure 函数重名的名称是一个好习惯。危险在于你的局部绑定可能会“遮蔽”一个核心 Clojure 函数,从而导致令人困惑的错误。现在我们有三个值,我们想知道它们是否都是递增的或者都是递减的。这实际上与 Clojure 的比较函数相当简单,这些函数可以接受超过两个参数。
(>= next-to-last the-last elevation)如果next-to-last大于或等于the-last,并且如果the-last大于或等于elevation,则返回true。 -
在 REPL 中,按照以下方式移动到
bike-race命名空间:(in-ns 'bike-race) -
测试
same-slope-as-current?函数:![图 5.7:测试程序![图片]()
图 5.7:测试程序
它似乎按预期工作,包括当
current中只有一个值时。 -
函数的其余部分将围绕一个有三个分支的
cond表达式来构建,以处理三种可能的情况:current为空的初始情况;当我们处于与current中相同的斜坡上时的持续情况;以及当我们越过一个山顶或山谷并需要重置current的斜坡变化情况。这里是减少函数:
bike_race.clj 42 (fn [{:keys [current] :as acc} [distance elevation :as this-position]] 43 (cond (empty? current) 44 {:current [this-position] 45 :calculated [{:race-position distance 46 :elevation elevation 47 :distance-to-next 0 48 :elevation-to-next 0}]} 49 (same-slope-as-current? current elevation) 50 (-> acc 51 (update :current conj this-position) 52 (update :calculated 53 conj 54 {:race-position distance 55 :elevation elevation 56 :distance-to-next (- (first (first current)) distance) 57 :elevation-to-next (- (second (first current)) elevation)})) The complete code for this step can be found at https://packt.live/2sTxk4m在我们上面提到的“持续情况”中,当我们处于一个延长
current中斜率的位置时,我们只需从current中的第一个项目减去当前的高度和距离。而“斜率变化”的情况稍微复杂一些,因为我们必须重置current,并注意包括最新的“峰值或谷值”。一个图可能使这一点更清晰:![图 5.8:新的当前值从上一个峰值开始]
![图片 B14502_05_08.jpg]
(assoc :current [peak-or-valley this-position])由于我们在
:current中重新开始使用新的值,而不是update,所以我们使用assoc,它完全替换了旧值。 -
使用以下命令测试函数:
(distances-elevation-tp-next-peak-or-valley distance-elevation)输出如下:
![图 5.9:结果的部分视图]
![图片 B14502_05_09.jpg]
图 5.9:结果的部分视图
在这个问题中,数据最重要的部分是项目之间的关系。这类问题需要一种方法,使我们能够一次“看到”多个项目。与我们在上一章中使用 map 的窗口技术不同,在这个问题中,我们事先不知道需要查看多远。这正是reduce能够大放异彩的地方,因为它允许我们根据问题的需求来调整累加器的形状。
练习 5.03:胜负连串
在这个练习中,我们将从一个向量开始,这个向量包含了塞雷娜·威廉姆斯在 2015 年所参加的所有比赛。每一场比赛都由一个映射表示:
{:loser-sets-won 0,
:winner-sets-won 2,
:winner-name "Williams S.",
:loser-name "Williams V.",
:tournament "Wimbledon",
:location "London",
:date "2015-07-06"}
注意
你不需要这些数据来完成这个练习,但如果你想要玩一下这些数据,它在这里可用:packt.live/37HKOyC。
目标是为每一场比赛添加一个:current_streak字段,如果威廉姆斯表现不佳并且连续输了三场比赛,它会显示“Lost 3”,或者如果她赢了五场,它会显示“Won 5”:
-
在一个方便的目录中,打开一个 REPL 和一个名为
tennis_reduce.clj的文件,其中包含相应的命名空间定义:(ns tennis-reduce)在 Clojure 中,当一个命名空间包含多个单词时,这些单词由连字符连接。然而,相应的文件必须使用下划线。
注意
我们将使用这个文件和命名空间来完成本章剩余的与网球相关的练习。
-
从课程的 GitHub 仓库中,在
packt.live/2sPo4hv,将serena-williams-2015变量复制到你的文件中。 -
基于对
reduce的调用设置函数的骨架,并提供一个初始化映射:(defn serena-williams-win-loss-streaks [matches] (reduce (fn [acc match] ;; TODO: solve problem ) {:matches [] :current-wins 0 :current-losses 0} matches))我们在这里提供的映射作为初始值显示了我们需要为每次迭代的数据形式。当前胜利和当前失败的计数器是自解释的:我们只需根据每场连续比赛发生的情况更新这些值。
:matches可能一开始看起来很奇怪。它存在是因为我们希望返回整个匹配序列,并带有新的:current-streak字段。由于 Clojure 的不可变性,我们不能像通常那样“就地”修改匹配项。当我们遍历matches列表中的项时,我们为每个匹配项添加一些数据,然后将其放置在累加器中的:matches向量中。 -
从对
reduce的调用中提取匹配项:(defn serena-williams-win-loss-streaks [matches] (:matches (reduce (fn [acc match] ;; TODO: solve problem ) {:matches [] :current-wins 0 :current-losses 0} matches))):current-wins和:current-losses字段在函数外部没有用,所以我们只想返回新装饰的匹配项。 -
编写一个辅助函数,用于格式化字符串以展示当前连胜:
(defn streak-string [current-wins current-losses] (cond (pos? current-wins) (str "Won " current-wins) (pos? current-losses) (str "Lost " current-losses) :otherwise "First match of the year"))有三种可能的情况:连胜(至少一场胜利,零场失败)、连败(至少一场失败,零场胜利)或尚未进行任何比赛(零场胜利,零场失败)。现在是使用
cond的好时机,它允许我们拥有多个条件而不需要嵌套的if语句。最终通配条件测试可以是任何东西,除了false或nil。我们使用:otherwise关键字因为它易于阅读。最后,pos?是一个方便且常用的谓词,用于确定一个数字是否大于零,而不是编写(> current-wins 0)。这种逻辑原本可以是主减少函数的一部分。将其拆分为一个独立的、非常简单的函数可以使代码更容易阅读。
-
为减少函数编写一个骨架。像往常一样,它接受两个参数:累加器
acc和当前匹配项。我们在这里所做的只是进行一些解构,以便在函数内部轻松访问此上下文。我们还保留了原始映射acc和match的引用,因为我们的函数最终将返回它们的修改版本:(fn [{:keys [current-wins current-losses] :as acc} {:keys [winner-name] :as match}] ;; TODO: do something ) -
为当前匹配项引入一个
let绑定并将其插入累加器中的:matches向量:(fn [{:keys [current-wins current-losses] :as acc} {:keys [winner-name] :as match}] (let [this-match (assoc match :current-streak (streak-string current-wins current-losses))] (update acc :matches #(conj % this-match))))尽管我们的函数还没有完成,但这是最重要的数据流部分。
match参数被streak-string辅助函数格式化的当前连胜信息“装饰”,然后插入到函数最终将返回的:matches向量中。我们还没有上下文信息,所以这是下一步。 -
最后一步是生成上下文信息:我们需要更新累加器中的
:current-wins和:current-losses,以便我们为下一次迭代做好准备。这里的逻辑是,如果威廉姆斯赢得了当前比赛,那么我们需要将当前连胜数加 1 并将输球数重置为零。这些将用于计算下一场比赛的胜利和失败连败。相反,如果威廉姆斯输掉了上一场比赛,我们将当前胜利连败数设为零,并将输球连败数加 1。为了将这个逻辑转换为代码,我们首先添加另一个
let绑定,serena-victory?,稍后我们将引用它:serena-victory? (= winner-name "Williams S.")现在剩下的只是更新
acc中的:current-wins和:current-losses。我们将使用->连接宏,因为需要对累加器做一些事情:(-> acc (update :matches #(conj % this-match)) (assoc :current-wins (if serena-victory? (inc current-wins) 0)) (assoc :current-losses (if serena-victory? 0 (inc current-losses))))assoc的调用只是应用了上面讨论的条件逻辑,增加计数器或将它们重置为零。以下是当我们把所有部分放在一起时的完整函数:(defn serena-williams-win-loss-streaks [matches] (:matches (reduce (fn [{:keys [current-wins current-losses] :as acc} match] (let [this-match (assoc match :current-streak (streak-string current-wins current-losses)) serena-victory? (= (:winner-name match) "Williams S.")] (-> acc (update :matches #(conj % this-match)) (assoc :current-wins (if serena-victory? (inc current-wins) 0)) (assoc :current-losses (if serena-victory? 0 (inc current-losses)))))) {:matches [] :current-wins 0 :current-losses 0} matches)))在数据上尝试这个函数。你应该会看到类似这样的结果:

图 5.10:在数据上使用函数
这个例子展示了使用 reduce 的几个有趣之处。首先,我们可以自由地创建任何类型的上下文,并将其传递给减少函数的每一次迭代。为了使事情简单起见,我们只是计算了胜利或失败的数量,但上下文可以像你需要的那样复杂。
第二点是指最终返回值也可以是我们需要的任何值。在这个例子中的函数实际上看起来像是你可以用 map 做的事情:它接受一个序列并返回一个长度相同的序列。然而,通过从之前对减少函数的调用中构建数据,它做了 map 无法做到的事情。
不使用 reduce 的减少
在我们继续之前,重要的是要指出,有时在将序列转换为非序列时,除了 reduce 之外,还有其他更好选择。这通常是因为 Clojure 提供了为我们做艰苦工作的函数。有时,巧妙地使用 Clojure 的“序列到序列”函数可以让你得到所需的数据。
作为一条一般规则,在转向 reduce 之前,通常最好尽可能多地使用可以处理惰性序列的函数。在某些情况下,这可能是因为性能原因,在几乎所有情况下,如果你的代码可以保持在序列的领域,你的代码将更容易编写,更重要的是,更容易阅读。尽管如此,大多数解决方案都需要两者都有一点。知道如何结合两者是一项重要的技能。
zipmap
Clojure 的 zipmap 函数是一个从两个序列构建映射的工具。第一个序列成为新映射的键,第二个序列成为值。这通常用于构建查找表。当需要根据内容而不是列表中的位置重复访问序列中的数据时,查找表会非常方便。
可以想象一个这样的场景,这会很有用。也许在程序的一个步骤中,你有一个包含个人联系数据的映射列表。后来,你发现你经常需要电话号码并需要找到相应的用户。如果你有一个键为电话号码的查找表,你可以通过简单的 (get users-by-phone "+44 011 1234 5678") 找到用户。只要你有有意义且唯一的键,映射就可以提供方便的访问。
zipmap 的基本操作是对齐两个序列,一个用于键,一个用于值,zipmap 将它们“压缩”在一起:
user> (zipmap [:a :b :c] [0 1 2])
{:a 0, :b 1, :c 2}
通常,你只有第二个列表,即值。你将从列表中推导出值并将它们用作键。只是要小心,键必须是唯一的,以避免冲突。
练习 5.04:使用 zipmap 创建查找表
映射是一种极其有用且灵活的方法,可以快速访问数据。然而,通常你拥有的数据是顺序的,你可能会发现你希望能够访问单个项目而不必遍历整个序列。如果你知道你将使用的查找所需项目的标准,从你的数据中构建查找表可以是一个有趣的解决方案。
在这个练习中,你有一些 Petra Kvitova 在 2014 年参加的匹配列表。假设你需要能够快速通过日期访问匹配项,可能需要将它们插入某种类型的日历或测试同一天有哪些选手在比赛。无论原因如何,你需要构建一个映射,其中键是日期,值是单个匹配项。因为同一个选手永远不会在同一天打两场比赛,我们可以确信日期键是唯一的。以下是构建查找表的方法:
-
将以下变量从书的 GitHub 仓库复制到你的 REPL 中:
packt.live/39Joc2H:kvitova_matches.clj 1 (def matches 2 [{:winner-name "Kvitova P.", 3 :loser-name "Ostapenko J.", 4 :tournament "US Open", 5 :location "New York", 6 :date "2016-08-29"} 7 {:winner-name "Kvitova P.", 8 :loser-name "Buyukakcay C.", 9 :tournament "US Open", 10 :location "New York", 11 :date "2016-08-31"} The complete code for this step can be found at https://packt.live/2Ggpsgs -
使用
map创建一个包含每个匹配项日期的序列:user> (map :date matches) ("2016-08-29" "2016-08-31" "2016-09-02" "2016-09-05" "2016-09-20" "2016-09-21") -
将两个序列合并成一个映射:
user> (def matches-by-date (zipmap (map :date matches) matches)) #'user/matches-by-date -
使用映射通过日期查找匹配项:
user> (get matches-by-date "2016-09-20") {:winner-name "Kvitova P.", :loser-name "Brengle M.", :tournament "Toray Pan Pacific Open", :location "Tokyo", :date "2016-09-20"}
在一行代码中,你已经创建了一种快速找到给定日期匹配的方法。因为它非常简洁,这种模式可以很容易地集成到更复杂的函数中。
注意
建立这样的查找表在内存资源方面可能看起来是浪费的。我们不是在内存中加倍数据量吗?实际上,我们并没有。Clojure 的不可变数据结构有效地共享数据,这是在不冲突的情况下实现的,因为数据不能被修改。这意味着在这个例子中,原始序列和我们创建的查找表基本上是访问相同数据的方式。
映射到序列,再回到映射
我们在上一章中简要提到的一种最有用的技术是使用into从成对的项列表构建映射。这种模式非常灵活,值得仔细研究。
在其最简单形式中,这个模式看起来是这样的:
user> (into {} [[:a 1] [:b 2]])
{:a 1, :b 2}
总的来说,映射实际上只是数据对,Clojure 知道如何在这两者之间进行转换。从映射中创建元组序列同样简单:
user> (seq {:a 1 :b 2})
([:a 1] [:b 2])
当使用映射作为映射有意义时,请使用它,但不要犹豫,当使用它作为序列更容易时,就使用它。
当你需要“修改”一个映射(在 Clojure 中不是真正修改,而是创建一个新的映射,其中包含修改后的数据)时,你可能倾向于使用keys函数来遍历映射中的值:
user> (def letters-and-numbers {:a 5 :b 18 :c 35})
#'user/letters-and-numbers
user> (reduce (fn [acc k]
(assoc acc k (* 10 (get letters-and-numbers k))))
{}
(keys letters-and-numbers))
{:a 50, :b 180, :c 350}
在这里,我们使用reduce将每个值乘以 10。这可行,但它增加了问题的复杂性和心理负担,而这个问题可以更容易地解决:
user> (into {} (map (fn [[k v]] [k (* v 10)]) letters-and-numbers))
{:a 50, :b 180, :c 350}
我们只是将映射letters-and-numbers解释为键值对的列表。在提供给map的函数中,我们使用了解构来将k和v分配给元组内的键和值,然后我们再次将其包装在两个元素的向量中。多亏了into,我们最终又得到了一个映射。
注意
为了方便,还有一个专门用于遍历映射中的键值对的reduce版本,称为reduce-kv。主要区别在于,使用reduce-kv时,你提供的减少函数接受三个参数,而不是两个:第一个与reduce相同,但下一个是映射中的键和相应的值。
group-by
在 Clojure 中总结数据并不总是意味着直接调用reduce。该语言提供了构建在reduce之上的函数,有时这些函数更方便。group-by就是其中之一。
group-by函数接受一个序列,对每个项目调用一个函数,并使用函数调用返回的任何内容作为映射中的键。键的值将是返回相同键的所有项目的列表。
假设我们有一个映射列表,其中每个映射代表一道菜,有一个:name键用于菜名,还有一个:course字段告诉我们这道菜是在餐的哪个部分上提供的:
(def dishes
[{:name "Carrot Cake"
:course :dessert}
{:name "French Fries"
:course :main}
{:name "Celery"
:course :appetizer}
{:name "Salmon"
:course :main}
{:name "Rice"
:course :main}
{:name "Ice Cream"
:course :dessert}])
使用group-by,我们可以按类别组织这个列表。我们对每个项目调用的函数只是:course关键字,以提取相应的值:
注意
对于我们的大部分示例,我们使用关键字作为映射键。这通常更易于阅读,并提供了使用关键字作为函数的便利性。然而,Clojure 允许我们使用任何值作为映射键。就像我们一直使用字符串作为网球运动员的名字一样,你也可以使用任何 Clojure 值作为映射键:整数、向量、映射,甚至是函数!
user> (group-by :course dishes)
{:dessert
[{:name "Carrot Cake", :course :dessert}
{:name "Ice Cream", :course :dessert}],
:main
[{:name "French Fries", :course :main}
{:name "Salmon", :course :main}
{:name "Rice", :course :main}],
:appetizer
[{:name "Celery", :course :appetizer}]}
通过非常少的编码,我们得到了一个组织良好的映射。group-by在底层使用reduce,实际上只是封装了一个相当简单的模式。我们可以像这样编写group-by的简化版本:
user> (defn our-group-by [f xs]
(reduce (fn [acc x]
(update acc (f x) (fn [sublist] (conj (or sublist []) x))))
{}
xs))
#'user/our-group-by
如果我们在菜肴列表上调用our-group-by,我们会得到相同的结果:
user> (our-group-by :course dishes)
{:dessert
[{:name "Carrot Cake", :course :dessert}
{:name "Ice Cream", :course :dessert}],
:main
[{:name "French Fries", :course :main}
{:name "Salmon", :course :main}
{:name "Rice", :course :main}],
:appetizer [{:name "Celery", :course :appetizer}]}
官方版本将具有更好的性能,但像group-by这样的函数真正的优势是它使我们从细节中解放出来。任何时候你有一个列表和一些类别,group-by都准备好帮助你。
练习 5.05:使用 group-by 进行快速汇总统计
在这个练习中,我们将使用group-by来快速统计我们网球比赛数据中不同锦标赛进行的比赛数量:
-
在与练习 5.03,赢和输的连败相同的目录下创建一个
deps.edn文件,内容如下:{:deps {org.clojure/data.csv {:mvn/version "0.1.4"} semantic-csv {:mvn/version "0.2.1-alpha1"}}} -
将
tennis_reduce.clj中的命名空间声明更改,使其引用这两个新库:(ns packt-clj.tennis-reduce (:require [clojure.java.io :as io] [clojure.data.csv :as csv] [semantic-csv.core :as sc])) -
在与上一个练习相同的目录下启动一个 REPL,使用相同的
deps.edn文件,然后打开并评估tennis_reduce.clj。 -
在你的 REPL 中,移动到
packt-clj.tennis-reduce命名空间,如下所示:user> (in-ns 'packt-clj.tennis-reduce) -
确保你有一个
match_scores_1968-1990_unindexed_csv.csv文件在相同的目录下。这是我们第四章,映射和过滤中使用的相同数据文件。你可以在这里找到它:packt.live/36k1o6X。 -
设置现在熟悉的
with-open宏,并给你的函数一个有表达力的名字:(defn tennis-csv->tournament-match-counts [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify ;;.... ))) -
编写一个调用
group-by的代码,构建一个键为:tourney_slug实例,值为在该地进行的比赛列表的映射。为了使输出更易于管理,可以通过select-keys映射列表来临时移除比赛映射中的所有但少数键:(defn tennis-csv->tournament-match-counts [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (map #(select-keys % [:tourney_slug :winner_name :loser_name])) (group-by :tourney_slug)))) -
评估源文件,然后尝试调用此函数。定义一个变量,这样你的屏幕就不会充满网球比赛数据:
packt-clj.tennis-reduce> (def tournaments (tennis-csv->tournament-match-counts "match_scores_1991-2016_unindexed_bcsv.csv")) #'user/tournaments -
检查一些数据,首先使用
keys函数,以查看所有锦标赛的名称:packt-clj.tennis-reduce> (keys tournaments) ("chicago" "bologna" "munich" "marseille" "dubai" "milan" "buzios" "miami" "warsaw" "bucharest" "wimbledon" "umag" "besancon" ;; ....etc. ) -
看一个单独的锦标赛。再次限制返回的数据,这次使用
take:packt-clj.tennis-reduce> (take 5 (get tournaments "chicago")) ({:tourney_slug "chicago", :winner_name "John McEnroe", :loser_name "Patrick McEnroe"} {:tourney_slug "chicago", :winner_name "John McEnroe", :loser_name "MaliVai Washington"} {:tourney_slug "chicago", :winner_name "Patrick McEnroe", :loser_name "Grant Connell"} {:tourney_slug "chicago", :winner_name "John McEnroe", :loser_name "Alexander Mronz"} {:tourney_slug "chicago", :winner_name "Patrick McEnroe", :loser_name "Richey Reneberg"}) -
使用
count来获取单个锦标赛进行的比赛数量:packt-clj.tennis-reduce> (count (get tournaments "chicago")) 31 -
使用原始函数中的
count计算每个锦标赛进行的总比赛数:(defn tennis-csv->tournament-match-counts [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (group-by :tourney_slug) (map (fn [[k ms]] [k (count ms)])) (into {}))))注意到
select-keys已经消失了。由于我们将比赛列表减少为一个单一整数,删除任何字段都没有意义。在这里,我们使用了一个你之前见过的模式:map的调用将 Clojure 映射视为键值对的序列。传递给map的函数然后返回一个包含关键字和新生成的总数的两项向量。最后,into将序列重新包装成一个映射。 -
再次评估文件并再次调用
tennis-csv→tournament-match-counts:packt-clj.tennis-reduce> (def tournament-totals (tennis-csv->tournament-match-counts "match_scores_1991-2016_unindexed_csv.csv")) #'user/tournament-totals -
检查数据:
packt-clj.tennis-reduce> (get tournament-totals "chicago") 31 packt-clj.tennis-reduce> (get tournament-totals "wimbledon") 4422 packt-clj.tennis-reduce> (get tournament-totals "roland-garros") 4422 packt-clj.tennis-reduce> (get tournament-totals "australian-open") 4422 packt-clj.tennis-reduce> (get tournament-totals "us-open") 4422在这里,我们可以看到大满贯赛事在同一时期内拥有相同数量的比赛,因为这些七轮赛事的结构是完全相同的。
在这个练习中,我们能够用几行简短的代码提取我们想要的数据。除了展示group-by的强大功能外,这也是映射和减少如何一起工作的一个好例子。我们使用group-by来结构我们的数据,然后使用map来进一步塑造它。如果我们想限制调查的范围为某些球员或某些赛事,例如,使用filter也很容易想象。
总结网球比分
在上一章中,我们能够使用filter从网球比分中生成一些总结数据。如果我们想知道特定球员赢得了多少场比赛,我们可以过滤掉该球员的胜利并调用count。虽然当我们的兴趣仅限于一个球员时这种方法效果很好,但如果我们需要更完整的数据,它就会变得繁琐。例如,如果我们需要知道数据集中每位球员参加或赢得的比赛数量,我们就必须为每个查询过滤所有比赛的整个历史。map和filter函数在许多情况下非常有用,但将大量集合减少为一个更紧凑的报告并不是它们最擅长的。
假设对于每位球员,我们需要知道他们所参加的比赛数量、赢得的比赛数量和输掉的比赛数量。我们将通过两种不同的方式在 Clojure 中解决这个问题,第一种是使用reduce,第二种是使用group-by,这是 Clojure 众多方便的基于reduce的功能之一。
在第一个练习中,我们将使用一种常见的减少模式,逐行从 CSV 文件构建我们的数据。当然,我们将使用带有映射作为累加器的三参数版本的reduce。
练习 5.06:使用 reduce 进行复杂累积
对于我们一直在使用的网球数据集中的每一行 CSV,我们需要完成的工作相当简单:计算胜利和失败的数量。在命令式语言中,最常见的方法是遍历结果,并对每一行执行类似以下操作:
var matches = [{winner_slug: 'Player 1',
loser_slug: 'Player 2'},
{winner_slug: 'Player 2',
loser_slug: 'Player 1'}];
var players = {}
for (var i = 0; i < matches.length; i++) {
var winnerSlug = matches[i].winner_slug;
var loserSlug = matches[i].loser_slug;
if (!players[winnerSlug]) {
players[winnerSlug] = {wins: 0, losses: 0};
}
players[winnerSlug].wins = players[winnerSlug].wins + 1;
if (!players[loserSlug]){
players[loserSlug] = {wins: 0, losses: 0};
}
players[loserSlug].losses = players[loserSlug].losses + 1;
}
console.log(players);
我们将在 Clojure 中做同样的事情,但这是在传递给reduce的函数的作用域内:
-
打开
tennis_reduce.clj,启动你的 REPL,评估文件,然后移动到with-open模式中的packt-Copy,并准备对reduce的调用框架。我们将把这个函数称为win-loss-by-player:(defn win-loss-by-player [csv] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (reduce (fn [acc row] ) {} ; an empty map as an accumulator ))))这次我们不需要调用
sc/cast-with,因为我们只需要字符串值。而且我们也不需要调用doall,因为reduce不是惰性的。 -
编写传递给
reduce的函数:(fn [acc {:keys [winner_slug loser_slug]}] (-> acc (update-in [winner_slug :wins] (fn [wins] (inc (or wins 0)))) (update-in [loser_slug :losses] (fn [losses] (inc (or losses 0))))))在这里,我们使用
->来将累加器acc通过两次调用update-in。这个函数,就像assoc-in一样,允许我们通过提供一个向量作为第二个参数来通过访问嵌套数据结构的内容。这个例子中的每个调用看起来会是这样:(update-in acc ["roger-federer" :wins] (fn [wins] (inc (or wins 0))))通过在
reduce遍历比赛列表时重复调用累加器,我们最终得到一个大型映射,将玩家 "slugs" 映射到包含:wins键和:losses键的小映射:{ ... "player" {:wins 10 :losses 5} "another-player" {:wins 132 :losses 28} ... } -
评估你的文件并尝试在 CSV 数据上运行该函数。将结果放入变量中,以避免在屏幕上填充所有数据:
packt-clj.tennis-reduce> (def w-l (win-loss-by-player "match_scores_1991-2016_unindexed_csv.csv")) #'user/w-l -
使用 "slug" 查找玩家:
packt-clj.tennis-reduce> (get w-l "roger-federer") {:losses 240, :wins 1050}注意
我们在这里需要使用
get,因为我们的映射中的键是字符串。如果我们使用keyword函数在构建映射时转换玩家的 "slugs",我们可以使用(:roger-federer w-l)来访问玩家的数据。
Elo 介绍
在本章的其余部分以及接下来的几章中,我们将使用 Elo 评分系统来开发玩家评分并预测比赛结果。该算法本身相当简单,它将允许我们展示 Clojure 可以用作数据分析工具。由于我们将大量引用它,因此值得仔细看看它是如何工作的。Elo 评分系统是由 Arpad Elo 为棋手评分而开发的。美国棋艺联合会从 1960 年开始使用它。
Elo 评分通过为每个玩家建立一个分数来实现。这个分数用于计算比赛的预期结果。当比赛的真实结果已知时,玩家的评分会根据他们的表现相对于预期结果而上升或下降。换句话说,如果一个低评分的新手输给了高评分的玩家,新手的评分不会下降太多,因为这种结果是可以预见的。另一方面,如果他们击败了高评分的玩家,他们的评分将大幅上升,而高评分玩家的评分将相应地降低。
显然的问题当然是,我们最初是如何知道一个玩家的评分的?这需要查看他们的先前比赛,以及对手的评分,而这些对手的评分反过来又是由他们的对手的评分决定的,以此类推。如果这听起来像是递归的,那是因为它确实是。我们的策略将采取复杂简化的形式:从最早的比赛开始,我们将累积玩家的评分,然后我们将使用这些评分来计算每场后续比赛的分数:

图 5.11:从一场比赛到下一场比赛的评分降低:每次结果都会提高下一场比赛的评分
这看起来熟悉吗?可能是因为我们正在做与之前减少相同的事情:计算上下文,将其向前移动,并再次用于下一次计算。与这个项目不同的是,上下文要复杂得多。我们通过数据的方法在本质上是一样的。
在我们能够执行这个降低操作之前,我们需要构建 Elo 实现的一些关键部分。Elo 系统的核心是一个简单的公式,用于确定玩家赢得特定比赛的概率。它看起来是这样的:

图 5.12:计算给定比赛的结局概率
P1 和 P2 这里是玩家一和玩家二获胜的概率。R1 和 R2 是他们在比赛前的相应评分。
如果我们为一名评分为 700 的玩家和一名评分为 1,000 的更强玩家之间的匹配填充值,我们得到以下结果:

图 5.13:单场比赛的 Elo 计算示例
P1 值表示较弱玩家赢得比赛的概率为 15%,较强玩家赢得比赛的概率为 85%。这些百分比的可靠性当然取决于评分的质量。然而,在我们查看评分是如何计算之前,让我们将这些方程式转换为 Clojure 函数。
练习 5.07:计算单场比赛的概率
在这个练习中,我们将为我们的 Elo 系统实现设置一个构建块,即计算两名玩家在比赛中获胜概率的公式,基于他们当前的评分:
-
在与之前练习相同的文件夹中,将
math.numeric-tower,这是 Clojure 的标准math库,添加到您的deps.edn文件中。现在它应该看起来像这样:{:deps {org.clojure/data.csv {:mvn/version "0.1.4"} semantic-csv {:mvn/version "0.2.1-alpha1"} org.clojure/math.numeric-tower {:mvn/version "0.0.4"}}}在
tennis_reduce.clj中更新命名空间声明:(ns packt-clj.tennis-reduce (:require [clojure.java.io :as io] [clojure.data.csv :as csv] [semantic-csv.core :as sc] [clojure.math.numeric-tower :as math])) -
打开一个 REPL 会话,评估
tennis_reduce.clj,并移动到packt-clj.tennis-reduce命名空间。 -
编写一个函数来实现计算玩家击败另一名玩家概率的公式:
packt-clj.tennis-reduce> (defn match-probability [player-1-rating player-2-rating] (/ 1 (+ 1 (math/expt 10 (/ (- player-2-rating player-1-rating) 400))))) -
用不同强度的玩家尝试你的函数:
packt-clj.tennis-reduce> (match-probability 700 1000) 0.15097955721132328 packt-clj.tennis-reduce> (match-probability 1000 700) 0.8490204427886767 packt-clj.tennis-reduce> (match-probability 1000 1000) 1/2 packt-clj.tennis-reduce> (match-probability 400 2000) ;; beginner vs. master 1/10001
当比赛最终进行时,如果强手获胜,他们的评分将适度增加(而对手的评分将略有下降),因为结果并不令人惊讶。另一方面,如果弱手获胜,评分的变化将更加显著。
这个方程式显示了比赛后玩家分数是如何更新的:

图 5.14:计算选手得分的方程
选手的新评级(R')基于他们的前一个评级(R),比赛得分(S),预期得分(ES)和K因子。
网球比赛的得分(S)要么为 0(输球),要么为 1(胜利)。如果一个选手预计以 0.75 的概率获胜,并且他们最终赢得了比赛,那么方程中的(S - ES)部分计算为 1 - 0.75 = 0.25。这个结果会被 Elo 系统称为"K因子"的乘数。K因子决定了比赛结果对选手整体评级的影响。高K因子意味着评级会有很大的波动;低K因子意味着它们会更稳定。如果我们使用K因子为 32,那么我们得到 32 * 0.25 = 8,所以在这个例子中,选手的评级将上升 8 分。如果选手输了,我们会得到 32 * (0 - 0.75) = -24。再次强调,意外结果对评级的影响要大得多。
练习 5.08:更新选手评级
在这个练习中,我们将更新选手评级:
-
在与上一个练习相同的文件和 REPL 会话中,定义一个
k-factor变量和一个封装更新选手评级方程的函数:packt-clj.tennis-reduce> (def k-factor 32) #'packt-clj.tennis-reduce/k-factor packt-clj.tennis-reduce> (defn recalculate-rating [previous-rating expected-outcome real-outcome] (+ previous-rating (* k-factor (- real-outcome expected-outcome)))) #'packt-clj.tennis-reduce/recalculate-rating现在,让我们用之前定义的
match-probability函数的一些输出测试这个方程。 -
评级为 1,500 分的选手输给了略弱一些的选手(1,400 分):
packt-clj.tennis-reduce> (match-probability 1500 1400) 0.6400649998028851 packt-clj.tennis-reduce> (recalculate-rating 1500 0.64 0) 1479.52选手的评级下降了近 21 分。
-
评级较低的选手,400 分,击败了强大的选手(1,000 分):
packt-clj.tennis-reduce> (match-probability 400 1000) 0.030653430031715508 packt-clj.tennis-reduce> (recalculate-rating 400 0.03 1) 431.04在这个例子中,理论上较弱的选手获得了 31 分,这接近于当K为 32 时的每场比赛可能获得的最大分数。这显示了K如何决定单场比赛的重要性。
这两个方程就是我们所需要的所有数学。就是这样!Elo 系统的美妙之处在于实际的计算相当简单。现在,是时候开始使用我们编写的函数处理一些真实数据了。
活动 5.01:计算网球 Elo 评级
一家体育新闻网站要求你提供男子职业网球巡回赛的改进评级。他们想知道当前选手的相对实力,以及过去某一年特定选手的实力。最重要的是,记者们希望得到未来比赛的优质预测。
你的任务是构建这个新系统的原型。目前基于 REPL 的实现是可以的,但重要的是能够根据过去的比赛数据展示结果的准确性。
要做到这一点,你需要编写一个函数,使用reduce解析我们一直在使用的 CSV 文件。这个函数不仅会计算选手评级,还会跟踪其预测比赛结果的成功率。这将允许你向记者展示你的算法效果如何,在此之前,它将允许你调整代码以获得最佳可能的预测。
您的累加器映射需要构建以下信息:
-
玩家评分:这是最重要的部分:一个巨大的映射,将每个玩家与其评分相关联。该映射将更新为正在分析的比赛中的两位玩家的新评分。
-
成功次数:对于每一场比赛,其中两位选手中有一位有超过 50%的胜率,预期的赢家实际上是否获胜?通过计算成功次数,您将能够除以总的比赛预测次数,以确定您预测的精确度。
-
总比赛次数:已考虑的比赛总数。
-
预测次数:可以预测赢家(即预测不是 50-50 的比赛)的比赛数量。由于我们从成功次数中排除了这些比赛,因此我们也需要从预测次数中排除它们。
这些步骤将帮助您完成活动:
-
使用包含您将使用的 Clojure 库的必要引用的
deps.edn文件设置您的项目,并包括来自packt.live/37DCkZn的网球数据文件。 -
在新文件和命名空间中放置您的作品。包括来自前一个练习的
recalculate-rating和match-probability函数。 -
为新的函数编写骨架。它应该接受两个参数:CSV 文件的路径和 K 因子。
-
根据之前活动和练习中使用的
with-open模式调整,用于读取文件、映射每一行并将有用的字段转换为整数。 -
准备一个
reduce调用,它将包含您需要编写的剩余逻辑的大部分。 -
设计一个初始化器/累加器映射,作为
reduce的第二个参数,以便充分跟踪您需要从一个迭代传递到下一个迭代的所有信息。 -
编写代码以在每个比赛后更新累加器。使用您已经拥有的函数来预测赢家并根据比赛的实际情况调整评分。
-
在网球数据集上测试您的函数。
-
在测试时,结果将会很大,所以请记住将它们分配给一个变量。通过查询结果映射来检查您的结果。
-
您应该能够像这样查询您的结果:

图 5.15:预期的查询结果
您还应该能够检查比赛预测的正确率:

图 5.16:检查比赛预测
注意
本活动的解决方案可以在第 693 页找到。
摘要
通过本章,我们在探索 Clojure 的集合以及如何使用它们解决问题的过程中又迈出了重要的一步。涉及集合的技术将始终是您 Clojure 编程体验的核心:它们将指导您如何组织代码,以及您如何选择和设计数据结构。
在下一章中,我们将探讨在 Clojure 中灵活处理集合的方法。
第六章:6. 递归与循环
概述
在本章中,你将学习更灵活的方式来处理集合。当你需要解决的问题不适合我们之前看到的模式时。我们还将使用 doseq 进行具有副作用循环,并了解如何通过使用专门的重复函数,如 repeat 和 iterate,来避免编写一些循环。你将使用 recur 进行递归循环,并确定何时这是可能的,使用 loop 宏来工作,并通过递归解决复杂问题。
在本章结束时,你将能够实现递归的不同方面,并了解它们如何取代传统的循环。
简介
我们程序中的数据并不总是以 map 或 reduce 等函数特别适应的整洁线性形式存在。我们在前两章中讨论的所有技术都无法用于遍历非线性结构,如树或图。虽然通过创意使用 reduce 可以做很多事情,但 reduce 提供的强大防护栏有时可能会妨碍编写表达性代码。有些情况需要程序员有更多控制权的工具。Clojure 为这类问题提供了其他资源,这就是我们将在本章中探讨的内容。
当诸如 map 和 reduce 等函数不再适应手头的任务时,递归扮演着重要的角色。递归思维是学习 Clojure 技能的一个重要方面。因为函数式编程语言往往强调递归,如果你的背景是更程序性的语言,这可能会显得有些不熟悉。大多数编程语言实际上都支持递归,所以这个概念并不一定那么陌生。此外,我们用 reduce 做的一些事情实际上是递归的,所以即使你之前很少使用递归,学习曲线也不应该那么陡峭。
话虽如此,如果你在函数式编程方面没有太多经验,递归的一些方面可能需要你以新的方式思考。与 map 和 filter 或甚至 reduce 相比,递归方法要灵活得多。而“灵活”意味着强大但容易出错。当我们试图让递归函数只做我们想要的事情时,我们会犯错误,最终陷入无限循环,耗尽调用栈(我们很快就会讨论这意味着什么)或遇到其他类型的错误,否则这些错误根本不可能发生。这就是为什么“循环”,无论是使用 loop 宏还是递归函数,都应该是在其他选项不起作用时你转向的选择。
Clojure 最程序性的循环:doseq
在我们开始递归之前,让我们先看看 doseq 宏。它可以说是 Clojure 循环替代方案中最程序性的。至少,它看起来很像其他语言中找到的 foreach 循环。下面是 doseq 的一个非常简单的用法:
user> (doseq [n (range 5)]
(println (str "Line " n)))
Line 0
Line 1
Line 2
Line 3
Line 4
nil
如果将其翻译成英文,我们可能会说:“对于从 0 到 5 的每个整数,打印出一个包含单词 'Line' 和整数的字符串。”你可能会问:“那里那个 nil 是做什么的?”这是个好问题。doseq 总是返回 nil。换句话说,doseq 不收集任何东西。doseq 的唯一目的是执行副作用,比如打印到 REPL,这正是 println 在这里所做的。出现在你的 REPL 中的字符串——Line 0、Line 1 等等——不是返回值;它们是副作用。
注意
在 Clojure 中,就像在 Lisp 家族中的许多语言中一样,产生副作用的函数通常以感叹号结尾。虽然这不是一个严格的规则,但这种约定确实使代码更容易阅读,并有助于提醒我们注意副作用。Clojure 开发者经常使用感叹号来表示一个函数修改了一个可变的数据结构,写入文件或数据库,或执行任何在函数作用域之外产生持久影响的操作。
那么,为什么不直接使用 map 呢?好吧,有几个很好的理由。第一个是 map 并不保证整个序列都会被执行。map 函数是惰性的,而 doseq 不是。
通常情况下,在使用 map、filter、reduce 以及所有其他序列操作函数时,你应该始终尝试使用 println 语句,这可能会救命。(不过,记住,println 返回 nil,所以你必须小心不要将其放在函数的末尾,否则它会掩盖返回值)。对于某种类型的顺序数据,当你想要产生副作用时,使用 doseq 是很重要的,而且仅在此情况下。通过对此严格,你也会使你的代码更容易阅读和维护。doseq 是你源代码中的一个标志,表示:“小心,这里可能有副作用!”它也是一个明确的信号,表明我们并不关心返回值,因为 doseq 总是返回 nil。这种做法鼓励开发者将具有副作用的代码隔离在程序的具体部分。
但如果我们只想在前面例子中的奇数行打印某些内容怎么办?这里有我们可以这样做的一种方法:
(doseq [n (range 5)]
(when (odd? n)
(println (str "Line " n))))
这段代码本身并没有问题。然而,作为一般规则,最好是从 doseq 的主体中移除尽可能多的逻辑,可能的做法如下:
(doseq [n (filter odd? (range 5))]
(println (str "Line " n)))
通过在数据形状的地方和消费数据的地方之间强制分离,我们不仅移除了条件语句,而且以一种方式组织了我们的代码,这为更好的实践打开了大门。也许在将来,我们可能需要选择不同的行来打印。如果发生这种情况,我们的代码已经处于正确的位置,用 Clojure 序列处理的清晰词汇编写,并且可能从惰性评估中受益。记住:塑造数据,然后使用数据。
循环快捷方式
通常,最好避免编写真正的循环。Clojure 提供了一些有趣的函数,可以在一些简单的情况下提供帮助,在这些情况下,你真正想要的只是某种形式的重复。与本章中的大多数技术不同,这些函数返回懒序列。我们在这里提到它们,因为很多时候,当一开始可能需要循环时,这些函数提供了一个更简单的解决方案。
最简单的例子是 repeat 函数,它如此简单,几乎不算是循环结构。然而,它有时仍然很有用。repeat 简单地重复它被调用的任何值,返回一个该值的懒序列。这是一个重复自己的简单方法:
user> (take 5 (repeat "myself"))
("myself" "myself" "myself" "myself" "myself")
是的,就这么简单。尽管如此,如果你需要快速将默认值加载到映射中,它仍然可能很有用。想象一个游戏,其中每个玩家都由一个映射表示。你需要用各种计数器的默认值初始化玩家,其中大多数的默认值是 0。一种方法是用 repeat。由于 repeat 返回一个懒序列,它将提供你所需要的所有零:
user> (zipmap [:score :hits :friends :level :energy :boost] (repeat 0))
{:score 0, :hits 0, :friends 0, :level 0, :energy 0, :boost 0}
repeat 函数之后的下一步是 repeatedly 函数。repeatedly 不是接受一个值,而是接受一个函数,并返回对该函数的懒序列调用。提供给 repeatedly 的函数不能接受任何参数,这限制了它在 repeatedly 中的用途,就像 repeat 会返回一个相同值的列表一样。
repeatedly 最常见的用途是生成随机值的序列。rand-int 的调用可能会每次都变化(除非,当然,你调用 (rand-int 1),它只能返回 0。)这是一个生成从 0 到 100 的随机整数的列表的好方法,其中 repeatedly 简单地调用 rand-int 10 次。rand-int 的输出几乎每次都不同,因此生成的序列是一系列随机整数:
user> (take 10 (repeatedly (partial rand-int 100)))
(21 52 38 59 86 73 53 53 60 90)
为了方便,repeatedly 可以接受一个整数参数,限制返回值的数量。我们可以不调用 take 就写出前面的表达式,如下所示:
user> (repeatedly 10 (partial rand-int 100))
(55 0 65 34 64 19 21 63 25 94)
在下一个练习中,我们将尝试一个更复杂的场景,使用 repeatedly 生成随机测试数据。
repeatedly 之后的下一步是一个名为 iterate 的函数。像 repeatedly 一样,iterate 会反复调用一个函数,返回结果懒序列。然而,提供给 iterate 的函数接受参数,并且每次调用的结果会传递给下一次迭代。
假设我们有一个年利率为 1% 的银行账户,我们想要预测下一年每个月的余额。我们可以编写一个像这样的函数:
user> (defn savings [principal yearly-rate]
(let [monthly-rate (+ 1 (/ yearly-rate 12))]
(iterate (fn [p] (* p monthly-rate)) principal)))
为了预测未来 12 个月的余额,我们将请求 13 个月的余额,因为返回的第一个值是起始余额:
user> (take 13 (savings 1000 0.01))
(1000
1000.8333333333333
1001.667361111111
1002.5020839120368
1003.3375023152968
1004.1736169005594
1005.0104282479765
1005.847936938183
1006.6861435522981
1007.5250486719249
1008.3646528791514
1009.2049567565506
1010.045960887181)
通过每月复利,你已经比年利率多赚了几乎 5 分!
repeatedly和iterate这样的函数可以在非常具体的情况下使用,它们完美地匹配你的需求。然而,现实世界通常要复杂一点。有时,手头的任务将需要编写定制的数据遍历方式。现在是时候转向递归了。
练习 6.01:无限流杂货
您的雇主正在构建一个系统来自动处理从传送带下来的杂货。作为他们研究的一部分,他们希望您构建一个模拟器。目标是有一个无限流随机杂货。您的任务是编写一个函数来完成这项工作:
-
在一个方便的地方创建一个新的目录;添加一个只包含一个空映射的
deps.edn文件,并启动一个新的 REPL。 -
以一个有表达力的名称,如
groceries.clj,打开一个新文件,并包含相应的命名空间声明:(ns groceries)在我们开始之前,我们需要构建我们的杂货店模拟器。第一步是定义所有可能的商品。(这个商店提供的选项不多,但至少它有无限供应。)将
grocery-articles变量从packt.live/2tuSvd1复制到你的 REPL 中并评估它:grocery_store.clj 3 (def grocery-articles [{:name "Flour" 4 :weight 1000 ; grams 5 :max-dimension 140 ; millimeters 6 } 7 {:name "Bread" 8 :weight 350 9 :max-dimension 250} 10 {:name "Potatoes" 11 :weight 2500 12 :max-dimension 340} 13 {:name "Pepper" 14 :weight 85 15 :max-dimension 90} The full file is available at https://packt.live/35r3Xng. -
定义一个函数,该函数将返回包含随机排序杂货文章的长列表:
(defn article-stream [n] (repeatedly n #(rand-nth grocery-articles)))rand-nth每次被调用时都会从grocery-articles中返回一个随机选择的商品。repeatedly创建了一个对rand-nth的调用懒惰序列。n参数告诉repeatedly返回多少个随机商品。 -
通过请求一些文章来测试函数:
groceries> (article-stream 12) ({:name "Olive oil", :weight 400, :max-dimension 280} {:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Green beans", :weight 300, :max-dimension 120} {:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Flour", :weight 1000, :max-dimension 140} {:name "Ice cream", :weight 450, :max-dimension 200} {:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Green beans", :weight 300, :max-dimension 120} {:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Ice cream", :weight 450, :max-dimension 200} {:name "Pepper", :weight 85, :max-dimension 90} {:name "Bread", :weight 350, :max-dimension 250}) -
再次尝试以确保结果是随机的:
groceries> (article-stream 5) ({:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Green beans", :weight 300, :max-dimension 120} {:name "Bread", :weight 350, :max-dimension 250} {:name "Olive oil", :weight 400, :max-dimension 280} {:name "Pepper", :weight 85, :max-dimension 90})它似乎工作得很好。这展示了在在其他语言中可能更自然地写
for循环的情况下,函数是如何被组合的。在 JavaScript 中,我们可能再次编写一个像这样的函数(假设groceryArticles是一个对象数组):function randomArticles (groceryArticles, n) { var articles = []; for (var i = 0; i < n.length; i++) { articles.push( groceryArticles[Math.random(groceryArticles.length – 1)] ); } return articles; }repeatedly这样的函数提供了一种简洁的方式来表达这一点,并节省了我们编写所有这些迭代逻辑的麻烦。
递归的简单应用
正如我们之前所说的,递归函数是一种在执行过程中会调用自身的函数。直观上,递归可以想象成你可能见过的那种图片,其中在主图片内部有一个原始图片的小版本。由于第二个图片与第一个图片相同,它也包含了一个非常小的第三个图片版本。之后,任何进一步的图片通常都很难表示成比一个微小的点更大的东西。然而,即使我们看不到它们,我们也可以想象这个过程基本上永远进行下去……或者至少达到分子水平。递归以类似的方式工作。而且,像图片中那样一直持续进行的递归过程的问题也是一个非常现实的问题。然而,在我们查看递归的陷阱之前,让我们先看看一些简单的例子。
首先,我们将做一件你已经知道如何做的事情:找到一组数字的总和。在现实生活中,你永远不会为此使用递归,但这个问题故意设计得简单,这样我们就可以指出一些递归的机制:
(defn recursive-sum [so-far numbers]
(if (first numbers)
(recursive-sum
(+ so-far (first numbers))
(next numbers))
so-far))
这个函数的调用看起来是这样的:
user> (recursive-sum 0 [300 25 8])
333
这可能对你来说很熟悉,因为这与我们传递给reduce的函数非常相似。这并不令人惊讶。我们甚至可以将reduce视为“受控递归”或“有护栏的递归”的框架,这就是为什么在可能的情况下通常最好使用reduce,只有在必须时才使用递归。
虽然这里有一些重要的区别,所以让我们更仔细地看看这个函数是如何工作的。首先要注意的是有两个分支的条件语句:(if (first numbers))。当我们第一次调用这个函数时,(first numbers)返回 300。这是真值,所以我们继续进行,并且立即我们的函数再次调用recursive-sum(我们警告过你,递归中会有很多这种情况)。函数再次被调用,但参数不同:(first numbers)被加到我们的累加器so-far上,而不是再次使用numbers作为第二个参数,我们有了(next numbers)。
每次调用recursive-sum,从输入序列到输出整数的一个整数就会被移动:

图 6.1:递归地将输入序列中的项移动到输出整数
使用reduce,我们不需要考虑如何遍历序列。reduce函数本身负责迭代的机制:从一个项目移动到下一个项目,并在没有更多项目时停止。在递归函数中,我们必须确保每个函数调用都接收到正确的数据,并且在数据被消耗后函数停止。当你需要它时,递归非常强大,因为作为程序员,你完全控制迭代。你可以决定每个后续调用的参数是什么。你也可以决定递归如何以及何时停止。
那么,我们如何遍历序列呢?在每次调用recursive-sum时,输入序列被分成第一个项目和所有后续项目之间。first的调用给我们当前的项目,而next的调用帮助设置下一次函数调用的参数。重复这个分割动作使我们沿着序列向下移动。
然而,还有一个问题:我们何时停止?这就是我们的函数,就像绝大多数递归函数一样,围绕一个条件组织起来的原因。我们的条件很简单:停止添加还是继续?next函数在这里也很重要。当对一个空列表调用时,next返回nil:
user> (next '())
nil
在这种情况下,nil 可靠地意味着是时候停止迭代并返回我们积累的值了。更复杂的情况将需要更复杂的分支,但基本思想通常保持不变。
练习 6.02:分割购物袋
在这个练习中,我们将回到上一个练习中的购物传送带模拟。现在我们能够模拟一个无限的文章流,我们需要能够在传送带末端到达时将食品项目放入购物袋。如果袋子太满,它就会破裂或开始溢出。我们需要知道何时停止,以免它太满。幸运的是,条形码阅读器可以告诉我们物品的重量和最长尺寸。如果这些中的任何一个超过了某个数值,袋子就会被移除,并用一个空袋子替换:
-
使用与上一个练习相同的 环境。
-
我们将购物袋建模为文章的列表。定义一个
full-bag?断言,这样我们就会知道何时停止填充袋子:(defn full-bag? [items] (let [weight (apply + (map :weight items)) size (apply + (map :max-dimension items))] (or (> weight 3200) (> size 800)))) -
使用不同长度的购物流输出测试
full-bag?:groceries> (full-bag? (article-stream 10)) true groceries> (full-bag? (article-stream 1)) false groceries> (full-bag? (article-stream 1000)) true groceries> (full-bag? '()) false -
设置两个函数,
bag-sequences和它的递归辅助函数bag-sequences*。首先定义bag-sequences*,因为bag-sequences将调用它:(defn bag-sequences* [{:keys [current-bag bags] :as acc} stream] ;; TODO: write code ) (defn bag-sequences [stream] (bag-sequences* {:bags [] :current-bag []} stream))如您从辅助函数的参数中看到的那样,我们定义了一个累加器,这次有两个字段:
:bags将保存所有完成的包的列表,而:current-bag将保存我们正在测试的项目。当:current-bag填满时,我们将它放入:bags,并在:current-bag中开始一个新的空向量。第二个函数,没有星号,将是面向公众的函数。我们库的用户不必担心提供累加器;
bag-sequences*将是真正的递归函数,并完成所有工作。 -
在
bag-sequences*函数内部,我们将使用cond表达式来对到达的文章的状态做出反应。写出cond表达式的第一个,负条件:(defn bag-sequences* [{:keys [current-bag bags] :as acc} stream] (cond (not stream) (conj bags current-bag) ;; TODO: the other cond branches ))在这里,我们决定如果
stream中没有更多的文章会发生什么。如果没有东西可以放入包中,那么就是时候将current-bag添加到列表中,并返回到目前为止积累的所有内容。备注
在递归函数中,尽早测试输入序列是否到达末尾是一种常见的做法。这个测试通常很简单,所以最好把它放在一边。更重要的是,如果我们知道输入序列不为空,我们就不必在后续的测试中防范
nil值。这有助于消除后续测试子句中的一些可能错误,并允许我们编写更简单、更易读的代码。 -
添加当前袋子满的条件:
(defn bag-sequences* [{:keys [current-bag bags] :as acc} stream] (cond (not stream) (conj bags current-bag) (full-bag? (conj current-bag (first stream))) (bag-sequences* (assoc acc :current-bag [(first stream)] :bags (conj bags current-bag)) (next stream)) ;; TODO: one more branch, for when the bag is not full yet ))多亏了方便的
full-bag?函数,我们知道当前袋子已满。这意味着当我们下一次调用bag-sequences*时,我们需要在acc内部移动一些数据。bag-sequences*的两个参数都需要更新。我们的assoc调用一开始看起来可能有些奇怪,但assoc可以接受多个键值对。stream中最新的文章将成为新“袋”向量中的第一篇文章,因此我们将它分配给acc中的:current-bag键。此时,current-bag绑定(来自函数参数中的解构)仍然指向我们决定已满的袋子。我们将把它添加到我们在acc中的:bags键维护的袋子列表中。由于我们希望继续前进到
stream,我们使用next来跳转到下一篇文章:(next stream)。 -
编写最终的默认条件。如果我们已经通过了前两个条件,我们知道
stream不为空,当前袋子也没有满。在这种情况下,我们只需要将当前文章添加到当前袋子中。有了这个条件,我们的函数就完成了:(defn bag-sequences* [{:keys [current-bag bags] :as acc} stream] (cond (not stream) (conj bags current-bag) (full-bag? (conj current-bag (first stream))) (bag-sequences* (assoc acc :current-bag [(first stream)] :bags (conj bags current-bag)) (next stream)) :otherwise-bag-not-full (bag-sequences* (update acc :current-bag conj (first stream)) (next stream))))这次,我们将使用
update而不是assoc来“修改”acc中的:current-bag键。这种形式的update函数将其第三个参数作为一个函数,该函数将被应用于与提供的键对应的值以及任何进一步的参数。这意味着在这种情况下,conj将被调用,就像我们写了(conj (:current-bag acc) (first stream))一样。 -
使用我们在上一项练习中编写的
article-stream函数来测试该函数:groceries> (bag-sequences (article-stream 12)) [[{:name "Pepper", :weight 85, :max-dimension 90} {:name "Pepper", :weight 85, :max-dimension 90} {:name "Green beans", :weight 300, :max-dimension 120} {:name "Flour", :weight 1000, :max-dimension 140} {:name "Olive oil", :weight 400, :max-dimension 280}] [{:name "Bread", :weight 350, :max-dimension 250} {:name "Pepper", :weight 85, :max-dimension 90} {:name "Green beans", :weight 300, :max-dimension 120} {:name "Olive oil", :weight 400, :max-dimension 280}] [{:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Bread", :weight 350, :max-dimension 250}]]这看起来似乎有效!每个袋子都表现为一个物品的向量。向量的长度取决于物品的大小和重量。
我们已经解决了我们在本章开头提到的问题之一:以不同长度的步骤遍历序列。在这个例子中,我们将输入序列分割成大小取决于底层数据属性的数据块。
何时使用 recur
现在,bag-sequence 对于相对较短的 grocery-stream 序列工作得很好,但当我们将其移入我们的多模式杂货超级平台的生产环境中时,整个系统迅速陷入停滞。这是出现在所有技术人员控制台上的消息:
packt-clj.recursion> (def production-bags (bag-sequences (article-stream 10000)))
Execution error (StackOverflowError) at packt-clj.recursion/article-stream$fn (recursion.clj:34).
null
那么,发生了什么?什么是 StackOverflowError?
栈(stack)是 JVM 跟踪嵌套函数调用的一种方式。每次函数调用都需要进行一点簿记工作来维护一些上下文信息,例如局部变量的值。运行时还需要知道每个调用的结果应该放在哪里。当一个函数在另一个函数内部被调用时,外部函数会等待内部函数完成。如果内部函数也调用了其他函数,它也必须等待这些函数完成,依此类推。栈的工作是跟踪这些函数调用的链。
我们可以使用一个非常简单的函数来举例。这个函数接受两个整数并对它们执行两种不同的操作:
user> (defn tiny-stack [a b]
(* b (+ a b)))
#'user/tiny-stack
user> (tiny-stack 4 7)
77
这里是调用 tiny-stack 时发生情况的简化版本:
我们调用 tiny-stack 并产生一个初始的栈帧。它等待函数内容的评估。
在 tiny-stack 等待的同时,* 函数被调用。产生一个新的栈帧。b 绑定立即评估,但由于对 + 的调用,它还不能返回。
+ 最终被调用,产生一个新的、短暂的栈帧。两个整数相加,返回值,然后擦除 + 的栈帧。
* 的调用现在可以完成。它将返回值传递回 tiny-stack,然后擦除其栈帧。
tiny-stack 返回 77 并擦除了其栈帧:


图 6.2:嵌套函数调用中栈帧的可视化
这就是栈帧应该工作的方式。大多数时候,我们根本不需要考虑它。然而,当我们使用递归遍历序列的长度时,我们实际上是通过嵌套使用栈来移动序列的。因为运行时可以处理的栈帧数量有限,如果我们有一个非常长的序列,我们最终会耗尽栈帧,我们的程序将会崩溃:


图 6.3:递归的表示
使用递归,输入向量的长度被转换为调用栈中的深度,直到它太深
到目前为止,你可能认为递归根本不是一个很好的模式。这实际上是由于 JVM 中内置的限制。其他基于 JVM 的 Lisp 没有这个限制,在这些语言中,前面的代码会正常工作。
然而,Clojure 中有一个解决方案,它被称为 recur。让我们再次看看我们在上一节中编写的 recursive-sum 函数:
(defn recursive-sum [so-far numbers]
(if (first numbers)
(recursive-sum
(+ so-far (first numbers))
(next numbers))
so-far))
首先,让我们看看在长输入序列上会发生什么爆炸:
user> (recursive-sum 0 (range 10000))
Execution error (StackOverflowError) at user/recursive-sum (REPL:53).
null
要使用 recur,我们只需将原始方程中的 recursive-sum 调用替换为 recur:
user> (defn safe-recursive-sum [so-far numbers]
(if (first numbers)
(recur
(+ so-far (first numbers))
(next numbers))
so-far))
#'user/safe-recursive-sum
user> (safe-recursive-sum 0 (range 10000))
49995000
为什么这会起作用呢?好吧,使用 recur,一个函数变成了尾递归。尾递归意味着连续的调用不会增加调用栈。相反,运行时会将它们视为当前帧的重复。你可以将其视为保持在同一帧中,而不是等待所有嵌套调用解决。这样,循环可以继续而不会增加栈的大小。这允许我们处理大量数据而不会遇到可怕的栈溢出。
一个函数只有在其返回一个完整的自我调用且没有其他内容时才能是尾递归。一开始这可能有点难以理解,但随着我们通过一些示例进行工作,它应该会变得更加清晰。在下一个练习中,我们将查看在尾递归函数中直接使用 recur 的简单例子。
练习 6.03:使用 recur 进行大规模杂货分区
如我们之前提到的,我们之前对 bag-sequences 的实验在输入流变得过长时扩展性不好,因为我们遇到了栈溢出异常。也许我们可以通过使用 recur 来改进之前的设计:
-
将环境设置成与上一个练习相同。
-
将
bag-sequences和bag-sequences*函数复制一份,并使用新名称,例如robust-bag-sequences和robust-bag-sequences*。 -
在
robust-bag-sequences*中,使用recur而不是调用bag-sequences*:(defn robust-bag-sequences* [{:keys [current-bag bags] :as acc} stream] (cond (not stream) (conj bags current-bag) (full-bag? (conj current-bag (first stream))) (recur (assoc acc :current-bag [(first stream)] :bags (conj bags current-bag)) (next stream)) :otherwise-bag-not-full (recur (assoc acc :current-bag (conj current-bag (first stream))) (next stream))))与
bag-sequences*的上一个版本相比,唯一的区别是我们已经将递归调用(我们写出的函数名,bag-sequences*)替换为recur。这个函数是尾递归的。为什么?好吧,让我们看看与cond表达式的三个分支相对应的三个可能的输出。第一个分支只是返回数据,所以那里根本没有任何递归。其他两个返回recur的调用,这是函数中最后要评估的内容。这符合尾递归的定义,即函数必须返回一个对自身的调用 并且没有其他内容。 -
在面向公众的
robust-bag-sequences函数中,别忘了将bag-sequences*的调用更新为新函数名:(defn robust-bag-sequences [stream] (robust-bag-sequences* {:bags [] :current-bag []} stream)) -
评估你的命名空间,并在一个非常长的
article-stream上测试新函数。别忘了将结果赋值给一个变量,否则它将充满你的 REPL!在这里,我们将一百万篇文章放入 343,091 个袋子中:
groceries> (def bags (robust-bag-sequences (article-stream 1000000))) #'packt-clj.recursion/bags groceries> (count bags) 343091 groceries> (first bags) [{:name "Olive oil", :weight 400, :max-dimension 280} {:name "Potatoes", :weight 2500, :max-dimension 340}]由于
article-stream的内容是随机的,你的结果可能会有所不同。这个例子展示了使用
recur来轻松提高递归函数性能的基本方法。robust-bag-sequences*函数确实是尾递归的,因为它返回一个完整的自我调用且没有其他内容。
那么 loop 呢?
如你所知,Clojure 确实有一个 loop 宏。如果你听到这个,突然想到“太好了,我可以用 loop 来代替!”,你可能会感到失望。loop 宏确实是有用的,但 loop 的可怕秘密是它几乎与使用 recur 编写的递归函数相同。
loop 宏的优势在于它可以包含在函数内部。这消除了编写一个设置递归并可能在结果上做一些“后期制作”的公共函数以及一个执行实际递归的辅助函数的需要。当然,这种模式没有问题,但使用 loop 可以通过限制需要定义的函数数量来使命名空间更容易阅读。
注意
Clojure 提供了另一种避免公共函数的机制。使用defn-而不是defn定义的函数仅在其定义的命名空间内可用。
loop的基本逻辑和结构与使用recur的函数非常相似:loop的调用以一个或多个绑定开始,就像递归函数一样,由于recur而重新开始。就像递归函数一样,recur的参数必须在每次迭代中修改,以避免无限循环。并且loop的调用也必须是尾递归,就像使用recur的函数一样。
这里是一个使用loop对想象中的杂货店中的商品进行操作的函数的简单框架。假设process函数对每个商品执行一些重要操作,例如向不同的服务发送 API 调用。现在,我们将它定义为别名identity的存根函数,这是 Clojure 函数,它简单地返回它提供的任何参数:
(def process identity)
(defn grocery-verification [input-items]
(loop [remaining-items input-items
processed-items []]
(if (not (seq remaining-items))
processed-items
(recur (next remaining-items)
(conj processed-items (process (first remaining-items)))))))
显然,基本模式与我们之前看过的递归函数非常相似:检测是否继续迭代的条件语句,以及在末尾的recur调用,开始变得非常熟悉。重要的是要记住,初始绑定只是那样:初始。就像函数的参数一样,它们在循环开始时分配,然后通过recur调用重新分配。确保迭代能够顺利且不会无限进行,取决于你。
练习 6.04:使用loop处理杂货
使用loop重写之前练习中的robust-bag-sequences函数:
-
使用与之前练习相同的环境。
-
为具有与
robust-bag-sequences相同调用签名的函数编写大纲:(defn looping-robust-bag-sequences [stream] ) -
在函数内部设置一个循环,其参数与
robust-bag-sequences*相同:(defn looping-robust-bag-sequences [stream] (loop [remaining-stream stream acc {:current-bag [] :bags []}] ;;TODO: the real work ))如你所见,我们的累加器的初始设置将在绑定中发生。
-
通过重用之前练习中的
robust-bag-sequences*代码,填写其余的逻辑:(defn looping-robust-bag-sequences [stream] (loop [remaining-stream stream acc {:current-bag [] :bags []}] (let [{:keys [current-bag bags]} acc] (cond (not remaining-stream) (conj bags current-bag) (full-bag? (conj current-bag (first remaining-stream))) (recur (next remaining-stream) (assoc acc :current-bag [(first remaining-stream)] :bags (conj bags current-bag))) :otherwise-bag-not-full (recur (next remaining-stream) (assoc acc :current-bag (conj current-bag (first remaining-stream))))))))这个版本几乎与原始版本相同。主要区别在于,由于变量的绑定方式,我们最终使用
let绑定来解构累加器,以便有current-bag和bags绑定。除此之外,代码相同。 -
测试函数的新版本:
groceries> (looping-robust-bag-sequences (article-stream 8)) [[{:name "Bread", :weight 350, :max-dimension 250} {:name "Potatoes", :weight 2500, :max-dimension 340}] [{:name "Potatoes", :weight 2500, :max-dimension 340}] [{:name "Potatoes", :weight 2500, :max-dimension 340} {:name "Olive oil", :weight 400, :max-dimension 280}] [{:name "Flour", :weight 1000, :max-dimension 140} {:name "Green beans", :weight 300, :max-dimension 120} {:name "Pepper", :weight 85, :max-dimension 90}]]这段代码展示了
loop和递归函数可以多么相似。选择哪种形式主要取决于哪种版本会使你的代码更容易理解。将loop视为一种递归形式也会使你更容易记住在循环内编写尾递归代码。
尾递归
如我们之前所说,recur告诉 JVM 期望函数是尾递归。但这究竟意味着什么呢?用recur替换函数名实际上并不足以使递归函数调用成为尾递归。
让我们从递归函数不是尾递归时会发生什么的例子开始。到目前为止,我们已经做了很多整数序列的添加。这里有一个新的变化:假设整数不是简单的序列,而是在嵌套序列中,可能像这样:
(def nested [5 12 [3 48 16] [1 [53 8 [[4 43]] [8 19 3]] 29]])
嵌套向量如这种在 Clojure 中是表示树的一种常见方式。这些向量本身是分支节点,而在这个例子中,整数是叶子:

图 6.4:嵌套向量是表示树结构的一种常见方式
我们还没有遇到过这种问题。这恰好是递归可能是最佳或甚至是唯一解决方案的问题类型。它也是一个重要的类型:这些嵌套向量实际上定义了一个树结构。树当然是表示数据最有用的方式之一,所以这是一个相当重要的主题。
实际上,要解决这个问题,我们需要一个递归函数,当它看到数字时进行加法,当它看到列表时调用自身。这里是一个看起来很像我们之前写过的其他函数的起点:
(defn naive-tree-sum [so-far x]
(cond (not x) so-far
(integer? (first x)) (recur (+ so-far (first x)) (next x))
; TODO: more code
))
cond形式中的第一个条件相当标准:如果我们到达输入的末尾,我们只需返回so-far(我们的累加器)中的内容。下一个条件现在也应该看起来相当直接:如果我们有一个数字,将其加到我们的运行总和中,然后通过在第一个项目和下一个项目之间拆分输入序列继续进行。
现在,让我们为当(first x)是一个向量时的情况写下最后一个条件。新的recur调用需要计算(first x)以便它可以是整数。这将是这样的:
(defn naive-tree-sum [so-far x]
(cond (not x) so-far
(integer? (first x)) (recur (+ so-far (first x)) (next x))
(or (seq? (first x)) (vector? (first x)))
(recur (recur so-far (first x)) (next x)))) ;; Warning!!!
如果你将这段代码输入到你的 REPL 中并评估它,你会得到一个错误:
1\. Caused by java.lang.UnsupportedOperationException
Can only recur from tail position
发生了什么?为什么这不起作用?
表面上,我们只是在遵循既定的模式。嵌套的recur调用看起来有点奇怪。但如果(first x)是一个向量或列表,我们不能简单地将其添加到so-far中。我们的函数需要一个整数作为so-far参数。我们需要通过评估整个树的部分将(first x)中的向量转换成整数。当这完成时,我们有了简单、简单的整数而不是子树,我们最终可以继续处理序列的其余部分,使用(next x)。
然而,编译器拒绝编译我们的代码的原因是,由于最后一行,该函数不是尾递归的。在函数的最后一行,第一个recur必须等待第二个完成后再继续。这是对recur的尾位置要求的违反:禁止同时调用recur。正如我们之前所说的,要成为尾递归,一个函数必须只返回对自身的调用,不再有其他操作。但在这个情况下,一个recur正在等待另一个返回。
我们也可以从堆栈帧的角度来考虑这个问题。尾递归意味着当递归函数再次被调用(通过recur)时,不会产生新的帧:前一个函数调用被新的一个“忘记”或“擦除”。前一个调用的唯一痕迹是在对当前调用参数所做的更改中。这个函数的问题在于第一次调用recur不能被“忘记”。它正在等待第二次调用的结果。只有当第二次调用解决后,第一次调用才能继续。如果我们处于需要两个堆栈帧共存的情况,我们就不能使用recur。当处理线性数据时,这通常不是问题。另一方面,树结构通常不能以recur所需的线性方式处理。
让我们尝试不使用recur重写同一个函数:
user> (defn less-naive-tree-sum [so-far x]
(cond (not x) so-far
(integer? (first x)) (less-naive-tree-sum (+ so-far (first x)) (next x))
(or (seq? (first x)) (vector? (first x)))
(less-naive-tree-sum (less-naive-tree-sum so-far (first x)) (next x))))
#'user/less-naive-tree-sum
user> (less-naive-tree-sum 0 nested)
252
这行得通!尽管我们可能遇到了新问题。没有recur,这个函数版本在运行包含太多项目的树时可能会使栈爆炸。这可能是也可能不是问题,这取决于需要处理的数据类型。如果我们有数千个项目或子向量,我们就需要找到另一种解决方案。为此,你需要等待下一章,我们将学习如何生成自己的懒序列,这将允许我们在大型、复杂的树上使用递归。
没有使用recur和懒序列的递归在很多情况下都可以正常工作。当输入数据不是成千上万或数百万项时,“正常”的非懒递归可能就足够了。现在,重要的是要理解recur的使用是有局限性的。幸运的是,许多你需要用递归完成的任务都可以很好地适应尾递归模式。而且不用担心:如果你忘记了,编译器总是在那里提醒你。
使用递归解决复杂问题
当我们谈论递归时,实际上有两种截然不同的用例类别。这在 Clojure 中尤其如此。一方面,递归是构建循环的主要、低级方式,在其他语言中会使用for、foreach或with。另一方面,像 Clojure 这样的函数式语言通常使程序员更容易找到优雅的递归解决方案来处理复杂问题。
尾递归和围绕recur构建的函数或循环适合处理数据、输入和输出基本上是线性的问题。因为尾递归函数一次只能返回一个调用,所以它们无法处理需要通过多个分支路径遍历数据的问题。Clojure 提供了你需要的工具。使用它们可能需要一些练习,以递归风格来处理问题。
为了帮助构建这项技能,本章剩余的练习将致力于解决一个复杂问题:在节点网络中找到最有效的路径。或者换句话说:如何在欧洲首都之间廉价旅行。这些练习将向你展示如何分解问题并递归地解决它。
练习 6.05:欧洲火车之旅
在这个练习中,我们需要找到旅行者从一个欧洲城市到另一个城市最经济的路线。我们拥有的只是一个城市到城市连接的列表和欧元金额。为了这个练习,我们将假装这些是唯一可用的路线,并且给定路线的火车票价格是恒定的。以下是我们的路线:
train_routes.clj
1 (def routes
2 [[:paris :london 236]
3 [:paris :frankfurt 121]
4 [:paris :milan 129]
5 [:milan :rome 95]
6 [:milan :barcelona 258]
7 [:milan :vienna 79]
8 [:barcelona :madrid 141]
9 [:madrid :lisbon 127]
10 [:madrid :paris 314]
The full code for this step is available at https://packt.live/2FpIjVM
这里是它的可视化表示:

图 6.5:欧洲火车路线
注意
城市之间的路径列表和可视地图是表示图(在计算机科学中指节点系统)的两种方式。树是图中没有“循环”的一种类型:你不能从点 A 到点 B,再到点 C,然后再回到点 A。另一方面,欧洲铁路网络有很多循环。在图论中,每个城市都会被称为节点,城市之间的路径会被称为边。
目标是编写一个函数,它接受两个城市并返回表示最佳路线的城市列表。为了解决这个问题,我们将使用递归,以及上一章中介绍的大多数技术。
在这个练习中,我们将设置项目并将路线列表转换成一个我们可以查询的表格,以查看 1)城市 A 是否与城市 B 相连,如果是,2)城市 A 和 B 之间的旅行成本。
因此,表格看起来会是这样:
{:paris {:london 236
:frankfurt 121
:milan 129
;;...etc...
}
:milan {:paris 129
:vienna 79
:rome 95
;;...etc...
}}
我们还必须确保所有路线都在两个方向上表示。在初始列表中,我们有[:paris :milan 129]。我们还需要表示反向路线,即米兰到巴黎。在上面的例子中,:milan在:paris部分,以及:paris在:milan部分:
-
创建一个新的项目目录,包含一个
deps.edn文件,其中只包含一个空映射{},并启动一个新的 REPL。 -
以
train_routes.clj为名创建一个新文件,其中只包含一个(ns train-routes)命名空间声明。 -
将
packt.live/39J0Fit中的routes变量复制到新的命名空间中。 -
第一步是将所有路线按出发城市分组。使用
group-by来完成这个操作。首先定义一个名为grouped-routes的函数:(defn grouped-routes [routes] (->> routes (group-by first))) -
在路线列表上运行这个函数的早期版本,并查看单个城市的输出结果:
train-routes> (:paris (grouped-routes routes)) [[:paris :london 236] [:paris :frankfurt 121] [:paris :milan 129] [:paris :amsterdam 139]]通过调用
group-by,我们得到了所有:paris路线的列表。现在我们需要一种方法将这个子列表转换成一个映射。 -
编写一个函数,它接受这些子列表之一并返回一个映射:
(defn route-list->distance-map [route-list] (->> route-list (map (fn [[_ city cost]] [city cost])) (into {})))此函数使用
map-into模式创建一个包含两个元素向量元组的列表。我们不需要第一个元素,因为它与子列表关联的键相同,所以我们使用解构将city和cost放置在一个新的向量中。 -
在 REPL 中使用一些样本数据测试
route-list->distance-map:train-routes> (route-list->distance-map [[:paris :milan 129] [:paris :frankfurt 121]]) {:milan 129, :frankfurt 121} -
继续构建
grouped-routes函数。再次使用map-into模式将route-list->distance-map应用于group-by调用返回的所有子列表:(defn grouped-routes [routes] (->> routes (group-by first) (map (fn [[k v]] [k (route-list->distance-map v)])) (into {})))map调用将顶层映射的键值对视为一系列两个元素的向量,并对每个值运行route-list->distance-map。into调用将序列转换回映射。 -
在 REPL 中测试这个版本的
grouped-routes:train-routes> (:paris (grouped-routes routes)) {:london 236, :frankfurt 121, :milan 129, :amsterdam 139}完美!这种类型的地图将使查找从起点(
:paris)到目的地(:amsterdam)之间的路线变得容易。我们仍然需要生成反向路线。我们将使用
mapcat,在我们在第四章中提到的模式中,为每个输入路线生成两条路线。这可以在group-by调用之前进行:(defn grouped-routes [routes] (->> routes (mapcat (fn [[origin-city dest-city cost :as r]] [r [dest-city origin-city cost]])) (group-by first) (map (fn [[k v]] [k (route-list->distance-map v)])) (into {})))在
mapcat调用中的匿名函数返回一个包含两个子向量的向量。其中第一个是原始路线,第二个是将起点和终点城市颠倒的相同路线。多亏了mapcat,结果是一个元素数量是输入列表两倍的扁平化列表。 -
再次在 REPL 中测试:
train-routes> (:paris (grouped-routes routes)) {:london 236, :frankfurt 121, :milan 129, :madrid 314, :geneva 123, :amsterdam 139}现在,
[:madrid :paris 34]路线也被包括为从:paris到:madrid的路线。 -
定义一个带有查找表的
lookup变量:train-routes> (def lookup (grouped-routes routes)) #'train-routes/lookup我们稍后会需要这个变量。
-
测试查找表。首先,我们将请求从巴黎到马德里的路线:
train-routes> (get-in lookup [:paris :madrid]) 314我们能回到巴黎吗?
train-routes> (get-in lookup [:madrid :paris]) 314让我们尝试一条我们知道不存在的路线:
train-routes> (get-in lookup [:paris :bratislava]) nil我们的查找表回答了两个重要的问题:城市 A 和城市 B 之间有路线吗?费用是多少?
现在我们有一个数据存储,当我们需要从欧洲铁路图中的任何给定点查找哪些城市可用时,我们可以咨询它。将数据重新排列成易于查询的形式,在处理像这样一个复杂问题时,可能是一个重要的步骤。由于对数据的这种便捷访问,接下来的步骤将会更容易。
寻路
如果我们所在的城镇与我们想要旅行的城镇没有直接连接,我们需要选择中间城镇。要从城市 A 到城市 F,我们可能首先会去城市 C;或者,我们可能需要先去城市 B,然后去城市 D,才能到达城市 F。为了找到最佳路径,我们首先需要找到所有可能的路径。
这就是为什么递归方法是一个很好的选择。基本策略是测试城市 A 和城市 F 是否相连。如果是这样,我们就已经找到了答案。如果不是,我们就查看从城市 A 可以直接到达的所有城市。我们对每个这样的城市都进行同样的过程,以此类推,直到最终找到一个直接连接到城市 F 的城市。这个过程是递归的,因为我们重复对每个节点进行同样的过程,直到我们找到我们正在寻找的东西。
让我们尝试用网络的一小部分来可视化这个过程。在这个例子中,我们将从巴黎开始,搜索柏林。第一步是测试从巴黎可以到达的城市。在伦敦、阿姆斯特丹和法兰克福,我们问:你是柏林吗?

图 6.6:从巴黎开始,查询所有可用的城市
由于我们寻找的城市都不是伦敦、阿姆斯特丹和法兰克福中的任何一个,所以我们从这三个城市重复这个过程:

图 6.7:从之前找到的城市再次搜索
从伦敦出发,我们无处可去。但从阿姆斯特丹和法兰克福出发,我们可以到达柏林。成功!我们不仅找到了柏林,还找到了两条到达那里的路径。
我们可以用 Clojure 向量来表示这些路径:
[:paris :london nil]
[:paris :amsterdam :berlin]
[:paris :frankfurt :berlin]
[:paris :frankfurt :prague nil]
注意,经过布拉格和伦敦的路径以 nil 结尾。这就是我们将如何表示不导向我们目的地并必须从结果集中删除的路径。
我们还有一个尚未解决的问题。什么阻止我们从阿姆斯特丹回到巴黎?这将创建无限循环,其中我们从巴黎搜索,然后搜索阿姆斯特丹,然后再次搜索巴黎,以此类推。为了解决这个问题,我们需要“记住”我们去过的城市。
这是我们的一般方法。现在,让我们编写更多的代码!
练习 6.06:搜索函数
下一步是编写主搜索函数,一个我们将称之为 find-path* 的递归函数。find-path 函数将是公共接口,它将调用 find-path*:
-
到目前为止,我们已经准备好编写主搜索函数。
find-path函数可以用作包装函数,作为公共接口。为了开始,让我们编写空函数:(defn find-path* [route-lookup destination path] ;; TODO: write code ) (defn find-path [route-lookup origin destination] ;; TODO: write code )我们首先编写了“私有”函数
find-path*,因为“公共”函数find-path将会引用它。函数参数中已经有一些设计决策了。这两个函数都接受
route-lookup参数。这将是由grouped-routes生成的查找表。它们都接受目的地参数。由于我们想要构建一个城市列表,一个路径,私有函数find-path*不接收像find-path那样的起点参数。相反,它将接收当前的路径。由于它是一个递归函数,当前的“起点”将始终是路径中的最后一个城市。换句话说,如果我们正在测试一个路径,
path的值可能是[:paris :milan]。这意味着在下一个迭代中,find-path*将尝试从:milan可到达的所有城市,使:milan成为临时的起点。以相同的方式测试下一个城市,并且路径在每次连续调用中增加一个城市。 -
从
find-path调用find-path*:(defn find-path [route-lookup origin destination] (find-path* route-lookup destination [origin]))这很简单。我们将初始
origin打包成一个向量,传递给find-path*。这样,我们就知道path参数中至少会有一个城市。 -
设置递归函数的基本条件结构:
(defn find-path* [route-lookup destination path] (let [position (last path)] (cond (= position destination) path (get-in route-lookup [position destination]) (conj path destination) ;; TODO: still not there )))这部分做了两件事。我们需要多次引用当前位置,所以立即创建一个
let绑定是个好主意。正如我们之前提到的,我们的当前位置总是path参数中的最后一个项目。整个过程就是在这个列表的末尾添加城市。接下来,我们开始设置我们将要使用的一些不同检查。我们添加的两个条件都结束了递归并返回了一个值。这些都是“我们到了吗?”测试。第二个是我们将调用最多的一个,所以让我们先看看它。
如果你记得我们的查找表是如何结构的,有一个顶层键集,每个键对应我们系统中的一个城市。每个键的值是一个可达城市的映射。这就是为什么我们可以在这里使用
get-in。假设我们的查找表看起来像这样:{:paris {:frankfurt 121 :milan 129 ;; etc. } ;; etc. }如果我们调用
(get-in routes-lookup [:paris :milan]),我们将得到129。如果我们当前的位置是:paris,我们的目的地是:milan,那么这个调用将返回真值。在这种情况下,我们将:milan添加到当前路径中,并返回该路径。我们已经到达了。那么,为什么我们需要第一个条件呢?在什么情况下目的地城市已经在路径中了呢?只有一种可能发生这种情况,但我们确实需要处理它。总有一天,用户会调用你的函数,并询问从
:paris到:paris的最佳路线,我们不希望在这样一个简单的请求上崩溃堆栈。 -
测试简单情况。我们已经有足够的代码来处理两个情况,所以让我们看看我们的函数是否工作。首先尝试使用一个城市路径的
find-path*:train-routes> (find-path* lookup :sevilla [:sevilla]) [:sevilla]现在,让我们用
find-path尝试同样的事情:train-routes> (find-path lookup :sevilla :sevilla) [:sevilla]如果目的地城市只比起点城市远一步,当前的代码也应该能工作:
train-routes> (find-path* lookup :madrid [:sevilla]) [:sevilla :madrid]到目前为止,一切顺利。继续前进!
-
开始构建递归逻辑以
find-path*:(defn find-path* [route-lookup destination path] (let [position (last path)] (cond (= position destination) path (get-in route-lookup [position destination]) (conj path destination) :otherwise-we-search (let [path-set (set path) from-here (remove path-set (keys (get route-lookup position)))] (when-not (empty? from-here) (->> from-here (map (fn [pos] (find-path* route-lookup destination (conj path pos)))) (remove empty?)))))))对于最终条件,我们使用一个表达式的 Clojure 关键字,如
:otherwise-we-search作为条件,但任何不是false或nil的东西都可以。如果我们走到这一步,我们知道我们还没有到达目的地,所以我们必须继续搜索。 -
让我们逐行查看这一行。我们首先定义
path-set,这将允许我们测试一个城市是否已经在我们的路径中。你可以在 REPL 中尝试构建和使用一个集合:train-routes> (set [:amsterdam :paris :milan]) #{:paris :milan :amsterdam} train-routes> ((set [:amsterdam :paris :milan]) :berlin) nil train-routes> ((set [:amsterdam :paris :milan]) :paris) :paris为什么这一点很重要,在下一行中变得明显。我们将
from-here绑定到这个:(remove path-set (keys (get route-lookup position)))我们不能像之前那样使用
get-in,因为这次我们不仅仅想要从position可达的一个城市,我们想要所有这些城市。所以,我们通过(get route-lookup position)获取当前城市的整个子图,然后提取一个关键词列表。现在,上一行的path-set绑定变得有用。我们使用它来移除我们已经访问过的任何城市。这就是我们避免在:paris和:amsterdam之间永远递归来回的方法。from-here绑定现在包含我们仍然需要测试的所有城市。首先,我们使用 Clojure 中命名良好的empty?断言来检查from-here是否为空。假设我们的目的地是:berlin,当前路径是[:paris :london]。从:london出去的唯一方法是回到:paris,但我们已经去过那里。这意味着现在是放弃的时候了,所以我们返回nil。很快你就会看到,解析为nil的路径将被忽略。 -
之后,我们开始将
from-here通过一系列 s-expressions 穿插。第一个是实际递归将要发生的地方:(map (fn [pos] (find-path* route-lookup destination (conj path pos)))) -
我们正在映射从当前位置可达的城市。比如说,我们从
:london到达了:paris。在查找表中,:paris的值如下:train-routes> (:paris lookup) {:london 236, :frankfurt 121, :milan 129, :madrid 314, :geneva 123, :amsterdam 139} -
我们不能回到
:london,这意味着from-here是[:frankfurt :milan :madrid :geneva :amsterdam]。提供给map的匿名函数将为每个这些城市作为pos被调用一次。因此,每个城市都将被追加到递归调用find-path*的路径参数中。以下值将作为path参数尝试find-path*:[:london :paris :frankfurt] [:london :paris :milan] [:london :paris :madrid] [:london :paris :geneva] [:london :paris :amsterdam]记住
map返回一个列表。这里返回的列表将是调用find-path*的每个城市的调用结果。这些调用中的每一个都将产生一个列表,直到搜索找到目的地城市或没有更多地方可查找。现在,我们可以开始可视化路径搜索的递归结构:
![图 6.8:每个城市解析为一个新的城市列表,每个城市又解析为一个新的列表]
![图片 B14502_06_08.jpg]()
图 6.8:每个城市解析为一个新的城市列表,每个城市又解析为一个新的列表
最终,搜索要么找到目的地,要么用尽选项,此时所有列表都将被解析。如果没有更多选项并且目的地仍未找到,则返回
nil。否则,返回一个解析路径的列表。这个简单的map调用最终会遍历整个城市图,直到找到所有可能的路线。在返回之前,对 map 的调用会通过最后一个 s 表达式进行线程化:(移除空?)。这就是我们过滤掉从未找到目的地的
nil路径的方法。 -
在一个短路径上测试当前函数的状态。
为了使测试更容易,我们将使用一个较小的铁路网络。首先,为查找表定义一个新的变量:
train-routes> (def small-routes (grouped-routes [[:paris :milan 100][:paris :geneva 100][:geneva :rome 100][:milan :rome 100]])) #'train-routes/small-routes train-routes> small-routes {:paris {:milan 100, :geneva 100}, :milan {:paris 100, :rome 100}, :geneva {:paris 100, :rome 100}, :rome {:geneva 100, :milan 100}}之间应该恰好有两条路径从
:paris到:rome:train-routes> (find-path* small-routes :rome [:paris]) ([:paris :milan :rome] [:paris :geneva :rome]) -
使用稍微大一点的网络测试当前函数的状态。我们将在
:paris和:milan之间添加另一条路线:train-routes> (def more-routes (grouped-routes [[:paris :milan 100] [:paris :geneva 100] [:paris :barcelona 100] [:barcelona :milan 100] [:geneva :rome 100] [:milan :rome 100]])) #'train-routes/more-routes -
使用这组路线,我们的结果并不完全符合预期:
train-routes> (find-path* more-routes :rome [:paris]) ([:paris :milan :rome] [:paris :geneva :rome] ([:paris :barcelona :milan :rome])) -
数据看起来很好,但那些额外的括号是从哪里来的?这是使用递归方式使用
map的结果。map总是返回一个列表。:barcelona路线需要额外的递归级别,因为它比其他路线多一个项目。因此,它被包装在一个列表中。我们可以通过添加另一个可能的路线来验证这一点:train-routes> (def even-more-routes (grouped-routes [[:paris :milan 100] [:paris :geneva 100] [:paris :barcelona 100] [:barcelona :madrid 100] [:madrid :milan 100] [:barcelona :milan 100] [:geneva :rome 100] [:milan :rome 100]])) #'train-routes/even-more-routes train-routes> (find-path* even-more-routes :rome [:paris]) ([:paris :milan :rome] [:paris :geneva :rome] (([:paris :barcelona :madrid :milan :rome]) [:paris :barcelona :milan :rome]))如您所见,穿过
:madrid的五城市路径被额外的一组括号包裹。 -
展开嵌套列表。为了解决这个问题,使用
mapcat来移除包含的列表。以下是find-path*的最终版本:(defn find-path* [route-lookup destination path] (let [position (last path)] (cond (= position destination) path (get-in route-lookup [position destination]) (conj path destination) :otherwise-we-search (let [path-set (set path) from-here (remove path-set (keys (get route-lookup position)))] (when-not (empty? from-here) (->> from-here (map (fn [pos] (find-path* route-lookup destination (conj path pos)))) (remove empty?) (mapcat (fn [x] (if (keyword? (first x)) [x] x)))))))))最后的添加是调用
mapcat。一开始这可能看起来有些奇怪,但请记住:mapcat会从它返回的列表项中移除外层括号。这就是为什么我们有这个条件:如果x只是一个路径,比如[:prague :bratislava],我们不希望它被直接连接,这就是为什么我们返回[x]而不是直接连接。当这些项连接在一起时,新的包装器会立即被移除,原始向量保持完整。在其他情况下,当底层向量被包装在列表中时,mapcat“移除”它。 -
在小型和大型铁路网络上测试这个版本的函数。首先,我们将使用
even-more-routes进行测试:train-routes> (find-path* even-more-routes :rome [:paris]) ([:paris :milan :rome] [:paris :geneva :rome] [:paris :barcelona :madrid :milan :rome] [:paris :barcelona :milan :rome])太好了!
-
现在,使用完整的查找表尝试它。这里我们不会打印完整的输出,因为它们相当长:
train-routes> (find-path* lookup :rome [:paris]) ([:paris :frankfurt :milan :rome] [:paris :frankfurt :berlin :warsaw :prague :vienna :milan :rome] [:paris :frankfurt :berlin :warsaw :prague :bratislava :vienna :milan :rome] [:paris :frankfurt :berlin :warsaw :prague :budapest :vienna :milan :rome] [:paris :frankfurt :geneva :milan :rome] [:paris :frankfurt :prague :vienna :milan :rome] [:paris :frankfurt :prague :bratislava :vienna :milan :rome] [:paris :frankfurt :prague :budapest :vienna :milan :rome] ;; etc. )现在,我们可以遍历整个网络。我们的
find-path函数返回任何两个城市之间的所有可能路线。
再次强调,Clojure 帮助我们编写了一个简洁的解决方案来解决一个相当复杂的问题。这样的递归算法依赖于一个结合两个元素的设计。一方面,递归函数将每个新节点视为第一个节点。递归解决方案通常因为这一点而被认为是“优雅”的。通过解决一个项目的问题,它可以解决整个项目网络的问题。然而,要工作,这需要第二个设计元素:一种从节点到节点累积结果的方法。在这个例子中,我们通过向其中一个参数添加城市来积累我们的知识。在下一个练习中,我们将使用不同的技术来整合数据。
这个练习也展示了在 REPL 中使用小型样本输入的价值。交互式编程体验允许你快速尝试事情并验证你的假设。
练习 6.07:计算路线的成本
只有一个问题需要解决。在我们的原始问题描述中,我们没有要求所有可能的路线,而只是最便宜的路线!我们需要一种方法来评估所有路径的成本并选择一个。为了完成这个任务,我们将使用前两个练习中的查找表来计算find-path*返回的每个路径的成本。然后,我们可以使用来自第五章,多到一:Reducing的减少模式来找到成本最低的路径:
-
编写一个
cost-of-route函数。为了做到这一点,我们将使用来自第四章,映射和过滤中的模式
map,使用两个输入列表。第一个将是路径,第二个将是偏移一个项目的路径,这样每次调用都可以评估从一个城市到下一个城市的成本。它应该看起来像这样:(defn cost-of-route [route-lookup route] (apply + (map (fn [start end] (get-in route-lookup [start end])) route (next route))))到现在为止,这应该看起来很熟悉。
(next route)提供了route的偏移版本。对于每一对城市,我们使用get-in的方式与之前相同。那次调用给出了路径中给定段落的成本。然后,我们使用(apply +)模式来找到总成本。 -
测试新函数:
train-routes> (cost-of-route lookup [:london :paris :amsterdam :berlin :warsaw]) 603它甚至可以处理边缘情况:
train-routes> (cost-of-route lookup [:london]) 0 -
编写一个
min-route函数来找到最便宜的路线。现在,我们将利用另一个熟悉的模式,这次使用
reduce。我们想要找到总成本最低的路线,我们需要一个像min这样的函数,但它将返回具有最低成本的项目,而不仅仅是最低成本本身:(defn min-route [route-lookup routes] (reduce (fn [current-best route] (let [cost (cost-of-route route-lookup route)] (if (or (< cost (:cost current-best)) (= 0 (:cost current-best))) {:cost cost :best route} current-best))) {:cost 0 :best [(ffirst routes)]} routes))这个函数唯一稍微棘手的地方是调用
reduce时的初始化值:{:cost 0 :best [(ffirst routes)]}。我们从一个默认成本0开始。到目前为止一切顺利。默认的:best路线应该是对应于零距离的路线,这意味着我们不会去任何地方。这就是为什么我们使用ffirst,它不是一个打字错误,而是一个嵌套列表的便利函数。它是(first (first my-list))的快捷方式,因此它返回外层列表的第一个元素的第一个元素。 -
现在,将所有这些放在一起。将
min-route函数的调用添加到公共的find-path函数中:(defn find-path [route-lookup origin destination] (min-route route-lookup (find-path* route-lookup destination [origin]))) -
在几对城市上测试一下:
train-routes> (find-path lookup :paris :rome) {:cost 224, :best [:paris :milan :rome]} train-routes> (find-path lookup :paris :berlin) {:cost 291, :best [:paris :frankfurt :berlin]} train-routes> (find-path lookup :warsaw :sevilla) {:cost 720, :best [:warsaw :prague :vienna :milan :barcelona :sevilla]}通过这段代码的调试涉及了许多不同的步骤,但最终,这一切都是值得的。我们实际上用大约 50 行代码解决了一个相对困难的问题。最好的是,这个解决方案涉及了许多我们已经见过的技术,这再次表明,当它们一起使用时,它们是多么的重要和强大。
HTML 简介
在这本书的许多剩余章节中,以及从本章末尾的活动开始,我们将以某种方式与 超文本标记语言(HTML)一起工作,它几乎包含了你见过的每一个网页。从数据生成 HTML 是一个极其常见的编程任务。几乎每种主流编程语言都有多个模板库用于生成网页。甚至在 Clojure 之前,Lisps 就已经使用嵌套的 s-expressions 来做这件事了。s-expressions 特别适合 HTML 文档,因为它们的结构类似于逻辑树。
如果你不太熟悉制作 HTML 的基本知识,简要回顾一下基础知识是值得的。HTML 是文本文件的内容,它为网页上其他类型的内容(即图像、音频和视频资源)提供结构。HTML 的基本单位被称为元素。以下是一个简单的段落元素,使用 <p> 标签:
<p>A short paragraph.</p>
HTML 标准,其中包含几个版本,包含许多种类的元素。<html> 元素包含整个网页。反过来,它又包含 <head> 和 <body> 元素。前者包含用于显示页面的各种类型的元数据;后者包含将向用户展示的实际内容。《body>` 元素可以包含文本和更多元素。
我们在这里只使用一小部分元素:
-
<div>: 可能是所有元素中最广泛使用的元素,<div>是一个通用的容器,可以容纳从段落大小到整个文档大小的任何内容。然而,它不能用于小于段落大小的内容,因为<div>的结束会导致文本流的断裂。 -
<p>: 段落元素。 -
<ul>和<li>: "UL" 代表 "unordered list",即不带数字的列表。一个<ul>应该只包含 "list items",即<li>元素。 -
<span>、<em>和<strong>: 这些元素是文本的一部分;它们用于包裹单个单词或单个字母。它们不会在文本流中引起中断。《span>是一个通用元素。《em>(用于强调)通常产生斜体文本,而<strong>通常产生粗体文本。 -
<a>: 一个超文本链接元素。这也是一个文本级元素。《a>元素的href` 属性(我们将在下一节解释属性)告诉浏览器当你点击链接时应该去哪里。 -
<img>:<img>标签通过其src属性引用插入图像。 -
<h1>、<h2>和<h3>:这些是标题元素,用于页面标题、部分标题、子部分标题等。
这些几个元素就足以开始生成网页内容。您可以通过查阅,例如,Mozilla 开发者网络(MDN)的网页文档来了解其他内容。
注意
可以参考 MDN 网页文档 packt.live/2s3M8go。
由于我们将生成 HTML,因此为了生成良好格式的 HTML,您只需要了解以下几点:
-
大多数 HTML 标签有三个部分:开始标签、结束标签和一些内容。
-
开始标签由一个用尖括号括起来的短字符串组成:
<h1>。 -
结束标签类似,只是在标签名前有一个斜杠,例如
</h1>。 -
开启标签可以包含属性,属性是键值对,值用引号括起来:
<h1 class="example-title">Example</h1>。 -
某些属性被称为“布尔属性”,不需要值。键的存在就足够了:
<input type="checkbox" checked> -
一些标签没有任何内容。它们可以写成包含标签名后跟斜杠的单个元素:
<br/>。 -
在 HTML 的某些方言中,某些无内容的标签可以不带斜杠书写:
<img>。 -
如果一个元素开始于另一个元素内部,其结束标签必须出现在包含元素的结束之前。
最后一点很重要。这意味着像这样写是不正确的:
<div>Soon a paragraph <p>will start</div>, then end too late.</p>
这里的 <p> 元素应该是 <div> 的子元素。子元素必须完全包含在其父元素内。这在操作 HTML 时是一个非常好的事情,因为它意味着正确形成的 HTML 总是树结构,有一个根节点,即包含其他节点的 <html> 元素,以此类推。正如您将看到的,这很好地与我们之前已经查看过的树结构相匹配。
现在,您已经知道足够多的知识来编写一个生成良好格式 HTML 的系统。(然而,要成为一名著名的网页设计师,您还需要学习一些其他知识。)
在 Clojure 中,向量通常用于表示 HTML 文档的结构。执行此操作的更受欢迎的库之一称为 Hiccup。使用 Hiccup,包含链接的简短段落看起来像这样:
[:p "This paragraph is just an "
[:a {:href "http://example.com"} "example"] "."]
输出如下:
<p>This paragraph is just an <a href="http://example.com">example</a>.</p>
该语法相当简单。除了使用向量外,它还使用关键字来识别 HTML 标签,并使用映射来添加如 href 或 class 等属性。
一些标签,如 <link>、<meta>、<br>、<input> 和 <img>,通常不关闭,因此它们应该得到特殊处理。所有其他标签即使没有内容也应该显式关闭。
注意
关于 Hiccup 的更多信息可以在 packt.live/36vXZ5U 找到。
活动 6.01:从 Clojure 向量生成 HTML
你所在的公司正在开发一个新的网络应用程序。生成和提供 HTML 页面,从逻辑上讲,是运营的关键部分。你的团队被要求编写一个从 Clojure 数据生成 HTML 的库。
从数据生成 HTML 是一个极其常见的编程任务。几乎每种主流编程语言都有多个模板库用于生成网页。甚至在 Clojure 之前,Lisps 就已经使用嵌套 s-expressions 来做了。s-expressions 特别适合像逻辑树一样结构化的 HTML 文档。
目标
在这个活动中,你将编写自己的系统,从嵌套向量生成 HTML,使用这种格式。目标是能够处理任何使用这种语法编写的向量,包括任意数量的后代向量,并生成一个包含正确结构化 HTML 的单个字符串。
你的代码还应处理“布尔属性”。Clojure 映射不允许键没有值来执行此操作。你需要发明一种约定,以便你的库的用户可以将一些非字符串值分配给这些属性,并在输出字符串中获得布尔属性。
步骤
以下步骤将帮助你完成这个活动:
-
设置一个新的项目目录,并包含一个空的
deps.edn文件。这个活动不需要任何外部依赖。为这个新库创建一个有吸引力的命名空间。 -
如果你决定使用
clojure.string库,现在是时候在你的命名空间:require部分中引用它了。 -
通常,先编写一些较小的函数是个好主意。例如,简单的函数可以接受一个关键字并输出一个包含开标签或闭标签的字符串,这将很方便。你需要
name函数来将关键字转换为字符串。对于这个目标,一个好的选择是接受一个映射的函数,例如
{:class "my-css-class"},并返回一组格式正确的 HTML 属性:class="my-css-class"。别忘了处理布尔属性的情况。记住,Clojure 映射可以被读取为键值对的序列。别忘了在值周围加上引号。包含单个转义引号的字符串看起来是这样的:"\""。可能有一个谓词函数来确定向量的第二个元素是否是一个属性映射。
当解析一个向量时,你会知道第一个元素是一个关键字。第二个元素可能是一个映射,如果存在属性的话,但也可能不是。使用
map?谓词来测试这一点。 -
有趣的部分将是编写递归函数。我们不会过多地谈论这一点,除了我们之前在“欧洲火车之旅”示例中使用的基本的树遍历模式应该为你提供一个大致的基础。你将无法使用
recur,因为你需要处理一个真实的树。除了字符串内容外,您还需要处理许多不同种类的元素。在这种情况下,编写非常清晰的谓词通常是一个好主意,这些谓词将用于决定如何处理一个元素,例如
singleton-with-attrs?,例如。这些在编写递归函数的条件部分时将非常有用。
完成活动后,您将能够使用您选择的输入来测试您的代码。您应该看到以下类似的输出:

图 6.9:预期输出
注意
本活动的解决方案可以在第 696 页找到。
摘要
在本章中,我们覆盖了很多内容。在 Clojure 中,递归,就像在许多函数式语言中一样,是一个核心概念。一方面,它对于一些相当简单的循环情况可能是必要的。在这些情况下,recur,无论是与loop一起使用还是在递归函数中,几乎可以看作是“Clojure 的循环语法。”理解尾递归对于避免错误很重要,但除此之外,它相对简单。另一方面,递归可以是一种解决复杂问题的极其强大的方法。如果它有时让您感到头晕,那很正常:递归不仅仅是一种技术。它是一种解决问题的思维方式。不过,别担心。在下一章中,您将能够进一步练习您的递归技能!
在下一章中,我们将继续探索递归技术,并重点关注懒计算。
第七章:7. 递归 II:惰性序列
概述
在本章中,随着我们继续探索递归技术,我们将重点关注惰性评估。我们将编写能够安全地产生可能无限长的惰性序列的函数,使用惰性评估来消费线性数据,从线性数据中产生惰性评估的树结构,并编写消费树结构的函数。
到本章结束时,你将能够思考新的理解和解决问题的方法。
简介
在最简单的情况下,一个 惰性序列 是两件事物的混合体:
-
一个包含零个或多个项的 列表(不是向量!)
-
对列表可能未来项的 引用,这些项在必要时可以计算
换句话说,有一个实部和虚部。大多数时候,你不需要考虑这种区别。这正是惰性序列的全部意义:当你需要时,虚部才会变成实部。如果你可以避开边缘情况,你就不需要担心虚部,因为一旦需要,它就会变成实部。
在过去的几章中,我们已经以许多不同的方式使用了惰性序列。它们是 Clojure 的重要、独特特性,作为 Clojure 程序员,你每天都会用到它们。到现在为止,你已经看到它们是类似列表的结构,但有所区别:当你使用列表的第一个元素时,列表的其余部分在运行时可能不存在。记住,在惰性序列中,各个项只有在需要时才会被计算。或者,用 Clojure 的术语来说,它们只有在被消费时才会被实现。你可以定义一个可能无限的惰性序列,然后只使用和计算前三个项。
到目前为止,我们使用的惰性序列是由核心 Clojure 函数返回的,例如 iterate,我们在 第六章,递归和循环 中简要介绍了它,或者熟悉的功能,例如 map 和 filter,我们在 第四章,映射和过滤 中看到了它们。在这个路径上的下一步是学习编写自己的函数来产生惰性序列。
当在 Clojure 中工作时,惰性序列被频繁使用,正如你现在所看到的。编写函数来构建你自己的惰性序列是一个高级步骤,更容易出错。在处理序列时,它应该被视为最后的选项。以下是在决定解决序列相关问题时使用哪种技术的大致指南:
-
当处理顺序数据时,
map、filter和remove这些接受和返回序列的函数将是你所需要的所有。 -
reduce,因为你的计算需要考虑数据中项之间的关系,或者因为你需要产生一个汇总结果。 -
recur),或者因为你的数据是非线性的,例如树或图。 -
选项 4:在选项 3 的某些子集情况下,输入数据极其庞大,或者你的计算产生了太多的分支。结果是调用栈溢出。在这种情况下,惰性序列是一个解决方案。
每向下移动一步列表,程序员的努力和代码复杂性都会增加。当你接近列表的起始位置时,Clojure 提供了更多的帮助和防止潜在错误的能力。当你进一步向下移动列表时,你必须更加注意你解决方案的实现方式。成为一名有效的 Clojure 开发者的一部分是知道如何在可能的情况下避免选项 3 和 4,并在真正需要时有效地使用它们。对于某些类型的问题,例如解析深层嵌套的数据结构,惰性序列是一个非常好的选择,可能是 Clojure 中唯一实用的解决方案。这就是为什么它是一项重要的技能。还有另一个好处。了解惰性序列是如何产生的,将使你对它们的工作原理有更深入的理解。
在本章中,我们将首先使用惰性序列来处理线性数据,然后再转向更复杂的树结构。
一个简单的惰性序列
首先,让我们考虑最简单的惰性序列生产者,即 range 函数,它简单地返回一个可能无限的一系列连续整数。编写它的最简单方法是使用 iterate:
(defn iterate-range [] (iterate inc 0))
在这里,iterate 返回一个以初始化值零开始的序列,然后继续调用 inc 函数的结果,然后是那个结果,以此类推。每个中间值都成为返回的惰性序列中的一个项。它工作得很好:
user> (take 5 (iterate-range))
(0 1 2 3 4)
iterate 函数正在做所有的工作。我们本可以在这里停止,但我们不会学到很多关于惰性序列是如何构建的。这是一个更底层的版本,执行相同的任务。它使用 lazy-seq 宏,这是所有惰性序列的基础:
user> (defn our-range [n]
(lazy-seq
(cons n (our-range (inc n)))))
#'user/our-range
这个函数可能看起来对你来说很奇怪。我们将详细分解它。在我们这样做之前,让我们确保它工作正常:
user> (take 5 (our-range 0))
(0 1 2 3 4)
这里有三点引人注目。
-
lazy-seq的使用。这就是“魔法”发生的地方。注意,lazy-seq包装了函数的整个主体。 -
cons的使用。cons函数的名字可以追溯到 Lisp,它比 Clojure 要古老得多。cons是一个连接两个东西的函数。在大多数 Lisp 方言中,列表是通过使用cons将一个项目连接到列表的其余部分来构建的。在这里使用cons意味着我们在返回一个这样的列表。 -
递归的使用。没有
lazy-seq,这个递归调用将立即执行,并且持续执行,直到栈溢出。有了lazy-seq,下一个调用不会发生;相反,返回了对那个未来调用的引用。
函数的结构反映了它产生的数据结构:
![图 7.1:函数结构
![图片 B14502_07_01.jpg]
图 7.1:函数结构
多亏了 lazy-seq,递归调用变成了引用。
在左侧,我们有实部;在右侧,有虚部。随着序列的消耗,越来越多的对 our-range 的虚拟调用变成了实际调用。对进一步虚拟调用的引用保持在列表的末尾,以便在需要更多整数时准备就绪。
注意
未来尚未实现的计算通常被称为 thunk。这个词听起来像已经思考过的事情,但实际上是尚未被 "thunk" 的事情。
这只是懒序列最简单可能的版本,但它足以提供一个基本模式的概念,我们将在此基础上继续扩展。
消耗序列
如 our-range 这样的迭代序列接受单个输入,并通过在输入上调用一个函数来构建序列,然后是第一个函数调用的结果,然后是那个调用的结果,依此类推。虽然这种计算有时可能很有用,但更常见的是我们的代码需要接受某种类型传入的数据。
实现这种模式的组合了懒序列的迭代风格和前一章中的递归函数。与其他形式的递归一样,这些函数将围绕条件构建,通常是 if 或 cond,并通过处理输入序列的第一个项目并在其余输入上再次调用自己来前进。并且像 our-range 一样,它们通常使用 cons 构建输出,将当前项目附加到一个指向未来可能计算的列表上。
练习 7.01:寻找拐点
在这个练习中,我们正在构建一个分析用户 GPS 手表数据的工具。跑步者和徒步旅行者想知道他们在上山或下山时花费了多少时间。传入的数据是一个可能无限长的元组序列,包含海拔(以米为单位)和时间戳,即用户开始锻炼以来的毫秒数。
每个元组看起来如下所示:
[24.2 420031]
我们需要遍历输入序列,寻找局部峰值或谷值,即海拔高于前后点的点(峰值),或低于前后点的点(谷值)。我们本可以使用 reduce 来代替,就像我们在第五章 多对一:归约 中为自行车比赛问题所做的那样。然而,输入流可能非常非常长,所以我们不想强制评估,这就是使用 reduce 会发生的事情。
-
定义一些符合要求格式的样本数据。您可以在
packt.live/2Rhcbu6找到这个变量:(def sample-data [[24.2 420031] [25.8 492657] [25.9 589014] [23.8 691995] [24.7 734902] [23.2 794243] [23.1 836204] [23.5 884120]]) -
通过比较三个连续的项目可以检测到峰值或谷值。编写一个用于检测峰值或谷值的谓词。
当我们遍历海拔-时间元组的列表时,我们将查看当前项和接下来的两个项目,以识别峰值和山谷。在三个项目的子序列中,如果中间项大于第一个和最后一个,它是一个峰值;如果它小于两者,它是一个山谷。在其他所有情况下,中间项既不是峰值也不是山谷。我们将编写两个函数,
local-max?和local-min?,将这个逻辑转换为函数:(defn local-max? [[a b c]] (and (< (first a) (first b)) (< (first c) (first b)))) (defn local-min? [[a b c]] (and (> (first a) (first b)) (> (first c) (first b))))如果我们想要通过一些解构来清理我们的代码,我们可以避免所有那些
first调用。函数将接受一个包含三个项目的单列表。每个项目是一个包含两个项目的元组。通过两层解构,我们可以提取所需的元素而不调用任何函数:(defn local-max? [[[a _] [b _] [c _]]] (and (< a b) (< c b))) (defn local-min? [[[a _] [b _] [c _]]] (and (> a b) (> c b))) user> (local-max? (take 3 sample-data)) false user> (local-min? (take 3 sample-data)) false如果我们在
sample-data中向前移动两个项目,我们会发现一个山谷。第三个、第四个和第五个项目是[25.9 589014]、[23.8 691995]和[24.7 734902]。中间的值小于第一个和最后一个:user> (local-min? (take 3 (drop 2 sample-data))) true -
概述递归函数。正如我们讨论的那样,我们的函数将与迄今为止我们看到的大多数递归函数具有相同的基本形状。对于我们的条件,我们需要处理四种情况。在此之前,我们必须创建一个对
current-series的局部绑定,它将是data中的前三个项目。由于我们只是在布局函数,所以我们目前将返回不同的关键字而不是真实值:
(defn inflection-points [data] (lazy-seq (let [current-series (take 3 data)] (cond (< (count current-series) 3) :no-more-data-so-stop (local-max? current-series) :peak (local-min? current-series) :valley :otherwise :neither-so-keep-moving))))在
cond表达式的第一个测试中,我们检查是否已经没有数据了,这可以通过检查current-series的长度来完成。如果current-series不包含三个项目,那么我们知道我们永远不会找到峰值或山谷,所以是时候停止了。像往常一样,我们首先放置这个条件,这样在随后的测试中我们可以确保至少有一些数据。接下来的两个测试使用我们之前定义的谓词:local-max?和local-min?。在这些情况下,我们将当前值添加到累积的data中。最后一种可能性是当前项既不是最小值也不是最大值。在这种情况下,我们对那个特定的项不感兴趣,所以我们将继续而不将其放入累积数据中。 -
现在,我们可以开始决定这四种情况中的每一种会发生什么。第一种情况,当我们没有数据时,是最简单的,但也许也是最令人惊讶的。在这里,我们只是返回一个空列表:
(< (count current-series) 3) '()基于
recur递归,这样做是没有意义的:在输入数据结束时返回一个空列表意味着该函数总是返回…一个空列表。实际上并不太有用。然而,对于惰性序列,最后一个项目仅仅是将要返回的列表的结尾。在许多情况下,整个列表不会被消耗,输入序列的结尾也永远不会到达。如果我们把惰性序列看作是一系列指向潜在计算的指针,那么这里的空列表仅仅是最后一次潜在计算的结果。
重要的是,这应该是一个空列表,而不是,例如,一个空向量。向量不支持惰性。
-
下两个情况是当
local-max?或local-min?决定current-series中的第二个项目确实是一个峰值或谷值时。当这种情况发生时,我们需要做两件事。首先,我们需要通过添加适当的关键字将元组标记为峰值或谷值;其次,我们需要确保这个元组成为输出序列的一部分。添加关键字很简单:
(conj (second current-series) :peak)由于每个项都是一个包含两个值的元组,
conj会将其转换为包含三个值的元组:[24.7 734902 :peak]注意
当使用
conj时,总是要小心你正在使用什么类型的序列。当对一个向量调用时,conj会将新项追加到向量的末尾。当对一个列表调用时,它将追加到开头。这可能会让人惊讶,但有一个原因:conj总是以最有效的方式添加新项,这取决于所使用的数据结构。对于向量来说,这意味着添加到末尾;对于列表来说,这意味着添加到开头。 -
接下来,我们需要将这个新值纳入将要返回的序列中。而不是像使用
recur时那样将其放置在某种累加器中,当前项变成了所有剩余待计算项的列表的头部。这正是cons函数所做的事情。而“所有剩余待计算项的列表”在这里是通过递归调用inflection-points来表示的:(local-max? current-series) (cons (conj (second current-series) :peak) (inflection-points (rest data))) (local-min? current-series) (cons (conj (second current-series) :valley) (inflection-points (rest data)))这样想吧。在右边,你看到了所有剩余的项。
inflection-points的调用最终会返回它们,至少是潜在地。正如我们所说的,当前项是这个列表的头部。在左边,是已经计算过的项。就在当前项左侧的那个,将成为一个列表的头部,其中第二个项是当前项。以此类推,直到列表的起始位置。一个项通过调用一个函数与右边链接,而与左边链接是因为对该项调用了函数。注意
递归可能难以理解。不用担心!随着练习,它会变得更容易。
当使用
recur时,我们倾向于将结果放置在递归函数下一次调用的参数中。我们将对current-series参数这样做。因为这个函数将返回一个惰性序列,我们将使用cons并将当前项放在潜在的结果集的前面。每次函数调用都返回列表的一部分,父调用可以将这部分整合到它返回的内容中。 -
最后一个条件是最不有趣的:忽略当前项并继续到下一个。我们以现在熟悉的方式在
inflection-points的下一个递归调用中从数据中移除一个项来做这件事::otherwise (inflection-points (rest data))这里是完整的函数:
(defn inflection-points [data] (lazy-seq (let [current-series (take 3 data)] (cond (< (count current-series) 3) '() (local-max? current-series) (cons (conj (second current-series) :peak) (inflection-points (rest data))) (local-min? current-series) (cons (conj (second current-series) :valley) (inflection-points (rest data))) :otherwise (inflection-points (rest data)))))) -
使用我们在开头定义的
sample-data变量测试函数:user> (inflection-points sample-data) ([25.9 589014 :peak] [23.8 691995 :valley] [24.7 734902 :peak] [23.1 836204 :valley]) -
使用 Clojure 的
cycle函数将sample-data转换成一个我们的慢跑者反复跑过的电路:user> (take 15 (inflection-points (cycle sample-data))) ([25.9 589014 :peak] [23.8 691995 :valley] [24.7 734902 :peak] [23.1 836204 :valley] [25.9 589014 :peak] [23.8 691995 :valley] [24.7 734902 :peak] [23.1 836204 :valley] [25.9 589014 :peak] [23.8 691995 :valley] [24.7 734902 :peak] [23.1 836204 :valley] [25.9 589014 :peak] [23.8 691995 :valley] [24.7 734902 :peak]) It just keeps going and going!cycle返回一个无限重复seq的惰性序列。
这个练习介绍了你将在大多数生成惰性序列的函数中找到的基本结构。通过使用cons将其添加到下一个递归调用中,使当前成为未来计算列表的头部,这就是惰性序列递归的精髓。随着我们向前推进,周围的代码可能会变得更加复杂,但这个操作将是所有我们将要编写的生成惰性序列函数的构建块。
练习 7.02:计算移动平均值
为了将此付诸实践,让我们想象一个与上一章中提到的杂货传送带类似的场景。不过这次,我们正在对我们超级商店接收到的土豆进行质量控制。土豆在通过一个门时被称重。只要平均值保持在一定的范围内,我们就可以继续接收土豆。即使有一些小的土豆或几个大的土豆,只要大多数土豆都在限制范围内就可以。如果平均值过高或过低,那么肯定出了问题,因此我们将停止接收土豆。
-
启动一个新的 REPL。
-
首先,我们需要模拟一个土豆来源。我们需要定义一个随机土豆生成函数。我们将使用与杂货店相同的技术来生成无限序列的随机值,使用
repeatedly和rand-int。在这个版本中,我们添加了10,这将作为极端最小土豆尺寸。这样我们的生成器将产生10到400克的土豆:user> (def endless-potatoes (repeatedly (fn [] (+ 10 (rand-int 390))))) #'user/endless-potatoes -
使用
take测试这个惰性序列。你将自然得到不同的随机数:user> (take 5 endless-potatoes) (205 349 97 250 18) user> (take 10 endless-potatoes) (205 349 97 250 18 219 68 186 196 68)注意到两次的前五个元素都是相同的。这些项目已经被实现。新的土豆生成只会在序列中更远的地方计算更多项目时发生。
现在我们来到了第一个设计挑战。我们需要一种方式来表示列表中每个项目的当前平均值。除了当前土豆的重量外,我们还需要该点在序列中的土豆数量以及到目前为止的总重量。我们可以使用一个包含三个值的元组来保存这三个值。如果列表中的第五个土豆重量为
200克,而前五个土豆的总重量为784克,那么这个土豆可以这样表示:[200 5 784]由于我们有了项目数量和总数,这种设计使我们能够轻松地在序列中的任何一点计算平均值。
-
要找到三个项目后的平均值,我们会从序列中取出三个项目,如果我们的函数返回如下所示:
([59 1 59] [134 2 193] [358 3 551])将到目前为止的总重量
551除以土豆数量3,将给出当前的平均值,在这个例子中是179.33333。我们已经有了设计,但还需要实现它。让我们来看看选择序列处理技术的选项。
map在这里不可用,因为我们需要累积数据。《第四章》中提到的map窗口模式听起来可能可以完成这项工作,但它不能从序列的一端累积数据。“窗口”仅限于几个列表项。那么也许reduce是答案?不幸的是,reduce不是惰性的:整个序列会立即实现。在某些情况下这可能可以接受,但在这个情况下我们不知道输入会有多长。这就留下了递归。我们不能使用
recur,原因和不能使用reduce一样:我们可能正在处理一个几乎无限的流。这也意味着没有recur的标准递归会在我们处理完第一吨土豆之前就崩溃。因此,我们将使用lazy-seq来产生不同的计算。 -
从一个返回惰性序列的函数骨架开始,使用
lazy-seq宏来包装递归函数的内容:(defn average-potatoes [prev arrivals] (lazy-seq ))prev参数将开始为一个空向量。我们将使用它将当前总重量传递给下一次计算。 -
使用上一章学到的模式填写递归函数的内容,从停止递归的条件开始,即当输入被耗尽时。与设计为无限继续的函数不同,例如
range和average-potatoes,只有当它还有输入时才会继续,因此我们需要检查是否还有剩余的内容:(defn average-potatoes [prev arrivals] (lazy-seq (if-not arrivals '() )))在这里,我们看到与上一个练习相同的模式。当我们到达列表的末尾时,我们返回一个空列表。
注意
这里也可以返回
nil,因为cons在这里将nil视为空列表。试着在你的 REPL 中运行(cons 5 nil)。空列表在这里更好地告诉我们发生了什么。 -
在执行递归之前计算当前项。在这里我们将使用
let绑定,因为我们需要当前项两次:一次作为列表中的一个项,另一次作为prev参数,这将是我们计算下一个土豆总重量的基础:(defn average-potatoes [prev arrivals] (lazy-seq (if-not arrivals '() (let [[_ n total] prev current [(first arrivals) (inc (or n 0)) (+ (first arrivals) (or total 0))]] ;; TODO: the actual recursion ))))let绑定的第一部分使用了一些解构来从prev参数中获取当前的计数和到目前为止的总数。我们不需要前一个土豆的重量,因为它已经包含在运行总和中,这就是为什么我们为那个值使用了下划线。通过下一个绑定,我们构建实际的元组,它将成为序列中的一个项。这里的唯一复杂性是
n和total可能为nil,因此我们必须检查这一点,并在它们为nil时提供0。元组的第一个项只是当前土豆的重量;第二个项是我们将用于计算平均值的计数;最后一个项是到目前为止所有土豆的总重量。 -
添加递归逻辑:
(defn average-potatoes [prev arrivals] (lazy-seq (if-not arrivals '() (let [[_ n total] prev current [(first arrivals) (inc (or n 0)) (+ (first arrivals) (or total 0))]] (cons current (average-potatoes current (next arrivals)))))))在最后一步中,我们只是将当前项目附加到由后续对
average-potatoes的递归调用创建的列表中。我们将current作为prev参数传递给下一次调用。还要注意,我们使用
next而不是rest:这样,当我们到达序列的末尾时,到达将是nil,这正是我们在函数开头使用if-not测试时所期望的。next函数与我们在前一个例子中使用的rest非常相似。两个函数都接受一个列表,并返回该列表除了第一个项目之外的所有项目。当列表中没有更多项目时,这两个函数之间的区别是显而易见的。在这种情况下,rest返回一个空列表,而next返回nil。选择哪一个取决于情况。next的优点是,它很容易测试真值,就像我们在这里做的那样,而不是像我们在上一个练习中使用rest那样调用empty?。因为rest永远不会返回nil,所以没有空指针异常的风险。 -
测试您的新惰性函数。让我们先看看几个单独的项目:
user> (take 3 (average-potatoes '() endless-potatoes)) ([321 1 321] [338 2 659] [318 3 977])每个元组的第三个元素正确地指示了累积的权重。这部分看起来是正确的。
现在我们尝试处理大量项目。我们将从长长的土豆列表中取出最后一个元素:
user> (last (take 500000 (average-potatoes '() endless-potatoes))) [43 500000 102132749]在
500,000个土豆的情况下,一切似乎仍然按预期工作。
在这个例子中,重要的是要注意使用 rand-int 和 inc 进行惰性评估的前一个例子之间的区别。使用 rand-int 时,每次调用都是相互独立的。惰性评估在这里之所以有用,是因为它允许有一个可能无限长的序列。使用 inc 时,当我们实现我们的 range 版本时,情况稍微复杂一些,因为每次调用以及序列中的每个项目都依赖于前一次调用的结果。这也适用于 average-potatoes 函数:每次调用都依赖于前一次调用中建立的总量和计数,并将此信息传递给下一次调用。同时,它还做了更多的事情,因为每次计算也会消耗输入序列中的一个项目。
核心的 map 和 filter 函数也这样做:它们的输出长度不超过它们的输入。这又增加了一个使用消耗和产生惰性序列的写函数的理由:它们可以与其他惰性函数一起链接,而无需强制评估。
惰性消耗数据
惰性评估是一个接口,它大多数时候都隐藏了用户是否已经执行了计算,或者即将执行计算。这就是我们说惰性序列包含可能延迟的计算的含义。“可能”是因为我们不知道,也不需要知道计算是否已经发生。
然而,要变得有用,延迟评估必须从一次函数调用传递到下一次。如果我们将一个延迟序列传递给函数 A,然后将结果传递给函数 B,依此类推,只有当 A 和 B 被设计为返回延迟序列时,评估才会保持延迟。
例如,假设我们有一个名为 xs 的延迟序列。如果它被传递给 map,然后是 filter,然后是 take-while,它将始终保持“延迟”状态。如果我们在这个函数调用链中插入一个 reduce,或者如果我们使用 mapv 而不是 map,延迟性就会消失。
最后,潜在的评估并不足够。你确实需要在某个时候得到一些实际数据!然而,通常在最后保留延迟性是有利的。这意味着避免像这样的事情:
(->> xs
(map some-func)
(reduce some-reducing-func)
(filter some-predicate?))
这里,我们取一个序列并使用 map 将一个函数应用于每个项。map 的调用是延迟的,但 reduce 的调用不是,所以整个序列被实现。然后,filter 会丢弃一些结果。根据函数执行的操作,这可能是有可能编写此代码的唯一方法。然而,理想情况下,我们会在调用 reduce 或任何其他不保留延迟性的函数之前,尽可能使用 filter 来缩小数据。
(->> xs
(map some-func)
(filter some-predicate?)
(take-while another-predicate?)
(reduce +))
这种模式的第一个优点是代码变得更简单。filter 和 take-while 可能已经移除了一些 reduce 调用根本不需要处理的数据类型。通过等到最后一刻才强制序列完全实现,实际上可能需要做的计算工作更少。
为了了解这可能如何工作,让我们看看这个简单的表达式:
(nth (filter even? (range)) 3)
评估从 (range) 开始,如果 range 不是延迟的,它将返回从零到无穷大的所有整数。换句话说,CPU 使用率会达到 100%,评估会在出现错误时停止。
尽管如此,我们可以想象 (range) 的返回值看起来像这样:


图 7.2:范围值的表示
一个未实现的延迟序列:一个起点和一个指向未来计算的指针。
这比从零计数到无穷大要少得多的工作。本质上是一个指向计算序列其余部分的指令的指针,如果需要的话。
然而,我们不需要所有的整数,只需要偶数:
(filter even? (range))
通过这个 filter 调用,我们创建了一个基于第一个的第二个延迟序列:


图 7.3:创建第二个延迟序列
通过添加 filter,我们创建了一个新的延迟序列,它仍然只包含一个实际的数据项。
我们目前拥有的唯一具体数据仍然是零。我们所做的只是增加了获取序列其余部分的指令。到目前为止,一切都是虚拟的。
然后,最后,我们调用 nth 并请求序列中的第四个项:
user> (nth (filter even? (range)) 3)
6
现在,一切都变了。我们需要前四个项的真实值。nth 导致 filter 序列的前四个项被评估。为此,filter 需要评估 range 序列的前七个项。突然,(range) 看起来像这样:

图 7.4:范围内的当前值
前七个项已经实现。序列的其余部分仍然是虚拟的。
And (filter even? (range)) looks as follows:

图 7.5:从范围内过滤偶数
列表中的第四个值现在可以像任何其他值一样被消费。
这里要理解的重要事情是:在这些序列中的最后一个元素是指向进一步潜在计算的指针。filter 和 range 一起工作,只为 nth 提供足够的数据,不再更多。通过从函数到函数传递指针(或“未来计算的指令”),最终消费者可以向上发送信号,从指针到指针,以获取必要的数据。
注意
Clojure 中的懒序列实际上被实现为分块序列。这是一个实现细节:作为一个优化,Clojure 有时实现比实际需要的更多项。虽然这是值得了解的,但这不应该有任何影响。这只意味着你不能指望一个项不被实现。
The capacity to avoid performing certain calculations until the last millisecond, or to avoid them entirely, is an important feature of lazy computation. There is another aspect that can be equally important: the ability to "forget" some of the calculations that have already been performed.
Consider a non-lazy Clojure collection, such as a vector. Whatever size the vector is, the entire vector is available in memory at the same time. When the program no longer needs the vector, the platform on which the program is running, the JVM or a JavaScript engine, has a garbage collector that will free up the memory occupied by the vector. But if the program still needs even just one item from that vector, the garbage collector will keep waiting until it can safely reclaim that memory space.
懒序列的行为方式完全不同。它不是一个单一实体,而是一系列链接的实体,每个实体都指向下一个。正如我们所看到的,这就是懒序列甚至可以引用尚不存在或可能永远不会存在的项的方式。然而,方程式中的另一个重要部分是。如果不再需要序列的第一个部分,懒序列也会被垃圾回收。
在最后一个例子中,我们正在寻找范围内的第四个偶数。那么,如果我们正在寻找第七百万个偶数呢?
user> (nth (filter even? (range)) 7000000)
14000000
在这种情况下,没有必要保留对由(range)产生的最初 13,999,999 个整数的引用。这意味着它们可以被安全地垃圾回收。
懒序列的这个特性意味着它们可以用来处理那些一次性无法全部装入计算机内存的数据集。通过“忘记”第一部分并且不立即计算最后一部分,Clojure 可以处理极其长的序列,同时只使用包含它们所需内存的一小部分。然而,这里有一个限制。
如果一个程序保持对序列开头的引用,那么它就不再可以被垃圾回收。通常情况下,这不会有什么影响,但面对非常长的序列时,这成为一个重要的考虑因素。在整个章节中,你可能注意到我们经常在函数内部重复使用像(first my-seq)这样的表达式,尽管使用局部的let绑定可能更有诱惑力。这是避免阻止序列被垃圾回收的引用的一种方法。
懒树
到目前为止,我们已经看到懒序列的“懒”特性是它们可以指向未来的计算,这些计算只有在必要时才会执行。还有一个同样重要的优点,我们现在将要探讨。记得在第六章,递归和循环中,Clojure 中的递归函数需要使用recur来避免栈溢出吗?还记得recur只与特定类型的递归一起工作,即尾递归,其中下一个对递归函数的调用可以完全替换上一个调用吗?问题,你可能会记得,是只有有限的栈帧可用。树根节点的函数调用需要等待所有对子节点、孙节点、曾孙节点等的调用都完成。栈帧是一种有限的资源,但我们需要操作的数据通常非常庞大。这种不匹配是一个问题。
这就是懒评估发挥作用的地方。使用懒评估,Clojure 本身处理下一个计算的链接,而不是调用栈。这样,当我们使用递归函数遍历整个树时,我们不再试图使用栈来映射树的结构。相反,懒序列本身完成所有跟踪哪些结果需要返回给哪些函数的工作。
练习 7.03:网球历史树
在这个练习中,我们回到了网球的世界。正如你所记得的,在第五章,多对一:减少中,我们基于历史数据建立了一个为网球运动员建立 Elo 评分的系统。这些预测在你工作的体育新闻网站上变得很受欢迎。一些读者询问了更多关于 Elo 预测的信息。为了回应你对预测引擎的新兴趣,你的雇主希望前端团队构建一个可视化,展示球员在最近几场比赛中的评分演变。你的任务就是提供必要的数据。
我们可以重用第五章,多对一:减少中我们所做的大部分工作,用于导入数据和生成评分。不过,需要做一项修改。在第五章,多对一:减少中,我们只对最终评分感兴趣。我们需要修改之前的代码,逐行追加每场比赛中玩家的当前评分。
-
设置一个与活动 5.01,计算网球 Elo 评分中相同的工程,并将你为活动编写的函数复制到一个新文件中。你还需要
match-probability,recalculate-rating和elo-world-simple。同时,将match_scores_1968-1990_unindexed_csv.csv从packt.live/36k1o6X复制到新项目中。deps.edn文件看起来是这样的:{:paths ["src" "resources"] :deps {org.clojure/data.csv {:mvn/version "0.1.4"} semantic-csv {:mvn/version "0.2.1-alpha1"} org.clojure/math.numeric-tower {:mvn/version "0.0.4"}}}命名空间声明应如下所示:
(ns packt-clojure.lazy-tennis (:require [clojure.math.numeric-tower :as math] [clojure.java.io :as io] [clojure.data.csv :as csv] [semantic-csv.core :as sc])) -
修改
elo-world-simple,使得每行(每个匹配)都被保留,并且记录下两位玩家的评分。添加存储每场比赛玩家评分的能力,在比赛时,相当简单。所有更改都发生在函数的末尾。从调用
reduce的初始化映射开始。在
elo-world-simple中,reduce函数的调用如下(为了简洁,省略了减少函数的内容):(reduce (fn [{:keys [players] :as acc} {:keys [winner_name winner_slug loser_name loser_slug] :as match}] ;; TODO: content temporarily unavailable ) {:players {} :match-count 0 :predictable-match-count 0 :correct-predictions 0}) -
在新版本中,我们将用
:matches字段替换:match-count字段,其初始值将是一个空向量:(reduce (fn [{:keys [players] :as acc} {:keys [winner_name winner_slug loser_name loser_slug] :as match}] ;; TODO: your content will be restored shortly ) {:players {} :matches []}) -
要计算
elo-world-simple版本中的比赛数量,对于 CSV 文件中的每一行,我们只需在累加器中增加:match-count field,如下所示:(-> acc ;; TODO: more missing code (update :match-count inc)) -
在这个版本中,我们需要将当前比赛(带有已经计算出的
winner-rating和loser-rating)附加到不断增长的比赛列表中。以下是修改acc的完整系列。新部分是提供给update的匿名函数:(-> acc (assoc-in [:players winner_slug] (recalculate-rating k winner-rating winner-probability 1)) (assoc-in [:players loser_slug] (recalculate-rating k loser-rating loser-probability 0)) (update :matches (fn [ms] (conj ms (assoc match :winner_rating winner-rating :loser_rating loser-rating))))) -
将函数重新组合起来。为了清晰起见,让我们将其重命名为
elo-db。由于我们只关心比赛列表,我们可以删除所有专门用于计算正确预测数量的代码。最后,在调用reduce之后,我们将在->线程宏中添加两个额外的调用:(->> ;; calls to reduce, etc. :matches reverse):matches关键字作为一个函数,从reduce返回的映射中提取匹配列表。我们还需要reverse匹配的顺序,因为我们要运行的查询是从现在开始,向过去推进的。这给我们提供了函数的最终状态:
tennis_history.clj 16 (defn elo-db 17 ([csv k] 18 (with-open [r (io/reader csv)] 19 (->> (csv/read-csv r) 20 sc/mappify 21 (sc/cast-with {:winner_sets_won sc/->int 22 :loser_sets_won sc/->int 23 :winner_games_won sc/->int 24 :loser_games_won sc/->int} The full code for this step can be found at https://packt.live/2GffSKv在 REPL 中,运行此函数对 CSV 文件进行处理,并将匹配结果存储在一个变量中:
packt-clojure.lazy-tennis> (def ratings (elo-db "match_scores_1991-2016_unindexed_csv.csv" 35)) #'packt-clojure.lazy-tennis/db我们可以验证评分是否可用:
packt-clojure.lazy-tennis> (map #(select-keys % [:winner_rating :loser_rating]) (take 5 ratings)) ({:winner_rating 985.2418497337458, :loser_rating 927.9839328429639} {:winner_rating 1265.3903009991964, :loser_rating 875.8644912132612} {:winner_rating 1012.6267015729657, :loser_rating 969.5966741618663} {:winner_rating 1311.801159776237, :loser_rating 1002.1872608853402} {:winner_rating 853.6200747439426, :loser_rating 950.2283493122825})在有了这些数据之后,我们就可以编写搜索和提取信息的函数了。
-
编写一个函数来测试玩家是否在给定的比赛中是赢家或输家。
我们需要能够跳过我们不感兴趣的匹配,所以这是一个常见的测试。将其制作成一个自己的谓词函数会使我们的代码更容易阅读。你可能会从第四章,映射和过滤中认出这个模式,使用 Clojure 集合:
(defn player-in-match? [{:keys [winner_slug loser_slug]} player-slug] ((hash-set winner_slug loser_slug) player-slug)) packt-clojure.lazy-tennis> (player-in-match? (first ratings) "gael-monfils") "gael-monfils" packt-clojure.lazy-tennis> (player-in-match? (first ratings) "boris-becker") nil -
现在我们已经准备好编写递归函数来搜索匹配列表。从一个简单的骨架开始:
(defn match-tree-by-player [m player-name] (lazy-seq (cond (empty? m) ;; No more matches (player-in-match? (first m) player-name) ;; Build the tree! ::otherwise ;; Keep walking through the tree )))就像所有消耗另一个序列的递归函数一样,这个函数的结构围绕一个条件。这里我们有测试而没有相应的代码。我们现在需要做的就是填补空白!
-
从最后一个条件开始,即当前匹配不包含我们要找的玩家。
在这种情况下,我们忽略当前记录,继续沿着输入列表移动,递归地对剩余项调用
match-tree-by-player:(match-tree-by-player (rest m) player-slug) -
再次强调,基本模式应该是相当熟悉的:
cons会将当前项——当前匹配项——连接到稍后要计算的其余部分。然而,这次我们遇到了一个新的问题:我们需要继续搜索当前比赛中获胜者和失败者所打的匹配。这意味着有两个独立的搜索,也就是对match-tree-by-player进行两次独立的递归调用。由于我们有两个东西,我们需要将它们放入某个东西中。在这种情况下,向量通常是一个不错的选择。以下是代码:(cons (first ms) (cons [(match-tree-by-player (rest ms) (:Winner (first ms))) (match-tree-by-player (rest ms) (:Loser (first ms)))] '()))对那个
cons的第二次调用感到惊讶吗?它在那里是因为我们需要向输出添加不止一个项目。我们添加当前匹配,然后是包含两个新树头部的向量。在向量内部,只有当需要时才会执行对match-tree-by-player的两个调用。最后的
'()是必要的,因为cons需要一个集合作为第二个参数。 -
添加当
m为空时的代码。就像上一个例子一样,我们提供一个空列表,以便懒序列最终有一个结束的地方。
现在我们可以看到完整的函数:
(defn match-tree-by-player [m player-slug] (lazy-seq (cond (empty? m) '() (player-in-match? (first m) player-slug) (cons (first m) (cons [(match-tree-by-player (rest m) (:winner_slug (first m))) (match-tree-by-player (rest m) (:loser_slug (first m)))] '())) ::otherwise (match-tree-by-player (rest m) player-slug)))) -
测试这个函数。让我们从一个我们知道不在匹配记录中的玩家开始:
packt-clojure.tennis> (match-tree-by-player ratings "non-tennis-player") ()我们得到一个空列表,这正是我们想要的。
现在让我们尝试从“数据库”中选取一个玩家(这是那些你真的希望将结果附加到变量上而不是试图将所有内容打印到 REPL 中的时刻之一):
packt-clojure.tennis> (def federer (match-tree-by-player ratings "roger-federer")) #'packt-clojure.lazy-tennis/federer packt-clojure.lazy-tennis> (type federer) clojure.lang.LazySeq我们可以检查第一个项目以查看我们的数据是否真的在那里:
packt-clojure.lazy-tennis> (:winner_rating (first federer)) 1129.178155312036到目前为止,一切顺利!
让我们退后一步,思考一下我们在这里创造了什么。如果我们尝试查看列表中有多少项,我们会得到这个:
packt-clojure.lazy-tennis> (count federer)
2
这看起来很奇怪。我们知道罗杰·费德勒(Roger Federer)比这打了很多场比赛。count函数在这里没有按预期工作,因为它不理解这个分层返回值的结构。count能看到的第一项是一个比赛,cons到一个向量上。所有其他结果都嵌套在这个向量中。层次结构中的每个项目都共享这种相同的结构。从概念上讲,它看起来像这样:


图 7.6:一个分层数据结构,其中包含在向量中的 cons 箭头是惰性的
在这个图中,惰性发生在向量内部。这两个惰性序列还没有实现。我们可以通过仔细钻入结构来检查这一点:
packt-clojure.lazy-tennis> (realized? (first (second federer)))
false
这是一件好事,因为整个结构,如果实现,将会非常大。真的,非常大。隐藏在快速响应时间背后的是一棵树,如果完全实现,最终会在原始列表的数据上重复数千次。尝试从match-tree-by-player内部移除lazy-seq包装器。结果将类似于这样:


图 7.7:栈溢出——递归调用太多
让我们简要地看看这个错误信息。Clojure 的堆栈跟踪并不总是那么容易阅读,但这个确实包含了一些有趣的信息。大部分是"Caused by java.lang.stackOverflowError"这个语句。这就是导致编译器异常并停止我们的代码的原因。在这下面,重复出现的提到match-tree-by-player的行告诉我们match-tree-by-player一直在反复调用自己。这只是整个堆栈跟踪的一小部分,它会在几个屏幕上继续下去。早些时候,我们谈到了堆栈帧。在这里,每一行都是一个帧。每个函数的调用都在等待下一个函数解决。在某个点上,有太多的函数在相互等待,最终堆栈溢出。
再次强调,惰性评估使我们免于这种情况,并允许我们以复杂的方式遍历数据,并在几行代码中构建非常复杂的数据结构。
练习 7.04:自定义 take 函数
前面的练习给了我们一种从我们的线性数据集中提取树的方法。它仍然不允许你做雇主要求的事情,即显示一个有限的树,只显示层次结构中的几个级别。一旦我们有了这个,我们就能展示设计团队想要的任何级别的树。
我们真正需要的是 Clojure 的take函数的一个特殊版本,它理解我们的数据结构。我们希望能够编写(take-matches 4 federer)并得到一个四层树。
要做到这一点,我们需要解决两个问题。首先,我们需要能够遍历我们创建的树。其次,我们需要一种方法来计算我们遍历的层数。
-
这个练习建立在之前的练习之上。使用来自
packt.live/38Dzp3H的tennis_history.clj文件或制作一个完整的副本。 -
从一个只在其第一个参数为零时工作的函数版本开始:
(defn take-matches [limit tree] (cond (zero? limit) '()))检查它是否工作。为此,你需要上一个练习中的
federer变量:packt-clojure.lazy-tennis> (take-matches 0 federer) () -
让我们添加请求零个或一个项目的可能性:
(defn take-matches [limit tree] (cond (zero? limit) '() (= 1 limit) (first tree)))这种行为重复了上一个练习中的测试。我们只是简单地返回延迟序列中的第一个匹配项。
尽管如此,进行测试仍然是一个好主意。我们将使用
select-keys来避免产生大量额外的输出数据:packt-clojure.lazy-tennis> (select-keys (take-matches 1 federer) [:winner_slug :loser_slug]) {:winner_slug "roger-federer", :loser_slug "guido-pella"} -
当
limit大于一时,添加一个:otherwise条件:(defn take-matches [limit tree] (cond (zero? limit) '() (= 1 limit) (first tree) :otherwise-continue (cons (first tree) (cons [(take-matches (dec limit) (first (second tree))) (take-matches (dec limit) (second (second tree)))] '()))))这一部分应该看起来很熟悉,因为它与我们最初构建此结构的函数的结构相似,有两层
cons,其中第二层附加了一个包含两个更多take-matches调用的向量。通过每次说
(dec limit),未来的take-matches调用最终会达到零并停止遍历树。 -
测试完整的函数:
(take-matches 3 federer)由于每个匹配映射中包含的字段太多,结果太长无法在此打印。它们应该具有以下基本结构:
({} [({} [{} {}]) ({} [{} {}])])在下一个练习中,我们将使这些结果更容易看到。
知道何时使用延迟加载
你可能已经注意到这次我们没有使用 lazy-seq。我们应该使用吗?显然,用 lazy-seq 宏包装函数体是很容易的。我们是否这样做主要取决于 take-matches 函数在我们应用程序内部的使用方式和位置。如果 take-matches 是在将数据传递给前端之前的最后一步,那么让它变得延迟加载就没有意义了。我们确信数据大小合理,并且我们需要所有这些数据来生成我们要展示的视觉效果。在这种情况下,使函数变得急切(与延迟加载相反)似乎是一个不错的选择。
另一方面,如果 take-matches 将用于其他任务,那么使其延迟加载可能是有意义的。例如,如果我们想提取更多级别的数据,然后对结果执行某种其他类型的操作,延迟评估的好处可能很重要。
我们越来越接近满足雇主的要求。我们可以将树作为延迟序列检索,现在我们可以限制要包含的匹配历史级别的数量。我们唯一剩下的问题是结果难以可视化,即使在 REPL 中也是如此。我们需要能够格式化每个匹配映射。然而,由于树结构,我们不能只是使用 map 来转换每个匹配。这就是我们将在下一个练习中实现的内容。
练习 7.05:格式化匹配项
我们需要一种方法来访问树中的每一张地图。与其编写一个专门的map函数,我们将在take-matches函数中添加一个额外的参数,在返回每个匹配项之前调用它。这将使我们能够消除一些不需要向用户展示的数据键。同样,将评分显示为整数而不是浮点数会更好。我们还将添加一个功能:由于我们在比赛前就有每个玩家的评分,我们可以使用match-probability函数来显示 Elo 预测。
-
在上一个练习相同的文件中(或项目的副本中),向
take-matches函数添加一个函数参数,并在返回比赛之前调用它:(defn take-matches [limit tree f] (cond (zero? limit) '() (= 1 limit) (f (first tree)) :otherwise-continue (cons (f (first tree)) (cons [(take-matches (dec limit) (first (second tree)) f) (take-matches (dec limit) (second (second tree)) f)] '()))))这实际上非常简单。只需注意不要忘记在最后的两次递归调用中传递
f参数。格式化函数在树的每一层都需要。现在我们可以使用
select-keys或任何我们想要的格式化函数来调用这个函数。为了测试这个,我们将重用前两个练习中的
federer变量。如果需要,可以像这样重建它,其中matches是elo-db的输出:(def federer (match-tree-by-player matches "roger-federer"))让我们看看几个值:
packt-clojure.lazy-tennis> (take-matches 3 federer #(select-keys % [:winner_slug :loser_slug])) ({:winner_slug "roger-federer", :loser_slug "guido-pella"} [({:winner_slug "roger-federer", :loser_slug "marcus-willis"} [{:winner_slug "roger-federer", :loser_slug "daniel-evans"} {:winner_slug "pierre-hugues-herbert", :loser_slug "marcus-willis"}]) ({:winner_slug "benjamin-becker", :loser_slug "guido-pella"} [{:winner_slug "dudi-sela", :loser_slug "benjamin-becker"} {:winner_slug "guido-pella", :loser_slug "diego-schwartzman"}])])我们的数据可视化可能需要一些润色,但即使以这种形式,我们不仅可以看到树的实际结构,还可以看到参与罗杰·费德勒最近比赛的玩家的胜负历史!
注意
如果我们想要使
f参数可选,我们可以提供identity函数作为默认的格式化函数。identity函数简单地返回它被调用的任何参数,这使得它成为理想的占位符函数。 -
编写一个
matches-with-ratings函数,使用更复杂的格式化函数,将评分浮点数转换为整数,并只显示玩家的姓名和评分:(defn matches-with-ratings [limit tree] (take-matches limit tree (fn [match] (-> match (update :winner_rating int) (update :loser_rating int) (select-keys [:winner_name :loser_name :winner_rating :loser_rating]) (assoc :winner_probability_percentage (->> (match-probability (:winner_rating match) (:loser_rating match)) (* 100) int))))))由于
match-probabilty返回一个长小数,我们将其转换为百分比,使其更易于用户使用。 -
测试新功能:![图 7.8:带有评分的比赛
![图片]()
图 7.8:带有评分的比赛
现在我们的结果讲述了一个真实的故事!除了显示谁赢了谁输了,它们还显示了结果是否在预期之中,或者是一个惊喜。很容易看出哪些比赛是冷门,比如当 Dudi Sela 只有 35%的胜率时赢得比赛,以及哪些胜利几乎是不可避免的,比如罗杰·费德勒有 98%的胜率击败马库斯·威利斯。
这个练习也是使用函数作为参数的强大功能的良好示例。通过对我们take-matches函数的这种微小修改,它已经变成了一个类似于自定义map函数的工具,可以用于许多不同的目的。
活动七.01:历史、以玩家为中心的 Elo
体育新闻网站的数据可视化团队非常喜欢你的工作。读者们越来越好奇 Elo 分数是如何随时间演变的。邮件如潮水般涌入,要求提供更多信息。因此,记者们有一个新的可视化请求。他们发现,在单个网页上展示超过四个级别的网球历史是困难的。之后,分支太多,读者就不再阅读了。
在新的项目中,记者们希望展示单个球员在多场比赛中的演变过程。对于给定的一场比赛,他们希望展示他们关注的球员的相对较长的历史,而对于每个对手则展示更短的历史。
这是团队希望能够制作的那种图形:

图 7.9:关注球员有很长的历史,但对手的历史被截断显示
记者们还不知道历史需要有多长,因此两个长度都需要参数化。如果对手的历史超过一个级别,函数不应该只关注对手,而应该返回一个包含对手、对手的对手等等的完整子树。
你的任务是调整现有的代码以适应这个新用例。
-
作为起点,你需要从练习 7.03,网球历史树中的代码中获取
match-probability、recalculate-rating、elo-world-db和match-tree-by-player函数。你还需要在deps.edn文件中包含它们的依赖项。 -
你将要编写的递归函数将是练习 7.04 中的
take-matches函数的专用版本。它将操作match-tree-by-player的输出,而match-tree-by-player函数又使用elo-world-db的输出。 -
新的函数将根据比赛是否涉及“关注球员”而具有不同的行为。你需要为这两种行为分别设置参数(例如,在
take-matches中的limit参数)。 -
和
take-matches一样,你的新函数应该接受一个函数参数,该参数将在单个比赛中被调用。提示
不要忘记,如果你需要,可以在你的新函数内部调用
take-matches。 -
你为不同类型的比赛提供两种不同的行为将影响你的函数如何递归地遍历树。
注意
这个活动的解决方案可以在第 701 页找到。
总结
惰性序列和递归可能相当具有挑战性。到现在为止,你应该知道如何安全地消耗惰性序列,如何生成它们,以及如何使用它们从线性数据源构建树结构,所有这些都不需要吹爆你的运行时栈。
正如我们之前所说的,只有当所有其他选项都不奏效时,才应该编写函数来生成基于递归的懒序列。从map和filter开始。如果还不够,尝试reduce。也许其他的递归形式会有效。如果这些都不能解决问题,那么你就有懒序列,这是一个极其强大且高效的工具。
懒序列和递归总是让我们思考。能够编写自己的懒序列也会让你成为一个更明智的懒序列消费者。在 Clojure 中,这一点非常有价值,因为懒序列无处不在。我们在这里探讨的技术也可以帮助你开始思考理解和解决问题的新的方法。
到现在为止,你已经走在能够有效地在 Clojure 中编码的道路上了。要成为一个有效的开发者,你还需要了解管理 Clojure 项目所需的工具。这正是我们将要探讨的。在下一章中,你将学习关于命名空间以及如何使用 Leiningen 来组织和运行你的项目。
第八章:8. 命名空间、库和 Leiningen
概述
在本章中,我们将学习如何组织 Clojure 代码。我们首先来看命名空间——一种将 Clojure 函数分组的方法。我们将了解如何创建自己的命名空间以及如何导入他人编写的命名空间。命名空间是 Clojure 库的构建块。我们将学习如何导入和使用 Clojure 库。在了解命名空间和库之后,我们将研究如何构建 Clojure 项目。然后,我们将查看 Leiningen 项目模板以及它是如何帮助开发者创建应用程序的。
到本章结束时,你将能够使用 Leiningen 组织和运行你的项目。
简介
在上一章中,我们学习了 Clojure 中的序列以及如何使用它们来构建 Clojure 程序。现在你已经熟悉了如何使用 Clojure 实现各种功能,是时候更熟悉在 Clojure 和 ClojureScript 中创建、构建、测试、部署和运行项目的基本任务了。
Clojure 从一开始就被设计成一种非常实用的语言。完成任务意味着与外部世界互动、构建项目、使用库以及部署你的工作。作为一名开发者,你需要以结构化的方式组织编写的代码。在本章中,你将看到命名空间如何帮助你组织代码,以及构建工具如 Leiningen 如何帮助你构建整个应用程序。
在现实世界的项目中,你不会编写所有的代码。外部依赖是任何项目的关键部分,我们将在这里学习如何将它们引入你的项目和代码中。
第一步是了解 Clojure 命名空间的一般工作原理。然后,我们将查看项目级别,使用 Leiningen 和你的 project.clj 文件将所有内容整合成一个 Java 可执行文件。最后,我们将看看 Leiningen 在项目生命周期中提供的便利之处。
命名空间
命名空间是一种组织 Clojure 函数的方式;你可以将命名空间想象成一个目录(或文件),它存储了一组特定的函数。每个目录都是独立的,这有助于保持不同组函数的分离,并为你的代码提供清晰的架构。它还有助于避免命名冲突可能带来的混淆。
考虑这样一个情况,你已经编写了一个名为calculate-total的函数,作为你的项目的一部分,你正在使用一个库(关于库的更多内容将在本章后面介绍),该库也包含一个名为calculate-total的函数。尽管这两个函数具有相同的名称,但它们的工作方式不同,输出略有不同,并且旨在不同的场景中使用。当你开始在代码中使用calculate-total时,系统如何知道你实际上想要哪个calculate-function?这就是命名空间发挥作用的地方。这两个函数将存在于不同的命名空间中,因此当你调用函数时,你可以声明适当的命名空间来指定你想要使用的是哪一个。
从更技术性的角度来说,命名空间在符号(对人类读者有意义)和 var 或类之间提供了一个映射。命名空间可以与 Java 中的包或 Ruby 和 Python 中的模块相比较。我们将首先使用 REPL 来探索 Clojure 中命名空间的概念。
练习 8.01:调查 REPL 中默认启动的命名空间
在这个练习中,我们将研究 REPL 中命名空间是如何处理的:
-
打开终端或命令提示符,并添加以下命令以使用 Leiningen 启动 REPL:
lein repl这将使用 Leiningen 启动 REPL。启动 REPL 可能需要几秒钟。一旦 REPL 启动,你应该看到以下类似的内容:
![图 8.1:启动 REPL]()
图 8.1:启动 REPL
最后的行,
user=>,告诉我们我们处于默认的user命名空间。在这个命名空间中,clojure.core命名空间中的函数对我们可用。让我们探索几个函数。 -
在 REPL 中,输入以下代码来计算两个数字的和:
(+ 1 2)这段简单的代码应该返回:
3 -
让我们尝试使用 Clojure 的
filter odd函数来返回奇数:(filter odd? [1 2 3 4 5 6])这将返回以下内容:
(1 3 5)
我们看到在默认用户命名空间中,我们可以访问核心 Clojure 函数。但如果我们想访问定义在其他命名空间中的函数怎么办?Clojure 为我们提供了in-ns函数,该函数切换到请求的命名空间。如果请求的命名空间不存在,此函数还会创建一个新的命名空间。在下一个练习中,我们将使用in-ns函数来访问当前正在使用的不同命名空间中的数据。
练习 8.02:在命名空间中导航
在上一个练习中,我们使用了默认用户命名空间中的函数。在这个练习中,我们将查看如何访问其他命名空间中的数据:
-
在终端中,调用
in-ns函数来创建一个新的命名空间:(in-ns 'new-namespace)在 REPL 中,我们将看到已经创建了一个新的命名空间:
![图 8.2:创建的新命名空间]()
图 8.2:创建的新命名空间
你应该注意到 REPL 提示已经更改为
new-namespace=>。这个视觉提示告诉我们我们已经成功切换到了一个新的命名空间。在这个命名空间内部声明的任何内容都将在此可用。
-
我们将在新的命名空间中声明一个变量。输入以下声明:
(def fruits ["orange" "apple" "melon"])REPL 告诉我们已创建了一个新变量:
#'new-namespace/fruits -
为了检查其内容,我们将从 REPL 按如下方式访问它:
fruits如预期,REPL 返回给我们向量:
["orange" "apple" "melon"] -
现在我们将使用
in-ns函数切换命名空间:(in-ns 'other-namespace)REPL 告诉我们知道发生了变化:
#object[clojure.lang.Namespace 0x2f2b1d3c "other-namespace"]REPL 的提示符也发生了变化:
other-namespace=> -
现在按如下方式访问
fruits向量:fruits我们将看到不愉快的结果:
CompilerException java.lang.RuntimeException: Unable to resolve symbol: fruits in this context, compiling:(null:0:0)我们在
new-namespace中声明了fruits向量,但我们尝试从另一个命名空间访问它。要从另一个命名空间访问变量,我们需要明确指出变量来自哪个命名空间。 -
在 REPL 中输入完全限定名称以按如下方式访问数据:
new-namespace/fruits这次,我们得到了
fruits向量:["orange" "apple" "melon"]
使用完全限定名称可能会变得繁琐。在下一个练习中,我们将看到 Clojure 如何帮助我们管理多个命名空间。
使用 refer 函数导入 Clojure 命名空间
Clojure 提供了 refer 函数,旨在帮助开发者编写紧凑的代码。这是通过将特定命名空间的内容导入当前命名空间来实现的,从而使得这些内容可以轻松访问。在之前的例子中,我们使用了 new-namespace/fruits 从 new-namespace 外部的不同命名空间访问 fruits 向量。refer 允许我们一次性引用 new-namespace,然后根据需要多次使用 fruits,而不必每次都指定完整的命名空间。
在上一节中,我们使用了 in-ns 函数。在本节中,我们将使用 refer 函数。虽然这两个函数都帮助我们处理命名空间,但我们使用它们的目的不同。in-ns 函数为我们的代码创建一个作用域。我们在命名空间内放置数据和函数。当我们想要创建一个新的命名空间以及相应的代码作用域时,我们使用 in-ns。现在,另一方面,refer 将允许我们在当前命名空间的作用域内工作,并从不同的命名空间导入数据。我们可以导入一个或多个命名空间,同时仍然在一个命名空间的作用域内工作。
练习 8.03:使用 refer 函数导入命名空间
在这个练习中,我们将使用 refer 函数导入 Clojure 命名空间。这将帮助我们理解 refer 函数的使用方法。
使用 refer 允许我们引用来自其他命名空间的功能或对象:
-
在 REPL 中,输入以下命令以使用
refer函数导入新的命名空间:(clojure.core/refer 'new-namespace)我们在这里使用
refer导入new-namespace的数据。如果我们使用了in-ns函数,我们就会改变代码的作用域。我们将能够访问new-namespace的数据,但由于作用域发生了变化,我们将失去对之前切换到new-namespace之前工作的other-namespace的访问权限。我们的目标是编写在other-namespace作用域内的代码,并且只访问new-namespace的功能。 -
之后,我们可以直接在 REPL 中调用
fruits向量来使用它:fruits输出如下:
["orange" "apple" "melon"]
使用 refer 允许我们将指定命名空间中的所有对象包含到当前命名空间中。refer 函数允许我们使用可选关键字来控制导入命名空间。我们现在将看到它们的作用。
refer 函数的高级使用
在 第二章,数据类型和不可变性 中,我们学习了 Clojure 中的关键字。我们之前章节中学到的 refer 函数的基本用法可以通过关键字进行修改或扩展。它们是可选的,因为我们可以使用它们,但不必使用。
我们可以与 refer 一起使用的关键字是:
-
:only::only关键字允许我们只导入我们指定的函数。这意味着任何未指定的函数都不会被导入。 -
:exclude::exclude关键字允许我们排除某些函数的导入。我们将导入除了我们想要排除的函数之外的所有函数。 -
:rename::rename关键字允许我们重命名导入的函数。这设置了一个别名——一个函数的新名称——我们将使用这个新名称来引用该函数。
我们现在知道了三个可以修改使用 refer 函数导入命名空间的 refer 关键字。在接下来的三个练习中,我们将使用每个关键字导入命名空间并使用它们的数据。
练习 8.04:使用 :only 关键字
本练习的目的是展示我们如何通过使用 :only 关键字来扩展 refer 函数的基本用法。我们将使用带有 :only 关键字的 refer 函数导入命名空间。然后,我们将从导入的命名空间中访问数据:
-
在 REPL 中,输入以下命令以使用
in-ns函数创建garden命名空间:(in-ns 'garden)REPL 为我们创建了一个新的命名空间:
#object[clojure.lang.Namespace 0x6436be0 "garden"] -
我们可以在该命名空间中定义两个变量:
(def vegetables ["cucumber" "carrot"]) (def fruits ["orange" "apple" "melon"])REPL 通知我们已创建变量:
#'garden/vegetables #'garden/fruits -
然后,我们使用
in-ns函数切换到新的命名空间:(in-ns 'shop)注意
使用
:only关键字,我们可以引用另一个命名空间,但只导入选定的部分。 -
使用
refer函数和:only关键字导入garden命名空间:(clojure.core/refer 'garden :only '(vegetables))这将返回以下内容:
nil我们可以直接在新
shop命名空间中访问vegetables变量。 -
调用
vegetables变量以访问其内容:vegetablesREPL 返回预期的向量如下:
["cucumber" "carrot"] -
然而,如果我们想访问另一个变量
fruits,可以这样调用fruits向量:fruits在 REPL 中,我们遇到了异常:
CompilerException java.lang.RuntimeException: Unable to resolve symbol: fruits in this context, compiling:(null:0:0)因为当我们导入命名空间时,我们使用了
:only关键字来导入vegetables变量。如果我们想使用其他变量,则需要使用完整的命名空间名称。 -
使用完全限定名称调用
fruits向量:garden/fruits这次,我们得到了预期的向量如下:
["orange" "apple" "melon"]
在这个练习中,我们在一个命名空间中创建了变量,然后使用 refer 函数导入这个命名空间。在导入过程中,我们使用了 :only 关键字,这允许我们只导入选定的数据。
在下一个练习中,我们将使用refer函数和:exclude关键字导入一个命名空间。
练习 8.05:使用:exclude关键字
在上一个练习中,我们使用refer函数从一个命名空间导入到另一个命名空间的内容。我们使用:only关键字限制了导入,只导入所需的数据。现在我们将使用第二个关键字,它允许我们使用refer函数控制命名空间的导入。
使用:exclude关键字允许我们从命名空间中导入部分内容,但排除我们不需要的部分:
-
首先,我们将使用
in-ns函数切换到新的命名空间:(in-ns 'market)REPL 告诉我们我们成功切换到了新的命名空间:
#object[clojure.lang.Namespace 0x177c36c4 "market"] -
下一步是导入
garden命名空间,但排除vegetables变量。我们使用带有:exclude关键字的refer函数:(clojure.core/refer 'garden :exclude '(vegetables))这将返回以下内容:
nil -
我们通过尝试访问
fruits变量来测试导入:fruits到目前为止,一切顺利,因为 REPL 返回了向量:
["orange" "apple" "melon"] -
我们将尝试访问被排除的变量
vegetables:vegetables我们立即在 REPL 中看到了一个异常消息:
CompilerException java.lang.RuntimeException: Unable to resolve symbol: vegetables in this context, compiling:(null:0:0) -
我们需要使用完全限定名来访问
vegetables变量:garden/vegetables这次,REPL 显示了预期的向量:
["cucumber" "carrot"]
在这个练习中,我们使用refer函数导入了一个命名空间。在导入过程中,我们使用了:exclude关键字,这允许我们限制要导入的数据。
在下一个练习中,我们将使用refer函数和:rename关键字导入一个命名空间。
练习 8.06:使用:rename关键字
在上一个练习中,我们使用refer函数从一个命名空间导入到另一个命名空间的内容。我们使用:exclude关键字限制了导入,只导入我们想要的数据。现在我们将使用第三个关键字,它允许我们使用refer函数控制命名空间的导入。
我们将看到:rename关键字的用法。它允许我们从另一个命名空间导入并重命名某些符号:
-
我们将使用
in-ns函数切换到新的命名空间:(in-ns 'shops) -
当我们导入
garden命名空间时,我们希望将fruits变量重命名为owoce(波兰语中的水果)。我们将使用带有:rename关键字的refer函数:(clojure.core/refer 'garden :rename '{fruits owoce}) -
我们在 REPL 中访问
vegetables变量:vegetables这在 REPL 中返回了向量:
["cucumber" "carrot"] -
尝试访问
fruits变量:fruits它告诉我们它不可访问:
CompilerException java.lang.RuntimeException: Unable to resolve symbol: fruits in this context, compiling:(null:0:0)因为我们将
fruits变量重命名了,所以我们需要使用我们使用refer函数时定义的新名称来访问fruits。 -
现在在 REPL 中输入
owoce:owoce这次,我们得到了预期的向量:
["orange" "apple" "melon"]
在这个练习中,我们使用refer函数导入了一个命名空间。在导入过程中,我们使用了:rename关键字,这允许我们限制应该导入的数据。
现在我们知道如何使用refer。首先,我们使用refer函数导入了一个命名空间。然后我们看到了如何使用三个关键字(:only、:exclude和:rename)来修改使用refer函数时的导入。
当我们想要从一个命名空间导入到另一个命名空间的某些函数时,我们使用 :only 关键字。:only 关键字允许我们限制导入的函数。
当我们想要从一个命名空间导入到另一个命名空间,但不导入某些函数时,我们使用 :exclude 关键字。:only 关键字允许我们排除我们不想导入的函数。
当我们想要从一个命名空间导入到另一个命名空间并更改导入过程中某些函数的名称时,我们使用 :rename 关键字。
在下一节中,我们将学习 require 和 use 如何帮助我们管理命名空间。
使用 require 和 use 导入 Clojure 函数
在上一节中,我们学习了如何使用 refer 导入 Clojure 函数。在本节中,我们将学习如何使用 require 和 use 导入 Clojure 函数。
虽然 refer 允许我们直接引用其他命名空间的变量而不需要完全限定它们,但通常我们需要的不仅仅是这样。在之前的练习中,我们导入了一个命名空间,并从中访问了如 fruits 这样的变量,而没有使用命名空间名称作为 garden/fruits 变量的前缀。通常,我们想要从一个命名空间加载函数并使用这些函数。如果我们想读取文件,我们需要从 Clojure I/O 库(用于输入输出操作,如读取和写入文件的库)导入代码。
使用 require 函数,我们将加载一个我们将指定的命名空间。这样,加载命名空间中的函数就可用在我们的命名空间中使用了。这是一种编写 Clojure 代码、重用现有函数并在我们的代码中使它们可用的好方法。尽管我们使用 require 加载了新函数,但我们仍然需要在使用时完全限定它们。
虽然 require 函数允许我们加载指定的命名空间,但 use 函数更进一步,隐式地使用 refer 允许代码引用其他命名空间的变量,而无需完全限定它们。
refer、require 和 use 都有不同的用途:
-
refer允许我们从不同的命名空间调用函数(函数不是导入的)。 -
require从不同的命名空间导入函数,但在使用时我们必须对它们进行限定。 -
use加载来自不同命名空间的函数,我们不需要对它们进行限定。
练习 8.07:使用 require 和 use 导入 Clojure 函数
在这个练习中,我们将学习 require 和 use 函数如何帮助我们导入 Clojure 命名空间。我们将使用这两种方法导入 Clojure 命名空间。这将帮助我们理解两种方法之间的区别。
通过使用 require,我们确保在需要使用时,提供的命名空间总是完全加载的。
Clojure 提供了一系列命名空间,例如clojure.edn或clojure.pprint,这些命名空间帮助开发者创建程序。EDN 代表可扩展数据表示法。它是一个表示对象的系统。它提供了一系列丰富的功能,例如指定日期和时间的功能。clojure.edn命名空间允许我们使用edn格式。想象一下,你想要将一个日期从一个程序发送到另一个程序。如果你将日期作为字符串发送,例如“Monday 7.10.2019”,那么没有关于时区的信息。接收这个日期字符串的程序不知道这个时间是在伦敦还是纽约。使用edn,我们可以发送包含时区信息的日期对象。
clojure.print命名空间包含一些实用函数,这些函数有助于以易于理解和阅读的格式从程序中打印数据。
考虑打印如图所示的哈希:

图 8.3:打印哈希
与图 8.3中显示的打印哈希不同,clojure.pprint命名空间中的函数允许我们以这种方式打印哈希:

图 8.4:使用命名空间中的函数打印哈希
默认情况下,当我们启动一个新的会话时,只有clojure.core可用。clojure.core命名空间包含主要的 Clojure 函数,如filter、map、reduce和count。这些是在 Clojure 中工作时经常使用的核心函数。这就是为什么它们在 Clojure 中默认可用。其他命名空间需要我们添加:
-
我们将在 REPL 会话中导入一个新的命名空间,这将帮助我们美化打印一些内容:
(require 'clojure.pprint) -
我们现在可以使用这个命名空间中的函数。我们调用
print-table函数在 REPL 中打印表格:(clojure.pprint/print-table [{:text "Clojure"}{:text "is"}{:text "fun"}])这将在 REPL 中打印一个表格:
![图 8.5:在 REPL 中打印表格]()
图 8.5:在 REPL 中打印表格
使用完全限定名称可能会变得繁琐,因为每次我们想要调用任何函数时,我们都必须提供其完整名称,包括命名空间。这导致代码冗长,并且命名空间名称重复很多。幸运的是,Clojure 允许我们为命名空间设置别名。为了设置别名,我们使用
:as关键字。使用:as关键字,我们可以缩短调用函数的方式。我们不需要写出完整的命名空间,只需简单地使用我们选择的别名。 -
使用
:as关键字调用require函数以简化导入函数:(require '[clojure.pprint :as pprint]) -
现在我们可以使用别名来调用
print-table函数:(pprint/print-table [{:text "Clojure"}{:text "is"}{:text "fun"}])我们刚刚看到了
require函数的使用方法。接下来,我们将看到use函数如何帮助我们导入命名空间。 -
我们调用
use函数在 REPL 中导入命名空间:(use 'clojure.pprint)前面的语句将加载
clojure.pprint命名空间并引用该命名空间。我们可以不使用完全限定名称来使用这个命名空间中的函数。
-
在没有命名空间名称的情况下调用
print-table函数以打印表格:(print-table [{:text "Clojure"}{:text "is"}{:text "fun"}])这将为我们打印一个表格:
![图 8.6:调用 print-table 函数]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_08_07.jpg)
图 8.6:调用 print-table 函数
我们刚刚看到了
use的工作方式。接下来,我们将探讨使用use中的关键字,如:only和:rename。 -
从
clojure.string命名空间导入一个函数:(use '[clojure.string :only [split]])这将导入
string命名空间中的split函数并返回以下内容:nil我们可以在没有命名空间名称的情况下使用
split函数:![图 8.7:使用 split 函数]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_08_07.jpg)
图 8.7:使用 split 函数
-
当使用
use导入时,重命名clojure.edn命名空间中的函数:(use '[clojure.edn :rename {read-string string-read}])这将返回以下内容:
nil -
我们已将
clojure.end命名空间中的read-string函数重命名为string-read。现在我们可以没有命名空间名称地调用string-read函数:(class (string-read "#inst \"1989-02-06T13:20:50.52Z\""))输出如下:
java.util.Date我们有一个表示 1989 年的日期的字符串。我们将这个字符串传递给
edn函数,该函数将字符串转换为Date对象。当我们调用class函数时,它告诉我们我们有一个Date对象。
我们已经看到了如何使用 refer 和 use 导入命名空间。在接下来的活动中,我们将将这些知识付诸实践。
活动 8.01:在应用程序中更改用户列表
在这个活动中,我们将应用关于导入命名空间的知识来解决一个实际问题。想象一下,我们在一家 IT 公司工作,我们负责设计后端应用程序。我们后端中的一个函数返回一个用户列表。新的前端功能需要以不同的格式获取这个列表。这个活动的目的是更改用户列表。
需要进行的两个更改是:
-
大写用户名
-
检查 John、Paul 和 Katie 是否属于管理员组
应用程序当前显示用户列表、他们的头衔、他们的名字和姓氏。头衔和名字之间用下划线(_)分隔。在这个活动中,我们将在头衔和名字之间添加一个空格。然后,我们将名字和姓氏的首字母大写。最后,我们将检查我们的用户是否属于管理员组。
这些步骤将帮助您完成活动:
-
使用
use和:rename关键字导入clojure.string命名空间,并为replace和reverse函数指定。 -
创建一组用户。
-
替换头衔和名字之间的下划线。
-
使用
capitalize函数将用户组中每个人的首字母大写。 -
使用字符串的
replace和capitalize函数更新用户列表。 -
仅从
clojure.pprint命名空间导入print-table函数。 -
打印包含用户的表格。
-
导入
clojure.set命名空间,但不包括join函数。 -
创建一组管理员。
-
在两个用户和管理员集合上调用
subset?函数。
初始的用户和管理员列表将如下所示:
#{"mr_john blake" "miss_paul smith" "dr_katie hudson"}
管理员列表将如下所示:
#{"Mr Paul Smith" "Dr Mike Rose" "Miss Katie Hudson" "Mrs Tracy Ford"}
最终的用户列表将如下所示:

图 8.8:预期结果
注意
此活动的解决方案可以在第 706 页找到。
当你想使用(use)时与当你需要(require)时
虽然乍一看use和require可能非常相似,但通过一些实践,你将理解何时使用哪一个。
如果我们想使用require导入命名空间,我们将如下调用:
(require 'clojure.pprint)
使用use导入命名空间的方式如下:
(use 'clojure.pprint)
如果我们比较这两个语句,我们会看到唯一的区别是导入函数,要么是require要么是use。它们的语法是相同的。我们调用一个函数然后是我们要导入的命名空间。尽管如此,使用这两个导入函数的原因是不同的,正如我们将在本主题中看到的那样。
当有疑问时,选择require。它允许你为命名空间添加别名,使代码比使用完全限定名称更易于阅读:
(require '[clojure.string :as str])
这样,我们可以非常容易地调用函数。考虑以下示例:
(str/reverse "palindrome")
当选择use时,建议添加别名并只导入所需的函数:
(use '[clojure.string :as str :only (split)])
当你只导入所需的函数时,你可以很容易地稍后添加更多。只导入我们目前所需的函数有助于我们维护代码。处理代码的开发者不会花费时间搜索我们导入的函数的使用情况。
在不使用:only关键字的情况下使用use可能存在两个潜在问题:
-
检查代码并不能快速告诉我们某个函数来自哪个命名空间。通过使用别名的命名空间,我们可以更容易地建立这一点。
-
如果我们添加一个新的库或者现有的库引入了一个我们已经在使用的同名新函数,我们避免未来出现任何名称冲突。
现在我们已经了解了命名空间,我们将提升一个层次,研究如何使用 Leiningen 来构建项目。
Leiningen——Clojure 中的构建工具
使用命名空间,我们将函数放入文件并将相关函数分组在一起。这有助于将代码分离成单元。考虑一种情况,其中实用函数与前端函数分离。这有助于我们导航代码并找到函数。我们知道负责创建 HTML 的前端函数不会在负责连接数据库的后端命名空间中。构建工具服务于不同的目的。正如其名所示,这些是帮助我们构建的工具。使用它们,我们可以自动化可执行应用程序的创建。另一种选择是自行编译所有代码并将其放在服务器上。即使在只有少量功能的程序中,我们也存在忘记编译命名空间的风险。命名空间越多,应用程序越复杂,手动代码编译出错的风险就越大。构建工具编译我们的代码并将其打包成可用的形式。开发者指定需要编译的应用程序部分,构建工具会自动为他们编译这些部分。这有助于最小化编译错误。一些构建工具有 Maven、Gradle、Webpack 和 Grunt。
Leiningen 是 Clojure 社区中非常流行的构建工具。其流行度来自于它丰富的功能集。它提供了许多模板,允许开发者在不进行太多项目设置的情况下开始编写代码。通常,开发者想要创建的应用程序类型可能已经被其他人创建。这样,我们可以重用我们之前开发者的成果,而不是自己编写大量代码。我们有一个提供网页常见结构的 Web 应用程序模板。有一个为后端服务器提供的模板,其文件结构和配置在后端服务器中很常见。在下一个练习中,我们将创建一个新的项目。
练习 8.08:创建 Leiningen 项目
本练习的目的是了解 Leiningen 项目的标准结构。我们将基于 Leiningen 的模板创建一个示例项目。这将使我们能够探索 Leiningen 创建的文件以及每个文件的目的。
为了创建一个新的 Leiningen 项目,我们将使用命令行:
-
使用
app模板调用一个新的lein任务。要创建一个新项目,执行以下命令:lein new app hello-leiningen在上述命令中,
lein命令接受三个参数:new:一个lein任务,告诉 Leiningen 要执行哪种类型的任务。任务new将基于模板创建项目。app:创建项目时使用的模板名称。Leiningen 将使用指定的模板创建项目。hello-leiningen:项目的名称。我们应该看到我们的新项目已经创建:
Generating a project called hello-leiningen based on the 'app' template. -
创建项目后,我们将导航到项目的目录:
cd hello-leiningen -
现在,按照以下方式检查项目:
find .我们将看到 Leiningen 已经为我们创建了一些文件:
![图 8.9:检查项目]()
图 8.9:检查项目
这里有一些需要注意的重要事项:
-
我们有一个源目录,
src,我们将在这里放置我们的代码。 -
project.clj文件包含我们项目的描述。 -
README.md是一个入口点,其中包含有关我们应用程序的信息。 -
我们有一个用于测试的
test目录。
我们将在以下章节中更详细地探讨这些点。测试将在第十章,测试中介绍。在下一节中,我们将查看project.clj文件。
调查project.clj
你创建的project.clj文件将看起来类似于这个:

图 8.10:project.clj文件
让我们看看每个参数:
-
hello-leiningen项目有一个快照版本。这意味着它还不是经过稳定生产测试的版本,而是一个开发版本。当你准备好发布你的项目时,请确保添加一个适当的版本。一个好的指南是使用语义版本控制。有时使用快照版本是必要的,比如在修复被包含在下一个版本之前进行错误修复。一般规则是除非需要错误修复,否则使用稳定版本。 -
描述:添加描述是让想要了解项目目的的人的好起点。此外,当项目在 Maven 或 Clojars 等项目仓库中发布时,更容易搜索到项目。
-
url参数允许我们为项目放置一个网页 URL。在我们的网页上,我们可以添加更多关于我们项目的信息。大多数项目网站都会有:理由:为什么创建项目
文档:对其用法的描述
指南:使用项目的示例
-
许可证:许可证的使用方式可能正如你所预期的那样。它是一种规范软件使用的法律工具。本质上,它是在软件所有者和用户之间的一项协议,允许用户在特定条件下使用软件。
软件许可证有很多种类型。许可证的例子包括:
MIT 许可证:这允许分发和修改源代码。
Apache 许可证 2.0:与 MIT 许可证类似,这允许分发和修改代码,但要求保留版权声明。
GNU AGPLv3:与 MIT 许可证类似,这允许分发和修改代码,但要求说明与软件原始版本相比的更改。
-
jar文件。它们基本上是带有一些项目元数据的zip文件(压缩文件)。当我们指定project.clj中的依赖项时,Leiningen 将在本地仓库中搜索。如果依赖项尚未存储在本地,那么它将在 Maven 和 Clojars 网站上搜索并下载依赖项。这些依赖项随后将可用于我们的项目。 -
使用
:main关键字,我们指定了项目和应用入口点的命名空间。当我们运行项目时,它会被调用。 -
使用
:profiles关键字,我们有一个uberjar配置文件,我们希望在 AOT 编译。另一方面,我们希望:main命名空间不进行 AOT 编译。我们创建一个带有 AOT 的 uberjar,因为我们希望在运行之前编译代码。我们不希望:main进行 AOT 编译,因为我们希望在启动应用程序时再进行编译。例如,:main可以使用环境设置、在 AOT 编译时不可用的参数等符号。它们只有在启动应用程序时才可用。如果我们编译得太快,应用程序将无法访问我们在启动应用程序时传递的参数。 -
配置文件:Leiningen 允许我们在项目中设置各种配置文件。多亏了配置文件,我们可以根据我们的需求定制项目。
例如,开发版本可能需要测试套件,我们可能需要测试依赖项。另一方面,在创建生产 jar 时,我们不需要测试依赖项。
我们将在本章的结尾查看 Leiningen 的配置文件。
现在我们已经查看了 project.clj,我们将看看 README.md 文件能为我们提供什么。
README 文件是一个 Markdown 文件,它提供了我们认为用户需要了解的关于我们项目的相关信息。Markdown 是一种标记语言,允许我们格式化文档。
通常,README 文件将包含以下部分:
-
项目名称:在这里我们放置一个关于项目做什么的简短描述
-
安装:在这里我们告知用户安装应用程序所需的步骤
-
用法:在这里我们告知用户如何使用我们的项目
-
示例:一个包含代码示例的部分,展示如何使用我们的项目
-
错误:任何已知的错误
-
变更日志:在这里我们记录版本之间的任何更改
-
许可证:在这里我们告知用户我们项目的许可证类型
下面是一个示例 README 文件:

图 8.11:示例 README 文件
您可以添加更多部分。这完全取决于您认为用户需要了解哪些重要信息。在下一个主题中,我们将修改源文件并运行我们的应用程序。
练习 8.09:在命令行上执行应用程序
本练习的目的是创建一个 Leiningen 应用程序并探索应用程序的运行方式。
这将帮助我们理解 Leiningen 在 project.clj 文件中提供的不同选项。
正如我们在本练习的结尾将要看到的,为了从命令行运行我们的应用程序,我们需要调用 Leiningen 的 run 任务。Leiningen 的 run 任务将会在 project.clj 文件中搜索 :main 关键字及其对应的命名空间。
在我们的例子中,project.clj 中的 :main 关键字将看起来像这样:
:main ^:skip-aot hello-leiningen.core
^:skip-aot 指示 Leiningen 跳过我们指定的命名空间的 AOT。在这里,命名空间是 hello-leiningen.core。当我们探索 project.clj 文件时,我们讨论了为什么我们想要跳过 :main 命名空间的 AOT。
默认情况下,Leiningen 将搜索我们在 :main 关键字中指定的命名空间。在我们的例子中,它将搜索 hello-leiningen.core 命名空间。在这个命名空间中,如果我们有 -main 函数,它将被调用:
-
创建新项目后,
hello-leiningen.core命名空间的内容如下:(ns hello-leiningen.core (:gen-class)) (defn -main "I don't do a whole lot ... yet." [& args] (println "Hello, World!"))当我们使用 Leiningen 创建应用程序时,它将在
core命名空间中自动生成代码。(:gen-class)指示 Leiningen 从命名空间生成一个 Java 类。构建工具(如 Leiningen)执行 Java 字节码,因此我们需要将 Clojure 编译成字节码才能运行core命名空间。接下来,我们有
-main函数。默认情况下,当应用程序启动时,Leiningen 将搜索具有该名称的方法并执行它。因此,-main是我们应用程序的入口点。与 Clojure 中的所有函数一样,
-main可以提供一个可选的文档字符串。在这里,它告诉我们该函数目前还没有做很多事情。此函数接受可选参数。我们可以在启动应用程序时传递参数。通常,通过传递环境类型(如测试或生产)作为命令行参数来启动各种环境的应用程序。当 Leiningen 调用
-main函数时,它将执行该函数的主体。在这种情况下,该函数将字符串Hello World!打印到控制台。 -
要从命令行运行应用程序,我们使用 Leiningen 的
run任务:lein run这将在控制台打印以下内容:
Hello, World!
这个练习展示了如何运行在 project.clj 文件中定义的 Leiningen 应用程序。
在下一个练习中,我们将扩展应用程序以从命令行获取参数。
练习 8.10:使用参数在命令行上执行应用程序
在这个练习中,我们将编写一个小型的命令行应用程序,该程序接受一个字符串作为输入,解析该输入并替换该字符串的内容。
-
创建新项目后,
hello-leiningen.core命名空间的内容如下:(ns hello-leiningen.core (:gen-class)) (defn -main "I don't do a whole lot ... yet." [& args] (println "Hello, World!")) -
当我们使用 Leiningen 创建应用程序时,它将在
core命名空间中自动生成代码。(:gen-class)指示 Leiningen 从命名空间生成一个 Java 类。构建工具(如 Leiningen)执行 Java 字节码,因此我们需要将 Clojure 编译成字节码才能运行core命名空间。 -
接下来,我们有
-main函数。默认情况下,当应用程序启动时,Leiningen 将搜索具有该名称的方法并执行它。因此,-main是我们应用程序的入口点。 -
与 Clojure 中的所有函数一样,
-main可以提供一个可选的文档字符串。在这里,它告诉我们该函数目前还没有做很多……。此函数接受可选参数。我们可以在启动应用程序时传递参数。通常,应用程序通过传递环境类型(如测试或生产)作为命令行参数来为各种环境启动。 -
当 Leiningen 调用
-main函数时,它将执行此函数的主体。在这种情况下,该函数将打印字符串 "Hello World!" 到控制台。 -
要从命令行运行应用程序,我们使用 Leiningen 的运行任务:
lein run -
这将在控制台打印以下内容:
Hello, World! -
导入
clojure.string。我们想在-main函数中操作字符串。为了做到这一点,我们需要导入字符串命名空间。(ns hello-leiningen.core (:require [clojure.string :as str]))在导入
clojure.string命名空间后,我们可以使用该命名空间中的函数。 -
更新
-main函数以在运行-main函数时替换某些单词:(defn -main "I don't do a whole lot ... yet." [& args] (-> (str/join " " args) (str/replace "melon" "banana") (str/replace "apple" "orange") println))此代码将用其他水果替换单词 "melon" 和 "apple"。
-
我们按照以下方式运行应用程序:
lein run "apple" "melon" "grapes" -
这将在 REPL 中打印以下内容:
orange banana grapes
我们现在知道如何从命令行运行应用程序并传递参数。在以下活动中,你将使用这些知识来创建一个新的应用程序。
活动 8.02:求和数字
通常,开发者创建的应用程序需要在不同的环境和业务场景中运行。这要求应用程序具有灵活性。实现这一目标的一种方法是通过使用命令行参数来改变应用程序的行为。
在这个活动中,你将创建一个应用程序,该应用程序从命令行接收整数作为输入参数,并将它们相加以将结果打印到控制台。根据传递的整数,结果将不同。这显示了应用程序的灵活性。
这些步骤将帮助你完成活动:
-
创建一个 Leiningen 应用程序。
-
修改
-main函数以将字符串参数转换为整数,相加并打印结果。
输出将如下所示:

图 8.12:预期输出
注意
本活动的解决方案可以在第 708 页找到。
与外部库一起工作
库是打包好的程序,可以用于其他项目。外部库是从其他开发者那里来的库。在 Clojure 中,此类库的例子包括 Ring,一个 HTTP 库;clojure.java-time,一个用于时间和日期操作的库;以及 hiccup,一个用于使用 Clojure 风格语法编写 HTML 代码的库。
大多数项目都需要开发者使用现有代码打包成的库。这是好事。我们不希望重复编写代码,如果问题已经解决,并且有人为它创建了一个我们可以使用的库。
在本节中,我们将使用 clojure.java-time 库来显示当前时间。
练习 8.11:在 Leiningen 项目中使用外部库
本练习的目的是向你展示如何向 Leiningen 项目添加库,并演示如何在代码中使用这个库:
-
首先要做的事情是在
project.clj文件中添加对time库的依赖。dependencies部分应该看起来像这样::dependencies [[org.clojure/clojure "1.9.0"] [clojure.java-time "0.3.2"]] -
下一步是将库导入到我们的
core命名空间。将hello-leiningen.core修改如下:(ns hello-leiningen.core (:require [java-time :as time])) -
最后,我们将把
-main函数改为使用clojure.java-time库中的函数来打印本地时间:(defn -main "Display current local time" [& args] (println (time/local-time))) -
我们使用
run任务从命令行运行应用程序:lein run这将给出类似于以下输出的结果:
#object[java.time.LocalTime 0x2fa47368 23:37:55.623]
我们现在已经知道如何添加和使用外部库。我们现在可以打包我们的应用程序到一个 jar 文件中并运行它。
使用 Leiningen 创建和执行 jar 文件
当我们讨论 Clojure 的项目结构时,我们提到项目被打包成 jar 文件。为了提醒你,jar 文件是一个压缩(打包)的文件,是一个可执行的应用程序。Leiningen 有创建 jar 文件的任务。
Leiningen 提供了两个可以创建 jar 文件的任务:
-
jar
-
uberjar
这两个都会创建一个包含我们代码的压缩文件。区别在于,jar 任务只会打包我们的代码,而 uberjar 任务还会打包依赖项。如果你想在服务器上运行一个应用程序并且想要一个独立的文件,那么 uberjar 是你的首选选项。如果你在服务器上有多个应用程序并且它们共享库,那么将每个应用程序打包成 jar 文件在总体上比打包成 uberjars 占用更少的空间。这是因为库在服务器上被你的应用程序共享。
如果我们想运行一个 jar,我们需要从我们的核心命名空间生成一个命名的类。
练习 8.12:创建 Jar 文件
在这个练习中,我们将展示如何使用 Leiningen 任务创建 jar 文件:
-
将
hello-leiningen.core命名空间声明修改为包含一个(:gen-class)函数调用:(ns hello-leiningen.core (:require [java-time :as time]) (:gen-class)):gen-class指令是 Clojure 中的一个重要概念。这个指令将生成一个与目标命名空间对应的 Java 类。生成 Java 类的结果是一个.class文件。Java.class文件包含可以在 JVM 上执行的 Java 字节码。这样的文件可以通过 Leiningen 等构建工具执行。运行 jar 和 uberjar 的 Leiningen 任务是相同的。在我们的情况下,我们将创建一个 uberjar。
-
在命令行中调用 Leiningen 的
uberjar任务:lein uberjar这个任务将在目标目录内创建
hello-leiningen-0.1.0-SNAPSHOT.jar和hello-leiningen-0.1.0-SNAPSHOT-standalone.jarjar 文件。当我们比较文件时,我们会看到它们的尺寸差异很大:
![图 8.13:比较文件]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_08_13.jpg)
jar -tvf target/uberjar/hello-leiningen-0.1.0-SNAPSHOT-standalone.jar运行这个命令将给出类似于以下输出的结果:
![图 8.14:检查文件的独立版本]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_08_14.jpg)
图 8.14:检查文件的独立版本
我们会注意到一个
clojure目录。然而,当我们检查第一个文件时,却找不到 Clojure 目录:![图 8.15:检查 Clojure 目录]
![图片 B14502_08_15.jpg]
图 8.15:检查 Clojure 目录
第一个 jar 文件(
hello-leiningen-0.1.0-SNAPSHOT.jar)仅包含应用程序代码,而第二个文件(hello-leiningen-0.1.0-SNAPSHOT-standalone.jar)还包含核心 Clojure 代码。独立文件旨在在我们只有一个 Clojure 应用程序时使用。在这种情况下,我们希望所有依赖项都在一个地方。当我们在一个服务器上运行多个 Clojure 应用程序时,每个应用程序都包含核心 Clojure 函数会占用比所需更多的空间。在这种情况下,将核心 Clojure 代码只保留一次,并允许所有应用程序访问它,可以节省磁盘空间。 -
为了运行一个 jar 文件,我们将调用以下命令:
java -jar target/uberjar/hello-leiningen-0.1.0-SNAPSHOT-standalone.jar这将显示当前的本地时间,如下所示:
![图 8.16:打印本地时间]
![图片 B14502_08_16.jpg]
图 8.16:打印本地时间
在下一节中,我们将探讨 Leiningen 配置文件——这是一个强大的功能,允许我们自定义我们的项目。
Leiningen 配置文件
配置文件是 Leiningen 的一个工具,允许我们更改项目的配置。配置文件是一个影响项目行为的规范。例如,在开发或测试期间,如果我们希望在构建中包含测试框架,但生产构建不需要测试依赖项,使用配置文件是分离不同开发设置并针对同一代码库运行的一个很好的方法。
Leiningen 允许我们在需要的地方定义配置文件:
-
在
project.clj文件中 -
在
profiles.clj文件中 -
在
~/.lein/profiles.clj文件中
在project.clj中定义的 Leiningen 配置文件仅针对该特定项目。这些配置文件不会影响其他项目。这允许项目之间分离,并能够独立地自定义它们。我们可能有一个使用 Clojure 最新版本并需要不同库的应用程序,而另一个应用程序则依赖于较旧的 Clojure 版本。
在profiles.clj中定义的 Leiningen 配置文件也仅针对特定项目。这些配置文件不会影响其他项目。将配置文件放在project.clj和profiles.clj中的区别在于,project.clj中的配置文件会被提交到版本控制中。在profiles.clj中定义的配置文件独立于project.clj中的项目配置,并且不需要提交到版本控制。来自两个文件的相同名称的配置文件,profiles.clj中的配置文件将优先于project.clj中的配置文件。
现在我们已经了解了 Leiningen 配置文件是什么以及它们可以定义的位置,我们将探讨创建配置文件的语法。首先,我们将在project.clj文件中定义一个配置文件。
练习 8.13:将 Leiningen 配置文件添加到项目中
本练习的目的是在 project.clj 文件中添加一个新的开发配置文件。这将允许我们为软件开发生命周期的开发阶段定制项目。
如果我们想要添加对测试框架(如 expectations)的依赖项,我们将修改 project.clj 文件,使其看起来像这样:
(defproject hello-leiningen "0.1.0-SNAPSHOT"
;;; skipped irrelevant content
:profiles {:uberjar {:aot :all}
:dev {:dependencies [[expectations "2.1.10"]]}})
在 :profiles 哈希中,我们有一个 :dev 哈希,它依赖于 expectations 框架。通过此更改,dev 配置文件对我们可用。我们可以通过列出可用的配置文件来检查这一点:
-
调用 Leiningen 的
show-profiles任务将显示可用的配置文件:lein show-profiles输出如下:
![图 8.17:打印可用的配置文件]()
图 8.17:打印可用的配置文件
本例中可用的配置文件有:
base: 提供基本 REPL 功能的配置文件。debug: 当使用此配置文件运行 Leiningen 任务时,它们会在控制台打印更多信息,例如使用的依赖项名称。default: 当未选择任何配置文件时运行的默认配置文件。除非被覆盖,否则配置文件默认为leiningen/default配置文件。dev: 在project.clj文件中由开发者设置的开发配置文件。leiningen/default: 当未选择任何配置文件时运行的默认配置文件。leiningen/test: 运行测试文件的测试配置文件。offline: 使用离线存储的依赖项,而不下载新的依赖项的配置文件。uberjar: 创建 uberjar 文件的配置文件。update: 更新依赖项的配置文件。user: 为 Linux 用户定义的配置文件。whidbey/repl: 一个配置文件,其中在 REPL 中的结果以格式化的方式打印。如您所见,
dev配置文件列在其他配置文件中。 -
如果我们想要运行此配置文件,我们将调用
with-profiles任务:lein with-profile dev test调用此任务将使用
dev配置文件运行测试。在 第十章,测试 中,我们将探讨 Clojure 的测试,此任务将被经常使用。
在这个练习中,我们在 project.clj 文件中添加了一个新的 Leiningen 配置文件。这允许我们为特定项目设置配置。此配置独立于其他项目的配置。
在下一个练习中,我们将添加用户范围的配置文件。
用户范围配置文件
Leiningen 允许我们定义影响所有 Leiningen 项目的用户范围配置文件。这是一个放置我们希望所有项目都有的通用代码的好地方。最常见的情况包括包含用于测试或美化打印输出的库。一旦我们在用户范围配置文件中包含了一个测试库,该库就可以用于为所有项目编写测试。这也使我们升级库时受益。我们只需要在一个地方升级其版本。
用户级别的配置文件允许我们添加希望在所有项目中包含的依赖项。其中一个这样的依赖项是 Ultra,它为在 REPL 中工作的开发者提供了诸如着色和格式化打印等功能。
练习 8.14:使用用户级配置文件
本练习的目的是向 profiles.clj 文件中添加一个新的库。这将使我们能够在所有 Leiningen 项目中访问添加的库,减少每次创建新项目时手动添加依赖项的需求。此外,如果库的新版本可用,我们只需要更新一个 profiles.clj 文件,更新将在所有我们的 Leiningen 项目中可用。
如果我们经常需要以用户可访问的方式显示输入,我们可以使用 humanize 库。这个库可以将名词复数化,以友好的方式显示日期,并将数字转换为字符串。
为了在所有项目中使用 humanize 库,我们在 ~/.lein/profiles.clj 文件中将它添加到 :user 关键字中:
{:user {:dependencies [[clojure-humanize "0.2.2"]]}}
很常见,你会在 profiles.clj 文件中定义更多的库和插件。它可能看起来像这样,包括 humanize 库和两个插件 ultra 和 localrepo:
{:user {:plugins [[lein-localrepo "0.5.4"]
[venantius/ultra "0.6.0"]]
:dependencies [[clojure-humanize "0.2.2"]]}}
-
使用 Leiningen 启动 REPL:
lein repl -
导入 humanize 库:
(require 'clojure.contrib.humanize)虽然我们在
project.clj文件中没有 humanize 库,但我们仍然可以导入这个库:![图 8.18:导入 humanize 库]()
图 8.18:导入 humanize 库
-
将数字转换为字符串的方法如下:
(clojure.contrib.humanize/numberword 4589)humanize 将传入的数字转换为字符串,如下所示:
"four thousand five hundred and eighty-nine" -
将毫秒数转换为时间值:
(clojure.contrib.humanize/duration 500)500 毫秒甚至不是一秒,因此输出将如下所示:
"less than a second"这就结束了我们对 Clojure 和 Leiningen 项目结构中命名空间的探索。在接下来的活动中,我们将使用我们在 Leiningen 中关于库和配置文件的新知识。
有用的 Clojure 库
Clojure 生态系统拥有许多优秀的库。正如我们在本章所学,使用库可以帮助我们创建 Clojure 应用程序。库提供了我们可以在代码中使用的功能。我们不需要自己编写代码,因为我们可以重用其他人编写的代码。这节省了我们的时间,并意味着我们可以专注于开发特定于我们应用程序的功能。Clojure 有许多库。Clojure 提供了一个中心位置来搜索 clojars.org 上可用的库。我们将了解两个有用的 Clojure 库,cheshire 和 lein-ancient。cheshire 库允许我们处理 JSON 格式。通过 cheshire 库,我们可以将 Clojure 数据转换为 JSON 格式,反之亦然。这是一个非常强大的功能。想象一下,我们有一个假日预订应用程序。在该应用程序中,我们希望显示当前的天气信息。有国家服务提供此类信息。天气服务允许以通用的 JSON 格式下载数据。当我们下载天气数据时,我们需要将 JSON 转换为 Clojure 数据。cheshire 库帮助我们进行数据转换。lein-ancient 是一个有用的 Leiningen 插件。此插件允许我们检查我们的 Leiningen 项目中的过时依赖项。通常,我们使用的库的新版本会被发布。新版本引入了错误或安全修复。使用更新的库有助于我们的应用程序保持安全。在以下活动中,我们将创建一个使用有用的 Clojure 库的 Leiningen 项目。
活动编号 8.03:构建格式转换应用程序
本活动的目的是创建一个 Leiningen 项目,该项目可以在 JSON 格式和 Clojure 数据之间进行转换。JSON 是一种流行的传输格式,常用于在各个后端应用程序之间发送数据。使用通用格式可以增加不同应用程序之间的互操作性,并降低构建和维护应用程序的开发成本。
与大多数用于生产的应用程序一样,我们的应用程序将具有开发和默认生产配置文件。我们将使用 Leiningen 配置文件来创建一个测试配置文件,该配置文件将使用测试库来确保我们的应用程序运行正确。
随着我们的应用程序成熟,所使用的库的新版本将被发布。我们将使用一个插件来通知我们是否我们使用的任何库已经过时。
完成此活动后,应用程序将具有以下功能:
-
在 JSON 和 Clojure 数据之间进行转换
-
用于在生产发布前检查代码的测试配置文件
-
检查过时库
这些步骤将帮助您完成活动:
-
将
cheshire"3.0.0" 库作为依赖项包含进来。 -
创建一个将哈希表转换为 JSON 的功能。
-
创建一个将 JSON 转换为哈希表的功能。
-
将
expectations库添加到为项目定义的测试配置文件中。 -
将
lein-expectations插件添加到项目中。 -
编写 JSON 函数的测试。
-
将
lein-ancient添加到用户全局配置文件中。
库的最新版本可以在clojars.org网站上找到。
创建应用程序并运行后,你应该会有以下类似的输出。
从哈希转换为 JSON 格式应返回以下内容:

图 8.19:从哈希转换为 JSON
从哈希生成 JSON 应返回以下内容:

图 8.20:从哈希生成 JSON
运行测试配置文件应返回以下内容:

图 8.21:执行测试配置文件
检查过时依赖项应返回以下内容:

图 8.22:检查过时的依赖项
注意
本活动的解决方案可以在第 709 页找到。
摘要
在本章中,我们学习了 Clojure 中的命名空间。命名空间是 Clojure 的关键概念。我们将代码组织到命名空间中。我们研究了使用refer、require和use导入命名空间的多种方法。对于每种导入选项,我们学习了导入函数的语法以及何时使用每种类型的函数。我们深入研究了:only、:exclude和:rename关键字,这些关键字帮助我们精细调整导入。
然后,我们学习了 Leiningen——一个流行的 Clojure 构建工具。我们创建了一个 Leiningen 应用程序,并探讨了 Clojure 项目的结构。我们添加了库依赖项。最后,我们看到了如何使用配置文件来自定义 Leiningen 项目。我们创建了一个应用程序,该应用程序接受命令行参数,这些参数被应用程序用来自定义输出。
在下一章中,我们将研究主机平台互操作性——从 Clojure 访问 Java 和 JavaScript。
第九章:9. 使用 Java 和 JavaScript 实现宿主平台互操作性
概述
在本章中,我们将探讨 Clojure 与 Java 和 JavaScript 之间的互操作性。Clojure 运行在 Java 和 JavaScript 提供的平台之上。Clojure 被设计为使用 Java 或 JavaScript 提供的库。我们将学习如何在 Clojure 中访问 Java 和 JavaScript 对象和方法。我们还将学习如何在 Clojure 和 Java 或 JavaScript 之间转换数据。在学会如何从 Clojure 访问 Java 和 JavaScript 之后,我们将研究如何使用 Java 类执行输入输出(I/O)操作,如读取和写入文件。然后我们将学习如何处理代码中的错误和异常。
到本章结束时,你将能够使用适当的语法和语义从 Clojure 访问 Java 和 JavaScript 对象和方法,并处理 Java 异常和 JavaScript 错误。
简介
在上一章中,我们学习了如何创建 Leiningen 项目。项目为我们代码的组织提供了一个结构。我们围绕命名空间来构建我们的项目。我们创建了新的命名空间,并导入外部 Clojure 库以便在代码中使用它们。
现在你已经熟悉了使用命名空间和创建 Clojure 项目,是时候更加熟悉使用 Java 和 JavaScript 的项目工作了。
如我们在第一章,“Hello REPL!”中学到的,Clojure 编译成 Java 字节码并在Java 虚拟机(JVM)上运行。JVM 是一个宿主平台。任何编译成 Java 字节码的编程语言都可以在 JVM 上运行。因为 Clojure 编译成 Java 字节码并在 JVM 上运行,所以我们称它为宿主语言。Java 始于 20 世纪 90 年代,现在是最受欢迎的后端语言之一。我们可以利用现有的 Java 库,而不是自己编写大量代码。这有助于我们更快地交付新功能。
正如我们将要看到的,在 Clojure 中导入 Java 类与使用 Clojure 库有所不同。在本章中,我们将通过编写一个执行 I/O 操作(从文件中读取和写入)的应用程序来学习如何在 Clojure 中导入和调用 Java 类。
在本章的第二部分,我们将探讨 ClojureScript 和 JavaScript 的互操作性。JavaScript 是一种在浏览器中运行的脚本语言。它是目前最受欢迎的前端语言。ClojureScript 编译成 JavaScript。在 ClojureScript 中,我们可以使用 JavaScript 库。这使我们能够访问其他开发者编写的大量代码,极大地提高了我们的生产力。
在 Clojure 中使用 Java
开发者编写的任何代码都需要转换为机器能理解的代码。解释器使用开发者的代码并将其编译成机器码。每个操作系统都是不同的,因此需要特定平台的编译器和解释器。Java 之所以如此成功,其中一个原因就是它提供了 JVM,它将人类可理解的代码转换为机器码。开发者通常对 JVM 不感兴趣。他们可以专注于用 Java 编写代码,而不必与底层操作系统交互。这项工作由 JVM 完成。
Clojure 是一种托管语言。这意味着它使用 JVM 而不是创建一个新的运行时环境。Clojure 巧妙地重用了 JVM 提供的设施。这是一个非常强大的方法。垃圾回收、线程、并发、IO 操作(所有这些将在以下段落中解释)都是 JVM 经过实战检验的技术,Clojure 依赖这些技术。
Java 垃圾回收是 Java 程序执行自动内存管理的过程。Java 程序编译成可以在 JVM 上运行的字节码。当 Java 程序在 JVM 上运行时,对象会在堆上创建,堆是程序专用的内存部分。最终,一些对象将不再需要。垃圾收集器找到这些未使用的对象并将它们删除以释放内存。在我们的程序中,我们可能有一个包含用户名的向量。用户名是字符串。每个字符串都会在内存中占用一些空间。当我们在一个页面上显示用户列表时,我们需要这个列表。当我们点击一个用户并显示她的个人资料时,我们不需要其他用户的信息。我们可以从内存中删除这些信息,以便内存可用于其他数据。
在并发计算中,多个计算和操作在重叠的时间段内执行。这与顺序编程形成对比,在顺序编程中,一个操作必须完成,其他操作才能开始。在顺序计算中,将用户名向量首字母大写的操作必须完成,第二个仅查找管理员用户的操作才能开始。在并发计算中,两个操作的执行是重叠的。我们不需要等待一个完成,然后才能开始第二个。
线程是在 JVM 上的轻量级进程。当计算程序使用两个或更多线程时,它们会并发运行。程序的一部分是一个独立的线程。在我们的用户名示例中,一个线程将过滤管理员的用户名,而另一个线程将首字母大写名字。
IO 操作是处理从源读取(例如键盘)和写入目标(例如显示器)的过程。Java 提供了对多个源和目标的支持。我们可以从文本文件中读取并写入打印机。Java IO 将在本章中介绍。
作为 Clojure 开发者,我们有权访问 JVM 库生态系统。由于 Java 是最受欢迎的语言之一,通过针对 JVM,我们加入了一个庞大的社区。这使我们能够使用许多经过良好测试和优化的库。
为了使用现有的 Java 代码,我们需要将其导入到我们的项目中。Clojure 为我们提供了导入和操作 Java 的工具。
在接下来的几节中,我们将探讨以下任务:
-
导入 Java 类
-
创建 Java 类的新实例
我们将开始导入 Java 类。Clojure 提供了一个 import 函数来完成这项工作。这个函数可以导入单个类或多个类。
练习 9.01:在 Clojure 中导入单个 Java 类
如我们在上一章所学,Clojure 代码组织到命名空间中。Java 将其代码组织到包含类的包中。在这个练习中,我们将学习如何导入包和 Java 类。
我们将使用 REPL 探索 Java 互操作性。
-
打开终端并添加以下命令以启动 REPL:
lein repl为了使用 Java 类,我们需要导入它。首先,我们将导入一个单个类。我们将从
BigDecimal类开始,它允许我们存储非常大的十进制数。 -
使用
BigDecimal类调用import函数:(import 'java.math.BigDecimal)这将让我们知道类已经被导入:
java.math.BigDecimal在 Java 中,我们使用
new关键字构造类的实例:BigDecimal big_number = new BigDecimal("100000");类似地,在 Clojure 中,我们可以从
BigDecimal类构造一个对象。 -
在
BigDecimal类上调用new函数以创建其实例:(new BigDecimal "100000")在 REPL 中执行前面的语句返回一个
BigDecimal值:100000M -
通常,我们希望多次使用一个值。将值存储在变量中是一个不错的选择:
(def big-num (new BigDecimal "100000"))在 REPL 中调用变量的实例将给出
BigDecimal中存储的值:![图 9.1:打印 BigDecimal 存储的值]
![img/B14502_09_01.jpg]
图 9.1:打印 BigDecimal 存储的值
Clojure 提供了一个特殊的简写符号,用于使用点
.构造类实例。我们在类名后放置一个点。在这些情况下,Clojure 假设我们想要构造类的实例。前面的
BigDecimal示例可以使用点符号编写。 -
使用点符号构造
BigDecimal实例:(def big-num (BigDecimal. "100000"))注意
BigDecimal类名后面的点:![图 9.2:打印 BigDecimal 值]
![img/B14502_09_02.jpg]
图 9.2:打印 BigDecimal 值
我们已经看到了如何导入一个类并创建其实例。非常常见的情况是,你想要导入多个类。在下一个练习中,我们将研究如何导入多个 Java 类。
Java 中的时间处理
Java 标准库提供了 java.time 和 java.util 包,其中包含用于处理时间的类。两个有用的类是:
-
Locale:表示特定地理区域的类,例如US(美国)、FR(法国)。 -
LocalTime:表示当前区域设置的类。东欧的本地时间比世界时(伦敦)快两小时。
在本章中,我们将看到在 Java 和 Clojure 中操作时间的示例。
练习 9.02:在 Clojure 中导入多个 Java 类
导入多个类可以分为两个动作:
-
从同一包中导入类
-
从不同包中导入类
为了从同一 Java 包中导入多个类,我们将使用向量导入:
-
使用向量调用
import函数,包含两个类:(import '[java.math BigDecimal BigInteger])一旦它们被导入,我们就可以像以前一样使用这两个类。
-
创建
BigInteger类的实例:(BigInteger. "10000")我们将在 REPL 中看到一个新创建的
BigInteger实例:10000 -
创建
BigDecimal类的实例:(BigDecimal. 100000.5)我们将在 REPL 中看到一个新创建的
BigInteger实例:100000.5M如果我们想要从不同的包中导入类,我们指定它们的完整名称,包括包名。
-
从
time包导入LocalTime类和从util包导入Locale类:(import 'java.time.LocalTime 'java.util.Locale)一旦它们被导入,我们就可以像以前一样构造这些类。
-
使用点表示法创建
Locale类的实例:(Locale. "pl")输出如下:
#object[java.util.Locale 0x50e7be4d "pl"]LocalTime类提供了获取当前时间的静态方法。静态方法是一个类方法。我们调用它时,是在类上而不是在我们创建的类的实例上。在 Java 中,当我们想要使用静态方法时,我们调用类名后跟方法名。静态的now方法返回当前时间:LocalTime time = LocalTime.now(); System.out.println(time);在这里,静态的
now方法是在LocalTime类上调用的。now方法是LocalTime类中的静态方法。now方法返回当前时间。时间格式为hour:minutes:seconds.miliseconds。由于我们可以在任何时间运行代码,所以每次执行代码的输出都会不同:![图 9.3:打印本地时间
![图片 B14502_09_03.jpg]()
图 9.3:打印本地时间
我们还可以在 Clojure 中访问
LocalTime类的静态方法。这是通过声明类后跟一个斜杠和方法名来完成的。 -
使用斜杠表示法从
LocalTime类调用静态的now方法:(LocalTime/now)输出类似于以下内容:
#object[java.time.LocalTime 0x355f5f59 "23:10:29.761"]一个 Java 类可以拥有静态字段——属于类而不是类的实例的字段。在 Java 中,我们使用点来访问类字段。
LocalTime类有与一天中的时间相对应的类字段:NOON字段表示中午,12:00,而MIDNIGHT字段表示午夜,00:00。为了获取
LocalTime的NOON字段,我们将编写以下内容:LocalTime.NOON这将给我们以下结果
![图 9.4:打印 NOON 值
![图片 B14502_09_04.jpg]()
图 9.4:打印 NOON 值
当我们运行代码时,我们正在访问
NOON字段。在输出中,我们看到中午是12:00。在 Clojure 中,我们使用斜杠形式,就像我们访问静态now方法时看到的那样。 -
使用斜杠符号从
LocalTime类访问静态MIDNIGHT字段:(LocalTime/MIDNIGHT)输出如下:
#object[java.time.LocalTime 0x2712e99d "00:00"]当我们运行代码时,我们正在访问
MIDNIGHT字段。在输出中,我们看到午夜是00:00。正如我们所看到的,使用斜杠访问静态字段和方法与点操作符使用相同的语法。
如果我们想访问实例方法,我们使用点操作符和函数名。在以下示例中,我们将使用
BigDecimal上的negate方法,该方法取反BigDecimal的值。 -
在
BigDecimal实例上调用negate函数:(.negate big-num)输出如下:
-100000M这是一个调用不带任何参数的函数的例子。在以下示例中,我们将看到如何调用接受参数的实例方法。
BigDecimal类提供了一个指数方法pow,该方法将基数提升到指定的幂。为了计算BigDecimal实例的指数乘积,我们将整数传递给幂方法。在 Java 中,我们使用
pow方法,如下一步所示。 -
首先,我们将创建一个
BigDecimal实例:BigDecimal big_num = new BigDecimal("100000"); -
然后,我们将调用
pow方法:big_num.pow(2);如果我们打印
pow方法的调用,我们会得到以下输出:10000000000在 Clojure 中,我们也可以使用
pow方法。 -
在
BigDecimal实例上调用pow方法:(.pow big-num 2)这给我们以下结果:
10000000000M
在这个练习中,我们从 Java 包中导入了多个类。我们还看到了如何调用静态和实例方法。这使我们能够导入和使用我们可能需要的任何 Java 类。
在下一个练习中,我们将了解宏,这些宏帮助我们使用 Clojure 中的 Java 类。Clojure 的语法非常简洁。用 Clojure 编写的代码比用 Java 编写的代码短。Java 中的一种常见模式是在类实例上调用多个方法。这些方法被链接在一起,每个方法都作用于前一个方法调用的结果。Clojure 提供了简化这种方法链的宏。我们将在第十一章 宏 中了解更多关于宏的内容。为了本章和了解 Java 互操作性的目的,我们可以将宏视为类固醇上的函数。我们迄今为止使用得很多的宏之一是 let。使用 let,我们可以创建一个局部变量:
(let [num 2]
(+ 3 num))
在这个例子中,let 允许我们定义一个值为 2 的变量 num。加法函数将 3 添加到我们的变量中。let 和 + 都是它们列表中的第一个元素。我们可以看到,let 宏和加法函数的使用方式相似。
Clojure 宏帮助我们简化代码。我们将在第十一章 宏 中深入了解宏。在此期间,我们将看看我们可以使用哪些宏来简化 Java 互操作性。
练习 9.03:帮助我们使用 Clojure 中的 Java 的宏
在这个练习中,我们将找出我们的时区与目标时区伦敦(UTC)之间的秒级差异。为了做到这一点,我们将使用 time 包中的两个 Java 类。我们将链式调用方法以获取结果。
ZonedDateTime 类包含特定时区中的日期和时间信息。如果我们生活在印度,这个类允许我们获取印度的当前日期和时间,而不是伦敦(UTC)的当前时间。
-
导入
ZonedDateTime类:(import 'java.time.ZonedDateTime) -
获取
ZonedDateTime的实例:(ZonedDateTime/now)输出如下:
#object[java.time.ZonedDateTime 0x1572c67a "2019-10-05T18:00:27.814+02:00[Europe/Warsaw]"]在这个例子中,时区位于华沙的中央欧洲。
-
使用
ZonedDateTime的getOffset方法。这将告诉我们我们的时区与 UTC 时区之间的时间差:(. (ZonedDateTime/now) getOffset)输出如下:
#object[java.time.ZoneOffset 0x362c5bf1 "+02:00"]请注意,前面的陈述可以写成以下形式:
(.getOffset (ZonedDateTime/now))这些语句是等效的。这是一个关于使用哪个的问题。大多数 Clojure 代码使用
.getOffset函数调用,尽管了解其他形式也是好的。中央欧洲和伦敦(UTC)之间的时间差是两小时(+2)。 -
现在我们知道了时区之间的时间差,我们可以以秒为单位获取这个值:
(. (. (ZonedDateTime/now) getOffset) getTotalSeconds)输出如下:
7200在输出中,我们看到中央欧洲和伦敦之间的时间差是 7,200 秒。根据你所在的位置,输出可能会有所不同。重要的是,方法链看起来很冗长。我们有两个点操作符和一些括号。这已经看起来很混乱了,随着更多方法链的使用,它将变得更加混乱。幸运的是,Clojure 提供了一个方便的
..(点号点号)宏用于方法链。这个例子可以使用点号宏重写。
-
使用点号宏从
ZonedDateTime获取秒级的时间差:(.. (ZonedDateTime/now) getOffset getTotalSeconds)输出如下:
7200输出是相同的。方法调用更易于阅读。每当您需要在 Java 对象上链式调用方法时,点号宏可以简化代码。
这是一个在不同对象上调用方法的例子。Clojure 提供了一个
doto宏,用于我们想在 Java 类的同一实例上调用方法的情况。在 Java 中,编写需要大量修改字符字符串的代码是很常见的。考虑一个我们在网站上有一个自动查找器的情况。每次我们输入一个新字符时,我们需要创建一个新的字符串。随着持续的输入,这意味着会创建许多字符串对象。这些对象会占用大量的内存空间。使用
StringBuffer,我们创建一个实例,并可以向这个实例添加字符。这比拥有字符串对象节省了大量的内存。在 Java 中,我们可以像以下这样使用
StringBuffer:StringBuffer string = new StringBuffer("quick"); string.append("brown"); string.append("fox"); string.append("jumped"); string.append("over"); string.append("the"); string.append("lazy"); string.append("dog"); -
在 Clojure 中,可以通过在
StringBuffer类上调用 append 方法来构建一个句子,如下所示:(let [string (StringBuffer. "quick")] (.append string " brown") (.append string " fox") (.append string " jumped") (.append string " over") (.append string " the") (.append string " lazy") (.append string " dog") (.toString string))我们得到的输出是一个句子:
"quick brown fox jumped over the lazy dog"词语
string有重复。doto宏消除了这种重复。doto宏会隐式调用我们指定的实例上的函数。前面的代码可以使用doto重新编写。 -
使用
StringBuffer和doto宏构造一个句子:(let [string (StringBuffer. "quick")] (doto string (.append " brown") (.append " fox") (.append " jumped") (.append " over") (.append " the") (.append " lazy") (.append " dog")) (.toString string))输出如下句子:
"quick brown fox jumped over the lazy dog"在这个例子中,我们消除了代码重复。使用
doto宏,我们在StringBuffer的实例上调用方法。一旦完成,我们将实例转换为字符串。
在这个练习中,我们查看了一些在需要与 Java 类一起工作时很有用的宏。我们经常调用很多方法。doto 和 dot-dot 宏允许我们简化具有多个方法调用的代码。
在下一个主题中,我们将使用我们在 Clojure 中调用 Java 类的新知识。我们将研究 Java IO 操作。然后,我们将创建一个咖啡订购应用程序,该应用程序执行文件操作。
使用 Java I/O
I/O 处理从源读取数据并将数据写入目的地。这些是程序执行的一些最常见的活动。源和目的地是非常广泛的概念。你可以从文件或键盘读取数据,并在监视器上显示数据。你也可以从数据库读取数据,并将数据写入提供数据的 API。Java 为许多读取和写入数据的源和目的地提供了类。
在这个主题中,我们将查看最常见的 I/O 情况:
-
从文件读取和写入
-
从键盘读取并写入监视器
我们已经在不知不觉中处理了 I/O。每次我们启动 REPL 并在键盘上输入时,我们都会执行写入操作。同样,REPL 中的所有函数调用都会打印到监视器上,执行输出操作。
I/O 是一个庞大且复杂的话题。即使是创建 Java 的人最初也没有完全搞对,正如我们可以从 I/O 的类和包的数量中看到的那样。我们有 java.io 包和 java.nio(新 IO)包。java.io 包包含用于在 Java 中处理输入和输出的类。这个包包含从键盘读取和向打印机或屏幕等目的地显示的方法。虽然 java.io 包包含许多有用的类,但它被认为是复杂的。为了编写 I/O 代码,我们必须使用许多类。较新的包 java.nio(新 I/O)引入了简化 Java 中输入和输出工作的新 I/O 类。
Java 有许多 I/O 类,因为使用输入和输出的场景有很多。我们将看到 PushbackReader 类的实际应用。这个类允许我们在确定如何解释当前字符之前,先读取几个字符来查看接下来是什么。这在我们需要考虑文件中的其他数据来读取和解释数据时非常有用。
幸运的是,Clojure 是一种非常实用的语言,并提供了处理 I/O 的工具。Clojure 有一个with-open宏,它帮助我们处理文件。打开文件,或者更准确地说,任何数据流,都会使用计算机资源,如 CPU 和 RAM。在完成从文件中读取后,我们希望释放这些资源,以便它们可用于其他任务。with-open宏关闭打开的流,从而释放资源。关闭资源是自动完成的,我们不需要自己考虑关闭资源。这可以防止我们的应用程序因为有许多未使用的打开资源而变慢或崩溃。
Clojure 中的不可变性
在第二章,数据类型和不可变性中,我们学习了不可变性。Clojure 默认使用不可变性。我们不修改数据结构,而是基于现有数据创建新的结构。例如,存储在映射中的员工信息通过创建一个新的带有必要更改的员工映射来更新。如果需要,原始的员工映射保持完整并可用。正如我们所学的,这种方法可以防止我们在 Clojure 中从多个地方访问一个数据结构时出现许多错误。
然而,有时我们想要修改数据。我们希望默认运行应用程序,并在用户选择退出选项时停止它。Ref 是 Clojure 的并发原语之一。我们将在第十二章,并发中了解更多关于 Clojure 的并发知识。现在,我们只需要知道,使用ref我们可以更改数据的值。我们将使用 ref 来控制我们即将创建的应用程序的状态。
在接下来的两个练习中,我们将创建一个咖啡订购应用程序。在开发过程中,我们将有机会处理文件读写等 I/O 操作。我们将首先为应用程序的前端部分创建一个新的 Leiningen 项目。我们将显示咖啡菜单并处理用户选择。
练习 9.04:咖啡订购应用程序 – 显示菜单
在本章中,我们看到了如何在 Clojure 中使用 Java 类。这个练习的目的是扩展我们对 Clojure 和 Java 的知识。我们将创建一个咖啡订购应用程序。
该应用程序将具有以下功能:
-
显示咖啡菜单
-
能够订购咖啡(类型和数量)
-
显示订单确认
一旦我们完成应用程序,我们就能下订单并显示它们:

图 9.5:咖啡应用程序
-
打开终端并创建一个新的 Leiningen 项目:
lein new app coffee-app这将创建一个与上一章中我们研究的项目相似的 Leiningen 项目。
在这个应用程序中,我们将从键盘获取用户输入。为了帮助我们,我们将使用来自
java.util包的名为Scanner的 Java 类。 -
将命名空间导入到
core.clj文件中:(ns coffee-app.core (:require [coffee-app.utils :as utils]) (:import [java.util Scanner]) (:gen-class))我们已经导入了
Scanner类。这个类允许我们从键盘获取输入。为了使用Scanner类的方法,我们需要创建这个类的实例。我们还导入了
coffee-app.utils命名空间,其中我们将有实用函数。 -
我们将菜单存储在哈希表中。哈希表是我们在第一章,Hello REPL中学习到的 Clojure 集合。在哈希表中,我们使用咖啡类型,例如
:latte作为键。键的值是价格:(def ^:const price-menu {:latte 0.5 :mocha 0.4})在价格菜单中,摩卡的价格是
0.4。 -
创建
Scanner类的实例:(def input (Scanner. System/in))当我们想要从用户那里获取输入时,我们将调用这个类实例的方法。
Scanner类需要知道输入的来源。在我们的情况下,我们使用System类的默认输入源——键盘。当用户运行应用程序时,他们应该看到一个带有选项的菜单。这些选项是显示和订购咖啡、列出订单和退出应用程序:![图 9.6:咖啡应用程序的选项
![图片]()
图 9.6:咖啡应用程序的选项
-
添加显示菜单和处理用户选择的代码:
(defn- start-app [] "Displaying main menu and processing user choices." (let [run-application (ref true)] (while (deref run-application) (println "\n| Coffee app |") (println "| 1-Menu 2-Orders 3-Exit |\n") (let [choice (.nextInt input)] (case choice 1 (show-menu) 2 (show-orders) 3 (dosync (ref-set run-application false))))))) -
在
start-app函数中,我们将应用程序设置为默认运行:run-application (ref true) (while (deref run-application)并发原语是特殊的。为了获取它们存储的值,我们使用
deref函数。 -
在
while块内部,应用程序将一直运行,直到用户选择退出选项。在这种情况下,我们将更新ref的值:(dosync (ref-set run-application false))更新后,
ref变为false。当ref的值为false时,while块将停止,我们的应用程序将退出。 -
当我们的应用程序运行时,用户可以从菜单中选择选项:
(println "\n| Coffee app |") (println "| 1-Menu 2-Orders 3-Exit |\n")这将显示以下菜单:
![图 9.7:选择选项
![图片]()
图 9.7:选择选项
我们能够显示初始菜单。我们可以处理用户从菜单中的选择。
-
为了获取用户响应,我们调用
Scanner实例的nextInt方法:choice (.nextInt input) -
最后,一旦我们获取了用户输入,我们检查菜单中的哪个选项应该被执行:
(case choice 1 (show-menu) 2 (show-orders))我们现在知道了当启动应用程序时主应用程序菜单中的逻辑。现在是时候深入了解并查看
show-menu函数的代码。 -
显示菜单:
(defn- show-menu [] (println "| Available coffees |") (println "|1\. Latte 2.Mocha |") (let [choice (.nextInt input)] (case choice 1 (buy-coffee :latte) 2 (buy-coffee :mocha)))) -
在
show-menu函数中,我们让用户了解两种可用的咖啡,拿铁和摩卡:(println "| Available coffees |") (println "|1\. Latte 2.Mocha |")这将显示咖啡菜单:
![图 9.8:显示咖啡菜单
![图片]()
图 9.8:显示咖啡菜单
我们现在需要响应用户的咖啡选择。
-
我们使用
Scanner实例来获取用户输入:choice (.nextInt input) -
最后,我们继续购买用户选择的咖啡:
(case choice 1 (buy-coffee :latte) 2 (buy-coffee :mocha))show-menu函数不是很长。它的目的是显示可用的咖啡并获取用户输入。一旦用户选择,我们就调用buy-coffee函数来处理购买所选咖啡。 -
询问用户他们想要多少杯咖啡:
(defn- buy-coffee [type] (println "How many coffees do you want to buy?") (let [choice (.nextInt input) price (utils/calculate-coffee-price price-menu type choice)] (utils/display-bought-coffee-message type choice price)))buy-coffee函数询问用户想要购买多少杯咖啡。同样,我们使用 Scanner 类的实例 –input– 来获取用户的选择。接下来,该函数调用两个实用函数来处理购买。这些函数负责计算咖啡价格并向用户显示反馈消息。所有函数都将放置在
utils.clj文件中。而不是将所有函数放在一个大的文件中,将函数拆分到不同的命名空间中是一种良好的实践。一个常见的命名空间名称是utils。我们可以将任何有用的操作数据的函数保存在那里。 -
创建
utils命名空间:(ns coffee-app.utils)因为我们将放置在这个命名空间中的方法执行 I/O 操作,所以我们本可以将其命名为
coffee-app.io。在我们的情况下,命名空间名称utils和io都是有效的。在更大的应用程序中,将utils命名空间拆分为不同的命名空间是常见的做法。 -
计算咖啡价格:
(defn calculate-coffee-price [coffees coffee-type number] (-> (get coffees coffee-type) (* number) float))我们的第一个实用函数计算咖啡价格。它使用 get 函数检查传递的咖啡类型的
coffees哈希。该哈希在核心命名空间中定义。然后,从哈希中获取的值乘以用户订购的咖啡杯数。最后,我们将数字转换为浮点数。这允许我们将1.2000000000000002这样的数字转换为1.2。在处理购买咖啡时最后使用的实用函数是
display-bought-coffee-message函数。 -
在购买咖啡后向用户显示一条消息:
(ns coffee-app.utils) (defn display-bought-coffee-message [type number total] (println "Buying" number (name type) "coffees for total:€" total))display-bought-coffee-message函数接受一个订单映射,并根据映射中的数据为用户构建一个字符串消息。用户被告知他们以指定价格购买了一定数量的咖啡杯。使用此函数,我们可以控制在完成订单后传递给用户的信息:
Buying 2 latte coffees for total:€ 1.0主菜单中的第二个选项允许我们查看已放置的订单:
![图 9.9:查看放置的订单的选项 2]()
图 9.9:查看放置的订单的选项 2
负责显示订单的函数是来自
coffee-app.core命名空间的show-orders函数。 -
显示放置的订单:
(ns coffee-app.core) (defn- show-orders [] (println "\n") (println "Display orders here"))此函数显示已放置的咖啡订单。在本练习中,我们通知用户订单将在此处显示。在接下来的练习中,我们将实现保存和显示订单:
Display orders here当我们运行应用程序并购买两杯拿铁时,我们将看到以下输出:
![图 9.10:显示放置的订单输出]()
图 9.10:显示放置的订单输出
在这个练习中,我们学习了如何在 Clojure 中处理 I/O 和 Java。我们创建了一个允许我们查看咖啡菜单并订购咖啡的咖啡订购应用程序。
在下一个练习中,我们将扩展此应用程序并实现保存咖啡订单和检索订单。
练习 9.05:咖啡订购应用程序 – 保存和加载订单
本练习的目的是扩展我们的 I/O 知识。我们将学习如何保存和读取文件。我们将扩展咖啡订购应用程序,以便将数据保存到文件并从文件中读取数据。
应用程序将具有以下功能:
-
保存订单
-
检索订单
-
显示已保存的订单
一旦我们完成应用程序,我们就能显示订单:

图 9.11:已放置订单的输出
此应用程序的主要功能是保存和加载订单。我们将为此创建实用函数:
-
为了与文件 I/O 一起工作,我们需要导入 I/O 命名空间:
(ns coffee-app.utils (:require [clojure.java.io :as io]) (:import [java.io PushbackReader]))我们将使用
PushbackReaderJava 类来读取文件。我们还将使用 Clojure I/O 库中的实用工具。 -
我们将实现的第一项功能是将数据保存到文件:
(defn save-to [location data] (spit location data :append true))spit函数是一个 Clojure I/O 函数,它将数据写入指定的文件位置。当:append关键字设置为 true 时,我们想要存储的数据将被追加到现有数据。否则,每次我们保存数据时,新数据将覆盖现有文件内容。保存文件后,我们希望从文件中检索数据。 -
为了从文件中检索数据,我们需要确保文件存在:
(defn file-exists? [location] (.exists (io/as-file location)))file-exists?函数调用一个 I/O 函数as-file,该函数返回java.io.File。然后,我们调用exists函数来检查我们是否在请求的位置有一个文件。file-exists函数的返回值是一个布尔值。如果文件存在,我们得到true:![图 9.12:file_exists?函数输出为 true
![img/B14502_09_12.jpg]()
图 9.12:file_exists?函数输出为 true
如果文件不存在,我们得到
false:![图 9.13:file_exists?函数输出为 false
![img/B14502_09_13.jpg]()
图 9.13:file_exists?函数输出为 false
一旦我们知道文件存在,我们就可以加载已保存的订单。
-
我们将需要从文件中加载订单:
(defn load-orders "Reads a sequence of orders stored in file." [file] (if (file-exists? file) (with-open [r (PushbackReader. (io/reader file))] (binding [*read-eval* false] (doall (take-while #(not= ::EOF %) (repeatedly #(read-one-order r)))))) []))load-orders函数接受一个文件名作为参数。我们调用file-exists?函数来检查我们是否在请求的位置有一个文件。file-exists?函数的返回值在load-orders函数中的if块中使用。如果没有文件,我们返回一个空向量。如果有文件,我们将读取其内容。我们使用
with-open宏从文件中读取。宏将在我们完成读取后自动处理关闭文件。这将为我们释放计算机资源。我们使用PushbackReader类从文件中读取。此类允许我们在确定如何解释当前字符之前,先读取几个字符来查看接下来会发生什么。我们的计划是读取订单,直到我们到达文件末尾,由::EOF关键字标记。我们反复读取一个订单。我们已将
read-eval的绑定更改为false。从我们不知道的文件中读取是不安全的。默认情况下,read-eval设置为true。这意味着我们读取的任何数据都可以被评估。用户数据或通过网络发送的文件永远不应该被信任。我们处理的数据可能包含恶意代码。当我们用 Clojure 操作数据时,数据应该始终以不评估数据内容的方式读取。 -
我们将使用
clojure.java.io命名空间中的read函数来读取订单文件中的每一行:(defn read-one-order [r] (try (read r) (catch java.lang.RuntimeException e (if (= "EOF while reading" (.getMessage e)) ::EOF (throw e)))))一旦我们到达文件末尾,就会抛出一个 Java 错误,我们会捕获这个错误。在捕获错误后,我们返回
::EOF关键字,这指示我们的 while 循环停止从文件中读取。我们将在本章后面学习更多关于 Java 异常的内容。我们有自己的实用函数来保存和加载数据到文件。我们已经准备好使用这些函数。 -
保存咖啡订单。使用
save-to函数保存咖啡订单:(defn save-coffee-order [orders-file type number price] (save-to orders-file {:type type :number number :price price}))save-coffee-order函数接受要保存数据的文件名、咖啡类型、咖啡杯数和订单价格作为参数。使用这些数据,我们构建一个映射,并将其传递给
save-to函数。save-to函数将数据保存到我们指定的文件中。在实现保存咖啡订单的能力后,我们可以使用这个函数来处理购买咖啡。
-
处理购买咖啡。
buy-coffee函数将负责计算咖啡价格,保存咖啡订单,并向用户显示反馈信息:(ns coffee-app.core) (defn buy-coffee [type] (println "How many coffees do you want to buy?") (let [choice (.nextInt input) price (utils/calculate-coffee-price price-menu type choice)] (utils/save-coffee-order orders-file type choice price) (utils/display-bought-coffee-message type choice price)))在
buy-coffee函数中,我们询问用户想要购买多少杯咖啡。我们使用 Scanner 类的实例input来获取用户的选择。接下来,函数调用三个实用函数来处理购买。在我们计算订单价格后,我们保存订单,并最终向用户显示有关已下订单的信息。在下单后,我们准备好加载订单以便在菜单中显示。
-
我们将使用
show-orders函数来显示订单:(def ^:const orders-file "orders.edn") (defn show-orders [] (println "\n") (doseq [order (utils/load-orders orders-file)] (println (utils/display-order order))))在
show-orders函数中,我们从订单文件中获取订单。我们使用doseq遍历一系列订单。使用doseq,对于每个订单,我们将调用display-order函数。 -
显示订单的数据是从传递给
display-order函数的订单参数中构建的:(defn display-order [order] (str "Bought " (:number order) " cups of " (name (:type order)) " for €" (:price order)))display-order函数从一个订单映射中创建一个字符串。我们访问购买咖啡杯数、购买咖啡类型和订单价格的信息。购买两杯咖啡后,我们将得到以下输出:
![Figure 9.14: Output for the display-order function]
![img/B14502_09_14.jpg]
图 9.14:display-order函数的输出
在这个练习中,我们扩展了我们的咖啡订购应用程序。我们添加了将订单保存到文件和从文件加载数据的功能。在实现这些功能的过程中,我们学习了更多关于 Java I/O 的知识。这些功能提高了我们的咖啡订购应用程序。
处理 Java 数据类型
数据类型指的是数据是如何分类的。Java 中的任何变量或对象都有一个特定的类型。在这本书中,我们看到了如字符串("巴黎")、布尔值(true、false)、数字(1、2)和集合([:one :two :three])这样的类型。
Clojure 重用了 Java 中最常见的几种数据类型,例如字符串和数字。这是一个很好的方法,因为自从 90 年代 Java 被创建以来,许多开发者在他们的代码中测试了 Java 类型。这让我们在使用 Java 数据类型时有了信心。有一些类型在 Clojure 中并不直接存在。在这种情况下,我们使用 Clojure 中的 Java 互操作性来访问 Java 数据类型。
当在 Clojure 中编写应用程序时,我们可以使用本书中介绍的数据类型。如果我们知道 Java 中的数据类型,我们也可以使用它们。在前面的练习中,我们学习了如何使用 Java 类中的访问方法。现在我们知道如何处理类了。
在下一个练习中,我们将学习如何在 Clojure 中处理 Java 集合。Java 提供了如ArrayList和HashMap这样的集合:
-
ArrayList就像 Clojure 的向量。ArrayList中的元素按顺序存储。 -
HashMap就像 Clojure 的哈希表。HashMap中的元素以键/值对的形式存储。
练习 9.06:Java 数据类型
本练习的目的是学习如何在 Clojure 中处理 Java 数据类型。通常,当我们使用 Clojure 工作时,我们依赖于外部库。有许多 Java 库。了解如何使用 Java 数据类型将帮助我们有效地使用 Java 库。在这个练习中,我们将处理一个地理应用程序的一部分。该应用程序将国家、首都和河流等信息作为 Java 集合存储。我们将编写代码在 Java 和 Clojure 集合之间进行转换:
-
使用以下命令启动 REPL:
lein repl它将如下开始:
![图 9.15:REPL 的输出
![图片]()
图 9.15:REPL 的输出
-
我们将创建一个包含一些首都的向量:
(def capitals ["Berlin" "Oslo" "Warszawa" "Belgrad"]) -
检查
capitals向量:capitals输出如下:
["Berlin" "Oslo" "Warszawa" "Belgrad"] -
我们可以检查我们的
capitals向量的类:(class capitals)输出如下:
clojure.lang.PersistentVector我们可以看到
capitals是一个 Clojure 的PersistentVector。 -
使用 Clojure 的向量,我们可以在 Java 中创建一个
ArrayList:(def destinations (java.util.ArrayList. capitals))我们从一个向量创建了一个
ArrayList。我们可以这样检查它:destinations输出如下:
["Berlin" "Oslo" "Warszawa" "Belgrad"] -
我们可以检查我们的
destinations数组的类:(class destinations)输出如下:
java.util.ArrayList变量
destinations具有 Java 的ArrayList类。我们刚刚从 Clojure 转换为 Java。我们将capitals向量转换为destinationsArrayList。 -
我们也可以进行逆向转换。我们可以这样从 Java 转换为 Clojure:
(vec destinations)Clojure 核心库中的
vec函数允许我们将ArrayList转换为向量。 -
我们可以检查我们新转换的数据的类:
(class (vec destinations))输出如下:
clojure.lang.PersistentVector当我们将
ArrayList转换为向量时,我们得到 Clojure 中的PersistentVector类。 -
我们能够使用 ArrayList 和向量在 Java 和 Clojure 之间来回转换。Clojure 有另一种存储数据的集合类型。哈希以键/值对的形式存储数据:
(def fluss {"Germany" "Rhein" "Poland" "Vistula" }) -
我们定义了一个包含国家和这些国家中的河流(在德语中为 fluss)的哈希。
fluss输出如下:
{"Germany" "Rhein" "Poland" "Vistula" } -
我们可以检查
fluss变量的类:(class fluss)输出如下:
clojure.lang.PersistentArrayMapfluss变量是 Clojure 的PersistentArrayMap。 -
使用 Clojure 的哈希,我们可以使用 Java 创建一个 HashMap:
(def rivers (java.util.HashMap. fluss)) -
我们使用 Clojure 的哈希从 Java 创建一个 HashMap。
rivers输出如下:
{"Poland" "Vistula" "Germany" "Rhein"}rivers变量包含国家和这些国家中的河流。 -
我们可以如下检查
rivers变量的类:(class rivers)输出如下:
java.util.HashMap我们可以看到
rivers是一个 Java 的 HashMap。 -
使用 Java 的 HashMap,我们可以创建一个 Clojure 中的哈希:
(into {} rivers)输出如下:
{"Poland" "Vistula" "Germany" "Rhein"}在前面的代码中,我们使用了 Clojure 核心库中的
into函数。into函数接受目标集合和源集合作为两个参数。我们的目标集合是 Clojure 中的一个哈希。记住,我们在 Clojure 中使用花括号
{}定义哈希。我们将 Java 中的riversHashMap 的内容放入 Clojure 的哈希{}中。 -
我们可以检查我们新转换的哈希的类:
(class (into {} rivers))输出如下:
clojure.lang.PersistentArrayMap我们转换后的数据类是 Clojure 的
PersistentArrayMap。
在这个练习中,我们扩展了我们对 Java 与 Clojure 互操作性的知识。我们学习了如何将数据从 Java 转换为 Clojure,然后再转换回来。在接下来的活动中,你将使用你对 Java 互操作性的知识来创建一个执行 I/O 操作的应用程序。
活动九.01:图书订购应用程序
在这个活动中,我们将应用我们对 I/O 和 Java 的新知识来创建一个图书订购应用程序。一家媒体公司决定创建一个允许用户订购图书的应用程序。用户可以从应用程序中的列表中选择年份和标题。一旦用户下单,我们应该能够按年份分组查看订单。
一旦完成活动,你应该会有以下类似的输出。
初始菜单:

图 9.16:菜单显示
列出年份:

图 9.17:按年份提供的书籍
一年中的书籍:

图 9.18:2019 年购买的书籍
询问用户想购买多少本书:

图 9.19:询问购买书籍的数量
订单确认信息:

图 9.20:订单确认信息
列出购买的书籍:

图 9.21:购买书籍列表
这些步骤将帮助你完成活动:
-
创建一个新的项目。
-
导入必要的命名空间。
-
创建一个按年份存储书籍的映射。
-
为存储订单的文件创建一个变量。
-
创建一个初始菜单,包含订购书籍和列出订单的选项。
-
创建一个按年份显示书籍的菜单。
-
创建应用程序的
main方法。 -
创建一个将数据保存到文件的函数。
-
创建一个保存订单的函数。
-
创建一个计算
书籍价格的函数。 -
创建一个显示订单确认信息的函数。
-
创建一个显示已购买订单的函数。
-
创建一个读取单个订单的函数。
-
创建一个检查文件是否存在的函数。
-
创建一个从文件中加载订单的函数。
-
创建一个用于订购书籍的下拉菜单。
-
创建一个按年份购买书籍的函数。
-
创建一个按年份显示订单的函数。
-
创建一个用于列出订单的下拉菜单。
备注
此活动的解决方案可以在第 712 页找到。
在 ClojureScript 中使用 JavaScript
ClojureScript 允许我们使用 JavaScript 构造。我们可以在 ClojureScript 中像调用其他方法一样调用 JavaScript 方法和函数。当我们从 Clojure 调用 Java 时,我们使用了.点或\斜杠这样的运算符。在 ClojureScript 中使用 JavaScript 也将需要我们学习新的语法。
虽然 Java 大量操作类,但在 JavaScript 中我们操作对象。我们想在对象上使用的两个 JavaScript 构造是:
-
方法
-
字段
为了从 JavaScript 对象中访问一个方法,我们在方法名前放置一个点(.)。访问对象的字段与访问字段非常相似。我们在字段名前使用.-(一个点和短横线)。你可能想知道为什么访问函数的语法与访问字段略有不同。在 JavaScript 中,一个对象可以有一个与字段同名的函数和字段。在 ClojureScript 中,我们需要一种方法来区分函数调用和字段访问。
在 JavaScript 中,代码如下:
var string = "JavaScript string"
var string_length = string.length;
var shout = string.toUpperCase();
在 ClojureScript 中,代码如下:
(def string "JavaScript string")
(def string_length (.-length string))
(def shout (.toUpperCase string))
让我们花一分钟来欣赏 ClojureScript 的设计。我们在书的开始部分学习了 ClojureScript 函数及其调用方法。本质上,它与 Clojure 的语法相同。除非我们有一个序列,否则列表中的第一个位置被视为一个函数:
(range 1 10)
调用range将返回从 1 到 10 的数字序列:

图 9.22:调用 range 的输出
在这里,range 处于第一个位置,ClojureScript 编译器正确地将 range 视为一个函数。我们刚刚看到了如何调用 JavaScript 方法和字段。通过添加一个点或连字符,语法没有任何变化。这种在 ClojureScript 中将方法调用放在第一个位置的统一性减轻了开发者的心理负担。我们不需要学习很多特殊的语法来实现 JavaScript 互操作性。
当我们比较使用 range 函数和检查字符串长度时,我们将看到这种一致性。
我们这样调用 range 函数:
(range 1 10)
它将按以下方式工作:

图 9.23:调用 range 函数
检查字符串长度的方式如下:

图 9.24:检查字符串长度
range 函数和访问 JavaScript 字符串的长度字段都放在语句的第一个位置。
更令人惊讶的是,ClojureScript 在 JavaScript 之上进行了改进。在 JavaScript 中,没有命名空间的概念。当我们定义一个函数或变量时,它属于全局命名空间。当两个或多个库使用相同的变量或方法名称时,这会引发冲突并导致错误。库设计者使用 JavaScript 对象作为模块/命名空间,并将函数放在它们的对象中。然而,这只是一个权宜之计,而不是语言设计。在 ClojureScript 中,命名空间是语言中一等公民。
我们应该注意一个命名空间。ClojureScript 使用 js 命名空间来引用程序的全局作用域。在 ClojureScript 中,像 Number、String 和 Date 这样的核心 JavaScript 对象都通过 js 命名空间访问。在这个命名空间中,我们还会找到浏览器定义的对象,如 window。
为了构造一个 JavaScript 对象,我们使用对象的名称后跟一个点。这是我们之前在 Clojure 中构造 Java 类实例时使用的相同语法。
在 JavaScript 中,代码看起来如下:
var num = new Number(123);
在 ClojureScript 中,代码看起来如下:
(def num (js/Number. 123))
注意我们使用了 js 命名空间。正如本节所述,核心 JavaScript 对象如 Number 是通过这个命名空间访问的。
ClojureScript 利用 JavaScript 数据类型。ClojureScript 并不发明新的数据类型,而是重用现有的 JavaScript 数据类型。以下表格展示了 ClojureScript 数据类型及其 JavaScript 来源:

图 9.25:ClojureScript 数据类型及其来源
如我们所见,在 ClojureScript 中常用到的数据类型都是基于 JavaScript 数据类型的。
我们已经看到了如何在 ClojureScript 中访问 JavaScript。在下一个练习中,我们将学习如何在 ClojureScript 中处理 JavaScript 数据类型。
练习 9.07:使用 JavaScript 数据类型
本练习的目的是学习如何在 ClojureScript 中处理 JavaScript 数据类型。我们将学习如何将 ClojureScript 数据转换为 JavaScript 对象。稍后,我们将学习反向过程,即如何将 JavaScript 对象转换为 ClojureScript 数据:
-
我们将为我们的代码创建一个新的项目:
lein new mies js-interop此命令创建了一个名为
js-interop的基本 ClojureScript 项目。 -
我们将使用以下命令启动 REPL。
scripts/repl输出如下:
![图 9.26:REPL 的输出
![图片 B14502_09_26.jpg]()
图 9.26:REPL 的输出
-
ClojureScript 提供了
js-obj函数,用于将 ClojureScript 数据创建为 JavaScript 对象:(js-obj "Austria" "Donau")输出如下:
#js {:Austria "Donau"}调用
js-obj函数创建了一个新的 JavaScript 对象。注意 REPL 中的#js。这个符号在 REPL 中告诉我们,接下来的表达式是一个 JavaScript 对象。 -
经常情况下,我们会使用嵌套结构,其中一个对象包含另一个对象:
(def rivers-map-js (js-obj "country" {"river" "Donau"}))输出如下:
![图 9.27:嵌套结构
![图片 B14502_09_27.jpg]()
图 9.27:嵌套结构
rivers-map-js对象是一个嵌套结构。它包含一个国家键,其值是另一个包含一些河流详细信息的对象。 -
我们可以访问
rivers-map-js对象中的字段:(.-country rivers-map-js)输出如下:
{"river" "Donau"} -
在访问国家后,我们得到了关于河流的嵌套数据。我们将尝试访问这些嵌套数据:
(.-river (.-country rivers-map-js))输出如下:
nil当我们尝试获取河流信息时,我们得到
nil。结果是,我们无法使用 JavaScript 互操作性来访问数据。原因是js-obj函数是浅层的。它不会将嵌套数据结构转换为 JavaScript 对象。如果我们想将嵌套的 ClojureScript 数据转换为 JavaScript 对象,我们需要使用其他方法。 -
为了转换所有嵌套数据,我们需要使用
clj->js函数:(def rivers-map-js-converted (clj->js {"country" {"river" "Donau"}}))输出如下:
#cljs.user/rivers-map-js-converted -
使用
clj->js函数,我们能够转换嵌套的 ClojureScript 数据:rivers-map-js-converted输出如下:
#js {:country #js {:river "Donau"}}注意两个
#js符号。每个符号都告诉我们我们有一个 JavaScript 对象。第一个对象包含一个国家的名称。在这个国家对象内部,我们还有一个名为河流的 JavaScript 对象。 -
当我们拥有嵌套的 JavaScript 对象时,我们可以使用 JavaScript 互操作性从它们中访问数据:
(.-river (.-country rivers-map-js-converted))输出如下:
"Donau"我们能够使用 JavaScript 互操作性访问嵌套的 JavaScript 对象。
-
到目前为止,我们已经从 ClojureScript 转换到了 JavaScript。也可以反过来转换,从 JavaScript 转换到 ClojureScript:
(js->clj #js {:river "Donau"})输出如下:
{"river" "Donau"}使用
#js符号,我们将一个 JavaScript 对象转换为 ClojureScript 数据。 -
将嵌套 JavaScript 对象转换为 ClojureScript 数据:
(js->clj #js {:country #js {:river "Donau"}})输出如下:
{"country" {"river" "Donau"}}使用
js->clj函数,我们再次将 JavaScript 对象转换为 ClojureScript 数据。请注意,我们使用了#js符号两次。每次我们有一个 JavaScript 对象时,我们必须使用#js符号来标记它。这指示 ClojureScript 将以下数据视为 JavaScript 对象。在这个练习中,我们学习了如何将 ClojureScript 数据转换为 JavaScript 对象。然后我们看到了如何逆向操作,将 JavaScript 对象转换为 ClojureScript 数据。
我们已经准备好开始构建 ClojureScript 应用程序。在第八章,命名空间、库和 Leiningen中,我们学习了如何构建项目和如何使用 Leiningen 创建 Clojure 项目。我们也可以使用 Leiningen 创建 ClojureScript 项目。使用 Leiningen 模板创建 ClojureScript 应用程序将创建在 ClojureScript 中工作的必要配置。最常用的 ClojureScript 模板之一是 Figwheel 模板。我们将在下一主题中学习这个模板。
Figwheel 模板
Figwheel 是一个编译 ClojureScript 代码的工具。Figwheel 的一个卖点是热代码重新加载。当我们对 ClojureScript 文件进行更改时,代码将被重新编译,浏览器中的一个页面将被更新。这有助于通过给程序员提供快速反馈来加快开发过程。
Figwheel 不仅重新加载我们的代码,而且在代码重新加载方面非常智能。当我们对代码进行更改导致代码错误时,Figwheel 会给我们编译错误。
如果我们尝试使用未声明的函数,Figwheel 会通知我们:

图 9.28:Figwheel 的消息
Figwheel 通知我们,在core.cljs文件的第 42 行,我们尝试调用一个未声明的handle-sort-finish函数。
通过 Figwheel 提供的这种简洁且高质量的反馈,我们可以比在堆栈跟踪中挖掘错误时更快地开发 ClojureScript 应用程序。或者更糟,我们的应用程序运行但给出意外的结果。
Figwheel 支持交互式编程风格。我们在代码库中做出的更改将被重新编译并在网页浏览器中显示。我们在第一章,Hello REPL!中学习了 Clojure 默认使用不可变数据结构。你可以整天重新加载函数定义。它们是无副作用的,并且与运行系统的本地状态无关。这意味着运行相同的函数多次不会改变应用程序的状态。因此,重新编译和重新加载是安全的。
Figwheel 鼓励使用 React 来开发应用程序。React 是一个允许我们在页面上操作元素的 Web 工具。React 允许你编写函数,这些函数表达的是给定当前应用程序状态,这些元素的当前状态应该是什么。
在下一节,我们将学习关于 Rum 的内容。Rum 是一个库,用于在页面上使用应用程序状态创建 HTML 元素。
使用 Rum 进行响应式 Web 编程
许多网站允许用户与网页交互。用户可以点击、拖动和排序元素。这些页面是动态的——它们会对用户操作做出响应。一个对用户交互做出反应的编程页面称为响应式编程。
HTML 提供了页面元素的结构。文档对象模型(DOM)是 JavaScript 中的 HTML 表示。JavaScript 允许我们在最终显示为网页 HTML 元素的 DOM 元素上操作。
使页面能够对用户操作做出反应的一种方法是通过重新渲染(显示)整个页面。渲染整个页面会消耗计算机资源。如果只有页面的一小部分需要重新渲染,我们就会浪费宝贵的资源重新渲染整个页面。幸运的是,我们有一个解决方案,它允许我们只重新渲染页面已更改的部分。
React.js 是一个支持响应式编程的 JavaScript 库。React.js 的基本块是组件。在 React.js 中,我们定义组件的外观和行为。使用 React.js,我们可以根据当前应用程序的状态创建组件。状态的变化会导致需要更改的组件重新渲染。React 内部检查应用程序状态哪些部分已更改,哪些组件依赖于这些状态部分。因此,React 只重新渲染那些使用了应用程序状态中已更改部分的部分。
Rum 是一个用于在网页上创建 HTML 元素的 Clojure 库。Rum 基于 React.js。我们通常在 ClojureScript 应用程序中有些状态。状态可能是一组用户。我们可以通过添加或删除用户来操作用户列表,并且基于我们的操作,网页应该更新。在 Rum 中,可以定义页面元素,如用户条目,它将根据用户列表的变化做出反应。添加新用户将导致页面显示更新后的用户列表。
在下一个练习中,我们将创建一个使用 Rum 的 Figwheel 项目,并探索 Figwheel 和 Rum 为开发者提供的优势。
练习 9.08:调查 Figwheel 和 Rum
本练习的目的是了解 Figwheel 和 Rum。Figwheel 将为我们创建 ClojureScript 项目结构。Rum 将允许我们构建对用户操作做出响应的 HTML 组件:
-
创建 Figwheel 和 Rum 项目:
lein new figwheel-main hello-clojurescript.core -- --rum我们使用 Leiningen 调用
fighwheel-main模板。此模板将创建一个名为hello-clojuresript.core的主命名空间的新 ClojureScript 项目。我们想使用 Rum,因此我们传递
--rum命令行参数以将 Rum 支持添加到项目中:![图 9.29:创建 Figwheel 和 Rum 项目]()
图 9.29:创建 Figwheel 和 Rum 项目
Leiningen 下载模板并为我们创建 ClojureScript 项目。
-
我们将移动到命令行中的项目:
cd hello-clojurescript.core/这将更改目录到
hello-clojurescript.core:![图 9.30:更改目录]()
图 9.30:更改目录
我们已准备好运行我们的应用程序。为了运行我们的 ClojureScript 应用程序,我们需要构建它。
-
Figwheel 提供了一个自动构建配置,允许我们运行新创建的 ClojureScript 应用程序:
lein fig:build调用此命令将构建一个 ClojureScript 应用程序。首先,Figwheel 将下载任何必要的依赖项:
![图 9.31:构建 ClojureScript 应用程序]()
图 9.31:构建 ClojureScript 应用程序
在这里,Figwheel 下载了三个依赖项:
-
Rum
-
Figwheel-main库 -
ClojureScript
其次,Figwheel 将检查我们的配置:
![图 9.32:Figwheel 检查配置]()
-
图 9.32:Figwheel 检查配置
由于我们没有更改默认配置,我们的项目配置没有问题。我们很快就会看到默认配置的样子。
第三,Figwheel 将编译我们的代码,并输出一个要运行的主文件:

图 9.33:编译代码
-
在 Figwheel 构建我们的应用程序后,它将启动一个服务器,该服务器将提供我们的应用程序:
![图 9.34:启动服务器]()
[Figwheel] Watching paths: ("test" "src") to compile build – dev我们在源文件中做出的任何更改都将导致代码重新编译,并且我们的应用程序将在网页浏览器中更新。
我们现在将研究核心源文件。
-
Figwheel 将在
core.cjs文件中为我们导入两个命名空间:(ns ^:figwheel-hooks hello-clojurescript.core (:require [goog.dom :as gdom] [rum.core :as rum]))第一个命名空间是 Google 的
dom命名空间,它允许我们操作 DOM 元素。第二个命名空间是rum,因为我们设置了我们的应用程序使用 Rum。记住,当我们创建应用程序时,我们传递了--rum命令行参数。在命名空间中,我们定义了
^:figwheel-hooks关键字。这是由 Figwheel 自动生成的,并指示 Figwheel 需要自动编译此文件。 -
Google DOM 命名空间允许我们在页面上操作 DOM 元素:
(defn get-app-element [] (gdom/getElement "app"))getElement函数将在页面中搜索具有appID 的元素。默认情况下,Figwheel 将创建一个带有div的索引页面。这个div将具有appID。 -
Figwheel 在
resources/public文件夹中创建一个默认的索引文件:<!DOCTYPE html> <html> <head> <link href="css/style.css" rel="stylesheet" type="text/css"> </head> <body> <div id="app"></div> <script src="img/dev-main.js" type="text/javascript"></script> </body> </html>在索引文件中,我们关注的主要事情是从
css/style.css文件导入样式,创建一个带有应用程序id的div,我们将在这里挂载我们的应用程序,并将编译后的代码作为从dev-main.js文件的脚本添加。 -
响应式应用程序需要管理状态以响应用户交互。状态被定义为原子:
(defonce app-state (atom {:text "Hello world!" :counter 0}))在当前状态下,我们存储一个带有
:text键,其值为"Hello world!"和带有:counter键,其值为零的哈希。状态是通过defonce定义的。这是因为我们不希望在 Figwheel 重新加载代码时重新定义状态。这样,我们可以在页面重新加载期间保留应用程序状态。 -
使用
defc宏定义 Rum 组件:(rum/defc hello-world [] [:div [:h1 (:text (deref app-state))] [:h3 "Edit this in src/hello_clojurescript/core.cljs and watch it change!"]])hello-world组件构建一个 HTMLdiv元素。在div内部,我们有一个h1HTML 元素和一个h3HTML 元素。h1元素将显示存储在应用程序状态中的文本。因为应用程序状态是一个原子,如果我们想访问值,我们需要对其进行解引用。解引用是一个返回原子中存储的值的操作。h3元素告诉我们我们可以编辑core.cljs文件,并在网页上看到更改。定义好的组件需要挂载到页面上。
-
为了在页面上看到组件,我们需要将它们挂载:
(defn mount [el] (rum/mount (hello-world) el)) (defn mount-app-element [] (when-let [el (get-app-element)] (mount el)))rum/mount函数将hello-world组件挂载到 DOM 元素上。该元素是我们之前调查的get-app-element函数返回的。一旦组件挂载到 DOM 元素上,它就会在页面上显示:
![图 9.36:将组件挂载到 DOM 元素后的初始页面
![图片 B14502_09_36.jpg]
图 9.36:将组件挂载到 DOM 元素后的初始页面
我们的
hello-world组件显示两个标题。首先是带有Hello world!的h1标题,然后是带有关于编辑core.cljs文件信息的h3标题。 -
Rum 允许我们定义响应式组件。响应式组件是一种对应用程序状态变化做出反应的组件。当发生变化时,组件将在页面上以从应用程序状态中取出的新值重新渲染:
(rum/defc hello-world < rum/reactive [] [:div {} (band "Metallica" (:counter (rum/react app-state)))])我们使用 Rum 的
< rum/reactive语法标记组件为响应式。<符号告诉 Rum 组件是一个特殊类型。在我们的例子中,它是一个响应式组件。Rum 将对app-state的变化做出反应。hello-world组件将调用乐队组件,并将乐队名称与定义在应用程序状态中的:counter一起传递。每当
app-state更新时,Rum 都会对变化做出反应并重新渲染乐队组件。 -
在
hello-world组件中,我们将显示一个乐队:(rum/defc band [name likes] [:div {:class "band" :on-click #(increment-likes)} (str name " is liked " likes " times")])band组件接受两个乐队名称和点赞数作为参数。组件将显示乐队名称和点赞数:
![图 9.37:在 hello-world 组件中显示乐队名称
![图片 B14502_09_37.jpg]
图 9.37:在 hello-world 组件中显示乐队名称
在组件内部,我们使用:on-click DOM 属性。
on-click属性允许我们在用户点击网页上的元素时附加一个函数:
(defn increment-likes []
(swap! app-state update-in [:counter] inc))
函数通过使用 Clojure 的inc函数增加计数器的值来更新app-state哈希中的:counter键:
![图 9.38:乐队名称的点赞数增加
![图片 B14502_09_38.jpg]
图 9.38:乐队名称的点赞数增加
在页面元素上点击三次将更新计数器。更新计数器将触发组件以新值重新渲染。
在这个练习中,我们更新了hello-world组件。在代码更改后,组件在浏览器中显示。我们不必重新编译代码。代码是由 Figwheel 重新编译的。当我们运行 Figwheel 时,它开始监视我们的文件以查找更改:
[Figwheel] Watching paths: ("test" "src") to compile build - dev
多亏了 Figwheel,我们能够专注于编码,而不必担心重新编译我们的代码。这是由 Figwheel 自动完成的。
在本节中,我们学习了关于 Figwheel 的内容。它为 ClojureScript 项目创建了一个模板。Figwheel 的主要功能是热代码重新加载。我们源文件中的更改会自动重新编译并在浏览器中重新显示。
我们还学习了关于 Rum 的内容。Rum 是一个库,有助于创建响应式组件。这些组件会响应应用程序状态的变化,并在网页上重新显示。
在下一个主题中,我们将更深入地探讨 JavaScript 互操作性。
拖放
在网页中,最常见的使用场景之一就是使用拖放。它如此普遍,以至于我们如今很少注意到它。jQuery UI 库提供了用于编码拖放功能的函数。使用这个库,我们可以将 HTML 元素标记为可拖动和可放置。
该库提供了一些选项,允许我们改变拖放行为。我们可以:
-
将可拖动元素的移动限制在网页上的特定区域
-
指定元素在拖动后是否返回原始位置
-
指定元素是否自动对齐到其他元素
-
拖动时提供视觉辅助,例如透明度或动画
-
在页面区域中仅接受一个可放置元素
-
允许或禁止拖放后回滚
-
元素被放置后提供视觉反馈
使用 jQuery 的可拖动和可放置功能,我们可以创建一个真正交互式的页面。
我们已经看到了 JavaScript 互操作性的语法。现在是时候将我们的知识付诸实践了。在下一个练习中,我们将使用 JavaScript 互操作性创建一个拖放应用。该应用将基于 Figwheel 模板,并使用 Rum 实现响应式行为。
练习 9.09:使用拖放与 JavaScript 互操作
本练习的目的是让用户熟悉 ClojureScript 中的 JavaScript 互操作性。我们将创建一个前端应用,允许用户拖放元素。在编码拖放行为时,我们将使用 JavaScript 中的对象和函数。JavaScript 的 jQuery UI 库提供了拖放方法。我们将使用这个库的方法。
该应用将基于一个 Figwheel 模板,该模板有助于构建 ClojureScript 应用。其主要功能之一是热代码重新加载。任何对源文件的更改都会在浏览器中重新编译和更新。这有助于通过为程序员提供快速反馈来加快开发过程:
-
基于 figwheel 模板创建一个新的 ClojureScript 应用:
lein new figwheel-main hello-drag-and-drop -- --rumREPL 将显示信息,说明已创建了一个基于
figwheel模板的新项目:![图 9.39:REPL 输出]()
图 9.39:REPL 输出
在项目中,我们将使用
jayq外部库。jayq是 jQuery 的 ClojureScript 包装器。 -
在
project.clj中添加外部 ClojureScript 依赖项。在project.clj中,在:dependencies部分添加jayq库:[jayq "2.5.4"]project.clj中的dependencies部分应如下所示:![图 9.40:依赖关系的输出]()
图 9.40:依赖关系的输出
-
现在我们已经在
project.clj中配置了依赖项,我们可以在hello-drag-and-drop.core命名空间中导入它们:(ns ^:figwheel-hooks hello-drag-and-drop.core (:require [jayq.core :as jayq :refer [$]] [goog.dom :as gdom] [rum.core :as rum]))这些库将帮助我们创建页面上的拖放元素。拖放实现将基于 jQuery UI 的一个组件。我们需要在
index.html文件中导入这些 JavaScript 库。 -
打开
resources/public/index.html文件,并在<head>标签内添加 jQuery 和 jQuery UI 的导入:<script src="img/jquery-3.4.1.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script> <script src="img/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script> -
在导入必要的库之后,我们将启动 Figwheel。在终端中,键入以下内容:
lein fig:build这将为我们启动 Figwheel:
![图 9.41:启动 Figwheel]()
图 9.41:启动 Figwheel
Figwheel 将编译 ClojureScript 代码并为我们启动一个服务器。服务器将自动打开一个网页浏览器,显示
index.html文件的内容:![图 9.42:index.html 的内容]()
图 9.42:index.html 的内容
Figwheel 的一个卖点是可以代码重载。当我们对 ClojureScript 文件进行更改时,代码将被重新编译,浏览器中的页面将被更新。
在我们的应用程序中,我们将实现拖放功能。我们将把可拖动的卡片移动到可放置的瓷砖中。我们首先定义一个瓷砖组件。
-
在
hello-clojurescript.core内部添加一个瓷砖的定义:(rum/defc tile [text number] [:div {:class "tile" :id number} text])在这里,一个瓷砖是一个
rum组件,基本上是一个 HTML 块。我们使用rum的defc方法定义一个组件。瓷砖接受两个参数:文本和一个数字。在其内部,一个允许我们设置元素数字参数属性的哈希用于设置瓷砖 div 的 ID。文本将在 div 元素内部显示。我们还为样式设置了"tile"类。 -
在
resources/public/css/styles.css内部添加一个 CSS 定义。CSS 代表层叠样式表。样式表允许我们为网页上的 HTML 元素设置样式。层叠意味着如果一个 HTML 元素有一些样式,那么这个元素内部的任何 HTML 元素都将继承相同的样式:.tile { border: 1px solid green; display: inline-block; height: 100px; width: 200px; }在这里,我们定义了一个具有实心绿色边框的瓷砖组件的样式。边框的宽度应为 1 像素,高度应为 100 像素,宽度应为 200 像素,组件是内联显示的,这意味着它与其他元素在同一行上。当我们渲染瓷砖时,它们将看起来如下:
![图 9.43:定义瓷砖组件]()
图 9.43:定义瓷砖组件
我们在同一行上有两个瓷砖。每个瓷砖都有一个绿色边框。
-
这种样式将帮助我们清楚地看到每个瓦片。我们有一个实心的绿色线来区分瓦片。我们将创建一个包含瓦片的组件:
(rum/defc tiles [] [:.tiles {} (tile "first" 1) (tile "second" 2)])我们创建了一个包含两个
tile组件的div组件。我们将它们标记为第一和第二。请注意,我们没有直接写div标签。当我们省略div标签并提供一个类或 ID 时,Rum 会隐式创建一个div元素。在这里,我们使用了类的简写符号,并给组件类命名为tiles。 -
我们想通知用户一个元素已经被放下。我们将在一个
atom中存储关于放下元素的信息:(defonce is-element-dropped? (atom false))当应用程序启动时,我们将值设置为
false。我们不希望 Figwheel 在重新加载页面时重新定义原子,所以我们只定义一次。 -
当一个元素被放下时,组件将显示信息:
(rum/defc dropped-message < rum/reactive [] [:div {} (str "Was element dropped? " (rum/react is-element-dropped?))])我们使用来自 Rum 的
reactive指令。这个指令指示 Rum,该组件将react到application state的变化。在我们的例子中,对is-element-dropped?原子的任何更改都会导致组件以新的值重新渲染。我们已经有三个组件了,但它们在网页浏览器中还未可见。我们需要编写代码将我们的组件挂载到页面上。我们将有一个顶层组件,它将包含我们应用程序的所有 HTML。 -
我们将把所有我们的组件放入一个
main组件中。这个main组件将包含我们可以拖拽的卡片和我们可以放下元素的瓦片:(rum/defc content [] [:div {} (tiles) (dropped-message)]) -
在定义了我们的
main组件之后,我们就准备好告诉 Rum 如何挂载这个组件:(defn mount [el] (rum/mount (content) el))我们告诉 Rum,我们想要挂载一个名为
content的组件。挂载点是具有 IDapp的元素。当我们检查网页浏览器时,我们可以看到应用的变化:![图 9.44:瓦片组件的变化
![图片 B14502_09_44.jpg]
![图 9.44:瓦片组件的变化
我们不必自己编译代码。所有这些都是由 Figwheel 完成的。在创建可拖拽元素之后,是时候创建我们可以拖拽的元素了。
-
我们将创建一个新的
rum组件——一个card:(rum/defc card [number] [:.card {:data-number number :id number}])卡片组件将接受一个参数,一个数字。这个参数在属性哈希中使用了两次。我们将使用这个数字为该组件设置一个 ID。我们还将设置一个带有数字的数据属性。
-
一旦我们有了卡片组件,我们可以创建一个可以包含多个卡片的组件:
(rum/defc cards [] [:.cards {} (card 1) (card 2)]) -
在这个例子中,我们创建了两个卡片。最后,我们需要将我们的卡片放置在某个地方。一个好的地方是我们的
main组件。现在它应该看起来像这样:(rum/defc content [] [:div {} (tiles) (cards) (dropped-message)]) -
主要内容由卡片和瓦片组成。即使我们访问了网页浏览器,我们也看不到任何卡片。我们必须添加一些样式:
.card { border: 1px solid red; display: inline-block; height: 50px; width: 50px; }在为卡片添加样式后,网页浏览器将显示新的内容:
![图 9.45:样式化卡片
![图片 B14502_09_45.jpg]
图 9.45:样式化卡片
除了两个大绿色瓦片外,我们还有两个小红色卡片。可拖拽和可放下元素现在已放置。我们现在可以实施拖拽和放下行为。
-
我们将为卡片添加拖动功能。我们将使用 jQuery UI 中的
draggable函数。将拖动卡片的代码添加到hello-clojurescript.core:(defn make-draggable [] (.draggable ($ ".card") (attrs {:revert true :cursor "move"}))) -
我们找到具有
card类的 HTML 元素,并使用我们在本节开头导入的jayq库中的$函数。$函数将创建一个 jQuery 对象。我们在该对象上调用draggable方法,并传递属性。属性是通过一个新的函数attrs构建的:(defn attrs [a] (clj->js (sablono.util/html-to-dom-attrs a)))attrs函数接受属性作为参数。我们使用sablono的html-to-dom-attrs函数将所有 HTML 属性转换为它们的 DOM 等效属性。我们从 HTML 属性转换为 DOM 属性,因为 jQuery 操作的是 DOM,而不是 HTML。 -
我们需要将
sablono库导入到hello-drag-and-drop.core命名空间中:(ns ^:figwheel-hooks hello-drag-and-drop.core (:require [goog.dom :as gdom] [jayq.core :as jayq :refer [$]] [rum.core :as rum] [sablono.util]))clj->js函数将递归地将 ClojureScript 值转换为 JavaScript。集合/向量/列表变为数组,关键字和符号变为字符串,映射变为对象。我们可以在 ClojureScript 中编码,当我们需要使用 JavaScript 构造时,clj->js将必要的结构从 ClojureScript 转换为 JavaScript。 -
最后一步是在
on-reload函数中调用make-draggable:(defn ^:after-load on-reload [] (mount-app-element) (make-draggable))Figwheel 将在网页浏览器中编译和重新加载代码。现在我们可以拖动红色卡片了:
![图 9.46:拖动红色卡片
![图片 B14502_09_46.jpg]()
图 9.46:拖动红色卡片
我们应用程序的最后一部分是实现瓷砖的放置行为。一个
tile应该接受一个被拖动的卡片。 -
对于放置行为,我们将使用 jQuery UI 库中的
droppable函数:(defn make-droppable [] (.droppable ($ (str ".tile")) (attrs {:hoverClass "hovered-tile" :drop handle-drop :activate start-dragging})))与
make-draggable函数类似,我们使用$函数使用tileCSS 类构建 jQuery 对象。接下来,我们调用 jQuery UI 库中的droppable函数,并将属性作为参数传递。 -
我们设置了两个属性。第一个是
:hoverClass,它的值是hovered-tile。这个属性允许我们在鼠标悬停时添加样式。在styles.css中添加以下声明:.hovered-tile { background-color: cornflowerblue; }当我们在拖动过程中悬停在瓷砖上时,其背景颜色将变为蓝色。
-
对于第二个属性
:drop,我们分配handle-drop函数:(defn handle-drop [event ui] (let [draggable-id (jayq/data (.-draggable ui) "number")] (println "Dropping element with id" draggable-id) (reset! is-element-dropped? true) (.draggable (.-draggable ui) "disable") (.droppable ($ (str "#" (.-id (.-target event)))) "disable") (.position (.-draggable ui) (attrs {:of ($ (str "#" (.-id (.-target event)))) :my "left top" :at "left top"}))))在
handle-drop函数内部,我们指定元素被放置时的行为。在函数中有几个操作。我们使用 JavaScript 互操作性中的.-(点号和连字符)访问ui元素上的draggable字段。这个字段被传递给jayq库中的data函数以访问data-numberHTML 属性。我们打印被拖动元素的 ID。我们重置原子,通知元素已被放置。我们禁用被拖动的元素。这将向元素添加ui-draggable-disabledCSS 类。我们禁用我们放置元素的元素,防止放置更多元素。最后,我们将放置元素的定位设置为可放置容器的左上角handle-drop函数是使用 JavaScript 互操作性的一个很好的例子。我们在 JavaScript 对象上调用函数,并访问这些对象中的字段。 -
可拖动小部件允许我们添加一个当元素正在拖动时被调用的函数:
(defn start-dragging [event ui] (reset! is-element-dropped? false))在我们的实现中,我们将
is-element-dropped?原子设置为false。 -
我们需要为我们掉落的元素添加样式。在
styles.css中添加以下声明:.card.ui-draggable-disabled { background-color: yellow; }这将设置掉落元素的背景颜色为黄色。
-
最后,我们将在
on-js-reload上调用make-droppable函数。它应该看起来如下:(defn ^:after-load on-reload [] (mount-app-element) (make-draggable) (make-droppable))当我们在网页浏览器中掉落一个元素时,我们将看到以下结果:
![图 9.47:掉落元素
![图片]()
图 9.47:掉落元素
在这个练习中,我们创建了一个拖放应用程序。我们使用了 JavaScript 互操作性来访问函数、对象和字段。
在编码这个应用程序时我们没有遇到任何问题。有时我们的应用程序不会按预期运行。在下一节中,我们将探讨 Clojure 中的异常和错误处理。
Clojure 中的异常和错误
在理想的世界里,每个程序都能在没有问题的情况下运行。在现实世界中,错误会发生,程序不会按计划运行。Java 和 Clojure 中的错误和异常是一种机制,用于在发生此类意外情况时通知开发者。
错误表示一个应用程序不应该尝试捕获或处理的严重问题。异常表示应用程序可能想要捕获的条件。换句话说,错误是应用程序无法从中恢复的情况。这些条件可能是磁盘空间或内存不足。如果一个应用程序在保存数据时耗尽磁盘空间,那么这个应用程序无法实现其目的。除非我们提供更多的磁盘空间,否则应用程序无法成功运行。异常是应用程序可以从中恢复的条件。这样的条件可能是尝试在建立数据库连接之前从数据库中访问列表,或者尝试在字符串上使用算术运算而不是数字。
错误和异常都是Throwable类的子类。这个类表示一个可以被抛出的 Java 对象。抛出意味着引发一个警告,如错误或异常。Java 提供了四种结构来处理错误和异常:
-
throw -
try -
catch -
finally
throw 允许开发者抛出一个异常。我们可以有一个接受用户输入,例如他们的年龄的 Web 应用程序。在这个应用程序中,我们可以在显示受年龄限制的内容之前检查用户的年龄。当我们对输入执行算术运算时,我们期望从用户那里得到一个数字。如果用户输入了一个字符串,则应用程序无法执行此类计算。在这种情况下抛出错误将提醒应用程序输入不正确。一旦我们抛出一个异常或错误,我们就可以使用剩下的三个 Java 构造来处理它们。
try 是 Java 中的一个保留字,允许开发者编写一个可能产生 Throwable 对象的代码块。这段代码位于 try 块内,并受到错误的保护。回到我们的应用程序,年龄检查将被放置在 try 块内。当我们遇到错误或异常时,我们可以处理它。第三个构造将帮助我们做到这一点。
catch 是 Java 中的一个保留字,允许开发者处理和解决异常和错误。当遇到指定的异常或错误时,catch 块下的代码块将被执行。在我们的例子中,当我们尝试操作一个年龄字符串(如数字)时,会抛出一个异常,并执行 catch 块。在这个块中,我们可以向用户返回一个消息,告诉他们需要输入数字。
finally 是 Java 中处理异常和错误的最后一个保留字。在 finally 下的代码块总是被执行。有些情况下,我们希望在遇到异常与否的情况下都执行代码。一个例子是 I/O 操作。如果文件不存在,打开文件可能会引发错误。如果文件存在,则不会抛出错误。打开文件使用计算机资源,如 RAM,我们希望在完成读取文件后释放这些资源。在读取 finally 块后关闭文件是一种常见的做法。它如此常见,以至于 Clojure 提供了我们在本章 I/O 部分看到的 with-open 宏。
try-catch-finally 块最常见的一个例子是读取或写入文件。在 try 块内,我们有一个读取或写入文件的操作。catch 块将防止 I/O 异常,如文件不存在。在 finally 块中,我们将有代码来关闭文件。关闭文件释放了计算机资源,以便其他任务使用。
以下表格展示了 Java 中最常见的异常和错误:

图 9.48:Java 中的常见异常和错误
在下一个练习中,我们将学习如何在 Clojure 中使用 throw、try、catch 和 finally。
练习 9.10:在 Clojure 中处理错误和异常
本练习的目的是学习如何在 Clojure 中处理异常和错误。在 Clojure 中,我们通常与 Java 的数据一起工作。在这个练习中,我们将创建一个函数,它接受一个 Java ArrayList实例和一个索引。ArrayList类类似于 Clojure 中的向量,我们在第二章,数据类型和不可变性中看到了它。ArrayList类存储数据。我们可以使用索引从ArrayList类中访问元素,就像我们在 Clojure 中的向量一样。从ArrayList中访问元素可能会引发异常。在设计我们的函数时,我们将处理抛出的异常:
-
打开终端并启动 REPL:
lein repl在打开 REPL 后,我们将定义一个
ArrayList实例。 -
我们将创建一个包含三个数字的
ArrayList:(def three-numbers-array (java.util.ArrayList. [0 1 2])) -
我们有一个包含三个数字的数组,从 0 到 2:
three-numbers-array输出如下:
[0 1 2]我们将创建一个函数,使我们能够访问数组的元素。
-
array-list-getter函数将使我们能够访问数组中的元素:(defn array-list-getter [array index] (.get array index))array-list-getter函数接受两个参数:一个数组和一个索引。我们使用传递的索引从数组中访问一个元素。 -
当我们访问数组中存在的元素时,我们会得到它:
(array-list-getter three-numbers-array 1)输出如下:
1我们想要获取索引为 1 的元素,并且成功获取到了。
-
当我们尝试访问一个不存在的元素时,Clojure 会抱怨:
(array-list-getter three-numbers-array 5)输出如下:
IndexOutOfBoundsException Index: 5, Size: 3 java.util.Arraylist.rangeCheck (ArrayList.java:657)我们的数组只有三个元素。当我们尝试访问索引为
5的元素时,Clojure 会引发IndexOutOfBoundsException。 -
我们可以捕获代码抛出的错误:
(defn array-list-getter [array index] (try (.get array index) (catch IndexOutOfBoundsException ex (str "No element at index " index)))) -
新的
array-list-getter定义捕获了IndexOutOfBoundsException:(array-list-getter three-numbers-array 5)输出如下:
"No element at index 5"在
catch块中,我们指定要捕获的错误或异常以及如何处理它。在这里,我们返回信息表明数组在传递的索引处没有元素。 -
如果我们的代码没有抛出异常,则
catch块不会执行:(array-list-getter three-numbers-array 1)输出如下:
1我们在索引
1处有一个元素。array-list-getter函数为我们返回这个数字。没有抛出异常。 -
finally块中的代码总是在try块完成之前执行。即使没有抛出异常,也会发生这种情况:(defn array-list-getter [array index] (try (.get array index) (catch IndexOutOfBoundsException ex (str "No element at index " index)) (finally (println "Login usage of array-list-getter")))) -
执行这段正确的代码会返回预期的结果,并打印出一条消息,表明此代码总是会被执行:
(array-list-getter three-numbers-array 1)输出如下:
Login usage of array-list-getter: 1我们可以看到,当代码没有抛出任何错误或异常时,只有
finally块被执行,而没有catch块。 -
当我们的代码将引发错误情况时,
catch和finally块将被执行:(array-list-getter three-numbers-array 5)这次,我们尝试访问一个不存在的元素。这段代码将引发异常并执行
finally块。在 REPL 中,我们看到两条消息。一条来自catch块,另一条来自finally块。在这个练习中,我们学习了错误和异常。Clojure 从 Java 中重新使用了这些构造。可以抛出错误或异常的代码由
try块保护。当抛出异常时,catch块中的代码将被执行。对于某些代码需要在不考虑抛出的异常的情况下运行的情况,使用finally块。
就像在 Java 中一样,错误在 JavaScript 中也会发生。在本章的最后部分,我们将学习 JavaScript 中的错误以及如何在 ClojureScript 中处理它们。
JavaScript 中的错误
在上一节中,我们学习了 Java 中的错误和异常以及如何在 Clojure 中处理它们。在 JavaScript 应用程序中导致问题的意外情况也会发生。这导致需要处理错误。JavaScript 不区分错误和异常,因此任何导致应用程序无法按预期运行的代码情况都是错误。
就像在 Java 中一样,在 JavaScript 中,我们有处理错误的工具。JavaScript 提供了四个构造:
-
throw -
try -
catch -
finally
它们与我们之前章节中看到的一样。JavaScript 重新使用了来自其他语言(如 Java)的错误处理概念。由于 JavaScript 不是 Java,我们在 ClojureScript 中处理错误的方式与 Clojure 中不完全相同。它非常接近,但直接从 Clojure 复制的代码在 ClojureScript 中不会立即工作。在下一个练习中,我们将看到如何在 ClojureScript 中处理 JavaScript 错误,并检查与 Clojure 中错误处理的微小语法差异。
ClojureScript Leiningen 模板
我们已经使用 Leiningen 为我们创建了项目。当我们创建一个新项目时,我们使用项目模板。模板开发者可以在互联网上发布项目模板,其他开发者(就像我们一样)可以使用这些模板来创建项目。
到目前为止,我们已经使用 Figwheel 创建了 ClojureScript 项目。正如我们所学的,Figwheel 为我们提供了大量的默认配置。一个新的 Figwheel 项目带有热代码重新加载、REPL 和测试等功能。
有时候我们不需要 Figwheel 提供的所有这些美好的东西。我们想要一个简单的 ClojureScript 设置。对于这种情况,我们可以使用 mies 项目模板。mies 模板为 ClojureScript 创建了一个基本的项目结构。
再次强调,对于大多数我们想要开发网站应用程序的情况,我们会使用 Figwheel。在罕见的情况下,当我们想要一个最小的 ClojureScript 项目设置时,我们将使用 mies。
练习 9.11:在 ClojureScript 中处理错误
这个练习的目的是学习 ClojureScript 如何处理 JavaScript 错误。在这个练习中,我们将编写一个函数来缩写编程语言名称。当一个编程语言不受支持时,我们将抛出一个错误来通知用户该语言不受支持:
-
创建项目:
lein new mies error-handling此命令将为我们创建一个新的项目。
-
我们将从命令行运行 ClojureScript REPL:
scripts/repl这启动了 REPL。
![图 9.49:REPL 的输出]()
图 9.49:REPL 的输出
当 REPL 启动时,我们可以调查 ClojureScript 中的错误处理。
-
在我们的代码中,我们将支持以下语言:
(def languages {:Clojure "CLJ" :ClojureScript "CLJS" :JavaScript "JS"}) -
我们将实现一个缩写编程语言名称的函数:
(defn language-abbreviator [language] (if-let [lang (get languages language)] lang (throw (js/Error. "Language not supported"))))函数将尝试从之前定义的语言哈希中获取语言的简短版本。
如果找不到语言,我们将抛出一个错误。ClojureScript 中
throw的语法与我们之前看到的 Clojure 语法非常相似。在这里,我们不是使用 Java 类,而是从js命名空间访问Error对象。 -
当一个函数用一个有效的参数调用时,它返回编程语言的缩写名:
(language-abbreviator :JavaScript)输出如下:
"JS"我们看到 JavaScript 的简称是
JS。 -
当我们用一个无效的参数调用函数时,它将抛出一个错误:
(language-abbreviator :Ruby)这将返回以下错误:
Execution error (Error) at (<cljs repl>:1) Language not supported我们看到 Ruby 不是一个受支持的语言,并且用 Ruby 作为参数调用
language-abbreviator函数会抛出一个错误。我们知道如何在 ClojureScript 中抛出错误。现在我们将看到如何捕获它们。 -
我们将创建一个返回“一周语言”的函数:
(defn get-language-of-the-week [languages] (let [lang-of-the-week (rand-nth languages)] (try (str "The language of the week is: " (language-abbreviator lang-of-the-week)) (catch js/Error e (str lang-of-the-week " is not a supported language")))))函数使用 Clojure 的
rand-nth函数从序列中随机选择一个元素。使用这种语言,我们尝试获取语言的缩写版本。如果语言不受支持并且抛出错误,我们将捕获错误并通知用户该语言不受支持。 -
使用不受支持的语言调用
get-language-of-the-week函数将导致错误:(get-language-of-the-week [:Ruby :Kotlin :Go])输出如下:
"Go is not a supported language"Go语言被选为一周的语言。遗憾的是,我们没有Go的缩写名。language-abbreviator函数抛出的错误被get-language-of-the-week函数中的catch块捕获。 -
我们将使用支持的语言调用
get-language-of-the-week函数:(get-language-of-the-week [:Clojure :JavaScript :ClojureScript])输出如下:
"The language of the week is: CLJS"当我们使用支持的语言调用
get-language-of-the-week函数时,我们得到所选语言的缩写名。 -
我们将扩展我们的“一周语言”函数以包括
finally块:(defn get-language-of-the-week [languages] (let [lang-of-the-week (rand-nth languages)] (try (str "The language of the week is: " (language-abbreviator lang-of-the-week)) (catch js/Error e (str lang-of-the-week " is not a supported language")) (finally (println lang-of-the-week "was chosen as the language of the week")))))使用
finally块,我们可以执行我们想要运行的任何代码,无论我们的代码中抛出什么错误。 -
我们将从支持的语言中选择一周的语言:
(get-language-of-the-week [:Clojure :JavaScript :ClojureScript])输出如下:
ClojureScript was chosen as the language of the week "The language of the week is: ClojureScript"ClojureScript 被选为一周的语言。
get-language-of-the-week函数返回所选语言的缩写名和finally块的消息。 -
我们将从不受支持的语言中选择“一周语言”:
(get-language-of-the-week [:Ruby :Kotlin :Go])输出如下:
:Kotlin was chosen as the language of the week ":Kotlin is not a supported language"Kotlin 被选为一周的语言。
get-language-of-the-week函数返回了两条消息:catch块中关于 Kotlin 不是受支持语言的提示和finally块中的消息。
我们刚刚看到了 ClojureScript 中 try-catch-finally 块的使用。使用这些结构将帮助我们编写能够处理许多意外情况的代码。
现在我们知道如何在 Clojure 和 ClojureScript 中处理异常。我们看到了如何使用 JavaScript 和 ClojureScript 之间的互操作性。是时候将我们的知识付诸实践了。我们将编写一个使用 JavaScript 互操作性的 ClojureScript 应用程序。
活动 9.02:创建支持台
本活动的目标是编写一个使用外部 JavaScript 库的 Web 应用程序。我们将创建一个支持台应用程序来管理在支持台中提出的问题。该应用程序允许我们对问题进行排序并在完成后解决它们。通过排序问题,我们可以提高单个问题的优先级。
应用程序将具有以下功能:
-
显示列表被排序的次数:
低于三次:很少次数
低于六次:中等次数
超过六次:多次
-
通过优先级过滤问题列表,例如仅显示优先级高于 3 的问题。
-
对问题列表进行排序。
-
解决一个问题。
以下步骤将帮助您完成活动:
-
创建一个新的项目。
-
在
project.clj中将jayq和cuerdas库作为依赖项添加。 -
创建用于按优先级过滤问题列表的
utils函数。 -
创建用于获取排序后问题列表的
utils函数。 -
创建用于按问题计数获取排序后消息的
utils函数。 -
创建用于从列表中删除问题的
utils函数。 -
创建在排序完成后被调用的
utils函数。 -
在
index.html中添加 jQuery 和 jQuery UI。 -
将
jayq、cuerdas和utils导入核心命名空间。 -
定义优先级列表。
-
定义应用程序状态。
-
定义计数器 Rum 组件。
-
在点击函数上创建问题。
-
定义问题项 Rum 组件。
-
定义反应性问题组件。
-
定义反应性页面内容组件。
-
使项目组件可排序。
-
挂载页面组件。
-
调用
mount函数。 -
调用可排序函数。
-
运行应用程序。
初始问题列表将如下所示:

图 9.50:初始问题列表
排序后的问题列表将如下所示:

图 9.51:排序后的问题列表
解决三个问题后的问题列表将如下所示:

图 9.52:解决问题后的问题列表
注意
本活动的解决方案可在第 718 页找到。
摘要
在本章中,我们学习了 Clojure 和 Java 的互操作性。我们看到了如何在 Clojure 中导入 Java 类。我们构造了 Java 类的实例并在这些实例上调用方法。我们还学习了帮助我们在 Clojure 中使用 Java 的宏。
接下来,我们学习了 Java 中的输入/输出(I/O)操作。我们从磁盘访问文件,进行读取和写入内容。我们看到了如何使用键盘从用户那里获取输入,以及如何将信息显示回用户。
之后,我们学习了 ClojureScript 中的互操作性。我们创建了一个使用 JavaScript 库中的对象和方法进行拖放操作的应用程序。
最后,我们学习了 Clojure 和 ClojureScript 中的异常和错误。我们看到了错误是如何抛出的,以及如何使用 try-catch 块来防范错误。我们还研究了 finally 块及其使用时机。
我们通过开发一个帮助台应用程序来结束本章的学习,该应用程序允许用户按优先级排序项目列表。
在下一章中,我们将探讨 Clojure 和 ClojureScript 中的测试。我们将了解为什么测试很重要,这两种语言提供了哪些测试库,以及如何在 Clojure 和 ClojureScript 中使用测试库。
第十章:10. 测试
概述
在本章中,我们将探讨 Clojure 中的测试。我们首先了解不同类型的测试。然后,我们探索最常用的单元测试库,以便测试我们的 Clojure 函数。我们学习如何进行测试驱动开发。我们深入研究基于属性的测试,这有助于我们生成大量的测试数据。然后,我们学习如何将测试集成到 Clojure 和 ClojureScript 项目中。
到本章结束时,你将能够使用各自的标准化测试库在 Clojure 和 ClojureScript 中测试程序。
简介
在上一章中,我们学习了 Clojure 中的宿主平台互操作性(inter-op)。我们探讨了如何在 Clojure 中使用 Java 代码和在 ClojureScript 中使用 JavaScript。在我们的互操作性冒险中,我们创建了一个咖啡订购应用程序。该应用程序具有各种功能,例如显示带有咖啡选择的菜单和订购咖啡。我们运行了代码,并看到了应用程序正在运行。现在是学习 Clojure 中测试的时候了。
Clojure 从一开始就被设计成一个非常实用的语言。完成任务意味着与外部世界互动,构建项目,使用库,并部署你的工作。我们需要确信我们编写的代码确实做了它应该做的事情。作为一名开发者,你需要测试你的应用程序。在本章中,我们将了解可以使用哪些类型的测试。我们将查看单元测试,因为它们是开发者编写的最常见类型的测试。
考虑这样一个情况,我们有一个机票订购应用程序。这个应用程序允许用户搜索航班并预订航班。其一个功能是搜索航班。用户应该能够输入搜索日期。结束日期应该在开始日期之后——在我们飞出之前就返回是没有太多意义的。测试使我们能够确保处理开始和结束日期的代码是有序的。
同样,我们希望确保当许多客户进入我们的网站时,它不会变慢。用户体验元素,如网站速度,在软件中也会进行测试。
第一步是了解为什么测试很重要,以及 Clojure 和 ClojureScript 中可以执行哪些类型的测试。然后,我们将探讨 Clojure 和 ClojureScript 中的测试库。最后,我们将探讨一种特殊的测试类型,称为生成测试,以及它是如何帮助开发者编写测试的。
为什么测试很重要
在本章的开头,我们看到了软件测试的重要性。为什么?为了回答这个问题,我们需要了解什么是软件测试。它可以定义为确保特定软件无错误的过程。软件错误是导致程序崩溃或产生无效输出的问题。在第九章,Java 和 JavaScript 与宿主平台互操作性中,我们学习了 Clojure 和 ClojureScript 中的错误。测试是一个逐步的过程,确保软件通过客户或行业设定的预期性能标准。这些步骤还可以帮助识别错误、差距或缺失的需求。错误、错误和缺陷是同义词。它们都意味着我们的软件存在问题。
软件测试的好处如下:
-
提供具有低维护成本的高质量产品
-
确保产品的准确性和一致性
-
发现开发阶段未被识别的错误
-
检查应用程序是否产生预期的输出
-
帮助我们了解客户对产品的满意度
根据我们观察软件的角度,存在许多软件测试方法。最常见的区别是功能测试和非功能性测试。现在我们将讨论什么使测试成为功能性的或非功能性的,以及何时使用一种类型或另一种类型是合适的。
功能性测试
功能性测试试图捕捉正在测试的软件的功能性需求。需求来自软件的规范。
以机票订购应用程序为例,它允许用户购买航班。如前所述,其功能之一是搜索航班。用户可能会使用不同的标准进行搜索。一个标准可能是搜索直飞航班。功能性测试将确保当用户搜索直飞航班时,他们不会看到转机航班。
通常,功能性测试包括以下步骤:
-
根据需求规范文档确定软件组件具有哪些功能和特性
-
根据需求创建输入数据
-
确定预期的输出
-
执行测试
-
将预期结果与实际输出进行比较
虽然功能性测试有优势,但有些测试领域并未被功能性测试覆盖。在这种情况下,将执行所谓的非功能性测试。
非功能性测试
非功能性测试检查与功能性需求不直接相关的事项。换句话说,非功能性测试关注的是软件作为一个整体的操作方式,而不是软件或其组件的具体行为。
在非功能性测试中,我们关注的是诸如安全性、系统在各种负载条件下的行为、是否用户友好以及是否提供本地化以在不同国家运行等问题。
再次考虑机票订购应用程序。此应用程序允许用户购买机票。用户应该能够使用信用卡支付。应用程序应该安全地处理支付。这意味着交易应该被加密。加密是将消息或信息编码成只有授权方才能访问,而未授权方无法访问的过程。未授权的人不应该能够看到交易详情。
对于机票订购应用程序的另一个非功能性测试将是负载测试。通过负载测试,我们将测试我们的应用程序能否处理非常高的页面负载。在节日期间,许多客户将访问我们的网站。我们需要确保数千名用户可以同时使用该应用程序。应用程序应该响应迅速,当许多客户使用时不会变慢。
功能性测试确保我们的应用程序是安全的。虽然我们已经分别讨论了功能性和非功能性测试,但它们不应被视为对立的测试方法,而应被视为互补的方法。它们通常一起执行,以确保软件具有高标准的质量并能在各种条件下运行。
在软件中进行测试和捕捉错误并非免费。这需要开发人员和测试人员的时间和资源。话虽如此,在开发后期修复错误比在开发早期捕捉它们要昂贵得多。单元测试使我们能够早期捕捉许多错误,同时不会从开发人员那里消耗太多资源。
在下一个主题中,我们将探讨单元测试是什么,以及 Clojure 中最受欢迎的单元测试框架。
Clojure 单元测试
单元测试是对单个软件组件或模块的测试。在 Clojure 中,一个函数是单元测试的良好候选者。函数旨在一次执行一个任务。否则,一个任务中的逻辑变化会影响第二个任务。当一个函数只有一个责任时,我们比它执行多个任务时更容易对函数进行推理。Clojure 为单元测试提供了一系列测试框架。当我们使用测试库时,我们通常称它们为框架。框架是一个支持和包围测试的结构。使用测试框架,我们支持对代码进行测试。我们的代码被为我们的代码编写的多个测试所包围。
测试中有许多概念,以下是其中两个:
-
断言:一个布尔(true 或 false)表达式。断言是对我们程序特定部分的一个声明,它将是 true 或 false。例如,我们可以声明,当我们用一个字符串而不是一个数字作为参数传递给函数时,我们的程序中的函数将抛出一个错误。断言将是:这个函数会抛出错误吗?答案是是(true)或否(false)。
-
存根:代码或概念的一部分的临时替换。存根模拟被替换的软件组件的行为。在航班票订购应用程序中,我们可能有一个支付组件,它接受卡详情并与银行联系以提取机票费用。在通过银行完成支付后,我们会显示票务详情。存根将模拟与银行联系,但实际上并不与银行联系。当我们使用存根时,我们可以专注于测试显示票务详情,而不需要处理与银行联系和所有卡交易。这使测试专注于单一任务,在本例中,是通过银行支付后显示票务。
clojure.test框架是 Clojure 标准库中提供的默认 Clojure 单元测试框架。clojure.test的目的是为开发者提供一系列测试函数。在我们的第一个练习中,我们将使用 clojure.test 库为前一章中的咖啡应用程序编写单元测试。
练习 10.01:使用 clojure.test 库进行单元测试
本练习的目的是学习如何使用clojure.test库进行单元测试。这是 Clojure 的默认测试库。由于 Clojure 已经包含了这个库,因此我们不需要将其作为外部依赖项导入。在前一章中,我们创建了一个允许我们显示咖啡菜单和订购咖啡的咖啡订购应用程序。在本练习中,我们将为咖啡订购应用程序中创建的函数编写单元测试。
首先,我们将创建应用程序,然后我们将编写测试:
-
创建咖啡订购应用程序:
lein new app coffee-appLeiningen 为我们创建了项目。默认情况下,我们有一个名为
core.clj的源文件。在这个文件中,我们将添加负责显示菜单选项和处理它们的代码。 -
在
core命名空间中导入java.util.Scanner类:(ns coffee-app.core (:require [coffee-app.utils :as utils]) (:import [java.util Scanner]) (:gen-class))我们已经导入了
Scanner类。这个类允许我们从键盘获取输入。为了使用Scanner类的方法,我们需要创建这个类的一个实例。 -
创建
Scanner类的一个实例。当我们想要从用户那里获取输入时,我们将在这个类实例上调用方法。
Scanner类需要知道输入的来源。在我们的情况下,我们使用System类的默认in源——键盘。(def input (Scanner. System/in))当用户运行应用程序时,他们应该看到一个带有选项的菜单。这些选项包括显示和订购咖啡、列出订单以及退出应用程序。
![图 10.1:显示所有选项的应用程序菜单
![图片]()
图 10.1:显示所有选项的应用菜单
-
添加显示菜单和处理用户选择的代码:
(defn- start-app [] "Displaying main menu and processing user choices." (let [run-application (ref true)] (while (deref run-application) (println "\n| Coffee app |") (println "| 1-Menu 2-Orders 3-Exit |\n") (let [choice (.nextInt input)] (case choice 1 (show-menu) 2 (show-orders) 3 (dosync (ref-set run-application false))))))) -
在
start-app函数中,我们将应用设置为默认运行状态:run-application (ref true) (while (deref run-application)为了获取
run-application中存储的值,我们使用deref函数。 -
在
while块内部,应用将一直运行,直到用户选择退出选项。在这种情况下,我们将更新ref的值:(dosync (ref-set run-application false)) -
更新后,
ref不再为真,而是为假。当ref的值为假时,while块将停止,我们的应用将退出。当我们的应用运行时,用户可以从菜单中选择选项:(println "| 1-Menu 2-Orders 3-Exit |\n")这将显示以下菜单:
![图 10.2:咖啡订购应用的菜单
![图片]()
图 10.2:咖啡订购应用的菜单
我们能够显示初始菜单。我们可以着手处理用户从菜单中的选择。
-
为了获取用户响应,我们调用
Scanner实例的nextInt方法:choice (.nextInt input)最后,一旦我们获取了用户输入,我们就检查菜单中的哪个选项应该被执行:
(case choice 1 (show-menu) 2 (show-orders))当我们启动应用时,我们就知道了主应用菜单中的逻辑。现在是时候深入挖掘并查看
show-menu函数的代码了。 -
显示可用的咖啡菜单:
(defn- show-menu [] (println "| Available coffees |") (println "|1\. Latte 2.Mocha |") (let [choice (.nextInt input)] (case choice 1 (buy-coffee :latte) 2 (buy-coffee :mocha))))在
show-menu函数中,我们让用户了解两种可用的咖啡——Latte和Mocha:(println "| Available coffees |") (println "|1\. Latte 2.Mocha |")这将显示咖啡菜单:
![图 10.3:咖啡菜单显示
![图片]()
图 10.3:咖啡菜单显示
用户可以选择数字
1或2。我们现在需要响应用户的咖啡选择。 -
我们使用
Scanner实例来获取用户输入:choice (.nextInt input) -
最后,我们继续购买用户选择的咖啡:
(case choice 1 (buy-coffee :latte) 2 (buy-coffee :mocha))show-menu函数并不长。它的目的是显示可用的咖啡并获取用户输入。一旦用户选择了他们的咖啡,我们就调用buy-coffee函数来处理所选咖啡的购买。 -
询问用户他们想要购买多少杯咖啡:
(defn- buy-coffee [type] (println "How many coffees do you want to buy?") (let [choice (.nextInt input) price (utils/calculate-coffee-price price-menu type choice)] (utils/display-bought-coffee-message type choice price)))buy-coffee函数询问用户想要购买多少杯咖啡。同样,我们使用Scanner类的实例input来获取用户的选择。接下来,函数调用两个实用函数来处理购买。这些函数负责计算咖啡价格并向用户显示反馈信息。所有函数都将放置在
utils.clj文件中。将所有函数放在一个大的文件中不是好习惯,将函数拆分到各种命名空间中是更好的做法。一个常见的命名空间名称是utils。我们可以将任何有用的操作数据的函数保存在那里。 -
创建一个
utils命名空间:(ns coffee-app.utils -
计算咖啡价格:
(defn calculate-coffee-price [coffees coffee-type number] (-> (get coffees coffee-type) (* number) float))我们的第一个实用函数计算咖啡价格。它使用
get函数来检查传入的咖啡类型在coffees哈希中。该哈希定义在核心命名空间中:(ns coffee-app.core (:require [coffee-app.utils :as utils]) (:import [java.util Scanner]) (:gen-class)) (def ^:const price-menu {:latte 0.5 :mocha 0.4})从哈希中获得的值然后乘以用户订购的咖啡杯数。最后,我们将数字强制转换为浮点数。这允许我们将 1.2000000000000002 这样的数字转换为 1.2。
当我们处理购买咖啡时使用的最后一个实用函数是
display-bought-coffee-message函数。 -
在购买咖啡后向用户显示消息:
(ns coffee-app.utils) (defn display-bought-coffee-message [type number total] (println "Buying" number (name type) "coffees for total:€" total))display-bought-coffee-message函数接收订单映射并基于映射中的数据为用户构建一个字符串消息。用户被告知他们以指定价格购买了特定数量的咖啡杯。使用此函数,我们可以在用户完成订单后控制传递给用户的信息:
![图 10.4:显示购买咖啡的消息]()
图 10.4:显示购买咖啡的消息
主菜单中的第二个选项允许我们查看已放置的订单:
![图 10.5:订单允许用户查看他们的订单]()
图 10.5:订单允许用户查看他们的订单
负责显示订单的函数是来自
coffee-app.core命名空间的show-orders函数。 -
显示已放置的订单:
(ns coffee-app.core) (defn- show-orders [] (println "\n") (println "Display orders here"))此函数将显示所下的咖啡订单。在这个练习中,我们通知用户订单将在这里显示。在接下来的练习中,我们将实现保存和显示订单:
Display orders here当我们运行应用程序并购买两杯拿铁时,我们将看到以下输出:
![图 10.6:用户购买两杯咖啡时的输出]()
图 10.6:用户购买两杯咖啡时的输出
-
添加主函数如下:
(defn -main "Main function calling app." [& args] (start-app)) -
为了运行应用程序,我们将使用以下命令:
lein run一旦运行应用程序,我们就可以看到可用的咖啡并下订单,就像我们在图 10.6中看到的那样。
我们的应用程序已成功运行。我们现在将为我们的应用程序创建测试。
-
检查测试目录。我们使用
tree命令显示测试目录中的文件夹和文件列表:tree test当我们创建应用程序时,Leiningen 为我们创建了
test目录。有几种方法可以检查项目结构。我们使用前面的tree命令来检查项目结构。![图 10.7:项目结构]()
(ns coffee-app.core-test (:require [clojure.test :refer :all] [coffee-app.core :refer :all]))此文件导入了 Clojure 测试命名空间,以及源目录中的核心文件。该文件包含一个测试方法。此方法称为
a-test。因为我们已自动生成了a-test测试函数,所以我们可以在创建 Leiningen 项目后运行测试:(deftest a-test (testing "FIXME, I fail." (is (= 0 1))))当我们使用 Leiningen 创建新项目时,它将创建一个测试函数。此函数称为
a-test,位于core_test.clj文件中。 -
为了运行测试,我们需要调用 Leiningen 的
test任务。test任务是一个将在测试目录中运行测试的任务:lein test输出如下:
![图 10.8:运行测试任务]()
图 10.8:运行测试任务
a-test测试失败,因为我们还没有在core_test.clj文件中实现a-test测试。Leiningen 通知我们它测试了coffee-app.core-test命名空间。我们有信息表明测试失败,包括测试文件中的哪一行(第 7 行)导致了测试失败。Leiningen 甚至提供了关于测试期望的结果和实际结果的信息。在这种情况下,默认测试尝试比较数字一和零。为了使测试通过,让我们更改
a-test函数。 -
为了修复 Leiningen 项目的默认
test函数,我们将更改我们刚刚看到的默认a-test函数的实现:(deftest a-test (testing "FIXME, I fail." (is (= 1 1))))我们更改了测试,使其表明 1 等于 1。这将使我们的
a-test通过。 -
我们使用以下方式运行测试:
lein test我们可以再次运行测试:
![图 10.9:修复默认的 a-test 函数后的测试运行
![图片]()
图 10.9:修复默认的 a-test 函数后的测试运行
这次,Leiningen 通知我们它运行了一个测试,包含一个断言(测试条件)。没有失败和错误。我们现在知道如何运行测试。是时候为
utils命名空间编写测试了。我们将为utils命名空间创建一个测试文件。 -
为
utils命名空间创建一个测试文件。在文件中,我们将编写代码来测试utils命名空间中的函数:touch test/coffee_app/utils_test.clj在创建
utils_test.clj之后,我们将有两个测试文件:![图 10.10:创建 utils_test.clj 后,我们现在有两个测试文件
![图片]()
图 10.10:创建 utils_test.clj 后,我们现在有两个测试文件
在
utils_test.clj中,我们想要测试utils命名空间中的函数。我们将向测试命名空间添加必要的依赖。在core_test.clj中,我们将保留在core.clj文件中定义的函数的测试。utils_test.clj文件将包含在utils.clj文件中定义的函数的测试。 -
我们将从源目录中导入我们将要测试的
clojure.test库和命名空间:(ns coffee-app.utils-test (:require [clojure.test :refer [are is deftest testing]] [coffee-app.core :refer [price-menu]] [coffee-app.utils :refer :all]))clojure.test命名空间有几个测试函数。我们使用:refer关键字导入它们,我们在第八章“命名空间、库和 Leiningen”中学习了它。我们导入了四个函数:are: 允许您测试多个测试场景is: 允许您测试单个测试场景deftest: 定义 Clojure 测试testing: 定义一个将被测试的表达式我们从源目录中导入
coffee-app.core和coffee-app.utils命名空间。从core命名空间中,我们导入price-menu,其中包含可用的咖啡列表以及每款咖啡的价格。最后,我们导入utils命名空间,其中包含我们想要测试的函数。 -
clojure.test对象提供了用于测试的is宏。我们将在第十一章 宏 中学习关于宏的内容。为了这个练习,你可以将宏视为特殊函数。宏的使用方式与函数相同。is宏接受一个测试和一个可选的断言消息。将以下代码添加到utils_test.clj:(deftest calculate-coffee-price-test-with-single-is (testing "Single test with is macro." (is (= (calculate-coffee-price price-menu :latte 1) 0.5))))deftest宏允许我们定义测试。每个测试都是使用testing宏定义的。testing宏可以提供一个字符串来提供测试上下文。在这里,我们通知你这是一个使用is宏的单个测试。在这个测试中,我们调用calculate-coffee-price函数,传递price-menu,它包含有关可用咖啡的信息。我们传递的第二个参数是我们想要购买咖啡杯数。在我们的例子中,我们想要购买一杯。对于测试,调用
calculate-coffee-price函数得到一杯拿铁的结果应该是 0.5。我们现在将运行测试:
lein test输出如下:
![图 10.11:使用 is 宏运行测试后的结果
![图片 B14502_10_11.jpg]()
图 10.11:使用 is 宏运行测试后的结果
我们可以看到新添加的测试通过了。
-
虽然我们可以使用
is宏编写测试,但使用is宏多次测试会导致代码的不必要重复。考虑下一个测试,其中我们运行了三个场景:购买一杯咖啡——用户决定购买一杯咖啡
购买两杯咖啡——用户决定购买两杯咖啡
购买三杯咖啡——用户决定购买三杯咖啡
(deftest calculate-coffee-price-test-with-multiple-is (testing "Multiple tests with is macro." (is (= (calculate-coffee-price price-menu :latte 1) 0.5)) (is (= (calculate-coffee-price price-menu :latte 2) 1.0)) (is (= (calculate-coffee-price price-menu :latte 3) 1.5))))在
calculate-coffee-price-test-with-multiple-is测试内部,我们使用了三个使用is宏的单个测试。我们测试了三种不同的场景:购买一杯咖啡、购买两杯咖啡和购买三杯咖啡。 -
运行多个
is测试。我们运行calculate-coffee-price-test-with-multiple-is测试:lein test输出如下:
![图 10.12:运行多个 is 测试
![图片 B14502_10_12.jpg]()
图 10.12:运行多个 is 测试
新测试已运行并通过。在先前的代码中,我们可以看到我们重复调用了多次
calculate-coffee-price函数。应该有更高效的方式来编写针对多个场景的测试。 -
当我们计划使用
is宏编写多个测试时,are宏是一个便利的宏。are宏是一个用于测试多个测试场景的测试宏。它在许多测试场景中与is宏不同。is宏允许我们测试一个场景。它是单数的。are宏允许我们测试多个场景。它是复数的。当我们想要测试单个场景时,我们使用is宏;当我们想要测试多个场景时,我们使用are宏。之前的多个is宏调用测试可以重写为:(deftest calculate-coffee-price-test-with-are (testing "Multiple tests with are macro" (are [coffees-hash coffee-type number-of-cups result] (= (calculate-coffee-price coffees-hash coffee-type number-of-cups) result) price-menu :latte 1 0.5 price-menu :latte 2 1.0 price-menu :latte 3 1.5)))are宏会对我们编写的断言进行多个测试。在先前的测试中,我们编写了一个断言:
(= (calculate-coffee-price coffees-hash coffee-type number-of-cups) result)使用
coffees-hash coffee-type number-of-cups调用calculate-coffee-price的结果应该等于预期结果。在向量中,我们指定了四个我们需要运行测试的参数:
price-menu :latte 1 0.5参数包括关于咖啡的信息、咖啡类型、杯数和结果——计算咖啡价格的结果。
再次,我们使用
equals(=)函数来检查调用calculate-coffee-price函数的结果与预期结果是否一致。 -
当我们再次运行测试时,我们得到以下结果:
lein test输出如下:
![图 10.13:使用 are 宏后的测试输出]()
图 10.13:使用 are 宏后的测试输出
我们的新测试通过了。我们使用了are宏来简化多个测试断言的编写。每次我们需要使用is宏编写多个测试时,使用are宏将使我们的代码更短、更易读。
在这个练习中,我们看到了如何使用clojure.test库编写测试。在下一个练习中,我们将查看另一个用于测试的 Clojure 库。
使用 Expectations 测试库
Expectations库中的主要哲学是围绕一个预期。expectation对象是基于单元测试应该包含一个断言每个测试的想法构建的。这种设计选择的结果是,预期具有非常简洁的语法,并减少了执行测试所需的代码量。
最简语法有助于保持代码,因为它更容易阅读和推理简短且专注于测试一个功能的代码。另一个好处与测试失败的代码相关。当测试失败时,很容易检查哪个测试失败了以及为什么,因为测试专注于一个功能而不是多个功能。
Expectations库允许我们测试以下内容:
-
代码抛出的错误:我们可以测试我们的代码的一部分是否抛出错误。想象一个计算折扣的函数。这个函数接受数字作为输入并相乘。如果我们传递一个如"
text"的字符串和一个数字5,我们将得到一个错误,因为 Clojure 不能将数字和字符串相乘。我们可以编写测试来检查在这种情况下是否抛出错误。 -
函数的返回值:我们可以测试函数是否返回预期的值。想象一个计算折扣的函数。这个函数接受数字作为输入并相乘。在乘法之后,它应该返回一个数字。我们可以编写测试来检查我们的函数是否返回一个数字而不是集合或字符串。
-
集合中的元素:我们可以编写测试来检查集合是否包含预期的元素。想象一个检查用户列表中是否有儿童的函数。这个函数接受用户列表作为输入。我们可以编写测试来检查用户的年龄。
为了使用Expectations,我们需要将其导入 Leiningen 项目:
-
我们添加了对
expectations库的依赖项[expectations "2.1.10"`]。 -
lein-expectations是一个 Leiningen 插件,它从命令行运行 expectations 测试[lein-expectations "0.0.8"]。
我们将为calculate-coffee-price函数编写测试。这将使我们能够比较在Expectations库中编写的测试与使用clojure.test库编写的测试。
练习 10.02:使用 Expectations 测试咖啡应用程序
本练习的目的是学习如何使用Expectations库在 Clojure 中编写单元测试。我们将为calculate-coffee-price函数编写测试:
-
将
expectations添加到project.clj文件中。在将Expectations库添加到project.clj后,文件应如下所示:(defproject coffee-app "0.1.0-SNAPSHOT" ;;; code omitted :dependencies [[org.clojure/clojure "1.10.0"] [expectations "2.1.10"]] :plugins [[lein-expectations "0.0.8"]] ;;; code omitted ) -
为
utils测试创建一个文件。为了使用 Expectations 库,我们首先需要导入函数。
utils命名空间应如下所示:(ns coffee-app.utils-test (:require [coffee-app.core :refer [price-menu]] [coffee-app.utils :refer :all] [expectations :refer [expect in]])) -
测试
calculate-coffee-price函数。购买三杯拿铁应该花费我们 1.5 元。以下测试将检查这个条件:(expect 1.5 (calculate-coffee-price price-menu :latte 3))我们已经准备好运行测试。
-
使用 Leiningen 任务运行
expectations测试。为了在命令行上运行测试,我们需要使用来自lein-expectations插件的 Leiningen 任务:lein expectations此任务将执行
expectations测试。![图 10.14:运行 expectations 测试后的输出]()
图 10.14:运行 expectations 测试后的输出
如我们所料,对于三杯拿铁,我们需要支付 1.5 元。如果我们传递一个字符串而不是数字作为杯数会发生什么?我们预计会出错。使用
expectations,我们可以测试错误。 -
expectations库允许我们测试函数是否抛出错误。calculate-coffee-price函数需要一个数字。传递一个字符串应该导致错误:(expect ClassCastException (calculate-coffee-price price-menu :latte "1"))输出如下:
![图 10.15:使用 Expectations 库测试 calculate-coffee-price 函数]()
图 10.15:使用 Expectations 库测试 calculate-coffee-price 函数
运行测试后,我们看到所有测试都通过了。测试并不总是通过。使用
expectations,当测试失败时,我们会得到通知。 -
当我们运行一个失败的测试时,
Expectations会通知我们。当错误未抛出时测试错误将失败:(expect ClassCastException (calculate-coffee-price price-menu :latte 2))输出如下:
![图 10.16:使用 Expectations 库运行失败的测试]()
图 10.16:使用 Expectations 库运行失败的测试
Expectations库通知我们有一个测试失败了。我们还知道在哪个命名空间有一个失败的测试以及哪一行代码导致了测试失败。这使我们能够快速找到失败的测试。我们知道将字符串传递给
calculate-coffee-price将导致错误。使用 Expectations,我们还可以检查函数的返回类型。 -
在 Clojure 代码中,我们经常组合函数。一个函数作用于其他函数运行的结果。检查我们调用的函数返回我们期望的类型值是很常见的。使用
Expectations,我们可以检查函数的返回类型:(expect Number (calculate-coffee-price price-menu :latte 2))我们期望
calculate-coffee-price将返回一个数字:![图 10.17:使用 Expectations]
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_17.jpg)
图 10.17:使用 Expectations
运行测试确认,该数字是
calculate-coffee-price函数正确的返回类型。使用Expectations,我们还可以测试一个集合是否包含请求的元素。 -
price-menu哈希表包含有关可用咖啡的信息,例如类型和价格。使用Expectations,我们可以测试元素是否属于一个集合:(expect {:latte 0.5} (in price-menu))我们期望在菜单上我们有拿铁,并且它的价格是
0.5。![图 10.18:测试拿铁是否属于菜单]
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_18.jpg)
图 10.18:测试拿铁是否属于菜单
如预期的那样,在我们的菜单上,我们有拿铁。我们现在知道了 Clojure 中的两个测试库:clojure.test和 Expectations。我们将要学习的第三个测试库是Midje。
使用 Midje 库进行单元测试
Midje是 Clojure 中的一个测试库,它鼓励编写可读的测试。Midje建立在clojure.test提供的自底向上测试之上,并增加了对自顶向下测试的支持。自底向上测试意味着我们首先为单个函数编写测试。如果这个函数被其他函数使用,我们将在完成其他函数的实现后编写测试。
在咖啡订购应用程序中,我们有load-orders函数:
(defn load-orders
"Reads a sequence of orders in file at path."
[file]
(if (file-exists? file)
(with-open [r (PushbackReader. (io/reader file))]
(binding [*read-eval* false]
(doall (take-while #(not= ::EOF %) (repeatedly #(read-one-order r))))))
[]))
load-orders函数使用了file-exists?函数。Clojure 中的函数不应执行许多操作。良好的实践是编写专注于单个任务的短小函数。file-exist函数检查文件。load-orders函数加载订单。因为我们不能从不存在的文件中加载订单,所以我们需要使用file-exist函数来检查保存订单的文件:
(defn file-exists? [location]
(.exists (io/as-file location)))
在自底向上的测试中,我们首先需要编写file-exists的实现。在我们有了file-exist的工作实现之后,然后我们可以编写load-orders的实现。这种编写测试的方式迫使我们思考所有函数的实现细节,而不是专注于我们想要实现的功能。我们的原始目标是加载文件中的数据,但现在我们专注于检查文件是否存在。
使用自上而下的方法,我们可以在不实现被测试函数使用的函数的情况下编写针对主测试函数的工作测试。我们声明我们想要测试load-orders函数,并且它使用file-exist函数,但我们不需要有一个完整的file-exist实现。我们只需要声明我们将使用此函数。这允许我们专注于我们想要测试的功能,而不用担心实现所有子步骤。
为了使用Midje,将其作为依赖项([midje "1.9.4"])添加到project.clj文件中。
练习 10.03:使用 Midje 测试咖啡应用
本练习的目的是学习如何使用Midje库并编写自上而下的测试。我们将为calculate-coffee-price编写测试。我们将使用自上而下的方法为load-orders函数编写测试:
-
我们将导入
Midje命名空间到utils命名空间:(ns coffee-app.utils-test (:require [coffee-app.core :refer [price-menu]] [coffee-app.utils :refer :all] [midje.sweet :refer [=> fact provided unfinished]]))在导入
Midje命名空间后,我们就可以使用Midje命名空间中的fact宏了。 -
Midje使用fact宏,该宏声明了关于我们测试未来版本的某些事实。宏在=>符号的两侧都接受一个参数。fact宏声明左侧的结果应该在符号的右侧预期:(fact (calculate-coffee-price price-menu :latte 3) => 3)我们编写了一个测试,预期三杯拿铁的价格是
3。Midje支持在 REPL 中进行自动测试。 -
使用自动测试,我们不需要每次更改时都运行测试。当自动测试器检测到更改时,它会自动运行测试。为了在
Midje中使用自动测试,我们在 REPL 中启用自动测试:lein repl -
在启动 REPL 后,我们导入
Midje命名空间并启用自动测试器:user=> (use 'midje.repl) user=> (autotest)在启动 REPL 后,我们导入了
Midje命名空间。第二步是调用
autotest函数。当我们的代码发生变化时,此函数将自动运行测试。启用自动测试后,我们的测试是通过我们在 REPL 中使用的
autotest函数运行的:![图 10.19:执行测试]()
图 10.19:执行测试
-
Midje告诉我们测试失败了。三杯拿铁的价格不是3,而是1.5。当我们更改测试的实现时,自动测试再次运行:(fact (calculate-coffee-price price-menu :latte 3) => 1.5)自动测试的运行方式如下:
![图 10.20:更改实现后运行自动测试]()
图 10.20:更改实现后运行自动测试
这次,我们被告知测试通过。现在我们知道了如何运行自动测试以及如何使用
Midje编写测试。现在是时候探索Midje中的自上而下测试了。 -
在
utils命名空间中,我们有一个display-bought-coffee-message函数,该函数显示购买咖啡类型的数量信息。此函数有一个硬编码的货币符号:(defn display-bought-coffee-message [type number total] (str "Buying" number (name type) "coffees for total:€" total))从实用函数中获取货币代码而不是硬编码它将是一个不错的选择。正如欧元在许多欧洲国家使用一样,一些国家使用相同的货币,将获取货币的逻辑封装到函数中是一个好主意。
-
我们将把有关货币的信息保存在一个哈希表中。记得从 第一章,Hello REPL!,哈希是一个 Clojure 集合,我们使用键和值来存储数据:
(def ^:const currencies {:euro {:countries #{"France" "Spain"} :symbol "€"} :dollar {:countries #{"USA"} :symbol "$"}})这允许我们检查不同国家使用的货币和货币符号。
-
由于我们不打算编写货币函数的实现,我们将为此提供一个存根(替代)。
我们在本章开头看到了存根的解释:
(unfinished get-currency) -
这告诉
Midje我们计划使用get-currency函数,但我们还没有实现它。我们将针对欧元进行测试,因此我们将添加helper变量:(def test-currency :euro) -
显示购买咖啡信息的函数最初看起来像这样:
(defn get-bought-coffee-message-with-currency [type number total currency] (format "Buying %d %s coffees for total: %s%s" number (name type) "€" total)) -
get-bought-coffee-message-with-currency函数的测试如下所示:(fact "Message about number of bought coffees should include currency symbol" (get-bought-coffee-message-with-currency :latte 3 1.5 :euro) => "Buying 3 latte coffees for total: €1.5" (provided (get-currency test-currency) => "€"))在测试中,我们使用
Midje =>符号。我们期望调用get-bought-coffee-message-with-currency的结果等于字符串消息。我们使用
Midje提供的函数来存根对get-currency函数的调用。当Midje测试调用此函数时,它应该返回欧元符号,€。如果我们在 REPL 中检查自动运行,我们将看到以下内容:
![图 10.21:使用 Midje 测试 get-bought-coffee-message-with-currency 函数]
![图片 B14502_10_21.jpg]()
![图 10.21:使用 Midje 测试 get-bought-coffee-message-with-currency 函数]
-
Midje通知我们有一个测试失败了。get-currency函数应该被调用,但实际上并没有被调用。我们只是编写了一个编译并运行的测试。我们没有得到编译错误。我们专注于显示消息的逻辑,这部分是成功的。一旦我们有get-bought-coffee-message-with-currency的测试,现在就是考虑使用get-currency来显示消息的时候了:(defn get-bought-coffee-message-with-currency [type number total currency] (format "Buying %d %s coffees for total: %s%s" number (name type) (get-currency test-currency) total))这个
get-bought-coffee-message-with-currency函数的实现使用了get-currency函数:![图 10.22:使用 get-currency 函数后的再次测试]
![图片 B14502_10_22.jpg]()
![图 10.22:使用 get-currency 函数后的再次测试]
当我们在 REPL 中检查自动测试时,我们看到现在所有测试都通过了。
在这个练习中,我们能够使用 Midje 库编写测试。这个库允许我们使用自顶向下的方法编写测试,我们首先考虑测试主函数,然后是它调用的任何其他函数都是存根。这有助于我们专注于测试的主函数的行为,而不必担心实现所有使用的函数。
虽然我们使用了各种库来编写测试,但所有测试都是有限的。当我们测试 calculate-coffee-price 时,我们只测试了几次。如果我们能测试更多次,我们就可以更有信心地认为 calculate-coffee-price 函数按预期执行。编写几个测试可能很快,但编写 100 或 200 个测试则需要时间。幸运的是,使用属性测试,我们可以非常快速地生成大量的测试场景。
属性测试
属性测试,也称为生成式测试,描述了对于所有有效测试场景都应成立的属性。属性测试由一个生成有效输入的方法(也称为生成器)和一个接收生成输入的函数组成。此函数将生成器与被测试的函数结合起来,以决定该特定输入的属性是否成立。使用属性测试,我们可以在广泛的搜索空间内自动生成数据以查找意外问题。
想象一个房间预订应用程序。我们应该允许用户搜索适合家庭的房间。这样的房间至少应该有两张床。我们可以有一个函数,它只返回至少有两张床的房间。使用单元测试,我们需要编写以下场景:
-
零张床
-
一张床
-
两张床
-
三张床
-
四张床
-
五张床
-
以及其他场景
如果我们想要测试有 20 张床的房间,那就意味着要创建超过 20 个非常相似的测试。我们只需更改床的数量。我们可以通过一般性地描述家庭房间的特性来概括此类测试。正如我们所说,家庭房间至少有两张床。属性测试允许我们概括输入并为我们生成它们。因为输入是自动生成的,所以我们不受手动输入测试的限制,可以轻松地创建 1,000 个测试场景。在我们的家庭房间示例中,输入是房间数量。测试将涉及指定房间号是一个数字。使用属性测试,整数输入将自动为我们生成。
Clojure 提供了 test.check 库用于属性测试。属性测试有两个关键概念:
-
test.check.generators命名空间包含许多内置生成器,以及组合函数,用于从内置生成器创建您自己的新生成器。 -
属性:属性是输入的特性。任何函数的输入都可以用一般术语来描述。在我们的家庭房间示例中,输入是房间数量。因此,属性是一个数字。
在下一个练习中,我们将为咖啡订购应用程序编写属性测试。
练习 10.04:在咖啡订购应用程序中使用属性测试
本练习的目的是学习如何使用属性测试创建测试。我们将描述 calculate-coffee-price 函数的输入,这将允许我们自动生成测试。
为了使用test.check库,我们需要在project.clj文件中将[org.clojure/test.check "0.10.0"]添加为依赖项:
-
在我们可以在 utils 命名空间中使用
test.check之前,我们需要导入必要的命名空间:(ns coffee-app.utils-test (: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]] [coffee-app.core :refer [price-menu]] [coffee-app.utils :refer :all]))我们导入三个
test.check命名空间:clojure.test.check.generators:将生成输入clojure.test.check.properties:将允许我们以通用形式描述输入clojure.test.check.clojure-test:将允许我们与 clojure.test 集成如果我们想在 REPL 中导入这些命名空间,我们会这样做:
(require '[clojure.test.check :as tc] '[clojure.test.check.generators :as gen] '[clojure.test.check.properties :as prop])一旦我们导入了必要的命名空间,我们就可以看看如何生成输入。
-
为了生成测试输入,我们将使用生成器。
calculate-coffee-price函数接受一个杯数作为参数。我们需要一个创建类似small-integer这样的数字的生成器:(gen/sample gen/small-integer)生成器命名空间中的
small-integer函数返回介于-32768和32767之间的整数。sample函数返回指定类型的样本集合。在上面的例子中,我们有一个小整数的样本集合:![图 10.23:创建小整数的样本]()
图 10.23:创建小整数的样本
-
使用生成器组合子,我们可以获得新的生成器。
fmap生成器允许我们通过将函数应用于另一个生成器创建的值来创建一个新的生成器。fmap生成器的工作方式类似于我们第一章节中了解到的map函数。它允许我们将函数映射到由以下生成器创建的值。在这个例子中,small-integer生成器创建的每个整数都通过inc函数进行增加:(gen/sample (gen/fmap inc gen/small-integer))这将返回以下内容:
(1 2 1 -1 -3 4 -5 -1 7 -6)我们能够通过使用
fmap组合子应用inc函数来增加small-integer生成器生成的数字数量。我们现在知道了如何使用生成器创建输入。现在是学习如何描述输入属性的时候了。
-
属性是一个实际的测试——它结合了一个生成器和你想要测试的函数,并检查在给定的生成值下函数是否按预期行为。
属性是通过
clojure.test.check.properties命名空间中的for-all宏创建的:(defspec coffee-price-test-check 1000 (prop/for-all [int gen/small-integer] (= (float (* int (:latte price-menu))) (calculate-coffee-price price-menu :latte int))))defspec宏允许你像标准clojure.test测试一样运行test.check测试。这允许我们将基于属性的测试与标准单元测试一起扩展到测试套件中。在for-all宏中,我们使用小整数生成器创建一系列小整数。当生成器创建的咖啡杯数乘以咖啡的价格时,我们的测试通过。这个计算的结果应该等于运行calculate-coffee-price函数的结果。我们打算运行这个测试 1,000 次。这真是太神奇了,我们只用了三行代码就能创建 1,000 个测试。 -
我们可以使用 Leiningen 运行
test.check测试:lein test输出如下:
![图 10.24:使用 Leiningen 测试 test.check]()
{:num-tests 5, :seed 1528580863556, :fail [[-2]], :failed-after-ms 1, :result false, :result-data nil, :failing-size 4, :pass? false, :shrunk {:total-nodes-visited 5, :depth 1, :pass? false, :result false, :result-data nil, :time-shrinking-ms 1, :smallest [[-1]]}}我们的测试失败了。原始失败的例子
[-2](在:fail键中给出)已经被缩减到[-1](在[:shrunk :smallest]下)。测试失败是因为在calculate-coffee-price的实现中,我们只返回绝对的非负值。当前calculate-coffee-price的实现如下:(defn calculate-coffee-price [coffees coffee-type number] (-> (get coffees coffee-type) (* number) float Math/abs))在最后一行,我们有
Math/abs函数调用。calculate-coffee-price应该只返回绝对数值。然而,在我们的测试中,我们允许生成负数。我们需要使用不同的生成器来匹配calculate-coffee-price函数的预期结果。 -
test.check提供了一个nat生成器,可以创建自然数(非负整数)。calculate-coffee-price的测试应该更新为以下内容:(defspec coffee-price-test-check 1000 (prop/for-all [int gen/nat] (= (float (* int (:latte price-menu))) (calculate-coffee-price price-menu :latte int))))当我们使用这个生成器运行测试时,测试通过了:
lein test输出如下:
![图 10.25:使用 nat 创建非负整数并运行测试]()
图 10.25:使用 nat 创建非负整数并运行测试
我们能够测试
calculate-coffee-price函数 1,000 次。每次我们生成一个整数,并将其作为杯数。使用test.check,我们可以真正地检查参数与生成的输入是否匹配。我们只测试了杯数参数。现在是时候编写生成器并测试所有参数了。 -
为了生成
calculate-coffee-price函数的所有剩余参数,我们将使用一些新的生成器。创建所有参数的代码如下:(defspec coffee-price-test-check-all-params 1000 (prop/for-all [int (gen/fmap inc gen/nat) price-hash (gen/map gen/keyword (gen/double* {:min 0.1 :max 999 :infinite? false :NaN? false}) {:min-elements 2})] (let [coffee-tuple (first price-hash)] (= (float (* int (second coffee-tuple))) (calculate-coffee-price price-hash (first coffee-tuple) int)))))存储咖啡菜单信息的咖啡哈希表包含有关咖啡类型的键和其值的双精度数:
{:latte 0.5 :mocha 0.4}gen/map生成器允许我们创建一个哈希表。在哈希表中,我们想要生成一个关键字作为键和一个双精度数作为值。我们将值限制在 0.1 到 999 之间。我们只对数字感兴趣。我们不想得到无限值。使用生成器,如果我们想的话,可以创建一个无限值。我们也不想生成 NaN(不是一个数字)。最后,我们的哈希表应该至少有两个元素——确切地说,是两个元组。每个元组是一对键和值。在
let块中,我们取第一个元组并将其分配给coffee-tuple。这将帮助我们测试并传递适当的参数给calculate-coffee-price函数。我们将再次运行测试:
lein test输出如下:
![图 10.26:生成函数的所有参数后运行测试]()
图 10.26:生成calculate-coffee-price函数的所有参数后运行测试
我们看到test.check测试都通过了。用几行代码,我们就能够测试 2,000 种场景。这真是太神奇了。
到目前为止,我们已经测试了 calculate-coffee-price 函数。在接下来的活动中,你将编写来自咖啡订购应用程序的其他函数的测试。
活动 10.01:为咖啡订购应用程序编写测试
在这个活动中,我们将应用关于单元测试的知识来编写测试套件。许多在生产中运行的应用程序非常复杂。它们有很多功能。开发者编写单元测试是为了增加他们对应用程序的信任。编写的代码应该满足业务需求。一个编写良好且维护良好的测试套件会给开发者和使用此类应用程序的人带来信心,即应用程序的功能按预期执行。
在上一章中,我们编写的咖啡订购应用程序使我们能够显示咖啡菜单并订购一些咖啡。在本章中,我们通过测试 calculate-coffee-price 函数学习了 Clojure 中的单元测试库。在咖啡订购应用程序中,还有一些尚未测试的函数。
在这个活动中,我们将为以下函数编写单元测试:
-
display-order: 显示关于顺序的信息 -
file-exist: 检查指定的文件是否存在 -
save-coffee-order: 将咖啡订单保存到文件 -
load-orders: 从文件中加载咖啡订单
这些步骤将帮助您完成活动:
-
导入测试命名空间。
-
使用
clojure.test库创建测试以显示订单消息:使用
is宏的测试使用
are宏的测试 -
使用
clojure.test库创建测试以检查文件是否存在或文件不存在 -
使用
clojure.test库创建测试以保存订单、加载空订单、加载咖啡订单。 -
使用
expectations库创建测试以将数据保存到文件、保存咖啡订单、保存咖啡数据以及加载订单 -
使用
expectations库创建测试以检查文件是否存在。 -
使用
expectations库创建测试以保存和加载订单。 -
使用
Midje库创建测试以显示订单消息。 -
使用
Midje库创建测试以检查文件是否存在。 -
使用
Midje库创建测试以加载订单。 -
使用
test.check创建测试以显示订单消息:导入
test.check命名空间测试显示的订单
-
使用
test.check创建测试来检查文件是否存在。 -
使用
test.check创建测试以加载订单。
clojure.test 和 test.check 测试的输出将如下所示:

图 10.27:clojure.test 和 test.check 测试的预期输出
expectations 测试的输出将如下所示:

图 10.28:expectations 测试的预期输出
Midje 测试的输出将如下所示:

图 10.29:Midje 测试的输出
test.check 测试的输出将如下所示:

图 10.30:test.check 测试的输出
注意
该活动的解决方案可以在第 723 页找到。
我们现在知道如何使用四个库在 Clojure 中编写单元测试。在下一节中,我们将探讨 ClojureScript 中的测试。
ClojureScript 中的测试
在 Clojure 中,我们使用clojure.test库进行测试。在 ClojureScript 中,我们有clojure.test的端口,形式为cljs.test。在cljs.test中,我们具有使用clojure.test库编写测试时使用的功能。我们可以使用is和are宏来编写我们的测试。cljs.test提供了异步测试的设施。异步测试是一种测试异步代码的类型。我们将很快看到为什么cljs.test允许我们测试异步代码是如此重要。
同步代码是开发者大多数时候编写的内容,即使没有意识到这一点。在同步代码中,代码是逐行执行的。例如,第 10 行定义的代码需要完成执行,第 11 行的代码才能开始执行。这是逐步执行。异步编程是一个更高级的概念。
在异步编程中,执行代码和完成代码的执行不能按行进行。例如,我们可以在第 10 行安排下载一首歌曲,然后在第 11 行我们可以有代码让用户知道下载已完成。在同步代码中,我们必须等待下载完成,然后才能向用户显示信息或执行其他操作。这并不是我们真正想要的。我们希望在下载歌曲时通知用户进度。在异步代码中,我们会在歌曲下载之前安排下载歌曲并开始显示进度条。
在 Java 和 Clojure 中,我们会使用线程来编写异步代码。线程是在 JVM 上消耗少量计算机资源的进程。一个线程会处理下载歌曲,另一个线程会显示进度条。
如我们在第一章,Hello REPL中学到的,ClojureScript 在 JavaScript 之上运行。JavaScript 提供了一个单线程环境。这与允许创建许多线程的 Java 形成对比。为单个线程编写代码更简单,因为我们不需要在许多线程之间协调资源共享。需要异步代码的 ClojureScript 应用程序需要使用一些其他设施而不是线程。
JavaScript 提供了回调来管理异步代码的编写。回调是我们定义的,在满足某些条件时运行的函数。在我们的下载示例中,回调会让我们知道下载何时完成,这样我们就可以通知用户。
ClojureScript 提供了 core.async 库来处理异步代码。core.async 库有许多函数和宏:
-
go:创建一个标记代码为异步的块。块的结果被放置在通道上。 -
<!:从一个通道中获取一个值。
我们为什么需要一个 go 块和通道?
异步代码按定义是异步的。我们不知道何时会从异步调用中获得返回值。当我们使用通道进行异步调用时,我们的代码变得更简单。这是因为返回值被放置在通道上。我们不需要管理这个通道。core.async 为我们管理这个通道。当我们准备好时,我们只需从这个通道中获取值。没有显式的通道管理,我们的代码更短,因为代码可以专注于更简单的任务,这些任务是我们编写的。
在以下练习中,我们将看到如何在 ClojureScript 中设置和使用测试库。
练习 10.05:在 ClojureScript 中设置测试
这个练习的目的是学习如何在 ClojureScript 中设置测试库以及如何使用这些库。我们将使用 cljs.test 进行测试。
在这个练习中,我们将创建多个文件夹和文件。有许多创建文件夹和文件的方法。读者可以自由选择他们最舒适的方法。以下步骤将使用命令行。


图 10.31:命令及其描述
-
创建一个名为
hello-test的项目,如下所示:mkdir hello-test这将创建一个项目,我们将在此项目中保存我们的代码。一旦我们完成设置,项目结构应如下所示。我们可以使用
tree命令或您喜欢的任何方式来检查目录:tree输出如下:
![图 10.32:项目结构]()
![图 B14502_10_32]()
图 10.32:项目结构
-
在源文件夹中,我们将保存我们的代码:
mkdir -p src/hello_test执行此命令将创建
src和hello_test文件夹。 -
创建一个源文件。在源文件中,我们将保存我们的代码:
touch src/hello_test/core.cljs此命令创建一个空的核心文件。
-
创建一个核心命名空间。在
core.cljs文件中,添加一个命名空间:(ns hello-test.core) -
在
core.cljs文件中,放置一个用于加法运算的函数:(defn adder [x y ] (+ x y)) -
创建一个测试文件夹。
我们将为我们的测试文件创建一个文件夹:
mkdir -p test/hello_test此命令将创建
test和hello_test文件夹。 -
创建配置。
我们将在
project.clj文件中保存项目配置。该文件应如下所示:(defproject hello-test "0.1.0-SNAPSHOT" :description "Testing in ClojureScript" :dependencies [[org.clojure/clojure "1.10.0"] [org.clojure/clojurescript "1.10.520"] [cljs-http "0.1.46"] [org.clojure/test.check "0.10.0"] [funcool/cuerdas "2.2.0"]])这是一个标准的
project.clj文件,就像我们在 第八章,命名空间、库和 Leiningen 中创建的那样。在project.clj文件中,我们有:dependencies键,我们将在此键中放置我们需要的用于测试的库。cljs-http库将允许我们进行 HTTP 调用。我们将使用GET请求来执行将被测试的异步调用。cuerdas库有许多字符串实用函数。以下是一些函数:capital:将字符串的第一个字符转换为大写。字符串"john"变为"John"。Clean:删除并替换多个空格为单个空格。字符串" a b "变为"a b"。Human:将字符串或关键字转换为人类友好的字符串(小写和空格)。字符串"DifficultToRead"变为"difficult to read"。Reverse:返回一个反转的字符串。字符串"john"变为"nhoj"。我们将编写操作字符串的单元测试。
-
添加测试插件依赖。我们将使用
lein-doo插件来运行 ClojureScript 测试。在project.clj文件中添加以下行::plugins [[lein-doo "0.1.11"]]lein-doo插件将被用来运行 ClojureScript 测试。这个插件将自动运行测试并显示测试结果。我们将对 Web 浏览器环境运行lein-doo。lein-doo依赖于 JavaScript 的Karma库在 JavaScript 环境中运行测试。Karma 是一个帮助运行 JavaScript 测试的 JavaScript 工具。我们需要安装Karma的必要依赖。 -
安装 Karma。Karma 使用
npm相当于我们在第八章中学习的 Maven。基本上,它是一个项目仓库。虽然 Maven 托管 Java 项目,但 npm 托管 JavaScript 项目。我们将使用 npm 来安装 Karma:
npm install karma karma-cljs-test –save-dev使用
-save-dev标志,我们在当前目录中安装karma包。使用-save-dev标志的目的是允许我们在项目之间分离不同的测试配置。一个遗留项目可能仍然依赖于 Karma 的旧版本,而新项目可以使用 Karma 的新版本。 -
我们将安装 Chrome Karama 启动器。我们的测试将在 Chrome 浏览器中运行(启动):
npm install karma-chrome-launcher –save-dev前面的命令在
npm中搜索karma-chrome-launcher项目。当npm找到这个项目时,它将下载 Chrome 启动器并安装它。使用-save-dev标志,我们在当前目录中安装karma-chrome-launcher。 -
安装 Karma 命令行工具。
安装 Karma 库的最终步骤是安装允许执行 Karma 命令的命令行工具:
npm install -g karma-cli我们将全局安装 Karma 命令行工具,因为运行测试的 ClojureScript 插件需要访问 Karma 命令。
-
我们需要在
project.clj文件中设置测试任务的构建配置::cljsbuild {:builds {:test {:source-paths ["src" "test"] :compiler {:output-to "out/tests.js" :output-dir "out" :main hello-test.runner :optimizations :none}}}}ClojureScript 构建配置在
project.clj文件中的:cljsbuild键下设置。我们指定一个:browser-test构建。此构建将访问src和test目录中的文件。代码将被编译到out目录下的tests.js文件。测试的:main入口点是hello-test.runner命名空间。对于测试,我们不需要任何编译优化,因此我们将优化参数设置为:none。 -
创建核心测试文件:
touch test/hello_test/core_test.cljs此命令创建
core_test.cljs文件。 -
导入测试命名空间。
core_test.cljs文件将包含测试。我们需要导入必要的命名空间:(ns hello-test.core-test (:require [cljs.test :refer-macros [are async deftest is testing]] [clojure.test.check.generators :as gen] [clojure.test.check.properties :refer-macros [for-all]] [clojure.test.check.clojure-test :refer-macros [defspec]] [cuerdas.core :as str] [hello-test.core :refer [adder]]))我们从
cljs.test命名空间导入测试宏。我们将使用它们来测试我们的代码。我们还从test.check命名空间导入命名空间。我们将为我们的函数编写基于属性的测试。cuerdas命名空间将用于操作字符串。最后,我们从hello-test.core命名空间导入测试函数。 -
创建一个测试运行器。
测试运行器是一个运行所有测试的文件。我们将使用 Karma 的浏览器引擎来测试我们的代码:
touch test/hello_test/runner.cljs -
导入测试运行器的命名空间。
在
hello_test.runnerfile中,我们导入核心测试命名空间和lein-doo命名空间:(ns hello-test.runner (:require [doo.runner :refer-macros [doo-tests]] [hello-test.core-test])) (doo-tests 'hello-test.core-test)我们让
lein-doo知道它需要从hello-test.core-test命名空间运行测试。 -
一旦安装 Karma 并创建所有文件,项目结构应该如下所示:
tree输出如下:
![图 10.33:安装 Karma 并创建所有文件后的项目结构]
![图片 B14502_10_33.jpg]
图 10.33:安装 Karma 并创建所有文件后的项目结构
我们准备好启动测试运行器。
-
启动测试运行器:
lein doo chrome test我们调用
lein doo插件,使用 Chrome 浏览器运行测试。请记住,JavaScript 是一种在浏览器中运行的编程语言。![图 10.34:启动测试运行器]
![图片 B14502_10_34.jpg]
图 10.34:启动测试运行器
lein doo 插件为我们启动了一个 Karma 服务器。服务器正在监视源代码和测试目录。当我们对 ClojureScript 文件进行更改时,测试将针对我们的代码运行。
在这个练习中,我们学习了如何在 ClojureScript 中设置测试。在下一个练习中,我们将学习如何编写 ClojureScript 测试。
练习 10.06:测试 ClojureScript 代码
在上一个练习中,我们为 ClojureScript 测试配置了一个项目。在这个练习中,我们将编写 ClojureScript 测试。我们将使用来自 cuerdas 库的函数来操作字符串。我们还将测试异步的 ClojureScript 代码。
我们将实现并测试三个函数:
-
profanity-filter:在聊天应用或网络论坛中过滤某些单词是很常见的。粗口过滤器将移除我们认为不恰当的单词。 -
prefix-remover:这个函数将使用字符串函数并从单词中移除前缀。 -
http-caller:这个函数将对一个网址进行 HTTP 调用。这将帮助我们测试异步代码。
-
导入核心文件的命名空间。
在
core.cljs文件中,添加必要的命名空间:(ns hello-test.core (:require [cuerdas.core :as str]))我们导入用于字符串操作的
cuerdas命名空间。 -
创建一个粗口过滤器。在
hello_test.core.cljs文件中,我们将编写的第一个函数是一个粗口过滤器:(defn profanity-filter [string] (if (str/includes? string "bad") (str/replace string "bad" "great") string))在这个函数中,我们测试传入的字符串是否包含单词
bad。如果包含,我们将它替换为单词great。 -
导入测试命名空间。
在
hello_test.core_test.cljs文件中,导入必要的测试命名空间:(ns hello-test.core-test (:require [cljs.test :refer-macros [are async deftest is testing]] [cuerdas.core :as str] [hello-test.core :refer [profanity-filter]])) -
编写
profanity-filter函数的测试。在
hello_test.core_test.cljs文件中,为粗口过滤器函数添加一个测试:(deftest profanity-filter-test (testing "Filter replaced bad word" (is (= "Clojure is great" (profanity-filter "Clojure is bad")))) (testing "Filter does not replace good words" (are [string result] (= result (profanity-filter string)) "Clojure is great" "Clojure is great" "Clojure is brilliant" "Clojure is brilliant")))测试看起来与我们使用
clojure.test库编写的测试类似。我们使用is和are宏来设置测试场景。我们已经准备好运行测试。 -
为了运行测试,我们从命令行调用
lein doo任务。如果你在之前的练习中有一个正在运行的lein doo,它将监视文件变化并为我们运行测试:![图 10.35:从命令行调用 lein doo 任务]()
图 10.35:从命令行调用 lein doo 任务
粗口过滤器测试已运行。输出告诉我们有一个测试成功执行。
-
如果你没有运行
lein doo,你需要启动lein doo:lein doo chrome test启动
lein doo任务将开始监视我们的 ClojureScript 文件变化:![图 10.36:启动 lein doo 任务]()
图 10.36:启动 lein doo 任务
一旦
lein doo开始监视我们的文件变化,我们就可以开始了。我们被告知karma服务器已经启动。自动运行器正在监视src和test目录中的变化。这些目录中的任何变化都将导致lein doo再次运行测试。前往
hello_test.core_test.cljs文件,保存文件,并观察测试的执行过程:![图 10.37:执行测试]()
图 10.37:执行测试
我们被告知有一个测试已成功执行。
-
自动运行器会告诉我们测试是否失败。如果我们添加以下测试,自动运行器会告诉我们有一个测试失败了:
(deftest capitalize-test-is (testing "Test capitalize? function using is macro" (is (= "katy" (str/capitalize "katy"))) (is (= "John" (str/capital "john"))) (is (= "Mike" (str/capitalize "mike")))))测试失败如下:
![图 10.38:自动运行器在测试失败时通知我们]()
(is (= "Katy" (str/capitalize "katy")))在测试中,我们将小写字符串"
katy"传递给cuerdas库中的capitalize函数。capitalize函数将首字母大写,"k",并返回一个新的字符串,"Katy"。这个新字符串与测试中的字符串Katy进行比较。由于两个字符串
Katy和Katy相等,测试将通过。自动运行器告诉我们现在所有的测试都通过了:
![图 10.39:修复字符串大小写后所有测试通过]()
图 10.39:修复字符串大小写后所有测试通过
-
我们可以检查我们的代码抛出的错误:
(deftest error-thrown-test (testing "Catching errors in ClojureScript" (is (thrown? js/Error (assoc ["dog" "cat" "parrot"] 4 "apple")))))在前面的代码中,我们想要在第四个索引处插入一个苹果,但因为我们只有三个元素,所以这个索引不存在。记住,在 Clojure 中,第一个索引是 0,所以列表中的第三个元素的索引是 2。尝试在索引 4 处添加一个元素会在 ClojureScript 中生成一个错误。在我们的测试中,我们捕获了这个错误:
![图 10.40:第三次测试通过,因为我们捕获了代码中的错误]()
图 10.40:第三次测试通过,因为我们捕获了代码中的错误
自动运行测试测试我们的代码,第三次测试通过。
-
在 ClojureScript 中,我们可以向网站发起请求。这些请求是异步的。我们将导入帮助我们进行异步调用的 ClojureScript 命名空间:
(ns hello-test.core (:require-macros [cljs.core.async.macros :refer [go]]) (:require [cljs.core.async :refer [<!]] [cljs-http.client :as http]))cljs-http.client命名空间将允许我们进行 HTTP 调用。来自core.async命名空间的函数将为我们管理异步调用。 -
我们的 HTTP 函数将接受三个参数,一个网站地址、HTTP 参数以及一个在向网站地址发送请求后调用的回调函数:
(defn http-get [url params callback] (go (let [response (<! (http/get url params))] (callback response)))) -
我们有进行异步调用的函数。我们需要导入这个函数:
(ns hello-test.core-test (:require [hello-test.core :refer [http-get])) -
在 ClojureScript 中,HTTP 调用是异步发生的。一个
GET请求会在请求完成后运行回调函数。这对于测试异步代码来说是非常理想的:(deftest http-get-test (async done (http-get "https://api.github.com/users" {:with-credentials? false :query-params {"since" 135}} (fn [response] (is (= 200 (:status response))) (done)))))async宏允许我们为测试编写异步代码块。在我们的代码块中,我们向 GitHub API 发起 GET 请求以访问当前公开用户列表。http-get函数将回调函数作为最后一个参数。在回调中,我们检查响应。成功的响应将具有状态200。回调中的最后一个函数调用是
done。done是一个在我们准备好放弃控制并允许下一个测试运行时被调用的函数:![图 10.41:第四次测试通过]
![图片 B14502_10_41.jpg]
图 10.41:第四次测试通过
我们的请求成功了,第四次测试通过了。
-
导入用于基于属性的测试的命名空间。ClojureScript 允许我们使用基于属性的测试来检查我们的函数:
(ns hello-test.core-test (:require [clojure.test.check.generators :as gen] [clojure.test.check.properties :refer-macros [for-all]] [clojure.test.check.clojure-test :refer-macros [defspec]]))我们已经了解了生成器和用于基于属性的测试的属性。使用生成器,我们可以创建各种类型的函数输入,如数字或字符串。属性允许我们描述输入的特性。
defspec宏允许我们编写可以用clsj.test库运行的测试。 -
使用基于属性的测试,我们可以检查 1,000 种场景与我们的粗话过滤器。在 ClojureScript 中,基于属性的测试结构与 Clojure 中的结构相同:
(defspec simple-test-check 1000 (for-all [some-string gen/string-ascii] (= (str/replace some-string "bad" "great") (profanity-filter some-string))))使用
for-all宏,我们指定函数参数应该具有哪些属性。对于粗话过滤器,我们生成 ASCII 字符串。ASCII 是从美国信息交换标准代码(American Standard Code for Information Interchange)缩写而来,是一种电子通信的字符编码标准:![图 10.42:第五次测试通过]
![图片 B14502_10_42.jpg]
图 10.42:第五次测试通过
我们的第五次测试通过了。此外,test.check告诉我们执行了 1,000 个测试场景。
在这个练习中,我们看到了如何在 ClojureScript 中设置测试。我们编写了函数,并使用cljs.test和test.check库来测试它们。在下一节中,我们将看到如何将测试集成到现有项目中。
使用 Figwheel 测试 ClojureScript 应用程序
在 第九章,Java 和 JavaScript 与宿主平台互操作性 中,我们学习了 Figwheel。Figwheel 允许我们创建 ClojureScript 应用程序。大多数开发者使用 Figwheel,因为它提供了热代码重新加载功能。这意味着我们代码中的任何更改都会被重新编译,并且运行在网页浏览器中的应用程序会得到更新。
在上一个练习中,我们学习了如何向 ClojureScript 项目添加测试。Figwheel 内置了测试配置。创建应用程序后,任何 Figwheel 应用程序都可以添加测试。因为测试配置包含在每个项目中,开发者可以节省时间。开发者不需要安装外部工具或创建配置;他们可以直接开始编写测试。
在 第九章,Java 和 JavaScript 与宿主平台互操作性 中,我们详细讨论了 Figwheel 项目。作为提醒,在 Figwheel 中,我们使用两个概念:
-
响应式组件
-
应用状态管理
对于响应式组件——对用户操作做出反应的 HTML 元素——我们将使用 Rum 库。应用程序的状态将保存在一个原子中。并发是 第十二章,并发 中讨论的主题。就我们的目的而言,原子是一种类似于集合的数据结构。我们在 第一章,Hello REPL! 中学习了集合。集合和原子之间的主要区别是我们可以更改原子的值,而集合是不可变的。
练习 10.07:Figwheel 应用程序中的测试
在上一个部分,我们学习了 Figwheel 支持测试 ClojureScript 应用程序。我们回顾了使用 Figwheel 创建 ClojureScript 应用程序的好处。我们还提醒了自己在 Figwheel 应用程序中的重要概念,例如响应式组件和应用状态管理。
在这个练习中,我们将调查 Figwheel 如何配置项目以支持 ClojureScript 中的测试。Figwheel 旨在支持开发者创建应用程序。Figwheel 为我们设置了默认的测试配置。在 练习 10.5,在 ClojureScript 中设置测试 中,我们看到了配置 ClojureScript 中的测试需要多少设置。使用 Figwheel,我们不需要编写此配置;我们可以专注于编写我们的代码。
为了在 Figwheel 中编写测试,我们需要了解 Figwheel 如何设置默认的测试配置:
-
创建一个 Figwheel 应用程序:
lein new figwheel-main test-app -- --rum我们使用 Rum 创建了一个新的 Figwheel 项目。
-
测试
project.clj文件中的配置。Figwheel 在
project.clj文件中放置了一些测试配置::aliases {"fig:test" ["run" "-m" "figwheel.main" "-co" "test.cljs.edn" "-m" "test-app.test-runner"]}在
project.clj文件中,Figwheel 定义了别名以帮助在命令行上运行任务。别名是我们经常使用的命令的快捷方式。使用别名可以节省开发者输入。Figwheel 定义了fig:test任务。此任务在命令行上运行,并带有多个参数:
-m:在文件中搜索主函数。记得在 第八章,命名空间、库和 Leiningen 中,Leiningen 项目的main函数是应用程序的入口点。我们在main函数中启动应用程序。-co:从给定的文件中加载选项。 -
测试
test.cljs.edn文件中的配置。在test.cljs.edn文件中,我们有测试配置:{ ;; use an alternative landing page for the tests so that we don't launch the application :open-url "http://[[server-hostname]]:[[server-port]]/test.html" } {:main test-app.test-runner}当 Figwheel 应用程序运行时,它会启动一个网页。Figwheel 提供了两个网页。有一个网页用于我们正在开发的实际应用程序。还有一个不同的网页用于测试。
Figwheel 还在
test-app.test-runner命名空间中提供了一个主方法。 -
测试运行器命名空间。在
test/test_app/test_runner.cljs文件中,我们有运行 ClojureScript 测试的代码:(ns test-app.test-runner (:require ;; require all the namespaces that you want to test [test-app.core-test] [figwheel.main.testing :refer [run-tests-async]])) (defn -main [& args] (run-tests-async 5000))首先,在文件中,我们引入了我们想要测试的命名空间。最初,要测试的唯一命名空间是由 Leiningen 默认创建的
test-app.core-test命名空间。如果我们为测试添加更多文件,我们需要在这些文件中导入命名空间。第二个需要的命名空间是一个包含实用函数的 Figwheel 命名空间。第二,我们有
-main函数。这个函数由 Leiningen 调用来运行测试。Figwheel 提供了一个run-tests-async函数。这意味着测试以异步方式运行。这意味着测试可以比同步方式运行得更快。它们运行得更快,因为测试不需要等待其他测试完成才能开始。 -
在
test/test_app/core_test.cljs文件中,我们有 Figwheel 自动生成的测试:(ns test-app.core-test (:require [cljs.test :refer-macros [deftest is testing]] [test-app.core :refer [multiply]]))Figwheel 首先需要我们熟悉的
cljs.test命名空间及其宏。测试将使用deftest、is和testing等宏。需要的第二个命名空间是
test-app.core命名空间。这个命名空间,从源目录开始,包含了multiply函数的实现。 -
在
core_test.cljs文件中,我们有两组自动生成的测试:(deftest multiply-test (is (= (* 1 2) (multiply 1 2)))) (deftest multiply-test-2 (is (= (* 75 10) (multiply 10 75))))两个测试都使用了熟悉的
is宏。使用is宏,我们测试调用multiply函数是否等于预期的输出。将 1 乘以 2 应该等于调用带有两个参数的multiply函数:1 和 2。 -
运行默认测试。当我们基于 Figwheel 创建一个新应用程序时,该应用程序有一些默认测试。在创建应用程序后,我们可以立即运行默认测试:
lein fig:test输出如下:
![图 10.43 使用 fig:test 命令运行测试
![图片 B14502_10_43.jpg]
图 10.43 使用 fig:test 命令运行测试
我们使用 Leiningen 来启动 Figwheel。为了运行测试,我们使用
fig:test命令行任务。这个任务将从project.clj文件中读取 Figwheel 配置并按照配置运行测试。在前面的步骤中,我们看到了两个默认测试。两个测试都通过了,并且我们被告知测试通过了。
-
Figwheel 的卖点在于热代码重载。为了获得一个交互式开发环境,请运行以下命令:
lein fig:build这将启动 Figwheel,它会自动编译我们的代码:
![图 10.44:Figwheel 验证 figwheel-main.edn 文件上的配置]()
图 10.44:Figwheel 验证 figwheel-main.edn 文件上的配置
Figwheel 读取并验证
figwheel-main.edn文件上的配置。然后,if编译我们的源代码到dev-main.js文件。测试代码编译到dev-auto-testing.js文件。 -
使用 Figwheel,我们可以在浏览器中看到测试的摘要。访问
http://localhost:9500/figwheel-extra-main/auto-testing:![图 10.45:所有测试通过]()
图 10.45:所有测试通过
Figwheel 通知我们所有测试都已通过。显示了一个摘要,说明了哪些测试被执行。
在这个练习中,我们学习了 Figwheel 如何在 ClojureScript 中支持测试。我们看到了 Figwheel 提供的默认测试配置。在下一个练习中,我们将看到如何向 Figwheel 应用添加测试。
练习 10.08:测试 ClojureScript 应用
这个练习的目的是学习如何测试 ClojureScript 应用。通常,前端代码很复杂。浏览器中应用的状态会改变。用户交互会导致许多不可预测的场景。为前端应用编写 ClojureScript 测试可以帮助我们及早捕捉到错误。
在上一章中,我们学习了关于 Figwheel 应用模板的内容。它是用 ClojureScript 编写前端应用的非常常见的模板。我们将创建一个能够响应用户操作的应用。当用户点击操作按钮时,我们将增加计数器。
初始时,计数将为零:
图 10.46:初始点击次数为零
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_46.jpg)
图 10.46:初始点击次数为零
在六次点击后,计数将改变:

图 10.47:计数变为六
我们知道我们的应用将做什么。我们现在可以实施这个功能了。
-
创建一个 Figwheel 应用:
lein new figwheel-main test-app -- --rum我们使用 Rum 创建了一个新的 Figwheel 项目。
-
在上一节中,我们了解到 Figwheel 支持测试。在创建项目后,我们已经准备好运行测试:
lein fig:test输出如下:
图 10.48:包含两个断言的两个测试通过
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_48.jpg)
图 10.48:包含两个断言的两个测试通过
Figwheel 编译我们的代码并运行测试。我们测试了 tet-app.core-test 命名空间。两个测试都通过了。
-
我们将在
src/test_app/core.cljs文件中实现一个处理用户点击的函数:(ns test-app.core) (defn handle-click [state] (swap! state update-in [:counter] inc))handle-click函数有一个参数。该参数是当前应用状态。我们在:counter键下增加 atom 中存储的值。 -
我们将在
core.cljs文件中的 atom 中存储状态应用:(ns test-app.core) (defonce state (atom {:counter 0}))原子是一个带有
:counter键的哈希表。键的初始值是零。 -
创建计数器组件。
我们创建一个 Rum 组件,该组件将显示鼠标点击次数:
(rum/defc counter [number] [:div {:on-click #(handle-click state)} (str "Click times: " number)])组件显示点击次数,该次数作为参数传递。在组件内部,我们使用
handle-click函数来响应:on-click动作。每当用户点击组件时,handle-click函数就会被调用。 -
创建页面组件。我们将把
counter组件放在page-content组件内部。在页面上有一个主要组件,我们将把所有组件放在那里是一个好的做法。在我们的例子中,我们有一个组件:(rum/defc page-content < rum/reactive [] [:div {} (counter (:counter (rum/react state)))])容器使用 Rum 的
reactive指令。此指令指示 Rum 以特殊方式处理组件。响应式组件将对应用程序状态的变化做出响应。每当应用程序状态发生变化时,组件将更新,并使用新的应用程序状态在浏览器中重新显示。我们在第九章,Java 和 JavaScript 与宿主平台互操作性中学习了响应式组件,并在本练习之前的章节中复习了相关知识。 -
最后,我们需要将我们的
page-component附加到网页上。正如我们在第九章,Java 和 JavaScript 与宿主平台互操作性中所做的那样,我们使用 Rum 的mount方法:(defn mount [el] (rum/mount (page-content) el))page-content组件被挂载到网页上。 -
运行应用程序。
我们将运行我们的 Figwheel 应用程序:
lein fig:build此命令将为我们启动 Figwheel:
![图 10.49:启动 Figwheel]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_50.jpg)
图 10.49:启动 Figwheel
Figwheel 成功启动了我们的应用程序。我们可以在浏览器中看到页面。它将如下所示:
![图 10.50:应用程序开始时的点击次数]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_51.jpg)
图 10.50:应用程序开始时的点击次数
当应用程序启动时,点击次数为零。经过六次点击后,状态更新,并在页面上显示新的值:
![图 10.51:更新后的点击次数]()
](https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-ws/img/B14502_10_51.jpg)
图 10.51:更新后的点击次数
我们可以看到页面上的组件对我们的操作做出反应。现在是时候为
handle-click函数编写测试了。 -
我们将为我们的测试创建固定数据。测试固定数据是一组对象的固定状态,用作运行测试的基线。测试固定数据的目的确保测试在一个已知且固定的环境中运行,以便结果可重复。
由于我们将操作应用程序的状态,我们希望每次运行测试时状态都相同。我们不希望之前的测试影响后续的测试。
handle-click函数接受一个状态原子作为参数。为了测试handle-click函数,我们需要一个状态原子。cljs.test提供了use-fixtures宏,它允许我们在测试运行之前预设测试到所需的状态。这是一个创建状态原子以进行进一步操作的好地方。我们将把我们的测试放在
core_test.cljs文件中:(ns test-app.core-test (:require [cljs.test :refer-macros [are deftest is testing use-fixtures]] [test-app.core :refer [handle-click multiply]])) (use-fixtures :each {:before (fn [] (def app-state (atom {:counter 0}))) :after (fn [] (reset! app-state nil))})使用
:each关键字,我们指定我们想要为每个测试运行设置。这样,我们可以为每个测试设置状态。另一种选择是使用:only关键字,它将只在每个测试中设置一次设置。在设置中,我们有两个键:
:before:在测试执行之前运行一个函数:after:在测试执行之后运行一个函数在
:before和:after中,我们设置应用程序原子的状态。在每个测试之前,我们将:counter设置为零。在每个测试之后,我们将应用程序状态重置为nil。将计数器设置为零将其重置。这样,每次我们运行新的测试时,计数器都是从零开始的。之前的测试不会影响后续的测试。在设置好设置之后,我们准备启动测试运行器。
-
测试
handle-click函数。我们将测试处理多个点击:
(deftest handle-click-test-multiple (testing "Handle multiple clicks" (are [result] (= result (handle-click app-state)) {:counter 1} {:counter 2} {:counter 3})))我们使用
are宏来简化测试。我们比较预期结果与调用handle-click函数的返回值。调用handle-click三次应该将计数器增加到三。 -
我们现在将运行测试:
lein fig:test输出如下:
![图 10.52:运行测试]()
图 10.52:运行测试
如我们在摘要中看到的,测试通过了。
handle-click测试使用了app-state,这是我们使用use-fixtures宏设置的。在每个测试之前,设置创建了一个应用程序状态。在每个测试之后,设置应该将状态重置为零。我们将编写一个新的测试来检查应用程序状态是否已重置。 -
在以下测试中,我们将测试单个点击:
(deftest handle-click-test-one (testing "Handle one click" (is (= {:counter 1} (handle-click app-state)))))在这个测试中,我们使用
is宏来测试单个点击。 -
我们将再次运行测试:
lein fig:test输出如下:
![图 10.53:再次运行测试]()
图 10.53:再次运行测试
运行新的测试告诉我们状态已经被重置。我们看到我们的测试通过了,因为应用程序状态已经成功重置。
在这个练习中,我们学习了如何将测试集成到 ClojureScript 应用程序中。我们使用 Figwheel 模板创建了一个项目。这个模板允许我们创建一个网络应用程序。在应用程序中,我们添加了用户交互。应用程序计算点击次数。我们编写了测试来确保我们的函数按预期执行。
你现在可以开始编写网络应用程序并将测试添加到其中。在接下来的活动中,你将应用你的新知识。
活动 10.02:带有测试的支持台应用程序
本活动的目的是向网络应用程序添加测试套件。许多应用程序需要复杂的功能和许多特性。虽然手动测试可以捕捉到许多错误,但它耗时且需要许多测试人员。使用自动化测试,检查应用程序更快,并且可以测试更多功能。ClojureScript 提供了帮助进行单元测试的工具。
在上一章中,我们编写了一个支持台应用程序,该应用程序允许我们通过帮助台管理提出的问题(packt.live/2NTTJpn)。该应用程序允许您对问题进行排序并在完成后解决它们。通过排序问题,我们可以提高问题的优先级。在这个活动中,我们将使用 clsj.test 和 test.check 为属性测试添加单元测试。
您将编写以下测试:
-
一个显示排序消息状态的函数
-
一个按优先级过滤问题列表的函数
-
一个对问题列表进行排序的函数
-
一个从问题列表中删除项的函数
这些步骤将帮助您完成活动:
-
将测试依赖项添加到
project.clj文件中。 -
将命名空间导入到
core_test.cljs文件中。 -
在应用程序状态中创建带有问题的固定装置。
-
为排序消息函数编写
cljs.test测试。 -
使用
test.check为排序消息函数编写测试。 -
使用
cljs.test编写按优先级过滤问题的测试。 -
使用
cljs.test编写排序问题列表的测试。 -
使用
cljs.test编写从列表中删除问题的测试。 -
使用
cljs.test编写测试来处理排序函数。
初始问题列表将如下所示:
![图 10.54:初始问题列表]
![img/B14502_10_54.jpg]
图 10.54:初始问题列表
排序后的问题列表将如下所示:
![图 10.55:排序后的列表]
![img/B14502_10_55.jpg]
图 10.55:排序后的列表
当运行测试时,输出应如下所示:
![图 10.56:运行测试后的输出]
![img/B14502_10_56.jpg]
图 10.56:运行测试后的输出
注意
本活动的解决方案可以在第 730 页找到
概述
在本章中,我们学习了 Clojure 中的测试。首先,我们探讨了为什么测试很重要。我们查看了一些好处,例如降低维护成本和错误修复。我们还学习了可用的测试方法。我们专注于单元测试,因为这是开发者编写的最常见类型的测试。
接下来,我们探讨了 Clojure 中可用的四个测试库。我们首先了解了标准的 clojure.test 库,它提供了一套丰富的测试功能。我们学习的第二个库是 Expectations。它允许我们编写简洁的测试,因为它侧重于可读性。
Midje库使我们能够探索自顶向下的测试驱动开发(TDD)。我们为主要的函数创建了一个测试,并为将来要实现的函数创建了存根。TDD 允许我们专注于测试函数的特性,而不必担心实现所有使用的子函数。
最后使用的库是test.check,它向我们介绍了基于属性的测试。使用基于属性的测试,我们以一般形式描述函数参数的性质。这使得测试可以根据这些属性生成输入。使用这种类型的测试,我们可以用几行代码运行成千上万的测试场景。无需枚举每一个单独的测试用例。
在本章的第二部分,我们学习了 ClojureScript 中的测试。我们了解到cljs.test库为我们提供了与clojure.test库相当的功能。使用clsj.test,我们能够测试 ClojureScript 代码。我们还研究了宏,使我们能够测试异步的 ClojureScript 代码。我们还设置了一个自动运行器,当我们的代码发生变化时,它会自动运行 ClojureScript 测试。
最后,我们完成了两个活动,这些活动使我们能够在项目中应用我们的测试知识。我们使用了在前几章中学习到的库来编写测试,用于开发前几章中的应用程序。
在下一章中,我们将学习宏的概念。宏是一种强大的功能,它允许我们影响 Clojure 语言。
第十一章:11. 宏
学习目标
在本章中,你将学习 Clojure 宏的工作原理以及如何编写它们。宏是 Clojure 的一个非常强大的特性,在许多其他非 Lisp 语言中根本不存在。编写宏需要学习一些新的概念和一些新的技能。本章将带你了解基本概念:区分编译时和运行时执行、引用策略和宏卫生。
到本章结束时,你将能够自动生成函数并创建自定义环境以简化你的代码。
简介
宏是 Lisp 几十年来的一大特色。它们有时被描绘为 Lisp 世界的一种超级能力。虽然其他语言中也有宏,但几十年来,Lisp 拥有最完整的宏系统。为什么是这样?Lisp 家族的语言共享编写修改自身代码的能力。人们经常谈论“代码即数据”:Lisp 程序,其嵌套的括号称为s 表达式,实际上就是列表。作为语言,Lisp 擅长操作列表。Lisp 这个名字最初来自“LISt Processor”,当这种语言在 1958 年首次发明时。因此,Lisp 可以操作 Lisp 程序的代码。通常,这意味着程序修改了自己的代码。
注意
术语同构性通常应用于 Lisp 语言。虽然这个术语的确切含义取决于说话者是谁,但通常意味着 Lisp 是用它们可以自己操作的形式编写的,并且这些相同的结构在 Lisp 解释器或编译器内部或多或少是镜像的。
有时,这被称为元编程:编写一个将为你编写程序的程序。如果这听起来太好了而不真实,那是因为它确实如此。当然,宏不能为你做所有事情。不过,它们可以编写你的程序的一部分,通过移除一些样板代码和其他重复形式,或者通过变换语言的语法以更好地适应手头的任务,使你的代码更容易编写。
这是否曾经发生在你身上?你正在处理一个大项目,你发现自己一个接一个地编写函数和方法,它们几乎完全相同。你知道 DRY 原则:不要重复自己。“也许我这里缺少一个抽象,也许我可以简化这个,”你对自己想。“然而,当你尝试时,总是有一个需要不同且不能抽象掉的代码片段。这可能是每次都不同的条件逻辑。所以,你放弃了更干净的解决方案,继续敲击键盘。宏可能有所帮助。”
这里有一个稍微更具体的场景。你正在编写包含大量布尔逻辑的代码。几天后,你感觉你的代码库中的几乎每个函数都有几个这样的:
(and
(or (> x 23) (> y 55))
(or (= c d) (= e f))
(or (> a 55) (> b 55)))
每次逻辑都略有不同,因此你不能编写一个函数,然而每次逻辑都如此相似,以至于你感觉自己在不断地重复自己。如果你可以只写这个会怎么样呢?
(and-ors (> x 23) (> y 55) | (= c d) (= e f) | (> a 55) (> b 55))
好吧,你可以。使用宏。
这消除了嵌套括号的一层,因此更容易输入。甚至可能更容易阅读。
有几个原因使得这在使用函数时变得不可能。最重要的原因是像and-ors这样的宏保留了and(它本身也是一个宏)的“短路”属性。一旦某个条件失败,评估就会停止,剩余的条件将被忽略,这可能会带来性能上的好处,或者避免可能的副作用。另一个原因是,我们能够在使用宏之前不需要定义|作为分隔符号。这就像能够定义我们自己的语言运算符一样。
这种“魔法”之所以可能,是因为宏是在你的代码编译之前执行的。它们的目的是在代码传递给编译器之前对其进行转换。如果这听起来复杂且令人困惑,那么,有时确实如此。然而,通过构建具体的例子,你很快就会意识到,尽管宏不是魔法的,但它们是有用的。而且,尽管它们可能非常复杂,但并不总是必须如此。了解它们的工作原理也将帮助你避免在不应该是最佳解决方案时使用它们。
Clojure 提供了一个基于——并改进了——Common Lisp 宏系统的现代宏系统,因此它是一个非常强大的工具。尽管如此,Clojure 程序员通常编写的宏比 Common Lisp、Scheme 或 Racket 程序员要少得多。原因可能多种多样。任何关于宏的书籍,在任何 Lisp 中,通常都会首先警告说,只有在你绝对确定一个函数不会工作的时候才应该使用宏。通常,Clojure 的函数式编程足够有用和强大,以至于编写宏实际上并不是必需的。
有时候,尽管如此,宏可能是解决问题的最佳解决方案,或者可以帮助简化原本可能复杂和/或重复的代码。学习编写宏也是任何自重的 Clojure 程序员必经的仪式。那么,让我们开始吧!
什么是宏?
宏是在你的代码编译之前执行的一段代码。宏调用中包含的代码被转换成不同的东西,然后传递给编译器。在 Clojure 中,宏是通过调用defmacro来定义的。对defmacro的调用看起来与对defn的调用相当相似:
(defmacro my-macro
"Macro for showing how to write macros"
[param]
;;TODO: do something
)
尽管表面上看起来相似,但宏和函数之间存在着巨大的差异。与函数不同,宏不是在运行时被调用的。当你的程序最终开始运行时,宏已经被调用。它们产生的代码已经包含在你的程序中,就像你亲自输入一样:
![图 11.1:将编译时与运行时分开是理解宏的关键
![img/B14502_11_01.jpg]
图 11.1:将编译时与运行时分开是理解宏的关键
当你思考和使用宏时,请记住这个想法:你代码中的任何宏原则上都可以被你自己输入的代码所替换。有些宏非常复杂,用手工代码替换它们会非常困难且耗时,但只要有足够的时间、耐心和专业知识,理论上是可以做到的。就像你在运行代码之前先写代码一样,宏在代码运行之前会被展开。
一个非常简单的宏
让我们从一个非常简单且不太有用的宏开始:
(defmacro minimal-macro []
'(println "I'm trapped inside a macro!"))
这看起来很像一个函数定义。我们也可以这样写:
(defn minimal-function []
(println "I'm trapped inside a function!"))
如果我们在 REPL 中运行这两个,结果也是相同的:
user> (minimal-macro)
I'm trapped inside a macro!
nil
user> (minimal-function)
I'm trapped inside a function!
nil
这两个定义之间只有一个有意义的区别,而且这个区别非常小。你看到它了吗?是那个 ',它是 quote 特殊形式的缩写。尽管它很小,但它产生了巨大的影响。
当我们运行 minimal-function 时,Clojure 运行时只是简单地执行对 println 的调用。当我们运行 minimal-macro 时,宏实际上在 Clojure 运行时读取源代码之前,将 (println "I'm trapped inside a macro!") 语句插入到你的源代码中。更准确地说,我们说 Clojure 展开了宏。
最好的方法是通过使用 Clojure 的 macroexpand 函数来欣赏 minimal-macro 和 minimal-function 之间的区别,这个函数将宏调用转换成实际的代码。
这里是调用 macroexpand 在 minimal-function 调用上的结果:
user> (macroexpand '(minimal-function))
(minimal-function)
这看起来有点多余,而且有很好的理由:(minimal-function) 不是一个宏,所以它只会展开成它自己。
注意
当调用 macroexpand 时,引用你正在展开的表式非常重要。否则,macroexpand 将尝试展开对 minimal-function 的调用。因为 println 返回 nil,所以结果将是 nil,也就没有东西可以展开。每次当你从 macroexpand 得到令人惊讶的结果时,确保你没有忘记引用宏。
展开 (minimal-macro) 的过程相当不同:
user> (macroexpand '(minimal-macro))
(println "I'm trapped inside a macro!")
当我们在 REPL 中输入 (minimal-macro) 时,它会被展开成 println 表达式,就像你亲自输入一样。注意,展开后的形式不再被引用。(正如你可能开始猜测的那样,引用是宏编写的一个重要部分。)
编译时与运行时
为了看到引用的重要性,让我们尝试编写一个不带开头的 ' 的宏:
(defmacro mistaken-macro []
(println "I'm trapped... somewhere!"))
在 REPL 中调用这个宏会产生相同的结果:
user> (mistaken-macro)
I'm trapped... somewhere!
nil
这有什么问题?mistaken-macro 似乎和其他宏一样好使……或者不是吗?让我们尝试展开它:
user> (macroexpand '(mistaken-macro))
I'm trapped... somewhere!
nil
这很奇怪!输出与直接调用宏时的输出相同,但它与 minimal-function(它只是展开成自身)和 minimal-macro(它展开成 println 表达式)都完全不同。那么,这里到底发生了什么?为什么移除 quote 引号会有如此大的差异?
调用 (mistaken-macro) 产生与其它形式相同的输出几乎可以称为巧合。println 的调用发生了,但它发生在编译时。由于 println 返回 nil,(mistaken-macro) 展开成正好是 nil。这也是为什么我们在调用 macroexpand 时看到 "I'm trapped…" 消息的原因:那条消息是宏展开的副作用!
minimal-macro 和 mistaken-macro 之间的区别在于前者通过引用形式实际上返回了一个 println 符号和消息字符串。另一方面,mistaken-macro 展开成 nil,因为它在展开时立即调用 println。
在这里有几个重要的观点,我们将在本章的其余部分进行阐述:
-
宏展开时和代码运行时实际上会运行代码。这就是为什么
mistaken-macro实际上会打印出一条消息。 -
理解编译时间和运行时间之间的区别是很重要的。
-
引用很重要,因为它是你作为程序员控制宏展开时执行哪些代码以及运行时运行哪些代码的方式之一。
记住:宏是编写代码的代码。让我们编写一个稍微复杂一点的宏,它将展开成任意数量的 println 调用。我们的目标是,如果我们提供 3 作为参数,宏将展开成这样:
(do
(println "Macro")
(println "Macro")
(println "Macro"))
注意
当与宏一起工作时,始终先思考宏要生成的代码,然后再考虑如何生成它是一个好主意。
与将其视为一系列执行步骤相比,最好将其视为包含一个符号(do)和一系列子列表的列表,每个子列表包含一个符号和一个字符串。将 do 和 println 视为符号而不是特殊形式和函数是一个好方法,因为在展开时,我们只想确保最终生成的形式具有正确的符号并按正确的顺序排列。如果引用了 println,那么宏展开代码也是这样看待它的。
要生成一个列表的列表,我们可以使用 repeat:
user> (repeat 3 '(println "Macro"))
((println "Macro")
(println "Macro")
(println "Macro"))
那个单引号又出现了。如果我们省略它,我们会得到 (nil nil nil nil nil)。多亏了引用,那些 println 调用中没有一个被调用。我们正在接近我们的目标代码,甚至还没有调用 defmacro。
唯一缺少的是初始的 do 符号。只要我们引用它,向列表的头部添加一个值也是相当容易的:
user> (cons 'do (repeat 3 '(println "Macro")))
(do
(println "Macro")
(println "Macro")
(println "Macro"))
我们的代码!剩下要做的就是将其封装成一个宏:
user> (defmacro multi-minimal [n-times]
(cons 'do (repeat n-times '(println "Macro"))))
当我们直接调用宏时,它似乎可以工作:
user> (multi-minimal 3)
Macro
Macro
Macro
nil
macroexpand确认了这一点:
user> (macroexpand '(multi-minimal 3))
(do
(println "Macro")
(println "Macro")
(println "Macro"))
这里的目的是表明宏是写代码的代码。multi-minimal的内容看起来并不像输出。我们甚至可以更进一步,将它们包装在一个函数中:
user> (defn multi-min [n-times]
(cons 'do (repeat n-times '(println "Macro"))))
#'user/multi-min
user> (defmacro multi-minimal-two [n-times]
(multi-min n-times))
#'user/multi-minimal-two
user> (multi-minimal-two 3)
Macro
Macro
Macro
nil
正如我们稍后将要看到的,一些宏看起来有点像模板,但没有任何东西要求它们的定义与输出的代码相似。将代码构建代码分离到单独的函数中可以是一个有用的模式,特别是对于调试。
运行时参数
显然,我们无法期望这个特定的宏非常有用。然而,如果它能打印出除了“宏”之外的内容那就很好了。如果我们给它一个第二个参数——一个要传递给所有println调用的字符串呢?已经有一个参数了;添加第二个参数应该很容易……对吧?
让我们尝试只添加一个参数并将其传递给println:
user> (defmacro parameterized-multi-minimal [n-times s]
(cons 'do (repeat n-times '(println s))))
#'user/parameterized-multi-minimal
不幸的是,这不起作用:
user> (parameterized-multi-minimal 3 "My own text.")
Syntax error compiling at (Chapter11:localhost:58838(clj)*:49:36).
Unable to resolve symbol: s in this context
问题出在哪里?像往常一样,当我们不理解宏的问题时,我们会求助于macroexpand:
user> (macroexpand '(parameterized-multi-minimal 3 "My own text."))
(do (println s) (println s) (println s))
当宏展开完成后,s参数已经消失了。它去哪里了?与defn不同,defmacro不提供运行时变量绑定的上下文。在宏展开期间,原始的n-times参数是可用的,但在之后,宏本身已经被它产生的代码所取代。n-times或s都没有留下任何痕迹。
当然,肯定有绕过这个问题的方法,因为如果宏不接受参数,它们将变得相当无用。如果我们想要有一个上下文,我们只需要在运行时仍然存在的引用代码中显式地创建那个上下文。在这种情况下,最简单的解决方案是一个let绑定。我们需要将我们的目标代码更改为如下所示:
(let [string-to-print "My own text."]
(println string-to-print)
(println string-to-print)
(println string-to-print))
为什么let绑定被称为string-to-print而不是s?记住,当代码最终运行时,s和n-times不再作为符号存在。n-times以一种方式存在,因为它决定了有多少println调用最终出现在展开表达式中。从宏调用中来的s参数需要继续存在,不是作为string-to-print,而是作为绑定给string-to-print的字符串。换句话说,在先前的目标代码中,s是存在的:它是"My own text"。
我们可以通过使用 Clojure 的一些列表操作能力来仔细构建我们需要的精确代码,也就是说,一个包含println表达式子列表的let绑定:
(defmacro parameterized-multi-minimal [n-times s]
(concat (list 'let ['string-to-print s])
(repeat n-times '(println string-to-print))))
我们将检查以确保它工作:
user> (parameterized-multi-minimal 3 "My own text.")
My own text.
My own text.
My own text.
nil
这次,当我们将其绑定到string-to-print时,s没有被引用。当宏展开时,s的值将被插入到宏将要返回的列表中。像let和string-to-print这样的项被引用,因为我们不希望在宏展开中包含它们的值;我们希望它们以符号的形式出现。
为了实现这一点,我们不得不做一些巧妙的引号操作,对某些项应用单引号,而对其他项不应用。这就是为什么我们使用 list 而不是 '()``。单引号是 (quote...) 的快捷方式。quote是一个特殊形式,其效果是引号括号内的所有内容,包括子列表。有时,这正是我们想要的,比如'(println string-to-print)。我们需要列表中的两个项都作为符号出现,而不是作为值。list简单地返回一个列表。通过使用list`,我们可以选择哪些项应该被引号引用,哪些不应该。
这不是一个最优的引号解决方案。简而言之,我们将看到 Clojure 提供了另一种更复杂的语法,这使得这更加容易。然而,基本原理是相同的,所以了解引号的工作机制是很好的。
在这个宏版本的另一个值得注意(或者简单地说,奇怪)的事情是:为什么我们使用 concat 来连接列表和 repeat 调用的结果?之前,我们是在 do 表达式内部包裹所有的 println 调用。现在有了 let 块,这不再是必要的了。同时,我们仍然需要确保 println 调用没有被包裹在列表中。使用 concat 解决了眼前的问题,但这并不是一个优雅的解决方案。在下一段中,新的引号语法也会使这变得更加容易。
语法引号
编写宏的艺术中,很大一部分在于掌握扩展代码和输出代码之间的分离。对这种分离的控制很大程度上取决于在宏展开时决定什么被引号引用,什么没有被引号引用。之前的例子开始揭示了标准 quote 特殊形式的局限性。一旦列表被引号引用,它的所有符号和子列表也会被 quote。因此,quote 是一个相当直接的工具。
因此,Clojure,像许多 Lisps 一样,提供了一个更复杂的引号机制,称为 ',即反引号字符,而不是标准的单引号。当单独使用时,反引号的行为与 quote 大致相同:默认情况下,所有符号和子列表都会被引号引用。区别在于,语法引号允许我们标记某些形式、子列表或符号,这些不应该被引号引用。
使用语法引号,我们可以简化之前章节中的宏:
(defmacro parameterized-with-syntax [n-times s]
'(do ~@(repeat n-times '(println ~s))))
这是如何工作的?这里有三点新内容:
-
在
do形式和println形式前面的syntax-quote反引号 -
在
s符号前面的~ -
在
repeat形式前面的~@
syntax-quote 反引号开始语法引用。然后,我们可以看到两种防止对子元素进行引用的不同方式。其中最简单的是 '(println ~s) 中的波浪号:println 符号,当宏展开时,列表本身将被引用,但 ~s 将评估为 s 的值。波浪号被称为 unquote。多亏了 unquote,我们现在可以将 s 的值插入到每个 println 调用中,我们不再需要将整个表达式包裹在一个 let 表达式中。
~@ 被称为 unquote-splicing,一次做两件事。像 unquote 一样,它防止对其附加的表达式进行引用。然而,与 unquote 不同的是,它只适用于列表,因为它的作用是将列表的内容拼接到包含列表中。在此之前,我们必须使用 cons 或 concat 来避免所有 (println "String!") 表达式都被包裹在一个列表中。
展开后,对新宏的调用看起来像这样:
user> (macroexpand '(parameterized-with-syntax 3 "Syntax quoting!"))
(do
(clojure.core/println "Syntax quoting!")
(clojure.core/println "Syntax quoting!")
(clojure.core/println "Syntax quoting!"))
你可能会注意到与之前的一些宏展开相比有细微的差别:println 符号被命名空间化了!这是编写健壮宏的一个重要特性,我们将在探讨宏卫生问题时进一步探讨。首先,让我们先练习使用我们新的宏技能。
练习 11.01:and-ors 宏
在本章开头,为了激发你对宏魔法的兴趣,我们向你展示了一个名为 and-ors 的宏。它的目的是在有许多表达式包含在顶层 and 表达式中时,使编写嵌套布尔逻辑更容易。
考虑这个函数(希望,真正的代码会有更具描述性的参数名!):
(defn validate-params
[a b c d]
(and
(or (> a 5) (> a b))
(or (= b a) (> b 5))
(or (> a c) (> c 5) (= c b))
(or (= a d) (> d 5))))
这样的逻辑本身很难阅读,尤其是在有很多条件的情况下。
假设你正在编写一个将包含大量重要业务逻辑的库,这些逻辑大多用一系列布尔运算符表达,就像前面的函数那样。我们可以做的任何使它更简单的事情都将有助于提高你的代码库的可读性。and-ors 宏将是一个受欢迎的改进。在前面函数中,你可以用以下方式代替嵌套的 or 表达式:
(defn validate-params
[a b c d]
(and-ors
(> a 5) (> a b) |
(= b a) (> b 5) |
(> a c) (> c 5) (= c b) |
(= a d) (> d 5)))
这是一个诚实的微小改进,但它确实去除了一些括号,并且通常有助于将重点放在逻辑表达式本身上。现在,我们只需要弄清楚如何使这个工作:
-
从
defmacro的骨架开始:(defmacro and-ors [& or-exps] ;; TODO: write a macro )就像函数一样,宏可以是可变参数的,可以接受未知数量的参数。这正是我们在这里需要的,因为我们不知道会有多少
or表达式。 -
编写逻辑,每次在
or-exps列表中出现|符号时,将其分割:(defmacro and-ors [& or-exps] (let [groups (remove (partial = '(|)) (partition-by (partial = '|) or-exps))] ;; TODO: do something finally ))将
or-exps的参数列表拆分成子列表是一个简单的序列操作。有几种方法可以做到这一点,包括编写一个小的外部函数。在这里,我们选择使用partition-by。记住,partition-by接收一个函数,该函数用于决定在哪里拆分列表。每当函数返回不同的响应时,列表就会被拆分。我们只关心当前项是|还是其他东西,这正是(partial = '|)要做的。这里的唯一技巧是我们需要引用|符号,以确保我们谈论的是符号本身,而不是它的值。(这是好事,因为它没有值。)根据
|符号进行拆分会将符号作为子列表留下。你可以在 REPL 中尝试这个,如果你在输入中引用了|符号:user> (partition-by (partial = '|) [1 2 '| 3 4]) ((1 2) (|) (3 4))我们需要从结果中移除
(|)子列表。我们将使用remove来完成这个任务:(remove #(= '(|) %) (partition-by (partial = '|) or-exps))注意
|符号现在已从我们的代码中消失。它将以任何方式都不会出现在编译后的代码中。这就是为什么我们不必担心它被定义或命名空间化。从编译后的代码的角度来看,它就像它从一开始就不在那里一样。现在,我们有
groups绑定,它包含一个列表的列表。 -
准备输出外部的
and表达式。我们想要创建的结构是包含零个或多个表达式的
and表达式。所以首先,我们需要and表达式:(defmacro and-ors [& or-exps] (let [groups (remove (partial = '(|)) (partition-by (partial = '|) or-exps))] '(and ;; TODO: the ors )))在这里,重要的是引用。
(and…)前面的反引号开始语法引用。这首先确保(and…)形式将被输出,其次确保我们能够使用splice-insert来包含新的or表达式。 -
将每个子列表转换为
or表达式。这里是完整的宏:
(defmacro and-ors [& or-exps] (let [groups (remove (partial = '(|)) (partition-by (partial = '|) or-exps))] '(and ~@(map (fn [g] '(or ~@g)) groups))))map的调用将返回一个列表。由于我们不想在(and…)表达式内部有额外的括号,所以我们在这里使用insert-splice。在匿名映射函数内部,我们需要再次开始语法引用,因为前面的~@暂停了它。这就是为什么我们在(or…)前面放上了反引号。同样的过程在groups的每个元素中都会重复,因为每个元素都是一个要放在(or…)表达式内部的项列表。 -
测试并宏展开你的新宏:
user> (and-ors (> 5 3) (= 6 6) | (> 6 3) | (= 5 5 5)) true它似乎有效。让我们用嵌套的
and-ors来试一试:user> (and-ors (and-ors (= 3 3) | (= 5 5) (= 6 8)) | (> 5 3) (= 6 6) | (> 6 3) | (= 5 5 5)) true宏展开将告诉我们我们是否得到了我们最初想要的。为了避免展开
and和or宏,我们在这里使用macroexpand的不同版本,称为macroexpand-1。这两个之间的区别是,当一个宏展开包含其他宏时,macroexpand将递归地继续展开所有嵌套的宏,而macroexpand-1将在第一个宏之后停止。在编写自己的宏时,macroexpand-1通常更直观,因为它阻止我们看到内置宏的细节,例如let或,如本例中的and或or:user> (macroexpand-1 '(and-ors (> 5 3) (= 6 6) | (> 6 3) | (= 5 5 5))) (clojure.core/and (clojure.core/or (> 5 3) (= 6 6)) (clojure.core/or (> 6 3)) (clojure.core/or (= 5 5 5)))这个练习展示了即使是四行的宏也能让你引入可能是有用的语法改进。是否编写像
and-ors这样的宏的决定将基于是否在代码库中编写所有那些重复的布尔值变得痛苦。
练习 11.02:自动 HTML 库
在这个练习中,你需要编写一个宏,给定一个项目列表,自动为每个项目创建一个函数。这在需要大量非常相似函数的情况下非常有用。
回到第六章,递归和循环,你构建了一个库,将 Clojure 向量转换为 HTML,使用关键字来识别 HTML 标签。在那个系统中,HTML 元素会写成这样:
[:h1 "First things first"]
输出如下:
<h1>First things first</h1>
最近,一位同事决定用函数而不是向量重写这个库。使用这种新方法,HTML 元素会写成函数的形式:
(h1 {:class "intro"} "First things first")
函数可以嵌套,这样开发者就可以在源代码中编写整个 HTML 页面。
很不幸,你的同事在完成项目之前就离开了,现在你需要接手这个项目。
你可以在packt.live/2Gf4bn9查看新库的样子。
阅读代码时,你开始看到库是如何工作的,以及为什么它会被放弃。你意识到,就像你的前同事一样,你不想为 HTML 规范中的每个元素编写单独的函数!这将是一个漫长且容易出错的复制粘贴过程,难以维护。仔细观察后,你开始怀疑你的前同事也有同样的想法。最后一个函数tag-fn看起来像是对这种方法的一种尝试性概括。它还包括了类似于第四章中 wrap-unwrap 技术的mapcat的巧妙使用,以便将项目列表插入到包含列表中。不幸的是,文件就到这里结束了。
使用tag-fn为每个元素生成一个函数似乎是个好主意。这意味着仍然需要为每种 HTML 元素定义一个var。你的代码看起来会是这样:
(def p (tag-fn "p"))
(def ul (tag-fn "ul"))
(def li (tag-fn "li"))
(def h1 (tag-fn "h1"))
(def h2 (tag-fn "h2"))
(def h3 (tag-fn "h3"))
(def h4 (tag-fn "h4"))
;; etc. etc. etc.
这样做比手动编写所有函数要好得多,但仍然显得过于重复。也许宏可以帮助解决这个问题?
使用正确的宏,我们只需从 HTML 规范中复制一个元素列表,将它们用引号括起来,然后在加载源文件时运行一次宏:
(define-html-tags "p" "ul" "li" "h1" "h2" "h3" "h4")
输出将是一系列def表达式,就像我们在前面的代码中所看到的那样。让我们看看:
-
设置一个新的项目目录,并创建一个空的
deps.edn文件。从本书的 GitHub 仓库(packt.live/2Gf4bn9)复制htmlgen.clj文件。你将在文件的底部添加你的代码。 -
首先,勾勒出你想要生成的代码。因为我们需要能够生成任意数量的函数,所以我们的宏将返回一个
def形式的列表。将这些形式包含在do表达式中会更简单:(do (def h1 (tag-fn "h1")) (def h2 (tag-fn "h2")) (def h3 (tag-fn "h3")) (def h4 (tag-fn "h4"))) -
使用语法引用将
syntax-quote应用于do表达式来编写宏的骨架:(defmacro define-html-tags [& tags] '(do ;; TODO: macro code ))注意
我们给宏一个
[& tags]。我们稍后会回到这一点。 -
对
tags中的参数进行映射,以生成一系列def表达式:(defmacro define-html-tags [& tags] '(do ~@(map (fn [tagname] '(def ~(symbol tagname) (tag-fn ~tagname))) tags)))这里有几个需要注意的地方。仔细看看哪些是引用的,哪些不是:使用
unquote-splice(~@)确保map返回的元素是do表达式的直接子元素。然后,使用syntax-quote('),我们引用整个def表达式,除了将要成为我们定义的变量名称的部分,我们使用unquote(~)来保护它不被引用。最后,tag-fn表达式从def前的反引号继承其引用。我们仍然需要使用unquote,以便将tagname值(例如"h1")插入,而不是仅仅插入tagname符号本身。这里需要
symbol函数,因为输入是一个字符串。tag-fn接受一个字符串,但它的名称需要是一个符号。 -
测试新的宏。评估宏定义,然后使用它来定义一些 HTML 元素函数:
(define-html-tags "h1" "h2" "h3" "h4" "h5" "p" "div" "span")通过复制官方的 HTML 标签列表,你可以快速实现整个标准。现在,你可以在
packt-clojure.htmlgen命名空间内部使用你的新函数来生成 HTML 元素:packt-clj.htmlgen> (div (h1 "First things first") (p {:class "intro"} "What's the best way to get started?")) "<div><h1>First things first</h1><p class=\"intro\">What's the best way to get started?</p></div>"它工作了。你已经满足了作业和管理的要求……至少目前是这样。
自动生成大量函数是宏非常适合的任务。这样的宏不过是函数的一个模板。当然,宏可以做更多的事情,但有时,大量生成简单的函数正是你所需要的。
在这个练习中,我们编写的宏是可变参数的。它接受可变数量的参数,其参数列表如下:[& tag]。可能会有人想重写宏,使其接受标签名称的列表或向量。这样,我们就可以定义我们的列表,然后单独对它调用宏。这是我们的宏,重写为接受列表:
(defmacro define-html-tags-from-list [tags]
'(do
~@(map (fn [tagname]
'(def ~(symbol tagname) (tag-fn ~tagname)))
tags)))
不幸的是,这不起作用:
![图 11.2:在编译时,像 heading-tags 这样的符号只是一个符号,不是一个列表]

图 11.2:在编译时,像 heading-tags 这样的符号只是一个符号,不是一个列表
宏失败了,因为在编译时,heading-tags还不是列表。宏只能“看到”一个符号,就像错误信息所说的那样,它不知道如何从一个符号创建一个序列。练习中的宏代码需要在编译时访问实际的标签名称。
这就是为什么不能在宏上使用apply的原因:
packt-clj.htmlgen> (apply define-html-tags ["br" "p" "a"])
Syntax error compiling at (Exercise01:localhost:52997(clj)*:116:24).
Can't take value of a macro: #'packt-clojure.htmlgen/define-html-tags
当 apply 调用发生时,代码已经运行,此时宏展开已经太晚了。同样,宏不能作为 map 或 filter 等函数的参数传递。虽然宏为语言带来了极大的灵活性,但使用宏而不是函数总是有代价的。
练习 11.03:扩展 HTML 库
你的团队对你的快速实现所有已知的 HTML 元素作为函数印象深刻,现在,HTML 生成库在你的组织中变得越来越受欢迎。正如成功的库经常发生的那样,使用它的开发者开始遇到问题。你的团队中最常见的挫败感之一是,在包含元素内部包裹元素列表很尴尬。
这里有一个常见用例的简单示例:通过映射字符串列表来创建无序列表(<ul>),将每个字符串转换为一个列表项(<li>)。多亏了原始 tag-fn 的编写方式,它能够处理序列以及字符串,因此从项目向量创建 HTML 列表的代码相当简单:
(ul (map li ["item 1" "item2"]))
然而,这种模式在你的同事的代码中出现的频率如此之高,以至于他们开始抱怨你的库迫使他们重复。他们问道:“为什么不能更简单一些?我们知道当给 ul 一个列表时,将会产生一个 (map li…) 调用。这不能是自动的吗?”
“嗯,”你回答,“当然可以。”经过一番思考,你决定想让你的同事能够编写以下内容:
(ul->li ["item 1" "item 2"])
这不仅适用于 ul。ol->li 也会非常有用。一些表格元素也可以使用相同的方法:table->tr 和 tbody->tr 对于表格行列表(<tr>)非常有用,同样,tr->td 对于包含表格单元格列表(<td>)的表格行(<tr>)也非常有用。你决定编写第二个、专门的宏 define-html-list-tags,它将接受标签名对并定义相应的函数:
-
在上一个练习的同一文件中,定义一个辅助函数来构建新的函数。它将被命名为
subtag-fn,并且比tag-fn更简单,因为它不需要处理那么多不同的情况:(defn subtag-fn [tagname subtag] (fn [content] (str (->opening-tag tagname nil) (apply str (map subtag content)) (->end-tag tagname))))函数的内容看起来相对熟悉。这里唯一的新代码片段是
(map subtag content)。对于像ul→li这样的函数,li函数(我们假设它已经定义好了——确保首先调用define-html-tags!)将在content参数的每个项上被调用。 -
测试辅助函数。辅助函数将在运行时被调用,因此很容易测试:
packt-clj.htmlgen> ((subtag-fn "ul" li) ["Item 1" "Item 2"]) "<ul><li>Item 1</li><li>Item 2</li></ul>"再次注意,这是因为
li函数已经定义好了。我们将其作为符号传递——li,不带引号——而不是像"ul"这样的字符串。ul函数将在宏运行之后才会被定义。然而,这里有一个小问题:我们无法可选地传递一个属性映射。这破坏了我们为其他函数建立的接口,所以我们需要在继续之前修复它。
-
将
subtag-fn返回的匿名函数添加第二个参数:(defn subtag-fn [tagname subtag] (fn subtag-function-builder ([content] (subtag-function-builder nil content)) ([attrs content] (str (->opening-tag tagname attrs) (apply str (map subtag content)) (->end-tag tagname)))))这段代码突出了匿名函数的两个有趣特性。首先,它们支持多个参数,这使得它们与
defn定义的对应函数一样灵活。其次,尽管是匿名的,但它们可以有名字。这种特性存在于其他语言中,如 JavaScript,并且对于调试目的可能很有用。在阅读错误消息时,有一个函数名可以大有帮助。在 Clojure 中给匿名函数命名还有一个优点,即函数可以通过这种方式引用自己。因此,在前面的代码中,单个版本的函数可以用nil填充attrs参数,然后调用双参数版本。这样,我们就不必两次编写函数的逻辑。 -
测试新的
subtag-fn:packt-clj.htmlgen> ((subtag-fn "ul" li) {:class "my-class"} ["Item 1" "Item 2"]) "<ul class=\"my-class\"><li>Item 1</li><li>Item 2</li></ul>"这有效。现在,让我们尝试单参数形式:
packt-clj.htmlgen> ((subtag-fn "ul" li) ["Item 1" "Item 2"]) "<ul><li>Item 1</li><li>Item 2</li></ul>"它仍然有效!
-
编写将定义如
ul->li这样的函数的宏:设计宏的第一步通常是决定如何调用它。对这个宏的调用应该看起来像这样:
(define-html-list-tags ["ul" "li"] ["ol" "li"]) (defmacro define-html-list-tags [& tags-with-subtags] '(do ~@(map (fn [[tagname subtag]] '(do (def ~(symbol tagname) (tag-fn ~tagname)) (def ~(symbol (str tagname "->" subtag)) (subtag-fn ~tagname ~(symbol subtag))))) tags-with-subtags)))要理解这里发生的事情,让我们从传递给
map的匿名函数内部开始。第一行应该看起来很熟悉:(def ~(symbol tagname) (tag-fn ~tagname))这与之前练习中的
define-html-tags宏完全相同。即使我们要定义ul->li,我们仍然需要定义ul函数。下一条是定义如
ul->li这样的函数的地方:(def ~(symbol (str tagname "->" subtag)) (subtag-fn ~tagname ~(symbol subtag)))这个定义与前面的代码遵循相同的模式,只是我们使用
str来构建将成为函数名的符号,并将子标签字符串也转换成符号。这两个定义都被
do表达式包裹,map的输出反过来又被单个do表达式包裹。展开后,它看起来像这样:packt-clj.htmlgen> (macroexpand '(define-html-list-tags ["ul" "li"] ["ol" "li"])) (do (do (def ul (packt-clj.htmlgen/tag-fn "ul")) (def ul->li (packt-clj.htmlgen/subtag-fn "ul" li))) (do (def ol (packt-clj.htmlgen/tag-fn "ol")) (def ol->li (packt-clj.htmlgen/subtag-fn "ol" li)))) -
测试新的宏:
packt-clj.htmlgen> (define-html-list-tags ["ul" "li"] ["ol" "li"]) #'packt-clj.htmlgen/ol->li packt-clj.htmlgen> (ol->li ["Item 1" "Item 2"]) "<ol><li>Item 1</li><li>Item 2</li></ol>" packt-clj.htmlgen> (ol->li {:class "my-class"} ["Item 1" "Item 2"]) "<ol class=\"my-class\"><li>Item 1</li><li>Item 2</li></ol>"它有效。
在之前的练习中,使用嵌套的 do 表达式可能看起来不够优雅。我们当然永远不会手动以这种方式编写代码!然而,对于由宏生成的代码,这真的不是问题。这并不意味着宏应该扩展成复杂的代码。简单总是更好。然而,大多数时候,没有人需要阅读这段代码。有人(可能是你)可能需要阅读和调试宏的代码,所以尽可能保持代码简单通常是值得的,即使是以牺牲一些额外的嵌套 do 表达式或其他重复代码怪癖为代价。
我们当然可以使用 mapcat 来创建一个扁平列表:
(defmacro define-html-list-tags-with-mapcat [& tags-with-subtags]
'(do
~@(mapcat (fn [[tagname subtag]]
['(def ~(symbol tagname) (tag-fn ~tagname))
'(def ~(symbol (str tagname "->" subtag)) (subtag-fn ~tagname ~(symbol subtag)))])
tags-with-subtags)))
这里有一个微妙的不同之处,即语法引用已经从包含的 (do…) 表达式移动到了 (def…) 表达式本身。在运行时,向量将不再存在,所以我们不想引用它。
此版本在展开时生成更美观的代码:
packt-clj.htmlgen> (macroexpand '(define-html-list-tags-with-mapcat ["ul" "li"] ["ol" "li"]))
(do
(def ul (packt-clj.htmlgen/tag-fn "ul"))
(def ul->li (packt-clj.htmlgen/subtag-fn "ul" li))
(def ol (packt-clj.htmlgen/tag-fn "ol"))
(def ol->li (packt-clj.htmlgen/subtag-fn "ol" li)))
你的体验可能会有所不同,但一般来说,简化宏代码比生成优雅的展开更重要。宏是你要调试的代码。
在这个和上一个练习中,我们使用了def以及构建新函数的辅助函数。没有什么阻止你使用defn编写定义新函数的宏。
ClojureScript 中的宏
在学习宏时,区分编译时间和运行时间可能是最重要的概念。在进一步探讨这一区别的后果之前,值得看看它如何影响 ClojureScript 中的宏,在 ClojureScript 中,编译和执行之间的关系比在 JVM Clojure 中要复杂一些。
ClojureScript 在 JavaScript 运行时中运行,如浏览器或 Node.js。这是可能的,因为 ClojureScript 代码首先由 ClojureScript 编译器编译,该编译器是一个用 Clojure 编写的程序,在 JVM 上运行。这意味着一旦 ClojureScript 程序编译并运行,就不再能访问编译阶段。
这对在 ClojureScript 中使用宏有几个后果,其中最重要的是 ClojureScript 宏不能定义在.cljs文件中
与其他 ClojureScript 代码并排。相反,它们是在单独的文件中定义的,这些文件具有.clj文件扩展名或跨编译的.cljc扩展名。
在本书配套的 GitHub 仓库中,有一个最小的ClojureScript 项目展示了这一点。它包含两个命名空间:minmacros/core.cljs和minmacros/macros.cljc。
minmacros/core.cljs命名空间使用特殊语法来引入minmacros/macros.cljc中的宏。以下是全部内容:
(ns minmacros.core
(:require-macros [minmacros.macros :as mm]))
(println "Hello from clojurescript")
(mm/minimal-macro)
这是在唯一需要指定:require-macros的情况。在 JVM Clojure 中不存在,只有 ClojureScript 中存在。如果minmacros/macros.cljc包含我们也想导入的函数,我们必须单独引入命名空间:
(ns minmacros.core
(:require-macros [minmacros.macros :as mm])
(:require [minmacros.macros :as mm]))
注意,我们可以在两种情况下使用相同的命名空间别名。这是因为宏展开阶段与代码执行阶段是完全独立的。
要看到这些宏的实际效果,请从 GitHub 复制完整的Chapter11/minmacros目录(packt.live/2TQHTjQ)。在你的终端中,切换到minmacros目录。从那里,你可以从命令行运行以下命令:
$ clj --main cljs.main --compile minmacros.core --repl
如果你使用的是 Windows,你需要从packt.live/36m0O8q下载一份cljs.jar。然后,假设cljs.jar在你的工作目录中,你可以运行以下命令:
$ java -cp "cljs.jar;src" cljs.main --compile minmacros.core --repl
在你的终端中,println的输出应该出现在 REPL 提示符之前:

图 11.3:终端输出
其中第二个,minmacros/macros.cljc,就像任何其他 Clojure 命名空间一样编写。实际上,这是一个要求,因为它将在 JVM 上编译。即使这里的宏针对 JavaScript 运行时,宏展开代码也不能包含任何 JavaScript 特定的代码。然而,展开后的代码可以包含 ClojureScript 特定的代码,因为它将在浏览器或 Node.js 等环境中运行。
例如,在宏定义中使用 JavaScript 的原生字符串函数是不行的。考虑以下(虽然有些人为)的例子,我们在宏展开代码中尝试使用名为 includes 的 JavaScript 字符串方法作为测试:
(defmacro js-macro [symbol-name]
'(def ~(symbol symbol-name)
~(if (.includes symbol-name "b")
"Hello"
"Goodbye")))
Java 字符串没有 includes 方法,所以当我们尝试在 REPL 中调用这个宏时,我们会得到一个错误:
cljs.user=> (minmacros.macros/js-macro "hello")
Unexpected error (IllegalArgumentException) macroexpanding minmacros.macros/js-macro at (<cljs repl>:1:1).
No matching method includes found taking 1 args for class java.lang.String
基于 Java 的 ClojureScript 编译器不能使用 JavaScript 字符串方法;因此,宏展开失败。
虽然在宏展开期间使用 JavaScript 特定的代码是不可能的,但使用宏创建仅在 JavaScript 平台上运行的代码是完全可行的。这里有一个同样人为的宏,它正是这样做的:
(defmacro runtime-includes [function-name character]
'(defn ~(symbol function-name) []
(if (.includes "Clojurescript macros" ~character)
"Found it!"
"Not here...")))
在 REPL 中,我们可以定义并调用一个函数,该函数返回 "Found it!" 或 "Not here…":
cljs.user=> (load-file "minmacros/core.cljs")
nil
cljs.user=> Hello from clojurescript
I'm trapped inside a Clojurescript macro!
cljs.user=> (minmacros.macros/runtime-includes "hello" "m")
#'cljs.user/hello
cljs.user=> (hello)
"Found it!"
cljs.user=>
实际上,这类问题很少出现。宏展开代码需要底层平台帮助的情况极为罕见。如果你发现自己在这两个平台上都在这样做,那可能是一个迹象,表明你需要重新思考你试图通过宏实现的目标。这些例子的目的是帮助说明 ClojureScript 编译过程的细节在编写宏时是如何重要的。在编写宏时,始终非常重要地区分编译时间和运行时;在 ClojureScript 中,两者之间的距离要大得多。一旦考虑到这些问题,ClojureScript 中编写宏的实际过程与编写 Clojure 宏是相同的。
宏卫生
与大多数编程语言一样,Clojure 提供了许多资源来避免名称冲突。命名空间、let 绑定和词法作用域都有助于使选择错误名称覆盖变量变得相当困难。因为它们在不同的空间和不同的时间操作,宏有绕过一些安全栏的可能性。"宏卫生" 是编写避免 变量捕获 的宏的艺术。变量捕获发生在宏产生的符号与周围环境中的宏相同时。
注意
术语 变量捕获 来自 Lisp 家族的其他语言。与 Clojure 不同,大多数 Lisp 语言没有不可变的数据结构,所以“变量”这个词非常合适。尽管大多数 Clojure “变量”实际上并不是变量,我们仍将继续说“变量捕获”。
这里有一个快速示例。在本章的早期,我们尝试编写一个这样的宏:
user> (defmacro parameterized-multi-minimal [n-times s]
(cons 'do (repeat n-times '(println s))))
#'user/parameterized-multi-minimal
这个宏不起作用。它产生了这个错误:
user> (parameterized-multi-minimal 5 "error?")
Syntax error compiling at (Exercise01:localhost:52997(clj)*:121:36).
Unable to resolve symbol: s in this context
原因是s参数在展开后消失了。结果,(println s)失败了,因为在运行时没有s。但是,如果s已经定义了呢?我们可以这样做:
user> (let [s "Wrong"]
(parameterized-multi-minimal 2 "Right"))
Wrong
Wrong
nil
尽管没有语法错误,但这实际上是非常错误的。根据上下文,这个宏的行为可能不会相同。真正的参数,“Right”,被环境中的变量“Wrong”所掩盖。很容易想象这样的代码可能会产生极其不可预测的结果。宏编写者对宏被调用时的环境中s可能绑定到什么没有任何了解或控制。
Clojure 宏系统提供了一些防止这类问题的保护措施。我们已经看到了其中之一。syntax-quote反引号将符号分配到宏定义的命名空间中:
user> '(my-symbol 5)
(user/my-symbol 5)
这提供了一种对变量捕获的初步保护,因为命名空间中的符号不会与核心 Clojure 函数或局部let绑定混淆。实际上,let宏不允许使用命名空间符号作为绑定。
注意
当你在查看你正在工作的宏的展开时,如果你看到被分配给当前命名空间的符号,这可能是一个迹象,表明这些符号容易受到变量捕获的影响。
让我们尝试一个稍微更现实的例子。这是一个定义类似于let的环境的宏,其中body参数可以被评估。这是宏的常见结构,其中宏设置并可能拆除一个专门的环境。这个环境可能是一个数据库连接、一个打开的文件,或者像这个例子中一样,只是一系列绑定,这使得编写代码更容易。这个宏将接受一个整数和一个符号,然后提供一个环境,其中自动绑定整数为一个字符串、一个整数和一个 Java double。符号参数用于定义-as-string、-as-int和-as-double绑定,这些绑定可以在提供的body代码中使用:
(defmacro let-number [[binding n] body]
'(let [~(symbol (str binding "-as-string")) (str ~n)
~(symbol (str binding "-as-int")) (int ~n)
~(symbol (str binding "-as-double")) (double ~n)]
~body))
在一个简单的情况下,let-int宏可以这样使用:
user> (let-number [my-int 5]
(type my-int-as-string))
java.lang.String
这个结果只是表明my-int-as-string确实被定义了,并且整数5已经被强制转换为字符串。
但是,当其中一个内部绑定已经定义并在宏参数中使用时,看看会发生什么:
user> (let [my-int-as-int 1000]
(let-number [my-int (/ my-int-as-int 2)]
(str "The result is: " my-int-as-double)))
"The result is: 250.0"
即使在使用宏时,1000 除以 2 应该是 500,而不是 250!发生了什么?这里的问题是,在宏中,(/ my-int-as-int 2)参数在传递给宏之前没有被评估。宏“不知道”500 这个值。它“只看到”编译时存在的代码。这个宏调用展开的版本提供了更好的视角来了解正在发生的事情:
user> (macroexpand-1 '(let-number [my-int (/ my-int-as-int 2)]
(str "The result is: " my-int-as-double)))
(clojure.core/let
[my-int-as-string
(clojure.core/str (/ my-int-as-int 2))
my-int-as-int
(clojure.core/int (/ my-int-as-int 2))
my-int-as-double
(clojure.core/double (/ my-int-as-int 2))]
(str "The result is: " my-int-as-double))
首先要注意的是 (/ my-int-as-int 2) 出现了三次。当 my-int-as-double 被定义时,局部绑定 my-int-as-int 会覆盖原始绑定。如果原始绑定是 1,000,那么局部的 my-int-as-int 被定义为 500。当 my-int-as-double 被定义时,my-int-as-int 变为 500,然后再次除以二。
这是一个微妙的变量捕获错误,可能会产生灾难性的后果。大多数时候,一切都会正常工作。然后,偶尔,结果会变得无法解释地不正确。长时间盯着源代码看也不会有帮助,因为实际的错误只有在代码展开时才会显现。虽然这看起来像是一个奇怪的边缘情况,但这样的错误实际上在嵌套 let-number 宏中很容易发生。
使用自动 gensyms 避免变量捕获
幸运的是,对于像 let-number 这样的宏,有一个解决方案,即只评估一次参数,然后将结果存储在局部绑定中,这样就可以用于进一步的计算:
(defmacro let-number [[binding n] body]
'(let [result# ~n
~(symbol (str binding "-as-string")) (str result#)
~(symbol (str binding "-as-int")) (int result#)
~(symbol (str binding "-as-double")) (double result#)]
~body))
在 let 绑定的第一行,由 n 表示的计算被执行并绑定到 result#。所有后续的绑定都使用 result# 来产生它们的特定版本:string、integer、double。在编写宏时养成避免重复计算的好习惯是非常好的。
首先,让我们确保这能正常工作:
user> (let [my-int-as-int 1000.0]
(let-number [my-int (/ my-int-as-int 2)]
(str "The result is: " my-int-as-double)))
"The result is: 500.0"
这样更好。表达式 (/ my-int-as-int 2) 只被评估了一次,现在结果也是正确的。如果宏内部的重复代码有副作用,多次评估代码可能会产生其他未预料到的后果。
尽管如此,还有一个问题:为什么我们写 result# 而不是仅仅 result?这是一个 Clojure 宏系统帮助我们避免其他类型错误的例子。当在语法引用表达式中使用时,result 符号的后缀有一个特殊的意义。在这种情况下,Clojure 的语法引用会将 result 转换为所谓的 let-number 宏,你不会看到绑定到名为 result 或 result# 的符号。相反,会有类似 result__15090__auto__ 的东西。这是一个生成的、唯一的符号。Gensyms 是 Clojure 宏系统的一个关键组件,因为当有效地使用时,它们可以防止宏产生的符号与环境中的符号或宏参数中的代码中的符号发生名称冲突。在 REPL 中,你可以生成自己的 gensyms:
user> '(result#)
(result__15099__auto__)
user> '(result#)
(result__15103__auto__)
每次都会产生不同的符号,除非这些符号在同一个语法引用表达式中:
user> '(= result# result#)
(clojure.core/= result__15111__auto__ result__15111__auto__)
这就是为什么 gensyms 可以用来引用同一事物。这也可能是一个限制,因为通常你需要在宏内部使用多个语法引用表达式。我们稍后会展示如何处理这些情况。
为了理解这一点的重要性,想象一下 let-number 的一个版本,其中 n 的结果被分配给 result 而没有 # 后缀。由于这个原因,result 绑定将可以在 body 参数内的代码中使用。大多数时候,这不会成为问题,只要 body 代码没有使用名为 result 的值。但如果发生冲突,直接后果将是非常棘手的错误。
在使用 result 而没有 # 后缀提供的保护时,请考虑这种宏的使用。为此,我们需要将字面结果符号插入到展开的代码中。这需要使用一点宏技巧,即先取消引用然后再次引用符号,如下所示 ~'result。初始的取消引用暂停了语法引用,然后单引号应用了一个不使用命名空间的常规引用。
注意
通常应避免使用取消引用-引用模式,因为 gensyms 要安全得多。然而,在某些情况下,简单的引用符号可能很有用,特别是当符号在宏内部应该具有特殊的语法意义时,正如我们在 练习 11.01,The and-ors Macro 中看到的 | 一样。
我们得到以下宏代码:
user> (defmacro bad-let-number [[binding n] body]
'(let [~'result ~n
~(symbol (str binding "-as-string")) (str ~'result)
~(symbol (str binding "-as-int")) (int ~'result)
~(symbol (str binding "-as-double")) (double ~'result)]
~body))
使用这个宏可能会有危险:
(let [result 42]
(bad-let-number [my-int 1000]
(= result 1000))) ;; Would return "true"
这会非常令人困惑。我们期望初始 let 绑定中的 result 与 let-number 绑定中的 result 相同。调试这个错误需要探索宏的内部工作原理,这可能会是一个漫长而痛苦的过程。Gensyms 可以防止这类问题,Clojure 也使得它们易于使用。
注意
并非所有的 Lisp 都有自动的 gensyms。在 Common Lisp 中,宏编写者必须显式调用 gensym 函数。Clojure 也有一个 gensym 函数,可以在语法引用环境之外创建 gensyms。我们稍后会使用 gensym 函数。
作为一般规则,任何不是暴露接口部分的宏绑定都应该使用 gensyms 定义。在 let-number 宏中,如 my-int-as-string 这样的绑定是接口的一部分,因此需要真实的符号名称。像 result# 这样的绑定仅在“幕后”使用(在编译时),应该使用 gensyms 进行保护。
练习 11.04:监控函数
在这个练习中,我们将编写一个创建函数的宏。这些函数将被包裹在添加了一些额外行为的逻辑中,而原本的函数是普通的。
这些天,你为一家向其他公司提供软件即服务(Software as a Service)的公司工作。管理层希望重新思考向客户收取的价格。他们想知道,在非常细粒度的层面上,哪些服务提供成本最高,哪些客户消耗了最多的资源。
你的任务是开发一个系统来记录代码库中一些重要、高级函数的计算时间。一开始,你以为你不得不遍历整个代码库,并在每次调用这些高级函数时添加计时和报告逻辑。然后,你想起了宏。你已经决定写一个概念验证宏来向你的老板展示。
你想要衡量的所有高级函数都有,包括其他参数,一个client-id参数。你将使用该参数向监控框架提供反馈。一个要求是,即使抛出异常,数据也将被发送。收集详细的错误统计信息将有助于监控和诊断。
在定义函数时,将调用宏而不是defn。除了defn的标准参数外,宏还将接受一个函数,该函数将被调用来向监控系统发送数据。让我们开始吧:
-
在 REPL 中设置宏的概要:
(defmacro defmonitored [fn-name tx-fn args & body-elements] ;; TODO: everything )这意味着,当定义一个需要监控的函数时,我们将编写如下内容,假设
send-to-framework是一个已经定义好的函数:(defmonitored my-func send-to-framework [an-arg another-arg client-id]...)调用该函数将与调用任何其他函数没有区别,只要有一个
client-id参数:(my-func an-arg another-arg 42) -
建立宏内部的基本布局。它将有两个部分:在外部,一些编译时的
let绑定,在内部,一个语法引用的(defn…)表达式:(defmacro defmonitored [fn-name tx-fn & args-and-body] (let [ ;; TODO: compile time let bindings ] '(defn ~fn-name ~[] ;; TODO: the defn template )))外部的
let绑定将设置一些将在更“模板”式的defn部分中使用的值。我们在
defn调用中放置了未引用的fn-name和一个空参数列表。即使它目前还没有做任何事情,宏应该已经可以编译,你应该能够用它来定义一个函数:user> (defmonitored my-func identity []) #'user/my-func user> (my-func) nil -
如你所知,Clojure 的
defn宏允许我们为同一个函数定义多个参数数量。这实际上意味着函数定义可以有两种不同的结构。首先是我们大多数时候使用的单个参数数量:(defn a-func [arg1 arg2] (+ arg1 arg2))多参数函数有几个“主体”。每个“主体”都是一个以参数列表开头的列表,该参数列表特定于该参数数量,然后包括相应的代码:
(defn b-func ([arg1] arg1) ([arg1 arg2] (+ arg1 arg2)) ([arg1 arg2 arg3] (* (+ arg1 arg2) arg3)))因为我们要让
defmonitored充当defn的替代品,所以我们需要能够处理这两种不同的结构。我们现在将要做的许多事情都需要我们能够访问每个函数主体内部的代码,如果有多个参数数量。为了避免处理两个不同的案例,即单参数和多个参数,我们将检查我们有哪些类型。如果我们只有一个参数数量,我们将把参数列表和函数主体包裹在一个列表中,使其具有与多个参数数量相同的结构。
这样的函数是正确的,即使它只有一个参数数量:
(defn a-func ([arg] (println arg)))要做到这一点,我们需要添加一个
vector?检查:(defmacro defmonitored [fn-name tx-fn & args-and-body] (let [pre-arg-list (take-while (complement sequential?) args-and-body) fn-content (drop-while (complement sequential?) args-and-body) fn-bodies (if (vector? (first fn-content)) '(~fn-content) fn-content)] '(defn ~fn-name ~@pre-arg-list ;; TODO: more magic ~@fn-bodies)))当
fn-content的第一个项目是一个参数列表时,我们使用语法引用,然后unquote来将函数体包含在一个列表中。 -
为了处理多种参数数量,我们需要分别处理每个函数体。这似乎是编写一个可以应用于每个函数体的函数的好时机。
我们将调用
wrap-fn-body函数来处理这个问题。即使这是一个函数而不是宏,它也会在编译时被调用,所以我们的所有宏编写技巧在这里仍然适用。函数将重复宏的基本结构:外部的
let绑定和内部的语法引用“模板”。这次,我们将从绑定开始:(defn wrap-fn-body [fn-name tx-fn b] (let [arg-list (first b) client-id-arg (first (filter #(= 'client-id %) arg-list)) fn-body (rest b)] ;; TODO: the body ))我们知道函数体中的第一个项目是参数列表。这很容易提取,就像
fn-body一样,它是一切不是参数列表的东西。 -
在参数列表中添加对
client-id参数的检查。我们的目标是能够按客户端测量函数的使用情况,所以我们绝对需要一个client-id参数。如果它缺失,代码应该失败。为此,我们将简单地检查arg-list绑定:(defn wrap-fn-body [fn-name tx-fn b] (let [arg-list (first b) client-id-arg (first (filter #(= 'client-id %) arg-list)) fn-body (rest b)] (when-not (first (filter #(= % 'client-id) arg-list)) (throw (ex-info "Missing client-id argument" {}))) ;; TODO: the body ))我们可以安全地在这里使用“unquote/quote”模式在
client-id符号上,而不是生成符号,因为我们知道client-id不会意外地从周围的代码中提取出来,正是因为它会在参数列表中。 -
终于到了编写“模板”部分的时候了。这是一段较长的代码块,但实际上非常简单。我们稍后会回来解释这里的一些引用策略:
'(~arg-list (let [start-time# (System/nanoTime)] (try (let [result# (do ~@fn-body)] (~tx-fn {:name ~(name fn-name) :client-id ~'client-id :status :complete :start-time start-time# :end-time (System/nanoTime)}) result#) (catch Exception e# (~tx-fn {:name ~(name fn-name) :client-id ~'client-id :status :error :start-time start-time# :end-time (System/nanoTime)}) (throw e#)))))整体模式相当简单。函数的核心在于内部的
let绑定,在展开的代码中,我们将result#生成符号绑定到实际代码调用产生的输出,该代码仍然在fn-body中。一旦我们得到这个结果,我们就会通过使用
System/nanoTime作为我们的:end-time来向tx-fn发送一些信息。这个函数的“核心”随后被两个东西包裹起来,第一个是一个try/catch形式。如果有异常,我们会通过tx-fn发送一个稍微不同的消息。最后,外部的let绑定是我们建立start-time#绑定的地方,这样我们也可以报告函数开始的时间。注意,我们被要求使用几个自动生成符号:
start-time#、end-time#、results#,甚至还有e#,异常。 -
将所有这些放在一起。
这是完整的
wrap-fn-body函数:(defn wrap-fn-body [fn-name tx-fn b] (let [arg-list (first b) fn-body (rest b)] (when-not (first (filter #(= % 'client-id) arg-list)) (throw (ex-info "Missing client-id argument" {}))) '(~arg-list (let [start-time# (System/nanoTime)] (try (let [result# (do ~@fn-body)] (~tx-fn {:name ~(name fn-name) :client-id ~'client-id :status :complete :start-time start-time# :end-time (System/nanoTime)}) result#) (catch Exception e# (~tx-fn {:name ~(name fn-name) :client-id ~'client-id :status :error :start-time start-time# :end-time (System/nanoTime)}) (throw e#)))))))现在,从最终的
defmonitored宏中调用wrap-fn-body。你可以通过映射函数体列表来完成这个操作。自然地,map会将它们包裹在一个列表中,而我们不想这样,所以我们“unquote-splice”(~@)列表:(defmacro defmonitored [fn-name tx-fn & args-and-body] (let [pre-arg-list (take-while (complement sequential?) args-and-body) fn-content (drop-while (complement sequential?) args-and-body) fn-bodies (if (vector? (first fn-content)) '(~fn-content) fn-content)] '(defn ~fn-name ~@pre-arg-list ~@(map (partial wrap-fn-body fn-name tx-fn) fn-bodies)))) -
测试这个宏,使用
println作为报告函数。首先,定义一个简单的函数:user> (defmonitored my-func println [client-id m] (assoc m :client client-id)) #'user/my-funcmy-func函数似乎工作正常:user> (my-func 32 {:data 123}) {:client-id 32, :name my-func, :start-time 770791427794572, :end-time 770791428448202, :status :complete} {:data 123, :client 32}让我们尝试一些有问题的案例。我们首先抛出一个异常:
user> (defmonitored exception-func println [client-id] (throw (ex-info "Boom!" {})))输出如下:

图 11.4:一个异常,计时、记录和报告
如果我们尝试定义一个没有 client-id 参数的函数会怎样呢?
user> (defmonitored no-client-func println [no-client-id] (+ 1 1))
输出如下:

图 11.5:在宏展开期间抛出的异常表现为语法错误
defmonitored 宏展示了如何使用宏来修改 Clojure 本身。这种情况下需要一个具有额外功能的 defn,所以我们就是这样写的。当然,通过将函数包裹在一个“计时和报告”函数内部,也可以完成相同的事情。但这样做会比简单地用 defmonitored 替换 defn 要侵入性大得多。
何时使用手动 gensyms
当使用 Clojure 的自动 gensyms,如 result# 时,重要的是要记住它们是语法引用的一个特性。在语法引用表达式之外写入 result# 不会抛出异常,但不会有任何魔法发生:
user> (macroexpand '(def my-number# 5))
(def my-number# 5)
另一方面,如果我们语法引用 my-number#,那种“魔法”就会回来:
user> (macroexpand '(def 'my-number# 5))
(def 'my-number__14717__auto__ 5)
这通常不会成为问题。记住自动 gensyms 只在语法引用内部工作是很简单的。不过,还有另一件重要的事情需要记住:自动 gensym 只在它最初定义的语法引用表达式的范围内有效。这意味着,在嵌套的语法引用表达式中,外部表达式中的 result# 不会展开成与内部表达式中的 result# 相同的 gensym。让我们通过一个例子来试试。
考虑这个小程序:
(defmacro fn-context [v & symbol-fn-pairs]
'(let [v# ~v]
~@(map (fn [[sym f]]
'(defn ~sym [x#]
(f v# x#))) (partition 2 symbol-fn-pairs))))
这个宏应该允许我们在 let 块内部定义多个函数,以便每个函数通过 闭包 引用相同的绑定。我们希望它生成的代码,至少在概念上,看起来大致如下:
(let [common-value 5]
(defn adder [n] (+ common-value 5))
(defn subtractor [n] (- common-value 5))
(defn multiplier [n] (* common-value 5)))
为了定义这些函数,我们会写出这个:
(fn-context 5 adder + subtractor - multiplier *)
symbol-fn-pairs 参数预期是一个交替序列的符号,这些符号将成为函数的名称,其中一些也将成为函数。然后我们在宏的主体中使用 partition-by 来组织这个列表成对。(在生产代码中,我们可能希望检查 symbol-fn-pairs 是否包含偶数个项,就像 let 如果其绑定中有奇数个项会抱怨一样。)
让我们看看这个宏是否按预期工作:
user> (fn-context 5 adder + subtractor - multiplier *)
Syntax error compiling at (Activity:localhost:52217(clj)*:246:15).
Unable to resolve symbol: v__14896__auto__ in this context
哦,不!发生了什么?
在我们使用 macroexpand 开始调试之前,我们可以确定问题与 v# 绑定有关,因为 v__14896__auto__ gensym 前缀是 v。
宏展开很难阅读,尤其是在有很多 gensyms 的情况下,坦白说,当它们展开时,几乎算不上是美观的东西。首先尝试找到语法错误消息中提到的符号。在这里,我们将使用 macroexpand-1 来避免展开 let 和 defn 宏:
user> (macroexpand-1 '(fn-context 5 adder + subtractor - multiplier *))
(clojure.core/let
[v__14897__auto__ 5]
(clojure.core/defn
adder
[x__14895__auto__]
(+ v__14896__auto__ x__14895__auto__))
(clojure.core/defn
subtractor
[x__14895__auto__]
(- v__14896__auto__ x__14895__auto__))
(clojure.core/defn
multiplier
[x__14895__auto__]
(* v__14896__auto__ x__14895__auto__)))
这里的问题在于,在初始的let绑定中,v#变成了v__14897__auto__。后来,在函数定义的内部,我们想要使用那个绑定,但不幸的是,在每个函数中,v#已经被替换成了一个稍微不同的 gensym,即v__14896__auto__。突然,语法错误变得更有意义:在每个函数中,我们试图使用一个不存在的绑定。
为什么在这个情况下v#失败了?这里的问题是,我们有两个独立的语法引用表达式。第一个是顶级的let,其中v#最初被使用。然后,我们使用unquote-splice将函数列表拼接到let表达式中。在传递给map的匿名函数内部,我们再次使用语法引用的背单引号来返回defn形式。而且这就是我们遇到麻烦的地方。因为我们不再处于同一个语法引用表达式中,v#不会展开到相同的 gensym。结果,这两个符号被当作完全不同的值来处理,我们得到了一个语法错误。
这是我们不能使用自动 gensym 的时候。相反,我们将不得不以老式的方式来做这件事,并亲自使用gensym函数。下面是如何做的:
(defmacro fn-context [v & symbol-fn-pairs]
(let [common-val-gensym (gensym "common-val-")]
'(let [~common-val-gensym ~v]
~@(map (fn [[sym f]]
'(defn ~sym [x#]
(~f ~common-val-gensym x#))) (partition 2 symbol-fn-pairs)))))
在这里的主要区别在于,我们将宏的第一版本的主体包裹在一个单独的未引用的let表达式中。这意味着它将在编译时存在,但在展开的代码中将在运行时消失。在这个外部的let表达式中,我们调用gensym并将它的值赋给common-val-gensym。在编译时,我们将使用这个符号来引用 gensym。因为我们的let表达式包裹了整个宏的主体,所以common-val-gensym在整个宏中都将保持相同的值。
让我们测试一下:
user> (fn-context 5 adder + subtractor - multiplier *)
#'user/multiplier
user> (adder 5)
10
user> (subtractor 12)
-7
user> (multiplier 10)
50
任何相对复杂的宏都可能包含多个语法引用表达式,因此知道何时手动创建和分配 gensym 可能很重要。这种知识可以帮助避免一些难以调试的情况,在这些情况下,你的代码看起来是正确的,但就是不起作用。
作为一条旁注,fn-context宏可以通过使用partial函数轻松地被功能解决方案所替代。记住,partial接受一个函数和一个或多个参数,并返回一个与原始函数相同的函数,除了第一个一个或多个参数已经被“填充”了。因此,而不是使用宏和defn形式,我们可以简单地使用def和partial来定义新的函数:
user> (let [x 100]
(def adder (partial + x))
(def subtractor (partial - x))
(def multiplier (partial * x)))
#'user/multiplier
user> (adder 5)
105
Clojure 函数式编程提供的可能性如此之大,以至于在日常编码中,几乎很少遇到只能用宏来表示的问题。库的作者倾向于在需要有一个非常清晰的代码接口时稍微更频繁地使用宏。Clojure 宏的最大优势可能是,大多数时候,我们不需要编写它们:要么库的作者已经这样做了,要么在函数式方面有一个解决方案。当然,如果我们真的必须编写一个宏,Clojure 提供了一些非常出色的工具来完成这项工作。
在本章中,我们仔细研究了宏编写中最常见的几个陷阱。通过考虑这些因素,你应该能够编写有效的宏。它们还应该作为提醒,为什么在方便的时候应该避免使用宏:编写和调试宏可能是一项困难且容易出错的任务。在适当的条件下,宏可以是一个非常强大的工具,用于从源代码中删除样板代码和其他重复形式。
活动 11.01:网球 CSV 宏
在一些早期的章节中,我们处理了包含在 CSV 文件中的网球数据。每次,我们都使用一个标准的 Clojure 宏 with-open,并且每次都遵循几乎相同的模式,其中文件的 内容通过 ->> 宏传递给一系列转换:
-
使用
clojure.data.csv库的read-csv函数读取内容。 -
使用
semantic-csv库的mappify函数将输入文件的每一行转换成一个映射。 -
使用
semantic-csv的cast-with函数将一些字段转换为数值类型。 -
通过使用
map调用select-keys对数据集中的每个项目进行选择,删除一些不必要的字段。 -
使用
doall函数调用结束,以确保我们避免返回一个一旦文件关闭就无法完成的惰性序列。
这些步骤中的每一个都给你的代码添加了一些重复的样板。结果是,每次你想编写一个分析这些 CSV 文件之一的函数时,你都会重复相同的代码,这既繁琐又容易出错,而且难以阅读,因为你的代码中的重要逻辑被埋在样板代码中。
由于你工作的数据驱动体育新闻网站上网球数据的成功,你现在编写了很多具有这种相同模式的函数。你已经决定是时候通过一个名为 with-tennis-csv 的良好宏来简化你的生活,以清理这段代码。
你的目标是编写一个名为 with-tennis-csv 的宏,它将封装访问 CSV 数据的大部分或所有重复步骤。
接口设计
该宏应接受以下作为参数:
-
CSV 文件名(一个字符串)。
-
cast-with的字段名到类型的映射(一个关键字到函数的映射)。 -
要保留的关键字列表。如果提供了空列表,则保留所有关键字。
-
任意数量的 Clojure 表达式。这些表达式必须接受并返回一个映射列表。
Clojure 表达式将被插入到 ->> 表达式链的中间:

图 11.6:插入 Clojure 表达式
宏调用者将提供将被放置在转换链中的表单。
因为 Clojure 表达式将在双箭头串联宏内部使用,所以它们的最终参数必须省略。
以下 blowout 函数展示了你想要编写的宏的可能用途。该函数接收一个 CSV 文件路径和一个阈值,然后返回一个匹配列表,其中胜者通过超过 threshold 场比赛获胜。结果被缩小到三个字段:两位选手的姓名和胜者击败败者的比赛数:
(defn blowouts [csv threshold]
(with-tennis-csv csv
{:winner_games_won sc/->int :loser_games_won sc/->int}
[:winner_name :loser_name :games_diff]
(map #(assoc % :games_diff (- (:winner_games_won %) (:loser_games_won %))))
(filter #(> (:games_diff %) threshold))))
实现
这里的主要挑战是确保表单被正确地插入到 ->> 串联宏中。
确保在宏内部没有对任何参数进行多次评估:
-
首先,确定在先前的例子中
with-tennis-csv的调用应该扩展成什么样子。 -
使用
deps.edn文件设置你的项目,并在tennis-macro命名空间中引入必要的库。 -
确定你的宏的参数将是什么。别忘了它应该接受任意数量的要串联的表单。
-
在你的宏中,作为一个语法引用的“模板”,插入目标代码中始终存在的所有部分,无论宏如何调用。
-
插入要串联的表达式。
-
找到一个方法,只有在提供了键并且可以选中时才应用
select-keys。
要测试你的宏,你可以使用我们一直在使用的 CSV 文件:packt.live/2Rn7PSx。
注意
本活动的解决方案可以在第 733 页找到。
摘要
在本章中,我们探讨了 Clojure 的宏系统,以及许多与宏相关的问题。到现在,你应该已经掌握了宏的基本概念,从编译时和运行时评估的区别开始,并有一个心理模型,这将允许你在必要时编写自己的宏,或者理解他人编写的宏。宏的卫生问题、变量捕获和双重评估是宏编写过程的核心。了解所有这些将帮助你编写宏、阅读宏,最重要的是,决定何时编写宏以及何时不编写。
无论你是否打算在 Clojure 中使用宏来编写自己的领域特定语言(DSL),你都将从 Clojure 宏中受益。它们提供的灵活性使得库作者能够以没有宏就无法实现的方式扩展 Clojure。许多常用的 Clojure 库,例如你将在第十四章“使用 Ring 的 HTTP”中学习的 Ring HTTP 服务器,都广泛地使用了宏来简化开发者的工作。
在下一章中,我们将探讨 Clojure 的另一个强项:处理并发。
第十二章:12. 并发
概述
在本章中,我们将探索 Clojure 的并发特性。在 JVM 上,你将学习使用多个处理器线程进行编程的基础:启动新线程和使用结果。为了协调你的线程,我们将使用 Clojure 的创新引用类型。其中之一,原子,也可以在 JavaScript 环境中使用。
到本章结束时,你将能够构建简单的浏览器游戏,并使用原子管理它们的状态。
简介
自从 Clojure 语言首次推出以来,其并发模型一直是其主要卖点之一。在编程中,“并发”一词可以应用于许多不同的场景。首先给出一个简单的定义,任何你的程序或系统有多个同时的操作流程时,你就是在处理并发。在多线程的 Java 程序中,这意味着代码在独立的处理器线程中同时运行。每个处理器线程遵循其自身的内部逻辑,但为了正常工作,你的程序需要协调不同线程之间的通信。尽管 JavaScript 运行时是单线程的,但浏览器和 Node.js 环境都有自己处理同时逻辑流程的方法。虽然 Clojure 的并发根源肯定在 Java 中,但其中一些思想和工具在ClojureScript中也同样适用。
在本章中,你将学习并发编程的基础。Clojure 的一些特性,如软件事务内存(STM),主要适用于大型、复杂的系统。虽然我们无法在一个章节中模拟所有这种复杂性,但我们将探讨 Clojure 提供的基本概念和工具。为了展示这些技术和让你熟悉并发,我们将使用两个不同的环境。在 JVM 上,你将学习如何创建相互通信的线程。在浏览器上,你将学习如何协调发生在网页不同部分的事件。
一般的并发
现代计算机使用线程在多个处理器核心之间分配执行。这有很多原因,包括微芯片设计的物理特性以及需要用户环境在后台程序进行密集计算时仍然保持响应性的需求。每个人都希望能够在检查电子邮件、听音乐和运行 Clojure REPL 的同时进行操作!在程序内部,这种多任务处理也可以代表显著的性能提升。当一个线程正在等待来自磁盘驱动器的数据,另一个线程等待网络时,另外两个线程可以处理数据。当正确执行时,这可以代表性能和整体效率的显著提升。然而,这里的操作短语是“当正确执行时”。并发可能很棘手。
大多数计算机代码是以线性方式编写的:这样做,那样做,然后这样做。方法或函数的源代码是从上到下读取的。我们在编写代码时线性地思考代码。在多线程系统中,代码的执行方式并非如此。程序的一些部分将在一个线程中运行,而其他部分将在其他线程中同时运行。协调成为了一个新的问题。经验表明,多线程应用程序比单线程应用程序更难编写,并且更容易出错。最重要的是,它们对我们来说更难理解。因此,虽然存在更好的性能潜力,但也存在更大的复杂性潜力。就像编程的许多方面一样,这完全是关于权衡。
Clojure 最重要的并发特性实际上是从一开始你就一直在使用的:不可变性。Clojure 的不可变数据类型在整个系统中提供了一种特殊类型的保护。值一旦锁定就永远不变;如果另一个线程需要“修改”你的数据,它实际上会使用一个高效的副本,而不会干扰其他线程。而且因为这是 Clojure 语言的基本组成部分,你可以“免费”获得它:数据默认是不可变的,因此没有额外的程序来为并发做准备。
当创建一个新的线程时,我们说当前线程已经被分叉。父线程与其新线程共享其状态,但失去了对执行流程的控制。让我们看看当值是可变的时,事情可能会出错:
![图 12.1:在具有可变数据结构的语言中,共享状态可能导致问题]
![图片 B14502_12_01.jpg]
图 12.1:在具有可变数据结构的语言中,共享状态可能导致问题
在这个图表中,我们可以看到一个主线程创建了一个分叉。在原始线程中,变量x是一个数组:[5, 3, 7]。然后分叉修改了这个数组。因为它在两个线程之间是共享的,所以数组也在主线程中被修改。从主线程的角度来看,x的值似乎突然无原因地改变,就像被某种外部力量修改一样。当然,这是一个过于简化的说法,因为像 Java 这样的语言确实允许程序员保护自己免受这类问题的困扰。然而,共享的可变状态确实会带来这种类型问题的风险。
在 Clojure 中,不可变性在很大程度上解决了这个问题。下面是一个表示 Clojure 版本相同内容的相似图表:
![图 12.2:Clojure 的不可变数据结构永远不会被修改:更改会创建新的数据版本]
数据版本
![图片 B14502_12_02.jpg]
图 12.2:Clojure 的不可变数据结构永远不会被修改:更改会创建新的数据版本
使用 pmap 进行自动并行化
几乎所有类型的并发都会给程序员带来一些额外的复杂性。然而,这个规则有一个例外:Clojure 的 pmap 函数。这个名字是 map 的缩写,pmap 在列表中的每个项目上调用一个函数。区别在于每个函数调用都在一个单独的线程上运行,这样一些计算可以同时进行。
在这个图中,我们映射一个想象中的函数,pfn,到一个简单的向量上。对于每个项目,pmap 在一个新线程中调用 pfn:

图 12.3:pmap 生成新线程以便同时进行计算
这可能听起来是个好消息。在某些情况下,可能确实如此。但如果你在想“我将一直使用 pmap,我的程序会运行得更快”,你大多数时候都会失望。问题是 pmap 必须进行大量的幕后工作来生成新线程。结果是,当计算特别耗时的时候,pmap 才比单线程的 map 函数更有效。我们可以通过一个微不足道的 map 操作来比较 map 和 pmap 的相对速度。我们将使用 Clojure 的 time 宏来比较执行时间。time 宏包装一个表达式,正常评估它,并打印出评估所需的时间:
user> (time (doall (map inc (range 10))))
"Elapsed time: 0.081947 msecs"
(1 2 3 4 5 6 7 8 9 10)
user> (time (doall (pmap inc (range 10))))
"Elapsed time: 2.288832 msecs"
(1 2 3 4 5 6 7 8 9 10)
增加十个整数不是一个资源密集型任务。使用普通的 map 函数,它只需要不到十分之一微秒。使用 pmap 执行相同的操作需要几乎 30 倍的时间!为什么这么慢?创建线程消耗资源。显然,只有在额外开销值得的时候才应该使用 pmap。
注意
我们在这里需要使用 doall;否则,我们只是计时一个未实现的惰性序列的创建速度。
练习 12.01:测试随机性
回到你在数据新闻网站的工作岗位上,一位分析师担心你程序中使用的随机数生成器不够随机。她希望你通过生成一个非常长的随机整数序列并检查几个数字是否均匀分布来测试它。通过这样做,你将能够看到使用 map 或 pmap 哪个更快,这要归功于 Clojure 的 time 函数。
打开一个 REPL。这是一个一次性测试,所以不需要创建项目:
-
生成一个介于
0和1000之间的非常长的整数序列:user> (def random-ints (doall (take 10000000 (repeatedly (partial rand-int 1000))))) #'user/random-ints注意,我们使用
doall来确保repeatedly返回的惰性序列被完全实现。 -
定义一个函数,用于计算列表中整数的出现次数:
user> (defn int-count [i xs] (count (filter #(= % i) xs))) #'user/int-count -
使用
map来计算random-ints中某些整数的出现次数:user> (map #(int-count % random-ints) [0 1 2 45 788 500 999 ]) (1034 1009 971 1094 968 1029 908) -
这似乎有效,但速度相当慢。为了发现它有多慢,我们可以使用 Clojure 的
time函数提供一个快速的基准测试。别忘了将map的输出包裹在doall中。否则,你只是在计时一个未实现的惰性序列的创建:(time (doall (map #(int-count % random-ints) [0 1 2 45 788 500 999]))) "Elapsed time: 7307.28571 msecs" (9808 10027 9825 10090 9963 10096 9984)当然,你的时间可能会更长或更短。
-
现在尝试使用
pmap:user> (time (doall (pmap #(int-count % random-ints) [0 1 2 45 788 500 999]))) "Elapsed time: 1602.424627 msecs" (9808 10027 9825 10090 9963 10096 9984)这要快得多!这对于分析师要求更大的样本量时很有用。
在这个例子中,pmap 出现并显著加快了速度。pmap 的美妙之处在于不需要额外的编码。写 pmap 而不是 map 已经足够简单。然而,像许多工具一样,最重要的是知道何时不要使用它。这个例子特别适合并行化,因为数据集很大。pmap 应该只在你知道计算通常会很慢的情况下使用。
期货
使用 pmap,Clojure 会为你处理所有的线程管理,这使得事情变得简单。然而,很多时候,你需要比 pmap 提供的更多线程控制。Clojure 的 futures 正是为此而设计的。它们是用于生成和等待新线程的机制。
考虑这样一种情况,需要两个昂贵的计算来执行第三个操作,例如将两个结果相加。在单线程环境中,我们只需这样写:
(+ (expensive-calc-1 5) (expensive-calc-2 19))
以这种方式编写,expensive-calc-1 的调用需要在 expensive-calc-2 开始之前完成。如果这两个计算可以并行运行,我们可以在最佳情况下将执行时间缩短近一半。然而,并行运行这两个线程也会带来一些新问题。我们需要一种协调返回值的方法,尤其是当我们不知道 expensive-calc-1 或 expensive-calc-2 哪个会先完成时。我们需要一种在调用 + 之前等待两者的方法。
Futures 是为此类情况而设计的。future 宏会导致其包含的代码在单独的线程中运行。它立即返回一个占位符,这是一个对 future 结果的引用。当新线程中的代码完成时,占位符可以被 解引用。
在 Clojure 中,当值不是立即可用,只能通过额外一步来访问时,才会应用解引用。使用 var 或 let 绑定时,值是立即可用的。没有额外的步骤需要执行。我们只需使用它。使用 future 以及我们将在本章后面看到的引用类型时,我们不知道值是否已经可用。使用 future 的 deref 函数意味着我们愿意等待它完成。deref 是必要的,因为我们需要一种方式来指示这种特殊行为。为了使你的代码更容易阅读(和输入),你不必写 (deref my-future),只需输入 @my-future 即可。这是一个读取宏的例子:Clojure 在读取你的代码时会立即将 @my-future 转换为 (deref my-future)。
考虑到这一点,我们可以重写前面的表达式。不过,首先,我们将使用 Java 的Thread/sleep方法定义一个人为地慢的函数:
user> (defn expensive-calc-1 [i]
(Thread/sleep (+ 500 (rand 1000)))
(println "Calc 1")
(+ i 5))
user> (defn expensive-calc-2 [i]
(Thread/sleep (+ 500 (rand 1000)))
(println "Calc 2")
(+ i 5))
注意
Thread/sleep方法是 Java 互操作的一个方便的工具,用于模拟长时间运行的计算或耗时的输入/输出操作。虽然它对实验很有用,但在生产代码中你很少需要它。
使用这些函数,原始表达式会依次评估它们:
user> (+ (expensive-calc-1 10) (expensive-calc-2 25))
Calc 1
Calc 2
45
无论这个函数运行多少次,expensive-calc-1总是会在expensive-calc-2之前返回。使用未来,这将会改变:
user> (let [c1 (future (expensive-calc-1 10))
c2 (future (expensive-calc-2 20))]
(+ (deref c1) (deref c2)))
Calc 2
Calc 1
40
user> (let [c1 (future (expensive-calc-1 10))
c2 (future (expensive-calc-2 20))]
(+ (deref c1) (deref c2)))
Calc 1
Calc 2
40
首先,你可能注意到这个版本稍微快一些。有时expensive-calc-1先返回,有时expensive-calc-2赢得比赛。哪个更快不重要:最终加法只有在两者都完成时才会发生。这是deref函数的工作,它会阻塞评估直到由相应的future调用启动的计算返回。
(deref c1)或(deref c2)表达式可以用@c1或@c2替换,多亏了@读取宏。
练习 12.02:众包拼写检查器
你有没有在搜索引擎中输入一个单词,只是为了看看你是否拼对了?在这个练习中,我们将构建一个命令行工具,它改进了这种使用互联网来验证拼写的方法。
目标是能够写出一个命令,后面跟着一个单词的几个可能的拼写。拼写工具将查询维基百科,并返回得到最多点击的单词。我们将使用未来,以便不同的搜索查询可以并行运行。
-
使用 Leiningen 创建一个新的项目,使用
app模板。你可以叫它任何你想要的。在我们的例子中,我们将使用packt-clj.crowdspell:lein new app packt-clj.crowdspell -
前往 Leiningen 创建的新目录,并修改
project.clj文件中的依赖项。我们需要三个库:org.clojure/tools.cli用于接受用户输入,clj-http用于发起 HTTP 请求,以及org.clojure/data.json。:dependencies映射应该看起来像这样::dependencies [[org.clojure/clojure "1.10.1"] [org.clojure/tools.cli "0.4.2"] [clj-http "3.10.0"] [org.clojure/data.json "0.2.6"]] -
在
src/packt_clj/目录下,创建一个crowdspell目录。我们将使用这个目录来创建项目所需的任何命名空间。在那里创建一个名为fetch.clj的文件,并插入以下命名空间定义:(ns packt-clj.crowdspell.fetch (:require [clj-http.client :as http] [clojure.data.json :as json]))所有的获取和解析结果都将在这个命名空间中发生。
clj-http库将帮助我们向维基百科端点发起网络请求,而clojure.data.json将帮助我们解析我们得到的 JSON 数据。 -
当你在那里时,为
get-best-word创建一个空的函数定义。这将是应用程序的关键接口:给定一个单词列表,get-best-word将根据从维基百科检索到的数据返回最好的一个。目前我们所知道的是它需要一个语言代码和一个单词列表作为参数:(defn get-best-word [language-code words])由于维基百科为每种语言使用不同的 URL,例如
ja.wikipedia.org/或 https://en.wikipedia.org/,我们可以使用语言代码参数来国际化我们的应用程序。 -
在
src/packt_clj/crowdspell.clj中定义的packt-clj/crowdspell命名空间将是应用程序的入口点。那里应该已经有一个由lein new命令创建的-main函数。修改
-main函数,使其调用get-best-word。这也需要更新命名空间声明,以便你可以访问clojure.tools.cli和packt-clj.crowdspell.fetch。命名空间声明现在应该看起来像这样:
(ns packt-clj.crowdspell (:require [clojure.tools.cli :as cli] [packt-clj.crowdspell.fetch :as fetch]) (:gen-class))-main函数应该看起来像这样:(defn -main [& args] (println (fetch/get-best-word "en" args)))目前,我们只是将英语作为语言代码硬编码。在这个阶段,应用程序理论上可以在命令行编译和运行,但不会做任何事情。
-
在
src/packt_clj/crowdspell/fetch.clj中,为word-search函数编写一个骨架。这个函数将搜索单个单词。get-best-word函数将使用 futures 协调多个并发 HTTP 请求。每个请求都将运行word-search:(defn word-search [word language-code] (try ;; TODO: the HTTP request (catch Exception e {:status :error})))由于我们希望应用程序支持多语言,因此有一个
language-code参数。我们将在稍后将其作为命令行参数添加。我们在这里使用
try块,因为我们的请求可能会因为各种原因而失败。我们已经做出了设计决策:函数将返回一个包含,但不仅限于,:status代码的 map。 -
现在是编写 HTTP 请求本身的时候了。
clj-http.client库使得这个过程相对简单。我们只需确保使用正确的维基百科特定参数:srsearch是我们的搜索词,而srlimit告诉 API 我们只想得到一个项目。只需要一个项目,因为响应中的元数据包括一个字段,指示找到了多少个项目。由于我们只对计数感兴趣,这就足够了。如果你喜欢,可以先在 REPL 中尝试这个请求:packt-clj.crowdspell.fetch> (http/get (str "https://en.wikipedia.org/w/api.php") {:query-params {"action" "query" "list" "search" "srlimit" 1 "srsearch" "Clojure" "format" "json"} :accept :json :cookie-policy :none})如果一切顺利,一个包含 HTTP 响应(包括所有头信息)的 map 应该会填充你的 REPL。你应该找到一个键,它说
:status 200,这意味着请求是成功的。除了200之外的状态代码都意味着有问题:![图 12.4:成功 HTTP 请求的 REPL 输出]()
图 12.4:成功 HTTP 请求的 REPL 输出
-
让我们在
try块内添加这个请求作为一个let绑定。唯一需要改变的是插入两个参数:我们要搜索的单词和语言。首先,从维基百科端点的 HTTP 请求结果创建一个let绑定。我们稍后需要整个请求:(defn word-search [word language-code] (try (let [http-result (http/get (str "https://" language-code ".wikipedia.org/w/api.php") {:query-params {"action" "query" "list" "search" "srlimit" 1 "srsearch" word "format" "json"} :accept :json :cookie-policy :none})] ;; TODO: do something with the result {:status :ok :total-hits total-hits :word word}) (catch Exception e {:status :error}))) -
现在我们只需要解释 HTTP 请求的结果。实际上有两个步骤:首先,将原始 JSON 响应转换为 Clojure 映射,然后提取我们所需的数据。Wikipedia API 提供了一个
totalhits字段,我们可以用它来决定哪个单词最受欢迎。我们可以将这两个步骤合并成一个简短的代码片段:(-> (json/read-str (:body http-result) :key-fn keyword) (get-in [:query :searchinfo :totalhits]))json/read-str正是这样做的:它读取响应体并将其转换为映射。:key-fn选项允许我们提供一个函数,该函数将在所有键上被调用。在几乎所有情况下,这里都使用keyword函数,这样我们就可以享受到 Clojure 关键字的便利。剩下的就是抓取我们需要的唯一数据。结果映射是一个大型、多层嵌套的映射,这对于
get-in来说不是问题。 -
一旦我们有了出现次数,我们将所有我们稍后需要的所有数据都包装在一个映射中:状态、出现次数和单词本身:
{:status :ok :total-hits total-hits :word word}我们知道这个单词是从函数提供的原始参数中得到的。我们还知道状态是
:ok:如果查询导致错误,我们就会在catch块中。最终的函数看起来像这样:
(defn word-search [word language-code] (try (let [http-result (http/get (str "https://" language-code ".wikipedia.org/w/api.php") {:query-params {"action" "query" "list" "search" "srlimit" 1 "srsearch" word "format" "json"} :accept :json :cookie-policy :none}) total-hits (-> (json/read-str (:body http-result) :key-fn keyword) (get-in [:query :searchinfo :totalhits]))] {:status :ok :total-hits total-hits :word word}) (catch Exception e {:status :error})))如果你测试 REPL 中的
word-search,你应该看到类似这样的内容:![图 12.5:从 HTTP 请求中提取的重要数据]()
图 12.5:从 HTTP 请求中提取的重要数据
-
现在我们转向
get-best-word。它的任务是并行调用word-search。对于这个任务,futures 正是我们所需要的。由于单词是以列表形式提供的,第一步将是为每个单词在 future 中调用word-search。这非常直接:(defn get-best-word [language-code words] (let [results (map (fn [a] [a (future (word-search a language-code))]) words)] ;; TODO: decide which word is the best ))HTTP 请求将同时运行,并且随着它们的完成,响应将变得可用。这个表达式将返回一个 future 列表。在我们能够使用它们之前,我们需要取消引用它们。除了这个区别之外,我们可以将
results看作是一个普通值的列表。Clojure 的future和deref函数为我们管理异步性。 -
此处的最后一步将是选择出现次数最多的单词。我们将使用我们在第五章中引入的模式
reduce:(reduce (fn [best-so-far [word result-future]] (let [{:keys [status total-hits] :as result} @result-future] (if (= status :ok) (if (> total-hits (:total-hits best-so-far)) result best-so-far) best-so-far))) {:total-hits 0} results)显然,在这里取消引用
@result-future是第一个非常重要的步骤。但是一旦值被取消引用,所有数据都可用,我们就可以忘记它的异步过去。reduce的其余调用遵循熟悉的模式:我们检查当前项是否有比best-so-far更好的分数,如果是,它就会替换best-so-far。为了制作一个非常精致的应用程序,我们希望在出现错误时提醒用户,但现在,简单地忽略失败的请求就足够了。
-
一旦我们找到了最佳单词,剩下的就是从最佳单词中提取
:word键。为此,我们将使用线程宏->和:word。总的来说,这给我们留下了这个函数:(defn get-best-word [language-code words] (let [results (map (fn [a] [a (future (word-search a language-code))]) words)] (-> (reduce (fn [best-so-far [word result-future]] (let [{:keys [status total-hits] :as result} @result-future] (if (= status :ok) (if (> total-hits (:total-hits best-so-far)) result best-so-far) best-so-far))) {:total-hits 0} results) :word))) -
在 REPL 中测试
get-best-word,使用一些“Clojure”拼写得非常糟糕的单词,以及正确的拼写:packt-clj.crowdspell.fetch> (get-best-word "en" ["Fortran" "Pascal"]) "Pascal" packt-clj.crowdspell.fetch> (get-best-word "en" ["Clojur" "Clojure" "Clojrre"]) "Clojure"看起来一切正常!
-
为了使这个功能成为一个有用的应用程序,我们仍然需要将这个行为打包成一个命令行工具。在
src/packt_clj/crowdspell.clj中的-main函数几乎已经准备好了,只是语言代码参数仍然硬编码为en。clojure.tools.cli库将使您能够在命令行中轻松地将语言代码作为可选参数添加。目标是能够将我们的代码编译成一个 uberjar,然后输入以下内容:java -jar packt-clj.crowdspell-0.1.0-SNAPSHOT-standalone.jar --language en Clojur Clojure Clojrre -
clojure.tools.cli/parse-opts函数接受来自-main的args值和一个参数前缀描述符列表。理解这一点最好的方式是通过一个例子,比如我们的语言代码选项:(cli/parse-opts args [["-l" "--language LANG" "Two-letter language code for search" :default "en"]])嵌套向量是
parse-opts的配置参数。"-l"和"--language LANG"定义了命令行选项的简写和长写形式。请记住,选项的长写形式将以关键字形式用作parse-opts返回的嵌套:options映射中参数的名称。下一个字符串是一个文档字符串,如果出现错误(例如,未知选项前缀)时将显示出来。
在这三个项目之后,可能会有额外的关键字标记参数。这里有很多可能性,我们不会一一探索。对于我们的目的来说,
:default就足够了。如果用户没有提供--language选项(或较短的-l选项),他们将获得来自英语维基百科输出的结果。 -
要测试命令行选项的配置,实际上并不需要使用命令行。
parse-opts可以在REPL中运行,使用一个字符串列表伪装成命令行参数:packt-clj.crowdspell> (cli/parse-opts ["--language" "fr" "Cloj" "Clojure"] [["-l" "--language LANG" "Language code for search"]]) {:options {:language "fr"}, :arguments ["Cloj" "Clojure"], :summary " -l, --language LANG Language code for search", :errors nil} -
这很有用,因为它显示了返回映射的结构。为了最终确定
-main函数,我们只需要知道如何从映射中提取语言选项和参数:(defn -main [& args] (let [parsed (cli/parse-opts args [["-l" "--language LANG" "Two-letter language code for search" :default "en"]])] (fetch/get-best-word (get-in parsed [:options :language]) (:arguments parsed)))) (System/exit))))对
System/exit的调用确保我们的程序将立即退出。因为我们使用了future,否则程序将不会退出,直到由future创建的线程完全终止。这应该就是我们需要的所有代码了。
-
在编译之前,我们可以在
REPL中通过模拟命令行参数的列表来测试我们的代码:packt-clj.crowdspell> (-main "-l" "en" "Klojure" "Cloojure" "Clojure") "Clojure" -
要编译,只需在项目的根目录中运行
lein uberjar。现在我们终于可以测试整个应用程序了:$ java -jar target/uberjar/packt-clj.crowdspell-0.1.0-SNAPSHOT-standalone.jar --language en Clojur Clojure Clojrre Clojure
好吧,它工作得很好。但是速度很慢。是的,Java 运行时需要几秒钟才能启动,这对于应该快速、易于使用的应用程序来说是个问题。最好将这个版本视为一个概念验证。基于 Node.js 的 ClojureScript 版本将具有更短的启动时间。或者这可以构建成一个网络服务。您将在第十四章“HTTP 与 Ring”中学习如何构建应用程序服务器。现在,最快、最简单的方法是直接在 REPL 中使用fetch.clj,尽管这个要求可能会限制潜在客户数量。
在这个练习中,我们学习了如何使用 Clojure futures 编写一个简单的多线程应用程序。Futures 特别适合将离散任务分配给单独的线程的情况。更重要的是,future结合deref提供了一种协调从单独线程返回的数据的方法。
协调
Futures 非常适合像crowdspell示例这样的情况。工作被分配给一个线程;线程独立执行其任务并返回结果给初始线程。协调发生在结果的收集:评估会阻塞,直到所有 future 都完成。由于不可变性,可以保证同时运行的线程不会相互干扰,因为没有共享的内容。
这个简单的模型之所以有效,正是因为它的简单。然而,有时需要更多的协调,尤其是在需要线程间通信时。
使用 future,我们进行分支,执行计算,并返回数据:

图 12.6:使用 future,当 future 被取消引用时发生协调
消息发送是线程之间通信的一种方式。现在,我们想象三个线程相互发送消息:

图 12.7:多个线程之间的复杂交互
在 Clojure 中实现此类通信模型,您还可以使用core.async库,这是一个用于在线程之间创建通道的复杂工具。core.async库被广泛使用,但需要单独的章节来正确介绍它:

图 12.8:线程引用和修改共享数据
并发编程的基本挑战源于程序的时间线不再是线性的。相互通信的线程不知道何时会收到新数据,也不知道他们发送的数据何时会在接收端被处理。除非通信得到精心管理,否则会发生意外。如果两个线程在没有协调的情况下同时尝试更新相同的数据,结果将变得不可预测。一个更新会覆盖另一个。当一个值正在更新时,原始值被修改,更新突然变得过时。如果涉及更多的线程,交互(以及错误的可能性)会成倍增加。在crowdspell练习中,futures 协调了不同线程的返回值。而不是相互覆盖数据,结果被组装成一个连贯的列表,程序的其他部分可以使用。
Clojure 有几种引用类型:vars、atoms、agents 和 refs。在分别讨论每一种之前,先思考一下什么是引用类型可能是个好主意。首先,引用类型不是数据结构。实际上,你可以用 Clojure 的任何数据结构配合这些引用类型中的任何一种。相反,引用类型位于你的代码和数据之间,并提供了一种特定的方式来引用你的数据。
通过可视化这种关系可能会有所帮助。引用类型作为一个接口,允许你的代码对数据进行更改并检索数据的当前状态。记住,引用类型指向的值仍然是熟悉的 Clojure 数据类型:整数、字符串、向量、映射等等:

图 12.9:引用类型:一种引用和交互数据的方式
大多数时候,在 Clojure 中编码时,我们不会考虑事物名称和事物本身之间的区别。考虑这个简单的let绑定:
(let [some-numbers [3 12 -1 55]]
;; TODO: do something
)
some-numbers是什么?通常,我们只是说,或者想,“它是一个向量。”当然,它实际上是一个指向向量的符号。它在let绑定中的事实意味着some-numbers将只在该特定的let表达式中指向[3 12 -1 55]。换句话说,let定义了一种特定的指向方式。
现在考虑这个let绑定:
(let [some-atomic-numbers (atom [3 12 -1 55])]
;; TODO: do something with an atom
)
这次,some-atomic-numbers仍然只是一个符号。它指向一个原子(我们将在下一节解释这是什么意思),而原子指向向量:

图 12.10:原子上的绑定点,以及原子指向的值
不可变性意味着值不会改变。一个整数仍然是整数,一个向量保持等于自身。不可变性并不意味着符号总是指向同一个值。Clojure 的引用类型是管理某些类型变化的同时继续使用不可变数据的一种方式。
到现在为止,你已经非常熟悉一种引用类型:变量,它在其他角色中是 Clojure 识别函数的方式。到目前为止,我们将变量视为不可变的身份,这在很大程度上是准确的。一般来说,如果你在程序中看到(def x 5),这意味着x的值不会改变。
然而,在 REPL 中,你很快会发现你可以通过再次调用def来简单地重新定义一个变量:
user> (def x 5)
#'user/x
user> x
5
user> (def x 6)
#'user/x
user> x
6
虽然在用 REPL 进行实验时这样做是可以的,但在程序中这样覆盖一个变量会非常不寻常。话虽如此,变量引用类型确实提供了alter-var-root函数,这是一种更优雅地更新变量的方式。它接受一个函数并更新其值:
user> (def the-var 55)
#'user/the-var
user> (alter-var-root #'the-var #(+ % 15))
70
如果你确实需要使用这个函数,可能是因为要改变你工作环境的一些功能。这里的目的是不是鼓励你开始修改变量,而是要展示即使是变量也有改变其值的语义。Vars碰巧是一种强烈反对改变但并不完全禁止改变的引用类型。
其他引用类型——原子、代理和引用——被设计成让你能够更精细地控制如何管理变化,它们通过控制它们指向的内容来实现这一点。在前面的图中,我们展示了let绑定some-atomic-numbers指向一个包含向量的原子。现在我们可以通过展示原子如何随着对它的不同函数调用而演变来完善这个图景:


图 12.11:原子随时间指向不同的值
在这张图片中,数据(向量)仍然是不可变的。原子允许我们定义某种身份,这种身份在不同的时间可能有不同的值。正如我们将看到的,这在多线程程序中特别有用,其中一个或多个线程需要访问另一个线程中的稳定身份。
那么,原子、引用和代理是什么?让我们快速看一下这些引用类型中的每一个。
原子
原子是最简单且最常用的引用类型。它们也是目前在 ClojureScript 中唯一可用的引用类型,这主要是因为 JavaScript 运行时是单线程的。
原子的生命周期从使用atom函数对数据进行初始定义开始:
user> (def integer-atom (atom 5))
#'user/integer-atom
就像future一样,可以使用deref(或@读取宏)访问底层数据:
user> (deref integer-atom)
5
使用swap!来更改数据,它通过应用你提供的函数来更新原子指向的当前值:
user> (swap! integer-atom inc)
6
user> (deref integer-atom)
6
swap!函数不会将值分配给原子。相反,它应用一个函数。这样,如果自上次我们解引用原子以来,原子的值已更改,则函数将简单地应用于新的值,无论它是什么。
让我们看看这个动作。以下是我们在 REPL 中将要执行的代码,再次使用Thread/sleep来模拟一些长时间运行的任务:
user> (do
(future (do (Thread/sleep 500) (swap! integer-atom + 500)))
(future (swap! integer-atom * 1000))
(deref integer-atom))
6000
这里发生了什么?第一个 future 在调用swap!之前等待半秒钟。第二个 future 立即执行,将原子的当前值6乘以1000。现在再次尝试解引用integer-atom:
user> @integer-atom
6500
如果我们通过修改Thread/sleep调用的持续时间来改变时间,结果也会改变:
user> (do
(def integer-atom (atom 6))
(future (swap! integer-atom + 500))
(future (do (Thread/sleep 500) (swap! integer-atom * 1000)))
(deref integer-atom))
506
user> @integer-atom
506000
当调用swap!时,你无法提前确定原子的值。但你知道你的函数将在那个时间点被调用,无论值是什么。
注意
此外,还有一个较少使用的compare-and-set!函数,它提供了更细粒度的控制。它接受一个额外的值,通常是原子的当前值,并且只有在它仍然匹配时才会修改原子。换句话说,如果另一个线程已经对你的原子做了某些操作,compare-and-set!将保持不变。
概念:重试
原子的更改不是瞬时的。记住,我们向原子发送了一个函数。根据正在执行的工作,某些函数可能需要更多时间来完成。在繁忙的环境中,这可能意味着几个线程同时尝试更改原子。
以下图显示了两个线程Thread A和Thread B对一个原子简单实现的修改。尽管它们只是将原子的值乘以 3 或 4,但让我们想象这个操作需要几毫秒:

图 12.12:对 Clojure 原子简单实现的重叠更新
原子的初始值为 5。然后Thread A介入,将其乘以 3。当它开始执行其操作时,Thread B也开始。Thread B的函数输入仍然是5。Thread A的函数完成,原子的值设置为15。然后Thread B的函数完成其计算,而没有看到原子的新值。这个结果是基于“过时”的初始版本,但它覆盖了第一次计算的结果。最终,就像Thread A的更新从未发生一样。
Clojure 不希望这种情况发生在你身上!以下是一个真实 Clojure 原子的行为:

图 12.13:当发生冲突时,真实的 Clojure 原子会重试
这次,当原子尝试应用来自Thread B的更新时,它检测到基础值已更改,并重新应用函数以使用新值。
我们可以在 REPL(读取-评估-打印-循环)中观察到这一点,使用一个动作缓慢(长时间休眠)的函数和一个快速函数在两个不同的线程中:
user> (do
(def integer-atom (atom 5))
(future (swap! integer-atom (fn [a] (Thread/sleep 2000)(* a 100))))
(future (swap! integer-atom (fn [a] (Thread/sleep 500) (+ a 200))))
@integer-atom)
5
user> @integer-atom
205
user> @integer-atom
20500
在这种情况下,我们可以认为对swap!的两次调用几乎同时执行,当时integer-atom的值仍然是 5。通过在do块结束时取消引用integer-atom,我们可以看到原子尚未被更新。然而,几秒钟后,结果显示两个函数都已应用,并且顺序正确。
参考和软件事务内存
参考是 Clojure 最复杂的引用类型。正是由于这些参考,Clojure 语言在首次推出时因其在并发管理方面的先进性而闻名,特别是 STM(软件事务内存)。STM 是一种抽象,它保证了可以以安全、协调的方式对多个refs进行更改。
在 STM(扫描隧道显微镜)背后,有一个基本概念:事务。如果你已经与数据库事务工作过,你对基本想法已经很熟悉了。一个数据库事务可能由几个相关操作组成。如果在事务过程中,任何单个操作失败,整个事务将“回滚”到初始状态,就像什么都没发生一样。事务是一种避免只执行部分动作的无效状态的方法。
注意
在第十三章“数据库交互和应用层”中,我们将更深入地探讨数据库事务。
解释数据库事务的经典例子也适用于此处:想象一个银行交易,其中资金从一个账户转移到另一个账户。为此,至少需要发生两个动作:从第一个账户中扣除一定金额,并将其添加到另一个账户。如果由于某种原因其中一个操作失败,我们不希望另一个操作成功。如果在第一个账户的资金扣除后第二个账户被删除,那么这些资金应该返回到原始账户;否则,它们将消失得无影无踪。在数据库事务中,如果第二步没有完成,第一步也会被取消。这样,系统会自动返回到一个已知、正确的状态。
Clojure 的软件事务内存在概念上与数据库事务相似,但它是与程序内部的数据一起工作的。Clojure 中的事务由dosync宏管理,它创建了一个空间,在这个空间中,对 refs 的操作将被协调。想象一下,前面提到的银行场景是使用 Clojure 引用类型而不是数据库来实现的。如果你从一个银行账户引用中提取资金并存入另一个账户,那么这两个操作都将成功,或者整个dosync块将重试。像数据库事务一样,refs 的目的确保你的系统保持一致状态。这是 refs 和 atoms 之间第一个主要区别之一:与 atoms 不同,refs 可以提供协调。当两个 atoms 被更新时,没有这样的保证。
引用实际上提供了更细粒度的控制,以修改它们。有几个函数可以更新引用,每个函数都有不同的语义。最常见的是 alter 和 commute。alter 是最限制性的:如果基础值在 dosync 块外部已更改,则整个事务将重试。当不需要此类保证时,可以使用 commute 函数。例如,在添加或从总数中减去时,操作顺序不影响结果,因此这些操作可以接受基础值的更改而不需要重试。并且当访问数据时,可以使用 ensure 函数代替 deref。在这种情况下,如果正在读取的引用已更改,将触发重试。
练习 12.03:股票交易
在这个练习中,我们将观察 REPL 中的引用以更好地了解其行为。
您当前的项目是一个股票交易应用的原型。您需要编写一个函数来模拟客户以给定价格购买一定数量的股票。为了使购买成功,必须发生以下四件事:
-
客户账户会因交易金额而被借记。
-
经纪人账户会因相同金额而被贷记。
-
经纪人的股票账户(针对该特定股票)被借记,也就是说,该账户现在有 n 少量股票。
-
客户的股票账户(针对该特定股票)被贷记:现在有 n 更多股票。
然而,如果在发生此操作期间股票价格发生变化,整个购买必须被无效化并重试。
此外,由于这是一个模拟,我们将使用 Thread/sleep 来减慢函数的执行速度:
-
在 REPL 中,为我们将需要的五个不同值设置一些引用。前三个将具有整数值,代表使用任何货币的账户余额:
user> (def client-account (ref 2100)) #'user/client-account user> (def broker-account (ref 10000)) #'user/broker-account user> (def acme-corp-share-price (ref 22)) #'user/acme-corp-share-price -
由于客户和经纪人可能会为不同的公司拥有股票,我们将使用映射来处理:
user> (def broker-stocks (ref {:acme-corp 50})) #'user/broker-stocks user> (def client-stocks (ref {:acme-corp 0})) #'user/client-stocks客户开始时拥有零
Acme Corp股票和账户中的2100货币余额。经纪人拥有50股票和10000的余额。 -
编写一个描述完整交易的函数:
user> (defn buy-acme-corp-shares [n] (dosync (let [purchase-price (* n @acme-corp-share-price)] (alter client-account #(- % purchase-price)) (alter broker-account #(+ % purchase-price)) (alter client-stocks update :acme-corp #(+ % n)) (alter broker-stocks update :acme-corp #(- % n))))) -
我们在这里的所有代码都包含在
dosync宏中。除此之外,代码相当简单。即使在dosync环境中,在访问其值时也需要解引用其他引用,这就是为什么我们写@acme-corp-share-price。更新client-stocks和broker-stocks映射的语法可能看起来有点奇怪。alter函数的第二个参数始终是一个函数,在这种情况下,它是我们之前用于更新映射的update函数。alter函数的其他参数将简单地传递给update,在初始参数之后,该参数将是包含在引用中的映射。总的来说,最终的update调用将如下所示:(update {:acme-corp 0} :acme-corp #(+ % n)) -
因为
update的行为与alter相同,并将任何额外的参数传递给提供的函数,所以我们可以将我们对alter的调用重写如下:(alter client-stocks update :acme-corp + n) (alter broker-stocks update :acme-corp - n) -
同样,前面的行也可以使用相同的语法:
(alter client-account - purchase-price) (alter broker-account + purchase-price)这些形式更简洁,可能对经验丰富的 Clojure 程序员来说更容易阅读。使用匿名函数的形式具有明确提醒我们我们正在提供函数的优点,并且清楚地展示了参数的顺序。
-
让我们尝试我们的新函数:
user> (buy-acme-corp-shares 1) {:acme-corp 49}dosync块返回最后一个值,在这种情况下,是broker-account在交易中的值。这有时可能很有用,但我们真正感兴趣的数据在 refs 中:user> @client-account 2078 user> @broker-account 10022 user> @broker-stocks {:acme-corp 49} user> @client-stocks {:acme-corp 1}在这里,我们可以看到两个账户的余额已经正确更新,并且一股股票已经从
broker-stocks移动到client-stocks。这意味着我们的最佳情况场景是有效的:没有 refs 在当前线程之外被更改,并且交易是瞬时的。在这些条件下,原子会表现得一样好。现在是我们模拟一个更具挑战性的购买环境的时候了!
-
修改
buy-acme-corp-shares以使交易变慢并打印一些信息:user> (defn buy-acme-corp-shares [n] (dosync (let [purchase-price (* n @acme-corp-share-price)] (println "Let's buy" n "stock(s) at" purchase-price "per stock") (Thread/sleep 1000) (alter client-account #(- % purchase-price)) (alter broker-account #(+ % purchase-price)) (alter client-stocks update :acme-corp #(+ % n)) (alter broker-stocks update :acme-corp #(- % n))))) #'user/buy-acme-corp-shares使用
Thread/sleep,交易现在将持续一秒钟。 -
将所有账户重置为其初始值。为了使这更容易,让我们使用
ref-set函数编写一个快速重置函数:user> (defn reset-accounts [] (dosync (ref-set acme-corp-share-price 22) (ref-set client-account 2100) (ref-set broker-account 10000) (ref-set client-stocks {:acme-corp 0}) (ref-set broker-stocks {:acme-corp 50}))) #'user/reset-accounts user> (reset-accounts) {:acme-corp 50} user> @acme-corp-share-price 22所有账户和股价现在都应该回到它们的初始值。这将使观察函数的行为更容易。
-
使用两个独立的线程在交易期间更改客户的账户。为此,我们将使用
future并缩短线程的等待时间,通过更改client-account:user> (do (reset-accounts) (future (buy-acme-corp-shares 1)) (future (dosync (Thread/sleep 300) (alter client-account + 500)))) Let's buy 1 stocks at 22 per stock #<Future@611d7261: :pending>Let's buy 1 stocks at 22 per stock注意到
println消息出现了两次。发生了什么?让我们看看这些值:user> @client-account 2578两次交易都被正确记录:+500 和-22。发生了什么:首先,
buy-acme-corp-shares试图完成交易,但在将新的账户余额写入client-account的时候,由于另一个线程的存款,基础值已经改变。如果没有这个,buy-acme-corp-shares就会覆盖账户余额,忽略最近的存款。客户不会高兴。 -
模拟一个繁忙的经纪人账户。经纪人账户可能比客户的账户要繁忙得多。让我们添加更多交易:
user> (do (reset-accounts) (future (buy-acme-corp-shares 1)) (future (dosync (Thread/sleep 300) (alter client-account + 500))) (future (dosync (Thread/sleep 350) (alter broker-account - 200))) (future (dosync (Thread/sleep 600) (alter broker-account + 1200)))) Let's buy 1 stock(s) at 22 per stock #<Future@2ffabed2: :pending>Let's buy 1 stock(s) at 22 per stock Let's buy 1 stock(s) at 22 per stock解引用原子以查看它们的最终值:
user> @broker-account 11022 user> @client-account 2578对 refs 的更多更改会导致更多的重启,这一点我们可以从“让我们以每股 22 美元的价格购买 1 股股票”的消息打印了三次中看出。每次对账户的修改都会导致整个交易被重试。这正是原子无法做到的:在
dosync块内部,对任何 refs 的更改都会导致整个块重启。
这个练习展示了使用 refs 简化跨线程数据共享的基本方法。显然,现实生活中的应用通常会更加复杂,但即使在这个规模上,我们也能看到并发带来的困难以及 Clojure 如何提供处理这些困难的工具。
我们可以观察到的有几件事情。与原子一样,refs 使用的重试策略防止了几乎同时发生的操作相互干扰。但 refs 更进一步,确保即使一个 refs 导致重试,事务中的所有更新都将重试。这保证了数据一致性。这也意味着我们放弃了某些对 refs 更改时间的控制。作为程序员,我们习惯于非常线性地思考:“这样做,然后那样做。”多线程应用程序打破了这种思维方式。Clojure 的引用类型,尤其是 refs,可以帮助我们编写更好的代码,特别是如果我们学会更多地从操作的正确性和一致性而不是严格的操作顺序来思考。
与引用更多的凝聚力
在上一个例子中,STM 帮助我们确保更新是一致的:买家账户中的钱少了,但股票多了;卖家钱多了,但股票少了。如果这四个变化中的任何一个失败了,系统就会回到之前的有效状态。无论如何,每个人最终都会在他们的账户上得到正确的余额。然而,我们还没有考虑到一种可能性。如果在交易期间股票价格发生变化怎么办?让我们看看:
user> (do
(reset-accounts)
(future (buy-acme-corp-shares 1))
(future (dosync
(Thread/sleep 300)
(alter acme-corp-share-price + 10))))
Let's buy 1 stocks at 22 per stock
#<Future@11e639bf: :pending>
user> @client-account
2078
客户账户上的余额表明购买价格为 22。事件序列表明客户得到了一笔好交易。当buy-acme-corp-shares等待 1,000 毫秒时,在第二个未来,股票价格变更为 32。当购买最终完成时,价格不再是 22 而是 32。为什么引用没有保护我们免受这种情况的影响?
这里的问题是buy-acme-corp-shares函数查询了acme-corp-share-price原子的值,但没有对它做任何事情。因此,dosync没有跟踪对该引用所做的更改。在下一个练习中,我们将探索两种不同的解决方案:ensure函数和巧妙地使用alter。
练习 12.04:跟上股票价格
股票购买功能的初始原型似乎正在正常工作,但团队意识到它无法正确响应在交易完成期间发生的股票价格变动。你被要求提出一些解决方案。
-
在你的 REPL 中,使用与上一个练习相同的环境。如果需要,重新创建相同的五个 refs,并确保
reset-accounts函数已定义。 -
在
buy-acme-corp-shares中使用ensure来取消引用acme-corps-stock-price:user> (defn buy-acme-corp-shares [n] (dosync (let [price (ensure acme-corp-share-price)] (println "Let's buy" n "stock(s) at" price "per stock") (Thread/sleep 1000) (alter client-account #(- % price)) (alter broker-account #(+ % price)) (alter client-stocks update :acme-corp #(+ % n)) (alter broker-stocks update :acme-corp #(- % n))))) #'user/buy-acme-corp-shares -
运行与之前相同的交易。我们将添加一个额外的
println语句来查看股票价格更新发生的时间:user> (do (reset-accounts) (future (buy-acme-corp-shares 1)) (future (dosync (Thread/sleep 300) (println "Raising share price to " (+ @acme-corp-share-price 10)) (alter acme-corp-share-price + 10)))) Let's buy 1 stock(s) at 22 per stock #<Future@5410594c: :pending>Raising share price to 32 Raising share price to 32 Raising share price to 32 user> @client-account 2078在这个输出中,有两点值得注意:
Raising share price…被打印了 3 次,而客户账户余额仍然只减少了 22。发生了什么?当在
buy-acme-corp-shares中调用ensure时,acme-corp-share-price的值被冻结,直到dosync块完成。第二个dosync宏会一直重试,直到第一个完成。当buy-acme-corp-shares终止后,acme-corp-share-price最终可以提升。在
buy-acme-corp-shares购买股票的瞬间,价格仍然是 22。因此,数据一致性得到了保持。然而,存在一个问题。在现实世界中,单个买家无法迫使整个股市等待一笔交易完成。这种解决方案在某种程度上是正确的,但在这个场景中它不会起作用。 -
使用
alter触发重试。这次,我们将回到deref来访问当前股价。我们还会在acme-corp-share-price上调用alter,如果该引用已更改,则触发重试。你可能认为“我们无法更改股价!”当然,你是对的,但我们的alter调用实际上不会做任何事情,因为我们只会提供一个identity函数作为参数。我们调用alter,但仅仅是为了说“保持原样”:user> (defn buy-acme-corp-shares [n] (dosync (let [price @acme-corp-share-price] (println "Let's buy" n "stock(s) at" price "per stock") (Thread/sleep 1000) (alter acme-corp-share-price identity) (alter client-account #(- % price)) (alter broker-account #(+ % price)) (alter client-stocks update :acme-corp #(+ % n)) (alter broker-stocks update :acme-corp #(- % n))))) #'user/buy-acme-corp-shares让我们看看会发生什么:
user> (do (reset-accounts) (future (buy-acme-corp-shares 1)) (future (dosync (Thread/sleep 300) (println "Raising share price to " (+ @acme-corp-share-price 10)) (alter acme-corp-share-price + 10)))) Let's buy 1 stock(s) at 22 per stock #<Future@2b64a327: :pending>Raising share price to 32 Let's buy 1 stock(s) at 32 per stock这次,
Let's buy 1 stock(s)…被打印了两次,价格不同。Raising share price…只打印了一次。在第一次调用buy-acme-corp-shares时,股价发生了变化。由于变化,触发了重试,现在有了正确的股价。购买最终以正确的价格完成:user> @client-account 2068
这个练习展示了 Clojure 的 STM 的强大功能和微妙之处。根据你需要解决的问题和你工作的环境,你可能需要在不同的环境下进行重试。在这个例子中,很明显我们无法要求股市等待甚至 1 秒钟来完成我们的交易。引用(Refs)让你能够精确地定义所需的重试行为。(除了 alter 和 ref-set,还有 commute,它提供了一套更新引用的语义,当需要较少的控制时使用。)当然,这种程度上的控制也需要仔细思考引用之间的关系。
代理
代理与其他引用类型的主要区别在于,虽然原子和引用的更新是同步的,但代理的更新是异步的。对代理所做的更改被发送到一个队列,一个等待更改的列表,函数在另一个线程中运行。与引用和原子不同,调用线程在等待操作完成时不会被阻塞。因此,虽然代理在更新方面提供的控制比引用少得多,但它们不会因为重试而减慢操作。在原子和引用中,重试是解决同时性突变问题的必要手段;在代理中,通过放弃同时性,简单地按接收顺序执行传入的函数来解决这个问题。
我们可以通过进行需要几秒钟才能完成的更改来观察代理的异步特性,这要归功于Thread/sleep:
user> (def integer-agent (agent 5))
#'user/integer-agent
user> (send integer-agent (fn [a] (Thread/sleep 5000) (inc a)))
#<Agent@3c221047: 5>
user> (send integer-agent (fn [a] (Thread/sleep 5000) (inc a)))
#<Agent@3c221047: 5>
user> @integer-agent
5
user> @integer-agent
6
user> @integer-agent
7
首先,我们定义代理,将其值设置为5。然后我们在 5 秒后发送两个相同的修改来增加代理的值。如果我们快速输入@integer-agent(或(deref(整数代理)),但那需要更多时间),我们会看到值仍然是5。如果我们等待一段时间后再输入@integer-agent,我们会看到值已经增加到6。然后几秒钟后,它再次增加到7`。
如果我们快速输入@integer-agent(或(deref(整数代理)),但那需要更多时间),我们会看到值仍然是5。如果我们等待一段时间后再输入@integer-agent,我们会看到值已经增加到6。然后几秒钟后,它再次增加到7`。
如果我们将前一个例子中的代理替换为原子(并使用swap!而不是send),最终结果相同,但我们被迫等待操作完成才能重新获得 REPL(读取-评估-打印-循环)的控制权:
user> (def integer-atom (atom 5))
#'user/integer-atom
user> (swap! integer-atom (fn [a] (Thread/sleep 5000) (inc a)))
6
user> (swap! integer-atom (fn [a] (Thread/sleep 5000) (inc a)))
7
如果你尝试在你的 REPL 中这样做,你将看到每次调用swap!后,REPL 提示符都会被阻塞 5 秒钟。
由于它们不会阻塞,有些情况下代理比原子或引用更可取。例如,假设你的主应用程序将一个计算密集型任务分成几个部分,这些部分可以传递给不同的线程。当这些线程在工作时,你想要向用户展示一个进度条。当一个线程完成一项工作单元时,它会在代理中增加一个计数器。异步代理的优势在于这不会减慢工作线程的速度:控制权立即返回到线程,该线程可以立即开始工作,代理可以独立处理更新:


图 12.14:工作线程向代理发送进度更新
类似于引用和解除链接的原子,代理也受益于 STM(软件事务内存)。在一个dosync块内部,对多个代理所做的更改可以享受与引用相同的重试语义。因为代理的更新是异步的,所以在输出方面,它们不像引用那样提供那么多的控制,但它们在非常繁忙的系统中的无限重试导致的死锁倾向较小。
ClojureScript 中的原子
变量和原子是 ClojureScript 中唯一可用的引用类型。尽管 JavaScript 运行时不是多线程的,但代码执行通常是线性的。在浏览器中,单页应用程序需要能够处理来自每个链接或输入的事件,或者来自滚动和悬停等操作的事件。应用程序状态需要由触发这些事件的代码共享,而原子被证明是一个非常不错的选择。(这是幸运的,因为它们是唯一的选择。)
本章的其余部分将专注于浏览器中的原子。你的 Clojure 并发第一次真正的生活体验可能不会是一个复杂的多线程 JVM 应用程序。你很可能在基于浏览器的 ClojureScript 程序中迈出你的第一个 Clojure 并发步骤。许多最著名的用于构建浏览器应用程序的 ClojureScript 框架,如 Reagent、Re-frame 和 Om,都使用原子来管理状态。
练习 12.05:剪刀石头布
在这个练习中,我们将使用 ClojureScript 实现著名的剪刀石头布游戏。真正的游戏是在两个人之间进行的,他们数到三然后同时做出手势,要么是“石头”、“剪刀”或“布”。这三个选择中的每一个都可以击败另外两个,并且可以被另一个击败。因此,“石头砸剪刀”,“剪刀剪布”,“布包石头”。如果两个玩家选择了相同的物品,那么就是平局,他们需要再次进行游戏。
-
在命令行提示符下,使用以下
Leiningen命令创建一个新的figwheel项目:lein new figwheel packt-clj.rock-scissors-paper -- --rum -
切换到
packt-clj.rock-scissors-paper/目录,并输入以下内容:lein figwheel几秒钟后,你的浏览器应该会打开默认的 Figwheel 页面:
![图 12.15:一个等待你编写代码的全新 ClojureScript 项目]()
图 12.15:一个等待你编写代码的全新 ClojureScript 项目
打开
packt-clj.rock-scissors-paper/src/packt_clj/rock_scissors_paper/core.cljs并准备编写一些代码。 -
让我们先设计底层的数据。这是一个非常简单的游戏,所以不会占用太多时间。我们需要跟踪电脑的选择(石头、剪刀或布)和用户的选择。我们还需要有一个游戏状态,它将是三种状态之一:
:setup(游戏尚未开始),:waiting(等待用户进行游戏),和:complete(我们将显示获胜者并提议再次进行游戏)。将提供的
app-state定义替换为以下内容:(defonce app-state (atom {:computer-choice nil :game-state :setup :user-choice nil}))我们还希望将其变成我们自己的应用程序,所以让我们更新一些函数名。例如,将
hello-world组件重命名为rock-scissors-paper。 -
所有的游戏逻辑都将放在一个
rock-paper-scissors组件中。目前,我们只需显示一些文本:(rum/defc rock-paper-scissors [] [:div [:h1 "Rock, Paper, Scissors"]])当我们在这里时,我们可以稍微改变文件末尾,以使用我们在第九章中使用的模式,Java 和 JavaScript 与宿主平台的互操作性:
(defn on-js-reload [] (rum/mount (rock-paper-scissors) (. js/document (getElementById "app")))) (on-js-reload) -
现在我们将定义游戏逻辑本身。我们将尝试让这个程序的部分不依赖于 ClojureScript。第一个函数将确定电脑选择石头、纸或剪刀:
(defn computer-choice [] (nth [:rock :scissors :paper] (rand-int 3))) -
唯一稍微复杂的问题是将“石头砸剪刀”规则转换成代码。当然,我们可以简单地写一个长的
cond结构,但由于这是 Clojure,所以我们将使用数据结构:(def resolutions {:rock {:paper :computer-wins :scissors :player-wins} :scissors {:rock :computer-wins :paper :player-wins} :paper {:scissors :computer-wins :rock :player-wins}})在
resolutions映射中,顶层键对应于人类玩家的选择。每个项目包含基于电脑可能做出的两个非平局选择的两种可能结果。 -
这意味着如果玩家选择
:rock而电脑选择:scissors,我们可以得到如下结果:packt-clj.rock-scissors-paper.core> (get-in resolutions [:rock :scissors]) :player-wins -
这就是我们将如何编写我们的
resolve-game函数。通过简单的相等检查来检查平局是容易的:(defn resolve-game [player computer] (if (= player computer) :tie (get-in resolutions [player computer]))) -
我们还希望通过提供像“石头砸剪刀”这样的消息来告诉用户他们赢或输的原因。这些消息不需要提到谁赢了,所以我们只需要将一对对象与一个消息关联起来。无论顺序如何,
:rock和:paper应该导致Paper wraps rock。由于顺序不重要,sets可能是一个不错的选择。我们可以使用集合作为映射键,如下所示:(def object-sets->messages {#{:rock :scissors} "Rock crushes scissors." #{:scissors :paper} "Scissors cut paper." #{:paper :rock} "Paper wraps rock."})大多数时候我们使用关键词作为映射键,以至于有时我们会忘记更复杂的数据结构也可以使用。这样,我们写以下内容时就不重要了:
(get object-sets->messages #{:rock :scissors})我们也可以写出以下内容:
(get object-sets->messages #{:scissors :rock}) -
让我们将这个逻辑封装成一个函数:
(defn result-messages [a b] (get object-sets->messages (hash-set a b)))这里,
hash-set构建了用于查找适当消息的集合。a和b参数可以互换,是玩家的选择或电脑的选择。 -
到目前为止,一个游戏可以通过两次函数调用来解决:一次是知道谁赢了,另一次是知道为什么。在这里,玩家选择了
:scissors并击败了选择了:paper的电脑:packt-clj.rock-scissors-paper.core> (resolve-game :scissors :paper) :player-wins packt-clj.rock-scissors-paper.core> (result-messages :scissors :paper) "Scissors cut paper." -
下一步是将我们的游戏玩法转换成视图。让我们将游戏视图拆分成它自己的组件,我们将称之为
game-view。游戏状态只有三种,所以我们可以通过case表达式来避免。我们将从占位符开始:(rum/defc game-view < rum/reactive [] (case (:game-state (rum/react app-state)) :setup [:div "Ready to play?" [:div [:a {:href "#start"} "Start"]]] :waiting [:div "Choose one" [:div [:a {:href "#rock"} "Rock"]] [:div [:a {:href "#paper"} "Paper"]] [:div [:a {:href "#scissors"} "Scissors"]]] :complete [:div [:a {:href "#restart"} "Play again?"]]))要查看这个组件的输出,我们可以将其连接到我们之前定义的
(rock-scissors-paper)函数:(rum/defc rock-paper-scissors [] [:div [:h1 "Rock, Paper, Scissors"] (game-view)])到目前为止,
game-view组件只是根据游戏状态显示一些不同的标记,因为游戏状态卡在:setup上,因为没有代码让任何事情发生。尽管如此,确保一切按预期工作仍然是一个好主意。这里的重点是app-state如何通过rum/react进行解引用。rum库添加了许多超出只是解引用的内置行为。现在,我们可以将rum/react视为一个花哨的、特定于框架的deref版本:![图 12.16:我们有一个开始屏幕,但还没有游戏玩法]()
图 12.16:我们有一个开始屏幕,但还没有游戏玩法
-
要进入下一个游戏状态,当玩家点击“开始”时需要发生某些事情。我们需要一个启动游戏的函数。
要开始新游戏,我们需要对
app-state做两件事:将:game-state设置为:waiting,并将:computer-choice设置为computer-choice函数的输出。同样,清理:player-choice字段也可能是良好的实践,因为它不再有效。我们的start-game函数可以看起来像这样:(defn start-game [] (swap! app-state (fn [state] (assoc state :computer-choice (computer-choice) :game-state :waiting :player-choice nil))))注意,我们正在使用
swap!。由于app-state是一个真正的原子,这就是我们必须通过提供函数与之交互的方式。如果我们想更简洁,我们的swap!调用可以重写如下:(swap! app-state assoc :computer-choice (computer-choice) :game-state :waiting :player-choice nil) -
在
game-view组件内部,我们现在可以在:setup和:complete阶段添加start-game作为点击处理程序:(rum/defc game-view < rum/reactive [] (case (:game-state (rum/react app-state)) :setup [:div "Ready to play?" [:div [:a {:href "#start" :onClick start-game} "Start"]]] :waiting [:div "Choose one" [:div [:a {:href "#rock"} "Rock"]] [:div [:a {:href "#paper"} "Paper"]] [:div [:a {:href "#scissors"} "Scissors"]]] :complete [:div [:a {:href "#restart" :onClick start-game} "Play again?"]]))让我们检查这个新行为。如果你点击“开始”,你现在应该看到这个:
![图 12.17:启动石头、剪刀、布应用程序]()
图 12.17:启动石头、剪刀、布应用程序
-
现在我们需要为每个选择编写处理程序。由于每个处理程序基本上都会做同样的事情,只是值不同,让我们编写一个返回函数的函数,我们将它称为
player-choice:(defn player-choice [choice] (fn [] (swap! app-state (fn [state] (assoc state :player-choice choice :game-state :complete)))))这里对
app-state所做的更改将:game-state移动到下一个阶段,并添加choice参数作为匿名函数的关闭手段。 -
我们不需要编写三个单独的处理程序,我们可以在视图中调用这些函数。现在我们的
game-view组件看起来像这样:(rum/defc game-view < rum/reactive [] (case (:game-state (rum/react app-state)) :setup [:div "Ready to play?" [:div [:a {:href "#start" :onClick start-game} "Start"]]] :waiting [:div "Choose one" [:div [:a {:href "#rock" :onClick (player-choice :rock)} "Rock"]] [:div [:a {:href "#paper" :onClick (player-choice :paper)} "Paper"]] [:div [:a {:href "#scissors" :onClick (player-choice :scissors)} "Scissors"]]] :complete [:div [:a {:href "#restart" :onClick start-game} "Play again?"]]))注意,使用
start-game处理程序时,我们提供的是函数本身,不带括号。这是因为start-game本身是处理程序。对于player-choice,我们在定义视图时调用该函数;它不是处理程序,而是返回一个匿名处理程序,当用户点击链接时,实际上会调用这个处理程序。 -
现在,当点击“石头”、“剪刀”或“布”时,你应该看到最终屏幕:
![图 12.18:石头、剪刀、布的最终屏幕]()
图 12.18:石头、剪刀、布的最终屏幕
-
最后一步是显示结果。由于这比其他视图更复杂,所以将其拆分为一个新的组件是值得的,我们将它称为
result-view。让我们看看代码,然后我们将通过逻辑来分析:(rum/defc result-view < rum/reactive [] (let [player (:player-choice (rum/react app-state)) computer (:computer-choice (rum/react app-state)) result (resolve-game player computer)] [:div [:div "You played " [:strong (name player)]] [:div "The computer played " [:strong (name computer)]] (if (= result :tie) [:div "It was a tie!"] [:div [:div (result-messages player computer)] [:div (if (= result :player-wins) "You won!" "Oops. The computer won.")]]) [:div [:a {:href "#start" :onClick start-game} "Play again?"]]]))我们从为玩家选择、电脑选择和结果(由两个选择推导而来)设置一些
let绑定开始,结果。之后的所有内容都在单个
:div元素中完成。这是必要的,因为在 React 中,以及在所有基于 React 的 ClojureScript 框架中,一个组件只能返回一个 HTML 元素。如果没有这个包装:div元素,我们会得到一个错误。在显示两个选项之后,使用
name将关键字转换为字符串,我们就可以得到实际的结果。在出现平局的情况下,没有太多可以显示的,所以我们首先测试这一点。result-messages函数提供了一个关于发生了什么的良好总结,然后我们可以根据result的值最终告诉玩家他们赢了还是输了。最后,我们在game-view组件中放置了之前在其中的"Play again?"链接。 -
现在我们只需要将
result-view组件插入到game-view组件中:(rum/defc game-view < rum/reactive [] (case (:game-state (rum/react app-state)) :setup [:div "Ready to play?" [:div [:a {:href "#start" :onClick start-game} "Start"]]] :waiting [:div "Choose one" [:div [:a {:href "#rock" :onClick (player-choice :rock)} "Rock"]] [:div [:a {:href "#paper" :onClick (player-choice :paper)} "Paper"]] [:div [:a {:href "#scissors" :onClick (player-choice :scissors)} "Scissors"]]] :complete (result-view)))注意
本练习的完整代码可在本书的 GitHub 仓库中找到:
packt.live/2uoDolF。现在你应该能够玩游戏了:

图 12.19:再次玩游戏的提示
你赢了!通过构建这个简单的游戏,你已经学会了使用 Clojure 的一种引用类型的基本模板,用于有状态的 ClojureScript 应用程序。尽管 JavaScript 运行时是单线程的,但原子在这里很有用,因为它们允许事件处理器以比简单覆盖数据更安全的方式与共享程序状态交互。
监视器
在之前的练习中,我们提到,当使用Rum库时,编写(rum/react app-state)基本上是取消引用app-state原子。然而,显然还有更多的事情在进行中,否则我们就会使用deref。在 Rum 等 ClojureScript 库中,原子通常作为应用程序的“单一事实来源”。Rum 和 Om 框架都使用普通的原子;Reagent 以及基于 Reagent 的流行库Re-frame,都使用一种特殊的atom实现,有时被称为“ratom”(来自r/atom,如果r是 Reagent 的命名空间别名)。你将在第十五章前端:ClojureScript UI中了解更多关于 Reagent 的内容。
为什么原子在 ClojureScript 库中如此受欢迎?首先,原子有助于管理并发更新。当存在单一事实来源时,这意味着当所有部分都尝试更新相同的数据源时,程序中的许多部分可能会相互干扰。正如我们之前所看到的,原子及其内置的重试逻辑有助于避免许多这些问题。
然而,除此之外,Clojure(以及 ClojureScript)的原子还具有另一个重要的特性,使它们在基于浏览器的应用程序中作为单一事实来源特别有用。现代 JavaScript 架构中的一种常见模式大致如下:
-
发生一个事件并由应用程序处理。在之前的练习中,这些是点击处理器。当然,还有许多其他可能发生的事件:滚动事件、超时、成功(或失败)的网络请求等等。
-
事件发生时,应用程序状态被修改。在剪刀石头布游戏中,玩家做出了选择,并通过
swap!反映在app-state中。 -
应用程序的其他部分会对应用程序状态的变化做出反应。引用应用程序状态的视图会自动更新。在
app-state中推进:game-state字段导致游戏的不同阶段被显示。一旦定义了适当的视图,框架似乎会确保视图被更新。
当应用程序的一部分更新应用程序状态时,其他部分会做出响应。原子通过接受在原子变化时被调用的“观察者”函数来帮助实现这一点。要“观察”一个原子,我们使用add-watch函数:
user> (def observable-atom (atom 5))
#'user/observable-atom
user> (add-watch observable-atom :watcher-1
(fn [k a old new]
(println "The observable atom has gone from" old "to" new)))
#<Atom@14b35f8d: 5>
user> (swap! observable-atom inc)
The observable atom has gone from 5 to 6
6
我们提供了一个匿名函数,当原子发生变化时,它只是简单地打印出一条消息。当添加watch函数时,需要一个像:watcher-1这样的键,以便稍后可以通过remove-watch函数识别并移除特定的观察者。这个键随后作为观察者函数的第一个参数可用,在这个例子中是k。a参数是原子本身。通常,这两个参数不会被使用;在大多数情况下,你真正需要的是在old和new参数中。
在上一个练习中,我们使用rum/reactive混合物如下定义了我们的组件:
(rum/defc game-view < rum/reactive []
;;
)
rum/reactive随后根据需要添加观察者,以便组件知道何时更新。这样,多个组件可以引用app-state中的相同数据;当数据发生变化时,组件正在观察,并且可以相应地更新。这种模式恰好与 React.js 中使用的某些常见模式相匹配,这就是为什么它在 ClojureScript 库和应用程序中如此常见。通常,当使用这些框架时,你不需要定义自己的观察者。框架会为你处理这些。
验证器是原子的另一个特性,你可以在你的 ClojureScript 应用程序中使用它。像观察者一样,验证器是可以添加到 Clojure 引用类型的函数。例如,当原子即将通过调用swap!被修改时,如果原子上设置了任何验证器,它们将被调用。如果其中任何一个没有返回true,更新将失败,并抛出异常(或者在 JavaScript 运行时是错误):

图 12.20:执行错误
当更新失败验证时,会抛出异常。
在这里,验证器对integer-atom强制执行“小于 6”的规则。swap!的调用试图将值增加到6,但相反抛出了异常。在下一个练习中,我们将结合一些验证。
练习 12.06:一、二、三…“Rock!”
你的网页版剪刀石头布游戏开始引起一些兴趣。你已经围绕它创建了一个初创公司,现在你的投资者想要一个更接近原始版本的游戏改进版。你的计划是在用户做出选择之前引入倒计时,就像游戏原始版本中两个玩家在揭示选择前协调他们的动作一样:“一、二、三……石头!”
要做到这一点,我们将使用 JavaScript 间隔来为倒计时提供一些时间。在浏览器中,间隔是 JavaScript 开发者可以导致函数在经过一定数量的毫秒后重复调用的方式。我们将使用间隔来模拟一个滴答作响的时钟,其中时钟的每一次滴答都将是一个应用程序需要响应的事件。这将展示如何使用应用程序状态来协调和响应事件。
注意
这个练习基于你之前练习中的代码。要么使用同一个项目,要么创建一个副本。
-
在命令行提示符下,启动 ClojureScript REPL:
lein figwheel应该打开一个浏览器窗口,邀请你玩一个剪刀石头布的游戏。
-
在
app-state原子中添加一个:countdown字段:(defonce app-state (atom {:computer-choice nil :game-state :setup :player-choice nil :countdown 3})) -
向
app-state添加一个验证器,以确保:game-state字段始终包含游戏阶段关键字,并且倒计时永远不会超过 3 或低于 0:(set-validator! app-state #(and (>= 3 (:countdown %) 0) (#{:setup :waiting :complete} (:game-state %))))注意
大于和小于函数族都接受超过两个参数。这是一种方便的测试一个值是否介于两个其他值之间的方法。
-
我们想要做的绝大多数改进都将影响游戏的
:waiting阶段。让我们创建一个专门的视图,我们将称之为choices-view函数。它将显示两个东西:倒计时和选择列表。作为第一步,设置与之前相同的选项列表视图:
(rum/defc choices-view < rum/reactive [] [:div.choices-view [:div.choices [:div "Choose one" [:div [:a {:href "#rock" :onClick (player-choice :rock)} "Rock"]] [:div [:a {:href "#paper" :onClick (player-choice :paper)} "Paper"]] [:div [:a {:href "#scissors" :onClick (player-choice :scissors)} "Scissors"]]]]])同时,将这个新视图添加到
game-view中,而不是之前与:waiting游戏状态对应的列表:(rum/defc game-view < rum/reactive [] (case (:game-state (rum/react app-state)) :setup [:div "Ready to play?" [:div [:a {:href "#start" :onClick start-game} "Start"]]] :waiting (choices-view) :complete (result-view)))在这一点上,游戏应该仍然像以前一样工作。
-
在倒计时达到零之前,选择列表中的链接应该处于非活动状态,以防止玩家过早点击。由于每个链接都需要处理两种不同的状态,因此将这种行为封装在组件中是有意义的,如下所示:
(rum/defc choice-link-view [kw label countdown] (if (zero? countdown) [:div [:a {:href (str "#" (name kw)) :on-click (player-choice kw)} label]] [:div label]))注意
记得在源文件中将
choice-link-view放在choices-view之前。前两个参数简单地提供了构建链接所需的键和文本标签,就像以前一样。然而,
countdown参数将允许我们确定应该显示什么。如果倒计时已达到零,我们显示链接。如果没有,我们只显示标签。 -
我们还需要更新
choices-view:(rum/defc choices-view < rum/reactive [] (let [ready? (= :waiting (:game-state (rum/react app-state))) countdown (:countdown (rum/react app-state))] [:div.choices-view [:div.choices [:h3 "Choose one"] (choice-link-view :rock "Rock" countdown) (choice-link-view :paper "Paper" countdown) (choice-link-view :scissors "Scissors" countdown)]]))当你尝试玩游戏时,如果你点击“开始”,你应该看到这个:
![图 12.21:点击“开始”可用的选项]()
图 12.21:点击“开始”可用的选项
-
我们还希望在视图中也显示倒计时。让我们也为此创建一个新的组件,这样我们就可以添加一些显示逻辑:
(rum/defc countdown-view < rum/reactive [countdown] [:div.countdown [:div.countdown-message (if (> countdown 0) "Get ready to make your choice..." "Go!")] [:h1 countdown]])而这个视图也可以从
choices-view中调用:(rum/defc choices-view < rum/reactive [] (let [countdown (:countdown (rum/react app-state))] [:div.player-choices-view (countdown-view countdown) [:div.choices [:h3 "Choose one"] (choice-link-view :rock "Rock" countdown) (choice-link-view :paper "Paper" countdown) (choice-link-view :scissors "Scissors" countdown)]]))如果你在这个时候尝试玩游戏,你应该会看到在非活动链接上方显示
3:![图 12.22:倒计时已经存在,但还没有开始移动]
![图片 B14502_12_22.jpg]
图 12.22:倒计时已经存在,但还没有开始移动
-
对于倒计时的计时,我们将使用
setInterval,这意味着我们需要一些 JavaScript 互操作。这个函数将导致:countdown字段每秒递减:(defn start-countdown [] (js/setInterval #(swap! app-state update :countdown dec) 1000)) -
setInterval函数返回一个标识符,我们稍后会需要它来取消间隔。每次新游戏开始时,我们需要启动间隔并记录其标识符。这些事情可以通过start-game函数来完成,我们将更新它。(因此,start-countdown需要放在你的源文件中的start-game函数之前。)(defn start-game [] (let [interval (start-countdown)] (swap! app-state (fn [state] (assoc state :computer-choice (computer-choice) :game-state :waiting :countdown 3 :interval interval)))))interval将被放入 app-state 中以便稍后使用。 -
我们知道我们不想让倒计时低于 0。我们也不想在游戏中不取消上一个间隔就启动一个新的间隔。经过几轮游戏后,我们会有许多间隔都在尝试更新
:countdown字段。使用
clearInterval函数停止间隔非常简单。但我们是怎样知道何时应该调用它的呢?一个解决方案是在start-countdown函数中传递给setInterval的函数中添加一个检查。然而,对于这个练习,我们将使用一个观察者:(add-watch app-state :countdown-zero (fn [_k state old new] (when (and (= 1 (:countdown old)) (= 0 (:countdown new))) (js/clearInterval (:interval new)))))我们不会移除这个观察者,但我们仍然需要给它一个标识符,我们使用一个描述性的关键字。这个
add-watch调用的有趣之处在于我们提供的匿名函数。这个函数将在app-state原子中的每次变化时被调用。大多数时候,这个函数将不会做任何事情。当然,例外的情况是当倒计时即将达到零时。在这种情况下,使用存储在原子中的间隔标识符调用clearInterval。现在倒计时应该按计划工作。当它达到零时,消息变为
Go!,链接变为活动状态:
![图 12.23:成功的倒计时]
![图片 B14502_12_23.jpg]
图 12.23:成功的倒计时
这个练习向我们展示了观察者和验证者是如何工作的。记住,在 Clojure 中,它们可以用于所有不同类型的引用类型。
活动 12.01:一个 DOM Whack-a-mole 游戏
在你的 Rock, Paper, Scissors 浏览器游戏取得惊人的成功之后,你决定创建一个更加雄心勃勃的产品,基于经典的 Whack-a-mole 游戏。Whack-a-mole 是一款早期的街机游戏。老鼠从桌子上的几个洞中随机弹出。玩家拿着一根槌子,试图在老鼠出现时立即击打它们。被击中后,老鼠会消失回它的洞中,直到再次弹出。
你的 Whack-a-mole 版本将在网页浏览器中使用 DOM 元素。它可能看起来像这样(如果你懂一些 CSS,你可以自由地让它看起来更好一些):



一旦玩家点击“点击开始!”按钮,时钟开始,实际上只是 HTML <div> 元素的地鼠开始随机激活:



在这一点上,如果玩家点击地鼠,它将回到等待状态,并为玩家的得分加一分。游戏在固定秒数后停止,可能是 20 秒左右。
地鼠的行为遵循以下规则:一次只能看到两个地鼠。要显示的地鼠是随机选择的。地鼠在固定的时间内可见(可能是 2 或 3 秒),如果玩家在这段时间内没有点击它,它将恢复到隐藏状态。
要构建这个游戏,你应该采取以下基本步骤:
-
使用在“剪刀石头布”练习中使用的相同的基本 ClojureScript 和 Rum 设置。
-
如同上一个练习,使用
setInterval来在游戏开始后倒计时秒数。为了更流畅,可能最好使用小于 1 秒的间隔。100 毫秒可能恰到好处。 -
使用多个 atoms 来管理所需的各个计数器:倒计时时钟(间隔本身)、游戏中剩余的毫秒数、游戏状态(
:waiting或:playing)和地鼠的向量。 -
地鼠本身应该有两个值:它们的状态(
:waiting或:live)以及如果它们处于:live状态,剩余的毫秒数。这些值可以包含在一个 map 或一个包含两个元素的向量元组中。 -
为“开始”按钮和活动地鼠的点击编写事件处理器。
-
如果你懂一些 CSS,请随意通过向
resources/public/css/style.css添加一些定义来使游戏看起来更好。注意
本活动的解决方案可以在第 738 页找到。
摘要
并发,由于其本质,是一个复杂的问题。虽然不可能涵盖你可能需要的所有技术,但希望这一章能为你提供开始所需的工具。我们介绍了 pmap 和 future 的用法来使用多线程。我们还看到了 Clojure 的引用类型:var、atoms、agents 和 refs。我们使用 atoms 在基于浏览器的 ClojureScript 应用程序中管理状态。
对于这些主题中的每一个,都有更多可以说的。你以后学到的东西将取决于你需要解决的问题的类型。并发是问题比几乎所有其他领域都更加多样化的领域之一。熟悉 Clojure 对这些问题的基本方法将帮助你找到解决方案的正确方向。
在下一章中,我们将迈出另一大步,通过学习如何与数据库交互,向现实世界的 Clojure 迈进。
第十三章:13. 数据库交互和应用层
概述
在本章中,我们将在您的本地机器上创建并连接到 Apache Derby 实例。我们还将创建并使用连接池以实现高效的数据库交互,创建并加载数据库模式。然后我们将使用 clojure.java.jdbc 创建、读取、更新和删除数据。
到本章结束时,您将能够实现一个位于数据库实例之上的应用层,从磁盘上的 CSV 文件中摄取数据,并通过应用层 API 将其写入数据库。
简介
到目前为止,我们一直在磁盘和内存中与 逗号分隔值(CSV)文件进行交互,但没有持久化。每次我们重启我们的 REPL,我们就会丢失之前所做的所有数据操作或 ELO 计算结果,必须从头开始。如果每次都能持久化这种状态,我们就可以从上次离开的地方继续。实际上,一旦我们建立了一种持久化存储的方法,我们就可以想象构建一个带有 Web 界面或 ClojureScript 前端的 ELO 计算应用程序,以便我们的进度可以从会话持续到会话。
当考虑持久化时,大多数应用程序会寻求一个关系型数据库实现(例如 MySQL、Oracle 或 PostgreSQL)。有许多实现可供选择,每个都有自己的优点和缺点。
我们将使用 Apache Derby 作为磁盘上的 Driver 表和 Car 表,并通过引用将它们链接起来。我们将在后面的 创建数据库模式 部分介绍描述这种关系的方法。
关于我们潜在的 ELO 计算应用程序,在考虑持久化时有许多主题需要考虑。我们需要确定我们想要存储哪些数据,然后确定最佳描述这些数据及其模型不同部分之间关系的模型。我们将不得不考虑一些约束;例如,我们将如何唯一标识我们正在持久化的实体?这将在我们的数据库模式中使用 数据定义语言(DDL)进行编码。
定义了模式后,我们需要一种插入、检索、更新和删除数据的方法。由于 Apache Derby 是基于 结构化查询语言(SQL)的 RDBMS,这是这些目的与数据库交互的最合适方式。我们将构建覆盖前面所有要求的 SQL 命令。这些命令将理解底层数据模型,关系的构建方式以及如何访问我们感兴趣的模型的相关部分。Apache Derby 将为我们执行 SQL 命令并返回结果。
在从 Clojure 与此数据库交互方面,我们将主要使用 clojure.java.jdbc,这是一个历史悠久、稳定、低级别的库,用于通过 Java 数据库连接(JDBC)与数据库通信。
应该注意的是,这个库的维护者已经创建了一个后继者,next.jdbc,它专注于简单性和性能。一个有趣的练习是将这里提供的示例重新工作,以符合 next.jdbc API。
连接到数据库
如前所述,我们将利用 JDBC 进行所有数据库交互。JDBC 允许 Java 客户端使用一个定义良好的 应用程序编程接口(API)连接到 RDBMS。这个 API 在我们(客户端)和我们的数据库(服务器)之间提供了一个清晰的合同。由于 Clojure 位于 Java 虚拟机(JVM)之上,因此 JDBC 是我们的自然选择。
对于熟悉 JDBC 的人来说,你可能会遇到(偶尔难以操作的)JDBC URL。这些 URL 根据不同的 RDBMS、数据库的位置以及如何进行安全保护等因素而有所不同。本质上,它们是一个数据库连接描述符。
幸运的是,clojure.java.jdbc 通过其 db-spec(数据库规范)概念抽象了这一点。db-spec 是一个简单的映射结构,包含我们想要建立的连接的相关细节。然后可以将这个 db-spec 结构传递给任何 clojure.java.jdbc API 调用,它将在幕后为我们建立连接。这个规范相当广泛,可以采取多种不同的形式。随着我们的进展,我们将讨论其中的一些。
一个重要的要点是,clojure.java.jdbc 预期在执行 API 调用之前,目标数据库的驱动程序必须存在于类路径上。驱动程序充当将基于 JDBC 的 API 调用转换为 RDBMS 理解的某种手段。因此,每个 RDBMS 都将有自己的特定驱动程序。如果没有这个,任何数据库操作都会抛出异常。
例如,如果我们定义一个只包含 clojure.java.jdbc 依赖项的 deps.edn 文件,然后尝试执行 jdbc/query 操作,这将得到以下结果:
{:deps {org.clojure/java.jdbc {:mvn/version "0.7.9"}}}
(require '[clojure.java.jdbc :as jdbc])
在这里,我们遇到了第一个具体的 db-spec 定义。这是 DriverManager 形式的 db-spec,是 clojure.java.jdbc 的首选格式:
(def db {:dbtype "derby" ;; the type of RDBMS
:dbname "derby-local" ;; the DB as it will be stored on disk
:create true ;; essential on first interaction
})
分析我们的 db-spec 定义,我们正在与 Apache Derby 数据库交互(这是我们正在寻找的驱动程序)。我们在当前工作目录中将数据库命名为 derby-local。:create true 标志将在数据库文件不存在时创建该文件:
user=> (jdbc/get-connection db)
输出如下:

图 13.1:由于未添加驱动程序导致的错误
遇到这种情况通常表明你没有将驱动程序添加到你的 deps.edn 文件或等效文件中。
clojure.java.jdbc 的 GitHub 页面的 发布和依赖 部分包含了对流行 RDBMS 的驱动程序的链接。一旦找到适当的驱动程序版本,请将其添加到您的 deps.edn 文件中。以下是一个示例:
{:deps {org.apache.derby/derby {:mvn/version "10.14.2.0"}}
如您所见,Apache Derby 提供了一个包含其数据库实现和嵌入式驱动程序的包,这意味着我们不需要在我们的项目中显式添加驱动程序依赖项。
练习 13.01:建立数据库连接
在这个练习中,我们将连接到本地磁盘数据库:
-
我们将首先设置我们的依赖项。在你的当前工作目录中创建一个名为
deps.edn的文件,并粘贴以下内容:{:deps {org.apache.derby/derby {:mvn/version "10.14.2.0"} org.clojure/java.jdbc {:mvn/version "0.7.9"}}}应该注意的是,在撰写本文时,Apache Derby 版本 10.15.1.3 是可用的。不应与
clojure.java.jdbc0.7.9一起使用!clojure.java.jdbc的维护者建议它未经 10.15.x 测试。 -
需要
clojure.java.jdbc并将其别名(一个临时名称)作为jdbc以便于使用:user=> (require '[clojure.java.jdbc :as jdbc]) nil -
在这里,我们使用前面介绍中的
db-spec定义。引入一个具体的db-spec定义实例:user=> (def db {:dbtype "derby" :dbname "derby-local" :create true}) => #'user/db -
测试我们能否获取到这个数据库的连接:
user=> (jdbc/get-connection db)输出如下:
![图 13.2:获取数据库连接
![图片]()
图 13.2:获取数据库连接
注意
上述输出是我们连接的toString表示形式。内容并无实际意义,因为我们不关心连接对象的内部表示。
太好了!我们有了在磁盘上创建 Apache Derby 实例的方法,并且已经成功建立了连接。因此,这个db-spec定义是有效的,可以在接受db-spec定义的任何地方使用。
注意
任何时候我们希望删除我们的本地数据库并重新开始,我们都可以通过删除当前工作目录中与我们的数据库名称匹配的目录来实现。
连接池简介
虽然clojure.java.jdbc为我们创建数据库连接很方便(它在每次 API 调用时都会这样做,当我们传递一个db-spec定义时),但由此产生的性能开销我们应该注意。这可能会变得很麻烦,因为建立连接(尤其是连接到远程机器)通常比我们的查询实际执行所需的时间长得多!因此,这是一个昂贵的操作,我们希望避免。连接池就是这样一种避免这种开销的方法。
当我们谈论连接池时,我们实际上是在谈论提前建立一个或多个连接,并在需要数据库连接时使它们可用给我们的应用程序。这样,我们在应用程序启动时一次性处理连接开销,并从那时起受益于连接重用。
clojure.java.jdbc本身不提供连接池实现,但它与包括c3p0和hikari-cp在内的多个连接池库很好地集成。我们将重点介绍hikari-cp,因为它是一个 Clojure 包装器,用于闪电般的hikariCP连接,具有超级简单的 API。
hikari-cp 提供了一个 API,允许我们构建一个连接池数据源;我们可以使用这个来构建一个替代的 db-spec 定义,以替代我们的基于 DriverManager 的 db-spec,无需进行其他更改。hikari-cp 将为我们管理连接池。
练习 13.02:创建连接池
在这个练习中,我们将创建一个替代的 db-spec 定义,它可以替代在 练习 13.01 的 步骤 3 中创建的数据库连接。好处是提高数据库交互的速度,因为不需要为每次交互重新建立连接:
-
将
hikari-cp依赖项添加到我们的应用程序中:{:deps {hikari-cp {:mvn/version "2.8.0"} org.apache.derby/derby {:mvn/version "10.14.2.0"} org.clojure/java.jdbc {:mvn/version "0.7.9"}}} -
现在,根据我们精确的需求,我们有几种不同的方法来构建一个有效的数据源。当我们从头开始,并且需要创建数据库以及建立连接时,我们将需要找到合适的 JDBC URL。Apache Derby 的 URL 构建起来比其他数据库要简单,遵循以下简单的语法:
jdbc:derby:[subprotocol:][databaseName][;attribute=value]注意
更多关于 Derby JDBC 数据库连接 URL 的详细信息可以在
packt.live/2Fnnx9f找到。 -
因此,我们可以像这样定义我们的
db-spec定义(使用datasource格式):user=> (require '[clojure.java.jdbc :as jdbc] '[hikari-cp.core :as hikari]) nil user=> (def db {:datasource (hikari/make-datasource {:jdbc-url "jdbc:derby:derby-local;create=true"})}) => #'user/db为了分解这个问题,我们正在连接到一个名为
derby-local的 Apache Derby 实例。您会记得,create=true指示数据库如果不存在则创建。 -
或者,如果我们已经知道数据库已经存在,那么不需要
create=true标志。我们可以修改 JDBC URL 或者允许hikari-cp为我们构建它:(def db {:datasource (hikari/make-datasource {:database-name "derby-local" :datasource-class-name "org.apache.derby.jdbc.EmbeddedDataSource"})})注意,在这里,我们需要指定
datasource-class-name,在这种情况下,是嵌入式版本,因为我们是在本地运行。 -
无论我们使用什么方法构建我们的数据源,我们都可以将其传递给
clojure.java.jdbc库作为替代的db-spec定义:(jdbc/get-connection db)输出如下:
![图 13.3:打印输出]()
图 13.3:打印输出
我们现在已经成功定义并测试了两种不同的 db-spec 格式,展示了 clojure.java.jdbc 提供的灵活性。应该注意的是,相当多的替代方案也是可接受的,包括 :connection-uri(一个 JDBC URL)和 :connection(一个已经建立的连接,很少需要)。
注意
您可以参考 http://clojure.github.io/java.jdbc/#clojure.java.jdbc/get-connection 以获取支持的 db-spec 定义的全部详细信息。
总结一下,clojure.java.jdbc 在它所消费的内容上非常灵活。因此,当我们开始更认真地与我们的新创建的数据库交互时,我们将使用连接池数据源。
创建数据库模式
我们已经有了数据库连接。在我们开始持久化和查询数据之前,我们必须定义我们的数据库模型,或者更常见地称为“模式”。这将是以下形式:
-
表
-
表中的字段/列
-
表之间的关系
让我们考虑一个体育活动追踪器的例子,我们的网球超级巨星可能在他们的业余时间使用。我们希望存储应用程序用户和活动。让我们看看我们如何使用两个表来模拟这些。
app_user表将存储名字、姓氏、身高和体重。活动表将存储日期、活动类型、距离和持续时间。
主键
注意到我们存储的信息没有独特之处。当我们只有名字可以查询时,我们如何正确地加载用户的身高和体重?例如,可能有多个用户使用相同的名字创建,然后我们会遇到关于活动正确所有权的难题。
我们需要引入insert语句;然而,利用 Apache Derby 的一个特性是有用的,它可以代表我们分配一个唯一的 ID,然后在插入时将其传达给我们。
将GENERATED ALWAYS AS IDENTITY添加到列定义中,将指示 Apache Derby 在向我们的表中插入每一行新数据时自动分配一个单调递增的整数 ID。这消除了我们可能有的构建一个 ID 的开销,并保证了其唯一性。
外键
当考虑一个活动时,我们可以观察到,一个活动不能在没有app_user存在的情况下存在;也就是说,一个活动必须引用app_user表中的一个现有条目。这就是外键概念出现的地方。
外键是创建父表和子表之间关系的一种方式。我们可以在活动表中定义一个外键,它引用我们的app_user表的主键。当我们创建一个活动时,我们必须有app_user表的主键可用,这样我们才能将其添加到我们的活动中。有了这种链接/关系,我们就可以构建一个查询,查询属于某个用户的所有活动,例如。
简单外键的定义看起来像这样:
<foreign key field name> <foreign key type> REFERENCES <parent table>
此外,我们通常还会添加ON DELETE CASCADE到这个定义中,表示当从父表中删除相应的条目时,子表中的条目也应该被删除。如果活动表中的条目不能作为一个独立的实体存在,这是很重要的;也就是说,它只有在与app_user关联的上下文中才有意义。
练习 13.03:定义和应用数据库模式
给定之前提到的表要求,我们现在将使用 DDL(即我们将使用以创建这些结构的实际 SQL 命令)来规范这些要求:
-
在 DDL 中表示,我们将有如下内容:
(def create-app-user-ddl "CREATE TABLE app_user ( id INT GENERATED ALWAYS AS IDENTITY CONSTRAINT USER_ID_PK PRIMARY KEY, first_name VARCHAR(32), surname VARCHAR(32), height SMALLINT, weight SMALLINT)") => #'user/create-app-user-ddl (def create-activity-ddl "CREATE TABLE activity ( id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, activity_type VARCHAR(32), distance DECIMAL(5,2), duration INT, user_id INT REFERENCES app_user ON DELETE CASCADE)") #'user/create-activity-ddl注意
我们前面的每个符号都带有
-ddl后缀。这是通常用于描述数据库架构的语法。 -
clojure.java.jdbc提供了一种辅助函数,可以为我们构造 DDL 语句。尽管如此,对于我们这个用例来说,唯一真正的优势是能够切换实体的格式(表名、列名和数据类型),以及使用关键字代替手动构造字符串的部分。生成create-app-user-ddl的等效函数执行如下:(def create-app-user-ddl-2 (jdbc/create-table-ddl :app_user [[:id :int "GENERATED ALWAYS AS IDENTITY CONSTRAINT USER_ID_PK PRIMARY KEY"] [:first_name "varchar(32)"] [:surname "varchar(32)"] [:height :smallint] [:weight :smallint]] {:entities clojure.string/lower-case})) -
观察到
clojure.java.jdbcAPI,我们可能会直观地期望可以使用jdbc/execute!函数执行这些 DDL 语句。我们可以(这种方法适用于创建单个表),然而,如果我们希望创建多个表,则可以使用db-do-commands,它接受一个或多个要在事务中执行的命令语句:user=> (jdbc/db-do-commands db [create-app-user-ddl create-activity-ddl]) => (0 0)注意
重新运行前面的命令会导致错误,指示表已存在。
输出如下:
![图 13.4:执行错误]()
图 13.4:执行错误
应该指出,通常使用 CREATE TABLE IF NOT EXISTS 就足够避免这种情况。然而,Apache Derby 不支持此语法。避免此错误将涉及编写自定义代码来完成以下操作:
-
在执行创建表之前,尝试从该表执行
SELECT语句并检测到table does not exist错误。 -
尝试创建并优雅地处理
table already exists错误消息。 -
利用迁移库,例如
Migratus,来跟踪我们到目前为止已应用的架构更新,并在引入新更改时自动应用这些更改。
总结来说,我们现在可以使用 DDL 定义我们的数据库架构,并将此架构应用到我们的数据库中,以便进行数据摄入。
管理我们的数据
在处理持久化存储以及与之交互的服务时,我们通常会遇到 clojure.java.jdbc 提供了一个直接映射到每个这些操作的 API,正如我们期望任何优秀的数据库库所做的那样。
以下命令描述了每个 CRUD 操作以及在使用 clojure.java.jdbc 时应使用的适当 API 调用。请注意,表、列和值是任意的,仅用于展示调用格式。
对于在 example 表中创建条目,我们将 col1 设置为数值 42,将 col2 设置为字符串值 "123":
(jdbc/insert! db-spec :example {:col1 42 :col2 "123"})
我们可以读取或提取示例表中 id 值为 13 的条目:
(jdbc/query db-spec ["SELECT * FROM example WHERE id = ?" 13])
要更新 ID 为 13 的行,我们将 col1 设置为数值 77,将 col2 设置为字符串值 "456":
(jdbc/update! db-spec :example {:col1 77 :col2 "456"} ["id = ?" 13])
从示例表中删除或移除 ID 为 13 的条目:
(jdbc/delete! db-spec :example ["id = ?" 13])
应该指出,这些函数具有多种参数形式,其中可选的最后一个参数是一个 SQL 选项的映射。随着我们逐一介绍每个函数,我们将涵盖这些选项。
值得注意的是,三个 API 调用后面都有一个 ! 后缀。这通常(但不总是!)用来表示函数本身将执行副作用。在函数式编程的世界里,这可以被认为足够重要,值得额外注意。
插入、更新和删除确实会导致副作用——以某种方式改变持久存储。相比之下,查询只是一个简单的读取函数,除了数据检索外不会有任何影响。
插入数据
我们首先来谈谈 CRUD 中的 C。确实,在我们数据库中填充了一些数据之前,我们无法对数据库进行任何有趣的操作。
回想一下我们两个数据库表,app_user 和 activity,以及它们之间的关系。我们的外键引用规定,一个活动不能在没有先存在一个 app_user 的情况下存在。因此,创建一个活动必须引用 app_user 中的一个实体。
插入单行
jdbc/insert! 运行在两种模式下,可以接受一个列值对的映射,或者接受一个列的向量和值的向量。让我们来探讨这两种模式之间的区别。
首先,使用映射模式,我们的 insert 命令及其相关的返回值结构将是:
user=> (jdbc/insert!
<db-spec>
table name keyword>
{<column_name> <column_value>
..})
({:1 1M})
向量模式中的等效操作如下:
user=> (jdbc/insert!
<db-spec>
<table name keyword>
[<column name> ..]
[<column value> ..])
(1)
注意
有可能省略描述列名的向量。这将要求我们使用表创建时的列顺序来插入值。然而,当 Apache Derby 中有一个或多个列是自动生成的时候,这是不可能的。
尽管在数据库中创建的行在术语上完全等效,但你会注意到返回值是不同的。
在第一种情况下,我们返回一个包含一个映射的单元素序列。与 :1 关键字关联的值是我们刚刚插入的行中生成的并持久化的 ID。这很方便;我们可以用它来持久化任何需要此作为外键的表中的进一步行。如果没有自动返回生成的 ID,我们就需要提交一个单独的查询到数据库去检索它。
在第二种情况下,我们再次得到一个包含一个整数的单元素序列——这次包含一个整数。可能会诱使我们假设这个整数对应于一个生成的 ID;这是不正确的——这个整数实际上告诉我们受影响的行数。jdbc/insert! 只支持插入单行;这个整数始终是 1,因此几乎没有用处。
这里应该注意几个重要点。所谓的“生成 ID”或“生成键”格式是 RDBMS 特定的。如果我们换成 MySQL,我们会发现我们的返回值会是以下形式:
({:generated_key 1})
因此,我们应该小心处理这些返回值,并意识到数据库的改变可能会导致代码损坏。
({:1 1M}) 返回值看起来有点奇怪。关键字化的整数是完全有效的——只是不太常见。Apache Derby 显然没有 Clojure 关键字的概念;clojure.java.jdbc(很有帮助地)默认关键字化我们的返回键。
这很自然地引出了我们可以传递给任何 CRUD API 调用的选项,即:
-
keywordize?(布尔值,默认为true) -
标识符(函数,默认为
identity)
如果我们愿意接收原始的键,那么我们可以关闭关键字化:
user=> (jdbc/insert! db :app_user {:first_name "Andre" :surname "Agassi" :height 180 :weight 80} {:keywordize? false})
({"1" 1M})
插入多行
insert-multi!(与 insert! 类似)在两种不同的模式下工作。它接受映射集合或向量集合。调用任一的结果总体上是相同的,但有一些关键的区别需要你注意。
我们已经讨论了“生成的 ID”(在处理映射时)与“影响的行数”(在处理向量时)的返回值。这在处理 insert-multi! 时同样适用。
此外,我们还应该意识到,在向量模式下进行多次插入将执行批处理事务。当进行大量插入时,这更高效。
以下代码演示了在映射和向量模式下的 insert-multi!:
user=> (jdbc/insert-multi!
<db-spec>
<table name keyword>
[{<column name> <column value> ..}
{<column name> <column value> ..}])
({:1 1M} {:1 2M})
(jdbc/insert-multi!
<db-spec>
<table name keyword>
[<column name> ..]
[[<column value> ..] [<column value> ..]])
=> (1 1)
再次提醒,注意返回值表明我们已经为插入的行分配了 ID 1 和 2。
我们可以使用映射或向量模式插入部分记录。在映射模式下工作,我们只需省略任何不需要的键值对。在向量模式下,我们必须指定列名,然后根据需要插入 nil 值。
练习 13.04:数据插入
让我们从创建我们定义的每个表中的条目开始,确保我们尊重外键约束:
-
使用以下任一函数调用将我们 1990 年代的最佳网球运动员作为
app_user插入。我们建议选择其中之一,以避免数据中的虚假重复:user=> (jdbc/insert! db :app_user {:first_name "Andre" :surname "Agassi" :height 180 :weight 80}) ({:1 1M}) user=> (jdbc/insert! db :app_user [:first_name :surname :height :weight] ["Andre" "Agassi" 180 80]) (1)注意
当考虑数据库的现有状态时,我们生成的键的
1M值可能会有所不同,因为它代表下一个唯一的整数值。 -
我们已成功插入第一条记录。现在让我们创建几个活动并将它们与我们的
app_userAndre 关联。这将允许我们练习jdbc/insert-multi!API 调用:user=> (jdbc/insert-multi! db :activity [{:activity_type "run" :distance 8.67 :duration 2520 :user_id 1} {:activity_type "cycle" :distance 17.68 :duration 2703 :user_id 1}]) ({:1 1M} {:1 2M})
在插入数据时,有几个考虑因素需要记住。如果插入单行,使用 insert!。如果插入多行,使用 insert-multi!。如果我们对生成的键感兴趣,那么我们应该优先考虑映射插入模式。另一方面,如果性能至关重要,我们可能更倾向于向量插入模式。当插入完整和部分行混合时,读者可以使用他们个人的映射与向量模式偏好。
查询数据
为了查看我们迄今为止在数据库中持久化的内容,我们将考虑 db-spec 定义以及包含 SQL 字符串的向量。
练习 13.05:查询我们的数据库
在这里,我们将介绍我们在查询之前练习中插入的数据所拥有的各种选项:
-
要找到我们在
app_user和活动表中的内容,以下就足够了:user=> (jdbc/query db ["select * from app_user"]) ({:id 1, :first_name "Andre", :surname "Agassi", :height 180, :weight 80}) user=> (jdbc/query db ["select * from activity"]) ({:id 1, :activity_type "run", :distance 8.67M, :duration 2520, :user_id 1} {:id 2, :activity_type "cycle", :distance 17.68M, :duration 2703, :user_id 1})如在管理我们的数据介绍中所述,
jdbc/query有一个 3 元组定义,接受一个选项映射。由于我们没有提供那个(我们在这里使用了 2 元组版本),所以我们接受了所有默认选项。现在我们将探索这些选项以及它们如何用于操作结果集。考虑到前面的返回值。默认情况下,我们返回一个由映射组成的序列,其中键是小写关键字。
在支持的所有选项中,我们将首先介绍的前三个选项是微不足道的,允许我们控制返回键的格式,它们是
keywordize?、identifiers和qualifier。 -
将
keywordize?设置为false会给我们字符串键。可以通过提供一个单参数函数来覆盖标识符,该函数将键转换为我们的选择格式。例如,使用这些选项(和clojure.string)可以检索键为上字符串的数据:{:keywordize? false :identifiers str/upper-case} user=> (require '[clojure.string :as str]) => nil user=> (jdbc/query db ["select * from app_user"] {:keywordize? false :identifiers str/upper-case}) ({"ID" 1, "FIRST_NAME" "Andre", "SURNAME" "Agassi", "HEIGHT" 180, "WEIGHT" 80})qualifier仅在keywordize?为真(默认值)时才有效,并允许我们为我们的关键字指定一个命名空间。然后我们的键以以下形式返回::<qualifier>/<column name> user=> (jdbc/query db ["select * from app_user"] {:identifiers str/upper-case :qualifier "app_user"}) (#:app_user{:ID 1, :FIRST_NAME "Andre", :SURNAME "Agassi", :HEIGHT 180, :WEIGHT 80}) -
对于那些不熟悉前面格式的读者,这表示我们有一个地图,其中我们的关键字命名空间是同质的(每个键都有相同的
app_user命名空间)。更具体地说,我们可以从那个查询中看到单个键:user=> (-> (jdbc/query db ["select * from app_user"] {:identifiers str/upper-case :qualifier "app_user"}) first keys) => (:app_user/ID :app_user/FIRST_NAME :app_user/SURNAME :app_user/HEIGHT :app_user/WEIGHT) -
同样,我们可以使用(映射的)序列或(向量的)序列插入数据。我们也可以在我们的查询中控制映射与向量结果格式。在前面的代码中,我们看到映射是默认的,可以通过传递以下选项映射来改变这一点:
{:as-arrays? true}输出如下:
user=> (jdbc/query db ["select * from activity"] {:as-arrays? true}) [[:id :activity_type :distance :duration :user_id] [1 "run" 8.67M 2520 1] [2 "cycle" 17.68M 2703 1]]
回想一下我们第一次与 CSV 文件交互的章节,你可能认出这和用于从 CSV 文件读取或写入的数据结构相同;也就是说,一个向量的序列,其中第一个向量对应于文件的列,后续的向量是文件的数据条目。
操作查询返回值
除了操作返回值的格式之外,还有两个额外的选项,它们使我们能够完全控制查询返回的每一行,或者控制整个结果集。我们通过:row-fn或:result-set-fn选项提供这些;如果需要,它们可以组合使用。
row-fn选项应该是一个单参数函数,其中唯一的参数是当前行的映射表示。同样,result-set-fn选项也应该是一个单参数函数,其中唯一的参数是表示整个查询结果的映射序列。考虑以下示例。
(defn custom-row-fn [row]
)
(defn custom-result-set-fn [result-set]
)
我们的功能可以做什么没有限制,除了返回一个与输入相同的数据结构中的值。
应该注意的是,你传递的result-set-fn选项不应该是惰性的;否则,在函数完成之前,连接可能会被关闭。"reduce"(或底层调用reduce的函数)在这里是一个不错的选择。
练习 13.06:使用自定义函数控制结果
row-fn适用的例子包括执行一些在原始 SQL 中难以实现的计算或聚合,格式化值,以及用计算值丰富每一行。
结合这三个用例,让我们考虑我们的活动表,特别是持续时间列。这是一个整数值,表示所讨论活动的秒数。为了显示或向用户报告,我们可能发现以小时、分钟和秒来引用它更友好:
-
定义一个
row-fn,其中唯一的参数是行的映射表示,一般来说,行应该被返回,并应用任何操作:(defn add-user-friendly-duration [{:keys [duration] :as row}] (let [quot-rem (juxt quot rem) [hours remainder] (quot-rem duration (* 60 60)) [minutes seconds] (quot-rem remainder 60)] (assoc row :friendly-duration (cond-> "" (pos? hours) (str hours "h ") (pos? minutes) (str minutes "m ") (pos? seconds) (str seconds "s") :always str/trim)))) #'user/add-user-friendly-duration -
现在将其添加到我们的 SQL 选项映射中,并重新运行练习 13.05,查询我们的数据库:
user=> (jdbc/query db ["select * from activity"] {:row-fn add-user-friendly-duration}) ({:id 1, :activity_type "run", :distance 8.67M, :duration 2520, :user_id 1, :friendly-duration "42m"} {:id 2, :activity_type "cycle", :distance 17.68M, :duration 2703, :user_id 1, :friendly-duration "45m 3s"}) -
现在我们来计算数据库中所有活动所经过的总距离。这可以通过原始 SQL 轻易实现,但无论如何,这将给我们一个探索替代方案的机会。
我们将定义我们的函数如下:
(fn [result-set] (reduce (fn [total-distance {:keys [distance]}] (+ total-distance distance)) 0 result-set)) -
当与我们的查询一起使用时,我们可以预测性地检索一个表示所有活动总距离的单个数字:
user=> (jdbc/query db ["select * from activity"] {:result-set-fn (fn [result-set] (reduce (fn [total-distance {:keys [distance]}] (+ total-distance distance)) 0 result-set))}) 26.35M -
为了展示
row-fn和result-set-fn如何协同工作,我们可以使用一个row-fn来提取距离,然后使用一个更简单的result-set-fn来汇总这些数字,如下所示:(jdbc/query db ["select * from activity"] {:row-fn :distance :result-set-fn #(apply + %)}) => 26.35M
通过这两个最后的练习,我们看到clojure.java.jdbc在 API 调用中直接给我们提供了对查询结果的完全控制。无论我们的要求如何,我们都可以利用jdbc/query函数的选项来实现我们想要的结果,并使用自定义格式。
更新和删除数据
最后,我们来到了 CRUD 中的U和D;更新和删除。这些操作比前两个简单,可以更简洁地描述。
当我们建模的世界状态发生变化时,我们希望在我们的持久化数据中反映这一点。在我们执行更新之前,我们确定以下内容:
-
受影响的表(s)
-
我们希望设置的新值
-
我们想要具有那些值的 数据子集
jdbc/update!的签名给我们提供了以下提示,如果我们还没有意识到的话:
(update! db table set-map where-clause opts)
练习 13.07:更新和删除现有数据
假设我们发现安德烈·阿加西减重了 2 公斤。我们可以推断我们将更新app_user表,将体重设置为 78 公斤,其中名字和姓氏是Andre和Agassi(或者 ID 是1;我们可能从之前的查询中获得了这个信息):
-
构建以下
update!函数调用:user=> (jdbc/update! db :app_user {:weight 78} ["first_name = 'Andre' and surname = 'Agassi'"]) => (1)注意
更新(和删除)在成功时,只会返回受影响的行数。
-
如果我们查询
app_user表,我们预计这个新事实将被持久化:user => (jdbc/query db ["select * from app_user"]) => ({:id 1, :first_name "Andre", :surname "Agassi", :height 180, :weight 78}) -
现在,我们可以想象一个情况,即
Agassi从我们的活动跟踪服务中删除了他的账户并要求删除他的数据。我们有一个如下函数签名:(delete! db table where-clause opts) -
我们可以构建一个函数调用,该调用将从我们的数据库中删除
Agassi及其所有活动:user=> (jdbc/delete! db :app_user ["first_name = 'Andre' and surname = 'Agassi'"]) => [1]有趣的是,受影响的行数报告为
1。由于我们设置了ON DELETE CASCADE选项,我们预计安德烈的所有活动也将被删除。让我们验证这一点是否确实如此:user=> (jdbc/query db ["select * from activity"]) => ()
如我们所见,安德烈的活动已被删除。因此,我们可以得出结论,受影响的行将始终对应于从目标表中删除的行。
应用层的介绍
到目前为止,我们一直在创建临时函数,在 REPL 中测试它们,偶尔创建一个或两个命名空间将它们组合在一起。我们可以将应用层视为将所有这些命名空间和函数组合成一个具有相关 API 的工作、连贯的应用程序。本质上,我们在这个步骤中设计应用程序的后端。然后我们将学习如何在下一章中通过 REST 公开该 API;在设计我们的应用程序时牢记这一点将很有用。
在设计我们的应用层时,退一步考虑我们的需求是有意义的。如果我们考虑活动跟踪应用程序,我们可能现实地有以下高级需求:
-
创建一个新用户。
-
为指定用户创建一个活动。
-
查询用户和活动。
-
在单个用户(即按活动或时间段)上运行报告。
实现前面的要求将给我们一个功能性的(尽管有限)应用程序,用户可以开始与之交互以跟踪活动和衡量他们的健康。
由于我们已经在我们学习如何与数据库交互的过程中演示了大部分功能,我们可以利用我们已编写的大部分代码,随着我们的进行使其更加通用。
我们可以根据我们自己对逻辑分割的个人观点以多种不同的方式构建前面的应用程序,这种分割最有意义。我们可以在设计阶段花费数小时,在编写任何代码之前确定确切的项目结构;然而,我们更愿意提出一个起始结构,开始充实它,并采取敏捷/演化的方法来开发这个简单的应用程序。
练习 13.08:定义应用层
我们将在这里创建我们的后端/应用层;定义我们的命名空间并公开适当的 API。
看看我们的需求,我建议以下命名空间:
-
schema:我们的数据模型 -
ingest:单个用户和活动摄取 -
query:针对用户和活动的通用查询,以及更复杂的报告查询
再次记住,我们理想情况下会在其上叠加一个 REST 服务,想象一个顶层 web 或 api 命名空间,它将与前面的命名空间以及公共函数进行交互。
在进行此练习之前,请将以下内容添加到您的 deps.edn 文件或类似文件中:
{:deps {..
semantic-csv {:mvn/version "0.2.1-alpha1"}
org.clojure/data.csv {:mvn/version "0.1.4"}}
从一个干净的数据库开始,我们将首先定义我们的 ns 模式,包含我们的 DDL 定义,我们将对其进行略微扩展以支持我们的报告需求。值得注意的是,我们向活动表添加了一个 activity_date 字段,使我们能够报告跨时间段的各项活动:
-
定义我们的命名空间,包括我们的
jdbc和hikari需求:(ns packt-clj.fitness.schema (:refer-clojure :exclude [load]) (:require [clojure.java.jdbc :as jdbc] [hikari-cp.core :as hikari]))注意前一段代码中使用了
(:refer-clojure :exclude [load])。这不是必需的,但将在我们定义自己的load函数时抑制一个警告,即我们正在替换clojure.core中的一个函数。如果没有这一行,我们会遇到以下警告:WARNING: load already refers to: #'clojure.core/load in namespace: packt-clj.fitness.schema, being replaced by: #'packt-clj.fitness.schema/load -
现在,定义我们的
jdbc-url参数并创建一个hikari连接池数据源。这个db变量将在整个练习中引用和使用,无论何时我们加载我们的模式、插入行或从我们的数据库查询行:(def ^:private jdbc-url "jdbc:derby:derby-local;create=true") (def db {:datasource (hikari/make-datasource {:jdbc-url jdbc-url})}) -
现在,我们将创建我们的
app_user和activityDDL:(def ^:private create-app-user-ddl "CREATE TABLE app_user ( id int GENERATED ALWAYS AS IDENTITY CONSTRAINT USER_ID_PK PRIMARY KEY, first_name varchar(32), surname varchar(32), height smallint, weight smallint)") (def ^:private create-activity-ddl "CREATE TABLE activity ( id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, activity_type VARCHAR(32), distance DECIMAL(7,2), duration INT, activity_date DATE, user_id INT REFERENCES app_user ON DELETE CASCADE)") -
最后,我们将所有这些内容整合到一个
load函数中,该函数将我们的数据库模式(即我们的 DDL)应用到由我们的 JDBC URL 指定的数据库中,通过连接池实现:(defn load [] (jdbc/db-do-commands db [create-app-user-ddl create-activity-ddl]))注意,我们的一些变量被定义为私有的,这意味着在模式命名空间之外引用它们不是必需的(或允许的)。我们通过调用公共
load函数间接引用它们。请注意,schema/load是这个ns中构成我们的公共 API 的唯一函数。db变量是公共的,我们预计在执行任何查询或摄取数据时都会引用它。 -
现在是我们的摄取代码,我们将允许创建单个用户和活动:
(ns packt-clj.fitness.ingest (:require [clojure.java.jdbc :as jdbc])) (defn user [db app_user] (first (jdbc/insert! db :app_user app_user))) (defn activity [db activity] (first (jdbc/insert! db :activity activity)))注意
jdbc/insert!返回一个单元素序列。因此,我们可以对每个insert语句的结果调用first,以节省调用者的工作量。 -
这是我们的查询代码,我们将稍微扩展我们之前所写的:
(ns packt-clj.fitness.query (:require [clojure.java.jdbc :as jdbc])) (defn all-users [db] (jdbc/query db ["select * from app_user"])) (defn user [db user-id] (jdbc/query db [(str "select * from app_user where id = " user-id)])) -
如下扩展
all-activities函数:(defn all-activities [db] (jdbc/query db ["select * from activity"])) (defn activity [db activity-id] (jdbc/query db [(str "select * from activity where id = " activity-id)])) (defn activities-by-user [db user-id] (jdbc/query db [(str "select * from activity where user_id = " user-id)])) -
现在,让我们将更高级的查询定义添加到
query命名空间中。我们将引入medley和java-time依赖项到我们的项目中,并在创建一个确定最活跃用户的函数之前在ns查询中引入它们:{:deps {.. clojure.java-time {:mvn/version "0.3.2"} medley {:mvn/version "1.2.0"}} (ns packt-clj.fitness.query (:require [clojure.java.jdbc :as jdbc] [java-time :as t] [medley.core :as medley]))medley是一个第三方便利库,它提供了通常需要使用clojure.core组件构建的常用函数: -
most-active-user函数看起来如下:(defn most-active-user [db] (jdbc/query db ["select au.first_name, au.surname, a.duration from app_user au, activity a where au.id = a.user_id "] {:row-fn (fn [{:keys [first_name surname duration]}] {:name (str first_name " " surname) :duration duration}) :result-set-fn (fn [rs] (->> rs (group-by :name) (medley/map-vals #(apply + (map :duration %))) (sort-by val) last))})) -
最后,我们将创建一个函数,该函数将按月计算我们的单个用户活动报告:
query.clj 41 (defn monthly-activity-by-user 42 [db user-id] 43 (jdbc/query 44 db 45 [(str "select au.first_name, au.surname, a.duration, a.activity_type, a.distance, a.activity_date from app_user au, activity a where au.id = a.user_id and a.user_id = " 1)] 46 {:row-fn (fn [row] (update row :activity_date t/local-date)) 47 :result-set-fn (fn [rs] 48 (reduce 49 (fn [acc {:keys [activity_date activity_type distance duration first_name surname] :as row}] 50 (let [month-year (t/as activity_date :month-of-year :year)] The complete code can be referred at: https://packt.live/37G4naC -
现在我们已经定义了应用程序层,我们可以开始与我们在每个命名空间中公开的函数进行交互。我们应该看到它们以直观的方式读取和返回结果。为了使用我们的 API,我们需要要求和别名每个命名空间:
(require '[packt-clj.fitness.ingest :as ingest] '[packt-clj.fitness.schema :as schema] '[packt-clj.fitness.query :as query]) -
我们必须将我们的模式加载到我们的空数据库中,注意在删除其父表之前先删除任何子表:
user=> (jdbc/execute! schema/db ["drop table activity"]) [0] user=> (jdbc/execute! schema/db ["drop table app_user"]) [0] user=> (schema/load) (0 0) -
现在,让我们定义一些用户并将它们持久化到数据库中:
user=> (def users [{:first_name "Andre" :surname "Agassi" :height 180 :weight 80} {:first_name "Pete" :surname "Sampras" height 185 :weight 77 } {:first_name "Steffi" surname "Graff" :height 176 :weight 64}]) #'user/users user=> (doseq [user users] (ingest/user schema/db user)) nil注意我们在前面的代码中使用
doseq。doseq可以在我们不感兴趣于结果时(就像我们使用map时那样)用来遍历一个集合。由于我们纯粹是为了副作用而遍历一个集合,我们不能对这个操作过于懒惰,因为没有保证我们会持久化每个用户。熟悉
sample-activities.csv文件,它包含了我们刚刚持久化的三个用户中的 20 个随机生成的活动。注意文件的结构并不完美地映射到我们的模式,并考虑我们可以用来将 CSV 文件解析成我们的ingest/activity函数支持的格式的潜在方法。一种方法是为键定义一个映射,这些键正好符合我们的模式要求。如果值是单参数函数,可以从给定行中提取相关数据,我们可以依次应用这些函数,生成一个符合我们模式的映射。
-
按照以下方式定义访问器映射:
user=> (def accessors {:activity_type :type :distance :distance_metres :duration :duration_seconds :user_id :userid :activity_date (fn [{:keys [day month year]}] (str year "-" month "-" day))}) user=> #'user/accessors注意除了
activity_date访问器之外的所有访问器都在执行一个简单的重命名操作。activity_date执行的是一个(非常轻微的!)更复杂的操作,从一行中提取多个字段并将它们组合成一个。我们可以想象扩展这个功能以执行任意复杂的解析和数据提取。 -
需要
apply-accessors函数来实际取一个行、一个访问器映射,并返回一个符合模式的映射:user=> (defn apply-accessors [row accessors] (reduce-kv (fn [acc target-key accessor] (assoc acc target-key (accessor row))) {} accessors)) => #'user/apply-accessorsreduce-kv可以用来遍历我们的访问器映射中的键值对。 -
在定义了我们的访问器和
apply-accessors函数之后,我们现在可以读取我们的 CSV 文件并将其解析成与我们的活动表模式相匹配的形式:user=> (require '[semantic-csv.core :as sc] '[clojure.data.csv :as csv] '[clojure.java.io :as io]) => nil user=> (def activities (->> (csv/read-csv (io/reader "resources/sample-activities.csv")) sc/mappify (map #(apply-accessors % accessors)))) user=> #'user/activities检查我们的第一个条目,我们看到它确实看起来像我们预期的那样:
user=> (first activities) => {:activity_type "swim", :distance "5100.00", :duration "9180", :user_id "1", :activity_date "2019-01-22"} -
我们现在可以用与持久化用户相同的方式持久化这些活动:
user=> (doseq [activity activities] (ingest/activity schema/db activity)) => nil -
最后,让我们依次练习我们的查询并验证结果:
user=> (count (query/all-users schema/db)) => 3 user=> (count (query/all-activities schema/db)) => 60 user=> (query/user schema/db 1) => ({:id 1, :first_name "Andre", :surname "Agassi", :height 180, :weight 80}) user=> (query/activity schema/db 1) => ({:id 1, :activity_type "swim", :distance 5100.00M, :duration 9180, :activity_date #inst"2019-01-22T00:00:00.000-00:00", :user_id 1}) user=> (count (query/activities-by-user schema/db 1)) => 20 user=> (query/most-active-user schema/db) => ["Pete Sampras" 136680] user=> (clojure.pprint/pprint (query/monthly-activity-by-user schema/db 3))输出将如下所示:
![图 13.5:验证结果时的输出]()
图 13.5:验证结果时的输出
我们现在已经创建了应用程序的后端,逻辑上分离了应用程序包含的各种功能。我们创建了一个数据库,加载了我们的模式,然后摄取和检索了数据。这展示了典型的应用程序生命周期,并且我们希望我们能想象出一个 REST 服务或移动应用坐在这个我们构建的 API 之上。
活动第 13.01:持久化历史网球结果和 ELO 计算
基于你对历史网球结果和 ELO 计算的经验,你被网球分析有限公司雇佣。他们有一个难以处理的大的 CSV 文件;他们希望将竞争对手数据建模并持久化到数据库中。一旦导入,他们希望在整个数据集上执行 ELO 计算,同时保持完整的 ELO 历史记录。最终目标是找到历史数据集中 ELO 评分最高的竞争对手。
这些步骤将帮助你完成活动:
-
将所需的依赖项添加到你的
deps.edn文件或等效文件中。 -
创建
packt-clj.tennis.database、packt-clj.tennis.elo、packt-clj.tennis.ingest、packt-clj.tennis.parse和packt-clj.tennis.query命名空间。 -
在
database命名空间中,定义一个连接池,引用磁盘上的新tennis数据库,并将其存储在db变量中。 -
使用 DDL 定义一个包含
id(由 CSV 文件提供)和full_name字段的数据库player表。 -
定义一个包含
id(可以从 CSV 文件构建复合 ID)、tournament_year、tournament、tournament_order、round_order、match_order和winner_id(外键引用步骤 4中的球员 ID)字段的tennis_match表。 -
定义一个包含
id(可以自动生成)、player_id(外键引用步骤 4中的球员 ID)和rating字段的elo表。 -
创建(并执行)一个
load函数,该函数将应用步骤 4-6中的 DDL 到步骤 3中定义的数据库。在
parse命名空间中,定义一个historic函数,该函数接受一个表示本地磁盘上文件路径的字符串。此函数应从磁盘读取 CSV 文件;将文件转换为映射序列;依次遍历每一行;提取与我们的players和matches数据结构相关的字段(字段可能不会完全按原样提取;也就是说,可能需要一些额外的解析或格式化);并构建数据结构,最终返回以下形式的映射:{:players [<player 1> ...] :matches [<match 1> ...]}在这里,
players和matches是符合我们创建的模式的映射。应根据需要定义辅助函数。一些可能有助于实现的功能包括将行解析为
winning-player、losing-player和match列,以及定义将target-key函数映射到前述结构中每个结构的row-extraction函数。注意
我们应谨慎避免多次定义唯一的球员。我们还应意识到 CSV 文件中的
match_id列不是唯一的!应构建一个适当的复合键。 -
在
ingest命名空间中,定义一个historic函数,该函数接受一个db-spec定义和一个表示本地磁盘上路径/文件名的字符串。此函数应将文件路径传递到第 8 步中定义的函数,解构玩家和比赛,然后依次对每个执行insert-multi!。注意
确保满足外键约束,必须在比赛之前摄取玩家。
-
将
match_scores_1991-2016_unindexed_csv.csvCSV 文件复制到resources目录中,然后使用第 8 步中的historic函数从该文件摄取所有player和match数据。注意
match_scores_1991-2016_unindexed_csv.csvCSV 文件可以在 GitHub 上找到:packt.live/30291NO。 -
现在我们已经摄取了数据,我们希望计算所有历史比赛的 ELO 值,在计算过程中存储 ELO 评级。在
query命名空间中,定义一个all-tennis-matches函数,该函数接受db-spec并返回tennis_match表的内容。应该按tournament_year、tournament_order、reverse round_order和match_order适当排序,以确保我们按时间顺序计算评级。 -
现在,我们将利用我们在第五章“多对一:减少”中遇到的两个函数,即
match-probability和recalculate-rating。将这些引入到elo命名空间中。 -
在
elo命名空间中,定义一个新的calculate-all函数,该函数接受db-spec并使用query/all-tennis-matches检索所有网球比赛(按时间顺序排列,如第 10 步所述),然后遍历此数据集,计算每场比赛的 ELO 评级,返回一个符合elo表模式的 ELO 评级集合。 -
定义一个简单的函数,该函数接受
calculate-all函数调用的结果并将其持久化到elo表中。调用此函数以持久化我们的 ELO 计算。 -
最后,在
query命名空间中定义一个select-max-elo函数(我们感兴趣的是具有最高 ELO 评级的玩家),该函数返回以下形式的结果:{:max-rating … :player-name …}执行此操作后,我们应该看到一个熟悉的名字!
注意
此活动的解决方案可以在第 745 页找到。
摘要
这一章介绍了 Apache Derby RDBMS,创建了一个具有最小设置的本地托管实例。然后我们探讨了数据模型以及如何使用 DDL 将其编码为模式。我们使用 clojure.java.jdbc 在调查 API 允许我们执行 CRUD 操作之前加载此模式,并花费时间了解如何控制查询执行的结果。
然后,我们为我们的 ELO 计算应用程序构建了一个应用层。在这个过程中,我们学习了哪些函数应该作为我们 API 的一部分公开,哪些是应用程序内部的,应该对用户保密。
在下一章中,我们将探讨我们应用层的公共 API,学习如何构建一个基于 REST 的 Web 服务,以便通过 HTTP 公开该 API。通过这种方式,我们可以从 REST 客户端发起调用,并通过网络与我们的应用程序交互,而不是通过本地托管的 REPL。
随后,我们将通过添加一个更高层次的 UI 层来改进这种 RESTful 交互,这样用户就可以通过网页浏览器与我们的服务进行交互。
第十四章:14. 使用 Ring 的 HTTP
概述
在本章中,我们将处理请求并生成响应,通过中间件路由传入的请求并操作请求。我们还将使用各种内容类型(包括 JavaScript 对象表示法(JSON)和 可扩展数据表示法(EDN))提供响应,使用 Ring 和 Compojure 创建一个网络应用程序,并通过 HTTP 提供静态资源。
到本章结束时,您将能够通过 HTTP 公开 CRUD 操作。
简介
在上一章中,我们构建了应用程序层并通过 REPL 与其交互。这对于单个用户执行临时交互来说足够好了,但它无法扩展。实际上,我们可以想象一个场景,第三方甚至我们自己的服务想要使用我们数据库中存储的数据,执行计算,并持久化更新。这种交互将是程序性的,因此从 超文本传输协议(HTTP)或类似协议公开将是有益的。
我们可以通过通过网络服务公开我们的应用程序层来实现这一点。网络服务允许通过网络(通常是互联网,尽管它也可以是私有应用程序的内部网络)与我们的应用程序层进行交互。
要构建我们的网络服务,我们需要一个网络应用程序库来构建我们的 API,一个网络服务器来通过 HTTP 提供它,以及一个路由库来将传入的请求路由到适当的处理程序。Clojure 有许多每种类型的实现;然而,对于本章,我们将专注于使用 Compojure 进行路由,并使用 Jetty 提供所有服务。
本章的扩展可以包括您使用提供的示例和练习,并使用替代的网页应用库,例如 Pedestal 来实现它们。
HTTP、Web 服务器和 REST
在我们深入构建网络服务之前,让我们先了解基础知识。HTTP 是互联网通信的主要协议之一,尤其是在使用网页浏览器进行工作时。该协议为客户端(通常是网页浏览器)与服务器通信提供了一个合同。在这个例子中,浏览器将构建一个包含统一资源标识符(URI)的请求,并将其用于与服务器通信。服务器将解释请求,使用 URI 字符串来确定客户端感兴趣检索/操作的资源,然后构建一个包含指示请求已完成的响应信息或包含响应体形式的负载的响应。
通常,在构建网络服务时,我们希望遵循 表征状态转移(REST)架构。该架构规定了一系列我们可以选择对资源执行的操作,使我们能够将资源通过多个有效状态进行转换。
例如,假设我们正在与我们的最喜欢的在线零售商网站上的个人资料进行交互。首先,我们将检索我们的个人资料,然后可能会检索我们的当前地址。我们将更改此地址,然后保存更改。在 HTTP 上 REST 交互的术语中,这可能看起来如下:
上一示例中的 123 是我们的唯一用户 ID。
在 URI 前面的 GET/PUT 方法被称为 HTTP 方法。GET 表示我们希望读取 URI 提供的资源相关的内容。PUT 方法有一个包含更新地址的关联体;我们指示服务器使用提供的地址创建/更新地址资源。
在 PUT 和 POST 之间需要区分的一个重要区别是,当更新现有资源或我们偶然知道我们正在创建的实体的唯一 ID 时,应使用 PUT。POST 仅用于资源的创建,并且不需要我们知道其唯一 ID。相反,此 ID 将由网络服务本身分配,并通过响应头传达给客户端。
支持的全部方法包括 GET、POST、PUT、DELETE、HEAD、PATCH、CONNECT、OPTIONS 和 TRACE。其中前四种是最常见的。
请求和响应
对于那些完全不了解构建网络服务的人来说,我们将介绍许多新概念。这些概念不一定复杂,但将提供足够的细节来理解每个构建块。如前所述,我们将使用 Ring(Clojure 最广泛使用的网络应用程序库)来构建我们的网络服务。一个 Ring 应用程序仅由四个组件组成:请求、处理器、响应和中间件。
我们理解请求和响应的概念;现在我们将详细讲解它们,包括如何解析前者以及如何构建后者,以及它们通常采取的形式。
用最简单的话说,我们网络服务的功能应该是接收一个表示为映射的传入请求,根据该映射的内容执行一些操作(例如,获取用户的个人资料或更新他们的地址),并生成一个适当的响应映射,以便浏览器渲染或客户端更广泛地解释。在 Ring 中,执行这种请求到响应转换的功能被称为处理器。
最基本操作将遵循以下流程:


图 14.1:请求-响应过程的表示
因此,Ring 处理器是一个单参数函数,接受一个 request 映射,并返回一个 response 映射:
(defn handler [request]
..
<response-map>)
request 映射看起来像什么?至少,一个 request 映射将包含以下顶级键(包括示例值):
{:remote-addr "0:0:0:0:0:0:0:1",
:headers {"user-agent" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36",
"cookie" "phaseInterval=120000; previewCols=url%20status%20size%20timeline; stats=true",
"connection" "keep-alive",
"accept" "text/html,application/xhtml+xml"},
:server-port 8080,
:uri "/request-demo",
:server-name "localhost",
:query-string nil,
:body #object[org.eclipse.jetty.server.HttpInputOverHTTP 0x4a7d22dd "HttpInputOverHTTP@4a7d22dd[c=0,q=0,[0]=null,s=STREAM]"],
:scheme :http,
:request-method :get}
下面是每个属性的含义:
-
:remote-addr: 调用客户端的 IP 地址 -
:headers: 随请求发送的附加信息,与安全、内容协商、cookie 等相关 -
:server-port: 请求服务器所服务的端口 -
:uri: 指向客户端希望与之交互的资源 URI(用于路由) -
:query-string: 可以用来过滤返回的资源内容的字符串 -
:body: 包含一个可选的有效载荷的流(只能读取一次!) -
:request-method: HTTP 请求方法(用于路由)
一个值得强调的重要点是,这个列表并不全面。第三方和自定义中间件经常可以添加他们自己的键,具有他们自己的特殊含义和用途。
在我们的处理程序被调用的点上,request 映射中只包含几个感兴趣的键:query-string 和 body。其余的键已经检查并用于将请求路由到适当的处理程序,例如。
Response 映射要简单得多,将只包含三个键:状态、标头和正文。status 键是一个三位数,表示请求的结果。这些有很多,分为五个不同的类别:
-
1xx:信息性
-
2xx:成功
-
3xx:重定向
-
4xx:客户端错误
-
5xx:服务器错误
通常,我们会遇到表示 OK 的 200 状态,或者可能是 404 “未找到”或 500 “内部服务器错误”的错误消息。
标头提供了有关生成响应的额外信息——最常见的是如何解释正文。正文(有时称为有效载荷)将包含任何对客户端感兴趣检索或生成的数据。
我们可以轻松手动构建这个响应映射,就像我们在 练习 14.01,创建一个 Hello World 网络应用程序 中将看到的那样。
我们现在已经遇到了 Ring 的四个组件中的三个(中间件更复杂,将在单独的部分中介绍)。
Jetty 是将我们的 Ring 应用程序提供到网络上的网络服务器。它是允许通过网络与我们的应用程序交互的软件,就像您的最喜欢的网站可以通过互联网提供一样。
练习 14.01:创建一个 Hello World 网络应用程序
这个练习将使我们使用 Ring 和 Jetty,我们将创建一个简单的网络应用程序,具有静态的 Hello World 响应:
-
从
deps.edn或类似的依赖项开始:{:deps {ring/ring-core {:mvn/version "1.7.1"} ring/ring-jetty-adapter {:mvn/version "1.7.1"}}} -
现在定义我们的 Ring 处理器,记住它是一个单参数函数,接受一个
request映射并返回一个response映射。我们的response映射包含一个ok200状态码和一个字符串响应在body中:user=> (defn handler [request] {:status 200 :body "Hello World"}) =>#'user/handler -
现在我们将启动一个 Jetty 网络服务器,传递我们的处理程序和一些选项:
user=> (require '[ring.adapter.jetty :refer [run-jetty]]) => nil user=> (def app (run-jetty handler {:port 8080 :join? false})) =>#'user/app默认情况下,Jetty 将在端口 80 上启动;我们已将其覆盖为使用
8080。请注意,我们还传递了:join? false。这确保我们的 Web 服务器不会阻塞 REPL 正在运行的当前线程,这意味着我们可以在同时与之交互并执行其他基于 REPL 的操作。我们现在有一个正在运行的 Web 服务器,通过 HTTP 公开我们的单个处理器。注意
我们可以使用 Java 互操作停止当前运行的服务器:
user=> (.stop app)=> nil如果在任何时候遇到诸如
Address already in use之类的错误,在启动新应用之前,务必停止现有应用。这将在我们修改应用程序并测试它们在浏览器中的效果时非常有用。如果您已停止应用,请确保在继续之前通过重新运行步骤 3来重新启动它。
-
在浏览器中导航到 localhost:8080:![图 14.2:在浏览器中打印 Hello Word
![图片]()
图 14.2:在浏览器中打印 Hello Word
成功!我们已经创建了我们的第一个处理器,我们的 Web 服务器仅用几行代码就启动并运行了。请确保停止我们的运行中的应用程序。
请求路由
在前面的示例中,请注意,run-jetty 只接受单个处理器。我们很可能希望我们的服务能够提供存储、查看以及操纵多个资源的能力。为此,我们需要支持任意数量的处理器来满足我们的每个资源,以及一种找到正确处理器来处理我们的请求的方法。这就是请求路由发挥作用的地方。
重新审视请求对象,我们发现它包含(包括其他内容)以下键:
-
URI
-
请求方法
我们可以增强处理器以检查传入的请求,提取这些键的内容,然后确定调用适当的函数来满足请求并生成响应。我们可能会称这个处理器为调度器或路由器。在考虑这种方法时,我们应该回忆起章节介绍中提到的 Compojure,它将自己定位为 Ring 的一个小型路由库,允许 Web 应用由小型、独立的组件组成。
这正是我们所寻找的。现在我们将学习如何使用Compojure来执行请求路由并进一步扩展我们的应用。
使用 Compojure
如果手动路由请求听起来相当繁琐,那么请松一口气,因为 Compojure 将所有压力都从请求路由中释放出来。我们定义了我们要匹配的 HTTP 方法和资源路径的配对,Compojure 会处理其余部分。
一个简单的路由方法定义如下,使用来自 compojure.core 命名空间的 GET 宏:
(def route
(GET "/hello" request "Hello World"))
GET(以及所有其他 HTTP 方法宏)期望一个表示我们想要匹配的资源路径。在上面的示例中,我们匹配了位于 Web 服务根目录的 hello 路径。Compojure 给了我们很大的控制权,允许我们期望路径参数或使用正则表达式进行匹配,如果需要的话。
宏的下一个参数实际上是将传入的请求映射(或其部分)绑定到我们指定的本地符号。在前面的例子中,整个传入请求映射已经被绑定到 request 符号。如果我们愿意,可以选择解构映射的元素。
宏的最后一个参数是路由的主体。在这里,我们可以添加我们需要的任何逻辑,最终表达式作为响应返回。在这种情况下,我们简单地返回字符串 "Hello world"。Compojure 将普通字符串解释为隐式的 200 响应(即成功),这意味着我们不需要像在 练习 14.01,创建一个 Hello World 网络应用程序 中那样构建一个带有显式 :status 键的映射。
现在我们很可能会在我们的应用程序中有不止一个路由,但 Jetty 只接受一个处理器。我们现在可以求助于 Compojure 提供的 routes 函数或 defroutes 宏;这两个都可以用来将一个或多个路由组合成一个处理器。使用宏,我们将我们的路由组合在一起,将它们绑定到 routes 变量,然后我们可以将其传递给 Jetty:
(defroutes routes
<route-1>
<route-2>
..)
如果用户导航到一个我们没有定义的路由,会怎样?当我们定义路由及其唯一路径时,我们是在要求路由库检查传入的请求,并依次尝试将其与我们的每个路由匹配。如果在找到匹配之前路由列表已经耗尽,则会抛出异常。在浏览器中,我们将收到以下(不是特别有用的!)错误消息:

图 14.3:页面工作错误
我们可以通过提供一个通配符路由来避免这种情况,确保这是定义的 最后一个 路由。Compojure 为我们提供了一个 not-found 路由,我们可以将其纳入我们的路由定义中。它允许我们优雅地处理无法找到匹配资源的请求。包含此 not-found 路由将如下所示:
(require '[compojure.route :as route])
(defroutes routes
<route-1>
<route-2>
(route/not-found "Not the route you are looking for"))
练习 14.02:使用 Compojure 引入路由
让我们从调整我们的 Hello World 应用程序开始,通过替换处理器为 Compojure 路由定义:
-
添加
compojure依赖项:{:deps {compojure {:mvn/version "1.6.1"} ring/ring-core {:mvn/version "1.7.1"} ring/ring-jetty-adapter {:mvn/version "1.7.1"}} -
将我们的原始
hello world处理器转换为compojure路由定义格式,使用compojure.core中的GET宏:(defn handler [request] {:status 200 :body "Hello World"})如果我们使用 Compojure,它将看起来如下:
(require '[compojure.core :refer [GET]]) (def route (GET "/" request "Hello World")) -
在我们的
run-jetty调用中将handler替换为route,启动应用程序(首先停止任何现有应用程序!),然后将您的浏览器指向localhost:8080:(require '[ring.adapter.jetty :refer [run-jetty]]) (defn run [] (run-jetty route {:port 8080 :join? false})) (.stop app) (def app (run))输出如下:
![图 14.4:将输出打印到浏览器]()
图 14.4:将输出打印到浏览器
不会有任何实质性的变化;然而,我们已经为推出 Compojure 以及在我们的应用程序中支持任意数量的路由铺平了道路。
-
让我们定义多个路由,将它们组合在一个处理器下以练习 Compojure 的路由能力:
(require '[compojure.core :refer [defroutes]]) (defroutes routes (GET "/route-1" request "Hello from route-1") (GET "/route-2" request "Hello from route-2")) -
将这些路由传递给我们的
run-jetty调用:(defn run [] (run-jetty routes {:port 8080 :join? false})) (.stop app) (def app (run))我们现在可以通过浏览器依次导航到我们的每个路由。浏览到我们的第一条路由,
http://localhost:8080/route-1:![图 14.5:浏览到第一条路由]
![图片 B14502_14_05.jpg]
![图 14.5:浏览到第一条路由]
浏览到我们的第二条路由,
http://localhost:8080/route-2:![图 14.6:浏览到第二条路由]
![图片 B14502_14_06.jpg]
![图 14.6:浏览到第二条路由]
-
当我们在这里时,让我们尝试导航到一个不存在的路由,
localhost:8080/nothing-to-see-here。您的浏览器应该会通知您本地主机无法处理此请求,可能表明一个500错误:![图 14.7:浏览到一个不存在的路由]![图片 B14502_14_07.jpg]
![图 14.7:浏览到一个不存在的路由]
-
回顾我们对 Compojure 的
not-found路由定义的讨论,让我们将其作为没有其他路由匹配我们的请求的情况下的后备方案:user=> (require '[compojure.route :as route]) nil user=> (defroutes routes (GET "/route-1" request "Hello from route-1") (GET "/route-2" request "Hello from route-2") (route/not-found "Not the route you are looking for")) => #'user/routes -
重新启动我们的网络服务器,并再次浏览到
http://localhost:8080/nothing-to-see-here,我们现在收到一个更加友好的消息:![图 14.8:再次导航到 nothing-to-see-here]![图片 B14502_14_08.jpg]
![图 14.8:再次导航到 nothing-to-see-here]
-
最后,让我们(暂时地)将我们的
not-found路由移动到路由定义的开头,重新启动应用程序,并浏览到http://localhost:8080/route-1:(defroutes routes (route/not-found "Not the route you are looking for") (GET "/route-1" request "Hello from route-1") (GET "/route-2" request "Hello from route-2"))输出如下:
![图 14.9:导航到路由-1]
![图片 B14502_14_09.jpg]
![图 14.9:导航到路由-1]
这证明了在定义和组合多个路由时所需的谨慎。网络服务器将为其遇到的第一个匹配路由提供响应。我们可以将其视为一个简单的优先顺序,定义中较早的路由比后面的路由更受青睐。
很好——我们现在理解了如何使用 Compojure 定义路由,将其与其他 Compojure 路由定义结合,并在有人导航到不存在的资源时提供一个合理的未找到消息。
![响应格式和中间件]
前面的简单路由定义都返回了一个字符串作为响应体,这对于通过网络浏览器与我们的服务交互的人类来说是完全可以接受的。当我们开始有其他网络服务或前端与我们的服务交互时,我们很可能需要提供替代的响应格式。JavaScript 前端可能希望得到一个 JSON 响应,而 ClojureScript 前端可能更倾向于 EDN。EDN 是在 Clojure 生态系统中被青睐的数据格式;实际上,Clojure 本身就是用这种格式编写的,这意味着在这个阶段你已经熟悉它了!
客户可以通过在请求中提供接受头来指示它接受的格式。accept头是一个描述application/json和application/edn的字符串。服务器可以检查此头,并根据客户端接受的格式渲染响应。服务器将通过在响应中返回一个指示它已选择的格式的content-type头来协助客户端。
注意
客户没有义务以请求的格式返回数据,但如果可能的话,应该这样做。
直觉上,我们可能会倾向于在每个路由中手动检查accept头,并在返回之前对响应进行编码。虽然这会工作,但它会在我们的应用程序中引入大量重复的代码,并分散对路由本身核心工作的注意力。例如,我们可能有一个render-response函数,该函数根据accept头确定要渲染的格式。我们将被迫在所有路由中都包含这个调用。更可取的是,能够定义一次并在所有路由中应用它。
这就是中间件概念出现的地方。简单来说,中间件是一个包裹我们的路由的函数,它允许我们在生成响应之前和/或之后执行代码。
用于同步响应(即客户端将使用相同的连接等待响应)的中间件函数通常具有以下格式:
(defn custom-middleware
[handler]
(fn [request]
(->> request
;; manipulate request before
handler
;; manipulate response after
)))
从本质上讲,它是一个接受处理程序并返回一个新函数的函数,该新函数调用原始处理程序。除了这个要求之外,它可以在将请求传递给处理程序调用之前或之后操纵请求,或者操纵由调用处理程序生成的响应,或者两者都操纵。
现在应该很明显,我们可以定义一个中间件来处理响应渲染。在我们这样做之前,让我们看看muuntaja,它专门为此任务而编写。确实,它通过将我们的处理程序包裹在muuntaja.middleware/wrap-format的调用中,可以开箱即支持 JSON 和 EDN。例如,以下对run函数的调整将自动协商传入请求体和传出响应体的格式:
(require '[muuntaja.middleware :as middleware])
(defn run
[]
(run-jetty
(middleware/wrap-format routes)
{:port 8080
:join? false}))
练习 14.03:使用 Muuntaja 进行响应渲染
在这个练习中,我们将介绍中间件以及如何通过考虑accept头来渲染响应。我们的目标是尊重调用者提供的accept头,以便我们可以根据需要返回 JSON、EDN 或纯字符串。我们还将探讨如何使用curl(一种流行的工具,用于向 Web 服务发出调用)与我们的路由进行交互。
通过利用muuntaja 中间件,我们的路由代码可以完全不受客户端请求的响应格式的影响:
-
我们将首先介绍 muuntaja 依赖项并要求它:
{:deps {compojure {:mvn/version "1.6.1"} metosin/muuntaja {:mvn/version "0.6.4"} ring/ring-core {:mvn/version "1.7.1"} ring/ring-jetty-adapter {:mvn/version "1.7.1"}} user=> (require '[muuntaja.middleware :as middleware]) =>nil -
现在让我们定义一组新的路由,其中之一返回一个字符串,另一个返回一个嵌套的数据结构,这取决于客户端传递的
accept头值:(defroutes routes (GET "/string" request "a simple string response") (GET "/data-structure" request {:body {:a 1 :b #{2 3 4} :c "nested data structure"}}) (route/not-found "Not found"))注意
默认情况下,Compojure 不知道如何渲染一个映射,无论是否指定了
accept头。我们感兴趣的是中间件如何根据我们感兴趣的响应格式处理关键字和集合。
-
记住,中间件封装了一个处理器;因此,引入 muuntaja 的
wrap-format中间件就像这样修改我们的run函数一样简单:(defn run [] (run-jetty (middleware/wrap-format routes) {:port 8080 :join? false})) -
现在重新启动我们的应用,通过表达对响应格式的无偏好,然后规定我们更喜欢
application/edn来请求我们的string资源。这里我们使用curl,根据您的操作系统通过终端或命令提示符:$ curl -i http://localhost:8080/string输出如下:
![图 14.10:打印 curl 命令的输出
![图片 B14502_14_10.jpg]
$ curl -i -H "accept: application/edn" http://localhost:8080/string输出如下:
![图 14.11:打印 curl 命令的输出
![图片 B14502_14_11.jpg]
图 14.11:打印 curl 命令的输出
注意,两种情况下的响应格式是相同的,这可以通过检查前面的
content-type头来确认。这表明我们的服务器无法将响应渲染为EDN,并选择忽略accept头指令。 -
我们的
data-structure路由稍微有趣一些。让我们提交三个不同的请求并比较它们。这是第一个请求:curl -i http://localhost:8080/data-structure输出如下:
![图 14.12:第一次请求的输出
![图片 B14502_14_12.jpg]
图 14.12:第一次请求的输出
这是第二个请求:
curl -i -H "accept: application/json" http://localhost:8080/data-structure
输出如下:
![图 14.13:第二次请求的输出
![图片 B14502_14_13.jpg]
图 14.13:第二次请求的输出
这是第三个请求:
curl -i -H "accept: application/edn" http://localhost:8080/data-structure
输出如下:
![图 14.14:第三次请求的输出
![图片 B14502_14_14.jpg]
图 14.14:第三次请求的输出
在这种情况下,省略accept头默认会给我们一个 JSON 响应。对 JSON 和 EDN 的请求会被尊重。
这展示了使用中间件进行内容协商是多么的简单(且强大)。
处理请求体
到目前为止,我们已经实现了一些简单的GET操作,提供静态响应。回想一下,我们的GET方法是如何读取数据的。在创建(PUT/POST)或更新(PUT)时,我们应该在请求中提供body。这个body是我们希望创建或更新的实体。
注意
可以在GET请求中提供body;然而,这并不常见,且body的内容不应对返回的值有任何实质性影响。
主体,尤其是存储实体时,通常采用映射的形式。因此,映射可以提供为 JSON 或 EDN,并且应相应地进行解析。我们作为路由部分编写的代码对传入的格式是无关紧要的,因为中间件将为我们处理格式化并提供一个我们可以工作的 EDN 表示。
练习 14.04:处理请求主体
在这个练习中,我们将学习 wrap-formats 中间件是如何应用于传入的请求主体,而不仅仅是输出的响应主体。我们还将学习请求的哪个部分将包含主体的内容,如何实现快速内存数据库,以及如何通过 clj-http(一个 Clojure HTTP 客户端库)而不是 curl 与路由交互。我们将使用这些知识在我们的内存数据库上执行基本的 CRUD 操作:
-
让我们引入
clj-http作为依赖项,以演示与我们的网络服务器交互的本地 Clojure 方法。我们还需要一种构造 JSON 有效负载的方法,因此包括了clojure.data.json:{:deps {clj-http {:mvn/version "3.10.0"} compojure {:mvn/version "1.6.1"} metosin/muuntaja {:mvn/version "0.6.4"} org.clojure/data.json {:mvn/version "0.2.6"} ring/ring-core {:mvn/version "1.7.1"} ring/ring-jetty-adapter {:mvn/version "1.7.1"}} -
我们将定义一个原子作为我们服务器的低成本内存数据库。我们的
GET、PUT和DELETE路由将使用它作为存储来演示如何使用request主体:(require '[compojure.core :refer [defroutes DELETE GET PUT]] '[compojure.route :as route]) (def db (atom {})) (defroutes routes (GET "/data-structure" request (when-let [data-structure (@db :data)] {:body data-structure})) (PUT "/data-structure" request (swap! db assoc :data (:body-params request)) {:status 201}) (DELETE "/data-structure" request (swap! db dissoc :data)) (route/not-found "Not found"))我们的
GET方法将读取与:data键关联的值;我们的PUT方法将存储在传入请求中的:body-params键的内容,在原子中的:data键下,使我们能够往返数据结构。最后,我们的DELETE将删除我们存储的结构。 -
我们的
run函数保持不变,因为muuntajawrap-format对传入的请求主体和输出的响应主体都有效:(defn run [] (run-jetty (middleware/wrap-format routes) {:port 8080 :join? false})) -
在重启我们的服务器后,使用
clj-http持久化一个 JSON 数据结构:(require '[clj-http.client :as http] '[clojure.data.json :as json] '[clojure.edn :as edn]) (-> (http/put "http://localhost:8080/data-structure" {:content-type :application/json :body (json/write-str {:a 1 :b #{2 3 4}})}) :status) => 201注意
请求主体 必须 是一个字符串,因此我们对 Clojure 数据结构的
json/write-str调用。我们还必须提供一个content-type标头,以帮助我们的网络服务正确格式化传入的数据。 -
现在以 EDN 格式检索持久化的数据:
user=> (-> (http/get "http://localhost:8080/data-structure" {:accept :application/edn}) :body edn/read-string) => {:b [4 3 2], :a 1}注意,我们尝试将一个集合作为有效负载的一部分进行持久化;然而,它被返回为一个向量。这是一个需要注意的重要点:JSON 到 EDN 转换会导致数据丢失。这是由于 EDN 比 JSON 具有更多的内置类型支持(例如,集合和关键字)。
如果我们有几个客户端与我们的服务交互,这尤其危险;一个持续/消费 JSON,另一个持续/消费 EDN。有一个解决方案,我们可以定义一个模式并强制转换传入的请求主体。
注意
wrap-format确实 将字符串键强制转换为关键字,正如我们在前面的步骤中看到的。 -
现在,让我们确认我们的 EDN 持久性和检索是否按预期进行:
(-> (http/put "http://localhost:8080/data-structure" {:content-type :application/edn :body (pr-str {:a 1 :b #{2 3 4}})}) :status) => 201 (-> (http/get "http://localhost:8080/data-structure" {:accept :application/edn}) :body edn/read-string) => {:a 1, :b #{4 3 2}} -
我们现在完成了
data-structure资源;让我们从服务器中删除它:(-> (http/delete "http://localhost:8080/data-structure") :status) => 200 -
前面的
200状态表明删除成功;我们可以通过尝试再次检索来确认这一点:(http/get "http://localhost:8080/data-structure" {:accept :application/edn}) Execution error (ExceptionInfo) at slingshot.support/stack-trace (support.clj:201). clj-http: status 404我们收到了一个
404异常,正如我们所预期的,因为资源是not found。
太好了——我们已经了解到 wrap-format 中间件将帮助我们格式化 JSON 和 EDN 请求体以及响应体,正如我们之前提到的。我们知道请求体将被 wrap-format 中间件消费,并且 EDN 格式的结果将被放置在传入请求的 body-params 中。我们还获得了一些使用 clj-http 与 Clojure 服务交互的技巧。
静态文件
在互联网的早期,网络服务器被用来提供静态 HTML 页面和图像。尽管从那时起技术已经取得了很大的进步,但提供静态资源仍然是当今网络服务器的一个基本要求。
回想一下我们来自 第五章 的网球比赛 CSV 文件,多对一:减少,我们可能希望通过我们的网络服务提供这个文件的下载。
compojure.route,我们之前用它来提供 not-found 路由,同时也提供了一种从磁盘上的自定义位置轻松服务静态文件的方法。compojure.route/files 接受一个路径,其中文件将被公开,以及一个选项映射,我们可以用它来覆盖我们的文件从哪个目录提供服务。
以下代码将允许我们通过浏览我们的网络服务器的 /files/<filename> 路由来访问 /home/<user>/packt-http/resources/ 目录下的任何文件:
(route/files "/files/" {:root "/home/<user>/packt-http/resources/"})
练习 14.05:服务静态文件
在这个练习中,我们将通过我们的网络服务提供几个文件,观察文件类型如何决定网络浏览器的响应。我们将创建一个具有 .txt 文件扩展名的文本文件,并查看其内容在我们的浏览器中显示。然后我们将看到请求 CSV 文件是如何导致文件下载到我们的本地机器的:
-
创建一个名为
resources的子目录,包含一个名为sample.txt的文本文件,内容为This is only a sample,以及我们从packt.live/2NT96hM熟悉的match_scores_1991-2016_unindexed_csv.csv文件。 -
现在我们将使用
compojure.route/files函数在files路由后面提供这些文件:(defroutes routes (route/files "/files/" {:root "./resources/"}) (route/not-found "Not found"))重新启动网络服务器,然后浏览到
http://localhost:8080/files/sample.txt,期望输出类似于以下内容:![图 14.15:sample.txt 文件输出]()
图 14.15:sample.txt 文件输出
-
现在浏览到
http://localhost:8080/files/match_scores_1991-2016_unindexed_csv.csv,期望文件按以下方式下载:![图 14.16:下载 CSV 文件]()
图 14.16:下载 CSV 文件
注意
浏览器已经检查了响应头中的content-type头;在sample.txt的情况下,它报告为text/plain并在浏览器中渲染。在 CSV 的情况下,content-type头报告为text/csv,通常不会渲染,而是下载到磁盘。然而,根据您在浏览器中的默认应用程序启动设置,它可能由电子表格软件打开。
太棒了!我们已经看到通过我们的网络服务公开本地文件以供显示或下载是多么简单。
集成到应用层
回想一下第十三章中活动 13.01,持久化历史网球结果和 ELO 计算,以及它在数据库交互和应用层中提到的,我们可以设想一个位于应用层之上的网络服务。我们每个命名空间中的公共函数都是公开通过 HTTP 的候选者。
回想一下,我们在 REST 中公开的是读取或操作特定资源的访问。考虑到我们的users和activities作为资源,我们可能想要检索所有users资源,所有activities资源,通过 ID 的单个用户,或给定用户的全部活动。我们首先构建我们导航到每个预期资源的路径。
让我们考虑users资源。这是系统中所有用户的集合,并且合理地位于/users路径上。就我们创建的路由而言,我们的起点将是以下内容:
(def route
(GET "/users" [] (query/all-users db)))
现在,要访问该集合中的单个用户,我们需要一种方法来键入users资源。由于我们已经定义了一个与我们的users资源相关的ID字段,我们可以通过 ID 唯一地识别一个特定的用户。
因此,我们可以设想我们的user-by-id路由看起来与前面的路由非常相似,只是多了一个参数。我们的参数选项是路径参数或查询参数。两者之间的区别在于,path参数用于它们在集合中唯一标识资源时;query参数用于通过属性过滤资源。例如,我们可以有以下内容:
"/users/123"
"/users?first-name=David"
在第一种情况下,我们请求 ID 为 123 的单个用户。在后一种情况下,我们过滤users集合,以包含那些名字为David的用户。
在了解这些知识的基础上,我们可以将我们的公共 API 映射到每个函数,将其映射到path参数,我们将在网络服务中公开它:
ingest/user POST /users
ingest/activity POST /activities
query/all-users GET /users
query/user GET /users/:id
query/activities-by-user GET /users/:id/activities
query/all-activities GET /activities
query/activity GET /activities/:id
query/most-active-user GET /reports?report-type=most-active-user
query/monthly-activity-by-user GET /reports?report-type=monthly-activity-by-user
现在我们已经描述了每条路径,我们可以看到用户、活动和报告的自然分组。Compojure 通过使用compojure.core/context宏定义我们的路由时,为我们提供了一种反映这种分组的方法。例如,为了将GET和POST /users路由分组,我们可以这样做:
(context "/users" []
(GET "/" []
..
(POST "/" request
..)
我们不仅为两个路由创建了一个共享的路径前缀,而且还有在上下文级别以及路由级别解构请求的能力。如果我们有一个共享的查询参数,我们可以在定义上下文时解构它,然后从该上下文中的任何路由引用它。
在 Compojure 中访问路径和查询参数
请求映射的结构使得 path 和 query 参数可以通过 :params 键访问。因此,我们可以解构我们感兴趣的参数如下:
(def route
(GET "/users/:id/activity" {:keys [id] :params} (query/all-users db))
然而,Compojure 提供了一些增强的解构能力,因为参数是访问传入请求中最常见的项目之一。使用 Compojure 的解构,我们可以将前面的路由重写如下:
(def route
(GET "/users/:id/activity" [id] (query/all-users db))
注意,我们只是提供了一个包含 id 符号的向量。Compojure 然后在传入请求映射的 params 键中查找 id 键,并将值提供给我们。向量中的任何内容都会自动从与 :params 键关联的值中提取出来,并在路由体中使用。
练习 14.06:与应用层集成
在这个练习中,我们将使用上一章中的 packt-clj.fitness 后端和健身数据库,将其扩展到可以通過 REST 网络服务摄取和查询用户和活动。我们将从表示为输入流的传入体中读取数据。在这种情况下,slurp 是理想的,因为它将在流上打开一个读取器,并将流内容作为字符串返回。slurp 也可以用于文件、URI 和 URL。
-
在
deps.edn文件中将以下依赖项添加到packt-clj.fitness中:{:deps {.. clj-http {:mvn/version "3.10.0"} compojure {:mvn/version "1.6.1"} metosin/muuntaja {:mvn/version "0.6.4"} org.clojure/data.json {:mvn/version "0.2.6"} ring/ring-core {:mvn/version "1.7.1"} ring/ring-jetty-adapter {:mvn/version "1.7.1"}} -
创建一个新的命名空间,
packt-clj.fitness.api,并包含以下requires路由:(ns packt-clj.fitness.api (:require [clojure.edn :as edn] [compojure.core :refer [context defroutes DELETE GET PUT POST]] [compojure.route :as route] [muuntaja.middleware :as middleware] [packt-clj.fitness.ingest :as ingest] [packt-clj.fitness.query :as query] [packt-clj.fitness.schema :as schema] [ring.adapter.jetty :refer [run-jetty]] [ring.middleware.params :as params])) -
定义我们查询和持久化用户的四个路由,记住我们可以使用
context来分组具有相同路径前缀和/或引用相同query参数的路由:(defroutes routes (context "/users" [] (GET "/" [] {:body (query/all-users schema/db)}) (POST "/" req (let [ingest-result (ingest/user schema/db (edn/read-string (slurp (:body req))))] {:status 201 :headers {"Link" (str "/users/" (:1 ingest-result))}})) (GET "/:id" [id] (when-first [user (query/user schema/db id)] {:body user})) (GET "/:id/activities" [id] {:body (query/activities-by-user schema/db id)}))) -
在前面的
defroutes定义中定义三个与活动相关的路由:(defroutes routes .. (context "/activities" [] (GET "/" [] {:body (query/all-activities schema/db)}) (POST "/" req (let [ingest-result (ingest/activity schema/db (edn/read-string (slurp (:body req))))] {:status 201 :headers {"Link" (str "/activities/" (:1 ingest-result))}})) (GET "/:id" [id] (when-first [activity (query/activity schema/db id)] {:body activity})))注意
重要的是要记住,我们的
body被表示为一个流,这意味着它只能读取一次。任何后续尝试读取它都会发现它已经耗尽。在调试时,应特别注意不要在路由实际使用之前读取body。在编写与body交互的中间件时也应考虑这一点。 -
现在添加我们的报告路由以及我们的通配符
not-found路由:(defroutes routes .. (context "/reports" [report-type id] (GET "/" [] {:body (case report-type "most-active-user" (query/most-active-user schema/db) "monthly-activity-by-user" (query/monthly-activity-by-user schema/db id) nil)})) (route/not-found "Not found") -
创建我们的
run函数,该函数将启动我们的 Jetty 服务器,提供我们定义的路由:(defn run [] (run-jetty (-> routes middleware/wrap-format params/wrap-params) {:port 8080 :join? false})) -
现在让我们启动服务器并探索我们创建的一些端点。检索所有用户,然后验证我们是否可以检索单个用户资源:
(require '[packt-clj.fitness.api :as api]) (def app (api/run))输出如下:
![图 14.17:检索所有用户]()
图 14.17:检索所有用户
这是检索单个用户的输出:
![图 14.18:检索单个用户
![图片 B14502_14_18.jpg]()
图 14.18:检索单个用户
-
添加一个新用户,一个相关联的活动,然后检索用户参与的活动列表:
(require '[clj-http.client :as http]) (-> (http/post "http://localhost:8080/users" {:body (pr-str {:first_name "Boris" :surname "Becker" :height 191 :weight 85})}) :headers (get "Link")) user=> "/users/4" (-> (http/post "http://localhost:8080/activities" {:body (pr-str {:user_id 4 :activity_type "run" :activity_date "2019-03-25" :distance 4970 :duration 1200})}) :headers (get "Link")) user=> "/activities/61"浏览到
http://localhost:8080/users/4/activities,输出如下:![图 14.19:打印最终输出
![图片 B14502_14_19.jpg]()
图 14.19:打印最终输出
注意
前面的用户和活动 ID (4 和 61) 是自动生成的,如果您在写入前两个记录之前持久化了任何其他数据,它们将会发生变化。
优秀。我们已经成功地将现有的应用层通过少量代码使其可通过网络浏览器或任何其他网络客户端访问。
活动 14.01:通过 REST 暴露历史网球结果和 ELO 计算
作为 packt-clj.tennis 的一部分提供的应用层,在 第十三章,数据库交互和应用层 中得到了良好的反响。现在您被要求通过 REST 网络服务使其更广泛地可用。最有兴趣的是浏览球员数据、网球比赛和随时间变化的 ELO。还请求了持久化新的网球比赛和重新计算 ELO 的能力。应假定历史数据已经存在于我们的数据库中。
考虑您应用层的公共 API,确定您想要公开的资源,然后构建您的网络服务,通过网络客户端(无论是浏览器还是其他 HTTP 客户端)公开这些资源。还需要额外的应用层函数来支持我们将创建的路由。
这些步骤将帮助您执行活动:
-
将所需的依赖项添加到您的
deps.edn文件或等效文件中。 -
创建命名空间,
packt-clj.tennis.api。 -
定义我们的
routes参数,添加一个包含路由的players上下文,这些路由公开了所有球员资源、使用其唯一 ID 的单个球员以及球员参加的所有网球比赛。将这些路由连接到query命名空间中适当的函数。 -
创建一个
run函数,该函数将在端口8080上启动 Jetty 服务器,公开我们的routes参数,确保我们使用中间件来帮助我们进行内容协商并合理化请求映射中参数的位置。 -
在我们的
players上下文中,添加一个路由以返回单个球员的 ELO。同样,构建适当的query函数以支持此提取。 -
现在添加一个包含路由的
tennis-matches上下文,该路由公开所有比赛以及通过其唯一 ID 的单个比赛。 -
检索
Pete Sampras(ID:s402)和Novak Djokovic(ID:d643)的当前 ELO。 -
在
tennis-matches上下文中,添加一个路由以使用现有球员创建一个新的比赛。新比赛的持久化应重新计算涉及球员的 ELO。 -
构建并持久化一场虚构的比赛,由
Sampras和Djokovic进行,其中Djokovic获胜:{:id "2019-1-d643-s402-5" :tournament_year 2019, :tournament "umag", :tournament_order 1, :round_order 5, :match_order 1, :winner_id "d643", :loser_id "s402"} -
获取
Sampras(s402)和Djokovic(d643)的更新后的 ELO 值,预期Sampras的 ELO 值将下降,而Djokovic的 ELO 值将上升。这里是预期的输出:

图 14.20:打印 Sampras 的评级

图 14.21:打印 Djokovic 的评级
注意
本活动的解决方案可以在第 753 页找到。
摘要
本章介绍了 HTTP、Web 服务器以及 Web 服务器与客户端之间的请求-响应交互。介绍了多个客户端,包括最典型的(Web 浏览器)以及curl和clj-http。我们学习了 Web 服务器如何接收传入的请求,并根据传入请求映射的关键元素进行路由,然后构建响应映射,该映射随后呈现给请求的客户端。
我们了解了中间件及其如何拦截我们的请求和/或响应映射。然后我们使用muuntaja来格式化我们为客户端生成的内容,以及将来自客户端的数据格式化为 JSON 或 EDN。
在考虑我们数据库内容与相关资源组的关系时,我们学会了如何使用 REST 架构构建适当的路径来处理它们。这为我们现有的应用层与一个允许任何 Web 客户端通过 HTTP 与之交互的 Web 服务集成铺平了道路。
我们现在可以想象一个前端(用户界面)从我们的 Web 服务中提取资源,格式化它们,并将它们呈现给用户,然后用户能够操作这些资源。在这种情况下,ID(它们并不特别用户友好)可以由 UI 跟踪,并从用户那里隐藏。
我们将在下一章探讨如何通过 ClojureScript 与我们的新服务交互。
第十五章:15. 前端:ClojureScript UI
概述
在本章中,我们将介绍 React 的虚拟 DOM 和生命周期的基本知识,然后提供构建前几章中概述的数据应用程序所需丰富用户界面的工具。我们将了解如何从 ClojureScript 中调用 JavaScript 代码以及如何在 JavaScript 和 ClojureScript 对象之间进行转换。
到本章结束时,你将能够为数据应用程序构建一个丰富的用户界面。
简介
Clojure 是一种托管语言,这意味着它在另一种语言之上运行。与 Clojure 在 JVM 之上运行的方式相同,ClojureScript 在 JavaScript 之上运行。更精确地说,一个 ClojureScript 程序被转换成一个可以在浏览器、服务器端以及任何支持 JavaScript 的环境中运行的 JavaScript 程序。例如,考虑 Node.js,这是一个开源的 JavaScript 服务器环境,它允许我们执行 JavaScript 程序。
在本章中,我们将学习 ClojureScript 的基础知识以及如何创建一个在浏览器中运行的 ClojureScript 程序。我们将在 Reagent 框架之上构建一个小型的前端应用程序,该应用程序连接到 HTTP 端点并显示用户可以与之交互的用户界面(UI)组件。我们将使用 Hiccup 作为 UI 组件的标记语言,并了解如何从 ClojureScript 中执行 JavaScript 代码。
我们将使用支持热代码重载的 Figwheel 构建一个应用程序。当你的应用程序运行时,你可以通过更改代码或评估 REPL 中的代码来修改它。你的应用程序将神奇地更新,而无需刷新页面。
我们将学习如何组织 Reagent 应用程序的不同组件:CSS、HTML 和cljs文件。我们将构建几个 Reagent 组件,这些组件可以访问和修改应用程序的状态,并从网络中获取数据。
Hiccup 而不是 HTML
Hiccup 是一个用于在 Clojure 中表示 HTML 的库。在活动 6.01,从 Clojure 向量生成 HTML中,你实现了 Hiccup 的简化版本。正如你所记得的,Hiccup 使用:
-
向量表示元素
-
映射表示元素的属性(包括样式)
在 Hiccup 向量中,第一个元素是一个关键字,它指定了相应的 HTML 元素:
-
:div代表<div>标签 -
:span代表<span>标签 -
:img代表<img>标签
在 Hiccup 中,一个空的<div>由[:div]表示。
第二个元素是一个可选的映射,它表示元素的属性,其中属性的名称遵循 kebab-case 约定:我们用一个下划线字符分隔单词(on-click而不是onClick)。
例如,[:div {:class "myDiv"}]代表<div class="myDiv"></div>。
注意,在 HTML 中,style属性是一个字符串,而在 Hiccup 中它是一个遵循 kebab-case 约定的键值对映射。例如,考虑以下示例:
[:div
{:style {:color "white"
:background-color "blue" }}
"Hello Hiccup"]
这在 HTML 中表示如下:
<div style="color: white; background-color: blue;">Hello Hiccup</div>
这里,color: white表示Hello Hiccup文本和background-color在div标签内的颜色将是蓝色。
在可选的属性映射之后,我们有子元素——我们想要的任意数量。例如,考虑以下内容:
[:div "Hello " "my " "friends!"]
它代表了以下内容:
<div>Hello my friends</div>
我们可以将 Hiccup 元素嵌套为 Hiccup 元素的子元素。例如,考虑以下内容:
[:div
[:img {:src "https://picsum.photos/id/10/2500/1667"}]
[:div "A beautiful image"]]
这代表了以下 HTML 代码:
<div>
<img src="img/1667"> </img>
<div>A beautiful image </div>
</div>
在 Hiccup 中,我们可以以两种方式指定元素的类:
-
指定元素属性:
[:div {:class "myClass"} "Life is beautiful"] -
使用 Hiccup 缩写,通过附加一个点和类的名称:
[:div.myClass "Life is beautiful"]
与 HTML 相比,Hiccup 更加紧凑且易于阅读。
此外,我们可以在 Hiccup 中将代码和数据混合在一起,以动态生成 UI 组件,而无需使用额外的模板语言,就像我们通常在 JavaScript 中做的那样。
例如,假设我们想要创建一个包含 10 个todo项的列表。我们通常会手动写下如下内容:
[:ul
[:li "todo 1"]
[:li "todo 2"]
[:li "todo 3"]
...]
然而,我们可以使用map和into生成完全相同的 Hiccup 元素:
(into [:ul]
(map (fn [num] [:li (str "todo " num)]) (range 10)))
Reagent 入门
Reagent 是 React.js 的极简 ClojureScript 包装器。React.js 是一个用于构建 UI 的 JavaScript 库。
Reagent 组件与 Hiccup 组件类似,除了第一个元素可以是关键字或函数。当它是关键字时,它是一个 Hiccup 组件;当它是函数时,Reagent 会调用该函数以渲染组件,并将向函数传递向量中的剩余部分。
Reagent 应用程序由三部分组成:ClojureScript 代码、HTML 页面和 CSS 规则。
在 Reagent 中,就像在 React 中一样,HTML 页面是极简的:它主要是一个带有 ID 的<div>元素,通常是<div id="app">。
CSS 规则在 JavaScript 应用程序中工作方式相同。ClojureScript 代码通常从core.cljs文件开始,该文件渲染应用程序的主要组件。在本章的练习和活动中,应用程序将是一个单独的命名空间,但在生产应用程序中,应用程序被分割成几个命名空间,就像在 Clojure 中一样。
在 Reagent 中,应用程序的状态存储在一个 ratom(reagent/atom 的缩写)中,它具有与常规 Clojure 原子相同的接口。ratom 与 Clojure 原子的区别在于,当 ratom 发生变化时,UI 会重新渲染。
React.js 采用了函数式编程方法,并鼓励开发者从操作数据结构的组件构建他们的前端应用程序。React 以非常高效的方式在浏览器中的文档对象模型(DOM)中渲染数据结构。React 使开发者免于处理 DOM 的低级操作,从而让他们能够专注于业务逻辑。
Clojure 的数据驱动方法、不可变数据结构以及通过原子管理变化的方式,使得 React 和 ClojureScript 成为了一个强大的组合。
虚拟 DOM 和组件生命周期
当我们在 React.js 中构建应用程序时,我们并不直接与 DOM 打交道,DOM 是浏览器的渲染机制和对象模型。相反,React 向开发者提供了一个虚拟 DOM,并隐藏了实现细节。这就是 React 如此强大的原因。简而言之,当开发 React 应用程序时,开发者编写返回数据结构的 React 组件,React 框架会自动更新 DOM 并在 UI 上渲染组件。
此外,React 足够智能,可以计算出更新 UI 状态所需的最小 DOM 更改量,这使得 React 应用程序性能极高。
如果应用程序需要复杂组件的行为,例如组件挂载时或组件更新前执行特殊操作,React 提供了组件可以与之交互的生命周期方法。
练习 15.01:创建 Reagent 应用程序
在这个练习中,我们将创建一个非常简单的 Reagent 组件:一个带有几个 CSS 属性的图片。我们将在本章中使用以下 CSS 属性:
-
内边距:7px;// 内部填充
-
光标:指针;// 光标的类型
-
外边距左:10px;// 元素之间的水平间距
-
外边距下:10px;// 元素之间的垂直间距
-
边框:1px 实线灰色;// 1px 实线边框,颜色为灰色
-
边框半径:10px;// 角落的半径
-
颜色:灰色;// 文本颜色
-
字体大小:15px;// 字体的大小
-
浮动:左;水平对齐代替默认的垂直对齐
-
在命令行提示符下,使用以下 Leiningen 命令创建一个新的 Figwheel 项目:
lein new figwheel packt-clj.reagent-sandbox -- --reagent -
将文件夹切换到
packt-clj.reagent-sandbox/目录,并输入以下命令来启动应用程序:lein figwheel几秒后,你的浏览器应该会打开默认的 Figwheel 页面:
![图 15.1:一个等待你编写代码的新 ClojureScript 项目]()
图 15.1:一个等待你编写代码的新 ClojureScript 项目
-
在当前文件夹下,使用你喜欢的编辑器打开
src/packt_clj/reagent_sandbox/core.cljs文件,查看以下表达式:(reagent/render-component [hello-world] (. js/document (getElementById "app")))此代码通过调用
reagent/render-component函数并传入两个参数来渲染 UI。第一个参数是要渲染的 Reagent 组件[hello-world],第二个参数是组件将要渲染的 HTML 元素 – 在我们的例子中,是 ID 为app的元素。 -
让我们看看渲染应用程序主要组件的
hello-world函数:(defn hello-world [] [:div [:h1 (:text @app-state)] [:h3 "Edit this and watch it change!"]])hello-world返回一个向量,一个类型为:div的 Hiccup 组件,有两个子组件。第一个子组件是[:h1 (:text @app-state)],这是一个:h1组件,其文本来自对app-state原子解引用的:text值(参见第十二章,并发,关于原子的内容)。第二个子组件是[:h3 "Edit this and watch it change!"],这是一个:h3组件,具有固定文本。让我们看看它是如何工作的! -
返回到你运行 lein figwheel 的终端窗口。你在一个 Figwheel REPL 中。你应该看到一个像这样的提示符:
dev:cljs.user => -
现在,让我们通过在 REPL 中输入以下内容来切换到 packt-clj.reagent-sandbox.core 命名空间:
dev:packt-clj.reagent-sandbox.core=> (require 'packt-clj.reagent-sandbox.core) nil dev:packt-clj.reagent-sandbox.core=> (in-ns 'packt-clj.reagent-sandbox.core) dev:packt-clj.reagent-sandbox.core=> -
现在,让我们检查我们应用的状态:
dev:packt-clj.reagent-sandbox.core!{:conn 2}=> app-state #<Atom: {:text "Hello world!"}> -
然后让我们修改 atom 中的
:text值:dev:packt-clj.reagent-sandbox.core=> (swap! app-state assoc-in [:text] "Hello Reagent!") {:text "Hello Reagent!"}应用程序立即更新为新的文本:
![图 15.2:打印更新后的文本]()
图 15.2:打印更新后的文本
正如我们之前提到的,app-state是一个 ratom;因此,当它发生变化时,Reagent 会重新渲染 UI。
练习 15.02:以样式显示图像
让我们使用几个 CSS 属性渲染一个图像:
-
编辑
src/packt_clj/reagent_sandbox/core.cljs:(defn image [url] [:img {:src url :style {:width "500px" :border "solid gray 3px" :border-radius "10px"}}]) (defn hello-world [] [:div [:h1 (:text @app-state)] [:div [image «https://picsum.photos/id/0/5616/3744»]] [:h3 "Edit this and watch it change!"]])第一部分创建了一个图像组件,第二部分将图像组件的实例作为应用主组件的一部分。当你保存文件时,你的应用应该看起来像这样:
![图 15.3:渲染图像]()
图 15.3:渲染图像
这就是我们所说的热代码重新加载:你更改代码,应用会立即更新,而无需刷新页面。
管理组件状态
在 Reagent 中,组件的行为取决于组件的状态。状态存储在两个地方:
-
当组件实例化时传递给组件的参数
-
一个 ratom
传递给组件的参数不能被组件修改,但 ratom 可以被修改。当我们想要允许组件在用户交互(例如,点击)时更改其状态(或另一个组件的状态)时,这很有用。
要构建具有复杂应用状态的现实生活生产应用程序,我们在 Reagent 之上使用一个框架,例如,Reframe,这是一个在 Reagent 之上编写单页应用的流行框架。
练习 15.03:一个修改其文本的按钮
让我们创建一个初始文本为"ON"的切换按钮,当我们点击它时,它会将其文本更改为"OFF":
-
编辑
src/packt_clj/reagent_sandbox/core.cljs。我们向app-stateratom 添加有关按钮是否开启的信息,通过包括一个初始值为true的:button-on?键:(defonce app-state (atom {:text "Hello world!" :button-on? true})) -
现在我们创建一个
button组件,按钮的文本取决于:button-on?的值,点击处理程序切换:button-on?的值。注意,点击处理程序通过:on-click引用(而在纯 HTML 中,它是onClick):(defn button [] (let [text (if (get-in @app-state [:button-on?]) "ON" "OFF")] [:button {:on-click #(swap! app-state update-in [:button-on?] not)} text])) -
最后,我们将按钮实例化为我们应用的一部分:
(defn hello-world [] [:div [:h1 (:text @app-state)] [button] [:div [image "https://picsum.photos/id/0/5616/3744"]] [:h3 "Edit this and watch it change!"]])切换到你的浏览器窗口,刷新页面,点击按钮,看看文本是如何修改的。在这种情况下,我们必须刷新页面,因为我们已经改变了应用程序的初始状态。
-
你也可以通过 REPL 更改按钮的状态:
dev:packt-clj.reagent-sandbox.core=> (swap! app-state assoc-in [:button-on?] true) {:text "Hello world!", :button-on? true}UI 立即更新。我们不需要刷新页面,因为热重载。点击按钮或交换 ratom 是更新应用程序状态的两种等效方式。
带有子组件的组件
正如我们在介绍 Hiccup 时所看到的,我们可以在 Reagent 组件内部程序化地生成子组件。例如,我们可以从一个图像 URL 数组开始,将每个 URL 转换为图像组件。这样,我们就能程序化地生成一个图像网格。
练习 15.04:创建图像网格
让我们创建一个组件,该组件以网格的形式渲染一系列图像:
-
编辑
src/packt_clj/reagent_sandbox/core.cljs。首先,我们创建一个image-with-width组件,该组件接收图像宽度作为参数:(defn image-with-width [url width] [:img {:src url :style {:width width :border "solid gray 3px" :border-radius "10px"}}]) -
按如下方式创建一个网格组件:
(defn image-grid [images] (into [:div] (map (fn [image-data] [:div {:style {:float "left" :margin-left "20px"}} [image-with-width image-data "50px"]]) images))) -
现在,我们创建一个图像 URL 向量:
(def my-images ["https://picsum.photos/id/0/5616/3744" "https://picsum.photos/id/1/5616/3744" "https://picsum.photos/id/10/2500/1667" "https://picsum.photos/id/100/2500/1656" "https://picsum.photos/id/1000/5626/3635" "https://picsum.photos/id/1001/5616/3744" "https://picsum.photos/id/1002/4312/2868" "https://picsum.photos/id/1003/1181/1772" "https://picsum.photos/id/1004/5616/3744" "https://picsum.photos/id/1005/5760/3840"]) -
最后,我们使用
my-images实例化图像网格:(defn hello-world [] [:div [:h1 (:text @app-state)] [image-grid my-images]])现在,当我们切换到浏览器窗口时,我们看到以下内容:
![图 15.4:图像网格
![img/B14502_15_04.jpg]
图 15.4:图像网格
-
为了更好地理解
image-grid,让我们检查在 REPL 中通过传递my-images的前三个元素给image-grid函数时返回的Hiccup向量:dev:packt-clj.reagent-sandbox.core=> (image-grid (take 3 my-images))这将返回以下内容:
[:div [:div {:style {:float "left", :margin-left "20px"}} [#object[packt_clj$reagent_sandbox$core$image_with_width] «https://picsum.photos/id/0/5616/3744» «50px»]] [:div {:style {:float "left", :margin-left "20px"}} [#object[packt_clj$reagent_sandbox$core$image_with_width] «https://picsum.photos/id/1/5616/3744» «50px»]] [:div {:style {:float "left", :margin-left "20px"}} [#object[packt_clj$reagent_sandbox$core$image_with_width] «https://picsum.photos/id/10/2500/1667» «50px»]]]
这是一个具有三个子组件的 :div 组件,其中每个子组件都是一个具有 style 映射和嵌套 image-with-width-component 的 :div 元素。
热重载
你有没有注意到 app-state 是通过 defonce 而不是通过 def 定义的,就像我们通常在 Clojure 中定义变量一样?
defonce 和 def 之间的区别在于,当 defonce 被调用两次时,第二次调用没有效果。
让我们看看一个简单的例子:
(defonce a 1)
(defonce a 2)
a 的值现在是 1。在热重载的上下文中,defonce 是至关重要的。原因是,在热代码重载的情况下,我们希望:
-
要重新加载的代码
-
app的状态保持不变
这两个愿望似乎相互矛盾,因为 app 的初始状态是在代码中定义的。因此,重新加载代码似乎意味着重新初始化 app 的状态。在这里,defonce 来拯救我们。设置 app 初始状态的代码只调用一次!
如果你好奇,你可以在理解 defonce 和 def 之间的区别的目的是,在你的应用程序代码中将 defonce 替换为 def,并看看每次我们保存代码更改时应用程序如何回到其初始状态。
JavaScript 互操作
现在是时候学习在 ClojureScript 中如何与底层 JavaScript 语言进行互操作了。通过互操作,我们主要指的是:
-
从 ClojureScript 访问窗口全局对象
-
从 ClojureScript 代码中调用 JavaScript 函数
-
在 JavaScript 和 ClojureScript 对象之间进行转换
为了访问窗口作用域,我们使用js/前缀。例如,js/document代表文档对象,js/Math.abs代表Math作用域中的abs函数。为了在 JavaScript 对象上调用方法,我们使用点符号,如下所示:
-
(. js/Math abs -3)等同于Math.abs(3)。 -
(. js/document (getElementById "app"))对应于document.getElementById("app")。
现在,你可以完全理解src/packt_clj/reagent_sandbox/core.cljs中的表达式,该表达式渲染 UI:
(reagent/render-component [hello-world]
(. js/document (getElementById "app")))
当我们想要在 JavaScript 和 ClojureScript 对象之间进行转换时,我们使用js->clj和clj->js函数。
考虑以下示例:
(clj->js {"total" 42})
这返回了 JavaScript 对象{total: 42},在 REPL 中表示为#js {:total 42}。
注意 ClojureScript 关键词被转换为字符串:
(clj->js {:total 42})
这返回了 JavaScript 对象{total: 42}。
clj->js是递归的,这意味着嵌套对象也被转换为 JavaScript 对象。考虑以下示例:
(clj->js {"total" 42
"days" ["monday" "tuesday"]})
这将返回以下内容:
{total: 42,
days: ["monday" "tuesday"]}
你也可以使用#js表示法来生成 JavaScript 对象,但请注意它不是递归的。ClojureScript 中的#js {:total 42}在 JavaScript 中生成{total: 42}。
那么从 JavaScript 到 ClojureScript 的方向呢?你使用js->clj函数。(js->clj #js {"total" 42})返回 ClojureScript 对象{"total" 42}。
如果你想要将 JavaScript 字符串转换为 ClojureScript 关键词,你需要通过传递额外的参数到js->clj来对键进行关键词化:
(js->clj #js {"total" 42} :keywordize-keys true)
这将返回 ClojureScript 对象{:total 42}。
练习 15.05:从 HTTP 端点获取数据
让我们使用我们的 JavaScript 互操作知识从返回 JSON 的 HTTP 端点获取数据,即packt.live/2RURzar。此端点返回一个由三个对象组成的 JSON 数组,包含有关图片的数据:
-
在 JavaScript 中,我们会使用 JavaScript 的
fetch函数和两个承诺将服务器响应转换为 JSON 对象:fetch("https://picsum.photos/v2/list?limit=3") .then((function (response){ return response.json(); })) .then((function (json){ return console.log(json); })) -
在 ClojureScript 中,前面的代码翻译成以下内容:
(-> (js/fetch "https://picsum.photos/v2/list?limit=3") (.then (fn [response] (.json response))) (.then (fn [json] (println (js->clj json :keywordize-keys true)))))它将三个 ClojureScript 对象打印到控制台上的 ClojureScript 向量中:
[{:id "0", :author "Alejandro Escamilla", :width 5616, :height 3744, :url "https://unsplash.com/photos/yC-Yzbqy7PY", :download_url "https://picsum.photos/id/0/5616/3744"} {:id "1", :author "Alejandro Escamilla", :width 5616, :height 3744, :url "https://unsplash.com/photos/LNRyGwIJr5c", :download_url "https://picsum.photos/id/1/5616/3744"} {:id "10", :author "Paul Jarvis", :width 2500, :height 1667, :url "https://unsplash.com/photos/6J--NXulQCs", :download_url "https://picsum.photos/id/10/2500/1667"}]
所有部件现在都已就绪,可以构建一个小的前端应用程序,该应用程序显示来自互联网的图像网格。
活动 15.01:从互联网显示图像网格
你被要求为一名自由职业图形编辑器编写一个前端应用程序,该应用程序显示来自互联网的六张图片和两个按钮:
-
一个清除图片的按钮
-
一个隐藏作者名字的按钮
此按钮可以被图形编辑器用来查看添加到图片中的作者名字。
这些步骤将帮助你完成活动:
-
创建一个新的 Figwheel 项目。
-
创建两个按钮;一个将清除图片,另一个将隐藏作者名字。
-
添加图片。
完成活动后,你应该能看到类似以下的内容:

图 15.5:预期结果
注意
本活动的解决方案可以在第 758 页找到。
活动 15.02:排名的网球运动员
让我们通过结合从本章获得的知识和我们在第十四章中讨论的材料来结束本章。在第十四章的使用 Ring 的 HTTP活动中,我们构建了一个返回网球运动员数据的 HTTP API 服务器。在当前活动中,你被要求为这个 HTTP 服务器构建一个前端。
你必须构建一个 Web 应用,它:
-
显示所有网球运动员的名字
-
当用户点击他们的名字时,显示任何网球运动员的排名
在开始之前,你需要执行第十四章,使用 Ring 的 HTTP的活动,对服务器的代码进行轻微修改以支持我们将要构建的 Web 应用的 API 请求。当前活动将从该服务器请求数据。默认情况下,Web 服务器不允许来自另一个主机的请求。在我们的情况下,API 服务器运行在端口8080,而前端服务器运行在端口3449。为了允许前端应用发出的请求由 API 服务器提供服务,我们需要配置 API 服务器,使其允许跨源资源共享(CORS)。
在开始活动之前,你需要进行以下更改:
-
打开包含活动 14.01,通过 REST 公开历史网球结果和 ELO 计算代码的文件夹。
-
将以下依赖项添加到
deps.edn文件中:jumblerg/ring-cors {:mvn/version "2.0.0"} -
打开
packt-clj/src/packt_clj/tennis/api.clj文件,并在文件顶部的require表达式中添加以下行:[jumblerg.middleware.cors :refer [wrap-cors]] -
打开
packt-clj/src/packt_clj/tennis/api.clj文件,并在文件末尾run函数的定义中添加以下两行:(wrap-cors ".*") (wrap-cors identity)run函数现在应该看起来像这样:(defn run [] (run-jetty (-> routes middleware/wrap-format params/wrap-params (wrap-cors ".*") (wrap-cors identity)) {:port 8080 :join? false})) -
现在,按照活动 14.01,通过 REST 公开历史网球结果和 ELO 计算的解释来运行服务器。
按照以下步骤完成此活动:
-
设置一个新的 Figwheel 项目,使用 Reagent。
-
编写代码,从第十四章,使用 Ring 的 HTTP,从服务器获取网球运动员数据,并将其插入你新的 ClojureScript 应用的应用状态中。你还需要在应用状态中为所选的运动员提供一个当前运动员的字段。
-
编写用于显示玩家列表和用于显示单个玩家及其数据的视图。
-
集成处理选择要查看的运动员的链接和加载和清除玩家列表的按钮的处理程序。
完成当前活动后,你应该在浏览器中看到如下内容:
![图 15.6:玩家列表]()
图 15.6:玩家列表
当用户点击 Michael Stich 时,应用看起来像这样:

图 15.7:运动员的评级
注意
本活动的解决方案可以在第 762 页找到。
摘要
在本章中,您学习了如何使用 Reagent(React.js 的包装器)在 ClojureScript 中构建前端应用程序的基础知识。
我们使用 Hiccup 标记语言构建了几个 Reagent 组件,该语言使用 Clojure 集合来定义 HTML 结构和属性。应用程序的状态存储在一个 Reagent 原子中,您通过 REPL 与之交互。
我们看到了如何从 ClojureScript 调用 JavaScript 代码,以及如何在不同对象之间进行转换。您使用这些互操作功能从 HTTP 服务器获取图像数据,并将数据转换为 ClojureScript 对象。
我们已经到达了本书的结尾。从第一章的首页开始,您已经看到了很多新事物,Hello REPL!。除了语法基础之外,您还学到了很多关于函数式编程的知识,更重要的是,您学会了如何以函数式的方式思考。知道不可变性是什么是一回事,而知道如何使用不可变数据完成任务则是另一回事。通过专注于集合,我们能够向您展示 Clojure 的一些最独特特性,如惰性序列,同时构建您解决问题的思维模式库。Clojure 通常被认为是一种以数据为中心的语言,因此集合是其关键部分。编程不仅仅是掌握语言特性。
我们的另一个重点是使用 Clojure 完成任务,这意味着您需要了解如何在 Clojure 工具生态系统中进行导航。您已经学会了如何设置项目,如何为 Clojure 和 ClojureScript 项目使用 Leiningen,以及如何组织命名空间。平台和互操作性也是这幅画面的重要组成部分:您已经了解了如何在 Clojure(Script)中使用 Java 或 JavaScript 功能。测试对于任何真实世界的项目来说也是一项必要的技能。您现在也已经了解了这一点。您还看到了足够多的宏和 Clojure 的并发工具,以至于您将知道在需要解决复杂问题时从哪里开始。
最后,您已经处理过数据库和 Web 服务器。在几乎任何软件项目中,至少会有这两种技术中的之一存在。通常,两者都会存在。当然,这两个主题本身都非常广泛,但到目前为止,您已经了解了如何以 Clojure 的方式——正如您在本章中所做的那样——以及以 ClojureScript 的方式接近它们。而且,除了细节之外,我们希望您在 Clojure 中的第一步已经让您对编程的新思维方式有了认识。这样,您将能够学习未来需要的任何东西。
附录
关于
本节包含在内,以帮助学生执行书中的活动。它包括学生要执行的详细步骤,以完成并实现书的目标。
1. 欢迎使用 REPL!
活动 1.01:执行基本操作
解答:
-
打开 REPL。
-
打印消息
"我不怕括号"来激励自己:user=> (println "I am not afraid of parentheses") I am not afraid of parentheses nil -
将 1、2 和 3 相加,然后将结果乘以 10 减 3,这对应于以下
infix表示法:(1 + 2 + 3)*(10 - 3):user=> (* (+ 1 2 3) (- 10 3)) 42 -
打印消息
"做得好!"来祝贺自己:user=> (println "Well done!") Well done! Nil -
通过按Ctrl + D或输入以下命令退出 REPL:
user=> (System/exit 0)
通过完成这个活动,你已经编写了打印标准输出的消息的代码。你还使用prefix表示法和嵌套表达式执行了一些数学运算。
活动 1.02:预测大气二氧化碳水平
解答:
-
打开您最喜欢的编辑器和旁边的 REPL 窗口。
-
在您的编辑器中,定义两个常量
base-co2和base-year,分别具有值 382 和 2006:(def base-co2 382) (def base-year 2006) -
在您的编辑器中,编写定义
co2-estimate函数的代码,不要忘记使用doc-string参数对其进行文档化。 -
你可能会想将函数体写在一行中,但嵌套大量的函数调用会降低代码的可读性。通过在
let块中分解它们,也更容易推理每个步骤的过程。使用let来定义局部绑定year-diff,它是从year参数中减去 2006 的结果:(defn co2-estimate "Returns a (conservative) year's estimate of carbon dioxide parts per million in the atmosphere" [year] (let [year-diff (- year base-year)] (+ base-co2 (* 2 year-diff)))) -
通过评估
(co2-estimate 2050)来测试您的函数。你应该得到470作为结果:user=> (co2-estimate 2050) 470 -
使用
doc查找您函数的文档,并确保它已被正确定义:user=> (doc co2-estimate) ------------------------- user/co2-estimate ([year]) Returns a (conservative) year's estimate of carbon dioxide parts per million in the atmosphere nil
在这个活动中,我们计算了给定年份的二氧化碳百万分之一估计水平。
活动 1.03:meditate 函数 v2.0
解答:
-
打开您最喜欢的编辑器和旁边的 REPL 窗口。
-
在您的编辑器中,定义一个名为
meditate的函数,它接受两个参数calmness-level和s,不要忘记编写其文档。 -
在函数体中,首先编写一个打印字符串
"Clojure Meditate v2.0"的表达式:(defn meditate "Return a transformed version of the string 's' based on the 'calmness-level'" [s calmness-level] (println "Clojure Meditate v2.0")) -
根据规范,编写第一个条件来测试平静度是否严格低于 5。编写条件表达式的第一个分支(即"then")。
-
编写第二个条件,它应该嵌套在第一个条件的第二个分支中(即"else")。
-
编写第三个条件,它应该嵌套在第二个条件的第二个分支中。它将检查
calmness-level是否正好是 10,并在这种情况下返回s字符串的反转:(defn meditate "Return a transformed version of the string 's' based on the 'calmness-level'" [s calmness-level] (println "Clojure Meditate v2.0") (if (< calmness-level 4) (str (clojure.string/upper-case s) ", I TELL YA!") (if (<= 4 calmness-level 9) (clojure.string/capitalize s) (if (= 10 calmness-level) (clojure.string/reverse s))))) -
通过传递具有不同平静度的字符串来测试您的函数。输出应类似于以下内容:
user=> (meditate "what we do now echoes in eternity" 1) Clojure Meditate v2.0 "WHAT WE DO NOW ECHOES IN ETERNITY, I TELL YA!" user=> (meditate "what we do now echoes in eternity" 6) Clojure Meditate v2.0 "What we do now echoes in eternity" user=> (meditate "what we do now echoes in eternity" 10) Clojure Meditate v2.0 "ytinrete ni seohce won od ew tahw" user=> (meditate "what we do now echoes in eternity" 50) Clojure Meditate v2.0 nil如果你一直在使用
and运算符来确定一个数字是否在两个其他数字之间,重写你的函数以移除它,并仅使用<=运算符。记住<=可以接受超过两个参数。 -
在文档中查找
cond运算符,并重写你的函数以用cond替换嵌套的条件:user=> (doc cond) ------------------------- clojure.core/cond ([& clauses]) Macro Takes a set of test/expr pairs. It evaluates each test one at a time. If a test returns logical true, cond evaluates and returns the value of the corresponding expr and doesn't evaluate any of the other tests or exprs. (cond) returns nil. nil user=> (defn meditate "Return a transformed version of the string 's' based on the 'calmness-level'" [s calmness-level] (println "Clojure Meditate v2.0") (cond (< calmness-level 4) (str (clojure.string/upper-case s) ", I TELL YA!") (<= 4 calmness-level 9) (clojure.string/capitalize s) (= 10 calmness-level) (clojure.string/reverse s)))
通过完成这个活动,你已经编写了一个文档化的函数,该函数接受多个参数,打印一条消息,并根据条件返回字符串的转换。
2. 数据类型和不可变性
活动 2.01:创建一个简单的内存数据库
解决方案:
-
首先,创建辅助函数。你可以通过不带参数执行
read-db函数来获取哈希表,并通过执行带有哈希表作为参数的write-db函数来写入数据库:user=> (def memory-db (atom {})) #'user/memory-db (defn read-db [] @memory-db) #'user/read-db user=> (defn write-db [new-db] (reset! memory-db new-db)) #'user/write-db -
首先,创建
create-table函数。这个函数应该接受一个参数:表名。它应该在我们的哈希表数据库的根处添加一个新的键(表名),其值应该是一个包含两个条目的另一个哈希表——在data键处的空向量以及在indexes键处的空哈希表:user=> (defn create-table [table-name] (let [db (read-db)] (write-db (assoc db table-name {:data [] :indexes {}})))) #'user/create-table -
测试你的
create-table函数是否正常工作。输出应该如下所示:user=> (create-table :clients) {:clients {:data [], :indexes {}}} user=> (create-table :fruits) {:clients {:data [], :indexes {}}, :fruits {:data [], :indexes {}}} -
让我们创建下一个函数:
drop-table。该函数也应该接受一个参数:表名。它应该从我们的数据库中删除一个表,包括其所有数据和索引:user=> (defn drop-table [table-name] (let [db (read-db)] (write-db (dissoc db table-name)))) #'user/drop-table -
测试你的
drop-table函数是否正常工作。输出应该如下所示:user=> (create-table :clients) {:clients {:data [], :indexes {}}} user=> (create-table :fruits) {:clients {:data [], :indexes {}}, :fruits {:data [], :indexes {}}} user=> (drop-table :clients) {:fruits {:data [], :indexes {}}} -
让我们继续到
insert函数。这个函数应该接受三个参数:table、record和id-key。record参数是一个哈希表,id-key对应于记录映射中的一个键,该键将用作唯一索引。例如,在fruits表中插入记录将看起来像这样:user=> (insert :fruits {:name "Pear" :stock 3} :name) {:fruits {:data [{:name "Pear", :stock 3}], :indexes {:name {"Pear" 0}}}}目前,我们不会处理表不存在或索引键已存在于给定表中的情况。
尝试使用
let块将insert函数的工作分成多个步骤。在一个
let语句中,为使用read-db检索的数据库值创建一个绑定:(defn insert [table-name record id-key] (let [db (read-db)在同一个
let语句中,为数据库的新值创建第二个绑定(在data向量中添加记录后):(defn insert [table-name record id-key] (let [db (read-db) new-db (update-in db [table-name :data] conj record)在同一个
let语句中,通过计算data向量中的元素数量来检索记录插入的索引:(defn insert [table-name record id-key] (let [db (read-db) new-db (update-in db [table-name :data] conj record) index (- (count (get-in new-db [table-name :data])) 1)]在
let语句的主体中,更新id-key处的索引,并使用write-db将结果映射写入数据库:user=> (defn insert [table-name record id-key] (let [db (read-db) new-db (update-in db [table-name :data] conj record) index (- (count (get-in new-db [table-name :data])) 1)] (write-db (update-in new-db [table-name :indexes id-key] assoc (id-key record) index)))) #'user/insert -
为了验证你的
insert函数是否正常工作,尝试多次使用它来插入新的记录。输出应该看起来像这样:user=> (insert :fruits {:name "Apricot" :stock 30} :name) {:fruits {:data [{:name "Pear", :stock 3} {:name "Apricot", :stock 30}], :indexes {:name {"Pear" 0, "Apricot" 1}}}} user=> (insert :fruits {:name "Grapefruit" :stock 6} :name) {:fruits {:data [{:name "Pear", :stock 3} {:name "Apricot", :stock 30} {:name "Grapefruit", :stock 6}], :indexes {:name {"Pear" 0, "Apricot" 1, "Grapefruit" 2}}}} -
创建一个
select-*函数,该函数将返回作为参数传递的表的全部记录。给定前面的三个记录,输出应该类似于以下内容:user=> (select-* :fruits) [{:name "Pear", :stock 3} {:name "Apricot", :stock 30} {:name "Grapefruit", :stock 6}] user=> (defn select-* [table-name] (get-in (read-db) [table-name :data])) #'user/select-* -
创建一个
select-*-where函数,它接受三个参数:table-name、field和field-value。该函数应使用索引映射来检索记录在数据向量中的索引,并返回该元素。给定前面的三个记录,输出应类似于以下内容:user=> (select-*-where :fruits :name "Apricot") {:name "Apricot", :stock 30} user=> (defn select-*-where [table-name field field-value] (let [db (read-db) index (get-in db [table-name :indexes field field-value]) data (get-in db [table-name :data])] (get data index))) #'user/select-*-where -
修改
insert函数以拒绝任何索引重复。当id-key记录已存在于indexes映射中时,我们不应修改数据库,并向用户打印错误信息。输出应类似于以下内容:user=> (insert :fruits {:name "Pear" :stock 3} :name) Record with :name Pear already exists. Aborting user=> (select-* :fruits) [{:name "Pear", :stock 3} {:name "Apricot", :stock 30} {:name "Grapefruit", :stock 6}] user=> (defn insert [table-name record id-key] (if-let [existing-record (select-*-where table-name id-key (id-key record))] (println (str "Record with " id-key ": " (id-key record) " already exists. Aborting")) (let [db (read-db) new-db (update-in db [table-name :data] conj record) index (- (count (get-in new-db [table-name :data])) 1)] (write-db (update-in new-db [table-name :indexes id-key] assoc (id-key record) index))))) #'user/insert最终输出应类似于以下内容:
user=> (create-table :fruits) {:clients {:data [], :indexes {}}, :fruits {:data [], :indexes {}}} user=> (insert :fruits {:name "Pear" :stock 3} :name) Record with :name Pear already exists. Aborting user=> (select-* :fruits) [{:name "Pear", :stock 3} {:name "Apricot", :stock 30} {:name "Grapefruit", :stock 6}] user=> (select-*-where :fruits :name "Apricot") {:name "Apricot", :stock 30}
在这个活动中,我们利用了关于读取和更新简单和深层嵌套数据结构的新知识来实现一个简单的内存数据库。
3. 深入了解函数
活动三.01:构建距离和成本计算器
解决方案:
-
首先定义
walking-speed和driving-speed常量:(def walking-speed 4) (def driving-speed 70) -
创建两个其他常量,代表具有坐标
:lat和:lon的两个位置。您可以使用之前的巴黎和波尔多的示例,或者查找您自己的位置。您将使用它们来测试您的距离和行程函数:(def paris {:lat 48.856483 :lon 2.352413}) (def bordeaux {:lat 44.834999 :lon -0.575490}) -
创建距离函数。它应接受两个参数,代表我们需要计算距离的两个位置。您可以在函数参数中直接使用顺序和关联解构来分解两个位置的纬度和经度。您可以将计算的步骤分解在
let表达式中,并使用Math/cos函数来计算余弦和Math/sqrt来计算一个数的平方根:(defn distance "Returns a rough estimate of the distance between two coordinate points, in kilometers. Works better with smaller distance" [{lat1 :lat lon1 :lon} {lat2 :lat lon2 :lon}] (let [deglen 110.25 x (- lat2 lat1) y (* (Math/cos lat2) (- lon2 lon1))] (* deglen (Math/sqrt (+ (* y y) (* x x)))))) -
创建一个名为
itinerary的多方法。它将提供未来添加更多类型交通的灵活性。它应使用:transport值作为调度值:(defmulti itinerary "Calculate the distance of travel between two location, and the cost and duration based on the type of transport" :transport) -
为
:walking调度值创建行程函数。您可以在函数参数中使用关联解构来检索HashMap参数中的:from和:to键。您可以使用let表达式来分解距离和持续时间的计算。距离应简单地使用您之前创建的distance函数。为了计算持续时间,您应使用在步骤 1中定义的walking-speed常量:(defmethod itinerary :walking [{:keys [:from :to]}] (let [walking-distance (distance from to) duration (/ (distance from to) walking-speed)] {:cost 0 :distance walking-distance :duration duration})) -
对于
:driving行程函数,您可以使用包含与成本函数关联的车辆的调度表。创建一个vehicle-cost-fns调度表。它应该是一个HashMap,键是车辆类型,值是基于距离的成本计算函数:(def vehicle-cost-fns { :sporche (partial * 0.12 1.3) :tayato (partial * 0.07 1.3) :sleta (partial * 0.2 0.1) }) -
为
:driving分派值创建行程函数。你可以在函数参数中使用关联解构来检索HashMap参数中的:from、:to和:vehicle键。驾驶距离和持续时间可以像步行距离和持续时间一样计算。成本可以通过使用:vehicle键从分派表中检索cost函数来计算:(defmethod itinerary :driving [{:keys [:from :to :vehicle]}] (let [driving-distance (distance from to) cost ((vehicle vehicle-cost-fns) driving-distance) duration (/ driving-distance driving-speed)] {:cost cost :distance driving-distance :duration duration}))现在尝试以下操作:
user=> (def london {:lat 51.507351, :lon -0.127758}) #'user/london user=> (def manchester {:lat 53.480759, :lon -2.242631}) #'user/manchester user=> (itinerary {:from london :to manchester :transport :walking}) {:cost 0, :distance 318.4448148814284, :duration 79.6112037203571} user=> (itinerary {:from manchester :to london :transport :driving :vehicle :sleta}) {:cost 4.604730845743489, :distance 230.2365422871744, :duration 3.2890934612453484}
在这个活动中,我们通过构建两个位置之间的距离和成本计算器,将我们在本章中学到的解构和多重方法技术付诸实践。在未来,你可以想象将这段代码放在一个网络服务器后面,完成一个完整的行程计算应用的建设!
4. 映射和过滤
活动 4.01:使用映射和过滤来报告摘要信息
解决方案:
-
首先,使用
->>线程宏设置一个简单的框架:(defn max-value-by-status [field status users] (->> users ;; code will go here ))这定义了我们的函数的基本结构,我们可以总结如下:从
users开始,将其发送通过一系列转换。 -
这些转换中的第一个将是过滤掉所有没有我们正在寻找的状态的用户。我们将自然地使用
filter来做这件事,并包括一个谓词,该谓词将每个用户的:status字段与函数中传入的status参数进行比较:(过滤 #(= (:status %) status))。这样,我们的函数现在看起来是这样的:
(defn max-value-by-status [field status users] (->> users ;; step 1: use filter to only keep users who ;; have the status we are looking for (filter #(= (:status %) status)) ;; More to come! ))我们知道我们有了正确的用户集。现在,我们需要提取我们感兴趣的域。
field参数是一个关键字,因此我们可以将其用作一个函数来提取我们映射每个用户时所需的数据,如下所示:(映射 field)。现在,我们的函数看起来是这样的:
(defn max-value-by-status [field status users] (->> users ;; step 1: use filter to only keep users who ;; have the status we are looking for (filter #(= (:status %) status)) ;; step 2: field is a keyword, so we can use it as ;; a function when calling map. (map field) ;; Watch this space! )) -
在最后一步,我们使用
(apply max)来在这堆干草中找到针:对应于field的最大值。完整的函数看起来像这样:
(defn max-value-by-status [field status users] (->> users (filter #(= (:status %) status)) (map field) (apply max 0))) (defn min-value-by-status [field status users] (->> users (filter #(= (:status %) status)) (map field) (apply min 0)))
活动 4.02:任意网球对抗
解决方案:
-
第一步将是设置函数和
with-open宏:(defn rivalry-data [csv player-1 player-2] (with-open [r (io/reader csv)] )) -
在其中,设置一个
let绑定。第一个绑定将是csv/read-csv返回的lazy-seq:(let [rivalry-seq (->> (csv/read-csv r) sc/mappify (sc/cast-with {:winner_sets_won sc/->int :loser_sets_won sc/->int :winner_games_won sc/->int :loser_games_won sc/->int}))] ;; more to come ) -
在相同的
->>链中,我们还想只保留我们的玩家实际上是对抗的匹配。像之前做的那样,我们将使用集合模式来查看“比赛中的胜者和败者”集合是否等于我们正在寻找的两个玩家的集合。我们还将使用map和select-keys来只保留我们想要的字段:(filter #(= (hash-set (:winner_name %) (:loser_name %)) #{player-1 player-2})) (map #(select-keys % [:winner_name :loser_name :winner_sets_won :loser_sets_won :winner_games_won :loser_games_won :tourney_year_id :tourney_slug])) -
我们可以开始收集一些数据,所以我们在同一个
let语句中创建更多的绑定:player-1-victories (filter #(= (:winner_name %) player-1) rivalry-seq) player-2-victories (filter #(= (:winner_name %) player-2) rivalry- seq)这些是简单的过滤调用,给我们两个胜利列表。
-
我们有了所有需要的绑定,现在是在
let语句的作用域内使用它们的时候了。所有的事情都可以在我们将要返回的映射内部发生。现在我们已经有了我们的三个序列,即player-1-victories、player-2-victories和整体的rivalry-seq,这使得通过调用count和first获取一些总结数据变得更容易。我们还将写一个额外的filter调用,检查竞争中的得分差异:{:first-victory-player-1 (first player-1-victories) :first-victory-player-2 (first player-2-victories) :total-matches (count rivalry-seq) :total-victories-player-1 (count player-1-victories) :total-victories-player-2 (count player-2-victories) :most-competitive-matches (->> rivalry-seq (filter #(= 1 (- (:winner_sets_won %) (:loser_sets_won %)))))}注意
你可能会惊讶,我们在这个解决方案中不需要调用
doall。这是因为rivalry-seq是通过调用count完全实现的。最终的代码可以在packt.live/2Ri3904找到。
通过完成这个活动,我们已经为任何实际相互对抗的两个玩家产生了总结数据。
5. 多对一:减少
活动 5.01:计算网球 Elo 评级
解决方案:
-
这里是你需要的最小
deps.edn文件:{:deps {org.clojure/data.csv {:mvn/version "0.1.4"} semantic-csv {:mvn/version "0.2.1-alpha1"} org.clojure/math.numeric-tower {:mvn/version "0.0.4"}}} -
这里是相应的命名空间声明:
(ns packt-clj.elo (:require [clojure.math.numeric-tower :as math] [clojure.java.io :as io] [clojure.data.csv :as csv] [semantic-csv.core :as sc]) -
对于你函数的整体结构,我们将遵循迄今为止使用的相同模式:一个
with-open宏和一些预处理代码:(defn elo-world ([csv k] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (sc/cast-with {:winner_sets_won sc/->int :loser_sets_won sc/->int :winner_games_won sc/->int :loser_games_won sc/->int}) ;; TODO: just getting started )))) -
下一步是概述对
reduce的调用。:match-count、:predictable-match-count和:correct-predictions字段只是计数器,将根据每个匹配是否被正确预测而需要更新。:players映射将包含每个玩家的键;值将是他们的 Elo 评级:(defn elo-world ([csv k] (with-open [r (io/reader csv)] (->> (csv/read-csv r) sc/mappify (sc/cast-with {:winner_sets_won sc/->int :loser_sets_won sc/->int :winner_games_won sc/->int :loser_games_won sc/->int}) (reduce (fn [{:keys [players] :as acc} {:keys [:winner_name :winner_slug :loser_name :loser_slug] :as match}] ;; TODO: more code ) {:players {} :match-count 0 :predictable-match-count 0 :correct-predictions 0}))))) -
从这里,它只是一个应用我们已开发的逻辑的问题。首先,我们提取和计算,然后更新累加器。减少函数的主体从一些
let绑定开始:(let [winner-rating (get players winner_slug 400) loser-rating (get players loser_slug 400) winner-probability (match-probability winner-rating loser-rating) loser-probability (- 1 winner-probability) predictable-match? (not= winner-rating loser-rating) prediction-correct? (> winner-rating loser-rating) correct-predictions (if (and predictable-match? prediction-correct?) (inc (:correct-predictions acc)) (:correct-predictions acc)) predictable-matches (if predictable-match? (inc (:predictable-match-count acc)) (:predictable-match-count acc))] ;; TODO: update the accumulator )winner-rating和loser-rating是从累加器中的:players映射中提取的。在通过调用match-probability对比赛胜者做出预测后,剩余的操作只是应用我们的预测是否正确的结果。 -
现在,我们最终可以更新累加器了。以下代码将放在上面的
let表达式内部。这就是减少函数将返回的内容。我们使用->宏将现有的acc通过一系列变化传递:(-> acc (assoc :predictable-match-count predictable-matches) (assoc :correct-predictions correct-predictions) (assoc-in [:players winner_slug] (recalculate-rating k winner-rating winner-probability 1)) (assoc-in [:players loser_slug] (recalculate-rating k loser-rating loser-probability 0)) (update :match-count inc)) -
这里是整个函数,当我们把所有东西放在一起时:
tennis.clj
16 (defn elo-world-simple
17 ([csv k]
18 (with-open [r (io/reader csv)]
19 (->> (csv/read-csv r)
20 sc/mappify
21 (sc/cast-with {:winner_sets_won sc/->int
22 :loser_sets_won sc/->int
23 :winner_games_won sc/->int
24 :loser_games_won sc/->int})
25 (reduce (fn [{:keys [players] :as acc} {:keys [:winner_name :winner_slug
26 :loser_name :loser_slug] :as match}]
The complete code for this snippet can be found at: https://packt.live/38wSCUn
尝试不同的k值以查看是否可以提高预测的精度可能很有趣。这就是为什么我们跟踪:correction-predictions和:predictable-match-count。还有许多其他类型的改进可以实施:修改函数以在多个 CSV 文件上运行(如果评分从数据集的起始点,即 1877 年开始,我们预计质量会提高);也许根据两位选手的相对强度或基于当前的胜负连串来上下文调整k;或者给在特定地点赢得更多比赛的选手分配奖金。你现在有一个实验的框架。
6. 递归和循环
活动 6.01:从 Clojure 向量生成 HTML
解决方案:
-
我们将在解决方案中使用
clojure.string。这并不是严格必要的,但clojure.string/join是一个非常方便的函数。因为clojure.string是一个标准的 Clojure 命名空间,一个只包含空映射的deps.edn文件就足够用于这项活动:(ns my-hiccup (:require [clojure.string :as string])) -
这里列出了我们将在主 HTML 生成函数中稍后使用的较小函数:
(defn attributes [m] (clojure.string/join " " (map (fn [[k v]] (if (string? v) (str (name k) "=\"" v "\"") (name k))) m)))第一个函数
attributes接受map属性名称和值。我们将映射视为一个序列,并映射到[key value]对。键是 Clojure 关键字,因此需要使用名称函数将它们转换为字符串。键值对变成一个字符串,key="value"。然后,我们使用clojure.string/join将所有子字符串组合成一个字符串,确保每个属性与其他属性之间通过一些空格分隔。如果输入映射m为空,clojure.string/join将返回一个空字符串。 -
下一个函数是用于构建不同类型标签的简单格式化工具:
(defn keyword->opening-tag [kw] (str "<" (name kw) ">")) (defn keyword-attributes->opening-tag [kw attrs] (str "<" (name kw) " " (attributes attrs) ">")) (defn keyword->closing-tag [kw] (str "</" (name kw) ">"))下一个辅助函数集是谓词,我们将在主递归函数遍历嵌套向量树时需要它们。它们都接受相同类型的参数:我们正在分析的树或子树。
-
我们需要能够区分具有属性和不具有属性的输入向量。我们通过查看向量中第二个项的类型来实现这一点:
(defn has-attributes? [tree] (map? (second tree))) -
简单元素如
[:h1 "Hello world"]的第二个项是一个字符串,所以has-attributes?将返回nil。如果第二个项是另一个向量,结果将相同。在[:h1 {:class "title"} "Hello Universe"]中,has-attributes?将返回true。因为(map? nil)返回nil,所以我们不需要为单元素向量设置特殊案例。(has-attributes? [:br])简单地返回nil:(defn singleton? [tree] (and (vector? tree) (#{:img :meta :link :input :br} (first tree)))) -
singleton?函数用于测试一个元素是否属于一组不允许有闭合标签的 HTML 元素集合中的成员。自然地,我们使用 Clojure 集合来测试这一点。不过,首先我们要确保当前项是一个向量,因为有时当前项将是一个字符串(例如在[:h1 "Hello world"]中的第二个项):(defn singleton-with-attrs? [tree] (and (singleton? tree) (has-attributes? tree))) (defn element-with-attrs? [tree] (and (vector? tree) (has-attributes? tree)))这两个谓词遵循相同的基本逻辑,但建立在已经定义的函数之上。
-
现在是时候编写主要递归函数了。像谓词一样,它将接受一个树作为参数,这当然可以是整个向量树或子树。像大多数递归函数一样,有一个
cond包含多个分支。这里是基本结构:
(defn my-hiccup [tree] (cond )) -
在将所有条件合并之前,让我们逐一分析各种条件:
(not tree) tree -
如果
tree不是真值,这意味着我们无法进行任何操作,所以我们只需返回nil。对于其他输入,我们不必担心得到nil:(string? tree) tree -
如果
tree是一个字符串,我们不想对其进行转换。它可以原样集成到输出字符串中:(singleton-with-attrs? tree) (keyword-attributes->opening-tag (first tree) (second tree)) -
这就是为什么我们需要
singleton-with-attrs?谓词的原因。现在,我们只需要将单例树与相应的格式化函数匹配。因为单例元素没有任何内容,所以这里不可能进行递归:(singleton? tree) (keyword->opening-tag (first tree)) -
这是之前条件的简化版本:
(element-with-attrs? tree) (apply str (concat [(keyword-attributes->opening-tag (first tree) (second tree))] (map my-hiccup (next (next tree))) [(keyword->closing-tag (first tree))])) -
现在,我们终于可以做一些递归了!如果一个元素有属性,开始从列表中制作一个字符串。这就是
(apply str…)的作用。我们将使用concat将开标签和属性(现在格式化为字符串)添加到由next调用生成的列表中,这些调用将在map调用中发生。在列表的末尾,我们有格式化的关闭标签。这是我们不能使用recur:的经典案例,因为(apply str (concat…))的调用无法完成,直到所有底层的my-hiccup调用都完成:(vector? tree) (apply str (concat [(keyword->opening-tag (first tree))] (map my-hiccup (next tree)) [(keyword->closing-tag (first tree))])) -
最后一个条件遵循与之前相同的模式。我们本可以将这个条件作为默认条件,而不是用
vector?进行测试。如果我们的输入向量树格式不正确,没有匹配情况的错误应该能让我们找到调试的正确方向。在生产代码中,我们可以添加一个:otherwise条件,这将抛出一个异常。你将在第九章,Java 和 JavaScript 的主机平台互操作性 中学习关于异常的内容。如果我们重新组装
my-hiccup,它看起来是这样的:(defn my-hiccup [tree] (cond (not tree) tree (string? tree) tree (singleton-with-attrs? tree) (keyword-attributes->opening-tag (first tree) (second tree)) (singleton? tree) (keyword->opening-tag (first tree)) (element-with-attrs? tree) (apply str (concat [(keyword-attributes->opening-tag (first tree) (second tree))] (map my-hiccup (next (next tree))) [(keyword->closing-tag (first tree))])) (vector? tree) (apply str (concat [(keyword->opening-tag (first tree))] (map my-hiccup (next tree)) [(keyword->closing-tag (first tree))]))))如果你在 REPL 中尝试
my-hiccup函数,你应该能够生成一个完整的 HTML 页面的字符串:![图 6.10:最终输出]()
图 6.10:最终输出
注意
随意尝试不同的输入和页面结构。你甚至可以将输出字符串复制到文本文件中,然后加载到浏览器中。
通过完成这个活动,我们现在能够处理任何使用这种语法编写的向量,包括任意数量的后代向量,并生成一个包含正确结构化 HTML 的单个字符串。
7. 递归 II:懒序列
活动第 7.01 部分:历史,以玩家为中心的 Elo
解决方案:
-
设置你的项目,该项目应基于本章最后练习中编写的代码。
-
解决方案遵循与
take-matches建立的模式。让我们从参数开始。我们需要为“焦点玩家”进行的比赛和其他玩家之间的比赛定义不同的行为。我们当然需要一种识别玩家的方法,所以我们将添加一个player-slug参数。在take-matches中这不是必要的,因为在那里我们对待所有比赛都是一样的,无论谁参与了比赛。在
take-matches中,我们有一个limit参数来控制我们深入树的深度。在这种情况下,我们需要两个不同的参数,我们将它们称为focus-depth和opponent-depth。结合起来,这为我们新的focus-history函数提供了以下参数:(defn focus-history [tree player-slug focus-depth opponent-depth f] ;;... )tree参数当然是match-tree-by-player调用的结果,就像之前一样。最后,
f参数将与take-matches中的方式相同。 -
控制函数在树中的移动将是一项挑战。像往常一样,我们将设置一个
cond形式,以确定函数如何对传入的数据做出反应。前两个条件相当简单,实际上几乎与take-matches中的代码相同:(defn focus-history [tree player-slug focus-depth opponent-depth f] (cond (zero? focus-depth) '() (= 1 focus-depth) (f (first tree))))与
take-matches的唯一不同之处在于,现在我们使用focus-depth而不是limit。在这里使用focus-depth的事实仍然很重要。我们只关心这个阶段的focus-depth,而不是opponent-depth,因为如果focus-depth为零或一,整个操作就会停止,在这种情况下我们就不再关心opponent-depth。 -
最终条件是,这个函数在行为上与
take-matches不同。尽管可能没有你第一眼看到的那样复杂,但它确实更复杂。为了理解这一点,让我们看看take-matches的等效部分::otherwise-continue (cons (f (first tree)) (cons [(take-matches (dec limit) (first (second tree)) f) (take-matches (dec limit) (second (second tree)) f)] '())) -
在这一点上,我们将
(f (first tree))放在当前惰性序列的头部。这样,我们就将其连接到序列的其余部分,其第一个元素将是一个包含两个更多分支惰性序列起始点的向量。在这里,为了引入两种可能情况的不同行为,我们只需要在两个元素的向量中替换对
take-matches的调用。这两个匹配是当前匹配的“父”匹配;也就是说,它们是当前匹配之前赢家和输家所进行的匹配。我们需要先测试以找出哪些“父”匹配属于焦点玩家,哪些属于对手。对于焦点玩家的上一场比赛,我们调用focus-history。对于对手的上一场比赛,我们调用take-matches。换句话说,我们不再像上面那样只有两个对take-matches的调用,而是有两个分支条件::otherwise (cons (f (first tree)) (cons [(if (player-in-match? (ffirst (second tree)) player-slug) (focus-history (first (second tree)) player-slug (dec focus-depth) opponent-depth f) (take-matches opponent-depth (first (second tree)) f)) (if (player-in-match? (first (second (second tree))) player-slug) (focus-history (second (second tree)) player-slug (dec focus-depth) opponent-depth f) (take-matches opponent-depth (second (second tree)) f))] '())) -
在这两种情况下,无论我们调用
focus-history还是take-matches,我们都要小心调整tree参数和focus-depth参数。记住tree始终相对于由一场比赛和两个元素的向量组成的当前两项序列,这就是为什么我们使用(first (second tree))和(second (second tree)),即向量中两个懒序列的第一个和第二个。虽然将这些分配给let绑定以避免重复(second tree)可能很有吸引力,但在这些情况下通常最好避免“保留头部”。这是完整的函数:
(defn focus-history [tree player-slug focus-depth opponent-depth f] (cond (zero? focus-depth) '() (= 1 focus-depth) (f (first tree)) :otherwise (cons (f (first tree)) (cons [(if (player-in-match? (ffirst (second tree)) player-slug) (focus-history (first (second tree)) player-slug (dec focus-depth) opponent-depth f) (take-matches opponent-depth (first (second tree)) f)) (if (player-in-match? (first (second (second tree))) player-slug) (focus-history (second (second tree)) player-slug (dec focus-depth) opponent-depth f) (take-matches opponent-depth (second (second tree)) f))] '())))) -
值得注意的是,这个函数比
take-matches稍微复杂一点。正如递归解决方案通常那样,代码本身相当简单。困难在于选择最佳策略。这里是函数从开始到结束的运行过程。首先,我们读取数据并生成评分:
packt-clojure.lazy-tennis> (def ratings (elo-db "match_scores_1991-2016_unindexed_csv.csv" 35))然后我们为感兴趣的球员构建懒匹配树:
#'packt-clojure.lazy-tennis/ratings packt-clojure.lazy-tennis> (def federer (match-tree-by-player ratings "roger-federer")) #'packt-clojure.lazy-tennis/federer现在调用我们的新函数:
packt-clojure.lazy-tennis> (focus-history federer "roger-federer" 4 2 #(select-keys % [:winner_name :loser_name :winner_rating :loser_rating]))结果中的缩进揭示了树结构:
![图 7.10:焦点历史的成果
![图片 B14502_07_10.jpg]()
图 7.10:焦点历史的成果
费德勒打出的比赛包含更深层次的先前比赛的子树。数据已准备好传递给前端团队,他们将把它转换成漂亮的可视化。
8. 命名空间、库和 Leiningen
活动 8.01:在应用程序中更改用户列表
解决方案:
-
使用
use和:rename关键字导入clojure.string命名空间,为replace和reverse函数重命名:(use '[clojure.string :rename {replace str-replace, reverse str-reverse}]) -
创建一组用户:
(def users #{"mr_paul smith" "dr_john blake" "miss_katie hudson"}) -
替换尊称和名字之间的下划线:
(map #(str-replace % #"_" " ") users)这将返回以下内容:
("mr paul smith" "miss katie hudson" "dr john blake") -
使用
capitalize函数将用户组中每个人的首字母大写:(map #(capitalize %) users)这将返回以下内容:
("Mr_paul smith" "Miss_katie hudson" "Dr_john blake") -
使用字符串的
replace和capitalize函数更新用户列表:(def updated-users (into #{} (map #(join " " (map (fn [sub-str] (capitalize sub-str)) (split (str-replace % #"_" " ") #" "))) users))) updated-users输出如下:
#{"Mr Paul Smith" "Dr John Blake" "Miss Katie Hudson"} -
仅从
clojure.pprint命名空间导入print-table函数:(use '[clojure.pprint :only (print-table)]) -
打印用户表:
(print-table (map #(hash-map :user-name %) updated-users))输出如下:
![图 8.23:打印用户表
![图片 B14502_08_23.jpg]()
图 8.23:打印用户表
-
导入
clojure.set命名空间,排除join函数:(use '[clojure.set :exclude (join)]) -
创建并显示一组管理员:
(def admins #{"Mr Paul Smith" "Miss Katie Hudson" "Dr Mike Rose" "Mrs Tracy Ford"})输出如下:
#'user/admins -
现在执行以下操作:
admins输出如下:
#{"Mr Paul Smith" "Dr Mike Rose" "Miss Katie Hudson" "Mrs Tracy Ford"} -
在两个集合上调用
subset?函数:(subset? users admins)输出如下:
false -
要打印最终输出,执行以下操作:
(print-table (map #(hash-map :user-name %) updated-users))输出如下:

图 8.24:打印最终用户表
在这个活动中,我们处理了两个功能。第一个功能是将用户名大写。第二个功能是使用Clojure.set函数检查是否有任何用户也是管理员。
活动 8.02:求和数字
解决方案:
-
创建一个 Leiningen 应用程序:
lein new app hello-leiningen -
将字符串参数转换为整数:
(map #(Integer/parseInt %) args) -
添加整数进行求和:
apply + -
按如下方式打印结果:
println输出将如下所示:
![图 8.25:打印求和结果]

图 8.25:打印求和结果
完整的解决方案应如下所示:
(ns hello-leiningen.core)
(defn -main
"Sum integers passed as arguments."
[& args]
(println (apply + (map #(Integer/parseInt %) args))))
在这个活动中,我们创建了一个新的 Leiningen 项目。这个应用程序可以从命令行接受参数。输入到命令行的数字会被求和,并将结果显示出来。
活动 8.03:构建格式转换应用程序
解决方案:
-
在
project.clj中添加cheshire依赖项:(defproject json-parser "0.1.0-SNAPSHOT" ;;; code committed :dependencies [[org.clojure/clojure "1.10.0"] [cheshire "3.0.0"]] ;;; code ommited ) -
在核心命名空间中创建一个从哈希转换为 JSON 的函数:
(ns json-parser.core (:require [cheshire.core :as json]) (:gen-class)) (defn generate-json-from-hash [hash] (json/generate-string hash))在 REPL 中测试
generate-json-from-hash函数应该给出以下结果:![图 8.26:从哈希生成 JSON]
![图片 B14502_08_26.jpg]()
图 8.26:从哈希生成 JSON
-
创建一个从 JSON 转换为哈希的函数:
(defn generate-hash-from-json [json] (json/parse-string json))在 REPL 中测试
generate-hash-from-json应该给出以下结果:![图 8.27:从 JSON 生成哈希]
![图片 B14502_08_27.jpg]()
图 8.27:从 JSON 生成哈希
-
将
expectations库添加到为项目定义的测试配置文件中。在project.clj中添加以下内容:(defproject json-parser "0.1.0-SNAPSHOT" ;;; code ommited :profiles {:qa {:dependencies [[expectations "2.1.10"]]} ;;; code ommited }) -
为项目添加
lein-expectations插件:(defproject json-parser "0.1.0-SNAPSHOT" ;;; code ommited :profiles {:qa {:plugins [[lein-expectations "0.0.8"]]} ;;; code ommited }) -
编写 JSON 函数的测试。在
json-parser/test/json_parser/core_test.clj文件中添加以下内容:(ns json-parser.core-test (:require [expectations :refer [expect]] [json-parser.core :refer :all])) (expect (generate-json-from-hash {:name "John" :occupation "programmer"}) "{\"name\":\"John\",\"occupation\":\"programmer\"}") (expect (generate-hash-from-json "{\"name\":\"Mike\",\"occupation\":\"carpenter\"}") {"name" "Mike", "occupation" "carpenter"})使用
qa配置文件调用测试应该给出以下结果:![图 8.28:执行测试配置文件]
![图片 B14502_08_28.jpg]()
图 8.28:执行测试配置文件
-
将
lein-ancient添加到用户全局配置文件中。在~/.lein/profiles.clj中添加以下内容:{:user {:plugins [[lein-ancient "0.6.15"]] :dependencies [[clojure-humanize "0.2.2"]]}}检查过时依赖项应显示以下内容:
![图 8.29:检查过时依赖项]
![图片 B14502_08_29.jpg]()
图 8.29:检查过时依赖项
在这个活动中,我们创建了一个应用程序,它能够将 JSON 格式转换为 Clojure 数据格式,并反向转换。为了确保我们的应用程序能够正确运行,我们创建了一个测试配置文件,其中包含了 expectations 库和插件的依赖。为了确保我们所有项目中的库都不会过时,我们在用户全局配置文件中包含了 lein-ancient 插件。
9. Java 和 JavaScript 的平台互操作性
活动 9.01:图书订购应用程序
解决方案:
-
创建一个新的项目:
lein new app books-app -
导入必要的命名空间:
(ns books-app.core (:require [books-app.utils :as utils]) (:import [java.util Scanner]) (:gen-class)) -
创建一个按年份存储书籍的映射:
(def ^:const books {:2019 {:clojure {:title "Hands-On Reactive Programming with Clojure" :price 20} :go {:title "Go Cookbook" :price 18}} :2018 {:clojure {:title "Clojure Microservices" :price 15} :go {:title "Advanced Go programming" :price 25}}}) -
为存储订单的文件创建一个变量:
(def ^:const orders-file "orders.edn") -
创建一个初始菜单,包含订购书籍和列出订单的选项:
(def input (Scanner. System/in)) (defn- start-app [] "Displaying main menu and processing user choices." (let [run-application (ref true)] (while (deref run-application) (println "\n| Books app |") (println "| 1-Menu 2-Orders 3-Exit |\n") (let [choice (.nextInt input)] (case choice 1 (show-menu) 2 (show-orders) 3 (dosync (alter run-application (fn [_] false))))))))输出如下所示:
![图 9.53:初始菜单的输出]
![图片 B14502_09_53.jpg]()
图 9.53:初始菜单的输出
-
创建一个菜单来显示按年份排序的书籍:
(defn- show-menu [] (println "| Available books by year |") (println "|1\. 2019 2\. 2018 |") (let [choice (.nextInt input)] (case choice 1 (show-year-menu :2019) 2 (show-year-menu :2018))))输出如下所示:
![图 9.54:按年份显示可用书籍的输出]
![图片 B14502_09_54.jpg]()
图 9.54:按年份显示可用书籍的输出
-
创建应用程序的
main方法:(defn -main "Main function to run the app." [& args] (start-app)) -
创建一个将数据保存到文件的函数:
(ns books-app.utils (:require [clojure.java.io :as io]) (:import [java.io PushbackReader])) (defn save-to [location data] (spit location data :append true)) -
创建一个保存订单的函数:
(defn save-book-order [orders-file year prog-lang number price] (save-to orders-file {:year year :prog-lang prog-lang :number number :price price})) -
创建一个计算书籍价格的函数:
(defn calculate-book-price [books title number] (-> (get books title) :price (* number) float)) -
创建一个显示订单确认消息的函数:
(defn display-bought-book-message [title number total] (println "Buying" number title "for total:€" total))输出如下:
![图 9.55:订单确认消息]()
图 9.55:订单确认消息
-
创建一个显示已购买订单的函数:
(defn display-order [order books] (str "Bought " (:number order) ": " (:title (get (get books (:year order)) (:prog-lang order))) " published in " (name (:year order)) " for €" (:price order)))输出如下:
![图 9.56:显示已购买的订单]()
图 9.56:显示已购买的订单
-
创建一个读取单个订单的函数:
(defn read-one-order [r] (try (read r) (catch java.lang.RuntimeException e (if (= "EOF while reading" (.getMessage e)) ::EOF (throw e))))) -
创建一个检查文件是否存在的函数:
(defn file-exists [location] (.exists (io/as-file location))) -
创建一个从文件中加载订单的函数:
(defn load-orders "Reads a sequence of orders in file at path." [file] (if (file-exists file) (with-open [r (PushbackReader. (io/reader file))] (binding [*read-eval* false] (doall (take-while #(not= ::EOF %) (repeatedly #(read-one-order r)))))) [])) -
创建一个子菜单来订购书籍:
(ns coffee-app.core) (defn- show-year-menu [year] (let [year-books (get books year)] (println "| Books in" (name year) "|") (println "| 1\. " (:title (:clojure year-books)) " 2\. " (:title (:go year-books)) "|") (let [choice (.nextInt input)] (case choice 1 (buy-book year :clojure) 2 (buy-book year :go)))))输出如下:
![图 9.57:书籍订购子菜单]()
图 9.57:书籍订购子菜单
-
创建一个按年份购买书籍的函数:
(defn- buy-book [year prog-lang] (println "How many books do you want to buy?") (let [choice (.nextInt input) price (utils/calculate-book-price (get books year) prog-lang choice)] (utils/save-book-order orders-file year prog-lang choice price) (utils/display-bought-book-message (:title (get (get books year) prog-lang)) choice price)))输出如下:
![图 9.58:按年份购买书籍的函数]()
图 9.58:按年份购买书籍的函数
-
创建一个按年份显示订单的函数:
(defn- show-orders-by-year [year] (println "\n") (doseq [order (filter #(= year (:year %)) (utils/load-orders orders- file))] (println (utils/display-order order books)))) -
创建一个子菜单来列出订单:
(defn show-orders [] (println "| Books by publish year |") (println "|1\. 2019 2\. 2018 |") (let [choice (.nextInt input)] (case choice 1 (show-orders-by-year :2019) 2 (show-orders-by-year :2018))))输出如下:
![图 9.59:创建子菜单]()
图 9.59:创建子菜单
在这个活动中,我们创建了一个用于订购书籍和显示订单的应用程序。我们利用我们对 I/O 和 Java 的新知识来完成这个活动。
一旦完成活动,你应该得到类似以下输出。
初始菜单:

图 9.60:初始菜单
列出年份:

图 9.61:列出年份
一年的书籍:

图 9.62:2019 年购买的书籍
询问购买多少本书:

图 9.63:询问购买书籍的数量
订单确认消息:

图 9.64:订单确认消息
列出已购买的书籍:

图 9.65:列出已购买的书籍
在本节中,我们利用我们对 Java 互操作性的知识创建了一个命令行应用程序。在下一节中,我们将学习如何在 ClojureScript 中使用 JavaScript。
活动九.02:创建支持台
解决方案:
-
创建一个新的项目:
lein new figwheel-main support-desk -
在
project.clj中将jayq和cuerdas库作为依赖项添加::dependencies [[org.clojure/clojure "1.9.0"] [org.clojure/clojurescript "1.10.520"] [funcool/cuerdas "2.2.0"] [jayq "2.5.4"] [rum "0.11.2"]] -
创建一个
utils函数来按优先级过滤问题列表:(ns support-desk.utils) (defn get-priorities-list [list priority] (filter #(<= (:priority %) priority) list)) -
创建一个获取排序问题列表的
utils函数:(defn get-sorted-priorities-list [list] (sort-by :priority list)) -
创建一个根据问题数量获取排序信息的
utils函数:(defn get-sort-message [items-count] (str (cond (< items-count 3) "little" (< items-count 6) "medium" :else "many") " (" items-count ")"))对于
0个问题,输出如下:Sorting done: little (0) times对于
3个问题,输出如下:Sorting done: medium (3) times -
创建一个
utils函数来从列表中删除问题:(defn delete-item-from-list-by-title [title list] (remove #(= title (:title %)) list)) -
创建一个在排序完成后调用的
utils函数:(defn handle-sort-finish [state] (fn [ev ui] (swap! state update-in [:sort-counter] inc))) -
将 jQuery 和 jQuery UI 添加到
index.html:<script src="img/jquery-3.4.1.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script> <script src="img/code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script> -
将
jayq、cuerdas和utils导入到核心命名空间:(ns ^:figwheel-hooks support-desk.core (:require [cuerdas.core :as str] [goog.dom :as gdom] [jayq.core :as jayq :refer [$]] [rum.core :as rum] [support-desk.utils :as utils])) -
如下定义优先级列表:
(def priorities-list [{:title "IE bugs" :priority 2} {:title "404 page" :priority 1} {:title "Forgotten username" :priority 2} {:title "Login token" :priority 1} {:title "Mobile version" :priority 3} {:title "Load time" :priority 5}]) -
如下定义
app-state:(defonce app-state (atom {:sort-counter 0 :items (utils/get-sorted-priorities-list (utils/get-priorities-list priorities-list 3))})) -
定义
counterRum 组件:(rum/defc counter [number] [:div (str/format "Sorting done: %s times" (utils/get-sort-message number))]) -
创建
click函数的问题:(defn done-button-click [item] (swap! app-state update-in [:items] #(utils/delete-item-from-list-by- title (:title item) %))) -
在 Rum 组件中定义问题项:
(rum/defc item [item] [:li.ui-state-default {:key (:title item)} (str/format "Priority %s for: %s " (:priority item) (:title item)) [:button.delete {:on-click #(done-button-click item)} "Done"]])输出如下:
![图 9.66:在 Rum 组件中定义问题项]()
图 9.66:在 Rum 组件中定义问题项
-
定义
reactive问题项组件:(rum/defc items < rum/reactive [num] [:ul#sortable (vec (for [n num] (item n)))]) -
定义响应式页面
content组件:(rum/defc content < rum/reactive [] [:div {} (items (:items (deref app-state))) (counter (:sort-counter (rum/react app-state)))]) -
使项目组件可排序:
(defn attrs [a] (clj->js (sablono.util/html-to-dom-attrs a))) (defn make-sortable [] (.sortable ($ (str "#sortable")) (attrs {:stop (utils/handle-sort-finish app-state)}))) -
挂载页面组件:
(defn mount [el] (rum/mount (content) el)) (defn mount-app-element [] (when-let [el (get-app-element)] (mount el))) -
调用
mount函数:(mount-app-element) -
调用
sortable函数:(make-sortable) -
运行应用程序:
lein fig:build初始问题列表将如下所示:
![图 9.67:初始问题列表]()
图 9.67:初始问题列表
排序后的问题列表将如下所示:

图 9.68:排序后的问题列表
解决三个问题后的问题列表将如下所示:

图 9.69:解决问题后的问题列表
在这个活动中,我们创建了一个支持台应用程序。该应用程序显示问题列表。问题可以排序和解决。我们使用了 JavaScript 互操作性来添加排序功能。
10. 测试
活动十.01:为咖啡订购应用程序编写测试
解决方案:
-
导入测试命名空间:
(ns coffee-app.utils-test (:require [clojure.test :refer :all] [coffee-app.core :refer [price-menu]] [coffee-app.utils :refer :all])) -
使用
clojure.test库创建测试以显示订单消息。 -
使用
is宏测试应用程序:(deftest display-order-test (testing "Multiple tests with is macro" (is (= (display-order {:number 4 :price 3.8 :type :latte}) "Bought 4 cups of latte for €3.8")) (is (= (display-order {:number 7 :price 6.3 :type :espresso}) "Bought 7 cups of espresso for €6.3")))) -
使用
are宏进行测试:(deftest display-order-test (testing "Multiple tests with are macro" (are [order result] (= (display-order order) result) {:number 2 :price 1.5 :type :latte} "Bought 2 cups of latte for €1.5" {:number 3 :price 6.3 :type :mocca} "Bought 3 cups of mocca for €6.3" {:number 8 :price 10 :type :espresso} "Bought 8 cups of espresso for €10"))) -
使用
clojure.test库检查文件是否存在。测试文件不存在的情况:
(deftest file-exists-test (testing "File does not exist" (testing "Multiple tests with is macro" (is (false? (file-exists "no-file"))) (is (false? (file-exists "missing-file")))) (testing "Multiple tests with are macro" (are [file] (false? (file-exists file)) "eettcc" "tmp-tmp" "no-file-here"))))测试文件是否存在:
(deftest file-exists-test (testing "File does exist" (testing "Multiple tests with is macro" (is (file-exists "/etc")) (is (file-exists "/lib"))) (testing "Multiple tests with are macro" (are [file] (true? (file-exists file)) "/etc" "/var" "/tmp")))) -
使用
clojure.test库创建测试以保存和加载订单。保存订单:
(defn uuid [] (str (java.util.UUID/randomUUID))) (deftest saves-coffee-order (testing "Saves cofee order" (let [test-file (str "/tmp/" (uuid) ".edn") test-data {:type :latte, :number 2, :price 2.6}] (save-coffee-order test-file :latte 2 2.6) (is (= (list test-data) (load-orders test-file))))))加载空订单:
(deftest loads-empty-vector-from-not-existing-file (testing "saving and loading" (let [test-file (str "/tmp/" (uuid) ".edn")] (is (= [] (load-orders test-file))))))加载咖啡订单:
(deftest can-save-and-load-some-data (testing "saving and loading" (let [test-file (str "/tmp/" (uuid) ".edn") test-data {:number 1 :type :latte}] (save-to test-file test-data) (is (= (list test-data) (load-orders test-file))))))输出如下:
![图 10.57:保存和加载订单后的输出]()
图 10.57:保存和加载订单后的输出
-
使用期望库创建测试以显示订单消息:
(ns coffee-app.utils-test-expectations (:require [coffee-app.utils :refer :all] [expectations :refer [expect side-effects]])) (expect "Bought 4 cups of latte for €3.8" (display-order {:number 4 :price 3.8 :type :latte})) (expect "Bought 7 cups of espresso for €6.3" (display-order {:number 7 :price 6.3 :type :espresso})) (expect String (display-order {:number 7 :price 6.3 :type :espresso})) (expect #"Bought 7 cups" (display-order {:number 7 :price 6.3 :type :espresso})) (expect #"cups of espresso" (display-order {:number 7 :price 6.3 :type :espresso})) (expect #"for €6.3" (display-order {:number 7 :price 6.3 :type :espresso})) -
使用
Expectations库检查文件是否存在:(expect true (file-exists "/tmp")) (expect false (file-exists "no-file")) (expect Boolean (file-exists "etc")) -
使用
Expectations库创建测试以保存和加载订单。将数据保存到文件中:
(expect [["/tmp/menu.edn" {:type :latte :number 1 :price 2.4} :append true] ["/tmp/menu.edn" {:type :latte :number 3 :price 4.7} :append true]] (side-effects [spit] (save-to "/tmp/menu.edn" {:type :latte :number 1 :price 2.4}) (save-to "/tmp/menu.edn" {:type :latte :number 3 :price 4.7})))保存咖啡订单:
(expect [["/tmp/orders.edn" :latte 1 2.4] ["/tmp/orders.edn" :latte 2 3.9]] (side-effects [save-coffee-order] (save-coffee-order "/tmp/orders.edn" :latte 1 2.4) (save-coffee-order "/tmp/orders.edn" :latte 2 3.9)))保存咖啡数据:
(expect [["/tmp/coffees.edn" {:type :latte :number 1 :price 2.4}] ["/tmp/coffees.edn" {:type :latte :number 2 :price 3.9}]] (side-effects [save-to] (save-coffee-order "/tmp/coffees.edn" :latte 1 2.4) (save-coffee-order "/tmp/coffees.edn" :latte 2 3.9))) Load orders: (expect [] (load-orders "/tmp/data.edn"))输出如下:
![图 10.58:使用 Expectations 库进行测试]()
图 10.58:使用 Expectations 库进行测试
-
使用
Midje库显示订单消息:(ns coffee-app.utils-test-midje (:require [coffee-app.utils :refer :all] [midje.sweet :refer :all])) (facts "Passing an order should return display message" (fact (display-order {:number 4 :price 3.8 :type :latte}) => "Bought 4 cups of latte for €3.8") (fact (display-order {:number 7 :price 6.3 :type :espresso}) => "Bought 7 cups of espresso for €6.3")) (facts "Returned message should match regular expression" (fact (display-order {:number 7 :price 6.3 :type :espresso}) => #"Bought 7 cups") (fact (display-order {:number 7 :price 6.3 :type :espresso}) => #"cups of espresso") (fact (display-order {:number 7 :price 6.3 :type :espresso}) => #"for €6.3")) -
使用
Midje库检查文件是否存在:(facts "True should be returned when a file exists" (fact (file-exists "/tmp") => true) (fact (file-exists "/etc") => true)) (facts "False should be returned when a file does not exist" (fact (file-exists "no-file") => false) (fact (file-exists "missing-file") => false)) -
使用
Midje库创建测试以加载订单:(facts "Empty vector should be returned when there is no orders file" (fact (load-orders "/tmp/data.edn") => []) (fact (load-orders "/tmp/no-data.edn") => []))输出如下:
![图 10.59:使用 Midje 库加载订单的测试]()
图 10.59:使用 Midje 库加载订单的测试
-
使用
test.check创建测试以显示订单消息。导入test.check命名空间:(ns coffee-app.utils-test-check (:require [coffee-app.utils :refer :all] [clojure.test.check :as tc] [clojure.test.check.generators :as gen] [clojure.test.check.properties :as prop] [clojure.test.check.clojure-test :refer [defspec]])) -
测试显示订单功能:
(defspec display-order-test-check 1000 (prop/for-all [order (gen/fmap (fn [[number type price]] {:number number :type type :price price}) (gen/tuple (gen/large-integer* {:min 0}) gen/keyword (gen/double* {:min 0.1 :max 999 :infinite? false :NaN? false} )))] (= (str "Bought " (:number order) " cups of " (name (:type order)) " for €" (:price order)) (display-order order)))) -
使用
test.check创建测试以检查文件是否存在:(defspec file-exists-test-check 1000 (prop/for-all [file gen/string-alphanumeric] (false? (file-exists file)))) -
使用
test.check创建测试以加载订单:(defspec load-orders-test-check 1000 (prop/for-all [file gen/string-alphanumeric] (vector? (load-orders file))))输出如下:
![图 10.60:使用 test.check 创建加载订单的测试]
![图 B14502_10_60.jpg]
![图 10.60:使用 test.check 创建加载订单的测试]
在这个活动中,我们为咖啡订购应用程序创建了一个测试套件。我们使用四个单元测试库编写了测试。我们首先使用 clojure.test 编写测试,然后是 Expectations 和 Midje 的测试。最后,我们使用 test.check 库编写了基于属性的测试。
活动 10.02:带测试的支持台应用程序
解决方案:
-
将测试依赖项添加到
project.clj文件中::dependencies [[org.clojure/test.check "0.10.0"]] -
将命名空间导入到
core_test.cljs文件中:(ns support-desk.core-test (:require [cljs.test :refer-macros [are deftest is testing use-fixtures]] [clojure.test.check.generators :as gen] [clojure.test.check.properties :refer-macros [for-all]] [clojure.test.check.clojure-test :refer-macros [defspec]] [cuerdas.core :as str] [support-desk.utils :refer [delete-item-from-list-by-title get-priorities-list get-sort-message get-sorted-priorities-list handle-sort-finish]])) -
在应用程序状态中创建带有问题的固定值:
(ns support-desk.core-test) (use-fixtures :each {:before (fn [] (do (def priorities-list [{:title "IE bugs" :priority 2} {:title "404 page" :priority 1} {:title "Forgotten username" :priority 2} {:title "Login token" :priority 1} {:title "Mobile version" :priority 3} {:title "Load time" :priority 5}]) (def app-state (atom {:sort-counter 0}))))}) -
使用
cljs.test编写对排序消息函数的测试:(deftest get-sort-message-test (testing "Using is macro" (is (= "little (1)" (get-sort-message 1))) (is (= "medium (4)" (get-sort-message 4))) (is (= "many (8)" (get-sort-message 8)))) (testing "Using are macro" (are [result number] (= result (get-sort-message number)) "little (1)" 1 "little (2)" 2 "medium (3)" 3 "medium (4)" 4 "medium (5)" 5 "many (6)" 6))) -
使用
test.check编写对排序消息函数的测试:(defspec get-sort-message-test-check 10 (for-all [count gen/nat] (= (str/format "%s (%s)" (cond (< count 3) "little" (< count 6) "medium" :else "many") count) (get-sort-message count)))) -
使用
cljs.test编写对按优先级过滤问题函数的测试:(deftest get-priorities-list-test (testing "Testing filtering priorities based on priority number" (is (= [] (get-priorities-list priorities-list 0))) (is (= [{:title "404 page", :priority 1} {:title "Login token", :priority 1}] (get-priorities-list priorities-list 1))) (is (= [{:title "IE bugs", :priority 2} {:title "404 page", :priority 1} {:title "Forgotten username", :priority 2} {:title "Login token", :priority 1}] (get-priorities-list priorities-list 2))) (is (= [{:title "IE bugs", :priority 2} {:title "404 page", :priority 1} {:title "Forgotten username", :priority 2} {:title "Login token", :priority 1} {:title "Mobile version", :priority 3}] (get-priorities-list priorities-list 3))))) -
使用
cljs.test编写对排序问题列表的测试:(deftest get-sorted-priorities-list-test (testing "Sorting priorities list" (is (= [{:title "404 page", :priority 1} {:title "Login token", :priority 1} {:title "IE bugs", :priority 2} {:title "Forgotten username", :priority 2} {:title "Mobile version", :priority 3} {:title "Load time", :priority 5}] (get-sorted-priorities-list priorities-list))))) -
使用
cljs.test编写从列表中删除问题的测试:(deftest delete-item-from-list-by-title-test (testing "Passing empty list" (is (= [] (delete-item-from-list-by-title "Login token" []))) (is (= [] (delete-item-from-list-by-title "Login token" nil)))) (testing "Passing valid list" (is (= (delete-item-from-list-by-title "Login token" priorities-list))))) -
使用
cljs.test编写对处理排序函数的测试:(deftest handle-sort-finish-test (testing "Calling fn once" (is (= {:sort-counter 1} ((handle-sort-finish app-state) "event" "object")))) (testing "Calling fn twice" (is (= {:sort-counter 2} ((handle-sort-finish app-state) "event" "object"))))) -
我们将使用命令行来运行测试:
lein fig:test当运行测试时,它们应该显示以下内容:
![图 10.61:运行测试后的输出]
![图 B14502_10_61.jpg]
![图 10.61:运行测试后的输出]
在这个活动中,我们向一个支持台应用程序添加了 ClojureScript 测试。我们使用 cljs.test 编写了单元测试,并使用 test.check 库编写了基于属性的测试。
11. 宏
活动 11.01:网球 CSV 宏
解决方案:
-
下面是扩展代码的一个可能方案:
(with-open [reader (io/reader csv)] (->> (csv/read-csv reader) sc/mappify (sc/cast-with {:winner_games_won sc/->int :loser_games_won sc/->int}) (map #(assoc % :games_diff (- (:winner_games_won %) (:loser_games_won %)))) (filter #(> (:games_diff %) threshold)) (map #(select-keys % [:winner_name :loser_name :games_diff])) doall))这应该被视为最终输出的粗略草图。
-
设置你的项目。
deps.edn文件应该看起来像这样:{:deps {org.clojure/data.csv {:mvn/version "0.1.4"} semantic-csv {:mvn/version "0.2.1-alpha1"} org.clojure/math.numeric-tower {:mvn/version "0.0.4"}}}tennis_macro.clj文件的命名空间声明应该像这样:(ns packt-clj.tennis-macro (:require [clojure.java.io :as io] [clojure.data.csv :as csv] [semantic-csv.core :as sc])) -
宏的调用签名应该像这样:
(defmacro with-tennis-csv [csv casts fields & forms])因为这个宏需要能够处理可变数量的形式,所以我们使用
& forms,这将在宏体内部为我们提供一个形式列表。 -
添加
with-open和->>表达式,并添加那些永远不会改变的线程函数调用。别忘了为reader绑定使用 gensym:(defmacro with-tennis-csv [csv casts fields & forms] '(with-open [reader# (io/reader ~csv)] (->> (csv/read-csv reader#) sc/mappify (sc/cast-with ~casts) ;; TODO: what goes here? doall)))结果表明,如果
sc/cast-with传递了一个空的map,即没有要更改的字段,它就简单地不改变任何东西。另一方面,select-keys做的是相反的事情:如果没有要保留的键,它返回一个空的map。这将需要一些额外的逻辑,以便在没有提供字段时,我们得到所有字段,而不是一个字段都没有。这就是为什么我们还没有包括它的原因。 -
使用 unquote-splice (
~@) 插入线程形式:(defmacro with-tennis-csv [csv casts fields & forms] '(with-open [reader# (io/reader ~csv)] (->> (csv/read-csv reader#) sc/mappify (sc/cast-with ~casts) ~@forms ;; TODO: select-keys doall))) -
我们需要一种方法来有条件地应用
select-keys,取决于是否需要选择字段。有许多解决方法,但可能最简单的是定义select-keys的一个专用版本。我们将称之为maybe-select-keys:(defn maybe-select-keys [m maybe-keys] (if (seq maybe-keys) (select-keys m maybe-keys) m))这允许我们添加一个
map调用,它可以相同,无论是否选择字段:(defmacro with-tennis-csv [csv casts fields & forms] '(with-open [reader# (io/reader ~csv)] (->> (csv/read-csv reader#) sc/mappify (sc/cast-with ~casts) ~@forms (map #(maybe-select-keys % ~fields)) doall)))许多其他针对这部分问题的解决方案可能涉及多次引用字段。在这些情况下,应该使用 gensym:
(let [fields# ~fields] ) -
测试宏。让我们用
blowouts函数试试(这假设 CSV 文件已经被复制到项目目录中):user> (blowouts "match_scores_1991-2016_unindexed_csv.csv" 16) ({:winner_name "Jean-Philippe Fleurian", :loser_name "Renzo Furlan", :games_diff 17} {:winner_name "Todd Witsken", :loser_name "Kelly Jones", :games_diff 17} {:winner_name "Nicklas Kulti", :loser_name "German Lopez", :games_diff 17} {:winner_name "Jim Courier", :loser_name "Gilad Bloom", :games_diff 16} {:winner_name "Andrei Medvedev", :loser_name "Lars Koslowski", :games_diff 17} ;;; etc. )这些是在数据集中最不平衡的胜利。我们的宏似乎有效。
在这里,我们已经获得了一份从 1991 年到 2016 年击败罗杰·费德勒的所有球员名单:
user> (with-tennis-csv "match_scores_1991-2016_unindexed_csv.csv" {} [:winner_name] (filter #(= "Roger Federer" (:loser_name %)))) ({:winner_name "Lucas Arnold Ker"} {:winner_name "Jan Siemerink"} {:winner_name "Andre Agassi"} {:winner_name "Arnaud Clement"} {:winner_name "Yevgeny Kafelnikov"} {:winner_name "Kenneth Carlsen"} {:winner_name "Vincent Spadea"} {:winner_name "Patrick Rafter"} {:winner_name "Byron Black"} ;; .... etc. )
在编写宏时,应该始终问一个问题:这能作为一个函数吗?这里的答案可能介于“是”和“可能”之间。
一种方法可能是编写一个简单的函数,从 CSV 文件中提取所有数据。在通过doall之后,任何类型的转换都是可能的。然而,使用这种解决方案,会失去懒加载评估的好处,这意味着整个 CSV 文件都需要加载到内存中。如果处理步骤之一涉及过滤掉一些比赛,宏解决方案将更有效,因为过滤会在读取整个文件之前发生。
另一种方法可能是使用函数组合。用户将提供一系列函数,这些函数将被包装在一个名为with-open宏内部的单个函数中。这种方法将保留懒加载评估的优势。然而,提供的函数必须以精确的方式编写,可能不会那么清晰。在这里,我们一直在编写以下内容:
(filter #(> (:games_diff %) threshold))
相反,我们可能需要定义一个函数:
(fn [ms] (filter #(> (:games_diff %)) threshold))
这可能不是决定性的问题。一切取决于预期的用途和预期的受众。宏可以提供非常灵活的接口,这可能是选择使用它们的一个重要因素。
当你发现自己因为某种原因重复编写无法轻易封装成函数的代码时,编写宏通常是一个解决方案。在这种情况下,正如练习 11.04中的监控函数,编写宏可能是简化代码最不引人注目的方式。编写宏始终是在增加复杂性的权衡:正如我们在本章开头所说的,宏代码难以调试,它也可能使你的其他代码更难调试。但是,正如常说的,你永远不会需要调试的代码是你不需要编写的代码。所以,如果一个可靠的宏可以帮助你避免编写许多行代码,那么它可能值得。
12. 并发
活动 12.01:一个 DOM Whack-a-mole 游戏
解决方案:
-
使用
lein figwheel创建一个项目:lein new figwheel packt-clj.dom-whackamole -- --rum -
移动到新的
packt-clj.dom-whackamole目录,并启动 ClojureScript REPL:lein figwheel在你的浏览器中,在
localhost:3449/index.html,你应该看到默认的 Figwheel 页面:![图 12.26:默认 Figwheel 页面]()
![图 12.26:默认 Figwheel 页面]()
图 12.26:默认 Figwheel 页面
-
在你的编辑器或 IDE 中打开
dom-whackamole/src/packt-clj/dom-whackamole/core.cljs。这就是你将编写所有剩余代码的地方。 -
定义将决定游戏状态的原子:
(def game-length-in-seconds 20) (def millis-remaining (atom (* game-length-in-seconds 1000))) (def points (atom 0)) (def game-state (atom :waiting)) (def clock-interval (atom nil)) (def moles (atom (into [] (repeat 5 {:status :waiting :remaining-millis 0}))))大多数这些都是相当直观的。当游戏开始时,
clock-interval原子将被设置为 JavaScript 间隔。定义game-length-in-seconds然后乘以 1,000 并不是必需的,但它有助于使我们的代码更易读。moles原子将是一个包含:status和:remaining-millis字段的映射向量。为什么需要写出五个相同的映射,当repeat可以为我们完成这项工作呢?稍后,我们将使用向量中的索引更新地鼠,这就是为什么我们真的想要一个向量,而不是一个列表。repeat本身会返回一个简单的列表。为了避免这种情况,我们使用(into [] …)来确保我们有一个真正的向量。 -
另一种同样有效的方法是将所有这些项包裹在一个单一的原子中,其结构可以像这样:
(def app-state (atom {:points 0 :millis-remaining (* game-length-in-seconds 1000) :game-state :waiting :clock-interval nil :moles (into [] (repeat 5 {:status :waiting :remaining-millis 0}))}))这种方法意味着更改涉及数据访问的大多数函数,但不会从根本上改变游戏的构建方式。
注意
通常,在更复杂的应用中,单一原子方法会更受欢迎。这种方法的缺点是,对原子的任何更改都会导致所有组件更新。如果原子包含的多级映射中只有一部分发生了变化,那么许多这些更新都是无用的。为了避免这种情况,基于 React 的 ClojureScript 框架都有一些方法来跟踪应用程序状态原子中仅一部分的变化。Rum、Om 和 Reagent 都将这些游标称为游标。游标允许组件监听原子状态的特定部分,从而在原子中不相关的部分发生变化时避免不必要的重新渲染。
-
定义用于更改应用程序状态原子的函数:
(defn activate-mole [mole-idx] (swap! moles (fn [ms] (update ms mole-idx #(if (= :waiting (:status %)) {:status :live :remaining-millis 3000} %))))) (defn deactivate-mole [mole-idx] (swap! moles (fn [ms] (assoc ms mole-idx {:status :waiting :remaining-millis 0})))) -
前两个函数相当直接。
activate-mole使用update而不是assoc来测试地鼠是否已经被激活。如果它已经是:live,我们不想将剩余的毫秒数改回 3,000:core.cljs 43 (defn mole-countdown [] 44 (swap! moles 45 (fn [ms] 46 (into [] 47 (map (fn [m] 48 (if (= (:status m) :live) 49 (let [new-remaining (max (- (:remaining-millis m) 100) 0)] 50 (if (pos? new-remaining) 51 (assoc m :remaining-millis new-remaining) 52 {:status :waiting :remaining-millis 0})) 53 m)) 54 ms))))) The full code for this step is available at https://packt.live/2Rmq8aq.这些函数是游戏逻辑的核心。
第一个函数
mole-countdown从任何活跃地鼠的:remaining-millis字段中减去 100。为此,它映射地鼠列表。如果一个地鼠不是:live,它就会被忽略。(我们在这里不能使用filter,因为我们不想消除非活跃的地鼠;我们只想忽略它们。)如果一个地鼠是:live,我们就从剩余时间中减去 100。如果还有时间剩余,我们只需更新地鼠的剩余时间。如果我们达到了零,那么我们将状态改回:waiting。这是玩家在 3 秒内没有点击地鼠的情况。下一个函数
update-moles将在游戏时钟的每个 100 毫秒滴答时被调用。它调用mole-countdown然后检查列表中是否有足够的活跃地鼠。如果没有两个活跃的地鼠,则使用介于 0 和 4 之间的随机索引调用activate-mole。你可能会惊讶我们没有检查我们正在激活的地鼠是否已经是
:live。因为这项检查将每 100 毫秒发生一次(而且 Whack-a-mole 的游戏玩法不需要极端的精度),我们可以避免这样做。如果我们尝试激活一个已经激活的地鼠,什么也不会发生(多亏了我们如何编写activate-mole),我们可以在下一次时钟滴答声时再次尝试。当游戏时钟到达零时,将调用
reset-moles函数。游戏结束后,所有地鼠都会隐藏。最后,
whack!函数实际上是点击处理程序。它通过索引查找地鼠,然后如果地鼠恰好是:live,则调用deactivite-mole,在这种情况下,它还会给玩家的分数加一分。这里需要注意的是,所有这些函数都是直接与原子交互的。它们都使用
deref(通过@读取宏)而不是rum/react。到目前为止,所有这些逻辑都与 Rum 组件无关。 -
编写游戏时钟函数:
(defn clock-tick [] (if (= @millis-remaining 0) (do (reset! game-state :waiting) (reset-moles)) (do (update-moles) (swap! millis-remaining #(- % 100))))) (defn start-clock [] (when @clock-interval (js/clearInterval @clock-interval)) (swap! clock-interval (fn [] (js/setInterval clock-tick 100))))clock-tick函数确定每 100 毫秒发生什么。游戏要么结束(millis-remaining达到零),要么仍在进行。如果游戏结束,我们将重置地鼠并更改游戏状态回:waiting。如果没有,我们调用update-moles来推进它们的内部时间计数器,然后推进全局的millis-remaining原子。start-clock函数正是如此。这里的第一个步骤是检查是否存在现有的间隔并将其停止。我们绝对不希望同时运行多个间隔。(如果你注意到时钟以非常快的速度运行,那可能就是原因。)我们的
swap!调用实际上是一个带有clock-tick函数和 100 毫秒间隔的setInterval调用。 -
现在我们准备编写
start-game函数,该函数将在用户点击“开始”按钮时被调用:(defn start-game [] (start-clock) (reset! game-state :playing) (reset! points 0) (reset! millis-remaining (* game-length-in-seconds 1000))) -
让我们开始编写一些简单的 Rum 组件,它们只是显示当前状态:
(rum/defc clock < rum/reactive [] [:div.clock [:span "Remaining time: "] [:span.time (Math/floor (/ (rum/react millis-remaining) 1000))]]) (rum/defc score < rum/reactive [] [:div.score [:span "Score: "] [:span (rum/react points)]])clock和score视图简单地显示这些值。由于我们不想在时钟上实际显示毫秒数,所以我们将其除以 1,000。而且,我们不想显示像 5.034 这样的时间,所以我们使用 JavaScriptMath库中的floor方法向下取整。(如果你不知道这个 JavaScript 库也不要担心:在这个练习中显示毫秒数是可行的。) -
编写
start-game按钮的组件:(rum/defc start-game-button < rum/reactive [] (if (= (rum/react game-state) :waiting) [:button {:onClick start-game} "Click to play!"] [:div "Game on!"]))start-game-button视图会观察game-state原子,并显示一个“点击开始!”按钮或一条鼓励信息。 -
编写地鼠视图:
(rum/defc single-mole-view [mole-idx {:keys [status remaining-millis]}] [:div {:class [(str "mole " (name status))]} [:a {:onClick (partial whack! mole-idx)} (str "MOLE " (name status) "!")]]) (rum/defc moles-view < rum/reactive [] (let [ms (rum/react moles)] [:div {:class "game moles"} (single-mole-view 0 (first ms)) (single-mole-view 1 (second ms)) (single-mole-view 2 (nth ms 2)) (single-mole-view 3 (nth ms 3)) (single-mole-view 4 (nth ms 4))]))许多重要的游戏逻辑与地鼠相关,因此我们将其拆分为一个单独的
mole组件。请注意,single-mole-view没有使用< rum/reactive混合。这些视图将从其父视图获取所有属性。因此,它们不需要直接对原子的变化做出反应;它们将通过参数接收变化。single-mole-view显示地鼠的状态,:waiting或:live,并设置点击处理程序。我们已将whack!函数设置为在地鼠状态为:waiting时不做任何事情,因此我们在这里不需要添加任何关于这个状态的逻辑。moles-view简单地在一个<div>元素中包装对single-mole-view的调用,并从moles原子提供适当的数据。 -
编写基本视图:
(rum/defc app [] [:div#main [:div.header [:h1 "Welcome to DOM Whack-a-mole"] [:p "When a MOLE goes goes 'live', click on it as fast as you can."] (start-game-button) (clock) (score)] (moles-view)])app视图简单地将所有先前的视图重新组合在一起,同时提供一些额外的展示。 -
确保你的
app视图在文件末尾挂载:(defn on-js-reload [] (rum/mount (app) (.getElementById js/document "app"))) (on-js-reload)调用
on-js-reload确保在浏览器重新加载时再次读取你的代码。现在你可以玩游戏了!:
![图 12.27:玩 DOM Whack-a-mole]()
图 12.27:玩 DOM Whack-a-mole
13. 数据库交互和应用层
活动 13.01:持久化历史网球结果和 ELO 计算
解决方案:
-
在一个新的项目中,从以下依赖项开始:
{:deps {clojure.java-time {:mvn/version "0.3.2"} hikari-cp {:mvn/version "2.8.0"} org.apache.derby/derby {:mvn/version "10.14.2.0"} org.clojure/data.csv {:mvn/version "0.1.4"} org.clojure/java.jdbc {:mvn/version "0.7.9"} semantic-csv {:mvn/version "0.2.1-alpha1"}}} -
在我们的
src目录中,创建以下命名空间:packt-clj.tennis.database packt-clj.tennis.elo packt-clj.tennis.ingest packt-clj.tennis.parse packt-clj.tennis.query -
在数据库命名空间中使用
hikari创建我们的连接池非常简单:(ns packt-clj.tennis.database (:require [hikari-cp.core :as hikari])) (def db {:datasource (hikari/make-datasource {:jdbc-url "jdbc:derby:tennis;create=true"})}) -
我们的 DDL 应该看起来类似于以下内容。字段数据类型可能不同,尽管变化不大:
(def ^:private create-player-ddl "CREATE TABLE player ( id varchar(4) CONSTRAINT PLAYER_ID_PK PRIMARY KEY, full_name varchar(128))") (def ^:private create-tennis-match-ddl "CREATE TABLE tennis_match ( id varchar(32) CONSTRAINT MATCH_ID_PK PRIMARY KEY, tournament_year int, tournament varchar(32), tournament_order int, round_order int, match_order int, winner_id varchar(4) REFERENCES player(id) ON DELETE CASCADE, loser_id varchar(4) REFERENCES player(id) ON DELETE CASCADE)") (def ^:private create-elo-ddl "CREATE TABLE elo ( id int GENERATED ALWAYS AS IDENTITY CONSTRAINT ELO_ID_PK PRIMARY KEY, player_id varchar(4) REFERENCES player(id) ON DELETE CASCADE, rating DECIMAL(6,2))") -
利用
clojure.java.jdbc,我们可以应用模式,注意排序:(ns packt-clj.tennis.database (:require [clojure.java.jdbc :as jdbc] [hikari-cp.core :as hikari])) (defn load [] (jdbc/db-do-commands db [create-player-ddl create-tennis-match-ddl create-elo-ddl])) (require '[packt-clj.tennis.database :as database]) user=> (database/load) (0 0 0) -
这可能是这个活动的更具创造性的方面之一,这意味着有多种方法可以解决这个问题,以下只是其中之一。
在
parse命名空间中,我们首先定义了提取我们感兴趣的每个字段所需的访问器:(ns packt-clj.tennis.parse (:require [clojure.string :as str])) (def ^:private winning-player-accessors {:id :winner_player_id :full_name :winner_name}) (def ^:private losing-player-accessors {:id :loser_player_id :full_name :loser_name}) (def ^:private match-accessors {:id #(str (:match_id %) "-" (:round_order %)) :tournament_year (comp first #(str/split % #"-") :tourney_year_id) :tournament :tourney_slug :tournament_order :tourney_order :round_order :round_order :match_order :match_order :winner_id :winner_player_id :loser_id :loser_player_id})每个先前的定义都是一个
target-key(即,我们想在数据结构中存储值的位置)到accessor(即,一个单参数函数,给定一个行,将提取、格式化和聚合字段,如所需)的映射。 -
然后,我们可以定义一个函数,该函数将对任何给定的行执行这些访问器的应用:
(defn apply-accessors [row accessors] (reduce-kv (fn [acc target-key accessor] (assoc acc target-key (accessor row))) {} accessors)) -
可以定义一些命名良好的辅助函数来执行每个目标结构的提取,这些结构在简单的
parse-row函数中组合:(defn extract-winning-player [row] (apply-accessors row winning-player-accessors)) (defn extract-losing-player [row] (apply-accessors row losing-player-accessors)) (defn extract-match [row] (apply-accessors row match-accessors)) (defn parse-row [row] {:winning-player (extract-winning-player row) :losing-player (extract-losing-player row) :match (extract-match row)}) -
最后,我们可以将这些组合到我们的历史函数中,如下所示,添加相关的
requires:(ns packt-clj.tennis.parse (:require [clojure.data.csv :as csv] [clojure.java.io :as io] [clojure.string :as str] [semantic-csv.core :as sc])) (defn new-player? [seen candidate] (not (seen (:id candidate)))) (defn historic [file-path] (->> (io/reader file-path) (csv/read-csv) sc/mappify (reduce (fn [{:keys [player-ids-seen] :as acc} row] (let [{:keys [winning-player losing-player match]} (parse-row row) new-players (filter #(new-player? player-ids-seen %) [winning-player losing-player])] (-> acc (update-in [:data :players] into new-players) update-in [:data :matches] conj match) (update :player-ids-seen into (map :id new-players))))) {:player-ids-seen #{} :data {:players [] :matches []}}) :data))我们定义的
reduce函数首先将传入的行解析为三个我们感兴趣的目标数据结构:玩家(winning和losing)以及比赛本身。然后我们检查确保我们只会在我们没有看到过玩家的情况下持久化玩家。我们通过使用命名适当的helper函数与player-ids-seen集合进行检查来完成此操作。最后,我们使用 thread-first 宏来维护我们的累加器,添加新的玩家/比赛,并维护我们已经处理过的玩家 ID 集合,在提取映射的
:data部分之前。 -
在
ingest命名空间中,一个简单的parse/historic调用,以及在我们的let绑定中的解构,就足以提取我们将要插入到db变量中的玩家和比赛:(ns packt-clj.tennis.ingest (:require [packt-clj.tennis.parse :as parse] [clojure.java.jdbc :as jdbc])) (defn historic [db file-path] (let [{:keys [players matches]} (parse/historic file-path)] (jdbc/insert-multi! db :player players) (jdbc/insert-multi! db :tennis_match matches))) -
在将
match_scores_1991-2016_unindexed_csv.csv文件放入resources目录后,我们现在可以摄取我们的历史数据,并执行一些基本检查,以查看我们的player和tennis_match计数是否如下匹配:(require '[packt-clj.tennis.ingest :as ingest] '[clojure.java.jdbc :as jdbc] '[clojure.java.io :as io] '[packt-clj.tennis.database :as database]) user=> (ingest/historic database/db (io/file "packt-clj/resources/match_scores_1991-2016_unindexed_csv.csv")) user=> (jdbc/query database/db ["select count(*) from player"]) => ({:1 3483}) user=> (jdbc/query database/db ["select count(*) from tennis_match"]) => ({:1 95359}) -
在
query命名空间中提取所有网球比赛的 SQL 相当简单;然而,应注意round_order desc。由于round_order随着比赛的进行而减少,我们必须将其排序为逆序:(ns packt-clj.tennis.query (:require [clojure.java.jdbc :as jdbc])) (defn all-tennis-matches [db] (jdbc/query db ["select * from tennis_match order by tournament_year, tournament_order, round_order desc, match_order"])) -
利用我们的第五章中的函数多对一:减少,我们的
elo命名空间开始如下:(ns packt-clj.tennis.elo (:require [clojure.java.jdbc :as jdbc] [packt-clj.tennis.query :as query]))= (def k-factor 32) (defn match-probability [player-1-rating player-2-rating] (/ 1 (+ 1 (Math/pow 10 (/ (- player-2-rating player-1-rating) 1000))))) (defn recalculate-rating [previous-rating expected-outcome real-outcome] (+ previous-rating (* k-factor (- real-outcome expected-outcome)))) -
计算所有比赛的 ELO 评分可以如下实现。首先,我们可以定义两个辅助函数,第一个函数产生预期结果的元组。由于概率必须加起来为 1,我们可以计算一个概率,然后从 1 中减去以得到另一个概率:
(defn- expected-outcomes [winner-rating loser-rating] (let [winner-expected-outcome (match-probability winner-rating loser-rating)] [winner-expected-outcome (- 1 winner-expected-outcome)])) -
然后,我们在第二个辅助函数的主体中解构元组,这使得我们可以计算每个玩家的新的评分,并返回一个在
calculate-all中解构的更新后的玩家数据结构:(defn- calculate-new-ratings [current-player-ratings {:keys [winner_id loser_id]}] (let [winner-rating (get current-player-ratings winner_id 1000) loser-rating (get current-player-ratings loser_id 1000) [winner-expected-outcome loser-expected-outcome] (expected-outcomes winner-rating loser-rating)] [{:player_id winner_id :rating (recalculate-rating winner-rating winner-expected-outcome 1)} {:player_id loser_id :rating (recalculate-rating loser-rating loser-expected-outcome 0)}])) -
最后,我们解构调用
calculate-new-ratings的结果,提取winner和loserID,以便我们可以更新下一次迭代的current-player-ratings数据结构:(defn calculate-all [db] (->> (query/all-tennis-matches db) (reduce (fn [{:keys [current-player-ratings] :as acc} match] (let [[{winner-id :player_id :as new-winner-rating} {loser-id :player_id :as new-loser-rating}] (calculate-new-ratings current-player-ratings match)] (-> acc (update :elo-ratings into [new-winner-rating new-loser-rating]) (assoc-in [:current-player-ratings winner-id] (:rating new-winner-rating)) (assoc-in [:current-player-ratings loser-id] (:rating new-loser-rating))))) {:elo-ratings [] :current-player-ratings {}}) :elo-ratings))使用当前比赛的
winner_id和loser_id,如果它们在累加器(默认为1000)中找不到,我们可以查找它们现有的评分。接下来,我们使用之前描述的函数确定预期的结果。一旦我们有了这些,我们就可以将其插入到recalculate-rating函数中,并将更新的值存储在累加器中,以便下一次迭代。current-player-rating实际上是一个内存缓存;我们不想只为了再次查找而将评分持久化到数据库中。 -
在
persist-all函数中执行单个jdbc/insert-multi!调用比边持久化更高效:(defn persist-all [db] (let [elo-ratings (calculate-all db)] (jdbc/insert-multi! db :elo elo-ratings))) user=>(require '[packt-clj.tennis.elo :as elo] '[packt-clj.tennis.query :as query]) nil user=> (elo/persist-all database/db) -
提取所有名称和评分所需的 SQL 相当简单。附加一个
result-set-fn函数,该函数逐个遍历结果,直观且简单,尽管比原始 SQL 方法效率低:(defn select-max-elo [db] (jdbc/query db ["select p.full_name, e.rating from player p, elo e where p.id = e.player_id"] {:result-set-fn (fn [rs] (reduce (fn [{:keys [max-rating] :as acc} {:keys [full_name rating]}] (cond-> acc (< max-rating rating) (assoc :max-rating rating :player-name full_name))) {:max-rating Integer/MIN_VALUE :player-name nil} rs))}))我们首先定义最大评分为最小的整数,保证它不会出现在我们的最终结果中!通过简单比较现有最高评分与候选评分,我们可以确定是否使用条件线程首先宏更新我们的累加器。
-
最后,让我们确认具有最高 ELO 值的玩家是否符合预期:
user => (query/select-max-elo database/db) => {:max-rating 2974.61M, :player-name "Novak Djokovic"}
太棒了!我们已经成功构建了一个应用层,使我们能够摄取、查询和计算我们的大型网球结果 CSV 数据集。任何新加入应用的人应该能够仅从命名空间中理解其目的。在命名空间上下文中,每个单独函数的意图也应该清晰。
14. HTTP 与 Ring
活动十四点零一:通过 REST 公开历史网球结果和 ELO 计算
解决方案:
-
在
packt-clj.tennis的deps.edn文件中添加以下依赖项:{:deps {.. clj-http {:mvn/version "3.10.0"} compojure {:mvn/version "1.6.1"} metosin/muuntaja {:mvn/version "0.6.4"} org.clojure/data.json {:mvn/version "0.2.6"} ring/ring-core {:mvn/version "1.7.1"} ring/ring-jetty-adapter {:mvn/version "1.7.1"}} -
使用以下
require路由创建我们的命名空间:(ns packt-clj.tennis.api (:require [clojure.edn :as edn] [compojure.core :refer [context defroutes GET PUT]] [compojure.route :as route] [muuntaja.middleware :as middleware] [packt-clj.tennis.database :as database] [packt-clj.tennis.elo :as elo] [packt-clj.fitness.ingest :as ingest] [packt-clj.tennis.query :as query] [ring.adapter.jetty :refer [run-jetty]] [ring.middleware.params :as params])) -
用于公开我们的球员资源和他们参与的网球比赛的所需路由如下:
(defroutes routes (context "/players" [] (GET "/" [] {:body (query/all-players database/db)}) (GET "/:id" [id] (when-first [user (query/player database/db id)] {:body user})) (GET "/:id/tennis-matches" [id] {:body (query/tennis-matches-by-player database/db id)})) (route/not-found "Not found")) -
参考的
query函数定义在query命名空间中,如下所示:(defn all-players [db] (jdbc/query db ["select * from player"])) (defn player [db id] (jdbc/query db [(str "select * from player where id = '" id "'")])) (defn tennis-matches-by-player [db id] (jdbc/query db [(str "select * from tennis_match where winner_id = '" id "' or loser_id = '" id "'")])) -
我们的
run函数看起来与我们之前使用的相似,利用了wrap-format和wrap-params中间件:(defn run [] (run-jetty (-> routes middleware/wrap-format params/wrap-params) {:port 8080 :join? false})) -
我们可以在
players上下文中添加以下路由以检索 ELO,以及从我们的query命名空间中提取它的方法:(GET "/:id/elo" [id] (when-first [elo (query/player-elo database/db id)] {:body elo})) (defn player-elo [db id] (jdbc/query db [(str "select e.rating, e.id from elo e, player p where e.player_id = p.id and p.id = '" id "' and e.id in (select max(e2.id) from elo e2 where e2.player_id = '" id "')")])) -
定义一个新的
tennis-matches上下文,以及新的query/tennis-match函数:(context "/tennis-matches" [] (GET "/" [] {:body (query/all-tennis-matches database/db)}) (GET "/:id" [id] (when-first [tennis-match (query/tennis-match database/db id)] {:body tennis-match}))) (defn tennis-match [db id] (jdbc/query db [(str "select * from tennis_match where id = '" id "'")]))可选地,如果我们从一个干净的数据库开始,我们可以使用以下方式填充相关数据:
(require '[packt-clj.tennis.database :as database] '[packt-clj.tennis.ingest :as ingest] '[packt-clj.tennis.elo :as elo]) (database/load) (ingest/historic database/db "./resources/match_scores_1991-2016_unindexed_csv.csv") (elo/persist-all database/db) -
启动 Web 服务器后,我们可以使用浏览器检索当前的 ELO 作为参考:
(require '[packt-clj.tennis.api :as api]) (def app (api/run))输出如下:
![图 14.22:打印 Sampras 的当前评级]()
图 14.22:打印 Sampras 的当前评级
打印
Djokovic的当前评级:![图 14.23:打印 Djokovic 的当前评级]()
图 14.23:打印 Djokovic 的当前评级
-
我们使用
PUT定义我们的tennis-match创建路由,因为我们事先知道 ID。这被添加到tennis-matches上下文中。我们必须查询比赛中涉及的两个球员的 ELO,然后为每个球员创建一个新的记录,并更新他们的 ELO。这需要一个新的ingest/tennis-match和elo/persist函数,如下所示。注意,由于我们现在需要在elo命名空间外使用它,elo/calculate-new-ratings函数现在应标记为公共的(使用defn而不是defn-):(defn tennis-match [db tennis-match] (first (jdbc/insert! db :tennis_match tennis-match))) (defn persist [db elo-ratings] (jdbc/insert-multi! db :elo elo-ratings)) (PUT "/:id" req (let [id (-> req :params :id) {:keys [winner_id loser_id] :as tennis-match} (assoc (edn/read-string (slurp (:body req))) :id id) [{winner-elo :rating}] (query/player-elo database/db winner_id) [{loser-elo :rating}] (query/player-elo database/db loser_id) new-player-ratings (elo/clj-http to submit a PUT instruction to our web service as follows:(.stop app)
(def app (api/run))
(require '[clj-http.client :as http])
(http/put "http://localhost:8080/tennis-matches/2019-1-d643-s403-5"
{:body (pr-str {:tournament_year 2019,
:tournament "umag",
:tournament_order 1,
:round_order 5,
:match_order 1,
:winner_id "d643",
:loser_id "s402"})})
-
由于 Sampras 在我们的虚构比赛中输了,我们看到他的 ELO 略有下降,而 Djokovic 的 ELO 有所上升:
![图 14.24:打印 Sampras 的评级]()
图 14.24:打印 Sampras 的评级
以下是 Djokovic 的评级:

图 14.25:打印 Djokovic 的当前评级
因此,通过完成这个活动,我们已经通过 REST Web 服务使我们的应用层更广泛地可用。
15. 前端:ClojureScript UI
活动 15.01:从互联网显示图片网格
解决方案:
-
在命令行提示符下,使用以下 Leiningen 命令创建一个新的 Figwheel 项目:
lein new figwheel packt-clj.images -- --reagent -
移动到
packt-clj.images/目录并输入:lein figwheel几秒钟后,你的浏览器应该会打开默认的 Figwheel 页面:
![图 15.8:一个等待你编写代码的新鲜 ClojureScript 项目]()
图 15.8:一个等待你编写代码的新鲜 ClojureScript 项目
-
在你喜欢的编辑器中打开
src/packt_clj/images/core.cljs文件并修改代码:(ns packt-clj.images.core (:require [reagent.core :as r])) -
Reagent 常用的别名是
r而不是 Reagent:(defonce app-state (r/atom {:images [] :author-display true}))app-state由两部分数据组成:我们获取的图片向量,最初是一个空向量,以及是否显示作者姓名,最初为 true。 -
让我们创建一个按钮,从 HTTP 端点获取图片并更新
app-state中的:images值。我们需要两个处理器:fetch-images,它将图片向量更新到app状态中的:images,以及clear-images,它将空向量更新到app-state中的:images:(defn fetch-images [] (-> (js/fetch "https://picsum.photos/v2/list?limit=6") (. then (fn [response] (.json response))) (. then (fn [json] (swap! app-state assoc-in [:images] (js->clj json :keywordize-keys true)))))) (defn clear-images [] (swap! app-state assoc-in [:images] [])) -
下面是
fetch-or-clear-button组件的代码:(defn fetch-or-clear-button [] (let [handler (if (empty? (:images @app-state)) fetch-images clear-images) text (if (empty? (:images @app-state)) "Fetch Images" "Clear Images")] [:button.btn {:on-click handler} text])) -
我们通过使用
:button.btn简短的 Hiccup 语法将btn类应用到按钮上。btn类在resources/public/css/style.css中定义:.btn { padding: 7px 20px; cursor: pointer; margin-left: 10px; margin-bottom: 10px; border: 1px solid gray; } -
让我们构建一个
image组件和一个image-grid组件:(defn image [{:keys [download_url author]}] [:div [:img {:src download_url :height "130px" :style {:border "solid gray 3px" :border-radius "10px"}}] (when (:author-display @app-state) [:div {:style {:font-size "15px" :color "gray" }} (str "Image by ") author])]) (defn image-grid [images] (if (empty? images) [:div "Click the button to fetch images"] (into [:div] (map (fn [image-data] [:div {:style {:float "left" :margin-left "20px"}} [image image-data]]) images)))) -
最后一个组件是一个按钮,可以隐藏或显示作者姓名:
(defn author-display-button [] (let [text (if (:author-display @app-state) "Hide author" "Show author")] [:button.btn {:on-click #(swap! app-state update-in [:author-display] not)} text])) -
现在,我们将所有组件作为
main组件的子组件添加。我们将hello-world函数重命名为app:(defn app [] [:div [fetch-or-clear-button] [author-display-button] [image-grid (:images @app-state)]]) -
最后,我们渲染主组件(命名为
app而不是hello-world):(r/render-component [app] (. js/document (getElementById "app")))如果一切顺利,你应该会看到一个像这样的屏幕:

图 15.9:获取和清除图片的按钮
当你点击“获取图片”按钮时,图片会显示作者的姓名:

图 15.10:获取图片
最后,当你点击“隐藏作者”按钮时,作者姓名会消失:

图 15.11:隐藏作者信息
在这里,我们创建了一个单页应用程序,可以按需加载和清除图片和文本。
活动 15.02:带排名的网球运动员
解决方案:
-
在命令行提示符下,使用以下 Leiningen 命令创建一个新的 Figwheel 项目:
lein new figwheel packt-clj.tennis -- --reagent -
移动到
packt-clj.tennis/目录并输入以下命令:lein figwheel几秒钟后,你的浏览器应该会打开默认的 Figwheel 页面:
![图 15.12:一个等待你编写代码的新鲜 ClojureScript 项目]()
图 15.12:一个等待你编写代码的新鲜 ClojureScript 项目
-
在你喜欢的编辑器中打开
src/packt_clj/tennis/core.cljs文件并修改代码:(ns packt-clj.tennis.core (:require [reagent.core :as r]))app-state由玩家列表和当前选中玩家的详细信息组成。 -
我们从一个空的玩家列表开始:
(defonce app-state (r/atom {:players [] :current-player nil})) -
这里是获取特定玩家服务器上排名数据的代码:
(defn fetch-player [id full_name] (-> (js/fetch (str "http://localhost:8080/players/" id "/elo")) (.then (fn [response] (.json response))) (.then (fn [json] (swap! app-state assoc-in [:current-player] (assoc (js->clj json :keywordize-keys true) :full_name full_name)))))) -
这里是一个显示玩家名称和排名的玩家组件:
(defn player-alone [{:keys [rating full_name]}] [:div (str full_name " has a rating of: " rating)]) -
让我们创建一个按钮,用于清除当前玩家并返回所有玩家的列表:
(defn player-list-button [] [:button.btn {:on-click #(swap! app-state assoc-in [:current-player] nil)} "Display all players"]) -
让我们编写玩家列表的代码。我们首先编写列表元素的组件。内容是玩家的名称。当我们点击它时,它会获取所选玩家的排名数据:
(defn player [{:keys [id full_name]}] [:div [:span [:a {:href "#" :on-click (partial fetch-player id full_name)} full_name]]]) -
我们现在构建
player-list组件。它接收一个玩家向量,并返回一个包含每个玩家组件的:div:(defn player-list [players] (if (empty? players) [:div "Click the button to fetch players"] (into [:div] (map player players)))) -
这是获取玩家数据的函数代码:
(defn fetch-players [] (-> (js/fetch "http://localhost:8080/players/") (.then (fn [response] (.json response))) (.then (fn [json] (swap! app-state assoc-in [:players] (js->clj json :keywordize-keys true)))))) -
我们还需要一个函数,通过修改
app-state来清除玩家列表:(defn clear-players [] (swap! app-state assoc-in [:players] [])) -
现在我们添加一个按钮,该按钮可以清除或填充玩家列表:
(defn fetch-or-clear-button [] (let [handler (if (empty? (:players @app-state)) fetch-players clear-players) text (if (empty? (:players @app-state)) "Fetch Players" "Clear Players")] [:button.btn {:on-click handler} text])) -
现在,我们编写
main组件。当与:current-player关联的值不是nil时,我们显示当前选定的玩家。否则,我们显示玩家列表。这是主app组件的代码:(defn app [] (if (:current-player @app-state) [:div [player-list-button] [player-alone (:current-player @app-state)]] [:div [fetch-or-clear-button] [player-list (:players @app-state)]])) -
最后,我们渲染主组件:
(r/render-component [app] (. js/document (getElementById "app")))
通过完成这些活动,我们了解了如何组织试剂应用程序的不同组件:CSS、HTML 和 cljs 文件。能够迭代修改代码,并且网页能够立即更新而不需要刷新页面,这对我们来说非常有帮助,并且节省了时间。我们学习了如何在 ratom 中存储应用程序的状态,并访问 Reagent 组件的代码状态。


































































































































浙公网安备 33010602011771号