第 16 章 / 共 58 章 卷四 容器与文本

array 与 list

一个是连续同质的序列,一个是稀疏的整数键表——xlang 的两种基础序列容器,连同"谁是共享、谁是副本"的完整答案。

本章导读

从本章开始进入卷四"容器与文本"。数组是几乎每门语言的第一容器,xlang 给了它两副面孔:静态 array<T> 同质、连续、类型受检;var 动态数组 异构、灵活、运行期分派。除此之外还有一个不太常见的成员:list<T>——整数作键但不要求连续的稀疏列表,适合"第几天记了一笔账"这类数据。本章把三种容器的声明、索引、方法、运算符逐一实测,并用一整节回答第 12、15 章遗留的问题:容器传参和赋值,到底是共享还是复制?

本章学习目标

掌握 array<T> 的声明与初始化规则;会用负下标并理解越界即 fatal 的取舍;背下实测过的数组方法全集;理解数组运算符 + / += / == 的可用形态与字面量缺陷;掌握 var 动态数组的异构能力与 swap/reserve 专有方法;掌握 list 的稀疏语义与缺键 fatal;学会多维数组在 RC0 的正确打开方式;吃透容器共享引用语义与静态→var 的转换隔离。

16.1 静态数组 array<T>

静态数组用 array<T> 声明,全部元素同一类型。用 auto 从字面量推断时,同种字面量得到静态数组,混杂字面量则推断为动态数组:

decl.xl
auto a = [1, 2, 3, 4]
println(type(a))            // array<int>:同质字面量推断为静态数组
println(string(a.length))   // 4

array<float> b = [1, 2, 3]   // 整数字面量自动转 float
println(type(b))            // array<float>
array<string> names = ["xlang", "rc0"]
array<int> empty = []        // 空数组:必须显式写出类型

auto mixed = [1, "s"]
println(type(mixed))        // array:混杂字面量推断为动态数组(不叫 array<var>)

三条记忆点:① 类型名是 float 不是 double——写 array<double> 报 [sem] 未知类型: double;② 空数组没有字面量可供推断,必须写清 array<int> empty = [];③ 混杂字面量本身不报错,auto 会把它推断为动态数组,type() 显示为 array;但反过来把它塞进静态声明就会被拒:

# bad.xl 内容:array<int> bad = [1, "s"] > xl run bad.xl bad.xl:1:18: [sem] 初始化表达式类型和声明类型不匹配: target array<int>, value array

.length 是属性不是方法,静态与动态数组通用;空数组 length 为 0,可作空容器判断。注意容器本身不能用 string() 打印(报 [sem] 该表达式不能转换为目标类型),想看内容就遍历或按下标取。

16.2 索引、负下标与遍历

下标从 0 开始,支持负下标(-1 是最后一个元素),读写皆可:

idx.xl
auto c = [10, 20, 30]
println(string(c[0]) + " " + string(c[-1]))   // 10 30
c[-1] = 99                              // 负下标写入
println(string(c[2]))                    // 99

越界访问不是返回 null,而是进程直接终止,退出码 3,连报错文本都没有(RC0 实测:stdout、stderr 均无任何输出)。这与 var 字典缺失键返回 null 的宽松相反——数组把越界视为编程错误。经手外部输入的下标,先用 i < a.length 判断;第 10 章的 try/catch 也拦不住这类 fatal。

遍历三种形态:单变量得元素值;双变量得 下标, 值;需要步进或倒序时用 while 配 length:

iter.xl
for v in a
    println(string(v))              // 逐元素
end
for i, v in a
    println(string(i) + ":" + string(v))   // 0:1  1:2  2:3  3:4
end

16.3 数组运算符

数组有三个运算符,但 + 与 == 各有一个必须知道的 RC0 缺陷:

ops.xl
auto x = [1, 2]
auto y = [3]
auto b = x + y
println(string(b.length))     // 3:变量对变量的拼接正常

auto bad = x + [3]
println(string(bad.length))   // 0:右侧是字面量,结果是空数组(缺陷!)

x += [4]
println(string(x.length))     // 3:+= 就地追加正常
println(string(x[2]))          // 4

auto p = [1, 2]
auto q = [1, 2]
println(string(p == q))       // true:逐元素相等性比较

规律:+ 的两个操作数都先命名成变量就安全;+= 没有这个问题;== 比较两个变量正常,但把两个字面量直接写进比较([1,2] == [1,2])在 RC0 会让进程直接崩溃(段错误,退出码 139)——同样先赋变量再比较。var 动态数组不受 + 字面量缺陷影响:var v = [1,2]; v + [3] 结果正常。

16.4 数组方法全集(实测)

方法名多为英文常用名。以下每一行都经过 RC0 实测,输出见注释:

methods.xl
auto m = [1, 2, 3]
m.push(4)
m.append(5)                    // append 是 push 的同义别名
println(string(m.length))     // 5
m.insert(0, 0)                 // 在下标 0 处插入
println(string(m[0]) + " " + string(m.length))   // 0 6
println(string(m.indexOf(3)))        // 3:找到返回下标
println(string(m.indexOf(99)))       // -1:找不到
println(string(m.contains(9)))       // false:按值判断存在性
println(string(m.pop()))             // 5:删尾并返回尾元素
println(string(m.take(0)))           // 0:按下标删并返回该元素
println(string(m.remove(0)))         // true:按下标删除,返回是否生效
println(string(m.removeValue(3)))    // true:按值删除
m.reverse()                            // 就地反转
println(string(m[0]) + " " + string(m[1]))   // 4 2
m.clear()                              // 清空
println(string(m.length))            // 0

auto r = [1, 2, 3, 4, 5]
r.remove(1, 2)                       // 从下标 1 起连删 2 个
println(string(r.length) + " " + string(r[1]))   // 3 4(r 现为 [1,4,5])

两组容易混淆的对子:

  • remove(i) 与 removeValue(v):前者按下标删,后者按值删;都返回 bool 表示是否生效。
  • pop() 与 take(i):pop 无参专删尾部,take 按下标删任意位置;都返回被删元素。take(0) 就是"头部出队"。

两个直觉上"应该有"的方法并不存在:sort 与 slice 在 RC0 均未实现。在静态数组上调用报 [sem] member key receiver must be var or dynamic dict,在 var 数组上调用报 [checked] 找不到 class 方法——两种报错都只是"没有这个方法"。排序自己写(本章练习 1),切片用循环按段复制(练习 2)。

16.5 var 动态数组

var 声明的数组可异构混装,通用方法与静态数组一致,还多出两个专有方法:

dyn.xl
var da = [1, "two", 3.5]
println(string(da.length))     // 3
da.push(4)
println(string(da.pop()))       // 4
println(string(da.take(0)))     // 1
println(string(da.contains("two")))   // true

var s = [1, 2, 3]
s.swap(0, 2)                     // 交换两个下标的元素
println(string(s[0]) + " " + string(s[2]))   // 3 1
var r = []
r.reserve(100)                   // 预留容量,减少扩容次数
println(string(r.length))       // 0:reserve 不改 length

swap 与 reserve 是动态数组专有:静态数组调用它们报 [sem] member key receiver must be var or dynamic dict(见 16.9 坑 2)。需要 swap 的场景要么把声明改成 var,要么手写三行交换。

16.6 list:稀疏整数键列表

list 用整数作键但不要求连续——它是介于数组与 dict 之间的稀疏容器:

list.xl
list<string> l = [0: "a", 2: "b", 5: "c"]
println(string(l.length))         // 3:条目数,不含空洞
println(l[2])                     // b
for k, v in l
    println(string(k) + "=>" + v)  // 0=>a  2=>b  5=>c
end

list 与 array 的关键差异有四,全部实测:

  • length 语义:array 的 length 隐含"下标 0..n-1 都有效";list 的 length 只是条目计数,键 5 存在不代表键 0..4 存在。
  • 读缺失键 fatal:读 l[3](不存在的键)进程直接终止(退出码 3,无输出),try/catch 拦不住。
  • 负键是普通键:l[-1] 不是"最后一个元素",而是真的整数键 -1——没存过就 fatal。数组的负下标语义在 list 上不成立。
  • contains 与 in 查的是值,不是键:l.contains("b") 得 true,l.contains(3) 直接报 [sem] container.contains value type mismatch: target string, value int;2 in l 得 false 而 "b" in l 得 true。RC0 没有判断"键是否存在"的手段——要么自己维护键的约定,要么改用 dict(第 17 章)用 string 键。

