Common Lisp:符號計算簡單介紹(第十章)

第十章 賦值(Assignment)

10.1 導語

我們在第五章學習的宏函數setf改變了變量的值;這個行為被稱作賦值。在本書中我們已經盡量避免賦值的使用,僅僅是用來在頂層循環設置全局變量。我們還沒有學到如何在函數內部使用setf。
初學編程的時候為什么要避免使用賦值呢?因為賦值是很容易被誤用的,從而導致函數很難被理解和調試。如果你一開始學編程的時候用的語言是basic,pascal,modula或者C,他們都十分仰仗賦值,你可能對于lisp不怎么用賦值感到很驚訝。相比其他語言,lisp提供了豐富的控制結構集合(比如let,還有函數式操作)這使得賦值不那么緊要了。
然而在某些場合下,lisp中使用賦值就是很合適的。本章將會介紹一些使用賦值編程的標準技術,還有一些除了setf之外的內建賦值格式。賦值被經常用在迭代控制結構的組合上,我們將在后續章節去討論。

10.2 更新一個全局變量

假設我們現在正在經營一家檸檬水小攤,我們想要追蹤到現在為止已經賣掉多少瓶。將整個銷量存儲在一個全局變量中,“total-glasses”,初始化是0.

1.JPG

Common lisp中有一個慣例就是全局變量的開頭和結尾都會加上星號。自然在頂層循環進行快速的計算的時候可以忽略這個慣例,就是用全局變量進行計算。但是在你想要寫一個程序來處理全局變量,那就要加上星號了。
現在,每一次我們賣出一些檸檬水,就不得不更新這個變量。我們也想知道現在為止有多少被賣出。
2.JPG

請注意sell函數包括了兩個語句,第一個是用來更新變量TOTAL-GLASSES。第二個語句是用來打印現在為止已經賣出多少瓶的信息。因為使用format來打印結果,所以返回值是nil。

10.3 常規更新函數

setf可以給任何變量賦任何值。很普遍的賦值用法就是去更新一個變量,換言之,變量的舊值被用來計算變量的新值。我們的檸檬水小攤就是地A型的更新變量的例子。很多,或者說大部分賦值的使用就是這種方式。Common Lisp提供的內建宏函數,所表達的大部分更新函數都要比使用setf更加的簡潔。我們來考慮這兩種情況,通過加數或者減數來更新一個計數器,還有通過在前面增加或者刪除元素來更新一個列表。

10.3.1 宏函數incf和decf

給一個數字變量加上數,可以這么寫(setf A (+ A 5)),你也可以這么寫(incf A 5),incf和decf都是為了加數或者減數而定義的特殊賦值宏函數。如果加減的數字被省略,那么默認是1.


3.JPG

10.3.2 pushhe和pop宏函數

通過在最前面組合上元素的方式,可以再列表上加上一個元素,比如(SETF X (CONS ’FOO X)),你也可以更加優雅地表現你的意圖(PUSH ’FOO X)。push,是源自于經典計算機術語,pushdown stacks(壓棧),或者說進棧。棧就像自助餐廳里,放盤子的那個帶彈簧的器具,當以放入一個盤子到棧里面,他就會成為最頂上的那個元素,當你把這個元素從站里面拿出來,下面的盤子就會成為最上面的元素,我們來嘗試一下使用push來建立一個盤子的棧。


4.JPG

dish3現在就是棧最頂上的元素,(從左向右閱讀一個列表就是從頂部到底部閱讀一個棧),每一次調用push都會實行一個賦值,變量mystack總是會被更新加上一個內存單元。當我們把盤子從棧中拿出來的時候,最頂上的盤子就是dish3,lisp提供了一個pop宏函數來更新一個變量,方法是設置指針指向這個原始列表的rest。


5.JPG

請注意pop返回的結果是之前棧中最上層的元素,這個元素被彈出來其實是一個副作用,下面兩個語句是相等的。
6.JPG

let表達式首先記住的棧頂的元素,本地變量top-element。之后再函數體內通過設置mystack成為(rest mystack)而實現彈棧。最后返回值topelement。
為了和其他的賦值語句一致,push和pop實際上應該被稱作pushf和popf。他們的名字不是以f結尾時因為歷史原因。他們在setf出現之前就被使用了,也就是在這個f慣例出現之前。順便說一句,setf就是set field的縮寫,設置域。


