某日記

(後期)

平成23年9月29日(木曜日)

ISO C locale と ISO-2022 とエスケープシーケンス、あるいは mbrtowc() に 1 バイトずつ渡してはいけない理由

ISO C locale は一応ステートフルエンコーディングを扱えるということにはなっていて、実際 Citrus でも itojun さん謹製の ISO2022 モジュールをベースにした ja_JP.ISO-2022-JP locale が用意されてはいる。でも、一つだけ問題がある。

それは、ISO-2022 が状態遷移にエスケープシーケンスを使っているので、エスケープシーケンスを利用している他の機能 --- 一番メジャーな所では ISO 6429 のいわゆる ANSI 端末エスケープシーケンスおよびその眷属 --- との相性が良くない、ということなんだな。

一つ例を考えてみよう。ISO 6429 の画面消去のエスケープシーケンスは ESC [ 2 J (0x1B 0x5B 0x32 0x4A) という 4 バイトの列で表される。以下、現在の locale において、ASCII のコード範囲の文字は制御文字も含めて wchar_t にはそのままゼロ拡張された値が入る実装になっているとすれば、これはワイド文字列では

wchar_t wc[4] = { 0x1B, 0x5B, 0x32, 0x4A };
という 4 つのワイド文字に変換されないといけない。

マルチバイト文字をワイド文字に変換する手段はいくつかあるけれど、一文字ずつ変換するには mbrtowc() という関数を使う。普通は、あるまとまった長さのバイト数のマルチバイト文字列を渡して、そこから一文字を取り出すという使い方をするのだけれども、一バイトずつちまちまと渡していっても間違った使い方ではない、ということになっている。

ISO-2022 な locale において、さっきのエスケープシーケンスを一バイトずつちまちまと mbrtowc() に渡して行ってみよう:

  char mb[1];
  wchar_t wc;
  size_t ret;
  mbstate_t state;
//
  mbrtowc(NULL, NULL, 0, &state);  // state init
//
  mb[0] = 0x1B;
  ret = mbrtowc(&wc, mb, 1, &state);  // (1)
  mb[0] = 0x5B;
  ret = mbrtowc(&wc, mb, 1, &state);  // (2)
  mb[0] = 0x32;
  ret = mbrtowc(&wc, mb, 1, &state);  // (3)
  mb[0] = 0x4A;
  ret = mbrtowc(&wc, mb, 1, &state);  // (4)

ここで、(1) における mbrtowc の振る舞いは、

  1. マルチバイト文字列 mb を 1 バイト覗いてみる
  2. エスケープなので、有効なバイトとしてこれを食う
  3. state に「エスケープを読んだ」という状態を記録する
  4. ISO-2022の状態遷移か、それとも独立したエスケープ文字かが判然としないので、戻り値として (size_t)-2 (=一文字に満たない)を返す
  5. wc はいじらない。
という動作になる。呼び出し元としては、(size_t)-2 が返されているので、
  1. 入力バッファ(=mb)の内容はすべて(といっても 1 バイトだけど) mbrtowc に食われた
  2. しかしながらまだ 1 文字に満たないので、wc には値が入っていない
という風に判断できる。

(2) あたりから雲行きがおかしくなってくる。mbrtowc は次のように振る舞うしかない:

  1. マルチバイト文字列 mb を 1 バイト覗いてみる
  2. エスケープに続くのが 0x5B だったので、これは ISO-2022 の状態遷移じゃなかったということが分かる
  3. 0x5B は ASCII の左かぎ括弧で有効な文字だから、これを食う
  4. さっきのエスケープを忘れちゃいけないので wc には 0x1B を入れる
  5. 0x5B を返す手段がないので state に「0x5B を保留している」という状態を記録する
  6. 戻り値として 1 (=何事もなく 1 バイト読み込んで一文字変換できた)を返す
(3) は
  1. マルチバイト文字列 mb を 1 バイト覗いてみる
  2. 0x32 は数字の 0 だから、有効な文字だと判断して、これを食う
  3. でも、さっきの 0x5B をワイド文字にしないといけないので、wc には 0x5B を入れる
  4. 0x32 を返す手段がないので state に「0x32 を保留している」という状態を記録する
  5. 戻り値として 1 (=何事もなく 1 バイト読み込んで一文字変換できた)を返す
となる。(4) も同様に処理して 0x32 が取り出される。実際、Citrus の ja_JP.ISO-2022-JP locale はこういう風に動作する。

……はて、最後の 0x4A はどうやって取り出せばいいんだろう。こんな方法を考えるかもしれない:

  ret = mbrtowc(&wc, NULL, 0, &state);  // (5)
  ret = mbrtowc(&wc, "", 0, &state);  // (6)
これらはいずれも意図した動作にはならない。

(5) のように、第二引数に NULL を渡すと、これは単純に state の再初期化として処理されて、第一引数と第三引数は無視される。

(6) は一見よさそうだが、mbrtowc() としては返すべき適切な値がない。mbrtowc() の返り値は基本的に食ったバイト数を返すことになっているので、1 バイトも食っていないのだから 0 を返せばいいようにも見えるけれども、これではいつ停止すればいいのか判然としない(一般的には保留されてる文字が 1 文字とは限らないし、何文字保留されてるかを知る方法がない)。それに、規格上 0 を返すというのは「ヌル文字を食った」という意味として定められているので、wc には L'\0' が格納されていないといけない。なんでヌル文字を食った時に 0 を返す必要があるのか、私にはその理由がいまいち判然としないのだけれども、いずれにしてもそう決まっている。

1 バイトずつ渡すという方法ではうまく行きそうにない。

他に何か良い方法はないのか。実はあるんですね。

つまり、「ひょっとして、mbrtowc に 4 バイト一気に渡せば、先読みできるからちゃんと処理できるんじゃない?」という考え方ができる。これは限定的にはうまくいく可能性があるし、実際 Citrus の ja_JP.ISO-2022-JP locale は次の mbrtowc() 呼び出しに対して 1 を返して wc に 0x1b を格納する:

  ret = mbrtowc(&wc, "\x1B\x5B\x32\x4A", 4, &state);
このとき、state は初期状態に戻っているので、適切に入力バッファを進めることによって残りの 3 バイトも正しく処理できる。

ただ、どうもこの動作は本当に「良い」のか分からない。1 を返しているくせにこっそり 2 バイト目以降も見ている,というのが気にくわないし、ライブラリ作成者としてはユーザがどんな使い方をしてくるか予断を挟むべきではないから、続く呼び出しではまったく違うバッファを渡されないとも限らないし、それをされると正しくない動作になる可能性もある。

それに、何バイトまとめて渡せばいいのかよく分からない。「MB_LEN_MAX溜めてから渡せば?」という考え方もあるかもしれないけれど、そんなに溜まるのを待ってられない、というケースもある。そういう場合には state の保存とリストアをうまく使って処理するしかない:

  char buf[MB_LEN_MAX];
  wchar_t wc;
  int n = 0;
  size_t ret;
  mbstate_t state, saved_state;
//
  mbrtowc(NULL, NULL, 0, &state);
//
retry:
  if (n >= MB_LEN_MAX)
    // error
    return -1;
  buf[n++] = getchar();
  memcpy(&saved_state, &state, sizeof (mbstate_t));
  errno = 0;
  ret = mbrtowc(&wc, buf, n, &state);
  if (ret == (size_t)-1)
    // error
    return -1;
  else if (ret == (size_t)-2) {
    // incomplete
    memcpy(&state, &saved_state, sizeof (mbstate_t));
    goto retry;
  }
  // done  
せっかくの -2 が台無し、という気もするが、ここまでの帰着により、ISO-2022 を正しく処理できる可能性を担保するにはこうしないといけない。まあ、しかしながら、別のもっと根本的な問題として、ISO-2022 のステートシフトは無限に伸びる可能性があるので、上のコードでは n が MB_LEN_MAX を超えてしまう可能性がある。これは ISO C locale としてはもはや打つ手が無い。

もっと別の方法としては、mbsrtowcs() を使うという手もある。ただ、こっちも 1 バイトずつ渡すという芸当はできなくて(-2 を返してはくれない)、結局は何バイトかまとめて渡さないといけないから、上のコードと大差が無くなる。

まとめ: ISO C locale 面倒くさいね