第五章 變量和副作用
5.1 導語
本章讓你對lisp程序中出現的各種類型的變量加深理解,變量如何被創造的和值是如何改變的。Common Lisp比早期版本的Lisp更加有進步。我們也會討論副作用,那些函數除了返回值之外的操作,其實改變一個變量的值就是一種副作用。
5.2 本地變量和全局變量
每一個變量都有作用域,也就是變量可以被引用的范圍,到現在為止,我們見到過的變量都是出現在函數的參數列表中的。既然變量是被限定在函數體中的,那么他們就被成為本地變量,請看下面的例子:
(defun double (n) (* n 2))
我們每一次調用double函數,一個新的本地變量n就會被創造出來。在double函數體的外部,這個變量是不能不能被引用的,因為已經超過了它的作用域。換句話說,變量n在double函數外部是有著不同的的意思的。
(defun double (n) (* n 2))
(double 5) → 10
n → Error! N unassigned variable.
在錯誤信息中支出的未賦值變量n并不是我們在double函數中創造的變量n。他是另一個變量對任何特定函數來說都不是本地變量的變量。他是一個全局變量,因為全局變量是被初始化為沒有值的(就是未綁定,在老的術語來說),在頂級提示符中鍵入n的時候,也是會得到一個未定義變量錯誤。如果我們看這(double 5)的求值回溯圖,變量n的兩種意義的分別就開始顯現:

只有一個全局變量n,但是有很多本地變量n也沒有關系,因為他們在不同的語言上下文里。
5.3 setf給一個變量賦值
宏函數setf給變量賦值。如果變量已經有一個值,那么新的值就會覆蓋老的值。這歷史一個setf給全局變量賦值的例子:
> vowels VOWELS initially has no value.
Error: VOWELS unassigned variable.
> (setf vowels ’(a e i o u)) SETF gives VOWELS a
(A E I O U) value.
>(length vowels) Now we can use VOWELS
5 in Lisp expressions.
>(rest vowels)
(E I O U)
> vowels Its value is unchanged.
(A E I O U)
>(setf vowels
’(a e i o u and sometimes y)) Give VOWELS a new
(A E I O U AND SOMETIMES Y) value.
> (rest (rest vowels)) Use the new value.
(I O U AND SOMETIMES Y)
setf的第一個參數是變量名;setf不會對這個參數求值。(setf可以不對參數求職是因為他是宏函數)第二個參數就是想要設置給變量的值;這個參數是求值的。它的值被計算出來然后返回設置到變量中。
Global variables are useful for holding on to values so we don’t have to
continually retype them. Example:
全局變量用來保存值是非常有用的,我們就不是不得不重新輸入了:
> (setf long-list ’(a b c d e f g h i))
(A B C D E F G H I)
> (setf head (first long-list))
A
>(setf tail (rest long-list))
(B C D E F G H I)
>(cons head tail)
(A B C D E F G H I)
>equal long-list (cons head tail))
T
> (list head tail)
(A (B C D E F G H I))
head,tail和long-list都是全局變量。
5.4 副作用
像car和+這類普通函數的用處在于他們的返回值,其他函數的用處主要在于它的副作用。setf的副作用就是改變了變量的值,這個副作用要比setf函數返回的值要重要的多。defun函數被調用的原因也是因為它的副作用:定義一個新的函數,defun函數返回的值就是所定義的函數的名字。
另一個有副作用的函數是random函數,Common Lisp的random函數是一個隨機數生成器。(random n)返回一個隨機選定的數字,從零開始到n(但是不包括n)。假如n是一個整數,random就會返回整數;如果輸入是一個浮點數,random就會返回一個浮點數。
> (random 5)
3
> (random 5)
1
> (random 5.0)
2.32459
>(random 5.0)
4.94179
random函數的副作用對于用戶來說是隱藏的,他改變隨機數生成器內部的幾個變量,每一次被調用的時候允許生成不同的隨機數。
setf函數可以改變任何變量的值,本地變量或者全局變量都可以。在本書中我們將只對全局變量使用setf,因為避免更改本地變量的值是一個好的編程風格。但僅僅是表示可以做到,下面的例子表示韓式是可以改變本地變量的值的。另外,請注意這個函數的函數體有兩個括號(兩個表達式),當一個函數有多個表達式的時候,他會對這些表達式都求值,然后返回最后一個表達式的值。
(defun poor-style (p)
(setf p (+ p 5))
(list ’result ’is p))> (poor-style 8)
(RESULT IS 13)
>(poor-style 42)
(RESULT IS 47)
>p
Error! P unassigned variable
在函數poor-style內部,符號p指向的是一個本地變量,所以setf函數改變的是一個本地變量的值。全局變量并不受這個setf函數的影響。在求值回溯圖中,這個賦值是嵌套在poor-style內部的setf函數的副作用。你也可以看到poor-style返回的結果并不是這個語句的結果,因為這個語句不是最后一個語句。

