Python のインクリメンタル・デコーダ

公開:
2011-12-16

概要 Python の codecs モジュールの IncrementalDecoder を使えば、送られてくるバイト列を、文字境界を気にすることなく逐次デコードしていくことができます。

ソケット、シリアル通信、パイプなどを使ってテキストのデータを送受信することはよくある。このとき、テキストは当然のことながら、なんらかのエンコーディング(符号化方式)でバイト列として表現されてストリームを流れてゆく。

たとえば、u'モンティ・パイソン' という文字列を送信したいとする。エンコーディングは Shift_JIS にしよう。Python インタプリタを立ち上げて確認してみると、送信するバイト列は次のようになる。

C:\...>python
Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win
32
Type "help", "copyright", "credits" or "license" for more information.
>>> s = u'モンティ・パイソン'
>>> x = s.encode('shift_jis')
>>> x
'\x83\x82\x83\x93\x83e\x83B\x81E\x83p\x83C\x83\\\x83\x93'
>>>

この '\x83\x82\x83\x93\x83e\x83B\x81E\x83p\x83C\x83\\\x83\x93' というのがエンコードされた文字列だ。

さて、ここでソケットやシリアル通信などのストリームを受信するときに忘れてはならない事実を思い出そう。それは、受信した時点でデータのすべてがそろっているとは限らないということである。今回の例では、Shift_JIS にエンコードされた u'モンティ・パイソン' は 18 バイトのバイト列('\x83\x82\x83\x93\x83e\x83B\x81E\x83p\x83C\x83\\\x83\x93')になっている。一回の受信でこの 18 バイトぜんぶを受信できるとは限らないのだ。受信できるのは先頭から 10 バイトまでかもしれないし、13 バイトかもしれない。ぜんぶかもしれない。残りのバイト列はストリームから再びデータを読み取ったときに手に入る。受信したデータは、逐次文字列にデコード(復号)して、あなたのアプリケーションの表示ルーチンに渡さなければならない。

ここでいやな予感がするはずだ。先頭から 13 バイトを受信したとしたら、手元にあるのは '\x83\x82\x83\x93\x83e\x83B\x81E\x83p\x83' というバイト列だ。これをデコードしてみると……

>>> a = x[:13]
>>> a.decode('shift_jis')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'shift_jis' codec can't decode byte 0x83 in position 12: inc
omplete multibyte sequence
>>>

例外が発生してしまう! バイト列の最後の '\x83' が、Shift_JIS では単独で存在することを許されない、半端なバイトになってしまっているからだ。

このように、ひとつの文字を複数のバイト列で表すようなエンコーディングでは、受け取ったデータがかならずしも文字の境界にそろっているとは限らない。

さて、じつは Shift_JIS には、(データの最初の文字境界が正しければ)先頭の 1 バイトを見ればそれが 1 バイト表現の文字か 2 バイト表現の文字の前半分かを判別できるという特徴がある(「日本語と文字コード」)。1 バイト目が 0x81 から 0x9F または 0xE0 から 0xEF であれば、その文字は 2 バイト表現で、後続の 1 バイトとあわせてひとつの文字を表現している。それ以外であれば、制御文字か、ASCII の英数字か、半角カタカナか、いずれにしてもその 1 バイト単独でひとつの文字を表している。この知識を利用すれば、文字列の境界を見つけることはけしてむずかしくはない。先頭から受信したデータをパースする関数を書いてもいいし、正規表現でマッチングさせてもいい。半端なバイトがついてきているとわかったら、そこでデータを切り取って、あまった 1 バイトは次の受信データの先頭につなげるために取っておく。昔はみんな C でこういうコードをたくさん書いていたのではないかと思われる。

しかしこういうバッファリング機構を実装してしまうと、受信するデータのエンコーディングが変わったときに、バッファリング機構のコードもそれにあわせて書き換えなければならない。エンコーディングが Shift_JIS じゃなくて UTF-8 になったら? 「このソフト、中国語のテキストも送れるよね?」と言われたら? Big5 の仕様を調べる気になんてなる? せっかく Python でエンコードやデコードに楽をしてるのだから、ここでも Python なら、きっとなにかやってくれてるはず!……と信じて探してみたら、codecs モジュールIncrementalDecoder というものが見つかった。これこそ、ほしかったものそのもので、やってほしかったことのすべてを引き受けてくれる。もっと早く知っとくべきだった。おなじ思いをほかの人には味わってほしくないので、いまこれを書いている。

そういうわけで、IncrementalDecoder の使いかたを以下で説明します。

IncrementalDecoder はクラスで、実際の逐次デコードにはそのインスタンスを使う。だが、まずは自分が使いたいエンコーディングを扱う IncrementalDecoder クラスを手に入れなければならない。そのための関数が、codecs.getincrementaldecoder(encoding) で、これは encoding を扱うコーデックを探し出して、それを処理する IncrementalDecoder クラスを返すというものになっている。いわゆるファクトリ関数だ。このためコードはちょっと回りくどく見えるかもしれない。

>>> import codecs
>>> Decoder = codecs.getincrementaldecoder('shift_jis')
>>> decoder = Decoder()

モジュールをインポートしたあとは一行で次のように書いてもおなじこと。

>>> decoder = codecs.getincrementaldecoder('shift_jis')()

あとは、ストリームから受信したバイト列をなにも考えずに decoderdecode(object[, final]) メソッドに食わせていけばよい。

>>> decoder.decode(x[:13])
u'\u30e2\u30f3\u30c6\u30a3\u30fb\u30d1'
>>> decoder.decode(x[13:])
u'\u30a4\u30bd\u30f3'

このように、文字境界の途中で切れていても、つながっているところまでの文字列をデコードして返してくれる。

省略可能な第二引数の final は、それがデータの終端であるときに True を指定する。このときに、まだ中途半端なデータが残っていると、このメソッドは UnicodeDecodeError の例外を発生させる。

>>> decoder.decode(x[:13], True)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'shift_jis' codec can't decode byte 0x83 in position 12: inc
omplete multibyte sequence

これは自然なことといえる。

さいごに、UnicodeDecodeError と対をなすものとして、IncrementalEncoder というクラスと codecs.getincrementalencoder(encoding) という関数もある。しかし、これらにはあまり用があるようには思えない。送信する側のテキストは当然送信側が完全な Unicode 文字列として保持しているはずだから。