跳轉到內容

在 48 小時內編寫自己的 Scheme/評估,第一部分

來自華夏公益教科書,開放的世界,開放的書籍
在 48 小時內編寫自己的 Scheme
 ← 解析 評估,第一部分 錯誤檢查和異常 → 

開始評估器

[編輯 | 編輯原始碼]

目前,我們只是打印出我們是否識別給定的程式片段。我們即將邁出實現 Scheme 直譯器的第一步:將值分配給程式片段。我們將從最基本的步驟開始,但很快你就會開始處理計算。

讓我們首先告訴 Haskell 如何打印出各種可能 LispVal 的字串表示形式。

 
showVal :: LispVal -> String
showVal (String contents) = "\"" ++ contents ++ "\""
showVal (Atom name) = name
showVal (Number contents) = show contents
showVal (Bool True) = "#t"
showVal (Bool False) = "#f"

這是我們第一次真正介紹模式匹配。模式匹配是一種解構代數資料型別的方法,根據其建構函式選擇一個程式碼子句,然後將元件繫結到變數。任何建構函式都可以在模式中出現;如果標籤與值的標籤相同,並且所有子模式與其相應的元件匹配,則該模式與該值匹配。模式可以任意深度巢狀,匹配以從內到外,從左到右的順序進行。如果這令人困惑,那麼當我們深入評估器時,你將看到一些深度巢狀模式的示例。

現在,你只需要知道上述定義的每個子句都與 LispVal 的一個建構函式匹配,而右側說明了如何處理該建構函式的值。

ListDottedList 子句的工作原理相似,但我們需要定義一個輔助函式 unwordsList 將包含的列表轉換為字串

showVal (List contents) = "(" ++ unwordsList contents ++ ")"
showVal (DottedList head tail) = "(" ++ unwordsList head ++ " . " ++ showVal tail ++ ")"

unwordsList 函式的工作原理與 Haskell Prelude 的 unwords 函式類似,它用空格將單詞列表粘合在一起。由於我們正在處理一個 LispVal 列表而不是單詞列表,因此我們定義了一個函式,該函式首先將 LispVal 轉換為它們的字串表示形式,然後將 unwords 應用於它。

unwordsList :: [LispVal] -> String
unwordsList = unwords . map showVal

我們對 unwordsList 的定義不包含任何引數。這是一個無點風格的例子:完全用函式組合和部分應用來編寫定義,而不考慮單個值或“點”。相反,我們將它定義為幾個內建函式的組合。首先,我們將 map 部分應用於 showVal,這將建立一個函式,該函式接受一個 LispVal 列表並返回其字串表示形式列表。Haskell 函式是柯里化的:這意味著一個具有兩個引數的函式,如 map,實際上是一個返回一個引數函式的函式。因此,如果你只提供一個引數,你將得到一個引數函式,你可以稍後傳遞、組合和應用它。在本例中,我們將它與 unwords 組合:map showValLispVal 列表轉換為其字串表示形式列表,然後 unwords 用空格將結果連線在一起。

我們在上面使用了函式 show。此標準 Haskell 函式允許你將任何型別(它是類 Show 的例項)轉換為字串。我們希望能夠對 LispVal 做同樣的事情,因此我們將其設為類 Show 的成員,將它的 show 方法定義為 showVal

instance Show LispVal where show = showVal

對型別類的完整處理超出了本教程的範圍;你可以在 其他教程Haskell 2010 報告 中找到更多資訊。

讓我們透過更改我們的 readExpr 函式來嘗試一下,使其返回實際解析的值的字串表示形式,而不是隻返回“已找到值”。

readExpr input = case parse parseExpr "lisp" input of
    Left err -> "No match: " ++ show err
    Right val -> "Found " ++ show val

然後編譯並執行……

$ ghc -package parsec -o parser listing4.1.hs
$ ./parser "(1 2 2)"
Found (1 2 2)
$ ./parser "'(1 3 (\"this\" \"one\"))"
Found (quote (1 3 ("this" "one")))

評估器的開始:基本運算

[編輯 | 編輯原始碼]

