开始
#include <stdio.h>
int main(int argc, char** argv)
{
printf("大家好\n");
return 0;
}
上面这段代码中,在控制台打印"大家好"。并不陌生,这个"大家好"是一个典型的字符串,这段代码在各种平台都可以顺利工作,并且输出看起来一模一样;然而这背后是有着深刻的差别的。
#include <stdio.h>
int main(int argc, char** argv)
{
const char* text = "大家好";
printf("length of \"%s\" is : %lu\n", text, strlen(text));
return 0;
}
运行上面的代码,利用VC编译得到的结果是6,使用GCC编译得到的结果是9。好吧,我突然想起来可以使用宽字符,那么来吧。
#include <stdio.h>
int main(int argc, char** argv)
{
setlocale(LC_ALL, "");
const wchar_t* text = L"大家好";
wprintf(L"length of \"%ls\" is : %lu\n", text, wcslen(text));
return 0;
}
都输出了3,终于,这回都一样了吧。但是
#include <stdio.h>
int main(int argc, char** argv)
{
setlocale(LC_ALL, "");
const wchar_t* text = L"𠀫大家好";
wprintf(L"length of \"%ls\" is : %lu\n", text, wcslen(text));
return 0;
}
这回VC输出了5,而GCC输出了4。值得注意的是MinGW也输出了5。
经过这几个例子的测试,我们可以简单得出一个结论,字符串的处理不仅仅和编译器有关,还和平台有关。
解释
默认情况下,VC使用本地编码进行处理,在中文环境下,那就是GB18030。GB18030采用了变长编码,对于大部分中文而言,每个字符用2个字节编码;GCC默认使用UTF-8处理字符串,常见中文字符都可以用3个字节编码,"大家好"需要6个字节。strlen是用于计算C字符串长度的函数,按字节计数,因此VC、GCC默认情况下得到的字符串长度是不一样的。但是,编译器都是可以选定处理字符串的方式的,那么咱们就处理一下。使用-fexec-charset=GB18030选项进行编译后,GCC同样输出了6。
为了解决字符串编码混乱的问题,人们就提出了宽字符串。在C中就用wchar_t表示。但是,wchar_t是平台相关的,在Windows下,wchar_t是2个字节,采用的是UTF-16编码;而在Linux下,wchar_t是4个字节,直接表示UNICODE码点。wcslen按照wchar_t的数量来计算字符串的长度,所以VC和GCC对于L"大家好"得到了同样的长度,但是需要注意,它们占用的内存长度是不一样的。我们知道UNICODE的范围为0~0x10FFFF,显然用2个字节不能表达所有的UNICODE字符,"𠀫"的UNICODE码点是0x2002B,所以在Windows平台上它需要两个wchar_t才能表示,而在linux下,wchar_t可以直接表示它。
好,问题终于搞清楚了,但是这个UTF-8、UNICODE到底是什么鬼?
字符编码
为了促进大家不要乱用字符串,就只介绍UTF-8、UTF-16、UNICODE,鼓励大家使用平台推荐的字符编码。例如Windows平台推荐使用宽字符串,实际就是UTF-16;linux下推荐大家使用UTF-8。为了鼓励大家,从VS2013开始,MFC的多字节版本是不安装的,并且明确表示成DEPRECATED。
UNICODE也称万国码,就是一个庞大的编码表,涵盖了世界上所有的字符,编码范围为0~0x10FFFF,每一个编码称为一个码点(Code Point),使用4个字节足以表示,但是太占用内存了,这违背勤俭节约的美德,总之费钱的事儿大伙儿都不喜欢。于是就产生了UTF-8、UTF-16这样的编码方式,它们都是想办法把UNICODE中的码点转换成尽量少的字节,同时也要保持自同步,就是说你得能认出来哪几个字节对应一个UNICODE码点,而不需要外部数据。
UTF-8是这么干的。它说一个UNICODE码点有21个位,那么你看这样行不行?我这里有几个字节,我把一个字节的前几位保留下来作为同步位,剩下的用于表示UNICODE的码点,整理一下得到
| Unicode/UCS-4 | bit数 | UTF-8 | byte数 | 备注 |
|---|---|---|---|---|
| 0000 ~007F | 0~7 | 0XXX XXXX | 1 | |
| 0080 ~07FF | 8~11 | 110X XXXX 10XX XXXX | 2 | |
| 0800 ~FFFF | 12~16 | 1110 XXXX 10XX XXXX 10XX XXXX | 3 | 基本定义范围:0~FFFF |
| 1 0000 ~1F FFFF | 17~21 | 1111 0XXX 10XX XXXX 10XX XXXX 10XX XXXX | 4 | Unicode6.1定义范围:0~10 FFFF |
我们是讲中文的,中文的Unicode码点用UTF-8表示,至少也得3个字节,实在太吃亏了。所以我们还是喜欢用国家标准GB18030,变长编码,兼容UNICODE,完美。但是,平台都是人家的,不遵循平台规则,程序就得多转一道手续;还好有UTF-16。
UTF-16是2个字节的,只能表达0~FFFF范围内的UNICODE码点,这显然不行。莫慌,UNICODE标准说了,0xD800到0xDFFF这一段是不会编码任何字符的,叫做代理区,于是UTF-16就可以用代理的方式表达所有的UNICODE码点,具体是这么做的。
非代理区的码点,那UTF-16可以直接用2个字节表示。对于超过的那就得请出代理对(Surrogate Pair)了,UTF-16规定,0xD800 ~ 0xDBFF这段范围,高6位保留,剩下的10位作为UNICODE码点的高10位;0xDC00 ~ 0xDFFF这段范围,高6位保留,剩下的10位作为UNICODE码点的低10位。这么一合,正好20位。等等,UNICODE码点不是有21位吗?别慌,0-0xFFFF这段范围内的码点可以用一个UTF-16表示啊,不需要代理它们。对于需要代理的码点,例如0x2002B,那就给它减去一个0x10000,这不就变成20位了嘛,等到恢复的时候再给它加回来,21位的码点就又回来了。
uint32_t uc = 0x2002b;
// UTF-16, 用代理对表示0x2002B
uc -= 0x10000;
uint16_t h = 0xd800 | ((uc & 0x000ffc00) >> 10);
uint16_t l = 0xdc00 | ((uc & 0x3ff));
// 从UTF-16代理对中恢复 UNICODE 码点
h &= 0x3ff;
l &= 0x3ff;
uint32_t rc = 0x10000 + (h << 10) + l;
// 编码前和解码后必然相等
assert(uc == rc);
UTF-16可以具体为UTF-16LE和UTF-16BE,就是大小端的分别,就不说了。
画红线部分
使用VC编译器时需要格外注意,在VC2015之前,VC没有提供处理字符串的方式,所以面临者一个严峻的问题。先通过几个实例来观察一下,做个了解,知道这个问题确实存在,才能有动力去遵守规则。
给定一段代码
const char* text = "你好";
printf("lu\n", strlen(text));
那么请问,这段程序执行后输出什么?于是你自以为读了前面的说明,已经很了解了,就说好办,在Windows下,结果为4,在Linux下结果为6。对此,我只能说图样图森破。
对于VC(默认参数,中文环境,GB18030),存在以下事实
- 如果你的源代码采用UTF-8保存,那么这个输出是6,没错,VC把字符串处理成了UTF-8, 结果输出6;
- 如果你的源代码采用UTF-8 with BOM或者GB18030保存,那么VC把字符串当作GB18030处理,结果输出4;
- 如果你的源代码是UTF-8,但是采用了新的VC编译器,且指定了/source-charset:UTF-8,那么结果输出4;
- 如果你的源代码是UTF-8,但是采用了新的VC编译器,且指定了/execution-charset:GBK, 那么输出结果是6,看上去UTF-8编码的源文件锁定了VC的字符处理方式。
所以我的建议是,将你的代码保存成UTF-8 with BOM,足以覆盖已知的所有字符,还有什么不满意的呢。
应用事项
非常多的库在使用字符串的时候要求使用UNICODE,形式上可能是UTF-8或者UTF-16,使用时应注意。
WINDOWS的UNICODE支持
Windows从很早的时候就开始支持UNICODE,但是习惯的力量使得大家并不怎么遵守Windows的规范,但这是十分不推荐的,采用平台的规范可以十分明显的减少BUG的可能,尤其是字符串这种比较容易隐藏BUG的玩意儿。VC非常体贴的提供了tchar.h,Windows SDK中也用宏处理了几乎所有的Windows API,所以这么做是推荐的
- 使用_T、TEXT宏包裹字面值的字符串,如_T("大家好"),TEXT("大家好")
- 使用T系列的函数操作字符串,例如_tcslen、 _ttoi、_tstrcpy 等
- 自行编写字符串函数时利用UNICODE、_UNICODE宏做预定义判定
- 在MFC中使用CString,CString的构造函数可以接受多字节字符串和宽字节字符串
- 当使用std::string时要确认使用的字符编码类型,否则有中文的情况下就不一定正确
- _tcslen并不能正确计算字符的个数,它与strlen一样,返回的仅仅是编码基础单元的数目。如"𠀫"是一个字符,但占用2个UTF-16编码单元,用_tcslen获取的长度为2,对于编写程序而言,这种情况是没有影响的;但对于计算字数而言,就需要编写另外的计算函数了。
UTF-8是如此的成功,应用场合很多,大量第三方库也在使用,而接口上非常容易迷惑人。例如
void SetText(const char* text);
这个操作很容易就能传进去各种多字节编码的字符串,例如GB18030,结果就是乱码。如果遵守框架的规范,那么这个错误就可以在编译器得到确认。
Qt的支持
Qt使用UNICODE,QString采用了UTF-16,而且QString提供了多种方法,可以方便的将各种编码的字符串转成UTF-16,这也有一个隐藏的问题,就像上文中提到的,C字符串需要指明处理方式。例如在Windows下使用VC编译器
QString text = "中文";
QString处理这一句,是把输入当作一个UTF-8编码字符串的,而VC将这个字符串编码成了本地编码(GB18030),于是text并不是你想要的内容,那么怎么办呢?一种方法就是指定VC处理字符串的方式为UTF-8,大家都好;另一种就是写成宽字符
QString text = QString::fromWcharArray(L"中文");
这样写未免太长了点,所以指定处理字符串的方式是最方便的,缺点就是容易让人误解,毕竟大部分时间我们在看代码,而不是看Build脚本。
补充部分
文中一直提到了GB18030,这个是有渊源的,简单来说就是,我们有一个汉子编码标准,叫做GB_2312,字不全,微软就搞了个GBK,兼容GB_2312;于是我们又发力,搞了个GB18030标准,兼容GB_2312、GBK,而且还扩大影响,编码了所有UNICODE定义的码点,厉害吧。
象征性说点
字符编码真是一部血泪史,时至今日,还经常能见到乱码,你说气人不气人。说到这里就来气,你说你个DICOM标准,你既然造标准,你就把字符编码给统一了不就行了,又这又那的,弄一大锅,你瞅着来气不?
我得给自己点个赞,你看,我就介绍了UTF-8和UTF-16,其它没说,存在ICU、ICONV这样的工具我都没说,就是希望大家用的紧窄点。老子曰:“少则得、多则惑”。