🔥 本文专栏:Lua
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
允许自己暂时平凡,但不要因此怀疑未来的无限可能。
思维导图:本文的理解链条

整个思路可以概括为:
text
大型项目需要模块化组织
↓
不同功能拆分到不同文件
↓
产生跨文件使用代码的需求
↓
C++:头文件声明 + 编译链接
Lua:运行时调用 require() 加载模块
↓
require() 并非编译阶段的文件包含
↓
require("player") 接收的是模块名
↓
检查 package.loaded 缓存
↓ 未命中
调用 package.searchers 中的搜索函数
↓
得到 Loader(模块加载函数)
↓
require() 调用 Loader,取得模块值
↓
更新缓存,供以后复用
一、为什么需要模块?先从 C++ 多文件项目说起
1. 一个程序当然可以写在同一个文件中,但这不是大型项目的合理组织方式
假设我们现在需要开发一个游戏服务端,内部包括玩家属性、背包、任务、战斗等不同业务功能。对于一个很小的程序,我们完全可以把所有定义都放在一个文件中,然后从 main() 开始执行。
但是随着功能不断增加,一个文件的代码量就会越来越大。问题不仅在于文件变长、阅读困难,还在于不同功能之间的职责边界变得模糊:想修改背包逻辑,需要在大量无关的代码中寻找;想理解项目结构,也很难一眼看出系统由哪些部分组成。
所以,我们自然会把不同功能拆分到不同的文件、目录中。例如 C++ 项目可以组织为:
text
GameServer/
├── main.cpp
├── player/
│ ├── Player.h
│ └── Player.cpp
├── bag/
│ ├── Bag.h
│ └── Bag.cpp
└── battle/
├── Battle.h
└── Battle.cpp
这里需要建立一个认知:模块首先是按职责划分出来的代码组织单元,并不要求"一个模块 = 一个类"。 玩家模块完全可能包含 Player、PlayerManager、PlayerData 等多个类。
多文件结构真正的价值,在于我们可以通过目录和文件的划分理解系统由什么组成,以及不同部分分别负责什么。
2. 文件拆分以后,如何访问另一个文件中的代码?
对于 C++,我们通常通过头文件暴露声明,通过源文件提供实现。例如:
Player.h:
cpp
#pragma once
class Player {
public:
void levelUp();
};
Player.cpp:
cpp
#include "Player.h"
void Player::levelUp() {
// 玩家升级逻辑
}
main.cpp:
cpp
#include "Player.h"
int main() {
Player player;
player.levelUp();
}
其中,#include 是预处理阶段的文本包含机制,并不是执行程序时再读取这个头文件。随后,相关源文件通常分别编译为目标文件,再由链接器解析符号引用、组合所需的目标文件和库。
那么,Lua 同样可以把玩家、背包、战斗等功能放在不同文件中;但它解决跨文件协作的方式,与上述 C++ 编译链接流程并不相同。

