简单之美: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 访问原文并评论。

相关文章