条件判断中的逻辑运算

逻辑运算包括3个:与and、或or、非not。其中最后一个“非not”是一元运算,已经在之前一元运算一节中介绍过了。本章只介绍前面两个“与and”和“或or”。

那为什么没有在之前的二元运算一节中介绍与and和或or呢?因为“短路”!在主流编程语言(比如C、Rust)中逻辑运算都是短路的。比如对于与and运算,如果第一个操作数是false,那么就没必要(也不能)求第二个操作数了。比如语句is_valid() and count(),假如is_valid()的返回值是false,那么就不能执行后续的count()。所以,逻辑运算的执行过程是:1.先判断左操作数,2.如果是false则退出,3.否则判断右操作数。而之前介绍二元数值运算的执行过程是:1.先求左操作数,2.再求右操作数,3.最后计算。可见逻辑运算跟数值运算的流程不同,不能套用之前的做法。

在具体介绍逻辑运行之前,先来看逻辑运算的两个使用场景:

  1. 作为判断条件,比如上一章中if、while等语句中的判断条件语句,比如if t and t.k then ... end
  2. 求值,比如print(v>0 and v or -v)

其实第1种场景可以看做是第2种场景的一种特殊情况。比如上述的if语句例子,就等价于下面的代码:

local tmp = t and t.k
if tmp then
    ...
end

就是先对t and t.k这个运算语句进行求值,然后把值放到临时变量中,最后再判断这个值的真假来决定是否跳转。但是,这里我们其实并不关心具体的求值结果是t还是t.k,而只关心true或者false,所以可以省去临时变量!下面可以看到省去临时变量可以省掉一个字节码,是很大的优化。由于逻辑运算大部分应用是第1种场景,所以是值得把这个场景从第2种通用场景中独立出来特地做优化的,通过省去临时变量,直接根据求值结果来判断是否跳转。

如本节标题所示,本节只介绍第1种场景;下一节再介绍第2种场景。

跳转规律

上面介绍了逻辑运算的短路特性,在每次判断完一个操作数后,都可能发生跳转,跳过下一个操作数。逻辑运算最终对应的字节码,就是根据每个操作数做跳转。不同的运算组合就会导致各种各样的跳转组合。现在就要从各种跳转组合中归纳出跳转规律,以便用作后续的解析规则。这可能是整个解释器中最绕的一部分。

下面都用最简单的if语句作为应用场景,并先看最基础的and和or运算。下面两图分别是if A and B then ... endif X or Y then ... end的跳转示意图:

 A and B                      X or Y

+-------+                    +-------+
|   A   +-False-\    /--True-+   X   |
+---+---+       |    |       +---+---+
    |True       |    |           |False
    V           |    |           V
+-------+       |    |       +-------+
|   B   +-False>+    |       |   Y   +-False-\
+---+---+       |    |       +---+---+      |
    |True       |    \---------->|True      |
    V           |                V          |
  block         |              block        |
    |           |                |          |
    +<----------/                +<--------/
    V                            V

左图是与and运算。两个操作数A和B判断后的处理一样,都是True则继续执行;False则跳转到代码块结尾。

右图是或or运算。两个操作数的处理流程不一样。第一个操作数X的处理是:False则继续执行,True则跳转到下面代码块开始。而第二个操作数Y的处理跟之前A、B的处理方式一样。

不过只看这两个例子是总结不出通用规律的。还需要看些复杂的:

A and B and C               X or Y or Z                 (A and B) or Y               A and (X or Y)

+-------+                    +-------+                    +-------+                    +-------+
|   A   +-False-\    /--True-+   X   |                    |   A   |-False-\            |   A   +-False-\
+---+---+       |    |       +---+---+                    +---+---+       |            +---+---+       |
    |True       |    |           |False                       |True       |                |True       |
    V           |    |           V                            V           |                V           |
+-------+       |    |       +-------+                    +-------+       |            +-------+       |
|   B   +-False>+    +<-True-+   Y   |            /--True-+   B   |       |    /--True-+   X   |       |
+---+---+       |    |       +---+---+            |       +---+---+       |    |       +---+---+       |
    |True       |    |           |False           |      False|<---------/     |           |False      |
    V           |    |           V                |           V                |           V           |
