字符集與編碼五之代碼單元及length方法

在前一篇章中已經談了不少Unicode中的重要概念,但仍還有一些概念沒有提及,一則不想一下說太多,二則有些概念也無法三言兩語就說清楚,本文在此準備談一下代碼單元及由此引發的一些話題。

什麼是代碼單元?UTF-8,UTF-16和UTF-32中的8,16和32究竟指什麼?


代碼單元指一種轉換格式(UTF)中最小的一個分隔,稱爲一個代碼單元(Code Unit),因此,一種轉換格式只會包含整數個單元。

各種UTF編碼方案下的代碼單元

  1. UTF-8的8指的就是最小爲8位一個單元,也即一字節爲一個單元,UTF-8可以包含一個單元,二個單元,三個單元及四個單元,對應即是一,二,三及四字節。

  2. UTF-16的16指的就是最小爲16位一個單元,也即兩字節爲一個單元,UTF-16可以包含一個單元和兩個單元,對應即是兩個字節和四個字節。我們操作UTF-16時就是以它的一個單元爲基本單位的。

  3. 同理,UTF-32以32位一個單元,它只包含這一種單元就夠了,它的一單元自然也就是四字節了。

所以,現在我們清楚了:

UTF-X中的數字X就是各自代碼單元的位數。

你可能要問,Unicode整出這麼多的概念來做什麼?討論代碼單元這犢子有什麼用?下面我將會以Java爲例來做些說明,我們首先討論一個非常普通的方法,string.length(),你可能覺得自己已經完全理解了length,不就是字符串長度嗎?可是如果深入再問下這個長度究竟怎麼來的,它跟這裏的代碼單元有什麼關係?你可能就未必能說清楚了,這裏面的東西可能比你想像的要複雜。

注:有些讀者的語言背景可能並不是Java,但我想這裏討論的情況對於無論是哪種語言平臺它都有一定的借鑑意義。任何語言平臺它去處理Unicode時都必然要面對類似的問題。

Java中的string.length究竟指什麼?


如果你閱讀一下java中String類中的length方法的說明,就會注意到以下文字:

Returns the length of this string. The length is equal to the number of Unicode code units in the string.

返回字符串的長度,這一長度等於字符串中的Unicode代碼單元的數目。

我們知道Java語言裏String在內存中以是UTF-16方式編碼的,所以長度即是UTF-16的代碼單元數目。

BMP內的字符長度

通過前面的篇章我們知道,UTF-16保存BMP中的字符時使用了兩字節,也即一個代碼單元。這就意味着,Java中所有的BMP字符長度都是1,無論它是英文還是中文。這一點我想大家都沒有疑問。代碼示例如下:

    @Test
    public void testStringLength() {
        String str = "hello你好";
        assertThat(str.length()).isEqualTo(7);
    }

