3.1 表的创建与双重身份


文档摘要

3.1 表的创建与双重身份 本节摘要:表的构造器有位置式、记录式与显式键三种写法,可混排。内部视角下,一张表同时维护"数组部分"与"哈希部分":连续整数键进数组段,其余进哈希段——这就是表既是数组又是字典的原因,也是 ipairs、长度操作符、table.concat 只认数组段的根源。本节讲透创建语法、键的合法性、双重身份的性能含义,以及表作为命名空间的组织术。 一个结构走天下 先做一个思想实验:如果让你设计一门要住进游戏引擎、Redis、路由器的脚本语言,你会给它几种复合结构?数组肯定要,字典肯定要,对象要吧?结构体要吧?列表、集合、树呢? Lua 的答案是:一种。表。数组是键恰为 1 到 n 的表;字典是键任意的表;对象是带函数字段的表加一块元表;结构体是字段名作键的表;

3.1 表的创建与双重身份

本节摘要:表的构造器有位置式、记录式与显式键三种写法,可混排。内部视角下,一张表同时维护"数组部分"与"哈希部分":连续整数键进数组段,其余进哈希段——这就是表既是数组又是字典的原因,也是 ipairs、长度操作符、table.concat 只认数组段的根源。本节讲透创建语法、键的合法性、双重身份的性能含义,以及表作为命名空间的组织术。

一个结构走天下

先做一个思想实验:如果让你设计一门要住进游戏引擎、Redis、路由器的脚本语言,你会给它几种复合结构?数组肯定要,字典肯定要,对象要吧?结构体要吧?列表、集合、树呢?

Lua 的答案是:**一种。表。**数组是键恰为 1 到 n 的表;字典是键任意的表;对象是带函数字段的表加一块元表;结构体是字段名作键的表;集合是"键存在即成员"的表;树是表套表。这个决定激进到初学者怀疑人生,但它的寄宿逻辑无懈可击:宿主与脚本之间每多一种结构,就多一套转换协议、一册文档、一堆边界 bug。一种结构意味着 C 侧只需实现 lua_newtablelua_settablelua_gettable 三个原语,就能与脚本交换任意复杂的数据。Redis 的 EVAL 把键与参数表塞给脚本、OpenResty 把请求信息打包成 ngx 表、游戏引擎把实体属性暴露成表——大家不约而同,因为这是唯一公约。

构造器:三种写法

空表local t = {}

位置式(数组写法):按出现顺序自动分配整数键 1、2、3……:

> local route = { "auth", "ratelimit", "proxy", "log" } > print(route[1], route[4]) auth log

记录式(键值写法)name = value 形式,键是字符串 "name":

> local node = { host = "10.0.0.8", port = 6379, weight = 3 } > print(node.host, node["port"]) 10.0.0.8 6379

node.hostnode["host"] 完全等价——点号是键为"合法标识符字符串"时的语法糖。键不是合法标识符时只能用方括号:node["max-conn"]node[2024]

显式键式[表达式] = value,键可以是任意表达式:

> local k = "level" > local t = { [k] = 7, ["max-conn"] = 100, [1] = "first" } > print(t.level, t["max-conn"], t[1]) 7 100 first

三种写法可以混排,位置式的下标计数不受显式键干扰:

> local t = { "a", "b", host = "h1", "c", [10] = "j" } > print(#t, t[3], t.host, t[10]) 3 c h1 j

前三个位置元素占 1、2、3,host 是哈希键不占位置序号,[10] 显式放远端——#t 报 3,因为连续段到 3 为止。这个例子已经预演了"边界"问题,3.2 节细说。

混排构造器最实用的场合是给记录表带一个数组字段——宿主接口的常见返回形态:

local resp = { code = 200, headers = { "content-type: text/plain", "x-trace: 9f2a" }, -- 数组字段 body = "ok", }

嵌套:表套表造结构

构造器直接嵌套,复杂数据一气呵成:

local cluster = { name = "primary", nodes = { { host = "10.0.0.8", port = 6379, role = "master" }, { host = "10.0.0.9", port = 6379, role = "replica" }, }, policy = { failover = true, timeout = 3.0 }, } print(cluster.nodes[2].role) --> replica print(cluster.policy.failover) --> true

访问链 cluster.nodes[2].role 混用两种索引形态:方括号走整数键,点号走字符串键。任何一层缺席会怎样?中间层为 nil 时继续索引直接报错(1.2 节的错误家族),守卫链 a and a.b and a.b.c 或第 5 章的默认值元表可以兜住。

双重身份的内幕

表凭什么既是数组又是字典?看内部:

双重身份的内幕

"边界"(border)这个词先按下,3.2 节正式定义。这里先消化三个实践推论:

推论一:热点路径用数组。数组段访问是直寻,哈希段要做哈希与冲突处理。同样存一千个元素,遍历数组段明显快于遍历哈希段——游戏每帧处理的实体列表、网关每请求处理的规则数组,都应保持位置式结构。

推论二:构造时一次到位。表构造器能根据元素个数预分配:

local t = {} for i = 1, 10000 do t[i] = i * i end -- 多次扩容 local u = table.create and table.create(10000) or {} -- LuaJIT 专属预分配

普通 Lua 没有公开的预分配 API(LuaJIT 的 table.create 是扩展),但用构造器语法写出初始规模也能帮虚拟机估计容量:{ 0, 0, 0, ... } 不现实时,逐个赋值也没那么慢,真正要避免的是"先建大数组再逐个 nil 掉中间段"这种制造洞的写法。

推论三:表从不收缩数组段的语义。删除中间元素制造的洞不会让后面的元素前移(它不是真数组),要"真删除"得用 table.remove(3.2 节)。

键的规矩

nil 不能作键,NaN 也不能。给 nil 键赋值等于删键:

> local t = {} > t[nil] = 1 input:1: table index is nil > t[0/0] = 1 input:1: table index is NaN

为什么 NaN 被禁?哈希与相等判定都依赖"键能稳定等于自己",而 NaN 不等于 NaN,放进去就找不回来。nil 被禁则是语义决定:t[k] = nil 早已承担"删除"语义,nil 作键会与之冲突。

整数键与浮点键归一。数学上相等的数做同一个键:t[2]t[2.0] 是一个位置。但 t[2] 是数组语义键,t["2"] 是另一个键——字符串 "2" 与数字 2 不混。从外部数据(JSON、协议文本)构造表时,键类型的整数/字符串之别是漏数据的高发点。

引用作键:表、函数、userdata、协程都能作键,按引用相等判定。最典型的是"集合去重"与"对象挂元数据":

local seen = {} local items = { a = {}, b = {}, c = {} } for name, obj in pairs(items) do if not seen[obj] then seen[obj] = name -- 以对象为键登记 end end print(seen[items.a]) --> a

字符串作键则自带驻留与值相等(1.3 节),所以配置键、协议字段名放心用字符串。

表作命名空间

表是 Lua 组织代码的容器——模块即表、类即表、包即表的嵌套:

-- 手工搭一个命名空间(第 6 章 require 会给出标准做法) local net = {} net.tcp = {} net.tcp.connect = function(opts) return { fd = 7, opts = opts } end net.udp = { send = function(...) return true end } net.version = "1.2" local conn = net.tcp.connect{ host = "10.0.0.8" } print(conn.fd, net.version) --> 7 1.2

命名空间的存在让全局环境保持干净:全局只有 net 一个名字,其余全在表内。这直接呼应 1.3 节"能 local 就 local"的守则——表内字段不是全局变量,它是这张表的字段,作用域与生命周期由表的持有者管理。C 宿主侧同理:注册进脚本的全局 API 本身就是一张表(Redis 的 redis 表、OpenResty 的 ngx 表、1.2 节示例的 host 表),宿主给脚本的"世界",就是一张预先布置好的表

💡 关键直觉:把表读作"可编程的 JSON 加方法"。JSON 能表达的它全能表达,JSON 不能的(函数字段、任意类型键、嵌套引用共享)它也能。跨语言数据交换选它,跨层组织代码也选它,一门语言只需要练熟一个结构。

深拷贝与深比较:引用语义的功课

引用语义的副作用是"复制"与"比较"都要自己动手。浅拷贝一张表只需一次循环,深拷贝要处理嵌套与环:

local function deep_copy(obj, seen) if type(obj) ~= "table" then return obj end seen = seen or {} if seen[obj] then return seen[obj] end -- 环引用:返回已拷贝的对应物 local copy = {} seen[obj] = copy -- 先登记再递归(环在此断开) for k, v in pairs(obj) do copy[deep_copy(k, seen)] = deep_copy(v, seen) end return copy end local orig = { name = "cluster", nodes = { { id = 1 }, { id = 2 } } } orig.nodes[1].peer = orig.nodes[2] -- 制造交叉引用 local dup = deep_copy(orig) dup.nodes[1].id = 99 print(orig.nodes[1].id, dup.nodes[2].peer.id) -- 1 2(原物未被污染)

seen 表是关键:它把"已拷贝的表"登记下来,环与共享引用都在这里得到正确处理——交叉引用拷完后仍然是交叉(dup.nodes[2].peer 指向 dup 的第二节点,不是原物的)。深比较同理,需要 seen 集合防环:

local function deep_equal(a, b, seen) if a == b then return true end -- 值语义或同一引用 if type(a) ~= "table" or type(b) ~= "table" then return false end seen = seen or {} if seen[a] == b then return true end -- 正在比较的环对 seen[a] = b local count = 0 for k, v in pairs(a) do count = count + 1 if not deep_equal(v, b[k], seen) then return false end end for _ in pairs(b) do count = count - 1 end -- 数量对账 return count == 0 end print(deep_equal({1, {2, 3}}, {1, {2, 3}})) --> true print(deep_equal({1, 2}, {1, 2, 3})) --> false

这两段代码出现在几乎每个 Lua 项目的工具库里。它们同时是本章概念的操练场:引用语义(为什么 a == b 不够)、nil 语义(b[k] 缺失时 deep_equal 要能判 false)、遍历协议(数量对账用 pairs)。配置快照、撤销栈、跨边界数据隔离(把要过 C 边界的表拷一份防后续修改)是它们的高频使用场景。

数组还是字典:选型三问

同一份数据用位置式还是记录式,新手常拿不准。三个问题问下来答案自现:

顺序有意义吗? 有(处理次序、优先级、队列语义)用数组;没有(按名取用、集合成员)用字典。日志行有顺序,配置项没有。

键会动态出现吗? 键是运行时算出来的(用户输入、ID、路由规则)必然是字典;键在写代码时就枚举完毕(host、port、retry)用记录式更直观,还能享受点号访问的书写顺畅。

要过边界吗? 与宿主或外部系统(JSON、协议)交换时,按对方的约定走:JSON 数组对应位置式、JSON 对象对应记录式;Redis 的 KEYS 是数组、ARGV 常是字典语义。边界格式决定内部表示,免得来回倒腾。

三问之后再补一条经验法则:拿不准就用字典(键明确、可扩展、遍历可控),只有明确需要顺序与长度语义时才上数组——因为数组段的边界规则(下一节的主角)是表里唯一"需要小心伺候"的部分。

与宿主交换数据的完整演练

模拟一次"宿主下配置、脚本回结果"的全流程,看表在两个方向上的形态:

-- 方向一:宿主把配置塞进脚本环境(模拟宿主注入) local HOST_CONFIG = { workers = { { id = 1, weight = 5 }, { id = 2, weight = 3 }, { id = 3, weight = 2 } }, limits = { qps = 5000, burst = 200 }, zone = "east", } -- 脚本侧:加权选择一个 worker(数组遍历 + 记录取值混用) local function pick_weighted(workers) local total = 0 for _, w in ipairs(workers) do total = total + w.weight end local r = math.random(1, total) for _, w in ipairs(workers) do r = r - w.weight if r <= 0 then return w.id end end return workers[#workers].id end -- 方向二:脚本把结果整理成表返回(宿主按约定字段读取) local function build_report(cfg) return { zone = cfg.zone, picked = pick_weighted(cfg.workers), limit_total = cfg.limits.qps + cfg.limits.burst, generated_by = "lua", } end print(build_report(HOST_CONFIG).picked) -- 随机 1 到 3 之一

一次交换、四种表的用法:数组(workers)遍历加权、嵌套记录(limits)逐层取值、构造返回表(按约定字段)、字段携带类型标记(generated_by)——宿主与脚本之间的接口设计,就是两张表的字段约定设计。把这个演练里的 HOST_CONFIG 换成 Redis 的 KEYS 或 OpenResty 的 ngx.ctx,形态完全一致。

本节要点回顾

  • 一种结构三个身份:数组(键 1 到 n)、字典(任意键)、命名空间/对象(函数字段加元表),宿主与脚本因此只需一套公约;
  • 构造器三式混排:位置式自动整数键、记录式字符串键糖、显式 [表达式] 键,嵌套写复杂数据一气呵成;
  • 内部双段:连续整数键进数组段(直寻快),其余进哈希段;两段对访问透明,但 ipairs、#、table.concat 只认数组段边界;
  • 键的禁令:nil 与 NaN 不得作键;整数与数学相等浮点同键;字符串 "2" 与数字 2 不同键;
  • 引用作键天然支持去重与对象挂元数据;字符串键靠驻留获得值相等与高性能;
  • 命名空间即表:全局保持极简,能力收进表内——宿主注入的 API 表是同一套思路在 C 侧的镜像;
  • 深拷贝与深比较是引用语义的功课:seen 表防环、先登记再递归、数量对账——每个项目工具库的标配件。

下一节讲表的遍历与长度之谜,四个应用场景把"交界"两个字落到实处。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U