Lua 模块与包

442 阅读17分钟

一、什么是模块

模块就是一些代码(可以是 Lua 编写的,也可以是 C 语言编写的),这些代码可以通过函数 require 加载,然后创建和返回一个表,这个表就类似命名空间。

所有的标准库都是模块,例如 mathstring 模块

使用表来承载模块,有很显著的优点,可以像操作普通表一样操作模块,而且能利用 Lua 语言的所有功能实现额外的功能。

例如引入 math 模块

-- 两种书写方式都可以使用
local math = require "math"
--local math = require("math")

-- sin 使用的是弧度,不是角度
print(math.sin(3.14))

也可以直接引入模块中的一个函数,例如以下代码

直接引入模块中的函数,实际上只是省去了模块这一中间变量,从加载的模块 table 中获取相应的 value

-- 引用模块中的某个函数
-- 等同于 require("math").sin
-- 此时 require("math") 获取到了引入表,`.sin` 即从表中获取了对应的值 value ,此时为一个函数
local sin = require "math".sin
print(sin(3.14))

二、require(modname)

Lua 通过 require(modname) 函数进行加载模块,modname 为需要加载的模块名(字符串类型)。

0、require 函数加载模块的流程

require 会先从 package.loaded 中获取,如果没有找到相应模块,则进入根据搜索器列表 package.searches 中设置的搜索器按顺序进行查找。

package.searches 默认内置了 4 个搜索器,按顺序分别为 预加载搜索器Lua 搜索器C 标准库搜索器C 库子模块搜索器

假设我们使用了 require('A') 进行加载 A 模块,会进行以下加载步骤:

1、第一步:会在 package.loaded 中检查模块 "A" 是否已经存在,如果存在则会将其返回,不存在则进入第二步骤

package.loaded 是一个 table , 存储着加载成功的模块,以模块名为 key ,模块返回结果为 value 的形式存放。

如果 package.loaded 不存在对应的模块,则会进入到后续的步骤进行搜索,无论后续的哪一步骤让模块加载成功,都会将模块的返回值(该返回值类型可以是 function 、 table 等数据类型)作为 value 和加载的模块名(例如这里的 A )为 key ,以 key-value 的形式存放到 package.loaded table 中。如果模块没有返回值,则会用 true 代替返回值,从而达到不会每次加载相同模块都需要运行一次加载流程。

举个例子

加载两个模块,然后通过打印 package.loaded 查看已经加载的模块

print("package.loaded 已经加载的模块:")

-- 获取当前 lua 的文件夹路径
local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
-- 设置加载模块路径
package.path = package.path .. ";" .. currentPath .. "../?.lua"

require("一个合理的模块")
require("module.sub")

for path, package in pairs(package.loaded) do
    print("----- 模块【" .. path .. "】包含的属性:-----")
    if type(package) == "table" then
        for key, value in pairs(package) do
            print(key, "---", value)
        end
    else
        print(path, "---", package)
    end
    print("--------------------------------")
end

打印的内容会比较多,因为 Lua 默认加载的函数也会在其中,但在众多的输出中,可以找到加载的 "一个合理的模块" 和 "module.sub" 模块(见下图)

"一个合理的模块" 模块会返回一个 table ,所以会将 table 存储在 package.loaded 表中(这里输出的便是存储的内容)。

具体代码可以进入 github 查看 github.com/zincPower/l…

"module.sub" 模块则没有返回值,所以 Lua 会默认返回 true ,将其存储在 pacakge.loaded 表中(从输出的内容也可以验证这一点)。

具体代码可以进入 github 查看 github.com/zincPower/l…

2、第二步:在 “预加载搜索器” 中使用 package.preload 查找是否有对应加载函数,如果有则会将加载函数返回,否则进入第三步骤

package.preload 也是一个 table ,只是他的 value 必须是一个加载函数。

会根据 require 传入的模块名,在 preload 中查询,如果找到对应的 key ,则调用 value(是一个函数),会将请求的模块名和加载的来源(这里是通过预加载器,即 preload)传递给 value 函数,最后会将该函数的返回值作为模块的返回值存储在第一步提到的 package.loaded 中, 方便后续加载相同的模块。