這裏我們用了JUnit的方式來測試,如果你對此還不熟悉,可以簡單理解它是對使用main方法來測試的一種更好替代。更多瞭解,百度一下,你就知道(抑或是谷歌一下,hope you are feeling lucky if you have no proxy(like VPN, goAgent, etc)。

不出所料,“hello你好”有5個英文字符加2箇中文字符,所以它的長度就是7。因此,很多人就得出了一個“結論”:“string.length就是字符數”。但我們知道UTF-16同樣可以表示增補平面中的字符,而且用了四字節來表示,也即兩個代碼單元。如果length的API說明所言不虛的話,那麼,一個增補平面中的字符,它的長度將是2!

增補平面中的字符長度

以前面反覆提到的U+1D11E爲例,這是一個五位的碼點,用UTF-8編碼需要四個字節,用UTF-16需要一個代理對,同樣是四個字節才能表示。這是一個音樂符(MUSICAL SYMBOL G CLEF),在你的電腦中可能無法正常顯示這個字符,因爲沒有相應的字庫文件支持。可參考網站上的顯示http://www.fileformat.info/info/unicode/char/1d11e/index.htm。下圖則是前面的一個截圖:

image

下面的圖是我在Live Writer上的截圖,可以看到如果選用開源中國oschina默認的“微軟雅黑”字體,是無法正常顯示的,顯示變成了一個白板。爲了顯示它,我只能對這個字符特別指定“Unifont Upper”字體它才能正常顯示。

image

注:Unifont Upper字體是我特別去下載的,一般電腦上應該都沒有這個字體。微軟有個“Arial Unicode MS”,但試了發現它還是隻能支持BMP中的字符。Unifont Upper可以到以下網址下載http://www.unifoundry.com/unifont.html,是免費的,做的也比較粗糙,大家看上圖中放大的效果就知道了,很明顯是點陣字體,而且似乎只提供了一種尺寸,一放大就呈鋸齒狀了。不過這是GNU號召一些志願者爲大家提供的,大家都是無償勞動,有好過沒有,這點大家應該都能理解。要想更好,通常要矢量字體,肯定有商業機構提供這些字體,一切不過是錢的問題,我想大家對此也是心知肚明。有沒有免費又好用的,這個我就沒有費心去找過了。


另:由於博客後臺不支持增補字符,這個字符在發表後被去掉了。

讓我們來看看:

    @Test
    public void testSupplementaryStringLength() {
        // only "one" character here
        String str = "??";
        assertThat(str.length()).isEqualTo(2);
    }

這個Live Writer的源碼插件不能很好地支持增補字符,我拷貝代碼到Live Writer的源碼插件時,發現編碼已經丟失了,變成了兩個??。以上代碼我已經發到git.oschina.net上,見http://git.oschina.net/goldenshaw/java_code_complete/blob/master/jcc-modules/jcc-core/src/test/java/org/jcc/core/encode/EncodingTest.java。雖然很多系統及軟件聲稱“支持Unicode”,但在後續的討論中,我們將始終面對各種尷尬情況,爲此不得不截圖來說明。

這是Live Writer源碼插件中的情況,發現編碼已經丟失了,變成了兩個??。

image

而直接拷貝到Live Writer中,雖然沒法顯示,起碼編碼沒有丟失,還是一個字符,跟前面類似

image

如果特別調整一下這個字符的字體,它還是可以顯示的,跟前面的道理一樣。

image

我想,這裏就是一個個活生生的例子:

很多軟件依然不能很好地支持Unicode中的增補字符,甚至連編碼也無法正確處理。

從源碼插件輸出兩個問號,我們可以猜測一下,當拷貝發生時,操作系統給了源碼插件一段UTF-16編碼的字節流,其中有一個增補字符是以代理對形式表示的,插件顯然不能正確處理代理對,把它當作了兩個字符,但單個代理對的編碼都是保留的,顯然沒有什麼字符可以對應,於是插件就用兩個問號代替了。

以上僅是一種合理猜測,因爲沒有深入瞭解windows中的拷貝機理及源碼插件中的源代碼,真正的原因也許不是這樣,但可以肯定的一點就是它一定在某個環節出錯了。

好了,讓我們直接看看代碼運行的情況,沒法正常顯示,我們就截圖來說明,雖然免不了繁瑣了些。測試是通過了的,一個增補字符它的長度確實變成了2。我給大家截個圖,這是大家電腦上可能顯示出來的效果:

image

一個方框,裏面一個問號,當字庫裏沒有這個字時,eclipse中就會以這樣一個符號來表示,這點跟Live Writer上顯示一個白板又有所不同,但原因是一樣的。

爲了顯示這朵奇葩,還是要設置Unifont Upper字體纔行:

image

真是繁瑣,其實從前面圖中兩個引號緊密圍住那個無法顯示的字符也可以看出,這裏確實只有一個字符,我沒騙大家,它的長度也的確變成了2,測試也通過了,正如API中所說的以及我們分析的那樣。

現在這一字符終於顯示出它的真容來了。現在把期望長度改一改,把原來的2設置成1,再跑一下:

image

JUnit華麗麗地報錯了!它說期望的長度是1,但實際的卻是2!

注:圖中的紅條可直觀表示測試失敗,如果出現綠條則說明測試通過,這是JUnit中的一個約定。

所以現在終於證實了string.length的API上所言不虛,圖上的str只有一個字符,但它的長度卻不是1。它返回的的確就是UTF-16的代碼單元的數目,而不是我們想像中的所謂“字符數”。

以上是Java平臺的情況,不同的語言平臺會有不同的情況,這個需要具體問題具體分析。特別的,使用增補字符去測試能更容易揭示內部表示的實質。

既然已經到這步,我們不妨多看幾個例子。

char類型與增補字符

image

在上圖中,還試圖把這個字符賦值給一個char變量,發現編譯器提示出錯。爲什麼呢?因爲Java中char使用了16位,而這個字符在16位內已經無法表示,所以它放不進一個char中。可以看到,char可以放一個英文字符,一箇中文字符,那是因爲這些字符都在BMP中,但卻無法放置這個音樂符,eclipse的即時編譯立馬就報錯了:“Invalid character constant”(非法的字符常量)。與此類似,如果一箇中文字符來自增補平面,那麼它也將無法放入char中。

另:使用了這種非等寬字體後,代碼的對齊顯示方面也出了問題,縮進變得不統一。

增補字符的轉義表示

另外我們可以以轉義的代理對的方式表示這個音樂符號,這樣可以避免字庫方面的問題。

當然了,任何字符都可以用轉義的方式表示,而不僅僅限於增補字符。如果你沒有安裝輸入法,可以簡單使用\u4F60來表示“你”這個字符。

注:如果擔心傳輸中出現亂碼,可使用轉義的表示,這時只含有ASCII字符,不過這種表示法效率很低。比如我們要用“\, u, 4, F, 6, 0”整整6個ASCII字符才能表示“你”這個漢字,也即要用6字節才能表示。而直接表示的話,用GBK只要兩字節,而用UTF-8也不過是三字節。

從前面的篇章中,我們知道U+1D11E,寫成UTF-16的代理對是(D8 34 DD 1E)。

Java中的轉義表示始終是以\u後接四個16進制數字爲界的(其實就是UTF-16的代碼單元),你不能簡單像碼點那樣寫成\u1D11E,這種寫法相當於“\u1D11”+”E”,即前面四位1D11做轉義,後面當成正常的字母E。如果要轉義的字符碼點超過U+FFFF,我們需要兩個一對的轉義”\uD834\uDD1E”來表示,從這裏我們也可看到,所謂的轉義表示其實就是UTF-16編碼。

注:以上是Java平臺的情況,不同語言平臺可能有不同的策略。

新的Javascript版本(ES6)支持一種將碼點直接放在大括號內的轉義表示法“\u{1D11E}”,這顯然要比寫成代理對的形式簡單。

我們可以用“\uD834\uDD1E”的轉義方式表示U+1D11E這個碼點所表示的字符,只是這樣的話,不明就裏的人可能認爲這裏有兩個字符了,所以輸出2不奇怪。

這也正好從另一角度說明,增補字符需要四個字節,也即兩個代碼單元才能表示。

測試的代碼如下:(注:這是後面補上的,所以跟下圖有些差別,沒有重新截圖)

    @Test
    public void testSupplementaryStringLengthUsingSurrogatePair() {
        // only "one" character here, code point is U+1D11E
        // 這裏只有一個字符U+1D11E,由於後臺不支持保存增補字符,所以這裏的換成了兩個問號,可查看git上的源碼
        String str = "??";
        // actually the same character, but represented by surrogate pair
        // 同樣的一個字符,以轉義的代理對方式表示
        String anotherStr = "\uD834\uDD1E";
        assertThat(str.length()).isEqualTo(2);
        assertThat(anotherStr.length()).isEqualTo(2);
        // these two equals each other
        assertThat(anotherStr.equals(str)).isTrue();
        assertThat(str.codePointAt(0)).isEqualTo(0x1D11E);
        // not D834
        assertThat(anotherStr.codePointAt(0)).isEqualTo(0x1D11E);
        assertThat(anotherStr.codePointAt(1)).isEqualTo(0xDD1E);
    }

在這裏還讓兩個string進行了equals比較,可以看到那條綠油油的成功指示條,測試是通過的。

image

另外,上圖中還對兩個string在index=0處的碼點進行了求值(圖中的codePointAt()方法),可以看到無論是以字符表示的str還是以代理對錶示的anotherStr,它們的碼點都是0x1D11E,這也從另一個側面證明了它們是同一個字符。

錯誤的代理對

如果反轉了代理對,會是什麼情況呢?前面篇章已經談及,代理對必須嚴格按照先高後低的順序來書寫,以下代碼中把代理對寫反了:

    @Test
    public void 測試錯誤的代理對() {
        String wrongPairStr = "\uDD1E\uD834";
        assertThat(wrongPairStr.codePointAt(0)).isEqualTo(0xDD1E);
        assertThat(wrongPairStr.codePointAt(1)).isEqualTo(0xD834);
        System.out.println(wrongPairStr);
        System.out.println("\uD834\uDD1E");
        
    }

上面的測試,方法名直接使用了中文,其實Java中是可以支持這種命名方式的,我們也免去了寫完英文方法又要寫中文註釋的麻煩。當然,這樣看上去可能讓有些人覺得彆扭。

讓我們實際測試一下:

image

可以看到,輸出了兩個問號,在index=0處的碼點也變成了0xDD1E,而不是原來的0x1D11E了,而正常順序則只輸出一個字符。再一次的由於字庫原因,它不能正常顯示,我也懶得去調整console的字體了,大家明白怎麼回事就行了。

結論

以上說了這麼多,不外乎就是爲了證明一件事,java中string.length()不是你想像中的那樣。在前面系列中的第三篇中,我們就已經談到變長的引入也同時帶來了複雜性,其中就說到了它影響到我們對java中的string.length的理解,現在length顯得有點尷尬,如果我們真的想確切地知道有幾個字符,length顯然是不能給出正確答案的。

目前來說,增補字符使用得還比較少,多數處理的還是BMP中的字符,所以哪怕你沒有認識清楚它的本質,通常也不會給你帶來什麼麻煩。但事情是在不斷髮展的,也許不久的未來,處理這些增補字符的問題就會成爲常態。

好了,關於代碼單元及string.length方法就分析到這裏。String中的許多方法跟編碼問題緊密相關,在下一篇中,我們還將分析一下string.getBytes()方法並初步探討一下亂碼的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章