7.JPG

10.3.3 更新本地變量

賦值不應該被胡亂的使用,例如,改變本地變量一般是被認為不優雅的做法,只是應該使用let綁定本地變量就好了。(當然也有例外)。一個更加不優雅的做法就是改變出現在函數參數列表里的變量的值,這樣會使得函數難以理解,看看接下來的寫的很爛的代碼


8.JPG

這段代碼可以通過引入一些變量和使用let* 函數來改善。 當所有的賦值都被移除,我們可以確保變量的值一旦被創造出來就不會被改變。使用無賦值風格的程序是很容易理解的,也很優雅。


9.JPG

有一些時候是使用賦值來代替let綁定是更加方便的做法。接下來就是例子,請注意每一個變量初始值都是nil,然后會一次性賦一個新的值。這種有紀律性的賦值并不是一個壞的風格;與出現在前面例子中的賦值是不一樣的。
10.JPG

10.4 when和unless

when和unless都是需要求值超過一個表達式的時候使用的,它的語法是這樣的:


11.JPG

when函數首先對測試部分語句求值,如果返回值是nil,when就僅僅返回nil。如果結果是非nil,when會對他的函數體內的語句求值然后返回最后一個值。unless是相似的,除了對測試部分求值為false的時候才繼續計算之外。對于這兩個條件式來說,都是先對測試部分求值,然后范湖i最后一個語句的值。最后一個語句之前的語句都只是起到副作用,比如i/o和賦值。
when和unless只有在文體上比cond要好一些,他們的語法更加簡單一些,也更加平易近人,因為他們的只是被切分成兩個部分。舉個例子,假設我們想要寫一個函數來接受兩個數字作為輸入,并使他們相乘。假設這個函數需要第一個輸入的數字是奇數,第二個輸入的數字是偶數。如果輸入除了一點紕漏,那么程序可以通過加1或者減1的方式來修正輸入,并且打印出一個合適的警告信息。


12.JPG

10.5 虛擬變量

一個虛擬變量就是指指針可能被存儲的任何地方。一個像X或者N的普通變量包含了一個指向它的值的指針。但是指針也可以被存儲在其他地方,比如一個內存單元的car和cdr。賦值的意思其實是將一個指針替換成另一個指針,所以當我們說變量N的值是3的時候,說的其實是一個叫做n的變量包含了一個指向數字3的指針。一個表達式(incf n)就是將原來的指針替換為一個指向4的指針。
本章介紹的賦值宏函數可以給虛擬變量賦值,也就是說他們可以在很多不同的地方存儲指針。SETF, INCF, DECF, PUSH, 或者POP的第一個參數就是一個位置描述,請看例子;


13.JPG

如你所見,setf和相關的語句可以接受位置描述,如(fourth x),然后在那些地方存儲新的指針,舉個例子,表達式(fourth x)定義的指針就是列表x的第四的內存單元的car。這個為止也被稱為x的cdddr的car,如下所示。


14.JPG

10.6 樣例學習:井字游戲

在本節我們會寫我們的第一個大程序:不止是玩井字游戲,還要解釋每一步背后的意思。在面對這樣復雜度的程序設計的時候,我們需要首先互點時間想一想整個的設計,特別是將會使用的數據結構設計。我們先在畫板上進行設計:


15.JPG

我們怎么來描述一個井號畫板呢?有字符board帶頭的一個列表,后面是十個數字,每一個數字描述的是每一個位置的內容。如果對應位置的數字是1,那么就表示該位置的內容是O,如果對應位置的數字是10,就表示對應位置的內容是X。函數make-board創造一個新的井字游戲畫板。


16.JPG

請注意如果B是一個存儲井字游戲畫板的變量,滑板的位置1可以寫入,(nth 1 B),位置2可以寫入(nth 2 B),依次類推。(nth 0 B)就是返回一個字符畫板。
現在,讓我們來寫一個函數打印畫板,convert-to-letter函數是將0,1,10等分別轉換成空格,O或者X的函數。print-row是打印畫板一行的函數,print-row在print-board函數中逐次調用。
17.jpg

我們能通過改變列表里面任意位置的數字來實現玩家走一步的效果,只要把對應位置的數字從0改成1或者10就好。在make-move函數中的變量player不是1就是10,這取決于是誰走出這一步。


