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()等输出函数的时候,需要由宽字符串转换为多字节字符串。 

  1. 因为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]. 在存储容量的价格方面,考虑到内存要比硬盘贵,节约内存空间也是理所当然的。

[4]. 这样做是为了对应多线程的情况。——译者注

[5]. 有时候,三个国家使用的汉字即使字形相同也是不一样的,因此分配了不同的码位。——译者注

[6]. 1987年的时候,韩国国内的标准KSC 5601修订版中韩语只有2350个字符,同一时期的Unicode已经包含了这些字符,如果是日常使用的话,16位的Unicode也没什么问题。

[7]. 因此从逻辑上来讲不再需要BOM了,但还是有附加了BOM的编辑器和没有BOM就不能正常运行的应用存在。

[8]. 只是处理了不同的语种,但不是按国家划分的编码。——译者注