现代 Lua(以 5.5 为基准)核心知识体系交互讲解

全景: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」:本页以官方 PUC Lua 5.5 为基准,但 Neovim / OpenResty 跑的是 LuaJIT(5.1 语法)、魔兽插件是 5.1、Roblox 用的是方言 Luau。5.3 起才有的整数子类型与位运算符,在这些 5.1 系上或缺失或不同。
table 和闭包这两个概念吃透——整门语言九成的表达力都从这两处长出来;剩下两张王牌是协程与 C API,它们决定了 Lua 在宿主里的生存方式。

跑通第一个脚本

在讲语法之前,先让解释器把你写的东西跑起来。这一章做三件事:装上 Lua、用 REPL 和脚本两种方式运行代码、以及读懂 Lua 的报错——最后这件对 Lua 格外要紧,因为它「变量默认是全局的」这条规则会让你的拼写错误在很远的地方才炸出来。

Lua 是所有主流语言里装起来最轻的一个:一个几百 KB 的解释器,没有虚拟机、没有强制的包管理依赖,源码 make 一分钟就能编完。

装解释器,两种跑法

  • macOS brew install lua;Debian/Ubuntu apt 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* 看看到底装了什么。
REPL 里直接敲表达式就会打印结果(5.3 起不需要 = 前缀),所以 > 1+2 会直接显示 3把 REPL 一直开着:读到本页任何一个片段,粘进去回车,比盯着看有效得多。

上一卡那个脚本只有两行,但已经用到了 Lua 的几条基本约定。不求全懂,只求知道每处的职责。

print(...) 全局函数,直接可用

  • Lua 不需要 require 任何东西就能用 print——它和 typepairs 一样属于基础库,默认就在全局环境里;
  • 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)                --> 12
Lua 的索引从 1 开始,不是 0。t[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>
「程序跑完没报错」不等于「没出错」。拼错变量名不会报错,所以 Lua 脚本经常是安静地算错而不是崩溃。两个习惯能挡掉绝大部分:所有变量都写 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只有 falsenil 为假
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)))  --> thread
type() 返回的是字符串:判断要写 type(x) == "nil"type(x) == "number"——type(x) == nil 永远是 false(Lua 5.4/5.5);而且类型名拼错也不会报错,只会安静地判假。
table、function、thread、full userdata 是对象:变量存的是引用,赋值/传参不拷贝。

这一卡有 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  3
忘写 local 不会有任何提示——赋值成功、读取返回 nil、程序继续跑。把 count 拼成 cout,你会得到一个新全局变量和一个永远是初值的 count,而报错(如果有的话)出现在几十行之外用到 count 的地方。三个对策: 一切局部变量写 local 编辑器装 lua-language-server,它会标出可疑的全局赋值; Lua 5.5 起可以用 global 声明强制要求全局变量必须先声明(见 18 章),但 5.4 及 LuaJIT 上没有这个保险。
对照 JS:locallet(块级作用域),Lua 没有 var 那种函数级提升;而「不声明即全局」正是 JS 非严格模式的老毛病,Lua 把它保留成了默认行为。另外把频繁用到的全局提升为局部local print = print)在热点循环里确实更快——因为读全局要查一次表,读局部直接命中寄存器;但这是优化手段,别一上来就到处这么写。

一个 number 底下其实有 integer 与 float 两套表示——知道手里是哪种,除法和格式化才不会出意外。

一个 number,两套表示

5.3 起 number 拆成两个子类型(默认均为 64 位),行为可预测又能自动转换:

  • 3 / 21.5/ 总产生 float)
  • 7 // 23// 向下取整,同类型进同类型出)
  • 2 ^ 24.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.0true。但作为表的键时它们是同一个键——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 码点)
Lua 字符串是字节序列:#"你好"6 不是 2("你好"):sub(1,1) 切出来的是半个 UTF-8 字符(一个无效字节)。数字符、按字符定位请用 5.3 起的 utf8 库(utf8.lenutf8.offset)。
对照 JS:无模板字符串;拼接用 ..string.format。索引从 1 开始,s:sub(1,3) 取前三字节。

