16 编译、执行和错误

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 语言的独立解释器就是使用这个函数来构造错误信息的。


练习题我个人感觉实用性比较低,决定跳过它

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343