TT Lab
开始
学习 学习路径 课程

打造好服务的计算机科学 — 用测量重新学习教科书概念

字符串有三种长度,而今天是哪里的今天

在 TT Lab 中继续学习

一句话总结

“昵称最多 10 个字”这一句话里,藏着三种长度:存储层统计的 UTF-8 字节数、Python len() 统计的码位数、人眼数到的字符数。再加上“相同的名字”和“今天”,我们要用数字对齐这些人眼与机器的计数出现分歧的地方。

为什么需要它

注册界面说“最多 6 个字”,服务器用 len(name) <= 6 检查,存储列却是按字节计算的。在只用英文的测试里,三者相同,没有任何问题。然而一旦输入一个家庭 emoji,屏幕上是一个字符,len() 却是 5,字节数是 18。通知预览按字节截断后,末尾会出现一个带问号的菱形;在 Mac 上粘贴过来的韩文名字,明明是同一个名字,却能通过重复检查。

日期也是同样的形状。事件以 UTC 打时间戳,而用户过的是自己时区的“今天”。如果按 UTC 日期汇总每日统计,首尔用户早上九点之前的活动会被算到前一天。

工作原理

三种长度。 这是在本 Pod 的 Python 3.12 上测得的例子。

字符串 码位 UTF-8 字节 人眼所见的字符
a 1 1 1
ß 1 2 1
가(预组合形) 1 3 1
가(分解为字母,NFD) 2 6 1
é(e + 组合重音符) 2 3 1
👍🏽(带肤色修饰符) 2 8 1
家庭 emoji(用 ZWJ 连接三个人) 5 18 1

Python 文档把 str 定义为“码位的不可变序列”,所以 len() 就是码位数。字节数由编码决定。RFC 3629 的 UTF-8 用 1–4 个八位字节表示一个字符,后续的八位字节都是 10xxxxxx 形式,由第一个八位字节告知整体长度。因此正如 RFC 所说,在八位字节流的任何位置都很容易找到字符边界。按字节上限截断时,只要向后退到不是 10xxxxxx 的位置,就不会留下残缺的字符。

人眼所见的字符是 UAX #29 的字素簇(grapheme cluster)。它由不在组合标记和 ZWJ 之前断开的规则(GB9)、把韩文字母 L、V、T 合成一个音节的规则(GB6–GB8)、emoji ZWJ 序列规则(GB11)、国旗成对规则(GB12、GB13)等构成。标准库 unicodedata 里没有划分这种边界的函数。所以实验中要自己写简化后的规则:组合标记、变体选择符、肤色修饰符、ZWJ 要附着在前一个字符上,ZWJ 之后的字符也要附着,字母按 L、V、T 规则合并。不处理国旗、Prepend 和 SpacingMark,并且省略了 GB11 中“前面必须是图形符号”的条件。真实服务应使用实现了 UAX #29 的库,并确认该库遵循的 Unicode 版本。

相同的名字。 UAX #15 把相同分成两种。规范等价(canonical)是“抽象字符相同,且正确渲染后始终看起来一样”的关系,预组合形 가 与字母 ㄱ+ㅏ 就是这样。NFC、NFD 就是用来对齐它的。兼容等价(compatibility)更弱,外形可以不同。全角 K 与 K,连字 fi 与 fi,由 NFKC、NFKD 来对齐。同一份文档警告说,KC 和 KD 会抹掉格式区分,所以“不能盲目地应用到任意文本上”。因此原文保持不变,只把它用在比较用的键上。

大小写方面,str.casefold 文档中的例子已经把一切说清楚了。ß 本来就是小写,所以 lower() 保持不变,而 casefold() 把它换成 ss。Straße 和 STRASSE 用 lower 分得开,用 casefold 就会相遇。

“今天”是哪里的今天。 datetime 文档中有两句话。给 aware datetime 加上 timedelta,“不做时区调整”。两个带有相同 tzinfo 的 aware 对象相减,会忽略 tzinfo,返回墙钟时间之差。zoneinfo 文档中的例子就是它的结果。在洛杉矶 12:00 上加 timedelta(days=1),在夏令时结束的第二天仍是 12:00。反过来,如果在转换成 UTC 的那个瞬间再加 24 小时,转换日那天当地时间就会偏移一小时。“每天 09:00”是为每个当地日期生成 09:00 再换成 UTC,而两次预约之间的实际间隔,必须在换成 UTC 之后再相减,才会显现出 23 小时和 25 小时。zoneinfo 读取系统的 IANA 数据库或 tzdata 包,所以容器镜像里有没有这些数据,也需要确认。

在现场相遇的样子

编码判定、乱码(mojibake)、剔除不可见字符,在“处理客户数据”课程的 fde-dat-encoding 实验中有深入讲解。夏令时转换日不存在的时刻与出现两次的时刻,如何用 fold 区分,由“日志来自未来”课程负责。用 errors="ignore" 悄悄吞掉被截断的字符串、最后以 0 结束的批处理为什么危险,由“失败了,退出码却是 0”课程从工具纪律的角度来讲。本模块位于它们之间,负责把计数——即长度、截断、相等、日期——确定为服务规则。

下一项实验要做什么

统计 360 个昵称的字节数、码位数和字符数,实现简化的字符规则,然后按字节上限和字符上限不破坏字符地截断。依次叠加 NFC、casefold、NFKC 来统计冲突的名字,把 UTC 事件按当地日期汇总,把跨越夏令时的“每天 09:00”预约换成 UTC,最后把这一切汇总成一个注册审核函数。