速查总览:这一页怎么查
本章是全页的索引:章节地图告诉你哪个话题在哪一章,定位与选型回答「什么时候该选 Lua」,版本与发行版速查解决查资料时最费时的一个问题——你手上跑的那个 Lua,到底是 5.1 还是 5.5、是 PUC 还是 LuaJIT。
全页二十三章,按「语法 → 表与元表 → 运行机制 → 标准库 → 嵌入与工程」排列。不必顺读——按关键词定位,翻该章第一张速查表。
章节地图
| 章 | 查什么 | 关键词 |
|---|---|---|
| 01 | 安装、lua / luac 命令行、程序骨架、按报错反查 | lua -e · arg · traceback |
| 02 | 八种类型、local 与作用域、整数 / 浮点子类型、字符串与转义、真值 | nil · integer · [[…]] |
| 03 | 算术与整除 //、关系与逻辑、位运算、.. 与 #、优先级表 | // · ~ · and/or · # |
| 04 | if、三种循环、数值 for 与泛型 for、break / goto | repeat · ipairs · goto |
| 05 | 一等函数、多返回值、变参 ...、闭包与 upvalue、尾调用、冒号语法 | select · vararg · self |
| 06 | 表:整门语言唯一的数据结构;序列与「洞」、# 的契约、table 库、引用语义 | 序列 · # · insert |
| 07 | 元表与元方法全表:__index / __newindex / 运算 / 比较 / __close | setmetatable · __call |
| 08 | 用表 + 元表手搭类与继承(Lua 没有 class 关键字) | __index 链 · new |
| 09 | error / assert、pcall / xpcall、warn、load 动态加载 | pcall · level · traceback |
| 10 | <const> 与 <close> 两个变量属性(5.4 起) | to-be-closed |
| 11 | 协程:create / resume / yield / wrap / status / close | 生成器 · 状态机 |
| 12 | require 与模块惯例、package 搜索器、_ENV 沙箱、序列化 | package.path · _ENV |
| 13 | collectgarbage 全部选项、弱表、分代 vs 增量 GC | __mode · step · gen |
| 14 | Lua 模式(不是正则):字符类、捕获、find / gsub / gmatch、format | %w · %b · gsub |
| 15 | 深水区:sort 抛错不回滚、concat 遇洞截断、数组 / 哈希双骨架、时间刻度、yield 红线 | 契约 · os.clock |
| 16 | 标准库逐库详解 + 一屏一行一函数总表 | string · math · io · os |
| 17 | C API:虚拟栈、注册 C 函数、嵌入宿主、5.5 的变化 | lua_State · 栈索引 |
| 18 | 5.5 新特性:global 声明、具名变参表、只读循环变量、紧凑数组 | 5.5 · table.create |
| 19 | 高频陷阱清单、给 JS/TS 开发者的对照表、5.1 → 5.5 演进 | 1-based · nil |
| 20 | 界面:宿主决定 UI、Neovim 实验场、LÖVE 与终端 | LÖVE · Neovim |
| 21 | 测试:三个断言陷阱、三十行手搭框架、luaunit 与 busted | assert · pcall |
| 22 | 动手项目、资料索引、自测标准 | PiL · 参考手册 |
三条最常见的查法
| 你手上有的 | 去哪 |
|---|---|
一条报错原文(attempt to index a nil value…) | 01 章的反查表 |
| 一个函数名想不起来参数顺序 | 16 章的一屏总表;行为契约在 15 章 |
「为什么 #t 给的数不对」「为什么改了一个表另一个也变了」 | 06 章;元表相关看 07 章 |
lua -v 一句话就能问出来。网上大量教程和 StackOverflow 答案针对的是 5.1(LuaJIT 与魔兽插件的基准),//、~、<const> 这些在那边全都不存在——照抄会得到语法错误而不是运行错误,还算幸运的。goto、变量属性这几处,它们在 LuaJIT(5.1 语法)上或缺失或不同。Lua 的立身之本是可嵌入:核心实现约 3 万行 C、编译后不到 300KB,官方定位就是嵌进宿主程序的胶水语言——「小而快」不是宣传语,是压倒一切的设计原则。
今天它占据哪些疆域
- 游戏脚本的事实标准:魔兽世界的整套插件 UI、Roblox 上的全部创作者逻辑;
- 高性能网关:OpenResty 用它写请求处理,Kong 整个构建在其上;
- 编辑器与数据库:Neovim 把它定为一等配置语言,Redis 用它实现原子操作。
和邻居怎么选
- vs Python:宿主程序(游戏 / 服务器 / 嵌入式)要内置脚本引擎,几乎总是选 Lua——Python 解释器的体积与嵌入成本都太高;
- vs JavaScript:同为动态语言,V8 几十 MB,Lua 胜在极小;
- 什么时候别选:要独立开发应用、要现成的标准库——它的电池是故意不带的。
-- 整门语言只有一种数据结构:table 身兼数组、字典、对象与模块
local langs = { "C", "Lua", hero = "table" }
local function make_counter()
local n = 0 -- 被闭包捕获的私有状态(upvalue)
return function()
n = n + 1
return n
end
end
local tick = make_counter()
print(tick(), tick(), #langs) --> 1 2 2Lua 的版本差异不向后兼容——5.1 到 5.4 之间有真正会让代码跑不起来的改动。而现实是:装到手的多半不是最新版,宿主程序里嵌的更不是。
版本年表与关键改动
| 版本 | 年份 | 带来了什么 | 破坏了什么 |
|---|---|---|---|
| 5.1 | 2006 | module、setfenv、增量 GC | ——LuaJIT 与魔兽插件至今停在这里 |
| 5.2 | 2011 | _ENV、goto、bit32 库 | 废弃 setfenv / getfenv、module |
| 5.3 | 2015 | 整数子类型、原生位运算符 & | ~ << >>、//、utf8 库 | bit32 移除;5 / 2 起变成浮点 2.5 |
| 5.4 | 2020 | 分代 GC、<const> 与 <close>、warn、新的整数 for 语义 | __gc 需显式设置;协程 close 语义调整 |
| 5.5 | 2025-12 | global 声明、具名变参表、只读循环变量、紧凑数组与 table.create、增量式大回收 | 循环变量不可改;全局变量的写法更严(18 章) |
「哪个 Lua」:发行版对照
| 发行版 | 语法基准 | 用在哪 | 要注意 |
|---|---|---|---|
| PUC Lua(官方) | 5.5 / 5.4 | 命令行、嵌入 C/C++、Redis 脚本 | 本页基准 |
| LuaJIT | 5.1 + 少量 5.2 回填 | OpenResty / Kong、Neovim、大量游戏 | 没有整数子类型与 //;有 FFI 与极快的 JIT |
| Luau | 5.1 的方言 | Roblox | 加了类型标注,不是标准 Lua |
| 魔兽世界 | 5.1 | 插件 UI | 沙箱化,很多标准库被拿掉 |
一眼看出版本的三个探针
| 试这句 | 结果 |
|---|---|
print(_VERSION) | 给出 Lua 5.4 这样的字符串,但 LuaJIT 也会说自己是 Lua 5.1 |
print(jit and jit.version) | 非 nil 就是 LuaJIT,顺带给出它的版本 |
print(math.type(1)) | 给 integer 说明是 5.3+;给 nil 或报错说明是 5.1 / 5.2 |
5 / 2 在 5.3 起是 2.5 而不是 2,而 5 // 2 才是 2。从 5.1 代码库升上来时,这条改动不报错、只是结果不同——数组下标算出 2.5 时才炸,离出错点已经很远(02、03 章)。// 和位运算符,用 math.floor(a/b) 与位运算函数;别依赖整数与浮点的区分。这两条一守,同一份代码能在 5.1 到 5.5 上都跑——代价是放弃 5.3 之后最实用的两个语法糖。起步 · 命令 · 报错速查
本章覆盖:安装与版本确认、lua 与 luac 的命令行选项全表、最小程序骨架与基本约定,以及按报错原文反查——这一条对 Lua 格外要紧,因为「变量默认是全局的」这条规则会让拼写错误在离现场很远的地方才炸出来。
Lua 是主流语言里装起来最轻的一个:一个几百 KB 的解释器,没有虚拟机、没有强制的包管理器,源码 make 一分钟编完。
装与验
| 平台 | 命令 | 装到的多半是 |
|---|---|---|
| macOS | brew install lua | 5.4 |
| Debian / Ubuntu | apt install lua5.4 | 5.4,命令名就叫 lua5.4 而不是 lua |
| Windows | winget / scoop | 5.4 |
| 源码 | make all test | 你指定的版本,要 5.5 基本只能走这条 |
| 验证 | lua -v | 敲不出来就试 lua5.4、ls /usr/bin/lua* |
lua 命令行选项
| 选项 | 作用 |
|---|---|
lua | 进 REPL。直接敲表达式就会打印结果(5.3 起不需要 = 前缀) |
lua script.lua a b | 跑脚本,参数进全局表 arg |
-e "stat" | 直接执行一段代码:lua -e "print(_VERSION)"——试一句话最快的方式 |
-l name | 先 require 某个模块再执行,可重复 |
-i | 跑完脚本留在 REPL 里,现场翻变量,调试利器 |
-v | 打印版本 |
-E | 忽略 LUA_PATH / LUA_INIT 等环境变量,排查「为什么我这台机器行为不一样」 |
-W | 打开警告(warn 的输出,09 章) |
- | 从标准输入读程序:echo 'print(1)' | lua - |
-- | 停止解析选项,后面的都当参数 |
luac 与环境变量
| 写法 | 作用 |
|---|---|
luac -o out.luac in.lua | 编译成字节码 |
luac -p in.lua | 只检查语法、不生成文件——最轻量的「保存前过一遍」 |
luac -l -l in.lua | 反汇编列出指令,看编译器到底生成了什么 |
luac -s | 剥掉调试信息,代价是报错里没有行号了 |
LUA_PATH / LUA_CPATH | require 的搜索模板;里面的 ;; 表示「此处插入默认路径」(12 章) |
LUA_INIT | 启动时先执行的代码;值以 @ 开头表示文件名 |
arg[0] · arg[1…] · arg[-1] | 脚本名 · 脚本参数 · 解释器本身与它的选项 |
程序骨架:五条基本约定
| 约定 | 说明 |
|---|---|
没有 main、不需要 import | 代码直接写在文件顶层;print / type / pairs 默认就在全局环境里 |
拼接用 ..,不是 + | 数字会自动转字符串;两侧要留空格,1..2 会被当成小数点而报 malformed number |
注释 -- 到行尾 | 多行 --[[ … ]];长字符串同理写 [[ … ]] |
| 语句不需要分号 | 换行即可;分号合法但通常只在一行写多句时用 |
| 索引从 1 开始 | t[1] 是第一个元素,s:sub(1,3) 取前三个字节。这条贯穿整个标准库,是从别的语言转过来最持久的不适——唯一例外是 arg[0],那是命令行约定 |
-- ① 试一句话:不用建文件
$ lua -e "print(_VERSION, math.type(1))"
Lua 5.5 integer
-- ② 一个完整的 Lua 程序:没有 main、没有 import
local name = arg[1] or "Lua"
print("你好," .. name) --> 你好,Lua
print("脚本名:" .. arg[0]) --> 脚本名:hello.lua
-- ③ 跑完留在 REPL 里翻变量
$ lua -i hello.lua
> name
Lua
-- ⚠️ .. 两边留空格,否则会被当成小数点
print(1 .. 2) --> 12lua:Debian / Ubuntu 装的是 lua5.4,同一台机器上还可能并存多个版本。更隐蔽的是宿主里嵌的那个 Lua 与命令行的不是同一个——在终端试通的代码,进了 Neovim 或 OpenResty 可能因为那边是 LuaJIT(5.1)而跑不起来(00 章版本表)。lua -i script.lua:脚本跑完变量都还在,可以现场翻。Lua 的报错格式固定:文件名:行号: 一句话描述,后跟调用栈。括号里的 (field 'x') / (global 'x') / (local 'x') 直接点名了是哪个东西——先看这个提示,比看行号快。
运行期错误
| 报错原文 | 原因 | 改法 |
|---|---|---|
attempt to index a nil value (field 'config') | 在 nil 上取字段:t.config.debug 而 t.config 不存在 | 先判空,或用 t.config and t.config.debug(03 章 and/or) |
attempt to index a nil value (global 'foo') | 变量名拼错,或模块忘了 require | 读不存在的全局返回 nil 不报错——见下方「为什么位置会漂移」 |
attempt to call a nil value (method 'x') | 用 : 调了一个不存在的方法;常见于元表 __index 没接上 | 07、08 章 |
attempt to perform arithmetic on a nil value | 拿 nil 参与运算 | 同上,多半是上游返回了 nil 而没检查 |
attempt to concatenate a nil value (local 's') | .. 碰上 nil | 用 tostring(s) 或 s or "" |
attempt to compare number with nil | 比较两个类型不同的值 | Lua 不做隐式比较转换(03 章) |
attempt to compare two table values | 表之间用了 <,但没定义 __lt | 07 章元方法 |
bad argument #1 to 'insert' (table expected, got nil) | 标准库参数类型不对,#N 指明第几个参数 | 15、16 章的参数契约 |
table index is nil / is NaN | 用 nil 或 NaN 当键 | 键必须非 nil;NaN 也不行(06 章) |
stack overflow | 递归没有出口,且不是尾调用 | 改成尾调用形式(05 章)或改迭代 |
attempt to yield across a C-call boundary | 在 C 函数的回调里 yield | 15 章的 yield 红线清单 |
cannot resume dead coroutine | 协程已结束或已出错 | 先用 coroutine.status 判断(11 章) |
module 'x' not found: + 一串路径 | require 找不到 | 那串路径就是它找过的地方,对照 package.path(12 章) |
invalid order function for sorting | table.sort 的比较函数不自洽(比如用了 <=) | 必须是严格小于(15 章) |
attempt to add a 'string' with a 'string' | 对字符串用了 + | 拼接用 ..;但 "5" + 1 反而不报错——能转数字的字符串会被自动转换 |
语法错误
| 报错原文 | 多半是 |
|---|---|
'end' expected (to close 'function' at line N) near '<eof>' | 少写一个 end。括号里的行号指出没闭合的是哪一个 |
unexpected symbol near '=' | if x = 1 then——Lua 的相等是 ==,赋值不是表达式 |
malformed number near '1..2' | .. 两侧没留空格 |
'<name>' expected near '…' | 关键字当了变量名,或 local 后面跟了非标识符 |
unfinished string near '…' | 引号没闭合;多行文本该用 [[ … ]] |
调试手段
| 手段 | 怎么用 |
|---|---|
luac -p file.lua | 只查语法,不执行。改完先过一遍,比跑起来再看快 |
lua -i file.lua | 跑完留在 REPL,现场翻变量与表 |
xpcall(f, debug.traceback) | 拿到出错时的完整调用栈——pcall 只给一句消息(09 章) |
lua -W | 打开 warn 的输出(09 章) |
| 遍历打印表 | for k, v in pairs(t) do print(k, v) end——print(t) 只给地址(06 章) |
collectgarbage("count") | 看当前内存占用(KB),查泄漏用(13 章) |
| 全局变量守卫 | 给 _G 装 __newindex 元方法,把「意外创建全局变量」变成当场报错(07、12 章);5.5 的 global 声明是语言级的同一件事(18 章) |
count 拼成 cout,那一行悄无声息地成功,程序继续跑,直到几十行外拿它做运算才炸。对策只有一个:全部变量都写 local,再配上全局守卫(12 章)。(global 'x') 是最值钱的一处提示:它说明这个名字既不是局部变量也不是字段,而是一个没人赋过值的全局名——十有八九是拼写错误或者忘了 local。Lua 的报错格式很简单:文件名:行号: 一句话描述,后面跟一段调用栈。难的不是读格式,而是报错位置常常离真正的错误很远。
最常见的一条:attempt to index a nil value
- 意思是你在一个 nil 上取字段——
t.config.debug里如果t.config不存在,再取.debug就炸; - 括号里会告诉你是哪个名字:
(field 'config')、(global 'foo')、(local 'x')——先看这个提示,比看行号快。
为什么报错位置常常离错误很远
- 没声明的变量默认是全局变量,读一个不存在的全局变量返回 nil 而不报错;
- 于是把
count拼成cout,赋值那行悄无声息地成功了,程序继续跑,直到很远处某个地方拿它去做运算才炸——报错点和错误点之间可能隔着几十行。
-- ① nil 索引:最常见的一条(以下均为 Lua 5.5 输出)
local t = { name = "lua" }
print(t.config.debug)
-- lua: nilindex.lua:2: attempt to index a nil value (field 'config')
-- stack traceback:
-- nilindex.lua:2: in main chunk ← 出事位置
-- [C]: in ?
-- ② 拼错变量名:⚠️ 不报错!这才是最危险的
local count = 0
cout = count + 1 -- 想写 count,拼成了 cout
print(count) --> 0 (退出码 0,一切「正常」)
-- 没有任何提示:cout 被当成新的全局变量创建了
-- ③ 调用不存在的方法
local s = "hello"
print(s:lenght()) -- 把 length 拼错了
-- lua: callnil.lua:2: attempt to call a nil value (method 'lenght')
-- ④ 语法错误:会指出是哪一行的哪个结构没闭合
-- lua: syn.lua:4: 'end' expected (to close 'for' at line 2) near <eof>local,以及用 luacheck 这类静态检查扫一遍。xpcall(f, debug.traceback)(09 章)。语法错误的提示很够用:'end' expected (to close 'for' at line 2) 会直接指出是哪一行的哪个结构没闭合。语言基础
本章覆盖:八种类型与 type() 的返回值、local 与作用域规则、整数 / 浮点子类型与 math.type、字符串的四种写法与全部转义、以及「只有 nil 与 false 为假」这条贯穿全页的规则。
Lua 的类型系统小到可以一眼看完:八种类型,其中只有一种是复合结构(table)。真正要记的是三处细节——数值的整数 / 浮点子类型、字符串的四种写法、以及「只有 nil 和 false 为假」。
八种类型
type() 返回 | 是什么 | 要点 |
|---|---|---|
nil | 「没有值」 | 未赋值的变量、不存在的表字段都是它;赋 nil 即删除(06 章) |
boolean | true / false | 只有 false 和 nil 为假——0 和 "" 都是真 |
number | 数值 | 5.3 起分 integer / float 两个子类型,用 math.type() 区分 |
string | 字节串 | 不可变、可 intern;长度按字节算,中文要 utf8 库(16 章) |
function | 函数 | 一等公民,可存进表、当参数、当返回值(05 章) |
table | 唯一的数据结构 | 数组、字典、对象、模块、集合全是它(06 章) |
userdata | C 侧的数据块 | 宿主给的句柄,如文件对象(17 章) |
thread | 协程 | 不是操作系统线程(11 章) |
数值:整数与浮点
| 写法 | 结果 |
|---|---|
3 · 0xFF | integer(64 位有符号) |
3.0 · 1e3 · 0x1p4 | float(双精度) |
5 / 2 | 2.5——除法永远给 float,即使整除得尽 |
5 // 2 · 5.0 // 2 | 2(integer)· 2.0(float) |
math.type(x) | "integer" / "float" / nil(不是数) |
3 == 3.0 | true——值相等即相等,子类型不影响比较 |
math.tointeger(3.0) | 3;无法精确转换时给 nil |
tostring(3.0) | "3.0"——打印时看得出子类型,这是最快的判断方式 |
字符串:四种写法与转义
| 写法 | 用途 |
|---|---|
"…" · '…' | 两种引号等价,处理转义序列 |
[[…]] | 长字符串:不处理任何转义,可跨行;紧跟的第一个换行会被吃掉 |
[==[…]==] | 内容里含 ]] 时加等号;写 Lua 模式与正则时最省心的写法(14 章) |
\n \t \r \\ \" \' | 常规转义 |
\ddd · \xHH · \u{XXXX} | 十进制 / 十六进制字节 / Unicode 码点 |
\z | 吃掉后面所有空白——把一条长字符串拆成多行源码而不引入换行 |
local 与作用域
| 写法 | 含义 |
|---|---|
local x = 1 | 局部变量,作用域到所在块结束。能写 local 就写 local——更快,也避免污染全局 |
x = 1(无 local) | 创建全局变量,存进 _G。拼错名字就是这么悄无声息地生效的(01 章) |
local function f() | 等价于先声明再赋值,因此函数内可以递归调用自己;写成 local f = function() 就不行 |
local x <const> | 编译期常量,不可再赋值(5.4 起,10 章) |
local f <close> | 离开作用域时自动调 __close(5.4 起,10 章) |
| 上限 | 一个函数里最多 200 个局部变量;超了报 too many local variables |
0 是真值。从 C / Python / JS 转过来的人几乎都会在这里栽一次:if count then 对 count = 0 成立,想判「有没有」和判「是不是零」得分开写。同理 "" 也是真——Lua 里只有 nil 与 false 两个假值,没有第三个。nil 和「不存在」在 Lua 里是同一件事:t.missing 给 nil,把 t.k = nil 也就删掉了这个键。所以「这个键存在但值为 nil」这种状态表达不出来——需要区分时,约定一个哨兵值(如 NULL = {})代替。变量不带类型、值才带类型——先把 Lua 全部八种值认个脸熟,后面每一章都在讲其中一两种。
八种值,逐个认脸
Lua 动态类型:变量不带类型,类型属于值。共 8 种:
- nil 唯一值 nil,表示「无」
- boolean true / false
- number 含 integer 与 float 两个子类型
- string 不可变字节序列(8 位干净,可含 \0)
- function Lua 或 C 函数
- userdata 宿主 C 数据——full userdata 是 Lua 分配并受 GC 管理的一块内存(可带元表),light userdata 只是包了个裸 C 指针(无元表、不受 GC 管理);纯 Lua 代码里几乎不会自己创建它们,只会从 C 库拿到(见 17 章)
- thread 协程
- table 唯一的数据结构:关联数组
八种里你日常真正会碰到的只有五种
| 类型 | 日常频率 | 要注意的一点 |
|---|---|---|
nil | 极高 | 「不存在」与「值是 nil」无法区分 |
boolean | 高 | 只有 false 和 nil 为假 |
number | 高 | 底下分整数与浮点两个子类型 |
string | 高 | 不可变、8 位干净(可含 \0) |
table | 极高 | 唯一的数据结构 |
function | 高 | 一等值,可存进表、可作返回值 |
userdata | 低 | 只会从 C 库拿到(17 章) |
thread | 中 | 就是协程(11 章) |
- 「只有一种数据结构」是 Lua 最重要的一条设计:数组、字典、对象、类、模块、命名空间,全部由
table承担。整个语言因此小得能塞进任何地方,代价是这些用法之间没有类型上的区分,靠约定; nil兼职两个语义——「这个键没有」和「这个键的值是 nil」在 Lua 里是同一件事。所以表里存不了nil,也就有了后面「数组中间的洞」那一系列坑。
print(type(nil)) --> nil
print(type(true)) --> boolean
print(type(10)) --> number
print(type("hi")) --> string
print(type(print)) --> function
print(type({})) --> table
print(type(coroutine.create(function() end))) --> threadtype() 返回的是字符串:判断要写 type(x) == "nil"、type(x) == "number"——type(x) == nil 永远是 false(Lua 5.4/5.5);而且类型名拼错也不会报错,只会安静地判假。Lua 最需要提前记住的一条规则在这里:没写 local 的变量是全局的。它是本页出现频率最高的坑,先看清楚它,后面很多莫名其妙的 bug 就有解释了。
默认全局:Lua 的头号坑
- Lua 不要求声明变量。写
x = 1而没有local,就创建了一个全局变量; - 更麻烦的是读一个不存在的全局变量不报错,直接返回
nil。于是拼错名字既不会在赋值时报错、也不会在读取时报错,程序安静地算错; - 规矩很简单:凡是不打算给别处用的变量,一律写
local。这不是风格建议,是防御措施(也更快,见下)。
作用域规则
local的作用域从声明的下一条语句开始,到所属块(do/函数体/循环体/if分支)结束;- 正因为「从下一条语句开始」,
local x = x是合法且常用的写法:右侧的x还是外层那个,等号左侧的新x尚未生效——这是把全局变量「抓」成局部的惯用法; - 词法作用域意思是:一个函数能访问的外层变量,由它写在代码里的位置决定,而不是由谁调用它决定。被内层函数捕获的外层局部变量叫 upvalue(05 章闭包卡展开)。
全局变量到底存在哪
- 全局变量并不特殊,它们只是一张普通表的字段:这张表叫环境表,名字是
_ENV; - 所以
x = 1实际等价于_ENV.x = 1,而_G就指向这张全局环境表; - 这个设计的好处是环境可以被替换——把一段代码的
_ENV换成你自己造的表,就得到了一个沙箱。完整机制见 12 章「_ENV 与沙箱」,现在知道「全局变量 = 一张表的字段」即可。
local x = 10
do
local x = x -- 新 x = 外层的 10(右侧 x 尚未进入作用域)
print(x) --> 10
end
print(x) --> 10
-- 每次执行 local 都产生新变量 → 闭包捕获各自的副本(for 见 04 章、闭包见 05 章,此处先感受现象)
local fns = {}
for i = 1, 3 do
local y = i
fns[i] = function() return y end
end
print(fns[1](), fns[2](), fns[3]()) --> 1 2 3local 不会有任何提示——赋值成功、读取返回 nil、程序继续跑。把 count 拼成 cout,你会得到一个新全局变量和一个永远是初值的 count,而报错(如果有的话)出现在几十行之外用到 count 的地方。三个对策:① 一切局部变量写 local;② 编辑器装 lua-language-server,它会标出可疑的全局赋值;③ Lua 5.5 起可以用 global 声明强制要求全局变量必须先声明(见 18 章),但 5.4 及 LuaJIT 上没有这个保险。local ≈ let(块级作用域),Lua 没有 var 那种函数级提升;而「不声明即全局」正是 JS 非严格模式的老毛病,Lua 把它保留成了默认行为。另外把频繁用到的全局提升为局部(local print = print)在热点循环里确实更快——因为读全局要查一次表,读局部直接命中寄存器;但这是优化手段,别一上来就到处这么写。一个 number 底下其实有 integer 与 float 两套表示——知道手里是哪种,除法和格式化才不会出意外。
一个 number,两套表示
5.3 起 number 拆成两个子类型(默认均为 64 位),行为可预测又能自动转换:
3 / 2→ 1.5(/总产生 float)7 // 2→ 3(//向下取整,同类型进同类型出)2 ^ 2→ 4.0(^总是 float)- 整数溢出按二进制补码回绕
四条规则,记住就不会算错
3 / 2 --> 1.5/总是产生 float,10/2 得 5.0 而不是 5 7 // 2 --> 3//向下取整,同类型进同类型出 2 ^ 2 --> 4.0^总是 float math.maxinteger + 1 --> math.mininteger 整数溢出按补码回绕
- 第一条最容易出事:拿
/的结果去做数组下标、去和整数比较、去string.format("%d", ...),都会出问题(14 章%d遇到 1.5 直接报错); - 判断手里是哪种用
math.type(x),返回"integer"/"float"/nil;要转换用math.tointeger(x),转不了返回nil(比//1安全); ==跨子类型仍按数学值比较:1 == 1.0为true。但作为表的键时它们是同一个键——t[1]与t[1.0]指向同一格,因为浮点键有整数值时会被规范化成整数;- 整数溢出不报错,这与本章的动态类型一样,是「小而快」的取舍:不做检查就没有检查的开销。
print(3 / 2) --> 1.5
print(7 // 2) --> 3
print(7.0 // 2) --> 3.0
print(2 ^ 10) --> 1024.0
print(math.type(3)) --> integer
print(math.type(3.0)) --> float
print(math.maxinteger + 1 == math.mininteger) --> true(回绕)
print(math.tointeger(3.0)) --> 3(可精确转整则转)a[2.0]=true 实际写入键 2;且 1.0 == 1。math.type(返回 "integer"/"float",非数字返回 nil);要把 2.0 收回整数用 math.tointeger——直接拿浮点冒充整数,如 string.format("%d", 2.5),会报 number has no integer representation(Lua 5.4/5.5)。字符串一旦创建就不可变,三种字面量写法各管一种场景。
三种字面量
字符串不可变(修改即产生新串)。三种字面量:引号串、长括号串 [[ ]](不转义、可跨行、忽略紧接的首个换行),以及 [=[ ]=] 用于内容含 ]] 的情形。
不可变意味着什么
- 「修改即产生新串」有一个直接的性能后果:循环里用
..累加字符串是 O(n²)——每次都要分配并拷贝一个新串。正确做法是把片段收进一张表,最后table.concat一次拼好; - 好处是字符串可以被内部化(interning):相同内容的短字符串在 Lua 里是同一个对象,所以
==比较是 O(1) 的指针比较,做表的键也很快; - 「8 位干净」的含义是字符串就是字节序列,可以含
\0——它不像 C 字符串那样以 0 结尾,因此能直接拿来装二进制数据(配string.pack/unpack,16 章); - 长括号串
[[ ]]有两个细节:不处理任何转义(写 Windows 路径、正则、HTML 片段时省心),以及忽略紧跟在开头的第一个换行(所以[[后面可以直接换行,不会多出一个空行)。内容含]]时升级成[=[ ]=],等号可以加任意多个。
local a = "换行:\n 制表:\t 引号:\""
local b = '单引号里也一样'
local c = [[
长括号串:原样保留,
不处理\n,可含 "引号"。]]
local d = [==[ 含 ]] 的内容用 [==[ ]==] ]==]
-- 数值转义
print("\65\66\67") --> ABC(十进制字节)
print("\x48\x49") --> HI(十六进制)
print("\u{4F60}\u{597D}") --> 你好(UTF-8 码点)#"你好" 是 6 不是 2,("你好"):sub(1,1) 切出来的是半个 UTF-8 字符(一个无效字节)。数字符、按字符定位请用 5.3 起的 utf8 库(utf8.len、utf8.offset)。.. 或 string.format。索引从 1 开始,s:sub(1,3) 取前三字节。Lua 判真假只有一条规则,而它和你从别的语言带来的直觉都不一样。
一条规则
Lua 的假值(false values)只有 nil 和 false。其它一切——包括 0、""、{}——都为真。条件判断与 and/or 的求值都遵循此规则。
0 和空串为真,这条从别的语言来一定会踩
- Lua 的假值只有两个:
nil和false。0、""、{}、0/0(NaN)全部为真; - 所以
if count then在 count 为 0 时照样进分支——从 JS/Python/C 迁过来的人最常在这里出错,正确写法是if count > 0 then; - 这条规则也决定了
and/or的行为:a or b在 a 为0时返回 0(不是 b)。所以x = x or default这个惯用法只在「x 可能是 nil/false」时安全,用它给数字或字符串兜底会在 0 和 "" 上失效; - 好处是规则极简且无歧义:不需要记一张真值转换表,也不会出现 JS 里
[] == false那类怪事。
if 0 then print("0 是真") end --> 0 是真
if "" then print("空串是真") end --> 空串是真
if not nil then print("nil 为假") end --> nil 为假
-- 惯用法:默认值
local function greet(name)
name = name or "客人" -- name 为 nil/false 时取默认
return "你好," .. name
end0/""/NaN 均为 falsy,Lua 里它们都是 truthy。x = x or 默认值 是最常用的缺省参数写法,但当 false 是合法取值时会被误替换——这种场合改写 if x == nil then x = 默认值 end。注释就两种形态,值得看一眼的是块注释在哪里收尾。
两种形态
行注释以 -- 起始;块注释用 --[[ ... ]],内容含 ]] 时升级为 --[=[ ... ]=]。
块注释在哪收尾,是个真问题
--[[ ... ]]里如果出现]](比如注释掉的代码里有t[a[i]]),注释会提前结束,后面的内容变成语法错误;- 解法是升级成
--[=[ ... ]=],等号数量任意,只要与结尾配对; - 一个流传很广的小技巧:
--[[开头、--]]结尾。这样在开头那行前面再加一个-变成---[[,整块就从「被注释」翻转成「生效」——两行都成了行注释; - Lua 没有文档注释的语言级约定,社区惯例是 LDoc / LuaDoc 那套
---起头的格式。
-- 这是行注释
--[[ 这是
多行块注释 ]]
--[==[ 块注释里可以出现 ]] 而不提前结束 ]==]
-- 小技巧:用行首减号(横线)数量快速开关一段块注释
---[[
print("加一个 - 就能启用这段代码")
--]]]] 就提前收尾:--[[ local t = a[b[1]] ]] 会报 unexpected symbol near ']'(Lua 5.4/5.5)——注释掉含 ]] 的代码,要升级成 --[==[ ... ]==]。--- 起头的注解注释(---@param、---@return)能给函数标类型,补全与静态检查立刻上一个台阶——纯动态的 Lua 尤其值这一手。运算符与表达式
本章覆盖:算术(含 // 与取模的符号规则)、关系、逻辑(and / or 返回操作数而非布尔)、位运算、.. 与 #,以及从低到高的完整优先级表——其中只有 .. 和 ^ 是右结合。
运算符本身没有意外,容易出错的是三处:and / or 返回的不是布尔值、% 的符号跟除数走、^ 永远给浮点。
算术
| 运算符 | 含义 | 要点 |
|---|---|---|
+ - * | 加减乘 | 两个 integer 得 integer;有一个 float 就全转 float |
/ | 除 | 结果永远是 float,4 / 2 得 2.0 |
// | 向下取整除(5.3 起) | 向负无穷取整:-7 // 2 得 -4 而不是 -3 |
% | 取模 | 结果符号跟除数:-7 % 3 得 2。这与 C / Java 不同 |
^ | 乘幂 | 永远给 float(2^2 是 4.0),且右结合 |
-x | 取负 | — |
关系与逻辑
| 运算符 | 含义 | 要点 |
|---|---|---|
== ~= | 等于 / 不等于 | 不是 !=。类型不同直接判不等,不做隐式转换:"1" == 1 为 false |
< > <= >= | 比较 | 只能数与数、串与串比;混着比报 attempt to compare。表要比较得定义 __lt(07 章) |
and | 逻辑与 | 返回值不是布尔:左边为假返左边,否则返右边 |
or | 逻辑或 | 左边为真返左边,否则返右边。x = x or 默认值 是最常见的惯用法 |
not | 逻辑非 | 这个确实返回布尔 |
a and b or c | 三元运算的替代 | 有坑:b 为 false 或 nil 时会错误地落到 c |
位运算(5.3 起)· 连接 · 长度
| 运算符 | 含义 | 要点 |
|---|---|---|
& | ~ | 与 / 或 / 异或 | 操作数必须能无损转成整数,否则报错 |
~x | 按位取反(一元) | 与二元的异或共用同一个符号,看位置区分 |
<< >> | 左移 / 右移 | >> 是逻辑右移,补 0,不是算术右移;移位数 ≥ 64 得 0 |
.. | 字符串连接 | 右结合;数字自动转串;两侧留空格(1..2 报 malformed number) |
# | 长度 | 字符串给字节数;表只对无洞序列有定义(06 章) |
位运算在 LuaJIT(5.1 语法)上不存在,那边用 bit 库的函数(00 章版本表)。
优先级:从低到高
| 级别 | 运算符 |
|---|---|
| 最低 | or |
and | |
< > <= >= ~= == | |
| | |
~(二元异或) | |
& | |
<< >> | |
..(右结合) | |
+ - | |
* / // % | |
not # - ~(一元) | |
| 最高 | ^(右结合) |
只有两处右结合:.. 与 ^。所以 2^3^2 是 2^(3^2) = 512,不是 64。
a and b or c 不是安全的三元运算符:当 b 本身是 false 或 nil 时,整个表达式会落到 c。flag and false or true 永远得 true。真要分支就写 if——Lua 没有三元运算符,硬凑一个只会埋雷。x = x or 默认值 能用是因为 or 返回操作数本身而非布尔。同理 t = t or {} 是最常见的「参数可选」写法。但当默认值需要在 false 也生效时这招失效——那时只能老实写 if x == nil then。Lua 的算术符号和多数语言一样,但有两处需要单独记:除法总是产生小数,以及 5.3 起整数和浮点是两个子类型。
除法的三个符号
/永远返回浮点数——7/2是3.5,4/2也是2.0而不是2;//是向下取整除法(5.3 引入):7//2得3;对负数是向下取整而非截断,-7//2得-4;%取模,结果符号跟除数走:-7%3得2(不是 -1),这点和 C/Java 不同。
幂与其他
^是幂运算,总是返回浮点:2^10得1024.0;- 整数除以整数用
//才能保持整数类型,这在做索引计算时很要紧; - 字符串会在算术场合自动转成数字:
"5" + 1得6——方便但也容易掩盖类型错误。
print(5 % 3) --> 2
print(-5 % 3) --> 1 (结果符号随除数 3)
print(5 % -3) --> -1
print(5.5 % 2) --> 1.5 (浮点取模也可)
print(-7 // 2) --> -4 (向下取整,不是截断的 -3)"10" + 5 == 15),但可读性差,建议显式 tonumber。1/0 得 inf、0/0 得 nan(且 nan 不等于自身),而整数 1//0 直接报 attempt to divide by zero、1%0 报 attempt to perform 'n%0'(Lua 5.4/5.5)——写通用算式时别默认「除零不会崩」。关系运算没什么意外,逻辑运算才是 Lua 的特色:and/or 不返回布尔值,而是返回其中一个操作数。
关系运算
==~=(不等号是~=而非!=)<><=>=;- 不同类型一律不相等,且不会自动转换:
"1" == 1是false; - 表、函数按引用比较——两个内容相同的表
{1} == {1}是false(除非定义了__eq,见 07 章)。
and / or 返回值而非布尔
a and b:a 为假返回 a,否则返回 b;a or b:a 为真返回 a,否则返回 b;- 两者都短路求值,右侧可能根本不执行;
- 由此得到 Lua 最常见的两个惯用法:默认值
local n = arg or 10,以及三元替代cond and X or Y——后者有个陷阱,见下方 pitfall。
print(1 ~= 2) --> true
print(nil and 5) --> nil
print(3 and 5) --> 5
print(false or "备用") --> 备用
-- 三元表达式的惯用替代
local max = (a > b) and a or b -- 注意:当 a 为 nil/false 时会失效
-- 不同类型不可比大小(会报错),但可比相等
print(1 == "1") --> false(类型不同,不相等)cond and X or Y 当 X 本身可能是 nil/false 时会返回 Y,不等价于真三元。1 < "1" 报 attempt to compare number with string(Lua 5.4/5.5)——外部读进来的「字符串数字」,先 tonumber 再比较。位运算符是 5.3 才引入的(此前只能靠 bit32 库或 LuaJIT 的 bit)。用之前先确认运行时版本。
六个运算符
&与、|或、~异或(放在两个操作数中间时是异或,放在前面是按位取反)、<<左移、>>右移;- 操作数必须能无损转成整数,否则报错——
1.5 & 1会抛「number has no integer representation」; - 移位是逻辑移位(补 0),不是算术移位;移动位数达到或超过 64 位时结果为 0(
1 << 64得到 0),不会像 C 那样是未定义行为。
版本注意
- LuaJIT 基于 5.1,没有这些运算符——那边要用
bit库的bit.band(a,b)之类; - 写要跨运行时的库时,位运算是最容易踩版本墙的地方之一。
print(0xF0 & 0x0F) --> 0
print(0xF0 | 0x0F) --> 255
print(5 ~ 3) --> 6 (二元 ~ 是异或)
print(~0) --> -1 (一元 ~ 是取反)
print(1 << 4) --> 16
print(256 >> 2) --> 64>> 是逻辑移位,负数右移不保符号:-1 >> 1 得 9223372036854775807,不是 C 程序员预期的 -1(Lua 5.4/5.5)。需要算术右移,用 // 除以 2 的幂(-8 // 2 得 -4)。两个看着简单、实际最容易出事的运算符:拼接用 ..(不是 +),而 # 的含义比「长度」微妙得多。
.. 字符串连接
- 数字会自动转字符串参与拼接;
nil和布尔不会,拼它们直接报attempt to concatenate a nil value; ..两侧建议留空格:1..2会被词法分析当成小数点而报错;- 循环里反复
..拼接是 O(n²)——每次都新建字符串。拼大量文本请把片段放进表再table.concat。
# 长度运算符
- 对字符串是字节数(不是字符数!UTF-8 中文一个字算 3 字节,要数字符用
utf8.len); - 对表是「序列长度」——只有当表是从 1 开始、中间无 nil 的连续序列时,结果才是你以为的那个数;
- 一旦中间有 nil(「洞」),
#的结果是未定义的:#{1,2,nil,4}在 Lua 5.5 返回2,但标准允许它返回 4。详见 06 章「序列、长度与洞」。
print("值 = " .. 42) --> 值 = 42
print(#"héllo") --> 6 (UTF-8:é 占 2 字节)
print(#({10,20,30})) --> 3
-- 大量拼接时用 table.concat 更高效(避免中间串)
local parts = {}
for i = 1, 5 do parts[i] = "行" .. i end
print(table.concat(parts, "\n"))#t 结果未定义(可能是任一边界)。序列才可靠。# 的主场:t[#t] 读栈顶、t[#t+1] = v 压栈、t[#t] = nil 弹栈——这三个惯用法都只在无洞的序列上可靠。优先级表不用背全——记住几个反直觉的组合,其余拿不准就加括号。
从高到低
优先级由低到高:
orand< > <= >= ~= ==|→~→&→<< >>..(右结合)+ -* / // %- 一元:
not # - ~ ^(右结合,优先级高于一元)
三处与常见直觉不同
..与^是右结合:2^3^2是2^(3^2)= 512 而不是 64;a..b..c从右往左拼(对结果没影响,但对元方法调用顺序有);^的优先级高于一元负号:-2^2是-(2^2)= -4,不是 4。这与数学写法一致,但和某些语言相反;..的优先级低于算术:"n=" .. 1+2得"n=3",不会先拼再加。但比比较运算符高,所以a .. b == c是(a..b) == c;- 拿不准就加括号——这不是水平问题,是可读性问题:读代码的人也在心里查同一张表。
print(2 ^ 2 ^ 3) --> 256.0 (右结合:2^(2^3)=2^8)
print(-2 ^ 2) --> -4.0 (^ 高于一元负:-(2^2))
print(1 .. 2 .. 3) --> 123 (.. 右结合)
print(2 + 3 * 4) --> 14not x == y 被解析成 (not x) == y,not 1 == 1 得 false——想表达「不等」用 ~=,别写 not ... ==。.. 与 ^ 是右结合,其余二元运算左结合。拿不准就加括号。控制结构
本章覆盖:if、while / repeat、数值 for 与泛型 for、break 与 goto,以及 ipairs 与 pairs 的分工。三个「没有」要单独记:没有 switch、没有 continue、没有三元运算符,各有惯用替代。
控制结构一共五种,全部以 end 收尾(repeat 除外,它用 until)。Lua 没有 switch、没有 continue、没有三元运算符——三者各有惯用替代。
条件与循环
| 结构 | 写法 | 要点 |
|---|---|---|
| 条件 | if c then … elseif c2 then … else … end | 只有 nil 与 false 为假;elseif 是一个词 |
| 先判后做 | while c do … end | — |
| 先做后判 | repeat … until c | 循环体里声明的局部变量在 until 里仍可见——这是 Lua 独有的作用域规则,也正是它有用的地方 |
| 数值 for | for i = a, b, step do … end | 三个表达式只求值一次;step 默认 1、不能为 0;5.5 起循环变量只读 |
| 泛型 for | for k, v in 迭代器 do … end | 本质是「迭代函数 + 状态 + 控制变量」三元组(5.4 起可带第四个 to-be-closed 值) |
遍历表:ipairs 还是 pairs
ipairs(t) | pairs(t) | |
|---|---|---|
| 遍历什么 | t[1] 起的连续整数键 | 所有键,含字符串键 |
| 遇到 nil | 立刻停——数组中间有洞就只走到洞前 | 跳过不存在的键,继续 |
| 顺序 | 1、2、3… 有序 | 无序,且两次运行顺序可能不同 |
| 用在 | 确定是序列时 | 字典,或不确定有没有洞时 |
要「按顺序遍历字典」只能自己来:先把键收集进一个数组,table.sort 后再遍历(06 章)。
跳转,以及三个「没有」
| 要做的事 | Lua 怎么写 |
|---|---|
| 跳出循环 | break。只能跳一层,且必须是块里的最后一句(或用 do break end) |
| continue | 没有。写 goto continue + 循环体末尾放 ::continue::——这是官方推荐的写法 |
| switch | 没有。用表当跳转表:local handlers = { a = f1, b = f2 } 再 handlers[k]() |
| 三元运算符 | 没有。a and b or c 在 b 可能为假时会出错(03 章) |
| 跳出多层 | goto 到外层的标签,或把内层包成函数用 return |
goto 的限制 | 只能向前跳或跳出块,不能跳进块、不能跳过局部变量的声明 |
pairs 遍历中删除键是允许的,插入新键则是未定义行为——可能报 invalid key to 'next',也可能静默漏掉元素。要边遍历边改结构,先把要改的键收集到一个数组里,遍历结束后再动手。for 的三个表达式只在开始时求值一次:for i = 1, #t do 里的 #t 不会每轮重算,循环中往 t 里加元素也不会延长循环。这既是性能优势,也是「循环没跑够」时第一个该想到的原因。语法上没有惊喜,真正要记的是「什么算真」——Lua 的真值规则和几乎所有动态语言都不同。
写法
if 条件 then ... elseif 条件 then ... else ... end,用elseif一个词(写成else if会要求多一个end);- 条件不需要括号,块结尾一律用
end; - Lua 没有三元运算符,惯用
cond and X or Y代替(陷阱见 03 章逻辑运算卡)。
真值规则:只有两个假值
- 只有
false和nil是假,其余全是真; 0是真、""(空串)是真、{}(空表)也是真——这是从 JS/Python/C 转过来的人最容易栽的一条;- 所以判断「有没有元素」不能写
if t then(空表也为真),要写if #t > 0 then或if next(t) then。
local n = 3
if n < 0 then
print("负")
elseif n == 0 then
print("零")
else
print("正")
end
-- 表分派替代 switch
local handlers = {
start = function() return "开始" end,
stop = function() return "停止" end,
}
local h = handlers["start"]
print(h and h() or "未知命令")else if 分开写是合法的嵌套 if,不会当场报错——要到文件末尾才冒出 'end' expected (to close 'if' at line 1) near <eof>(Lua 5.4/5.5),报错行离写错的那行可能隔着整个文件。记住 elseif 是一个词。if x = f() then 在 Lua 是语法错误,报 'then' expected near '='(Lua 5.4/5.5)——先 local x = f() 再判断。两种条件循环,差别不只是「先判断还是后判断」——repeat 的作用域规则是 Lua 特有的一个贴心设计。
while:先判断
while 条件 do ... end,条件为假时退出;条件一开始就为假则一次都不执行。
repeat-until:后判断,且条件能看见块内变量
repeat ... until 条件,至少执行一次,条件为真时退出(注意是 until,语义与 while 相反);- 关键特性:
until的条件表达式可以访问循环体里声明的local——因为块的作用域延伸到了 until 之后。C 的do-while做不到这点; - 这让「算出一个值再决定要不要继续」的写法很自然,不用把变量提到循环外面。
local i = 1
while i <= 3 do
print(i); i = i + 1
end
repeat
local line = "读到内容" -- until 能看到 line
print(line)
until line == "读到内容"repeat 的 until 条件属于循环体作用域,所以想用 goto ::continue:: 模拟 continue 时,label 放在 until 之前不算「块尾」,只要跨过一个 local 声明就编译报错——这种循环改写成 while 更省事(label 的位置规则见本章末卡)。continue;用 goto 跳到循环末尾的标签实现(见「break / goto / label」)。for i = 起, 止, 步长 do——注意 Lua 的区间是闭区间,两端都包含。
三个参数
for i = 1, 10 do跑 10 次(1 到 10,含 10),步长省略时为 1;- 步长可以是负数:
for i = 10, 1, -1 do倒着数;步长为 0 会报错; - 三个表达式只在循环开始前求值一次——循环体里改动它们引用的变量不会影响循环次数。
循环变量的性质
i是每轮新建的局部变量,作用域仅限循环体,循环结束后不存在;- 因此在循环里创建闭包捕获
i时,每轮捕获的是不同的变量(这点和早期 JS 的var相反,不会踩「所有闭包都拿到最后一个值」的坑); - Lua 5.5 起循环变量是只读的:在循环体里给
i赋值会报attempt to assign to const variable 'i';5.4 及以前允许改(但改了也不影响循环推进)。
for i = 1, 5 do io.write(i, " ") end --> 1 2 3 4 5
print()
for i = 10, 1, -2 do io.write(i, " ") end --> 10 8 6 4 2
print()
for x = 0.0, 1.0, 0.25 do io.write(x, " ") end --> 0.0 0.25 0.5 0.75 1.0for i = 10, 1 do 步长默认 1、起点已越过终点,循环体一次都不执行(Lua 5.4/5.5),整段逻辑被安静跳过——倒着数必须写全 for i = 10, 1, -1 do。i = i + 1 会编译报错。for k, v in pairs(t) do 是日常最常写的一种循环。它背后是一套迭代器协议,理解了它就能写自己的迭代器。
先记住两个常用的
ipairs(t):从 1 开始按整数下标遍历,遇到第一个 nil 就停——用来遍历「数组部分」;pairs(t):遍历所有键值对,顺序不确定(哈希表本就无序,且同一份代码两次运行顺序可能不同);- 所以:要有序就用
ipairs或数值 for;要全量就用pairs,但别依赖它的顺序。
协议:explist 求出三个值
for vars in explist do中的 explist 会求出迭代函数、状态、控制变量初值三个值(5.4 起还可有第四个「关闭值」,属<close>范畴,见 10 章);- 每轮以 (状态, 控制变量) 调用迭代函数,返回值赋给循环变量;第一个返回值为 nil 时循环结束;
- 所以
pairs(t)其实就是返回了next, t, nil这三个值——自己写迭代器也只要返回这样一组即可。
local t = {a=1, b=2, c=3}
for k, v in pairs(t) do print(k, v) end -- 全部键值(无序)
for i, v in ipairs({"x","y","z"}) do print(i, v) end -- 序列 1..n
-- 自定义迭代器:闭包工厂
local function range(n)
local i = 0
return function() -- 每次调用返回下一个,或 nil
i = i + 1
if i <= n then return i end
end
end
for v in range(3) do print(v) end --> 1 2 3pairs 顺序不保证;要有序请对键排序后遍历。ipairs 遇到第一个 nil 即停。pairs 遍历中新增键是未定义行为(官方手册 next 条目)——要批量增删,先把键收集到另一张表,遍历完再动手。Lua 的跳转设施很克制:只有 break 和 goto,没有 continue。
break
- 跳出最内层循环,Lua 没有带标签的 break;
- 要跳出多层循环,用
goto跳到外层循环之后的标签。
goto 与 label(5.2 引入)
- 语法是
goto 名字和::名字::; - 最主要的用途就是模拟
continue:在循环体末尾放一个::continue::标签,需要跳过本轮时goto continue; - 限制:只能在同一函数内跳转,且不能跳进某个局部变量的作用域(否则那个变量会处于未初始化状态)——这条限制正是 Lua 敢开放 goto 的原因。
for i = 1, 5 do
if i == 4 then break end
print(i)
end
-- 模拟 continue
for i = 1, 5 do
if i % 2 == 0 then goto next end
print("奇数", i)
::next::
end::continue:: 要想让 goto 跨过中间的 local 声明,必须是块内最后一条语句:label 后面再跟任何语句,编译即报 jumps into the scope of local 'v'(Lua 5.4;5.5 措辞无「local」)。::continue::,goto continue 跳到最内层可见的那个(Lua 5.4/5.5)——不必为内外层起不同的名字。函数
本章覆盖:五种定义写法、多返回值什么时候展开什么时候被截断、变参与 select / table.pack、闭包与 upvalue、尾调用的判据,以及冒号语法糖。多值规则是这门语言里最容易「少拿到一个值」的地方。
函数是 Lua 的第一张王牌(另一张是 table)。要点集中在多返回值的展开规则——它是这门语言里最容易「少拿到一个值」的地方。
五种定义写法
| 写法 | 等价于 |
|---|---|
function f(x) … end | f = function(x) … end——创建的是全局变量 |
local function f(x) … end | 先声明后赋值,因此函数体内可以递归调用 f |
local f = function(x) … end | 同上但不能递归——赋值时 f 还不在作用域里 |
function t.f(x) … end | t.f = function(x) … end |
function t:f(x) … end | 隐式多一个首参 self:t.f = function(self, x) … end |
多返回值:什么时候展开,什么时候截断
| 场景 | 结果 |
|---|---|
local a, b = f() | 按位取;不够补 nil,多的丢掉 |
g(f()) | 全部展开当参数——只要 f() 在最后一个位置 |
g(f(), 1) | 只取第一个——不在末位就被截成一个值 |
{ f() } | 全部收进表;但若 f 返回 nil 会造成洞,改用 table.pack |
(f()) | 加一层括号就强制只取第一个——这是个容易看漏的语法 |
return f() | 原样透传全部返回值,而且是尾调用 |
print(f(), g()) | f 被截成一个值,g 全展开 |
变参与工具函数
| 写法 | 作用 |
|---|---|
function f(...) | 收集任意多个参数 |
local a, b = ... | 取前两个 |
select("#", ...) | 参数个数,含 nil——数 #{...} 会被洞骗到 |
select(n, ...) | 返回第 n 个及其后所有参数 |
table.pack(...) | 打包成表,并带上 n 字段记录真实个数 |
table.unpack(t, i, j) | 展开成多值;有洞时要显式给 i, j |
| 具名变参表(5.5) | 直接给变参一个表名,省掉 pack(18 章) |
闭包与尾调用
| 概念 | 要点 |
|---|---|
| upvalue | 闭包捕获的外层局部变量。捕获的是变量本身而不是值——多个闭包捕获同一个变量会共享它 |
| 循环里造闭包 | 每轮迭代的循环变量是新的,所以每个闭包捕获到的是各自那一份 |
| 尾调用 | return f(x) 不增长调用栈——可以无限深递归 |
| 不是尾调用 | return f(x) + 1、return (f(x))、return f(x), 1 都不是——返回后还有事要做 |
| 代价 | 尾调用不留栈帧,所以也不出现在 traceback 里(会显示 (...tail calls...)) |
#{...} 和 {...} 都会被 nil 骗。传进来的参数里只要有一个 nil,表里就有洞,# 的结果不可靠。数个数一律用 select("#", ...),需要保存就用 table.pack——它带的 n 字段是唯一可靠的计数。obj:method(a) 只是 obj.method(obj, a) 的语法糖,定义端的 function t:f() 同理。定义用冒号、调用用点号(或反之)是最常见的一类 self 为 nil 报错——两端保持一致就不会出事(08 章)。Lua 的函数是一等值:可以赋给变量、放进表、当参数传、当返回值返回。这不是「支持函数式编程」的口号,而是理解 Lua 很多写法的前提。
函数就是值
function f() end只是f = function() end的语法糖——函数没有「名字」,名字属于变量;- 所以
local function f()定义的是局部函数(且能递归调用自己,因为 local 先声明再赋值); - 写在表里就成了「模块」和「方法」:
function M.foo()、function obj:bar(),本质都是往表里塞一个函数值。
调用的两处语法糖
- 单个字符串或表字面量作参数时可省略括号:
require "mod"、f{1,2}——标准库和很多 DSL 大量使用这个写法; - 参数个数不匹配不报错:多传的丢弃,少传的补
nil。这很灵活,但也意味着打错参数个数不会有任何提示。
local function add(a, b) return a + b end -- 推荐:支持递归自引用
local sub = function(a, b) return a - b end
local ops = { add = add, sub = sub }
print(ops.add(3, 4)) --> 7
-- 高阶函数
local function apply(f, x) return f(x) end
print(apply(function(n) return n*n end, 5)) --> 25add(1) 报 attempt to perform arithmetic on a nil value (local 'b')——错误指向函数内部而非调用处,回溯要多看一层。local function f 先声明后赋值,故 f 内部可递归调用自己;local f = function 则不行。Lua 函数能返回任意多个值,这是它区别于多数语言的一个核心设计——标准库大量依赖它(如 pcall、string.find)。
多返回值的截断规则
- 在表达式列表的最后一个位置,多返回值会全部展开;
- 在其他任何位置,只保留第一个值:
print(f(), 1)里 f 只贡献一个值,print(1, f())才全展开; - 加一层括号会强制只取第一个:
(f())永远是单值——这是个刻意设计的「截断」写法。
接收与计数
- 多余的返回值被丢弃,不够的补
nil;用_占位跳过不关心的返回值(_只是个普通变量名的约定); select("#", ...)数个数,select(n, ...)取第 n 个之后的所有值;table.pack(...)把变参装成表并带n字段,table.unpack(t)反向展开——处理含 nil 的变参时必须用它们,原因见本章首卡的速查表。
local function minmax(t)
return math.min(table.unpack(t)), math.max(table.unpack(t))
end
local lo, hi = minmax({3,1,4,1,5})
print(lo, hi) --> 1 5
print((minmax({3,1,4}))) -- 只取第一个(括号截断)
print(select("#", 1,2,nil)) --> 3
print(select(2, "a","b","c")) --> b c{f()} 有 3 个元素而 {f(), 0} 只有 2 个——拼表、传参时把多值调用夹在中间是静默丢数据的经典来源。... 表示「剩余的所有参数」。它不是一个表,而是一组值——这个区别是下面所有坑的来源。
基本用法
- 参数列表写
function f(a, ...),函数体里用...引用剩余参数; ...遵守和多返回值一样的截断规则:只在最后位置才全部展开;- Lua 5.5 起可以给它命名:
function f(...args)(名字前没有逗号),得到一张带n字段的表(变量本身 const 不可重赋值,但表内容可改,Lua 5.5)——见 18 章。
含 nil 时必须用 select
#{...}会先把变参装进表再取长度,一旦参数里有 nil 就产生「洞」,长度不可靠;f(1, nil, 3)中select("#", ...)得 3(正确),而#{...}在 5.5 得 1、5.4 得 3——未定义行为,换版本答案就变;- 所以要数变参个数一律用
select("#", ...),要装表用table.pack(...)(它带准确的n字段)。
local function sum(...)
local s = 0
for _, v in ipairs({...}) do s = s + v end
return s
end
print(sum(1, 2, 3, 4)) --> 10
-- 含 nil 时用 table.pack 保留个数
local function count(...)
return table.pack(...).n
end
print(count(1, nil, 3)) --> 3{...} 遇 nil 会产生洞,长度不可靠;需要精确个数用 table.pack / select("#",...)。for i = 1, select("#", ...) do local v = (select(i, ...)) end——select(i, ...) 返回从第 i 个起的所有值,套一层括号截成单值(Lua 5.4/5.5 洞也不丢);原样转发则直接 return f(...)。内层函数可以访问并持续持有外层的局部变量,这样的函数叫闭包,被捕获的那个变量叫 upvalue。Lua 的闭包实现得很彻底,是它很多惯用法的基础。
捕获的是变量,不是值
- 闭包持有的是变量本身——外层函数已经返回了,那个局部变量依然活着,且闭包对它的修改是持久的;
- 所以闭包天然能做私有状态:把变量藏在外层函数里,只暴露操作它的闭包,外部无法直接访问(Lua 的对象封装常用这招);
- 多个闭包捕获同一个变量时,它们共享同一份——这既是特性(协同修改)也是坑(意外共享)。
典型用途
- 计数器 / 累加器:把状态封在闭包里,不污染全局;
- 迭代器:闭包保存遍历位置,配合泛型 for 使用(见 04 章);
- 回调与柯里化:预先绑定一部分参数,返回一个新函数。
local function counter()
local n = 0
return function() n = n + 1; return n end, -- 递增
function() return n end -- 读取,与上者共享 n
end
local inc, get = counter()
print(inc(), inc(), get()) --> 1 2 2local 就是同一个变量——闭包全部共享,最后全部读到终值(全是 4)。Lua 保证尾调用不增长调用栈——这不是「优化」(可能做也可能不做),而是语言规范强制要求的行为。
什么算尾调用
- 必须是
return f(...)这个精确形式:函数返回值直接就是另一个调用的返回值,中间不做任何加工; return f() + 1不是尾调用(还要做加法);return (f())也不是(括号是一次截断操作);- 满足条件时,当前函数的栈帧被复用而非新增——因此可以写无限深的递归而不会栈溢出。
代价
- 被复用的栈帧从调用栈上消失了,所以出错时的 traceback 会看到
(...tail calls...)而非完整链路; - 调试递归逻辑时这会让定位变难——这是拿可调试性换的无限递归能力。
-- 尾递归:即使 n 很大也不爆栈
local function loop(n)
if n == 0 then return "done" end
return loop(n - 1) -- 尾调用
end
print(loop(1000000)) --> done
-- 注意:return f(x) + 1 不是尾调用(还要做加法)return f(...) 的精确形式。return (f(x)) 或 return f(x)+1 都不算。(...tail calls...) 看不到调用链?临时把 return f(x) 改成 local r = f(x) return r 破坏尾调用,完整栈帧就回来了(调完记得改回去)。Lua 没有内建的类和对象。所谓「方法」,只是一个存在表里的函数加上一点语法糖——看穿这一层,08 章的元表 OOP 就不难了。
冒号做了什么
- 定义时
function obj:m(a)等价于function obj.m(self, a)——冒号自动加了一个名为self的首参; - 调用时
obj:m(1)等价于obj.m(obj, 1)——冒号自动把点号左边的对象作为首参传入; - 两处的糖是对称的,所以定义和调用必须都用冒号,或都用点号并手写 self。
最常见的错误
- 定义用冒号、调用用点号:
obj.m(1)——self收到的是1,方法内访问self.xxx就会报 index a nil/number value; - 反过来定义用点号、调用用冒号,则第一个真实参数被
self占掉,参数整体错位; - 记法:冒号 = 「带上我自己」,两边保持一致即可。
local acc = { total = 0 }
function acc:add(x) -- 隐式 self 参数
self.total = self.total + x
return self -- 返回 self 便于链式
end
acc:add(3):add(4)
print(acc.total) --> 7
-- 等价的点语法
acc.add(acc, 10)obj.inc() 报 attempt to index a nil value (local 'self'),obj.inc(5) 报 attempt to index a number value (local 'self')——错误信息里出现 local 'self',基本就是点号冒号混用。. 定义就要手动接 self;混用是常见 bug 源。表(Table)
本章覆盖:四种构造写法、#t 的契约(只对无洞序列有定义)、table 库全部函数与各自的边界、以及引用语义带来的浅拷贝 / 身份比较问题。表是 Lua 唯一的数据结构,数组、字典、对象、模块、集合全是它。
整门语言只有这一种数据结构:数组、字典、对象、模块、集合全是表。它内部其实有两副骨架——数组部分与哈希部分(15 章),而「洞」就是这两副骨架错位的产物。
构造与访问
| 写法 | 含义 |
|---|---|
{} | 空表。每次求值都是一个新表 |
{10, 20, 30} | 数组部分,键为 1, 2, 3 |
{x = 1, y = 2} | 等价于 {["x"] = 1, …},键是字符串 |
{["k-1"] = 1, [10] = "a"} | 任意键;键不是合法标识符时必须用方括号 |
{10, 20, x = 1} | 两部分可混写 |
t.k · t["k"] | 完全等价 |
t[1] | 下标从 1 开始 |
t.k = nil | 删除这个键——赋 nil 与「不存在」是同一件事 |
table.create(n) | 预分配数组空间(5.5,18 章) |
#t 的契约:只对无洞序列有定义
| 表 | #t |
|---|---|
{1, 2, 3} | 3 ✓ |
{1, nil, 3} | 1 或 3 都合法——有洞时结果未定义,别依赖 |
{x = 1} | 0——字典部分不计入长度 |
{} 后 t[2] = "a" | 0 或 2:跳着赋值就造出了洞 |
「序列」的定义是:键恰好是 1..n 且中间没有 nil。只有序列才能安全用 #、ipairs、table.concat、table.sort——本页凡说「序列」都是这个意思。自己维护一个 n 字段,是绕开整类问题最省事的办法。
table 库
| 函数 | 作用 | 要点 |
|---|---|---|
table.insert(t, v) | 追加到末尾 | 等价于 t[#t+1] = v,但表有洞时两者行为不同 |
table.insert(t, pos, v) | 插入并后移 | O(n);pos 越界报 position out of bounds |
table.remove(t, pos) | 删除并前移,返回被删的值 | 不给 pos 就删末尾 |
table.concat(t, sep, i, j) | 拼成字符串 | 元素必须是字符串或数字,遇别的类型报错;遇洞会静默截断(15 章) |
table.sort(t, cmp) | 原地排序 | 不稳定;cmp 必须是严格小于;抛错后表停在半排序态(15 章) |
table.unpack(t, i, j) | 展开成多值 | 05 章 |
table.pack(...) | 打包并记 n | 05 章 |
table.move(a1, f, e, t, a2) | 区间搬运 | 5.3 起;支持同表内重叠区间,是手写 shift 的正解 |
引用语义
| 写法 | 发生了什么 |
|---|---|
local b = a | 两个名字指向同一个表——改 b 就是改 a |
a == b | 比较身份而非内容:两个内容相同的表不相等(除非定义 __eq,07 章) |
| 浅拷贝 | for k, v in pairs(t) do c[k] = v end——嵌套的表仍是共享的 |
| 深拷贝 | 标准库没有,要自己递归,还得处理环引用 |
| 当键用 | 表可以当键,按身份区分——这正是「哨兵值 NULL = {}」的原理 |
function f(t) t.x = 1 end 会改到调用方的表。想不影响外部就在函数里先拷一份;反过来,需要返回「修改后的表」时通常不必返回——原地改已经生效了,多一个返回值反而让调用方误以为原表没变。set[v] = true,判存在写 if set[v] then,删除写 set[v] = nil。查询是 O(1),比在数组里线性找快得多——Lua 没有单独的 set 类型,因为不需要。表是 Lua 唯一的数据结构——数组、字典、对象、模块、命名空间全都是它。掌握表基本就掌握了 Lua 的一半。
一张表,两个部分
- 表内部同时维护数组部分(连续整数键)和哈希部分(其余键),由实现自动选择,你不用管;
- 键可以是除 nil 和 NaN 外的任意值——字符串、数字、布尔、甚至另一张表或函数都能当键;
- 把某个键赋成
nil就是删除它;读不存在的键返回nil而不报错。
构造与访问的写法
{1, 2, 3}数组式(自动编号 从 1 开始);{x = 1}记录式;{["复杂 key"] = 1}显式键式,三者可混用;t.name是t["name"]的语法糖——仅当键是合法标识符时可用点号;- 键
1(数字)和"1"(字符串)是两个不同的键,这点常被忽略。
local t = {
10, 20, 30, -- t[1]=10, t[2]=20, t[3]=30
name = "lua", -- t.name
["复杂 键"] = true, -- t["复杂 键"]
[100] = "x",
}
print(t[1], t.name, t[100]) --> 10 lua x
t.age = 30 -- 动态新增
t.name = nil -- 删除键(置 nil)cfg.a.b(a 不存在)报 attempt to index a nil value (field 'a')。另外读写不对称:读 t[nil] 合法返回 nil,写 t[nil] = v 却报 table index is nil(NaN 键同理报 table index is NaN)。nil,键可是任意类型(含表/函数)。Lua 里没有真正的「数组」,只有恰好满足某种形状的表。这个形状叫序列,而不满足它时 # 的行为是未定义的——这是表相关 bug 的最大来源。
什么是序列
- 键恰好是 1..n 的连续整数、中间没有 nil,这样的表才叫序列;
#t只对序列有明确定义,返回 n;- 中间出现 nil 就叫「洞」,此时
#t可以合法地返回任意一个「边界」——#{1,2,nil,4}在 Lua 5.5 返回 2,但换个版本或换个构造方式返回 4 也完全合规。
怎么避免
- 删中间元素用
table.remove(t, i)(它会把后面的元素前移,保持序列),不要直接t[i] = nil; - 需要「可能有空位」的集合时,别依赖
#,改用显式计数字段,或用pairs遍历; - 变参装表同理——用
table.pack(...)拿它的n字段,而不是#{...}。
local seq = {10, 20, 30}
print(#seq) --> 3
local holed = {1, 2, nil, 4}
print(#holed) --> 可能是 2 或 4(未定义!)
-- 安全遍历「含洞」表:自己记录长度,或用 table.pack 的 .n
local packed = table.pack(1, nil, 3)
for i = 1, packed.n do print(i, packed[i]) endfalse 或哨兵值,别用 nil。t[#t+1] = v(等价 table.insert(t, v)),只要表保持序列形状就可靠;判断表完全为空用 next(t) == nil——#t == 0 只说明序列部分为空,哈希部分可能还有键。增删、排序、拼接、批量搬移——序列的日常操作 table 库都替你写好了,别自己造轮子。
序列上的工具
table.insert(t, [pos,] v)·table.remove(t, [pos])table.concat(t, sep, i, j)拼接序列table.sort(t, cmp)原地排序table.pack(...)→ 带.n的表;table.unpack(t, i, j)table.move(a1, f, e, t, a2)批量搬移(5.3+)table.create(nseq [, nrec])预分配(5.5)
整个库都只对「序列」有定义
- 序列(sequence)的定义是「键恰好是 1..n 且没有洞」。
insert/remove/concat/sort/unpack全部只在序列上有定义——表里有洞时它们的行为是未定义的,不是「会报错」,而是「结果不保证」; - 这就是 07 章
#那条禁忌的延伸:一旦往数组中间放nil,这一整个库就不能信了。要表示「这一格是空的」,用一个哨兵值(如false)而不是nil; table.sort的比较函数必须是严格小于:写成<=会让排序算法在等值元素上判断矛盾,可能直接抛invalid order function for sorting;table.sort不稳定(等值元素的相对顺序不保证)。需要稳定排序得自己把下标编进比较函数里;- 5.5 新增的
table.create(n)可以预分配数组部分,省掉边填边扩容的多次 rehash(18 章)。
local t = {3, 1, 2}
table.insert(t, 4) -- 末尾追加 → {3,1,2,4}
table.insert(t, 1, 0) -- 位置 1 插入 → {0,3,1,2,4}
table.remove(t, 1) -- 移除位置 1 → {3,1,2,4}
table.sort(t, function(a,b) return a > b end) -- 降序
print(table.concat(t, "-")) --> 4-3-2-1table.remove 会把后面元素前移、跳过下一个:{1,2,2,3} 边 ipairs 边删 2,结果剩 {1,2,3}——漏删了一个。要按条件删就倒着来:for i = #t, 1, -1。table.sort 的比较器须是严格弱序(a<b);返回 a<=b 可能导致 invalid order 错误。表是引用类型:变量里存的是「指向表的引用」而不是表本身。这条规则决定了赋值、传参、比较三处的行为。
三个直接后果
- 赋值不复制:
local b = a之后b[1] = 99,a[1]也变成 99——两个名字指着同一张表; - 传参不复制:函数内修改传入的表,调用方看得到(这是 Lua 里「输出参数」的常见做法);
- 比较比的是身份:
{1} == {1}是false,因为是两张不同的表。要按内容比较得自己写,或定义__eq(见 07 章)。
需要副本时
- 浅拷贝:遍历
pairs逐个赋值,或用table.move——只复制一层,嵌套的表仍是共享的; - 深拷贝:标准库没有,需要自己递归实现(注意处理循环引用)或用第三方库;
- 字符串则相反——它是不可变值类型,可以放心传递,改动总是产生新字符串。
local a = {1, 2, 3}
local b = a
b[1] = 99
print(a[1]) --> 99 (a、b 指向同一张表)
-- 浅拷贝
local function shallow(t)
local r = {}
for k, v in pairs(t) do r[k] = v end
return r
endcopy.cfg.level,原表的 cfg 跟着变——嵌套表仍是共享的。把表当集合的键也按身份比较:set[{1}] = true 之后再查 set[{1}] 得 nil,因为是两张不同的表。元表与元方法
本章覆盖:元表的装卸与 raw* 系列、元方法一览与各自的触发时机——查找与赋值、运算与比较、调用与转字符串、生命周期。这是 Lua 唯一的「可扩展语义」机制,面向对象、只读表、运算符重载、确定性释放都从这里长出来。
元表是 Lua 唯一的「可扩展语义」机制:给一个值挂一张表,规定它在各种运算下的行为。面向对象、只读表、默认值、运算符重载、资源自动释放,全都从这一处长出来。
装卸与查询
| 写法 | 作用 |
|---|---|
setmetatable(t, mt) | 挂上元表,返回 t 本身——所以能写 return setmetatable({}, mt) |
getmetatable(t) | 取元表;若元表里有 __metatable 字段,返回的是那个字段 |
rawget(t, k) · rawset(t, k, v) | 绕过 __index / __newindex 直接读写 |
rawequal(a, b) · rawlen(t) | 绕过 __eq / __len |
| 谁能有元表 | 表和 userdata 各自独立;字符串共享一张(这就是 s:upper() 能用的原因);其余类型按类型共享 |
查找与赋值
| 元方法 | 触发时机 | 要点 |
|---|---|---|
__index | 读一个不存在的键 | 值可以是表(去那张表里找,可串成链)或函数(调用它)。已存在的键不触发 |
__newindex | 给一个不存在的键赋值 | 同样可以是表或函数。只读表、写日志、校验都靠它;要真正写进去得用 rawset |
__len | #t | 表默认按序列算,定义后接管 |
__call | t(…) | 让表可以像函数一样调用——可调用对象与函子的基础 |
__tostring | tostring(t) · print(t) | 调试时第一个该加的元方法,否则只看到地址 |
__name | 默认的 tostring 与报错信息 | 给类型起个名字,报错会显示它 |
__metatable | getmetatable / setmetatable | 设了它,元表被保护:查询只给这个值,再 setmetatable 直接报错 |
运算与比较
| 元方法 | 运算符 |
|---|---|
__add __sub __mul __div __mod __pow __unm __idiv | + - * / % ^ -x // |
__band __bor __bxor __bnot __shl __shr | & | ~ ~x << >>(5.3 起) |
__concat | .. |
__eq | ==——只在两边都是表(或都是 userdata)且不是同一个对象时才触发 |
__lt · __le | < · <=(> 与 >= 由它们反推) |
二元运算的规则:先看左操作数有没有该元方法,没有再看右操作数。两边都没有才报 attempt to perform arithmetic。
生命周期
| 元方法 | 触发时机 | 要点 |
|---|---|---|
__close | <close> 变量离开作用域(5.4 起) | 确定性释放,出错时也会调用——这是 Lua 的 RAII(10 章) |
__gc | 对象被 GC 回收前 | 必须在 setmetatable 时元表里就已经有它,事后补上无效;调用时机不确定,别拿它管重要资源(13 章) |
__mode | — | 不是回调,是标记:"k" / "v" / "kv" 把表变成弱表(13 章) |
__index 只在键不存在时触发——值为 false 的键是存在的,不会走元方法;而值为 nil 等于不存在,会走。同理 __newindex 只管新键,改已有键不触发。做只读表时这条最容易漏:表里已有的字段照样能被改掉。setmetatable(t, {__index = function(_, k) return 默认值 end})——带默认值的表。把 __index 指向另一张表就是继承(08 章),指向函数就是惰性计算或代理。读缺失键、写新键这两个拦截点,是 Lua 一切默认值、代理与继承花样的总开关。
两个拦截点
setmetatable(t, mt) 绑定元表。__index(表或函数)在读取缺失键时触发;__newindex(表或函数)在写入不存在的键时触发。已存在的键读写不触发。
触发条件:只在「缺失」时
local n = 0
local t = setmetatable({a = 1}, {__index = function() n = n + 1; return 0 end})
local _ = t.a -- a 存在 → 不触发
local _ = t.b -- b 缺失 → 触发
print(n) --> 1
-- __newindex 同理
proxy.x = 1 -- x 不存在 → 触发
proxy.x = 2 -- x 已存在 → 不触发- 这条「只拦缺失」的规则是性能设计:正常读写不用付元表查找的代价,只有落空时才走慢路径;
- 也正因如此,写代理表时
__newindex里必须用rawset(t, k, v)存值——直接写t[k] = v会再次触发自己,无限递归; - 反过来,如果想让每次写入都被拦截,就不能把值存进本表:常见做法是本表永远空着,真实数据放在另一张影子表里。
__index 给表还是给函数
| 写法 | 行为 | 用在 | |
|---|---|---|---|
| 表 | __index = base | 到 base 里再查一次(可继续沿链) | 继承、默认值表 |
| 函数 | __index = f(t, k) | 返回值即结果,可以凭空算 | 惰性计算、代理、记账 |
- 表形式更快(C 层直接查表,还能尾链下去);函数形式更灵活但每次落空都要跑一次 Lua 函数;
- 08 章的「类」用的就是表形式——
Class.__index = Class让实例查不到的方法回落到类表,这是 Lua 面向对象的全部机制。
local defaults = { color = "black", size = 1 }
local obj = setmetatable({}, { __index = defaults })
print(obj.color) --> black(自身没有,回落到 defaults)
-- 只读表
local ro = setmetatable({}, {
__index = { pi = 3.14 },
__newindex = function() error("只读!", 2) end,
})
print(ro.pi) --> 3.14
-- ro.x = 1 --> 报错:只读!__index 回落来的「默认值」对 pairs 和 # 完全不可见(一个键都遍历不到);而把已有字段置 nil 后它重新变成「缺失键」,读取又走回 __index。想表达「明确没有」存 false——false 会挡住回落。__index 是表时做「查表回落」,是函数时做「动态计算」。链式 __index 即可实现原型继承。在 __newindex 里想真正写入而不再触发自己,用 rawset(t, k, v)(rawget 同理绕过 __index)。让你的表学会加减乘除——向量、矩阵、大数这些自定义类型的运算符都从这组元方法长出来。
非数值参与运算时查元方法
非数值参与运算时查元方法:先查第一操作数,没有再查第二个。适合做向量/矩阵/大数等类型。
查找顺序:先第一操作数,再第二个
local a = setmetatable({}, {__add = function() return "A的__add" end})
a + 1 --> A的__add
1 + a --> A的__add ← 数字没有 __add,于是查第二操作数- 这条规则让
vec + 1与1 + vec都能工作,而你只需要写一个元方法; - 代价是元方法里不能假定哪个参数是「自己」——写向量库时必须先判断两个参数各是什么类型,否则
1 + vec会取错字段; - 字符串是个特例:Lua 会先尝试把字符串转成数字(
"1" + 1得 2),转不了才查元方法。这条自动转换在 5.4 中仍然存在,但依赖它是坏习惯。
local Vec = {}
Vec.__index = Vec
Vec.__add = function(a, b) return setmetatable({a[1]+b[1], a[2]+b[2]}, Vec) end
Vec.__tostring = function(v) return "("..v[1]..","..v[2]..")" end
local function vec(x,y) return setmetatable({x,y}, Vec) end
print(tostring(vec(1,2) + vec(3,4))) --> (4,6)__add 不保证两个操作数都是你的类型:vec + 1 时 b 就是数字 1,元方法里直接 b[1] 报 attempt to index a number value (local 'b')——先判型再运算。相等、大小、调用、打印……这些「杂项」行为各有一个元方法开关,按需定制。
杂项行为的开关
__eq:仅当两者同为表或同为 full userdata 且非原始相等时触发,结果转布尔__lt __le:< <=(>由<反推)__call:让表像函数一样被调用__tostring:定制tostring/print输出__len:定制#;__concat:定制..
__eq 的触发条件比想象中窄
local mt = {__eq = function() n = n + 1; return true end}
local e1, e2 = setmetatable({}, mt), setmetatable({}, mt)
e1 == e1 --> true,但 __eq 没被调用(原始相等,直接短路)
e1 == e2 --> true,__eq 被调用
调用次数总计:1- 三个前提缺一不可:两者同为表(或同为 full userdata)、两者不是同一个对象、结果会被转成布尔;
- 所以
table == "字符串"永远是false,不会去查__eq——跨类型比较在 Lua 里没有定制余地; __lt与__le同理;>与>=由它们反推(a > b即b < a),所以不存在__gt。
#(长度)的两件事
#{1, 2, nil, 4} --> 2(5.5 与 5.4 一致)
-- 但这是未定义行为:带洞表的 # 只保证返回某个边界
#setmetatable({}, {__len = function() return 99 end}) --> 99- 带洞的表用
#是本页最值得记住的一条禁忌——返回值取决于表的内部存储布局,换个 Lua 版本、甚至换个插入顺序都可能不同; - 要长度可靠,就自己维护一个计数字段,或者保证序列里没有
nil; __len能定制#,但不影响ipairs与table.*的行为——它们各有自己的边界规则。
local Set = {}
Set.__index = Set
Set.__eq = function(a, b) return a.size == b.size end
Set.__len = function(s) return s.size end
Set.__call = function(s, x) return s.items[x] == true end -- 可调用
local s = setmetatable({items={a=true}, size=1}, Set)
print(#s) --> 1
print(s("a")) --> true(通过 __call)__eq 只在两操作数类型匹配且非原始相等时才被调用;数字/字符串的相等不走元方法。__lt 别忘了配 __le:只有 __lt 时 a<=b 在 Lua 5.5 报 attempt to compare two table values(5.4 默认编译靠兼容项用 not (b<a) 兜底,别依赖);a>b 则两版都自动换算成 b<a,无需 __gt。这组元方法管的不是运算,而是对象的生命周期、弱引用与元表自身的保护。
生命周期与保护
__close:to-be-closed 变量离开作用域时调用(5.4+,见「变量属性」)__gc:对象被回收前的终结器__mode:"k"/"v"/"kv"弱键/弱值(见「内存与垃圾回收 · 弱表」)__name:错误信息/tostring 中显示的类型名__metatable:设置后getmetatable返回它、setmetatable报错(保护)
五个各管一件事
| 元方法 | 时机 | 确定性 |
|---|---|---|
__close | 变量离开作用域(含 break/return/出错) | 确定,立即 |
__gc | 对象被回收前 | 不确定,何时发生看 GC |
__mode | 建表时读一次,决定键/值是否弱引用 | — |
__name | 错误信息与 tostring 里显示的类型名 | — |
__metatable | 设了就锁住元表 | — |
- 要释放资源就用
__close,不要用__gc:后者可能在几秒后、也可能在程序退出时才跑,文件句柄和锁等不起; __gc还有个陷阱——元表必须在setmetatable时就已经含__gc,事后往元表里补一个是无效的;__metatable设了之后getmetatable返回它、setmetatable直接报错,是给沙箱与库作者用的保护开关(12 章)。
local mt = {
__name = "Point",
__tostring = function(p) return "Point("..p.x..")" end,
__metatable = "protected", -- 隐藏真实元表
}
local p = setmetatable({x=1}, mt)
print(getmetatable(p)) --> protected
-- setmetatable(p, {}) --> 报错:cannot change a protected metatable__gc 终结器执行顺序/时机不保证;资源清理优先用 <close>(确定性)。__gc 生效,setmetatable 那一刻元表里就得有 __gc 字段:事后用 mt.__gc = function() ... end 补上的终结器永远不会跑。想以后再填实现,先放个占位值把对象标记上。面向对象
本章覆盖:十行的类模板、__index 链式继承、冒号与点号的分工,以及元表式 / 闭包式 / 原型式三种风格的取舍。Lua 没有 class 关键字——机制完全透明,代价是每个项目的写法都略有不同。
Lua 没有 class 关键字,类是用表 + 元表拼出来的约定。好处是机制完全透明,代价是每个项目的写法都略有不同——下面这套是流传最广的一版。
最小类模板
| 写法 | 做了什么 |
|---|---|
local Dog = {} | 类就是一张表 |
Dog.__index = Dog | 关键的一行:实例找不到的键,去 Dog 里找——方法就是这么被找到的 |
function Dog.new(name) | 构造函数。用点号定义,因为它不需要 self |
return setmetatable({name = name}, Dog) | 造一个实例并挂上类的元表;setmetatable 返回表本身,所以能直接 return |
function Dog:bark() | 用冒号定义方法,隐式带 self |
d:bark() | 等价于 d.bark(d) |
继承:把 __index 串成链
| 写法 | 含义 |
|---|---|
local Puppy = setmetatable({}, {__index = Dog}) | 子类自己找不到的键,去父类找 |
Puppy.__index = Puppy | 实例先找子类——这两行缺一不可,也是最容易漏掉一行的地方 |
| 调用父类方法(super) | Dog.bark(self)——显式传 self,没有 super 关键字 |
| 多重继承 | 把 __index 写成函数,依次去多张表里找 |
| 查找代价 | 链有多长,找一个方法就要走多少层;热路径上把方法缓存成局部变量 |
instanceof | 没有。自己沿 getmetatable 往上走,或给每个类挂一个标记字段 |
三种风格对照
| 风格 | 怎么做 | 取舍 |
|---|---|---|
| 元表式(上面这套) | __index + setmetatable | 最通用:方法只存一份,内存省;私有性靠约定(下划线前缀) |
| 闭包式 | 构造函数里造局部变量与内层函数,返回一张只含方法的表 | 真正的私有状态,外部无法访问;代价是每个实例都复制一份方法 |
| 原型式 | 直接 setmetatable(new, {__index = prototype}),不分类与实例 | 最简单,适合配置与默认值,不适合复杂层次 |
四个高频坑
| 症状 | 原因 |
|---|---|
attempt to index a nil value (local 'self') | 定义用了冒号,调用用了点号(或反过来)。两端必须一致(05 章) |
| 改一个实例,所有实例都变了 | 类字段是共享的:把 Dog.tags = {} 写在类上,所有实例用的是同一张表。可变的默认值必须在构造函数里现造 |
| 子类方法找不到 | 漏了 Puppy.__index = Puppy |
加了 __eq 但两个对象仍不等 | __eq 只在两边都是表且不是同一个对象时才触发(07 章) |
Puppy.new 不会自动调用 Dog.new,得自己写:先 local self = Dog.new(...),再 setmetatable(self, Puppy) 改挂元表。忘了改元表,造出来的就是个父类对象——它能跑,只是少了子类的方法,很难一眼看出。把「类」翻译成 Lua:类表既装方法又当元表,一句 __index 赋值就接通了实例与方法。
类表兼作元表
惯用模式:类表自己当元表,Class.__index = Class,实例方法查不到时回落到类表。
那两行样板各自在做什么
local Dog = {}
Dog.__index = Dog -- ① 类表自己当「查不到时去哪找」
function Dog.new(name)
return setmetatable({name = name}, Dog) -- ② 实例的元表就是类表
end
function Dog:speak() return self.name .. " 汪" end- ①和②合起来的效果:实例上找不到
speak→ 查元表(Dog)的__index→ 又是Dog→ 找到方法。一张表同时扮演了「类」和「元表」两个角色,省掉一层间接; function Dog:speak()的冒号是语法糖,等价于function Dog.speak(self);调用处d:speak()等价于d.speak(d)——冒号只在「定义处和调用处都用」时才配对,混用是最常见的一处错误;- Lua 里没有
class关键字,所以这套约定不是唯一写法:闭包式(每个实例带一份方法)更封闭但更费内存,各种 OOP 库还有别的花样。读别人代码时先确认它用的是哪一套。
local Animal = {}
Animal.__index = Animal
function Animal.new(name)
return setmetatable({ name = name }, Animal)
end
function Animal:speak()
return self.name .. " 发出声音"
end
local a = Animal.new("狗")
print(a:speak()) --> 狗 发出声音Animal.__index = Animal:setmetatable 只挂上元表,查方法靠的是元表里的 __index 字段,漏掉它实例调方法就报 attempt to call a nil value (method 'speak')(Lua 5.4/5.5)。class+prototype。没有语法糖,但机制透明——你能看清 this/继承的每一步。继承没有魔法——不过是让方法查找沿 __index 链再多走一层。
沿 __index 链多走一层
让子类表以父类为 __index,方法查找沿链上溯;子类可覆盖同名方法,并用 Parent.method(self, ...) 调用父类实现。
链有多长就能上溯多远
A.__index = A ; function A:who() return "A" end
B = setmetatable({}, {__index = A}) ; B.__index = B
C = setmetatable({}, {__index = B}) ; C.__index = C
setmetatable({}, C):who() --> "A" 三层链一路上溯- 每落空一次就要多走一层,继承层次深了会有实打实的查找成本——热点方法可以在子类里复制一份(
C.who = A.who)把链压平; - 没有
super关键字:调父类实现要写Parent.method(self, ...),注意是点号加显式 self,写成Parent:method(...)会把 Parent 自己当成 self; - 多重继承只能自己实现:把
__index写成函数,依次去几个父类里找。但那样每次落空都要跑 Lua 函数,代价明显高于表形式。
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name)
local self = Animal.new(name) -- 复用父构造
return setmetatable(self, Dog)
end
function Dog:speak() -- 覆盖
return Animal.speak(self) .. ":汪汪"
end
print(Dog.new("旺财"):speak()) --> 旺财 发出声音:汪汪__tostring、__add,子类实例全部拿不到(tostring 仍打印 table: 0x…,相加直接报错)——子类要用运算符,得把元方法逐个拷到自己的类表,或干脆共享元表。__index 为函数,依次查多个父表。但通常单继承 + 组合更清晰。错误处理
本章覆盖:error 的 level 参数、assert 的透传性质、pcall / xpcall 的返回形状、warn,以及 load / loadfile / dofile / string.dump。外加一张「该抛还是该返回」的判据表——Lua 没有 try…finally,那件事交给 <close>。
Lua 的错误模型是 raise / catch,但没有 try…finally 语法——「无论如何都要执行」交给 <close>(10 章)。API 只有几个,要点全在参数与返回值的形状。
抛出
| 写法 | 行为 |
|---|---|
error("msg") | 抛出,消息前自动加 文件:行号: |
error("msg", 0) | level 0:不加位置前缀——消息要给用户看时用这个 |
error("msg", 1) | 默认,位置指向调用 error 的那一行 |
error("msg", 2) | 位置指向调用者——写库函数做参数校验时该用的就是它,报错才指向用户代码 |
error({code = 404}) | 抛一张表:结构化错误。此时不加任何前缀,也不会被转成字符串 |
assert(v, "msg") | v 为假时抛出;为真时原样返回所有参数——所以能写 local f = assert(io.open(path)) |
捕获
| 写法 | 返回 | 要点 |
|---|---|---|
pcall(f, a, b) | true, f 的返回值… 或 false, 错误值 | 第一个返回值永远是布尔,别忘了接 |
xpcall(f, handler, a, b) | 同上,但出错时先调 handler | handler 在出错现场执行,栈还没展开——这是能拿到 traceback 的唯一时机 |
xpcall(f, debug.traceback) | — | 最值钱的一条写法:拿到完整调用栈而不只是一句消息 |
| 捕不住什么 | — | 协程里的错误不会穿过 resume 抛出,而是作为 resume 的第二个返回值(11 章) |
警告与动态加载
| 写法 | 作用 |
|---|---|
warn("…") | 发一条警告(5.4 起)。默认关闭,lua -W 或 warn("@on") 打开 |
load(chunk, name, mode, env) | 把字符串或读取函数编译成函数;失败返回 nil, 错误消息(不抛) |
load(s, nil, nil, env) | 第四个参数指定 _ENV——沙箱就是这么搭的(12 章) |
loadfile(path) | 同上但读文件;只编译不执行 |
dofile(path) | 编译并立即执行;出错直接抛,不走 package.loaded 缓存 |
string.dump(f) | 把函数序列化成字节码 |
该抛还是该返回
| 情况 | 惯例 |
|---|---|
| 预期内的失败(文件打不开、网络超时) | 返回 nil, 错误消息——标准库就是这么做的(io.open、tonumber) |
| 调用方用错了 API(参数类型不对) | error(..., 2) 抛出——这是 bug,不该被静默吞掉 |
| 要跨很多层传递 | 抛出,让外层一个 xpcall 兜住 |
| 需要「无论如何都执行」的清理 | <close> 变量(10 章)——Lua 没有 finally |
pcall 的第一个返回值是布尔,不是结果。local v = pcall(f) 拿到的永远是 true / false,不是 f 的返回值——这是最常见的一次误用。正确写法是 local ok, v = pcall(f),并先判 ok 再用 v。assert 透传全部返回值这个性质被大量利用:local f = assert(io.open(path, "r")) 一行既打开文件又在失败时报出 io.open 给的具体原因。但别在需要优雅降级的地方用它——assert 会中断整个脚本(21 章)。抛错的两个入口:error 手动抛任意错误对象,assert 把「假值」直接变成错。
两个抛错入口
error(obj [, level]) 抛错(错误对象可为任意值(含 nil);字符串会自动加位置前缀,level 控制位置归属)。assert(v [, msg]) 在 v 为假时以 msg 抛错,否则原样返回参数。
level 决定错误信息指向谁
error("boom", 1) --> lt.lua:49: boom ← 默认,指向 error 这一行
error("boom", 0) --> boom ← 完全不加位置
error("boom", 2) --> 指向调用者那一行
error({code = 42}) --> 错误对象是表,type 为 table,不加任何前缀level = 2是写库时的正确选择:参数校验失败时,用户想看到的是「你调错了的那一行」,而不是库内部报错的那一行;- 「字符串会自动加位置前缀」这条只对字符串成立——抛表、抛任意值时 Lua 一个字都不加,所以结构化错误对象需要自己记录位置;
assert(v, msg)是if not v then error(msg) end的快捷方式,但它原样返回全部参数(assert(1,2,3)返回1 2 3),所以能写成local f = assert(io.open(...))这种一行式;- 注意
assert的第二个参数总会被求值——写assert(x, "错误:" .. expensive())时那个拼接每次都会跑。
local function div(a, b)
assert(b ~= 0, "除数不能为零") -- 常用作参数校验
return a / b
end
local function parse(s)
local n = tonumber(s)
if not n then
error("无法解析: " .. s, 2) -- level 2:归咎于调用者
end
return n
enderror 抛表等非字符串对象时不加位置前缀,没被 pcall 接住的话解释器只打印「(error object is a table value)」——想让顶层也看得懂,给错误表配 __tostring。另外 error() 不带参数时,Lua 5.4 里 pcall 拿到 nil,5.5 起变成字符串 <no error object>。error(obj, 0) 不加位置前缀;error(msg, 2) 把位置指向调用方,库函数报参数错常用。Lua 的 try/catch:把「会不会炸」变成返回值里的一个布尔。
把「会不会炸」变成返回值
pcall(f, ...) 返回 ok, 结果或错误对象。xpcall(f, handler, ...) 在栈展开之前调用 handler(常用 debug.traceback 拿回溯)。
xpcall 存在的唯一理由:栈还在
pcall返回时栈已经展开了——你只拿到错误对象,现场没了;xpcall的 handler 在栈展开之前被调用,所以此刻debug.traceback还能拿到完整回溯。把 handler 写成function(m) return debug.traceback(m, 2) end就能拿回「出错时的调用链」;- 这也是为什么不能在
pcall之后再去debug.traceback——那时打印的是当前位置的栈,与出错点无关; - 实务:库内部用
pcall,程序最外层与后台任务用xpcall——后者出问题时你需要日志里有回溯。
local ok, err = pcall(function()
error("出问题了")
end)
print(ok, err) --> false ...:出问题了
-- 带回溯的保护调用
local ok2, result = xpcall(function()
error("崩溃")
end, debug.traceback)
print(ok2) --> false
-- result 含完整栈回溯pcall 再 debug.traceback 拿不到出错现场——栈已展开,回溯里只剩调用方(Lua 5.4/5.5 只剩 main chunk 一帧);要完整回溯必须让 xpcall 的 handler 在展开前抓。另外 handler 的多个返回值只有第一个会传出来。try/catch,但以返回值形式表达成败,更函数式。无 finally —— 用 <close> 做确定性清理。有些问题值得说一声但不值得中断程序——warn 就是那条不打断执行的通道。
不打断执行的通道
warn(msg, ...)(5.4 引入)输出警告但不中断程序。以 @ 开头的控制消息可开关:warn("@on") / warn("@off")。宿主还可从 C 侧定制警告的处理方式。
它的定位:介于 print 与 error 之间
print走 stdout、混在正常输出里;error会中断流程。warn走 stderr 且不中断——正好是「弃用提示、配置可疑、降级处理」这类消息该待的地方;- 默认是关闭的:Lua 启动时警告系统处于 off 状态,要先
warn("@on")才会输出。这是刻意的——嵌入 Lua 的宿主程序不该被脚本的警告刷屏; - 以
@开头的消息是控制消息而非内容,宿主可以定义自己的控制字;C 侧用lua_setwarnf可以把警告接到宿主的日志系统里(17 章); - 多个参数会被拼接成一条警告(不像 print 用制表符分隔)——这是为了让控制消息能被完整识别。
warn("@on") -- 启用警告(默认关闭)
warn("配置项 ", "timeout", " 缺失,使用默认值")
-- 多个参数会被拼接为一条警告
warn("@off") -- 之后的 warn 被忽略@ 开头」:warn("@on", " extra") 会被当普通警告原样打印、并不改开关;未知控制串(如 warn("@foo"))被静默忽略(Lua 5.4/5.5)。警告走 stderr,带「Lua warning: 」前缀,别指望在 stdout 里看到。warn("@on")。适合库向使用者提示「能用但不推荐」的情况。把字符串变成可执行函数——配置、热更新、沙箱都从这一步开始。
字符串变函数
load(chunk [, chunkname [, mode [, env]]]) 把一段字符串(或读取函数)编译成一个函数(chunk);编译失败不抛错,而是返回 nil 与错误信息(正好与 pcall 的成败协议呼应)。mode 限定 "t"(文本)/"b"(字节码)/"bt",env 指定该 chunk 的 _ENV(即沙箱,见「模块与包 · _ENV 与沙箱」)。loadfile 同理但从文件读;string.dump 把函数反向序列化成字节码串。
失败不抛错,返回 nil + 消息
load("this is not lua")
--> nil [string "this is not lua"]:1: syntax error near 'is'
load("return 1 + 1")() --> 2- 「编译失败返回 nil」的协议正好与
pcall的ok, err同形,所以可以写成local f = assert(load(src))一行搞定; mode参数限定接受什么:"t"只接受文本、"b"只接受字节码、"bt"都行(默认)。加载不可信内容时必须传"t"——恶意字节码能让解释器崩溃甚至更糟,Lua 的字节码校验器不是安全边界;- 第四个参数
env就是沙箱的入口(12 章):给这段代码一张受控的全局表,它就看不到真正的_G; string.dump反向把函数序列化成字节码串——但字节码在大版本间不兼容(5.4 的 dump 在 5.5 上加载不了),别拿它做长期存储。
local f = load("return 1 + 2") -- 把字符串编译成函数(chunk)
print(f()) --> 3
-- 编译失败返回 nil + 错误信息(不抛错,交给你判断)
local g, e = load("return 1 +")
print(g, e) --> nil [string ...]: unexpected symbol
-- 运行不可信代码:先 load,再用 pcall 兜住(见本章 pcall / xpcall)
local chunk = load("return 6 * 7")
if chunk then print(pcall(chunk)) end --> true 42
-- string.dump:把函数存成字节码,load 时用 mode "b" 读回
local bytes = string.dump(function() return "hi" end)
print(load(bytes, "=dumped", "b")()) --> hiload 都要完整编译,热路径别反复 load 同一字符串;把结果函数缓存起来复用。mode 设为 "t" 拒绝字节码——恶意字节码可绕过安全检查、直接崩溃解释器。变量属性
本章覆盖 5.4 起的两个局部变量属性:<const>(编译期常量,违反是编译错误)与 <close>(离开作用域自动调 __close,抛错时也会调)。后者是 Lua 唯一的确定性资源释放机制,补上了长期缺失的 finally。
5.4 给局部变量加了两个属性,写在变量名后的尖括号里。<const> 是编译期的,<close> 是运行期的——后者是 Lua 唯一的确定性资源释放机制。
<const>
| 规则 | 说明 |
|---|---|
| 写法 | local PI <const> = 3.14 |
| 约束 | 不能再赋值,违反是编译期错误:attempt to assign to const variable |
| 只管名字 | 表的内容照样能改——local t <const> = {} 之后 t.x = 1 合法 |
| 好处 | 编译器可以把值直接内联,比普通局部变量还快一点;更重要的是把「这个不该变」写进了代码 |
| 只能用在 | 局部变量。全局变量与表字段没有这个属性 |
<close>:Lua 的 RAII
| 规则 | 说明 |
|---|---|
| 写法 | local f <close> = 一个带 __close 元方法的值 |
| 何时触发 | 变量离开作用域时——正常结束、break、return、以及抛错,都会调 |
| 元方法签名 | __close(obj, err):第二个参数是导致退出的错误值,正常退出时为 nil |
| 值的要求 | 必须有 __close 元方法,或者是 false / nil(这两个被特许,什么也不做) |
| 顺序 | 同一个块里多个 <close> 按声明的逆序关闭 |
| 报错 | 值既没有 __close 又不是 false/nil,声明那一行就报错 |
| 泛型 for | 迭代器三元组后的第四个值也可以是 to-be-closed(04 章) |
和 __gc 的分工
<close> / __close | __gc | |
|---|---|---|
| 时机 | 确定:离开作用域那一刻 | 不确定:GC 想回收时,可能永远不发生 |
| 出错时 | 照样调用,还能拿到错误值 | — |
| 该管 | 文件句柄、锁、连接、临时文件 | 最后的兜底,别拿它管重要资源 |
| 注册 | 声明变量时写 <close> | 必须在 setmetatable 之前元表里就有(07、13 章) |
<close> 补上的正是 Lua 长期缺的 try…finally:把清理逻辑写在资源自己的 __close 里,调用处只需一个声明,之后所有出口——包括抛错的那条——都会被覆盖到。标准库的文件对象已经带 __close,可以直接写 local f <close> = io.open(path)。给绑定上锁:改不了的 local,在编译期就把误赋值拦下来。
编译期只读绑定
local x <const> = expr 声明只读局部(5.4+)。任何后续赋值在编译期报错。编译器还能据此优化。5.5 起 global 声明也可带 <const>。
它拦的是绑定,不是值
load("local y <const> = 1; y = 2")
--> nil [string "..."]:1: attempt to assign to const variable 'y'
↑ 编译期就报错,不是运行时- 注意是绑定只读:
local t <const> = {}之后t.x = 1完全合法——锁住的是「t 指向谁」,不是表的内容。要内容只读得靠__newindex(07 章); - 编译器据此可以优化:常量表达式会被直接内联,不再每次去取局部变量槽;
- 它还是
<close>的前提——<close>变量隐含<const>,否则「离开作用域时关掉的到底是哪个对象」就说不清了; - 5.5 起
global声明也能带<const>(18 章),用来把库函数声明成不可被覆盖的只读全局。
local MAX <const> = 100
-- MAX = 200 --> 编译错误:attempt to assign to const variable 'MAX'
local cfg <const> = { retries = 3 }
cfg.retries = 5 -- 允许:const 锁的是「绑定」,不是表内容<const> 冻结的是变量绑定,不是所指向表的内容——表仍可变。要真正只读表用元表 __newindex。<const> 会被直接内联进字节码:luac -l 下闭包读它是 LOADI 常量加载、0 upvalue,而普通 local 要走 GETUPVAL(Lua 5.4)。作用域一结束就清理,连出错路径也不放过——这是 Lua 版的 RAII。
作用域结束就清理
local r <close> = obj:当 r 离开作用域(正常、break、return、错误)时,自动调用 obj 的 __close 元方法。obj 须为 nil/false 或带 __close。这是 Lua 的确定性清理机制。
四条退出路径都会触发
do
local r <close> = setmetatable({}, {__close = function() print("[清理]") end})
print("作用域内")
end
print("作用域外")
输出:作用域内 → [清理] → 作用域外- 正常结束、
break、return、抛错——四条路径全都会调__close。出错路径也覆盖是它相对「函数末尾手动 close」的根本优势; - 对比
__gc:那个要等 GC 心情好;<close>是确定性的、立即的,这正是 RAII 的定义; - 值必须是
nil、false或带__close的对象——给个普通表会在声明处直接报错,而不是等到作用域结束才发现; - 协程里挂着的
<close>变量由coroutine.close负责触发(11 章)——否则一个永远不被 resume 完的协程会把资源攥到进程结束。
local function open(name)
print("打开 " .. name)
return setmetatable({}, { __close = function()
print("关闭 " .. name)
end })
end
do
local f <close> = open("a.txt")
print("使用中…")
end -- 离开块 → 自动「关闭 a.txt」
-- 即使中途 error,__close 也会执行<close> 的值必须是 nil、false 或带 __close,否则声明处立刻报「variable 'x' got a non-closable value」。多个 close 变量按声明的逆序关闭,出错时 __close 第二参数收到错误对象;close 变量本身也是 const,不能再赋值(Lua 5.4/5.5)。using(C#)/defer(Go)/RAII(C++)之效,填补 Lua 无 finally 的空缺。协程(Coroutine)
本章覆盖:协程 API 全表、resume 与 yield 之间值是怎么互相传的、四种状态、create 与 wrap 在错误传播上的差别。协程是协作式的,不是线程、没有并行——它是生成器、状态机与异步流程的唯一原语。
协程是可以暂停和恢复的函数——协作式,不是抢占式;不是操作系统线程,没有并行。它是 Lua 实现生成器、状态机与异步流程的唯一原语。
API 全表
| 函数 | 作用 | 要点 |
|---|---|---|
coroutine.create(f) | 造一个协程(thread 类型) | 不会开始执行 |
coroutine.resume(co, …) | 启动 / 恢复 | 返回 true, yield 的值… 或 false, 错误——形状和 pcall 一样 |
coroutine.yield(…) | 暂停并交回控制权 | 它的参数成为 resume 的返回值 |
coroutine.wrap(f) | 造一个协程并包成普通函数 | 调用它即 resume;不返回 true,出错直接抛 |
coroutine.status(co) | 查状态 | 见下表 |
coroutine.isyieldable() | 当前能不能 yield | 写通用库时用来判断自己跑在不在协程里 |
coroutine.running() | 当前协程 + 是不是主协程 | 返回两个值 |
coroutine.close(co) | 关掉一个挂起或出错的协程 | 5.4 起;会触发里面的 <close> 变量(10 章) |
值怎么在两边传
| 方向 | 写法 |
|---|---|
| 外 → 内(第一次) | resume(co, a, b) 的参数 → 协程函数的参数 |
| 内 → 外 | yield(x, y) 的参数 → resume 的第 2、3 个返回值 |
| 外 → 内(后续) | resume(co, m) 的参数 → 上一次 yield 的返回值 |
| 结束 | 协程函数 return 的值 → 最后一次 resume 的返回值 |
记法:resume 与 yield 是一对互相喂值的函数,谁先执行谁把值交给对方的返回位置。
四种状态
| status | 含义 |
|---|---|
suspended | 刚创建,或 yield 了——这是唯一能 resume 的状态 |
running | 正在跑(就是它自己在问) |
normal | 它 resume 了别人,自己在等 |
dead | 跑完了或出错了。再 resume 会得到 cannot resume dead coroutine |
create 还是 wrap
create + resume | wrap | |
|---|---|---|
| 调用方式 | resume(co, …) | 像普通函数 g(…) |
| 返回值 | 多一个 ok 布尔 | 直接就是 yield 的值,更适合当迭代器 |
| 出错 | 返回 false, err,不抛 | 直接抛到调用处 |
| 选哪个 | 要处理错误、要查状态 | 写生成器、放进泛型 for |
协程里的错误不会自动冒出来。用 resume 就必须检查第一个返回值,否则错误被完全吞掉——这是协程代码里最常见的一类静默失败。
table.sort 的比较函数、gsub 的替换函数、元方法里 yield,会得到 attempt to yield across a C-call boundary。5.4 放宽了一部分,但仍有清单——完整红线在 15 章。coroutine.wrap 返回的函数可以直接放进泛型 for:for v in coroutine.wrap(function() … yield(v) … end) do。这是 Lua 写自定义迭代器最短的路径——不用手写「迭代函数 + 状态 + 控制变量」三元组(04 章)。普通函数只能一口气跑完,协程可以停在半路、下次接着跑——生成器和调度器都建在这上面。
可暂停的函数
coroutine.create(f) 得到 thread;resume(co, ...) 启动/继续,参数传给 f 或作为 yield 的返回值;yield(...) 暂停并把值传回给 resume 方。协程是协作式的:只在 yield 处让出。
resume 与 yield 是双向传值
co = coroutine.create(function(a, b)
print("协程收到", a, b) -- 1 2 ← 来自第一次 resume
local c = coroutine.yield(a + b) -- ← 把 3 传回给 resume
print("resume 传回", c) -- 99 ← 来自第二次 resume
return "done"
end)
resume(co, 1, 2) --> true 3 status 变 suspended
resume(co, 99) --> true done status 变 dead
resume(co) --> false cannot resume dead coroutine- 四条通道要分清:第一次 resume 的参数 → 函数形参;yield 的参数 → resume 的返回值;后续 resume 的参数 → yield 的返回值;函数 return 的值 → 最后一次 resume 的返回值;
resume的第一个返回值是成功标志,语义与pcall一致——协程里抛的错不会炸掉调用方,而是变成false, 错误对象;- 「协作式」的含义:协程只在自己调
yield时让出,没有抢占、没有时间片。所以一个死循环的协程会把整个程序卡死; - 协程不是线程:同一时刻只有一个在跑,不存在数据竞争,也用不上锁。
local co = coroutine.create(function(a, b)
print("启动:", a, b) -- 首次 resume 的参数
local c = coroutine.yield(a + b) -- 让出 a+b,等待下次 resume
print("恢复,拿到:", c)
return "结束"
end)
print(coroutine.resume(co, 1, 2)) --> true 3 (yield 出 3)
print(coroutine.resume(co, 99)) --> true 结束 (c=99)
print(coroutine.resume(co)) --> false cannot resume dead coroutineresume 返回 false 加错误对象——不检查返回值,错误就被静默吞掉。另外 yield 不能穿越 C 函数边界(在 table.sort 比较函数里 yield 报「attempt to yield across a C-call boundary」),但穿过 pcall 没问题(Lua 5.4/5.5)。yield,但更强:resume/yield 双向传值,且非对称——由代码显式驱动,而非 for-of。wrap 把 resume 协议藏起来,让协程用起来像一个普通函数。
把 resume 协议藏起来
coroutine.wrap(f) 返回一个函数,每次调用即 resume(出错则直接抛出,不返回 ok)。适合做惰性生成器。coroutine.status(co) → suspended/running/normal/dead。running()、isyieldable() 辅助判断。
create 与 wrap 的取舍
create + resume | wrap | |
|---|---|---|
| 调用形式 | resume(co, ...) | 就像普通函数 f(...) |
| 出错时 | 返回 false, err | 直接抛出 |
| 能查状态 | 能(status(co)) | 拿不到 thread 对象 |
| 适合 | 调度器、需要处理错误 | 惰性生成器、for-in 迭代器 |
wrap生成器:local gen = coroutine.wrap(...)之后连着调三次得1 2 3——直接就能放进for x in gen do,这是它最常见的用法;- 代价是错误会穿透出来:生成器内部出错时你看到的是普通的运行时错误,而不是一个可检查的返回值。要兜住就得在外面包
pcall; status的四个值里normal最容易被忽略——它表示「这个协程 resume 了别人,正在等」,只在嵌套协程里出现。
-- 用 wrap 做斐波那契生成器
local function fib_gen()
return coroutine.wrap(function()
local a, b = 0, 1
while true do
coroutine.yield(a)
a, b = b, a + b
end
end)
end
local nextfib = fib_gen()
for _ = 1, 6 do io.write(nextfib(), " ") end --> 0 1 1 2 3 5cannot resume dead coroutine」,没有「重试」一说(Lua 5.4/5.5)。for v in coroutine.wrap(...) do。挂起的协程手里可能还攥着资源,close 让你不等它跑完就强制释放。
强制释放挂起的协程
coroutine.close(co)(5.4 引入)把挂起或出错的协程置为 dead,并运行其中未完成的 <close> 变量的 __close,确保资源释放。
它填的是哪个洞
- 一个协程
yield在半路、之后再也没人 resume 它——它栈上那些<close>变量永远不会被触发,文件句柄、锁、连接就一直攥着; - GC 最终会回收协程对象,但那是不确定的时机,而且
__gc与__close是两回事; coroutine.close(co)把它置为dead并立即跑完所有未完成的__close,返回是否全部成功;- 典型场景:迭代器提前
break、任务被取消、超时放弃。凡是「协程可能不被跑完」的地方,都该在收尾处调一次close。
local co = coroutine.create(function()
local file <close> = setmetatable({}, {__close = function() print("清理资源") end})
coroutine.yield()
end)
coroutine.resume(co) -- 挂起在 yield
coroutine.close(co) --> 清理资源(触发 __close)
print(coroutine.status(co)) --> deadcannot close a running coroutine」;5.5 起允许协程 close 自己,效果是就地终止、外层 resume 正常返回。__close 抛错或协程本身带错死亡时返回 false 加错误对象。无论成败,close 过后 status 一律 dead(Lua 5.4/5.5)。模块与包
本章覆盖:模块模板与 require 的缓存语义、package.path / cpath 的模板规则与 ;;、四个搜索器与 preload,以及 _ENV 沙箱三件套。一个文件返回一张表,就是 Lua 模块系统的全部约定。
Lua 的模块系统只有一个约定:一个文件返回一张表。require 负责找到它、跑一次、把结果缓存起来。
模块模板与 require
| 写法 | 说明 |
|---|---|
local M = {} … return M | 标准模块模板。5.1 的 module() 函数早已废弃,别再用 |
local m = require("a.b") | 模块名里的 . 会被替换成目录分隔符,找的是 a/b.lua |
| 缓存 | 结果存进 package.loaded["a.b"],第二次 require 不再执行文件 |
| 强制重载 | package.loaded["a.b"] = nil 再 require;但已经持有旧表的地方不会更新 |
| 返回 false / nil | 视为「加载过了」,package.loaded 记 true;所以模块末尾一定要 return M |
require 的第二个返回值 | 加载器找到它的路径——排查「加载到了哪个文件」时直接看它 |
搜索路径
| 写法 | 含义 |
|---|---|
package.path | Lua 文件的搜索模板,分号分隔,? 是模块名的占位符 |
package.cpath | C 扩展(.so / .dll)的同类模板 |
环境变量 LUA_PATH | 覆盖 package.path;里面的 ;; 表示「此处插入默认值」——想追加而不是替换就靠它 |
package.searchers | 四个搜索器依次尝试:preload 表 → Lua 文件 → C 库 → all-in-one 加载器 |
package.preload["名"] = f | 手动注册一个加载器,优先级最高——打包成单文件、做测试替身都靠它 |
| 找不到时 | 报错里列出所有试过的路径——那串清单直接告诉你该往哪放文件 |
沙箱三件套
| 机制 | 作用 |
|---|---|
_ENV | 每个函数都有的上值,全局变量 x 其实就是 _ENV.x。5.2 起取代了 setfenv |
local _ENV = {…} | 把这一段之后的「全局」换成你给的表——一行就是一个沙箱 |
load(s, name, "t", env) | 给一段外来代码指定环境;"t" 表示只接受源码、拒绝字节码,加载不可信输入时必写 |
| 全局守卫 | 给 _G 挂 __newindex,把「意外创建全局变量」变成当场报错(07 章) |
| 白名单 | 沙箱表里只放需要的函数。os.execute、io、load、debug 一律别给 |
require 只执行一次。模块顶层的代码是初始化,不是「每次引入都跑一遍」。把带副作用的逻辑(连数据库、注册回调)写在顶层,就会得到「第二个文件 require 之后什么也没发生」的困惑——导出一个 M.setup() 让调用方显式调。local insert = table.insert)是 Lua 生态的常见写法:局部变量访问比走全局表快,而且模块被塞进沙箱后也不受影响。热路径上这件事值得做,别处属于口味。require 不只是读文件——它有查找规则、有缓存,模块的写法惯例从这里定型。
查找、加载、缓存
现代模块惯例:文件返回一张表,不污染全局。require("mod") 查找、加载、缓存结果(同名只执行一次),重复 require 返回同一份。
缓存意味着模块体只跑一次
require("m")第一次会执行模块文件并把返回值存进package.loaded["m"],之后每次都直接返回那一份;- 后果一:模块顶层的副作用只发生一次——这正是「单例」在 Lua 里的实现方式,不需要额外机制;
- 后果二:热更新必须先
package.loaded["m"] = nil,否则改了文件再 require 拿到的还是旧的; - 后果三:循环依赖会拿到半成品。A require B、B 又 require A 时,B 拿到的是 A 那份还没执行完的(甚至是
true占位)。解法是把互相依赖的部分推迟到函数里再 require; - 模块返回一张表、不写任何全局,是 5.1 之后的统一惯例——老代码里的
module()函数已被移除,看到就知道那是 5.1 时代的写法。
-- 文件 mymath.lua
local M = {}
function M.square(x) return x * x end
function M.cube(x) return x * x * x end
return M
-- 使用方
local mymath = require("mymath")
print(mymath.square(5)) --> 25
-- 再次 require 返回缓存的同一张表return M,require 只会得到 true(package.loaded 里存的也是 true),之后 mod.fn 报「attempt to index a boolean value」——且因为有缓存,补上 return 后要清掉 package.loaded 才生效(Lua 5.4/5.5)。module()(5.1 遗物,已废弃)。坚持「local M = {} … return M」最清晰。找不到模块时先看这里——path、cpath 和 searchers 决定 require 到底去哪儿找。
require 到底去哪找
package.pathLua 模块搜索模式(?替换为模块名)package.cpathC 扩展(.so/.dll)搜索模式package.loaded已加载模块缓存表package.preload预注册加载器package.searchers搜索器列表(5.2 起,原名 loaders)
五张表,一条流水线
| 字段 | 作用 |
|---|---|
package.loaded | 已加载缓存——命中就直接返回,流水线到此结束 |
package.preload | 预注册的加载器,优先于文件查找 |
package.path | Lua 文件搜索模式,? 替换为模块名 |
package.cpath | C 扩展(.so/.dll)搜索模式 |
package.searchers | 搜索器列表,按顺序试(5.2 起,原名 loaders) |
- 排查「找不到模块」的顺序:先打印
package.path看模式对不对,再确认文件名与require的名字大小写一致(Linux 区分大小写,Windows 不区分——这是「我这能跑你那不行」的经典来源); - 模块名里的
.会被替换成目录分隔符:require("a.b")找的是a/b.lua; package.preload是把模块嵌进单个可执行文件的标准手段——嵌入式场景(17 章)里很常用。
print(package.path) -- 形如 ./?.lua;/usr/local/share/lua/5.5/?.lua;...
-- 手动注册一个虚拟模块
package.preload["greet"] = function()
return { hi = function() return "你好" end }
end
print(require("greet").hi()) --> 你好?:require "a.b" 按 ./?.lua 找的是 ./a/b.lua,而不是叫「a.b.lua」的文件——模块文件名里别用点(Lua 5.4/5.5)。package.loaded["mod"] = nil,再 require。全局变量不过是一张表的字段;看透这一点,沙箱就只是换一张表。
全局只是一张表的字段
全局变量其实是环境表 _ENV 的字段:x ≡ _ENV.x,_G 指向默认全局环境。每个 chunk 都有自己的 _ENV upvalue。给 load 传第 4 个参数 env(见「错误处理 · 动态加载」),就能让被加载的代码跑在一张受控表里——看不到、也改不了真正的全局,这便是沙箱。
换一张表就是一个沙箱
local sandbox = {print = print, type = type}
local f = load("x = 1; return type(os)", "s", "t", sandbox)
f() --> nil ← 沙箱里根本没有 os
sandbox.x --> 1 ← 写入落到了沙箱表里
真正的全局 x --> nil ← 一点没污染- 机制极简:每个 chunk 有一个名叫
_ENV的 upvalue,x就是_ENV.x的语法糖。换掉_ENV,这段代码眼中的整个世界就换了; - 沙箱的安全性取决于你放进去什么:放了
load就等于放弃了控制(它能再造环境),放了string.rep就可能被用来吃内存; - 沙箱挡不住资源耗尽:死循环、无限分配都不受
_ENV约束。真要防,得用debug.sethook设指令计数钩子强制中断; - 另一条:
string的元表是全局共享的——沙箱里能拿到字符串就能拿到("").rep,进而摸到整个 string 库。要彻底隔离得连元表一起换。
-- 全局名 = 当前 _ENV 的字段
print(_ENV.print == print) --> true
print(_G.type == type) --> true
-- 沙箱:只暴露白名单,屏蔽 os / io 等危险库
local sandbox = { print = print, math = math }
local code = [[
print(math.max(1, 2)) -- 允许(在白名单里)
os.exit() -- os 为 nil,报错出不去
]]
local chunk = load(code, "=untrusted", "t", sandbox)
print(pcall(chunk)) --> 先打印 2,再 false ...a nil value (global 'os')load/dofile/require/string.rep 等仍可能被滥用(加载新代码、撑爆内存);真正的沙箱要连带限制这些并设资源上限。__index 元表回落到只读的安全全局,既省得逐个列举,又防止被加载代码写穿到真实环境。Lua 圈的土办法序列化:把表打印成一段能被 load 读回来的源码。
生成可回读的源码
Lua 无内置序列化,但有个惯用法:把表转成一段可被 load 执行的 Lua 源码(通常形如 return {...})存盘;读回时 load 那段源码即得回原表。配合「错误处理」章的 load 与本章的沙箱,构成配置文件、存档、进程间传数据的基础。
这个土办法的四个边界
- 循环引用会无限递归——要自己记一张「访问过的表」并对重复引用做特殊处理;
- 函数、userdata、thread 存不了:
string.dump能存函数字节码,但跨版本不兼容、且丢失 upvalue; - 浮点精度:用
tostring输出可能丢位数,正确做法是string.format("%q", x)——它对数字也保证可无损回读(5.5 起浮点打印本身也保证这一点,见 18 章); - 回读时必须当作不可信输入:
load传mode="t"且给一张沙箱环境表,否则存档文件就成了任意代码执行的入口; - 字符串键要判断是不是合法标识符:是就写
k = v,不是就写["k"] = v——这一步漏了,带空格或数字开头的键会生成语法错误的源码。
-- 把表序列化成可回读的 Lua 源码(简版:只处理 string/number/boolean/table)
local function serialize(v)
local tp = type(v)
if tp == "number" or tp == "boolean" then
return tostring(v)
elseif tp == "string" then
return string.format("%q", v) -- %q:安全转义,可回读
elseif tp == "table" then
local parts = {}
for k, val in pairs(v) do
parts[#parts+1] = "[" .. serialize(k) .. "]=" .. serialize(val)
end
return "{" .. table.concat(parts, ",") .. "}"
end
error("不支持的类型: " .. tp)
end
local data = { name = "lua", tags = { "fast", "small" }, n = 3 }
local src = "return " .. serialize(data) -- 形如 return {["name"]="lua",...}
-- 回读:load 那段源码即还原(生产环境应在沙箱里 load,见本章前面)
local restored = load(src)()
print(restored.name, restored.tags[2]) --> lua smallload 不可信来源的串——务必走沙箱。%q 是关键:它把字符串输出成 Lua 能重新解析的安全字面量,自动处理引号、换行与转义——手写拼接极易漏掉这些。内存与垃圾回收
本章覆盖:collectgarbage 的全部选项、弱表的三种 __mode 与各自用途、增量与分代两种模式的分工,以及 __gc 的三条坑。需要动手的场合只有三种:查内存、控制回收时机、用弱表做缓存。
Lua 的内存全自动管理,平时不用管。需要动手的只有三种场合:查内存、控制回收时机、以及用弱表做缓存。
collectgarbage(opt) 全部选项
| 选项 | 作用 |
|---|---|
"collect" | 默认值:做一次完整回收 |
"count" | 返回当前占用内存,单位是 KB、带小数——查泄漏就靠它 |
"step" | 走一步增量回收;返回是否完成了一个周期 |
"stop" · "restart" | 停止 / 重启自动回收 |
"isrunning" | 自动回收在不在跑 |
"incremental" | 切到增量模式,可带参数调步长 |
"generational" | 切到分代模式(5.4 起的默认) |
查泄漏的标准做法:collectgarbage() 完整回收一次,再 collectgarbage("count")。不先回收,读到的数字里混着一堆还没清的垃圾,看不出趋势。
弱表:__mode
| 写法 | 含义 | 典型用途 |
|---|---|---|
__mode = "k" | 键是弱引用:键对象没人用了,整个条目消失 | 给对象挂附加信息,对象死了记录自动清掉 |
__mode = "v" | 值是弱引用 | 缓存:内存紧张时自动让位 |
__mode = "kv" | 两端都弱 | 双向映射 |
| 什么不会被弱引用 | 字符串、数字、布尔不算可回收对象——拿它们当键的弱表,条目不会自动消失 | |
| 什么时候设 | 要在往表里放东西之前设好;中途改 __mode 是未定义行为 | |
两种模式
| 增量(incremental) | 分代(generational) | |
|---|---|---|
| 怎么工作 | 把一次完整回收切成很多小步,穿插在程序运行中 | 假设大多数对象很快就死,频繁扫新对象、偶尔扫全部 |
| 适合 | 要控制单次停顿上限的场景 | 大量短命临时对象的常见负载——5.4 起是默认 |
| 5.5 的改动 | 增量式大回收:把分代模式下那次「扫全部」也切成增量,削掉最后一个长停顿(18 章) | |
__gc 的三条坑
| 坑 | 说明 |
|---|---|
| 注册时机 | 必须在 setmetatable 时元表里就有 __gc,事后往元表里加不生效 |
| 时机不确定 | 可能很久之后才调,程序退出时甚至可能一次都不调。要确定性释放用 <close>(10 章) |
| 里面别抛错 | __gc 中的错误会变成一条警告(warn),不会传播出来——错了你也看不见 |
collectgarbage("stop") 在有明确边界的批处理里是有价值的:一段只跑几秒、结束就退出的脚本,关掉 GC 能省下全部回收开销。但长期运行的程序别这么干——内存只涨不落,迟早出事。回收器平时不用管,但量内存、停 GC、手动推一步,全靠这一个函数。
一个函数管所有 GC 操作
collectgarbage(opt, ...) 常用选项:"collect" 整轮回收、"count" 用量(KB)、"step" 走一步、"stop"/"restart" 暂停/恢复、"incremental"/"generational" 切换模式。
常用选项各在什么时候用
| 选项 | 做什么 | 典型用途 |
|---|---|---|
"count" | 返回当前用量(KB,浮点) | 量内存(本机空脚本约 41 KB) |
"collect" | 跑一整轮完整回收 | 测内存前先跑一次,排除垃圾干扰 |
"step" | 走一步 | 手动分摊停顿 |
"stop" / "restart" | 暂停 / 恢复 | 关键路径上临时关掉 |
"incremental" / "generational" | 切模式 | 调优(见本章末卡) |
- 量内存的标准姿势是「先 collect 再 count」:直接 count 量到的是「活对象 + 还没回收的垃圾」,两次测量之间的差值毫无意义;
"stop"不是免费的:暂停期间分配的对象一个都不回收,时间稍长就是内存暴涨。它只适合「这一小段绝对不能有停顿」的场景,用完立刻 restart。
print(collectgarbage("count")) -- 当前内存用量(KB,浮点)
collectgarbage("collect") -- 强制完整回收
collectgarbage("stop") -- 暂停自动 GC(性能关键段)
-- … 紧要计算 …
collectgarbage("restart") -- 恢复collectgarbage("setpause")/("setstepmul") 在 5.5 已删除,报「bad argument #1 to 'collectgarbage' (invalid option 'setpause')」;5.5 统一改用 collectgarbage("param", "pause", 200) 读写参数并返回旧值。新旧调参接口的完整对照在 18 章。想让缓存「有人用就在、没人用就让 GC 收走」,靠的就是弱引用。
让 GC 能收走缓存
元表 __mode 含 "k" 则键为弱引用,含 "v" 则值为弱引用。当某对象只被弱引用持有时,可被回收,对应条目自动移除。适合缓存、对象到元数据的映射。
只被弱引用持有的对象会消失
local weak = setmetatable({}, {__mode = "v"})
do local tmp = {} ; weak[1] = tmp ; weak[2] = {} end
collectgarbage("collect")
weak[2] --> nil ← 只有弱表引用它,被回收,条目自动移除__mode的三种取值:"k"弱键、"v"弱值、"kv"两边都弱;- 选哪种取决于「谁决定生死」:
"k"用于「对象活着时才需要它的元数据」(对象→附加信息的映射);"v"用于「别处还在用就留着」的缓存; - 陷阱:字符串和数字不算「可回收对象」——用它们做弱键或弱值,条目永远不会被清掉。弱表只对表、函数、协程、full userdata 生效;
- 另一个陷阱:值里如果间接引用了键,弱键表就清不掉(键被值吊着)。这种情形要用ephemeron 表——Lua 5.2 起
__mode = "k"已经实现了 ephemeron 语义,会正确处理这种循环。
-- 弱值缓存:值无人强引用时自动清除
local cache = setmetatable({}, { __mode = "v" })
local function get(key)
local v = cache[key]
if not v then
v = { data = "为 " .. key .. " 计算的结果" }
cache[key] = v
end
return v
end__mode 只对可回收对象生效:数字、布尔以及字符串都按标量对待,永远不会从弱表移除——弱值表里存字符串等于强引用,缓存字符串结果别指望它自动清(Lua 5.4/5.5)。两种模式各治一种停顿;先弄清自己实际跑在哪种模式下,才谈得上调优。
两种模式各治一种停顿
分代 GC(5.4 引入。lua_newstate 建出的状态机默认增量模式,但独立解释器 lua 启动时会自动切到分代——命令行跑脚本时分代才是实际生效的模式,Lua 5.4/5.5)基于「多数对象早死」假设:频繁小回收扫新对象,少见大回收扫全堆。5.5 把分代模式下原本停顿式的大回收改为增量式,分摊到多步,延迟更平滑。可用 collectgarbage 切换与调参。
你以为的默认,和实际生效的默认不是一回事(源码核对)
lstate.c g->gckind = KGC_INC; ← lua_newstate 建出的状态:增量
lua.c lua_gc(L, LUA_GCGEN); ← 独立解释器启动时:切到分代
(5.4 在 lua.c:645,5.5 在 lua.c:724,本机源码逐行核对)- 所以结论是分场景的:命令行
lua script.lua跑的是分代模式;而把 Lua 嵌进 C 宿主时,除非宿主自己切,否则是增量模式; - 这条差异很实际:在命令行上调好的 GC 参数,搬进游戏引擎或 Nginx 里可能对不上——因为模式根本不同。上手先
collectgarbage("incremental")或("generational")显式定一次,别靠默认; - 分代基于「多数对象早死」:频繁的小回收只扫新对象,少见的大回收才扫全堆。吞吐好,但那次大回收在 5.4 里是停顿式的,会造成延迟尖刺;
- 5.5 的改进正是把分代模式下的大回收也改成增量式,分摊到多步——这是 18 章列的 5.5 两大主线(安全性与性能)里性能那一半的重头。
collectgarbage("generational") -- 切到分代模式(独立解释器启动时已是分代)
collectgarbage("incremental") -- 切到增量模式(lua_newstate 的初始模式)
-- 5.4 分代调参:两个乘数 minormul / majormul
collectgarbage("generational", 20, 100)
-- 5.5 改走 "param" 接口,generational/incremental 的数字参数被忽略
-- collectgarbage("param", "minormajor", 100)collectgarbage("incremental", 200, 100) 这类数字参数在 5.5 被静默忽略、不报错——传入后用 "param" 读回的仍是默认值;5.5 调参必须走 collectgarbage("param", ...)。字符串模式匹配
本章覆盖 Lua 模式的全部语法:字符类、四个量词(- 就是懒惰的 *)、锚点、%b 平衡匹配与 %f 前沿、捕获与位置捕获,以及 find / match / gmatch / gsub 四个函数。它不是正则——没有交替、没有 {n,m}、分组不能加量词,但有正则没有的 %b。
Lua 的「模式」不是正则:语法更小、用 % 转义而不是反斜杠、没有交替 |、没有 {n,m}、不能给分组加量词。学它的成本是十五分钟,而这十五分钟能换掉绝大多数文本处理需求。
字符类
| 写法 | 匹配 | 取反 |
|---|---|---|
%a | 字母 | %A |
%d | 数字 | %D |
%l · %u | 小写 · 大写字母 | %L · %U |
%s | 空白 | %S |
%w | 字母或数字(不含下划线,与正则的 \w 不同) | %W |
%p | 标点 | %P |
%c · %x · %g | 控制字符 · 十六进制数字 · 可打印且非空格 | 各自的大写 |
. | 任意字符,包括换行 | — |
[abc] · [^abc] · [a-z] | 集合 / 取反 / 区间;类内可嵌 %d 这类 | — |
%% · %. | 转义。魔法字符是 ^ $ ( ) % . [ ] * + - ? | — |
一律用 % 转义,不是反斜杠。这也意味着模式串里几乎不需要写 \,普通双引号字符串就够用。
量词、锚点与两个特殊项
| 写法 | 含义 |
|---|---|
* | 0 次或多次,贪婪 |
+ | 1 次或多次,贪婪 |
- | 0 次或多次,懒惰——就是别的语言里的 *? |
? | 0 次或 1 次 |
^ · $ | 串首 / 串尾锚定。^ 只在模式开头才是锚点 |
%bxy | 平衡匹配:从 x 到与之配对的 y,%b() 能正确处理嵌套括号 |
%f[set] | 前沿模式:匹配「前一个字符不在 set、当前字符在 set」的位置,零宽——Lua 版的单词边界 |
%1…%9 | 反向引用第 n 个捕获 |
量词只能跟在单个字符或字符类后面:(ab)* 不合法。需要重复一组东西时只能换思路——这是 Lua 模式最主要的表达力缺口。
捕获
| 写法 | 捕到什么 |
|---|---|
(…) | 捕获匹配到的子串,按左括号顺序编号 |
() | 位置捕获:空括号捕获的是一个下标数字,不是字符串 |
| 没有捕获时 | match 与 gmatch 返回整个匹配;find 只返回起止位置 |
| 上限 | 一个模式里最多 32 个捕获 |
四个函数
| 函数 | 返回 | 要点 |
|---|---|---|
string.find(s, p, init, plain) | 起止位置,再跟上各捕获 | 第四个参数 plain=true 关掉模式、按字面找——找普通子串时又快又省心 |
string.match(s, p, init) | 捕获(无捕获则整个匹配) | 只找第一处 |
string.gmatch(s, p) | 迭代器 | 直接放进泛型 for;不支持 ^ 锚点 |
string.gsub(s, p, repl, n) | 新串 + 替换次数 | 两个返回值,只想要串时记得用括号或只接第一个 |
gsub 的 repl 可以是三种东西:字符串(里面 %1 引用捕获、%0 是整个匹配)、表(拿捕获当键去查)、函数(拿捕获当参数调用)。后两者返回 nil 或 false 时保留原文不替换——这个性质让「按需替换」很好写。
与正则的差别
| 正则有 | Lua 模式 |
|---|---|
交替 a|b | 没有。分两次匹配,或用字符类 |
次数 {2,5} | 没有。手写重复,或用 %d%d%d? 这类 |
分组加量词 (ab)+ | 没有 |
环视 (?=) | 没有。%f 只覆盖了边界这一种情形 |
| 非捕获组 | 没有(也不需要——括号不能加量词) |
| — | Lua 有而正则没有的:%b 平衡匹配。正则要做到这件事得靠递归扩展 |
真正需要完整正则时,生态里有 lrexlib(绑定 PCRE / POSIX)。但先问一句是不是该换个解析思路——需求超出 Lua 模式,往往也超出了「一行搞定」的范围。
plain 参数。string.find(s, "a.b") 里的 . 是通配符,string.find(s, "1+1") 里的 + 是量词——直接把用户输入当模式传进去,轻则找错、重则报 malformed pattern。find(s, sub, 1, true) 才是「按字面找」。string.format 与模式无关但常一起用:%d %s %f %x %c 同 C,外加 %q——把字符串转成可被 Lua 重新读回的字面量(自动处理引号、换行、转义),是手写序列化时最省事的一招(12 章)。Lua 没有内置正则——它用一套只有几条规则的「模式」干同类的活,字符类长得像正则,规矩却简单得多。
几条规则顶掉正则
字符类:%a 字母、%d 数字、%s 空白、%w 字母数字、%l/%u 小/大写、%p 标点、%x 十六进制。大写取反(%A = 非字母)。. 任意字符。自定义集 [...]、取反 [^...]。
量词:* 0+ 贪婪、+ 1+ 贪婪、- 0+ 惰性、? 0/1。锚点 ^(开头)$(结尾)。魔法字符 ( ) . % + - * ? [ ] ^ $ 需用 % 转义。
三个与正则不一样的地方,先记住
- 转义符是
%不是\:%d是数字、%.是字面点号。在 Lua 字符串字面量里写正则习惯的\d会先被字符串转义吃掉,得到的是一个反斜杠加 d; - 惰性量词是
-不是*?:"<a><b>"用<(.-)>得a(惰性),用<(.*)>得a><b(贪婪); - 大写字符类是取反:
%A非字母、%D非数字、%S非空白。这条比正则的[^...]短得多,也是 Lua 模式里最好用的一处; - 魔法字符共 11 个:
( ) . % + - * ? [ ] ^ $。要按字面匹配用户输入时,安全做法是先用s:gsub("%W", "%%%0")之类把它们全转义,或者直接用find(s, pat, 1, true)的 plain 模式(find("a.b", ".", 1, true)返回2 2,而不是1 1)。
print(string.match("年龄 25 岁", "%d+")) --> 25
print(string.match(" trim ", "^%s*(.-)%s*$")) --> trim(去首尾空白)
print(string.match("a1b2", "[%a][%d]")) --> a1
print(string.match("价格:$9.99", "%$([%d.]+)")) --> 9.99(用 %$ 匹配字面 $)| 择一、无 {n,m} 计数、无回溯反向引用(但有捕获引用 %1)。%——即使不是魔法字符(%, 照样匹配逗号);但别对字母数字乱加 %,那是字符类的保留位。圆括号不只是分组:它把匹配到的片段拎出来返回给你,还能在模式里回头引用。
括号把片段拎出来
圆括号 (...) 捕获;可多组,按出现顺序返回。空捕获 () 返回当前位置(数字)。模式内 %1..%9 引用前面第 n 个捕获。%b() 匹配平衡对,%f[set] 边界。
三种捕获,两个都不像正则
普通捕获 match("abcabc", "(abc)%1") --> abc 模式内回引 %1
位置捕获 match("hello", "l()") --> 4 空括号返回位置
平衡匹配 match("f(a(b)c)d", "%b()") --> (a(b)c) 自动配对嵌套括号
边界 gsub("THE (quick) fox",
"%f[%a]%a+%f[%A]", "X") --> X (X) X 共 3 处- 位置捕获
()是 Lua 独有的:返回的是数字下标而不是字符串。用来在一次扫描里同时拿到内容和位置,省掉再调一次find; %b()解决了正则做不到的事——正则(不带递归扩展)无法匹配任意深度的嵌套括号,而%bxy一个记号就够。写表达式解析、剥括号时特别有用;%f[set]是前沿(frontier)模式:匹配一个「前一个字符不在 set、当前字符在 set」的空位置,等价于正则的单词边界\b,但可以自定义字符集;- 捕获数量上限是 32 个,且没有命名捕获——捕获一多就该考虑换 LPeg 了。
local y, m, d = string.match("2025-12-22", "(%d+)-(%d+)-(%d+)")
print(y, m, d) --> 2025 12 22
local pos = string.match("hello", "()l") -- 空捕获取位置(首个 l 的起点)
print(pos) --> 3
-- %1 反向引用:找重复的字母对
print(string.match("aabbcc", "(%a)%1")) --> a(匹配 aa)() 也占一个捕获编号,会把后面 %1..%9 的序号挤位;引用不存在的捕获直接报错,Lua 5.4/5.5 invalid capture index %1。%b() 匹配从 ( 到配对 ) 的整段(含嵌套),解析括号表达式利器。四个函数分工明确:找位置用 find,取内容用 match,遍历用 gmatch,改写用 gsub。
四个函数各司其职
find(s,p,[init,plain])→ 起止位置(+捕获);plain 关闭模式做纯查找match(s,p,[init])→ 首个匹配(有捕获则返回捕获)gmatch(s,p)→ 迭代器,遍历所有匹配gsub(s,p,repl,[n])→ 替换,返回新串与替换次数;repl 可为串/表/函数
gsub 的 repl 有三种形态
字符串 gsub("aaa", "a", "b") --> "bbb", 3 第二个返回值是替换次数
表 gsub("$a $b", "%$(%w+)", {a="1", b="2"}) --> "1 2", 2
← 用捕获去查表
函数 gsub(s, pat, function(c) ... end)
← 返回 nil/false 表示不替换- 表和函数形态是 Lua 模式最被低估的能力:模板渲染
gsub(tpl, "{{(%w+)}}", data)一行即可,不需要任何模板库; - 函数返回
nil或false时保留原文而不是替换成空串——这让「有条件替换」不需要额外判断; - 替换串里
%1–%9引用捕获、%0是整个匹配、%%才是字面百分号。用户输入直接当 repl 是个真实的注入点; - 四个函数的分工记这一句:要位置用 find,要内容用 match,要遍历用 gmatch,要改写用 gsub。
find还独有plain参数做纯字面查找。
for word in string.gmatch("the quick brown fox", "%a+") do
io.write(word, "|") --> the|quick|brown|fox|
end
print()
-- gsub 的 repl 用函数:把每个数字翻倍
local out = string.gsub("a1b2c3", "%d", function(d)
return tostring(tonumber(d) * 2)
end)
print(out) --> a2b4c6
print(("key=val"):find("=", 1, true)) -- plain 查找 → 4 4gsub 返回两个值(新串+次数),把结果直接传给别的函数会把次数也带过去,用括号 (s:gsub(...)) 截断成单值;另外 gmatch 无锚定——^ 在它的模式里退化成字面字符,("abc"):gmatch("^a") 一次都不匹配。s:match(...)、s:gsub(...) 等,等价于 string.xxx(s,...)。格式化输出走的是 C 的 printf 路线:一条格式串管宽度、精度、对齐。
C printf 那一路
string.format(fmt, ...) 支持 %d %i %u %f %g %e %s %x %o %c %a 等。%q 输出可被 Lua 重新读取的安全字面量。%s 会对参数调 tostring。5.3+ 用 %d 格式化非整值浮点会报错。
两个会咬人的地方
string.format("%d", 1.5)
--> bad argument #2 to 'string.format'
(number has no integer representation)
string.format("%q", 'he said "hi"\n')
--> "he said \"hi\"\
" ← 换行被写成「反斜杠 + 真实换行」,这是合法 Lua 字面量- 第一条是 5.3 之后的收紧:
%d只接受有整数表示的值,1.5直接报错而不是静默截断。想截断要自己写math.floor或用%.0f; - 第二条是
%q的设计目的——它输出的不是给人看的,是给load读回去的。所以换行、引号、控制字符都按 Lua 字面量规则转义,这也是序列化那张卡(12 章)该用它的原因; %s会对参数调tostring,所以能直接塞表(走__tostring,07 章);%a(十六进制浮点)是精确输出浮点的另一条路,不丢任何位——5.5 起tostring本身也保证浮点可无损回读(18 章)。
print(string.format("%d 个,单价 %.2f 元", 3, 9.9))
print(string.format("%5d|%-5d|", 42, 42)) -- 宽度/左对齐
print(string.format("十六进制 %x", 255)) --> ff
print(string.format("%q", '含"引号"的串')) -- 安全转义,可回读%d 遇到带小数的浮点直接报错,Lua 5.4/5.5 bad argument #2 to 'format' (number has no integer representation)——除法结果先 math.floor 或改用 %.0f。先知道模式缺什么,才不会拿正则的习惯硬套 Lua。
先知道缺什么
Lua 模式是刻意精简的:实现小、无正则回溯的指数爆炸风险。但缺少:
- 择一
a|b(需拆成多次匹配或字符集) - 计数量词
{2,4} - 前后断言 lookahead/lookbehind
- 转义用
%而非\
缺的这几样,各有替代路子
| 正则有 | Lua 模式 | 怎么绕 |
|---|---|---|
择一 a|b | 没有 | 拆成多次 match,或用字符集 [ab] |
计数量词 {2,4} | 没有 | 写死重复,或匹配后再判长度 |
| 前后断言 | 没有 | %f[set] 只能顶一部分 |
| 命名捕获 | 没有 | 按顺序取 |
| — | %b() 平衡匹配 | 正则反而做不到 |
- 这些缺失是刻意的:没有择一与回溯,Lua 模式的匹配就不可能出现正则那种指数级爆炸(ReDoS)——拿用户输入当模式在 Lua 里相对安全得多;
- 整个模式引擎在
lstrlib.c里只有几百行,这正是 Lua「能塞进任何地方」这一定位的一部分; - 真需要完整能力时用 LPeg(解析表达式语法):它不是正则,而是可组合的解析器,写复杂语法比正则更清晰,也没有回溯爆炸问题。
-- Lua 模式没有 | ,以下需分开处理:
local function match_any(s, ...)
for _, p in ipairs({...}) do
local m = s:match(p)
if m then return m end
end
end
print(match_any("error: x", "error", "warn")) --> error"\d" 会直接编译失败,Lua 5.4/5.5 invalid escape sequence near '"\d'——Lua 字符串的转义表里没有 \d,字符类一律用 % 前缀。标准库:深水区
标准库的深水区:那些签名里没写、文档也不加粗,但不知道就会错的契约。首卡是一张定位表,其后逐条展开——table.sort 抛错停在半排序态、concat 遇洞静默截断、# 背后的数组 / 哈希双骨架、os.time 与 os.clock 的两种刻度、yield 过不去的 C 边界。结论以 Lua 5.5 为准。
这一章的每张卡展开一条契约。先用这张表定位:左边是你正要调的函数,右边是它没写在签名里的那条规矩。
契约清单
| 你在用 | 没写出来的契约 | 后果 |
|---|---|---|
table.sort(t, cmp) | 不稳定;cmp 必须是严格小于(用 <= 会报 invalid order function);抛错后表停在半排序态、不回滚 | 相等元素顺序不可预期;出错后数据已被打乱 |
table.concat(t, sep) | 元素只能是字符串或数字;遇到洞会静默截断 | 少拼一半而不报错 |
table.insert / remove | 按 #t 定位,而 #t 对有洞的表没有定义(06 章) | 插到意料之外的位置 |
table.unpack(t) | 同样依赖 #t;有洞时必须显式给 i, j | 少展开几个值 |
#t | 只对无洞序列有定义;表内部是数组 + 哈希两副骨架,边界处结果会跳 | 长度忽大忽小 |
os.time() / os.clock() | 两只表、两种刻度:一个是墙上时间(秒),一个是 CPU 时间(可能高精度) | 拿 os.time 测耗时,短任务全是 0 |
coroutine.yield | 不能跨 C 调用边界——排序比较函数、gsub 回调、元方法里都不行 | attempt to yield across a C-call boundary |
pairs 遍历中改表 | 删键允许,新增键是未定义行为(04 章) | invalid key to 'next' 或静默漏元素 |
string 的下标 | 全部按字节,且 1-based;负数从末尾数 | 切中文切出半个字(16 章 utf8) |
string.gsub 的返回 | 两个值:新串 + 次数 | 直接当参数传会多塞一个数字进去(05 章) |
共同的形状
| 规律 | 说明 |
|---|---|
| 「洞」是重复出现的主角 | #、ipairs、concat、sort、unpack 全部只对无洞序列有定义。自己维护一个 n 字段,这一整类问题就消失了 |
| 失败不一定回滚 | Lua 的标准库没有事务语义。出错后数据停在中途是常态,pcall 接住了不等于状态还干净 |
| 多返回值会悄悄跑到别处 | gsub、find、pcall 都返回多个值,放在参数末位就会全部展开(05 章) |
本章结论以 Lua 5.5 为准,与 LuaJIT 有分岔的地方各卡会逐条点名(00 章版本表)。
#t 给什么、pairs 按什么顺序走,都取决于表内部两副骨架当时的形状——同一份代码换个数据规模、换个 Lua 版本就可能换个结果。别把观察到的行为当成契约。「比较器必须严格小于」这条 19 章清单和 06 章都讲过;深水区是它的另外两条暗契约——相等的元素会被打乱,以及比较器一旦抛错,表就停在半排序态。
不稳定:相等元素不保原序
table.sort是快排族的原地排序,规范不承诺稳定性:右侧里 90 分组原顺序是甲丙戊庚,排完变成甲戊丙庚——丙、戊换了位;- 对照:Python 的 sort、JS 的 sort(ES2019 起)、Go 的
sort.SliceStable都有稳定承诺或稳定选项,Lua 没有,要「同分保持先来后到」得自己动手(见 tip)。
抛错 = 数据已被打乱
- 比较器中途
error(最常见的抛错源:拿 nil 字段做比较)时,pcall拿回 false,但表既不是原样也没排好——{5,3,8,1,9,2,7}排到一半抛错,表停在{1,3,8,2,9,5,7}; table.sort不是事务,pcall包住它救不回数据:要么先浅拷贝再排,要么保证比较器对任何一对元素都不抛错。
local recs = {
{name="甲", score=90}, {name="乙", score=80}, {name="丙", score=90},
{name="丁", score=80}, {name="戊", score=90}, {name="庚", score=90},
}
table.sort(recs, function(a, b) return a.score > b.score end)
for _, r in ipairs(recs) do io.write(r.name, r.score, " ") end
--> 甲90 戊90 丙90 庚90 乙80 丁80 (90 分组原序甲丙戊庚,被打乱;5.5)
-- 比较器抛错:表停在半排序态
local t = {5, 3, 8, 1, 9, 2, 7}
local ok = pcall(table.sort, t, function(a, b)
if a == 9 or b == 9 then error("badfield") end
return a < b
end)
print(ok, table.concat(t, ",")) --> false 1,3,8,2,9,5,7(半成品)<= 多半当场就炸 invalid order function for sorting(元素多或含重复时必触发;{3,1,2} 这种小数据能溜过去——19 章清单有);更隐蔽的是比较器偶尔抛错——nil 字段只在特定数据里出现,测试环境全绿,线上抛错时表已经是半成品。for i, r in ipairs(recs) do r.i = i end,比较器写成「主键不同比主键、相同比下标」:if a.score ~= b.score then return a.score > b.score end return a.i < b.i。table 库对参数的要求比签名严得多——有的立刻甩 bad argument(还算友好),有的静默少给你一截,后者才严重。
concat:类型严格,范围却听 # 的
- 元素只收字符串和数字,遇上布尔/表/nil 报
invalid value (boolean) at index 3 in table for 'concat'; - 拼接范围默认
1..#t——遇上洞,5.5table.concat({1,2,nil,4})静默给"12"(它的 # 是 2),同一行在 LuaJIT 上却报invalid value (nil) at index 3(它的 # 是 4)。一个截断一个报错,都「合规」,因为 # 对带洞的表本就未定义(06 章)。
insert / remove:位置必须真实存在
insert只接受 2 或 3 个参数,传 4 个报wrong number of arguments——想「插一段」得逐个来或用table.move;- pos 超出
1..#t+1报position out of bounds;remove对空表返回 nil,不报错。
unpack 的默认区间也是 1..#
table.pack(1, nil, 3)存了.n = 3,但这张表的#是 1——table.unpack(p)只还原出 1 个值,且不报错;- 打包内容可能含 nil 时,取的时候必须写全
table.unpack(p, 1, p.n)——06 章讲了「存」的那一半(用 pack 拿 .n),这是「取」的另一半。
print(table.concat({1, 2, nil, 4}))
--> 12 (5.5:静默截断;LuaJIT:invalid value (nil) at index 3)
print(pcall(table.concat, {1, "a", true}))
--> false invalid value (boolean) at index 3 in table for 'concat'
print(pcall(table.insert, {1, 2, 3}, 10, "x"))
--> false bad argument #2 to 'table.insert' (position out of bounds)
local p = table.pack(1, nil, 3)
print(#p, p.n) --> 1 3
print(select("#", table.unpack(p))) --> 1(丢了两个,不报错)
print(select("#", table.unpack(p, 1, p.n))) --> 3i, j。bad argument #N to 'table.insert' (原因),#N 直指肇事参数;concat 的类型错(invalid value…)和 insert 的参数个数错(wrong number of arguments)另有文案——格式不统一,但都在调用当场炸,不会静默。06 章说表的两部分「由实现自动选择,你不用管」——日常够用;但当你追问「# 为什么给 2」「内存去哪了」「大表为什么卡一下」,答案全在这副骨架里。
键住哪里,账单差几倍
- 连续整数键住数组部分——5.5 起是「紧凑数组」(18 章),每槽只存值加 1 字节类型标记;其余键住哈希部分,每槽要存键+值+链指针,贵好几倍;
- 同样 10 万个值,5.5 顺序整数键 ≈1.1 MB、
i*100稀疏键 ≈3 MB、字符串键 ≈8 MB(字符串对象本身也计入)——绝对值因机器与版本而异,比值是结构决定的,用右侧的量具自己跑一遍。
# 是在数组部分上二分
- 长度运算符在数组上二分找「此处非 nil、下一个是 nil」的位置——带洞的表有多个合法落点,
{1,2,nil,4}5.5 二分落在 2、LuaJIT 落在 4,都合规; - 这就是 06 章那句「未定义」的机制成因:不是掷骰子,是取决于各实现内部布局与二分的落点,而这些你控制不了。
成长的代价:翻倍 rehash,且不退款
- 两部分都按 2 的幂成长,装满触发 rehash——分配新空间、整表搬迁,逐个 append 大表会有周期性停顿,还留着最多一倍闲置;
- 反方向不自动收缩:10 万元素逐个置 nil,内存一字节不还(1152 KB 原样),只有丢弃整表才归零——要释放就换新表;
- 已知规模用 5.5 的
table.create(n)预分配:同样 10 万顺序键879 KB vs 1152 KB——879 恰是 10 万槽 × 9 字节的精确账单,省掉的差额正是翻倍余量。
-- 量具:某种构造方式吃多少内存(KB)
local function kb(f)
collectgarbage("collect"); collectgarbage("collect")
local before = collectgarbage("count")
local keep = f()
collectgarbage("collect")
return math.floor(collectgarbage("count") - before)
end
local N = 100000
print(kb(function() local t={}; for i=1,N do t[i]=i end; return t end))
--> 1152 (顺序键:数组部分)
print(kb(function() local t={}; for i=1,N do t[i*100]=i end; return t end))
--> 3072 (稀疏键:掉进哈希部分)
print(kb(function() local t=table.create(N); for i=1,N do t[i]=i end; return t end))
--> 879 (5.5 预分配:省掉翻倍余量)t = table.create(N)),旧表交给 GC。table.create(nseq, nrec) 的第二个参数是给哈希部分的预留,建大字典同样有效:10 万字符串键6.6 MB vs 8.1 MB——省的是节点翻倍余量,大头(字符串对象)省不掉。16 章 os 卡讲了「墙钟用 time、测性能用 clock」的分工;深水区讲刻度——以及一条藏在 C 标准里的跨平台分岔。测错了表,数字看着也很像真的。
os.time 的刻度是 1 秒
- 返回整数秒,连续两次调用相等;拿它测短代码永远得 0——不是代码快,是它根本没有亚秒刻度。
os.clock 的步进有限
- 分辨率不是无限的:循环等它「变一次」就能测出步进,本机 5.5 约 1 ms,机器不同步进不同;
- 短于一个步进的代码段测出 0 不代表不耗时——重复 N 遍取总时长再除 N。
两种系统上走的不是同一种时间
os.clock包装 C 的clock(),ISO C 只承诺返回「处理器时间的近似」;- 分岔:Windows(CRT 文档明说按墙钟实现)上等待外部命令约 1 秒,os.clock 差 1.05;Linux 上同样等 1 秒,clock() 只走 0.07——睡眠、等 IO 的时间不计入;
- 含 IO 或等待的代码,两个平台给出的「耗时」完全不同——跨平台基准别依赖 os.clock。
print(os.time() == os.time()) --> true(整数秒,两次调用落在同一秒)
-- 测 os.clock 的步进:等它变一次
local c1 = os.clock()
local c2 = os.clock()
while c2 == c1 do c2 = os.clock() end
print(c2 - c1) --> 0.001(本机步进;你的机器自己跑)
-- 等待 1 秒外部命令,两只表的答案(Windows)
local c0, t0 = os.clock(), os.time()
os.execute("ping -n 2 127.0.0.1 > nul")
print(os.clock() - c0, os.time() - t0)
--> Windows: 1.05 1(墙钟) Linux 同类实验: 0.07 1(CPU 时间)11 章那句 pitfall 点过 sort 和 pcall;这里给整张地图——这条红线比传闻窄得多(5.1 时代连 pcall、元方法都过不去),但它还在,而且报错位置有迷惑性。
5.5 清单
- 可让出 ✓:
pcall/xpcall内部、元方法(__index/__add/__concat)、自写的 for 迭代器、gmatch的循环体; - 不可 ✗:
gsub的替换函数、table.sort的比较器——报attempt to yield across a C-call boundary。
判据:你是不是「C 的回调」
gmatch能行,是因为 yield 发生在你自己的循环体(Lua 帧)里,迭代器只在两次迭代之间被调;gsub的 repl 则嵌在 C 函数的调用栈里执行——回调的发起者是不可让出的 C 函数,就过不去。
版本与实现注记
- 5.1 时代 pcall/元方法也是红线(yield 当年名声差正因于此),5.2 起大幅放开;
- LuaJIT 与 5.5 口径一致:pcall ✓、__index ✓、gsub ✗,只是报错文案少个冠词(
across C-call boundary)。
local function probe(f)
local co = coroutine.create(f)
local ok, err = coroutine.resume(co)
return ok and "可让出" or err
end
print(probe(function() pcall(coroutine.yield) end))
--> 可让出
print(probe(function()
local m = setmetatable({}, {__index = coroutine.yield})
return m.x
end))
--> 可让出(元方法里 yield,5.5 没问题)
print(probe(function() ("abc"):gsub("%a", coroutine.yield) end))
--> attempt to yield across a C-call boundary
print(probe(function()
table.sort({2, 1}, function(a, b) coroutine.yield(); return a < b end)
end))
--> attempt to yield across a C-call boundarygmatch 把匹配收集进表(循环体里随便 yield),处理完再一次性 gsub/concat 拼回。标准库速查
本章首卡是一屏一行一函数的标准库总表,其后逐库详解 base / string / math / io / os / utf8 / debug,每库配可运行示例。行为契约在 15 章「深水区」。
把 base / string / math / io / os / utf8 / debug(附 table / coroutine)压成一屏——按库分组,每个函数配一个最小示例调用,右侧注释给用途 / 结果;标 ★ 的本页另有专卡 / 专章。
怎么用这一屏
- 按库分组、每个函数一行、右侧注释给用途或结果——它的定位是「知道有这么个东西」而不是「学会怎么用」;
- 标 ★ 的本页另有专卡或专章:模式匹配见 14 章、协程见 11 章、GC 见 13 章、C API 见 17 章;
- 速查表最该被反复看的是你以为自己知道的那些——
table.remove的第二个参数、select("#", ...)、string.rep的分隔符参数,这类「原来还能这样」的细节都在这一屏里。
-- —— 基础库 base ——
print("x", 1) -- 输出到 stdout,参数间加 Tab
type({}) -- 取类型名 → "table"
tostring(3.14) -- 任意值 → 字符串(走 __tostring)
tonumber("ff", 16) -- 串 → 数字,可指定进制 → 255
pairs({a=1, b=2}) -- 迭代所有键值(含哈希部分)
ipairs({10, 20, 30}) -- 从 1 连续迭代数组部分
next({a=1}) -- 手动取下一对键值
select("#", "a", "b") -- 变参个数或第 n 个 → 2
rawget(t, "k") -- 绕过 __index 读
rawset(t, "k", 1) -- 绕过 __newindex 写
rawequal({}, {}) -- 绕过 __eq 比较 → false
rawlen({1, 2, 3}) -- 绕过 __len 取长度 → 3
setmetatable(t, mt) -- 设元表
getmetatable("s") -- 取元表(字符串有共享元表)
load("return 6 * 7") -- 编译串 / 函数为 chunk ★
pcall(fn, arg) -- 保护调用,捕获错误
xpcall(fn, debug.traceback) -- 带错误处理器的 pcall
error("boom") -- 抛出错误
assert(ok, "失败信息") -- 断言,假则 error
warn("msg") -- 发警告(5.4+)
collectgarbage("count") -- GC 控制 / 查询用量
print(_VERSION) -- 版本串 → "Lua 5.4"
_G["print"] -- 全局环境表(_G)
-- —— string 库 ——
string.sub("café", 1, 3) -- 子串,支持负索引 → caf
string.len("café") -- 字节长度 → 5
string.rep("ab", 3, "-") -- 重复 + 分隔 → ab-ab-ab
string.byte("A") -- 字符 → 字节值 → 65
string.char(72, 73) -- 字节值 → 字符 → HI
string.upper("go") -- 转大写 → GO
string.lower("GO") -- 转小写 → go
string.reverse("abc") -- 反转字节序 → cba
string.find("a=1", "=") -- 查找起止位置 → 2 2 ★
string.match("id=42", "%d+") -- 提取首个匹配 → 42 ★
string.gmatch("a,b,c", "%a+") -- 迭代所有匹配 ★
string.gsub("a.b", "%.", "/") -- 替换 → a/b 1 ★
string.format("%.2f", 3.14159) -- 格式化 → 3.14
string.pack("i4", 42) -- 值 → 二进制(5.3+)
string.unpack("i4", bytes) -- 二进制 → 值
string.packsize("i4d") -- 打包字节数 → 12
-- —— math 库 ——
math.pi -- 常量 π ≈ 3.1416
math.huge -- 正无穷 inf
math.maxinteger -- 整数上界
math.mininteger -- 整数下界
math.floor(3.7) -- 向下取整 → 3
math.ceil(3.2) -- 向上取整 → 4
math.abs(-5) -- 绝对值 → 5
math.sqrt(9) -- 平方根 → 3.0
math.exp(1) -- e 的幂 → 2.718
math.log(8, 2) -- 对数 → 3.0
math.sin(0) -- 正弦(弧度)→ 0.0
math.cos(0) -- 余弦 → 1.0
math.tan(0) -- 正切 → 0.0
math.atan(1, 1) -- 反正切(取代旧 atan2)
math.max(1, 5, 3) -- 最大 → 5
math.min(1, 5, 3) -- 最小 → 1
math.fmod(7, 3) -- 浮点取余 → 1.0
math.modf(3.75) -- 拆整数 / 小数 → 3.0 0.75
math.type(3) -- integer / float / nil
math.tointeger(3.0) -- 转整数 → 3
math.random(1, 6) -- 随机整数(无参 [0,1))
math.randomseed() -- 播种(无参自动,5.4+)
-- —— io 库 ——
io.open("data.txt", "w") -- 打开 → 句柄(失败 nil+err)
io.lines("data.txt") -- 逐行迭代器(自动关闭)
io.read("l") -- 读默认输入一行
io.write("hi") -- 写默认输出
io.input("in.txt") -- 设 / 取默认输入句柄
io.output("out.txt") -- 设 / 取默认输出句柄
io.popen("ls") -- 管道执行进程
io.tmpfile() -- 临时文件句柄
f:read("a") -- 句柄按格式读全部
f:write("hi") -- 句柄写入
f:lines() -- 句柄逐行迭代
f:seek("set", 0) -- 定位读写位置
f:flush() -- 刷新缓冲
f:close() -- 关闭句柄
f:setvbuf("full") -- 设缓冲策略
-- —— os 库 ——
os.time() -- 当前时间戳
os.date("%Y-%m-%d") -- 格式化时间(! 前缀=UTC)
os.difftime(t2, t1) -- 两时间戳差(秒)
os.clock() -- 进程 CPU 时间(测性能)
os.getenv("PATH") -- 读环境变量
os.execute("ls") -- 执行 shell 命令
os.remove("tmp.txt") -- 删除文件
os.rename("a.txt", "b.txt") -- 重命名
os.tmpname() -- 临时文件名
os.setlocale("C") -- 设本地化
os.exit(0) -- 退出进程
-- —— utf8 库 ——
utf8.char(20320, 22909) -- 码点 → UTF-8 串 → 你好
utf8.codepoint("A") -- 取码点值 → 65
utf8.len("café ☕") -- 字符数(非字节数)→ 6
utf8.codes("café") -- 迭代 (位置, 码点)
utf8.offset("café", 3) -- 第 n 字符的字节位置
utf8.charpattern -- 匹配单字符的模式串(常量)
-- —— debug 库 ——
debug.traceback("msg", 1) -- 栈回溯字符串
debug.getinfo(print) -- 函数 / 栈帧信息
debug.getlocal(1, 1) -- 读局部变量
debug.setlocal(1, 1, 42) -- 写局部变量
debug.getupvalue(fn, 1) -- 读 upvalue
debug.setupvalue(fn, 1, 9) -- 写 upvalue
debug.sethook(f, "l") -- 设调试钩子
debug.getmetatable(io) -- 取任意类型元表
debug.setmetatable(x, mt) -- 设任意类型元表
-- —— table 库(详见「table 标准库」专卡) ——
table.insert(t, "x") -- 追加 / 插入元素 ★
table.remove(t, 1) -- 移除并返回 ★
table.concat({"a", "b"}, ",") -- 连接为字符串 → a,b ★
table.sort(t) -- 原地排序(可传比较器) ★
table.unpack({1, 2, 3}) -- 展开为多值 ★
table.pack("a", "b") -- 打包为带 n 的表 ★
table.move(a, 1, 3, 1, b) -- 批量移动元素 ★
-- —— coroutine 库(详见协程专章) ——
coroutine.create(fn) -- 创建协程 ★
coroutine.resume(co, 1) -- 恢复运行 ★
coroutine.yield(1, 2) -- 挂起并返回 ★
coroutine.status(co) -- 状态查询 ★
coroutine.wrap(fn) -- 包成可调用迭代器 ★
coroutine.isyieldable() -- 能否让出 → bool ★
coroutine.running() -- 当前协程 ★
coroutine.close(co) -- 关闭协程(5.4+) ★math.pow / math.atan2 5.3 起已删——用 ^ 与 math.atan(y,x)。字节 vs 字符:Lua 串是字节序列,#s / s:sub 按字节切,处理 Unicode 一律走 utf8 库。安全:load 执行任意代码,不可信输入须传受限 env 沙箱;debug.* 能突破封装,仅用于调试、别写进业务。os.time,测性能用 os.clock(CPU 时间)。f:read / io.read 格式:"l" 行去换行、"L" 含换行、"n" 数字、"a" 全部、数字 n 读 n 字节。标 ★ 的库 / 函数本页另有专卡或专章详解。不用 require 就能直接用的那批全局函数——类型转换、遍历、元表、保护调用都在这里。
不用 require 的那批全局函数
tostring / tonumber(s [,base])转换pairs / ipairs / next遍历select变参操作rawget/rawset/rawequal/rawlen绕过元方法setmetatable/getmetatableload(chunk [,name,mode,env])动态编译pcall/xpcall/error/assert/warn_G全局表、_VERSION
raw* 系列:绕开元方法的后门
rawget(t,k)/rawset(t,k,v)/rawequal(a,b)/rawlen(t)分别绕开__index/__newindex/__eq/__len;- 写代理表时
rawset是必需的,否则__newindex里直接赋值会递归调用自己(07 章); rawget的另一个用途是区分「本表真的有这个键」与「从__index继承来的」——面向对象里判断实例有没有覆盖某方法就靠它;next(t, k)是pairs的底层:返回下一个键值对。遍历过程中往表里加新键是未定义行为(删除当前键是允许的),这是 Lua 手册明文写着的一条。
print(tonumber("ff", 16)) --> 255
print(tonumber("3.14")) --> 3.14
print(rawequal({}, {})) --> false(不走 __eq)
-- load:把字符串编译成函数
local f = load("return 6 * 7")
print(f()) --> 42
print(_VERSION) --> Lua 5.4load 执行任意代码,勿对不可信输入使用;必要时传入受限的 env 沙箱。select("#", ...)——它如实数出个数(select("#", nil, nil) 得 2),而 #{...} 遇到 nil 结果不可靠(得到 0)。字节层面的字符串工具箱:切、拼、转码、二进制打包,模式匹配之外的全部家当。
字节层面的工具箱
sub(s,i,j)子串(支持负索引)、len、rep(s,n[,sep])byte(s,i,j)/char(...)字节值(0-255)互转upper/lower/reverse- 模式:
find/match/gmatch/gsub/format(见「字符串模式匹配」) pack/unpack/packsize二进制打包(5.3+)
两个容易忽略的性质
- 所有索引都支持负数:
s:sub(-3)取最后三个字节、s:byte(-1)取最后一个字节。这让「取扩展名」「去尾字符」这类操作不需要先算长度; - 字符串可以用冒号调库函数:
s:upper()等价于string.upper(s)——因为所有字符串共享一个元表,其__index指向string表。这也是沙箱要小心的地方(12 章):能拿到任意字符串就能摸到整个 string 库; string.pack/unpack/packsize(5.3+)做二进制打包,格式串控制字节序、宽度、对齐——读写二进制文件协议不需要写 C 扩展;byte/char处理的是字节(0–255)而不是字符,非 ASCII 要走utf8库。
local s = "Lua Programming"
print(s:sub(1, 3)) --> Lua
print(s:sub(-11)) --> Programming(负索引从末尾)
print(("ab"):rep(3, "-")) --> ab-ab-ab
print(string.byte("A")) --> 65
print(string.char(72,73)) --> HI("café"):upper() 得到 CAFé(é 不变),reverse 会把多字节 UTF-8 字符拆碎——反转后 utf8.len 直接返回 nil。s:byte(1, -1) 一次取出整串所有字节值(("ABC"):byte(1, -1) → 65 66 67);反向 string.char 超出 0-255 报 value out of range。数学函数之外,这个库还管整数/浮点的身份鉴定和随机数。
数学函数与身份鉴定
- 常量
math.pi、math.huge、math.maxinteger/mininteger floor/ceil/abs/sqrt/exp/log/sin/cos/tan/max/min/fmod/modfmath.type(x)→ integer/float/nil;math.tointeger(x)math.random([m[,n]])、math.randomseed([x[,y]])(5.4 起新 RNG,无参 randomseed 自动播种)
5.4 换掉了随机数发生器
- 5.4 起
math.random用的是 xoshiro256**,质量远好于旧版直接转发 C 的rand()——旧版在某些平台上只有 15 位随机性,做洗牌、抽样时会露馅; - 不带参数的
math.randomseed()会自动用一个「尽量随机」的种子播种(5.4+)。旧代码里那句math.randomseed(os.time())现在不但多余,还更糟——同一秒内启动的两个进程会得到同一个序列; math.type(x)是判断整数/浮点的唯一正确方式,不要用x % 1 == 0:那对2.0也返回真,可它是浮点;math.maxinteger/math.mininteger给出 64 位整数边界,math.huge是浮点无穷大——这两组不是一回事,比较时别混。
print(math.floor(3.7), math.ceil(3.2)) --> 3 4
print(math.max(1, 5, 3), math.min(1, 5, 3)) --> 5 1
math.randomseed() -- 5.4+:无参自动随机播种
print(math.random(1, 6)) -- 1..6 的整数(骰子)
print(math.type(3), math.type(3.0)) --> integer floatmath.pow、math.atan2 等 5.3 起已移除;用 ^ 和 math.atan(y,x)。math.maxinteger + 1 静默回绕成 math.mininteger;整数 1 // 0 报错 attempt to divide by zero,浮点 1.0 // 0 却安静地得 inf(Lua 5.4/5.5)。Lua 的文件读写只有薄薄一层:打开拿句柄,句柄上读写,用完关掉。
打开、读写、关掉
io.open(name, mode) → 文件句柄(失败返回 nil+错误)。模式 r/w/a/r+/w+/a+,加 b 二进制。句柄方法::read(fmt)、:write(...)、:lines()、:seek()、:close()。io.lines(name) 便捷逐行迭代。
read 的格式串在 5.3 改过
- 格式:
"l"读一行(不含换行)、"L"读一行(含换行)、"n"读一个数、"a"读到 EOF、数字n读 n 个字节; - 5.3 起格式串前面不再需要
*("*l"改成"l")。旧写法仍被接受,所以网上两种写法混着流传——新代码统一不加; io.open失败时返回nil, 错误信息而不是抛错,所以标准写法是local f = assert(io.open(path, "r"))——一行完成打开与报错;- 文件句柄要么显式
:close(),要么交给<close>(10 章):句柄的__gc会兜底关闭,但时机不确定,写文件时可能导致数据迟迟不落盘; io.lines(name)迭代结束后会自动关闭文件,但中途break就不会——这正是coroutine.close那一卡(11 章)讲的同一类问题。
local f = io.open("data.txt", "w")
if f then
f:write("第一行\n第二行\n")
f:close()
end
-- 逐行读取(推荐:配合 <close> 自动关闭)
for line in io.lines("data.txt") do
print(line)
endio.open 失败不抛错,返回 nil+原因串,链式 io.open(f):read("a") 会变成 attempt to index a nil value;而 io.lines 打不开文件是直接抛错(cannot open file ...)——两者错误风格不同,别混着处理。"l" 行(去换行)、"L" 含换行、"n" 数字、"a" 全部、数字 n 读 n 字节。时间、环境变量、进程控制——Lua 与操作系统打交道的全部出口。
与操作系统打交道的出口
os.time([t])/os.date([fmt[,t]])时间;os.difftimeos.clock()进程 CPU 时间(计时用)os.getenv(name)环境变量os.execute(cmd)、os.remove、os.rename、os.tmpnameos.exit([code[,close]])
计时用 clock 还是 time,取决于你要量什么
os.clock()量的是 CPU 时间(本进程实际占用处理器的秒数,浮点)——量算法快慢用它,睡眠和等 IO 的时间不计入;os.time()量的是墙上时钟(整数秒)——量「用户等了多久」用它,但秒级精度对多数基准测试来说太粗;os.date("*t")返回一张表(year/month/day/hour/min/sec/wday/yday/isdst),os.date("!%Y-%m-%d")的感叹号表示用 UTC;os.exit(code, close)的第二个参数很重要:传true才会正常关闭 Lua 状态、跑完终结器;不传就直接退进程,缓冲区里没写完的内容会丢。
print(os.date("%Y-%m-%d %H:%M:%S")) -- 格式化当前时间
print(os.date("!%Y-%m-%dT%H:%M:%SZ")) -- ! 前缀 = UTC
-- 计时
local t0 = os.clock()
for i = 1, 1000000 do end
print(string.format("耗时 %.3fs", os.clock() - t0))os.time{...} 的表必须给全 year/month/day,缺一个报 field 'day' missing in date table(hour/min/sec 可省);os.date 的格式串也会校验,非法格式符报 invalid conversion specifier。os.time;测性能用 os.clock(CPU 时间),二者语义不同。Lua 字符串是字节序列,想按「字符」思考就得靠这个库换算。
按字符思考的换算层
UTF-8 感知工具(5.3 引入):utf8.char(...) 码点→串、utf8.codepoint(s,i,j)、utf8.len(s[,i,j]) 字符数、utf8.codes(s) 迭代、utf8.offset(s,n[,i]) 第 n 字符的字节位置(5.5 同时返回结束位置)、utf8.charpattern 匹配单个 UTF-8 字符的模式。
它只做换算,不做「字符串是 Unicode」这件事
- Lua 的字符串始终是字节序列,
utf8库不改变这一点——它提供的是「把字节位置换算成字符位置」的工具; - 所以
#s仍然是字节数,要字符数得用utf8.len(s);utf8.len遇到非法编码会返回nil加出错位置,正好可以拿来做输入校验; - 要遍历字符用
for pos, code in utf8.codes(s);要按字符切用utf8.offset(s, n)先换算出字节位置再sub; utf8.charpattern是一个能匹配单个 UTF-8 字符的模式串,可以直接嵌进 14 章的模式里用;- 5.5 起
utf8.offset同时返回该字符的结束位置,省掉「再算一次下一个字符起点」的一步(18 章)。
local s = "café ☕"
print(utf8.len(s)) --> 6(字符数,非字节数 #s=9)
for pos, code in utf8.codes(s) do
io.write(code, " ") -- 逐个码点
end
print()
print(utf8.char(20320, 22909)) --> 你好#/sub 按字节切。s:gmatch(utf8.charpattern);utf8.len 还能当校验器——遇到非法字节返回 nil 加出错的字节位置(utf8.len("\xE9zz") → nil 1)。一扇看进解释器内部的窗:栈帧、局部变量、upvalue 都能读能改。
看进解释器内部
反射/调试用途,生产逻辑慎用:debug.traceback([msg[,level]]) 栈回溯、debug.getinfo 函数/栈帧信息、debug.getlocal/setlocal、debug.getupvalue/setupvalue、debug.sethook 钩子、debug.getmetatable/setmetatable(可改非表类型元表)。
它在生产环境是个安全洞
debug.getupvalue/setupvalue能读写任意闭包的 upvalue、debug.setmetatable能给任何类型(包括数字)挂元表、debug.getlocal能读别的栈帧的局部变量;- 换句话说,沙箱里只要留了
debug库,所有隔离都是纸糊的(12 章)——它能直接改掉宿主的_ENV; - 合法用途只有两类:调试与错误报告(
debug.traceback配xpcall,09 章)、以及性能剖析(debug.sethook设指令计数或行钩子); debug.sethook还有一个防御用途:给不可信代码设指令计数上限并强制报错,这是沙箱唯一能挡住死循环的手段。
local function f() return debug.traceback("到此一游", 1) end
print(f()) -- 打印带调用栈的回溯
local info = debug.getinfo(print)
print(info.what) --> C(print 是 C 函数)xpcall(f, debug.traceback) 让错误自带调用栈;debug.getinfo(f).what 返回 "C" 还是 "Lua",可判断函数的实现语言。C API 与嵌入
本章覆盖:虚拟栈的索引规则(含伪索引)、push / to / check 三组函数的分工、表操作与调用、注册一个 C 函数的完整步骤,以及四种常见崩法。C 和 Lua 之间只靠一个栈交换数据——这是 Lua 的本命用法,也是它一切设计的理由所在。
C API 的全部心智模型只有一句:C 和 Lua 之间靠一个虚拟栈交换数据。所有函数都是「往栈上放」「从栈上取」「用栈上的东西做事」这三类。
索引规则
| 写法 | 指向 |
|---|---|
1 | 栈底(函数被调用时就是第一个参数) |
-1 | 栈顶——最常用的一个 |
lua_gettop(L) | 栈上元素个数,也就是参数个数 |
LUA_REGISTRYINDEX | 伪索引:C 侧存放持久数据的注册表 |
lua_upvalueindex(n) | 伪索引:C 闭包的第 n 个上值 |
三组函数
| 组 | 代表 | 用途 |
|---|---|---|
| push | lua_pushnumber · pushinteger · pushstring · pushboolean · pushnil · pushcfunction | C → Lua:把值放上栈 |
| to | lua_tonumber · tointeger · tolstring · toboolean | Lua → C:不检查类型,转不了给 0 / NULL |
| check | luaL_checknumber · checkinteger · checkstring · checktype · optinteger | 写库函数时该用的就是这一组:类型不对直接抛出带参数位置的标准报错 |
| 栈操作 | lua_settop · pop · pushvalue · remove · insert · rotate | 调整栈 |
| 表操作 | lua_newtable · getfield · setfield · gettable · settable · rawget / rawset | 带 raw 的绕过元方法(07 章) |
| 调用 | lua_call · lua_pcall | 要能接住错误就用 pcall;call 出错会 longjmp 走掉 |
注册一个 C 函数
| 步骤 | 要点 |
|---|---|
| 函数签名 | int f(lua_State *L)——返回值是「往栈上留了几个返回值」,不是错误码 |
| 取参数 | luaL_checkinteger(L, 1),位置从 1 数起 |
| 返回 | push 想返回的值,然后 return 个数 |
| 成库 | 填一张 luaL_Reg 表,luaL_newlib(L, funcs) |
| 入口 | 动态库导出 int luaopen_模块名(lua_State *L),名字必须与 require 的模块名对上 |
| 抛错 | luaL_error(L, "…")——它不返回,后面的清理代码不会执行 |
四种常见崩法
| 症状 | 原因 |
|---|---|
| 栈越用越长,最后崩 | push 了没 pop。每个 C 函数都该保持栈平衡,写完数一遍 |
| 拿到的字符串指针失效 | lua_tolstring 给的指针由 Lua 管理,值被 GC 或被弹出栈后就悬空——要留就自己拷一份 |
用了 lua_call,Lua 侧报错后 C 的资源没释放 | 错误是用 longjmp 实现的,会跳过 C 的清理代码。改用 lua_pcall |
| 栈空间不够 | 要往栈上放很多值前得先 lua_checkstack;默认只保证 20 个槽位 |
longjmp(或 C++ 异常,取决于编译方式)实现错误传播,它会直接跳出你的 C 函数,栈上的局部清理一律不做。所以在 C 侧持有资源时,要么用 lua_pcall 包住可能出错的调用,要么把资源交给带 __gc 的 userdata 管(13 章)。lua_* 还是 luaL_* 时,规则很简单:luaL_ 是辅助库,更安全、更省事(自动报错、自动建表),写模块时优先用它;lua_ 是底层原语,需要精确控制时才下到这一层。C 和 Lua 之间不传指针不传结构体,所有数据都排队走这条虚拟栈。
一切都走虚拟栈
C 与 Lua 通过每个 lua_State 的虚拟栈交换数据。正索引从栈底(1)起,负索引从栈顶(-1)起。lua_push* 压值、lua_to*/luaL_check* 取值、lua_pop 弹出。库无全局状态,完全可重入。
为什么要有这么一层栈
- 因为 GC:Lua 的对象随时可能被移动或回收,C 侧不能长期持有裸指针。栈是「Lua 知道 C 正在用哪些值」的唯一途径——只要值在栈上,GC 就不会动它;
- 因为可移植:不暴露内部结构,Lua 就能自由改实现(5.5 的紧凑数组就是这样悄悄换掉的,18 章),而 C 扩展不用改;
- 负索引是最实用的一处设计:
-1永远是栈顶,写代码时不必去数当前有几个值; - 纪律:每个 C 函数都要清楚自己进入时栈上有什么、退出时留下几个返回值。返回值个数就是 C 函数的返回值(
return 2表示「我在栈顶留了两个值」); luaL_check*系列比lua_to*更该用:类型不对时它会抛出带参数编号的标准错误信息,不用自己写校验。
/* C 端:调用 Lua 全局函数 add(3, 4) */
lua_getglobal(L, "add"); /* 压入函数 */
lua_pushinteger(L, 3); /* 压入参数 */
lua_pushinteger(L, 4);
lua_call(L, 2, 1); /* 2 参 1 返回 */
int r = (int)lua_tointeger(L, -1); /* 取栈顶结果 */
lua_pop(L, 1); /* 清理 */lua_tostring 只认字符串和数字,对 boolean/nil 返回 NULL;而且对数字它会把栈上那个槽原地改成字符串(类型 number → string)——遍历表时对键调它会弄坏 lua_next。luaL_check* 系列在类型不符时抛 Lua 错误并带清晰信息,写 C 扩展应优先用它做参数校验。几行样板代码就能把 Lua 解释器塞进 C 程序,再把 C 函数暴露给脚本调。
把解释器塞进 C 程序
最小嵌入:luaL_newstate() 建状态 → luaL_openlibs() 开标准库 → luaL_dostring/dofile() 跑脚本 → lua_close()。反向:把 C 函数(签名 int f(lua_State*))注册进 Lua。
最小嵌入只有四行
lua_State *L = luaL_newstate(); // 建状态 luaL_openlibs(L); // 开标准库(可裁剪,见本章末卡) luaL_dostring(L, "print('hi')"); // 跑脚本 lua_close(L); // 收
- 没有全局状态:所有东西都挂在
lua_State上,所以同一个进程里可以开任意多个互不干扰的解释器,也天然支持多线程(一线程一状态); - 这正是 Lua 成为「嵌入式标杆」的技术根据——整个解释器约 300 KB、无外部依赖、可重入,Nginx、Redis、魔兽世界、各种游戏引擎都是这么用的;
- 反向注册 C 函数:签名固定是
int f(lua_State *L),参数从栈上取、结果压回栈、返回值是「压了几个」; luaL_Reg数组 +luaL_newlib是注册一整个库的标准样板,比逐个lua_pushcfunction干净。
/* 注册一个 C 函数 */
static int l_greet(lua_State *L){
const char *name = luaL_checkstring(L, 1);
lua_pushfstring(L, "你好, %s", name);
return 1; /* 返回值个数 */
}
static const luaL_Reg lib[] = {
{"greet", l_greet},
{NULL, NULL}
};
int luaopen_mylib(lua_State *L){
luaL_newlib(L, lib); /* 建表并注册 */
return 1;
}lua_pcall,别用 lua_call——后者遇错走 panic,gcc 嵌入(5.4/5.5 同)进程直接 Abort,输出 PANIC: unprotected error in call to Lua API (boom)。嵌入方升级 5.5 前看这里:裁剪标准库、零拷贝字符串都是给宿主的新工具。
给宿主的新工具
- luaL_openselectedlibs(L, load, preload):按位选择要打开/预加载哪些标准库,嵌入时裁剪更精细
- luaL_makeseed(L):生成用于哈希/随机的种子
- 外部字符串:C 侧可提供不由 Lua 管理内存的字符串,零拷贝共享大文本
- 静态二进制:加载内存中的二进制 chunk 时可复用其原始内存
四项改动,各解决一个嵌入方的老问题
| 新东西 | 解决什么 |
|---|---|
luaL_openselectedlibs | 过去只能全开或全部手动注册;现在按位选择,沙箱与裁剪都容易了 |
| 外部字符串 | 大文本过去必须拷进 Lua 堆;现在可零拷贝共享宿主内存 |
| 静态二进制 | 加载内存中的字节码时可复用原始内存,省一次拷贝 |
luaL_makeseed | 给哈希与随机数生成种子,宿主不必自己造 |
- 「外部字符串」对嵌入大文本的场景是实打实的省:以前把一个几十 MB 的配置或脚本喂给 Lua,堆里就得多一份;
- 升级前必须知道的一条:字节码格式不兼容——5.4 的
string.dump产物在 5.5 上加载不了(09 章)。预编译分发的项目要一并重新编译。
/* 只打开需要的库(示意):相较 luaL_openlibs 全开,可减小占用 */
luaL_openselectedlibs(L, mask_load, mask_preload);
/* 载入内存中的预编译 chunk,5.5 可复用原始内存,减少拷贝 */LUA_VERSION_NUM 条件编译(5.4=504、5.5=505);5.5 里 luaL_openlibs 本身就是 luaL_openselectedlibs(L, ~0, 0) 的宏,裁剪时传 LUA_GLIBK | LUA_STRLIBK 这类位掩码即可(gcc 只开这两个后 math 为 nil)。Lua 5.5 新特性
2025-12-22 发布,距 5.4 五年。本章覆盖六条改动及其迁移影响:global 声明、具名变参表、只读循环变量、紧凑数组与 table.create、增量式大回收,以及库与 C API 的调整。主题是安全性与性能两条线。
5.5 于 2025-12 发布,距 5.4 五年。主题是两个:安全性(把「意外的全局变量」和「被改写的循环变量」变成错误)与性能(紧凑数组、增量式大回收)。
改动清单
| 特性 | 是什么 | 对旧代码的影响 |
|---|---|---|
global 声明 | 全局变量需要显式声明 | 最大的一条:把 01 章那个「拼错名字悄无声息」的问题从惯例提升为语言机制 |
| 具名变参表 | 给 ... 直接起个表名 | 纯增强;省掉 table.pack(05 章) |
| 循环变量只读 | for 的控制变量不能在循环体里赋值 | 会让旧代码报错——但那种写法本来就是 bug 的温床 |
紧凑数组 & table.create | 预分配数组空间、更省内存的表示 | 纯性能;已知大小时用 table.create(n) 免去反复扩容 |
| 增量式大回收 | 分代 GC 里那次「扫全部」也切成增量 | 纯性能:削掉最后一个长停顿(13 章) |
| 其他 | 若干库函数与 C API 调整 | 见本章末卡与 17 章 |
该不该升
| 情况 | 结论 |
|---|---|
| 写独立的命令行脚本 / 新项目 | 升。global 声明与只读循环变量能当场拦掉两类最常见的错误 |
| 嵌在宿主里(Neovim / OpenResty / 游戏) | 由不得你——宿主嵌的是哪个版本就是哪个,多半还是 LuaJIT 的 5.1(00 章) |
| 要发布给别人用的库 | 别用 5.5 独有语法,否则装不上 5.5 的人根本编译不过——这类特性是语法级的,无法用 if _VERSION 绕过 |
| 包管理器只有 5.4 | 源码编译;或者继续用 5.4,两者差距远小于 5.1 到 5.3 那一跳 |
local 加上、别改循环变量),再切 5.5。这样绝大多数改动在 5.4 上就已经完成,切过去只是打开了强制检查。漏写 local 就静默创建全局——Lua 最经典的坑,5.5 终于能在编译期拦住了。
终于能在编译期查拼写
每个 chunk 都隐式以 global * 开头 —— 所有未声明的自由名默认是全局变量(即旧行为)。一旦进入任意 global 声明的作用域,「默认全局」失效:此后所有名字都必须显式声明。
- global * · 通配:所有未声明名字均为可读写全局(默认)
- global<const> * · 通配只读:未声明名字均为只读全局(适合库/内置)
- global none · 严格模式:只能访问已显式声明的全局
- global X, Y · 声明具体名字,可带
<const>属性
四种写法,从最松到最严
| 写法 | 效果 | 用在 |
|---|---|---|
global * | 未声明名字都是可读写全局(即旧行为,默认) | 不写就是这个 |
global <const> * | 未声明名字都是只读全局 | 防止意外覆盖库函数 |
global none | 只能访问已显式声明的全局 | 严格模式,推荐新代码 |
global X, Y | 声明具体名字,可带 <const> | 配合 none 白名单 |
- 规则的关键在这一句:一旦作用域里出现任意一条
global声明,「默认全局」就失效,之后所有名字都必须显式声明——所以它是渐进的,老文件不写就完全不受影响; - 它修的是 02 章那个「Lua 头号坑」:漏写
local静默建全局、拼错名字读到nil也不报错。以前只能靠运行时的strict.lua之类的元表把戏兜,现在是编译期检查; - 这也是 5.5 两条主线(安全性、性能)里安全性那一半的头号特性。
X = 1 -- Ok,默认全局
do
global Y -- 使隐式的 global * 失效
Y = 1 -- Ok,Y 已声明为全局
X = 1 -- 错误:X 未声明
end
X = 2 -- Ok,块外又恢复默认全局
-- 常见「严格模式」写法(放在文件开头)
global<const> * -- 所有内置只读,可捕获误写
global result -- 显式声明可写的全局
print(math.pi) -- Ok
result = 42 -- Ok
typo_var = 1 -- 错误:未声明global<const> * 并不会冻结全局表,Lua 5.5 _ENV.X = 1 照写不误,其他 chunk 也能随意改——它防拼写错误,不是沙箱。另外手册已把 global 列为保留字,5.5.0 默认编译暂靠 LUA_COMPAT_GLOBAL 兼容选项让旧代码里叫 global 的变量继续用,该选项未来会移除,改名要趁早。"use strict" + 显式声明,但 Lua 保留了逐块、可通配的细粒度控制;旧代码不加 global 声明则行为完全不变(向后兼容)。新项目推荐文件头写 global<const> *,把手滑创建全局的经典 bug 挡在编译期。以前收集变参要写一行 table.pack 样板,5.5 让你在参数表里直接给变参表起名。
给变参表起个名
变参函数的 ... 之后可选地跟一个名字(前面没有逗号)。该名字是一个只读局部变量,引用一张包含所有变参的表。若不命名,变参仍只能通过 ... 表达式访问。
它省掉的那一行样板
-- 5.4 及以前 function f(...) local args = table.pack(...) for i = 1, args.n do ... end end -- 5.5 function f(... args) -- 注意 ... 后面没有逗号 for i = 1, #args do ... end end
- 这个名字是一个只读局部变量,引用一张包含全部变参的表——不写名字时行为完全同旧版,只能用
...表达式; - 好处不只是少写一行:
table.pack每次调用都新建一张表,而具名变参表由编译器安排,不需要时不会构造; - 要区分「传了
nil」和「没传」仍然要看table.pack的.n字段或select("#", ...)——#args遇到尾部的nil还是会失灵(06 章序列那条规则)。
local function log(level, ... args) -- 注意:... 与 args 之间无逗号
print("变参个数:", select("#", ...))
print("首个:", args[1])
print("变参表:", args) -- table: 0x...
end
log("INFO", "a", "b", "c")
-- 命名前的等价写法(仍然可用)
local function old(...)
local t = table.pack(...) -- t.n 记录个数
return t
end#args 求个数;首选 args.n(5.5 的变参表自带 n 字段记录个数),需兼容旧版本时用 select("#", ...)。args[i]、args.n)时,编译器根本不会分配这张表,直接翻译成内部变参访问;一旦把它整个传出去或塞进闭包才真正建表。Lua 5.5 1000 次纯索引调用零分配,1000 次 return args 约分配 100KB——性能敏感路径注意用法。在循环体里给 i 赋值从来就改不动迭代次数,5.5 干脆把它定为编译错误。
把改不动的东西定为错误
for 循环的控制变量在 5.5 中变为只读(如同带 <const>)。在循环体内对它赋值会报编译错误,消除了一类隐蔽 bug。
它消除的是哪类误解
- 写
for i = 1, 10 do i = i + 1 end想跳着走——在 5.4 及以前这段代码合法,但完全无效:每轮开始时控制变量都会被重新赋值,你的修改立刻被覆盖; - 于是代码看起来在做一件事、实际做另一件事,而且没有任何提示。5.5 把它定为编译错误,等于把「你想的和它做的不一样」提前暴露出来;
- 要跳步就改用
for i = 1, 10, 2(步长),或者换while; - 与
<const>(10 章)是同一条思路:把「这个绑定不该被改」写进语言,让编译器去查。
for i = 1, 10 do
i = i + 1 -- 5.5:编译错误(变量只读)
print(i)
end
-- 需要改动时,复制到新的局部变量
for i = 1, 10 do
local j = i
j = j + 1
print(j)
endi = i + 10 后三轮照样跑满,数值 for 用内部副本计数,改了本来就不影响迭代;5.5 只是把这种自欺写法变成编译错「attempt to assign to const variable 'i'」(泛型 for 只有第一个控制变量 k 只读,v 照样能赋值)。真要跳步,用 while。同样的大数组少花六成内存,还能预分配省掉反复扩容——5.5 给表的数组部分动了大手术。
给表的数组部分动手术
5.5 重新设计了表的数组部分存储,连续整数键的大数组约省四成内存(每槽从 16 字节降到 9 字节;旧版在对齐/填充上浪费超 40%——15 章有逐槽账单的)。新增 table.create(nseq [, nrec]) 预分配数组部分(nseq)与可选的哈希部分(nrec),避免边填边扩容的多次 rehash。构造器也支持更多嵌套层级。
省下来的是什么
- 旧版每个数组槽存一个完整的
TValue:值 8 字节 + 类型标记 1 字节,因对齐补到 16 字节——超过 40% 浪费在填充上; - 5.5 把类型标记单独抽出来存成一个字节数组,每槽降到约 9 字节,大数组内存降约四成(15 章有逐槽账单的);
table.create(nseq [, nrec])解决的是另一个问题:边填边扩容会触发多次 rehash,每次都要重新分配并搬运。预分配之后一次到位;- 这一条对「读一个几十万行的文件到数组里」这类脚本是直接可感的——既省内存,也省掉反复搬运的时间。
-- 预知规模时预分配,减少扩容开销
local t = table.create(1000) -- 预留 1000 个数组槽
for i = 1, 1000 do t[i] = i * i end
-- 构造器现在支持更多层级(更深的嵌套字面量)
local cfg = { a = { b = { c = { d = 1 } } } }table.create(1000) 得到的是空表:Lua 5.5 #t 为 0、next(t) 为 nil——nseq 只是预分配提示,不是「造一张长 1000 的表」。参数也有校验:负数或过大直接报「bad argument #1 to 'create' (out of range)」。分代模式的大回收原本一停停到底,5.5 把它切成小步摊开,延迟尖刺被削平。
削平延迟尖刺
5.5 的改动点:分代模式(5.4 引入。lua_newstate 默认增量模式,但独立解释器启动时自动切到分代——命令行跑脚本时分代才是实际生效的模式,见 13 章)下的大回收(major collection)原本是原子式「停顿世界」的,会造成延迟尖刺;5.5 把它改为增量式,工作分摊到多步,显著削平停顿。两种模式的机制与调参见「内存与垃圾回收 · GC 模式:分代 vs 增量」。
改的是分代模式下的那一次大回收
- 分代 GC 的日常是频繁的小回收(只扫新对象,很快);但每隔一阵要做一次大回收扫全堆——5.4 里这一次是原子的、停顿世界的;
- 后果是「平时都很流畅、偶尔卡一下」,而这种偶发尖刺在游戏与实时系统里最棘手,也最难复现;
- 5.5 把大回收也切成增量步骤分摊执行,用一点吞吐换掉延迟尖刺;
- 结合 13 章那条源码核对的结论一起看:命令行跑脚本时分代是实际生效的模式,所以这项改进对绝大多数脚本使用者是默认受益的;而嵌入宿主里默认是增量模式,本来就没有这个尖刺。
collectgarbage("incremental", pause, stepmul, stepsize) 带参形式在 5.5 不报错但参数被静默忽略,得改用 collectgarbage("param", "pause", 200);旧的 "setpause"/"setstepmul" 则直接报「invalid option 'setpause'」(Lua 5.4/5.5 对照)。主菜之外,5.5 还带了一把小改动,条条都可能碰到。
一把小改动
- utf8.offset 现在同时返回该字符的结束位置
- 浮点打印:以足够位数的十进制输出,保证能无损回读
lua.c动态加载 readline- 底层与 C API 变化(外部字符串、
luaL_openselectedlibs/luaL_makeseed、静态二进制、字节码不兼容)详见 17 章「5.5 的 C API 变化」
升级前必看的一条:字节码不兼容
string.dump的产物在大版本间不通用,5.4 编的字节码 5.5 加载不了。预编译分发、把脚本打进二进制的项目要全部重新编译;- 浮点打印改为「足够位数、保证无损回读」——这修的是序列化的一个老坑(12 章):以前
tostring(0.1)可能丢位,存盘再读回来就不是原来的数了; utf8.offset同时返回结束位置,按字符切串少算一次;- C API 侧的变化(外部字符串、
luaL_openselectedlibs、luaL_makeseed、静态二进制)见 17 章——只有嵌入方需要关心,纯脚本使用者不受影响。
-- 浮点回读:print 输出的字面量重新 load 得到同一个值
print(0.1 + 0.2) -- 5.4: 0.3(位数不足,回读后已不是原值)
-- 5.5: 0.30000000000000004(足够位数,无损回读)
-- utf8.offset(5.5 返回起止两个位置)
local s = "héllo"
local i, j = utf8.offset(s, 2) -- 第 2 个字符的起始与结束字节位置print(0.1 + 0.2) 从 5.4 的 0.3 变为 0.30000000000000004——依赖旧输出格式的快照 / 文本比对测试会失败。0.1+0.2 这类值会变长。跨版本要稳定文本(日志、快照测试),显式 string.format("%.14g", x),Lua 5.4/5.5 输出一致。陷阱与进阶提示
本章覆盖:高频易错清单、给 JS/TS 开发者的逐条对照表,以及 5.1 → 5.5 的版本演进脉络。
最容易反复踩的坑,集中列一遍,上线前对着扫一眼。
上线前对着扫一眼
- 索引从 1 开始:
t[0]不是「第一个」,库函数都按 1..n - 默认全局:漏写
local即建全局(5.5 用global<const> *防) - nil 制造洞:数组中间放 nil →
#/ipairs失灵 - 0 和 "" 为真:条件判断别沿用 JS 直觉
- / 恒浮点:要整数商用
//;10/2得5.0不是5 - 比较器:
table.sort的 cmp 必须严格小于,别用<=
六条里有四条同源
- 「1-based」「nil 制造洞」「
#不可靠」「table.*只对序列有定义」其实是同一件事的四个面:Lua 的数组是「键为 1..n 的表」这个约定,而不是一种独立类型。约定一破,围绕它的所有工具同时失效; - 记法:把
nil当成「删除」而不是「一个值」。要表示空位就用false或哨兵对象; - 「默认全局」在 5.5 有了编译期解法(18 章
global none);在 5.4 及以前只能靠local纪律或strict.lua; - 「
/恒浮点」与「0 和 "" 为真」则是纯粹的跨语言直觉冲突——它们本身规则很简单,只是和你的旧习惯不同(02 章各有一卡)。
-- 典型:误以为含洞表长度可靠
local t = {}; t[1]=1; t[3]=3
print(#t) -- 未定义(1 或 3)
-- 典型:整除
print(10 / 3) --> 3.3333333333333335(5.5 起按可无损回读的位数打印)
print(10 // 3) --> 3#t 可能返回 1 也可能返回 3,一次 print 碰巧正确不代表可靠,别拿测试里的偶然结果当保证。而 table.sort 用 <= 比较器是真报错——「invalid order function for sorting」(Lua 5.4/5.5,含重复元素时触发)。语法看着眼熟,直觉全不同——这张表把从 JS 迁移要重写的心智开关列全了。
要重写的心智开关
- 索引:JS 从 0,Lua 从 1
- 真值:JS
0/""/NaN假;Lua 只有nil/false假 - 相等:Lua 无隐式转换的
==(近似 JS 的===),不等号是~= - 作用域:
local≈let;但未声明变量默认全局 - 类型:number 分整/浮;字符串不可变、按字节
- 拼接:用
..,无模板串;格式化用string.format - 对象:表 = 对象+数组+Map;OOP 靠元表手搭
- 异步:无 Promise/async;用协程做协作式并发
八项对照,逐条给出对应关系
| JS | Lua | |
|---|---|---|
| 索引起点 | 0 | 1 |
| 假值 | 0 "" NaN null undefined | 只有 nil 和 false |
| 相等 | == 隐式转换、=== 不转 | == 不转换(≈ ===),不等号是 ~= |
| 作用域 | let/const 块作用域 | local 块作用域,不写就是全局 |
| 数字 | 只有 double | 整数与浮点两个子类型 |
| 字符串 | UTF-16 码元序列 | 字节序列,按字符要用 utf8 库 |
| 拼接 | + / 模板串 | ..,没有模板串,格式化用 string.format |
| 异步 | Promise / async | 没有;用协程做协作式并发 |
- 最容易出错的是「相等」那一行的反面:Lua 的
==不隐式转换,所以"1" == 1是false——但算术运算会自动把数字字符串转成数字("1" + 1得 2,07 章)。比较不转、运算转,这条不对称要单独记; - 「没有异步」并不意味着写不了并发:协程 + 宿主的事件循环(OpenResty、luv)就是 Lua 世界的 async/await,只是调度器要由宿主提供。
-- JS: arr.map(x => x*2) 的 Lua 写法
local function map(t, f)
local r = {}
for i, v in ipairs(t) do r[i] = f(v) end
return r
end
print(table.concat(map({1,2,3}, function(x) return x*2 end), ",")) --> 2,4,6obj[1] 和 obj["1"] 是同一个键;Lua 里 t[1] 与 t["1"] 是两个不同的键(t[1]=x 后 t["1"] 仍是 nil,而 t[1.0] 等于 t[1])。还有惯用的 x = x || default 直译成 x or default 会把 false 值也覆盖掉——Lua 里 false 是合法取值时要显式判 nil。二十年五个大版本,知道每版加了什么,读旧代码、选运行时心里才有谱。
五个大版本的增量
- 5.1(2006):模块系统、增量 GC、可重入解析器。LuaJIT 仍基于 5.1
- 5.2(2011):
goto、_ENV环境、可让出的 pcall、终结器 - 5.3(2015):整数子类型、位运算、
//、utf8 库、string.pack - 5.4(2020):分代 GC、
<const>/<close>、warn、新随机数 - 5.5(2025):global 声明、具名变参表、只读循环变量、紧凑数组、增量大回收
为什么 LuaJIT 停在 5.1
- LuaJIT 是基于 5.1 的独立实现(部分吸收了 5.2/5.3 的特性),性能常常比官方解释器快一个数量级——但它不是 5.4/5.5;
- 所以选运行时要先问「我需要哪些语言特性」:整数子类型、位运算、
//、utf8库(5.3),<const>/<close>/分代 GC(5.4),global声明(5.5)在 LuaJIT 上都没有; - 反过来,OpenResty、大量游戏引擎与嵌入式场景仍以 LuaJIT 为主——它们要的是速度和 FFI,而不是新语法;
- 实务判据:写业务脚本、要新语法、要长期跟官方走 → 官方 5.4/5.5;跑在 OpenResty 或既有引擎里、要极致性能与 FFI → LuaJIT,并按 5.1 写。两边的代码不通用,这个选择要在项目开头做。
-- 查询当前版本
print(_VERSION) --> Lua 5.5
-- 特性探测优于版本号硬比较
local has_integers = math.type ~= nil -- 5.3+
local has_close = load("local x <close> = nil") ~= nil -- 编译成功才为真(5.4+)luaconf.h 里的兼容选项(如 LUA_COMPAT_GLOBAL)未来会移除——有条件就在关掉兼容选项的构建下跑一遍测试,为下次升级提前排雷。Lua 的界面:宿主里的 UI 与 LÖVE
本章覆盖:宿主速查(谁提供什么 UI API、各自跑哪版 Lua)、独立 Lua 自己开窗口的五条路与取舍、以及零依赖的终端方案。「Lua 有什么 UI 库」这个问题问错了方向——Lua 是嵌入式语言,界面属于宿主,它只是那层脚本。
Lua 的界面问题只有一个入口:先确定宿主是谁。宿主决定了你能调什么 API、跑的是哪版 Lua,也决定了搜到的教程有没有用。
宿主速查
| 宿主 | Lua 版本 | 界面 API | 文档入口 |
|---|---|---|---|
| Neovim | LuaJIT(5.1) | vim.api 的窗口 / 缓冲区 / 浮动窗 | :h lua-guide |
| LÖVE | LuaJIT(5.1) | love.graphics 画布 + love.update / love.draw 回调 | love2d.org 的 wiki |
| Roblox | Luau(5.1 方言 + 类型标注) | 引擎的组件树 | creator 文档 |
| 魔兽世界插件 | 5.1(沙箱化) | 客户端的控件系统(Frame / Texture) | Wowpedia |
| OpenResty | LuaJIT(5.1) | 没有界面——它输出的是 HTTP 响应 | lua-nginx-module 文档 |
| Redis | 5.1 | 没有界面 | EVAL 命令文档 |
注意这一列全是 5.1。本页讲的整数子类型、//、位运算符、<const> / <close>,在上面这些地方一个都没有(00 章版本表)。
独立 Lua 自己开窗口:五条路
| 方案 | 是什么 | 取舍 |
|---|---|---|
| LÖVE | 2D 游戏框架,自带窗口、绘制、音频、输入 | 最省事的一条:装上就能跑;界面风格是「自己画」,不是原生控件 |
| IUP | 跨平台原生控件绑定 | 控件是系统原生的;要编译 C 扩展、版本匹配麻烦 |
| wxLua | wxWidgets 绑定 | 控件全、体积大;维护活跃度一般 |
| Dear ImGui 绑定 | 即时模式 GUI | 适合工具与调试面板;同样要编译 |
| 终端 | io.write + ANSI 转义序列 | 零依赖的底线方案:清屏 \27[2J、定位 \27[行;列H、颜色 \27[31m |
共同问题是装起来比写起来麻烦:多数绑定要编译 C 扩展、要匹配 Lua 版本、要处理平台差异。如果界面是这个程序的主体,通常不该从「独立 Lua + GUI 绑定」出发——换成 LÖVE,或者把 Lua 嵌进一个 C++ / Rust 程序当脚本层(17 章),才是它被设计出来要做的事。
cmd.exe 则要看系统版本——做跨平台 TUI 时这一条要先验。nvim_open_win 换到 LÖVE 一点用没有——学的是宿主的 API,不是 Lua 的。前面几门语言都能问「用哪个 UI 库」,Lua 不能。它被设计成嵌进别的程序里——解释器只有几百 KB,标准库里连文件对话框的影子都没有。所以真正该问的是:我的宿主程序给了什么 UI API?
看看 Lua 实际都在哪些界面背后
| 宿主 | Lua 在这里干什么 | 界面归谁 |
|---|---|---|
| Neovim | 插件、配置、整个 UI 层 | 编辑器提供窗口/缓冲区 API |
| LÖVE | 游戏逻辑与绘制 | 框架给画布、音频、输入 |
| Roblox | 游戏与 UI 脚本 | 引擎的组件树 |
| 魔兽世界插件 | 整个插件生态 | 游戏客户端的控件系统 |
| Redis / nginx / 各种嵌入式设备 | 逻辑扩展 | 根本没有界面 |
这带来一个真实的好处
- 你不必学一套 UI 框架——窗口管理、事件循环、渲染、输入法这些最难的部分,宿主已经做完了,你只写「按下这个键做什么」。
- 代价是知识不通用:Neovim 的
nvim_open_win换到 LÖVE 一点用没有。学的是宿主的 API,不是 Lua 的。 - 所以判断一份 Lua UI 教程有没有用,第一件事是看它针对哪个宿主,而不是看它写得好不好。
独立 Lua 想自己开窗口呢
- 有办法,但都是绑定层:IUP(跨平台原生控件)、wxLua(wxWidgets 绑定)、Dear ImGui 的各种 Lua 绑定、以及下面要讲的 LÖVE。
- 共同问题是装起来比写起来麻烦:多数要编译 C 扩展、要匹配 Lua 版本、要处理平台差异,而且维护活跃度普遍不高。
- 诚实的结论:如果界面是这个程序的主体,通常不该从「独立 Lua + GUI 绑定」出发,而是换个宿主——用 LÖVE,或者把 Lua 嵌进一个 C++/Rust 程序当脚本层(这也正是 Lua 被设计出来要做的事)。
-- 同一件事,在不同宿主里长什么样
-- Neovim:编辑器给你缓冲区和窗口
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_open_win(buf, false, { relative = "editor", width = 24, height = 2 })
-- LÖVE:框架给你一块画布和每帧回调
function love.draw()
love.graphics.print("25 °C = 77.0 °F", 20, 20)
end
-- 独立 Lua:标准库里什么都没有,只能靠终端
io.write("25 °C = 77.0 °F\n")
-- 想确认自己在什么宿主里,先看这些全局变量存不存在
print(_VERSION) -- Lua 5.5 / Lua 5.1(LuaJIT)
print(vim ~= nil, love ~= nil, jit ~= nil)想动手写点 Lua 界面又不想折腾绑定,Neovim 是最省事的入口:编辑器已经把窗口管理、事件循环、渲染全做好了,你只写 Lua;而且它能在完全没有界面的情况下跑脚本,方便实验和验证。
无界面也能造窗口
nvim -l script.lua可以在不进入编辑器界面的情况下执行 Lua 并调用完整的 UI API——非常适合拿来做实验。- 造一个浮动窗口:
nvim_create_buf(false, true)建临时缓冲区 → 写两行文本 →nvim_open_win开窗,返回窗口 id 1001,读回配置得 宽 24、高 2、带边框,缓冲区行数 2。 nvim_win_close(win, true)之后nvim_win_is_valid(win)变 false——句柄失效是可验证的,写插件时用它判断窗口还在不在。- 模型很清楚:缓冲区(内容)和窗口(视图)是分开的。同一个缓冲区可以开多个窗口,关掉窗口不影响内容。这是 Vim 的基本概念,也是它的 UI API 的骨架。
最大的坑:Neovim 的 Lua 是 5.1
nvim -l里_VERSION得 Lua 5.1,jit.version得 LuaJIT 2.1——Neovim 内嵌的是 LuaJIT,对应的是 Lua 5.1 方言。- 直接后果:本页 18 章讲的 5.5 新特性,在 Neovim 里一个都用不了。整数除法
//、位运算符、<close>/<const>属性、goto之外的新语法、math.type、整数子类型——全部没有。 - 反过来 LuaJIT 也有 5.1 之外的东西(
ffi、部分 5.2 特性),所以它既不是纯 5.1 也不是 5.4。写之前跑一句print(_VERSION, jit and jit.version)最实在。 - 这条不是 Neovim 独有:各个宿主内嵌的 Lua 版本各不相同,是嵌入式语言的常态。魔兽是 5.1、Redis 是 5.1、有的嵌入式设备还停在更早的版本。
-- 存成 win.lua,用 nvim -l win.lua 跑(不进界面)
local buf = vim.api.nvim_create_buf(false, true) -- 不列出、临时
vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
" 温度换算 ",
" 25 °C = 77.0 °F ",
})
local win = vim.api.nvim_open_win(buf, false, {
relative = "editor", width = 24, height = 2,
row = 1, col = 2, border = "rounded", style = "minimal",
})
local cfg = vim.api.nvim_win_get_config(win)
print(cfg.width, cfg.height) -- 24 2
print(vim.api.nvim_buf_line_count(buf)) -- 2
vim.api.nvim_win_close(win, true)
print(vim.api.nvim_win_is_valid(win)) -- false
-- 先确认自己在哪个方言上
print(_VERSION, jit and jit.version)
-- Lua 5.1 LuaJIT 2.1.1774896198// 和位运算符直接语法错误,而报错信息只说「unexpected symbol」,不会告诉你是版本不对。LuaJIT 里要用 math.floor(a / b) 和 bit 库。nvim -l 是被严重低估的 Lua 实验载体:启动快、有完整的 vim.* 标准库(vim.fn、vim.json、vim.system 都在),还能直接把结果 print 出来。写插件时它也是最快的单元测试跑法——不用开界面就能验逻辑。如果确实要一个独立的、有界面的 Lua 程序,实际可行的只有两条路:用 LÖVE 把自己变成一个游戏,或者老老实实用终端。
LÖVE:不只是游戏框架
- 它给 Lua 补上了运行时该有的一切:窗口、2D 绘图、图片与字体、音频、键盘鼠标手柄输入、以及一个每帧回调的主循环(
love.load/love.update/love.draw)。 - 它不是 UI 工具包——没有按钮、没有输入框、没有布局系统,一切都得自己画。但对「界面简单、图形自由度高」的小工具(可视化、玩具、教学演示),这反而更灵活。
- 分发是它最大的优点:把代码和资源打成一个
.love文件(其实就是 zip),或者和运行时拼成单个可执行文件。相比前面几章那些「要带一整个运行时」的方案,这条路轻得多。 - 要控件的话,社区有 SUIT、Slab 这类即时模式 UI 库——都是纯 Lua 的,扔进项目就能用,不用编译。
终端:标准库就能做到的那部分
- 纯 Lua 的标准库里没有任何终端控制函数,但ANSI 转义序列只是普通字符串——
io.write就能发出去。Lua 5.5 下\27[2J(清屏)、\27[1;32m(亮绿)、\27[7m(反显)都正常工作。 - 够用来做什么:带颜色的输出、原地刷新的进度条、简单的表格。做个能看的命令行工具,这些通常就够了。
- 再往上要光标定位、按键读取、窗口尺寸,就得上
lcurses这类绑定——又回到「要编译 C 扩展」的问题。
什么时候该承认「不该用独立 Lua」
- 需要成套控件(表单、表格、树、对话框)、需要无障碍、需要中文输入法好用——这三条里中了任何一条,独立 Lua 的 GUI 绑定都撑不住。
- 正确的转向不是换一个 Lua GUI 库,而是换掉「独立 Lua」这个前提:让 Lua 回到它擅长的位置——嵌在一个用别的语言写的程序里当脚本层。宿主负责界面,Lua 负责逻辑与可定制性。
- 这不是退让,这就是 Lua 的设计意图。它在游戏引擎、编辑器、网络中间件里活得那么好,正是因为没去和 UI 框架竞争。
-- LÖVE:三个回调就是一个完整程序(main.lua)
local c = 25
function love.update(dt)
if love.keyboard.isDown("up") then c = c + 30 * dt end
if love.keyboard.isDown("down") then c = c - 30 * dt end
end
function love.draw()
love.graphics.print(("%.1f °C = %.1f °F"):format(c, c * 9 / 5 + 32), 20, 20)
love.graphics.rectangle("fill", 20, 50, (c + 40) * 2, 16)
end
-- $ love . 跑起来
-- 打包:把目录压成 zip 改名 .love 即可分发
-- 纯 Lua:ANSI 转义就是普通字符串(5.5 可用)
local ESC = string.char(27)
io.write(ESC .. "[2J" .. ESC .. "[H") -- 清屏 + 光标归位
io.write(ESC .. "[1;32m" .. "温度换算" .. ESC .. "[0m\n") -- 亮绿
io.write(("%s[7m %5.1f °F %s[0m\n"):format(ESC, 77.0, ESC)) -- 反显^[[1;32m 之类的乱码混在内容里。成熟的库(比如 Kotlin 的 Mordant)会自动检测并降级,自己拼转义序列就得自己判断——纯 Lua 标准库里没有 isatty,通常只能给工具加一个 --no-color 选项,或者读 NO_COLOR 环境变量。\r 回到行首、\27[K 清到行尾。先清再写,否则上一行比这一行长时会留下残字——这是自己写进度条最常见的毛病。测试:标准库不给,就自己搭
本章覆盖:assert 与 error level 的行为、必须用 pcall 包住每条用例的原因、深比较 / 浮点 / # 三个断言陷阱、三十行手搭框架要哪些部件,以及 luaunit 与 busted 的对照。标准库不给测试框架,原料只有 assert 与 pcall。
Lua 标准库没有测试框架,原料只有 assert 与 pcall。好消息是三十行就能搭出够用的一个;坏消息是有三处行为不搞清楚,写出来的测试会中途夭折或者根本没在比你以为的东西。
原料与规则
| 写法 | 行为 | 要点 |
|---|---|---|
assert(v, msg) | v 为假就抛错 | 抛的是真正的错误,会中断整个脚本——不是「记一笔继续跑」 |
pcall(用例) | 接住错误 | 每条用例都必须包一层,否则第一条失败之后全不跑 |
xpcall(用例, debug.traceback) | 连调用栈一起拿 | 用例多了以后,那几行栈是定位失败的关键(09 章) |
error(msg, 2) | 位置指向调用者 | 写断言辅助函数必须用它;用默认 level 1 的话,每条失败都指向辅助函数内部同一行,毫无用处 |
error({code = 42}) | 抛表 | Lua 一个字都不加,位置要自己带 |
三个断言陷阱
| 你以为 | 实际 | 怎么办 |
|---|---|---|
{1,2} == {1,2} 为真 | 为假——表比的是引用(06 章) | 自己写深比较:递归比 pairs 的每个键值,两边都要比一遍(只比一边会漏掉对方多出来的键) |
0.1 + 0.2 == 0.3 为真 | 为假,差约 5.55e-17 | 设一个容差:math.abs(a - b) < 1e-9 |
#{1, 2, nil, 4} 是 4 | 是 2——有洞的表 # 没有定义(06、15 章) | 别断言长度,或先确认是无洞序列 |
手搭一个够用的框架:要什么
| 部件 | 怎么做 |
|---|---|
| 注册用例 | 一张表:tests[名字] = 函数 |
| 跑一条 | local ok, err = xpcall(fn, debug.traceback) |
| 断言 | eq(a, b) / deep_eq(a, b),失败时 error(信息, 2) |
| 统计 | 数通过 / 失败,末尾按失败数 os.exit(n)——CI 靠退出码判断成败 |
| 可选 | 只跑匹配某模式的用例(14 章的 string.find)、给输出上色(20 章 ANSI) |
生态:什么时候值得引
| 自己搭 | luaunit | busted | |
|---|---|---|---|
| 依赖 | 零 | 单文件,丢进项目即可 | LuaRocks 装,依赖较多 |
| 风格 | 你说了算 | xUnit:TestXxx 函数 + assertEquals | BDD:describe / it / assert.are.same |
| 附带 | — | 多种输出格式(含 TAP、JUnit XML) | mock / spy、覆盖率、异步 |
| 适合 | 脚本、单模块、嵌入环境 | 想要标准输出格式又不想装东西 | 正经项目 |
os.exit 都没有(魔兽插件、Redis 脚本都是沙箱)。那种环境里的测试只能把结果攒起来交给宿主报告,CI 那套「退出码即成败」的假设不成立——写测试前先确认目标环境给了什么(20 章)。error(msg, 2) 是这一章最实用的一行。它让失败信息指向写用例的那一行而不是辅助函数内部——没有这一行,跑出几十条失败你也不知道它们分别在哪(09 章 level 语义)。Lua 没有 assertEquals、没有 expect,只有一个 assert(值, 信息)。它够用,但有三处行为不搞清楚,写出来的测试要么中途夭折、要么根本没在比你以为的东西。
一:assert 失败会中断整个脚本
assert抛的是真正的错误,不是「记一笔然后继续」。一条用例挂掉,后面所有用例都不会跑——这和 JS / Python 的测试框架完全不同,因为那边有框架在外面接着;- 所以 Lua 里每条用例都必须包在
pcall里:local ok, err = pcall(用例函数),失败时ok为false、err是错误信息,跑完这条继续下一条; - 想连错误发生位置的调用栈一起拿到,用
xpcall(fn, debug.traceback)——返回的字符串首行是文件:行: 信息,后面跟着完整的stack traceback。用例多了以后,这几行栈是定位失败的关键。
二:== 对表是引用比较,比不了内容
{1, 2} == {1, 2}得false——两个不同的表,内容一样也不相等(和 JS 的对象一样,见 06 章「表」);- 于是 Lua 里的「深比较」必须自己写:递归比
pairs出来的每个键值,两边都要比一遍(只比一边会漏掉「对方多出来的键」)。第二张卡的框架里那十行就是干这个的; - 顺带两个数值上的坑:浮点别用
==(0.1 + 0.2 == 0.3为false,差5.55e-17,要比就设一个容差);#对中间有 nil 的表不可靠(#{1, 2, nil, 4}是 2 不是 4),所以「断言长度」这种写法在稀疏表上会骗你。
三:错误信息的位置前缀,取决于谁调的
assert(false, "msg")从一个 Lua 函数里调用时,信息是a2.lua:3: msg——带位置;直接pcall(assert, false, "msg")时是光秃秃的msg,因为这时调用者是 C 函数,没有行号可加。Lua 5.5 与 LuaJIT 5.1 行为一致;- 这条直接决定了断言辅助函数要怎么写:如果你把断言封装成
T.eq(a, b),里面直接error(信息),报出来的位置是辅助函数内部那一行——每条失败都指向同一行,毫无用处。解法是error(信息, 2),level 2 让位置指向调用者,也就是写用例的那一行(error的 level 语义见 09 章); - 抛非字符串时 Lua 一个字都不加:
error({code = 42})被pcall接住后仍是个table,err.code是 42。要做结构化的失败信息,就得自己带上位置。
-- 一:assert 失败会中断整轮,必须 pcall 包住
local ok, err = pcall(function() assert(1 == 2, "一加一不等于二") end)
print(ok, err) --> false probe.lua:2: 一加一不等于二
assert(false) -- 裸 assert 的信息:probe.lua:6: assertion failed!
-- 要栈就用 xpcall
local ok2, err2 = xpcall(fn, debug.traceback)
-- err2 首行是 "文件:行: 信息",后面跟着 stack traceback
-- 二:表比较是引用,浮点和 # 也不能想当然
print({1, 2} == {1, 2}) --> false ← 内容一样也不相等
print(0.1 + 0.2 == 0.3) --> false ← 差 5.55e-17
print(#{1, 2, nil, 4}) --> 2 ← 不是 4
-- 三:位置前缀取决于调用者是 Lua 还是 C
pcall(function() assert(false, "msg") end) --> a2.lua:3: msg
pcall(assert, false, "msg") --> msg ← 没有位置
-- 断言辅助函数一定要用 level 2,否则永远指向自己这一行
local function eq(a, b)
if a ~= b then
error(("期望 %s,实际 %s"):format(b, a), 2) -- ← 这个 2 是关键
end
end== 比表。assert(split("a,b", ",") == {"a", "b"}) 永远失败——不是因为切分错了,而是因为两个表天然不相等;反过来 assert(t1 ~= t2) 则永远通过,看起来测了实际什么都没测。凡是断言对象是表,必须走深比较;这也是为什么本章后面那三十行框架里,same 比 eq 长得多。assert 的返回值是它的全部参数原样返回(09 章讲过),所以它能直接夹在表达式中间:local f = assert(io.open(path))——打开成功就拿到句柄,失败就带着 io.open 给的错误信息当场抛错。写测试的辅助代码时这个惯用法很省事,但别把它当断言用在用例里:它一失败整轮就停了。Lua 没有标准框架,但也不需要多少代码。把本章前面的三条规则落成函数,三十行就能得到一个有断言、有统计、失败能定位、退出码正确的框架——足够撑起一个中小型库,而且没有任何依赖。
它需要哪几件
T.test(名字, 函数):用xpcall(fn, debug.traceback)跑一条用例,把成败计进pass/fail,失败时打第一行错误信息。用 xpcall 而不是 pcall,是为了失败时手里有栈;T.eq(实际, 期望):基本类型比较,失败时error(信息, 2)指向用例那一行;T.same(a, b):表的深比较,两个方向都要遍历——只遍历a会漏掉「b多出来的键」;T.raises(函数, 模式):断言「应该抛错」。里面就是一个pcall:没抛错反而要失败,抛了再用find匹配错误信息。这一条不写,「错误路径」就永远是测试盲区;T.report():打印汇总,并os.exit(失败数 == 0 and 0 or 1)——退出码是给 CI 看的,少了这一句,测试全挂 CI 也是绿的。
输出长这样
- 拿它测一个
split函数,五条用例(含一条故意写错的T.eq(1 + 1, 3)),Lua 5.5.0 上的输出是四行ok加一行FAIL,失败那行带着t.lua:43: 期望 3,实际 2——行号 43 正是写这条断言的地方,而不是框架内部,这就是error(..., 2)换来的; - 末尾
4 passed, 1 failed,进程退出码为 1。把它接进make test或 CI 就是一句lua test.lua。
local T = { pass = 0, fail = 0 }
function T.eq(a, b) -- 值相等
if a ~= b then
error(("期望 %s,实际 %s"):format(tostring(b), tostring(a)), 2)
end
end
function T.same(a, b) -- 表深比较
if a == b then return end
if type(a) ~= "table" or type(b) ~= "table" then
error(("%s ~= %s"):format(tostring(a), tostring(b)), 2)
end
for k, v in pairs(a) do T.same(v, b[k]) end
for k in pairs(b) do -- ← 反向也要遍历
if a[k] == nil then error("多出键 " .. tostring(k), 2) end
end
end
function T.raises(fn, pat) -- 断言"应该抛错"
local ok, err = pcall(fn)
if ok then error("本该抛错,却正常返回了", 2) end
if pat and not tostring(err):find(pat) then
error(("错误信息不匹配:%s"):format(err), 2)
end
end
function T.test(name, fn)
local ok, err = xpcall(fn, debug.traceback) -- 失败时手里有栈
if ok then T.pass = T.pass + 1; print(" ok " .. name)
else T.fail = T.fail + 1
print(" FAIL " .. name .. "\n " .. (err:gsub("\n.*", ""))) end
end
function T.report()
print(("\n%d passed, %d failed"):format(T.pass, T.fail))
os.exit(T.fail == 0 and 0 or 1) -- 退出码给 CI
end
-- —— 用起来 ——
T.test("按逗号切分", function() T.same(split("a,b,c", ","), {"a","b","c"}) end)
T.test("会抛错的分支", function() T.raises(function() split(nil) end, "nil value") end)
T.report()
-- ok 按逗号切分
-- FAIL 故意失败一条
-- t.lua:43: 期望 3,实际 2 ← 43 是用例那一行,不是框架里
-- 4 passed, 1 failed (退出码 1)os.exit 那一句。Lua 脚本正常跑完退出码就是 0——哪怕你打印了一屏 FAIL,CI 看到的仍然是成功。这是自搭框架最容易漏、后果最严重的一处:测试一直在跑、一直在挂、一直显示通过。另一个:
T.same 递归比较遇到自引用的表会栈溢出(t.self = t)。要在真实项目里用,加一张「已访问」表记录比过的配对;日常测数据结构一般碰不到,但配置对象、AST 节点这类容易带回边。spec/helper.lua,测试文件用 require 取用。再配一句 lua test.lua 的 make test,整套工程化就齐了——Lua 项目的测试基建成本低到没有理由不写。要跑多个测试文件,让入口脚本按目录 require 一遍,最后统一 T.report();require 的路径规则见 12 章「模块与包」。手搭的框架撑到一定规模就该换成现成的。Lua 生态里就两个主流选择,分界线很清楚:要不要引入 LuaRocks。
luaunit:一个文件,扔进项目就能用
- 整个框架就是一个
luaunit.lua(下载下来 131 KB),不需要包管理器、不需要编译,require("luaunit")就能跑——这一点对嵌入式场景、游戏脚本、或者「不想给项目引入 LuaRocks」的情况非常关键; - 写法是 xUnit 风格:全局表名以
Test开头会被自动发现,表里test开头的方法就是用例。断言用lu.assertEquals(对表是按内容比,省掉自己写深比较)、lu.assertErrorMsgContains等; - 在 Lua 5.5.0 上跑三条用例(含一条故意失败),输出是
.F.加失败详情与stack traceback,末行Ran 3 tests in 0.001 seconds, 2 successes, 1 failure,退出码 1——把lu.LuaUnit.run()的返回值交给os.exit即可; - 它还能输出 TAP 与 JUnit XML(
--output junit),CI 里直接被识别成测试报告。
busted:生态里的事实标准,代价是 LuaRocks
- 写法是 BDD 风格,和 JS 那套同形:
describe/it/before_each/after_each,断言走assert.are.same(深比较)、assert.has_error; - 装法是
luarocks install busted,跑法是目录里敲一句busted——它自己发现spec/下的*_spec.lua; - 相对 luaunit 的实质优势是生态配套:mock/spy(
stub、spy.on)、覆盖率(配 luacov)、多种输出格式、以及大量第三方库本身就用它写测试——读别人的spec/目录不用重新学一套。
怎么选
- 脚本、插件、嵌进宿主的代码 → 手搭的三十行或 luaunit,零依赖是硬需求;
- 要发布到 LuaRocks 的库 → busted,因为使用者和贡献者预期看到的就是
spec/; - Neovim 插件是个特例:社区惯例是 busted 风格(
describe/it),但跑在 Neovim 内置的测试壳里,要按各自项目的约定来; - 无论选哪个,断言表相等一律用框架提供的深比较(
assertEquals/are.same),别退回==——这是本章前面那条坑的唯一根治办法。
-- —— luaunit:单文件,直接 require ——
local lu = require("luaunit")
TestSplit = {} -- 全局、Test 开头 → 自动发现
function TestSplit:testSame()
lu.assertEquals({1, 2}, {1, 2}) -- 表按内容比,不是引用
end
function TestSplit:testError()
lu.assertErrorMsgContains("nil value", function() return (nil).x end)
end
os.exit(lu.LuaUnit.run()) -- 返回值就是退出码
-- Lua 5.5.0:
-- .F.
-- Ran 3 tests in 0.001 seconds, 2 successes, 1 failure (退出码 1)
-- —— busted:luarocks install busted,然后目录里敲 busted ——
-- spec/split_spec.lua
describe("split", function()
local subject
before_each(function() subject = require("split") end)
it("按逗号切分", function()
assert.are.same({"a", "b"}, subject("a,b", ",")) -- same = 深比较
end)
it("传 nil 会抛错", function()
assert.has_error(function() subject(nil) end)
end)
end)local TestSplit = {} 就一条都不会跑,而它不报错,只安静地告诉你 Ran 0 tests。这和 js-ts 里 node --test 不认 *.spec.* 是同一类事故:「全绿」不等于「跑过了」,看汇总里的用例条数才算数。另外 Lua 5.4 起
local 声明有了属性语法,而这类框架多年来在多个版本间求兼容,装之前先确认它声明支持的 Lua 版本——luaunit 在 5.5.0 上工作正常,但生态里仍有不少库停在 5.1/5.3 的写法。--output junit 能直接产出 CI 认识的 XML 报告,配上 --name 结果.xml 就能让 GitHub Actions / GitLab 把失败用例渲染成列表,而不是让人去翻日志。busted 侧对应的是 --output=junit(或用 busted --coverage 配 luacov 出覆盖率)。零依赖也能有像样的 CI 报告,这一点常被低估。资料索引:项目 · 读物 · 自测
地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。
Lua 语言本体小,两周能过完语法;功力长在「和宿主打交道」上。下面按难度排的四个项目,从纯 Lua 一路走进 C 边界。
动手项目
| 项目 | 做什么 | 练到的 |
|---|---|---|
| ① 纯 Lua 小工具 | 只用标准库写一个 JSON 解析器或 ini 读取器,产出可 require 的模块 | 表、字符串模式、闭包、pcall(06、14、09、12 章) |
| ② 进驻真实宿主 | 写一个 Neovim 插件(vim.api,发到 GitHub 供人安装),或给魔兽 / Roblox 写 mod | 嵌入式语言在别人地盘上的生存方式(20 章) |
| ③ 亲手嵌一次 | 用 C API 把 Lua 嵌进一个 C 程序:注册宿主函数、双向传 table、保护模式处理脚本错误 | 栈式 API——理解了它,Lua 的一切设计都说得通(17 章) |
| ④ 深水区二选一 | 通读 Lua 源码(lua.c 入口到 lvm.c 虚拟机,约 3 万行),或用 OpenResty 写一个限流 / 鉴权中间件跑上真实网关 | 动态语言实现 / 生产环境的 Lua |
资料
| 要什么 | 去哪 | 注意 |
|---|---|---|
| 语义的唯一权威 | 官方 Reference Manual | 薄到可以通读;务必选对版本(本页对应 5.5) |
| 系统教程 | 《Programming in Lua (4th)》,Roberto Ierusalimschy 亲笔 | 覆盖到 5.3,之后的增量补手册即可 |
| 惯用法与性能技巧 | lua-users wiki | 老但含金量高 |
| 找库 / 装库 | LuaRocks | 注意每个 rock 支持的 Lua 版本 |
| OpenResty | openresty.org 的 lua-nginx-module 文档 | 那边是 LuaJIT(5.1) |
| LuaJIT 能用什么 | LuaJIT 官网的 extensions 页 | 它明确列出了从 5.2 / 5.3 回填了哪些 |
自测:会了没有
| 题 | 答在哪 |
|---|---|
说出 #t 在什么情况下不可靠,以及两种绕法 | 06、15 章 |
__index 与 __newindex 分别在什么时候不触发 | 07 章 |
local function f 与 local f = function 的区别 | 05 章 |
resume 和 yield 之间值是怎么传的 | 11 章 |
为什么 <close> 能做到 __gc 做不到的事 | 10、13 章 |
| Lua 模式相比正则少了哪三样,多了哪一样 | 14 章 |
| 给你一个现成的 C 程序,把 Lua 嵌进去:注册一个宿主函数给脚本调用,再从 C 里读回脚本返回的 table | 17 章——做得到,这一页就毕业了 |
utf8 库、<const> / <close> 全部没有,位运算得用它自带的 bit 库。练完本页的 5.4 / 5.5 语法直接上手会措手不及,先翻 LuaJIT 文档确认可用特性再动工(00 章版本表)。