図解: Perl と Unicode 文字列

id:tomi-ru さんが [http://e8y.net/mag/015-encode/:title] というとてもプラクティカルな [http://search.cpan.org/perldoc?Encode:title=Encode] 入門をお書きになったので,わたしも違う切り口で書いてみたくなりました。

いちおうの基礎(読み飛ばし可)

この2つは異なります。とくに知らなくても下記の文書を読むことはできますが,理解しているとためになります。くわしく知りたい人は自習してください。

実際にはエンコーディングと文字セットは深く関連しており,独立した概念とはいいきれません。たとえば,EUC-JP ではアラビア文字を表現することができません。このように,ある「エンコーディング」が表現できる「文字セット」はアルゴリズムや仕様により制限されています。

エンコーディングとは「何らかの文字セットをデータ列に変換するルール」,なのですが,データ列はたいていの場合「バイト列」です。以下ではこの「バイト列」を byte stream と呼びます。

Perl と文字列

  • Perl(5.8 以降)は,下記の2種類の文字列をネイティブにサポート*1しています。
    • Latin-1 文字列*2(今回は触れません)
    • Unicode 文字列
  • Perl で素直に使える文字セットとしては*3 Unicode が一番多くの文字種・文字数をサポートしています。

(日本語)テキスト処理を行うとき,

  • EUC-JP や UTF-8 などのエンコーディングで表現された「byte stream」で文字列を保持する
    • 文字列操作をおこなう際,各エンコーディングの特性に配慮したコーディングをおこなう
  • Unicode 文字列」をアプリケーション内部ロジックで保持する
    • 文字列操作は Perl 組み込みの機構を利用する

の選択肢があります。


もしあなたが

場合,アプリケーション内部で扱う文字列を「Unicode 文字列」に統一するとしあわせになれる,かもしれません。

PerlUnicode 文字列を扱うときのマナー

  • 外界からやってくるものは,原則的に((PerlIO レイヤやモジュールを利用する場合,かならずしも byte stream で入出力されるとはいいきれないので,原則的に,です。))すべて byte stream です。
  • 外界へは,原則的にすべて byte stream で出力します。
    • これらの byte stream は,Shift_JIS 文字列を表すのかもしれないし,EUC-JP 文字列を表すのかもしれないし,etc etc ...
  • Encode::decode() で byte stream から Unicode 文字列に変換することができます。
  • Encode::encode()Unicode 文字列から byte stream に変換することができます。

そのドメインで扱う文字列は何?

  • それぞれのドメイン*4でどの文字列の種類(Unicode 文字列か byte stream か)を利用しているのかを意識することが大切です。
  • それぞれのドメインで扱う文字列の種類を統一することも大事です。
  • ドメインの境界でのみ文字列を変換するようにします。
  • 外部モジュールがどのような文字列を扱うのかを意識することも必要です。

なんだかめんどうですか?でも「境界」さえ意識すればそんなに難しくありません。それにこういう考え方は他の局面(下記)でも役立ちますよ。

アナロジー: そのドメインで扱うデータは何?

*5

  • 他のデータと変換可能性が高く,癖の少ないデータをコアロジックで利用すると便利です。

例題

例題として,HTTP コンテンツを半角に変換して帯域圧縮してくれるプロキシ……はさすがに書くのがたいへん(かつ挙動を示しにくい)なので,LWP でコンテンツを取得して半角に変換し表示するプログラムを書いてみます。

どんな文字列を扱っているのか,をわかりやすくするためハンガリアン記法をあえて導入しています。ハンガリアン記法については「間違ったコードは間違って見えるようにする - The Joel on Software Translation Project」が秀逸ですよね。

use strict;
use warnings;
use utf8;   # for literals in UTF-8

use Encode ();
use LWP;    # this should load everything I need

my $req = HTTP::Request->new( GET => $ARGV[0] );

my $res = LWP::UserAgent->new()->request($req);
exit  if ! $res->is_success;

#### 入力(byte stream の世界)
my $bContent = $res->content;

#### 変換: コアロジックのため Unicode 文字列に
my $uContent = Encode::decode('UTF-8', $bContent);

#### コアロジック(Unicode 文字列の世界)
my $uOutput =  UZ2HKana($uContent);
   $uOutput =~ tr/ 0-9a-zA-Z/ 0-9a-zA-Z/;

#### 変換: 出力のため byte stream に
my $bOutput = Encode::encode('UTF-8', $uOutput);

#### 出力(byte stream の世界)
print $bOutput;

exit;

# 以下のコードは深くつっこまないで ><
my (@hankaku_kana, $hankaku_kana_offset);
sub UZ2HKana {
    my ($u) = @_;
    if (! @hankaku_kana) {
        @hankaku_kana
            = grep { ! /^\s*$/ } split m{( . [゙゚]? )}xmso, <<'END_KANA';
                ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトド
                ナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲン
                ヴヵヶヷヸヹヺ・ー
END_KANA
        $hankaku_kana_offset = ord('ァ');
    }

    $u =~ s{([ァ-ー])}{$hankaku_kana[ord($1) - $hankaku_kana_offset]}gxms;

    return $u;
}

今回はプレーンな .pl ファイルなのであまり分離できていませんが,

  • byte stream な入力(レスポンスの content は byte stream*6 )を Unicode 文字列に変換し
  • Unicode 文字列で処理を行い
  • byte stream に変換して出力する

という流れは示せているのではないかと思います。

で,

% perl hankaku.pl http://b.hatena.ne.jp/

ってやると

...... snip snip snip ......

<li><a href="/">トップ</a></li>
<li><a href="/hotentry">人気エントリー</a></li>
<li><a href="/entrylist">注目エントリー</a></li>
<li><a href="/news">ニュース</a></li>
<li><a href="/video">動画</a></li>
<li><a href="/asin">商品</a></li>
<li><a href="/hotkeyword">注目キーワード</a></li>

...... snip snip snip ......

となります。ヤッタネ!

ところで,そらぞらしく一度 byte stream な content を取得して Encode::decode()Unicode 文字列化しています。が,実は HTTP::Response のベースクラスである [http://search.cpan.org/perldoc?HTTP::Message:title=HTTP::Message] には decoded_content() というメソッドがありまして,これはレスポンスの charset 等を見てよしなに Unicode 文字列に変換してくれます。なので,

my $uContent = $res->decoded_content( default_charset => 'UTF-8' );

で一足飛びに Unicode 文字列で取得することができます((レスポンスに charset が指定されていない場合に備えて default_charset を念のために指定しています(デフォルトだと ISO-8859-1 なもので)。あとさすがに <meta http-equiv="Content-Type" content="..."> は見てくれないみたい。))。

おわりに

結局 How? の話になってしまいました。ほんとうは Why? を書くべきなんでしょうが……難しいです。

テキスト処理の場合に内部は「Unicode 文字列」に統一するといいよ,というメッセージを書いてきましたが,これはあくまで原則論です。たとえばドキュメントの行数をカウントしたり,各行に行番号を付与したりする場合,わざわざ Unicode 文字列に変換する必要はありませんよね。TMTOWTDI ともいいますし適材適所で!


以下,蛇足です。

むかしむかし(アプリケーション内部エンコーディングとして EUC-JP を利用していた頃)

かつて,内部の文字列(とソースコード)を EUC-JP byte stream で統一していた時代がありました。

一見 Unicode 文字列の場合と同じようですが,全然違います。たとえば,

$str =~ s///;

となっているところですが,Unicode 文字列の場合,

$str =~ s/\x{65e5}/\x{30cb}/;

のように,ある一文字を別の一文字で置換しています。いっぽう EUC-JP の場合は,

$str =~ s/\x{c6}\x{fc}/\x{a5}\x{cb}/;

のように,連続する2バイトを別の2バイトで置換しています。このため,たまたまある文字の2バイト目が \x{c6} で続く文字の1バイト目が \x{fc} の場合,おかしな置換がなされてしまいます。

EUC-JP のメリット
  1. EUC-JP では漢字の表現が 2 バイトですみます。UTF-8Unicode の符号化形式の一種)ではたいてい 3 バイト必要になります。
  2. EUC-JP ではたいていの場合*7,半角文字は 1 バイト,全角文字は 2 バイトになります。画面上を占める幅との相関が直感的です((Perl で Unicode Character について触る - daily dayflower の「文字の幅を調べる」で書いたとおり,Unicode 文字列でも [http://search.cpan.org/perldoc?Unicode::EastAsianWidth:title=Unicode::EastAsianWidth] を使うと文字幅を取得することができます。))。
  3. たとえば Web アプリケーションの場合,HTML・外部テキストデータ・データベース等のエンコーディングEUC-JP に統一しておけば,入力の段階で EUC-JP に変換さえすれば*8以降変換の必要がありません。アプリケーション内部エンコーディングEUC-JP)のまま HTML やデータベースに出力することができ,コード上は簡潔に書けます*9
Unicode のメリット
  1. (繰り返しになりますが)Unicode が一番多くの文字種・文字数をサポートしています。
    • このため,あなたが Unicode centric で書いたアプリケーションを世界中の人が使うことができます(EUC-JP ではこうはいきません)。
  2. また,このため様々なエンコーディング(で表現できる文字セット)から Unicode 文字セットに変換しても情報の欠落はおきません*10
  3. PerlUnicode をネイティブにサポートしているため,バイト境界(文字を byte stream で表現する際の2バイト目,など)を意識する必要がありません。

もし,あなたが上図のようにドメイン境界を意識しつつ EUC-JP を使っていたのだとしたら。代わりに Unicode 文字列を使うことをためらう理由はありませんよね((パフォーマンスが心配ですって?))。

もし,あなたが上記「EUC-JP のメリットその3」を享受しているとしたら。あなたの書いている分野ではおそらく今回述べたようなことは必要ないのでしょう(個人的プロジェクトである,とか,慣れとスピード重視,とか)。でも,もし EUC-JP のバイト境界等々に悩まされるようになったら,ふたたび今回のお話を読み返してみてください。

隣の芝生は?

Ruby に明るくないため,以下間違いもあるかと思います。ご指摘歓迎。)

Ruby 1.9 の String クラスのドキュメントをもとに Ruby 1.9 での多言語文字列の扱いを図示してみました。

Ruby 1.9 では,これまでの Ruby と異なり,文字列オブジェクトごとにエンコーディング情報(encoding プロパティ)をもっています。つまり Perl のように文字列自身を(Unicode などの統一文字セットに)変容させる必要はありません。文字列オブジェクトへの演算(文字数カウントや切り出し,正規表現など)はそれぞれのエンコーディング情報に基づいてなされます。

レガシーコードでの互換性(文字列の比較や結合など)がやや微妙な気もしますが,うむむ……意欲的なしくみです(個人的にはうらやましい)。すべてのオブジェクトがクラスインスタンスであるという特長が生かされていますね。

いささか不正確な部分はありますが*11Perl とのメカニズムの違いを図示してみました。

(改変しました。旧版は にあります)

Perl と思想がそもそも違いますね((Ruby 1.8 以前では各エンコーディング用文字列操作エンジンを $KCODE という組み込み変数で(グローバル)ステートフルに切り替えている,ということだと思います。))。Perl の場合,「共通語」である Unicode 文字列に変換してどうこうしようという思想であり,Ruby の場合,もともともっている属性(エンコーディング等)のままどうこうしようという思想であるといえます。

さて,各言語で新しいエンコーディングをサポートするにはどうすればいいでしょうか。

Perl の場合,癖のあるエンコーディング*12でなければ,Unicode Character との変換マップさえ用意すれば,コアに組み込まれた Unicode 用文字列操作ライブラリ・正規表現エンジンを利用することができます。

いっぽう Ruby の場合,新しいエンコーディングのために文字列操作ライブラリ・正規表現エンジンに都度都度手を入れる必要がでてきます。
2008-06-23 追記:

まつもと: Ruby 1.9の場合、Cのライブラリを記述することで新しいエンコーディング対応を追加できます。「新しいエンコーディングのために文字列操作ライブラリ・正規表現エンジンに都度都度手を入れる必要」はありません。

http://d.hatena.ne.jp/dayflower/20080620/1213925271#c

実際にはマルチバイトから文字コードへの変換など,エンコーディングごとの差分を吸収する10個強の関数を定義すれば,文字列操作・正規表現エンジン自体に手を入れる必要がなく,新しいエンコーディングに対応できるようです。Ruby のソースツリーの enc/ ディレクトリ以下を見ればわかるかも。

2008-06-23 追記

ブコメなどで「文字列種」というのがわかりにくいなどのご意見がありましたので,とっぱらってみました。曖昧な表現をすることにより逆にクリアになったかと。基本的に記事の修正は <ins>, <del> を使っているのですが,読みづらくなるので今回は直接修正してあります。また,Latin-1 だの byte stream だのがごちゃごちゃでてきてややこしいのは未熟な文章によるものです。混乱させてごめんなさい。

もともとEncode::* の使い方はだいたいわかってきたけど,どういう指針で書けばいいかわからない,という方が対象のつもりでした。How To ではなく概念的な話を書こうと思っていたのですが,それゆえ反って難しい内容になってしまいました。図版と簡単な説明だけあげればよかったかな。文字セットだのエンコーディングだのの話は今回の本筋と関係ないので分離しようかなぁ。

*1:後日書きますが,Latin-1 をネイティブサポートしていると断言するのはやや問題があります。

*2:Unicode は「文字セット」なのに Latin-1 は「エンコーディング」という非対称性がありますが,深くつっこまないでください。

*3:Perl で文字鏡番号や TRON コードを扱うモジュールはあるのでしょうか?

*4:ここでいうドメインとは,もちろん domain name のことではなく,あなたが解決しなければならない問題領域のことです。

*5:自力で SQLエスケープ処理を行うことはまずないのですが。

*6:今回は UTF-8 で決め打ちしています。

*7:半角カナの場合は異なります。

*8:いまどきのブラウザでは HTML が EUC-JP で記述されていれば,フォーム等の入力も EUC-JP になるようです。

*9:これはアプリケーション内部エンコーディングUTF-8 byte stream で統一しても同様のことがいえます。せっかく Perl がネイティブにサポートしている機能(正規表現など)を無視することになってしまいますけど。

*10:ただし,逆は真ならず。

*11:図では省略していますが Perl には Unicode 用の文字列操作エンジンのほかに Latin-1 用の文字列操作エンジンも存在します。また,Ruby では各エンコーディング用の文字列操作エンジンがおのおの独立して存在しているように描きましたが,実際にはコアライブラリや http://www.geocities.jp/kosako3/oniguruma/ が各エンジンを内包しています。

*12:ISO-2022-JP のようにシフト状態が存在するエンコーディングの場合,変換マップを用意するだけではうまく変換できません。ロジックを記述する必要があります。