Lua编译执行与错误处理详解:从loadfile到assert的最佳实践 | 南锋

南锋

南奔万里空,脱死锋镝余

Lua编译执行与错误处理详解:从loadfile到assert的最佳实践

虽然我们把Lua语言成为解释型语言,但Lua语言总是在运行代码前先预编译源码为中间代码(这没什么大不了的,很多解释型语言也这样做)。编译阶段的存在听上去超出了解释型语言的范畴,但解释型语言的区分并不在与源码是否被编译,而在于是否有能力(且轻易地)执行动态生成的代码。可以认为,正是由于诸如dofile这样函数的的存在,才使得Lua语言能够被称为解释型语言。

编译

此前,我们已经介绍过函数dofile,它是运行Lua代码段的主要方式之一。实际上,函数dofile是一个辅助函数,函数loadfile才完成了真正的核心工作。与函数dofile类似,函数loadfile也是从文件中加载Lua代码段,但它不会运行代码,而是只是编译代码,然后将编译后的代码段作为一个函数返回。此外,与函数dofile不同,函数loadfile只返回错误码而不抛出异常。可以认为,函数dofile就是:

1
2
3
4
function dofile (filename)
local f = assert(loadfile(filename))
return f()
end

请注意,如果函数loadfile执行失败,那么函数assert会引发一个错误。
对于简单的需求而言,由于函数dofile在一次调用中就做完了所有工作,所以改函数非常易用。不过,函数loadfile更灵活。在发生错误的情况中,函数loadfile会返回nil及错误信息,以允许我们按自定义的方式来处理错误。此外,如果需要多次运行一个文件,那么只需要调用一次loadfile函数后再多次调用它的返回结果即可。由于只编译一次文件,因此这种方式的开销要比多次调用函数dofile小得多(编译在某种程度上相比其他操作开销更大)。
函数load与函数loadfile类似,不同之处在于该函数从一个字符串或函数中读取代码段,而不是从文件中读取。例如,考虑如下的代码:

1
f = load("i = i + 1")

在这句话代码执行后,变量f就会变成一个被调用时执行i = i + 1的函数:

1
2
3
i = 0 
f();print(i) -- 1
f();print(i) -- 2

尽管函数load的功能很强大,但还是应该谨慎地使用。相当于其他可选的函数而言,该函数的开销较大并且可能会引起诡异的问题。请先确定当下已经找不到更简单的解决方式后再使用该函数。
如果要编写一个用后即弃的dostring函数(例如加载并运行一行代码),那么我们可以直接调用函数load的返回值:

1
load(s)()

不过,如果代码中有语法错误,函数load就会返回你来和形如”attempt to call a nil value”的错误信息。为了更清楚地展示错误信息,最好使用函数assert:

1
assert(load(s))()

通常,用函数load来加载字符串常量是没有意义的。例如,如下的两行代码基本等价:

1
2
3
f = load("i = i + 1")

f = function() i = i + 1 end

但是,由于第2行代码会与其外层的函数一起被编译,所以其执行速度要快得多。与之对比,第一段代码在调用函数load时会进行一次独立的编译。

由于函数load在编译时不涉及词法定界,所以上述示例的两段代码可能并不完全等价。为了清洗地展示它们之间的区别,让我们稍微修改一下上面的例子:

1
2
3
4
5
6
i = 32
local i = 0
f = load("i = i +1;print(i)")
g = function() i = i + 1; print(i) end
f() -- 33
g() -- 1

函数g像我们所预期地那样操作局部变量i,但函数f操作的却是全局变量i,这是由于函数load总是在全局环境中编译代码段。
函数load最典型的用法是执行外部代码(即那些来自程序本身之外的代码段)或动态生成的代码。例如,我们可能想运行用户定义的函数,由用户输入函数的代码后调用函数load对其求值。请注意,函数load期望的输入是一段程序,也就是一系列的语句。如果需要对表达式求值,那么可以在表达式前添加return,这样才能构成一条返回指定表达式值的语句。例如:

1
2
3
4
print "enter your expression:"
local line = io.read()
local f = assert(load("return " .. line))
print("the value of your expression is" .. fun())

由于函数load所返回的函数就是一个普通函数,因此可以反复对其进行调用:

