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语言这样的简单的编程语言中最后不要出现,你怎么看?

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