5.5 特殊函數let
到現在為止,我們所見到的本地變量都是由用戶定義的函數創造并調用的,例如doublel和average。另一種創造本地變量的方法就是用特殊函數let。例如,既然兩個數的平均數average是他們和的一半,我們會需要一個叫做sum的本地變量來在average函數內部使用。我們可以使用let函數去創造這個本地變量并且給出一個初始值。而后,在函數內部使用這個let語句來計算。
(defun average (x y)
(let ((sum (+ x y)))
(list x y ’average ’is (/ sum 2.0))))>average 3 7)
(3 7 AVERAGE IS 5.0)
let函數的語法結構是:
(LET ((var-1 value-1)
(var-2 value-2)
...
(var-n value-n))
body)
let函數的第一個參數是一系列的變量和值。編號n中的語句是被求值的,然后本地變量n被創造并被賦值,最后函數體重大額語句被求值返回到上一層函數中。

我們來看一下let函數的內部結構,內部的粗箭頭是中空的箭頭,標記了let函數的函數體。表達出let函數在average函數的語法區域內創造了自己的語法區域。當let的函數體被求值,eval會指向average創造的變量x和y。如果let函數的箭頭是粗的實心的箭頭,那么eval在求值的時候就無法使用x和y。這種情況下,eval會跳出函數體,在全局變量中尋找x和y的值,很明顯這個會印發錯誤。
下面是let函數一下子創造兩個本地變量的例子:
(defun switch-billing (x)
(let ((star (first x))
(co-star (third x)))
(list co-star ’accompanied ’by star)))> (switch-billing ’(fred and ginger))
(GINGER ACCOMPANIED BY FRED)
下面的求值回溯圖準確的表示了let函數創造本地變量star和co-star的過程,請注意這兩個變量和值的語句,(first x)和(third x),都是在本地變量被創造之前就被求值的。

5.6 特殊函數let*
特殊函數let類似于let,除了一點,他叔一個個創造本地變量,而不是像let那樣一次性創造所有。因此,在第一個語句中創造的變量可以再第二個語句中直接使用。這個特性是十分有用的,特別是在長段的計算中警醒一些中間步驟。
例如,假設我想要計算物品價格變化的百分比的函數,新的價格和舊的價格作為輸入。我們的函數必須計算兩個輸入的價格之間的差值,然后將他們的差值除以舊的價格來得到價格的變化,然后乘以一百得到百分比。我們可以使用本地變量名,diff,proportion,percentage還命名變量。使用let來代替let,因為這些變量必須是被一個個創在,每一個的計算都依賴前一個。
(defun price-change (old new)
(let* ((diff (- new old))
(proportion (/ diff old))
(percentage (* proportion 100.0)))
(list ’widgets ’changed ’by percentage
’percent)))> (price-change 1.25 1.35)
(WIDGETS CHANGED BY 8.0 PERCENT)
price-change的求值回溯圖表現了let函數如何創在本地變量。請注意表達式(- new old)僅僅使用了兩個語法環境中的變量new和old。表達式(/ diff old)則是在嵌套語法環境中被使用的同時定義了本地變量diff。最終的語句返回時,let函數使用了所有的變量。

