Lua中模块和包

通常,Lua语言不会设置规则。相反,Lua语言提供的是足够强大的机制供不同的开发者实现最适合自己的规则。然而,这种方法对于模块而言并不是特别适用。模块系统的主要目标之一就是允许不同的人共享代码,缺乏公共规则就无法实现这样的分享。

Lua语言从5.1版本开始为模块和包定义了一系列的规则。这些规则不需要从语言中引入额外的功能,程序猿可以使用目前为止我们学习到的机制实现这些规则。程序员也可以自由地使用不同的策略。当然,不同的实现可能会导致程序不能使用外部模块,或者模块不能被外部程序使用。
从用户观点来看,一个模块就是一些代码,这些代码可以通过函数require加载,然后创建和返回一个表。这个表就像是某种命名空间,其中定义的内容是模块中导出的东西,比如函数和常量。
例如,所有的标准库都是模块。我们可以按照如下的方式使用数学库:

1
2
local m = require "math"
print(m.sin(3.14)) -- 0.0015926529164868

独立解释器会使用跟如下代码等价的方式提前加载所有标准库:
1
2
math = require "math"
string = require "string"

这种提前加载使得我们可以不用费劲地编写代码来加载模块math就可以直接使用函数math.sin。
使用表来实现模块的显著优点之一是,让我们可以像操作普通表那样操作模块,并且能利用Lua语言的所有功能实现额外的功能。在大多数语言中,模块不是第一类值(即它们不能被保存在变量中,也不能被当作参数传递给函数等),所以那些语言需要为模块实现一套专门的机制。而在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 			-- (require("mod")).foo
f()

上述这些方法的好处是无须语言的特别支持,它们使用的都是语言已经提供的功能。

函数 require

尽管函数require也只是一个没什么特殊之处的普通函数,但在Lua语言的模块实现中扮演者核心角色。要加载模块时,只需要简单地调用这个函数,然后传入模块作为参数。请记住,当函数的参数只有一个字符串常量时括号是可以省略的,而且一般在使用require时按照惯例也会省括号。不过尽管如此,下面这些用法也是正确的:

1
2
3
local m = require('math')
local modname = 'math'
local m = require(modname)

函数require尝试对模块的定义做最小的假设。对于函数来说,一个模块可以是定义了一些变量的代码。典型地,这些代码返回一个由模块中函数组成的表。不过,由于这个动作是由模块代码而不是由函数require完成的,所以某些模块可能会选择返回其他的值或者甚至引发副作用。
首先,函数require在表package.loaded中检查模块是否已被加载。如果模块已经被加载,函数require就返回相应的值。因此,一旦一个模块被加载过,后续的对于同一模块的所有require调用都将返回同一个值,而不会再运行任何代码。
如果模块尚未加载,那么函数require则搜索具有指定模块名的Lua文件(搜索路径有变量package.path指定)。如果函数require找到了相应的文件,那么就用函数loadfile将其进行加载,结果是一个我们称之为加载器的函数。
如果函数require找不到指定模块名的Lua文件,那么它就搜索相应名称的C标准库。如果找到了一个C标准库,则使用底层函数package.loadlib进行加载,这个底层函数会查找名为luaopen_modname的函数。在这种情况下,加载函数就是loadlib的执行结果,也就是一个被表示为Lua函数的C语言函数luaopen_modname。
不管模块是Lua文件还是C标准库中找到的,函数require此时都具有了用于加载它的加载函数。为了最终加载模块,函数require带着两个参数调用加载函数:模块名和加载函数所在文件名称。如果加载函数有返回值,那么函数require会返回这个值,然后将其保存在表package.loaded中,以便于将来在加载同一个模块时返回相同的值。如果加载函数么有返回值且表中的package.loaded【@rep{modname}]为空,函数require就假设模块的返回值是true。如果没有这种补偿,那么后续调用函数require时将会重复加载模块。
要强制函数require加载同一模块两次,可以先将模块从package.loaded中删除:
1
package.loaded.modname = nil

