Antlr4系列⑩:支持数组
array类型、字面量、下标读写、len
前几篇文章连续实现了调试器的能力,我们的小型DSL解释器已经能够被断点、单步调试了。这篇文章回到语言本身,继续补完语言的基础能力——数组(严格来说是"元素类型统一为数值的动态数组",不支持不同类型混装,保持和函数参数一样的简化设计)。
一、语法设计
-
数组定义
参考主流语言,用方括号包裹一组用逗号分隔的值作为数组字面量,比如
[1, 2, 3]。定义一个数组类型的变量,复用已有的number/string/boolean风格,新增array关键字:array arr = [1, 2, 3]。 -
读取元素 / 修改元素
用
arr[下标]读取数组中某个位置的值,下标从0开始;arr[下标] = 值修改数组中某个位置的值。 -
数组长度
新增内置的
len(arr)获取数组长度,配合while循环即可遍历整个数组,不需要额外设计for-each这样的新语法。
二、语法定义
根据以上设计新增语法:
setArg :'number' id = ID '=' calcu #numarg
|'string' id = ID '=' STRING #chararg
|'boolean' id = ID '=' BOOL #boolarg
|'array' id = ID '=' arrayLiteral #arrayarg // 新增
;
// 新增:数组字面量,方括号包裹0个或多个用逗号分隔的calcu
arrayLiteral: '[' (calcu (',' calcu)*)? ']'
;
calcu: calcu opt=(MUL|DIV) calcu # mulAndDiv
| calcu opt=(ADD|SUB) calcu # addAndSub
| '(' calcu ')' # parens
| NUMBER # number
| ID '(' (calcu (',' calcu)*)? ')' # funcCall
| LEN '(' ID ')' # arrayLen // 新增
| ID '[' calcu ']' # arrayGet // 新增
| ID # argValue
;
// assignStmt新增一个分支,用于修改数组中某个下标的值
assignStmt: id = ID '=' calcu #numAssign
| id = ID '=' STRING #charAssign
| id = ID '=' BOOL #boolAssign
| arr = ID '[' index = calcu ']' '=' value = calcu #arrayElemAssign // 新增
;
//以下为本篇新增词法
LEN : 'len' ;
arrayLiteral里的元素直接复用calcu,意味着数组的每个元素既可以是字面量,也可以是变量甚至一个完整的四则运算表达式,比如array arr = [a + 1, b * 2]。arr[下标]这种写法在calcu里新增了一个arrayGet分支,在assignStmt里新增了一个arrayElemAssign分支,分别对应"读"和"写"两种场景——这和之前"定义用setArg,赋值用assignStmt"是同一种思路的延续。
三、语法实现
先扩展一下VisitorResult,之前只支持数值、字符串、布尔三种类型,现在加上数组(内部用List<Double>表示):
private VisitorResult(Object value) {
if (value instanceof String
|| value instanceof Double
|| value instanceof Boolean
|| value instanceof List
|| value == null) {
this.value = value;
} else {
throw new RuntimeException("不支持的参数类型" + value);
}
}
@SuppressWarnings("unchecked")
public List<Double> getArray() {
if (isArray()) {
return (List<Double>) value;
}
throw new RuntimeException("非Array类型");
}
public boolean isArray() {
return this.value instanceof List;
}
接着在MyRuleSetVisitor里补充数组的定义、字面量求值、读取、长度:
/**
* 定义数组类型变量,先求出数组字面量对应的List,再保存到当前作用域
*/
@Override
public VisitorResult visitArrayarg(RuleSetParser.ArrayargContext ctx) {
VisitorResult array = visit(ctx.arrayLiteral());
currentScope.define(ctx.id.getText(), array.getArray());
return VisitorResult.nil();
}
/**
* 数组字面量,依次对每个元素求值后收集成一个List
*/
@Override
public VisitorResult visitArrayLiteral(RuleSetParser.ArrayLiteralContext ctx) {
List<Double> values = new ArrayList<>();
for (RuleSetParser.CalcuContext element : ctx.calcu()) {
values.add(visit(element).getNumber());
}
return VisitorResult.value(values);
}
/**
* 按下标读取数组元素
*/
@Override
public VisitorResult visitArrayGet(RuleSetParser.ArrayGetContext ctx) {
String name = ctx.ID().getText();
List<Double> array = getArray(name);
int index = visit(ctx.calcu()).getNumber().intValue();
checkIndex(name, array, index);
return VisitorResult.value(array.get(index));
}
/**
* len(数组),返回数组长度
*/
@Override
public VisitorResult visitArrayLen(RuleSetParser.ArrayLenContext ctx) {
List<Double> array = getArray(ctx.ID().getText());
return VisitorResult.value((double) array.size());
}
private List<Double> getArray(String name) {
Object value = currentScope.get(name);
if (!(value instanceof List)) {
throw new RuntimeException("未定义的数组:" + name);
}
return (List<Double>) value;
}
private void checkIndex(String name, List<Double> array, int index) {
if (index < 0 || index >= array.size()) {
throw new RuntimeException("数组" + name + "下标越界:" + index + ",数组长度为" + array.size());
}
}
修改元素值的实现有一个值得说的细节:
/**
* 修改数组中某一个下标的值。数组本身是可变的List,被currentScope持有的是同一个引用,
* 这里直接对取出来的List调用set,不需要像标量变量那样再调用一次assign把新值写回作用域,
* 因为"修改数组里某个元素"和"数组变量本身指向另一个对象"是两回事。
*/
@Override
public VisitorResult visitArrayElemAssign(RuleSetParser.ArrayElemAssignContext ctx) {
List<Double> array = getArray(ctx.arr.getText());
int index = visit(ctx.index).getNumber().intValue();
checkIndex(ctx.arr.getText(), array, index);
array.set(index, visit(ctx.value).getNumber());
return VisitorResult.nil();
}
关于引用语义:前几篇文章里,
number/string/boolean变量的赋值都要调用Scope.assign重新把新值put进作用域,是因为这些值本身是不可变的(Java的Double、String、Boolean都是不可变对象),"改值"唯一的办法就是"换一个新对象塞回去"。而数组用的是Java的ArrayList,本身是可变对象,Scope里保存的其实是这个List对象的引用。所以arr[1] = 99不需要调用assign把一个新的List重新塞回作用域,直接对拿到的这个List调用set修改其中一项即可——这也意味着,如果把同一个数组传给两处(目前的语言还没有支持把数组当函数参数传递,但设计上要提前想到这一点),两边修改的会是同一份数据,这是可变对象天然带来的"共享"特性,后续如果要支持数组作为函数参数,需要格外注意这一点。
另外值得一提的是,print(arr)不需要任何额外改造就能直接支持打印数组——visitPrintArg本来就是拿到变量的原始值直接调用System.out.println,Java的List本身有现成的toString()实现(会打印成[10.0, 20.0, 30.0]这样的格式),正好够用。
四、测试代码与执行结果
String expression =
"array arr = [10, 20, 30] \n" +
"print(arr) \n" +
"number first = arr[0] \n" +
"print(first) \n" +
"arr[1] = 99 \n" +
"print(arr) \n" +
"number i = 0 \n" +
"number sum = 0 \n" +
"while (i < len(arr)) { \n" +
" sum = sum + arr[i] \n" +
" i = i + 1 \n" +
"} \n" +
"print(sum) \n" +
"number bad = arr[10]"; // 故意制造一个越界访问
calcute(expression);
执行结果如下:
执行:
array arr = [10, 20, 30]
print(arr)
number first = arr[0]
print(first)
arr[1] = 99
print(arr)
number i = 0
number sum = 0
while (i < len(arr)) {
sum = sum + arr[i]
i = i + 1
}
print(sum)
number bad = arr[10]
[10.0, 20.0, 30.0]
10.0
[10.0, 99.0, 30.0]
139.0
Exception in thread "main" java.lang.RuntimeException: 数组arr下标越界:10,数组长度为3
at cn.irule.MyRuleSetVisitor.checkIndex(MyRuleSetVisitor.java:276)
......
依次验证:数组字面量定义并打印[10.0, 20.0, 30.0];按下标读取arr[0]得到10.0;arr[1] = 99修改元素后再打印,变成[10.0, 99.0, 30.0],说明修改是原地生效的;用while配合len(arr)遍历数组求和,10 + 99 + 30 = 139.0,结果正确;最后故意访问下标10(数组只有3个元素),程序抛出了预期的越界异常,符合边界检查的设计。
五、遗留的问题
目前数组的元素只能是数值,还不支持字符串数组或者混合类型数组,也还没有支持把数组当作参数传给函数(第⑦篇实现的函数目前只接受数值参数)。这些都是可以在数组这个基础上继续扩展的方向。下一篇文章我们计划把目光转向字符串相关的能力:字符串拼接、和数值之间的相互转换,以及内置函数的注册机制,让len(arr)这样的写法未来能够以一种更通用、可扩展的方式支持更多内置能力。