現在,我們從評估器的開始著手。評估器的目的是將一些“程式碼”資料型別對映到一些“資料”資料型別,即評估的結果。在 Lisp 中,程式碼和資料的型別是相同的,因此我們的評估器將返回一個 LispVal。其他語言通常具有更復雜的程式碼結構,具有各種語法形式。

評估數字、字串、布林值和引用的列表相當簡單:返回資料本身。

 
eval :: LispVal -> LispVal
eval val@(String _) = val
eval val@(Number _) = val
eval val@(Bool _) = val
eval (List [Atom "quote", val]) = val

這引入了新的模式型別。符號 val@(String _) 與任何作為字串的 LispVal 匹配,然後將 val 繫結到整個 LispVal,而不僅僅是 String 建構函式的內容。結果的型別為 LispVal,而不是 String。下劃線是“不關心”變數,匹配任何值但不會將其繫結到變數。它可以在任何模式中使用,但在 @-模式(你將變數繫結到整個模式)和簡單的建構函式測試中特別有用,在這種情況下,你只關心建構函式的標籤。

最後一個子句是我們第一次介紹巢狀模式。List 包含的資料型別是 [LispVal],一個 LispVal 列表。我們將其與特定的雙元素列表 [Atom "quote", val] 匹配,該列表中的第一個元素是符號“quote”,第二個元素可以是任何東西。然後我們返回第二個元素。

讓我們將 eval 整合到我們現有的程式碼中。首先將 readExpr 改回,使其返回表示式而不是表示式的字串表示形式。

readExpr :: String -> LispVal
readExpr input = case parse parseExpr "lisp" input of
    Left err -> String $ "No match: " ++ show err
    Right val -> val

然後更改我們的主函式以讀取表示式、評估它、將其轉換為字串並將其打印出來。現在我們瞭解了 >>= 單子排序運算子和函式組合運算子,讓我們使用它們使它更加簡潔。

main :: IO ()
main = getArgs >>= print . eval . readExpr . head

在這裡,我們獲取 getArgs 操作的結果(一個字串列表),並將其傳遞到以下組合中:

  1. 獲取第一個值 (head);
  2. 解析它 (readExpr);
  3. 評估它 (eval);
  4. 將其轉換為字串並打印出來 (print)。

以正常方式編譯並執行程式碼

$ ghc -package parsec -o eval listing4.2.hs
$ ./eval "'atom" 
atom
$ ./eval 2
2
$ ./eval "\"a string\""
"a string"
$ ./eval "(+ 2 2)"

Fail: listing6.hs:83: Non-exhaustive patterns in function eval

我們仍然無法用這個程式做太多有用的事情(見證 (+ 2 2) 呼叫的失敗),但基本骨架已經到位。很快,我們將使用一些函式來擴充套件它,使其變得有用。

新增基本運算

[編輯 | 編輯原始碼]

接下來,我們將改進我們的 Scheme,以便我們可以將其用作簡單的計算器。它還不是“程式語言”,但已經很接近了。

首先向 eval 新增一個子句來處理函式應用。請記住,函式定義的所有子句都必須放在一起,並且按文字順序進行評估,因此這應該放在其他 eval 子句之後。

eval (List (Atom func : args)) = apply func $ map eval args

這是另一個巢狀模式,但這次我們匹配的是 cons 運算子“:”而不是字面列表。Haskell 中的列表實際上是 cons 應用和空列表的鏈的語法糖:[1, 2, 3, 4] = 1:(2:(3:(4:[])))。透過對 cons 本身而不是字面列表進行模式匹配,我們說“給我列表的其餘部分”,而不是“給我列表的第二個元素”。例如,如果我們將 (+ 2 2) 傳遞給 eval,則 func 將繫結到“+”,而 args 將繫結到 [Number 2, Number 2]

子句的其餘部分由幾個我們之前見過的函式和一個我們尚未定義的函式組成。我們必須遞迴地評估每個引數,因此我們將 eval 對映到 args 上。這就是讓我們能夠編寫像 (+ 2 (- 3 1) (* 5 4)) 這樣的複合表示式的原因。然後我們獲取結果的已評估引數列表,並將它與原始函式一起傳遞給 apply 函式。