一個很普遍的錯誤就是在需要使用let*函數的地方使用了let函數。考慮下面的函數fauly-size-range。它使用max函數和min函數來找到一組數字中的最大最小值。max和min是common lisp的內建函數,都接受一個或者更多的輸入。最后一個語句將結果除以1.0的作用是是的返回值是浮點數而不是一個分數。
(defun faulty-size-range (x y z)
(let ((biggest (max x y z))
(smallest (min x y z))
(r (/ biggest smallest 1.0)))
(list ’factor ’of r)))> (faulty-size-range 35 87 4)
Error in function SIZE-RANGE:
BIGGEST unassigned variable.
表達式(/ BIGGEST SMALLEST 1.0)問題的原因是它的語法環境中并不包括上面這些變量,因此符號biggest會被解釋為全局變量。

解決問題的方法就是將let替換為let*:
(defun correct-size-range (x y z)
(let* ((biggest (max x y z))
(smallest (min x y z))
(r (/ biggest smallest 1.0)))
(list ’factor ’of r)))
CORRECT-SIZE-RANGE函數顯示的的求值回溯圖顯示就是在同一個語法環境中。

不要被上面的例子誤導認為let就比let好可以全部替代。在一些情況下,樂透是唯一合適的解決方法,但這里我們就不深入了。在文體上來說,可以的話最好是使用let而不是let,因為任何閱讀你代碼的人不需要依賴被創造出來的本地變量的位置。依賴程度小的程序更容易被閱讀。
5.7 副作用會引發bug
最好的情況是盡量避免程序中的副作用,這里有一個random函數的副作用引發的錯誤。假設我們想要一個monitor拋硬幣的函數。大部分時候的會返回head或者tail,但是也有有幾率會返回edge,表示硬幣是剛好立在地面上。我們該怎么做呢?首先選一個從0開始到101(但是不包括101)的隨機數,如果產生的隨機數是0到49,就會返回head,如果結果是51到100,那么就會返回tail。剛好是50的話,就會返回edge。
(defun coin-with-bug ()
(cond ((< (random 101) 50) ’heads)
((> (random 101) 50) ’tails)
((equal (random 101) 50) ’edge)))> (coin-with-bug)
HEADS
> (coin-with-bug)
TAILS
> (coin-with-bug)
TAILS
> (coin-with-bug)
NIL
為什么函數會返回nil?問題在于每一次調用這個函數,都會有三次對表達式(RANDOM 101)的求值。假設第一次求值的結果是65,是的id一個語句返回false。第二次返回35,結果也是false,第三次返回除了50之外的數字,那就還是false,最后cond就會返回nil。
修復這個bug也是很簡單的,使用let函數來保存表達式(RANDOM 101)的值,就只會求值一次然后保存為本地變量,直接測試分類就可以實現了。
(defun fair-coin ()
(let ((toss (random 101)))
(cond ((< toss 50) ’heads)
((> toss 50) ’tails)
(t ’edge))))
小結
如果一個變量不是這個額函數創造那就會被認為是全局變量。本地變量的作用域被現實在創造他們的語句中,例如參數列表中的變量就是被限制在本函數中使用,還有let函數和let函數創造的變量會被認為是他們函數體中的本地變量。全局變量是具名的因為以全局作為作用域,他們不屬于任何函數。
setf函數是給變量賦值的宏函數,或者說改變已經有值的變量的值。副作用就是叫做賦值的功能,正是這個副作用是setf函數的功能所在。 effects.當多個表達式出現在函數體中或者let,let函數體重的時候,最后一個表達式的值將會被返回,其他的表達式只是為了他們的副作用而存在。
本章涉及函數
賦值宏函數:setf
創造本地變量的特殊函數:let,let*
Lisp Toolkit:documentation和apropos
大部分Common Lisp實現都包括了每一個內建函數和變量的在線文檔。訪問這個文檔一個方法是使用documentation函數,返回文檔字符串(documentation string)。
> (documentation ’cons ’function)
"(CONS x y) returns a list with x as the car
and y as the cdr."
> (documentation ’print-length ’variable)
"PRINT-LENGTH determines how many elements to
print on each level of a list. Unlimited if NIL."
程序員不是經常使用documentation函數的,因為有一個更快捷的方法來獲取文檔字符串。在我的機器上,例如,當我把鼠標擋在一個符號上,或者按下control-meta-shift-s的時候,對應的文檔就會有一個彈框出來。
你可以在定義函數的時候帶上文檔字符串,位置的話應該放在參數列表的后面。
(defun average (x y)
"Returns the mean (average value) of its two
inputs."
(/ (+ x y) 2.0))> (documentation ’average ’function)
"Returns the mean (average value) of its two
inputs."
為函數寫文檔字符串是一個好習慣。也會對使用你程序的人有所幫助,在你需要的時候在線文檔總是可以方便獲得的,
另外一個給程序加上文檔的方法是在文件中加上注釋。Lisp程序中的注釋必須有前導分號。加載Lisp程序的時候,只要遇到分號,那么本行之后的所有的內容豆漿杯忽略。注釋的好處是幫助那些測試程序的人;注釋將被Lisp無視而且也不作為在線文檔的一部分。但是他們因為提供了比文檔字符串多得多的信息而十分實用。注釋可能更加精細,例如,解釋函數中一個或兩個更加精細的語句。
按照慣例,Lisp注釋出現在三處。第一處出現在一個語句的右邊,并且是前導一個分號。第二種是出現在一個函數當中,并且獨占一行,前導兩個空格。第三種是出現在函數定義之外,并且前導三個分號。一些Lisp編輯器會基于注釋的前導分號個數自動進行縮進。一耳光如此風格的注釋如下“
;;; Function to compute Einstein’s E = mc2
(defun einstein (m)
(let ((c 300000.0)) ; speed of light in km/sec.
;; E is energy
;; m is mass
(* m c c)))
另一個很有用的文檔函數就是apropos,它的作用是找出所有具有包含你指定的字符串的內建函數。例如,假設你想要找到所有包含字符串total的變量和函數。你可以使用aprops:
> (apropos "TOTAL" "USER")
ARRAY-TOTAL-SIZE (function)
ARRAY-TOTAL-SIZE-LIMIT, constant, value: 134217727
我們看見的是common lisp的內建函數有ARRAY-TOTAL-SIZE和一個內建常量ARRAY-TOTAL-SIZE-LIMIT。(一個常量就是一個值不可以改變的變量,pi就是一個常量。)
apropos的第二個參數名字叫做包名。你應該總是使用字符串“USER”(全部大寫)作為第二個參數。否則lisp就會輸出很多實現中定義的函數,這些函數來自其他包,你可能并不在意那些包是什么。包是common lisp另一個隱藏的特性,在本書我們也不準備討論。
第五章進階話題
5.8 符號和值單元
我們來回顧一下,一個符號是由五個部分組成的。兩個部分我們到現在已經介紹了,就是符號的名字和函數單元。現在介紹第三個組成部分,每一個符號都有的就是值單元部分。他指向的就是這個符號所命名的全局變量。例如,有一個全局變量叫做TOTAL,它的值是12,然后這個TOTAL的內部結構看起來是這樣:

相似的,有個全局變量fish的值是trout,結構看起來就是這樣。

符號T和nil都是求值為自身的,因為他們的值單元是指向自己的,換句話書喲,符號T就是以自己名字命名的全局變量符號T;nil的值也是符號nil。這些符號的內部結構都是有一個循環存在的,

一個符號可以給很多變量命名,所以其中只有一個是全局變量。換言之,只有一個是存在在全局變量的語法環境中的。值單元為那個變量所保留。所有其他變量都必須存在于本地語法環境中,他們的值也是存儲在除了符號值單元的其他地方。Common Lisp不特別制定本地變量的值存儲在哪里。這些細節留給具體實現去處理。
因為字符的組成單元有函數部分和之部分的分別,我們可以有相同名字的函數和變量。如果我們給全局變量CAR賦值Rolls-Royce,符號car看上去就是這樣:

由Common Lisp來決定一個符號是指向函數還是一個變量,這都是基于符號出現的語法環境。如果一個符號出現在列表的第一個元素,那么就會被認為是一個函數名,在其他位置的話就會被認為是變量名。所以(CAR ’(A B C))調用的額是car函數,返回的是A。而(LIST ’A ’NEW CAR) 中的car指向的是i個全局變量產生值(A NEW ROLLS-ROYCE)。
區別全局變量和本地變量
到現在我們已經清楚了,符號并不是變量本身;他們是作為變量的名字存在(或者也作為函數的名字)。準確的說一個符號的指向是根據符號的出現的位置決定的。在下面的例子中,有兩個叫做x的變量,全局變量x的值是57,而函數newvar的變量x被賦予甚么值都沒有關系。
(setf x 57)
(defun newvar (x)
(list ’value ’of ’x ’is x))> x
57
> (newvar ’whoopie)
(VALUE OF X IS WHOOPIE)
> x
57
在newvar函數內部,名字x是指向本地變量x,這個變量是由函數創造的并且賦值whoopie。在函數外部,x指向的是全局變量x,它的值是57,符號x的值單元一直是指向57的。函數newvar的本地變量存儲在其他地方。求值回溯圖展現了兩個X之間的關系。
全局變量的值是57