下一次在加载这个模块时,函数require就会重新加载模块。
对于函数require来说,一个常见的抱怨是它不能给待加载的模块传递参数。例如,数学模块可以对角度和弧度的选择增加一个选项:
1
2
-- 错误的代码
local math = require("math","degree")

这里的问题在于,函数require的主要目的之一就是避免重复加载模块,一旦一个模块被加载,该模块就会在后续所有调用require的程序部分被复用。这样,不同参数的同名模块之间就会产生冲突。
1
2
local mod = require "mod"
mod.init(0,0)

如果加载函数返回的是模块本身,那么还可以写成:
1
local mod = require "mod".init(0,0)

请记住,模块在任何情况下只加载一次;至于如何处理冲突的加载,取决于模块自己。

模块重命名

通常,我们通过模块本来的名称来使用它们,但有时,我们也需要将一个模块改名以避免命名冲突。一点典型的情况就是,处于测试的目的而需要加载同一模块的不同版本。对于一个Lua语言模块来说,其内部的名称并不要求是固定的,因此通常修改.lua文件的文件名就够了。不过,我们却无法修改C标准库的二进制目标代码中luaopen_*函数的名称。为了进行这种重命名,函数require运用了一个连字符的技巧:如果一个模块名中包含连字符,那么函数require就会用连字符之前的内容创建luaopen_*函数的名称。例如,如果一个模块的名称为mod-v3.4,那么函数require会认为该模块的加载函数应该是luaopen_mod而不是luaopen_mod-v3.4(这也不是有效的C语言函数名)。因此,如果需要使用两个名称均为mod的模块(或相同模块的两个不同版本),那么可以对其中的一个进行重命名,如mod-v1.当调用m1=require “mod-v1”时,函数require会找到改名后的文件mod-v1并将其中原名为luaopen_mod的函数作为加载函数。

搜索路径

在搜索一个Lua文件时,函数require使用的路径与典型的路径略有不同。典型的路径是很多目录组成的列表,并在其中搜索指定的文件。不过,IOS C(Lua语言依赖的抽象平台)并没有目录的概念。所以,函数require使用的路径是一组模块,其中的每项都指定了蒋模块名(函数require的参数)转换为文件名的方式。更准确地说,这种路径中的每一个模块都是一个包含可选问号的文件名。对于每个模板,函数require会用模块名来替换每一个问号,然后检查结果是否存在对应的文件;如果不存在,则尝试下一个模板。路径中模板以在大所述操作系统中很少被用于文件名的分号隔开。例如,考虑如下路径:

1
?;?.lua;c:\windows\?;/usr/local/lua/?/?.lua

在使用这个路径时,调用require “sql”将尝试打开如下的Lua文件:
1
2
3
4
sql
sql.lua
c:\windows\sql
/usr/local/lua/sql/sql.lua

函数require只处理分号(作为分隔号)和问号,所有其他的部分(包括目录分隔符合文件扩展名)则由路径自有定义。
函数require用于搜索Lua文件的路径是变量package.path的当前值。当package模块被初始化后,它就把变量package.path设置成环境变量LUA_PATH_5_3的值。如果这个环境变量没有被定义,那么Lua语言则尝试另一个环境变量LUA_PATH。如果这两个环境变量都没有被定义,那么Lua语言则使用一个编译是定义的默认路径。在使用一个环境变量的值时,Lua语言会将其中所有的”;;”替换成默认路径。例如,如果LUA_PATH_5_3设为”mydir/r.lua;;”,那么最终路径就会是模板”mydir/?.lua”后跟默认路径。
搜索C标准库的路径的逻辑与此相同,只不过C标准库的路径来自变量package.cpath而不是package.path。类似地,这个变量的初始值也来自环境变量LUA_CPATH_5_3或LUA_CPATH。在POSIX系统中这个路径的典型值形如:
1
./?.so;/usr/local/lib/lua/5.2/?.so

请注意定义文件扩展名的路径。在上例中,所有模块使用的都是.so,而在Windows操作系统中此典型路径通常形如:
1
.\>.dll;C:\Program Files\Lua502\dll\?.dll

