之前突然发现自己对字符编码还是一知半解,基本上只是听说过各种编码的名字,对它们之间的特点和区别还是不甚了解。所以这段时间查阅了许多资料,对字符编码也大概有了一些整体的了解,写下这篇文章作为总结。

在Unicode之前

为了在计算机的中储存人类可以阅读的文本,必须按照一定的规范将字符映射为计算机可以储存的数值,在计算机发展的早期渐渐形成了统一的标准,在1967年ASCII编码首次作为规范标准发布。这是一套用来表示现代英文的编码约定,全称为美国信息交换标准代码。ASCII编码非常简单,只定义了128个字符,每个字符通过唯一的编号来表示,每个字符占用一个字节(8bit)的空间,因为只有128个字符(2的7次方),所以每个字符的第一位始终为0。

一个ASCII字符只有8位,最多只能表示256个字符,对于英文来说足够了,但是对于像中文这样的语言而言是远远不足的。所以在ASCII之上做了一些扩展,用两个字节来表示一个字符,这就是1981年发布的GB2312编码,为了与ASCII作区分,GB2312中每个字节的最高位都是1。这一套编码中包含了6000多个常用的简体汉字,基本满足日常使用的需求。但是不支持繁体汉字和一些生僻字,所以在后来又在GB2312上进行了扩展,这就是之后的GBK编码,全称为汉字内码扩展规范

事实上在那个年代还有很多不同的汉字编码百花齐放,而且不止是中文,世界上其他各种语言都在指定自己的标准,不同编码之间无法相互兼容,这为互联网的推广带来了很大的麻烦,统一字符编码势在必行。

Unicode

Unicode是国际标准化组织制定的一套字符编码方案,致力于统一世界上所有语言字符的编码。Unicode为每个字符分配了一个固定的数值,称为编码点(Code Point),所有的编码点组成的集合称为编码空间(Code Space)。目前Unicode的编码空间共包含0x10FFFF(十进制的1114111)个编码点,被划分为17个平面,每个平面包含0xFFFF个字符。从1991年发布的第一个版本开始,每一年都会有新的字符被编入Unicode中,目前所定义的字符集只用了不到五分之一的编码空间。

编码方式

Unicode制定了一套字符集编码的标准,而在实际中如何去表示一个编码点呢,有几种不同编码方案:UTF-8、UTF-16和UTF-32,这几种方案各有特点。

UTF-32:

这是最简单的一种编码方式,定长编码。使用4个字节作为一个编码单元,也就是说每一个编码点都用4个字节来表示。

定长编码的一个好处就是每个字符的做占用的空间都是相同的,所以当我们想要获取第n个位置的字符时,直接在首字符的地址加上一个固定的偏移量就可以了,也就是说可以在O(1)的时间复杂度索引字符串的任意位置,这也是我们常说的随机索引。但是这样做的缺点也十分明显,每个字符占用32个bit,肯定会造成大量的空间浪费,出于这个原因UTF-32编码用得并不多。

UTF-16:

在介绍UTF-16之前,先讲讲UCS-2编码。在早期的Unicode标准中,只定义了不到65535(0xFFFF,2的16次方)个编码点,所有的字符都可以用两个字节的UTF-16编码来表示,所以在那个时候UTF-16还是一个定长编码,UCS-2就等同于UTF-16。然而设计师还是错误的估算了编码点的范围,16位的范围并不足以囊括世界上的所有文字,所以Unicode需要扩大最初的范围。在新的标准中编码空间被扩展到了0x10FFFF的大小,分成17块65535大小的板块,第一个板块包含了最初UCS-2中定义的65535个编码点,被称为基本多文种平面(BMP),余下新增的16个板块称为辅助平面。所以在今天来说,UTF-16可以看成UCS-2的父集。

随着标准的扩充,UTF-16也必须扩展以支持更多的编码点。在如今的UTF-16编码中使用了2个字节作为一个编码单元,一个编码点需要2个或4个字节来表示。

