古有 Babel 通天塔,今有 Unicode 字符集,很多編程語言支持 Unicode,甚至在語法層面直接支持,絕大部分程序員可能會因此覺得自己懂 Unicode 了,自己的代碼不需要特別注意就能處理世界上所有語言的字元了,覺得 Unicode 高大上真善美,其實並非如此,下面講述下作為一個程序員,你需要了解的幾個關鍵概念,裡面頗有幾個大坑,看看各位知道幾個??


第一坑:surrogate pair

關於 Unicode、BMP、UCS-2、UCS-4、UTF-8、UTF-16、UTF-32 的概念就不再贅述了,網上有很多講述極好的文章,這裡面有兩個很重要的術語:

  1. code point: 指 Unicode 標準裏「字元」的編號,目前 Unicode 使用了 0 ~ 0x10FFFF 的編碼範圍。這裡字元二字加了引號,是因為這個概念很混淆,後面會再講述。
  2. code unit: 指某種 Unicode 編碼方式裏編碼一個 code point 需要的最少位元組數,比如 UTF-8 需要最少一個位元組,UTF-16 最少兩個位元組,UCS-2 兩個位元組,UCS-4 和 UTF-32 四個位元組,後面三個是定長編碼。

早期的時候,Unicode 只用到了 0~0xFFFF 範圍的數字編碼,這就是 BMP 字符集,UCS-2 編碼,很多語言就用 2 bytes 來表示 wchar_t 或者 char,典型的例子是 C/C++/Java。但後來 Unicode 組織胡搞瞎搞居然用到了超過這個範圍的數字,於是就出來 surrogate pair 的概念了。

Surrogate pair 是專門用於 UTF-16 的,以向後兼容 UCS-2,做法是取 UCS-2 範圍裏的 0xD800~0xDBFF(稱為 high surrogates) 和 0xDC00~0xDFFF(稱為 low surrogates) 的 code point,一個 high surrogate 接一個 low surrogate 拼成四個位元組表示超出 BMP 的字元,兩個 surrogate range 都是 1024 個 code point,所以 surrogate pair 可以表達 1024 x 1024 = 1048576 = 0x100000 個字元,這就是 Unicode 的字符集範圍上限是 0x10FFFF 的原因,為了照顧 UTF-16 以及一大堆採用了 UTF-16 的語言、操作系統(比如 Windows),這個上限不能突破,哪怕 UTF-8 和 UTF-32 可以編碼更大的範圍,實在是歷史悲劇!

採用 2 bytes 表示 wide char 的編程語言,比如 C/C++/Java,就有一個大坑:

  • string 的長度並不是切分成 char 數組的長度;
  • 翻轉 string 並不是簡單的把 string 切分成 char 數組然後翻轉數組並拼接!

Java 的 String 內部用的 UTF-16 編碼,其 String.length() 不處理 surrogate pair,它直接返回 code unit 的個數,也就是 Java 的 2 位元組 char 的個數,坑死人不賠命!

Java 的 StringBuilder.reverse() 和 StringBuffer.reverse() 則都對 surrogate pair 特殊處理,會把這個 pair 當成單個字元看待,可喜可賀,但是有可能會把非 surrogate 轉成 surrogate pair,比如 0xDC00 0xD800 這個字元串,第一個字元在 low surrogate 範圍,第二個字元在 high surrogate 範圍,由於順序不符合 surrogate pair 標準,所以這是兩個獨立字元,但翻轉後成了 0xD800 0xDC00,這是一個合法的 surrogate pair,表示一個字元,也就是兩個字元翻轉成了一個字元,等價於 0x100000,這是 surrogate pair 規則的根深蒂固問題,倒也不怪 Java。

到這裡,大家有沒有理解 UTF-2,UTF-16 以及 surrogate pair 多麼齷齪了吧,沒理解的同學請自覺重讀。

新興編程語言 Rust 的設計很好,其 String 是 UTF-8 編碼,其 char 是 UCS-4 編碼,並且 char 指的是 unicode scalar value,這個術語指去掉 high surrogate 和 low surrogate 這 2048 個 code points 後剩下的 code points,這個設計非常完美,完全避開了 surrogate pair—— surrogate pair 表達的字元就用 0x10000 ~ 0x100000 範圍直接表達了。

然而,Rust 並沒有做到極致,不能直接在語言裏處理 grapheme cluster 概念,而是要藉助外部庫,非常可惜。 Grapheme cluster 概念就是 Unicode 的第二大坑,且聽我道來。


