D 语言字符串的故事

    背景知识:了解字符编码的基础知识   

    实在受不了 Andrei 讲故事的能力,俺决定按照自己的思路来诠释 D 语言中的字符串,顺便兑现先前之承诺。本文部分资料来自《The D Programming Language》字符串章节。

    文字处理真是太重要了,以至于大多数高级编程语言都会特别对待之。D 语言亦不例外。在步入正题之前,咱们先掰扯一下文字处理的背景。

    很久以前,大多数计算机主要说英语。为了方便交流,ANSI 还制订了一个通用的字符集。这个字符集包括了大小写英文字母、阿拉伯数字、标点符号、控制字符等 128 个字符,且和 128 个数字做了一一映射。这就是著名的 ASCII 。如图1所示:

ASCII

    图1 ASCII 码表

    在以二进制为基础的计算机环境中,128 个字符只需要 1 个字节的 7 位即可表示。这样就产生了 7 位的编码格式。

    后来,计算机的使用越来越广泛。为此,它必须支持更多的语言。只要看一眼图1,你就会明白 ASCII 编码格式根本没法表示除英语之外的大多数语言。为了突破这个限制,人们开始在 ASCII 编码第 8 位(保留位)上动脑筋。由此,噩梦开始了。

    出于兼容 ASCII 的考虑,人们把编码值 32~127 (即 0x20~0x7F) 之间的字符集保持不变。而在编码值 128~255 (即 0x80~0xFF) 之间新增了 128 个扩展字符。随之再赋以不同的代码页。这样这组扩展字符集就是唯一的了。人们按照这种方式创建了几种语言。比如基于拉丁文的西欧语言,希腊语,阿拉伯语,土耳其语等。问题是老家伙们精力过剩,居然为同一个字符集创建了多个不同的编码格式!光西欧国家的拉丁文字就有以下种种编码格式:

  1. 1.主要用在 Windows 操作系统中的 Windows 1252 编码格式
  2. 2.用于 MS-DOS 的 OEM-850 编码格式
  3. 3.ISO 标准 ISO 8859-1 编码格式
  4. 4.EBCDIC 的几种格式,如 EBCDIC-US 编码格式
  5. 5.苹果公司使用的 MacRoman 编码格式

    这样一来,迸发了很多问题。首先,当你运行的程序所在的系统代码页和期望的代码页不一致时,将严重危害程序的行为。你希望的文字会被当成另外完全不一样的文字来表现和处理。因此,程序显现出来的结果往往是乱码。例如,泰语代码页 874 中编码值范围为 0x80~0xFF 的扩展字符和 Windows 1252 代码页中同一范围内的字符完全不同。其次,很多运行时中的字符串处理函数很可能以英语为中心。例如下面的示例用来把小写字母转变成大写字母的函数就只适用于英语(D 语言版):

