第十一章 迭代和塊結構
11.1 導語
名詞“迭代”的意思就是重復,或者說一遍又一遍的做一件事情。遞歸和函數式操作就是重復的,但是迭代(iteration)(也被稱作循環(looping))是一個最簡單的循環(repetitive)控制結構。實際上,所有的編程語言都會包括一些寫迭代表達式的方法。
在lisp中的迭代比大部分其他語言要更加精妙一些。Lisp提供強大的迭代結構,叫做do和do * 。還有更簡化的版本叫做dotimes和dolist。
在本章我們也會學習塊結構,一個從algol族語言(包括pascal,modula,ada)中引申過來的概念,我們可以看到如何將表達式組織進塊(blocks)中,如何給塊命名,以及為什么塊很有用。
11.2 dotimes和dolist
最簡單的迭代格式就是dotiimes和dolist,兩個都是宏函數,就是說他們不會對所有參數求值,他們有相同的語法。
dotimes會發福對函數體內的語句求值n次,次數是由一個步進的所因變量控制,從0一直到n-1。然后就會返回結果語句的值,如果忽略的話默認就是nil。(結果語句是括起來的因為是可選的)下面是一個dotimes的例子,從0數到3.索引變量被命名為I,請注意dotimes返回的結果是nil。
dolist和dotimes的語法是一樣的,只是把步進的數字替換成了步進的元素列表。在接下來的例子中,dolist返回的值是字符flowers。
11.3 離開循環體
不想再繼續循環,想馬上離開循環提的時候,可以調用return函數,return函數接受一個輸入,返回的值就作為迭代語句的結果。當return函數被用在強制結束迭代的時候,迭代的結果語句表達式將會被忽略。
一個叫做find-first-odd的函數返回的是列表的第一個奇數。它使用dolist來循環列表中的元素,找到奇數的時候調用return來離開循環。如果列表不包括奇數,之后當循環結束,dolist會返回nil。一個關于find-first-odd很有意思的點就是循環體中包括了兩個語句,而不是一個。循環體可以包含任何數量的語句。
dolist設定一個特定的返回值語句是十分有用的,如下例,函數check-all-odd使用dolist來檢查是不是所有元素都是odd,如果是,dolist就會在循環結束的時候返回字符T,如果有任何非奇數的數字存在,函數馬上從循環中返回,返回值是nil。
11.4 遞歸搜索和迭代搜索的比較
用在搜索普通列表的時候,迭代比遞歸用起來更加簡單。據不同的實現來說,也許也是更有效率的。比較兩個find-first-odd的版本,這些代碼已經將format語句簡化省略了。
迭代版本有幾個小優點。首先,這個終極的是語句是沒有疑問的,dolist一定會在列表的盡頭停止操作。而在遞歸版本中我們必須要特意加一個cond語句來含混的檢查這個邊界。第二,在迭代版本中變量E是迭代中自帶的,不需要另外設置步進變量,這個很方便。在遞歸版本中,我們不得不使用(rest x)來指向下一個操作對象,在每一個遞歸中含混的計算每一個(rest x)。
在其他情況下,遞歸可以比迭代更加簡潔而且自然。例如,你可以很方便的用car/cdr遞歸來搜索一個樹,沒有相等同的方法來優雅實現這樣的功能。當然迭代的解決方法是存在的,但是以很丑陋的方式。
11.5 使用賦值構建結果
在第八章,我們見到了使用不同的方式來建立一個結果,比如通過遞歸調用構建一個列表。在迭代程序里,結果是通過反復賦值來構建的,我們首先見到如何在dolist或者dotimes的函數體里通過setf的賦值來構建結果。之后張潔麗你會看到如何使用do來賦值。
Let’s start by using DOTIMES to compute the factorial function. First we
create an auxiliary variable PROD with initial value one. We will repetitively
update this value in the body of the DOTIMES, and then return the final value
of PROD as the result of the DOTIMES. Since the index variable I varies
from zero to N-1 rather than from one to N, we must add one to I each time
we reference its value in the body. Thus, (IT-FACT 5) counts from zero up to
four, but it multiples PROD by the numbers one through five.
我們首先來看看使用dotimes計算階乘函數,首先我們創建一個輔助變量prod,初始值是1,我們會在dotimes的函數體內反復更新這個變量,然后最后返回prod的值。因為索引變量時從0到n-1,而不是0到n,我們要每一次都給I加1。(it-face 5)是從0到4,但是prod的相乘數字是1到5。
下面是另一個使用賦值的例子,寫一個迭代的交集函數。變量elementE幣綁定在集合x的元素上,如果element是集合y的元素,會被壓入result-set,否則就不會。當所有的x的元素被處理結束,dolist返回result-set的值。
11.6 使用mapcar的迭代和遞歸的比較
mapcar是應用一個函數到列表里每一個元素的最簡單方法。考慮一下列表中的數字計算平方的問題,函數是版本是比遞歸版本要簡單得多的。
mapcar所做的事情不僅僅是處理輸入的列表,并在結尾處停止,而且還把結果組合成一個列表。素有這些操作在遞歸版本中都必須明確作出處理。如果哦我們使用dolist來寫一個迭代的版本的話,終結街側竟會自動處理,但是我們仍然必須使用復制來構建結果。下面就是這樣一個嘗試。
The function’s result is faulty: It’s backwards. This is typical for an
iterative solution. Since the function proceeds through the input list from left
to right, and pushes each result onto the front of the result list, the result list
ends up backwards. The square of the first number in the input list is the last
number in the result list, and so on. We can fix this by writing (REVERSE
RESULT) as the result-form of the DOLIST.
函數的結果是個作物,看上去是反過來的。這是一個迭代方案的典型結果。因為函數是從左到右進行處理的,那么在壓棧的時候就會是倒過來的順序。第一個數字的平方在結果列表中是最后一個,依次類推。我們可以再最后加上一個reverse來修正結果。
如果你已經閱讀過進階話題章節的話,你會明白為什么有經驗的lisp程序員在迭代函數的最后傾向于使用破壞性函數nreverse來替代reverse了。如果你跳過了那些章節,也不必擔心。
11.7 do宏
do是lisp中最強大的迭代形式,他可以綁定任何數字到變量中,就像let一樣。也可以在多音變量里以你喜歡的方式步進任何值,并且允許你定義你自己的測試來決定什么時候離開循環。因為他太過強大,所以do的語法是稍微有點復雜的。
首先每一個在do的變量列表里的變量都被賦予了一個初始值,之后測試部分就會被求值,如果結果是true,do就會對終結操作求值然后返回最后一個的值,否則do就會春旭求值函數體。函數體中可能會包括return語句啦強制返回。當do到達整個函數體的最后的時候,就會開始下一個循環的迭代。首先,變量列表里的每一個變量都會被更新表達式來更新值。(更新表達式也可以被忽略,變量就會維持原有的值)當所有變量都被更新過后,終結測試就會再一次求值,如果返回true,do就會求值終結操作,否則就會再次求值函數體。
下面叫做launch的函數是用do來寫的,請注意他只是用了一個索引變量cnt,他會從n遞減到0。用dotimes來寫launch也是可以的,但是會有一點難看,因為dotimes所因變量的步進是在一個“錯誤“的方向。
下面是一個使用do來定義的count-slices函數的實現,(count-slices在第八章介紹過)這個循環使用兩個索引變量。cnt是從0開始并備用在構建結果。Z的步進是loaf的后續rest。
這個Do函數的函數體是空的,所有的計算都在變量列表的表達式里面結束了。假設我們求值(count-slices ‘(x x))。當我們進入Do,cnt就初始化為0z就初始化為(x x)。之后來到終結測試,z不是nil循環就不會終結,函數體是空的,所以do就開始更新變量,cnt被設置成為(+ cnt 1),也就是加上了1,Z設置成為(rest z),就是列表(x),之后do再一次計算終結測試的結果,z依然不是nil,所以再一次迭代,這一次cnt的值成了2,z成了nil,終結測試也變成了true,被求值的表達式和被返回的循環終結結果是cnt,所以返回值是2.
11.8 隱式賦值的好處
do相比dotimes和dolist有一些優勢。你可以以你喜歡的方式來步進變量,比如以遞減計數而不是遞增計數。do可以通知綁定多個變量,這樣子在do中建立一個變量列表的結果就變得很容易。也就是說,沒有必要使用let加上顯式的setf來實現了。下面是一個使用do來實現的階乘函數版本。
這個版本的fact使用遞減計數而不是遞增計數字,并且使用了do的平行綁定屬性,當開始計算(fact 5)的時候,i被初始化為5,result初始化為1.更新變量的時候,表達式(- I 1)求值為4,(* result I)求值為5.只有在所有表達式都求值結束,變量自身才會被改變;I被設置成為4,result被設置成為5。下一次精力循環,(- I 1)求值為3,等等以此類推,剩下的如下圖;
count-slices和fact的函數體都是空的,這也是使用do最重要的理由。在變量列表中的更新表達式里就可以完成所有的隱式賦值工作,所以就不需要再寫一個setf或者push了。這樣風格的函數被認為是非常優雅的。
有些時候最好不要把所有的工作都丟給更新表達式。特別是當更新表達式中有條件式的時候。看看這個版本的it-intersection,沒有函數體。
由于do想要每一次都通過循環來更新result,但是我們想要的只是在在(first x)是y的一個元素的時候,值進行改變。是一個更簡單的版本可以忽略在變量列表里的表達式,而是在函數體重設置一個條件push來實現。
如果你想做的事情就是迭代列表中的元素,那么dolist回事比do更精練的方法,但是do是更加一般化的方法。例如,我們使用do來同事迭代多個列表,函數會比較從兩個列表中的相對應的元素,一直到有兩個相等的出現為止。例如下面的函數FIND-MATCHINGELEMENTS。
11.9 宏函數 Do *
下面是一個用do來實現的find-first-odd函數,按照一般慣例,變量x作為輸入的rest的步進變量。在函數體內部,我們洗衣歌(frist x)來支出輸入的元素。
宏函數Do * 的語法和do是一樣的,但是區別在于do是逐個創建更新變量,就像let一樣,而不是像let一樣一次性創建所有。在find-first-odd函數中使用do的一個好處就是,他允許我們去定義第二個索引變量來保存列表的后續元素,第一個所用變量保存的是后續元素的cdr。
請注意,索引變量E使用的初始值和更新表達式都是(first X),這樣做的原因是,如果更新值被省略的話,E的值不會在每一次進入循環的時候改變。在do的變量列表中,e出現在x之后是很重要的,因為e的值仰賴與x的值。
11.10 do無限循環
把nil作為終結測試的話,do循環就會永遠進行下去,對需要從鍵盤輸入內容(比如數字)的函數來說,這是一個很有用的特性。如果用戶鍵入除了數字之外的其他對象,函數就會打印一個錯誤信息,然后再次等待輸入。如果用戶卻是輸入了一個數字,函數就會離開循環,使用return返回那個數字,下面是例子。
11.11 隱式塊(implicit blocks)
在common lisp中函數體是被包括在隱式塊(implicit blocks)當中的,函數名字就是塊名(blocks name),一個塊就是一些表達式的序列,在這個序列里,可以使用return-form特殊函數來跳出這個塊。在接下來的例子中,find-first-odd的函數體就是一個叫做find-first-odd的塊。return-form的參數是一個塊名和一個結果表達式,塊名是不被求值的,所以不必加引號。
在這個例子中,我們使用return-form來跳出find-first-odd的函數體,而不僅僅是dolist的函數體。return-form從特定名稱的最近的封閉函數體中返回。循環形式,諸如,dotimes,dolist,do和do*的函數體都是封閉的隱式塊,名字是nil。表達式(return x)實際上僅僅是(return-form nil x)的簡寫形式。所以在find-first-odd的函數體中,return-form是嵌套在一個叫做nil的塊當中,這個塊被包含在一個叫做find-first-odd的塊當中。
下面的例子中并不包含迭代,是需要return-form。函數square-list使用mapcar來對一個列表的數字進行操作。但是,如果列表中有元素不是數字的話,square-list并不會報錯,而是返回字符nope。在lambda表達式內部的return-form跳出的不僅僅是lambda表達式,還有mapcar,還有square-list本身。
除了包含函數體的隱式塊之外,塊也可能通過特殊函數block來進行顯式定義。這個特性只在進階應用里有使用,所以這里我們就不展開了。
小結
dolist和dotimes都是最簡單的迭代形式,do和do*的強大是因為可以同時步進多個變量,以及使用任意的更新表達式和終結測試。但是對于最簡單的問題,像搜索列表中的元素之類的,dolist還是更精練些。
所有的迭代形式都會對他們的索引變量進行隱式賦值。這是最最感性的賦值類型;你不會再需要去寫setf語句了,因為循環本身已經做好了賦值。有些時候,還是在循環體重使用顯式賦值來構建結果更加好一些。特別是在像itinsection函數這種有條件式賦值的時候。
函數名也被用作隱式塊的名字,我們因此可以使用return-form在函數體的任何地方跳出函數。
本章涉及函數
迭代宏函數: DOTIMES, DOLIST, DO, DO*.
用于塊結構的特殊函數: BLOCK, RETURN-FROM.
對已有匿名塊使用的普通函數: RETURN.
Lisp Toolkit: TIME
宏函數time告訴你需要多少時間來對表達式求值。也可能會告訴你在求值時候占用了多少內存,或者其他有用的信息。time具體提供什么以及用什么樣的形式展現,根據lisp實現的不同而不同。在評估程序的小籠包的時候time是很有用的,例如,比較一個問題的兩個解決方案,那個更快一些,或者看看一個函數在接收到一個大型輸入的時候運行多慢。
第十一章進階話題
11.12 PROG1, PROG2, 和PROGN
PROG1, PROG2, 和PROGN是三個非常簡單的函數,他們都接受任意數量的表達式作為輸入,然后一次性計算所有表達式。prog1返回的是第一個表達式的值,prog2返回的是子二個表達式的值,progn返回的是最后一個表達式的值,
這些語句在今天已經不是很常用了,他們在早期的lisp版本中是很重要的,那時候,一個函數的函數體可以包含至多一個表達式,一個cond語句最多包含一個序列。
progn還有用的一個地方在于是在于一個if的真值部分和假值部分。如果你想要對一些真值部分或者假值部分的多個表達式求值,那么就必須用progn,block或者let將他們組合在一起。
prog1和prog2的效果是很容易用let來實現的,例如,(pop x)就等同于下面兩個表達式。
時至今日,第二種一般被認為是更容易理解的形式。
11.13 可選參數(optional argument)
common lisp函數可以被定義成接受可選參數的形式,關鍵字參數或者任意數量的參數,只要在參數列表中放置叫做lambda列表關鍵(lambda-list keyword)字的特殊字符。例如,在lambda列表字符串后面的變量就被稱作可選變量。下面的函數接受一個參數x和一個可選的參數y。如果一個可選變量沒有被提供的話,默認就是nil。
可選參數沒有被提供的話,不是一定要用nil作為默認值的。在lambda列表中,使用這樣的形式(變量名 值),就可以用自己的定義替換默認值。下面的函數divide-check,divisor的默認值是2。(rem,由divide-check調用,是一個內建函數,返回一個數被另一個數除了之后額余數)。
11.14 REST參數
下面,列表關鍵字&restlambda之后的參數將會被綁定在一個函數的參數的列表上。它允許函數接受無限數量的參數,就像+和format那樣。下面的函數接受無限數量的參數并返回他們的平均值。
在使用&rest參數的時候,需要特別小心的地方是遞歸函數。在首次調用的時候,函數的參數是會被收集聚合成一個列表的,如果函數接下來遞歸調用自身,市容那個列表的cdr作為輸入,就會出現一個列表的列表,而不是一個原始的列表,下面的例子,函數faulty-square-all就想要返回所有的參數的平方的列表。
我們可以使用apply來進行遞歸調用以修正這個問題。使用apply,(cdr args)的值就會被看做參數的列表來調用,而不是一個單獨的參數。
PROG1, PROG2, 和PROGN函數也可以使用&restlambda列表關鍵字來輕松定義。
內建版本的PROG1, PROG2, 和PROGN函數杜宇穿件參數的列表沒有干擾,因為他們只是需要返回一個值罷了。
11.15 關鍵字參數
在之前章節的進階話題中我們已經見過一些函數是接受關鍵字參數的了。例如member和find-if,例如,當你想要member使用equal作為等于斷言的時候,你會這樣寫:
關鍵字參數在一個函數接受大量的可選參數的時候是很有用的。通過使用關鍵字,我們避免了去記憶那些可選參數的命令。我們要記住的只是他們的名字。你也可以通過用lambda列表關鍵字&key來創造你自己的接受關鍵字參數的函數,比如&optional,就可以替換默認值。下面的函數make-sundae就接受超過6個關鍵字參數。
像:cherries這種關鍵字都是求值為自身的,這也是為什么他們沒有引號。請注意我們調用make-sundae的時候,使用關鍵字:cherries,但是在參數列表中還有makesundae的函數體中,我們只是使用了普通字符cherries。這是一個很重要的區別。在makesundae內部,cherries僅僅是另一個變量。唯一一件特殊的事情是他得到這個值的方式。只有&rest變量會被特殊對待,用&key定義的變量都會用一個特別方式得到值;當調用make-sundae的時候,我們通過使用:cherries關鍵字接續上一個值來給cherries定義一個值。
11.16 輔助變量(auxiliary variables)
lambda列表關鍵字&aux被用于定義輔助本地變量。你可以僅僅定義一個變量名,初始值回事nil,或者你可以使用一個列表的形式(變量 表達式)。在后面的表達式會被求值,然后結果會作為變量的初始值。下面例子中的輔助變量len就用來保存列表的長度。
關鍵字&aux完成的事情是和特殊函數let*相同的;都是使用逐個綁定來創造變量。選擇使用哪個純粹是個人口味問題。
進階話題涉及函數
PROG1, PROG2, PROGN
Lambda列表關鍵字: &OPTIONAL, &REST, &KEY, &AUX.