给任务文本设定“最多 12 个字符”,然后拿 text.length 判断,"🙂" 会被算成 2。若把同一限制用在网络字节数上,又会把它算成 4。两个数字都正确,问题在于“字符”没有说明计量单位。04 先把合成任务输入的长度问题拆开:UTF-16 代码单元、Unicode 码点、UTF-8 字节,各有不同的用途;至于用户眼里有几个字,这三种计数均不承诺回答。

沿用 03 的任务预览,本篇只增加一个文本计量函数和一个任务标识的正则实验。计量与标识校验尚未接入 CLI;命令行接受 submit 检索🙂,不代表这条输入经过字节长度规则、已入队或已持久化。实验环境为 Kotlin/JVM 2.2.21、JDK 21、Gradle Wrapper 8.14。

下标指向的是 Char,不是完整的码点

Kotlin 的字符串文档允许用 text[index] 取 Char。在本篇的 JVM 平台上,JDK 21 的 String.length() 以 UTF-16 代码单元计数;索引取到的是一个 16 位 Char。中文 "中" 位于基本多文种平面,长度为 1;增补字符 "🙂" 需要一个高代理项、一个低代理项,长度为 2。"🙂"[0] 是高代理项,单独取出并不是那个 emoji。

1
2
3
4
"🙂".length                     // 2 个 UTF-16 代码单元
Character.isHighSurrogate("🙂"[0]) // true
Character.isLowSurrogate("🙂"[1]) // true
"🙂".codePointAt(0) // 0x1F642

最后一行调用的是 JDK String API,不是把单个 Char 转换成码点。索引 0 处的代理项和紧接的代理项合在一起才得到这个码点。测试在 TaskTextTest 中直接断言了这些值;它不是一个适用于 Kotlin/Native 或 JS 的 JVM 之外结论。

码点数和编码字节数也不能替换

本章 inputMetrics 直接把三种计量写在同一行,避免只用一个没有单位的 length:

1
2
fun inputMetrics(text: String): String =
"utf16=${text.length}; codePoints=${text.codePointCount(0, text.length)}; utf8=${text.toByteArray(Charsets.UTF_8).size}"

codePointCount 是 JDK 21 对给定 UTF-16 范围计数的函数;这个 API 对未配对代理项也计为一个码点,不能拿它当任意输入的 Unicode 合法性验证。toByteArray(Charsets.UTF_8) 则先执行编码,再统计结果的字节长度;JDK 的 UTF-8 常量定义了这里选择的字符集。未讨论替换字符与损坏代理项的编码策略;本章输入样本都是有效的 Unicode 文本。

输入 UTF-16 单元(length) 码点(codePointCount) UTF-8 字节(编码后 size)
中 1 1 3
🙂 2 1 4
e\u0301(e 加组合重音) 2 2 3

这三行来自 TaskTextTest.distinguishesUtf16CodePointsAndEncodedBytes 的断言。第三行屏幕上可能呈现为一个带重音的字形,却是两个码点;字形如何呈现还受到字体和渲染环境影响。由此不能推论“两个码点必有两个可见字符”,更不能用三列中的任一列承诺完整的 Unicode 字素簇计数。本章没有引入字素簇切分器。

如果限制的是协议正文的字节数,应该规定字符集并对真正要写入的编码结果计数;如果限制的是 JVM 字符串下标访问范围,用 length 才是对应的量。若产品明确要求“用户可见的字符数”,需要另外选择并验证字素簇规则,不能把这三个单位偷偷改名成“字符”。

正则的“找到”不等于“全部合法”

合成任务标识规定:小写英文字母开头,总长 1–12,后续只允许小写字母、数字、下划线或连字符。实验故意分别暴露整串校验与子串搜索:

1
2
3
4
private val taskIdPattern = Regex("[a-z][a-z0-9_-]{0,11}")

fun validTaskId(text: String): Boolean = taskIdPattern.matches(text)
fun containsTaskId(text: String): Boolean = taskIdPattern.containsMatchIn(text)

Kotlin Regex API 区分 matches 的全输入匹配与 containsMatchIn 的查找。测试验证 validTaskId("a_3") 为真,而 validTaskId("id:a_3") 为假;同一串的 containsTaskId("id:a_3") 为真。拿搜索结果放行任务标识,会把带前缀的非法值当作合法值。"任务"、"a!" 和空串均被 validTaskId 拒绝:这是有意限定为 ASCII 标识,不是断言 Kotlin 正则不能匹配中文。正则计的长度是该模式的匹配范围,不等于编码后的字节上限。

怎么复核,哪些还没有测到

在仓库根目录执行 cd examples/kotlin-language && bash verification/04.sh。脚本先记下 UTC 时间、内核与 JDK/Gradle 环境,离线 clean test;00–04 五个 suite 各 2/2,无跳过和失败。它随后运行 submit 检索🙂:退出 0,输出 TASK input accepted (not persisted): 检索🙂;缺少文本的 submit 退出 1,提示 Usage: submit <task text>。命令、退出码、JUnit XML 与逐项断言见 writing-plans/kotlin/verification/04/,不能只凭这段转述核验。脚本的正向 CLI 只检查 Unicode 文本没有在原有入口被拒绝;计量和正则的断言发生在 JUnit 内。

下载含Wrapper、源码与证据的04工程快照(校验SHA-256)。压缩包也包含运行说明与版本账本,在没有仓库 `examples/` 的环境中仍可找回源码;需要本机 JDK 21 和已缓存的 Gradle/Kotlin 依赖才能离线复跑,素材目录的 `README.md` 给出命令。没有真实 HTTP 编码/数据库/并发测试,任务标识校验尚未上线到 CLI。

练习与答案

练习一:把 "🙂".length 当成“一个 emoji 的个数”,为什么得到 2?答:在 JVM 上这是两个 UTF-16 代码单元;若只需这个样本的码点数,codePointCount(0, text.length) 得 1,但码点数仍非通用的用户可见字符数。

练习二:为什么 "e\u0301" 与 "🙂" 的 length 都是 2,却不该在网络限额上等价处理?答:前者 UTF-8 编码后是 3 字节,后者是 4 字节;必须先明确协议字符集与计量对象。

练习三:输入 id:a_3 中找得到合法片段,为何完整标识仍无效?答:containsMatchIn 找到了子串,matches 需要整个输入都符合规则;标识验证用的是后者。允许中文标识时也不能靠切换这两个方法解决,还要重新定义字符集与长度单位。

下一篇把注意力从“有多少”移到“有没有”:可选字段缺失、空字符串、显式 null 的契约不同,!! 也不会凭空让外部输入变得可靠。