1:  auto toUpper(char ch)
2:  {
3:      return (ch <= 'Z' ? ch : ch + 'A' - 'a');
4:  }

     这段代码假定的大小写字母之间的关系不适用于重音字符。同样,像阿拉伯语言,希伯来语言和东亚语言就没有大小写的概念。

    最重要的问题是像这种 8 位编码的单字节字符集(SBCS)根本不能用来编码东亚字符。因为这些字符的数量远远超出了 8 位编码格式的极限 256 个字符。于是,多字节字符集(MBCS)应运而生。

    多字节字符集拓宽了编码范围,以用于东亚语言。例如,中文字符就有 10000 多个基本字符。常用的解决方案是使用两个字节来编码大多数字符。为什么是“大多数”呢?因为 ASCII 字符集和日语片假名音节表等字符集中的字符仍使用单字节来表示。这就产生了混合单字节和双字节字符的代码页。在 UNIX 等系统上,甚至用 1~4 个字节来表示字符。

    但是,MBCS 引入了新的问题。人们在处理 MBCS 时,不得不考虑以下事情:

  1. 1.总是要把首字节和它的尾字节当做一个单元来处理。单独的首字节或者尾字节不足以标识字符。
  2. 2.各个代码页的首字节和尾字节范围各不相同。
  3. 3.确保你的字符串分析程序考虑了 SBCS 和 MBCS 混合的情况。
  4. 4.在处理 MBCS 时,注意插入符位置和光标移动。

    由于 SBCS 和 MBCS 的局限性和处理 MBCS 字符串潜在的复杂性,IT 届的几位大佬共同开发了 Unicode 字符集。

    Unicode 最初起源于 Xerox 和 Apple 之间的合作研究。随后,IBM 和 Microsoft 迅速加入。随着 W3C 和业界诸多大佬们的推动,Unicode 现在已经成为业界事实上的字符编码标准。而 ISO 10646 则是被所有 ISO 成员国认可的,全球性的法定标准。这两者包含同样的字符表和二进制表示,本是同根生。

    Unicode 包含了现在计算机中使用的所有字符。它至少可以处理 110 万个代码点(Code Point)。同时提供了 8 位,16 位,32 位编码格式。 16 位编码是它的缺省编码。而且 Unicode 把这上百万的代码点分布在 17 个平面中。每个平面都可以表示 65000 多个字符。第 0 平面(即 BMP - 基本多文种平面)中的字符用来表示世界上大多数的文字,如图2所示,它以抽象的形式显示了 BMP 的 Unicode 编码布局。完整的 Unicode 编码表请参考http://zh.wikibooks.org/wiki/Unicode

Unicode BMP 图2 Unicode BMP

    接下来,我们就了解下 Unicode 到底有哪些好玩的东东。

    我们先来关注一下 Unicode 字符集的编码格式。这个可能是开发人员处理文字时最关心的问题。有多种不同的二进制格式都可以用来表示 Unicode 中唯一的代码点,Unicode 技术委员会公布了三种编码格式标准:UTF-8,UTF-16,UTF-32。

    UTF-8 是一种针对 Unicode 字符集的可变长度字符编码。它可以用来表示 Unicode 标准中的任何字符,且其编码中的第一个字节仍与 ASCII 兼容。这使得原来处理 ASCII 字符的软件无须或者只须稍作修改,即可继续使用。因此,它逐渐成为电子邮件,网页等 Web 应用或 Internet 协议传输中,优先采用的编码方案。

    UTF-8 使用 1~4 个字节为每个字符编码:

  • 1.128 个 ASCII 字符只需一个字节编码(Unicode 范围在 U+0000~U+007F 间)。
  • 2.带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要两个字节编码(Unicode 范围在 U+0080~U+07FF 间)。
  • 3.其它 BMP 中的字符(这包含了大部分常用字)使用三个字节编码。
  • 4.其它极少使用的 Unicode 辅助平面字符使用四字节编码。

    为了有效的分析字符串,UTF-8 编码使用第一个字节指明某个多字节序列中的字符数。对于 UTF-8 编码中的任意字节 B ,如果 B 的第一位为 0 ,则 B 为 ASCII 字符,且 B 独立的表示一个字符;如果 B 的第一位为 1 ,第二位为 0 ,则 B 为非 ASCII 字符(该字符有多个字节表示)中的一个字节,且不为字符的第一个字节编码;如果 B 的前两位为 1 ,第三位为 0 ,则 B 为非 ASCII 字符(该字符有多个字节表示)中的第一个字节,且该字符由两个字节表示;如果 B 的前三位为 1 ,第四位为 0 ,则 B 为非 ASCII 字符(该字符有多个字节表示)中的第一个字节,且该字符由三个字节表示;如果 B 的前四位为 1 ,第五位为 0 ,则 B 为非 ASCII 字符(该字符有多个字节表示)中的第一个字节,且该字符由四个字节表示。因此,对于 UTF-8 编码的任意字节,根据第一位可判断是否为 ASCII 字符;根据前二位可判断该字节是否为一个字符编码的第一个字节;根据前四位(如果前两位均为1),可确定该字节为字符编码的第一个字节,并且可判断对应的字符由几个字节表示;根据前五位(如果前四位均为1),可判断编码是否有错误或数据传输过程中是否有错误。Unicode 和 UTF-8 之间的转换关系如图3所示:

