用仓颉实现一个可运行的表达式引擎:tinyexpr4cj 设计与实践 ✍️

关键词: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, esin, 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,一起把它打磨得更好。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

扑克中的黑桃A

感谢您的支持

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值