技术预计阅读 17 分钟6508 字0 次浏览

Antlr4系列⑦:自定义函数与调用栈

function定义/调用、递归、词法作用域、调用栈防死递归

目录

在上一篇文章中我们给语言加上了作用域链,解决了变量定义和赋值混淆、代码块变量泄漏的问题。到目前为止,我们的小型DSL已经具备了变量、四则运算、字符串/布尔类型、分支、循环这些编程语言的基本要素,但还缺一样重要的东西——函数。本篇文章我们就来实现自定义函数:函数的定义、参数传递、return返回值,并支持递归调用。

一、语法设计

  1. 函数定义

    参考主流语言设计function 函数名(参数1, 参数2, ...) { 语句 },为了不让本篇的内容过于膨胀,这里做一个简化:函数的参数和返回值统一按数值(calcu)处理,暂不支持字符串、布尔类型的参数和返回值。

  2. 函数调用

    函数调用形如函数名(参数1, 参数2, ...),可以作为calcu表达式的一部分参与四则运算(比如n * factorial(n - 1)),也可以单独作为一条语句调用(不关心返回值,只是为了利用函数体内的副作用,比如打印)。

  3. return语句

    使用return关键字返回一个值,也可以不带值,表示函数执行到此结束但没有返回值。

  4. 函数需要先定义后调用

    和大多数脚本语言顶层代码从上往下顺序执行的习惯一致,函数必须写在调用它的代码之前,暂不支持前向引用。

二、语法定义

根据以上设计新增语法,完善后的结果如下(只列出本篇新增/改动的部分,完整文件见仓库):

main: setArg SEMI?
    | assignStmt SEMI?
    | print SEMI?
    | ifStmt
    | whileStmt
    | breakStmt
    | continueStmt
    | funcDecl                                              // 新增
    | returnStmt SEMI?                                      // 新增
    | calcu SEMI?                                           // 新增:表达式语句,用于不关心返回值、单纯调用一个函数的场景
    ;

// 新增:函数定义,参数列表可以为空
funcDecl: FUNCTION name = ID '(' paramList? ')' block
        ;
paramList: ID (',' ID)*
         ;

// 新增:return语句,可以不带返回值
returnStmt: RETURN calcu?
          ;

calcu: calcu opt=(MUL|DIV) calcu                          # mulAndDiv
     | calcu opt=(ADD|SUB) calcu                          # addAndSub
     | '(' calcu ')'                                      # parens
     | NUMBER                                             # number
     | ID '(' (calcu (',' calcu)*)? ')'                   # funcCall  // 新增
     | ID                                                 # argValue
     ;

//以下为本篇新增词法
FUNCTION : 'function' ;
RETURN : 'return' ;

