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
編譯時要指定 -encoding
為 UTF-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-8
、UTF-16
等編碼為位元組。
char 與 String
在討論原始編碼時談過,撰寫 Java 原始碼時,開發者可以使用 MS950
、UTF-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