函数package.searchpath中实现了搜索库的所有规则,该函数的参数包括模块名和路径,然后遵循上述规则来搜索文件。函数package.searchpath要么返回第一个存在的文件的文件名,要么返回nil外加描述所有文件都无法成功打开的错误信息,如下:
1
2
3
4
5
path = ".\\?.dll;C:\\Programe Files\\Lua502\\dll\\?.dll"
print(package.searchpath("X",path))
nil
no file '.\X.dll'
no file 'C:\Program Files\Lua502\dll\X.dll'

作为一个有趣的练习,我们在示例中实现了与函数package.searchpath类似的函数。

示例,实验班的package.searchpath

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function search(modname,path)
modname = string.gsub(modname,"%.","/")
local msg = {}
for c in string.gmatch(path, "[^;]+") do
local fname = string.gsub(c,"?",modname)
local f = io.open(fname)
if f then
f:close()
return fname
else
msg[#msg + 1] = string.format("\n\tno file '%s'",fname);
end
end
return nil , table.concat(msg) -- 没找到
end

上述函数首先替换目录分隔符,在本例中即把所有的点换成斜杠。之后,该函数遍历路径中的所有组成部分,也就是每一个不含分号的最长匹配。对于每一个组成部分,该函数使用模块名来替换问号得到最终的文件名,然后检查相应的文件是否存在。如果存在,该函数关闭这个文件,然后返回文件的名称;否则,该函数保存失败的文件名用于可能的错误提示(请注意字符串缓冲区在避免创建无用的长字符串时的作用)。如果一个文件都找不到,该函数则返回nil及最终的错误信息。

搜索器

在现实中,函数require比此前描述过的稍微复杂一点。搜索Lua文件和C标准库的方式只是更加通用的搜索器的两个实例。一个搜索器是一个以模块名为参数,以对应模块的加载器或nil为返回值的简单函数。
数组package.searchers列出了函数require使用的所有搜索器。在寻找模块时,函数require传入模块名并调用列表中的每一个搜索器知道它们其中的一个找到了指定模块的加载器。如果所有所搜器都被调用完后还找不到,那么函数require就抛出一个异常。
用一个列表来驱动对一个模块的搜索给函数require提供了极大的灵活性。例如,如果想保存被压缩在zip文件中的模块,只需要提供一个合适的搜索器,然后把它增加到该列表中。在默认配置中,我们此前学习过的用于搜索Lua文件和C标准库的搜索器排在列表的第二、三位,在它们之前是预加载搜索器。
预加载搜索器使得我们能够为要记载的模块定义任意的加载函数。预加载搜索器使用一个名为package.preload的表来映射模块名称和加载函数。当搜索指定的模块名时,该搜索器只是简单地在表中搜索指定的名称。如果它找到了对应的函数,那么就将该函数作为相应模块的加载函数返回;否则,则返回nil。预加载搜索器为处理非标场景提供了一种通用的方式。例如,一个静态链接到Lua中的C标准库可以将其luaopen函数注册到表preload中,这样luaopen函数只有当用户加载这个模块时才会被调用。用这种方式,程序不会为没有用到的模块浪费资源。

Lua语言中编写模块的基本方法

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

一个用于复数的简单模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
local M = {}   -- 模块

-- 创建一个新的复数
local function new(r,i)
return{r = r , i = i}
end

M.new = new -- 把'new'加到模块中

-- constant 'i'
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

function M.mul(c1,c2)
return new(c1.r*c2.r - c1.i*c2.i,c1.r*c2.i + c1.i*c2.r)
end

local function inv(c)
local n = c.r^2 + c.i^2
return new(c.r/n,-c.i/n)
end

function M.div(c1,c2)
return M.mul(c1,inv(c2))
end

function M.tostring(c)
return string.format("(%g,%g)",c.r,c.i)
end

return M

请注意我们是如何通过简单地把new和inv声明为局部变量而使它们称为代码段的私有函数的。
有些人不喜欢最后的返回语句。一种将其省略的方式是直接把模块对应的表放在package.loaded中:

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

请注意,函数require会把模块的名称作为第一个参数传给加载函数。因此,表索引中的可变长参数表达式…其实就是模块名。在这一赋值语句后,我们就不再需要在模块的最后返回M了:如果一个模块没有返回值,那么函数require会返回package.loaded[modname]的当前值。不过,笔者认为在模块的最后加上return语句更清晰。如果我们忘了return语句,那么在测试模块的时候就很容易就会发现问题。
另一种编写模块的方法是把所有的函数定义为局部变量,然后在最后构造返回的表,参考示例

示例:使用导出表的模块

1
2
3
4
5
6
7
8
9
10
11
12
13
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,
mul = mul,
div = div,
tostring = tostring,
}

