什么是字符集与编码?

用一个简单的例子理解字符集与编码

我们知道在计算机中只能存储二进制数据,但是我们人类只能认识文字,那么怎么才能把我们人类所认识的文字存储到计算机里呢?

想要存储文字那肯定需要建立文字(字符)与二进制数据的映射关系,所以我们需要思考两个问题

  1. 需要把哪些文字(字符)映射成二进制数据,也就是界定清楚字符范围
  2. 怎么映射,怎么能将一个文字(字符)映射成一个二进制的数据(编码),然后读取的时候还需要将二进制的数据还原为文字(解码)

比如我们定义了一个名字为mengxin code字符集,里面包含了下面6个字符

a,b,c,d,e,f

同时我们还定义了一个编码方案

'a' -> 00000001
'b' -> 00000010
'c' -> 00000011
'd' -> 00000100
'e' -> 00000101
'f' -> 00000110

假如说,我们有几个字符串,编码后的样子如下

'ad'   --> 0000000100000100
'adc'  --> 000000010000010000000011

再看什么是字符集,什么是编码方案(规则),什么是编码呢?

  • 字符集
    • 字符集描述了字符的范围,例如mengxin code就是我们一个字符集。
  • 编码方案(规则)
    • 把字符"a"转换成二进制的 00000001,这个字符与二进制的映射关系就是编码方案
  • 编码
    • 根据维基百科中的解释: “编码是信息从一种形式或格式转换为另一种形式的过程;解码则是编码的逆过程”。
    • 按照编码规范把字符"a"转换为二进制的00000001,这个将字符映射为二进制的过程就叫做编码。

ASCII码

ASCII(American Standard Code for Inofrmation Interchange, 美国信息交换标准码)
ASCII占用1个字节(byte),是基于拉丁字母的一套编码,当时设计的一个字符占用1个字节(byte),也就是8比特(bit)。
由于ASCII只有8比特,只能有256(2^8)种表示方式,所以ASCII编码只能用来表示一些数字,字母,以及一些符号。
虽然一个字节能表示256种状态,但是ASCII一共规定了128个字符的编码,只使用了一个字节的后7位,字节的第一位统一规定为0.

下面是完整的ASCII编码规则
在这里插入图片描述

Unicode

当时漂亮国设计ASCII的时候,只考虑了自己,没考虑别的国家,可是其他国家也需要电脑怎么办呢,于是很多国家都有了自己的字符集以及编码方案。
在这里插入图片描述

中国制定了GB 2312用来把中文编进去,但是GB 2312只包含了6000多个汉字,还有很多没有包含在其中,所以,后面又出现了GBKGB 18030

类似的,日文、韩文等其它国家也有这个问题,这样各个国家只管自己的话,那肯定是不行的,编码解码的过程肯定需要同一套规则,否则肯定没法正常解析,于是就需要将字符集、编码规则进行统一,所以Unicode就诞生了。


那么Unicode到底是什么呢?

比如"萌新是个菜鸡" 这句话,用Unicode的另一种表现形式表示,是下面这个样子

\u840c\u65b0\u662f\u4e2a\u83dc\u9e21

可以看到每个字都变成了\uxxxx的样子,\u表示Unicode的意思,也有可能用"U+"表示,每一个这个号码也被称为代码点(code point)。

说到代码点,还有一个名词叫做码元(code unit),举个例子,一个字符的代码点转换为二进制后有n个字节(n*8个二进制数),那么码元就是n个。

Unicode给这个世界上几乎每一个字符都分配了一个号码,当然了这个号码是由16进制组成的,并且Unicode一直在持续的更新中,到今天为止(2021.12.19),最新的版本为14.0.

下面列举了几个Unicode号码与字符的对应关系

Unicode字符
0041A
0042B
0043C
0061a
0062b
0063c

完整的Unicode字符可以到这个网站去查看。

http://www.unicode.org/charts/

Unicode能直接存储吗?

假如说我们有一个字符串是hello,我们希望把这个字符串存储到磁盘上,我们来看一下这个过程。
首先先将字符串转成UNICODE的号码

hello -> \u0068\u0065\u006c\u006c\u006f

Unicode给每个字符分配了一个唯一的号码,将字符转换为Unicode号码的过程有的人称之为编码,也有的人认为这不是编码,将字符转成二进制的形式才能叫做编码。

本身这种问题就容易有争议,总之有自己的理解就好


然后我们应该如何把它存储到磁盘中呢,毕竟UNICODEASCII不一样,ASCII规定了字符与二进制的对应关系,而UNICODE可没有,UNICODE只规定了一个字符一个号码之间的映射关系。

