示例: 表达式求值
在本节中,我们会构建一个简单算术表达式的求值器。我们将使用一个接口 Expr 来表示 Go 语言中任意的表达式。现在这个接口不需要有方法,但是我们后面会为它增加一些。
// An Expr is an arithmetic expression.
type Expr interface{}
我们的表达式语言包括浮点数符号(小数点);二元操作符 +
,-
,*
, 和 /
;一元操作符 -x
和 +x
;调用 pow(x,y)
,sin(x)
,和 sqrt(x)
的函数;例如 x 和 pi 的变量;当然也有括号和标准的优先级运算符。所有的值都是 float64 类型。这下面是一些表达式的例子:
sqrt(A / pi)
pow(x, 3) + pow(y, 3)
(F - 32) * 5 / 9
下面的五个具体类型表示了具体的表达式类型。Var 类型表示对一个变量的引用。(我们很快会知道为什么它可以被输出。)literal 类型表示一个浮点型常量。unary 和 binary 类型表示有一到两个运算对象的运算符表达式,这些操作数可以是任意的 Expr 类型。call 类型表示对一个函数的调用;我们限制它的 fn 字段只能是 pow,sin 或者 sqrt 。
Unresolved include directive in modules/ROOT/pages/ch7/ch7-09.adoc - include::example$/ch7/eval/ast.go[]
为了计算一个包含变量的表达式,我们需要一个 environment 变量将变量的名字映射成对应的值:
type Env map[Var]float64
我们也需要每个表达式去定义一个 Eval 方法,这个方法会根据给定的 environment 变量返回表达式的值。因为每个表达式都必须提供这个方法,我们将它加入到 Expr 接口中。这个包只会对外公开 Expr,Env,和 Var 类型。调用方不需要获取其它的表达式类型就可以使用这个求值器。
type Expr interface {
// Eval returns the value of this Expr in the environment env.
Eval(env Env) float64
}
下面给大家展示一个具体的 Eval 方法。Var 类型的这个方法对一个 environment 变量进行查找,如果这个变量没有在 environment 中定义过这个方法会返回一个零值,literal 类型的这个方法简单的返回它真实的值。
func (v Var) Eval(env Env) float64 {
return env[v]
}
func (l literal) Eval(_ Env) float64 {
return float64(l)
}
unary 和 binary 的 Eval 方法会递归的计算它的运算对象,然后将运算符 op 作用到它们上。我们不将被零或无穷数除作为一个错误,因为它们都会产生一个固定的结果——无限。最后,call 的这个方法会计算对于 pow,sin,或者 sqrt 函数的参数值,然后调用对应在 math 包中的函数。
func (u unary) Eval(env Env) float64 {
switch u.op {
case '+':
return +u.x.Eval(env)
case '-':
return -u.x.Eval(env)
}
panic(fmt.Sprintf("unsupported unary operator: %q", u.op))
}
func (b binary) Eval(env Env) float64 {
switch b.op {
case '+':
return b.x.Eval(env) + b.y.Eval(env)
case '-':
return b.x.Eval(env) - b.y.Eval(env)
case '*':
return b.x.Eval(env) * b.y.Eval(env)
case '/':
return b.x.Eval(env) / b.y.Eval(env)
}
panic(fmt.Sprintf("unsupported binary operator: %q", b.op))
}
func (c call) Eval(env Env) float64 {
switch c.fn {
case "pow":
return math.Pow(c.args[0].Eval(env), c.args[1].Eval(env))
case "sin":
return math.Sin(c.args[0].Eval(env))
case "sqrt":
return math.Sqrt(c.args[0].Eval(env))
}
panic(fmt.Sprintf("unsupported function call: %s", c.fn))
}
一些方法会失败。例如,一个 call 表达式可能有未知的函数或者错误的参数个数。用一个无效的运算符如 ! 或者<去构建一个 unary 或者 binary 表达式也是可能会发生的(尽管下面提到的 Parse 函数不会这样做)。这些错误会让 Eval 方法 panic。其它的错误,像计算一个没有在 environment 变量中出现过的 Var,只会让 Eval 方法返回一个错误的结果。所有的这些错误都可以通过在计算前检查 Expr 来发现。这是我们接下来要讲的 Check 方法的工作,但是让我们先测试 Eval 方法。
下面的 TestEval 函数是对 evaluator 的一个测试。它使用了我们会在第11章讲解的 testing 包,但是现在知道调用 t.Errof 会报告一个错误就足够了。这个函数循环遍历一个表格中的输入,这个表格中定义了三个表达式和针对每个表达式不同的环境变量。第一个表达式根据给定圆的面积 A 计算它的半径,第二个表达式通过两个变量 x 和 y 计算两个立方体的体积之和,第三个表达式将华氏温度 F 转换成摄氏度。
func TestEval(t *testing.T) {
tests := []struct {
expr string
env Env
want string
}{
{"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 12, "y": 1}, "1729"},
{"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"},
{"5 / 9 * (F - 32)", Env{"F": -40}, "-40"},
{"5 / 9 * (F - 32)", Env{"F": 32}, "0"},
{"5 / 9 * (F - 32)", Env{"F": 212}, "100"},
}
var prevExpr string
for _, test := range tests {
// Print expr only when it changes.
if test.expr != prevExpr {
fmt.Printf("\n%s\n", test.expr)
prevExpr = test.expr
}
expr, err := Parse(test.expr)
if err != nil {
t.Error(err) // parse error
continue
}
got := fmt.Sprintf("%.6g", expr.Eval(test.env))
fmt.Printf("\t%v => %s\n", test.env, got)
if got != test.want {
t.Errorf("%s.Eval() in %v = %q, want %q\n",
test.expr, test.env, got, test.want)
}
}
}
对于表格中的每一条记录,这个测试会解析它的表达式然后在环境变量中计算它,输出结果。这里我们没有空间来展示 Parse 函数,但是如果你使用 go get 下载这个包你就可以看到这个函数。
go test(§11.1) 命令会运行一个包的测试用例:
$ go test -v gopl.io/ch7/eval
这个 -v 标识可以让我们看到测试用例打印的输出;正常情况下像这样一个成功的测试用例会阻止打印结果的输出。这里是测试用例里 fmt.Printf 语句的输出:
sqrt(A / pi)
map[A:87616 pi:3.141592653589793] => 167
pow(x, 3) + pow(y, 3)
map[x:12 y:1] => 1729
map[x:9 y:10] => 1729
5 / 9 * (F - 32)
map[F:-40] => -40
map[F:32] => 0
map[F:212] => 100
幸运的是目前为止所有的输入都是适合的格式,但是我们的运气不可能一直都有。甚至在解释型语言中,为了静态错误检查语法是非常常见的;静态错误就是不用运行程序就可以检测出来的错误。通过将静态检查和动态的部分分开,我们可以快速的检查错误并且对于多次检查只执行一次而不是每次表达式计算的时候都进行检查。
让我们往 Expr 接口中增加另一个方法。Check 方法对一个表达式语义树检查出静态错误。我们马上会说明它的 vars 参数。
type Expr interface {
Eval(env Env) float64
// Check reports errors in this Expr and adds its Vars to the set.
Check(vars map[Var]bool) error
}
具体的 Check 方法展示在下面。literal 和 Var 类型的计算不可能失败,所以这些类型的 Check 方法会返回一个 nil 值。对于 unary 和 binary 的 Check 方法会首先检查操作符是否有效,然后递归的检查运算单元。相似地对于 call 的这个方法首先检查调用的函数是否已知并且有没有正确个数的参数,然后递归的检查每一个参数。
func (v Var) Check(vars map[Var]bool) error {
vars[v] = true
return nil
}
func (literal) Check(vars map[Var]bool) error {
return nil
}
func (u unary) Check(vars map[Var]bool) error {
if !strings.ContainsRune("+-", u.op) {
return fmt.Errorf("unexpected unary op %q", u.op)
}
return u.x.Check(vars)
}
func (b binary) Check(vars map[Var]bool) error {
if !strings.ContainsRune("+-*/", b.op) {
return fmt.Errorf("unexpected binary op %q", b.op)
}
if err := b.x.Check(vars); err != nil {
return err
}
return b.y.Check(vars)
}
func (c call) Check(vars map[Var]bool) error {
arity, ok := numParams[c.fn]
if !ok {
return fmt.Errorf("unknown function %q", c.fn)
}
if len(c.args) != arity {
return fmt.Errorf("call to %s has %d args, want %d",
c.fn, len(c.args), arity)
}
for _, arg := range c.args {
if err := arg.Check(vars); err != nil {
return err
}
}
return nil
}
var numParams = map[string]int{"pow": 2, "sin": 1, "sqrt": 1}
我们在两个组中有选择地列出有问题的输入和它们得出的错误。Parse 函数(这里没有出现)会报出一个语法错误和 Check 函数会报出语义错误。
x % 2 unexpected '%'
math.Pi unexpected '.'
!true unexpected '!'
"01-hello" unexpected '"'
log(10) unknown function "log"
sqrt(1, 2) call to sqrt has 2 args, want 1
Check 方法的参数是一个 Var 类型的集合,这个集合聚集从表达式中找到的变量名。为了保证成功的计算,这些变量中的每一个都必须出现在环境变量中。从逻辑上讲,这个集合就是调用 Check 方法返回的结果,但是因为这个方法是递归调用的,所以对于 Check 方法,填充结果到一个作为参数传入的集合中会更加的方便。调用方在初始调用时必须提供一个空的集合。
在第3.2节中,我们绘制了一个在编译期才确定的函数 f(x,y) 。现在我们可以解析,检查和计算在字符串中的表达式,我们可以构建一个在运行时从客户端接收表达式的 web 应用并且它会绘制这个函数的表示的曲面。我们可以使用集合 vars 来检查表达式是否是一个只有两个变量 x 和 y 的函数——实际上是3个,因为我们为了方便会提供半径大小 r。并且我们会在计算前使用 Check 方法拒绝有格式问题的表达式,这样我们就不会在下面函数的 40000 个计算过程(100x100个栅格,每一个有4个角)重复这些检查。
这个 ParseAndCheck 函数混合了解析和检查步骤的过程:
Unresolved include directive in modules/ROOT/pages/ch7/ch7-09.adoc - include::example$/ch7/surface/surface.go[]
为了编写这个 web 应用,所有我们需要做的就是下面这个 plot 函数,这个函数有和 http.HandlerFunc 相似的签名:
func plot(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
expr, err := parseAndCheck(r.Form.Get("expr"))
if err != nil {
http.Error(w, "bad expr: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
surface(w, func(x, y float64) float64 {
r := math.Hypot(x, y) // distance from (0,0)
return expr.Eval(eval.Env{"x": x, "y": y, "r": r})
})
}

这个 plot 函数解析和检查在 HTTP 请求中指定的表达式并且用它来创建一个两个变量的匿名函数。这个匿名函数和来自原来 surface-plotting 程序中的固定函数 f 有相同的签名,但是它计算一个用户提供的表达式。环境变量中定义了 x,y 和半径 r 。最后 plot 调用 surface 函数,它就是 gopl.io/ch3/surface 中的主要函数,修改后它可以接受 plot 中的函数和输出 io.Writer 作为参数,而不是使用固定的函数 f 和 os.Stdout 。图7.7中显示了通过程序产生的 3 个曲面。
练习 7.13: 为 Expr 增加一个 String 方法来打印美观的语法树。当再一次解析的时候,检查它的结果是否生成相同的语法树。
练习 7.14: 定义一个新的满足 Expr 接口的具体类型并且提供一个新的操作例如对它运算单元中的最小值的计算。因为 Parse 函数不会创建这个新类型的实例,为了使用它你可能需要直接构造一个语法树(或者继承 parser 接口)。
练习 7.15: 编写一个从标准输入中读取一个单一表达式的程序,用户及时地提供对于任意变量的值,然后在结果环境变量中计算表达式的值。优雅的处理所有遇到的错误。
练习 7.16: 编写一个基于 web 的计算器程序。