1
2
3
4
5
6
7
print "enter function to be plotted (with variable 'x'):"
local line = io.read()
local f = assert(load("return " .. line ))
for i = 1, 20 do
x = i -- 全局的'x'(当前代码段内可见)
print(string.rep("*",f()))
end

我们也可以使用读取函数作为函数load的第1个参数。读取函数时以分几次返回一段程序,函数load会不断地调用读取函数知道读取函数返回nil(表示程序段结束)。作为示例,以下的调用与函数loadfile等价:

1
f = load(io.lines(filename,"*L"))

调用io.lines(filename.”*L”)返回一个函数,这个函数每次被调用时就从指定文件返回一行。因此,函数load会一行一行地从文件中读出一段程序。以下的版本与之相似但效率稍高:

1
f = load(io.lines(filename,1024))

这里,函数io.lines返回的迭代器会以1024字节为块读取源文件。
Lua语言将所有独立的代码段当做匿名可变长参数函数的函数体。例如,load(“a = 1”)的返回值与以下表达式等价:

1
function(...) a = 1 end

像其他任何函数一样,代码段中可以声明局部变量:

1
2
f = load("local a = 10;pirnt(a + 20")
f() -- 30

使用这个特行,可以在不使用全局变量x的情况下重写之前运行用户定义函数的示例:

1
2
3
4
5
6
print "enter function to be plotted(with variable 'x'):"
local line = io.read()
local f = assert(load("local x = ...;return " .. line))
for i = 1, 20 do
print(string.rep("*",f(i)))
end

在上述代码中,在代码段开头增加了”local x = …” 来将x声明为局部变量。之后使用参数i调用函数f,参数i就是可变长参数表达式的值(…)。
函数load和函数loadfile从来不引发错误。当有错误发生时,它们会返回nil及错误信息:

1
print(load("i i"))			-- nil [string "i i"]:1: '=' expected near 'i'

另外,这些函数没有任何副作用,它们既不改变后创建变量,也不向文件写入等。这些函数只是将程序段编译为一种中间形式,然后将结果作为匿名函数返回。一种常见的误解是认为加载一段程序也就是定义了函数,但实际上在Lua语言中函数定义是在运行时而不是在编译时发生的一种赋值操作。例如,假设有一个文件foo.lua:

1
2
3
4
-- 文件foo.lua
function foo(x)
print(x)
end

当执行

1
f = loadfile("foo.lua")

时,编译foo的命令并没有定义foo,只有运行代码才会定义它:

1
2
3
4
f = loadfile("foo.lua")
print(foo) -- nil
f() -- 运行代码
foo("ok") -- ok

这种行为可能看上去有些奇怪,但如果不使用语法糖对其进行重写则看上去会清晰很多:

1
2
3
4
-- 文件'foo.lua'
foo = function(x)
print(x)
end

如果线上产品级别的程序需要执行外部代码,那么应该处理加载程序段时报告的所有错误。此外,为了避免不愉快的副作用发生,可能还应该在一个受保护的环境中执行这些代码。

预编译的代码

生成预编译文件(也被称为二进制文件)最简单的方式是,使用标准发行版中附带的luac程序。例如,下列命令会创建文件prog.lua的预编译版本prog.lc:

1
$ luac -o prog.lc prog.lua

Lua解析器会像执行普通Lua代码一样执行这个新文件,完成与原来代码完全一致的动作:

1
$ lua prog.lc

几乎在Lua语言中所有能够使用源码的地方都可以使用预编译代码。特别地,函数loadfile和函数load都可以接受预编译代码。
我们可以直接在Lua语言中实现一个最简单的Luac:

1
2
3
4
p = loadfile(arg[1])
f = io.open(arg[2],"wb")
f:write(string.dump(p))
f:close()

这里关键函数是string.dump,该函数的入参是一个Lua函数,返回值是传入函数对应的字符串形式的预编译代码。
luac程序提供了一些有意思的选项。特别地,选项-l会列出编译器为指定代码段生成的操作码。

示例 luac -l的输出示例

