面倒事に巻き込まれないための文字列処理指針

ytqwerty2008-03-29


最初に言っておく。
面倒事に巻き込まれたくないので参考URLとかは一切出さないことにします。
文字コードの選択

世の中には、エスケープがあって、コード体系の切り替えが入る体系(例えばKanji-IN,OUTのあったN88 DISK-BASICの文字列*1やそのへん。今ならFireFoxの文字エンコードメニューにあるISO-2022-JPとか)もありますが、そういうのはもう嫌になるぐらい扱いづらいので、内部コードとしては使わないのが普通です。ワードサイズが7ビットしかないCPUならともかく、そうじゃなければ外部保存形式専用と割り切っていいでしょう。要するに読み書きする時に内部で使っている形式との間で変換するわけですね。

……というわけで、まず発想の基本として、プログラム内部で扱う文字列の形式は、お仕着せのものを使う必要はありません。えーと、ファイルからSJISで読んで画面に表示するにはWの付くAPIに渡してあたふた。charとwchar_t、AnsiStringとWideString、どっちで持ってればいいんだ?気にしなさんな。もし内部処理に最も都合がいい形式がUTF-32であれば、迷わずそれを選ぶべきです。もし内部処理に最も都合がいい形式がEUC-JPであれば、迷わずそれを選ぶべきです。
実際問題、内部UTF-32 or 内部UTF-8で、API呼ぶたびにUTF-16に変換して、ファイルI/OのたびにSJIS(というよりロケール文字コード)に変換して、とやってる処理系なんて山のようにあります。

仮に内部コードを自分で決められるなら、UTF-16は避けた方がいいですね。16ビット単位なので、エンディアン気にしてメモリイメージのまま読み書きできないし、同じデータ型にバイナリイメージ突っ込めないし、16ビット単位にしたことで楽になっているかと言えば、大雑把にやる分には楽ですが、真面目に処理するとなるとやっぱりMBCS処理しないといけないし。

SJISは、バイト数が文字数という素晴らしい利点があります。厳密には違うんですけど、大雑把な対応でいいならば、固定長フォントの世界では超絶便利な内部コードです。
ただまあ、SJISは2バイト目の取りえる値と1バイト文字が重なっているため、結構厄介な文字コードでした。
UTF-8は、3バイト文字4バイト文字とあるのが面倒ですが、1バイト文字、先頭バイト、2バイト目以降が明確に分離されていて、すっきりしています。あとビットシフトの法則が「シフト」JISより綺麗です。どうでもいいです。それくらいならUTF-16も同じなのですが、8ビット単位なのは大きな利点です。バイナリイメージ突っ込めますし。

じゃあなんでUTF-16が重要か?
短い答え。Windows APIが取る文字コードUTF-16だから。
変換コスト無しでOSに渡せるため効率がよい。

まあ悪い話ばかりじゃないです。早期にUTF-16を採用してくれたため、UTF-16で上から下まで統一されているのは大きなメリットです。
文字列処理で一番厄介なのは、複数の文字コードがメタ情報も無くやってくることですからね。
環境変数変えたら日本語で付けたファイル名が総化けのどっかのOSは、ねえ。

入れ物

文字コードを決めたら、入れ物を用意します。
入れ物に何を入れることができるか、と、言語ランタイム/ライブラリがどこまでサポートしているかは全く別の話です。
DBCSを入れることになっているAnsiStringですが、データとしては、任意の長さの8ビットデータ列であれば何でも格納できます。EUC-JP入れようがUTF-8入れようが、内部処理として使う分には何でもOK。「UTF-16をリトルエンディアンで」AnsiStringに突っ込むなんてのもOKです。
これがNUL終端文字列だと少し話は違っていて、「UTF-16をリトルエンディアンで」入れてしまうと上位の0が終端と被るため、長さの取り扱いが正しくできなくなります。そもそも長さという文字列処理においてしょっちゅう使うデータを得るために、文字列全部走査しないといけないなんて有り得ないので、NULL終端文字列は入れ物の形式としては激しく劣ります。バイナリイメージ突っ込めないし。文字の配列を使うなと言ってるのではないですよ。長さは別に持っとけってだけの話です。

