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

set 与 dict

一个管"有没有",一个管"键到值的映射"——加上 var 与静态两套行为的完整对照,本章把 xlang 的关联容器一次讲透。

本章导读

数组回答"第几个是什么",set 回答"在不在里面",dict 回答"这个名字对应什么"。set<T> 支持完整的数学集合运算;dict 则是 xlang 里用途最广的容器——配置包、计数器、JSON 式数据都靠它。本章的重头戏不是罗列方法,而是三组对照:var dict 与静态 dict<T> 读缺失键的双套行为(null vs fatal)、两种遍历顺序(var dict 插入序、静态 dict 与 set 的哈希序)、运算符的动静差异(+ 只有静态可用、- 只有 var 可用——实测就是这么不对称)。

本章学习目标

掌握 set 的声明、四种集合运算与子集比较;理解 set 遍历顺序不可依赖;掌握 dict 的 string 键规则与 dict<T> 中 T 的含义;背下 var/静态 dict 缺键读取的双套行为与各自的防御写法;会用点号访问与安全索引 ?[...];理解两种遍历序与删除重插的影响;掌握 dict 运算符的动静差异;会用共享语义在函数间传递 dict。

17.1 set 的声明与初始化

set 用 set<T> 声明,字面量写法是 set<T>{...},重复元素自动去重:

setdecl.xl
set<int> a = set<int>{1, 2, 3, 2, 1}
println(string(a.length))   // 3:去重后只剩 1, 2, 3
set<string> tags = set<string>{"xlang", "rc0"}

注意一个限制:set 目前只有静态形态。把 set 字面量装进 var 会被 checked 阶段拒绝——var vs = set<int>{1, 2} 报 [checked] 当前 C 生成暂不支持将该表达式打包为 var。本章后面讲 set 的运算与遍历,都基于静态 set<T>。

17.2 集合运算与子集比较

四种集合运算全部可用,产出新 set:

setops.xl
set<int> a = set<int>{1, 2, 3}
set<int> b = set<int>{2, 3, 4}
auto u = a | b   // 并集:length 4
auto i = a & b   // 交集:length 2(2, 3)
auto d = a - b   // 差集:length 1(只在 a 的 1)
auto s = a ^ b   // 对称差:只在一方出现的元素
println(string(s.length))                        // 2
println(string(s.contains(1)) + " " + string(s.contains(4)))   // true true

子集比较用小于等于号族,语义与数学一致:

subset.xl
set<int> small = set<int>{1}
set<int> big   = set<int>{1, 2, 3}
println(string(small <= big))   // true:子集
println(string(small < big))    // true:真子集
println(string(big >= small))   // true
println(string(big > small))    // true

但有一个实测缺陷:set 字面量直接写进比较表达式会得到错误结果——set<int>{1} < big 返回 false。先把字面量赋给变量再比较才正确(与第 16 章数组 == 的字面量崩溃同源:字面量操作数的容器运算在 RC0 都不可靠)。

17.3 set 成员方法与遍历

setmethods.xl
set<int> s = set<int>{1, 2}
s.add(3)
s.add(3)                    // 重复添加无副作用
println(string(s.length))       // 3
println(string(s.contains(2)))   // true:查元素
println(string(s.remove(2)))     // true:删除成功
println(string(s.remove(9)))     // false:不存在
s.clear()
println(string(s.length))       // 0

遍历用 for-in 单变量取元素,但顺序不可依赖——实测插入 {30, 5, 100, 2},遍历出来的却是:

setiter.xl
set<int> vs = set<int>{30, 5, 100, 2}
for v in vs
    println(string(v))   // 5  2  30  100(哈希序,不是插入序也不是排序)
end

需要稳定顺序时,把元素转入数组再处理(排序写法见第 16 章练习 1)。set 的价值在 membership 判断与集合运算,不在顺序。

17.4 dict 的声明与键规则

dict 是键值映射。RC0 的键只支持 string,整数键字面量在 checked 阶段直接被拒:

# bad.xl 内容:var bad = {1: "one"} > xl run bad.xl bad.xl:1:11: [checked] 当前 C 生成 dict 字面量键只支持 string

正确形态——var 动态 dict 与静态 dict<T>:

dictdecl.xl
var d = {"name": "xlang", "ver": "rc0"}     // 动态 dict:值可异构
dict<int> scores = {"alice": 90, "bob": 85}   // 静态 dict:值同质
var empty1 = {}
dict<int> empty2 = {}                       // 空 dict 两种写法

