《實用Common Lisp編程》第16-17章,面向對象細節補遺(2):廣義函數與繼承

上一節,我們測試了廣義函數的三個主要的輔助函數 :around,:before 和 :after 的行爲。

 

這次,我們來看看,廣義函數在繼承關係中的行爲,以及特化對象與多重函數等。

 

廣義函數與繼承

 

從書中,我們知道,common lisp和其他常見的 oop 最大的不同是,common lisp的多態行爲是用廣義函數而不是常見的對象方法來實現的。

 

對一個廣義函數來說,不同的對象可以通過對象實例進行特化,並分別實現這個廣義函數,因此,不同的對象就此擁有了函數名相同但行爲不同的對象。

 

另一方面,子類的廣義函數實現,可以通過 call-next-method ,來調用父類的同名廣義函數,從而實現一種“串聯”的效果。

 

並且,方法是按照特殊程度排列的,越特殊(或者說,與調用對象越相似或相等)的方法越先被找到,而越泛化的方法越遲被找到。

 

總的來說,一個類自身的方法總是最先被找到,然後是父類的同名方法,父類的父類的同名方法,等等。

 

如果一個方法特化了一個以上的對象,我們稱之爲多重方法,這種方法的匹配更復雜一點,它按方法參數從左到右,以類似於單對象示例的方法匹配。

 

最後,如果一個對象有多個父類,那麼按照繼承列表從左到右開始,越左邊的同名方法越特殊。

 

嗯,規則大概就是這樣,如果文字描述讓你有點頭暈,我建議你還是先看看實例(其實我也一樣。。。)。

 

 

類實現未定義的廣義函數

 

書中說,我們可以不用 defgeneric 定義廣義函數,而直接創建一個方法,那麼廣義函數的定義會被自動創建,我們這就來驗證一下:

 

(defclass person ()
    ()) 

(defmethod greet ((people person))
    (format t "greet ~%"))
  
測試:

(greet (make-instance 'person))

greet 

NIL


嗯,沒有報錯,看來真的可以。


子類調用父類的方法

這次,我們來試試,當子類調用一個自己不存在的方法,而其父類擁有該方法的時候,會發生什麼事情。

如果一切正常的話,父類的方法應該會被調用。

(defclass animal ()
    ())

(defmethod greet ((people animal))
    (format t "animal's greet method ~%"))

(defclass person (animal)
    ())
 
測試:

(greet (make-instance 'person))
animal's greet method 
NIL

嗯,的確是父類的方法被調用了。


父類調用子類擁有的方法

現在,我們將之前的關係掉轉,如果一個父類沒有方法 greet ,而子類有的話,會發生什麼事?

嗯。。。我猜會出現一個錯誤,因爲類型從來都是向上轉而不是向下轉的,但是我還是想試一試:

(defclass animal ()
    ())

(defclass person (animal)
    ())
    
(defmethod greet ((people person))
    (format t "person's greet method ~%"))
 
測試:

(greet (make-instance 'animal))

*** - NO-APPLICABLE-METHOD: When calling #<STANDARD-GENERIC-FUNCTION GREET>
      with arguments (#<ANIMAL #x21B1854E>), no method is applicable.
The following restarts are available:
RETRY          :R1      try calling GREET again
RETURN         :R2      specify return values
ABORT          :R3      Abort main loop

噢噢,果然如我所料,沒有可應用的方法。

注意上面的代碼和之前的代碼的細微區別:第一,我們將 greet 改成了 person 類的特化方法。另外,我們在測試的時候,生成的是一個 animal 實例,而不是之前常用的 person 實例。


子類和父類擁有同名方法

嗯,探險完畢,我們現在來做些正常點的事情。

當子類和父類擁有同名方法的時候,如果子類調用方法,那麼子類自己的方法就比父類的同名方法更特殊,因此,子類自己的方法就會被調用。

按理來說是這樣,嗯,讓我們實際試試:

(defclass animal ()
    ())
    
(defmethod greet ((people animal))
    (format t "animal's greet method ~%"))

(defclass person (animal)
    ())
    
(defmethod greet ((people person))
    (format t "person's greet method ~%"))

測試:
 
(greet (make-instance 'person))
person's greet method 
NIL

嗯,再來試試調用父類:

(greet (make-instance 'animal))
animal's greet method 
NIL

一切正常,我們可以看見,父子兩個類中的 greet 方法互不影響。


子類方法通過調用call-next-method調用父類的同名方法

在上面,我們看到父子兩個類擁有分別調用的 greet 方法,它們互不相關。

但是,當上面的情況出現時,也即是說,父類和子類擁有同名的方法,那麼這時,我們可以在子類的同名方法中,通過調用 call-next-method 方法,來調用父類的同名方法,達到組合兩個方法來使用的效果。

(defclass animal ()
    ())
    
(defmethod greet ((people animal))
    (format t "animal's greet method ~%"))

(defclass person (animal)
    ())
    
(defmethod greet ((people person))
    (format t "person's greet method ~%")
    (call-next-method)) ; call animal's greet method
 
試試:

(greet (make-instance 'person))
person's greet method 
animal's greet method 
NIL

從上面的測試可以看到,person 先調用自己的 greet 方法,然後通過調用 call-next-method 方法,將執行權轉給了父類的 greet 方法,從而調用了父類的 greet 方法。

call-next-method 並沒有限制我們只能調用兩個 greet 方法,換言之,如果 animal 還有一個父類,這個父類還有一個 greet 方法,我們同樣可以在 animal 的 greet 方法中通過 call-next-method 來調用這個 greet 方法,以此類推。

另外,子類的 greet 方法的 call-next-method 並不會影響到父類的行爲:

(greet (make-instance 'animal))
animal's greet method 
NIL

可以看到,animal 類的 greet 方法的行爲沒有改變。


兩個廣義函數的實現擁有不同的參數

書上說,每個廣義函數的實現,也即是,同名的方法,只能有相同的參數,我們這就來試試它是不是真的。

我定義一個廣義函數 greet ,然後定義兩個方法,它們一個不接收除對象實例外的其他參數,另一個則接受一個 name 參數:

(defclass a ()
    ())
    
(defmethod greet ((obj a))
    (format t "a's greet ~%"))
    
(defclass b ()
    ())
    
(defmethod greet ((obj b) name)
    (format t "b's greet call by ~a ~%" name))
 
測試:

(load "t")
;; Loading file /tmp/t.lisp ...
*** - #<STANDARD-METHOD (#<STANDARD-CLASS B> #<BUILT-IN-CLASS T>)> has 2, but
      #<STANDARD-GENERIC-FUNCTION GREET> has 1 required parameter
The following restarts are available:
SKIP           :R1      skip (DEFMETHOD GREET # ...)
RETRY          :R2      retry (DEFMETHOD GREET # ...)
STOP           :R3      stop loading file /tmp/t.lisp
ABORT          :R4      Abort main loop

噢噢,當我試圖將文件載入解釋器的時候,lisp就跟我抱怨起來了,看來果然不能用參數不同的同名方法阿。


使用 &key 、 &optional 、 &rest 使方法支持不同參數

看了上面的示例,你肯定有點傷心,因爲如果方法不能支持不同參數的話,那麼連 JAVA 的那種根據參數進行重載方法的小把戲我們在強大 common lisp 中居然就不可以做了。

嗯,而實際上,根據書本的第五章,我們可以在廣義函數中使用 關鍵字方法 &key 、可選方法 &optional 或者 不定長方法 &rest ,來達到同名方法使用不同參數的效果。

(defgeneric greet (obj &key))

(defclass a ()
    ())
    
(defmethod greet ((obj a) &key)
    (format t "a's greet ~%"))
    
(defclass b ()
    ())
    
(defmethod greet ((obj b) &key name)
    (format t "b's greet call by ~a ~%" name))
 
這個定義有點兒複雜,讓我來解釋一下。

首先,我定義了一個廣義函數 greet,它接受一個 obj 參數,以及一個關鍵字參數列表,但我沒有指名關鍵字參數列表裏面的關鍵字。

然後,在 a 類的 greet 方法中,我同樣在定義 greet 方法中放置了一個空的關鍵字列表 &key ,我只是聲明一個關鍵字列表,但沒有指定任何關鍵字,也即是,實際上,這個 greet 方法只接受 obj 一個參數。

最後,在 b 類的 greet 方法中,我定義了特化成 b 示例的 obj 參數,以及,一個名字爲 name 的關鍵字參數,也即是說,這個 greet 方法可以接受兩個參數,一個 obj,一個 name。

嗯,解釋得差不多了,是時候測試一下了:

(greet (make-instance 'a))
a's greet 
NIL

我先試了類 a 的 greet ,它如我們意料之中所想的那樣,只接受一個參數就可運行。

好,接下來,我們試試類 b  的 greet 方法,如果一切正常的話,它應該接受兩個參數,並且第二個參數要聲明爲 :name :

(greet (make-instance 'b) :name "huangz")
b's greet call by huangz 
NIL

嗯,看上去不錯,這樣一來,我們就成功地讓同一個廣義函數的不同方法支持不同的參數了。

最後,值得一提的是,不單單是關鍵字參數,我們還可以通過可選參數 &optional 和 不定量參數 &rest ,來達到讓同名方法支持不同數量參數的目的。


多重方法

到目前爲止,我們所有的方法都是特例化單個類實例來實現的,而實際上,特例化的類實例的數量並沒有限制。

而特例化多於一個類實例的方法,稱之爲多重方法,這個特性非常之酷,讓我們免去了寫對象分派器的功夫,我們這就來試試:

(defgeneric greet (one two))

(defclass a () ())
(defclass b () ())
(defclass c () ())
(defclass d () ())

(defmethod greet ((one a) (two b))
    (format t "hello a and b ~%"))

(defmethod greet ((one c) (two d))
    (format t "halo c N d ~%"))
 
上面的代碼裏,我們定義了一個廣義函數 greet ,它接受兩個參數 one 和 two。

接着,我們定義了 a、b、c、d四個方法(如果你喜歡的話,也可以把它們想做東南西北、或者梅蘭竹菊什麼的。。。)

然後,我們定義了兩個 greet 方法,這兩個 greet 方法,根據接受的對象的示例的不同,它們產生的結果也不同。

對於第一個 greet 方法,它接受一個 a 類的對象和 b 類的對象,並打印一個普通的英語問候對白:

(greet (make-instance 'a) (make-instance 'b))
hello a and b 
NIL

而第二個 greet 方法,則接受 c 類的對象和 d 類的對象,並打印一個有搖滾風格的問候對白(嗯,我知道,這根搖滾的關係似乎不大。。。):

(greet (make-instance 'c) (make-instance 'd))
halo c N d 
NIL

嗯,兩個方法都運行得很好,這時一個疑問可能出現在你的腦海中,如果我用一個 a 對象 和 c 對象作爲參數調用 greet 方法,結果會怎麼樣?

這就來試試:

(greet (make-instance 'a) (make-instance 'c))

*** - NO-APPLICABLE-METHOD: When calling #<STANDARD-GENERIC-FUNCTION GREET>
      with arguments (#<A #x21DA86DE> #<C #x21DA86EE>), no method is
      applicable.
The following restarts are available:
RETRY          :R1      try calling GREET again
RETURN         :R2      specify return values
ABORT          :R3      Abort main loop

噢,一個錯誤產生了,實際上,這不太算一個嚴重的錯誤,它出錯的原因是我們沒有定義符合 a 類對象和 c 類對象使用的 greet 方法。

再進一步將,你可以爲任何類對象的組合定義不同的方法,比如這裏的四個類 a、b、c、d 就一共有二的四次方(2^4)種不同的 greet 方法可供定義,如果你有五個類,就是 2^5 ,如果你有六個,就是 2^6 。。。


多繼承方法

另一個到目前爲止,我們的方法都是基於單個父類進行的,如果說,一個子類有多個同名方法,那麼將產生怎麼結果。

按照書上的說法,多個父類的同名方法的特殊程度按照它們被子類繼承的順序,從左到右,越靠左邊的越先被找到。

也即是,如果我們用一個類 a ,它繼承了 b 和 c 方法,定義如下:

(defclass a (b c)
    ())
 
那麼當類 a 的實例尋找一個不存在於類 a 中的方法時,它會先查找 b,再查找 c。

嗯,規則就是這樣,我們來詳細試試:

(defgeneric greet (people))

(defclass b ()
    ())

(defmethod greet ((people b))
    (format t "b's greet ~%"))
    
(defclass c ()
    ())
    
(defmethod greet ((people c))
    (format t "c's greet ~%"))
    
(defclass a (b c)
    ())
 
運行試試:

(greet (make-instance 'a))
b's greet 
NIL

如我們所料,b 類的 greet 方法被調用了,因爲它在 a 繼承列表的左邊,先於類 c ,因此它的 greet 方法先於類 c 的 greet 方法被找到。


基於EQL特化符來特化一個特定於某個對象的方法

最後一個到目前爲止,我們的方法的特化都是基於類進行的,也就是,對整個類的所有對象實例來說,都產生相同的行爲。

比如在以下程序中,無論你怎麼調用 greet 方法,它總是單調地打印出同一句話:

(defgeneric greet (people))

(defclass person ()
    ())
    
(defmethod greet ((people person))
    (format t "hello someone"))
 
它的執行結果如下:

[2]> (greet (make-instance 'person))
hello someone
NIL
[3]> (greet (make-instance 'person))
hello someone
NIL
[4]> (greet (make-instance 'person))
hello someone
NIL

嗯,這個程序實在太乏味了,就像網上那些弱智的人工智能測試一樣可笑——那些呆呆的機器人,無論你重複問它多少遍 hello ,它都只回復你同一句話,唉。

而實際上,你可以根據一個EQL特化符,來讓某個方法對一個特定的對象產生特殊的行爲:

(defgeneric greet (people))

(defclass person ()
    ())
    
(defmethod greet ((people person))
    (format t "hello someone"))
    
(defvar *huangz* :huangz)

(defmethod greet ((people (eql *huangz*)))
    (format t "hello, huangz!"))
    
(defvar *admin* (make-instance 'person))

(defmethod greet ((people (eql *admin*)))
    (format t "welcome back, master!"))
 
注意看代碼,我們定義了三個 greet 方法,兩個全局變量。

第一個 greet 爲所有不屬於 *huangz* 和 *admin* 的其他 person 類的實例服務,作出一般迴應 “hello someone" 。

(greet (make-instance 'person))
hello someone
NIL

而第二個 greet 方法,則使用 eql 操作符特例化了全局變量 *huangz* ,當我用 *huangz* 變量作爲參數傳給 greet 方法的時候,它會打印一條熱情的信息給我:

(greet *huangz*)
hello, huangz!
NIL

而第三個 greet 方法,則更科幻一點,當它遇到全局變量 *admin* 的時候,它會打印一條相當親切而忠心的信息:

(greet *admin*)
welcome back, master!
NIL

還有兩點(哦不,三點)細節要注意:

首先,eql 特化符使用的對象可以是任何對象的實例(也即是,任何類型),比如  *huangz* 就是一個關鍵字符號,而 *admin* 則是一個 person 對象的實例。

其次,你看到,要使用 eql 特化符特化對象,被特化的對象必須先被定義出來。

最後,eql 特化符和類特化符可以配合使用,比如你可以拓展爲 huangz 特化的 greet 方法的參數,讓他多接受一個 weather 參數,當下雨的時候,就對 huangz 打印下雨的消息,而當天氣晴朗的時候,就對huangz 打印天氣晴朗的消息,諸如此類。

這種組合特化符的特性非常強大,你可以慢慢研究,只要你有多幾個參數,你就可以搗鼓出寫不完那麼多的特化方法出來(還記得2^n嗎)。。。。

小結

嗯,這一章,我們詳細實驗了 common lisp 中關於廣義函數在繼承情況下的種種表現,並瞭解到特化符操作的強大和蛋疼之處。

下一章,我們再來看看, common lisp 如何在繼承中,處理對象的槽(slot)。






 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章