从字符编码到编程语言内存模型设计权衡
| 软件工程 | 12 min read
编码问题的本质
当我们提起字符编码,实际上是在探讨一个问题:一维线性延展的文字和符号如何在计算机中存储与表示。换句话说,字符编码解决了如何将数以万计的文字符号映射为计算机可以表示的二进制序列。
先抛出两个最容易混淆的概念:
-
字符集:一套命名规范,我们平时谈论的ASCII、GBK、Unicode均是字符集。通过key,value的形式实现字符到编码的映射。比如Unicode下'李'的编码格式是
U+674e,'a'对应的编码格式是U+0061,'A'对应U+0041,从这里可以看出GBK,Unicode等字符集都是兼容ASCII的。最初的字符集即ASCII,后出现了 拓展ASCII,大陆地区的GB2312, 台湾地区的Big5等。直到现在使用的Unicode,对世界上几乎所有的文字做了统一编码。 -
字符编码:字符集如何在计算机中存储的实现方案,ASCII码的实现即每个字符均使用一个字节表示。而我们常说的UTF-8、UTF-16、UTF-32均指的是不同的Unicode实际存储方案,不同的实现方案使得同一套Unicode编码在实际内存中的表示也不同。
最早的ASCII码只对制表符、英文字母、数字以及常见的符号做了映射。一个字符占用8bits,首位置0,实际存储7位,也就是最多容纳128个字符。后西欧地区将首位置1在ASCII码的基础上对其进行了拓展,在兼容ASCII码的基础上拓展了128个当地的文字字符。中国大陆地区比较有代表性的是GB2312、GBK等字符集格式。Unicode则实现了地球上各地区不同语言、文字的统一编码。
Unicode码点范围从U+0000 到 U+10FFFF共分了17个平面(Plane),常见的英文、中文、日语等都统一放到了0号BMP平 面。其他例如生僻字、emoji等放到了其他平面。
UTF-x
前面提到,Unicode只实现了字符到码点的k,v映射,而UTF-x是将码点转换为计算机可存储 / 传输的字节序列的规则。常见的规则包括UTF-8、UTF-16、UTF-32等。
UTF-8
utf-8采用变长编码,将所有的0~0x10FFFF动态编码成1-4个字节。具体来说,utf8动态规定了二进制头为字节数标志位,例如最高位为0,剩余7位编码表示该字符占一个字节,最高位110表示该符号占两字节,剩余位做填充。 常见中文字符占3字节。
以此类推...
在编辑器中编写代码时,我们最常用的实际是编辑器默认指定的utf-8。我们每一次按下Ctrl+S实际上在磁盘上保存的是代码的utf-8字节序列。
对于C语言来说,char类型占1个字节。前面提到Unicode的编码方式均兼容ASCII,gcc不会识别.c源文件使用的是utf-8还是GBK编辑的。gcc在编译时会将该字符编码下的程序字节序列完整读到内存中并执行输出,只需要保证终端的编码方式和编辑器的编码格式对应即可。
可是,如果使用char来存储类似字符这样在utf-8下占三个字节的字符,比如'李'这个字符实际上我们尝试使用 char 类型来存 \xe6 \x9d \x8e三个字节的变量,则会报错溢出:
lavance@lavanceeee:~/c_practice$ gcc demo01.cdemo01.c: In function ‘main’:demo01.c:5:18: warning: multi-character character constant [-Wmultichar] 5 | char c = '李'; | ^~~~demo01.c:5:18: warning: overflow in conversion from ‘int’ to ‘char’ changes value from ‘15113614’ to ‘-114’ [-Woverflow]前面提到,C语言char类型占一个字节,而一个字节无法存储中文,那么如何在C语言中输出中文呢?这很简单, C编程课上我们知道可以使用 char[]。下面我们可以分别在Windows和Linux这两种环境下试着解读到底发生了什么以及乱码产生的原因。
在windows下终端的默认编码格式为GBK,将编辑器的编码格式也改为GBK后看如下代码:
#include<stdio.h>#include<string.h>
void print_bytes(const char *str){ for (int i=0; str[i] != '\0'; i++){ printf("0x%02x ", (unsigned char)str[i]); }}
int main() { char c_GBK[] = "李"; printf("%s\n", c_GBK); print_bytes(c_GBK);}
// 李// 0xc0 0xee结果打印出了'李'以及这个字符的正确GBK格式,从输出结果也可以知道,'李'的GBK编码 0xC0EE 在内存中占了两个字节。
当我们将编辑器的编码格式改为utf-8时,问题就出现了:
鏉?0xe6 0x9d 0x8e很简单:在内存中 c_GBK 数组保存的内容是utf-8下'李'字符的编码,即第二行显示的内容 [E6, 9D, 8E]。关键在于,windows下终端默认使用GBK来进行解码,而GBK规定了汉字使用两个字节,错误地将前两个字节识别为了汉字'鏉',最后一个字节无法找到对应的字符使用?占位。
在Linux下终端默认使用utf-8作编解码,输出和显示均为'李'的正确utf-8编码,且可以知道BMP平面内的中文字符在utf-8下占3个字节。
lavance@lavanceeee:~/c_practice$ gcc demo01.c -o ch_display_linuxlavance@lavanceeee:~/c_practice$ ./ch_display_linux李0xe6 0x9d 0x8eUTF-16
JavaScript、Java等不直接挂载编辑器保存后的字节序列文件(大多数情况下的utf-8)。而是会先将源码反序列为Unicode再使用UTF-16编码并进行后续程序的挂载和内存分配。utf-16使用2字节编码BMP平面内的常见字符,所以各种编程语言一般也将字符基本类型定为两个字节的类型。比如C/C++中的wchar_t、Java的char类型等。
选utf-16还是utf-8更取决于是'底层字符处理'还是'字符存储/传输'的实际场景,在文本编辑、网络 传输场景中选择utf-8这一边长编码形式,主要考虑的是如何节省空间。而在涉及高频率的字符处理场景,比如编程语言对字符串的截取,遍历,查找等的实现更偏向考虑效率问题,utf-16对于BMP平面使用2字节定长编码就更有优势。
比如现在要定位某字符串第五个字符是什么,在utf-8编码下,需要先找到前四个字符,而每一个字符均是边长存储的,意味着需要判断每字节的高位才能确定这是多少长度的字符,。使用utf-16则容易很多,第五个字符的开始地址即起始地址偏移 5*2=10 个字节。
JavaScript中一个字符占用一个单元(两字节)。非BMP平面内的emoji,生僻字则占用更多,对于这些超出0xFFFF的特殊字符来说,UTF-16使用了多个16位码元进行组合表示(代理对):
Unicode的补充区域从U+1000到U+10FFFF,大小为 0x10FFFF-0x10000+1=2^20,也就是需要20位才可以表示一个字符。为了正确表示多码元表示一个字符,同时区分单个16位单元的字符,utf-16做了如下规定:将20位分为两段,每段对应填入高代理区(0xD800-0xDBFF)和低代理区(0xDC00-0xDFFF)的低10位,这两段不表示单个字符,而是组合表示一个字符。每段的前6位为标识段,后10位存数据,这样就可以使用两个码元表示一个字符了。
举个例子:比如想得到'🤗'表情的Unicode码:
const str1 = '🤗';
console.log(str1.charCodeAt(0)); // 55358console.log(str1.charCodeAt(1)); // 5659955358转2进制为0b1101 1000 0011 1110,高代理区的高6位为0b110110,低十位取0000111110
56599转2进制为0b1101 1101 0001 0111,低代理区的高6位为0b110111,低十位取0b0100010111
拼接后再加0x10000 = 11111100100010111(0x1f917),对应U+1F917的符号'🤗'
console.log("\u{1F917}"); // 🤗UTF-32
相比于前两个,utf-32显得亲和了许多,也更加存粹:单纯的拿空间换时间来提高效率。utf-32将全部字符都使用4字节存储。在随机访问速度上理论上来说比utf-16更快,因为即使utf-16强制使用2字节表示BMP平面,那我们日常也会处理大量的emoji表情,而单码元和多码元的判断无疑是需要时间开销的。 utf-32使用绝对固定长度就完全避免了这些判断开销。但是一般来讲,4个字节表示一个字符还是太奢侈了,不然也不会出现utf-8,utf-16了对吧:)
结语
字符编码的问题一直困扰了我很久,对于编码问题的知识也是碎片化的只言片语泛泛而谈,索性花了点时间系统性梳理了一下。本文仅从字符编码以及Unicode的各种实现细节在编程语言中的应用和联系这两个角度做了梳理。实际上字符编码涉及字体格式、显示、系统内核API等这些更加专业和深层次的 东西,本文仅从最宏观的视角出发对字符编码做了梳理。
内容将持续修正与更新...