为了能正确表示辅助平面中的编码点,UTF-16对编码点的前缀做了一些约束,引入了一个称为代理编码点(surrogate)的概念。也就是在Unicode的编码空间中划分出了一块保留区域,落在在这个区域中的编码点就是代理编码点,这块区域包含从前缀110110到前缀110111的所有编码点,也就是从11011000000000001101111111111111的范围,十六进制为0xD8000xDFFF。这个区域中的编码点只能成对出现在UTF-16编码中,出现在UTF-32和UTF-8中都是非法的。

UTF-16在编码的时候遵循以下规则:

字节数 UTF-16二进制表示 编码点 编码范围
2 xxxxxxxxyyyyyyyy xxxxxxxxxxxxxxxx 0 ~ 0xFFFF
4 110110xxxxxxxxxx + 110111yyyyyyyyyy xxxxxxxxxxyyyyyyyyyy + 0x10000 0x10000 ~ 0x10FFFF

当编码点在0到0xFFFF的范围内时,这两个字节中的所有bit都可用来表示编码点;而当编码点大于0xFFFF,就必须要使用两个代理编码点了,分别取前后两个字节中低位的10个bit,这样就有了20bit的编码空间,最大能表示0x100000的值,再加上0xFFFF,正好就是0x10FFFF,Unicode中定义的最大编码空间。

UTF-8:

UTF-8使用单个字节作为编码单元,这是一种变长编码,根据需要使用1个到4个字节来表示一个编码点。在这种编码模式中,一个字节可能是表示一个单字节的字符,也可能是多字节字符中的一部分,在解析的时候必须要能够区分出来。所以在UTF-8中每个字节最高的几个bit不用来储存编码值,而是用来表示该字节在其所表示的字符中的位置:

字节数 UTF-8二进制表示 编码点 编码范围
1 0xxxxxxx xxxxxxx (7bit) 0 ~ 0x7F
2 110xxxxx + 10yyyyyy yyyyyzzzzzz (11bit) 0x80 ~ 0x7FF
3 1110xxxx + 10yyyyyy + 10zzzzzz xxxxyyyyyyzzzzzz (16bit) 0x800 ~ 0xD7FF + 0xE000 ~ 0xFFFF
4 11110xxx + 10yyyyyy + 10zzzzzz + 10wwwwww xxxyyyyyyzzzzzzwwwwww (21bit) 0x10000 ~ 0x10FFFF

3个字节的情况下有两个编码范围,这是因为上一节中提到的代理编码点不能表示任何字符

简单来说UTF-8的编码规则只有两条:

  1. 单字节字符的最高位为0,后7位为该字符的编码值。
  2. n个字节的符号(n > 1),第一个字节的最高n位都为1,n + 1位为0,剩余的字节的最高位都为10。

可以看到,单字节的UTF-8编码最高位作为标志位始终为0,在上面提到的ASCII编码中最高位没有用上也始终为0。也就是说前128个字符的编码方式与ASCII是完全相同的,这样一来UTF-8就能够完全兼容ASCII,用ASCII编码的文件无需任何转换就可以直接被UTF-8所识别。

对空间的高效利用,以及对ASCII兼容性,使得UTF-8成为了最主流的编码方式。

字节序

说到字节序的问题必须先谈一谈大端和小端,在计算机的世界中多字节的数据会按照其字节顺序被储存,而字节之间的排列方式有两种:大端模式(Big-Endian)和小端模式(Big-Endian):

  • 大端模式:低位字节排放在内存中的高位地址,高位字节排放在内存中的低位地址。
  • 小端模式:低位字节排放在内存中的低位地址,高位字节排放在内存中的高位地址。

比如说有一个short类型的数据0x3A80,需要占用2个字节的空间,其中高位字节为3A,低位字节为80

使用大端模式储存时内存的排列方式如下,内存中的高地址方向存放的是低位字节80

使用小端模式存储时内存中的排列方式如下,内存中高地址方向存放的是高位字节3A

再回到Unicode中,由于UTF-16使用了两个字节作为一个编码单元,在解析的时候每次需要读取两个字节,所以字节序就变得尤为重要。例如汉字的编码点为0x5440,如果以错误的字节序来读取的话,则会将其识别为0x4054,这样一来就变成了汉字

为了保证字符串始终能以正确的字节序来读取,标准建议UTF-16文件在起始的位置加上0xFEFF,称为字节顺序标记(BOM)。因为在读取文件是按照低地址到高地址的顺序,所以如果读取到0xFEFF则说明该文件是采用大端模式来储存的;如果读取到0xFFFE则说明文件是采用小端模式来存储的。

