☰
Expr 自定义函数完全指南:从环境注入到类型签名与编译期折叠
2026/9/26 10:28:54 网站建设 项目流程
  • 后端
  • 开发工具

【免费下载链接】expr

Expression language and expression evaluation for Go

项目地址:https://gitcode.com/gh_mirrors/ex/expr
点击查看免费下载

本篇技术指南以 docs/functions.md 为核心骨架,系统讲解 Go 表达式语言 Expr 中"函数"的完整玩法:如何通过环境注入、结构体方法与expr.Function选项三种方式定义自定义函数,如何用类型签名让类型检查器精确理解参数与返回值,以及如何用ConstExpr让函数在编译期完成常量求值。读者阅读后将掌握在 Expr 中安全、高性能地扩展函数能力的完整方案,并能读懂类型检查器在背后的匹配机制。

Expr 自带一套内置函数(参见 语言定义文档),但真实业务中你几乎总是需要定义自己的函数——无论是解析字符串、调用业务逻辑还是封装计算。本文将按"由简到繁、由用到理"的顺序展开。

一、自定义函数的三种途径

1. 注入到环境(map 方式)

最简单的做法是把函数直接放进环境变量 map 中,表达式里就能以普通标识符形式调用它:

env := map[string]any{ "add": func(a, b int) int { return a + b }, } program, err := expr.Compile(`add(1, 2)`, expr.Env(env)) if err != nil { panic(err) } output, err := expr.Run(program, env) if err != nil { panic(err) } fmt.Print(output) // 3

环境 map 的键就是表达式中的函数名,expr.Env(env)选项负责把 map 交给编译期的类型检查器做类型推断与校验(见 expr.go 中Env选项的实现)。

2. 结构体方法(struct 方式)

当你的函数与业务数据天然绑定(例如需要操作Posts字段、依赖时间格式化逻辑)时,更推荐把函数定义为结构体的方法。编译期对传入的Env{}做检查时,struct 上的方法会被自动识别为可用函数:

type Env struct { Posts []Post `expr:"posts"` } func (Env) Format(t time.Time) string { return t.Format(time.RFC822) } type Post struct { Body string Date time.Time } code := `map(posts, Format(.Date) + ": " + .Body)` program, err := expr.Compile(code, expr.Env(Env{})) if err != nil { panic(err) } env := Env{ Posts: []Post{ {"Oh My God!", time.Now()}, {"How you doin?", time.Now()}, }, } output, err := expr.Run(program, env) if err != nil { panic(err) } fmt.Print(output)

注意两个细节:

  • 字段名默认使用 Go 字段名(首字母大写),也可以通过expr:"posts"标签重命名,表达式里写小写的posts即可访问(Post的Body、Date未打标签,直接按原名访问);
  • 方法名默认原样暴露,例如Format在表达式中调用为Format(.Date),配合 predicate 语法 中的.简写可以写出非常简洁的映射逻辑。

3.expr.Function选项(性能最佳)

从性能角度看,最佳方案是使用expr.Function选项注册函数。它绕过了反射调用,让函数以更直接的方式进入函数表(function table),同时还能携带类型签名供类型检查器使用。基础用法如下:

atoi := expr.Function( "atoi", func(params ...any) (any, error) { return strconv.Atoi(params[0].(string)) }, ) program, err := expr.Compile(`atoi("42")`, atoi) if err != nil { panic(err) } output, err := expr.Run(program, nil) fmt.Print(output) // 42

expr.Function(name, fn, types...)的函数体签名固定为func(params ...any) (any, error):参数以any切片形式传入,需要自己断言类型;返回值必须携带 error。在 expr.go 的实现中可以看到,后续传入的types参数会被逐个转换为reflect.Type并存入c.Functions[name],最终包装为 builtin.Function 结构体。

二、类型签名:让类型检查器真正"看懂"你的函数

问题:默认签名太宽泛

按第一种写法注册atoi后,类型检查器只能看到"一个接受任意数量any参数、返回any的函数"。这意味着:

// 编译通过,但返回值是 any,下游做算术运算会被拒绝或需要断言 program, err := expr.Compile(`atoi("42") + 1`, atoi)

