Antlr4系列⑪:内置函数注册机制
BuiltinFunction注册表、len/sum/abs/max、放宽变量类型限制
上一篇文章给语言加上了数组,为了实现len(arr),我们专门在.g4里加了一条LEN '(' ID ')'语法。本来这篇文章打算继续讲字符串拼接,但动手之后发现:要让字符串拼接的结果能像数值一样参与后续计算(而不仅仅是打印出来看看),需要把calcu从"只处理数值"升级成"能处理任意类型的通用表达式",这个改动比预想的要大,值得单独用一篇文章讲清楚。所以这篇文章我们先做一件更基础也更有必要的事——把len这种"每加一个新能力就要改一次.g4"的做法,换成一套通用的内置函数注册机制,字符串相关的内容放到下一篇。
一、问题:len为什么要单独开一条语法
回头看上一篇的写法:
| LEN '(' ID ')' # arrayLen
这条语法有两个别扭的地方:
len长得和普通函数调用一模一样(名字(参数)),却因为要拿到数组"本身"而不是数组的某个数值,被迫单独设计了一套语法。- 以后每多一个类似的能力(比如求数组之和
sum、绝对值abs),都要重复这个套路——加词法、加语法分支、加对应的visitXXX方法,而这些能力本质上并没有引入任何新的语法结构,只是"内置的、不需要用户自己定义的函数"而已。
真正的问题出在visitFuncCall里:调用函数时,参数是这样求值的:
argValues.add(visit(argExpr).getNumber()); // 旧代码
不管函数是用户自己写的还是将来想内置的,参数一律被强制转成Double。这就是为什么len(arr)没法直接复用funcCall语法——arr作为参数传进去的一瞬间就被要求必须是数值,数组这个类型直接被拦在了门外。而它的根源还要往前追一步:visitArgValue在读取变量的时候就已经做了限制:
// 旧代码
Object value = currentScope.get(ctx.getText());
if (value instanceof Double) {
return VisitorResult.value(value);
}
throw new RuntimeException("未定义的参数:" + ctx.getText() + " 或参数" + ctx.getText() + "定义为非数值型");
读取一个变量这个动作本身,其实不应该关心它到底是什么类型,这个限制放在这里属于"操之过急"。
二、解决思路
- 放宽
visitArgValue:读取变量只管把值原样包装成VisitorResult返回,类型检查交给真正使用这个值的地方去做(比如四则运算里调用getNumber(),不是数值型自然会在那里报错)。 visitFuncCall里参数求值也不再提前转成Double,改成保留完整的VisitorResult列表。- 新增一个内置函数注册表:
Map<String, BuiltinFunction>,BuiltinFunction是一个函数式接口,接收List<VisitorResult>参数、返回一个VisitorResult,内部想怎么处理参数、返回什么类型完全自己决定。 visitFuncCall调用时优先查内置函数表,查不到再按老办法查用户自定义函数(funcDefine)。
这样len就不再需要任何专属语法,直接复用已有的ID '(' 参数列表 ')'(也就是funcCall)语法即可,.g4反而可以做减法——把上一篇专门为len加的LEN词法和arrayLen语法整个删掉。
三、语法定义
.g4这次是做减法:
calcu: calcu opt=(MUL|DIV) calcu # mulAndDiv
| calcu opt=(ADD|SUB) calcu # addAndSub
| '(' calcu ')' # parens
| NUMBER # number
| ID '(' (calcu (',' calcu)*)? ')' # funcCall // 修改:user函数和内置函数统一走这里
| ID '[' calcu ']' # arrayGet
| ID # argValue
;
删掉了LEN '(' ID ')' # arrayLen这一行,以及词法里的LEN : 'len' ;——len不再是语言的一个语法关键字,只是内置函数表里的一个普通条目。
四、语法实现
先定义内置函数的接口:
/**
* 内置函数的统一接口,和用户用function定义的函数不同,内置函数直接用Java代码实现,
* 参数以VisitorResult的原始形态传入,具体是数值、字符串还是数组由函数自己按需取用,
* 不像调用用户函数那样强制把每个参数都转成Double
*/
@FunctionalInterface
public interface BuiltinFunction {
VisitorResult call(List<VisitorResult> args);
}
注册几个内置函数,作为示例:
/**
* 注册内置函数。相比每新增一个能力就往.g4里加一条专门的语法规则(比如上一篇文章里len曾经
* 单独占用的LEN词法和arrayLen语法),注册成内置函数不需要改语法,只要在这里加一行,
* 新的能力就能通过已有的"函数调用"语法直接使用
*/
private void registerBuiltins() {
builtins.put("len", args -> VisitorResult.value((double) args.get(0).getArray().size()));
builtins.put("sum", args -> {
double total = 0;
for (Double item : args.get(0).getArray()) {
total += item;
}
return VisitorResult.value(total);
});
builtins.put("abs", args -> VisitorResult.value(Math.abs(args.get(0).getNumber())));
builtins.put("max", args -> VisitorResult.value(Math.max(args.get(0).getNumber(), args.get(1).getNumber())));
}
len和sum从参数里取出数组(getArray()),abs和max从参数里取出数值(getNumber())——是数组还是数值完全由内置函数自己按需解读,visitFuncCall本身不再对参数类型做任何假设:
@Override
public VisitorResult visitFuncCall(RuleSetParser.FuncCallContext ctx) {
String funcName = ctx.ID().getText();
List<VisitorResult> argValues = new ArrayList<>();
for (RuleSetParser.CalcuContext argExpr : ctx.calcu()) {
argValues.add(visit(argExpr));
}
BuiltinFunction builtin = builtins.get(funcName);
if (builtin != null) {
return builtin.call(argValues);
}
RuleSetParser.FuncDeclContext funcDecl = funcDefine.get(funcName);
if (funcDecl == null) {
throw new RuntimeException("未定义的函数:" + funcName);
}
List<String> paramNames = funcDecl.paramList() == null
? Collections.emptyList()
: paramNames(funcDecl.paramList());
if (argValues.size() != paramNames.size()) {
throw new RuntimeException("函数" + funcName + "需要" + paramNames.size() + "个参数,实际传入" + argValues.size() + "个");
}
if (callStack.size() >= MAX_CALL_DEPTH) {
throw new RuntimeException("函数调用层级超过" + MAX_CALL_DEPTH + "层,可能存在死递归:" + callStack);
}
Scope callerScope = currentScope;
Scope funcScope = new Scope(globalScope);
for (int i = 0; i < paramNames.size(); i++) {
funcScope.define(paramNames.get(i), argValues.get(i).getNumber()); // 用户函数的参数仍然只支持数值
}
callStack.push(funcName);
currentScope = funcScope;
try {
visit(funcDecl.block());
return VisitorResult.nil();
} catch (ReturnException e) {
return e.getValue();
} finally {
currentScope = callerScope;
callStack.pop();
}
}
关于内置函数不占用调用栈:内置函数是直接执行的Java代码,不涉及
Scope切换,也不会递归地重新走一遍visitFuncCall,所以既不需要callStack.push,也不会有栈溢出的风险,这也是它和用户自定义函数的本质区别——一个是解释器"解释执行"的脚本代码,一个是解释器自身"原生实现"的能力。
最后放宽visitArgValue:
@Override
public VisitorResult visitArgValue(RuleSetParser.ArgValueContext ctx) {
// 这里不再要求value必须是Double:读取变量本身不应该关心它到底是什么类型,
// 类型是否匹配交给真正使用这个值的地方去检查(比如四则运算会调用getNumber(),
// 不是数值型就会在那里报错),这样像len(arr)这样把数组整体作为参数传递才有可能实现
Object value = currentScope.get(ctx.getText());
if (value == null) {
throw new RuntimeException("未定义的参数:" + ctx.getText());
}
return VisitorResult.value(value);
}
五、测试代码与执行结果
String expression =
"array arr = [3, -7, 2, 9] \n" +
"number arrLen = len(arr) \n" +
"print(arrLen) \n" +
"number arrSum = sum(arr) \n" +
"print(arrSum) \n" +
"number absVal = abs(-7) \n" +
"print(absVal) \n" +
"number maxVal = max(3, 9) \n" +
"print(maxVal) \n" +
"number bad = len(5)"; // 故意传一个非数组的参数给len
calcute(expression);
执行结果如下:
执行:
array arr = [3, -7, 2, 9]
number arrLen = len(arr)
print(arrLen)
number arrSum = sum(arr)
print(arrSum)
number absVal = abs(-7)
print(absVal)
number maxVal = max(3, 9)
print(maxVal)
number bad = len(5)
4.0
7.0
7.0
9.0
Exception in thread "main" java.lang.RuntimeException: 非Array类型
at cn.irule.VisitorResult.getArray(VisitorResult.java:60)
at cn.irule.MyRuleSetVisitor.lambda$registerBuiltins$0(MyRuleSetVisitor.java:64)
......
len(arr)得到4.0,sum(arr)算出3 - 7 + 2 + 9 = 7.0,abs(-7)是7.0,max(3, 9)是9.0,全部正确;最后故意给len传了一个数值而不是数组,在getArray()里正确地抛出了"非Array类型",说明类型检查被推迟到了真正使用参数的地方,符合预期。另外把上一篇文章数组那个例子(arr[i]读写下标、while (i < len(arr))遍历)重新跑了一遍,结果和上一篇完全一致,说明这次重构没有破坏原有行为。
六、遗留的问题
内置函数注册表解决的是"新增一种不需要用户写代码就能用的能力"该怎么落地的问题,但目前这套机制只在calcu(也就是数值上下文)里能被调用到,返回值如果不是数值(比如以后想加一个内置的str(number)把数字转成字符串),调用方拿到手之后除了打印基本什么都做不了——因为string类型的变量目前还只能通过字符串字面量来定义或赋值,没办法接收一个函数调用的结果。这正是本来想在这篇讲的字符串拼接会遇到的同一个根本问题:要让字符串(乃至将来任何类型)都能像数值一样自由地参与表达式计算、被变量接收,calcu需要从"数值表达式"升级为真正通用的"表达式"。下一篇文章我们就来动这个手术。