如果使用的是UTF-8编码则不需要关心这个问题,因为UTF-8的编码单元只有一个字节,每次只需要读取一个字节即可,所以不存在字节顺序的问题。

组合字符

Unicode的复杂性不仅体现在其编码方式上,在Unicode中有一些字符存在多种不同的表示方式。这是什么意思呢?有一些文字会带有音调符号,比如一个带有音标的符号ǎ,它可以直接通过编码点0x01CE来表示,也可以使用一个a(编码点为0x0061)和一个̌(编码点为0x030C)组合起来表示,虽然说编码看起来不一样,但是这两种写法在语义上和视觉上都是相同的。这样就引入了一个新的概念,我们称ǎ字符和ǎ组成的序列是标准等价的。

这样麻烦就来了,当用两种写法来表示同一个字符的时候,计算机根据字节比较会认为它们是不同的。为了能正确判断字符串之间的等价性,Unicode规定了一套标准的正规化算法(有四种正规化的形式,就不再展开介绍了),也就是将所有标准等价的字符转换成统一的表示形式:

let c1 = '\u{01CE}'; // ǎ
let c2 = '\u{0061}\u{030C}'; // ǎc1.normalize(); // 01CE
c1.normalize(); // 01CE
复制代码

在上面的这一段JavaScript代码中,ǎ的两种写法在经过正规化之后都被转换成了相同编码01CE,这样一来就能正确的进行相等性比较了。

到了Emoji这边情况就变得更加复杂了,很多Emoji表情是用多个Unicode码点来表示的,比如说❤️是由一个心型字符 ❤(0x2764)和一个样式控制符号(0xFE0F)组合而成。此外Emoji还支持使用零宽度连接符(ZWJ,码点为0x200D)将多个Emoji字符组合新的字符。也就是将0x200D字符放在两个Emoji字符的中间,这两个Emoji会被连接起来组成新的Emoji字符。比如说?和?可以组合成?‍?(\u{1f469}\u{200d}\u{1f466}),像?‍?‍?‍?这种Emoji更是由7个Unicode字符组合成的复杂字符。

从上面的这些例子中可以看出,在Unicode中语义上的单个字符实际上可能是由许多个字符组合而成的,为了更好的描述这种场景,Unicode中引入了一个称为字位簇(grapheme cluster)的概念。字位簇用来表示一个语义上的字符,不论是单个字符还是包含多个字符序列的组合字符,都视为一个字位簇。

实际应用

在了解了Unicode的各种特性之后再来看看不同语言中对于字符编码的处理吧,下面对比了一下个人平常使用的语言中字符编码的异同:

JavaScript

在JavaScript刚刚发布的那个年代,还是UCS-2的天下,所以JavaScript内部字符串的编码方式采用了UTF-16,准确的说是UTF-16的子集UCS-2。

这一历史问题为今天的JavaScript带来了一些困扰,因为所有的字符在JavaScript中都被视为两个字节的编码,如果字符串中包含辅助平面的编码点时,JavaScript会将其视为2个2字节的字符来处理。这个问题影响了JavaScript中的字符处理函数:

let c = '?'; // 0x20017
c.length; // 2c.charCodeAt(0).toString(16); // 0xD840
c.charCodeAt(1).toString(16); // 0xDC17
复制代码

上面代码中汉字"?"的Unicode编码点是0x20017,大小超过了0xFFFF,位于辅助平面中,所以在UTF-16中需要4个字节,编码为0xD840DC17。调用length的输出是2,说明JavaScript将其识别成了两个字符。charCodeAt是一个用来打印指定位置字符编码值的方法,将结果转换成16进制后可以看到分别输出了两个编码单元的值d840dc17。想必前端的同学一定对这些多字节字符处理上的坑深恶痛绝。

不过好消息是ES6以来这些坑也在陆续填上了:新增的codePointAt方法能正确识别4字节的UTF-16字符、新的Unicode字符表示方法\u{20017}、新增for…of循环也能正确的遍历4字节字符...

