Skip to main content

第 13 章:单子解析(Monadic parsing)

原文:Graham Hutton, Programming in Haskell, Second Edition, Chapter 13。维护者已确认本书可翻译并发布用于学习研究。

本章展示如何使用单子实现解析器。我们先解释什么是解析器以及它们为什么有用,然后说明解析器如何自然地看作函数,接着介绍一系列用于编写解析器的基本原语和派生函数,最后开发一个算术表达式解析器和一个交互式计算器。

13.1 什么是解析器?(What is a parser?)

解析器是这样一种程序:它接收一串字符作为输入,并产生某种形式的树,使这个字符串的句法结构显式呈现出来。例如,给定字符串 2*3+4,一个算术表达式解析器可能会产生下面形式的树,其中数字出现在叶子处,运算符出现在节点处:

      +
/ \
* 4
/ \
2 3

这棵树的结构显式表明:+* 都是带有两个参数的运算符,并且 * 的优先级高于 +

解析器是计算中的重要主题,因为大多数真实程序都会使用解析器预处理其输入。例如,计算器程序会在求值之前解析数值表达式,而 GHC 系统会在执行 Haskell 程序之前解析它们。每种情况下,把输入结构显式化都会大大简化后续处理。例如,一旦一个数值表达式被解析成上面这样的树结构,求值就很直接。

13.2 解析器作为函数(Parsers as functions)

在 Haskell 中,解析器可以很自然地直接看作一个函数:它接收字符串并产生树。因此,给定一个合适的树类型 Tree,解析器的概念可以表示为类型 String -> Tree 的函数。我们用下面的声明把它简写为 Parser

type Parser = String -> Tree

不过,一般来说,解析器并不总会消耗其整个参数字符串。例如,一个数字解析器可能被应用到一个由数字后接单词组成的字符串。为此,我们把解析器类型推广为也返回参数字符串中未被消耗的部分:

type Parser = String -> (Tree,String)

类似地,解析器也并不总会成功。例如,一个数字解析器可能被应用到一个由单词组成的字符串。为了处理这种情况,我们进一步把解析器类型推广为返回结果列表,并约定空列表表示失败,单元素列表表示成功:

type Parser = String -> [(Tree,String)]

返回列表还打开了返回多个结果的可能性,如果参数字符串可以用多种方式解析的话。不过,为了简单起见,我们只考虑最多返回一个结果的解析器。

最后,不同解析器很可能会返回不同种类的树,或者更一般地说,返回任意种类的值。例如,一个数字解析器可能会返回一个整数值。因此,从具体结果类型 Tree 中抽象出来,并把它变成 Parser 类型的参数,会很有用:

type Parser a = String -> [(a,String)]

总结来说,这个声明表明:类型为 a 的解析器是一个函数,它接收输入字符串并产生结果列表,其中每个结果都是一对值,由类型为 a 的结果值和输出字符串组成。解析器类型也可以用 Dr. Seuss 风格的押韵来读:

A parser for things
Is a function from strings
To lists of pairs
Of things and strings

最后注意,解析器的类型 String -> [(a,String)] 与上一章中状态转换器的类型 State -> (a,State) 类似,其中被操作的状态是字符串。关键区别是,解析器还可能通过返回结果列表而失败,而状态转换器总会返回单个结果。这样,解析器可以看作状态转换器的一种推广形式。

13.3 基本定义(Basic definitions)

首先导入两个标准库,它们分别提供本实现中会用到的应用函子和字符相关函数:

import Control.Applicative
import Data.Char

为了让 Parser 类型可以成为类的实例,先用 newtype 重新定义它,并引入一个名为 P 的哑构造子:

newtype Parser a = P (String -> [(a,String)])

这种类型的解析器随后可以通过一个简单移除哑构造子的函数应用到输入字符串:

parse :: Parser a -> String -> [(a,String)]
parse (P p) inp = p inp

第一个解析原语称为 item。如果输入字符串为空,它会失败;否则,它以第一个字符作为结果值成功:

item :: Parser Char
item = P (\inp -> case inp of
[] -> []
(x:xs) -> [(x,xs)])

解析器 item 是基本构件,所有其他从输入中消耗字符的解析器最终都由它构造出来。下面两个例子展示了它的行为:

> parse item ""
[]

> parse item "abc"
[('a',"bc")]

