5.1 中文支持策略和基础知识

在第4章中提到的crowbar是不支持中文的。一个标榜着像Perl一样的语言,怎么可以不能正确处理含有本地语的中文文本文件呢?本章开始,我们就来看一看汉化的处理方式。

5.1.1 现存问题

看了“crowbar是不支持中文的”这句话后,肯定有人会问:“这是真的吗?”举个例子吧。

print(“你好\n”);

这行代码在大多数环境下应该可以正常运行。但crowbar处理器其实并没有意识到字符编码的问题,只不过是直接输出了一个含有字符串常量的字节序列,根本不能说它支持中文。具体有以下这些问题。

1. GB2312环境下的0x5C问题

现在的crowbar代码是采用GB2312编码保存的,因此可能会因为特殊字符而引起误动作。具体来说就像中文的“沑”和“昞”。

比如,“昞”的GB2312编码是 0x955C 。第二个字节的5C,是反斜杠的编码,这会导致字符串常量“昞”被编码成“"”和“\”组成的字符串。这里只是在说字符串常量的话题,crowbar程序在读取来自外部文件和标准输入的字符串时不会受到影响。

2. length()函数

我们在crowbar book_ver.0.2 中为字符串引入了 length() 函数。现在的这个函数在执行代码"北京欢迎您".length()时会返回10。这种设计与C的 strlen()相同,但这样的设计在crowbar中几乎派不上用场。“北京欢迎您”是5个字,调用 length() 函数就应该返回5 才对(在C 语言中,很多情况下要有很强的“字节数”意识,因此 strlen()的设计不得不说还是挺方便的)。

现在的crowbar中,字符串只有 length()函数,今后还要引入截取字符串的 substr()等函数,在那个时候这个问题就更为重要了。

既然现在crowbar的实现有以上的问题,那怎么解决好呢?想要考虑清楚这个问题,需要很多基础知识。下面我们就对以下几项进行简单说明。

5.1.2 宽字符(双字节)串和多字节字符串

宽字符(wide character)和多字节字符(multibyte character)的说法源于C语言的用语。

多数人在编写 C 语言程序的时候使用 char 数组来表示字符串。可是, char只能存储1个字节(通常是8位),存不下一个中文的字符。因此,中文字符的存储都是使用GB2312(EUC,即扩展UNIX编码,Expanded UNIX code) [1]、GBK、UTF-8等 编码 。这种用多个字节保存一个字符的字符串形式称为 多字节字符串 。

但是,这种保存方式存在一个问题,即不从 char数组的开头开始看,就找不到哪里是字符的分割点。C语言使用 str[i]获取字符串中一个字符时,也不知道要取的是一个英文数字字符还是中文字符的第一个字节或第二个字节。在这种情况下,如果要制作一个编辑器,在按退格(backspace)键的时候,如果只是删除了中文字符的第二个字节,那么后面跟着的内容就全都变成乱码了。实际上,以前的很多编辑器都有这个问题。

GB2312的 0x5C问题大概也是这个原因。如果只看一个中文字符的第二个字节,就会被误认为是“\”。

只保存8位的 char类型显然不能满足需求,要是有一个内存空间足够的类型用来保存中文等字符就好了。C语言中的 wchar_t类型,就是一个有足够内存空间的类型(或者说是我们期望的类型)。

以 wchar_t 数组表示一个字符串的方式叫作 宽字符串 。正如我们期待的, str[i]可以取出这个字符串中第i+1个字符 [2]

在C语言的标准中,并没有规定 wchar_t到底是几个字节,以及 wchar_t 在存储文字时用什么编码格式。在基于UNIX的gcc中执行 sizeof(wchar_t) 的话返回4,在Windows(MinGW的gcc或VC++等)中返回2。另外,在C代码中如果想要定义宽字符和宽字符串的话,宽字符使用 L'a',宽字符串使用 L"abc"。

宽字符串 L"abc"在 wchar_t是4字节的环境中,需要消耗16字节的内存空间(最后的Null字符也是4个字节)。我们暂且不说这种存储方式进行类型转换的时候会发生编译错误,存储在内存上的 L"abc",以字节为单位去看的话中间可能会出现很多0,会被误判为字符串末尾的Null字符。因此,宽字符串不能使用 strcpy()和 strcmp()之类的函数,必须要用 wcscpy()代替 strcpy(),用 wcscmp()代替 strcmp()。

补充知识 wchar_t肯定能表示1个字符吗?

我们在5.1.2节中说到, wchar_t类型有足够的内存空间保存中文等字符(或者说是我们想要的类型)。可能会有人不满意这种模棱两可的说法,那么就让我们来确认一下C语言标准中的定义吧。

在IOS C99(ISO/9899:1999)中关于wchar_t的最小内存空间记载如下(7.18.3):

wchar_t(参考7.17节)用带符号整数类型定义的情况下, WCHAR_MIN 的值不得小于-127, WCHAR_MAX的值不得大于127。 

wchar_t 用无符号整数类型定义的情况下, WCHAR_MIN 的值必须是0, WCHAR_MAX的值不得大于255。

