一般而言,Unicode是個字元集,對世界大部份文字系統做了整理與編碼,主流程式語言至今也多半支援Unicode,如果你必須處理文字,勢必也要認識規則表示式(Regular expression)如何處理Unicode,並留意語言與程式庫支援間的差異性。

字元就是碼點

規則表示式用來處理文字比對,文字可以由零或多個字元組成,然而,何謂字元?

一般初學者在接觸規則表示式時,處理的對象通常是以ASCII為主,也就是現代英文中會出現的文字,所以,對ASCII來說,字元就是a到z、A到Z、0到9以及,.+-*/等符號,而規則表示式中的預定義字元類(Predefined character class)處理的對象,也是以ASCII中的符號為主,如果要處理Unicode呢?例如判斷是否為全形空白?

Unicode中規範了大量的空格符號(https://goo.gl/cstj8h),而這邊指的全形空白,是指中日韓統一表意文字(CJK Unified Ideographs)規範的空白,也就是Unicode碼點(Code point)U+3000,語言或程式庫支援Unicode規則表示式的話,可提供\uhhhh表示法來指定碼點,因此,規則表示式當中的\u3000,可用來比對全形空白。以Java為例," ".matches("\\u3000")結果會是true。

簡單來說,如果要處理Unicode的話,一個字元並不是指a到z這類符號,而是指一個Unicode碼點。所以,[a-z]表示式以Unicode的觀點來看,應該是[\u0061-\u007A],而表示式.是指符合Unicode任一碼點。因此,字元就是碼點的觀念,必須先建立,才不會在比對上引發問題。

例如,å這個字元的碼點為U+00E5,然而,也可以使用a字元與∘組合標示(combining mark)來表示。試著在某些編輯器上鍵入a與∘,就會顯示為å,若以Java字串表示,就是"a\u030A",然而,若使用規則表示式進行比對的話,比對到的是a字元,而不是å。

如果你使用Java,要留意" ".matches("\\u3000")與" ".matches("\u3000")的不同,後者是Java在字串中使用碼點指定某字元的表示方式,也就是後者相當於撰寫" ".matches(" ")罷了。就這個例子來說,雖然兩者結果都是true,意義卻不同,在面對規範等價(Canonical equivalence)時,就會有不同的結果。

方才談到的"a\u030A"就是個例子,這個組合實際上表示å,而Java中也可以使用"\u00e5"來表示,在Java中使用規則表示式API,來比對"a\u030A"與"\u00e5"時,預設兩個是不等價的,然而編譯規則表示式時,若啟用了Pattern.CANON_EQ,兩者會視為相同。

var regex2 = Pattern.compile("a\u030A", Pattern.CANON_EQ);
regex2.matcher("\u00E5").find(); // true

在上例中,"a\u030A"代表"å",若將"a\u030A"寫為"a\\u030A",那麼,"\\u030A"會被視為規則表示式\u030A,這時,只是表示比對a字元後要有個∘字元,結果就會是false。

Unicode分類、文字、區塊

在Unicode的規範中,每個Unicode字元會隸屬於某個分類(https://goo.gl/hFDhNs)。例如,å是個字母(Letter),若想比對字母,可以使用\pL,若不想比對字母,可以使用\PL,例如Java中,"\u00e5".matches("\\pL")會是true,可以進一步指定子特性,這時必須加上{},例如\p{Lu}表示大寫字母、\p{Ll}表示小寫字母。

可以加上Is,例如\p{IsL}、\p{IsLu}等,也可以使用 \p{general_category=Lu}或簡寫為\p{gc=Lu},實際上,還有冗長名稱的寫法,不過,支援的方式端視語言而定。以Java來說,不能撰寫\p{Letter},而必須撰寫為\p{IsLetter},而\p{White_Space}必須寫為\p{IsWhite_Space},例如,若要判斷全形空白,寫為" ".matches("\\p{IsWhite_Space}")的結果會是true。

有的語言可能會使用多種文字來書寫,例如日語就包含了漢字、平假名、片假名等文字,有的語言只使用一種文字,例如泰文。Unicode將碼點群組為文字(script)特性(https://goo.gl/8aXxjB),可以使用IsHan、script=Han或sc=Han的方式來指定特性,例如測試漢字,"林".matches("\\p{IsHan}")會傳回true。

Unicode中規範了區塊(block),也就是一個碼點連續範圍,這是與文字不同之處。因為,文字不一定是連續碼點範圍,而可能來自多個碼點範圍。在規則表示式方面,我們可以使用InCJKUnifiedIdeographs、block=CJKUnifiedIdeographs或blk=CJKUnifiedIdeographs方式來指定區塊。其中的CJKUnifiedIdeographs,代表的碼點範圍為\u4E00-\u9FFF,也就是測試中文時常用的Unicode碼點範圍。例如,"林".matches("\\p{InCJKUnifiedIdeographs}")結果就會是true。

規則表示式的一致性

原始規則表示式中的預定義字元類,是以ASCII中的符號為主,例如\s表示的空白字元,相當於[\t\n\x0B\f\r],因此沒辦法比對全形空白,Unicode規則表示式的規範中,提供了建議相容特性(https://goo.gl/m7xYku),也就是針對原始規則表示式有預定義字元類等表示,一旦遇上Unicode時,可以遵照哪些行為。

在Java中,在編譯規則表示式時,我們可以透過UNICODE_CHARACTER_CLASS旗標,若是嵌入式表示的話,是使用(?U)來啟用相容特性,例如,(?U)\s的話,相當於\p{IsWhite_Space},因此" ".matches("(?U)\\s")結果會是true。

\w原本用來比對任一ASCII字元,相當於[a-zA-Z0-9_],因此"林".matches("\\w")結果會是false,然而,"林".matches("(?U)\\w")結果會是true。根據Java API文件,(?U)\w的行為,相當於[\p{Alpha}\p{gc=Mn}\p{gc=Me}\p{gc=Mc}\p{Digit}\p{gc=Pc}\p-{IsJoin_Control}],啟用相容特性的好處由此可見。

在字母大小寫比對上,在設定Pattern.CASE_INSENSITIVE時,只能比較英文字母,此時,我們可以加上Pattern.UNICODE_CASE,啟用Unicode版本的忽略大小寫。例如,比較Ä與ä:

var regex1 = Pattern.compile("\u00C4", Pattern.CASE_INSENSITIVE);
regex1.matcher("\u00E4").find(); // false
var regex2 = Pattern.compile("\u00C4", Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
regex2.matcher("\u00E4").find(); // true

先前談到,如果使用.來比對a與∘組合,結果會是false,在Java 9中,新增了一個\X,可用來直接比對具有組合標示的字元,例如"a\u030A".matches(".")會是false,而"a\u030A".matches("\\X")會是true。

語言或程式庫支援差異

在使用規則表示式處理Unicode時,多數開發者可能知道\uhhhh指定碼點的寫法,不過,其實這指的多半是,如何在字串中指定某個Unicode碼點(雖然就效果上經常相同),而不是真正用上規則表示式在Unicode的支援。

對於規則表示式如何採用Unicode,Unicode組織在〈UNICODE REGULAR EXPRESSIONS〉(https://goo.gl/rVJwD5)做了規範,如果必須處理Unicode,建議閱讀一下其中內容。

然而,各語言或程式的支援程度並不相同,也有一些方言,部份差異性可以參考〈Unicode Regular Expressions〉(https://goo.gl/Sf6n2V)這份文件,當中列出了Java、Perl、PCRE、XRegExp等的一些比較。

如果對JavaScript、Python、Java的規則表示式支援,感到興趣,我在〈Regex〉(https://goo.gl/6ohUyM)也整理了一些相關的文件可供參考。

專欄作者

熱門新聞

Advertisement