Lua 判真假只有一条规则,而它和你从别的语言带来的直觉都不一样。

一条规则

Lua 的假值(false values)只有 nil 和 false。其它一切——包括 0""{}——都为真。条件判断与 and/or 的求值都遵循此规则。

0 和空串为真,这条从别的语言来一定会踩

  • Lua 的假值只有两个nilfalse0""{}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
end
对照 JS 最易出错:JS 里 0/""/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)——注释掉含 ]] 的代码,要升级成 --[==[ ... ]==]
装了 lua-language-server 之后,--- 起头的注解注释(---@param---@return)能给函数标类型,补全与静态检查立刻上一个台阶——纯动态的 Lua 尤其值这一手。

运算符与表达式

算术、关系、逻辑、位运算、连接与长度,以及它们的优先级。

Lua 的算术符号和多数语言一样,但有两处需要单独记:除法总是产生小数,以及 5.3 起整数和浮点是两个子类型。

除法的三个符号

  • / 永远返回浮点数——7/23.54/2 也是 2.0 而不是 2
  • //向下取整除法(5.3 引入):7//23;对负数是向下取整而非截断,-7//2-4
  • % 取模,结果符号跟除数走-7%32(不是 -1),这点和 C/Java 不同。

幂与其他

  • ^ 是幂运算,总是返回浮点2^101024.0
  • 整数除以整数用 // 才能保持整数类型,这在做索引计算时很要紧;
  • 字符串会在算术场合自动转成数字"5" + 16——方便但也容易掩盖类型错误。
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/0inf0/0 得 nan(且 nan 不等于自身),而整数 1//0 直接报 attempt to divide by zero1%0attempt to perform 'n%0'(Lua 5.4/5.5)——写通用算式时别默认「除零不会崩」。

关系运算没什么意外,逻辑运算才是 Lua 的特色and/or 不返回布尔值,而是返回其中一个操作数。

