其實你並不懂 Unicode
古有 Babel 通天塔,今有 Unicode 字符集,很多編程語言支持 Unicode,甚至在語法層面直接支持,絕大部分程序員可能會因此覺得自己懂 Unicode 了,自己的代碼不需要特別注意就能處理世界上所有語言的字元了,覺得 Unicode 高大上真善美,其實並非如此,下面講述下作為一個程序員,你需要了解的幾個關鍵概念,裡面頗有幾個大坑,看看各位知道幾個??
第一坑:surrogate pair
關於 Unicode、BMP、UCS-2、UCS-4、UTF-8、UTF-16、UTF-32 的概念就不再贅述了,網上有很多講述極好的文章,這裡面有兩個很重要的術語:
- code point: 指 Unicode 標準裏「字元」的編號,目前 Unicode 使用了 0 ~ 0x10FFFF 的編碼範圍。這裡字元二字加了引號,是因為這個概念很混淆,後面會再講述。
- 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),這是來自維基百科的一張表: