技术预计阅读 15 分钟5692 字0 次浏览

Antlr4系列⑫:字符串拼接与通用表达式

calcu升级为通用表达式、字符串拼接、setArg/assignStmt/print合并简化

目录

上一篇文章末尾提到,要支持字符串拼接,calcu需要从"只处理数值"升级成能处理任意类型的通用表达式。这篇文章我们就来做这次手术。改动看起来不小,但每一步都很自然,做完之后你会发现语法反而比以前更简单了。

一、问题:类型信息卡在了语法层面

回顾一下现在的setArg

setArg :'number'  id = ID '='  calcu                      #numarg
       |'string'  id = ID '='  STRING                     #chararg
       |'boolean' id = ID '='  BOOL                       #boolarg
       ;

number定义的变量,等号右边允许写一个完整的表达式(calcu);而stringboolean定义的变量,等号右边却只能写一个孤零零的字面量(STRING/BOOL词法)。这种不对称从第③篇文章就一直存在,当时没有引起什么问题,是因为字符串、布尔值从来没有参与过运算,"能不能写表达式"根本无所谓。现在我们想支持"Hello, " + name这样的字符串拼接,问题就暴露出来了:拼接的结果也是一个字符串,如果string类型的变量依然只能接一个STRING词法,这个拼接出来的结果根本没地方放。

二、解决思路

思路很直接:让calcu不仅能产生数值,也能产生字符串、布尔值,ADD+)运算符在遇到字符串时自动切换成拼接语义。做到这一步之后,一个有意思的副作用出现了——setArgassignStmtprint里那些因为"数值/字符串/布尔"而各自独立的语法分支,很多都可以合并成一个,因为它们的语法结构本来就是一样的,只是等号右边接受的类型不同,而现在等号右边统一都是calcu了。

三、语法定义

先看calcu本身的变化:

calcu: calcu opt=(MUL|DIV) calcu                          # mulAndDiv
     | calcu opt=(ADD|SUB) calcu                          # addAndSub  // 修改:ADD支持字符串拼接
     | '(' calcu ')'                                      # parens
     | NUMBER                                             # number
     | STRING                                             # stringValue  // 新增
     | BOOL                                                # boolValue    // 新增
     | ID '(' (calcu (',' calcu)*)? ')'                   # funcCall
     | ID '[' calcu ']'                                    # arrayGet
     | ID                                                 # argValue
     ;

新增了STRINGBOOL两个基本值分支,calcu现在能表达"数值 / 字符串 / 布尔值 / 数组 / 函数调用 / 变量"里的任意一种。

有了这个基础,setArgassignStmtprint都可以做简化:

// setArg:三个关键字后面统一接calcu,关键字的意义从"决定语法结构"变成了"定义时的一次类型断言"
setArg :'number'  id = ID '='  calcu                      #numarg
       |'string'  id = ID '='  calcu                      #chararg  // 修改
       |'boolean' id = ID '='  calcu                      #boolarg  // 修改
       |'array'   id = ID '='  arrayLiteral                #arrayarg
       ;

// assignStmt:原来numAssign/charAssign/boolAssign三个分支的语法结构其实完全一样
// (都是"id = calcu"),只是靠等号右边的词法形状(calcu/STRING/BOOL)人为地分成三份,
// 现在calcu已经能表达全部三种结果,直接合并成一个
assignStmt: id = ID '=' calcu                              #assign  // 修改:合并了原来的numAssign/charAssign/boolAssign
          | arr = ID '[' index = calcu ']' '=' value = calcu #arrayElemAssign
          ;

// print:同理,三个各自只接受一种类型的分支合并成一个,统一接受calcu,
// 这意味着现在可以直接print(1 + 2)、print(a + b)这样的表达式,不需要先赋值给一个变量再打印
print: PRINT '(' calcu ')'                                 # printValue  // 修改
     ;

可以看到,setArg三个分支之所以还保留着三份,是因为number/string/boolean这几个关键字仍然有存在的意义——它们在定义变量的同时对表达式的结果做了一次类型断言(比如string要求等号右边算出来必须是字符串);而assignStmtprint原本按类型分开纯粹是语法层面的将就,一旦等号(或括号)右边能表达任意类型,这种拆分就没有必要了,合并之后代码反而更简洁。

四、语法实现

1. 字符串/布尔字面量求值,以及一个悄悄存在的历史遗留问题

/**
 * 字符串字面量,去掉两端的引号得到真正的文本内容。以前STRING的原始文本(带引号)会被
 * 一路带着跑到打印的地方才显示出来,现在字符串要参与拼接,引号必须在这里就去掉,
 * 否则"a" + "b"拼出来的会是带着引号的"a""b",不符合直觉
 */
@Override
public VisitorResult visitStringValue(RuleSetParser.StringValueContext ctx) {
    String text = ctx.getText();
    return VisitorResult.value(text.substring(1, text.length() - 1));
}

@Override
public VisitorResult visitBoolValue(RuleSetParser.BoolValueContext ctx) {
    return VisitorResult.value(Boolean.parseBoolean(ctx.getText()));
}

关于引号:细心的读者可能记得,从第③篇文章开始,字符串变量存的其实是带引号的原始文本,打印出来也是带引号的(比如之前文章里的执行结果"字符串")。当时这么做纯粹是图省事——反正字符串只会被原样打印,长什么样打出来就是什么样,没人会较真。但现在字符串要参与拼接,这个"省事"的选择就变成了债——如果不在这里去掉引号,"Hello, " + name拼出来会变成"Hello, ""World"这种带着引号疙瘩的怪东西。所以这篇文章顺便把这个历史遗留问题一起修正了:从这篇开始,字符串打印出来不再带引号,这是语义上更正确的行为。

