Tables

表,是 Lua 中主要的(事实上也是唯一的)数据结构机制,data structuring mechanism,也是一种功能强大的机制。我们用表,来表示数组,arrays、集合,sets、记录,records,以及其他许多简单、统一与高效的数据结构。Lua 也使用表,来表示包,packages 和对象,objects。当我们写下 math.sin 时,我们想到的是“math 库中的函数 sin”。但对于 Lua 来说,这个表达式意味着“使用字符串 "sin" 作为键,来对表 math 进行索引"。

Lua 中的表,本质上是一个关联数组。表是一个,不仅可以接受数字作为索引,还可以接受字符串,或任何其他语言值(nil 除外)作为索引的数组。

Lua 中的表,既不是值,也不是变量,而是 对象,objects。如果你熟悉 Java 或 Scheme 中的数组,那么你就会明白我的意思。咱们可以将表,视为动态分配的对象,dynamically-allocated object;程序只能操作表的引用(或指针)。Lua 从不在幕后,进行隐藏复制,hidden copies,或创建新表。

我们通过 构造表达式,constructor expression,来创建表,而最简单的构造表达式,就是 {}

> a = {}
> k = 'x'
> a[k] = 10
> a[20] = "great"
> a["x"]
10
> k = 20
> a[k]
great
> a["x"] = a["x"] + 1
> a["x"]
11

表始终是匿名的。保存表的变量,与表本身之间,并无固定关系:

> a = {}
> a["x"] = 10
> b = a
> b["x"]
10
> b["x"] = 20
> a["x"]
20
> a = nil
> a["x"]
stdin:1: attempt to index a nil value (global 'a')
stack traceback:
        stdin:1: in main chunk
        [C]: in ?
> b["x"]
20

当程序不再有着对某个表的引用时,垃圾回收器最终会删除该表,并重新使用其内存。

表的索引

每个表都可以存储具有不同类型索引的值,并按需要增长,以容纳新条目:

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

请注意最后那行:与全局变量一样,表字段,table fields,在未初始化时,其值为 nil。与全局变量一样,我们也可以将表字段赋值为 nil,来删除他。这并非巧合: Lua 将全局变量,存储在普通表中。(我们将在第 22 章 环境 中,进一步讨论这个问题。)

为表示结构(体),我们会使用字段名作为索引。Lua 通过提供 a.name 作为 a["name"] 的语法糖,来支持这种表示法。因此,我们可以用更简洁的方式,编写上一示例的最后几行,如下所示:

> a = {}
> a.x = 10
> a.x
10
> a.y
nil

对于 Lua 来说,这两种形式是等价的,可以自由混合。然而,对于人类读者来说,每种形式都可能表示不同的意图。点表示法清楚地表明,我们将表用作一种结构(体),其中有一组固定、预定义的键。而字符串表示法,给人的印象是,表可以有任何字符串作为键,而且出于某种原因,我们正在操作这个特定的键。

初学者常犯的错误,是混淆 a.xa[x]。第一种形式表示 a["x"],即以字符串 "x" 为索引的表。第二种形式,则是以变量 x 的值,为索引的表:

> a = {}
> x = 'y'
> a[x] = 10
> a[x]
10
> a.x
nil
> a.y
10

注意:在使用非单字符、不符合变量名命名规范的字符串,作为表的索引时,就无法通过点表示法,访问到对应的字段值。使用符合变量名命名规范的字符串做表索引,仍然可以通过点表示法,访问到对应字段值。

> a = {}
> k = "index-0"
> a[k] = 1000
> a["index-0"]
1000
> a."index-0"
stdin:1: <name> expected near '"index-0"'
> a.index-0
stdin:1: attempt to perform arithmetic on a nil value (field 'index')
stack traceback:
        stdin:1: in main chunk
        [C]: in ?
> j = "index_1"
> a[j] = 2000
> a["index_1"]
2000
> a.index_1
2000

由于我们可以用任何类型,为表编制索引,因此在为表编制索引时,我们会遇到与相等的情形下,同样的微妙问题。虽然我们可以用数字 0 和字符串 "0",为表编制索引,但这两个值是不同的,因此表示了表中的不同条目。同样,字符串 "+1""01""1",也表示了不同的条目。如果对索引的实际类型有疑问,就要使用显式转换来明确:

> i = 10; j = "10"; k = "+10"
> a = {}
> a[i] = "number key"
> a[j] = "string key"
> a[k] = "another string key"
> a[i]
number key
> a[j]
string key
> a[k]
another string key
> a[tonumber(j)]
number key
> a[tonumber(k)]
number key
> a.10
stdin:1: syntax error near '.10'
> a."10"
stdin:1: <name> expected near '"10"'

注意:表本身,也可以作为其他表的索引。

> a = {}
> b = {}
> b[a] = 1000
> a
table: 0x55c780623980
> b
table: 0x55c780623c50
> b[a]
1000

如果不注意这一点,就会在程序中引入微妙的错误。

整数和浮点数,不存在上述问题。与 2 等于 2.0 的相比一样,这两个值在用作键时,指的是同一个表项:

> a = {}
> a[2.0] = 10
> a[2.1] = 20
> a[2]
10
> a[2.0]
10
> a[2.1]
20
> a[2] = 30
> a[2.0]
30

更具体地说,在用作键时,任何可以转换为整数的浮点数值,都会被转换。例如,当 Lua 执行 a[2.0] = 10 时,他会将键 2.0 转换为 2。对无法转换为整数的浮点值,则会保持不变。

关于表构造器

构造器,是创建,及初始化表的表达式。构造器是 Lua 的一大特色,也是 Lua 最有用,和最通用的机制之一。

最简单的构造器,便是空构造器 {},我们已经看到。构造器也可以初始化表。例如,下面的语句,将用字符串 "Sunday",初始化 days[1](构造器的第一个元素的索引为 1,而不是 0),用 "Monday" 初始化 days[2],以此类推:

> days = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
> days[4]
Wednesday

Lua 还提供了一种特殊语法,来初始化类似记录的表,a record-like table,如下面的示例:

> a = {x = 10, y=20}
> a.x
10
> a['x']
10
> a['y']
20

前一行与这些命令等价:

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

然而,原本的表达式的速度,会更快,因为 Lua 已创建出了大小合适的表。

无论使用何种构造器,来创建表格,我们都可以在结果中,添加或删除字段:

> w = {x = 0, y = 0, label = "console"}
> x = {math.sin(0), math.sin(1), math.sin(2)}
> w[1] = "another field"
> x.f = w
> w["x"]
0
> w[1]
another field
> x.f[1]
another field
> w.x = nil             -- 移除字段 "x"
> w.x
nil

不过,正如我(作者)刚才提到的,使用适当的构造器,创建表除了更简洁外,效率也更高。

我们可以在同一个构造器中,混合使用记录式和列表式的初始化,record-style and list-style initializations:

> 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
table: 0x5638dc5ecef0

上面的示例,还说明了我们如何嵌套表(以及构造函数),来表示更复杂的数据结构。每个 polyline[i] 的元素,都是一个表,代表着一条记录:

> polyline[1].x
0
> polyline[4].y
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])
---

这种语法比较繁琐,more cumbersome,但也更灵活:正如我们在下面的等价关系中所展示的,列表样式和记录样式,都是这种更通用语法的特例:

{x = 0, y = 0}      <-->    {["x"] = 0, ["y"] = 0}
{"r", "g", "b"}     <-->    {[1] = "r", [2] = "g", [3] = "b"}

在最后一项后面,咱们始终可以加上逗号。这些最后的逗号是可选的,但总是有效:

> a = {[1] = "red", [2] = "green", [3] = "blue",}
> a[3]
blue

这种灵活性,使那些会生成 Lua 构造函数的程序,无需将最后一个元素作为特例处理(:生成 JSON 代码的程序,就需要将最后一个元素,作为特列处理)。

最后,我们可以在构造器中,使用分号而不是逗号。这个功能是旧版本 Lua 留下的,我想现在已经很少使用了。

数组、列表与序列

Arrays, Lists and Sequences

要表示传统的数组或列表,我们只需使用具有整数键的表。我们不需要声明大小,只需初始化所需的元素即可:

> a = {}; for i = 1, 10 do a[i] = io.read(); end
this
that
he
she
I
test
they
those
these
here
> a[10]
here

既然我们可以用任何值为表编制索引,那么我们也可以用任何我们喜欢的数字,开始数组的索引。不过,在 Lua 中,数组通常以 1 开头(而不是像 C 语言中那样以 0 开头),Lua 中的许多设施,都遵循了这一约定。

注意:建立从 0 开始索引的数组,也是可行的。

> a = {}; for i = 0, 2 do a[i] = io.read(); end
He
She
They
> a[0]
He

