简单之美:Lua Table
Posted on in Tech
| Abstract | 简单之美:Lua Table |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Version | v1.0 |
| Updated | 2026-11-07 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- 一张表走天下:Lua 为什么只给你一个 table
- Table 的两副面孔:数组部分和哈希部分
- 元表:Lua 留的一个受控后门
- 元方法 cheat sheet
- 不是 OO 语言,却能搭出 OO
- LuaJIT 给 table 开的两个性能后门:table.new / table.clear
- OpenResty 再加的四个判断函数:clone / isarray / nkeys / isempty
- 共享内存字典:跨进程的那张表,ngx.shared.DICT
- 总结 cheat sheet
JavaScript 给你 Array、Object、Map、Set;Python 给你 list、dict、set、tuple;Java 给你一整个 java.util 包。到了 Lua,翻遍标准库,你能用来装数据的容器只有一种:table。
第一次认真写 Lua 脚本时,这事挺反直觉的——不是说"学一门新语言总得适应一下",而是真的会疑惑:没有数组、没有专门的哈希表、没有 Set,这语言是不是缺了点什么?
用下来才发现,这恰恰是 Lua 最耐琢磨的地方:它不是能力不够,而是想清楚了"数组""哈希""对象""集合"在内存里其实是同一件事——一组键到值的映射,区别只在键的形状——于是干脆只留一种容器,把选择权交给你。
一、Table 的两副面孔:数组和哈希,揉在一起
先看一个最朴素的例子:
local t = {
1, 2, 3, -- 这三个会被当成数组,下标自动是 1, 2, 3
name = "walter", -- 这是哈希的字符串键
[10] = "ten", -- 这是哈希的稀疏整数键
}
print(t[1]) --> 1
print(t.name) --> walter
print(t[10]) --> ten
这一张表里,1, 2, 3 和 name、[10] 看起来风马牛不相及,但在 Lua 眼里它们都是同一回事:键到值的映射。只不过 Lua 的 C 实现做了一个很实用的优化——内部把一张 table 拆成两部分:
- 数组部分(array part):专门存放从
1开始、连续递增的整数键,就是一段普通数组,查找是 O(1) 的下标访问。 - 哈希部分(hash part):存放其余所有键——字符串、浮点数、稀疏的整数、布尔值,甚至另一张 table——用哈希表实现。
你写 t[1]、t[2]、t[3] 这种连续下标时,Lua 会自动把它们放进数组部分;你写 t.name 或 t[10](10 前面没有 1~9 连续存在)时,它们进哈希部分。这个划分是 Lua 虚拟机自己做的,你不需要也无法手动指定——这也是"只有一种容器"能成立的关键:容器对外统一,内部按需优化,跟 Python 的 list 和 dict 分开实现、CPython 程序员各管各的不是一回事。
这个"两副面孔"直接决定了两个你一定会用到、也一定会踩坑的东西:
ipairs 和 pairs 不是一回事。
local t = {10, 20, 30, name = "walter"}
for i, v in ipairs(t) do
print(i, v)
end
-- 1 10
-- 2 20
-- 3 30
-- 遇到 name 这种非连续整数键,直接跳过,而且一旦中间断了(遇到 nil)就停
for k, v in pairs(t) do
print(k, v)
end
-- 1 10
-- 2 20
-- 3 30
-- name walter
-- 顺序不保证,但保证遍历到所有键
ipairs 只认数组部分那种"从 1 开始连续"的序列,pairs 才会把数组和哈希两部分都走一遍。把两者用混——比如指望 ipairs 能遍历到字符串键,或者指望 pairs 按插入顺序给结果——是 Lua 新手最常见的坑之一。
#t(长度运算符)在有"空洞"的表上行为未定义。
local t = {1, 2, nil, 4}
print(#t) -- 可能是 2,也可能是 4,官方文档明确说这是未定义行为
这不是 Lua 的 bug,是规则写明了的:# 只对"没有空洞的序列"(1 到 n 全部有值)保证结果正确。真要处理可能有空洞的集合,该用 table.remove/table.insert 维护连续性,或者后面会讲到的 table.nkeys 直接数键的个数。
Table 的本质就一句话:一组键值映射,Lua 在实现层面悄悄分成数组+哈希两部分来提速,但语义上对你来说永远是一张表。
二、元表:Lua 留的一个受控后门
原生的 table 能做的事其实很有限:存、取、遍历。没有运算符重载,没有继承,没有"访问不存在的键时给个默认值"这种便利。Lua 的设计者没有把这些功能硬塞进 table 本身,而是单独开了一个口子——元表(metatable)。
每张 table 都可以关联一张"元表",元表里定义了一组以 __ 开头的特殊键(叫元方法,metamethod),Lua 虚拟机在特定操作触发不到位时,会去查元表该不该接管。两个核心函数:
setmetatable(t, mt) -- 给 table t 设置元表 mt
getmetatable(t) -- 取出 t 当前的元表,没有则返回 nil
最常见的用法是 __index——当你访问一个不存在的键时,该去哪找:
local defaults = {timeout = 30, retries = 3}
local config = setmetatable({timeout = 60}, {
__index = defaults,
})
print(config.timeout) --> 60(自己有,不查元表)
print(config.retries) --> 3(自己没有,查元表的 defaults)
这就是"默认值表"的标准写法——比每次都写 config.retries or 3 干净得多,也是后面讲 OOP 继承的根基。
__newindex 则反过来,管的是"赋一个不存在的键"该怎么处理,常见用法是做只读表:
local function readonly(t)
return setmetatable({}, {
__index = t,
__newindex = function(_, k, v)
error("attempt to modify read-only table, key = " .. tostring(k), 2)
end,
})
end
local ro = readonly({a = 1, b = 2})
print(ro.a) --> 1
ro.c = 3 --> 报错:attempt to modify read-only table, key = c
再比如运算符重载——Lua 没有 class,但你可以让自定义的"类型"支持 +:
local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
return setmetatable({x = x, y = y}, Vector)
end
Vector.__add = function(a, b)
return Vector.new(a.x + b.x, a.y + b.y)
end
Vector.__tostring = function(v)
return string.format("(%d, %d)", v.x, v.y)
end
local v1 = Vector.new(1, 2)
local v2 = Vector.new(3, 4)
print(v1 + v2) --> (4, 6),触发 __add 和 __tostring 两个元方法
元方法 Cheat Sheet
| 元方法 | 触发时机 | 典型用途 |
|---|---|---|
__index |
访问不存在的键(t.k) |
默认值、继承、惰性加载 |
__newindex |
给不存在的键赋值(t.k = v) |
只读表、写拦截、代理/校验 |
__call |
把 table 当函数调用(t(args)) |
仿函数、工厂对象 |
__tostring |
tostring(t) / print(t) |
自定义打印格式 |
__eq |
a == b(且 a、b 都是 table) |
值相等而非引用相等 |
__lt / __le |
a < b / a <= b |
自定义排序比较 |
__add __sub __mul __div __mod __pow __unm |
算术运算符 | 向量、矩阵、Decimal 之类的数值类型 |
__concat |
a .. b |
自定义拼接逻辑 |
__len |
#t |
自定义长度语义(LuaJIT 需开 5.2 兼容) |
__gc |
对象被垃圾回收 | 释放外部资源(文件句柄、FFI 内存) |
__metatable |
getmetatable(t) 被调用 |
保护元表不被外部篡改 |
一句话记住元表:table 本身保持极简,所有"花活"都通过一张可插拔的元表来扩展——这是"先做减法、再开受控的口子"的设计哲学,而不是把所有功能都塞进核心数据结构。
三、不是 OO 语言,却能搭出 OO
Lua 官方文档说得很直白:Lua 不是面向对象语言,没有 class 关键字,没有内置继承。但靠 __index 这一个元方法,你能搭出一套能跑的类系统——这也是 Lua 在游戏脚本、嵌入式场景里经久不衰的原因之一:需要 OO 的时候自己搭一个轻量的,不需要的时候一点负担都没有。
-- 基类 Animal
local Animal = {}
Animal.__index = Animal
function Animal.new(name, sound)
local self = setmetatable({}, Animal)
self.name = name
self.sound = sound
return self
end
function Animal:speak()
print(self.name .. " says " .. self.sound)
end
-- 子类 Dog,继承 Animal
local Dog = setmetatable({}, {__index = Animal})
Dog.__index = Dog
function Dog.new(name)
local self = Animal.new(name, "Woof")
return setmetatable(self, Dog)
end
function Dog:fetch()
print(self.name .. " fetches the ball")
end
local d = Dog.new("Rex")
d:speak() --> Rex says Woof(自己没有 speak,沿着 __index 链查到 Animal)
d:fetch() --> Rex fetches the ball
拆开看这套写法在干什么:
function Animal:speak()里的冒号:是语法糖,等价于function Animal.speak(self)——调用时d:speak()也会自动把d作为第一个参数传进去。这是 Lua 面向对象的"隐式 self",跟 Python 显式写self异曲同工。Dog的元表指向Animal,Dog自己的元表又指向Dog——于是查找d.speak时:先查d自己(没有)→ 查d的元表Dog(没有 speak,只有 fetch)→ 查Dog的元表Animal(找到了)。这条链就是 Lua 版本的"原型链",跟 JavaScript 的 prototype chain 是同一个思路。- 没有任何"类"关键字,
Animal和Dog本质上都只是普通 table,区别只是它们被当成了别人的元表。
这套写法的代价也要说清楚:没有编译期检查,拼错一个方法名不会报错,只会在运行时返回 nil 再在调用处炸掉;链查找有性能开销,继承层级越深,一次未命中的属性访问要多走几层 __index。真要做大型项目的类系统,社区有现成的库(如 middleclass、30log)把这套模式包装得更规范,但骨架都是上面这几行代码。
四、LuaJIT 给 table 开的两个性能后门
标准 Lua 5.1 的 table 库只有 insert、remove、concat、sort、maxn 这几个函数,都是"用表"的工具,不涉及表本身怎么分配内存。LuaJIT 在完全兼容 5.1 的基础上,另外塞了两个专门解决性能问题的扩展,它们不在 table.* 命名空间里直接可用,要显式 require:
local new_tab = require("table.new")
local clear_tab = require("table.clear")
-- table.new(narr, nhash):预先分配好数组部分 narr 个槽位、
-- 哈希部分 nhash 个槽位,避免插入过程中反复扩容搬迁
local t = new_tab(100, 0)
for i = 1, 100 do
t[i] = i * i
end
-- table.clear(t):清空表里所有键值对,但保留已经分配好的内存
-- 不触发重新分配,适合反复复用的"池化"表
clear_tab(t)
这两个函数解决的是同一类问题:Lua 的 table 会按需自动扩容,但扩容意味着重新分配内存、搬迁旧数据。如果你提前知道一张表大概要装多少条数据(比如解析一个固定长度的协议包、批量处理一批请求),用 table.new 一次分配到位,能省掉中间好几次扩容;如果你有一张表要在循环里反复用、反复清空(比如每次请求都复用一个临时表而不是重新 {}),用 table.clear 能避免一次分配一次回收的 GC 压力。
LuaJIT 官方文档专门提醒:这两个函数是给"非常特定场景"用的,不要把它当成常规优化手段到处用——大多数情况下,让 GC 正常接管、新建一张 {} 反而更简单也更不容易出 bug。
五、OpenResty 再加的四个判断函数
OpenResty 内置了自己 fork 的 LuaJIT(叫 luajit2),在上面那两个函数之外,又往 table 命名空间里塞了四个专门解决"判断一张表是什么形状"的函数,同样需要 require:
local clone = require("table.clone")
local isarray = require("table.isarray")
local nkeys = require("table.nkeys")
local isempty = require("table.isempty")
local t = {1, 2, 3}
local t2 = clone(t) -- 浅拷贝一份新表,和 t 互不影响
print(isarray({1, 2, 3})) --> true(数组部分连续、哈希部分为空)
print(isarray({a = 1})) --> false
print(nkeys({1, 2, a = 1})) --> 3(数组 2 个 + 哈希 1 个,不管有没有空洞都能数对)
print(isempty({})) --> true
这四个函数要解决的是前面提过的那个老问题:用 #t 判断"这是数组还是对象"是不靠谱的,尤其是要把 Lua table 序列化成 JSON 的场景——JSON 的数组 [1,2,3] 和对象 {"a":1} 在 Lua 这边都是 table,序列化库必须先判断这张表"长得像数组"还是"长得像对象",才能决定输出 [...] 还是 {...}。在 table.isarray 出现之前,大家各写各的启发式判断(通常是看 #t 是否等于 pairs 遍历出的键总数),既慢又容易在空洞表上判断错;现在直接调用内置的 C 实现,既准确又快。
nkeys 同理解决了"这张表到底有几个键"这个看似简单、实际上 #t 完全回答不了的问题——它不受空洞影响,老老实实把数组部分和哈希部分的键都数一遍。
六、共享内存字典:跨进程的那张表
前面讲的所有 table,不管普通表还是元表加持过的表,都只活在一个 Lua 虚拟机实例里。而 OpenResty 跑在 Nginx 之上,Nginx 的典型部署是一个 master 进程带多个 worker 进程,每个 worker 都是独立的 Lua 虚拟机,互相看不到对方的内存。你在 worker A 处理的一个请求里往某张 table 塞了数据,worker B 处理下一个请求时完全看不到——这对"限流计数""会话状态""简单缓存"这类必须跨请求、跨 worker 共享的场景是个硬伤。
OpenResty 的解法是 ngx.shared.DICT——一块由 Nginx 配置文件声明、所有 worker 进程都能访问的共享内存区域,Lua 层面把它包装成一个"类 table"的对象。注意它不是普通 table,不支持 pairs/ipairs/元表,但提供了一套读写 API,而且每个方法内部都是原子操作,不需要你自己加锁:
http {
lua_shared_dict limiter 10m; # 声明一块 10MB 的共享内存区域,取名 limiter
server {
location /api {
access_by_lua_block {
local limiter = ngx.shared.limiter
local key = "req_count:" .. ngx.var.remote_addr
-- incr 是原子自增:key 不存在时用 init 初始化成 0 再加
local count, err = limiter:incr(key, 1, 0, 60)
if not count then
ngx.log(ngx.ERR, "incr failed: ", err)
return
end
if count > 100 then
ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end
}
proxy_pass http://backend;
}
}
}
这段代码就是一个最小可用的限流器:incr(key, step, init, init_ttl) 把计数原子加 1,如果 key 不存在就先用 init 初始化并设置 init_ttl 秒后过期——不管多少个 worker 并发处理请求,这个计数都是全局唯一、不会因为并发而错乱的。
除了计数,ngx.shared.DICT 还内置了简单的队列操作:
local queue = ngx.shared.my_queue
queue:rpush("jobs", "task-1") -- 从队尾塞入
queue:rpush("jobs", "task-2")
print(queue:llen("jobs")) --> 2
local job = queue:lpop("jobs") -- 从队头弹出,FIFO
print(job) --> task-1
ngx.shared.DICT Cheat Sheet
| 方法 | 作用 | 要点 |
|---|---|---|
get(key) |
读取值 | 不存在或已过期返回 nil |
set(key, value, exptime?, flags?) |
无条件写入 | 空间不足时按 LRU 淘汰旧数据 |
safe_set(key, value, exptime?) |
写入但不淘汰 | 空间不足直接返回 nil, "no memory" |
add(key, value, exptime?) |
仅当 key 不存在时写入 | 已存在返回 false, "exists" |
replace(key, value, exptime?) |
仅当 key 存在时写入 | 不存在返回 false, "not found" |
delete(key) |
删除 | 等价于 set(key, nil) |
incr(key, step, init?, init_ttl?) |
原子加减 | 常用于计数器、限流 |
lpush(key, value) / rpush(key, value) |
队列头部/尾部入队 | key 不存在会自动创建 |
lpop(key) / rpop(key) |
队列头部/尾部出队 | 空列表返回 nil |
llen(key) |
队列长度 | |
ttl(key) / expire(key, ttl) |
查询/设置剩余过期时间 | |
flush_all() / flush_expired() |
清空全部/清空已过期项 | 慎用 flush_all,影响所有 worker |
get_keys(max_count?) |
列出 key | 数据量大时代价不低,慎用 |
capacity() / free_space() |
查总容量/剩余空间 | 用于监控告警 |
几个容易踩的边界:只能存标量(布尔、数字、字符串),不能直接存一张 Lua table——要存结构化数据得自己用 cjson 先编码成字符串;容量是 lua_shared_dict 指令里写死的,满了会按 LRU 淘汰还没过期的旧数据,容量规划不到位照样会丢数据;"原子"指的是单次方法调用内部原子,如果你的逻辑是"先 get 判断、再 set 写入"这种组合操作,中间仍然可能被别的 worker 插一脚,真正的"检查并设置"要用 add,而不是手写 get + set。
总结:一张表的哲学
一路看下来,Lua table 能串起来的这几层——数组哈希揉一起、元表当唯一扩展口、LuaJIT 加性能后门、OpenResty 加判断函数、共享内存字典解决跨进程——本质上都是同一个设计选择的延伸:先把数据结构简化到极致,再用一个受控的接口(元表)去承载所有复杂度。这跟"大道至简"是一个意思:少一个概念,就少一处要记、要踩坑、要维护的地方;真正的扩展性不是靠堆砌功能,而是靠留一个足够通用的钩子。
下次再碰到一门号称"轻量级"的语言或工具,不妨先看它是不是也在做这件事——是真的想清楚了"核心是什么"而做了减法,还是只是功能没做全。
Cheat Sheet 速查
| 层级 | 关键 API / 概念 | 一句话 |
|---|---|---|
| 基础 | #t、ipairs、pairs |
#t 对有空洞的表行为未定义;ipairs 只走连续整数键 |
| 元表 | setmetatable / getmetatable |
table 保持极简,所有扩展走元表 |
| 元方法 | __index __newindex __call __add __tostring ... |
见上文元方法 cheat sheet |
| OOP | t.__index = t + setmetatable(obj, t) |
没有 class,靠 __index 链模拟原型继承 |
| LuaJIT | require("table.new") / require("table.clear") |
预分配、复用表,减少 GC 压力 |
| OpenResty | require("table.isarray"/"nkeys"/"isempty"/"clone") |
准确判断表的形状,别再手写 #t 启发式 |
| 跨进程 | ngx.shared.DICT |
真正跨 worker 共享的原子字典,只能存标量 |
全文思维导图
@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* 简单之美:Lua Table
** Table 本体
*** 数组部分:连续整数键
*** 哈希部分:其余所有键
*** # 对空洞表未定义
*** ipairs vs pairs
** 元表 metatable
*** setmetatable / getmetatable
*** __index / __newindex
*** 运算符重载 __add __eq __tostring
*** 唯一的扩展机制
** 面向对象
*** Lua 本身不是 OO
*** __index 链 = 原型链
*** 冒号语法糖 self
** LuaJIT 扩展
*** table.new 预分配
*** table.clear 清空复用
** OpenResty 扩展
*** table.isarray
*** table.nkeys
*** table.isempty
*** table.clone
** 共享内存字典
*** ngx.shared.DICT
*** 原子计数 incr
*** 队列 lpush/rpush/lpop/rpop
*** 只能存标量
@endmindmap
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。