2. ADD运算符的字符串拼接语义

@Override
public VisitorResult visitAddAndSub(RuleSetParser.AddAndSubContext ctx) {
    VisitorResult left = visit(ctx.calcu(0));
    VisitorResult right = visit(ctx.calcu(1));
    if (ctx.opt.getType() == RuleSetLexer.SUB) {
        return VisitorResult.value(left.getNumber() - right.getNumber());
    }
    // ADD:只要两侧有一侧是字符串,就做字符串拼接(非字符串的一侧借助toString()转成文本),
    // 两侧都是数值时才是加法
    if (left.isVarchar() || right.isVarchar()) {
        return VisitorResult.value(left.toString() + right.toString());
    }
    return VisitorResult.value(left.getNumber() + right.getNumber());
}

SUB(减法)语义不变,永远要求两侧是数值。ADD则先判断两侧有没有字符串,只要有一侧是,就统一走拼接逻辑,非字符串的一侧借助VisitorResult.toString()(这个方法在前几篇文章里就已经实现好了,知道怎么把数值、布尔值格式化成文本)转换成文本再拼起来,所以"Score: " + 87能够正确地拼出"Score: 87.0"

3. 合并之后的定义、赋值、打印

/**
 * 定义字符型变量:等号右边现在是一个通用的calcu,可以是字符串字面量,也可以是字符串拼接
 * 表达式(比如"a" + b),调用getString()既完成了求值,也顺带做了一次类型校验
 */
@Override
public VisitorResult visitChararg(RuleSetParser.CharargContext ctx) {
    currentScope.define(ctx.id.getText(), visit(ctx.calcu()).getString());
    return VisitorResult.nil();
}

/**
 * 定义布尔型变量:等号右边同样改成通用的calcu
 */
@Override
public VisitorResult visitBoolarg(RuleSetParser.BoolargContext ctx) {
    currentScope.define(ctx.id.getText(), visit(ctx.calcu()).getBool());
    return VisitorResult.nil();
}

/**
 * 给已定义的变量重新赋值。calcu现在能产生任意类型的结果,不再需要像以前那样区分
 * numAssign/charAssign/boolAssign,取出原始值直接沿作用域链写回去即可
 */
@Override
public VisitorResult visitAssign(RuleSetParser.AssignContext ctx) {
    VisitorResult value = visit(ctx.calcu());
    assign(ctx.id.getText(), value.getValue());
    return VisitorResult.nil();
}

/**
 * 打印:value现在是通用的calcu,求值之后直接println——VisitorResult自己的toString()
 * 知道该怎么把数值/字符串/布尔值/数组/nil分别显示成什么样子
 */
@Override
public VisitorResult visitPrintValue(RuleSetParser.PrintValueContext ctx) {
    System.out.println(visit(ctx.calcu()));
    return VisitorResult.nil();
}

三个类型专属的visitNumAssign/visitCharAssign/visitBoolAssign合并成了一个visitAssign,三个visitPrintArg/visitPrintBoolean/visitPrintString合并成了一个visitPrintValue——这也是这次重构最直观的收益:代码量不增反减。

五、测试代码与执行结果

String expression =
        "string greeting = \"Hello, \" \n" +
                "string name = \"World\" \n" +
                "print(greeting + name + \"!\") \n" +
                "number score = 87 \n" +
                "print(\"Score: \" + score) \n" +
                "print(1 + 2 * 3) \n" +
                "array arr = [10, 20, 30] \n" +
                "print(arr[0] + arr[1]) \n" +
                "print(true) \n" +
                "greeting = \"Hi, \" \n" +
                "print(greeting + name + \"!\")";
calcute(expression);

执行结果如下:

执行:
string greeting = "Hello, "
string name = "World"
print(greeting + name + "!")
number score = 87
print("Score: " + score)
print(1 + 2 * 3)
array arr = [10, 20, 30]
print(arr[0] + arr[1])
print(true)
greeting = "Hi, "
print(greeting + name + "!")

Hello, World!
Score: 87.0
7.0
30.0
true
Hi, World!

greeting + name + "!"正确拼出了Hello, World!(字符串左结合,先拼greetingname,再拼"!");"Score: " + score把数值87.0自动转成文本拼了上去;print(1 + 2 * 3)证明print现在可以直接打印一个表达式的计算结果而不需要中间变量;print(arr[0] + arr[1])证明数组下标访问的结果也能直接参与加法运算并打印;print(true)证明布尔字面量也能被calcu直接处理;最后用合并后的assigngreeting重新赋值成"Hi, ",再拼接一次得到Hi, World!,说明赋值合并之后行为依然正确。另外把之前几篇文章里函数递归、数组读写、while遍历、if条件的例子重新跑了一遍,结果和之前完全一致,这次重构没有引入回归。

六、遗留的问题

到这里,我们的小型DSL已经把数值、字符串、布尔值、数组这几种类型,统一在了同一套通用表达式(calcu)之上,setArg/assignStmt/print也都因此变得更简洁。不过condif/while的条件表达式)目前还是一套独立的语法,比较运算符、逻辑运算符都定义在cond里而不是calcu里——这在当初是为了让calcu保持"纯数值运算"的单纯,现在calcu已经是通用表达式了,condcalcu之间这条边界还要不要继续保留,是个值得继续思考的问题,这里先留给读者自己想一想,本系列到这里先告一段落。回顾整个系列,从最初一个只会做加减乘除的计算器,到现在拥有变量、分支、循环、作用域、函数递归、数组、内置函数、通用表达式,甚至还有一个能设断点、单步调试的调试器——希望这个循序渐进的过程,能帮助你比较扎实地理解Antlr4从语法设计到解释执行的完整链路。

花开空白

西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论