Unicode とサロゲートコードポイント

Posted on 土 14 11月 2020 in 規格

Unicode は、文字コードの標準を目指して創設された規格であり、文字をどう処理するか、テキストデータとしてどう表すかを規定している。今や国際的に普及した規格で、特に Unicode が規定する符号化方式 UTF-8 は、いまやテキストデータのエンコーディングデファクト標準となっている。

Unicode は歴史的経緯からサロゲートコードポイントという仕様を包含している。今回は、この仕様の紹介と、UTF-8 を使う際の注意点を見ていく。なお,

を元にしていく.

Unicode と固定長の夢

当初、Unicode は ASC-II の固定長 7bit 表現に倣い、固定長 16bit で世界中の文字を表現する規格として提案された。当時の提案 [1] では、

In the Unicode system, a simple unambiguous fixed-length character encoding is integrated into a coherent overall architecture of text processing.

Unicode システムでは,単純で明確な固定長の文字コードによって,全ての文書処理においての一貫性が提供されます.

が、Unicode の売り文句だった。つまり,文字一つ一つに 16bit の中からコードを割り当てることで,世界中の文字を表そうとしたのだ.このコンセプト自体は、現在の Unicode でも引き継がれているが、残念ながら今や Unicode は固定長ではなくなってしまった。Unicode の文字列において,長さとは何かというのは,また色々とややこしい話になるので,とりあえず置いておく.

さて,当初 16bit,つまり 65536 文字で世界中の文字を納めようという計画は,CJK (Chinese / Japanese / Korean) 圏の参戦で見事に頓挫することになる.文字の登録件数は,Unicode 1.0 [2] の時点で28706 件だったのが,Unicode 4.0 [3] では 96382 件まで膨れ上がることになる.Unicode 4.0 の数字から分かる通り,65536 文字を明に超える.Unicode は,2.0 から 16bit 固定長で全ての文字を収容することを諦め,以降 3.0 からは本格的に 21bit 体制に移行していくことになる.

さて,21bit 体制に移行する時に問題となったのが,それまで使っていたエンコーディング方法だ.Unicode はそれまで 16bit 前提の世界で,割り当てられた文字をそのまま 2byte 固定表現でエンコーディングする方法をとっていた.しかし,文字が 16bit に収まらなくなってしまった今,そのエンコーディングで全ての文字を表すことはできない.そこで Unicode は固定表現を諦め,それまでの互換性を保ったまま可変長の文字表現を導入することとなる.

こうして最初に掲げられた Unicode の標語は,仕様から姿を消すことになった.

UTF-16 とサロゲートペア

ところで,なぜ 21bit という中途半端なビット数を採用することにしたのだろうか? その話に入る前に,Unicode の簡単な概要を述べておく.

Unicode は扱える文字一つ一つにコードポイント (code point) と呼ばれる符号を割り当てている.符号は 0 から 0x10FFFF (1,114,111) の間から採用される.つまり,その空間に入る文字数しか Unicode は扱えない.ついでに,21bit 最高値は 0x1FFFFF なので,厳密に 21bit 空間がフルに使えるわけではない.この数は

0xFFFF+0x4002 \mathrm{0xFFFF} + \mathrm{0x400} ^ 2

という計算式によって得られる.この 0x400 という数はサロゲート領域と呼ばれるコードポイント空間のサイズ (の 2 分の 1) からくる.

実は,Unicode のコードポイント,つまり 0 から 0x10FFFF までの数は,全てが文字を割り当てられることを想定されているわけではない.文字を割り当てるコードポイントは,通常のコードポイントと区別して,Unicode ではスカラー値 (Unicode scalar value) と呼ばれている.正確な Unicode スカラー値の定義は,サロゲート領域に含まれないコードポイントのことだ.サロゲート領域とは,0xD800 から 0xDFFF のコードポイント空間を指し,この空間の値には文字は割り当てられない.よって,Unicode スカラー値とは,それ以外の 0 から 0x10FFFF の間の値のことということになる.この微妙な位置に領域が空いているのは,Unicode コードポイントとして何か特別な使用方法を意図してのものではなく,テキストデータへのエンコーディングを見据えてのものになる.

Unicode が初期,16bit 固定長の夢を描いており,それが途中で破綻したことは上で説明した.しかし,破綻したからといって,では今から 16bit による文字表現はやめますと言っても,全ての処理系がはいそうですかと応じられるわけではなかった.特に,文字表現はほぼ根幹となるようなデータ型であり,それが 16bit 固定になっていた場合,そのサイズを変えるのは容易なことではない.そこで,Unicode は 16bit 固定長の夢を諦めても,テキストデータの表現において処理するデータの単位 (これをコードユニットと言う) を 16bit から変えるわけにはいかない.さらに,それまで搭載していた文字については,今までのテキストデータと互換する必要がある.つまり,今まで 16bit で一つの文字を表していたものについては,コードポイントをそのまま 16bit で表した数値を一つのコードユニットとして扱い,かつ 16bit に収まらないコードポイントを持つ文字は,何とかして 16bit 単位のデータとして表現しなければいけない.もちろん,16bit を超えるコードポイントを持つ文字は,16bit 2つ分に分割して表現するという方法が取れるが,その場合デコーディングの方法が一意に定まらなくなってしまう.つまり,コードポイント 0x1f34e の文字を,

