Ruby 与字符编码

96
零小白
2014.02.25 15:14* 字数 3023

本文是关于 Ruby 的字符编码相关内容的一篇笔记,而不是一篇详细的教程。本文的主要内容参考Ruby 对多语言的支持

Ruby 在1.9版之前堪称是对字符编码支持最差的语言之一,而现在变成了支持最好的语言之一。在 1.8 中,一个字符串就是一连串的字节,而 Ruby 1.9 则要复杂的多,看起来就像在处理单元就是一个个字符一样。例如:

#encoding: utf-8
"1个".size  # 在 1.8 中返回的4(因为每个汉字占3个字节), 而在 1.9 中返回的是2

在 1.9 中字符串是一串被编码的数据,字符串不仅包含着原始的字节,同时还附属着编码信息来指明如何处理这些字节。我们可以通过 encoding 这个方法来查看。

puts str.encoding.name    #  UTF-8

该代码表明应该用 utf-8 字符编码来处理 str 变量指向的一串字节。同时,我们可以通过 bytesize 方法来查看有多少个字节。

我们可以通过 force_encoding 方法来显式的指定应该用一个字符编码来处理特定的字符串。我们没有改变编码数据,我们仅仅改变了处理这些数据的规则而已。

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

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

但是,这样做可能会很危险。因为我们所指定的字符编码规则可能会不能正确的处理这些字节。我们可以通过 valid_encoding? 方法来看看字节能够被顺利的处理。

# 数据有正确的 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

如果我们想改变数据本身,我们应该使用 encode (或者 encode! )这个方法。它会将字符转换成为另一个种编码形式。

# 合法的 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

值得注意的是,字符串的比较是依据的数据本身,也就是字节。

str = "中国"
puts str.encoding.name    # >> UTF-8
# 把数据转码到 GBK
str2 = str.encode("GBK")
p str == str2    # >> false

因此,在处理一组字符串时,应该首先把它们转换成为相同的 Encoding。我们可以通过 compatible? 方法来测试两种编码的相容性。如果返回 false 则表明两种编码不相容,如果要对二者进行操作至少要转换一个数据。否则返回一个 Encoding 对象说明两者相容,可以进行字符串连接操作,连接后的字符串采用返回值所对应的编码。

# 两种不同 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

显示迭代

Ruby 1.9 中的字符串不在是可以枚举的,也就不再包含 Enumerable 模块,同时也没有 each 这个方法。但是字符串依然提供了更加具体的几个迭代的方法。

utf8_resume = "Résumé"

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

同时,上边的方法可以通过不指定块来获得 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"]

三种默认编码类型

源码的编码

刚才已经说明,每一个字符串都有一个 Encoding 对象,也就是说在创建字符串的时候就要为它指定一个 Encoding 对象。例如:

str = "A new string"

Ruby1.9 的实现方法是,所有的源码都有一个 Encoding 对象,当你在源码中创建字符串时,源码的 Encoding 对象会自动赋予给字符串。

现在,我们需要知道源码如何确定一个源码的 Encoding 对象。Ruby 为此提供了很多方法。

  • 如果不指定,则 Ruby2.0 默认编码为 utf-8,而 Ruby1.9 默认编码则为 ASCII。
$ cat no_encoding.rb
p __ENCODING__
$ ruby no_encoding.rb
#<Encoding:UTF-8>
  • 如果需要设定源码的 Encoding 对象,则有一种推荐的方法叫做 “神奇注释”。如果文件包含 Shebang ,这个“神奇注释”必须出现在第二行,否则必须出现在第一行。
# encoding: UTF-8

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

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

# encoding: UTF-8

# coding: UTF-8

# -*- coding: UTF-8 -*-
  • 如果命令行使用了 -e 选项来执行 Ruby 代码,命令行会从所处环境获得源码的 Encoding。
$ echo $LC_CTYPE
en_US.UTF-8
$ ruby -e 'p __ENCODING__'
#<Encoding:UTF-8>
  • Ruby 1.9 仍然支持来自 Ruby 1.8 的 -K* 形式开关,包括本文大量使用的 -KU 开关。不过,这种方法的存在只是为了向前兼容性,“神奇注释”才是王道。
$ ruby -KU no_encoding.rb
#<Encoding:UTF-8>
默认的外部编码和内部编码

字符串经常还可以通过另一种方法来创建:从 IO 对象读取。这时候我们就不能简单的将源码的 Encoding 对象赋值给字符串了,因为外码数据与源码无关。因此,IO 对象至少要附着一种 Encoding 对象。而 Ruby 为此提供了两种编码:外部编码和内部编码。

我们通过设置打开文件的模式来设定外部编码和内部编码,并通过 IO 对象的 external_encoding 和 internal_encoding 方法来访问外部编码和内部编码。

外部编码是数据在 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"]