二、理解 Lua 模块之前,先把它放回编译与执行机制
1. Lua 的编译与执行是两个阶段
在之前的学习中,我们已经认识了 Lua 的基本执行流程:
text
Lua 源代码
↓
编译成字节码
↓
Lua 虚拟机执行字节码
(取指 → 分派 → 执行)
这里的编译与虚拟机执行,都是 Lua 解释器所提供的能力,并不要求有两个独立的程序。
现在假设 main.lua 中有:
lua
local Player = require("player")
print("main.lua 继续执行")
关键问题:编译 main.lua 时,遇到 require("player"),会不会立刻去找 player.lua,把它一起读进来?
答案是:不会仅仅因为编译到这行代码,就读取另一个模块文件。
require 不是 Lua 语法关键字,而是运行环境提供的普通函数。在编译阶段,这里主要被处理为一次函数调用:获取 require,准备字符串参数,在运行时调用它并接收结果。
2. 可以通过一个实验验证"模块加载发生在运行时"
我们故意不创建 player.lua,只保留:
lua
-- main.lua
local Player = require("player")
先进行语法检查:
bash
luac -p main.lua
只要 main.lua 自己没有语法错误,就可以通过检查;模块文件不存在,并不会让当前文件的编译立即失败。
但是如果真正运行:
bash
lua main.lua
并且模块不在其他搜索位置,那么执行到 require() 时就会出现类似错误:
text
module 'player' not found
我们也可以把 require() 放在一个不会执行的分支中:
lua
if false then
require("player")
end
print("执行完成")
即使没有 player.lua,程序也不会因为这个从未执行的调用去查找模块。
所以这里建立第一个核心认知:
编译阶段只生成函数调用所需的字节码;当虚拟机运行到相关函数调用时,才会真正调用
require(),进而触发模块加载。
3. require() 是标准库函数,谁来实现它的功能?
说到这里,还可以联系一个之前容易产生的疑问:我们在 C++ 中使用 std::sort、std::vector 时,通常要显式包含标准库头文件;但是在 Lua 中,直接写:
lua
local t = {10, 20, 30}
table.insert(t, 40)
table.remove(t, 1)
print(t[1])
却不需要在脚本开头 require("table")。
原因在于:标准 Lua 命令行程序通常会在执行脚本之前,初始化 Lua 状态,并注册相应的标准库。 因而脚本执行时就可以直接访问 print、table.insert、require 等函数。嵌入 Lua 的 C/C++ 宿主也可以通过 luaL_openlibs() 打开标准库,或者只开放需要的库。
在标准 Lua 5.4 中,table.insert()、table.remove() 和 require() 的实现来自 C 编写的标准库代码。
这里要区分两种事情:
- 类似普通数值加法这样的语言操作,可以由 Lua 虚拟机的对应字节码执行逻辑完成。
table.insert()、require()这样的标准库功能,则通过普通函数调用机制进入已注册的函数实现。
虚拟机负责执行函数调用,而具体的标准库函数负责完成对应功能。 这不是说虚拟机要为 table.insert、require 分别设计一条专用的字节码指令。
同样,require() 加载另一个 Lua 文件时,也不是 fork() 出一个子进程,更不是启动另一个独立 Lua 解释器。普通场景下,它就是在当前 Lua 状态中,通过函数调用继续加载并执行另外一份代码。
三、什么是 Lua 模块?为什么经常看到 return 一个 Table?
现在先不着急研究搜索细节,我们先考虑一个最基础的问题:被加载的模块究竟向调用方提供什么?
假设项目结构如下:
text
game/
├── main.lua
└── player.lua
player.lua:
lua
local Player = {}
function Player.new(name)
return {name = name, level = 1}
end
function Player.levelUp(player)
player.level = player.level + 1
end
return Player
main.lua:
lua
local Player = require("player")
local p1 = Player.new("战士")
Player.levelUp(p1)
print(p1.name, p1.level) -- 战士 2
这里并没有出现新的模块专用语法。我们之前已经知道:Table 是键值对的集合,函数是数据类型,所以可以将函数作为 Table 中的值。这样就能通过 Player.new、Player.levelUp 访问一组相关功能。
真正需要理解的是末尾的:
lua
return Player
1. Chunk 可以返回值
我们之前已经认识过 Chunk(编译单元) 。一个 Lua 文件中的代码可以作为一个 Chunk 来加载和执行,而 Chunk 也能够通过 return 返回值。
因此,player.lua 末尾的 return Player,并不是从 Player.new() 或 Player.levelUp() 返回,而是作为这个 Chunk 执行结束时提供的返回值。
在语法上,return 必须作为所属代码块中的最后一条语句;把它放在模块文件结尾,正好满足这个要求。
2. 模块返回值不一定是 Table
我们经常返回 Table,是因为一个模块通常希望一次对外提供多个函数、配置或者共享数据;但这只是常见的组织方式,并不是语言强制规定。
例如,下面的模块也是合法的:
lua
-- version.lua
return "1.0.0"
lua
-- answer.lua
return 100
lua
-- factory.lua
return function(name)
return {name = name}
end
甚至模块可以没有 return,或者写 return nil。至于这种情况下 require() 最终得到什么,后面结合缓存机制再解释。
所以,这里对模块先建立一个简单认知:
常见 Lua 模块就是一份可以被加载执行的 Chunk,借助
return向调用方提供值;使用 Table 返回一组函数和数据,是最常见的写法。
四、require("player") 传入的是模块名,不是文件名
现在我们知道 require() 会在运行期间加载模块,但它接收的仅仅是一个字符串:
lua
local Player = require("player")
这里的 "player" 叫做模块名 。它不是一个已经确定的磁盘文件路径,也不要求一定存在名为 player.lua 的独立文件。
那么,Lua 如何确定模块对应哪个文件?这就需要认识 package。
1. package 本身是一个 Table
lua
print(type(package)) -- table
print(type(package.path)) -- string
print(type(package.loaded)) -- table
print(type(package.searchers))-- table
print(type(package.preload)) -- table
这里不需要把 package 想得很复杂。它就是 Lua 标准库提供的一个 Table,保存模块加载相关的数据与函数。
我们先看其中负责 Lua 文件搜索路径的字段:
lua
package.path
注意:package.path 只是一个字符串,不是负责执行搜索的函数。
2. package.path 是怎样的字符串?
比如某个 Lua 5.4 环境中,它可能包含下面这样的路径模板:
text
/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
实际上,这些路径模板以分号 ; 连接在同一个字符串中,例如:
text
./?.lua;./?/init.lua
其中:
;负责分隔多个搜索路径模板。?是占位符,由搜索过程替换成模块名对应的路径部分。./在这里表示进程的当前工作目录,不一定是当前 Lua 脚本所在目录。
例如:
lua
require("player")
当普通 Lua 文件搜索器使用 ./?.lua 这个模板时,就会尝试 ./player.lua;如果第一个位置没找到,可以继续使用 ./?/init.lua,尝试 ./player/init.lua。
假设模块名有点号:
lua
require("player.attribute")
在默认搜索规则中,模块名里的 . 会先转换为系统目录分隔符,所以在 Linux 上,模板 ./?.lua 对应的候选路径就是:
text
./player/attribute.lua
这也是为什么 Lua 项目经常可以通过模块名反映目录层级,但这个层级仍然依赖相应的搜索规则。
3. package.path 可以直接修改
既然 package 是 Table,而 path 只是其中的一个字符串字段,那么我们当然能够直接修改它:
lua
package.path = "./modules/?.lua;" .. package.path
这里用到了之前学习的字符串拼接运算符 ..。这段代码就是在原有路径前面增加一个模板,并保留原来的搜索路径。
例如目录:
text
game/
├── main.lua
└── modules/
└── player.lua
如果进程当前工作目录为 game/,并执行上述路径配置,那么后续的 Lua 文件模块搜索就会优先尝试:
text
./modules/player.lua
如果直接赋值 package.path = "./modules/?.lua",则会覆盖原有模板,因此实际项目里往往更适合把新路径拼接到原路径中。
再补充一个小工具:
lua
local filepath, err = package.searchpath(
"player",
"./modules/?.lua;./?.lua"
)
print(filepath or err)
package.searchpath() 只负责按照给定模板查找可读文件,不会执行模块代码;它可以帮助我们单独验证"模块名 → 文件路径"的映射结果。
五、文件搜索之前,require() 为什么先检查 package.loaded?
理解了路径以后,还不能直接得出"每次 require() 都会查找文件"。因为在搜索之前,require() 会先检查缓存。
1. package.loaded 保存什么?
lua
package.loaded
它同样是一个 Table,保存模块名 → 模块缓存值的映射关系。
注意,这里的缓存值不是 Lua 文件的源码,也不是模块编译后的字节码,而通常是模块 Loader 执行完成后提供的返回值。
例如:
player.lua:
lua
print("player.lua 被执行")
local Player = {}
return Player
main.lua:
lua
local p1 = require("player")
local p2 = require("player")
print(p1 == p2)
print(p1 == package.loaded["player"])
输出:
text
player.lua 被执行
true
true
因为第一次调用时,require() 得到模块返回的 Table,将它缓存到 package.loaded["player"];第二次调用时,缓存已经有效,所以直接返回同一个 Table,不需要重新读取、编译和执行模块。