13.4 顺序组合解析器(Sequencing parsers)

现在把解析器类型做成函子、应用函子和单子类的实例,从而可以使用 do 记法按顺序组合解析器。这些声明与状态转换器的声明类似,只是还需要考虑解析器可能失败的情况。第一步是把 Parser 类型做成函子:

instance Functor Parser where
-- fmap :: (a -> b) -> Parser a -> Parser b
fmap g p = P (\inp -> case parse p inp of
[] -> []
[(v,out)] -> [(g v,out)])

也就是说,如果解析器成功,fmap 会把函数应用到解析器的结果值上;否则传播失败。例如:

> parse (fmap toUpper item) "abc"
[('A',"bc")]

> parse (fmap toUpper item) ""
[]

函数 toUpper 由库 Data.Char 提供。接着,可以如下把 Parser 类型做成应用函子:

instance Applicative Parser where
-- pure :: a -> Parser a
pure v = P (\inp -> [(v,inp)])

-- <*> :: Parser (a -> b) -> Parser a -> Parser b
pg <*> px = P (\inp -> case parse pg inp of
[] -> []
[(g,out)] -> parse (fmap g px) out)

在这个例子中,pure 把一个值转换为总是成功的解析器,其结果就是这个值,并且不消耗任何输入字符串:

> parse (pure 1) "abc"
[(1,"abc")]

接着,<*> 把一个返回函数的解析器应用到一个返回参数的解析器上,得到一个返回函数应用结果的解析器,而且只有所有组成部分都成功时才成功。例如,现在可以用应用风格定义一个解析器,它消耗三个字符、丢弃第二个,并把第一个和第三个作为一对返回:

three :: Parser (Char,Char)
three = pure g <*> item <*> item <*> item
where g x y z = (x,z)

例如:

> parse three "abcdef"
[(('a','c'),"def")]

> parse three "ab"
[]

注意,应用函子机制会自动确保上面的解析器在输入字符串太短时失败,而不需要我们自己检测或管理这种情况。最后,把 Parser 类型做成单子:

instance Monad Parser where
-- (>>=) :: Parser a -> (a -> Parser b) -> Parser b
p >>= f = P (\inp -> case parse p inp of
[] -> []
[(v,out)] -> parse (f v) out)

也就是说,如果把解析器 p 应用到输入字符串 inp 时失败,那么解析器 p >>= f 会失败;否则,它会把函数 f 应用到结果值 v 上,得到另一个解析器 f v,然后把这个解析器应用到第一个解析器产生的输出字符串 out 上,得到最终结果。

因为 Parser 是一个单子类型,现在可以使用 do 记法来顺序执行解析器并处理它们的结果值。例如,解析器 three 可以用另一种方式定义如下:

three :: Parser (Char,Char)
three = do x <- item
item
z <- item
return (x,z)

回忆一下,单子函数 return 只是应用函数 pure 的另一个名字,在这里它会构造总是成功的解析器。

本章余下部分采用单子化方法,用 do 记法编写解析器,并且通常避免在解析器上使用函子式的 fmap 和应用函子式的 <*> 原语。不过,有些用户更喜欢以应用风格编写解析器,而且使用应用函子方法有时有利于优化解析器性能。

13.5 作出选择(Making choices)

do 记法会按顺序组合解析器,序列中每个解析器产生的输出字符串会成为下一个解析器的输入字符串。组合解析器的另一种自然方式是:把一个解析器应用到输入字符串上,如果它失败,则改为把另一个解析器应用到相同输入上。现在考虑如何为解析器定义这样的选择运算符。

在两个候选之间作选择并非解析器特有,而是可以推广到一系列应用类型上。库 Control.Applicative 中的下面类声明捕获了这个概念:

class Applicative f => Alternative f where
empty :: f a
(<|>) :: f a -> f a -> f a

也就是说,为了让一个应用函子成为 Alternative 类的实例,它必须支持指定类型的 empty<|> 原语。这个类还提供两个其他原语,下一节会讨论。直觉上,empty 表示已经失败的候选,而 <|> 是该类型上合适的选择运算符。这两个原语还需要满足下面的恒等律和结合律:

empty <|> x       = x
x <|> empty = x
x <|> (y <|> z) = (x <|> y) <|> z

Alternative 类型的动机例子是 Maybe 类型。对它来说,empty 由失败值 Nothing 给出,而 <|> 如果第一个参数成功就返回它,否则返回第二个参数:

