Categories: Yurii谈开发

浅谈编码

本文由Yurii原创,转载请注明来源: Life Sailor

本文链接 浅谈编码


《学到不会忘》中我提到,为了写《正则指引》,专门抽了些时间学习Unicode,也因此明白了很多与编码有关的问题,只是最后没有全部写进《正则指引》中,以免离题。不过,这并不妨碍专门用一篇文章来讲解编码问题。

其实所谓编码问题,不外乎若干概念,弄明白了这些概念,编码问题就可以迎刃而解了,所以这里按照概念来展开讲解。

字符和字符集

字符,就是我们日常使用的各种文字,比如中文的,英文的ABC,日文的,都是字符。手写可以用到的字符几乎是无限的,但在计算机中,必须事先约定好字符的范围,也就是穷举出所有“可以使用”的字符。这个范围,就是通常说的“字符集”(Character Set)。

ISO8859-1是开发中常见的字符集(MySQL默认就采用这种字符集),它支持的语言有英语、德语、法语等,也即包含了英语、德语、法语中的字符。GBK是另一种常见的字符集,它源自GB2312字符集,GB表示“国标”,GB2312即是国家标准,它的另一个名字是CP936(Code Page 936),以前在Linux下播放MP3,如果发现ID3标签乱码,设定为CP936就可以解决。因为制定较早,GB2312只包含6763个汉字,并不足够覆盖日常的使用,所以诞生了GBK,其中的K表示“扩展”。有意思的是,GBK是微软制定的字符集,而不是“国标”,只是曾由国家技术监督局标准化司、电子工业部科技与质量监督司公布为“技术规范指导性文件”。除此以外,港台地区以前使用Big5字符集,曾经在Dos下玩过港台游戏的朋友应该还记得“大五码”这个名字。

与字符相关的另一个概念是字形(glyph),它是字符显示出来的样子,同一个字符可以有好几种写法,每种写法其实对应一种字形。下面举例列出了“高”字的几种字形(资料来自Wiki)。

编码与码值

在计算机内部,所有的数据都是编码保存的,字符也不例外。因此,每一种字符集不但约定了可以使用的文字的范围,而且为每一个字符确定了唯一的代码,称为码值(Code Point,也叫“代码点”)。

在ISO8859-1字符集中,A的码值是41(十六进制),=的码值是3d(十六进制);在GBK字符集中,的码值是b7 a1(十六进制),的码制是b7 a2(十六进制)。因为单个字节只能表示最多256个字符,而中文字符超过256个,所以GBK编码选用2个字节表示单个字符,相应的,其码值也是4位十六进制数值。

我们说的“GBK编码”、“ISO8859-1编码”,其实既指其对应的字符集,又指其对应的码值规定。通常,两者是一体的,但是在Unicode编码中的情况,却不是这样。

Unicode

随着计算机和互联网的发展,各自为战的字符集很快就遇到了问题:如果我需要在一篇文章中同时使用中文和日文字符,该怎么办呢?设定为日文编码(常用的为Shift-JIS、EUC-JP)则不能涵盖中文字符,设定为中文编码则必须放弃日文字符,所以需要一种统一的、可以覆盖各种语言的字符集,于是Unicode字符集应运而生了。

Unicode的最初想法是用2个字节(16位,65536个码值)来表示世界上所有的语言,所以它的字符集称为UCS-2(2 byte Universal Character Set)。用2个字节表示一个字符,就会带来字节序问题:在传输和存储时,到底是先传输高位字节(big endian),还是低位字节(little endian)呢?这个问题Unicode也没有确切的答案,所以设定了BOM(byte order mark,字节序标识)来解决。BOM对应的码值是fe ff,无论fe ff,还是ff fe,在Unicode中都没有实际的意义,所以不会造成干扰。在读取使用了BOM的文件时,先读取头两个字节,如果是ff fe,就是little endian,如果是fe ff,就是big endian;如果文件的开头两个字节既不是fe ff,也不是ff fe,默认采用big endian。如果你用Windows的记事本创建Unicode编码的文件,文件头就会包含little endian的BOM,其它一些文本编辑工具则不会。如果用程序解析包含BOM的XML文件,可能遇到非法字符的错误,必须先截去开头的BOM信息。

最早制定Unicode规范时,大家乐观认为觉得65536个字符就可以覆盖地球上所有语言中的字符了,这种今天看来草率的乐观,导致了不少后果。

