Ruby 与字符编码

作者: 零小白 | 来源:发表于2014-02-25 15:14 被阅读5475次

    本文是关于 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。

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

    相关文章

      网友评论

        本文标题:Ruby 与字符编码

        本文链接:https://www.haomeiwen.com/subject/jtvhtttx.html