+-------+       |    |       +-------+            |       +-------+            |       +-------+       |
|   C   +-False>+    |       |   Z   +-False-\    |       |   Y   +-False-\    |       |   Y   +-False>+
+---+---+       |    |       +---+---+       |    |       +---+---+       |    |       +---+---+       |
    |True       |    \---------->|True       |    \---------->|True       |    \---------->|True       |
    V           |                V           |                V           |                V           |
  block         |              block         |              block         |              block         |
    |           |                |           |                |           |                |           |
    +<---------/                 +<----------/                +<---------/                 +<---------/
    V                            V                            V                            V

根据这4个图可以归纳如下规律(这里省略了归纳的具体步骤。实际中可能需要更多的例子才能归纳,但是举太多例子又太多臃肿):

  • 跳转条件取决于语句(比如上面例子中的A,B,X,Y等)后面的逻辑运算符(也就是and或者or):

    • 如果后面跟and运算,则False跳转而True继续执行。比如第1个图中的A和B,后面都是and运算,所以都是False跳转。

    • 如果后面跟or运算,则True跳转而False继续执行。比如第2个图中的X和Z,后面都是or运算,所以都是True跳转。

    • 如果后面没有逻辑运算符,也就是整条判断语句结束,则False跳转而True继续执行。这个规则跟上面and的相同。上面4个图最后一个判断语句都是如此。

  • 跳转目标位置的规则:

    • 如果连续相同的跳转条件,则跳转到同样位置。比如第1个图中连续3个False跳转,第2个图中连续2个True跳转;而第3个图中的两个False跳转并不连续,所以跳转位置不同。那么在语法分析时,如果两个操作数具有相同的跳转条件,就合并跳转列表。

    • 如果遇到不同的跳转条件,则终结前面的跳转列表,并跳转到当前判断语句之后。比如第2个图中Z的False终结前面的两个True的跳转列表,并跳转到Z语句后面;再比如第3个图中B的True终结之前的False跳转列表,并跳转到B语句后面。

    • 不过第4个图貌似没有遵守上述两条规则,两个False跳转并不连续但也连起来了,或者说X的True跳转并没有终结A的False跳转列表。这是因为A并不是跟X运算,而是跟(X or Y)运算;要先求(X or Y),此时X的True跳转是全新的,并不知道前面的A的False跳转列表;然后再求A and (X or Y)时,就是True和False两个跳转列表并存了;最终语句结束的False,合并之前A的False跳转列表,并终结X的True跳转列表。

    • 判断语句的结束对应的是False跳转,所以会终结True跳转列表,并继续False跳转列表。在block结束后,终结False跳转列表到block结尾。上面4个图中都是如此。

至此,介绍完准备知识。下面开始编码实现。

字节码

上一章控制结构的几个条件判断语句,包括if、while、和repeat..until等,对判断条件的处理都是False跳转,所以只有一个测试并跳转的字节码,即Test。而现在需要2种跳转,False跳转和True跳转。为此我们去掉之前的Test,并新增2个字节码:

pub enum ByteCode {
    TestAndJump(u8, i16),  // 如果Test为True,则Jump。
    TestOrJump(u8, i16),   // 如果Test为False,则Jump。跟上一章的`Test`功能相同。

命名中的“And”和“Or”,跟本节介绍的逻辑运算并无关系,而是源自Rust语言中Option和Error类型的方法名,分别是“那么就”和“否则就”的意思。不过本节最开头的两个例子中,t and t.k可以描述为:如果t存在“那么就”取t.k,t.k or 100可以描述为:如果t.k存在就取其值“否则就”取100。也可以说是相关联的。

只不过上面介绍的跳转规则第1条,如果后面跟and运算,则False跳转,对应的是TestOrJump。这里的andOr没有对应上,不过关系不大。

官方Lua实现中,仍然只是一条字节码TEST,关联两个参数分别是:判断条件的栈地址(跟我们的一样),和跳转条件(True跳转还是False跳转)。而具体的跳转位置,则需要再加一条无条件跳转的JUMP字节码。看上去2条字节码不太高效。这么做是为了跟另外一个应用场景,在下一节中介绍。

ExpDesc

在解析逻辑运算符生成跳转字节码时,还不知道跳转的目的位置。只能先生成一个字节码占位,而留空跳转位置的参数。在后续确定目的位置后再填补参数。这个做法跟上一章介绍控制结构时是一样的。而不一样的是,上一章里只会有1个跳转字节码,而这次可能会出现多个字节码拉链的情况,比如上面的第1个图,3个字节码跳转到同一位置。这个拉链可能是True跳转,也可能是False跳转,也可能这两条链同时存在,比如上面第4个图中解析到Y时候。所以需要一个新的ExpDesc类型来保存跳转链表。为此,新增Test类型,定义如下:

enum ExpDesc {
    Test(usize, Vec<usize>, Vec<usize>), // (condition, true-list, false-list)

关联3个参数。第1个是判断条件在栈上的位置,无论什么类型(常量、变量、表索引等)都会先discharge到栈上,然后再判断真假。后面2个参数是True和False这2条跳转链表,内容分别是需要补齐的字节码的位置。

Lua官方实现中,跳转表是通过跳转字节码中留空的参数来实现的。比如上面第1个图中连续3个False的跳转,判断A、B、C生成的字节码分别是JUMP 0, JUMP $A, JUMP $B,然后在ExpDesc中保存$C。这样通过$C就可以找到$B,通过$B就可以找到$A,而参数0表示链表末尾。最后一边遍历,一边统一修复为JUMP $end。这种设计很高效,无需额外存储,利用暂时留空的Jump参数就可以实现拉链。同时也略显晦涩,容易出错。这种充分利用资源,按bit微操内存,是很典型的C语言项目的做法。而Rust语言标准库中提供了列表Vec,虽然会产生在堆上的内存分配,稍微影响性能,但是逻辑清晰很多,一目了然。只要不是性能瓶颈,就应该尽量避免晦涩而危险的做法,尤其是在使用追求安全的Rust语言时。

语法分析代码

现在终于可以语法分析了。从exp()函数的二元运算部分开始。之前介绍二元数值运算的求值顺序,要先处理第一个操作数。本节开头也介绍了,对于逻辑运算的处理顺序,由于短路的特性,也要先处理第一个操作和可能的跳转,然后才能解析第二个操作数。所以,在继续解析第二个操作数前,先处理跳转:

    fn preprocess_binop_left(&mut self, left: ExpDesc, binop: &Token) -> ExpDesc {
        match binop {
            Token::And => ExpDesc::Test(0, Vec::new(), self.test_or_jump(left)),
            Token::Or => ExpDesc::Test(0, self.test_and_jump(left), Vec::new()),

            _ => // 省略discharge其他类型的部分
        }
    }

这个函数中,新增了对逻辑运算的处理部分。以and为例,生成ExpDesc::Test类型,临时保存处理后的2条跳转列表,而关联的第1个参数没有用,这里填0。调用test_or_jump()函数来处理跳转列表。按照上面介绍的规则,and运算符对应的是False跳转,是会终结之前的True跳转列表,所以test_or_jump()函数会终结之前的True跳转列表,并只返回False跳转列表。那么这里就新建一个列表Vec::new()作为True跳转列表。

再看test_or_jump()的具体实现:

    fn test_or_jump(&mut self, condition: ExpDesc) -> Vec<usize> {
        let (icondition, true_list, mut false_list) = match condition {
            // 为True的常量,无需测试或者跳转,直接跳过。
            // 例子:while true do ... end
            ExpDesc::Boolean(true) | ExpDesc::Integer(_) | ExpDesc::Float(_) | ExpDesc::String(_) => {
                return Vec::new();
            }

            // 第一个操作数已经是Test类型,说明这不是第一个逻辑运算符。
            // 直接返回已有的两个跳转列表即可。
            ExpDesc::Test(icondition, true_list, false_list) =>
                (icondition, Some(true_list), false_list),

            // 第一个操作数是其他类型,说明这是第一个逻辑运算符。
            // 只需要discharge第一个操作数到栈上即可。
            // 之前也没有True跳转列表,所以返回None。
            // 也没有False跳转列表,所以新建一个列表,用来保存本次跳转指令。
            _ => (self.discharge_any(condition), None, Vec::new()),
        };

        // 生成TestOrJump,但第二个参数留空
        self.byte_codes.push(ByteCode::TestOrJump(icondition as u8, 0));

        // 把刚生成的字节码,假如到False跳转列表中,以便后续修复
        false_list.push(self.byte_codes.len() - 1);

        // 终结之前的True跳转列表,并跳转到这里,如果有的话
        if let Some(true_list) = true_list {
            self.fix_test_list(true_list);
        }

        // 返回False跳转列表
        false_list
    }

对于or运算符和对应的test_and_jump()函数,大同小异,只是翻转下True和False跳转列表。这里不再介绍。

处理完第一个操作数和跳转后,再来处理第二个操作数就很简单了,只需要连接跳转列表即可:

    fn process_binop(&mut self, binop: Token, left: ExpDesc, right: ExpDesc) -> ExpDesc {
        match binop {
            // 省略其他二元运算符处理
            Token::And | Token::Or => {
                // 第一个操作数已经在上面的preprocess_binop_left()中被转换为ExpDesc::Test
                if let ExpDesc::Test(_, mut left_true_list, mut left_false_list) = left {
                    let icondition = match right {
                        // 如果第二个操作数也是Test类型,比如本节上面第4个图中`A and (X or Y)`的例子,
                        // 那么分别连接两个跳转列表。
                        ExpDesc::Test(icondition, mut right_true_list, mut right_false_list) => {
                            left_true_list.append(&mut right_true_list);
                            left_false_list.append(&mut right_false_list);
                            icondition
                        }
                        // 如果第二个操作数是其他类型,则无需处理跳转链表
                        _ => self.discharge_any(right),
                    };

                    // 返回连接后想新跳转列表
                    ExpDesc::Test(icondition, left_true_list, left_false_list)
                } else {
                    panic!("impossible");
                }
            }

处理完二元运算部分,接下来就是应用场景。本节只介绍作为判断条件的应用场景,而在下一节中再介绍求值。上一章中的几个控制结构语句(if、while、repeat..until等)都是直接处理跳转字节码,代码逻辑类似。本节开头介绍的跳转规则中,整条逻辑运算的判断语句结束,是False跳转,所以调用刚才介绍的test_or_jump()函数处理,可以代替并简化上一章的直接处理字节码的代码逻辑。这里仍然用if语句为例:

    fn do_if_block(&mut self, jmp_ends: &mut Vec<usize>) -> Token {
        let condition = self.exp();

        // 上一章,这里是生成Test字节码。
        // 现在,替换并简化为test_or_jump()函数。
        // 终结True跳转列表,并返回新的False跳转列表。
        let false_list = self.test_or_jump(condition);

        self.lex.expect(Token::Then);

        let end_token = self.block();

        if matches!(end_token, Token::Elseif | Token::Else) {
            self.byte_codes.push(ByteCode::Jump(0));
            jmp_ends.push(self.byte_codes.len() - 1);
        }

        // 上一章,这里是修复刚才生成的一条Test字节码。
        // 现在,需要修改一条False跳转列表。
        self.fix_test_list(false_list);

        end_token
    }

至此完成语法分析部分。

虚拟机执行

虚拟机执行部分,首先是要处理新增的2个字节码,都很简单,这里忽略不讲。需要讲的是一个栈操作的细节。之前向栈上赋值时的函数如下:

    fn set_stack(&mut self, dst: u8, v: Value) {
        let dst = dst as usize;
        match dst.cmp(&self.stack.len()) {
            Ordering::Equal => self.stack.push(v),
            Ordering::Less => self.stack[dst] = v,
            Ordering::Greater => panic!("fail in set_stack"),
        }
    }

首先判断目标地址dst是否在栈的范围内:

  • 如果在,则直接赋值;
  • 如果不在并且刚好是下一个位置,则使用push()压入栈中;
  • 如果不在,并且超过下一个位置,之前是不可能出现的,所以调用panic!()

但是逻辑运算的短路特性,是可能导致上述第3种情况出现的。比如下面的语句:

if (g1 or g2) and g3 then
end

按照我们的解析方式,会生成如下临时变量,占用栈上位置:

|      |
+------+
|  g1  |
+------+
|  g2  |
+------+
|  g3  |
+------+
|      |

但在执行过程中,如果g1为真,则会跳过对g2的处理,而直接处理g3,此时上图中g2的位置并未设置,那么g3就会超过栈顶的位置,如下图所示:

|      |
+------+
|  g1  |
+------+
|      |
:      :
:      : <-- 设置g3,超过栈顶位置

所以,要修改上述set_stack()函数,支持设置超过栈顶的元素。这可以通过调用set_vec()实现。

测试

至此,完成了逻辑运算在条件判断中的应用场景。可以通过本节开头的几个图中的例子来测试。这里省略。