Lisp-的国度-全-
Lisp 的国度(全)
原文:Land of Lisp
译者:飞龙
引言
因此,你决定拿起一本关于 Lisp 的书并阅读前言。也许你惊讶地看到一本看起来像漫画书的东西夹杂在书架上的其他计算机编程书籍中。谁会费心写一本关于像 Lisp 这样奇怪的学术编程语言的漫画书呢?或者也许你听到其他人狂热地谈论 Lisp 语言,心想,“哇,Lisp 真的听起来和其他人谈论的语言很不一样。也许我应该什么时候找一本 Lisp 书。”无论如何,你现在手里拿着一本关于一种非常酷但也很不寻常的编程语言的书籍。

什么是使 Lisp 如此酷且不寻常的原因?
Lisp 是一种非常表达性的语言。Lisp 被设计成让你以最清晰和适当的方式表达最复杂的编程思想。Lispers 有自由以最有助于解决任何手头问题的方式编写程序。
当你编写 Lisp 代码时,指尖上的力量正是使其与众不同的原因。一旦你“理解”了 Lisp,你作为一个程序员的身份将永远改变。即使你余生再也没有写过 Lisp 代码,学习 Lisp 也会从根本上改变你作为一个程序员的状态。
在某种程度上,学习一种典型的编程语言类似于成年人学习外语。假设你明天出去决定你要学习法语。你可能会上你找到的所有法语课程,阅读只有法语的资料,甚至搬到法国去。但无论你做什么,你对法语的理解总是会有些不完美。无论你最终成为多好的法语说话者,在你的梦中,你可能仍然会说着你的母语。
Lisp 是不同的。它不仅仅是学习任何外语。一旦你学会了 Lisp,你甚至会在梦中用 Lisp 思考。Lisp 是一个如此强大的理念,它将取代你之前的编程经验,并成为你的新母语!无论你在任何语言中遇到新的编程思想,你总会对自己说,“那有点像我在 Lisp 中会怎么做,除了……”。这就是只有 Lisp 才能给你的那种力量。

到目前为止,你可能对 Lisp 的了解仅限于至少有一个人(就是我)对此非常兴奋。但你的时间是宝贵的,学习新事物必然需要一些努力。
好消息是 Lisp 并没有表面上看起来那么困难。例如,以下是一个有效的 Lisp 表达式:
(+ 3 (* 2 4))
你能猜出这个表达式的值是多少吗?如果你回答是 11,那么你已经弄懂了如何阅读基本的 Lisp 代码。它写起来就像数学一样,只不过函数——在这个例子中,是加法和乘法——在数字之前,而且所有内容都在括号内。
如果 Lisp 如此出色,为什么更多的人不使用它?
实际上,相当多的大型公司确实使用 Lisp 进行一些严肃的工作(你可以在snipurl.com/e3lv9/找到长长的工业 Lisp 项目列表)。其他编程语言不断“借用”Lisp 的特性,并将它们作为最新的最伟大的想法展示出来。此外,许多人认为将在 Web 的未来发挥重要作用的语义网,使用了大量用 Lisp 编写的工具。
注意
语义网背后的理念是为网站创建一套协议,以便计算机能够确定网页上信息的“含义”。这是通过使用特殊元数据(通常称为资源描述框架,或 RDF)来注释网页,这些元数据链接到常见的词汇表,不同网站可能共享。许多用于处理描述逻辑和 RDF 数据的工具是用 Lisp 编写的(例如,RacerPro 和 AllegroGraph)。
因此,Lisp 肯定有一个光明的未来。但有些人可能会认为学习 Lisp 不值得付出努力。
Lisp 如何获得这种不应得的声誉?
我认为人们在决定生活中哪些事物值得学习时,会遵循一种经验法则。大多数人会在以下三个类别中寻求知识:
-
许多人学习的内容(例如微积分、C++等)
-
容易学习的东西(例如呼啦圈、Ruby 等)
-
有价值且容易欣赏的东西(例如热核物理,或者那种你把手指放在嘴里吹得非常响的哨子)
Lisp 不属于这些类别。它不像微积分那样受欢迎,也不像学习那样容易,也不像那个响亮的哨子那样明显有价值。如果我们遵循这些(通常非常合理的)经验法则,我们就会得出结论,一个理智的人应该远离 Lisp。然而,在 Lisp 的情况下,我们将摒弃这些规则。正如你从阅读这本书中看到的那样,Lisp 给你提供了对计算机编程的深刻见解,每个严肃的程序员都应该有一些使用这种不寻常语言的经验,即使这需要一点努力。
如果你仍然不确信,你可能想看看书末尾的漫画书尾声。你现在可能无法完全理解那里的所有内容,但它会给你一个关于 Lisp 中可用的高级功能和 Lisp 编程与其他类型编程不同的感觉。
Lisp 是从哪里来的?
Lisp 语言家族的历史非常悠久,与其他语言的历史不同。我们需要追溯到很久以前才能找到它的起点。
很久以前(在 20 世纪 40 年代),地球被一个名为泛古洋的巨大海洋覆盖,以及一个名为泛大陆的单个贫瘠陆地。在这个残酷的环境中,第一台计算机程序进化了,是用纯机器语言编写的(或者说“一和零”,就像他们说的)。
这些原型语言与特定的计算机系统紧密相连,如 ENIAC、Zuse Z3 和其他早期的真空管装置。通常,这些早期的计算机非常原始,编程它们只是简单地翻转开关或修补电缆来物理编码每个操作。
这些原型语言的黑暗时期见证了不同计算机架构的大量实验和不同计算机指令集的爆炸式增长。竞争激烈。虽然这些原始语言实验中的大多数最终消失了——成为古代生存斗争的牺牲品——但其中一些却繁荣起来。

在某个时刻,计算机获得了自己的内存来存储程序,以及允许用文本编写程序而不是只用纯数字的原始汇编器。这些汇编语言包括 Short Code、ARC 汇编和 EDSAC 初始指令。
汇编语言使软件开发变得更加高效,使得古代汇编器能够避开这个原始海洋中的众多捕食者。但汇编语言仍然存在显著的局限性。它们总是围绕特定处理器的指令集进行设计,因此它们不能在不同的机器架构之间移植。编程语言需要进化以超越特定机器指令集的局限。
20 世纪 50 年代见证了第一个机器无关的编程语言的诞生。像 Autocode 和信息处理语言这样的语言通过肺和腿实现了这种独立性,同时也通过新的软件类型,如编译器和解释器。
随着编译器和解释器的出现,计算机程序现在可以使用人类友好的语法编写。编译器可以将人类编写的计算机程序自动转换为计算机可执行的机器友好二进制格式。另一方面,解释器直接执行人类编写的程序中描述的操作,而不需要将它们全部转换为机器友好的二进制格式。
首次,程序员可以使用旨在使计算机编程成为一种愉快活动的语言,而无需在计算机硬件的原始级别上进行操作。这些解释和编译的编程语言就是我们今天所认为的第一种“真正”的编程语言。这些早期语言中最令人印象深刻的一种,FORTRAN(于 1957 年开发),在多种架构上得到了广泛支持,并且至今仍在被大量使用。

到目前为止,最成功的语言都是围绕一个中心思想设计的:提供一个通用设计和语法,使编程尽可能容易。然而,设计一个好的编程语言证明是非常困难的。因此,这些语言中的大多数,如 FORTRAN、BASIC 和 C,实际上只是旧想法的大杂烩,相互抄袭,拼凑在一起,缺乏真正的美感。它们通常只在表面上容易使用。尽管如此,这些凶猛的语言在丛林中漫游了数十年,寻找容易捕获的猎物。
在这些可怕的巨兽阴影中,潜伏着一种小而谦逊、完全不同的生物——大多数时候隐藏在视线之外,但几乎从第一台机器无关语言爬上陆地以来就存在。这些语言使用数学语法,如 20 世纪 30 年代数学家开发的 lambda 演算。
这些语言并不关心是否实用或易于新手学习,它们非常智能,并希望推动语言设计的极限。它们提出了关于程序符号、语言语义和最简单可能的语言语法的疑问。
从这些高度智能的数学语法中演变出了最引人注目的生物:最初的 Lisp 编程语言。与大多数其他编程语言不同,它并非源自 FORTRAN 或其他关注实用主义或易用性的语言。它的血统是完全独立的,直接源自数学。但 Lisp 是从哪里来的呢?
有些人声称 Lisp 的起源背后的故事已经永远迷失在时间的迷雾中。其他人(可能更正确)说 Lisp 的创造是约翰·麦卡锡在 1959 年的工作。据说,有一天,他在麻省理工学院召集了他的部落,并提出了一个巧妙的主意。麦卡锡设想了一种完全理论化的编程语言,它将具有最少的语法和语义,但同时又能够创造出极其优雅的程序。这些程序如此优雅,以至于用 Lisp 本身编写 Lisp 的解释器只需要大约 50 行计算机代码!

注意
约翰·麦卡锡发表了论文“符号表达式的递归函数及其通过机器的计算,第一部分”,ACM 通讯(1960 年 4 月):184-195。您可以在www-formal.stanford.edu/jmc/recursive.pdf上阅读它。

当麦卡锡首次发表他的想法时,这只是一个对数学语法的智力探索。但很快,Lisp 语言就发展起来,可以与编译器和解释器一起工作。它现在运行在真正的计算机上,就像 FORTRAN 和其他编程语言一样!但与这些其他语言不同,Lisp 保留了一种从其数学血统中继承的美。
在第一个 Lisp 出现不久之后,第一个 Lisp 程序员也出现了,他们捕捉了这些温顺的生物,并将它们转化为更加精细的编程语言。随着时间的推移,这些程序员将原始的 Lisp 转变为 MACLISP 和 Interlisp 等方言。

虽然早期 Lisp 的狩猎对早期 Lisp 程序员来说是一种成功的业余爱好,但很快,这些猎人就有一个竞争对手:克罗马农人。克罗马农人比和平的 Lisp 程序员更具侵略性,他们使用 COBOL 等可怕的语言攻击更大的软件开发项目。为商业应用开发的 COBOL 是一个丑陋而卑鄙的巨兽,但这对克罗马农人来说却是有利可图的猎物。另一方面,Lisp 程序员更愿意沉思优雅的编程和偶尔的 Lisp 猎捕。
现在,虽然 Lisp 是一个极其强大的想法,但其他编程语言已经在市场份额和更成熟的发展工具方面领先。这使得 Lisp 及其程序员面临挑战,难以获得主流成功的牵引力。然而,温和的 Lisp 程序员并不关心这些琐事。尽管他们的性格各异,但 Lisp 程序员和克罗马农人还是相对和谐地生活在一起。
以他们自己的方式,Lisp 程序员正在蓬勃发展。当时,他们从图像识别、计算机化数据分类和其他属于“人工智能(AI)”一般范畴的问题中受益匪浅。这些问题的高度数学性质使得他们的研究适合 Lisp 方法,Lisp 程序员将这些新的 Lisp 方言构建到更加先进的计算机系统中以应对这些问题。许多人认为这是 Lisp 的黄金时代。
不幸的是,在这段短暂的黄金时期之后,风向突然对可怜的 Lisp 程序员们不利。在 20 世纪 80 年代中期,地球轴线的突然倾斜改变了气候,导致 Lisp 语言所需的生存食物来源短缺。人工智能研究进展的失望导致许多学术研究资助枯竭,而且许多 Lisp 所青睐的硬件(如 Symbolics、Lisp Machine, Inc. 和德州仪器的 Lisp 计算机)在复杂指令集计算机(CISC)和精简指令集计算机(RISC)硬件架构的能力方面落后。世界对 Lisp 和依赖它们的 Lisp 程序员来说变得不再友好。“人工智能寒冬”已经到来,Lisp 命运难逃。
这最终给了克罗马农人在语言竞赛中的绝对优势。1983 年发展起来的新热潮,巨石阵、FORTRAN 衍生的面向对象语言——如 C++——逐渐征服了商业软件开发。这使克罗马农人免受困扰 Lisp 程序员的“人工智能寒冬”的影响。此外,狡猾的克罗马农人借鉴了 Lisp 程序员开创的一些想法来解决主流语言的问题。因此,垃圾回收和参数多态性,最初在 Lisp 中发现,成为主流程序员使用的语言中的常见特性。
最终,通过巨大的努力,古老的编程语言巨兽被克罗马农人驯服成了 C#、Java 和类似的语言。人们产生了这样的信念,即这些语言比过去任何可用的工具都更容易使用,而 Lisp 的黄金时代早已被遗忘。最近,像 Python 和 Ruby 这样的语言进一步将这些克罗马农语言精炼成更现代的方向。
但在这段时间里 Lisp 程序员们发生了什么?他们是否完全屈服于人工智能寒冬?他们是否再次潜伏在阴影中,等待阳光再次照耀的一天?没有人能确切知道。但如果你足够努力地寻找,也许在最高的山脉,最深的丛林,或者在麻省理工学院最底层的地下室里,你可能会瞥见一种奇怪的生物。有些人称之为 Windigo;其他人称之为 Yeti、Sasquatch 或 rms。但真正了解的人认为,它可能真的存在——它可能只能是——一个 Lisp 程序员。

Lisp 的力量从何而来?
我已经说过 Lisp 是一种特别强大的语言。那么约翰·麦卡锡(以及后来的 Lisp 创新者)有哪些关键洞察力使得这种力量成为可能?
要使编程语言强大,你需要让它具有表现力。拥有表现力强的语言意味着你可以用很少的实际代码做很多事情。但语言需要哪些特性才能实现这一点?我认为有两个特性是最重要的。
第一种特性是语言中内置了许多功能。这样,对于大多数你需要完成的事情,已经有其他人为你完成了一些工作,你可以利用这些工作来使你的代码看起来简洁。许多现代语言都有这种特性。例如,Java 语言以其强大的库而闻名,例如,它让你能够轻松地从另一台 PC 通过套接字获取数据。
第二种赋予语言力量的特性是让你尽可能深入地玩弄它,使其按照你的意愿行事。这样,即使语言的设计者从未想过你要做什么,你仍然可以修改语言,直到它能够优雅地解决你的问题。这种特性在语言中提供起来要困难得多。假设你想在 Java 中添加类似嵌套函数定义支持的功能。如果你对 Java 非常了解,思考如何添加这样的支持就像是噩梦。
大多数语言难以同时支持这两种特性,原因在于它们彼此冲突。语言越丰富,其复杂性就越高。而语言越复杂,对其进行修改就越痛苦。这就是为什么对最成熟的编程语言进行自我修改几乎是不可能的。
当然,只要你足够努力,你总是可以对任何语言进行根本性的改变。例如,在 C++被开发出来时,它最初是以 C 预处理器形式出现的。编写了一个特殊的 C 程序,可以将用新的 C++方言编写的代码转换为普通的 C 代码,然后你可以通过标准的 C 编译器来运行它。这就是 Bjarne Stroustrup,C++的发明者,能够调整 C 语言并添加特性以使其成为自己的语言的原因。然而,编写这样的转换器是一个极其困难且繁琐的过程,你只会将其视为最后的手段。
相比之下,Lisp 语言使得经验丰富的 Lisper 能够非常容易地修改运行程序的编译器/解释器,同时仍然支持丰富的语言特性和广泛的库。实际上,在 Lisp 中玩弄语言比在创建的任何其他语言中都更容易!
例如,在 Lisp 中编写一个函数来计算两点之间的距离会很简单,就像在大多数其他语言中一样。但经验丰富的 Lisper 会发现,发明一种新的函数定义嵌套方式或设计一个有趣的 if-then 命令同样容易。甚至在你自己的 Lisp 中编写面向对象的编程支持也不复杂(而且大多数 Lisper 可能都曾在某个时刻这样做过)。在 Lisp 中,每个人都可以成为迷你版的 Stroustrup!

Lisp 是如何实现这一巧妙功能的?Lisp 的一个核心特性是,直接用 Lisp 编写 Lisp 本身,其简单程度令人难以置信。结果是,这一点正是关键属性,使得 Lisp 能够打破两个特性的悖论。它最初作为一种能够执行优雅地编写自身的酷炫数学技巧的语言,最终拥有了既功能丰富又可调整的属性。这反过来又使其成为编写几乎所有类型程序的完美工具!
想象一下:给一个程序员他的编程语言中的 fish 命令,他可能会吃上一天的中国外卖,喝上 Jolt。给一个程序员一种允许他编写自己的 fish 命令的编程语言,他可能会吃上一天的中国外卖,喝上 Jolt,并且这一习惯可能会持续一生(诚然,这可能会因为营养不足而缩短寿命,我们甚至都不想讨论可能的心律失常)。
因此,你现在应该明白为什么 Lisp 是一种非常酷且非常独特的编程语言。与大多数编程语言相比,它有着漫长且不寻常的历史。大多数语言源于工程领域,而 Lisp 则起源于一个更数学化的背景。对于那些愿意花点时间学习新事物的人来说,它有很多东西可以提供。
第一部分:Lisp 是力量

第一章:Lisp 入门
本章从介绍 Lisp 的各种方言开始。然后我们将简要讨论 ANSI Common Lisp,这是我们将在本书中使用的方言。最后,你将开始安装和测试 CLISP,这是 ANSI Common Lisp 的实现,它将允许你运行你将要创建的所有 Lisp 游戏!
Lisp 方言
任何遵循 Lisp 核心原则的语言都被认为是 Lisp 方言。由于这些原则非常简单,所以毫不奇怪,实际上已经创建了数百种 Lisp 方言。实际上,由于许多初学者将创建自己的 Lisp 方言作为练习,因此可能存在成千上万的未完成 Lisp 正在沉睡在地球上各个硬盘驱动器上被长期遗弃的目录中。然而,Lisp 社区的绝大多数人使用两种 Lisp:ANSI Common Lisp(通常缩写为 CL)和 Scheme。
在这本书中,我们将专门讨论 ANSI Common Lisp 方言,这是两者中稍微更受欢迎的一个。尽管如此,你从阅读这本书中获得的大部分知识也将与 Scheme 相关(尽管函数名称在方言之间可能略有不同)。
两种 Lisp 的故事
ANSI Common Lisp 和 Scheme 之间存在一些深层次的哲学差异,它们吸引了不同性格的程序员。一旦你更深入地了解 Lisp 语言,你就可以决定你更喜欢哪种方言。没有绝对的对错之分。
为了帮助你做出决定,我为你创建了一个以下的人格测试:

如果你选择了 A,你喜欢你语言中的原始力量。你并不介意你的语言因为许多实用主义的妥协而显得有些丑陋,只要你能写出紧凑的代码。ANSI Common Lisp 是最适合你的语言!ANSI Common Lisp 的根源可以追溯到古老的 Lisp 方言,它建立在数百万程序员的辛勤工作之上,使其功能极其丰富。当然,由于无数历史事件,它有一些巴洛克式的函数名称,但这个 Lisp 真正能在正确的黑客手中翱翔。
如果你选择了 B,你喜欢干净、优雅的语言。你对基本的编程问题更感兴趣,并且乐于在美丽的草地上消磨时光,思考你代码的美丽,偶尔写一篇关于理论计算问题的研究论文。Scheme 是适合你的语言!它是在 1970 年代中期由 Guy L. Steele 和 Gerald Jay Sussman 创建的,涉及对理想 Lisp 的深入思考。Scheme 中的代码通常稍微冗长一些,因为 Schemers 更关心他们代码中的数学纯粹性,而不是创建尽可能短的程序。
如果你选择了 C,你是一个想要拥有一切的人:ANSI CL 的力量和 Scheme 的数学美。在这个时候,没有 Lisp 方言完全符合要求,但这种情况可能会在未来改变。一种可能适合你的语言(尽管在 Lisp 书中提出这种说法是亵渎的)是 Haskell。它不被认为是 Lisp 方言,但它的追随者遵循 Lisper 中流行的范式,如保持语法统一,支持原生列表,并大量使用高阶函数。更重要的是,它具有极端的数学严谨性(甚至比 Scheme 还要严格),这使得它能够在干净利落的外表下隐藏非常强大的功能。它本质上是一只披着羊皮的狼。像 Lisp 一样,Haskell 是一种任何程序员都应进一步调查的语言。
新兴的 Lisp
正如刚才提到的,目前还没有一个真正的 Lisp 方言同时具备 ANSI Common Lisp 的强大功能和灵活性以及 Scheme 的优雅性。然而,一些新的竞争者可能在不久的将来获得两者的最佳结合。
一个显示出希望的新 Lisp 是 Clojure,这是由 Rich Hickey 开发的方言。Clojure 建立在 Java 平台之上,允许它直接利用许多成熟的 Java 库。此外,Clojure 包含一些巧妙且经过深思熟虑的特性,以简化多线程编程,这使得它成为编程看似无处不在的多核 CPU 的有用工具。
另一个有趣的挑战者是 Arc。它是一种真正的 Lisp 语言,主要由知名的 Lisper 保罗·格雷厄姆开发。Arc 仍处于早期开发阶段,对其相对于其他 Lisp 的改进程度意见分歧很大。此外,其开发进展缓慢,可能要过一段时间才能有人说出 Arc 是否可能成为一个有意义的竞争者。
在结语中,我们将浅尝 Arc 和 Clojure。
用于脚本编写的 Lisp 方言
一些 Lisp 方言被用于脚本编写,包括以下这些:
-
Emacs Lisp 用于在流行的(并且总体上很棒)Emacs 文本编辑器内部进行脚本编写。
-
Guile Scheme 在几个开源应用程序中用作脚本语言。
-
Script-Fu Scheme 与 GIMP 图像编辑器一起使用。
这些方言是主要 Lisp 分支的较老版本的分支,通常不用于创建独立的应用程序。然而,它们仍然是完全值得尊重的 Lisp 方言。
ANSI Common Lisp
1981 年,为了应对语言众多方言的令人眼花缭乱的数目,不同 Lisp 社区的成员起草了一个名为 Common Lisp 的新方言规范。1986 年,经过进一步的调整,这种语言被转化为 ANSI Common Lisp 标准。许多较老版本的 Lisp 的开发者修改了他们的解释器和编译器以符合这个新标准,这成为了最流行的 Lisp 版本,并且至今仍然是。
注意
在本书中,术语Common Lisp指的是 ANSI 标准定义的 Common Lisp 版本。
Common Lisp 的一个关键设计目标是创建一个多范式语言,这意味着它支持许多不同的编程风格。你可能听说过面向对象编程,这在 Common Lisp 中可以做得相当不错。其他你可能之前没有听说过的编程风格包括函数式编程、泛型编程和领域特定语言编程。这些在 Common Lisp 中都有很好的支持。随着我们继续阅读本书,你将学习到这些风格以及其他风格。
CLISP 入门
有许多优秀的 Lisp 编译器可用,但其中一个特别容易入门:CLISP,一个开源的 Common Lisp。CLISP 易于安装,可在任何操作系统上运行。
其他流行的 Lisp 包括 Steel Bank Common Lisp (SBCL),这是一个比 CLISP 更强大的快速 Common Lisp,也是开源的;Franz, Inc 的强大商业 Lisp Allegro Common Lisp;LispWorks;Clozure CL;和 CMUCL。Mac 用户可能想考虑 LispWorks 或 Clozure CL,这些在他们的机器上运行起来会更容易。然而,就我们的目的而言,CLISP 是最好的选择。
注意
从第十二章开始,我们将使用一些被认为是非标准的 CLISP 特定命令。然而,在此之前,任何 Common Lisp 的实现都可以用于运行本书中的示例。
安装 CLISP
你可以从clisp.cons.org/下载 CLISP 安装程序。它可以在 Windows PC、Mac 和 Linux 变种上运行。在 Windows PC 上,你只需运行安装程序。在 Mac 上,有一些额外的步骤,这些步骤在网站上都有详细说明。
在基于 Debian 的 Linux 机器上,你应该会发现 CLISP 已经存在于你的标准源中。只需在命令行中输入apt-get install clisp,CLISP 就会自动安装。
对于其他 Linux 发行版(Fedora、SUSE 等),你可以使用 CLISP 网站上列出的“Linux 软件包”下的标准软件包。经验丰富的 Linux 用户可以从源代码编译 CLISP。
启动 CLISP
要运行 CLISP,从你的命令行输入clisp。如果一切顺利,你会看到以下提示:
$ `clisp`
i i i i i i i ooooo o ooooooo ooooo ooooo
I I I I I I I 8 8 8 8 8 o 8 8
I \ `+' / I 8 8 8 8 8 8
\ `-+-' / 8 8 8 ooooo 8oooo
`-__|__-' 8 8 8 8 8
| 8 o 8 8 o 8 8
------+------ ooooo 8oooooo ooo8ooo ooooo 8
Copyright (c) Bruno Haible, Michael Stoll 1992, 1993
Copyright (c) Bruno Haible, Marcus Daniels 1994-1997
Copyright (c) Bruno Haible, Pierpaolo Bernardi, Sam Steingold 1998
Copyright (c) Bruno Haible, Sam Steingold 1999-2000
Copyright (c) Sam Steingold, Bruno Haible 2001-2006
[1]>
与所有 Common Lisp 环境一样,CLISP 启动后会自动将你放入一个读取-评估-打印循环(REPL)。这意味着你可以立即开始输入 Lisp 代码。
通过输入(+ 3 (* 2 4))来试一试。你会在表达式下方看到打印出的结果:
[1]> `(+ 3 (* 2 4))`
11
这显示了 REPL 的工作方式。你输入一个表达式,然后 Lisp 会立即评估它并返回结果值。当你想要关闭 CLISP 时,只需输入(quit)。
现在你已经在电脑上安装了 CLISP,你就可以开始编写 Lisp 游戏了!
你学到了什么
在这一章中,我们讨论了 Lisp 的不同方言和安装 CLISP。在过程中,你学习了以下内容:
-
Lisp 有两个主要的方言:Common Lisp 和 Scheme。两者都有很多优点,但在这本书中,我们将重点关注 Common Lisp。
-
Common Lisp 是一种多范式语言,这意味着它支持许多不同的编程风格。
-
CLISP 是一个易于设置的 Common Lisp 实现,这使得它成为 Lisp 新手的绝佳选择。
-
你可以直接在 CLISP 的 REPL 中输入 Lisp 命令。

第二章:创建你的第一个 Lisp 程序
既然我们已经讨论了一些 Lisp 的哲学思想,并且有一个运行的 CLISP 环境,我们就准备好编写一些实际的 Lisp 代码,形式为一个简单的游戏。
猜数字游戏
我们将要编写的第一个游戏几乎是想象中最简单的游戏。它是经典的猜数字游戏。
在这个游戏中,你从 1 到 100 中选择一个数字,然后电脑必须猜出它。
以下展示了如果你选择数字 23,游戏玩法可能的样子。电脑首先猜测 50,然后每次连续猜测,你输入(smaller)或(bigger),直到电脑猜出你的数字。
> `(guess-my-number)`
50
> `(smaller)`
25
> `(smaller)`
12
> `(bigger)`
18
> `(bigger)`
21
> `(bigger)`
23
要创建这个游戏,我们需要编写三个函数:guess-my-number、smaller和bigger。玩家只需从 REPL 中调用这些函数。正如你在上一章中看到的,当你启动 CLISP(或任何其他 Lisp)时,你会看到一个 REPL,你输入的命令将在这里被读取,然后评估,最后打印。在这种情况下,我们正在运行guess-my-number、smaller和bigger这些命令。
在 Lisp 中调用函数时,你需要在其周围加上括号,以及你希望传递给函数的任何参数。由于这些特定的函数不需要任何参数,我们在输入时只需将它们的名称用括号括起来即可。
让我们思考这个简单游戏背后的策略。经过一番思考,我们得出以下步骤:
-
确定玩家数字的上限和下限(大和小)。由于范围在 1 到 100 之间,最小的可能数字是 1,最大的数字是 100。
-
在这两个数字之间猜一个数字。
-
如果玩家说数字更小,降低大限。
-
如果玩家说数字更大,提高小限。

通过遵循这些简单的步骤,每次猜测都将可能数字的范围减半,电脑可以快速缩小范围,找到玩家的数字。
这种类型的搜索被称为二分搜索。正如你可能知道的,这种二分搜索在计算机编程中经常被使用。例如,你可以遵循这些相同的步骤,以高效地找到给定有序值表中的特定数字。在这种情况下,你只需跟踪表中最大和最小的行,然后以类似的方式快速定位到正确的行。
在 Lisp 中定义全局变量
当玩家调用构成我们游戏的功能时,程序需要跟踪小和大限制。为了做到这一点,我们需要创建两个名为 *small* 和 *big* 的全局变量。
定义小变量和大变量
在 Lisp 中,全局定义的变量被称为顶层定义。我们可以使用 defparameter 函数来创建新的顶层定义:
> `(defparameter *small* 1)`
*SMALL*
> `(defparameter *big* 100)`
*BIG*
函数名 defparameter 有点令人困惑,因为它实际上与参数没有什么关系。它所做的只是让你定义一个全局变量。
我们发送给 defparameter 的第一个参数是新变量的名称。围绕 *big* 和 *small* 名称的星号——亲切地称为耳罩——是完全任意和可选的。Lisp 将星号视为变量名称的一部分,并忽略它们。Lispers 喜欢以这种方式标记所有全局变量,作为一种约定,以便于区分它们,这些将在本章后面讨论。
注意
虽然在严格的技术意义上耳罩可能是“可选的”,但我建议你使用它们。如果你在 Common Lisp 新闻组中发布任何代码,并且你的全局变量缺少耳罩,我无法保证你的安全。
另一个全局变量定义函数的替代方案
当你使用 defparameter 设置全局变量的值时,任何先前存储在变量中的值都将被覆盖:
> `(defparameter *foo* 5)`
FOO
> `*foo*`
5
> `(defparameter *foo* 6)`
FOO
> `*foo*`
6
如你所见,当我们重新定义变量 *foo* 时,其值会发生变化。
你可以使用另一个用于声明全局变量的命令,称为 defvar,它不会覆盖全局变量的先前值:
> `(defvar *foo* 5)`
FOO
> `*foo*`
5
> `(defvar *foo* 6)`
FOO
> `*foo*`
5
一些 Lisp 程序员在定义全局变量时更喜欢使用 defvar 而不是 defparameter。然而,在这本书中,我们将仅使用 defparameter。
注意
当你在其他地方阅读关于 Lisp 的内容时,你也可能会看到程序员在提到 Common Lisp 中的全局变量时使用术语动态变量或特殊变量。这是因为 Common Lisp 中的全局变量有一些特殊的能力,我们将在未来的章节中讨论。
基本 Lisp 礼仪
与其他语言相比,Lisp 中调用命令和代码格式化的方式有些奇怪。首先,你需要用括号包围命令(及其参数),就像 defparameter 函数一样:
> `(defparameter *small* 1)`
*SMALL*
没有括号,命令将不会被调用。
此外,当 Lisp 读取你的代码时,会完全忽略空格和换行符。这意味着你可以以任何疯狂的方式调用这个命令,结果相同:
> `( defparameter`
`*small* 1)`
*SMALL*
由于 Lisp 代码可以以如此灵活的方式格式化,Lispers 有很多关于格式化命令的约定,包括何时使用多行和缩进。我们将松散地遵循本书中代码示例中的一些常见缩进约定。然而,我们更感兴趣的是编写游戏而不是讨论源代码缩进规则,所以我们不会在本书中花费太多时间在代码布局规则上。
在 Lisp 中定义全局函数
我们的guess-my-number游戏让计算机响应玩家请求开始游戏,以及请求更小或更大的猜测。为此,我们需要定义三个全局函数:guess-my-number、smaller和bigger。我们还将定义一个用于以不同数字重新开始游戏的函数,称为start-over。在 Common Lisp 中,函数使用defun定义,如下所示:
(defun *`function_name`* (*`arguments`*)
...)
首先,我们指定函数的名称和参数。然后我们跟上组成函数逻辑的代码。
定义guess-my-number函数
我们要定义的第一个函数是guess-my-number。这个函数使用*big*和*small*变量的值来生成玩家的数字猜测。定义看起来像这样:
> `(defun guess-my-number ()`
`(ash (+ *small* *big*) −1))`
GUESS-MY-NUMBER
函数名guess-my-number后面的空括号()表示这个函数不需要任何参数。
虽然你不需要在 REPL 中输入代码片段时担心缩进或换行符,但你必须确保正确放置括号。如果你忘记了一个括号或将其放在错误的位置,你很可能会得到一个错误。
每当我们在这个 REPL 中运行这样的代码片段时,输入表达式的结果值将被打印出来。Common Lisp 中的每个命令都会生成一个返回值。例如,defun命令只是返回新创建函数的名称。这就是为什么我们在调用defun后在 REPL 中看到函数名称被重复输出
。
这个函数做什么?如前所述,在这个游戏中,计算机的最佳猜测将是两个极限之间的一个数字。为了实现这一点,我们选择两个极限的平均值。然而,如果平均数最终变成一个分数,我们希望使用接近平均的数字,因为我们只猜测整数。
我们在guess-my-number函数中实现这一点,首先加上代表高限和低限的数字,然后使用算术移位函数ash将极限的总和减半并缩短结果。代码(+ *small* *big*)将这两个变量相加。因为加法发生在另一个函数调用中,
,所以得到的总和随后传递给ash函数。
在 Lisp 中,包围ash函数和加法(+)函数的括号是强制性的。这些括号告诉 Lisp,“我想调用这个函数。”
内置的 Lisp 函数ash查看一个以二进制形式表示的数字,然后将其二进制位向左或向右移动,丢弃在过程中丢失的任何位。例如,数字 11 以二进制形式表示为 1011。我们可以使用ash将这个数字的位向左移动,通过将1作为第二个参数使用:
> `(ash 11 1)`
22
这产生了 22,这在二进制中是 10110。我们可以通过将-1作为第二个参数传递来将位向右移动(并去掉末尾的位):
> `(ash 11 −1)`
5
这产生了 5,这在二进制中是 101。
通过在guess-my-number中使用ash函数,我们不断地将可能的数字搜索空间减半,以快速缩小到正确的最终数字。如前所述,这个过程称为二分搜索,是计算机编程中的一个有用技术。在 Lisp 中,ash 函数通常用于此类二分搜索。
让我们看看调用我们新函数时会发生什么:
> `(guess-my-number)`
50
由于这是我们第一次猜测,调用此函数时看到的输出告诉我们一切按计划进行:程序选择了数字 50,正好位于 1 和 100 之间。
当用 Lisp 编程时,你会编写许多不会在屏幕上显式打印值的函数。相反,它们会简单地返回函数体中计算出的值。例如,假设我们想要一个只返回数字 5 的函数。我们可以这样编写这个函数:
> `(defun return-five ()`
`(+ 2 3))`
因为函数体中计算出的值
等于 5,调用(return-five)将只返回 5。
这就是guess-my-number的设计方式。我们在屏幕上看到这个计算结果(数字 50),并不是因为函数导致数字显示,而是因为这是 REPL 的一个特性。
注意
如果你之前使用过其他编程语言,你可能记得必须写一些像return...这样的东西来返回一个值。在 Lisp 中,这并不是必要的。函数体中计算出的最终值会自动返回。
定义smaller和bigger函数
现在我们将编写我们的smaller和bigger函数。像guess-my-number一样,这些是通过defun定义的全局函数:
> `(defun smaller ()`
`(setf *big* (1- (guess-my-number)))`
`(guess-my-number))`
SMALLER
> `(defun bigger ()`
`(setf *small* (1+ (guess-my-number)))`
`(guess-my-number))`
BIGGER
首先,我们使用defun开始定义一个新的全局函数smaller。因为这个函数没有参数,所以括号是空的![http://atomoreilly.com/source/nostarch/images/783564.png]。
接下来,我们使用 setf 函数来更改全局变量 *big* 的值 ![http://atomoreilly.com/source/nostarch/images/783562.png]。由于我们知道数字必须小于上一次猜测,它现在可能的最大值是比那次猜测少一个。代码 (1- (guess-my-number)) 计算这个值:它首先调用我们的 guess-my-number 函数以获取最新的猜测,然后使用 1- 函数,该函数从结果中减去 1。
最后,我们希望 smaller 函数显示一个新的猜测。我们通过在函数体中将 guess-my-number 的调用作为最后一行来实现这一点 ![http://atomoreilly.com/source/nostarch/images/783560.png]。这次,guess-my-number 将使用更新后的 *big* 值,导致它计算下一个猜测。函数的最终值将自动返回,导致由 guess-my-number 生成的新的猜测(smaller 函数返回的)。
bigger 函数的工作方式完全相同,只是它提高了 *small* 的值。毕竟,如果您调用 bigger 函数,您是在说您的数字比上一次猜测大,所以现在可能的最小值(即 *small* 变量所代表的值)是比上一次猜测多一个。函数 1+ 简单地将 1 加到 guess-my-number 返回的值上。
在这里,我们看到我们的函数正在发挥作用,我们的猜测数字是 56:
> `(bigger)`
75
> `(smaller)`
62
> `(smaller)`
56
定义 start-over 函数
为了完成我们的游戏,我们将添加 start-over 函数来重置我们的全局变量:
(defun start-over ()
(defparameter *small* 1)
(defparameter *big* 100)
(guess-my-number))
如您所见,start-over 函数会重置 *small* 和 *big* 的值,然后再次调用 guess-my-number 函数以返回一个新的起始猜测。无论您想用不同的数字开始全新的游戏,都可以调用此函数来重置游戏。
在 Lisp 中定义局部变量
对于我们的简单游戏,我们已经定义了全局变量和函数。然而,在大多数情况下,您可能希望将定义限制在单个函数或代码块中。这些被称为 局部 变量和函数。
要定义局部变量,请使用 let 命令。一个 let 命令具有以下结构:
(let (*`variable declarations`*)
...body...)
let 命令中的第一件事是一系列变量声明 ![http://atomoreilly.com/source/nostarch/images/783564.png]。这是我们声明一个或多个局部变量的地方。然后,在命令的主体中(并且仅在此主体中),我们可以使用这些变量 ![http://atomoreilly.com/source/nostarch/images/783562.png]。以下是一个 let 命令的示例:
> `(let ((a 5)`
`(b 6))`
`(+ a b))`
11
在这个例子中,我们分别为变量 a ![http://atomoreilly.com/source/nostarch/images/783564.png] 和 b ![http://atomoreilly.com/source/nostarch/images/783562.png] 声明了值 5 和 6。这些都是我们的变量声明。然后,在 let 命令的主体中,我们将它们相加 ![http://atomoreilly.com/source/nostarch/images/783560.png],得到显示的值 11。
当使用 let 表达式时,你必须用括号包围声明的变量列表。此外,你必须用另一组括号包围每一对变量名和初始变量。
注意
尽管缩进和换行完全是任意的,因为 let 表达式中的变量及其值形成了一种简单的表格,常见的做法是将声明的变量垂直对齐。这就是为什么在先前的例子中 b 直接位于 a 之下的原因。
在 Lisp 中定义局部函数
我们使用 flet 命令来定义局部函数。flet 命令具有以下结构:
(flet ((*`function_name`* (*`arguments`*)
...function body...))
...body...)
在 flet 的顶部,我们声明一个函数(在前两行)。然后这个函数将在主体中对我们可用 ![http://atomoreilly.com/source/nostarch/images/783560.png]。函数声明由函数名、该函数的参数 ![http://atomoreilly.com/source/nostarch/images/783564.png],以及函数体 ![http://atomoreilly.com/source/nostarch/images/783562.png],我们在其中放置函数的代码。
以下是一个示例:
> `(flet ((f (n)`
`(+ n 10)))`
`(f 5))`
15
在这个示例中,我们定义了一个单独的函数 f,它接受一个参数 n ![http://atomoreilly.com/source/nostarch/images/783564.png]。函数 f 然后将 10 添加到这个变量 n ![http://atomoreilly.com/source/nostarch/images/783562.png],它已经传入。然后我们用数字 5 作为参数调用这个函数,导致返回值 15 ![http://atomoreilly.com/source/nostarch/images/783560.png]。
与 let 一样,你可以在 flet 的作用域内定义一个或多个函数。
单个 flet 命令可以一次声明多个局部函数。只需在命令的第一部分添加多个函数声明:
> `(flet ((f (n)`
`(+ n 10))`
`(g (n)`
`(- n 3)))`
`(g (f 5)))`
12
在这里,我们声明了两个函数:一个名为 f ![http://atomoreilly.com/source/nostarch/images/783564.png] 和一个名为 g ![http://atomoreilly.com/source/nostarch/images/783562.png]。在 flet 的主体中,我们可以引用这两个函数。在这个例子中,主体首先用 5 调用 f 得到 15,然后调用 g 减去 3,最终结果为 12。
要使函数名在定义的函数中可用,我们可以使用 labels 命令。它在基本结构上与 flet 命令相同。以下是一个示例:
> `(labels ((a (n)`
`(+ n 5))`
`(b (n)`
`(+ (a n) 6)))`
`(b 10))`
21
在这个例子中,局部函数 a 将 5 加到一个数字上
。接下来,声明了函数 b
。它调用了函数 a,然后将其结果加上 6
。最后,使用值 10 调用了函数 b
。由于 10 加 6 加 5 等于 21,数字 21 成为整个表达式的最终值。需要使用 labels 而不是 flet 的特殊步骤是函数 b 调用函数 a 的地方
。如果我们使用了 flet,函数 b 就不会“知道”函数 a。
labels 命令允许你从一个局部函数调用另一个局部函数,并允许函数调用自身。这在 Lisp 代码中很常见,被称为 递归。(你将在未来的章节中看到许多递归的例子。)
你已经学到的内容
在本章中,我们讨论了定义变量和函数的基本 Common Lisp 命令。在这个过程中,你学习了以下内容:
-
要定义一个全局变量,使用
defparameter命令。 -
要定义一个全局函数,使用
defun命令。 -
使用
let和flet命令分别定义局部变量和函数。 -
函数
labels类似于flet,但它允许函数调用自身。调用自身的函数被称为 递归函数。
第三章。探索 Lisp 代码的语法
如你所学,Lisp 命令必须以一种相当不寻常的方式输入,每个命令周围都有括号。在本章中,我们将探讨 Lisp 为什么会这样工作。
要理解为什么任何语言——无论是编程语言还是人类语言——看起来都有一定的样子,我们需要从语言学领域的两个概念开始:语法和语义。
语法和语义
这里是英语语言中的一个典型句子:
| 我的狗吃了我的作业。 |
|---|
这个句子具有英语句子正确的语法。文本的 语法 代表了它需要遵循的基本规则,才能成为一个有效的句子。以下是一些遵循英语语言规则的句子规则,该文本遵守:
-
句子以标点符号结束。
-
句子包含主语和谓语。
-
句子由英语字母表中的字母组成(与埃及象形文字或苏美尔楔形文字相反)。
然而,句子不仅仅有语法。我们还关心句子的实际意义。当我们谈论句子的 语义 时,我们指的是它的意义。
例如,以下是一些语义大致相同的句子:
| 我的狗吃了我的作业。 |
|---|
| 我拥有的狗吃掉了我的学校作业。 |
| Der Hund hat meine Hausarbeit gefressen. |
前两句只是用英语表达相同内容的不同方式。第三句是德语,但它仍然与第一两句具有相同的意义和语义。
在编程语言中,这两种想法之间的区别同样存在。例如,这里是一行有效的 C++ 代码:
((foo<bar>)*(g++)).baz(!&qux::zip->ding());
这行代码遵循 C++ 语法的规则。为了说明我的观点,我在其中加入了很多独特的 C++ 语法,这使它与其他语言区分开来。如果你将这一行代码放入另一种具有不同语法的编程语言程序中,它可能无效并引发错误。
当然,这段 C++ 编程代码也有其意义。如果我们把这一行代码放入一个 C++ 程序中(在适当的环境中),它会使你的电脑 执行 一些操作。程序执行的操作是程序的 语义。通常,可以编写在不同的编程语言中具有相同语义的程序;也就是说,程序在这两种语言中都会做同样的事情。
所有这些归结起来就是,大多数编程语言具有相似的语义能力。然而,基本的 Lisp 代码与任何其他主要编程语言的代码都不同,因为它具有远为简单的语法。拥有简单的语法是 Lisp 语言的一个定义性特征。
Lisp 语法的基本构建块
从上一节中疯狂的一行 C++ 代码中,你可以得到这样的想法:C++ 有很多奇怪的语法——用于表示命名空间、解引用指针、执行类型转换、引用成员函数、执行布尔操作等等。
如果你编写一个 C++ 编译器,你需要做很多艰苦的工作,以便编译器能够读取这段代码并遵守许多 C++ 语法规则,在你能够理解代码之前。
编写 Lisp 编译器或解释器要容易得多。Lisp 编译器或解释器中读取代码的部分(Lisper 实际上称之为 reader)比 C++ 或任何其他主要编程语言都要简单。拿一段随机的 Lisp 代码:
(defun square (n)
(* n n))
这个函数声明,它创建了一个简单的平方数字的函数,仅由括号和符号组成。实际上,你可以将其视为由括号分隔的一堆嵌套列表。
Lisp 只有一种组织代码片段的方式:它使用括号将代码组织成 列表。
所有基本的 Lisp 代码都使用这种简单的类似列表的语法:

但我们可以将这些列表放入哪些类型的事物呢?嗯,除了其他列表,我们还可以将符号、数字和字符串放入我们的代码中。在这里,我们将探讨你在 Lisp 中将使用的这些基本构建块,或者说数据类型。(我们将在后面的章节中讨论许多其他 Common Lisp 数据类型。)
符号
符号是 Lisp 中的基本数据类型,被广泛使用。Lisp 中的符号是一个独立的单词。Common Lisp 符号通常由字母、数字和像+ - / * = < > ? ! _这样的字符组成。一些有效的 Lisp 符号示例有foo、ice9、my-killer-app27,甚至—<<==>>—。
Common Lisp 中的符号是不区分大小写的(尽管大多数 Lisper 避免使用大写)。为了说明这一点,我们将使用一个名为eq的函数,它让我们看到两个符号是否相同:
> `(eq 'fooo 'FoOo)`
T
如你所见,这个函数返回了T,这告诉我们 Lisp 认为这两个符号是相同的。(现在忽略符号前面的引号。这将在我们讨论数据模式时解释。)
数字
Lisp 支持浮点数和整数。当你写一个数字时,是否存在小数点决定了你的数字被视为浮点数还是整数。在 Common Lisp 中,数字 1 和 1.0 是两个不同的实体。
例如,如果你使用大多数数学函数同时带有整数和浮点数,整数将会变成“中毒”,并返回一个浮点数。以下是一个具体的例子:
> `(+ 1 1.0)`
2.0
注意,返回的数字2.0中的小数点表示它是一个浮点数。
Lisp 可以用数字做一些惊人的事情,尤其是与其他大多数语言相比。例如,这里我们使用函数expt来计算 53 的 53 次方:
> `(expt 53 53)`
2435684816502271213247760652010472551853345312868564084450513087957
6720609150223301256150373
这不酷吗?大多数语言在涉及如此大的数字的计算中都会崩溃。
最后,你应该知道,如果你除以两个整数,可能会发生一些奇怪的事情:
> `(/ 4 6)`
2/3
除法函数正在将 4 除以 6。但出乎意料的是,它并没有返回一个分数(0.66666...),而是返回了一个有理数,用两个整数之间的除号表示。所以2/3的结果代表了一个单一的有理数,这是在数学上编码这种分数的理想方式。
注意,如果我们的计算中有浮点数,我们会得到不同的答案:
> `(/ 4.0 6)`
0.6666667
与前面的例子一样,带有小数点的数字(4.0)使我们的数字“中毒”,给出了一个分数作为结果。
如果你不是数学爱好者,这可能对你没有太大帮助,但至少你现在知道,当你编码时看到这种事情时发生了什么。你也可以放心,当你稍后在另一个计算中使用这个数字时,Lisp 会正确处理这个数字。Lisp 很聪明。
字符串
Lisp 是最后一个基本构建块是字符串。虽然从理论角度来看,字符串并不是 Lisp 的基础,但任何与人类通信的程序通常都需要字符串,因为人类喜欢用文本进行交流。
在 Lisp 中表示字符串时,用双引号包围字符。例如,"Tutti Frutti"是一个有效的字符串。
我们可以使用一个名为 princ 的函数来显示字符串:
> `(princ "Tutti Frutti")`
Tutti Frutti
"Tutti Frutti"
注意,在 REPL 中打印我们的文本会导致文本出现两次。首先,我们看到由 princ 命令引起的实际打印
。然而,由于 REPL 总是显示输入表达式的评估结果,所以我们看到我们的字符串被重复显示
。这是因为 princ 函数也返回一个值,恰好是源字符串。
字符串也可以包含所谓的转义字符。如果你想让字符串包含双引号或反斜杠,你需要在这些字符前加上反斜杠。例如,这个字符串包含两个转义引号:
> `(princ "He yelled \"Stop that thief!\" from the busy street.")`
He yelled "Stop that thief!" from the busy street.
如你所见,两个引号前的反斜杠告诉 Lisp 这些是在字符串中的字面引号,就像显示的字符串中的任何其他字符一样。
^([1]) 如同在第二章中讨论的那样,在读取-评估-打印循环(或 REPL)中,我们输入的函数将被读取,然后评估,最后打印出来。
Lisp 如何区分代码和数据
当我们编写 Lisp 程序时,Lisp 是如何决定程序中的哪些部分是代码(要执行的内容)以及哪些部分只是数据?Lisp 的语法有一种特殊的方式来区分这两者。
Common Lisp 在读取你的代码时使用两种模式:代码模式和数据模式。你可以在编写 Lisp 代码时在这两种模式之间切换。
代码模式
每当你将某些内容输入到 Lisp REPL 中时,编译器会假设你正在输入一个你想要执行的命令。换句话说,Lisp 总是假设你在编写代码,并默认为代码模式。
正如我们已经讨论过的,Lisp 预期 Lisp 代码将以列表的形式输入。然而,代码应该是一种特殊类型的列表:形式。因此,当你处于代码模式时,就像你在开始输入到 REPL 时一样,你输入的命令需要以形式的结构来组织:

形式(form)简单来说是一个以特殊命令开头的列表——通常是函数的名称。
当读取一个形式时,Lisp 将列表中的所有其他项发送到该函数作为参数。例如,将以下内容输入到你的 REPL 中:
> `(expt 2 3)`
8
这计算了 2³ = 8。它是通过调用 expt 来实现的,该命令计算指数。这个命令是以 Lisp 的标准方式输入的:作为一个以函数名称开头的形式。
当 Lisp 读取此类命令的参数文本时,它通常假设这些参数也处于代码模式。以下是一个例子:
> `(expt 2 (+ 3 4))`
128
这个例子有两个嵌套的表单。Lisp 首先以代码模式查看整个表达式。它确定我们进入了 expt 命令的表单。然后 Lisp 查看这个命令的参数,这些参数也在代码模式下。这些参数中的一个 (+ 3 4) 是一个独立的表单。这个表单随后被执行,得到 7。之后,这个结果被传递给外部的 expt 表单,然后执行。
数据模式
如你所想,任何以数据模式编写的代码都被视为数据。这意味着计算机不会尝试“执行”它,这允许我们在代码中包含纯粹的数据信息。
让我们看看数据模式在实际中的应用。我们将输入与之前代码模式中相同的表单,只有一个区别:
> `'(expt 2 3)`
(expt 2 3)
这次,我们在列表前加了一个单引号。Lisp 并没有响应数字 1 和 2 的和,而是简单地重复我们的表达式。单引号告诉 Lisp 将随后的表单视为数据块——只是一个项目的列表。Lisp 然后打印出评估我们所输入内容的结果,即列表本身。它忽略了列表中的任何函数或变量,将所有内容视为数据。
在列表前放置一个引号,以便它们不会被评估为命令,这被称为 引用。通过使用引用,你可以告诉 Lisp,“接下来的这部分不是命令。它只是程序的数据块。”
Lisp 中的列表
列表是 Lisp 中的一个关键特性。它们是所有 Lisp 代码(以及数据)的粘合剂。以任何基本的 Lisp 代码为例,如下所示:
(expt 2 3)
这段代码包含一个符号(expt)和两个数字,它们作为一个列表绑定在一起,由括号表示。

你可以将 Lisp 程序想象成一栋房子。如果你在 Lisp 中构建一栋房子,你的墙壁将由列表组成。砖块由符号、数字和字符串组成。然而,墙壁需要灰浆来粘合。同样,Lisp 中的列表由称为 cons 单元 的结构粘合在一起。
cons 单元
Lisp 中的列表由 cons 单元组成。理解 cons 单元和列表之间的关系将帮助你更好地了解 Lisp 的工作原理。
cons 单元看起来是这样的:

它由两个小连接的盒子组成,这两个盒子都可以指向其他东西。一个 cons 单元可以指向另一个 cons 单元或另一种类型的 Lisp 数据。通过能够指向两个不同的事物,可以将 cons 单元链接成列表。实际上,Lisp 中的列表只是一个抽象的幻象——它们实际上都是由 cons 单元组成的。
例如,假设我们创建了列表 (1 2 3)。以下是这个列表在计算机内存中的表示方式:

它由三个 cons 单元组成。每个单元指向一个数字,以及列表的下一个 cons 单元。最后的 cons 单元指向nil,以终止列表。(如果你在其他编程语言中使用过链表,这个基本概念是相同的。)你可以将这种安排想象成你朋友的调用链:“当我知道这个周末有个派对时,我会给 Bob 打电话,然后 Bob 会给 Lisa 打电话,Lisa 会继续打……每个人在调用链中只负责一个电话,这个电话激活列表中的下一个调用。”
列表函数
在 Lisp 编程中,操作列表非常重要。在 Lisp 中有三个基本函数用于操作 cons 单元(以及列表):cons、car和cdr。
cons 函数
如果你想在你的 Lisp 程序中链接任何两份数据(无论类型如何),通常的做法是使用cons函数。当你调用cons时,Lisp 编译器通常会分配一小块内存,即 cons 单元,它可以存储两个被链接对象的引用。(通常,被链接的两个项目中的第二个将是一个列表。)例如,让我们将符号chicken链接到符号cat:
> `(cons 'chicken 'cat)`
(CHICKEN . CAT)
如你所见,cons返回一个单一的对象,即 cons 单元,由括号和两个连接项之间的点表示。不要将这个与普通列表混淆。中间的点使这个单元成为一个 cons 单元,仅将这两个项目链接在一起。
注意我们如何使用单个引号作为前缀来标记我们的两份数据,以确保 Lisp 将它们视为数据而不是尝试将它们作为代码来评估。
如果我们在列表的右侧附加符号nil而不是其他数据,会发生一些特殊的事情:
> `(cons 'chicken 'nil)`
(CHICKEN)
与我们的cat不同,这次输出中没有显示nil。这是因为有一个简单的原因:nil是一个特殊的符号,用于在 Lisp 中终止列表。话虽如此,Lisp 的 REPL 正在走捷径,只是说我们创建了一个包含一个项目的列表,即我们的chicken。它可以通过显式显示我们的 cons 单元并打印(CHICKEN . NIL)来显示结果。然而,因为这个结果偶然也是一个列表,所以它将显示列表表示法。
这里的教训是,Lisp 总是会不遗余力地“隐藏”cons 单元。当它能够这样做时,它会使用列表来显示你的结果。只有当没有其他方法可以显示你的结果时,它才会显示一个 cons 单元(在两个对象之间有一个点)。不要将这个与普通列表混淆。中间的点使这个单元成为一个 cons 单元,仅将这两个项目链接在一起。
之前的例子也可以这样写:
> `(cons 'chicken ())`
(CHICKEN)
空列表()在 Common Lisp 中可以与nil符号互换使用。将列表的终止符想象成空列表是有意义的。当你向一个空列表添加一只鸡时,你会得到什么?只是一个包含一只鸡的列表。cons函数还可以在列表的前面添加一个新项目。例如,要将pork添加到包含(beef chicken)的列表的前面,可以使用cons如下所示:
> `(cons 'pork '(beef chicken))`
(PORK BEEF CHICKEN)
当 Lisp 程序员谈论使用 cons 时,他们会说他们在 连接 东西。在这个例子中,我们将猪肉连接到一个包含牛肉和鸡肉的列表中。
由于所有列表都是由 cons 单元组成的,我们的 (beef chicken) 列表必须是由它自己的两个 cons 单元创建的,可能像这样:
> `(cons 'beef (cons 'chicken ()))`
(BEEF CHICKEN)
结合前两个例子,我们可以看到所有列表在作为 cons 单元查看时的样子。这就是 真正 发生的事情:
> `(cons 'pork (cons 'beef (cons 'chicken ())))`
(PORK BEEF CHICKEN)
事实上,这告诉我们当我们将三个元素的列表连接在一起时,我们得到一个包含三个元素的列表。数据永远不会被全部复制或删除。
REPL 将我们输入的项目作为列表回显给我们,(pork beef chicken),但它也可以(尽管不太方便)将项目按我们输入的顺序回显:(cons 'pork (cons 'beef (cons 'chicken ())))。任何一种回应都是完全正确的。在 Lisp 中,一串 cons 单元和列表是同一回事。
car 和 cdr 函数
列表只是由两个元素的单元组成的长时间链。
car 函数用于从单元的 第一个 插槽中获取内容:
> `(car '(pork beef chicken))`
PORK
cdr 函数用于从 第二个 插槽中获取值,或列表的剩余部分:
> `(cdr '(pork beef chicken))`
(BEEF CHICKEN)
你可以将 car 和 cdr 组合成新的函数,如 cadr、cdar 或 cadadr。这让你可以简洁地从复杂列表中提取特定数据。输入 cadr 与使用 car 和 cdr 相同——它从列表中返回第二个元素。(第二个 cons 单元的第一个插槽将包含该元素。)看看这个例子:
> `(cdr '(pork beef chicken))`
(BEEF CHICKEN)
> `(car '(beef chicken))`
BEEF
> `(car (cdr '(pork beef chicken)))`
BEEF
> `(cadr '(pork beef chicken))`
BEEF
我们知道 cdr 会移除列表中的第一个元素
。如果我们然后取这个缩短的列表并使用 car,我们将得到新列表中的第一个元素
。然后,如果我们同时使用这两个命令,我们将得到原始列表中的第二个元素
。最后,如果我们使用 cadr 命令,它给出的结果与同时使用 car 和 cdr 相同
。本质上,使用 cadr 命令就是想要列表中的第二个元素。
列表函数
为了方便,Common Lisp 在基本的三种函数——cons、car 和 cdr 的基础上构建了许多函数。其中一个有用的函数是 list 函数,它一次性完成创建所有 cons 单元的工作,并构建我们的列表:
> `(list 'pork 'beef 'chicken)`
(PORK BEEF CHICKEN)
记住,使用 list 函数创建的列表、通过指定单个 cons 单元创建的列表,或在数据模式下使用单引号创建的列表之间没有区别。它们都是同一类东西。

嵌套列表
列表可以包含其他列表。以下是一个例子:
'(cat (duck bat) ant)
这是一个包含三个项目的列表。该列表的第二个项目是(duck bat),它本身也是一个列表。这是一个嵌套列表的例子。
然而,在底层,这些嵌套列表仍然只是由 cons 单元格组成的。让我们看看一个例子,其中我们从嵌套列表中提取项目。这里,第一个项目是(peas carrots tomatoes),第二个项目是(pork beef chicken):
> `(car '((peas carrots tomatoes) (pork beef chicken)))`
(PEAS CARROTS TOMATOES)
> `(cdr '(peas carrots tomatoes))`
(CARROTS TOMATOES)
> `(cdr (car '((peas carrots tomatoes) (pork beef chicken))))`
(CARROTS TOMATOES)
> `(cdar '((peas carrots tomatoes) (pork beef chicken)))`
(CARROTS TOMATOES)
car函数给我们列表中的第一个项目,在这个例子中是一个列表!
。接下来,我们使用cdr命令从这个内部列表中移除第一个项目,留下(CARROTS TOMATOES)!
。使用这些命令一起给出相同的结果!
。最后,使用cdar给出与单独使用cdr和car相同的结果!
。
如此例所示,cons 单元格允许我们创建复杂结构,我们在这里使用它们来构建嵌套列表。为了证明我们的嵌套列表仅由 cons 单元格组成,以下是我们可以使用cons命令创建此嵌套列表的方法:
> `(cons (cons 'peas (cons 'carrots (cons 'tomatoes ())))`
`(cons (cons 'pork (cons 'beef (cons 'chicken ()))) ()))`
((PEAS CARROTS TOMATOES) (PORK BEEF CHICKEN))
这里有一些基于car和cdr的函数的更多示例,我们可以在我们的数据结构上使用:
> `(cddr '((peas carrots tomatoes) (pork beef chicken) duck))`
(DUCK)
> `(caddr '((peas carrots tomatoes) (pork beef chicken) duck))`
DUCK
> `(cddar '((peas carrots tomatoes) (pork beef chicken) duck))`
(TOMATOES)
> `(cadadr '((peas carrots tomatoes) (pork beef chicken) duck))`
BEEF
Common Lisp 已经为您定义了所有这些函数。您可以直接使用任何名为c*r的函数,最多可达四层深度。换句话说,cadadr已经为您准备好了使用,而cadadar(深度为五层)则没有(您需要自己编写该函数)。这些函数使得在 Lisp 中操作基于 cons 单元格的结构变得容易,无论它们可能多么复杂。


您学到了什么
在本章中,我们讨论了基本的 Lisp 语法。在这个过程中,您学习了以下内容:
-
Lisp 中的括号用于将语法量保持在最小。
-
列表是由 cons 单元格创建的。
-
您可以使用
cons命令创建列表。 -
您可以使用
car和cdr检查列表的各个部分。
第二部分。Lisp 的对称性

第四章。使用条件做出决策
在前面的章节中,你学习了某些基本的 Lisp 命令,以及 Lisp 背后的某些哲学。在本章中,我们将详细探讨处理条件的命令。这些命令的优雅性表明,Lisp 不寻常的哲学和设计确实具有实际的好处。
nil 和 () 的对称性
当我们观察 Lisp 命令和数据结构的工作方式时,有一件事特别引人注目:它们以每一种可能的方式都充满了对称性。这种对称性可以为你的 Lisp 代码带来一种其他语言无法拥有的优雅,而 Lisp 的简单语法是实现这种对称性的一个重要因素。
空等于假
由于 Lisp 哲学强烈强调使用列表来存储和处理信息,因此 Common Lisp 的设计倾向于使切片和切块此类列表变得容易的行为,这并不会让人感到惊讶。在 Common Lisp 中,关于列表的最深刻的设计决策是,它在评估条件时自动将空列表视为假值:
> `(if '()`
`'i-am-true`
`'i-am-false)`
I-AM-FALSE
> `(if '(1)`
`'i-am-true`
`'i-am-false)`
I-AM-TRUE
这个例子表明,当我们把空列表 () 传递给 if 形式时,它评估为假值,而包含项目的列表评估为真。
由于我们可以轻松地检测空列表,我们可以使用 递归 来处理列表。使用这种技术,我们可以从列表的前端取项目,并将列表的其余部分发送回同一个函数,直到列表为空。(检测空列表如此容易是一件好事,因为 Lisp 中有如此多的函数最终都会成为列表消耗者。)

让我们看看一个常见的列表消耗函数,它计算列表的长度。
> `(defun my-length (list)`
`(if list`
`(1+ (my-length (cdr list)))`
`0))`
> `(my-length '(list with four symbols))`
4
这个函数是用经典 Lisp 风格编写的。它在从列表前端移除项目的同时递归地调用自己。以这种方式调用自己不仅在 Lisp 中是允许的,而且通常被强烈推荐。Lisp 中的列表是递归的(cons 的 cons 的 cons ...),因此消耗列表的行为自然映射到递归函数。
注意
递归调用自己有时会导致代码运行缓慢。在 第十四章 中,我们将使用一种特殊且可能更快的递归类型重写 my-length 函数。
空列表的四种伪装
不仅空列表评估为假,而且在 Common Lisp 中,它还是唯一的假值。任何与空列表不等价的价值都将被视为真值。这解释了为什么在先前的例子中,表达式 '(1) 被视为真。然而,Lisp 中还有一些其他表达式是唯一的空列表的伪装:

我们可以通过相互比较这些表中的表达式来看到它们是等价的:
(eq '() nil) ==> T
(eq '() ()) ==> T
(eq '() 'nil) ==> T
注意,表中唯一看起来正常的值是左侧比较中的引号列表。其他三个似乎都违反了我们之前章节中讨论的 Lisp 形式的规则。
前两个例子尤其令人困惑。它们缺少了告诉 Lisp 环境的引号,“嘿,这个项目是一份数据,而不是代码!”在 nil 的情况下,你可能会期望这实际上是一个可能具有某种任意值的变量的名称。在未引用的 () 的情况下,你根本无法知道会发生什么。括号看起来像是一种需要评估的代码形式,但 Lisp 形式始终有一个符号在开头,告诉它要做什么。当形式内部没有任何内容时,我们该怎么办?
事实上,Common Lisp 在幕后被设计成确保当你将这些值用于程序时,它们看起来都像空列表,这使得大多数 Lisp 条件语句可以以优雅的简洁性编写。例如,有一个名为 nil 的常量,它评估为自身,并允许你在第一种情况下省略引号!。第二种情况!是 Common Lisp 解析空形式时的自然结果。第三种情况!是由于 Common Lisp 规范中的一个要求,即 () 和 nil 应该被同等对待。
虽然所有这些值都相同具有一定的美感,但并非每个 Lisp 程序员都同意这种观点。毕竟,假和空列表真的是同一种东西吗?其他流行 Lisp 方言(Scheme)的创造者对这个问题有不同的看法,他们更愿意将假和空列表的概念完全分开,尽管这会牺牲代码的简洁性。
条件语句:if 及其之后
既然你已经了解了 Lisp 如何处理真和假,那么让我们来看看 if 以及其他一些条件命令。
一次只做一件事的 if
if 命令可以在条件为真(例如,1 + 2 = 3)或假(例如,1 + 2 = 4)时执行不同的操作。
> `(if (= (+ 1 2) 3)`
`'yup`
`'nope)`
YUP
> `(if (= (+ 1 2) 4)`
`'yup`
`'nope)`
NOPE
if 命令还可以用来检查列表是否为空:
> `(if '(1)`
`'the-list-has-stuff-in-it`
`'the-list-is-empty)`
THE-LIST-HAS-STUFF-IN-IT
> `(if '()`
`'the-list-has-stuff-in-it`
`'the-list-is-empty)`
THE-LIST-IS-EMPTY
到目前为止,我们看到的唯一一种基于条件进行分支的命令是 if 命令:
> `(if (oddp 5)`
`'odd-number`
`'even-number)`
ODD-NUMBER
我们在这里所做的只是检查数字 5 是否为奇数,然后根据结果,在 if 形式中评估以下两个表达式之一。由于 5 是奇数,它评估了第一个这样的表达式,整个形式返回 odd-number。
在这个看似无害的小命令中,有很多事情正在发生——这些事情对于理解 Lisp 至关重要。以下是两个重要的观察:
-
只有
if后面的一个表达式实际上会被评估。 -
在
if语句中我们只能做一件事。
通常,当一个函数在 Lisp 中执行时,函数名后的所有表达式都会在函数本身评估之前被评估。然而,if并不遵循这些规则。为了看到这一点,考虑以下示例:
> `(if (oddp 5)`
`'odd-number`
`(/ 1 0))`
ODD-NUMBER
任何自重的、守法的 Lisp 函数都会在你尝试运行此代码时把你踢到路边,因为你正在除以零。
但if不仅仅是一个函数。它是一个特殊形式,这赋予它特殊的权限,例如不按正常方式评估所有参数的权利。这是有意义的,因为条件的目的就是运行某些东西但不运行其他东西。在这种情况下,它只是愉快地忽略了除以零的错误,因为它只适用于偶数部分的分支。Lisp 中的条件命令通常是特殊形式。

注意
一些条件命令可能是宏,它们类似于用户创建的特殊形式。作为特殊形式通常意味着一个命令是直接“嵌入”到语言中的。在第十六章(The Magic of Lisp Macros)中,你将学习如何自己编写这样的宏。
由于if语句内部只有一个表达式会被评估,所以在分支内部做两件或更多的事情是不可能的。
实际上,有一种巧妙的编程风格(称为函数式编程,我们将在第十四章中讨论),它认为这是一个好事。然而,对于你真的想做多项操作的情况,你可以使用一个特殊的命令progn,在单个表达式中插入额外的命令。使用progn,只有最后一个评估被作为整个表达式的值返回。例如,在下一个例子中,我们使用这个命令直接在我们的条件分支中设置一个特殊的全局变量。
> `(defvar *number-was-odd* nil)`
> `(if (oddp 5)`
`(progn (setf *number-was-odd* t)`
`'odd-number)`
`'even-number)`
ODD-NUMBER
> `*number-was-odd*`
T

超越 if:when 和 unless 的替代方案
由于每次在if语句内想要做多项操作时使用progn都有些麻烦,Lisp 有其他几个包含隐式progn的命令。其中最基本的是when和unless:
> `(defvar *number-is-odd* nil)`
> `(when (oddp 5)`
`(setf *number-is-odd* t)`
`'odd-number)`
ODD-NUMBER
> `*number-is-odd*`
T
> `(unless (oddp 4)`
`(setf *number-is-odd* nil)`
`'even-number)`
EVEN-NUMBER
> `*number-is-odd*`
NIL
使用when时,当条件为真时,所有包含的表达式都会被评估。使用unless时,当条件为假时,所有包含的表达式都会被评估。这些命令的权衡是,当条件以相反的方式评估时,它们无法做任何事情;它们只是返回nil并什么都不做。
做一切命令:cond
但如果你是那种想要所有功能的程序员怎么办?也许你根本不想妥协,只想有一个能做所有事情的函数!Lisp 已经为你准备好了。

cond 形式是 Lisp 中进行分支的经典方式。通过大量使用括号,它允许隐式地使用 progn,可以处理多个分支,甚至可以连续评估多个条件。由于 cond 自 Lisp 石器时代以来一直存在,并且功能全面,许多 Lisp 程序员认为它是真正的 Lisp 条件。
这里有一个例子:
> `(defvar *arch-enemy* nil)`
> `(defun pudding-eater (person)`
`(cond ((eq person 'henry) (setf *arch-enemy* 'stupid-lisp-alien)`
`'(curse you lisp alien - you ate my pudding))`
`((eq person 'johnny) (setf *arch-enemy* 'useless-old-johnny)`
`'(i hope you choked on my pudding johnny))`
`(t '(why you eat my pudding stranger ?))))`
> `(pudding-eater 'johnny)`
(I HOPE YOU CHOKED ON MY PUDDING JOHNNY)
> `*arch-enemy*`
JOHNNY
> `(pudding-eater 'george-clooney)`
(WHY YOU EAT MY PUDDING STRANGER ?)

如您所见,cond 的主体使用括号层来分隔条件的不同分支。然后每个括号部分的第一个表达式包含使该分支激活的条件。在我们的例子中,我们有针对每种布丁小偷的不同分支:一个针对亨利
,一个针对约翰尼
,还有一个针对其他人
。我们使用 eq 来比较提供的名字与每个潜在的肇事者。
cond 形式中的条件总是从上到下进行检查,因此第一个成功的匹配将驱动行为。在这个例子中,最后一个分支
的条件为 t(表示真),这保证了至少最后一个分支将始终被评估。这是一个常见的 cond 习惯用法。
与 when 和 unless 一样,触发的分支可以包含多个命令,因为存在隐式的 progn。在这种情况下,前两个分支 
设置了一个额外的 *arch-enemy* 变量,除了提供返回变量。
使用 case 进行分支
让我们看看最后一个 Lisp 命令:case 形式。通常使用 eq 函数作为条件,case 允许您提供一个要比较的值。使用 case,我们可以将前面的例子重写如下:
> `(defun pudding-eater (person)`
`(case person`
`((henry) (setf *arch-enemy* 'stupid-lisp-alien)`
`'(curse you lisp alien - you ate my pudding))`
`((johnny) (setf *arch-enemy* 'useless-old-johnny)`
`'(i hope you choked on my pudding johnny))`
`(otherwise '(why you eat my pudding stranger ?))))`
这个版本的代码对眼睛来说更容易接受。由 case 语句的每个部分处理的个人名称清晰可见——它不是隐藏在等式检查中。根据您使用的 Lisp 版本,这种 case 语句可能也更有效,尤其是在处理大量情况的长语句中。
警告
由于 case 命令使用 eq 进行比较,它通常仅用于基于符号值的分支。它不能用于基于字符串值的分支,以及其他情况。有关详细信息,请参阅 比较内容:eq、equal 和更多。
带条件的酷技巧
Lisp 的基本设计让你可以用几个简单的命令获得很多好处。具体来说,涉及 Lisp 中条件的两个反直觉技巧可以帮助你编写更干净的代码。第一个涉及到两个新的条件命令。第二个利用了 Lisp 对真和假的简单理解。
使用 Stealth Conditionals 和 and or
条件运算符 and 和 or 是简单的数学运算符,允许你以与使用加法和减法操作数字相同的方式操作布尔值。
例如,以下是我们可以如何使用 and 来检查三个数字是否都是奇数的方法:
> `(and (oddp 5) (oddp 7) (oddp 9))`
T
因为 5、7 和 9 都是奇数,整个表达式评估为真。
同样,我们可以使用 or 来检查一组数字中是否至少有一个是奇数:
> `(or (oddp 4) (oddp 7) (oddp 8))`
T
因为 7 是奇数,所以 or 命令仍然评估为真,尽管 4 和 8 都是偶数。
但 and 和 or 之间还有一些更有趣的东西,你可能只是通过查看这两个例子而不会注意到。到目前为止,这两个命令看起来像是完全普通的数学运算符;它们看起来不像 if 或 cond 这样的条件命令。然而,它们可以用于条件行为。
例如,以下是我们可以如何使用这些条件来设置全局变量,仅在数字是偶数时将其设置为真:
> `(defparameter *is-it-even* nil)`
*IS-IT-EVEN*
> `(or (oddp 4) (setf *is-it-even* t))`
T
> `*is-it-even*`
T
如果我们使用一个奇数做同样的事情,变量保持不变:
> `(defparameter *is-it-even* nil)`
*IS-IT-EVEN
> `(or (oddp 5) (setf *is-it-even* t))`
T
> `*is-it-even*`
NIL
这个例子说明了 Lisp 使用 简化的布尔评估。这意味着一旦 Lisp 确定列表中的一个 or 值为真,它就简单地返回真,而不会麻烦地评估剩余的语句。同样,一旦它确定列表中的一个 and 值为假,它就会停止,而不会麻烦地评估其余的语句。
虽然这看起来可能像是一个微不足道的、深奥的观察,但实际上在许多情况下可能非常有用。例如,想象一下,如果你想将文件保存到磁盘,但只有当文件被修改,并且只有当用户希望保存时才这样做。基本结构可以写成如下:
(if *file-modified*
(if (ask-user-about-saving)
(save-file)))
在这里,函数 ask-user-about-saving 会询问用户关于文件的情况,然后根据用户的意愿返回真或假。然而,由于在 Common Lisp 和大多数其他 Lisp 方言中,短路布尔评估保证用于布尔运算,我们可以这样写:
(and *file-modified* (ask-user-about-saving) (save-file))
仅当你超越布尔运算符作为简单数学运算符的典型用途时,才可能使用这种更简洁的样式来评估条件代码。这种形式在三个表达式之间具有优雅的对称性,这可能是某些 Lisp 程序员所喜欢的。然而,其他人可能会争辩说,你的代码的读者可能会轻易忽略 (save-file) 除了返回布尔值之外还做了其他事情的事实。需要一点时间来理解 and 和 or 实际上意味着的更广泛的概念。
编写此代码的第三种方法,它是在前两种方法之间的折衷,如下所示:
(if (and *file-modified*
(ask-user-about-saving))
(save-file)))
许多经验丰富的 Lisper 会认为这个版本比前两个版本更清晰,因为只有明确设计为返回布尔值的表达式才被视为条件的一部分。
使用返回不仅仅是真值的函数
现在,让我们看看 Lisp 简单思考真和假方式的另一个好处。正如我们之前讨论的,Common Lisp 中的任何值(除了nil的不同变体)都是真。这意味着在条件中常用的函数有返回不仅仅是真值的选项。
例如,Lisp 命令member可以用来检查列表中是否存在某个元素:
> `(if (member 1 '(3 4 1 5))`
`'one-is-in-the-list`
`'one-is-not-in-the-list)`
'ONE-IS-IN-THE-LIST
这看起来很简单。然而,再次强调,幕后发生了一些你可能没有预料到的事情。让我们单独运行member命令:
> `(member 1 '(3 4 1 5))`
(1 5)
这里到底发生了什么?为什么它返回(1 5)?
实际上,对此有一个完全合理的解释。每当 Lisper 编写一个返回真和假的函数时,她都会想,“除了 t 之外,我还能返回什么?”由于 Common Lisp 中所有非 nil 值都评估为真,返回其他值本质上是一种免费服务。member函数的实现者决定,某个疯狂的 Lisper 可能会看到列表尾部对于使用此函数的某些计算的价值。
注意
记住从第三章中提到的,列表'(3 4 1 5)与嵌套构造(cons 3 (cons 4 (cons 1 (cons 5 nil))))相同。这应该清楚地说明为什么(cons 1 (cons 5 nil))是member函数容易返回的值。
但为什么它不直接返回找到的值,而不是尾部?实际上,这本来是一个定义member函数的有用方法,因为它允许以这种方式将原始值传递给其他函数。不幸的是,有一个特定的边缘情况会破坏这个计划:
> `(if (member nil '(3 4 nil 5))`
`'nil-is-in-the-list`
`'nil-is-not-in-the-list)`
'nil-is-in-the-list
如您在此示例中看到的,即使我们搜索nil作为成员,member函数仍然给出正确答案!如果member函数实际上返回nil(换句话说,我们正在搜索的原始值),它将被评估为假,示例将错误地声明 nil 不在列表中。然而,由于member函数在找到的项目处返回列表的尾部,可以保证它始终是一个真值。成功发现所需值将始终返回至少包含一个值的列表,我们知道它始终评估为真。
一个真正受益于丰富返回值的函数是find-if,如下所示:
> `(find-if #'oddp '(2 4 5 6))`
5
> `(if (find-if #'oddp '(2 4 5 6))`
`'there-is-an-odd-number`
`'there-is-no-odd-number)`
'there-is-an-odd-number
find-if 函数实际上接受另一个函数作为参数,在这个例子中是 oddp。find-if 将找到列表中第一个使 oddp 返回 true 的值。在这种情况下,它将找到第一个(如果有)奇数。
你可以清楚地看到 find-if 如何扮演双重角色:要么作为匹配某些约束条件的值的检索器,要么作为条件中的真/假值。
注意
关于示例中 oddp 前面的奇怪井号 (#),现在不用担心。我们将在第七章(Chapter 7. 超越基本列表)和第十四章(Chapter 14. 使用函数式编程提升 Lisp 的水平)中更详细地讨论 find-if 函数和其他所谓的更高阶函数。
然而,find-if 函数优雅的对称性有一个单一、微小、丑陋的瑕疵。如果我们再次尝试我们的边缘情况,搜索 nil 值,我们会得到一个相当令人失望的结果:
> `(find-if #'null '(2 4 nil 6))`
NIL
null 函数,对于任何 nil 值都返回 true,正确地找到了 nil。不幸的是,在这个令人烦恼的案例中,我们不想在条件语句中使用 find-if,因为正确找到的值仍然返回一个评估为 false 的结果。对称性已经被打破。
这些就是那些甚至让成熟的 Lispers 流泪的小事情。
比较东西:等式、等于以及其他
Lisp 中有很多美丽的对称性。不过,Lisp 中不那么美丽的一部分涉及比较事物的命令。
如果你想在 Lisp 中比较两个值以确定它们是否“相同”,你会发现一大堆不同的函数,这些函数都声称能完成这个任务。在这些函数中,equal、eql、eq、=、string-equal 和 equalp 是最常用的。Lisper 必须深入了解这些函数的细微差别,才能正确地比较值。

在我们开始剖析这种疯狂之前,让我给你介绍一下康拉德比较东西的经验法则。遵循这个法则,尽管你可能不会写出世界上最干净的 Lisp 代码,但你很可能能够在新 sgroup 上发布一些样本,而不会让更有经验的 Lispers 用火炬和长柄叉把你赶出小镇。

符号应该始终使用 eq 与其他符号进行比较:
> `(defparameter *fruit* 'apple)`
*FRUIT*
> `(cond ((eq *fruit* 'apple) 'its-an-apple)`
`((eq *fruit* 'orange) 'its-an-orange))`
ITS-AN-APPLE
eq 函数是所有 Lisp 比较函数中最简单的,它也非常快。它实际上不适用于比较除符号以外的项,但如果你考虑到符号在 Lisp 中的核心作用,你会意识到这个函数是多么有用。经验丰富的 Lispers 可能会看不起用除 eq 之外的方式比较两个已知是符号的代码。
注意
eq 也可以用来比较连接(由 cons 命令创建的链接)。然而,它仅在连接直接与其自身比较,且由相同的 cons 调用创建时才返回 true 值。这意味着,两个看似完全相同的无关连接可能会在 eq 测试中失败。由于 eq 只能对 cons 单元进行自比较,因此对于初学者来说,使用 eq 与 cons 一起并不是特别有用。然而,对于高级 Lisp 用户来说,在某些情况下可能会想要使用 eq 来比较 cons。
如果你不是在处理两个符号,那么就使用 equal。这个命令会告诉你两个东西是否 isomorphic,即它们“看起来相同”。它适用于整个基本 Lisp 数据类型系列,如下所示:
;;comparing symbols
> `(equal 'apple 'apple)`
T
;;comparing lists
> `(equal (list 1 2 3) (list 1 2 3))`
T
;;Identical lists created in different ways still compare as the same
> `(equal '(1 2 3) (cons 1 (cons 2 (cons 3))))`
T
;;comparing integers
> `(equal 5 5)`
T
;;comparing floating point numbers
> `(equal 2.5 2.5)`
T
;;comparing strings
> `(equal "foo" "foo")`
T
;;comparing characters
> `(equal #\a #\a)`
T
如你所见,Lisp 中的大多数项目都可以有效地使用 equal 进行比较,包括字符串和字符(将在下一章中讨论)。
现在你对 Lisp 比较的最基本知识已经足够让你在下一个鸡尾酒会上混个脸熟,让我们来看看所有其他的比较命令。

eql 命令与 eq 命令类似,但与 eq 不同,它还处理数字和字符的比较:
;;comparing symbols
> `(eql 'foo 'foo)`
T
;;comparing numbers
> `(eql 3.4 3.4)`
T
;;comparing characters
> `(eql #\a #\a)`
T
equalp 命令基本上与 equal 命令相同,但它可以处理一些带有额外复杂性的困难比较情况。例如,它可以比较不同大小写的字符串,可以比较整数与浮点数:
;;comparing strings with different CAPS
> `(equalp "Bob Smith" "bob smith")`
T
;;comparing integers against floating point numbers
> `(equalp 0 0.0)`
T
剩余的比较命令只是针对特定数据类型的特殊化。否则,它们与 equal 类似。例如,等号(equal sign)函数处理数字,string-equal 处理字符串,char-equal 处理字符。
我希望你现在可以真正理解 Lisp 用户对比较的重视程度。
你学到的内容
在本章中,我们讨论了 Lisp 中条件的工作方式。在这个过程中,你学习了以下内容:
-
nil、'nil、()和'()在 Common Lisp 中基本上是同一件事。 -
Lisp 使得检查空列表变得容易。这使得编写列表消耗者变得简单。
-
Lisp 条件,如
if命令,仅在正确条件下才会评估 Lisp 代码。 -
如果你需要一个能够做所有事情的条件命令,那么你应该使用
cond。 -
在 Lisp 中比较东西很复杂,但如果你只是用
eq来比较符号,用equal来比较其他所有东西,你就可以应付了。
第五章:构建文本游戏引擎
当你编写程序时,无论你使用哪种编程语言或你的程序做什么,它可能都需要处理文本。当然,总有一天我们可能都会在我们的头骨底部有以太网端口(到那时,100Mbps 以太网将完全被采用)。但在你能够直接通过连接与 MacBook 交换思想的那一天到来之前,你将不得不在软件中使用字母文本进行输入和输出。
计算机始终与文本保持一种脆弱的关系。尽管我们倾向于认为文本处理是计算机硬件和软件的中心任务(确实,8 位字节是现代计算机的标准设计元素,在很大程度上,这是由于它非常适合编码西文字符集),但事实的真相是,人类对文本的概念对计算机来说实际上是陌生的。
在本章中,你将学习如何使用 Lisp 来操纵文本。你将再次看到,Lispy 解决问题的方法允许你创建充满优雅和对称性的代码。为了展示这种方法,我们将做一些似乎不可避免地需要用文本思考的事情:构建一个简单的文本冒险游戏的引擎。然而,我们将以避免通过人为地将人类的文本概念强加于其设计来约束我们的代码的方式来做这件事。这将使我们能够编写专注于计算机优势的代码。
阅读本章时,请记住,处理文本并不是计算机的长处。这是一个必要的恶,最好将其保持在最低限度。
巫师的冒险游戏
在这个游戏中,你是一名巫师的学徒。你将探索巫师的房子。当我们完成游戏(在第十七章 Chapter 17 中)时,你将能够解决谜题并赢得一个魔法甜甜圈。
我们的游戏世界
这里是我们游戏世界的一张图片:

如你所见,我们可以访问三个不同的地点:客厅、阁楼和花园。玩家可以通过门和阁楼的梯子来在地方之间移动。
将这个游戏世界想象成一个简单的有向图,有三个节点(用椭圆表示)和四条边(用箭头表示):

玩家可以通过沿着边在两个方向上移动节点之间。无论玩家在哪里,他们都可以与他们周围的各种物体互动。

基本要求
我们的游戏代码需要处理一些基本的事情:
-
环顾四周
-
走向不同的地点
-
拾起物体
-
对拾起的物体执行操作
在本章中,我们将解决这些要求中的前三个。为了在物体上执行更复杂的行为,我们将使用后面章节中介绍的更高级的 Lisp 技术。因此,我们的游戏引擎在完成第十七章之前将具有一定的局限性。
当在游戏世界中环顾四周时,你将能够从任何位置“看到”三种类型的事物:
-
基本风景
-
到其他地点的一条或多条路径
-
可以捡起并操纵的物体
让我们一次添加一个这些功能。
使用关联列表描述风景
我们冒险游戏中的世界非常简单,只包含三个位置。让我们首先创建一个顶层变量 *nodes*,以包含我们游戏中存在的位置的描述:
(defparameter *nodes* '((living-room (you are in the living-room.
a wizard is snoring loudly on the couch.))
(garden (you are in a beautiful garden.
there is a well in front of you.))
(attic (you are in the attic.
there is a giant welding torch in the corner.))))
这个变量包含了一个列表和三个位置的描述。本质上,*nodes* 变量基本上给我们提供了一种查找与查找键相关联的数据的方法。在这种情况下,键是地点的名称(living-room、garden 或 attic),而数据是那个地点的文本描述。这种结构被称为 关联列表,或简称为 alist(alist 在第七章超越基本列表中有更详细的介绍))。
关于这个 *nodes* 变量的定义有一件相当不寻常的事情:尽管它包含了我们游戏世界中各种位置的描述,但它实际上并不包含任何文本字符串。由于 Common Lisp 有字符串数据类型,我们本可以使用引号来编写描述。例如,我们可以写成 "你在一个美丽的花园里。你面前有一个井。" 而不是这样,我们使用更基本的数据类型——符号和列表——来编码这些信息。
我们为什么不直接使用字符串呢?正如我在本章开头提到的,文本操作并不是真正的计算基本概念。在这个游戏中,我们将以复杂的方式操作玩家与游戏世界交互时显示的消息。对于大多数现实世界的程序,你将生成的输出信息(如 HTML、PDF 或甚至更丰富的图形格式)可能比简单的文本复杂得多。
通过从一开始就确保你的源数据结构不受输出格式的假设影响,你的编码可以充分利用你的编程语言。由于在 Lisp 中最易操作的是符号和列表,大多数经验丰富的 Lisp 程序员在可能的情况下都会尝试在软件设计中专注于这些数据类型。因此,在我们的设计中我们将避免使用字符串。(在下一章中,我们将把这些列表和符号转换成正确格式的文本。)
注意
Common Lisp 并不强制你以这种方式使用列表和符号来表示字符串。如果更方便,你可以直接处理字符串。(你将在本书后面的例子中看到如何处理字符串,特别是在第十一章打印文本的 format 函数中。)使用列表和符号作为操作文本的中间件无疑是传统的 Lisp 技术。然而,它往往能产生非常优雅的代码,因为列表操作对于 Lisp 来说是如此基础。
描述位置
现在我们已经创建了我们游戏世界的 alist,我们需要创建一个命令来描述一个位置。为了完成这个任务,我们将使用 assoc 函数通过一个键来在列表中找到正确项:
> `(assoc 'garden *nodes*)`
(GARDEN (YOU ARE IN A BEAUTIFUL GARDEN. THERE IS A WELL IN FRONT OF YOU.))
使用 assoc,我们可以轻松地创建 describe-location 函数:
(defun describe-location (location nodes)
(cadr (assoc location nodes)))
要使用此功能,我们需要传递一个位置和*nodes*列表:
> `(describe-location 'living-room *nodes*)`
(YOU ARE IN THE LIVING-ROOM. A WIZARD IS SNORING LOUDLY ON THE COUCH.)
为什么我们不直接从describe-location函数中引用*nodes*变量呢?因为这个函数是用函数式编程风格编写的。在这种风格中,一个函数将只引用函数本身中声明的参数或变量,并且除了返回一个值(在这种情况下是位置的描述)之外,不会做任何事情。
通过编写不直接引用“外部世界”中的变量且不执行任何除了返回值之外的操作的函数,你可以编写可以轻松隔离测试的代码。你应该尽可能以这种方式编写你的 Lisp 函数。(我们将在第十四章第十四章中更详细地讨论函数式编程风格。)

描述路径
现在我们已经有了每个位置的基本描述,我们还需要其他位置的路径描述。我们将创建第二个变量*edges*,它包含玩家可以在地图上移动到其他地点的路径。 (我们使用术语edges,因为这是连接图中节点的线的正确数学术语。)
(defparameter *edges* '((living-room (garden west door)
(attic upstairs ladder))
(garden (living-room east door))
(attic (living-room downstairs ladder))))
使用这种结构,我们创建了一个describe-path函数,它使用我们的符号系统构建给定边的文本描述。
(defun describe-path (edge)
`(there is a ,(caddr edge) going ,(cadr edge) from here.))
这个describe-path函数看起来相当奇怪——几乎更像是一段数据而不是一个函数。让我们试一试,然后弄清楚它是如何工作的。
> `(describe-path '(garden west door))`
(THERE IS A DOOR GOING WEST FROM HERE.)
这个函数基本上返回一个包含少量计算信息的片段数据。Lisp 的这个特性称为准引用,它允许我们创建包含少量 Lisp 代码嵌入的数据块。
如何使用准引用
要启用准引用,你必须使用反引号[`]而不是单引号[']在从代码模式切换到数据模式时。describe-path函数中就有这样一个反引号。
Lisp 中的单引号和反引号都将一段代码“翻转”成数据模式,但只有反引号可以使用逗号字符进行取消引用,从而翻转回代码模式。
用一点想象力,这应该对你来说是有意义的。毕竟,逗号看起来就像一个倒置的反引号,不是吗?下面是如何在describe-path函数中实现翻转的(代码模式的部分被阴影覆盖):

Lisp 试图使列表操作尽可能简单。在这里,你可以看到我们的程序,它使用符号列表来存储我们的文本,现在可以利用准引用功能以非常简洁和清晰的方式构建句子。
描述多个路径
现在,让我们使用我们的 describe-path 函数来创建一个更高级的函数。由于一个位置可能从它那里有任意数量的路径退出,我们需要一个函数,可以通过查找我们的边数据结构来生成从给定位置的所有边的描述:
(defun describe-paths (location edges)
(apply #'append (mapcar #'describe-path (cdr (assoc location edges)))))
这个函数使用了一些可能对不熟悉 Lisp 世界的人来说非常陌生的命令。许多编程语言会使用某种形式的 for-next 循环来遍历边,然后使用临时变量将每条路径的描述组合在一起。Lisp 使用一种更优雅的方法。让我们看看它是如何工作的:
> `(describe-paths 'living-room *edges*)`
(THERE IS A DOOR GOING WEST FROM HERE. THERE IS A LADDER GOING UPSTAIRS FROM HERE.)
describe-paths 函数执行以下步骤:
-
找到相关边。
-
将边转换为描述。
-
合并描述。
让我们看看它是如何执行这些步骤的。
寻找相关边。
describe-paths 函数的第一部分相当直接。为了找到从客厅出发的相关路径和边,我们再次使用 assoc 在边的列表中查找位置:
> `(cdr (assoc 'living-room *edges*))`
((GARDEN WEST DOOR) (ATTIC UPSTAIRS LADDER))
将边转换为描述。
接下来,将边转换为描述。以下只是完成此任务的代码,单独展示:
> `(mapcar #'describe-path '((GARDEN WEST DOOR) (ATTIC UPSTAIRS LADDER)))`
((THERE IS A DOOR GOING WEST FROM HERE.)
(THERE IS A LADDER GOING UPSTAIRS FROM HERE.))
Lisp 程序员经常使用 mapcar 函数。这个函数接受另一个函数和一个列表,然后将这个函数应用到列表的每一个成员上。以下是一个例子:
> `(mapcar #'sqrt '(1 2 3 4 5))`
(1 1.4142135 1.7320508 2 2.236068)
这个例子将 sqrt(平方根)函数和 (1 2 3 4 5) 列表传递给 mapcar。结果,该函数通过将 sqrt 应用到列表的每个成员并创建一个新列表,生成原始数字的平方根列表。
接受其他函数作为参数的函数,如 mapcar,非常有用,并且是 Lisp 的一个显著特征。这些函数被称为 高阶函数。
这里是另一个例子:
> `(mapcar #'car '((foo bar) (baz qux)))`
(foo baz)
这次,我们的源列表包含两个较小的列表。car 函数,它获取列表中的第一个项目,导致 mapcar 返回每个较小列表 foo 和 baz 的第一个项目。
你可能想知道我们传递给 mapcar 的函数名前为什么有 #' 符号。这个符号序列是 function 操作符的缩写。Lisp 读取器(你的 Lisp 环境中读取你输入代码的部分)会将前面的例子转换为以下更长的版本:
> `(mapcar (function car) '((foo bar) (baz qux)))`
(foo baz)
Common Lisp 要求你在直接将函数作为值引用时使用 function 操作符,因为函数的名称可能与程序中的其他命名项冲突,导致不可预测的错误。例如,想象如果我们向前面的例子添加更多内容,如下所示:
> `(let ((car "Honda Civic"))`
`(mapcar #'car '((foo bar) (baz qux))))`
(foo baz)
在这个版本中,car 符号可能有两种不同的含义。car 的第一种含义是它是 Lisp 中内置的标准函数(在第三章第三章。探索 Lisp 代码的语法中介绍)。然而,我们也在创建一个名为 car 的局部变量
。由于我们在调用 mapcar 时在 car 前面加上了 #'
,所以我们不会混淆我们正在谈论的是哪个 car。
现在,让我们再次看看 describe-paths 函数:
(defun describe-paths (location edges)
(apply #'append (mapcar #'describe-path (cdr (assoc location edges)))))
注意 append 和 describe-path 函数是如何作为值传递给 apply 和 mapcar 函数的,这些函数被设计用来接收和使用函数。
Common Lisp 对函数名和变量名的跟踪方式不同。它有多个 命名空间,包括一个用于变量和一个用于函数的。(我们将在第十六章第十六章。Lisp 宏的魔法中了解更多关于命名空间的内容。)另一种流行的 Lisp 方言 Scheme,在将函数用作值时,并不强制要求你用函数操作符标记函数。
换句话说,Scheme 只有一个命名空间用于函数和变量。例如,在 Scheme 中,你可以直接编写 (map sqrt '(1 2 3 4 5)) 来生成从 1 到 5 的数字的平方根,而不会产生错误(map 是 mapcar 的 Scheme 版本)。由于这种设计,在 Scheme 中,一个变量和单独的函数不能在相同的代码块中可用。这个设计决策是 Scheme 的巨大好处(或诅咒)之一,这取决于你的观点。由于命名空间数量的这种差异,Scheme 有时被称为 Lisp-1,而 Common Lisp 有时被称为 Lisp-2。
加入描述
一旦我们使用 mapcar 生成所有路径和边的描述列表,我们需要将它们合并成一个单一的描述。我们通过 append 函数完成这个任务,它将几个列表合并成一个大的列表:
> `(append '(mary had) '(a) '(little lamb))`
(MARY HAD A LITTLE LAMB)
我们使用 append 函数将路径描述的列表合并成一个描述整个事物的列表,一次完成。问题是 append 需要所有传递给它的列表作为单独的参数。在 describe-paths 中,我们的列表是一个大列表,而不是可以作为参数传递的单独对象。实际上,我们甚至不知道从任何给定位置可能有多少条路径。
apply 函数解决了这个问题。你传递给它一个函数和一个对象列表,它假装列表中的项是单独的对象,并将它们作为这样的对象传递给指定的函数。例如,如果我们有一个嵌套列表 '((mary had) (a) (little lamb)),apply 函数将添加一小块胶带,使 append 函数能够与单个大列表一起工作:
> `(apply #'append '((mary had) (a) (little lamb)))`
(MARY HAD A LITTLE LAMB)
警告
由于apply函数将列表中的每个项目作为参数传递给target函数,当在包含数千项或更多项的非常大的列表上调用它时,你可能会遇到问题。你可以在 REPL 中检查call-arguments-limit变量的值,以查看函数允许的最大参数数量。(较新的 Lisp 方言通常设计为允许任何大小的参数列表,而没有人工限制。)
你可以看到apply如何使describe-paths函数能够构建一个长列表,描述从单个位置出发的所有路径。让我们使用这种方法来处理我们构建的路径描述列表:
> `(apply #'append '((THERE IS A DOOR GOING WEST FROM HERE.)`
`(THERE IS A LADDER GOING UPSTAIRS FROM HERE.)))`
(THERE IS A DOOR GOING WEST FROM HERE. THERE IS A LADDER GOING UPSTAIRS FROM HERE.)
现在我们已经查看完describe-paths函数的每个部分,让我们回顾一下它是如何工作的:
(defun describe-paths (location edges)
(apply #'append (mapcar #'describe-path (cdr (assoc location edges)))))
该函数接受两个参数:当前玩家的位置以及游戏地图的边/路径的关联列表。首先,它使用assoc从边列表中查找正确的位置。由于assoc从关联列表中返回键和值,我们调用cdr来检索仅值。接下来,我们使用mapcar将describe-path函数映射到我们找到的每个边上。最后,通过应用append将描述所有路径的列表连接成一个长列表。
describe-path使用的编程风格对于 Lisp 代码来说非常典型。它涉及传递一个复杂的数据块并在几个步骤中对其进行操作,通常使用高阶函数。要成为一名熟练的 Lisp 程序员,你应该尝试习惯阅读以这种方式编写的代码。
描述特定位置的对象
为了创建帮助我们可视化游戏世界的最终代码片段,我们需要描述给定位置地板上的对象,玩家可以捡起并使用它们。
列出可见对象
要做到这一点,我们首先创建一个对象列表:
> `(defparameter *objects* '(whiskey bucket frog chain))`
*OBJECTS*

我们还可以创建第二个变量*object-locations*,以关联列表的形式跟踪每个对象的位置:
(defparameter *object-locations* '((whiskey living-room)
(bucket living-room)
(chain garden)
(frog garden)))
接下来,我们编写一个函数来列出从给定位置可见的对象:
(defun objects-at (loc objs obj-locs)
(labels ((at-loc-p (obj)
(eq (cadr (assoc obj obj-locs)) loc)))
(remove-if-not #'at-loc-p objs)))
objects-at函数使用labels命令声明了一个名为at-loc-p的新函数!。 (记住,labels函数允许你在局部定义函数。) 由于at-loc-p函数不会在其他地方使用,我们可以在objects-at函数内部直接声明它,从而将其隐藏在程序中其他代码之外。
at-loc-p函数接受一个对象的符号,并根据该对象是否存在于位置loc返回t或nil。它是通过在obj-locs关联列表中查找对象来做到这一点的。然后,它使用eq来查看找到的位置是否与所讨论的位置匹配!。
为什么我们把这个函数命名为 at-loc-p?当一个函数返回 nil 或一个真值时,这是 Common Lisp 的一个约定,在函数名称的末尾附加一个 p。例如,你可以通过调用 (oddp 5) 来检查数字 5 是否为奇数。这样的真/假函数被称为 predicates,这就是为什么我们使用字母 p。
列表最后一行的 remove-if-not 函数,正如你所预期的那样,会从列表中移除所有那些传入的函数(在这种情况下,at-loc-p)不返回 true 的项目。本质上,它返回一个过滤后的对象列表,包含那些 at-loc-p 返回 true 的项目。
下面是 object-at 函数的实际应用:
> `(objects-at 'living-room *objects* *object-locations*)`
(WHISKEY BUCKET)
描述可见对象
现在,我们可以编写一个函数来描述给定位置可见的对象:
(defun describe-objects (loc objs obj-loc)
(labels ((describe-obj (obj)
`(you see a ,obj on the floor.)))
(apply #'append (mapcar #'describe-obj (objects-at loc objs obj-loc)))))
在这个列表中,describe-objects 首先创建了一个 describe-obj 函数!。这个函数生成一个漂亮的句子,说明一个给定的对象在地板上,使用伪引用!。函数的主要部分是调用 objects-at 来找到当前位置的对象,将 describe-obj 映射到这个对象列表上,并最终将这些描述追加到一个单独的列表中!。
让我们尝试运行 describe-objects:
> `(describe-objects 'living-room *objects* *object-locations*)`
(YOU SEE A WHISKEY ON THE FLOOR. YOU SEE A BUCKET ON THE FLOOR)
完美!

描述一切
现在,我们将所有这些描述函数整合到一个简单的命令 look 中。因为这个命令将是玩家在游戏中四处张望时可以输入的实际命令,所以 look 需要知道玩家的当前位置。因此,我们需要一个变量来跟踪玩家的当前位置。让我们称它为 *location*:
(defparameter *location* 'living-room)
因为 *location* 的值初始化为 living-room 符号,它在游戏开始时出现,玩家将发现自己身处巫师的房子客厅里。在这个时候,我们可以编写一个 look 函数来描述我们所需要的一切,通过调用所有的描述函数来实现:
(defun look ()
(append (describe-location *location* *nodes*)
(describe-paths *location* *edges*)
(describe-objects *location* *objects* *object-locations*)))
由于 look 函数使用全局变量名(如 *location*、*nodes* 等),玩家在查看世界时不需要传递任何奇怪的值。然而,这也意味着 look 函数不是函数式编程风格,因为在函数式编程风格中,函数只引用函数本身声明的参数或变量。*location* 及其类似物是全局变量,所以 look 函数不符合标准。
由于玩家的位置在游戏过程中会发生变化,look 在游戏中的不同时间会做不同的事情。换句话说,当你环顾四周时看到的物品会根据你的位置而变化。相比之下,函数式编程风格中的函数只要参数相同,总是返回相同的结果。我们之前创建的函数,如 describe-location、describe-paths 和 describe-objects,无论何时调用,只要它们的参数保持不变,总是返回相同的内容。
现在我们来看看使用 look 时我们看到的内容:
> `(look)`
(YOU ARE IN THE LIVING-ROOM OF A WIZARD’S HOUSE.
THERE IS A WIZARD SNORING LOUDLY ON THE COUCH.
THERE IS A DOOR GOING WEST FROM HERE.
THERE IS A LADDER GOING UPSTAIRS FROM HERE.
YOU SEE A WHISKEY ON THE FLOOR.
YOU SEE A BUCKET ON THE FLOOR)

在我们的世界中四处走动
现在我们可以看到我们世界中的事物了,让我们编写一些代码,以便我们可以四处走动。walk 函数(不是函数式风格)接受一个方向,并允许我们走到那里:
(defun walk (direction)
(let ((next (find direction
(cdr (assoc *location* *edges*))
:key #'cadr)))
(if next
(progn (setf *location* (car next))
(look))
'(you cannot go that way.))))
首先,这个函数会在 *edges* 表中查找可用的行走路径,使用当前的位置 ![httpatomoreillycomsourcenostarchimages783562.png]。这是由 find 函数用来定位带有适当方向的路径 ![httpatomoreillycomsourcenostarchimages783564.png]。(find 在列表中搜索一个项目,然后返回找到的项目。)方向(如 west、upstairs 等)将位于每个路径的 cadr 中,因此我们需要告诉 find 将 direction 与列表中所有路径的 cadr 进行匹配。
我们可以通过向 find 传递一个 关键字参数 ![httpatomoreillycomsourcenostarchimages783560.png] 来做到这一点。在 Common Lisp 中,许多函数(如 find)具有内置功能,可以通过在函数调用末尾传递特殊参数来访问。例如,以下代码在列表中找到第一个在 cadr 位置有符号 y 的项目:
> `(find 'y '((5 x) (3 y) (7 z)) :key #'cadr)`
(3 Y)
关键字参数有两个部分:
-
第一部分是名称(在这个例子中是
:key),它以冒号开头。(我们将在 第七章 中更详细地讨论这个冒号的意义。) -
第二个是值,在这个例子中是
#'cadr。
在我们的 walk 函数中,我们以相同的方式使用关键字参数来根据给定的方向找到合适的路径。
一旦我们得到正确的路径,我们就将结果存储在变量 next 中!。然后 if 表达式检查 next 是否有值!(next 变量不是 nil)。如果 next 有值,if 就会调整玩家的位置,因为这是一个有效方向!。调用 look 函数!检索新位置的描述并将其作为值返回。如果玩家选择了一个无效方向,look 将生成一个警告而不是新描述!。
现在我们 walk 函数的样子如下:
> `(walk 'west)`
(YOU ARE IN A BEAUTIFUL GARDEN.
THERE IS A WELL IN FRONT OF YOU.
THERE IS A DOOR GOING EAST FROM HERE.
YOU SEE A CHAIN ON THE FLOOR.
YOU SEE A FROG ON THE FLOOR.)
方向前有一个引号,因为方向名需要以数据模式书写。强迫玩家在游戏命令中放置引号有点尴尬,但我们现在创建的界面是为了方便调试和开发。实际上,这几乎都不值得称为“界面”,因为我们只是直接将游戏命令输入到 REPL 中。在下一章中,我们将创建一个更漂亮的界面,使用定制的 REPL,它专为玩文本游戏而设计,将处理这个瑕疵。
注意
你可以使用 Lisp 宏 在不需要在方向前加引号的 vanilla Lisp REPL 中创建命令,这样你就可以直接写 (walk west),例如。你将在第十六章(第十六章。Lisp 宏的魔力)中了解更多关于宏的内容。
拾取物体
接下来,让我们创建一个命令来拾取我们世界中的物体。为此,我们修改了用于跟踪物体位置的变量 *object-locations*:
(defun pickup (object)
(cond ((member object
(objects-at *location* *objects* *object-locations*))
(push (list object 'body) *object-locations*)
`(you are now carrying the ,object))
(t '(you cannot get that.))))
pickup 函数使用 member 函数来检查 object 是否确实位于当前位置的地板上。(member 函数检查特定项目是否在项目列表中。)我们使用 objects-at 命令来生成当前位置的物体列表。
如果物体位于当前位置,我们使用 push 命令!将一个新项目推入 *object-locations* 列表中,包括项目和它的新位置。新位置将是 body,即玩家的身体。
push 命令!简单地将一个新项目添加到列表变量的列表前面。例如,以下示例将数字 7 添加到列表 1 2 3 中:
> `(defparameter *foo* '(1 2 3))`
*FOO*
> `(push 7 *foo*)`
(7 1 2 3)
> `*foo*`
(7 1 2 3)
这个 push 命令基本上是在 setf 之上构建的一个便利函数。例如,我们可以用 (setf *foo* (cons 7 *foo*)) 来替换前面的 push 命令,并得到相同的结果。只是使用 push 更简单。
将一个新位置推送到我们的 *object-locations* 列表确实看起来有点奇怪。因为我们从未删除过对象的老位置,只是推送新的位置,这意味着 *object-locations* 可能会包含单个对象的多个条目,而这个列表现在为该对象存储了两个位置。幸运的是,我们用来在给定位置(在 objects-at 命令中)查找对象的 assoc 命令总是返回列表中找到的第一个项目。因此,使用 push 命令使得 assoc 命令表现得好像列表中给定键的值已经被完全替换。
使用 push 和 assoc 命令以这种方式一起使用,我们可以假装 alist 中的值在变化,同时仍然保留旧值。旧值只是被新值压制,从而保留所有旧值的历史。push/assoc 习语是 Lisper 常用的技术。
现在,让我们回到客厅并尝试拿起一个物品:
> `(walk 'east)`
(YOU ARE IN THE LIVING-ROOM OF A WIZARDS HOUSE. THERE IS A WIZARD SNORING
LOUDLY ON THE COUCH. THERE IS A DOOR GOING WEST FROM HERE. THERE IS A LADDER
GOING UPSTAIRS FROM HERE. YOU SEE A WHISKEY ON THE FLOOR. YOU SEE A BUCKET ON
THE FLOOR.)
> `(pickup 'whiskey)`
(YOU ARE NOW CARRYING THE WHISKEY)
它成功了。我们现在携带了威士忌,这意味着我们现在可以在我们的世界中拿起东西了!
检查我们的库存
最后,让我们创建一个允许玩家查看他们携带的物品清单的函数:
(defun inventory ()
(cons 'items- (objects-at 'body *objects* *object-locations*)))
这个库存函数使用 objects-at 函数检索请求位置上的对象列表。它搜索什么位置?如果你还记得,当一个对象被玩家拿起时,我们将其位置更改为 'body':这是我们现在用来查询的位置。
让我们尝试一下这个 inventory 函数:
> `(inventory)`
(ITEMS- WHISKEY)
如你所见,我们现在只携带一个物品:我们刚刚捡起的威士忌瓶子。
就这样!我们现在有一个基本的文字冒险游戏引擎。我们可以用 look 来环顾四周;用 walk 在地方之间行走;用 pickup 拿起物品;用 inventory 检查我们的库存。
当然,我们实际上并没有一个真正的游戏,因为我们无法对找到的对象进行任何操作。我们将在第十七章(第十七章)中添加一个实际操作对象的机制。在下一章中,我们将专注于改进我们游戏的用户界面。尽管 REPL 非常适合我们的游戏原型设计,但添加一个定制的文本游戏界面将使游戏对玩家来说玩起来更加流畅。
你学到了什么
在本章中,我们为文字冒险游戏构建了一个简单的引擎。在这个过程中,你学习了以下内容:
-
游戏世界可以用一个数学图来表示,其中包括玩家可以访问的
*nodes*(节点)和这些地方之间的*edges*(边)。 -
你可以将这些节点存储在一个名为
*nodes*的*association list*(关联列表)中。这个列表允许你通过节点的名称来查找节点/位置的性质。在我们的游戏中,我们存储的性质是每个节点/位置的描述。 -
你使用
assoc函数在一个 alist 中查找一个键(在我们的例子中是位置名称)。 -
伪引号是一种技术,允许你将小块计算机代码插入到更大的数据块中。
-
一些 Lisp 函数接受其他函数作为参数。这些被称为高阶函数。
mapcar函数是 Common Lisp 中最受欢迎的高阶函数。 -
要替换 alist 中的值,你需要将新项目
push到列表中。assoc函数只会报告最新的值。
第六章. 与世界交互:Lisp 中的读取和打印
到目前为止,我们还没有编写任何直接与外界交互的代码。相反,所有由命令生成的结果都只是作为值返回,我们可以通过调用我们的 Lisp REPL 中的函数来看到这些值。
然而,代码不能整日坐在黑盒子里。在某个时候,它将需要与世界交互,因此它需要一个用户界面。幸运的是,Lisp 提供了许多帮助创建用户界面的工具。有针对不同 Common Lisp 版本的各种图形用户界面库,以及用于构建 Web 界面的库。实际上,我们将在第十三章构建 Web 服务器!中构建自己的玩具 Web 界面。
在本章中,我们将关注所有用户界面中最基本的,即命令行界面。

打印和读取文本
对于命令行界面,我们需要能够直接从屏幕打印文本并读取用户输入的文本的命令。执行此操作的两个命令恰当地是 print 和 read。正如你所期望的,这两个命令之间有很多对称性。
打印到屏幕
print 函数简单地允许你将内容打印到控制台:
> `(print "foo")`
"foo"
"foo"
不要被调用 print 函数导致 "foo" 被打印两次的事实所困惑。第一个 "foo"
是 print 函数实际上打印的内容。第二个 "foo"
存在是因为,正如你所知,REPL 总是打印输入的任何表达式的值。恰好 (print "foo") 的值是 "foo",导致单词被显示两次。在本章接下来的示例中,我通常会省略 REPL 打印的额外最终值,以避免混淆。
print 函数是一种将 Lisp 值打印到屏幕的简单方法。然而,高级 Lisp 程序员通常更喜欢一个相关的函数,称为 prin1。为了理解两者之间的区别,让我们在 REPL 中尝试这两个函数:
> `(progn (print "this")`
`(print "is")`
`(print "a")`
`(print "test"))`
"this"
"is"
"a"
"test"
print 函数会导致每个项目单独打印在一行上。现在,让我们尝试 prin1:
> `(progn (prin1 "this")`
`(prin1 "is")`
`(prin1 "a")`
`(prin1 "test"))`
"this""is""a""test"
如您所见,prin1 不会将打印的项放在单独的行上。更准确地说,print 和 prin1 命令在各个方面都是相同的,除了 print 在打印值之前会开始新的一行。此外,print 还会在打印值的末尾放置一个空格字符。
因为 prin1 做得较少,它实际上是一个更简单、更基本的函数。它更灵活,因此通常在更严肃的 Lisp 代码中使用。在这本书中,我们将更频繁地使用 print 函数,但您应该了解 prin1 命令。
向用户问好
以下是一个简单的函数示例,名为 say-hello,您可以从 Lisp 提示符中调用它。它会询问用户的姓名,并以问候语作为回应。当您运行程序时,请务必在您的姓名周围输入引号,即使这看起来可能有些奇怪。
> `(defun say-hello ()`
`(print "Please type your name:")`
`(let ((name (read)))`
`(print "Nice to meet you, ")`
`(print name)))`
SAY-HELLO.
> `(say-hello)`
"Please type your name:" `"bob"`
"Nice to meet you,"
"bob"
在 say-hello 函数的第一行中,我们打印了一条消息,询问用户输入他们的姓名
。然后,我们定义了一个名为 name 的局部变量,并将其设置为 read 函数返回的值
。read 函数将使 Lisp 等待用户在 REPL 中输入某些内容。只有当用户在提示符中输入了一些内容并按下回车键后,变量 name 才会被设置为结果。一旦我们知道用户的姓名,就会打印一条个性化的消息,问候用户 
。
如您从这个简单的函数中可以看到,print 和 read 函数(几乎)完全符合您的预期。print 函数会在屏幕上打印一些内容。read 函数允许用户将一些内容输入到程序中。然而,这些函数中有一个明显的特性:显示和输入的每个值都被引号包围。
从 print 和 read 开始
当您需要在屏幕上打印某些内容时,您应该首先考虑使用 print 命令。如果您需要读取某些内容,您应该首先考虑使用 read 命令。其他打印命令可以让您创建之前的示例,而不需要多余的引号,但每当您在 Lisp 中进行输入或输出任务时,您都应该问自己,“print 或 read 能完成这项工作吗?”如果您始终以这两个函数作为起点,您将节省很多麻烦。
警告
如果使用不当,read 命令可能会很危险。有关详细信息,请参阅《read 和 eval 的危险》。
print 和 read 函数是用计算机的思维来考虑值的,而不是人类的思维。计算机喜欢被引号包围的文本字符串。它没有人类的头脑,因此当我们给它提供原始文本信息时,它无法理解我们的意图。然而,如果一个文本片段被引号包围,即使是愚蠢的老式计算机也能推断出我们传递给它的值可能是一串文本。
print 和 read 命令实际上将这种哲学推向了极致。在 Lisp 中几乎任何可想象的数据类型(除了实际函数和一些高级数据结构)都可以使用这些命令打印和读取,而不会有一点损失。你可能已经能够想象出一些这个特性会非常有价值的场景,比如将一些复杂和庞大的数据写入文件,然后在以后再次加载它。
作为简单的例子,以下代码与前面的函数有完全相同的设计,但令人惊讶的是,它可以读取和打印一个数字而不是字符串。注意程序如何在不使用引号的情况下打印和读取数字,因为 Lisp 只需看到其原始形式中的数字就能知道它是什么。
> `(defun add-five ()`
`(print "please enter a number:")`
`(let ((num (read)))`
`(print "When I add five I get")`
`(print (+ num 5))))`
ADD-FIVE
> `(add-five)`
"please enter a number:" `4`
"When I add five I get"
9
让我们看看当我们使用 print 来输出值时会发生什么的一些更多例子。
`(print '3)` => 3 *`An integer`*
`(print '3.4)` => 3.4 *`A float`*
`(print 'foo)` => FOO
*`A symbol. It may be printed in all caps, since Common`*
*`Lisp symbols are blind to letter case.`*
`(print '"foo")` => "foo" *`A string`*
`(print '#\a)` => #\a *`A character`*
这些例子都非常无聊,因为 print 几乎只是打印出我们放入的内容。请注意,我们在每个值的前面都明确地放置了引号。它可以省略,在所有情况下都是隐式的,除了符号名称,因为符号也可以指代函数。
最后一个例子展示了如何在 Lisp 中输入字面字符。要创建一个 Lisp 字符,只需在实际字符前放置 #\ 符号。Lisp 还为不可见字符定义了特殊的字面量。对于日常使用来说,最重要的是 #\newline、#\tab 和 #\space。
read 函数的输出表将和 print 函数的这张表一样无聊,以同样的对称方式。
注意
在上面的例子中,我提到 Common Lisp 符号对字母大小写是盲目的。虽然这对于大多数字符串来说是正确的,但实际上可以通过用垂直管道 | 包围符号来创建大小写敏感的符号。因此,符号 |CaseSensitiveSymbol| 将保留其大小写。被垂直管道包围的符号甚至可以包含标点符号。因此 |even this is a legal Lisp symbol!| 也是一个合法的 Lisp 符号!
以人类喜欢的方式读取和打印内容
当然,我们最初的 say-hello 函数在问候人们方面做得相当糟糕,即使它有一些有趣的特性。如果我们有更多可以使它对人类更友好的函数会更好。实际上,我们可以创建一个(非常对称的)小表格,总结我们想要的内容:

如你所见,Lisp 有一个命令可以以对人类有吸引力的方式打印数据片段。princ函数可以接受任何 Lisp 数据,并尝试以人类更喜欢的形式打印这些数据。它会做你可能会期望的基本事情:省略字符串上的引号,以原始形式打印字符等等。以下是一些示例:
`(princ '3)` => 3
`(princ '3.4)` => 3.4
`(princ 'foo)` => FOO
`(princ '"foo")` => foo
`(princ '#\a)` => a
这里是一个示例,展示如何princ一个具有特殊意义的字符:
> `(progn (princ "This sentence will be interrupted")`
`(princ #\newline)`
`(princ "by an annoying newline character."))`
This sentence will be interrupted
by an annoying newline character.
从本质上讲,princ可以用来打印任何你想要的字符输出。这与print有根本的不同。正如我们讨论过的,print命令的酷之处在于它以某种方式打印对象,使得它们总能被“读取”回它们的内部表示。然而,这意味着print不能用来生成任何任意的文本。另一方面,princ可以用来打印任何你想要的东西。
因此,尽管princ可以以人类更喜欢的形式打印内容,但它是一条单行道。一旦我们用princ打印了东西,只有类似人类的智能才能解读如何将这些内容转换回有意义的、适当的 Lisp 数据结构。由于计算机目前还无法做到这一点,这意味着我们心爱的对称性已经被打破。
当然,我们总是可以作弊并制定一些任意的规则来让计算机解释人类输入的内容。一种明显的方法是告诉计算机,“让用户输入他们想要的任何内容,直到他们按下回车键,然后将整个内容视为一个字符串。”在 Common Lisp 中执行此操作的功能称为read-line。然而,它没有read、print和princ函数的任何复杂性,因为它只知道字符和字符串。
带着这些新知识,我们终于可以完整地创建一个用于问候某人的函数,而不需要丑陋的引号或其他奇怪的东西:
> `(defun say-hello ()`
`(princ "Please type your name:")`
`(let ((name (read-line)))`
`(princ "Nice to meet you, ")`
`(princ name)))`
SAY-HELLO
> `(say-hello)`
Please type your name: `Bob O'Malley`
Nice to meet you, Bob O'Malley
这个版本的say-hello函数与我们的第一个版本类似。然而,当计算机请求用户输入他们的名字
,现在它不再在文本字符串周围打印引号。同样的情况也适用于打印问候语 
。此外,用户现在可以输入任何名字(包括包含空格和引号的名字),因为read-line命令
会捕获并返回直到按回车键之前输入的所有文本,而不会出现任何麻烦。
Lisp 中代码与数据之间的对称性
你已经看到 Lisp 有非常优雅和对称的工具,可以将原始字符串数据从外部世界转换成 Lisp 语法表达式,反之亦然。但 Lisp 有更深层次的对称性。它可以将程序代码和数据互换使用。使用相同的数据结构来存储数据和程序代码的编程语言被称为 同构的。
在第三章中,我们讨论了代码模式和数据模式时,你看到了同构性的一个例子。在那个例子中,我们使用引号在两种模式之间切换:
`> '(+ 1 2)` ;data mode
(+ 1 2)
`> (+ 1 2)` ;code mode
3
在上一章中,我们通过在定义 describe-path 函数时使用伪引号,将这个概念又推进了一步。
但 Lisp 中的引号和伪引号功能在能力上有些有限。如果我们以某种方式从头生成一段 Lisp 代码并希望像代码一样执行它怎么办?例如,让我们将一段原始代码存储在一个变量中:
> `(defparameter *foo* '(+ 1 2))`
*FOO*
我们如何执行 *foo* 变量中的代码?我们需要一个更强大的命令来实现这一点。这就是 eval 命令:
> `(eval *foo*)`
3
由于 eval 命令既强大又简单,对初学者来说极具吸引力。你想编写一个具有自我修改代码的程序?那么 eval 将是你的最佳选择。实际上,这可能是过去人工智能(AI)爱好者如此热爱 Lisp 的主要原因。尝试编写一些使用 eval 命令的程序。你会发现这非常有趣。
然而,有经验的 Lisp 程序员很少使用 eval。在你积累了几千行 Lisp 代码之前,你真的不知道何时适当地使用这个极其强大的命令。通常,初学者会用 eval 命令代替定义 Lisp 宏。我们将在第十六章中讨论宏。
事实上,Lisp 中数据和代码的对称性几乎使 Lisp 成为同构性的典范。引号、伪引号、eval 命令和宏允许你在代码中利用这一特性。
警告
不当使用 eval 可能会带来安全风险。更多信息请参阅读取和评估的危险。
为我们的游戏引擎添加自定义界面
到目前为止,我们一直在使用 Lisp REPL 来输入我们的游戏命令。这对于我们的游戏原型设计来说效果非常好。但现在你已经了解了基本的 Common Lisp 输入和输出命令,我们可以开始构建我们自己的自定义文本游戏界面,这将更适合与玩家交互。
设置自定义 REPL
在 Lisp 中创建自己的 REPL 几乎可以说是轻而易举的。以下是我们游戏的一个简单自定义 REPL,它允许我们以与标准 REPL 完全相同的方式调用look命令:
> `(defun game-repl ()`
`(loop (print (eval (read)))))`
GAME-REPL
> `(game-repl)`
`(look)`
(YOU ARE IN THE LIVING-ROOM. A WIZARD IS SNORING LOUDLY ON THE COUCH. THERE IS
A DOOR GOING WEST FROM HERE. THERE IS A LADDER GOING UPSTAIRS FROM HERE. YOU
SEE A WHISKEY ON THE FLOOR.)
如果这个关于game-repl的解释让你感到困惑,请打断我:首先它reads 一个命令,然后evals 它,最后prints 它。你之前没有见过的唯一命令是loop(在第十章中详细介绍),正如你所期望的,它只是无限循环。在 CLISP 中,你需要按 ctrl-C 并输入:a才能退出无限循环。正如你所见,通过简单地调用read、eval、print和loop,很容易构建自己的 REPL。

当然,为了自定义我们 REPL 的行为,我们希望调用这些函数的自己的版本。此外,我们希望有一种更优雅的方式退出游戏。因此,让我们按照以下方式重新定义game-repl:
(defun game-repl ()
(let ((cmd (game-read)))
(unless (eq (car cmd) 'quit)
(game-print (game-eval cmd))
(game-repl))))
在这个版本中,我们首先使用局部变量cmd
捕获玩家输入的命令。这样,我们可以拦截任何尝试调用quit并使用它来退出我们的game-repl。换句话说,我们希望 REPL 继续运行,除非用户输入了quit
。否则,函数evals 和prints
,但使用我们即将编写的自定义版本。最后,game-repl函数递归地调用自身
,只要我们没有在之前决定退出,它就会循环回。
编写自定义的read函数
我们game-read函数的目的是修复使标准 Lisp read函数不适合玩游戏的两个烦恼:
-
标准的 Lisp
read强制我们在命令周围加上括号。正如任何老式文字冒险游戏玩家所知,我们应该能够只输入look而不加任何括号。为了实现这一点,我们可以简单地调用read-line并插入我们自己的括号。 -
使用
read时,我们必须在所有函数命令前加上引号。我们应该能够输入walk east而无需在east前加引号。为此,我们将在事后在参数前加上引号。

这里是game-read的一个定义,它同时做了这两件事:
(defun game-read ()
(let ((cmd (read-from-string
(concatenate 'string "(" (read-line) ")"))))
(flet ((quote-it (x)
(list 'quote x)))
(cons (car cmd) (mapcar #'quote-it (cdr cmd))))))
read-from-string命令
的工作方式与read命令类似,但它允许我们从字符串中读取语法表达式(或任何其他基本 Lisp 数据类型),而不是直接从控制台读取。
我们使用的字符串是 read-line ![httpatomoreillycomsourcenostarchimages783562.png] 获取的字符串的一个修改版本。我们通过使用 concatenate 命令在其周围添加引号来修改它,该命令可以用于连接字符串,以及一些括号。结果是 cmd 变量将被设置为玩家请求的命令,并转换为 Lisp 语法表达式。例如,如果玩家输入 walk east,则 cmd 变量将被设置为表达式 (walk east),它是一个包含两个符号的列表。
接下来,我们定义一个名为 quote-it 的局部函数 ![httpatomoreillycomsourcenostarchimages783560.png],我们可以使用它来引用玩家在命令中的任何参数。它究竟是如何引用参数的呢?好吧,结果是单引号只是 Lisp 命令 quote 的简写。这意味着 'foo 和 (quote foo) 是相同的。我们可以通过将参数放入一个带有 quote 命令的列表中来引用原始参数。
记住,局部函数可以用 labels 或 flet 定义。由于我们在 quote-it 函数 ![httpatomoreillycomsourcenostarchimages783560.png] 中没有使用任何递归,我们可以使用更简单的 flet 命令。game-read 函数中的最后一行将 quote-it 应用到玩家命令中的每个参数。它是通过将 quote-it 映射到 cmd 变量的 cdr 来做到这一点的。
让我们尝试我们的新函数:
> `(game-read)`
`walk east`
(WALK 'EAST)
如您所见,game-read 函数能够添加括号和引号——这正是我们游戏所需要的!
注意
我们的定制读取器有一些限制,一个足够愚蠢的游戏玩家可能会将其暴露出来。玩家可以输入一个奇怪的字符串,如 "(look",括号不匹配,这会在 game-read 命令中引发 Lisp 异常。就其本身而言,这并没有什么问题,因为标准的 read 命令在接收到混乱的输入时也会表现得奇怪。(在这种情况下,它会让你输入另一行输入,希望你会最终提供缺失的括号。)然而,我们的 game-repl 并没有正确处理这种情况,导致实际的 game-repl 崩溃。这就像你在玩 Zork,输入了一个如此恶心的命令,以至于它本身就把 Zork 游戏搞崩溃了。这种情况可以通过额外的异常处理来解决,如第十三章所述 Chapter 13。
编写游戏评估函数
现在我们已经创建了一个几乎完美的 Lisp 读取器,让我们思考一下我们如何可以改进eval命令。在游戏中使用eval的主要问题在于它允许你调用任何 Lisp 命令,即使这个命令与玩游戏无关。为了帮助保护我们的程序免受黑客攻击,我们将创建一个game-eval函数,它只允许调用某些命令,如下所示:
(defparameter *allowed-commands* '(look walk pickup inventory))
(defun game-eval (sexp)
(if (member (car sexp) *allowed-commands*)
(eval sexp)
'(i do not know that command.)))
game-eval函数使用member函数检查输入命令中的第一个单词是否在允许的命令列表中
。如果是,我们就使用标准的eval来执行玩家的命令
。通过检查玩家调用的命令是否在官方列表中,我们保护自己免受调用恶意命令的任何尝试。

警告
我们的game-eval函数并不能提供 100%的防黑客保护。有关详细信息,请参阅《read 和 eval 的危险》中的《read 和 eval 的危险》。
编写game-print函数
我们game-repl系统中的最后一块缺失的拼图是game-print函数。在我们游戏的 Lisp REPL 版本的所有限制中,有一个是最明显的:游戏中打印的所有文本描述都是大写的。
上次我检查的时候,在整个当前千年里,计算机已经能够显示大写和小写字符。通过编写我们自己的game-print函数,我们可以解决这个问题。
在我们逐步分析game-print函数的代码之前,让我们看看它的输出示例:
> `(game-print '(THIS IS A SENTENCE. WHAT ABOUT THIS? PROBABLY.))`
This is a sentence. What about this? Probably.
如您所见,game-print函数将我们的基于符号的写作转换为正确的大写文本。通过拥有这个函数,我们可以在最舒适的格式下将文本存储在我们的游戏引擎中:符号列表。这种格式使得操作文本更加容易。然后,在展示点,我们可以用展示细节装饰这些符号列表。
当然,在这个例子中,装饰非常简单。我们只是调整了大小写。但您已经可以看到将展示细节与数据模型分离的一些小好处。例如,如果我们把describe-path函数改为写出像“这里左边有一扇门。”这样的句子,就不需要做任何进一步的更改;程序会自动知道在句首大写Left。
然而,真正的益处在于当你想要使用更复杂的演示方法时,例如生成 HTML 代码。你可能想要为你的文本游戏添加自定义语义,以增强文本的外观,例如改变颜色、字体等。例如,你可以允许你的游戏描述包含诸如“你正被一个(红色邪恶恶魔)攻击”之类的短语。然后你只需在game-print函数中捕捉到关键词red,就可以将包含的文本以红色显示。我们将在第十七章中创建一个类似的 HTML 演示系统。

现在,我们准备查看game-print函数的代码:
(defun tweak-text (lst caps lit)
(when lst
(let ((item (car lst))
(rest (cdr lst)))
(cond ((eq item #\space) (cons item (tweak-text rest caps lit)))
((member item '(#\! #\? #\.)) (cons item (tweak-text rest t lit)))
((eq item #\") (tweak-text rest caps (not lit)))
(lit (cons item (tweak-text rest nil lit)))
((or caps lit) (cons (char-upcase item) (tweak-text rest nil lit)))
(t (cons (char-downcase item) (tweak-text rest nil nil)))))))
(defun game-print (lst)
(princ (coerce (tweak-text (coerce (string-trim "() "
(prin1-to-string lst))
'list)
t
nil)
'string))
(fresh-line))
game-print函数及其辅助函数比我们之前看到的函数要复杂一些。代码执行的第一部分是在game-print中,它将包含我们想要调整布局的文本的符号列表转换为字符串,使用的是 Lisp 的许多print变体之一的prin1-to-string
。to-string部分意味着这个函数不会将结果输出到屏幕上,而是仅将其作为字符串返回。1表示它将保持在单行上。标准的print命令在其输出前添加换行符,并在其后添加空格。prin1和prin1-to-string变体不会添加这些额外的字符。
接下来,game-print使用coerce函数将字符串转换为字符列表
。通过将我们的字符串强制转换为列表,我们可以将函数的更大目标简化为列表处理问题。这正是 Lisp 的舒适区。在这种情况下,我们正在创建一个由我们想要修复的文本组成的字符列表。
现在,我们可以将数据发送到list-eater函数tweak-text
。注意,在game-print函数的代码中使用的某些参数单独打印在一行上,以便于清晰。你可以通过查看缩进来轻松地看到哪些参数对应于哪个命令。例如,t和nil参数
属于tweak-text。
tweak-text函数会检查列表中的每个字符并根据需要修改它。在这个函数的顶部,我们定义了两个局部变量item和rest,我们通过从我们要调整的句子前端咬掉一个项目来获取它们
。然后,tweak-text函数使用cond来检查列表顶部的字符以不同的条件
。
它首先检查的条件是字符是否为空格字符
。如果是,它就保持空格不变并处理列表中的下一个字符。如果字符是句号、问号或感叹号
,我们就为字符串的其余部分开启cap参数(通过在递归调用中使用值t作为参数),以指示下一个符号是句子的开头,需要大写字母。
我们还跟踪是否遇到了引号
。我们这样做是因为,不经常,符号列表不足以编码英文文本。例如,有一个逗号(逗号不允许在标准的 Common Lisp 符号中)或具有非标准大写的产品名称。在这些情况下,我们只需回退到使用文本字符串。以下是一个例子:
> `(game-print '(not only does this sentence`
`have a "comma," it also mentions the "iPad."))`
Not only does this sentence have a comma, it also mentions the iPad.
我们的示例游戏实际上不需要回退功能。尽管如此,这个特性允许game-print函数处理许多基本的异常文本情况,这些情况可能在你尝试自己扩展游戏时遇到。我们告诉函数通过在递归调用中开启lit变量来将大写字母按字面意思处理。只要这个值被设置,tweak-text函数就会阻止大写字母规则(从
开始)被触发。
tweak-text函数接下来检查下一个字符是否应该大写。如果是,我们在处理列表中的下一个项目之前,使用char-upcase函数将当前字符转换为大写(如果它还不是的话)
。
如果没有满足其他条件,我们知道当前字符应该是小写的
,我们可以使用char-downcase函数将其转换。
在tweak-text完成对字符列表中的文本进行纠正后,game-print函数将其强制转换成一个合适的字符串并princ它
。game-print末尾的fresh-line函数确保屏幕上出现的下一个项目将从新的一行开始。
我们现在已经完成了将原始符号列表打印到屏幕上的任务,使用了一套适合冒险游戏引擎需求的装饰。
尝试我们的新游戏界面
我们现在已经完成了为我们的游戏引擎定制 REPL 所需的所有组件。只需调用game-repl函数,就可以探索我们的新游戏世界。记住,我们将在第十七章 Chapter 17 中扩展这个引擎,使其成为一个完整的游戏,并添加额外的命令。
> `(game-repl)`
`look`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs from here. You
see a whiskey on the floor. You see a bucket on the floor.
`walk west`
You are in a beautiful garden. There is a well in front of you. There is a
door going east from here. You see a frog on the floor. You see a chain on
the floor.
`pickup chain`
You are now carrying the chain
`scratch head`
I do not know that command.
`pickup chicken`
You cannot get that.
`walk east`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs from here. You
see a whiskey on the floor. You see a bucket on the floor.
`walk upstairs`
You are in the attic. There is a giant welding torch in the corner. There is a
ladder going downstairs from here.
`inventory`
Items- chain
`walk china`
You cannot go that way.
`walk downstairs`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs from here. You
see a whiskey on the floor. You see a bucket on the floor.
`pickup bucket`
You are now carrying the bucket
`look`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs from here. You
see a whiskey on the floor.
`quit`
成功!我们现在拥有了一个极其灵活的文本游戏引擎。它可以在 Lisp REPL 中进行扩展和调试。它还提供了一个完全可定制的界面,为玩家提供无缝的文字冒险体验。在我们将其组装起来的过程中,你看到了一些令人费解的 Lisp 技术,这些技术使我们能够用最少的填充代码或其他开销构建这个引擎。

read 和 eval 的危险
我们在创建定制的 Lisp REPL 时使用了 eval 和 read 命令。这些命令非常强大,但也非常危险。在不采取适当预防措施的情况下使用它们可能会允许黑客通过运行恶意命令来攻击你的软件。
例如,假设我们的程序需要一个名为 format-harddrive 的函数。这不是我们希望任何人都能够访问的函数,如果黑客设法诱骗我们的游戏 REPL 调用它,可能会非常危险。
在本章中我们创建的 game-eval 函数有一些粗略的安全措施,以防止玩家将 format-harddrive 作为游戏命令输入。如果我们尝试在我们的新游戏 REPL 中运行此命令,会发生以下情况:
> `(game-repl)`
`format-harddrive`
I do not know that command.
我们的 game-eval 函数只会运行批准列表中的命令。这为我们游戏提供了一种防火墙,使我们能够利用 Lisp 的功能来评估命令,同时防止玩家破解游戏。
然而,玩家还可以尝试更复杂的攻击手段。例如,他们可以输入 walk (format-harddrive)。幸运的是,我们的 game-read 函数通过使用 quote-it 将所有函数参数强制转换为数据模式。在 game-read 中使用 quote-it,实际执行的代码是 (walk '(format-harddrive))。在 (format-hardrive`) 前面的引号将恶意命令转换为数据模式,因此不会发生任何坏事。
一种会破坏我们程序的方法是使用 reader macros。这是一组高级功能,内置在 Common Lisp 的 read 命令中,为执行恶意计算机代码开辟了另一条途径。(记住在我们对游戏命令使用 eval 之前,它们首先会通过 read。)一个能够成功执行恶意代码的游戏命令示例是 walk #.{format-harddrive}。
底线是,你永远不能确定使用 eval 或 read 的 Lisp 程序完全安全,免受黑客攻击。在编写生产级 Lisp 代码时,你应该尽可能避免使用这两个命令。
你学到了什么
在本章中,我们创建了一个定制的 REPL 来增强我们的文本冒险游戏。在这个过程中,你学习了以下内容:
-
print和read函数允许你通过控制台直接与用户进行通信。这两个函数以计算机友好的方式工作。 -
其他输入/输出函数不如
read和print那么优雅,但更适合与人类交互。例如包括princ和read-line。 -
一种同构编程语言以类似格式存储其程序代码和程序数据。Lisp 的引用、准引用、
eval和宏功能使其非常同构。 -
编写自己的自定义 REPL 很简单。
-
将你的内部 Lisp 数据转换为最适合你程序接口的格式很简单。这使得将表示细节与程序内部数据结构分开变得容易。
第 6.5 章。lambda:一个值得拥有自己章节的重要函数
在 Lisp 中,lambda 命令的重要性怎么强调都不过分。事实上,这个命令几乎就是 Lisp 存在的全部原因。
lambda 的作用
简而言之,lambda 允许你创建一个没有命名的函数。例如,假设我们创建一个 half 函数,它接受一个数字并将其减半。到目前为止,我们是这样编写这样的函数的:
(defun half (n)
(/ n 2))

结果表明,在 Lisp 中,函数实际上是我们可以查看和传递的值,就像它们是数字或列表一样。有经验的 Lisp 程序员会说,在 Lisp 中函数是一等值。正如你在第五章中看到的,你可以通过使用函数操作符来获取由单词 half 表示的函数:
> `#'half`
#<FUNCTION HALF ...>
lambda 命令只是让你在一步中完成这两件事。你可以定义一个函数然后获取它,而不必给你的函数命名:
> `(lambda (n) (/ n 2))`
#<FUNCTION :LAMBDA ...>
lambda 命令的第一个参数是一个参数列表,这与 defun 中使用的参数列表没有区别。其余的参数只是未命名函数体中的命令。
一旦你有一个代表你的未命名除半函数的值,你可以直接将其传递给其他 Common Lisp 命令,例如 mapcar 或 apply 命令。例如,我们可以这样做,优雅地将列表中的所有值除以二:
> `(mapcar (lambda (n) (/ n 2)) '(2 4 6))`
(1 2 3)
由于 lambda 命令的并非所有参数都会被评估,因此 lambda 本身实际上不是一个真正的函数。它是一种称为宏的东西。记得从第二章中提到的,在 Lisp 函数本身被评估之前,所有传递给 Lisp 函数的参数都会被评估。另一方面,宏具有特殊的功能,并允许打破这些规则。你将在第十六章中了解更多关于宏的内容。
此外,为了使事情更加复杂,lambda 实际返回的值是一个普通的 Lisp 函数——在这种情况下,是一个将数字减半的函数。当 Lisp 程序员谈论 lambda 函数——他们几乎从早餐、午餐和晚餐都在谈论——他们谈论的是使用 lambda 创建的函数。他们不是在谈论 lambda 宏本身,它不是一个函数。明白了吗?
lambda 让你的程序能够做非常复杂的事情。
lambda 形式允许你的编程代码实现概念上的飞跃。
尽管大多数编程语言试图保持函数和值的世界分开,但 Lisp 允许你根据需要连接这两个世界。例如,如果你想打包一个小型的临时函数并将其传递给程序的另一部分,lambda 就能完全满足你的需求。
你会发现大多数 Lisp 程序都非常重视这个命令。这本书中的剩余示例也是如此。
为什么 lambda 如此重要
能够像传递普通数据一样传递函数的能力非常宝贵。一旦你习惯了这样做,你就可以在程序设计中打开各种概念上的可能性。最终,你的程序将开始看起来与更多(我敢说)更平凡的编程语言(如 Java 或 C)的程序大不相同。这种依赖于传递函数作为值的编程风格被称为高阶 函数式编程。我们将在第十四章(Chapter 14)中更详细地探讨这种风格。
Lisp 程序员对 lambda 如此着迷的另一个更重要的原因是,实际上,在纯粹数学的意义上,lambda 实际上是唯一的 Lisp 命令!
回想一下,Lisp 在编程语言中很独特,因为它直接源于一个称为 lambda 演算 的数学概念。简而言之,lambda 演算是一种仅包含一个命令的理论编程语言:lambda 命令。通过只有一个这样的命令并使用特殊的代码转换,可以创建一个完全功能(尽管可能不实用)的编程语言。
关键点是,lambda 特殊形式是 Lisp 系统中最基本的命令,也是 Lisp 中其他函数的核心概念。事实上,它甚至是从 Lisp 本身的概念中起源的核心概念。
现在你已经对 lambda 有了一个基本的理解,你就可以处理一些更复杂的编程示例了,没有这个命令允许的匿名函数,这些示例将很难编写。
你所学到的东西
这本简短章节讨论了如何创建匿名函数。以下是主要要点:
-
通过使用
lambda,你可以创建一个不需要命名的函数。 -
许多 Lisp 函数接受函数作为参数。如果你使用这些函数,你就是在使用一种称为 高阶函数式编程 的技术。
第七章。超越基本列表
在本章中,我们将超越基本列表概念。我们将讨论特殊类型的列表,并编写一个将列表操作提升到新水平的游戏。
异常列表
如您在第三章中学习的那样,Lisp 中的列表是由 cons 单元构成的——这些小的数据结构允许您将两块数据链接在一起。列表中最后一个 cons 单元的右槽应该包含一个 nil。
通过连接几个 cons 单元,您可以创建任何长度的列表。例如,这就是我们如何使用 cons 单元创建包含数字 1、2 和 3 的列表:
(cons 1 (cons 2 (cons 3 nil)))

由于人类将一系列 cons 单元视为列表非常繁琐,Lisp 为打印此类列表提供了一种特殊、简化的语法。您可以通过在 REPL 中评估一系列 cons 单元来亲自查看这一点:
> `(cons 1 (cons 2 (cons 3 nil)))`
(1 2 3)
当 Lisp 在 REPL 中将我们的链式 cons 单元回显给我们时,它使用更简单的列表语法。重要的是要记住,这种外观上的差异完全是表面的。无论 Lisp 列表如何显示,本质上,它始终是一个 cons 单元的链。
点划线列表
那么,如果我们偏离经典的“cons 链”公式会发生什么?当打印列表时,Lisp 环境将如何处理这种情况?
假设我们尝试创建一个包含数字 1、2 和 3 的列表,如下所示:
(cons 1 (cons 2 3))
在这里,我们不是为列表中的第三个数字创建第三个 cons 单元,而是将其塞入前一个单元的右槽。如果我们将这个结构输入到 Lisp REPL 中,打印出来的响应会是什么样子呢?让我们试试:
> `(cons 1 (cons 2 3))`
(1 2 . 3)
为了表示列表中的最后一个项目没有在 nil 结尾的列表的正确位置找到,Lisp 在这个最后一个项目前放置一个点。这个点基本上是 Lisp 的说法:“我尝试使用列表符号打印你输入的这个结构,但列表中的最后一个项目没有包含我预期的通常的 nil;相反,它包含了 3。”
以非 nil 结尾的 Lisp 列表被称为点划线列表。在 Lisp 的领域中,点划线列表有点奇怪。就其本身而言,它们并不是 Lisp 编程中非常有用的工具。Lisp 程序员将数据存储在点划线列表中作为常规做法是非常不寻常的。然而,鉴于 cons 单元在 Lisp 中的普遍性,您将经常在一系列 cons 单元的末尾遇到非 nil 值。这就是为什么您应该熟悉点划线列表,即使您可能永远不会直接使用它们。
另一种思考这种点符号的方法是将其视为 cons 命令的替代语法,用于数据模式。实际上,如果我们想让自己生活得更艰难,我们甚至可以使用点符号创建常规的、正确的列表,如下所示:
> `'(1 . (2 . (3 . nil)))`
(1 2 3)
使用这种思维方式,点出现在点划线列表中,仅仅是因为 Lisp 被迫显示最终的 cons 单元,以保持其列表打印机制的连贯性。
对
在 Lisp 程序中,点划线列表的一个常见且实用的用途是优雅地表示对。例如,假设我们想要表示数字 2 和 3 的对。一种方法是将这两个数字连接起来:
> `(cons 2 3)`
(2 . 3)
实质上,我们在这里所做的只是创建一个长度为二的点划线列表。正如预期的那样,Lisp 使用点符号来显示这个对。
以这种方式在 Lisp 中创建对非常方便且高效。它方便,因为我们可以使用标准的 car 和 cdr 命令从对中提取成员。它相对高效,因为 Lisp 环境只需要分配一个 cons 单元来连接两个项目。
这些类型的对在 Lisp 程序中常用。例如,你可以使用它们来存储点或复杂数据结构中的键/值对。当我们讨论关联列表时,你将看到对的后一种用途。
循环列表
这里是我们用于第三章中说明构成列表 '(1 2 3) 的 cons 单元的图片:

现在假设我们创建了这个列表的一个奇怪变异体。让我们让第三个 cons 单元的 cdr 指向第一个 cons 单元,而不是 nil:

列表中的每个 cons 单元在理论上都存在于内存中的独立对象。由于单元格中的 car 和 cdr 插槽可以指向内存中的任何其他对象,因此 cons 单元可以指向列表的上游 cons 单元。我们称这种列表为 循环列表。
但在你尝试在任何 Common Lisp 环境中实验循环列表之前,你应该运行此命令:
(setf *print-circle* t)
将 *print-circle* 设置为 true 警告 Lisp,你计划在自引用数据结构上玩一些小把戏,并且它在打印屏幕上创建的任何怪物时需要格外小心。如果你不设置这个变量就打印循环列表,你无法预测会发生什么,但无论结果如何,都不会很美观(除非你在堆栈溢出和无限循环打印中找到了某种美)。
当你将 *print-circle* 设置为 true 时,Common Lisp 将使用更复杂的打印例程来打印数据结构。这些例程(默认情况下被禁用以提高性能)将检查你是否遇到了之前见过的 cons 单元,这样打印就不会导致无限循环。
那么你将如何创建一个循环列表?最直接的方法是使用 setf 命令在第一个参数中放入额外的内容,如下所示:
> `(defparameter foo '(1 2 3))`
FOO
> `(setf (cdddr foo) foo)`
#1=(1 2 3 . #1#)
在这个例子中,我们通过将简单列表末尾的 nil 替换为对列表本身的引用,创建了一个无限列表 '(1 2 3 1 2 3 1 2 3 ...)。
在setf命令的第一个参数中放置复杂表达式的能力,如本例所示,非常酷,我们将在第九章“第九章. 高级数据类型和泛型编程”中更详细地探讨它。
注意
CLISP(以及其他通用 Lisp)可以非常合理地处理循环列表的打印。某种方式,它必须解决列表的一部分引用另一部分的事实。正如你所见,它使用一种晦涩但相当巧妙的记法来链接表达式的自引用部分。然而,我相信你也能欣赏到,随着任何自引用数据的复杂性增加,Lisp 打印机为这类数据提供的打印结果可能对程序员来说难以理解。
关联列表
可以从 cons 单元创建的一个特别有用的数据结构是关联列表,或简称为alist。alist 由存储在列表中的键/值对组成。
按照惯例,如果一个键在列表中多次出现,则假定该键的第一个出现包含所需值。例如,以下是一个由比尔、丽莎和约翰订购的咖啡饮料的 alist 示例:
(defparameter *drink-order* '((bill . double-espresso)
(lisa . small-drip-coffee)
(john . medium-latte)))
要查找给定人员的订单,请使用assoc函数:
> `(assoc 'lisa *drink-order*)`
(LISA . SMALL-DRIP-COFFEE)
此函数从列表开头搜索所需键,然后返回键/值对。现在假设在取饮料订单之前,丽莎叫住你并选择将她的订单改为稍微奢侈一些的东西。你可以使用push函数更改她的订单:
> `(push '(lisa . large-mocha-with-whipped-cream) *drink-order*)`
((LISA . LARGE-MOCHA-WITH-WHIPPED-CREAM)
(BILL . DOUBLE-ESPRESSO)
(LISA . SMALL-DRIP-COFFEE)
(JOHN . MEDIUM-LATTE))
此函数只是将新项目添加到现有列表的前面。
由于默认情况下,关联列表中键的第一个引用优先于对该键的后续引用,因此丽莎订购的小滴咖啡的订单被她最近的订单所取代:
> `(assoc 'lisa *drink-order*)`
(LISA . LARGE-MOCHA-WITH-WHIPPED-CREAM)
如你所见,alist 是跟踪任何可变键/值对集合的绝佳方式。alist 易于理解,易于使用 Lisp 函数操作,并且在打印出来时易于理解(毕竟,它们只是成对的列表)。
此外,一旦值存储在 alist 中,它将永远保留在那里,这使得审计任何数据的历史变得容易。例如,在我们的咖啡示例中,丽莎订购的滴咖啡订单即使在被替换后仍然可用。
然而,alist 确实有一个严重的限制:除非你处理的是非常短的列表(不到十项),否则它们不是存储和检索数据的高效方式。由于这种低效性,尽管 alist 通常是 Lisp 程序员工具箱中的第一个工具之一,但随着程序的成熟,它们可能被其他类型的数据结构所取代。(在第九章“第九章. 高级数据类型和泛型编程”中,我们将更详细地讨论基于列表的数据结构,如 alist 的性能限制。)
应对复杂数据
Cons 单元是表示各种类似列表结构的一个很好的工具。事实上,大多数 Lisp 程序员在面临不受性能限制的编程任务时,几乎都会完全依赖它们。因为由 cons 单元构成的结构的操作和可视化是 Lisp 设计的关键,这些结构使用起来非常方便,调试起来也很容易。
事实上,即使你有性能限制,由 cons 单元构成的结构通常也是一个很好的选择。Lisp 编译器通常可以将对 cons 单元的更改减少到单个汇编指令!
可视化树状数据
如第三章中所述,Lisp 程序中的数据(和代码)是用语法表达式表示的。在这个格式中,数据使用嵌套列表表示,通常在每个列表的前面使用 Lisp 符号来解释数据的结构。
例如,假设我们想在 Lisp 中表示房屋的组成部分:
(defparameter *house* '((walls (mortar (cement)
(water)
(sand))
(bricks))
(windows (glass)
(frame)
(curtains))
(roof (shingles)
(chimney))))
这种数据结构非常优雅地捕捉了构成房屋的部件的层次性质。由于它是以 Lisp 语法表达式结构化的,我们可以看到构成层次级别的列表。此外,它遵循语法表达式的惯例,在每个列表的前面放置一个符号。例如,我们可以看到描述窗户的列表首先包含 Lisp 符号windows ![http://atomoreilly.com/source/nostarch/images/783564.png],然后是三个项目,代表玻璃、框架,最后是窗帘 ![http://atomoreilly.com/source/nostarch/images/783562.png]。
正如你所见,具有层次结构和树状性质的数据可以非常自然地以这种方式表达。事实上,许多 Lisper 认为 XML(一种流行的表示层次数据的格式)某种程度上是对 Lisp 开创的语法表达式格式的重新发明。
然而,如果我们超越树状结构,存储在语法表达式中的数据可能会变得难以可视化,即使存储数据在 cons 单元中相对容易。例如,假设我们有一个存储在语法表达式中的数学图。这类图,其中任何任意节点都可能通过边连接到另一个节点,在计算机程序中通常难以可视化。即使是 Lisp 表示 cons 单元的优雅系统对于这类数据也帮助不大。接下来,我们将探讨可视化这类图的选项。
可视化图
在数学中,一个图由一些通过边连接的节点组成。这些节点或边可能还与额外的数据相关联。
这样的图表可以存储在 cons 单元格中,但它们很难可视化。我们在第五章中看到了这一点,当时我们将巫师之家的地图(由一个定向图组成)存储在两个 alists 中:一个包含节点信息,另一个包含边信息。为了本章,我将它们重命名为*wizard-nodes*和*wizard-edges*,如下所示:
(defparameter *wizard-nodes* '((living-room (you are in the living-room.
a wizard is snoring loudly on the couch.))
(garden (you are in a beautiful garden.
there is a well in front of you.))
(attic (you are in the attic. there
is a giant welding torch in the corner.))))
(defparameter *wizard-edges* '((living-room (garden west door)
(attic upstairs ladder))
(garden (living-room east door))
(attic (living-room downstairs ladder))))
如你所见,从这些原始数据表中很难理解这个游戏世界的结构。不幸的是,具有图形状或包含超出简单树结构的其他属性的数据非常常见。如果有一个工具能够最优地安排这些数据以创建一个漂亮的图表,那岂不是很好?幸运的是,有一个出色的开源工具可以执行这项任务,你将在下一节尝试它。
创建图表
Graphviz 可以从你的数据生成图表。确实,你在第五章中看到了一个简单的 Graphviz 表示的巫师之家的示例:

Graphviz 是开源的,可以从 Graphviz 网站获取(www.graphviz.org/)。下载并安装后,创建图表很容易。首先,你将在你的电脑上创建一个名为test.dot的 DOT 文件,并输入以下信息:
digraph {
a->b;
}
这定义了一个由节点 A 和 B 通过箭头连接的定向图。(DOT 文件格式中有许多语法选项可用,如 Graphviz 网站上的文档所述。)
现在,要从 DOT 文件生成图形位图,请在命令行中运行neato(Graphviz 工具之一),如下所示:
neato -Tpng -O test.dot
这应该在文件test.dot.png中创建一个看起来像这样的图片:

如你所见,Graphviz 使用简单。它甚至可以快速生成大型、复杂的图表,只有轻微的图形错误。(由于完美的图布局仍然是计算机科学中的一个未解问题,因此 Graphviz 的布局并不完美。然而,它们比你想象的要接近完美。)
现在你已经让 Graphviz 运行起来了,让我们创建一个命令库,这样我们就可以方便地用 Lisp 绘制图表。我们可以用这个来绘制我们冒险游戏世界的图表。
注意
本章示例中使用的图工具以不包含在 Common Lisp 标准中的方式进行某些系统调用。它们仅在 CLISP 环境中可用。代码需要一些修改才能在其他 Lisp 系统中运行。
生成 DOT 信息
为了创建一个图形绘制库,我们希望生成一个捕获图形所有细节的 Graphviz DOT 文件。为此,我们需要转换玩家可以访问的节点标识符,转换连接这些节点的边,并为每个节点和边生成标签。我们将使用代表巫师世界地图的节点来测试我们的库。
转换节点标识符
在将节点转换为 DOT 格式时,我们首先需要做的是将节点标识符转换为有效的 DOT 标识符。我们通过编写一个 dot-name 函数来完成此操作:
(defun dot-name (exp)
(substitute-if #\_ (complement #'alphanumericp) (prin1-to-string exp)))
DOT 格式的节点只能包含字母、数字和下划线字符。为了确保我们使用的节点标识符是合法的,我们将任何禁止的字符更改为下划线。以下是 dot-name 函数的使用示例:
> `(dot-name 'living-room)`
"LIVING_ROOM"
> `(dot-name 'foo!)`
"FOO_"
> `(dot-name '24)`
"24"
此函数接受任何基本 Lisp 类型,然后我们可以使用 prin1-to-string 函数将其转换为字符串。我们可以处理生成的字符串并根据需要替换下划线。
注意
为了简化,我们的 dot-name 函数假设没有节点标识符仅在它们的非字母数字组件上有所不同。例如,如果我们有一个名为 foo? 的节点和另一个名为 foo* 的节点,dot-name 函数会将它们都转换为 foo,导致名称冲突。
substitute-if 函数根据测试函数的结果替换值:
> `(substitute-if #\e #'digit-char-p "I'm a l33t hack3r!")`
"I'm a leet hacker!"
此示例中的测试函数 digit-char-p 告诉我们字符串中的字符是否是数字。像这样的测试函数,它接受一个值并根据该值确定真值,通常被称为 谓词。
substitute-if 函数的另一个有趣特性是我们可以将其用于列表:
> `(substitute-if 0 #'oddp '(1 2 3 4 5 6 7 8))`
'(0 2 0 4 0 6 0 8)
在这里,列表中的所有奇数都被替换为数字 0。substitute-if 函数是 泛型函数 的一个例子——一个可以接受多个数据类型作为参数并相应处理的函数。(泛型编程在 第九章 中讨论。)
当我们在 dot-name 函数中使用 substitute-if 时,我们只替换那些非字母数字的字符。虽然 Common Lisp 中没有为我们提供测试这一点的谓词,但我们可以轻松地动态创建这个谓词。以下 dot-name 函数中的片段为我们创建了一个具有正确行为的谓词函数:
(complement #'alphanumericp)
Lisp 已经有一个谓词函数,可以告诉我们一个字符是否是字母数字,称为 alphanumericp。然而,我们只想替换那些 不是 字母数字的字符。我们可以通过将其传递给名为 complement 的高阶函数来创建 alphanumericp 的这个相反(或 补集)函数。
通过将此函数传递给 substitute-if,我们得到我们想要的行为,而无需使用 defun 将新函数污染顶层,仅为了将其提供给 substitute-if。
注意
Common Lisp 有一个名为 substitute-if-not 的函数,本可以在 dot-name 函数中使用它来代替 substitute-if,从而让我们在 lambda 函数中省略 not。然而,以 not 结尾的 Lisp 函数最好避免使用。它们可能会在未来版本的 ANSI Common Lisp 标准中被移除,这意味着它们被认为是过时的。
为图节点添加标签
现在我们已经可以调整节点标识符以使其适合 DOT,让我们编写另一个函数来生成节点绘制时应显示的标签。标签将包括节点名称和与节点链接的数据。但我们也需要确保我们不会在标签中放入过多的文本。以下是生成标签的代码:
(defparameter *max-label-length* 30)
(defun dot-label (exp)
(if exp
(let ((s (write-to-string exp :pretty nil)))
(if (> (length s) *max-label-length*)
(concatenate 'string (subseq s 0 (- *max-label-length* 3)) "...")
s))
""))
*max-label-length*
是一个全局变量,它决定了标签的最大字符数。如果一个节点标签大于限制
,它会被裁剪,并添加省略号来表示这一点
。write-to-string 函数
与我们之前使用的 prin1-to-string 函数类似——它将表达式写入字符串。
:pretty 参数是一个 关键字参数 的例子,某些 Lisp 函数使用它来让你选择你想要传递的参数。在 write-to-string 的情况下,它告诉 Lisp 不要改变字符串以使其更美观。如果没有这个参数,Lisp 会将新行或制表符放入转换后的字符串中,使其看起来更悦目。通过将 :pretty 关键字参数设置为 nil,我们告诉 Lisp 输出表达式而不添加任何装饰。(标签中包含新行可能会让 Graphviz 产生混淆,所以我们不想给 Lisp 任何提示。)
生成节点的 DOT 信息
现在我们可以为每个节点生成名称和标签,我们可以编写一个函数来接受节点 alst 并生成编码它们的 DOT 信息,如下所示:
(defun nodes->dot (nodes)
(mapc (lambda (node)
(fresh-line)
(princ (dot-name (car node)))
(princ "[label=\"")
(princ (dot-label node))
(princ "\"];"))
nodes))
这个函数使用 mapc 遍历节点列表中的每个节点
,然后 princ 将每个节点以 DOT 格式直接打印到屏幕上。mapc 是 mapcar 的一个稍微更高效的变体;区别在于它不返回转换后的列表。nodes->dot 函数使用我们创建的 dot-name
和 dot-label
函数来转换数据。
之后,当我们想要生成包含这些信息的文件时,我们将编写一个函数来从控制台获取这些数据。
使用控制台作为生成文件的中间件,而不是直接写入文件,这似乎有点奇怪,但实际上这是 Lisp 中的一种常见范式。这种方法的直接好处是我们可以在 REPL 中轻松调试代码,打印的行很容易看到。
现在,让我们尝试使用 nodes->dot 函数来生成巫师房子中节点的 DOT 信息:
> `(nodes->dot *wizard-nodes*)`
LIVING_ROOM[label="(LIVING-ROOM (YOU ARE IN TH..."];
GARDEN[label="(GARDEN (YOU ARE IN A BEAUT..."];
ATTIC[label="(ATTIC (YOU ARE IN THE ATTI..."];
在这里,你可以看到巫师房子的节点以及每个节点附加的简略信息,以 DOT 格式显示。请注意,我们对 nodes->dot 函数返回的值不感兴趣——只对它在 REPL 中打印的信息感兴趣。Lispers 会说我们只对函数的 副作用 感兴趣。尽管 mapc 不返回列表,但它仍然导致代码遍历列表并生成与使用 mapcar 相同的打印输出,因此它生成与 mapcar 相同的副作用,但速度更快。
将边转换为 DOT 格式
下一步是生成连接我们的节点的边的 DOT 信息。这些将成为我们视觉图中的箭头。edges->dot 函数通过直接打印到控制台生成必要的数据。
(defun edges->dot (edges)
(mapc (lambda (node)
(mapc (lambda (edge)
(fresh-line)
(princ (dot-name (car node)))
(princ "->")
(princ (dot-name (car edge)))
(princ "[label=\"")
(princ (dot-label (cdr edge)))
(princ "\"];"))
(cdr node)))
edges))
让我们使用这个函数来生成巫师房子的边界的 DOT 信息:
> `(edges->dot *wizard-edges*)`
LIVING_ROOM->GARDEN[label="(WEST DOOR)"];
LIVING_ROOM->ATTIC[label="(UPSTAIRS LADDER)"];
GARDEN->LIVING_ROOM[label="(EAST DOOR)"];
ATTIC->LIVING_ROOM[label="(DOWNSTAIRS LADDER)"];
在这里,我们可以清楚地看到巫师房子中节点之间的关系,以 DOT 格式显示。例如,第一行
指出,玩家可以通过使用标签为 (WEST DOOR) 的边从 LIVING_ROOM 节点走到 GARDEN 节点。
生成所有 DOT 数据
为了完成我们的 DOT 数据生成,我们调用 nodes->dot 和 edges->dot,并用一些额外的装饰来结束,如下所示:
(defun graph->dot (nodes edges)
(princ "digraph{")
(nodes->dot nodes)
(edges->dot edges)
(princ "}"))
这个函数通过将我们的图定义为有向图
,然后调用我们的 nodes->dot
和 edges->dot
函数来整合一切。
下面是我们新库创建的最终 DOT 信息,用于我们的巫师游戏:
> `(graph->dot *wizard-nodes* *wizard-edges*)`
digraph{
LIVING_ROOM[label="(LIVING-ROOM (YOU ARE IN TH..."];
GARDEN[label="(GARDEN (YOU ARE IN A BEAUT..."];
ATTIC[label="(ATTIC (YOU ARE IN THE ATTI..."];
LIVING_ROOM->GARDEN[label="(WEST DOOR)"];
LIVING_ROOM->ATTIC[label="(UPSTAIRS LADDER)"];
GARDEN->LIVING_ROOM[label="(EAST DOOR)"];
ATTIC->LIVING_ROOM[label="(DOWNSTAIRS LADDER)"];}
我们现在可以生成一个合适的 Graphviz DOT 文件,它捕捉了我们生成漂亮图片所需的巫师地图的所有细节。这包括玩家可以访问的节点、连接这些节点的边以及每个节点和边的标签。
将 DOT 文件转换为图片
要将 DOT 文件转换为实际的位图,我们捕获 DOT 文件数据,将其放入文件中,然后直接从系统命令行执行 dot 命令,如下所示:
(defun dot->png (fname thunk)
(with-open-file (*standard-output*
fname
:direction :output
:if-exists :supersede)
(funcall thunk))
(ext:shell (concatenate 'string "dot -Tpng -O " fname)))
这个函数在我们的图形绘制库中执行最关键的操作,使用了一些高级 Lisp 技术。
首先,为了使这个 dot->png 函数尽可能可重用,我们不直接调用 graph->dot 函数。相反,我们编写 dot->png 以接受一个 Thunk
。
使用 Thunks
在 Lisp 中,创建没有参数的小函数是很常见的。这些函数官方上被称为 nullary functions。然而,Lispers 经常创建这样的函数来描述他们不想立即运行的计算。在这种情况下,没有参数的函数通常被称为 thunk 或 suspension。在这种情况下,dot->png 函数需要的 Thunk 将是一个函数,当调用时,将 DOT 文件打印到控制台。
为什么 Thunk 在我们的 dot->png 函数中很有用?记住,对我们来说,最容易的方法是将 graph->dot 和其他 DOT 文件函数的结果直接打印到控制台进行编写和调试。当我们调用 graph->dot 时,它不会返回一个值,而是作为副作用在控制台打印出来。因此,我们不能直接将 graph->dot 的值传递给 dot->png。相反,我们传递 graph->dot 作为 Thunk。然后 dot->png 负责调用 graph->dot,捕获结果并将它们发送到文件。
由于使用计算机程序生成文本数据非常常见,这种特定的技术在 Lisp 代码中得到了广泛使用:首先,我们将内容直接打印到控制台;然后,我们将它包装在 Thunk 中;最后,我们将结果重定向到其他位置。
正如你在 第十四章 中所看到的,遵循函数式编程风格的 Lisp 程序员避免使用这种技术,因为在打印到控制台时需要副作用。
写入文件
函数 with-open-file 允许 dot->png 将信息写入文件
。为了让你了解这个函数的工作方式,这里有一个示例,它创建了一个名为 testfile.txt 的新文件,并将文本 “Hello File!” 写入其中:
(with-open-file (my-stream
"testfile.txt"
:direction :output
:if-exists :supersede)
(princ "Hello File!" my-stream))
在这个示例中,你可以看到传递给 with-open-file 的第一个项目
成为了一个特殊 Common Lisp 数据类型 流 的名称,它是通过 with-open-file 为我们创建的。
创建流
打印函数,如 princ,可以接受流作为可选参数。在这种情况下,这些打印函数不会打印任何内容到控制台,而是打印到流对象。
重要的是要理解 with-open-file 从流变量名创建一个流变量,就像 let 从变量名创建变量一样:
(with-open-file (my-stream ...)
...*`body has my-stream defined`*...)
(let ((my-variable ...))
...*`body has my-variable defined`*...)
因此,如果我们把 my-stream 这个名字放在 with-open-file
的第一个列表前面,这就像在 let
的开始定义 my-variable。在 with-open-file
的主体中,我们将有一个名为 my-stream 的变量可用,就像在 let
的主体中 my-variable 可用一样。
但现在不必太担心流的确切含义。我们将在 第十二章 中更详细地探讨它们。现在,你只需要知道流是可以连接到文件的对象,我们可以将其传递给函数(如 princ)以写入连接的文件。
理解关键字参数
with-open-file 命令也大量使用了关键字参数。让我们再次看看这个命令的先前的例子:
(with-open-file (my-stream
"testfile.txt"
:direction :output
:if-exists :supersede)
(princ "Hello File!" my-stream))
关键字参数有两个部分:参数的名称和参数的值。参数的名称始终是一个以冒号开头的符号。这个例子中有两个关键字参数::direction
,设置为 :output(我们只写入文件而不读取它),以及 :if-exists
,设置为 :superseded(如果已存在同名文件,则丢弃旧版本)。
with-open-file 有关键字参数,因为打开文件是一个复杂的操作,有许多神秘的选项可用。如果 with-open-file 只提供了常规参数来设置所有这些,那么每次调用 with-open-file 都会因为所有参数而变得长而繁琐。此外,人类很难查看长长的参数列表并记住每个参数的作用。
你可能已经注意到了,Common Lisp 中的符号有时以冒号开头。这包括关键字参数,它们总是以冒号开头。这是因为 Lisp 中的常规符号可以指向其他东西。例如,我们可以将变量 cigar 设置为 5 然后返回它:
> `(let ((cigar 5))`
`cigar)`
5
然而,有时我们不想让一个符号指向其他东西。我们希望直接使用这个符号,并希望它有自己的含义。在 Common Lisp 中,以冒号开头的符号(不出所料,被称为 关键字符号)总是指代它自己:
> `:cigar`
:CIGAR
> `(let ((:cigar 5))`
`:cigar)`
*** - LET: :CIGAR is a constant, may not be used as a variable
如你所见,关键字符号:cigar可以直接在 REPL 中评估,并且已经有一个值!。它的值是方便的:cigar。如果我们尝试将:cigar重新定义为其他内容,Common Lisp 不会允许我们这样做!。它是一个常量的事实是有用的,因为 Lisp 编译器可以潜在地对这种简单类型的符号进行比其他类型更多的优化。此外,我们可以在我们知道符号仅仅具有其自身意义的地方使用关键字符号来减少代码中的错误。有时一支雪茄就是一支雪茄。
捕获控制台输出
我们的dot->png以与示例中所示略有不同的方式将数据发送到文件:通过声明流的名字为*standard-output*(Common Lisp 中的一个特殊全局变量,它控制打印函数将输出发送到的默认位置)。因此,在 thunk 内部进行的任何打印都将被重定向到我们的 DOT 文件。
让我们再次查看我们的dot->png函数,以了解这一点:
(defun dot->png (fname thunk)
(with-open-file (*standard-output*
fname
:direction :output
:if-exists :supersede)
(funcall thunk))
(ext:shell (concatenate 'string "dot -Tpng -O " fname)))
那么,dot->png函数究竟是如何导致我们的 DOT 数据被保存到文件而不是仅仅发送到控制台的呢?为了回答这个问题,你需要稍微锻炼一下你的大脑。同时,你还需要回忆我们在第二章中关于局部和动态变量的讨论。
记住,let 命令通常创建一个词法,或局部,变量。正如我们讨论过的,with-open-file创建的流变量类似于使用let创建变量。因此,它通常会导致为我们创建一个词法流变量。
然而,如果已经存在具有相同名称的动态变量,let将暂时覆盖动态变量的值到新值。*standard-output*就是这样一种动态变量。这意味着我们可以通过将其传递到with-open-file命令中来暂时覆盖*standard-output*的值!
在with-open-file的主体中,我们调用我们的 thunk,任何打印到控制台中的值现在将自动路由到我们的文件中,而不是控制台。令人惊讶的是(这是由 Common Lisp 中词法和动态变量的设计所实现的),这一点也适用于我们的graph->dot函数中的princ语句,尽管它们是从dot->png间接调用的。
创建我们图的图像
最后,我们需要一个函数来将这些部分连接起来,使我们能够轻松地从一些节点和边创建一个图:
(defun graph->png (fname nodes edges)
(dot->png fname
(lambda ()
(graph->dot nodes edges))))
此函数接受一个 DOT 文件名(作为变量 fname),以及图的节点和边
,并使用它们来生成图。为此,它调用 dot->png
并创建适当的 thunk——一个 lambda 函数
。对于 thunk 来说,这是惯例,它不接受任何参数。
graph->dot 函数在 thunk 内部
作为 延迟计算 被调用。具体来说,如果我们直接调用 graph->dot,其输出将直接显示在控制台上。然而,在 thunk 内部,它将在 dot->png 函数的空闲时被调用,并且输出将被用来生成带有文件名的 DOT 文件,该文件名作为 graph->png 的第一个参数传入。
让我们尝试使用我们的新函数绘制巫师房子的图表!
(graph->png "wizard.dot" *wizard-nodes* *wizard-edges*)
调用此函数后,你现在应该看到一个名为 wizard.dot.png 的文件,这是巫师房子的地图图片:

这可能不是世界上最漂亮的图,但它信息量很大,并且非常容易理解。此外,代码非常灵活,对节点和边数据的依赖性很少。
在我们的工具库中有了这些工具后,我们现在可以轻松地从 Lisp 程序中的任何互连数据创建图表。当你需要处理复杂数据时,你会发现这项技术是一个非常有价值的调试工具。
创建无向图
在边上带有箭头的图称为 有向图:

但有时我们拥有无向数据,允许我们沿着边在两个方向上旅行。这种图比有向图更不繁忙,并且可能更容易理解:

以下代码通过新的函数扩展了我们的图表工具,使我们能够绘制无向图:
(defun uedges->dot (edges)
(maplist (lambda (lst)
(mapc (lambda (edge)
(unless (assoc (car edge) (cdr lst))
(fresh-line)
(princ (dot-name (caar lst)))
(princ "--")
(princ (dot-name (car edge)))
(princ "[label=\"")
(princ (dot-label (cdr edge)))
(princ "\"];")))
(cdar lst)))
edges))
(defun ugraph->dot (nodes edges)
(princ "graph{")
(nodes->dot nodes)
(uedges->dot edges)
(princ "}"))
(defun ugraph->png (fname nodes edges)
(dot->png fname
(lambda ()
(ugraph->dot nodes edges))))
这段代码与创建我们有向图的代码非常相似。让我们看看一些不同之处。
uedges->dot 函数与 edges->dot 函数非常相似
。然而,我们绘制的图可能包含多个节点之间的有向边,我们希望用一条单条无向边来替换。例如,在我们的巫师地图上,我们可以通过门向东走从花园到客厅。当然,我们也可以通过门向西走从客厅到花园。在我们的无向图中,我们希望合并这一点;本质上,我们只想说,“花园和客厅之间有一扇门。”
uedges->dot 函数通过使用 maplist 函数遍历边列表来擦除这样的重复边。这类似于 mapcar 函数,但函数内部接收的是整个列表的剩余部分,而不仅仅是列表中的当前项:
> `(mapcar #'print '(a b c))`
A
B
C
...
> `(maplist #'print '(a b c))`
(A B C)
(B C)
(C)
...
maplist 函数将列表中从当前项到末尾的所有内容发送给 print 函数。uedges->dot
然后使用从 maplist 获得的未来节点信息来检查节点目标是否出现在边列表中。实际的检查是通过 assoc 函数完成的,在剩余边列表中寻找当前边,计算为 (cdr lst)
。在这种情况下,它跳过该边,因此任何一对边中只打印一个。
ugraph->dot
函数与 graph->dot 函数类似,但在生成 DOT 数据时,它将图描述为只是一个图
,而不是有向图。ugraph->png 函数
与 graph->png 函数基本相同,只是它调用 ugraph->dot 而不是 graph->dot。
我们设计了 dot->png 函数以接受不同的 thunks,以便它能与不同的 DOT 数据生成器一起工作。现在我们已经利用这种灵活性来生成这些函数,它们为无向图输出图片。例如,让我们尝试为巫师的房子生成一个无向图:
(ugraph->png "uwizard.dot" *wizard-nodes* *wizard-edges*)
在这里,"uwizard.dot" 是我们想要创建的 DOT 文件名。*wizard-nodes* 和 *wizard-edges* 变量包含描述巫师世界地图的节点和边的数据。此代码生成 uwizard.dot.png 文件,其外观如下:

现在你有了针对有向和无向图的完整工具集,将这些函数写入名为 graph-util.lisp 的文件中,这样你就可以从其他程序中访问它们。
你学到了什么
在本章中,我们讨论了列表的奇特类型,并为数学图创建了一个绘图库。在这个过程中,你学习了以下内容:
-
你可以在 Lisp 中创建以非 nil 值结尾的列表。这些列表在最后一个项目前有一个额外的点,被称为 点列表。
-
对 是当你将两个不是列表的项 cons 到一起时得到的结果。它们也可以被视为只包含两个项目的点列表。
-
循环列表 是指最后一个 cons 单元指向同一列表中较早的 cons 单元的列表。
-
关联列表(alists) 是对列表示。它们可以用来存储以键值对形式存在的数据。
-
Lisp 语法表达式非常适合存储和可视化类似列表和层次结构的数据。额外的工具可能有助于可视化更复杂的数据。
-
如果你的数据是以数学图的形式,使用 Graphviz 生成你的数据图片是有帮助的。
-
在 Lisp 程序中生成文本数据的一个常见技术是编写将文本打印到控制台以方便调试的函数,并将这些函数包装在 thunks 中。然后你可以将这些 thunks 发送到其他函数,这些函数捕获控制台输出并将文本路由到适当的目的地,例如写入文件。
第八章. 这不是你爸爸的 Wumpus
在上一章中,我们在一个简单的游戏中使用了数学图。然而,作为一个老派的极客,当我看到这些图时,我首先想到的是老游戏“猎杀 Wumpus”。当我九岁的时候,我想不出比坐在我的 TI-99/4A 前玩这个优秀游戏更有趣的事情了。
这里是原始标题屏幕:
在“猎杀 Wumpus”游戏中,你是一名猎人,在洞穴网络中寻找一个神秘的怪物——传说中的 Wumpus。在这个过程中,你还要应对蝙蝠和沥青坑。啊,那些日子真是美好!

但,不幸的是,那些日子已经一去不复返了。我们现在已经进入了一个新的千年,没有人会对这些粗糙的图形感到印象深刻。至于剧情,让我们说它按照现代标准听起来有点老套。我想我们都可以同意,猎杀 Wumpus 迫切需要一次翻新。这是一个相当大的挑战,但我认为我们可以应对。
因此,我向您展示……

大盗 Wumpus 游戏
在这个新的“猎杀 Wumpus”版本中,你是 Lisp 外星人。你和 Wumpus 刚刚抢劫了一家酒馆,并带着赃物逃跑了。然而,在逃跑过程中,Wumpus 决定背叛你,带着钱和你的车跑了。但在他开车离开之前,你设法在他肾脏上打了几枪。

现在你处于一个非常艰难的情况。你没有车,也没有钱,而且无法追踪你以前的犯罪伙伴。但你也没有选择。你有你的原则,所以你将去猎杀 Wumpus。你知道他受伤后不会走得太远。他很可能需要躲起来几天来恢复,这意味着他仍然会在拥堵城市中某个地方。问题是这个城镇的道路极其复杂,没有人能找到出路,尤其是像你这样的外地人。你如何在这个不可能的迷宫中找到 Wumpus 呢?

幸运的是,作为 Lisp 外星人,你总是带着你可靠的口袋电脑。使用 Lisp 和你的图工具,你完全装备好了来分析复杂的数据,比如拥堵城的道路和交叉口。当然,你有征服这个难以渗透的道路系统的工具。

独角兽已经是你犯罪的伙伴一段时间了,所以你非常了解他的行事风格。他总是在使用任何新的藏身之处之前,会仔细地侦察。而且由于他受伤了,任何距离他的藏身之处一两个街区(即一两个图边)的地方都应该有一些明显的迹象:他的血迹。
问题是他仍然有他可靠的 AK-47,而你只有一把手枪和一颗子弹。如果你想除掉他,你必须绝对确定你已经追踪到了他。你需要冲进他的藏身之处并立即射击他,而你只有一次机会来完成这个任务。

很不幸,你和独角兽并不是这个镇上唯一的罪犯。在拥堵城中最令人畏惧的匪帮是恐怖的萤火虫团伙。这些人是一群无情的绑架犯。如果你遇到他们,他们会绑架你,打你,抢你的东西,蒙上你的眼睛,然后把你从他们的车里踢出来,把你留在这个城镇的某个随机地方。
幸运的是,如果你知道留意他们发光的胸甲(因此得名),就可以避免他们。如果你看到一些闪烁的灯光,你就知道这些人离你现在的位置只有一条街的距离。此外,你知道这个团伙有 exactly three 个独立的团队,分别从三个不同的地点管理这个城市。

最后,你还需要应对警察。你知道他们可能已经在镇上设置了路障试图抓住你和独角兽。你应该仍然可以访问拥堵城的任何地方,但你需要小心你走的街道。(换句话说,如果你沿着错误的边走,警察就会抓住你。)不幸的是,你不知道可能有多少这样的路障。
正如你所见,找到独角兽并找回你的钱和车将会很困难。如果你认为自己足够像 Lisp 外星人,可以挑战独角兽,那么让我们编写这个游戏并追踪他吧!
定义拥堵城的边界
拥堵城的地图将是一个无向图,每个节点存储在变量 *congestion-city-nodes* 中的数据。每个节点可能的数据包括独角兽的存在、萤火虫团队和各种危险标志。
存储在 *congestion-city-edges* 中的边集将连接节点,与这些边相关联的数据将提醒我们任何警察路障的存在。我们使用 defparameter 在程序顶部声明这些和其他全局变量:
(load "graph-util")
(defparameter *congestion-city-nodes* nil)
(defparameter *congestion-city-edges* nil)
(defparameter *visited-nodes* nil)
(defparameter *node-num* 30)
(defparameter *edge-num* 45)
(defparameter *worm-num* 3)
(defparameter *cop-odds* 15)
我们首先使用 load 命令加载我们的图工具!,这将评估 graph-util.lisp(我们在上一章中创建的)中的所有代码,因此图工具函数将可用。注意,拥堵城市将有 30 个位置!(节点,使用 *node-num* 定义),45 条边!(道路,使用 *edge-num* 定义),以及 3 个蠕虫团队!(使用 *worm-num* 定义)。每条街道将有 1/15 的机会!包含一个路障(使用 *cop-odds* 定义)。
生成随机边
接下来,我们创建一个随机边列表来连接所有节点:
(defun random-node ()
(1+ (random *node-num*)))
(defun edge-pair (a b)
(unless (eql a b)
(list (cons a b) (cons b a))))
(defun make-edge-list ()
(apply #'append (loop repeat *edge-num*
collect (edge-pair (random-node) (random-node)))))
首先,我们声明 random-node 函数!,它返回一个随机节点标识符。它使用 random 函数,该函数返回小于您传递给它的整数的随机自然数。由于我们将在用户界面中显示节点标识符,我们使用 1+ 函数将节点编号编号为 1 到 30(上限,因为 *node-num* 变量设置为 30),而不是 0 到 29。
make-edge-list 函数!生成实际的随机边列表。它使用 loop 命令循环 *edge-num* 次!,然后收集所需的边数!。我们将在下一节中更详细地了解 loop 命令。城市的图是无向的,因此这个函数使用辅助函数 edge-pair!在随机选择的节点之间创建 两个 有向边。这一额外步骤一旦记住无向图与有向图相同,就像两个相反的有向边镜像每一条无向边一样,就很有意义了。(当我们在本章的后面将边构建到 alist 中时,这一步骤将确保列表正确形成。)

让我们在 CLISP REPL 中尝试使用 make-edge-list 函数:
> `(make-edge-list)`
((16 . 20) (20 . 16) (9 . 3) (3 . 9) (25 . 18) (18 . 25) (30 . 29)
(29 . 30) (26 . 13) (13 . 26) (12 . 25) (25 . 12) (26 . 22) (22 . 26)
(30 . 29) (29 . 30) (3 . 14) (14 . 3) (28 . 6) (6 . 28) (4 . 8) (8 . 4)
(27 . 8) (8 . 27) (3 . 30) (30 . 3) (25 . 16) (16 . 25) (5 . 21) (21 . 5)
(11 . 24) (24 . 11) (14 . 1) (1 . 14) (25 . 11) (11 . 25) (21 . 9) (9 . 21)
(12 . 22) (22 . 12) (21 . 11) (11 . 21) (11 . 17) (17 . 11) (30 . 21) (21 . 30)
(3 . 11) (11 . 3) (24 . 23) (23 . 24) (1 . 24) (24 . 1) (21 . 19) (19 . 21) (25 . 29)
(29 . 25) (1 . 26) (26 . 1) (28 . 24) (24 . 28) (20 . 15) (15 . 20)
(28 . 25) (25 . 28)
(2 . 11) (11 . 2) (11 . 24) (24 . 11) (29 . 24) (24 . 29)
(18 . 28) (28 . 18) (14 . 15)
(15 . 14) (16 . 10) (10 . 16) (3 . 26) (26 . 3) (18 . 9) (9 . 18) (5 . 12)
(12 . 5) (11 . 18) (18 . 11) (20 . 17) (17 . 20) (25 . 3) (3 . 25))
您可以看到构成边的节点编号对。这个边对列表将形成拥堵城市道路系统的骨架。
使用循环命令进行循环
我们的make-edge-list函数使用了强大的loop命令,它可以用来遍历各种类型的数据。我们将在第十章中详细讨论loop。然而,我们的游戏使用了loop几次,所以让我们考虑一些简单的例子来阐明它是如何工作的。
使用loop可以做的方便事情之一是创建一个数字列表。例如,以下命令将创建一个包含 10 个 1 的列表:
> `(loop repeat 10`
`collect 1)`
(1 1 1 1 1 1 1 1 1 1)
在loop命令中,我们指定要重复的次数,然后指定每次循环要收集的对象(在这种情况下,是数字 1)。
有时候,我们希望在循环过程中保持一个运行计数。我们可以使用以下语法来完成:
> `(loop for n from 1 to 10`
`collect n)`
(1 2 3 4 5 6 7 8 9 10)
在这个例子中,我们说n应该从 1 循环到 10。然后我们collect每个n并将其作为列表返回。
实际上,我们可以在循环的collect部分放置任何 Lisp 代码。在以下示例中,我们在收集时添加了 100:
> `(loop for n from 1 to 10`
`collect (+ 100 n))`
(101 102 103 104 105 106 107 108 109 110)
防止岛屿
现在我们可以生成随机边。当然,如果我们只是用随机边连接随机节点,由于随机性,我们无法保证整个拥堵城市的所有节点都连接在一起。例如,城市的某些部分可能形成一个岛屿,没有连接到主道路系统。

为了防止这种情况,我们将我们的边列表,找到未连接的节点,并使用以下代码将这些岛屿连接到城市网络的其他部分:
(defun direct-edges (node edge-list)
(remove-if-not (lambda (x)
(eql (car x) node))
edge-list))
(defun get-connected (node edge-list)
(let ((visited nil))
(labels ((traverse (node)
(unless (member node visited)
(push node visited)
(mapc (lambda (edge)
(traverse (cdr edge)))
(direct-edges node edge-list)))))
(traverse node))
visited))
(defun find-islands (nodes edge-list)
(let ((islands nil))
(labels ((find-island (nodes)
(let* ((connected (get-connected (car nodes) edge-list))
(unconnected (set-difference nodes connected)))
(push connected islands)
(when unconnected
(find-island unconnected)))))
(find-island nodes))
islands))
(defun connect-with-bridges (islands)
(when (cdr islands)
(append (edge-pair (caar islands) (caadr islands))
(connect-with-bridges (cdr islands)))))
(defun connect-all-islands (nodes edge-list)
(append (connect-with-bridges (find-islands nodes edge-list)) edge-list))
首先,我们声明一个名为direct-edges的实用函数 ![http://atomoreilly.com/source/nostarch/images/783564.png],它找到边列表中所有从给定节点开始的边。它是通过创建一个新的列表,使用remove-if-not ![http://atomoreilly.com/source/nostarch/images/783562.png] 删除所有不在car位置包含当前节点的边来做到这一点的。
为了找到岛屿,我们编写了get-connected函数 ![http://atomoreilly.com/source/nostarch/images/783560.png]。这个函数接受一个边列表和一个源节点,并构建一个列表,其中包含与该节点连接的所有节点,即使需要跨越多个边。
找到连接节点的常用方法是启动一个visited列表 ![http://atomoreilly.com/source/nostarch/images/783554.png],然后从源节点开始沿着连接节点进行搜索。新找到的节点使用push命令 ![http://atomoreilly.com/source/nostarch/images/783510.png] 添加到已访问列表中。我们还会遍历这个找到节点的所有子节点,使用mapc ![http://atomoreilly.com/source/nostarch/images/783544.png]。
如果我们遇到一个已经被访问过的节点,我们知道我们可以忽略它。一旦搜索完成,visited列表将包含所有连接的节点。
现在我们有一个用于查找连接节点的函数,我们可以使用它来创建一个函数,该函数将找到我们图中的所有岛屿。find-islands函数首先定义一个局部函数,称为find-island ![http://atomoreilly.com/source/nostarch/images/783556.png]。这个函数使用connected函数检查哪些节点连接到我们节点列表中的第一个节点。然后它使用set-difference函数从完整节点列表中减去这些节点。(set-difference接受两个列表,并返回第一个列表中但不在第二个列表中的所有项。)
任何剩余的节点都被认为是未连接的。如果存在任何未连接的节点 ![http://atomoreilly.com/source/nostarch/images/783566.png],我们再次递归地调用find-islands函数以找到额外的岛屿。
一旦我们找到了所有岛屿,我们需要一种方法将它们连接起来。这是connect-with-bridges函数的工作。它返回一个额外的边列表,将这些岛屿连接起来。为此,它检查岛屿列表中是否存在cdr ![http://atomoreilly.com/source/nostarch/images/783498.png]。如果存在,这意味着至少有两个陆地,可以用桥梁连接。它使用edge-pair函数创建这个桥梁,然后对岛屿列表的尾部递归地调用自身,以防需要额外的桥梁。
最后,我们使用函数connect-all-islands ![http://atomoreilly.com/source/nostarch/images/783062.png] 将所有岛屿预防函数连接起来。它使用find-islands来找到所有陆地,然后调用connect-with-bridges来构建适当的桥梁。然后它将这些桥梁附加到初始边列表中,以产生一个最终、完全连接的陆地。
为拥堵城市构建最终边
为了完成拥堵城市的边,我们需要将边从边列表转换为 alist。我们还将添加警察路障,这些路障将随机出现在一些边上。对于这些任务,我们将创建make-city-edges、edges-to-alist和add-cops函数:
(defun make-city-edges ()
(let* ((nodes (loop for i from 1 to *node-num*
collect i))
(edge-list (connect-all-islands nodes (make-edge-list)))
(cops (remove-if-not (lambda (x)
(zerop (random *cop-odds*)))
edge-list)))
(add-cops (edges-to-alist edge-list) cops)))
(defun edges-to-alist (edge-list)
(mapcar (lambda (node1)
(cons node1
(mapcar (lambda (edge)
(list (cdr edge)))
(remove-duplicates (direct-edges node1 edge-list)
:test #'equal))))
(remove-duplicates (mapcar #'car edge-list))))
(defun add-cops (edge-alist edges-with-cops)
(mapcar (lambda (x)
(let ((node1 (car x))
(node1-edges (cdr x)))
(cons node1
(mapcar (lambda (edge)
(let ((node2 (car edge)))
(if (intersection (edge-pair node1 node2)
edges-with-cops
:test #'equal)
(list node2 'cops)
edge)))
node1-edges))))
edge-alist))
这些是《大盗乌普苏斯》中最繁琐的函数。让我们更仔细地看看它们。
make-city-edges函数
首先,make-city-edges函数创建一个节点列表,使用一个loop ![http://atomoreilly.com/source/nostarch/images/783564.png]。(这只是一个从 1 到*node-num*的数字列表。)接下来,它通过调用make-edge-list和connect-edge-list函数 ![http://atomoreilly.com/source/nostarch/images/783562.png] 创建一个随机(但完全连接)的边列表。这个结果存储在edge-list变量中。然后它创建一个包含cops ![http://atomoreilly.com/source/nostarch/images/783560.png] 的随机边列表。我们使用let*命令定义这些变量,这使得我们可以引用先前定义的变量。
以下示例展示了使用 let 和 let* 定义变量之间的区别:
> `(let ((a 5)`
`(b (+ a 2)))`
`b)`
*** - EVAL: variable A has no value
> `(let* ((a 5)`
`(b (+ a 2)))`
`b)`
7
如您所见,let 不允许您引用其他已定义的变量(变量 b 不能引用 a 的值)。另一方面,当使用 let* 定义变量时,这种引用是允许的。对于我们的目的,使用 let* 允许我们的 cops
定义包含对 edge-list 的引用。
一旦我们创建了边列表并确定了警察的位置,我们需要将我们的边列表转换为 alist 并向其中添加警察
。边通过 edges-to-alist 函数转换为 alist,警察通过 add-cops 函数添加。
边到 alist 函数
edges-to-alist 函数将边列表转换为边 alist。例如,假设我们有一个以下城市,只有三个位置和两条连接这些位置的边:

我们会用边列表 `'((1 . 2) (2 . 1) (2 . 3) (3 . 2))' 来描述这一点。记住,每条边都是重复的,因为边是无向的,可以在两个方向上使用。如果我们用 alist 来描述同一个城市,那会是什么样子?
记住,alist 是一个列表,它允许我们查找一个键(在这个例子中,是我们城市中的三个节点之一)并找到与该键关联的信息(在这种情况下,是与它相连的道路列表)。对于这个小型城市,alist 将是 `'((1 (2)) (2 (1) (3)) (3 (2)))'。
为了构建这个 alist,edges-to-list 函数首先使用 mapcar
对边列表中找到的节点进行映射。为了构建节点列表,我们使用 remove-duplicates 函数,该函数从列表中删除重复项。默认情况下,remove-duplicates 使用 eql 函数来检查相等性,尽管它还允许你使用 :test 关键字参数选择不同的测试函数。由于我们在 make-city-edges 函数中检查的是 cons 对的相等性,我们将 :test 设置为 #'equal
。
在这个外部的 mapcar
中,我们使用另一个 mapcar
来映射所有指向该节点的 direct-edges。这些嵌套的 mapcar 函数共同允许 edges-to-alist 将城市的边转换为 alist。
添加警察函数
当我们编写 make-city-edges 函数时,我们随机标记了一些边以显示它们上面有警察!
。我们现在将使用这个警察边的列表来标记我们 alist 中的包含警察的边。这是 add-cops 函数的工作。
要做到这一点,我们使用嵌套的mapcar命令映射每个节点内的边 ![httpatomoreillycomsourcenostarchimages783566.png]![httpatomoreillycomsourcenostarchimages783498.png]。然后我们使用intersection函数 ![httpatomoreillycomsourcenostarchimages783062.png]检查给定边是否有警察。(intersection函数告诉我们两个列表之间共享哪些项目。)
要确切了解add-cops函数正在做什么,再次想象我们的城市只有三个位置和两条街道将有所帮助。在这个例子中,其中一条街道上有警察:

由add-cops创建的此城市的生成 alist 将看起来像这样:
((1 (2)) (2 (1) (3 COPS)) (3 (2 COPS)))
这实际上是一个嵌套的 alist。外层 alist 是根据第一个节点组织的,内层 alist 是根据第二个节点组织的。
使用这种格式的边,我们可以通过调用(cdr (assoc node1 edges))轻松找到与给定节点相连的所有边。要检查给定边是否包含警察,我们可以调用(cdr (assoc node2 (cdr (assoc node1 edges)))),这将向下两级以获取与两个节点之间特定边链接的实际数据。(使用这种嵌套 alist 格式的另一个额外好处是它与我们的图库完全兼容——这是一个我们将很快利用的特性。)
构建拥堵城市的节点
现在,我们将为我们的城市中的节点构建一个 alist。这些节点可能包含 Wumpus 或萤火虫,或者它们可能包含各种线索,如血液、灯光或警报声。
我们游戏中的大多数线索都是基于另一个节点的邻近性,因此我们需要编写一些函数来告诉我们两个节点在图中的城市中是否相隔一个节点。neighbors函数通过边表的 alist 查找节点的邻居。如果第二个节点在该列表中,我们知道我们相隔一个节点。
(defun neighbors (node edge-alist)
(mapcar #'car (cdr (assoc node edge-alist))))
(defun within-one (a b edge-alist)
(member b (neighbors a edge-alist)))
首先,这个函数在边的 alist 中通过neighbors查找第一个节点(a)。然后它使用member来查看另一个节点(b)是否在这些节点中。
从两个节点距离处也可以看到 Wumpus 的血迹线索。我们可以为这样的两个节点编写第二个检查函数:
(defun within-two (a b edge-alist)
(or (within-one a b edge-alist)
(some (lambda (x)
(within-one x b edge-alist))
(neighbors a edge-alist))))
首先,我们检查我们是否在我们的目标节点内一个节点范围内 ![httpatomoreillycomsourcenostarchimages783564.png],因为如果我们在一个节点内,我们也在两个节点内。接下来,我们提取所有一个节点范围内的节点 。最后,我们检查这些新节点中是否有任何节点在一个节点范围内 ![httpatomoreillycomsourcenostarchimages783562.png],这将使它们在原始节点两个节点范围内。
现在我们有了这些实用函数,让我们编写构建最终节点 alist 的函数(基本上,这是我们城市的最终地图。)以下是代码列表:
(defun make-city-nodes (edge-alist)
(let ((wumpus (random-node))
(glow-worms (loop for i below *worm-num*
collect (random-node))))
(loop for n from 1 to *node-num*
collect (append (list n)
(cond ((eql n wumpus) '(wumpus))
((within-two n wumpus edge-alist) '(blood!)))
(cond ((member n glow-worms)
'(glow-worm))
((some (lambda (worm)
(within-one n worm edge-alist))
glow-worms)
'(lights!)))
(when (some #'cdr (cdr (assoc n edge-alist)))
'(sirens!))))))
make-city-nodes 函数首先为乌普斯
和萤火虫
随机选择节点,然后使用 loop
运行节点编号。在运行节点时,它构建一个描述城市中每个节点的列表,appended 从各种来源
。通过使用 append,描述这些节点(且在 append 的主体内)的代码部分可以选择添加零、一个或多个项目到描述中,创建自己的子列表,包含零、一个或多个项目。
在列表的开头,我们放置节点名称,n
。如果乌普斯位于当前节点,我们添加单词 乌普斯
(但用列表包裹,正如我们刚才描述的那样)。如果我们距离乌普斯两个节点以内,我们显示它的血迹
。如果节点有一个萤火虫团伙,我们显示它
,如果萤火虫团伙在一个节点之外,我们显示它的灯光
。最后,如果从节点的边包含警察,我们表明可以听到警笛声
。
要检查警笛线索,我们只需获取 (cdr (assoc n edges)) 的边,并查看这些节点中是否有 cdr 中的值。'cops 符号将附加到边的 cdr 上。由于我们在这个游戏中只有一条关于边的数据点,寻找 cdr 的存在是检查警察存在的一个充分的检查。例如,如果我们使用我们之前有警察的 alist 示例:
((1 (2)) (2 (1) (3 COPS)) (3 (2 COPS)))
你可以看到,如果列表中的边有警察,例如这里
,cdr 将指向一个非 nil 的值。没有警察的边
将有一个 cdr 是 nil。

初始化《大盗乌普斯》新游戏
在处理完我们的图构建工具之后,我们可以编写一个简单的函数来初始化一个全新的《大盗乌普斯》游戏:
(defun new-game ()
(setf *congestion-city-edges* (make-city-edges))
(setf *congestion-city-nodes* (make-city-nodes *congestion-city-edges*))
(setf *player-pos* (find-empty-node))
(setf *visited-nodes* (list *player-pos*))
(draw-city))
这里有两个新函数。一个,find-empty-node 函数
,确保玩家在游戏开始时不会直接站在坏蛋身上。以下是该函数的代码:
(defun find-empty-node ()
(let ((x (random-node)))
(if (cdr (assoc x *congestion-city-nodes*))
(find-empty-node)
x)))
find-empty-node 函数相当简单。首先,它随机选择一个节点!作为玩家的起始位置。然后检查它是否是一个完全空的节点!。如果节点里有东西,它就简单地再次调用自己,尝试另一个随机位置!。
警告
如果你决定修改游戏并使其充满坏人,你可能会陷入一个没有空节点的情况。在这种情况下,这个函数将永远搜索并锁定你的 Lisp REPL,因为我们没有添加任何检查来检测这种情况。
我们 new-game 命令中的另一个新函数是 draw-city,我们将在下一节中编写。
绘制我们城市的地图
我们终于准备好绘制我们新城市的地图了。我们使用标准的图形数据格式,因此编写这个函数轻而易举:
(defun draw-city ()
(ugraph->png "city" *congestion-city-nodes* *congestion-city-edges*))
我们在上一章中创建了 ugraph->png 函数,作为我们的图形库的一部分。
现在从 REPL 中调用 (new-game),并在你的网络浏览器中打开 city.dot.png 图片:

注意
由于我们用代码创建的每个城市地图都是唯一的,所以你的地图将完全不同于这张图片。
最后,我们可以惊叹于我们城市规划的结果!
从部分知识绘制城市
当然,在狩猎开始之前就已经知道猎物在哪里,狩猎起来会非常无聊。为了解决这个问题,我们想要一张只显示我们迄今为止访问过的节点的城市地图。为此,我们使用一个名为 *visited-nodes* 的全局列表,最初只设置为玩家的位置,但随着我们在城市中访问其他节点,我们将更新它。使用这个 *visited-nodes* 变量,我们可以计算一个更小的图,只包括我们已知的城市部分。
已知节点
首先,我们可以构建一个只包含已知节点的 alist:
(defun known-city-nodes ()
(mapcar (lambda (node)
(if (member node *visited-nodes*)
(let ((n (assoc node *congestion-city-nodes*)))
(if (eql node *player-pos*)
(append n '(*))
n))
(list node '?)))
(remove-duplicates
(append *visited-nodes*
(mapcan (lambda (node)
(mapcar #'car
(cdr (assoc node
*congestion-city-edges*))))
*visited-nodes*)))))
在 known-city-nodes 的底部,我们需要确定我们可以“看到”哪些节点,基于我们去过的地方。我们将能够看到所有已访问的节点!,但我们还想要跟踪所有位于已访问节点一个节点范围内的节点!。(我们将在稍后讨论 mapcan 函数。)我们使用类似于之前讨论的 within-one 函数的代码来计算“一个节点范围内”的人。
接下来,我们将对这个相关节点列表使用 mapcar,处理每个节点!。如果当前节点被玩家占据,我们用星号标记它!。如果节点尚未访问过!,我们用问号标记它!。
已知边
现在,我们需要创建一个 alist,其中不包含我们尚未到达的任何警察警报:
(defun known-city-edges ()
(mapcar (lambda (node)
(cons node (mapcar (lambda (x)
(if (member (car x) *visited-nodes*)
x
(list (car x))))
(cdr (assoc node *congestion-city-edges*)))))
*visited-nodes*))
这个函数与 known-city-nodes 函数相似。值得注意的是这里的代码行
,在这里我们从边缘列表中移除了 cdr,这样只有在包含警察的边缘的两端节点都被访问过时,地图上才会显示警察。
mapcan 函数
在 known-city-nodes 中使用的 mapcan 函数是 mapcar 的一个变体。然而,与 mapcar 不同,mapcan 假设映射函数生成的值都是应该一起附加的列表。当列表中的项与您想要生成的结果之间不存在一对一关系时,这很有用。
例如,假设我们经营一家汉堡店,并出售三种类型的汉堡:单层汉堡、双层汉堡和双层芝士汉堡。要将汉堡列表转换为肉饼和芝士片的列表,我们可以编写以下函数:
> `(defun ingredients (order)`
`(mapcan (lambda (burger)`
`(case burger`
`(single '(patty))`
`(double '(patty patty))`
`(double-cheese '(patty patty cheese))))`
`order))`
INGREDIENTS
> `(ingredients '(single double-cheese double))`
'(PATTY PATTY PATTY CHEESE PATTY PATTY)
仅绘制城市的已知部分
由于我们现在有可以生成节点和边已知信息的函数,我们可以编写一个将此信息转换为图片的函数,如下所示:
(defun draw-known-city ()
(ugraph->png "known-city" (known-city-nodes) (known-city-edges)))
现在让我们重新定义 new-game 函数,以便在游戏开始时绘制已知的城市:
(defun new-game ()
(setf *congestion-city-edges* (make-city-edges))
(setf *congestion-city-nodes* (make-city-nodes *congestion-city-edges*))
(setf *player-pos* (find-empty-node))
(setf *visited-nodes* (list *player-pos*))
(draw-city)
(draw-known-city))
这个函数几乎与 new-game 的上一个版本完全相同,除了我们还创建了一个仅由城市的已知部分组成的绘图
。
现在,如果我们从 REPL 调用 new-game 函数,我们将得到一个名为 known-city.dot.png 的新图片,我们可以在浏览器中查看。它看起来可能像这样:

现在我们已经准备好在拥堵城市的地图上漫步了!
在镇上漫步
我们需要两个函数来在城市节点之间旅行:一个常规的 walk 函数和一个当我们认为我们找到了 Wumpus 并且我们想要用最后一颗子弹 charge 该位置时的函数。由于这两个函数非常相似,我们将让它们都将大部分工作委托给一个共同的 handle-direction 函数:
(defun walk (pos)
(handle-direction pos nil))
(defun charge (pos)
(handle-direction pos t))
这两个函数之间的唯一区别是它们传递给 handle-direction 的标志,该标志设置为 nil 或 t,具体取决于旅行的类型。
handle-direction 函数的主要任务是确保移动是合法的,它通过检查城市的边缘来实现这一点:
(defun handle-direction (pos charging)
(let ((edge (assoc pos
(cdr (assoc *player-pos* *congestion-city-edges*)))))
(if edge
(handle-new-place edge pos charging)
(princ "That location does not exist!"))))
首先,这个函数查找玩家可以从当前位置移动到的合法方向
。然后它使用玩家想要移动到的pos,并在可能的方向列表中查找它。一旦我们确定了一个方向是合法的(也就是说,具有该编号的节点与玩家的当前位置共享边),我们就需要找出玩家在前往这个新地方时等待的惊喜,使用我们接下来要创建的handle-new-place函数
。否则,我们显示一个有用的错误信息
。
现在让我们创建handle-new-place函数,该函数在玩家到达新地方时被调用:
(defun handle-new-place (edge pos charging)
(let* ((node (assoc pos *congestion-city-nodes*))
(has-worm (and (member 'glow-worm node)
(not (member pos *visited-nodes*)))))
(pushnew pos *visited-nodes*)
(setf *player-pos* pos)
(draw-known-city)
(cond ((member 'cops edge) (princ "You ran into the cops. Game Over."))
((member 'wumpus node) (if charging
(princ "You found the Wumpus!")
(princ "You ran into the Wumpus")))
(charging (princ "You wasted your last bullet. Game Over."))
(has-worm (let ((new-pos (random-node)))
(princ "You ran into a Glow Worm Gang! You're now at ")
(princ new-pos)
(handle-new-place nil new-pos nil))))))
首先,我们从节点列表中检索玩家正在前往的节点
。接下来,我们确定该节点是否包含萤火虫团伙
。如果他们已经在已访问的节点中,我们忽略这个团伙,因为他们只会攻击一次。
接下来,handle-new-place函数更新*visited-nodes*
(将新位置添加到列表中)和*player-pos*
。然后它再次调用draw-known-city
,因为我们现在有一个我们知道的新地方。
接下来,它检查边缘是否有警察
,然后检查 Wumpus 是否在那个位置
。如果玩家遇到 Wumpus,我们的handle-new-place函数需要知道我们是否正在向该位置进攻。如果我们正在向 Wumpus 进攻,我们就赢得了游戏。否则,Wumpus 会杀死我们,游戏结束。
另一方面,如果我们向一个不包含 Wumpus 的位置进攻,我们会浪费我们的唯一子弹,并且也会输掉游戏
。最后,如果该位置有一个之前未遇到的萤火虫团伙,跳转到随机的新位置,递归调用handle-new-place
。
我们的游戏现在完成了!
让我们狩猎一些 Wumpus 吧!
要玩我们的游戏,只需在 REPL 中输入我们创建的旅行命令(walk和charge),然后切换到浏览器并刷新known-city.dot.png来规划你的下一步。
例如,这是我们样本游戏中停止的地方:

由于我们没有线索,我们知道这些节点中的任何一个都是安全的访问。让我们尝试(walk 20):

哎呀!这里出血了。这意味着无脑怪必须在两个节点之外!尽管只有一个节点之差,但仍然可以安全地(walk 11):

哦,不!这些街道中的一条有警察路障。让我们用(walk 20) (walk 19)回溯,然后我们可以尝试(walk 7):

真糟糕!现在我们附近有無腦怪和一些萤火虫。让我们随机射击并尝试(walk 10):

嗯,这没有帮助,因为这条路有警察。然而,因为节点 10 只有一个未探索的街道,我们可以肯定地说,1 和 10 之间的街道上有警察。
你可以看到,要想成为“大盗无脑怪”大师,需要认真思考!记住,你可以通过使用new-game函数来开始一个新游戏,拥有新的地图。一旦你追踪到无脑怪,就可以使用charge函数攻击它。
如果你掌握了这个游戏的基本版本,可以尝试增加节点、边、警察和萤火虫的数量,以获得更大的挑战!
你学到了什么
在本章中,我们使用 Lisp 的图形工具制作了一个更复杂的游戏。在这个过程中,你学习了以下内容:
loop函数允许我们在各种类型的数据上循环。它将在第十章(第十章。使用 loop 命令循环`
(NIL NIL NIL)
这创建了一个长度为 3 的数组。为了表明创建的值不仅仅是一个列表,Common Lisp 在数组前面加上了井号(#)。
要获取和设置数组中的项目,请使用`aref`函数。例如,以下是如何获取索引为 1 的项目:
(defparameter x (make-array 3))
(NIL NIL NIL)
(aref x 1)
NIL
当然,我们的数组现在只是填充了`nil`,所以没有什么值得获取的。要将数组中的项目设置为更有趣的值,请使用`aref`与`setf`命令结合:
(defparameter x (make-array 3))
(NIL NIL NIL)
(setf (aref x 1) 'foo)
FOO
x
(NIL FOO NIL)
(aref x 1)
FOO
虽然`aref`通常是一个用来从数组中**获取**值的命令,但在这个例子中特别指出这种方式时,它允许我们在数组中**设置**值。这种使用`setf`和`aref`命令结合的能力展示了 Common Lisp 的一个特性:它对泛型编程的支持。让我们更仔细地看看`setf`命令,以了解更多关于这个特性是如何工作的。
## 使用泛型设置器
据说 Common Lisp 语言支持**泛型设置器**。这意味着在大多数情况下,从数据结构(无论是数组、列表、字符串还是其他)中**提取值**的代码与将数据放入相同数据结构的代码是相同的。`setf`命令可以与执行获取操作的函数一起使用,并且可以使用相同的函数来执行设置操作。
我们已经看到`aref`可以用来从数组中获取值,当与`setf`一起使用时,它可以用来在同一个数组中设置值。`setf`命令可以在 Common Lisp 中大多数从数据结构获取项的命令中以通用方式执行这个技巧。以下是一个涉及列表的例子:
(setf foo '(a b c))
(A B C)
(second foo)
B
(setf (second foo) 'z)
Z
foo
(A Z C)
如你所预期,表达式`(second foo)`返回`B`。但是,当我们把`(second foo)`传递给`setf`命令![httpatomoreillycomsourcenostarchimages783564.png]时,它似乎知道`B`的来源,并且能够将表达式`(second foo)`当作一个普通变量来处理。基本上,`setf`命令会问自己,“我的第一个参数中的项最初是从哪里来的?”在这种情况下,值来自名为`foo`的列表中的第二个元素。因此,如果我们尝试`setf`这个位置,源变量`foo`将被修改。
事实上,`setf`的第一个参数是 Common Lisp 的一种特殊子语言,称为**泛型引用**。并不是每个 Lisp 命令都允许在泛型引用中使用,但你仍然可以放入一些相当复杂的内容:
(setf foo (make-array 4))
(NIL NIL NIL NIL)
(setf (aref foo 2) '(x y z))
(X Y Z)
foo
(NIL NIL (X Y Z) NIL)
(setf (car (aref foo 2)) (make-hash-table))
S(HASH-TABLE)
(setf (gethash 'zoink (car (aref foo 2))) 5)
5
foo
(NIL NIL (#S(HASH-TABLE (ZOINK . 5)) Y Z) NIL)
这个例子展示了 Common Lisp 中`setf`的真正威力。在第一次使用中,我们将列表`(x y z)`作为第三个元素放入数组中![httpatomoreillycomsourcenostarchimages783564.png]。如果我们现在打印`foo`,我们可以看到它已经成功了![httpatomoreillycomsourcenostarchimages783562.png]。在第二次使用中,我们将`foo`数组中的第一个元素替换为一个哈希表![httpatomoreillycomsourcenostarchimages783560.png]。哈希表是我们将在哈希表中学习到的另一种高级数据类型。使用`setf`来做这件事非常简单,因为`setf`的第一个参数中的泛型引用可以是任意复杂的。
最后,我们将值 `5` 插入到这个哈希表中,键为 `zoink`!图片。`gethash` 函数允许你从哈希表中获取值,正如我们很快就会看到的。在这里,借助 `setf`,我们将数字 5 放入哈希表中。
我希望你能从这个例子中体会到 `setf` 在修改程序中的复杂数据结构时是多么有用。
`setf` 的另一个酷特性是你可以扩展通用引用语法以支持新的访问值方式。`setf` 是一种真正通用的修改值的方法,无论嵌套级别或使用的数据类型如何。
## 数组与列表
你现在已经看到了一些在 Lisp 中使用数组的示例。然而,要完全理解数组的好处,我们需要将它们与列表进行比较。
几乎可以用列表完成的所有事情也可以用数组完成。然而,当访问特定元素时,数组通常比列表快得多,所以区别在于性能。
例如,数组处理函数 `aref` 与列表处理函数 `nth` 非常相似,它允许你访问常规列表中特定位置的元素,而无需使用数组。以下是在列表上使用 `nth` 的一个示例:
(nth 1 '(foo bar baz))
BAR
然而,只有在使用非常小的列表时才使用 `nth` 函数是有意义的。例如,如果列表 X 中有成千上万个项目,运行命令 `(nth 1000 x)` 会非常慢,因为 Lisp 列表是由 cons 单元的链构成的。因此,Lisp 找到列表中的第 1000 个元素的唯一方法是通过先遍历前 999 个对象。
相比之下,在大型数组上运行命令 `(aref x 1000)` 会直接访问第 1000 个元素,而不需要通过前 999 个元素进行计数。这意味着在大型数组上 `aref` 的执行速度会比在大型列表上执行 `nth` 命令要快得多。实际上,无论数组有多大,`aref` 调用都会非常快。即使你有一个包含十亿个元素的数组,检索最后一个元素也会非常快。唯一的真正限制因素是你的系统:你的计算机有多少 RAM 以及你的 Lisp 环境如何有效地利用它。

我们不仅能够快速访问数组值,还可以在任意特定位置更改值,通常比在列表上执行相同操作要快。
由于在大型数据结构中设置和获取特定值非常重要,请记住数组作为帮助你以最佳性能编写代码的工具。
# 哈希表
正如数组有点像列表一样,*哈希表* 有点像 *alists*,只不过它们还允许你更快地访问任意元素。
实际上,哈希表如此高效,有时甚至感觉像魔法。想想《银河系漫游指南》三部曲中的 Babel 鱼——某种不可思议的有用之物,它根本不应该存在。这就是为什么现在几乎所有现代语言都提供了哈希表数据类型。

## 使用哈希表
使用`make-hash-table`命令创建一个新的哈希表:
(make-hash-table)
S(HASH-TABLE ...)
与 alists 类似,哈希表使用查找键和值来存储项。我们可以使用`gethash`函数通过项的键从哈希表中检索项:
(defparameter x (make-hash-table))
S(HASH-TABLE ...)
(gethash 'yup x)
NIL ;
NIL
到目前为止,我们的哈希表仍然是空的。这意味着当我们查找哈希表中的任何键时,例如本例中的`'yup`,我们将收到`NIL`作为答案 ![http://atomoreilly.com/source/nostarch/images/783564.png]。实际上,我们收到两个`NIL` ![http://atomoreilly.com/source/nostarch/images/783564.png]![http://atomoreilly.com/source/nostarch/images/783562.png]——`gethash`命令返回多个值,这在 Common Lisp 中是可以做到的(将在下一节讨论)。第一个返回值是哈希表中实际存储的值,第二个表示键是否在表中找到(在这种情况下,没有找到)。
就像数组一样,我们再次可以将用于引用数据元素的命令(在这种情况下,`gethash`)与`setf`命令结合起来,以便用数据填充我们的表:
(defparameter x (make-hash-table))
S(HASH-TABLE ...)
(setf (gethash 'yup x) '25)
25
(gethash 'yup x)
25 ;
T
在这个例子中,我们使用查找键`yup`将值`25`存储在哈希表中 ![http://atomoreilly.com/source/nostarch/images/783564.png]。然后,当我们查找表中的`yup`时,我们得到答案 25 ![http://atomoreilly.com/source/nostarch/images/783562.png]。我们还得到第二个值`t` ![http://atomoreilly.com/source/nostarch/images/783560.png],这意味着,“是的,我在表中找到了这个键。”
记得我们讨论 alists 时,我们设置了一个包含咖啡饮品顺序的数据结构吗?这里就是那个相同的数据,但这次它是使用哈希表存储的:
(defparameter *drink-order* (make-hash-table))
S(HASH-TABLE ...)
(setf (gethash 'bill *drink-order*) 'double-espresso)
DOUBLE-ESPRESSO
(setf (gethash 'lisa *drink-order*) 'small-drip-coffee)
SMALL-DRIP-COFFEE
(setf (gethash 'john *drink-order*) 'medium-latte)
MEDIUM-LATTE
现在访问任何人的饮料订单变得简单:
(gethash 'lisa *drink-order*)
'small-drip-coffee ;
T
## 返回多个值
Common Lisp 允许你返回多个值作为结果。一些核心 Common Lisp 函数就是这样做的,包括你看到的`gethash`函数。另一个常用的执行此操作的函数是`round`函数,它将数字四舍五入:
(round 2.4)
2 ;
0.4
正确调用此函数将我们的数字四舍五入到 2 ![http://atomoreilly.com/source/nostarch/images/783564.png],但它还生成了第二个值,这是四舍五入操作的余数 ![http://atomoreilly.com/source/nostarch/images/783562.png]。这两个值都从这个函数调用中返回。
你也可以通过使用`values`函数在自己的代码中创建多个值。例如,我们可以编写一个`foo`函数,它返回两个不同的数字,3 和 7:
(defun foo ()
(values 3 7))
FOO
(foo)
3 ;
7
这两个值都在 REPL 中打印出来,就像`round`函数一样。然而,Lisp 认为第一个值更重要,并且在后续计算中始终默认使用它。例如,我们可以在调用`foo`之后执行加法,如下所示:
(+ (foo) 5)
8
在这种情况下,加法运算符只是忽略了`foo`返回的第二个值。
然而,有时你可能需要使用那个额外的返回值。你可以通过使用`multiple-value-bind`命令来实现:
(multiple-value-bind (a b) (foo)
(* a b))
21
在这个例子中,我们将变量`a`和`b`绑定到`foo`返回的两个值(`3`和`7`)。使用`multiple-value-bind`调用我们的函数使我们能够使用函数返回的额外值,否则这些值会被忽略。
你可能会想知道是否可以直接从你的函数中返回一个列表,而不是使用多值特性。答案是,你可以。然而,使用多值特性可能会导致更优化和更简洁的代码。
在这本书中,我们不会大量使用多值。事实上,更近期的 Lisp 方言,如 Arc 和 Clojure,根本不支持多值。相反,它们只在需要返回多个值的情况下返回一个列表。
## 哈希表性能
与数组一样,无论你的哈希表包含多少项,访问和修改哈希表中的值只需要恒定的时间。例如,假设我们有一个只包含 10 个条目的哈希表。我们使用键访问表中的值,平均需要 1 毫秒。现在假设哈希表中有 1,000,000 个条目。由于哈希表的设计方式,我们仍然可以期望检索一个值只需要大约 1 毫秒。换句话说,无论表有多大,我们都可以在 1 毫秒的恒定时间内访问项。
想想这是多么令人难以置信!即使你的哈希表包含 1,000,000 个条目,`gethash`函数也能在恒定的时间内确定你想要的项的确切位置,即使你提供了那个键!
在这个基于大量数据的网络程序时代,哈希表能够以快速检索的方式存储大量值,这使得它们变得不可或缺。对于大多数在线存储系统来说,高效地存储键/值对是至关重要的。即使是存储大量在线数据的最新工具,如 Google 的 BigTable 或 Amazon 的 S3,也是围绕使用键快速检索值来构建的,这使得它们类似于哈希表。
然而,你不能总是期望哈希表提供最佳性能。原因如下:
**虚拟内存分页和缓存未命中:**
与数组一样,大的哈希表可能会导致操作系统开始将虚拟内存分页到硬盘上,从而降低性能。同样,它们可能会增加 CPU 内的缓存未命中次数。
**哈希冲突:**
内部,哈希表使用一个称为*哈希函数*的特殊函数,该函数将键转换为数字。这样的哈希函数可能导致*哈希冲突*。基本上,当两个键偶然被哈希函数转换为相同的数字时,就会发生哈希冲突。在这种情况下,哈希表仍然可以正确地工作,但性能会略有下降。在罕见的情况下,某些类型的键可能与哈希函数相互作用,增加冲突的数量,阻碍应用程序执行查找的能力,从而进一步降低性能。
**小表的不效率:**
对于非常小的表,哈希表所需的创建和查找时间可能使它们比简单的结构(如 alists)效率更低。只有在哈希表中包含大量数据时,哈希表的性能优势才明显。
**操作速度不一:**
在 Common Lisp 中,如果你创建了一个小的哈希表,然后填充它,你会发现偶尔添加新值会特别慢。这是因为`make-hash-table`函数被设计成最小化创建小哈希表的成本。然而,当你开始添加值使表变大时,Lisp 需要额外的时间来分配更多的内存,以便表可以容纳更多的项。这些额外的分配会导致表增长时偶尔的慢速插入。
哈希表并非总是最佳解决方案的最后一个原因是:它们并不像由 cons 单元构建的传统 Lisp 结构那样 Lispy。这意味着它们可能比 cons 单元更难调试,因为它们不能像在 Lisp REPL 中那样自然地读取和打印。因此,一个很好的经验法则是,在你构思新的代码片段时,尽量避免使用数组和哈希表。然后,如果性能最终成为问题,并且只有在那时,才谨慎地修改代码的关键部分,以利用数组和哈希表来解决任何性能问题。
## 使用哈希表加速《大盗乌姆普斯》
让我们看看哈希表能为你的代码带来哪些实际例子。在我们的最新游戏《大盗乌姆普斯》中,存在一个明显的低效问题,现在我们可以通过哈希表来纠正这个问题。
回想一下上一章的内容,我们知道《大盗乌姆普斯》使用节点和边组成的列表来表示城市的图。这意味着为了找到给定节点的连接,我们必须在列表中进行线性搜索。在《大盗乌姆普斯》中这不是什么大问题,因为拥堵城市没有很多交叉口。但如果我们有一个有千个节点和千个边的城市呢?让我们计时`get-connected`函数,看看我们会得到什么样的数字:
(setf *edge-num* 1000)
1000
(setf *node-num* 1000)
1000
(time (dotimes (i 100) (get-connected 1 (make-edge-list))))
Real time: 57.699303 sec.
Run time: 57.687607 sec.
Space: 39566832 Bytes
GC: 43, GC time: 0.120005 sec.
`time`命令是一个 Lisp 实用程序,它输出有关代码块的各种有用的计时信息,而`dotimes`函数允许我们运行我们的代码 100 次,构建 100 个城市。使用这些命令,在我的电脑上运行这段代码大约需要一分钟。考虑到 CPU 在一分钟内可以处理多少亿条指令,这绝对是一个令人震惊的糟糕性能。
为了解决这个问题,我们将用哈希表替换此代码中的边列表,这样`get-connected`函数就能在常数时间内找到节点的连接。我们还将用访问表替换访问列表,这样函数可以快速判断一个节点是否已经被访问。
下面是使这一切发生的代码,它由我们之前函数的哈希版本组成:
(defun hash-edges (edge-list)
(let ((tab (make-hash-table)))
(mapc (lambda (x)
(let ((node (car x)))
(push (cdr x) (gethash node tab))))
edge-list)
tab))
首先,我们需要`hash-edges`函数,它将我们的边列表转换为哈希表 。在函数开始时,我们创建一个新的哈希表并将其命名为`tab` 。然后,我们使用`mapc` 遍历表格。记住,`mapc`就像`mapcar`一样,只不过你会在只关心副作用而不关心生成最终列表的地方使用它。
对于每个节点,我们希望表格包含一个与它相连的节点列表。因此,当我们遍历列表时,我们将一个新的邻居推入当前起始节点的当前邻居列表 。我们可以像对常规 Lisp 变量值一样使用哈希表值的`push`命令。这再次利用了内置在 Common Lisp 中的通用变量系统,我们将在以通用方式处理数据中讨论,见以通用方式处理数据。
你可能想知道为什么我们不需要处理表中某个节点还没有值的情况。如果没有值存在,我们如何将某物推入表中的值?好吧,结果证明,因为`gethash`函数在表中找不到键时返回`NIL`,所以这段代码将简单地在新邻居上推入一个空列表,并在之前没有找到的地方在表中插入一个新的记录。这样,`push`命令神奇地做了“正确的事情”,无论节点是新的还是旧的。
最后,一旦我们的表格被填充,我们就将其作为结果返回 。它包含与原始边列表相同的数据。不同之处在于,我们现在可以以闪电般的速度找到拥堵城市中任何节点的邻居。
(defun get-connected-hash (node edge-tab)
(let ((visited (make-hash-table)))
(labels ((traverse (node)
(unless (gethash node visited)
(setf (gethash node visited) t)
(mapc (lambda (edge)
(traverse edge))
(gethash node edge-tab)))))
(traverse node))
visited))
现在我们准备编写`get-connected-hash`,它检索拥堵城市中与起始节点相连的所有节点。它的行为与`get-connected`相同,但通过哈希表进行了优化。
这个函数首先做的事情是创建一个已访问节点的哈希表。然后我们遍历拥堵城市的节点,从起始节点开始。每次我们访问一个新的节点时,我们会问自己是否以前访问过它。现在我们可以通过在`visited`表中查找当前节点来非常高效地回答这个问题。如果答案是未访问过,我们需要将这个节点标记为已访问,并通过`mapc`检查它的所有邻居——检查我们的边表。最后,我们返回我们的`visited`表,最终将包含与起始节点相连的所有节点。
现在我们可以使用这种新逻辑重新运行我们的测试:
(time (dotimes (i 100)
(get-connected-hash 1 (hash-edges (make-edge-list)))))
Real time: 1.221269 sec.
Run time: 1.224076 sec.
Space: 33096264 Bytes
GC: 36, GC time: 0.10801 sec. :
如你所见,与计算图中连接需要一分钟相比,现在只需一秒钟就能完成同样的事情!这就是你必须知道如何使用哈希表的原因。
# Common Lisp 结构
*结构*是 Common Lisp 中的一种高级数据类型。结构和它们的属性可以是你代码中表示数据的有用方式。
## 与结构一起工作
结构可以用来表示具有属性的物体,就像你在使用`defstruct`命令的典型面向对象编程(OOP)语言中找到的那样,如下所示:
(defstruct person
name
age
waist-size
favorite-color)
PERSON
根据该结构中的定义,一个`person`有四个属性(Lisper 们也称之为*slots*):`name`、`age`、`waist-size`和`favorite-color`。
定义了这种结构后,我们可以使用`make-person`命令创建人的实例,这是`defstruct`为我们自动创建的一个特殊函数:
(defparameter *bob* (make-person :name "Bob"
:age 35
:waist-size 32
:favorite-color "blue"))
BOB
现在我们将`*bob*`输入到 REPL 中,我们看到我们的新人物被标记为具有`#S`前缀的结构。我们还看到该结构是`person`类型,以及它的每个属性(`name`、`age`、`waist size`和`favorite-color`)的值:
*bob*
S(PERSON :NAME "Bob" :AGE 35 :WAIST-SIZE 32 :FAVORITE-COLOR "blue")
我们可以通过调用另一个自动创建的函数`person-age`来确定鲍勃的年龄:
(person-age *bob*)
35
我们还可以使用`setf`与这些命令一起更改鲍勃的年龄。(生日快乐,鲍勃!)
(setf (person-age *bob*) 36)
36
Lisp 读取器还可以直接从人的打印表示中创建人,这是 Lisp 中打印/读取对称性的另一个绝佳例子:
(defparameter *that-guy* #S(person :name
"Bob" :age 35 :waist-size 32 :favorite-color "blue"))
> (person-age *that-guy*)
35
在这里,我们创建了一个名为`*that-guy*`的新变量,并仅使用该人的打印表示形式来设置其值 。这个变量现在包含了一个真实的`person`结构,就像我们使用了`make-person`函数  一样。
正如你所见,`defstruct`是一个非常强大的命令,可以用来构建特殊函数,这些函数可以轻松创建新对象的实例并访问其属性。
## 何时使用结构
现在,许多主流程序员认为,在开发大型和健壮的应用程序时,面向对象是必需的。另一方面,许多 Lisper 认为,即使不采取纯面向对象的方法,也可以构建高质量的软件。
从第十四章开始,我们将探讨一些这些替代方法,包括高阶函数式编程和领域特定语言编程。Lisp 语言的设计使得利用这些替代方法比其他更面向对象的语言要容易得多。
不论如何,即使你不是在编写纯面向对象风格的软件,结构和它们的属性仍然可以证明是表示代码中数据的有用方式。例如,我们可以使用标准列表和自己的`make-person`函数来完成与`defstruct`创建`person`类相同的事情。毕竟,如果我们可以用列表自己创建一个像这样的人物,为什么还要麻烦使用结构呢:
(defun make-person (name age waist-size favorite-color)
(list name age waist-size favorite-color))
MAKE-PERSON
(defun person-age (person)
(cadr person))
PERSON-AGE
(defparameter *bob* (make-person "bob" 35 32 "blue"))
BOB
*bob*
("bob" 35 32 "blue")
(person-age *bob*)
35
虽然这种方法可行,但它有几个缺点。首先,为了检查一个人的年龄或其他属性,我们需要编写许多容易出错的函数,从列表的正确位置提取属性。此外,我们临时对象的打印版本  非常难以理解。我们如何知道`BOB`是一个人?鲍勃的年龄是 35 岁还是 32 岁?常规列表并不适合编码具有多个属性的对象。
使用列表在现实世界中表示一个对象还存在另一个问题,那就是一个对象(如`person`对象)的属性可能会随时间改变。Lisp 中的列表在处理一旦创建列表就不再改变的信息时效果最佳。然而,当鲍勃 36 岁时,我们需要更改他的年龄属性。
计算机科学家将数据结构随时间部分变化称为 *变异*。在用 `defstruct` 创建的结构中更改特定属性值(变异属性)很容易,因此这些结构非常适合处理需要可变性的数据。因此,将 `person`(或任何随时间变化的对象)存储在结构中是有意义的。我们将在第十四章中更详细地讨论变异问题。
### 注意
`defstruct` 功能并不是创建 Common Lisp 中对象的唯一工具。例如,在本书的序言中,您将看到 Common Lisp 的 Common Lisp 对象系统(CLOS)允许您构建非常复杂基于对象系统。如果您想以强面向对象的方式编码,您可能会在 Common Lisp 中找到所有需要的面向对象语言功能。确实,CLOS 具有许多其他地方找不到的高级面向对象特性。正因为如此,CLOS 经常被用作研究面向对象思想的研究工具。
# 以通用方式处理数据
Common Lisp 提供了许多不同的数据类型,可用于编写优雅且高效的程序。但如果不加注意,拥有如此多的数据类型可能会导致代码丑陋且重复。
例如,假设我们想要添加存储为列表和数组的多组数字。由于列表和数组的行为不同,我们是否需要编写两个不同的加法函数——一个用于列表,另一个用于数组?如果能编写一段代码来处理这两种情况,而不关心数字的存储方式,那就太好了。
Common Lisp 拥有我们编写此类通用代码所需的所有功能,包括通用库函数、类型谓词、`defmethod` 和通用访问器。我们可以利用这些功能编写适用于多种类型数据的代码——包括内置类型以及我们可能使用 `defstruct` 创建的自定义类型——而无需在代码中重复使用多余的内容。
## 与序列一起工作
编写适用于任何类型参数的代码的最简单方法是将类型检查的工作交给别人。Common Lisp 库中包含了许多函数,可以通用地处理其参数中不同类型的数据,其中最常用的是 *序列函数*。序列函数在 Lisp 中对象序列的三个主要方式上通用:列表、数组和字符串。
您甚至没有意识到就已经看到了这些序列函数中的一个:`length` 函数。您可以使用 `length` 函数来检查所有三种序列类型的长度:
(length '(a b c))
3
(length "blub")
4
(length (make-array 5))
5
没有通用的 `length` 函数,您需要使用三个不同的函数来确定字符串、数组和列表的长度。
### 注意
Common Lisp 有一个用于检查列表长度的特定函数,称为`list-length`。由于通用函数通常需要额外的类型检查来确定正确的行为,因此它们可能执行得较慢。`list-length`函数对于性能敏感的代码很有用,但大多数 Lisper 更喜欢在常规代码中使用通用的`length`函数。
### 序列搜索函数
一些序列函数允许你在序列中进行搜索:
+ `find-if`用于找到满足谓词的第一个值。
+ `count`用于找出某个对象在序列中出现的频率。
+ `position`告诉你一个项目在哪里。
+ `some`和`every`告诉你序列中的某些或所有值是否遵循特定的谓词。
这里有一些例子:
(find-if #'numberp '(a b 5 d))
5
(count #\s "mississippi")
4
(position #\4 "2kewl4skewl")
5
(some #'numberp '(a b 5 d))
T
(every #'numberp '(a b 5 d))
NIL
在这些例子中,我们使用`find-if`来找到序列中的第一个数字,即数字 5 。我们使用`count`来找出字符`s`在`"mississippi"`中出现的次数 。我们使用`position`来找到字符 4 出现的位置。在这种情况下,它在第五个位置,从零开始计数 。我们使用`some`来查看序列中是否有任何项目是数字。确实有一个数字 。最后,我们使用`every`来查看列表中的每个项目是否都是数字,这显然不是情况 。
### 遍历序列的序列函数
一个特别有用的通用序列函数是`reduce`。`reduce`函数允许你遍历一个序列并将其简化为单个结果。在这里,我们使用`reduce`将列表中的项目相加:
(reduce #'+ '(3 4 6 5 2))
20
这些数字的总和是 20。下面是一个图表,展示了这个例子中发生的具体情况:

在右侧,用灰色表示的是我们的列表。在左侧,你可以看到输入到加法函数(`+`)中的数字对以及计算出的中间结果。这表明加法函数始终接收一个中间结果以及列表中的下一个数字作为其参数。唯一的例外是在对加法函数的第一次调用中。由于开始时没有中间结果,当我们第一次调用加法函数时,我们将列表开头的数字 3 提升到我们的中间结果列中。因此,第一次调用加法函数时,它实际上直接接收了列表顶部的*两个项目*。
让我们来看一个稍微复杂一点的例子,这次我们使用自己的归约函数。我们要找到列表中的最大偶数:
(reduce (lambda (best item)
(if (and (evenp item) (> item best))
item
best))
'(7 4 6 5 2)
:initial-value 0)
6
我们传递给`reduce`以从列表中提炼出答案的归约函数有两个参数 。第一个参数是我们迄今为止找到的最佳值——换句话说,是我们迄今为止找到的最大偶数。第二个参数是列表中的下一个数字。
我们的`reduce`函数需要返回新的最佳数字作为结果。因此,如果最新的数字比之前的最佳数字更好 ,我们就返回它 。否则,我们返回之前的最佳数字 。
记住,我们正在`reduce`的列表中的第一个数字将被用作起始值。如果这成问题,我们可以通过传递一个名为`:initial-value`的关键字参数来显式地传递一个初始值给`reduce`函数 。
为`reduce`函数指定一个初始值通常是必要的,否则可能会导致代码中出现 bug。在我们的例子中,它可能会错误地将列表前面的奇数视为最佳的大偶数。让我们看看如果我们省略初始值会发生什么。
(reduce (lambda (best item)
(if (and (evenp item) (> item best))
item
best))
'(7 4 6 5 2))
7
是的,由于没有指定初始的`reduce`值,事情会变得非常糟糕。
`reduce`函数的另一个优点是它是通用的,正如所有这些序列函数一样。这意味着它可以以完全相同的方式`reduce`列表、数组或字符串,你可以使用`reduce`来编写对不同的序列类型之间的差异视而不见的函数。
之前我提到,能够编写一个可以同样好地求和列表或数组中的数字的单个函数将非常方便。现在我们可以编写这样的函数:
(defun sum (lst)
(reduce #'+ lst))
SUM
(sum '(1 2 3))
6
(sum (make-array 5 :initial-contents '(1 2 3 4 5)))
15
(sum "blablabla")
Error: The value #\b is not of type NUMBER.

`sum`对数组和列表之间的区别毫无察觉;它对两者都有效。然而,由于加法对字符没有意义,当在字符串上使用`sum`函数时,它会返回一个错误。
另一个在序列上迭代的函数是`map`函数。这个函数的行为与`mapcar`相同。然而,与`mapcar`不同,`map`函数适用于所有序列类型,而不仅仅是列表。你通过传递一个额外的参数到`map`函数来指定从映射中返回的序列类型。
这里是`map`的一个例子:
(map 'list
(lambda (x)
(if (eq x #\s)
#\S
x))
"this is a string")
(#\t #\h #\i #\S #\ #\i #\S #\ #\a #\ #\S #\t #\r #\i #\n #\g)
在这个例子中,我们将字符串中的每个`s`字符转换为其大写版本。我们传递给`map`的映射函数简单地检查当前字符是否为`s`,如果是,则返回大写字母`S` 。
此计算的结果是一个字符列表 ![http://atomoreilly.com/source/nostarch/images/783560.png]。这是因为我们告诉 `map` 函数我们想要一个列表作为结果 ![http://atomoreilly.com/source/nostarch/images/783564.png]。如果我们要求一个 `string`,那么结果将是一个字符串。
### 两个重要的序列函数
`subseq` 函数允许您通过指定起始和结束点从较大的序列中提取子序列:
(subseq "america" 2 6)
"eric"
如您所见,单词 `america` 从第二个字符开始,到第六个字符结束,包含了名字 `eric`。
`sort` 函数允许您传递一个任意函数用于排序。在这种情况下,我们只是使用了小于 (`<`) 函数:
(sort '(5 8 2 4 9 3 6) #'<)
(2 3 4 5 6 8 9)
我们讨论的序列函数比之前还要多,但本章的示例将帮助您迈出良好的一步。
### 注意
要获取序列函数的完整列表,以及所有 Common Lisp 函数,请访问 *Common Lisp Hyperspec* [`www.snipurl.com/rz3h0`](http://www.snipurl.com/rz3h0)——这是对所有 Common Lisp 提供内容的详尽但令人畏惧的描述。
## 使用类型谓词创建自己的通用函数
Common Lisp,就像几乎所有其他 Lisp 一样,是一种动态类型语言。这意味着您的代码中的参数或变量可以持有任何类型的数据——符号、字符串、数字、函数或您想要放入其中的任何其他内容。实际上,相同的参数或变量甚至可以在运行程序的不同时间持有不同类型的数据。
因此,有一系列函数来告诉您变量中是否包含某种类型的数据是有意义的。例如,您可以使用 `numberp` 来检查是否有一个数字:
(numberp 5)
T
您可能会最频繁使用的类型谓词是 `arrayp`、`characterp`、`consp`、`functionp`、`hash-table-p`、`listp`、`stringp` 和 `symbolp`。
您可以使用类型谓词来编写处理不同类型数据的通用函数。假设我们想要编写一个函数,允许我们添加数字或列表。这里是我们可能编写此类函数的一种方式:
(defun add (a b)
(cond ((and (numberp a) (numberp b)) (+ a b))
((and (listp a) (listp b)) (append a b))))
ADD
(add 3 4)
7
(add '(a b) '(c d))
(A B C D)
在这个 `add` 函数中,我们使用谓词来检查传入的参数是否是数字或列表,然后相应地操作。如果没有给出两个数字或两个列表,它将简单地返回 `nil`。
虽然您可以使用类型谓词编写支持多种类型数据的函数,但大多数 Lisp 程序员不会这样编写 `add` 函数,以下是一些原因:
**适用于所有类型的单一、单一函数:**
这对于两种类型来说是可以的,但如果我们要处理十种或更多类型,我们的函数会迅速变成一个巨大的怪物。
**为适应新情况所需的修改:**
每当我们想要支持新的类型时,都需要更改 `add` 函数,这增加了破坏现有代码的可能性。理想情况下,我们希望独立处理每个新情况,而不触及已经工作的代码。
**难以理解:**
很难确切地看到主 `cond` 语句在做什么,以及类型是否都被正确路由到适当的位置。
**性能:**
生成的函数可能运行较慢。例如,如果 Lisp 解释器/编译器确定在追加时两个项目都是列表,它可能能够为追加两个列表创建更快的代码。然而,在我们的 `add` 函数的第一个尝试中,两个参数的类型从未真正完全明显。我们的编译器需要一些智慧才能从条件 `(and (listp a) (listp b))` 中判断出两个变量都保证是列表。如果我们明确地声明每种类型情况的参数类型,编译器的工作会更容易。
因为能够拥有一个在给定特定数据类型时执行不同操作的单一函数非常有用,所以 Common Lisp 命令 `defmethod` 允许我们定义多个版本的函数,每个版本都支持不同的类型。当该函数被调用时,Lisp 会检查调用时的参数类型,并自动选择正确的函数版本。根据参数类型在编译器/解释器中选择函数不同版本的正确术语是 *类型分派*。
下面是我们如何使用 `defmethod` 编写我们的 `add` 函数:
(defmethod add ((a number) (b number))
(+ a b))
ADD
(defmethod add ((a list) (b list))
(append a b))
ADD
(add 3 4)
7
(add '(a b) '(c d))
(A B C D)
如您所见,这个版本的 `add` 函数使用一个单独的函数处理每种类型的情况,并且可以添加新案例而无需修改现有代码。总体而言,代码更容易理解。此外,编译器可以看到参数的类型,并且可能能够利用这些知识编写更快的代码。
`defmethod` 函数类似于 `defun`,但它允许我们编写具有相同名称的多个函数。当使用 `defmethod` 时,我们可以在函数的参数列表中明确声明每个参数的类型,这样 Lisp 就可以使用这些类型声明来确定每种情况下正确的 `add` 版本。
如果你熟悉面向对象的世界,那么“方法”这个词可能对你有特殊的意义。由于这个新命令叫做 `defmethod`,它是否与面向对象有关?简而言之,是的。这个命令不仅可以与 Common Lisp 的内置类型一起使用,还可以与使用 `defstruct` 创建的结构一起使用。`defstruct` 和 `defmethod` 的组合基本上构成了一个简单的对象系统。
现在,我们将使用这个对象系统来编写一个游戏!
# 《兽人战斗游戏》
在《兽人战斗游戏》中,你是一名被 12 个怪物包围的骑士,正在进行一场生死之战。凭借你卓越的智慧和你的剑术技巧,你必须在与兽人、九头蛇和其他讨厌的敌人战斗中谨慎制定战略。一旦失误,你可能会在数量上处于劣势,无法在耗尽体力之前杀死他们。使用 `defmethod` 和 `defstruct`,让我们对这些害虫发起猛烈的攻击!

## 玩家和怪物的全局变量
我们将想要跟踪三个玩家统计数据:健康、敏捷和力量。当玩家的健康值达到零时,该玩家将死亡。敏捷将控制玩家在战斗单回合中可以执行多少次攻击,而力量将控制攻击的猛烈程度。随着游戏的进行,这些值都会发生变化,并以微妙的方式影响游戏玩法和策略。
(defparameter player-health nil)
(defparameter player-agility nil)
(defparameter player-strength nil)
我们将把我们的怪物存储在一个名为 `*monsters*` 的数组中。这个数组将是异构的,意味着它可以包含不同类型的怪物,无论是兽人、九头蛇还是其他任何东西。我们将使用 `defstruct` 创建我们的怪物类型。当然,我们仍然需要弄清楚如何以有意义的方式处理列表中的每种类型——这就是我们将使用 Lisp 的泛型功能的地方。
我们还将定义一个用于构建怪物的函数列表,我们将将其存储在变量 `*monster-builders*` 中。随着我们为每种类型的怪物编写代码,我们将创建一个构建每种类型怪物的函数。然后我们将每个怪物构建器推送到这个列表中。在这个列表中拥有所有构建器函数将使我们能够随意为我们的游戏创建随机怪物。
最后,我们将创建一个名为 `*monster-num*` 的变量来控制我们的骑士必须与多少个对手战斗。更改此变量可以增加(或减少)奥克之战的难度级别。
(defparameter monsters nil)
(defparameter monster-builders nil)
(defparameter monster-num 12)
## 主游戏函数
现在我们准备编写游戏的第一段实际代码,从驱动整个系统的整体函数开始。
首先,我们将定义一个名为 `orc-battle` 的函数。这个函数将初始化怪物并开始游戏循环,一旦战斗结束,它将确定胜利者并打印出适当的游戏结束消息。正如你所见,`orc-battle` 调用了许多辅助函数来完成实际工作:
(defun orc-battle ()
(init-monsters)
(init-player)
(game-loop)
(when (player-dead)
(princ "You have been killed. Game Over."))
(when (monsters-dead)
(princ "Congratulations! You have vanquished all of your foes.")))
在顶部,我们调用怪物和玩家的初始化函数 。然后我们开始主游戏循环 。游戏循环将一直运行,直到玩家或怪物死亡。根据玩家  或怪物  是否死亡,我们将打印出游戏结束的消息。
接下来,我们将创建 `game-loop` 函数来处理游戏循环。这个函数处理一回合的战斗,然后递归地调用自身进行下一回合:
(defun game-loop ()
(unless (or (player-dead) (monsters-dead))
(show-player)
(dotimes (k (1+ (truncate (/ (max 0 player-agility) 15))))
(unless (monsters-dead)
(show-monsters)
(player-attack)))
(fresh-line)
(map 'list
(lambda(m)
(or (monster-dead m) (monster-attack m)))
monsters)
(game-loop)))
`game-loop` 函数处理怪物和玩家攻击的重复循环。只要战斗中的双方都还活着,该函数将首先在 REPL 中显示一些关于玩家的信息 。
接下来,我们允许玩家攻击怪物。`game-loop`函数使用玩家的敏捷性来调节在战斗的单轮中可以发起多少次攻击,使用一些调整因素将敏捷性转换为一个小的、合适的数字 。当游戏开始时,玩家每轮将有三次攻击。战斗的后期阶段可能会使这个数字减少到每轮一次攻击。
玩家攻击循环中计算出的敏捷因素  被传递到`dotimes`命令中,该命令接受一个变量名和一个数字*n*,并运行代码块*n*次:
(dotimes (i 3)
(fresh-line)
(princ i)
(princ ". Hatchoo!"))
0. Hatchoo!
1. Hatchoo!
2. Hatchoo!
`dotimes`函数是 Common Lisp 的循环命令之一(循环将在第十章中更详细地介绍 Chapter 10)。
玩家攻击之后,我们允许怪物进行攻击。我们通过使用`map`函数遍历我们的怪物列表来实现这一点 。每种怪物都有一个特殊的`monster-attack`命令,只要怪物还活着,我们就会调用这个命令 。
最后,`game-loop`函数递归地调用自己,这样战斗就可以继续进行,直到一方或另一方被击败 。
## 玩家管理函数
我们需要的用于管理玩家属性(健康、敏捷和力量)的函数非常简单。以下是我们需要的初始化玩家、检查他们是否死亡以及输出他们属性的函数:
(defun init-player ()
(setf player-health 30)
(setf player-agility 30)
(setf player-strength 30))
(defun player-dead ()
(<= player-health 0))
(defun show-player ()
(fresh-line)
(princ "You are a valiant knight with a health of ")
(princ player-health)
(princ ", an agility of ")
(princ player-agility)
(princ ", and a strength of ")
(princ player-strength))
`player-attack`函数让我们管理玩家的攻击:
(defun player-attack ()
(fresh-line)
(princ "Attack style: [s]tab [d]ouble swing [r]oundhouse:")
(case (read)
(s (monster-hit (pick-monster)
(+ 2 (randval (ash player-strength −1)))))
(d (let ((x (randval (truncate (/ player-strength 6)))))
(princ "Your double swing has a strength of ")
(princ x)
(fresh-line)
(monster-hit (pick-monster) x)
(unless (monsters-dead)
(monster-hit (pick-monster) x))))
(otherwise (dotimes (x (1+ (randval (truncate (/ player-strength 3)))))
(unless (monsters-dead)
(monster-hit (random-monster) 1))))))
首先,这个函数会打印出一些不同的攻击类型,玩家可以选择 。正如你所见,玩家有三个可能的攻击选择:刺击、双击和旋风斩。我们读取玩家的选择,然后在`case`语句中处理每种类型的攻击 。
刺击攻击是最凶猛的攻击,可以针对单个敌人进行。由于刺击是对单个敌人进行的,我们将首先调用`pick-monster`函数让玩家选择攻击目标 。攻击强度是从`*player-strength*`计算出来的,使用随机因素和一些其他的小调整来生成一个既好又不过于强大的攻击强度 。一旦玩家选择了一个怪物进行攻击并且攻击强度已经计算出来,我们就调用`monster-hit`函数来实施攻击 。
与刺击攻击不同,双击攻击较弱,但可以同时攻击两个敌人。攻击的另一个好处是,随着挥击的开始,骑士可以知道它将有多强——然后可以使用这个信息在中挥击时选择最佳攻击目标。双击攻击的这个额外功能为游戏增加了战略深度。否则,双击代码与刺击代码类似,打印一条消息并允许玩家选择攻击目标。在这种情况下,可以选择两个怪物。
最后的攻击,即旋风式挥击,是一种狂野、混乱的攻击,对敌人没有选择性。我们根据玩家的力量运行一个基于`dotimes`循环,然后多次攻击随机敌人。然而,每次攻击都非常弱,力量仅为 1。
这些攻击必须正确使用,在战斗的正确阶段使用,才能取得胜利。为了在`player-attack`函数中的攻击中增加一些随机性,我们使用了`randval`辅助函数来生成随机数。它定义如下:
(defun randval (n)
(1+ (random (max 1 n))))
`randval`函数返回一个从 1 到*n*的随机数,同时确保无论*n*多小,至少返回数字 1。使用`randval`而不是仅使用`random`函数生成随机数,为游戏中的随机性提供了现实检查,因为 0 对于我们在计算中使用的一些值来说没有意义。例如,即使是力量最弱的玩家或怪物,攻击力量也应至少为 1。
`randval`中使用的`random`函数是 Lisp 中的标准随机值函数。它可以以几种不同的方式使用,尽管最常见的是通过传递一个整数*n*并接收一个从 0 到*n*−1 的随机整数:
(dotimes (i 10)
(princ (random 5))
(princ " "))
1 2 2 4 0 4 2 4 2 3
## 玩家攻击的辅助函数
我们的`player-attack`函数需要两个辅助函数来完成其工作。首先,它需要一个`random-monster`函数来选择一个怪物作为混乱旋风攻击的目标,同时确保所选怪物尚未死亡:
(defun random-monster ()
(let ((m (aref monsters (random (length monsters)))))
(if (monster-dead m)
(random-monster)
m)))
`random-monster`函数首先从怪物数组中随机选择一个怪物,并将其存储在变量`m`中。由于我们希望选择一个活着的怪物进行攻击,如果我们意外选择了一个已死的怪物,我们会递归地再次尝试该函数。否则,我们返回所选怪物。
`player-attack`函数还需要一个允许玩家选择攻击目标的非随机攻击函数。这是`pick-monster`函数的工作:
(defun pick-monster ()
(fresh-line)
(princ "Monster #😊
(let ((x (read)))
(if (not (and (integerp x) (>= x 1) (<= x monster-num)))
(progn (princ "That is not a valid monster number.")
(pick-monster))
(let ((m (aref monsters (1- x))))
(if (monster-dead m)
(progn (princ "That monster is alread dead.")
(pick-monster))
m)))))
为了让玩家选择一个怪物进行攻击,我们首先需要显示一个提示  并读取玩家的选择 。然后我们需要确保玩家选择了一个不太大也不太小的整数 。如果发生了这种情况,我们将打印一条消息并再次调用 `pick-monster` 让玩家重新选择。否则,我们可以安全地将选定的怪物放置在变量 `m` 中 。
玩家可能犯的另一个错误是攻击一个已经死亡的怪物。我们接下来检查这种情况,并再次允许玩家进行另一轮选择 。否则,玩家已经成功做出了选择,我们将返回所选的怪物作为结果 。
现在我们来处理我们的怪物。
## 怪物管理函数
我们将使用 `init-monsters` 函数来初始化存储在 `*monsters*` 数组中的所有敌人。这个函数将随机从 `*monster-builders*` 列表中选择函数,并使用 `funcall` 调用它们来构建怪物:
(defun init-monsters ()
(setf monsters
(map 'vector
(lambda (x)
(funcall (nth (random (length monster-builders))
monster-builders)))
(make-array monster-num))))
首先,`init-monsters` 函数构建一个空数组来存储怪物 。然后它在这个数组上 `map` 以填充它 。在 `lambda` 函数中,你可以看到如何通过在我们的怪物构建者列表中 `funcall` 随机函数来创建随机怪物 。
接下来,我们需要一些简单的函数来检查怪物是否死亡。注意我们如何在 `*monsters*` 数组上使用 every 命令来查看 `monster-dead` 函数对于每个怪物是否为真。这将告诉我们整个怪物种群是否已经死亡。
(defun monster-dead (m)
(<= (monster-health m) 0))
(defun monsters-dead ()
(every #'monster-dead monsters))
我们将使用 `show-monsters` 函数来显示所有怪物的列表。这个函数将转而将部分工作委托给另一个函数,因此它实际上不需要了解太多关于不同怪物类型的信息:
(defun show-monsters ()
(fresh-line)
(princ "Your foes:")
(let ((x 0))
(map 'list
(lambda (m)
(fresh-line)
(princ " ")
(princ (incf x))
(princ ". ")
(if (monster-dead m)
(princ "dead")
(progn (princ "(Health=")
(princ (monster-health m))
(princ ") ")
(monster-show m))))
monsters)))
由于我们的玩家需要用数字选择怪物,因此我们将在遍历列表中的怪物时维护一个计数,变量为 `x` 。然后我们通过怪物列表 `map`,对每个怪物调用一个 `lambda` 函数,这将为每个怪物打印一些漂亮的文本 。我们使用 `x` 变量来打印出我们编号列表中每个怪物的编号 。在这个过程中,我们使用 `incf` 函数,它将在我们遍历列表时增加 `x` 的值。
对于死亡的怪物,我们不会打印太多关于它们的信息,只显示一个表示它们已经死亡的消息 ![http://atomoreilly.com/source/nostarch/images/783554.png]。对于活着的怪物,我们调用通用怪物函数,以针对每种不同类型的敌人以特殊方式计算健康值 ![http://atomoreilly.com/source/nostarch/images/783510.png] 和生成怪物描述 ![http://atomoreilly.com/source/nostarch/images/783544.png]。
## 怪物们
到目前为止,我们还没有看到任何真正赋予怪物生命的函数。让我们来解决这个问题。
首先,我们将描述一个通用怪物。
### 通用怪物
如你所预期,奥克、九头蛇和其他坏蛋都有一个共同点:一个健康计,它决定了它们在被击败之前可以承受多少次打击。我们可以将这种行为捕捉在 `monster` 结构体中:
(defstruct monster (health (randval 10)))
这种使用 `defstruct` 函数的方法利用了一个特殊功能:当我们声明结构体中的每个槽位(在这种情况下,`health`)时,我们可以在名称周围加上括号,并为该槽位添加一个默认值。但更重要的是,我们可以声明一个在创建新的 `monster` 时将被评估的表单。由于这个表单调用 `randval`,每个怪物将以不同的、随机的健康值开始战斗。
让我们尝试创建一些怪物:
(make-monster)
S(MONSTER :HEALTH 7)
(make-monster)
S(MONSTER :HEALTH 2)
(make-monster)
S(MONSTER :HEALTH 5)
我们还需要一个函数,当怪物被攻击时,它会减少怪物的健康值。我们将让这个函数输出一个消息,解释发生了什么,包括怪物死亡时显示的消息。然而,我们不会使用 `defun` 来创建这个函数,而是使用通用的 `defmethod`,这样我们就可以在骑士击败特定怪物时显示特殊消息:
(defmethod monster-hit (m x)
(decf (monster-health m) x)
(if (monster-dead m)
(progn (princ "You killed the ")
(princ (type-of m))
(princ "! "))
(progn (princ "You hit the ")
(princ (type-of m))
(princ ", knocking off ")
(princ x)
(princ " health points! "))))
`decf` 函数 ![http://atomoreilly.com/source/nostarch/images/783564.png] 是 `setf` 的一个变体,它允许我们从变量中减去一个量。`type-of` 函数让 `monster-hit` 假装它知道被击中的怪物的类型 ![http://atomoreilly.com/source/nostarch/images/783562.png] ![http://atomoreilly.com/source/nostarch/images/783560.png]。这个函数可以用来找出任何 Lisp 值的类型:
(type-of 'foo)
SYMBOL
(type-of 5)
INTEGER
(type-of "foo")
ARRAY
(type-of (make-monster))
MONSTER
目前,怪物的类型将始终是 `monster`,但很快我们将为每种怪物类型改变这个值。
我们还可以使用两种更通用的方法来创建怪物:`monster-show` 和 `monster-attack`。
`monster-attack` 函数实际上并没有做任何事情。这是因为我们所有的怪物攻击都将非常独特,因此定义一个通用攻击是没有意义的。这个函数只是一个占位符。
(defmethod monster-show (m)
(princ "A fierce ")
(princ (type-of m)))
(defmethod monster-attack (m))
现在我们已经有了一些通用怪物代码,我们终于可以创建一些真正的坏蛋了!
### 恶魔兽
奥克是一个简单的敌人。他可以用他的战锤发动强攻,但除此之外,他几乎无害。每个奥克都有一把具有独特攻击水平的战锤。奥克最好被忽略,除非你想要在战斗开始时从群体中淘汰那些具有异常强大战锤攻击的奥克。
要创建兽人,我们使用`defstruct`定义一个`orc`数据类型。在这里,我们将使用`defstruct`的另一个高级特性来声明`orc`包含`monster`的所有字段。
通过在我们的`orc`类型中包含`monster`类型的字段,`orc`将能够继承适用于所有怪物的字段,例如`health`字段。这类似于在 C++或 Java 等流行语言中通过定义一个泛型类然后创建其他更专门的类继承这个泛型类所能实现的效果。
一旦声明了结构,我们就将自动由`defstruct`生成的`make-orc`函数推送到我们的`*monster-builders*`列表中:
(defstruct (orc (:include monster)) (club-level (randval 8)))
(push #'make-orc monster-builders)
### 注意
注意这种方法是多么强大。我们可以创建任意数量的新怪物类型,而永远不会需要更改我们的基本兽人战斗代码。这只有在像 Lisp 这样的动态类型语言中才可能,这些语言支持函数作为一等值。在静态类型编程语言中,主要的兽人战斗代码需要一种硬编码的方式来调用每种新类型怪物的构造函数。使用一等函数,我们不需要担心这个问题。

现在,让我们为兽人专门化我们的`monster-show`和`monster-attack`函数。注意,这些函数的定义方式与之前版本相同,只是在参数列表中明确声明这些函数是针对兽人的:
(defmethod monster-show ((m orc))
(princ "A wicked orc with a level ")
(princ (orc-club-level m))
(princ " club"))
(defmethod monster-attack ((m orc))
(let ((x (randval (orc-club-level m))))
(princ "An orc swings his club at you and knocks off ")
(princ x)
(princ " of your health points. ")
(decf player-health x)))
我们`orc`类型的独特之处在于每个兽人都有一个`orc-club-level`字段。这些针对兽人的`monster-show`和`monster-attack`版本会考虑这个字段。在`monster-show`函数中,我们显示这个俱乐部等级 ,以便玩家可以评估每个兽人带来的危险。
在`monster-attack`函数中,我们使用俱乐部等级来决定玩家被俱乐部击中的严重程度 。
### 恶意九头蛇

九头蛇是一个非常讨厌的敌人。它会用它的许多头攻击你,你需要砍掉这些头才能击败它。九头蛇的特殊能力是它可以在每一轮战斗中长出一个新的头,这意味着你希望尽早击败它。
(defstruct (hydra (:include monster)))
(push #'make-hydra monster-builders)
(defmethod monster-show ((m hydra))
(princ "A malicious hydra with ")
(princ (monster-health m))
(princ " heads."))
(defmethod monster-hit ((m hydra) x)
(decf (monster-health m) x)
(if (monster-dead m)
(princ "The corpse of the fully decapitated and decapacitated
hydra falls to the floor!")
(progn (princ "You lop off ")
(princ x)
(princ " of the hydra's heads! "))))
(defmethod monster-attack ((m hydra))
(let ((x (randval (ash (monster-health m) −1))))
(princ "A hydra attacks you with ")
(princ x)
(princ " of its heads! It also grows back one more head! ")
(incf (monster-health m))
(decf player-health x)))
处理九头蛇的代码与处理兽人的代码类似。主要区别在于九头蛇的健康值也充当了九头蛇头数的替代品。换句话说,有三个健康点的九头蛇也会有三个头。因此,当我们编写针对九头蛇的特定`monster-show`函数时,我们使用怪物的健康值来打印关于九头蛇头数的漂亮信息 。
与兽人相比,九头蛇的另一个不同之处在于,当玩家攻击兽人时,兽人并没有什么特别有趣的行为。正因为如此,我们不需要为兽人编写一个自定义的`monster-hit`函数;兽人只是简单地使用了我们为通用`monster`创建的通用`monster-hit`函数。
另一方面,当九头蛇被击中时,它会做出有趣的事情:它会失去头!因此,我们创建了一个特定的九头蛇`monster-hit`函数,每次打击都会移除头部,这相当于降低了九头蛇的生命值!。此外,我们现在可以打印一条关于骑士如何砍掉这些头部的戏剧性信息!
九头蛇的`monster-attack`函数与兽人的类似。一个有趣的不同之处在于,我们每次攻击都会增加生命值,这样九头蛇就能在每个回合长出新的头!
### 腐朽的粘液菌
粘液菌是一种独特的怪物。当它攻击你时,它会缠绕在你的腿上并使你失去行动能力,让其他坏蛋完成你的任务。它还可以向你脸上喷射粘液。在战斗中你必须迅速思考,决定是早点结束粘液菌以保持你的敏捷性,还是忽略它先专注于更凶猛的敌人。(记住,通过降低你的敏捷性,粘液菌会减少你在战斗后期回合中可以发动的攻击次数。)

(defstruct (slime-mold (:include monster)) (sliminess (randval 5)))
(push #'make-slime-mold monster-builders)
(defmethod monster-show ((m slime-mold))
(princ "A slime mold with a sliminess of ")
(princ (slime-mold-sliminess m)))
(defmethod monster-attack ((m slime-mold))
(let ((x (randval (slime-mold-sliminess m))))
(princ "A slime mold wraps around your legs and decreases your agility by ")
(princ x)
(princ "! ")
(decf player-agility x)
(when (zerop (random 2))
(princ "It also squirts in your face, taking away a health point! ")
(decf player-health))))
粘液菌的`monster-attack`函数必须做一些特殊的事情,这允许它使玩家失去行动能力。首先,它使用粘液菌的粘性(在构建每个粘液菌时生成)来生成一个针对玩家的随机攻击,存储在变量`x`中!。与游戏中的大多数其他攻击不同,这种粘液菌攻击影响玩家的敏捷性,而不是他们的健康!
然而,如果粘液菌不能至少对玩家的健康造成一点伤害,或者战斗可能会尴尬地结束,玩家和粘液菌将永远冻结在原地。因此,粘液菌还有一个超级微弱的喷射攻击,在所有攻击的一半中发生!,但只会从玩家那里减去一个生命值!
### 狡猾的亡命徒
亡命徒是你所有敌人中最聪明的。他可以使用他的鞭子或弹弓,并试图削弱你的最佳资产。他的攻击并不强大,但每轮都会持续造成两个点的伤害。
(defstruct (brigand (:include monster)))
(push #'make-brigand monster-builders)
(defmethod monster-attack ((m brigand))
(let ((x (max player-health player-agility player-strength)))
(cond ((= x player-health)
(princ "A brigand hits you with his slingshot,
taking off 2 health points! ")
(decf player-health 2))
((= x player-agility)
(princ "A brigand catches your leg with his whip,
taking off 2 agility points! ")
(decf player-agility 2))
((= x player-strength)
(princ "A brigand cuts your arm with his whip,
taking off 2 strength points! ")
(decf player-strength 2)))))

狡猾的强盗在发动攻击时,首先会查看玩家的健康、敏捷和力量,并选择这三个中的最大值作为攻击的重点!。如果几个属性的大小相等,强盗会选择健康作为攻击重点,其次是敏捷,然后是力量。如果健康是最大值,玩家会被弓箭射中!。如果敏捷是最大值,强盗会鞭打玩家的腿!。如果力量是最大值,强盗会鞭打玩家的手臂!。
我们现在已经完全定义了我们游戏中的所有怪物!
## 战斗吧!
要开始游戏,请在 REPL 中调用 `orc-battle`:
(orc-battle)
You are a valiant knight with a health of 30, an agility of 30, and a strength of 30
Your foes:
1. (Health=10) A wicked orc with a level 5 club
2. (Health=3) A malicious hydra with 3 heads.
3. (Health=9) A fierce BRIGAND
4. (Health=3) A malicious hydra with 3 heads.
5. (Health=3) A wicked orc with a level 2 club
6. (Health=7) A malicious hydra with 7 heads.
7. (Health=6) A slime mold with a sliminess of 2
8. (Health=5) A wicked orc with a level 2 club
9. (Health=9) A fierce BRIGAND
10. (Health=2) A wicked orc with a level 6 club
11. (Health=7) A wicked orc with a level 4 club
12. (Health=8) A slime mold with a sliminess of 2
那个有七个头的九头蛇看起来相当难看——让我们先给它来一刺:
Attack style: [s]tab [d]ouble swing [r]oundhouse:s
Monster #:6
The corpse of the fully decapitated and decapacitated hydra falls to the floor!
Your foes:
1. (Health=10) A wicked orc with a level 5 club
2. (Health=3) A malicious hydra with 3 heads.
3. (Health=9) A fierce BRIGAND
4. (Health=3) A malicious hydra with 3 heads.
5. (Health=3) A wicked orc with a level 2 club
6. dead
7. (Health=6) A slime mold with a sliminess of 2
8. (Health=5) A wicked orc with a level 2 club
9. (Health=9) A fierce BRIGAND
10. (Health=2) A wicked orc with a level 6 club
11. (Health=7) A wicked orc with a level 4 club
12. (Health=8) A slime mold with a sliminess of 2
没有其他坏蛋能真正脱颖而出,所以我们将尝试一个旋风踢来降低一些整体的生命值:
Attack style: [s]tab [d]ouble swing [r]oundhouse:r
You hit the SLIME-MOLD,
knocking off 1 health points! You hit the SLIME-MOLD, knocking off 1 health points!
You hit the ORC, knocking off 1 health points! You lop off 1 of the hydra's heads!
You lop off 1 of the hydra's heads! You lop off 1 of the hydra's heads! You hit the
ORC, knocking off 1 health points! The corpse of the fully decapitated and decapaci
tated hydra falls to the floor! You hit the ORC, knocking off 1 health points! You hit
the ORC, knocking off 1 health points! You hit the ORC, knocking off 1 health points!
Your foes:
1. (Health=9) A wicked orc with a level 5 club
2. (Health=2) A malicious hydra with 2 heads.
3. (Health=9) A fierce BRIGAND
4. dead
5. (Health=2) A wicked orc with a level 2 club
6. dead
7. (Health=4) A slime mold with a sliminess of 2
8. (Health=3) A wicked orc with a level 2 club
9. (Health=9) A fierce BRIGAND
10. (Health=2) A wicked orc with a level 6 club
11. (Health=6) A wicked orc with a level 4 club
12. (Health=8) A slime mold with a sliminess of 2
太棒了!甚至杀死了其中一个较弱的敌人。现在,我们有了满的敏捷性,每轮可以发动三次攻击。这意味着我们应该用最后的攻击来战略性地消灭一些更强大的坏蛋。让我们使用双击:
Attack style: [s]tab [d]ouble swing [r]oundhouse:d
Your double swing has a strength of 3
Monster #:8
You killed the ORC!
Monster #:10
You killed the ORC!
An orc swings his club at you and knocks off 5 of your health points. A hydra
attacks you with 1 of its heads! It also grows back one more head! A
brigand catches your leg with his whip, taking off 2 agility points! An orc
swings his club at you and knocks off 1 of your health points. A slime mold wraps
around your legs and decreases your agility by 2! It also squirts in your face,
taking away a health point! A brigand cuts your arm with his whip, taking off 2
strength points! An orc swings his club at you and knocks off 1 of your health
points. A slime mold wraps around your legs and decreases your agility by 1!
You are a valiant knight with a health of 21, an agility of 25, and a strength of 28
Your foes:
1. (Health=9) A wicked orc with a level 5 club
2. (Health=3) A malicious hydra with 3 heads.
3. (Health=9) A fierce BRIGAND
4. dead
5. (Health=2) A wicked orc with a level 2 club
6. dead
7. (Health=4) A slime mold with a sliminess of 2
8. dead
9. (Health=9) A fierce BRIGAND
10. dead
11. (Health=6) A wicked orc with a level 4 club
12. (Health=8) A slime mold with a sliminess of 2
他们已经让我们打得很好了,但我们还有很多战斗力。这场战斗还没有结束!
如您所见,如果您想在兽人战斗中生存下来,就需要谨慎的策略。希望您喜欢这款新游戏!
# 你学到了什么
在本章中,我们讨论了 Common Lisp 中更高级的数据结构。然后我们用这些来创建一个怪物战斗游戏。在这个过程中,你学到了以下内容:
+ 数组与列表类似,但允许您更高效地访问特定偏移量的项。
+ 哈希表与 alists 类似,但允许您更高效地查找与键关联的值。
+ 在适当的位置使用数组和哈希表通常会使您的代码运行得更快。
+ 唯一真正的方法是使用 `time` 命令来计时,以判断更改数据结构或算法是否使您的程序更快。
+ Common Lisp 有泛型函数,可以用于多种数据类型。其中最有用的是可以透明处理列表、数组和字符串的序列函数。
+ 您可以使用 `defstruct` 命令在列表中创建具有属性的对象。
# 第三部分。Lisp 是黑客

# loop 和 format:Lisp 的阴暗面
之前,我们探讨了 Common Lisp 语言的精髓,并对其简洁和优雅表示钦佩。然而,围绕这个核心,Lisp 还有一些不那么光鲜、有些阴暗的部分,它们本身也具有一定的魅力。它们可能缺乏 Lisp 核心的美感,但它们以强大的功能来弥补这一点。对于任何初学的 Lisp 程序员来说,这些语言的部分真是令人愉悦。
我们在本节中将要介绍的扩展,`loop` 和 `format`,非常强调功能强大而非数学上的优雅。这导致 Lisp 程序员之间偶尔出现争议,有些人质疑这些命令提供的强大功能是否值得在优雅性上的妥协。这些程序员认为,在编写任何严肃的代码时都应该避免使用 `loop` 和 `format`。
但学习和使用这些命令有一个很好的理由:它们体现了 Lisp 的灵活性和可扩展性。由于 Lisp(有争议地)是可用的最灵活的编程语言,黑客们已经通过数十年的数千个自己的黑客技巧来扩展它。`loop` 和 `format` 作为这些扩展中最成功的一些,必须非常出色才能在达尔文式的战场上生存下来。

# 第十章。使用 loop 命令进行循环
`loop` 和 `format` 命令功能强大且对黑客友好。尽管它们提供的许多功能在 Lisp 语言的其他地方也有提供,但这些高度专业化的命令如果喜欢简洁的代码,学习它们是值得的。我们将在本章中探讨 `loop`。下一章将介绍 `format`。
# 循环宏
在计算机程序内部进行的任何类型的循环都可以通过 `loop` 宏来完成。以下是一个简单的示例:
(loop for i
below 5
sum i)
10
这段代码将 5 以下的自然数相加,如下所示:
| 0 + 1 + 2 + 3 + 4 = 10 |
| --- |
你可以看到,这个 `loop` 命令并不像一个合适的 Lisp 命令那样工作。首先,它在括号方面存在挑战。在此之前,我们从未有过七个连续的标记而没有括号!

使其更加不符合 Lisp 特性的还有,其中一些额外的标记(`for`、`below` 和 `sum`)似乎具有特殊的意义。回想一下 第三章,形式(括号之后)的第一个标记通常是决定代码基本行为的东西,而形式的其他部分包含参数。在 `loop` 宏中,这些“魔法标记”中的几个从根本上影响了 `loop` 的行为。它们的意义如下:
+ `for` 允许你声明一个变量(在这种情况下,命名为 `i`),该变量遍历一系列值。默认情况下,它将从零开始计数整数。
+ `below` 告诉 `for` 构造在达到指定值(在这种情况下,为 `5`)时停止,不包括该值本身。
+ `sum` 将给定表达式的所有值(在这种情况下,表达式只是 `i`)相加,并使 `loop` 返回该数字。
## 一些循环技巧
`loop` 宏拥有丰富的特殊令牌,几乎可以实现任何行为。让我们看看一些可能性。
### 从一个起始点计数到结束点
通过使用 `from` 和 `to` 子句,你可以使 `for` 构造遍历任何特定的整数范围:
(loop for i
from 5
to 10
sum i)
45
### 遍历列表中的值
在以下示例中,我们使用 `in` 令牌遍历列表中的值:
(loop for i
in '(100 20 3)
sum i)
123
### 在循环中执行操作
`do` 令牌接受任意表达式并在 `loop` 中执行它:
(loop for i
below 5
do (print i))
0
1
2
3
4
### 在特定条件下执行操作
`when` 令牌允许你根据需要运行 `loop` 的以下部分:
(loop for i
below 10
when (oddp i)
sum i)
25
注意,只有奇数的和被返回。
### 提前退出循环
以下 `loop` 使用了几个新技巧:

(loop for i
from 0
do (print i)
when (= i 5)
return 'falafel)
0
1
2
3
4
5
FALAFEL
注意,在 `loop` 的 `for` 部分中没有告诉它停止计数的任何内容——它从零开始一直计数到无穷大。然而,一旦我们达到 `5`,`when` 子句就会触发循环立即返回值 `'falafel'`。
### 收集一系列值

`collect` 子句允许你从 `loop` 中返回多个项目,形式为一个列表。当需要修改列表中的每个项目时,此命令非常有用,如下例所示:
(loop for i
in '(2 3 4 5 6)
collect (* i i))
(4 9 16 25 36)
### 使用多个 `for` 子句

一个 `loop` 宏可以有多个 `for` 子句。考虑以下示例:
(loop for x below 10
for y below 10
collect (+ x y))
你认为会有多少数字作为结果返回?有两种可能性:要么同时递增 `x` 和 `y` 并返回一个包含 10 个项目的列表,要么以嵌套方式迭代 `x` 和 `y` 并返回 100 个数字。答案是前者:
(loop for x below 10
for y below 10
collect (+ x y))
(0 2 4 6 8 10 12 14 16 18)
如您所见,两个数字在 0 到 9 之间同时递增。
如果在 Common Lisp 的 `loop` 中有多个 `for` 子句,每个子句都会被检查,并且当任何一个子句的值用尽时,`loop` 会停止。这意味着 `for` 子句不会在多个循环变量之间独立 `loop`,所以如果你在两个各有 10 个值的范围内 `loop`,它仍然只会 `loop` 10 次。
然而,有时你想要生成多个范围之间的笛卡尔积。换句话说,你想要一个循环为两个或更多范围的每一种可能的组合运行一次。为了实现这一点,你需要使用嵌套循环来处理`x`和`y`:
(loop for x below 10
collect (loop for y below 10
collect (+ x y)))
((0 1 2 3 4 5 6 7 8 9) (1 2 3 4 5 6 7 8 9 10) (2 3 4 5 6 7 8 9 10 11)
(3 4 5 6 7 8 9 10 11 12) (4 5 6 7 8 9 10 11 12 13) (5 6 7 8 9 10 11 12 13 14)
(6 7 8 9 10 11 12 13 14 15) (7 8 9 10 11 12 13 14 15 16)
(8 9 10 11 12 13 14 15 16 17) (9 10 11 12 13 14 15 16 17 18))
在这个例子中,我们创建了 10 个包含 10 个项目的列表,总共循环了 100 个项目。
此外,请注意,使用从零开始的`for`变量,例如以下示例中的`i`变量,提供了一种跟踪列表中项目索引数字的干净方式:
(loop for i
from 0
for day
in '(monday tuesday wednesday thursday friday saturday sunday)
collect (cons i day))
((0 . MONDAY) (1 . TUESDAY) (2 . WEDNESDAY)
(3 . THURSDAY) (4 . FRIDAY) (5 . SATURDAY) (6 . SUNDAY))
你可能认为我们到现在已经涵盖了所有可能的循环变体。如果是这样,你大错特错了。看吧!循环宏的周期表!

## 你想知道关于循环的一切
我们迄今为止讨论的个别示例只是对`loop`完整功能的简要提示。但别担心!你现在拥有了世界上第一个也是唯一的循环宏周期表。只需将它贴在你的显示器上,粘在你的钱包上,或者直接激光雕刻到你的视网膜上,你就能保证迅速达到`loop`熟练水平!
几乎所有可以在循环宏中使用的合法命令都在周期表中得到了涵盖。它展示了如何操作散列表和数组,以及执行特殊的循环操作。周期表中的每个方格都包含一个示例。如果你运行这个示例,你应该能够弄清楚给定命令的行为。
# 使用循环进化!

让我们再创建一个游戏,充分利用`loop`。但这不会是我们玩的游戏。相反,它将是一个随着我们观看而演化的游戏世界!我们将创建一个由草原和热带雨林组成的环境,里面充满了四处奔跑、觅食、进食和繁殖的动物。经过几百万个时间单位后,我们将看到它们已经进化成不同的物种!
### 注意
这个示例改编自 A.K. Dewdney 的文章“模拟进化:虫子学会捕食细菌”,发表在《科学美国人》的“计算机娱乐”专栏(1989 年 5 月:138-141)。

我们的游戏世界极其简单。它由一个简单的矩形平面组成,边缘环绕到对面。从数学上讲,它具有环面拓扑结构。这个世界的绝大部分都是草原,这意味着几乎没有植物生长供动物食用。在世界的中心是一个小型的热带雨林,那里的植物生长得更快。我们的动物是食草动物,它们将在这个世界中觅食以寻找食物。
让我们创建一些描述我们世界范围的变量:
(defparameter width 100)
(defparameter height 30)
(defparameter jungle '(45 10 10 10))
(defparameter plant-energy 80)
我们给世界设定了 100 个单位的宽度和 30 个单位的长度。使用这些尺寸应该可以在我们的 Lisp REPL 中轻松显示世界。`*jungle*`列表定义了包含丛林的世界地图中的矩形。列表中的前两个数字是丛林左上角的 x 和 y 坐标,最后两个数字是其宽度和高度。最后,我们给出每个植物所含的能量量,设置为 80。这意味着如果动物找到植物,它通过吃它将获得 80 天的食物。
### 注意
如果你的终端窗口不够大,无法显示整个世界,请更改`*width*`和`*height*`变量的值。将`*width*`变量设置为终端窗口宽度减去二,将`*height*`变量设置为终端窗口高度减去一。
## 在我们的世界中种植植物
如你所想,在计算机上模拟进化是一个缓慢的过程。为了看到生物进化,我们需要模拟大量时间,这意味着我们希望这个项目的代码非常高效。当动物在我们的世界中游荡时,它们需要能够检查给定 x,y 位置是否有植物。实现这一点最有效的方法是将所有植物存储在基于每个植物 x 和 y 坐标的哈希表中。
(defparameter plants (make-hash-table :test #'equal))
默认情况下,Common Lisp 哈希表在测试键的相等性时使用`eq`。然而,对于这个哈希表,我们定义`:test`使用`equal`而不是`eq`,这将允许我们使用 x 和 y 坐标对的 cons 对作为键。如果你还记得我们检查相等性的经验法则,cons 对应该使用`equal`进行比较。如果我们没有进行这个更改,每次检查键都会失败,因为即使两个不同的 cons 单元具有相同的内容,使用`eq`测试时也会被视为不同。
植物将在全球范围内随机生长,尽管在丛林地区的植物密度将高于草原地区。让我们编写一些函数来种植新的植物:
(defun random-plant (left top width height)
(let ((pos (cons (+ left (random width)) (+ top (random height)))))
(setf (gethash pos plants) t)))
(defun add-plants ()
(apply #'random-plant jungle)
(random-plant 0 0 width height))
`random-plant`函数在世界的指定区域内创建一个新的植物。它使用`random`函数构建一个随机位置,并将其存储在局部变量`pos`中 ![http://atomoreilly.com/source/nostarch/images/783564.png]。然后它使用`setf`来指示哈希表中植物的存在 ![http://atomoreilly.com/source/nostarch/images/783562.png]。哈希表中实际存储的唯一项是`t`。对于这个`*plants*`表,表的键(每个植物的 x,y 位置)实际上比表中存储的值要多。
看起来有点奇怪,要费这么大的劲创建一个散列表,只是为了在每个槽位中存储`t`。然而,Common Lisp 默认并没有为持有数学集合而设计的结构。在我们的游戏中,我们想要追踪所有包含植物的全球位置集合。结果证明,散列表是表达这种方式的完美选择。你只需使用每个集合项作为键,并将`t`作为值存储。实际上,这样做确实是一种折衷方案,但它是一种相对简单且高效的折衷方案。(其他 Lisp 方言,如 Clojure,直接内置了集合数据结构,使得这种折衷方案变得不必要。)
每天我们的模拟运行时,`add-plants`函数将创建两种新的植物:一个在丛林中  和一个在地图的其余部分 。由于丛林很小,与世界的其余部分相比,它将有更茂密的植被。
## 创建动物

我们世界中的植物非常简单,但动物要复杂一些。因此,我们需要定义一个结构来存储我们游戏中每个动物的属性:
(defstruct animal x y energy dir genes)
让我们详细看看这些字段中的每一个。
### 动物的解剖结构

我们需要追踪每个动物的一些属性。首先,我们需要知道它的 x 和 y 坐标。这表明动物在世界地图上的位置。
接下来,我们需要知道动物有多少`能量`。这是一个达尔文式的生存游戏,所以如果动物不能觅食足够的食物,它就会饿死。能量字段追踪动物剩余的能量天数。动物在能量供应耗尽之前找到更多的食物至关重要。
我们还需要追踪动物面向的方向。这很重要,因为动物每天都会在世界地图上的相邻方块中移动。`dir`字段将指定动物下一个 x,y 位置的方位,为一个从 0 到 7 的数字:

例如,方位 0 将导致动物在第二天向上并向左移动。
最后,我们需要追踪动物的`基因`。每种动物恰好有八个基因,由正整数组成。这些整数代表八个“槽位”,如下环绕着动物:

每天早上,动物将决定是否继续面向前一天的方向,或者转向面向新的方向。它将通过咨询这八个槽位并随机选择一个新的方向来完成这项工作。基因被选择的概率将与存储在基因槽中的数字成正比。
例如,一个动物可能具有以下基因:
(1 1 10 1 1 1 1 1)
让我们用表格表示这些基因,显示每个槽位编号及其存储的值的大小:

在这个例子中,一个动物在 2 号槽中存储了一个大数(10)。查看我们围绕动物的八个槽位的图片,你可以看到 2 号槽指向右边。因此,这个动物将会进行很多右转,并形成一个圆圈。当然,由于其他槽位仍然包含大于零的值,动物偶尔也会移动到其他方向。
让我们创建一个`*animals*`变量,用单个起始动物填充。你可以把这个动物看作是“亚当”(或者“夏娃”,取决于你更喜欢我们的无性动物是男性还是女性)。
(defparameter animals
(list (make-animal :x (ash width −1)
:y (ash height −1)
:energy 1000
:dir 0
:genes (loop repeat 8
collecting (1+ (random 10))))))
我们将动物起点设置为世界的中心,将`x`和`y`位置分别设置为地图宽度和高度的一半。我们将它的初始能量设置为`1000`,因为它还没有进化很多,我们希望它在生存上有一定的机会。它一开始面向上左,其`dir`字段设置为`0`。对于其基因,我们只使用随机数。
注意,与`*plants*`结构不同,后者是一个哈希表,`*animals*`结构只是一个普通的列表(目前只包含一个成员)。这是因为,对于我们模拟的核心,我们从未需要搜索我们的动物列表。相反,我们只需在模拟的每一天遍历一次`*animals*`,让我们的生物体进行日常活动。列表已经支持高效的线性遍历,因此使用另一个更复杂的数据结构(如表)对我们的模拟性能不会有显著影响。
### 处理动物运动

`move`函数接受一个动物作为参数,并根据我们描述的方向网格将其移动,可以是正交或对角移动:
(defun move (animal)
(let ((dir (animal-dir animal))
(x (animal-x animal))
(y (animal-y animal)))
(setf (animal-x animal) (mod (+ x
(cond ((and (>= dir 2) (< dir 5)) 1)
((or (= dir 1) (= dir 5)) 0)
(t −1))
width)
width))
(setf (animal-y animal) (mod (+ y
(cond ((and (>= dir 0) (< dir 3)) −1)
((and (>= dir 4) (< dir 7)) 1)
(t 0))
height)
height))
(decf (animal-energy animal))))
`move`函数修改`x`和`y`字段,使用`animal-x`和`animal-y`访问器。正如我们讨论的那样,这些是通过`defstruct`宏自动生成的,基于字段名称。在这个函数的顶部,我们使用访问器检索动物的 x 和 y 坐标  。然后我们使用相同的访问器设置相同的值,借助`setf`  。
为了计算新的 x 坐标,我们使用 `cond` 命令首先检查方向是否为 2、3 或 4 。这些是动物可能面对且指向世界东部的方向,因此我们希望将 x 坐标加一。如果方向是 1 或 5,则意味着动物正直接面向北或南 。在这些情况下,x 坐标不应改变。在所有其他情况下,动物面向西,我们需要减一 。y 坐标以类似的方式调整 。
由于世界需要在边缘处绕回,我们使用 `mod` (余数)函数进行一些额外的数学计算来计算坐标的模数,并允许在地图上绕回  。如果一个动物的 x 坐标会变成 `*width*`,则 `mod` 函数将其放回零,对 y 坐标和 `*height*` 也做同样的处理。例如,如果我们的函数使动物向东移动直到 `x` 等于 100,这意味着 (`mod 100 *width*`) 等于零,动物将绕回到游戏世界的远西边。
`move` 函数需要做的最后一件事是减少动物拥有的能量量一个单位。毕竟,运动需要能量。
### 处理动物转向
接下来,我们将编写 `turn` 函数。此函数将使用动物的基因来决定在给定的一天中它是否以及如何转向。
(defun turn (animal)
(let ((x (random (apply #'+ (animal-genes animal)))))
(labels ((angle (genes x)
(let ((xnu (- x (car genes))))
(if (< xnu 0)
0
(1+ (angle (cdr genes) xnu))))))
(setf (animal-dir animal)
(mod (+ (animal-dir animal) (angle (animal-genes animal) x))
8)))))
此功能需要确保动物转向的量与给定槽位中的基因数量成比例。它是通过首先计算所有基因的总和,然后在那个总和内选择一个随机数来实现的 。之后,它使用一个名为 `angle` 的递归函数 ,该函数遍历基因,并根据每个基因对总和的贡献找到对应的数字。它从参数 `x` 中的运行计数中减去当前基因存储的数字 。如果运行计数达到或超过零,则函数已达到所选数字并停止递归 。最后,它将转向量加到当前方向上,并在需要时,通过使用 `mod`  将数字绕回零。
### 处理动物进食

进食是一个简单的过程。我们只需检查动物当前位置是否有植物,如果有,就消耗它:
(defun eat (animal)
(let ((pos (cons (animal-x animal) (animal-y animal))))
(when (gethash pos plants)
(incf (animal-energy animal) plant-energy)
(remhash pos plants))))
动物的能量通过植物储存的能量量来增加。然后我们使用`remhash`函数从世界中移除植物。
### 处理动物繁殖

在任何动物模拟中,繁殖通常是最有趣的部分。我们将通过让我们的动物进行无性繁殖来简化问题,但这仍然应该是有趣的,因为当它们的基因被复制时,错误会逐渐进入,导致突变。
(defparameter reproduction-energy 200)
(defun reproduce (animal)
(let ((e (animal-energy animal)))
(when (>= e reproduction-energy)
(setf (animal-energy animal) (ash e −1))
(let ((animal-nu (copy-structure animal))
(genes (copy-list (animal-genes animal)))
(mutation (random 8)))
(setf (nth mutation genes) (max 1 (+ (nth mutation genes) (random 3) −1)))
(setf (animal-genes animal-nu) genes)
(push animal-nu animals)))))
要产生健康的后代,需要一个健康的父母,因此我们的动物只有在拥有至少 200 天的能量时才会繁殖。我们使用全局常量`*reproduction-energy*`来决定这个截止数字应该是多少。如果动物决定繁殖,它将失去一半的能量给它的子女。
要创建新的动物,我们只需使用`copy-structure`函数复制父母的结构。但是,我们需要小心,因为`copy-structure`只执行结构的浅拷贝。这意味着如果结构中包含比数字或符号更复杂的值的字段,这些字段中的值将与父母共享。动物的基因,存储在列表中,是我们动物结构中唯一的这种复杂值。如果我们不小心,动物基因的突变将同时影响所有父母和子女。为了避免这种情况,我们需要使用`copy-list`函数创建基因列表的显式副本。
这里有一个例子,展示了如果我们仅仅依赖于`copy-structure`函数的浅拷贝,可能会发生多么糟糕的事情:
(defparameter *parent* (make-animal :x 0
:y 0
:energy 0
:dir 0
:genes '(1 1 1 1 1 1 1 1)))
PARENT
(defparameter *child* (copy-structure *parent*))
CHILD
(setf (nth 2 (animal-genes *parent*)) 10)
10
*parent*
S(ANIMAL :X 0 :Y 0 :ENERGY 0 :DIR 0 :GENES (1 1 10 1 1 1 1 1))
*child*
S(ANIMAL :X 0 :Y 0 :ENERGY 0 :DIR 0 :GENES (1 1 10 1 1 1 1 1))
在这里,我们创建了一个所有基因都设置为`1`的父代动物 。接下来,我们使用`copy-structure`创建一个子代 。然后我们将第三个(从零开始计数)基因设置为`10` 。现在我们的父代看起来是正确的 。不幸的是,由于我们忘记使用`copy-list`为子代创建一个单独的基因列表,当父代发生变异时,子代的基因也被改变了 。任何当你有超出简单原子符号或数字的数据结构时,在使用`setf`时都需要非常小心,以免这类错误悄悄进入你的代码。在未来的章节(特别是第十四章)中,你将学习如何通过不使用像`setf`那样直接修改数据的函数来避免这些问题。
要在我们的`reproduce`函数中变异一个动物,我们随机选择它的八个基因中的一个,并将其放入`mutation`变量中。然后我们使用`setf`对这个值进行一点调整,再次使用一个随机数。我们在以下这一行做了这个调整:
(setf (nth mutation genes) (max 1 (+ (nth mutation genes) (random 3) −1)))
在这一行,我们稍微改变了一下基因列表中的一个随机槽位。这个槽位的数字存储在局部变量`mutation`中。我们给这个槽位中的值加上一个小于三的随机数,然后从总数中减去一。这意味着基因值将改变一正一负,或者保持不变。由于我们不希望基因值小于一,我们使用`max`函数确保它至少是一。
然后,我们使用`push`将这个新生物插入到我们的全局`*animal*`列表中,这样它就被添加到了模拟中。
## 模拟我们世界的一天
现在我们已经有了处理动物日常细节的函数,让我们写一个模拟我们世界一天的函数。
(defun update-world ()
(setf animals (remove-if (lambda (animal)
(<= (animal-energy animal) 0))
animals))
(mapc (lambda (animal)
(turn animal)
(move animal)
(eat animal)
(reproduce animal))
animals)
(add-plants))
首先,这个函数会从世界中移除所有死亡的动物 。(如果一个动物的能量小于或等于零,它就是死亡的。)接下来,它映射到列表上,处理每个动物可能的日常活动:转身、移动、进食和繁殖 。由于所有这些函数都有副作用(它们直接使用`setf`修改单个动物结构),我们使用`mapc`函数,它不会浪费时间从映射过程中生成结果列表。
最后,我们调用 `add-plants` 函数,该函数每天向世界添加两种新的植物(一个在丛林中,一个在草原上)。由于景观上总是有新的植物在生长,我们的模拟世界最终应该达到平衡,允许在模拟的时间跨度内生存一个相当大的动物群体。
## 绘制我们的世界
一个模拟的世界如果没有我们能看到我们的生物四处奔跑、寻找食物、繁殖和死亡,那就没有多少乐趣。`draw-world` 函数通过使用 `*animals*` 和 `*plants*` 数据结构来绘制当前世界的快照到交互式命令行环境(REPL)中。
(defun draw-world ()
(loop for y
below height
do (progn (fresh-line)
(princ "|")
(loop for x
below width
do (princ (cond ((some (lambda (animal)
(and (= (animal-x animal) x)
(= (animal-y animal) y)))
animals)
#\M)
((gethash (cons x y) plants) #*)
(t #\space))))
(princ "|"))))
首先,该函数使用一个 `loop` 来遍历世界的每一行。每一行都以一个新行(使用 `fresh-line` 创建)开始,后面跟着一个垂直线,这显示了世界的左边缘。接下来,我们遍历当前行的列,检查每个位置是否有动物。我们使用 `some` 函数 来执行此检查,该函数允许我们确定列表中是否至少有一个项目满足某个条件。在这种情况下,我们检查的条件是当前 x 和 y 坐标处是否有动物。如果有,我们在该位置绘制字母 `M`。(如果你发挥想象力,大写字母 `M` 看起来有点像动物。)
否则,我们检查是否存在植物,我们将用星号(`*`)字符来表示。如果没有植物或动物,我们绘制一个空格字符。最后,我们再画一条垂直线来结束每一行的绘制。
注意,在这个函数中,我们需要搜索整个 `*animals*` 列表,这将会造成性能损失。然而,`draw-world` 并不是我们模拟的核心程序。正如你很快就会看到的,我们游戏的用户界面将允许我们一次运行数千天的模拟,直到最后才将世界绘制到屏幕上。由于在这种情况下我们不需要每天都在屏幕上绘制屏幕,所以 `draw-world` 的性能对模拟的整体性能没有影响。
## 创建用户界面
最后,我们将为我们的模拟创建一个用户界面函数,称为 `evolution`。
(defun evolution ()
(draw-world)
(fresh-line)
(let ((str (read-line)))
(cond ((equal str "quit") ())
(t (let ((x (parse-integer str :junk-allowed t)))
(if x
(loop for i
below x
do (update-world)
if (zerop (mod i 1000))
do (princ #.))
(update-world))
(evolution))))))
首先,这个函数在 REPL 中绘制世界 。然后它等待用户在 REPL 中使用 `read-line`  输入命令。如果用户输入 `quit`,模拟结束 。否则,它将尝试使用 `parse-integer` 解析用户的命令 。我们为 `parse-integer` 设置了 `:junk-allowed` 为 `true`,这使得界面可以接受一个字符串,即使它不是一个有效的整数。
如果用户输入一个有效的整数 *n*,程序将运行 *n* 模拟天,使用循环 。它还会在每 1000 天打印一个点,以便用户可以看到计算机在运行模拟时没有冻结。
如果输入的不是有效的整数,我们将运行 `update-world` 来模拟额外的一天。由于 `read-line` 允许空值,用户只需按下回车键,就可以看到动物在其世界中四处移动。
最后,`evolution` 函数递归地调用自身来重新绘制世界并等待更多用户输入 。我们的模拟现在完成了。
## 让我们观看一些进化吧!
要开始模拟,按照以下方式执行 `evolution`:
(evolution)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| M
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
目前我们的世界是空的,除了中心的亚当/夏娃动物。按回车键几次,可以循环显示几天:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| *
|
|
|
| * M
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
*`[enter]`*
|
|
|
|
|
|
| *
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| *
|
| *
|
| * M
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
我们未充分进化的动物正在随机乱走,一些植物开始生长。
接下来,输入 **`100`** 来看看 100 天后世界是什么样子:
100
| *
* |
|
* * |
| * ** *
|
| * * *
* * |
| M *
M* * |
| * M *
|
| * * *
|
| * M M *
|
| M M M
* |
| * M M * *
|
| * M M MM *
* |
| M* *
* |
| M M M
* |
| * M MM
|
| * * * M M * M
M * * * |
| * * * M MM * M
* * |
| M M M* *
* * |
| * * M M M
|
| * * M
* * * |
|
M * |
| * * M M
|
| * * * M M
|
| * * * M M M
M |
| * * M M
* * * |
| *
* |
| * *
* |
| *
* |
| * * M
* |
| *
* |
| * * M *
* * * |
我们的动物已经繁殖了很多,但这与它吃过的食物量没有太大关系,而是与我们给予它的“启动能量”大量有关。
现在,让我们全力以赴,运行五百万天的模拟!由于我们使用的是 CLISP,这可能会比较慢,你可能想在晚上启动它,让它整夜运行。使用性能更高的 Lisp,如 SBCL,可能只需几分钟。
5000000
| *
M M |
| * * *
M |
| M
* |
| M *
* M M |
| * M *
* |
| **
* |
| M
|
| M * M M
* |
| * * M M M M
M M |
| M
M |
| M* * M M MMM M
M |
| * * MMM * M M
M |
| * M * M M MM MMM M
* |
| M MMMMMM M M
M * |
| M MMMM MMM M * M
M |
| M * M MMM
* * |
| M M M M M M
M * |
| M M M MMM M
M M |
| M M M MM
M * * |
| * MMM M
MM M M |
| M MM *
M |
| MMM M M M
|
| * M M
|
| M M M *M *
M M |
| M M
MM M |
| M * M * M
M |
| MM M M M
M |
| M M M
|
| M * M * *
* |
| M M M
|
五百万天后,我们的世界看起来与一百天后并没有太大的不同。当然,动物更多了,既有穿越草原的,也有享受丛林茂密植被的。
但外表是欺骗性的。这些动物与它们的早期祖先明显不同。如果你仔细观察(通过按回车键),你会看到一些生物直线移动,而另一些生物只是在小范围内跳跃,在任何方向上都不会走超过一步。(作为一个练习,你可以调整代码,为每种动物使用不同的字母,以便更容易观察它们的运动。)你可以通过输入 `quit` 退出模拟,然后检查 REPL 中的 `*animals*` 变量内容来更清楚地看到这种对比:
*animals*
S(ANIMAL :X 6 :Y 24 :ENERGY 65 :DIR 3 :GENES (67 35 13 14 1 3 11 74))
S(ANIMAL :X 72 :Y 11 :ENERGY 78 :DIR 6 :GENES (68 36 13 12 2 4 11 72))
S(ANIMAL :X 16 :Y 26 :ENERGY 78 :DIR 0 :GENES (71 36 9 16 1 6 5 77))
S(ANIMAL :X 50 :Y 25 :ENERGY 76 :DIR 4 :GENES (2 2 7 5 21 208 33 9))
S(ANIMAL :X 53 :Y 13 :ENERGY 34 :DIR 4 :GENES (1 2 8 5 21 208 33 8))
S(ANIMAL :X 58 :Y 10 :ENERGY 66 :DIR 6 :GENES (5 2 7 2 22 206 29 3))
S(ANIMAL :X 74 :Y 3 :ENERGY 77 :DIR 0 :GENES (68 35 11 12 1 3 11 74))
S(ANIMAL :X 47 :Y 19 :ENERGY 47 :DIR 2 :GENES (5 1 8 4 21 207 30 3))
S(ANIMAL :X 27 :Y 22 :ENERGY 121 :DIR 1 :GENES (69 36 11 12 1 2 11 74))
S(ANIMAL :X 96 :Y 14 :ENERGY 78 :DIR 5 :GENES (71 37 9 17 2 5 5 77))
S(ANIMAL :X 44 :Y 19 :ENERGY 28 :DIR 1 :GENES (1 3 7 5 22 208 34 8))
S(ANIMAL :X 55 :Y 22 :ENERGY 18 :DIR 7 :GENES (1 3 8 5 22 208 34 7))
S(ANIMAL :X 52 :Y 10 :ENERGY 63 :DIR 0 :GENES (1 2 7 5 23 208 34 7))
S(ANIMAL :X 49 :Y 14 :ENERGY 104 :DIR 4 :GENES (4 1 9 2 22 203 28 1))
S(ANIMAL :X 39 :Y 23 :ENERGY 62 :DIR 7 :GENES (70 37 9 15 2 6 5 77))
S(ANIMAL :X 97 :Y 11 :ENERGY 48 :DIR 0 :GENES (69 36 13 12 2 5 12 72))
...
如果你仔细观察列表中的所有动物,你会注意到它们有两种不同的基因组。一组动物在列表的前面有较高的数字,这导致它们主要直线移动。另一组动物在列表的后面有较大的数字,这导致它们在较小的区域内跳跃。没有动物在两种极端之间。我们进化出两种不同的物种了吗?
如果你创建一个函数来衡量这些进化动物在固定时间内行进的距离,距离的直方图将如下所示:

这是一个清晰的二模态分布,表明这些动物的行为似乎分为两个群体。想想这些动物生活的环境,并尝试推理为什么这种二模态分布会进化。我们将在下一节讨论这个难题的解决方案。
## 解释进化
进化难题的解决方案相当直接。在这个虚构的世界里,动物可以采取两种可能的生存策略:
+ 关注丛林中丰富的食物供应。采用这种策略的任何动物都需要在运动上保守。它不能随着时间的推移走得太远,否则可能会掉出丛林。当然,这类动物*确实*需要至少进化一点跳跃运动,否则它们将永远找不到任何食物。让我们称这些保守的、跳跃的、生活在丛林中的动物为*象种*。
+ 在草原上觅食稀疏的植被。在这里,生存的最关键特征是覆盖大距离。这类动物需要思想开放,并且必须不断迁移到地图上的新区域以寻找食物。(然而,它不能直线旅行得太直,否则可能会与自己的后代竞争资源。)这种策略需要一点天真的乐观,有时可能会导致灾难。让我们称这些思想开放、敢于冒险的动物为*马种*。

将模拟扩展到进化三个政府分支的任务留给读者作为练习。
# 你学到了什么
在本章中,我们详细讨论了`loop`命令。在这个过程中,你学习了以下内容:
+ `loop`命令是一个一站式循环商店——它可以完成你需要`loop`完成的任何事情。
+ 要在循环中计数数字,请使用`for`短语。
+ 要在循环中计数列表中的项目,请使用`for in`短语。
+ 你可以使用`collect`短语在列表内收集项目,并将它们作为一个列表返回。
+ 使用循环宏周期表来找到`loop`支持的其他有用短语。
# 第十一章. 使用格式函数打印文本
即使在现代编程时代,能够操纵文本仍然非常重要,而 Common Lisp 拥有一些最花哨的文本打印函数。无论你需要操纵 XML、HTML、Linux 配置文件,还是任何其他文本格式的数据,Lisp 都能使你的工作变得简单。
在 Common Lisp 中,最重要的高级文本打印函数是`format`函数,这是本章的主题。
# `format`函数的解剖结构
这里是`format`函数使用的一个示例:
(format t "Add onion rings for only ˜$ dollars more!" 1.5)
Add onion rings for only 1.50 dollars more!
NIL
让我们看看这个函数的每个部分代表什么。

## 目标参数
`format`函数的第一个参数是*目标*参数,它告诉`format`将生成的文本发送到何处。以下是它的可能值:
**`nil`**
不打印任何内容;只需返回值作为字符串。
**`t`**
将值打印到控制台。在这种情况下,函数只是返回 nil 作为值(如我们的示例所示)。
**`stream`**
将数据写入输出流(在第十二章中介绍)。
在以下示例中,我们将第一个参数设置为`nil`,因此它简单地以字符串形式返回值:
(princ (reverse
(format nil "Add onion rings for only ˜$ dollars more!" 1.5)))
!erom srallod 05.1 ylno rof sgnir noino ddA
"!erom srallod 05.1 ylno rof sgnir noino ddA"
生成的字符串值(`"Add onion rings for only 1.50 dollars more!"`)传递给`reverse`函数,然后使用`princ`命令将反转后的字符串打印到屏幕上。
在这个示例中,REPL 也会打印输入表达式的值,以及`princ`命令输出的信息。这就是为什么你会看到值被显示两次。在本章的其余部分,示例将省略 REPL 打印的这些值,只显示我们代码显式打印的信息。
## 控制字符串参数
`format`函数的第二个参数是一个`控制字符串`,它控制文本格式。`format`函数的力量在于控制字符串。在我们的当前示例中,控制字符串是`"Add onion rings for only ˜$ dollars more!"`。
默认情况下,此字符串中的文本将简单地作为输出打印。然而,你可以在字符串中放置*控制序列*来影响输出的格式,正如本章剩余部分所描述的。我们当前的示例包含控制序列`˜$`,表示*货币浮点值*。`format`函数识别的每个控制序列都以波浪号(`˜`)字符开头。
## 值参数
控制字符串后面的`format`参数包含值,或实际要显示和格式化的数据。正如你所看到的,控制字符串与这些参数交互并控制它们的格式。
# 打印 Lisp 值的控制序列
任何 Lisp 值都可以使用`print`或`prin1`命令打印。要打印供人类阅读的值,不使用任何分隔符,我们可以使用`princ`命令:
(prin1 "foo")
"foo"
(princ "foo")
foo
我们可以使用`˜s`和`˜a`控制序列与`format`一起产生与`prin1`和`princ`相同的行为。当与`format`一起使用时,`˜s`控制序列包含适当的分隔符。`˜a`显示没有分隔符的值,以便人类阅读:
(format t "I am printing ˜s in the middle of this sentence." "foo")
I am printing "foo" in the middle of this sentence.
(format t "I am printing ˜a in the middle of this sentence." "foo")
I am printing foo in the middle of this sentence.
我们可以通过在控制序列内输入参数来进一步调整这些控制序列的行为。例如,我们可以在`a`或`s`前放置一个数字*n*,以指示值应该用空格在右侧*填充*。然后`format`命令将添加空格,直到值的总宽度达到*n*。
例如,在以下示例中,通过写入`˜10a`,我们在`foo`的右侧添加了七个空格,使得格式化值的总宽度为 10 个字符:
(format t "I am printing ˜10a within ten spaces of room." "foo")
I am printing foo within ten spaces of room.
我们也可以通过添加@符号在值的左侧添加空格,如下所示:
(format t "I am printing ˜10@a within ten spaces of room." "foo")
I am printing foo within ten spaces of room.
在这种情况下,添加的空格总数加上值`foo`等于 10 个字符。
控制序列可以接受不止一个参数。在先前的例子中,我们只设置了第一个参数,它控制最终格式化字符串的宽度。让我们看看一个同时设置`˜a`控制序列第二个参数的例子:
(format t "I am printing ˜10,3a within ten (or more) spaces of room." "foo")
I am printing foo within ten (or more) spaces of room.
如您所见,控制序列的附加参数用逗号分隔。在这种情况下,第二个参数设置为`3`,这告诉`format`命令以三组(而不是一次一个)添加空格,直到达到目标宽度 10。在这个例子中,总共添加了九个空格到格式化值中。这意味着它超出了我们的目标宽度 10(按设计),反而导致总宽度为 12(九个空格加上字母`foo`)。以这种方式以倍数填充字符串不是常用的功能,所以`˜a`控制序列的第二个参数很少使用。
有时候我们需要控制添加到字符串中的确切空格数,而不管最终值的长度。我们可以通过在`˜a`控制序列中设置第三个参数来实现这一点。例如,假设我们想在最终格式化值后打印出恰好四个空格。要将第三个控制序列参数设置为四,我们在参数前放置两个逗号来表示前两个参数为空,然后跟一个`4`:
(format t "I am printing ˜,,4a in the middle of this sentence." "foo")
I am printing foo in the middle of this sentence.
注意到结果中恰好插入了四个额外的空格。由于在逗号之前没有指定第一个和第二个参数,将使用它们的默认值。
第四个控制序列参数指定了用于填充的字符。例如,在以下列表中,我们用四个感叹号填充打印的值:
(format t "The word ˜,,4,'!a feels very important." "foo")
The word foo!!!! feels very important.
这些控制序列参数也可以组合使用。例如,我们可以在代码中添加 `@` 符号来表示感叹号应该出现在值的前面,如下所示:
(format t "The word ˜,,4,'!@a feels very important." "foo")
The word !!!!foo feels very important.
现在你已经了解了 `format` 命令的控制序列概述,让我们看看如何使用它们进行格式化,从数字开始。
# 数字格式化控制序列
`format` 命令有许多专门设计用于控制数字外观的选项。让我们看看其中一些更有用的选项。
## 整数格式化控制序列
首先,我们可以使用 `format` 来使用不同的基数显示一个数字。例如,我们可以使用 `˜x` 控制序列以十六进制(基数-16)显示一个数字:
(format t "The number 1000 in hexadecimal is ˜x" 1000)
The number 1000 in hexadecimal is 3E8
类似地,我们可以使用 `˜b` 控制序列以二进制(基数-2)显示一个数字:
(format t "The number 1000 in binary is ˜b" 1000)
The number 1000 in binary is 1111101000
我们甚至可以显式声明一个值将以十进制(基数-10)数字的形式显示,使用 `˜d` 控制序列:
(format t "The number 1000 in decimal is ˜d" 1000)
The number 1000 in decimal is 1000
在这种情况下,如果我们只是使用了更通用的 `˜a` 控制序列,我们也会得到相同的结果。区别在于 `˜d` 支持特定于打印十进制数字的特殊参数和标志。例如,我们可以在控制序列内部放置冒号以启用逗号作为数字分组分隔符:
(format t "Numbers with commas in them are ˜:d times better." 1000000)
Numbers with commas in them are 1,000,000 times better.
要控制数字的宽度,我们可以设置填充参数,就像我们在 `˜a` 和 `˜s` 控制序列中所做的那样:
(format t "I am printing ˜10d within ten spaces of room" 1000000)
I am printing 1000000 within ten spaces of room
要更改用于填充的字符,请传入所需的字符(在这种情况下,是 *x* 字符)作为第二个参数:
(format t "I am printing ˜10,'xd within ten spaces of room" 1000000)
I am printing xxx1000000 within ten spaces of room
## 浮点数格式化控制序列
浮点值使用 `˜f` 控制序列处理。与之前讨论的所有控制序列一样,我们可以通过更改第一个参数来更改值的显示宽度。当与浮点数一起使用时,`format` 命令将自动将值四舍五入以适应请求的字符数(包括小数点):
(format t "PI can be estimated as ˜4f" 3.141593)
PI can be estimated as 3.14
如你所见,`3.14` 的最终宽度是四个字符宽,正如控制序列所指定的。
`˜f` 控制序列的第二个参数控制小数点后显示的数字位数。例如,如果我们将在前面的示例中传递 `4` 作为第二个参数,我们得到以下输出:
(format t "PI can be estimated as ˜,4f" 3.141593)
PI can be estimated as 3.1416
注意,Common Lisp 实际上在标准中包含了常数 `pi`,因此你也可以这样重写命令:
(format t "PI can be estimated as ˜,4f" pi)
PI can be estimated as 3.1416
`˜f` 控制序列的第三个参数会导致数字按十的因子缩放。例如,我们可以将 `2` 作为第三个参数传递,我们可以用它将分数乘以 10²,将其转换为百分比:
(format t "Percentages are ˜,,2f percent better than fractions" 0.77)
Percentages are 77.0 percent better than fractions
除了 `˜f`,我们还可以使用控制序列 `˜$`,它用于货币格式化:
(format t "I wish I had ˜$ dollars in my bank account." 1000000.2)
I wish I had 1000000.20 dollars in my bank account.
你在本章的开头看到了一个使用 `˜$` 的示例。
# 打印多行输出
Common Lisp 在打印时开始新行有两个不同的命令。第一个,`terpri`,只是告诉 Lisp 终止当前行并开始一个新行以打印后续输出。例如,我们可以这样在不同的行上打印两个数字:
(progn (princ 22)
(terpri)
(princ 33))
22
33
我们也可以使用 `fresh-line` 来开始新行。此命令将开始新行,但前提是 REPL 中的光标位置不在行的最前面。让我们看看一些示例:
(progn (princ 22)
(fresh-line)
(princ 33))
22
33
(progn (princ 22)
(fresh-line)
(fresh-line)
(princ 33))
22
33
正如您所看到的,在两个 `princ` 调用之间放置两个 `fresh-line` 语句导致 Lisp 在输出数字之间只打印了一行。第一个 `fresh-line` 开始新行;第二个 `fresh-line` 被简单地忽略。
实际上,`terpri` 命令表示“开始新行”,而 `fresh-line` 命令表示“如果需要,开始新行”。任何使用 `terpri` 命令的代码都需要“知道”之前打印了什么。否则,可能会出现难看的空白行。由于程序的不同部分最好尽可能少地了解彼此,大多数 Lisp 程序员更喜欢使用 `fresh-line` 而不是 `terpri`,因为它允许他们将一条数据的打印与下一条数据的打印解耦。
`format` 命令有两个与 `terpri` 和 `fresh-line` 类似的控制序列:
**`˜%`**
在所有情况下都会创建新行(类似于 `terpri`)
**`˜&`**
只在需要时创建新行(类似于 `fresh-line`)。
这些示例说明了这种差异:
(progn (format t "this is on one line ˜%")
(format t "˜%this is on another line"))
this is on one line
this is on another line
(progn (format t "this is on one line ˜&")
(format t "˜&this is on another line"))
this is on one line
this is on another line
正如您所看到的,使用额外的 `˜%` 会打印出难看的空白行 ,而在相同的位置使用 `˜&` 则不会。
这两个行终止序列也可以在它们前面有一个额外的参数,以指示要创建的新行数。这在我们需要使用空行来分隔输出时很有用。例如,以下示例中添加的 `5` 在输出中添加了五行空行:
(format t "this will print ˜5%on two lines spread far apart")
this will print
on two lines spread far apart
# 输出对齐
`format` 命令也让我们对文本对齐有了很多控制。控制序列允许我们格式化表格、居中文本以及执行其他有用的对齐技巧。
为了帮助您理解各种对齐规则,我们将创建一个简单的函数,该函数返回具有不同字符长度的不同动物名称:
(defun random-animal ()
(nth (random 5) '("dog" "tick" "tiger" "walrus" "kangaroo")))
RANDOM-ANIMAL
(random-animal)
"walrus"
现在假设我们想在表格中显示一些随机的动物。我们可以通过使用 `˜t` 控制序列来实现这一点。`˜t` 可以接受一个参数,指定格式化值应出现的列位置。例如,为了使我们的动物表格出现在第五、第十五和第二十五个字符位置的三列中,我们可以创建如下表格:
(loop repeat 10
do (format t "˜5t˜a ˜15t˜a ˜25t˜a˜%"
(random-animal)
(random-animal)
(random-animal)))
kangaroo tick dog
dog walrus walrus
walrus tiger tiger
walrus kangaroo dog
kangaroo tiger dog
tiger walrus kangaroo
tick dog tiger
kangaroo tick kangaroo
tiger dog walrus
kangaroo kangaroo tick
记住,带有 `repeat 10` 子句的 `loop` 命令会执行循环体 10 次。正如您所看到的,使用 `˜t` 控制序列导致动物被整齐地排列在表格中。

现在假设我们想要所有动物在单行上均匀分布。为了做到这一点,我们可以使用 `˜<` 和 `˜>` 控制序列,如下所示:
(loop repeat 10
do (format t "˜30<˜a˜;˜a˜;˜a˜>˜%"
(random-animal)
(random-animal)
(random-animal)))
tick tiger tick
tick tiger dog
tick dog dog
kangaroo kangaroo tiger
tiger tiger kangaroo
walrus kangaroo dog
dog dog walrus
kangaroo dog walrus
walrus dog walrus
kangaroo tiger tick
让我们分解这个控制字符串来了解它是如何工作的:

首先,`˜30<` 告诉函数我们正在启动一个对齐文本块。参数 `30` 表示该块应该有 30 个字符宽。接下来,我们有一行三个 `˜a` 控制序列,每个动物一个。每个 `˜a` 都由 `;` 分隔,这告诉 `format` 我们正在开始一个新的值,该值将由 `˜<` 对齐。(`˜;` 序列表示应在何处插入额外空格以对齐值。)然后我们使用 `˜>` 命令序列结束对齐部分。
由于每行动物之间的等距分布并不能保证通过打印多行创建的列能够正确对齐,我们在对齐命令序列 `˜<` 中添加了 `:@` 标志。例如,我们可以创建一个整齐居中的单列,如下所示:
(loop repeat 10 do (format t "˜30:@<˜a˜>˜%" (random-animal)))
dog
walrus
kangaroo
tick
tick
tiger
dog
kangaroo
kangaroo
dog
同样,我们可以使用 `:@` 与多个对齐值一起使用,在行的左右两端添加额外的空间来居中对齐:
(loop repeat 10
do (format t "˜30:@<˜a˜;˜a˜;˜a˜>˜%"
(random-animal)
(random-animal)
(random-animal)))
walrus tick tick
walrus tiger tick
tick dog tick
walrus tiger tiger
kangaroo dog kangaroo
tiger kangaroo walrus
tiger kangaroo kangaroo
kangaroo tiger tick
tick tiger walrus
walrus tiger tick
这一步让我们更接近于拥有三个整齐居中的列,但我们的列仍然有点波浪状,因为我们是在单行内对齐值,而没有告诉 `format` 使用三个居中对齐的列来排列值。
为了产生整齐的列,我们仍然会使用 `:@` 标志,但我们将使用三个独立的 10 个字符对齐部分来描述我们的行:
(loop repeat 10
do (format t "˜10:@<˜a˜>˜10:@<˜a˜>˜10:@<˜a˜>˜%"
(random-animal)
(random-animal)
(random-animal)))
tiger kangaroo kangaroo
kangaroo kangaroo walrus
tick tick tick
dog dog dog
tiger dog walrus
dog tiger kangaroo
walrus dog tick
tick walrus kangaroo
dog tick walrus
tiger tiger tiger
最后,我们拥有了梦想中完美居中的随机动物列!
如您所见,`format` 的布局选项非常灵活。由于我们在调试应用程序时经常需要创建复杂的数据列表和表格,这些技巧在您需要掌握数据时非常有用,即使是在更复杂的程序中也是如此。
# 使用控制序列遍历列表
带有众多控制序列的 `format` 函数实际上是一种编程语言。(事实上,许多 Lisp 程序员会称其为**领域特定语言**,这个概念我们将在第十七章(Chapter 17)中再次探讨。)而且,像大多数编程语言一样,`format` 可以遍历数据。它是通过使用 `˜{` 和 `˜}` 控制序列来实现的。
要实现这种循环,请将包含 `˜{` 和 `˜}` 的控制字符串和要迭代的列表传递给 `format` 函数。控制字符串中 `˜{` 和 `˜}` 序列之间的部分几乎像循环的主体。它将被执行多次,具体取决于其后列表的长度。`format` 函数将遍历这个列表,将每个项目应用于指定的控制字符串部分。
例如,让我们创建一个动物列表,我们可以用它来测试:
(defparameter *animals* (loop repeat 10 collect (random-animal)))
ANIMALS
*animals*
("dog" "kangaroo" "walrus" "kangaroo" "kangaroo" "walrus" "kangaroo"
"dog" "tick" "tick")
现在我们使用 `˜{ ˜}` 控制序列来遍历这个列表:
(format t "˜{I see a ˜a! ˜}" *animals*)
I see a dog! I see a kangaroo! I see a walrus! I see a kangaroo! I see
a kangaroo! I see a walrus! I see a kangaroo!
I see a dog! I see a tick! I see a tick!
要生成这个循环,我们只需将单个变量 `*animals*`,一个项目列表,传递给 `format` 函数。控制字符串会遍历列表,为 `*animals*` 的每个成员构造句子 `"我看到一个 ˜a"`。
单个迭代结构也可以从列表中获取多个项目,如下例所示:
(format t "˜{I see a ˜a... or was it a ˜a?˜%˜}" *animals*)
I see a dog... or was it a kangaroo?
I see a walrus... or was it a kangaroo?
I see a kangaroo... or was it a walrus?
I see a kangaroo... or was it a dog?
I see a tick... or was it a tick?
在这里,我们有一个单独的循环结构中包含两个 `˜a` 控制序列。每个 `˜a` 从列表中拉取一个动物,因此每次循环迭代都会打印出两个动物。
# 创建漂亮数据表的疯狂格式化技巧
让我们看看最后一个 `format` 示例,它使用了一些你已经见过的控制序列,以及一些新的控制序列。这个例子将说明如何将不同的控制序列组合起来以实现复杂的行为。
(format t "|˜{˜<|˜%|˜,33:;˜2d ˜>˜}|" (loop for x below 100 collect x))
| 0 1 2 3 4 5 6 7 8 9 |
|10 11 12 13 14 15 16 17 18 19 |
|20 21 22 23 24 25 26 27 28 29 |
|30 31 32 33 34 35 36 37 38 39 |
|40 41 42 43 44 45 46 47 48 49 |
|50 51 52 53 54 55 56 57 58 59 |
|60 61 62 63 64 65 66 67 68 69 |
|70 71 72 73 74 75 76 77 78 79 |
|80 81 82 83 84 85 86 87 88 89 |
|90 91 92 93 94 95 96 97 98 99 |
要创建这个格式良好的数字表,我们首先使用循环控制序列 `˜{ ˜}` 通过 `loop` 命令创建的数字列表进行迭代。在迭代过程中,我们放置了之前使用过的对齐控制序列 `˜< ˜>`。在这种情况下,我们不是用它们来对齐文本,而是用它们来将生成的文本分成几部分。这就是我们将 100 个数字分成 10 行整洁的行的方法。我们在对齐控制序列 `˜< ˜>` 内放置了 `˜:;` 控制序列,这将导致文本被分成等长的片段。
当在对齐中使用时,此序列之前的控制字符串 `˜:;`(在这种情况下恰好是 `|˜%|`)只有在当前光标位置超过由第二个参数指定的某个点时才会触发,即 `33`。换句话说,我们是在告诉格式化函数:“嘿,一旦你有 33 个字符的文本,就开启一个新行。”
`|˜%|` 控制字符串会导致换行并打印垂直线。要显示的数字使用 `˜2d` 格式化,这将打印一个左对齐的数字,宽度为两个字符。
### 注意
要详细了解每个控制序列的详细信息,请参阅 *Common Lisp HyperSpec* 在 [`www.lispworks.com/documentation/HyperSpec/Front/index.htm`](http://www.lispworks.com/documentation/HyperSpec/Front/index.htm)。
# 机器人攻击!

在这里,我们看看一个如此恐怖的游戏,它肯定会让你做噩梦:机器人攻击!在这个游戏中,机器人已经占领了世界,你的任务是摧毁它们。尽管情节可能听起来很可怕,但这个游戏真正会给 Lisp 程序员带来噩梦的部分是它滥用 `loop` 和 `format` 命令,以便将一个功能齐全的机器人战斗游戏压缩到 *单页代码* 中!(此程序使用了上一节中讨论的“疯狂格式化技巧”)
我已经用一些基本解释注释了代码。如果你想详细了解游戏是如何工作的,你需要回顾前几章的大部分信息。此外,你可以访问[`landoflisp.com/`](http://landoflisp.com/)下载游戏的源代码,并阅读代码的更详细解释。
要赢得游戏,你需要策略性地在场上移动,以使所有机器人相互碰撞。移动键是 QWE/ASD/ZXC。这些字符在你的键盘左侧形成一个网格,让你可以向上、向下、向左、向右移动,也可以斜向移动。你还可以使用 T 键进行传送。
享受吧!

# 你学到了什么
本章并没有真正涵盖 `format` 函数的所有功能。然而,它确实提供了一个介绍,其中你学习了以下内容:
+ `format` 命令的第一个参数决定了输出是发送到 REPL、流还是作为字符串返回。
+ `format` 命令的第二个参数是一个 *控制字符串*,它允许你改变数据打印的方式。控制字符串具有复杂的语法,几乎可以像一门编程语言一样独立存在。
+ `format` 命令剩余的参数是可以从控制字符串中引用的值,用于将值嵌入到格式化输出中。
+ 要将 Lisp 值嵌入到格式化字符串中,请使用 `˜s` 或 `˜a` 控制序列。
+ 许多控制序列可用于打印和自定义数字的显示外观。
+ `format` 命令还具有复杂的循环能力,可以用于格式化以多种不同风格排列的表格。
# 第十二章。处理流
几乎你编写的每个计算机程序都需要在某个时候与外部世界交互。也许你的程序只需要通过 REPL 与用户通信,打印信息并从键盘捕获用户的输入。你编写的其他程序可能需要读取或写入硬盘上的文件。此外,你可能想编写与其他计算机交互的程序,无论是通过本地网络还是互联网。在 Common Lisp 中,这类交互通过流来实现。
*流* 是 Common Lisp 中的数据类型,它允许你将一些外部资源看作是你可以用代码操作的数据。外部资源可以是各种东西:磁盘上的文件、网络上的另一台计算机,或者是屏幕上控制台窗口中的文本。正如你将在本章中学到的,通过使用流,Lisp 程序可以像与列表或哈希表交互一样容易地与外部资源交互。
# 流的类型
当我们从 Common Lisp 程序与外部资源通信时,我们通过使用流来完成。不同类型的资源有不同的流类型可用。另一个因素是流的流向——有时你可能想向资源写入数据,有时你可能想从资源读取数据。
## 按资源类型分类的流

当按它们操作的资源类型组织时,以下是最常用的流类型:
**控制台流**
我们到目前为止与 REPL 通信时使用的内容。
**文件流**
让我们在硬盘上的文件中读取和写入。
**套接字流**
让我们通过网络与其他计算机进行通信。
**字符串流**
让我们从 Lisp 字符串发送和接收文本。
在这些流类型中,字符串流是这个家族中的“黑羊”。字符串流不仅让你与外界通信,还允许你以新的和有趣的方式操作字符串。
## 按方向分类的流
当你向资源写入数据时,你使用 *输出流*。对于从资源读取数据,你使用 *输入流*。
### 输出流
输出流用于诸如写入 REPL、写入文件或通过套接字发送信息等任务。在最基本层面上,你可以对输出流做两件事:
+ 检查流是否有效。
+ 将新项目推入流。

如你所见,流在 Lisp 中比真正的数据结构更受限制。例如,列表支持与流相同的所有功能(我们可以使用 `push` 将新项目推入列表,并使用 `listp` 检查列表是否有效),我们还可以使用列表执行某些任务,这些任务我们无法使用输出流执行(例如,使用 `setf` 改变列表中的项)。但流这种有限的功能实际上使它们在许多情况下非常有用。
要检查我们是否有有效的输出流,我们可以使用 `output-stream-p` 函数。例如,REPL 有一个与之关联的输出流,称为 `*standard-output*`。我们可以使用以下代码来检查这是否是一个有效的输出流:
(output-stream-p *standard-output*)
T
Lisp 字符是一个可以使用基本命令 `write-char` 推入输出流的项目。例如,要将字符 `#\x` 写入 `*standard-output*` 流,我们可以运行以下命令:
(write-char #\x *standard-output*)
xNIL
此代码将一个 *x* 打印到标准输出(在这种情况下,它与 REPL 相同)。请注意,此函数还返回 `nil`,导致 *x* 和返回值在同一行上打印。正如你在 第六章 中看到的,这个额外的 `nil` 只是代码在 REPL 中运行时的副作用。如果我们把这个命令作为更大程序的一部分运行,只有 *x* 会被打印出来。
### 注意
在本章中,我们将仅讨论基于文本字符的流。在 Common Lisp 中,您还可以创建基于其他数据类型的流。例如,如果您正在处理二进制数据,您可能希望发送或接收原始字节而不是字符。但就我们的目的而言,操作文本数据(因此使用与文本字符一起工作的流)是最方便的。
### 输入流
输入流用于读取数据。与输出流一样,您可以使用输入流执行的操作有限。在最基本层面上,您可以使用输入流做两件事:
+ 检查流是否有效。
+ 从流中弹出项目。
我们可以使用`input-stream-p`命令来检查我们是否有有效的流。例如,与标准输出一样,REPL 有一个相关的输入流,称为`*standard-input*`,我们可以如下验证:
(input-stream-p *standard-input*)
T

我们可以使用`read-char`命令从流中弹出项目。由于我们从 REPL 中读取,我们需要输入一些字符并按回车键将数据发送到标准输入流:
(read-char *standard-input*)
123
\1
如您所见,流前面的 1 被`read-char`弹出并返回。
使用其他命令与流交互
除了`write-char`和`read-char`之外,Common Lisp 还有许多其他命令用于与流交互。事实上,在第六章中引入的所有打印和读取命令都可以接受一个流作为额外参数,这使得我们可以使用 Lisp 强大的输入/输出能力与任何流一起使用。例如,我们可以明确告诉`print`命令将输出打印到`*standard-output*`,如下所示:
(print 'foo *standard-output*)
FOO
这在处理除`*standard-output*`之外的流时可能很有用,您很快就会看到。
# 文件操作
除了使用流在 REPL 中写入和读取外,我们还可以使用流将数据写入和读取到文件中。
您可以使用多种方式在 Common Lisp 中创建文件流。最好的方法是使用`with-open-file`命令。如您很快就会看到的,此命令包含特殊的错误预防功能,使其比其他可用的文件命令更安全。以下示例使用`with-open-file`将字符串`"my data"`写入名为`data.txt`的文件:
(with-open-file (``my-stream "data.txt" :direction :output)
(print "my data" my-stream))
在这个例子中,`with-open-file` 命令将输出流绑定到名称 `my-stream` 。这将在名为 `my-stream` 的文件中创建一个文件输出流。这个流将在 `with-open-file` 命令的体内部可用(直到最后的闭合括号 ),并且我们发送到这个流中的任何数据都将最终存储在磁盘上的名为 `data.txt` 的文件中。`print` 命令将 `my-stream` 作为其输出的目标 。因此,运行此示例后,你应该在启动 CLISP 的文件夹中找到一个名为 `data.txt` 的新文件。该文件的内容是文本 "`my data`"。
将 `:output` 作为 `with-open-file` 的方向指定将创建一个输出流。要将其改为输入流,我们可以将方向更改为 `:input`,如下所示:
(with-open-file (my-stream "data.txt" :direction :input)
(read my-stream))
"my data"
如你所见,这导致数据——与上一个示例中写入文件中的相同数据——从文件中读取。
如你在 第六章 中所学,`print` 和 `read` 命令可以打印和读取任何基本 Common Lisp 数据类型。这种功能使得使用流将程序数据存储到硬盘驱动器变得容易。以下是一个更复杂的示例,它将关联列表(alist)写入文件:
(let ((animal-noises '((dog . woof)
(cat . meow))))
(with-open-file (my-stream "animal-noises.txt" :direction :output)
(print animal-noises my-stream)))
((DOG . WOOF) (CAT . MEOW))
(with-open-file (my-stream "animal-noises.txt" :direction :input)
(read my-stream))
((DOG . WOOF) (CAT . MEOW))
在这个例子中,我们正在创建一个动物及其发出的声音的关联表。我们创建了一个名为 `animal-noises` 的新 alist 。我们将 `dog` 和 `cat` 的键放入这个列表中。现在我们可以将这个 alist 写入一个名为 `animal-noises.txt` 的新文件 。稍后,我们可以轻松地从文件中重新构建这个 alist 。
`with-open-file` 命令可以接受修改其行为的关键字参数。例如,你可以告诉命令如果存在具有给定名称的文件时应该做什么。在以下示例中,我们将使用 `:if-exists` 关键字参数显示错误信息:
(with-open-file (my-stream "data.txt" :direction :output :if-exists :error)
(print "my data" my-stream))
*** - OPEN: file #P"/home/user/data.txt" already exists
或者,你可能只想覆盖现有的文件。在这种情况下,将 `:if-exists` 关键字参数设置为 `:supersede`,如下所示:
(with-open-file (my-stream "data.txt" :direction :output
:if-exists :supersede)
(print "my data" my-stream))
"my data"
`with-open-file` 命令为你提供了一个非常简洁的方式来处理文件。与大多数编程语言不同,当使用此命令时,你不需要手动打开和关闭文件,也不需要担心由于未能正确关闭文件而可能损坏文件。(实际上,Common Lisp 也有用于打开和关闭文件的低级命令,但 `with-open-file` 以一种干净的方式将它们打包起来,隐藏了所有丑陋的细节。)

`with-open-file`的主要目的是获取文件资源。它控制文件并承担关闭文件的责任。实际上,即使`with-open-file`内部的代码抛出一个丑陋的错误,停止程序运行,`with-open-file`仍然会正确关闭文件,以确保该资源保持完整。
### 注意
Common Lisp 有许多以`with-`开头的命令,可以安全地以这种方式分配资源。这些`with-`命令,可在核心 Lisp 库中找到,是用 Lisp 的强大宏系统构建的。你将在第十六章中了解更多关于 Lisp 宏的知识,以及如何创建自己的`with-`命令。
# 与套接字一起工作

现在我们已经使用流与 REPL 和文件进行通信,让我们看看我们如何使用它们与另一台计算机进行通信。
如果你想要编写一个可以与标准网络(几乎所有网络现在都使用 TCP/IP 协议)上其他地方的计算机进行通信的程序,你首先需要创建一个套接字。*套接字*是在计算机网络上在运行在不同计算机上的程序之间路由数据的机制。
不幸的是,套接字没有进入 ANSI Common Lisp 标准,这意味着目前没有标准的方式来与套接字交互。然而,每个版本的 Common Lisp 都支持套接字,即使它不遵循任何标准。由于我们在这本书中选择了 CLISP 作为我们的 Lisp,我们将只考虑 CLISP 的套接字命令。
### 注意
cl-sockets *(*[`common-lisp.net/project/cl-sockets/`](http://common-lisp.net/project/cl-sockets/)*)* 和 usocket *(*[`common-lisp.net/project/usocket/`](http://common-lisp.net/project/usocket/)*)* 是将标准套接字库添加到 Common Lisp 的两种尝试。
## 套接字地址
网络中的每个套接字都必须有一个*套接字地址*。这个套接字地址有两个组成部分:
**IP 地址**
一个唯一标识网络中计算机的数字(通常以点分隔的 4 个字节表示,例如 192.168.33.22)。
**端口号**
任何想要使用网络的程序都必须选择一个唯一的端口号,这个端口号在该计算机上的其他程序尚未使用。
IP 地址和端口号组合起来构成套接字地址。由于 IP 地址在网络中是唯一的,端口号对于给定的计算机是唯一的,因此网络上的每个套接字地址都是特定计算机上运行的特定程序的唯一标识。通过网络(通过称为*TCP 数据包*的数据块)传输的任何消息都将带有套接字地址,以指示其目的地。
一旦计算机收到标记有其 IP 地址的数据包,操作系统将查看消息的套接字地址中的端口号,以确定哪个程序应该接收该消息。
操作系统是如何知道哪个程序接收指定端口的消息的呢?因为它知道,一个程序必须首先为该端口创建一个套接字才能使用它。换句话说,套接字就是计算机程序告诉操作系统的一种方式:“嘿,如果你收到端口 251 上的任何消息,请将它们发送给我!”
## 套接字连接
为了在两个程序之间通过套接字发送消息,我们首先需要遵循一些步骤来初始化一个 *套接字连接*。创建此类连接的第一步是让其中一个程序创建一个处于监听状态的套接字,等待查看网络上是否有其他程序想要开始通信。拥有处于监听状态套接字的计算机被称为 *服务器*。然后,另一个程序(称为 *客户端*)在其端创建一个套接字,并使用它来与服务器建立连接。如果一切顺利,这两个程序现在可以通过它们之间运行的套接字连接传输消息。
但说得够多了。让我们现在尝试连接两个程序,亲自看看魔法是如何发生的!
## 通过套接字发送消息
首先,在你的计算机上打开两个 CLISP 副本,分别在两个不同的控制台窗口中。我们将一个称为客户端,另一个称为服务器。(或者,如果你在一个网络上有两台计算机并且知道它们的 IP 地址,你可以在两个不同的机器上创建两个控制台,以获得完整的网络体验。)
### 注意
你 *必须* 使用 CLISP 来运行本章中显示的套接字代码。
在服务器上,通过调用 `socket-server` 来控制一个端口:
(defparameter my-socket (socket-server 4321));ON THE SERVER
MY-SOCKET
此命令获取端口 4321 并将套接字绑定到它,使用操作系统。套接字绑定到 `my-socket` 变量,这样我们就可以与之交互。
此命令有些危险,因为操作系统期望我们在完成套接字后放弃它。如果我们不这样做,就没有人能够再使用这个套接字了。实际上,如果你在套接字练习中犯了任何错误,你可能会弄乱端口 4321 上的套接字,然后你需要切换到另一个端口号,直到你重新启动计算机。(在下一章中,你将学习如何使用 Common Lisp 的异常处理系统来绕过这些丑陋的问题。)
接下来,让我们从这个套接字(仍然在服务器上)创建一个流,该流处理来自单个客户端的连接:
(defparameter my-stream (socket-accept my-socket));ON THE SERVER
运行此命令后,服务器似乎会锁定,并且你不会返回到 REPL 提示符。不要惊慌——`socket-accept` 命令是一个 *阻塞操作*,这意味着函数不会退出,直到客户端已连接。
现在切换到你的客户端 CLISP,并使用 `socket-connect` 命令连接到服务器上的那个套接字:
(defparameter my-stream (socket-connect 4321 "127.0.0.1"));ON THE CLIENT
MY-STREAM
IP 地址 127.0.0.1 是一个特殊的地址,它始终指向调用它的计算机。如果你为这个练习使用了两台不同的计算机,你应该输入服务器的实际 IP 地址。
运行此命令后,服务器将解锁,`my-stream` 变量的值将被设置。我们现在在 CLISP 的两个副本中都有一个打开的流,我们可以用它来在它们之间进行通信!
CLISP 在这里创建的流被称为 *双向* 流。这意味着它可以作为输入流和输出流,我们可以使用任何一组命令与之通信,以双向进行通信。让我们在客户端和服务器之间发送一个友好的问候。
在客户端输入以下内容:
(print "Yo Server!" my-stream)
"Yo Server!"
在服务器上输入以下内容:
(read my-stream)
"Yo Server!"
然后,仍然在服务器上,输入以下内容:
(print "What up, Client!" my-stream)
"What up, Client!"
回到客户端,运行以下命令:
(read my-stream)
"What up, Client!"
当你完成时,你的两个 CLISP 窗口应该看起来像这样:

我们通过套接字发送的消息是一个 Lisp 字符串,但由于 Lisp 优雅的流处理能力,我们可以以相同的方式发送几乎任何标准的 Lisp 数据结构,而无需任何额外的工作!
## 整理我们的工作
在这个练习中,我们创建的资源释放至关重要。首先,在客户端和服务器上运行以下命令来关闭两端的流:
(close my-stream)
T
接下来,在服务器上运行 `socket-server-close` 命令来释放端口,并断开与该端口的套接字连接。如果不这样做,端口 4321 将无法使用,直到你重新启动。
(socket-server-close my-socket)
NIL
# 字符串流:怪异类型
流通常用于从 Lisp 程序内部与外部世界通信。一个例外是字符串流,它只是让字符串看起来像流。就像你可以使用其他类型的流读取或写入外部资源一样,字符串流将允许你读取或写入字符串。
你可以使用 `make-string-output-stream` 和 `make-string-input-stream` 命令创建字符串流。以下是一个使用 `make-string-output-stream` 的示例:
(defparameter foo (make-string-output-stream))
(princ "This will go into foo. " foo)
(princ "This will also go into foo. " foo)
(get-output-stream-string foo)
"This will go into foo. This will also go into foo. "
你可能想知道为什么有人会想要做这件事,因为我们已经可以直接在 Lisp 中操作字符串,而不需要使用流。实际上,使用字符串流这种方式有几个很好的理由。它们在调试时很有用,而且可以有效地创建复杂的字符串。
## 将流发送到函数
使用字符串流允许我们使用需要流作为参数的函数。这对于仅使用字符串作为数据输入和输出的文件或套接字代码的调试来说非常棒。
例如,假设我们有一个`write-to-log`函数,它将日志信息写入流。通常,我们希望将日志信息发送到文件流,以便将其写入文件以进行安全存储。然而,如果我们想调试该函数,我们可能希望将其发送到字符串流,这样我们就可以查看它写入的数据并确保其正确性。如果我们将`write-to-log`函数硬编码为仅写入文件,我们就不会有这种灵活性。这就是为什么在可能的情况下,编写使用流这一抽象概念的功能函数而不是使用其他方法访问外部资源是有意义的。
## 处理长字符串
当处理非常长的字符串时,字符串流可以使代码的性能更好。例如,将两个字符串连接起来可能是一个昂贵的操作——首先,它需要一个新块内存来存储两个字符串,然后字符串需要被复制到这个新位置。由于这个瓶颈,许多编程语言使用称为*字符串构建器*的设备来避免这种开销。在 Lisp 中,我们可以通过使用字符串流获得类似性能的好处。
## 阅读和调试
使用字符串流的另一个原因是它们可以使我们的代码更容易阅读和调试,尤其是在我们使用`with-output-to-string`宏时。

这里有一个使用此命令的例子:
(with-output-to-string (*standard-output*)
(princ "the sum of ")
(princ 5)
(princ " and ")
(princ 2)
(princ " is ")
(princ (+ 2 5)))
"the sum of 5 and 2 is 7"
`with-output-to-string`宏将拦截任何本应输出到控制台、REPL 或其他输出流的文本,并将其捕获为字符串。在前面的例子中,`with-output-to-string`调用体内的`princ`函数创建的输出被自动重定向到字符串流中。一旦`with-output-to-string`命令的主体完成,整个放入流中的打印输出将作为结果返回。
你也可以使用`with-output-to-string`宏通过“打印”每个部分来轻松构建复杂的字符串,然后将结果捕获为字符串。这通常比使用`concatenate`命令更加优雅和高效。
### 注意
使用`with-output-to-string`与函数式编程的原则(在第十四章中讨论)相悖。一些 Lisper 认为这个函数(以及拦截输入或输出到其他目的地的类似函数)是一种丑陋的修补。你会在 Lisp 社区中看到一些关于`with-output-to-string`的使用是优雅的还是丑陋的争议。
# 你学到了什么
本章介绍了如何使用流使您的 Lisp 程序与外部资源交互。你学习了以下内容:
+ 不同类型的流与不同类型的资源交互。这些包括*控制台流*、*文件流*、*套接字流*和*字符串流*。
+ 流可以根据其方向进行分类。*输出流*让我们可以向资源写入。*输入流*让我们可以从资源读取。
+ 套接字流允许计算机程序通过网络进行通信。为了建立套接字流,我们首先需要在两端打开套接字,并在程序之间建立一个套接字连接。
+ 字符串流允许我们在调试时使用需要流的功能,而不需要链接到外部资源。它们也通过使用 `with-output-to-string` 来有效地构建复杂字符串,并且优雅。
# 第十三章。让我们创建一个 Web 服务器!
在第六章中,你学习了如何通过向 REPL 发送和接收文本与用户交互。然而,当人们现在谈论“与用户交互”时,他们通常指的是 Web 上的用户。在本章中,你将学习如何通过从头开始构建 Web 服务器来与 Web 用户交互。由于网络通信本质上容易出错,你将首先学习 Lisp 中如何处理错误。
# Common Lisp 中的错误处理
任何时候你与外界交互,就像我们的 Web 服务器将要做的那样,都可能发生意外的事情。无论现代计算机网络多么聪明,它都无法预见到每一个可能出现的异常情况。毕竟,即使是最聪明的网络也无法从某个笨蛋绊倒在错误电缆上而恢复过来。
Common Lisp 提供了一套非常广泛的功能来处理代码中意外异常情况。这个异常处理系统非常灵活,它可以用来做其他大多数语言中异常系统不可能做到的事情。
## 发出条件信号
如果你正在编写一个函数,并且出了严重的错误,Lisp 函数可以通知 Lisp 环境,已经遇到了问题。这是通过*发出一个条件信号*来完成的。可能会发生什么问题呢?也许一个函数尝试除以零。或者也许库函数接收到了错误类型的参数。或者也许是因为你绊倒了网络电缆,套接字通信被中断了。
如果你想要直接发出一个条件信号,你可以使用 `error` 命令。如果你写的函数自己检测到问题——一个严重到程序无法正常继续的问题,你会这样做。使用 `error` 命令将中断你的运行中的 Lisp 程序,除非你在其他地方拦截错误以防止中断。让我们发出一个条件信号,并打印消息“foo”来描述错误:
(error "foo")
*** - foo
The following restarts are available:
ABORT :R1 Abort main loop
如您所见,发出这个条件会导致 Lisp 中断我们的程序,打印消息“foo”,并在 REPL 中显示错误提示。(在 CLISP 中,您可以在此时输入 **`:a`** 来终止程序并返回到正常的 REPL。)
大多数情况下,当您的程序发出条件时,这很可能不是因为您自己调用了 `error`。相反,这可能是由于您的程序有错误,或者您调用了库函数,而这个函数发出了一个条件。然而,任何阻止程序正常执行并导致条件的事情,您的程序都会停止并显示一个类似于前面示例的错误提示。
## 创建自定义条件
在我们的第一个例子中,我们将描述条件的字符串传递给了 `error` 命令。然而,这个文本字符串只是自定义了错误信息,并不会导致不同“类型”的条件。Common Lisp 也允许你拥有各种类型的条件,这些条件可以用不同的方式处理。
信号条件的一种更复杂的方式是首先使用 `define-condition` 定义一个自定义条件,如下面的示例所示:
(define-condition foo () ()
(:report (lambda (condition stream)
(princ "Stop FOOing around, numbskull!" stream))))
这是一个创建新类型条件的典型例子,我们将其命名为 `foo`。当这个条件被发出时,我们可以提供一个自定义函数,该函数将被调用以报告错误。在这里,我们声明了一个 lambda 函数来完成这个目的。在 lambda 函数内部,我们打印一个自定义消息来报告错误。
让我们看看触发这个新条件会发生什么:
(error 'foo)
*** - Stop FOOing around, numbskull!
The following restarts are available:
ABORT :R1 Abort main loop
如您所见,我们的自定义消息已被打印出来。这种技术允许程序员获得一个更有意义的错误报告,该报告针对触发特定条件的具体情况进行了定制。
## 拦截条件
当我们使用 `define-condition` 创建一个条件时,它会赋予一个名称(例如 `foo`)。这个名称可以被程序的高级部分用来拦截和处理该条件,这样就不会停止程序的执行。我们可以通过 `handler-case` 命令来实现,如下所示:
(defun bad-function ()
(error 'foo))
BAD-FUNCTION
(handler-case (bad-function)
(foo () "somebody signaled foo!")
(bar () "somebody signaled bar!"))
"somebody signaled foo!"
在 `handler-case` 命令中,我们首先放入的是可能会发出我们想要处理的条件的代码片段。
在这个例子中,我们正在观察的代码是对 `bad-function` 的调用。`handler-case` 的其余部分允许我们指定在特定条件发生时要执行的操作 ![http://atomoreilly.com/source/no_starch_images/783562.png]。当这段代码运行时,`bad-function` 通过调用 `(error 'foo)` 触发 `foo` 条件。通常,这会导致我们的程序中断,并在 REPL 中出现错误提示。然而,我们的 `handler-case` 命令拦截了 `foo` 条件 ![http://atomoreilly.com/source/no_starch_images/783562.png]。这意味着程序可以继续运行而不会中断,`handler-case` 评估为“有人发出了 foo 信号!” ![http://atomoreilly.com/source/no_starch_images/783560.png]。
## 防止资源受到意外条件的影响
当程序中发生意外异常时,总有可能导致程序崩溃,甚至可能对程序外的资源造成损害。异常会中断代码的正常流程,并且它们可能会在代码进行敏感操作时立即停止。
例如,当程序遇到意外异常时,它可能正在向文件或套接字流写入数据。在这种情况下,程序有机会关闭文件/套接字流并释放文件句柄或套接字至关重要;否则,该资源可能会无限期地锁定。如果这些资源没有得到适当的清理,用户可能需要首先重新启动计算机,资源才能再次可用。
`unwind-protect` 命令可以帮助我们避免这些问题。使用这个命令,我们可以告诉 Lisp 编译器,“无论发生什么,这段代码都必须运行。”考虑以下示例:
(unwind-protect (/ 1 0)
(princ "I need to say 'flubyduby' matter what"))
*** - /: division by zero
The following restarts are available:
ABORT :R1 Abort main loop
:r1
I need to say 'flubyduby' matter what
在 `unwind-protect` 中,我们进行除以 0 的操作,这会触发一个条件 ![http://atomoreilly.com/source/no_starch_images/783564.png]。即使我们告诉 CLISP 终止程序,程序仍然会打印出其关键信息 ![http://atomoreilly.com/source/no_starch_images/783562.png]。
我们通常可以通过依赖 Common Lisp 的“`with-`”宏来避免直接调用 `unwind-protect`;其中许多宏在底层会自己调用 `unwind-protect`。在第十六章中,我们将创建自己的宏来了解这是如何实现的。
### 注意
在书的漫画书尾声中,你将了解 Common Lisp 信号系统的一个附加功能,称为 *restarts*。
# 从零开始编写 Web 服务器
现在你已经对套接字(在第十二章[Working with Streams]中介绍)和错误处理有了基本的了解,你已经有足够的知识来创建一个能够服务用 Lisp 编写的动态网页的 Web 服务器。毕竟,为什么 Apache(世界上最受欢迎的 Web 服务器)要独占所有乐趣呢?
## Web 服务器是如何工作的
超文本传输协议,或 HTTP,是用于传输网页的互联网协议。它在一旦建立了套接字连接后,在 TCP/IP 之上添加了一层,用于请求页面。当运行在客户端计算机上的程序(通常是网络浏览器)发送一个正确编码的请求时,服务器将检索请求的页面并通过套接字流发送它作为响应。

### 注意
这个网络服务器是从由 Ron Garret 创建的 *http.lisp* 中改编而来的。
例如,假设客户端是 Firefox 网络浏览器,并且请求 *lolcats.html* 页面。客户端的请求可能看起来像这样:
GET /lolcats.html HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.5)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
对于我们的网络服务器来说,这个请求最重要的部分是第一行。在那里我们可以看到所进行的请求类型(一个 `GET` 请求,这意味着我们只想查看页面而不修改它),以及请求的页面名称 (*lolcats.html*)。发送到服务器的这些数据被称为 *请求头*。你将在稍后看到,可以在请求头下方发送更多信息到服务器,在 *请求体* 中。
### 注意
对于来自遥远未来的读者,*lolcats* 是第三千年早期的一个病毒式互联网现象。它涉及带有有趣标题的猫的图片。如果你们那个时代的人们不再熟悉 lolcats,这并不会造成太大的损失。
作为响应,服务器将通过套接字流发送一个表示网页的 HTML 文档。这被称为 *响应体*。以下是一个响应体的示例:
Sorry dudez, I don't have any L0LZ for you today :-(
```
HTML 文档被包裹在 html 开头
和结尾标签
之间。在这些标签内,你可以声明一个主体部分
。在主体部分,你可以写入将在网络浏览器中作为网页主体的文本消息!。
对于一个完全符合 HTML 规范的网页,文档中必须存在其他项目,例如 DOCTYPE 声明。然而,我们的示例将正常工作,并且我们可以忽略这些技术细节以进行简单的演示。
网络服务器通常还会生成一个 响应头。这个头可以给网络浏览器提供有关它刚刚接收到的文档的额外信息,例如它是否是 HTML 或其他格式。然而,我们将要创建的简化版网络服务器不会生成这样的头,而是简单地返回一个主体。
注意
由于我们使用的是 CLISP 特定的套接字命令,你必须运行 CLISP 才能使本章中提供的示例网络服务器正常工作。
请求参数
网络表单是使网站运行的基本元素。例如,假设我们为网站创建一个简单的登录表单。

当网站访客点击此页面的提交按钮后,它将向网站发送一个POST请求。POST请求看起来与前面示例中的GET请求非常相似。然而,POST请求通常意味着它可能会更改服务器上的数据。
在我们的示例登录表单中,我们需要告诉服务器用户 ID 和密码,这是访客在此表单的文本字段中输入的。作为POST请求一部分发送到服务器的这些字段的值被称为请求参数。它们通过在请求头下方附加到请求体中发送。
这就是我们的登录示例中可能出现的POST请求:
POST /login.html HTTP/1.1
Host: www.mywebsite.com
User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.5)
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 300
Connection: keep-alive
`Content-Length: 39`
`userid=foo&password=supersecretpassword`
这个POST请求头部的额外参数Content-Length表示请求底部参数数据的长度。具体来说,Content-Length: 39告诉服务器包含请求参数的文本(如![http://www.example.com/source/nostarch/images/783564.png]所示)是 39 个字符长。
GET请求的请求参数
正如我们之前讨论的,请求参数的典型用途是在POST请求期间将网页表单数据发送回服务器。然而,GET请求也可能包含请求参数。通常,在GET请求中,我们希望查看请求 URL 中的参数,而在POST请求中,参数则隐藏在请求体中。
例如,假设你访问 Google 并搜索“dogs”。在这种情况下,后续页面将有一个类似www.google.com/search?q=dogs&hl=en&safe=off&的 URL。URL 中的这些值(如表示[q]uery=“dogs”的那个)也是请求参数。
我们正在创建的 Web 服务器需要允许服务器代码访问这两种类型的请求参数:请求体中的那些(这在POST请求中很常见)以及出现在 URL 中的那些(这在GET请求中很常见。)
解析请求参数的值
HTTP 使用一种特殊的方式来表示用户可能输入到表单中的非字母数字字符,即使用HTTP 转义码。这些转义码允许你在请求参数的值中使用那些在 HTTP 格式中通常不可用的字符。例如,如果用户输入"foo?",它将在请求中显示为"foo%3F",因为问号是用转义码表示的。我们的 Web 服务器需要解码这些转义字符,因此我们将编写的第一个函数是decode-param:
(defun http-char (c1 c2 &optional (default #\Space))
(let ((code (parse-integer
(coerce (list c1 c2) 'string)
:radix 16
:junk-allowed t)))
(if code
(code-char code)
default)))
(defun decode-param (s)
(labels ((f (lst)
(when lst
(case (car lst)
(#\% (cons (http-char (cadr lst) (caddr lst))
(f (cdddr lst))))
(#\+ (cons #\space (f (cdr lst))))
(otherwise (cons (car lst) (f (cdr lst))))))))
(coerce (f (coerce s 'list)) 'string)))
注意
我们在这里讨论的 HTTP 转义码与我们在这本书的其他部分讨论的 Lisp 字符串中的转义字符无关。
首先,这个函数定义了一个名为f的局部函数!
,我们将使用它来递归处理字符。为了使这个递归工作,我们需要使用coerce将字符串转换为字符列表!
,然后将这个列表传递给f。
f函数检查列表中的第一个字符,看它是否是一个百分号(%)或一个加号(+)。如果是百分号,我们知道列表中的下一个值是一个 ASCII 码,表示为十六进制数。(ASCII 码是一组标准数字,对应于文本字符,在许多计算机系统和应用程序中共享。)
为了解码这个 ASCII 码,我们创建了一个名为http-char的函数!
。在这个函数中,我们使用parse-integer函数将这个字符串转换为整数!
。在这种情况下,我们在parse-integer:上使用了一些关键字参数::radix参数告诉函数解析一个十六进制数,而:junk-allowed参数告诉它在给出无效数字时只返回 nil,而不是发出错误信号。
然后,我们使用code-char函数将这个整数(它包含 ASCII 码)转换为用户实际输入的实际字符。
根据 HTTP 编码的规则,如果请求参数中的值包含一个加号,它应该被转换为空格字符。我们在这里进行这个转换!
。
任何其他字符都会通过f函数保持不变。然而,我们仍然需要调用f函数处理列表的其余部分,直到所有字符都被处理!
。
这里有一些decode-param函数的示例:
> `(decode-param "foo")`
"foo"
> `(decode-param "foo%3F")`
"foo?"
> `(decode-param "foo+bar")`
"foo bar"
解码请求参数列表
服务器接下来需要做的事情是解码一系列参数,这些参数将以字符串形式给出,例如 "name=bob&age=25&gender=male"。正如我们之前讨论的,网页的 URL 通常在末尾包含这样的名称/值对。正如你所看到的,这个字符串表明我们在网页上寻找的人的名字是 bob,年龄是 25 岁,性别是男性。这些名称/值对由一个和号(&)分隔。这些字符串的结构与关联列表(alist)的结构相当,因此我们将使用以下函数将这些参数存储为一个 alist:
(defun parse-params (s)
(let* ((i1 (position #\= s))
(i2 (position #\& s)))
(cond (i1 (cons (cons (intern (string-upcase (subseq s 0 i1)))
(decode-param (subseq s (1+ i1) i2)))
(and i2 (parse-params (subseq s (1+ i2))))))
((equal s "") nil)
(t s))))
parse-params 函数通过使用 position 函数找到字符串中第一个出现的 & 和 = 符号
。如果找到一个名称/值对(如果字符串中找到了等号并且存储在 i1 中,我们知道这是真的),我们使用 intern 函数将名称转换为 Lisp 符号
。然后我们使用 cons 函数将这个名称连接到参数的值上,我们使用我们的 decode-param 函数来解码这个值
。最后,我们递归地调用 parse-params 函数处理字符串的剩余部分
。
让我们尝试我们的新 parse-params 函数:
> `(parse-params "name=bob&age=25&gender=male")`
((NAME . "bob") (AGE . "25") (GENDER . "male"))
将这些数据放入 alist 中将允许我们的代码在需要时轻松引用特定的变量。
注意
decode-param 和 parse-params 如果使用尾调用编写,可能会实现更高的性能,我们将在第十四章(第十四章. 使用函数式编程提升 Lisp 的层次)中讨论。
解析请求头
接下来,我们将编写一个函数来处理请求头的第一行。(这将是看起来像 GET /lolcats.html HTTP/1.1 这样的行)。
以下 parse-url 函数将处理这些字符串:
(defun parse-url (s)
(let* ((url (subseq s
(+ 2 (position #\space s))
(position #\space s :from-end t)))
(x (position #\? url)))
(if x
(cons (subseq url 0 x) (parse-params (subseq url (1+ x))))
(cons url '()))))
这个函数首先使用字符串的分隔空格来找到并提取 URL
。然后它检查这个 URL 是否包含问号,这可能表明需要处理请求参数
。例如,如果 URL 是 *lolcats.html?extra-funny=yes*,那么问号让我们知道在 URL 中有一个名为 *extra-funny* 的参数。如果存在这样的参数,我们需要提取它们,然后使用我们的 parse-params 函数来解析它们
。如果没有请求参数,我们只需返回 URL
。注意,这个函数会跳过请求方法(通常是 GET 或 POST)。更高级的 Web 服务器会提取这个数据点。
让我们尝试我们的新 URL 提取器:
> `(parse-url "GET /lolcats.html HTTP/1.1")`
("lolcats.html")
> `(parse-url "GET /lolcats.html?extra-funny=yes HTTP/1.1")`
("lolcats.html" (EXTRA-FUNNY . "yes"))
现在我们能够读取第一行,我们将处理请求的其余部分。以下 get-header 函数将把请求的剩余行转换为漂亮的 alist:
(defun get-header (stream)
(let* ((s (read-line stream))
(h (let ((i (position #\: s)))
(when i
(cons (intern (string-upcase (subseq s 0 i)))
(subseq s (+ i 2)))))))
(when h
(cons h (get-header stream)))))
这个函数从流
中读取一行,根据冒号
的位置将其转换为键/值对,然后递归地转换头部中的附加行
。如果它遇到不符合头部行的行,这意味着我们已经到达了头部的空行,并且完成了。在这种情况下,i 和 h 都将是 nil,函数终止。
在生成上述键时使用的 intern 命令是一个简单的函数,它将字符串转换为符号。我们也可以使用 read 命令来完成这个目的,就像我们在本书中之前做的那样。但请记住,read 命令的灵活性也使其成为黑客攻击的绝佳目标,他们可能会尝试创建格式不正确的头部来破解你的网络服务器。这就是为什么使用更有限、更具体的 intern 函数来处理发送到我们网络服务器的互联网数据是明智的。
使用字符串流测试 get-header
由于 get-header 函数直接从套接字流中获取其数据,你可能会认为我们无法直接通过 REPL 测试它。然而,正如你在上一章中看到的,除了套接字之外,还有几种不同类型的资源可以通过 Common Lisp 的流接口访问。由于流之间的通用接口,我们可以通过传递字符串流而不是套接字流来测试我们的 get-header 函数:
> `(get-header (make-string-input-stream "foo: 1`
`bar: abc, 123`
`"))`
((FOO . "1") (BAR . "abc, 123"))
使用 make-string-input-stream 函数,我们可以从字面字符串创建一个输入流。在这个例子中,我们取一个定义了两个键(foo 和 bar)并以其一个空行结束的字符串,就像一个典型的 HTTP 头部。注意,我们有一个从
到
的单个字面字符串。这样的字符串在 Common Lisp 中是允许的。正如你所见,get-header 函数适当地从这个流中提取了两个键及其值,就像它会从套接字流中提取这些值一样。
使用这个技巧,你可以直接从 REPL 测试操作流的函数。要做到这一点,只需用字符串流替换其他更复杂的流类型。
解析请求体
在 POST 请求中,通常会在头部下方存储参数,在称为 请求体 或 请求内容 的区域。以下 get-content-params 函数提取这些参数:
(defun get-content-params (stream header)
(let ((length (cdr (assoc 'content-length header))))
(when length
(let ((content (make-string (parse-integer length))))
(read-sequence content stream)
(parse-params content)))))
首先,这个函数在头部搜索一个名为content-length的值
,它告诉我们包含这些内容参数的字符串的长度。如果存在content-length,那么我们知道有参数需要解析
。然后,该函数将使用make-string
创建一个给定长度的字符串,并使用read-sequence
将流中的字符填充到该字符串中。然后,它将结果通过我们的parse-params函数运行,将参数转换为我们的清理后的 alist 格式
。
我们的大结局:服务函数!
现在所有的部件都已经就位,我们可以编写我们网络服务器的心脏部分:serve函数。以下是它的全部辉煌:
(defun serve (request-handler)
(let ((socket (socket-server 8080)))
(unwind-protect
(loop (with-open-stream (stream (socket-accept socket))
(let* ((url (parse-url (read-line stream)))
(path (car url))
(header (get-header stream))
(params (append (cdr url)
(get-content-params stream header)))
(*standard-output* stream))
(funcall request-handler path header params))))
(socket-server-close socket))))
serve函数接受一个单一参数:request-handler
,这是想要使用这个网络服务器的网站创建者提供的。当服务器通过网络接收到请求时,它会将请求解析成干净的 Lisp 数据结构(使用我们在本章中讨论过的函数),然后将这个请求信息传递给request-handler。然后request-handler显示正确的 HTML。

让我们详细看看我们的serve函数,看看它是如何完成这个任务的。
首先,serve创建一个绑定到 8080 端口的套接字
。这是几个常用端口之一,用于提供网页服务,尤其是在网站仍在开发阶段时。 (80 端口通常用于生产网站/服务器。)然后我们调用unwind-protect
,这确保无论服务器运行过程中发生什么,socket-server-close都会在某个时刻被调用以释放套接字。
接下来,我们启动主要的网络服务循环。在这个循环中,我们为访问我们服务器的任何客户端打开一个流
。然后我们使用with-open-stream宏来保证,无论发生什么,那个流都将被正确关闭。现在我们准备好读取和解析客户端对我们服务器提出的网站请求,使用我们创建的所有读取和解析函数
。
最后,我们调用request-handler函数,传入请求详情
。注意我们事先重新定义了*standard-output*动态变量。这意味着请求处理器可以直接写入标准输出,所有打印的数据将自动重定向到客户端流。正如你在第十二章中学到的,从标准输出捕获数据可以让我们最小化字符串连接。此外,它还将使我们的request-handler函数更容易调试,正如你很快就会看到的。
注意
我们没有在我们的 Web 服务器中做的一件事是防止request-handler触发异常时 Web 服务器崩溃。相反,我们只是保证在异常情况下不会损坏任何资源。我们很容易添加额外的异常处理来确保即使在发生可怕的异常的情况下,服务器也能继续运行。然而,由于我们的目标是学习 Lisp 并在浏览器中开发游戏,最好是立即了解任何异常,即使那会导致我们的服务器崩溃。
构建动态网站
为了尝试我们闪亮的新 Web 服务器,让我们构建一个简单的网站,该网站使用简单的函数hello-request-handler来问候访客:
(defun hello-request-handler (path header params)
(if (equal path "greeting")
(let ((name (assoc 'name params)))
(if (not name)
(princ "<html><form>What is your name?<input name='name' />
</form></html>")
(format t "<html>Nice to meet you, ˜a!</html>" (cdr name))))
(princ "Sorry... I don't know that page.")))
这个hello-request-handler函数只支持一个网页,称为greeting。提供这个greeting页面的第一步是查看这个页面是否确实是客户端请求的
。如果不是,我们向用户打印一个道歉,因为我们没有找到指定的页面
。否则,我们检查请求参数,看我们是否知道用户的姓名
。如果不知道,我们要求用户使用网页表单输入用户名
。如果我们确实知道用户的姓名,我们将热情地问候访客
。
注意
我们在构建我们的 Web 服务器和这个原始网站时采用了许多捷径。例如,发送给客户端的任何 HTML 都应该包裹在一个合适的 HTML 骨架中,例如<html><body>...</body></html>。然而,即使如此,我们的页面也不会完全符合现代 HTML 标准。此外,当客户端请求一个不存在的页面时,适当的响应是显示 404 错误页面,而不仅仅是打印一个礼貌的道歉。幸运的是,网络浏览器对这样的捷径非常宽容,它们仍然会显示我们的简化响应。
测试请求处理器
在我们启动新网站之前,让我们通过首先查看有关 lolcats 的页面来在 REPL 中测试我们的hello-request-handler:
> `(hello-request-handler "lolcats" '() '())`
Sorry... I don't know that page.
完美。正如你所看到的,当我们向请求处理器请求除greeting页面之外的页面时,它只是打印出一个道歉。现在让我们尝试查看正确的greeting页面:
> `(hello-request-handler "greeting" '() '())`
<html><form>What is your name?<input name='name' /></form></html>
太棒了!我们的请求处理器已生成一个 HTML 表单,要求用户输入用户名。现在让我们为用户名传递一个参数,就像表单已被处理并发送到服务器一样:
> `(hello-request-handler "greeting" '() '((name . "Bob")))`
<html>Nice to meet you, Bob!</html>
由于我们设计 Web 服务器的方式,在 REPL 中独立调试请求处理器非常简单。我们能够看到hello-request-handler生成了正确的响应,而无需实际启动 Web 浏览器。
启动网站
既然我们知道我们的新网站正在运行,让我们启动它!但首先,我们需要确保本章讨论的所有函数都已在一个 CLISP 实例中定义。如果您在阅读时没有将这些函数输入到 REPL 中,您可以将它们全部保存到一个名为webserver.lisp的文件中,然后使用(load "webserver")来加载它们。
一旦你在 CLISP 中定义了你的函数,通过在 REPL 中输入以下内容来启动服务器:
> `(serve #'hello-request-handler)`
就这样!现在你应该能够通过 Web 浏览器访问该网站:


如您所见,当您从浏览器(使用 127.0.0.1:8080,这将指向运行 Web 浏览器的同一台机器上的 8080 端口)访问我们的greeting页面时,您会被要求输入您的名字。然后服务器会显示一个后续页面,通过名字问候您。这表明我们的 Web 服务器能够从请求参数中解析出名字,并将名字传递给我们的hello-request-handler函数。
现在我们已经拥有了一个完全功能的 Web 服务器和请求处理基础设施。在未来的章节中,我们将使用这些工具来创建一个令人惊叹的、图形化的、基于 Web 的游戏。
你学到了什么
在本章中,您使用 Common Lisp 创建了一个 Web 服务器,并在过程中学习了以下内容:
-
您可以使用
error函数在 Common Lisp 中发出条件。您可以使用handle-case命令捕获此类错误。如果某些代码绝对、肯定需要在发生任何错误的情况下调用,您可以将此代码放在unwind-protect命令内部。 -
Web 服务器处理 HTTP 请求。最常见的一种请求是
GET请求,用于查看信息。另一种常见类型是POST请求,用于提交 Web 表单,例如。您可以通过查看请求头来告诉请求的类型、请求的页面以及其他信息。GET和POST请求都可能包含请求参数,这些参数可以出现在请求 URL 的末尾或在请求体的底部。
第 13.5 章. 函数式编程很美



















第四部分:Lisp 是科学

第十四章:使用函数式编程提升 Lisp 的水平
正如你在前面的章节中看到的,Lisp 使得快速编写代码和构建简单的游戏变得非常容易。然而,Lisp 的主要声誉在于作为一种学术工具,适合解决最复杂的科学问题。它也适合黑客攻击,这可以说是附带的好处。
在本书的其余部分,我们将专注于语言的科学方面,探索一些高级技术来构建一个更复杂的游戏,我希望这真的会让你大开眼界。它将做些你可能从未想过在计算机程序中做到的事情。
在本章中,你将学习第一个高级 Lisp 概念,称为 函数式编程技术。在下一章中,我们将使用这项技术来构建一个简单的掷骰子战争游戏,以及一个粗略的人工智能对手来与之对战!
什么是函数式编程?
我们已经在前面章节中讨论了函数式编程的概念。简单地说,函数式编程是“一种编程风格,我们使用函数编写所有的代码。”
然而,当我们在这个上下文中使用术语 函数 时,我们指的是非常具体的东西——与数学家使用这个词时所指的完全相同。那么,当数学家使用这个词时,他们是什么意思呢?
你可能已经知道了答案。试着回忆一下你上代数前的时候。如果你在那个特别的课程中没有睡着,你可能会记得你的老师在大黑板上画了类似这样的东西:

这张图片显示了一个函数有可以输入的参数,称为函数的 定义域。然后函数将这些参数取走并返回一个值。这个值被称为位于函数的 值域 内。
注意
一些高级 Lisp 程序员可能会对有人说函数“返回值”感到不适。这是因为 Lisp 源自一种称为 λ-演算 的东西,这是一种在 1930 年代由 Alonzo Church 开发的基本的编程类似代数。在 λ-演算中,你通过在起始程序上执行替换规则来“运行”一个程序,以确定函数的结果。因此,一组函数的结果通过执行替换规则神奇地出现;函数永远不会“有意识地”决定返回一个值。
因此,Lisp 纯粹主义者更喜欢说一个函数“评估为结果”。然而,在编程世界的几乎每个人喜欢说函数返回一个值。这取决于你决定哪种关于函数的思考方式感觉最自然。
这里有一些数学函数的重要属性,我们希望我们的 Lisp 函数也遵守:
-
只要传入相同的参数,函数总是返回相同的结果。(这通常被称为引用透明性。)
-
函数永远不会引用函数外部定义的变量,除非我们确定这些变量将保持不变。
-
函数不会修改(或如函数式程序员喜欢说的变异)任何变量。
-
函数的目的是除了返回结果之外不做任何事情。
-
函数不会做任何外界可见的事情,例如在屏幕上弹出对话框或让计算机发出“Bing!”的声音。
-
函数不会从外部来源获取信息,例如键盘或硬盘。
如果我们尽可能遵守这些规则,我们可以说我们的代码是以函数式风格编写的。
真正的数学函数的一个很好的例子是正弦函数。同样,Lisp 中的sin函数(计算数学正弦)是 Lisp 函数遵守函数式风格的绝佳例子:
> `(sin 0.5)`
0.47942555
sin函数只要传入相同的参数(在这种情况下,0.5)就会始终返回相同的结果。它不会做任何与外界交互的事情。它存在的全部目的就是返回正弦值。它遵守前面列出的所有规则。
显然,在计算机程序中用函数式风格编写所有代码是不可能的。例如,其中一条规则规定计算机不允许发出“Bing!”的声音——如果计算机偶尔不发出“Bing!”的声音,谁会想使用它呢?

每当一段代码做了一些外界可见的事情,例如发出“Bing!”的声音或在屏幕上显示对话框时,我们就说这段代码引起了副作用。函数式程序员认为这样的副作用会使你的代码“不洁”。
这种包含副作用的不洁代码的术语是命令式代码。术语命令式意味着代码是以“食谱”风格编写的,你基本上会说“首先做这个,然后做那个。”就像食谱一样,命令式代码中的大多数行都会执行副作用,例如写入屏幕或修改全局变量。命令式代码与函数式代码相反。
这引导我们到函数式编程的核心哲学。它指出你应该将你的程序分成两部分:
-
第一部分,也是最大的一部分,应该是完全函数式且无副作用的。这是你程序中的干净部分。
-
程序的第二部分,较小的一部分,是包含所有副作用的部分,与用户和外界交互。这段代码是不洁的,应该尽可能保持最小。
如果一段代码弹出一个对话框,例如,我们认为它是脏的,并将其放入我们代码的命令式部分。像对话框这样的东西并不真的是数学,我们不应该让它们与我们的数学函数和其他干净、功能性的代码互动。

函数式风格编写的程序结构
现在我们已经讨论了如何进行函数式编程,让我们写一个简单的程序,遵循这种风格。由于我们希望这个程序成为大多数软件的典型例子,我们应该弄清楚世界上大多数软件实际上做什么。那么,世界上大多数程序实际上做什么呢?它们跟踪小部件!

下面是我们的整个示例程序,用函数式风格编写的:
;the clean, functional part
(defun add-widget (database widget)
(cons widget database))
;the dirty, nonfunctional part
(defparameter *database* nil)
(defun main-loop ()
(loop (princ "Please enter the name of a new widget:")
(setf *database* (add-widget *database* (read)))
(format t "The database contains the following: ˜a˜%" *database*)))
正如承诺的那样,它分为两部分:干净部分和脏部分。我确实说过程序的干净部分应该比脏部分大得多。然而,由于这个例子非常简短,脏部分最终变得稍微大一些。通常,你可以预期干净部分大约占实际代码的 80%。
注意
一些编程语言甚至比 Lisp 更专注于函数式编程。例如,Haskell 有强大的功能,让你可以用函数式风格编写 99.9% 的代码。然而,最终,你的程序仍然需要某种形式的副作用;否则,你的代码无法完成任何有用的任务。
那么,我们的示例程序做什么呢?嗯,它基本上做了世界上大多数计算机程序设计要做的:它在数据库中跟踪小部件!
这个例子中的数据库非常原始。它只是一个存储在全局变量 *database* 中的 Lisp 列表。由于数据库一开始是空的,我们初始化这个变量并将其设置为空 ![http://atomoreilly.com/source/nostarch/images/783560.png]。
我们可以通过调用函数 main-loop 来开始跟踪一些小部件 ![http://atomoreilly.com/source/nostarch/images/783554.png]。这个函数只是启动一个无限循环,询问用户小部件的名称 ![http://atomoreilly.com/source/nostarch/images/783510.png]。然后,在读取小部件信息后,它调用 add-widget 函数将新小部件添加到数据库中 ![http://atomoreilly.com/source/nostarch/images/783544.png]。
然而,add-widget 函数
位于代码的干净部分。这意味着它是功能性的,不允许直接修改 *database* 变量。像所有功能性代码一样,add-widget 函数只能返回一个新值,不能做更多的事情。这意味着它唯一能够“添加”小部件到数据库的方式是返回一个全新的数据库!它是通过简单地接受传递给它的数据库,然后将小部件连接到数据库来创建一个新的数据库
。新的数据库与之前的数据库完全相同,只是现在列表的最前面增加了一个新的小部件。
想想这听起来有多疯狂。想象一下,我们正在运行一个包含数百万个小部件的 Oracle 数据库服务器:

然后,当我们添加一个新的小部件时,数据库服务器通过创建一个全新的数据库副本来完成这个任务,这个副本与之前的数据库唯一的不同之处在于增加了一个新的项目:

这将非常低效。然而,在我们的小部件示例中,事情并没有看起来那么糟糕。确实,每次调用 add-widgets 函数时,都会创建一个新的小部件列表,并且重复调用此函数会使列表越来越长。然而,由于每个新的小部件只是简单地添加到列表的前面,结果发现小部件列表的尾部与列表的上一版本完全相同。因此,add-widget 函数在创建新列表时可以通过简单地连接一个新小部件到列表的前面,并将旧列表作为尾部来保存其余项目来“欺骗”
。这允许以快速的方式创建新列表,并且只需要分配非常少的内存。实际上,add-widget 分配的唯一新内存是一个新的连接单元,用于将新小部件连接到之前的列表。
在创建新数据结构时这种欺骗行为是使高效功能性编程成为可能的关键技术。此外,由于功能性编程的一个基本原则是永远不要修改旧数据,因此结构共享可以安全地进行。
因此,我们的 add-widget 函数为我们创建了一个包含额外项目的数据库。在代码的脏部分,main-loop 函数将全局的 *database* 变量设置为这个新数据库。这样,我们就间接地在两步中修改了数据库。
-
add-widget函数,这个程序的大脑,为我们生成了一个更新的数据库。 -
负责脏活累活的
main-loop函数修改了全局的*database*变量以完成操作。
这个示例程序展示了用函数式风格编写的 Lisp 程序的基本布局。让我们尝试运行我们的新程序,看看它是如何工作的:
> `(main-loop)`
Please enter the name of a new widget: `Frombulator`
The database contains the following: (FROMBULATOR)
Please enter the name of a new widget: `Double-Zingomat`
The database contains the following: (DOUBLE-ZINGOMAT FROMBULATOR)
...
记住,你可以按 ctrl-C 来退出这个例子中的无限循环。
高阶编程
对于学习用函数式风格编写程序的程序员来说,一个常见的难题是他们发现很难将不同的代码块组合起来执行单个操作。这被称为代码组合。编程语言应该使代码组合变得容易。换句话说,它应该使你能够将不同的代码片段组合起来,共同解决问题。在编写函数式代码时,高阶编程是代码组合的最强大工具,它允许你使用接受其他函数作为参数的函数。
让我们通过一个例子来了解为什么代码组合可能对初学的函数式程序员来说是一个挑战。假设我们想要将以下列表中的每个数字加二:
> `(defparameter *my-list* '(4 7 2 3))`
*MY-LIST*
要做到这一点,我们需要编写代码来遍历列表,以及编写代码来将一个数字加二。这是我们需要的两个组合任务。
使用命令式代码进行代码组合
执行这个任务的一个可能的天真(且命令式)的方法是使用一个loop:
;For demonstration purposes only. A Lisper would not write code like this.
> `(loop for n below (length *my-list*)`
`do (setf (nth n *my-list*) (+ (nth n *my-list*) 2)))`
NIL
> `*my-list*`
(6 9 4 5)
在这里,我们创建了一个变量n,它通过loop遍历列表中的所有项
。然后我们使用setf将列表中位置n的数字加二
。这类似于如果你是 C 程序员可能会编写的代码。尽管它看起来很丑陋,但关于它也有一些积极的东西可以说:
-
这种结构的代码可能非常高效。它节省空间,因为我们不需要为存储新列表分配任何内存(我们只是在修改旧列表,使其中的所有数字都加二)。而且它也可能非常节省时间,如果我们把这个循环改写为在数组上工作而不是在列表上。记住,在列表中找到第n项是慢的。
-
这样编写的代码明显组合了循环
和将一个数字加二
的任务。通过将我们的加法代码放在循环内部,我们将这两个活动组合起来,以完成一个更复杂的任务:将整个数字列表中的数字加二。
然而,命令式方法存在明显的缺点:
-
这会破坏原始列表。如果我们稍后使用
*my-list*变量,并且没有意识到这段代码已经破坏了列表中的原始值,这就会成为一个问题。Lisper 会说,允许*my-list*变量随意修改,使得这个变量成为程序中的隐藏状态。与隐藏状态相关的错误在鼓励命令式编程风格的编程语言中很常见。 -
我们需要创建一个变量
n![http://atomoreilly.com/source/nostarch/images/783564.png] 来跟踪我们在列表中的位置。这使得代码更加臃肿,也增加了更多可能出现错误的地方。我们总是有风险给n赋予错误的值或错误地使用它来访问列表中的项。
使用函数式风格
现在我们来看看如果我们以函数式风格重写这段代码会发生什么。让我们首先像初学者一样写它,不使用高阶编程:
> `(defun add-two (list)`
`(when list`
`(cons (+ 2 (car list)) (add-two (cdr list)))))`
ADD-TWO
> `(add-two '(4 7 2 3))`
(6 9 4 5)
在这里,我们正在创建一个名为 add-two 的函数 ![http://atomoreilly.com/source/nostarch/images/783564.png],它将 2 加到列表前面的数字上,然后递归地调用自身来构建列表的尾部。
这段代码避免了命令式解决方案的许多缺点。它不会破坏原始列表,也不需要我们使用数字索引。不幸的是,它也失去了命令式版本的一个关键好处:不再有明确的界限来区分添加 2 到列表项的代码和遍历列表的代码。这两个活动现在深深地交织在一起,这也是我们需要创建一个特殊的函数 add-two 来使这个解决方案工作的原因。我们失去了以干净的方式组合这两个任务的能力。
高阶编程来拯救
如果我们想以函数式风格编写这个任务的代码,但仍然允许我们的代码可组合,我们需要使用高阶函数。以下是一个经验丰富的 Lisper 如何将 2 加到列表中每个数字上的方法:
> `(mapcar (lambda (x)`
`(+ x 2))`
`'(4 7 2 3))`
(6 9 4 5)
现在我们终于有一个版本的代码是函数式的,并且允许我们组合遍历代码和添加代码。在这里,遍历是通过 mapcar 函数完成的,它是一个高阶函数,因为它将提供的函数应用于列表中的每个成员。添加是通过一个 lambda 函数完成的,它只负责将 2 加到数字上,并且对数字在列表中的事实视而不见。这个例子表明,高阶编程可以让我们编写清晰界定的代码块,然后组合它们,而无需打破函数式风格。
为什么函数式编程很疯狂
我们已经知道为什么函数式编程很疯狂的一个原因:函数式程序实际上不能做任何事情,因为它们不能有 副作用。正如著名的函数式程序员 Simon Peyton Jones 喜欢说的,“没有副作用你能做的只是按下一个按钮,然后看着盒子热一段时间。”(这从技术上讲是不正确的,因为盒子变热本身就是一个副作用。)
我们已经看到,我们可以通过在我们的程序中添加一个“脏”部分来绕过函数式编程的限制,这部分代码与代码的其他部分保持分离,并包含所有我们的命令式代码,这些代码不是函数式风格的。然而,回想一下函数式风格的问题:它可能导致代码效率极低。
性能一直是函数式程序的一个巨大关注点。必须编写不允许修改现有变量值,而只能创建新变量的代码,这可能导致大量的内存复制和内存分配,这可能会使程序速度慢到几乎停止。减轻这种复制和分配的一种方法是在我们程序的不同数据部分之间使用共享结构。
尽管如此,以函数式风格编写的代码具有影响性能的其他属性。例如,函数式代码大量使用递归,而不是循环。使用递归会导致 Lisp 编译器/解释器在程序栈上放置大量项目,这可能会非常慢。
幸运的是,函数式程序员已经开发出可以解决绝大多数性能问题的优化技术。这些包括记忆化、尾调用优化、惰性评估和高级编程,这些内容我们将在下一章中介绍。使用这些技术和其他技术,经验丰富的函数式程序员可以编写通常与其他风格编写的代码具有可比性能的代码。
然而,有些类型的程序根本不能以纯函数式的方式编写。例如,你可能不会用函数式风格来编写一个全功能的 Oracle 式关系数据库系统。然而,较小的、内存驻留的数据库系统可能能够使用纯函数式技术(例如,Haskell 程序员可以在happs.org/上获得的 HAppS-IxSet)。因此,实际上并没有一个严格的界限来决定何时可以使用函数式编程。
为什么函数式编程很棒
现在我已经告诉你函数式程序员必须忍受的所有头痛问题,你可能想知道,“为什么有人会费心以这种方式编程?”答案是,函数式编程有许多吸引人的好处,这些好处可以弥补这些头痛。
函数式编程减少错误
计算机程序中的错误通常发生是因为,在特定情况下,代码的行为与程序员在编写代码时预期的行为不符。在函数式编程中,函数的行为取决于一个且仅有一个因素:传递给函数的显式参数。这使得程序员更容易理解程序可能遇到的所有情况,包括可能导致错误的情况。
编写仅依赖于其参数行为的函数也使得错误易于复制。如果你用通过其参数传入的相同数据调用一个函数,它应该每次都做完全相同的事情。这就是我们所说的引用透明性。
功能性程序更加紧凑
结果表明,许多常规计算机程序的工作涉及创建、初始化和更新变量。功能性程序不做任何这些。正如我们之前讨论的,功能性程序利用了高阶函数,这不需要我们在代码中创建大量的临时变量,这使得我们的代码更加紧凑。
功能性代码更加优雅
功能性编程的最大优势是它将所有计算机编程带回到了数学领域。数学方程式弹出对话框或写入硬盘是没有意义的。可以认为,如果我们让我们的计算机代码回到这种相同的纯净水平,它将更加优雅。此外,如果我们的代码更接近数学世界,我们可能能够使用数学工具来编写更好的计算机代码。
事实上,许多研究仍在继续进行,使用数学证明来检查功能性计算机程序的正确性。尽管这项研究还没有达到实用程序员会使用这些技术的程度,但它们在未来可能会更加普遍。而且,几乎可以肯定,功能性编程风格将对于在代码上进行正确性证明变得至关重要。
你学到了什么
在本章中,我们讨论了功能性编程。在这个过程中,你学习了以下内容:
-
以功能性风格编写的程序在给定的参数值相同时总是给出相同的结果。
-
功能性程序不包含副作用。它们存在的全部目的就是计算一个值并返回。
-
非功能性程序通常读起来像菜谱,包含诸如“首先做这个,然后做那个”之类的语句。这种编程风格被称为命令式编程。
-
编写 Lisp 程序的一个好策略是将它们分解成一个干净的功能性部分和一个脏的命令式部分。
-
功能性程序可以快速编写,更加紧凑,并且往往具有更少的错误,尤其是在经验丰富的功能性程序员手中。
第十五章。使用功能性风格编写的“末日骰子”游戏
现在我们终于准备好在功能性风格中创建一个更复杂(并且有趣)的计算机程序了。随着我们在本书的其余部分扩展这个程序,你将了解编写优雅功能性代码的技术,同时保持程序中的强大性能。

“末日骰子”的规则
“末日骰子”是一款与“风险”、“骰子战争”(www.gamedesign.jp/flash/dice/dice.html)和“KDice”(kdice.com/)同属一类的游戏。一开始,我们将保持“末日骰子”的规则极其简单。在后面的章节中,我们将扩展规则,直到最终我们会有一个与“骰子战争”非常相似的游戏。
下面是我们将开始使用的简化规则:
-
两个玩家(命名为 A 和 B)在一个六边形网格上占据空间。网格中的每个六边形上都会有一些六面骰子,属于占领者。
-
在一个回合中,玩家可以进行任意数量的移动,但必须至少进行一次移动。如果玩家无法移动,游戏结束。
-
移动包括攻击对手拥有的相邻六边形。玩家必须在她自己的六边形中拥有比相邻六边形更多的骰子才能进行攻击。目前,所有攻击都将自动导致胜利。在未来变体中,我们将实际掷骰子进行战斗。但到目前为止,拥有更多骰子的玩家将自动获胜。
-
在赢得一场战斗后,输掉战斗的玩家的骰子将从棋盘上移除,并且除了一个之外的所有赢家的骰子都将移动到新占领的六边形上。
-
在玩家完成她的移动后,将增援部队添加到该玩家的骰子军队中。增援到玩家占领的六边形的增援是逐个骰子添加的,从左上角开始,横向和纵向移动。作为增援添加的骰子最大数量比玩家在其完成的回合中从对手那里拿走的骰子少一个。
-
当一名玩家不能再进行她的回合时,游戏结束。此时占据最多六边形区域的玩家是赢家。(也可能出现平局。)
“末日骰子”的一个示例游戏
由于我们的“末日骰子”实现将包括一个 AI 玩家,我们将从极小的游戏板尺寸开始。正如你可能知道的,AI 代码可能非常计算密集。在我们这个游戏的早期、非常天真的版本中,任何大于 2x2 六边形网格的棋盘都会让 CLISP 崩溃!
下面是一个在微小的 2x2 棋盘上进行的完整游戏示例:

游戏开始时,玩家 A(用黑色六边形表示)拥有顶部两个六边形,每个六边形上有三个骰子。玩家 B 占据底部行(用白色六边形表示),分别有三个骰子和一个骰子。玩家 A 用他的一堆骰子中的一枚攻击孤立的骰子。攻击后,玩家 A 的一枚骰子留在了原地,而其他骰子移动到被征服的位置。然后玩家 A 结束回合。

现在玩家 B 用三堆骰子攻击玩家 A 的两个骰子。然后玩家 B 传递。在这个时候,玩家 B 在她的左侧六边形上获得一个强化骰子。这是因为她杀死了玩家 A 的两个骰子。根据规则,强化骰子的数量是杀死的骰子数量减去一。

玩家 A 现在用他的三个骰子进行攻击并传递。此外,他还获得一个强化骰子。

玩家 B 现在只有一个合法的移动,即攻击两个对抗一个。

现在玩家 A 处于优势,杀死了玩家 B 剩余的所有骰子。正如你所见,玩家 A 在传递之前被允许进行多次攻击。游戏以玩家 A 获胜结束。
实现“末日骰子”版本 1
让我们开始用 Lisp 编写这个游戏。正如我们在上一章中讨论的,这个游戏将包含干净的、函数式的代码和脏的、命令式的代码。你将能够通过它旁边的“干净/函数式”或“脏/命令式”图标来判断代码块属于哪个类别。
定义一些全局变量
首先,我们将创建一些全局变量来定义我们游戏的基本参数:
(defparameter *num-players* 2)
(defparameter *max-dice* 3)
(defparameter *board-size* 2)
(defparameter *board-hexnum* (* *board-size* *board-size*))
我们声明将有两位玩家
,每个方格上的骰子最大数量为三个
,并且游戏板将是 2x2
。在 Dice of Doom 的后续版本中,我们将增加所有这些参数,以允许更具有挑战性的游戏。
由于了解当前游戏板大小下六边形的总数是有用的,我们也定义了*board-hexnum*
。请注意,尽管网格由六边形组成,但它基本上仍然是一个方形网格,因为六边形的数量正好等于网格边长的平方。
注意
在本章中,每个代码示例都有一个相关的图标,表示它是脏的、命令式的还是干净的、函数式的代码。到本章结束时,你应该能够轻松地区分它们,并对每种风格的优点有所了解。
表示游戏板
我们将使用一个简单的列表来表示游戏板。六边形将存储在这个列表中,从左上角开始,然后横向和纵向移动。对于每个六边形,我们将存储一个包含两项的列表:一个表示当前占据六边形的数字,另一个表示该位置的骰子数量。
例如,以下是一个游戏板及其编码列表的示例:

((0 3) (0 3) (1 3) (1 1))
注意,大多数 Lisp 程序员喜欢从零开始计数。因此,玩家 A 和 B 分别用数字 0 和 1 表示。这个列表表明玩家 A 在第一个六边形上有三个骰子,在第二个六边形上也有三个。玩家 B 在第三个六边形上有三个骰子,在第四个六边形上有一个。
当我们创建我们的 AI 玩家时,它需要能够非常快速地查看棋盘上的许多六边形。因此,我们将以数组的形式创建我们棋盘的第二个表示。记住,在列表中检查数字位置(例如,六边形 2)需要 nth 函数,这可能是缓慢的。另一方面,数组将允许在特定位置进行非常快速的查找,即使是在非常大的棋盘尺寸下。
board-array 函数将用列表表示的棋盘转换为数组:

(defun board-array (lst)
(make-array *board-hexnum* :initial-contents lst))
当游戏开始时,我们将从一个随机化的棋盘开始。这是创建随机棋盘的函数:

(defun gen-board ()
(board-array (loop for n below *board-hexnum*
collect (list (random *num-players*)
(1+ (random *max-dice*))))))
这个函数不是函数式风格(如图标所示),因为它每次调用都会产生不同的、随机的结果。它将棋盘生成为一个列表,但完成后会使用 board-array 将列表转换为我们的更快的数组格式
。
它使用 Lisp 函数 random 生成随机值。这个函数每次都会产生一个不同的随机整数,大于或等于零,但小于传递给它的数字。我们使用我们的全局变量 *num-players* 和 *max-dice* 为每个六边形生成随机值
。
让我们尝试一下 gen-board 函数:
> `(gen-board)`
#((0 3) (1 2) (1 3) (0 1))
记住,井号(#)表示我们创建了一个数组,而不是一个列表。
我们将使用字母来命名玩家(仅限于 A 和 B,直到我们开始介绍更多玩家)。这是一个将玩家编号转换为字母的函数:

(defun player-letter (n)
(code-char (+ 97 n)))
code-char 函数将 ASCII 码转换为相应的字符。让我们为玩家 1 调用它来看看结果:
> `(player-letter 1)`
#\b
最后,让我们创建一个函数,它将接受一个编码的棋盘,并以美观的方式在屏幕上绘制它。它将以与我们的绘图相同的方式倾斜棋盘,因此可以清楚地看到任何给定的六边形周围相邻的六个六边形。

(defun draw-board (board)
(loop for y below *board-size*
do (progn (fresh-line)
(loop repeat (- *board-size* y)
do (princ " "))
(loop for x below *board-size*
for hex = (aref board (+ x (* *board-size* y)))
do (format t "˜a-˜a " (player-letter (first hex))
(second hex))))))
由于整个 draw-board 函数的目的是将内容写入控制台,它肯定不是函数式的。让我们更仔细地看看这个函数。
外层循环遍历存储在变量y中的棋盘所有行 ![httpatomoreillycomsourcenostarchimages783564.png]。存在两个内层循环。第一个内层循环在左侧添加缩进,使棋盘看起来倾斜 ![httpatomoreillycomsourcenostarchimages783562.png]。第二个内层循环遍历存储在变量x中的列 ![httpatomoreillycomsourcenostarchimages783560.png]。然后使用x和y计算适当的十六进制数,并使用aref从棋盘数组中检索该十六进制数 ![httpatomoreillycomsourcenostarchimages783554.png]。最后,它打印出十六进制中的数据 ![httpatomoreillycomsourcenostarchimages783510.png]。
这是draw-board函数的输出,以及与之比较的绘图:
> `(draw-board #((0 3) (0 3) (1 3) (1 1)))`
a-3 a-3
b-3 b-1

将“末日骰子”的规则与游戏的其他部分解耦
现在我们准备编写处理我们第一个“末日骰子”实现核心部分的代码。在编写此代码时,我们将采用一种强大的函数式编程技术:函数管道。这意味着我们的游戏将由一系列函数组成,这些函数依次对一个大数据块进行操作,该数据块将包含我们游戏棋盘的表示,并在过程中对其进行修改。函数管道将使我们能够构建一个与游戏代码其他部分完全解耦的游戏规则引擎。为了理解为什么这如此酷,让我们首先考虑编写具有智能人工智能玩家的棋盘游戏所涉及的一些内容。
首先,任何棋盘游戏的计算机实现都需要处理人类玩家走棋的代码。这部分代码需要了解棋盘游戏的规则,并在允许发生之前确保人类玩家的走棋是合法的。
我们还需要编写人工智能代码。为了让人工智能玩家选择走棋,它需要了解棋盘游戏的全部规则。
注意到了什么?我们游戏引擎的这两个独立部分都需要理解游戏规则!显然,我们想要做的是将我们的游戏代码分成三个主要部分:
-
处理人类的走棋
-
人工智能玩家
-
规则引擎
一部分处理玩家的走棋。另一部分是人工智能玩家的代码。这两部分然后与一些理解规则的代码进行通信,有点像“规则引擎”。这种设计可能吗?
在传统的命令式编程风格中,编写这样的程序会非常困难。大多数命令式游戏引擎会复制“理解规则”的代码,因为在一个命令式语言中编写完全解耦的组件非常复杂。这是因为棋盘游戏需要大量的上下文——每一个动作都依赖于之前发生的动作。这意味着每次 AI 模块或玩家处理模块需要检查规则时,它必须详细地告诉“规则代码”当前的上下文。两者都需要告诉规则代码:“现在是某某玩家的回合,游戏板看起来是这样的。”没有这些信息,规则代码就无法判断一个动作是否合法。
传递这个上下文需要大量的繁琐的记账代码,容易出错,且效率低下。效率低下是因为,在简单的设计中,玩家处理代码可能会检查 AI 代码已经探索并认为合法的动作的合法性。
然而,使用函数式编程,我们可以在程序中完全解耦这三个关注点。我们将能够做到这一点,而不需要记账代码,并且以避免任何合法性计算重复的方式。我们将通过将我们的规则代码编码在懒游戏树中来实现这一点!
注意
我们使用的基本方法——使用懒游戏树和函数管道以函数式风格编写游戏——在 John Hughes 的经典论文“为什么函数式编程很重要”中有描述(www.scribd.com/doc/26902/whyfp/)。
在本章中,我们将创建一个还不是懒的棋盘树。您需要等到第十八章才能理解懒编程以及懒游戏树的样子。那时您也将能够完全欣赏这种架构设计有多么酷。

生成游戏树
我们游戏的整个规则集都编码在以下主函数中:

(defun game-tree (board player spare-dice first-move)
(list player
board
(add-passing-move board
player
spare-dice
first-move
(attacking-moves board player spare-dice))))
game-tree函数根据一定的起始配置构建所有可能动作的树。这个函数将在游戏开始时只被调用一次。然后,它将递归地构建游戏的所有可能动作的树,直到最终的胜利位置。我们的游戏的其他部分将优雅地遍历这棵树,以符合游戏的规则。
为了从给定的上下文中计算游戏树的合法可能动作,该函数需要通过参数传递四份数据
:
-
棋盘的样子
-
当前玩家
-
在当前回合中,玩家捕获了多少个骰子,这是根据我们的规则计算未来增援所需的
-
当前移动是否是当前玩家的第一次移动,因为玩家在至少进行一次移动之前不能传递回合
当game-tree函数创建树时,它将在每个分支上放置有关当前棋盘和当前玩家的信息
。子分支将包含从当前分支的所有合法后续移动:

玩家可能有两种合法的移动类型:攻击一个六边形或将其回合传递给下一个玩家(假设他们已经至少攻击过一次)。通过add-passing-move函数将传递移动添加到合法移动列表中
。攻击移动通过attacking-moves函数添加到列表中
。接下来,让我们看看这些函数。
计算传递移动
这里是向游戏树添加传递移动的函数:

(defun add-passing-move (board player spare-dice first-move moves)
(if first-move
moves
(cons (list nil
(game-tree (add-new-dice board player (1- spare-dice))
(mod (1+ player) *num-players*)
0
t))
moves)))
这个函数的任务是在允许传递的情况下,将传递移动添加到移动总数中。当前移动列表被传递到这个函数
,然后该函数将返回扩展后的移动列表。如果移动是玩家回合中的第一次移动
,则不允许传递,我们只返回未更改的列表
。否则,我们向列表中添加一个新的移动。
我们的游戏树中的每个移动都由两部分组成:
-
第一部分是对移动的描述。由于我们只是传递这个移动,我们将描述设置为
nil
。 -
移动的第二部分是一个全新的游戏树,它包含了在此移动执行后存在的所有可能的移动。我们通过递归调用
game-tree来创建这个游戏树
。由于这是玩家的回合结束,玩家可能会收到骰子作为增援。因此,我们使用add-new-dice函数更新发送给这个新game-tree调用的棋盘
。
当然,我们还需要更改当前玩家,因为现在是一个新人的回合开始了。我们通过将当前玩家编号加一,并用玩家总数作为除数取模来实现这一点
。以这种方式更改玩家将允许代码在未来版本中即使增加游戏中的玩家数量也能正常工作。
计算攻击移动
这里是向游戏树添加可能攻击移动的函数:

(defun attacking-moves (board cur-player spare-dice)
(labels ((player (pos)
(car (aref board pos)))
(dice (pos)
(cadr (aref board pos))))
(mapcan (lambda (src)
(when (eq (player src) cur-player)
(mapcan (lambda (dst)
(when (and (not (eq (player dst) cur-player))
(> (dice src) (dice dst)))
(list
(list (list src dst)
(game-tree (board-attack board cur-player src dst (dice src))
cur-player
(+ spare-dice (dice dst))
nil)))))
(neighbors src))))
(loop for n below *board-hexnum*
collect n))))
attacking-moves函数比add-passing-move函数复杂一些。它负责扫描当前游戏棋盘,并确定当前玩家合法允许执行哪些移动。
由于必须花费大量时间来确定给定六边形上的玩家是谁,我们首先编写了一个方便的函数player,该函数返回给定棋盘位置的玩家 ![http://atomoreilly.com/source/nostarch/images/783564.png]。我们编写了一个类似函数来获取给定六边形上的骰子数量 ![http://atomoreilly.com/source/nostarch/images/783562.png]。
接下来,我们需要从上到下扫描棋盘,找出当前玩家占据的方格。对于每个占据的方格,可能有一个或多个从该位置开始的合法攻击。由于任何六边形的攻击数量可能不同,我们使用mapcan来扫描棋盘 ![http://atomoreilly.com/source/nostarch/images/783560.png]。记住,mapcan允许我们扫描的每个六边形将其结果作为列表返回。然后mapcan将这些列表连接起来。这样,任何扫描的六边形都可以向列表贡献从 0 到n个移动。
在mapcan函数中使用的lambda函数,该函数为每个六边形调用一次,我们首先想要检查当前玩家是否占据了该六边形 ![http://atomoreilly.com/source/nostarch/images/783554.png]。然后我们想要检查所有相邻的六边形,看看是否有任何一个六边形构成了有效的攻击。我们使用另一个mapcan ![http://atomoreilly.com/source/nostarch/images/783510.png] 来完成这个操作。我们将通过使用neighbors函数来确定这个六边形的相邻六边形,这个函数我们将很快编写 ![http://atomoreilly.com/source/nostarch/images/783498.png]。
我们如何决定一个六边形是否可以成为攻击目标?嗯,它必须是一个我们尚未拥有的六边形,并且(根据规则)源六边形需要比目标六边形有更多的骰子 ![http://atomoreilly.com/source/nostarch/images/783544.png]。如果我们找到了一个合法的攻击移动,我们随后描述这个移动 ![http://atomoreilly.com/source/nostarch/images/783556.png]。描述只是一个源位置和目标位置的列表。然后(就像传递移动一样)递归地生成另一个游戏树,描述如果执行这个移动会发生什么 ![http://atomoreilly.com/source/nostarch/images/783566.png]。
寻找相邻六边形
接下来,让我们创建一个函数来计算给定六边形的相邻六边形:

(defun neighbors (pos)
(let ((up (- pos *board-size*))
(down (+ pos *board-size*)))
(loop for p in (append (list up down)
(unless (zerop (mod pos *board-size*))
(list (1- up) (1- pos)))
(unless (zerop (mod (1+ pos) *board-size*))
(list (1+ pos) (1+ down))))
when (and (>= p 0) (< p *board-hexnum*))
collect p)))
游戏板上的每个六边形可能有最多六个邻居,如果六边形在板的边缘,则可能更少。我们通过一个loop构建可能的邻居列表
,然后收集那些位置编号不在板边缘的六边形
。此外,由于我们的位置编号从行到行是循环的,我们需要确保如果我们位于板的左侧边缘
或右侧边缘
,不要向左看或向右看。
这个函数被标记为清洁(它处于函数式风格),但尽管如此,它仍然包含一个loop。通常,循环违反函数式编程的原则。然而,许多 Lisper 认为,如果loop只是收集一些值,那么在函数式代码中使用它是可以接受的,因为它实际上并没有改变任何值或产生任何其他副作用。因此,我们将允许自己在游戏的函数式风格部分使用这样的循环。
让我们尝试我们的neighbors函数:
> `(neighbors 2)`
(0 3)

如您所见,它正确地告诉我们六边形 2 的邻居是六边形 0 和 3。
攻击
现在让我们编写我们的board-attack函数:

(defun board-attack (board player src dst dice)
(board-array (loop for pos
for hex across board
collect (cond ((eq pos src) (list player 1))
((eq pos dst) (list player (1- dice)))
(t hex)))))
这是一个确定如果六边形src攻击六边形dst会发生什么的函数。它通过在板上loop,跟踪当前位置
和该位置六边形的内含物
来工作。如果当前六边形是源六边形,我们只需在那个地方放置一个骰子;根据我们的规则,攻击后留下一个骰子
。如果当前六边形是目标位置,我们将剩余的骰子放在那里,减去留下的一个
。在其他情况下,我们只是收集相同的六边形
。
让我们尝试我们的board-attack函数:
> `(board-attack #((0 3) (0 3) (1 3) (1 1)) 0 1 3 3)`
#((0 3) (0 1) (1 3) (0 2))

如您所见,从六边形 1 攻击到 3 会导致board-attack正确更新游戏板,使得一个骰子留在原来的方格上,两个在新的、征服的方格上。
注意
本章中的许多函数都有低效之处,以保持简单。我们将在游戏的未来版本中修复这些问题。
增援
要在棋盘上添加强化,我们需要扫描整个游戏棋盘,找到可以容纳另一个骰子的占据点,并将骰子放在那里。当然,强化骰子的数量受限于玩家在上一个回合中捕获的对手骰子的数量。因此,我们需要持续计算剩余的强化骰子数量。
跟踪剩余骰子的最明显方法是有 remaining-dice 变量,并在放置骰子时递减。然而,拥有递减(变异)的骰子与函数式风格不符。
因此,我们将使用局部递归函数来编写我们的 add-new-dice 函数,该函数也将维护骰子的运行计数。
下面是这个 add-new-dice 函数:

(defun add-new-dice (board player spare-dice)
(labels ((f (lst n)
(cond ((null lst) nil)
((zerop n) lst)
(t (let ((cur-player (caar lst))
(cur-dice (cadar lst)))
(if (and (eq cur-player player) (< cur-dice *max-dice*))
(cons (list cur-player (1+ cur-dice))
(f (cdr lst) (1- n)))
(cons (car lst) (f (cdr lst) n))))))))
(board-array (f (coerce board 'list) spare-dice))))
add-new-dice 首先定义了一个名为 f 的局部函数
。这个函数将遍历棋盘上的六边形,并输出一个包含强化骰子的新列表。由于我们的棋盘实际上为了效率原因存储在一个数组中,我们在调用 f 之前使用 coerce 函数将数组转换为列表
。
在函数 f 内部,我们必须考虑三种情况:
-
我们已经到达棋盘的末尾。在这种情况下,强化后的棋盘也将完成,所以我们只需返回
nil
。 -
我们已经没有
spare-dice可以添加作为强化。在这种情况下,剩余的棋盘将保持不变,因此我们可以直接返回列表的剩余部分作为新的棋盘
。 -
上述两种情况都不适用。在其他所有情况下,我们需要分析当前六边形并决定是否需要在其中添加强化。我们检查当前玩家是否占据该六边形,以及在该方块上我们是否拥有少于最大数量的骰子
。如果是这种情况,我们在六边形上添加一个新的骰子,并对剩余的棋盘递归地调用 f
。否则,我们保持当前六边形不变,并通过递归地调用 f对剩余的棋盘进行操作
。
尝试在棋盘上添加强化:
> `(add-new-dice #((0 1) (1 3) (0 2) (1 1)) 0 2)`
#((0 2) (1 3) (0 3) (1 1))
如您所见,add-new-dice 正确地为玩家 A(玩家 0)放置了两个强化骰子。
尝试我们的新游戏树函数
我们现在已经编写了创建简化版“末日骰子”的完整游戏树的所需所有代码。但要注意!大多数棋类游戏的游戏树都非常庞大。即使在 2x2 的棋盘上,我们的游戏也可能包含数百种可能的动作。你只想在接近游戏结束的棋盘上调用game-tree函数,否则你将无助地看着 CLISP REPL 打印出一个巨大的树,显示游戏可能进展的所有可能方式。
这里有一个安全的棋盘位置供你尝试:

> `(game-tree #((0 1) (1 1) (0 2) (1 1)) 0 0 t)`
(0
#((0 1)(1 1) (0 2) (1 1))
(((2 3)(0
#((0 1) (1 1) (0 1) (0 1))
((NIL(1
#((0 1) (1 1) (0 1) (0 1))
NIL)))))))
游戏树首先列出当前玩家编号
,棋盘布局 ![http://atomoreilly.com/source/nostarch/images/783562.png],然后是该上下文的有效动作。对于初始棋盘位置,在玩家 A 的回合开始时,只有一个可能的动作:玩家可以从六边形 2 移动到六边形 3,捕获该位置的玩家 B 的骰子 ![http://atomoreilly.com/source/nostarch/images/783560.png]。之后,玩家可以选择过。玩家 B 现在没有可用的动作。由于这个玩家的游戏树没有列出可用的动作 ![http://atomoreilly.com/source/nostarch/images/783554.png],游戏结束,玩家 A 获胜。
与另一名人类玩“末日骰子”
现在我们已经完全捕获了“末日骰子”游戏的全局“游戏树”函数,创建一个人类对人类版本的游戏变得简单。我们只需要创建一些函数,让它们随着玩家选择动作而沿着游戏树向下移动。
主循环
这里是沿着游戏树向下移动的函数,允许两名人类玩“末日骰子”:

(defun play-vs-human (tree)
(print-info tree)
(if (caddr tree)
(play-vs-human (handle-human tree))
(announce-winner (cadr tree))))
这个函数play-vs-human是游戏的主循环。它接受一个描述棋盘起始位置的树。
首先,它调用一个名为print-info的函数,该函数将在屏幕上绘制棋盘,以及有关游戏当前状态的其他有用信息
。接下来,我们需要检查是否存在后续动作。这些后续动作将从游戏树的caddr位置开始
。
如果有后续动作可用,我们调用handle-human函数,该函数将与当前玩家交互,帮助他选择新的动作。然后handle-human函数将返回表示玩家选择的子树的分支。然后我们可以递归地将这个子分支传递给play-vs-human以继续游戏
。
如果没有后续动作可用,游戏就正式结束了。然后我们调用announce-winner函数,它恰当地会宣布获胜者
。
游戏状态信息提供
这里是print-info函数,它描述了游戏树中当前节点的状态:

(defun print-info (tree)
(fresh-line)
(format t "current player = ˜a" (player-letter (car tree)))
(draw-board (cadr tree)))
此函数在 REPL 上显示两个重要的信息。首先,它显示了当前玩家是谁
。然后,它使用draw-board函数打印出游戏板的漂亮版本
。
处理来自人类玩家的输入
接下来是允许人类选择下一步行动的函数。它显示了一个非常有用的、编号的菜单,列出了所有当前可用的移动供玩家选择。

(defun handle-human (tree)
(fresh-line)
(princ "choose your move:")
(let ((moves (caddr tree)))
(loop for move in moves
for n from 1
do (let ((action (car move)))
(fresh-line)
(format t "˜a. " n)
(if action
(format t "˜a -> ˜a" (car action) (cadr action))
(princ "end turn"))))
(fresh-line)
(cadr (nth (1- (read)) moves))))
要显示可用的移动列表,我们使用一个loop遍历所有可用的移动,并打印每个移动的描述
。这个loop不是函数式的,因为它在屏幕上打印内容供玩家阅读。我们使用变量 n 在每次移动前打印一个计数数字
,其中 n 在loop内部从 1 开始计数
。
每个移动都与一个动作值相关联。如果动作非 nil
,则该动作是攻击,动作值描述了攻击的源和目标六边形。我们使用format命令打印这种攻击动作
。
我们使用一个空的动作值来表示传递动作。在这种情况下,我们只需princ“结束回合”来描述这个动作
。
在显示可用的移动后,我们使用read读取玩家的选择。然后,使用nth函数,我们可以选择游戏树的那部分并从我们的handle-human函数返回它
。
确定胜者
宣布胜者的任务可以很好地分为一个干净/功能部分和一个脏/强制部分。
干净的部分涉及计算胜者的任务。我们希望以能够处理不仅仅是两个玩家的方式计算这一点,因为我们的游戏未来将允许更多。此外,该函数必须意识到可能的平局。
为了实现这一点,我们将编写一个名为winners的函数,该函数返回一个或多个在游戏结束时捕获了最多六边形的玩家列表。如果有平局,它将简单地返回所有共享第一名的玩家,即所有玩家占据的总空间数。按照这种设计,该函数将适用于任何数量的玩家,并且优雅地处理平局。这是winners函数的样子:

(defun winners (board)
(let* ((tally (loop for hex across board
collect (car hex)))
(totals (mapcar (lambda (player)
(cons player (count player tally)))
(remove-duplicates tally)))
(best (apply #'max (mapcar #'cdr totals))))
(mapcar #'car
(remove-if (lambda (x)
(not (eq (cdr x) best)))
totals))))
我们通过四个步骤计算给定结束棋盘位置的赢家。
-
首先,我们统计出占据棋盘上每个六边形的玩家!。使用
across loop结构,我们可以直接遍历结束棋盘的数组并收集每个六边形的占据者。 -
第二,我们需要使用这个统计来计算每个玩家占领的总方块数。
totals变量将是一个 player->spaces 对的 alist。我们通过使用remove-duplicates找到至少在统计中有一次条目的所有玩家来构建这个 alist!。然后我们可以映射到这个列表,并为每个占据者创建一个计数!。 -
第三,我们想要找出单个玩家占据的六边形数量的最大值。我们通过映射
cdr到我们的 alist 上来从计数中去除!。然后,我们应用max到这个列表中,以找到单个玩家占据的最大空间数量。 -
最后,我们需要创建一个包含所有“最佳”玩家的列表。我们通过使用
remove-if函数从总数中去除所有非最佳玩家来实现这一点!。然后,我们通过映射car到最佳玩家的列表中,提取出最佳玩家的玩家编号!。
接下来,让我们编写“宣布赢家”的函数:

(defun announce-winner (board)
(fresh-line)
(let ((w (winners board)))
(if (> (length w) 1)
(format t "The game is a tie between ˜a" (mapcar #'player-letter w))
(format t "The winner is ˜a" (player-letter (car w))))))
这个函数相当简单。首先,我们通过调用我们之前的功能来计算赢家!。然后我们检查是否有多个赢家!(平局)。对于平局,我们打印一条特殊信息!。否则,我们只宣布一个赢家!。
尝试玩“末日骰子”的人与人版本
现在我们有一个完全可玩的游戏“末日骰子”。以下是一个从开始到结束的示例游戏:
> `(play-vs-human (game-tree (gen-board) 0 0 t))`
current player = a
b-2 b-2
a-2 b-1
choose your move:
1\. 2 -> 3
`1`
current player = a
b-2 b-2
a-1 a-1
choose your move:
1\. end turn
`1`
current player = b
b-2 b-2
a-1 a-1
choose your move:
1\. 0 -> 2
2\. 0 -> 3
3\. 1 -> 3
`1`
current player = b
b-1 b-2
b-1 a-1
choose your move:
1\. end turn
2\. 1 -> 3
`1`
current player = a
b-1 b-2
b-1 a-1
The winner is b
创建一个智能电脑对手
正如我们在为“末日骰子”设计游戏树代码时讨论的那样,有一个独立的游戏树生成器使得将 AI 玩家添加到游戏引擎中变得容易。实际上,我们现在要添加一个电脑玩家,它只需 23 行额外的代码就能玩出绝对完美的游戏!
那么,一个 AI 玩家是如何决定走哪一步的呢?我们将使用以下策略:
-
检查每个可用的走法。
-
给出每个走法导致的棋盘位置的评分。
-
选择具有最大评分的走法。
这个计划听起来很简单,但这个算法中有一个步骤相当棘手:计算给定棋盘位置的最佳评分。
如果一个动作直接导致胜利,很容易为该动作给出评分——任何胜利的动作显然都应得到非常高的评分。然而,大多数游戏中的动作都不能导致立即胜利。在这些情况下,为了确定一系列动作的结果是否值得一个好的评分,我们需要弄清楚对手玩家将如何回应。
但我们如何知道对手玩家将决定做什么?如果我们不小心,我们可能会陷入一个丑陋的僵局,我们说,“他认为我认为他认为我认为……”,以便为给定的棋盘位置计算一个有意义的评分。我们如何考虑对手的行为而不让自己头疼?

最小-最大算法
结果表明,对于两人棋盘游戏,存在一种简单的方法来模拟对手将如何行动。我们简单地接受这样一个真理:“对我对手有利的就是对我有害的。”这意味着我们可以使用以下方法来模拟对手的动作:
-
查看每个可用的动作。
-
为每个动作导致的棋盘位置给出评分。
-
选择评分最低的动作。
这个用于估计对手将如何行动的算法与用于主要玩家的算法相同,只是在第 3 步中,我们选择评分最低的动作而不是评分最高的动作。这种方法的优点,称为最小-最大算法,是我们使用与主要 AI 玩家相同的评分来计算对手的动作,然后只是稍微调整第 3 步。
这非常重要:结果证明,如果我们能够避免在游戏中为对手和自己计算不同的评分,那么在游戏树中搜索好的动作就会变得大大简化并加快速度。
注意
基本的最小-最大算法仅在两人游戏中有效。当有三名或更多玩家参与游戏时,我们不能再断言“对我对手有利的就是对我有害的”这一说法完全正确。这是因为一个额外的真理变得很重要:“敌人的敌人是我的朋友。”这意味着我的某些对手可能会在某个时刻通过采取损害共同敌人的动作来充当朋友,而不会直接影响我。我们将在第二十章中更详细地讨论这个问题。
将最小-最大转化为实际代码
现在我们已经准备好将最小-最大思想付诸实践,如下所示:

(defun rate-position (tree player)
(let ((moves (caddr tree)))
(if moves
(apply (if (eq (car tree) player)
#'max
#'min)
(get-ratings tree player))
(let ((w (winners (cadr tree))))
(if (member player w)
(/ 1 (length w))
0)))))
rate-position函数为游戏树的一个给定分支生成一个数值评分。为了做到这一点,我们首先需要确定从给定位置(即当前动作不是游戏中的结束动作)是否有任何可用的动作
。
如果有可用动作,我们需要查看所有后续动作以决定如何评级当前位置。我们通过调用get-ratings
,一个将返回每个后续动作的点评级的函数来实现这一点。按照最小-最大原则,然后我们将选择所有后续动作中的最佳(max)
或最差(min)
评级,具体取决于正在评级的动作是为 AI 玩家还是其对手。
如果没有后续动作,我们需要检查当前棋盘位置的胜者
。如果玩家不是这个位置胜者之一,我们可以给这个位置分配最低评级0
。否则,我们将胜者数量除以 1 来确定我们的评级
。通过这样做,我们也为平局给出了有意义的评级。如果玩家是唯一的胜者,根据这个公式,评级将是最大值1。对于两名玩家的平局,评级将是合理的0.5。
下面是get-ratings函数的样子:

(defun get-ratings (tree player)
(mapcar (lambda (move)
(rate-position (cadr move) player))
(caddr tree)))
这个函数简单地映射给定树分支的每个可用后续动作的rate-position。
使用人工智能玩家创建游戏循环
之前,我们编写了一个名为handle-human的函数,该函数与人类交互以决定游戏中的移动。这里有一个类似的功能,handle-computer,它与我们的 AI 玩家交互以选择移动:

(defun handle-computer (tree)
(let ((ratings (get-ratings tree (car tree))))
(cadr (nth (position (apply #'max ratings) ratings) (caddr tree)))))
这个handle-computer函数相当直接。首先,我们获取每个可用动作的评级
。然后我们选择评级最高的动作
。
最后,让我们创建一个处理与计算机对弈的主循环的函数。这个函数与之前的play-vs-human函数类似:

(defun play-vs-computer (tree)
(print-info tree)
(cond ((null (caddr tree)) (announce-winner (cadr tree)))
((zerop (car tree)) (play-vs-computer (handle-human tree)))
(t (play-vs-computer (handle-computer tree)))))
与play-vs-human函数一样,play-vs-computer首先打印出有关当前游戏状态的详细信息
。如果没有更多动作可用,它随后调用announce-winner函数
。
接下来,我们需要检查当前玩家是谁。按照惯例,我们将人类玩家称为玩家 A(玩家 0)。如果玩家编号是 0,我们将调用我们的旧handle-human函数,让人类玩家决定她的走法
。否则,我们将玩家视为 AI 玩家,并使用handle-computer函数来决定走法
。
我们现在已经为《末日骰子》编写了一个完全功能化的 AI 引擎!
玩我们的第一次人机对战游戏
以下是对抗计算机 AI 的示例游戏。计算机玩了一局最优游戏并获胜。
> `(play-vs-computer (game-tree (gen-board) 0 0 t))`
current player = a
a-3 b-3
a-2 b-2
choose your move:
1\. 0 -> 3
`1`
current player = a
a-1 b-3
a-2 a-2
choose your move:
1\. end turn
`1`
current player = b
a-2 b-3
a-2 a-2
current player = b
b-2 b-1
a-2 a-2
current player = a
b-3 b-1
a-2 a-2
choose your move:
1\. 3 -> 1
`1`
current player = a
b-3 a-1
a-2 a-1
choose your move:
1\. end turn
`1`
current player = b
b-3 a-1
a-2 a-1
current player = b
b-1 a-1
b-2 a-1
current player = b
b-1 a-1
b-1 b-1
current player = a
b-2 a-1
b-2 b-1
The winner is b
让《末日骰子》更快
函数式编程风格可能会导致代码运行缓慢,至少对于新手程序员来说是这样。我们使用了函数式风格来开发《末日骰子》的核心。因此,我们这款游戏的第一版效率极低。为了使其可玩,我们不得不将游戏限制在 2x2 的棋盘上。但现在,随着我们优化游戏引擎,我们可以将棋盘大小增加到 3x3。
让我们增加控制棋盘大小的参数,以实现这一目标。除非你是一个极其有耐心的人,不介意电脑花费数分钟构建初始游戏树并决定走法,否则你可能不想在这个新大小上玩游戏,除非你已经实现了本章其余部分的全部优化。
(defparameter *board-size* 3)
(defparameter *board-hexnum* (* *board-size* *board-size*))
在那里,我们已经将棋盘大小升级到 3x3。
本章的其余部分将介绍一些优化函数式代码的重要技术。这些技术适用于所有以函数式风格编写的程序,包括《末日骰子》。在后面的章节中,我们将添加其他优化。最终,我们将在更大的棋盘上与 AI 玩家对战,同时仍然保持使用函数式风格编写的优雅代码。
闭包
在我们开始优化《末日骰子》之前,有一个重要的 Lisp 编程概念我们需要讨论:闭包。闭包是每当创建 lambda 函数时从外部世界捕获的一些额外数据。为了理解在闭包中捕获变量的如何和为什么,请考虑以下示例:
> `(defparameter *foo* (lambda ()`
`5))`
*FOO*
> `(funcall *FOO*)`
5
在这个例子中,我们创建了一个新的、未命名的函数
,然后将*foo*设置为这个函数。接下来,我们使用funcall命令调用这个函数
。正如你所期望的,这个函数返回的值是5。lambda 函数所做的只是返回这个数字。
接下来,考虑这个更有趣的例子:
> `(defparameter *foo* (let ((x 5))`
`(lambda ()`
`x)))`
*FOO*
这个版本的foo与之前的*foo*版本完全相同,除了我们首先声明了一个局部变量x ![http://atomoreilly.com/source/nostarch/images/783564.png],它被设置为5。然后,在lambda体的内部,我们返回x ![http://atomoreilly.com/source/nostarch/images/783562.png]。所以,你认为如果我们调用这个新的*foo*版本会发生什么?
这个问题之所以困难,是因为x被声明为一个“局部”变量。然而,一旦我们调用*foo*,x(显然)就不再存在了,因为我们已经远远超过了评估let表达式体的点。
让我们试一试看看会发生什么:
> `(funcall *foo*)`
5
天哪!不知怎么的,我们创建的 lambda 表达式记住了创建时x的值。我们之前认为的局部变量x不知怎么的设法在其创建的块之外存活了下来!
当我们在第二章中首次介绍let表达式时,你了解到高级 Lisper 更喜欢将使用let表达式创建的变量称为词法变量。现在你可以看到为什么了:以这种方式创建的变量不需要是局部的,如果它在闭包中被捕获,通过在 lambda 表达式中使用该变量。
要理解闭包是如何工作的,请记住 Lisp 使用垃圾回收。实际上,它是第一个具有此功能的语言。垃圾回收意味着你永远不需要“释放”变量(就像你在 C 编程中做的那样)。Lisp 编译器/解释器足够智能,能够知道何时变量不再被使用,并自动销毁它们。
在你退出一个let表达式之后,垃圾回收将在某个任意未来的时间发生。定期地,Lisp 会搜索其内存以查找那些在任何地方都不再被引用的项目,因此可以安全地销毁它们。如果 Lisp 注意到在let中定义的变量不再被任何东西使用,它将销毁那个变量。
然而,如果你在let表达式中创建一个 lambda 表达式(就像我们之前做的那样),这些变量可能会继续存在,因为它们在 lambda 表达式中被引用。在这种情况下,垃圾回收器会留下这些变量。基本上,你已经创建了永久的变量——至少在 lambda 表达式不再被使用并且没有被垃圾回收之前是这样的。
你可以使用闭包做很多酷的事情。它们通常用于在函数使用之间缓存小块信息。例如,这里有一个记住当前正在打印的行号的函数:
> `(let ((line-number 0))`
`(defun my-print (x)`
`(print line-number)`
`(print x)`
`(incf line-number)`
`nil))`
MY-PRINT
> `(my-print "this")`
0
"this"
nil
> `(my-print "is")`
1
"is"
nil
> `(my-print "a")`
2
"a"
nil
> `(my-print "test")`
3
"test"
nil
为了跟踪行号,我们首先创建一个名为 line-number 的词法变量
。接下来,我们使用 defun
在 let 的主体中声明我们的 my-print 函数。这个命令将在幕后创建一个 lambda 函数,因此也允许我们生成闭包。
在 my-print 函数的主体中,我们可以打印 line-number
,甚至使用 incf
来修改它。(incf 只是将一个变量加一。)因为 line-number 变量被捕获在闭包中,它可以在 my-print 的调用之间“存活”,这样我们就可以计数行号。
记忆化
我们将要执行的第一项优化称为 记忆化。这项技术利用闭包。记忆化只适用于以函数式风格编写的函数。正如你所知,函数式风格的函数行为仅取决于传递给它的参数。此外,函数式风格的函数唯一的行为是计算一个值并将其返回给调用者。
这表明了一个明显的优化:如果我们记住这个函数每次调用的参数和结果会怎样?那么,如果这个函数再次以相同的参数被调用,我们就不需要重新计算结果。相反,我们可以简单地返回预先计算的结果。
Dice of Doom 中的几个函数可以从记忆化中受益。
记忆化邻居函数
让我们从 neighbors 函数开始,它让我们知道从给定位置可以攻击棋盘上的哪些六边形:
> `(neighbors 0)`
(3 1 4)
neighbors 函数告诉我们,如果我们想从六边形 0 攻击棋盘上的其他六边形,我们只能到达 3、1 或 4 号六边形(基于我们新的 3x3 棋盘大小)。
如你所记,neighbors 函数需要做各种关于棋盘边缘的丑陋检查,因为边缘的六边形在它们可以攻击的六边形方面是有限的。然而,由于棋盘的形状在游戏中永远不会改变,这些数字对于给定的棋盘位置永远不会改变。这使得 neighbors 成为记忆化的完美候选!以下是实现这一点的代码:
(let ((old-neighbors (symbol-function 'neighbors))
(previous (make-hash-table)))
(defun neighbors (pos)
(or (gethash pos previous)
(setf (gethash pos previous) (funcall old-neighbors pos)))))
让我们剖析这段代码,以理解正在发生的事情。首先,我们将 neighbors 函数的旧版本保存在一个名为 old-neighbors 的局部变量中
。symbol-function 命令简单地检索绑定到符号的函数。在这里使用 symbol-function 允许我们保留对 neighbors 旧值的访问,即使我们定义了一个具有相同名称的新函数,就像我们很快要做的那样。
接下来,我们定义一个局部变量previous
,它将保存函数曾经见过的所有参数和结果。这可以表示为一个哈希表,其中参数是哈希键,结果是值。
现在我们定义一个新的neighbors函数,它将覆盖旧的neighbors定义
。这个新定义将为旧版本的函数添加记忆化。然后我们在哈希表中查找参数pos,如果有的话就返回它
。否则,我们调用函数的旧定义(这就是为什么我们需要创建old-neighbors词法变量),并将这个新的参数/结果对添加到哈希表中
。由于setf返回被设置的值,这个命令也会导致这个新计算的结果返回给neighbors的调用者。
注意
请注意,不要多次声明neighbors函数的记忆化版本,而不重新声明函数的原始版本。否则,neighbors函数将被包裹在多层不美观的记忆化层中,因为没有检查记忆化是否已经完成。
记忆化游戏树
在我们的程序中,记忆化带来的最大收益将是game-tree函数。如果你考虑一下棋盘游戏的工作方式,这是有道理的。在棋盘游戏中,通过以稍微不同的顺序执行相同的移动,你经常可以得到相同的棋盘位置。在我们原始的game-tree函数版本中,每一个不同的移动序列都会导致游戏树中完全不同的分支,我们需要以完全重复和低效的方式构建这些分支。
在game-tree代码的记忆化版本中,函数可以对自己说,“嘿,我以前见过这个棋盘位置!”然后可以共享游戏树的分支。下面是一个执行此操作的game-tree的记忆化版本:
(let ((old-game-tree (symbol-function 'game-tree))
(previous (make-hash-table :test #'equalp)))
(defun game-tree (&rest rest)
(or (gethash rest previous)
(setf (gethash rest previous) (apply old-game-tree rest)))))
如你所见,这种记忆化几乎与neighbors函数中使用的记忆化完全相同。唯一的区别是我们将哈希表设置为使用equalp而不是默认的eql来进行键的测试
。
这是因为键(即game-tree的参数)包含游戏板,以数组的形式。如果我们把测试函数改为equalp,那么 Lisp 将检查游戏板上的每一个六边形,确保它们匹配后才会使用之前的计算。
记忆化rate-position函数
另一个将从记忆化中受益极大的函数是rate-position函数。这里就是它,已经记忆化了:
(let ((old-rate-position (symbol-function 'rate-position))
(previous (make-hash-table)))
(defun rate-position (tree player)
(let ((tab (gethash player previous)))
(unless tab
(setf tab (setf (gethash player previous) (make-hash-table))))
(or (gethash tree tab)
(setf (gethash tree tab)
(funcall old-rate-position tree player))))))
由于传递给rate-position的tree参数,我们需要对这个函数的记忆化做一些特殊处理,以确保其正确工作。游戏树可能非常大,因此我们需要确保我们永远不会使用equal(或类似的大型列表慢速比较函数)来比较游戏树对象。相反,我们希望使用eql来比较。因此,我们分别处理rate-position的两个参数的记忆化(tree和player)。我们通过嵌套哈希表来实现这一点。
首先,我们创建一个外部的哈希表,使用默认的eql测试
。然后,我们定义一个tab变量,在外部哈希表中查找我们的一个变量(player),以检索一个内部哈希表
。如果tab在外部哈希表中未找到
,我们将创建一个新的、空的内部哈希表,使用相同的键存储在外部哈希表中
。函数的其余部分与我们的前例类似,只是我们现在使用内部哈希表,以tree参数作为键
。
这种记忆化将使我们更接近拥有更大、更有趣的“末日骰子”棋盘。
注意
您使用记忆化来优化以函数式风格编写的代码的性能。然而,记忆化代码本身并不是以函数式风格编写的。它不能是,因为它要求您维护和更新目标函数先前调用的表。
尾调用优化
我们将要使用的下一个优化函数式程序的技术被称为尾调用优化。为了理解这个概念,让我们研究一个简单的函数,该函数用于计算列表的长度:
> `(defun my-length (lst)`
`(if lst`
`(1+ (my-length (cdr lst)))`
`0))`
MY-LENGTH
> `(my-length '(fie foh fum))`
3
到目前为止,my-length函数应该很容易理解。首先,它检查列表是否为空
。如果不为空,它将递归地对其列表的尾部调用自身,并使用1+函数将总数加一
。如果列表为空,函数仅返回0
。
结果表明,这个函数实际上相当低效。我们可以通过尝试使用它来处理一个非常大的列表来轻松地看出这一点:
> `(defparameter *biglist* (loop for i below 100000 collect 'x))`
*BIGLIST*
> `(my-length *biglist*)`
*** - Program stack overflow. RESET
在 CLISP 中调用此函数实际上会导致程序崩溃!(其他 Common Lisp 编译器/解释器可能表现更好,这取决于编译器编写者是否使用了任何特殊技巧来预测 Lisp 代码中的这种常见陷阱。)
这是因为1+函数。它告诉 Lisp,“首先,找出较短列表的长度,然后在结果上调用1+。”
问题在于每次我们递归调用 my-length 时,Lisp 必须记住稍后需要将结果加一,一旦计算出列表尾部的长度。由于列表有 100,000 个项目长,它必须在执行单个加法之前记住 99,999 次!CLISP 解释器将这些加法的提醒放在程序栈上,最终导致栈溢出,程序崩溃。
那么,我们如何避免这个问题呢?我们通过如下重写我们的 my-length 函数来实现:
> `(defun my-length (lst)`
`(labels ((f (lst acc)`
`(if lst`
`(f (cdr lst) (1+ acc))`
`acc)))`
`(f lst 0)))`
MY-LENGTH
> `(my-length '(fie foh fum))`
3
在这里,我们定义一个局部函数 f ![http://atomoreilly.com/source/nostarch/images/783564.png],它将充当我们的列表消耗者。这个函数接受一个额外的参数,通常称为 accumulator,这里简称为 acc ![http://atomoreilly.com/source/nostarch/images/783564.png]。这个 acc 参数保持着我们之前遇到的列表中项目数量的累计计数。当我们最初调用函数 f 时,我们将 acc 设置为 0
。
通过使这个累加器可用,这意味着当 f 递归调用自身时
,它现在不再需要将一加到结果上。相反,它只需将一加到累加器上。一旦我们到达列表的末尾(lst 是 nil
),那么 acc 将等于列表中的项目总数,因此我们可以直接返回它
。
重要的是,在函数 f 的最后一件事情中,如果列表中还有更多项目,它将递归地调用自身
。(if 语句中的附加行
不计入,因为如果表达式评估为真,这部分将不会被调用。)当一个函数在 Lisp 中将其自身(或另一个函数)作为其最后的行为调用时,我们称这个行为为 尾部调用。一个聪明的 Lisp 编译器,在看到尾部调用时,可以对自己说,“嘿,既然我在再次调用 f 之后不需要做任何事情,我就可以直接跳到 f,而不需要将当前的程序上下文放在栈上。”
这实际上类似于在 BASIC 中执行 GOTO 或者在 C++ 中执行 longjmp。在这些所有情况下,我们只是“忘记”了我们来自哪里,这非常快,而且不会使栈碎片化。然而,在 Lisp 中的尾部调用情况下,这也是完全安全的。任何使用过 GOTO 或 longjmp 的人都知道它们绝对不安全!
注意,在前面示例代码中存在两个不同的 lst 定义。一个是 my-length 函数的参数,另一个是函数 f 的参数
。随着程序运行和 f 的递归调用,这两个 lst 参数的值将会不同。然而,在函数 f 内部,其参数列表中的版本将具有优先权。通过优先级隐藏一个变量以另一个变量称为 变量遮蔽。
注意
我在 my-length 函数中使用了变量遮蔽,这样在编写函数 f 内部的代码时,我就不可能意外地使用“错误的列表”。其他程序员不喜欢这种技术,因为具有相似名称但值不同的变量可能会导致混淆。你需要决定哪种论点对你最有说服力,以及你将在自己的代码中使用变量遮蔽。
Common Lisp 中的尾调用支持
不幸的是,在 Common Lisp 中,你不能百分之百确信编译器/解释器会执行尾调用优化。ANSI Common Lisp 标准没有要求这一点。(在 Scheme 的情况下,情况实际上不同,因为 Scheme 对尾调用优化有严格的要求。)
然而,大多数 Common Lisp 编译器支持此功能,尽管 CLISP 需要一些额外的说服才能使某些函数(包括我们的示例函数)的尾调用优化工作。原因在于尾调用本身在某些神秘的情况下可能会导致性能问题。此外,当我们调试程序时,能够查看完整的调用堆栈会很好;尾调用优化将阻止这一点,因为,根据其本质,它们将最小化堆栈上的信息。
这里是我们需要采取的额外步骤,以便让 CLISP 对 my-length 函数进行尾调用优化:
(compile 'my-length)
调用此函数将告诉 CLISP 通过其完整的编译器运行 my-length 函数,包括尾代码优化步骤。现在我们可以用 my-length 对我们的巨型列表进行测试了!
> `(my-length *biglist*)`
100000
恶魔骰子的尾调用优化
我们游戏中一个肯定可以从尾调用优化中受益的函数是 add-new-dice 函数。以下是完全优化的版本:
(defun add-new-dice (board player spare-dice)
(labels ((f (lst n acc)
(cond ((zerop n) (append (reverse acc) lst))
((null lst) (reverse acc))
(t (let ((cur-player (caar lst))
(cur-dice (cadar lst)))
(if (and (eq cur-player player)
(< cur-dice *max-dice*))
(f (cdr lst)
(1- n)
(cons (list cur-player (1+ cur-dice)) acc))
(f (cdr lst) n (cons (car lst) acc))))))))
(board-array (f (coerce board 'list) spare-dice ()))))
如前所述,我们在名为 f 的函数中进行列表消耗
,该函数也有一个累加器。然而,这次 acc 变量将包含一个包含额外骰子的新六边形列表。我们现在可以在两个地方调用 f 以尾调用位置
,我们将新的六边形 cons 到 acc 变量中。
一旦我们处理完棋盘上所有六边形的列表,我们就可以直接返回acc。然而,由于我们在遍历列表的过程中向acc中添加了东西,acc实际上会被反转。因此,我们需要在最后额外调用一次reverse!
。
我们现在已经探索了一些优化函数式风格编写的计算机程序的基本技术。
3x3 棋盘上的一个示例游戏
现在,让我们享受我们劳动的果实。以下是在 3x3 棋盘上与 AI 玩家进行的一场完整游戏。正如你所看到的,在一个势均力敌的起始棋盘上,计算机现在几乎是无敌的。
> `(play-vs-computer (game-tree (gen-board) 0 0 t))`
current player = a
b-1 a-2 a-3
a-1 b-1 b-2
b-2 a-2 b-3
choose your move:
1\. 1 -> 4
2\. 1 -> 0
3\. 2 -> 5
4\. 7 -> 4
`3`
current player = a
b-1 a-2 a-1
a-1 b-1 a-2
b-2 a-2 b-3
choose your move:
1\. end turn
2\. 1 -> 4
3\. 1 -> 0
4\. 5 -> 4
5\. 7 -> 4
`1`
current player = b
b-1 a-3 a-1
a-1 b-1 a-2
b-2 a-2 b-3
current player = b
b-1 a-3 a-1
b-1 b-1 a-2
b-1 a-2 b-3
current player = a
b-1 a-3 a-1
b-1 b-1 a-2
b-1 a-2 b-3
choose your move:
1\. 1 -> 4
2\. 1 -> 0
3\. 5 -> 4
4\. 7 -> 4
5\. 7 -> 3
6\. 7 -> 6
`1`
current player = a
b-1 a-1 a-1
b-1 a-2 a-2
b-1 a-2 b-3
choose your move:
1\. end turn
2\. 4 -> 0
3\. 4 -> 3
4\. 7 -> 3
5\. 7 -> 6
`1`
current player = b
b-1 a-1 a-1
b-1 a-2 a-2
b-1 a-2 b-3
current player = b
b-1 a-1 a-1
b-1 a-2 b-2
b-1 a-2 b-1
current player = a
b-2 a-1 a-1
b-1 a-2 b-2
b-1 a-2 b-1
choose your move:
1\. 4 -> 3
2\. 4 -> 8
3\. 7 -> 3
4\. 7 -> 6
5\. 7 -> 8
`2`
current player = a
b-2 a-1 a-1
b-1 a-1 b-2
b-1 a-2 a-1
choose your move:
1\. end turn
2\. 7 -> 3
3\. 7 -> 6
`1`
current player = b
b-2 a-1 a-1
b-1 a-1 b-2
b-1 a-2 a-1
current player = b
b-1 b-1 a-1
b-1 a-1 b-2
b-1 a-2 a-1
current player = a
b-1 b-1 a-1
b-1 a-1 b-2
b-1 a-2 a-1
choose your move:
1\. 7 -> 3
2\. 7 -> 6
`1`
current player = a
b-1 b-1 a-1
a-1 a-1 b-2
b-1 a-1 a-1
choose your move:
1\. end turn
`1`
current player = b
b-1 b-1 a-1
a-1 a-1 b-2
b-1 a-1 a-1
current player = b
b-1 b-1 b-1
a-1 a-1 b-1
b-1 a-1 a-1
current player = a
b-1 b-1 b-1
a-1 a-1 b-1
b-1 a-1 a-1
The winner is b
你已经学到了什么
在本章中,我们利用函数式编程的知识开发了一个带有 AI 的棋盘游戏。在这个过程中,你学习了以下内容:
-
函数式编程技术允许你使用一个独立的“规则引擎”来编写游戏程序,这个引擎与代码的其他部分是分开的。你通过使用函数管道和构建一个游戏树来实现这一点,该游戏树在游戏进行过程中由游戏代码的其他部分独立遍历。
-
你可以使用最小-最大算法为两人游戏创建一个 AI 玩家。这个算法基于这样一个真理:“对我敌人有利的就是对我有害的。”它允许你高效地评估两人棋盘游戏中的位置。
-
词汇变量(我们一直称之为局部变量)如果被 lambda 表达式引用,它们可以超出其创建的形式而存在。以这种方式捕获变量被称为创建闭包。
-
函数式程序可以使用记忆化进行优化,这需要你缓存函数计算出的先前结果。
-
你还可以通过使用尾调用优化来改进函数式程序,这可以确保调用栈不被滥用。你通过控制哪个函数出现在你的列表吞噬函数的尾调用(最终)位置来实现这一点。
第十六章。Lisp 宏的魔力
宏编程允许你在你的 Lisp 编译器/解释器内部捣鼓,将 Lisp 转换成你自己的定制编程语言。当面对一个困难的编程挑战时,许多经验丰富的 Lisper 会首先问自己:“我能用哪种编程语言来使这个问题容易解决?”然后他们会使用宏将 Lisp 转换成那种语言!
没有任何其他编程语言拥有如此简单而全面的宏系统。甚至可以争论说,由于一个简单的原因,将这个特性添加到其他编程语言中是不可能的:Lisp 语言是唯一一种将计算机代码和程序数据由相同的“材料”构成的。正如本书多次讨论的那样,Lisp 中存储数据的基本结构是符号、数字和列表,它们由 cons 单元组成。同样,Lisp 程序的代码也是由这些相同的基本构建块构成的。正如您在本章中将会看到的,Lisp 中代码与数据之间的这种对称性是使 Lisp 宏系统成为可能的关键。

注意
您可能听说过其他编程语言,如 C++,也具有名为宏的功能。例如,在 C++ 语言中,您会使用 #define 指令来创建这些宏。然而,这些并不是一回事!Lisp 宏以完全不同且更为复杂的方式工作。
一个简单的 Lisp 宏
有时候,当您编写计算机程序时,您会感到一种 似曾相识 的感觉。我相信您知道这种感觉。您正在电脑上打字,突然意识到,“嘿,这已经是本周第三次写这段相同的代码了!”
假设,例如,您的程序需要一个特殊的 add 函数:
(defun add (a b)
(let ((x (+ a b)))
(format t "The sum is ˜a" x)
x))
这个函数将两个数字相加,并以副作用在 REPL 上打印出总和。您可能会在程序调试期间发现这个函数很有用:
> `(add 2 3)`
The sum is 5
5
这个 add 函数看起来很简单,但它的代码有一个烦恼:为什么您需要这么多括号来声明变量 x
?let 命令需要这么多括号,以至于当您只需要一个变量时,代码看起来特别荒谬。
let 所需的括号是程序员几乎每天都必须处理的 视觉噪音 的一个例子。然而,您不能仅仅写一个常规函数来隐藏那些括号,因为 let 命令可以做一些常规 Lisp 函数不支持的事情。let 命令是一个 特殊形式。它是语言的核心部分,并具有超出标准 Lisp 函数的特殊能力。
宏让我们摆脱了多余的括号。让我们创建一个新的宏名为 let1:
(defmacro let1 (var val &body body)
`(let ((,var ,val))
,@body))
如您所见,宏的定义看起来与函数的定义相似。然而,我们不是使用 defun 来定义它,而是使用 defmacro。像函数一样,它有一个名称(在这种情况下,let1)和传递给它的参数
。
一旦我们定义了宏 let1,它就可以像 let 一样使用,只是它使用更少的括号:
> `(let ((foo (+ 2 3)))`
`(* foo foo))`
25
> `(let1 foo (+ 2 3)`
`(* foo foo))`
25
宏展开
虽然宏定义看起来与函数定义非常相似,但实际上宏与函数非常不同。为了理解原因,想象一下你的 Lisp 实际上是一个可爱的小球,快乐地运行你的 Lisp 程序。

这个小球只理解标准的 Lisp 代码。如果它看到我们的let1命令,它将不知道该怎么办。

现在想象一下,我们有一根魔杖,在 Lisp 查看代码之前,就改变了代码的外观。在我们的例子中,它将let1转换成常规的let,这样 Lisp 就会保持快乐。

这根魔杖被称为宏展开。这是一种特殊的转换,在 Lisp 解释器/编译器的核心看到它之前,你的代码会经历这种转换。宏展开器的任务是找到你的代码中的任何宏(如我们的 let1 宏)并将它们转换成常规的 Lisp 代码。
这意味着宏的运行时间与函数的运行时间不同。常规的 Lisp 函数在你执行包含函数的程序时运行。这被称为运行时。另一方面,宏在程序运行之前运行,当你的 Lisp 环境读取和编译程序时。这被称为宏展开时间。
现在我们已经讨论了 Lisp 宏背后的基本思想,让我们更仔细地看看let1是如何定义的。
宏是如何转换的
当我们使用defmacro命令定义一个新的宏时,我们基本上是在教 Lisp 宏展开系统一个新的转换,它可以在运行程序之前使用这个转换来翻译代码。宏接收其参数中的原始源代码,形式为 Lisp 表达式。它的任务是帮助宏展开器将这个原始代码转换成标准的 Lisp 代码,这样 Lisp 小球就会保持快乐。
让我们更仔细地看看我们的let1宏是如何转换的。这是它的定义再次:
(defmacro let1 (var val &body body)
`(let ((,var ,val))
,@body))
这个defmacro调用的第一行
告诉宏展开器,“嘿,如果你在代码中看到一个以 let1 开头的形式,这就是你需要做的,将其转换成标准的 Lisp。”使用defmacro定义的宏也可以传入参数,这些参数将包含在宏中使用时在宏内部找到的原始源代码。let1宏有三个这样的参数传入:var、val和body
。那么这三个参数代表什么呢?

如你所见,当我们使用let1时,我们最终会在其中得到三个不同的表达式,它们是let1宏的参数:
var
第一个参数是我们定义的变量的名称。这个名称将在我们的宏中使用名为 var 的参数中可用。在这个例子中,它将等于符号 foo。
val
第二个表达式包含确定变量值的代码。在我们的宏中,这是第二个参数 val。它将等于列表 (+ 2 3)。
body
let1 调用中的第三个表达式是体代码,它使用了新创建的变量(在这种情况下,foo)。它将通过名为 body 的参数在宏中可用。
由于 let 命令允许其体中有多个语句,我们希望在 let1 宏中反映这种行为。这就是为什么在定义 let1 的 defmacro 命令中,最后的 body 参数前面有一个特殊的键词 &body。这告诉宏展开器“给我宏中所有剩余的表达式列表。”正因为如此,我们的 let1 示例中的 body 参数实际上是 ((* foo foo))——一个嵌套列表。在这个例子中,我们只在 let1 中放置了一个单条语句。
现在您已经看到了我们 let1 宏的参数值,让我们看看宏是如何使用这些信息将 let1 转换为 Lisp 编译器可以理解的标准的 let 命令的。在 Lisp 中转换源代码的最简单方法是使用带引号语法。(如果您不记得如何使用带引号,请参阅 How Quasiquoting Works。) 使用带引号,我们可以使用传递给 let1 的代码构建正确的 let 命令。以下是我们的 let1 宏再次供参考:
(defmacro let1 (var val &body body)
`(let ((,var ,val))
,@body))
如您所见,let1 宏返回一个以符号 let 开头的带引号的列表 ![httpatomoreillycomsourcenostarchimages783564.png],后面跟着变量名称和值,放置在一个适当的嵌套列表中,这是 Lisp 的 let 命令所要求的。逗号导致实际的变量名称和值被放置在这些位置。最后,我们将 let1 中的 body 代码放置在 let 命令中的类似位置 ![httpatomoreillycomsourcenostarchimages783562.png]。
使用切片逗号 (,@) 将 body 参数插入到转换后的代码中。要理解为什么需要以这种方式处理 body,请考虑以下我们 let1 宏的使用:
> `(let1 foo (+ 2 3)`
`(princ "Lisp is awesome!")`
`(* foo foo))`
Lisp is awesome!
25
在这种情况下,我们在 let 的体中放置了多个东西。记住,let 命令包含一个隐式的 progn 命令,并且可以在其中包含多个 Lisp 指令。我们新的 let1 宏通过在 body 参数前面放置特殊的 &body 标记来实现这一点,导致所有剩余的语法表达式作为列表传递给 let1。因此,在前面的例子中,body 参数包含代码 ((princ "Lisp is awesome!") (* foo foo))。
使用简单宏
现在我们已经编写了 let1 宏,让我们以更简洁的方式重写我们的自定义 add 函数:
(defun add (a b)
(let1 x (+ a b)
(format t "The sum is ˜a" x)
x))
这不是对眼睛更友好吗?
我们可以使用 macroexpand 命令来查看宏生成的代码。只需将宏的代码传递给 macroexpand,就像这样:
> `(macroexpand '(let1 foo (+ 2 3)`
`(* foo foo)))`
(LET ((FOO (+ 2 3))) (* FOO FOO)) ;
T
你现在可以看到 let1 生成的原始代码
。结尾的 T
只意味着 macroexpand 被传递了一个有效的宏,它能够展开。
当你的宏变得更加复杂时,你会发现 macroexpand 是测试和调试它们结构的一个非常有价值的工具。
更复杂的宏
假设你需要一个自定义的 my-length 命令。这是一个经典的列表消费者函数,它将计算列表的长度。我们将以适当的“尾调用优化”风格(在第十四章讨论)编写它,其中递归函数调用位于尾位置。以下是代码:
(defun my-length (lst)
(labels ((f (lst acc)
(if lst
(f (cdr lst) (1+ acc))
acc)))
(f lst 0)))
如你所见,这个函数有很多重复的内容,又一次给我们带来了那种令人讨厌的 déjà vu 感觉。在这个函数中有两个重复的模式:
-
就像其他列表消费者函数一样,我们有一个讨厌的检查来查看列表是否为空
和相关的 cdr使用
。 -
我们做了所有这些冗长的操作来创建一个局部函数
f
。
让我们编写一些使这个函数(以及具有相同重复的其他函数)更加简洁的宏。
分割列表的宏
首先,让我们创建一个 split 宏。它将使我们能够编写更简洁的列表消费者函数,例如我们的 my-length 函数。
列表消费者总是检查列表是否为空。如果不是,它们会使用 car 和/或 cdr 将列表拆分开,然后对列表的头部和/或尾部执行操作。split 宏为我们做了这件事。以下是使用完成的 split 宏时的样子:
> `(split '(2 3)`
`(format t "This can be split into ˜a and ˜a." head tail)`
`(format t "This cannot be split."))`
This can be split into 2 and (3).
> `(split '()`
`(format t "This can be split into ˜a and ˜a." head tail)`
`(format t "This cannot be split."))`
This cannot be split.
split 宏的第一个参数是要拆分为头部和尾部的列表
。如果这是可能的,split 宏中的下一个表达式将被调用
。作为额外的好处,我们的 split 宏自动为我们创建了两个变量,分别命名为 head 和 tail。这样,我们就不必总是在列表消费者函数内部调用 car 和 cdr。如果列表为空
,我们调用最后的表达式
。
让我们看看 split 宏的代码。注意,这个宏的初始版本包含一些我们很快就会讨论的 bug:
;Warning! Contains Bugs!
(defmacro split (val yes no)
`(if ,val
(let ((head (car ,val))
(tail (cdr ,val)))
,yes)
,no))
我们的 split 宏需要三个(而且只有三个)表达式作为参数
。这意味着当我们使用这个宏时,我们总是需要恰好三个项目。
split 需要生成的代码相当简单。首先,我们有一个 if 语句检查列表是否为空
。如果是,我们将分解列表并将其放入我们的两个局部变量 head
和 tail
。然后我们放入处理“是的,我们可以分解列表”情况的代码
。如果我们不能分解列表,我们调用无情况
。请注意,在无情况下,我们没有访问 head/tail 变量,因为如果列表不能分解,它们就不会被创建。
使用这个新的 split 宏,我们可以稍微清理一下我们的 my-length 宏:
(defun my-length (lst)
(labels ((f (lst acc)
(split lst
(f tail (1+ acc))
acc)))
(f lst 0)))
注意我们现在如何使用 split 创建的 tail 变量,简化了我们的代码
。自动生成此类变量的宏被称为 反身宏。
然而,我们的 split 宏还没有完成。尽管它基本上是可行的,但它包含一些微妙的错误,我们需要解决。
避免宏中的重复执行
宏中可能发生的一个常见错误是不正确地重复执行代码。事实上,我们当前的 split 宏包含这个缺陷。以下是一个清楚地显示问题的示例:
> `(split (progn (princ "Lisp rocks!")`
`'(2 3))`
`(format t "This can be split into ˜a and ˜a." head tail)`
`(format t "This cannot be split."))`
Lisp rocks!Lisp rocks!Lisp rocks!This can be split into 2 and (3).
在这个 split 的使用中,“Lisp rocks!” 被打印了三次,尽管它在原始代码中只出现了一次。这是怎么做到的?
记住,传递给宏的参数是原始源代码。这意味着传递给 split 的 val 参数包含 progn 语句的原始代码
,包括其中 princ 语句的原始代码。由于我们在 split 宏内部引用 val 三次,这导致 princ 语句执行了三次。
我们可以通过运行这个示例通过 macroexpand 来验证这一点:
> `(macroexpand '(split (progn (princ "Lisp rocks!")`
`'(2 3))`
`(format t "This can be split into ˜a and ˜a." head tail)`
`(format t "This cannot be split.")))`
(IF (PROGN (PRINC "Lisp rocks!") '(2 3))
(LET
((HEAD (CAR (PROGN (PRINC "Lisp rocks!") '(2 3))))
(TAIL (CDR (PROGN (PRINC "Lisp rocks!") '(2 3)))))
(FORMAT T "This can be split into ˜a and ˜a." HEAD TAIL))
(FORMAT T "This cannot be split.")) ;
T
如您所见,princ 语句出现了三次
。这导致意外的行为,并且效率低下,因为我们不必要地重复运行相同的代码。
如果你仔细思考这个问题,解决方案并不难找出。我们只需要在 split 宏内部创建一个局部变量,如下所示:
;Warning! Still contains a bug!
(defmacro split (val yes no)
`(let1 x ,val
(if x
(let ((head (car x))
(tail (cdr x)))
,yes)
,no)))
注意,我们在 split 的新版本中使用了 let1。正如这所示,在 其他 宏中使用宏是完全可行的。
现在,如果我们重新运行之前的示例,我们可以看到 split 的行为是正确的,princ 只打印了一次语句:
> `(split (progn (princ "Lisp rocks!")`
`'(2 3))`
`(format t "This can be split into ˜a and ˜a." head tail)`
`(format t "This cannot be split."))`
Lisp rocks!This can be split into 2 and (3).
不幸的是,这个新的 split 宏版本引入了 另一个 错误。让我们接下来解决这个新错误。
避免变量捕获
要看到我们 split 的新版本中的错误,尝试运行以下代码:
> `(let1 × 100`
`(split '(2 3)`
`(+ x head)`
`nil))`
*** - +: (2 3) is not a number
您能告诉我发生了什么吗?我们刚刚在 split 宏的新版本中创建了一个变量 x!以下是如果我们对 split 进行 macroexpand 时它的样子:
> `(macroexpand '(split '(2 3)`
`(+ x head)`
`nil))`
(LET ((X '(2 3)))
(IF X (LET ((HEAD (CAR X)) (TAIL (CDR X))) (+ X HEAD)) NIL)) ;
T
注意到 split 的展开版本包含了一个 x 的定义 ![http://atomoreilly.com/source/nostarch/images/783562.png]。这阻止了我们在麻烦示例中的竞争定义 ![http://atomoreilly.com/source/nostarch/images/783564.png]。在这种情况下,split 宏意外地 捕获 了变量 x 并以意想不到的方式覆盖了它。我们如何避免这个问题?
一个简单的解决方案是在宏中不创建变量 x,而是使用一些疯狂的长名称,比如 xqweopfjsadlkjgh。然后我们可以相当有信心,宏内部使用的变量永远不会与使用它的代码中的变量冲突。实际上,有一个名为 gensym 的 Common Lisp 函数,其任务就是生成疯狂的长变量名,正好用于这个目的:
> `(gensym)`
#:G8695
gensym 函数会为您创建一个唯一的变量名,保证在您的代码中不会与其他任何变量名冲突。您可能会注意到它还有一个特殊的前缀(#)来区分其他名称。Common Lisp 将基于 gensym 的名称视为特殊情况,并阻止您直接使用 gensym 变量的名称。
现在,让我们在 split 宏内部使用 gensym 函数来保护宏,防止它导致变量捕获:
;This function is finally safe to use
(defmacro split (val yes no)
(let1 g (gensym)
`(let1 ,g ,val
(if ,g
(let ((head (car ,g))
(tail (cdr ,g)))
,yes)
,no))))
在我们修订的宏的第一行中,我们定义了一个变量 g,它包含 gensym 名称 ![http://atomoreilly.com/source/nostarch/images/783564.png]。非常重要的一点是,注意这一行前面没有反引号。这意味着这一行代码是在 宏展开时间 运行的,而不是 运行时,在这个点上定义变量 g 是完全正常的。然而,下一行的 let1 前面有一个反引号 ![http://atomoreilly.com/source/nostarch/images/783562.png]。这一行将在运行时运行,所以我们不希望在这一点上使用硬编码的变量。在这个新版本中,我们改用存储在 g 中的唯一 gensym 名称。
现在每次使用 split 宏时,都会生成一个唯一的名称来保存内部值。我们可以通过运行一些示例并通过 macroexpand 来测试这一点:
> `(macroexpand '(split '(2 3)`
`(+ x head)`
`nil))`
(LET ((#:G8627 '(2 3))) (IF #:G8627 (LET ((HEAD (CAR #:G8627))
(TAIL (CDR #:G8627))) (+ X HEAD)) NIL)) ;
T
> `(macroexpand '(split '(2 3)`
`(+ x head)`
`nil))`
(LET ((#:G8628 '(2 3))) (IF #:G8628 (LET ((HEAD (CAR #:G8628))
(TAIL (CDR #:G8628))) (+ X HEAD)) NIL)) ;
T
注意,在这两种情况下都创建了一个不同名称的局部变量 
!这保证了变量名不仅在你自己的代码中是唯一的,而且在 split 宏被多次嵌套使用时也是唯一的。我们现在已经创建了一个完全调试好的 split 宏版本。
虽然现在它没有错误,但这并不意味着它没有变量捕获的问题。请注意,宏仍然定义了变量 head 和 tail。如果你在其他代码中使用这个函数,其中 head 或 tail 有不同的含义,你的代码就会失败!然而,在 head 和 tail 的情况下,捕获是有意的。在这种情况下,变量捕获是一个 特性,而不是错误——它是一个反身宏。正如我们讨论过的,这意味着它使得我们可以在宏的主体中使用命名变量或函数。
递归宏
让我们再次看看我们改进的 my-length 宏:
(defun my-length (lst)
(labels ((f (lst acc)
(split lst
(f tail (1+ acc))
acc)))
(f lst 0)))
正如我们讨论过的,在这段代码中还有一个额外的重复模式:局部函数 f 的创建。让我们写另一个宏来消除这种额外的视觉噪音:recurse。下面是 recurse 宏的使用示例:
> `(recurse (n 9)`
`(fresh-line)`
`(if (zerop n)`
`(princ "lift-off!")`
`(progn (princ n)`
`(self (1- n)))))`
9
8
7
6
5
4
3
2
1
lift-off!
recurse 宏的第一个参数是一个变量列表及其起始值
。在这种情况下,我们只声明了一个变量(n)并将其起始值设置为 9。宏中的其余行构成了递归函数的主体。
在主体中,我们首先开始一个新行
。然后我们检查 n 是否已经达到零
。如果是,我们打印 “起飞!”
。否则,我们打印当前的数字
并再次递归地调用该函数。像我们的 split 宏一样,recurse 宏也是反身的。在 recurse 的情况下,它使得一个名为 self 的函数可用,我们在准备执行递归时调用它
。此时,我们还将 n 减去 1,以降低倒计时数字。
现在我们已经看到了 recurse 应该如何工作,让我们编写这个 recurse 宏。为了处理参数列表和起始值,对我们来说有一个可以将项目分组为对列表的函数是有用的。下面是一个完成这个任务的函数,pairs:
> `(defun pairs (lst)`
`(labels ((f (lst acc)`
`(split lst`
`(if tail`
`(f (cdr tail) (cons (cons head (car tail)) acc))`
`(reverse acc))`
`(reverse acc))))`
`(f lst nil)))`
PAIRS
> `(pairs '(a b c d e f))`
((A . B) (C . D) (E . F))
pairs函数是一个尾调用优化的列表消耗者,它讽刺地有自己的局部函数f ![http://atomoreilly.com/source/no_starch_images/783564.png]。(很快我们就不再需要声明这样的函数了。)它使用split从列表中分离出一个项 ![http://atomoreilly.com/source/no_starch_images/783562.png]。然而,由于它需要一次性处理列表中的两个项(一个对),我们需要运行一个额外的检查来查看尾部是否为空 ![http://atomoreilly.com/source/no_starch_images/783560.png]。如果没有项在列表中 ,我们返回累积的值。否则,我们递归地处理列表的其余部分,将一对新项放入累加器 ![http://atomoreilly.com/source/no_starch_images/783554.png]。
现在我们终于准备好编写recurse宏了:
(defmacro recurse (vars &body body)
(let1 p (pairs vars)
`(labels ((self ,(mapcar #'car p)
,@body))
(self ,@(mapcar #'cdr p)))))
如您所见,它只是将递归转换成了传统的局部函数。首先,它使用我们新的pairs函数来分解变量名和起始值,并将结果放入p ![http://atomoreilly.com/source/no_starch_images/783564.png]。然后它定义了一个简单地命名为self的局部函数。self的变量名是p中的奇数项 ![http://atomoreilly.com/source/no_starch_images/783562.png]。由于我们希望self在宏内部可以通过词法引用访问,我们使用一个普通的名字而不是gensym名字来为这个函数命名。在宏的底部,我们简单地调用self,传入所有起始值 ![http://atomoreilly.com/source/no_starch_images/783560.png]。
现在我们已经创建了recurse宏,让我们再次使用这个新的语言结构来清理我们的my-length函数:
(defun my-length (lst)
(recurse (lst lst
acc 0)
(split lst
(f tail (1+ acc))
acc)))
如您所见,在这个版本的my-length函数中,几乎没有重复或视觉上的杂音。
现在您可以体会到宏在尝试编写干净、简洁的代码时是多么有帮助。然而,宏的广泛使用也将要求您承担一些成本,您需要意识到这些成本。接下来,我们将探讨宏的潜在缺点。
宏:危险与替代方案
宏允许我们编写生成其他代码的代码,这使得 Lisp 语言成为元编程和新语言想法原型设计的绝佳工具。但是,在某种程度上,宏只是一个小把戏:它们让你欺骗 Lisp 编译器/解释器接受你自己的定制语言结构,并将它们视为标准 Lisp。它们确实是程序员工具箱中的强大工具,但它们并不像你在本书中遇到的其他一些编程工具那样优雅。
宏的主要缺点是它们可能会让其他程序员难以理解你的代码。毕竟,如果你正在创建自己的语言方言,其他程序员可能不熟悉它。甚至你未来的自己——比如说,一两年后——如果过度使用了宏,也可能难以理解你代码的结构。正因为如此,经验丰富的 Lisp 程序员会尽最大努力在可能的情况下使用其他技术来代替宏编程。通常,一个初学者 Lisp 程序员会在可以用其他更干净的方式解决的问题中编写宏。
例如,看到我们如何通过添加名为 split 和 recurse 的几个宏来清理我们的 my-length 函数,这很有趣。然而,在前两章中,你学习了另一个工具,函数式编程,它也可以用来清理列表吞噬函数。函数程序员经常使用的一个强大函数是 reduce。它是一个高阶函数,接受一个函数和一个列表,并将为列表中的每个值调用该函数。以下是使用强大的 reduce 函数而不是宏重写的 my-length 函数:
(defun my-length (lst)
(reduce (lambda (x i)
(1+ x))
lst
:initial-value 0))
如你所见,这个 my-length 的新版本轻易地超越了我们的旧版本。它更短,并且不依赖于我们创建的任何非标准宏。
reduce 的第一个参数是我们的归约函数
。它的任务是跟踪并更新一个累积值,这里命名为 x。这个变量 x 将持有当前的累积值,在这种情况下,将是到目前为止的列表长度。这意味着我们可以简单地给 x 加一,将其更新到新的值
。由于归约函数将为列表中的每个项目调用一次,它最终将生成列表的长度。(归约函数还接收一个参数,即列表中的当前项目,这里给出的是变量 i。然而,我们不需要它来计算列表的长度。)传递给 reduce 的下一个项目是我们想要归约的列表
。最后,由于我们正在计算的累积长度应该有一个初始值为零,我们通过将 :initial-value 关键字参数设置为零来表示这一点
。
显然,还有其他场景,我们在这章中创建的列表吞噬宏仍然很有用。有许多情况,reduce 函数不能那么容易地使用。所以最终,仍然有许多情况,创建自己的 Lisp 方言正是解决问题的正确方法,正如你将在下一章中看到的。
你学到了什么
本章介绍了宏编程。你学到了以下内容:
-
宏让你可以编写编写代码的代码。有了宏,你可以在编译器能够查看它之前,创建自己的编程语言并将其转换为标准 Lisp。
-
宏可以让你在编写代码时摆脱那种似曾相识的感觉,在没有任何其他方法可以做到的情况下。
-
在编写宏时,你必须小心,以免导致代码意外地重复执行。
-
你需要小心避免在宏中发生意外的变量捕获。你可以通过使用
gensym命名来避免这种情况。 -
如果宏创建的变量有意暴露,作为宏的一个特性,那么这个宏被称为反身宏。
-
宏编程是一个非常强大的技术。然而,尽可能尝试使用函数式编程来解决问题。宏应该始终是最后的手段。
第十七章。领域特定语言
使用宏的最好理由之一是进行领域特定语言(DSL)编程。DSL 编程是一种高级宏编程技术,它允许我们通过大幅改变 Lisp 代码的结构和外观来优化它以适应特定目的,从而解决困难的编程问题。尽管宏对于进行 DSL 编程不是严格必要的,但通过编写一组宏,你可以在 Lisp 中轻松创建一个 DSL。
什么是领域?
根据 2000 年的美国人口普查,美国平均家庭有 1.86 个孩子。由于没有哪个家庭的孩子数量正好是 1.86 个,因此很明显,没有哪个家庭真正完美地平均。同样,也没有所谓的平均计算机程序。每个程序都是为了解决特定的问题而设计的,每个人类探究领域,或领域,都有其独特的需求,这些需求会影响解决该领域问题的程序。通过 DSL,我们增强了编程语言的核心,以考虑这些特定领域的需求,从而可能使我们的代码更容易编写和理解。
让我们来看看一些特定的领域,并创建一些领域特定语言(DSL),这样我们就可以使用 Lisp 在这些领域内轻松工作。在本章中,我们将创建两个不同的 DSL。首先,我们将创建一个用于编写可伸缩矢量图形(SVG)文件的 DSL。然后我们将编写一个用于创建文本冒险游戏命令的 DSL——我们最终将把我们的巫师冒险游戏从第五章和第六章升级到完全可玩的状态!
编写 SVG 文件

SVG 格式是一种用于绘制图形的文件格式。在这个格式中,你指定像圆圈和多边形这样的对象,然后将它们传递给一个兼容的计算机程序来查看。由于 SVG 格式使用纯数学函数而不是原始像素来指定绘图,因此程序可以轻松地将 SVG 图像渲染成任何大小,使得这种格式的图像易于缩放。
SVG 格式目前正受到许多网络开发者的关注。所有现代浏览器(不包括 Microsoft Internet Explorer)都原生支持 SVG。最近,Google 发布了一套名为 SVG Web 的库,为 SVG 提供了良好的支持,甚至在 Internet Explorer 中也是如此。这使得 SVG 在超过 90% 的当前网络浏览器中工作。最终,SVG 已经成为在网站上绘制图形的实用和高效选项。
SVG 格式建立在 XML 格式之上。以下是一个完整的 SVG 文件示例:
<svg >
<circle cx="50"
cy="50"
r="50"
style="fill:rgb(255,0,0);stroke:rgb(155,0,0)">
</circle>
<circle cx="100"
cy="100"
r="50"
style="fill:rgb(0,0,255);stroke:rgb(0,0,155)">
</circle>
</svg>
简单地复制这段文本并将其放置在名为 example.svg 的文件中(或从 landoflisp.com/ 下载此文件)。然后你可以从 Firefox 网络浏览器(Safari、Chrome 和 Opera 网络浏览器也应该可以)打开该文件。
这是你应该看到的内容,有一个红色和蓝色的圆圈:

现在,让我们编写一些宏和函数,让我们能够直接在 Common Lisp 中创建这样的图片!
使用标签宏创建 XML 和 HTML
XML 数据格式(就像 HTML 数据格式一样)主要由嵌套标签组成:
<mytag>
<inner_tag>
</inner_tag>
</mytag>
每个标签
都有一个对应的闭合标签
。闭合标签具有相同的名称,但前面有一个斜杠。此外,标签可能包含属性:
<mytag color="BLUE" height="9"></mytag>
在这个例子中,我们创建了一个名为 mytag 的标签,它具有蓝色属性和高度为 9。
编写宏辅助函数
通常,当你编写一个宏来执行一个任务时,你会发现你的宏需要做的很多工作可以通过一个函数来处理。正因为如此,通常明智的做法是首先编写一个辅助函数,它完成宏需要做的绝大部分工作。然后你编写宏,通过利用辅助函数使其尽可能简单。这就是我们在编写一个用于在 Lisp 中创建 XML 风格标签的宏时将要做的。
这是我们的辅助函数,名为 print-tag,它打印一个单个的开头(或闭合)标签:
(defun print-tag (name alst closingp)
(princ #\<)
(when closingp
(princ #\/))
(princ (string-downcase name))
(mapc (lambda (att)
(format t " ˜a=\"˜a\"" (string-downcase (car att)) (cdr att)))
alst)
(princ #\>))
首先,print-tag函数打印一个开方括号
。由于这只是一个字符,我们使用字面字符语法,通过在括号前加上#. 然后我们检查谓词closingp
。如果它是真的,标签前面需要有一个斜杠,使其成为一个闭合标签。然后我们打印标签的名称,使用string-downcase函数转换为小写
。接下来,我们遍历属性列表alst中的所有属性
并打印出每个属性/值对
。最后,我们通过添加一个闭合方括号
来结束。
以下是一个print-tag函数的示例用法。由于它是一个普通函数而不是宏,所以在 REPL 中调试起来很容易。这也是为什么在创建宏时使用辅助函数是一个好主意。
> `(print-tag 'mytag '((color . blue) (height . 9)) nil)`
<mytag color="BLUE" height="9">
如您所见,这个函数很好地打印了一个 XML 标签。然而,如果所有标签都必须以这种方式创建,那将是一件非常繁琐的事情。这就是为什么我们将编写tag宏的下一个原因。
创建标签宏 Macro
我们将要创建的tag宏是从保罗·格雷厄姆的 Arc Lisp 方言中同名宏中采纳的。它在几个关键方面改进了print-tag函数,所有这些都无法在没有宏的情况下修复:
-
标签总是成对出现。然而,如果我们想要嵌套标签,函数将无法打印出包围其内部打印的标签的标签。这是因为它要求我们在嵌套标签评估前后执行代码。这在宏中是可能的,但在函数中则不行。
-
标签名称和属性名称通常不需要以动态方式更改。正因为如此,在标签名称前加上单引号是多余的。换句话说,标签名称应该默认被视为数据模式中的内容。
-
与标签名称不同,属性值的动态生成是非常希望的。我们的宏将有一个语法,将属性值放入代码模式,这样我们就可以执行 Lisp 代码来填充这些值。
理想情况下,这就是我们希望在 REPL 中使用标签宏的方式:
> `(tag mytag (color 'blue height (+ 4 5)))`
<mytag color="BLUE" height="9"></mytag>
注意,标签名称和属性列表不再需要前面的引号。此外,现在使用 Lisp 代码动态计算属性变得容易。在这种情况下,我们计算高度是 4 加 5。
这是完成这个任务的宏:
(defmacro tag (name atts &body body)
`(progn (print-tag ',name
(list ,@(mapcar (lambda (x)
`(cons ',(car x) ,(cdr x)))
(pairs atts)))
nil)
,@body
(print-tag ',name nil t)))
如你所料,宏首先调用print-tag来生成开标签 ![http://atomoreilly.com/source/nostarch/images/783564.png]。当我们为print-tag生成属性列表时,这有点棘手,因为我们希望属性的值处于代码模式。我们通过使用list来包装属性来实现这一点 ![http://atomoreilly.com/source/nostarch/images/783562.png]。然后我们使用mapcar遍历属性,这些属性与pairs函数配对 ![http://atomoreilly.com/source/nostarch/images/783554.png]。(记住,我们在上一章的末尾创建了pairs函数。)对于每个属性对,我们在列表中生成一个代码片段,该片段由 cons 组成,属性值的面前没有引号,这样我们就可以动态地计算它 ![http://atomoreilly.com/source/nostarch/images/783560.png]。
接下来,我们将所有嵌套在tag宏内部的代码放入其中,以便在开标签之后调用它 ![http://atomoreilly.com/source/nostarch/images/783510.png]。最后,我们创建一个闭标签 ![http://atomoreilly.com/source/nostarch/images/783544.png]。
为了更好地理解这个宏如何处理属性列表,让我们将我们的示例输出传递给macroexpand:
> `(macroexpand '(tag mytag (color 'blue height (+ 4 5))))`
(PROGN (PRINT-TAG 'MYTAG
(LIST (CONS 'COLOR 'BLUE)
(CONS 'HEIGHT (+ 4 5)))
NIL)
(PRINT-TAG 'MYTAG NIL T)) ;
T
通过查看宏展开,应该很清楚tag宏如何构建传递给print-tag的属性列表 ![http://atomoreilly.com/source/nostarch/images/783564.png]以及它如何允许我们动态生成属性值,例如高度属性 ![http://atomoreilly.com/source/nostarch/images/783562.png]。
下面是使用此宏的另一个示例,现在包含两个内部标签:
> `(tag mytag (color 'blue size 'big)`
`(tag first_inner_tag ())`
`(tag second_inner_tag ()))`
<mytag color="BLUE" size="BIG">
<first_inner_tag></first_inner_tag>
<second_inner_tag></second_inner_tag>
</mytag>
注意它如何正确地使用适当的 XML 开闭标签包围内部嵌套标签。同时,我还添加了换行和缩进来使输出更清晰 ![http://atomoreilly.com/source/nostarch/images/783564.png]。tag函数的实际输出始终打印在单行上,没有换行或缩进。
使用tag宏生成 HTML
tag宏可以用于生成 XML 或 HTML。例如,我们可以这样做来生成一个“Hello World”HTML 文档:
> `(tag html ()`
`(tag body ()`
`(princ "Hello World!")))`
<html><body>Hello World!</body></html>
由于 HTML 使用预定义的标签(与 XML 不同,XML 的标签可以有任意名称),我们可以为特定的 HTML 标签编写简单的宏,使它们在 Lisp 中编写 HTML 变得更加容易。例如,这里有一些简单的html和body宏:
(defmacro html (&body body)
`(tag html ()
,@body))
(defmacro body (&body body)
`(tag body ()
,@body))
现在,我们可以更优雅地编写我们的“Hello World”HTML 示例:
> `(html`
`(body`
`(princ "Hello World!")))`
<html><body>Hello World!</body></html>
然而,我们希望使用tag宏来创建 SVG 绘图。因此,让我们扩展我们的 SVG 领域 DSL。
创建 SVG 特定宏和函数
首先,让我们编写svg宏,它包含整个 SVG 图像。如下所示:
(defmacro svg (&body body)
`(tag svg (xmlns "http://www.w3.org/2000/svg"
"xmlns:xlink" "http://www.w3.org/1999/xlink")
,@body))
svg宏建立在tag宏之上。对于我们的目的,SVG 图像需要创建两个特殊属性:
-
xmlns属性告诉 SVG 查看器(在我们的例子中,是 Firefox 网络浏览器)在哪里可以找到 SVG 格式的正确文档
。 -
第二个属性允许在图片内启用超链接
。我们将在下一章的更高级示例中使用这个超链接功能。
要绘制图像,我们需要操作颜色。为了简化问题,我们将只将颜色表示为存储在列表中的 RGB 三元组。例如,颜色 (255 0 0) 是亮红色。
通常,生成特定颜色的亮或暗变体很有用。下面的 brightness 函数为我们做到了这一点:
(defun brightness (col amt)
(mapcar (lambda (x)
(min 255 (max 0 (+ x amt))))
col))
如果你将亮红色传递给这个函数并将亮度设置为 -100,你可以看到它会生成一个较深的红色:
> `(brightness '(255 0 0) −100)`
(155 0 0)
接下来,让我们创建一个函数来设置 SVG 图像元素的样式:
(defun svg-style (color)
(format nil
"˜{fill:rgb(˜a,˜a,˜a);stroke:rgb(˜a,˜a,˜a)˜}"
(append color
(brightness color −100))))
svg-style 函数接受一个颜色,然后设置图片元素的填充和描边(轮廓)
。通过使用我们的亮度函数,我们可以使轮廓成为填充的较暗变体
。这样,我们只需要为图片中的每个元素指定一个颜色,同时保持令人愉悦的外观。
现在,让我们创建一个函数来绘制圆。由于我们不需要在圆内嵌套其他 SVG 标签,因此不需要编写绘制圆的宏——一个函数就足够了。
(defun circle (center radius color)
(tag circle (cx (car center)
cy (cdr center)
r radius
style (svg-style color))))
我们将想要设置每个圆的中心、半径和颜色。中心需要分配给圆的 cx
和 cy
SVG 属性。半径放在 r 属性
中。我们使用我们的 svg-style 函数
设置圆的样式。
现在,我们已经准备好使用我们新的 DSL 来绘制之前展示的简单 SVG 图像,这里是我们的操作方法:
> `(svg (circle '(50 . 50) 50 '(255 0 0))`
`(circle '(100 . 100) 50 '(0 0 255)))`
<svg xmlns:xlink="http://www.w3.org/1999/
xlink"><circle cx="50" cy="50" r="50"
style="fill:rgb(255,0,0);stroke:rgb(155,0,0)"></circle><circle cx="100"
cy="100" r="50" style="fill:rgb(0,0,255);stroke:rgb(0,0,155)"></circle></svg>
现在我们有一个功能性的 SVG DSL。让我们给我们的 DSL 添加更多功能,以便我们能够欣赏 DSL 可以给我们的程序带来的力量。
构建更复杂的 SVG 示例
让我们在我们的 SVG DSL 中添加一个新功能,使其能够轻松绘制任意多边形:
(defun polygon (points color)
(tag polygon (points (format nil
"˜{˜a,˜a ˜}"
(mapcan (lambda (tp)
(list (car tp) (cdr tp)))
points))
style (svg-style color))))
SVG 多边形将多边形的所有点存储在 points 属性中
。我们通过使用包含 ˜{ ˜} 控制字符串的 format 语句来构建点的列表
。记得从第十一章(Chapter 11)中,这些控制字符串允许我们在 format 函数内部迭代列表。在这种情况下,我们正在迭代点的列表。然后我们使用 mapcan
,你可能记得这与使用 mapcar 后跟 append 相同,来展平点对的列表。
在本例中,我们将绘制一些随机游走。随机游走是一种图表,如果你在每个时间点决定抛硬币,然后向上或向下移动一步,你将得到的结果。随机游走的行为与股市中的股票非常相似。它们通常被用作金融建模的起点。以下是一个生成随机游走的函数:
(defun random-walk (value length)
(unless (zerop length)
(cons value
(random-walk (if (zerop (random 2))
(1- value)
(1+ value))
(1- length)))))
此函数从 value 参数开始构建一个数字列表。然后它随机增加或减少此值。我们使用 random 函数
来选择移动的方向。(注意,为了保持简单,此函数没有进行尾调用优化,因为 cons 发生在递归调用之后。)
下面是如何使用 random-walk 函数的一个示例:
> `(random-walk 100 10)`
(100 101 102 101 100 101 102 103 102 103)
现在我们使用我们的 SVG DSL 来绘制一系列随机游走图片:
(with-open-file (*standard-output* "random_walk.svg"
:direction :output
:if-exists :supersede)
(svg (loop repeat 10
do (polygon (append '((0 . 200))
(loop for x
for y in (random-walk 100 400)
collect (cons x y))
'((400 . 200)))
(loop repeat 3
collect (random 256))))))
由于本例中创建的数据量相当巨大,我们将数据直接输出到文件(命名为 random_walk.svg),而不是打印到 REPL。我们通过重定向 *standard-output* 动态变量
,这是一种在第十二章(Chapter 12)中介绍的技术。注意我们如何可以自由地混合 Lisp 代码和我们的 DSL 命令。例如,我们可以在 SVG 宏内部直接循环以一次性生成 10 个多边形
。
为了使图表更美观,我们将用颜色填充每条图表线下的区域。为此,我们将每条线表示为一个多边形,底边沿着图表的底部(y 坐标为 200)包括作为点来闭合形状:

这也是为什么我们在创建每个多边形时,会添加左下角
和右下角
的点。为了增加更多乐趣,我们还随机化了每条图表线的颜色
。
下面是使用这个非常简单的 DSL 代码生成的随机图表的示例:

现在你已经看到在 Lisp 中编写 XML、HTML 和 SVG DSL 是多么容易,让我们创建一种完全不同的 DSL——一种将允许我们为第五章(第五章. 构建文本游戏引擎)和第六章(第六章. 与世界交互:在 Lisp 中读取和打印)中的巫师冒险游戏构建自定义游戏命令的 DSL!
为巫师冒险游戏创建自定义游戏命令

如果你记得,当我们上次在第五章和第六章中遇到以我们的巫师和学徒为主角的游戏时,我们可以在世界中四处走动并捡起物品。然而,我们实际上无法执行任何其他有趣或有趣味的动作。为了让游戏变得有趣,它应该包括可以在游戏中的特定对象和/或位置执行的特殊动作。我们需要可以亲吻的青蛙、可以战斗的龙,甚至可能还有可以救出的少女!
在游戏中创建这些有趣的活动提出了独特的挑战。一方面,这类不同的游戏动作之间显然有许多相似之处。例如,大多数动作将需要我们拥有一个对象。另一方面,它们都需要具有独特和个性化的属性(通过特定命令的 Lisp 代码实现)或者游戏会变得无聊。正如你将看到的,一个 DSL 可以帮助你向你的游戏添加许多这样的独特命令。
要从这里运行代码到本章结束,我们将使用第五章和第六章中的所有游戏代码。只需将这些章节中的代码放入名为wizards_game.lisp的文件中(或从landoflisp.com/下载wizards_game.lisp)。游戏加载后,你可以在 CLISP REPL 中直接输入游戏命令,如 look。或者,你可以使用我们在第六章中创建的game-repl命令来获得更精致的游戏体验。记住,quit命令将带你退出游戏 REPL。
这里是如何从 REPL 加载游戏代码并开始运行游戏命令的步骤:
> `(load "wizards_game.lisp")`
;; Loading file wizards_game.lisp ...
;; Loaded file wizards_game.lisp
T
> `(look)`
(YOU ARE IN THE ATTIC. THERE IS A GIANT WELDING TORCH IN THE CORNER. THERE IS
A LADDER GOING DOWNSTAIRS FROM HERE.)
> `(game-repl)`
`look`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs
from here. You see a whiskey on the floor. You see a bucket on the floor.
`quit`
手动创建新的游戏命令
那么,我们的游戏领域特定语言(DSL)应该是什么样子呢?真正了解的唯一方法就是首先手动创建一些命令。然后我们可以看看不同命令之间是否存在任何共同模式,这些模式可以作为我们 DSL 的基础。
焊接命令

在巫师房子的阁楼里有一台焊接机。让我们允许玩家如果带着这些物品到那个地点,就可以将链条焊接在桶上。以下是实现这一功能的代码:
(defun have (object)
(member object (inventory)))
(defparameter *chain-welded* nil)
(defun weld (subject object)
(if (and (eq *location* 'attic)
(eq subject 'chain)
(eq object 'bucket)
(have 'chain)
(have 'bucket)
(not *chain-welded*))
(progn (setf *chain-welded* t)
'(the chain is now securely welded to the bucket.))
'(you cannot weld like that.)))
首先,我们需要一种简单的方法来检查玩家是否正在携带一个物品,使用 have 函数
。记住,我们创建了一个名为 inventory 的命令来检查玩家携带的物品。如果一个物品是存货列表的成员,这意味着玩家必须“拥有”那个物品。
接下来,我们的程序需要一种方法来跟踪链条和桶是否已经焊接在一起,因为游戏后期会有一些只有在焊接发生后才能执行的动作。为此,我们创建了一个名为 *chain-welded* 的全局动态变量
。
最后,我们需要创建焊接命令本身
。只有在满足一系列条件的情况下才能进行焊接
:
-
你必须身处阁楼。
-
你必须将
chain和bucket作为焊接命令的主题和宾语。 -
你必须随身携带链条和桶。
-
链条和桶不能已经焊接在一起。
如果这些条件得到满足,我们将我们的 *chain-welded* 变量设置为 true
并打印一条表示成功的消息。如果任何条件失败,我们将表明焊接未成功
。
让我们在 CLISP REPL 中尝试这个命令:
> `(weld 'chain 'bucket)`
(YOU CANNOT WELD LIKE THAT.)
嗯,这正是正确的回答。毕竟,我们不在阁楼里,也没有携带正确的物品。到目前为止,一切顺利。
接下来,让我们在我们的花哨的 game-repl 中尝试我们的新命令:
> `(game-repl)`
`weld chain bucket`
I do not know that command.
`quit`
什么?为什么它“不知道”那个命令?答案是简单的:我们的 game-repl 有一些基本的保护措施来防止运行未经授权的命令。为了解决这个问题,我们需要将 weld 添加到我们允许的命令列表中:
> `(pushnew 'weld *allowed-commands*)`
(WELD LOOK WALK PICKUP INVENTORY)
> `(game-repl)`
`weld chain bucket`
You cannot weld like that.
通过使用 pushnew 命令,只有在 weld 函数尚未出现在该列表中时,才会将其添加到允许的命令中。问题解决!
一个浸没命令

在巫师的花园里有一口井。让我们创建一个命令,让玩家可以将桶浸入井中,以装满水:
(setf *bucket-filled* nil)
(defun dunk (subject object)
(if (and (eq *location* 'garden)
(eq subject 'bucket)
(eq object 'well)
(have 'bucket)
*chain-welded*)
(progn (setf *bucket-filled* 't)
'(the bucket is now full of water))
'(you cannot dunk like that.)))
(pushnew 'dunk *allowed-commands*)
就像我们的 weld 命令一样,我们首先需要一个变量来跟踪水桶是否已经被填满 ![httpatomoreillycomsourcenostarchimages783564.png]。接下来,我们需要一个 dunk 函数 ![httpatomoreillycomsourcenostarchimages783562.png]。注意,在浸没过程中,我们再次需要满足一系列条件才能成功完成动作 ![httpatomoreillycomsourcenostarchimages783560.png]。其中一些与我们的焊接命令所需的条件相似。例如,浸没也需要玩家在特定位置拥有正确的对象。其他条件是浸没特有的,例如,玩家在能够浸没之前需要有一个焊接好的链条。最后,我们需要将 dunk 函数推送到允许的动作列表中 ![httpatomoreillycomsourcenostarchimages783554.png]。

游戏动作宏
现在我们已经为我们的游戏创建了两个自定义游戏动作,很明显,weld 和 dunk 命令在某些方面非常相似。然而,正如我们的 SVG 库一样,每个游戏命令都需要包含一定量的动态逻辑,以自定义命令的行为。让我们编写一个 game-action 宏来处理这些问题。这将使创建新的游戏命令变得容易得多。
(defmacro game-action (command subj obj place &body body)
`(progn (defun ,command (subject object)
(if (and (eq *location* ',place)
(eq subject ',subj)
(eq object ',obj)
(have ',subj))
,@body
'(i cant ,command like that.)))
(pushnew ',command *allowed-commands*)))
这个 game-action 宏体现了我们的 dunk 和 weld 命令之间的共同模式。game-action 的参数是命令的名称、参与动作的两个对象、需要发生的地方,以及 body 参数中的任意附加代码,这允许我们向命令添加自定义逻辑 ![httpatomoreillycomsourcenostarchimages783564.png]。
game-action 宏的主要任务是定义一个新函数来处理命令 ![httpatomoreillycomsourcenostarchimages783562.png]。你可能觉得宏能够独立定义一个新函数是非常强大的,但没有任何东西可以阻止它这样做。我希望这个例子能让你看到 Common Lisp 宏系统是多么灵活和令人费解。
由于这个游戏的所有游戏动作都需要位置、主题和对象,我们可以在宏内部直接处理一些条件 ![httpatomoreillycomsourcenostarchimages783560.png]。然而,我们将为每个特定命令留出其他条件。注意,例如,游戏句子的主题需要由玩家拥有 ![httpatomoreillycomsourcenostarchimages783554.png],但对象则不需要。这很有道理,因为有许多可以执行的动作,例如“扔石头龙”,其中句子的对象(龙)不需要在玩家的库存中。
一旦满足了基本的宏级条件,我们将把其余的逻辑推迟到单个命令的级别!
。如果条件没有满足,我们将打印一个错误消息,该消息根据当前命令进行了定制!
。最后,我们将命令 pushnew 到允许的命令列表中,用于我们的花哨的 game-repl!
。
在这个宏中,我们不做的一件事是定义或设置任何全局变量。如果游戏命令需要定义 *chain-welded* 或 *bucket-filled* 全局变量,它必须自己完成。这很有道理,因为很明显,我们的游戏的状态变量和特定命令之间不可能存在一对一的关系。例如,某些命令可能被允许多次执行,使得状态变得不必要。或者一个动作可能依赖于多个状态变量。这种命令的多样性使得它们独特且有趣。
使用这个宏,我们现在有一个简单的领域特定语言(DSL)来创建新的游戏动作!本质上,这个命令给我们提供了一个自己的编程语言,专门用于创建游戏命令的领域。让我们用我们新的游戏命令编程语言重写之前的 weld 和 dunk 命令:
(defparameter *chain-welded* nil)
(game-action weld chain bucket attic
(if (and (have 'bucket) (not *chain-welded*))
(progn (setf *chain-welded* 't)
'(the chain is now securely welded to the bucket.))
'(you do not have a bucket.)))
(setf *bucket-filled* nil)
(game-action dunk bucket well garden
(if *chain-welded*
(progn (setf *bucket-filled* 't)
'(the bucket is now full of water))
'(the water level is too low to reach.)))
如您所见,这些命令现在看起来更容易阅读。注意 weld 命令是如何检查桶的所有权的,而 dunk 命令则不需要检查井的所有权。
为了进一步说明使用宏来实现我们的游戏命令 DSL 的价值,让我们实现一个更复杂的游戏命令,splash:
(game-action splash bucket wizard living-room
(cond ((not *bucket-filled*) '(the bucket has nothing in it.))
((have 'frog) '(the wizard awakens and sees that you stole his frog.
he is so upset he banishes you to the
netherworlds- you lose! the end.))
(t '(the wizard awakens from his slumber and greets you warmly.
he hands you the magic low-carb donut- you win! the end.))))
对于这个命令,可能有三种不同的场景会发生:
-
桶是空的。
-
你的桶满了,但你偷了青蛙。在这种情况下,你输了。
-
你的桶满了,但你没有偷青蛙。你赢了!
使用我们的 game-action 宏,我们可以支持许多动作命令,每个命令都有特殊的行为。尽管如此,我们仍然能够避免不必要的重复。
注意
game-action 命令在宏的体内部暴露了 subject 和 object 变量。这允许游戏命令访问这些信息,但如果有创建 game-action 命令的代码也使用了名为 subject 和 object 的变量,则可能会引起名称冲突。作为一个练习,尝试修改 game-action 宏,使 subject 和 object 变量被 gensym 名称替换,如第十六章 中所述。
让我们尝试完成巫师冒险游戏!
这里是巫师冒险游戏的一个示例运行,展示了我们在这个游戏中加入的一些丰富功能。自己玩一玩,看看你是否能赢得魔法甜甜圈!

> `(game-repl)`
`look`
You are in the living-room. There is a wizard snoring loudly on the couch.
There is a door going west from here. There is a ladder going upstairs from
here. You see a whiskey on the floor. You see a bucket on the floor.
`pickup bucket`
You are now carrying the bucket
`pickup whiskey`
You are now carrying the whiskey
`inventory`
Items- whiskey bucket
`walk upstairs`
You are in the attic. There is a giant welding torch in the corner. There is a
ladder going downstairs from here.
`walk east`
You cannot go that way.
`walk downstairs`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs from here.
`walk west`
You are in a beautiful garden. There is a well in front of you. There is a
door going east from here. You see a frog on the floor. You see
a chain on the floor.
`dunk bucket well`
The water level is too low to reach.
`pickup chain`
You are now carrying the chain
`walk east`
You are in the living-room. A wizard is snoring loudly on the couch. There is
a door going west from here. There is a ladder going upstairs
from here.
`splash bucket wizard`
The bucket has nothing in it.
你学到了什么
本章展示了如何在 Lisp 中创建 DSLs。你学习了以下内容:
-
当你需要为非常具体的领域进行一些奇怪的编程时,宏是一个很好的解决方案。有了它们,你可以创建自己的 DSL。
-
通常,为宏(如
print-tag)编写一个辅助函数是有意义的,然后编写一个宏(如tag)来添加只有宏才能提供的改进。这些改进通常涉及能够以更清晰、通常更安全的语法访问代码。 -
你可以将领域特定语言(DSLs)与常规 Lisp 代码混合,这给了你很多力量。
-
当你需要编写非常具体的代码时,DSLs 非常有用——无论是网页代码、绘图代码还是构建特殊游戏命令的代码。
第十八章。懒编程
在第十四章中,你了解到当使用干净、类似数学的函数构建程序时,你的程序可以更简单、更干净。这些函数总是返回相同的结果,这完全取决于传入它们的参数。当你只依赖这些类型的函数时,你就是在使用函数式编程风格。
然而,当我们使用函数式编程风格在第十五章创建 Dice of Doom 游戏时,一个问题变得明显:如果你的函数完全依赖于传入它们的参数,你需要传入的东西通常会变得非常大。
在 Dice of Doom 游戏中,我们传递game-tree变量,它包含游戏棋盘所有可能未来的状态。这是一个真正的巨大结构,即使在可怜的 3x3 棋盘上也是如此!因此,虽然游戏当前的设计使我们的代码非常简单和优雅,但它似乎不太适合更大的游戏棋盘,这会导致指数级更大的游戏树。我们唯一可能保持优雅代码的同时,允许在更大的棋盘上进行更复杂的游戏的方法是,使我们的程序足够智能,从游戏开始时就不查看每一个可能的走法。这是可能的吗?是的,是可能的,使用一个叫做懒加载评估的功能。在本章中,我们将使用懒加载评估来创建 Dice of Doom 的改进版本。
将懒加载评估添加到 Lisp
使用懒加载评估,我们仍然可以在代码的一个地方创建整个游戏树——在游戏开始时。然而,我们使用一些巧妙的技巧,使得游戏树的一些分支隐藏在云中:

游戏树分支从一开始就被声明了。然而,我们并不麻烦去计算云分支的实际值,就像我们创建一个“真实”分支时那样。这是懒加载评估的懒部分。
相反,我们等待看是否有人“查看”一个云分支。一旦发生这种情况,POOF!,我们在游戏树的那个位置创建一个真实的分支:

这意味着只有当代码的某个部分偶然查看这些分支时,这些游戏树中的分支才会被创建。如果玩家从未选择过特定的移动,并且 AI 从未决定考虑它,我们的程序将懒地避免进行计算,以确定给定分支的实际样子。
一些语言,如 Haskell 和 Clojure Lisp,在语言的核心中包含对懒计算的支撑。实际上,Clojure 鼓励使用它,并清楚地展示了它对函数式编程的实用性。然而,ANSI Common Lisp 标准并没有包含任何类似的功能来实现这种懒计算。幸运的是,凭借 Common Lisp 强大的宏系统,我们可以轻松地将这个功能添加到语言中!
创建懒和强制命令
我们将要创建的最基本的懒计算命令是lazy和force。lazy命令将是一个包装器,你可以将其放在一段代码周围,告诉 Lisp 你希望这段代码以懒方式评估,如下所示:
> `(lazy (+ 1 2))`
#<FUNCTION ...>
如你所见,计算机不会尝试计算 1 加 2 的值。相反,它只是返回一个函数。要获取计算的真正结果,我们必须在我们的另一个基本懒计算命令上调用懒值:
> `(force (lazy (+ 1 2)))`
3
重要的是,计算确实被执行了,但不是在懒值创建时,而是在它被强制时。为了证明这一点,让我们看一个更复杂的例子:
> `(defun add (a b)`
`(princ "I am adding now")`
`(+ a b))`
ADD
> `(defparameter *foo* (lazy (add 1 2)))`
*FOO*
> `(force *foo*)`
I am adding now
3
在这里,我们创建了自己的add函数,它作为副作用,会在控制台打印出加法操作发生的时间
。接下来,我们使用我们的函数懒加两个数字,并将结果存储在变量*foo*中
。到目前为止,我们知道加法实际上还没有发生,因为消息“我现在正在加”还没有出现。
然后,我们force我们的变量
。通过强制赋值,计算实际上被执行,并返回3的结果。你可以看到,当我们强制赋值懒值时,加法操作发生了,因为我们的消息也打印到了控制台
。
下面是lazy简单实现的代码:
(defmacro lazy (&body body)
(let ((forced (gensym))
(value (gensym)))
`(let ((,forced nil)
(,value nil))
(lambda ()
(unless ,forced
(setf ,value (progn ,@body))
(setf ,forced t))
,value))))
我们通过声明一个宏来实现lazy,这个宏的代码如下
。这个宏在生成的代码中需要两个变量。我们需要将这些变量声明为gensym名称
,正如在第十六章中讨论的那样。接下来,我们开始生成宏将要输出的代码
(注意这一行开头的反引号)。
在宏生成的代码顶部,有两个局部变量的声明,使用我们创建的gensym名称
。第一个变量告诉我们这个懒值是否已经被强制
。如果是nil,值可以隐藏在云彩中。如果变量为真,值就不再隐藏在云彩中,因为它已经被强制。
一旦通过调用force计算了值,我们将结果值存储在另一个变量中,尽管最初这个值没有被使用,并设置为nil
。当我们的lazy宏被调用时,我们希望它返回一个函数,这个函数可以在稍后调用以强制我们的懒值返回一个结果。因此,我们接下来声明一个 lambda 函数
。
记住,任何在这个 lambda 函数外部声明的局部变量都将被函数作为闭包捕获。这意味着上面的局部变量 
将在 lambda 函数的后续调用之间持续存在。这为什么很重要呢?好吧,一旦云彩消失 POOF!,我们就完成了计算值的全部工作,我们不想在未来在懒值被强制和再次检查多次时再次做这件事。我们可以通过在调用之间记住第一次force这里的值来避免这种情况
。
当我们的懒值被强制(通过调用我们创建的 lambda 函数)时,我们必须问自己的第一个问题是它是否已经被强制,或者仍然隐藏在云彩后面
。对于尚未被强制的一个值,我们就会 POOF!执行懒计算
,并将其保存为我们的value。我们还标记它为forced
。现在云彩已经消失了。
云彩消失后,我们只需简单地返回我们的计算值
。这可能刚刚被计算,或者它可能已经存在于之前的force调用中。
与(诚然令人费解的)lazy宏的代码不同,force函数非常简单。它所做的只是调用由lazy创建的 lambda 函数:
(defun force (lazy-value)
(funcall lazy-value))
现在我们已经有一套完整的原始懒评估命令。许多不同类型的复杂工具可以建立在这些简单的lazy和force命令之上。
创建懒列表库
我们现在将使用我们的新命令构建一个基于 Clojure 实现的懒列表库的库。 (在 Clojure 中,懒列表被称为懒序列。)
由于处理 Lisp 列表的基本命令是cons命令,您可能不会对第一个用于处理惰性列表的命令是lazy-cons命令感到惊讶:
(defmacro lazy-cons (a d)
`(lazy (cons ,a ,d)))
这个宏模拟了cons的行为,只不过结果是包裹在lazy宏中。为了配合lazy-cons,我们还将创建lazy-car和lazy-cdr命令:
(defun lazy-car (x)
(car (force x)))
(defun lazy-cdr (x)
(cdr (force x)))
所有这些函数所做的只是强制惰性值,然后分别调用car和cdr。让我们尝试使用这些新命令:
> `(defparameter *foo* (lazy-cons 4 7))`
*FOO*
> `(lazy-car *foo*)`
4
> `(lazy-cdr *foo*)`
7
如您所见,我们可以像使用cons一样使用lazy-cons
。然后我们可以像分解cons一样分解惰性cons 
。
到目前为止,看起来我们的惰性列表函数与标准的cons、car和cdr函数没有太大区别。然而,我们实际上可以使用它们来完成一些相当惊人的壮举。例如,考虑以下定义:
(defparameter *integers*
(labels ((f (n)
(lazy-cons n (f (1+ n)))))
(f 1)))
在这里,我们使用了lazy-cons命令来声明一件看似不可能的事情:一个包含所有正整数的列表的变量!我们通过创建一个局部函数f
,然后递归地调用它来构建一个无限的lazy-cons链,使用一个不断增长的数字n
。一旦我们声明了这个看似不可能的*integers*变量,我们就可以像预期的那样使用它:
> `(lazy-car *integers*)`
1
> `(lazy-car (lazy-cdr *integers*))`
2
> `(lazy-car (lazy-cdr (lazy-cdr *integers*)))`
3
只要我们坚持只使用我们的lazy-命令,我们就可以从我们的无限整数列表中取出我们想要的任何东西,根据需要强制从*integers*中获取更多和更多的数字。
由于并非所有列表都是无限的(例如正整数的列表),我们还需要有一个lazy-nil的概念来终止列表。同样,我们需要一个lazy-null函数,我们可以用它来检查是否到达了列表的末尾,就像null函数可以用来检查常规列表的末尾一样。
(defun lazy-nil ()
(lazy nil))
(defun lazy-null (x)
(not (force x)))
现在我们已经拥有了处理惰性列表的基本构建块,让我们为我们的库创建一些有用的函数。
在常规列表和惰性列表之间的转换
我们想要能够做的一件明显的事情是将常规列表转换为惰性列表。make-lazy函数允许我们这样做:
(defun make-lazy (lst)
(lazy (when lst
(cons (car lst) (make-lazy (cdr lst))))))
正如make-lazy函数清楚地显示的那样,编写惰性列表库函数有点像写禅宗公案。理解它们的唯一方法就是长时间地盯着它们看。英语没有合适的词汇来清楚地解释像make-lazy这样的函数。
从广义上讲,make-lazy 使用递归遍历列表 ![http://atomoreilly.com/source/nostarch/images/783562.png],然后将每个 cons 元素包裹在 lazy 宏的调用中 ![http://atomoreilly.com/source/nostarch/images/783564.png]。然而,要完全理解这个函数(以及我们懒加载库中剩余的其他函数),你只需仔细思考 lazy 和 force 的真正含义,并对每个函数进行一些冥想。幸运的是,一旦我们的小懒列表库完成,它将隐藏大多数懒加载的奇怪之处。
正如我们编写 make-lazy 函数将常规列表转换为懒加载列表一样,我们可以创建一些函数来做相反的操作——将懒加载列表转换为常规列表。take 和 take-all 函数允许我们这样做。
(defun take (n lst)
(unless (or (zerop n) (lazy-null lst))
(cons (lazy-car lst) (take (1- n) (lazy-cdr lst)))))
(defun take-all (lst)
(unless (lazy-null lst)
(cons (lazy-car lst) (take-all (lazy-cdr lst)))))
我们想要两个不同的命令来从懒加载列表转换为常规列表的原因是,与常规列表不同,懒加载列表可以是无限的。因此,有一个额外的命令让我们能够从列表中取出指定数量的项是有用的。take 函数接受一个额外的参数 n,它表示我们想要取出的值的数量 ![http://atomoreilly.com/source/nostarch/images/783564.png]。如果我们只想获取所有值,我们可以调用 take-all 函数 ![http://atomoreilly.com/source/nostarch/images/783562.png]。当然,这个函数不能用于无限列表——从无限列表中取出所有项会导致无限循环。
让我们尝试使用我们新的懒加载列表转换函数:
> `(take 10 *integers*)`
(1 2 3 4 5 6 7 8 9 10)
> `(take 10 (make-lazy '(q w e r t y u i o p a s d f)))`
(Q W E R T Y U I O P)
> `(take-all (make-lazy '(q w e r t y u i o p a s d f)))`
(Q W E R T Y U I O P A S D F)
如你所预期,如果我们从所有正整数的列表中取出前 10 个整数,我们只会得到从 1 到 10 的数字作为结果 ![http://atomoreilly.com/source/nostarch/images/783564.png]。take 函数也可以用于通过调用 make-lazy 创建的有限列表 ![http://atomoreilly.com/source/nostarch/images/783562.png]。然而,如果列表是有限的,我们可以使用更简单的 take-all 函数,并直接得到懒加载列表中所有项的常规列表 ![http://atomoreilly.com/source/nostarch/images/783560.png]。
在懒加载列表中进行映射和搜索
我们还希望能够在懒加载列表上进行映射和搜索。以下是一些允许这样做的函数:
(defun lazy-mapcar (fun lst)
(lazy (unless (lazy-null lst)
(cons (funcall fun (lazy-car lst))
(lazy-mapcar fun (lazy-cdr lst))))))
(defun lazy-mapcan (fun lst)
(labels ((f (lst-cur)
(if (lazy-null lst-cur)
(force (lazy-mapcan fun (lazy-cdr lst)))
(cons (lazy-car lst-cur) (lazy (f (lazy-cdr lst-cur)))))))
(lazy (unless (lazy-null lst)
(f (funcall fun (lazy-car lst)))))))
(defun lazy-find-if (fun lst)
(unless (lazy-null lst)
(let ((x (lazy-car lst)))
(if (funcall fun x)
x
(lazy-find-if fun (lazy-cdr lst))))))
(defun lazy-nth (n lst)
(if (zerop n)
(lazy-car lst)
(lazy-nth (1- n) (lazy-cdr lst))))
这些函数与 mapcar、mapcan、find-if 和 nth 函数类似。唯一的区别是它们接受和返回懒加载列表。这意味着它们不是使用 null、car 和 cdr,而是使用我们刚刚创建的这些函数的懒加载版本(lazy-null、lazy-car 和 lazy-cdr)。
使用这些函数相当简单:
> `(take 10 (lazy-mapcar #'sqrt *integers*))`
(1 1.4142135 1.7320508 2 2.236068 2.4494898
2.6457512 2.828427 3 3.1622777)
> `(take 10 (lazy-mapcan (lambda (x)`
`(if (evenp x)`
`(make-lazy (list x))`
`(lazy-nil)))`
`*integers*))`
(2 4 6 8 10 12 14 16 18 20)
> `(lazy-find-if #'oddp (make-lazy '(2 4 6 7 8 10)))`
7
> `(lazy-nth 4 (make-lazy '(a b c d e f g)))`
E
使用lazy-mapcar调用平方根函数映射正整数,我们得到了正整数的平方根的懒列表。前 10 个如下所示 ![httpatomoreillycomsourcenostarchimages783564.png]。接下来,我们调用lazy-mapcan ![httpatomoreillycomsourcenostarchimages783562.png]并检查每个正整数是否为偶数。如果是,我们返回一个包含数字的懒列表 ![httpatomoreillycomsourcenostarchimages783560.png]。如果不是,我们返回一个懒空列表 ![httpatomoreillycomsourcenostarchimages783554.png]。结果是,我们从整数懒列表中过滤掉了所有偶数。我们可以使用lazy-find-if在懒列表中找到第一个奇数 ![httpatomoreillycomsourcenostarchimages783510.png]。在这种情况下,数字是 7。最后,我们可以使用lazy-nth从懒列表的特定位置选择一个数字 ![httpatomoreillycomsourcenostarchimages783544.png]。
我们现在已经编写了一个完整的,尽管相当简单的懒列表库。将本章中我们编写的所有函数放入名为lazy.lisp的文件中(或者直接从landoflisp.com/下载该文件)。
现在,你们将看到懒列表如何极大地提升我们“末日骰子”游戏引擎的效能!
末日骰子,版本 2
在第十五章中,我们创建了“末日骰子”游戏的第一个版本。现在,我们将修改该版本中的一些函数。为了继续,将那一章的代码放入名为dice_of_doom_v1.lisp的文件中,以便我们可以在新版本中引用它(或者直接从landoflisp.com/下载该文件)。
要使用我们之前的“末日骰子”和新的懒列表库,请在 REPL 中运行以下命令:
> `(load "dice_of_doom_v1.lisp")`
> `(load "lazy.lisp")`
接下来,我们将棋盘的大小增加到更宽敞的 4x4:
> `(defparameter *board-size* 4)`
> `(defparameter *board-hexnum* (* *board-size* *board-size*))`
为了使游戏以合理的速度运行在这个更大的规模上,我们将游戏树每个分支的移动列表改为懒列表,而不是普通的列表。通过简单地将我们游戏中的一种结构从普通列表转换为懒列表,整个游戏树将因此变为懒列表。为了实现这一点,我们现在需要重新定义我们游戏第一版中的一些函数,以便使用我们新的懒列表函数。
首先,让我们对计算从给定棋盘位置可能的攻击和传球移动的函数做一些小的修改:
(defun add-passing-move (board player spare-dice first-move moves)
(if first-move
moves
(lazy-cons (list nil
(game-tree (add-new-dice board player
(1- spare-dice))
(mod (1+ player) *num-players*)
0
t))
moves)))
(defun attacking-moves (board cur-player spare-dice)
(labels ((player (pos)
(car (aref board pos)))
(dice (pos)
(cadr (aref board pos))))
(lazy-mapcan
(lambda (src)
(if (eq (player src) cur-player)
(lazy-mapcan
(lambda (dst)
(if (and (not (eq (player dst)
cur-player))
(> (dice src) (dice dst)))
(make-lazy
(list (list (list src dst)
(game-tree (board-attack board
cur-player
src
dst
(dice src))
cur-player
(+ spare-dice (dice dst))
nil))))
(lazy-nil)))
(make-lazy (neighbors src)))
(lazy-nil)))
(make-lazy (loop for n below *board-hexnum*
collect n)))))
如你们所见,add-passing-move函数只需要做一个小改动。由于移动列表现在是懒列表,我们使用lazy-cons将一个传球移动添加到可能的移动列表的顶部 ![httpatomoreillycomsourcenostarchimages783564.png]。
attacking-moves 函数需要一些额外的更改。首先,由于它现在需要返回一个惰性列表,我们在两个地方使用 lazy-mapcan 代替 mapcan 来计算移动 
。lazy-mapcan 函数还要求其内部创建的列表是惰性的,我们通过 make-lazy 函数来实现 
。此外,我们还将返回 nil 的任何地方现在改为返回 lazy-nil 
。最后,我们还使计算出的棋盘位置列表变为惰性
,因为它被输入到外部的 lazy-mapcan。
接下来,让我们对处理人类玩家的两个函数进行类似的更改:
(defun handle-human (tree)
(fresh-line)
(princ "choose your move:")
(let ((moves (caddr tree)))
(labels ((print-moves (moves n)
(unless (lazy-null moves)
(let* ((move (lazy-car moves))
(action (car move)))
(fresh-line)
(format t "˜a. " n)
(if action
(format t "˜a -> ˜a" (car action) (cadr action))
(princ "end turn")))
(print-moves (lazy-cdr moves) (1+ n)))))
(print-moves moves 1))
(fresh-line)
(cadr (lazy-nth (1- (read)) moves))))
(defun play-vs-human (tree)
(print-info tree)
(if (not (lazy-null (caddr tree)))
(play-vs-human (handle-human tree))
(announce-winner (cadr tree))))
在 handle-human 函数中,我们有一个局部函数 print-moves,它是一个遍历移动列表的列表消耗函数。我们修改它以在检查列表的末尾时使用我们的惰性命令
,从列表的前端移除一个移动
,并在列表的尾部递归
。最后,我们修改 handle-human 以使用 lazy-nth 在人类从选项列表中选择移动后选择一个移动
。
在 play-vs-human 函数中,我们只做了一处精确的更改。为了确定我们是否到达了游戏的末尾,我们需要检查后续可能的移动列表是否为空,然后宣布获胜者。我们简单地使用 lazy-null 来检查惰性移动列表是否为空
。
在这些简单的更改到位后,你可以在更大的棋盘尺寸上与另一名人类玩家玩“末日骰子”,因为除非一名玩家决定做出移动,否则树中的任何移动都不会实现。在我们的更大,4x4 的棋盘上,输入以下内容以开始游戏(就像我们游戏的第 1 版一样):
> `(play-vs-human (game-tree (gen-board) 0 0 t))`
current player = a
a-1 a-3 a-1 b-2
b-3 a-3 a-3 a-1
a-3 a-3 b-1 a-2
b-3 a-3 a-1 a-3
choose your move:
1\. 5 -> 10
2\. 6 -> 10
3\. 9 -> 10
4\. 11 -> 10
5\. 15 -> 10
第 1 版会在执行此命令的瞬间停止。这是因为它需要在游戏开始播放之前,为整个游戏中所有可能的移动生成整个游戏树。
使用我们的惰性版本“末日骰子”,游戏可以立即开始!
使我们的 AI 在更大的游戏棋盘上工作
接下来,我们将调整我们的游戏 AI 函数以在处理移动时使用新的惰性列表库。在这个过程中,我们将对 AI 代码进行一些额外的改进。
剪切游戏树

在《末日骰子》版本 1 中,我们的 AI 代码在某些方面非常强大。这是因为,在每一个决策点,AI 玩家会查看每一个可能的未来棋盘位置来选择绝对最佳的下一步走法。通过这种方式,它可以玩出完美的《末日骰子》,赢得每一场可以赢得的比赛。
然而,这种设计无法扩展到更大的棋盘。这是因为一旦棋盘变得太大,就几乎不可能考虑每一个可能的未来走法。事实上,我们新提出的懒惰游戏树的核心目的就是避免考虑每一个可能的走法。因此,我们需要一种方法告诉计算机,“只考虑这么多走法,不再考虑更多。”换句话说,我们希望能够告诉它只看两步、三步或四步,然后停止进一步查看。
《末日骰子》的函数式编程风格允许我们以一种非常优雅但并不明显的方式做到这一点。
解决这个问题的明显方案是对版本 1 中的get-ratings和rate-position进行修改,添加一个名为search-depth的新参数。然后,在每次调用这些函数时,我们可以问自己,“我们已经达到想要的最大搜索深度了吗?”
这种方法的缺点是它会使那些函数变得复杂,代码难以理解。实际上,我们评估棋盘位置的方式在理论上与希望搜索的深度是两个独立的问题。程序员喜欢说,这些问题是正交的,如果我们可以分别编写函数来独立处理这些问题,那就最好不过了。
实际上,有了我们新的懒惰游戏树,我们可以编写一个单独的函数,专门负责“修剪”搜索树,并且完全独立于主要 AI 代码,后者负责考虑和评估可能的走法。
下面是这个修剪树的函数:
(defun limit-tree-depth (tree depth)
(list (car tree)
(cadr tree)
(if (zerop depth)
(lazy-nil)
(lazy-mapcar (lambda (move)
(list (car move)
(limit-tree-depth (cadr move) (1- depth))))
(caddr tree)))))
这是一个相当简单的函数,它只接受两个参数:一个懒惰树和希望修剪到的深度!。因此,它只是输出一个新的游戏树,递归地调用自身,每深入树的一层就减少深度!。一旦这个深度达到零!,我们就知道我们已经到达了想要修剪的水平,并将懒惰走法列表设置为空列表!。
现在我们需要做的只是在我们进行 AI 评级计算之前调用我们的新limit-tree-depth函数。我们通过稍微调整我们的handle-computer函数来实现这一点:
(defparameter *ai-level* 4)
(defun handle-computer (tree)
(let ((ratings (get-ratings (limit-tree-depth tree *ai-level*)
(car tree))))
(cadr (lazy-nth (position (apply #'max ratings) ratings)
(caddr tree)))))
在调用get-ratings以获取每个下一个可用移动的评分之前,我们将我们的游戏树转换为修剪后的游戏树
。现在,我们所有的 AI 代码都可以在修剪后的树上运行,完全不知道存在一个更大的游戏树,或者它没有包括在计算中的更深层次的移动。通过这种技术,我们已经成功地将限制 AI 搜索深度的代码与实际评估棋盘位置的算法解耦。另一个小的修改是在从懒加载移动列表中选择移动时使用lazy-nth
。
注意
limit-tree-depth函数使用了一种相当粗糙的方法来修剪我们的树:它只是简单地修剪所有超过一定深度的树分支。对于大多数棋盘游戏来说,这样做是修剪游戏树的最佳方式。然而,《末日骰子》有一个不寻常的特性,即每个玩家都允许连续进行多次移动。如果limit-tree-depth在修剪分支时考虑到我们切换玩家的次数,可能更优。但我们的简单版本已经足够好了。
在这一点上,我们也应该对play-vs-computer进行精确的修改:
(defun play-vs-computer (tree)
(print-info tree)
(cond ((lazy-null (caddr tree)) (announce-winner (cadr tree)))
((zerop (car tree)) (play-vs-computer (handle-human tree)))
(t (play-vs-computer (handle-computer tree)))))
在这里,我们只是添加了一个lazy-null来检查单个位置上懒加载移动列表的末尾
。
现在,让我们看看另一个将提高我们 AI 代码能力的技巧。
应用启发式
通过修剪我们的游戏树,我们从根本上改变了我们的 AI 玩家。如果没有修剪,AI 玩家能够始终玩出完美的游戏。然而,通过修剪树,AI 就有可能“错过”一些东西,因为它不再考虑每一个可能未来的移动。在《末日骰子》版本 2 中,电脑玩家将无法再玩出完美的游戏——只能玩出“相当不错”的游戏。
事实上,我们用 AI 玩完美游戏的能力换取了更好的性能。在这个过程中,我们将 AI 代码从可以被数学分析出的精确事物转变为“更柔软”且远不那么精确的事物。正如计算机科学家所说,我们现在已经进入了启发式领域。
在计算机科学中,启发式是一种不完美的编程技术,但允许我们快速获得良好的结果。广义上讲,任何快速但不是每次都能保证成功的技巧都是启发式。当我们编写使用启发式(如我们的《末日骰子》AI 引擎现在所做的那样)的代码时,通常值得进行一些创造性思考,并以不同的方式“玩弄”代码。
基本上,既然我们已经放弃了寻找完美解决方案的目标,现在正在使用不精确的技术,那么以不同的方式调整启发式代码中的旋钮可能会显著提高我们的结果。事实上,我们发现我们可以对 Dice of Doom AI 的启发式算法进行一个简单的修改,这将显著提高 AI 玩家的游戏表现。
大胜与小胜
在 Dice of Doom 代码的版本 1 中,AI 玩家没有理由担心其胜利的幅度。它唯一关心的是当游戏结束时,它至少比对手多拥有一个棋盘领土,这意味着它已经获胜。
然而,现在我们在 AI 代码中使用不精确的启发式算法,游戏中的领先幅度在任何时刻都非常重要。这种情况下的启发式规则是:“如果我在游戏中彻底打败了我的对手,那么即使我只看几步棋,他/她恢复的可能性也很小。”
记住,最小-最大算法(正如我们在 AI 中使用的那样)会给树中的每个最终叶子分支分配一个得分。在我们的游戏版本 1 中,这个得分要么是 0 或 1,或者当游戏以平局结束时,有时是 1/2。在版本 2 中,这些并不是树中的真正“最终叶子”,而只是我们更小的修剪树中的叶子。在这种情况下,如果我们的叶子得分有更大的值范围,那么我们就可以判断哪些走法会导致我们赢得“很多”的游戏,哪些走法会导致我们只赢得“一点”。
让我们编写一个score-board函数,该函数使用一些更复杂的启发式算法来评估叶子节点的棋盘位置:
(defun score-board (board player)
(loop for hex across board
for pos from 0
sum (if (eq (car hex) player)
(if (threatened pos board)
1
2)
−1)))
score-board函数遍历棋盘上的所有六边形,并使用循环宏的sum指令为每个六边形累积得分。如果我们要评估的玩家拥有当前的六边形,我们希望将正分加到总分上!图片链接!图片链接!图片链接!图片链接。
为了决定为占据的六边形添加多少分,我们做出另一个启发式观察:与更强的对手相邻的六边形并不像没有强邻居的六边形那样有价值。我们将一个与拥有更多骰子的敌人相邻的六边形称为受威胁的六边形。对于受威胁的六边形!图片链接,我们将只添加 1 分到总分!图片链接。对于不受威胁的六边形,我们将添加 2 分!图片链接。最后,对于每个由对手拥有的六边形,我们将从总分中减去 1 分!图片链接。
再次强调,重要的是要认识到 score-board 是一个启发式函数,没有真正正确或错误的方式来生成这样的分数。我们不仅可以为不受威胁的六边形加 2 分,同样也可以加 1.5 分。在开发这个示例时,我使用不同版本的 score-board 函数与各种对手进行了模拟,这个版本最终表现相当不错。开发启发式方法不是一门精确的科学。
这里是确定给定六边形是否受到威胁的函数:
(defun threatened (pos board)
(let* ((hex (aref board pos))
(player (car hex))
(dice (cadr hex)))
(loop for n in (neighbors pos)
do (let* ((nhex (aref board n))
(nplayer (car nhex))
(ndice (cadr nhex)))
(when (and (not (eq player nplayer)) (> ndice dice))
(return t))))))
首先,我们获取相关的六边形,并确定占据该六边形的玩家以及该玩家有多少个骰子
。然后,我们遍历当前位置的所有相邻方格
。之后,我们找出每个相邻方格的玩家和骰子数量
。一旦我们找到一个由对手拥有的、骰子数量更多的相邻六边形(一个威胁性的邻居)
,我们就可以返回 true
。以这种方式调用 return 会导致循环提前停止,并以 true 作为结果。
现在我们已经完成了我们的 score-board 和 threatened 函数,我们准备编写我们的改进的 get-ratings 和 rate-position 函数:
(defun get-ratings (tree player)
(take-all (lazy-mapcar (lambda (move)
(rate-position (cadr move) player))
(caddr tree))))
(defun rate-position (tree player)
(let ((moves (caddr tree)))
(if (not (lazy-null moves))
(apply (if (eq (car tree) player)
#'max
#'min)
(get-ratings tree player))
(score-board (cadr tree) player))))
如您所见,我们更新了几行代码 
,以与我们的新懒散游戏树兼容。请注意,任何缺乏后续移动(即,叶子)的游戏位置现在都会调用我们的新 score-board 函数
。
现在我们有一个完全工作的启发式 AI 玩家,可以在更大的游戏棋盘上玩游戏,让我们试试。像往常一样,以下示例中玩家 B 的所有移动都由 AI 算法自动计算:
> `(play-vs-computer (game-tree (gen-board) 0 0 t))`
current player = a
a-1 b-2 b-1 a-3
b-3 a-1 a-3 a-3
b-3 b-2 b-2 b-2
a-3 a-3 a-2 a-2
choose your move:
1\. 3 -> 2
2\. 6 -> 2
3\. 6 -> 10
4\. 6 -> 1
5\. 6 -> 11
6\. 7 -> 11
7\. 7 -> 2
8\. 13 -> 9
`3`
current player = a
a-1 b-2 b-1 a-3
b-3 a-1 a-1 a-3
b-3 b-2 a-2 b-2
a-3 a-3 a-2 a-2
choose your move:
1\. end turn
2\. 3 -> 2
3\. 7 -> 11
4\. 7 -> 2
5\. 13 -> 9
`1`
current player = b
a-2 b-2 b-1 a-3
b-3 a-1 a-1 a-3
b-3 b-2 a-2 b-2
a-3 a-3 a-2 a-2
current player = b
a-2 b-1 b-1 a-3
b-3 b-1 a-1 a-3
b-3 b-2 a-2 b-2
a-3 a-3 a-2 a-2
current player = b
b-2 b-1 b-1 a-3
b-1 b-1 a-1 a-3
b-3 b-2 a-2 b-2
a-3 a-3 a-2 a-2
current player = b
b-2 b-1 b-1 a-3
b-1 b-1 b-1 a-3
b-3 b-2 a-2 b-1
a-3 a-3 a-2 a-2
current player = a
b-3 b-2 b-2 a-3
b-1 b-1 b-1 a-3
b-3 b-2 a-2 b-1
a-3 a-3 a-2 a-2
choose your move:
1\. 3 -> 2
2\. 7 -> 11
3\. 7 -> 2
4\. 7 -> 6
5\. 10 -> 6
6\. 10 -> 5
7\. 10 -> 11
8\. 13 -> 9
9\. 15 -> 11
...
在这些更改到位后,当与只选择随机移动的玩家对抗时,AI 玩家将赢得大约 65 到 70% 的所有游戏(取决于棋盘大小和 AI 级别)。这实际上是一个非常不错的结果。我们的简单 gen-board 函数经常创建非常不平衡的起始位置,所以剩下的 30% 的游戏对于计算机来说很多都是无法赢得的。
Alpha Beta Pruning
让我们在 Dice of Doom AI 的第 2 版中添加一个最后的改进。
Alpha-beta pruning 是一个著名的最小-最大算法优化,通过跳过一些分支(剪枝这些分支)来提高性能,如果确定这些分支不会影响最终的最小-最大评估。

游戏树中的哪个分支无法影响最终结果?为了理解 alpha-beta 剪枝是如何工作的,请看以下图片,展示了简单 2x2 节点的游戏树:

图的上方是游戏的起始位置。箭头指向可能的移动。在每个棋盘上方都标明当前是哪个玩家(A 或 B)进行移动。
图片还显示了游戏树的最小-最大分析结果。在每个棋盘的右下角,你可以看到一个数字,显示我们的最新 get-ratings 函数(带有新的 score-board 逻辑)将如何评估该位置。对于叶节点(最底部的棋盘),这个数字是通过 score-board 计算得出的。对于分支节点,这个数字是基于最小-最大算法计算得出的。
游戏树中每个允许选择移动的位置都被标记为 MAX 节点或 MIN 节点。由于图中分析是基于寻找玩家 A 的最佳移动,因此允许玩家 A 进行选择的所有位置都被标记为 MAX。允许玩家 B 进行选择的所有位置都被标记为 MIN。从图中可以看出,这个游戏相当无趣,玩家 B 实际上只有一个移动选择的位置。换句话说,游戏树中只有一个 MIN 节点。
从左到右工作,最小-最大算法深度优先遍历,一直探索到叶节点。这被称为 深度优先搜索。(我们假设没有进行修剪,*ai-level* 设置得非常高。)然后它为具有多个分支的任何节点选择最大或最小分数。
当它这样做时,图中 MIN 节点的第一个(左侧)分支最终得分为 8。如果 AI 引擎现在进入右侧分支,它只关心在那里找到的内容,只要得分低于 8。毕竟,8 和任何大于 8 的最小值仍然是 8,使得这些大数字对计算的最终结果无关紧要。
一旦 AI 在右侧分支找到一个得分为 8 的节点(图中用星号标记),它就知道右侧分支的其余部分是无关紧要的,可以从我们的计算中剪除。这意味着最小-最大算法不需要查看图中标记的虚线分支。
这是一个简单的例子,展示了 alpha-beta 剪枝的实际应用。在图中显示的游戏树中,这种剪枝仅导致微小的节省,因为总共可以剪枝的节点数量很少。然而,对于更大的游戏树,alpha-beta 剪枝的节省通常是巨大的,构成了游戏树中大多数节点。
我们将在我们的游戏中对 alpha-beta 剪枝的实现采取一些简化措施。首先,alpha-beta 剪枝算法通常会传递两个变量,自然地称为 alpha 和 beta。
这是因为我们可以编写一次处理 MAX 节点和 MIN 节点的代码,通过在高低限制之间切换 alpha 和 beta。在我们的例子中,我们将使用变量 upper-limit 和 lower-limit,表示我们在遍历游戏树时关心的最高和最低值。作为代价,将会有一些看起来重复的代码来处理 MAX 和 MIN 情况。然而,将 alpha-beta 剪枝视为 upper-limit 和 lower-limit 使得代码更容易理解。
我们做出的另一个妥协是,我们没有将剪枝代码与最小-最大代码解耦。记住,在使用剪枝代码时,我们编写了一个独立的函数名为 limit-tree-depth,它将剪枝操作与 AI 代码的其他部分分离。我们同样可以采用类似的方法来分离 alpha-beta 剪枝代码,创建一个能够将游戏树转换为剪枝版本的函数。然而,这样做稍微复杂一些,因为 alpha-beta 剪枝代码必须能够访问中间的最小-最大计算。对于更高级的 AI 引擎,这是一个好主意。对于我们的简单引擎,我们将在最小-最大函数内部直接添加我们的 alpha-beta 剪枝检查。
那么,让我们开始吧。首先,我们将重写我们的 get-ratings 函数为两个新函数:ab-get-ratings-max 和 ab-get-ratings-min。
记住,get-ratings 函数负责计算从单板布局中多个可用走法中的最佳得分。然而,现在我们希望它在决定找到了“尽可能好”的走法后,能够提前停止对走法的评估。确定是否达到这一点取决于所讨论的节点是 MAX 走法(当前玩家的走法)还是 MIN 走法(对手的走法)。
让我们先看看负责 MAX 节点的版本:
(defun ab-get-ratings-max (tree player upper-limit lower-limit)
(labels ((f (moves lower-limit)
(unless (lazy-null moves)
(let ((x (ab-rate-position (cadr (lazy-car moves))
player
upper-limit
lower-limit)))
(if (>= x upper-limit)
(list x)
(cons x (f (lazy-cdr moves) (max x lower-limit))))))))
(f (caddr tree) lower-limit)))
现在,我们将额外的 upper-limit 和 lower-limit 参数传递给 ab-get-ratings-max!
。这个函数实际上永远不会直接检查 lower-limit 参数,因为它只关心从树中的给定位置找到可能的最大评分。然而,它将这个值传递给子分支,这些分支可能包含关心下限的 MIN 节点。
当我们评估树的下一个分支
(通过调用 ab-rate-position,我们很快就会编写这个函数),我们将结果保存为 x。如果 x 大于或等于我们的 upper-limit
,我们知道我们已经得到了我们所能期望的最好的结果,并且可以只将最新的评估值作为列表中的最终值返回
。
如果 x 不够大,我们需要继续查看剩余的分支
。注意,如果 x 大于之前的 lower-limit,它将成为新的 lower-limit。
接下来,让我们看看 ab-get-ratings-min 函数:
(defun ab-get-ratings-min (tree player upper-limit lower-limit)
(labels ((f (moves upper-limit)
(unless (lazy-null moves)
(let ((x (ab-rate-position (cadr (lazy-car moves))
player
upper-limit
lower-limit)))
(if (<= x lower-limit)
(list x)
(cons x (f (lazy-cdr moves) (min x upper-limit))))))))
(f (caddr tree) upper-limit)))
ab-get-ratings-min 函数基本上与 ab-get-ratings-max 函数相同,除了上下限的角色互换了。基于这两个函数的重复性,你可以想象 ab-get-ratings-max 和 ab-get-ratings-min 函数如何合并成一个函数。如前所述,采用这种方法,而不是 upper-limit 和 lower-limit,你会使用更通用的术语 alpha 和 beta,因为这些将根据节点是 MAX 节点还是 MIN 节点而有所不同。
接下来,我们需要调整 rate-position 函数,这是评估单板排列的函数:
(defun ab-rate-position (tree player upper-limit lower-limit)
(let ((moves (caddr tree)))
(if (not (lazy-null moves))
(if (eq (car tree) player)
(apply #'max (ab-get-ratings-max tree
player
upper-limit
lower-limit))
(apply #'min (ab-get-ratings-min tree
player
upper-limit
lower-limit)))
(score-board (cadr tree) player))))
在我们的新 ab-rate-position 中,我们检查游戏树中的这个节点是我们的一步棋还是对手的一步棋
。如果是我们的棋,那么它是一个 MAX 节点,我们希望调度到 ab-get-ratings-max
。如果是对手的回合,我们则调度到 ab-get-ratings-min
。否则,ab-rate-position 与我们之前的 rate-position 函数相同。
为了完成我们对 alpha-beta 剪枝的支持,我们需要修改另一个函数:启动我们的最小-最大计算的 handle-computer 函数:
(defun handle-computer (tree)
(let ((ratings (ab-get-ratings-max (limit-tree-depth tree *ai-level*)
(car tree)
most-positive-fixnum
most-negative-fixnum)))
(cadr (lazy-nth (position (apply #'max ratings) ratings) (caddr tree)))))
这个函数通过调用 ab-get-ratings-max
开始最小-最大计算,因为第一步肯定属于目标玩家,因此是一个 MAX 节点。
当我们调用这个函数时,我们需要传入我们的起始upper-limit和lower-limit。由于我们处于 minimax 搜索的非常开始阶段,我们希望将这些设置得尽可能大和尽可能小。理想情况下,我们希望它们是正无穷大和负无穷大。尽管许多 Lisp 环境包含对这些概念的支持,但它们不是 ANSI Common Lisp 标准的一部分。然而,标准确实定义了most-positive-fixnum和most-negative-fixnum,它们是非常大的正数和负数,非常适合我们的目的。因此,我们将这些传递给ab-get-ratings-max以开始我们的限制!!。
如果我们想要从我们的 AI 引擎中挤出更多效率,我们可以,相反,将upper-limit和lower-limit设置为score-board函数的最大值和最小值。这将略微提高可能进行的剪枝量。然而,score-board函数可能会根据棋盘的大小返回不同的分数范围,并且如果我们决定在未来进一步优化棋盘评分,它可能还有其他依赖项。因此,目前最好的做法是在我们的 minimax 计算开始时将我们的限制设置为接近无限大,这样我们就不必担心这个问题。
作为再次提高我们 AI 性能的最终奖励,让我们将棋盘的大小增加到 5x5 游戏区域。使用我们新的懒、修剪和剪枝 AI 算法,我们应该能够轻松地处理这个更大的棋盘:
(defparameter *board-size* 5)
(defparameter *board-hexnum* (* *board-size* *board-size*))
注意
记住,我们为一些早期的函数使用了记忆化。如果你已经在 4x4 棋盘上玩了一些游戏,特别是neighbors函数,可能会根据这个旧的棋盘大小返回结果。这仅在你已经在这个 4x4 棋盘上玩过游戏并且在此期间没有重新启动 Lisp 的情况下才是问题。为了解决这个问题,只需在 REPL 中重新运行dice_of_doom_v1.lisp中neighbors函数的定义(包括文件底部的记忆化修订版)以清除任何缓存的计算结果。
现在看看我们的游戏是什么样子:
> `(play-vs-computer (game-tree (gen-board) 0 0 t))`
current player = a
a-2 b-2 a-1 b-2 b-2
a-1 b-2 b-3 b-3 a-3
a-1 b-2 a-3 b-1 b-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
choose your move:
1\. 9 -> 13
2\. 9 -> 4
3\. 9 -> 14
4\. 12 -> 13
5\. 17 -> 22
6\. 23 -> 18
7\. 23 -> 22
`3`
current player = a
a-2 b-2 a-1 b-2 b-2
a-1 b-2 b-3 b-3 a-1
a-1 b-2 a-3 b-1 a-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
choose your move:
1\. end turn
2\. 12 -> 13
3\. 14 -> 13
4\. 14 -> 15
5\. 17 -> 22
6\. 23 -> 18
7\. 23 -> 22
`1`
current player = b
a-3 b-2 a-1 b-2 b-2
a-1 b-2 b-3 b-3 a-1
a-1 b-2 a-3 b-1 a-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
current player = b
a-3 b-1 a-1 b-2 b-2
b-1 b-2 b-3 b-3 a-1
a-1 b-2 a-3 b-1 a-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
current player = b
a-3 b-1 b-1 b-1 b-2
b-1 b-2 b-3 b-3 a-1
a-1 b-2 a-3 b-1 a-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
current player = b
a-3 b-1 b-1 b-1 b-1
b-1 b-2 b-3 b-3 b-1
a-1 b-2 a-3 b-1 a-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
current player = b
a-3 b-1 b-1 b-1 b-1
b-1 b-1 b-3 b-3 b-1
b-1 b-2 a-3 b-1 a-2
b-1 b-3 a-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
current player = b
a-3 b-1 b-1 b-1 b-1
b-1 b-1 b-3 b-3 b-1
b-1 b-2 a-3 b-1 a-2
b-1 b-1 b-2 b-2 a-1
b-3 b-1 b-1 a-3 b-3
current player = b
a-3 b-1 b-1 b-1 b-1
b-1 b-1 b-3 b-3 b-1
b-1 b-2 a-3 b-1 a-2
b-1 b-1 b-2 b-2 b-2
b-3 b-1 b-1 a-3 b-1
current player = a
a-3 b-2 b-2 b-2 b-2
b-2 b-2 b-3 b-3 b-1
b-1 b-2 a-3 b-1 a-2
b-1 b-1 b-2 b-2 b-2
b-3 b-1 b-1 a-3 b-1
choose your move:
1\. 0 -> 4
2\. 0 -> 1
3\. 0 -> 5
4\. 12 -> 13
5\. 14 -> 10
6\. 14 -> 9
7\. 14 -> 13
8\. 14 -> 15
9\. 23 -> 18
10\. 23 -> 17
11\. 23 -> 22
12\. 23 -> 24
到目前为止,我们的 REPL 游戏界面对于如此大的游戏区域来说变得非常不实用。我们将在下一节解决这个问题。
你学到的内容
在本章中,我们使我们的“末日骰子”游戏计算机玩家变得更加复杂。我们使用懒列表实现游戏树,并应用了多种优化技术来限制 AI 引擎搜索的棋盘位置数量。在这个过程中,你学习了以下内容:
-
懒编程允许你以高效的方式处理非常大的(甚至无限的)数据结构。
-
一旦你有了
lazy宏和force函数,你可以使用它们来构建更复杂的懒操作,包括构建懒列表库。 -
启发式算法是不完美的算法,可以通过一些创造性思维来提高代码的性能。在我们的例子中,我们对评分叶节点的启发式方法进行了一些更改。
-
一旦我们将“末日骰子”转换为使用懒树,我们就能优雅地修剪游戏树,以限制 AI 在考虑其移动时的思考深度。
-
Alpha-beta 剪枝让我们能够进一步改进性能,通过剪枝那些无法影响 AI 考虑的移动最终得分的分支。
第十九章:创建基于网页的“末日骰子”图形版本
在前一章中,我们创建了“末日骰子”的第二个版本,用于在更大的游戏板上进行游戏。使用我们粗糙的控制台界面来理解和移动变得相当困难。当然,如果有一个漂亮的图形游戏板,我们可以简单地点击想要移动的位置,那么“末日骰子”将变得无限好。好消息是……
在本章中,我们将从早期章节中收集大量代码,将“末日骰子”转换成一个可以在网页浏览器中直接玩的全功能图形游戏!
使用 SVG 格式绘制游戏板
我们已经在 第十三章 中编写了一个原始的网络服务器。此外,我们在 第十七章 中介绍了如何使用 DSL 绘制 SVG 图形。幸运的是,新的 HTML5 标准包括一些功能,使得直接在标准 HTML 文档中嵌入 SVG 图片成为可能。这样,我们将能够使用我们简单的网络服务器来提供一些完全交互式的矢量图形。您将惊讶于做这件事有多容易。
注意
在本书编写时,唯一支持在 HTML 中内联 SVG 的网络浏览器是 Firefox 3.7 Alpha。请使用这个版本,或者使用 Firefox 的较新版本与我们的新版本“末日骰子”。如果您遇到问题,请尝试在 Firefox 地址栏中导航到 about:config 页面,并将 html5.enable 配置设置设置为 true。这将允许 Firefox 使用最新的 HTML5 设置。
此外,请记住,我们的网络服务器库不是纯 ANSI Common Lisp,而是使用了某些 CLISP 特定扩展。这意味着它需要 CLISP 才能运行。
首先,我们需要从其他章节中提取代码,为准备工作做好准备。在前一章中,我们创建了“末日骰子”引擎的第二个版本。将那一章的所有代码放入名为 dice_of_doom_v2.lisp 的文件中。您也应该已经从 第十三章 中创建了一个名为 webserver.lisp 的文件。(这些文件都可以从 landoflisp.com/ 免费获取。)
让我们加载这些文件:
> `(load "dice_of_doom_v2.lisp")`
> `(load "webserver.lisp")`
对于我们的 SVG 支持,我们还需要第十六章 ![ch18.html "第十六章。Lisp 宏的魔力"] 和第十七章 ![ch19.html "第十七章。领域特定语言"] 中的 SVG 渲染代码。将这些函数放在 svg.lisp 中。(此文件也可从 landoflisp.com/ 获取。)作为参考,我们需要的功能有 let1、split、pairs、print-tag、tag、svg、brightness、svg-style 和 polygon。接下来加载此文件:
> `(load "svg.lisp")`
现在让我们编写一些代码,使用 SVG 绘制我们游戏板的漂亮版本。首先,我们需要定义一些常量来控制绘制板所需的各种尺寸:
(defparameter *board-width* 900)
(defparameter *board-height* 500)
(defparameter *board-scale* 64)
(defparameter *top-offset* 3)
(defparameter *dice-scale* 40)
(defparameter *dot-size* 0.05)
板的宽度和高度将是 900x500 像素,这对于在大多数人的电脑屏幕上玩游戏来说是一个很好的尺寸。板的比例 ![http://atomoreilly.com/source/no_starch_images/783564.png] 表示屏幕上单个六边形的宽度的一半(以像素为单位)。*top-offset* 变量 ![http://atomoreilly.com/source/no_starch_images/783562.png] 告诉我们我们希望在板的底部上方有额外的三个六边形高度的空间。我们需要这个空间,因为一个上面有很多骰子的六边形,其骰子会向上突出,我们需要空间让这些骰子在屏幕上可见。*dice-scale* 变量 ![http://atomoreilly.com/source/no_starch_images/783560.png] 告诉我们单个骰子在屏幕上大约高宽 40 像素。最后,我们将 *dot-size* 设置为 0.05,这意味着每个点将是骰子大小的 0.05 倍 ![http://atomoreilly.com/source/no_starch_images/783554.png]。
绘制骰子
现在我们准备编写一个可以绘制骰子的函数。请注意,我们不会使用位图或类似的东西来绘制。相反,我们将通过直接从原始 SVG 多边形渲染骰子“硬”方法来绘制骰子。以下是代码:
(defun draw-die-svg (x y col)
(labels ((calc-pt (pt)
(cons (+ x (* *dice-scale* (car pt)))
(+ y (* *dice-scale* (cdr pt)))))
(f (pol col)
(polygon (mapcar #'calc-pt pol) col)))
(f '((0 . −1) (−0.6 . −0.75) (0 . −0.5) (0.6 . −0.75))
(brightness col 40))
(f '((0 . −0.5) (−0.6 . −0.75) (−0.6 . 0) (0 . 0.25))
col)
(f '((0 . −0.5) (0.6 . −0.75) (0.6 . 0) (0 . 0.25))
(brightness col −40))
(mapc (lambda (x y)
(polygon (mapcar (lambda (xx yy)
(calc-pt (cons (+ x (* xx *dot-size*))
(+ y (* yy *dot-size*)))))
'(−1 −1 1 1)
'(−1 1 1 −1))
'(255 255 255)))
'(−0.05 0.125 0.3 −0.3 −0.125 0.05 0.2 0.2 0.45 0.45 −0.45 −0.2)
'(−0.875 −0.80 −0.725 −0.775 −0.70 −0.625
−0.35 −0.05 −0.45 −0.15 −0.45 −0.05))))
要绘制一个骰子,我们需要传递三个参数 ![http://atomoreilly.com/source/no_starch_images/783564.png]。前两个是骰子在 SVG 图片中应出现的位置的 x 和 y 坐标。第三个是我们想要骰子的颜色。这个函数将对那个颜色做一些自由处理,并根据需要修改它,以给骰子一点阴影。
在这个函数中我们绘制的任何内容都需要根据我们定义的 *dice-scale* 常量进行缩放渲染。因此,我们首先定义一个局部函数 calc-pt,它为我们缩放一个点 ![http://atomoreilly.com/source/no_starch_images/783562.png]。由于我们需要绘制几个缩放的多边形,让我们也创建一个便利函数 f,它对多边形中的所有点运行 calc-pt,然后通过调用 polygon 函数 ![http://atomoreilly.com/source/no_starch_images/783560.png] 来绘制它。
我们图片中的骰子将有三个可见的面:顶面、前面和右面。我们通过三次调用我们的函数 f 来绘制这些面,从这里开始 ![http://atomoreilly.com/source/no_starch_images/783554.png],并使用一些硬编码的坐标来绘制三个面。
我们最后需要做的是在骰子的面上绘制小圆点。我们通过 mapcing
小圆点的坐标与一个可以渲染点的 lambda 函数进行映射
。这个 lambda 函数使用 *dot-size* 变量来缩小代表骰子面上每个点的正方形多边形。我们可以编写更复杂的代码来绘制圆形和/或椭圆形的点,但点非常小,正方形看起来就很好。
让我们尝试在 x=50 和 y=50 的位置用 RGB 红色 (255 0 0) 绘制一个骰子:
> `(svg 100 100 (draw-die-svg 50 50 '(255 0 0)))`
<svg xmlns
:xlink="http://www.w3.org/1999/xlink" height="100" width="100"><polygon
points="50,10 26.0,20.0 50,30.0 74.0,20.0 " style="fill:rgb(255,40,40);stroke:rgb
(155,0,0)"></polygon><polygon points="50,30.0 26.0,20.0 26.0,50 50,60.0
" style="fill:rgb(255,0,0);stroke:rgb(155,0,0)"></polygon><polygon points="50,
30.0 74.0,20.0 74.0,50 50,60.0 " style="fill:rgb(215,0,0);
stroke:rgb(115,0,0)"></polygon><polygon points="46.0,
13.0 46.0,17.0 50.0,17.0 50.0,13.0 " style="fill:rgb(255,255,255);stroke:rgb(155,155,
155)"></polygon><polygon points="53.0,16.0 53.0,20.0 57.0,20.0 57.0,16.0
" style="fill:rgb(255,255,255);stroke:rgb(155,155,155)"></polygon><polygon
points="60.0,18.999998 60.0,23.0 64.0,23.0 64.0,18.999998 "
style="fill:rgb(255,255,255);stroke:rgb(155,155,155)"></polygon><polygon
points="36.0,17.0 36.0,21.000002 40.0,21.000002 40.0,17.0 " style="fill:rgb(255,255,
255);stroke:rgb(155,155,155)"></polygon><polygon points="43.0,20.0 43.0,
24.0 47.0,24.0 47.0,20.0 " style="fill:rgb(255,255,255);stroke:rgb(155,155,155)"><
/polygon><polygon points="50.0,23.0 50.0,27.0 54.0,27.0 54.0,23.0 "
style="fill:rgb(255,255,255);
stroke:rgb(155,155,155)"></polygon><polygon points="56.0,34.0 56.0,38.0
60.0,38.0 60.0,34.0 " style="fill:rgb(255,255,255);stroke:rgb(155,155,155)"></polygon>
<polygon points="56.0,46.0 56.0,50.0 60.0,50.0 60.0,46.0 "
style="fill:rgb(255,255,255);stroke:rgb(155,155,155)">
</polygon><polygon points="66.0,30.0 66.0,34.0 70.0,34.0 70.0,30.0 " style=
"fill:rgb(255,255,255);stroke:rgb(155,155,155)"></polygon><polygon
points="66.0,42.0 66.0,46.0 70.0,46.0 70.0,42.0 " style="fill:rgb
(255,255,255);stroke:rgb(155,155,155)"></polygon><polygon points="30.0,30.0
30.0,34.0 34.0,34.0 34.0,30.0 " style="fill:rgb(255,255,255);stroke:rgb
(155,155,155)"></polygon><polygon points="40.0,46.0 40.0,50.0 44.0,
50.0 44.0,46.0 " style="fill:rgb(255,255,255);stroke:rgb(155,155,155)"
></polygon></svg>
如果你想看看最终的骰子是什么样子,只需将这个乱七八糟的东西保存到一个名为 die.svg 的文件中。然后在 Firefox 中加载这个结果,你应该会看到以下图片(放大显示):

绘制格子
接下来,让我们编写一个函数来绘制整个六边形格子,包括基础和格子上的骰子:
(defun draw-tile-svg (x y pos hex xx yy col chosen-tile)
(loop for z below 2
do (polygon (mapcar (lambda (pt)
(cons (+ xx (* *board-scale* (car pt)))
(+ yy (* *board-scale*
(+ (cdr pt) (* (- 1 z) 0.1))))))
'((−1 . −0.2) (0 . −0.5) (1 . −0.2)
(1 . 0.2) (0 . 0.5) (−1 . 0.2)))
(if (eql pos chosen-tile)
(brightness col 100)
col)))
(loop for z below (second hex)
do (draw-die-svg (+ xx
(* *dice-scale*
0.3
(if (oddp (+ x y z))
−0.3
0.3)))
(- yy (* *dice-scale* z 0.8)) col)))
这个函数接收很多参数,因为很多信息都编码在棋盘的单个格子中。当我们下一节绘制棋盘时,你会了解到这些参数的精确含义。
首先,我们的 draw-tile-svg 函数绘制基础。为了给基础一个轻微的 3D 效果,我们将绘制两次,一层堆叠在另一层之上。这里
是绘制两个基础的循环。在这个循环内部,我们需要绘制一个六边形多边形
。我们将缩放函数映射到坐标上,以便它们缩放到我们的 *board-scale* 变量。这里
你可以看到使用十进制表示法编码的六边形的六个点。如果玩家选择该基础进行移动,基础的颜色会稍微变亮。我们通过在创建多边形时增加格子的亮度来实现这一点
。
在我们完成绘制格子基础之后,我们需要绘制位于格子上的骰子。我们通过遍历骰子的数量
并调用我们的 draw-die-svg 函数
来完成这个任务。在计算骰子的 x 和 y 位置时,我们需要进行一些缩放数学运算。这个数学运算中最有趣的部分是我们根据给定骰子的 x、y 和 z 坐标之和是奇数还是偶数,稍微向左或向右移动骰子
。这使得堆叠看起来有点不完美,并且会给整个棋盘的堆叠骰子带来令人愉悦、自然的外观。
现在让我们调用我们的函数来绘制一个完成的瓷砖,看看它的样子。同样,只需将此命令的输出复制到一个名为 tile.svg 的文件中。
> `(svg 300 300 (draw-tile-svg 0 0 0 '(0 3) 100 150 '(255 0 0) nil))`
<svg xmlns:xlink="http://www.w3.org/
1999/xlink" height="300" width="300"><polygon points="36,143.6 100,124.4
164,143.6 164,169.2 100,188.4 36,169.2 " style="fill:rgb
(255,0,0);stroke:rgb(155,0,0)">
...
当你在 Firefox 中查看文件时,你应该看到以下内容:

绘制游戏板
现在我们准备编写一个函数,将整个游戏板作为 SVG 绘制出来。它将非常类似于我们用来将游戏板绘制到控制台的 draw-board 函数。它扮演着相同的角色,但只是将结果输出为 SVG 数据。
(defparameter *die-colors* '((255 63 63) (63 63 255)))
(defun draw-board-svg (board chosen-tile legal-tiles)
(loop for y below *board-size*
do (loop for x below *board-size*
for pos = (+ x (* *board-size* y))
for hex = (aref board pos)
for xx = (* *board-scale* (+ (* 2 x) (- *board-size* y)))
for yy = (* *board-scale* (+ (* y 0.7) *top-offset*))
for col = (brightness (nth (first hex) *die-colors*)
(* −15 (- *board-size* y)))
do (if (member pos legal-tiles)
(tag g ()
(tag a ("xlink:href" (make-game-link pos))
(draw-tile-svg x y pos hex xx yy col chosen-tile)))
(draw-tile-svg x y pos hex xx yy col chosen-tile)))))
(defun make-game-link (pos)
(format nil "/game.html?chosen=˜a" pos))
draw-board-svg 函数接受游戏板作为参数,但还需要两个其他参数,这对于将图片用作我们游戏用户界面的前端非常重要!其中一个参数是 chosen-tile,它指示玩家用鼠标点击的瓷砖。我们将使该瓷砖的颜色稍微亮一些,这样玩家就可以知道计算机已经识别了选择。另一个参数是 legal-tiles,它指示玩家可以合法点击的瓷砖。
偶然的是,SVG 图片有一个用于网页链接的功能,它的工作方式与常规 HTML 中的 <a href="..."> 超链接类似。如果一个瓷砖是玩家下一步合法的瓷砖,我们将用这样的链接包裹该瓷砖的 SVG,使其可点击。拥有 legal-tiles 参数让我们知道我们想要哪些瓷砖是可点击的。
draw-board-svg 函数由几个嵌套循环组成,这些循环遍历瓷砖板的 y 和 x 坐标。对于每个瓷砖,我们定义大量局部变量(使用在第十章中引入的 loop 宏提供的局部变量功能)。首先,我们声明 pos,它指示当前瓷砖在六边形数组中的位置。然后我们获取那个六边形。接下来,我们计算瓷砖的像素坐标,存储在变量 xx 和 yy 中。如您所见,这些坐标的数学计算有点复杂,因为游戏板在屏幕上是按透视绘制的。
我们定义的最后一个局部变量是 col,它将保存当前位置瓷砖和骰子的颜色。我们通过使用一个包含骰子颜色的列表来完成此操作,目前该列表包含红色(代表玩家 A)和蓝色(代表玩家 B)。我们还根据 y 坐标使用 brightness 函数(在第十七章中讨论)稍微加深颜色,这会使背景行变暗,增加我们 SVG 游戏板的 3D 效果。
如果当前瓷砖是合法瓷砖的成员
,我们将像之前提到的那样将其包装在一个网络链接中。在 SVG 中,这是通过创建一个形式为<a xlink:href="...">的标签来完成的,我们在这里创建它
。请注意,我们还把每个瓷砖包装在一个<g>标签中,这告诉 SVG 渲染器将这个瓷砖中的多边形视为一个组。为了确定我们想要链接的实际 URL,我们调用make-game-link函数。这个函数构建一个合适的 URL。一旦我们开始编写处理我们游戏网络服务器的代码,你将更好地理解 URL 的格式。
最后,我们准备调用我们的draw-tile函数
。在我们的代码中,有两个不同的调用版本:一个用于超链接版本,另一个用于非链接版本。
呼吸!现在我们终于可以使用 SVG 格式动态地绘制一个完整的游戏板了:
> `(svg *board-width* *board-height* (draw-board-svg (gen-board) nil nil))`
<svg xmlns:xlink="http://www.w3.org/
1999/xlink" height="500" width="900"><polygon points="256,185.6 320,166.4
384,185.6 384,211.2 320,230.4 256,211.2 "
...
如果你将输出保存为board.svg并在 Firefox 中加载它,你应该看到以下内容:

构建网络服务器界面
现在我们已经完成了 Dice of Doom 版本 3 的图形部分,我们准备编写与网络服务器交互的部分。
编写我们的网络请求处理器
我们网络服务器处理的核心函数名为dod-request-handler。它是我们可以传递给网络服务器库中的serve命令的函数,并且负责处理来自网络浏览器的所有网络请求。以下是dod-request-handler的代码:
(defparameter *cur-game-tree* nil)
(defparameter *from-tile* nil)
(defun dod-request-handler (path header params)
(if (equal path "game.html")
(progn (princ "<!doctype html>")
(tag center ()
(princ "Welcome to DICE OF DOOM!")
(tag br ())
(let ((chosen (assoc 'chosen params)))
(when (or (not *cur-game-tree*) (not chosen))
(setf chosen nil)
(web-initialize))
(cond ((lazy-null (caddr *cur-game-tree*))
(web-announce-winner (cadr *cur-game-tree*)))
((zerop (car *cur-game-tree*))
(web-handle-human
(when chosen
(read-from-string (cdr chosen)))))
(t (web-handle-computer))))
(tag br ())
(draw-dod-page *cur-game-tree* *from-tile*)))
(princ "Sorry... I don't know that page.")))
首先,这个函数检查从网络服务器获取的当前页面是否是game.html
。这是我们游戏将在网络服务器上驻留的页面。在页面的顶部,我们指定doctype
。以这种方式完成时,它告诉网络浏览器预期一个 HTML5 编码的网页。然后我们添加一些简单的 HTML 来居中页面并打印一个欢迎信息
。
从网络服务器库传递的params可能包含一个名为chosen的重要值,我们可以使用此行来获取它
。如果没有选中的瓷砖,或者如果游戏树当前为空
,这意味着玩家必须是在开始一个全新的游戏。如果是这种情况,我们将调用名为web-initialize的函数
。
接下来,我们需要找出游戏是否已经结束。我们可以通过检查移动列表是否为空(你可能记得,这是存储在树的caddr位置)来判断这一点。在这种情况下,我们将宣布一个获胜者
。
接着,我们需要检查当前玩家是否是玩家零,这意味着玩家是人类玩家。在这种情况下,我们将调用函数 web-handle-human
来构建页面主体中的剩余 HTML 数据。我们同样使用 read-from-string 函数从 chosen 参数中提取所选瓷砖的编号,如果它存在的话。
在所有其他情况下,我们知道我们正在处理一个电脑玩家,并将控制权交给 web-handle-computer
来构建剩余的 HTML。
最后,dod-request-handler 函数需要调用 draw-dod-page 函数来绘制游戏板,我们在这里完成
。
我们游戏服务器的局限性
我们游戏服务器的局限性相当显著。首先,为了简化起见,dod-request-handler 函数根本不尝试确定网络请求来自何方。它表现得好像所有游戏交互都来自单个玩家,因此对于《末日骰子》来说,它不是一个真正的多人服务器。如果多个玩家同时尝试玩不同的游戏,dod-request-handler 会感到困惑,并且会发生不好的事情。
将 dod-request-handler 扩展成一个真正的支持多个、并行游戏的网络服务器并不困难。为此,我们需要从它从网络服务器接收的作为参数的头部数据中提取会话信息,然后所有引用的变量(例如 *cur-game-tree* 等)都需要存储在散列表中,使用会话信息作为键。这样,每位玩家都会有自己的游戏树,然后我们的引擎就可以并行服务多个游戏。这种多游戏版本的 dod-request-handler 的实现是“留给读者的练习。”
dod-request-handler 的另一个局限性是它使用 read-from-string 函数从 URL 中读取信息。正如你在前面的章节中学到的,这个函数可以被破坏,在经验丰富(且邪恶)的 Lisp 程序员手中运行任意代码。
初始化新游戏
下面是 web-initialize 函数,它初始化我们的游戏引擎以开始一场全新的《末日骰子》游戏:
(defun web-initialize ()
(setf *from-tile* nil)
(setf *cur-game-tree* (game-tree (gen-board) 0 0 t)))
正如你所见,它生成一个随机的游戏板,从中构建一棵树,然后将结果存储在全局变量 *cur-game-tree* 中
。
宣布获奖者
下面是宣布获胜者的函数,它是在网页浏览器内执行的:
(defun web-announce-winner (board)
(fresh-line)
(let ((w (winners board)))
(if (> (length w) 1)
(format t "The game is a tie between ˜a" (mapcar #'player-letter w))
(format t "The winner is ˜a" (player-letter (car w)))))
(tag a (href "game.html")
(princ " play again")))
它与我们之前的 announce-winner 函数完全相同,只是现在在末尾添加了一些额外的代码来构建一个网页链接
,这将允许我们方便地开始一场全新的游戏,因为当前的游戏已经结束。
处理人类玩家
web-handle-human函数负责在当前回合的玩家是人类玩家时创建 HTML 并进行账目管理。
(defun web-handle-human (pos)
(cond ((not pos) (princ "Please choose a hex to move from:"))
((eq pos 'pass) (setf *cur-game-tree*
(cadr (lazy-car (caddr *cur-game-tree*))))
(princ "Your reinforcements have been placed.")
(tag a (href (make-game-link nil))
(princ "continue")))
((not *from-tile*) (setf *from-tile* pos)
(princ "Now choose a destination:"))
((eq pos *from-tile*) (setf *from-tile* nil)
(princ "Move cancelled."))
(t (setf *cur-game-tree*
(cadr (lazy-find-if (lambda (move)
(equal (car move)
(list *from-tile* pos)))
(caddr *cur-game-tree*))))
(setf *from-tile* nil)
(princ "You may now ")
(tag a (href (make-game-link 'pass))
(princ "pass"))
(princ " or make another move:"))))
人类玩家最近的选择决定了这个函数将执行什么操作。web-handle-human函数通过引用最近选择的位置来了解人类玩家的选择,这个位置来源于通过网络请求传递的参数变量。它还可以引用*from-tile*全局变量,这告诉它玩家最初选择哪个地砖作为移动的起始位置。它需要这两个值,因为移动既有源位置也有目标位置。
如果玩家尚未选择位置,我们想要打印一条消息要求玩家选择一个六边形
。如果玩家选择通过,我们想要打印一条消息说玩家的增援已经部署
。(记住,增援是在有人通过之后立即部署的。)
接下来,我们检查*from-tile*变量是否为 nil。如果是这种情况,这意味着玩家尚未为掷骰攻击选择起始位置。如果它是nil,我们可以将*from-tile*设置为刚刚选择的地点
,并要求玩家选择目的地。
如果当前选定的位置与*from-tile*变量相同,这意味着选了两次地砖。这一定意味着玩家改变了主意,想要撤销选择。因此,我们将*from-tile*设置为nil并打印一条取消消息
。
在所有其他情况下,这意味着玩家已经为攻击的起始和结束位置选择了两个有效位置。现在我们可以将*cur-game-tree*向前推进,指向可用移动懒列表中的适当下一个树
。我们想要打印一条消息,允许玩家通过
或再次发起攻击。
我们现在已经完成了游戏服务器将用于与人类玩家交互的代码。接下来,让我们编写一个处理计算机玩家的函数。
处理计算机玩家
处理我们计算机玩家的网络界面相当简单。毕竟,计算机玩家不需要任何花哨的用户界面东西来了解游戏中的情况。当计算机移动时发生的所有网络操作都是为了人类玩家的利益。以下是web-handle-computer代码,当 AI 玩家移动时,它在网络界面中渲染 HTML:
(defun web-handle-computer ()
(setf *cur-game-tree* (handle-computer *cur-game-tree*))
(princ "The computer has moved. ")
(tag script ()
(princ
"window.setTimeout('window.location=\"game.html?chosen=NIL\"',5000)")))
这个函数所做的只是调用我们之前的handle-computer函数,该函数将返回游戏树中计算机选择的下一个分支。我们使用这个来更新我们的*cur-game-tree*变量
。接下来,我们打印一条消息来声明玩家已经移动
。函数的最后部分是一个巧妙的小技巧,用来让我们的网页界面更加生动。它在网页的 HTML 中放入了一点点 JavaScript
,这会强制网络浏览器在五秒后自动加载一个新的网页。这意味着当计算机 AI 玩家移动时,我们能看到所有动作在一个粗略的动画中发生!
在 HTML 中绘制 SVG 游戏棋盘
我们还需要编写一个函数来完成 Dice of Doom 的版本 3:draw-dod-page函数。这个函数将我们的页面游戏服务器代码与绘制我们棋盘的 SVG 代码接口。
(defun draw-dod-page (tree selected-tile)
(svg *board-width*
*board-height*
(draw-board-svg (cadr tree)
selected-tile
(take-all (if selected-tile
(lazy-mapcar
(lambda (move)
(when (eql (caar move)
selected-tile)
(cadar move)))
(caddr tree))
(lazy-mapcar #'caar (caddr tree)))))))
这个函数最复杂的部分是确定棋盘上哪些方块是玩家可以点击的有效方块的代码
。如果玩家已经选择了一个方块,我们希望找到所有移动
,其中移动的起始位置与所选方块
匹配,并返回给定移动的终点位置
。如果玩家还没有选择方块,我们只想返回所有合法的起始位置
。
我们现在已经完成了 Dice of Doom 的完全图形版本。让我们开始玩吧!
玩 Dice of Doom 的版本 3
首先,我们需要启动我们的网络服务器。只需提供我们的dod-request-handler,我们就可以开始了:
> `(serve #'dod-request-handler)`
现在,切换到 Firefox 并访问localhost:8080/game.html。你应该能在浏览器中看到我们的游戏:

当你点击一个方块时,它会高亮显示:

现在你可以选择一个方块进行攻击。在这个例子中,我们将选择所选堆叠右侧的两个骰子的堆叠:

接下来,通过点击pass网页链接来跳过我们的回合。这将导致强化骰子被放置(在这种情况下,只在左上角放置一个额外的骰子):

如果你现在点击 继续,你将看到游戏自动通过计算机玩家的移动,以类似的方式。它将继续这样进行,直到有游戏获胜者。你可以通过简单地回到原始的 game.html URL 来开始新游戏。
这比我们迄今为止使用的原始控制台界面要好得多!但还有一些最终改进我们将对“末日骰子”进行,以使其更加生动。我们将在本书的下一章(也是最后一章)中介绍这些改进。
你所学的
在本章中,我们讨论了如何从 Lisp 程序生成 Web 浏览器中的交互式图形。在这个过程中,你学习了以下内容:
-
您可以通过使用 SVG 格式渲染棋盘来创建“末日骰子”的图形版本。
-
HTML5 标准支持内联 SVG 图像。您可以使用此功能创建一个基于 Web 的交互式游戏版本。
-
我们用于示例的简单 Web 服务器有几个限制。例如,我们的游戏不能由多个玩家进行。然而,请求处理器可以被扩展以允许进行多个并行游戏。
第二十章. 使“末日骰子”游戏更加有趣
现在是时候创建“末日骰子”的最终版本了。我们游戏的第 4 版将比之前的版本更有趣。
尽管你可能没有意识到,我们在游戏规则上做出了一些重大妥协,以使其更容易编程。在本章中,我们将允许更多玩家参与,增加掷骰子,并对“末日骰子”进行一些更多更改,使其成为一个更加有趣的游戏。
增加玩家数量
首先,将上一章的所有代码放入名为 dice_of_doom_v3.lisp 的文件中(也可从配套网站获取),然后执行以下命令:
> `(load "dice_of_doom_v3.lisp")`
我们将要做的第一个改变是将玩家数量从两个增加到四个。其中三个将是人工智能对手,由计算机进行游戏。由于我们迄今为止的代码编写方式,这只需要很少的额外代码:
(defparameter *num-players* 4)
(defparameter *die-colors* '((255 63 63) (63 63 255) (63 255 63)
(255 63 255)))
首先,我们只需将我们的 *num-players* 变量更改为 4。然后我们需要为我们的新玩家指定额外的骰子颜色。四位玩家的颜色将是红色、蓝色、绿色和紫色。
结果表明,我们迄今为止创建的人工智能在四人游戏中运行得很好。
我们的 AI 游戏引擎将使用所谓的“偏执策略”。这意味着 AI 玩家将始终假设每个其他玩家(包括人类)的唯一目标就是——如何表达?——个人地欺骗他们。这不是一个坏策略;然而,超过两个玩家的游戏打开了新的可能性。例如,输掉游戏的玩家可能会联合起来对付获胜的玩家,以提高他们的胜算。我们的游戏 AI 不够聪明,无法形成这样的合作团伙,但已经足够好了。
现在我们已经调整了一些常数以增加玩家数量,让我们再调整几个:
(defparameter *max-dice* 5)
(defparameter *ai-level* 2)
在这里,我们将六边形瓷砖上的最大骰子数量从三个增加到五个,并将我们的 AI 级别从四降低到二。根据本章中描述的新规则,我们需要使我们的 AI 稍微简单一些,以确保它保持敏捷。由于现在有四个竞争玩家,AI 实际上不需要非常聪明就能挑战人类对手。
掷骰子
我相信你肯定注意到了我们游戏中的一个明显的缺陷:尽管它被称为“末日骰子”,但实际上它完全没有随机性!骰子从未被掷出,较大的堆叠总是自动获胜,这使得游戏变得相当无聊。现在我们终于要纠正这个缺陷了。
在这个游戏版本中,在攻击期间,两个骰子堆都会被掷出,掷出最高数字的一方赢得战斗。平局对防守方有利。如果攻击者失败,该玩家必须放弃攻击六边形中除一个以外的所有骰子。
在人工智能编程的术语中,这意味着我们将向我们的游戏树添加 机会节点。我们将以非常简单的方式实现这一点。
构建机会节点
到目前为止,我们懒散的移动列表中的每个移动都恰好有两个项目:移动的描述(攻击的源和目的地列表,或者对于通过移动为 nil)以及当移动被采取时的游戏树的新节点。现在我们只是简单地为移动添加第三个项目,它包含一个不成功攻击的游戏树。这意味着我们的移动列表中的每个移动都将作为机会节点,根据攻击是否成功,为下一个游戏树提供两个可能的后续节点。
让我们更新 attacking-moves 函数,以便为移动添加这个额外项目,使每个移动都充当一个机会节点。
(defun attacking-moves (board cur-player spare-dice)
(labels ((player (pos)
(car (aref board pos)))
(dice (pos)
(cadr (aref board pos))))
(lazy-mapcan (lambda (src)
(if (eq (player src) cur-player)
(lazy-mapcan
(lambda (dst)
(if (and (not (eq (player dst) cur-player))
(> (dice src) 1))
(make-lazy (list (list (list src dst)
(game-tree (board-attack board cur-player src dst (dice src))
cur-player
(+ spare-dice (dice dst))
nil)
(game-tree (board-attack-fail board cur-player src dst (dice src))
cur-player
(+ spare-dice (dice dst))
nil))))
(lazy-nil)))
(make-lazy (neighbors src)))
(lazy-nil)))
(make-lazy (loop for n below *board-hexnum*
collect n)))))
在这个 attacking-moves 的更新版本中,唯一的新增内容就在这里
,我们在创建游戏树中的新移动时添加了第三个项目。在这个机会节点的替代分支中,棋盘是通过调用 board-attack-fail 函数构建的,我们将在下一节中编写这个函数。
board-attack-fail 函数正是你所期望的那样工作:它接受一个棋盘,并返回一个棋盘,其中从攻击失败的六边形中移除了所有骰子(除了一个)。
(defun board-attack-fail (board player src dst dice)
(board-array (loop for pos from 0
for hex across board
collect (if (eq pos src)
(list player 1)
hex))))
在这里,我们只是遍历棋盘,并返回每个未修改的六边形
,除非它恰好是攻击的源六边形。在这种情况下,我们将从该六边形中移除所有骰子(除了一个)
。
进行实际的骰子掷出
接下来,我们需要编写一些函数来实际掷骰子。以下是一个掷骰子堆的函数:
(defun roll-dice (dice-num)
(let ((total (loop repeat dice-num
sum (1+ (random 6)))))
(fresh-line)
(format t "On ˜a dice rolled ˜a. " dice-num total)
total))
首先,它通过为每个骰子循环一次来计算掷出的骰子堆的总数。对于每个骰子,它生成一个 1 到 6 之间的随机数。然后,它将总和存储在total变量中!。接下来,roll-dice函数打印关于掷骰子的描述性消息!。最后,它返回总和!。
由于我们永远不会单独掷骰子堆,让我们创建另一个函数,将两堆骰子相互对抗:
(defun roll-against (src-dice dst-dice)
(> (roll-dice src-dice) (roll-dice dst-dice)))
这只是两次调用roll-dice并比较两次滚动的总和。当我们沿着游戏树前进,选择人类或计算机选择的获胜或失败移动时,我们将想要使用这个函数。
从我们的游戏引擎调用掷骰子代码
在我们的游戏引擎的上下文中,掷骰子简单地意味着在人类或计算机选择移动后,选择机遇节点的获胜或失败分支。这个动作由pick-chance-branch函数执行:
(defun pick-chance-branch (board move)
(labels ((dice (pos)
(cadr (aref board pos))))
(let ((path (car move)))
(if (or (null path) (roll-against (dice (car path))
(dice (cadr path))))
(cadr move)
(caddr move)))))
这个函数接受当前棋盘以及包含需要解决的机遇节点的移动!。当移动内部的路径不是null时,我们使用源和目标六边形路径上的骰子数量调用roll-against!。我们检查null路径,因为这表示移动是“跳过”,不需要掷骰子。
如果攻击的骰子滚动成功,我们从移动中的机遇节点中移除第一个子树!。如果攻击失败,我们返回机遇节点的第二个子节点!。
现在,我们需要确保在人类或计算机选择移动时调用pick-chance-branch函数。首先,让我们处理人类的情况:
(defun handle-human (tree)
(fresh-line)
(princ "choose your move:")
(let ((moves (caddr tree)))
(labels ((print-moves (moves n)
(unless (lazy-null moves)
(let* ((move (lazy-car moves))
(action (car move)))
(fresh-line)
(format t "˜a. " n)
(if action
(format t "˜a -> ˜a" (car action) (cadr action))
(princ "end turn")))
(print-moves (lazy-cdr moves) (1+ n)))))
(print-moves moves 1))
(fresh-line)
(pick-chance-branch (cadr tree) (lazy-nth (1- (read)) moves))))
在这里,我们只是在我们之前的handle-human函数末尾添加了对pick-chance-branch的调用,在需要返回包含游戏下一个状态的子树分支的点!。
我们以相同的方式更新handle-computer函数:
(defun handle-computer (tree)
(let ((ratings (get-ratings (limit-tree-depth tree *ai-level*) (car tree))))
(pick-chance-branch
(cadr tree)
(lazy-nth (position (apply #'max ratings) ratings) (caddr tree)))))
再次,我们只是在函数末尾简单地添加了对pick-chance-branch的调用!。
现在可以玩我们更新的“末日骰子”游戏了。然而,在这个阶段,计算机玩家将玩得非常糟糕,因为 AI 还没有理解机遇节点存在。它将简单地假设每次攻击都会成功,这使得它过于鲁莽,无法玩得体面。我们需要改进我们的 AI,使其在做出决策时考虑到掷骰子。
更新 AI
为了让 AI 能够处理对我们游戏现在重要的骰子滚动,它必须对骰子滚动的统计信息有所了解。以下表格提供了它所需的统计信息:
(defparameter *dice-odds* #(#(0.84 0.97 1.0 1.0)
#(0.44 0.78 0.94 0.99)
#(0.15 0.45 0.74 0.91)
#(0.04 0.19 0.46 0.72)
#(0.01 0.06 0.22 0.46)))
此表格包含了我游戏中每个可能的骰子配对获胜的概率。列代表攻击骰子,从一枚骰子开始。行代表目标骰子,从两枚骰子开始(攻击所需的最低骰子数量)。
此表格告诉我们,例如,两枚攻击骰子对抗一枚防御骰子的投掷有 84%的获胜概率。四枚攻击骰子对抗三枚防御骰子有 74%的获胜概率。
如果你记得,我们 AI 代码中的核心函数是get-ratings函数,它为可能的后续走法列表提供得分。我们需要修改它计算每个可能走法得分的算法,以考虑骰子滚动的成功概率。我们现在将利用我们的*dice-odds*表格以及每个攻击成功或失败的结果的点分数,为每个可用的走法插值一个综合得分:
(defun get-ratings (tree player)
(let ((board (cadr tree)))
(labels ((dice (pos)
(cadr (aref board pos))))
(take-all (lazy-mapcar
(lambda (move)
(let ((path (car move)))
(if path
(let* ((src (car path))
(dst (cadr path))
(odds (aref (aref *dice-odds*
(1- (dice dst)))
(- (dice src) 2))))
(+ (* odds (rate-position (cadr move) player))
(* (- 1 odds) (rate-position (caddr move)
player))))
(rate-position (cadr move) player))))
(caddr tree))))))
在我们更新的get-ratings函数中,我们从表格中查找每个攻击成功的概率
。然后我们将概率与获胜子树的评级
相乘。此外,我们还将攻击失败的几率(胜率减一)乘以失败棋盘位置的评级
。现在我们有一个更新的get-ratings函数,它理解随机节点,并在生成走法得分时适当考虑它们。
为了让我们的游戏 AI 完全兼容随机节点,我们需要进行一个小小的额外修改。我们的剪枝函数需要了解每个走法中随机节点的两个分支,这样它就可以正确地修剪每个走法的获胜和失败选项:
(defun limit-tree-depth (tree depth)
(list (car tree)
(cadr tree)
(if (zerop depth)
(lazy-nil)
(lazy-mapcar (lambda (move)
(cons (car move)
(mapcar (lambda (x)
(limit-tree-depth x (1- depth)))
(cdr move))))
(caddr tree)))))
我们对每个走法的尾部使用mapcar
,因此对任何随机节点的两个分支都进行修剪。
注意
“末日骰子”版本 4 将不会有 alpha-beta 剪枝。在有随机节点的情况下执行正确的 alpha-beta 剪枝非常复杂。
改进“末日骰子”增援规则
到目前为止,玩家回合结束时增加的增援数量总是等于捕获的对手骰子数量减一。这个增援规则保证了游戏中骰子的总数总是减少,因此游戏最终一定会结束,游戏树的大小总是有限的。
然而,自从版本 2 以来,我们的游戏树一直是一个懒树,所以如果树是无限的,那完全没问题。记住,懒加载的主要好处之一是可以拥有无限大小的数据结构。
因此,我们现在将调整我们的强化规则,使我们的游戏在战略上更有趣。
根据我们的新规则,强化骰子的数量将等于玩家最大连续领土中的瓷砖数量。这增加了许多战略深度,因为玩家必须不断决定是否冒险连接他们的领土,或者甚至通过派遣自杀任务来牺牲较小的、不可行的领土。
为了实现这个新的强化规则,我们首先定义一个名为get-connected的函数,该函数返回当前玩家拥有的、与目标瓷砖作为邻居集群连接的瓷砖列表:
(defun get-connected (board player pos)
(labels ((check-pos (pos visited)
(if (and (eq (car (aref board pos)) player)
(not (member pos visited)))
(check-neighbors (neighbors pos) (cons pos visited))
visited))
(check-neighbors (lst visited)
(if lst
(check-neighbors (cdr lst) (check-pos (car lst) visited))
visited)))
(check-pos pos '())))
此函数使用与我们在第八章中用于计算连通性的 Grand Theft Wumpus 游戏相同的算法来查找连通的瓷砖。我们通过递归遍历六边形及其邻居,同时维护一个visited列表。
get-connected函数通过定义两个递归局部函数来实现这一点。check-pos函数 ![http://atomoreilly.com/source/nostarch/images/783564.png] 检查单个位置,并将从该位置可访问的新邻居添加到访问列表中。check-neighbors函数 ![http://atomoreilly.com/source/nostarch/images/783562.png] 检查整个邻居列表,同样将新邻居添加到访问列表中。这两个函数相互递归调用,直到找到集群中的所有邻居。为了开始这个递归计算,我们使用目标位置和一个最初为空的visited列表调用check-pos函数 ![http://atomoreilly.com/source/nostarch/images/783560.png]。
现在我们可以找到集群了。但是,为了找到最大的集群,我们需要largest-cluster-size函数:
(defun largest-cluster-size (board player)
(labels ((f (pos visited best)
(if (< pos *board-hexnum*)
(if (and (eq (car (aref board pos)) player)
(not (member pos visited)))
(let* ((cluster (get-connected board player pos))
(size (length cluster)))
(if (> size best)
(f (1+ pos) (append cluster visited) size)
(f (1+ pos) (append cluster visited) best)))
(f (1+ pos) visited best))
best)))
(f 0 '() 0)))
此函数定义了一个局部函数f,我们将使用它来检查棋盘上的每个位置,同时维护先前访问过的节点列表以及迄今为止找到的最大、最佳集群的大小 ![http://atomoreilly.com/source/nostarch/images/783564.png]。
只要当前位置编号小于板上的总点数
,我们就继续检查瓷砖。如果当前要检查的瓷砖属于玩家,并且尚未访问
,我们将调用 get-connected 来检索从这个位置可达的六边形集群
。然后,如果集群的大小大于迄今为止找到的最佳大小
,我们将在这个递归调用中将它作为新的最佳大小。否则,我们通过调用 f 并保持先前的最佳大小继续进行
。(此时最佳变量将保留迄今为止从先前迭代中找到的最佳值。)无论如何,pos 变量都会在每次递归调用 f 时增加,这样我们最终就能覆盖整个板。
最后,我们需要更新 add-new-dice 以利用我们选择增援数量的新规则:
(defun add-new-dice (board player spare-dice)
(labels ((f (lst n)
(cond ((zerop n) lst)
((null lst) nil)
(t (let ((cur-player (caar lst))
(cur-dice (cadar lst)))
(if (and (eq cur-player player) (< cur-dice *max-dice*))
(cons (list cur-player (1+ cur-dice))
(f (cdr lst) (1- n)))
(cons (car lst) (f (cdr lst) n))))))))
(board-array (f (coerce board 'list)
(largest-cluster-size board player)))))
如您所见,add-new-dice 函数仍然接收 spare-dice 作为参数以保持与旧代码的兼容性
,但现在这个参数被简单地忽略。相反,添加到板上的增援数量取决于最大集群的大小
。否则,add-new-dice 与我们之前的版本相同。
这就是我们需要的所有代码,以启用新的增援规则。请注意,由于我们代码的设计,AI 玩家可以完全访问游戏树。由于游戏树现在包含所有这些新的增援数据,AI 将自动调整其游戏策略,以考虑新的增援规则!
结论
我们在创建“末日骰子”游戏的过程中走了一段相当长的旅程,沿途使用了大量的不同编程技术。在这本书中的其他所有游戏中,我们也进行了更多的旅行。感谢您与我一起踏上 Lisp 编程世界的旅程!
我建议您花点时间享受您辛勤劳动的成果,并玩几局“末日骰子”的第四版和最终版本。再次提醒,您只需要通过我们的网络服务器提供“末日骰子”请求处理器:
> `(serve #'dod-request-handler)`
现在,您可以在 Firefox 中玩“末日骰子”(再次,地址为 localhost:8080/game.html),按照预期的方式玩,有四名玩家和我们在本章中添加的所有新规则。

祝您在所有“末日骰子”战斗和未来的 Lisp 编程中一切顺利!

附录 A. 结语
现在你已经通读了这本书,这里有一个最后的奖励:一个关于整个 Lisp 编程语言家族背后的技术的故事,设定在不太遥远的未来……











功能型巡洋舰
Lisp 方言
Common Lisp
概述
函数式编程是一种数学化的编程方法,由 Lisp 的创造者开创。函数式编程对程序员施加某些限制,但它可以导致非常优雅的代码。在使用函数式编程时,给定函数使用的每个变量必须是以下之一:

-
传递给该函数的参数
-
在该函数内部创建的局部变量
-
一个常量
此外,函数式编程不允许函数有副作用。这意味着函数不能写入磁盘,不能在屏幕上打印消息,或者做任何除了返回结果之外的事情。目标是使用“函数式代码”编写程序的大部分内容,同时保留一小部分代码,用于执行任何仍然需要的脏乱的非函数式操作。
它如何杀死错误
以函数式风格编写代码可以保证函数只做一件事(返回一个值)并且只依赖于一个东西(传递给它的参数)。这使得调试变得非常容易。无论你运行函数多少次,只要传递给它相同的数据,你总是会得到相同的结果。
示例 A-1. 示例
(defun unique-letters (name)
(concatenate 'string
"Hello "
(coerce (remove-duplicates name) 'string)))
(defun ask-and-respond ()
(princ "What is your name?")
(princ (unique-letters (read-line))))
说明
如果你将此代码输入到 Lisp REPL 中并执行(ask-and-respond),你将被要求输入你的名字,然后会以你的名字问候你,但所有重复的字母都被移除了。这个函数中的所有艰苦工作都是由unique-letters处理的,它是以函数式风格编写的
。与用户交互的脏活,不能以纯函数式方式编写,由ask-and-respond处理
。
弱点
函数式编程的主要弱点是,程序实际上要“做”一些事情,几乎总是需要一些副作用。这意味着你不能编写一个全部用函数式风格编写的有用程序。至少会有一些代码是非函数式的。
函数式编程在第十四章中讨论。

宏团近战战士
Lisp 方言
Common Lisp
摘要
真正的宏是 Lisp 最独特和最惊人的特性之一。事实上,Lisper 忍受他们代码中所有那些令人烦恼的括号的原因是,那些括号使得 Lisp 的宏系统变得神奇。

真正的宏允许你以非常基本的方式向 Lisp 添加新功能。经验丰富的 Lisper 可以使用宏来干净优雅地让他们的 Lisp 编译器/解释器执行他们的命令。
如何杀死错误
通过使用宏,经验丰富的 Lisper 可以最小化代码重复,并更好地将底层语言定制到当前问题。这导致代码更干净,错误更少。
例 A-2. 示例
(defmacro three-way-if (expr a b &rest c)
(let ((val (gensym)))
`(let ((,val ,expr))
(cond ((and (numberp ,val) (zerop ,val)) ,a)
(,val ,@c)
(t ,b)))))
解释
Lisp 宏如此强大,以至于你可以实际编写自己的 if-then 命令!这里显示的代码创建了一个名为three-way-if的宏,它有三个分支:一个用于nil值
,一个用于数值零
,以及一个用于其他所有情况
。对于大多数用途,这样的函数可能看起来很愚蠢,但如果你需要编写一个经常需要区分零和nil(或需要处理其他特定领域的头痛问题)的程序,编写一个宏将使你的生活更容易。
弱点
由于 Lisp 宏非常强大,程序员滥用它们的风险总是存在的。过度使用宏可能会让其他程序员难以理解你的代码。
宏在第十六章中讨论。
重启团装甲战士
Lisp 方言
Common Lisp
摘要
正确的异常处理极其困难。实际上只有两种好的方法:根本不处理异常,当异常发生时让程序直接崩溃,或者以最直接和具体的方式处理每一个异常。但是,在代码中真正处理每一个潜在的异常是否真的可能?如果你编写 Common Lisp 代码,你可以非常接近这个理想目标。

例如,假设你编写了一个函数来提高小部件列表中的价格。但在函数处理列表中的某个小部件时,出现了内存分配错误。你无法提前准备这种类型的错误,因为它可能发生在程序的任何地方。这使得使用传统的异常处理方法无法解决这个问题。
即使调用栈中较低层的函数捕获并解决了异常的根源,程序仍然面临一个无法解决的问题:一些小部件的价格已经上涨,而另一些则没有。然而,Common Lisp 有一个机制来解决这个问题,称为 重启。
在支持重启的语言中,提高小部件价格的函数可以宣布,“嘿,大家!如果在我处理小部件时发生什么坏事,当对我来说安全完成我的工作时,请使用我的重启(称为 try-again)!” 另一个位于调用树较低层的函数现在可以处理错误,然后调用 try-again 来确保小部件价格不会变得损坏。这允许函数在失败的确切点完成提高小部件价格的操作。
事实上,如果你有一个无法承受关闭的程序(例如,一个网络服务器),你仍然可以在不结束程序的情况下处理大量极端的异常。即使程序遇到真正异常的异常,它也可以简单地将控制权转回到交互式解释器。然后程序员可以修复异常的原因,访问可用的重启列表,并立即继续运行程序。
它如何杀死错误
通过使用重启和 Lisp REPL,可以在运行中的程序中修复错误,允许你以几乎可以忽略不计的中断来“热脚本”长时间运行的应用程序。
示例 A-3. 示例
(defun raise-widget-prices (widgets)
(when widgets
(loop (restart-case (progn (raise-price (car widgets))
(return))
(try-again () (princ "trying again"))))
(raise-widget-prices (cdr widgets))))
说明
这是一个实现函数的示例,该函数会提高一系列小工具的价格。单个小工具价格提高的实际工作由raise-price函数完成 ![httpatomoreillycomsourcenostarchimages783564.png]。对该函数的调用被包裹在一个loop和一个restart-case命令中,该命令声明了一个名为try-again的重启 ![httpatomoreillycomsourcenostarchimages783560.png]。如果价格可以无问题地提高,raise-price函数将正常完成,循环通过return中断 ![httpatomoreillycomsourcenostarchimages783562.png],然后处理小工具列表中的下一个项目。另一方面,如果在提高小工具价格时发生错误,另一个函数(或程序员)可以尝试修复问题并调用try-again重启来重试失败点的小工具 ![httpatomoreillycomsourcenostarchimages783560.png],这导致循环的另一个循环 ![httpatomoreillycomsourcenostarchimages783564.png]。然后函数可以继续处理列表的其余部分,提高剩余小工具的价格 ![httpatomoreillycomsourcenostarchimages783554.png]。
通过使用重启,你的代码可以为处理异常提供多种替代后续选项,即使是最异常的异常也能得到适当的处理。
弱点
尽管 Common Lisp 拥有现存最先进的异常处理系统之一,但在代码中仍然难以适当地处理每一个异常。然而,重启功能赋予你独特的修复正在运行程序并允许其继续运行的能力,这在其他语言中通常是不可能的。
重启在第十四章中讨论。

通用设置器供应船
Lisp 方言
Common Lisp
概述
要修改 Common Lisp 中变量的值,你使用setf。然而,此命令还具有惊人的特殊功能:你可以传递一个复杂的 Lisp 表达式作为参数,该表达式检索一个值。然后它可以反转该表达式并使用它来修改该值,而不仅仅是检索它。这类表达式被称为通用设置器。

除了setf之外,许多命令还支持通用设置器。使用此功能,大多数数据结构类型都可以无需任何特定的“设置”函数。
它如何杀死虫子
当你有一个复杂、嵌套的数据结构时,理解从特定位置检索数据的代码通常比理解在相同位置设置值的代码更容易。如果你想在复杂结构中的特定位置设置值,你通常需要从结构中向后工作以找出如何更改它。但是,使用通用设置器,你可以让 Lisp 为你处理复杂的代码。拥有更简单的代码是防止错误的好方法。
示例 A-4. 示例
(defparameter foo (list 1 (make-hash-table) 3))
(setf (gethash 'my-key (nth foo 1)) 77)
说明
示例创建了一个名为foo的变量,它包含三个项目列表
。列表中的第二个项目是一个空的哈希表。然后它一次性将名为my-key的键和值为77添加到foo内部的表中,通过将一个复杂的表达式放入setf中,以“获取”这个位置
。
弱点
通过修改现有数据结构,通用设置器产生副作用,这违反了函数式编程的一个原则。这意味着在纯函数式编程风格中不能使用它们。
通用设置器在第九章中讨论。


DSL Guild Hot Rods
Lisp 方言
Common Lisp
概述
由于 Lisp 有如此简单的语法(所有内容都用括号分隔),因此很容易用它来构建自己的定制编程语言,专为特定领域设计。这种领域特定语言(DSLs)往往大量使用 Lisp 宏系统。它们代表了宏编程的极端形式,将 Lisp 转换成一种全新的编程语言。

说明
这是一个使用 DSL 构建 HTML 页面的代码示例。在这种情况下,页面在浏览器中显示“Hello World”,第二个单词以粗体显示。html和body命令(在第十六章 Chapter 16. The Magic of Lisp Macros 中为 HTML 库创建的宏)生成包含页面主体的开闭标签
。然后它调用常规的 Lisp 函数princ来生成文本。第二个单词被另一个自定义 DSL 命令bold
包裹,该命令在指定文本周围生成开闭粗体标签。
示例 A-5. 示例
(html (body (princ "Hello ")
(bold (princ "World!"))))
弱点
由于 DSLs 是你自己创建的编程语言,如果你不小心,你肯定可以自己给自己挖坑。很容易创建别人(甚至可能是你自己)都无法理解的语言。
第十七章 讨论了 DSL,包括允许你直接在 Lisp 代码中编写 HTML 的 DSL,如本例所示。
CLOS Guild Battleship
Lisp 方言
Common Lisp
概述
Common Lisp 拥有任何主要编程语言中最复杂的面向对象编程框架,称为Common Lisp 对象系统(CLOS)。它可以使用元对象协议(MOP)在基本级别进行自定义。在编程的任何其他地方都找不到类似的东西。它让你能够创建极其复杂的软件,同时又不失去对代码的控制。

如何杀死虫子
面向对象编程(OOP)是保持虫子(错误)在控制之下的一种常用技术。通过以面向对象的方式编写代码,你可以解耦代码的不同部分。当你解耦代码时,你会将代码分解成逻辑组件,这些组件可以独立进行测试。
示例 A-6. 示例 1:将代码包裹在方法周围
(defclass widget ()
((color :accessor widget-color
:initarg :color)))
(defmethod describe-widget ((w widget))
(format t "this is a ˜a widget" (widget-color w)))
(defmethod describe-widget :before ((w widget))
(add-to-log "Somebody is checking on a widget"))
Common Lisp 中面向对象编程的基本概念在第九章中讨论。有关 CLOS 设计的详细信息,我建议阅读编译在www.dreamsongs.com/CLOS.html的 CLOS 论文。
说明
对于这个例子,假设我们经营一家销售小部件的公司,我们需要一些面向对象的 Lisp 代码来帮助跟踪它们。首先,我们需要使用defclass创建一个新的 CLOS 类(称为widget)
。它有一个属性(或 Lisp 术语中的槽)描述小部件的颜色。接下来,我们声明一个describe-widget,它会打印出小部件的描述
。按照惯例,设计用于操作特定类型对象的函数被称为方法。在这种情况下,describe-widget被认为是widget对象的方法。
现在假设我们想在用户检查小部件时,每次都向日志文件写入一条条目。使用 CLOS,我们可以声明一个或多个前置方法,这些方法将在主describe-widget方法执行之前自动被调用
。
如果我们没有可用的前置方法,我们需要弄脏我们的主小部件代码以添加日志,如下所示:
(defmethod describe-widget ((w widget))
(add-to-log "Somebody is checking on a widget")
(format t "this is a ˜a widget" (widget-color w)))
在这里,我们在 describe-widget 方法的中间添加了日志记录命令
。这段代码看起来很丑,因为将日志写入与描述小部件本质上没有关系。在这个版本中,日志记录也与主代码紧密耦合,这意味着我们不能再独立于调试代码测试小部件代码。使用 before 方法会导致更干净、更解耦的代码。
说明
这个示例演示了多重分派,这是一种强大的技术,可以根据参数的类型选择方法。
示例 A-7. 示例 2:多重分派
(defclass color () ())
(defclass red (color) ())
(defclass blue (color) ())
(defclass yellow (color) ())
(defmethod mix ((c1 color) (c2 color))
"I don't know what color that makes")
(defmethod mix ((c1 blue) (c2 yellow))
"you made green!")
(defmethod mix ((c1 yellow) (c2 red))
"you made orange!")
示例首先创建一个 color 类
并定义了三个派生类:red、green 和 blue
。然后我们声明一个 mix 方法,它将告诉我们混合任何两种颜色会发生什么。默认情况下,当我们混合两种颜色时,它只是说,“我不知道那是什么颜色”
。然而,使用多重分派,我们可以定义更多版本的 mix 方法。例如,我们可以声明一个混合蓝色和黄色的版本
,以及一个混合黄色和红色的版本
。以下是使用不同颜色调用这些方法时发生的情况:
> `(mix (make-instance 'red) (make-instance 'blue))`
"I don't know what color that makes"
> `(mix (make-instance 'yellow) (make-instance 'red))`
"you made orange!"
关于这个示例的重要事项是,为了确定在给定情况下调用哪个混合方法,CLOS 需要考虑传递给方法的所有对象。它是基于多个对象类型的分派到特定方法实现的。这是传统面向对象语言(如 Java 或 C++)中不可用的功能。
弱点
Lisp 社区中关于面向对象技术在编程中应扮演多大角色的观点差异很大。这种风格的批评者抱怨说,面向对象技术通过要求它们存在于许多不同的对象中,迫使数据隐藏在许多不同的地方。数据位于不同的地方可能会使程序难以理解,尤其是如果这些数据随时间变化。因此,许多 Lisp 程序员更喜欢使用函数式技术而不是面向对象技术,尽管这两种技术通常可以谨慎地一起使用。尽管如此,仍然有许多领域在面向对象技术中非常有价值,例如用户界面编程或模拟编程。

Continuation Guild 火箭舱
Lisp 方言
方案(在 Common Lisp 中有限制地支持,例如通过使用延续传递风格或通过使用特殊库)

概述
在 20 世纪 70 年代,创建了一种特殊的 Lisp 方言,它具有一个特别强大的编程特性,称为 延续。基本上,延续让你可以将“时间旅行”放入你的代码中。这允许你做一些事情,比如运行程序的逆向、横向或其他疯狂的方式。例如,这对于实现高级编程技术,如 非确定性编程 非常有用。在非确定性编程中,你编写代码,让计算机有多个选择来决定下一步做什么。如果某个选择不满意,计算机可以使用延续“回滚时间”来尝试不同的路径。
示例 A-8. 示例
(define continuation null)
(define (foo n)
(* (call-with-current-continuation
(lambda (c)
(set! continuation c)
(+ n 1)))
2))
注意
这个示例是 Scheme Lisp 语言的,不会在 Common Lisp 中运行。
如何杀死错误
有许多情况,在代码中拥有时间旅行可以使代码更容易理解。经典的例子是在一个网络服务器中。通常,一个人必须访问网页上的几个页面才能执行一个单一的操作。使用了解延续的网络服务器,你可以编写代码,让这些页面看起来是同时访问的,这使得你的代码的 bug 少得多。稍后,网络服务器使用延续将你的代码分成几个部分(通过使用延续的时间旅行能力),处理处理多页面网络操作的所有丑陋细节。
说明
在示例中,我们创建了一个简单的函数,名为 foo ![http://atomoreilly.com/source/nostarch/images/783564.png],它将一个数字加一,然后将其翻倍。例如,运行 (foo 7) 将返回 16。然而,在函数内部,有一个对 call-with-current-continuation 的调用 ![http://atomoreilly.com/source/nostarch/images/783562.png],它捕获了翻倍步骤之前的函数状态。它将这个“时间点”保存在变量 continuation ![http://atomoreilly.com/source/nostarch/images/783560.png] 中。当前程序的运行状态在此行被捕获 ![http://atomoreilly.com/source/nostarch/images/783562.png]。如果调用捕获的延续,那么在延续被捕获之后发生的所有事情都将被执行。在延续被捕获之后发生的 foo 命令的唯一部分是乘以二 ![http://atomoreilly.com/source/nostarch/images/783554.png]。因此,变量 continuation 现在是一个时间机器,我们可以用它跳回到这个过去时刻,用另一个数字替换我们想要翻倍的数字。所以,如果我们现在调用 (continuation 100),它将返回 200(这是 100 翻倍的结果)。我们已经回到了过去!
弱点
续续是如此出色的特性,以至于它几乎没有缺点。它唯一真正的问题是对于编程语言的创造者。真正的续续在技术上很难放入编程语言中,所以很少有语言支持它们。Scheme 碰巧是其中之一。想了解更多关于基于续续的 Web 服务器,请参阅 Shriram Krishnamurthi 等人撰写的“PLT Scheme Web 服务器的实现和使用”。




简洁派微战斗机
Lisp 方言
Arc Lisp(在 Common Lisp 中使用自定义宏间接可用)

概述
Lisp 允许你编写非常简洁的代码,但看起来并不像是你的猫在键盘上走过。(我正在看着你,Perl!)这是由于我们已经提到的各种特性,例如宏、函数式编程和 Lisp 的动态类型系统。
然而,有一种 Lisp 方言将这一理念推向了极致:Arc。事实上,代码简洁性是这种语言的主要设计目标。Arc 的设计者保罗·格雷厄姆分析了大量计算机代码,试图找出编写尽可能简洁的代码所需的原始命令,同时保持代码的可读性。
它如何杀死虫子
使用 Arc,目标是编写简短的程序。它旨在让你以最简洁的方式表达你的想法,不留任何让错误隐藏的地方。
示例 A-9. 示例
(accum a
(for n 1 1000
(unless (some [is 0 (mod n _)] (range 2 (- n 1)))
a.n)))
注意
这个例子是在 Arc Lisp 方言中,不会在 Common Lisp 中运行。
说明
这个例子使用检查当前循环值是否有较小数可以整除的朴素方法,创建了一个包含 1 到 1000 之间所有素数的列表。
accum函数创建了一个名为a的局部函数,用于收集找到的任何素数!。我们使用for循环!遍历整数,检查是否有比当前i的值小的数可以整除i!。如果没有找到,i将通过调用带有这个新数字的函数a添加到素数列表!。方括号[ ]是创建具有一个参数的 lambda 函数的快捷方式,该参数通过下划线字符访问。
弱点
找到一个最优的简洁命令集是困难的。当可用的命令太多时,你的代码可能难以理解,因为难以记住每个函数的作用。当命令太少时,程序可能会变得过于庞大。Arc Lisp 试图找到一个折中的方案,尽管仍有空间为代码简洁性优化的替代语言设计。
第十六章展示了如何使用宏来使代码简洁,以及在该讨论之后章节中展示了 Lisp 简洁性的许多其他示例。

多核公会战斗者
Lisp 方言
Clojure Lisp(在 Common Lisp 中通过 CL-STM 扩展提供)

概述
现在大多数计算机都有多个核心,因此寻找编写多核/多线程代码的优雅方法引起了广泛关注。一种流行的方法是使用功能数据结构以及一个软件事务内存系统。
使用软件事务内存,你可以在几个线程之间共享复杂的数据结构,并保证没有任何线程会在数据中看到不一致的信息,即使它在另一个线程尝试写入共享数据时尝试读取它。
如何战斗错误
多线程代码往往非常容易出错。通过使用软件事务内存,你可以大大提高编写无 bug 多线程软件的机会。
说明
在这个例子中,我们定义了两个账户,分别称为checking和savings
,它们之间的总金额为$300。然后我们定义了一个transfer-to-savings函数,可以通过调用它将钱从checking账户转移到savings账户
。
示例 A-10. 示例
(def checking (ref 100))
(def savings (ref 200))
(defn transfer-to-savings [n]
(dosync (alter checking - n)
(alter savings + n)))
注意
此示例使用 Clojure Lisp 方言,无法在 Common Lisp 中运行。
因为这个函数包含一个dosync块,Clojure 会确保这两个alter操作
在相同的时间点发生。当然,这两个值并不是在完全相同的时间点被修改,但语言确保它们看起来是同时发生的。如果另一个线程在dosync块内同时读取这两个账户,它将看到总金额正好是$300,无论哪个线程检查这些值多少次。
弱点
软件事务内存会带来性能损失,这抵消了使用多个 CPU 核心带来的部分性能提升。然而,随着 CPU 核心数量的增加,这种损失变得越来越小。
懒惰公会战舰
Lisp 方言
Clojure(在 Common Lisp 中通过 Series 库、CLAZY 库或自定义宏提供)

概述
懒编程语言仅在编译器确定它绝对必要以产生可见结果时才会执行计算。Clojure 是最受欢迎的包含懒编程作为主要特性的 Lisp 方言。然而,所有 Lisp 方言中都有懒编程的有限形式。
如何杀死错误
懒惰的语言允许你创建无限大的数据结构(只要你不尝试使用所有的数据),这使得更多的代码可以表述为大型数据结构的转换。一般来说,调试数据结构比调试算法更容易。算法涉及随时间展开的步骤,要理解它们,你通常需要观察它们执行的过程。另一方面,数据独立于时间存在,这意味着你只需查看数据结构就能找到其中的错误。
示例 A-11. 示例
(take 20 (filter even? (iterate inc 0)))
注意
此示例使用 Clojure Lisp 方言,无法在 Common Lisp 中运行。
说明
此代码返回前 20 个正偶数。为此,它首先使用iterate函数创建一个从零开始的整数列表,形成一个无限大的正整数列表
。然后它过滤出偶数
。最后,它从该结果中取出前 20 个数字
。直到最后的take命令,正在操作的数据结构在理论上是无尽的。然而,由于 Clojure 是一种懒语言,它仅在需要时实例化这些数据结构。这意味着只有前 20 个这样的数字会被生成。(即使如此,只有在实际使用最终值的情况下,例如将其打印到屏幕上,它们才会被生成。)
弱点
由于懒编程语言选择代码运行的顺序,如果你尝试跟踪代码的执行过程,可能会导致调试困难。
第十八章 讨论了懒编程。



浙公网安备 33010602011771号