关系运算

  • == ~=不等号是 ~= 而非 !=< > <= >=
  • 不同类型一律不相等,且不会自动转换:"1" == 1false
  • 表、函数按引用比较——两个内容相同的表 {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,不等价于真三元。
不同类型比大小是运行时错误而不是 false: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 >> 19223372036854775807,不是 C 程序员预期的 -1(Lua 5.4/5.5)。需要算术右移,用 // 除以 2 的幂(-8 // 2-4)。
对照 JS:JS 位运算截为 32 位有符号,Lua 是 64 位整数(除非编译为 32 位);移位是逻辑移位。

两个看着简单、实际最容易出事的运算符:拼接用 ..(不是 +),而 # 的含义比「长度」微妙得多。

.. 字符串连接

  • 数字会自动转字符串参与拼接;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 弹栈——这三个惯用法都只在无洞的序列上可靠。

优先级表不用背全——记住几个反直觉的组合,其余拿不准就加括号。

从高到低

优先级由低到高:

  • or
  • and
  • < > <= >= ~= ==
  • |~&<< >>
  • ..(右结合)
  • + -
  • * / // %
  • 一元:not # - ~
  • ^(右结合,优先级高于一元)

三处与常见直觉不同

  • ..^ 是右结合2^3^22^(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)     --> 14
一元运算高于二元比较:not x == y 被解析成 (not x) == ynot 1 == 1false——想表达「不等」用 ~=,别写 not ... ==
..^ 是右结合,其余二元运算左结合。拿不准就加括号。

控制结构

条件、三种循环、跳转。注意 repeat 的作用域与 5.5 只读循环变量。

语法上没有惊喜,真正要记的是「什么算真」——Lua 的真值规则和几乎所有动态语言都不同。

写法

  • if 条件 then ... elseif 条件 then ... else ... endelseif 一个词(写成 else if 会要求多一个 end);
  • 条件不需要括号,块结尾一律用 end
  • Lua 没有三元运算符,惯用 cond and X or Y 代替(陷阱见 03 章逻辑运算卡)。

真值规则:只有两个假值

  • 只有 falsenil 是假其余全是真
  • 0 是真、""(空串)是真、{}(空表)也是真——这是从 JS/Python/C 转过来的人最容易栽的一条;
  • 所以判断「有没有元素」不能写 if t then(空表也为真),要写 if #t > 0 thenif 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 是一个词。
条件里不能顺手赋值:C 习惯的 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 == "读到内容"
repeatuntil 条件属于循环体作用域,所以想用 goto ::continue:: 模拟 continue 时,label 放在 until 之前不算「块尾」,只要跨过一个 local 声明就编译报错——这种循环改写成 while 更省事(label 的位置规则见下一卡)。
Lua 没有 continue;用 goto 跳到循环末尾的标签实现(见「break / goto / label」)。

for i = 起, 止, 步长 do——注意 Lua 的区间是闭区间,两端都包含

三个参数

  • for i = 1, 10 do10 次(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.0
倒序循环忘写步长,不报错也不跑:for i = 10, 1 do 步长默认 1、起点已越过终点,循环体一次都不执行(Lua 5.4/5.5),整段逻辑被安静跳过——倒着数必须写全 for i = 10, 1, -1 do
5.5:控制变量只读,循环体内 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 3
pairs 顺序不保证;要有序请对键排序后遍历。ipairs 遇到第一个 nil 即停。
遍历中改表有边界:给已有键改值或置 nil 都允许,但 pairs 遍历中新增键是未定义行为(官方手册 next 条目)——要批量增删,先把键收集到另一张表,遍历完再动手。

Lua 的跳转设施很克制:只有 breakgoto没有 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」)。
label 的可见性以块为界:嵌套循环里每层都可以有自己的 ::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))  --> 25
参数个数写错没有任何警告:少传的补 nil,等函数体里用到才炸。Lua 5.4/5.5 add(1)attempt to perform arithmetic on a nil value (local 'b')——错误指向函数内部而非调用处,回溯要多看一层。
local function f 先声明后赋值,故 f 内部可递归调用自己;local f = function 则不行。

Lua 函数能返回任意多个值,这是它区别于多数语言的一个核心设计——标准库大量依赖它(如 pcallstring.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
多值调用不在列表末尾就被截成 1 个,丢值不报错:f 返回三个值时,{f()} 有 3 个元素而 {f(), 0} 只有 2 个——拼表、传参时把多值调用夹在中间是静默丢数据的经典来源。
对照 JS:类似解构,但更灵活:多值在表达式列表末尾会展开,中间则被截为 1 个 —— 位置敏感。

... 表示「剩余的所有参数」。它不是一个表,而是一组值——这个区别是本卡所有坑的来源。

基本用法

  • 参数列表写 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("#",...)
逐个遍历可能含 nil 的变参用 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  2
for 的循环变量每轮都是新变量,循环里建的闭包各记各的(得到 1、2、3);但 while 循环配循环外的 local 就是同一个变量——闭包全部共享,最后全部读到终值(全是 4)。
闭包 + upvalue 是 Lua 实现私有状态、模块、迭代器、OOP 的基石。

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 都不算。
调试递归时 traceback 只剩 (...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',基本就是点号冒号混用。
对照 JS:冒号即「显式化的 this」。用 . 定义就要手动接 self;混用是常见 bug 源。

表(Table)

Lua 唯一的数据结构:数组、字典、对象、集合……都是它。

表是 Lua 唯一的数据结构——数组、字典、对象、模块、命名空间全都是它。掌握表基本就掌握了 Lua 的一半。

一张表,两个部分

  • 表内部同时维护数组部分(连续整数键)和哈希部分(其余键),由实现自动选择,你不用管;
  • 键可以是除 nil 和 NaN 外的任意值——字符串、数字、布尔、甚至另一张表或函数都能当键;
  • 把某个键赋成 nil 就是删除它;读不存在的键返回 nil 而不报错。

构造与访问的写法

  • {1, 2, 3} 数组式(自动编号 从 1 开始);{x = 1} 记录式;{["复杂 key"] = 1} 显式键式,三者可混用;
  • t.namet["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)
键名打错不报错、只静默给 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)。
对照 JS:像 JS 对象+数组的合体,但索引从 1 开始,删除键是赋 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]) end
别把 nil 存进数组中间当占位符。要「空位」用 false 或哨兵值,别用 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-1
正向遍历中 table.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] = 99a[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
end
浅拷贝只断开第一层:改 copy.cfg.level,原表的 cfg 跟着变——嵌套表仍是共享的。把表当集合的键也按身份比较:set[{1}] = true 之后再查 set[{1}] 得 nil,因为是两张不同的表。
对照 JS:与 JS 对象/数组一致(引用共享),但 Lua 无内置深拷贝/展开语法,需自己写。

元表与元方法

为值定义运算、索引、比较等行为——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 + 11 + 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 > bb < a),所以不存在 __gt