WCHAR_MIN和 WCHAR_MAX,顾名思义是 wchar_t最大值和最小值的常量。总之,在标准中保证了 wchar_t 的大小只有 1个字节,但是从 wchar_t 的定义本身来看,规定如下:

wchar_t值的范围要能够容纳处理器所支持的区域设置中最大的扩展字符集(包含全部编码要素的整数类型)。

至少在我看来这句话的意思是, wchar_t的大小必须能存下所支持字符集的任何一个字符。

但是,实际上Windows的 wchar_t只有两个字节,所以不能表示超过UCS2范围的字符。因此,至少在 Windows 中, wchar_t 类型不能表示一个字符(实际上Windows的宽字符串是UTF-16)。可见世上没有最理想的事。

5.1.3 多字节字符/宽字符之间的转换函数群

字符的表示方法有多字节字符和宽字符两种,具体内容在前面的小节已经介绍过了,相信大家也应该清楚了它们的使用方法。

宽字符串把字符保存在 wchar_t中,因此前面说到的制作编辑器、为字符串添加 length()和 substr()函数都不成问题。可是,像英文数字这种平时只需要1个字节表示的字符,在这里也需要占用 sizeof(wchar_t) 大小的内存空间了。

因此,在保存文件的时候,不推荐使用宽字符形式。宽字符是C语言的概念,显而易见文件和编程语言是独立的。之前,“用同样的字节数来表示所有的字符”这种想法,在编程时处理字符串是很方便的,但是作为文件处理方式的时候就不再占据优势了,反而只会单纯地占用容量 [3]。于是,crowbar的处理器有必要进行如下的变化。

从文件和标准输入中输入字符串时,从多字节字符转换为宽字符。

向文件和标准输出中输出字符串时,从宽字符转换为多字节字符。

这样一来,现在的状态就成了“在crowbar外部使用多字节字符,内部使用宽字符”。在C语言中可以使用以下的函数群进行转换操作。这些函数是以ISO C95标准进行了标准化的函数群,这些函数不仅名字具有标准性,十分好记,详细的设计也编成了手册可在线阅读。在这里,我只对它们的基本功能进行说明。

多字节字符向宽字符转换

int mbtowc(wchar_t pwc, const char s, size_t n);

从多字节字符的字节序列中读取出代表一个字符的字节(最大n字节),将转换后的宽字符的指针保存在变量 pwc中。功能和名字一样, mb(多字节字符)转换为 wc(宽字符)。

size_t mbrtowc(wchar_t pwc, const char s, size_t n, mbstate_t *ps)

在C语言的规格书中将多字节字符定义(5.2.1.2)为“可以携带依赖于转换状态的表现形式”。

利用 换码序列 (escape sequence)这种特殊的字节序列进行编码间的切换,当文字中出现了换码序列时,它之后的内容就是中文,而在中文中换码序列之后的内容就是ASCII字符。

用这种方式将某个多字节字符转换为宽字符时,必须要知道它现在是什么状态。

如果使用 mbtowc() 从开头对多字节字符串进行处理的话,那么mbtowc()会在内部记住当前的状态,并且根据状态转换出适当的宽字符。可能很多人会觉得这样就已经很好了。但是,如果这段使用了 mbtowc()的转换程序用来进行一个多线程的字符串处理时, mbtowc()的这种“记状态”的机制就被破坏了。

于是 mbrtowc()将保存当前状态的内存空间交给了调用者。这个空间的类型是 mbstate_t。在最初调用的时候, mbstate_t可以使用 memset()等函数进行清零。

函数的名字是 mbrtowc(),中间加了一个 r。我想这个 r可能是reentrant的 r。

size_t mbstowcs(wchar_t dest, const char src, size_t len)

上述两个函数是用来转换文字的,这个函数可以将整个字符串一起处理。于是函数名字在 mb和 wc后面都加上了 s。

函数将多字节字符串 src转换为宽字符串,字符最大为 n字节,结果保存在 dest 指针中。函数的返回值会返回写入到 dest 的宽字符个数(不包含结尾的 L'\0'),如果只想知道宽字符的字符个数的话,经常使用的方法是给 dest传 NULL并获取返回值。

size_t mbsrtowcs(wchar_t dest, const char **src, size_t len, mbstate_t ps) 

和转换单个字符的函数一样,加上 r 就变成了reentrant 版。 src 变成了指针的指针,是因为要在转换过程中将 src 移动到下一个要转换的多字节的开头 [4]

宽字符向多字节字符转换

int wctomb(char *s, wchar_t wc)

可见,这是反过来将 wc转换为 mb的函数,也就是将一个宽字符转换为多字节字符的字节序列的函数。

size_t wcrtomb(char s, wchar_t wc, mbstate_t ps)

加上 r就是reentrant版。

size_t wcstomb(char dest, const wchar_t src, size_t n)

加上 s就是字符串版。

size_t wcsrtombs(char dest, const wchar_t **src, size_t len, mbstate_t ps)

s和 r都加上,变成了字符串版的reentrant版。

为了能够使用这些函数,在Windows(MinGW)中编译时必须配置启动项 -lmsvcp60。