5.3 crowbar book_ver.0.3的实现
本节将要说明如何在crowbar_ver.0.3中实现(实现到什么程度,怎么实现)对中文的支持。
5.3.1 要实现到什么程度?
crowbar对中文(或者说国际化)的支持应该到什么程度呢?这个“程度”包含了多方面的含义。首先,到目前为止,crowbar的变量或者函数名之类的标识符只支持字母。
Java等语言可以使用汉字命名变量,但我想很少有人会用到(这只是因为习惯的问题,其实用汉字来命名变量,代码可读性没准会有飞跃性的提高),这个功能即使支持了中文也没人会用到。另外,如果要让标识符支持汉字,就要决定是否允许变量名里面包含全角空格。因为比较麻烦,这里就先跳过了。
另外还有一个问题就是,要支持一个什么样的字符集?
姑且以宽字符(wchar_t)为一个字符来处理。使用5.1.3 节中介绍过的 mbtowc()系列函数将多字节字符转换为宽字符。
在Linux 中使用 sizeof(wchar_t) 返回4,但在Windows 中返回2。因此,宽字符可以正常处理ASCII字符和普通的中文,但是超过了UCS2范围的字符(在UTF-16中被组成代理对的字符)就不能直接处理了。A方言这样的合成字符也是不能处理的(使用兼容Latin-1的U+00C4来表示就另当别论了)。当然并不是完全不能表示,如果使用字符串的 length() 函数获取字符串的长度的话,会得到和实际长度不同的结果。
这样一来,也不能说是完全支持了Unicode,但是对于大多数人来说,暂且让我可以正常地使用中文和英语就可以了。我想比起花很多时间来追求完美的支持,把大多数人觉得“可以”的范围赶快做出来,是更好的选择。
以下两种编码方式为用于输入输出的多字节的字符编码方式。
GB2312
UTF-8
先假设crowbar处理器中的C代码和用crowbar书写的代码以及 fgets()等读取输入输出文件的函数,全部使用了统一的编码方式。
5.3.2 发起转换的时机
在crowbar中,以下这些情况需要由多字节字符转换为宽字符,或者由宽字符转换为多字节字符。
1. 用crowbar书写的代码中的字符串常量在编译时需要转换为宽字符串。
2. fgets()函数读取的字符串需要由多字节字符串转换为宽字符串。
3. 调用 print()、 fputs()等输出函数的时候,需要由宽字符串转换为多字节字符串。
- 因为C代码中使用GB2312(EUC-CN)嵌入错误信息,所以在组装错误信息时需要转换为宽字符串(信息在显示的时候,需要根据规则3再次进行转换)。
5. 在接收命令行参数 ARGS的时候,需要转换为宽字符串。
5.3.3 关于区域设置
在5.3.1节中说道:
先假设crowbar的处理器中的C代码和用crowbar书写的代码以及 fgets()等读取输入输出文件的函数,全部使用了统一的编码方式。
可是,编码方式究竟是什么呢?简单地说,就是环境默认的字符编码。Windows是GB2312,Linux是EUC-CN或者UTF-8。UNIX可以根据环境变量 LANG进行切换。
那么,实际上使用 mbtowc()系列函数将多字节字符串转换为宽字符串,想要为转换函数群指定默认区域设置必须调用下面的函数。
setlocale(LC_CTYPE, "")
在crowbar中,需要在 main()函数中执行上面的语句。
setlocale()函数的详细设计在这里不再赘述,请参考(C语言标准库的)手册等资料。
5.3.4 解决0x5C问题
在5.1.1节中提到,当前的crowbar由于运行在GB2312环境下,字符串常量中不能使用例如“昞”这样的字符。
即使可以使用 mbtowc()系列函数正确地处理GB2312,还必须在一开始就用lex解释字符串常量,这还不算完,为了解决这个问题还要在crowbar.l中添加代码。
GB2312的汉字,第1个字节定义在0xA1-0xF7之间,第2个字节定义在0xA1-0xFE之间。因此,如果只支持GB2312的话,要在crowbar.l中添加下面4行代码。
(之前省略)
<STRING_LITERAL_STATE>[\xa1-\xf7][\xa1-\xfe] {
crb_add_string_literal(yytext[0]);
crb_add_string_literal(yytext[1]);
}
(之后省略)
为了在编译时能区分源文件的编码,在解释器中保存一个标识。
/ 保存编码方式的枚举类型 /
typeof enum {
GB_2312_ENCODING=1, / GB2312 /
UTF_8_ENCODING / UTF-8 /
} Encoding;
struct CRB_Interpreter_tag {
(中间省略)
/* 在CRB_Interpreter结构体中保存
crowbar代码的编码方式 */
Encoding source_encoding;
};
在此基础上,在lex 的启动条件中添加 GB_2312_2ND_CHAR(GB2312 的第2个字节),代码在读取到了GB2312的第1个字节时跳转到 GB_2312_2ND_CHAR执行。
<STRING_LITERAL_STATE>. {
/ 从解释器中取得编码方式 /
Encoding enc = crb_get_current_interpreter()->source_encoding;
/ 先将字符添加到字符串常量中 /
crb_add_string_literal(yytext[0]);
/* 如果代码运行在GB2312环境下,
再判断这个字符,如果是GB2312的第1个字节,
跳转到GB_2312_2ND_CHAR执行 */
if (enc == GB_2312_ENCODING
&& ((unsigned char*)yytext)[0] >= 0xa1)
&& ((unsigned char*)yytext)[0] <= 0xf7)) {
BEGIN GB_2312_2ND_CHAR;
}
}
<GB_2312_2ND_CHAR>. {
/ 添加GB2312的第2个字节 /
crb_add_string_literal(yytext[0]);
BEGIN STRING_LITERAL_STATE;
}
增加 CRB_Interpreter的 source_encoding成员,是因为在创建 CRB_Interpreter 的内存空间时,不能使用 #ifdef 进行分割(请参考interface.c的函数 CRB_Interpreter())。
补充知识 失败的#ifdef
如前面所述,在执行解释器的处理器中,用 #ifdef来切换语言的(默认)设定(GB2312、GBK或者是UTF-8)。在最初的crowbar中,就是使用 #ifdef进行对应处理器的切换。
在一些C的入门书中都有这样一句话:为了提高移植性而适当地使用 #ifdef。以我的理解,“适当地使用”其实就是“尽量别用”的意思。因此,这次我(在处理器切换时)使用了 #ifdef,这对我来说也是一次失败。
根据处理器不同而使用 #ifdef选择不同代码片段的话,会使代码变得很难理解。另外,像这样分散的代码通常很难进行充分的测试。在理想状态下,所有 #ifdef的组合可以伴随着每日构建进行自动化测试,这感觉还不错,但是我认为这在实际中很难实现。
如果是为了提高移植性,那么也可以不使用 #ifdef来处理各种分支,只要写一个尽可能适应各种处理器的代码不是就行了吗?
编程方面的著作《程序设计实践》[5]中有以下记载。
如果我们对于条件编译持否定态度,那么就会由此发生一些问题。先不说最麻烦的。条件编译基本上都不可能进行测试。(中间省略)在对其中一个 #ifdef代码块进行测试的同时,如果想测试另外的 #ifdef代码块,除非改变环境使另一个 #ifdef代码块生效,否则无法进行验证。
(中间省略)
由此我们得知,让我们感兴趣的是,在所有目标环境中都可以运行的共通性功能。
5.3.5 应该是什么样子
5.3.1节决定了crowbar不处理合成字符和UCS2范围以外的字符。
如果只是为了对应中文的话,这样的设计(指5.3.1节中提到的设计方式)就没问题了。但如果想要完美地实现,恐怕就需要考虑以下几点(以Unicode为前提)。
1. 内部表现也要使用UTF-8
如果考虑合成字符的话,就不可能让字符有固定长度。如果想要取得字符串的第n个字符,每次都必须从字符串的开头扫描,所以还是算了吧。
2. 不使用 mbtowc() 系列函数,自己实现全部的转换
如果自己保存转码表,就要根据不同的情况使用不同的转码表。比如,在需要和Java兼容的时候要使用Java的转码表,如果要在Windows对话框中显示一个字符串的时候又要使用Windows的转码表等。
mbtowc()系列函数不仅意味着“在所有的处理器中,总是可以返回所期望结果”,还表示“如果自己保存转码表的话,所有转换都要自己进行”。
作为一个还算现实的做法(只要能处理好中文就可以了),我制作的这个语法处理器,正好解决了所有的问题。如果一味追求结果而不能实现也是没有意义的。
补充知识 还可以是别的样子——Code Set Independent
在5.3.5节中介绍了自己保存转码表和将内部编码变为UTF-8这两种方法。
在UTF-8这种方法中,首先让内部的编码方式使用Unicode,在正常的情况下,不论是从外部输入的字符编码还是向外部输出的字符编码都是Unicode。
除此之外还有另外一种方式,即内部编码不固定。这种方式称为 Code SetIndependent(CSI)。
若将内部编码固定为Unicode,那么在UNIX的EUC环境中,是绝对不可能使用EUC以外的编码方式的。在这种情况下,每次发生读写时都要使用转码表在其中转换。暂且不说效率低下的问题,更重要的是,如果想要表示在Unicode中没有的字符,或者要把在Unicode中认为是一样的字符当做不同的字符处理,这些情况使用Unicode都是不能处理的。
实际上,Ruby1.9就采用了CSI方式。在Ruby中,每个字符串都会保存着自己的编码方式。
例如在输出文件的情况下,只要Ruby知道转换方法,就可以将要输出的编码方式(外部编码方式)和字符串的编码方式(内部编码方式)进行转换。
具体来说,比如在Ruby支持的编码方式中有Emacs-Mule,这种编码方式没有采用像Unicode一样将中文汉字分配统一编码的方式,它基于ISO-2022为各国(但是没有国籍限制)语言分配了不同的编码 [8]。在处理以这种方式编码(同时存在多种语种的文字)的文件时,如果像crowbar那样限定内部字符编码为Unicode的编码方式,那么在转换为内部表现时就会产生不可逆的(无法恢复到原来状态的)信息丢失。
CSI既有优点也有缺点。
当有N种外部编码方式时,Unicode正常的处理方法是准备输入和输出的编码方式转换器(共2N种)。与此相对,CSI只需要(N-1)种。另外,在程序中进行比较和连接字符串的时候也会受到限制。
现在,为了方便实现而优先将内部编码限定为Unicode,这实际上还是有些问题的,这就是我坚持CSI的原因。
这是一个哲学或者说是价值观的问题。两种方式都有它们的合理性,在使用的时候应该在平衡利弊的基础上再做出决定。
注 释
[1]. EUC-CN是GB2312最常用的表示方法。浏览器编码表上的GB2312,通常都是指EUC-CN表示法。
[2]. 为什么不是取出第i个字符呢?因为C的数组下标从0开始。
[3]. 在存储容量的价格方面,考虑到内存要比硬盘贵,节约内存空间也是理所当然的。
[5]. 有时候,三个国家使用的汉字即使字形相同也是不一样的,因此分配了不同的码位。——译者注
[6]. 1987年的时候,韩国国内的标准KSC 5601修订版中韩语只有2350个字符,同一时期的Unicode已经包含了这些字符,如果是日常使用的话,16位的Unicode也没什么问题。