PIL.17Lua中的模块与包

6.1k 词

通常,Lua并不设置什么规则,而是提供足够的方法给开发者来实现最适合他们自己的规则。然而,这些方法对于模块来说工作得并不好。模块系统的一个重要目的就是允许不同的团队共享代码。通用规则的缺乏阻碍了这个共享的实现。

从 5.1 开始,Lua就定义了一系列关于模块和包的规则(一个包就是很多模块的集合)。这些规则并不需要从语言获得额外的设置;程序员可以用我们已经见到的东西来实现它。程序员可以自由使用不同的规则。当然,有些实现可能会使程序无法使用外部的模块,或者外部的程序不能使用它。

从用户的角度看,模块就是 能通过 require加载,然后创建并返回一个表 的代码(用C或者Lua写的)。模块导出的所有东西,比如函数和常量,都定义在表内,这个表工作类似一个命名空间。

来看个例子,所有的标准库都是模块。我们可以像下面这样使用数学库:

local m = require "math"
print(m.sin(3.14))

然而,发行版内的解释器预加载了所有的标准库,代码与下相等:

math = require "math"
string = require "string"

这个预加载允许我们写一些常用的函数而不用自己去加载 那些库。

用表来实现模块的一个非常明显的好处就是,我们可以向操纵其他表一样操作模块,并能利用Lua全部的能力来建立额外的特性。在大部分语言中,模块并不是第一类的值(这就是说,他们不能存储在变量中,或者作为参数传递给函数 等等);要为模块提供一些额外的特性时,这样的语言需要一些特别的方法。在LUa我们可以自由活动额外的特性。

具体点说,用户有好几种方法可以从一个模块调用函数。常用的方法是:

local mod = require "mod"
mod.foo()

我们也可以为模块设置一个局部的名字:

local m = require "mod"
m.foo()

同时,还可以为单独的函数提供名字:

local m = require "mod"
local f = m.foo
f()

还可以只导入一个特定的函数:

local f = require "mod".foo -- (require("mod")).foo
f()

这些使用方法是Lua已经提供的,不需要什么额外的工作来支持。

抛开require函数在整个模块实现中的重要角色不提,它其实只是一个普通的函数,没有什么特权。要加载一个模块,我们简单的以一个参数调用它,也就是模块的名字。记住,当给函数的参数是一个 字符串,括号是可选的,通常我们会省略它。下面的用法是正确的:

local m = require ('math')
local modname = 'math'
local m = require(modname)

函数require试图对一个模块是什么做最小的假设。对它来说,一个模块只是一些定义了几个值(函数或包含函数的表)的代码。典型的,这些代码会返回一个由模块函数组成的表。然而,因为这个动作是由模块代码完成的,而不是通过 require,某些模块可能会选择返回其他值或者,设置会有一些副作用(如建立了全局变量)。

require的第一步是检查表 package.loaded,确定这个模块是否已经加载。如果加载,就返回对应的值。因此,一旦一个模块加载后,其他调用加载这个模块只会简单的返回同样的值,而不会再次运行模块代码。

如果模块没有加载,require会以模块名字搜索一个文件。(这个搜索被变量package.path来引导,我们在后面会讨论)如果找到这样一个文件,就会使用 loadfile来加载。结果就是我们叫做 loader 的函数。(loader在调用的时候会加载模块)

如果require不能找到对应的Lua文件,就会以那个名字搜索一个C库(这时,搜索通过变量package.cpath来引导)。如果找到一个C库,则会以底层函数 package.loadlib来加载,寻找一个叫做 luaopen_模块名 的函数。在这样的情况下,loader是 loadlib的结果。luaopen_模块名是个C函数,但表现得就像一个Lua函数。