18.JPG

在繼續之前,我們先做一個模板來測試一下幾個函數,我們會定義變量 * computer * 和 * opponent * 來存儲10和1的值(分別就是X和O),因為這樣子顯得很清楚。
19.JPG

為了讓程序容易表現,設置畫板的表現方式是很重要的,對于井字游戲來說還是比較簡單的,因為三個數字成一行的話只有8種配置存在。橫的三個,豎的三個,還有對角線兩個。我們可以說每一種組合都是一個triplet(三聯體)。我們會把所有的三聯體存儲在一個全局變量中 * triplets * 。
20.JPG

現在,我們可以寫一個函數sum-triplet來計算畫板中由三聯體定義的位置的值的和。例如,右對角線的三聯體是(3 5 7)。三個元素的的位置的值的和是11,表示有一個10和一個1還有一個空格(以某一個順序排布)在那個對角線上。如果和是21,那么就表示有兩個X和一個O存在。如果和是12,就表示有一個X和兩個O存在。
21.JPG

想要完全分析出一個畫板,我們需要瀏覽所有的和,函數compute-sums的作用就是返回一個所有8個和的列表。
22.JPG

請注意玩家O如果在一行達到了三個O,其中一個和就會是3,類似的如果玩家X達到三個一線的話,其中一個和就是30,我們可以寫一個斷言來檢查這個條件。


23.JPG

我們等會兒回過頭在看畫板分析這個問題?,F在我們先來看看這個游戲的基本框架。函數play-one-game給用戶提供了先出手的機會,傳遞一個新的,空的畫板作為輸入。
24.JPG

函數opponent-move的作用是讓對手走一步,并且判斷這一步是不是合法。然后更新畫板,調用computer-move。首先,如果對手的一步讓三個符號連成一線,對手會贏游戲結束。第二,如果在畫板上已經沒有空白的地方存在了,游戲就會陷入和局而結束。我們假設對手是O,計算機是X。
25.JPG

合法的一部的意思是輸入1到9之間的一個整數,這個證書對應了畫板上的空位。函數read-a-legal-mobe讀取一個lisp對象并且檢查他是不是合法的一步。如果不是,函數就會調用他自身然后讀取另一步。請注意,第一個兩個cond語句每個都包含測試部分和結果部分,最后一個值(遞歸調用)會被返回。
26.JPG

board-full-p斷言會被opponent-move調用來判斷在畫板上還有沒有更多的空白位置。
27.JPG

函數computer-move類似于oppnent-move,除了晚間是X而不是O之外,而且駛入不是從鍵盤的來,而是會調用choose-best-move函數。這個函數會返回一個雙元素的列表,第一個元素師X擺放的位置,第二個元素是一個字符串來解釋每一步之后的策略。
28.JPG

現在我們機會已經準備好玩我們的第一個游戲了。我們的第一個版本中,choose-best-move只有一個策略,隨機選一個合法的位置。函數random-move-strategy返回一個列表,列表的第一個元素就是移動的位置,第二個元素是一個字符串,來解釋走這一步的策略。函數pick-random-empty-position從1到9之間選擇一個隨機數,如果那個位置為空,那么就是用,否則他就會遞歸調用自身來嘗試另一個隨機數。
29.JPG

你可以先嘗試和電腦玩一下游戲來看看感覺如何,很快你就會感覺到,隨機選擇策略對于計算機端來說不是一個很好地選擇;有些時候會讓計算機下出很蠢的棋來:
30.JPG

計算機很明顯在已經有兩個X連成一線的情況下,在本可以贏的時候在位置3下了一步。隨機選擇一步,在位置4放一個X對于全局沒有任何好處,因為在垂直方向上那條路已經被O給封死了。
為了使我們的程序更加聰明,我們可以編程找到兩個連成一線的情況,如果有兩個X連成一線,計算機應該填上第三個來贏得游戲,與之相反,如果有兩個O連成一線,就應該在第三個位置放一個X來阻止對手勝利。
31.JPG

如果不能滿足他們各自的的策略,make-three-in-a-row和block-opponent都會返回nil?,F在我們需要去修改choose-best-move函數來使用更加好的策略。我們引入一個or到函數體當中這樣就可以一個個評判具體策略,直到有一個不是nil。
32.JPG