接下来对本次新增的部分进行解释。

  1. main新增了三个分支:funcDecl(函数定义)、returnStmt(return语句)、calcu SEMI?(表达式语句)。其中calcu SEMI?这个分支值得说一下:print(...)这类写法本质上也是一种"只是为了产生副作用、不关心结果"的语句,这里我们用更通用的方式,让任意一个calcu表达式都可以独立作为一条语句出现,最直接的用途就是像tryReadY()这样单独调用一个函数而不使用它的返回值。

  2. funcCall被定义成calcu的一个新分支,意味着函数调用可以出现在任何calcu能出现的位置——可以是number result = factorial(5)里等号右边的一部分,也可以是n * factorial(n - 1)这样嵌套在四则运算里的一部分,这也是后面能够写出递归函数的语法基础。

  3. ID '(' ... ')'(函数调用)和ID(变量,即argValue)都是以ID开头,antlr4会根据紧跟在ID后面的到底是不是(来自动判断走哪个分支,不需要我们操心。

三、语法实现

接下来使用idea插件将定义好的.g4文件重新生成java文件覆盖之前的文件。函数的实现涉及三个关键设计点,逐一说明。

1. return如何跳出函数

return可能写在函数体内部很深的if/while嵌套里,和上一篇break/continue的处理思路完全一样:用异常把控制权一路传递回函数调用的地方。

/**
 * return语句使用的控制流异常,命中return时抛出,携带返回值,由函数调用处捕获
 */
public class ReturnException extends RuntimeException {

    private final VisitorResult value;

    public ReturnException(VisitorResult value) {
        this.value = value;
    }

    public VisitorResult getValue() {
        return value;
    }

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

2. 函数体的作用域:词法作用域 而不是 动态作用域

这是本篇最容易踩坑的地方。函数体执行的时候,新建的作用域parent应该指向谁?直觉上可能会写成new Scope(currentScope)——也就是"调用方当时所在的作用域",这样写编译不会报错,简单的例子也能跑通,但存在一个严重的问题:如果调用方在自己的局部作用域里定义了一个变量,恰好和函数内部用到的某个变量重名,函数就会意外地读到调用方的局部变量,而不是报"未定义"的错误。这种"函数能看到调用它的地方的局部变量"的行为叫动态作用域,绝大多数现代编程语言都不是这样设计的。

正确的做法是词法作用域:函数只能访问自己定义时所在的作用域(在我们的实现里,函数只能在最外层定义,也就是全局作用域)和自己的参数,访问不到调用方的局部变量。因此函数执行时新作用域的parent要固定指向globalScope,而不是currentScope

/**
 * 最外层的全局作用域,函数体执行时的父作用域固定指向它,而不是调用者当时所在的作用域,
 * 这样函数内部只能访问全局变量和自己的参数,访问不到调用者的局部变量(词法作用域而非动态作用域)
 */
private final Scope globalScope = new Scope(null);

private Scope currentScope = globalScope;

3. 调用栈:让死递归报错更友好

递归函数一旦忘了写终止条件,会无限调用下去,Java本身会抛出StackOverflowError,报错信息对使用者不太友好。这里自己维护一个简单的调用栈,记录当前调用链上的函数名,超过阈值就主动抛出一个更清晰的异常:

/**
 * 已定义的函数,key为函数名,value为函数定义语法树节点,函数需要先定义后调用
 */
private final Map<String, RuleSetParser.FuncDeclContext> funcDefine = new HashMap<>();

/**
 * 调用栈,保存当前调用链上的函数名,用于在递归层级过深时给出比Java原生StackOverflowError更友好的报错
 */
private final Deque<String> callStack = new ArrayDeque<>();

private static final int MAX_CALL_DEPTH = 1000;

把以上三点串起来,完整的函数定义、调用实现如下:

/**
 * 函数定义,只是把函数名和语法树节点记录下来,真正的执行发生在被调用的时候,因此函数需要先定义后调用
 */
@Override
public VisitorResult visitFuncDecl(RuleSetParser.FuncDeclContext ctx) {
    funcDefine.put(ctx.name.getText(), ctx);
    return VisitorResult.nil();
}

/**
 * return语句,把返回值包装进ReturnException抛出,由函数调用处捕获
 */
@Override
public VisitorResult visitReturnStmt(RuleSetParser.ReturnStmtContext ctx) {
    VisitorResult value = ctx.calcu() == null ? VisitorResult.nil() : visit(ctx.calcu());
    throw new ReturnException(value);
}

/**
 * 函数调用:
 * 1. 参数表达式必须在调用方当前作用域里求值,不能等切换到函数自己的作用域之后再算,
 *    否则参数表达式里如果引用了调用方的局部变量将无法解析。
 * 2. 函数执行时使用的新作用域的parent固定是globalScope而不是调用方的currentScope,
 *    这样函数内部只能看到全局变量和自己的参数,看不到调用方的局部变量。
 * 3. 用callStack记录调用深度,超过阈值主动抛出异常,避免死递归时让Java抛出不友好的StackOverflowError。
 * 4. block执行时如果遇到return会抛出ReturnException,在这里捕获取出返回值;
 *    如果一直执行到block结束都没有return,则函数调用的结果视为VisitorResult.nil()。
 */
@Override
public VisitorResult visitFuncCall(RuleSetParser.FuncCallContext ctx) {
    String funcName = ctx.ID().getText();
    RuleSetParser.FuncDeclContext funcDecl = funcDefine.get(funcName);
    if (funcDecl == null) {
        throw new RuntimeException("未定义的函数:" + funcName);
    }
    List<String> paramNames = funcDecl.paramList() == null
            ? Collections.emptyList()
            : paramNames(funcDecl.paramList());
    List<RuleSetParser.CalcuContext> argExprs = ctx.calcu();
    if (argExprs.size() != paramNames.size()) {
        throw new RuntimeException("函数" + funcName + "需要" + paramNames.size() + "个参数,实际传入" + argExprs.size() + "个");
    }
    if (callStack.size() >= MAX_CALL_DEPTH) {
        throw new RuntimeException("函数调用层级超过" + MAX_CALL_DEPTH + "层,可能存在死递归:" + callStack);
    }
    List<Double> argValues = new ArrayList<>(argExprs.size());
    for (RuleSetParser.CalcuContext argExpr : argExprs) {
        argValues.add(visit(argExpr).getNumber());
    }

    Scope callerScope = currentScope;
    Scope funcScope = new Scope(globalScope);
    for (int i = 0; i < paramNames.size(); i++) {
        funcScope.define(paramNames.get(i), argValues.get(i));
    }

    callStack.push(funcName);
    currentScope = funcScope;
    try {
        visit(funcDecl.block());
        return VisitorResult.nil();
    } catch (ReturnException e) {
        return e.getValue();
    } finally {
        currentScope = callerScope;
        callStack.pop();
    }
}

private List<String> paramNames(RuleSetParser.ParamListContext paramList) {
    List<String> names = new ArrayList<>();
    paramList.ID().forEach(id -> names.add(id.getText()));
    return names;
}

关于参数求值的顺序:注意参数表达式argValues是在切换currentScope之前求值的。如果先切换作用域再计算参数,参数表达式里但凡引用了调用方的局部变量(比如add(x + 1, y)里的xy),就会因为找不到而报错——因为切换之后currentScope已经变成了只能看到全局作用域的函数作用域。

四、测试代码与执行结果

用三个例子分别验证:普通函数调用、递归函数、以及词法作用域是否生效。

String addExpression =
        "function add(a, b) { \n" +
                "    return a + b \n" +
                "} \n" +
                "number sum = add(3, 4) \n" +
                "print(sum)";
calcute(addExpression);

String factorialExpression =
        "function factorial(n) { \n" +
                "    if (n <= 1) { \n" +
                "        return 1 \n" +
                "    } \n" +
                "    return n * factorial(n - 1) \n" +
                "} \n" +
                "number result = factorial(5) \n" +
                "print(result)";
calcute(factorialExpression);

String scopeExpression =
        "function tryReadY() { \n" +
                "    print(y) \n" +
                "} \n" +
                "if (true) { \n" +
                "    number y = 1 \n" +
                "    tryReadY() \n" +
                "}";
calcute(scopeExpression);

执行结果如下:

执行:
function add(a, b) {
    return a + b
}
number sum = add(3, 4)
print(sum)

7.0
执行:
function factorial(n) {
    if (n <= 1) {
        return 1
    }
    return n * factorial(n - 1)
}
number result = factorial(5)
print(result)

120.0
执行:
function tryReadY() {
    print(y)
}
if (true) {
    number y = 1
    tryReadY()
}

Exception in thread "main" java.lang.RuntimeException: 未定义的变量:y
	at cn.irule.MyRuleSetVisitor.visitPrintArg(MyRuleSetVisitor.java:127)
	......
	at cn.irule.MyRuleSetVisitor.visitFuncCall(MyRuleSetVisitor.java:408)
	......

第一个例子add(3, 4)打印出7.0;第二个例子factorial(5)通过n * factorial(n - 1)递归计算阶乘,打印出120.0,说明函数调用可以正确嵌套在四则运算里,也支持递归;第三个例子里,y是在调用tryReadY()if代码块里定义的局部变量,tryReadY函数体内直接访问y并没有"意外地"拿到调用方的1,而是正确地报出"未定义的变量:y"——证明我们的函数确实是词法作用域,而不是动态作用域,符合预期。

五、遗留的问题

到这里,我们的小型DSL已经是一门"五脏俱全"的编程语言了:变量、四则运算、条件、循环、作用域、函数递归,一应俱全。也正是因为有了函数调用和调用栈的概念,接下来终于可以着手实现一个有实际意义的功能——单步调试:设置断点、单步执行、查看调用栈和变量值。下一篇文章我们将实现调试器的第一部分:断点和单步执行的基础机制。

花开空白

西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论