# 前言 #
在很多編程語言中,經常用 String 類型來表示字符串,用 Char 來表示字符類型;在以往的觀念中,String 與 Char 數組 (字符數組) 是等價的,但隨著計算機的發展以及編程語言的演進,這兩者之間好像出現了一些細微的不同。這篇文章將向讀者展示,Char 數組與 String 是如何從統一走向分離的。
# 從數數開始 #
先從一個簡單的 Python 程序講起,返回一個字符串的長度。
輸入數據如下:
?
hello?world 你好,世界 cafe?
?
在筆者的環境中結果如下:
?
Python?3.8.0?(default,?Dec??9?2021,?17:53:27) [GCC?8.4.0]?on?linux Type?"help",?"copyright",?"credits"?or?"license"?for?more?information. >>>?len("hello?world") 11 >>>?len("你好,世界") 5 >>>?len("cafe?") 5 >>>?len("") 5 >>>?len("") 7按理說,len() 函數應該返回字符串包含的 “字符” 的個數,但是這里的 “字符” 與直覺上好像有一些出入。
?
? | 直覺上的長度 | py3 輸出的長度 | 相等與否 |
---|---|---|---|
hello world | 11 | 11 | ? |
你好,世界 | 5 | 5 | ? |
cafe? | 4 | 5 | ? |
? | 3 | 5 | ? |
? | 1 | 7 | ? |
發生什么事了
到底是誰錯了?人還是機器?
機器永遠是對的?!猨yy
py3 輸出的長度表示的是什么意思呢?
# 字符類型 #
為了回答前面的問題,我們需要了解一下字符類型,雖然在 Python 中沒有獨立的字符類型,但其他大多數編程語言中都有單獨的字符類型,如 C#,Java,Rust,Julia 中的 Char,以及 Go 中的 rune 和 Swift 中的 Character;雖然都叫字符類型,但是由于一些原因,這些字符類型的實現并不一樣,有的是 16 位有的是 32 位 (其原因會在后面說明) 。在比較現代的編程語言中,字符類型的實現多數為 32 位,我們就從 32 位字符類型開始講起吧。
注: 在后文中出現的 Char 或者是 Char 類型,均指代編程語言中的字符類型,由于 “字符” 一詞存在歧義,故選擇了 Char 這個詞來指代。至于“字符”一詞的歧義,也會在后文中展開說明。
32 位字符類型基本都是用來表示 Unicode Scalar Value(Unicode 標量值), 或者說 Unicode 中的 Code Point。
首先, Unicode 是什么呢?——Unicode 標準 (The Unicode Standard) [1],簡單說就是一個 “字符表”,一種規則,給每個字符一個編號。
可以類比于 ASCII 碼,大寫字母 “A” 的編號是十進制的 65。
下方的截圖來自于 Unicode 官網?[2],可以看到圖中列出了幾個字符,每個字符的下方會有一個編號 (如 U+0669,十六進制) ,這個編號就稱為 Code Point。
目前 Unicode Code Point 編號達到了 21 位 (二進制) ,所以用 32?位來存儲是完全夠用的。
32 位字符類型表示的就是這個編號,比如上圖中的 U+0669。
# Char == 字符?#
? | 直覺上的長度 | py3 輸出的長度 | 相等與否 |
---|---|---|---|
hello world | 11 | 11 | ? |
你好,世界 | 5 | 5 | ? |
cafe? | 4 | 5 | ? |
? | 3 | 5 | ? |
? | 1 | 7 | ? |
回到前面字符串長度的表格,現在我們可以回答之前提出的問題了,py3 輸出的字符串長度,其含義是字符串中 Unicode Code Point 的個數 (感興趣的讀者可以自己驗證,這里就忽略了) 。
再來看左側的 “直覺上的長度”,為了更準確地講解,我們引入一個新概念,用戶視角的字符 (User-perceived character) ,指符合人類直覺的字符,因為是從直覺的角度出發,沒有明確的定義,比如一個“塊塊”,一個 emoji 表情 ?!爸庇X上的長度” 含義就是 用戶視角的字符 的個數。
從這個表格中可以看到,有一些 “字符”,在人類視角是一個 (一個 用戶視角的字符) ,但是在計算機的角度是由多個 Unicode Code Point 組成。
很顯然,一個用戶視角的字符,可能需要多個 Unicode 字符 (Code Point) 來表示。比如這個表示家庭的 emoji,,實際上由七個 Unicode Code Point 組成,但是由前端渲染成了一個 “用戶視角的字符”;或者這個 ,由兩個 Code Point 組成 (一個 ,外加一個表示膚色的 Code Point) 。
更一般地講,32 位 Char 類型無法表示全部的用戶視角的字符,能表示的有英文、漢字、日語等;不能表示的有法語等小語種、部分組合而成的 emoji 。
不少編程語言中都是用 Char 作為字符類型,久而久之便形成了 “Char == 字符” 這樣的假設,潛意識地認為字符類型可以表示任何用戶角度的字符,但現在通過上面的幾個例子,可以看到 Char 類型與用戶視角的字符并不相同。
這里建議讀者把 32 位 Char 類型當作表示 Unicode Code Point 的類型,不再使用 “字符” 類型這個詞。因為不論 “字符” 還是 “Character”,在計算機中都是含有歧義的詞語;比如一個 “字符” 是指 8 位?16 位?還是 32 位?更或是“用戶角度的字符”?而這些概念都有對應的指代更為明確的詞語。
“Char == 字符” 的時代已經過去了,雖然 Char 依然表示字符類型,但卻無法表示很多 “用戶視角的字符”,歡迎來到全新的 Unicode 時代~
類比計算機發展早期,比如在 C 語言中,一個 char (8 位) 可以表示 ASCII 字符;但在當今的時代,即使是 32 位的 Char,依然有很多用戶視角的字符超出了其表示能力。
小結
很多用戶角度的字符已經超出了 Unicode 字符的表示范圍,即 32 位 Char 類型的表達能力。
“Char == 字符” 假設,不再成立,或者在一定范圍內成立。
字符是一個很容易產生歧義的詞,當談及字符時候一定要小心 (指代不夠明確,是用戶角度的字符?Char 類型?32 位?16 位?8 位?)。
Char 類型有可能誤導使用者,其在部分場景夠用,但是更復雜的場景會暴露其不足 (在后面的 Code Point Unaware String 章節會有進一步的討論) 。
# 走進字符串 #
可以看到,即使是一個用戶視角的字符,我們也可能需要一個字符串 (多個 Char 類型) 來表示。
我們對字符串最直觀的認識 —— Char 數組,這個直觀認識不管從人類直覺還是從計算機發展都是很理所當然的。
從人類直覺來說,字符串,字面意思,“字符” 的 “串”,使用代碼來表示,自然而然會想到用 Char 數組。
從筆者個人學習經歷來說,筆者學的第一門編程語言是 C 語言,C 語言里面并沒有單獨的 String 類型,在 C 里面,字符串與 char 數組是等價的。
在一些語言的 String 設計中,這種直覺也是 “行得通” 的,比如 Java、C#、Swift,其字符串的長度都表示 (該語言中) 字符類型的個數,對 String 索引操作得到的也是字符類型。
但如果我們再擴大一下視角,來橫向看一下各個編程語言中的 String 類型,我們會發現,“字符串是 Char 數組” 這個概念,好像并不統一。
我們通過各個語言中 String 類型索引和長度的含義,以此來判斷其 String 是否為 Char 數組。
索引和長度的含義可能是語言中內置 Char 類型,此時 String 與 Char 數組無異,我們可以像操作數組一樣操作 String;
除此之外,索引和長度還可能是指 “字節”,二者的關系 —— 一個 Char 可以由一個多個字節組成 (更具體的關系在后面的編碼章節會繼續深入),比較的語言和結果如下:
? | Swift | Go | Rust | Julia | Java | C# |
---|---|---|---|---|---|---|
String 索引含義 | Character 類型 | byte | byte | byte | Char 類型 | Char 類型 |
String 是 Char 數組? | ? | ? | ? | ? | ? | ? |
注:這里并不考察 String 與 Char 數組的轉換,因為所有語言都支持這種操作。
可以看到,這些語言都做出了不同的選擇 (設計) ,設計者到底為什么做出決定,各個語言為何在 string 的概念上不盡相同。
這一切的背后到底是道德的淪喪,還是人性的扭曲。
仔細思考一下這個分類,其實是很粗糙的,同時也存在一些問題的,比如:
不同語言中字符類型相同嗎?C 語言中 char 是 8 位,Java 中是 16 位,Rust 中是 32 位,其他語言呢?基于不同的 char 類型比較得出的結論有意義嗎?
String 類型的屬性依賴于另一個類型?假如改變某一個語言中字符類型的實現,那 String 的屬性也跟著變了
為了解釋這些疑問,同時為了看到各語言中 String 類型更本質的區別,我們再進一步來看看 String 類型的實現。
# 深入實現之前 —— Unicode 編碼?#
在討論 String 的實現之前,我們先簡單講解一下 Unicode 編碼,讀者可能之前聽到過 UTF-8、UTF-16、UTF-32,這里會盡可能地從 high-level 進行講解。
首先我們從編碼說起,一句話解釋,編碼就是將 Code Points 轉化成二進制字節的過程;
依然從老朋友 ASCII 碼講起,對于 ASCII 碼而言,編碼只有一種,直接將編號換算 (進制轉換) 成二進制 (或者十六進制) 即可;
如:大寫字母 “A” 的編號 65,編碼后就是 0x41。
基于這種直接進制轉換的思想,我們便得到了名為 UTF-32 的編碼方式 —— 直接將 Unicode Code Point 轉換成 32 位的二進制字節即可;
如下圖中的 A 和 ,分別換算成 00 00 00 41 和 00 01 F6 00,
這種編碼方式的缺點很明顯 —— 浪費空間,對于常用的小編號 Code Point (字母和數字,在 ASCII 字符集內的字符) ,會存在前導零,這些前導零會浪費空間。
為了解決 UTF-32 浪費空間的前導零,便有了變長編碼 —— UTF-8。
讀者只需要理解兩種編碼的由來和特點即可,至于如何具體編碼,這篇文章不會包含這部分內容。
在上圖中可以看到,Code Point 經過 UTF-8 編碼得到的字節從一個到四個不等。
再引入一個概念——Code Unit ?(編碼單元) ,指的是編碼后的基本單元的長度,UTF-32 的編碼單元是 32 位,4 個字節,編碼名稱中的 32 指的就是編碼單元的長度;
UTF-8 同理,編碼后的基本單元長度是 8 位,一個字節。
關于 UTF-16,從現在的角度來看,既有前導零浪費空間的問題 (下圖中的 A 字符) ,又是變長編碼 (變長編碼的缺點在后面會討論到) ,
這東西可以說完全是 “時代的眼淚”,UTF-16 的設計初衷是 “定長編碼”,因為早期的 Unicode 標準犯了一個歷史錯誤——過早承諾了 Code Point 可以用 16 位存下。
但隨著 Unicode 標準的演進,最初的設計優點 (定長編碼) 不復存在,成了只留下了缺點 (浪費空間和變長) 的編碼方案。
雖然不在本文的關注范圍內,但是如果遇到文本亂碼,那基本上是編碼問題了。
你無法在不知道編碼的情況下解決亂碼的問題。
沒有編碼的字符串,毫無意義!——馬主任
在了解了 Unicode 和相關編碼之后,再來回頭看一下各種語言的 Char 和 String 類型的實現:
? | Swift | Go | Rust | Julia | Java | C# |
---|---|---|---|---|---|---|
String 實現編碼 | UTF-16 -> UTF-8 | UTF-8 | UTF-8 | UTF-8 | UTF-16 | UTF-16 |
String 索引含義 | Character 類型 | byte | byte | byte | Char 類型 | Char 類型 |
字符類型 | User-perceived character | 32 位 | 32 位 | 32 位 | 16 位 | 16 位 |
字符類型是用戶角度的字符? | ? | ? | ? | ? | ? | ? |
String 是編碼單元數組? | ? | ? | ? | ? | ? | ? |
可以很明顯的看到,首先是 String 類型實現上的不同——有的語言采用了 UTF-8 編碼,有的采用了 UTF-16。
其次,連 Char 類型的實現也不一樣,Swift 中的 Character 類型,其抽象程度是最高的,可以直接用來表示用戶角度的字符;比如需要 7 個 Code Point 才能表示的一家子 ,在 Swift 中完全可以用一個 Character 類型的變量存下。
其它語言中的 Char 類型有 32 位的也有 16 位的,這兩種實現中只有 32 位的 Char 可以表示 Unicode Code Point,而 16 位 Char 甚至連一個 Code Point 都無法表示,要知道,Code Point 可是 Unicode 標準中最小的帶有語義的單元。
編程語言中各不相同的 String 和 Char 類型的設計,是否存在統一的視角進行解讀?
# String 類型的歷史變遷 #
下面,從計算機發展的角度來理解一下各語言的設計初衷。
# ASCII 碼, C 語言
計算機早期處理的自然語言字符很有限,使用 ASCII 編碼就能裝下,對于字符串和字符的概念也沒有明確的區分。
比如在 C 語言中,使用 8 位的 Char 類型表示 ASCII 字符,而字符串也自然而然地使用了 char 數組。
從這里開始慢慢誕生了一個假設或者說是潛意識的習慣 —— “一個 Char 表示一個字符”,很顯然,這里需要限定是 8 位 Char 和 ASCII 碼字符。
# Unicode, UTF-16,Java 和 C#
隨著計算機的發展,出現了 Unicode 來統一人類的語言的編碼,但是早期的 Unicode 犯了一個 歷史性錯誤——承諾 Unicode 字符可以用 16 位存下。
與此同時第一批支持 Unicode 的 Java 和 windows 都成了受害者——選擇了 UTF-16 編碼,但這個編碼延續了以前的“習慣” —— “一個 Char 依然能表示一個字符”,只不過這里的 Char 變成了 16 位,字符變成了早期 Unicode 字符集里的字符。
這個時期的編程語言里也出現了獨立的數據類型 String 來表示字符串,幸運的是,在 UTF-16 編碼下,String 依然可以視作 Char 數組,因為 UTF-16 編碼中每個編碼單元都是 16 位,這些語言中的 Char 也從以前語言中的 8 位變成了 16 位。
# Unicode,UTF-8 和最近的編程語言
隨著 Unicode 的進一步發展,標準內的字符集已經無法用 16 位裝下了,需要 32 位才能表示一個 Unicode 編碼的字符。
甚至還出現了一些組合字符或者修飾字符,甚至有些自然語言里無法清晰地定義 “字符” 這個概念。
這時候即使是能夠表示 Unicode 編碼字符的 32 位 Char,也不能表示用戶角度的字符了。
此時上述“習慣” —— “Char == 字符”已經不成立了,與此同時出現了 UTF-8 的編碼方式,該編碼方式被大量的網站和協議,工具等采用,采用 UTF-16 實現 String 的編程語言開始產生新的問題:
雖然 String 依然是 Char 數組,但這里的 16 位 Char 表示能力已經遠低于后面語言中的 32 位 Char 了,甚至一些漢字和 emoji 需要兩個 16 位 Char 才可以表示。
丟棄假設“Char == 字符”,String 不再視作 Char 數組,如 Go、Rust、Julia。這些語言中,不存在 String 到 Char 的抽象開銷,由此帶來了高效的字符串處理效率,但是需要用戶先打破自己對字符串的傳統認知
# String 類型的設計與性能 #
說完了比較 high-level,比較抽象的設計層面,我們再來關注一下更具體的東西——性能,看看 String 的設計與性能會存在什么樣的聯系。
首先補充兩個背景知識:
UTF-8 已成為事實上的標準。
變長編碼帶來的 Code Point 抽象層開銷。
這兩條背景知識剛好對應著 String 性能的兩個維度——外部和內部:
String 外部的性能 (與其他庫或者接口操作) ,String 實現采用非 UTF-8 編碼時,會在 String 編解碼時產生開銷 (如 Java 和 C#) 。
String 內部的性能 (自身操作) ,比如查找,切片等操作,String 抽象成非 “編碼單元” 數組時,由于多了一層抽象層 (編碼索引和字符索引之間的轉換) ,在 String 自身操作時會產生性能問題 (如 Swift) 。
舉例說明 UTF-8 變長編碼索引 Code Point 時的性能開銷:
比如,如果我們想得到漢字“陽”的字符類型索引,由于前面的字符 (字母標點和漢字) 對應的編碼字節都是不確定的,所以需要從頭開始計算,這樣一個 O(N) 的操作對于字符串這種準基本類型代價是很高的。
再來定性分析一下各語言 String 類型的性能:
? | Swift | Go | Rust | Julia | Java | C# |
---|---|---|---|---|---|---|
String 是編碼單元數組? | ? | ? | ? | ? | ? | ? |
字符串操作開銷 | 有 | 無 | 無 | 無 | 無 | 無 |
String 實現編碼 | UTF-8 | UTF-8 | UTF-8 | UTF-8 | UTF-16 | UTF-16 |
API 間編解碼開銷 | 無 | 無 | 無 | 無 | 有 | 有 |
繼續具體地看一下各個語言的 String 類型:
Swift
該語言中的 Character 抽象能力最強,可以表示用戶角度的字符,其 String 類型的長度也是符合人類直覺的 “字符串” 的長度;但是由于 Character 這層抽象層,不僅在使用上帶來不便,為什么 Swift 的 String 這么難用?[3],性能也會受影響,Swift String 性能問題)?[4]。
Java
在 Java 18 中,各種 API 已經默認使用了 UTF-8 編碼,但是使用 UTF-16 實現的 String 不可避免的會產生編解碼的開銷;與此同時,由于 16 位 Char 表達能力的缺陷,在 String 類型的 API 中除了 Char 相關的,也有 Code Point 相關的,如 CharAt (int index)?[5] 和 codePointAt (int index)?[6]。
C#
不光有 16 位的 Char 類型,還有 32 位的 Rune 類型。
(一點吐槽,Java 設計 UTF-16 String 是由于 Unicode 標準的歷史錯誤,但是 C# 的時期是可以避免這點的,為什么也栽了跟頭呢?)
雖然 C# String 實現和微軟自家生態都是基于 UTF-16,但微軟官方也在提倡采用 UTF-8 編碼,Use the Windows UTF-8 code page?[7]。
Go,Rust,Julia
基于 UTF-8 編碼,無額外抽象層的 String 類型,在性能上看不到明顯短板,唯一可能讓人感到違背直覺的——無法按照 char 來操作,接下來我們就來講解一下。
# Code Point Unaware String?#
比較現代的語言都選擇了 UTF-8 實現的 string,性能上可以看到兩方面都沒有劣勢,唯一可能讓人感到違背直覺的——無法按照 char 來操作,更準確的說法是,Code Point Unaware String,在網上也可以經常看到一些討論,如 what if strings were Code Point aware??[8]?on Rust,Indexing strings by Unicode code point instead of code unit??[9]?on Julia。
那么這個設計的初衷是什么呢,即使不去設計編程語言,作為語言 (庫) 的使用者又該如何理解呢?
首先,從實現的角度來說,變長的 UTF-8 編碼不適合 “直接” 抽象成 Code Point 數組 (可以參考 Swift 的抽象層導致的性能問題) ;
其次,在當今的軟件中,索引 Code Point 并不像大家直覺中那么重要,絕大部分場景 Code Point 都不能滿足需求。
從實際的應用軟件角度來考慮:
鼠標移動和選定——從上面的例子可以發現,用戶角度的字符并不是由 Char 一一對應的,在這種情況下,使用到的基本單位是字素 (grapheme)
文本查找和替換的場景——類似下方背景中性能問題提到的例子,需要先通過遍歷或者搜索得到字符串的位置,再通過這個位置來操作,而這個位置是字節還是 Code Point 的并不會影響功能,但是會影響性能 (后者需要額外的計算)
限制長度的場景——如輸入字符串,協議傳輸,數據庫等;更關心字符串編碼后的長度,即字節數,使用到的基本單位是 (code unit)
在屏幕上渲染 “字符”——同樣地,與 Char 無關,一個 Char 占據多少列是不確定的,需要由渲染引擎決
最后一點,不是那么明顯但是影響深遠,Code Point 這層抽象不是一個 “正確” 的抽象。 Code Point 在簡單的場景中使用看起來不會有問題,但一旦問題變復雜,程序員可能意識不到是編程語言的數據類型的問題;Code Point 作為一個不準確的抽象,如果僅指望通過它來進行簡化,無異于掩蓋 Unicode 的復雜性,而這種行為反倒會埋下潛在的隱患。
由此可見,該抽象層可能會誤導程序員,掩蓋了所處理問題的復雜性。 # 總結 # 從編程語言的歷史演進中可以看到,String 可以說是始于 Char 數組,但隨著處理問題越來越復雜 (源于人類語言的復雜性) ,在一些現代語言中,String 已經不再作為 Char 數組,而是獨立成單獨的類型;出于不同的目的或者是原因,各個編程語言中的字符類型和 String 類型也存在著這樣或那樣的差異。
審核編輯:劉清
評論
查看更多