表(Table)是Lua语言中最主要(事实上唯一)和强大的数据结构。使用表,Lua语言可以表示数组、集合、记录很很多其他数据结构。也可以使用表来表示包(package)和其他对象。当调用函数math.sin时,对于Lua来说,其实际含义是“以字符串"sin"为键检索表math“。

Lua语言中的表本质上是一种辅助数组(associative array),这种数组不仅可以用数值作为索引,也可以使用字符串或其他任意类型的值作为索引(nil除外)。

Lua语言中的表要么是值要么是变量,他们都是对象(object)。可以认为,表是一种动态分配的对象,程序只能操作指向表的引用(或指针)。除此以外,Lua不会进行隐藏的拷贝(深拷贝)或创建新的表。

我们使用构造器表达式(constructor expression)创建表,其最简单的形式是{}:

a = {}				-- 创建一个表然后用表的引用赋值
k = "x"
a[k] = 10			-- 新元素,键是"x",值是10
a[20] = "great"			-- 新元素,键是20,值是"great"
a["x"]				--> 10
k = 20
a[k]				--> "great"
a["x"] = a["x"] + 1		-- 增加元素"x"的值
a["x"]				--> 11

表永远是匿名的,表本身和保存表的变量之间没有固定的关系:

a = {}
a["x"] = 10
b = a		-- 'b'和'a'引用同一张表                                     
b["x"]		--> 10
b["x"] = 20
a["x"]		--> 20
a = nil		--> 只有b仍然指向表
b = nil		--> 没有指向表的引用了

对于一个表而言,如果程序中不再有指向它的引用时,垃圾收集器会最终删除这个表并重用其内存。

5.1 表索引

同一个表中存储的值可以有不同的类型索引,并可以按需增长以容纳新的元素:

a = {}		-- 空的表
-- 创建1000个新元素
for i = 1, 1000 do a[i] = i * 2 end
a[9]		--> 18
a["x"] = 10
a["x"]		--> 10
a["y"]		--> nil

注意上述代码最后一行:如同全局变量一样,未经初始化的表元素为nil,将nil赋值给表元素可以将其删除。这并非巧合,因为Lua语言实际上就是用表来存储全局变量的。

当把表当作结构体使用时,可以把索引当作成员名称使用(a.name等价于a[“name”])。因此,可以使用这种方式改写前述示例的最后几行:

a = {}				-- 空白表
a.x = 10			-- 等价于a["x"] = 10
a.x		--> 10		-- 等价于a["x"]
a.y		--> nil		-- 等价于a["y"]

对Lua语言来说,这两种形式是等价且可以混用的,不过对于阅读来说,他们可能代表了不同的意图。比如a.name表示这个表是被当作结构体使用的,此时表实际上是由固定的、预先定义的键组成的集合;而a[“name”]说明表可以使用任意字符串作为键,并且出于某种原因我们操作的是指定的键。

a.x和a[x]是不同的。实际上,a[x]是指由变量x对应的值索引的表,例如:

a = {}
x = "y"
a[x] = 10			-- 把10放在字段"y"中
a[x]		--> 10		-- 字段"y"的值
a.x		--> nil		-- 字段"x"的值
a.y		--> 10		-- 字段"y"的值

由于可以使用任意类型索引表。当不能确定表索引的真实数据类型时,可以使用显示的类型转换:

i = 10; j = "10"; k = "+10"
a = {}
a[i] = "number key"
a[j] = "string key"
a[k] = "another string key"
a[tonumber(j)]		--> number key
a[tonumber(k)]		--> number key

若不注意这一点,就很容易引起bug。整型和浮点型的表索引不存在上述问题。例如2和2.0的值相等,当它们被用作表索引使用时指向同一个表元素。

更准确地说,任何能够被转换为整型的浮点数都会被u转换成整型值。而不能被转换成整型的浮点数则不会发生上述的类型转换。

5.2 表构造器

表构造器是用来创建和初始化的表达式,是Lua中独特的也是最有用、最灵活的机制之一。

简单的构造器是空构造器{}。表构造器也可以用来初始化列表(构造器第一个元素的索引是1不是0):

days = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
print(day[4])		--> Wednesday

Lua还提供了一种初始化记录式(record-like)表的特殊语法:

a = {x = 10, y = 20}

上述代码等价于:

a = {}; a.x = 10; a.y = 20

不过,在第一种写法中,由于能够提前判断表的大小,所以运行速度更快。

无论使用哪种方式创建表,都可以随时增加或删除表元素:

w = {x = 0, y = 0, label = "console"}
x = {math.sin(0), math.sin(1), math.sin(2)}
w[1] = "another field"		-- 把键1增加到表'w'中
x.f = w						-- 把键"f"增加到表'x'中
print(w["x"])				--> 0
print(w[1])					--> another field
print(x.f[1])				--> another field
w.x = nil					-- 删除字段"x"

