0%

《lua 程序设计》读书笔记(3):表 & 函数

Lua 表是 Lua 中最重要的数据结构(也是唯一的数据结构),Lua 的全局变量、模块组织等功能都与 Lua 表相关,Lua 表很好地体现了 Lua 精简的设计思路。任何一门编程语言都会提供函数,以实现代码的模块化、抽象化,而 Lua 也不例外。这篇文章将学习 Lua 中的表和函数。

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

Lua 中表的本质上是一种辅助数组,这种数组不仅可以使用数值作为索引,也可以使用字符串或其他任意类型的值作为索引(nil 除外)。可以认为,表是一种动态分配的对象,程序只能操作指向表的引用(或指针)。除此之外,Lua 语言不会进行隐藏的拷贝或创建新的表。

使用构造器表达式创建表,最简单的形式为 {}。表永远是匿名的,表本身和保存表的变量之间没有固定的关系:

1
2
3
4
5
6
7
8
> a = {}
> a["x"] = 10
> b = a
> b["x"]
10
> b["x"] = 20
> a["x"]
20

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

表索引

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

1
2
3
4
5
6
7
8
9
> a = {}
> 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.name 的点分形式清晰地说明了表是被当成结构体使用的。此时表实际上是由固定的、预先定义的键组成的集合
  • a["name"] 的字符串索引形式则说明了表可以使用任意字符串作为键

但是需要注意 a.xa[x] 的区别:a.x 代表的是 a["x"],即以字符串 “x” 作为键索引表;而 a[x] 则是由变量 x 对应的值来索引表。

1
2
3
4
5
6
7
8
> a[10]=10
> a["x"]="x"

> x=10
> a.x
x
> a[x]
10

由于可以使用任意类型索引表,因此在索引表时会遇到相等性比较方面的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> 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[2] = 2
> a[2.0]
2
  • 数字和字符串是不同的类型,这意味着它们的索引值也是不同的
  • 整型和浮点型类型的表索引不存在上述问题。由于 22.0 值相等,所以当它们被当成表索引使用时指向同一个表元素

表构造器

表构造器是用来创建和初始化表的表达式。最简单的构造器是 空构造器 {}。表构造器也可以用来初始化列表:

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

Lua 语言也提供了一种初始化记录式(record-like)表的特殊语法,以下两种写法等价,但是由于第一种写法能够提前判断表的大小,所以运行速度更快。无论哪种方式创建表,都可以随时增加或删除表元素:

1
2
3
4
5
6
7
8
9
> a = {x=10, y=20}
> a["x"]
10

> b = {}
> b.x = 10
> b.y = 20
> b["x"]
10

在同一个构造器中,可以混用记录式和列表式写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> 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[2].x
-10
> polyline[3].y
1

还有一种更加通用的构造器语法,即通过方括号括起来的表达式显式地指定每一个索引。不管是记录式构造器还是列表式构造器均是其特殊形式:

1
2
3
4
5
6
7
8
> opnames = {["+"] = "add", ["-"] = "sub", ["*"] = "mul", ["/"] = "div"}
> i = 20
> s = "-"
> a = {[i+0]=s, [i+1]=s..s, [i+2]=s..s..s}
> opnames[s]
sub
> a[22]
---

在最后一个元素总是可以紧跟一个逗号。虽然该逗号是可选的,但是总是添加能够简化我们下次添加元素的处理。表构造器中的逗号也可以用分号替代。

数组、列表和序列

想表示常见的数组或列表,那么只需要 使用整型作为索引 的表即可。由于可以使用任意值对表进行索引,所以可以使用任意数字作为第一个元素的索引。Lua 中数组的索引按照惯例是从 1 开始。

当操作列表时,往往需要事先获取列表的长度,可以将列表的长度保存在某个非数值类型的字段中(通常是 "n")。列表的长度经常也是隐式的,由于未初始化的元素均为 nil,所以可以利用 nil 来标记列表的结束。我们把所有元素均不为 nil 的数组称为序列。

获取长度的操作符 # 可以获取表对应序列的长度。但是对于中间存在空洞(nil)的列表,序列长度操作符是不可靠的。它只能用于序列(即所有元素均不为 nil 的列表)。所以当确实需要处理存在空洞的列表时,应该将列表的长度显式地保存起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
> a[1] = 1
> a[2] = 2
> a[3] = 3
> a[4] = 4
> a[5] = 5

