C#
AdventCalendar
unicode
C#Day 13

文字って何かね?

元ネタ: 「文字列を文字の列とみなす単純化」ってどういうこと?解説編 - 西尾泰和のはてなダイアリー


Shift JISおじさん(半角文字は1バイト、全角文字は2バイト派)

今どきShift JISもないだろうと思いますが、レガシーな業務システムなんかだと割と普通に残っていますね。

C#でShift JISな文字を扱いたければ、System.Text.Encodingクラスを使っていろいろすればいいです。

var text = "あいう";

var enc = System.Text.Encoding.GetEncoding("Shift_JIS"); // コードページ932でも可。
// 日本語WindowsならDefaultプロパティでもいい。
var bytes = enc.GetBytes(text);

後で書きますが、C# のchar型やstring型はUnicodeをベースにしていますから、使える文字集合はShift JISよりずっと広いです。たとえば "你好" (ニーハオ) の「你」なんてのはJIS第三水準漢字ですがShift JISでは使えません。そういうのをEncodingクラスで処理すると "?好"のように「?」に化けます。

「?」に化けるのは、エンコーディング失敗時に文字を「?」に置換するフォールバック設定がデフォルトだからです。失敗時には例外をスローしたければこんな感じ。

var text = "你好";

var enc = System.Text.Encoding.GetEncoding("Shift_JIS",
System.Text.EncoderFallback.ExceptionFallback,
System.Text.DecoderFallback.ReplacementFallback);
try
{
var bytes = enc.GetBytes(text);
}
catch (System.Text.EncoderFallbackException ex)
{
// 変換に失敗した文字は ex.CharUnknown プロパティで参照できる
}

失敗した時に"?"に変換されるのも嫌だし、例外がスローされるのも嫌だという人は、独自のEncoderFallback実装クラスを作る必要があります。


文字はcharだよ派

C#のstringはそういう発想で作られています(LengthプロパティとかIndexOf()メソッドとかSubstring()メソッドとかそういうの全部)。C#的には自然な発想ですね。さっきの"你好"も普通に扱えます。

char型は2バイト。これはすべての文字を2バイトで表そうとしたUnicode 1.1の名残りですが、ご存じのようにその構想は失敗しました。現在のUnicodeコードポイントは最大21ビットでして2バイト(16ビット)には当然収まりません。収まらない文字は「サロゲートペア」と呼ばれるものを使って、char型2つで表現します。その結果、"𩸽" (ほっけ)は2文字ということになってしまいます。

UIを作ってて、単純にテキストボックスにMaxLengthプロパティを設定すると、Windows FormsでもWPFでもそういうことになります。


文字はUnicodeコードポイントだよ派

言い換えれば、文字は4バイト固定だよ派です。UTF-32がそういう文字エンコーディングですね。4バイトと言っても上で書いたようにUnicodeでは21ビットしか使わないので、符号付32ビット整数すなわちint型に安全に収まります(uint型にする必要はない)。

C#のstringを直接int配列に変換することはできませんが、for文などのループを使えばこんな感じに書けます。

static IEnumerable<int> ToUtf32(string text)

{
for (var i = 0; i < text.Length; i += (char.IsSurrogate(text, i) ? 2 : 1))
{
yield return char.ConvertToUtf32(text, i);
}
}

実は最近のInternet Explorerがこういう(Unicodeコードポイントを1文字と数える)挙動をします。<input type="text"/>maxlength属性を指定して、サロゲートペアで表される文字(「𩸽」など)や、次で紹介している結合文字列やIVSを入れてみるとわかりますよ。


複数のUnicodeコードポイントで1文字を表すこともあるよ派 (2018/12/24追記)

言い換えれば、固定長の文字エンコーディングなんて幻想だよ派です(Rubyは文字列についてCSIという方針を採用していたため、結果としてこのグループに入ることになりました)。

実際、Unicodeという規格にはそのような文字が含まれます。

Unicodeでは、後述する結合文字列やIVSなどを含む、ユーザーが文字として認識する単位を書記素クラスター(grapheme cluster)として定義しています。 書記素クラスターについてはUnicodeとは? その歴史と進化、開発者向け基礎知識 - Build Insiderという記事で触れられています。


結合文字列