第一,因为东亚文字(主要是中、日、韩三种语言的文字)字符非常多,为了节省码值,就将三种语言中字形类似的字符映射到同一码值,这种做法称为UniHan(统一汉字,在Unicode规范中,也称为“东亚文字(East Asian)”),比如中文(包括大陆、香港、台湾)和韩文及日文的“骨”、“直”等字,虽然写出来的字形(glyph)有微小差别,但码值是相同的。这样的好处是节省了码值,而且某些跨语言搜索可以直接进行,比如搜索日文关于“直角”的资料,直接输入“直角”即可。这样的坏处是,不能依靠码值判断到底属于中日韩语言中的哪一种(三种语言中的常用字符大都属于CJK_Unified_Ideography这个书写系统),而且,对于码值相同但字形不同的字符,到底选择哪个字形来显示,还应当参考locale设定(使用过Linux的人大都会记得zh-CN.UTF-8这样的locale设定,它可以影响到”直“、”骨“之类的字形选择)。

网络上有不少资料说,匹配中文字符的正则表达式是[\x4e00-\x9fa5],也有资料说是[\x4e00-\x9fff]。从原理上看,它们都是用字符组表示某个范围,起始码值都是4e 00,结束码值却有不同,这是为什么呢?仔细阅读UniHan规范可知,其实它们的原理都是使用CJK_Unified_Ideography书写系统(Script,这是一种Unicode属性,下面会详细讲到)中的文字,在1992年提交给IRG(International Rapporteur Group)的字符只排到9f a5。在这之后,制定更新版本的Unicode规范时都进行了扩展,新增了字符。不过从根本上说,4e00-9fff是预留给东亚文字的码值范围,所以使用[\x4e00-\x9fff]是更好的选择。具体信息可以参考 UniHan规范

如果要“完整地”匹配所有的中文(东亚文字),还必须考虑Unicode各版本中增补的CJK统一表意字符,从CJK Unified Ideographs Extension ACJK Unified Ideographs Extension B一直到最新的CJK Unified Ideographs Extension E,具体细节可以参考Wiki上的说明

第二,用2个字节表示单个字符并不合适。对ASCII字符来说,用单个字节就可以表示,2个字节造成了大量的浪费;另一方面,65536个字符并不够表示世界上的所有字符,所以Unicode规范进行了扩编,截止本文写作时止,最新的Unicode 6.1.0规范包含110116个字符,所需的字节当然超过2个(16位)。针对这种问题,Unicode字符提供了不同的字符编码方式(Character Encoding Scheme),可以这么理解:字符的码值是一回事,在存储和传输时,具体落实为几个字节,如何表示,又是另一回事,码值的具体表示形式,就由字符编码方式来规定。常见的Unicode字符编码方式有:UTF-8,UTF-16等,其中的UTF是UCS Transformation Format的缩写,明确表示它是一种传输格式,所以我们可以说“Unicode字符集”,也可以说“Unicode编码”,还可以说“UTF-8编码”、“UTF-16编码”,但不能说“UTF-8字符集”、“UTF-16字符集”。

UTF-8是一种变长编码,第一个字节的最高位如果是0,则表示这个字符用单个字节表示,否则,从这一位开始向后数,有多少个连续的1,这个字符就用多少个字节表示。于是,英文字符只需要1个字节就可以表示,而中文字符一般需要3个字节来表示。比如字,其码值为53 d1,但UTF-8文字编码方式下表示为e5 8f 91

如果我们拿到一段文本,不知道它到底是GBK编码还是UTF-8编码,就可以依据UTF-8编码的这个特征进行判断。不过我之前试验过一个取巧(但不那么保险)的办法:因为中文里的“的”字出现非常频繁,而当时要判断的文本一般都不短,所以直接查找文本中是否出现了GBK的“的”字或UTF-8的“的”字,也可以判断出来。

UTF-16则是一种定长编码,每个字符都采用2个字节,16位来表示。的UTF-16编码方式下表示为53 d1。相比UTF-8,它的字符长度固定,本来是一种好处。但是因为Unicode字符集已经超过了65536个字符,所以UTF-16已经没有什么优势了,对超过16位的Unicode字符,UTF-16必须补充另外两个字节来表示,多出来的这两个字节称为代理对(Surrogate Pair)。