我们可以直接把这个号码转成二进制存储到磁盘中吗?

我们来思考一下,我们大部分常见的Unicode都是由4个号码组成,但是Unicode目前最多可能由6个号码组成,每个号码都是16进制的,6个16进制位转成2进制最大需要3个字节。

我们还要考虑存储了以后,读取的时候怎么能分辨出哪些字节是同一个字符的,而这也是Unicode没有规定的,所以如果直接把Unicode转换成二进制存储的话只能按最大3个字节存储,才能分辨出哪些字节属于同一个字符。

字符   Unicode   二进制
h  ->  0068  ->  00000000 00000000 01101000
e  ->  0065  ->  00000000 00000000 01100101
l  ->  006c  ->  00000000 00000000 01101100
l  ->  006c  ->  00000000 00000000 01101100
o  ->  006f  ->  00000000 00000000 01101111

我们再来思考一个问题,一个英文字符用3个字节存储,是不是太浪费了,这明明就最后一位有意义,只是为了方便读取,才用0填充前面的没有意义的位,那么我们能不能做一个标记,标记出一个字符到底占用了多少空间,比如我们可以将英文字符存储为1个字节,并且还知道,这个字符就占用了一个字节。

所以直接将Unicode的号码直接存储不太合适,于是出现了很多将Unicode转换为二进制的编码方案,规定了如何存储Unicode的号码,例如:UTF-8,UTF-16,UTF-32,UCS-2 等等



UTF-8(8-bit Unicode Transformation Format)

UTF-8最大的一个特点,就是它是一种变长的编码方式。它可以使用1-4个字节表示一个字符,根据不同的字符而变化字节的长度。

UTF-8是怎么将Unicode编码为二进制的?

Unicode对应二进制使用UTF8编码后(二进制)
U+0000 to U+007F00000000 00000000 to 00000000 011111110xxxxxxx
U+0080 to U+07FF00000000 10000000 to 00000111 11111111110xxxxx 10xxxxxx
U+0800 to U+FFFF00001000 00000000 to 11111111 111111111110xxxx 10xxxxxx 10xxxxxx
U+010000 to U+10FFFF00000001 00000000 00000000 to 00010000 11111111 1111111111110xxx 10xxxxxx 10xxxxxx 10xxxxxx

有一个地方需要注意,实际上Unicode在U+D800-U+DFFF范围内中不存在任何字符


可以看到如果一个UTF-8编码后的二进制,第一个字节的第一位是0,那么这个字节单独就是一个字符。
如果第一位是1,则有多少个连续的1,就表示当前字符占用多少个字节,并且后面都是以10开头。这样就完美解决了,一个英文字符都要占用3个字节的问题,虽然会导致那些6个号码的Unicode转成二进制后占用4字节,但那毕竟是少数,常用的依然是4位的号码。

下面我们来看一个例子,理解一下UTF-8是如何存储的

假如一个文件中有两行,第一行是abc,第二行是萌新

abc
萌新

使用UTF-8的编码过程如下,由于Linux每行都有一个换行符/结束符,还应该把结束符加上

字符     Unicode    二进制                            使用UTF8编码后
a   -->  0061  -->  00000000 00000000 01100001  -->  01100001
b   -->  0062  -->  00000000 00000000 01100010  -->  01100010
c   -->  0063  -->  00000000 00000000 01100011  -->  01100011

LF  -->  000a  -->  00000000 00000000 00001010  -->  00001010

萌  -->  840c  -->  00000000 10000100 00001100  -->   11101000 10010000 10001100
新  -->  65b0  -->  00000000 01100101 10110000  -->   11100110 10010110 10110000

LF  -->  000a  -->  00000000 00000000 00001010  -->   00001010

可能有的同学会有疑惑,为什么明明没有换行,却有一个换行符,可以根据Unicode的Character Code Charts
中发现,Unicode不管是换行还是结束,LF、NL、EOL在Unicode中其实都是一个东西,总之,你这一行结束了,就给你加一个000A
在这里插入图片描述

根据上面我们计算的结果看,一个文件使用UTF-8编码后,二进制应该是下面这样子的

01100001 01100010 01100011 00001010 11101000 10010000 10001100 11100110 10010110 10110000

下面我们进行验证一下是不是这样,首先创建一个文件,并设置文件编码为utf8

vim utf8.txt

# vim命令模式下,将保存文件的编码设置为utf-8
:set filecoding=utf-8

然后在文件中写上我们需要验证的两行字符串

abc
萌新

使用xxd命令查看文件的二进制数据,发现确实和我们预期相符。
在这里插入图片描述

MySQL中需要注意的问题