不要关心这个模块是Lua文件还是C库,require现在有了一个加载器。为了最终加载这个模块,require以两个参数调用加载器(loader):模块名,找到的加载器名字。(多数模块会忽略这些参数)。如果加载器返回了什么值,require返回这些值并把他们保存在 package.loaded表中,将来再加载这个模块的时候会返回这个值。如果加载器没有返回任何值,表项package.loaded[@rep{modname}]仍然是空的,require表现得就像这个模块返回了 true 。没有这个修正的话,接下来调用 require 加载这个模块会再次执行这个模块。

为了让 require 强制性的重复加载同样的模块,我们可以在 package.loaded中擦除对应的项:

package.loaded.模块名 = nil

这样下次的话 require 就会再次加载了。

一个经常遇到的抱怨就是,require 不能在模块在加载的时候传递参数。具体说,数据库模块可能会有一个选项来在 弧度 和角度间选择:

-- bad code
local math = require("math", "degree")

这里的问题是,require 的一个主要目的就是避免多次加载同一模块。一旦加载一个模块,程序的任何部分都可以重复使用这个模块。当以不同的参数加载同一模块时可能和出现冲突。如果你想要你的模块有参数,较好的方式就是建立一个显式的函数来设置他们,这样:

local mod = require "mod"
mod.init(0, 0)

如果初始化函数返回模块本身,我们可以这样写:

local mod = require "mod".init(0, 0)

无论何时要记住,模块本身只会被加载一次。

重命名一个模块

通常,我们以模块的原始名字来使用它,但某些时候我们要重命名来避免名字冲突。一个典型的情况就是当我们要加载同一模块的不同版本来测试时。Lua模块在内部不会保持名字固定,所以一般重命名 .lua文件就够了。然而,我们不能编辑C库的 对象代码来改变 luaopen_*函数的名字。为了支持类似的重命名,require使用了个小把戏:如果模块名包含一个连字符 -require会在建立 luaopen_*函数的时候去掉这个连字符后的内容。比如,如果一个模块叫 mod-v3.4require 会期望它的打开函数是 luaopen_mod,而不是luaopen_mod-v3.4(即使是一个合法的C名字)。因此,我们要使用两个模块(或同一模块的不同版本),我们可以把其中一个命名为 mod-v1。当我们调用 m1 = require "mod-v1时,require会找到命名过的 文件,但在文件中,其打开函数依然是 luaopen_mod

路径搜索

当搜索一个Lua文件时,引导 require 的路径和典型的路径有点不同。一个典型的路径就是一个目录列表,在里面搜索给定的文件。然而,ISO C并没有目录的概念。因此,require 使用的路径是一个 模板 列表,每个模板指定了一个可选的方式来 转换一个模块名( require 的参数)到一个文件名。更特别地,路径中的每个模块都是一个包含可选 ? 的名字。 对于每个模板,require以模块名替换对应的?,然后检查是否存在这么样一个文件;如果没有,就继续下一个妙手空空。路径中的模板以 ;分隔。:

“?;?.lua;c:windows?;/usr/local/lua/?/?.lua”

当我们调用 require "sql"时,将会尝试下面的文件:

sql
sql.lua
c:windowssql
/usr/local/lua/sql/sql.lua

require用来搜索Lua文件的路径总是 变量package.path的当前值。当模块 package在初始化时,其会设置这个变量值为环境变量LUA_PATH_5_3;如果环境变量没有定义,则会尝试环境变量LUA_PATH。如果两者都没有定义的话,Lua使用一个编译器定义的默认路径。比如,当我们设置 LUA_PATH_5_3mydir/?.lua时,最终的路径将会是 mydir/?.lua加上默认的路径。

用来搜索C库的路径工作起来相似,其值从 package.cpath取得。一个POSIX中典型的路径值会是:

./?.so;/usr/local/lib/lua/5.2/?.so

注意这里面定义了后缀名,因此在windows中应该是这样的:

.?.dll;c:Program FilesLua502dll?.dll

函数 package.searchpath对搜索库的这些规则进行了编码。其接受一个模块名和一个路径,然后根据这些规则来寻找一个文件。其返回第一个找到 文件名或者 nil 加上描述所有文件打开都失败的错误消息,例如:

> path = ".\?.dll;C:\Program Files\Lua502\dll\?.dll"
> print(package.searchpath("X", path))
nil
no file '.X.dll'
no file 'C:Program FilesLua502dllX.dll'

搜索器

实际上,require比我们已经描述的更复杂些。搜索Lua文件和搜索C库是 searchers(搜索器)的两个不同实例。一个搜索器只是一个函数,其会根据模块名来返回这个模块的加载器,或者在其找不到时返回nil

数组package.searchers列出了require使用的搜索器。当找寻一个模块时,require会把参数逐个传递给表中的搜索器,直到有返回这个模块加载器的出现。如果并没有,那require会给出一个错误。

使用一个列表来驱动对模块的搜索允许require变得非常灵活。如果我们想把模块放在压缩的 zip 文件中,我们只需要提供一个何时的搜索器函数,然后把他放在这个列表中。默认设置下,Lua文件和C库的搜索器分别是第二第三个元素。在他们之前,是预加载的搜索器。

预加载(preload)的搜索器允许一个专门的函数来加载模块。其使用一个表,package.preload,来映射模块名与加载器函数。当搜索一个模块名时,这个搜索器简单的在表中寻找给定名字。如果找到就把对应函数返回为加载器,否则返回nil。这个加载器提供了一个操控某些不符合习惯的情况的一般性方法。比如,静态链接至Lua的C库可以把其 luaopen_*函数注册到 preload表中,这样其只会在用户需要那个模块时被调用。这样的方式,程序将不会因为要打开不使用的模块而浪费资源。

package.searchers的默认内容包含第四个函数,这和子模块相关。我们后面讨论。

编写模块的基本方式

最简单的建立一个模块就是:建立一个表,把所有希望导出的函数放在里面,然后返回这个表。

local M = {}
local function (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

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

注意:只是通过在其前面加上 local 就把函数 new, inv定义成为了私有的

某些人可能不喜欢最后的返回语句。一个避免的方式是直接把模块表赋值给 package.loaded

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

需要注意的是 require 在调用加载器的时候会传递模块名作为第一个参数。因此,... 就代表了那个名字。在这个赋值后,我们就不需要在模块的最后返回 M:如果一个模块不返回一个值,require 将会返回package.loaded[modname]的当前值(如果不是nil)。不管怎么样,我发现在最后写上return会非常的清晰。如果我们忘记了这点,任何与这个模块相关的测试都会检查到错误。

另外一个方式就是把所有的函数定义为局部的,然后在最后构造要返回的表:

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

-- defines constant 'i'
local i = complex.new(0, 1)

other functions follow the same pattern
return {
new = new,
i = i,
add = add,
sub = sub,
mul = mul,
div = div,
tostring = tostring,
}

这种方式的好处是什么?我们不需要在每个名字前加上前缀 M 或者其他类似的前缀;这里有一个显式的导出列表;我们同样的方式定义和使用导出的/内部的 函数。不好的地方是什么?导出列表到了模块的后面而不是开始,在进行快速文档的时候会更实用;导出列表有点多余,因为我们必须写两次名字。(最后一个坏处有可能是一个好处,因为其允许在模块内外拥有不同的名字,但我想程序员很少做这个事情)

不管我们如何定义一个模块,用户都可以以标准的方式进行使用:

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

后面我们会看到怎样使用某些Lua的进阶特性,比如元表和环境,来写模块。然而,多数时候我都只使用这些基本的方式。

子模块和包

Lua允许模块名字是层级的,使用一个.来分别名字等级。一个mod.sub 的模块是 mod 的子模块。一个 包是模块的完整树;其是Lua中发行版的单元。

当我们需要一个mod.sub模块时,函数 require 将会首先查询 package.loaded表,然后package.preload表,使用的是mod.sub作为键。这里,.就是一个普通的字符,和其他字符一样。

然而,当搜索定义了那个子模块的文件时,require. 翻译为另外一个字符,通常是系统的目录分隔符(/或 windows中的)。