如果设置了内部编码,数据还是以外部编码读取,但是在创建字符串时会将其转到内部编码。这个程序带来了便利。

str1 = "中国"
str2 = nil
open("data.txt", "r:GBK") do |file|
  str2 = file.read
end

puts str1    # >> 中国
puts str2    # >> 中国
p [str1.encoding.name, str1.bytes]    # >> ["UTF-8", [228, 184, 173, 229, 155, 189]]
p [str2.encoding.name, str2.bytes]    # >> ["GBK", [214, 208, 185, 250]]

p str1 == str2    # >> false

我们通过设置内部编码,将字符串转换成为内部编码。

str1 = "中国"
str2 = nil
open("data.txt", "r:GBK:UTF-8") do |file|
  str2 = file.read
end

puts str1    # >> 中国
puts str2    # >> 中国
p [str1.encoding.name, str1.bytes]    # >> ["UTF-8", [228, 184, 173, 229, 155, 189]]
p [str2.encoding.name, str2.bytes]    # >> ["UTF-8", [228, 184, 173, 229, 155, 189]]

p str1 == str2    # >> true

在写模式下,外部编码以相同的方式工作。但是,此时你就没有必要显示的指定一个内部编码了,Ruby 会自动将输出的字符串的编码设为内部编码,如果需要的话将数据转换为外部编码。

open("data.txt", "w:UTF-16LE") do |file|
  puts file.external_encoding.name    # UTF-16LE
  p    file.internal_encoding    # nil
  data = "My data…"
  file << data
end

如果不设置它们,内部编码默认值是 nil 。外部编码默认值会从环境中去取得,类似于通过命令行设定源码的方式。

$ echo $LC_CTYPE
en_US.UTF-8

$ ruby -e 'puts Encoding.default_external.name'
UTF-8

这两个 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>]

其他细节

Encoding 对象的其他特性

Encoding 对象很简单,基本上只是表示了 Ruby 中编码的名称。另外,Encoding 对象存储了一些方法,在处理编码时很有用。

首先, list 包含 Ruby 中加载的所有 Encoding 对象。

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

其次,find 可以查找相应的编码。如果不存在则抛出一个 ArgumentError 的异常。

$ 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>'

有些 Encoding 对象名称不只一个,通过 aliases 方法可以返回一个 hash,通过键可以获得它的别名。

$ puts Encoding.aliases["ASCII"]
US-ASCII

$ puts Encoding.aliases["US-ASCII"]
nil

$ p Encoding.find("ASCII") == Encoding.find("US-ASCII")
true

另外 Ruby 中有一些是还没有完全实现字符处理的空壳编码,我们可以利用 dummy? 方法来查看。

encode = Encoding.find("UTF-7")
p encode.dummy?    #    true

我们可以找出所有的空格编码。

Encoding.list.select(&:dummy?).map(&:name)
处理二进制

不是所有的数据都是文本的形式,Ruby 提供了一种 Ruby 独有的编码—— ASCII-8BIT,这种编码单纯的把数据看做原始的字节码。你可以理解为关闭了字符处理,而只是处理字节。

$ 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}

上述代码中 BINARY 只是 ASCII-8BIT 的别名。Ruby 通过使的 ASCII-8BIT 和 US-ASCII 编码相兼容来方便数据的处理,即 ASCII-8BIT 的意思是 ASCII 外加上了一些其他自己,这样将有助于数据处理你可以将其中部分数据视为 ASCII。例如,PNG 图片的头几个字节就包含一个完整的 ASCII 字符串“PNG”。通过 ASCII-8BIT ,我们可以一个简单的 US-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。

$ 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é"]

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

告知 Ruby 将数据视为二进制的,而不想做任何转换是很简单的。在调用 open() 时在操作模式后添加一个 b 就可以了。

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

Ruby 1.9 对二进制标签有更严格的规则,如果 Ruby 认为需要(编码不兼容US-ASCII ?)而你没有提供这个标签的话它会发出一些抱怨。例如:

# 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

很容易修复,把 b 添加上去就行了。不过将这个过去丢掉的 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

另外,我们现在可以为打开的 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

值得注意的是,正则表达式的默认编码类型不是 UTF-8 而是 US-ASCII,这样的好处是,它可以处理任何和 US-ASCII 兼容的数据。

如果正则表达式包含非 ASCII 字符,或者通过编码选项来显式指定,那么我们就可以得到一个非 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/

Ruby 还支持 /e (EUC_JP)和 /s (Shift_JIS 的一个扩展 Windows-31J)也同样可以继续使用。Ruby 1.9 还支持原来的 /n 选项,不过因为遗留原因会产生一些错误,所以建议不要再用了。

在 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。

只要正则表达式的编码和数据的编码是兼容的,那么模式匹配功能就可以正常运行。

编程技术
Web note ad 1