ひたすらバイナリイメージにこだわってますが、一部整合性が取れてないだけで処理が狂うようなのは良くないため、入れ物には、ただひたすらデータをそのまま持ち運べることが求められます。

テキストファイルにNUL混じってただけで、あるツール通したらNUL以降がさっと消えててしかもしばらく気付かなかったりしたら、怒るでしょ?

あとはまあ高速な結合が必要ならロープ作るとか。末尾への追加が多いようならlengthとcapacityを別管理するとか。或いはギャップバッファ構成するとか。処理の都合に応じてその辺も工夫します。そういった処理上の要請が無いなら、言語が提供する文字列型に、目的とする文字コードが入る分にはそれ使っておけばいいんじゃないでしょうか。

Haskellみたくデフォルト文字列型がリンクリストだったりすると、悩ましいですが……。

余計なことはしない

Unicodeには、実に色々な制御文字や取り決めがあります。
例えば結合文字とか見つけたら合成してあげたくなりますが、しない。
変な制御文字も全部スルー。
今まで、関係ない文字列処理の最中、文字列中に08(BS)見つけたらその前の文字と一緒に消したりしてあげてました?
お節介なだけです。余計なことすんな、が正しい反応でしょう。

大体Unicodeはまともに扱おうとすると超巨大なライブラリが必要になる上に、RTLなんて並大抵の苦労じゃ実現できないものまであります。
どこまでやるかはアプリケーション次第です。
メモ帳が実装しているからって、盲目的にやる必要なしです。

改行やTABをどう見せるかはアプリケーション次第(印刷だとマージン取りたいですよね!エディタですとマーク欲しいですよね!)だったじゃないですか。

だから、言語ランタイムに期待することも、データ壊さないことだけです。逆に、言語ランタイムが色々処理してくれると、スルーしたいときに逆に厄介なことに……。

文字数単位で処理をしようとしない

ここでは、SJISでもUTF-8でもEUCなんちゃらでもなんでもいいのでとにかくMBCSをイメージしてください。UTF-16も16ビットが「バイト」なだけで同じですね。1バイトが何ビットかなんてのはこの際どうでもいい話です。

文字列の途中を抜き出す処理で、MBCSの途中を切ってしまったら……化けます。当然。

ですが、それを恐れるあまり、「文字」数単位でなにもかもやってしまおうというのは余りにもそのあれです。MBCS恐怖症過ぎ。

悲しいことに、日本人の語る文字列処理は扱いにくいSJISの反動からか、文字単位で処理をしたい要求が強そう(一部の声がでかいだけの気もする)です。「MID$は日本語に対応していないからKMID$を使え」系の主張を見かけたら、眉に唾をつけてください。

大抵のMBCSは、1バイト目を見れば、その文字が何バイトかわかります。そうじゃない文字コードは……そりゃ世界は広いので探せばあるでしょうけれども。まあ、その場合は次のバイトを見て、それで決まらなければ次のバイトを見て、決定していくだけです。いくらなんでも前から走査できない文字コードは無いでしょう。

というわけで、文字列中の、ある文字が開始するインデックスがわかっている場合、次の文字が開始するインデックス、その次の……というわけで以降の文字が開始するインデックスは確実にわかることになります。

ということは、気をつけていれば、文字を途中で切ってしまうようなインデックスを作ってしまうことは、避けられます。

文字を途中で切ってしまうようなインデックスがプログラム中に存在しない以上、文字列の途中を抜き出す処理は、常に文字の境界で行われることになります。

そもそも、MBCSの面倒ごとを避けるためなんて消極的なものではなく、本当に文字数単位で何かしたいことってありますか?
原稿書いているときは文字数のカウントが出てくると便利ですね。
文字列が保持する「文字数」は、文字列を描画する時に必要な「幅」と同じような概念です。
KLENやKMID$、KINSTRは便利ですが、遅いのです。本当に文字数が必要ならともかく、そうではないところに使うと、無駄に計算オーダーを上げてしまいます。

気をつけるべきは、文字を途中で切ってしまうようなインデックスを作らないこと。これだけです。