instance Alternative Maybe where
-- empty :: Maybe a
empty = Nothing

-- (<|>) :: Maybe a -> Maybe a -> Maybe a
Nothing <|> my = my
(Just x) <|> _ = Just x

Parser 类型的实例是这个想法的自然扩展:empty 是无论输入字符串为何都总是失败的解析器,而 <|> 是一个选择运算符。如果第一个解析器在输入上成功,它就返回第一个解析器的结果;否则,把第二个解析器应用到同一个输入上:

instance Alternative Parser where
-- empty :: Parser a
empty = P (\inp -> [])

-- (<|>) :: Parser a -> Parser a -> Parser a
p <|> q = P (\inp -> case parse p inp of
[] -> parse q inp
[(v,out)] -> [(v,out)])

例如:

> parse empty "abc"
[]

> parse (item <|> return 'd') "abc"
[('a',"bc")]

> parse (empty <|> return 'd') "abc"
[('d',"abc")]

最后注意,库文件 Control.Monad 提供了一个名为 MonadPlus 的类,它对单子类型扮演与 Alternative 相同的角色,其原语称为 mzeromplus。不过,我们更倾向于为解析器使用应用函子选择原语 empty<|>,因为它们与后面讨论的文法中对应符号相似。

13.6 派生原语(Derived primitives)

现在已经有三个基本解析器:item 在输入字符串非空时消耗单个字符;return v 总是以结果值 v 成功;empty 总是失败。结合顺序执行和选择,这些原语可以用于定义若干其他有用解析器。首先,定义一个单字符解析器 sat p,它识别满足谓词 p 的字符:

sat :: (Char -> Bool) -> Parser Char
sat p = do x <- item
if p x then return x else empty

使用 sat 以及库 Data.Char 中的合适谓词,现在可以定义解析单个数字、小写字母、大写字母、任意字母、字母数字字符和特定字符的解析器:

digit :: Parser Char
digit = sat isDigit

lower :: Parser Char
lower = sat isLower

upper :: Parser Char
upper = sat isUpper

letter :: Parser Char
letter = sat isAlpha

alphanum :: Parser Char
alphanum = sat isAlphaNum

char :: Char -> Parser Char
char x = sat (== x)

例如:

> parse (char 'a') "abc"
[('a',"bc")]

接着,使用 char 可以为字符串 xs 定义解析器 string xs,并把字符串本身作为结果值返回:

string :: String -> Parser String
string [] = return []
string (x:xs) = do char x
string xs
return (x:xs)

也就是说,空字符串总是可以解析;而对于非空字符串,先解析第一个字符,再递归解析剩余字符,并把字符串作为结果值返回。注意,string 只有在完整目标字符串都从输入中被消耗时才成功。例如:

> parse (string "abc") "abcdef"
[("abc","def")]

> parse (string "abc") "ab1234"
[]

接下来的两个解析器 many psome p 会尽可能多次地应用解析器 p,直到它失败为止,并把每次成功应用 p 得到的结果值放入列表返回。这两个重复原语的区别在于,many 允许对 p 应用零次或多次,而 some 要求至少成功应用一次。例如:

> parse (many digit) "123abc"
[("123","abc")]

> parse (many digit) "abc"
[("","abc")]

> parse (some digit) "abc"
[]

事实上,我们不需要自己定义 manysome,因为 Alternative 类已经提供了合适的默认定义:

class Applicative f => Alternative f where
empty :: f a
(<|>) :: f a -> f a -> f a
many :: f a -> f [a]
some :: f a -> f [a]

many x = some x <|> pure []
some x = pure (:) <*> x <*> many x

注意,这两个新函数通过相互递归来定义。特别地,上面对 many x 的定义说明,x 要么至少应用一次,要么完全不应用;而对 some x 的定义说明,x 可以先应用一次,然后再应用零次或多次,并把结果放入列表返回。这些函数为任何作为该类实例的应用类型提供,但主要用于解析器。

使用 manysome 后,可以定义标识符、自然数和空白的解析器。标识符(变量名)由一个小写字母后跟零个或多个字母数字字符组成;自然数由一个或多个数字组成;空白由零个或多个空格、制表符和换行字符组成:

ident :: Parser String
ident = do x <- lower
xs <- many alphanum
return (x:xs)