let c = '?';
Array.from(c).length; // 1
c.codePointAt(0) // 20017
复制代码

Objective-C

OC中对字符串的处理与JavaScript类似,内部的字符串编码同样采用了UCS-2,上面的那个例子在OC中会获得同样的结果:

NSString *s = @"?"; // 0x20017s.length; // 2[s characterAtIndex:0]; // 0xD840[s characterAtIndex:1]; // 0xDC17复制代码

想要获得正确的字符数可以先将字符串转换成定长的UTF-32编码,然后再除以4:

[@"?" lengthOfBytesUsingEncoding:NSUTF32StringEncoding] / 4; // 1复制代码

这样子可以正确的识别出Unicode码点的个数,然而对于组合字符还是无能为力。

这个问题同样会影响到比较字符串时常用的isEqualToString方法:

NSString *s1 = @"a\u030C"; // ǎNSString *s2 = @"\u01CE"; // ǎ

[s1 isEqualToString:s2]; // NO复制代码

若要对字符串进行标准等价比较,必须使用compare方法,或者先使用precomposedStringWithCanonicalMapping方法将字符串正规化:

[s1 compare:s2] == NSOrderedSame; // YES[s1 precomposedStringWithCanonicalMapping]; 复制代码

Swift

String

Swift在字符串编码上做了很多事情,Swift用String类型来表示字符串,不同的是在遍历字符串的时候有很多种选择,可以按照字符来遍历,也可以按照UTF-8或UTF-16编码来遍历:

let s = "\u{0061}\u{030C}" // ǎfor var c in s {...} // ǎ
for var c in s.utf8 {...} // 0x61、0xCC、0x8C
for var c in s.utf16 {...} // 0x0061、0x030C
复制代码

在上面的代码中s是直接以Unicode标量来初始化的,而s.utf8会将其转换成UTF-8的编码方式,随后遍历每一个编码单元,UTF-16也与之类似。字符串对象中utf8和utf16这两个属性的类型分别是String.UTF8ViewString.UTF16View,它们都是一个集合类型,实现了BidirectionalCollection协议,之所以没实现RandomAccessCollection是因为UTF-8和UTF-16都是变长编码,没办法做到随机索引。

String类型重载了==符号,而且在比较的时候会自动将字符串正规化后再进行比较:

let s1 = "\u{0061}\u{030C}" // ǎ
let s2 = "\u{01CE}" // ǎs1 == s2 // true
复制代码

在这一点上

Character

一个字符串是多个字符组成的序列,Swift中表示单个字符的类型是Character。Character表示的是一个Unicode的字位簇,也就是说一个Character中可以包含多个Unicode编码点:

let s = "?‍?‍?‍?abc"
s.first // ?‍?‍?‍?
复制代码

可以看到像上面这种带组合字符的情况在Character中能够被正确的处理,s.first获取到的第一个字符是?‍?‍?‍?(而不是?)。

Character中提供了unicodeScalars属性用来访问字位簇中的每一个Unicode编码点,每个编码点通过Unicode.Scalar类型来表示:

let c = "?‍?‍?‍?"c.unicodeScalars.count // 7
c.unicodeScalars.first?.value // 0x1F468 (Unicode编码点)
c.unicodeScalars.first?.utf16 // 0xD83D、0xDC68
复制代码

参考资料

http://blog.csdn.net/zhuxipan1990/article/details/51602299

http://blog.jobbole.com/111261/

https://zh.wikipedia.org/wiki/UTF-16

https://zh.wikipedia.org/wiki/UTF-8

https://objccn.io/issue-9-1/