新的策略使得游戲更加有趣了,計算機會在對手明顯要贏得時候進行防守,也會在合適的時候利用機會取得勝利、


33.jpg

小結

setf宏可以給變量賦任何值。更新一個變量意味著基于它的舊值來計算一個新的值。兩個常規格式的更新語句是給一個數字變量加上或者減去(如incf和decf的操作),或者是給一個列表前面加上或者減去一個元素(如push和pop的操作)。大部分更新操作是用在全局變量上。改變本地變量的值一般被認為是不好的編程風格,相比之下,使用let函數來綁定新變量會更好些。
一個虛擬變量就是一個指針會被存儲的任何地方。本章討論的所有膚質宏函數都可以操作虛擬變量,不僅僅是針對普通變量。
賦值操作的使用在lisp編程里是很保守的。let,函數式操作,還有尾遞歸函數,這些其他語言所欠缺的,是的賦值在很對哦情況下變得不是很緊要了。沒有賦值的程序一般被認為是很優雅的。

本章涉及函數

賦值宏函數: SETF, INCF, DECF, PUSH, POP.
條件式: WHEN, UNLESS.

Lisp Toolkit: BREAK and ERROR

break和error函數對于調試是很有用的,也會使得函數對于bug更有抗性。break在第八章的工具小結被介紹,但是沒有展開他的全貌,break和error都接受一個格式控制字符串作為一個參數,附加的參數,格式控制指令也會出現在控制字符串里。
break打印的是由格式控制字符串生成的信息,并且會調用lisp進入調試器。在調試器使用結束之后,通過使用一些調試器命令,比如go,proceed和restart,你可以從斷點開始繼續執行你的程序。(調試器的實現是獨立的,所以你的調試器的命令形式取決于你的lisp實現)。
下面的例子是使用break來調試一個函數,這個函數假設接受一個售價和一個傭金率作為輸入,計算出傭金,打印信息,然后根據傭金是不是大于100美元,返回rich或者poor。有些時候,他會返回nil,這就是一個bug。


34.JPG

為了調試這個程序,我們開始在函數體中插入break調用,然后我們可以使用調試器來檢查控制棧和本地變量的值。


35.JPG

現在錯誤的原因就非常明顯了,當傭金剛好等于100美元的時候,cond語句都不是一個真值,所以cond會fanhuinil。解決方法就是將第二個測試表達式替換為T。
error函數接受的參數和break相同,第一個參數是格式化字符串,之后是一些附件參數。error和break之間的一個區別是error從不返回。你不能從error中繼續。第二,error僅僅是報告錯誤然后終止程序,沒有進入調試器的打算,雖然打不粉實現是會進的。
通過插入狀態檢查(sanity checks),程序會變得更加健壯。狀態檢查就是一些確保都是正常,有錯誤就報錯的表達式。例如,這個版本的average函數就會檢查他的輸入是不是都是數字。
36.JPG

Common Lisp還提供了一些其他的函數來報告錯誤。warn函數打印一個警告信息但是不會終止運行中的程序。cerror表示“continual error”,用戶會被告知出錯然后會有繼續執行的選項。這些函數,還有新的Common Lisp條件系統都允許你標記和設置任意的錯誤條件,這個不會在本書介紹。請看你的用戶參考手冊來獲取細節。

第十章進階話題

10.7 Do-It-Yoursef List Surgery

你可以通過使用虛擬變量調用setf來直接操作指針。例如,假設我們想要把一個三個內存單元的列表轉換成一個兩個內存單元的列表,把中間的那個內存單元拿掉。換句話說。我們想要把第一個內存單元的cdr直接指向第三個內存單元。


37.JPG

請注意B的值是不會被snip給改變的。只有第一個單元的cdr被改變了。


38.jpg

我們可以使用setf來創造下面的循環結構。
39.JPG

循環列表circ看起來就像這樣:


40.JPG

直接更改內存單元的指針來修改列表的方法被稱作list surgery(是在不知道怎么翻譯,再深入學習一下之后回來補上,暫且可以理解為列表操作,有朋友知道的話請告知)。列表操作在面對大型的復雜的列表的時候是非常有用的,因為改變一些指針要比建立全新的列表快的多。這也會減少程序的內存要求(或者說更少的使用垃圾回收機制)。進階的common lisp編程包括了很多列表操作(list surgery),但是對于初學者就不是很必要了。最常用的列表操作已經內建在common lisp中,我們會在下一節見到。