1
2
3
4
5
6
7
8
9
main<stdin:0,o> (7 instructions, 28bytes at 0x988cb30)
0 + params, 2 slots, 0 upvalues, o locals, 4 constants, 0 functions
1 [1] GETGLOBAL 0 -2 ; x
2 [1] GETGLOBAL 1 -3 ; y
3 [1] ADD 0 0 1
4 [1] GETGLOBAL 1 -4 ; x
5 [1] USB 0 0 1
6 [1] SETGLOBAL 0 -1 ; a
7 [1] RETURN 0 1

预编译形式的代码不一定比源代码更小,但是却加载得更快。预编译形式的代码的另一个好处是,可以避免由于意外而修改源码。然而,与源代码不同,蓄意损坏或构造的二进制代码可能会让Lua解析器奔溃或甚至执行用户提供的机器码。当运行一般的代码时通常无须担心,但应该避免运行以预编译形式给出的非受信代码。这种需求,函数load正好有一个选项可以适用。
除了必需的第1个参数外,函数load还有3个可选参数。第2个参数是程序段的名称,只在错误信息中被用到。第4个参数是环境。第3个参数正是我们这里关心的,它控制了允许加载的代码段的类型。如果该参数存在,则只能是如下的字符串:字符串”t”允许加载文本类型的代码段,字符串”b”只允许加载二进制类型的代码段,字符串”bt”允许同时加载上述两种类型的代码段。

错误

人人皆难免犯错误。因此,我们必须尽可能地处理错误。由于lua语言是一种经常被嵌入在应用程序中的扩展语言,所以当错误发生时并不能简单地奔溃或退出。相反,只要错误发生,Lua语言就必须提供处理错误的方式。
Lua语言会在遇到非预期的情况时引发错误。例如,当试图将两个非常值类型的值相加,对不是函数的值进行调用,对不是表类型的值进行索引等。我们也可以显示地通过调用函数error并传入一个错误信息来作为参数引发一个错误。通常,这个函数就是在代码中提示出错的合理方式:

1
2
3
pirnt "enter a number :"
n = io.read("n")
if not n then error("invalid input") end

由于“针对某些情况调用函数error”这样的代码结构太常见了,所以Lua语言提供了一个内建的函数assert来完成这类工作:

1
2
print "enter a nubmer:"
n = assert(io.read("*n"),"invalid input")

函数assert检查其第1个参数是否为真,如果该参数为真则返回该参数;如果该参数为假,则引发一个错误。该函数的第2个参数是一个可选的错误信息。不过,要注意函数assert只是一个普通函数,所以Lua语言会总是在调用该函数前先对参数进行求值。如果编写形如

1
2
n = io.read()
assert(tonumver(n),"invalid input:" .. n .. "is not a number")

的代码,那么即使n是一个数值类型,Lua语言也总是会进行字符串连接。在这种情况下使用显示的测试可能更加明智。
当一个函数发现某种意外的情况发生时,在进行一场处理时可以采取两种基本方式:一种是返回错误代码(通常是nil或者false),另一种是通过调用函数error引发了一个错误。如何在这两种方式之间进行选择并没有固定的规则,但笔者通常遵循如下的知道原则:容易避免的异常应该引发错误,否则应该返回错误码。
以函数math.sin为例,当调用参数传入了一个表该如何反应呢?如果要检查错误,那么就不得不编写如下代码:

1
2
3
local res = math.sin(x)
if not res then -- 错误?
error-handling code

当然,也可以在调用函数前轻松地检查出这种异常:

1
2
if not tonumber(x) then			-- x是否为数字
error-handling code

通常,我们既不会检查参数也不会检查函数sin的返回值;如果sin的参数不是一个数值,那么就意味着我们的程序可能出现了问题。此时,处理异常最简单也是最实用的做法就是停止运行,然后输出一条错误信息。
另一方面,让我们再考虑一下用于打开文件的函数io.open。如果要打开的文件不存在,那么该函数应该有怎么样的行为呢?在这种情况下,没有什么简单的方法可以在调用函数前检测到这种异常。在很多系统中,判断一个文件是否存在的唯一方法就是试着去打开这个文件。因此,如果由于外部原因导致函数io.open无法打开一个文件,那么它应返回false及一条错误信息。通过这种方式,我们就有机会采取恰当的方式来处理这个异常情况,例如要求用户提供另外一个文件名:

1
2
3
4
5
6
7
8
local file, msg
repeat
print "enter a file name:"
local name = io.read()
if not name then return end --没有输入
file, msg = io.open(name,"r")
if not file then print(msg) end
until file

