“�”是什么?
U+FFFD,即替换字符(Replacement Character),只是 Unicode 表中的另一个码位。应用程序和库可以在检测到 Unicode 错误时使用它。
如果将码位的一半切掉,那么另一半也就没什么用了,除了显示错误。这时就会使用�。
JS 版本
const text = "前端柒八九"; const encoder = new TextEncoder(); const bytes = encoder.encode(text); const partial = bytes.slice(0, 11); const decoder = new TextDecoder("UTF-8"); const result = decoder.decode(partial); console.log(result); // 输出 "前端柒�"
Rust 版本
fn main() { let text = "前端柒八九"; let bytes = text.as_bytes(); let partial = &bytes[0..11]; let result = String::from_utf8_lossy(partial); println!("{}", result); // 输出 "前端柒�" }
在 JavaScript 中使用 TextEncoder 和 TextDecoder 来处理编码,而在 Rust 中使用 String::from_utf8_lossy 来处理字节。它们的目标是在 UTF-8 编码中处理文本并截取部分字节。
4. UTF-32 问题
UTF-32 非常适用于处理码位。它的编码方式中,每个码位始终是 4 个字节,那么strlen(s) == sizeof(s) / 4,substring(0, 3) == bytes[0, 12](上面代码为伪代码)等等。
问题在于,我们不想处理码位。一个码位即不是一个书写单位,又并不总是代表一个字符。我们应该处理的是扩展形素簇(extended grapheme clusters),或简称为形素(graphemes)。
形素是在特定书写系统的上下文中的最小可区分的书写单位。
例如,ö 是一个形素,é也是一个形素。还有像각这样的形素。基本上,形素是用户认为是一个字符的单元。
问题是,在 Unicode 中,一些形素是由多个码位编码的!
例如,é(一个单一的形素)在 Unicode 中编码为 e(U+0065 拉丁小写字母 E)+ ´(U+0301 连接重音符)。两个码位!
它也可能不止两个:
☹️是U+2639+U+FE0F👨🏭是U+1F468+U+200D+U+1F3ED🚵🏻♀️是U+1F6B5+U+1F3FB+U+200D+U+2640+U+FE0Fy̖̠͍̘͇͗̏̽̎͞是U+0079+U+0316+U+0320+U+034D+U+0318+U+0347+U+0357+U+030F+U+033D+U+030E+U+035E
即使在最宽的编码 UTF-32 中,👨🏭 仍需要三个 4 字节单元来进行编码。它仍然需要被视为一个单独的字符。
我们可以将 Unicode 本身(没有任何编码)视为可变长度的。
扩展形素簇(Extended Grapheme Cluster)是一个或多个 Unicode 码位的序列,必须将其视为一个单独的、不可分割的字符。
因此,在码位级别上:不能只取序列的一部分,它总是应该作为一个整体选择、复制、编辑或删除。
不正确使用形素簇会导致像这样的错误:
无论是否选择UTF-32还是UTF-8在处理形素上遇到相似的问题。所以如何使用形素才是我们应该关心的。
5. Unicode 病症
上面的例子中大部分都是涉及到表情符号,这会给人一种错觉。Unicode只有在表示表情符号时,会遇到问题。--其实不是。
扩展形素簇也用于常见的语言。
例如:
ö(德语)是一个单一字符,但包含多个码位(U+006FU+0308)。ą́(立陶宛语)是U+00E1U+0328。각(韩语)是U+1100U+1161U+11A8。
所以,问题不仅仅是表情符号。
"🤦🏼♂️".length 是多少?
不同的编程语言给出了不同的结果。
Python 3:
>>> len("🤦🏼♂️") 5
JavaScript / Java / C#:
>> "🤦🏼♂️".length 7
Rust:
println!("{}", "🤦🏼♂️".len()); // => 17
不同的语言使用不同的内部字符串表示(UTF-32、UTF-16、UTF-8),并以存储字符的单位(整数、短整数、字节)来报告长度。
但是!如果你问任何不懂编程理论的人,他们会给你一个明确的答案:🤦🏼♂️ 字符串的长度是 1。
这就是扩展形素簇的意义:人们视为单一字符的内容。在这种情况下,🤦🏼♂️ 显然是一个单一字符。
🤦🏼♂️ 由 5 个码位组成(U+1F926U+1F3FBU+200DU+2642U+FE0F)仅仅是实现细节。它不应该被分开,不应该被计为多个字符,文本光标不应该定位在其中,不应该被部分选择,等等。
这是文本的一个不可分割的单位。在内部,它可以被编码为任何形式,但对于面向用户的 API,应该将其视为一个整体。
唯一正确处理此问题的现代语言是 Swift:
print("🤦🏼♂️".count) // => 1
而对于我们比较熟悉的JS和Rust,我们可以使用一些方式做一下封装。
function visibleLength(str) { return [...new Intl.Segmenter().segment(str)].length; } visibleLength("🤦🏼♂️"); // 输出结果为1
当然,我们还可以校验其他的形素。
visibleLength("ö"); // => 1 visibleLength("👩💻"); // => 1 visibleLength("👩💻👩❤️💋👩"); // => 2 visibleLength("と日本語の文章"); // => 7
但是呢,Intl.Segmenter的兼容性不是很好。
如果,我们要实现多浏览器适配,我们可以找一些第三方的库。
如果想了解更多细节,可以参考JS 如何正确处理 Unicode
对于Rust我们可以使用unicode_segmentationcrate。
extern crate unicode_segmentation; // "1.9.0" use std::collections::HashSet; use unicode_segmentation::UnicodeSegmentation; fn count_unique_grapheme_clusters(s: &str) -> usize { let is_extended = true; s.graphemes(is_extended).collect::<HashSet<_>>().len() } fn main() { assert_eq!(count_unique_grapheme_clusters(""), 0); assert_eq!(count_unique_grapheme_clusters("🤦🏼♂️"), 1); assert_eq!(count_unique_grapheme_clusters("🇺🇸"), 1); }
6. 如何检测扩展形素簇
大多数编程语言选择了简单的方式,允许我们迭代字符串时使用 1-2-4 字节的块,但不支持直接处理扩展形素簇。
由于它是默认方式,结果我们看到了损坏的字符串:
如果遇到这种问题,我们首先的就是应该想到使用Unicode 库。
使用库
即使是像 strlen、indexOf 或 substring 这样的基本操作也应该使用 Unicode 库!
例如:
C/C++/Java:使用 ICU。这是Unicode自身发布的库,包含了关于文本分割的所有规则。Swift:只需使用标准库。Swift 默认情况下会正确处理。Javascript的话,我们上面提到过,可以使用浏览器内置功能Intl.Segmenter或者graphemer/text-segmentationRust而言,我们可以使用unicode_segmentation
不管选择哪种方式,确保它使用的是新版本的 Unicode,因为形素的定义会随版本而变化。
Unicode 规则更新
从大约 2014 年开始,Unicode 每年都会发布其标准的重大修订版本。
每年更新
随之而来的不良反映就是,定义形素簇的规则每年也会发生变化。今天被认为是由两个或三个独立码位组成的序列,明天可能会成为一个形素簇!这种朝令夕改的做法,很是让人深恶痛绝。
更糟糕的是,我们自己的应用程序的不同版本可能运行在不同的 Unicode 标准上,并报告不同的字符串长度!
7. "Å" !== "Å" !== "Å"
将其中任何一个复制到你的 JavaScript 控制台:
"Å" === "Å"; "Å" === "Å"; "Å" === "Å";
你会得到让你匪夷所思的答案。没错,它们的打印结果都是false。
还记得之前的,ö 是由两个码位组成,U+006FU+0308 。基本上,Unicode 提供了多种编写字符如 ö 或 Å 的方式。
- 通过将普通的拉丁字母
A与一个组合字符组合成Å, - 或者使用已经预先组合的码位
U+00C5。
因为,它们看起来是相同的(Å 与 Å),所以从用户的角度,我们就认为它们应该是相同的,但结果却和我们的想法大相径庭。
这就是为什么我们需要规范化。有四种形式:
这里先从NFD和NFC介绍。
NFD(Normalization Form C) 尝试将一切都分解为最小可能的部分,并如果存在多个部分,则按照规范顺序对这些部分进行排序。
- 它消除任何规范化差异,并生成一个分解的结果
NFC(Normalization Form C),尝试将一切组合成已经预先组合的形式(如果存在)
- 它消除任何规范化差异,通常生成一个合成的结果
不同的形式用于不同的用例,以确保文本在不同的方式下都保持一致。所以,尽管"Å" !== "Å" !== "Å",但通过适当的规范化,我们可以使它们等同。
对于某些字符,Unicode 中还存在多个版本。例如,有 U+00C5 带有上面环圈的拉丁大写字母 A,但还有外观相同的 U+212BÅngström 符号。
这些字符在规范化过程中也会被替换,以确保它们的一致性。
NFD 和 NFC 被称为“规范化规范”(canonical normalization)。另外两种形式是“兼容规范化”(compatibility normalization):
NFKD试图将所有内容分解,并使用默认形式替换视觉变体。
- 它消除规范化和兼容性差异,并生成一个分解的结果
NFKC试图将所有内容组合在一起,同时用默认形式替换视觉变体。
- 它消除规范化和兼容性差异,并通常生成一个合成的结果
视觉变体是表示相同字符的独立 Unicode 码位,但它们应该呈现不同的方式。比如,①、⁹ 或 𝕏。
在比较字符串或搜索子字符串之前,进行规范化!
Unicode规范化传送 🚪
在JavaScript 中,我们可以使用 normalize() 方法来实现 NFC(Normalization Form C)和 NFD(Normalization Form D)。
const str1 = "Å"; const str2 = "Å"; const normalizedStr1 = str1.normalize("NFC"); // NFC 形式 const normalizedStr2 = str2.normalize("NFC"); // NFC 形式 console.log(normalizedStr1 === normalizedStr2); // true
上述代码首先使用 normalize('NFC') 方法将两个字符串都转换为 NFC 形式,然后比较它们是否相等。这将使 "Å" 和 "Å" 的比较结果为 true。
如果使用 NFD 形式,只需将 normalize('NFC') 更改为 normalize('NFD') 即可。
8. Unicode 取决于区域设置
俄罗斯名字尼古拉
在Unicode 中编码为 U+041D 0438 043A 043E 043B 0430 0439。
保加利亚名字尼古拉
也写成 U+041D 0438 043A 043E 043B 0430 0439。
它们的Unicode值完全一样,但是所显示的字体信息却不尽相同。是不是有种小脑萎缩的感觉。
然后心中有一个 🤔,计算机如何知道何时呈现保加利亚风格的字形,何时使用俄罗斯的字形?
其实,计算机也不知。Unicode 并不是一个完美的系统,它有很多不足之处。其中一个问题是将本应呈现不同外观的字形分配给相同的码位,比如西里尔字母的小写字母 K 和保加利亚的小写字母 K(都是 U+043A)。
针对一些表音语言这块还能好点,但是到了我们大亚洲,很多国家的文字都是表意的。许多汉字、日语和韩语表意字形的写法都截然不同,但被分配了相同的码位。
Unicode 的动机是为了节省码位空间。渲染信息应该在字符串外部以区域设置/语言元数据的方式传递。
在实践中,依赖于区域设置带来了许多问题:
- 作为元数据,
区域设置通常会丢失。 - 人们不限于使用单一区域设置。例如,我们可以阅读和写作中文,美国英语、英国英语、德语和俄语。
- 难以混合和匹配。比如在保加利亚文本中使用俄罗斯名字,反之亦然。
- 没有地方可以指定区域设置。即使制作上面的两个屏幕截图也不容易,因为在大多数软件中,没有下拉菜单或文本输入来更改区域设置。
9. 处理特殊语言
另一个不幸的例子是土耳其语中无点 i 的 Unicode 处理。
与英语不同,土耳其语有两种 I 变体:有点和无点。
Unicode决定重用ASCII中的 I 和 i,并只添加了两个新的码位:İ和ı。
这导致了在相同输入上 toLowerCase/toUpperCase 表现不同:
var en_US = Locale.of("en", "US"); var tr = Locale.of("tr"); System.out.println("I".toLowerCase(en_US)); // => "i" System.out.println("I".toLowerCase(tr)); // => "ı" System.out.println("i".toUpperCase(en_US)); // => "I" System.out.println("i".toUpperCase(tr)); // => "İ"
所以,我们在不知道字符串是用哪种语言编写的情况下将字符串转换为小写,会出现问题。
如果我们项目中涉及到土耳其语的字符转换,在 JS 中toLowerCase是达不到上面的要求的。因为,在JavaScript中,toLowerCase方法默认使用Unicode规范进行转换,根据Unicode的规范,大写 I 被转换为小写 i,而不是 ı。这是因为JavaScript的toLowerCase方法按照Unicode的标准工作。
要想使用JS正确处理上面的问题,我们就需要额外的 API.
"I".toLocaleLowerCase("tr-TR"); // => "ı" "i".toLocaleUpperCase("tr-TR"); // => "İ"
我们也可以通过对String.prototype上做一层封装。
String.prototype.turkishToUpper = function () { var string = this; var letters = { i: "İ", ş: "Ş", ğ: "Ğ", ü: "Ü", ö: "Ö", ç: "Ç", ı: "I" }; string = string.replace(/(([iışğüçö]))+/g, function (letter) { return letters[letter]; }); return string.toUpperCase(); }; String.prototype.turkishToLower = function () { var string = this; var letters = { İ: "i", I: "ı", Ş: "ş", Ğ: "ğ", Ü: "ü", Ö: "ö", Ç: "ç" }; string = string.replace(/(([İIŞĞÜÇÖ]))+/g, function (letter) { return letters[letter]; }); return string.toLowerCase(); }; // 代码演示 "DİNÇ".turkishToLower(); // => dinç "DINÇ".turkishToLower(); // => dınç
这样就可以正确规避JS针对土耳其语言中的准换问题。
在Rust中,我们可以使用如下代码:
fn turkish_to_upper(input: &str) -> String { let letters = [ ('i', "İ"), ('ş', "Ş"), ('ğ', "Ğ"), ('ü', "Ü"), ('ö', "Ö"), ('ç', "Ç"), ('ı', "I"), ]; let mut result = String::new(); for c in input.chars() { let mut found = false; for &(source, target) in &letters { if c == source { result.push_str(target); found = true; break; } } if !found { result.push(c); } } result.to_uppercase() } fn turkish_to_lower(input: &str) -> String { let letters = [ ('İ', "i"), ('I', "ı"), ('Ş', "ş"), ('Ğ', "ğ"), ('Ü', "ü"), ('Ö', "ö"), ('Ç', "ç"), ]; let mut result = String::new(); for c in input.chars() { let mut found = false; for &(source, target) in &letters { if c == source { result.push_str(target); found = true; break; } } if !found { result.push(c); } } result.to_lowercase() } fn main() { let input = "İşğüöçı"; let upper_result = turkish_to_upper(input); let lower_result = turkish_to_lower(input); println!("Upper: {}", upper_result); //Upper: İŞĞÜÖÇI println!("Lower: {}", lower_result); // Lower: işğüöçı }
后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。