ちなみに逆方向に辿る時は、1バイト文字、先頭バイト、後続バイトが全て被っていない体系(UTF-8,16)なら直接検索してもOK、ごっちゃな体系(SJIS)の場合は、後続バイトには出てこない値があるところまで一旦戻る。

1バイト文字、先頭バイト、後続バイトが全て被っていない体系にはもうひとつメリットがあって、MBCS対応処理をろくすっぽやってない手抜きコードでも、まあまあそれなりに安全なんです。
例えば順方向検索のとき、変なコードが入ってきて無いと仮定しての話ですが、検索対象文字列は必ず1バイト文字か多バイト文字の先頭バイトのどちらかから始まりますよね。
1バイト文字は1バイト文字としか、先頭バイトは先頭バイトとしか値が重なりませんので、MBCSを考慮せずに作った検索処理にかけた場合も、被検索文字列中の、正しい「文字を途中で切ってしまわない」インデックスを返してくれます。

概ねそんな感じで、MBCSに全く対応していないツールに通しても、誤動作しませんので、UTF-8は欧米受けが大変良いです。だって何もしなくていいんですもの。

UTF-16も同様ですね。サロゲートペアに対応していないツールは、表示や編集上の不具合はあるかもしれませんが、少なくとも、他の文字と勘違いしていらんことを……なんてことにはならないです。

SJISですと、バックスラッシュが地雷でしたから……楽になりました。
ただ、SJISは厄介なだけあって、SJISを処理できるルーチンは(特定の文字がコードxxであることを期待してなければ)容易にあらゆるMBCSを処理できるルーチンに書き換えることができます。WindowsならIsDBCSLeadByte、BSDならmblen、ロケールと先頭バイトからその文字のバイト数を返すAPI、これひとつで総てOK。

もちろん文字単位で処理をしたいというモチベーションは充分有りです。しかしその場合は、最初からそれに適した文字コードないしデータ構造を取らせるべきで、その方が計算オーダーも落とさずに済んで色々と幸せです。BSDのmbstowcsなんかはこの目的に特化していて、SJISロケールで実行すると、SJIS一文字分をコード変換無しにひとつのwchar_tにパックしてくれます。まあ最初っからUTF-32にしとけって話ではあります。

逐次処理をする

あとまあどうでもいい話なんですが、内部UTF-16で外部UTF-8で書き出しを行う時、いくらライブラリに文字コード変換関数があるからって、ある程度でっかいデータを扱うとわかっているなら、先頭から1文字ずつ切り出しては変換して順次書き出す処理ぐらいは自分で作りましょうや。
ライブラリが例外投げる仕様だったりすると、一部おかしなデータがあった時に、データが失われるのが恐いとか、例外すら投げないのは実はもっと恐いとか、そんな。
それにほら、出力先がパイプやソケットだったりすると、受け取った方も、並列で処理できますし、ね。

まとめ

文字列はひとつの固まりじゃなくて複雑なデータ構造なんです。表現形式も無数にあります。しかも、PCを使うとなればまさに直接関わらなければならない部分です。
「かんたんにもじれつしょりができる」なんて幻想は、捨ててください。どの言語でも、同じです。どんなライブラリを使っていても、同じです。(実際問題、個々のケースに対して個別に処理を書くのが最善手ですので、そんな大したライブラリなんか要らないということでもあります。無いと困るのは文字コード間の変換テーブルくらいかな。それもWindowsですとAPIにありますし……Windowsが持ってないコード体系使おうとすると途端に困りますが、使わない使わない)
そして、「かんたんにもじれつしょりができる」幻想を追うために、無意味に計算オーダー増やしてみたり無意味に外人責めてみたりするのも、筋が違います。*2
面倒事に巻き込まれないためには、面倒な文字列処理に正面から取り組むしかありません。

*1:PC-98のテキスト画面は半角記号を沢山持っていて、DISK-BASICは80-ffをそういった記号に割り当てていたため、SJISDOS-BASICよりも使える記号の数が多かった。Kanji-IN,OUTも悪いことばかりでは無い……といいつつテキストVRAMに直接POKEしてしまえば全文字出せるので大したメリットでも無かったですけれども。

*2:むしろAdaCoreに変な形でSJISやらEUC-JP対応を強いてる日本企業を責め(ry