全局變量的值還是57
對符號x的求值規則是在newvar函數的函數體中,從內想挖的在語法環境中尋找,是不是有這樣一個名字的變量。當前環境中的變量有這么一個,被賦值whoopie,eval就不會去指向全局變量x。
真實的規則是要比這個復雜一點。eval函數性當前語法環境向外尋找的時候只有遇到語法環境結束或者找打該變量才會停止。在語法環境結束的情況下,是不可以向更外層尋找的;只能指向全局變量。下面的例子說明的很清楚:
(setf a 100)
(defun f (a)
(list a (g (+ a 1))))
(defun g (b)
(list a b))> (f 3)
(3 (100 4))
在這個例子中,我們創造了一個全局變量叫做A,賦值100.當調用F的時候,他創造了本地變量A賦值3。然后在調用函數g,g的語法環境是獨立于f的。(每一個由defun定義的函數都有獨立的而語法環境)。在求值回溯圖中,粗線指示出了g的邊界,在f中創造的變量,在g中是不能訪問的。所以在g的函數體中,沒有具名變量A,eval就忽視了語法環境的邊界指向了全局變量A。

5.10 綁定,作用域和賦值
因為Common Lisp是從更加古老的方言進化而來的,所以在術語上所繼承的一些東西就不是很合適。本書致力于使用一套正確的不容易混淆的術語體系,但是為了和其他的書籍保持一致,也為了龐大的Lisp社區,我來化一節的篇幅來辨清術語綁定(binding)的誤用。
由于某些歷史原因,變量具有一個值被稱作被綁定,變量沒有值的情況被稱作未綁定。我們在書中所說的未賦值錯誤,相同意思的錯誤信息,在大部分lisp實現中被稱為未綁定變量。
創造一個新變量并且給他賦值的過程被稱作綁定。假如變量是出現在參數列表中,被稱作lambda綁定。如果是出現在let或者let*的語句的變量列表中,被稱作let綁定。
但是舊時候的lisp程序員是自己陷入到術語邏輯陷阱中,當他們討論變量綁定的時候,其實并不是真的在說有語法作用域的lisp。當變量的語法作用與出錯,Common Lisp會提供另一種作用域規則,叫做動態作用域,我們會在地十四章接觸到。動態作用域是大部分早期lisp方言的標準配置,除了Scheme和T之外。對于動態作用域來說,“綁定”并不是一定就是有一個值,因為一個變量被綁定了但是沒有值也是可能的。
在后續章節的中,老的lisp程序員可能會對函數f和g這么表達,符號a被函數f綁定到3上在Common Lisp中這樣的表述是不恰當的。符號從未被綁定。只有變量被綁定。還有,沒有唯一的叫做a的變量,而是有兩個。即使是F的本地變量a存在時候,全局變量a也可以被g在f函數體的語法環境之外引用。這樣合適的措辭才Common Lisp中就可以這樣說,F函數將本地變量a綁定到3。