10.8 破壞性操作列表

破壞性列表操作是指那些改變了內存單元內容的操作。這些操作是很危險的,因為他們能夠創造循環結構,變得很難打印出來,而且還有共享結構可能會很淡判斷。但是破壞性函數也是很強大而且有效地工具。根據慣例,大部分破壞性函數的名字都有一個前導N(基本上就是意外的歷史遺留吧因為)。

10.8.1 NCONC

nconc(由concatenate而來)是一個破壞性版本的append。append函數創造一個新的列表作為結果,nconc是物理上的改變第一個輸入的最后一個內存單元指向第二個輸入。


41.JPG

如果第一個輸入是nil,就會值返回第二個輸入,因此,也不應該事先就假設(NCONC X Y)就愛一定會改變x的值。如果x是nil,它的值不會被改變。所以要在setf的函數體內,使用nconc來給x賦值才會可以。


42.jpg

nconc函數實際上接受任意數量的輸入,并且暴力連接所有的輸入成為一個內存單元鏈條。我們也可以寫一個自己版本的nconc來接受兩個列表。技能點:如果第一個輸入是nil,那么就簡單append第二個輸入然后返回。
43.JPG

10.8.2 NSUBST

nsubst是subst的一個破壞性版本。他通過改變一些內存單元的car指針來修改列表。


44.JPG

在最后一個例子中,既然我們在列表中搜索(a i),我們告訴nsubst使用equal作為等于斷言,原先默認的也不會起作用了。

10.8.3 其他破壞性函數

很多其他的Common Lisp內建函數也有對應的破壞性版本。例如有nreverse,nunion,nintersection和nset-difference。對于前綴n的命名慣例也只有兩個例外。
append確實是第一個擁有破壞性副本的lisp函數,它的破壞性版本叫做nconc,(也有一個函數叫做conc,但是因為使用的含混不清在后續的方言中就消失了)很多年之后nconc才導致了n前綴來表示破壞性函數的慣例,這也是為什么沒有nappend的原因。另一個n前綴慣例的例外情況就是remove。它的破壞性副本叫做delete,再一次是優于歷史原因,(delete在nconc之后才發明,但是卻是在n慣例形成之前,所以沒有一個nremove的版本)。ni原版本認為是noncopying或者nonconsing的縮寫。

10.9 使用破壞性操作編程

一個破壞性函數特別有用的地方在于給復雜的列表結構做出細微的改變,比如在井字游戲中的make-move函數。還有另一個例子,假設我們使得接下來的表格存儲在全局變量 * things * 中。

45.JPG

我們如何將字符object1改成frob呢?表達式(ASSOC ’OBJECT1 THINGS)將會返回列表(OBJECT1 LARGE GREEN SHINY CUBE)我們可以使用setf來改變第一個內存單元的的car部分存儲的指針,既然這是一個列表的破壞性操作,那么列表的值也將會被改變。我們就來寫一個一般的重命名函數:
46.JPG

我們可以使用nconc,另一個破壞性操作,來給列表中的對象加上一個新的屬性。
47.JPG

10.10 SETQ和SET

在早起的Lisp方言中,setf和虛擬變量是不可獲得的,賦值函數叫做setq,setq特殊函數今天仍然存在。它的語法和宏函數setf相同,也可以被用在給一般變量賦值(但虛擬變量不行)。


48.JPG

如果你閱讀比較古老的lisp書籍,你會注意到他們的賦值使用setq而不是setf完成的?,F代Common Lisp程序員使用setf作為賦值語句,不論是普通變量還是虛擬變量。setq在今日被認為是陳舊的。在內部,仍然,大部分lisp實現使用setq來完成普通變量的賦值,所以你還可以在調試器輸出中看到setq。
set函數,類似于setf,來自于最早的lisp方言,lisp1.5,set會對兩個參數都進行求值,第一個參數必須求值為一個字符,因為Common Lisp使用語法作用域,而lisp1.5則不是,set函數的意義也就改變了。在common lisp中,set在字符的值單元里存儲一個值,及時是本地變量有同名的變量存在。symbol-value函數返回的是一個字符值單元里的呢榮,這里是一個使用set和symbol-value的例子。


49.JPG
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容