最容易误解的一点:静态声明 dict<int> 里的 int 是值的类型,不是键——键永远是 string。需要整数键时改用第 16 章的 list<T>,或把数字转成字符串键(注意 "01" 与 "1" 是不同的键,规范化要自己保证)。

17.5 读取与缺键:两套行为

这是 dict 最重要的一对差异,直接决定健壮性写法:

read.xl
var vd = {"a": 1}
println(string(vd["missing"] == null))   // true:var dict 读缺失键得 null
println(type(vd["missing"]))           // null

dict<int> sd = {"a": 1}
println(string(sd["a"]))               // 1

而静态 dict 读缺失键是进程直接终止(退出码 3,无任何输出,try/catch 拦不住)——println(string(sd["missing"])) 整段程序就此结束。对应的防御写法分别是判 null 与先 contains:

guard.xl
// var 风格:判 null
if vd["k"] != null then
    println(string(vd["k"]))
end
// 静态风格:先 contains
if sd.contains("k") then
    println(string(sd["k"]))
end
// 通用风格:安全索引 ?[...](两种 dict 都支持,缺键得 null)
println(string(sd?["missing"] == null))   // true
println(string(sd?["a"]))                // 1

三个补充事实,全部实测:① ? 的写法是 d?["k"],中间没有点;② contains 与 in 查的都是键(与 list 查值相反!),且键必须是 string——sd.contains(1) 报 [sem] dict.contains key must be string,1 in sd 报 [checked] dict membership requires a string key;③ var dict 还可以用点号访问:

dot.xl
var d = {"name": "xlang"}
println(d.name)        // xlang:点号访问(仅 var dict)
println(string("a" in sd))     // true
println(string("z" in sd))     // false

静态 dict 用点号访问会报 [sem] member key receiver must be var or dynamic dict——它只认方括号。

17.6 写入、删除与遍历顺序

写入就是方括号赋值,复合赋值对值直接生效:

write.xl
var d = {"count": 10}
d["count"] += 5
println(string(d["count"]))   // 15
d["new"] = 1                 // 新键直接插入
println(string(d["new"]))     // 1

遍历顺序是本章第二组对照:var dict 保留插入序;静态 dict<T> 是哈希序。同样五个键,两种 dict 的遍历结果:

order.xl
var d = {"apple": 1, "banana": 2, "cherry": 3, "date": 4, "fig": 5}
for k, v in d
    println(k as string)   // apple banana cherry date fig(插入序)
end
dict<int> sd = {"apple": 1, "banana": 2, "cherry": 3, "date": 4, "fig": 5}
for k, v in sd
    println(k)             // date banana cherry fig apple(哈希序)
end

var dict 的插入序还有一条细则:删除后重新插入的键排到末尾:

reinsert.xl
var d2 = {"b": 2, "a": 1, "c": 3}
// 先遍历:b a c
d2.remove("b")
d2["b"] = 20
// 再遍历:a c b(重插排尾)

删除方法 remove(k) / take(k) / clear() 两种 dict 都有,语义与数组一致:remove 返回 bool,take 返回被删的值:

del.xl
dict<int> a = {"x": 1, "y": 2}
println(string(a.remove("x")))   // true
println(string(a.take("y")))     // 2
println(string(a.length))       // 0

var dict 的插入序让它可以直接当"有序记录"用(比如词频统计按首次出现顺序输出);静态 dict 的遍历顺序则与 set 一样不可依赖。

17.7 dict 运算符的动静差异

dict 的 +、+=、-、== 在 var 与静态形态下的可用性是不对称的,全部实测:

运算符var dict静态 dict<T>
+(合并,右侧覆盖同键)✗ 静默产出空 dict(缺陷)✓
+=(就地合并)✗ 编译报错✓
-(按键移除)✓✗ 报 numeric operator 错
==(相等性比较)✓✓
dictops.xl
dict<int> a = {"x": 1, "y": 2}
dict<int> ov = {"y": 20, "z": 3}
auto b = a + ov
println(string(b.length))          // 3
println(string(b["y"]))             // 20:右侧覆盖同键
a += ov
println(string(a.length) + " " + string(a["y"]))   // 3 20

var v1 = {"x": 1, "y": 2}
var rm = {"x": 0}
var c = v1 - rm                     // var 的 - 可用:移除键 x
println(string(c.contains("x")) + " " + string(c.length))   // false 1
var v2 = v1 + rm                    // var 的 +:静默得空 dict(缺陷,别用)
println(string(v2.length))          // 0

