Rubyのエンコーディングとファイル名

昨日のまとめ。

  • ファイルを操作するスクリプトを書いたら身に覚えのないinvalid byte sequenceをくらった
  • てっきりファイル名がへんなファイルのせいだと思っていたらコマンドライン引数で変なバイト列をくわせていたからだった
  • ARGVをいじくってうまく避けるしかないのかな

そう、ファイル名の問題ではなかった。だが、そうだとしてもファイルを扱う以上はファイル名にも同じように気を付けなければならい。そんなことを書いた後でこう思った。でもコマンドライン引数と違って、ファイル名ならEncoding.default_externalに従って扱えるよね? あれ?

で、調べてみた。

Ruby 1.9.0、1.9.1、1.9.2はいまさら調べても意味がないのでおいておいて、1.9.3、2.0.0、2.1.0(現時点ではpreview2)で動作を確認した。

おおづかみなところではEncoding.default_externalにエンコードが設定される。(正確にはEncoding.find('filesystem')で得られるエンコードに従う。)

だから、おかしなファイル名があるとおかしなことになる。例として以下の状況を作っておく。三つのディレクトがあり、それぞれディレクトリ名はUS-ASCIIのfoo、UTF-8のばー、Shift_JISのばず

% mkdir foo   ばー   $(nkf -s <<<ばず)
% touch foo/1 ばー/1 $(nkf -s <<<ばず)/1

このディレクトリで以下のスクリプトを実行する。

#!/usr/bin/env ruby
# encoding: utf-8
Dir.entries('.').each do |x|
  p [x.encoding, x.valid_encoding?, x, (/./ =~ x rescue $!)]
end

すると1.9.3、2.0.0、2.1.0のどのバージョンでも次のような結果となる。(以降も各バージョンで結果は同じ。)

[#<Encoding:UTF-8>, true, "ばー", 0]
[#<Encoding:UTF-8>, true, "foo", 0]
[#<Encoding:UTF-8>, true, ".", 0]
[#<Encoding:UTF-8>, false, "\x82\u0382\xB8", #<ArgumentError: invalid byte sequence in UTF-8>]
[#<Encoding:UTF-8>, true, "..", 0]

ファイル名といっておいてディレクトリを扱っているのは、ファイル名を関接的に得るのは通常ディレクトリ操作にるよるからだ。

ファイルを扱うためにはファイル名が必要で、そのファイル名は何らかの形で与えられた文字列である。その文字列は、スクリプト中に書かれている、コマンドライン引数に由来する、ネットワークを含む入出力から切り出す、そしてディレクトリから得る、などとなる。このうちの最後のディレクトリ由来以外は通常の文字列の扱いの中から生まれてくるものなので比較的注意が行き届きやすい。(コマンドライン引数ではまった私はともかくとして。)

ディレクトリ操作は、こうした入出力やコード中の文字列と、ちょっとだけ異なるところがある。次のコードを実行してみるとその一端が分かる。

#!/usr/bin/env ruby
# encoding: euc-jp
Dir.glob('*', File::FNM_DOTMATCH).each do |x|
  p [x.encoding, x.valid_encoding?, x, (/./ =~ x rescue $!)]
end

結果は以下の通りで、これも1.9.3、2.0.0、2.1.0ともに同じ。

[#<Encoding:EUC-JP>, false, "\xE3\x81\x{B0E3}\x83\xBC", #<ArgumentError: invalid byte sequence in EUC-JP>]
[#<Encoding:EUC-JP>, true, "foo", 0]
[#<Encoding:EUC-JP>, true, ".", 0]
[#<Encoding:EUC-JP>, false, "\x82\xCE\x82\xB8", #<ArgumentError: invalid byte sequence in EUC-JP>]
[#<Encoding:EUC-JP>, true, "..", 0]

これはDir.globの引数*のエンコーディングに従ってディレクトリが読み取られていることを示している。つまりDir.globEncoding.default_externalに従っていない。

とだけいうと例外を作るイケナイこっぽくきこえるかもしれないが、これらの二つのメソッドは複数の起点を指定できるという事情がある。たとえば以下のように、ものによってエンコーディングを合わせることができる。

#!/usr/bin/env ruby
# encoding: utf-8
require 'find'
p1 = 'foo/*'.encode('binary')
p2 = 'ばー/*'.encode('utf-8')
p3 = 'ばず/*'.encode('shift_jis')
Dir.glob([p1, p2, p3]) do |x|
  p [x.encoding, x.valid_encoding?, x, (/./ =~ x rescue $!)]
end

実行結果は以下の通り。

[#<Encoding:ASCII-8BIT>, true, "foo/1", 0]
[#<Encoding:UTF-8>, true, "ばー/1", 0]
[#<Encoding:UTF-8>, false, "ばー/\x82\u0381[", #<ArgumentError: invalid byte sequence in UTF-8>]
[#<Encoding:Shift_JIS>, true, "\x{82CE}\x{82B8}/1", 0]

Dir.globDir[]以外のメソッドはどうなっているか。

Dir.newDir.openDir.foreachDir.entriesの四つだが、最初に書いた通りこれらはEncode.default_externalに従っている。ファイルと同じような挙動になる。どうも2.0.0ではドキュメントから落ちてしまっていたようだが、これら四つのメソッドはオプション引数としてエンコーディングの指定を受け入れる。指定方法は異なるがこれもファイルと同じである。(2.1.0のドキュメントには説明が加えられた。)

ちなみに、ここで扱っているサンプルのような状況ではDir.globたちとDir.newたちの違いが目立つが、通常は外部から得た文字列が使われることが多く、それらは特に指定しなければEncoding.default_externalに従う。よって普通の、というか、サンプルコードではない何かの仕事をするためのコードでは、結果的として両グループの挙動はわりと似たような結果となる。(ことが多いと思う。)

Dir.open('foo', encoding: 'binary') {|x| ... }

それで、えーと、たぶん横滑ししていると思うのだけど、こうして順番に確認していってみると、つまるところは正規表現なんだっていう話。すべては正規表現。便利なのに。正規表現はやっぱり「文字」を扱うツールだから、エンコーディングが実態と合っていないとうまく動けない。なので正規表現が使われるところでは、そのあたり、きちんとクリアしておきましょう。そういう普通のことだった。

あと、あれです。Find.findの動作はDir.globと同じ。でも、そのあたりの挙動について2.1.0で修正が入っている。変更の意図と変更後のコードの意図はよく分かるのだけど、変更前のコードがどういう状況でまずいことになるのかいまいち理解できなかったのが今もちょっと気になっている。いや、なんか、それなりにいろいろ試したんだけども。