ivaneye.com

Clojure进阶:使用Clojure编写文字冒险游戏

本文翻译自:Casting SPELs in Clojure

准备

任何学过Lisp的人都会说Lisp和其它语言有很大的不同.它有很多不可思议的地方.本文将告诉你它有哪些独特之处!

本文适用于Clojure,它是一个运行在JVM上的Lisp方言.Clojure的API和语法和 Common Lisp很类似,但是还是有足够多的区别,需要单独为其写个教程.

在大部分情况下,我们会说Lisp而不是Clojure,因为大部分的概念在Lisp中是通用的.我们会指出Clojure特有的内容.

Clojure是运行在JVM之上的,所以你需要先安装JVM.如果你是MAC机,那么Java已经被安装过了.如果是Linux或者Windows系统,你需要到Oracle Java官网下载对应版本的Java.

而Clojure,你可以从它的官网获得最新版本.下载完成后,你只需要解压缩,打开命令行,切换到解压缩目录,输入:

java -jar clojure.jar

如果没有问题,那么你将会看到Clojure输出提示

Clojure 1.6.0
user=>

教程中有很多Clojure代码片段,类似下面的样子:

'(these kinds of boxes)

你只需要将这些代码片段拷贝到Clojure REPL中运行就可以了!当你学习完此教程,你将会有一个你自己的文字冒险游戏了!

语法和语义

每一个编程语言是由语法和语义组成的.语法是组成你的程序的骨架,你必须要遵循它们,这样编译器才能知道你的程序里什么是什么,比如说什么是函数,什么是变量,等等!

而语义是个比较"随便"的东西,例如你的程序里有哪些不同的命令,或者在程序的哪个部分能访问到哪些变量!这里Lisp比较特别的地方就是,Lisp的语法比其它任何语言都要简单.

首先,Lisp语法规定,所有传递给Lisp编译器的文本需要是个list,当然这个list可以无限嵌套.每个list都必须使用括号包裹.

另外,Lisp编译器使用两种模式来读取你的代码:代码模式和数据模式.当你在数据模式下,你可以将任何东西塞到你的list中.但是在代码模式下,你的list需要是叫做form的特殊类型.

form也是个list,不过它的第一个符号被lisp编译器特殊对待了---一般被当做函数的名字.在这种情况下,编译器会将list中的其它元素作为函数参数传递给这个函数.默认情况下,编译器运行在代码模式下,除非你特意告诉它进入数据模式.

为我们的游戏世界定义数据

为了进一步的学习form,让我们来创建一些form,来定义我们游戏世界里的数据.首先,我们的游戏有一些对象,玩家可以使用他们--让我们来定义吧:

(def objects '(whiskey-bottle bucket frog chain))

让我们来看看这行代码是什么意思:Lisp编译器总是使用代码模式来读取内容,所以第一个符号(这里是def),肯定是个命令.

在这里,它的作用就是给某个变量设值:这里变量就是objects,而值是一个包含四个对象的list.这个list是数据(我们可不想编译器去调用一个叫做whiskey-bottle的函数),所以在读取这个list时 我们需要将其设值为数据模式.在list前面的哪个单引号就是干这个的:

def命令就是用来设值的(如果你学过Common Lisp,你应该会知道它和CommonLisp中的setf命令等价,但是Clojure中没有setf命令)

现在我们在游戏里定义了一些对象,现在让我们来定义一下游戏地图.下面是我们的游戏世界:

在这个简单的游戏里,只有三个地点:一个房子,它包含起居室,阁楼和花园.让我们来定义一个新变量,叫做game-map来描述这个游戏地图:

(def game-map (hash-map
   'living-room '((you are in the living room
                   of a wizards house - there is a wizard
                   snoring loudly on the couch -)
                  (west door garden)
                  (upstairs stairway attic))
   'garden '((you are in a beautiful garden -
              there is a well in front of you -)
             (east door living-room))
   'attic '((you are in the attic of the
             wizards house - there is a giant
             welding torch in the corner -)
            (downstairs stairway living-room))))

这个map包含了三个地点的所有重要信息:每个地点都有个独立的名字,一个简短的描述,描述了我们能在这些地点看到什么,以及如何进入此处或从此处出去.

请注意这个包含了丰富信息的变量是如何定义的---Lisp程序员更喜欢用小巧的代码片段而不是一大片代码,因为小代码更容易理解.

现在我们有了一个地图和一组对象,让我们来创建另一个变量,来描述这些对象在地图的哪些地方.

(def object-locations (hash-map
                       'whiskey-bottle 'living-room
                       'bucket 'living-room
                       'chain 'garden
                       'frog 'garden))

这里我们将每个对象和地点进行了关联.Clojure提供了Map这个数据结构.Map使用hash-map函数来创建,它需要一组参数类似(key1 value1 keys value2...).我们的game-map变量也是个Map---三个key分别是living-room,garden和attic.

我们定义了游戏世界,以及游戏世界中的对象,现在就剩下一件事了,就是描述玩家的地点!

(def location 'living-room)

搞定,现在让我们来定义游戏操作吧!

环顾我们的游戏世界

我们想要的第一个命令能够告诉我们当前地点的描述.那么我们该怎么定义这个函数呢?它要知道我们想要描述的地点以及能够从map中查找地点的描述.如下:

(defn describe-location [location game-map]
  (first (location game-map)))

defn定义了一个函数.函数的名字叫做describe-location,它需要两个参数:地点和游戏地图.这两个变量在函数定义的括号内,所以它们是局部变量,因此对于全局的location和game-map没有关系.

注意到了吗?Lisp中的函数与其它语言中的函数定义相比,更像是数学中的函数:它不打印信息或者弹出消息框:它所作的就是返回结果.

我们假设现在我们在起居室里!

为了能找到起居室的描述,describe-locatin函数首先需要从地图中找到起居室.(location game-map)就是进行从game-map中查找内容的,并返回起居室的描述.然后first命令来处理返回值,取得返回的list的第一个元素,这个就是起居室的描述了. 现在我们来测试一下

(describe-location 'living-room game-map)
 user=> (describe-location 'living-room game-map)
 (you are in the living-room of a wizard's house -
 there is a wizard snoring loudly on the couch -)

很完美!这就是我们要的结果!请注意我们在living-room前添加了一个单引号,因为这个符号是地点map的一个名称!但是,为什么我们没有在game-map前面添加单引号呢?这是因为我们需要编译器去查询这个符号所指向的数据(就是那个map)

函数式编码风格

你可能已经发现了describe-location函数有几个让人不太舒服的地方.

第一,为什么要传递位置和map参数,而不是直接使用已经定义的全局变量?原因是Lisp程 序员喜欢写函数式风格的代码.函数式风格的代码,主要遵循下面三条规则:

你也许会怀疑在这种限制下你还能写代码吗?答案是:可以!为什么很多人对这些规则感到疑惑呢?一个很重要的原因是:遵循此种风格的代码更加的引用透明(referential transparency):这意味着,对于给定的代码,你传入相同的参数,永远返回相同的结果---这能减少程序的错误,也能提高程序的生产力!

当然了,你也会有一些非函数式风格的代码,因为不这么做,你无法和其它用户或外部内容进行交互.教程后面会有这些函数,他们不遵循上面的规则.

describe-location函数的另一个问题是,它没告诉我们怎么进入一个位置或者怎么从某个位置出来.让我们来编写这样的函数:

(defn describe-path [path]
  `(there is a ~(second path) going ~(first path) from here -))

这个函数看起来很明了:它看起来更像是数据而不是函数.我们先来尝试调用它,看它做了些什么:

(describe-path '(west door garden))
user=> (describe-path '(west door garden))
(user/there user/is user/a door user/going west user/from user/here clojure.core/-)

这是什么?!结果看起来很乱,包含了很多的/和一些其它的文字!这是因为Clojure会将命名空间的名字添加到表达式的前面.我们这里不深究细节,只给你提供消除这些内容的函数:

(defn spel-print [list] (map (fn [x] (symbol (name x))) list))

修改调用方式

(spel-print (describe-path '(west door garden)))
user=> (spel-print (describe-path '(west door garden)))
(there is a door going west from here -)

现在结果很清晰了:这个函数接收一个描述路径的list然后将其解析到一个句子里面.我们回过头来看这个函数,这个函数和它产生的数据非常的像:它就是拼接第一个和第二个list的元素到语句中!它是怎么做到的?使用语法quote!

还记得我们使用quote来从代码模式切换到数据模式吗?语法quote的功能类似,但是还不只这样.在语法quote里,我们还能使用'~'再次从数据模式切换回代码模式.

语法quote是List的一个很强大的功能!它能使我们的代码看起来像它创建的数据.这在函数式编码中很常见:创建这种样子的函数,使得我们的代码更易读也更稳健:

只要数据不变,函数就不需要修改.想象一下,你能否在VB或C中编写类似的代码?你可能需要将文字切成小块,然后在一点点的组装-这和数据本身看起来差距很大,更别说代码的稳健性了!

现在我们能描述一个路径,但是一个地点可能会有多个路径,所以让我们来创建一个函数叫做describe-paths:

(defn describe-paths [location game-map]
  (apply concat (map describe-path (rest (get game-map location)))))

这个函数使用了另一个在函数式编程中很常用的技术:高阶函数.apply和map这两个函数能将其它的函数作为参数.map函数将另一个函数分别作用到list中的每个对象上,这里是调用describe-path函数.apply concat是为了减少多余的括号,没有多少功能性操作!我们来试试新函数

(spel-print (describe-paths 'living-room game-map))
user=> (spel-print (describe-paths 'living-room game-map))
(there is a door going west from here -
there is a stairway going upstairs from here -)

漂亮!

最后,我们还剩下一件事要做:描述某个地点的某个对象!我们先写个帮助函数来告诉我们在某个地方是否有某个对象!

(defn is-at? [obj loc obj-loc] (= (obj obj-loc) loc))

=也是个函数,它判断对象的地点是否和当前地点相同!

我们来尝试一下:

(is-at? 'whiskey-bottle 'living-room object-locations)
user=> (is-at? 'whiskey-bottle 'living-room object-locations)
true

返回结果是true,意味着whiskey-bottle在起居室.

现在让我们来使用这个函数描述地板:

(defn describe-floor [loc objs obj-loc]
  (apply concat (map (fn [x]
                       `(you see a ~x on the floor -))
                     (filter (fn [x]
                               (is-at? x loc obj-loc)) objs))))

这个函数包含了很多新事物:首先,它有匿名函数(fn定义的函数).第一个fn干的事,和下面的函数做的事情是一样的:

(defn blabla [x] `(you see a ~x on the floor.))

然后将这个blabla函数传递给map函数.filter函数是过滤掉那些在当前位置没有出现的物体.我们来试一下新函数:

(spel-print (describe-floor 'living-room objects object-locations))
user=> (spel-print (describe-floor 'living-room objects object-locations))
(you see a whiskey-bottle on the floor - you see a bucket on the floor -)

现在,让我们来将这些函数串联起来,定义一个叫look的函数,使用全局变量(这个函数就不是函数式的了!)来描述所有的内容:

(defn look []
  (spel-print (concat (describe-location location game-map)
          (describe-paths location game-map)
          (describe-floor location objects object-locations))))

我们来试一下:

user=> (look)
(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 stairway going upstairs from here -
you see a whiskey-bottle on the floor -
you see a bucket on the floor -)

很酷吧!

环游我们的游戏世界

好了,现在我们能看我们的世界了,让我们来写一些代码来环游我们的世界.walk-direction包含了一些方向可以使我们走到那里:

(defn walk-direction [direction]
  (let [next (first (filter (fn [x] (= direction (first x)))
                            (rest (location game-map))))]
    (cond next (do (def location (nth next 2)) (look))
          :else '(you cannot go that way -))))

这里的let用来创建局部变量next,用来描述玩家的方向.rest返回一个list,包含原list中除了第一个元素外的全部元素.如果用户输入了错误的方向,next会返回 ().

cond类似于if-then条件:每个cond都包含一个值,lisp检查该值是否为真,如果为真则执行其后的动作.在这里,如果下一个位置不是nil,则会定义玩家的location到新位置,然后告诉玩家该位置的描述!如果next是nil,则告诉玩家,无法到达,请重试:

(walk-direction 'west)
user=> (walk-direction '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 -)

现在,我们通过创建look函数来简化描述.walk-direction也是类似的功能.但是它需要输入方向,而且还有个quote.我们能否告诉编译器west仅仅是个数据,而不是代码呢?

构建SPELs

现在我们开始学习Lisp中一个很强大的功能:创建SPELs!SPEL是"语义增强逻辑"的简称,它能够从语言级别,按照我们的需求定制,对我们的代码添加新的行为-这是Lisp最为强大的一部分.为了开启SPELs,我们需要先激活Lisp编译器的SPEL

(defmacro defspel [& rest] `(defmacro ~@rest))

现在,我们来编写我们的SPEL,叫做walk:

(defspel walk [direction] `(walk-direction '~direction))

这段代码干了什么?它告诉编译器walk不是实际的名称,实际的名字叫walk-direction,并且direction前面有个quote.SPEL的主要功能就是能在我们的代码被编译器编译之前插入一些内容!

注意到了吗?这段代码和我们之前写的describe-path很类似:在Lisp中,不只是代码和数据看起来很像,代码和特殊形式对于编译器来说也是一样的-高度的统一带来简明的设计!我们来试试新代码:

(walk east)
user=> (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 stairway going upstairs from here -
you see a whiskey-bottle on the floor -
you see a bucket on the floor -)

感觉好多了! 现在我们来创建一个命令来收集游戏里的物品

(defn pickup-object [object]
  (cond (is-at? object location object-locations)
        (do
          (def object-locations (assoc object-locations object 'body))
          `(you are now carrying the ~object))
        :else '(you cannot get that.)))

这个函数检查物品是否在当前地点的地上-如果在,则将它放到list里面,并返回成功提示!否则提示失败! 现在我们来创建另一个SPEL来简化这条命令:

(defspel pickup [object] `(spel-print (pickup-object '~object)))

调用

(pickup whiskey-bottle)
user=> (pickup whiskey-bottle)
(you are now carrying the whiskey-bottle)

现在我们来添加更多有用的命令-首先,一个能让我们查看我们捡到的物品的函 数:

(defn inventory []
  (filter (fn [x] (is-at? x 'body object-locations)) objects))

以及一个检查我们是否有某个物品的函数:

(defn have? [object]
   (some #{object} (inventory)))

创建特殊操作

现在我们只剩下一件事情需要做了:添加一些特殊动作,使得玩家能够赢得游戏.第一条命令是让玩家在阁楼里给水桶焊接链条.

(def chain-welded false)
(defn weld [subject object]
  (cond (and (= location 'attic)
             (= subject 'chain)
             (= object 'bucket)
             (have? 'chain)
             (have? 'bucket)
             (not chain-welded))
        (do (def chain-welded true)
            '(the chain is now securely welded to the bucket -))
        :else '(you cannot weld like that -)))

首先我们创建了一个新的全局变量来进行判断,我们是否进行了此操作.然后我们创建了一个weld函数,来确认此操作的条件是否完成,如果已完成则进行此操作.

来试一下:

(weld 'chain 'bucket)
user=> (weld 'chain 'bucket)
(you cannot weld like that -)

Oops...我们没有水桶,也没有链条,是吧?周围也没有焊接的机器!

现在,让我们创建一条命令来将链条和水桶放到井里:

(def bucket-filled false)
(defn dunk [subject object]
  (cond (and (= location 'garden)
             (= subject 'bucket)
             (= object 'well)
             (have? 'bucket)
             chain-welded)
        (do (def bucket-filled true)
            '(the bucket is now full of water))
        :else '(you cannot dunk like that -)))

注意到了吗?这个命令和weld命令看起来好像!两条命令都需要检查位置,物体和对象!但是它们还是有不同,以至于我们不能将它们抽到一个函数里.太可惜了!

但是...这可是Lisp.我们不止能写函数,还能写SPEL!我们来创建了SPEL来处理:

(defspel game-action [command subj obj place & args]
  `(defspel ~command [subject# object#]
     `(spel-print (cond (and (= location '~'~place)
                             (= '~subject# '~'~subj)
                             (= '~object# '~'~obj)
                             (have? '~'~subj))
                        ~@'~args
                        :else '(i cannot ~'~command like that -)))))

非常复杂的SPEL!它有很多怪异的quote,语法quote,逗号以及很多怪异的符号!更重要的是他是一个构建SPEL的SPEL!!即使是很有经验的Lisp程序员,也需要费下脑细胞才能写出这么个玩样!!(这里我们不管)

这个SPEL的只是向你展示,你是否够聪明来理解这么复杂的SPEL.而且,即使这段代码很丑陋,如果它只需要写一次,并且能生成几百个命令,那么也是可以接受的!

让我们使用这个新的SPEL来替换我们的weld命令:

(game-action weld chain bucket attic
   (cond (and (have? 'bucket) (def chain-welded true))
              '(the chain is now securely welded to the bucket -)
         :else '(you do not have a bucket -)))

现在我们来看看这条命令变得多容易理解:game-action这个SPEL使得我们能编写我们想要的核心代码,而不需要额外的信息.这就像我们创建了我们自己的专门创建游戏命令的编程语言.使用SPEL创建伪语言称为领域特定语言编程(DSL),它使得你的编码更加的快捷优美!

(weld chain bucket)
user=> (weld chain bucket)
(you do not have a chain -)

...我们还没有做好焊接前的准备工作,但是这条命令生效了!

下面我们重写dunk命令:

(game-action dunk bucket well garden
             (cond chain-welded
                   (do (def bucket-filled true)
                       '(the bucket is now full of water))
                   :else '(the water level is too low to reach -)))

注意weld命令需要检验我们是否有物体,但是dunk不需要.我们的game-action这个SPEL使得这段代码易写易读.

最后,就是将水泼到巫师身上:

(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 -)
                   :else '(the wizard awakens from his slumber and greets you
                               warmly -
                               he hands you the magic low-carb donut - you win!
                               the end -)))

现在你已经编写完成了一个文字冒险游戏了!

点击这里是完整的游戏.

点击这里是代码.

为了使教程尽可能的简单,很多Lisp的执行细节被忽略了,所以最后,让我们来看看这些细节!

附录

现在,我们来聊一聊被忽略的细节!

首先,Clojure有一套很成熟的定义变量以及改变变量值的系统.在此教程中,我们只使用了def来设置和改变全局变量的值.而在真正的Clojure代码里,你不会这么做.取而代之,你会使用Refs,Atoms和Agents,它们提供了更清晰,以及线程安全的方式来管理数据.

另一个问题就是我们在代码中大量使用了符号(symbol)

'(this is not how Lispers usually write text)
"Lispers write text using double quotes"

符号在Clojure有特殊含义,主要是用来持有函数,变量或其它内容的.所以,在Lisp中将符号作为文本信息描述是很奇怪的事情!使用字符串来显示文本信息可以避免这样的尴尬!不过,使用字符串的话,在教程里就没法讲很多关于符号的内容了!

还有就是SPEL在Lisp里面更普遍的叫法是"宏",使用defmacro来定义,但是这个名字不易于教学,所以没有提及.你可以阅读此文,这是我为什么没有使用"宏"这个名字的原因.

最后,在编写类似game-action这样的SPEL的时候,很可能会发生命名重复的问题.当你编写了足够多的lisp的时候,你会越来越能体会到这个问题了.

Q. 后面我该阅读哪些内容来扩充我的Lisp知识? A. 在cliki网站有很多Lisp书籍可以下载.

如果你对于理论上的内容很感兴趣,那么我推荐Paul Graham的 On Lisp电子书,它是免费的.他网站上的一些短文也很精彩.

如果你对实际应用比较感兴趣,那么大多数Lisp程序员对Perter Seibel编写的"Practical Common Lisp"这本书推崇有加,你可以从这里获得

为什么没有使用"宏"这个词

编写这个教程的一个意图是使用宏来解决真实的难题.而经常的,当我向没有Lisp经验的人解释宏这个概念的时候,我得到的答复往往是,"哦!C++里也有宏".当发生这种事情的时候,我就很难去解释宏的概念了.的确,Lisp中的宏和C++中的宏的确有几分相似,它们都是为了能通过编译器来改进代码的编写...

...所以,假设一下,如果John McCarthy使用了"add"而不是"cons"这个词来将元素添加到list中:我就真的很难解释cons是如何工作的了!

所以,我决定在此文中使用一个新的词汇:SPEL,语义增强逻辑的简称,它更易理解 一些:

所以,我希望,作为一个Lisp程序员,你能接受这个术语.当然了,像这样的新词汇会被接受的可能性非常低.

如果你有一个库或者是一个Lisp实现者,请先放下你手头上的工作,先在你的库里,添加下面这行代码:

(defmacro defspel [& rest] `(defmacro ~@rest))

译者感想

源代码