5.表底层、长度与遍历顺序

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]nillist[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

上面三组数据都会被扫到。
但是 abc 谁先输出,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_anew_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,迭代函数不再返回有效下标,循环就结束。
一般不会频繁手写这种迭代器。
大多数时候 ipairspairs、普通 for 已经够用。

如果容器确实有特殊遍历规则,可以封装一些明确的方法。
但是不要乱写迭代器。
迭代器适合封装容器规则,但不适合把简单逻辑写复杂。

总结

# 只适合连续序列。
有洞别背固定输出,需要数量就自己维护 count
ipairs 遇到第一个 nil 就停。
pairsnext,能扫 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

×

喜欢就点赞,疼爱就打赏