商品名称的长度按什么计算

商品名称含一个表情符号,界面显示为 A😀B。Java 的 length 返回 4,按 Unicode 码点计数得到 3。若要求“将表情替换为一个问号”,对 CharMatcher 使用表情的字符串形式,可能得到两个问号;若只删除其中一个代理项,还会留下不成对的 UTF-16 代码单元。

问题来自字符单位没有对齐。Java 的 char 是 16 位 UTF-16 代码单元;补充平面的一个码点由高、低两个代理项组成。一个用户感知的字形还可能由多个码点组合,因此 code point 也不能直接等同于界面中的一个字符。商品编码只允许 ASCII 时,这些区别可以由输入校验排除;商品名称允许多语言和表情时,就必须直接面对它们。

本篇使用 Guava 33.5.0-jre、Java 8 API,双 JDK 为 Zulu 8.0.472 与 Corretto 21.0.11。完整测试源和 运行说明覆盖 UTF-16、组合字符、前后缀及空白策略。前篇 分隔契约关注边界是否丢失,这里继续追踪一次字符操作究竟修改了哪些单位。

CharMatcher 的输入域是 char

CharMatcher 的固定版本源码将匹配入口定义为 matches(char)。字符串扫描通过 charAt 逐个读取代码单元。于是 anyOf("😀") 得到的是“匹配高代理项或低代理项”的谓词,并没有得到“匹配 U+1F600 这个码点”的谓词。表情在源码中也可写成 \uD83D\uDE00,两个转义分别生成一个 char。

对 A\uD83D\uDE00B,测试得到如下结果:

操作 结果 被处理的单位
String.length 4 UTF-16 代码单元
codePointCount 3 码点
anyOf 表情后 countIn 2 两个代理项
anyOf 表情后 replaceFrom 为问号 A??B 每个匹配的 char
删除高代理项 D83D A、孤立低代理项、B 单个 char 被删除

最后一项不宜直接打印后凭显示结果判断。终端、字体或后续编码器可能以替换符显示孤立代理项,掩盖字符串内部的内容。测试直接读取 charAt(1) 并断言为 DE00,观测点位于 Java 字符串内部。它证明此次操作产生了孤立低代理项,没有把某个输出设备的替换策略混入结论。

JDK 8 Character 文档分别说明 char 与 int 版本的码点能力。按码点操作可以使用 Java 8 的 String.codePoints(),再以 appendCodePoint 重建结果。本实验用 U+1F600 判定目标码点,得到 A?B。这个对照完成了明确的“一个指定码点替换一次”,并不保证输入中所有表情序列都被识别;表情序列可能含多个码点及连接符。

适合 CharMatcher 的输入包括 ASCII SKU、固定语法中的冒号或分号、单个 BMP 字符集合。若目标已经被定义为 char 集合,组合 matcher 能直接表达保留与删除规则。反过来,将任意自然语言字符集塞进 anyOf,再假设它按视觉字符处理,会在建立谓词时就丢失单位约束。

可迁移模式是先固定操作单位,再选遍历方法:字节协议按字节预算,ASCII 字段按受限 char 域,Unicode 标量处理按码点,界面截断按所需的字素边界规则。上层需求中的“长度”必须落到其中一种,才有可验证的上限。

码点相同与规范等价不同

字母 é 可以表示为单个 U+00E9,也可以表示为 e 加 U+0301 组合锐音。测试中两个 Java String 的 equals 为 false,分解形式包含两个码点。删除 U+0301 后,分解形式变成 e,而预组合形式仍是 é。这种删除并没有实施 Unicode 规范化,只是删除了特定代码单元。

JDK 8 Normalizer提供规范化形式。实验以 NFC 处理分解形式,结果与预组合形式相等。NFC 处理的是规范等价关系,不是“去掉所有重音”;NFKC 还涉及兼容等价,选择它可能把业务希望区分的形式合并。对于商品展示名和商品唯一键,这种合并的可接受性并不相同。

规范化的位置会改变系统行为。若先去重、后规范化,两个原先不同的键可能变成同一个;若先计算哈希、后修改规范形式,哈希输入与保存文本也不一致。业务若决定按 NFC 识别重复名称,应在比较和存储键生成之前统一执行,并保留展示所需的原始输入。原始输入与比较键属于两个字段,不能靠一个“清洗后的字符串”同时承担。

本实验只覆盖 é 的两种编码,没有把它推广成所有语言、所有组合序列和所有 Unicode 版本都已验证。Unicode 规范化的形式定义可查 Unicode UAX #15。修改字符串前应先确认需求是规范等价、大小写折叠、音调忽略还是显示截断;它们对字符信息的保留规则各不相同。

另一个可迁移模式是把“表示统一”和“内容删除”分开验收。前者需要写出等价关系,后者需要列出被删除的输入集合。日志键、搜索索引、文件名和用户标识都可能需要不同规则,不能把某个名称字段的清理配置共享给所有文本入口。

Strings 的前后缀保护到哪一层

Strings.commonPrefix 和 commonSuffix 对代理对有专门处理。两个表情若共享高代理项但低代理项不同,直接逐 char 比较会得到只含高代理项的前缀。Guava 会检查截断边界是否处于有效代理对中,必要时回退一个位置,因此这两个表情的共同前缀为空。

Strings 固定实现先逐 char 找相等区间,再调用 validSurrogatePairAt 检查相邻位置;commonSuffix 采用对应的尾部边界检查。测试分别使用“高代理项相同、低代理项不同”和“低代理项相同、高代理项不同”的输入,两种结果均为空。这保证结果不会在该边界拆开一个有效代理对。

