2013/12/07 追記: Ruby 2.1.0 には不正バイトを除去する専用のメソッド String#scrub が追加されています。詳しくは Ruby 2.1.0 に追加される String#scrub の紹介 を参照

こんにちは @sonots です。

Ruby の invalid byte sequence in UTF-8 例外を encode("UTF-8", "UTF-8") で回避するのはおかしいよ、という話をします。

Ruby 1.9 でUTF-8的に正しくないバイト列がある文字列を扱っていると、正規表現マッチや gsub といったメソッドを使っているところで ArgumentError: invalid byte sequence in UTF-8 例外が発生します。文字列を生成したときではなくて正規表現マッチなんかをしたときに始めてエラーが出るのですが、その辺の話については @tmtms さんのブログの記事が大変詳しくて勉強になるのでそちらを見るとよいかと思います。

再現コードはこんなかんじですね。
str = "\xff"
str.force_encoding('UTF-8')
 
begin
  str =~ /hoge/
rescue => e
  p e # ArgumentError: invalid byte sequence in UTF-8
end
そこでどう解決するのかぐぐるわけですが、"invalid byte sequence in UTF-8" で検索すると、
str = str.encode("UTF-8", "UTF-8", :invalid => :replace, :undef => :replace, :replace => '?')
のようにすると回避できるよ、という記事がいくつかヒットします。ですがそれは間違いです(ブログを晒すのははばかられるのでリンクは貼りませんが、ぐぐるとすぐみつかるでしょう)。これには2つの間違いが含まれています。

1. Ruby の String#encode メソッドは dst_encoding と src_encoding に同じエンコードを指定すると変換処理を行いません。

Ruby Doc に書かれていないのでちょっと不親切な気がしないでもないですが、そういう仕様になっています。不正な文字列を replace する処理も行わないということですね。コードにするとこんなかんじですかね。
str = "\xff"
str.force_encoding('UTF-8')
str = str.encode("UTF-8", "UTF-8", :invalid => :replace, :undef => :replace, :replace => '?')
p str #=> "\xff" のまま

begin
  str =~ /hoge/
rescue => e
  p e
end
そこについては、そういう仕様なんですが、実行してみた人はあれ?と気付くでしょう。そう、例外が起きなくなるのです。正確には encode("UTF-8", "UTF-8") だけでもOKです。
str = "\xff"
str.force_encoding('UTF-8')
str = str.encode("UTF-8", "UTF-8")

begin
  str =~ /a/
rescue => e # No throw!!
  p e
end
この性質を逆手におって、String#encode を呼べば例外が出なくなるよ、とバッドノウハウを教えているブログも見つかります(例によってリンクは貼りませんがぐぐると見つかるでしょう)。これが2つ目の間違いです。

2. String#encode("UTF-8", "UTF-8") で invalid byte sequence 例外が出なくなるのは Ruby のバグです。

チケットもあがっているようです。https://bugs.ruby-lang.org/issues/6190

ということを @repeatedly さんが成瀬さんに聞いてくださいました ^ ^ ということでこの性質を利用するのは間違いです。 とのことで 2.0や次の1.9.3 patch release からはおそらく例外があがることになると思われますので、そのようなコードを書いてしまっている方は今のうちに直しておきましょう!
追記: @n0kada さんによると 2.0 系統ではすでに修正済みとのことです!

こんなかんじになりますかね # invalid byte を除去する専用のメソッドが欲しいですね ...
str = "\xff"
str.force_encoding('UTF-8')
str = str.encode("UTF-16BE", "UTF-8", :invalid => :replace, :undef => :replace, :replace => '?').encode("UTF-8")
p str #=> "?"

begin
  str =~ /a/
rescue => e
  p e
end
2012/03/22 に起票されたチケットなので、けっこう前からあるバグで、ぐぐった感じだとバッドノウハウ化しているようなので今のうちに警鐘をならす意味でブログを書きました。

最後に

ちなみにここで @repeatedly さんが話している pull req をくれた人、というのが私のことですね ^ ^; 例外を回避するためにこの処理をいれたコードを @tagomoris さんの fluent-plugin-parser のほうに pull request を送っておりまして(コレ)、ツッコミを受けたという話でした。

バグだとは気付きませんで、テスト通ってるし、あってるんじゃない?とか浅い考えをしておりました。すみません。そういう深い所まで見れるようになっていかないといけませんね。精進いたします。

@tagomorisさん、@repeatedlyさん、@nalshさんありがとうございました!

あと、修正版の pull req 送ってあるのでよろしくおねがいいたします :D > @tagomorisさん