掌握 Lua 数组 (Table) 的核心操作


精通 Lua 之基石:全面掌握 Lua 数组 (Table) 的核心操作

在 Lua 这门以简洁、高效和可扩展性著称的脚本语言中,table 类型扮演着至关重要的角色。它并非仅仅是一个简单的数据结构,而是 Lua 中构建几乎所有复杂数据类型的基石,包括我们通常意义上理解的数组(或称为序列)、字典(哈希映射)、集合,甚至是面向对象编程中的对象和模块(命名空间)。本文将深入探讨如何将 Lua 的 table 用作数组(以下简称为 Lua 数组),并详细阐述其核心操作,助你打下坚实的 Lua 基础。

一、 Lua Table 的本质:数组与哈希表的统一

理解 Lua 数组操作的前提是明白 Lua table 的本质。与其他许多语言不同,Lua 没有内置专门的 array 类型。table 是 Lua 中唯一的数据结构构造器,它是一种关联数组(Associative Array)。这意味着 table 可以用任何非 nil 的 Lua 值(包括数字、字符串、布尔值、函数甚至其他 table)作为键(key),并将这些键映射到相应的值(value)。

当我们谈论在 Lua 中使用“数组”时,我们特指一种 table 的特定使用模式:主要使用连续的正整数(通常从 1 开始)作为键来存储一系列值。Lua 内部对这种使用模式进行了优化。当一个 table 的键主要是从 1 开始的连续整数时,Lua 会尝试使用类似传统数组的内存布局(数组部分,array part)来存储这些值,以提高访问效率。同时,对于非整数键或者不连续的整数键,Lua 会使用哈希表(哈希部分,hash part)来存储。

这种设计的优点在于其极高的灵活性。你可以在同一个 table 中混合使用整数键和非整数键,甚至创建“稀疏”数组(即整数键不连续)。但为了有效地模拟和操作传统数组,我们通常遵循以下约定:

  1. 使用正整数作为索引(键)。
  2. 索引从 1 开始。 这是 Lua 的标准约定,与 C/C++/Java/Python 等语言从 0 开始不同。
  3. 保持索引的连续性。 尽量避免在序列中间创建 nil 值“空洞”,因为这会影响长度操作符 #ipairs 迭代器的行为。

二、 创建 Lua 数组

创建 Lua 数组主要通过 table 的构造器 {} 来完成。

1. 创建空数组:

lua
local emptyArray = {}
print("空数组长度:", #emptyArray) -- 输出: 空数组长度: 0

2. 创建带初始值的数组:

可以在构造器内直接列出元素,它们会自动被赋予从 1 开始的连续整数键。

```lua
local fruits = {"Apple", "Banana", "Orange"}
print("第一个水果:", fruits[1]) -- 输出: 第一个水果: Apple
print("数组长度:", #fruits) -- 输出: 数组长度: 3

local numbers = {10, 20, 30, 40, 50}
print("第三个数字:", numbers[3]) -- 输出: 第三个数字: 30
print("数组长度:", #numbers) -- 输出: 数组长度: 5
```

3. 混合类型数组:

Lua 数组的元素可以是任何类型,并且可以在同一个数组中混合使用。

lua
local mixedArray = {1, "hello", true, print, { nested = true }}
print("第二个元素:", mixedArray[2]) -- 输出: 第二个元素: hello
print("第五个元素类型:", type(mixedArray[5])) -- 输出: 第五个元素类型: table
print("数组长度:", #mixedArray) -- 输出: 数组长度: 5

4. (较少用于纯数组) 指定键值对创建:

虽然主要用于创建字典或稀疏结构,但也可以显式指定整数键。

lua
local specificIndices = { [1] = "one", [3] = "three" }
print(specificIndices[1]) -- 输出: one
print(specificIndices[2]) -- 输出: nil (因为没有指定索引 2)
print(specificIndices[3]) -- 输出: three
-- 注意:此时 #specificIndices 的行为可能不符合预期,因为它不是一个严格的序列
-- 在 Lua 5.1/5.2 中,# 遇到非整数键或 nil 空洞时行为未定义或可能给出非预期结果
-- 在 Lua 5.3+ 中,# 只计算序列部分(从1开始到第一个nil整数键之前)
-- 对于上面这个例子,在 Lua 5.3+ 中 #specificIndices 会是 1

最佳实践: 对于模拟数组,始终使用 {elem1, elem2, ...} 的形式或后续添加元素的方式来确保索引从 1 开始且连续。

三、 访问和修改数组元素

访问和修改 Lua 数组元素使用方括号 [] 语法,并提供 1 基的整数索引。

1. 访问元素:

```lua
local colors = {"Red", "Green", "Blue"}

local firstColor = colors[1] -- firstColor = "Red"
local secondColor = colors[2] -- secondColor = "Green"
local thirdColor = colors[3] -- thirdColor = "Blue"

print(firstColor, secondColor, thirdColor) -- 输出: Red Green Blue

-- 访问不存在的索引会返回 nil
local nonExistent = colors[4]
print("不存在的元素:", nonExistent) -- 输出: 不存在的元素: nil
print(colors[0]) -- 输出: nil (索引从 1 开始)
```

2. 修改元素:

通过赋值语句可以直接修改指定索引处的值。

```lua
local planets = {"Mercury", "Venus", "Earth", "Mars"}
print("原始第三颗行星:", planets[3]) -- 输出: 原始第三颗行星: Earth

planets[3] = "Terra" -- 修改索引为 3 的元素
print("修改后的第三颗行星:", planets[3]) -- 输出: 修改后的第三颗行星: Terra

-- 如果索引超出了当前数组末尾,赋值操作会扩展数组(可能产生 nil 空洞)
planets[5] = "Jupiter"
print("第五颗行星:", planets[5]) -- 输出: 第五颗行星: Jupiter
print("当前数组长度:", #planets) -- 输出: 当前数组长度: 5 (Lua 5.3+ 会正确计算到 5)
print(planets[4]) -- 输出: Mars (索引 4 仍然存在)
```

注意: 访问或修改时务必注意 Lua 的 1 基索引约定。

四、 获取数组长度

获取 Lua 数组(严格来说是序列)的长度,最常用的方法是使用一元长度操作符 #

```lua
local letters = {'a', 'b', 'c', 'd'}
local len = #letters
print("数组 'letters' 的长度:", len) -- 输出: 数组 'letters' 的长度: 4

local empty = {}
print("空数组长度:", #empty) -- 输出: 空数组长度: 0
```

# 操作符的重要限制:

# 操作符在 Lua 5.1/5.2 中对于包含 nil 值“空洞”或非正整数键的 table 行为是未定义的。从 Lua 5.3 开始,# 操作符的行为被明确定义为:返回序列的长度,即从索引 1 开始到第一个 nil 值整数键之前的最后一个整数键。

```lua
local sequence = {10, 20, 30}
print(#sequence) -- 输出: 3

local withNilHole = {10, nil, 30}
print(#withNilHole) -- 在 Lua 5.3+ 中输出: 1 (因为索引 2 是 nil)
-- 在 Lua 5.1/5.2 中行为未定义,可能为 1 或 3

local sparse = {[1] = 10, [3] = 30}
print(#sparse) -- 在 Lua 5.3+ 中输出: 1 (索引 2 是隐式的 nil)
-- 在 Lua 5.1/5.2 中行为未定义

local mixed = {"a", "b", key = "value"}
print(#mixed) -- 输出: 2 (只计算序列部分)
```

结论: 为了让 # 操作符可靠地返回你期望的数组元素数量,必须确保你的数组是一个从 1 开始且没有 nil 值空洞的连续整数索引序列。如果需要处理可能包含 nil 或非序列的 table 大小,你需要使用 pairs 迭代并手动计数,或者使用 table.maxn (Lua 5.2+,返回最大的正整数键)。

lua
-- 获取包含 nil 空洞或非序列 table 的最大索引 (Lua 5.2+)
local t = {[1]=1, [5]=5, [-1]=-1, ["key"]="value"}
print(table.maxn(t)) -- 输出: 5

五、 添加元素

向 Lua 数组添加元素主要有两种方式:在末尾追加和在指定位置插入。

1. 在末尾追加元素:

  • 方法一:直接赋值给 #t + 1
    这是传统且高效的方式。利用 # 获取当前长度,然后将新元素赋值给下一个索引。

    ```lua
    local tasks = {"Read email", "Write report"}
    print("原始任务:", table.concat(tasks, ", ")) -- 输出: 原始任务: Read email, Write report
    print("原始长度:", #tasks) -- 输出: 原始长度: 2

    tasks[#tasks + 1] = "Attend meeting"
    print("追加后任务:", table.concat(tasks, ", ")) -- 输出: 追加后任务: Read email, Write report, Attend meeting
    print("追加后长度:", #tasks) -- 输出: 追加后长度: 3
    ```

  • 方法二:使用 table.insert(t, value)
    这是 Lua 标准库提供的函数,专门用于在序列的末尾添加元素。它更具可读性,并且在某些 Lua 实现或 JIT 编译器下可能经过特别优化。

    lua
    local items = {100, 200}
    table.insert(items, 300) -- 等价于 items[#items + 1] = 300
    print("追加后 items:", table.concat(items, ", ")) -- 输出: 追加后 items: 100, 200, 300
    print("追加后长度:", #items) -- 输出: 追加后长度: 3

2. 在指定位置插入元素:

使用 table.insert(t, pos, value) 可以在数组的指定位置 pos 插入一个新元素 value。原来在 pos 位置及之后的所有元素都会向后移动一位。

```lua
local letters = {'a', 'c', 'd'}
print("原始 letters:", table.concat(letters, "")) -- 输出: 原始 letters: acd
print("原始长度:", #letters) -- 输出: 原始长度: 3

-- 在索引 2 的位置插入 'b'
table.insert(letters, 2, 'b')
print("插入后 letters:", table.concat(letters, "")) -- 输出: 插入后 letters: abcd
print("插入后长度:", #letters) -- 输出: 插入后长度: 4

-- 在开头插入 '!' (索引 1)
table.insert(letters, 1, '!')
print("再插入后 letters:", table.concat(letters, "")) -- 输出: 再插入后 letters: !abcd
print("再插入后长度:", #letters) -- 输出: 再插入后长度: 5

-- 在末尾插入 'e' (等价于 table.insert(letters, 'e') 或 letters[#letters+1]='e')
table.insert(letters, #letters + 1, 'e')
print("末尾插入后 letters:", table.concat(letters, "")) -- 输出: 末尾插入后 letters: !abcde
print("末尾插入后长度:", #letters) -- 输出: 末尾插入后长度: 6
```

性能考量: 在数组中间插入元素 (table.insert(t, pos, value)pos <= #t) 通常比在末尾追加 (table.insert(t, value)t[#t+1]=value) 代价更高,因为它需要移动后续的所有元素。对于非常大的数组和频繁的中间插入操作,性能影响可能比较明显。

六、 删除元素

从 Lua 数组中删除元素同样有两种主要场景:删除末尾元素和删除指定位置元素。

1. 删除末尾元素:

使用 table.remove(t) 可以高效地移除并返回数组 t 的最后一个元素。

```lua
local numbers = {1, 2, 3, 4, 5}
print("原始 numbers:", table.concat(numbers, ", ")) -- 输出: 原始 numbers: 1, 2, 3, 4, 5
print("原始长度:", #numbers) -- 输出: 原始长度: 5

local removedValue = table.remove(numbers)
print("被移除的元素:", removedValue) -- 输出: 被移除的元素: 5
print("删除后 numbers:", table.concat(numbers, ", ")) -- 输出: 删除后 numbers: 1, 2, 3, 4
print("删除后长度:", #numbers) -- 输出: 删除后长度: 4
```

2. 删除指定位置元素:

使用 table.remove(t, pos) 可以移除并返回数组 t 在指定位置 pos 的元素。原来在 pos 之后的所有元素都会向前移动一位来填补空缺。

```lua
local colors = {"Red", "Green", "Blue", "Yellow"}
print("原始 colors:", table.concat(colors, ", ")) -- 输出: 原始 colors: Red, Green, Blue, Yellow
print("原始长度:", #colors) -- 输出: 原始长度: 4

-- 删除索引为 2 的元素 ("Green")
local removedColor = table.remove(colors, 2)
print("被移除的颜色:", removedColor) -- 输出: 被移除的颜色: Green
print("删除后 colors:", table.concat(colors, ", ")) -- 输出: 删除后 colors: Red, Blue, Yellow
print("删除后长度:", #colors) -- 输出: 删除后长度: 3
```

性能考量: 与插入类似,删除数组中间的元素 (table.remove(t, pos)pos < #t) 的成本通常高于删除末尾元素 (table.remove(t)), 因为它也需要移动后续元素。

重要警告:避免使用 t[pos] = nil 来“删除”数组元素

虽然可以将数组中某个索引的值设为 nil,但这并不是推荐的删除序列元素的方式。这样做会在数组中创建一个 nil 空洞,破坏其序列的连续性。

```lua
local data = {10, 20, 30, 40}
data[2] = nil -- 错误的方式 "删除" 元素
print(data[1], data[2], data[3], data[4]) -- 输出: 10 nil 30 40

-- 后果:
-- 1. #data 的行为变得不可靠 (Lua 5.3+ 会返回 1)
print("长度 (#):", #data) -- Lua 5.3+ 输出: 1
-- 2. ipairs 迭代会提前停止
for i, v in ipairs(data) do
print("ipairs:", i, v) -- 只会输出: ipairs: 1 10
end
-- 3. 其他依赖序列连续性的操作可能失败
-- 例如 table.concat 会在 nil 处停止
print(table.concat(data, ",")) -- 输出: 10 (Lua 5.3+) 或 报错 (Lua 5.1/5.2)
```

正确做法: 始终使用 table.remove(t, pos) 来删除序列中的元素,它会保持数组的紧凑和连续性。

七、 遍历数组

遍历 Lua 数组(序列)是常见的操作,主要有两种推荐的方法:

1. 使用数字 for 循环和 # 操作符:

这是最通用、最直接且通常性能最好的遍历序列的方式。它依赖于 # 操作符正确返回序列长度。

lua
local scores = {95, 88, 72, 100}
print("遍历 scores (数字 for):")
for i = 1, #scores do
local score = scores[i]
print("索引:", i, " 值:", score)
end
-- 输出:
-- 索引: 1 值: 95
-- 索引: 2 值: 88
-- 索引: 3 值: 72
-- 索引: 4 值: 100

这种方法的好处是:
* 明确控制索引范围 (1 到 #t)。
* 即使数组在循环内部被修改(例如,添加元素),只要 #scores 在循环开始时确定,循环次数就是固定的(除非你修改循环变量 i 或使用 break)。但要注意,如果在循环中删除元素,可能会跳过某些元素或访问 nil

2. 使用 ipairs 迭代器:

ipairs 是专门为遍历序列(从索引 1 开始的连续整数键)设计的迭代器。它会依次返回索引和值,直到遇到第一个 nil 值的整数键。

lua
local weekdays = {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday"}
print("遍历 weekdays (ipairs):")
for index, day in ipairs(weekdays) do
print("索引:", index, " 值:", day)
end
-- 输出:
-- 索引: 1 值: Monday
-- 索引: 2 值: Tuesday
-- 索引: 3 值: Wednesday
-- 索引: 4 值: Thursday
-- 索引: 5 值: Friday

ipairs 的优点:
* 语法简洁、语义清晰,明确表示正在遍历一个序列。
* 保证按索引 1, 2, 3... 的顺序遍历。
* 对于严格的序列(无 nil 空洞),ipairs 和数字 for 循环效果相同。

ipairs 的限制:
* 如前所述,它会在第一个 nil 整数键处停止。如果你的 table 不是严格的序列,ipairs 可能不会遍历所有你期望的元素。

对比 pairs 迭代器:

Lua 还提供了 pairs 迭代器,它可以遍历 table 中所有的键值对,包括非整数键和不连续的整数键。pairs 不保证遍历顺序。因此,对于需要按顺序处理的数组(序列),应该使用数字 for 循环或 ipairs,而不是 pairs

lua
local mixedTable = {[1]="one", [3]="three", key="value"}
print("遍历 mixedTable (pairs):")
for k, v in pairs(mixedTable) do
print("键:", k, " 值:", v) -- 输出顺序不保证
end
-- 可能输出 (顺序不定):
-- 键: 1 值: one
-- 键: key 值: value
-- 键: 3 值: three

八、 其他常用数组操作

除了上述核心操作,table 库还提供了一些其他有用的函数,可以应用于数组(序列)。

1. 数组排序:table.sort(t, [comp])

对数组 t 进行原地排序。默认按 Lua 的标准比较规则(数字按大小,字符串按字母顺序)升序排列。可以提供一个可选的比较函数 comp(a, b),该函数在 a 应该排在 b 前面时返回 true

```lua
local numbers = {5, 1, 4, 2, 3}
table.sort(numbers)
print("排序后 numbers:", table.concat(numbers, ", ")) -- 输出: 排序后 numbers: 1, 2, 3, 4, 5

local names = {"Charlie", "Alice", "Bob"}
table.sort(names)
print("排序后 names:", table.concat(names, ", ")) -- 输出: 排序后 names: Alice, Bob, Charlie

-- 自定义降序排序
local scores = {80, 100, 90}
table.sort(scores, function(a, b) return a > b end)
print("降序排序后 scores:", table.concat(scores, ", ")) -- 输出: 降序排序后 scores: 100, 90, 80

-- 对包含 table 的数组排序
local persons = { {name="Bob", age=30}, {name="Alice", age=25} }
table.sort(persons, function(a, b) return a.age < b.age end) -- 按年龄升序
print("按年龄排序后:")
for i, p in ipairs(persons) do print(p.name, p.age) end
-- 输出:
-- 按年龄排序后:
-- Alice 25
-- Bob 30
```

注意: table.sort 是原地修改,不会返回新的排序后的数组。它同样要求 t 是一个没有 nil 空洞的序列。

2. 数组连接成字符串:table.concat(t, [sep], [i], [j])

将数组 t 的元素(假定都是字符串或数字)连接成一个单一的字符串。
* sep (可选): 元素之间的分隔符字符串,默认为空字符串 ""
* i (可选): 开始连接的索引,默认为 1。
* j (可选): 结束连接的索引,默认为 #t

```lua
local parts = {"Lua", "is", "awesome"}
local sentence = table.concat(parts, " ")
print(sentence) -- 输出: Lua is awesome

local csvData = {1, 2, 3, 4, 5}
local csvString = table.concat(csvData, ",")
print(csvString) -- 输出: 1,2,3,4,5

-- 连接子序列
local letters = {'a', 'b', 'c', 'd', 'e'}
local sub = table.concat(letters, "", 2, 4) -- 连接索引 2 到 4 ('b', 'c', 'd')
print(sub) -- 输出: bcd
```

注意: table.concat 要求数组元素是字符串或数字(会自动转换成字符串)。如果遇到 nil 或其他类型,行为可能因 Lua 版本而异(早期版本可能报错,较新版本可能在 nil 处停止)。

3. 数组(序列)的复制:

Lua 中 table 是引用类型。简单的赋值 newArray = oldArray 只会创建对同一个 table 的引用,修改 newArray 也会影响 oldArray。要创建数组的独立副本,需要手动遍历并复制元素。

  • 浅拷贝 (Shallow Copy): 只复制数组本身,如果元素是 table 或其他引用类型,则复制的是引用。

    ```lua
    local original = {1, 2, { nested = true }}
    local shallowCopy = {}
    for i = 1, #original do
    shallowCopy[i] = original[i]
    end

    shallowCopy[1] = 100 -- 修改副本的值类型元素,不影响原始
    shallowCopy[3].nested = false -- 修改副本中嵌套 table 的内容,会影响原始

    print(original[1]) -- 输出: 1
    print(original[3].nested) -- 输出: false
    ```

  • 深拷贝 (Deep Copy): 递归地复制所有嵌套的 table,创建完全独立的副本。这通常需要一个递归函数。

    ```lua
    function deepcopy(orig)
    local orig_type = type(orig)
    local copy
    if orig_type == 'table' then
    copy = {}
    for orig_key, orig_value in pairs(orig) do
    copy[deepcopy(orig_key)] = deepcopy(orig_value) -- 递归复制键和值
    end
    setmetatable(copy, deepcopy(getmetatable(orig))) -- 复制元表
    else -- number, string, boolean, nil, function, userdata, thread
    copy = orig
    end
    return copy
    end

    local original = {1, 2, { nested = true }}
    local deepCopy = deepcopy(original)

    deepCopy[1] = 100
    deepCopy[3].nested = false

    print(original[1]) -- 输出: 1
    print(original[3].nested) -- 输出: true (深拷贝后,修改副本不影响原始的嵌套 table)
    ```

选择浅拷贝还是深拷贝取决于你的具体需求。对于只包含值类型(数字、字符串、布尔)的简单数组,浅拷贝通常就足够了。

九、 总结与最佳实践

掌握 Lua 中 table 作为数组的核心操作是编写高效、可靠 Lua 代码的关键。回顾一下要点和最佳实践:

  1. 理解基础: Lua 没有专门的数组类型,使用 table 并通过连续的、从 1 开始的整数键来模拟数组(序列)。
  2. 创建: 使用 {} 构造器,{val1, val2, ...} 是创建序列的最直接方式。
  3. 访问与修改: 使用 t[index],牢记 1 基索引。
  4. 长度: 使用 #t 获取序列长度,但要确保数组是连续的(无 nil 空洞),否则 # 的行为可能不符合预期(尤其在 Lua 5.3 之前)。
  5. 添加:
    • 末尾追加:t[#t + 1] = valuetable.insert(t, value)
    • 指定位置插入:table.insert(t, pos, value),注意性能影响。
  6. 删除:
    • 末尾删除:table.remove(t) (高效)。
    • 指定位置删除:table.remove(t, pos),注意性能影响。
    • 绝对避免 使用 t[pos] = nil 来删除序列元素,它会破坏序列结构。
  7. 遍历:
    • 首选数字 for 循环 (for i = 1, #t do ...),通用且高效。
    • ipairs(t) 用于按顺序遍历序列,语法简洁,但在 nil 空洞处停止。
    • 避免 使用 pairs(t) 遍历需要保证顺序的数组。
  8. 标准库函数: 善用 table.sort (原地排序) 和 table.concat (连接成字符串)。
  9. 复制: 理解浅拷贝和深拷贝的区别,根据需要选择合适的复制方式。
  10. 保持序列性: 为了可靠地使用 #ipairstable.sorttable.concat 等,尽量维护数组的序列性(1 基、连续整数索引、无 nil 空洞)。

Lua 的 table 虽是一个统一的结构,但通过遵循这些约定和熟练运用核心操作,你可以像使用其他语言中的专用数组一样高效地处理序列数据,同时还能享受到 table 带来的无与伦比的灵活性。深入理解并实践这些操作,将为你驾驭更复杂的 Lua 编程打下坚实的基础。


THE END