安道

博客文章

Ruby 对多语言的支持

这是一篇翻译文章,原文链接 http://blog.grayproductions.net/articles/understanding_m17n。原文是一个系列,翻译过来整合成了一篇文章,对文章内容做了适当的变化。因为原文是三年前写的,其中某些代码片段的执行结果和最新版本的 Ruby 相比可能会有所不同。

Ruby 在进入 1.9 版本时发生了重大的变化,以前 Ruby 堪称是对字符编码支持最差的语言之一,而现在变成了支持最好的语言之一,可以处理不同的字符编码。我们都在成长。

而这一变化带来的一个影响就是增加了学习的难度。之所以知道难度有所增加是因为我最近在尝试为标准库中的 CSV 库添加对所有的字符编码支持,我发现实现的过程就是一场战斗。这是一个全新的领域,没有太多的资料可以帮助理解 Ruby 的这个新特性。

我希望改变这种状况。

这篇文章的用意就是说明 Ruby 1.9 对字符编码的支持情况。我假设你对字符编码一无所知,从什么是字符编码、为什么会出现字符编码开始讲起。

然后探索 Ruby 1.8 对字符编码的支持情况,其实没有很多值得探索的东西,但是我希望在对细节探索的过程中可以帮助你理解为什么 Ruby 1.9 做出了改变,以及这个改变是如何实现的。

最后,我们会尽量深入而全面地探索 Ruby 1.9 中引入的字符编码的新特性。我会在一定的理论高度上说明,同时也会介绍我在处理字符编码方面的成功经验,兼具一般性和对 Ruby 的针对性。

下面是本文的目录:

  1. 什么是字符编码
  2. Unicode 字符集和 Unicode 编码
  3. 通用编码策略
  4. Ruby 1.8 中的字节和字符
  5. $KCODE 变量和 jcode 库
  6. 使用 iconv 转换字符编码
  7. Ruby 1.8 中字符编码的缺点
  8. Ruby 1.9 中的字符串
  9. Ruby 1.9 中的三种默认编码类型
  10. 多语言的其他细节
  11. Ruby 1.9 为我们提供了什么

1. 什么是字符编码?

为了理解字符编码,首先需要讨论电脑是如何储存字符数据的。我们都知道,当我们在键盘上按下 a 键后,电脑会在某个地方记录一个 a 字符符号,这是个很神奇的过程。

我认为大多数人都熟知电脑的核心基本上就是一堆数字 0 和 1。那么 a 也就应该是以一个数字的形式存储的。事实确实如此。我们可以在 Ruby 1.8 中查看是哪个数字:

$ ruby -ve 'p ?a'
ruby 1.8.6 (2008-08-11 patchlevel 287) [i686-darwin9.4.0]
97

?a 这个不常用的句法给出的是一个字符,而不是一个完整的字符串。在 Ruby 1.8 中,?a 返回的是字符编码所对应的编码值。通过索引访问字符串中的一个字符也可以得到相同的结果:

$ ruby -ve 'p "a"[0]'
ruby 1.8.6 (2008-08-11 patchlevel 287) [i686-darwin9.4.0]
97

字符串的这种表现使 Ruby 核心开发团队很困扰,所以在 Ruby 1.9 中已经做了改变:返回字符串中的一个字符。如果想在 Ruby 1.9 中查看编码字符可以使用 getbyte()

$ ruby_dev -ve 'p "a".getbyte(0)'
ruby 1.9.0 (2008-10-10 revision 0) [i386-darwin9.5.0]
97

上面的代码虽然告诉了我们怎样得到这个神奇的数字,但是却没有告诉我们这个数字的意义是什么。当决定将字符以数字的形式存储后,人们制定了一个简单的图表,将一些数字映射到特定的字符上。这个映射图表就是著名的 US-ASCII 或者直接叫 ASCII。

现今的 ASCII 表覆盖了你能在英式键盘上找到的所有字符:大、小写字母,数字和一些常见的符号;这个 128 个字符容量的 ASCII 映射表甚至还包括了一些控制字符序列。

很和谐的生活,不是吗?但是⋯⋯

这导致了两个不太和谐的事实:

  • 全世界的字符不可能仅仅包括这些字符
  • 1 个字节只用了其中 7 位,还有 1 位没有使用(所以只产生了 128 个字符)

太好了,还有 1 位空着,这 1 位可以产生另外的 128 个字符,我们也需要更多的字符。这真是个意外的发现啊!几乎每个人都对如何使用这额外的 128 个字符有着很好的想法,所以每个人都按照自己的想法在使用。至此,字符编码就诞生了。

这额外的 128 个字符具体是哪些字符要根据使用者所实现的方式来决定,所以我们说这些字符数据使用了某种编码方式。如要正确的读出数据的内容就必须要知道字符所用的是哪种编码。

举例说明一下,ISO-8859-1(也叫 Latin-1) 编码是一些操作系统、程序甚至是编程语言的默认编码方式,它主要使用欧洲语言中常用的重音符号来填充这额外的 128 个字符。

如果问题只是这额外的 128 个字符的话,可能也不会如此麻烦。很不幸的是,还一个问题:256 个字符仍然无法满足某些语言。既然 1 字节只能产生 256 个字符,那么这些语言就需要编码多个字节,使用多个字节来实现单一的字符。

多字节编码处理起来是很麻烦的,你需要很小心的处理数据的分隔,避免将一个字符的两个字节分开。

日本语就是一个很好的例子。日本语的大多数符号就是文字本身,而不是用来合成文字的,所以常用的就有上千种符号。日本语常用的字符编码之一是 Shift JIS,需要两个字节来实现其中一些字符。

这里只是举了一些很特殊的例子,实际上现在有大量的编码在使用着。你不用在每一个程序中都支持所有的编码方式,实际上也有很好的理由不去这么做。很重要的一点就是要知道世间存在着不同的编码方式,不同的人群使用不同的方式存储数据。这是当今的程序员无法避免而需要面对的问题。

如果你想一下的话应该可以想出一些没有正确使用编码的例子。你是不是怎经在你的邮件客户端或者 shell 中看到一些问号或者很奇怪的框框?通常这都意味着数据没有按照程序期望的方式进行编码,所以导致程序无法正确显示内容。这些就是我们极力避免发生的事情。

本节要点:

  • 不同的人群使用不同的方式存储数据
  • 所有的字符数据都有特定的编码方式告诉你如何处理这些数据
  • 为了正确的处理数据你必须知道数据所采用的编码方式
  • 有些编码方式处理起来很麻烦,比如多字节编码
  • 当程序无法得知字符编码方式时就会显示一大堆很混乱的输出(问号和框框)

2. Unicode 字符集和 Unicode 编码

字符编码方式越来越多,所以需要找到一种大家都可以用的方式。很难保证所有人都认可,不过 Unicode 算是最接近完美的解决方案了。

Unicode 的目标简单的说就是提供一个字符集,包含现在使用的所有字符,也就是所有的字母和数字、所有的象形文字图形和所有的符号。如你所想的,这是一项很艰巨的任务,不过完成的不错。花点时间查看一下 Unicode 标准现在包含的字符吧。Unicode 协会经常提醒我们,这个字符集还有空间可以放下更多的字符,所以如果发现了外星种族文字只需直接添加进去就行了。

为了真正的理解 Unicode 是什么,我需要澄清一个容易混乱的概念:字符集和字符编码不是指同一个事物。Unicode 是一个字符集,但是有不同的字符编码方式。下面解释一下。

字符集是将一堆的符号映射到对应的数字上,这些神奇的数字是电脑用来显示字符的。Unicode 把这些数字叫做 码位,通常使用 U+0061 的形式书写,其中 U+ 表示这是 Unicode 字符,后面的四个数字是“码位”的十六进制表现形式。所以 0061 就是 97,恰好就是 a 的 Unicode 码位,如果你还记得第一节中的内容,你会发现这和 US-ASCII 是一致的。这一点后续会详细说明。有一点值得注意,Ruby 1.8 和 1.9 都可以显示码位:

$ ruby -vKUe 'p "aé…".unpack("U*")'
ruby 1.8.6 (2008-08-11 patchlevel 287) [i686-darwin9.4.0]
[97, 233, 8230]

$ ruby_dev -ve 'p "aé…".unpack("U*")'
ruby 1.9.0 (2008-10-10 revision 0) [i386-darwin9.5.0]
[97, 233, 8230]

unpack() 参数中的 U 指明需要的是 Unicode 码位,* 指明后续的字符都照此处理。请注意 我使用了 -KU 让 Ruby 1.8 运行在 UTF-8 模式下。Ruby 1.9 基于我的环境设置,默认使用 UTF-8。当我们开始将语言细节的时候会详细说明这一点。

码位并不是文件中记录的内容,它们只是各个字符的抽象表现。字符如何写入真正的数据流就是“编码”了。针对 Unicode 有很多种编码方式,或者说有不同的方式将这些抽象的数字写入文件中。

不同的编码方式有不同的优点。例如,Unicode 的其中一种编码方式是 UTF-32,每 32 位(4字节)呈现一个码位。这种编码的好处是字符总是以四个字节的长度出现(不同于下面讨论的可变长度的编码方式)。而很明显的一个缺点就是空间的浪费,我的意思是说如果你的数据全是 ASCII 中的符号,你真正需要的只是一个字节,但是 UTF-32 却总是会使用四个字节。

在处理多字节编码时一定要小心谨慎。UTF-32 就是一个很好的例证来说明这其中的麻烦,因为数据的某些部分看起来很正常。例如,看一下 Ruby 1.9 中的这个简单的字符串:

$ ruby_dev -ve 'p "abc".encode("UTF-32BE")'
ruby 1.9.0 (2008-10-10 revision 0) [i386-darwin9.5.0]
"\x00\x00\x00a\x00\x00\x00b\x00\x00\x00c"

从上面的代码可以看到有很多的空字节,也请注意其中也有正常的“a”,“b”和“c”字节。我不是想展示坏习惯,但是如果你把“a”字节替换成两个字节的“ab”,那么编码将会被破坏,最终导致一些问题。在处理字符串的截取时也要格外小心,确保你没有在字符的中间截断。

Unicode 的另一种编码实现是 UTF-8。因为下述的一些原因,这种编码方式在当今的 Email 和网页中比较流行。其一,UTF-8 和 US-ASCII 完全兼容,UTF-8 的前 128 个码位和 US-ASCII 是一致的,而且 UTF-8 使用单一字节对其编码。在 Ruby 1.9 中执行:

$ cat ascii_and_utf8.rb
str   = "abc"
ascii = str.encode("US-ASCII")
utf8  = str.encode("UTF-8")

[ascii, utf8].each do |encoded_str|
  p [encoded_str, encoded_str.encoding.name, encoded_str.bytes.to_a]
end

$ ruby_dev -v ascii_and_utf8.rb
ruby 1.9.0 (2008-10-10 revision 0) [i386-darwin9.5.0]
["abc", "US-ASCII", [97, 98, 99]]
["abc", "UTF-8", [97, 98, 99]]

上面的代码片段使用了一些 Ruby 1.9 中的新特性。在这不做深入说明,只是简单介绍一下:encode() 可以将字符串从当前编码转换到所提供的编码方式;encoding() 返回字符串当前所用编码方式所对应的 Encoding 对象;name()Encoding 对象显示为编码的名称;Ruby 1.9 中的字符串提供了 Enumerator 来遍历其所含内容:bytes()chars()codepoints()lines(),我使用 bytes() 来获取它们的字节。在讨论 Ruby 1.9 处理编码方式时会对此做更深入的说明。

针对上面的示例,现在你需要关注的点是 US-ASCII 和 UTF-8 在底层字节上的表现是一样的。

当然,128 个字符无法包含更大容量的 Unicode 字符集,所以你需要更多的字节。UTF-8 是一种变长字节的编码方式,如果需要它会基于下列的规则使用更多的字节来表示更大的码位:

  1. 单字节字符的最高有效位总是 0:0xxxxxxx
  2. 有效位为 1 的数量表明该码位包含的字节数。因此一个由两个字节表示的字符,它的最高有效位是 110xxxxx;如果是三个字节的字符则是 1110xxxx
  3. 多字节字符的后续字节全部以 10 开头:10xxxxxx

同样,Ruby 1.9 可以向我们展示:

$ cat utf8_bytes.rb
# encoding:  UTF-8

chars = %w[a é …]
chars.each do |char|
  p char.bytes.map { |b| b.to_s(2) }
end

$ ruby_dev utf8_bytes.rb
["1100001"]
["11000011", "10101001"]
["11100010", "10000000", "10100110"]

注意观察,不同的字符是怎样的有不同的长度,每个字节是如何按照上述的规则显示的。这就使得处理 UTF-8 编码的内容更安全,因为你不会看到一个单独的“a”在数据中不表示一个真正的“a”。但是你在截取字符串时还是要小心,避免将多字节的字符截断。

就我而言,这些因素结合在一起决定了 UTF-8 对全球性的字符编码是一个很好的选择。你需要的字符都有;ASCII 的内容没有修改。而且大多数的软件都支持 UTF-8。

Unicode 是完美的吗?不,它不完美。

有些字符有多种表现形式,例如,Unicode 的码位实际上是 Latin-1 的超集,因此 Unicode 包含了像 é 这样的单字节版本的重音符号,然而 Unicode 有标识合成的概念,音调可以有一个码位,字母有另一个码位,当需要显示时二者就会组合在一起,这就会导致一些很微妙的事情发生:两个字符串包含了相同的内容,但根据测试方法的不同却有可能是不相等的。这也会减弱像 UTF-32 这种编码方式的好处,因为四个字节已经可以确保一个码位,但是现在却需要多个码位 来组成一个字符。

因为一些原因,亚洲文化也在减慢 Unicode 的推广。首先,Unicode 经常会使数据变得更庞大。例如,Shift JIS 可以用两个字节表示全部的日本语字符,但是在 Unicode 中,大多数的字符需要三个字节。硬盘的价格现在已经很便宜了,但是在某些情况下将大多数数据扩容 1.5 倍还是一个很关键的考虑点。

Unicode 协会在确定全部的字符时还不得不做出一些艰难的抉择,在这些抉择中,汉字的统一化 已经激烈的争论了很长时间。我想很多人都意识到为什么现在需要做出一个选择,但是这样的争辩势必会减缓 Unicode 的推广,特别是在日本。

最后,还有很多现存的数据不是使用 Unicode 编码的。很不幸,现在还无法无痛地将这些数据转换成 Unicode。所有这些因素结合起来使“Unicode 可以解决一切编码问题”这样的观点是不完美的。

不过,使用一种编码为更多的受众服务,Unicode 仍然是最好的选择。

本节重点:

  • 字符集和编码方式并不一定是同一种事物
  • Unicode 这个字符集可以使用不同的编码方式编码
  • Unicode 被设计用来支持人类所有的字符
  • 在现今的软件开发中,你找不到一个比 Unicode 更好的默认编码方案来满足更高的使用比率
  • UTF-8 几乎是可供选择的最好的 Unicode 编码方法,因为它和 US-ASCII 兼容,而且处理起来更安全
  • 多字节编码处理起来很麻烦,特别是像 UTF-32 这种包含常规数据的编码方式

3. 通用编码策略

在进入细节之前,让我们来尝试总结一下处理编码问题时的最佳实践方法。我很确定你知道在处理编码问题时有很多方面需要考虑,那么就让我们集中关注那些最能帮助我们的关键点吧。

尽量多的使用 UTF-8

我们知道 UTF-8 并不完美,但是它已经接近完美了。没有任何一种其他的编码方式供你选择来满足如此大范围的受众。它是我们最好的方法。基于这些原因,UTF-8 迅速成为网络和 Email 等的首选编码

如果你能够决定你的软件能够接受、支持和分发哪一种/哪一些编码,那么尽可能多的使用 UTF-8 吧。这绝对是默认的最好的选项。

注明你所使用的编码

我们已经知道,如果要正确的处理数据,你必须知道数据的编码方式。虽然有些工具可以帮助你猜测编码的方法,但是你要极力避免这样做,为此你需要在每一个步骤都为你的数据注明编码方式。

发送邮件时,确保你指定了正确的字符集;为网页添加一个元标签指明编码;为你的 API 允许接受和返回的数据指定编码方式。这样做可以提高大家注明编码的意识,对每个人都有好处。

提高编码安全意识

你要习惯思考:这种编码方式安全吗?当你调用方法时,问一问你自己。当你在某个过程中处理数据时,亲身去检查一下结果。

你是否在 Ruby 1.8 中使用过类似 str[1..-2] 的代码?我想你用过,这样做是不安全的。你在分隔字节,将一个大的字符切成很多小片段,你得到数据就会变得一团糟。

这么做听起来像是偏执狂,但确实没有看起来那么的不好。往往只有几个很关键的时刻需要你保护你的数据,反复的问自己这个问题可以帮助你发现这些时刻。

举个例子,我在增强 Ruby 1.9 标准库中的 CSV 库对多语言的支持时,需要使用正则表达式处理一些用户提供的数据。这很简单是吧?

Regexp.escape(data)

幸运的是,我本能的反应是,这么做安全吗?我给 Regexp.escape() 提供了一些 UTF-32 编码的数据进行测试。记住,多字节编码可以显示一些看起来一般的数据,这有利于进行一些边界测试。Ruby 损坏了我的数据:

p Regexp.escape("+".encode("UTF-32BE"))
"\x00\x00\x00\+"

这只是 Ruby 1.9 刚发布不久所带来的后果。这个问题好像在新版本中修正了:

$ ruby_dev -ve 'p Regexp.escape("+".encode("UTF-32BE"))'
ruby 1.9.0 (2008-10-10 revision 0) [i386-darwin9.5.0]
"\x00\x00\x00\\x00\x00\x00+"

不过,观点依然成立,某些时候你甚至不能相信 Ruby。一定要小心。

如此自然会得到一个结论,你需要知道数据在传输过程中的编码。HTML 是否能够以 UTF-8 方式接收表单数据?接收这些数据时,Ruby 是否是 UTF-8 模式?MySQL 中存储这些数据的表是否设置为 UTF-8 编码方式?现今的 Rails 会为你处理这三个步骤中的两个。这就是为什么你需要了解你所使用的工具。

这些策略不是你需要的全部,但却是一个好的开始。没有太多的内容需要记忆,它会很好的增强你处理编码问题的意识,这才是最重要的。

4. Ruby 1.8 中的字节和字符

Gregory Brown 在 Lone Star RubyConf 的一个培训环节中说过:Ruby 1.8 处理的是字节;Ruby 1.9 处理的是字符。Ruby 1.9 的情况有点小复杂,会在后面讨论,不过 Gregory 对 Ruby 1.8 的概括太正确了。

在 Ruby 1.8 中,一个字符串就是一连串的字节。

不过关键的问题是,这样的处理方式和我们之前讨论过的字符编码有什么关联呢?实际上 Ruby 将所有的责任都交到作为开发者的你身上了。Ruby 1.8 让你自己决定如何处理这些字节,而没有提供过多的编码方面的帮助。所以在使用 Ruby 1.8 时要了解一些基本的编码知识。

Ruby 1.8 如此处理字符串和其他任何系统一样有优点也有缺点。先说优点,Ruby 1.8 可以支持可以想象的任何一种编码方式。毕竟字符编码就是将一些字节映射到一个字符集上,而 Ruby 1.8 中的字符串就是一系列的字节。如果你说一个字符串包含了一些 Latin-1 数据,同时也已这种编码来处理它,Ruby 会欣然接受你所说的。

不过说实话这种处理方式的缺点要比优点多的多。Latin-1 只是一个简单的情况,一个字节对应一个字符。但对于其他很多字符编码,比如我们推荐使用的 UTF-8,事情就要复杂的多。

Ruby 1.8 中通过索引截取字符串是以字节的形式处理的,这就意味着可能不小心就把多字节的字符截断了。使用正则表达式处理数据时也面临同样的问题。这只是两个我们经常会遇到的情况,而 Ruby 1.8 中很多字符串相关的操作都不是编码安全的。你甚至不能调用一些简单的方法,例如 reverse(),它会将多字节字符的顺序打乱。还有一点要注意,size() 总是返回字节的数量,而不是字符的数量。

Ruby 1.8 也不会监管字符串的内容。也就是说在 Ruby 1.8 中,一个合法的 UTF-8 编码的字符串、一个损坏了的 UTF-8 字符串和一个既有 Latin-1 编码的数据也有 UTF-8 编码的数据的字符串都是字符串,Ruby 不管这些。后面两种字符串对你没有什么用,所以就靠你自己去避免产生这样的问题。如果你从两个不同编码方式的源获取了字符串,你就不能简单的使用 + 将二者连接起来。

这些可能已经让你开始绝望了,不过 Ruby 1.8 会在很多情况下抛出一个异常:正则表达式处理引擎不会处理四个字符的编码方式。我们经常可以利用这一点来处理字符。

Ruby 1.8 提供了哪些编码方式呢?下面是完整的列表:

  • 不编码(n 或者 N)
  • EUC(e 或者 E)
  • Shift_JIS(s 或者 S)
  • UTF-8(u 或者 U)

“不编码”是 Ruby 1.8 的默认模式,就是我们之前提到的:把一切都看做字节。如果你要用的编码方式不在上述的列表中,那么就只能使用“不编码”,而且要确保不做会破坏编码的事情。这有点难度,在 Ruby 1.8 中处理没采用上述列表中编码方式的数据是一项很有挑战的工作。

EUC(Extended Unix Code,详情请参照 Wikipedia)和 Shift_JIS 都是处理亚洲语言字符的编码方式,Shift_JIS 用于日本语,EUC 主要用于日本语、韩语和简体中文。你应该知道 Ruby 是来自日本的,很显然这些编码方式对亚洲用户很有用,而对我们(译者注:原文作者是西方人士)则没有太多的用处。

不过幸好 UTF-8 也出现在这个列表中了,是的,这就是说 Ruby 1.8 对处理 UTF-8 编码的数据提供了有限的支持,虽然不全面,但至少有点帮助。

列表中括弧内的字母在 Ruby 1.8 中用来标识你在以哪种编码方式处理数据,我会在详细讨论的时候告诉你在什么地方需要用到这样的标识。

如果字符采用上述列表中的方式进行编码意味着什么?意味着正则表达式处理引擎可以识别采用这些编码方式的字符,甚至在多字节的时候也可以。这就保证了正则表达式构建目标的字符,比如字符类([...])和匹配一个字符的快捷方式(.),可以正确的匹配数据中特定位置上的字符,不管这些字符是由几个字节组成的。同时也改变了匹配空白的 \s 和匹配单词字符的 \w 二者的定义方式。在 Unicode 中一个单词中的字符数量要比 ASCII 中的 [A-Za-z0-9_] 多一些。

举几个例子来说明一下具体的运作方式吧。我会在 Ruby 1.8 中使用一个简单的使用 UTF-8 编码的字符串,向你展示多种编码方式的效果。记住默认的模式是“不编码”,所以如果你不指定其他方式就会“不编码”。

在 Ruby 1.8 中经常对字符串进行的一个操作是将其转换成一个包含字符串中所有字符的数组。如果我们这么做就会发现 Ruby 1.8 将字符串按照字节处理是有一些不足之处的。下面的代码基本上可以实现这个操作:

$ ruby -e 'p "Résumé".scan(/./m)'
["R", "\303", "\251", "s", "u", "m", "\303", "\251"]

你或许已经知道 scan() 的作用是将字符串中与其参数指定的正则表达式匹配的字符组成一个数组。其中的 /m 选项将正则表达式设为多行模式,在多行模式下一个 . 匹配所有的字符(但一般不匹配换行符)。

那么上面的代码有什么不好的地方呢?字符串中的“é”字符在 UTF-8 模式下占了两个字节,Ruby 1.8 的规则告诉我们一切都是字节,我们看到的结果就证明了这一点。多字节的字符被截断了,这样的操作方式并不好,因为如果我现在修改了数组的内容,很有可能我会破坏数据。

再次说明,以上是采用默认的“不编码”方式,因为我们没有指明要使用其他的方式。如果我们将正则表达式设为 UTF-8 模式的话,我们就会得到真正的字符:

$ ruby -e 'p "Résumé".scan(/./mu)'
["R", "\303\251", "s", "u", "m", "\303\251"]

注意到“é”的两个字节现在是在一起了吗?(我会在下一节中告诉你怎样避免 Ruby 转义内容得到真正的“é”)正则表达式处理引擎将 UTF-8 编码的一个字符的两个字节放在一起了。我所指定的编码让匹配一个字符的 . 将连个字节一起捕获。

我通过为正则表达式字面值指定 /u 将其设为 UTF-8 模式。你或与已经发现了,这个字母就是上述编码列表括弧中的字母。以此类推,你可以指定 /e 设置 EUC 编码,指定 /s 设置 Shift_JIS 编码,甚至可以指定 /n 设置“不编码”。Regexp.new() 可以接受第三个参数用于设定要创建的正则表达式的编码:Regexp.new(".", Regexp::MULTILINE, "u")