可以运行代码,通过调用 showLoadedModule 函数,感受这一过程

print("package.preload:")

local function showLoadedModule()
    for path, package in pairs(package.loaded) do
        print("----- 模块【" .. path .. "】包含的属性:-----")
        if type(package) == "table" then
            for key, value in pairs(package) do
                print(key, "---", value)
            end
        else
            print(path, "---", package)
        end
        print("--------------------------------")
    end
end

package.preload["testModule"] = function(name, source)
    print("加载函数", name, source)
    return { name = "江澎涌" }
end

do
    for k, v in pairs(package.preload) do
        print(k, "-->", v)
    end
end
--> package.preload:
--> testModule	-->	function: 0x6000015f4de0

--showLoadedModule()

--- 会调用到 preload 的加载函数,加载完会将 testModule 的返回值放入到 loaded 中
require("testModule")
--> 加载函数	testModule	:preload:

--showLoadedModule()

3、第三步:在 “Lua 搜索器” 中会使用 package.path 查找对应的加载模块文件,如果找到则会使用 loadfile 对其加载,否则进入第四步

package.path 是一个字符串,字符串内部由一个个路径拼凑而成,这些路径表明在哪里进行查找我们需要的 Lua 文件

我们可以通过下面代码进行输出 package.path 路径

print("package.path: ", package.path)   --> 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

具体的如何使用 package.path 进行搜索,看下面的 “搜索路径” 小节

举个例子:

我们要加载两个名为 "一个合理的模块" 和 "module.sub" 自己编写的模块,则需要将他们存放的目录路径设置给 package.path 否则会查找不到

如何编写一个合理的模块,可以查看下面的 “模块的编写” 小节。

debug.getinfo(1, "S").source:sub(2):match("(.*/)") 是为了获取当前执行的 Lua 文件所在的文件夹路径。debug 的使用后续会有详细的文章分享。

可以运行代码,通过调用 showLoadedModule 函数,感受这一过程

print("package.path:")

local function showLoadedModule()
    for path, package in pairs(package.loaded) do
        print("----- 模块【" .. path .. "】包含的属性:-----")
        if type(package) == "table" then
            for key, value in pairs(package) do
                print(key, "---", value)
            end
        else
            print(path, "---", package)
        end
        print("--------------------------------")
    end
end

local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
package.path = package.path .. ";" .. currentPath .. "../?.lua"

--showLoadedModule()

require("一个合理的模块")
-- 会拆解为 module/sub
require("module.sub")

--showLoadedModule()

4、第四步:在 "C 标准库搜索器" 中会使用 package.cpath 搜索对应的 C 标准库,如果查找到了,则会使用 package.loadlib 进行加载,底层函数会查找名为 luaopen_modname 的函数

package.cpath 是一个字符串,字符串内部由一个个路径拼凑而成,这些路径表明在哪里进行查找我们需要的 C 标准库

print("package.cpath: ", package.cpath)     --> package.cpath: 	/usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.so

具体如何使用,后续 " C++ 中使用 Lua 的系列文章" 会进行分享

5、第五步:在 “C 库子模块搜索器” 中会使用 package.cpath 搜索对应的 C 标准库

和第四步的差异在于,“C 库子模块搜索器” 用于处理加载包含子模块的情况,具体的规则可以查看最后的 “子模块” 小节,如何使用在后续的 " C++ 中使用 Lua 的系列文章" 会进行分享

值得注意的是第四步和第五步都是对于 C 库的处理,只是考虑的情况不同。

6、一图胜千言

针对这一流程,我手绘了一下看看是否能达到一图胜千言了

三、模块

1、入参

模块文件会接收到两个参数,可以通过 ... 获取

  • 第一个入参:模块名
  • 第二个入参:该文件所在的路径

例如加载一个模块为 被加载的模块.lua 的文件

--- 注意不要有 `.lua`
require("被加载的模块")

在模块中,输出 ... 便可看到入参

print(...)            --> 被加载的模块	/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/调用模块/被加载的模块.lua

Lua 的模块加载不提供传递自定义参数,所以如果需要同一模块不同表现,就需要在模块内部自行处理,例如暴露函数初始化。

2、返回值