Unicode的前世今生相关推荐

  1. 史上最好理解的Unicode编码讲解(Unicode的前世今生)

    一.了解编码 在计算机中存储的数据都是01二进制的数据串,我们再电脑屏幕上看到的一切可视化的东西最终在计算机存储的都是01二进制串,现在看到我博客上的文字也是. 这里就需要一个映射关系,将我们看到的看 ...

  2. sscanf 连续相同编辑符_【第1995期】钉钉文档编辑器的前世今生

    前言 编辑器,一个重前端的领域方向.今日早读文章由钉钉@展新授权分享. @展新,来自钉钉文档团队.2011 年加入支付宝,一路成长于支付宝的前端团队,孵化了语雀,2018 年到钉钉,开启钉钉文档的旅程 ...

  3. 字符编码,ASCII、Unicode与UTF-8的理解

    首先我们先要明白的两点是:1.计算机中的信息都是由二进制的0和1储存的:2.我们再计算机屏幕上看到的各种字符都是计算机系统按照一定的规则将二进制数字转换而来的. 一.基本概念. 1.字符集(chars ...

  4. Java版本之前世今生(一)

    Java版本之前世今生(一) 文章目录 Java版本之前世今生(一) **1. Oak 语言** **2. JDK Beta** **3. JDK 1.0** **4. JDK 1.1** **5.J ...

  5. python 2x list 里面的中文打印效果为unicode 编码 的处理方法

    处理方法如下.这个问题在python 3x 中不会出现 #!/usr/bin/python # -*- coding: utf-8 -*- import sys reload(sys) sys.set ...

  6. 《windows核心编程系列》二谈谈ANSI和Unicode字符集

    第二章:字符和字符串处理 使用vc编程时项目-->属性-->常规栏下我们可以设置项目字符集合,它可以是ANSI(多字节)字符集,也可以是unicode字符集.一般情况下说Unicode都是 ...

  7. Go 学习笔记(31)— 字符串 string、字符 rune、字节 byte、UTF-8 和 Unicode 区别以及获取字符串长度

    1. 字符串 string 类型 Go 语言中字符串的内部实现使用 UTF-8 编码,通过 rune 类型,可以方便地对每个 UTF-8 字符进行访问.当然, Go 语言也支持按照传统的 ASCII ...

  8. Error: The INF file contains Unicode characters that could not be converted correctly

    昨天第一次为自己的windows mobile程序制作CAB安装包,但是在生成过程中,却出现了这样一个问题: 编译完成 -- 0 个错误,0 个警告 time -> G:\WindowsMobi ...

  9. UNICODE使用的一些知识和技巧

    UNICODE宏和_UNICODE宏的关系 在windows编程中,经常要编译Unicode版本的程序,方法是工程文件的配置中加上UNICODE或者_UNICODE编译条件,那么到底是用哪一个呢? J ...

最新文章

  1. 我把帮带份饭的信息错发给导师后.......
  2. iOS架构-静态库.framework手动打包及脚本化打包(5)
  3. elasticsearch按照配置时遇到的一些坑 [Failed to load settings from [elasticsearch.yml]]
  4. [react-router] React-Router怎么设置重定向?
  5. 工厂电子产品工艺文件_建智能工厂,人机如何达到最佳组合?
  6. python记录(4)- lxml模块创建xml文件
  7. Linux操作系统中readelf命令常见用法
  8. 20190429 - 如何访问 macOS 的 httpd、mysql 等服务
  9. 查看计算机各程序运行时间,查看电脑运行时间_查看电脑运行时间命令
  10. java文件内容比较_怎么用JAVA技术编写一个两文件内容比较的程序?
  11. 5分钟入门MP4文件格式-mp4的ftyp
  12. Python3开发 语法(四)
  13. 一、OpenTCS4.12 创建一个新的通信驱动
  14. 详解Linux帮助命令——help、--help、man、info
  15. 关于跳转页面报404、500错误设置自定义图片和文字
  16. 计蒜客 - 德克萨斯长角牛(最短路)
  17. python中datetime是什么意思_Python中time和datetime的区别
  18. Leetcode 题解-59题
  19. 数学竞赛倒计时23天_变上限下限积分求导法则
  20. Long Short-Term Memory(长短期记忆网络)

热门文章

  1. 面试官:因为这个语言,我淘汰了90%的人!!
  2. 揭秘鸿蒙系统中的 JS 开发框架
  3. “你写的 Bug 让我来改好吗” | 每日趣闻
  4. PS5 发布,揭秘真假难辨的虚拟和现实
  5. SEO优化中如何引导流量
  6. Highcharts X轴纵向显示
  7. 后端服务性能压测实践
  8. 唯一聚集索引上的唯一和非唯一非聚集索引
  9. x86终端登录horizon view设置
  10. http://www.cnblogs.com/amboyna/archive/2008/03/08/1096024.html