var dict 想合并就手写循环(for k, v in ov { d[k] = v });var 的 += 则直接编译报错 [sem] var compound assignment target is not supported: op +=, left var, right var。

最后一条重要纪律:keys() / values() / entries() 方法不存在。更隐蔽的是对 var dict 写 d.keys 不报错——它被当成点号访问缺失键,静默返回 null。需要键列表就自己收集:

keys.xl
var keys = []
for k, v in vd
    keys.push(k)
end

遍历的三种形态回顾:单变量得键,双变量得 键, 值;var dict 双变量里的 k 是 var,打印或拼接前要 k as string(静态 dict 的 k 直接就是 string)。

17.8 传递与共享:dict 的引用语义

第 16 章在数组上得出的结论对 dict 同样成立:var dict 传参与赋值都是共享引用,函数内修改对调用方可见:

sharing.xl
func touch(var d)
    d["touched"] = 1
end
var cfg = {"a": 1}
touch(cfg)
println(string(cfg["touched"]))   // 1:函数内的写入可见

var d1 = {"k": 1}
var d2 = d1
d2["k"] = 99
println(string(d1["k"]))         // 99:赋值不产生副本

工程含义:把 dict 当"配置包"在函数间传递非常顺手,但任何拿到它的函数都能改它。需要隔离时手工复制:var copy = {}; for k, v in src { copy[k] = v }。

深入细节

拾遗 1:宽严哲学的一对样本

var dict 缺键得 null、静态 dict 缺键 fatal,是 xlang"动态宽容、静态严格"哲学的最典型样本。var 面向探索式脚本:数据结构边走边长,缺键是常态,返回 null 让探测式写法(先读再判)成立。静态 dict<T> 面向契约式代码:键集合在设计上应已知,缺键意味着逻辑漏洞,fatal 把问题暴露在开发期,而不是让它带着 null 流到下游。选择哪种 dict,本质是在选择"运行时容错"还是"开发期暴露"。

拾遗 2:两种遍历序的实现

var dict 底层在哈希桶之外维护了一条插入顺序的链表:插入时追加到链尾,删除时从链上摘除,重新插入再次排到链尾——这正是 17.6 观察到的行为,哈希桶负责 O(1) 查找,链表负责有序遍历。静态 dict<T> 与 set 没有这条链表,遍历只能暴露哈希桶的物理顺序(本章的 date banana cherry fig apple、set 的 5 2 30 100)。推论:需要"有序字典"语义的场合,即便值类型同质,也应优先选 var dict,而不是静态 dict<T>。

拾遗 3:非 string 键的出路与展望

键类型限制在 string,根因是 cgen 目前只为 string 键生成哈希与相等性代码。需要非 string 键时的三条出路:① 整数键用 list<T>(稀疏友好,但注意它没有键存在判断);② 数字键转字符串(自己保证 01 与 1 的规范化);③ 复合键手工编码("x,y" 形式)。自定义类型作键需要哈希与相等性协议,属于后续版本的键类型扩展方向,第 24 章讲协议方法时会再谈。

⚠️ 常见错误

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

坑 1:set 字面量直接参与比较
# bad.xl 内容: # set<int> big = set<int>{1, 2, 3} # println(string(set<int>{1} < big)) > xl run bad.xl false(错误结果,编译器不报错!)

原因:RC0 对字面量操作数的容器比较不可靠(数组 == 甚至是崩溃)。正确做法:先把字面量赋给 set 变量再比较:set<int> one = set<int>{1}; println(string(one < big)) 得 true。

坑 2:以为有 keys(),拿到静默的 null
# bad.xl 内容: # var vd = {"a": 1} # println(vd.keys) > xl run bad.xl null(不报错——被当成读取缺失键 "keys")

原因:keys/values/entries 方法不存在;点号访问缺失键静默得 null,后续的遍历或下标操作才会爆出莫名其妙的错。正确做法:键列表用 for k, v in d 自己收集。

坑 3:静态 dict 读缺失键 fatal
# bad.xl 内容: # dict<int> sd = {"a": 1} # println(string(sd["missing"])) > xl run bad.xl (无任何输出,退出码 3)

原因:把 var dict 的探测习惯带到静态 dict 上,程序直接终止,try/catch 拦不住。正确做法:先 contains / in 判断,或改用安全索引 sd?["k"](缺键得 null)。

坑 4:非 string 键
# bad.xl 内容:var bad = {1: "one"} > xl run bad.xl bad.xl:1:11: [checked] 当前 C 生成 dict 字面量键只支持 string