require 的本质是将模块的结果以模块名为键放到 package.loaded 表中,这样做的目的是:为了下一次获取统一模块时可以返回同样的返回值,而且也标记了已经加载过了(未加载为 nil)。

有两种方式可以将设置模块的返回值:

第一种: 在模块最末尾用 return 的方式,将结果返回,一般是一个表结构(当然也可以是其他的类型)。require 函数会将结果存到 package.loaded 表中。

第二种: 直接将结果存入 package.loaded 表中,表的 key 就用模块名(通过 ... 获取),所以简单方式就是 package.loaded[...] (会舍弃掉 ... 的第二个参数)。用这种方式,就可以不用 return 了,但是模块会默认返回 true ,作为模块已经被加载过的标记(因为并不是每个模块都会自行设置 package.loaded[...])。但是值得注意的是 required 在返回最终的值时会检测 package.loaded[模块名] 是否已经有值了,有的话则直接放回我们在前自己手动设置的值,而舍弃 true 这一默认值;否则保存 true 这样就可以标记该模块已经被加载了。

返回值的表述会比较绕,可以移步代码,运行一下 调用模块.lua 代码,对比一下加载 被加载的模块.lua被加载没有返回值的模块.luapackage.loaded 表现就很清楚。

举个例子

下面两种方式效果是一样的

-- 第一种方式
return {
    name = name,
    foo = foo
}

-- 第二种方式
package.loaded[...] = {
    name = name,
    foo = foo
}

3、删除已加载模块

从模块加载流程中得知,已加载的模块结果会被存放在 package.loaded 中,而 package.loaded 是一个表,key 就是模块名。从之前分享的 “ Lua 数据类型 —— 表” 一文中,知道删除一个元素就是将其赋值为 nil 。

所以综上所述,我们就可以用这样的方法删除已经加载的模块

package.loaded.modname = nil

-- 或
package.loaded[modname] = nil

4、搜索路径

Lua 的所有搜索路径中,都是一组模版,每个模版间用 ";" 连接。

每个模版都会使用 “模块名” 替换 ? ,然后检查文件是否存在,如果不存在,就检查下一个模版,直到所有的模版都被检查完,如果还没有找到相应文件就会返回两个值 “nil” 和 “错误信息(已经搜索过的路径)”

举个例子:

假设我们的搜索路径是如下内容

?;?.lua;/usr/local/lua/?/?.lua

此时调用 require "user" , 则会在以下路径中查询相应的文件:

user
user.lua
/usr/local/lua/user/user.lua

4-1、package.path 和 package.cpath 的区别

经过第一小节,聪明的你其实已经知道他们的区别了,上面的规则适用于这两种路径

  • Lua 文件的搜索路径是 package.path

  • C 标准库的搜索路径是 package.cpath

4-2、搜索路径的初始化

在 package 模块初始化后,Lua 会从几个地方尝试赋值 package.path :

  1. 会先检查是否有环境变量 LUA_PATH_5_4(后面的是版本,因为我现在使用的的版本是 lua 5.4.4 ),如果有则会将其值复制给 package.path,如果没有则执行第二点;
  2. 检查环境变量 LUA_PATH 是否存在,有的话则赋值给 package.path,否则 Lua 会使用一个编译时定义的默认路径。

对于 package.cpath 也是一样的逻辑,只是是从 LUA_CPATH_5_4LUA_CPATH 中获取。值得注意对于 C 库,不同平台的后缀会有不同。 例如在 POSIX 使用的是 .so 后缀,而 Windows 使用的是 .dll 后缀。

在使用终端的交互模式中,如果想要使用默认路径,可以使用 lua -E 来启动一个交互模式。

在环境变量的设置中,可以使用 ;; 表示默认路径, 例如 model/?.lua;; 则最后会表示为在 model/?.lua 和默认路径中进行搜索。

5、搜索器

require 函数内部其实是通过一个个搜索器来实现的,而所有的搜索器存储在 package.seachers 中。

Lua 内置了四个搜索器,按顺序依次是:

  1. 预加载搜索器,从 package.preload 的表中搜索,这个表存储的是 “模块名->加载函数” 。能够为要加载的模块定义任意的加载函数,提供了一种通用的方式。
  2. Lua 文件搜索器
  3. C 标准库搜索器
  4. C 库子模块搜索器