#(长度)的两件事

#{1, 2, nil, 4}  --> 2(5.5 与 5.4 一致)
                 -- 但这是未定义行为:带洞表的 # 只保证返回某个边界

#setmetatable({}, {__len = function() return 99 end})  --> 99
  • 带洞的表用 # 是本页最值得记住的一条禁忌——返回值取决于表的内部存储布局,换个 Lua 版本、甚至换个插入顺序都可能不同
  • 要长度可靠,就自己维护一个计数字段,或者保证序列里没有 nil
  • __len 能定制 #,但不影响 ipairstable.* 的行为——它们各有自己的边界规则。
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)。
对照 JS:相当于手写 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())      --> 旺财 发出声音:汪汪
元方法不沿 __index 链继承:父类定义的 __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
end
error 抛表等非字符串对象时不加位置前缀,没被 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 含完整栈回溯
pcalldebug.traceback 拿不到出错现场——栈已展开,回溯里只剩调用方(Lua 5.4/5.5 只剩 main chunk 一帧);要完整回溯必须让 xpcall 的 handler 在展开前抓。另外 handler 的多个返回值只有第一个会传出来。
对照 JS:相当于 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」的协议正好与 pcallok, 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")())    --> hi
每次 load 都要完整编译,热路径别反复 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
初始化表达式是编译期常量(数字/字符串/布尔/nil)的 <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("作用域外")

输出:作用域内 → [清理] → 作用域外
  • 正常结束、breakreturn抛错——四条路径全都会调 __close出错路径也覆盖是它相对「函数末尾手动 close」的根本优势;
  • 对比 __gc:那个要等 GC 心情好;<close>确定性的、立即的,这正是 RAII 的定义;
  • 值必须是 nilfalse 或带 __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)。
对照 JS:兼具 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 coroutine
协程里未捕获的错误不会炸掉程序,而是让 resume 返回 false 加错误对象——不检查返回值,错误就被静默吞掉。另外 yield 不能穿越 C 函数边界(在 table.sort 比较函数里 yield 报「attempt to yield across a C-call boundary」),但穿过 pcall 没问题(Lua 5.4/5.5)。
对照 JS:像 generator 的 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 + resumewrap
调用形式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 5
wrap 的函数出错时直接向调用方抛出,且该协程就地变 dead——用 pcall 接住后再调用只会得到「cannot resume dead coroutine」,没有「重试」一说(Lua 5.4/5.5)。
wrap 生成器可直接用于泛型 for: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))   --> dead
Lua 5.4 不能 close 正在运行的协程——报「cannot close a running coroutine」;5.5 起允许协程 close 自己,效果是就地终止、外层 resume 正常返回。
返回值照搬 pcall 的成败协议:成功返回 true;__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 只会得到 truepackage.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.path Lua 模块搜索模式(? 替换为模块名)
  • package.cpath C 扩展(.so/.dll)搜索模式
  • package.loaded 已加载模块缓存表
  • package.preload 预注册加载器
  • package.searchers 搜索器列表(5.2 起,原名 loaders)

五张表,一条流水线