使用这种简单的方式,我们可以修正前面提到的不安全的字符串的方法。例如,Ruby 1.8 正常情况下的 size() 统计字节的数量:

$ ruby -e 'p "Résumé".size'
8

不过现在如果需要,可以统计字符的数量:

$ ruby -e 'p "Résumé".scan(/./mu).size'
6

我们还可以修正危险的 reverse() 方法,正常情况下这个方法会搞乱多字节字符“é”的字节顺序:

$ ruby -e 'p "Résumé".reverse'
"\251\303mus\251\303R"

\303\251 是 UTF-8 编码模式下的“é”,但是上面代码所示的\251\303 将数据破坏了,不表示任何的字符。我们可以通过以下方法进行修正:

$ ruby -e 'p "Résumé".scan(/./mu).reverse.join'
"\303\251mus\303\251R"

我们使用正则表达式引擎将字符串转换成由字符组成的数组,然后反转(reverse())数组,再把数组的元素连接起来(join())组成字符串。你可以看到这样做保证“é”字节的顺序不变。

认真的理解一下上面的示例知道你知道到底是怎么回事。这些就是 Ruby 1.8 为字符串处理提供的全部支持,所以你要学会如何使用。

下面的例子说明了上面我提到的针对正则表达式的第二个影响:

$ ruby -e 'p "Résumé"[/\w+/]'
"R"

$ ruby -e 'p "Résumé"[/\w+/u]'
"R\303\251sum\303\251"

在默认的“不编码”模式下,\w[A-Za-z0-9_] 是等价的,不会匹配构成字符“é”所需的特殊字节,所以匹配到此为止。但是 UTF-8 模式有所改变,得到了整个单词。

Ruby 1.8 除正则表达式处理引擎之外没有提供过多额外的编码支持。我们会在后面的章节中讨论一个神奇的变量和一些有用的标准库,但 Ruby 1.8 中对字符编码的支持主要就这些内容。

另外有一个小特性可以顺便提一下,你可以使用字符串的 unpack() 方法获取 Unicode 字符的码位:

$ruby -e 'p "Résumé".unpack("U*")'
[82, 233, 115, 117, 109, 233]

unpack() 参数中的 U 指定将字符转换成 Unicode 的码位,* 指明后续字符也做此操作。

我不经常需要需要处理字符的码位,不过你可以使用这种方式实现一个很好玩的功能。Unicode 的码位是 Latin-1 字节值的超集,所以你可以使用 unpack()pack() 在这两种编码之间转换:

utf8 = latin1.unpack("C*").pack("U*")
# ... 或者 ...
latin1 = utf8.unpack("U*").pack("C*")  # 更危险

我会在后续的章节告诉你一种更好的编码转换方法。

很重要的一点需要记住,这不是全部的字符编码支持。例如,如果要将 Unicode 字符转换成大写形式要遵循一个很长的规则列表,但是 upcase() 并不知道这个规则列表,而且你也无法通过正则表达式来解决这个麻烦。如果需要某个编码方式提供这个特性,你需要寻找额外的支持库或者自己实现解决方案。

5. $KCODE 变量和 jcode 库

我创建的所有 Ruby 文件都是以一行 Shebang 声明开始的:

#!/usr/bin/env ruby -wKU

