一个管"有没有",一个管"键到值的映射"——加上 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。
set 用 set<T> 声明,字面量写法是 set<T>{...},重复元素自动去重:
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>。
四种集合运算全部可用,产出新 set:
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
子集比较用小于等于号族,语义与数学一致:
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 都不可靠)。
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},遍历出来的却是:
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 判断与集合运算,不在顺序。
dict 是键值映射。RC0 的键只支持 string,整数键字面量在 checked 阶段直接被拒:
正确形态——var 动态 dict 与静态 dict<T>:
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" 是不同的键,规范化要自己保证)。
这是 dict 最重要的一对差异,直接决定健壮性写法:
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:
// 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 还可以用点号访问:
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——它只认方括号。
写入就是方括号赋值,复合赋值对值直接生效:
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 的遍历结果:
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 的插入序还有一条细则:删除后重新插入的键排到末尾:
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 返回被删的值:
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 一样不可依赖。
dict 的 +、+=、-、== 在 var 与静态形态下的可用性是不对称的,全部实测:
| 运算符 | var dict | 静态 dict<T> |
|---|---|---|
| +(合并,右侧覆盖同键) | ✗ 静默产出空 dict(缺陷) | ✓ |
| +=(就地合并) | ✗ 编译报错 | ✓ |
| -(按键移除) | ✓ | ✗ 报 numeric operator 错 |
| ==(相等性比较) | ✓ | ✓ |
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。需要键列表就自己收集:
var keys = [] for k, v in vd keys.push(k) end
遍历的三种形态回顾:单变量得键,双变量得 键, 值;var dict 双变量里的 k 是 var,打印或拼接前要 k as string(静态 dict 的 k 直接就是 string)。
第 16 章在数组上得出的结论对 dict 同样成立:var dict 传参与赋值都是共享引用,函数内修改对调用方可见:
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 }。
var dict 缺键得 null、静态 dict 缺键 fatal,是 xlang"动态宽容、静态严格"哲学的最典型样本。var 面向探索式脚本:数据结构边走边长,缺键是常态,返回 null 让探测式写法(先读再判)成立。静态 dict<T> 面向契约式代码:键集合在设计上应已知,缺键意味着逻辑漏洞,fatal 把问题暴露在开发期,而不是让它带着 null 流到下游。选择哪种 dict,本质是在选择"运行时容错"还是"开发期暴露"。
var dict 底层在哈希桶之外维护了一条插入顺序的链表:插入时追加到链尾,删除时从链上摘除,重新插入再次排到链尾——这正是 17.6 观察到的行为,哈希桶负责 O(1) 查找,链表负责有序遍历。静态 dict<T> 与 set 没有这条链表,遍历只能暴露哈希桶的物理顺序(本章的 date banana cherry fig apple、set 的 5 2 30 100)。推论:需要"有序字典"语义的场合,即便值类型同质,也应优先选 var dict,而不是静态 dict<T>。
键类型限制在 string,根因是 cgen 目前只为 string 键生成哈希与相等性代码。需要非 string 键时的三条出路:① 整数键用 list<T>(稀疏友好,但注意它没有键存在判断);② 数字键转字符串(自己保证 01 与 1 的规范化);③ 复合键手工编码("x,y" 形式)。自定义类型作键需要哈希与相等性协议,属于后续版本的键类型扩展方向,第 24 章讲协议方法时会再谈。
以下报错全部来自真实编译器输出(文件路径已缩短)。
原因:RC0 对字面量操作数的容器比较不可靠(数组 == 甚至是崩溃)。正确做法:先把字面量赋给 set 变量再比较:set<int> one = set<int>{1}; println(string(one < big)) 得 true。
原因:keys/values/entries 方法不存在;点号访问缺失键静默得 null,后续的遍历或下标操作才会爆出莫名其妙的错。正确做法:键列表用 for k, v in d 自己收集。
原因:把 var dict 的探测习惯带到静态 dict 上,程序直接终止,try/catch 拦不住。正确做法:先 contains / in 判断,或改用安全索引 sd?["k"](缺键得 null)。
原因:RC0 的 dict 键只支持 string,dict<int> 的 int 是值的类型。正确做法:整数键改用 list<T>,或把键转成字符串并自行规范化。
| 能力 | set<T> | var dict | dict<T> |
|---|---|---|---|
| 元素/键 | 同质 T 元素 | string 键,异构值 | string 键,同质 T 值 |
| 缺键读 | — | null | fatal(可用 ?["k"] 得 null) |
| 遍历序 | 哈希序(不可依赖) | 插入序(重插排尾) | 哈希序(不可依赖) |
| 点号访问 | — | ✓ | ✗(member key receiver) |
| contains / in | 查元素 | 查键(须 string) | 查键(须 string) |
| 运算 | | & - ^ 及子集比较 | - 与 ==(+ 缺陷、+= 报错) | + += ==(无 -) |
| 成员方法 | add contains remove clear | contains remove take clear(无 keys/values/entries) | |
| var 形态 | ✗(不能打包为 var) | ✓ | — |
| 传递语义 | 共享引用,函数内修改对调用方可见 | ||
编写 wordfreq.xl:给定一句话(字符串),按空格切成单词(用第 16 章的逐字符扫描法),用 var dict 统计每个单词出现次数,按插入序打印 "词 次数"。
参考思路:逐字符扫描,遇空格结算当前单词;计数用探测式写法——if d[w] == null then d[w] = 1 else d[w] = d[w] + 1 end。var dict 读缺键得 null 在这里正合适。
编写 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。
编写 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 确认未被改动。
编写 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 的优先级比 + 低。