字段作用
package.loaded已加载缓存——命中就直接返回,流水线到此结束
package.preload预注册的加载器,优先于文件查找
package.pathLua 文件搜索模式,? 替换为模块名
package.cpathC 扩展(.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 章);
  • 回读时必须当作不可信输入loadmode="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  small
简版不处理环状引用、函数、userdata;真实项目用现成库(如 serpent)。且切勿 load 不可信来源的串——务必走沙箱。
%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")          -- 恢复
5collectgarbage("setpause")/("setstepmul") 在 5.5 已删除,报「bad argument #1 to 'collectgarbage' (invalid option 'setpause')」;5.5 统一改用 collectgarbage("param", "pause", 200) 读写参数并返回旧值。新旧调参接口的完整对照在 18 章。
绝大多数场景无需手动干预;仅在延迟敏感段可临时 stop/restart,或用 step 平摊。

想让缓存「有人用就在、没人用就让 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
弱表条目何时消失取决于 GC 时机,不可依赖其确定性。别把弱表当「有序过期缓存」。
__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)
别在 5.5 里沿用 5.4 的调参写法:collectgarbage("incremental", 200, 100) 这类数字参数在 5.5 被静默忽略、不报错——传入后用 "param" 读回的仍是默认值;5.5 调参必须走 collectgarbage("param", ...)
实时/交互应用受益于 5.5 的增量大回收:不再有一次性长停顿造成的掉帧。

字符串模式匹配

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(用 %$ 匹配字面 $)
这是 Lua 模式,不是正则:无 | 择一、无 {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) 一行即可,不需要任何模板库;
  • 函数返回 nilfalse保留原文而不是替换成空串——这让「有条件替换」不需要额外判断;
  • 替换串里 %1%9 引用捕获、%0 是整个匹配、%% 才是字面百分号。用户输入直接当 repl 是个真实的注入点;
  • 四个函数的分工记这一句:要位置用 find,要内容用 match,要遍历用 gmatch,要改写用 gsubfind 还独有 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  4
