Java 與 Unicode

May 27, 2022

來看一個問題:你寫的 .java 原始碼檔案是什麼編碼?

原始檔編碼

這其實是個簡單但蠻重要的問題,許多開發者卻答不出來,若使用的文字編譯器,預設是使用 UTF-8 編碼儲存文字,UTF-8 是 Unicode 規範下的一種編碼方式,如果用它來撰寫一個 Main.java 如下:

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello");
        System.out.println("哈囉");
    }
}

在 Windows 如下執行編譯的話:

> javac Main.java

就會噴出一堆無法理解字元編碼的錯誤訊息:

Main.java:4: error: unmappable character (0xE5) for encoding x-windows-950
        System.out.println("????");
                            ^
Main.java:4: error: unmappable character (0x93) for encoding x-windows-950
        System.out.println("????");
                             ^
Main.java:4: error: unmappable character (0x9B) for encoding x-windows-950
        System.out.println("????");
                               ^
Main.java:4: error: unmappable character (0x89) for encoding x-windows-950
        System.out.println("????");
                                ^
4 errors

javac 預設使用作業系統編碼來讀取 .java 檔案內容,因此在 Windows 如上執行 javac,會試圖以 MS950 讀取 Main.java,然而 Main.java 儲存時使用的是 UTF-8,兩個編碼對不上,javac 就看不懂原始碼了。

如果文字編譯器是使用 UTF-8,javac 編譯時要指定 -encodingUTF-8,編譯器就會用指定的編碼讀取 .java 的內容。例如:

> javac -encoding UTF-8 Main.java

產生的 .class 檔案,使用反組譯工具還原的程式碼中,會看到以下的內容:

public class Main {
    public static void main(String args[]) {
        System.out.println("Hello");
        System.out.println("\u54C8\u56C9");
    }
}

char 的值可以用 \uxxxx 表示,上面反組譯的程式中,"\u54C8\u56C9" 就是 "哈囉" 兩個字元的 \uxxxx 表示方式。

Unicode 與 UTF

方才又是 Unicode 又是 UTF-8 的,這些到底是什麼?不少開發者搞不清楚 Unicode 與 UTF 間的關係,確實地,若開發者平常任務中不需要處理文字、特殊字元,或者沒遇過亂碼的問題,不清楚 Unicode 與 UTF 間的關係是沒什麼問題,然而,如果經常要處理文字、被亂碼問題坑過,就必須搞清楚 Unicode 與 UTF 間的關係。

字元集是一組符號的集合,字元編碼是字元實際儲存時的位元組格式,如前面的範例看到的,讀取時使用的編碼不正確,編輯器會解讀錯誤而造成亂碼,在還沒有 Unicode 與 UTF(Unicode Transformation Format)前,各個系統間編碼不同而造成的問題,困擾著許多開發者。

要統一編碼問題,必須統一管理符號集合,也就是要有統一的字元集,ISO/IEC 與 Unicode Consortium 兩個團隊都曾經想統一字元集,而 ISO/IEC 在 1990 年先公佈了第一套字元集的編碼方式 UCS-2,使用兩個位元組來編碼字元。

字元集中每個字元會有個編號作為碼點(Code point),實際儲存字元時,UCS-2 以兩個位元組作為一個碼元(Code unit),也就是管理位元組的單位;最初的想法很單純,令碼點與碼元一對一對應,在編碼實作時就可以簡化許多。

後來 1991 年 ISO/IEC 與 Unicode 團隊都認識到,世界不需要兩個不相容的字元集,因而決定合併,之後才發佈了 Unicode 1.0。

由於越來越多的字元被納入 Unicode 字元集,超出碼點 U+0000 至 U+FFFF 可容納的範圍,因而 UCS-2 採用的兩個位元組,無法對應 Unicode 全部的字元碼點,後來在 1996 年公佈了 UTF-16,除了沿用 UCS-2 兩個位元組的編碼部份之外,超出碼點 U+0000 至 U+FFFF 的字元,採用四個位元組來編碼,因而視字元是在哪個碼點範圍,對應的 UTF-16 編碼可能是兩個或四個位元組,也就是說採用 UTF-16 儲存的字元,可能會有一個或兩個碼元。

