目录
关键词:Cangjie / 递归下降 / 表达式解析 / AST / 测试 / 性能
背景 🌟
在众多业务场景中,我们需要在运行时对用户给出的字符串表达式进行求值:快捷计算、流程编排、数据透视、报表模板、规则引擎……这类能力如果用脚本语言嵌入,成本并不低;而像 TinyExpr 这样的“轻量表达式引擎”,恰好用很小的代码量,满足“够用”的表达式需求。
本项目将开源的 TinyExpr 迁移到了仓颉(Cangjie)语言,命名为 tinyexpr4cj。本文介绍该项目的语法设计、词法/语法解析、AST 与求值、误差与健壮性、测试策略等实践经验。
语法与语义 📐
TinyExpr 的语法十分克制:
<list> = <expr> {"," <expr>} // 返回最后一个表达式
<expr> = <term> {("+" | "-") <term>}
<term> = <factor> {("*" | "/" | "%") <factor>}
<factor> = <power> {"^" <power>} // 指数左结合
<power> = {("-" | "+")} <base> // 一元正负号
<base> = 常量 / 变量 / 函数调用 / 括号
几个关键点:
- 表达式列表:
a, b, c的值为c,非常适合“顺序求值,取最终结果”的场景。 - 指数运算:采用“左结合”而非多数语言的“右结合”,这与上游 TinyExpr、Office 表格类产品一致。
- 函数:包含常用初等函数以及
fac, ncr, npr,满足工程计算与组合数学的基本需求。
支持的函数与常量 🧠
- 常量:
pi, e - 单参函数:
sin, cos, tan, asin, acos, atan, sqrt, exp, ln(=log), log10, ceil, floor, sinh, cosh, tanh, abs, fac - 双参函数:
pow, atan2, ncr, npr
运算符优先级(高→低):一元 + -,指数 ^(左结合),乘除模 * / %,加减 + -。
词法与语法实现 🧩
词法(lexer.cj) 🔤
数值字面量支持整数、小数、科学计数法;标识符规则为 [A-Za-z_][A-Za-z0-9_]*。为了降低复杂度,当前未启用十六进制字面量(需要时可扩展)。
解析(parser.cj) 🧩
解析器采用“递归下降”方式,完全按照优先级由高到低、从小组合成大的 AST。相较于通用解析器,递归下降对这种小语法极为直观且高效。
AST 与求值 🌳
node.cj 定义了 NumNode / VarNode / UnaryNode / BinaryNode / CallNode 等节点:
VarNode:先从变量环境HashMap<String, Float64>取值,若命名为pi/e则返回常量;其余未知标识符按0.0处理,避免运行时异常在业务侧扩散(HLT 用例有覆盖)。CallNode:映射到std.math.*中的函数,ncr/npr/fac采用整型四舍五入与阶乘实现,越界返回0.0。
求值采用自顶向下的递归过程,每个节点只关心自身操作数的求值与组合,易于单元测试和调试。
接口与用法总览 🔌
以下为对外 API 与返回约定(摘自公开接口文档),并给出最小用例:
import std.collection.*
// 可执行表达式
public class CJExpr {
public func eval(env: HashMap<String, Float64>): Float64
}
// 编译结果
public class CompileResult {
public let expr: Option<CJExpr>
public let errorPos: Option<Int64>
}
// 编译/执行接口
public func compile(expr: String): CompileResult
public func eval(expr: CJExpr, env: HashMap<String, Float64>): Float64
// 用法示例
import std.collection.HashMap
import tinyexpr.*
main() {
// 编译表达式
let cr = compile("sqrt(x^2+y^2)")
if (!cr.expr.isSome()) { return }
let program = cr.expr.getOrThrow()
// 准备变量环境
var env = HashMap<String, Float64>()
env.add("x", 3.0)
env.add("y", 4.0)
// 求值 -> 5.0
println(eval(program, env))
}
语义要点:
- 列表表达式取最后一个值;
- 未在环境中提供的变量:若为
pi/e返回内置常量,其它按0.0处理; - 指数
^采用左结合:a^b^c == (a^b)^c。
误差、健壮性与权衡 🛡️
- 浮点误差:接口使用
Float64,测试中通过“绝对误差约束”判断(如1e-9)。 - 未知变量:按
0.0处理,使得像sin(pi/2)+round0这样的表达式在无round0时仍可运行,且行为直观(=1)。 - 组合函数:
ncr/npr/fac的输入会被“四舍五入取整”,后续采取边界判断,避免负数/越界导致异常。
测试策略 🧪
- HLT:从表达式字符串到结果的端到端测试,覆盖:三角/双曲/对数、指数左结合、表达式列表、组合函数等。
- LLT:广谱表达式与函数、变量绑定、非法输入(如不配对括号、逗号、参数分隔)等,确保鲁棒性。
在本项目中,我们避免直接依赖 @Assert/assertEqual 宏,以抛出 Exception 的方式进行断言,从而绕过某些宏的泛型推断限制,更贴近“失败即报错”的直观语义。
构建与测试 🔧
构建:
cjpm update
cjpm build
运行测试(推荐在各测试包目录中执行):
cd test/HLT && cjpm test
cd ../LLT && cjpm test
单文件快速试用(需先构建库):
cjc --import-path target/release/tinyexpr \
test/HLT/tinyexpr_test.cj \
-L target/release/tinyexpr -l tinyexpr
变更记录(v1.0.0 摘要) 🗒️
- 初始发布:Cangjie 版 TinyExpr(tinyexpr4cj)
- 递归下降解析与求值:
+ - * / % ^、括号、逗号(表达式列表取最后一个值) - 内置常量与函数:
pi, e;sin, cos, tan, asin, acos, atan, sqrt, exp, ln, log10, ceil, floor, sinh, cosh, tanh, abs, pow, atan2, fac, ncr, npr - 变量环境:
HashMap<String, Float64>传入变量值 - 指数左结合:
a^b^c == (a^b)^c - HLT/LLT 两层测试覆盖常见与边界场景
性能简析 ⚙️
递归下降 + 常量折叠使得短表达式与“重计算”(如指数/三角)表现良好;大量仅加减的长表达式,手写计算可能更快。这与上游 TinyExpr 的“快/慢”边界一致。
何时选择 tinyexpr4cj? ❓
- 需要一个“极简、可嵌入”的运行时公式引擎。
- 业务表达式主要由标准数学函数与四则运算构成,且长度适中。
如何继续演进 🚀
- 右结合指数的可选支持;
- 扩展数字字面量(十六/二进制);
- 更丰富的函数族(统计、金融);
- 更好的错误上下文与恢复(如建议修复位置)。
代码片段:一眼看懂运行路径 🧭
// 对外 API(tinyexpr.cj)
public func compile(expr: String): CompileResult {
let p = Parser(expr)
let (nodeOpt, pos) = p.parse()
if (nodeOpt.isSome()) { return CompileResult(Some(CJExpr(nodeOpt.getOrThrow())), None) }
return CompileResult(None, Some(pos))
}
public func eval(expr: CJExpr, env: HashMap<String, Float64>): Float64 { expr.eval(env) }
结语 ✍️
tinyexpr4cj 以极小的体积与复杂度,覆盖了大多数工程计算所需的表达式场景。希望它能成为你在 Cangjie 项目中“一行导入即可用”的小工具。欢迎提交 Issue/PR,一起把它打磨得更好。

被折叠的 条评论
为什么被折叠?