不是每个文件都需要这行声明,它只对可执行的文件有效。但是我还是倾向于把它加入我创建的所有文件,因为:

  • 你无法预测文件是否会被执行(例如,很多库都有这样的代码 if __FILE__ == $PROGRAM_NAME; end
  • 它指明了这个文件包含的是 Ruby 代码
  • 它指明了这些代码基于 -w-KU 的规则

上述第三条提到的规则,是通过命令行的开关指定的,这些规则比较有趣。-w 可以开启 Ruby 中很有用的错误提示,推荐你尽量多的使用这个参数。不过这个参数和字符编码没什么关系,真正起作用的是 -KU

-KU 设定了一个神奇的 Ruby 变量:$-K,也叫 $KCODE。如果你无法设置命令行参数,可以在代码中进行设定:

$KCODE = "U"

你或许已经发现了,这里的 U 就是上一节中提到的 Ruby 1.8 的 UTF-8 编码。这个变量还可以设置为 N(默认值)、ES。较新版本的 Rails 为你设置了 $KCODE = "U"

那么修改这个神奇变量的值有什么作用呢?首先,有个很小的作用是改变 Ruby 在使用 inspect() 时的输出转义。看一下下面的代码片段:

$ ruby -e 'p "Résumé"'
"R\303\251sum\303\251"

$ ruby -KUe 'p "Résumé"'
"Résumé"

能够看到数据的原始形式很不错,这能假定你的命令行能够正确的处理 UTF-8 数据。不过这只是设置 $KCODE 的一个副作用。

$KCODE 的主要作用是改变所有未指定编码的正则表达式的默认编码方式。因此我们无需在正则表达式后添加一个 /u 选项就可以截取 UTF-8 数据了:

$ ruby -e 'p "Résumé".scan(/./m)'
["R", "\303", "\251", "s", "u", "m", "\303", "\251"]

$ ruby -KUe 'p "Résumé".scan(/./m)'
["R", "é", "s", "u", "m", "é"]

$ ruby -KUe 'p "Résumé".scan(/./mn)'
["R", "\303", "\251", "s", "u", "m", "\303", "\251"]

注意上述代码的第二段将默认编码换到了 UTF-8。不过我仍可以通过显示指定编码方式进行覆盖,在第三段代码中我就添加了 /u 选项指定“不编码”。

最近我倾向于使用 $KCODE 而不是 $-K,因为前者看起来在 Ruby 中更常用。实际上,Ruby 1.8 在另外一个地方也是用了这个名称,有一个方法可以获取正则表达式所采用的编码方式:

$ ruby -e 'p /./.kcode'
nil

$ ruby -e 'p /./u.kcode'
"utf8"

仔细的观察后你会发现,kcode() 隐藏着一些问题。首先,你可以看到它对编码采用不同于前述的名称。而且它好像不能读取 $KCODE 变量的值,而是返回一个很诡异的名字:

$ ruby -e '$KCODE = "U"; re = /./m; p "Résumé".scan(re); p re.kcode'
["R", "é", "s", "u", "m", "é"]
nil

正如你所看到的,表达式的编码已经很明确的正确设置了,不过 kcode() 却没有返回设置后的结果。如果在 Ruby 1.8 中你真的想知道正则表达式的编码,我建议你采用如下的代码:

class Regexp
  def encoding
    if kcode
      kcode[0, 1]
    elsif %w[n N u U e E s S].include? $KCODE
      $KCODE.downcase
    else
      "n"
    end
  end
end

只使用 kcode() 返回结果的第一个字母可以让我们得到标准字母集合中的值。如果没有设定 kcode(),我们可以使用 $KCODE。不过请注意,我确保了它的值被设为一个我们期望看到的值。你可以把 $KCODE 设为任意的值,不过 Ruby 会悄无声息的忽略它,然后回滚到默认的 N。所以如果你完全依赖于它的返回值就要切身检测一下。最后,如果二者都没有设定就返回默认值。

以上就是你需要知道的关于 $KCODE 的一切。Ruby 1.8 提供了一个简单的标准库叫做 jcode,它可以和前面两节所讨论的内容较好的结合。

如要使用 jcode 库,需要先设置 $KCODE,然后再引入这个库。先设置 $KCODE 这一点很重要,如果在此之前你引入了 jcode 库,会得到一个警告(只要你听了我的建议通过 -w 打开了警告功能):

$ ruby -r jcode -e 'p "Résumé".jsize'
8

$ ruby -w -r jcode -e 'p "Résumé".jsize'
Warning: $KCODE is NONE.
8

所以我说 -w 很重要。

一旦正确的设置了 $KCODE,jcode 库会为字符串添加一系列用来处理字符的方法。这些方法就是将前一节讨论过的技术打包,所以你就获得了类似 jsize() 这样可以获得字符数量而不是字节数量的方法:

$ ruby -KU -r jcode -e 'p "Résumé".jsize'
6

或许 jcode 库添加的最有用的方法就是 each_char() 了:

$ ruby -KU -r jcode -e '"Résumé".each_char { |c| p c }'
"R"
"é"
"s"
"u"
"m"
"é"

完整的方法列表请查看该库的文档

6. 使用 iconv 转换字符编码

如果要完全说明 Ruby 1.8 对字符编码的支持还有最后一个标准库需要介绍,iconv,它可以处理一系列的字符编码转换操作。

这是个很重要的代码库。你或许接受了我的建议,在有的选择时,只处理 UTF-8 的数据是可行的,但是在现实的世界中还有很多没有采用 UTF-8 编码的数据。旧系统可能在 UTF-8 流行之前产生了数据;很多服务基于一些原因可能在不同的编码方式之间运行着。并不是每个人都完全的转向 UTF-8 了。如果你遇到了这样的数据,在导入数据前你需要将其转换成 UTF-8 编码,或许在输出时还需要再转换回来。这些就是通过 iconv 处理的。

我们先不看 Ruby 的 iconv 库,而是换一个稍微不同的方式来了解这个库。iconv 实际上是一个 C 语言库,实现了编码之间的转换,在大多数安装了该库的系统上都会为其提供一个命令行的接口。

iconv 程序使用很简单,只需完全按照下述的三个步骤操作:

  1. 告知 iconv 你要输出数据的编码方式,包括一些转换说明
  2. 告知 iconv 你所接收数据的编码方式
  3. 通过 STDIN(如果你愿意,可以直接列出文件) 向 iconv 提供输入,然后将 iconv 的 STDOUT 转到你所需要的输出上

例如,我有一些 UTF-8 编码的数据:

$ echo "Résumé" > utf8.txt
$ wc -c utf8.txt
       9 utf8.txt

我的终端处在 UTF-8 模式,所以 echo 的数据写入了文件。你可以看出这些数据被编码了,因为我们的文件有 9 个字节(“R”、“s”、“u”、“m”各一字节,“\n”一字节,两个“é”各两字节)。

通过下面的方法可以用 iconv 将这些数据转换成 Latin-1 编码:

$ iconv -t LATIN1 -f UTF8 < utf8.txt > latin1.txt
$ wc -c latin1.txt
       7 latin1.txt

你可以看到转换成功了,因为“é”在 Latin-1 编码中只占一个字节,我们节省了两个字节。

注意我三步走的用法:

  1. 我使用 -t LATIN1 设置转换后的编码,没有指定额外的转换说明
  2. 我使用 -f UTF8 指定输入的编码
  3. 我使用 < utf8.txt 指定输入文件,> latin1.txt 指定输出文件

我们总是使用这样的三个步骤。

关于 iconv 还有两件事你需要知道。第一,iconv 支持很多编码方法,包括本文提到的所有常见方法。不过,在不同的平台上可能有所不同,所以你需要检查一下可以使用的有哪些:

$ iconv --list
ANSI_X3.4-1968 ANSI_X3.4-1986 ASCII CP367 IBM367 ISO-IR-6 ISO646-US
  ISO_646.IRV:1991 US US-ASCII CSASCII
UTF-8 UTF8
UTF-8-MAC UTF8-MAC
ISO-10646-UCS-2 UCS-2 CSUNICODE
UCS-2BE UNICODE-1-1 UNICODEBIG CSUNICODE11
UCS-2LE UNICODELITTLE
ISO-10646-UCS-4 UCS-4 CSUCS4
UCS-4BE
UCS-4LE
UTF-16
…

上面的结果每一行代表一种编码,同一行内以空格分开的是 iconv 可以支持的编码的别名。所以,第一行这个因为过长而被我截断成两行的列表都是 US-ASCII 的别名。往下一行我们看一看到,iconv 同时接受 UTF8 和 UTF-8。

第二件关于 iconv 需要知道的事是,它有一些特殊的转换模式。举例说明一下,我们使用另外一组数据:

$ echo "On and on… and on…" > utf8.txt
$ cat utf8.txt
On and on… and on…

最后一个字符是省略号,或者称之为在一起的三个点号。Unicode 中有这个字符,但是 Latin-1 中没有。让我们来看一下当我们尝试转化数据的话会发生什么:

$ iconv -f UTF8 -t LATIN1 < utf8.txt > latin1.txt

iconv: (stdin):1:9: cannot convert
$ cat latin1.txt
On and on

如你所见,在遇到第一个有问题的字符时出现了一个错误提示。cat 命令的结果告诉我们它已经完成了转换。

这或许就是你所需要的结果,所以你可以告诉用户你无法处理他们的数据。不过我经常发现我需要尽力处理我手上的数据。iconv 的转换模式可以给我们一些帮助。

首先,你可以设置 iconv 忽略那些无法转换到新编码的字符:

$ iconv -t LATIN1//IGNORE -f UTF8 < utf8.txt > latin1_wignore.txt
$ cat latin1_wignore.txt
On and on and on

你看到了,这一次我们完成了整个转换过程,只是丢弃了有问题的字符。//IGNORE 添加了这样的转换模式。转换模式都是在输出编码后面设定的。这已经是个进步了,不过在这种情况下我们还可以做的更好。

iconv 的另一个模式可以尝试将有问题的字符生硬的转换到目标编码字符集中对应的字符上:

$ iconv -t LATIN1//TRANSLIT -f UTF8 < utf8.txt > latin1_wtranslit.txt
$ cat latin1_wtranslit.txt
On and on... and on...

这一次,iconv 没有丢掉这些省略号,而是将其替换成三个点号。三个点号虽然不如 Unicode 字符中的省略号好看,不过转换的工作却完成了,而且保留了数据的要表达的意思。

//TRANSLIT 模式并不能转换所有的字符,所以也有可能会遇到错误。不过你可以将两个模式结合起来成为 //TRANSLIT//IGNORE。在这个模式下,iconv 会尽量生硬的转换,如果实在无法转换则将其丢掉。注意,模式的先后顺序很重要,你要确保在丢掉字符之前 iconv 已经尽力转换了。

你还可以为 iconv 指定转换困难字符对应的字符。我从没有用过这样的模式,因为我发现生硬转换模式已经做的足够好了。如果你对这个模式好奇的话,可以通过 man iconv 查看文档。

以上就是你需要知道的关于 iconv 的一切。现在你已经是一个字符转换的专家了,恭喜你。

当然,如果能讨论一下这些对 Ruby 都有哪些作用的话就更好了。那就让我们开始讨论吧。

Ruby 标准库就像我们上面用到的程序一样,它只是为底层的 C 代码提供了一个方法接口。我们来看一下下面的代码:

#!/usr/bin/env ruby -wKU

require "iconv"

utf8 = "Résumé"
utf8.size  # => 8

latin1 = Iconv.conv("LATIN1", "UTF8", utf8)
latin1.size  # => 6

你可以看到步骤都是一样的。第一个参数是目标编码,第二个参数是数据的当前编码,第三个参数指定需要转换的数据,方法的返回值就是转换后的数据。

如果一次需要进行多个转换,可以创建一个 Iconv 实例然后重复使用:

#!/usr/bin/env ruby -wKU

require "iconv"

utf8_to_latin1 = Iconv.new("LATIN1//TRANSLIT//IGNORE", "UTF8")

resume = "Résumé"
utf8_to_latin1.iconv(resume).size  # => 6

on_and_on = "On and on… and on…"
utf8_to_latin1.iconv(on_and_on)  # => "On and on... and on..."

就是这样,new() 方法创建一个对象,记住了转换前后的编码方式,然后调用 iconv() (而不用前面使用的 conv() 类方法)方法转换数据。

如果出现问题,Ruby 接口会抛出一个异常,比如 Iconv::InvalidEncodingIconv::InvalidCharacter。详情请参照该库的文档

Ruby 1.8 中的这个库没有提供一个简便的方法来列出所有支持的编码,这也是为什么我先说明命令行程序的一个很大的原因,如果你要查看支持的编码,需要通过命令行查看。不过,Ruby 1.9 中已经添加了这个方法:

$ ruby_dev -r iconv -r pp -ve 'pp Iconv.list'
ruby 1.9.0 (2008-10-10 revision 0) [i386-darwin9.5.0]
[["ANSI_X3.4-1968",
  "ANSI_X3.4-1986",
  "ASCII",
  "CP367",
  "IBM367",
  "ISO-IR-6",
  "ISO646-US",
  "ISO_646.IRV:1991",
  "US",
  "US-ASCII",
  "CSASCII"],
 ["UTF-8", "UTF8"],
…

至此,对 Ruby 1.8 中字符编码相关工具的探索就结束了。在下一节中,我们将跳出这些讨论,来探究这样的系统存在什么问题。这会为我们对 Ruby 1.9 中新的多语言支持的讨论铺平道路。

7. Ruby 1.8 中字符编码的缺点

我们已经概述了 Ruby 1.8 对编码的支持情况,接下来要讨论它的问题所在了。这些长期存在的问题致使核心开发团队为 Ruby 1.9 增加了对多语言(m17n)的支持。

这些主要问题是:

  • 提供的编码支持力度不够
  • 只对正则表达式提供了支持,支持不够全面
  • $KCODE 是全局性的编码设置

我知道大多数的问题很明显,但是我还是会一个个的说明,确保能从过去的失误中学到一些经验。我很确信这样会使我们更好的理解为什么 Ruby 1.9 采用了如此的解决方法。

“支持力度不够”是三者中最明显的,Ruby 1.8 支持四种编码方法,其中还有一个是“不编码”。这就是说你只拥有 UTF-8 和两个针对亚洲人群的编码。对 UTF-8 的支持使我们继续使用着,不过还有太多的编码没有提供支持。

很重要的一点需要明确,我们不能一味的为 Ruby 1.8 添加对其他编码的支持。之前系统并没有作此设计。很快我们就会用完能够添加到正则表达式后面的字母。这样做太不优雅了。

一旦有了更多的编码方法,就要考虑提供更广泛的支持。通过对正则表达式进行改进或许能够解决一些问题,但这也只是允许我们分隔字符。还有很多地方需要编码。如果需要检验数据是否正确编码怎么办?要处理字符组合时怎么办?要检索 Unicode 的码位怎么办?正则表达式无法解决所有的问题。

而且,对大量的数据进行编码转换是很危险的。有很多地方你需要知道确切的编码方法:字符串中的数据,从 IO 中读取的数据,源代码本身的编码等。在 Ruby 1.8 中你无法区别对待这些情况,你只能在一处进行设置。我的源码使用 UTF-8,也对 $KCODE 做了相应设置,那么如果我需要加载使用 Shift JIS 编码的代码库怎么办?二者之间必有一个会出问题,这对代码可不好。

再次说明,我提炼出了这些问题点,因为我觉得这会有助于理解为什么 Ruby 1.9 做了改变。当我们深入讨论 Ruby 1.9 对编码的支持情况时,请留意这些缺点,看一下它们是如何解决的。

8. Ruby 1.9 中的字符串

Ruby 1.9 引入了一个全新的编码引擎叫做 m17n(英文是 multilingualization,在 m 和 n 之间有 17 个字母)。这个引擎在其他编程语言中可能不常见。

一般来说很多人会倾向于选择一个万能的编码,就像 Unicode 编码,然后所有的数据都按照这种编码操作。但是 Ruby 1.9 选择了另一种方法。Ruby 1.9 没有钟情于一种编码方法,而是让其能够处理超过 80 种编码方法。

为了实现这种解决方法,Ruby 在很多进行数据处理的地方做了改变。你会发现最大的改变发生在字符串上,所以我们先来看看这里的变化。

现在所有的字符串都被编码了

Ruby 1.8 中的字符串就是一堆的字节。有时你会把这些字节按照不同的方式处理,在正则表达式中看做字符,在调用 each() 时作为数据行。但是其本质上还是一些字节。索引数据时统计的是字节,查看长度时统计的也是字节。

但是在 Ruby 1.9 中字符串则是一串被编码的数据。这意味着,字符串包含了原始的字节,同时还附属了编码信息指明如何处理这些字节。

让我举个简单的例子来说明这个不同点。先不要管我是如何获得这些编码的,稍后会对此说明。现在将集中关注 Ruby 是如何利用附属的编码信息决定如何处理这些数据的:

# 附属的编码信息
puts utf8_resume.encoding.name    # >> UTF-8
puts latin1_resume.encoding.name  # >> ISO-8859-1

# size() 现在返回编码后的数据(或字符)长度
puts utf8_resume.size    # >> 6
puts latin1_resume.size  # >> 6

# 不过我们还可以调用 bytesize() 来查看一下其间的不同
puts utf8_resume.bytesize    # >> 8
puts latin1_resume.bytesize  # >> 6

# 现在索引的是编码后的数据(字符)
puts utf8_resume[2..4]    # >> sum
puts latin1_resume[2..4]  # >> sum

这些示例看上起很基础,不过我们可以从中学到很多东西。首先,注意字符串对象现在有了一个附属的编码对象(Encoding)。我前面说过,字符串这个容器包含了原始的字节和附属其上处理这些字节的编码信息。现在 Ruby 中所有的字符串都包括这两部分,甚至当你指定要把它们作为原始的字节来处理也是如此(稍后会详细说明)。

上述代码片段的后面两段说明,当我们让 Ruby 返回数据的长度(size())时,它会按照附属其上的规则先处理这些字节然后返回编码后的数据长度,一般来说就是字符的数量。如果需要我们可以直接查看原始字节大长度(bytesize()),但这已经不是常规的处理方法了。这是与 Ruby 1.8 很大的一点不同之处。

上述代码片段的最后一段说明索引也受到相同的影响。现在是以编码后的数据为准而不是字节了。所以虽然在 UTF-8 编码的字符串中需要跳过三个字节,而在 Latin-1 编码的字符串中只需要跳过两个字节,但是相同的索引长度还是得到了相同的返回结果。

很重要的结论是:字符串现在包含了字节和处理这些字节的规则。希望这会让你开始有很自然的感觉,因为这就是我们真正设想要字符编码去实现的

改变编码

现在我还不想讨论字符串是如何获得原始的 Encoding 对象的,这个问题会单独去讲。有些时候你想改变 Encoding,这就涉及到字符串的更多新特性了。下面对此做一些说明。

改变 Encoding 的第一种方法是调用 force_encoding()。这种方法告诉 Ruby,你更了解这些数据,你要改变处理这些数据的规则。例如:

abc = "abc"
puts abc.encoding.name  # >> US-ASCII

abc.force_encoding("UTF-8")
puts abc.encoding.name  # >> UTF-8

如上代码所示,我创建字符串时,Ruby 赋予它 US-ASCII 编码。再次说明,我们现在不关心 Ruby 是如何处理这个过程的。重点是我不想用 US-ASCII,而想换成 UTF-8 编码。所以我调用了 force_encoding(),告诉 Ruby 这实际上是 UTF-8 编码的数据,你要改变附属其上的 Encoding 对象。

很重要的一点需要注意,在这里我可以作此操作是因为这些字节在 US-ASCII 和 UTF-8 编码中是一样的。我并没有改变数据,只是改变了处理这些数据的规则。

这么做可能会很危险,有时你要冒着没有正确设置处理数据的规则这样的风险。让我们回到之前的 Latin-1 字符串来说明这个问题:

# 数据有正确的 Encoding
puts latin1_resume.encoding.name    # >> ISO-8859-1
puts latin1_resume.bytesize         # >> 6
puts latin1_resume.valid_encoding?  # >> true

# 发生了失误,设置了错误的 Encoding
latin1_resume.force_encoding("UTF-8")

# 数据没有改变,但是 Encoding 不一致了
puts latin1_resume.encoding.name    # >> UTF-8
puts latin1_resume.bytesize         # >> 6
puts latin1_resume.valid_encoding?  # >> false

# 当需要使用这些数据时
latin1_resume =~ /\AR/  # !> ArgumentError:
                        #    invalid byte sequence in UTF-8

注意我是如何使用 force_encoding() 转换 Encoding,而没有改变数据的。bytesize() 相同的返回值可以证明这一点。不过 valid_encoding?() 告诉我们这些字节却不是合法的 UTF-8 数据。更糟的是,如果我们尝试通过正则表达式使用这些被破坏的数据时会得到错误提示。

这样的情况致使我们使用另一种方法改变 Encoding。如果我们有一些附有某个 Encoding 的合法数据,我们想把这些数据转换成不同的 Encoding,我们需要转码数据本身。在 Ruby 1.9 中你可以使用 encode() 方法实现这一过程(或者不生成新的字符串,使用 encode!() 方法修改源字符串)。

让我们使用 encode() 在做一次 Latin-1 到 UTF-8 的转换:

# 合法的 Latin-1 数据
puts latin1_resume.encoding.name    # >> ISO-8859-1
puts latin1_resume.bytesize         # >> 6
puts latin1_resume.valid_encoding?  # >> true

# 把数据转码到 UTF-8
transcoded_utf8_resume = latin1_resume.encode("UTF-8")

# 现在已经正确的转换到 UTF-8 了
puts transcoded_utf8_resume.encoding.name    # >> UTF-8
puts transcoded_utf8_resume.bytesize         # >> 8
puts transcoded_utf8_resume.valid_encoding?  # >> true

你可以看到这种方法的不同之处在于它既改变了 Encoding 也改变了数据。实际上数据从旧的 Encoding 转换到了新的。

这就让我们有了一些非常简单的规则来决定何时使用哪种方案。如果你比 Ruby 更了解数据,则只需要改变 Encoding,这时使用 force_encoding()。遇到这种情况要小心一点,如果设置错误下次使用数据时(也许是和转换 Encoding 完全不同的操作)会触发一些错误。如果需要把数据从一种 Encoding 转换到另一种,则要使用 encode()

对比时要小心

不幸的是,对字符串处理方式的改变增加了字符串对比规则的复杂度。在这我要背道而驰,避免你花费过多的精力去记忆这些新的规则。

相反地,我觉得从长远来看,总结出一条规则就可以很好的为你服务了。为此我建议:在处理一组字符串时首先把它们标准化为相同的 Encoding。这种方式对对比和其他的共有操作都可用。

不过我觉得这很难处理多种不同的数据,也很难推测出在处理的过程中会发生怎样的状况。

在数据标准化阶段,Ruby 中的编码相容性这一概念可以提供些微的帮助。下面的代码展示了如何检查编码相容性并利用它:

# 两种不同 Encoding 的数据
p ascii_my                      # >> "My "
puts ascii_my.encoding.name     # >> US-ASCII
p utf8_resume                   # >> "Résumé"
puts utf8_resume.encoding.name  # >> UTF-8

# 检查相容性
p Encoding.compatible?(ascii_my, utf8_resume)  # >> #<Encoding:UTF-8>

# 合并相容的数据
my_resume = ascii_my + utf8_resume
p my_resume                   # >> "My Résumé"
puts my_resume.encoding.name  # >> UTF-8

这个例子中有两个不同编码的数据,US-ASCII 编码和 UTF-8 编码。我问 Ruby 这两种编码是否相容(compatible())?Ruby 对这个问题有两种答复。如果返回 false,说明不相容,如果要对二者就行操作就要转码其中至少一个数据。如果返回一个 Encoding 对象说明二者相容,可以进行字符串连接操作,连接后的字符串采用返回值所对应的编码。在我连接这两个字符串时你可以看到具体的操作规则。

这个功能算是本文介绍最有用的了,将 ASCII 编码连接到更大的编码。更复杂的情况就需要转码了。

显式迭代

在 Ruby 1.8 中,字符串的 each() 方法按照行来迭代数据。我以为事情就此结束了,因为这是处理数据的常见方式,不过问题是,基于什么原因使得按行处理是正确的选择?按字节或按字符迭代怎么样?在 Ruby 1.8 中你可以使用 each_byte() 方法按字节迭代,但是你要依靠正则表达式的一些技巧先获取字符。

这对 Ruby 1.9 中的所有被编码的数据,只依赖一种类型的迭代就有点说不过去了。字符串的 each() 方法被移除了,而且字符串也不再是可枚举的了。这可能是对核心 API 做的最大的改变,代码需要改写。

不过尽管放心,字符串迭代并没有消失。只是现在你需要显示指定你所需的迭代类型,因为你有很多选择:

utf8_resume.each_byte do |byte|
  puts byte
end
# >> 82
# >> 195
# >> 169
# >> 115
# >> 117
# >> 109
# >> 195
# >> 169

utf8_resume.each_char do |char|
  puts char
end
# >> R
# >> é
# >> s
# >> u
# >> m
# >> é

utf8_resume.each_codepoint do |codepoint|
  puts codepoint
end
# >> 82
# >> 233
# >> 115
# >> 117
# >> 109
# >> 233

utf8_resume.each_line do |line|
  puts line
end
# >> Résumé

类似地,如果要使用不同于 each() 的迭代器,你可以让以上的各迭代方式返回 Enumerator 对象。上面的几个方法可以通过不指定块来获的 Enumerator 对象,不过有一些方法时专门为这种用法准备的:

p utf8_resume.bytes.first(3)
# >> [82, 195, 169]

p utf8_resume.chars.find { |char| char.bytesize > 1 }
# >> "é"

p utf8_resume.codepoints.to_a
# >> [82, 233, 115, 117, 109, 233]

p utf8_resume.lines.map { |line| line.reverse }
# >> ["émuséR"]

我觉得从长远来看这种改变是好的,我觉得这让代码更具可读性。在我看来这是好事。

移除 each() 带来的最大烦恼是需要让代码能够同时运行在 Ruby 1.8 和 Ruby 1.9 中。如果遇到这种情况,你可以选择为 Ruby 1.8 的字符串添加一个方法:

if RUBY_VERSION < "1.9"
  require "enumerator"
  class String
    def lines
      enum_for(:each)
    end
  end
end

或者选择使用类似下面的技巧:

str.send(str.respond_to?(:lines) ? :lines : :to_s).each do |line|
  # ...
end

9. Ruby 1.9 中的三种默认编码类型

我怀疑很多 Ruby 用户在初次接触新的多语言引擎时会遇到这样的错误信息:

invalid multibyte char (US-ASCII)

Ruby 1.8 不太在意字符串字面量中的内容,但是 Ruby 1.9 却很在意。我想你会发现这样的改变是好的,不过我们不需要花费太多的时间去学习这些新的规则。

让我们先来看看 Ruby 三种默认编码类型的第一种。

源码的编码

Ruby 中被编码的数据越来越多,每一个字符串都需要一个特定的 Encoding 对象。也就是说在创建字符串的时候就需要为其选定一个 Encoding。创建字符串的方法之一是由 Ruby 对字符串字面量执行一些代码,如下所示:

str = "A new String"

这是个很简单的字符串,但是如果我使用的是如下的字符串字面量呢?

str = "Résumé"

这时字符串的 Encoding 对象是什么呢?因为有这样的疑问,所以我们才需要和字符编码做一些纠缠。你不能仅靠视觉来判断字符串的编码。现在如果我给你的只是一些字节,你可能会根据经验做出判断,但是这个数据并没有附属 Encoding 对象。

这对我们每天处理的大量数据来说是可能发生的,一个纯文本文件是不可能说明储存其中的诗句采用了何种编码。如果你考虑到了这种情况那么你就会发现一些有意思的事情。

涉及到程序源代码时情况可能更糟。我想以 UTF-8 书写我的代码,可是一些日本的程序员却想使用 Shift JIS。Ruby 应该支持为这种情况提供支持,事实也是如此,在 1.9 中提供了支持。让我们再看看更复杂的情况:试想我用 UTF-8 编写了一个 gem,然后一个日本的程序员想在他使用的 Shift JIS 代码中使用这个 gem,我们怎样将这两个程序无缝整合呢?

Ruby 1.8 中那个神奇的变量在此没有多大的作用,是时候还一种解决方案了。Ruby 1.9 为此提供的解决办法是“源码的编码”。

现在所有的 Ruby 源码都有一个 Encoding 对象,当你在源码中创建字符串时,源码的 Encoding 赋予了这个字符串。这样简单的方法很好的解决了上述的问题。只要我的源码是 UTF-8 编码、日本程序员的源码编码是 Shift JIS,我的字面量按照我的期望处理着,他的字面量按照他的期望处理着。很显然,如果要共用数据,我们就要通过文档或是代码来建立一些规则,以适应不同的编码,做到这一点就可以解决问题了。

因此现在唯一的问题就是,源代码的编码是什么?我怎样改变它?

有很多中方法可以为源代码指定一个 Encoding 对象。详情如下:

$ cat no_encoding.rb
p __ENCODING__
$ ruby no_encoding.rb
#<Encoding:US-ASCII>

$ cat magic_comment.rb
# encoding: UTF-8
p __ENCODING__
$ ruby magic_comment.rb
#<Encoding:UTF-8>
$ cat magic_comment2.rb
#!/usr/bin/env ruby -w
# encoding: UTF-8
p __ENCODING__
$ ruby magic_comment2.rb
#<Encoding:UTF-8>

$ echo $LC_CTYPE
en_US.UTF-8
$ ruby -e 'p __ENCODING__'
#<Encoding:UTF-8>

$ ruby -KU no_encoding.rb
#<Encoding:UTF-8>

上述代码的第一段说明了两个很重要的事情。第一,源码的编码一条主要的规则就是:如果不指定,默认使用 US-ASCII。这就是本节开头很多人遇到错误的原因,如果在没有改变源码的编码的情况下,在字符串自变量中包含任何的非 ASCII 数据,Ruby 都会以一个错误终止程序运行。所以如果要处理非 ASCII 数据就要改变源码的编码。第二个重要的事情是,一个新的 __ENCODING__ 关键字可以返回当前运行代码的源码编码。

上述代码的第二段展示了设定源码编码的一种推荐方法,叫做“神奇注释”。如果源码文件的第一行是一个注释,其内容是一个单词 encoding,跟着一个冒号和一个空格,然后是一个 Encoding 对象名称,这个源码文件的编码就被设为这个指定的 Encoding 了。如果文件包含 Shebang,这个“神奇注释”就必须在第二行,这两行之间不许有空格。一旦设置了 Encoding,这个文件中所有的字符串字面量就都被赋予这种编码了。

上述代码的第三段是为了方便而设置的一个特殊规则。如果在命令行使用 -e 开关执行 Ruby 代码,命令行会从所处环境获取源码的编码。我是将 LC_CTYPE 环境变量设置为 UTF-8,不过有些人也会设置 LANG 变量实现同样的效果。这让脚本的执行更顺利,Ruby 会自动匹配串联命令的编码。

上述代码的第四段又是一个很有趣的特例。Ruby 1.9 仍然支持来自 Ruby 1.8 的 -K* 形式开关,包括本文大量使用的 -KU 开关。这种开关会产生很多影响,不过只需注意一点,这是除“神奇注释”之外唯一一种改变源码编码的方法。这对向前兼容性是个好消息,多亏了这个方法 Ruby 1.8 的代码才可能不产生任何的编码问题而运行在 Ruby 1.9 中。不过我必须强调一点,这种方法的存在只是为了向前兼容性,“神奇注释”才是王道。

通过“神奇注释”,源码有了 Encoding 数据。不过为每个源码文件都加上这样的注释似乎有点多余,好像也没发生太大的变化,因为之前我们已经建议你在文件的第一行加入这行 Shebang 了:

#!/usr/bin/env ruby -wKU

现在,对于 Ruby 1.9 我们则推荐使用下面的代码:

#!/usr/bin/env ruby -w
# encoding: UTF-8

注意,“神奇注释”的格式很松散,以下的所有形式效果都一样:

# encoding: UTF-8

# coding: UTF-8

# -*- coding: UTF-8 -*-

其中一些形式可以被文本编辑器读取使用。

如果我们习惯添加这个“神奇注释”,不过我们喜好哪种编码,我们的代码都能够很好的结合在一起运行。Ruby 知道怎样处理不同的文件。这么做带来一个额外的好处,作为程序员的我们也能够看到这个注释,可以帮助我们更好的理解所使用的代码。我觉得这样的好习惯值得拥有。

默认的外部编码和内部编码

字符串经常还可以通过另外一种方法创建:从 IO 对象读取。这时为字符串设定源码编码就不合理了,因为这是外部数据和源码无关。但是你还是需要知道数据的编码方式才能正确的读取它们,甚至对于读取下一行这样简单的操作也会因为 UTF-8 和 UTF-16LE(LE 的意思是小字节序)两种编码的不同而发生变化。因此,IO 对象至少要附着一种 Encoding。Ruby 1.9 很大方,为此它提供了二种编码:外部编码和内部编码。

外部编码是数据在 IO 对象内所采用的编码。外部编码影响数据的读取;如果内部编码没有设定的话,返回的数据也会采用外部编码进行编码(稍后详细说明)。下面举例说明:

$ cat show_external.rb
open(__FILE__, "r:UTF-8") do |file|
  puts file.external_encoding.name
  p    file.internal_encoding
  file.each do |line|
    p [line.encoding.name, line]
  end
end

$ ruby show_external.rb
UTF-8
nil
["UTF-8", "open(__FILE__, \"r:UTF-8\") do |file|\n"]
["UTF-8", "  puts file.external_encoding.name\n"]
["UTF-8", "  p    file.internal_encoding\n"]
["UTF-8", "  file.each do |line|\n"]
["UTF-8", "    p [line.encoding.name, line]\n"]
["UTF-8", "  end\n"]
["UTF-8", "end\n"]

上例有四点需要注意:

  1. 在设置打开文件的模式时通过后面的 UTF-8 设定了外部编码
  2. 如上所示,可以使用 external_encoding() 查看外部编码
  3. 类似的 internal_encoding() 返回内部编码值,如果没有显式设定的话就是 nil
  4. 留意我指定了外部编码后所生成的字符串

内部编码的出现增加了问题的复杂度。如果设置了内部编码,数据还是以外部编码读取,但是在创建字符串时会将其转码到内部编码。这位程序员带来了便利。来看看到底发生了什么变化:

$ cat show_internal.rb
open(__FILE__, "r:UTF-8:UTF-16LE") do |file|
  puts file.external_encoding.name
  puts file.internal_encoding.name
  file.each do |line|
    p [line.encoding.name, line[0..3]]
  end
end

$ ruby show_internal.rb
UTF-8
UTF-16LE
["UTF-16LE", "o\x00p\x00e\x00n\x00"]
["UTF-16LE", " \x00 \x00p\x00u\x00"]
["UTF-16LE", " \x00 \x00p\x00u\x00"]
["UTF-16LE", " \x00 \x00f\x00i\x00"]
["UTF-16LE", " \x00 \x00 \x00 \x00"]
["UTF-16LE", " \x00 \x00e\x00n\x00"]
["UTF-16LE", "e\x00n\x00d\x00\n\x00"]

这个示例就有些不同之处了:

  1. 文件打开模式的第二个编码(这里的 UTF-16LE)设置内部编码,如第二个 puts 所示
  2. 这个变化让 Ruby 对所有数据进行了转换(我截取了输出结果的一部分,因为 UTF-16LE 的数据很复杂)

在写模式下外部编码以相同的方式工作。外部编码仍然表示 IO 对象的编码,或者表示写入完成后的数据编码。不过这时你不必指定一个内部编码了,Ruby 会自动将输出字符串的编码设为内部编码,然后如果需要的话会将数据转码到外部编码。例如:

$ cat write_internal.rb
# encoding: UTF-8
open("data.txt", "w:UTF-16LE") do |file|
  puts file.external_encoding.name
  p    file.internal_encoding
  data = "My data…"
  p [data.encoding.name, data]
  file << data
end
p File.read("data.txt")

$ ruby write_internal.rb
UTF-16LE
nil
["UTF-8", "My data…"]
"M\x00y\x00 \x00d\x00a\x00t\x00a\x00& "

留意一下在内部编码为 nil 的情况下,数据在被写入之前是如何进行转码的。Ruby 使用字符串的编码决定所需的是什么编码。

这两个 IO 相关的编码都很直观明了,但还是留下了一个问题:如果不设置它们会发生什么?答案是,如果任何一个没有设置,IO 会集成默认的外部编码和(或)内部编码。现在需要知道的就是 Ruby 是如何选择默认值的。

默认的外部编码从环境中获取,类似于通过命令行设定源码编码的方式:

$ echo $LC_CTYPE
en_US.UTF-8

$ ruby -e 'puts Encoding.default_external.name'
UTF-8
$ LC_CTYPE=ja_JP.sjis ruby -e 'puts Encoding.default_external.name'
Shift_JIS

默认的内部编码则是 nil。你需要主动修改去获得其他的编码方式。

这两个 IO 相关的编码各自有一个全局性的设置方法:Encoding.default_external=()Encoding.default_internal=()。你可以把它们设为 Encoding 对象或者是所对应的名称字符串。

你可以通过命令行开关来改变这两个编码的值。-E 开关可以同时设置这两个编码或者只设置其中一个:

$ ruby -e 'p [Encoding.default_external, Encoding.default_internal]'
[#<Encoding:UTF-8>, nil]

$ ruby -E Shift_JIS \
> -e 'p [Encoding.default_external, Encoding.default_internal]'
[#<Encoding:Shift_JIS>, nil]

$ ruby -E :UTF-16LE \
> -e 'p [Encoding.default_external, Encoding.default_internal]'
[#<Encoding:UTF-8>, #<Encoding:UTF-16LE>]

$ ruby -E Shift_JIS:UTF-16LE \
> -e 'p [Encoding.default_external, Encoding.default_internal]'
[#<Encoding:Shift_JIS>, #<Encoding:UTF-16LE>]

如果所见,这个开关接受的参数形式就和 File.open() 方法的读写模式字符串一样。

有一个更简便的命令行开关可以设置在任何地方都使用 UTF-8,这个开关是 -U,它把默认的内部编码设为 UTF-8。这样设置后你只需为 IO 对象设置外部编码,或干脆从环境中获取外部编码,你所读取的所有字符串就会被转码到 UTF-8。

关于默认的外部编码和内部编码有一点很重要,你应该把这二者视为对脚本的便捷操作方式。如果你可以掌控代码运行的场所,从环境或命令行开关获取编码会更为便利,但是对于希望其他人运行的代码来说就要更显式的指定编码。如果有疑虑,那就为 IO 对象设置你期望的外部编码和内部编码。这样做很繁琐,但是更安全,不会被一些外部力量悄悄的修改。还要记住,这两个编码的设置是全局性的,会对所有加载的代码产生影响,包裹从外部加载(require())的代码库。这么做既是福也是祸,所以当你有疑惑的时候记得要思考这个问题:字符串的编码从何而来?

10. 多语言的其他细节

我们已经讨论了 Ruby 1.9 中多语言引擎的核心事项,在字符串和 IO 上你可以看到变化所在。不过这个新的多语言系统很强大,还设置一些其他的细节。让我们来讨论一下在 Ruby 1.9 中处理字符编码会涉及到的周边话题。

编码类的更多特性

在解释多语言特性时我大量的使用了 Encoding 对象,但是还没有仔细讨论过这个对象。Encoding 很简单,基本上只是表示了 Ruby 中的编码名称。所以,Encoding 对象存储了一些工具,在处理编码问题时会很有用。

首先,你可以获得一个数组列表(list())包含所有在 Ruby 中加载的 Encoding 对象:

$ ruby -e 'puts Encoding.list.first(3), "..."'
ASCII-8BIT
UTF-8
US-ASCII
...

如果你只对某个特定的 Encoding 对象感兴趣,你可以通过名称找到它(find()):

$ ruby -e 'p Encoding.find("UTF-8")'
#<Encoding:UTF-8>

$ ruby -e 'p Encoding.find("No-Such-Encoding")'
-e:1:in `find': unknown encoding name - No-Such-Encoding (ArgumentError)
    from -e:1:in `<main>'

如果所见,如果 Ruby 不知道某个 Encoding 对象就会抛出一个 ArgumentError 异常。

有些 Encoding 对象的名称不止一个,这些别名可以替换着使用,但是指代的是同一个 Encoding 对象。例如,ASCII 有个别名叫 US-ASCII:

$ ruby -e 'puts Encoding.aliases["ASCII"]'
US-ASCII
$ ruby -e 'p Encoding.find("ASCII") == Encoding.find("US-ASCII")'
true

aliases() 方法返回一个 Hash,通过键可以获取 Ruby 知道的别名,返回的结果是这个别名所指代的编码。在调用像 Encoding.find()IO.open() 这样的方法时,在指定 Encoding 对象时可以使用原名或别名。

最后,如果你要编写一些支持更大范围编码的 Ruby 代码时,要了解一个会发生问题的地方。Ruby 支持的编码中,有一些还没有完全实现字符处理方式的“空壳编码”,这些空壳编码是用来声称编码支持完整性的。在支持更多的编码时你要将这些空格编码剔除,避免发生问题:

$ ruby -e 'puts "Dummy Encodings:", Encoding.list.select(&:dummy?).map(&:name)'
Dummy Encodings:
ISO-2022-JP
ISO-2022-JP-2
UTF-7

字符串转义

在 Ruby 1.8 中你可能会使用字节转义将原始字节插入字符串。例如,你可以通过下面的字节转义生成字符串“…”:

$ ruby -v -KU -e 'p "\xe2\x80\xa6"'
ruby 1.8.6 (2009-03-31 patchlevel 368) [i686-darwin9.6.0]
"…"

在 Ruby 1.9 中情况依旧,但是要记住,前面已经说过,即使是原始字节,其上仍然会附属 Encoding 对象:

$ cat utf8_escapes.rb
# encoding: UTF-8
str = "\xe2\x80\xa6"
p [str.encoding, str, str.valid_encoding?]

$ ruby -v utf8_escapes.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
[#<Encoding:UTF-8>, "…", true]

$ cat invalid_escapes.rb
# encoding: UTF-8
str = "\xe2\x80"
p [str.encoding, str, str.valid_encoding?]

$ ruby -v invalid_escapes.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
[#<Encoding:UTF-8>, "\xE2\x80", false]

我实验了两种情况,不过两种情况下都正常的将源码编码赋予字符串了。第一种情况生成了一个合法的 UTF-8 字符串;但是第二中情况中的字符串确实不合法的,如果尝试使用的话可能会产生错误。

不过有些特例情况字符串转义真的会改变字面量到编码。你还记得“如果不改变源码编码就不同使用多字节字符”吧:

$ cat bad_code.rb
"abc…"

$ ruby -v bad_code.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
bad_code.rb:1: invalid multibyte char (US-ASCII)
bad_code.rb:1: invalid multibyte char (US-ASCII)

但是,\x## 转义确实个特例:

$ cat ascii_escapes.rb
puts "Source Encoding:  #{__ENCODING__}"
str = "abc\xe2\x80\xa6"
p [str.encoding, str, str.valid_encoding?]

$ ruby -v ascii_escapes.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
Source Encoding:  US-ASCII
[#<Encoding:ASCII-8BIT>, "abc\xE2\x80\xA6", true]

注意,为了容纳这些字节,字符串的编码升级到了 ASCII-8BIT。在本节的后面内容中会继续讨论这个特殊的编码。现在只需注意这样一个事实:这种特例为二进制数据的处理提供了一种简单的方法。

八进制转义(###)、控制符转义(\cx\C-x)、元转义(\M-x)和元控制符转义(\M-C-x)与十六进制转义(\x##)一样都遵循这里说明的处理方式。

另外一个特例是通过码位输入 Unicode 字符的 \u#### 转义。当你使用这种形式的转义时,不过源码编码是什么,这个字符串的编码都是 UTF-8:

$ cat ascii_u_escape.rb
str = "\u2026"
p [str.encoding, str]

$ ruby -v ascii_u_escape.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
[#<Encoding:UTF-8>, "…"]

$ cat sjis_u_escape.rb
# encoding: Shift_JIS
str = "\u2026"
p [str.encoding, str]

$ ruby -v sjis_u_escape.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
[#<Encoding:UTF-8>, "…"]

$ cat utf8_u_escape.rb
# encoding: UTF-8
str = "\u2026"
p [str.encoding, str]

$ ruby -v utf8_u_escape.rb
ruby 1.9.1p0 (2009-01-30 revision 21907) [i386-darwin9.6.0]
[#<Encoding:UTF-8>, "…"]

注意上述代码中的字符串,不过源码文件使用哪种编码,三种情况下字符串的编码都是 UTF-8。这个特例为处理 UTF-8 数据提供一种简单的方法,完全不用考虑内置的编码问题。

如上所示,Unicode 转义可以只包含 4 个数字,也可以使用 \u{#...} 的形式,在花括号内使用 1 个到 6 个数字。这两种形式对字符串的编码效果一样。

处理二进制数据

不是所有的数据都是文本的形式,Ruby 的字符串类可以用来吃力原始的字节码。例如你可能需要处理 PNG 格式图片的原始字节码。

Ruby 1.9 为这种情况提供了一种编码,这种编码单纯的把数据看做原始的字节码。你可以把它看成关闭了字符处理而只处理字节的方法。

$ cat raw_bytes.rb
# encoding: UTF-8
str = "Résumé"
def str.inspect
  { data:     dup,
    encoding: encoding.name,
    chars:    size,
    bytes:    bytesize }.inspect
end
p str
str.force_encoding("BINARY")
p str

$ ruby raw_bytes.rb
{:data=>"Résumé", :encoding=>"UTF-8", :chars=>6, :bytes=>8}
{:data=>"R\xC3\xA9sum\xC3\xA9", :encoding=>"ASCII-8BIT", :chars=>8, :bytes=>8}

注意,转换了编码(没改变数据本身)后,Ruby 就没有了字符的概念,字符的长度和字节的长度是一样的,在检视字符串时给出的结果是原始的字节码。

如果你认为这种编码叫做 BINARY 的话也算正确,在上述代码中我就用了这个名称,它是一个别名。在监视字符串的输出中 Ruby 就使用了真正名称,Ruby 实际上称这中编码为 ASCII-8BIT,不过这也带来了一些麻烦。

显然,在 Ruby 之外没有叫做 ASCII-8BIT 的事物。即使是处理二进制的数据,检查是否保护 ASCII 数据片段的做法也是不常见的。例如,PNG 图片签名的头几个字节就包含一个完整的 ASCII 字符串“PNG”:

$ cat png_sig.rb
sig = "\x89PNG\r\n\C-z\n"
png = /\A.PNG/

p({sig => sig.encoding.name, png => png.encoding.name})

if sig =~ png
  puts "This data looks like a PNG image."
end

$ ruby png_sig.rb
{"\x89PNG\r\n\x1A\n"=>"ASCII-8BIT", /\A.PNG/=>"US-ASCII"}
This data looks like a PNG image.

Ruby 让 ASCII-8BIT 和 US-ASCII 相兼容来让事情变的合理,经过这样的处理,在上述的代码中我们就可以使用一个简单的 US-ASCII 正则表达式来验证 PNG 签名了。因此,ASCII-8BIT 的意思是 ASCII 外加一些其他的字节,如果有助于数据的处理你可以将其中部分数据视作 ASCII。

还有一点值得注意,如果以字节模式读取数据,Ruby 会将编码回滚到 ASCII-8BIT:

$ cat binary_fallback.rb
open("ascii.txt", "w+:UTF-8") do |f|
  f.puts "abc"
  f.rewind
  str = f.read(2)
  p [str.encoding.name, str]
end

$ ruby binary_fallback.rb
["ASCII-8BIT", "ab"]

这种处理方式是合理的,因为在字节模式下读取,你可以截断字符。如果你确实需要读取一些字节,但是不想改变编码,你需要手动设置并检校。你可以通过类似下面的方式实现:

$ cat read_to_char.rb
# encoding: UTF-8
open("ascii.txt", "w+:UTF-8") do |f|
  f.puts "Résumé"
  f.rewind
  str = f.read(2)
  until str.dup.force_encoding(f.external_encoding).valid_encoding?
    str << f.read(1)
  end
  str.force_encoding(f.external_encoding)
  p [str.encoding.name, str]
end

$ ruby read_to_char.rb
["UTF-8", "Ré"]

在上述的例子中,我先读取(read())了固定长度的字节,接着一个字节一个字节的读取,直到数据能够满足设定的编码为止。我复制(dup())了原来的数据,然后在确定读取完成之后进行了强制转换编码(force_encoding()),因为 UTF-8 和 ASCII-8BIT 是不兼容的,所以在这个过程中可能会抛出 Encoding::CompatibilityError 异常。

处理二进制数据还需要你了解 IO 对象的另一个情况,在 Windows 系统中,Ruby 会转换一些你读取的数据,转换的内容很简单:从 IO 对象中读取的 \r\n 会变成单一的 \n。这个功能可以让 Unix 上的脚本顺利的在具有不同行尾形式的平台上运行。这样做会带来一些额外的工作量:在读取非文本数据时,比如说二进制数据或像 UTF-16 这样和 ASCII 不兼容的编码,为了保证能够夸平台执行,你要提醒 Ruby 不要做这样的转换。

顺便提醒一下,这个功能不是新的,在 Ruby 1.8 中就有。

告知 Ruby 将数据视为二进制,而且不要做任何的转换(只对 Windows 平台)是很简单的。在调用 open() 是在操作模式后面添加一个 b(表示二进制) 就可以了。那么你就可以像这样读取数据:

open(path, "rb") do |f|
  # ...
end

或者这样:

open(path, "wb") do |f|
  # ...
end

如果你知道这种诡异的行为,也按照上面的方式处理了,那就给自己一个口头表扬吧,你做的很好。如果你没有这么做,那就要摒弃这种坏习惯,不过也无需灰心。我在使用 Perl 时就知道这个诡异的行为了(Perl 和 Ruby 的情况一样),然后就一直尽量按照正确的方式处理,但是最近在我的一个代码库中发现了十个不同的 bug,就是因为我没有添加 b,这太容易被遗忘了。

Ruby 1.9 对二进制标签有更严格的规则,如果 Ruby 认为需要而你没有提供这个标签的话它会发出一些抱怨。例如:

$ cat missing_b.rb
# Ruby 1.9 会让这个通过
open("utf_16.txt", "w:UTF-16LE") do |f|
  f.puts "Some data."
end
# 但这个无法通过
open("utf_16.txt", "r:UTF-16LE") do |f|
  # ...
end

$ ruby missing_b.rb
missing_b.rb:6:in `initialize': ASCII incompatible encoding needs binmode
                                (ArgumentError)
    from missing_b.rb:6:in `open'
    from missing_b.rb:6:in `<main>'

当然这很容易修复,把 b 添加上去就行了:

$ cat with_b.rb
open("utf_16.txt", "wb:UTF-16LE") do |f|
  f.puts "Some data."
end
open("utf_16.txt", "rb:UTF-16LE") do |f|
  puts f.external_encoding.name
end

$ ruby with_b.rb
UTF-16LE

上述代码片段中我用 external_encoding() 的结果来说明这就是我指定的编码。不过将这个过去丢掉的 b 加入会产生一个副作用,添加 b 后 Ruby 会认为你想要的外部编码是 ASCII-8BIT 而不是默认的外部编码:

$ cat b_means_binary.rb
open("utf_16.txt", "r") do |f|
  puts "Inherited from environment:  #{f.external_encoding.name}"
end
open("utf_16.txt", "rb") do |f|
  puts %Q{Using "rb":  #{f.external_encoding.name}}
end

$ ruby b_means_binary.rb
Inherited from environment:  UTF-8
Using "rb":  ASCII-8BIT

有个地方需要注意一下,Ruby 1.8 无形中让我们习惯了不使用 b。例如,你可以使用 IO::read() 读取一些数据,但这个方法没有办法指明数据是二进制的。所以你就需要一个更强大的办法实现夸平台支持:open(path, "rb") { |f| f.read }。不过 IO::read() 却很常用。IO::readlines()IO::foreach() 有同样的问题。Ruby 核心开发团队承认了这一点并了改进,现在你可以为所有打开 IO 的方法添加一个 Hash 类型的参数,可以设置 :mode,或者分别设置 :external_encoding:internal_encoding,还可以设置 :binmode。下面是一些例子:

File.read("utf_16.txt", mode: "rb:UTF-16LE")

File.readlines("utf_16.txt", mode: "rb:UTF-16LE")

File.foreach("utf_16.txt", mode: "rb:UTF-16LE") do |line|

end

File.open("utf_16.txt", mode: "rb:UTF-16LE") do |f|

end

open("utf_16.txt", mode: "rb:UTF-16LE") do |f|

end

还有一个较为快捷的方式,直接使用新的 IO::binread() 方法,它和 IO.read(..., mode: "rb:ASCII-8BIT") 作用一样。

正则表达式的编码

现在所有的数据都有了编码,所以为正则表达式也附属编码就在情理之中了。我们确实这么做了,但是正则表达式如何选择编码的方式有点不一样。让我们来看一下具体是怎么回事。

先看一下会让我们惊讶的例子:

$ cat re_encoding.rb
# encoding: UTF-8
utf8_str   = "résumé"
latin1_str = utf8_str.encode("ISO-8859-1")
binary_str = utf8_str.dup.force_encoding("ASCII-8BIT")
utf16_str  = utf8_str.encode("UTF-16BE")

re = /\Ar.sum.\z/
puts "Regexp.encoding.name:  #{re.encoding.name}"

[utf8_str, latin1_str, binary_str, utf16_str].each do |str|
  begin
    result = str =~ re ? "Matches" : "Doesn't match"
  rescue Encoding::CompatibilityError
    result = "Can't match non-ASCII compatible?() Encoding"
  end
  puts "#{result}:  #{str.encoding.name}"
end

$ ruby re_encoding.rb
Regexp.encoding.name:  US-ASCII
Matches:  UTF-8
Matches:  ISO-8859-1
Doesn't match:  ASCII-8BIT
Can't match non-ASCII compatible?() Encoding:  UTF-16BE

前面讨论的源码编码在这一刻失效了,如你所见这里的正则表达式的编码是 US-ASCII 而不是 UTF-8。这初看可能觉得不可思议,但是这么做却是有很靠谱的原因的。

上述代码片段中的正则表达式字面量只包含了七位长度的 ASCII 字符,所以 Ruby 选择了更为简单的编码。如果将其设为 UTF-8,对处理 UTF-8 数据可能有点用,但是现在它却可以处理任何和 ASCII 兼容的数据。从输出你可以看到,这个正则表达式试图处理了三种不同编码的字符串,因为这些字符串的编码和 ASCII 兼容。(有一个失败了,因为我改变了处理数据的方式,把一个字符变成了两个字节,但是正则表达式还是做了努力。)第四个没有进行尝试,因为 UTF-16 和 ASCII 是不兼容的。

当然,如果正则表达式包含八位长度字符的话,你可以使用特殊的转义来改变编码,或者利用 Ruby 1.8 形式的编码选项,然后就可以得到一个非 ASCII 编码的正则表达式:

$ cat encodings.rb
# encoding: UTF-8
res = [
  /…\z/,       # source Encoding
  /\A\uFEFF/,  # special escape
  /abc/u       # Ruby 1.8 option
]
puts res.map { |re| [re.encoding.name, re.inspect].join(" ") }

$ ruby encodings.rb
UTF-8 /…\z/
UTF-8 /\A\uFEFF/
UTF-8 /abc/

上面用到的 /u 你或许还记得,它能在 Ruby 1.8 中获得一个 UTF-8 编码的正则表达式。/e (EUC_JP)和 /s (Shift_JIS 的一个扩展 Windows-31J)也同样可以继续使用。Ruby 1.9 还支持原来的 /n 选项,不过因为遗留原因会产生一些错误,所以建议不要再用了。下面会为你介绍一种生成 ASCII-8BIT 编码正则表达式的方法。

在 Ruby 1.9.2 中,“正则表达式可以匹配任意和 ASCII 兼容的数据”这种概念有了一个新名称:

$ cat fixed_encoding.rb
[/a/, /a/u].each do |re|
  puts "%-10s %s" % [ re.encoding, re.fixed_encoding? ? "fixed" :
                                                    "not fixed" ]
end

$ ruby fixed_encoding.rb
US-ASCII   not fixed
UTF-8      fixed

“编码锁定”的正则表达式,在处理不完全由 ASCII 字符组成(ascii_only?())的字符串时,如果这个字符串包含与正则表达式不一样编码的内容就会抛出 Encoding::CompatibilityError 异常。如果 fixed_encoding?() 返回 false,正则表达式则可以用来处理任何与 ASCII 兼容的编码。甚至还有一个名为 FIXEDENCODING 的常量可以用来禁止对 ASCII 的降级处理:

$ cat force_re_encoding.rb
puts Regexp.new("abc".force_encoding("UTF-8")).encoding.name
puts Regexp.new( "abc".force_encoding("UTF-8"),
                 Regexp::FIXEDENCODING ).encoding.name

$ ruby force_re_encoding.rb
US-ASCII
UTF-8

注意,如果为 Regexp.new() 指定了 Regexp::FIXEDENCODING 参数,正则表达式就会使用传入的字符串的编码。你可以使用这种方式生成采用任何一种编码的正则表达式,包括前面提到的 ASCII-8BIT。

只要正则表达式的编码和数据的编码是兼容的,那么模式匹配功能就可以正常运行。(好吧,事实上 Ruby 1.9 引入了一款新的很强大的正则表达式引擎叫做 Oniguruma,不过这不在本文讨论范围之内。)一般情况下,在 Ruby 1.9 中使用正则表达式的编码选项就可以处理大多数的数据了,不过如果在使用过程中遇到一些问题的话就,就不要使用简单的正则表达式字面量形式(/.../),而使用上面介绍的这种构建正则表达式的方法来选择与数据能够较好匹配的编码。

处理 BOM

有些多字节的编码方法推荐在数据的开头加入字节顺序标记(Byte Order Mark,简称 BOM),用来指明字节的顺序。UTF-16 就是个很好的例子。

不过,Ruby 实际上不支持 UTF-16 编码,你能使用的是 UTF-16BE 和 UTF-16LE,二者分别对应的是“大端”(Big Endian)和“小端”(Little Endian)字节顺序。这里的“字节顺序”是指最高有效字节出现在开头还是结尾处:

$ ruby -e 'p "a".encode("UTF-16BE")'
"\x00a"

$ ruby -e 'p "a".encode("UTF-16LE")'
"a\x00"

如果需要读取这些 UTF-16 编码的数据就必须知道使用了哪种字节顺序才能正确处理。你可以直接指明这些 UTF-16 数据的具体编码方式(UTF-16BE 或 UTF-16LE),或者为这些数据添加一个 BOM。

Unicode 的 BOM 就是数据开头处的一个字符 U+FEFF,这个字符无法反转,所以这么做可以简单而正确的指明数据的字节顺序。这种方法还有一个小有点是它指明了你要读取的是 Unicode 数据。很多软件都会在数据的开头检查这个特殊的字符,用它来设置正确的字节顺序,然后假设这个字符并不存在而将余下的数据显示给用户。

Ruby 1.9 不会自动为数据添加 BOM,所以如果你需要的话就要自己动手了。不过过程不是很麻烦,基本的思路是将所需的字节放置在文件的开头。例如我们可以为 UTF-16LE 文件添加一个 BOM:

$ cat utf16_bom.rb
# encoding: UTF-8
File.open("utf16_bom.txt", "w:UTF-16LE") do |f|
  f.puts "\uFEFFThis is UTF-16LE with a BOM."
end

$ ruby utf16_bom.rb
$ ruby -e 'p File.binread("utf16_bom.txt")[0..9]'
"\xFF\xFET\x00h\x00i\x00s\x00"

注意我使用了 Unicode 转义将 BOM 所需的字符加入到数据中。因为输出字符串是 UTF-8 编码,Ruby 必须要把它转码到 UTF-16LE,转码的过程中正确的保留了字节的顺序,如你在输出结果中看到的那样。

BOM 的读取也很简单,我们要取出相关的字节,看它是否和某个 Unicode BOM 匹配,如果匹配我们就使用相匹配的编码再次读取数据。我们可以这样编写代码:

$ cat read_bom.rb
class File
  UTFS = [32, 16].map { |b| %w[BE LE].map { |o| "UTF-#{b}#{o}" } }
                 .flatten << "UTF-8"

  def self.open_using_unicode_bom(path, *args, &blk)
    # 读取 BOM 查找编码
    encoding = UTFS[0..-2].find(lambda { UTFS[-1] }) do |utf|
      bom = "\uFEFF".encode(utf)
      binread(path, bom.bytesize).force_encoding(utf) == bom
    end
    # 设置编码
    if args.first.nil?
      args << "r#{'b' unless encoding == UTFS[-1]}:#{encoding}"
    elsif args.first.is_a? Hash
      args.first.merge!(external_encoding: encoding)
    else
      args.first.sub!(/\A([^:]*)/, "\1:#{encoding}")
    end
    # 处理文件的 open()
    if blk
      open(path, *args) do |f|
        f.read_unicode_bom
        blk[f]
      end
    else
      f = open(path, *args)
      f.read_unicode_bom
      f
    end
  end

  def read_unicode_bom
    bytes = external_encoding.name[/\AUTF-?(\d+)/i, 1].to_i / 8
    read(bytes) if bytes > 1
  end
end

# example usage with the File we created earlier
File.open_using_unicode_bom("utf16_bom.txt") do |f|
  line = f.gets
  p [line.encoding, line[0..3]]
end

$ ruby read_bom.rb
[#<Encoding:UTF-16LE>, "T\x00h\x00i\x00s\x00"]

上述的代码片段只针对几种 Unicode 的 BOM,不过你可以使用类似的方式处理其他 BOM:找到确定编码方法所需的字节,在读取数据之前设置好所学的编码,然后在读取数据之后再检验数据的编码是否正确。字符串转义在写入字节时很便利,binread() 在检查 BOM 时也很便利。

我建议为 UTF-16 和 UTF-32 这样的 Unicode 编码加入 BOM,但是请不要为 UTF-8 添加。UTF-8 编码的字节顺序是其规范所定的,永远都不会变化,所以正确读取 UTF-8 数据无需 BOM 相助。如果添加了 BOM 就会破坏 UTF-8 可以装作 US-ASCII 这个好处(假设所有的字符都是七位)。

11. Ruby 1.9 为我们提供了什么

这是本文的最后一节,我想重新审视前面讨论过的编码策略。你已经看到了,Ruby 1.9 加强了字符编码处理功能,所以我们要了解一下这对我们来说有什么变化。

UTF-8 仍然时首选

最重要的是要注意 Ruby 1.9 没有改变的事情,在第三节中我说过普遍适用的编码是 UTF-8,这一点现在仍然正确。

我仍然推荐使用 UTF-8,它基本上可以解决一切编码问题。我坚信我们可以也应该在源码中、在数据转换过程中、在转码输出时使用 UTF-8。我们越多的使用它,世界就会变得越美好。

如前所述,Ruby 1.9 也为 UTF-8 的处理增加了一些新特性。例如,你可以使用命令行开关(-E-U)设置编码对所有读取的数据进行转换。这种简便的方式对简单的脚本很有用,但是在处理正式的代码时最好能在源码中显式注明所需的编码。

新规则

Ruby 1.9 表面上为我们提供了对数据处理的全新能力,不过这些新能力也意味着新责任。从现在开始在 Ruby 1.9 中养成一些好习惯吧:

不错,这稍微增加了一点工作量,但却很值得这么做:这会在潜意识里增加你对编码的重视,也能让 Ruby 正确处理数据。

新策略

UTF-8 作为一个简单而强大的选择,Ruby 1.9 在字符处理方面为我们提供一些让人兴奋的新选项。在这我只举一个例子,引导你按正确的方向思考,但是天空很广阔,我确信在未来的几年中会出现更为优雅的用法。

当我将 FasterCSV 代码库转成 Ruby 1.9 的 CSV 标准库时,我坐下来认真的思考了应该怎样处理对多语言的支持。下面这些想法最终实现了我的计划:

  • 我们经常会抛给 CSV 处理器大量的数据,比如当我们转存数据库的时候
  • 我宁愿复出性能底下的代价也要将所有输入的数据转码到 UTF-8。我无法确定这个代价有多大,但是这要比 Ruby 1.8 只能按照字节读取要好一些。当然我想尽量保证代码库的处理速度够快。
  • 因为处理器总是直接从 IO 对象读取数据,所以我们已经有办法进行 UTF-8 转码处理。
  • CSV 是一种很容易处理的格式,它只需要支持 Ruby 提供的编码中的四种标准方法即可
  • 最后,当然我只是想熟悉一下怎么处理对多语言的支持

我把所有的这些思考结合在一起制定了一个策略:不转码数据,转码处理器。

如果转码数据,每次读取都会复出一些代价;而转码处理器则是一次性消费。数据中的字符是以数据所使用的编码方法存在的,对处理器进行转码则可以按照正常的方式读取然后处理这些数据。写入数据的过程仍然照此处理,除非用户特别声明不要这么做。这样用户就可以自由的为他们的数据选择编码了。

不难发现在处理过程中的某些地方可能很繁琐,首先要做的是找出数据真实使用的编码方法。下面这段来自 Ruby 1.9 CSV 标准库的代码可以做到这一点:

@encoding =   if @io.respond_to? :internal_encoding
                @io.internal_encoding || @io.external_encoding
              elsif @io.is_a? StringIO
                @io.string.encoding
              end
@encoding ||= Encoding.default_internal || Encoding.default_external

这段代码确保在读取数据之后 @encoding 被设置为我所要处理的数据的真正编码方法。如果设置了 IO 的内部编码,那么就把数据转码到这种编码,然后赋值给 @encoding;否则,@encoding 的值就是外部编码。这段代码还可以处理直接包含在 StringIO 对象中的字符串,这时我们就可以直接读取所含字符串的编码。如果以上的操作都没有找到编码,那么就采用默认值(很可能是还没有设定),因为 Ruby 就是这么处理的。

一旦知道了编码方法,我们就需要一些方法来使用这个编码生成字符串对象和正则表达式对象。这些方法是:

def encode_str(*chunks)
  chunks.map { |chunk| chunk.encode(@encoding.name) }.join
end

def encode_re(*chunks)
  Regexp.new(encode_str(*chunks))
end

如果你阅读了前面对转码是如何运作的讨论,你就会发现这些代码很容易理解。你可以向 encode_str() 传递一个字符串或多个字符串作为参数,这个方法会对每一个字符串进行转码,然后把它们连接(join())在一起形成一个完整的字符串。encode_re() 只是包含了 encode_str(),因为 Regexp.new() 会根据传入的字符串正确的设置正则表达式对象的编码。

接下来是比较繁琐的一步。在处理原始数据的时候,你要完全避免使用字符串和正则表达式的字面量形式。例如,下面的代码就是 CSV 标准库用来在读取数据之前准备处理器的:

# Pre-compiles parsers and stores them by name for access during reads.
def init_parsers(options)
  # store the parser behaviors
  @skip_blanks      = options.delete(:skip_blanks)
  @field_size_limit = options.delete(:field_size_limit)

  # prebuild Regexps for faster parsing
  esc_col_sep = escape_re(@col_sep)
  esc_row_sep = escape_re(@row_sep)
  esc_quote   = escape_re(@quote_char)
  @parsers = {
    # for empty leading fields
    leading_fields: encode_re("\A(?:", esc_col_sep, ")+"),
    # The Primary Parser
    csv_row:        encode_re(
      "\G(?:\A|", esc_col_sep, ")",                # anchor the match
      "(?:", esc_quote,                              # find quoted fields
             "((?>[^", esc_quote, "]*)",             # "unrolling the loop"
             "(?>", esc_quote * 2,                   # double for escaping
             "[^", esc_quote, "]*)*)",
             esc_quote,
             "|",                                    # ... or ...
             "([^", esc_quote, esc_col_sep, "]*))",  # unquoted fields
      "(?=", esc_col_sep, "|\z)"                    # ensure field is ended
    ),
    # a test for unescaped quotes
    bad_field:      encode_re(
      "\A", esc_col_sep, "?",                   # an optional comma
      "(?:", esc_quote,                          # a quoted field
             "(?>[^", esc_quote, "]*)",          # "unrolling the loop"
             "(?>", esc_quote * 2,               # double for escaping
             "[^", esc_quote, "]*)*",
             esc_quote,                          # the closing quote
             "[^", esc_quote, "]",               # an extra character
             "|",                                # ... or ...
             "[^", esc_quote, esc_col_sep, "]+", # an unquoted field
             esc_quote, ")"                      # an extra quote
    ),
    # safer than chomp!()
    line_end:       encode_re(esc_row_sep, "\z"),
    # illegal unquoted characters
    return_newline: encode_str("\r\n")
  }
end

不要纠缠于那些重度优化的正则表达式,这段代码的要点是理解数据最终是如何在 encode_str()encode_re() 之间传递的。

这些就是为了使用内置于数据的编码进行处理需要对 CSV 标准库改动的主要部分。不过我也添加了一些额外代码用来解决遇到的问题,不过它们和这个策略本身没有太多的关联:

  • Regexp.escape() 没有正确处理我测试的所有编码,在此之后虽然做了一些改进,但是上一次我测试发现还是不能支持一些奇怪的编码。鉴于此我就要自己想办法了,如果你想知道我是怎么做的,查看 CSV.initialize()链接)中的代码,看一下 @re_esc@re_chars 是如何设定的;然后再看一下 CSV.escape_re()链接)。
  • CSV 对行尾的检测会向前偏移固定的几个字节。在处理编码后的数据时就存在着安全问题,因为你总是会停留在字符的中间。如果你感兴趣的话,可以查看 CSV.read_to_char()链接)看一下我是如何处理这个问题的。
  • 最后,对 Ruby 支持的所有编码进行测试有点麻烦,因为有些是“空壳编码”。如何提出这些“空壳编码”请参照之前的讨论

与其他事情一样,这个策略尤其好的地方也有不好的地方。我已经说过了,避免使用一般的字面量形式需要一些很繁琐的操作。代码复杂性的提高使代码更加不易阅读,也增加了维护的难度。这就是要复出的代价。

不过我认为这显式出了利用 Ruby 的新特性能够完成设想的可能性。我们可以像过去一样,坚持认为 UTF-8 是解决问题的万能钥匙。这种坚持在多数情况下是正确的。但是现在我们有了一些对旧版本 Ruby 来说不太可行的新选择。