0x0001 0xf34e

とエンコードした場合,デコードする際 0x10xf34e それぞれのコードポイントに対応する文字 2 つにデコードするか,0x1f34e のコードポイントに対応する文字 1 つにデコードするかが決められなくなるのだ.

こうなってしまっては仕方ないため,Unicode は,それまで使用していなかった領域を特別にサロゲート領域として指定し,そこのコードポイントには文字は割り当てないことにした.そして,そこの範囲で何とか 16bit のコードユニットになるよう頑張る方法を考えることにした.この方法の利点は,

  • 16bit を超えないコードポイントを持つ文字については,影響を受けない
  • それまでコードポイント1つを単純にコードユニット1つとして扱っていた実装は,コードユニット2つ分の文字は扱えないとすることでそれまでの処理を継続できる

というところにある.後は,デコードが一意に定まるようなエンコード / デコード方法を考えられれば良い.結果的に Unicode が採用したのは

  1. コードポイントの内,0xD800 - 0xDBFF (0b110110??????????) を high surrogate,0xDC00 - 0xDFFF (0b110111??????????) を low surrogate という名の領域として確保し,そこは文字を割り当てないようにする
  2. 0x10000 - 0x10FFFF のコードポイントは,0x10000 を引き (つまり,0x00000 - 0xFFFFF の 20bit 値になる),10bit の値を表すコードユニット 2 つとして扱う
  3. コードユニットの値は,上位 10bit は 0xD800 を足して high surrogate のコードポイントになるように,下位 10bit は 0xDC00 を足して low surrogate に収まるようにする

という方法になる.この方法が俗に言う UTF-16 と呼ばれるエンコーディング方法である.コードユニット 2 つになる場合は,high / low surrogate は区別できるため,連結さえしていれば順不同で保存できるが,デフォルトではビッグエンディアン,つまり high の次に low という順で保存することが決められている [4]

Unicode で格納できる文字が中途半端な上限を持っているのは,UTF-16 で表現できる値の上限で設定されているからである.

PEP 383

というわけで,UTF-16 の互換性のため,Unicode のコードポイントには文字の割り当てが避けられてる領域があった.この領域はもちろん,Unicode scalar value ではないので,エンコードの対象ではない.

ところで,プログラミング言語には,文字列の内部表現のシェアが大きく分かれている.大きくは,

  • UTF-8 / UTF-16 / UTF-32 のいずれかを内部表現として使っている言語
  • Unicode コードポイントの列を内部表現として使っている言語

に分かれている.個人的に観測している範囲では,古い言語は UTF-16 の採用率が高く,最近は UTF-8 の採用率が高い気がする.古い言語で UTF-16 採用率が高いのは,やはり Unicode の歴史的事情が大きく影響を与えているんじゃないだろうか? まあ,その辺の話は置いておいて,今回注目したいのが「Unicode コードポイントの列を内部表現として使っている言語」だ.例としては,Python,Haskell [5] が相当する.

Unicode コードポイントの列というのは,つまりは 0 から 0x10FFFF の間の整数の列ということだ.ところが,コードポイントにはサロゲートコードポイントが含まれているため,文字表現として実質必要なのは 0 から 0xD7FF0xE000 から 0x10FFFF の数値だけだ.なので,0xD800 から 0xDFFF の間は使われない領域ということになる.こういう領域を見ると有効活用したいと思うのがエンジニアの性らしく,Python / Haskell ではそれぞれが,実は微妙に Unicode コードポイントから拡張を施している.これらの言語では,コードポイントのうち,0xDC80 から 0xDCFF の数値は Unicode コードポイントして扱われない場合がある.

では,何に使われているかというと,ASCII 互換の文字コードでエンコードされたバイト列に対し,デコードに失敗した文字を表現するために使われる.正直こんな機能あまり出番はないと思うというか,使いたくなるユースケースはあまり思い浮かばないが,例えばこの機能によって文字列型でバイト列を扱うことができる.何を言ってるか分からないと思うので,とりあえず Python3 で例を見てみよう:

1
2
3
4
5
6
7
8
>>> b'ab\xe3\x81'.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe3 in position 2: ordinal not in range(128)
>>> b'ab\xe3\x81'.decode('ascii', 'surrogateescape')
'ab\udce3\udc81'
>>> b'ab\xe3\x81'.decode('ascii', 'surrogateescape').encode('ascii', 'surrogateescape')
b'ab\xe3\x81'

