實用Common Lisp程式設計——函式

turingbooks發表於2011-09-22

  有了語法和語義規則以後,所有Lisp程式的三個最基本組成部分就是函式、變數和巨集。在第3章裡構建資料庫時,這三個元件已經全部用到了,但是我沒有詳細提及它們是如何工作的,如何更好使用它們。接下來的幾章將專門講解這三個主題,先從函式開始。就跟其他語言裡一樣,函式提供了用於抽象和功能化的基本方法。

  Lisp本身是由大量函式組成的。其語言標準中有超過四分之三的名字用於定義函式。所有內建的資料型別純粹是用操作它們的函式來定義的。甚至連Lisp強大的物件系統也是構建在函式的概念性擴充套件——廣義函式(generic function)之上的,第16章將會介紹它們。

  而且,儘管巨集對於Lisp風格有著重要的作用,但最終所有實際的功能還是由函式來提供的。巨集執行在編譯期,因此它們生成的程式碼,即當所有巨集被展開後將實際構成程式的那些程式碼,將完全由對函式和特殊操作符的呼叫所構成。更不用說,巨集本身也是函式了——儘管這種函式是用來生成程式碼,而不是用來完成實際的程式操作的。

  5.1 定義新函式

  函式一般使用DEFUN巨集來定義。DEFUN的基本結構看起來像這樣:

  (defun name (parameter*)

   "Optional documentation string."

   body-form*)

  任何符號都可用作函式名。 通常函式名僅包含字典字元和連字元,但是在特定的命名約定裡,其他字元也允許使用。例如,將值的一種型別轉換成另一種的函式有時會在名字中使用→,一個將字串轉換成微件(widget)的函式可能叫做string->widget。最重要的一個命名約定是在第2章裡提到的那個,即要用連字元而不是下劃線或內部大寫來構造複合名稱。因此,frob-widget比frob_widget或frobWidget更具有Lisp風格。一個函式的形參列表定義了一些變數,將用來儲存函式在呼叫時所傳遞的實參。 如果函式不帶有實參,則該列表就是空的,寫成()。不同種類的形參分別負責處理必要的、可選的、多重的以及關鍵字實參。我將在下一節裡討論相關細節。

  如果一個字串緊跟在形參列表之後,那麼它應該是一個用來描述函式用途的文件字串。當定義函式時,該文件字串將被關聯到函式名上,並且以後可以通過DOCUMENTATION函式來獲取。

  最後,一個DEFUN的主體可由任意數量的Lisp表示式所構成。它們將在函式被呼叫時依次求值,而最後一個表示式的值將被作為整個函式的值返回。另外RETURN-FROM特殊操作符可用於從函式的任何位置立即返回,我很快就會談到它。

  第2章裡所寫的hello-world函式,形式如下:

  (defun hello-world () (format t "hello, world"))

  現在可以分析一下該程式的各個部分了。它的名字是hello-world,形參列表為空,因此不接受任何引數,它沒有文件字串,並且它的函式體由一個表示式所構成:

  (format t "hello, world")

  下面是一個更復雜一些的函式:

  (defun verbose-sum (x y)

   "Sum any two numbers after printing a message."

   (format t "Summing ~d and ~d.~%" x y)

   (+ x y))

  這個函式稱為verbose-sum,它接受的兩個實參分別與形參x和y一一對應並且帶有一個文件字串,以及一個由兩個表示式所組成的主體。由“+”呼叫所返回的值將成為verbose-sum的返回值。

  5.2 函式形參列表

  關於函式名或文件字串就沒有更多可說的了,而本書其餘部分將用很多篇幅來描述所有可在一個函式體裡做的事情,因此就只需討論形參列表了。

  很明顯,一個形參列表的基本用途是為了宣告一些變數,用來接收傳遞給函式的實參。當形參列表是一個由變數名所組成的簡單列表時,如同在verbose-sum裡那樣,這些形參被稱為必要形參。當函式被呼叫時,必須為它的每一個必要形參都提供一個實參。每一個形參被繫結到對應的實參上。如果一個函式以過少或過多的實參來呼叫的話,Lisp就會報錯。

  但是,Common Lisp的形參列表也給了你更靈活的方式將函式呼叫實參對映到函式形參。除了必要形參以外,一個函式還可以有可選形參,或者也可以用單一形參繫結到含有任意多個額外引數的列表上。最後,引數還可以通過關鍵字而不是位置來對映到形參上。這樣,Common Lisp的形參列表對於幾種常見的編碼問題提供了一種便利的解決方案。

  5.3 可選形參

  雖然許多像verbose-sum這樣的函式只有必要形參,但並非所有函式都如此簡單。有時一個函式將帶有一個只有特定呼叫者才會關心的形參,這可能是因為它有一個合理的預設值。例如一個可以建立按需增長的資料結構的函式。由於資料結構可以增長,那麼從正確性角度來說,它的初始尺寸就無關緊要了。那些清楚知道自己打算在資料結構中放置多少個元素的呼叫者們,可以通過設定特定的初始尺寸來改進其程式的效能,而多數呼叫者只需讓實現資料結構的程式碼自行選擇一個好的通用值就可以了。在Common Lisp中,你可以使用可選形參,從而使兩類呼叫者都滿意。不在意的呼叫者們將得到一個合理的預設值,而其他呼叫者們有機會提供一個指定的值。

  為了定義一個帶有可選形參的函式,在必要形參的名字之後放置符號&optional,後接可選形參的名字。下面就是一個簡單的例子:

  (defun foo (a b &optional c d) (list a b c d))

  當該函式被呼叫時,實參被首先繫結到必要形參上。在所有必要形參都被賦值以後,如果還有任何實參剩餘,它們的值將被賦給可選形參。如果實參在所有可選形參被賦值之前用完了,那麼其餘的可選形參將自動繫結到值NIL上。這樣,前面定義的函式會給出下面的結果:

  (foo 1 2) → (1 2 NIL NIL)

  (foo 1 2 3) → (1 2 3 NIL)

  (foo 1 2 3 4) → (1 2 3 4)

  Lisp仍然可以確保適當數量的實參被傳遞給函式——在本例中是2到4個。而如果函式用太少或太多的引數來呼叫的話,將會報錯。

  當然,你會經常想要一個不同於NIL的預設值。這時可以通過將形參名替換成一個含有名字跟一個表示式的列表來指定該預設值。只有在呼叫者沒有傳遞足夠的實參來為可選形參提供值的時候,這個表示式才會被求值。通常情況只是簡單地提供一個值作為表示式:

  (defun foo (a &optional (b 10)) (list a b))

  上述函式要求將一個實參繫結到形參a上。當存在第二個實參時,第二個形參b將使用其值,否則使用10。

  (foo 1 2) → (1 2)

  (foo 1) → (1 10)

  不過有時可能需要更靈活地選擇預設值。比如可能想要基於其他形參來計算預設值。預設值表示式可以引用早先出現在形參列表中的形參。如果要編寫一個返回矩形的某種表示的函式,並且想要使它可以特別方便地產生正方形,那麼可以使用一個像這樣的形參列表:

  (defun make-rectangle (width &optional (height width)) ...)

  除非明確指定否則這將導致height形參帶有和width形參相同的值。

  有時,有必要去了解一個可選形參的值究竟是被呼叫者明確指定還是使用了預設值。除了通過程式碼來檢查形參的值是否為預設值(假如呼叫者碰巧顯式傳遞了預設值,那麼這樣做終歸是無效的)以外,你還可以通過在形參識別符號的預設值表示式之後新增另一個變數名來做到這點。該變數將在呼叫者實際為該形參提供了一個實參時被繫結到真值,否則為NIL。通常約定,這種變數的名字與對應的真實形參相同,但是帶有一個-supplied-p字尾。例如:

  (defun foo (a b &optional (c 3 c-supplied-p))

   (list a b c c-supplied-p))

  這將給出類似下面的結果:

  (foo 1 2) → (1 2 3 NIL)

  (foo 1 2 3) → (1 2 3 T)

  (foo 1 2 4) → (1 2 4 T)

  5.4 剩餘形參

  可選形參僅適用於一些較為分散並且不能確定呼叫者是否會提供值的形參。但某些函式需要接收可變數量的實參,比如說前文已然出現過的一些內建函式。FORMAT有兩個必要實參,即流和控制串。但在這兩個之後,它還需要一組可變數量的實參,這取決於控制串需要插入多少個值。+函式也接受可變數量的實參——沒有特別的理由限制它只能在兩個數之間相加,它可以對任意數量的值做加法運算(它甚至可以沒有實參,此時返回0——加法的底數)。下面這些都是這兩個函式的合法呼叫:

  (format t "hello, world")

  (format t "hello, ~a" name)

  (format t "x: ~d y: ~d" x y)

  (+)

  (+ 1)

  (+ 1 2)

  (+ 1 2 3)

  很明顯,也可以通過簡單地給它一些可選形參來寫出接受可變數量實參的函式,但這樣將會非常麻煩,光是寫形參列表就已經足夠麻煩了,何況還要在函式體中處理所有這些形參。為了做好這件事,還不得不使用一個合法函式呼叫所能夠傳遞的那麼多的可選形參。這一具體數量與具體實現相關,但可以保證至少有50個。在當前所有實現中,它的最大值範圍從4096到536 870 911。 汗,這種絞盡腦汁的無聊事情絕對不是Lisp風格。

  相反,Lisp允許在符號&rest之後包括一攬子形參。如果函式帶有&rest形參,那麼任何滿足了必要和可選形參之後的其餘所有實參就將被收集到一個列表裡成為該&rest形參的值。這樣,FORMAT和+的形參列表可能看起來會是這樣:

  (defun format (stream string &rest values) ...)

  (defun + (&rest numbers) ...)

  5.5 關鍵字形參

  儘管可選形參和剩餘形參帶來了很大的靈活性,但兩者都不能幫助應對下面的情形。假設有一個接受四個可選形參的函式,如果在多數的函式呼叫中,呼叫者只想為四個引數中的一個提供值,並且更進一步,不同的呼叫者甚至有可能將分別選擇使用其中一個引數。

  想為第一個形參提供值的呼叫者將會很方便——只需傳遞一個可選實參,然後忽略其他就好了。但是所有其他的呼叫者將不得不為所不關心的一到三個形參傳遞一些值。這不正是可選形參想來解決的問題嗎?

  當然是。問題在於可選形參仍然是位置相關的——如果呼叫者想要給第四個可選形參傳遞一個顯式的值,就會導致前三個可選形參對於該呼叫者來說變成了必要形參。幸好我們有另一種形參型別,關鍵字形參,它可以允許呼叫者指定具體形參相應所使用的值。

  為了使函式帶有關鍵字形參,在任何必要的&optional和&rest形參之後,可以加上符號&key以及任意數量的關鍵字形參識別符號,後者的格式類似於可選形參識別符號。下面就是一個只有關鍵字形參的函式:

  (defun foo (&key a b c) (list a b c))

  當呼叫這個函式時,每一個關鍵字形參將被繫結到緊跟在同名鍵字後面的那個值上。如第4章所述,關鍵字是以冒號開始的名字,並且它們被自動定義為自求值常量。

  如果一個給定的關鍵字沒有出現在實參列表中,那麼對應的形參將被賦予其預設值,如同可選形參那樣。因為關鍵字實參帶有標籤,所以它們在必要實參之後可按任意順序進行傳遞。例如foo可以用下列形式呼叫:

  (foo) → (NIL NIL NIL)

  (foo :a 1) → (1 NIL NIL)

  (foo :b 1) → (NIL 1 NIL)

  (foo :c 1) → (NIL NIL 1)

  (foo :a 1 :c 3) → (1 NIL 3)

  (foo :a 1 :b 2 :c 3) → (1 2 3)

  (foo :a 1 :c 3 :b 2) → (1 2 3)

  如同可選形參那樣,關鍵字形參也可以提供一個預設值形式以及一個supplied-p變數名。在關鍵字形參和可選形參中,這個預設值形式都可以引用那些早先出現在形參列表中的形參。

  (defun foo (&key (a 0) (b 0 b-supplied-p) (c (+ a b)))

   (list a b c b-supplied-p))

  (foo :a 1) → (1 0 1 NIL)

  (foo :b 1) → (0 1 1 T)

  (foo :b 1 :c 4) → (0 1 4 T)

  (foo :a 2 :b 1 :c 4) → (2 1 4 T)

  同樣,如果出於某種原因想讓呼叫者用來指定形參的關鍵字不同於實際形參名,那麼可以將形參名替換成一個列表,令其含有呼叫函式時使用的關鍵字以及用作形參的名字。比如說下面這個foo的定義:

  (defun foo (&key ((:apple a)) ((:box b) 0) ((:charlie c) 0 c-supplied-p))

   (list a b c c-supplied-p))

  可以讓呼叫者這樣呼叫它:

  (foo :apple 10 :box 20 :charlie 30) → (10 20 30 T)

  這種風格在想要完全將函式的公共API與其內部細節相隔離時特別有用,通常是因為想要在內部使用短變數名,而不是API中的描述性關鍵字。不過該特性不常被用到。

  5.6 混合不同的形參型別

  在單一函式裡使用所有四種型別形參的情況雖然罕見,但也是可能的。無論何時,當用到多種型別的形參時,它們必須以這樣的順序宣告:首先是必要形參,其次是可選形參,再次是剩餘形參,最後才是關鍵字形參。但在使用多種型別形參的函式中,一般情況是將必要形參和另外一種型別的形參組合使用,或者可能是組合&optional形參和&rest形參。其他兩種組合方式,無論是&optional形參還是&rest形參,當與&key形參組合使用時,都可能導致某種奇怪的行為。

  將&optional形參和&key形參組合使用時將產生非常奇怪的結果,因此也許應該避免將它們一起使用。問題出在如果呼叫者沒有為所有可選形參提供值時,那麼沒有得到值的可選形參將吃掉原本用於關鍵字形參的關鍵字和值。例如,下面這個函式很不明智地混合了&optional形參和&key形參:

  (defun foo (x &optional y &key z) (list x y z))

  如果像這樣呼叫的話,就沒問題:

  (foo 1 2 :z 3) → (1 2 3)

  這樣也可以:

  (foo 1) → (1 nil nil)

  但是這樣的話將報錯:

  (foo 1 :z 3) → ERROR

  這是因為關鍵字:z被作為一個值填入到可選的y形參中了,只留下了引數3被處理。在這裡,Lisp期待一個成對的關鍵字/值,或者什麼也沒有,否則就會報錯。也許更壞的是,如果該函式帶有兩個&optional形參,上面最後一個呼叫將導致值:z和3分別被繫結到兩個&optional形參上,而&key形參z將得到預設值NIL,而不宣告缺失了東西。

  一般而言,如果正在編寫一個同時使用&optional形參和&key形參的函式,可能就應該將它變成全部使用&key形參的形式——它們更靈活,並且總會可以在不破壞該函式的已有呼叫的情況下新增新的關鍵字形參。也可以移除關鍵字形參,只要沒人在使用它們。 一般而言,使用關鍵字形參將會使程式碼相對易於維護和擴充——如果需要為函式新增一些需要用到新引數的新行為,就可以直接新增關鍵字形參,而無需修改甚至重新編譯任何呼叫該函式的已有程式碼。

  雖然可以安全地組合使用&rest形參和&key形參,但其行為初看起來可能會有一點奇怪。正常地來講,無論是&rest還是&key出現在形參列表中,都將導致所有出現在必要形參和&optional形參之後的那些值被特別處理——要麼作為&rest形參被收集到一個形參列表中,要麼基於關鍵字被分配到適當的&key形參中。如果&rest和&key同時出現在形參列表中,那麼兩件事都會發生——所有剩餘的值,包括關鍵字本身,都將被收集到一個列表裡,然後被繫結到&rest形參上;而適當的值,也會同時被繫結到&key形參上。因此,給定下列函式:

  (defun foo (&rest rest &key a b c) (list rest a b c))

  將得到如下結果:

  (foo :a 1 :b 2 :c 3) → ((:A 1 :B 2 :C 3) 1 2 3)

  5.7 函式返回值

  目前寫出的所有函式都使用了預設的返回值行為,即最後一個表示式的值被作為整個函式的返回值。這是從函式中返回值的最常見方式。

  但某些時候,尤其是想要從巢狀的控制結構中脫身時,如果有辦法從函式中間返回,那將是非常便利的。在這種情況下,你可以使用RETURN-FROM特殊操作符,它能夠立即以任何值從函式中間返回。

  在第20章將會看到,RETURN-FROM事實上不只用於函式,它還可以用來從一個由BLOCK特殊操作符所定義的程式碼塊中返回。不過DEFUN會自動將其整個函式體包裝在一個與其函式同名的程式碼塊中。因此,對一個帶有當前函式名和想要返回的值的RETURN-FROM進行求值將導致函式立即以該值退出。RETURN-FROM是一個特殊操作符,其第一個“引數”是它想要返回的程式碼塊名。該名字不被求值,因此無需引用。

  下面這個函式使用了巢狀迴圈來發現第一個數對——每個都小於10,並且其乘積大於函式的引數,它使用RETURN-FROM在發現之後立即返回該數對:

  (defun foo (n)

   (dotimes (i 10)

   (dotimes (j 10)

   (when (> (* i j) n)

   (return-from foo (list i j))))))

  必須承認的是,不得不指定正在返回的函式名多少會有些不便——比如改變了函式的名字,就需要同時改變RETURN-FROM中所使用的名字。 但在事實上,顯式的RETURN-FROM呼叫在Lisp中出現的頻率遠小於return語句在源自C的語言裡所出現的頻率,因為所有的Lisp表示式,包括諸如迴圈和條件語句這樣的控制結構,都會求值得到一個值。因此在實踐中這不是什麼問題。

  5.8 作為資料的函式——高階函式

  使用函式的主要方式是通過名字來呼叫它們,但有時將函式作為資料看待也是很有用的。例如,可以將一個函式作為引數傳給另一個函式,從而能寫出一個通用的排序函式,允許呼叫者提供一個比較任意兩元素的函式,這樣同樣的底層演算法就可以跟許多不同的比較函式配合使用了。類似地,回撥函式(callback)和鉤子(hook)也需要能夠儲存程式碼引用便於以後執行。由於函式已經是一種對程式碼位元進行抽象的標準方式,因此允許把函式視為資料也是合理的。

  在Lisp中,函式只是另一種型別的物件。在用DEFUN定義一個函式時,實際上做了兩件事:建立一個新的函式物件以及賦予其一個名字。在第3章裡我們看到,也可以使用LAMBDA表示式來建立一個函式而無需為其指定一個名字。一個函式物件的實際表示,無論是有名的還是匿名的,都只是一些二進位制資料——以原生編譯的Lisp形式存在,可能大部分是由機器碼構成。只需要知道如何保持它們以及需要時如何呼叫它們。

  特殊操作符FUNCTION提供了用來獲取一個函式物件的方法。它接受單一實參並返回與該引數同名的函式。這個名字是不被引用的。因此如果一個函式foo的定義如下。

  CL-USER> (defun foo (x) (* 2 x))

  FOO

  就可以得到如下的函式物件。

  CL-USER> (function foo)

  #

  事實上,你已經用過FUNCTION了,但它是以偽裝的形式出現的。第3章裡用到的#'語法就是FUNCTION的語法糖,正如“'”是QUOTE的語法糖一樣。 因此也可以像這樣得到foo的函式物件。

  CL-USER> #'foo

  #

  一旦得到了函式物件,就只剩下一件事可做了——呼叫它。Common Lisp提供了兩個函式用來通過函式物件呼叫函式:FUNCALL和APPLY。 它們的區別僅在於如何獲取傳遞給函式的實參。

  FUNCALL用於在編寫程式碼時確切知道傳遞給函式多少實參時。FUNCALL的第一個實參是被呼叫的函式物件,其餘的實參被傳遞到該函式中。因此,下面兩個表示式是等價的:

  (foo 1 2 3)  (funcall #'foo 1 2 3)

  不過,用FUNCALL來呼叫一個寫程式碼時名字已知的函式毫無意義。事實上,前面的兩個表示式將很可能被編譯成相同的機器指令。

  下面這個函式演示了FUNCALL的另一個更有建設性的用法。它接受一個函式物件作為實參,並使用實參函式在min和max之間以step為步長的返回值來繪製一個簡單的ASCII式柱狀圖:

  (defun plot (fn min max step)

   (loop for i from min to max by step do

   (loop repeat (funcall fn i) do (format t "*"))

   (format t "~%")))

  FUNCALL表示式在每個i值上計算函式的值。內層LOOP迴圈使用計算得到的值來決定向標準輸出列印多少星號。

  請注意,不需要使用FUNCTION或#'來得到fn的函式值。因為它是作為函式物件的變數的值,所以你需要它被解釋成一個變數。可以用任何接受單一數值實參的函式來呼叫plot,例如內建的函式EXP,它返回以e為底以其實參為指數的值。

  CL-USER> (plot #'exp 0 4 1/2)

  *

  *

  **

  ****

  *

  ********

  ****************

  *****************************

  **************************************************

  NIL

  然而,當實參列表只在執行期已知時,FUNCALL的表現不佳。例如,為了再次呼叫plot函式,假設你已有一個列表,其包括一個函式物件、一個最小值和一個最大值以及一個步長。換句話說,這個列表包含了你想要作為實參傳給plot的所有的值。假設這個列表儲存在變數plot-data中,可以像這樣用列表中的值來呼叫plot:

  (plot (first plot-data) (second plot-data) (third plot-data) (fourth plot-data))

  這樣固然可以,但僅僅為了將實參傳給plot而顯式地將其解開,看起來相當討厭。

  這就是需要APPLY的原因。和FUNCALL一樣,APPLY的第一個引數是一個函式物件。但在這個函式物件之後,它期待一個列表而非單獨的實參。它將函式應用在列表中的值上,這就使你可以寫出下面的替代版本:

  (apply #'plot plot-data)

  更方便的是,APPLY還接受“孤立”(loose)的實參,只要最後一個引數是個列表。因此,假如plot-data只含有最小、最大和步長值,那麼你仍然可以像這樣來使用APPLY在該範圍上繪製EXP函式:

  (apply #'plot #'exp plot-data)

  APPLY並不關心所用的函式是否接受&optional、&rest或&key實參——由任何孤立實參和最後的列表所組合而成的實參列表必定是一個合法的實參列表,其對於該函式來說帶有足夠的實參用於所有必要形參和適當的關鍵字形參。

  5.9 匿名函式

  一旦開始編寫或只是使用那些可以接受其他函式作為實參的函式,你就必然發現,有時不得不去定義和命名一個僅使用一次的函式,尤其是你可能從不用名字來呼叫它時,這會讓人相當惱火。

  覺得沒必要用DEFUN來定義一個新函式時,可以使用一個LAMBDA表示式建立匿名的函式。第3章裡討論過,一個LAMBDA表示式形式如下:

  (lambda (parameters) body)

  可以將LAMBDA表示式視為一種特殊型別的函式名,其名字本身直接描述函式的用途。這就解釋了為什麼可以使用一個帶有#'的LAMBDA表示式來代替一個函式名。

  (funcall #'(lambda (x y) (+ x y)) 2 3) → 5

  甚至還可以在一個函式呼叫表示式中將LAMBDA表示式用作函式名。由此一來,我們可以在需要時以更簡潔方式來書寫前面的FUNCALL表示式如下:

  ((lambda (x y) (+ x y)) 2 3) → 5

  但幾乎沒人這樣做。它唯一的用途是來強調將LAMBDA表示式用在任何一個正常函式名可以出現的場合都是合法的。

  在需要傳遞一個作為引數的函式給另一個函式,並且需要傳遞的這個函式簡單到可以內聯表達時,匿名函式特別有用。例如,假設想要繪製函式2x,你可以定義下面的函式:

  (defun double (x) (* 2 x))

  並隨後將其傳給plot:

  CL-USER> (plot #'double 0 10 1)

  **

  ****

  **

  ****

  ******

  ********

  **********

  ************

  **************

  ****************

  NIL

  但如果寫成這樣將會更簡單和清晰:

  CL-USER> (plot #'(lambda (x) (* 2 x)) 0 10 1)

  **

  ****

  **

  ****

  ******

  ********

  **********

  ************

  **************

  ****************

  NIL

  LAMBDA表示式的另一項重要用途是製作閉包(closure),即捕捉了其建立時環境資訊的函式。你在第3章裡使用了一點兒閉包,但要深入瞭解閉包的工作原理及其用途,更多的還是要從變數而非函式的角度去考察,因此我將在下一章裡討論它們。

enter image description here

相關文章