0%

《lua 程序设计》读书笔记(8):编译、执行和错误 & 模块和包

虽然 Lua 被称为解释型语言,但是 Lua 总是在运行代码前先预先编译源码为中间代码。解释型语言的区分并不在于源码是否编译,而在于是否有能力执行动态生成的代码。正是由于dofile 这样的函数存在,才使得 Lua 能够被称为解释型语言。

这篇文章将介绍 Lua 的编译执行原理以及 Lua 的模块/包管理机制。

编译、执行和错误

编译

函数 dofile 是运行 Lua 代码段的主要方式之一。实际上 dofile 是一个辅助函数,loadfile 才完成了真正的核心工作。loadfile 是从文件中加载 Lua 代码段,但是它不会运行代码,而是编译代码,然后将编译后的代码段作为一个函数返回

与函数 dofile 不同,函数 loadfile 只返回错误码而不抛出异常,可以认为 dofile 就是:

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

当发生错误时,函数 loadfile 会返回 nil 及错误信息,以允许我们按照自定义的方式来处理错误。而且如果需要多次运行同一个文件,只需要调用一次 loadfile 函数后再多次调用它的返回结果即可,此时只会编译一次文件。

loadloadfile 类似,但是该函数是从一个字符串或函数中读取代码段,而不是从文件中读取。如下代码中,f 就是一个被调用时执行 i = i + 1 的函数:

1
2
3
4
5
6
7
8
> f = load("i = i + 1")
> i = 0
> f()
> i
1
> f()
> i
2

尽管 load 功能强大,但还是应该谨慎使用,该函数开销比较大并且可能会引起诡异问题。所以需要确定当下已经找不到更简单的解决方式后再使用该函数。

需要注意,函数 load 在编译时不涉及词法定界,所以下面的代码并不完全等价:

1
2
3
4
5
6
7
i = 32
local i = 0

f = load("i = i + 1; print(i)")
g = function() i = i + 1; print(i) end
f()
g()

这里函数 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
2
> print(load("i i"))
nil [string "i i"]:1: syntax error near 'i'

而且这些函数没有任何副作用,它们既不改变或创建变量,也不向文件写入等。这些函数只是将程序段编译为一种中间形式,然后将结果作为匿名函数返回。需要注意,在 Lua 中函数定义是在运行时而不是在编译时发生的一种赋值操作

1
2
3
4
--foo.lua
function foo(x)
print(x)
end
1
2
3
4
f = loadfile("foo.lua")
print(foo)
f()
print(foo)

如果将 foo.lua 改写成以下形式,则更加清晰:

1
2
3
foo = function(x)
print(x)
end

如果线上产品级别的程序需要执行外部代码,那么应该处理加载程序段时报告的所有错误。而且,还应该在一个受保护的环境中执行这些代码。

预编译的代码

Lua 会在运行源代码之前先对其进行预编译,Lua 也允许我们以预编译的形式分发代码。生成预编译文件最简单的方式是使用标准发行版中附带的 luac 程序。

1
2
--foo.lua
print("test")

如下生成创建文件 foo.lua 的预编译版本 foo.lc,Lua 解释器会像执行普通 Lua 代码一样执行这个新文件:

1
2
3
# luac -o foo.lc foo.lua
# lua foo.lc
test

几乎在 Lua 中所有能够使用源代码的地方都可以使用预编译的代码,函数 loadfileload 都可以接受预编译的代码。如下在 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 函数,返回值则是该函数所对应的预编译代码(字符串形式,已经被正确格式化,可由 Lua 直接加载)。

luac 的 -l 选项可以为指定代码段生成操作码(opcode):

1
2
3
4
5
6
7
8
9
# luac -l foo.lua

main <foo.lua:0,0> (5 instructions at 0x55719fabdcc0)
0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions
1 [1] VARARGPREP 0
2 [1] GETTABUP 0 0 0 ; _ENV "print"
3 [1] LOADK 1 1 ; "test"
4 [1] CALL 0 2 1 ; 1 in 0 out
5 [1] RETURN 0 1 1 ; 0 out

预编译形式的代码不一定比源代码更小,但是却加载更快。预编译形式的代码另一个好处是,可以避免由于意外而修改源码。但是应该避免运行以预编译形式给出的非受信任代码。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
2
3
4
5
6
7
> status, err = pcall(function() error({code=121}) end)
> err
table: 0x55a1337505c0
> err.code
121
> status
false

总结一下在 Lua 中进行异常处理:

  • 可以通过 error 来抛出异常
  • 可以通过 pcall 来捕获异常,而错误信息则用来标识错误的类型

错误信息和栈回溯

虽然能够使用任何类型的值作为错误对象,但是错误对象通常是一个描述出错内容的字符串。当遇到内部错误时,Lua 会负责产生错误对象(此时错误对象永远都是字符串,而其他情况下则是传递给函数 error 的值)。如果错误对象是一个字符串,那么 Lua 还会尝试把一些有关错误发生位置的信息附上。

函数 error 还有第 2 个可选参数 level,用于指出向函数调用层次中的哪层函数报告错误,以说明谁应该为错误负责。

1
2
3
4
5
6
7
8
9
10
11
12
> function foo(str)
>> if type(str) ~= "string" then
>> error("string expected", 2)
>> end
>> end
> foo({x=1})
string expected
stack traceback:
[C]: in function 'error'
stdin:3: in function 'foo'
(...tail calls...)
[C]: in ?

