ASCII 碼
我們知道,計算機內部,所有信息最終都是一個二進制值。每一個二進制位(bit)有0和1兩種狀態,因此八個二進制位就可以組合出256種狀態,這被稱為一個字節(byte)。也就是説,一個字節一共可以用來表示256種不同的狀態,每一個狀態對應一個符號,就是256個符號,從00000000到11111111。
上個世紀60年代,美國製定了一套字符編碼,對英語字符與二進制位之間的關係,做了統一規定。這被稱為 ASCII 碼,一直沿用至今。
ASCII 碼一共規定了128個字符的編碼,比如空格SPACE是32(二進制00100000),大寫的字母A是65(二進制01000001)。這128個符號(包括32個不能打印出來的控制符號),只佔用了一個字節的後面7位,最前面的一位統一規定為0。
非 ASCII 編碼
英語用128個符號編碼就夠了,但是用來表示其他語言,128個符號是不夠的。比如,在法語中,字母上方有注音符號,它就無法用 ASCII 碼錶示。於是,一些歐洲國家就決定,利用字節中閒置的最高位編入新的符號。比如,法語中的é的編碼為130(二進制10000010)。這樣一來,這些歐洲國家使用的編碼體系,可以表示最多256個符號。
但是,這裏又出現了新的問題。不同的國家有不同的字母,因此,哪怕它們都使用256個符號的編碼方式,代表的字母卻不一樣。比如,130在法語編碼中代表了é,在希伯來語編碼中卻代表了字母Gimel(ג),在俄語編碼中又會代表另一個符號。但是不管怎樣,所有這些編碼方式中,0--127表示的符號是一樣的,不一樣的只是128--255的這一段。
至於亞洲國家的文字,使用的符號就更多了,漢字就多達10萬左右。一個字節只能表示256種符號,肯定是不夠的,就必須使用多個字節表達一個符號。比如,簡體中文常見的編碼方式是 GB2312,使用兩個字節表示一個漢字,所以理論上最多可以表示 256 x 256 = 65536 個符號。
中文編碼的問題需要專文討論,這篇筆記不涉及。這裏只指出,雖然都是用多個字節表示一個符號,但是GB類的漢字編碼與後文的 Unicode 和 UTF-8 是毫無關係的。
Unicode
正如上一節所説,世界上存在着多種編碼方式,同一個二進制數字可以被解釋成不同的符號。因此,要想打開一個文本文件,就必須知道它的編碼方式,否則用錯誤的編碼方式解讀,就會出現亂碼。為什麼電子郵件常常出現亂碼?就是因為發信人和收信人使用的編碼方式不一樣。
可以想象,如果有一種編碼,將世界上所有的符號都納入其中。每一個符號都給予一個獨一無二的編碼,那麼亂碼問題就會消失。這就是 Unicode,就像它的名字都表示的,這是一種所有符號的編碼。
Unicode 當然是一個很大的集合,現在的規模可以容納100多萬個符號。每個符號的編碼都不一樣,比如,U+0639 表示阿拉伯字母 Ain,U+0041 表示英語的大寫字母 A,U+4E25 表示漢字嚴。具體的符號對應表,可以查詢unicode.org,或者專門的漢字對應表。
Unicode 的問題
Unicode 統一了所有字符的編碼,是一個 Character Set,也就是字符集,字符集只是給所有的字符一個唯一編號,但是卻沒有規定如何存儲。
比如,漢字嚴的 Unicode 是十六進制數 4E25,轉換成二進制數足足有15位(100111000100101),也就是説,這個符號的表示至少需要2個字節,表示其他更大的符號,可能需要3個字節或者4個字節,甚至更多,用什麼規則存儲 Unicode 字符就成了關鍵。
將 Unicode 編碼轉換為二進制表示形式的邏輯如下:
將 Unicode 編碼轉換為十六進制表示形式。對於嚴的 Unicode 編碼 4E25,它的十六進制表示為 0x4E25。
將十六進制數轉換為二進制數,每個十六進制數對應四位二進制數。對於 4E25,將每個十六進制數轉換為四位二進制數:
4 轉換為二進制為 0100 E 轉換為二進制為 1110 2 轉換為二進制為 0010 5 轉換為二進制為 0101將以上二進制數連接起來,得到 0100111000100101。
注意:如果 Unicode 編碼超過四位二進制數(例如超過 U+FFFF),需要使用更多的位數進行表示。
這裏就有兩個嚴重的問題,第一個問題是,如何才能區別 Unicode 和 ASCII ?計算機怎麼知道三個字節表示一個符號,而不是分別表示三個符號呢?第二個問題是,我們已經知道,英文字母只用一個字節表示就夠了,如果 Unicode 統一規定,每個符號用三個或四個字節表示,那麼每個英文字母前都必然有二到三個字節是0,這對於存儲來説是極大的浪費,文本文件的大小會因此大出二三倍,這是無法接受的。
它們造成的結果是:1)出現了 Unicode 的多種存儲方式,也就是説有許多種不同的二進制格式,可以用來表示 Unicode。2)Unicode 在很長一段時間內無法推廣,直到互聯網的出現。
UTF-8
隨着互聯網的普及,強烈要求出現一種統一的編碼方式。UTF-8 就是在互聯網上使用最廣的一種 Unicode 的實現方式。其他實現方式還包括 UTF-16(字符用兩個字節或四個字節表示)和 UTF-32(字符用四個字節表示),不過在互聯網上基本不用。重複一遍,這裏的關係是,UTF-8 是 Unicode 的實現方式之一,但是它們的編碼規則還是不一樣的。
UTF-8 最大的一個特點,就是它是一種變長的編碼方式。它可以使用1~4個字節表示一個符號,根據不同的符號而變化字節長度。
UTF-8 的編碼規則很簡單,只有二條:
1)對於單字節的符號,字節的第一位設為0,後面7位為這個符號的 Unicode 碼。因此對於英語字母,UTF-8 編碼和 ASCII 碼是相同的。
2)對於n字節的符號(n > 1),第一個字節的前n位都設為1,第n + 1位設為0,後面字節的前兩位一律設為10。剩下的沒有提及的二進制位,全部為這個符號的 Unicode 碼。
下表總結了編碼規則,字母x表示可用編碼的位。
Unicode符號範圍 | UTF-8編碼方式
(十六進制) (二進制) 0000 0000-0000 007F(U+0000 到 U+007F) 0xxxxxxx 0000 0080-0000 07FF(U+0080 到 U+07FF) 110xxxxx 10xxxxxx 0000 0800-0000 FFFF(U+0800 到 U+FFFF) 1110xxxx 10xxxxxx 10xxxxxx 0001 0000-0010 FFFF(U+10000 到 U+10FFFF) 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
跟據上表,解讀 UTF-8 編碼非常簡單。如果一個字節的第一位是0,則這個字節單獨就是一個字符;如果第一位是1,則連續有多少個1,就表示當前字符佔用多少個字節。
下面,還是以漢字嚴為例,演示如何實現 UTF-8 編碼。
嚴的 Unicode 是4E25(100 1110 0010 0101),根據上表,可以發現 4E25 的範圍在 U+0800 到 U+FFFF 之間,因此需要使用 3 個字節進行編碼,即格式是1110xxxx 10xxxxxx 10xxxxxx,其中每個 x 代表 Unicode 二進制表示的一部分。然後,從嚴的最後一個二進制位開始,依次從後向前填入格式中的x,多出的位補0。這樣就得到了,嚴的 UTF-8 編碼是11100100 10111000 10100101,轉換成十六進制就是E4B8A5。
在一些網站上可能還會看到&#x、&#開頭的內容,例如:'身份证反面.jpg',這是漢字的 Unicode 編碼,&#後接十進制數字,&#x後接十六進制數字,例如嚴的 Unicode 可能表示成严,將20005轉化為二進制得到100111000100101。
UTF-16
UTF-16(Unicode Transformation Format-16)是一種Unicode字符編碼方案,是Unicode標準的一種實現方式之一,它使用兩個字節或四個字節表示來表示字符。
UTF-16 最初被設計為一種定長編碼方案,即每個字符都使用16位來表示。這使得 UTF-16 可以直接處理 Unicode 字符集中的大多數字符,包括常見的字符和一些較為罕見的字符。對於較早期的 Unicode 版本,這種定長編碼方案非常適用。
然而,隨着 Unicode 標準的發展,字符數量超過了16位的範圍,Unicode 收錄的字符很快就超過了65536個。如果還想用定長編碼似乎只能採取 UTF-32 這種編碼方式了。可是這種方式最大的問題是即使是英文字母也要四個字節來存儲,空間浪費太大了。所以 UTF-8 這種變長編碼方式開始流行起來了,英文字母只需要一個字節,漢字三個字節。更古怪更稀有的字符可以用四個,五個或更多字節表示,因為使用頻率低,所以空間浪費不大。當然定長編碼的好處是可以快速定位字符,對於 string.charAt(index) 方法有着較好的支持。UTF-8 的話,就需要從頭開始一個字符一個字符的解析才行,會慢一點。但是與查詢定位相比,順序輸出的情況更多,所以平常也不會感受到效率會比較慢。
UTF-16 比起 UTF-8,好處在於大部分字符都以固定長度的字節(2字節)存儲,對於ASCII字符,它們實際上只需要使用一個字節來表示,但UTF-16仍然使用兩個字節來編碼這些字符,導致 UTF-16 無法兼容 ASCII 編碼。
UTF-32
UTF-32(Unicode Transformation Format-32)是一種 Unicode 字符編碼方案,它使用32位(四個字節)來表示每個字符。與 UTF-8 和 UTF-16 不同,UTF-32 採用了定長編碼,即每個字符都使用相同長度的編碼單元。
UTF-32 的設計目標是為了提供一種簡單直觀的字符表示方式,每個字符都佔用相同的空間,方便進行索引和處理。由於每個字符都使用32位,UTF-32 可以直接表示 Unicode 字符集中的所有碼點,包括較為罕見的和輔助平面的字符。
相對於 UTF-8 和 UTF-16 ,UTF-32 確實存在一些空間浪費的問題,尤其是對於包含大量英文字母和其他 ASCII 字符的文本。因為 UTF-32 始終使用四個字節來表示每個字符,對於這些字符而言,它們實際上只需要較少的空間。
然而,UTF-32 在某些方面具有優勢。由於字符長度固定,UTF-32 可以實現快速定位和隨機訪問,這對於某些特定的應用場景非常重要。例如,在某些文本處理操作中,使用 UTF-32 編碼可以更高效地進行字符索引和處理。此外,UTF-32 也在一些操作系統和編程語言中作為內部字符串表示方式,因為它簡化了字符處理的邏輯。
需要注意的是,UTF-32 在存儲和傳輸上的空間佔用較大,對於包含大量文本數據的應用,可能會導致存儲需求和傳輸帶寬的浪費。因此,對於大多數應用場景而言,UTF-8 仍然是更常見和更廣泛使用的 Unicode 編碼方案。
碼點 與 碼元
在字符編碼中,"碼元"和"碼點"是兩個相關但不同的概念。
碼元(Code Unit)是計算機存儲和處理字符編碼時使用的最小單位。它表示編碼方案中的一個單元,通常由一定數量的比特(位)組成。不同的字符編碼方案使用不同大小的碼元。例如,UTF-8 使用8位(一個字節)作為碼元,UTF-16 使用16位(兩個字節)作為碼元,UTF-32 使用32位(四個字節)作為碼元。
碼點(Code Point)是字符編碼中的抽象概念,表示字符集中的一個唯一字符。每個字符都有一個唯一的碼點值,用於標識該字符。Unicode 字符集定義了一組字符,併為每個字符分配了一個唯一的碼點值,通常用十六進制表示。例如,拉丁字母"A"的 Unicode 碼點是 U+0041,中文字符"嚴"的 Unicode 碼點是 U+4E25。
在某些編碼方案中,一個碼點可以由一個或多個碼元來表示。例如,對於大多數常見的 Unicode 字符,UTF-8 和 UTF-16 使用一個碼元來表示一個碼點。但對於一些特定的字符,如 Emoji 表情符號或某些字符的複雜形狀,可能需要使用多個碼元來表示一個碼點。
總結一下:
- 碼元(Code Unit)是字符編碼方案中的最小存儲和處理單位,通常由一定數量的比特組成。
- 碼點(Code Point)是字符編碼中的抽象概念,表示字符集中的一個唯一字符,通常用十六進制表示。每個字符都有一個唯一的碼點值。
- 一個碼點可以由一個或多個碼元來表示,具體取決於所使用的字符編碼方案。
字符串截取導致的 BUG
我們可以看下面代碼執行情況:
const str = "🍑🐶👋🉐🏠";
console.log(str.length); // 10
console.log(str[0]); // '\uD83C'
console.log(str.slice(1, 3)); // '\uDF51\uD83D'
我們可以看到預期的結果與實際輸出不同,期望 str.length 輸出字符的數量5,但輸出的是10,期望 str[0] 輸出第一個字符🍑,但輸出的卻是'\uD83C',期望 str.slice(1, 3) 截取輸出🐶👋,但輸出的是'\uDF51\uD83D'
這是因為在 JavaScript 中,默認情況下,字符串被視為 Unicode 字符序列,並使用 UTF-16 編碼方案進行表示,UTF-16 使用16位(兩個字節)作為碼元。絕大多數字符可以使用兩個字節表示,然而,對於一些特殊字符,如 Emoji 表情字符,它們的碼點需要使用多個碼元來表示。
因此,當你使用 str.length 獲取字符串長度時,它返回的是碼元的數量,而不是實際字符的數量。對於包含 Emoji 表情字符的字符串,每個 Emoji 表情字符會被表示為兩個碼元,因此字符串的長度會大於你期望的字符數量。
類似地,當你使用下標訪問字符串的特定字符時,它返回的是對應位置的碼元,而不是完整的字符。這就解釋了為什麼str[0] 輸出的是 \uD83C,它是 Emoji 表情字符🍑的第一個碼元。
同樣地,str.slice(1, 3) 截取的是碼元的範圍,而不是字符範圍。因此,輸出的結果是 \uDF51\uD83D,它是🐶和👋兩個 Emoji 表情字符的碼元序列。
String.prototype.pointLength = function() {
let len = 0;
for(let i = 0; i < this.length;) {
const codePoint = this.codePointAt(i);
i += codePoint > 0xffff ? 2 : 1;
len++;
}
return len;
}
String.prototype.pointAt = function(index) {
let currentIndex = 0;
for(let i = 0; i < this.length;) {
const codePoint = this.codePointAt(i);
if (currentIndex === index) {
return String.fromCodePoint(codePoint);
}
i += codePoint > 0xffff ? 2 : 1;
currentIndex++;
}
}
String.prototype.sliceByPoint = function(start = 0, end = this.pointLength()) {
let results = "";
for(let i = start; i < end; i++) {
results += this.pointAt(i);
}
return results;
}
String 的 codePointAt(index) 方法返回一個非負整數,該整數是從給定索引開始的字符的 Unicode 碼位值。
- 如果 index 超出了 0 – str.length - 1 的範圍,codePointAt() 返回 undefined。
- 如果 index 處的元素是一個 UTF-16 前導代理(leading surrogate),則返回代理對的碼位。
- 如果 index 處的元素是一個 UTF-16 後尾代理(trailing surrogate),則只返回後尾代理的碼元。
在 Unicode 編碼中,代理對(surrogate pair)是一種特殊的編碼方式,用於表示超過16位的字符,如一些罕見的漢字和 Emoji 表情字符。代理對由一個 leading surrogate(高位代理項)和一個 trailing surrogate(低位代理項)組成。在 JavaScript 中,代理對字符被表示為兩個編碼單元(碼元),leading surrogate 作為第一個編碼單元,trailing surrogate 作為第二個編碼單元。
當你遇到一個 leading surrogate 時,你通常需要結合它後面的 trailing surrogate 來處理它們作為一個完整的字符。例如,對於一個代理對字符,你需要將 leading surrogate 和 trailing surrogate 合併,才能得到正確的字符表示。
比如 Emoji 表情字符🍑,\uD83C 是它的 leading surrogate,所以 str[0] 會返回代理對的碼位,也就是
\uD83C 與後續 trailing surrogate 組合表示的碼點。
Unicode 與 UTF-8 之間的轉換
通過上一節的例子,可以看到嚴的 Unicode碼 是4E25,UTF-8 編碼是E4B8A5,兩者是不一樣的。它們之間的轉換可以通過程序實現。
Windows平台,有一個最簡單的轉化方法,就是使用內置的記事本小程序notepad.exe。打開文件後,點擊文件菜單中的另存為命令,會跳出一個對話框,在最底部有一個編碼的下拉條。
裏面有四個選項:ANSI,Unicode,Unicode big endian和UTF-8。
1)ANSI是默認的編碼方式。對於英文文件是ASCII編碼,對於簡體中文文件是GB2312編碼(只針對 Windows 簡體中文版,如果是繁體中文版會採用 Big5 碼)。
2)Unicode編碼這裏指的是notepad.exe使用的 UCS-2 編碼方式,即直接用兩個字節存入字符的 Unicode 碼,這個選項用的 little endian 格式。
3)Unicode big endian編碼與上一個選項相對應。我在下一節會解釋 little endian 和 big endian 的涵義。
4)UTF-8編碼,也就是上一節談到的編碼方法。
選擇完"編碼方式"後,點擊"保存"按鈕,文件的編碼方式就立刻轉換好了。
Little endian 和 Big endian
上一節已經提到,UCS-2 格式可以存儲 Unicode 碼(碼點不超過0xFFFF)。以漢字嚴為例,Unicode 碼是4E25,需要用兩個字節存儲,一個字節是4E,另一個字節是25。存儲的時候,4E在前,25在後,這就是 Big endian 方式;25在前,4E在後,這是 Little endian 方式。
這兩個古怪的名稱來自英國作家斯威夫特的《格列佛遊記》。在該書中,小人國裏爆發了內戰,戰爭起因是人們爭論,吃雞蛋時究竟是從大頭(Big-endian)敲開還是從小頭(Little-endian)敲開。為了這件事情,前後爆發了六次戰爭,一個皇帝送了命,另一個皇帝丟了王位。
第一個字節在前,就是"大頭方式"(Big endian),第二個字節在前就是"小頭方式"(Little endian)。
那麼很自然的,就會出現一個問題:計算機怎麼知道某一個文件到底採用哪一種方式編碼?
Unicode 規範定義,每一個文件的最前面分別加入一個表示編碼順序的字符,這個字符的名字叫做"零寬度非換行空格"(zero width no-break space),用FEFF表示。這正好是兩個字節,而且FF比FE大1。
如果一個文本文件的頭兩個字節是FE FF,就表示該文件採用大頭方式;如果頭兩個字節是FF FE,就表示該文件採用小頭方式。
實例
下面,舉一個實例。
打開"記事本"程序notepad.exe,新建一個文本文件,內容就是一個嚴字,依次採用ANSI,Unicode,Unicode big endian和UTF-8編碼方式保存。
然後,用文本編輯軟件UltraEdit 中的"十六進制功能",觀察該文件的內部編碼方式。
1)ANSI:文件的編碼就是兩個字節D1 CF,這正是嚴的 GB2312 編碼,這也暗示 GB2312 是採用大頭方式存儲的。
2)Unicode:編碼是四個字節FF FE 25 4E,其中FF FE表明是小頭方式存儲,真正的編碼是4E25。
3)Unicode big endian:編碼是四個字節FE FF 4E 25,其中FE FF表明是大頭方式存儲。
4)UTF-8:編碼是六個字節EF BB BF E4 B8 A5,前三個字節EF BB BF表示這是UTF-8編碼,後三個E4B8A5就是嚴的具體編碼,它的存儲順序與編碼順序是一致的。
參考文章
http://www.ruanyifeng.com/blog/2007/10/ascii\_unicode\_and\_utf-8.html
https://blog.csdn.net/jayxujia123/article/details/24580103