全景:Lua 的定位与现状
钻进语法之前先回答三个问题:Lua 解决什么问题、今天它用在哪里、和邻近语言怎么选。后面每一章都是这张地图的放大。
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 2跑通第一个脚本
在讲语法之前,先让解释器把你写的东西跑起来。这一章做三件事:装上 Lua、用 REPL 和脚本两种方式运行代码、以及读懂 Lua 的报错——最后这件对 Lua 格外要紧,因为它「变量默认是全局的」这条规则会让你的拼写错误在很远的地方才炸出来。
Lua 是所有主流语言里装起来最轻的一个:一个几百 KB 的解释器,没有虚拟机、没有强制的包管理依赖,源码 make 一分钟就能编完。
装解释器,两种跑法
- macOS
brew install lua;Debian/Ubuntuapt install lua5.4;Windows 用 winget 或 scoop。装完敲lua -v; - REPL:直接敲
lua进交互式环境,试单行最快;脚本:lua script.lua,命令行参数进全局表arg。
你装到的版本,多半不是 5.5
- 本页以 Lua 5.5 为基准,但包管理器装到的大概率是 5.4,游戏与 Web 领域更常见 LuaJIT(对应 5.1);
- 核心语法在所有版本上都一样,差异集中在整数子类型、位运算符与少数标准库函数——本页凡涉及都会点名版本。
-- ① 交互模式:终端敲 lua 回车,逐行试
$ lua
Lua 5.5.0 Copyright (C) 1994-2025 Lua.org, PUC-Rio
> 1 + 2
3
> print("hi")
hi
> os.exit() -- 或按 Ctrl+D
-- ② 跑脚本:先把下面几行存成 hello.lua
print("你好," .. (arg[1] or "Lua"))
print("脚本名:" .. arg[0])
-- 再在同一目录执行:
-- $ lua hello.lua
-- 你好,Lua
-- 脚本名:hello.lua
-- $ lua hello.lua 世界
-- 你好,世界lua:Debian/Ubuntu 装的是 lua5.4,有些系统还会同时存在多个版本。敲 lua 提示 command not found 时,先试 lua5.4,或用 ls /usr/bin/lua* 看看到底装了什么。= 前缀),所以 > 1+2 会直接显示 3。把 REPL 一直开着:读到本页任何一个片段,粘进去回车,比盯着看有效得多。上一卡那个脚本只有两行,但已经用到了 Lua 的几条基本约定。不求全懂,只求知道每处的职责。
print(...) 全局函数,直接可用
- Lua 不需要
require任何东西就能用print——它和type、pairs一样属于基础库,默认就在全局环境里; print用 tab 分隔多个参数并自动换行。
其余三条约定
- 拼接用
..而不是+(+只做算术),数字会自动转成字符串;两侧要留空格,1..2会被当成小数点而报错; - 注释用
--到行尾,多行用--[[ ... ]]; - 语句不需要分号,代码可以直接写在文件顶层,不需要
main函数。
-- 这就是一个完整的 Lua 程序,没有 main、没有 import
local name = "Lua"
local count = 3
print("你好," .. name) --> 你好,Lua
print("第" .. count .. "次") --> 第3次(数字自动转字符串)
print(name, count) --> Lua 3(tab 分隔)
-- 拼接是 ..,不是 +
-- print("a" + "b")
-- Lua 5.5: attempt to add a 'string' with a 'string'
-- 注意 "5" + 1 反而不报错——能转成数字的字符串会被自动转换
--[[ 多行注释
写在这里面 ]]
-- ⚠️ .. 两边留空格,否则 1..2 会被当成小数点
print(1 .. 2) --> 12t[1] 是第一个元素,字符串的 s:sub(1, 3) 取的是前三个字节。这条贯穿整个标准库(string.find 的返回位置、table.insert 的下标全部是 1-based),是从其他语言转过来的人最持久的不适。唯一的例外是 arg[0]——它存脚本名,属于命令行约定而非 Lua 的索引规则。print 打表会显示 table: 0x... 这样的地址而非内容——想看表里有什么得自己遍历(06 章)。这是初学时最常见的一个「怎么打不出来」的困惑。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) 会直接指出是哪一行的哪个结构没闭合。语言基础
类型、变量、字面量——动态类型、8 位干净、引用语义的底盘。
变量不带类型、值才带类型——先把 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 尤其值这一手。运算符与表达式
算术、关系、逻辑、位运算、连接与长度,以及它们的优先级。
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 ... ==。.. 与 ^ 是右结合,其余二元运算左结合。拿不准就加括号。控制结构
条件、三种循环、跳转。注意 repeat 的作用域与 5.5 只读循环变量。
语法上没有惊喜,真正要记的是「什么算真」——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)——不必为内外层起不同的名字。函数
一等公民、多返回值、变参、闭包、尾调用与方法语法。
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)
Lua 唯一的数据结构:数组、字典、对象、集合……都是它。
表是 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,因为是两张不同的表。元表与元方法
为值定义运算、索引、比较等行为——Lua 可扩展语义的核心机制。
读缺失键、写新键这两个拦截点,是 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 补上的终结器永远不会跑。想以后再填实现,先放个占位值把对象标记上。面向对象
Lua 无内建 class:用表 + 元表 + __index 链手工搭建,灵活但需约定。
把「类」翻译成 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 为函数,依次查多个父表。但通常单继承 + 组合更清晰。错误处理
raise / catch 模型:error、assert、pcall/xpcall、消息处理器、warn 与动态加载。
抛错的两个入口: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>,给局部变量附加编译期/运行期语义。
给绑定上锁:改不了的 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)
协作式多任务:可暂停/恢复的函数。生成器、状态机、异步流程的基础。
普通函数只能一口气跑完,协程可以停在半路、下次接着跑——生成器和调度器都建在这上面。
可暂停的函数
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 系统,延伸到 _ENV 环境、沙箱与序列化:如何组织、隔离与持久化代码。
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 能重新解析的安全字面量,自动处理引号、换行与转义——手写拼接极易漏掉这些。内存与垃圾回收
自动内存管理、弱表与 GC 模式。5.4 分代、5.5 增量大回收。
回收器平时不用管,但量内存、停 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 自有的轻量「模式」——不是正则!语法更小、用 % 转义,内建于 string 库。
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,字符类一律用 % 前缀。标准库:深水区
下一章按库列 API,这一章先下到水面以下——讲那些签名和文档都不会加粗、但不知道就会错的契约:table.sort 抛错后表停在半排序态、concat 遇洞静默截断、# 与内存背后的数组/哈希双骨架、os.clock 在两种系统上走的不是同一种时间、yield 剩下的红线清单。所有结论在 Lua 5.5,与 LuaJIT 有分岔的地方逐条标明;「这个函数怎么调」是下一章「标准库速查」的活。
「比较器必须严格小于」这条 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(每库配可运行示例),末尾附一屏『标准库速查』总表——与 C / Go 同构:深水区 + 详解 + 速查。
不用 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",可判断函数的实现语言。把 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 字节。标 ★ 的库 / 函数本页另有专卡或专章详解。C API 与嵌入
Lua 的本命:作为库嵌入 C/C++ 宿主。基于虚拟栈的双向通信。
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 已五年。核心围绕安全性(全局声明、只读循环变量)与性能(紧凑数组、增量式大回收)。
漏写 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 开发者的对照,以及版本演进脉络。
从新手到老手都会踩的坑,集中列一遍,上线前对着扫一眼。
上线前对着扫一眼
- 索引从 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
「Lua 有什么 UI 库」这个问题问错了方向。Lua 是嵌入式语言,绝大多数时候界面是宿主程序的,Lua 只是那层脚本——这一章讲清这个格局,用 Neovim 做实验场,再说独立 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 清到行尾。先清再写,否则上一行比这一行长时会留下残字——这是自己写进度条最常见的毛病。测试:标准库不给,就自己搭
Lua 标准库里没有测试框架——和 C 一样,这件事被留给了生态。好消息是搭一个够用的成本极低:assert + pcall 就是全部原料,三十行能写出一个带统计和退出码的框架。本章先讲清楚 Lua 特有的三个断言陷阱(表比较是引用、assert 会中断整个脚本、位置前缀的规则),再手搭一个能用的,最后给出 busted 与 luaunit 的选型。示例全部在 Lua 5.5.0。
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的模块——把 table、字符串库、闭包与错误处理(pcall)练透。 - ② 进驻真实宿主:写一个 Neovim 插件(用
vim.api,产出物发到 GitHub 供人安装),或给魔兽/Roblox 写一个 mod——体会「嵌入式语言」在别人地盘上的生存方式。 - ③ 亲手嵌一次:用 C API 把 Lua 嵌进一个 C 程序——注册宿主函数给脚本调用、双向传 table 数据、用保护模式处理脚本错误。理解栈式 API 之后,Lua 的一切设计都说得通。
- ④ 深水区二选一:通读 Lua 源码(从 lua.c 入口到 lvm.c 虚拟机,总共约 3 万行,是动态语言实现的最佳教材),或用 OpenResty 写一个限流/鉴权中间件跑上真实网关。
书与资料(按阶段)
- 权威参考:官方 Reference Manual——语义的唯一权威定义,薄到可以通读,注意选对版本(本页对应 5.5);
- 系统教程:《Programming in Lua (4th)》,作者 Roberto Ierusalimschy 亲笔,覆盖到 5.3,之后的增量补手册即可;
- 社区智慧:lua-users wiki——惯用法、性能技巧与设计讨论的宝库;
- 找库:LuaRocks(包管理器与仓库);写 OpenResty 看 openresty.org 的 lua-nginx-module 文档。