サロゲートペアとRubyのStringについての覚書

はじめに

この記事は2020年ふりかえりアドベントカレンダー 10日目です。これを書いているのは12月14日ですが、あまり気にしないことにしました。昨日の記事は .irbrc で irb で使える独自メソッドを定義する - いまブログ です。

TL;DL

サロゲートペアは16ビット固定長で FFFF 外の文字を表現するために生まれた文字の表現方法です。

サロゲートペアが問題になるのは UTF-16 だけの話です。UTF-8 等では可変長のためこの表現方法を取っておらず、問題になりません。

おことわり

私は Unicode の歴史的経緯がよくわかっていないので正しい理解でないかもしれません。ですが、折角書いたり聞いたりしたのでまとめておきます。何か気になる点があれば、コメント、 twitter 等で連絡いただけるとありがたいです!

あらまし

フィヨルドブートキャンプの Slack で、 String55296 という数値を << で追加すると見慣れぬエラーになるという投稿がありました。

s = ""
s << 55296
# Main.rb:2:in `<main>': invalid codepoint 0xD800 in UTF-8 (RangeError)

その後別の方が調査して 0xD800サロゲートペアのコードポイントであると分かりました。

そこでサロゲートペアとはなんだろうということで調べてまとめて、以下の文をフィヨルドブートキャンプの Slack に投稿しました。(一部投稿時から編集しています)

サロゲートペアとは

サロゲートペアは Unicode が16ビットでは足りなくなったために生まれた、上位サロゲートと下位サロゲートを組み合わせて1つの文字を表現するための方法です。UTF-16 で使われています。他の Unicode エンコーディングでは使われていません。

もともと Unicode は16ビットの 固定長 として誕生しました。16ビットなので、16進数で 0000 〜 FFFF の65536文字を格納できます。

しかし、世界中の文字を格納するには65536文字では足りないことが分かりました。

そこで既存の固定長を前提にしているシステムを壊さないようにしつつ文字をもっと格納するためにサロゲートペアという仕組みがとられました。

サロゲートペアは上位サロゲートと下位サロゲートから構成されています。例えば「𠮷」(吉ではなく、土に口がついている字)を見てみます。 「𠮷」のコードポイントは U+20BB7 です。

> "𠮷".unpack("U*").each { p _1.to_s(16) }
"20bb7"

「𠮷」は、上位サロゲートが「D842」下位サロゲートが「DFB7」のコードポイントで出来ています。コードポイントから上位サロゲート・下位サロゲートを計算する方法は wikipedia とかに載っているので興味があれば...

余談ですが、実は RubyString"\unnnn"または "\u{nnnnn}" でコードポイントから文字に変換する機能があります。(nは16進数の数値。{}つけないほうは4桁までしか入りません)

試しに先程の 55296 を 16進数 D800 に直して表示しようとしてみると、同じ invalid Unicode codepoint というエラーになります。

irb(main):030:0> 55296.to_s(16)
=> "d800"
irb(main):032:0> "\ud800"
Traceback (most recent call last):
        3: from /Users/mi/.rbenv/versions/2.7.1/bin/irb:23:in `<main>'
        2: from /Users/mi/.rbenv/versions/2.7.1/bin/irb:23:in `load'
        1: from /Users/mi/.rbenv/versions/2.7.1/lib/ruby/gems/2.7.0/gems/irb-1.2.7/exe/irb:11:in `<top (required)>'
SyntaxError ((irb):32: invalid Unicode codepoint)
"\ud800"
   ^~~~

もっと Ruby の String とエンコーディングの話

フィヨルドブートキャンプに投稿したバージョンでは、以下の文を入れていました。その後教えていただいて誤りがあると分かったので教えていただいた内容を簡単にまとめます。

上位サロゲート、下位サロゲートは1文字では文字として成り立たないため、s << 55296 のようにStringに挿入しようとすると「変なもの入れてますよ!!」とエラーがでるのだと思います。

"\uD800" とは何をしているのか

これは String オブジェクトを __ENCODING__ に指定されたエンコーディングで作成する処理です。

疑似変数 __ENCODING__スクリプトエンコーディングを持っており、最近の環境では UTF-8 が多いかと思います。

"あ" のように ' " で文字列をくくると RubyString オブジェクトを生成します。このとき、 疑似変数 __ENCODING__エンコードされた String オブジェクト を生成します。

エンコードするときに壊れた文字データが入っていた場合は例外を出します。このため、上位サロゲート文字の "\uD800" は1文字では文字として成り立たないためエラーになっていました。

やり方を変えて、String.new するときに encoding 情報を渡すとエンコードされないため例外になりません。RubyString に壊れた文字をもたせること自体は問題なく、 ""String オブジェクトを作成したときにエンコードが走り正しい値かチェックされていたことが原因で例外を吐いていたということが分かりました。

String.new("\xD8\x00", encoding: Encoding::UTF_8)
=> "\xD8\u0000"