Download PDF version of this article PDF

真实世界的字符串比较

如何正确处理 Unicode 序列

Torsten Ullrich,弗劳恩霍夫奥地利研究所和格拉茨工业大学

 

在许多编程语言中,字符串比较是初学者的一个陷阱。即使对于高级用户来说,当输入任何 Unicode 字符串时,比较通常也会引起问题。Unicode 中不同字符(由不同的字节序列表示)的语义等价性需要在比较字符串之前对其进行规范化。本文展示了如何正确处理 Unicode 序列;尽管所有示例都以 Java 说明,但问题和解决方案是与语言无关的。

Java 中众多内置数据类型之一是 String,它表示文本或字符序列。对两个字符串进行相等性比较时,常常会引发关于按值比较、对象引用比较、严格相等和宽松相等之间区别的问题。不幸的是,这些问题通常只得到肤浅的解答,很多方面没有被考虑在内。最重要的方面,几乎总是被遗忘的,是语义等价性。

 

面向对象编程

字符串是对象。要测试对象(即,两个引用类型)的相等性,《Java 语言规范》2 指出以下内容:

 

“如果操作数值都为 null 或都引用同一个对象或数组,则 == 的结果为 true;否则,结果为 false。[...] 虽然 == 可以用于比较 String 类型的引用,但这种相等性测试确定两个操作数是否引用同一个 String 对象。如果操作数是不同的 String 对象,即使它们包含相同的字符序列 [...],结果也为 false。可以通过方法调用 s.equals(t) 来测试两个字符串 st 的内容是否相等。”

 

这种行为在图 1 中展示,这是一个示例应用程序,说明了 Java 中的简单字符串比较。其输出结果是:

Real-world String Comparison

 

 Simple Test: 
   String #1: 'John' [74, 111, 104, 110]
   String #2: 'John' [74, 111, 104, 110]
   #1 == #2 : false
   #1 equals #2 : true

 

这个定义能解决所有问题吗?不幸的是,这只说对了一半。问题在于同一个字母可以用不同的字符序列来表示。

 

从 ASCII 到 Unicode

计算机只处理数字,不处理字母。因此,电子通信需要字符和数字之间的标准化映射(反之亦然)。在 20 世纪 60 年代,美国标准协会(American Standards Association,现称为美国国家标准协会 ANSI)创建了 ASCII(美国信息交换标准代码)。该标准将 128 个字符编码为 7 位整数。可打印字符基于英语,包括拉丁字母的大小写、10 个阿拉伯数字以及一些标点符号、单词标记和特殊字符。

20 世纪 70 年代的微处理器更喜欢使用 8 位。用 8 位表示一个字符会产生 256 个可能的值。虽然 8 位编码的前 128 个值对应于 ASCII 编码,但 128 到 255 的值有不同的解释,称为*代码页*。

Unicode 在 20 世纪 80 年代被提议作为一项新标准。在 Unicode 中,字母称为*代码点*,通常用四个十六进制数字表示。该标准试图为所有已知文化的所有有意义的文本元素定义代码。ASCII 字符已转移到 Unicode 标准中,并构成前 128 个代码点。在 2020 年 3 月发布的 13.0 版本中,Unicode 包含 143,859 个字符。3

虽然 ASCII 包含的字符太少,但 Unicode 可能包含的太多了。无论字符数量多少,缺乏唯一性都是一个问题。例如,字母 *é* 在 Unicode 中可以用多种方式表示。之前的字符串示例:

 

  String rene0 = "nu0052nu0065nu006enu00e9";

 

 

  String rene1 = "nu0052nu0065nu006enu0065nu0301";

 

使用名称 René 演示了这个问题。不幸的是,以下代码的结果:

 

  compare("Unicode Test", rene0, rene1);

 

使用相等运算符和 equals 方法的结果都不令人满意。运算符和方法都无法识别等效的字符串。因此,两种方法都返回 false

 

 Unicode Test: 
   String #1: 'René' [82, 101, 110, -61, -87]
   String #2: 'René' [82, 101, 110, 101, -52, -127]
   #1 == #2 : false
   #1 equals #2 : false

 

解决这个问题的方法是规范化例程——一种将字符串重写为规范的、正常的、“标准”形式的算法。在 Java 中,java.text.Normalizer 类提供了将文本转换为 Unicode 标准附件 #15 中描述的标准规范化形式的功能。3 按照 W3C(万维网联盟)的建议,以下解决方案使用规范分解,然后是规范组合——NFC(规范化形式规范组合)。图 2 展示了 Java 中的字符串规范化。它返回:

Real-world String Comparison

 

 Unicode Test: 
   String #1: 'René' [82, 101, 110, -61, -87]
   String #2: 'René' [82, 101, 110, 101, -52, -127]
   #1 == #2 : false
   #1 equals #2 : false
 
Normalized Unicode Test:
   String #1: 'René' [82, 101, 110, -61, -87]
   String #2: 'René' [82, 101, 110, -61, -87]
   #1 == #2 : false
   #1 equals #2 : true

 

图 2 中的示例是在“友好”环境中比较字符串的首选方法。

在“不友好”环境中处理字符串和字符串比较的方法在关于 Unicode 安全机制的 Unicode 技术标准 #394,5 中描述。如果必须根据视觉外观来区分字符(例如,为了检测网络钓鱼攻击),那么问题会变得更加复杂。(Irongeek.com 对此主题提供了一种相当有趣且实用的见解。)

 

与语言无关的问题

尽管所有这些示例都通过 Java 中的源代码进行了说明,但 Unicode 规范化问题与任何编程语言无关。

