按字符还是按字节?string 命名空间 44 个函数,本章一个不落地全部实测,顺便修正"字符串没有 split"的旧印象。
前两章打下容器基础,本章专攻文本。xlang 的字符串操作集中在 string 命名空间——注册清单里整整 44 个函数,从 len 到 splitLines 本章全部实测一遍。先立两个总纲:第一,xlang 字符串是 UTF-8 编码,"长度"默认按字符(Unicode 码点)计,不按字节;第二,字符串没有下标运算——第 16、17 章已发现 s[0] 不可用,本章复核并给出官方替代 charAt。至于曾在语法导览里出现的 string.split,实测确认:split/join 都存在,第 16 章"没有内建 split"的旧结论仅对数组成立(该章拾遗已同步勘误)。
理解 UTF-8 下 len 与 byteLen 的分工;掌握 charAt/byteAt/chars/bytes 四种取字符方式;会用两种 for-in 遍历字符串;背下 string 命名空间函数的分类地图(查找/判断/匹配/大小写/修剪/删改/截取/分割拼接);掌握 split/join 的形态与限制;理解 + 拼接规则与互转函数;建立"不可变字符串每次拼接都分配新串"的成本意识。
xlang 字符串内部是 UTF-8 字节序列,但绝大多数 API 的计数单位是字符(Unicode 码点)。两个长度函数的分工由此而来:
println(string(string.len("hello"))) // 5 println(string(string.len("你好世界"))) // 4:中文按字符计,不是 12 println(string(string.byteLen("你好"))) // 6:每个汉字 UTF-8 占 3 字节 println(string(string.byteLen("hello"))) // 5:ASCII 字符 1 字节 println(string(string.isUtf8("你好"))) // true:校验编码有效性
记忆法:给人看的用 len(字符数),给机器/协议的用 byteLen(字节数)。本章后续的 charAt、slice、reverse 等,全部按字符工作——这是 xlang 对中文用户最友好的设计决策。
先复核第 16、17 章的发现:字符串不能用下标。静态字符串直接编译报错,var 字符串则静默给出 null:
官方替代是四个函数,按字符与按字节各一对:
println(string.charAt("hello", 1)) // e:按字符取下标 1 println(string.charAt("你好", 1)) // 好:中文同样按字符 println(string(string.byteAt("A", 0))) // 65:按字节取值,返回 int println(string(string.byteAt("你", 0))) // 228:"你"的首字节 auto cs = string.chars("你好a") // 整串拆成字符数组 println(string(cs.length)) // 3 println(cs[0] as string) // 你(动态数组,元素是 var) auto bs = string.bytes("你") // 整串拆成字节数组 println(string(bs.length)) // 3
另一个高频错觉是 .length——字符串没有这个属性:静态字符串写 s.length 报 [checked] class field receiver is not a class value,var 字符串写 v.length 则静默得到 0(缺陷)。长度只有一条路:string.len(s)。
静态字符串可直接 for-in,单变量逐字符,双变量得 字符索引, 字符——注意 i 是字符序号,不是字节偏移:
for ch in "a你b" println(ch as string) // a 你 b end for i, ch in "a你b" println(string(i) + ":" + (ch as string)) // 0:a 1:你 2:b(i 是字符序号) end
但 var 字符串不可遍历:for ch in v(v 是 var)在运行期抛 uncaught TypeError: for in value must be array, list, set or dict。var 世界进静态世界的老规矩——先 as:string s = v as string 再遍历,或用 string.chars(v as string) 转数组。
六个查找函数全部支持可选的 ignoreCase 尾参(默认 false):
println(string(string.contains("hello world", "world"))) // true println(string(string.contains("hello world", "WORLD", true))) // true:忽略大小写 println(string(string.indexOf("hello", "l"))) // 2:首个出现的字符下标 println(string(string.indexOf("hello", "z"))) // -1:找不到 println(string(string.lastIndexOf("hello", "l"))) // 3:最后一次出现 println(string(string.count("hello world", "o"))) // 2:出现次数 println(string(string.startsWith("hello", "he"))) // true println(string(string.endsWith("hello", "lo"))) // true println(string(string.hasAny("hello", "xyz"))) // false:集合中任一字符出现? println(string(string.hasAny("hello", "xl"))) // true
两个判空函数分工明确:isEmpty 只看长度,isBlank 把全空白也算"空":
println(string(string.isEmpty(""))) // true println(string(string.isEmpty(" "))) // false:空格也是字符 println(string(string.isBlank(" "))) // true:全空白 println(string(string.isBlank(""))) // true:空串也算 blank
精确比较用 compare / equals(同样带 ignoreCase),或直接用运算符——字符串按字典序逐字符比较:
println(string(string.compare("abc", "abd"))) // -1:左小右大 println(string(string.compare("Abc", "abc", true))) // 0 println(string(string.equals("Abc", "abc"))) // false println(string("abc" < "abd")) // true println(string("abc" < "ab")) // false:公共前缀后更长者大 println(string("你" > "a")) // true:按码点比较
模糊匹配三件套,实测语义如下:
println(string(string.like("hello", "h*"))) // true:* 是通配符 println(string(string.like("hello", "*ell*"))) // true println(string(string.like("hello", "h%"))) // false:% 不是通配符(非 SQL) println(string(string.sim("abc", "abd"))) // 0.6666666666666667:相似度 0~1 println(string(string.sim("color", "colour"))) // 0.8333333333333334 println(string(string.approx("abc", "abc"))) // true println(string(string.approx("abcde", "abcd"))) // false:连 0.8 相似都不算
结论:like 用 * 做通配;sim 返回 0~1 的相似度评分;approx 在 RC0 实测只在完全相等时返回 true(阈值等同精确匹配),需要"差不多"判断请用 sim 自设阈值(如 sim > 0.8)。
println(string.lower("Hello世界")) // hello世界:仅影响 ASCII println(string.upper("Hello世界")) // HELLO世界 println("[" + string.trim(" hi ") + "]") // [hi]:默认去两端空白 println("[" + string.trim("xxhixx", "x") + "]") // [hi]:可传自定义字符集 println("[" + string.ltrim(" hi ") + "]") // [hi ] println("[" + string.rtrim(" hi ") + "]") // [ hi]
filter 的名字极具误导性:它是"从字符串中删除集合内的字符",不是"保留":
println(string.filter("hello world", "lo")) // he wrd:l 和 o 被删光
想"只保留某些字符",把思路反过来:用 chars 拆成数组,遍历判断后 join(18.9 节)。
println(string.replace("hello", "l", "L")) // heLLo:全部替换 println(string.replace("aaa", "aa", "b")) // ba:从左到右、非重叠 println(string.remove("hello", "l")) // heo:删除全部出现 println(string.removeAt("hello", 1, 2)) // hlo:按下标区间删 println(string.insert("hello", 1, "X")) // hXello println(string.insert("hello", 5, "!")) // hello!:可在末尾插 println(string.repeat("ab", 3)) // ababab println(string.reverse("hello")) // olleh println(string.reverse("你好啊")) // 啊好你:按字符反转,不碎字节
注意 remove/replace 都是"全部出现"语义,没有"只替换第一处"的开关;下标类操作(removeAt/insert)全部按字符计。
println(string.slice("hello", 1, 3)) // ell:从下标 1 起取 3 个字符 println(string.slice("hello", 2)) // llo:count 默认 -1 = 取到末尾 println(string.slice("hello", -2)) // lo:负 start 从尾部倒数 println(string.slice("你好世界", 1, 2)) // 好世:按字符切 println(string.left("hello", 2)) // he println(string.right("hello", 2)) // lo println(string.mid("hello", 1, 3)) // ell:与 slice 等价(同为 0 基)
家族分工:slice/mid 是通用截取(0 基、负 start、count 省略到末尾);left/right 是"取头取尾"的便捷写法。没有 substr 这个名字——找它的人请直接转向 slice。
实测确认:split 与 join 都存在(语法导览用的就是它们)。split 返回动态数组,元素是 var,取用前须 as string;空字段保留:
auto parts = string.split("a,b,c", ",") println(type(parts)) // array(动态数组) println(string(parts.length)) // 3 println(parts[0] as string) // a auto csv = string.split("a,,c", ",") println(string(csv.length)) // 3:空字段保留 println("[" + (csv[1] as string) + "]") // [] auto lines = string.splitLines("one\ntwo\r\nthree") println(string(lines.length)) // 3:\n 与 \r\n 都认 println(lines[1] as string) // two
join 有一个硬限制:只接受静态数组,var 数组传不进去(as array 也不被支持,报 [sem] as 当前只支持基础值、class、native-backed class 和 union 目标类型):
auto sa = ["m", "n"] // 静态 array<string> println(string.join(sa, "-")) // m-n println(string.join(sa)) // mn:sep 默认空串 var va = ["x", "y"] string joined = "" for v in va joined += (v as string) + "," // var 数组的 join 变通:手工循环 end println(joined) // x,y,
拼接规则第 5 章已立:+ 两侧必须都是 string,违者 [sem] 字符串拼接两侧都必须是 string。跨类型先 string():
println(string(123) + " " + string(4.5) + " " + string(true)) // 123 4.5 true println(string(int("42") + 1)) // 43 println(string(float("2.5") * 2.0)) // 5.0 string s = "a" s += "b" // += 循环拼接可用 println(s) // ab
解析方向有一个坑要防:int() 只认整数字面量形态,解析失败抛 ValueError(运行期异常,可 try/catch):
try int n = int("3.99") // 小数字符串也不行! println(string(n)) catch e println("caught: " + (e.kind as string)) // caught: ValueError end // int("bad")、float("bad") 同样抛 ValueError;要截断先 float() 再 int()
最后是 string.format 的初探(完整格式化迷你语言是第 19 章的主场):
println(string.format(42)) // 42:空格式串原样输出 println(string.format(42, "05d")) // 00042:宽度+补零 println(string.format(255, "x")) // ff:十六进制 println(string.format(3.14159, ".2f")) // 3.14:两位小数 println(string.format(0.5, "%.1f")) // 50.0%:注意!% 是百分比标志
xlang 字符串不可变——每次"修改"都产生新串。用 xl cgen 看一个 100 次循环的 s += "x":循环体里是一次 xl6_xrt_str_concat(*__xl6_string_slot_18, "x") 调用——每一轮都拼接出一个新字符串对象,旧串等待引用计数归零后回收。n 次循环意味着 n 次新分配、累计 O(n²) 的拷贝量。短串无感,长串构建请用标准库 Buffer 类(原地追加,最后一次性取串):
auto buf = Buffer() for i in range(100) buf.append("x") // 原地追加,无中间新串 end string s = buf.text() // 一次性取出 println(string(string.len(s))) // 100
注意 Buffer 要用 auto(或 Buffer 类型)声明持有——class 实例的方法调用需要静态 receiver。Buffer 的完整能力(appendBytes/insert/reserve)在第 36 章标准库全景展开。
本章所有"按字符"的 API——len、charAt、slice、reverse、for-in——计数单位都是 Unicode 码点。对中英文混排这与直觉完全一致;但要意识到码点 ≠ 视觉字位:组合字符(如 é 写成 e + ́ 两个码点)会被计为 2 个字符。日常文本处理无需关心;做严格的编辑器级文本时才需要更上层的 grapheme 概念。字节世界的大门一直开着:byteLen/byteAt/bytes 三个 API 专门服务编码、协议与二进制场景。
printf 用户看到 string.format(3.14159, "%.2f") 会期待 3.14——实测输出 314.16%。xlang 的浮点格式迷你语言里,% 不是格式引导符,而是百分比标志(数值乘 100、末尾补 %);整数格式对 % 前缀则宽容忽略。正确写法是去掉 %:".2f"。这个与 printf 直觉相反的设计在第 19 章会连同 f-string 一起系统讲解,此处先立警示牌。
以下报错全部来自真实编译器输出(文件路径已缩短)。
原因:字符串没有下标运算;var 字符串 v[0] 更隐蔽——静默返回 null。正确做法:string.charAt(s, i) 按字符取,string.byteAt(s, i) 按字节取。
原因:length 是数组/list/dict 的属性,不是字符串的;var 字符串 v.length 静默得 0(RC0 缺陷)。正确做法:string.len(s);字节数用 string.byteLen(s)。
原因:int() 只接受整数字面量形态的字符串,"3.99"、"bad" 都抛 ValueError(可 try/catch)。正确做法:小数字符串先 float() 再 int() 截断;不可信输入一律 try/catch 兜底。
原因:join 的形参类型是 array——静态数组、split / splitLines / chars 的产物(静态类型恰为 array)都可以直接传入;唯独 var 不行:直传报 dynamic var argument 错,as array 也不在 as 支持的目标类型里。正确做法:var 数组的元素 as string 后 push 进静态 array<string> 再 join,或手工循环拼接。
| 分类 | 函数 | 要点 |
|---|---|---|
| 长度 | len / byteLen / isUtf8 | len 按字符,byteLen 按字节;无 .length 属性 |
| 取字符 | charAt / byteAt / chars / bytes | 无下标运算;chars/bytes 返回动态数组 |
| 查找 | contains / indexOf / lastIndexOf / count / startsWith / endsWith / hasAny | 均有 ignoreCase 尾参;indexOf 找不到得 -1 |
| 判断 | isEmpty / isBlank | blank = 空串或全空白 |
| 比较 | compare / equals / 运算符 < == > | 字典序按码点;compare 返回 -1/0/1 |
| 模糊 | like / sim / approx | like 用 * 通配;approx 实测等同精确相等,用 sim 自设阈值 |
| 大小写 | lower / upper | 仅影响 ASCII |
| 修剪 | trim / ltrim / rtrim / filter | trim 可传字符集;filter 是"删除"语义 |
| 删改 | replace / remove / removeAt / insert | replace/remove 作用于全部出现 |
| 截取 | slice / left / right / mid | 0 基、负 start、count 默认到末尾;无 substr |
| 生成 | repeat / reverse / padLeft / padRight / padCenter | reverse 按字符不碎中文 |
| 分割拼接 | split / splitLines / join / + | split 得动态数组(元素 var);join 只收静态数组;+ 两侧必须 string |
| 转换 | string() int() float() format | int("3.99") 抛 ValueError;浮点格式串 % 是百分比 |
| 性能 | += 循环 / Buffer | 不可变串每次拼接新分配;长串用 Buffer |
编写 csvstat.xl:给定多行 CSV 文本(字面量三行:"name,age,city" 加两行数据),用 splitLines 拆行、split 拆字段,打印每行字段数,并统计 age 列的平均值(int() 解析记得 try/catch)。
参考思路:for line in string.splitLines(text),行内 string.split(line as string, ",");跳过表头行(首行);年龄累加后除以数据行数;float 平均值用 float(总和) / float(行数)。
编写 wordfreq2.xl:把第 17 章练习 1 的手工扫描分词改为 string.split(text, " "),用 var dict 统计词频,按插入序打印 "词 次数"。再进阶一步:统计前先把每个词 lower + trim,统一大小写。
参考思路:for w in string.split(text, " "),string ws = string.lower(w as string),isEmpty 跳过空字段(连续空格会产生),计数用 if d[ws] == null then d[ws] = 1 else d[ws] = d[ws] + 1 end。
编写 camel.xl:写 func toCamel(string snake) as string,把 "user_login_count" 转成 "userLoginCount";再写 func toSnake(string camel) as string 做反向转换(遍历字符,遇大写插 _ 并转小写)。
参考思路:toCamel 用 split 按 "_" 拆,首段原样,其余段 string.upper(string.left(w, 1)) + string.slice(w, 1) 拼接(注意 join 只收静态数组,手工循环更直接);toSnake 逐字符判断 ch == string.upper(ch) and ch != string.lower(ch) 即大写字母。