repeat..until和continue语句

本节介绍 repeat..until语句,并讨论和尝试引入Lua语言并不支持的continue语句。

repeat..until语句

repeat..until语句跟while语句很像,只不过是把判断条件放在了后面,从而保证内部代码块至少执行一次。

  1. +--------+
  2. | repeat |
  3. +--------+
  4. /--->
  5. | block
  6. |
  7. | +-----------------+
  8. \----| until condition |
  9. +-----------------+

最终生成的字节码序列的格式如下,其中...代表内部代码块的字节码序列:

  1. ... <--\
  2. Test ---/ until判断条件

跟while语句的字节码序列相比,看上去就是把Test放到最后,并替换掉原来的Jump字节码。但情况并没有这么简单!把判断条件语句放到block后面,会引入一个问题,判断条件语句中可能会使用block中定义的局部变量。比如下面例子:

  1. -- 如果请求失败,则一直重试,直到成功为止
  2. repeat
  3. local ok = request_xxx()
  4. until ok

最后一行until后面的变量ok,本意明显是要引用第二行中定义的局部变量。但是,之前的代码块分析函数block()在函数结尾就已经删除了内部定义的局部变量,代码参见这里。也就是说,按照之前的语法分析逻辑,在解析到until时,内部定义的ok局部变量已经失效,无法使用了。这显然是不可接受的。

为了支持在until时依然能读到内部局部变量,需要修改原来的block()函数(代码就是被这些奇怪的需求搞乱的),把对局部变量的控制独立出来。为此,新增一个block_scope()函数,只做语法分析;而内部局部变量的作用域由外层的block()函数完成。这样原来调用block()函数的地方(比如if、while语句等)就不用修改,而这个特别的repeat..until语句调用block_scope()函数,做更细的控制。代码如下:

  1. fn block(&mut self) -> Token {
  2. let nvar = self.locals.len();
  3. let end_token = self.block_scope();
  4. self.locals.truncate(nvar); // expire internal local variables
  5. return end_token;
  6. }
  7. fn block_scope(&mut self) -> Token {
  8. ... // 原有的block解析过程
  9. }

然后,repeat..until语句的分析代码如下:

  1. fn repeat_stat(&mut self) {
  2. let istart = self.byte_codes.len();
  3. self.push_break_block();
  4. let nvar = self.locals.len(); // 内部局部变量作用域控制!
  5. assert_eq!(self.block_scope(), Token::Until);
  6. let icond = self.exp_discharge_top();
  7. // expire internal local variables AFTER condition exp.
  8. self.locals.truncate(nvar); // 内部局部变量作用域控制!
  9. let iend = self.byte_codes.len();
  10. self.byte_codes.push(ByteCode::Test(icond as u8, -((iend - istart + 1) as i16)));
  11. self.pop_break_block();
  12. }

上述代码中,中文注释的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语句又跳过了这个局部变量的定义,那这个局部变量就没有意义了。这就是冲突所在。比如下面的代码:

  1. repeat
  2. continue -- 跳转到until,跳过了ok的定义
  3. local ok = request_xxx()
  4. until ok -- 这里ok如何处理?

对比下,C语言中跟repeat..until语句等价的是do..while语句,是支持continue的。这是因为C语言的do..while语句中,while后面的条件判断是在内部代码块的作用域之外的。比如下面代码就会编译错误:

  1. do {
  2. bool ok = request_xx();
  3. } while (ok); // error: ‘ok’ undeclared

