Parsec 的迁移(一)

684 阅读5分钟
原文链接: zhuanlan.zhihu.com
将 Haskell 的 Parsec 库迁移到其它语言时,遇到一些很有意思的问题。Haskell 是一门独特的语言,有很多比较冷门的特性。比如常见的编程语言中,通常不会考虑 Monad 这种东西——顺序的workflow 就是在 bind 呀。所以看到 >>= 和 do 这类东西的时候,我确实花了很多时间去适应——道理我都懂但是鸽子为什么……这么……

最早着手这个工作的时候,我参考的是 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函数。