链接:https://juejin.cn/post/6910585840255107079
前言
在函数式编程的世界里,抽象
与组合
往往密不可分:多个细粒度抽象通过特定的组合则形成更高粒度的抽象,而后高粒度的抽象又可以被再次组合、不断递进,一步一步地抬升代码抽象的高度。我在工程开发中所感受到的函数式编程的魅力,也正是体现在它强大的抽象能力上。
Parser(解析器)
能分析输入,产生结果。如正则表达式引擎可以解析匹配输入的字符串、JSONSerialization
可帮助 iOS/Mac 开发者将 JSON 解析成 Objective-C 中的 Dictionary。 Parser Combinator
是Parser
的一种实现方式,其基于函数式编程思想,姿态十分优雅。使用它,我们可以非常方便地编写解析逻辑,代码简洁且易于理解。Parser Combinator
在设计上充分体现了组合
的思想,通过运用其多种Combinator(组合子)
,我们可以将若干的细粒度子Parser
组合在一起以得到更粗粒度的Parser
,而后,组合可以继续,抽象度也逐步提升。
在这篇文章中,我将使用 Swift 语言,逐步构建出一套轻量的Parser Combinator
库,然后以此来编写一款简单的解析器。借此文章,我希望读者能和我一起深刻地感受函数式编程的魅力,并且加深对其相关概念的认识,方便日后能在项目工程上写出更优雅、抽象的函数式代码。
因本人技术水平有限,若文章存在谬误,还望大家指正。
模型
在实现Parser Combinator
前,我们先来看看它的基本抽象模型。
基础模型
解析过程会消费输入,产生结果,这里的输入则为字符串。如下图所示,每一小格代表输入串中的一个元素,类型则为字符。Parser
会消费若干元素进行解析处理,而后产生相应的结果;除此之外,Parser
还会输出一个额外的状态:解析后所剩余的输入串,以供接下来的Parser
继续解析处理。
根据以上的描述,我们可使用 Swift 来表示Parser
的类型:
typealias Parser<Value> = (String) -> Result<(Value, String), Error>
复制代码
Parser
在这里被定义成了函数类型,因其解析后输出的结果不定,这里使用了Value
泛型来指代结果的类型。函数的输入参数类型为String
,返回的是Result
。若解析成功,Result
则装载了一个二元组,里面的值分别代表了解析的结果以及剩余的输入串;当解析失败时,错误信息也将通过Result
装载返回。
举个例子,假设现在有一个能将字符串中最长数字前缀解析出来的Parser
:
typealias Parser = (String) -> Result<(Int, String), Error>
当输入字符串"123abc"
时,Parser
输出的则是.success((123, "abc"))
。这里最长数字前缀被解析出来并转成了Int
类型,连同解析后所剩余的输入串一起作为结果返回。
优化模型
以上描述的模型遵循了函数式编程典型的数据不可变+纯函数
特性,也就是说这里没有可变的变量,且函数对于相同的输入仅有唯一的输出。但是这样对于 Swift 来说未免太苛刻了:有时候“可变”能够带来更多便利,另一方面,如果按照上面模型要求,Parser
在解析完后还需要返回剩下的输入串,那么每次解析都会有String
的实例被构建,这样显然对于性能来说不是一个好做法。所以,在实际使用 Swift 来实现Parser
时,我们要对模型进行"Swift 特色"的优化:
typealias Parser<Value> = (Context) -> Result<String, Error>
这个模型将不再遵循数据不可变+纯函数
特性,不过非常适用于 Swift。在这里,Context
是可变的,它将记录目前输入串所解析到的位置,以取代旧模型中直接返回剩余输入串的做法。
接下来的章节将会详细介绍模型中Context
的概念以及Parser Combinator
具体的实现。
实现
下面我们就来使用 Swift 实现这套轻量的Parser Combinator
库。
最近我加了一个iOS新裙,有想交流的可以了解一下,:891 / 488 / 181 里面还分享BAT,阿里面试题、面试经验,讨论技术,裙里资料直接下载就行, 大家一起交流学习!想要学习的直接来,不想的…那就算了吧,反正白嫖的你都不要。
Context
在一开始提到的“基础模型”中,Parser
作为函数类型,参数就是输入串String
,当其解析完成,除了会返回结果值,还会带上剩余的输入串。而在“优化模型”中,解析函数输入参数为Context
,解析完成后只有结果值返回。能够这样优化的原因是我们不必每次解析后都返回剩余的输入串,只需要将输入串当前所解析到的位置做个记录,而这里Context
就负责了这项工作,所以它是可变的
:
public final class Context {
public typealias Stream = String
public typealias Element = Stream.Element
public typealias Index = Stream.Index
public let stream: Stream
public init(stream: Stream) {
self.stream = stream
_cursor = stream.startIndex
}
private var _cursor: Index
}
以上代码,Context
记录了输入串stream
以及当前输入串所解析到的位置_cursor
(私有,所以用下划线命名)。位置的类型为字符串索引Index
,初始值为字符串的开头位置startIndex
。
Context
还对外提供了消费方法:
// MARK: - Iterator
extension Context: IteratorProtocol {
public func next() -> Element? {
let range = stream.startIndex..<stream.endIndex
guard range.contains(_cursor) else {
return nil
}
defer {
stream.formIndex(after: &_cursor)
}
return stream[_cursor]
}
}
这里我们让Context
实现了IteratorProtocol
,每次调用next()
,Context
将通过步进_cursor
从而消费并返回输入串中的一个元素(字符),当输入串在这之前已完全被消费完,这里则返回nil
。
Error
错误处理在解析中是十分必要的,当解析失败时,我们能通过错误信息清楚地了解失败原因。为此我们需要定义好错误的类型:
public struct Error: Swift.Error {
public let stream: Context.Stream
public let position: Context.Index
public let message: String
public init(stream: Context.Stream, position: Context.Index, message: String) {
self.stream = stream
self.position = position
self.message = message
}
}
Error
记录了输入串、输入串当前所解析到的位置以及以字符串类型表示的失败信息。当解析失败时,我们能通过Parser
返回的Result
获取到Error
的实例,从而了解到失败的原因,以及当前解析所在的位置。
为了方便抛出错误,我们可以扩展Context
,增加错误抛出的相关代码:
// MARK: - Error
public extension Context {
func `throw`<T>(_ errorMessage: String) -> Result<T, Error> {
.failure(error(with: errorMessage))
}
func error(with message: Stream) -> Error {
.init(stream: stream, position: _cursor, message: message)
}
}
我们通过Context
内部的数据构建了Error
,这样Context
就可以非常方便地向外抛出错误。
Parser
我们在前面模型章节所提到的Parser
其实是函数类型:
typealias Parser<Value> = (Context) -> Result<String, Error>
但是为了方便后续Combinator
的扩展以及增强代码的可读性,我们将Parser
定义为struct
类型,并把函数直接包装在它体内:
public struct Parser<Value> {
public typealias Result = Swift.Result<Value, Error>
public let parse: (Context) -> Result
public init(_ parse: @escaping (Context) -> Result) {
self.parse = parse
}
}
我们接下来为Parser
扩展一个便捷解析方法:
public extension Parser {
func parse(_ stream: Context.Stream) -> Result {
parse(.init(stream: stream))
}
}
方法parse
虽然名字跟被包装在Parser
里的函数一样,但是入参有所不同:这里入参直接是输入串,方法内部帮我们构造好了Context
,并调用了被包装在Parser
中的函数。使用此方法,我们可以直接输入字符串以得到解析结果。
初尝 Parser
OK,目前我们基本上已经定义好了Parser Combinator
库核心的几个数据结构,接下来先试玩一下:
public let element = Parser<Context.Element> {
if let element = $0.next() {
return .success(element)
} else {
return $0.throw("unexpected end of stream")
}
}
public let endOfStream = Parser<()> {
if $0.next() == nil {
return .success(())
} else {
return $0.throw("expecting end of stream")
}
}
以上我们定义了两个Parser
:
element
:在解析时通过Context
消费一个输入串元素(字符),如果输入串在之前已经被消费完了,则返回错误。endOfStream
:跟element
一样会通过Context
消费输入串,但是它预期的是输入串在之前已经被消费完毕,否则返回错误。
我们输入字符串,试运行一下这两个Parser
:
let inputOne = "hello world!"
let inputTwo = ""
let inputOneResultOfElement = element.parse(inputOne)
let inputTwoResultOfElement = element.parse(inputTwo)
let inputOneResultOfEndOfStream = endOfStream.parse(inputOne)
let inputTwoResultOfEndOfStream = endOfStream.parse(inputTwo)
输出:
inputOneResultOfElement: success("h")
inputTwoResultOfElement: failure(Error(stream: "", position: Swift.String.Index(_rawBits: 1), message: "unexpected end of stream"))
inputOneResultOfEndOfStream: failure(Error(stream: "hello world!", position: Swift.String.Index(_rawBits: 65793), message: "expecting end of stream"))
inputTwoResultOfEndOfStream: success()
不错,这两个Parser
符合预期地解析了输入。
Combinator
前言章节提到:Parser Combinator
在设计上充分体现了组合
的思想。借助Combinator(组合子)
,Parser
被不断进行组合,抽象度也逐步提升。为此,我们需要定义各种Combinator
。
Monad
在这里,我们用静态方法just
指代Monad
的return
函数,用flatMap
指代bind (>>=)
函数:
public extension Parser {
static func just(_ value: Value) -> Parser<Value> {
.init { _ in .success(value) }
}
func flatMap<O>(_ transform: @escaping (Value) -> Parser<O>) -> Parser<O> {
.init {
switch self.parse($0) {
case .success(let value):
return transform(value).parse($0)
case .failure(let error):
return .failure(error)
}
}
}
}
-
经
just
方法返回的Parser
,其解析逻辑不消费任何输入串,只会将just
的输入参数直接包装到Result
中,作为解析结果返回。 -
在
flatMap
里,我们会先运行当前Parser
的解析逻辑,若解析成功,解析的结果将作为参数传入flatMap
的闭包参数transform
中;闭包调用将返回一个新的Parser
,而后这个新Parser
的解析逻辑将会继续运行;若当前Parser
解析失败,错误信息将直接从flatMap
返回。
just
和flatMap
主要是针对解析成功后的结果进行映射,对于解析失败的情况,我们也能写出相对应的Combinator
:
public extension Parser {
static func error<T>(_ error: Error) -> Parser<T> {
.init { _ in .failure(error) }
}
static func `throw`(_ errorMessage: String) -> Parser<Value> {
.init { $0.throw(errorMessage) }
}
func flatMapError(_ transform: @escaping (Error) -> Parser<Value>) -> Parser<Value> {
.init {
switch self.parse($0) {
case .success(let value):
return .success(value)
case .failure(let error):
return transform(error).parse($0)
}
}
}
}
这里代码与上面的思想相同,就不再展开细说了。
Functor
完成Monad
后,我们就可以很方便地实现Functor
了,这里的核心就是map
方法:
public extension Parser {
func map<O>(_ transform: @escaping (Value) -> O) -> Parser<O> {
flatMap {
.just(transform($0))
}
}
}
借助Monad
的flatMap
和just
方法,map
就能如此简单地实现了。
map
方法做的事情是对当前Parser
解析后的结果做映射变换,就类似于 Swift 中对数组进行map
操作:let arr = [1,2,3,4].map { $0 + 1 }
。
和flatMap
类似,map
亦可编写针对错误情况的版本:
public extension Parser {
func mapError(_ transform: @escaping (Error) -> Error) -> Parser<Value> {
flatMapError {
.error(transform($0))
}
}
}
利用Monad
和Functor
的特性,我们可以结合之前提到的element
写出一个简单的用于消费两个字符的Parser
:
let twoElements = element.flatMap { first in
element.map { second in
String([first, second])
}
}
let result = twoElements.parse("hello")
以上代码 result 值为:result: success("he")
。
这里出现的两个element
分别解析出了结果中对应的两个字符,map
闭包则对解析得到的字符数组映射成了String
。
过滤
Swift 数组具有filter
方法,可根据谓词过滤掉不想要的元素。Parser
也可以做到类似的效果,我们可以直接为Parser
定义一个同名的filter
方法:
public extension Parser {
func filter(_ label: String, predicate: @escaping (Value) -> Bool) -> Parser<Value> {
flatMap {
predicate($0) ? .just($0) : .throw(label)
}
}
}
filter
接受两个参数,第一个以字符串为类型,表示谓词判断失败时,返回的错误中所携带的提示信息;第二个则为谓词闭包,通过返回Bool
以表示结果是否符合预期。
利用Monad
的特性我们可以很方便地实现filter
:通过在flatMap
中调用谓词闭包以得知结果是否符合预期,如果符合,结果会直接返回,否则将抛出错误。
我们可以基于filter
对Parser
进行扩展,以此实现两个便携Combinator
:
public extension Parser {
func equal(to value: Value) -> Parser<Value> where Value: Equatable {
filter("expecting \(value)") { $0 == value }
}
func notEqual(to value: Value) -> Parser<Value> where Value: Equatable {
filter("unexpected \(value)") { $0 != value }
}
}
以上两个Combinator
作用于结果值遵循了Equatable
协议的Parser
上,期望结果值等于/不等预期值。
下面就来试试:
let str = "hello"
let resultOne = element.equal(to: "h").parse(str)
let resultTwo = element.notEqual(to: "h").parse(str)
结果值分别为:
resultOne: success("h")
resultTwo: failure(Error(stream: "hello", position: Swift.String.Index(_rawBits: 65793), message: "unexpected h"))
因为element.equal(to: Character)
和element.notEqual(to: Character)
这两种形式太常见了,我们可以直接将它们封装成函数:
public func char(_ char: Character) -> Parser<Character> {
element.equal(to: char)
}
public func notChar(_ char: Character) -> Parser<Character> {
element.notEqual(to: char)
}
之前的代码就可以改写为:
let str = "hello"
let resultOne = char("h").parse(str)
let resultTwo = notChar("h").parse(str)
有时候我们也希望对接下来的输入串进行字符串匹配,基于上面的char
可以很容易地进行扩展:
public func string(_ string: String) -> Parser<String> {
.init {
for element in string {
if case .failure(let error) = char(element).parse($0) {
return .failure(error)
}
}
return .success(string)
}
}
对string
进行尝试:
let parser = string("Hello")
let resultOne = parser.parse("Helao")
得到的结果:
resultOne: failure(Error(stream: "Helao", position: Swift.String.Index(_rawBits: 262401), message: "expecting l"))
resultTwo: success("Hello")
要左不要右、要右不要左
很多时候我们希望Parser
能成功解析输入,但它的输出结果我们并不关心,从而选择忽略。如解析字符串字面量"Hello World"
,我们要的内容只是双引号"
对里面的字符串,但是解析成对出现的双引号也是必不可少的。
这里我们可以利用Monad
和Functor
的特性来实现要左不要右、要右不要左的Combinator
:
public extension Parser {
func usePrevious<O>(_ next: Parser<O>) -> Parser<Value> {
flatMap { previous in next.map { _ in previous } }
}
func useNext<O>(_ next: Parser<O>) -> Parser<O> {
flatMap { _ in next }
}
func between<L, R>(_ left: Parser<L>, _ right: Parser<R>) -> Parser<Value> {
left.useNext(self).usePrevious(right)
}
}
对于Parser
:left
和right
,有:
left.usePrevious(right)
:输入串会依次经过left
和right
的解析逻辑,但最后会忽略right
的解析结果,只返回left
的结果。left.useNext(right)
:输入串会依次经过left
和right
的解析逻辑,但最后会忽略left
的解析结果,只返回right
的结果。aParser.bwtween(left, right)
则会依次经过left
、aParser
、right
的解析逻辑,但最后会忽略left
、right
的解析结果,只返回aParser
的结果。
现在我们就可以编写一个用于解析以成对单引号包裹的字符字面量Parser
了:
public let charLiteral = element.between(char("'"), char("'"))
let str = "'A'"
let result = charLiteral.parse(str)
解析后得到的result
值为success("A")
。
选择
有时候Parser
在解析失败后,我们希望能有另一个Parser
接替工作重新解析。就像 Swift 的??
运算符一样,当左侧值非空,运算符值直接返回左侧值,否则将返回右侧值。以上场景正对应了函数式编程概念:Alternative
。
在为Parser
实现Alternative
前,我们先来扩展一下Error
:
public extension Error {
func merge(with another: Error) -> Error {
position > another.position ? self : another
}
}
这里为Error
添加的方法merge
用于合并错误,它会从两个Error
中选取位置最远的那个然后返回。
为什么要增加这样一个方法?考虑到存在场景:我们希望当aParser
解析失败后让bParser
重新尝试,但aParser
和bParser
最终都解析失败了,那么此时就会有两个Error
产生,但是最终究竟选取哪个Error
返回呢?merge
就是帮我们做这个决策:选取解析位置最远的那个Error
,这样能使最终得到的错误信息更加准确易懂。
接下来我们为Parser
实现Alternative
,其实就只是一个函数:or
// MARK: - Alternative
public extension Parser {
func or(_ another: Parser<Value>) -> Parser<Value> {
flatMapError { error in
another.mapError(error.merge)
}
}
}
若当前Parser
解析失败,通过or
参数传入的Parser
将会重新尝试。若两者解析都失败了,解析过程分别产生的错误Error
将被合并。
回溯
等等,上面关于选择
的实现,是不是有点问题呀?
let str = "Hello"
let parser = char("W").or(char("H"))
let result = parser.parse(str)
这段代码result
的值不应该是成功的.success("H")
吗,为什么最后得到的却是失败:failure(Error(stream: "Hello World", position: Swift.String.Index(_rawBits: 131329), message: "expecting H"))
?
如上图所示,白色的三角形代表char("W")
这个Parser
即将解析到的位置,在此位置上元素是字符H
,这并不满足要求,所以这个Parser
解析错误。因为or
的效果,传入的char("H")
将会继续解析,但是因为之前已经消费掉了输入串中的一个元素,接下来即将要解析的位置则位于红色三角形处,这里的元素已经是字符e
了,所以最终解析还是失败了。
要解决这个问题,我们就要进行回溯
操作:当char("W")
解析失败后,让char("H")
要解析的位置还是处于白色三角形处。
要实现回溯,我们需要在解析前先记录输入串当前的解析位置,当需要回溯时,就以这个记录重置解析位置。为了代码优雅,我们可以为Context
扩展以下方法:
public extension Context {
func `do`<T>(_ work: (Context) -> Result<T, Error>) -> Result<T, Error> {
let rewind = { [_cursor] in self._cursor = _cursor }
let result = work(self)
if case .failure = result { rewind() }
return result
}
}
方法do
接受一个nonescaping
的闭包参数,这个闭包的参数为Context
本身,返回值是一个带有泛型的Result
,闭包会在do
方法内部被调用。work
闭包调用前,do
会捕获当前Context
的解析位置,构造出用于回滚的闭包。当闭包调用后,其结果值将会原封不动通过do
方法返回,不过当结果表示的是失败时,回滚闭包将在方法返回前被调用,Context
的解析位置将会被重置。
现在,我们可以稍微修改一下Parser
的初始化方法,让所有Parser
的解析逻辑都运行在Context
的do
方法中:
public struct Parser<Value> {
public typealias Result = Swift.Result<Value, Error>
public let parse: (Context) -> Result
public init(_ parse: @escaping (Context) -> Result) {
self.parse = { $0.do(parse) } // ⚠️ here
}
}
OK,到此,带有回溯
效果的Parser
已实现完成。我们再来试一试前面的代码:
let str = "Hello"
let parser = char("W").or(char("H"))
let result = parser.parse(str)
测试结果为success("H")
,符合预期!
Many & Some
有时候我们希望多次运行某个Parser
的解析逻辑,并把多次的结果收集起来。如多次调用element
解析得到一个由多个字符组成的字符串。为此,我们可以构造many
和some
这两个Combinator
:
我们先来看some
:some
指Parser
须成功解析至少一次或多次。假设没有一次解析成功,则返回错误,而解析如果还未失败,那么将会一直运行下去,并用数组将之前的解析结果收集起来。
public extension Parser {
var some: Parser<[Value]> {
flatMap { first in
.init {
var result = [first]
while case .success(let value) = self.parse($0) {
result.append(value)
}
return .success(result)
}
}
}
}
many
要求则比some
宽松:Parser
允许成功解析零次或多次。若Parser
第一次解析就失败了,结果将返回一个空数组:
public extension Parser {
var many: Parser<[Value]> {
some.or(.just([]))
}
}
many
用or
连接some
和一个只返回空数组的Parser
,所以当some
解析失败时,空数组将作为结果返回。
接下来我们就可以实现一个用于解析以成对双引号包裹的字符串字面量Parser
了:
public let stringLiteral = notChar("\"").many
.map { String($0) }
.between(char("\""), char("\""))
notChar("\"").many
构造了一个用于解析零个或多个不为双引号"
的字符- 上面得到的结果类型是字符数组
[Character]
,这里使用了map
将结果转换成了String
类型 between(char("\""), char("\""))
则期望Parser
结果位于双引号对之间
测试一下:
let str = #""Hello World""#
let result = stringLiteral.parse(str)
得到的结果为:success("Hello World")
。
运算符
为了提升代码的简洁度和可读性,我们可以为一些Combinator
拟定运算符:
precedencegroup AlternativePrecedence {
associativity: left
}
precedencegroup FunctorPrecedence {
associativity: left
higherThan: AlternativePrecedence
}
infix operator <|> : AlternativePrecedence
infix operator *> : FunctorPrecedence
infix operator <* : FunctorPrecedence
public func <* <L, R>(lhs: Parser<L>, rhs: Parser<R>) -> Parser<L> {
lhs.usePrevious(rhs)
}
public func *> <L, R>(lhs: Parser<L>, rhs: Parser<R>) -> Parser<R> {
lhs.useNext(rhs)
}
public func <|> <T>(lhs: Parser<T>, rhs: Parser<T>) -> Parser<T> {
lhs.or(rhs)
}
至此,我们已经将整套Parser Combinator
库实现完了。其实这里还漏缺了许多有趣且有用的Combinator
,但考虑到文章篇幅问题就不在此一一详述了,感兴趣的读者可以尝试自己去摸索实现。Swift 针对字符和字符串也提供了很多 API,利用这些 API 我们也可以构造出很多新奇好玩的Combinator
。
接下来我们试着运用它来编写一个小小解析器。
小试牛刀
利用上面已经实现好的Parser Combinator
库,我们试着去编写一款针对字符串键值对的解析器,规则如下:
键和值都是以双引号对包裹的字符串字面量,中间以箭头分割。形如:"key" => "value"
,其中箭头左右两边都允许有若干空格。通过换行,我们可以拟定多个键值对:
"name" => "Tangent"
"city" => "深圳"
"introduction" => "iOS开发者"
从简单到复杂、从细粒度到粗粒度,接下来我们一步一步实现这款解析器:
首先为“空格”和“换行”构建Parser
,方便后面使用:
public let space = element.filter("expecting whitespace") { $0 == " " }
.map { _ in }
public let newline = element.filter("expecting newline") { $0.isNewline }
.map { _ in }
对于字符串字面量的解析,前面的章节已经实现了Parser
:stringLiteral
,用于提取字符串字面量双引号对之间的内容。
接着是箭头的解析:
let arrow = string("=>").between(space.many, space.many)
因为规则允许箭头左右两边有任意数量空格,所以这里使用了between(space.many, space.many)
。
下面构建键值对的Parser
:
typealias Entry = (key: String, value: String)
let entry: Parser<Entry> = stringLiteral.flatMap { key in
(arrow *> stringLiteral).map { value in
(key, value)
}
}
我们首先声明了键值对的类型为一个二元组(key: String, value: String)
,接着通过flatMap
、useNext
、map
这三个Combinator
对stringLiteral
> arrow
> stringLiteral
三个Parser
依次进行解析匹配,并把左右两个stringLiteral
的值提取出来构建键值对Entry
。
最后,我们需要构建能够解析多个键值对的Parser
:
let entries = (entry <* (newline <|> endOfStream)).some
因为规则要求每个键值对解析完后,紧接着要么是换行、要么是输入串的结尾(newline or endOfStream),所以这里使用了entry <* (newline <|> endOfStream)
的组合,接着通过some
来让解析匹配一次或多次。
以上,这个键值对小型解析器就完成了,我们来测试一下:
let string = """
"name" => "Tangent"
"city" => "深圳"
"introduction" => "iOS开发者"
"""
let result = entries.parse(string)
结果输出:
success([(key: "name", value: "Tangent"), (key: "city", value: "深圳"), (key: "introduction", value: "iOS开发者")])
总结
这篇文章带大家构建了一套轻量的Parser Combinator
库,并以此实现了一款简单的解析器。目的是希望能让大家更加深刻地感受到函数式编程的魅力,并加深对函数式编程相关概念的认识。如果后续有时间,我可能也会考虑出一篇用Parser Combinator
编写JSON
解析器的文章。
如有问题或指正,欢迎评论,谢谢!
参考
用Haskell构建Parser Combinator(一)
文章到这里就结束了,你也可以像上面说的那样,有想法学习的可以私聊一下我,我给你关于这一块的裙,白嫖的不知道你们觉得香不香。如果你有什么意见和建议欢迎给我留言。