关于Unicode,字符集,字符编码,每个程序员都应该知道的事#
作者:Jack47
2017.12.18 Update 2年以后,以Go语言作为主力开发语言后,看到了Rob Pike的 这篇文章,虽然是Go语言相关,但是里面涉及的语句非常简单,深入浅出的把下文中的几个概念讲清楚了,最重要的是非常非常简单的代码实例,能够让你一次把这些东西都搞懂,非常值得一看!
李笑来的文章如何判断一个人是否聪明?中提到:
必要、清晰、且准确的概念,是一切思考的基石。所谓思考,很大程度上,就是在建立那些概念与概念之间的关联。概念是必要、清晰、且准确的,它们之间的关联也应该是准确的。
确实很认同这两句话,搞清楚字符集,字符编码,Unicode等关键词的意义,基本上也就能搞明白遇到的编码问题了。本文力求通俗易懂,但涉及的内容比较多,而且编码问题又不是那么容易理解的,所以如果大家看完之后还是对编码问题一知半解,那也不要灰心,下次遇到编码问题时回过头来再看看本文。我也是断断续续花了很长时间才理解清楚编码问题的。
基本概念##
字符[character]###
字符代表了字母表中的字符,标点符号和其他的一些符号。在计算机中,文本是由字符组成的。
字符集合[character set]###
由一套用于特定用途的字符组成,例如支持西欧语言的字符集合,支持中文的字符集合。字符集合只定义了符号和他们的语意,其实跟计算机没有直接关系。
现实生活中,不同的语系有自己的字符集合,例如藏文有自己的字符集合,汉文有自己的字符集合。到计算机的世界中,也有各种字符集合,例如ASCII字符集合,GB2312字符集合,GBK字符集合。还有一个其他字符集合的超集--Unicode字符集定义了几乎绝大部分现存语言需要的字符,是一种通用的字符集,来支持多语言环境(可以同时处理多种语言混合的情况)。各个国家和地区在制定编码标准的时候,“字符集合”和“字符编码”一般都是同时制定的。所以像ASCII字符集合一样,它也同时代表了一种字符的编码。
字符编码[character encoding]###
是一套规则,定义了在计算机内存中如何表示字符,是字符集中的每个字符与计算机内存中字节之间的转换关系,也可以认为是把字符数字化,规定每个“字符”分别用一个字节还是多个字节存储,用哪些字节来存储。例如ASCII编码[你没看错,它既是一种字符集合,也是一种字符编码],定义了英文字母和符号在计算机中的表示方式,是用一个字节来表示。Unicode字符集合,有好几种字符编码方式,例如变长度编码的UTF8,UTF16等。中文字符集也有很多字符编码,例如上文提到的GB2312编码,GBK编码等。
知乎上的这篇介绍字符编码,字体,iconv的文章很赞,内容浅显易懂。还有一篇很有名的有关Unicode和字符集的文章可以看看:The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!),网上有中文版。
UCS和ISO 10646标准###
ISO 10646标准定义了通用字符集UCS[Universal Character Set],是其他所有字符集合的超集。它保证了和其他字符集合之间可以来回转换,不会丢失信息。
UCS不仅给每个字符做了编码,而且还定义了一个官方的名称。用来表示一个UCS或者Unicode的十六进制数字通常是用"U+"来作为前缀的,例如用"U+0041"来表示拉丁文中的大写字母A。
UCS[Universial Character Set]和Unicode的关系###
简单粗暴的总结一下,就是两拨人搞的同一套标准。具体经过如下:
在1980年代后期,有独立的两拨人想创建一个通用的字符集合。一个是国际化标准组织ISO[Internaltional Organization for Standardization],另外一个是最初成员大部分是美国多语言软件服务提供商的财团发起的Unicode项目。幸运的是在1991年左右,两个项目的成员都意识到世界不需要两个统一的字符集。于是他们一起合作制定了一个字符表。虽然两个项目至今仍然存在并独立发布各自的标准,但是Unicode财团和国际化标准组织都已经同意会让Unicode和ISO 10646标准互相兼容并会在未来紧密协作。具体两者之间的区别,见这里
什么是UTF8###
Unicode/UCS只是字符集合,虽然为每个字符分配了一个唯一的整数值,但具体怎么用字节来表示每个字符,是由字符编码决定的。Unicode的字符编码方式有UTF-8, UTF-16, UTF-32。由于UTF-16和UTF-32编码中包含"\0",或者"/"这样对于文件名和其他C语言库函数来说具有特殊意义的字符,所以不适合在Unix下用来做文件名称,文本文件和环境变量的Unicode编码。UTF-8没有这样的问题,它有很多优点:可以向前兼容ASCII码,是变长的编码,由于编码没有状态,所以很容易重新同步,在传输过程中丢失了一些字节后,具有鲁棒性。
POSIX语系[locale]机制###
语系[locale]就是软件运行时的语言环境,它是语言和文化规则的一个集合,包含字符编码,日期/时间的表示方式,字符排序的规则等。语系的名称通常是由ISO 639-1规定的语言[language]和ISO 3166-1规定的国家代码[country code]以及额外的字符编码名称[character encoding]共同组成,例如zh_TW.UTF-8语系,zh代表语言是汉语,TW是*地区,UTF-8是字符编码。而zh_CN.GBK中,CN是指*地区,采用GBK编码。
Linux下语系由几个类别的环境变量组成,指定了在软件中跟语言惯例相关的行为信息。例如LC_CTYPE
决定字符编码方式,LC_COLLATE
决定字符排序的规则。LANG
环境变量用来设置所有类别的默认语系,但是LC_*
这些变量能够覆盖每个单独的类别。
理解了上述概念,咋们就可以去实践一下了。
实战##
C语言对Unicode和UTF-8的支持###
多字节字符和宽字符####
C语言中用单独的一个char
类型的变量是无法唯一地表示像汉语这样的自然语言的。C语言标准支持两种不同的方式来处理扩展的自然语言编码方式:宽字符[wide characters]和多字节字符[multibyte characters]。
- 宽字符是一种内部表示方式,每个字符是用一个单独的
wchar_t
类型来表示的。 - 多字节字符是用来做输入和输出的,每个字符用C语言中
char
类型的序列来表示。所以每个字符会用一个或多个(最多MB_LEN_MAX)字节来表示
wchar_t
这种类型是从GNU glibc 2.2开始引入的,目的是在运行时用单个的对象来表示字符,跟当前使用的语系无关。ISO C99标准要求通过宏__STDC_ISO_10646__
来告诉程序支持wchar_t
类型,并且保证所有的宽字符处理函数都会把宽字符当作Unicode字符。C语言中处理宽字符的函数多数是在处理char类型字符的函数名基础上,添加了"w"或者是把"str"替换成"wcs",例如wprintf(),wscpy()等。字符串常量之前添加L前缀就可以告诉让编译器用wchar_t
类型来存储字符串常量,例如printf("%ls\n", L"Schöne Grüße")
,如果用宽字符来表示字符串,此时的字符串长度就是以wchar_t
为单位的,而不是字节;
。
2011版的C和C++标准都各自引入了固定大小的字符类型char16_t
和char32_t
来明确提供16位和32位Unicode编码格式,让wchar_t
成为实现相关的类型。ISO 10646:2003 Unicode 4.0标准说:
wchar_t
类型的宽度是由编译器指定的,可以小到只有8位。因此对于需要在C或C++编译器之间可移植的程序不应该使用wchar_t
来存储Unicode文本。wchar_t
类型的目的是存储编译器定义的宽字符,有可能不是用Unicode编码的。
多字节字符的字符编码方式,是由当前系统的语系[locale]来决定的,例如当前语系中字符编码是UTF-8,那么多字节字符编码就是UTF-8。因此语系也控制着宽字符和多字节之间的转换。
glibc2.2及更高版本完整地实现ISO C语言多字节转换函数(mbsrtowcs(), wcsrtomb()等)。这些函数用来在wchar_t
和任何语系相关的多字节编码,包括UTF-8,ISO 8859-1等之间进行转化。
建议是使用这些函数中可重启动的[restartable,函数名中有字母r],是多线程安全的函数,例如wcsrtombs(), mbsrtowcs()。
使用这些函数的好处是:
- 是跟厂商无关的标准
- 函数会根据用户的语系做正确的事情。程序需要做的是在程序开头调用
setlocale(LC_ALL, "")
来根据环境变量来设置用户语系
例如可以写出如下代码:
#include <stdio.h>
#include <locale.h>
int main()
{
if (!setlocale(LC_CTYPE, "")) {
fprintf(stderr, "Can't set the specified locale! "
"Check LANG, LC_CTYPE, LC_ALL.\n");
return 1;
}
printf("%ls\n", L"Schöne Grüße");
return 0;
}
setlocale(LC_CTYPE, "")函数,会依次测试环境变量 LC_ALL
, LC_CTYPE
和 LANG
的值,如果有值,就用这个值来决定用哪个语系数据来加载LC_CTYPE
这个分类(控制着多字节转换的函数)。
printf
中的%ls
格式说明符是用来指定把宽字符形式的字符串参数转化成由语系决定的多字节编码来输出。printf
函数是不知道输出的字符的编码方式的,它会把传给它的字节原封不动地输出出去。在显示的时候,操作系统会根据当前的语系来将这些字节解码到对应的字符,所以只有当传给printf
的字符编码方式和用户环境变量指定的字符编码方式相同,用printf
打印出的字符才不会乱码。
使用这些函数的坏处:
- 有些函数是非线程安全的,因为两次函数调用之间有隐藏的内部状态
- 不能同时支持多种语系或编码方式
通过上述的分析可以看到,如果全部都使用C语言库中多字节的函数来进行外部字符编码和程序内部使用的wchar_t
类型之间的转换,那么C语言库会根据环境变量LC_CTYPE
的值来选择正确的字符编码,你的程序甚至不用显示地知道当前多字节编码是什么。
然而,有一些情况下你可能不会全部都用C语言库中的多字节函数,此时程序不得不知道当前语系是什么。此时需要首先在程序开始处调用setlocale(LC_TYPE, ""
函数来根据环境变量设置语系。之后利用函数nl_langinfo(CODESET)
函数来获得当前语系指定的字符编码的名称。
C语言如何书写采用了某种字符编码的字符串常量###
对于一坨字节数据来说,字符编码就相当于是有色眼镜一样,我们可以戴上UTF-8编码的眼镜去解读这片字节数据,也可以戴上GBK编码的眼镜去解读它。只有当我们采用了跟写入时的编码一致的编码去解读,才能读取出有意义的字符串,否则可能就是乱码了。
转义序列####
转义序列[escape sequences]:转义是以多个字符的有序组合来表示原本很难直接表示出来的字符的技术。转义序列指在转义时使用的有序字符组合。
需要了解C语言中如下的几个转义方式:
'\798':值为十进制值798的字符
'\x7D':值为十六进制7D的字符
'\u0041':代表字符名称中名为U+0041的这个Unicode字符,可能最终编译器会用几个字节来存储这个字符。这种方式只有C99以后才支持。由编译器来决定具体用什么方式存储。
有了这几个转义字符这样就很容易书写出特定编码的字符串了,例如"我是Jack47",采用各种编码形式的值如下:
char gbk_name[] = "\xced2\xcac7Jack47";
char unicode_name[] = "\u6211\u662FJack47"
char utf8_name[] = "\xe6\x88\x91\xe6\x98\xafJack47"
上述的这种方式,是直接把编码后的字节写入到了数组里,是一种"硬编码"[hard code]的方式。
知道了上述的知识后,问题就来了,当前软件要支持UTF8,要如何修改?
如何修改软件来支持UTF8###
有两种办法,可以这样划分:
1. 软转换:数据在所有地方都是以UTF-8的形式存储的。
2. 硬转换:程序读取的输入是UTF-8数据,在程序内部转换成宽字符后进行处理,只有在最终输出的时候转换成UTF-8编码。在内部一个字符是一个固定大小的内存对象。
也可以这样划分:
1. 硬编码的方法
把UTF-8相关的信息硬编码到程序中。这样能够在某些场景下显著提高程序执行效率。这或许是那些只需要支持ASCII和UTF-8编码的程序的最好办法。
2. 取决于语系的方法
C语言提供了可以处理任意特定语系,采用多字节编码的字符串的处理函数。依赖于这些函数的程序员可以不用感知到UTF-8编码的实际细节。通过仅仅改变语系设置,就可以自动支持其他的多字节编码(例如EUC)。
如果使用了UTF-8或者其他类似的多字节编码,需要程序员清楚地区分以下概念:
1. 字节[Byte]
2. 字符[Character]
3. 显示时候的宽度
如何在不同编码间转换###
可以使用iconv函数在两个不同的编码之间进行转换,例如从GBK编码转换到UTF-8编码。
Java与Unicode###
Java语言内部使用的就是Unicode编码。char
类型表示一个Unicode字符[这是跟C语言不一样的地方],java.lang.String
类表示一个从Unicode字符构建的字符串。
java.io.DataInput
和java.io.DataOutput
接口分别有叫做readUTF
和writeUTF
的方法。但记住他们使用的不是UTF-8;他们用的是修改后的UTF-8编码:NUL字符不是用一个字节的0x00
来表示,而是用两个字节的0xC0 0x80
来表示的,在最后添加一个字节的0x00
。这样编码,字符串包含NUL字符而不需要增加表示字符串长度的前缀字段--这样C语言<string.h>
中定义的strlen()
和strcpy
这些函数就可以用来操作这些数据了。
一些练习###
- 如何处理输入的中文参数,例如中文参数的字符个数打印出来?
- 在json串中遇到了这样的字符串,是什么意思呢?"\u82f9\u679c\u624b\u673a"
参考资料##
在POSIX系统上(Linux, Unix)如何使用Unicode/UTF-8的一站式信息的文章,内容丰富,比较长,可以挑着看。
如果您看了本篇博客,觉得对您有所收获,请点击右下角的“推荐”,让更多人看到!