如果不想处理这些情况,但又想安全地运行程序,那么只需要用assert:

1
file = assert(io.open(name,"r"))	-- stdin:1:no-file: No such file or directory

这是Lua语言中一种典型的技巧:如果函数io.open执行失败,assert就引发一个错误。

错误处理和异常

对于大多数应用而言,我们无须在Lua代码中做任何错误处理,应用程序本身会负责处理这类问题。所以Lua语言的行为是由应用程序的第一次调用而触发的,这类调用通常是要求Lua语言执行一段代码。如果执行中发生了错误,那么调用会返回一个错误代码,以便应用程序采取适当的行为来处理错误。当独立解释器中发生错误时,主循环会打印错误信息,然后继续显示提示符,并等待执行指令的命令。
不过,如果要在Lua代码中处理错误,那么就应该使用函数pcall来封装代码。
假设要执行一段Lua代码并捕获执行中发生的所有错误,那么首先需要将这段代码封装到一个函数中,这个函数通常是一个匿名函数。之后通过pcall来调用这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
local ok , msg = pcall (function()
some code
if unexpected_condition then error() end
some code
print(a[i]) -- 潜在错误,'a'可能不是一个表
some code
end)

if ok then -- 执行被保护的代码时没有发生错误
regular code
else -- 执行被保护的代码时有错误发生:进行恰当的处理
error-handling code
end

函数pcall会以一种保护模式来调用它的第1个参数,以便捕获该函数执行中的错误。无论是否有错误发生,函数pcall都不会引发错误。如果没有错误发生,那么pcall返回true及调用函数的所有返回值;否则,则返回false及错误信息。
使用”错误信息”的命名方式可能会让人误解错误信息必须是一个字符串,因此称之为错误对象可能更好,这主要是因为函数pacll能够返回传递给error的任意语言类型的值。

1
2
local status , err = pcall(function() error({coude = 121}) end)
print(err.code) -- 121

这些机制为我们提供了在Lua语言中进行异常处理的全部。我们可以通过error来抛出异常,然后用函数pcall来捕获异常,而错误信息则用来表示错误的类型。

错误信息和栈回调

虽然能够使用任何类型的值作为错误对象,但错误对象通常是一个描述出错内容的字符串。当遇到内部错误出现时,Lua语言负责产生错误对象,如果错误对象是一个字符串,那么Lua语言会尝试把一些有关错误发生位置的信息附上:

1
2
local status , err = pcall(function() error("my error") end)
print(err) -- stdin:1:my error

位置信息中给出了出错代码段的名称和行号。
函数error还有第2个可选参数level,用于指出向函数调用层次中的哪层函数报告错误,以说明谁应该为错误负责。例如,假设编写一个用来检查其自身是否被正确调用了的函数:

1
2
3
4
5
6
function foo(str)
if type(str) ~= "string" then
error("string expected")
end
regular code
end

如果调用时被传递了错误的参数:

1
foo({x = 1})

由于函数foo调用的error,所以Lua语言会认为是函数foo发生了错误。然而,真正的肇事者其实是函数foo的调用者。为了纠正这个问题,我们需要告诉error函数错误实际发生在函数调用蹭的第2层中:

1
2
3
4
5
6
function foo (str)
if typr(str) ~= "string" then
error("string expected",2)
end
regular code
end

通常,除了发生错误的位置以外,我们还希望在错误发生时得到更多的调试信息。至少,我们希望得到具有发生错误时完整函数调用栈回溯。当函数pcall返回错误信息时,部分的调用栈已经被破坏了。因此,如果希望得到一个有意义的栈回溯,那么就必须在函数pcall返回前先将调用栈构造好。为了完成这个需求,Lua语言提供了函数xpcall。该函数与函数pcall蕾西,但它的第2个参数是一个消息处理函数。当发生凑无时,Lua会调用栈展开前调用这个消息处理函数,以便消息处理函数能够使用调试库来获取有关错误的更逗信息。两个常用的消息处理函数是debug.debug和debug.traceback,前者为用户提供一个Lua提示符来让用户检查错误发生的原因;后者则使用调用栈来构造详细的错误,Lua语言的独立解释器就是使用这个函数来构造错误信息的。

+