在MySQL中,实际上只有utf8mb4utf8mb3。至于utf8,到目前为止(2021.12.19),还是utf8mb3别名,以后可能会是utf8mb4的别名。
MySQL中的utf8mb4才是我们正常理解的编码后占用1-4字节的UTF-8。

在这里插入图片描述

我们来看看utf8mb3utf8m4的区别

  • utf8mb3
    • 仅支持BMP字符
    • 每个字符最大占用3个字节
  • utf8mb4
    • 支持BMP和补充字符
    • 每个字节最多可以占用4个字节

对比utf8mb3utf8mb4存储的值
● 对于BMP字符,utf8mb4utf8mb3存储后的值是一样的: 相同的二进制,相同的长度。
● 对于补充字符,utf8mb4需要四个字节来存储它,而utf8mb3根本不能存储该字符。

所以我们将utf8mb3转换为utf8mb4的时候,不需要担心字符不兼容的问题。

不过还是建议我们将数据库字符集设置为utf8mb4,因为并不会比utf8mb3多浪费空间,并且万一有一天我们需要存储补充字符,也不用修改数据库的编码了。


什么是BMP?

刚刚我们提到了BMP,那么到底什么是BMP呢?
Unicode字符的范围是(0x0000至0x10FFFF),目前将Unicode分为了17个组,每组称为平面(Plane),而每个平面拥有65536(即2^16)个码位,共1114112个,可能理解起来有点抽象,我们看看下面的表格就明白了。

平面Unicode代码点范围中文名称英文名称
0号平面U+0000 to U+FFFF基本多语言平面Basic Multilingual Plane,简称 BMP
1号平面U+10000 to U+1FFFF第一辅助平面或多文种补充平面Supplementary Multilingual Plane,简称 SMP
2号平面U+20000 to U+2FFFF第二辅助平面或表意文字补充平面Supplementary Ideographic Plane,简称 SIP
3号平面U+30000 to U+3FFFF第三表意文字补充平面Tertiary Ideographic Plane,简称 TIP
4号平面 - 13号平面U+40000 to U+DFFFF(尚未使用)
14号平面U+E0000 to U+EFFFF第十四辅助平面或特别用途补充平面Supplementary Special-purpose Plane,简称 SSP
15号平面U+F0000 to U+FFFFF保留为私人使用区(A区)Private Use Area-A,简称 PUA-A
16号平面U+100000 to U+10FFFF保留为私人使用区(B区)Private Use Area-B,简称 PUA-B

UTF-16(16-bit Unicode Transformation Format)

UTF-16也是一个变长的编码,可能占用2字节,也可能占用4字节,我们前面已经了解了,UTF-8是怎么将Unicode映射为二进制的,我们再来了解一下UTF-16是怎么将Unicode映射为二进制的。


UTF-16是怎么将Unicode编码为二进制的?

如果Unicode的范围是U+0000至U+FFFF,那么UTF-16转换为二进制与Unicode直接转换成的二进制是一样的。

如果Unicode的范围是U+10000到U+10FFFF,那么就比较特殊了,具体的操作为

  1. 将这个Unicode减去0x10000,得到的值为20比特长的0...0xFFFF.
  2. 高10位的比特的值(值的范围为0...0x3FF) 被加上0xd800得到第一个码元
  3. 低10位的比特的值(指的范围也是0xxx0x3FF) 加上0xdc00得到第二个码元

有一个地方需要注意,实际上Unicode在U+D800-U+DFFF范围内中不存在任何字符


例1

这个例子和上面那个UTF-8的例子一样

假如一个文件中有两行,第一行是abc,第二行是萌新

abc
萌新

使用UTF-16的编码过程如下,由于Linux每行都有一个换行符/结束符,还应该把结束符加上

字符     Unicode    二进制                    使用UTF16编码后
a   -->  0061  -->  00000000 01100001  -->  00000000 01100001
b   -->  0062  -->  00000000 01100010  -->  00000000 01100010
c   -->  0063  -->  00000000 01100011  -->  00000000 01100011

LF  -->  000a  -->  00000000 00001010  -->  00000000 00001010

萌  -->  840c  -->  10000100 00001100  -->   10000100 00001100
新  -->  65b0  -->  01100101 10110000  -->   01100101 10110000

LF  -->  000a  -->  00000000 00001010  -->   00000000 00001010

根据上面我们计算的结果看,使用UTF-16编码后,二进制应该是下面这样子的

00000000 01100001 00000000 01100010 00000000 01100011 00000000 00001010 10000100 00001100 01100101 10110000 00000000 00001010

下面我们进行验证一下是不是这样,首先创建一个文件,并设置文件编码为utf16