这种方式的有点在于,无须在每一个标识符前增加前缀M.或类似的东西。通过显式的导出表,我们能够以与在模块中相同的方式定义和使用导出和内部函数。这种方式的缺点在于,导出表位于最后不是最前面(把前面的话当作简略文档的话更有用),而且由于必须把每个名字都写两遍,所以导出表有点冗余(这一点其实可能会变成优点,因为这允许函数在模块内和模块外具有不同的名称,不过程序很少会用到)。
不管怎样,无论怎样定义模块,用户都能用标准的方法使用模块:

1
2
local cpx = require "complex"
print(cpx.tostring(cpx.add(cpx.new(3,4),cpx.i))) -- (3,5)

后续,我们会看到如何使用诸如元表和环境之类的高级Lua语言功能来编写模块。 不过,除了发现由于失误而定义的全局变量时又一个技巧外,笔者编写模块时都是用基本功能。

子模块和包

Lua支持具有层次结构的模块名,通过点来分隔名称中的层次。例如,一个名为mod.sub的模块是模块魔的一个子模块。一个包是一棵由模块组成的完整的树,它是Lua语言中用于发行程序的单位。
当加载一个名为mod.sub的模块时,函数require一次使用原始的模块名”mod.sub”作为键来查询表package.loaded和表package.preload。这里,模块名中的点像模块名中的其他字符一样,没有特殊含义。
然而,当搜索一个定义子模块的文件时,函数require会将点转换为另一个字符,通常就是操作系统的目录分隔符(例如,POSIX操作系统的斜杠或Windows操作系统的反斜杠)。转换之后,函数require会像搜索其他名称一样搜索这个名称。例如,假设目录分隔符是斜杠并且有如下路径:

1
./?.lua;/usr/local/lua/?.lua;/usr/local/lua/?/init.lua

调用require “a.b”会尝试打开以下文件:
1
2
3
./a/b.lua
/usr/local/lua/a/b.lua
/usr/local/lua/a/b/init.lua

这种行为使得一个包中的所有模块能够放到一个目录中。例如,一个具有模块p、p.a和p.b的包对应的文件可以分贝是p/init.lua、p/a.lua和p/b.lua,目录p又位于其他合适的目录中。
Lua语言使用的目录分隔符是编译时配置的,可以是任意的字符串(请记住,Lua并不知道目录的存在)。例如,没有目录层次的系统可以使用下画线作为”目录分隔符“,因此调用require “a,b”会搜索文件a_b.lua。
作为一种额外的机制,函数require在加载C语言编写的子模块时还有另外一个搜索器。当该函数找不到子模块对应的Lua文件或C文件时,它会再次搜索C文件所在的路径,不过这次将搜索包的名称。例如,如果一个程序要加载子模块a.b.c,搜索器会搜索文件a。如果找到了C标准库a,那么函数require就会在该库中搜索对应的加载函数luaopen_a_b_c。这种机制允许一个发行包将几个子模块组织为一个C标准库,每个子模块有各自的加载函数。
从Lua语言的视角看,同一个包中的子模块没有显式的关联。加载一个模块并不会自动加载它的任何子模块。同样,加载子模块也不会自动地加载其父模块。当然,只要包的实现者愿意,也可以创造这种关联。例如,一个特定的模块可能一开始就显式地加载它的一个或全部子模块。