この文章はJulia言語で入門するプログラミング(その13)の一部として書く予定だったものを、独立したページに切り出したものである。
問題発生
突然だが、あなたはとあるゲーム制作会社に勤務しており、ロールプレイイングゲームの実装を行なっているとしよう。あなたの担当する機能の一つに、敵と味方の戦況を表示する機能がある。これを使うと敵と味方のHPやMPが次のように一覧で表示されるのだ。
さて、この機能を重役も出席する進捗会でお披露目したところ、そのうちの一人から次のような要望が出てきた。
「うーん、縦の列がガタガタしているな。できればこんなふうになって欲しいんだが。」
重役の言葉は神の言葉に等しい。何とか対応したい。
全角と半角
まず一番シンプルな方針というのは、固定で半角スペースを埋め込むという対応である。キャラクターの名前が固定であれば、太郎の後ろには4つ、遠藤君の後ろには2つ、というように半角スペースを埋め込めばいい。そして今の仕様ではキャラクターの名前は固定である。なあんだ、簡単な話ではないか。
しかしこの対応は却下だ。なぜなら私が今から次のような文言を仕様書に追記するからだ。
「プレイヤー側キャラクターの名前は、ユーザーが決定できるようにする。選択可能な文字は、ひらがな、全角カタカナ、半角カタカナ、日本語漢字、全角英数字、半角英数字とする。文字数は全角換算で8文字、半角換算で16文字とする。」
反対しても無駄だ。この変更に関する稟議書は既に取締役会の承認を得ている。あなたの選ぶことのできる道は2つ。1つは株式の過半数を取得し緊急株主総会を開いて取締役会を解散させ、息のかかった人間で取締役を選出して稟議書を再審議し否決すること。もう1つはユーザーが入力した得体の知れない文字列から全角文字と半角文字を判定して必要な半角スペースの数を算出すること。どちらを選ぶかはあなた次第だ。ちなみに株式の過半数を取得するには5000億円ほど必要なことを付け加えておこう。
さて、どうせ皆さんに5000億円の支払いが不可能であるのは分かりきった話なので、全角と半角を判定する方向で話を進めよう。(もしもあなたが5000億円の支払いが可能な人物であれば、この記事の続きを読む必要はないので直ちに個人的にコンタクトさせていただきたい。他意はない。失礼な決めつけをお詫びしたいだけだ。本当だってば!)
全角と半角の判定の実装について入る前に、「文字コード」というものについて理解しておく必要がある。JuliaはUTF-8という文字コードを採用している。UTF-8を知らずして話を進めていくことはできないのだが、これは長い歴史の上に人類が到達した成果なので、いきなりUTF-8を理解するのは難しい。ここはコンピュータ上での文字表現のおおざっぱな歴史を辿りながら理解するのが早道だ。
文字コード
文字コードとはなんだろうか?私がコンピュータの画面上で「あ」と打って保存した時、コンピュータはそのままの文字としては保存できない。コンピュータ内部ではデータは二進数のビット列で保存されるので、文字もなんらかのビット列に置き換えられる必要がある。「あ」を意味する数字はどのようなビット列になるだろうか?率直に言えばなんでも良い。文字とビット列が一対一に対応している限り、どんな数字を割り振っても問題はない。書き込みと読み取りで同じルールに従っていることが大事だ。このような文字とビット列の符号化方式を文字コードという。
文字コードはフォントや斜体のような装飾とは違う。私はあまり装飾を使わないが、たとえどんな装飾で「あ」と書いたとしても、「あ」という文字を表現していることには変わりがない。ある文字コードを決めると「あ」という文字がどのようなビット列になるか定まる。別の文字コードにすると、「あ」という文字は別のビット列で表現される。これが文字コードの原則である。あるビット列を解釈するには、何の文字コードで符号化されたか、という情報が重要になる。
さて、自分のアプリケーションで読み書きするだけのデータであれば、自分の都合の良いように符号化方式を決めることができる。例えばディスク容量の節約のために頻出する文字に短いビット列を、大して出てこない文字に長いビット列を割り当てるのは理にかなったアイデアだ。英語では「e」は最頻出の文字という。であれば、英語のワープロであれば「e」に短いビット列を割り当てるべきだろう。文字ではなく単語に1つのビット列を割り当てるのもいいかも知れない。ソースコードであれば、「for」とか「if」に短いビット列を割り当てると効果が高いだろう。このサイトの文章なら「Julia」という単語に短いビット列を割り当てるのもいいかも知れないし、逆に象形文字に1キロバイトのビット列を割り当てたって困りはしないだろう。そして言うまでもなく「むううみん好きスキ大すき!」というのはあらゆる文化圏の言語で最小のビット列を割り当てる価値がある文字列だ。
パソコンやEメールが普及するずっと前、皆がワープロで文章を書いていた時代は大体このようなものだった。ワープロで書いた文章はそのワープロ専用の符号化方式でビット列に変換される。データはもしかするとフロッピーディスクで持ち運ばれるかも知れないが、結局同じ機種のワープロで読み出される。「フォントやルビを表現したい?好きにしなさい。どんな埋め込み方をするか知らないが、自分で解読できるんだろうね。」「なに縦書きをサポートしたい?26番目のビットが縦書きフラグになるんだね。よしよし頑張りなさい。先生は前の機種との互換性にさえ気を付けてくれたらそれでいいんだよ。」もちろんメーカ間の互換性などない。なんとも牧歌的な時代だ。まるでアルプスの少女ハイジのような世界観だ。[1]と、見てきたように語っているが、私は世代が違うのでよくわからない。このサイトが当時の時代感を伝えてくれる。
しかし時代を経るにつれ、やはりメーカー間の互換性というものも大事になってくる。そうなると、各社バラバラの方式でビット列に変換していては都合が悪い。A社とB社とC社がそれぞれ好き勝手な符号化方式で変換していると、全てのメーカー間で互換性を保とうとすると、その組み合わせは6通りになる。メーカーの数が増えていくとその組み合わせは爆発的に増えるのだ。これを解決するには、共通の符号化方式を決めて皆それに従えばよい。そのようなわけで、日本語を表現するのに適した符号化方式は何か、ということで徐々に共通の符号化方式が整備されていった。こうして共通規格としての「文字コード」というものが作られていく。「JIS X 0208」「JIS X 0213」「Shift_JIS」などの文字コードが規定され、互換性の問題などすったもんだがありながらも広まっていった。この辺りの経緯を追っていこう。[2]と、また見てきたように語ったが、実際の歴史はこの流れとは違う。例えばJIS X … Continue reading
JISコード
俗に「JISコード」と呼ばれる文字コードがある。JISというのは日本工業規格のことで、「JISコード」というだけでは意味が広すぎるのだが、文字コードの文脈でJISコードというと、ISO-2022-JPという名前の文字コードのことを指す。日本語を表現するために作られた文字コードだ。これはどのような符号化方式だろうか?これを知るためにはさらに歴史を遡ってASCII文字という文字コードを知る必要がある。
ASCII文字というのは、アルファベットと数字にいくつかの記号、それに制御文字を加えた128種の文字のことである。アメリカ合衆国出身の、最初にして最もスタンダードな文字コードである。ASCII文字の素晴らしいところは128文字しかないところで、7ビットあれば表現できる。1バイトは8ビットなので、まるまる半分は空いていることになるのだ。そのようなわけで、ASCII文字を拡張して、各国は各々自国の文字を残りの半分に割り当て始めた。日本も例外ではなく、JIS X 0201という規格では、空いた領域に半角カナや句読点などの記号を割り当てた。[3] … Continue reading
半角カナで日本語を表現できたならば、平仮名や漢字を表現したいとなるのが人情だ。しかし平仮名はともかく漢字までとなると、困ったことになる。1バイトでは足りなくなるのだ。1バイトではどう頑張っても256種の文字しか表現できない。漢字は常用漢字だけで2000種近く存在する。解決策は2バイトで文字を表現することだ。2バイトあれば65536種の文字を表現できる。これだけあれば大丈夫だろう。そのようなわけで、スペースや制御文字などのごく一部の例外を除く文字は全て2バイトで表現されることとなった。いわゆるASCII文字である「a」や「1」のような文字も等しく2バイトだ。これが「JIS X 0208 」と呼ばれる文字コードである。この時にはおよそ7000種の文字が符号化された。
さて全てが2バイトの美しい世界に収まったのだが、困ったことも起きた。容量の問題である。ASCIIコードで記述されたファイルを開き、JIS X 0208コードで保存すると、あら不思議。なんと容量が2倍になってしまうのだ。勘違いしないでほしい。預金残高が2倍になるんじゃない、ファイルサイズが2倍になるのだ。これを喜ぶ人はあまりいないだろう。
そうなるとJIS X 0208一本での運用は難しく、ASCII互換の「JIS X 0201」と、漢字や平仮名をサポートする「JIS X 0208」を混在させた文書というのが主流になる。こうすれば、ASCII文字は1バイトで保存できるし、漢字やひらがなが混ざった文章も取り扱える。これがJISコードである。
私が思うに、この方式が採用されたことにより、「半角英数」「全角英数」という概念も生まれたのではないかと思う。一つの文書の中に「ASCII互換の英数字」と「2バイト文字の英数字」が混在するのだ。これらを同じように表示してしまっては困る。なぜなら違う文字コードが割りあたっているので、見かけは同じでも内部的には全く別の文字だからだ。検索にも引っかからない。これは明らかに混乱をきたす。であれば、これらの表示は分けてしまおう。ASCII互換の英数字は1バイトだから半角で、2バイト文字の英数字は全角で表現しよう。分かりやすいね。ついでにカナもJIS X 0201由来の半角カナと、JIS X 0208由来の全角カナに分けてしまおうよ。そんな感じだったのではないか。結果、半角英数字を1バイト文字、日本語や全角英数字を2バイト文字と呼ぶ慣習ができたのだろうと思われる。
なお、この混在方式はいいことずくめのように見えるが、そう簡単にはいかない。2バイト文字を分解した上位1バイトと下位1バイト、それぞれを1バイト文字として解釈することが可能だからだ。そのため、1バイト文字と2バイト文字の切り替わりのたびに、「ここから2バイト文字ですよ」「ここまで2バイト文字ですよ」という制御コードを入れる必要がある。この情報をもとに、今が何の文字コードであるかを考慮しながらビット列を解釈していくのだ。しかし、「前方から順に読んでいかなければ正しく解釈できないため検索が遅い」とか「制御コードの分の容量が必要になる」という問題がある。
文字で読んでもよくわからないかも知れない。実際のデータを確認してみよう。今からやるのは、JISコードのエンコーディングで保存されたテキストファイルのバイト列を確認しようということである。通常であれば、バイナリエディタを使って、という流れになるのだが、今回はJuliaで確認してみよう。Juliaはファイルのバイト列を取り扱うことができるだ。(Juliaのインストールをしていない人は、Julia言語で入門するプログラミング(その1)を参考にしてインストールすることをお勧めする。)
さて、Macなら標準で入っているテキストエディットというアプリで、エンコーディングを指定してテキストファイルを保存することができる。Windowsだと標準のエディタはメモ帳だが、メモ帳はJISコードに対応していない。フリーのテキストエディタであるサクラエディタがJISコードに対応していることは確認したので、それを使っても良い。これらのツールを使って、新規にテキストファイルを作成し、中身を半角の「A」という1文字にして、JISコード(ISO-2022-JP)でファイルを保存しよう。
さて、このファイルのバイト列を確認するには、Juliaでこのファイルをread
してあげれば良い。read
関数の引数に渡すのはファイルパスである。(下記の例はファイルの属するフォルダに移動している状態でREPLを起動したので、ファイル名を相対パスで指定した形になっている。)
実行してみよう。
#「A」
julia> read("jisコード.txt")
1-element Array{UInt8,1}:
0x41
「A」1文字のファイルだと、文字コードは1バイトの0x41である。これはASCII互換である。
次にファイルの中身を「AB」にしてみよう。そうすると、次のようになる。
#「AB」
julia> read("jisコード.txt")
2-element Array{UInt8,1}:
0x41
0x42
これももちろんASCII互換だ。では次に、「AB」は消して、「あ」という一文字を入力しよう。
#「あ」
julia> read("jisコード.txt")
8-element Array{UInt8,1}:
0x1b
0x24
0x42
0x24
0x22
0x1b
0x28
0x42
えらく長いが、これは2バイト文字の開始、終了を占める制御コードが含まれているからだ。[4]0x1bというのは前回出てきたエスケープコードであるのを思い出しただろうか?実際の文字コードは2バイトの0x24, 0x22である。
0x1b
0x24
0x42
#ここまでが2バイト文字の開始コード
0x24
0x22
#ここからが2バイト文字の終了コード
0x1b
0x28
0x42
なんとなく要領が掴めたと思うので、ファイルの中身を「AあいB」という文字列に変更してバイト列を見てみよう。
#「AあいB」
julia> read("jisコード.txt")
12-element Array{UInt8,1}:
0x41 #A(1バイト)
0x1b #制御コード(3バイト)
0x24
0x42
0x24 #あ(2バイト)
0x22
0x24 #い(2バイト)
0x24
0x1b #制御コード(3バイト)
0x28
0x42
0x42 #B(1バイト)
1バイト文字と2バイト文字が切り替わるたびに制御コードのぶん肥大化していくことがわかるだろう。
Shift_JIS
こうした問題を解決するため、Shift_JISという文字コードが登場した。
Shift_JISの文字コードは、2つの特徴がある。1つがASCII互換であること(もう少し言えば半角カナも含めてJIS X 0201互換であること)、もう1つが2バイト文字の上位1バイトが1バイト文字が未使用の領域であることである。こうすることで、制御コードなしに「今読んでいるのが1バイト文字なのか、2バイト文字の1バイト目なのか」を知ることができる。2バイト文字の領域を1バイト文字の使用領域を避けるために複雑にシフトさせたことから、この名前になっている。この特徴により、JIS X 0201 と JIS X 0208を混在させる手法の欠点が改善された。
Shift_JISのバイト列を確認しておこう。まず、「A」「AB」はASCII互換である。
#「A」
julia> read("shift_jis.txt")
1-element Array{UInt8,1}:
0x41
#「AB」
julia> read("shift_jis.txt")
2-element Array{UInt8,1}:
0x41
0x42
そして、日本語が混在すると次のようになる。
#「あ」
julia> read("shift_jis.txt")
2-element Array{UInt8,1}:
0x82
0xa0
先ほどと違い、制御コードが存在しない。また、JISコードの時とは割り当たられているバイト列の値が違っているのがわかるだろう。「AあいB」も見ておこう。
#「AあいB」
julia> read("shift_jis.txt")
6-element Array{UInt8,1}:
0x41 #A(1バイト)
0x82 #あ(2バイト)
0xa0
0x82 #い(2バイト)
0xa2
0x42 #B
素晴らしく短いバイト列で表現できている。Shift_JISの問題点といえば、2バイト領域を割と贅沢に使っていることで、収録可能な漢字の数が少ないということくらいだ。それでも普段使いの漢字であれば十分にカバーできるので実用性は十分であり、その上MicrosoftがMS-DOSに採用したことで、Shift_JISはその地位を確立した。[5]と言うかMicrosoftなどの実装が先行して、その後規格に採用されるという典型的なデファクトスタンダードだったらしい。(「ユニコード戦記」より)
もっとも収録できない漢字があるというところについては、なかなか遺恨の残るところではあったようで、この後に紹介するユニコードの運命すら翻弄していくのである。
ユニコード
さて、Shift_JISができて日本語は(ひとまず)めでたしめでたし、というところだったのだが、世界レベルでは全く違ううねりが巻き起こっていた。全世界の言語と単一の文字コードで符号化しようという壮大な試み「ユニコード」である。
Shift_JISのような文字コードは世界中の言語で考案されていた。そして各々が1バイトなり2バイトなりに好きな文字を割り当てていた。当然、1つのビット列に対して、複数の言語の文字が対応しうる。そうなるとどうなるか。複数の言語が混じった文書を作ろうとすると、またしても制御コードを使って対応する必要があるのだ。ここからは日本語の文章、ここからは韓国語の文章、そしてここからはベトナム語の文章、さあどうなる?そう、日本語ではShift_JISが解決したのと同じような問題に改めて直面するのだ。そして解決策も同じような発想だ。あらゆる言語のあらゆる文字に対して固有のコードを割り当てれば良い。それがユニコードだ。
ユニコードのややこしいところは、
- まず全世界のあらゆる文字に一意の通し番号(コードポイント)を割り当てる
というプロセスがあり、
- その後にそれをビット列でどう表現するか
という別のプロセスがあることだ。後者のプロセスにいくつかバリエーションがあり、それがUTF-16とかUTF-8とか呼ばれるものだ。
一見すると、「全世界のあらゆる文字に一意の番号を割り当てる」というのは割合簡単そうであり、「それをビット列でどう表現するか」というところの方が難しそうだが、どっこいそうではない。前者の方がはるかに大変な仕事だったようである。
例えば日本語で言えば、「高」という漢字と「髙」という漢字がある。これらは同じ漢字だろうか?違う漢字だろうか?これは「髙橋さん」という人物にとっては重大な問題だろう。もしも同一の文字として判定されてしまうと、きっと「高」が勝つだろう。それは「髙」という漢字が、「髙橋」という名前が、今後数百年、いやもしかすると数十年のうちに事実上消滅することを意味するのではないだろうか?文字というのはその国の文化の根幹を担うもので、決して蔑ろにすべきではない。
とはいえ、細かく識別し個別に番号を振りさえすればいいというものでもない。あなたは取引先の松崎さんから半年前にもらったメールを探そうとするのだが、どういうわけか探し出すことができない。なぜならあなたが「松崎」さんだと思っていた人物は実際には「松﨑」さんだからだ。この例ならよく見ると字体が違うことがわかるが、もっと細かい差異でも別の漢字と判定されるということはありうる。見分けがつかないほど良く似た漢字に別々のコードポイントを振っていくと、検索が極めてやりづらくなるということも起こりうる。これは実用上の重大な問題だ。
ソフトウェアは現実の問題を解決するツールである。理念ばかり追い求めて実用性を失ってはどうしようもない。どこまでを同じ文字として、どこからを別の文字とすべきか?誰が答えを持っているのか?これは非常に難しい問題だ。
他にも合字というものがある。日本語で言うと、濁点のついた文字のようなものだ。「が」を表現するために、「が」を1文字とみなして1つの番号を割り当てるか、「か」という文字と「゛」という文字が組み合わさったものと考えるかだ。これも議論の分かれるところだ。理念を忘れて実装のこと考えたとしても、どちらが実現しやすいのかよくわからない。「か」と「が」のようなシンプルなケースでは、1つのコードポイントが1つの文字を表すという方が明快だが、アラビア文字について少しでも学んだならそのような甘い考えは粉々に打ち砕かれるだろう。[6]アラビア文字に興味が出た方におすすめのサイトを紹介しておこう。「Culti <アラビア語の基本の文字>」
とはいえ、この辺りややこしい話は幸いなことにユニコードコンソーシアムの人々が解決してくれている。コードポイントというものは正式に決められて、あなたが政治的な判断に頭を悩ませる必要はない。全ては解決済みの問題なのだ。最終的に覚えておくべきことは、世界中の文字にコードポイントという名前の通し番号が振られた、という事実である。コードポイントはよく「U+65B0」のように表記される。「U+」というのはユニコードのコードポイントを表現するための慣例的な表現で、その後の「65B0」は16進数で表現された通し番号である。
Juliaでユニコードのコードポイントを確認しておこう。これは簡単で、単にREPLに文字を打ち込めばそれで応答してくれる。
julia> 'A'
'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
julia> 'B'
'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
julia> 'あ'
'あ': Unicode U+3042 (category Lo: Letter, other)
julia> 'い'
'い': Unicode U+3044 (category Lo: Letter, other)
ちなみに、「あ」と「い」の間のU+3043のコードポイントの文字が気になるかも知れないが、「ぃ」である。コードポイント上は、「ぁ」「あ」「ぃ」「い」・・・と並んでいるのだ。
Unicodeのよくある誤解
Unicodeのコードポイントの話も終わったので、いよいよ本丸のUTF-8などのエンコーディングの話に移って行きたいが、その前によくある誤解を解いておこう。それは、「ユニコードは全世界のあらゆる文字が2バイトで表現される」というものである。
ユニコードの計画の最初期には、確かにそのような構想はあったようである。65536種の文字あれば世界中の文字が表現できるという考えである。「JIS X 0208」と同じ発想である。確かに、日本語も常用漢字で我慢すれば2000種に満たない訳で、なるほど全世界のあらゆる文化圏がそのような節度を持って振る舞えば可能だったのかも知れない。それであれば話は簡単だ。バイト数が違う文字の混在もないので、実装も極めて簡単だ。N文字先の文字を取得しようと思ったら2Nバイト移動すればいいだけだ。定数時間での探索。スバラシキシンセカイ。
ところが蓋を開けたら出るわ出るわ、日本、中国、韓国などの漢字圏からあまりに多くの似たような漢字が提出された。どう考えても65536種で収まるわけがない。どうするか。ユニコードコンソーシアムは強権をもってこれを制することにした。この漢字とこの漢字、似てるし一緒の文字にまとめてしまえ。スバラシキシンセカイヲマモルノダ。
これがCJK統合漢字と呼ばれるもので、これによりあらゆる漢字は2万種類に統合されることになり、各国から猛烈な反発を受けた。もちろん日本も反対した。日本はJISコードやShift_JISから漏れた文字をどうするかについて激しい議論が行われており、ユニコードには大きな期待を寄せていたのだ。そこに来てのこの横暴、到底受けいられるものではない。
結局のところ、理念だけ追い求めて実用性を失っては何にもならない。65536種に収めた美しいコード体系であっても、受け入れられなければ意味がないのだ。かくして、全世界のあらゆる文字を2バイトに収める計画は潰えた。65536種よりも多くの文字にコードポイントを割り振る以上、ユニコードを2バイトで表現するのは不可能だ。
現在では、ユニコードのコードポイントは、4バイトで表現されている。とはいえ、あらゆる文字を2バイトで、という考え方はユニコードの設計の根幹に影響を与えた重要思想であり、コードポイントの最初の2バイト(U+0000〜U+FFFF)までの領域は依然として重要な意味を持つ。この領域はBMP(基本多言語面)と呼ばれ、使用頻度の高い文字や記号が収められた領域となっている。
ユニコードの多様なエンコーディングについて理解するには、このような背景知識を持っておいた方がいい。
それでは、まずは最もシンプルなUTF-32エンコーディングから説明していこう。
UTF-32
これは極めてシンプルな発想で作られた文字コードだ。ユニコードの4バイト(=32ビット)のコードポイントをそのままビット列として表現したのがこのエンコーディングだ。
メリットはあらゆる文字が4バイトであることで、N文字先の文字を取得するためには4*Nバイト移動すればいいだけであると言うことだ。これにより文字列操作を効率よく行うことができる。[7]しかし、現実はそう上手くはいかないことをこのサイトが教えてくれる。UTF-32 でも固定長で処理出来るわけではない
デメリットはあらゆる文字が4バイトであることで、データのサイズが肥大化してしまうことと、ASCII非互換であることだ。
UTF-32についてはこんなところだ。バイト列を確認しておこう。「A」とだけ書かれたUTF-32エンコーディングのファイルは次のようになる。
julia> read("utf-32.txt")
8-element Array{UInt8,1}:
0xff
0xfe
0x00
0x00
0x41
0x00
0x00
0x00
先頭の「0xff 0xfe 0x00 0x00」というのは一旦忘れて欲しい。また、もしかすると、環境によっては違うバイトの並びで表示されるかも知れない。後で説明する。確認しておいて欲しいのは、「0x41 0x00 0x00 0x00」というのが「A」を表す記号だということだ。無駄が多いのは否めない。
UTF-16
UTF-16は1文字を16ビットで表現しようとしたエンコーディングで、ユニコードの構想当初に想定されていたエンコーディングである。もちろん先に説明したように、コードポイントは32ビットなのでその野望を完全に達成することは不可能なのだが、基本多言語面に収められた主要な文字についてはこれを達成することができる。基本多言語面については、コードポイントと同じビット列を2バイトのビット列で表現するのだ。そして、基本多言語面から漏れたマイナーな文字については「サロゲートペア」という4バイトのビット列で表現する。サロゲートペアに使われる4バイトの上位2バイトも下位2バイトも基本多言語面では未使用の領域が割り当てられているため、ある2バイトが基本多言語面の文字なのか、サロゲートペアの文字の一部なのかを間違える恐れはない。
UTF-16についてもバイト列を確認しておこう。「A」とだけ書かれたUTF-16エンコーディングのファイルは次のようになる。
julia> read("utf-16.txt")
4-element Array{UInt8,1}:
0xff
0xfe
0x41
0x00
先ほどと似て、先頭の「0xff 0xfe」というのがあるが、これも一旦忘れて欲しい。また、もしかすると、環境によっては違うバイトの並びで表示されるかも知れない。後で説明する。確認しておいて欲しいのは、「A」を表すエンコーディングは「0x41 0x00」で2バイトであるということである。
さらに、日本語の「あ」とだけ書かれたファイルは次のようになる。
julia> read("utf-16.txt")
4-element Array{UInt8,1}:
0xff
0xfe
0x42
0x30
これも同じく2バイトだ。
最後に「サロゲートペア」と呼ばれる文字についても確認しておこう。サロゲートペアに分類される漢字の一つが魚の「ホッケ」である。こんな漢字である。
julia> read("utf-16.txt")
6-element Array{UInt8,1}:
0xff
0xfe
0x67
0xd8
0x3d
0xde
サロゲートペアには4バイトが割り当てられていることがわかるだろう。
UTF-16のメリットは、(基本多言語面で使われる文字を使用している限りは)N文字先の文字を取得するためには2*Nバイト移動すればいいと言うこと。ほとんどのケースでUTF-32の半分のデータ容量で済むと言うことだ。サロゲートペアでしか構成されないような文書であっても、UTF-32と同じサイズにしかならない。
UTF-16のデメリットは、ASCII非互換なところだ。結局のところ、世の中には極めて多くのASCII文字で書かれた文書が存在する。アメリカにはASCIIの文書しか存在しないといってもいい。誰かがそれを苦労してUTF-16に変換してあげる必要がある。もちろん容量は2倍になる。「ええ?苦労して変換した挙句のご褒美が、追加で必要なディスクの請求書なのかい?冗談じゃない。そんなことに時間とお金を使うくらいなら、もっと別のことに使いたいものだね。」そうしてできたのがUTF-8である。
UTF-8
UTF-8とは、ASCII互換なユニコードエンコーディングである。Shift_JISの成り立ちを知っている技術者は、結局そこに落ち着くのか、という感想を抱いたに違いない。さて、UTF-8はUTF-32、UTF-16と違い、コードポイントとは全く切り離されたエンコーディング体系となっている。UTF-32はコードポイントがそのままバイト列になっていた。UTF-16も基本多言語面の文字に関してはそうなっている。しかし、UTF-8はそうではない。コードポイントとバイト列は全く無関係だ。ASCII文字の例えば「A」だと、コードポイントがU+0041、バイト列が0x41と似てはいるが、これはコードポイントが既存のASCIIコードを参考にして決められたという事実を反映しているに過ぎず、焦点はバイト列の0x41がASCIIコードと同じであるかどうかで決められている。
ASCII文字を贔屓にしたため、皺寄せを食らったのが非ASCII文字である。Shift_JISがASCII互換にするために2バイト領域を贅沢に使い、そのため十分な数の漢字を収録できなくなった、というのを覚えているだろうか?まさに同じことがUTF-8にも発生しており、3バイト以上の領域も躊躇いなく使われている。
例えば、「あ」という文字は基本多言語面の文字ではあるが、3バイトが割り当てられている。
julia> read("utf-8.txt")
3-element Array{UInt8,1}:
0xe3
0x81
0x82
このように非ASCII文字に厳しいUTF-8だが、非ASCII文字文化圏にもメリットはある。なんといってもワールドワイドウェブの時代だ。メールのメタデータやHTMLタグなどは全てASCII文字だし、他にもUTF-8には異なるコンピュータ間で通信する上での非常に良い性質があるのだ。
エンディアン
UTF-32やUTF-16でバイト列を表示した時、文字列の先頭に「0xFF 0xFE 0x00 0x00」「0xFF 0xFE」というようなものがあったのを覚えているだろうか?これは「エンディアン」と呼ばれるものを表している。
実は、コンピュータでは複数バイトのデータを読み書きするときの流儀に、上位アドレスから読み書きするか、下位アドレスから読み書きするか、という流儀があるのだ。どちらが優れているというわけでもないが、ともかく2つの流儀がある。この流儀のことを「エンディアン」とか「バイトオーダー」とか呼ぶ。上位アドレスから書いていくのをリトルエンディアン、下位アドレスから書いていくのをビッグエンディアンと呼ぶが、まあどっちがどっちかはどうでもいい。2種類あるというのが大事なことだ。[8] … Continue readingそして、どちらのエンディアンが採用されるかはCPUによって決まる。アプリからはCPUに「この2バイト(あるいは4バイト)のデータを書き込みなさい」と命令することしかできず、どのようなバイト順で書かれるかはCPUが決めるのだ。
このエンディアンだが、同一のコンピュータであれば書き込みと読み取りのエンディアンは一致するので、どちらであっても関係がない。しかし、複数のコンピュータ間でデータを受け渡しする時、書き込み時のエンディアンと読み込み時のエンディアンが異なっていては問題がある。このため、事前に取り決めをしておき、このデータはどちらのエンディアンで書かれたか、ということがわかるようにしておく必要がある。これがUTF-32やUTF-16の先頭に書かれた「0xFF 0xFE 0x00 0x00」「0xFF 0xFE」の正体である。バイトオーダーマーク(略してBOM)と呼ばれる。この場合はリトルエンディアンを意味している。ビッグエンディアンだと「0x00 0x00 0xFE 0xFF」「0xFE 0xFF」と逆順になる。UTF-16やUTF-32の場合、上位バイトと下位バイトを入れ替えても別の文字として成立するという性質がある。そのため正しく文字を解釈するには、エンディアンの情報が必要になるのだ。[9] … Continue reading
UTF-8の良いところの一つは、このエンディアンによる悩みがないことである。UTF-8は複雑なエンコーディング規則になっており、各文字の先頭1バイト目と2バイト目以降はビットパターンが明確に区別されている。そのため、仮に複数バイト一度に読み込み、エンディアンを取り違えたとしても、即座に誤った読み取りをしてしまったことがわかるのだ。そのようなわけで、UTF-8には本来BOMは不要である。しかし、実際にはUTF-8にはBOMが付与されることがある。この理由はすぐ後に述べる。
まとめよう。UTF-8の最大のメリットは、ASCII互換であることである。デメリットはバイト長が文字によってマチマチになることである。これによりN個先の文字を取得するためにはNに比例した時間がかかる。定数時間で取得するには別途インデックスを作るなどの工夫が必要になる。
文字コードはどこに保存されるのか
これまで長々と文字コードについて説明してきた。文字コードとはビット列から文字列を復元するために必要な情報である。当然、文字データを解読する前に何らかの方法で文字コードを取得しておく必要がある。
例えば、Eメールのデータの場合、メールヘッダと呼ばれる先頭部分に何の文字コードを使っているかという情報が書かれる。メールデータの先頭にメールヘッダがあり、その領域はASCII文字で記述されることになっているので、メーラは安心してメールヘッダから文字コードの情報を取り出すことができる。
HTMLも同じである。HTMLヘッダと呼ばれる領域で文字コードを宣言することができる。
ここから明らかなように、メールデータやHTMLファイルを非ASCII互換のエンコーディング(例:UTF-16)で保存するのは悪い考えだ。メールデータやHTMLファイルを読む時、メーラやブラウザは先頭からデータを読み込んでいく。この時、先頭にはASCII文字で書かれたヘッダがあるだろうと思って読むわけで、そのデータがASCII非互換だと都合が悪いからである。
まあ大概のメーラやブラウザは賢いので、先頭から読み始めた時のバイト列の様子から、おやおやこれはUTF-16っぽいぞ、と思うと読み直して上手いことやってくれるようになっているが、HTMLの規格としてはUTF-8が推奨されている。[10]HTMLで文字エンコーディングを指定する[11]HTMLの文字コードをどうするべきか、あるいはHTMLとは何かという話
さて、メールやHTMLのような構造化されたリッチなドキュメントであればそれでいい。あるいはMicrosoft Wordや一太郎のようなバイナリファイルであっても問題ない。どこかに文字コードを記述してくれている領域があり、そこを参照すればそのドキュメントのエンコーディングがわかる。めでたしめでたしだ。しかし、もっとプレーンなテキストファイルだとどうだろうか?ファイルを開いても文字列を表現するバイト列が並んでいるだけで、文字コードの情報はどこにも存在しない。バイト列に暗号化された文字列があり、キーはない。どうやって解読すればいいだろうか?なぜ、我々は普段テキストファイルをテキストエディタで文字化けせずに開けているのだろうか?
このヒントとなるのがBOMである。本来BOMとはバイトオーダーマーク、すなわちバイトの並び順を示すためのものだったが、実際には「文書の先頭に『0xFF 0xFE』なんて書くのはUTF-16さんに決まっているでしょう。隠しても無駄ですよ。」という感じで文字コードの識別にも応用されるのである。先頭の数バイトが純粋なデータではなく、データの説明をするためのデータ(メタデータ)の役割を果たしているのだ。
さて、このムーブメントに翻弄されたのがUTF-8である。UTF-8は、バイト列から文字列に復元する際には本来BOMを必要としない。しかし、そのドキュメントがUTF-8であることを示すために「0xEF 0xBB 0xBF」というBOMがつけられることがあるのだ。
BOMを手がかりに文字コードを特定するというのは、もちろんShift_JISのようなBOMのない文字コードの場合には使えないテクニックだ。しかし、各言語で使われる文字の出現頻度には偏りがあるし、使われる文字コードの種類によっても出現するバイトの種類に偏りが出るので、ある程度の文字数があれば文字コードを推測することが可能だ。そのためテキストエディタが十分賢ければ、なんだかんだで多くの場合には文字化けせずに文字を表示することが可能になっている。そこまでせずとも、特に指定しなければOSのデフォルトのエンコーディングで保存されるだろうから、とりあえずデフォルトのエンコーディングで開いてみるという戦略も悪くはない。だがいずれにしろ、運が悪いとたまに文字化けするという可能性は避けられない。
結局のところ、誰が開くともわからないテキストファイルを保存するときには、基本的にBOM付きのユニコードで保存すべしという話になる。そうすれば開く側が非常に楽である。Microsoftはそのような思想に基づいて、UTF-8といえどBOM付きを前提としている節がある。BOMがないとデフォルトのテキストエディタである「メモ帳」でUTF-8で保存した場合、問答無用でBOM付きのUTF-8になるし、BOM付きでないUTF-8で作成したCSVファイルをMicrosoft Excelで開くと文字化けする。[12]ただ、ちょっと事情が変わってきたようで、Windows10 19H1ではUTF-8のBOMなしが標準になったらしい。
ではUTF-8を使うときも常にBOM付きにすべきか、というとそうでもないのが困った話だ。Web界隈では、HTMLやPHPのソースコードなどでは、UTF-8にはBOMを付けないのが原則である。この場合、BOMをつけていることで逆にトラブルに見舞われる可能性がある。
このような思想の違いは、何処の馬の骨とも知れぬ野良テキストファイルを開かされる可能性のあるPCアプリ界隈と、基本的にはIT知識のある人間しかテキストファイルに触れることのないWebアプリ界隈の事情の違いを反映しているように思える。
このような事情を踏まえてか、現状、UTF-8にBOMをつけることは、必須ではないし推奨されてもいない、かといって禁止されてもない、という微妙な状況なのである。
ユニコードの守備範囲
結局BOM論争というのは、「ユニコードは何を符号化するのか?」という根本的な問いに端を発するものである。文書を取り扱うためのデータとしては、文字データ以外にも、ファイルの文字コード情報や作成日時などのメタデータや、フォントや文字色などの装飾データがある。このうち、ユニコードの守備範囲は文字データであると考えるのが素朴な感覚である。しかし、実際には、ユニコードは検討段階から文字コード以外の部分にまで手を伸ばしているのだ。
その一例が「ルビタグ」と呼ばれるもので、正式には「InterLiner Annotation」という名前のものだ。これは日本語のルビのようなものを表現するためのものだ。ルビというのは装飾である。装飾ではあるが、ユニコードの規格に取り入れられているのだ。ユニコード規格の制定当時、「装飾などの表示に関しては、HTMLやXMLなどの上位構造で指定されるべきものであり、ユニコードの守備範囲外である」とする一派と、「プレーンテキストだけでもある程度リッチな文書を表現できるようにしよう」という一派でかなりの議論が交わされたようである。そして、どうやら後者のリッチ派が多数派だったようなのだ。こうして、ルビタグというものが採用された。ただし、「基本的には非推奨」で、「送信側と受信側で合意が必要」であり、「HTMLなどの上位構造で指定されたものと矛盾した場合は、上位構造を優先する」という控えめなものではあるのだが、ともかくユニコードという規格はそこまで踏み込んでいるのである。
そう考えると、UTF-8にBOMをつけるかどうか、というのは文書データに文字コードを情報を示すメタデータをつけるかどうか、という話で、ルビタグと同じ判定基準になるのは理にかなっているのだろう。
私は当初、UTF-8のBOMやルビタグの話を知ったとき、かなりの違和感を覚えた。本来文字コードにはあるべきで無い物のはずだからだ。直交性に反していて気持ちが悪い。しかし、現実問題としてはプレーンテキストというものは世の中に氾濫していて、それを取り扱うアプリケーションも無数に存在するわけで、そう考えるとプレーンテキスト単体でもいろいろなことがうまくいくようになっているのも、それはそれで重要なように思えてきている。
全角と半角の判定
さて、あなたの5000億円がかかった勝負も大詰めだ。ここからようやく全角と半角の話に入ろう。
まず最初に注意事項を述べておくと、文字のバイト数で判定するのは誤った考え方である。よく全角文字を意味して2バイト文字と呼ばれることがあるが、これはJISコードやShift_JISでの文化である。すでに見てきたように、UTF-32やUTF-16では1バイト文字は存在しないし、UTF-8では日本語は3バイト以上になることもあるからである。
ユニコードの世界で全角と半角を考慮する上での重要なポイントは、日本語の全角文字と半角文字には別のコードポイントが割り当てられているということだ。全角数字と半角数字、全角カナと半角カナは、本来は同じ文字であり、見た目の上の表現が違うだけである、とみなされ、同じコードポイントが割り当てられてもおかしくはなかったのだが、歴史的な経緯を考慮して別の文字という扱いになっており、別のコードポイントが割り当てられている。
ということは文字のコードポイントさえ特定できれば、あとはその文字が全角なのか半角なのかということはわかるということになる。そして、どの文字が全角文字なのか、という情報は公開されている。
下記のサイトから辿っていくと、
Unicode® Standard Annex #11 EAST ASIAN WIDTH
次のURLに辿り着くことができる。
https://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt
ここに各コードポイントの文字が半角なのか全角なのか、まとめられているのだ。この情報を使えば、やりたいことは実現できるはずだ。
とはいえ、ここから実装に入ると、さすがに長くなりすぎてしまう。ユニコードに対する解説は終えられたので、このページはここまででおしまい。実装に関してはまた別のページで行うことにしよう。
実装編はこちら
参考文献
- Joel On Software
- 第4章の「すべてのソフトウェア開発者が絶対確実に知っていなければならないUnicodeとキャラクタセットに関する最低限のこと(言い訳なし!)」がユニコードの章になっている。私が初めてユニコードを理解したのはこれ。一度絶版になり残念だったが、電子書籍化されたようで安心した。
- ユニコード戦記
- ユニコードコンソーシアムの日本代表として尽力された著者による回顧録。当時の議論の様子や、委員会の内実などが生き生きと描写されている。ルビタグ問題などはこの本で初めて知った。国際規格が決まっていく過程がここまで生々しい人間の営みとして描かれることは珍しいのではないか。貴重な本だと思う。この本は電子書籍化して欲しい。
- Dive Into Python 3 文字列
- 現在Web上に無償で公開されているユニコード関連の記事で最も理解しやすいと思われる記事。この記事を超えることが目標だったが、達成できただろうか?分量が多いのだけは間違いないが・・・
References
↑1 | と、見てきたように語っているが、私は世代が違うのでよくわからない。このサイトが当時の時代感を伝えてくれる。 |
---|---|
↑2 | と、また見てきたように語ったが、実際の歴史はこの流れとは違う。例えばJIS X 0201という規格がある。これはASCII文字の他には半角カナしかない規格で、これはワープロの普及よりもずっと昔に制定されている。説明の都合上かなり単純化して語っている。 |
↑3 | 実際には1バイトは8ビットとは限らず、当時のシステムでは1バイト6ビットや7ビットというものもあったようだが、細かい話なので忘れることにする。 |
↑4 | 0x1bというのは前回出てきたエスケープコードであるのを思い出しただろうか? |
↑5 | と言うかMicrosoftなどの実装が先行して、その後規格に採用されるという典型的なデファクトスタンダードだったらしい。(「ユニコード戦記」より) |
↑6 | アラビア文字に興味が出た方におすすめのサイトを紹介しておこう。「Culti <アラビア語の基本の文字>」 |
↑7 | しかし、現実はそう上手くはいかないことをこのサイトが教えてくれる。UTF-32 でも固定長で処理出来るわけではない |
↑8 | 細かい話をすると4バイトのエンディアンには理論的には4!=24パターンのエンディアンがありえるが、現実的にはビッグエンディアンとリトルエンディアンの2パターンだ。 |
↑9 | ところで、CPUのエンディアンとは違い、UTF-16やUTF-32のエンディアンは取り決めごとでしかないので、実はエンディアンとは無縁の規格にすることもできたのではないかと思う。バイトオーダーのバリエーションなど認めず、「CPUのエンディアンに関わらず必ず下位アドレスから順にバイトを並べるべし。」というような取り決めである。CPUのエンディアンが事前にわかっているのであれば、エンディアンを考慮してアプリ側でバイトを入れ替えてもいいし、単に1バイトずつ読み書きするようにしてもいい。しかし実際にはビッグエンディアンとリトルエンディアンの双方が許容された。CPUのエンディアンに合わせて複数バイト読み書きできる方が計算資源の観点から効率的である、という理由なのだとは思うが、この辺りの背景は調べてもあまり情報が出てこない。 |
↑10 | HTMLで文字エンコーディングを指定する |
↑11 | HTMLの文字コードをどうするべきか、あるいはHTMLとは何かという話 |
↑12 | ただ、ちょっと事情が変わってきたようで、Windows10 19H1ではUTF-8のBOMなしが標準になったらしい。 |