从引用语义上看,p1、p2 与缓存项保存的是同一个 Table 的引用。因此,如果该 Table 中有可修改的共享字段,那么一个使用者的修改也会被其他使用者看到。
但需要注意,这里讨论的是同一个 Lua 状态中的模块缓存,不能据此认为不同进程或彼此独立的 Lua 状态天然共享这份 Table。
2. 没有返回值、return nil,为什么缓存会是 true?
之前有一个容易混淆的地方:模块 Chunk 的返回值,与 require() 最终返回的值,并不一定相同。
假设 player.lua 写成:
lua
print("初始化完成")
return nil
或者干脆没有 return:
lua
print("初始化完成")
这两种情况下,如果模块没有自己修改对应的缓存项,那么 require() 都会将:
lua
package.loaded["player"] = true
作为成功加载的标记,最后返回 true。
为什么不直接缓存 nil?因为我们之前知道,Lua Table 中把某个键赋值为 nil,相当于删除对应的键值对,不能用它稳定地表示"已经加载过这个模块"。
完整地说,模块成功加载后,require() 的常见处理结果如下:
| 模块 Loader 的执行结果 | 缓存值(假设模块未自行修改缓存项) |
|---|---|
return {} |
返回的 Table |
return 100 |
100 |
return "hello" |
"hello" |
return nil |
true |
| 没有返回值 | true |
return false |
false;下次 require() 仍会尝试加载 |
这里还有两个边界:
- 如果模块没有返回非
nil值,但执行过程中已经自行设置了package.loaded["player"],require()会保留对应的值,而不是无条件写入true。 false虽然可以存储在 Table 中,但对于require()的缓存判断来说,nil和false都不属于有效的"已加载"结果。
因此,日常编写模块时,直接在 Chunk 末尾 return 一个 Table 就很清晰;主动修改 package.loaded 属于需要知道、但没必要作为常规写法的特殊情况。
3. 缓存机制带来的另一个效果:初始化通常不会重复执行
模块不一定只是返回数据,也可能在执行时做一些初始化:
lua
-- game_config.lua
print("初始化游戏配置")
local Config = {maxLevel = 100}
return Config
连续两次 require("game_config"),在缓存保持有效的情况下,初始化输出只会出现一次。这里体现了 require() 的另一个作用:让模块的第一次加载与后续复用区分开来。
六、package.searchers:真正负责搜索模块的是谁?
到这里,我们已经知道:
package.path是搜索路径字符串。package.loaded是模块缓存 Table。
可是路径字符串自己不会去磁盘找文件;负责组织不同查找方式的,是:
lua
package.searchers
它同样是一个 Table,在标准 Lua 5.4 默认配置下包含四个搜索函数:
lua
for i, searcher in ipairs(package.searchers) do
print(i, type(searcher))
end
输出通常为:
text
1 function
2 function
3 function
4 function
四个搜索器分别是:
| 搜索器 | 查找方式 | 主要使用的信息 |
|---|---|---|
searchers[1] |
查找预先注册的模块加载函数 | package.preload |
searchers[2] |
查找并编译 Lua 模块文件 | package.path |
searchers[3] |
查找 C 动态库模块 | package.cpath |
searchers[4] |
尝试在一个 C 库中寻找子模块 | package.cpath |
这里我们只深入已经讨论的前两个搜索器,第三个先认识其定位,第四个留作后续专题。
1. require() 是整个模块加载流程的组织者
理解 require() 最简单的办法,就是把它看作一个负责协调模块缓存、搜索器以及 Loader 的函数。
完整流程如下:

其中有一个非常重要的分工:
Searcher(搜索器)负责找到 Loader(加载函数),而
require()负责调用 Loader,并根据模块执行结果更新缓存。
也就是说,搜索器成功时,并不需要自己把整个模块业务逻辑执行完。
Lua 5.4 中,搜索器成功时会返回 Loader,以及一份附加信息(Loader Data),例如所找到的文件路径;require() 随后将模块名和该附加信息交给 Loader。平时我们通常只关心模块值,所以写成 local Player = require("player") 即可。
2. 第二个搜索器:读取和编译文件,但暂时不执行模块代码
对于普通 Lua 文件模块,package.searchers[2] 会根据 package.path 定位文件、读取并编译成字节码,再将编译得到的 Chunk 作为 Loader 返回。
这里一定要区分:
text
找到并编译模块文件
≠
执行模块 Chunk
例如:
lua
-- player.lua
print("玩家模块真正开始执行")
return {name = "Player"}
我们可以尝试直接调用搜索器:
lua
local loader, filepath = package.searchers[2]("player")
print(type(loader)) -- 找到时为 function
print(filepath) -- 找到的文件路径
假设找到了文件,此时模块 Chunk 虽然已经被编译,但上面那句"玩家模块真正开始执行"尚未输出。
只有调用 Loader 时,模块代码才真正执行:
lua
local Player = loader("player", filepath)
print(Player.name)
注意:这里是为了观察机制而手动调用搜索器与 Loader ;它不会自动走完整的 require() 缓存管理流程。实际使用模块时,我们通常直接写 require()。
3.补充:为什么 require() 调用 Loader 时还需要传递文件路径?
这里我们需要进一步认识一个细节:在 Lua 5.4 中,当搜索器成功找到模块后,会返回一个 Loader(模块加载函数) 以及相关的附加信息。而 require() 在调用 Loader 时,会将模块名和这些附加信息作为参数传递进去。
例如,对于第二个搜索器,其调用过程可以理解为:
lua
local loader, filepath = package.searchers[2]("player")
-- require() 内部调用 Loader 的示意
local result = loader("player", filepath)
这里就产生了一个问题:既然第二个搜索器已经完成了文件定位、读取以及编译,得到了对应的 Loader,那么为什么还需要将文件路径传递给 Loader?难道还需要再次定位字节码吗?
实际上并不需要。
我们之前已经认识了,对于普通 Lua 文件模块,第二个搜索器会根据 package.path 定位文件,然后读取并编译成字节码,最终得到一个可调用的 Lua 函数,也就是 Loader。
这里需要明确,Loader 本身就已经关联了编译完成的模块代码,因此当 require() 调用 Loader 时,Lua 虚拟机就可以直接执行对应的字节码,不需要重新根据文件路径寻找、读取或者编译模块文件。
那么,传递文件路径的目的是什么?
其实,这里的文件路径只是搜索器提供的附加信息(Loader Data),并不是执行模块字节码所必需的参数。
例如,我们可以在 player.lua 中编写:
lua
local moduleName, filePath = ...
print("模块名:", moduleName)
print("模块文件路径:", filePath)
local Player = {}
return Player
当我们通过 require("player") 首次加载这个模块时,模块 Chunk 可以通过 ... 接收 require() 传入的模块名和文件路径。
但如果模块根本不需要这些信息,也可以直接忽略:
lua
local Player = {}
function Player.levelUp()
print("玩家升级")
end
return Player
此时,模块仍然能够正常执行。
因此,我们可以建立一个关键认知:
搜索器负责获取 Loader,而 Loader 已经具备执行对应模块代码的条件。require() 传递给 Loader 的第二个参数只是搜索器返回的附加信息,Loader 可以根据实际需求使用,也可以完全忽略,它并不是用于重新定位字节码的。
需要注意,这里的第二个参数也不一定是文件路径。对于第二个 Lua 文件搜索器,它通常是模块文件路径;而对于其他搜索器,则取决于搜索器自身提供了什么附加信息。
七、package.preload:不依赖独立文件,也能找到 Loader
理解第二个搜索器以后,再看第一个搜索器就很自然了。
1. 为什么不一定需要磁盘上的模块文件?
前面我们已经知道:require() 最终需要的是一个可以调用的 Loader。
第二个搜索器通过编译 Lua 文件获得 Loader。但既然 Lua 函数本身也是一种值,我们能不能提前写好一个函数,直接保存起来,供搜索器查找?
当然可以。这就是 package.preload 的用途。
lua
print(type(package.preload)) -- table
它里面保存的是:模块名 → 预先注册的 Loader 函数。
例如:
lua
-- main.lua
package.preload["player"] = function()
print("玩家模块开始加载")
local Player = {}
function Player.levelUp(player)
player.level = player.level + 1
end
return Player
end
print("模块注册完成")
local Player1 = require("player")
local Player2 = require("player")
print(Player1 == Player2)
print(Player1 == package.loaded["player"])
输出:
text
模块注册完成
玩家模块开始加载
true
true
这里我们没有创建任何 player.lua 文件。
需要特别注意两个时间点:
- 执行
package.preload["player"] = function() ... end时,只是在 Table 中注册 Loader,没有执行 Loader 内部的代码。 - 第一次执行
require("player")且缓存未命中时,第一个搜索器找到了这个函数,再由require()调用,才真正执行初始化逻辑。
因此,preload 的"预加载",更准确地说是预先注册模块加载函数,而不是"提前把模块全部执行完成"。
2. package.preload 和 package.loaded 不要混淆
| 对比 | package.preload |
package.loaded |
|---|---|---|
| 保存的内容 | 模块名 → Loader 函数 | 模块名 → 模块缓存值 |
| 主要作用 | 提前准备加载方式 | 复用已加载模块的结果 |
| 谁来使用 | 第一个搜索器 | require() 优先检查 |
| 是否说明模块已执行 | 不说明 | 存在有效缓存时说明可直接复用 |
此外,如果同名模块既存在于 package.preload,也存在于磁盘上的 player.lua,那么在没有有效缓存的前提下,默认第一个搜索器会先命中预注册 Loader,不必再进入第二个搜索器。
3. 预加载的实际价值是什么?
前面很容易产生一个想法:既然第一个搜索器不需要读取 player.lua,是不是意味着它一定比第二个搜索器快?
这里不能直接得出这个结论。
确实,当 Loader 已经准备好时 ,第一次 require() 可以省去相应独立 Lua 文件的路径查找、读取和编译。
它更值得关注的用途包括:
第一,宿主程序注册原生模块。 C/C++ 宿主可以把用于创建原生模块的 Loader 提前注册给 Lua,之后 Lua 仍然可以通过统一的 require("xxx") 取得模块。
第二,模块打包。 某些程序把多个模块打包在一起,没有必要为每个模块保留独立的磁盘文件;可以提前准备这些模块的 Loader。
第三,测试时替换依赖。 测试某个业务模块时,可以预先注册一个模拟模块,在首次 require() 时替代真实依赖。前提是对应模块没有被已有有效缓存抢先返回。
至于"首次加载时顺便初始化配置或资源",这是 require() 加载模块时本来就能够做到的事情,不属于 package.preload 独有的用途。第二个搜索器加载普通 Lua 文件,同样可以在执行模块代码时完成初始化。
八、第三个搜索器:Lua 模块不一定由 Lua 代码实现
前面两个搜索器最终都能得到 Loader,现在再向前迈一步:如果模块是 C/C++ 实现的呢?
对于标准 Lua 5.4,第三个搜索器使用:
lua
package.cpath
这个字段与 package.path 类似,也是一个搜索路径字符串,但主要用来查找 C 模块动态库。例如 Linux 上的 .so 文件、Windows 上的 .dll 文件。
假设执行:
lua
local Native = require("native")
在前两个搜索器没有找到 Loader 的情况下,第三个搜索器可能根据 package.cpath 定位 native.so,通过操作系统的动态链接机制将动态库引入当前进程,再寻找符合约定的入口函数,例如:
c
luaopen_native
它会把这个原生入口函数作为 Loader 提供给 require()。随后,require() 调用 Loader,最终取得该模块提供的 Lua 值(例如一个包含可调用原生函数的 Table)。
这里要区分:C/C++ 动态库中的代码已经被编译成机器码,不是由 Lua 编译器再编译成 Lua 字节码。 Lua 虚拟机通过函数调用机制进入原生函数,原生机器码由 CPU 执行,完成后再返回 Lua 的执行流程。
它同样不需要 fork() 子进程,也不意味着启动了第二个 Lua 解释器。
至于 C/C++ 如何通过 Lua C API 注册函数、传递参数和返回 Lua 值,涉及更完整的跨语言调用机制,可以单独作为后续专题,不必在理解模块搜索器时全部展开。
第四个搜索器则是C 模块集合搜索器:例如某个 C 动态库中同时放了多个子模块,它可以尝试从对应的根模块库中寻找特定子模块的入口函数。我们在本篇只建立总体认识。
九、结合游戏服务端:C++ → Lua 与 Lua → C++ 并不矛盾
学习第三个搜索器时,容易出现一个疑问。
我们之前理解的游戏服务端架构是:C++ 作为宿主程序,负责底层网络通信、连接管理、消息收发等基础设施;Lua 位于上层,负责游戏业务逻辑。
那么为什么又会说"Lua 调用 C++"呢?难道 Lua 也要接管底层网络 I/O?
其实,这是把两个不同维度混在一起了。
**第一个维度是架构职责:**谁负责网络 I/O,谁负责业务规则。
**第二个维度是调用方向:**由 C++ 调用 Lua,还是由 Lua 调用 C++。
假设客户端发出升级请求:
text
客户端请求
↓
C++ 接收网络消息、管理连接、解析协议
↓
C++ 调用 Lua 的玩家升级业务函数
↓
Lua 更新玩家等级,执行游戏规则
↓
Lua 如有需要,调用宿主注册的 C++ 日志等接口
↓
返回原来的执行流程