UTF-8

 

图3 Unicode 和 UTF-8 转换关系表

    UTF-8 的这些特质,保证了一个字符的字节序列不会包含在另一个字符的字节序列中。这确保了以字节为基础的子字符串匹配方法可以适用于在文字中搜索字或词。有些比较旧的可变长度 8 位编码(如 Shift JIS )没有这个特质,故字符串匹配的算法变得相当复杂。虽然这增加了 UTF-8 编码的字符串的信息冗余,但是利多于弊。

    另一方面,由于其字节序列设计,如果一个疑似为字符串的序列被验证为 UTF-8 编码,那么我们可以有把握地说它是 UTF-8 字符串。一段两字节随机序列碰巧为合法的 UTF-8 而非ASCII 的机率为 1/32 。对于三字节序列的机率为 1/256 ,对更长的序列的机率就更低了。

    虽说 UTF-8 优点不少,但缺点亦不少。比如不利于正则表达式检索,与其它 Unicode 编码方案相比(特别是 UTF-16),ASCII 字符占用的空间少了一半,但是像中文、日文、韩文(CJK)这样的象形文字,UTF-8 编码占用的空间就多出不少。

    UTF-16 是 Unicode 字符集的 16 位编码格式。它把在 BMP 内定义的字符使用 2 个字节表示。而在此之外的字符则使用 4 个字节表示。由于在 BMP 内,从 0xD8 到 0xDF 之间的区段没有使用,因此可以利用此区间内的值来对辅助平面的字符进行编码。具体的编码方法如下:

  • 1.如果字符编码 U 小于 0x10000(即十进制 0~65535),直接使用两个字节表示。
  • 2.如果字符编码 U 大于 0x10000,由于 Unicode 编码范围最大为 0x10FFFF,从 0x10000 到 0x10FFFF 之间 共有 0xFFFFF 个编码,即只需要 20 位就可以标识这些编码。用 U' 表示从 0~0xFFFFF 之间的值,将其前 10 位作为低位和 16 位的数值 0XD800 进行或操作,将后 10 位作为低位和 0XDC 做或操作,这样组成的 4个字节就构成了 U 的编码。如图4所示:

UTF-16

图4 UTF-16 编码表

    UTF-16 比起 UTF-8 的好处在于大部分的字符都以固定长度(2字节)存储,但 UTF-16 却无法兼容 ASCII 编码。此外,我们还需要注意 UTF-16 Little Endian(UTF-16LE) 和 UTF-16 Big Endian(UTF-16BE) 的区别。一般来说,Mac 操作系统使用 UTF-16BE 格式,而 Windows、Linux 则使用 UTF-16LE。

    人们为了弄清楚 UTF-16 的大小尾序,在 UTF-16 文件的开首,都会放置一个 U+FEFF 字符作为Byte Order Mark(BOM)(UTF-16LE 以 FF FE 代表,UTF-16BE 以 FE FF 代表),以显示这个文字档案是以 UTF-16 编码,其中 U+FEFF 字符在 Unicode 中代表的意义是 ZERO WIDTH NO-BREAK SPACE,顾名思义,它是个没有宽度也没有断字的空白。

    让我们来看一个 UTF-16 编码的例子。例子有三个字符:“朱”(U+6731)、半角逗号 (U+002C)、“聿”(U+807F),它们的 UTF-16 编码如图5所示:

