5.表底层、长度与遍历顺序
5.1 知识点
table 底层分段
table 底层分成数组段和哈希段:
- 数组段:连续正整数 key,比如 1、2、3。
- 哈希段:字符串 key、不连续整数 key、table key 等。
实际上某个 key 具体落在哪一段,和 Lua 版本、LuaJIT、运行时实现、rehash 过程都有关系。
table 很灵活,但长度和遍历顺序不可靠。
长度 # 和空洞
连续数组里,# 看起来像长度。
local list = { "A", "B", "C" }
print(#list) -- 输出:3
因为 list[1] 到 list[3] 都有值,list[4] 是 nil。
边界明确,结果也明确。
但是表一旦有洞,# 就不能当元素数量。
local list = {
[1] = "A",
[2] = "B",
[4] = "D"
}
print(#list)
list[2] 有值,list[3] 是 nil;list[4] 有值,list[5] 是 nil。
这种表已经不是严格连续数组,#list 的结果不能当元素数量。
项目里一般这么处理:
- 能保证 1 到 n 连续,才用
#t。 - 有洞就不能依赖
#t。 - 字典数量自己维护
count。 - 业务顺序自己维护
order表。
# 只是适合连续序列。
ipairs 和 nil
ipairs 从 1 开始往后走,遇到第一个 nil 就停。
local list = {
[1] = "A",
[2] = "B",
[4] = "D"
}
for i, v in ipairs(list) do
print(i, v)
-- 输出:1 A / 2 B
end
list[4] 有值也没用。
因为 list[3] 是 nil,遍历到这里就结束了。
所以按 id 做 key 的配置表,也不要用 ipairs:
local config = {
[1001] = { name = "FireBall" },
[1005] = { name = "IceBolt" }
}
for i, row in ipairs(config) do
print(i, row.name)
-- 输出:无输出,因为 config[1] 就是 nil
end
ipairs 也只适合连续数组。
pairs 和 next
pairs 背后走的是 next。
它能扫到表里的 key-value,但顺序不能当业务顺序。
local t = {
a = 1,
b = 2,
c = 3
}
for k, v in pairs(t) do
print(k, v)
end
上面三组数据都会被扫到。
但是 a、b、c 谁先输出,Lua 不保证。
某个版本、某次运行里看起来可能是固定的。
Lua 不保证顺序固定。
next 手写出来大概是这样:
local key, value = next(t, nil)
while key ~= nil do
print(key, value)
key, value = next(t, key)
end
next(t, nil) 拿第一个 key。
下一轮把上一次的 key 传回去,继续拿下一个 key。
返回 nil 时结束。
可以粗略类比成 C# 迭代器的 MoveNext,都是“往下取一项”。
但 Lua 的 next 不维护一个外部迭代器对象,而是靠上一次的 key 继续找下一个 key。
平时很少需要手写 next,但面试问 pairs 底层时得知道它和 next 的关系。
遍历时别乱改
遍历表时,不是所有修改都完全被禁止。
改已有 value、清掉已有 key,一般还是没问题的。
一般来说,真正要避免的是:遍历过程中新增不存在的 key。
local t = {
a = 1,
b = 2
}
for k, v in pairs(t) do
t["new_" .. k] = v -- 不建议:遍历过程中新增 key
end
新增出来的 new_a、new_b 会不会在本轮遍历里被扫到,Lua 不保证。
有时候看起来正常跑完。
有时候新增 key 也被扫到了。
有时候顺序变了。
如果触发 table 内部调整,问题会更难查。
一般的写法是先收集,再统一处理:
local pending = {}
for k, v in pairs(t) do
table.insert(pending, {
key = "new_" .. k,
value = v
})
end
for _, item in ipairs(pending) do
t[item.key] = item.value
end
第一轮只读原表,第二轮再改表。边界干净。
如果只是改已有字段,一般没问题:
local t = {
a = 1,
b = 2
}
for k, v in pairs(t) do
t[k] = v + 10 -- 修改已有 value
end
print(t.a) -- 输出:11
print(t.b) -- 输出:12
如果要删除,也尽量只删已经存在的 key:
local t = {
a = 1,
b = 2,
c = 3
}
for k, v in pairs(t) do
if v % 2 == 1 then
t[k] = nil -- 清掉已有 key
end
end
print(t.a) -- 输出:nil
print(t.b) -- 输出:2
print(t.c) -- 输出:nil
如果是数组列表,别在遍历时直接 table.remove 当前下标。table.remove(t, i) 会把 i + 1 后面的元素往前挪,循环自己的下标还在继续往后走,很容易跳过元素。
local list = { "A", "B", "C", "D" }
for i, v in ipairs(list) do
if v == "B" then
table.remove(list, i) -- 删除 B,C 会补到 2 号位
end
print(i, v)
-- 输出:1 A / 2 B / 3 D
-- C 被挪到 2 号位,但 ipairs 下一轮已经走到 3 号位,所以 C 被跳过了
end
print(list[1], list[2], list[3], list[4])
-- 输出:A C D nil
table.remove 删当前下标时,后面元素会往前补,遍历下标还在往后走,容易漏处理。
如果只是字典表,别用 table.remove:
local t = {
a = 1,
b = 2,
c = 3
}
for k, v in pairs(t) do
if k == "b" then
t[k] = nil -- 字典删除用 nil
end
end
table.remove 主要是给数组列表用的。
字典删除直接 t[k] = nil 就行。
数组里要边遍历边删除,一般用倒序遍历:
local list = { "A", "B", "C", "D" }
for i = #list, 1, -1 do
if list[i] == "B" or list[i] == "C" then
table.remove(list, i)
end
end
print(list[1], list[2], list[3])
-- 输出:A D nil
倒序删的好处是:后面的元素就算前移,也不会影响还没遍历到的前面部分。
不过热路径里如果删得很频繁,还是优先考虑前面讲过的“标记删除,帧末统一整理”。
自定义迭代器
Lua 的 for ... in ... do 背后是迭代器协议。
先知道它大概怎么跑就行。
简单理解,迭代器要返回三样东西:
- 迭代函数。
- 状态。也就是要遍历的表。
- 初始控制变量。
比如下面这个反向遍历数组的例子:
local function ReverseIpairs(list)
local function Iterator(state, index)
-- 每次进来,index 都是上一次返回出去的下标
-- 这里先 -1,就可以从后往前走
index = index - 1
if index >= 1 then
-- 第一个返回值会成为下一轮的控制变量
-- 后面的返回值会交给 for 循环里的变量
return index, state[index]
end
-- 不返回值,相当于返回 nil
-- 控制变量变成 nil,循环结束
end
-- Iterator:迭代函数
-- list:状态,也就是要遍历的表
-- #list + 1:初始控制变量,第一次进 Iterator 后会先减到 #list
return Iterator, list, #list + 1
end
local list = { "A", "B", "C" }
for i, v in ReverseIpairs(list) do
print(i, v)
-- 输出:3 C / 2 B / 1 A
end
这段代码第一次循环前,ReverseIpairs(list) 会先返回:
Iterator, list, 4
for 循环内部大致等价于:
-- ReverseIpairs(list) 返回三样东西:
-- iterator:迭代函数
-- state:迭代状态,这里就是要遍历的 list
-- index:初始控制变量,这里是 #list + 1
local iterator, state, index = ReverseIpairs(list)
while true do
-- 每一轮都把 state 和上一次的 index 传回迭代函数
-- 迭代函数会返回新的下标和值
local i, v = iterator(state, index)
-- 迭代函数返回 nil,表示没有下一项了,循环结束
if i == nil then
break
end
-- i 会作为下一轮的控制变量
-- 这里必须更新,否则每次都会从同一个位置继续算
index = i
print(i, v)
-- 依次输出:3 C / 2 B / 1 A
end
所以第一次调用是:
Iterator(list, 4)
函数里先 index = index - 1,变成 3,于是返回:
3, list[3]
下一轮再调用:
Iterator(list, 3)
继续减成 2,再返回:
2, list[2]
一直走到 index < 1,迭代函数不再返回有效下标,循环就结束。
一般不会频繁手写这种迭代器。
大多数时候 ipairs、pairs、普通 for 已经够用。
如果容器确实有特殊遍历规则,可以封装一些明确的方法。
但是不要乱写迭代器。
迭代器适合封装容器规则,但不适合把简单逻辑写复杂。
总结
# 只适合连续序列。
有洞别背固定输出,需要数量就自己维护 count。ipairs 遇到第一个 nil 就停。pairs 走 next,能扫 key-value,但顺序不能信。
遍历时别新增 key,先收集、后处理。
自定义迭代器必要的时候再写。
Lua table 很灵活,但长度、顺序、遍历修改都要自己保证可靠性。
5.2 知识点代码
Lesson5_表底层长度与遍历顺序.lua
print("**********表底层、长度与遍历顺序************")
print("**********知识点一 table 底层分段************")
local mixed = {
"A",
"B",
role = "cache",
[1001] = "skill"
}
print(mixed[1]) -- 输出:A,连续整数 key 可以按数组理解
print(mixed.role) -- 输出:cache,字符串 key 按哈希理解
print(mixed[1001]) -- 输出:skill,不连续整数 key 也可以作为 key
print("**********知识点二 长度 # 和空洞************")
local continuous = { "A", "B", "C" }
print(#continuous) -- 输出:3,连续数组可以把 # 当长度
local holeList = {
[1] = "A",
[2] = "B",
[4] = "D"
}
local holeLength = #holeList
print(holeList[3]) -- 输出:nil,中间有洞
print(holeList[holeLength] ~= nil and holeList[holeLength + 1] == nil)
-- 输出:true,有洞时只验证它是某个边界,不背固定数字
local players = {}
local playerCount = 0
local function AddPlayer(id, data)
if players[id] == nil then
playerCount = playerCount + 1
end
players[id] = data
end
local function RemovePlayer(id)
if players[id] ~= nil then
players[id] = nil
playerCount = playerCount - 1
end
end
AddPlayer(1001, { name = "RoleA" })
AddPlayer(2008, { name = "RoleB" })
RemovePlayer(1001)
print(playerCount) -- 输出:1,字典数量自己维护
print("**********知识点三 ipairs 遇 nil 就停************")
local sparseList = {
[1] = "A",
[2] = "B",
[4] = "D"
}
local ipairsCount = 0
for i, v in ipairs(sparseList) do
ipairsCount = ipairsCount + 1
print(i, v) -- 输出:1 A / 2 B,遇到 3 号位 nil 就停
end
print(ipairsCount) -- 输出:2
local config = {
[1001] = { name = "FireBall" },
[1005] = { name = "IceBolt" }
}
local configIpairsCount = 0
for _ in ipairs(config) do
configIpairsCount = configIpairsCount + 1
end
print(configIpairsCount) -- 输出:0,config[1] 是 nil
print("**********知识点四 pairs 和 next************")
local map = {
a = 1,
b = 2,
c = 3
}
local pairCount = 0
for _ in pairs(map) do
pairCount = pairCount + 1
end
print(pairCount) -- 输出:3,pairs 能扫到三组 key-value,但顺序不能依赖
local nextCount = 0
local key, value = next(map, nil)
while key ~= nil do
nextCount = nextCount + 1
-- 下一轮把上一次的 key 传回去,继续找下一个 key
key, value = next(map, key)
end
print(nextCount) -- 输出:3,pairs 背后就是反复调用 next
print("**********知识点五 遍历时别新增 key************")
local source = {
a = 1,
b = 2
}
local pending = {}
for k, v in pairs(source) do
-- 不在遍历 source 的时候直接新增 key
-- 先把要新增的数据收集起来
table.insert(pending, {
key = "new_" .. k,
value = v
})
end
for _, item in ipairs(pending) do
source[item.key] = item.value
end
print(source.new_a) -- 输出:1
print(source.new_b) -- 输出:2
print(#pending) -- 输出:2,先收集再统一修改
local valueMap = {
a = 1,
b = 2
}
for k, v in pairs(valueMap) do
valueMap[k] = v + 10 -- 修改已有 value,一般可以
end
print(valueMap.a) -- 输出:11
print(valueMap.b) -- 输出:12
local removeMap = {
a = 1,
b = 2,
c = 3
}
for k, v in pairs(removeMap) do
if v % 2 == 1 then
removeMap[k] = nil -- 清掉已有 key
end
end
print(removeMap.a) -- 输出:nil
print(removeMap.b) -- 输出:2
print(removeMap.c) -- 输出:nil
local removeList = { "A", "B", "C", "D" }
for i, v in ipairs(removeList) do
if v == "B" then
table.remove(removeList, i) -- 删除 B,C 会补到 2 号位
end
print(i, v)
-- 输出:1 A / 2 B / 3 D
-- C 被挪到 2 号位,但下一轮 ipairs 已经走到 3 号位,所以 C 被跳过了
end
print(removeList[1], removeList[2], removeList[3], removeList[4])
-- 输出:A C D nil
local reverseRemoveList = { "A", "B", "C", "D" }
for i = #reverseRemoveList, 1, -1 do
if reverseRemoveList[i] == "B" or reverseRemoveList[i] == "C" then
table.remove(reverseRemoveList, i) -- 倒序删除,不影响还没遍历到的前面元素
end
end
print(reverseRemoveList[1], reverseRemoveList[2], reverseRemoveList[3])
-- 输出:A D nil
print("**********知识点六 自定义迭代器************")
local function ReverseIpairs(list)
local function Iterator(state, index)
-- 每次进来,index 都是上一次返回出去的下标
-- 这里先 -1,就可以从后往前走
index = index - 1
if index >= 1 then
-- 第一个返回值会成为下一轮的控制变量
-- 后面的返回值会交给 for 循环里的变量
return index, state[index]
end
-- 不返回值,相当于返回 nil
-- 控制变量变成 nil,循环结束
end
-- Iterator:迭代函数
-- list:状态,也就是要遍历的表
-- #list + 1:初始控制变量,第一次进 Iterator 后会先减到 #list
return Iterator, list, #list + 1
end
local names = { "A", "B", "C" }
for i, v in ReverseIpairs(names) do
print(i, v)
-- 依次输出:3 C / 2 B / 1 A
end
local iterator, state, index = ReverseIpairs(names)
while true do
-- 每一轮都把 state 和上一次的 index 传回迭代函数
-- 迭代函数会返回新的下标和值
local i, v = iterator(state, index)
-- 迭代函数返回 nil,表示没有下一项了,循环结束
if i == nil then
break
end
-- i 会作为下一轮的控制变量
-- 这里必须更新,否则每次都会从同一个位置继续算
index = i
print(i, v)
-- 依次输出:3 C / 2 B / 1 A
end
print("**********知识点七 总结************")
print("# 只适合连续序列")
print("有洞别背固定输出,需要数量就自己维护 count")
print("ipairs 遇 nil 停")
print("pairs 走 next,但遍历顺序不能信")
print("遍历时别新增 key,稳一点就先收集后处理")
print("数组遍历时直接 remove 当前下标,容易跳过元素")
print("自定义迭代器知道函数、状态、控制变量这三件套")
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com