--- 第一个是预加载搜索器
--- 第二个是 Lua 搜索器
--- 第三个是 C 搜索器
--- 第四个是 C 库子模块搜索器
for k, v in pairs(package.searchers) do
    print(k, "-->", v)
end
--> 1	-->	function: 0x600003ac44e0
--> 2	-->	function: 0x600003ac4510
--> 3	-->	function: 0x600003ac4540
--> 4	-->	function: 0x600003ac4570

如果所有的搜索器都被调用完还找不到加载函数,则 require 会抛出异常

5-1、自定义搜索器

搜索器其实是一个以模块名为参数,以对应模块的加载器或 nil(如果找不到加载器)为返回值的简单函数。

举个例子

自定义一个搜索器,这里无论加载什么模块都是返回同一个加载器。搜索器内部都会加载 “被搜索器加载的文件.lua” 文件。

-- 设置自定义搜索器
package.searchers[#package.searchers + 1] = function(moduleName)
    print("moduleName: ", moduleName)
    local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
    return loadfile(currentPath .. "被搜索器加载的文件.lua")
end

local module = require("不能存在的模块")
print(module, module.name)
--> moduleName: 	不能存在的模块
--> ==================================
--> 进入模块
--> 模块入参:	不能存在的模块	nil
--> table: 0x60000362cb80	江澎涌

local module1 = require("不能存在的模块2")
print(module1, module1.name)
--> moduleName: 	不能存在的模块2
--> ==================================
--> 进入模块
--> 模块入参:	不能存在的模块2	nil
--> table: 0x60000362ccc0	江澎涌

-- 因为 “不能存在的模块2” 模块在上面已经加载过了,所以就不会在加载了,可以拿到上面加载的结果直接运行
local module2 = require("不能存在的模块2")
print(module2, module2.name)
--> table: 0x60000362ccc0	江澎涌

自定义搜索器可以实现一些特殊模块的搜索规则,例如在 zip 中的模块。

加载器可以理解为一个函数,通过他可以获取到模块的结果,进而保存至 package.loaded 中,达到缓存模块结果,不需要多次加载同一个模块。(这期间的编译是很消耗性能)

6、模块重命名

加载模块中,难免会遇到命名冲突的问题。

如果是 Lua 文件的话,比较好处理,只需要重新命名一下避开就行。

如果是 C 标准库的话,就没办法去改 luaopen_xxx 的函数,所以提供了一种 “连字符” 处理方式。就是一个模块包含连字符的话,require 函数只会用连字符之前的内容来创建 luaopen_xxx 的名称。所以只要将其中一个模块名称更改为携带版本即可,在寻找 luaopen_xxx 则会将版本去除后进行查找。

举个例子:

如果模块名为 mod-v1.0 ,执行 require "mod-v1.0" 打开的是 luaopen_mod 函数,而不是 luaopen_mod-v1.0

四、package.searchpath(name, path, sep, rep)

在给定路径 path 中搜索给定名称 name 。

路径是一个字符串,包含一个由分号分隔的模版(规则和 “搜索路径” 一样),尝试打开处理后的文件名。

参数:

  • name:要搜索的模块名
  • path:搜索的路径
  • sep:模块名中需要被替换的字符,默认为 "."
  • rep:替换字符所用的字符,默认为系统分隔符

返回值:

  • 如果找到了就会返回查找到的文件的完整路径
  • 如果没有找到返回两个值 nil 和没有成功的错误信息

举个例子:

如果路径是字符串 "./?.lua;./?.lc;/usr/local/?/init.lua" 搜索名称 foo.a 将 尝试按顺序打开文件./foo/a.lua./foo/a.lc/usr/local/foo/a/init.lua

local currentPath = debug.getinfo(1, "S").source:sub(2):match("(.*/)")
local path = "?.lua;"..currentPath .. "?.lua"

print(package.searchpath("module!sub", path, "!", "@"))
--> nil	no file 'module@sub.lua'
-->	    no file '/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/module@sub.lua'

print(package.searchpath("module.sub", path))
--> /Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/module/sub.lua

print(package.searchpath("模块", path))
--> nil	no file '模块.lua'
--> 	no file '/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/模块.lua'

五、模块的编写

定义一个合理的模块可以遵循以下几点:

  1. 将模块内部的变量和函数都声明为 local ,这样可以达到类似 java、kotlin 的 private 变量或函数,避免和全局的冲突或为后续的代码带来问题
  2. 模块的返回一般为 table ,然后将需要给外部调用的函数设置在 table 中,可以达到 java、kotlin 的 public 变量或函数

模块不是规定要返回 table ,可以选择任意的数据类型,也可以没有返回值

举个例子:

创建一个 “一个合理的模块.lua” 的文件,内容如下:

local man = {}

function man.sayHello()
    print("Hello.")
end

man.name = "jiang pengyong"

local age = 29
man.age = age

local function showInfo()
    print("My name is " .. man.name .. "." ..
            "I'm " .. man.age .. " years old.")
end
man.showInfo = showInfo

--- 第一种返回值的方式
return man

然后加载这个文件:

package.path = package.path..";/Users/jiangpengyong/Desktop/code/Lua/lua_study_2022/11 模块和包/?.lua"
local module = require("一个合理的模块")
print(module.name)      --> jiang pengyong
module.showInfo()       --> My name is jiang pengyong.I'm 29 years old.

模块的返回值并不一定要通过 return 来返回,可以通过 package.loaded[...] = xxx 进行设置返回值。(上面 “返回值” 一节中提及) 因为前面有提及到,模块会有两个入参,第一个就是模块名称,第二个是加载函数所在文件的名称,这种方式则是直接给 loaded 表设置返回值,而 [...] 则是取第一个参数,即模块名称。

所以使用第二种返回值的方式如下所示:

local man = {}

function man.sayHello()
    print("Hello.")
end

man.name = "jiang pengyong"

local age = 29
man.age = age

local function showInfo()
    print("My name is " .. man.name .. "." ..
            "I'm " .. man.age .. " years old.")
end
man.showInfo = showInfo

--- 第二种返回值的方式
package.loaded[...] = man

六、子模块

1、如何搜索 Lua 编写的子模块

Lua 支持具有层次结构的模块名,通过点来分隔名称中的层次。

例如 module.submodule 的子模块,而多个模块组成的树则叫做包。

当我们 require("module.sub") 搜索一个带有子模块的文件时,Lua 会进行以下步骤:

  1. 直接使用 module.sub 作为 key ,在 package.loadedpackage.preload 中搜索是否有对应的 value(注意此时 "module.sub" 的 "." 不会被转换为其他的字符)。
  2. 如果 1 没有搜索到,则会将 module.sub. 转为系统对应的目录分隔符(如果是 mac )则转为 /,此时就变为 module/sub ,然后进行 “搜索路径” 小节的规则进行替换,然后进行查找文件。

这个分隔符的替换,是在 Lua 编译时配置的,可以是任意字符串。

值得注意的是,如果子模块加载成功,在 package.loaded 其保存的 key 值是 module.sub , 而不是 module 不是 sub 也不是 module/sub

2、如何搜索 C 编写的子模块

如果是 C 编写的子模块,因为 C 函数不能带有 . ,则在调用 luaopen 函数时,则会将 . 转为 _

所以在经过了 “Lua 搜索器” 和 “C 搜索器” 都加搜索不到相应的文件时,会进入到 “第四个搜索器 —— C 库子模块搜索器” 中。

如要通过 require("module.sub") 加载 C 模块,“C 库子模块搜索器” 会在 package.cpath 中搜索是否有 module 的 C 标准库,如果找到了对应的库,则会搜索是否存在 luaopen_module_sub 函数,有则进行执行,然后将结果存储。

这里也就回应了上面 “第五步” 的问题了。

3、子模块的关联

对于同一包而言,加载一个子模块并不会将整个包的模块都加载,如果子模块有需要,该模块需要自己去创建这种联系。

七、写在最后

Lua 项目地址:Github传送门 (如果对你有所帮助或喜欢的话,赏个star吧,码字不易,请多多支持)

如果觉得本篇博文对你有所启发或是解决了困惑,点个赞或关注我呀

公众号搜索 “江澎涌”,更多优质文章会第一时间分享与你。

image.png