16.1 编译
此前,我们已经介绍过函数 dofile,它是运行 Lua 代码的主要方式之一。实际上,函数 dofile 是一个辅助函数,函数 loadfile 才完成了真正的核心工作。与函数 dofile 类似,函数 loadfile 也是从文件中加载 Lua 代码段,但它不会运行代码,而只是编译代码,然后将编译后的代码段作为一个函数返回。此外,与函数 dofile 不同,函数 loadfile 只返回错误码而不抛出异常。可以认为,函数 dofile 就是:
---@return function
---@param filename stirng
function dofile(filename)
local f = assert(loadfile(filename))
return f()
end
请注意,如果函数 loadfile 执行失败,那么函数 assert 会引发一个错误。
对于简单的需求而言,由于函数 dofile 在一次调用中就做完了所有工作,所以该函数非常易用。不过,函数 loadfile 更灵活。在发生错误的情况中,函数 loadfile 会返回 nil 及错误信息,以允许我们按自定义的方式来处理错误。此外,如果需要多次运行同一个文件,那么只需调用一次 loadfile 函数以后再多次调用它的返回结果即可。由于只编译一次文件,因此这种方式的开销要比多次调用dofile 小得多(编译在某种程度上相比其他操作开销更大)。
函数 load 与文件 loadfile 类似,不同之处在于该函数从一个字符串或函数中读取代码段,而不是从文件中读取。例如,考虑如下代码:
f = load("i = i + 1")
在这句代码执行后,变量 f 就会变成一个被调用时执行 i = i + 1 的函数:
i = 0
f()
print(i)
f()
print(i)
尽管函数 load 的功能很强大,但还是应该谨慎地使用。相对于其他可选的函数而言,该函数的开销较大并且可能会引起诡异的问题。请先确定当下已经找不到更简单的解决方式后再使用该函数。
如果要编写一个用后即弃的 dostring 函数(例如加载并运行一段代码),那么我们可以直接调用函数 load 的返回值:
load(s)()
不过,如果代码中有语法错误,函数 load 就会返回 nil 和形如 “试图调用一个 nil 值” 的错误信息。为了更清楚地展示错误信息,最好使用函数 assert:
assert(load(s))()
通常,用函数 load 来加载字符串常量是没有意义的。例如,如下得到两行代码基本等价:
f = load("i = i + 1")
f = function () i = i + 1 end
但是,由于第 2 行代码会与其外层的函数一起被编译,所以其执行速度要快得多。与之对比,第一段代码在调用函数 load 时会进行一次独立的编译。
由于函数 load 在编译时不涉及词法定界,所以上述示例的两段代码可能并不完全等价。为了清晰的展示它们之间的区别,让我们稍微修改一下上面的例子:
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,这样才能构成一条返回指定表达式值得语句。例如:
print("enter your expression:")
io.flush() --由于我使用的是idea 所以需要用这个语句来刷新流 否则在控制台看不到上一句 print 函数的内容
local line = io.read()
local func = assert(load("return " .. line))
print("the value of your expression is " .. func())
由于函数 load 所返回的函数就是一个普通函数,因此可以返回对其进行调用:
print("enter function to be plotted (with variable 'x'): ")
io.flush()
local line = io.read()
local f = assert(load("return " .. line))
for i = 1, 20 do
x = i
print(string.rep("*", f()))
end
我们也可以使用读取函数作为函数 load 的第一个参数。读取函数可以分几次返回一段程序,函数 load 会不断地调用读取函数直到读取函数返回 nil(表示程序段结束)。作为示例,以下的调用与函数 loadfile 等价:
f = load(io.lines(filename, "*L"))
正如我们在第 7 章所看到的,调用 io.lines(filename, "*L")
返回一个函数,这个函数每次被调用时就从指定文件返回一行。因此,函数 load 会一行一行地从文件中读出一段程序。以下的版本与之相似但效率稍高:
f = load(io.lines(filename, 1024))
这里,函数 io.lines 返回的迭代器会以 1024 字节为块读取源文件。
Lua 语言将所有独立的代码当做匿名可变长参数的函数体。例如,load("a = 1") 的返回值与以下表达式等价:
function(...) a = 1 end
像其他任何函数一样,代码段中可以声明局部变量:
f = load("local a = 10; print(a + 20)")
f()
使用这个特性,可以在不使用全局变量 x 的情况下重写之前运行用户定义函数的示例:
print("enter function to be plotted (with variable 'x'): ")
io.flush()
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 及错误信息:
print(load("i i")) --> nil [string "i i"]:1: syntax error near 'i'
此外,这些函数没有任何副作用,它们既不改变或创建变量,也不向文件写入等。这些函数只是将程序段编译为一种中间形式,然后将结果作为匿名函数返回。一种常见的误解是认为加载一段程序也就是定义了函数,但实际上在 Lua 语言中函数定义是运行时而不是在编译时发生的一种赋值操作。例如,假设有一个文件 foo.lua:
--- foo.lua
---@return void
---@param x any
function foo(x)
print(x)
end
当执行:
f = loadfile("foo.lua")
时,编译 foo 的命令并没有定义 foo,只有运行代码才会定义它:
f = loadfile("foo.lua")
print(foo) --> nil
f()
foo("ok") --> ok
这种行为可能看上去有些奇怪,但如果不使用语法糖对其进行重写则看上去会清晰很多:
--- foo.lua
---@return void
---@param x any
foo = function(x)
print(x)
end
如果线上产品级别的程序需要执行外部代码,那么应该处理加载程序段时报告的所有错误。此外,为了避免不愉快的副作用发生,可能还应该在一个受保护的环境中执行这些代码我们会在第 22 章中讨论相关的细节。
16.2 预编译的代码
生成预编译文件(也被称为二进制文件)的最简单方式是,使用标准发行版中附带的 luac 程序。例如,下列命令会创建文件 prog.lua 的预编译版本 prog.lc:
$ luac -o prog.lc prog.lua
Lua 解析器会像执行普通 Lua 代码一样执行这个新文件,完成与原来代码完全一致的动作:
lua prog.lc
几乎在 Lua 语言中所有能够使用源码的地方都可以使用预编译代码。特别地,函数 loadfile 和函数 load 都可以接受预编译代码。
我们可以直接在 Lua 语言中实现一个最简单的 luac:
p = loadfile(arg[1])
f = io.open(arg[2], "wb")
f:write(string.dump(p))
f:close()
这里的关键函数是 string.dump,该函数的入参是一个 Lua 函数,返回值是传入函数对应的字符串形式的预编译代码(已被正确地格式化,可由 Lua 语言直接加载)。
luac 程序提供了一些有意思的选项。特别地,选项 -l 会列出编译器为指定代码段生成的操作码(opcode)。例如,示例 16.1 展示了函数 luac 针对如下只有一行内容的文件在带有 -l 选项时的输出:
a = x + y - z
示例16.1 luac -l 的输出示例
main <test.lua:0,0> (7 instructions, 28 bytes at 00710520)
0+ params, 2 slots, 0 upvalues, 0 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 ; z
5 [1] SUB 0 0 1
6 [1] SETGLOBAL 0 -1 ; a
7 [1] RETURN 0 1
预编译形式的代码不一定比源代码更小,但是却加载得更快。预编译形式的代码的另一个好处是,可以避免由于意外而修改源码。然而,与源代码不同,蓄意损坏或构造的二进制代码可能会让 Lua 解析器崩溃或甚至执行用户提供的机器码。当运行一般的代码时通常无须担心,但应该避免运行以预编译形式给出的非受信代码。这种需求,函数 load 正好有一个选项可以适用。
除了必须的第 1 个参数外,函数 load 还有 3 个可选参数。第 2 个参数是程序段的名称,只在错误信息中心被用到。第 4 个参数是环境,我们会在第 22章中对其进行讨论。第 3 个参数正是我们所关心的,它控制了允许加载的代码段的类型。如果该参数存在,则只能是如下的字符串:字符串 "t" 允许加载文本类型的代码段,字符串 "b" 只允许加载二进制类型的代码段,字符串 "bt" 允许同时加载上述两种类型的代码段(默认)。
16.3 错误
Lua 语言会在遇到非预期的情况时引发错误。例如,当试图将两个非数值类型的值相加,对不是函数的值进行调用,对不是表类型的值进行索引等。我们可以显式地调用函数 error 并传入一个错误信息作为参数来引发一个错误。通常,这个函数就是在代码中提示出错的合理方式:
print("enter a number")
io.flush()
n = io.read("n")
if not n then
error("invalid input")
end
由于“针对某些情况调用函数 error”这样的代码结构太常见了,所以 Lua 语言提供了一个内建的函数 assert 来完成这类工作:
print("enter a number")
io.flush()
n = assert(io.read("*n"), "invalid input")
函数 assert 检查其第一个参数是否为真,如果该参数为真则返回该参数;如果该参数为假则引发一个错误。该函数的第 2 个参数是一个可选的错误信息。不过,要注意函数 assert 只是一个普通函数,所以 Lua 语言总是在调用该函数前先对参数进行求值。如果编写形如:
n = io.read()
assert(tonumber(n), "invalid input: " .. n .. " is not a number")
的代码,那么即使 n 是一个数值类型,Lua 语言也总是会进行字符串连接。在这种情况下使用显式的测试可能更加明智。
当一个函数发现某种意外的情况发生时,在进行异常处理时可以采取两种基本方式:一种是返回错误代码(nil 或者 false),另一种是通过调用函数 error 引发一个错误。如何在这两种方式之间选择并没有固定的规则,但笔者通常遵循如下的指导原则:容易避免的异常应该引发错误,否则应该返回错误码
以函数 math.sin 为例,当调用时参数传入了一个表该如何反应呢?如果要检查错误,那么久不得不编写如下代码:
local res = math.sin(x)
if not res then
--error-handling code
end
当然,也可以在调用函数前轻松地检查出这种异常:
if not tonumber(x) then
--error-handling code
end
通常,我们既不会检查参数也不会检查函数 math.sin 的返回值:如果 sin 的参数不是一个数值,那么就意味着我们的程序可能出现了问题。此时,处理异常最简单也是最实用的做法就是停止运行,然后输出一条错误信息。
另一方面,让我们再考虑一下用于打开文件的函数 io.open。如果要打开的文件不存在,那么该函数应该有怎样的行为呢?在这种情况下,没有什么简单的方法可以在调用函数前检测到这种异常。在很多系统中,判断一个文件是否存在的唯一方法就是试着去打开这个文件,。因此如果由于外部原因(比如文件不存在或权限不足)导致 io.open 无法打开一个文件,那么它应该返回 false 及一条错误信息。通过这种方式,我们就有机会采取恰当的方式来处理异常情况,例如要求用户提供另一个文件名:
local file, msg
repeat
print("enter a file name")
io.flush()
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:
file = assert(io.open(name, "r"))
这是 Lua 语言中的一种典型技巧:如果函数 io.open 执行失败,assert 就引发一个错误。请读者注意,错误信息(函数的第 2 个返回值)是如何变成 assert 的第 2 个参数的。
16.4 错误处理和异常
对于大多数应用而言,我们无须在 Lua 代码中做任何错误处理,应用程序本身会负责处理这类问题。所有 Lua 语言的行为都是由应用程序的一次调用而触发的,这类调用通常是要求 Lua 语言执行一段代码。如果执行中发生了错误,那么调用会返回一个错误代码,以便应用程序采取适当的行为来处理错误。当独立解释器中发生错误时,主循环会打印错误信息,然后继续显示提示符,并等待执行指定的命令。
不过,如果要在 Lua 代码中处理错误,那么就应该使用函数 pcall 来封装代码。
假设要执行一段 Lua 代码并捕获执行中发生的所有错误,那么首先需要将这段代码封装到一个函数中,这个函数通常是一个匿名函数。之后,通过 pcall 来调用这个函数:
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 会以一种保护模式来调用它的第一个参数,以便捕获该函数执行中的错误。无论是否有错误发生,函数 pcall 都不会引发错误。如果没有错误发生,那么 pcall 返回 true 及被调用函数(作为 pcall 的第 1 个参数传入)的所有返回值;否则,返回 false 及错误信息。
使用 “错误信息” 的命名方式可能会让人误解错误信息必须使用一个字符串,因此称之为 错误对象 可能更好,这主要是因为函数 pcall 能够返回传递给 error 的任意 Lua 语言类型的值。
local status, err = pcall(function()
error({ code = 121 })
end)
print(err.code)
这些机制为我们提供了在 Lua 语言中进行异常处理的全部。我们可以通过 error 来抛出异常,然后用函数 pcall 来捕获异常,而错误信息则用来标识错误的类型。
16.5 错误信息和栈回溯
虽然能够使用任何类型的值作为错误对象,但错误对象通常是一个描述出错内容的字符串。当遇到内部错误(比如尝试对一个非表类型的值进行索引操作)出现时,Lua 语言负责产生错误对象(这种情况下错误对象永远是字符串;而在其他情况下,错误对象就是传递给函数 error 的值。)如果错误对象是一个字符串,那么 Lua 语言还会尝试把一些有关错误发生位置的信息附上:
local status, err = pcall(function() error("my error") end)
print(error)
位置信息中给出了出错代码段的名称和行号。
函数 error 还有第 2 个可选参数 level,用于指出向函数调用层次中的哪层函数报告错误,以说明谁该为错误负责。例如,假设编写一个用来检查其自身是否被正常调用了的函数:
function foo(str)
if(type(str) ~= "string") then
error("string expected")
end
-- regular code
end
如果调用时被传递了错误参数:
foo({x = 1})
由于是函数 foo 调用的 error,所以 Lua 语言会认为是函数 foo 发生了错误。然而,真正的肇事者其实是函数 foo 的调用者。为了纠正这个问题,我们需要告诉 error 函数错误实际发生在函数调用层次的第 2 层中(第 1 层是 foo 函数自己):
function foo(str)
if(type(str) ~= "string") then
error("string expected", 2)
end
-- regular code
end
通常,除了发生错误的位置以外,我们还希望错误发生时得到更多调试信息。至少,我们希望得到具有发生错误时完整函数调用栈的栈回溯。当函数 pcall 返回错误信息时,部分的调用已经被破坏了。因此,如果希望得到一个有意义的栈回溯,那么就必须在函数 pcall 返回前先将调用栈构建好。为了完成这个需求,Lua 语言提供了函数 xpcall。该函数与函数 pcall 类似,但它的第 2 个参数是一个消息处理函数。当发生错误时,Lua 会在调用栈展开前调用这个消息处理函数,以便消息处理函数能够使用调试库来获取有关错误的更多信息。两个常用的消息处理函数是 debug.debug 和 debug.traceback,前者为用户提供一个 Lua 提示符来让用户检查错误发生的原因;后者则使用调用栈来构造详细的错误信息,Lua 语言的独立解释器就是使用这个函数来构造错误信息的。
练习题我个人感觉实用性比较低,决定跳过它