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

字符串全操作

按字符还是按字节?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 的形态与限制;理解 + 拼接规则与互转函数;建立"不可变字符串每次拼接都分配新串"的成本意识。

18.1 字符串的本质:UTF-8 与"字符"

xlang 字符串内部是 UTF-8 字节序列,但绝大多数 API 的计数单位是字符(Unicode 码点)。两个长度函数的分工由此而来:

len.xl
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 对中文用户最友好的设计决策。

18.2 访问单个字符:没有下标

先复核第 16、17 章的发现:字符串不能用下标。静态字符串直接编译报错,var 字符串则静默给出 null:

# bad.xl 内容: # string s = "abc" # println(s[0]) > xl run bad.xl bad.xl:2:9: [hir] typed HIR value expression is missing its static type

官方替代是四个函数,按字符与按字节各一对:

charat.xl
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)。

18.3 遍历形态

静态字符串可直接 for-in,单变量逐字符,双变量得 字符索引, 字符——注意 i 是字符序号,不是字节偏移:

iter.xl
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) 转数组。

18.4 查找与判断

六个查找函数全部支持可选的 ignoreCase 尾参(默认 false):

find.xl
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 把全空白也算"空":

blank.xl
println(string(string.isEmpty("")))     // true
println(string(string.isEmpty(" ")))    // false:空格也是字符
println(string(string.isBlank("   ")))  // true:全空白
println(string(string.isBlank("")))     // true:空串也算 blank

18.5 比较与模糊匹配

精确比较用 compare / equals(同样带 ignoreCase),或直接用运算符——字符串按字典序逐字符比较:

cmp.xl
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:按码点比较

模糊匹配三件套,实测语义如下:

fuzzy.xl
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)。

18.6 大小写与修剪

case.xl
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 的名字极具误导性:它是"从字符串中删除集合内的字符",不是"保留":

filter.xl
println(string.filter("hello world", "lo"))   // he wrd:l 和 o 被删光

想"只保留某些字符",把思路反过来:用 chars 拆成数组,遍历判断后 join(18.9 节)。

18.7 替换、删改与生成

modify.xl
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)全部按字符计。

18.8 截取:slice 家族

slice.xl
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。

18.9 split / join / splitLines

实测确认:split 与 join 都存在(语法导览用的就是它们)。split 返回动态数组,元素是 var,取用前须 as string;空字段保留:

split.xl
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 目标类型):

join.xl
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,

18.10 拼接、互转与 format 初探

拼接规则第 5 章已立:+ 两侧必须都是 string,违者 [sem] 字符串拼接两侧都必须是 string。跨类型先 string():

conv.xl
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):

parse.xl
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 章的主场):

fmt.xl
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%:注意!% 是百分比标志

深入细节

拾遗 1:不可变性与 += 的 cgen 证据

xlang 字符串不可变——每次"修改"都产生新串。用 xl cgen 看一个 100 次循环的 s += "x":循环体里是一次 xl6_xrt_str_concat(*__xl6_string_slot_18, "x") 调用——每一轮都拼接出一个新字符串对象,旧串等待引用计数归零后回收。n 次循环意味着 n 次新分配、累计 O(n²) 的拷贝量。短串无感,长串构建请用标准库 Buffer 类(原地追加,最后一次性取串):

buffer.xl
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 章标准库全景展开。

拾遗 2:"字符"是 Unicode 码点

本章所有"按字符"的 API——len、charAt、slice、reverse、for-in——计数单位都是 Unicode 码点。对中英文混排这与直觉完全一致;但要意识到码点 ≠ 视觉字位:组合字符(如 é 写成 e + ́ 两个码点)会被计为 2 个字符。日常文本处理无需关心;做严格的编辑器级文本时才需要更上层的 grapheme 概念。字节世界的大门一直开着:byteLen/byteAt/bytes 三个 API 专门服务编码、协议与二进制场景。

拾遗 3:format 的 % 陷阱

printf 用户看到 string.format(3.14159, "%.2f") 会期待 3.14——实测输出 314.16%。xlang 的浮点格式迷你语言里,% 不是格式引导符,而是百分比标志(数值乘 100、末尾补 %);整数格式对 % 前缀则宽容忽略。正确写法是去掉 %:".2f"。这个与 printf 直觉相反的设计在第 19 章会连同 f-string 一起系统讲解,此处先立警示牌。

⚠️ 常见错误

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

坑 1:用下标取字符
# bad.xl 内容: # string s = "abc" # println(s[0]) > xl run bad.xl bad.xl:2:9: [hir] typed HIR value expression is missing its static type

原因:字符串没有下标运算;var 字符串 v[0] 更隐蔽——静默返回 null。正确做法:string.charAt(s, i) 按字符取,string.byteAt(s, i) 按字节取。

