最早着手这个工作的时候,我参考的是 sanyaade-buildtools/goparsec · GitHub 这个库。当时觉得把 Bind 实现为一个组合子是个不错的选择啊,多像 LISP 里的 S 表达式呀。
把算子都实现为函数,把组合子实现为生成算子的函数,把Bind实现为组合子,这样就构造出了一个简单但是可用的系统。
然而我实地用它实现 Dwarfartisan/gisp · GitHub 项目的时候,发现跟预期的不太一样。主要问题在于构造复杂表达式的时候,很难阅读:
// RuneParser 实现 rune 的解析
var RuneParser = p.Bind(
p.Between(p.Rune('\''), p.Rune('\''),
p.Either(p.Try(EscapeCharr), p.NoneOf("'"))),
func(x interface{}) p.Parser {
return p.Return(Rune(x.(rune)))
},
)
例如这里的 RunParser 实现,实际上我们的逻辑是将初步解析成功的结果转为 rune 然后 Return。但是 Bind 在最前面,阅读的时候,这个数据传递的逻辑反而在第一步读到。而在 Haskell 中,这里是一个中缀运算符 >>= 。
再例如下面这个 EscapeCharr 算子的实现,同样我们要先阅读到 Bind_ ——这对应 Haskell 的 >> 算子——然后才是顺序调用的逻辑。而在 Haskell 中,这个典型的解析问题(可以参见 Haskell 的著名入门教程 Write Yourself a Scheme in 48 Hours)放在一个 do 环境(do是一个monad环境)中实现。这就让整个过程流畅的多,即使中间混合各种“平凡的”程序逻辑,也可以非常自然。
下面这段程序是 gisp 库中对字符代码的转义字符处理逻辑,使用 goparsec 库。
//用于rune
var EscapeCharr = p.Bind_(p.Rune('\\'), func(st p.ParseState) (interface{}, error) {
r, err := p.OneOf("nrt'\\")(st)
if err == nil {
ru := r.(rune)
switch ru {
case 'r':
return '\r', nil
case 'n':
return '\n', nil
case '\'':
return '\'', nil
case '\\':
return '\\', nil
case 't':
return '\t', nil
default:
return nil, st.Trap("Unknown escape sequence \\%c", r)
}
} else {
return nil, err
}
})
而下面这段代码用 goparsec2 重写了这部分逻辑:
//用于rune
var EscapeCharr = p.Chr('\\').Then(func(st p.State) (interface{}, error) {
r, err := p.RuneOf("nrt'\\")(st)
if err == nil {
ru := r.(rune)
switch ru {
case 'r':
return '\r', nil
case 'n':
return '\n', nil
case '\'':
return '\'', nil
case '\\':
return '\\', nil
case 't':
return '\t', nil
default:
return nil, st.Trap("Unknown escape sequence \\%c", r)
}
} else {
return nil, err
}
})
这里用对象方法 .Then 代替了 Bind_ 函数,减少了代码深度。
更进一步的,当前 gisp2 用 Do 函数进一步简化的代码形式的复杂度:
//用于rune
var EscapeCharr = p.Do(func(st p.State) interface{} {
p.Chr('\\').Exec(st)
r := p.RuneOf("nrt'\\").Exec(st)
ru := r.(rune)
switch ru {
case 'r':
return '\r'
case 'n':
return '\n'
case '\'':
return '\''
case '\\':
return '\\'
case 't':
return '\t'
default:
panic(st.Trap("Unknown escape sequence \\%c", r))
}
})
有意思的是,这个改动在不精确的测试场景中,甚至提升了代码性能。当然,这个结果并不能代表普遍可信的性能评估,但是它非常有趣:
为何我会说它“有趣”呢?因为Do其实是利用了算子原有的 Parse 行为的一个封装,首先如果我们判断到 err 就把它抛出来:
//Exec 调用被封装的算子,如果返回错误,用panic抛出
func (parsec Parsec) Exec(state State) interface{} {
re, err := parsec(state)
if err != nil {
panic(err)
}
return re
}
然后用一个recover在最外面捕捉:
// Do 构造一个算子,其内部类似 Monad Do Environment ,将 Exec 形式恢复成 Parse 形式。
// 需要注意的是,捕获的非 error 类型的panic会重新抛出。
func Do(fn func(State) interface{}) Parsec {
return func(state State) (re interface{}, err error) {
defer func() {
if r := recover(); r != nil {
if e, ok := r.(error); ok {
err = e
re = nil
} else {
panic(r)
}
}
}()
re = fn(state)
err = nil
return
}
}
这个方法早几年我就想到过,但是一直没有实践,因为传统上的经验来说,异常处理总是个代价比较高的事情,特别是错误信息司空见惯的组合子解析逻辑。
然而这里确实出现了性能优化的现象。无论如何,既好看又性能好,总是一件好事。
这里朋友们会看到,组合子接受的 Parsec 类型,实现了 Exec ,那么它本身似乎又可以直接作为函数去调用。它是如何实现的呢?
这个小技巧在 go 语言中,居然是出乎我意料的简单,当我实现它的时候,不无相见恨晚的心情:
// Parsec 是算子的公共抽象类型,实现 Monad 和解析逻辑
type Parsec func(state State) (interface{}, error)
Go 语言允许定义一个既有类型的“别名”,并且给这个新的type定义加上新的行为定义!这个语法特性在此展现出了强大而优雅的能力。我们为这个类型实现了Monad封装(Bind、Then、Over)和会抛出异常的Exec。完成了 goparsec2 的基本设计思路。这个设计方案比起第一版,可称优美。
但是它有一个问题,当我们直接调用一个 func(state State) (interface{}, error) 类型的函数,编译器不会认出它也可以是一个 Parsec 。所以我经常遇到类似(假设 Int 是前述类型的 func ) Int.Over(Char('.')) 报错的情况,编译器认为Int这个 func 没有实现 Over 方法。解决它的方法也很简单,只要做一次强制转型。因为完整的写 Parsec 太麻烦,我定义了一个辅助函数:
// M 工具函数实现将函数明确转型为 Parsec 算子的逻辑
func M(fn func(State) (interface{}, error)) Parsec {
return fn
}
于是在 goparsec2 的应用代码中,出现了很多形如:
func equals(st p.State) (interface{}, error) {
return p.M(p.One).Bind(eqs)(st)
}
的定义。
其实这个也不能说是完美的方案,如果我当初简单的把 Parsec 命名为 P,或许可以更简单的解决这个问题?
比较 gisp 的 RuneParser :
// RuneParser 实现 rune 的解析
var RuneParser = p.Bind(
p.Between(p.Rune('\''), p.Rune('\''),
p.Either(p.Try(EscapeCharr), p.NoneOf("'"))),
func(x interface{}) p.Parser {
return p.Return(Rune(x.(rune)))
},
)
和 gisp2 的实现:
// RuneParser 实现 rune 的解析
var RuneParser = p.Do(func(state p.State) interface{} {
p.Chr('\'').Exec(state)
c := p.Choice(p.Try(EscapeCharr), p.NChr('\'')).Exec(state)
p.Chr('\'').Exec(state)
return Rune(c.(rune))
})
这一系列的反思和重构,还是非常值得的。
=============
最终,我还是选择简化 Parsec 类型的命名为 P ,然后去掉了M函数。