Lua 中最重要的数据结构:表(Table)

发布时间 2023-06-28 12:21:15作者: 古明地盆

楔子

本次来介绍一下 Lua 中的表(Table),表是 Lua 语言中最主要(事实上也是唯一)的数据结构,表既可以当做数组来用,也可以当成哈希表来用。这个和 Python 中的字典非常类似,比如我们之前用查看变量类型的 math.type,本质上就是以字符串 "type" 来检索表 math。而在 Python 中,比如调用 math.sin,本质也是从 math 模块的属性字典里面查找 key 为 "sin" 对应的 value。

# python代码
import math
print(math.sin(math.pi / 2))  # 1.0
print(math.__dict__["sin"](math.pi / 2))  # 1.0

两者在设计上是比较相似的,下面来看看如何创建表。

表的创建

和 Python 字典一样,直接使用大括号创建即可。

t = {}
-- 返回的是表的一个引用
print(t)  -- table: 00000000010b9160
-- 类型为 table
print(type(t) == "table")  -- true

在这里我们需要补充一下 Lua 变量的知识,实现 Lua 变量分为全局变量和局部变量,这两者我们会在函数中细说。总之,我们目前创建的都是全局变量,其有一个特点:

-- 对于没有创建的变量,可以直接打印,结果是一个 nil
print(a)  -- nil

-- c 这个变量没有创建,因此是 nil,那么 d 也是 nil
d = c
print(d)  -- nil

-- 所以我们看到程序中,明明没有这个变量,但是却可以使用,只不过结果为 nil
-- 那么如果我们将一个已经存在的变量赋值为 nil,是不是等于没有创建这个变量呢?
-- 答案是正确的,如果将一个变量赋值为 nil,那么代表这个变量对应的内存就会被回收
name = "shiina mashiro"
name = nil  -- "shiina mashiro" 这个字符串会被回收

之所以介绍全局变量这个特性,是因为在表中,nil 是一个大坑,我们往下看。

表的相关操作

表创建完之后,就可以往里面添加元素了。

tbl = {}

tbl["name"] = "古明地觉"
tbl["age"] = 16

-- 打印 tbl 只是返回一个引用
print(tbl)  -- table: 00000000000290e0
print(tbl["name"], tbl["age"])  -- 古明地觉	16

-- 更改表的元素
-- table 类似于哈希表,key 是不重复的,所以重复赋值相当于更新
tbl["age"] = tbl["age"] + 1
print(tbl["age"])  -- 17

-- 还记得我们之前说,全局变量也是通过 table 存储的吗
-- 我们可以给一个变量不断地赋值,赋上不同类型的值
-- 就是因为 table 对 value 没有限制
-- 可以赋任意类型的 value,相当于发生了更新操作
tbl["age"] = 18
print(tbl["age"])  -- 18
tbl["age"] = "十六"
print(tbl["age"])  -- 十六


-- 创建 table 返回的是一个引用
tbl2 = tbl
-- 此时的 tbl2 和 tbl 指向的是同一个 table,修改 tbl2 会影响 tbl
tbl2["name"] = "satori"
print(tbl["name"])  -- satori

-- 我们说赋值给 nil,等价于回收对象
a = nil 
-- 但是只将 tbl 赋值为 nil,显然还不够,因为还有 tbl2 在指向上面的 table
b = nil 
-- 这样的话,table 就被回收了

Lua 的 table 既可以做哈希表,也可以当做数组,一会儿通过源码查看具体的实现。下面来看看 table 如何当成数组来使用:

tbl = {}

for i = 1, 10 do
    tbl[i] = i * 2
end

print(tbl[3])  -- 6

table 在 C 里面是一个结构体,同时实现了哈希表和数组两种结构。如果 key 是整型,那么会通过数组的方式来存储,如果不是,会使用哈希表来存储。注意:如果当成数组使用,那么索引也是从 1 开始的。

tbl = {}
-- 此时是通过哈希表存储的
tbl["x"] = 233
print(tbl["x"])  -- 233

-- 除了 tbl["x"] 这种方式,还可以使用 tbl.x,这两者是等价的
print(tbl.x)  -- 233

-- tbl["name"] 和 tbl.name 是等价的
tbl["name"] = "椎名真白"
print(tbl["name"], tbl.name)  -- 椎名真白 椎名真白
-- 但和 tbl[name] 不是等价的,因为 name 是一个变量,而 name = "x",所以结果是 tbl["x"]或者 tbl.x
name = "x"
print(tbl[name])  -- 233

然后我们知道 2 和 2.0 是相等的,那么在 table 中是怎么表现的呢?

a = {}

a[2] = 123
print(a[2.0])  -- 123

a[2.0] = 456
print(a[2])  -- 456

所以这两者是等价的,因为 2.0 会被隐式转化为 2。但是对于字符串则不一样:

a = {}
a[2] = 123
a["2"] = 456
print(a[2], a["2"])  -- 123	456

如果访问表中一个不存在的 key,会返回 nil。

print(a["xxx"])  -- nil

-- 我们看到得到的是一个 nil
-- 显然我们想到了,如果将一个 key 对应的值显式地赋值为 nil,那么也等价于删除这个元素
a[2] = nil 

表构造器

估计有人目前对 table 即可以当数组又可以当哈希表会感到困惑,别着急后面会慢慢说。我们目前创建表的时候,都是创建了一张空表,其实在创建的时候是可以指定元素的。

a = {"a", "b", "c" }
print(a[1], a[2], a[3])  -- a	b	c
-- 我们没有指定 key,所以此时表里面的三个元素是通过数组存储的
-- 这种存储方式叫做"列表式(list-style)",索引默认是 1 2 3 4...

-- 此外,还可以这么创建
b = {name="mashiro", age=18 }
print(b["name"], b["age"])  -- mashiro	18
-- 第二种方式是通过哈希表存储的,这种存储方式叫做"记录式(record-style)"
-- 此时的 "name"、"age" 我个人习惯称之为 key,当然你也可以称之为索引

但如果我们存储的 key 是数字或者说特殊字符呢?答案是使用 [] 包起来。

b = {["+"]="add", [3] = "xxx"}  -- 必须使用 ["+"] 和 [3]
-- 同理获取也只能是 b["+"] 和 b[3],不可以是 b.+ 和 b.3
print(b["+"], b[3])  -- add xxx

-- 表也是可以嵌套的
a["table"] = b
print(a["table"]["+"])  -- add

此外,两种存储方式也可以混合使用。

mix = {'a', name='mashiro', 'b', age=18 }
print(mix[1], mix[2])  -- a	b
print(mix["name"], mix["age"])  -- mashiro	18

这里有必要详细说明一下,即使是混合使用,如果没有显式地指定 key(也就是列表式),那么会以数组的形式存储,索引默认是 1 2 3...。所以 mix[1] 是 'a', mix[2] 是 'b'。

当然还有一种情况:

mix = {'a', [2] = 1 }
print(mix[2])  -- 1
mix = {'a', 'b', [2] = 1 }
print(mix[2])  -- b

解释一下,首先对于单个标量来说,默认是用数组存储的,索引就是 1 2 3...。但我们在通过记录式设置的时候,对应的 key 使用的如果也是数组的索引,那么记录式中设置的值会被顶掉。

-- 数组的最大索引是 1,所以[2] = 1是没有问题的
mix = {'a', [2] = 1 }
print(mix[2])  -- 1

-- 数组最大索引是 2,所以 [2] = 1 会被顶掉,因为冲突了
mix = {'a', 'b', [2] = 1 }
print(mix[2])  -- b
-- 事实上 mix = {'a', 'b', [2] = 1 } 这种方式就等价于 mix = {[1] = 'a', [2] = 'b', [2] = 1 }
-- 如果 key 是整型,那么通过数组存储, 否则通过哈希表存储
-- 只不过我们手动指定 [2] = 1 会先创建,然后被 [2] = 'b' 顶掉罢了


-- 等价于 mix = { [1]='a', [1] = 1 }
mix = {'a', [1] = 1 }
print(mix[1])  -- 'a'

-- 等价于 mix = { [1] = 1, [1]='a' }
mix = {[1] = 1, 'a'}
print(mix[1])  -- 'a'

-- 不管顺序,mix[1] 都会是 'a',因为列表式设置的 key 会将记录式设置的 key 顶掉