这样的规范(条件判断是在内部代码块的作用域之外)虽然在有的使用场景下不太方便(如上面的例子),但也有很简单的解决方法(比如把ok定义挪到循环外面),而且语法分析也更简单,比如就不需要拆出block_scope()函数了。那Lua为什么规定把条件判断语句放到内部作用域之内呢?推测如下,假如Lua也按照C语言的做法(条件判断是在内部代码块的作用域之外),然后用户写出下面的Lua代码,until后面的ok就被解析为一个全局变量,而不会像C语言那样报错!这并不是用户的本意,于是造成一个严重的bug。

  1. repeat
  2. local ok = request_xxx()
  3. 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开头的部分,新增代码如下:

  1. loop {
  2. match self.lex.next() {
  3. // 省略其他类型语句的解析
  4. t@Token::Name(_) | t@Token::ParL => {
  5. // this is not standard!
  6. if self.try_continue_stat(&t) { // !! 新增 !!
  7. continue;
  8. }
  9. // 以下省略标准的函数调用和变量赋值语句解析
  10. }

其中try_continue_stat()函数定义如下:

  1. fn try_continue_stat(&mut self, name: &Token) -> bool {
  2. if let Token::Name(name) = name {
  3. if name.as_str() != "continue" { // 判断语句开头是"continue"
  4. return false;
  5. }
  6. if !matches!(self.lex.peek(), Token::End | Token::Elseif | Token::Else) {
  7. return false; // 判断后面紧跟这3个Token之一
  8. }
  9. // 那么,就是continue语句。下面的处理跟break语句处理类似
  10. if let Some(continues) = self.continue_blocks.last_mut() {
  11. self.byte_codes.push(ByteCode::Jump(0));
  12. continues.push((self.byte_codes.len() - 1, self.locals.len()));
  13. } else {
  14. panic!("continue outside loop");
  15. }
  16. true
  17. } else {
  18. false
  19. }
  20. }

在解析到循环体的代码块block前,要先做准备,是push_loop_block()函数。block结束后,再用pop_loop_block()处理breaks和continues。breaks对应的Jump是跳转到block结束,即当前位置;而continues对应的Jump跳转位置是根据不同循环而定(比如while循环是跳转到循环开始,而repeat循环是跳转到循环结尾),所以需要参数来指定;另外,处理continus时要检查之后有没有新增局部变量的定义,即对比当前局部变量的数量跟continue语句时局部变量的数量。

  1. // before entering loop block
  2. fn push_loop_block(&mut self) {
  3. self.break_blocks.push(Vec::new());
  4. self.continue_blocks.push(Vec::new());
  5. }
  6. // after leaving loop block, fix `break` and `continue` Jumps
  7. fn pop_loop_block(&mut self, icontinue: usize) {
  8. // breaks
  9. let iend = self.byte_codes.len() - 1;
  10. for i in self.break_blocks.pop().unwrap().into_iter() {
  11. self.byte_codes[i] = ByteCode::Jump((iend - i) as i16);
  12. }
  13. // continues
  14. let end_nvar = self.locals.len();
  15. for (i, i_nvar) in self.continue_blocks.pop().unwrap().into_iter() {
  16. if i_nvar < end_nvar { // i_nvar为continue语句时局部变量的数量,end_nvar为当前局部变量的数量
  17. panic!("continue jump into local scope");
  18. }
  19. self.byte_codes[i] = ByteCode::Jump((icontinue as isize - i as isize) as i16 - 1);
  20. }
  21. }

至此,我们在保证向后兼容情况下,实现了continue语句!可以使用下述代码测试:

  1. {{#include ../listing/ch06.control_structures/test_lua/continue.lua}}

repeat..until的存在

上面可以看到由于在until部分需要扩展block中定义的局部变量的作用域,repeat..until语句的存在引入了两个问题:

  • 编程实现中,需要特意新建block_scope()函数;
  • 跟continue语句有冲突。

我个人认为,为了支持repeat..until这么一个使用频率很低的语句而引入上面两个问题,有些得不偿失。如果是我来设计Lua语言,是不会支持这个语句的。

官方的《Lua程序设计(第4版)》一书的 8.4练习 一节中,提出了如下问题:

练习8.3:很多人认为,由于repeat-until很少使用,因此在想Lua语言这样的简单的编程语言中最后不要出现,你怎么看?

我是真想知道作者对这个问题的回答,但可惜这本书的练习题都没有给答案。