ものかの >> archive >> Unicode正規化 その1 というページが正確でかつ分かりやすいと思います。

"がぎぐげご" ←これは普通の濁音ひらがな(Unicodeでは合成済み文字という扱い)です。Unicodeコードポイントで数えると5文字です。

"がぎぐげご" ←これは結合用濁点(文字コード U+3099)を使用していますので、「か゛き゛く゛け゛こ゛」と同じように、Unicodeコードポイントで数えると10文字です。C#で書く場合は"か\u3099き\u3099く\u3099け\u3099こ\u3099"となります。

Windowsでは(通常の運用では)そういう文字を入力することはほとんどありませんが、Max OS Xではファイルシステムがそのような文字体系を使用していますのでFinderでコピペをするとそういう文字がクリップボードに入ってきます。

(2017年12月追記)さすがに、絵文字のことを触れないわけには行かなくなりました。絵文字の利用はスマートフォンが先行していますが、WindowsやmacOSももちろんサポートしています。

絵文字における結合文字列は、Unicode 絵文字にまつわるあれこれ (絵文字の標準とプログラム上でのハンドリング)という記事をご覧ください。


異体字セレクタ(IVS)

異体字セレクタとは、それ自身は表示されないけれども、付加された文字の字形を指定する機能を持った特殊なUnicode文字です。未来情報産業株式会社の解説ページにありますが、「葛」(文字コード U+845B) の後ろに文字コード U+E0100 を組み合わせると葛󠄀城市の「葛󠄀」として表示されるし、文字コード U+E0101 を組み合わせると葛飾区の「葛」として表示されるというものです。そして、IVS自体がサロゲートペアで表される領域の文字だったりします。なので、C#でIVSを使って「葛󠄀城市」を表すには "葛\uDB40\uDD00城市"みたいな書き方になります。

(2017年12月追記)絵文字でも異体字セレクタが適用される部分があります。結合文字列のところでも補足した「Unicode 絵文字にまつわるあれこれ (絵文字の標準とプログラム上でのハンドリング)」の文字の後ろにつける variation selectorの項をご覧ください。


結合文字列やIVSを扱うには (2018/12/24追記)

結合文字列や異体字セレクタをうっかり切り離さずに扱いたい場合は、System.Globalization.StringInfoクラスやSystem.Globalization.TextElementEnumeratorクラスを使います。

static IEnumerable<string> ToTextElements(string text)

{
for (var itor = StringInfo.GetTextElementEnumerator(text); itor.MoveNext(); )
{
yield return itor.GetTextElement();
}
}

このメソッドの引数に "か\u3099き\u3099く\u3099け\u3099こ\u3099""葛\uDB40\uDD00城市"を渡すと、きちんと1文字を1文字として取り出せていることがわかります。

ところがこのメソッドで、スキントーンや結合文字によって組み合わされた絵文字を扱おうとすると、見事にコードポイント別にばらばらになってしまいます。

実はUnicodeの書記素クラスターには、「レガシー書記素クラスター」と「拡張書記素クラスター」の2種類が定められているのです。名前から想像できる通り、レガシー書記素クラスターの方はUnicodeの古いバージョンで定められたもので、現在も後方互換性のために残されているものです。拡張書記素クラスターはより新しいバージョン1で定められたもので、絵文字2などはそちらの方に含まれます。

ところが、StringInfoの結合文字判定は、どうやらバージョンが古くて、拡張書記素クラスターに対応していないようなのですね。実装はCoreCLRのソースコードから確認できます。

C# で新しい書記素クラスターをサポートした実装が欲しい人は、GraphemeSplitterというNuGetパッケージを使うといいでしょう。ufcppさんのブログ記事 2017年10月 および 2018年12月と、そこからたどれるGitHubリポジトリーも参考にしてください。





  1. 拡張書記素クラスターがUnicodeに取り込まれたのは2008年のUnicode 5.1からです。タイ文字の一部や、インドの文字(デーヴァナーガリーやタミル文字)などの結合がサポートされました。 



  2. 絵文字がUnicodeに取り入れられたのは6.0、地域標識記号文字の結合により国旗を表すようになったのは6.2、家族やスキントーンはUnicode Emoji 1.0がUTS #51として標準化された8.0からです。