在同一个构造器中,可以混用记录式(record-style)和列表式(list-style)写法:

polyline = {color = "blue",
			thickness = 2,
    		npoints = 4,
    		{x = 0, y = 0},		-- polyline[1]
    		{x = -10, y = 0},	-- polyline[2]
    		{x = -10, y = 1},	-- polyline[3]
        	{x = 0, y = 1},		-- polyline[4]
		   }

上述的示例也同时展示了如何嵌套表(和构造器)以表达更复杂的数据结构。每一个元素polyline[i]都是代表一条记录的表:

print(polyline[2].x)		--> -10
print(polyline[4].y)		--> 1

不过,这两种表都有局限。索引必须以1作为开始初始化表元素,也不能使用不符合规范的标识符作为索引。对于这类需求,可以使用另一种更加通用的构造器,即通过方括号括起来的表达式显示地指定每一个索引:

opnames = {["+"] = "add", ["-"] = "sub",
		   ["*"] = "mul", ["/"] = "div"}
		   
i = 20; s = "-"
a = {[i + 0] = s, [i + 1] = s..s, [i + 2] = s..s..s}
print(opnames[s])		--> sub
print(a[22])			--> ---

这里说的是,可以利用方括号来避免另外两种不使用方括号带来的限制:

i = 20; s = "-"
polyline = {[22] = {["+"] = "add", ["-"] = "sub",
		   ["*"] = "mul", ["/"] = "div"},
		   {[i + 0] = s, [i + 1] = s..s, [i + 2] = s..s..s}
		   }
print(polyline[22]["+"])		--> add
print(polyline[1][22])			--> ---

这种构造器虽然冗长,但却非常灵活,另外两种都是其特殊形式。例如,下列几种表达式等价:

{x = 0, y = 0} <–> {[“x”] = 0, [“y”] = 0}

{“r”, “g”, “b”} <–> {[1] = “r”, [2] = “g”, [3] = “b”}

在最后一个元素后可以紧跟一个逗号,也可以不加,是可选的。且逗号也可以用分号代替,这主要是为了兼容Lua语言的旧版本。

5.3 数组、列表和序列

若想表示数组或列表,只需要用整型作为索引的表即可。同时,也不需要预先声明表的大小,只需要直接初始化需要的元素即可:

-- 读取10行,然后保存在一个表内
a = {}
for i = 1, 10 do
	a[i] = io.read()
end

由于能够使用任意值对表进行索引,我们也可以使用任意数字作为第一个元素的索引。不过,在Lua中,数组索引惯例是从1开始,并且Lua中其他很多机制也遵循这个惯例。

当操作列表时,往往必须事先获取列表的长度。该长度可以存放在常量中,也可以存放在其他变量或数据结构中。通常,我们把列表的长度放在表中某个非数值类型的字段中(由于历史原因,该键通常是"n")。当然,列表的长度经常也是隐式的。另外,由于未初始化的元素均为nil,所以可以利用nil值来标记列表的结束。这种技巧只有在列表不存在空洞时(即所有元素均不为nil)才有效,此时我们把这种所有元素都不为nil的数组称为序列(sequence)(由指定的n个连续正数数值类型的键所形成的表)。

Lua语言提供了获取序列长度的操作符#。正如我们之前所看到的,对于字符串而言,该操作符返回字符串的字节数,对于表而言,该操作符返回表对应序列的长度。例如,可以使用如下的代码输出上述中读入的内容:

-- 输出行,从1到#a
for i = 1, #a do
	print(a[i])
end

长度操作符也为操作序列提供了几种有用的写法:

print(a[#a])		-- 输出序列'a'的最后一个值
a[#a] = nil			-- 移除最后一个值
a[#a + 1] = v		-- 把'v'加到序列的最后

对于中间存在空洞(nil值)的列表而言,序列长度操作符是不可靠的。因此,在存在各种争议的情况下,在确实需要处理存在空洞的列表时,应该显式地将列表的长度保存起来。

5.4 遍历表

我们可以使用pairs迭代器遍历表中的键值对:

t = {10, print, x = 12, k = "hi"}
for k, v in pairs(t) do
	print(k, v)
end
	--> 1	10
	--> 2	function: 0000000065b9cff0
	--> x	12
	--> k	hi

受限于表在Lua语言中的底层实现机制,遍历过程中元素的出现顺序可能是随机的,相同的程序在每次运行时也可能产生不同的顺序。唯一确定的是每个元素在遍历过程中会且只会出现一次。(经过我在5.4的实验,默认键值对的10和print是按照表中顺序固定在前面的)

对于列表而言,可以使用ipairs迭代器:

t = {10, print, 12, "hi"}
for k, v in ipairs(t) do
	print(k, v)
end
	--> 1	10
	--> 2	function: 0000000065b9cff0
	--> 3	12
	--> 4	hi

此时,Lua会确保遍历是按照顺序进行的。

另一种遍历序列的方法是使用数值型for循环:

t = {10, print, 12, "hi"}
for i = 1, #t do
	print(i, t[i])
end
	--> 1	10
	--> 2	function: 0000000065b9cff0
	--> 3	12
	--> 4	hi

5.5 安全访问

考虑到如下情景:我们想确认在指定的库中是否存在某一个函数。如果我们确定这个库确实存在,那么可以直接使用if lib.foo then …;否则,就得使用形如if lib and lib.foo then …的表达式。

当表的嵌套变得比较深时,这种写法就很容易出错,例如:

zip = company and company.director and
		company.directory.address and
		company.directory.address.zipcode

这种写法不仅冗长而且低效,该写法在一次成功的访问者对表进行了6此访问而非3次访问。

对于这种情景,在C#中我们可以使用一种安全访问操作符”?.“。例如,对于表达式a?.b,当a为nil时,其结果是nil而不会产生异常。使用这种操作符,可以将上例改写为:

zip = company?.director?.address?.zipcode

如果上述的成员访问过程中出现nil,安全访问操作符也会正确处理nil并最终返回nil。

但是,Lua没有提供这种操作符,不过我们可以使用其他语句来模拟安全访问操作符。

对于表达式a or {},当a为nil时其结果是一个空表。因此,对于表达式(a or {}).b,当a为nil时其结果也是nil。这样,在Lua中我们可以将之前的例子改写为:

zip = (((company or {}).directory or {}).address or {}).zipcode

再进一步,我们还可以写的更短和更高效:

E =  {}		-- 可以在其他类似表达式中复用
...
zip = (((company or E).directory or E).address or E).zipcode

虽然不如安全访问操作符简单,但是表中的每一个字段名都只被使用了一次,保证了尽可能少地对表进行访问。

5.6 表标准库

函数table.insert向序列的指定位置插入一个元素,其他元素依次后移。例如,对于列表t = {10, 20, 30},在调用table.insert(t, 1, 15)后它会变成{15, 10, 20, 30},另一种特殊但常见地情况是调用insert但不指定位置,此时该函数会在序列的最后插入指定地元素,而不会移动任何元素。

函数table.remove删除并返回序列指定位置的元素,然后将其后的元素向前移动填充删除元素后造成的空洞。如果在调用该函数时不指定位置,该函数会删除序列的最后一个元素。

借助这两个函数,可以很容易地实现栈、队列和双端队列。

Lua5.3对于移动表中的元素引入了一个更通用的函数table. move(a, f, e, t),调用该函数可以将表a从索引f到e的元素(闭区间)移动到位置t上。例如,如下代码可以在列表a的开头插入一个元素:

table.move(a, 1, #a, 2)
a[1] = newElement

如下的代码可以删除第一个元素:

table.move(a, 2, #a, 1)
a[#a] = nil

在计算机领域,移动实际上是将一个值从一个地方拷贝的另一个地方,所以在上例中,我们要显示地将最后一个元素删除。

函数table.move还支持使用一个表作为可选的参数。当带有可选的表作为参数时,该函数将第一个表中的元素移动到第二个表中。例如,table.move(a, 1, #a, 1, {})返回列表a的一个克隆,table.move(a, 1, #a, #b + 1, b)将列表a中的所有元素复制到列表b的末尾。

5.7 练习

在后续写练习的时候,发现#操作符在lua5.4可能发生了变化?

local tab1 = {}
tab1[1] = 1
tab1[2] = 1
tab1[4] = 1
tab1[8] = 1

local tab2 ={
[1]=1,
[2]=1,
[4]=1,
[8]=1
}

print(#tab1)--out4
print(#tab2)--out8
	--> 4
	--> 8

看到这个例子让我更加疑惑,后来在What’s new in Lua 5.4下,知道了原因。

Lua 5.4以前,#操作符的效率不是很高,因此在5.4中提升了#操作符的速度。首先,我们要明确#table的定义,#返回table的边界,边界可能是:

  • 一个表索引:这个索引的元素不为nil,且下一个元素为nil
  • 0: 第1个元素为nil的情况

Lua5.3为了寻找数组部分的边界,用二分查找的方式,花费的时间是O(log n)。Lua5.4也会用到二分查找,但它为一些常见的操作进行了优化,Table结构引入alimit这个字段用于猜测数组部分的边界,取长度时只要判断t[alimit+1]为nil就表示alimit为表的边界,如此一来就不必进行二分查找了,所以我们在做向序列增加元素这种操作时,效率会得到大大的提高。

(虽然这里可能并不是5.4更新带来的,但是让我更了解到#的机制)