一、什么是模块
模块就是一些代码(可以是 Lua 编写的,也可以是 C 语言编写的),这些代码可以通过函数 require
加载,然后创建和返回一个表,这个表就类似命名空间。
所有的标准库都是模块,例如 math
、 string
模块
使用表来承载模块,有很显著的优点,可以像操作普通表一样操作模块,而且能利用 Lua 语言的所有功能实现额外的功能。
例如引入 math
模块
lua
-- 两种书写方式都可以使用
local math = require "math"
--local math = require("math")
-- sin 使用的是弧度,不是角度
print(math.sin(3.14))
也可以直接引入模块中的一个函数,例如以下代码
直接引入模块中的函数,实际上只是省去了模块这一中间变量,从加载的模块 table 中获取相应的 value
lua
-- 引用模块中的某个函数
-- 等同于 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
查看已经加载的模块
lua
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 函数,感受这一过程
lua
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
路径
lua
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 函数,感受这一过程
lua
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 标准库
lua
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
--- 注意不要有 `.lua`
require("被加载的模块")
在模块中,输出 ...
便可看到入参
lua
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
和被加载没有返回值的模块.lua
再package.loaded
表现就很清楚。
举个例子
下面两种方式效果是一样的
lua
-- 第一种方式
return {
name = name,
foo = foo
}
-- 第二种方式
package.loaded[...] = {
name = name,
foo = foo
}
3、删除已加载模块
从模块加载流程中得知,已加载的模块结果会被存放在 package.loaded
中,而 package.loaded
是一个表,key 就是模块名。从之前分享的 " Lua 数据类型 ------ 表" 一文中,知道删除一个元素就是将其赋值为 nil 。
所以综上所述,我们就可以用这样的方法删除已经加载的模块
lua
package.loaded.modname = nil
-- 或
package.loaded[modname] = nil
4、搜索路径
Lua 的所有搜索路径中,都是一组模版,每个模版间用 ";" 连接。
每个模版都会使用 "模块名" 替换 ?
,然后检查文件是否存在,如果不存在,就检查下一个模版,直到所有的模版都被检查完,如果还没有找到相应文件就会返回两个值 "nil
" 和 "错误信息(已经搜索过的路径)"
举个例子:
假设我们的搜索路径是如下内容
ruby
?;?.lua;/usr/local/lua/?/?.lua
此时调用 require "user"
, 则会在以下路径中查询相应的文件:
sql
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
:
- 会先检查是否有环境变量
LUA_PATH_5_4
(后面的是版本,因为我现在使用的的版本是 lua 5.4.4 ),如果有则会将其值复制给package.path
,如果没有则执行第二点; - 检查环境变量
LUA_PATH
是否存在,有的话则赋值给package.path
,否则 Lua 会使用一个编译时定义的默认路径。
对于 package.cpath
也是一样的逻辑,只是是从 LUA_CPATH_5_4
和 LUA_CPATH
中获取。值得注意对于 C 库,不同平台的后缀会有不同。 例如在 POSIX 使用的是 .so
后缀,而 Windows 使用的是 .dll
后缀。
在使用终端的交互模式中,如果想要使用默认路径,可以使用 lua -E
来启动一个交互模式。
在环境变量的设置中,可以使用 ;;
表示默认路径, 例如 model/?.lua;;
则最后会表示为在 model/?.lua
和默认路径中进行搜索。
5、搜索器
require 函数内部其实是通过一个个搜索器来实现的,而所有的搜索器存储在 package.seachers
中。
Lua 内置了四个搜索器,按顺序依次是:
- 预加载搜索器,从
package.preload
的表中搜索,这个表存储的是 "模块名->加载函数" 。能够为要加载的模块定义任意的加载函数,提供了一种通用的方式。 - Lua 文件搜索器
- C 标准库搜索器
- C 库子模块搜索器
lua
--- 第一个是预加载搜索器
--- 第二个是 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" 文件。
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
。
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'
五、模块的编写
定义一个合理的模块可以遵循以下几点:
- 将模块内部的变量和函数都声明为 local ,这样可以达到类似 java、kotlin 的 private 变量或函数,避免和全局的冲突或为后续的代码带来问题
- 模块的返回一般为 table ,然后将需要给外部调用的函数设置在 table 中,可以达到 java、kotlin 的 public 变量或函数
模块不是规定要返回 table ,可以选择任意的数据类型,也可以没有返回值
举个例子:
创建一个 "一个合理的模块.lua" 的文件,内容如下:
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
然后加载这个文件:
lua
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 表设置返回值,而 [...]
则是取第一个参数,即模块名称。
所以使用第二种返回值的方式如下所示:
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
--- 第二种返回值的方式
package.loaded[...] = man
六、子模块
1、如何搜索 Lua 编写的子模块
Lua 支持具有层次结构的模块名,通过点来分隔名称中的层次。
例如 module.sub
是 module
的子模块,而多个模块组成的树则叫做包。
当我们 require("module.sub")
搜索一个带有子模块的文件时,Lua 会进行以下步骤:
- 直接使用
module.sub
作为 key ,在package.loaded
和package.preload
中搜索是否有对应的 value(注意此时 "module.sub" 的 "." 不会被转换为其他的字符)。 - 如果 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吧,码字不易,请多多支持)
如果觉得本篇博文对你有所启发或是解决了困惑,点个赞或关注我呀。
公众号搜索 "江澎涌",更多优质文章会第一时间分享与你。