技术预计阅读 14 分钟5528 字0 次浏览

Antlr4系列⑪:内置函数注册机制

BuiltinFunction注册表、len/sum/abs/max、放宽变量类型限制

目录

上一篇文章给语言加上了数组,为了实现len(arr),我们专门在.g4里加了一条LEN '(' ID ')'语法。本来这篇文章打算继续讲字符串拼接,但动手之后发现:要让字符串拼接的结果能像数值一样参与后续计算(而不仅仅是打印出来看看),需要把calcu从"只处理数值"升级成"能处理任意类型的通用表达式",这个改动比预想的要大,值得单独用一篇文章讲清楚。所以这篇文章我们先做一件更基础也更有必要的事——把len这种"每加一个新能力就要改一次.g4"的做法,换成一套通用的内置函数注册机制,字符串相关的内容放到下一篇。

一、问题:len为什么要单独开一条语法

回头看上一篇的写法:

| LEN '(' ID ')'                                      # arrayLen

这条语法有两个别扭的地方:

  1. len长得和普通函数调用一模一样(名字(参数)),却因为要拿到数组"本身"而不是数组的某个数值,被迫单独设计了一套语法。
  2. 以后每多一个类似的能力(比如求数组之和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() + "定义为非数值型");

读取一个变量这个动作本身,其实不应该关心它到底是什么类型,这个限制放在这里属于"操之过急"。

二、解决思路

  1. 放宽visitArgValue:读取变量只管把值原样包装成VisitorResult返回,类型检查交给真正使用这个值的地方去做(比如四则运算里调用getNumber(),不是数值型自然会在那里报错)。
  2. visitFuncCall里参数求值也不再提前转成Double,改成保留完整的VisitorResult列表。
  3. 新增一个内置函数注册表:Map<String, BuiltinFunction>BuiltinFunction是一个函数式接口,接收List<VisitorResult>参数、返回一个VisitorResult,内部想怎么处理参数、返回什么类型完全自己决定。
  4. 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())));
}

lensum从参数里取出数组(getArray()),absmax从参数里取出数值(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.0sum(arr)算出3 - 7 + 2 + 9 = 7.0abs(-7)7.0max(3, 9)9.0,全部正确;最后故意给len传了一个数值而不是数组,在getArray()里正确地抛出了"非Array类型",说明类型检查被推迟到了真正使用参数的地方,符合预期。另外把上一篇文章数组那个例子(arr[i]读写下标、while (i < len(arr))遍历)重新跑了一遍,结果和上一篇完全一致,说明这次重构没有破坏原有行为。

六、遗留的问题

内置函数注册表解决的是"新增一种不需要用户写代码就能用的能力"该怎么落地的问题,但目前这套机制只在calcu(也就是数值上下文)里能被调用到,返回值如果不是数值(比如以后想加一个内置的str(number)把数字转成字符串),调用方拿到手之后除了打印基本什么都做不了——因为string类型的变量目前还只能通过字符串字面量来定义或赋值,没办法接收一个函数调用的结果。这正是本来想在这篇讲的字符串拼接会遇到的同一个根本问题:要让字符串(乃至将来任何类型)都能像数值一样自由地参与表达式计算、被变量接收,calcu需要从"数值表达式"升级为真正通用的"表达式"。下一篇文章我们就来动这个手术。

花开空白

西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论