vim utf16.txt

# vim命令模式下,将保存文件的编码设置为utf-8
:set filecoding=utf-16

然后在文件中写上我们需要验证的两行字符串

abc
萌新

使用xxd命令查看文件的二进制数据,发现确实和我们预期相符。
在这里插入图片描述

例2

以U+10437(𐐷)为例

  1. 0x10437减去0x10000,结果为0x00437,二进制为0000 0000 0100 0011 0111
  2. 使用二进制分割它的上10位值和下10位值,分割后为: 0000 0000 0100 0011 0111
  3. 添加0xd800到上值,形成高位:0xd800 + 0x0001 = 0xd801二进制的值为1101 1000 0000 0001
  4. 添加0xdc00到下值,形成低位:0xdc00 + 0x0037 = 0xdc37二进制的值为1101 1100 0011 0111
字符     Unicode    二进制                        使用UTF16编码后
𐐷  -->  10437 -->  0001 0000 0100 0011 0111 --> 11011000 00000001 11011100 00110111
LF  --> 000a  -->  00000000 00001010        --> 00000000 00001010

通过计算可以得知,UTF16编码后的值为

11011000 00000001 11011100 00110111 00000000 00001010

下面我们进行验证一下是不是这样,首先创建一个文件,并设置文件编码为utf16

vim utf16.txt2

# vim命令模式下,将保存文件的编码设置为utf-8
:set filecoding=utf-16

然后在文件中写上我们需要验证的字符

𐐷

使用xxd命令查看文件的二进制数据,发现确实和我们预期相符。
在这里插入图片描述

Java中需要注意的地方

Java 8及之前String使用char数组实现的,而String的内码是使用的UTF-16,一个char是2个字节,所以当出现Unicode编号为U+10000到U+10FFFF之间的字符时,会占用2个char空间。

这就可能会出现一个问题,如果我们想统计一个字符串究竟有多长,直接使用length方法可能结果和我们想要的不一样,所以需要统计Unicode的代码点(code point)数量。

在这里插入图片描述

为什么Java、C#、JavaScript等字符串的内码都是采用了UTF-16作为编码?

这是由于历史原因,因为早期Unicode用双字节就能完全覆盖了,所以早期UTF-16可以作为定长编码使用,编程语言使用定长的肯定是最快的,但是后来Unicode收录的字符很快就超过了65536个,所以如果还想使用定长编码似乎只能采取UTF-32这种编码方式了,但是UTF-32最大的问题是即使英文字母也要用4个字节来存储,空间浪费太大了。
所以先UTF-8这种变长编码方式开始流行起来了,Go和Rust都是采用了UTF-8作为内码。

使用定长编码的好处是可以快速定位字符,对于类似string.charAt(index)方法有着比较好的支持,不过现在UTF-16也不是定长的了,UTF-8和UTF-16,就需要从头开始一个字符一个字符的解析才行,会慢一点。
不过相对于查询定位,顺序输出的情况更多。

Java内码使用UTF-16存储英文字符会浪费内存空间吗

Java9以前的String实现是内部使用了一个char数组来存储,每个字符最少使用两个字节,但是很多String内部仅包含英文字符,这种字符也占用了2个字节,相当于大多数的String对象内部的数组有一半的空间未使用。
不过Java 9已经修复了这个问题,Java 9内部存储是用了byte数组,只有在存储latin1无法表示的内容的时候,String才会以双字节存储,大大降低了内存的浪费。


UTF-32(32-bit Unicode Transformation Format)

UTF-32是32位的Unicode编码规则,UTF-32将每个Unicode号码转换为占用4个字节的二进制,占用空间比较多。

但是这种方式也有优点,由于UTF-32是定长的,所以可以直接根据Unicode码位进行索引,可以在常数时间内定位字符串里的第N个字符,因为第N个字符是用第4*N的字节开始的。其它可以变长的编码就必须要循环访问才能在序列中找到第N个字符了。

虽然每个字符使用固定长度的字节看起来方便,但是它不如其它Unicode编码使用的广泛。

总结

Unicode是什么?

严格来说Unicode是一个字符集,Unicode只是给所有的字符分配一个唯一的号码,但是没有规定如何转为二进制存储。

UTF-8、UTF-16、UTF-32是什么?

Unicode定义了字符与一个唯一编码的对应规则,而UTF-8、UTF-16、UTF-32是编码规则,负责将这个唯一的编号转换为二进制。

UTF-8是变长的,可能1个字节,也可能2个,3个甚至4个字节。
UTF-16也是变长的,一个字符可能占用2或者4个字节。
UTF-32是定长的,一个字符占用4字节。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值