> #a
5
> a[#a] = nil
> #a
4
> a[2] = nil
> #a
4
> b = {}
> b[1] = 1
> b[100] = 100
> #b
1

> c = {1, 2, 3, nil, nil}
> #c
3

遍历表

可以使用 pairs 迭代器遍历表中的键值对。遍历过程中元素出现的顺序可能是随机的,相同的程序在每次运行时也可能产生不同的顺序。

1
2
3
4
5
6
7
8
> t = {10, print, x = 12, k = "hi"}
> for k, v in pairs(t) do
>> print(k, v)
>> end
1 10
2 function: 0x558ad9515670
k hi
x 12

对于列表而言,使用 ipairs 迭代器,此时 Lua 会确保遍历是按照顺序进行的:

1
2
3
4
5
6
7
8
> t = {10, 20, print, "hi"}
> for k, v in ipairs(t) do
>> print(k, v)
>> end
1 10
2 20
3 function: 0x558ad9515670
4 hi

此时另外一种遍历方法时使用数值型 for 循环:

1
2
3
4
5
6
7
> for k = 1, #t do
>> print(k, t[k])
>> end
1 10
2 20
3 function: 0x558ad9515670
4 hi

安全访问

如果想确认指定库中是否存在某个函数,如果确定库存在,可以使用 if lib.foo then...;否则就得使用 if lib and lib.foo then...。当表的嵌套深度比较深时,这种写法就容易出错。

可以使用 Lua 中的一些语句来模拟出安全访问操作符。对于表达式 a or {},当 a 为 nil 时其结果是一个空表。因此对于 (a or {}) .b,当 a 为 nil 时其结果也同样是 nil。因此对于更复杂的表达式,可以使用如下方法编写:

1
2
3
4
5
6
zip = (((company or {}).director or {}).adddress or {}).zipcode

或者

E = {}
zip = (((company or E).director or E).adddress or E).zipcode

表标准库

表标准库提供了操作表和序列的一系列常用函数:

  • table.insert 向序列的指定位置插入一个元素,其他元素依次后移。如果不指定位置,则是在列表最后插入元素。
  • table.remove 删除并返回序列指定位置的元素,然后将其后的元素向前移动填充删除元素后造成的空洞。如果不指定位置,则删除序列的最后一个元素
  • Lua5.3 对于移动表中的元素引入了一个更通用的函数 table.move(a, f, e, t),调用该函数可以将表 a 中从索引 f 到 e 的元素移动到位置 t 上。该函数还支持使用一个表作为可选的参数,这样将第一个表中的元素移动到第二个表中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
> t = {10, 20, 30}
> table.insert(t, 1, 15)
> table.remove(t, 1)
15

> table.move(t, 1, #t, 2)
table: 0x558ada43efa0
> t[1]=1
> for _, v in ipairs(t) do
>> print(v)
>> end
1
10
20
30

> table.move(t, 2, #t, 1)
table: 0x558ada43efa0
> t[#a] = nil
> for _, v in ipairs(t) do
>> print(v)
>> end
10
20
30

> tt = {}
> table.move(t, 2, #t, 1, tt)
table: 0x558ada439990
> print(#tt)
2

函数

在 Lua 中,函数是对语句和表达式进行抽象的主要方式。函数调用是需要使用一对圆括号把参数列表括起来,即使被调用的函数不需要参数。唯一的例外是:当函数只有一个参数且该参数是字符串常量或表构造器时,括号是可选的Lua 语言也为面向对象风格的调用提供了一种特殊的语法,即冒号操作符,例如 o:foo(x) 即调用对象 o 的 foo 方法

一个 Lua 程序既可以调用 Lua 编写的函数,也可以调用 C(或宿主程序使用的其他任意语言)编写的函数。Lua 中函数定义的常见语法格式形如:

1
2
3
4
5
6
7
function add(a)
local sum = 0
for i = 1, #a do
sum = sum + a[i]
end
return sum
end

在该语法中,函数定义具有一个函数名、一个参数组成的列表和一组语句组成的函数体。参数的行为与局部变量的行为完全一致。调用函数时使用的参数个数可以与定义函数时使用的参数个数不一致。Lua 语言会通过抛弃多余的参数和将不足的参数设为 nil 的方式来调整参数的个数。对于想使用默认参数的情况,该特性比较有用。

1
2
3
4
5
6
7
8
9
> function f(a, b) print(a, b) end
> f()
nil nil
> f(3)
3 nil
> f(3, 4)
3 4
> f(3, 4, 5)
3 4

多返回值

Lua 中的函数允许返回多个结果,使用多种赋值可以同时获取到这些结果。当需要返回多个值时,只需要在 return 关键字后列出所要返回的值即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
function maximum(a)
local mi = 1
local m = a[mi]
for i = 1, #a do
if a[i] > m then
mi = i; m = a[i]
end
end
return m, mi
end


print(maximum({10, 20, 15}))
  • 当函数被作为一条单独语句使用时,其所有返回值均会丢弃
  • 当函数被作为表达式调用时(例如算术运算的操作数),将只保留函数第一个返回值
  • 只有当函数调用是一系列表达式中的最后一个(或者是唯一表达式)时,其所有返回值才能被获取到。这里一系列表达式表现为 4 种情况:多重赋值函数调用时传入的实参列表表构造器return 语句
  • 将函数调用用一对圆括号括起来可以强制其只返回一个结果

可变长参数函数

Lua 中的函数可以是可变长参数函数,即可以支持数量可变的参数。参数列表中的 ... 表示该函数的参数是可变长的。当函数需要访问这些参数时,仍需要使用 ...,但此时不同的是这三个点是作为一个表达式来使用,例如下面的例子中 {...} 的结果是一个由所有可变长参数组成的列表:

1
2
3
4
5
6
7
8
9
10
function add(...)
local s = 0
for _, v in ipairs{...} do
s = s + v
end
return s
end


print(add(1, 2, 3))

... 组成的表达式称为可变长参数表达式,其返回的是当前函数所有的可变长参数。例如下面的函数只是将调用它时所传入的所有参数简单地返回:

1
function id(...) return ... end

具有可变长参数的函数也可以具有任意数量的固定参数,但是固定参数必须放在可变长参数之前。Lua 会先将前面的参数赋给固定参数,然后将剩余参数作为可变长参数。

1
2
3
function fwrite(fmt, ...)
return io.write(string.format(fmt, ...))
end

当要遍历可变长参数,可以使用表达式 {...} 将可变长参数放在一个表中。但是如果可变长参数中包含 nil,则会导致 {...} 获取的表可能不再是一个有效的序列。Lua 提供了函数 table.pack,该函数像表达式 {...} 一样保存所有的参数,然后将其放入一个表中返回,但是这个表还有一个保存了参数个数的额外字段 n

1
2
3
4
5
6
7
8
9
10
11
12
function nonils(...)
local arg = table.pack(...)
for i = 1, arg.n do
if arg[i] == nil then return false end
end
return true
end


print(nonils(2, 3, nil))
print(nonils(2, 3))
print(nonils())

另一种遍历函数可变长参数的方法是使用函数 select。函数 select 总是具有一个固定的参数 selector 以及数量可变的参数。如果 selector 是数值 n,那么函数 select 则返回第 n 个参数后的所有参数;否则 selector 应该是字符串 #,以便函数 select 返回额外参数的总数。

1
2
3
4
5
6
7
8
> print(select(1, "a", "b", "c"))
a b c
> print(select(2, "a", "b", "c"))
b c
> print(select(3, "a", "b", "c"))
c
> print(select("#", "a", "b", "c"))
3

通常,我们在需要把返回值个数调整为 1 的地方使用函数 select,select(n, ...) 认为是返回第 n 个额外参数的表达式(这里利用到了上面的一个特性:当函数被作为表达式调用时,将只保留函数第一个返回值):

1
2
3
4
5
6
7
8
9
10
function add (...)
local s = 0
for i = 1, select("#", ...) do
s = s + select(i, ...)
end
return s
end


print(add(1, 2, 3, 4))

table.unpack

函数 table.unpack 的参数是一个数组,返回值为数组内的所有元素。pack 把参数列表转换成 Lua 语言中一个真实的列表(一个表),而 unpack 则把 Lua 中真实的列表(一个表)转换成一组返回值,进而可以作为另一个函数的参数被使用。

1
2
3
4
5
6
7
> print(table.unpack({10, 20, 30}))
10 20 30
> a, b = table.unpack{10, 20, 30}
> a
10
> b
20

unpack 的重要用途之一体现在泛型调用机制中,泛型调用机制允许我们动态地调用具有任意参数的任意函数。通常函数 table.unpack 使用长度操作符获取返回值的个数,因而该函数只能用于序列。但是也可以显式地限制返回元素的范围:

1
2
> print(table.unpack({"Sun", "Mon", "Tue", "Wed"}, 2, 3))
Mon Tue

可以利用递归在 Lua 中实现该函数:

1
2
3
4
5
6
7
function unpack(t, i, n)
i = i or 1
n = n or #t
if i <= n then
return t[i], unpack(t, i + 1, n)
end
end

正确的尾调用

Lua 支持尾调用消除。这意味着 Lua 可以正确地尾递归。尾调用是被当做函数调用使用的跳转。当一个函数的最后一个动作是调用另一个函数而没有再进行其他工作时,就形成了尾调用。例如如下代码就是尾调用:

1
function f(x) x = x + 1; return g(x) end

由于尾调用之后,程序也就不需要在调用栈中保存有关调用函数的任何信息。Lua 语言解释器就利用了这个特点,使得在进行尾调用时不使用任何额外的栈空间。这种实现就称为尾调用消除。

由于尾调用不会使用栈空间,所以一个程序中能够嵌套的尾调用数量是无限的。在 Lua 语言中,只有形如 return func(args) 的调用才是尾调用。由于 Lua 会在调用前对 func 及其参数求值,所以 func 及其参数都可以是复杂的表达式。