nat :: Parser Int
nat = do xs <- some digit
return (read xs)

space :: Parser ()
space = do many (sat isSpace)
return ()

例如:

> parse ident "abc def"
[("abc"," def")]

> parse nat "123 abc"
[(123," abc")]

> parse space " abc"
[((),"abc")]

注意,nat 会把读到的数字转换为整数,而 space 返回空元组 () 作为哑结果值,这反映出空白的具体细节通常并不重要。最后,使用 nat 可以很直接地定义整数值解析器:

int :: Parser Int
int = do char '-'
n <- nat
return (-n)
<|> nat

例如:

> parse int "-123 abc"
[(-123," abc")]

13.7 处理空白(Handling spacing)

大多数真实解析器都允许在输入字符串中的基本词法单元周围自由使用空白。例如,GHC 会以相同方式解析字符串 1+21 + 2。为了处理这类空白,定义一个新原语:它会在应用词法单元解析器之前和之后忽略任意空白:

token :: Parser a -> Parser a
token p = do space
v <- p
space
return v

使用 token 后,可以定义忽略标识符、自然数、整数和特殊符号周围空白的解析器:

identifier :: Parser String
identifier = token ident

natural :: Parser Int
natural = token nat

integer :: Parser Int
integer = token int

symbol :: String -> Parser String
symbol xs = token (string xs)

例如,使用这些原语后,可以如下定义一个解析非空自然数列表的解析器,它会忽略词法单元周围的空白:

nats :: Parser [Int]
nats = do symbol "["
n <- natural
ns <- many (do symbol ","
natural)
symbol "]"
return (n:ns)

这个定义说明,这样的列表以左方括号和一个自然数开始,后跟零个或多个逗号和自然数,并以右方括号结束。注意,nats 只有在精确消耗这种格式的完整列表时才成功:

> parse nats " [1, 2, 3] "
[([1,2,3],"")]

> parse nats "[1,2,]"
[]

13.8 算术表达式(Arithmetic expressions)

本章最后给出两个关于算术表达式的扩展编程示例。第一个例子考虑一种简单表达式,它由自然数通过加法、乘法和括号构成。我们假设加法和乘法都向右结合,并且乘法优先级高于加法。例如,2+3+4 表示 2+(3+4),而 2*3+4 表示 (2*3)+4

语言的句法结构可以用文法(grammar)这一数学概念来形式化。文法是一组规则,描述该语言的字符串如何构造。例如,我们的算术表达式语言可以由下面两条规则定义:

expr ::= expr + expr | expr * expr | (expr) | nat
nat ::= 0 | 1 | 2 | ...

第一条规则说明,表达式要么是两个表达式的加法或乘法,要么是带括号的表达式,要么是自然数。第二条规则说明,自然数要么是零、一、二,等等。

例如,使用上面的文法,表达式 2*3+4 的构造可以用下面的解析树表示,其中表达式中的词法单元出现在叶子处,而用于构造表达式的文法规则形成分支结构:

      expr
/ | \
expr + expr
/ | \ \
expr * expr nat
| | |
nat nat 4
| |
2 3

这棵树的结构显式表明:2*3+4 可以由两个表达式相加构造出来,第一个表达式由两个进一步的表达式相乘给出,而这两个表达式分别由数字二和三给出;第二个表达式由数字四给出。不过,这个文法也允许此例的另一棵可能解析树,对应于把表达式错误解释为 2*(3+4)

问题在于,我们的表达式文法没有考虑乘法优先级高于加法这一事实。不过,很容易通过修改文法来解决这个问题:为每个优先级层次设置单独规则,其中加法处于最低优先级,乘法处于中间优先级,括号和数字处于最高优先级:

expr   ::= expr + expr | term
term ::= term * term | factor
factor ::= (expr) | nat
nat ::= 0 | 1 | 2 | ...

使用这个新文法,2*3+4 的确只有一棵解析树,对应于把表达式正确解释为 (2*3)+4

现在已经处理了优先级问题,但文法仍未考虑加法和乘法向右结合这一事实。例如,表达式 2+3+4 目前有两棵可能的解析树,分别对应 (2+3)+42+(3+4)。不过,只需把加法和乘法规则修改为只在右参数上递归,而不是在两个参数上递归,就可以很容易修正这一点:

expr ::= term + expr | term
term ::= factor * term | factor