坑 2:s.length 不存在
# bad.xl 内容: # string s = "abc" # println(string(s.length)) > xl run bad.xl bad.xl:2:18: [checked] class field receiver is not a class value

原因:length 是数组/list/dict 的属性,不是字符串的;var 字符串 v.length 静默得 0(RC0 缺陷)。正确做法:string.len(s);字节数用 string.byteLen(s)。

坑 3:int() 解析非整数字符串
# bad.xl 内容: # int n = int("3.99") > xl run bad.xl uncaught ValueError: invalid integer string

原因:int() 只接受整数字面量形态的字符串,"3.99"、"bad" 都抛 ValueError(可 try/catch)。正确做法:小数字符串先 float() 再 int() 截断;不可信输入一律 try/catch 兜底。

坑 4:join 传 var 数组
# bad.xl 内容: # var va = ["x", "y"] # println(string.join(va as array, "-")) > xl run bad.xl bad.xl:2:27: [sem] as 当前只支持基础值、class、native-backed class 和 union 目标类型

原因:join 的形参类型是 array——静态数组、split / splitLines / chars 的产物(静态类型恰为 array)都可以直接传入;唯独 var 不行:直传报 dynamic var argument 错,as array 也不在 as 支持的目标类型里。正确做法:var 数组的元素 as string 后 push 进静态 array<string> 再 join,或手工循环拼接。

本章小结

分类函数要点
长度len / byteLen / isUtf8len 按字符,byteLen 按字节;无 .length 属性
取字符charAt / byteAt / chars / bytes无下标运算;chars/bytes 返回动态数组
查找contains / indexOf / lastIndexOf / count / startsWith / endsWith / hasAny均有 ignoreCase 尾参;indexOf 找不到得 -1
判断isEmpty / isBlankblank = 空串或全空白
比较compare / equals / 运算符 < == >字典序按码点;compare 返回 -1/0/1
模糊like / sim / approxlike 用 * 通配;approx 实测等同精确相等,用 sim 自设阈值
大小写lower / upper仅影响 ASCII
修剪trim / ltrim / rtrim / filtertrim 可传字符集;filter 是"删除"语义
删改replace / remove / removeAt / insertreplace/remove 作用于全部出现
截取slice / left / right / mid0 基、负 start、count 默认到末尾;无 substr
生成repeat / reverse / padLeft / padRight / padCenterreverse 按字符不碎中文
分割拼接split / splitLines / join / +split 得动态数组(元素 var);join 只收静态数组;+ 两侧必须 string
转换string() int() float() formatint("3.99") 抛 ValueError;浮点格式串 % 是百分比
性能+= 循环 / Buffer不可变串每次拼接新分配;长串用 Buffer

复习题

✍️ 先遮住答案想一想
  1. string.len("你好") 与 string.byteLen("你好") 各返回多少?为什么?
  2. 取字符串第 3 个字符,为什么不能写 s[2]?静态与 var 字符串各有什么表现?
  3. string.filter("hello", "lo") 的结果是什么?名字为什么误导?
  4. string.split("a,,b", ",") 得到几个元素?元素类型是什么,取用前要做什么?
  5. var 数组 ["x","y"] 如何用 string.join 拼接?直接传或 as array 为什么都不行?
  6. int("3.99") 与 int("bad") 的行为是什么?与 int(3.99) 有何不同?
  7. string.format(0.25, "%.1f") 输出什么?为什么不是 0.3?
参考答案
  1. 2 与 6。len 按 Unicode 码点计数,"你好"是 2 个字符;byteLen 按 UTF-8 字节数,每个汉字 3 字节。
  2. 字符串没有下标运算。静态字符串 s[2] 报 [hir] 编译错;var 字符串静默得 null。用 string.charAt(s, 2)。
  3. "he"——l 和 o 被删除。filter 是"删除集合内字符"而非"保留",望文生义必踩坑。
  4. 3 个(空字段保留)。返回动态数组,元素是 var,取用前须 as string。
  5. join 形参是静态 array,var 数组直传报 dynamic var argument 错,as array 也不在 as 支持的目标类型里。正解:元素 as string 后 push 进静态 array<string> 再 join,或手工循环拼接。
  6. 两者都抛 ValueError(可捕获);int() 对字符串只认整数形态。int(3.99) 是浮点截断得 3,不抛异常——解析字符串与转换数值是两条路径。
  7. 25.0%。浮点格式串中 % 是百分比标志(乘 100 加 % 号),不是 printf 的格式引导符;两位小数写法是 ".2f"。

编程练习

练习 1:CSV 统计器

编写 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(行数)。

练习 2:词频统计升级版

编写 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。

练习 3:蛇形转驼峰

编写 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) 即大写字母。