UTF-16-1 图5 UTF-16 编码示例

    还有一个让人容易迷惑的问题就是 UCS-2 和 UTF-16 。实际上,UTF-16 可以看成是 UCS-2 的超集。在没有辅助平面字符 Mapping of Unicode character planes(surrogate code points) 前,UTF-16 与 UCS-2 所指的是同一的意思。但当引入辅助平面字符后,就称为 UTF-16 了。现在若有软件声称自己支持 UCS-2 编码,其实是暗指它不能支持在 UTF-16 中超过两字节的字集。对于小于 0x10000 的 UCS 码,UTF-16 编码就等于 UCS-2 编码。更多关于代理对的信息,请参考 http://www.Unicode.org

    Unicode 编码格式中最简单的是 UTF-32 ,它使用 4 个字节来表示一个字符。

    OK,以上就是关于文字处理的背景知识。接下来,我们来了解一下 D 语言是如何支持文字处理的。

    D 语言天生支持 Unicode 字符集。在 D 语言中定义了三种字符类型来一一对应 UTF-8、UTF-16 和 UTF-32 代码单元,分别是 char 、wchar 、dchar 类型。但它们的初始值却不是合法的编码值:char.init 是 0xFF ,wchar.init 是 0xFFFF ,dchar.init 是 0x0000FFFF 。0xFF 不是合法的 UTF-8 编码,而 Unicode 亦故意没有给 0xFFFF 分配合法的代码点。

    如果我们单独使用字符类型的话,这三个类型的行为反而比较像无符号整数,间或偶尔可以存储一下合法的 UTF 代码点(编译器并不会强制进行合法的编码)。但是有意使用其语义的话,char、wchar、dchar 则会被当做合法的 UTF 代码点。如果想使用通用的 8 位,16 位,32 位无符号整数或者不是 UTF 的其它编码,最好分别使用 ubyte、ushort、uint 类型。

    当使用字符数组(即 char[]、wchar[]、dchar[])时,D 语言编译器和运行时库都会明白你正在和 UTF 编码格式的 Unicode 字符串打交道。因此,字符数组不但具有普通数组的强大能力,还附带了 Unicode 的一些额外好处。

    事实上,D 语言已经定义了三个字符串类型:string、wstring 和 dstring。它们实际上并不是特别的类型,只不过是字符数组类型的别名。此外,为了防止任意改变字符串中的单个字符,D 语言还在字符数组类型的前面加了一个修饰符 – immutable 。例如,string 类型就是比较啰嗦的 immutable(char)[] 类型的同义词。在 D 语言中,字符串中的字符是不能再次重新赋值的。如下所示:

1:  string str = "hello";
2:  char ch = str[0];    //Fine
3:   str[0] = 'H';         //Error! str[0] isn't mutable.
4:                      //Can't assign to immutable(char)!

     想要改变字符串中的单个字符的话,你只能通过字符串合并的方式创建另一个字符串。

1:  string str = "hello";
2:  str = 'H' ~ str[1..$];    //Fine, makes str == "Hello"

     为什么 D 语言会做出这样的决定?毕竟相对于修改原有的字符串来说,重新分配字符串是一种浪费。尽管如此,却有几大理由来促使 D 语言禁止在字符串中修改单个字符。一大理由是 immutable 简化了 string、wstring、dstring 对象复制然后修改的步骤,且有效地避免了字符串的滥用。考虑以下示例:

1:  string a = "hello";
2:  string b = a; // b is also "hello"
3:   string c = b[0 .. 4]; // c is "hell"
4:  // If this were allowed, it would change a, b, and c
5:  // a[0] = 'H';
6:  // The concatenation below leaves b and c unmodified
7:   a = 'H' ~ a[1 .. $];
8:  assert(a == "Hello" && b == "hello" && c == "hell");

     带有 immutable 修饰之后,你就会明白虽然同一个字符串内容有好几个引用变量,却不必担心修改其中一个会引发其余字符串的变动。复制字符串对象非常廉价,因为它不需要特别的复制管理(例如热拷贝和 Copy on Write)。

    另外一个更好的理由是这样改变过渡很平滑,不会让人有突兀感。字符串元素是可变长度的,大多数时候,你只是想要替换它的逻辑字符(代码点),而不是物理字符(代码单元),所以你很少想对单个字符做“外科手术”。如果放弃修改单个字符,而是针对整个字符串或者其中的片段,那么将更容易写出正确的 UTF 编码。说老实话,UTF 编码并不容易。拿上面的代码

