repeat..until和continue语句
本节介绍 repeat..until语句,并讨论和尝试引入Lua语言并不支持的continue语句。
repeat..until语句
repeat..until语句跟while语句很像,只不过是把判断条件放在了后面,从而保证内部代码块至少执行一次。
+--------+
| repeat |
+--------+
/--->
| block
|
| +-----------------+
\----| until condition |
+-----------------+
最终生成的字节码序列的格式如下,其中...
代表内部代码块的字节码序列:
... <--\
Test ---/ until判断条件
跟while语句的字节码序列相比,看上去就是把Test放到最后,并替换掉原来的Jump字节码。但情况并没有这么简单!把判断条件语句放到block后面,会引入一个问题,判断条件语句中可能会使用block中定义的局部变量。比如下面例子:
-- 如果请求失败,则一直重试,直到成功为止
repeat
local ok = request_xxx()
until ok
最后一行until后面的变量ok
,本意明显是要引用第二行中定义的局部变量。但是,之前的代码块分析函数block()
在函数结尾就已经删除了内部定义的局部变量,代码参见这里。也就是说,按照之前的语法分析逻辑,在解析到until
时,内部定义的ok
局部变量已经失效,无法使用了。这显然是不可接受的。
为了支持在until时依然能读到内部局部变量,需要修改原来的block()
函数(代码就是被这些奇怪的需求搞乱的),把对局部变量的控制独立出来。为此,新增一个block_scope()
函数,只做语法分析;而内部局部变量的作用域由外层的block()
函数完成。这样原来调用block()
函数的地方(比如if、while语句等)就不用修改,而这个特别的repeat..until语句调用block_scope()
函数,做更细的控制。代码如下:
fn block(&mut self) -> Token {
let nvar = self.locals.len();
let end_token = self.block_scope();
self.locals.truncate(nvar); // expire internal local variables
return end_token;
}
fn block_scope(&mut self) -> Token {
... // 原有的block解析过程
}
然后,repeat..until语句的分析代码如下:
fn repeat_stat(&mut self) {
let istart = self.byte_codes.len();
self.push_break_block();
let nvar = self.locals.len(); // 内部局部变量作用域控制!
assert_eq!(self.block_scope(), Token::Until);
let icond = self.exp_discharge_top();
// expire internal local variables AFTER condition exp.
self.locals.truncate(nvar); // 内部局部变量作用域控制!
let iend = self.byte_codes.len();
self.byte_codes.push(ByteCode::Test(icond as u8, -((iend - istart + 1) as i16)));
self.pop_break_block();
}
上述代码中,中文注释的2行,就是完成了原来block()
函数中内部局部变量作用域的控制。在调用完exp_discharge_top()
解析完条件判断语句之后,才去删除内部定义的局部变量。
continue语句
上面花了很大篇幅来说明repeat..until语句中变量作用域的问题,这跟Lua中并不存在的continue语句也有很大关系。
在上一节支持break语句时,提到了Lua语言并不支持continue语句。关于这个问题的争论非常多,在Lua中加入continue语句的呼声也很高,早在2012年就有相关的提案,其中详细罗列了加入continue语句的好处和坏处以及相关讨论。20年过去了,倔强的Lua即使在5.2版本加入了goto语句,也仍然没有加入continue语句。
“非官方FAQ”对此的解释是:
- continue语句只是众多控制语句之一,类似的包括goto、带label的break等。而continue语句并没有什么特殊,没有必要新增这个语句;
- 跟现有的repeat..until语句冲突。
另外,Lua的作者Roberto的一封邮件更能代表官方态度。其中说的原因就是上述第1点,即continue语句只是众多控制语句之一。一个有意思的地方是,这封邮件里举了两个例子,除continue外另外一个例子刚好也是repeat..until。上面非官方FAQ里也提到这两个语句冲突。
这两个语句冲突的原因是,如果repeat..until内部代码块中有continue语句,那么就会跳转到until的条件判断位置;如果条件判断语句中使用了内部定义的局部变量,而continue语句又跳过了这个局部变量的定义,那这个局部变量就没有意义了。这就是冲突所在。比如下面的代码:
repeat
continue -- 跳转到until,跳过了ok的定义
local ok = request_xxx()
until ok -- 这里ok如何处理?
对比下,C语言中跟repeat..until语句等价的是do..while语句,是支持continue的。这是因为C语言的do..while语句中,while后面的条件判断是在内部代码块的作用域之外的。比如下面代码就会编译错误:
do {
bool ok = request_xx();
} while (ok); // error: ‘ok’ undeclared
这样的规范(条件判断是在内部代码块的作用域之外)虽然在有的使用场景下不太方便(如上面的例子),但也有很简单的解决方法(比如把ok
定义挪到循环外面),而且语法分析也更简单,比如就不需要拆出block_scope()
函数了。那Lua为什么规定把条件判断语句放到内部作用域之内呢?推测如下,假如Lua也按照C语言的做法(条件判断是在内部代码块的作用域之外),然后用户写出下面的Lua代码,until后面的ok
就被解析为一个全局变量,而不会像C语言那样报错!这并不是用户的本意,于是造成一个严重的bug。
repeat
local ok = request_xxx()
until ok
总结一下,就是repeat..until语句为了避免大概率出现的bug,需要把until后面的条件判断语句放到内部代码块的作用域之内;那么continue语句跳转到条件语句中时,就可能跳过局部变量的定义,进而出现冲突。
尝试添加continue语句
Lua官方不支持continue语句的理由主要是他们认为continue语句的使用频率很低,不值得支持。但是在我个人编程经历中,无论是Lua还是其他语言,continue语句的使用频率还是很高的,虽然可能比不上break,但是远超goto和带label的break语句,甚至也超过repeat..until语句。而现在Lua中实现continue功能的方式(repeat..until true加break,或者goto)都比直接使用continue要啰嗦。那么能不能在我们的解释器中增加continue语句呢?
首先,自然是要解决上面说的跟repeat..until的冲突。有几个解决方案:
-
规定repeat..until中不支持continue语句,就像if语句不支持continue一样。但这样非常容易造成误会。比如一段代码有两层循环,外层是while循环,内层是repeat循环;用户在内层循环中写了continue语句,本意是想在内层repeat循环生效,但由于实际上repeat不支持continue,那么就会在外层while循环生效,continue了外层的while循环。这是严重的潜在bug。
-
规定repeat..until中禁止continue语句,如果有continue则报错,这样可以规避上面方案的潜在bug,但是这个禁止过分严格了。
-
规定repeat..until中如果定义了内部局部变量,则禁止continue语句。这个方案比上个更宽松了些,但可以更加宽松。
-
规定repeat..until中出现continue语句后,就禁止再定义内部局部变量;或者说,continue禁止向局部变量定义之后跳转。这个跟后续的goto语句的限制类似。不过,还可以更加宽松。
-
在上一个方案的基础上,只有until后面的条件判断语句中使用了continue语句后面定义的局部变量,才禁止。只不过判断语句中是否使用局部变量的判定很复杂,如果后续再支持了函数闭包和Upvalue,就基本不可能判定了。所以这个方案不可行。
最终选择使用倒数第2个方案。具体编码实现,原来在ParseProto
中有break_blocks
用来记录break语句,现在新增类似的continue_blocks
,但成员类型是(icode, nvar)
。其中第一个变量icode和break_blocks
的成员一样,记录continue语句对应的Jump字节码的位置,用于后续修正;第二个变量nvar
代表continue语句时局部变量的个数,用于后续检查是否跳转过新的局部变量。
其次,新增continue语句不能影响现有的代码。为了支持continue语句需要把continue
作为一个关键字(类似break
关键字),那么很多现存Lua代码中使用continue
作为label,甚至是变量名或函数名(本质也是变量名)的地方就会解析失败。为此,一个tricky的解决方案是不把continue
作为关键字,而是在解析语句时判断如果开头是continue
并且后面紧跟块结束Token(比如end
等),就认为是continue语句。这样在其他大部分地方,continue
仍然会被解释为普通的Name。
对应的block_scope()
函数中,以Token::Name开头的部分,新增代码如下:
loop {
match self.lex.next() {
// 省略其他类型语句的解析
t@Token::Name(_) | t@Token::ParL => {
// this is not standard!
if self.try_continue_stat(&t) { // !! 新增 !!
continue;
}
// 以下省略标准的函数调用和变量赋值语句解析
}
其中try_continue_stat()
函数定义如下:
fn try_continue_stat(&mut self, name: &Token) -> bool {
if let Token::Name(name) = name {
if name.as_str() != "continue" { // 判断语句开头是"continue"
return false;
}
if !matches!(self.lex.peek(), Token::End | Token::Elseif | Token::Else) {
return false; // 判断后面紧跟这3个Token之一
}
// 那么,就是continue语句。下面的处理跟break语句处理类似
if let Some(continues) = self.continue_blocks.last_mut() {
self.byte_codes.push(ByteCode::Jump(0));
continues.push((self.byte_codes.len() - 1, self.locals.len()));
} else {
panic!("continue outside loop");
}
true
} else {
false
}
}
在解析到循环体的代码块block前,要先做准备,是push_loop_block()
函数。block结束后,再用pop_loop_block()
处理breaks和continues。breaks对应的Jump是跳转到block结束,即当前位置;而continues对应的Jump跳转位置是根据不同循环而定(比如while循环是跳转到循环开始,而repeat循环是跳转到循环结尾),所以需要参数来指定;另外,处理continus时要检查之后有没有新增局部变量的定义,即对比当前局部变量的数量跟continue语句时局部变量的数量。
// before entering loop block
fn push_loop_block(&mut self) {
self.break_blocks.push(Vec::new());
self.continue_blocks.push(Vec::new());
}
// after leaving loop block, fix `break` and `continue` Jumps
fn pop_loop_block(&mut self, icontinue: usize) {
// breaks
let iend = self.byte_codes.len() - 1;
for i in self.break_blocks.pop().unwrap().into_iter() {
self.byte_codes[i] = ByteCode::Jump((iend - i) as i16);
}
// continues
let end_nvar = self.locals.len();
for (i, i_nvar) in self.continue_blocks.pop().unwrap().into_iter() {
if i_nvar < end_nvar { // i_nvar为continue语句时局部变量的数量,end_nvar为当前局部变量的数量
panic!("continue jump into local scope");
}
self.byte_codes[i] = ByteCode::Jump((icontinue as isize - i as isize) as i16 - 1);
}
}
至此,我们在保证向后兼容情况下,实现了continue语句!可以使用下述代码测试:
-- validate compatibility
continue = print -- continue as global variable name, and assign it a value
continue(continue) -- call continue as function
-- continue in while loop
local c = true
while c do
print "hello, while"
if true then
c = false
continue
end
print "should not print this!"
end
-- continue in repeat loop
repeat
print "hello, repeat"
local ok = true
if true then
continue -- continue after local
end
print "should not print this!"
until ok
-- continue skip local in repeat loop
-- PANIC!
repeat
print "hello, repeat again"
if true then
continue -- skip `ok`!!! error in parsing
end
local ok = true
until ok
repeat..until的存在
上面可以看到由于在until部分需要扩展block中定义的局部变量的作用域,repeat..until语句的存在引入了两个问题:
- 编程实现中,需要特意新建
block_scope()
函数; - 跟continue语句有冲突。
我个人认为,为了支持repeat..until这么一个使用频率很低的语句而引入上面两个问题,有些得不偿失。如果是我来设计Lua语言,是不会支持这个语句的。
官方的《Lua程序设计(第4版)》一书的 8.4练习 一节中,提出了如下问题:
练习8.3:很多人认为,由于repeat-until很少使用,因此在想Lua语言这样的简单的编程语言中最后不要出现,你怎么看?
我是真想知道作者对这个问题的回答,但可惜这本书的练习题都没有给答案。