Java常用类库 01:null、empty与blank的语义边界
商品编号里的空白会改变什么
商品目录接收了一个编号 BOOK-01,随后又收到 BOOK-01 和末尾带不换行空格的 BOOK-01\u00A0。三份输入应当指向同一件商品,还是后两份必须报错?这个决定先于 trim、isBlank 和 Strings.isNullOrEmpty 的选择。若先统一清洗再讨论身份,原始输入之间的差别已经丢失。
本篇约定商品编号只允许 1~32 个 ASCII 大写字母、数字或连字符,首字符必须是字母或数字;任何不合规输入直接拒绝,合法输入保持原值。商品名称可以另有规则,例如保留内部空格。这样的区分避免把“字符串工具接受什么”误当成“商品目录允许什么”。
第00篇建立了实验环境。本篇沿用 Guava `33.5.0-jre`、Commons Lang `3.20.0` 和 JUnit Jupiter `5.13.4`,主示例使用 Java 8 语法,另在 JDK 21 对照 `String.isBlank()`。完整参数表、输出和业务断言都在 第 00 篇工程附件内的 `src/test/java/blog/libraries/Chapter01Test.java`(工程附件入口)。缺失、null与空串保留了不同的信息
null 表示引用没有指向字符串对象;"" 是长度为零的字符串。字段缺失还多一层:输入里究竟有没有提供这个字段。用允许 null 值的 HashMap<String, String> 模拟一份表单,未放入 name 时,get("name") 返回 null;放入 name -> null 后,返回值仍然是 null。containsKey("name") 才能把这两种情况区分开。这里没有假设所有 Map 实现都接受 null,也没有把 Map 实验当成某种 JSON 绑定器的保证。
对部分更新请求,这个差别可以成为协议的一部分:缺失表示不修改,显式 null 表示清除,空串表示提交了一个空值。协议也可以禁止其中一种状态,但禁止需要显式校验。入口立刻 nullToEmpty 会把 null 与空串压成同一值;业务层此后不能仅凭转换结果还原原始状态。
Guava Strings对 isNullOrEmpty 的说明是:“Returns true if the given string is null or is the empty string.” 它只合并 null 与长度为零,单个普通空格仍返回 false。nullToEmpty、emptyToNull 是表示转换,后者不会顺便去除空白。这种克制恰好适合“协议已经决定把两种缺失表示合并”的位置;对于尚未决策的更新协议,过早调用则会丢信息。
测试分别断言缺失字段、显式 null、空串三个阶段,并检查 emptyToNull(" ") 仍为单个空格。字符串内容是否存在,与内容是否满足规则,因而能在不同的断言失败位置暴露出来。Objects.requireNonNull 只能帮助表达引用前置条件,也不能单独验证字段是否在原始载荷中出现。
[PATTERN] 有信息损失的转换放在协议已经作出决定的边界。字段存在性由载荷结构保存,字符合法性由字段规则决定;两者都不应依赖一个名叫 isEmpty 的方法替业务作决定。
char、码点与肉眼看到的字符
ASCII 的编码范围是 0~127,普通空格是 U+0020。把“空白”写成 c <= 32 实际上定义了一个数值区间;这个区间还包含 NUL 和文件分隔控制符,不能因为端点是空格就把整个区间叫作“ASCII 空格”。
Java 字符串的处理还涉及 UTF-16。char 是一个 16 位代码单元;补充平面的码点需要一对代理项。字符串 "\uD83D\uDE00" 的 length() 为 2,codePointCount 为 1。charAt 访问其中一个代码单元;codePoints() 则能把有效代理对作为一个码点遍历。这些术语和 API 边界见 Java 8 Character 文档。
这仍不等于“一个码点就是一个显示字符”。本篇只比较字符分类与字符串判定,不据此实现光标移动、按屏幕字符截断或用户可见长度限制。把字符单位写清楚,是为了避免以后把空白实验里的结论直接推广到任意 Unicode 字符处理。
参数表也放入了孤立高代理项 "\uD83D"。它占一个代码单元,Java String 可以保存这种内容;“不是空白”的结果并不能证明它是合法的 Unicode 标量序列,更不能证明它是合法商品编号。测试中 emoji、孤立代理项、空格加代理项的空白判定均为 false,业务白名单仍会拒绝三者。
Guava CharMatcher 的单位也是 char。所选版本的类文档与实现明确说明补充码点按两个单元处理。当前空白集合位于 BMP,使本篇的空白匹配不需要合并代理对;这不构成它可以替代通用码点处理器的理由。
四种集合产生了不同的blank结果
trim检查的是边界数值
Java 8 String.trim()按不大于 U+0020 的字符删除两端内容。trim().isEmpty() 因而回答“原字符串是否全部落在这个范围内”,并且调用前必须解决 null。它不会删除 U+2003 或 U+3000,却会把单独的 U+0000 删除。对标识符来说,NUL 不是一份可以默默修复的有效输入。
“只处理首尾”和“删除整个字符串中的匹配字符”也必须分开。" A\t".trim() 变为 "A",中间的字母留下,所以 trim().isEmpty() 为 false。若把这个操作换成全局删除,便可能改变名称内部本来允许的空格。测试通过混入字母的样本确认,不能只用全空白输入推测一般清洗行为。
Java whitespace与Unicode分隔符
Character.isSpaceChar 根据分隔符类别分类;Character.isWhitespace 使用 Java 自己规定的集合,包含若干控制字符,并排除 U+00A0、U+2007、U+202F 三种不换行空格。因此 tab 的两种结果分别为 false、true,NBSP 刚好相反。定义详见 Java 8 isWhitespace(int)及同页 isSpaceChar(int)。
下面的表以实际测试输入为行。W、S、G 都表示“字符串中的全部元素满足对应字符分类”,不是“至少含有一个”;空串因此返回 true。T 是 trim().isEmpty(),C 是 Commons StringUtils.isBlank。表里的 null 另行处理,不能把空串行直接套给 null。
| 输入 | T | W:Java whitespace | S:space char | G:Guava whitespace | C |
|---|---|---|---|---|---|
| 空串 | true | true | true | true | true |
| 普通空格 U+0020 | true | true | true | true | true |
| tab / CRLF | true | true | false | true | true |
| NUL U+0000 | true | false | false | false | false |
| 文件分隔符 U+001C | true | true | false | false | true |
| NBSP U+00A0 | false | false | true | true | false |
| FIGURE SPACE U+2007 | false | false | true | true | false |
| NARROW NBSP U+202F | false | false | true | true | false |
| EM SPACE U+2003 | false | true | true | true | true |
| ZERO WIDTH SPACE U+200B | false | false | false | false | false |
| IDEOGRAPHIC SPACE U+3000 | false | true | true | true | true |
| NEL U+0085 | false | false | false | true | false |
| 空格、A、tab 混合 | false | false | false | false | false |
| emoji / 孤立代理项 / 空格加代理项 | false | false | false | false | false |
NBSP 和 U+001C 形成两个方向的反例:G 接受 NBSP、W 拒绝;W 接受 U+001C、G 拒绝。因而不能把 G 描述为“更宽松版本的 W”。同样,T 接受 NUL 却拒绝 EM SPACE,T 与 W 也不存在简单的包含关系。替换工具方法时,仅跑一个 ASCII 空格样本会漏掉这些差异。
空串的全称判断需要特别处理。allMatch 在没有元素时返回 true,matchesAllOf 也如此;这表示不存在违反条件的元素。若商品名称要求“至少一个字符且所有字符合法”,必须另外表达非空约束。否则,即便字符分类本身完全正确,空串仍可能被接受。
Guava与Commons在哪一层作决定
Guava whitespace() 返回专用匹配器。在冻结的实现中,候选 char 经乘法和位移定位到预计算表,再与该槽内容比较;表里覆盖 25 个不同字符。固定 Guava 版本意味着这里使用该构件自带的表,不能期待运行时自动从网络更新 Unicode 分类。
Commons Lang 3.20.0 的 isBlank(CharSequence)先检查长度,再逐个 char 调用 Character.isWhitespace。遇到不匹配就返回 false;null 与空串返回 true。它因此同时承担了 null 策略和字符分类策略,调用处虽然短,语义仍有两部分。
对 null,Strings.isNullOrEmpty、StringUtils.isEmpty 与 StringUtils.isBlank 都返回 true;null.trim() 与 CharMatcher.whitespace().matchesAllOf(null) 则抛出 NullPointerException。异常不表示工具失效,只表示调用方违反了这个 API 的输入合同。若业务希望 null 被拒绝而空白可以保留,就不能直接让 isBlank 的返回值决定处理方式。
[PATTERN] 替换判定函数要同时比较输入域、字符集合和空集合结果。方法名相似只提供检索入口,反例表才给出迁移边界。
JDK升级也可能改变字符分类
JDK 21 的 String.isBlank()检查空串或全为 Java whitespace 码点的字符串,API 从 Java 11 才提供。它与 Commons 的 null 合同不同:实例方法不能在 null 引用上正常调用。本篇在 Java 8 源码中通过反射查找方法,Java 8 明确输出 NOT_AVAILABLE;在 JDK 21 才执行该列断言。反射只是让同一组固定样本能在两个 JDK 启动,不是业务代码调用普通字符串方法的推荐方式。
Unicode 属性有版本。Unicode 6.3 将 U+180E MONGOLIAN VOWEL SEPARATOR 从 Zs 改成 Cf,并相应调整 White_Space 等属性,见版本变更说明。这一历史反例足以否定“所有 JDK 的字符分类永远相同”的假设。本篇把 U+180E 单独作为版本边界样本,不混入两套 JDK 预期相同的主表。测试在 JDK 8 预期 Java 分类为 true,在 JDK 21 预期为 false,并分别验证 Commons 的委托结果;固定版本的 Guava 则在两者都预期 false。实际输出与运行环境在后文记录。
对依赖 JDK 字符数据库的方法,升级核验应保存原始样本和显式预期。把预期写成“新方法的结果必须等于旧方法的结果”可能让两个错误实现互相作证;把预期直接写成调用被测 API 更没有独立性。本篇参数中的布尔值来自逐项契约分析,每个 API 与该列预期比较,输出只是便于审计,并非断言的替代品。
这类变化会影响一个具体的升级动作:若旧服务允许以 U+180E 组成的字符串被判为空,新服务升级 JDK 后可能把它判为非空。应用此时必须选择遵循新字符分类,或冻结自己的业务集合;只固定 Commons 依赖版本不足以冻结它所委托的 JDK 行为。测试限定 JDK 8 和 21,也是为了让第三套未核验环境明确失败,而不是默默把未知版本当成已经验收。
商品编号为什么拒绝自动trim
完整测试中的 requireSku 使用这个业务规则:
1 | |
这是 完整可运行测试类 Chapter01Test.java(下载工程附件)中的方法摘录,所需 import、JUnit 入口和参数源均在该文件。这里的长度限制按 ASCII 合同计算,接受集合里的每个字符都只占一个代码单元,所以没有把一般 Unicode 的长度误当成字符数。
断言先检查合法 BOOK-01 返回同一个对象,然后分别拒绝 null、空串、普通空格、首部空格、末尾 NBSP、内部零宽空格、孤立代理项和小写编号。同时验证两种潜在碰撞:JDK trim 可把 BOOK-01 变成 BOOK-01,Guava trim 可把 BOOK-01\u00A0 变成 BOOK-01。若原始编号用于对账,这样的自动修复会隐藏上游格式错误;拒绝并要求上游更正能保留身份合同。
另一种产品合同也可能允许首尾空白并统一规范化。那时需要明确规范化后的值才是身份键,并用不同原始值碰撞的测试验证去重与错误返回。不能只在展示层 trim,而数据库唯一约束、查询键和缓存键各自保存另一种形式。本篇选择严格拒绝,因此无需建立一个通用“清洗所有字符串”工具类。
运行、手算与改动练习
在仓库根目录执行:
1 | |
本次在 Azul Zulu 1.8.0_472 与 Amazon Corretto 21.0.11 上分别完成全工程 clean verify,第 01 篇各有 20 项测试,失败、错误与跳过均为 0。其中 17 项对应参数表,另 3 项覆盖缺失语义、U+180E 版本边界和商品编号。完整结果见 JDK8输出与 JDK21输出。
U+180E 在 JDK 8 的 Java whitespace、space char 与 Commons 列均为 true,在 JDK 21 均为 false;Guava 在两套环境均为 false。主表的 17 组输入在两套环境中结果相同,JDK 21 的 isBlank 结果也与该表 W 列相同。Java 8 的这一列是 NOT_AVAILABLE,没有把反射找不到的方法算作已执行的现代 API 对照。
这些断言覆盖所列输入,不证明全体 Unicode、任意 JDK 或所有数据接入链路。字符处理性能、JSON 框架的缺失字段映射,以及生产商品导入行为均不在本次实验范围内。
手算输入 "\u001C\u00A0":T 在 NBSP 处停止,结果非空;W 因 NBSP 为 false;S 因 U+001C 为 false;G 因 U+001C 为 false;C 因 NBSP 为 false。组合样本中每个字符分别被某个集合接受,不代表整个字符串会被任何一个集合完整接受。这道题还说明“字符串包含空白”与“字符串全为空白”的逻辑差别。
改动练习是给参数源增加这个组合输入,并把五列预期都设为 false,再加入一个 32 位和一个 33 位的编号。先检查业务上限,再运行测试。另一道练习把商品编号规则改成允许小写,同时要求保持大小写;应新增 book-01 的成功断言,但不要顺手 toUpperCase,因为保留大小写与大小写不敏感是两个不同的协议。
| 需要回答的问题 | 合适的依据 | 必须额外保留的边界 |
|---|---|---|
| 字段有没有提交 | 载荷存在性、Map 的 containsKey | 与显式 null 分开 |
| 是否 null 或空串 | Strings.isNullOrEmpty / StringUtils.isEmpty | 空格仍是非空内容 |
| 是否全为 Java 空白 | StringUtils.isBlank;Java 11+ String.isBlank | null 策略、JDK 字符数据库 |
| 是否全为 Guava 所定义空白 | CharMatcher.whitespace().matchesAllOf | 固定 Guava 版本、null 前置条件 |
| 商品身份是否合法 | 明确的字符白名单、长度与拒绝规则 | 不静默清洗,不改变身份键 |
完整参数化字符矩阵、SKU规则和断言见 完整测试源码,复跑工程见第00篇首批附件或第39篇全系列附件;两个JDK的原始字符输出仍在本篇附件中。
字符串的值规则确定后,还要防止集合与可变元素把已校验的数据改掉。第02篇继续区分不可修改视图、浅复制与不可变对象。