gsub 返回两个值(新串+次数),把结果直接传给别的函数会把次数也带过去,用括号 (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
对照 JS:比 JS 模板字符串更接近 C 的 printf;宽度、精度、对齐都在格式串里控制。

先知道模式缺什么,才不会拿正则的习惯硬套 Lua。

先知道缺什么

Lua 模式是刻意精简的:实现小、无正则回溯的指数爆炸风险。但缺少:

  • 择一 a|b(需拆成多次匹配或字符集)
  • 计数量词 {2,4}
  • 前后断言 lookahead/lookbehind
  • 转义用 % 而非 \
需要完整能力时,用第三方库(如 LPeg —— Lua 的解析表达式语法库,可组合且更强大)。

缺的这几样,各有替代路子

正则有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,字符类一律用 % 前缀。
复杂文本解析优先考虑 LPeg:基于 PEG,可组合、可读、无正则那些坑。

标准库:深水区

下一章按库列 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.5 table.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+1position out of boundsremove 对空表返回 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)))  --> 3
静默截断比报错危险:concat/unpack 遇洞不吭声地少给,链路很长之后才表现成「数据少了几条」。表里可能有 nil 时,别让任何默认区间替你决定右边界——显式传 i, j
这些检查全在 C 层:越界这类报 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 预分配:省掉翻倍余量)
别在热路径上让表反复「清空重用」——置 nil 既不还内存也不缩数组部分,rehash 的账单反而可能更贵。要重用就整表换新(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 时间)
在 Linux 上拿 os.clock 测「接口响应时间」会得出小得不合理的数字——等网络的时间全没计入。纯标准库没有毫秒级墙钟;需要时用宿主提供的高精度时钟(LuaJIT 的 ffi、love.timer、luv 的 hrtime 等)。
微基准三件套:预热一轮、重复 N 遍取总时长除 N、确认差值远大于步进——差值和步进同量级就等于没测。

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 boundary
这个报错常从协程调度器深处爆出来,栈信息指着你的业务函数,真正的肇事者却是中间某层把它塞进了 sort 比较器或 gsub 回调。在协程上下文里,给这两类回调立个规矩:不许 yield,也不许调可能 yield 的函数。
真需要「在 gsub 里让出」就拆成两步:先用 gmatch 把匹配收集进表(循环体里随便 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/getmetatable
  • load(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.4
load 执行任意代码,勿对不可信输入使用;必要时传入受限的 env 沙箱。
数变参里的 nil 要用 select("#", ...)——它如实数出个数(select("#", nil, nil) 得 2),而 #{...} 遇到 nil 结果不可靠(得到 0)。

字节层面的字符串工具箱:切、拼、转码、二进制打包,模式匹配之外的全部家当。

字节层面的工具箱

  • sub(s,i,j) 子串(支持负索引)、lenrep(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
这些函数全按字节干活且只认 ASCII:("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.pimath.hugemath.maxinteger/mininteger
  • floor/ceil/abs/sqrt/exp/log/sin/cos/tan/max/min/fmod/modf
  • math.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  float
math.powmath.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)
end
io.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.difftime
  • os.clock() 进程 CPU 时间(计时用)
  • os.getenv(name) 环境变量
  • os.execute(cmd)os.removeos.renameos.tmpname
  • os.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))  --> 你好
Lua 字符串本身是字节序列;涉及 Unicode 字符边界一律走 utf8 库,别用 #/sub 按字节切。
逐字符遍历用 s:gmatch(utf8.charpattern)utf8.len 还能当校验器——遇到非法字节返回 nil 加出错的字节位置(utf8.len("\xE9zz") → nil 1)。

一扇看进解释器内部的窗:栈帧、局部变量、upvalue 都能读能改。

看进解释器内部

反射/调试用途,生产逻辑慎用:debug.traceback([msg[,level]]) 栈回溯、debug.getinfo 函数/栈帧信息、debug.getlocal/setlocaldebug.getupvalue/setupvaluedebug.sethook 钩子、debug.getmetatable/setmetatable(可改非表类型元表)。

它在生产环境是个安全洞

  • debug.getupvalue/setupvalue 能读写任意闭包的 upvalue、debug.setmetatable 能给任何类型(包括数字)挂元表、debug.getlocal 能读别的栈帧的局部变量;
  • 换句话说,沙箱里只要留了 debug 库,所有隔离都是纸糊的(12 章)——它能直接改掉宿主的 _ENV
  • 合法用途只有两类:调试与错误报告debug.tracebackxpcall,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 函数)
debug 库能突破封装(读改局部/upvalue、改元表),仅用于调试/工具,不要写进业务逻辑。
最常用的一招: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 代码用 lua_pcall,别用 lua_call——后者遇错走 panic,gcc 嵌入(5.4/5.5 同)进程直接 Abort,输出 PANIC: unprotected error in call to Lua API (boom)
对照 JS:思路类似 Node.js 的原生插件:C 侧实现、注册进解释器,脚本侧当普通函数调用。

嵌入方升级 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 可复用原始内存,减少拷贝 */
5.5 修改了字节码/内部结构:C 扩展与预编译 chunk 需针对 5.5 重新编译,不与 5.4 二进制兼容。
跨版本代码用 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 的变量继续用,该选项未来会移除,改名要趁早。
对照 JS:类似 "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
变参可能含 nil「洞」,不要用 #args 求个数;首选 args.n(5.5 的变参表自带 n 字段记录个数),需兼容旧版本时用 select("#", ...)
只把 args 当表索引用(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)
end
别惋惜失去了「改 i 跳步」——Lua 5.4 循环体里 i = 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 的头号理由——尤其在 256KB RAM 级设备上。

分代模式的大回收原本一停停到底,5.5 把它切成小步摊开,延迟尖刺被削平。

削平延迟尖刺