• 在 Python 中,unicodedata 模块提供了一个名为 normalize 的函数,该函数将字符串转换为规范化形式——NFC、NFKC(规范化形式兼容组合)、NFD(规范化形式规范分解)和 NFKD(规范化形式兼容分解)。

• C# 中的 System 命名空间中的函数 public string Normalize() 返回一个新的字符串,其文本值与输入字符串等效,但其二进制表示形式为 Unicode 规范化形式 NFC。

• 在 C++ 中,Unicode 字符串规范化不是标准库的一部分。因此,必须使用外部库——例如 Glib 中的 g_utf8_normalizeQt 中的 QString::normalized;或 Windows.h 中的 NormalizeString

在 JavaScript 中,ECMAScript 6 标准引入了规范化函数 String.prototype.normalize(),它负责 Unicode 规范化。

 

字符串操作

除了相等性测试之外,所有其他字符串函数当然也应该支持 Unicode。例如,单词的长度不应取决于其 Unicode 表示形式;字符串的反转不应反转必须以特定顺序解释的代码单元;并且匹配语义单元(例如,标点符号)的正则表达式必须意识到 Unicode。例如,Unicode 空格字符列表包括:

 

 SPACE U+0020 
NO BREAK SPACE U+00A0
PUNCTUATION SPACE U+2008
THIN SPACE U+2009
HAIR SPACE U+200A
. . . . . .

 

Wikipedia 上关于空白字符的文章(https://en.wikipedia.org/wiki/Whitespace_character)列出了所有语义等效(以及更多)的空白字符(截至 2021 年 4 月)。

在“恶意字符串大列表”(Big List of Naughty Strings)中可以找到一个有趣的奇特事物(测试用例)集合,其中包括当用作用户输入数据时很可能引起问题的字符串。1 查看此列表可以了解 Unicode 的可能性(请参阅图 3,其中显示了 Unicode 如何包含字母的几种变体——尤其是斜体、粗体等)。

Real-world String Comparison

简而言之,字符串比较绝非易事。

 

参考文献

1. 恶意字符串大列表。GitHub;https://github.com/minimaxir/big-list-of-naughty-strings/blob/master/blns.txt

2. Gosling, J., Joy, B., Steele, G., Bracha, G., Buckley, A., Smith, D., Bierman, G. 2021. 《Java 语言规范》,Java SE 16 版。Oracle America, Inc.;https://docs.oracle.com/javase/specs/jls/se16/jls16.pdf

3. Unicode 联盟。2018. 《Unicode 标准》,版本 11.0 核心规范;http://www.unicode.org/versions/components-11.0.0.html

4. Unicode 联盟。2020. Unicode 标准附件 #15,《Unicode 规范化形式》;https://www.unicode.org/reports/tr15/tr15-50.html

5. Unicode 联盟。2020 Unicode 技术标准 #39,《Unicode 安全机制》;https://www.unicode.org/reports/tr39/tr39-22.html

 

Torsten Ullrich 是弗劳恩霍夫奥地利研究有限公司视觉计算部门以及奥地利格拉茨工业大学计算机图形学和知识可视化研究所的研究员。 他曾在卡尔斯鲁厄大学(TH)学习数学,并于 2011 年在格拉茨工业大学获得计算机科学重建几何学博士学位。他的主要研究领域是视觉计算与数值优化、统计和数据分析的结合。他是弗劳恩霍夫奥地利研究有限公司视觉计算业务领域的副主管,负责科学研究协调。

版权所有 © 2021,所有者/作者持有。出版权已授权给 。

acmqueue

最初发表于 Queue 第 19 卷,第 3 期
数字图书馆 中评论本文





更多相关文章

Ethan Miller, Achilles Benetopoulos, George Neville-Neil, Pankaj Mehra, Daniel Bittman - 远内存中的指针
有效利用新兴的远内存技术需要考虑在父进程上下文之外操作丰富连接的数据。正在开发中的操作系统技术通过公开诸如内存对象和全局不变指针之类的抽象来提供帮助,这些抽象可以由设备和新实例化的计算遍历。这些想法将使运行在具有分离内存节点的未来异构分布式系统上的应用程序能够利用近内存处理来获得更高的性能,并独立扩展其内存和计算资源以降低成本。


Simson Garfinkel, Jon Stewart - 磨砺你的工具
本文介绍了我们在首次发布十年后更新高性能数字取证工具 BE (bulk_extractor) 的经验。在 2018 年至 2022 年间,我们将该程序从 C++98 更新到 C++17。我们还进行了完整的代码重构并采用了单元测试框架。DF 工具必须经常更新,以跟上其使用方式的变化。对 bulk_extractor 工具的更新描述可以作为可以而且应该做什么的示例。


Pat Helland - 自主计算
自主计算是一种商业工作模式,它使用协作来连接封地及其特使。这种基于纸质表格的模式已经使用了几个世纪。在这里,我们解释封地、协作和特使。我们研究特使如何在自主边界之外工作,并在保持局外人的同时提供便利。我们还研究了如何在不同的封地之间启动工作、长时间运行并最终完成。


Archie L. Cobbs - 持久性编程
几年前,我的团队正在为一个增强型 911 (E911) 紧急呼叫中心进行商业 Java 开发项目。我们试图使用传统的 Java over SQL 数据库模型来满足该项目的数据存储要求时,感到非常沮丧。在对该项目的特定要求(和非要求)进行一些反思之后,我们深吸一口气,决定从头开始创建我们自己的自定义持久层。





© 保留所有权利。

© . All rights reserved.