运算符方面:+ 合并(右侧覆盖同键,注意与数组相同的"字面量右操作数得空"缺陷)、+= 可用;- 不存在,调用报 [sem] numeric operator operands must be numeric。方法有 remove(k) / take(k) / clear(),语义与数组一致:

listops.xl
list<int> a = [1: 10, 3: 30]
list<int> ov = [3: 99, 5: 50]
auto b = a + ov                 // 键 3 被覆盖为 99,新增键 5
println(string(b.length))         // 3
println(string(b[3]) + " " + string(b[5]))   // 99 50
a += ov
println(string(a.remove(1)))      // true
println(string(a.take(3)))        // 99:删除并返回值
a.clear()
println(string(a.length))         // 0

16.7 多维数组与链式索引

多维数组的元素类型是另一个 array<T>。array<array<int>> 声明加字面量初始化在 RC0 可以正常工作——但下一步的链式索引会卡住:

# bad.xl 内容: # array<array<int>> mm = [[1, 2], [3, 4]] # println(string(mm[0][1])) > xl run bad.xl bad.xl:2:21: [checked] index receiver must be array, list, dict or var

静态容器的元素取出后类型信息丢失,第二个下标的 receiver 被判非法。规避办法是两步法——先取行再取列。但要特别注意:静态数组取出的行是副本,改它不影响原矩阵;var 数组取出的行是共享的,改它即改原矩阵:

multi.xl
array<array<int>> mm = [[1, 2], [3, 4]]
auto row = mm[1]
println(string(row[0]))     // 3:两步法读取正常
row[0] = 30
auto chk = mm[1]
println(string(chk[0]))     // 仍是 3:静态取行是副本,写不回!

var m = []
m.push([1, 2, 3])
m.push([4, 5, 6])
println(string(m[0][1]))   // 2:var 矩阵连链式索引都直接可用
auto r2 = m[1]
r2[0] = 40
auto chk2 = m[1]
println(string(chk2[0]))    // 40:var 取行共享,写回生效

实践结论:要就地修改的矩阵用 var 构造(逐行 push),静态多维数组只适合只读场景(配合两步法读取)。双层 for-in 遍历对两者都正常。

16.8 传递与共享:容器是引用语义

第 12、15 章曾按文档口径把 var 容器描述为写时复制(COW)。本章以完整实测矩阵更正:RC0 中容器是共享引用语义,COW 在简单场景从未触发(两章原文已同步勘误)。四组实测:

sharing.xl
// ① var 数组传 var 形参:函数内 push,调用方可见
func grow(var arr)
    arr.push(4)
end
var data = [1, 2, 3]
grow(data)
println(string(data.length))   // 4:可见!

// ② var 赋值 var:共享,无副本分离
var a = [1, 2]
var b = a
b.push(3)
println(string(a.length))      // 3:a 跟着变
b[0] = 99
println(string(a[0]))          // 99:索引写同样可见

// ③ 静态数组传 var 形参:转换隔离,唯一"不可见"的情形
auto fixed = [1, 2, 3]
grow(fixed)
println(string(fixed.length))  // 3:静态→动态转换产生副本

// ④ 静态传静态:同样共享
func grow2(array<int> arr)
    arr.push(9)
end
grow2(fixed)
println(string(fixed.length))  // 4:可见

规律只有一条:同构传递(var→var、array<T>→array<T>)共享底层数据;静态→var 的跨形态传递会复制转换,形成隔离。反方向也不通:var 容器传给静态 array<T> 形参报 [sem] dynamic var argument requires explicit 'as Type' conversion before overload resolution,需 as array<int> 显式转换。想保护调用方数据,在函数入口手工复制:

copy.xl
var local = []
for v in src
    local.push(v)
end

深入细节

拾遗 1:方法集的动静差异对照

RC0 对数组方法做两级校验:receiver 级(语义阶段)与方法存在级(checked 阶段)。静态数组调任何"不认识"的方法都先撞 receiver 检查,报 member key receiver;var 数组能过 receiver 关,再撞方法存在关,报"找不到 class 方法"。实测对照:

方法静态 array<T>var 动态数组
push / append / insert / remove / removeValue / pop / take / clear / contains / indexOf / reverse✓✓
swap / reserve✗(member key receiver)✓
sort / slice✗(member key receiver)✗(找不到 class 方法)

记忆法:改变容量布局的(swap、reserve)归动态数组;只动元素的双向通用;没实现的(sort、slice)两边都报,只是报错文案不同。

拾遗 2:链式索引为何只难为静态数组

m[0][1] 的失败不在运行期,而在 checked 阶段:类型检查器为下标表达式 m[0] 推导结果类型时,静态容器的泛型元素类型在 RC0 没有传递下去,第二个下标的 receiver 失去数组身份。var 矩阵之所以畅通,是因为 var 元素的下标访问走运行期分派,不依赖编译期类型推导。同理可以解释"静态取行是副本":auto row = mm[1] 触发的是一次值拷贝语义(静态数组元素的受管理副本),而 var 取行拿到的是原容器的引用。dict 里嵌套数组(第 17 章)也有同样的逐层取出纪律。

拾遗 3:字符串与数组互转

字符串一侧的 split / join / chars 由 string 命名空间提供(第 18 章详解);此处讲的是不依赖库函数的纯手工互转,也是理解"字符串可迭代"的最小例子——字符串 → 字符数组用 for-in 逐字符 push;数组 → 字符串用循环拼接:

strconv.xl
var chars = []
for c in "abc"
    chars.push(c)            // 单字符 string
end
println(string(chars.length))   // 3
string s = ""
for c in chars
    s += c as string
end
println(s)                  // abc

第 18 章会系统讲字符串处理;这里的循环法是 RC0 下一切"分割拼接"需求的基础件。

⚠️ 常见错误

以下报错全部来自真实编译器输出(文件路径已缩短)。

坑 1:以为有 sort / slice
# bad.xl 内容: # var a = [3, 1, 2] # a.sort() > xl run bad.xl bad.xl:2:3: [checked] 找不到 class 方法

原因:RC0 未实现 sort / slice(静态数组上则报 member key receiver 错)。正确做法:排序手写双重循环(练习 1);切片用循环按段 push 到新数组(练习 2)。

坑 2:静态数组调 swap / reserve
# bad.xl 内容: # auto a = [1, 2, 3] # a.swap(0, 2) > xl run bad.xl bad.xl:2:3: [sem] member key receiver must be var or dynamic dict

原因:swap / reserve 是动态数组专有方法。正确做法:声明改成 var;或手写三行交换(temp = a[i]; a[i] = a[j]; a[j] = temp)。

坑 3:运算符遇到字面量操作数
# bad.xl 内容: # auto x = [1, 2] # auto b = x + [3] ← 结果 length 为 0,静默出错 # println(string([1,2] == [1,2])) ← 进程直接崩溃(段错误) > xl run bad.xl (无任何输出,退出码 3 或 139)

原因:RC0 对字面量右操作数的 + 会产出空数组;字面量直接参与 == 会崩溃。正确做法:+ 与 == 的操作数都先命名成变量再运算;+= 不受此限。

坑 4:把数组直觉带到 list 上
# bad.xl 内容: # list<string> l = [0: "a", 2: "b"] # println(l[3]) ← 键不存在 # println(l[-1]) ← 负键不是"最后一个元素" > xl run bad.xl (无任何输出,退出码 3)

原因:list 读缺失键直接 fatal,try/catch 拦不住;负号在 list 上是普通整数键。更麻烦的是 contains/in 查的是值不是键,RC0 没有键存在判断。正确做法:键集合由程序自己维护约定;需要键存在判断的场景改用 dict(第 17 章)。

本章小结

能力array<T>var 数组list<T>
元素/键类型同质 T,连续下标可异构,连续下标整数键,可稀疏
负索引尾部倒数尾部倒数普通整数键(非尾部)
越界/缺键读fatal(退出码 3)fatalfatal
length元素数元素数条目数(不含空洞)
contains / in查值查值查值(无键判断手段)
通用方法push pop take insert remove removeValue clear contains(indexOf/reverse 仅数组)
专有方法—swap reserve—
运算符+ += ==(字面量操作数有缺陷)+ += ==+ +=(无 -)
传递语义共享引用;仅静态→var 形参转换产生隔离副本