例如:
lua
function levelUp(player)
player.level = player.level + 1
native.log("玩家升级成功") -- 假设宿主已注册该接口
end
这段 Lua 代码没有承担 Socket 读写、epoll、连接管理等职责,它只是调用一个已经暴露给 Lua 的原生日志接口。
即使某些项目向 Lua 提供 server.send() 这样的接口,也不代表 Lua 自己在进行底层 Socket I/O:Lua 可以只表达"业务上需要发什么",底层 C++ 再负责消息编码、发送队列和实际网络发送。
因此:
C++ → Lua、Lua → C++ 描述的是函数调用方向;C++ 底层、Lua 上层描述的是架构职责。两者并不冲突。
另外,Lua 使用原生功能不一定非要先 require() 加载一个 C 动态库。C/C++ 宿主也可以在初始化 Lua 状态时,直接将原生函数注册进 Lua 环境。第三个搜索器只是获取原生模块的一种方式。
十、最后把 require() 的全过程重新梳理一遍
到这里,我们已经不需要把每个 API 当成独立的知识点来记忆了。它们在同一条执行链中,各自负责不同的事情:
| 组件 | 在整条链路中的职责 |
|---|---|
require() |
组织和协调模块加载的整个流程 |
package.loaded |
保存模块名与缓存值的映射 |
package.searchers |
保存按不同规则查找模块的搜索函数 |
package.path |
为 Lua 文件搜索器提供路径模板 |
package.preload |
保存提前注册的模块 Loader |
package.cpath |
为 C 动态库搜索器提供路径模板 |
| Loader | 被 require() 调用时,真正执行模块加载逻辑 |
假设现在执行:
lua
local Player = require("player")
我们可以按照下面这条顺序理解:
第一步: main.lua 在编译阶段把 require("player") 处理为一次函数调用,并不会因此立即加载 player.lua。
第二步: Lua 虚拟机执行到这次函数调用,进入标准库函数 require()。
第三步: require() 先检查 package.loaded["player"]。若存在有效缓存,则直接返回。
第四步: 缓存没有命中,require() 按顺序调用 package.searchers 中的搜索器:第一个尝试查找预注册 Loader;第二个尝试根据 package.path 查找并编译 Lua 文件;第三个尝试 C 动态库;第四个尝试 C 库中的子模块。
第五步: 一旦某个搜索器成功提供 Loader,就不需要继续尝试后面的搜索器。require() 调用 Loader,执行对应的模块代码或原生模块初始化函数。
第六步: require() 根据 Loader 的返回值和模块缓存状态确定最终缓存值,并返回给调用方。对于最常见的 return Player,缓存值就是返回的 Table;对于没有返回值或返回 nil 的模块,若未自行设置缓存项,通常记为 true。
第七步: main.lua 得到模块值,继续执行后续字节码。同一个 Lua 状态内再次 require("player") 时,通常可直接复用缓存。
这样,我们就将之前认识的 C++ 多文件组织、Lua 编译与虚拟机执行、Chunk 返回值、Table 引用语义、标准库函数以及模块搜索器 全部联系到了一起。
最终需要抓住的核心,不是死记几个字段,而是这句话:
require()负责组织流程,package.loaded负责缓存,package.searchers负责寻找 Loader;不同搜索器通过不同方式取得 Loader,而真正执行 Loader、取得模块结果并处理缓存的,是require()。
附:动手验证的三个小实验
为了避免知识停留在概念上,可以自己在 Lua 5.4 环境中逐个验证。
实验一:编译通过,不代表模块在运行时一定能找到
lua
-- main.lua
local x = require("module_that_does_not_exist")
先运行 luac -p main.lua,再运行 lua main.lua,观察两者差异。
实验二:验证缓存的值和 Table 的引用关系
lua
-- player.lua
print("模块执行一次")
return {count = 0}
lua
-- main.lua
local a = require("player")
local b = require("player")
a.count = 10
print(b.count) -- 10
print(a == package.loaded["player"]) -- true
实验三:验证预注册 Loader 与磁盘文件搜索的优先级
lua
-- main.lua
package.preload["demo"] = function()
print("从 preload 加载")
return {source = "preload"}
end
local demo = require("demo")
print(demo.source) -- preload
即使在当前搜索目录里另建一个 demo.lua,只要 demo 还没有有效缓存,默认第一个搜索器仍会优先找到已注册的 Loader。