估计有人还有疑问,假设有这样一个 table:

tbl = {1, [100] = 100}

如果这样创建的话,那么中间的元素是什么?因为表对于整数 key 是以数组存储的,数组是连续的存储空间,而我们只创建了两个元素,索引分别是 1 和 100,那么其它元素是以什么形式存在呢?带着这些疑问,我们先往下看。

数组、列表和序列

现在我们知道了如果想表示常见的数组、或者列表,那么直接把表当成数组或列表来使用即可。

array = {1, 2, 3, 4, 5}

而且在 Lua 的 table 中,可以使用任意数字作为索引,只不过默认是从 1 开始的,Lua 中很多其他机制也遵循此惯例。但是 table 的长度怎么算呢?我们知道对字符串可以使用 #,同理对 table 来说也是如此。

mix = {1, 2, 3, name = 'mashiro', 'a' }
print(#mix)  -- 4
-- 但是我们看到,结果为 4,可明明里面有 5 个元素啊
-- 因为 # 计算的是索引为整型的元素的个数,更准确的说 # 计算的是使用数组存储的元素的个数

mix = {[0] = 1, 2, 3, 4, [-1]=5}
print(#mix)  -- 3
-- 此时的结果是 3,因为 0 和 -1 虽然是整型,但它们并没有存储在数组里
-- 因为 Lua 索引默认是从 1 开始,如果想要被存储的数组里面,那么索引必须大于 0

mix = {1, 2, [3.0]="xxx", [4.1] = "aaa" }
print(#mix)  -- 3
-- 这里同样是 3,因为 3.0 会被隐式转化为 3,存储在数组里。但是 4.1 不会,因此数组有 3 个元素

所以我们看到,# 计算的是存储在数组里面的元素,也就是 table 中索引为正整数的元素,但真的是这样吗?

首先对于数组中存在空(nil)的 table,使用 # 获取长度是不可靠的,它只适用于数组中所有元素都不为 nil 的 table。事实上,将 # 应用于 table 获取长度一直饱受争议,以前很多人建议如果数组中存在 nil,那么使用 # 操作符直接抛出异常,或者说扩展一下 # 的语义。然而这些建议都是说起来容易做起来难,主要是在 Lua 中数组也是通过 table 实现的,而 table 的长度不是很好理解。

我们举例说明:

a = {1, 2, 3, 4 }
a[2] = nil
print(#a)  -- 4

-- 上面我们很容易得出这是一个长度为 4,第二个元素为 nil 的 table
-- 但是下面这个例子呢? 没错,就是我们之前说的
b = {}
b[1] = 1
b[100] = 100
-- 是否应该认为这是一个具有 100 个元素,但其中有 98 个元素为 nil 的 table 呢?
print(#b)  -- 1
-- Lua 作者的想法是,像 C 语言使用 \0 作为字符串的结束一样
-- Lua 中也可以使用 nil 来隐式地表示 table 的结束,所以此时的结果是 1

但问题是 a 的第二个元素也是 nil 啊,为什么长度是 4 呢?因为在 table 中出现了 nil,那么 # 的结果是不可控的,有可能你多加一个 nil,结果就变了。我们不需要探究它的意义,总之在 table 中写 nil 是原罪。不管是列表式、还是记录式,都不要写 nil,因为设置为 nil,就表示删除这个元素。

b = {1, [100]=100}
-- 我们说它的长度为 1
print(#b)  -- 1
-- 但是数组中确实存在索引为 100 的元素
print(b[100])  -- 100
print(b[90])  -- nil

所以对 b 这个 table,其中数组到底是怎么存储的,其实没必要纠结。就当成索引为 2 到索引为 99 的元素全部是 nil 即可,但是计算长度的时候是不准的,总之table中最好不要出现 nil。

总结一下:

-- {'a', 'b', 'c'} 等价于 { [1]='a', [2]='b', [3]='c' }
t1 = {'a', 'b', 'c'}
t2 = { [1]='a', [2]='b', [3]='c' }
print(t1[1], t1[2], t1[3])  -- a b c
print(t2[1], t2[2], t2[3])  -- a b c

-- {'a', ['k1']='b', 'c'} 等价于 { [1]='a', ['k1']='b', [2]='c' }
t1 = {'a', ['k1']='b', 'c'}
t2 = { [1]='a', ['k1']='b', [2]='c' }
print(t1[1], t1[2], t1['k1'])  -- a c b
print(t2[1], t2[2], t2['k1'])  -- a c b

-- {'a', [2]='b', 'c'} 等价于 { [1]='a', [2]='b', [2]='c' }
-- [2]='b' 会被替换掉
t1 = {'a', [2]='b', 'c'}
t2 = { [1]='a', [2]='b', [2]='c' }
print(t1[1], t1[2])  -- a c
print(t2[1], t2[2])  -- a c

遍历表

遍历表可以使用 for 循环:

-- 等价于 { [1]="a", [2]="b", name="mashiro", [3]="c", age=18, [4]="d", [100]='e' }
tbl = {"a", "b", name="mashiro", "c", age=18, "d", [100]='e' }

-- for 循环除了 for i = start, end, step 这种方式之外,还可以作用在表上面
-- 只不过需要使用 pairs 将 table 包起来
for index, value in pairs(tbl) do
    print(index, value)
    --[[
    1	a
    2	b
    3	c
    4	d
    100	e
    age	18
    name	mashiro
    ]]
end

print(#tbl)  -- 4

这里的 for 循环中出现了两个循环变量,分别表示索引和值,如果只有一个变量,那么得到的是索引,或者哈希表的 key。然后我们看到 name 和 age 的输出顺序不对啊,是的,因为是通过哈希表存储的,所以不保证顺序。

但对于数组来说,则是按照索引从小到大的方式存储、并输出的。最后整个表的长度为 4,很明显应该是 5 才对,因为 # 计算表中数组的长度不准确。那么问题来了,如果我想精确统计结果,要怎么做呢?

tbl = {"a", "b", name="mashiro", "c", age=18, "d", [100]='e' }

count = 0
for key in pairs(tbl) do
    if math.type(key) == "integer" then
        count = count + 1
    end
end
print(#tbl)  -- 4
print(count) -- 5

然后除了 pairs,还有 ipairs。因为 table 会同时使用数组和哈希表两种结构,而 ipairs 只会遍历存在于数组里面的元素。

tbl = {[4] = "a", [3] = "b", name="mashiro", [1] = "c", age=18, [2] = "d" }
for index, value in ipairs(tbl) do
    print(index, value)
    --[[
    1	c
    2	d
    3	b
    4	a
    ]]
end

打印按照索引从小到大打印,但是不建议这么创建 table,要么不指定索引,要么按照顺序指定。

如果 table 中出现了 nil,那么我们使用 for 循环去遍历会发生什么奇特的现象呢?

-- 不过在此之前,还是先来回顾一个坑
tbl = {[3] = 1, 'a', 'b', 'c' }
-- 这个时候 tbl[3] 是多少呢?
print(tbl[3])  -- c

-- 我们说只要是列表式,都是从 1 开始,所以 [3] = 1 最终会被 [3] = 'c' 所顶掉


-- 下面来看看 table 中出现了 nil,for 循环会如何表现
tbl = {'a', nil, 'b', 'c' }
print(#tbl)  -- 4

for index, value in ipairs(tbl) do
    print(index, value)
    --[[
    1   a
    ]]
end
-- 长度虽然是 4(当然我们知道这不准),但在遍历的时候一旦遇到 nil 就会终止遍历
-- 当然这个 nil 要是数组中的 nil,不是哈希表中的 nil

-- 如果继续使用 ipairs 遍历,那么只能遍历出 'a' 这个元素,因为出现 nil 就遍历中止了
-- 但如果是 pairs,那么会遍历值不为 nil 的所有记录
tbl = {'a', nil, 'b', 'c', name=nil, age=18}
for index, value in pairs(tbl) do
    print(index, value)
    --[[
    1	a
    3	b
    4	c
    age	18
    ]]
end 
-- 但我们看到值 "b" 对应的索引是 3,尽管前面的是 nil,但是毕竟占了一个坑
-- 所以 "b" 对应的索引是 3

总之当表中出现 nil,会存在一些坑,注意一下即可。

表标准库

表的标准库提供一些函数,用于对表进行操作,而这个标准库也叫 table。

tbl = {10, 20, 30 }

print(tbl[1], tbl[1], tbl[2])

-- 使用 table.insert 可以插入一个值
-- 接收参数为:table 插入位置 插入的值
table.insert(tbl, 2, "xxx")
print(tbl[1], tbl[2], tbl[3], tbl[4])  -- 10 xxx 20 30
-- 如果不指定位置,那么默认会添加在结尾
-- 此时传递两个参数即可:table 插入的值
table.insert(tbl, "古明地觉")
print(tbl[#tbl])  -- 古明地觉

-- 既然有 insert,那么就会有 remove 
-- 接收参数:table 移除的元素的位置(索引)
print(tbl[1], tbl[2], tbl[3], tbl[4], tbl[5])  -- 10 xxx 20 30 古明地觉
table.remove(tbl, 3)
print(tbl[1], tbl[2], tbl[3], tbl[4], tbl[5])  -- 10 xxx 30 古明地觉 nil

我们看到使用 remove 之后,后面的元素会依次向前移动,因此无需担心会出现 nil 什么的。不过这也说明了,remove 的效率不是很高,因为涉及到元素的移动。但是 table 中的函数都是 C 实现的,也是很快的,因此也不用太担心。

注意:因为表使用了两种数据结构,分别是数组和哈希表,但 insert 和 remove 函数只能操作表里面的数组,不可以操作哈希表。

-- 在 Lua5.3 中,还提供了一个 move 函数
-- table.move(table, start, end, target),表示将 table 中 [start, end] 之间的元素移动到索引为 target 的位置上
-- 也是 start 位置的元素跑到 target 上面,start + 1 -> target + 1、 end -> target + end - start
tbl = {1, 2, 3, 4}
table.move(tbl, 2, #tbl, 3)
print(tbl[1], tbl[2], tbl[3], tbl[4], tbl[5])  -- 1	2 2	3 4
-- 很好理解,将 {1 2 3 4} 中索引为 [2, #t] 的元素移动到索引为 3 的位置上,因此结果是 1 2 2 3 4

-- 这里的 move 实际上是将一个值从一个地方拷贝 copy 到另一个地方
-- 另外,我们除了可以将元素移动到 table 本身之外,还可以移动到另一个 table
tbl1 = {"a", "b", "c", "d" }
tbl2 = {"x", "y" }
-- 表示将 tbl1 中 [2, #tbl1] 的元素移动到 tbl2 中索引为 2 的地方
table.move(tbl1, 2, #tbl1, 2, tbl2)
for idx = 1, #tbl2 do
    print(tbl2[idx])
    --[[
    x
    b
    c
    d
    ]]
end

-- table 标准库中还提供了 concat 函数,会将表里面的元素拼接起来
tbl = {1, 2, "xxx", 3, "aaa" }
print(table.concat(tbl))  -- 12xxx3aaa

再来看个思考题:

a = "b"
b = "a"

t = {a = "b", [a] = b }
print(t.a, t[a], t[t.b], t[t[b]])

上面的 print 会打印出什么呢?我们分析一下。首先看 t 这个表,其中 a = "b" 无需多说,关键是 [a] = b,我们说 a 和 b 都是变量,并且 a = "b"、b = "a",所以结果等价于 ["b"] = "a", 即:b = "a"。因此这里的 t 可以看做是 {a = "b", b = "a"}。

那么 t.a 显然是 "b",t[a] 等于 t["b"](t.b),因此结果是 "a"。而 t.b 结果是"a",那么 t[t.b] 等于是 t["a"](t.a),所以结果是 "b"。

t[t[b]] 稍微有点复杂,我们拆开看就简单了。t[b] -> t["a"] -> "b",那么t[t[b]] -> t["b"] -> "a",因此结果是"a"。所以最终 print 会打印出:b a b a。

t = {}
t.t = t
print(t)  -- table: 0000000000d98ef0
print(t.t)  -- table: 0000000000d98ef0
print(t.t.t)  -- table: 0000000000d98ef0

这也是比较有意思的地方,我们发现打印的都是一样的,因为 Lua 的 table 返回的一个引用,而 t.t = t,本身显然陷入了套娃的状态。