第二坑:grapheme cluster

倘若你很牛掰知道 surrogate pair,我猜你極大概率不知道 grapheme cluster 這玩意。如同 phoneme 這個單詞是表示聽覺上的音素、音位,表示聽覺上可以分辨的最小單元,grapheme 這個單詞表示視覺上的字素、字位,也就是人們識別的、口頭指稱的「字元」,grapheme cluster 實際就是指 grapheme,我沒看懂 Unicode glossary 中這倆術語的區別。 大概是由於 Unicode Text Segmenation 這個標準的緣故,現在大家用 grapheme cluster 這個稱呼更多。

據兩個例子,印度語的 ?????? 是英語 hello 的意思:

> jshell
| Welcome to JShell -- Version 11
| For an introduction type: /help intro

jshell> var s = "????";
s ==> "??????"

jshell> s.length()
$2 ==> 6

jshell> s.split("\b{g}")
$3 ==> String[4] { "?", "?", "??", "??" }

jshell> s.toCharArray()
$4 ==> char[6] { ?, ?, ?, ?, ?, ? }

jshell> for (var c : s.toCharArray()) System.out.printf("%c %X
", c, (int)c)
? 928
? 92E
? 938
? 94D
? 924
? 947

可以看到,這個印度語字元串包含了 6 個 UTF-16 code units,6 個 Unicode code points,並且不是 surrogate code point,所以它按理說是 6 個 "Unicode character"。 但其實它是 4 個 grapheme clusters,也就是說「人可以識別的 4 個字元」。這裡 {g} 是 JDK 9 新加的正則表達式語法,表示 grapheme cluster 邊界。JDK 9 還新增了 X 表示一個 grapheme cluster(這是抄的 Perl 5 正則表達式語法)。

另一個取自 Python 庫 grapheme 的例子,這個詭異的下劃線單詞:u?n?d?e?r?l?i?n?e?d?,注意這並不是大家常見的那種下劃線,這裡的下劃線是字元本身自帶的。這個字元串是 10 個字元(grapheme clusters),但它對應了 20 個 Unicode code points!

> jshell
| Welcome to JShell -- Version 11
| For an introduction type: /help intro

jshell> var s = "u?n?d?e?r?l?i?n?e?d?";
s ==> "u?n?d?e?r?l?i?n?e?d?"

jshell> s.length()
$2 ==> 20

jshell> s.split("\b{g}")
$3 ==> String[10] { "u?", "n?", "d?", "e?", "r?", "l?", "i?", "n?", "e?", "d?" }

jshell> s.toCharArray()
$4 ==> char[20] { u, ?, n, ?, d, ?, e, ?, r, ?, l, ?, i, ?, n, ?, e, ?, d, ? }

jshell> for (var c : s.toCharArray()) System.out.printf("%c %X
", c, (int)c)
u 75
? 332
n 6E
? 332
d 64
? 332
e 65
? 332
r 72
? 332
l 6C
? 332
i 69
? 332
n 6E
? 332
e 65
? 332
d 64
? 332

這個字元串用 Java 的 new StringBuilder(s).reverse() 翻轉,結果是錯的,有興趣的同學可以試驗下。

好的,問題來了,要怎麼正確的按照 grapheme cluster 來翻轉這些奇特的字元串呢?我給兩個例子,大家可以再發揮下各自喜愛的語言,看你喜愛的語言標準功能能否處理,是否要藉助第三方庫——要借用第三方庫是比較矬的,而完全處理不了的語言則是最矬的!

第一個例子是 Perl 的,作為擁有史上最強、當今最強正則表達式引擎的編程語言,它同樣是Unicode支持當今最強語言,沒有之一!2002 年發布的 Perl 5.8.0 是第一個推薦使用的支持 Unicode 的版本,到 2017 年發布的 Perl 5.26.0,2018 年發布的 Perl 5.28.0,十六年的努力,Perl 在向後兼容的同時,在字元串、正則表達式、標準庫上原生的完美支持最新 Unicode 標準,「你們 Python 粉啊,對 Perl 的力量一無所知!」

Perl 的手冊頁 perluniintro、perlunicook、perlunifaq 對其 Unicode 支持有極好的介紹,下面這段代碼來自 perlunicook,核心代碼是 reverse $s =~ /X/g 這句,X 表示匹配 grapheme cluster。

> cat a.pl
#!/usr/bin/perl
use utf8; # so literals and identifiers can be in UTF-8
use v5.12; # or later to get "unicode_strings" feature
use strict; # quote strings, declare variables
use warnings; # on by default
use warnings qw(FATAL utf8); # fatalize encoding glitches
use open qw(:std :encoding(UTF-8)); # undeclared streams in UTF-8
use charnames qw(:full :short); # unneeded in v5.16

my $s = "??????";
my @a = $s =~ /(X)/g;
print "$s
";
print join(" ", @a), "
";

my $str = join("", reverse $s =~ /X/g);
@a = $str =~ /(X)/g;
print "$str
";
print join(" ", @a), "
";

> perl a.pl
??????
? ? ?? ??
??????
?? ?? ? ?

Java >= 9 的支持也不錯:

> jshell
| Welcome to JShell -- Version 11
| For an introduction type: /help intro

jshell> import java.util.*;

jshell> var s = "????";
s ==> "????"

jshell> var a = Arrays.asList(s.split("\b{g}"))
a ==> [?, ?, ?, ?]

jshell> Collections.reverse(a)

jshell> String.join("", a).split("\b{g}")
$5 ==> String[4] { "?", "?", "?", "?" }

除了用 {g},也可以用 Java 8 的 X 以及 java.util.regex.Pattern 類處理, 或者 java.text.BreakIterator:

> jshell
| Welcome to JShell -- Version 11
| For an introduction type: /help intro

jshell> import java.util.*;

jshell> import java.text.*;

jshell> var s = "????";
s ==> "????"

jshell> var iter = BreakIterator.getCharacterInstance(Locale.US);
iter ==> [checksum=0xee8de505]

jshell> iter.setText(s);

jshell> var end = iter.last();
end ==> 4

jshell> for (var start = iter.previous(); start != BreakIterator.DONE; end = start, start = iter.previous()) System.out.println(s.substring(start, end))
?
?
?
?

綜上,一個 Unicode native 的編程語言,其 char 類型應該至少是 4 bytes,其字元串、正則表達式應該能以 grapheme cluster 為「字元」單位

從古至今,編程語言層出不窮,但在 string 類型直接考慮 grapheme cluster 的鳳毛麟角,這其中 Erlang 語言以及基於 Erlang 的 Elixir 語言就是這個鳳毛麟角——「你們 Go 粉啊,對 Erlang 的力量一無所知!「

> erl
Erlang/OTP 20 [erts-9.1.5] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V9.1.5 (abort with ^G)
1> Reverse = string:reverse("????").
[2340,2360,2350,2344]
2> io:format("~ts~n",[Reverse]).
????
ok
3>

在對待 grapheme cluster 上,macOS、Windows 都沒能倖免,在 macOS Safari、notes、Chrome、MS Word 以及 Windows 上的 Notepad 試驗後發現,它們對於 ???? 的處理非常奇怪:

  • 主窗口的文本輸入框裏,從遊標移動上看,它們把這個字元串認為是三個字元,在 Safari 和 Chrome 的地址欄輸入框裏,從遊標移動上看,它倆認為這是四個字元;
  • 從這個字元串末尾按 Delete(mac) or Backspace(Windows) 刪除,需要按六次才能刪完,也就是說按六個 code point 處理的;
  • 從這個字元串開頭按 Fn-Delete(mac) or Delete(Windows)刪除,只需要按三次就能刪完,不知道什麼鬼;

聯繫到 glyph 的渲染本身又是一大坑,有 ligature 這種奇葩玩意,我只能慶幸我沒進入前端開發領域??

OK, 到這裡估計你們對 Unicode 已經累覺不愛了,不好意思,沒完,咱再看 Unicode 第三坑。


第三坑:combining character

從上面兩坑我們學習到,有兩個 surrogate code point 組成一個"字元"(unicode scalar value),有多個 unicode scalar value 組成一個「字元」(grapheme cluster),但其實還有一種情況:一個 Unicode 字元有多種表示形式,單個 code point 表示以及多個非 surrogate code points 的組合形式。

出現這種組合的原因主要是變音符號(diacritical marks),這裡麪包含了重音符號(accent),這是來自維基百科的一張表:

很多語言裏都有這種帶變音符號的字母,比如 He?ll? 裡面很個性的 e 和 o,比如下面這張海報:

為什麼要有組合字元呢?因為大家都想給某個字元加各種注音符號,如果給每個組合都申請一個 code point,那恐怕 Unicode 0x10FFFF 個 code point 沒多久就用光了,大家也沒機會喫飽了撐的往 Unicode 里加 emoji 字元了。。。據說韓國人很精明,他們給韓文的組合字元申請了單獨的 code point,而印度人沒太在意這事??

更坑爹的是,拉丁字母裡帶注音符號的字元使用廣泛,早已經單獨收入 Unicode 裏了,所以出現一個 Unicode 字元會有多種 code point 表示的情況,也就牽涉到Unicode 標準 Unicode normalization forms,這就是 Perl 的內置標準模塊 Unicode::Normalize 的用途,只有 normalization 之後,在代碼裏才能用普通的 "==" 操作符或者 "equals()" 方法等可靠的判斷 Unicode 字元、字元串的等價。

Unicode 定義了兩種等價關係:canonical equivalence 和 compatibility equivalence,前者嚴格、保守,指組合前後的字元在顯示上是相同的,後者則寬泛、激進,顯示上不同但認為是等價字元,如下圖所示。

兩種等價方式帶來四種 normalization form:

  1. Canonical Equivalence
    1. Normalization Form D (NFD): 把單 code point 表示的字元拆開(Decompose)成多個 code point 表示形式,並對一個字元的多個 code point 進行規範排序(比如一個字元上下各有一個點,那麼總得有一個點對應的 code point 排在另一個點對應的 code point 前面);
    2. Normalization Form C(NFC):把多個 code point 形式組合(compose)成單 code point 形式,如果沒有單 code point 形式,則做一個正規排序
  2. Compatibility Equivalence
    1. Normalization Form KD(NFKD): 這裡的 K 指 Compatibility。意義與 NFD 類似。
    2. Normalization Form KC(NFKC): 意義與 NFC 類似。

這四種 normalization form 對應 Perl 標準模塊 Unicode::Normalize 裏四個同名函數。用法是這樣的:

  1. 如果你的業務邏輯需要比較嚴的等價變換,那麼對外部輸入文本先做 NFD,這個意圖是把 ? (A 上一個小圓圈)變成 A + 小圓圈,這樣在排序等場合符合直覺,然後在處理完後 NFC,把組合字元儘可能變成單個 code point,以節約存儲。
  2. 如果你的業務邏輯需要比較寬的等價變化,那麼類似,輸入經過 NFKD,業務處理,然後經過 NFKC,輸出。

這個用法在 perlunicook 的 Generic Unicode-savvy filter一節有講到。

有個需要注意的點是,同一個 Unicode string,經過 NFD 再 NFC 後,其未必等於原始字元串,原因是有的同一個字元,在 Unicode 中有多個單 code point 表示,也許是規範制定過程中參與人員太多、字元太多、歷時太長導致的失誤或者兼容性考慮。下面是 Unicode Normalization Forms 標準中的一個例子:


結語

恭喜您看到這裡,感謝您的耐心,您的 Unicode 技能水平打敗全球 99.99% 的程序員了,從此以後,您可以底氣十足的跟人說「你這語言 or 軟體的 Unicode 支持很爛嘛,水平不行啊」!其實昨天之前我也不知道 surrogate pair 之後的坑,在閱讀 The Rust Programming Language 一書時瞭解到 grapheme cluster 的概念,然後研究之下發現道道還挺多???♂?不過話說來,雖然 Unicode 細節坑死人,依然不妨礙它是人類信息技術歷時上偉大的通天塔,功德無量!而隨著 emoji 字元的盛行,跨文化交流的盛行,大家使用的、編寫的軟體還是會時不時遇到這些坑的,比如編輯器、終端模擬器裏的字元對齊。

最後,安利下 Rust 語言,如今的 Rust 1.31.1 已經發布,語法趨於成熟,「一百種指針的寫法」時代已經成為過去(可憐 2015 年 Rust 1.0 發布,2017 年底纔算語法趨於塵埃落定),這是一份深思熟慮了八年的 Better C++,在 C++ 一脈的零開銷抽象上再上一層樓,同時提供極高的內存安全性,內存使用效率,運行效率,您可以入手開始輪了,祝願 Rust 在小工具、高性能低延遲服務端、遊戲、大數據、科學計算等等領域發光發熱,也算 Mozilla 泉下瞑目了??


推薦閱讀:
相關文章