b'ab\xe3\x81' というバイト列の \xe3\x81 は,それぞれが 0x7F を超える値なため,ASCII では文字が割り当てられていない.そのため,もちろん ASCII エンコーディングでデコードしようとすると失敗する.ところが,surrogateescape というエンコーディングモードを使うと,このバイト列はデコードでき,サロゲートコードポイントを含む文字列が生成されることになる.Python の surrogateescape は次のようなことを行うモードだ:

デコード時
  1. バイト列が正しくデコードできるならデコードする
  2. 正しくデコードできないバイト c に出会すと,
    • c >= 0x80 ならば,コードポイント 0xDC00 + c にデコードする
    • c < 0x80 ならば,デコードに失敗する
エンコード時
  1. 0xDC80 から 0xDCFF の範囲の文字 c は,c - 0xDC00 に相当するバイトを出力する
  2. それ以外の文字はそのままエンコードする

c < 0x80 の時デコードに失敗するのが,ASCII 互換でない文字コードに対応できない理由だ [6].この仕様は,PEP 383 で決まっている.なお,技術的には c < 0x80 かどうかの分岐は取り除けるが,セキュリティリスクを軽減するためにこうなっているようだ [7].とにかくこれにより,メモリ効率的にはだいぶ無駄ではあるが,文字列としてバイト列をそのまま扱えるようにできたりする.

Haskell も PEP 383 と大体同じ方式のエンコーディングモードを搭載している.こちらは Roundtrip モードという名前になっている.実際に試してみる:

1
2
3
4
5
6
7
>>> import System.IO
>>> import GHC.IO.Encoding.Latin1
>>> import GHC.IO.Encoding.Failure
>>> ascii = mkAscii RoundtripFailure
>>> openFile "sample.txt" ReadWriteMode >>= \h -> hSetEncoding h ascii >> hPutStr h "ab\uDC81" >> hClose h
>>> openFile "sample.txt" ReadWriteMode >>= \h -> hSetEncoding h ascii >> hGetLine h >>= \s -> print s >> hClose h
"ab\56449"

なお,この時実行したディレクトリに,以下の内容の sample.txt というファイルが生成される:

$ od -tcx1 sample.txt
0000000   a   b 201
     61  62  81
0000003

また,56449 の 16 進数表記は 0xDC81 になる.個人的には,誰が使うのか分からない邪悪なモードという感は強いが,Unicode のサロゲートコードポイントに文字を割り当てないという決定は,こういうところにも影響を及ぼしているということだ.

まとめ

というわけで,今回は Unicode のサロゲートコードポイントができた経緯と,内容の紹介,そして付随する PEP 383 という仕様を紹介した.この辺の理解,ちょっと曖昧だったので,まあいい勉強にはなった.文字コード界隈は,歴史的経緯と実装が合わさって魔窟っすね.

後,GHC の Char の内部管理が,実は素直な Unicode コードポイントではないということは知っていたんだが,その詳細は知らなかったので,理解は進んだ.この辺,資料ほぼ皆無なので,誰も使っていないんだろな.ってことで,今回は以上.

[1]https://unicode.org/history/unicode88.pdf
[2]https://www.unicode.org/versions/Unicode1.0.0/
[3]https://www.unicode.org/versions/Unicode4.0.1/
[4]正確には UTF-16 には2つのバリエーションがあり,コードユニットが2つになった時,ビッグエンディアンで並べるか (UTF-16BE),リトルエンディアンで並べるか (UTF-16LE) が分かれている.通常の UTF-16 は BOM によりこの2つの方式のいずれかを指定することができ,指定されていない場合はビッグエンディアンになる.
[5]Haskell の方は,厳密には Haskell の仕様ではなく,Haskell の処理系 GHC の実装仕様である
[6]ところで,実はこの処理の流れだと UTF-16 とかも対応できる.UTF-16 で正しくデコードできないのは,high surrogate から続けて並んだりといったケースだが,そのような場合デコードできないバイト列は 0x80 以上のバイトだからだ.実際,CPython 3.7.8 で確認したところ surrogateescape はエラーなしに動くようだ.
[7]もしこの条件分岐がなかった場合,ASCII の範囲の任意の文字をサロゲートコードポイントとして内部に持ち,ASCII 互換の文字コードでエンコード時に,ちゃんとエンコードしてしまうことになる.そうすると,例えば入力でわざとデコードに失敗する文字コードを選択し,それを ASCII 互換の文字コードにエンコードさせることで,制御文字などをバリデーションを避けて埋め込める可能性がある.これを避ければある程度セキュリティリスクは抑えられるだろうという判断のようだ.また,世の中の多くの文字コード,特にロケール文字コードと呼ばれるものは ASCII 互換である.なので,基本的に ASCII の範囲でデコードが失敗することは少ない.なので,実用上も問題ないということらしい.以上のことは,PEP 383 の Discussion に書かれている.ASCII 以外の範囲で制御文字を持つ何かがあれば結構危うい気がするが,詳しくは調べてない.