はじめに
この記事は2020年ふりかえりアドベントカレンダー 10日目です。これを書いているのは12月14日ですが、あまり気にしないことにしました。昨日の記事は .irbrc で irb で使える独自メソッドを定義する - いまブログ です。
TL;DL
サロゲートペアは16ビット固定長で FFFF
外の文字を表現するために生まれた文字の表現方法です。
サロゲートペアが問題になるのは UTF-16 だけの話です。UTF-8 等では可変長のためこの表現方法を取っておらず、問題になりません。
おことわり
私は Unicode の歴史的経緯がよくわかっていないので正しい理解でないかもしれません。ですが、折角書いたり聞いたりしたのでまとめておきます。何か気になる点があれば、コメント、 twitter 等で連絡いただけるとありがたいです!
あらまし
フィヨルドブートキャンプの Slack で、 String
に 55296
という数値を <<
で追加すると見慣れぬエラーになるという投稿がありました。
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 とかに載っているので興味があれば...
余談ですが、実は Ruby の String
は "\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
が多いかと思います。
"あ"
のように '
"
で文字列をくくると Ruby は String
オブジェクトを生成します。このとき、 疑似変数 __ENCODING__
でエンコードされた String
オブジェクト を生成します。
エンコードするときに壊れた文字データが入っていた場合は例外を出します。このため、上位サロゲート文字の "\uD800"
は1文字では文字として成り立たないためエラーになっていました。
やり方を変えて、String.new
するときに encoding
情報を渡すとエンコードされないため例外になりません。Ruby の String
に壊れた文字をもたせること自体は問題なく、 ""
で String
オブジェクトを作成したときにエンコードが走り正しい値かチェックされていたことが原因で例外を吐いていたということが分かりました。
String.new("\xD8\x00", encoding: Encoding::UTF_8) => "\xD8\u0000"