UTF-16 至少使用兩個位元組,然而對於 +/?@#$ 或者是英文字元等,也使用兩個位元組,感覺蠻浪費儲存空間,而且不相容於已使用 ASCII 編碼儲存的字元,Unicode 的另一編碼標準 UTF-8 用來解決此問題。

UTF-8 儲存字元時使用的位元組數量,也是視字元落在哪個 Unicode 範圍而定,從 UTF-8 的觀點來看,ASCII 編碼是其子集,儲存 ASCII 字元時只使用一個位元組,其他附加符號的拉丁文、希臘文等,會使用兩個位元組(例如π),至於中文部份,UTF-8 採用三個位元組儲存,更罕見的字元,可能會用到四到六個位元組,例如微笑表情符號 U+1F642,就使用了四個位元組。

簡單來說,Unicode 對字元給予編號以便進行管理,真正要儲存字元時,可以採用 UTF-8UTF-16 等編碼為位元組。

char 與 String

在討論原始編碼時談過,撰寫 Java 原始碼時,開發者可以使用 MS950UTF-8(甚至是 UTF-16)等編碼,只要能正確儲存字元,而且 javac 編譯時以 -encoding 指定編碼,就可以通過編譯,對於原始碼中的非 ASCII 字元,編譯過程會轉為 \uxxxx 的形式;在執行時期,對於 \uxxxx 採用的實作是 UTF-16 Big Endian,記憶體中會使用兩個位元組,也就是一個碼元來儲存。

Java 支援 Unicode,char 型態佔 2 個位元組,對於碼點在 U+0000 至 U+FFFF 範圍內的字元,例如 '林',原始碼中可使用以下方式表示:

var fstName1 = '林';
var fstName2 = '\u6797';

U+0000 至 U+FFFF 範圍內的字元,Unicode 歸類為BMP(Basic Multilingual Plane),碼點與碼元一對一對應,現在問題來了,若字元不在 BMP 範圍內呢?例如高音譜記號的 Unicode 碼點為 U+1D11E,顯然無法只用一個 \uhhhh 來表示,也無法儲存在 char 型態的空間。

程式中若真的要表示 ,必須使用字串儲存,而高音譜記號可以 "\uD834\uDD1E" 來表示,分別表示 UTF-16 編碼時的高低碼元,這稱為代理對(Surrogate pair)。

可以使用字串的 length 取得字串物件管理的 char 數量,也就是碼元數量,如果字串中的字元,都是在 BMP 範圍內,length 傳回值,確實是等於字串中的字元數;然而,既然高音譜記號可以 "\uD834\uDD1E" 表示,那麼 length 傳回值會是多少呢?答案會是 2!

jshell> var g_clef = "\uD834\uDD1E";
g_clef ==> "?"

jshell> g_clef.length();
$2 ==> 2

然而高音譜記號是一個字元!如果字串中的字元,是在 BMP 範圍外,就不能把 length 傳回值,當成是字串中的字元數。

字串的 length 傳回值是 char 的數量,也就是表示字串使用的 UTF-16 碼元數量。

類似地,字串的 charAt 可以指定索引,取得字串中的 char,而不是字元,如果指定索引取得字串中的字元,可以使用 codePointAt,這會以 int型態傳回碼點號碼:

jshell> "\uD834\uDD1E".charAt(0) == 0X1D11E;
$3 ==> false

jshell> "\uD834\uDD1E".codePointAt(0) == 0X1D11E;
$4 ==> true

在 Java 中,字元不等於 char,字元是 Unicode 字元集中管理的符號,char是儲存資料用的型態。

如果想取得字串中的字元數量(而不是 char 的數量),可以使用字串的 codePoints 方法,這會傳回 java.util.stream.IntStream 型態,詳細的使用方式與 lambda 特性有關,現在只要先知道,該型態可用來逐一處理字串中每個字元的碼點,透過它的 count 方法,可以計算字元總數(而不是碼元數量):

jshell> "高音譜:\uD834\uDD1E".length(); // char 數量
$5==> 6

jshell> "高音譜:\uD834\uDD1E".codePoints().count() // 字元數量
$6==> 5

分享到 LinkedIn 分享到 Facebook 分享到 Twitter