泛型for
本章前面几节介绍了闭包。闭包最典型的应用场景是迭代器(iterator),而迭代器最常见的地方是for语句。以至于《Lua程序设计》和《Rust程序设计语言》这两本书中都把闭包、迭代器和for语句放在一起介绍。Lua语言中的for语句有两种格式,数值型和泛型。之前已经介绍了数值型for语句,本节来介绍使用迭代器的泛型for语句。
在介绍完闭包后,迭代器本身的概念是很简单的。本章前面几节一直用来举例的计数器闭包就可以认为是一个迭代器,每次生成一个递增数字。下面再看一个稍微复杂的迭代器,遍历一个表中的数组部分。这也是Lua语言自带的ipairs()
函数的功能:
function ipairs(t)
local i = 0
return function ()
i = i + 1
local v = t[i]
if v then
return i, v
end
end
end
上述代码中,ipairs()
是工厂函数,创建并返回一个闭包作为迭代器。这个迭代器有2个Upvalue,一个是固定不变的表t
,另外一个是遍历的位置i
。我们可以称这两个Upvalue为迭代环境。在遍历过程中,迭代器返回数组的索引和值;而当遍历结束时不返回值,也可以认为是返回了nil。
可以直接调用这个迭代器,但更常见的是在泛型for语句中使用:
-- 直接调用迭代器
local iter = ipairs(t)
while true do
local i, v = iter()
if not i then break end
block -- do something
end
-- 在泛型for语句中使用
for i, v in ipairs(t) do
block -- do something
end
迭代器这样的使用方式固然很方便,但是前面几节也介绍了,创建一个闭包比创建一个普通函数要有额外的开销,即无论对Lua闭包还是Rust闭包,都需要额外的2次内存分配和2次指针跳转。所以Lua语言中的泛型for语句为此做了特殊的优化,即由泛型for语句本身代替闭包来保存迭代环境(也就是Upvalue)。既然不需要Upvalue,那迭代器也就不需要使用闭包,而只需要普通函数了。
具体说来,泛型for语句的语法如下:
stat ::= for namelist in explist do block end
namelist ::= Name {‘,’ Name}
其执行流程如下:
-
循环开始时,对
explist
求值,得到3个值:迭代函数、不可变状态、和控制变量。大部分情况下explist
是函数调用语句,那么求值就遵循函数返回值的求值规则,即如果不足3个就用nil补上,如果超过3个就丢弃多余的。当然也可以不使用函数调用,而是直接罗列3个值。 -
然后,每次执行循环前,都用后面两个值(不可变状态和控制变量)作为参数来调用迭代函数,并判断第一个返回值:如果为nil则终止循环;否则把返回值赋值给
namelist
,并额外把第一个返回值赋值给控制变量,以作为后续再次调用迭代函数的参数。
可以看到explist
返回的这3个值拼起来就是一个闭包的功能:迭代函数是函数原型,后面两个是Upvalue。只不过泛型for语句帮忙维护了这两个Upvalue。利用泛型for语句的这个特性,重新实现上述的遍历数组的迭代器如下:
local function iter(t, i) -- t和i都从Upvalue变成了参数
i = i + 1
local v = t[i]
if v then
return i, v
end
end
function ipairs(t)
return iter, t, 0
end
相比于上面的闭包版本,这里t
和i
都从Upvalue变成了参数,而iter
也就变成了一个普通的函数。
这么看来,并不需要闭包(比如在上一章介绍完函数后)就可以完成泛型for语句。但是这毕竟是基于闭包所做的优化,在掌握闭包的基础上才能理解为什么这么做。所以我们才在介绍完闭包后才来实现泛型for语句。
另外,这里的函数调用语句ipairs(t)
只是返回了3个变量,另外也可以在泛型for语句中直接罗列这3个变量:
for i, v in ipairs(t) do ... end
for i, v in iter, t, 0 do ... end -- 直接罗列3个变量
下面这种直接罗列的做法省略一次函数调用,但是不方便。所以常见的还是第一种做法。
介绍完Lua中泛型for语句的特性,下面开始实现。
实现
根据上面的介绍,泛型for语句自己保存并维护迭代环境。那保存在哪里?自然还是在栈上。就像数值型for语句在栈上会自动创建3个变量(1个计数变量和2个匿名变量)一样,泛型for语句也需要自动创建3个匿名变量,对应上述的迭代环境:迭代函数、不可变状态、控制变量。这3个变量是对explist
求值后得到,如下面左图显示的栈示意图:
| | | | | |
+-----------+ +-----------+ +-----------+
| iter func |entry | iter func | | iter func |
+-----------+ +-----------+ +-----------+
| state |\ | state | | state |
+-----------+ 2args +-----------+ +-----------+
| ctrl var |/ | ctrl var | | ctrl var |<--first return value
+-----------+ +-----------+ ->+-----------+
| | : : / | name- | i
+-----------+ / | list | v
| return- |- | |
| values |
| |
接下来就执行循环,包括3个步骤:调用迭代函数、判断是否继续循环、控制变量赋值。
首先,以不可变状态state
和控制变量ctrl var
为两个参数,调用迭代函数iter func
。看左图中栈示意图,刚好已经排列成函数调用的阵型,所以可以直接调用函数;
其次,调用完函数后(上面中图),判断第一个返回值是否为nil,如果是则退出循环;如果没有返回值也退出循环;否则继续执行循环。在执行循环体前,需要处理返回值(上面右图):
-
把第一个返回值赋值给控制变量(上图中的ctrl-var),作为下次调用迭代函数的参数;
-
把返回值赋值给变量列表,也就是上面BNF里的
namelist
。比如上面遍历数组的例子中,就是i, v
。如果返回值个数少于变量列表,则用nil补足。这个补齐操作跟普通的函数调用一致。而不一致的地方是,普通函数调用的返回值会挪到函数入口处,即上图中iter func
的位置;而这里是向下偏移了3个位置。
这里需要说明的一点是,控制变量ctrl-var
就是namelist
的第一个name。所以实际上,栈上并不需要特意给ctrl-var
保留位置;每次调用完迭代函数后,直接把所有返回值挪到图中ctrl-var
的地方即可,这样第一个返回值刚好也就在ctrl-var
的位置。下图是这两个方案的对比。左图是开始的方案,特意给ctrl-var
保留位置;右图是新方案,只需要2个匿名变量来保存迭代环境,而ctrl-var
跟第一个name重叠:
| | | |
+-----------+ +-----------+
| iter func | | iter func |
+-----------+ +-----------+
| state | | state |
+-----------+ +-----------+
| ctrl var | i| name- | <--ctrl var
+-----------+ v| list |
i| name- | | |
v| list |
| |
右面的方案更简单,少一次变量赋值。而且正常情况下,这两个方案的功能一样。但在一个情况下功能会有差别,即在循环体内修改控制变量的时候。比如下面的示例代码:
for i, v in ipairs(t) do
i = i + 1 -- 修改控制变量`i`
print(i)
end
按照上面左图的方案,这里修改的i
是暴露给程序员的变量,而控制变量ctrl var
是隐藏的匿名变量,这两个变量是独立的。所以对i
的修改不影响控制变量ctrl var
。于是这个循环依旧可以遍历全部数组。
而按照右图的方案,i
和ctrl var
是一个值,修改i
就是修改了ctrl var
,也就影响了下一次的迭代函数调用,最终导致无法正常遍历整个数组。
哪个行为更合理呢?Lua手册的说明是:You should not change the value of the control variable during the loop。也就是说对这种行为并没有明确定义,所以那个方案都可以。不过Lua的官方实现是按照左图中的行为,为了保持一致我们这里也选择左图的方案。
字节码
上面介绍了泛型for语句在循环中的操作。这些操作需要一个新的字节码ForCallLoop
来完成。
在定义这个字节码之前,先来看下这个字节码要放置在哪里?是循环开始,还是循环结束?如果按照Lua代码,那应该是放在循环开始,然后在循环结束的地方生成一个Jump
字节码跳转回来继续循环,就像下面这样:
ForCallLoop # 如果调用迭代函数返回nil,则跳转到最后
... block ...
Jump (back-to-ForCallLoop)
但是这样的话每次循环都要执行2条字节码,开头的ForCallLoop
和结尾的Jump
。为了减少一次字节码,可以把ForCallLoop
放在循环结束的位置,这样只有第一次循环时要执行2条字节码,后续每次循环就只需要执行1条字节码了:
Jump (forward-to-ForCallLoop)
... block ...
ForCallLoop # 如果调用迭代函数返回不是nil,则跳转到上面block处
确定字节码位置之后,再来看定义。这个字节码需要关联3个参数:
- 迭代函数
iter func
的栈索引; - 变量个数,用于返回值的赋值。如果返回值个数小于变量个数,则需要填上nil;
- 跳转距离。
前面2个参数都可以用1个字节表示,那最后的跳转距离就也只剩1个字节的空间了,只能表示255的距离,不太够。为此只能再加上一个Jump
字节码一起来完成这个功能。但是大部分情况循环体并不大,不超过255的距离,为了少数的大循环体而再加一个字节码有点浪费。这种情况下最好的办法就是:
- 对于小循环体可以把跳转距离编入到
ForCallLoop
字节码中,只用这1个字节码; - 对于大循环体,把
ForCallLoop
字节码中的第3个参数跳转距离就设置为0,并新增一个Jump
字节码做配合。
这样在虚拟机执行的时候:
- 在继续循环需要向后跳转的情况:对于小循环体就直接根据第3个参数跳转;对于大循环体,第3个参数是0,实际跳转到下一条Jump字节码,然后再执行循环体的跳转。
- 在要结束循环需要继续向前执行的情况:对于小循环体无需特殊处理;对于大循环体,需要跳过下一条Jump字节码。
综上可得,字节码ForCallLoop
定义如下:
pub enum ByteCode {
ForCallLoop(u8, u8, u8),
具体的语法分析和虚拟机执行代码,这里就省略掉了。
至此,完成泛型for语句。我们也完成了Lua所有的语法!(此处应有掌声)