5.5 的改动点:分代模式(5.4 引入。lua_newstate 默认增量模式,但独立解释器启动时自动切到分代——命令行跑脚本时分代才是实际生效的模式,见 13 章)下的大回收(major collection)原本是原子式「停顿世界」的,会造成延迟尖刺;5.5 把它改为增量式,工作分摊到多步,显著削平停顿。两种模式的机制与调参见「内存与垃圾回收 · GC 模式:分代 vs 增量」。

改的是分代模式下的那一次大回收

  • 分代 GC 的日常是频繁的小回收(只扫新对象,很快);但每隔一阵要做一次大回收扫全堆——5.4 里这一次是原子的、停顿世界的
  • 后果是「平时都很流畅、偶尔卡一下」,而这种偶发尖刺在游戏与实时系统里最棘手,也最难复现;
  • 5.5 把大回收也切成增量步骤分摊执行,用一点吞吐换掉延迟尖刺
  • 结合 13 章那条源码核对的结论一起看:命令行跑脚本时分代是实际生效的模式,所以这项改进对绝大多数脚本使用者是默认受益的;而嵌入宿主里默认是增量模式,本来就没有这个尖刺。
GC 调参接口变了:5.4 的 collectgarbage("incremental", pause, stepmul, stepsize) 带参形式在 5.5 不报错但参数被静默忽略,得改用 collectgarbage("param", "pause", 200);旧的 "setpause"/"setstepmul" 则直接报「invalid option 'setpause'」(Lua 5.4/5.5 对照)。
对 Redis 脚本、NGINX Lua 模块、实时游戏帧,增量大回收意味着更可预测的延迟。

主菜之外,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_openselectedlibsluaL_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 个字符的起始与结束字节位置
5.5 浮点默认打印位数变多:print(0.1 + 0.2) 从 5.4 的 0.3 变为 0.30000000000000004——依赖旧输出格式的快照 / 文本比对测试会失败。
5.5 打印浮点是「先按 5.4 的 %.14g 试,回读不失真就照旧,失真才升精度」——所以多数输出和 5.4 完全一致,只有 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/25.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,含重复元素时触发)。
开新项目就打开严格全局 + 用 luacheck 静态检查,能拦掉大半低级错误。

语法看着眼熟,直觉全不同——这张表把从 JS 迁移要重写的心智开关列全了。

要重写的心智开关

  • 索引:JS 从 0,Lua 从 1
  • 真值:JS 0/""/NaN 假;Lua 只有 nil/false
  • 相等:Lua 无隐式转换的 ==(近似 JS 的 ===),不等号是 ~=
  • 作用域locallet;但未声明变量默认全局
  • 类型:number 分整/浮;字符串不可变、按字节
  • 拼接:用 ..,无模板串;格式化用 string.format
  • 对象:表 = 对象+数组+Map;OOP 靠元表手搭
  • 异步:无 Promise/async;用协程做协作式并发

八项对照,逐条给出对应关系

JSLua
索引起点01
假值0 "" NaN null undefined只有 nilfalse
相等== 隐式转换、=== 不转== 不转换(≈ ===),不等号是 ~=
作用域let/const 块作用域local 块作用域,不写就是全局
数字只有 double整数与浮点两个子类型
字符串UTF-16 码元序列字节序列,按字符要用 utf8 库
拼接+ / 模板串..没有模板串,格式化用 string.format
异步Promise / async没有;用协程做协作式并发
  • 最容易出错的是「相等」那一行的反面:Lua 的 == 不隐式转换,所以 "1" == 1false——但算术运算自动把数字字符串转成数字("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,6
JS 的对象键会转成字符串,obj[1]obj["1"] 是同一个键;Lua 里 t[1]t["1"] 是两个不同的键(t[1]=xt["1"] 仍是 nil,而 t[1.0] 等于 t[1])。还有惯用的 x = x || default 直译成 x or default 会把 false 值也覆盖掉——Lua 里 false 是合法取值时要显式判 nil。
把「默认全局 + 1-based + nil 语义」三点刻进肌肉记忆,从 JS 迁移的摩擦就去掉了八成。

二十年五个大版本,知道每版加了什么,读旧代码、选运行时心里才有谱。

五个大版本的增量

  • 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+)
