参数

上一节介绍了Lua函数的定义和调用流程。这一节介绍函数的参数。

参数这个名词有两个概念:

  • 形参,parameter,指函数原型中的参数,包括参数名和参数类型等信息;
  • 实参,argument,指函数调用时的参数,是具体的值。

本节后面介绍语法分析和虚拟机执行时,都要明确区分形参和实参。

很重要的一点是:在Lua语言中,函数的参数就是局部变量!在语法分析时,形参也会放到局部变量表中起始位置,这样后续代码中如果有对形参的引用,也会在局部变量表中定位到。在虚拟机执行阶段,实参被加载到栈上紧跟函数入口的位置,后面再跟局部变量,与语法分析阶段局部变量表中的顺序一致。比如对于如下函数:

  1. local function foo(a, b)
  2. local x, y = 1, 2
  3. end

在执行foo()函数时,栈布局如下(栈右边的数字0-3是相对索引):

  1. | |
  2. +-----+
  3. | foo |
  4. +=====+ <---base
  5. | a | 0 \
  6. +-----+ + 参数
  7. | b | 1 /
  8. +-----+
  9. | x | 2 \
  10. +-----+ + 局部变量
  11. | y | 3 /
  12. +-----+
  13. | |

参数和局部变量唯一的区别就是,参数的值是在调用时由调用者传入的,而局部变量是在函数内部赋值的。

形参的语法分析

形参的语法分析,也就是函数定义的语法分析。上一节介绍函数定义时,语法分析的过程省略了参数部分,现在加上。函数定义的BNF是funcbody,其定义如下:

  1. funcbody ::= `(` [parlist] `)` block end
  2. parlist ::= namelist [`,` `...`] | `...`
  3. namelist ::= Name {`,` Name}

由此可以看到,形参列表由两个可选的部分组成:

  • 可选的多个Name,是固定参数。上一节在解析新函数,创建FuncProto结构时,局部变量表locals字段被初始化为空列表。现在要改为初始化为形参列表。这样形参就在局部变量表的最前面,后续新建的局部变量跟在后面,与本节开头的栈布局图一致。另外,由于Lua语言中调用函数的实参个数允许跟形参个数不等。多则舍去,少则补nil。所以FuncProto结果中也要增加形参的个数,用以在虚拟机执行时做比较。

  • 最后一个可选的...,表明这个函数支持可变参数。如果支持,那么在后续的语法分析中,函数体内就可以使用...来引用可变参数,并且在虚拟机执行阶段,也要对可变参数做特殊处理。所以在FuncProto中需要增加一个标志位,表明这个函数是否支持可变参数。

综上,一共有三个改造点。在FuncProto中增加两个字段:

  1. pub struct FuncProto {
  2. pub has_varargs: bool, // 是否支持可变参数。语法分析和虚拟机执行中都要使用。
  3. pub nparam: usize, // 固定参数个数。虚拟机执行中使用。
  4. pub constants: Vec<Value>,
  5. pub byte_codes: Vec<ByteCode>,
  6. }