原因:RC0 的 dict 键只支持 string,dict<int> 的 int 是值的类型。正确做法:整数键改用 list<T>,或把键转成字符串并自行规范化。

本章小结

能力set<T>var dictdict<T>
元素/键同质 T 元素string 键,异构值string 键,同质 T 值
缺键读—nullfatal(可用 ?["k"] 得 null)
遍历序哈希序(不可依赖)插入序(重插排尾)哈希序(不可依赖)
点号访问—✓✗(member key receiver)
contains / in查元素查键(须 string)查键(须 string)
运算| & - ^ 及子集比较- 与 ==(+ 缺陷、+= 报错)+ += ==(无 -)
成员方法add contains remove clearcontains remove take clear(无 keys/values/entries)
var 形态✗(不能打包为 var)✓—
传递语义共享引用,函数内修改对调用方可见

复习题

✍️ 先遮住答案想一想
  1. set 的 |、&、-、^ 分别是什么运算?{1,2,3} ^ {2,3,4} 的结果?
  2. 为什么 set<int>{1} < big 可能得到错误结果?正确写法是什么?
  3. var d = {"a": 1},d["b"]、d?["b"]、d.keys 各自的结果是什么?
  4. dict<int> 里的 int 指什么?想以整数为键应该用什么容器?
  5. var dict 与静态 dict<T> 的遍历顺序有何不同?删除一个键再重新插入,var dict 的顺序怎样变化?
  6. dict 的 +、- 两个运算符在 var 与静态形态下分别表现如何?
  7. 函数 func f(var d) 内 d["x"] = 1,调用方的 var dict 会不会变?
参考答案
  1. 并、交、差、对称差;对称差是"只在一方出现"的元素,结果为 {1, 4}。
  2. RC0 字面量直接参与比较有缺陷,得 false;先把字面量赋给 set 变量再比较。
  3. d["b"] 得 null;d?["b"] 同样得 null(安全索引);d.keys 不报错但也得 null——它被当成点号访问缺失键,keys() 方法并不存在。
  4. int 是值的类型,键永远是 string;整数键改用 list<T>。
  5. var dict 保留插入序,静态 dict 是哈希序不可依赖;var dict 删除重插的键排到遍历顺序的末尾。
  6. + 只有静态可用(var 用 + 静默得空 dict,+= 编译报错);- 只有 var 可用(静态用 - 报 numeric operator 错)。== 两者皆可。
  7. 会变。容器是共享引用语义,函数内修改对调用方可见。

编程练习

练习 1:词频统计

编写 wordfreq.xl:给定一句话(字符串),按空格切成单词(用第 16 章的逐字符扫描法),用 var dict 统计每个单词出现次数,按插入序打印 "词 次数"。

参考思路:逐字符扫描,遇空格结算当前单词;计数用探测式写法——if d[w] == null then d[w] = 1 else d[w] = d[w] + 1 end。var dict 读缺键得 null 在这里正合适。

练习 2:集合工具

编写 settools.xl:写 func intersect(array<int> a, array<int> b) 返回两数组的公共元素数组(用 set<int> 的 & 运算);再写 func unionCount(array<int> a, array<int> b) 返回并集的元素个数。

参考思路:先把两个数组的元素逐个 add 进两个 set<int>;& 得交集后 for v in s 逐个 push 进结果数组(顺序是哈希序,若要稳定可再排序);并集个数用 auto u = s1 | s2 再取 u.length。

练习 3:配置合并器

编写 merge.xl:写 func merge(var base, var override) 返回新 dict,override 的键覆盖 base;要求不修改两个入参(利用 17.8 的复制技巧,不要用 var 的 +)。

参考思路:var out = {};先 for k, v in base 全量复制,再 for k, v in override 覆盖写入;返回 out。用 base = {"host": "a", "port": 80}、override = {"port": 8080} 验证,并打印 base 确认未被改动。

练习 4:分组统计

编写 group.xl:给定 var 数组 ["ant", "bear", "cat", "ape", "bat"],用 var dict 按首字母分组,按插入序打印每组(a: ant ape 等)。

参考思路:先 string sw = w as string 取出静态字符串;字符串在 RC0 没有下标取字符(s[0] 报 [hir] 错),首字符用 for c in sw 取第一个 c 后 break;然后 if d[key] == null then d[key] = [] end,再 auto g = d[key]; g.push(sw)——取出的数组与 dict 内共享(第 16 章结论),push 直接生效。注意拼接时写 (k as string) + ...,as 的优先级比 + 低。