复习题

✍️ 先遮住答案想一想
  1. remove(2) 与 removeValue(2) 的区别是什么?各自的返回值含义?
  2. 为什么静态数组不能调 swap?改一处声明即可修复,怎么改?
  3. auto x = [1, 2]; auto b = x + [3] 之后 b.length 是多少?正确写法是什么?
  4. list<int> l = [0: 10, 2: 30];l.length、l[1]、l[-2]、l.contains(2) 各自的结果是什么?
  5. array<array<int>> 的 mm,为什么 mm[1][0] 报错而 auto r = mm[1]; r[0] 可行?此时修改 r[0] 会不会影响 mm?var 矩阵呢?
  6. 函数 func f(var arr) 内 arr.push(1),调用方的 var 数组会不会变?如果调用方传入的是 array<int> 呢?
参考答案
  1. remove(2) 删除下标 2 处的元素,removeValue(2) 删除值为 2 的第一个元素;都返回 bool,表示是否真的发生了删除。
  2. swap 是动态数组专有方法,静态 array<int> 调用报 member key receiver 语义错;把 auto(或 array<int>)声明改为 var 即可。
  3. 0——字面量右操作数使 + 产出空数组(RC0 缺陷)。把 [3] 先赋给变量再相加,或改用 x += [3]。
  4. l.length 是 2(条目数);l[1] 键不存在,fatal 终止;l[-2] 同样 fatal(负号不是尾部语义);l.contains(2) 得 false——contains 查的是值不是键,值集合是 {10, 30},2 不在其中(若参数类型与值类型不符,如 l.contains("x"),则直接 [sem] 报类型不匹配)。
  5. 静态容器元素类型推导在 checked 阶段丢失,第二个下标的 receiver 失去数组身份;auto r 重新绑定出数组类型所以可行。静态取行是副本,改 r[0] 不影响 mm;var 矩阵取行共享,改 r[0] 即改原矩阵,且 var 连链式索引都可直接用。
  6. var→var 是共享引用,调用方可见;array<int> 传 var 形参时发生静态→动态转换,函数内拿到的是副本,调用方不可见。

编程练习

练习 1:手写排序

编写 sort_int.xl:写 func sortInt(array<int> a),用冒泡或插入排序就地升序排列(利用共享语义,无需返回)。用 [5, 2, 8, 1, 9, 3] 验证,排序后逐元素打印。

参考思路:双重循环:外层 i 从 0 到 n-2,内层 j 从 0 到 n-2-i,a[j] > a[j+1] 时三行交换(静态数组无 swap,手写 temp 交换)。

练习 2:切片函数

编写 slice.xl:写 func slice(var a, int from, int to) 返回新数组,含 from 不含 to,支持负参数(-1 表示末尾)。用 [10, 20, 30, 40, 50] 验证 slice(a, 1, 3) 与 slice(a, -2, 0)(0 约定为"到末尾")。

参考思路:先把负参数转正:from < 0 时 from += a.length;to 为 0 时视为 a.length;然后 while i < to 逐一下标 push 进结果 var 数组。

练习 3:稀疏台账

编写 ledger.xl:用 list<string> 模拟"只记录了部分天数"的体温台账(键为第几天,只记第 3、7、12 天),遍历时按 k=>v 打印,再演示 contains 按"值"查询(查有没有记过 "37.2")。

参考思路:字面量 [3: "36.5", 7: "37.2", 12: "36.8"];for k, v in t 打印;contains("37.2") 得 true。体会"contains 查值不查键"在本场景的用法。

练习 4:矩阵工具

编写 matrix.xl:用 var 数组构造 3×3 单位矩阵并打印;再写 func matMul(var A, var B) 计算两矩阵相乘返回新矩阵,用两个 2×2 矩阵验证。

参考思路:var m = [] 三行 push([0, 0, 0]),然后 auto r0 = m[0]; r0[0] = 1 依次做对角线(var 取行共享,写回生效)。相乘用三重循环,A 的第 i 行、B 的第 j 列对应元素乘积累加;取行后用下标取元素即可。