前言
字符和字符编码其实是很常见的模块,但在我们学习计算机知识的过程中,这部分都是随便提提,好像很简单但我们又似懂非懂,直到在工作中我们经常遇到各种问题。
所以把整个字符的来龙去脉搞懂对我们有很大的帮助,这也是本篇文章编写的初衷。
问题导入
平常工作中会遇到字符相关的问题,包括但不限于以下几个方面:
- 长度问题:一个中文字符占2个还是3个字节?同样个数的颜文字或泰语,为什么会比阿拉伯字母占位多?
- 乱码问题:一个文件在我电脑打开ok,怎么换一台电脑就乱码了?
- 使用问题:为什么UTF-8是使用得最广泛的?
本文思维导图如下:
编码的背景
我们都知道计算机只认识数字0和1,分别代表电路的关和开。 任何其他字符都要转成0和1才能被计算机识别,这就需要编码。
在讲解具体的编码类型和编码实现前,我们先来看一组基础的名词介绍。
名词解释
位:bit,二进制位,计算机信息表示的最小单位字节:一连串比特组成的位串,一般来说1byte = 8bit编码:信息从一种形式转换为另一种形式的过程,如字符、音频转成二进制解码:编码的逆过程字符集:charset,字符的集合。如ASCII字符集、ISO 8859系列字符集(ISO 8859-1~8859-16)、GB系列字符集(GB2312、GBK、GB18030)、BIG5字符集、Unicode字符集字符编码:编码字符集和实际存储字符的转换关系。如何将文字编号存储到内存中
字符编码关系图
从我们最熟悉的ASCII字符集出发,不同国家和机构在其基础上做了扩展衍生出了很多不同的字符集,接下来将会逐一讲解。
常见的编码类型
ASCII码
ASCII(发音: /ˈæski/ ASS-kee[1],American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母(也成罗马字母)的一套电脑编码系统。ASCII虽然占用一个字节(8bit,256种)表示,然而只规定了128位字符(占字节后7位)的编码,最前面的一位定为0。
最高位置为1的字节,即128-255的部分被不同的国家赋予了不一样的含义,所以不同字符集解析出来的信息是不一样的。 如欧洲对于ASCII的扩展形成了ISO 8859-1 ~ ISO 8859-16 字符集。
扩展的ASCII码:www.ascii-code.com/
缺点:
ASCII这种编码只适用于显示26个拉丁字母、阿拉伯数字和键盘上的英式标点符号,是单字节的,世界上还有很多种语言,无法进行表示,于是有了unicode码,它就像一本字典,包含了所有语言的编码。
Unicode是什么?有什么优缺点?
同样的信息,不同的字符集解析结果是不一样的,为了能让信息解析一致,需要有一种编码将所有字符纳入其中,给每一个符号定义一个唯一的编码,于是unicode出现了。Unicode的学名是"Universal Multiple-Octet Coded Character Set",即通用多八位编码字符集,简称为UCS。
关于Unicode平面
目前的Unicode字符分为17组编排,每组称为平面(Plane),而每平面拥有65536(即2^16)个code point(码位)。unicode 已经给 0 ~ 1114111 (十六进制为0x10FFFF)的字符都定义了一个唯一的codePoint。例如©字符被命名为“copyright sign”并且有一个值为U+00A9(0xA9,十进制169)的码位。摘录维基百科的码位范围归类图如下:
关于Unicode辅助平面
辅助平面:除了0号平面的其他16个平面都是辅助平面,可从上图中查看。
码位范围:0x10000 ~ 0x10FFFF。
0号平面内的字符用2个字节(1个UTF-16编码,UTF-16后文会有说明)的码位即可表示,辅助平面的字符需要用到4个字节(1对UTF-16编码,又称代理对),前两个字节称为高位代理,后两个称为低位代理,具体的转换过程为:
高位代理:H = Math.floor((c - 0x10000) / 0x400) + 0xD800
低位代理:L = (c - 0x10000) % 0x400 + 0xDC00
如 code point为 U+2A6A5的字符 会转为 0xD869 0xDEA5。这也是0号平面内 U+D800 ~ U+DFFF 间的字符要作为保留字符使用的原因。
UCS分类
| UCS编码 | 码位范围 | 码位数量 |
|---|---|---|
| UCS-2 | U+0000 ~ U+FFFF | 2^16=65536 |
| UCS-4 | U+00000000 ~ U+7FFFFFFF | 2^31=2147483648 |
unicode —— 大端和小端
- 大小端是指数据在内存中的字节存放顺序,统称为字节序。
- 大端:高字节存放到内存的低地址中
- 小端:高字节存放到内存的高地址中 十六进制数值 0x12345678 的小端字节序和大端字节序的写法如下:
至于为什么会有大端和小端之分呢?对于 16 位或者 32 位的处理器,由于寄存器宽度大于一个字节,那么必然存在着一个如何将多个字节排放的问题,因为不同操作系统读取多字节的顺序不一样,x86和一般的OS(如windows,Linux)使用的是小端模式。Mac OS是大端模式。
(如果一个文本文件的头两个字节是FE FF,就表示该文件采用大端方式;如果头两个字节是FF FE,就表示该文件采用小端方式。) 大小端的作用主要在用于数值计算或比较大小时,具体可见字节序探析:大端和小端的比较
unicode的缺点
unicode虽然解决了不同字符与二进制的对应关系,但这只是对于单个字符的,它没有定义该如何去拆分一段文字,不是所有的符号都只占1个字节,那个当一个符号占有n(2、3、4)个及以上的字节数时,计算机该怎么知道这n个字节表示1个符号,而不是n个字节表示n个符号呢?如果说unicode规定每个符号都占用4个字节,那么对于简单的字母来说,前面都将被填为0,这将会是一个极大的内存浪费。unicode因此无法普及。
UTF-8是什么?有什么优缺点?
针对unicode出现的问题,出现了UTF-8,它不是新的字符集,只是unicode的一种实现方式。
UTF是”UCS Transformation Format“ 的缩写
UTF-8的特点是可变长,根据不同的符号变化字节的长度。
UTF-8 将unicode编码成1~4个码元,每个为8位,所以叫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 | 0xxxxxxx |
| 0000 0080-0000 07FF | 110xxxxx 10xxxxxx |
| 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
| 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
根据上表可知,解析 UTF-8 编码非常简单。如果一个字节的第一位是0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节。
为什么UTF-8不需要考虑大小端问题?
对于 UTF-8,最小单位是1个字节,因此它是面向字节的。该算法一次读或写一个字节。字节在所有机器上都以相同的方式表示。 对于 UTF-16,最小单位是2个字节,对于 UTF-32,最小单位是4个字节。该算法一次读或写2字节或4字节。在 big-endian 和 little-endian 机器上,每个单词中字节的顺序是不同的。
UTF-16和UTF-32
- UFT-16 使用 2 个或者 4 个字节(用于表示辅助平面里的字符)来存储。Unicode字符被编码成1至2个码元,每个码元为16位。
- UTF-32 是固定长度的编码,始终占用 4 个字节,足以容纳所有的 Unicode 字符,所以直接存储 Unicode 编号即可,不需要任何编码转换。浪费了空间,提高了效率。UTF-32 与 UTF-16 一样有大端和小端,编码前会放置 U+0000FEFF 或 U+FFFE0000 以区分。
| Unicode | 字符 | UTF-8编码 | UTF-16编码 | UTF-16 BE编码 | UTF-16 LE编码 | UTF-32编码 | UTF-32 BE编码 | UTF-32 LE编码 |
|---|---|---|---|---|---|---|---|---|
| \u0041 | A | 0x41 | 0x0041 | 0x00 0x41 | 0x41 0x00 | 0x00000041 | 0x00 0x00 0x00 0x41 | 0x41 0x00 0x00 0x00 |
| \u4f9b | 供 | 0xE4 0xBE 0x9B | 0x4F9B | 0x4F 0x9B | 0x9B 0x4F | 0x00004F9B | 0x00 0x00 0x4F 0x9B | 0x9B 0x4F 0x00 0x00 |
| \u5e94 | 应 | 0xE5 0xBA 0x94 | 0x5E94 | 0x5E 0x94 | 0x94 0x5E | 0x00005E94 | 0x00 0x00 0x5E 0x94 | 0x94 0x5E 0x00 0x00 |
| \u94FE | 链 | 0xE9 0x93 0xBE | 0x94FE | 0x94 0xFE | 0xFE 0x94 | 0x000094FE | 0x00 0x00 0x94 0xFE | 0xFE 0x94 0x00 0x00 |
| \u524d | 前 | 0xE5 0x89 0x8D | 0x524D | 0x52 0x4D | 0x4D 0x52 | 0x0000524D | 0x00 0x00 0x52 0x4D | 0x4D 0x52 0x00 0x00 |
| \uA8BA | จบ | 0xE0 0xB8 0x88 0xE0 0xB8 0x9A | 0x080E1A0E | 0x08 0x0E 0x1A 0x0E | 0x0E 0x1A 0x0E 0x08 | 0x0000080E00001A0E | 0x00 0x00 0x08 0x0E 0x00 0x00 0x1A 0x0E | 0x0E 0x1A 0x00 0x00 0x0E 0x08 0000 0000 |
UCS-2 和 UTF-16的关系
上文说到,UCS-2是用2个字节来表示编码值,只能表示U+0000-U+FFFF之间的符号。例如,在UCS-2和UTF-16中,BMP中的字符U+00A9 copyright sign(©)都被编码为0x00A9。但对于非BMP的字符,例如😂是由\uD83D, \uDE02组成,UTF-16会把它们解释为一个字符,但UCS-2会把它们当成两个字符(UCS-2的时代,U+D800到U+DFFF内的值被占用)。
| 对比 | UTF-8 | UTF-16 | UTF-32 | UCS-2 | UCS-4 |
|---|---|---|---|---|---|
| 编码空间 | 0-10FFFF | 0-10FFFF | 0-10FFFF | 0-FFFF | 0-7FFFFFFF |
| 最少编码字节数 | 1 | 2 | 4 | 2 | 4 |
| 最多编码字节数 | 4 | 4 | 4 | 2 | 4 |
| 是否有字节序 | 否 | 是 | 是 | 是 | 是 |
如何表示简体中文?GB2312是什么?
GB/T 2312是中华人民共和国国家标准简体中文字符集,全称《信息交换用汉字编码字符集·基本集》,通常简称GB(“国标”汉语拼音首字母)。共收录6763个汉字,其中一级汉字3755个,二级汉字3008个;同时收录了包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母在内的682个字符。(GB2312编码表)
GB/T 2312的出现,基本满足了汉字的计算机处理需要,它所收录的汉字已经覆盖中国大陆99.75%的使用频率。但对于人名、古汉语等方面出现的罕用字和繁体字,GB/T 2312不能处理,因此后来GBK及GB 18030汉字字符集相继出现以解决这些问题。
GBK由于GB2312只有6763个汉字,但汉语太多了,在保证和GBK2312以及ASCII码不冲突的前提下又进行了扩充,GBK的k是扩展的意思。还是占2个字节,GBK编码可以表示20902个汉字,这些汉字包含了繁体字。(GBK编码表)
GB18130 随着时代发展,GBK两万字也无法满足需求,还有很多的汉字需要进行编码,这时2个字节(最多65536)已经不够,需要用4个字节。目前,GBK18130含有7万多汉字,包括少数民族文字。(GB18030-2005文档、GB18030-2000文档)
国标码位置布局图如下:
ANSI是什么?
ANSI不是一种特定的字符编码,而是针对不同系统设置的默认编码方式。对于英文文件是ASCII编码,对于简体中文文件是GBK编码,繁体中文版会采用 Big5 码。
Javascript中的字符和编码
JS中的字符串取length是怎么做的?
上图的这种情况我们基本都遇到过,但是我们很少去探索为什么会出现这样的现象。探索的第一步肯定是google JS的length到底是怎么工作的,从
mdn中可以看到:
该属性返回字符串中字符编码单元的数量。JavaScript 使用 UTF-16 编码,该编码使用一个 16 比特的编码单元来表示大部分常见的字符,使用两个代码单元表示不常用的字符。因此 length 返回值可能与字符串中实际的字符数量不相同。空字符串的
length为 0。静态属性 String.length 返回 1。
对于大多数字符来说,JS使用了两个字节(作为一个单元)来保存,但是我们知道有些字符用UTF-16表示会需要4个字节,那么该字符就会占用2个单元,造成以上的数量获取和直观感觉不一样的问题。
下面通过一些转换测试来看一下
1. 使用split分割查看每个字符
2. charCodeAt 和 codePointAt (ECMAScript 6)
str.charCodeAt(index):
charCodeAt()方法返回0到65535之间的整数,表示给定索引处的 UTF-16 代码单元。UTF-16 编码单元匹配能用一个 UTF-16 编码单元表示的 Unicode 码点。如果 Unicode 码点不能用一个 UTF-16 编码单元表示(因为它的值大于0xFFFF),则所返回的编码单元会是这个码点代理对的第一个编码单元) 。如果你想要整个码点的值,使用codePointAt()。
形参index:一个大于等于
0,小于字符串长度的整数。如果不是一个数值,则默认为0。如果index超出范围,charCodeAt()返回NaN。
str.codePointAt(pos)
codePointAt()返回值是在字符串中的给定索引的编码单元体现的数字,如果在索引处没找到元素则返回undefined。
API比较:
| API | 返回值概括 | 字符处于基本平面时的返回值 | 字符处于辅助平面时的返回值 |
|---|---|---|---|
| charCodeAt | 0 到 65535 之间的整数 或 NaN | Unicode 码点 | 返回第一个编码单元对应码点 |
| codePointAt | 字符串中的给定索引的编码单元体现的数字 或 undefined | 和charCodeAt 返回一致 | 返回整个码点的值 |
3. String.prototype.normalize()
The
normalize()method returns the Unicode Normalization Form of the string.
let string1 = '\u00F1'; // ñ
let string2 = '\u006E\u0303'; // ñ
console.log(string1 === string2); // false
console.log(string1.length); // 1
console.log(string2.length); // 2
let string1 = '\u00F1'; // ñ
let string2 = '\u006E\u0303'; // ñ
string1 = string1.normalize('NFD');
string2 = string2.normalize('NFD');
console.log(string1 === string2); // true
console.log(string1.length); // 2
console.log(string2.length); // 2
4. for in 和 for of
ES6之前 Unicode 只能表示 \u0000 ~ \uFFFF 之间的字符,ES6对超出这个范围的字符进行了支持。
5. 转化成数组后,可以看到非组合字符可以正确识别字符,组合字符会拆开
6. 组合字符和零宽字符
前面我们用了🇨🇳图标来举例,将🇨🇳字符展开后得到了'C'、'N'两个字符,下面是字符的合成过程:
一家四口emoji的合成过程:
隐形的字符:
零宽字符是计算机中不可打印的字符,上面的\u200d即是一种零宽字符,在JS中可以用于反爬虫
Zero Width Joiner (ZWJ) is a Unicode character that joins two or more other characters together in sequence to create a new emoji.
常见零宽连字:
| 名称 | 用处 | 示例 |
|---|---|---|
| 零宽空格(zero-width space, ZWSP) | 用于可能需要换行处 | Unicode: U+200B; HTML: ​ |
| 零宽不连字 (zero-width non-joiner,ZWNJ) | 放在电子文本的两个字符之间,抑制本来会发生的连字,而是以这两个字符原本的字形来绘制。 | Unicode: U+200C; HTML: ‌ |
| 零宽连字(zero-width joiner,ZWJ) | 一个控制字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。 | Unicode: U+200D; HTML: ‍ |
| 左至右符号(Left-to-right mark,LRM) | 一种控制字符,用于计算机的双向文稿排版中 | Unicode: U+200E; HTML: ‎ ‎ ‎ |
| 右至左符号(Right-to-left mark,RLM) | 一种控制字符,用于计算机的双向文稿排版中。 | Unicode: U+200F; HTML: ‏ ‏ ‏ |
| 字节顺序标记(byte-order mark,BOM) | 常被用来当做标示文件是以UTF-8、UTF-16或UTF-32编码的标记。 | Unicode: U+FEFF |
为什么返回字符单元而不是字符数量
如果要知道字符数量的话,需要扫描整个字符串然后知道哪些字节组成一个字符,而且现在有组合字符,比如上面的红旗由两个字符构成。而返回字符单元的话,只需要用分配的字符单元除以每个字符单元长度即可,这样可以提高效率。
转码工具
参考资料:
一图弄懂ASCII、GB2312、GBK、GB18030编码
Why does code points between U+D800 and U+DBFF generate one-length string in ECMAScript 6?