通常,在我们操作某个列表时,必须知道其长度。长度可以是一个常数,也可以存储在某个地方。我们常常会将列表的长度,存储在表的一个非数字字段中;由于历史原因,一些程序为此使用了 "n" 字段。不过,长度通常是隐含的。请记住,任何未初始化的索引,结果都是 nil;我们可以用这个值作为哨兵,as a sentinel,来标记列表的结束。例如,在咱们读入 10 行到某个列表后,很容易知道列表的长度是 10,因为其数字键是 12、......、10。只有当列表中没有 空洞,holes(即列表中的 nil 元素)时,这种方法才有效。我们称这种没有空洞的列表,为 序列,sequence

对于序列,Lua 提供了长度运算符 (#)。正如我们曾见过的,对于字符串,# 给出了字符串的字节数。对于表,他会给出表所代表序列的长度。例如,我们可以用以下代码,打印上一示例中,读取到的那些行:

> for i = 0, #a do print(a[i]); end

这个长度运算符,还为操作序列,提供了一种有用的习惯用法:

> a[#a + 1] = "Them"
> for i = 0, #a do print(a[i]); end
He
She
They
Them

对于有洞(nils)的列表,长度运算符是不可靠的。他只适用于,咱们定义为没有漏洞列表的序列。更确切地说,序列,a sequence 是一个表,其中正的数字键,由一个集合 {1,...,n} 组成。(记住,任何值为 nil 的键,实际上都不在表中。)特别的,没有数字键的表,即为长度为零的序列。

> b = {}
> b._a = "Test"
> b._1 = "test"
> #b
0

有空洞的列表下长度运算符的行为,是 Lua 最有争议的特性之一。多年来,人们提出了许多建议,要么在对有空洞列表使用长度运算符时,抛出错误,要么将其含义,扩展至这些列表。然而,这些建议说起来容易做起来难。问题在于,由于列表实际上是一个表,因此 “长度” 的概念有些模糊。例如,请看下面代码产生的列表:

> a = {}
> a[1] = 1
> a[2] = nil        -- 什么也没做,因为 a[2] 已是 nil,does nothing, as a[2] is already nil
> a[3] = 1
> a[4] = 1

我们可以很容易地说,这个列表的长度是 4,并且在索引 2 处有一个洞。然而,对于下个类似的例子,我们又能说什么呢?

a = {}
a[1] = 1
a[10000] = 1

我们是否应将 a,视为一个有 10000 个元素、9998 个空洞的列表?现在,程序执行了这个操作:

a[10000] = nil

现在列表长度是多少呢?应该是 9999 吗,因为程序删除了最后一个元素?或者还是 10000 呢,因为程序只把最后一个元素改成了 nil?或者长度应该缩减为 1

另一常见的提议,是让 # 运算符,返回表中元素的总数。这种语义清晰且定义明确,但不是很有用或直观,not very useful or intuitive。请考虑我们在这里,所讨论到的所有示例,并思考这样的运算符,对他们有多大用处。

然而更令人不安的,是列表末尾的那些 nils。下面列表的长度,应该是多少呢?

a = {10, 20, 30, nil, nil}

请记住,对于 Lua 来说,有着 nil 的字段,与不存在的字段,是不一样的。因此,前一个表,等于 {10, 20, 30};其长度是 3,而不是 5

> a = {10, 20, 30, nil, nil}
> #a
3
>
> a = {10, 20, 30, nil, 40}
> #a
5

你可能会认为,列表末尾的 nil,是一种非常特殊的情况。然而,许多列表,都是通过逐个添加元素的方式建立的。任何有空洞的列表,都是这样建立的,其末尾肯定也有一些 nil

尽管有这些讨论,但在程序中,我们用到的大多数列表,都是序列(例如,文件行就不能为 nil),因此,在大多数情况下,使用长度操作符是安全的。如果确实需要处理有空洞的列表,则应显式地,将长度存储在某个地方。

表的遍历

使用 pairs 这个迭代器,咱们就可以遍历表中的所有键值对:

> t = {10, print, x = 12, k = "Hi"}
> for k, v in pairs(t) do print(k, v) end
1       10
2       function: 00007ffdea28f640
x       12
k       Hi

由于 Lua 实现表的方式,遍历中元素出现的顺序,是未定义的。同一程序每次运行,都会产生不同的顺序。唯一可以确定的是,每个元素都会在遍历过程中,出现一次。

注意:在 Lua 5.3.3(运行于 Linux 系统),Lua 5.4.6(运行于 Windows/MSYS2)中,观察到元素在遍历中出现的顺序,并非是未定义的。而是会以在表定义时的先后顺序出现(如上面的示例中那样)。

对于列表,咱们可以使用 ipairs 这个迭代器:

> t = {10, print, 12, "Hi"}
> for k, v in ipairs(t) do print(k, v) end
1       10
2       function: 00007ffdea28f640
3       12
4       Hi

在这种情况下,Lua 可以轻松地确保顺序。

遍历序列的另一种方式,是使用一个数值的 for 循环:

> t = {10, print, 12, "Hi"}
> for k = 1, #t do print(k, t[k]) end
1       10
2       function: 00007ffdea28f640
3       12
4       Hi

安全的导览

Safe Navigation

假设情况如下:我们想知道某个函数库中,某个函数是否存在。如果我们确定库本身存在,那么我们可以写下类似 if lib.foo then ... 这样的代码。否则,我们就必须写下类似 if lib and lib.foo then ... 这样的代码。

在所嵌套的表层级,越来越深时,这种写法就会出现问题,下一个例子,就说明了这一点:

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

这种符号不仅繁琐,而且效率低下。在一次成功访问中,他要执行六次,而不是三次的表访问。

一些编程语言(如 C#),提供了 安全导览运算符,safe navigation operator(在 C# 中写成 ?.),来完成这项任务。当我们写下 a ?. b,且 anil 时,结果也是 nil,而不是报错。使用该运算符,我们可以这样,编写前面的示例:

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

如果路径中的任何组件为 nil,安全运算符就会传播该 nil,直到最终结果为止。

Lua 未提供安全导览操作符,我们认为他也不应提供。Lua 是极简主义的。此外,这个运算符也颇具争议,很多人认为,他会助长粗心的编程,这不无道理。不过,我们可以通过一些额外符号,a bit of extra notation,在 Lua 中模拟他。

anil 时,若咱们执行 a or {},结果就会是那个空表。因此,如果我们在 anil 时,执行 (a or {}).b,结果也将是 nil。利用这一思路,我们可以这样重写原来的表达式:

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

更好的是,我们还可以让这个表达式,更短一点,效率更高一点:

E = {}      -- 还可以在其他类似表达式中,重用这个全局变量
...
zip = (((company or E).director or E).address or E).zipcode

当然,相比使用安全导航操作符的语法,这种语法要更复杂。尽管如此,我们只需写下每个字段名称一次,这种语法只需执行最少的表访问次数(本例中为三次),而且不需要在语言中,添加新的操作符。我个人认为,这已经是一个很好的替代方案了。

关于表库

表库提供了几个,用于操作列表和序列的有用函数。注 1

注 1:咱们可将其视为 “序列库” 或 “列表库”;为了与旧版本兼容,这里保留了原来的名称。

函数 table.insert,会在序列的给定位置,插入一个元素,并向后(向上)移动其他元素到开放空间。例如,如果 t 是列表 {10, 20, 30},在调用 table.insert(t, 1, 15) 后,其将变成 {15, 10, 20, 30}。一个特殊且常见的情况是,如果我们调用不带位置的 insert,他会将元素,插入序列的最后位置,而不会移动任何元素。例如,下面的代码,会逐行读取输入流,将所有行存储在一个序列中:

> for line in io.lines() do table.insert(t, line) end
I
You
He
She
\eof
^Z
> print(#t)
5

注意:在 Lua 控制台中,咱们可以通过按下,适合咱们操作系统的键组合,来输入文件结束(EOF)字符。EOF 字符用于指示输入的结束,通常不是手动输入的。以下是在常见操作系统中,输入 EOF 的键组合:

  1. Windows: 要在 Windows 上的 Lua 控制台中输入 EOF,请按下 Ctrl + Z(Control 键和 Z 键),然后按下 Enter。这将表示输入结束,Lua 控制台将处理输入,直到遇到 EOF 字符。

  2. Unix/Linux(包括 macOS): 要在类 Unix 系统上的 Lua 控制台中,输入 EOF,请按下 Ctrl + D(Control 键和 D 键)。这将表示输入结束,Lua 控制台将处理输入,直到遇到 EOF 字符。

发送 EOF 字符后,Lua 将退出交互模式,控制台将返回常规命令提示符。请注意,具体 Lua 控制台的行为可能会略有不同,但这些键组合通常是在各种环境中指示 EOF 的标准方法。

(上述内容由 chat.openai.com,GPT-3.5 所提供。)

函数 table.remove,会从序列中给定的位置,移除并返回一个元素,同时向前(向下)移动后续元素,以填补空缺。如果调用时没有指定位置,则会移除序列最后一个元素。

有了这两个函数,就可以直接实现堆栈、队列和双队列,straightforward to implement stacks, queues and double queues。我们可以将此类结构,初始化为 t = {}push 操作等同于 table.insert(t,x)pop 操作等同于 table.remove(t)。调用 table.insert(t, 1, x),就可以插入结构的另一端(实际上就是结构的起点),而 table.remove(t, 1) 则可以从这端删除。后两个操作的效率并不高,因为他们必须前后(上下)移动元素。不过,由于 table 库是用 C 语言,实现的这些函数,这些循环的代价并不高,因此这种实现方式,对于小型数组(最多几百个元素)来说,已经足够好了。

Lua 5.3 引入了一个更为通用的函数,来移动表中的元素。调用 table.move(a, f, e, t),可以将表 a 中,从索引 fe(包括 ef)的元素,移动到位置 t。例如,要在列表 a 的开头,插入某个元素,我们可以执行以下操作:

> a = {10, 20, 30}
> newElement = 0
> table.move(a, 1, #a, 2)
table: 000001a91fc8ae00
> a[1] = newElement
> for k, v in pairs(a) do print(k, v) end
1       0
2       10
3       20
4       30

接下来的代码,会移除首个元素:

> table.move(a, 2, #a, 1)
table: 000001a9200d2240
> for k, v in pairs(a) do print(k, v) end
1       10
2       20
3       30
4       30
> a[#a] = nil
> for k, v in pairs(a) do print(k, v) end
1       10
2       20
3       30

请注意,正如计算中常见的那样,移动,move 实际上是,将值从一个地方 复制,copies 到另一个地方。在最后的示例中,我们必须显式地擦除,移动后的最后一个元素。

调用 table.move 时,咱们可以带上一个额外的可选参数,即一个表。在这种情况下,函数会将第一个表中的元素,移动到第二个表中。例如,调用 table.move(a, 1, #a, 1, {}),就会返回列表 a 的克隆(通过将其所有元素,复制到一个新列表中),而 table.move(a, 1, #a, #b + 1, b),则会将列表 a 中的所有元素,追加到列表 b 的末尾。

练习

练习 5.1:下面的脚本,将打印出什么?请解释一下。

> sunday = "monday"; monday = "sunday"; t = {sunday = "monday", [sunday] = monday}; print(t.sunday, t[sunday], t[t.sunday])
monday  sunday  sunday

练习 5.2:假设有以下代码:

a = {}; a.a = a

a.a.a.a 的值,会是什么?该序列中的任何一个 a,是否与其他 a 有某种不同?

> a = {}; a.a = a;
> a.a
table: 000001a9200d26c0
> a
table: 000001a9200d26c0
> a.a.a
table: 000001a9200d26c0
> a.a.a.a
table: 000001a9200d26c0

现在,将下面这行,添加到前面的代码中:

a.a.a.a = 3

现在 a.a.a.a 的值应是什么?

> a = {}; a.a = a
> a.a.a.a = 3
> a.a.a.a
stdin:1: attempt to index a number value (field 'a')
stack traceback:
        stdin:1: in main chunk
        [C]: in ?

练习 5.3:假设咱们打算创建一个表,将字符串(参见 “字面字符串” 部分)的每个转义序列,映射到其含义。应如何为该表,编写构造器?

练习 5.4:咱们可以用 Lua,将多项式 anxn + an-1xn-1 + ... + a1x1 + a0,表示为该多项式的系数列表,例如 {a0、a1、...、an}

请编写取多项式(用表表示),和 x 值,并返回多项式值的这样一个函数。

练习 5.5:你能写出上个练习中,最多使用 n 次加法和 n 次乘法(不使用指数),那样的函数吗?

练习 5.6:请编写一个测试给定表是否为有效序列的函数。

练习 5.7:编写一个将给定列表中的所有元素,插入另一个给定列表的给定位置的函数。

练习 5.8:表库 table 提供了一个函数 table.concat,他接收一个字符串的列表,并返回他们的连接:

print(table.concat({"Hello", " ", "World"}))    --> Hello World

请编写出咱们自己的该函数。

将咱们自己的实现,与那个针对大型列表(包含数十万条目)的内置版版本,做的性能比较。(咱们可以使用 for 循环,来创建出大型列表)。

Last change: 2024-07-23, commit: 1e691f0