注意生态分叉:LuaJIT 停留在 5.1 语义,Luau(Roblox)是独立方言。写库前先确认目标运行时。
升级或移植别靠猜:每版手册第 8 节「Incompatibilities」逐条列了不兼容项,照单排查最省事;5.5 手册还明说 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 的窗口 API、LÖVE 的绘制回调,三者之间零迁移。更麻烦的是它们连语言版本都不一定相同(见下一卡),所以搜到的代码片段先确认宿主再往里贴
写 Lua 界面代码前先确认三件事:宿主是谁、它的 Lua 是哪个版本、它的 UI API 在哪份文档里。这三个问题的答案决定了你能用什么语法、能调什么函数——比任何「Lua UI 教程」都重要。

想动手写点 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_VERSIONLua 5.1jit.versionLuaJIT 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
把在别处写好的 Lua 5.4/5.5 代码贴进 Neovim 插件,最常见的错误是整数除法 // 和位运算符直接语法错误,而报错信息只说「unexpected symbol」,不会告诉你是版本不对。LuaJIT 里要用 math.floor(a / b)bit 库。
nvim -l 是被严重低估的 Lua 实验载体:启动快、有完整的 vim.* 标准库(vim.fnvim.jsonvim.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))  -- 反显
ANSI 转义在输出被重定向到文件或管道时不会消失,会变成一堆 ^[[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 里每条用例都必须包在 pcalllocal ok, err = pcall(用例函数),失败时 okfalseerr 是错误信息,跑完这条继续下一条;
  • 想连错误发生位置的调用栈一起拿到,用 xpcall(fn, debug.traceback)——返回的字符串首行是 文件:行: 信息,后面跟着完整的 stack traceback。用例多了以后,这几行栈是定位失败的关键。

二:== 对表是引用比较,比不了内容

  • {1, 2} == {1, 2}false——两个不同的表,内容一样也不相等(和 JS 的对象一样,见 06 章「表」);
  • 于是 Lua 里的「深比较」必须自己写:递归比 pairs 出来的每个键值,两边都要比一遍(只比一边会漏掉「对方多出来的键」)。第二张卡的框架里那十行就是干这个的;
  • 顺带两个数值上的坑:浮点别用 ==0.1 + 0.2 == 0.3false,差 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 接住后仍是个 tableerr.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
Lua 里最常见的假通过,是拿 == 比表。assert(split("a,b", ",") == {"a", "b"}) 永远失败——不是因为切分错了,而是因为两个表天然不相等;反过来 assert(t1 ~= t2)永远通过,看起来测了实际什么都没测。凡是断言对象是表,必须走深比较;这也是为什么下一张卡那三十行框架里,sameeq 长得多。
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.luamake 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(stubspy.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)
luaunit 靠「全局变量名以 Test 开头」发现用例——写成 local TestSplit = {} 就一条都不会跑,而它不报错,只安静地告诉你 Ran 0 tests。这和 js-ts 里 node --test 不认 *.spec.* 是同一类事故:「全绿」不等于「跑过了」,看汇总里的用例条数才算数

另外 Lua 5.4 起 local 声明有了属性语法,而这类框架多年来在多个版本间求兼容,装之前先确认它声明支持的 Lua 版本——luaunit 在 5.5.0 上工作正常,但生态里仍有不少库停在 5.1/5.3 的写法。
luaunit 的 --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 文档。
项目④选 OpenResty 要注意:它跑在 LuaJIT 上,而 LuaJIT 基于 5.1 语义——整数子类型、位运算符、utf8 库、<const>/<close> 全部没有,位运算得用它自带的 bit 库。练完本页的 5.4/5.5 语法直接上手会措手不及,先翻 LuaJIT 文档确认可用特性再动工。
一条自测标准:给你一个现成的 C 程序,你能否用 C API 把 Lua 嵌进去——注册一个宿主函数给脚本调用、再从 C 里读回脚本返回的 table——能做到,这一页就毕业了。