Java在诞生时就有”先见之明“地选择了UTF-16作为内部文字编码方式,每个字符在JVM内部都使用16位来表示,所以Java中的char是long类型,也就是16位整数。但是随着Unicode字符集中的字符超过65536个,Java原来的字符串处理API就无能为力了。为弥补这个问题,Java 5.0另外提供了CodePoint相应的方法,比如计算CodePoint个数的codePointCount(),取代之前的length(),以及获取某个CodePoint的codePointAt()方法,取代之前的charAt()方法。另外,在进行跨语言通讯(比如调用Web Service)时,往往必须显式指定输入输出的文字编码方式为UTF-16,否则有可能遭遇乱码。

既然Unicode包含了几乎所有的字符,这些字符的分类管理当然也更复杂。比如,针对某个字符,必须能知道它属于哪种语言;再比如,还需要知道某个字符到底是空白字符,还是标点字符,还是文字字符——ASCII编码中的字符可以分为控制字符、字母字符、标点字符等等,各个分类所包含字符的码值是位于连续区间的,所以直接指定码值范围即可(参加下面的ASCII码表),但是在Unicode中,不同语言的标点字符,其码值必然不是连续的,必须要有办法表示这些分类。要满足这些需求,就必须依靠Unicode属性。

Unicode属性

Unicode不但包含了更多的字符,多种编码方式,还提供了非常有用的功能,即Unicode字符集中的每个字符,都具有好几种属性,它们从不同的方面描述这个字符的某个特征。最常见的属性有:Unicode Property、Unicode Block、Unicode Script,以下分别简要介绍。

Unicode Property的记法类似\p{L}\p{P},按照字符的功能分类Unicode字符,而每个Unicode字符只能属于一个Unicode Property。 不妨这么理解Unicode Property:它并不按照字符所属的语言来划分Unicode字符,而是按照字符的功能来划分,比如\p{Z}表示任意的空白字符或不可见的分隔符;\p{P}表示任何标点字符,等等。遇到中英文混排、全角半角同时出现的情况,我们就可以用\p{Z}匹配所有的空白字符(不关心到底是全角空格还是半角空格),用\p{P}匹配所有的标点字符(而不用关心逗号到底是中文逗号还是英文逗号),不用费心细节。

Unicode Block则不同于Unicode Property,它按照编码区间划分Unicode字符,每个Unicode Block中的字符编码都是落在同一个连续区间的。因为Unicode编码表中,某种语言的字符通常是落在同一区间的,所以它也可以粗略表示某类语言的字符,比如\p{InHebrew}表示希伯莱语字符,\p{InCJK_Unified_Ideographs}表示兼容CJK(中文、日文、韩文)统一表意字符。如果你细心观察,会发现Unicod Block的名字虽然类似某种语言的名字,但都有“In”(Java风格)或者“Is”(.NET风格)前缀,这表明它其实对应的还是“落在某个区间的Unicode字符”。

Unicode Script按照字符所属的书写系统来划分Unicode字符,比如\p{Greek}表示希腊语字符,\p{Han}表示汉语(中文字符)。它的写法类似Unicode Block,只是名字的开头没有“Is”或者“In”。

以上三种属性互相独立,之间没有层叠关系,可以用下面这幅图简要说明。

在处理字符串时,如果可以用到这几种属性,就会非常方便。如今流行的语言中,大都可以通过内建的正则表达式来获得这几种属性,并进行相应的处理。但是,语言对Unicode属性的支持并没有硬性的标准,所以造成不同语言的支持程度各有不同。一般地说,支持Unicode Property的语言有.NET、Java、PHP、Ruby(限1.9以上版本);支持Unicode Block的语言有.NET、Java;支持Unicode Script的语言有PHP和Ruby(限1.9以上版本)。具体的使用方法,可以参考Regex Tutorial的专题页面,也可以阅读《正则指引》第7章。

Yurii

