3. 语法分析
3.1 :写一个简单的分析程序
现在,让我们试着写一个简单的分析程序。我们会使用 Parsec 库,这个库可能来自 GHC 但是如果你使用其他编译器这个库可能需要单独下载。
开始添加这一行在导入节 (import section) :
import Text.ParserCombinators.Parsec hiding (spaces)
这让我们可以使用 Parsec 库,除了一个会和我们待会儿要定义的函数名字冲突的” spaces” 函数。
现在我们要定义一个识别 Scheme 标识符中允许的一个字符的分析器:
symbol :: Parser Char symbol = oneOf "!$#%&|*+-/:<=>?@^_~"
这是另一个 monad 的例子:在这里,隐藏的 " 附加信息”是输入流里面的所有关于位置的信息、回溯记录、第一和接下来的组 (first and follow sets) 等等。 Parsec 替我们照顾好了这一切。我们只需要使用 Parsec 库函数 oneOf ,它会识别后面字符串中的一个字符。 Parsec 提供了一些预定义的分析器:例如, letter 和 digit 。正如你将要看到的,你能将有限的分析器组合成更复杂的分析器。
让我们定义一个函数调用我们的分析器并且处理可能发生的错误:
readExpr :: String -> String readExpr input = case parse symbol "lisp" input of Left err -> "No match: " ++ show err Right val -> "Found value"
正如你能从类型签名 (type signature) 中看到的 , readExpr 是一个从字符串到字符串的函数。我们把参数命名为 input ,然后把它和我们在上面定义的 symbol 动作以及分析器的名字 (“lisp”) 一并交给 Parsec 函数 parse 。
parse 函数能返回分析值或者错误,所以我们需要处理可能发生的错误信息。按照 Haskell 惯例, Parsec 返回一个 Either 数据类型,它使用 Left 构造符表示错误,使用 Right 构造符表示一个正常值。
我们用 case … of 结构来匹配 parse 的结果和这些选择。如果我们得到一个 Left 值 ( 错误 ) ,我们把错误同 err 绑定,然后返回字符串 "No match” 表示这个错误。如果我们得到 Right 值,我们把这个值同 val 帮定,忽略它,然后返回字符串 "Found value” 。
Case … of 结构是一个模式匹配的例子,我们可以在后面看到更多的细节。
最后,我们需要改变我们的主函数来调用 readExpr 然后把结果输出:
main :: IO () main = do args <- getArgs putStrLn (readExpr (args !! 0))
你需要指明 "-package parsec” 来编译和运行这个程序,不然会出现链接错误。例如:
debian:/home/jdtang/haskell_tutorial/code# ghc -package parsec -o simple_parser listing3.1.hs debian:/home/jdtang/haskell_tutorial/code# ./simple_parser $ Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser a No match: "lisp" (line 1, column 1): unexpected "a"
(PS :现在可以使用 --make 选项,编译器会自动判断 )
3.2: 空白
接下来,我们需要给我们的分析器添加一系列改进让它可以慢慢识别更多更复杂的表达式。现在这一个分析器在符号 (symbol) 前面有空白时会出错:
debian:/home/jdtang/haskell_tutorial/code# ./simple_parser " %" No match: "lisp" (line 1, column 1): unexpected " "
让我们修正它使得我们可以忽略空白。
首先,我们定义一个分析器能识别任意多的空白字符。附带的说明,这就是我们为什么在我们导入 Parsec 库时添加 "hiding (space)” 语句:这里已经有一个 "spaces” 函数了,但是他不太符合我们的要求。(同样的原因,这里也有一个不完全是我们想要的分析器叫做 lexeme ,但是我们因为教学的目的忽略它)
spaces :: Parser () spaces = skipMany1 space
只是作为一个函数传递给其他函数就能够实现它的功能。这里我们把动作 space 传递给动作 skipMany1 ,得到一个能辨认至少一个空格的分析器。
现在,让我们编辑我们的语法分析函数,让它能使用这个新的分析器。我们用红色标识代码的变化:
readExpr input = case parse (spaces >> symbol) "lisp" input of Left err -> "No match: " ++ show err Right val -> "Found value"
我们在第二课里简单的接触了 >>( 绑定 ) 操作符,在那里我们提到它被用来在背后连接 do-block 的每一行。这里,我们显式的使用它来连接我们的空白分析器和符号分析器。可是, bind 有一个完全不同的含义在 Parser monad 和 IO monad 中。在 Parser monad 中, bind 表示“尝试匹配第一个分析器,然后尝试用第二个分析器匹配剩下的部分,如果任何一个出错,那么整个过程出错”。总的来说, bind 会在不同的 monad 中有不同的效果;它被用作一个通用的方法来组织整个计算,所以需要适应各种不同类型的计算。如果你想准确的辨别出它干什么,请阅读文档。
编译并运行这段代码。注意我们用 skipMany1 来定义 spaces ,他不会再识别出一个字符。变成了你必须放一些空白在字符的前面。我们可以简单看看这是如何有用的:
debian:/home/jdtang/haskell_tutorial/code# ghc -package parsec -o simple_parser listing3.2.hs debian:/home/jdtang/haskell_tutorial/code# ./simple_parser " %" Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser % No match: "lisp" (line 1, column 1): unexpected "%" expecting space debian:/home/jdtang/haskell_tutorial/code# ./simple_parser " abc" No match: "lisp" (line 1, column 4): unexpected "a" expecting space
3.3 返回值
现在,这个分析器并没有做太多的事情-它仅仅告诉我们一个字符串是否能被识别。然而,我们想让它做更多的事情:我们想要它将输入转换成一个数据结构,让我们可以容易的遍历它。在这一节,我们学习如何定义一个数据类型,和如何修改我们的分析器让它能返回这种数据类型。
首先,我们需要定义一个能存储任意 Lisp 值的数据类型:
data LispVal = Atom String | List [LispVal] | DottedList [LispVal] LispVal | Number Integer | String String | Bool Bool
这是一个代数数据类型的例子:它定义了一组 LispVal 类型的变量可以存储的可能值。每一个选择 ( 称作一个构造符并用 | 分隔 ) 包括一个构造符和这个构造符能够存储的数据类型的标签。在这里例子里面,一个 LispVal 可以是:
-
Atom ,存储一个字符串命名的原子 (atom)
-
List ,存储一组其他 LispVal(Haskell 列表用方括号表示 )
-
DottedList ,表示 Scheme 中 (a b . c) 形式。这个存储一个除了最后一个元素的表,然后最后一个元素用另一个域存储
-
Number ,包括一个 Haskell 数字
-
String ,包括一个 Haskell 字符串
-
Bool ,包括一个 Haskell 布尔值
构造符和类型有不同的命名空间,所以你可以有不同名的构造符和类型。类型和构造符标签都必须以大写字母开头。
下一步,让我们添加更多的分析函数来构造这些类型的值。字符串使用双引号标记,戒指是任意长度非引号字符,接下来是反引号标记:
parseString :: Parser LispVal parseString = do char '"' x <- many (noneOf "\"") char '"' return $ String x
我们再次使用 do-notatioin 而不是 >> 操作符。这是因为我们需要检索我们分析的值 (many (noneOf “\””) 返回值 ) 并且使用它,并且需要同时交叉使用一些其他的分析操作符。总的来说,在动作不需要返回值时使用 >> ,在你需要直接传递值到下一个动作时使用 >>= ,其它的情况是用 do-notation 。
当我们完成分析过程,我们就会从 many 获得 Haskell 字符串,我们把它用 String 构造符 ( 在我们的 LispVal 数据类型中 ) 转换成 LispVal 。每一个代数类型中的构造符像函数一样将它的参数转换成它的类型。它也可以作为模式 (pattern) 在模式匹配表达式的左边;我们已经在 3.1 课中看到一个例子,那里我们将我们的分析结果于两个 Either 数据类型构造器进行匹配。
我们使用内建的函数 return 来提升我们的 LispVal 成 Parser monad 。记住, do-block 中每一行必须有同样的类型,然而我们的字符串构造器的结构只是 LispVal 类型。 Return 能让我们在不消费任何输入而返回它作为内部值的情况下包装这个值成为 Parser 动作。这样,整个 parseString 动作就有 Parser LispVal 类型了。
$ 操作符是一个中缀函数:它和我们写成 return (String x) 含义相同,但是 $ 是右结合的,让我们省略一些圆括号。因为 $ 是一个操作符,你可以像正常使用一个函数那样使用它做任何事情:传递他,部分使用它等等。在这个方面,它和 Lisp 函数 apply 功能一致。
现在我们来看看 Scheme 变量。一个 atom 是一个字母或者符号,跟着任意多的字母、数字或者符号。
parseAtom :: Parser LispVal parseAtom = do first <- letter <|> symbol rest <- many (letter <|> digit <|> symbol) let atom = [first] ++ rest return $ case atom of "#t" -> Bool True "#f" -> Bool False otherwise -> Atom atom
这里,我们介绍另一个 Parsec 结合器,选择操作符 <|> 。它先尝试第一个分析器,如果失败,那么尝试第二个。如果任意一个成功,那么返回那个成功的分析器返回值。第一个分析器必须在它消费任何输入前失败:我们待会儿看看怎么实现回溯。
当我们读完第一个字符和 atom 剩下的部分,我们需要把他们放在一起。 "let” 语句定义了一个新变量 "atom” 。我们使用列表连接操作符 ++ 来连接它们。记住 first 只是一个字符,我们用方括号包围它来将它转换成一个单原子列表。如果我们想创造一个包括更多元素的列表,我们只需要用逗号将这些元素分开。
接下来我们使用一个 case 语句来判断哪一个 LispVal 我们创造和返回,用字面量字符串来表示 true 和 false 。 otherwise 选择是一个增强可读性的技巧:它被帮定给一个为 otherwise 的变量,我们忽略它的值,并且总是返回 atom 的值。
最后,我们创造再创造一个分析器,来分析数字。这个分析器展示了更多的方法来处理 monadic 值:
parseNumber :: Parser LispVal parseNumber = liftM (Number . read) $ many1 digit
从反方向很容易理解这个,因为 ($) 和 (.) 函数都是右结合的。 Parsec 结合器 many1 匹配一个或者更多它的参数,所以这里我们将匹配一个或者更多的数字。我们想从结果字符串构建一个 LispVal 数字,但是我们犯了一些错误。首先,我们用内建函数 read 把字符串转换成一个数字。然后我们把结果传递给 Number 构造符来得到一个 LispVal 类型。结合操作符 ".” 创造一个函数让它的右边的参数传递结果给左边参数,所以我们使用它连接两个函数。
不幸的是, many1 digit 结果是一个 Parser String ,所以我们结合的 Number . Read 函数仍然不能操作它。我们需要一种告诉它只操作 monad 里面的值的方法,再把结果返回给 Parser LispVal 。标准函数 liftM 刚好能做这件事,所以我们对我们的函数 Number . Read 使用 liftM ,然后把结果给 Parser 。
我们也在程序的最上方引入 Monad 模块来获得 liftM 函数。
import Monad
这种很大程度上依赖函数的结合,函数的实现,把函数传递给函数的编程方式在 Haskell 代码中很常见。它常常能让你在一行表示很复杂的算法,把中间的阶段分解成其它可以用不同方式结合的函数。不幸的是,这表明你需要常常从右向左阅读 Haskell 代码并且注意跟踪它们的类型。我们会看到更多的例子在后面的教程中,所以你会有希望更适应这种方式。
让我们创造一个分析器接受一个字符串、一个数字或者一个原子:
parseExpr :: Parser LispVal parseExpr = parseAtom <|> parseString <|> parseNumber
接下来修改 readExpr 让它调用我们的新分析器 :
readExpr :: String -> String readExpr input = case parse parseExpr "lisp" input of Left err -> "No match: " ++ show err Right _ -> "Found value"
编译并运行这段代码,你会注意到它接受任何数字、字符串或者符号,不过其它字符串都不行:
debian:/home/jdtang/haskell_tutorial/code# ghc -package parsec -o simple_parser listing3.3.hs debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "\"this is a string\"" Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser 25 Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser symbol Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser (symbol) bash: syntax error near unexpected token `symbol' debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "(symbol)" No match: "lisp" (line 1, column 1): unexpected "(" expecting letter, "\"" or digit
习题:
-
重写 parseNumber 使用
-
do-notation
-
用 >>= 操作符显式串联
-
-
我们的字符串并不太符合 R5RS 规范,因为它们不支持在字符串里转义。修改 parseString 让 \” 表示字面引用字符而不是字符串的结束。你可能希望用一个新分析器来替换 noneOf “\”” 让它能接受非引号字符或者返斜杠接一个引号符号。
-
修改前面的练习,让它支持 \n,\r,\t,\\ 以及其它你希望的转义字符。。
-
修改 parseNumber 让它提供 Scheme 标准中对不同基的支持。你可以发现 readOct 和 readHex 函数很有用。
-
给 LispVal 增加一个字符构造符,然后创造一个 R5RS 中表述的给字符字面量的分析器。
-
给 lispVal 增加一个浮点数构造符,支持 R5RS 对小数的语法。 Haskell 函数 readFloat 可能会有用。
-
增加数据类型和分析器来支持 Scheme 数字类型的全部数字塔 (full numeric tower) 。 Haskell 已经有内建类型来表示这些的大部分;看看 Prelude 。至于其它的,你可以定义复合类型来表示。例如 , 一个分数可以用分子和分母表示,一个复数可以用实部和虚部表示(每一部分都是一个实数)。
3.4 :递归分析器:添加列表,标记列表和引用
接下来,我们给我们的翻译器添加更多的分析器。我们从让 Lisp 著名的括号列表开始:
parseList :: Parser LispVal parseList = liftM List $ sepBy parseExpr spaces
这个与 parseNumber 类似,首先分析一系列用空白分开的表达式 (sepBy parseExpr spaces) 然后在 Parser monad 内部应用 List 构造符。注意我们把 parseExpr 传递给 sepBy ,尽管他是一个我们自己写的动作。
dotted-list 分析器会更复杂,但是荏苒使用几个我们熟悉的概念:
parseDottedList :: Parser LispVal parseDottedList = do head <- endBy parseExpr spaces tail <- char '.' >> spaces >> parseExpr return $ DottedList head tail
注意我们怎么使用 >> 把一系列的 Parser 动作连接起来的而且在右边的整个串使用 do-statement 。表达式 char '.' >> spaces 返回一个 Parser () ,然后与 parseExpr 结合产生一个 Parser LispVal ,完全符合我们在 do-block 中需要的类型。
接下来,我们添加一些对单引号支持的 Scheme 语法糖。
parseQuoted :: Parser LispVal parseQuoted = do char '\'' x <- parseExpr return $ List [Atom "quote", x]
这里的大多数都是非常熟悉的部分:它读入一个单引号字符,读入一个表达式并把它帮定给 x ,然后返回 (quote x) ,来使用 Scheme 标记。 Atom 构造符像一般的函数那样工作:你传入你要封装的字符串,然后它给你一个 LispVal 。你能对这个 LispVal 做任何你一般能做的事情,像把它放入一个表。
最后,编辑我们的 parseExpr 定义包括我们的新分析器:
parseExpr :: Parser LispVal
parseExpr = parseAtom
<|> parseString
<|> parseNumber
<|> parseQuoted
<|> do char '('
x <- (try parseList) <|> parseDottedList
char ')'
return x
这个表明了 Parsec 最后的特征:回溯。 parseList 和 parseDottedList 识别相同的字符串直到那个点;这个打破了一个选择不能在出错前消费任何输入的需要。 try 连接器试图使用指定的分析器,但是如果失败了,它会返回到上一个状态。这让你在不妨碍其它选择的情况下使用选择分支。
编译并运行这段代码:
debian:/home/jdtang/haskell_tutorial/code# ghc -package parsec -o simple_parser listing3.4.hs debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "(a test)" Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "(a (nested) test)" Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "(a (dotted . list) test)" Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "(a '(quoted (dotted . list)) test)" Found value debian:/home/jdtang/haskell_tutorial/code# ./simple_parser "(a '(imbalanced parens)" No match: "lisp" (line 1, column 24): unexpected end of input expecting space or ")"
注意我们可以在 parseExpr 里任意深的嵌套我们的分析器。这样,我们用一些定义得到一个完全的 Lisp 阅读器。这就是递归的威力。
习题:
-
添加反引号语法糖的支持: Scheme 标准详述了它应该怎样展开 (quasiquote/unquote) 。
-
添加向量的支持。你可以使用 Haskell 的实现: GHC 有 Array 数据类型,但是它可能难以使用。严格说,一个向量应该有常数时间的索引和更新,但是破坏性的更新在一个纯净的函数式语言里是很困难的。你可能在后面了解关于 set 的章节后会对如何实现它有更好的主意。
-
Instead of using the try combinator, left-factor the grammar so that the common subsequence is its own parser. You should end up with a parser that matches a string of expressions, and one that matches either nothing or a dot and a single expressions. Combining the return values of these into either a List or a DottedList is left as a (somewhat tricky) exercise for the reader: you may want to break it out into another helper function 。