`
tangtong
  • 浏览: 62469 次
  • 来自: ...
社区版块
存档分类
最新评论

Write Yourself a Scheme in 48 Hours(3)

阅读更多

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 可以是:

  1. Atom ,存储一个字符串命名的原子 (atom)

  2. List ,存储一组其他 LispVal(Haskell 列表用方括号表示 )

  3. DottedList ,表示 Scheme (a b . c) 形式。这个存储一个除了最后一个元素的表,然后最后一个元素用另一个域存储

  4. Number ,包括一个 Haskell 数字

  5. String ,包括一个 Haskell 字符串

  6. 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 类型了。

<!-- @page { margin: 0.79in } P { margin-bottom: 0.08in } A:link { so-language: zxx } -->

$ 操作符是一个中缀函数:它和我们写成 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
 

习题:

  1. 重写 parseNumber 使用

    1. do-notation

    2. >>= 操作符显式串联

  2. 我们的字符串并不太符合 R5RS 规范,因为它们不支持在字符串里转义。修改 parseString \” 表示字面引用字符而不是字符串的结束。你可能希望用一个新分析器来替换 noneOf “\”” 让它能接受非引号字符或者返斜杠接一个引号符号。

  3. 修改前面的练习,让它支持 \n,\r,\t,\\ 以及其它你希望的转义字符。。

  4. 修改 parseNumber 让它提供 Scheme 标准中对不同基的支持。你可以发现 readOct readHex 函数很有用。

  5. LispVal 增加一个字符构造符,然后创造一个 R5RS 中表述的给字符字面量的分析器。

  6. lispVal 增加一个浮点数构造符,支持 R5RS 对小数的语法。 Haskell 函数 readFloat 可能会有用。

  7. 增加数据类型和分析器来支持 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 阅读器。这就是递归的威力。

 

习题:

  1. 添加反引号语法糖的支持: Scheme 标准详述了它应该怎样展开 (quasiquote/unquote)

  2. 添加向量的支持。你可以使用 Haskell 的实现: GHC Array 数据类型,但是它可能难以使用。严格说,一个向量应该有常数时间的索引和更新,但是破坏性的更新在一个纯净的函数式语言里是很困难的。你可能在后面了解关于 set 的章节后会对如何实现它有更好的主意。

  3. 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

 

 

 

 

 

 

 

 

 

 

 

 

分享到:
评论

相关推荐

    Write Yourself a Scheme in 48 Hours

    Write a Scheme interpreter in Haskell step by step.

    Teach Yourself Scheme in Fixnum Days

    《Teach Yourself Scheme in Fixnum Days》是一本关于Scheme编程语言的自学教程,本书内容涵盖了从基础到高级的多个知识点,致力于让读者在有限的天数内掌握Scheme编程。在进行知识点梳理之前,我们先对文档内容进行...

    Teach.Yourself.Scheme.in.Fixnum.Days

    《Teach Yourself Scheme in Fixnum Days》是一本由Dorai Sitaram编写的经典书籍,旨在教授读者如何在有限的时间内掌握Scheme编程语言。Scheme是Lisp家族的一种方言,以其简洁性和灵活性而著称,是计算机科学教育和...

    Mit.Press-Teach.Yourself.Scheme.pdf (英文)

    《Teach Yourself Scheme in Fixnum Days》是一本详尽的教程,旨在帮助读者在有限的时间内掌握Scheme语言的基础及进阶知识。此书由Dorai Sitaram撰写,并且在网络上部分中文翻译已经存在(参考链接:...

    Programming In Scheme

    Chapters are organized as a series of groups, or "layers," each of which advances the reader to a new level in Scheme. The first layer (chapters 2-7) introduces Scheme procedures - how to define, use,...

    write-yourself-a-scheme-no-compiler-errors:与“Write Yourself a Scheme”相关的源代码简单地修改为使用最近的 ghcs 编译

    编写自己的方案无编译器错误“为自己编写一个计划”教程,其中包含一些微不足道的修改。 涉及 Read 类型类的错误非常令人分心; 我已经在listing7.hs、listing8.hs 等中留下了关于重叠模式匹配的警告,因为它只是一...

    scheme语言学习资料集合

    scheme语言相关的学习资料: guide_racket_scheme.pdf Lisp之根源.pdf Racket图文教程.pdf scheme-primer.pdf ...The_Little_Schemer.pdf ...Write_Yourself_a_Scheme_in_48_Hours.pdf The+Seasoned+Schemer.pdf

    write-yourself-a-scheme:48小时内为自己编写计划的游乐场

    标题 "write-yourself-a-scheme:48小时内为自己编写计划的游乐场" 指的是一项挑战,旨在引导参与者在48小时内学习并实现Scheme语言的一个简易解释器。这是一个编程项目,通过它,开发者可以深入理解编程语言的内部...

    A Preprocessing Scheme for High-Cardinality Categorical Attributes

    "A Preprocessing Scheme for High-Cardinality Categorical Attributes"这个主题探讨的就是如何有效地处理这类问题。 一、高基数类别变量的挑战 1. 维度灾难:高基数会导致数据的维度增加,这可能会引起过拟合,...

    (How to Write a (Lisp) Interpreter (in Python))中文版(包括上下篇)

    (How to Write a (Lisp) Interpreter (in Python))和(An ((Even Better) Lisp) Interpreter (in Python))的翻译,对解释器实现原理和函数式编程敢兴趣的可以下载看看!

    A novel color image watermarking scheme in nonsampled contourlet-domain

    ### 一种非采样轮廓域中的新型彩色图像水印方案 #### 概述 本文介绍了一种基于非采样轮廓变换(NSCT)和支持向量回归(SVR)技术的新型彩色图像水印算法。该算法针对几何畸变攻击具有良好的抵抗能力,能够在保持...

    Dybvig. The Scheme Programming Language

    This thoroughly updated edition of The Scheme Programming Language provides an introduction to Scheme and a definitive reference for standard Scheme, presented in a clear and concise manner....

    scheme语言的解释器scheme48

    scheme语言的解释器scheme48

    scheme_in_forth.tar_in_scheme_forth_源码

    标题 "scheme_in_forth.tar_in_scheme_forth_源码" 提供的信息表明,这是一个关于用Forth语言实现Scheme解释器的项目。Forth是一种结构简单、效率高的编程语言,而Scheme是Lisp家族的一种方言,以其简洁的语法和强大...

    A_Reliale A-MSDU Frame Aggregation scheme in 802.11n

    针对802.11n的聚合机制,提出一种解决方法:In this paper, we proposed an A-MSDU frame aggregation with sub-frame integrity check and retransmission at the MSDU level without altering the original MAC ...

    A_Tour_of_Scheme_in_Gambit

    《A Tour of Scheme in Gambit》是一份详尽的指南,旨在介绍如何在Gambit-C解释器中使用Scheme语言进行编程。这份文档由Mikael More撰写,并且允许免费无限传播,前提是保留原有的版权信息。文档提供了PDF、HTML和...

    scheme实现唤醒外部app

    3. **用户体验**:当用户设备上没有注册对应的APP时,scheme链接通常会导致错误提示,需要有合适的处理策略,如提供替代方案或下载链接。 4. **竞品冲突**:为了避免与其他应用的scheme冲突,建议选择具有独特性的...

Global site tag (gtag.js) - Google Analytics