View Comments

  • 余老师又出精品了。最后一段Unicode 属性稍显突兀。要是能先引入问题和应用场景再介绍就更容易理解啦。

    • “精品”谈不上,写着玩玩,给大家当个参考。
      你的意见很好,我已经补充了一些内容:)

  • 之前觉得[\x4e00-\x9fa5]对中文肯定是不全的,后来只看到iteye上的某个帖子有提过这类问题,还列了几个url...今天看了才知道原来wiki上都有说明...

  • 逻辑清楚、生动有趣,读起来赏心悦目。谢谢楼主的好文章!

  • 在读取使用了BOM的文件时,先读取头两个字节,如果是ff fe,就是little endian,如果是ff fe,就是big endian;

    -------

    笔误?big endian应该是fe ff

  • 谢谢分享。btw:能否对于一些零宽的字符简单介绍下,包括产生的原因,适用场景 :)

  • 还有个问题想请教下,假设我有一段100字节的文字,我用某种编码转换成汉字后,再用同样的编码转换为字节,是否还是 100 呢?谢谢

    (假设 100 个字节末尾不是半字的情况)

    • 如果是100字节,更应该叫“文本”或者“数据”,而不是“文字”吧。
      理论上说转换是可逆的,也就是说回去仍然是100字节,但实际情况不见得如此,编码转换的过程比较复杂,遇到无法转换的字符可能中止或者忽略,这样再转换回去就不能保证100字节了。

Recent Posts

德国育儿经验:家长需要和儿童谈论”空气动力学“吗?

家长应当和儿童,尤其是低龄儿童谈论“空气动力学”吗? 我的答案曾经是非常肯定的:不应当。不要说儿童,就是成年人也不见得理解这些抽象的概念,与儿童谈论这些名词,只会让人望而生畏。身为父母,我们应当做的是,以孩子能理解的、感兴趣的方式谈论相关的具体问题,但绝对不要提这些大词。 不过世界的奇妙就在于,父母对教育并没有绝对的权威,总是需要根据实际情况来修正自己的观点。在“空气动力学”的问题上,我就吃到了教训。 那是一个下午,家里小朋友在iPad上看完他最喜欢的Blippi(这个节目我之前介绍过,对80后父母来说,Blippi可以理解为“带你见识各种新鲜玩意的董浩叔叔”),忽然抬起头来问我:“爸爸,你知道什么是aerodynamics吗?” “什么?你问我知不知道什么是aerodynamics?”我的下巴都要掉下来了。“空气动力学”这种词还是上中学时,身为军迷的我们在《航空知识》上知道的。再往后英语好一些,能看原版科普视频了,才知道“空气动力学”的原文就是aerodynamics。可是,我家这个还没上小学的家伙,竟然就能真诚地瞪大眼睛,一本正经地问我“知不知道什么是aerodynamics”。 (more…)

4 months ago

忆孟繁超老师:他从来没有给我上过一堂正式的课,但我永远都是他的学生。

我本来是不应该认识孟老师的。 2001年,我在寝室夜谈里第一次听到孟老师的名字。当时有同学说“公共选修课的《法学概论》讲得真好,那个老师叫孟繁超”,开始我不怎么在意,慢慢才发现这么说的人还不少。那个年月网上的资料正丰富,出版管制也不那么严格,刚进大学不久的我正自由自在地看得过瘾,心想“大学里的法学概论讲再好,能讲些什么,还不是教科书上老一套”,所以这种课,不听也罢。 但生活就在这么奇妙。那年冬天,有天中午我吃过饭正准备午睡,忽然有人敲门问“计算机系有位叫余晟的同学在这里吗?” 大中午的谁会来找我?我正好奇这个问题,门一推开就有同学喊“孟老师,孟老师来了”。 那是我第一次见到孟老师,中年人,国字脸,身材高大,打扮很精神,披在身后的深色大衣让我一下子想起电影里的斗篷。他笑眯眯地说“你是余晟?听同学说你搞电脑很厉害,我家的电脑坏了,想请你去看看。” (more…)

4 months ago

“历史照进现实”,这似乎不太现实

中国人大概都对历史有一些特别的偏好。对我们普通人来说,历史首先是文化的象征,一个人“懂历史”,基本等于这个人“有文化”;历史也是民族自豪感的来源,哪怕考古上仍然存在争议,但是“五千年文明”的说法是普通人都耳熟能详的。 不过等我长大之后才发现,这种偏好大概还有更深层次的原因,那就是历史看起来有种道德的意味,因为我们从小就熟悉“以史为鉴”的智慧,也熟悉各种“历史的选择”:每当我们对现实感到失望、困惑的时候,我们经常去历史——而不是先贤的智慧中——中寻找解答。找到曾经发生的类似的故事,就可以预言未来的结局。 于是乎,失望也好、困惑也罢,总归会有光明的未来,历史总会给我们支撑的信念。 我曾经很相信,熟谙历史是种智慧,而且是深层次的智慧。但是看得越多、经历得越多,我就越觉得,这很难称之为“智慧”。 为什么? (more…)

