检查空白字符有多难?
摘要
一篇深度博客文章,探讨为什么检查空白字符出乎意料地复杂:从 Guava 的实现追溯到 Unicode 的 25 个 White_Space 属性字符,并比较 Java、Python 和 JavaScript 在哪些字符算作空白上存在分歧。
<p><a href="https://lobste.rs/s/1pmuy7/how_hard_is_it_check_for_whitespace">评论</a></p>
查看缓存全文
缓存时间: 2026/10/30 23:17
# 检查空白字符有多难?
来源:https://serce.me/posts/2026-10-30-how-hard-is-it-to-check-for-whitespace
最近我在阅读一段源代码时,偶然发现了 Guava(https://github.com/google/guava)中的这个方法——Guava 是 Google 推出的一个流行的 Java 库。
Guava 的空白字符匹配器实现
你可能已经从名字猜到了,这个方法检查给定的字符是否为空白字符。但代码看起来远比一个简单的 `c == ' '` 检查复杂得多。这让我很好奇,并由此展开了一段小探索,试图理解这些复杂性从何而来。如果你也对这段代码的工作原理感到好奇,那么这篇文章就是为你写的。
## 空白字符到底是什么?
在审查一个检查空白字符的函数时,首先要明确的是:空白字符究竟是什么。虽然 `SPACE (U+0020)` 可能是最为人知的空白字符,但它并不是唯一的。如果你写代码已经有一段时间了,大概还能说出几个——比如不换行空格 `NO-BREAK SPACE (U+00A0)`、制表符 `CHARACTER TABULATION (U+0009)`、换行符 `LINE FEED (U+00A0)`。
其实还有更多。Unicode 定义了一个 `White_Space` 属性(https://www.unicode.org/Public/17.0.0/ucd/PropList.txt),其中列出了 25 个字符。以下就是它们——我逐一仔细复制过来的:` , , , , , ,, , , , , , , , , , , , , , , , , `。
每个字符都有不同的用途。例如,`FIGURE SPACE (U+2007)` 在表格中与数字等宽,而 `THIN SPACE (U+2009)` 和 `HAIR SPACE (U+200A)` 则提供逐渐缩小的间距。`NO-BREAK SPACE (U+00A0)` 与普通空格等宽,但在换行时能将周围的文本保持在一起。
仅凭名称或字形,有时也很难判断一个字符是否属于空白字符集合。例如,`ZERO WIDTH SPACE (U+200B)` 根本不是空白字符。尽管名字如此,它其实是一个格式字符,可以在不添加可见间距的情况下指示单词或行的换行机会。
这个集合中还有一些不太明显的成员。`OGHAM SPACE MARK (U+1680)` 实际上可以渲染为一条可见的水平线。在带有竖线的 Ogham 字体中,空格会在字母之间延续这条线(例如 ᚛ᚆᚓᚂᚂᚑ ᚒᚑᚏᚂᚇ᚜),而在无竖线的字体中,它就是空白的。所以即使是"空白字符看起来一定是空的"这个假设,也并非总是成立。
## 不同运行时,不同规则
拥有一个统一的空白字符定义,只有在所有人都认同的前提下才有意义。但即便在同一个运行时内部,不同库对空白字符的定义也可能存在分歧。
想想我上面提到的 `NO-BREAK SPACE (U+00A0)`。它被包含在 Unicode 的 `White_Space` 属性中,但 Java 明确将其排除在外(https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Character.html#isWhitespace(char)),另外还有两个不换行空格也被排除。Javadoc 有个很有用的提示,指出该方法是*按照 Java 的定义*来检查空白字符,而不是按照我们之前查看的 Unicode 属性。
```
Character.isWhitespace(' '); // true
Character.isWhitespace('\u00A0'); // false
```
Java isWhitespace 截图
那其他运行时呢?再看看另外两个流行的运行时——Python 和 JavaScript——我们会发现,并不存在一个所有人都认同的权威字符列表。比如 Python 有一个很方便的 `str.isspace()` 方法。与 Java 不同的是,它将 `U+00A0` 视为空白字符。
那么 Python 是否只是遵循了 Unicode 的 `White_Space` 属性呢?并不完全是。
```
"\u00A0".isspace() # True
"\u0085".isspace() # True
"\u001C".isspace() # True
"\uFEFF".isspace() # False
```
这里最有趣的是 `FILE SEPARATOR (U+001C)`。它是一个 ASCII 控制字符,最初用于分隔信息片段,并未包含在 Unicode 的 White_Space 属性中。因此 Python 识别 29 个空白字符,而 Unicode 是 25 个。
JavaScript 则采取了另一种方式。它没有 `Character.isWhitespace()` 方法的等价物,但可以在正则表达式中使用 `\s` 字符类:
```
/\s/.test("\u00A0"); // true
/\s/.test("\u0085"); // false
/\s/.test("\uFEFF"); // true
```
上面最特殊的是 `NEXT LINE (U+0085)`。Unicode 明确将其包含在 `White_Space` 中,Python 也接受它,但 JavaScript 不接受。相反,JavaScript 识别 `ZERO WIDTH NO-BREAK SPACE (U+FEFF)`,而 Unicode 并未将它包含在这个集合中。
我没有检查其他运行时,但直觉告诉我,这些不一致不会仅限于这三个。
## 没有什么是一成不变的
让情况更复杂的是,Unicode 并非一成不变,而是在不断演进。这也影响了空白字符的定义。例如,Unicode 6.3(https://www.unicode.org/versions/Unicode6.3.0/)将 `MONGOLIAN VOWEL SEPARATOR (U+180E)` 从空格分隔符重新分类为格式字符,取消了它的 `White_Space` 属性。Unicode 的空白字符集合因此从 26 个字符变为 25 个。对于力求向后兼容的运行时和库来说,这些变化可能是一个相当大的挑战,因为你希望一门语言的标准库尽可能稳定。
## 快速检查空白字符
假设我们终于确定了要检查的字符集合——当前的 Unicode 空白字符集合。一种朴素的实现方式是遍历字符列表,逐一检查是否有匹配。虽然这样做可行,但绝对不是最高效的方式。
那么文章开头看到的 Guava 的实现(https://github.com/google/guava/blob/master/guava/src/com/google/common/base/CharMatcher.java)做了什么呢?它实现了一个完美哈希函数——一种保证已知集合中的字符不会发生哈希冲突的哈希函数。
```
static final String TABLE = "\u2002...";
@Override
public boolean matches(char c) {
int slot = (1682554634 * c) >>> 27;
return TABLE.charAt(slot) == c;
}
```
上面的 `TABLE` 是一个 32 个字符的字符串——实际上是一个哈希表,将每个索引(键)映射到一个字符(值)。它包含全部 25 个 Unicode 空白字符,其余位置由重复字符填充。
这里最有趣的部分是索引的计算。乘法 `1682554634 * c` 产生一个 32 位整数,`>>> 27` 将其右移 27 位,取最高有效位的 5 位——一个 [0, 32) 范围内的数字,用于查表。当然,可能的字符比表中的条目多得多,这就是为什么最终还需要 `== c` 检查来确认找到的字符正是我们要查找的那个。
你可以点击下面的字符来查看表格是如何填充的。
int value =
32
\*
1682554634
=\-1992826560
10001
001001101111110000101000000
17
0·
1·
2·
3·
4·
5·
6·
7·
8·
9·
10·
11·
12·
13·
14·
15·
16·
170020
18·
19·
20·
21·
22·
23·
24·
25·
26·
27·
28·
29·
30·
31·
神奇的 `1682554634` 这个数字确实看起来很神奇,我也不清楚它是怎么选出来的(如果你知道答案,请务必分享)。但它并不是唯一可用的乘数,你可以通过运行一个简单的暴力搜索来找到所有合适的选项。
```
static final char[] ALL_CHARS = (
"\u2002\u3000\r\u0085"
+ "\u200A\u2005\u2000"
+ "\u2029\u000B\u2008"
+ "\u2003\u205F\u1680"
+ "\u0009\u0020\u2006"
+ "\u2001\u202F\u00A0"
+ "\u000C\u2009\u2004"
+ "\u2028\n\u2007"
).toCharArray();
public static void main(String[] a) {
var slots = new BitSet(32);
outer:
for (int mul = 1; mul != 0; mul++) {
slots.clear();
for (char c : ALL_CHARS) {
int index = (mul * c) >>> 27;
if (slots.get(index)) {
continue outer;
}
slots.set(index);
}
System.out.println(mul);
}
}
```
有趣的是,愿意深入探究的人可以通过查看 git 历史来追踪 Guava 是如何走到这个实现的。它从一个更简单的匹配器演变为一个取模 79(https://github.com/google/guava/commit/49ae40b145bebb8bd06a967edc03cb285577d0cc)的版本,这个版本对 Unicode 字符集有一个有趣的观察,但缺点是涉及除法运算,之后才发展为完美哈希函数(https://github.com/google/guava/commit/31e1f16989ec41b360b074caebdc58e0117ecf68)。
如果你对完美哈希的其他应用感兴趣——说来也巧,就在我写这篇文章的时候,Daniel Lemire 发布了一篇关于同一主题的博客(https://lemire.me/blog/2026/10/26/billion-query-per-second-lookup-tables-in-c/),其中给出了使用完美哈希检查 HTTP 方法的示例。
## 结论
当我第一次看到 Guava 中的空白字符检查代码时,它激发了我的好奇心,结果我学到了一些关于 Unicode 的知识,也看到了完美哈希函数的一个有趣应用——这种东西在实际项目中并不常见。
希望你也从这篇文章中学到了一些有趣的东西。如果你发现了什么让你好奇的东西,请务必写一篇博客文章分享出来。我确实感觉到,在以 AI 优先的世界里,这类技术分享文章正变得越来越稀少,人们几乎没有理由去探究像 `isWhitespace` 这样简单的函数背后的实现原理了。
## 订阅
我每次发布新文章时都会发送一封邮件。
或者,通过 RSS(https://serce.me/feed.xml)订阅。
相似文章
拆行难
深入探讨在 Python 中拆分多行文本的复杂性,涵盖众多 Unicode 换行字符以及 ASCII 中换行表示的历史渊源。
为何你应该检查你的gem的代码行数
文章讨论了使用代码行数作为评估Ruby gems的度量指标,指出这有助于理解实现复杂性,并鼓励一种通过“轻松阅读”来注重清晰度和可维护性的设计理念。
@java: 你是否还在 Java 中每行用 \n 和 + 拼接多行字符串?codebysophy 来讲解 JEP 37…
这条来自 Java 的推文重点介绍了 JEP 378,它为 Java 引入了文本块(text blocks)功能,这是一种多行字符串字面量特性,简化了跨多行编写字符串的过程,无需手动拼接和转义序列。
现在需要运行五个 Python 类型检查器吗?
一篇博文主张 Python 库维护者应优先在测试套件中运行多个类型检查器,以确保公共 API 的兼容性,并强调了兼容性和代码污染方面的挑战。
非尾部分隔符并不令人愉悦
本文认为,在编程语言和数据格式中禁止尾部分隔符(如逗号)会使代码编辑更容易出错且不一致,并主张语言设计应允许尾部分隔符以提供更好的开发者体验。