使用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運行環境的行為
- SPEL這個術語可以被用來很高雅的解釋很多語言上觀念.
- 這個術語不會導致Lisp中的宏與其它的宏被混為一談
- SPEL這個詞重復的可能性非常低.Google搜索"macro 或者 macros 程序 -lisp -scheme"返回大概1150000條結果.而搜索"spel 或者 spels 程序 -lisp -scheme"值返回28400條結果.
所以,我希望,作為一個Lisp程序員,你能接受這個術語.當然了,像這樣的新詞匯會被接受的可能性非常低.
如果你有一個庫或者是一個Lisp實現者,請先放下你手頭上的工作,先在你的庫里,添加下面這行代碼:
(defmacro defspel [& rest] `(defmacro ~@rest))
譯者感想
- 本人對Lisp的宏還是有些了解的,所以個人無法接受SPEL這個新詞匯
- 且SPEL使得代碼不易閱讀,就game-action這個SPEL來說,使用了兩層,而使用宏只需要一層
- 附錄中是我使用Clojure的慣用法重新改寫的代碼,且文字翻譯成了中文.以及使用了宏而不是SPEL.各位可比較,自行選擇