1:  a = 'H' ~ a[1 .. $];

来说,在通常情况下,这样做就会有一个 BUG 。因为,它假设 a 中第一个代码点只有一个字节。正确的代码应当是这句:

1:  a = 'H' ~ a[stride(a,0) .. $];

    stride 函数在标准库 std.utf 模板中,它返回字符串中特定位置代码的长度。在 D 语言中,调用 stride(a, 0)就会返回字符串 a 中第一个字符的编码长度,并且供我们选择标记第二个字符起始位置的偏移量。

    语言直接支持 Unicode 的威力还可以从字符串常量显现出来。D 语言字符串常量了解 Unicode 代码点,且能够自动他它们编码成合适的编码格式。例如:

1:  import std.stdio;
2:  void main() 
3:  {
4:      string a = "No matter how you put it, a \u03bb costs \u20AC20.";
5:      wstring b = "No matter how you put it, a \u03bb costs \u20AC20.";
6:      dstring c = "No matter how you put it, a \u03bb costs \u20AC20.";
7:      writeln(a, '\n', b, '\n', c);
8:  }

    尽管 a,b,c 的内部编码表示完全不同,但我们并不需要担心程序的结果会走样。因为编译器会对这些字符串常量进行合适的编码,以保证它们的结果都是 String

    这样,我们就不必在运行时对这些字符串进行 UTF-8,UTF-16,UTF-32 编码了。即使编译时有些字符串常量有歧义,我们也可以使用 ‘c’、‘w’、‘d’ 这样的后缀来明确表达 UTF-8,UTF-16,UTF-32 编码格式。

    再来说一下字符串的连接操作。因为字符串最终要表示成 Unicode 代码点序列,所以 D 语言在这上面做了不少文章。你可以使用 ~ 或 ~= 连接一个任意长度字符串和另外一个任意长度的字符串或者字符。

1:  string a = "Hall\u00E5";
2:  wstring b = ", ";
3:  dstring c = "v\u00E4rd";
4:  auto d = b ~ c; // d has type wstring, same as b
5:  a ~= d ~ '!'; // concatenate string with character
6:  writeln(a);
7:   

     上面代码的结果是瑞典语的 “Hello world !”:Hello 。注意:上述代码压根不能在现有的 DMD 编译器上编译成功!

 

     此外,连接两个字符是不被允许的。而 ~= 操作符和 ~ 操作符类似。比如 a ~= b 结果等价于 a = a ~ b ,但是性能可能要比后者高一些。因为它可能只是扩展 a 而不是修改它。

    D 语言的字符串迭代和普通数组的迭代是一样,不论它是否有 immutable 修饰。但有时候可能期望的结果不尽如人意。比如:

 1:  void main() 
 2:  {
 3:      string str = "Hall\u00E5, v\u00E4rd!";
 4:      foreach (c; str) 
 5:      {
 6:          write('[', c, ']');
 7:      }
 8:      writeln();
 9:  }
10:   

     上述代码打印的结果就很不优雅:R1

    之所以会显示“?”(不同操作系统或许显示会有不同,取决于操作系统和使用的字体),这主要和控制台的设置以及代码页有关。但如果我们把代码写成这样:

1:  foreach (dchar c; str) 
2:  {
3:      write('[', c, ']');
4:  }
5:   

 

结果就会显示正常:R2 。所以,最佳方案是在迭代的时候把字符类型改写成 dchar 。因为编译器会在这种场景下做出正确的选择。注意:不知道 Andrei 老大有没有做过测试,我在 Windows 7 简体中文专业版 + DMD 2.041 上显示的仍然是乱码。大家可以自己做一下试验。

    OK,关于 D 语言字符串就到这里了。嗯,就到这里了。

posted @ 2010-03-20 12:05 Angel Lucifer 阅读(...) 评论(...) 编辑 收藏