8.1 局部变量和代码块
Lua 语言中的变量在默认情况下是全局变量,所有的局部变量在使用前必须声明。与全局变量不同,局部变量的生效范围仅限于声明它的代码块。一个代码块是一个控制结构的主体,或是一个函数的主体,或是一个代码段(即变量被声明时所在的文件或字符串):
x = 10
local i = 1
while i <= x do
local x = i * 2
print(x)
i = i + 1
end
if i > 20 then
local x
x = 20
print(x + 2)
else
print(x)
end
print(x)
请注意,上述示例在交互模式中不能正常运行。因为在交互模式中,每一行代码就是一个代码段。一旦输入示例的第二行,Lua 语言的解释器就会直接运行它并在下一行开始一个新的代码段。这样,局部的声明就超出了原来的作用范围。解决这个问题的一种方式就是显式地声明整个代码块,即将它放入一对 do - end 中。一旦输入了 do,命令就会只在遇到匹配的 end 时才结束,这样 Lua 语言解释器就不会单独执行每一行的命令。
当需要更好地控制某些局部变量的生效范围时,do 程序块也同样有用:
local x1, x2
do
local a2 = 2 * a
local d = (b ^ 2 - 4 * a * c) ^ (1 / 2)
x1 = (-b + d) / a2
x2 = (-b - d) / a2
end
print(x1, x2)
尽可能地使用局部变量是一种良好的编程风格。首先,局部变量可以避免由于不必要的命名而造成全局变量的混乱;其次,全局变量还能避免同一程序中不同代码部分中的命名冲突;再次,访问局部变量比访问全局变量更快;最后,局部变量会随着其作用域的结束而消失,从而使得垃圾收集器能够将其释放。
鉴于局部变量优于全局变量,有些人就认为 Lua 语言应该把变量默认视为局部的。然而,把变量默认视为局部的也有一系列问题(例如非全局变量的访问问题)。一个更好的解决办法并不是把变量默认视为局部变量,而是在使用变量前必须声明。Lua 语言的发行版中有一个用于全局变量检查的模块 strict.lua,如果试图在一个函数中对不存在的全局变量赋值或者使用不存在的全局变量,将会抛出异常。这在开发 Lua 语言代码时是一个良好的习惯。
局部变量的声明可以包含初始值,其赋值规则与常见的多重赋值一样:多余的值被丢弃,多余的变量被赋值为 nil。如果一个声明中没有赋初值,则变量会被初始化为 nil:
local a, b = 1, 10
if a < b then
print(a)
local a
print(a)
end
print(a, b)
Lua 语言中有一种常见的用法:
local foo = foo
这段代码声明了一个局部变量 foo,然后用全局变量 foo 对其赋初值(局部变量 foo 只有在声明之后才能被访问)。这个用法在需要提高对 foo 的访问速度时很有用。当其他函数改变了全局变量 foo 的值,而这段代码又需要保留原始值时,这个用法很有用,尤其是在进行运行时动态替换时。即使其他代码把 print 动态替换成了其他函数,在local print = print
语句之前的所有代码使用的都还是原先的print
函数。
有些人认为在代码块的中间位置声明变量是一个不好的习惯,实际上恰恰相反:我们很少会在不赋初值的情况下声明变量,在需要时才声明变量可以避免漏掉初始化这个变量。此外,通过缩小变量的作用域还有助于提高代码的可读性。
8.2 控制结构
Lua 语言提供了一组精简且常用的控制结构,包括用于条件执行的 if 以及用于循环的 while、repeat 和 for。所有的控制结构语法上都有一个显式的终结符:end 用于终结 if、for 及 while 结构,until 用于 repeat 结构。
控制结构的条件表达式的结果可以是任何值。请记住,Lua 语言将所有不是 false 和 nil 的值当作真(特别地,Lua 语言将 0 和空字符串也当作真)。
8.2.1 if then else
if 语句先测试其条件,并根据条件是否满足执行相应的 then 部分或 else 部分。else 部分是可选的。
if a < 0 then a = 0 end
if a < b then return a else return b end
if line > MAXLINES then
showpage()
line = 0
end
如果要编写嵌套的 if 语句,可以使用 elseif。它类似于在 else 后面紧跟一个 if,但可以避免重复使用 end:
if op == "+" then
r = a + b
elseif op == "-" then
r = a - b
elseif op == "*" then
r = a * b
elseif op == "/" then
r = a / b
else
error("invalid operation")
end
由于 Lua 语言不支持 switch 语句,所以这种一连串的 else-if 语句比较常见。
8.2.2 while
顾名思义,当条件为真时 while 循环会重复执行其循环体。Lua 语言先测试 while 语句的条件,若条件为假则循环结束;否则,Lua会执行循环体并不断地重复这个过程。
local i = 1
while a[i] do
print(a[i])
i = i + 1
end
8.2.3 repeat
顾名思义,repeat-until 语句会重复执行其循环体直到条件为真时才结束。由于条件测试在循环体之后执行,所以循环体至少会执行一次。
local line
repeat
line = io.read()
until line ~= ""
print(line)
和大多数其他编程语言不同,在 Lua 语言中,循环体内声明地局部变量的作用域包括测试条件:
-- 使用 Newton-Raphson法计算 'x' 的平方根
local sqr = x / 2
repeat
sqr = (sqr + x / sqr) / 2
local error = math.abs(sqr^2 - x)
until error < x /10000
8.2.4 数值型 for
for 语句有两种形式:数值型 for 和泛型 for。
数值型 for 的语法如下:
for var = exp1, exp2, exp3 do
--somthing
end
在这种循环中,var 的值从 exp1 变化到 exp2 之前的每次循环会执行 something,并在每次循环结束后将步长 exp3 增加到 var 上。第三个表达式 exp3 是可选的,若不存在,Lua 语言会默认步长值为 1.如果不想给循环设置上限,可以使用常量 math.huge:
for i = 1, math.huge do
if(0.3 8 i ^ 3 - 20 * i ^ 2 - 500 >= 0) then
print(i)
break
end
end
为了更好地使用 for 循环,还需要了解一些细节。首先,在循环之前,三个表达式都会运行一次;其次,控制变量是被 for 语句自动声明的局部变量,且其作用范围仅限于循环体内。一种典型的错误是认为控制变量在循环结束后仍然存在:
for i = 1, 10 do print(i) end
max = i
如果需要在循环结束后使用控制变量的值(通常在中断循环时),则必须将控制变量的值保存在另一个变量中:
local found = nil
for i = 1, #a do
if a[i] < 0 then
found = i
break
end
end
print(found)
最后,不要改变控制变量的值,随意改变控制变量的值可能产生不可预知的结果。如果要在循环正常结束前停止 for 循环,那么可以参考上面的例子,使用 break 语句。
8.2.5 泛型 for
泛型 for 遍历迭代函数返回的所有值,例如我们已经在很多示例中看到过的 pairs、ipairs 和 io.lines 等。虽然泛型 for 看似简单,但它功能非常强大。使用恰当的迭代器可以在保证代码可读性的情况下遍历几乎所有的数据结构。
当然我们也可以自己编写迭代器。尽管泛型 for 的使用很简单,但编写迭代函数却有不少细节需要注意。我们会在后续的第 18 章中继续讨论该问题。
与数值型 for 不同,泛型 for 可以使用多个变量,这些控制变量是循环体中的局部变量,我们也不应该在循环中改变其值。
8.3 break、return 和 goto
break 和 return 语句从当前的循环结构中跳出,goto 语句则允许跳转到函数中的几乎任何地方。
我们可以使用 break 语句结束循环,该语句会中断包含它的内层循环;该语句不能在循环外使用。break 中断后,程序会紧跟着被中断的循环继续执行。
return 语句用于返回函数的执行结果或简单地结束函数的运行。所有函数的最后都有一个隐含的 return,因此我们不需要在每一个没有返回值的函数最后书写 return 语句。
按照语法,return 只能是代码块中的最后一句:换句话说,它只能是代码块的最后一句,或者是 end、else 和 until 之前的最后一句。例如,在下面的例子中,return 是 then 代码块的最后一句:
local i = 1
while a[i] do
if a[i] == v then return i end
i = i + 1
end
通常,这些地方正是使用 return 的典型位置,return 之后的语句不会被执行。不过,有时在代码块中间使用 return 也是很有用的。例如,在调试时我们可能不想让某个函数执行。在这种情况下,可以显式地使用一个包含 return 的 do:
function foo()
return
do return end
-- other statements
end
goto 语句用于将当前程序跳转到相应的标签处继续执行。goto 语句一直以来备受争议,至今仍有许多人认为它们不利于程序开发且应该在编程语言中禁止。不过尽管如此,仍有很多语言处于很多原因保留了 goto 语句。goto 语句有很强大的功能,只要足够细心,我们就能够利用它来提高代码质量。
在 Lua 语言中,goto 语句的语法非常传统,即保留字 goto 后面紧跟着标签名,标签名可以是任意有效的标识符。标签名可以是任意有效的标识符。标签额语法稍微有点复杂:标签名称前后紧跟两个冒号(形如 ::name:: )。这个复杂的语法是有意而为的,主要是为了在程序中醒目地突出这些标签。
在使用 goto 跳转时,Lua 语言设置了一些限制条件。首先,标签遵循常见的可见性规则,因此不能直接跳转到一个代码块中的标签(因为代码块中的标签对外不可见)。其次,goto 不能跳转到函数外。最后,goto 不能跳转到局部变量作用域。
关于 goto 语句经典且正确的使用方式请参考其他一些编程语言中存在但 Lua 语言中不存在的代码结构,例如 continue、多级 break、多级 continue、redo和局部错误处理等。continue 语句仅仅相当于一个跳转到位于循环体最后位置处标签的goto语句,而 redo 语句则相当于跳转到代码块开始位置的 goto 语句:
while some_condition do
::redo::
if some_other_condition then goto continue
elseif yet_ another_condition then goto redo
end
-- some code
::continue::
end
Lua 语言规范中一个很有用的细节是,局部变量的作用域终止于声明变量的代码块中的最后一个有效语句处,标签被认为是无效语句。下列展示了这个实用的细节:
while some_condition do
if some_other_condition then goto continue end
local var = something
-- somecode
::continue::
end
读者可能认为,这个 goto 语句跳转到了变量 var 的作用域内。但实际上这个 continue 标签出现在该代码块的最后一个有效语句后,因此 goto 并未跳转进入变量 var 的作用域内。
goto 语句在编写状态机时也很有用。示例 8.1 给出了一个用于检验输入是否包含偶数个 0 的程序。
示例 8.1 一个使用 goto 语句的状态机的示例
::s1:: do
local c = io.read(1)
if c == '0' then goto s2
elseif c == nil then print 'ok'; return
else goto s1
end
end
::s2:: do
local c = io.read(1)
if c == '0' then goto s1
elseif c == nil then print 'not ok'; return
else goto s2
end
end
goto s1
虽然可以使用更好的方式来编写这段代码,但上例中的方法有助于将一个有限自动机自动地转化为 Lua 语言代码。
再举一个简单的迷宫游戏的例子。迷宫中有几个房间,每个房间的东南西北方向各有一扇门。玩家每次可以输入移动的方向,如果在这个方向上有一扇门,则玩家可以进入相应的房间,否则程序输出一个警告,玩家的最终目的是从第一个房间走到最后一个房间。
这个游戏是一个典型的状态机,当前玩家所在房间就是一个状态。为实现这个迷宫游戏,我们可以为每个房间对应的逻辑编写一段代码,然后用 goto 语句表示从一个房间移动到另一个房间。示例 8.2 展示了如何编写一个由 4 个房间组成的小迷宫。
示例 8.2 一个迷宫游戏
goto room1 --起始房间
::room1:: do
local move = io.read()
if move == "south" then goto room3
elseif move == "east" then goto room2
else
print("invalid move")
goto room1 --待在同一个房间
end
end
::room2:: do
local move = io.read()
if move == "south" then goto room4
elseif move == "west" then goto room1
else
print("invalid move")
goto room2
end
end
::room3:: do
local move = io.read()
if move == "north" then goto room1
elseif move == "east" then goto room4
else
print("invalid move")
goto room3
end
end
::room4:: do
print("Congratulations, you win!")
end
对于这个简单的游戏,读者可能会发现,使用数据驱动编程是一种更好的设计方法。不过,如果游戏中的每间房都不同,那么就非常适合使用这种状态机的实现方法。
8.4 练习
- 练习 8.1:大多数 C 语法风格的编程语言都不支持 elseif 结构,为什么 Lua 语言比这些语言更需要这种结构?
首先,Lua 语言没有 switch,所以需要一个结构来处理多重条件判断的问题,其次,使用 elseif 可以少写很多 end 避免出现因 end太多导致编写代码出错的问题。
- 练习 8.2:描述 Lua 语言中实现无条件循环的 4 种不同方法,你更喜欢哪一种?
while true do end
repeat until false
for i = 0, 1 , -1 do end
::continue:: do goto continue end
一般见得多的都是 while true,所以我也喜欢用 while true。
- 练习 8.3:很多人认为,由于 repeat-until 很少使用,因此像 Lua 语言这样的简单的编程语言中最好不要出现,你怎么看?
repeat-until 对标类 C 中的 do-while,有时候使用这种结构逻辑上更加通顺,可以减少开发的难度,减少出错的概率,所以还有保留的必要。
- 练习 8.4:正如在 6.4 节中我们所见到的,尾部调用伪装成了 goto 语句。请用这种方法重写 8.2.5 节的迷宫游戏。每个房间此时应该是一个新函数,而每个 goto 语句变成了一个尾调用。
---@return function
function room1()
print("enter room1")
local move = io.read()
if move == "south" then
return room3()
elseif move == "east" then
return room2()
else
print("invalid move")
return room1()
end
end
---@return function
function room2()
print("enter room2")
local move = io.read()
if move == "south" then
return room4()
elseif move == "west" then
return room1()
else
print("invalid move")
return room2()
end
end
---@return function
function room3()
print("enter room3")
local move = io.read()
if move == "north" then
return room1()
elseif move == "east" then
return room4()
else
print("invalid move")
return room3()
end
end
function room4()
print("Congratulations, you win!")
end
room1()
- 练习 8.5:请解释下为什么 Lua 语言会限制 goto 语句不能跳出一个函数。(提示:你要如何实现这个功能)
因为在进入一个函数的时候,编译器需要保存现场,将上下文进行压栈,如果 goto 语句可以跳出一个函数的话,必须要保存现场以便返回,但是这样就没有使用 goto 的必要了。
- 练习 8.6:假设 goto 语句可以跳转出函数,请说明示例 8.3 中的程序将会如何执行。
示例 8.3 一种诡异且不正确的 goto 语句的使用
function getlabel()
return function() goto L1 end
::L1::
return 0
end
function f(n)
if n == 0 then return getlabel()
else
local res = f(n - 1)
print(n)
return res
end
end
x = f(10)
x()
请试着解释为什么标签要使用局部变量相同的作用范围规则。
当 n == 0 的时候 getlabel 已经 return 了,当goto以后就不知道会跳转到哪里去了,如果直接使用栈中的上下文数据,会直接从f(1) 返回到 f(2) 但值不能保证传给了 f(2) 的 res,从编译原理的角度说,return 值是通过公共的内存区域来共享的,但 f(0) 并不知道 f(2) 的 公共区域地址在哪里,所以会导致很多奇怪的错误。
正因为有程序栈的存在,所以不能随意的在不同的块中随意跳转。