使用这些新规则后,2+3+4 现在只有一棵解析树,对应于把表达式正确解释为 2+(3+4)。事实上,我们的表达式文法现在已经无歧义,也就是说,每个良构表达式都恰好有一棵解析树。

对文法的最后一次修改是简化。考虑规则 expr ::= term + expr | term,它说明表达式要么是一个项加一个表达式,要么是一个项。换句话说,表达式总以一个项开始,后面可以跟一个加号和一个表达式,也可以什么都不跟。因此,表达式规则可以简化为 expr ::= term (+ expr | epsilon),其中符号 epsilon 表示空字符串。以类似方式简化项的规则,得到最终文法:

expr   ::= term   (+ expr | epsilon)
term ::= factor (* term | epsilon)
factor ::= (expr) | nat
nat ::= 0 | 1 | 2 | ...

现在可以很直接地把这个文法翻译为表达式解析器,只需用前面介绍的解析原语重写规则即可。文法中的顺序执行翻译为 do 记法,选择 | 翻译为 <|> 运算符,空字符串 epsilon 变成空解析,像 +* 这样的特殊符号用 symbol 函数处理,而自然数用 natural 原语解析:

expr :: Parser Int
expr = do t <- term
do symbol "+"
e <- expr
return (t + e)
<|> return t

term :: Parser Int
term = do f <- factor
do symbol "*"
t <- term
return (f * t)
<|> return f

factor :: Parser Int
factor = do symbol "("
e <- expr
symbol ")"
return e
<|> natural

注意,上面每个解析器都会返回所解析表达式的整数值,而不是某种形式的表达式树。使用我们的方法,把解析和求值合并在一起很容易。例如,expr 先解析一个整数值为 t 的项,然后解析一个加号后接整数值为 e 的表达式并返回值 t + e,或者什么都不继续解析而直接返回值 t

最后,使用 expr 定义一个函数,返回解析并求值某个表达式得到的整数值。为了处理未消耗输入和无效输入这两种情况,使用库函数 error :: String -> a,它会显示错误消息然后终止程序:

eval :: String -> Int
eval xs = case (parse expr xs) of
[(n,[])] -> n
[(_,out)] -> error ("Unused input " ++ out)
[] -> error "Invalid input"

例如:

> eval "2*3+4"
10

> eval "2*(3+4)"
14

> eval "2*3^4"
*** Exception: Unused input ^4

> eval "one plus two"
*** Exception: Invalid input

13.9 计算器(Calculator)

上一节开发了一个算术表达式解析器。现在把这个例子扩展为一个简单计算器程序,允许用户通过键盘交互式输入表达式,并在屏幕上显示这些表达式的值。我们的计算器会处理由整数值以及加法、减法、乘法、除法和括号构成的表达式。一个适合这类表达式的解析器 expr :: Parser Int 可以通过完成本章的某个练习得到。

首先考虑计算器的用户界面。为此使用第 10 章中的输入输出辅助函数 clswriteatgotogetCh。第一步,把计算器外框定义为字符串列表:

box :: [String]
box = ["+---------------+",
"| |",
"+---+---+---+---+",
"| q | c | d | = |",
"+---+---+---+---+",
"| 1 | 2 | 3 | + |",
"+---+---+---+---+",
"| 4 | 5 | 6 | - |",
"+---+---+---+---+",
"| 7 | 8 | 9 | * |",
"+---+---+---+---+",
"| 0 | ( | ) | / |",
"+---+---+---+---+"]

计算器上的前四个按钮 qcd= 分别允许用户退出、清空显示、删除一个字符,以及求值表达式;剩余十六个按钮允许用户输入表达式。

还把计算器上的按钮定义为字符列表,其中包含外框上出现的二十个标准按钮,以及一些为了灵活性而允许的额外字符,即 QCD、空格、Escape、Backspace、Delete 和换行:

buttons :: String
buttons = standard ++ extra
where
standard = "qcd=123+456-789*0()/"
extra = "QCD \ESC\BS\DEL\n"

使用列表推导式和按顺序执行一列输入输出动作的库函数,可以定义一个动作,把计算器外框显示在屏幕左上角:

showbox :: IO ()
showbox = sequence_ [writeat (1,y) b | (y,b) <- zip [1..] box]

