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);而string、boolean定义的变量,等号右边却只能写一个孤零零的字面量(STRING/BOOL词法)。这种不对称从第③篇文章就一直存在,当时没有引起什么问题,是因为字符串、布尔值从来没有参与过运算,"能不能写表达式"根本无所谓。现在我们想支持"Hello, " + name这样的字符串拼接,问题就暴露出来了:拼接的结果也是一个字符串,如果string类型的变量依然只能接一个STRING词法,这个拼接出来的结果根本没地方放。
二、解决思路
思路很直接:让calcu不仅能产生数值,也能产生字符串、布尔值,ADD(+)运算符在遇到字符串时自动切换成拼接语义。做到这一步之后,一个有意思的副作用出现了——setArg、assignStmt、print里那些因为"数值/字符串/布尔"而各自独立的语法分支,很多都可以合并成一个,因为它们的语法结构本来就是一样的,只是等号右边接受的类型不同,而现在等号右边统一都是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
;
新增了STRING、BOOL两个基本值分支,calcu现在能表达"数值 / 字符串 / 布尔值 / 数组 / 函数调用 / 变量"里的任意一种。
有了这个基础,setArg、assignStmt、print都可以做简化:
// 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要求等号右边算出来必须是字符串);而assignStmt和print原本按类型分开纯粹是语法层面的将就,一旦等号(或括号)右边能表达任意类型,这种拆分就没有必要了,合并之后代码反而更简洁。
四、语法实现
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!(字符串左结合,先拼greeting和name,再拼"!");"Score: " + score把数值87.0自动转成文本拼了上去;print(1 + 2 * 3)证明print现在可以直接打印一个表达式的计算结果而不需要中间变量;print(arr[0] + arr[1])证明数组下标访问的结果也能直接参与加法运算并打印;print(true)证明布尔字面量也能被calcu直接处理;最后用合并后的assign给greeting重新赋值成"Hi, ",再拼接一次得到Hi, World!,说明赋值合并之后行为依然正确。另外把之前几篇文章里函数递归、数组读写、while遍历、if条件的例子重新跑了一遍,结果和之前完全一致,这次重构没有引入回归。
六、遗留的问题
到这里,我们的小型DSL已经把数值、字符串、布尔值、数组这几种类型,统一在了同一套通用表达式(calcu)之上,setArg/assignStmt/print也都因此变得更简洁。不过cond(if/while的条件表达式)目前还是一套独立的语法,比较运算符、逻辑运算符都定义在cond里而不是calcu里——这在当初是为了让calcu保持"纯数值运算"的单纯,现在calcu已经是通用表达式了,cond和calcu之间这条边界还要不要继续保留,是个值得继续思考的问题,这里先留给读者自己想一想,本系列到这里先告一段落。回顾整个系列,从最初一个只会做加减乘除的计算器,到现在拥有变量、分支、循环、作用域、函数递归、数组、内置函数、通用表达式,甚至还有一个能设断点、单步调试的调试器——希望这个循序渐进的过程,能帮助你比较扎实地理解Antlr4从语法设计到解释执行的完整链路。