虽然 Lua 被称为解释型语言,但是 Lua 总是在运行代码前先预先编译源码为中间代码。解释型语言的区分并不在于源码是否编译,而在于是否有能力执行动态生成的代码。正是由于dofile 这样的函数存在,才使得 Lua 能够被称为解释型语言。
这篇文章将介绍 Lua 的编译执行原理以及 Lua 的模块/包管理机制。
编译、执行和错误
编译
函数 dofile
是运行 Lua 代码段的主要方式之一。实际上 dofile
是一个辅助函数,loadfile
才完成了真正的核心工作。loadfile
是从文件中加载 Lua 代码段,但是它不会运行代码,而是编译代码,然后将编译后的代码段作为一个函数返回。
与函数 dofile
不同,函数 loadfile
只返回错误码而不抛出异常,可以认为 dofile 就是:
1 | function dofile(filename) |
当发生错误时,函数 loadfile
会返回 nil 及错误信息,以允许我们按照自定义的方式来处理错误。而且如果需要多次运行同一个文件,只需要调用一次 loadfile 函数后再多次调用它的返回结果即可,此时只会编译一次文件。
load
与 loadfile
类似,但是该函数是从一个字符串或函数中读取代码段,而不是从文件中读取。如下代码中,f
就是一个被调用时执行 i = i + 1
的函数:
1 | > f = load("i = i + 1") |
尽管 load
功能强大,但还是应该谨慎使用,该函数开销比较大并且可能会引起诡异问题。所以需要确定当下已经找不到更简单的解决方式后再使用该函数。
需要注意,函数 load 在编译时不涉及词法定界,所以下面的代码并不完全等价:
1 | i = 32 |
这里函数 g()
操作的是局部变量,而函数 f()
操作的是全局变量,这是因为函数 load 总是在全局环境中编译代码段。而且 g()
会随着其所在的代码文件一起编译,而 f()
则在调用函数 load 时会进行一次独立的编译。
函数 load
最典型的用法是执行外部代码(即那些来自程序本身之外的代码段)或者动态生成的代码。由于函数 load
所返回的函数就是一个普通的函数,因此可以对其反复调用。
也可以将读取函数(reader function)作为函数 load 的第一个参数。读取函数可以分几次返回一段程序,函数 load 会不断地调用读取函数直到读取函数返回 nil。
Lua 将所有独立的代码段当做匿名可变长参数函数的函数体。例如 load("a = 1")
的返回值与以下表达式等价。像其他任何函数一样,代码段中可以声明局部变量。
1 | function(...) a = 1 end |
函数 load 和 loadfile 从来不引发错误,当有错误发生时,它们会返回 nil 以及错误信息。
1 | > print(load("i i")) |
而且这些函数没有任何副作用,它们既不改变或创建变量,也不向文件写入等。这些函数只是将程序段编译为一种中间形式,然后将结果作为匿名函数返回。需要注意,在 Lua 中函数定义是在运行时而不是在编译时发生的一种赋值操作。
1 | --foo.lua |
1 | f = loadfile("foo.lua") |
如果将 foo.lua
改写成以下形式,则更加清晰:
1 | foo = function(x) |
如果线上产品级别的程序需要执行外部代码,那么应该处理加载程序段时报告的所有错误。而且,还应该在一个受保护的环境中执行这些代码。
预编译的代码
Lua 会在运行源代码之前先对其进行预编译,Lua 也允许我们以预编译的形式分发代码。生成预编译文件最简单的方式是使用标准发行版中附带的 luac 程序。
1 | --foo.lua |
如下生成创建文件 foo.lua
的预编译版本 foo.lc
,Lua 解释器会像执行普通 Lua 代码一样执行这个新文件:
1 | # luac -o foo.lc foo.lua |
几乎在 Lua 中所有能够使用源代码的地方都可以使用预编译的代码,函数 loadfile
和 load
都可以接受预编译的代码。如下在 Lua 中实现一个最简单的 luac:
1 | p = loadfile(arg[1]) |
这里利用 string.dump()
,它的入参是一个 Lua 函数,返回值则是该函数所对应的预编译代码(字符串形式,已经被正确格式化,可由 Lua 直接加载)。
luac 的 -l
选项可以为指定代码段生成操作码(opcode):
1 | # luac -l foo.lua |
预编译形式的代码不一定比源代码更小,但是却加载更快。预编译形式的代码另一个好处是,可以避免由于意外而修改源码。但是应该避免运行以预编译形式给出的非受信任代码。load()
函数第三个参数允许我们控制加载的代码段的类型:
t
允许加载文本类型的代码b
允许加载二进制(预编译)类型的代码段bt
允许同时加载上述两种类型的代码段
错误
由于 Lua 是一种经常被嵌入在应用程序中的扩展语言,所以当错误发生时并不能简单地崩溃或退出。
在 Lua 中可以显式通过调用函数 error
并传入一个错误信息来引发一个错误。由于针对某些情况调用函数 error
这样的代码结构太常见了,所以 Lua 提供了一个内建的 assert
来完成这类工作。assert
检查其第一个参数是否为真,如果该参数为真则返回该参数;否则引发一个错误,该函数的第二个参数是一个可选的错误信息。
当一个函数发现某种意外的情况发生时,在进行异常处理时可以采用两种基本方式:
- 返回错误代码(通常是nil 或者 false)
- 通过调用
error
引发一个错误
如何在这两种方式之间进行选择没有固定的规则,一条参考原则是:容易避免的异常应该引发错误,否则应该返回错误码。
错误处理和异常
通常应用代码本身会负责处理错误,但是如果我们想在这些应用代码的外层处理错误,那么就应该使用函数 pcall(protected call)
来封装代码。假设要执行一段 Lua 代码并捕获执行中发生的所有错误,那么首先需要将这段代码封装到一个函数中,这个函数通常是一个匿名函数。之后通过 pcall
来调用这个函数。
pcall
会以保护模式来调用它的第一个参数,以便捕获该函数执行中的错误。无论是否有错误发生,函数 pcall
都不会引发错误:
- 如果没有错误发生:pcall 返回 true 以及被调用函数的所有返回值
- 否则返回 false 以及错误信息
pcall 能够返回传递给 error 的任意 Lua 类型的值。
1 | > status, err = pcall(function() error({code=121}) end) |
总结一下在 Lua 中进行异常处理:
- 可以通过
error
来抛出异常 - 可以通过
pcall
来捕获异常,而错误信息则用来标识错误的类型
错误信息和栈回溯
虽然能够使用任何类型的值作为错误对象,但是错误对象通常是一个描述出错内容的字符串。当遇到内部错误时,Lua 会负责产生错误对象(此时错误对象永远都是字符串,而其他情况下则是传递给函数 error 的值)。如果错误对象是一个字符串,那么 Lua 还会尝试把一些有关错误发生位置的信息附上。
函数 error 还有第 2 个可选参数 level,用于指出向函数调用层次中的哪层函数报告错误,以说明谁应该为错误负责。
1 | > function foo(str) |
为了在错误发生时能够得到完整函数调用栈的栈回溯,Lua 提供了函数 xpcall
,该函数与 pcall
类似,但是它的第 2 个参数是消息处理函数,当发生错误时,Lua 会在栈展开之前调用这个消息处理函数。这样消息处理函数能够使用调试库来获取有关错误的更多信息。两个常用的消息处理:
debug.debug
:为用户提供一个 Lua 提示符来让用户检查发生错误的原因debug.traceback
:使用调用栈来构造详细的错误信息
1 | xpcall(function() error("test xpcall") end, debug.debug()) |
1 | # lua xpcall.lua |
模块和包
Lua 从 5.1 版本开始为模块和包(package,模块的集合)定义了一系列的规则。从用户观点来看,一个模块就是一些代码(要么是 Lua 编写,要么是 C 编写),这些代码可以通过函数 require
加载,然后创建和返回一个表。这个表就像是某种命名空间,其中定义的内容是模块中导出的东西,比如函数和常量。
所有的都标准都是模块:
1 | > m = require "math" |
Lua 的独立解释器会使用和如下代码等价的方式提前加载所有标准库,这种提前加载可以让我们不用加载模块 math 就可以直接使用其中导出的东西。
1 | math = require "math" |
使用表来实现模块的显著优点之一是:让我们可以像操作普通表那样操作模块,并且能够利用 Lua 的所有功能实现额外的功能。
用户调用模块中的函数有几种方法:
1 | > local mod = require "mod" |
可以为模块设置一个局部名称:
1 | > local m = require "mod" |
也可以为个别函数提供不同的名称:
1 | > local m = require "mod" |
还可以只引入特定函数:
1 | > local f = require "mod".foo |
函数 require
函数 require
虽然是一个普通的函数,但是在 Lua 的模块实现中扮演核心角色。要加载模块时只需要简单地调用该函数,然后传入模块名作为参数。由于当函数的参数只有一个字符串常量时括号是可以省略的,而且一般在 require 时按照惯例也会省略括号。
函数 require 尝试对模块的定义做最小假设。对于该函数来说,模块可以是定义了一些变量的代码。典型地,这些代码返回一个由模块中函数组成的表。
- 首先函数 require 在表
package.loaded
中检查模块是否已经加载,如果模块已经加载,函数 require 就返回相应的值。因此一旦一个模块被加载过,后续对同一模块的所有 require 调用都将返回同一个值,而不会再运行任何代码。 - 如果模块尚未加载,require 则搜索具有指定模块名的 Lua 文件,搜索路径由变量
package.path
指定。如果找到了相应的文件,就用函数loadfile
将其进行加载,结果就是返回一个称为 loader 的函数(加载函数,当调用该函数时,模块被加载) - 如果 require 找不到指定模块名对应的 Lua 文件,则搜索相应名称的 C 标准库,此时搜索路径由变量
package.cpath
指定。如果找到了一个 C 标准库,则由package.loadlib
进行加载。该底层函数会查找名为luaopen_modname
的函数,此时 loader 就是 loadlib 的执行结果,即一个表示为 Lua 函数的 C 函数luaopen_modname
不管模块是在 Lua 文件还是 C 标准库中找到的,require
都具有了用于加载它的 loader
。为了最终加载模块,函数 require 会带着两个参数调用 loader
:模块名和 loader 所在文件的名称。如果加载函数有返回值,那么函数 require 会返回这个值,然后将其保存在表 package.loaded 中,这样将来加载同一模块时会返回相同的值。如果加载函数没有返回值,且表中 package.loaded[@rep{modname}]
为空,那么函数 require
就假设模块的返回值是 true。如果没有这种补偿,那么后续调用 require
将会重复加载模块。
所以要强制函数 require 加载同一模块两次,可以先将模块从 package.loaded
中删除。下一次再加载模块时,就会重新加载该模块:
1 | > for k, _ in pairs(package.loaded) do |
如果希望给待加载的模块传递参数,最好使用一个显式地函数来设置参数。函数 require
不支持给待加载的模块传递参数。
通常我们使用模块本身的名称来使用它们,但有时也需要将一个模块改名以避免命名冲突。对于一个 Lua 语言模块来说,其内部名称并不是固定的,因此通常修改 .lua
文件的文件名即可,但是对于 C 标准库的二进制目标代码中的 luaopen_*
函数的名称我们是无法修改的。因此为了支持重命名,require 使用了一个技巧:
- 如果一个模块名中包含连字符,那么
require
就会用连字符之前的内容创建luaopen_*
函数的名称
在搜索 Lua 文件时,require 使用的路径与典型的路径略有不同。典型的路径是很多目录组成的列表,并在其中搜索指定的文件。而 require
使用的路径是一组模版,其中每项都指定了将模块名转换为文件名的方式。更准确说,这种路径中的每一个模版都是一个包含可选问号的文件名。对于每个模块,require
会用模块名来替换每个问号,然后检查结果是否存在对应的文件。如果不存在,则尝试下一个模版。模版之间以 ;
隔开。函数 require
只处理分号(作为分隔符)和问号,所有其他部分(包括目录分隔符和文件扩展名)则由路径自定义。
搜索 Lua 文件的路径是变量 package.path
的当前值,当 package 模块被初始化后,它会依次根据环境变量 LUA_PATH_5_3(与 Lua 版本相关)、LUA_PATH 设置该变量的值。如果环境变量都没有被定义,那么 Lua 则使用一个编译时定义的默认路径。另外环境变量中的 ;;
将替换为默认路径。
搜索 C 标准库的路径的逻辑与此相同,但是 C 标准库的路径来自变量 package.cpath
而不是 package.path
。类似的,该变量的初始值也可以通过环境变量 LUA_CPATH_5_3 或 LUA_CPATH 设置。
1 | > print(package.path) |
函数 package.searchpath
实现了搜索库的所有规则,该函数的参数包括文件名和路径,然后遵循上述规则来搜索文件。
实际的实现中,require 比上述描述复杂一些,搜索 Lua 文件和搜索 C 标准库的方式只是更加通用的搜索器(searcher)的两个实例。一个搜索器是一个以模块名为参数,以对应模块的加载器或nil(如果找不到加载器)为返回值的简单函数。
数组 package.searchers
列出了 require
使用的所有搜索器。寻找模块时,就会调用列表中的每个搜索器直到找到了指定模块的加载器。如果所有搜索器被调用完还找不到,require
就抛出一个异常。Lua 文件和 C 标准库的搜索器排在列表的第二、三位,在它们之前是预加载搜索器。预加载搜索器使得我们能够为要加载的模块定义任意的加载函数,它使用一个名为 package.preload
的表来映射模块名称和加载函数。当搜索到指定的模块名时,则在该表中进行查找,如果找到对于的函数,那么该函数将作为 loader 返回,否则返回 nil。预加载搜索器为处理非标场景提供了一种通用方式。
package.searchers
的第 4 个函数只与子模块有关,下文会详细介绍。
1 | > for k, v in pairs(package.searchers) |
Lua 中编写模块的方法
Lua 中创建模块最简单的方法是:创建一个表,并将所有需要导出的函数放入其中,最后返回这个表。
1 | local M = {} |
如果不喜欢最后的返回语句,一种将其省略的方式就是直接把模块对应的表放到 package.loaded
中。
1 | local M = {} |
由于函数 require
会把模块名称作为第一个参数传给加载函数,因此表索引中可变参数表达式 ...
其实就是模块名。经过这条语句后就不再需要在模块的最后返回 M 了:因为一个模块没有返回值,那么函数 require 就会返回 package.loaded[modname]
的当前值。
另外一种编写模块的方法是把所有的函数定义为局部变量,然后在最后构造返回的表。
1 | local function new(r, i) return {r = r, i = i} end |
这种方式的优点是无需在每个标识符前增加前缀 M.
或类似东西。这种方式的缺点在于导出表位于模块的最后而不是最前面,而且必须把每个名字写两遍(但这也允许函数在模块内和模块外具有不同的名称)。
子模块和包
Lua 支持具有层次结构的模块名,通过 .
来分隔名称中的层次。例如一个名为 mod.sub
的模块是模块 mod 的一个子模块。一个包(package)是一颗由模块组成的完整的树。它是 Lua 中用于发行程序的单位。
- 当加载模块
mod.sub
时,函数require
就是使用mod.sub
作为键来查询表package.loaded
和表package.preload
。此时模块名中的.
没有特殊含义 - 但是当搜索一个定义子模块的文件时,
require
就会将.
转换为另一个字符,通常就是操作系统的目录分隔符,转换之后,require
就会像搜索其他名称一样搜索这个名称。这种行为使得一个包中的所有模块能够放到一个目录中。Lua 中使用的目录分隔符是在编译时配置的,可以是任意的字符串(Lua 并不知道目录的存在)。
C 语言中的名称不能包含 .
,因此一个用 C 编写的子模块 a.b
无法导出函数 luaopen_a.b
,这时函数 require
会将 .
转换为下划线。因此一个名为 a.b
的 C 标准库应将其加载函数命名为 luaopen_a_b
。
作为一种额外的机制,函数 require
在加载 C 编写的子模块时还有另外一个搜索器,该搜索器在 C 文件所在的路径中搜索包的名称。例如程序要加载子模块 a.b.c
,搜索器会搜索文件 a。如果找到了 C 标准库 a,那么 require 就会在该库中搜索对应的加载函数 luaopen_a_b_c
。这种机制允许一个发布包将几个子模块组织为一个 C 标准库,每个子模块由各自的加载函数。
从 Lua 本身的角度来看,同一个包中的子模块没有显式的关联,加载一个模块并不会自动加载它的任何子模块。同样,加载子模块也不会自动地加载其父模块。如果包的实现者愿意,也可以自行创造关联。