这项保护仍然没有上升到字素簇。e\u0301 与 e\u0300 的共同前缀是 e,组合附加符不属于共同部分。返回 e 符合字符串前缀的定义,但不能理解成“保留一个完整的原始显示字形”。同一个 Strings 类中出现了代理对保护,也不意味着所有方法都按相同单位计量。

例如 padStart("😀", 2, '_') 不添加下划线,因为输入已经占两个 UTF-16 代码单元;要求最小长度 3 才补一个下划线。它没有测量屏幕列宽,也不会根据字体判断宽字符。表格对齐若使用 padStart 计算显示宽度,即使避开代理项,也仍需考虑组合字符和全角字符的实际布局规则。

源码分析的边界在此很有价值:只需追踪“逐 char 比较、代理对边界修正、subSequence”三个动作,就能说明前后缀保证;没有依据把一个局部判断扩大成通用 Unicode 安全。上游 StringsTest 中关于 commonPrefix、commonSuffix 的代理对用例可用于复核边界,业务测试仍应补入自身允许的字符域。

空白匹配与缺失值转换

CharMatcher.whitespace().trimAndCollapseFrom 对匹配的连续字符段进行折叠,也会裁掉首尾匹配字符。输入 NBSP、空格、A、两个制表符、B、空格、NBSP,在实验中得到 A B。其中 NBSP 为 U+00A0,属于此版本 Guava 的 whitespace 集合;U+200B 零宽空格不在集合中,输入两侧的 U+200B 保持不变。

因此,whitespace 不能等同于“所有不可见字符”,也不能用来判断用户是否看见了内容。Strings.emptyToNull(" ") 返回的仍是一个空格,只有零长度字符串才变成 null。nullToEmpty(null) 返回空字符串,把缺失与空值合并;这项合并若用于可选字段展示可能合适,用于表达补丁请求中的“不修改”和“清空”则可能改变业务操作。

Strings 文档和前述固定源码分别给出空值转换及前后缀契约。CharMatcher.whitespace().removeFrom(null) 则抛 NullPointerException。不能因为同一个 base 包内部分方法接受 null,就推导所有文本工具都有统一的 null 策略。

字符规则的复用也应经过命名约束。若字段明确限定 ASCII 大写、数字和横线,可以把允许集合和长度验证放在同一入口;若同一 matcher 被复用到自然语言名称,则原来的输入前提已被破坏。对公共工具函数写清输入域,通常比增加更多“兼容字符”更容易保住契约。

拒绝策略应早于破坏性替换

自然语言字段经常同时用于展示和索引。若为了限制字符集合直接调用 retainFrom,所有不匹配的字符都会消失;两个原本不同的输入可能得到相同输出。例如只保留 ASCII 字母时,带重音的预组合字母可能整个被删除,分解形式中的基本字母却保留下来。相同的视觉名称因此可能得到不同索引。这是输入编码形式与删除集合共同造成的结果,单纯追加几个允许字符不能建立稳定的等价规则。

对于 SKU 这种身份字段,遇到不允许字符时直接拒绝通常比静默删除更容易保持唯一性:删除后产生的字符串不一定仍代表提交者原本指定的商品。展示名称则可以采用不同策略,保留原文,生成单独搜索键,记录处理规则版本。这里的选择来自身份字段不能悄悄改变指向的业务约束,实验没有比较搜索召回率,也没有证明某种规范化适合所有语言。

代理对保护也不等于验证输入是格式良好的 UTF-16。Java String 可以包含孤立代理项;commonPrefix 对两个相同的孤立高代理项,仍可能返回这个公共代码单元,因为它并没有在边界处拆开有效代理对。上游 StringsTest 对有效与无效代理项组合分别设有断言,说明公开契约只覆盖前后缀截断行为。需要拒绝孤立代理项的接口,应独立检查成对规则,不能把 commonPrefix 当成输入验证器。

排查这类问题时,证据最好同时记录 length、codePointCount 和十六进制代码单元。屏幕外观可能相同,String.equals 却不同;屏幕也可能显示替换符,而内部仍保存原来的孤立代理项。十六进制观测把编码问题与字体、终端、网络编码问题分开,后续才有依据决定修复发生在哪一层。

实验与练习

完整 Chapter08Test 有四个测试方法,分别覆盖匹配单位、组合序列与 NFC、前后缀和填充、空白与 null。JDK 8、21 各得到 4 个测试通过,失败、错误、跳过均为 0;原始记录保存于工程 evidence/08/。测试使用转义明确输入代码单元,避免编辑器是否进行了字符规范化影响样本。

反例题:先用 codePoints 遍历,再把每个码点截成 char,能否保持补充字符?不能。转为 char 会丢掉高位信息,应使用 appendCodePoint 或 Character.toChars 编码。另一个问题是“两码点等于两显示字符”也不成立,e 加组合锐音已构成反例。

改动练习:为商品搜索键增加 NFC 规范化,要求原始展示名保持不变。验收应同时检查预组合与分解形式拥有相同搜索键、原始字段仍不同、ASCII SKU 未经这条展示名流程处理。若还要忽略大小写,应额外定义大小写映射的语言环境,并增加相应语言样本。

需求 判断模式 合适的验证点
指定 ASCII 字符保留/删除 固定操作单位 受限输入域与 char 匹配
补充码点替换一次 固定操作单位 codePoints 加 appendCodePoint
规范等价的搜索键 分开表示统一与内容删除 Normalizer 前后等价断言
文本显示截断或对齐 明确显示边界 字素与布局规则单独测试
缺失值转空字符串 写清信息合并 null、空串、空白分别断言

下一篇:Immutable 集合的构建与共享边界。