虽然我们把Lua语言成为解释型语言,但Lua语言总是在运行代码前先预编译源码为中间代码(这没什么大不了的,很多解释型语言也这样做)。编译阶段的存在听上去超出了解释型语言的范畴,但解释型语言的区分并不在与源码是否被编译,而在于是否有能力(且轻易地)执行动态生成的代码。可以认为,正是由于诸如dofile这样函数的的存在,才使得Lua语言能够被称为解释型语言。
编译
此前,我们已经介绍过函数dofile,它是运行Lua代码段的主要方式之一。实际上,函数dofile是一个辅助函数,函数loadfile才完成了真正的核心工作。与函数dofile类似,函数loadfile也是从文件中加载Lua代码段,但它不会运行代码,而是只是编译代码,然后将编译后的代码段作为一个函数返回。此外,与函数dofile不同,函数loadfile只返回错误码而不抛出异常。可以认为,函数dofile就是:
1 | function dofile (filename) |
请注意,如果函数loadfile执行失败,那么函数assert会引发一个错误。
对于简单的需求而言,由于函数dofile在一次调用中就做完了所有工作,所以改函数非常易用。不过,函数loadfile更灵活。在发生错误的情况中,函数loadfile会返回nil及错误信息,以允许我们按自定义的方式来处理错误。此外,如果需要多次运行一个文件,那么只需要调用一次loadfile函数后再多次调用它的返回结果即可。由于只编译一次文件,因此这种方式的开销要比多次调用函数dofile小得多(编译在某种程度上相比其他操作开销更大)。
函数load与函数loadfile类似,不同之处在于该函数从一个字符串或函数中读取代码段,而不是从文件中读取。例如,考虑如下的代码:
1 | f = load("i = i + 1") |
在这句话代码执行后,变量f就会变成一个被调用时执行i = i + 1的函数:
1 | i = 0 |
尽管函数load的功能很强大,但还是应该谨慎地使用。相当于其他可选的函数而言,该函数的开销较大并且可能会引起诡异的问题。请先确定当下已经找不到更简单的解决方式后再使用该函数。
如果要编写一个用后即弃的dostring函数(例如加载并运行一行代码),那么我们可以直接调用函数load的返回值:
1 | load(s)() |
不过,如果代码中有语法错误,函数load就会返回你来和形如”attempt to call a nil value”的错误信息。为了更清楚地展示错误信息,最好使用函数assert:
1 | assert(load(s))() |
通常,用函数load来加载字符串常量是没有意义的。例如,如下的两行代码基本等价:
1 | f = load("i = i + 1") |
但是,由于第2行代码会与其外层的函数一起被编译,所以其执行速度要快得多。与之对比,第一段代码在调用函数load时会进行一次独立的编译。
由于函数load在编译时不涉及词法定界,所以上述示例的两段代码可能并不完全等价。为了清洗地展示它们之间的区别,让我们稍微修改一下上面的例子:
1 | i = 32 |
函数g像我们所预期地那样操作局部变量i,但函数f操作的却是全局变量i,这是由于函数load总是在全局环境中编译代码段。
函数load最典型的用法是执行外部代码(即那些来自程序本身之外的代码段)或动态生成的代码。例如,我们可能想运行用户定义的函数,由用户输入函数的代码后调用函数load对其求值。请注意,函数load期望的输入是一段程序,也就是一系列的语句。如果需要对表达式求值,那么可以在表达式前添加return,这样才能构成一条返回指定表达式值的语句。例如:
1 | print "enter your expression:" |
由于函数load所返回的函数就是一个普通函数,因此可以反复对其进行调用:
1 | print "enter function to be plotted (with variable 'x'):" |
我们也可以使用读取函数作为函数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 | f = load("local a = 10;pirnt(a + 20") |
使用这个特行,可以在不使用全局变量x的情况下重写之前运行用户定义函数的示例:
1 | print "enter function to be plotted(with variable 'x'):" |
在上述代码中,在代码段开头增加了”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 | -- 文件foo.lua |
当执行
1 | f = loadfile("foo.lua") |
时,编译foo的命令并没有定义foo,只有运行代码才会定义它:
1 | f = loadfile("foo.lua") |
这种行为可能看上去有些奇怪,但如果不使用语法糖对其进行重写则看上去会清晰很多:
1 | -- 文件'foo.lua' |
如果线上产品级别的程序需要执行外部代码,那么应该处理加载程序段时报告的所有错误。此外,为了避免不愉快的副作用发生,可能还应该在一个受保护的环境中执行这些代码。
预编译的代码
生成预编译文件(也被称为二进制文件)最简单的方式是,使用标准发行版中附带的luac程序。例如,下列命令会创建文件prog.lua的预编译版本prog.lc:
1 | $ luac -o prog.lc prog.lua |
Lua解析器会像执行普通Lua代码一样执行这个新文件,完成与原来代码完全一致的动作:
1 | $ lua prog.lc |
几乎在Lua语言中所有能够使用源码的地方都可以使用预编译代码。特别地,函数loadfile和函数load都可以接受预编译代码。
我们可以直接在Lua语言中实现一个最简单的Luac:
1 | p = loadfile(arg[1]) |
这里关键函数是string.dump,该函数的入参是一个Lua函数,返回值是传入函数对应的字符串形式的预编译代码。
luac程序提供了一些有意思的选项。特别地,选项-l会列出编译器为指定代码段生成的操作码。
示例 luac -l的输出示例
1 | main<stdin:0,o> (7 instructions, 28bytes at 0x988cb30) |
预编译形式的代码不一定比源代码更小,但是却加载得更快。预编译形式的代码的另一个好处是,可以避免由于意外而修改源码。然而,与源代码不同,蓄意损坏或构造的二进制代码可能会让Lua解析器奔溃或甚至执行用户提供的机器码。当运行一般的代码时通常无须担心,但应该避免运行以预编译形式给出的非受信代码。这种需求,函数load正好有一个选项可以适用。
除了必需的第1个参数外,函数load还有3个可选参数。第2个参数是程序段的名称,只在错误信息中被用到。第4个参数是环境。第3个参数正是我们这里关心的,它控制了允许加载的代码段的类型。如果该参数存在,则只能是如下的字符串:字符串”t”允许加载文本类型的代码段,字符串”b”只允许加载二进制类型的代码段,字符串”bt”允许同时加载上述两种类型的代码段。
错误
人人皆难免犯错误。因此,我们必须尽可能地处理错误。由于lua语言是一种经常被嵌入在应用程序中的扩展语言,所以当错误发生时并不能简单地奔溃或退出。相反,只要错误发生,Lua语言就必须提供处理错误的方式。
Lua语言会在遇到非预期的情况时引发错误。例如,当试图将两个非常值类型的值相加,对不是函数的值进行调用,对不是表类型的值进行索引等。我们也可以显示地通过调用函数error并传入一个错误信息来作为参数引发一个错误。通常,这个函数就是在代码中提示出错的合理方式:
1 | pirnt "enter a number :" |
由于“针对某些情况调用函数error”这样的代码结构太常见了,所以Lua语言提供了一个内建的函数assert来完成这类工作:
1 | print "enter a nubmer:" |
函数assert检查其第1个参数是否为真,如果该参数为真则返回该参数;如果该参数为假,则引发一个错误。该函数的第2个参数是一个可选的错误信息。不过,要注意函数assert只是一个普通函数,所以Lua语言会总是在调用该函数前先对参数进行求值。如果编写形如
1 | n = io.read() |
的代码,那么即使n是一个数值类型,Lua语言也总是会进行字符串连接。在这种情况下使用显示的测试可能更加明智。
当一个函数发现某种意外的情况发生时,在进行一场处理时可以采取两种基本方式:一种是返回错误代码(通常是nil或者false),另一种是通过调用函数error引发了一个错误。如何在这两种方式之间进行选择并没有固定的规则,但笔者通常遵循如下的知道原则:容易避免的异常应该引发错误,否则应该返回错误码。
以函数math.sin为例,当调用参数传入了一个表该如何反应呢?如果要检查错误,那么就不得不编写如下代码:
1 | local res = math.sin(x) |
当然,也可以在调用函数前轻松地检查出这种异常:
1 | if not tonumber(x) then -- x是否为数字 |
通常,我们既不会检查参数也不会检查函数sin的返回值;如果sin的参数不是一个数值,那么就意味着我们的程序可能出现了问题。此时,处理异常最简单也是最实用的做法就是停止运行,然后输出一条错误信息。
另一方面,让我们再考虑一下用于打开文件的函数io.open。如果要打开的文件不存在,那么该函数应该有怎么样的行为呢?在这种情况下,没有什么简单的方法可以在调用函数前检测到这种异常。在很多系统中,判断一个文件是否存在的唯一方法就是试着去打开这个文件。因此,如果由于外部原因导致函数io.open无法打开一个文件,那么它应返回false及一条错误信息。通过这种方式,我们就有机会采取恰当的方式来处理这个异常情况,例如要求用户提供另外一个文件名:
1 | local file, msg |
如果不想处理这些情况,但又想安全地运行程序,那么只需要用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 | local ok , msg = pcall (function() |
函数pcall会以一种保护模式来调用它的第1个参数,以便捕获该函数执行中的错误。无论是否有错误发生,函数pcall都不会引发错误。如果没有错误发生,那么pcall返回true及调用函数的所有返回值;否则,则返回false及错误信息。
使用”错误信息”的命名方式可能会让人误解错误信息必须是一个字符串,因此称之为错误对象可能更好,这主要是因为函数pacll能够返回传递给error的任意语言类型的值。
1 | local status , err = pcall(function() error({coude = 121}) end) |
这些机制为我们提供了在Lua语言中进行异常处理的全部。我们可以通过error来抛出异常,然后用函数pcall来捕获异常,而错误信息则用来表示错误的类型。
错误信息和栈回调
虽然能够使用任何类型的值作为错误对象,但错误对象通常是一个描述出错内容的字符串。当遇到内部错误出现时,Lua语言负责产生错误对象,如果错误对象是一个字符串,那么Lua语言会尝试把一些有关错误发生位置的信息附上:
1 | local status , err = pcall(function() error("my error") end) |
位置信息中给出了出错代码段的名称和行号。
函数error还有第2个可选参数level,用于指出向函数调用层次中的哪层函数报告错误,以说明谁应该为错误负责。例如,假设编写一个用来检查其自身是否被正确调用了的函数:
1 | function foo(str) |
如果调用时被传递了错误的参数:
1 | foo({x = 1}) |
由于函数foo调用的error,所以Lua语言会认为是函数foo发生了错误。然而,真正的肇事者其实是函数foo的调用者。为了纠正这个问题,我们需要告诉error函数错误实际发生在函数调用蹭的第2层中:
1 | function foo (str) |
通常,除了发生错误的位置以外,我们还希望在错误发生时得到更多的调试信息。至少,我们希望得到具有发生错误时完整函数调用栈回溯。当函数pcall返回错误信息时,部分的调用栈已经被破坏了。因此,如果希望得到一个有意义的栈回溯,那么就必须在函数pcall返回前先将调用栈构造好。为了完成这个需求,Lua语言提供了函数xpcall。该函数与函数pcall蕾西,但它的第2个参数是一个消息处理函数。当发生凑无时,Lua会调用栈展开前调用这个消息处理函数,以便消息处理函数能够使用调试库来获取有关错误的更逗信息。两个常用的消息处理函数是debug.debug和debug.traceback,前者为用户提供一个Lua提示符来让用户检查错误发生的原因;后者则使用调用栈来构造详细的错误,Lua语言的独立解释器就是使用这个函数来构造错误信息的。