4 months ago

无人出租车,是技术进步的一粒灰,还是普通人头上的一座山?

“无人出租车要来了”。以百度“萝卜快跑”为代表的无人出租车,眼看就要在国内多个城市成规模运营。 熟悉IT的人都知道,IT的独特优势就在于“大规模扩展时边际成本极低”。在软件时代,微软开发的Windows,多卖一份的成本只是多刻录一张光盘而已。在无人驾驶时代,从10辆车到10万辆车的成本,也遵循同样的规律。换句话说,一旦模式“跑通”了,就可以迅速大规模铺开。无人出租车的大规模应用,也是“指日可待”了。 只不过,新技术这一次似乎没有那么激动人心,反而引起了很多争议——无人驾驶出租车大规模推广,会不会影响广大出租车、网约车车主的收入甚至生计?如果是,这样的技术进步,真的是我们所需要、所期待的吗?对于这个问题,不同的人有相差迥异的答案。 按照我的观察,许多人对此是相当乐观的。理由在于,“技术的每一次飞跃发展,虽然有阵痛,最终都创造了更多的新岗位”。既如此,无人出租车短期“看似”抢了许多人的饭碗,但也只是短期的“阵痛”而已。看看历史,纺织机的发明,蒸汽机的改良,汽车的诞生,无不证明了“阵痛说”的正确性。 坦白说,这种观点我是怀疑的。 (more…)

4 months ago

回国感受:松弛一点,愉快一点

因为小朋友放暑假,近期带小朋友回国待了几个礼拜。最深的感受就是标题所说的:松弛一点,愉快一点。 我第一次突出意识到这点,是在上海下飞机乘地铁。当时我们乘的直梯就要关门,远远看见有个年轻小伙子跑过来,我连忙按住开门按钮,并招呼他”别着急,慢慢来“,等他进了轿厢才关门。本来我以为大家起码会打个招呼,露个笑脸,因为我已经习惯如此,但完全出乎我意料的是,他进来之后对我们完全视若不见,自顾自掏出手机,盯着看得入迷。 我继而发现,不管是在电梯里,站台上,还是车厢里,虽然四下里都是广播”请扶好站稳,抓好扶手,不要看手机“,但是似乎人人都盯着自己的手机。年轻人在打手机游戏,年纪大一点的在滑各种小视频,还有不少人在聊天软件里打字如飞……对着屏幕的表情都很生动,可是一旦抬起头来,似乎马上又换了个人。 后来又有一次,我乘地铁的时候,因为比较拥挤,一个小伙子倒退时踩了我一脚,他大概意识到了,很快把脚挪开,脸上闪过一丝不安,马上又恢复正常,我也没有计较。不幸的是,过了十来分钟,他又踩了我一脚,同样是先有一点不安,很快又恢复正常。 这次我忍不了了,于是我开口告诉他:“小伙子,你已经踩了我两脚了。” (more…)

4 months ago

First name, last name, middle name,浅谈外国人名

前几天,国内朋友发来一条消息,原来是乌克兰F-16坠落,飞行员丧生的新闻。我本来以为他要讨论此事的真假和原委,他真正的问题却完全出乎我的意料: 新闻里说,飞行员叫阿列克谢·“月鱼”·梅斯,对应原文是Alexei “Moonfish” Mes,为什么会有人把“月鱼”写在自己的名字里,而且还打引号。 之前看新闻,乌克兰还有一个著名的飞行员叫安德烈·“果汁”·皮尔希科夫(Andrii “Juice” Pishchykov),怎么“果汁”也是正式的名字? 未必Moonfish和Juice之类,还有什么特别的含义吗?…… 这堆问题看的我有点想笑,因为自己以前也很苦恼外国人的名字,只有在国外长期生活,才逐渐搞清楚这其中的名堂。所以,除了解答朋友的问题,我也把自己的解释写下来,搞清楚两个最不容易理解的点,就不会对外国人名有那么多问题了。 (more…)

4 months ago