为了在错误发生时能够得到完整函数调用栈的栈回溯,Lua 提供了函数 xpcall,该函数与 pcall 类似,但是它的第 2 个参数是消息处理函数,当发生错误时,Lua 会在栈展开之前调用这个消息处理函数。这样消息处理函数能够使用调试库来获取有关错误的更多信息。两个常用的消息处理:

  • debug.debug:为用户提供一个 Lua 提示符来让用户检查发生错误的原因
  • debug.traceback:使用调用栈来构造详细的错误信息
1
xpcall(function() error("test xpcall") end, debug.debug())
1
2
# lua xpcall.lua
lua_debug>

模块和包

Lua 从 5.1 版本开始为模块和包(package,模块的集合)定义了一系列的规则。从用户观点来看,一个模块就是一些代码(要么是 Lua 编写,要么是 C 编写),这些代码可以通过函数 require 加载,然后创建和返回一个表。这个表就像是某种命名空间,其中定义的内容是模块中导出的东西,比如函数和常量

所有的都标准都是模块:

1
2
3
> m = require "math"
> m.pi
3.1415926535898

Lua 的独立解释器会使用和如下代码等价的方式提前加载所有标准库,这种提前加载可以让我们不用加载模块 math 就可以直接使用其中导出的东西。

1
2
math = require "math"
string = require "string"

使用表来实现模块的显著优点之一是:让我们可以像操作普通表那样操作模块,并且能够利用 Lua 的所有功能实现额外的功能。

用户调用模块中的函数有几种方法:

1
2
> local mod = require "mod"
> mod.foo()

可以为模块设置一个局部名称:

1
2
> local m = require "mod"
> m.foo()

也可以为个别函数提供不同的名称:

1
2
3
> local m = require "mod"
> local f = m.foo
> f()

还可以只引入特定函数:

1
2
> local f = require "mod".foo
> f()

函数 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
2
3
4
5
6
7
8
9
10
11
12
13
> for k, _ in pairs(package.loaded) do
>> print(k)
>> end
math
_G
package
debug
coroutine
table
io
utf8
os
string

如果希望给待加载的模块传递参数,最好使用一个显式地函数来设置参数。函数 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
2
3
4
> print(package.path)
/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua
> print(package.cpath)
/usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.so

函数 package.searchpath 实现了搜索库的所有规则,该函数的参数包括文件名和路径,然后遵循上述规则来搜索文件。

实际的实现中,require 比上述描述复杂一些,搜索 Lua 文件和搜索 C 标准库的方式只是更加通用的搜索器(searcher)的两个实例。一个搜索器是一个以模块名为参数,以对应模块的加载器或nil(如果找不到加载器)为返回值的简单函数

数组 package.searchers 列出了 require 使用的所有搜索器。寻找模块时,就会调用列表中的每个搜索器直到找到了指定模块的加载器。如果所有搜索器被调用完还找不到,require 就抛出一个异常。Lua 文件和 C 标准库的搜索器排在列表的第二、三位,在它们之前是预加载搜索器。预加载搜索器使得我们能够为要加载的模块定义任意的加载函数,它使用一个名为 package.preload 的表来映射模块名称和加载函数。当搜索到指定的模块名时,则在该表中进行查找,如果找到对于的函数,那么该函数将作为 loader 返回,否则返回 nil。预加载搜索器为处理非标场景提供了一种通用方式

package.searchers 的第 4 个函数只与子模块有关,下文会详细介绍。

1
2
3
4
5
6
7
8
> for k, v in pairs(package.searchers)
>> do
>> print(k, v)
>> end
1 function: 0x56012c7a2930
2 function: 0x56012c7a2970
3 function: 0x56012c7a29b0
4 function: 0x56012c7a29f0

Lua 中编写模块的方法

Lua 中创建模块最简单的方法是:创建一个表,并将所有需要导出的函数放入其中,最后返回这个表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local M = {}

local function new(r, i)
return {r = r, i = i}
end

M.new = new

M.i = new(0, 1)

function M.add(c1, c2)
return new(c1.r + c2.r, c1.i + c2.i)
end

function M.sub(c1, c2)
return new(c1.r - c2.r, c1.i - c2.i)
end

return M

如果不喜欢最后的返回语句,一种将其省略的方式就是直接把模块对应的表放到 package.loaded 中。

1
2
local M = {}
package.loaded[...] = M

由于函数 require 会把模块名称作为第一个参数传给加载函数,因此表索引中可变参数表达式 ... 其实就是模块名。经过这条语句后就不再需要在模块的最后返回 M 了:因为一个模块没有返回值,那么函数 require 就会返回 package.loaded[modname] 的当前值。

另外一种编写模块的方法是把所有的函数定义为局部变量,然后在最后构造返回的表。

1
2
3
4
5
6
7
8
9
10
11
12
local function new(r, i) return {r = r,  i = i} end

local i = complex.new(0, 1)

......

return {
new = new,
i = i,
add = add,
sub = sub,
}

这种方式的优点是无需在每个标识符前增加前缀 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 本身的角度来看,同一个包中的子模块没有显式的关联,加载一个模块并不会自动加载它的任何子模块。同样,加载子模块也不会自动地加载其父模块。如果包的实现者愿意,也可以自行创造关联。