Write Yourself a Scheme in 48 Hours/Evaluation, Part 2

原文。
https://en.wikibooks.org/wiki/Write_Yourself_a_Scheme_in_48_Hours/Evaluation,_Part_2

更多操作:部分應用

既然現在我們可以來處理類型和參數之類的錯誤了,我們來重新整理下primitive列表并讓它能夠處理一些計算以外的事情。我們會添加一些布爾操作符,條件語句和一些基本的字符串操作。

從給primitives列表添加以下內容開始:

("=", numBoolBinop (==)),
("<", numBoolBinop (<)),
(">", numBoolBinop (>)),
("/=", numBoolBinop (/=)),
(">=", numBoolBinop (>=)),
("<=", numBoolBinop (<=)),
("&&", boolBoolBinop (&&)),
("||", boolBoolBinop (||)),
("string=?", strBoolBinop (==)),
("string<?", strBoolBinop (<)),
("string>?", strBoolBinop (>)),
("string<=?", strBoolBinop (<=)),
("string>=?", strBoolBinop (>=)),

這里會用到一些我們還沒有開始寫的輔助函數:numBoolBinopboolBoolBinopstrBoolBinop。與之前那些讀取一些數字參數并返回一個整型的函數不同,這些函數都會讀取兩個參數并且返回一個布爾值。并且事實上它們僅僅是期望的參數類型不同而已,因此這里我們將邏輯整理成一個通用的boolBinop函數并傳入一個會對參數進行處理的解包函數:

boolBinop :: (LispVal -> ThrowsError a) -> (a -> a -> Bool) -> [LispVal] -> ThrowsError LispVal
boolBinop unpacker op args = if length args /= 2 
                             then throwError $ NumArgs 2 args
                             else do left <- unpacker $ args !! 0
                                      right <- unpacker $ args !! 1
                                      return $ Bool $ left `op` right

由于每個參數都有可能會拋出一個類型不匹配的錯誤,因此我們必須為了Error Monad而在一個do代碼塊中將它們依次分解。然后再將操作符運用在兩個參數上并且將結果用Bool構造器封裝起來。任何一個函數都能夠通過一對反引號將它變成一個中綴操作符。

同時我們也來看下類型簽名。boolBinop函數讀取兩個函數作為它的前兩個參數:第一個用來將參數從LispVal類型解包成原生的Haskell類型,而第二個則是實際進行的操作。通過將部分的行為參數化,代碼的重用性變得更好了。

現在來根據不同情況下的解包函數來通過boolBinop定義三個函數:

numBoolBinop  = boolBinop unpackNum
strBoolBinop  = boolBinop unpackStr
boolBoolBinop = boolBinop unpackBool

現在我們還沒告訴Haskell如何從LispVal類型的值中解包出字符串。這其實和unpackNum函數類似,我們只需要對目標值進行模式匹配并且在失敗時拋出錯誤就行了。同樣,如果傳入的是一個可以被解釋成字符串的其他基本類型(數字或者布爾值)我們也會同樣默默將它轉換成對應的字符串表達形式。

unpackStr :: LispVal -> ThrowsError String
unpackStr (String s) = return s
unpackStr (Number s) = return $ show s
unpackStr (Bool s)   = return $ show s
unpackStr notString  = throwError $ TypeMismatch "string" notString

使用類似的代碼來對布爾值解包:

unpackBool :: LispVal -> ThrowsError Bool
unpackBool (Bool b) = return b
unpackBool notBool  = throwError $ TypeMismatch "boolean" notBool

在進入下一步之前,先編譯并運行幾個例子來看看它是否正確:

$ ghc -package parsec -o simple_parser [../code/listing6.1.hs listing6.1.hs]
$ ./simple_parser "(< 2 3)"
#t
$ ./simple_parser "(> 2 3)"
#f
$ ./simple_parser "(>= 3 3)"
#t
$ ./simple_parser "(string=? \"test\"  \"test\")"
#t
$ ./simple_parser "(string<? \"abc\" \"bba\")"
#t

條件:模式匹配

現在,我們繼續將if語句添加到我們的求值器中。根據Scheme標準,我們這里會認為除了#f以外的其他所有值都是True:

eval (List [Atom "if", pred, conseq, alt]) = 
     do result <- eval pred
        case result of
             Bool False -> eval alt
             otherwise  -> eval conseq

由于函數定義是會被依次進行計算的,這部分記得需要放在eval (List (Atom func : args)) = mapM eval args >>= apply funcq前面不然它會拋出一個Unrecognized primitive function args: "if"錯誤。

這又是一個嵌套模式匹配的例子。這里,我們要匹配一個四元素的列表。其他第一元素元素必須是Atom類型的if,其他則可能是任意的Scheme類型。我們求出pred的值,如果它是False的,則函數返回alt的值,否則的話,我們計算并返回conseq的值。

編譯并運行程序,你就能嘗試使用條件分支了:

$ ghc -package parsec -o simple_parser [../code/listing6.2.hs listing6.2.hs]
$ ./simple_parser "(if (> 2 3) \"no\" \"yes\")"
"yes"
$ ./simple_parser "(if (= 3 3) (+ 2 3 (- 5 1)) \"unequal\")"
9

列表操作:car cdr和cons

接下來我們將一些基本的列表操作添加到primitives中。由于我們已經選擇了使用Haskell的代數類型而不是Pair類型來表達列表了,因此這里的定義就反而可能比在大部分Lisp里更加復雜一點。通過打印出來得S表達式也許你能夠更加容易的理解它們的效果:

  1. (car '(a b c)) = a
  2. (car '(a)) = a
  3. (car '(a b . c)) = a
  4. (car 'a) = error – not a list
  5. (car 'a 'b) = error – car only takes one argument

我們可以直接將它們翻譯成對應的模式匹配子句,記得(x:xs)會將一個列表分割成第一個元素以及接下來的其他部分:

car :: [LispVal] -> ThrowsError LispVal
car [List (x : xs)]         = return x
car [DottedList (x : xs) _] = return x
car [badArg]                = throwError $ TypeMismatch "pair" badArg
car badArgList              = throwError $ NumArgs 1 badArgList

cdr函數也是同樣:

  1. (cdr '(a b c)) = (b c)
  2. (cdr '(a b)) = (b)
  3. (cdr '(a)) = NIL
  4. (cdr '(a . b)) = b
  5. (cdr '(a b . c)) = (b . c)
  6. (cdr 'a) = error – not a list
  7. (cdr 'a 'b) = error – too many arguments

我們可以用一個子句來代表前三種情況。我們的解析器將'()認為是一個空列表[],并且當你使用(x:xs)來對[x]進行匹配時,xs會綁定到一個空列表[]。其他的情況我們都用單獨的子句來表示:

cdr :: [LispVal] -> ThrowsError LispVal
cdr [List (x : xs)]         = return $ List xs
cdr [DottedList [_] x]      = return x
cdr [DottedList (_ : xs) x] = return $ DottedList xs x
cdr [badArg]                = throwError $ TypeMismatch "pair" badArg
cdr badArgList              = throwError $ NumArgs 1 badArgList

cons函數會有一點棘手,所以我們還是來一個個看下各種可能發生的情況吧。如果你將任何一個值和空列表(Nil)通過cons結合,那么你就會得到一個單元素的列表,Nil會充當一個終止符:

cons :: [LispVal] -> ThrowsError LispVal
cons [x1, List []] = return $ List [x1]

如果你將任意值和一個列表通過cons結合,這就像是就那個值插進列表的最前面:

cons [x, List xs] = return $ List $ x : xs

然后,如果你處理的是一個DottedList,那你需要考慮不正確的尾元素的情況并讓它保持還是一個合法的DottedList:

cons [x, DottedList xs xlast] = return $ DottedList (x : xs) xlast

如果你把兩個都不是列表的對象通過cons組合,或者把列表作為第一個參數,那就會得到一個DottedList。這是因為這樣通過cons組合的部分不像其他普通列表那樣由一個Nil來終結的緣故。

cons [x1, x2] = return $ DottedList [x1] x2

最后,任意傳入大于或小于兩個參數的情況都會引起錯誤:

cons badArgList = throwError $ NumArgs 2 badArgList

我們的最后一步是實現一個eqv?函數。Scheme提供了三種不同程度的相等斷言:eq?eqv?以及equal?。對我們來說,eq?eqv?基本上是一樣的:如果兩個值打印出來的結果是一樣的,那它們就相等,雖然貌似這樣運行起來也許會比較慢。所以我們這里就為它們兩個提供一個實現并且將它注冊成eq?eqv?

eqv :: [LispVal] -> ThrowsError LispVal
eqv [(Bool arg1), (Bool arg2)]             = return $ Bool $ arg1 == arg2
eqv [(Number arg1), (Number arg2)]         = return $ Bool $ arg1 == arg2
eqv [(String arg1), (String arg2)]         = return $ Bool $ arg1 == arg2
eqv [(Atom arg1), (Atom arg2)]             = return $ Bool $ arg1 == arg2
eqv [(DottedList xs x), (DottedList ys y)] = eqv [List $ xs ++ [x], List $ ys ++ [y]]
eqv [(List arg1), (List arg2)]             = return $ Bool $ (length arg1 == length arg2) && 
                                                             (all eqvPair $ zip arg1 arg2)
     where eqvPair (x1, x2) = case eqv [x1, x2] of
                                Left err -> False
                                Right (Bool val) -> val
eqv [_, _]                                 = return $ Bool False
eqv badArgList                             = throwError $ NumArgs 2 badArgList

除了處理兩個List值的部分,其他子句大多都是自解釋的。這里,在檢查確認了兩個列表是相等的長度之后,使用zip函數將列表配對并一一進行對比。eqvPair函數式一個局部定義的例子:它用where關鍵詞來定義,除了它的作用域僅僅是eqv函數的一個子句,其他都和普通的函數一樣。這里由于我們已經知道eqv函數只會在傳遞給它的不是兩個參數的時候才會拋出一個錯誤,因此Left err -> False這行其實是永遠也不會被執行的。

equal?和弱類型:異構列表

之前我們已經介紹過有關弱類型的概念了,因此這里我們嘗試創建一個equal?函數,它會忽視類型并僅僅判斷兩個值是否能被解釋成相同的結果。舉個栗子,(eqv? 2 "2") = #f,但我們希望能夠得到(equal? 2 "2") = #t。基本上,我們需要嘗試所有的解包方法,如果它們中的任何一個會讓對應的Haskell值相等,那就返回True。

一個顯而易見的方法就是把所有解包的函數都放進一個列表里然后通過mapM函數讓它們逐個執行。然而很不幸你沒法這么干,因為Haskell不允許你將不同類型的值放進同一個列表中。各式各樣的解包函數顯然會返回不同的類型,因此你沒法將它們存在一起。

我們這里需要使用一個GHC的擴展包--Existential Types,來使用異構列表,雖然它仍然需要受到類型類的約束。擴展在Haskell的使用當中是相當常見的:基本上你如果需要寫一些靠譜的大型程序都會或多或少用刀,它們也往往能互相兼容(Existential Types在Hugs和GHC里都運行良好并且很有希望被納入Haskell標準)。注意你需要使用一個特別的編譯參數來開啟這個功能:-fglasgow-exts。也可以添加-XExistentialQuantification或者是在程序的最開始加上這么一段注解{-# LANGUAGE ExistentialQuantification #-}。(總的來說,編譯時的參數位-Xfoo都可以被在源代碼中的{-# LANGUAGE foo #-}注解來替代。)

首先我們需要定義一個能夠表示LispVal -> something的函數的類型,只要這個something能夠支持判等:

data Unpacker = forall a. Eq a => AnyUnpacker (LispVal -> ThrowsError a)

這里和其他普通的代數數據類型都是類似的,除了這里有一個類型限制。它表示“對于任意是Eq實例的類型,你可以定義一個讀取一個將LispVal轉換成那個類型并且有可能拋出錯誤的函數作為參數的Unpacker類型”。我們將這個函數通過AnyUnpacker構造器進行封裝,然后我們就可以創建一個Unpacker列表來實現我們之前想要的效果。

equal?函數的定義之前,我們來首先來看一個讀取一個Unpacker類型然后判斷兩個LispVal值在解包后是否相等的的輔助函數:

unpackEquals :: LispVal -> LispVal -> Unpacker -> ThrowsError Bool
unpackEquals arg1 arg2 (AnyUnpacker unpacker) = 
             do unpacked1 <- unpacker arg1
                unpacked2 <- unpacker arg2
                return $ unpacked1 == unpacked2
        `catchError` (const $ return False)

在通過模式匹配獲取實際的解包函數之后,我們進入了一個ThrowsError Monad的do代碼塊。這里我們獲取兩個LispVal值在Haskell中對應的值然后對它們進行比較。如果在解包的過程中發生了任何錯誤,就也會返回一個False,這里由于catchError函數需要我們傳遞一個函數用來處理錯誤值,我們就直接使用const函數就可以了。

最后,我們給出equal?函數的定義。

equal :: [LispVal] -> ThrowsError LispVal
equal [arg1, arg2] = do
      primitiveEquals <- liftM or $ mapM (unpackEquals arg1 arg2) 
                         [AnyUnpacker unpackNum, AnyUnpacker unpackStr, AnyUnpacker unpackBool]
      eqvEquals <- eqv [arg1, arg2]
      return $ Bool $ (primitiveEquals || let (Bool x) = eqvEquals in x)
equal badArgList = throwError $ NumArgs 2 badArgList

這里第一步操作創建了一個異構列表[unpackNum, unpackStr, unpackBool],然后將一個被部分應用的(unpackEquals arg1 arg2)映射到它上面。得到一個布爾值列表后,我們使用Prelude中的函數or,如果其中任意一個結果是True則為True。

第二部操作使用eqv?函數對兩個參數進行測試。因為我們希望equal?會比eqv?更加寬松的緣故。因此如果eqv?返回True的話,這里也應該直接返回True。這就讓我們能夠避免處理一些類似于列表或者DottedList的情況了。(事實上這里引入了一個bug;練習2會提到)

最后,將上面的值用or連接起來并且將結果封裝在一個Bool構造器里,從而返回一個LispVal。let (Bool x) = eqvEquals in x是一個便捷的從代數類型中分解值得方式:通過模式匹配將eqvEquals中包含的值取出然后返回。這個let表達式的結果即是關鍵詞in之后的部分。

將函數插入到primitives列表中好讓它們能夠被使用:

("car", car),
("cdr", cdr),
("cons", cons),
("eq?", eqv),
("eqv?", eqv),
("equal?", equal)]

你需要通過-fglasgow-exts參數來開啟GHC擴展功能來進行編譯這段代碼:

$ ghc -package parsec -fglasgow-exts -o parser [../code/listing6.4.hs listing6.4.hs]
$ ./parser "(cdr '(a simple test))"
(simple test)
$ ./parser "(car (cdr '(a simple test)))"
simple
$ ./parser "(car '((this is) a test))"
(this is)
$ ./parser "(cons '(this is) 'test)"
((this is) . test)
$ ./parser "(cons '(this is) '())"
((this is))
$ ./parser "(eqv? 1 3)"
#f
$ ./parser "(eqv? 3 3)"
#t
$ ./parser "(eqv? 'atom 'atom)"
#t

習題

  1. 改變if函數的定義讓它只接受Bool類型的值并在其他情況下拋出異常而不是把所有不是False的值都當做True。
  2. equal?函數有一個bug由于在列表中的值都是通過eqv?而不是equal?來比較的。例如,(equal? '(1 "2") '(1 2))會得到一個False,而你也許會希望獲得True。修改equal?函數讓它在對列表進行遞歸計算的時候也會忽略類型。你可以模仿eqv?函數來顯示的定義它也可以將處理list的情況另外創建一個輔助函數來處理,并且將它判等時使用的函數進行參數化。
  3. 實現cond和case表達式
  4. 添加剩下的字符串函數。你現在可能還沒法實現一個自己的string-set!,這在Haskell里有點難實現,不過在接下來的兩章之后你可能就能夠實現它了。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容