二重エンコードの話についての補足

AJITOで酒を飲みながらid:nTeTsと昨日書いた記事についてしゃべっていて、id:nTeTsがこの問題をPerl文字列の内部表現やUTF8フラグに関わる問題と認識している節があった。それは単に間違っていて、この問題はPerl固有ではないしPerl文字列の内部表現などは一切関係ないのだが、まあ混乱しても無理はないとも思うのでその辺について補足してみたい。なお僕はPerl5.8からPerlを使い始めたので、本当の歴史的な経緯などは知らない。現状の仕様からリバースエンジニアリングして歴史的経緯を推測したにすぎないので、誤りが含まれる可能性は指摘しておく。

Encode::encodeとEncode::decodeのシグネチャを仮想的に型付きで表現するとしたら、理想的には次のようになっているべきである。

//decodeはバイナリ(:byte[])から内部表現(:String)への写像
String decode(Encoding enc, byte[] src);
//encodeは内部表現(:String)からバイナリ(:byte[])への写像
byte[] encode(Encoding enc, String src);

だからこそ、「入り口でdecodeし(byteからStringを得て)、プログラムではString(内部表現文字列)を使い、出口でencodeせよ(byteを出力せよ)」となるわけだ。
もし型を厳しくチェックする言語であれば、二重エンコードは単にコンパイラシグネチャの不一致ではねられるので問題は顕在化しにくかっただろう。

String text = "unk";
//コンパイルエラー! Stringを期待しているのにbyte[]が渡された
byte[] fuck = encode(UTF8, encode(UTF8, text));

実際のPerlのEncode::encodeはStringだけではなくbyte[]も受け取り、二重エンコードを容易に生み出す。これは設計ミスではなく後方互換性のための仕様である

  • Perl5.6まではbyte[]のようなものとして文字列が表現されていた
  • その仕様に基づいて書かれた膨大なCPANモジュールが存在する
  • しかしPerl5.8でUNICODEをネイティブにサポートする
  • 過去の資産はすべてそのまま動くこと

というのがEncodeに課せられた使命だったと思われる。そのためにlatin-1とマルチバイトバイナリの文字列結合に際しての奇妙な仕様とか、Encode::encodeがflagged UTF8文字列以外は単に受け付けないという仕様ではない、といったデザインがなされたのだろう。

これは難しい問題なので、単純な解決方法はない。コストの支払いの一部は、Perl5.8以降に新たに多言語サポートするプログラムを書くプログラマに委ねられた。しかしこれはやむを得ないことである。そうではなくて過去の資産を書いた者にその支払いを求めていたら、あるいは過去の資産と断絶した新しい世界を新たに作っていたら、おそらく誰もついてこなかったと思う。(ANSI Cにはwchar_tというマルチバイト文字を取り扱う型が存在するが、Windows界隈以外でコレを使ってるのを滅多に見ない。)

で、Perlでは歴史的な経緯により二重エンコードが発生しやすいのではあるが、問題はPerl固有ではない。外部から入力されたデータが二重エンコードされていたら、結局他の方法でマルチバイト文字列を処理するプログラム言語であってもなんらかの対応をしなければならないのだから。