显然这不是我们想要的——我们明明知道atoi应该接收一个string并返回int。

解法一:显式声明函数签名

通过第三个参数传入"类型模板"——一个函数类型指针,类型检查器就会以该签名为准进行参数类型校验和返回值推断:

atoi := expr.Function( "atoi", func(params ...any) (any, error) { return strconv.Atoi(params[0].(string)) }, new(func(string) int), ) program, err := expr.Compile(`atoi("42") + 1`, atoi) // 编译通过

new(func(string) int)并不是要真的调用这个函数,它只是告诉类型检查器:atoi接受一个string,返回int。此后表达式里再写出atoi(123)或atoi("42") + "x"这类类型不匹配的代码,会在编译期直接报错。在 expr.go 的实现中,指针类型的参数会被t.Elem()解引用,并且会校验其 Kind 必须是reflect.Func,否则编译期 panic 提示"type of xxx is not a function"。

解法二:直接复用 Go 标准函数作为签名

如果你注册的函数体本质上就是包装某个现成的 Go 函数,可以直接把该函数本身作为签名传入,无需手写new(func(...)):

atoi := expr.Function( "atoi", func(params ...any) (any, error) { return strconv.Atoi(params[0].(string)) }, strconv.Atoi, // 复用标准库函数签名:func(string) (int, error) )

strconv.Atoi的 Go 签名是func(string) (int, error),其返回值的第二个元素是 error。Expr 类型检查器会自动处理这种约定:(int, error)中的 error 被视为运行时错误通道,实际返回值类型按int计算。这比手写模板签名更简洁,也天然与标准库保持同步。

三、多重签名:让一个函数支持多种调用形态

有时一个函数需要接受多种参数组合,此时可以传入多个函数签名,类型检查器会像 Go 的方法重载一样逐个尝试匹配:

toInt := expr.Function( "toInt", func(params ...any) (any, error) { switch params[0].(type) { case float64: return int(params[0].(float64)), nil case string: return strconv.Atoi(params[0].(string)) } return nil, fmt.Errorf("invalid type") }, new(func(float64) int), new(func(string) int), ) // toInt(3.14) -> 3 // toInt("42") -> 42

签名列表new(func(float64) int)与new(func(string) int)让toInt同时接受浮点数和字符串。函数体内部则用type switch分派到实际转换逻辑。

从源码看,这一机制实现在类型检查器的 checkFunction 中:当注册了多个签名时,检查器会遍历f.Types,用每个签名去匹配实际参数;只要有一个签名匹配成功就采用该签名做返回类型推断;如果全部签名都匹配失败,则报错no matching overload for <函数名>——这与 Go 语言本身的重载报错风格一致。

四、编译期求值:ConstExpr常量折叠

你可能会问:哪些函数适合在编译期算完?答案很明确——那些参数全部是常量、结果稳定的纯函数。例如计算斐波那契数列的递归函数,每次运行fib(10)都重复递归一遍毫无必要,完全可以在编译期算出55后直接内联成常量。

首先把函数放进环境:

func fib(n int) int { if n <= 1 { return n } return fib(n-1) + fib(n-2) } env := map[string]any{ "fib": fib, }

然后用 ConstExpr 选项 声明它允许编译期求值:

program, err := expr.Compile(`fib(10)`, expr.Env(env), expr.ConstExpr("fib")) if err != nil { panic(err) }

其求值规则如下:

fib(10) // 编译期被替换为常量 55 fib(12+12) // 参数本身是常量表达式,同样被折叠为 267914296 fib(x) // 参数是变量 x,无法在编译期求值,回退为运行时调用

也就是说:只要函数的所有实参在编译期都能确定为常量,函数调用就会被折叠为常量结果;只要有一个参数依赖运行时变量,就保持为普通运行时调用。对应源码是 expr.go 中ConstExpr选项把函数名注册进配置,而实际的折叠动作发生在 optimizer 这一编译优化阶段——Expr 的Compile流程会在类型检查之后、字节码生成之前运行优化器(见 expr.go 的完整编译管线)。

使用建议:

  • 适合ConstExpr的函数:无副作用、结果仅由参数决定、计算昂贵(如递归、加密哈希);
  • 不适合:依赖外部状态、IO、时间、随机数的函数;
  • 配合上文的expr.Function与类型签名,可以让常量折叠在类型安全的前提下进行。

五、与内置函数的关系

Expr 自带数十个内置函数,涵盖字符串、日期、数值、数组、map、类型转换与位运算等类别(完整清单见 语言定义文档),例如trim、split、now()、duration、filter、reduce、toJSON、bitand等。自定义函数与内置函数共存于同一函数表:检查器在 checker.go 中优先查找config.Functions(即expr.Function注册的自定义函数),未命中再回落查询内置函数表。内置函数的声明方式与你用expr.Function注册自定义函数的方式高度一致——都归结为 builtin.Function 结构体(字段包括Types签名列表、Validate参数校验函数、Predicate标记等),例如 builtin/builtin.go 中filter、map等谓词函数就声明了func([]any, func(any) bool) []any这类签名。理解这一点,你就明白"自定义函数与内置函数只是注册来源不同,类型检查、编译优化与执行路径完全一致"。

如果你希望收紧安全边界,还可以用 DisableBuiltin / DisableAllBuiltins 选项在特定场景下禁用内置函数,只暴露你注册的自定义函数——这对多租户、低信任输入的场景尤其有用。

六、完整可运行示例与最佳实践

把本文所有知识点组合成一个完整示例:

package main import ( "fmt" "strconv" "github.com/expr-lang/expr" ) type Env struct { Orders []Order `expr:"orders"` } type Order struct { Total float64 Qty int } // 自定义函数:四舍五入到两位小数 func (Env) Round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } func main() { // 1. expr.Function 注册 + 多重签名 parseNum := expr.Function( "parseNum", func(params ...any) (any, error) { switch v := params[0].(type) { case string: return strconv.ParseFloat(v, 64) case float64: return v, nil } return nil, fmt.Errorf("unsupported type") }, new(func(string) float64), new(func(float64) float64), ) code := `map(orders, Round2(.Total * parseNum("1.2")))` program, err := expr.Compile(code, expr.Env(Env{}), parseNum) if err != nil { panic(err) } output, err := expr.Run(program, Env{Orders: []Order{ {Total: 19.99, Qty: 2}, {Total: 5.50, Qty: 4}, }}) if err != nil { panic(err) } fmt.Printf("%#v\n", output) }

工程实践要点总结:

  1. 优先expr.Function:性能最佳,能携带类型签名,应作为定义自定义函数的首选;环境注入与 struct 方法适合快速原型或函数与数据天然耦合的场景;
  2. 永远提供类型签名:不带签名的函数返回any,会让下游表达式失去类型安全;带签名后编译期即可捕获类型错误,把运行时 panic 提前到编译期;
  3. 尽量复用标准库签名:能用strconv.Atoi、fmt.Sprintf这类现成 Go 函数作为签名模板时,别手写new(func(...));
  4. 为纯函数开启ConstExpr:参数恒为常量的昂贵计算应折叠到编译期,这是 Expr 官方文档明确推荐的性能优化手段(详见 docs/configuration.md);
  5. 组合使用多个选项:expr.Env、expr.Function、expr.ConstExpr等选项都实现了统一的Option func(c *conf.Config)接口,可以放进一个切片后以options...展开传入expr.Compile(参见 docs/configuration.md 的 Options 一节),方便做多环境复用。

通过 docs/configuration.md 还可以继续了解AsBool()、WithContext()等与函数体系协同工作的选项——例如WithContext("ctx")会把context.Context作为首个参数自动注入到所有接受 context 的函数调用中,让自定义函数安全地访问超时与取消信号(对应实现见 patcher/with_context.go)。至此,你已经掌握了在 Expr 中定义、类型化并优化自定义函数的全部核心手段。

  • 后端
  • 开发工具

【免费下载链接】expr

Expression language and expression evaluation for Go

项目地址:https://gitcode.com/gh_mirrors/ex/expr
点击查看免费下载

相关推荐

上一篇:Instant 自托管数据库升级指南:从 PostgreSQL 16 平滑迁移到 17(dump/restore 全流程)
下一篇:Smart Connections:5分钟搭好Obsidian语义搜索,旧笔记不再石沉大海

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询