apply :: String -> [LispVal] -> LispVal
apply func args = maybe (Bool False) ($ args) $ lookup func primitives

內建函式 lookup 在一個鍵值對列表中查詢一個鍵(它的第一個引數)。但是,如果列表中沒有包含匹配鍵的鍵值對,lookup 將失敗。為了表示這一點,它返回內建型別 Maybe 的一個例項。我們使用函式 maybe 來指定在成功或失敗的情況下該做什麼。如果函式未找到,我們返回一個 Bool False 值,等效於 #f(我們將在稍後新增更強大的錯誤檢查)。如果找到了,我們使用 ($ args) 將其應用於引數,這是一個函式應用運算子的運算子部分。

接下來,我們定義支援的原始函式列表。

primitives :: [(String, [LispVal] -> LispVal)]
primitives = [("+", numericBinop (+)),
              ("-", numericBinop (-)),
              ("*", numericBinop (*)),
              ("/", numericBinop div),
              ("mod", numericBinop mod),
              ("quotient", numericBinop quot),
              ("remainder", numericBinop rem)]

看看 primitives 的型別。它是一個鍵值對列表,就像 lookup 所期望的那樣,但鍵值對的值是從 [LispVal]LispVal 的函式。在 Haskell 中,您可以輕鬆地將函式儲存在其他資料結構中,儘管這些函式必須具有相同的型別。

此外,我們儲存的函式本身也是函式 numericBinop 的結果,我們還沒有定義它。它接受一個原始 Haskell 函式(通常是一個運算子部分),並用程式碼對其進行包裝,以解包引數列表,將函式應用於它,並將結果包裝在我們的 Number 建構函式中。

numericBinop :: (Integer -> Integer -> Integer) -> [LispVal] -> LispVal
numericBinop op params = Number $ foldl1 op $ map unpackNum params

unpackNum :: LispVal -> Integer
unpackNum (Number n) = n
unpackNum (String n) = let parsed = reads n :: [(Integer, String)] in 
                           if null parsed 
                              then 0
                              else fst $ parsed !! 0
unpackNum (List [n]) = unpackNum n
unpackNum _ = 0

與 R5RS Scheme 一樣,我們不侷限於僅兩個引數。我們的數值運算可以對任何長度的列表起作用,因此 (+ 2 3 4) = 2 + 3 + 4,而 (- 15 5 3 2) = 15 - 5 - 3 - 2。我們使用內建函式 foldl1 來做到這一點。它實質上將列表中的每個 cons 運算子更改為我們提供的二元函式 op

與 R5RS Scheme 不同的是,我們正在實現一種弱型別形式。這意味著如果一個值可以解釋為一個數字(例如字串 "2"),即使它被標記為字串,我們也會將其用作一個數字。我們透過在 unpackNum 中新增幾個額外的子句來做到這一點。如果我們正在解包一個字串,嘗試使用 Haskell 的內建 reads 函式對其進行解析,該函式返回一個包含 (解析後的值,剩餘字串) 對的列表。

對於列表,我們對包含一個元素的列表進行模式匹配,並嘗試解包它。其他任何情況都將傳遞到下一個情況。

如果由於任何原因我們無法解析數字,我們現在將返回 0。我們將在短期內修復這個問題,以便它發出錯誤訊號。

以正常方式編譯並執行它。注意我們如何“免費”獲得巢狀表示式,因為我們在每個函式引數上呼叫了 eval

$ ghc -package parsec -o eval listing7.hs
$ ./eval "(+ 2 2)"
4
$ ./eval "(+ 2 (-4 1))"
2
$ ./eval "(+ 2 (- 4 1))"
5
$ ./eval "(- (+ 4 6 3) 3 5 2)"
3
練習
  1. 新增原語來執行 R5RS 的各種 型別測試 函式:symbol?string?number? 等。
  2. 更改 unpackNum,使其在值不是數字時始終返回 0,即使它是一個可以解析為數字的字串或列表。
  3. 新增 R5RS 中的 符號處理函式。符號是我們一直在資料建構函式中稱為 Atom 的東西。


在 48 小時內編寫自己的 Scheme
 ← 解析 評估,第一部分 錯誤檢查和異常 → 
華夏公益教科書