用户界面的最后一部分是定义一个函数,在计算器显示区域中显示字符串。它先清空显示区域,然后显示字符串的最后十三个字符:

display xs = do writeat (3,2) (replicate 13 ' ')
writeat (3,2) (reverse (take 13 (reverse xs)))

这样,如果用户从字符串中删除字符,它们会自动从显示中移除;如果用户输入超过十三个字符,显示看起来会向左滚动。

计算器本身由函数 calc 控制,它显示当前字符串,然后从键盘读取一个字符而不回显。如果这个字符是有效按钮,就处理它;否则响铃表示错误,并以相同字符串继续:

calc :: String -> IO ()
calc xs = do display xs
c <- getCh
if elem c buttons then
process c xs
else
do beep
calc xs

上面使用的动作 beep :: IO () 定义为 beep = putStr "\BEL"。接着,函数 process 接收一个有效字符和当前字符串,并根据该字符执行合适动作:

process :: Char -> String -> IO ()
process c xs | elem c "qQ\ESC" = quit
| elem c "dD\BS\DEL" = delete xs
| elem c "=\n" = eval xs
| elem c "cC" = clear
| otherwise = press c xs

下面分别考虑五种可能动作:

  • 退出会把光标移动到计算器外框下方并终止:
quit :: IO ()
quit = goto (1,14)
  • 删除字符在当前字符串为空时没有效果,否则从字符串中移除最后一个字符:
delete :: String -> IO ()
delete [] = calc []
delete xs = calc (init xs)
  • 求值会显示解析并求值当前字符串的结果,如果这个过程不成功则响铃:
eval :: String -> IO ()
eval xs = case parse expr xs of
[(n,[])] -> calc (show n)
_ -> do beep
calc xs
  • 清空显示会把当前字符串重置为空:
clear :: IO ()
clear = calc []
  • 任何其他字符都会追加到当前字符串末尾:
press :: Char -> String -> IO ()
press c xs = calc (xs ++ [c])

最后,定义一个运行计算器的顶层函数:先清屏,显示外框,并从空显示开始:

run :: IO ()
run = do cls
showbox
clear

13.10 章注(Chapter remarks)

包含本章解析原语的库文件可以从本书网站获得。关于单子化解析方法的更多细节可参见文献 [21, 22],本章也是基于这些文献写成。文献 [23] 给出了更详细的文法入门,而 Haskell 中构建解析器的更复杂方法见文献 [24, 25]。把解析器类型读作押韵诗的方式来自 Fritz Ruehr。

13.11 练习(Exercises)

  1. 定义解析器 comment :: Parser (),用于解析普通 Haskell 注释。这类注释以符号 -- 开始,并一直延伸到当前行末尾,行末由控制字符 '\n' 表示。
  2. 使用第二版算术表达式文法,画出表达式 2+3+4 的两棵可能解析树。
  3. 使用第三版算术表达式文法,画出表达式 2+32*3*4(2+3)+4 的解析树。
  4. 解释为什么算术表达式文法的最后一次简化会对所得解析器效率产生巨大影响。提示:先考虑如果没有进行这一步简化,一个只包含单个数字的表达式会如何被解析。
  5. 为算术表达式定义合适的类型 Expr,并修改表达式解析器,使其类型为 expr :: Parser Expr
  6. 扩展解析器 expr :: Parser Int,使其支持减法和除法,并基于下面对文法的修订使用整数值而不是自然数:
expr   ::= term   (+ expr | - expr | epsilon)
term ::= factor (* term | / term | epsilon)
factor ::= (expr) | int
int ::= ... | -1 | 0 | 1 | ...
  1. 进一步扩展算术表达式文法和解析器,使其支持乘方 ^。假设乘方向右结合,优先级高于乘法和除法,但低于括号和数字。例如,2^3*4 表示 (2^3)*4。提示:新的优先级层次需要文法中的一条新规则。

  2. 考虑由自然数和减法运算符构成的表达式,减法假定向左结合。

    a. 直接把这个描述翻译为文法。

    b. 把这个文法实现为解析器 expr :: Parser Int

    c. 这个解析器有什么问题?

    d. 说明如何修复它。提示:使用重复原语 many 和库函数 foldl 重写解析器。

  3. 修改计算器程序,让它指出错误的大致位置,而不只是响铃。可以利用解析器会返回未消耗输入字符串这一事实。

练习 1-4 的解答见附录 A。