本文是关于 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。
只要正则表达式的编码和数据的编码是兼容的,那么模式匹配功能就可以正常运行。