另外在初始化ParseProto结构时,用形参列表来初始化局部变量locals字段。代码如下:

  1. impl<'a, R: Read> ParseProto<'a, R> {
  2. // 新增has_varargs和params两个参数
  3. fn new(lex: &'a mut Lex<R>, has_varargs: bool, params: Vec<String>) -> Self {
  4. ParseProto {
  5. fp: FuncProto {
  6. has_varargs: has_varargs, // 是否支持可变参数
  7. nparam: params.len(), // 形参个数
  8. constants: Vec::new(),
  9. byte_codes: Vec::new(),
  10. },
  11. sp: 0,
  12. locals: params, // 用形参列表初始化locals字段
  13. break_blocks: Vec::new(),
  14. continue_blocks: Vec::new(),
  15. gotos: Vec::new(),
  16. labels: Vec::new(),
  17. lex: lex,
  18. }
  19. }

至此,完成形参的语法分析。其中涉及到可变参数、虚拟机执行等部分,下面再详细介绍。

实参的语法分析

实参的语法分析,也就是函数调用的语法分析。这个在之前章节实现prefixexp的时候已经实现过了:通过explist()函数读取参数列表,并依次加载到栈上函数入口的后面位置。与本节开头的栈布局图一致,相当于是给形参赋值。这里解析到实际的参数个数,并写入到字节码Call的参数中,用于在虚拟机执行阶段跟形参做比较。

但当时的实现并不完整,还不支持可变参数。本节后面再详细介绍。

虚拟机执行

上面的实参语法分析中,已经把实参加载到栈上,相当于是给形参赋值,所以虚拟机执行函数调用时,本来就无需再处理参数了。但是在Lua语言中,函数调用时实参个数可能不等于形参个数。如果实参多于形参,那无需处理,就认为多出的部分是个占据栈位置但无用的临时变量;但如果实参少于形参,那么需要对不足的部分设置为nil,否则后续字节码对这个形参的引用就会导致Lua的栈访问异常。除此之外,Call字节码的执行就不需要对参数做其他处理。

上面语法分析时已经介绍过,形参和实参的个数,分别在FuncProto结构中的nparam字段和Call字节码的关联参数中。所以函数调用的虚拟机执行代码如下:

  1. ByteCode::Call(func, narg) => { // narg是实际传入的实参个数
  2. self.base += func as usize + 1;
  3. match &self.stack[self.base - 1] {
  4. Value::LuaFunction(f) => {
  5. let narg = narg as usize;
  6. let f = f.clone();
  7. if narg < f.nparam { // f.nparam是函数定义中的形参个数
  8. self.fill_stack(narg, f.nparam - narg); // 填nil
  9. }
  10. self.execute(&f);
  11. }

至此,完成了固定参数的部分,还是比较简单的;下面介绍可变参数部分,开始变得复杂起来。

可变参数

在Lua中,可变形参和可变实参都用...来表示。当在函数定义的形参列表中出现时,代表可变形参;而在其他地方出现都代表可变实参。

在上面形参的语法分析中已经提到了可变参数,其功能比较简单,代表这个函数支持可变参数。本节接下来主要介绍可变参数作为实参的处理,也就是执行函数调用时实际传入的参数。

本节最开始就介绍函数的参数就是局部变量,并画了栈的布局图。不过这个说法只适合固定的实参,而对于可变实参就不适合了。在之前的foo()函数中加上可变参数作为示例,代码如下:

  1. local function foo(a, b, ...)
  2. local x, y = 1, 2
  3. print(x, y, ...)
  4. end
  5. foo(1, 2, 3, 4, 5)

加上可变参数后,栈布局该变成什么样?或者说,可变实参要存在哪里?上述代码中最后一行foo()调用时,其中12分别对应形参ab,而后面的345就是可变实参。在调用开始前,栈布局如下:

  1. | |
  2. +-----+
  3. | foo |
  4. +=====+ <-- base
  5. | 1 | \
  6. +-----+ + 固定实参,对应ab
  7. | 2 | /
  8. +-----+
  9. | 3 | \
  10. +-----+ |
  11. | 4 | + 可变实参,对应...
  12. +-----+ |
  13. | 5 | /
  14. +-----+
  15. | |

那进入到foo()函数后,后面的三个实参要存在哪里?最直接的想法是保持上面的布局不变,也就是可变实参存到固定实参的后面。但是,这样是不行的!因为这样就会挤占局部变量的空间,即示例里的xy就要后移,后移的距离是可变实参的个数。但是在语法分析阶段是不能确定可变实参的个数的,就无法确定局部变量在栈上的位置,就无法访问局部变量了。

Lua官方的实现是,在语法分析阶段忽略可变参数,让局部变量仍然在固定参数的后面。但是在虚拟机执行时,在进入到函数中后,把可变参数挪到函数入口的前面,并且记录可变实参的个数。这样后续在访问可变参数时,根据函数入口位置和可变实参的个数,就可以定位栈位置,即stack[self.base - 1 - 实参个数 .. self.base - 1]。下面是栈布局图:

  1. | |
  2. +-----+
  3. | 3 | -4 \
  4. +-----+ | num_varargs: usize // 记录下可变实参的个数
  5. | 4 | -3 + 相对于上图, +-----+
  6. +-----+ | 把可变实参挪到函数入口前面 | 3 |
  7. | 5 | -2 / +-----+
  8. +-----+
  9. | foo | <-- 函数入口
  10. +=====+ <-- base
  11. | a=1 | 0 \
  12. +-----+ + 固定实参,对应ab
  13. | b=2 | 1 /
  14. +-----+
  15. | x | 2 \
  16. +-----+ + 局部变量
  17. | y | 3 / 仍然紧跟固定参数后面

既然这个方案需要在虚拟机执行时需要记录额外信息(可变实参的个数),并且还要移动栈上参数,那么更简单的做法是直接记录可变实参:

  1. | |
  2. +-----+
  3. | foo | <-- 函数入口 varargs: Vec<Value> // 直接记录可变实参
  4. +=====+ +-----+-----+-----+
  5. | a=1 | 0 \ | 3 | 4 | 5 |
  6. +-----+ + 固定实参,对应ab +-----+-----+-----+
  7. | b=2 | 1 /
  8. +-----+
  9. | x | 2 \
  10. +-----+ + 局部变量
  11. | y | 3 /

相比于Lua的官方实现,这个方法没有利用栈,而是使用Vec,会有额外的堆上内存分配。但是更加直观清晰。

确定下可变实参的存储方式后,就可以进行语法分析和虚拟机执行了。

ExpDesc::VarArgs和应用场景

上面讲的是函数调用时传递可变参数,接下来介绍在函数体内如何访问可变参数。

访问可变实参是一个独立的表达式,语法是也...,在exp_limit()函数中解析,并新增一种表达式类型ExpDesc::VarArgs,这个类型没有关联参数。

读取这个表达式很简单,先检查当前函数是否支持可变参数(函数原型中有没有...),然后返回ExpDesc::VarArgs即可。具体代码如下:

  1. fn exp_limit(&mut self, limit: i32) -> ExpDesc {
  2. let mut desc = match self.lex.next() {
  3. Token::Dots => {
  4. if !self.fp.has_varargs { // 检查当前函数是否支持可变参数?
  5. panic!("no varargs");
  6. }
  7. ExpDesc::VarArgs // 新增表达式类型
  8. }

但是读到的ExpDesc::VarArgs如何处理?这就要先梳理使用可变实参的3种场景:

  1. ...作为函数调用的最后一个参数、return语句的最后一个参数、表构造的最后一个列表成员时,代表实际传入的全部实参。比如下面示例:

    1. print("hello: ", ...) -- 最后一个实参
    2. local t = {1, 2, ...} -- 最后一个列表成员
    3. return a+b, ... -- 最后一个返回值
  2. ...作为局部变量定义语句、或赋值语句的等号=后面最后一个表达式时,会按需求扩展或缩减个数。比如下面示例:

    1. local x, y = ... -- 取前2个实参,分别赋值给xy
    2. t.k, t.j = a, ... -- 取前1个实参,赋值给t.j
  3. 其他地方都只代表实际传入的第一个实参。比如下面示例:

    1. local x, y = ..., b -- 不是最后一个表达式,只取第1个实参并赋值给x
    2. t.k, t.j = ..., b -- 不是最后一个表达式,只取第1个实参并赋值给t.k
    3. if ... then -- 条件判断
    4. t[...] = ... + f -- 表索引,和二元运算操作数
    5. end

其中,第1个场景是最基本的,但也是实现起来最复杂的;后面两个场景属于特殊情况,实现起来相对简单。下面对这3种场景依次分析。

场景1:全部可变实参

先介绍第1种场景,即加载全部可变实参。这个场景中的3个语句如下:

  1. 函数调用的最后一个参数,是把当前函数的可变实参作为调用函数的可变实参,涉及两个可变实参,有点绕,不方便描述;

  2. return语句的最后一个参数,但是现在还不支持返回值,要在下一节介绍;

  3. 表构造的最后一个列表成员。

这3个语句的实现思路类似,都是在解析表达式列表的时候,只discharge前面的表达式,而保留最后一个表达式不discharge;然后在解析完整个语句后,单独检查最后一个语句是否为ExpDesc::VarArgs

  • 如果不是,则正常discharge。这种情况下,在语法分析时就能确定所有表达式的数量,而这个数量就可以编码进对应的字节码中。

  • 如果是,则用新增的字节码VarArgs加载全部可变参数,而实际参数的个数在语法分析时不知道,要在虚拟机执行时才能知道,所以总的表达式的数量也不知道,也就无法编码到对应的字节码中,就需要用特殊值或新字节码来处理。

这3个语句中第3个语句表构造相对而言最简单,下面先介绍表构造语句。

之前表构造的语法分析流程是:在循环读取全部成员过程中,如果解析到数组成员,则立即discharge到栈上;在循环读取完毕后,所有数组成员依次被加载到栈上,然后生成SetList字节码将其添加到表里。这个SetList字节码的第2个关联参数就是成员数量。为了简单起见,这里忽略超过50个成员时分批加载的处理。

现在修改流程:为了单独处理最后一个表达式,在解析到数组成员时,要延迟discharge。具体做法比较简单但不容易描述,可以参见下面代码。代码摘自table_constructor()函数,只保留跟本节相关内容。

  1. // 新增这个变量,用来保存最后一个读到的数组成员
  2. let mut last_array_entry = None;
  3. // 循环读取全部成员
  4. loop {
  5. let entry = // 省略读取成员的代码
  6. match entry {
  7. TableEntry::Map((op, opk, key)) => // 省略字典成员部分的代码
  8. TableEntry::Array(desc) => {
  9. // 使用replace()函数,用新成员desc替换出上一个读到的成员
  10. // 并discharge。而新成员,也就是当前的“最后一个成员”,被
  11. // 存到last_array_entry中。
  12. if let Some(last) = last_array_entry.replace(desc) {
  13. self.discharge(sp0, last);
  14. }
  15. }
  16. }
  17. }
  18. // 处理最后一个表达式,如果有的话
  19. if let Some(last) = last_array_entry {
  20. let num = if self.discharge_expand(last) {
  21. // 可变参数。在语法分析阶段无法得知具体的参数个数,所以用0来代表栈上全部
  22. 0
  23. } else {
  24. // 计算出总的成员个数
  25. (self.sp - (table + 1)) as u8
  26. };
  27. self.fp.byte_codes.push(ByteCode::SetList(table as u8, num));
  28. }

上述代码整理流程比较简单,这里不一一介绍。在处理最后一个表达式时,有几个细节需要介绍:

  • 新增的discharge_expand()方法,用以特殊处理ExpDesc::VarArgs类型表达式。可以预见这个函数后面还会被其他两个语句(return语句和函数调用语句)用到。其代码如下:
  1. fn discharge_expand(&mut self, desc: ExpDesc) -> bool {
  2. match desc {
  3. ExpDesc::VarArgs => {
  4. self.fp.byte_codes.push(ByteCode::VarArgs(self.sp as u8));
  5. true
  6. }
  7. _ => {
  8. self.discharge(self.sp, desc);
  9. false
  10. }
  11. }
  12. }
  • 最后一个表达式如果是可变参数,那么SetList字节码的第2个关联参数则设置为0。之前(不支持可变数据表达式的时候)SetList字节码的这个参数不可能是0,因为如果没有数组成员,那不生成SetList字节码即可,而没必要生成一个关联参数是0的SetList。所以这里可以用0作为特殊值。相比而言,这个场景里的其他两个语句(return语句和函数调用语句)本来就支持0个表达式,即没有返回值和没有参数,那就不能用0作为特殊值了。到时候再想其他办法。

    当然这里也可以不用0这个特殊值,而是新增一个字节码,比如叫SetListAll,专门用来处理这种情况。这两种做法差不多,我们还是选择使用特殊值0

  • 虚拟机执行时,对于SetList第二个关联参数是0的情况,就取栈上表后面的全部的值。也就是从表的位置一直到栈顶,都是用来初始化的表达式。具体代码如下,增加对0的判断:

  1. ByteCode::SetList(table, n) => {
  2. let ivalue = self.base + table as usize + 1;
  3. if let Value::Table(table) = self.get_stack(table).clone() {
  4. let end = if n == 0 { // 0,可变参数,直至栈顶的全部表达式
  5. self.stack.len()
  6. } else {
  7. ivalue + n as usize
  8. };
  9. let values = self.stack.drain(ivalue .. end);
  10. table.borrow_mut().array.extend(values);
  11. } else {
  12. panic!("not table");
  13. }
  14. }
  • 既然对于可变参数的情况,可以在虚拟机执行时根据栈顶来获取实际的表达式数量,那之前固定表达式的情况是不是也可以在执行时决定表达式数量,而不用在语法分析阶段就确定?这样一来SetList关联的第2个参数是不是就没用了?答案是否定的,因为栈上可能有临时变量!比如下面的代码:
  1. t = { g1+g2 }

表达式g1+g2的两个操作数都是全局变量,在对整个表达式求值前,要都分别加载到栈上,需要占用2个临时变量的位置。栈布局如下:

  1. | |
  2. +-------+
  3. | t |
  4. +-------+
  5. | g1+g2 | 先把g1加载到这里。然后在求值g1+g2时,结果也加载到这里,覆盖原来的g1
  6. +-------+
  7. | g2 | 在求值g1+g2时,把全局变量g2加载到这里的临时位置
  8. +-------+
  9. | |

此时栈顶是g2,如果也按照从表后直至栈顶的做法,那么g2也会被认为是表的一个成员。所以,对于之前的情况(固定数量的表达式)还是需要在语法分析阶段确定表达式的数量。

  • 那么,为什么对于可变参数的情况就可以根据栈顶来确定表达式数量呢?这就要求虚拟机在执行加载可变参数的字节码时,清理掉临时变量。这一点非常重要。具体代码如下:
  1. ByteCode::VarArgs(dst) => {
  2. self.stack.truncate(self.base + dst as usize); // 清理临时变量!!!
  3. self.stack.extend_from_slice(&varargs); // 加载可变参数
  4. }

至此,完成了可变参数作为表构造最后一个表达式的语句的处理。相关代码并不多,但理清思路和一些细节并不简单。

场景1:全部可变实参(续)

上面介绍了第1种场景下的表构造语句,现在介绍可变参数作为函数调用的最后一个参数的情况,光听这个描述就很绕。这两个语句对可变参数的处理方法差不多,这里只介绍下不同的地方。

本节上面介绍实参的语法分析时已经说明,所有实参通过explist()函数依次加载到栈顶,并把实参个数写入到Call字节码中。但当时的实现并不支持可变参数。现在为了支持可变参数,就要对最后一个表达式做特殊处理。为此我们修改explist()函数,保留并返回最后一个表达式,而只是把前面的表达式依次加载到栈上。具体代码比较简单,这里略过。复习一下,在赋值语句中,读取等号=右边的表达式列表时,也需要保留最后一个表达式不加载。这次改造了exp_list()函数后,在赋值语句中就也可以使用这个函数了。

改造explist()函数后,再结合上面对表构造语句的介绍,就可以实现函数调用中的可变参数了。代码如下:

  1. fn args(&mut self) -> ExpDesc {
  2. let ifunc = self.sp - 1;
  3. let narg = match self.lex.next() {
  4. Token::ParL => { // 括号()包裹的参数列表
  5. if self.lex.peek() != &Token::ParR {
  6. // 读取实参列表。保留和返回最后一个表达式last_exp,而把前面的
  7. // 表达式依次加载到栈上并返回其个数nexp。
  8. let (nexp, last_exp) = self.explist();
  9. self.lex.expect(Token::ParR);
  10. if self.discharge_expand(last_exp) {
  11. // 可变实参。生成新增的VarArgs字节码,读取全部可变实参!!
  12. None
  13. } else {
  14. // 固定实参。last_exp也被加载到栈上,作为最后1个实参。
  15. Some(nexp + 1)
  16. }
  17. } else { // 没有参数
  18. self.lex.next();
  19. Some(0)
  20. }
  21. }
  22. Token::CurlyL => { // 不带括号的表构造
  23. self.table_constructor();
  24. Some(1)
  25. }
  26. Token::String(s) => { // 不带括号的字符串常量
  27. self.discharge(ifunc+1, ExpDesc::String(s));
  28. Some(1)
  29. }
  30. t => panic!("invalid args {t:?}"),
  31. };
  32. // 对于n个固定实参,转换为n+1;
  33. // 对于可变实参,转换为0。
  34. let narg_plus = if let Some(n) = narg { n + 1 } else { 0 };
  35. ExpDesc::Call(ifunc, narg_plus)
  36. }

跟之前介绍的表构造语句不一样的地方是,表构造语句对应的字节码是SetList,在固定成员的情况下,其关联的用于表示数量的参数不会是0;所以就可以用0作为特殊值,来表示可变数量的成员。但是,对于函数调用语句,本来就支持没有实参的情况,也就是说字节码Call关联的用户表示实参数量的参数本来就可能是0,所以就不能简单把0作为特殊值。那么,就有2个方案:

  • 换一个特殊值,比如用u8::MAX,即255作为特殊值;
  • 仍然用0做特殊值,但是在固定实参的情况下,把参数加1。比如5个实参,那么就在Call字节码中写入6;N个字节码就写入N+1;这样就可以确保固定参数的情况下,这个参数肯定是大于0的。

我感觉第1个方案稍微好一点,更清晰,不容易出错。但是Lua官方实现用的是第2个方案。我们也采用第2个方案。对应到上述代码中的两个变量:

  • narg: Option<usize>表示实际的参数数量,None表示可变参数,Some(n)代表有n个固定参数;
  • narg_plus: usize是修正后的值,用来写入到Call字节码中。

跟之前介绍的表构造语句一样的地方是,既然用0这个特殊值来表示可变参数,那么虚拟机执行的时候,就需要有办法知道实际参数的个数。只能通过栈顶指针和函数入口的距离来计算出实际参数的个数,那也就需要确保栈顶都是参数,而没有临时变量。对于这个要求,有两种情况:

  • 实参也是可变参数,也就是最后一个实参是VarArgs,比如调用语句是foo(1, 2, ...),那么由于之前介绍过VarArgs的虚拟机执行会确保清理临时变量,所以这个情况下就无需再次清理;
  • 实参是固定参数,比如调用语句是foo(g1+g2),那么就需要清理可能存在的临时变量。

对应的,在虚拟机执行阶段的函数调用,也就是Call字节码的执行,需要如下修改:

  • 修正关联参数narg_plus;
  • 在需要时,清理栈上可能的临时变量。

代码如下:

  1. ByteCode::Call(func, narg_plus) => { // narg_plus是修正后的实参个数
  2. self.base += func as usize + 1;
  3. match &self.stack[self.base - 1] {
  4. Value::LuaFunction(f) => {
  5. // 实参数量
  6. let narg = if narg_plus == 0 {
  7. // 可变实参。上面介绍过,VarArgs字节码的执行会清理掉可能的
  8. // 临时变量,所以可以用栈顶来确定实际的参数个数。
  9. self.stack.len() - self.base
  10. } else {
  11. // 固定实参。需要减去1做修正。
  12. narg_plus as usize - 1
  13. };
  14. if narg < f.nparam { // 填补nil,原有逻辑
  15. self.fill_stack(narg, f.nparam - narg);
  16. } else if f.has_varargs && narg_plus != 0 {
  17. // 如果被调用的函数支持可变形参,并且调用是固定实参,
  18. // 那么需要清理栈上可能的临时变量
  19. self.stack.truncate(self.base + narg);
  20. }
  21. self.execute(&f);
  22. }

至此,我们完成了可变参数的第1种场景的部分。这部分是最基本的,也是最复杂的。下面介绍另外两种场景。

场景2:前N个可变实参

现在介绍可变参数的第2种场景,需要固定个数的可变实参。这个场景中需要使用的参数个数固定,可以编入字节码中,比上个场景简单很多。

这个场景包括2条语句:局部变量定义语句和赋值语句。当可变参数作为等号=后面最后一个表达式时,会按需求扩展或缩减个数。比如下面的示例代码:

  1. local x, y = ... -- 取前2个实参,分别赋值给xy
  2. t.k, t.j = a, ... -- 取前1个实参,赋值给t.j

这两个语句的处理方式基本一样。这里只介绍第一个局部变量定义语句。

之前这个语句的处理流程是,首先把=右边的表达式依次加载到栈上,完成对局部变量的赋值。如果当=右边表达式的个数小于左边局部变量的个数时,则生成LoadNil字节码对多出的局部变量进行赋值;如果不小于则无需处理。

现在需要对最后一个表达式特殊处理:如果表达式的个数小于局部变量的个数,并且最后一个表达式是可变参数...,那么就按需读取参数;如果不是可变参数,那还是回退成原来的方法,即用LoadNil来填补。刚才改造过的explist()函数就又派上用场了,具体代码如下:

  1. let want = vars.len();
  2. // 读取表达式列表。保留和返回最后一个表达式last_exp,而把前面的
  3. // 表达式依次加载到栈上并返回其个数nexp。
  4. let (nexp, last_exp) = self.explist();
  5. match (nexp + 1).cmp(&want) {
  6. Ordering::Equal => {
  7. // 如果表达式跟局部变量个数一致,则把最后一个表达式也正常
  8. // 加载到栈上即可。
  9. self.discharge(self.sp, last_exp);
  10. }
  11. Ordering::Less => {
  12. // 如果表达式少于局部变量个数,则需要尝试特殊处理最后一个表达式!!!
  13. self.discharge_expand_want(last_exp, want - nexp);
  14. }
  15. Ordering::Greater => {
  16. // 如果表达式多于局部变量个数,则调整栈顶指针;最后一个表达式
  17. // 也就无需处理了。
  18. self.sp -= nexp - want;
  19. }
  20. }

上述代码中,新增的逻辑是discharge_expand_want()函数,用以加载want - nexp个表达式到栈上。代码如下:

  1. fn discharge_expand_want(&mut self, desc: ExpDesc, want: usize) {
  2. debug_assert!(want > 1);
  3. let code = match desc {
  4. ExpDesc::VarArgs => {
  5. // 可变参数表达式
  6. ByteCode::VarArgs(self.sp as u8, want as u8)
  7. }
  8. _ => {
  9. // 对于其他类型表达式,还是用之前的方法,即用LoadNil来填补
  10. self.discharge(self.sp, desc);
  11. ByteCode::LoadNil(self.sp as u8, want as u8 - 1)
  12. }
  13. };
  14. self.fp.byte_codes.push(code);
  15. }

这个函数跟上面第1种场景中的discharge_expand()函数很像,但有两个区别:

  • 之前是需要实际执行中所有的可变参数,但这个函数有确定的个数需求,所以多了一个参数want

  • 之前函数需要返回是否为可变参数,以便调用者再做区别处理;但这个函数因为需求明确,不需要调用者做区别处理,所以没有返回值。

跟上面第1个场景相比,还有一个重要改变是VarArgs字节码新增一个关联参数,用以表示需要加载具体多少个参数到栈上。因为在这种场景下,这个参数肯定不小于2,而在下一种场景下,这个参数固定是1,都没有用到0,所以可以用0作为特殊值,来表示上面第1种场景中的执行时实际所有参数。

这个字节码的虚拟机执行代码也改变如下:

  1. ByteCode::VarArgs(dst, want) => {
  2. self.stack.truncate(self.base + dst as usize);
  3. let len = varargs.len(); // 实际参数个数
  4. let want = want as usize; // 需要参数个数
  5. if want == 0 { // 需要实际全部参数,流程不变
  6. self.stack.extend_from_slice(&varargs);
  7. } else if want > len {
  8. // 需要的比实际的多,则用fill_stack()填补nil
  9. self.stack.extend_from_slice(&varargs);
  10. self.fill_stack(dst as usize + len, want - len);
  11. } else {
  12. // 需要的比实际的一样或更少
  13. self.stack.extend_from_slice(&varargs[..want]);
  14. }
  15. }

至此,完成可变参数第2种场景部分。

场景3:只取第1个可变实参

前面介绍的两种场景都是在特定的语句上下文中,分别通过discharge_expand_want()discharge_expand()函数,把可变参数加载到栈上。而第3种场景是除了上述特定语句上下文外的其他所有地方。所以从这个角度说,第3个场景可以算是通用场景,那么也就要用通用的加载方式。在本节介绍可变参数这个表达式之前,其他所有表达式都是通过调用discharge()函数加载到栈上,可以看做是通用的加载方式。于是这个场景下,也要通过discharge()函数来加载可变参数表达式。

其实上面已经遇到了这种场景。比如,在上述第2种场景中,如果=右边的表达式个数和局部变量个数相等时,最后一个表达式就是通过discharge()函数处理的:

  1. let (nexp, last_exp) = self.explist();
  2. match (nexp + 1).cmp(&want) {
  3. Ordering::Equal => {
  4. // 如果表达式跟局部变量个数一致,则把最后一个表达式也正常
  5. // 加载到栈上即可。
  6. self.discharge(self.sp, last_exp);
  7. }

这里discharge()的最后一个表达式也可能是可变参数表达式...,那么就是当前场景。

再比如,上述两个场景中都调用了explist()函数来处理表达式列表。除了最后一个表达式外,前面的表达式都会被这个函数通过调用discharge()来加载到栈上。如果前面的表达式里就有可变参数表达式...,比如foo(a, ..., b),那么也是当前场景。

另外,上面也罗列了可变表达式在其他语句中的示例,都是属于当前场景。

既然这个场景属于通用场景,那么在语法分析阶段就不需要做什么改造,而只需要补齐discharge()函数中对可变表达式ExpDesc::VarArgs这个表达式的处理即可。这个处理也很简单,就是使用上面介绍的VarArgs字节码,只加载第1个参数到栈上:

  1. fn discharge(&mut self, dst: usize, desc: ExpDesc) {
  2. let code = match desc {
  3. ExpDesc::VarArgs => ByteCode::VarArgs(dst as u8, 1), // 1表示只加载第1个参数

这就完成了第3种场景。

至此,终于介绍完可变参数的所有场景。

小结

本节开始分别介绍了形参和实参的机制。对于形参,语法分析把形参加到局部变量表中,作为局部变量使用。对于实参,调用者把参数加载到栈上,相当于给参数赋值。

后面大部分篇幅介绍了可变参数的处理,包括3种场景:实际全部实参,固定个数实参,和通用场景下第1个实参。