Ruby 对多语言的支持

寻技术 Ruby编程 2023年10月11日 136

这是一篇翻译文章,原文链接 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(默认值)、E 和 S。较新版本的 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::InvalidEncoding 或Iconv::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 中被编码的数据越来越多,每一个字符串都需

关闭

用微信“扫一扫”