戰(zhàn))
1. 為什么我要在C語言里手搓UTF-8處理函數(shù)很多人第一次接觸字符編碼都是在網(wǎng)頁的meta charsetutf-8這行標(biāo)簽里。寫前端的時(shí)候知道要加這行寫C語言的時(shí)候卻往往忽略了一件事C語言標(biāo)準(zhǔn)庫里的char就是一個(gè)字節(jié)它根本不知道什么叫字符。你從文件里讀出來的一串字節(jié)到底怎么切分、怎么判斷長(zhǎng)度、怎么截?cái)嗳磕阕约禾幚怼N以谧鲆粋€(gè)純C的小工具時(shí)遇到了這個(gè)問題。需求很簡(jiǎn)單讀入一段文本按字符逐個(gè)處理遇到中文、日文、emoji 都要能正確識(shí)別為一個(gè)字符而不是按字節(jié)亂切。第一反應(yīng)當(dāng)然是找第三方庫比如各種成熟的 Unicode 處理庫。但項(xiàng)目有個(gè)硬約束——不能引入任何第三方依賴編譯出來就是一個(gè)干干凈凈的單文件可執(zhí)行程序扔到任何有C編譯器的機(jī)器上都能直接編。這個(gè)約束其實(shí)很常見。嵌入式環(huán)境、教學(xué)作業(yè)、競(jìng)賽題目、還有那些要求零依賴的命令行小工具都會(huì)碰到。于是我就決定自己寫一套 UTF-8 的編解碼和常用工具函數(shù)。做完之后發(fā)現(xiàn)這件事沒有想象中那么難核心邏輯加起來也就兩三百行但里面有幾個(gè)坑如果不提前知道調(diào)試起來會(huì)非常痛苦。這篇文章就把我整個(gè)實(shí)現(xiàn)過程拆開講清楚。適合兩類人看一類是正在學(xué)C語言、想搞明白字符編碼到底怎么回事的朋友另一類是有實(shí)際需求、需要在無第三方庫環(huán)境下處理多字節(jié)文本的開發(fā)者。我會(huì)從UTF-8的編碼原理講起然后給出完整的工具函數(shù)實(shí)現(xiàn)最后重點(diǎn)講我在實(shí)測(cè)中踩到的坑和排查過程。所有代碼都是純標(biāo)準(zhǔn)C不依賴任何平臺(tái)特有的頭文件。2. UTF-8的編碼規(guī)則到底是怎么設(shè)計(jì)的2.1 從ASCII的兼容性說起要理解UTF-8得先明白它要解決什么問題。ASCII用7個(gè)比特表示128個(gè)字符一個(gè)字節(jié)就夠了。但全世界那么多種文字128個(gè)位置遠(yuǎn)遠(yuǎn)不夠。于是有了Unicode給每個(gè)字符分配一個(gè)唯一的碼點(diǎn)code point比如漢字中的碼點(diǎn)是 U4E2Demoji 的碼點(diǎn)是 U1F600。問題來了碼點(diǎn)范圍從 U0000 一直到 U10FFFF跨度很大怎么用字節(jié)序列表示如果統(tǒng)一用4個(gè)字節(jié)那英文文本的體積會(huì)膨脹4倍而且和已有的ASCII系統(tǒng)完全不兼容。UTF-8的設(shè)計(jì)目標(biāo)就是變長(zhǎng)編碼 完全兼容ASCII。具體做法是ASCII范圍內(nèi)的字符U0000 到 U007F仍然用1個(gè)字節(jié)表示最高位是0。這樣任何一段純英文文本用UTF-8編碼和用ASCII編碼出來的字節(jié)完全一樣老程序讀它也不會(huì)出錯(cuò)。超出ASCII范圍的字符用2到4個(gè)字節(jié)表示每個(gè)字節(jié)的最高位都是1通過前導(dǎo)1的個(gè)數(shù)來標(biāo)記這個(gè)字符總共占幾個(gè)字節(jié)。2.2 四種長(zhǎng)度的字節(jié)模板UTF-8的編碼模板可以總結(jié)成一張表這張表是整個(gè)實(shí)現(xiàn)的核心建議直接背下來碼點(diǎn)范圍字節(jié)數(shù)字節(jié)模板U0000 ~ U007F10xxxxxxxU0080 ~ U07FF2110xxxxx 10xxxxxxU0800 ~ UFFFF31110xxxx 10xxxxxx 10xxxxxxU10000 ~ U10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx看這張表能發(fā)現(xiàn)幾個(gè)規(guī)律。第一首字節(jié)的前導(dǎo)1的個(gè)數(shù)正好等于這個(gè)字符的總字節(jié)數(shù)。1字節(jié)的首字節(jié)是0開頭2字節(jié)是110開頭3字節(jié)是1110開頭4字節(jié)是11110開頭。第二所有后續(xù)字節(jié)也叫續(xù)字節(jié)都是10開頭。這個(gè)設(shè)計(jì)非常巧妙它保證了兩個(gè)重要性質(zhì)任何一個(gè)字節(jié)你都能立刻判斷出它是首字節(jié)還是續(xù)字節(jié)而且從一個(gè)字節(jié)序列的任意位置開始掃描只要找到0或110或1110或11110開頭的字節(jié)就知道這是一個(gè)字符的起點(diǎn)。2.3 為什么這個(gè)設(shè)計(jì)能自同步自同步是UTF-8一個(gè)被低估的優(yōu)點(diǎn)。假設(shè)你的字節(jié)流中間丟了一個(gè)字節(jié)或者從中間某個(gè)位置開始讀你不需要從頭重新解析只要往后找到第一個(gè)不是10開頭的字節(jié)那就是下一個(gè)字符的起點(diǎn)。這個(gè)特性在網(wǎng)絡(luò)傳輸和文件讀取中非常實(shí)用。對(duì)比一下UTF-16它用固定的2字節(jié)或4字節(jié)表示但字節(jié)序大端小端問題、代理對(duì)surrogate pair問題都很麻煩而且和ASCII不兼容。UTF-8犧牲了一點(diǎn)解碼時(shí)的判斷成本換來了兼容性和健壯性這就是它成為互聯(lián)網(wǎng)事實(shí)標(biāo)準(zhǔn)的原因。理解了這張模板表接下來的編碼和解碼就是純粹的位運(yùn)算了。編碼就是把碼點(diǎn)的二進(jìn)制位按模板填進(jìn)去解碼就是反過來把x位置的位取出來拼成碼點(diǎn)。3. 手寫編解碼函數(shù)從碼點(diǎn)到字節(jié)序列3.1 編碼函數(shù)的設(shè)計(jì)思路編碼函數(shù)要做的事情是輸入一個(gè)碼點(diǎn)用uint32_t表示輸出對(duì)應(yīng)的UTF-8字節(jié)序列并返回寫入了幾個(gè)字節(jié)。我給它設(shè)計(jì)的簽名是這樣的int utf8_encode(uint32_t codepoint, unsigned char *out);返回寫入的字節(jié)數(shù)如果碼點(diǎn)非法比如落在代理區(qū) UD800~UDFFF或者超過 U10FFFF返回 -1。out緩沖區(qū)由調(diào)用者保證至少有4個(gè)字節(jié)。實(shí)現(xiàn)的時(shí)候我按碼點(diǎn)范圍分四種情況處理。這里有個(gè)細(xì)節(jié)要注意位運(yùn)算的移位和掩碼必須精確對(duì)應(yīng)模板。以3字節(jié)為例模板是1110xxxx 10xxxxxx 10xxxxxx總共能容納 46616 位正好覆蓋 U0800 到 UFFFF 的范圍。int utf8_encode(uint32_t cp, unsigned char *out) { if (cp 0x7F) { out[0] (unsigned char)cp; return 1; } else if (cp 0x7FF) { out[0] 0xC0 | (cp 6); out[1] 0x80 | (cp 0x3F); return 2; } else if (cp 0xFFFF) { if (cp 0xD800 cp 0xDFFF) return -1; // 代理區(qū)非法 out[0] 0xE0 | (cp 12); out[1] 0x80 | ((cp 6) 0x3F); out[2] 0x80 | (cp 0x3F); return 3; } else if (cp 0x10FFFF) { out[0] 0xF0 | (cp 18); out[1] 0x80 | ((cp 12) 0x3F); out[2] 0x80 | ((cp 6) 0x3F); out[3] 0x80 | (cp 0x3F); return 4; } return -1; }這段代碼里0xC0、0xE0、0xF0分別是2、3、4字節(jié)首字節(jié)的前綴掩碼0x80是續(xù)字節(jié)的前綴。每次取6位 0x3F是因?yàn)槔m(xù)字節(jié)只有6個(gè)可用位。這個(gè)每次6位的規(guī)律是UTF-8位運(yùn)算的核心節(jié)奏。3.2 解碼函數(shù)判斷長(zhǎng)度是第一步解碼比編碼稍微復(fù)雜一點(diǎn)因?yàn)槟阋扰袛喈?dāng)前字節(jié)是首字節(jié)還是續(xù)字節(jié)是首字節(jié)的話還要算出總長(zhǎng)度。我設(shè)計(jì)的簽名是int utf8_decode(const unsigned char *in, uint32_t *codepoint);返回消耗的字節(jié)數(shù)失敗返回 -1。這里有個(gè)關(guān)鍵點(diǎn)函數(shù)不能假設(shè)輸入緩沖區(qū)有多長(zhǎng)所以調(diào)用者必須保證傳入的指針指向一個(gè)完整的字符序列或者函數(shù)內(nèi)部只讀取它判斷出的長(zhǎng)度范圍內(nèi)的字節(jié)。我在實(shí)現(xiàn)里只讀取必要字節(jié)不做越界訪問。int utf8_decode(const unsigned char *in, uint32_t *cp) { unsigned char b0 in[0]; if (b0 0x80) { *cp b0; return 1; } else if ((b0 0xE0) 0xC0) { if ((in[1] 0xC0) ! 0x80) return -1; *cp ((uint32_t)(b0 0x1F) 6) | (uint32_t)(in[1] 0x3F); return 2; } else if ((b0 0xF0) 0xE0) { if ((in[1] 0xC0) ! 0x80 || (in[2] 0xC0) ! 0x80) return -1; *cp ((uint32_t)(b0 0x0F) 12) | ((uint32_t)(in[1] 0x3F) 6) | (uint32_t)(in[2] 0x3F); return 3; } else if ((b0 0xF8) 0xF0) { if ((in[1] 0xC0) ! 0x80 || (in[2] 0xC0) ! 0x80 || (in[3] 0xC0) ! 0x80) return -1; *cp ((uint32_t)(b0 0x07) 18) | ((uint32_t)(in[1] 0x3F) 12) | ((uint32_t)(in[2] 0x3F) 6) | (uint32_t)(in[3] 0x3F); return 4; } return -1; }判斷首字節(jié)類型用的是掩碼比較(b0 0xE0) 0xC0判斷是不是110開頭(b0 0xF0) 0xE0判斷是不是1110開頭(b0 0xF8) 0xF0判斷是不是11110開頭。這個(gè)技巧比逐位檢查更簡(jiǎn)潔也是標(biāo)準(zhǔn)做法。注意解碼時(shí)一定要校驗(yàn)續(xù)字節(jié)的高兩位是不是10。很多簡(jiǎn)化實(shí)現(xiàn)會(huì)跳過這個(gè)檢查結(jié)果遇到損壞的數(shù)據(jù)就會(huì)解出亂七八糟的碼點(diǎn)甚至越界讀取。這個(gè)校驗(yàn)是健壯性的關(guān)鍵。3.3 一個(gè)容易被忽略的細(xì)節(jié)最短編碼校驗(yàn)上面這段解碼代碼有個(gè)漏洞它沒有檢查最短編碼。什么意思比如字符AU0041本來應(yīng)該用1個(gè)字節(jié)0x41表示但理論上你可以用2字節(jié)0xC1 0x81來表示它解碼出來還是 U0041。這種叫過長(zhǎng)編碼overlong encoding是安全漏洞的常見來源。嚴(yán)格來說解碼時(shí)應(yīng)該校驗(yàn)2字節(jié)編碼的碼點(diǎn)必須 ≥ 0x803字節(jié)的必須 ≥ 0x8004字節(jié)的必須 ≥ 0x10000。我在第一版里漏了這個(gè)檢查后來在測(cè)試時(shí)用構(gòu)造的惡意數(shù)據(jù)才發(fā)現(xiàn)。加上校驗(yàn)的代碼是在每個(gè)分支里多一個(gè)判斷// 2字節(jié)分支內(nèi)解碼完成后 if (*cp 0x80) return -1; // 過長(zhǎng)編碼這個(gè)細(xì)節(jié)在實(shí)際項(xiàng)目中很重要尤其是處理來自外部的不可信數(shù)據(jù)時(shí)。雖然多幾行代碼但能避免很多潛在問題。4. 那些真正讓代碼好用的工具函數(shù)光有編解碼還不夠?qū)嶋H用起來你會(huì)發(fā)現(xiàn)最常需要的是一組順手的工具函數(shù)。我把它們分成三類字符計(jì)數(shù)、字符串遍歷、以及緩沖區(qū)安全操作。4.1 計(jì)算字符串的字符數(shù)strlen返回的是字節(jié)數(shù)不是字符數(shù)。對(duì)于一段中文文本字節(jié)數(shù)可能是字符數(shù)的3倍。寫一個(gè)utf8_strlen很直接從頭掃描每次解碼一個(gè)字符計(jì)數(shù)加一指針后移解碼消耗的字節(jié)數(shù)。size_t utf8_strlen(const char *s) { size_t count 0; const unsigned char *p (const unsigned char *)s; while (*p) { uint32_t cp; int len utf8_decode(p, cp); if (len 0) { p; continue; } // 遇到非法字節(jié)跳過 p len; count; } return count; }這里有個(gè)設(shè)計(jì)決策遇到非法字節(jié)怎么辦我選擇跳過1個(gè)字節(jié)繼續(xù)而不是直接返回錯(cuò)誤。原因是實(shí)際文本里偶爾會(huì)有損壞的字節(jié)如果整個(gè)函數(shù)因?yàn)橐粋€(gè)壞字節(jié)就失敗用戶體驗(yàn)很差。跳過并繼續(xù)至少能統(tǒng)計(jì)出大部分正確字符。當(dāng)然如果你需要嚴(yán)格的錯(cuò)誤報(bào)告可以改成返回錯(cuò)誤碼。4.2 按字符截?cái)嘧址@是最實(shí)用的函數(shù)之一。比如你要在界面上顯示一段文本限制最多20個(gè)字符但不想把一個(gè)中文字符從中間切斷那樣會(huì)顯示成亂碼。utf8_truncate就是干這個(gè)的size_t utf8_truncate(const char *s, size_t max_chars, char *out, size_t out_size) { const unsigned char *p (const unsigned char *)s; size_t chars 0, bytes 0; while (*p chars max_chars) { uint32_t cp; int len utf8_decode(p, cp); if (len 0) break; if (bytes len out_size) break; // 留出結(jié)尾的\0 memcpy(out bytes, p, len); bytes len; p len; chars; } out[bytes] \0; return bytes; }注意out_size的判斷用的是而不是因?yàn)橐o結(jié)尾的\0留位置。這個(gè) off-by-one 的坑我踩過第一次寫的時(shí)候用了結(jié)果緩沖區(qū)剛好滿的時(shí)候會(huì)越界寫一個(gè)字節(jié)。4.3 驗(yàn)證一段字節(jié)是不是合法UTF-8有時(shí)候你需要判斷一段數(shù)據(jù)到底是不是合法的UTF-8比如讀取配置文件時(shí)。utf8_validate遍歷整個(gè)字符串任何一步解碼失敗就返回0int utf8_validate(const char *s) { const unsigned char *p (const unsigned char *)s; while (*p) { uint32_t cp; int len utf8_decode(p, cp); if (len 0) return 0; p len; } return 1; }配合前面說的最短編碼校驗(yàn)這個(gè)函數(shù)能擋住絕大多數(shù)畸形數(shù)據(jù)。我在處理用戶上傳的文本文件時(shí)會(huì)先用它過一遍不合法就拒絕避免后續(xù)處理出問題。4.4 碼點(diǎn)和字符的相互轉(zhuǎn)換輔助還有兩個(gè)小工具很常用。一個(gè)是把碼點(diǎn)轉(zhuǎn)成可讀的十六進(jìn)制字符串方便調(diào)試打印void codepoint_to_hex(uint32_t cp, char *buf) { sprintf(buf, U%04X, cp); }另一個(gè)是判斷某個(gè)碼點(diǎn)是不是ASCII這在做文本分析時(shí)經(jīng)常需要int is_ascii(uint32_t cp) { return cp 0x7F; }別看這些函數(shù)簡(jiǎn)單組合起來就能搭出一個(gè)夠用的文本處理層。關(guān)鍵是它們都不依賴任何第三方庫純標(biāo)準(zhǔn)Cstring.h和stdint.h就夠了。5. 實(shí)測(cè)中踩到的坑和排查過程代碼寫完了不代表能用。我在實(shí)際測(cè)試中遇到了一連串問題這里把排查過程完整還原出來因?yàn)檫@些問題很可能你也會(huì)遇到。5.1 第一個(gè)坑有符號(hào)char導(dǎo)致的判斷錯(cuò)誤最開始我的解碼函數(shù)參數(shù)是const char *結(jié)果在處理高位字節(jié)時(shí)出了詭異的問題。比如字節(jié)0xE4漢字中的首字節(jié)在char是有符號(hào)類型的平臺(tái)上它會(huì)被解釋成負(fù)數(shù)-28。這時(shí)候b0 0x80這個(gè)判斷居然成立了因?yàn)?-28 確實(shí)小于 128于是函數(shù)把它當(dāng)成ASCII字符處理直接返回了錯(cuò)誤的碼點(diǎn)。排查這個(gè)問題的過程很典型我打印出每個(gè)字節(jié)的十六進(jìn)制值發(fā)現(xiàn)首字節(jié)明明是0xE4但程序走進(jìn)了一字節(jié)分支。盯著代碼看了半天才意識(shí)到是符號(hào)問題。解決辦法很簡(jiǎn)單所有處理字節(jié)的指針和變量一律用unsigned char。這個(gè)教訓(xùn)讓我養(yǎng)成了一個(gè)習(xí)慣凡是涉及字節(jié)操作的代碼unsigned char是默認(rèn)選擇。5.2 第二個(gè)坑緩沖區(qū)邊界與文件讀取我的工具需要從文件讀取內(nèi)容。第一版我用fseekftell拿到文件大小然后malloc對(duì)應(yīng)大小的緩沖區(qū)再fread一次性讀入。測(cè)試小文件沒問題但處理一個(gè)幾十兆的日志文件時(shí)崩潰了。用調(diào)試器跟進(jìn)去發(fā)現(xiàn)ftell返回的大小和實(shí)際讀到的字節(jié)數(shù)對(duì)不上。原因是文件是以文本模式打開的在某些平臺(tái)上換行符會(huì)被轉(zhuǎn)換導(dǎo)致實(shí)際字節(jié)數(shù)和ftell報(bào)告的不一致。改成二進(jìn)制模式打開rb后問題解決。這個(gè)坑在跨平臺(tái)時(shí)特別容易踩因?yàn)椴煌到y(tǒng)對(duì)文本模式的處理不一樣。另外ftell返回的是long類型在32位平臺(tái)上最大只有2GB。如果要處理更大的文件得用fseeko/ftello或者分塊讀取。我后來改成了分塊讀取的方式每次讀4KB邊讀邊處理內(nèi)存占用也小了很多。5.3 第三個(gè)坑BOM頭的處理從Windows上創(chuàng)建的UTF-8文件開頭往往會(huì)有一個(gè)BOMByte Order Mark字節(jié)序列是EF BB BF。這個(gè)BOM不是文本內(nèi)容的一部分但如果你不處理它utf8_strlen會(huì)把它算成一個(gè)字符顯示的時(shí)候也會(huì)多出一個(gè)看不見的字符。我一開始沒注意直到發(fā)現(xiàn)同一段文本在Windows記事本和Linux編輯器里顯示的長(zhǎng)度不一樣。排查時(shí)把文件頭幾個(gè)字節(jié)打印出來看到了EF BB BF才恍然大悟。處理辦法是在讀取文件后檢查開頭三個(gè)字節(jié)如果是BOM就跳過if (len 3 (unsigned char)buf[0] 0xEF (unsigned char)buf[1] 0xBB (unsigned char)buf[2] 0xBF) { memmove(buf, buf 3, len - 3); len - 3; }用memmove而不是memcpy因?yàn)樵春湍繕?biāo)內(nèi)存區(qū)域有重疊。這個(gè)細(xì)節(jié)如果搞錯(cuò)數(shù)據(jù)會(huì)被破壞。5.4 第四個(gè)坑代理區(qū)碼點(diǎn)的編碼前面編碼函數(shù)里我加了代理區(qū)的檢查但第一版沒有。測(cè)試時(shí)我故意構(gòu)造了一個(gè)碼點(diǎn) UD800 去編碼結(jié)果編出來一個(gè)3字節(jié)序列解碼回來還是 UD800看起來正常。但這個(gè)碼點(diǎn)在Unicode標(biāo)準(zhǔn)里是明確禁止單獨(dú)使用的它是為UTF-16的代理對(duì)保留的。雖然自編自解看起來沒問題但如果這個(gè)字節(jié)序列被其他程序處理就可能出問題。所以編碼時(shí)一定要拒絕代理區(qū)碼點(diǎn)。這個(gè)檢查加上之后我的utf8_encode才算真正健壯。5.5 排查工具一個(gè)簡(jiǎn)單的十六進(jìn)制打印函數(shù)上面這些問題的排查都離不開一個(gè)工具把字節(jié)按十六進(jìn)制打印出來。我寫了一個(gè)小函數(shù)調(diào)試時(shí)非常有用void hexdump(const unsigned char *data, size_t len) { for (size_t i 0; i len; i) { printf(%02X , data[i]); if ((i 1) % 16 0) printf(\n); } printf(\n); }遇到編碼問題時(shí)先用它把原始字節(jié)打出來對(duì)照UTF-8模板表一看問題往往就清楚了。這個(gè)習(xí)慣幫我省了大量時(shí)間。6. 完整可用的代碼組織與測(cè)試方法6.1 文件劃分建議雖然所有代碼可以塞進(jìn)一個(gè)文件但為了可維護(hù)性我建議分成兩個(gè)文件utf8.h放函數(shù)聲明和必要的類型定義utf8.c放實(shí)現(xiàn)。頭文件里加上防止重復(fù)包含的宏#ifndef UTF8_H #define UTF8_H #include stdint.h #include stddef.h int utf8_encode(uint32_t cp, unsigned char *out); int utf8_decode(const unsigned char *in, uint32_t *cp); size_t utf8_strlen(const char *s); size_t utf8_truncate(const char *s, size_t max_chars, char *out, size_t out_size); int utf8_validate(const char *s); #endif這樣其他項(xiàng)目要用直接拷貝這兩個(gè)文件就行零依賴。6.2 測(cè)試用例的設(shè)計(jì)自己寫的編解碼一定要有測(cè)試。我設(shè)計(jì)了幾組用例覆蓋各種邊界測(cè)試內(nèi)容輸入期望輸出ASCII單字節(jié)A長(zhǎng)度1碼點(diǎn)0x412字節(jié)邊界U0080編碼為C2 803字節(jié)中文中編碼為E4 B8 AD4字節(jié)emoji U1F600編碼為F0 9F 98 80最大碼點(diǎn)U10FFFF編碼為F4 8F BF BF非法代理區(qū)UD800編碼返回-1過長(zhǎng)編碼C1 81解碼返回-1截?cái)嗟男蛄蠩4 B8解碼返回-1特別要測(cè)的是往返一致性隨機(jī)生成一批合法碼點(diǎn)編碼后再解碼看是否和原碼點(diǎn)相同。這個(gè)測(cè)試能發(fā)現(xiàn)絕大多數(shù)位運(yùn)算錯(cuò)誤。6.3 性能上的實(shí)測(cè)感受有人可能擔(dān)心手寫解碼的性能。我實(shí)測(cè)下來在普通筆記本上utf8_strlen處理100萬個(gè)字符的文本耗時(shí)在幾十毫秒級(jí)別完全夠用。瓶頸通常在文件IO而不是解碼本身。如果真有極致性能需求可以把解碼函數(shù)內(nèi)聯(lián)或者用查表法預(yù)計(jì)算首字節(jié)對(duì)應(yīng)的長(zhǎng)度但大多數(shù)場(chǎng)景沒必要。6.4 幾個(gè)實(shí)用的擴(kuò)展方向這套代碼跑通之后我又基于它做了幾個(gè)擴(kuò)展。一個(gè)是utf8_next返回下一個(gè)字符的指針方便寫遍歷循環(huán)另一個(gè)是utf8_to_upper只對(duì)ASCII部分做大寫轉(zhuǎn)換因?yàn)閁nicode的大小寫轉(zhuǎn)換規(guī)則很復(fù)雜涉及語言環(huán)境不適合簡(jiǎn)單實(shí)現(xiàn)。這些擴(kuò)展都是按需添加的核心的編解碼和工具函數(shù)保持穩(wěn)定。提示如果你的項(xiàng)目需要處理Unicode的大小寫轉(zhuǎn)換、正規(guī)化、排序等復(fù)雜操作那還是建議用成熟的庫。手寫實(shí)現(xiàn)適合的是識(shí)別字符邊界、計(jì)數(shù)、截?cái)?、?yàn)證這類基礎(chǔ)需求不要試圖用幾百行代碼去覆蓋整個(gè)Unicode標(biāo)準(zhǔn)。7. 關(guān)于零依賴實(shí)現(xiàn)的一點(diǎn)個(gè)人體會(huì)這套UTF-8工具函數(shù)從開始寫到測(cè)試通過前后花了大概一個(gè)下午。真正寫代碼的時(shí)間不多大部分時(shí)間花在調(diào)試那幾個(gè)坑上——有符號(hào)char、BOM頭、過長(zhǎng)編碼校驗(yàn)。這些問題的共同點(diǎn)是它們不會(huì)在正常輸入下暴露只有遇到邊界情況或者異常數(shù)據(jù)才會(huì)冒出來。我現(xiàn)在回頭看覺得最有價(jià)值的不是那幾百行代碼本身而是對(duì)UTF-8編碼規(guī)則的理解。以前看到E4 B8 AD這樣的字節(jié)序列是一頭霧水現(xiàn)在能一眼看出這是一個(gè)3字節(jié)的漢字能手動(dòng)算出它的碼點(diǎn)。這種看穿字節(jié)的能力在處理任何涉及文本的底層問題時(shí)都用得上。另外一點(diǎn)體會(huì)是零依賴不等于重復(fù)造輪子。當(dāng)項(xiàng)目約束允許用庫的時(shí)候用庫是更明智的選擇因?yàn)槌墒斓膸旖?jīng)過了大量測(cè)試覆蓋了各種邊界情況。但當(dāng)你被迫要自己實(shí)現(xiàn)時(shí)理解原理能讓你寫出正確且健壯的代碼而不是抄一段看起來能跑、實(shí)際到處是坑的代碼。這兩者之間的差別往往就體現(xiàn)在那幾個(gè)不起眼的校驗(yàn)和邊界判斷上。如果你也在做類似的事情我的建議是先把模板表背熟然后老老實(shí)實(shí)把每個(gè)邊界用例都測(cè)一遍尤其是非法輸入。編解碼的正確性靠的不是聰明而是對(duì)每一個(gè)字節(jié)的較真。