LoginSignup
15
2

More than 1 year has passed since last update.

Kotlin / Swift での Unicode の扱いまとめ (見た目上の文字数カウント, UTF-8, UTF-16, BOM, 正規化, 異体字セレクタ)

Last updated at Posted at 2022-01-23

Kotlin と Swift での見た目上の文字数カウント実装を中心に、Unicode について知っておくべき知識をまとめます。

また、モバイルアプリで入力文字数のカウントや入力文字数の上限をどのように扱うかは以下の別の記事にまとめました。

文字数カウント

まずは、文字数カウントが難しい例として絵文字と異体字セレクタ表現の例を挙げます。詳しい説明はこの記事の後半を確認してください。

絵文字 🧑‍🦰 の文字数について確認します。🧑‍🦰 は以下の Unicode で構成されています。

文字 Code point UTF-8 表現 UTF-16 表現 Description
🧑 U+01F901 F0 9F A7 91
(4 bytes)
\uD83E \uDDD1
(4 bytes)
ADULT
ZWJ U+00200D E2 80 8D
(3 bytes)
\u200D
(2 bytes)
ZERO WIDTH JOINER
🦰 U+01F9B0 F0 9F A6 B0
(4 bytes)
\uD83E \uDDB0
(2 bytes)
EMOJI COMPONENT RED HAIR

🧑‍🦰 の文字数は以下の通りとなります。

カウント方式 カウント 構成
見た目上の文字数 = 書記素クラスタ 1 🧑‍🦰
Code Point 文字数 = UTF-8 文字数 3 🧑 + ZWJ + 🦰
UTF-16 文字数 (UTF-16 Code unit 数) 5 🧑 = \uD83E \uDDD1 = 2
ZWJ = \u200D = 1
🦰 = \uD83E \uDDB0 = 2

異体字セレクタ表現の「葛󠄀」は以下の Unicode で構成されています。

文字 Code point UTF-8 表現 UTF-16 表現 Description
U+00845B E8 91 9B
(3 bytes)
\u845B
(2 bytes)
基底文字
CJK UNIFIED IDEOGRAPH-845B
セレクタ U+0E0100 F3 A0 84 80
(4 bytes)
\uDB40 \uDD00
(4 bytes)
セレクタ
VARIATION SELECTOR-17

「葛󠄀」の文字数は以下の通りとなります。

カウント方式 カウント 構成
見た目上の文字数 = 書記素クラスタ 1 葛 (基底文字)
Code Point 文字数 = UTF-8 文字数 2 葛 (基底文字) + セレクタ
UTF-16 文字数 (UTF-16 Code unit 数) 3 葛 (基底文字) = \u845B = 1
セレクタ = \uDB40 \uDD00 = 2

Kotilin での文字数カウント

Kotlin でのそれぞれの文字数カウントは以下の実装となります。

実装 カウント カウントの種類
ICU4J BreakIterator 実装 1 見た目上の文字数 = 書記素クラスタ
codePointCount() 実装 3 Code Point 文字数 = UTF-8 文字数
"🧑‍🦰".length 5 UTF-16 文字数 (UTF-16 Code unit 数)

BreakIterator

ICU4JBreakIterator を使用して以下の Extension を実装できます。

最新の com.ibm.icu:icu4j:{version} への依存が必要です。

// Gradle で implementation("com.ibm.icu:icu4j:{version}") 依存関係が必要
import com.ibm.icu.text.BreakIterator
// Android 7 以上であれば android.icu が利用可能
// import android.icu.text.BreakIterator
fun CharSequence.displayLength(): Int {
    val iterator = BreakIterator.getCharacterInstance()
    iterator.setText(this.toString())
    var count = 0
    while (iterator.next() != BreakIterator.DONE) {
        count++
    }
    return count
}
"🧑‍🦰".displayLength() // => 1

codePointCount()

String.codePointCount() (Kotlin/Java) を使用して以下の Extension を実装できます。

fun String.codePointCount(): Int {
    return codePointCount(0, length)
}
"🧑‍🦰".codePointCount() // => 3

Swift での文字数カウント

Swift 4 以降であれば文字数のカウントは楽です。

実装 カウント カウントの種類
(Swift4 以上) "🧑‍🦰".count 1 見た目上の文字数 = 書記素クラスタ
"🧑‍🦰".unicodeScalars.count 3 Code Point 文字数 = UTF-8 文字数
"🧑‍🦰".utf16.count
("🧑‍🦰" as NSString).length
5 UTF-16 文字数 (UTF-16 Code unit 数)
"🧑‍🦰".utf8.count 11 UTF-8 Byte 数
  • Swift 3 まででは見た目上の文字数を正しくカウントする機能はありません
  • String.utf8.count は UTF-8 文字数ではなく UTF-8 Byte 数です

Unicode の解説

Kotlin や Swift に限らず、Unicode について知っておくべきことと、実装ごとの Unicode の扱いについて解説します。

Unicode Code point

Unicode では文字ごとに Code point が割り当てられており、U+{Code point} (U+XXXXXX) で表記します。

  • Code point は U+000000 ~ U+10FFFF で表現されます
    • Code point そのものは 3 bytes に収まるサイズです
  • Code point は Kotlin Int (32 bit = 4 bytes) 表現に収まるサイズです

Unicode Code point 一覧 (文字の一覧) は以下の Wikipedia 記事にまとめられています。

UTF-8

Unicode Code point を 1 byte (= 8 bits) 単位で符号化したものが UTF-8 文字列です。UTF-8 はデータサイズが可変長で、1 byte ~ 4 bytes ですべての Unicode Code point を表現します。

符号化単位は Code unit と呼ばれます。UTF-8 の Code unit は 1 byte です。

文字 Code point UTF-8 (16進数) bytes
Z U+00005A 5A 1 byte
U+003042 E3 81 82 3 bytes
😀 U+01F600 F0 9F 98 80 4 bytes
  • UTF-8 は Unicode Code point 1 文字を 1 byte ~ 4 bytes で表現します
    • 5 ~ 6 bytes まで表現できる仕様になっていますが、現在の Unicode では使用していません
  • UTF-8 の 1 文字 (1 byte ~ 4 bytes) = Unicode Code point 1文字

UTF-8 符号化の詳細は以下の Wikipedia 記事を参照してください。

UTF-16

Unicode Code point を 2 bytes (= 16 bits) 単位で符号化したものが UTF-16 文字列です。UTF-16 の Code unit は 2 bytes です。

UTF-16 の Code unit は \uXXXX の形式で \u0000 ~ \uFFFF で表現します。2 bytes の表現であるため必ず 4 桁の表現になります。

1 Code unit = 2 bytes で基本的な文字のほとんどを表現しますが、2 bytes ではすべての Unicode Code point を表現できないため、一部の文字を 2 Code unit = 4 bytes で表現し、この表現をサロゲートペアと呼びます。

文字 Code point UTF-16 bytes 種類
Z U+00005A \u005A 2 bytes 非サロゲートペア
U+003042 \u3042 2 bytes 非サロゲートペア
😀 U+01F600 \uD83D \uDE00 4 bytes サロゲートペア
ハイサロゲート = \uD83D
ローサロゲート = \uDE00

文字の範囲は以下の通りです。

種類 Code point UTF-16
非サロゲートペア U+000000 ~ U+00D7FF
U+00E000 ~ U+00FFFF
\u0000 ~ \uD7FF
\uE000 ~ \uFFFF
ハイサロゲート U+00D800 ~ U+00DBFF \uD800 ~ \uDBFF
ローサロゲート U+00DC00 ~ U+00DFFF \uDC00 ~ \uDFFF

UTF-16 符号化の詳細は以下の Wikipedia 記事を参照してください。

UTF-32

Unicode Code point を 4 bytes (= 32 bits) 単位で、そのまま符号化したものが UTF-32 文字列です。
4 bytes あれば Unicode をそのまますべて表現できるため、Unicode Code point そのものを表しています。
すべての文字が 4 bytes で表現されるため無駄が多く、プログラムを書くにあたって UTF-32 を扱うことはほぼありません。

Unicode escape sequence

Kotlin では文字列中に \uXXXX の形式で UTF-16 で文字を埋め込むことができます。C、Java、Scala、Python、JavaScript など多くの言語が \uXXXX 形式に対応しています。JSON 文字列が Unicode エスケープされる場合もこの形式です。

Kotlin 内容
"\uD83D\uDE00" 😀 (U+01F600)

Swift では文字列中に \u{Code point} (\u{XXXXXX}) の形式で Unicode Code point で文字を埋め込むことができます。

Code point 表現に対応している言語は珍しいですが、JavaScript も \u{Code point} 形式に対応しているようです。

Swift 内容
"\u{1F600}" 😀 (U+01F600)

結合文字列の文字カウント

結合文字列とは、2 つ以上の Unicode 文字を使用して 1 文字を表現する仕組みです。Code point や Code unit を数えても見た目上の文字数とならないのは結合文字列があるためです。

たとえば、Unicode では「ば」は「は」 + 「濁点」で表現することもできます。

Code point 1 文字の「ば」

文字 Code point  Description
U+003070 HIRAGANA LETTER BA

Code point 2 文字の「ば」

文字 Code point  Description
U+00306F HIRAGANA LETTER HA
U+003099 COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK

どちらの「ば」も見た目上は 1 文字です。

絵文字も ZWJ を適用してバリエーションを表現します。

「🧑」

文字 Code point Description
🧑 U+01F901 ADULT

「🧑‍🦰」

文字 Code point Description
🧑 U+01F901 ADULT
ZWJ U+00200D ZERO WIDTH JOINER
🦰 U+01F9B0 EMOJI COMPONENT RED HAIR

「👩‍❤️‍👨」

文字 Code point Description
👩 U+01F469 WOMAN
ZWJ U+00200D ZERO WIDTH JOINER
U+002764 HEAVY BLACK HEART
セレクタ U+00FE0F VARIATION SELECTOR-16
ZWJ U+00200D ZERO WIDTH JOINER
👨 U+01F468 MAN

どの絵文字も見た目上は 1 文字です。

結合文字列について詳細は以下の記事を参照してください。

異体字セレクタ

異体字セレクタは詳細な字体や字形のバリエーションを結合文字列で表すものです。異体字セレクタの種類は SVS と IVS の 2 種類があります。

  • SVS (Standardized Variation Sequence / 標準異体字シーケンス)
    • CJK 互換漢字、数学記号、基本的な絵文字など
  • IVS (Ideographic Variation Sequence / 漢字異体字シーケンス)
    • 漢字の字体や字形選択

たとえば「神󠄀」は以下の通りに表現されます。

文字 Code point UTF-8 表現 UTF-16 表現 Description
U+00795E E7 A5 9E
(3 bytes)
\u795E
(2 bytes)
CJK UNIFIED IDEOGRAPH-795E
セレクタ U+0E0100 F3 A0 84 80
(4 bytes)
\uDB40 \uDD00
(4 bytes)
VARIATION SELECTOR-17

異体字セレクタ結合文字列も絵文字と同様に、見た目上の文字数と Code point、UTF-8、UTF-16 のカウントがいずれも一致しないことがわかります。

書記素クラスタ (Grapheme cluster)

結合文字列によって表現される見た目上の1文字のまとまりを書記素クラスタ (Grapheme Cluster) と呼びます。

Unicode では書記素クラスタの境界を判定するルールが Grapheme Cluster Goundaries に定められています。

Kotlin/Java の BreakIterator は書記素クラスタの境界に従って文字を処理する実装です。

Swift では Character が書記素クラスタによる見た目上の 1 文字を表現します。

書記素クラスタ判定については以下の記事も参考になります。

正規化

「が」(U+00304C:HIRAGANA LETTER GA)と「が」(U+00304B:HIRAGANA LETTER KA + U+003099:COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK)は見た目も意味も同じですが、前者は合成済み文字で後者は結合文字列による表現です。この関係を正準等価(Canonical Equivalence)と表現します。

「カ」(U+00FF76:HALFWIDTH KATAKANA LETTER KA)と「カ」(U+0030AB:KATAKANA LETTER KA)は、意味は同じですが表示が異なります。この関係を互換等価(Compatibility Equivalence)と表現します。

結合文字列の「が」が含まれる文字列に対して合成済み文字「が」を検索しても「が」は含まれていないという結果になってしまいます。

この結合文字列の表現揺れを防ぐために似たような文字を変換する以下のルールが定められています。

  • NFD (正準等価分解, Normalization Form Canonical Decomposition)
    • 結合文字列へ分解: 合成済み文字「が」 -> 結合文字列「が」
  • NFC (正準等価分解後、正準等価合成, Normalization Form Canonical Composition)
    • 結合文字列へ分解したあとに、合成済み文字へ合成する
  • NFKD (互換等価分解, Normalization Form (K)Compatibility Decomposition)
    • 似た意味の結合文字列へ分解
  • NFKC (互換等価分解後、正準等価合成, Normalization Form (K)Compatibility Composition)
    • 似た意味の結合文字列へ分解したあと、合成済み文字へ合成する

NFC を適用することで、見た目が同じ文字を一つの表現に統一することができます。Unicode の表現揺れを防いで表現を統一し、正しく検索できるようにするためには NFC を用いることができます。

NFKD と NFKC は意味や見た目が変化してしまうため、曖昧な文字列の検索をしたい場合など限られた場面で使われます。

Kotlin (Java) では java.text.Normalizer を用いて NFC 化が可能です。

Swift では文字列比較は内部的に NFC で正規化されてから比較されるようです。

Swift で文字列を NFC 変換して取り出すには NSString.precomposedStringWithCanonicalMapping を使用します。

プログラムでの Unicode の扱い

バイトオーダー (UTF-16, UTF-32)

UTF-16 の Code unit は 2 bytes、UTF-32 の Code unit は 4 bytes です。これらのデータをネットワークやファイルで表現するときにはリトルエンディアン (LE) とビッグエンディアン (BE) があります。

たとえば 😀 (U+01F600 = \uD83D \uDE00) は、バイトオーダーを考慮すると以下の配置となります。

形式 バイト配列 説明
UTF-16BE D8 3D DE 00 そのまま配置
UTF-16LE 3D D8 00 DE 2 bytes ごとにリトルエンディアン配置 [ 3D D8 ] [ 00 DE ]
UTF-32BE 00 01 F6 00 そのまま配置
UTF-32LE 00 F6 01 00 4 bytes ごとにリトルエンディアン配置 [ 00 F6 01 00 ]

UTF-8 は 1 byte 単位で処理する形式であるためバイトオーダーはなく、😀 は必ず以下の並び順です。

形式 バイト配列
UTF-8 F0 9F 98 80

Kotlin の String/Char や Swift の Character/Unicode.Scalar として文字を扱う場合には UTF-16 や UTF-32 を 1 byte ずつ処理することはないため、バイトオーダーは意識する必要はありません。

また、ネットワークやファイルでのエンコードは UTF-8 が使われることが多いため UTF-16 や UTF-32 としてデータを読み書きする機会がそもそも稀です。

BOM (UTF-8, UTF-16, UTF-32)

UTF-8, UTF-16, UTF-32 はネットワークやファイルで表現する場合に、BOM (Byte Order Mark) を付けることができます。

\uFEFF をそれぞれの方式でエンコードしたものを BOM としています。それぞれの BOM の並びをまとめると以下の通りとなります。

エンコード名 バイトオーダー BOM バイト配列
UTF-8 - EF BB BF
UTF-16 BE FE FF
UTF-16 LE FF FE
UTF-16BE BE BOM 付加禁止
UTF-16LE LE BOM 付加禁止
UTF-32 BE 00 00 FE FF
UTF-32 LE FF FE 00 00
UTF-32BE BE BOM 付加禁止
UTF-32LE LE BOM 付加禁止

エンコード名に BE/LE が含まれている場合はバイトオーダーが自明であるため、BOM を付加しないルールとなっています。

UTF-8 にはバイトオーダーがないため BOM は本来不要ですが、ShiftJIS など他の文字コードが主流であった時代に UTF-8 のファイルであると判定するために用意されました。

通常は BOM は必要ありません。また、BOM はネットワークやファイルなど外部とのやりとりに必要であれば付けても良いというものです。文字列としてメモリに読み込んだ状態や、データベースに文字列を保存する場合などの文字コードが自明である場合には BOM を付けずに表現します。

Kotlin (Java) での文字列の内部表現

Kotlin での文字列の内部表現は UTF-16 です。

Kotlin (Java) での文字列の入出力

ネットワークやファイルなどでは文字列は UTF-8 で表現することが多いです。

InputStreamReader に Charsets.UTF_8 を指定すると、InputStreamReader が UTF-8 文字列を内部表現である UTF-16 文字列へ変換してくれます。OutputStreamWriter はその逆の変換です。

InputStreamReader.read() は 1 文字を読み取りますが、これは UTF-16 の Code unit 1 文字であるためサロゲートペアの組を一度には読み取れないことに注意してください。

CharsetDecoder を使うと UTF-8 から UTF-16 へ変換することができます。

CharsetDecoder はドキュメントに記載されたとおりのルールに従って UTF-8 文字列を処理する必要があります。たとえば、以下の実装は UTF-8 bytes InputStream から 1 byte ~ 4 bytes を読み取るごとに対応する UTF-16 文字列に変換していく実装です。

InputStreamReader を使うと以下の実装と同じ変換を対応してくれるため、通常は CharsetDecoder による文字コード変換を実装する必要はありません。

val stream: InputStream = ...
val decoder = Charsets.UTF_8.newDecoder()
val byteBuffer = ByteBuffer.allocate(4)
val charBuffer = CharBuffer.allocate(2)
var eof = false
while(!eof) {
    val byte = stream.read()
    if (0 <= byte) {
        byteBuffer.put(byte.toByte())
    } else {
        eof = true
    }
    byteBuffer.flip()
    var result = decoder.decode(byteBuffer, charBuffer, eof)
    if (!result.isUnderflow) {
        result.throwException()
    }
    if (eof) {
        result = decoder.flush(charBuffer)
        if (!result.isUnderflow) {
            result.throwException()
        }
    }
    charBuffer.flip()
    while(charBuffer.hasRemaining()) {
        val c = charBuffer.get()
        println("${c}-[0x${Integer.toHexString(c.code).uppercase()}]")
    }
    if (byteBuffer.hasRemaining()) {
        println("compact")
        byteBuffer.compact()
    } else {
        byteBuffer.clear()
    }
    charBuffer.clear()
}

Kotlin (Java) での BOM の扱い

Kotlin / Java では UTF-16, UTF-32 は BOM あり/なしどちらの入出力も対応しています。

UTF-8 は BOM なし前提として取り扱われます。

  • InputStream / InputStreamReader は BOM をスキップしたりはせず BOM もそのまま \uFEFF として読み込まれます
  • BOM ありの UTF-8 を読み込むときは BOM を読み飛ばす処理が必要があります
  • BOM ありの UTF-8 を書き出すときは、先頭に BOM を明示的に書き出す必要があります
  • Apache Commons Library の BOMInputStream を使うと BOM をスキップして読み込むことが可能です

ただし、BOM ありの UTF-8 入出力をあつかうことは稀です。不特定のファイルを読み込む場合など、特殊な事情がある場合には入力時に先頭の \uFEFF を読み飛ばす処理を書けば事足ります。

Swift での文字列の内部表現

Swift での文字列の内部表現は以下の通りです。

ただし、Swift では文字列を Unicode.Scalar として処理できるように統一されているため内部表現を意識する必要性はあまり高くありません。

対象 内部表現
Swift 5 以降の String UTF-8
Swift 4 以前の String UTF-16
NSString UTF-16

Swift 5 からは処理速度向上のために String の内部表現を UTF-8 へ切り替えたそうです。ネットワークやファイルなどの外部で UTF-8 が使われることが多かったり、アルファベットなどの基本的な文字列では省メモリだったりするため、UTF-8 のほうが今の時代はオーバーヘッドが少なく効率的であるという判断です。

NSString は従来通り UTF-16 です。

その他

API などサーバーサイドとの連携を考えるにあたって、Kotlin / Swift 以外の環境での Unicode の扱いも知っておくと助かります。

MySQL の UTF-8 は utf8mb4

MySQL のデータベースの文字エンコードには utf8 と utf8mb4 があります。utf8 は 3 bytes までの Unicode を扱えます。utf8mb4 は 4 bytes までの Unicode を扱えます。

MySQL は当初は utf8 設定を用意していましたが、これは byte 数が足りずにすべての UTF-8 を表せない不完全な設定でした。MySQL5.5 からは utf8mb4 設定を用いれば UTF-8 をすべて表現できるようです。歴史的経緯から utf8 と utf8mb4 と分かりにくい名前となっていますが、utf8 設定は使わない方がいいでしょう。

  • MySQL utf8: 3 bytes までの UTF-8 文字を扱える
  • MySQL utf8mb4: すべての UTF-8 文字を扱える
    • MySQL 5.5 から対応
  • VARCHAR(N) の N は Code point カウントを指しており、16383 文字が上限です
    • UTF-8 を 4 bytes と仮定し、 16383 * 4 = 65532 (2¹⁶ - 1 に収まる) の計算となります
  • MEDIUMTEXT と LONGTEXT は文字数ではなく bytes 数で上限が決められています。
    • MEDIUMTEXT は 16777215 bytes (2²⁴ − 1) = 16 MB が上限です
    • LONGTEXT は 4294967295 bytes (2³² − 1) = 4 GB が上限です

バックエンドデータベースが MySQL である場合は UTF-8 文字数(Code point カウント)を意識するとよさそうです。
VARCHAR よりも長い文字列は MEDIUMTEXT, LONGTEXT を使うことができます。VARCHAR の最大文字数と MEDIUMTEXT、 LONGTEXT の最大データサイズは以下のドキュメントに記載があります。

また、MySQL は UTF-8 文字列の検索方式には癖があるようです。カラムの検索設定として COLLATE を指定しますが、UTF-8 の絵文字などを正しく扱うためには utf8mb4_bin を指定する必要があります。

詳細は以下の記事を参照してください。

Ruby の文字列は CSI (Code Set Independent) 形式

Ruby は多言語に対応するため、CSI 方式により String ごとに文字コードを持つ実装となっているようです。

ネットワークやファイルなどの外部から受け取った文字列を変換せずに扱える点がメリットですが、扱っている文字列がどのエンコードであるかを常に意識する必要がある点が難しいかもしれません。

  • CSI 方式では String メモリバッファごとに文字コードを持つ
  • Ruby のソースコードは UTF-8, ShiftJIS, EUC-JP など ASCII 文字コード互換のものでのみ記述可能
  • Ruby 1.8 までは String は byte 配列であり、KCODE によって挙動が変わる
  • ruby 1.9 からは CSI 方式となって、String ごとに文字コードを持つようになった
    • ソースコードのデフォルトは ASCII
  • ruby 2.0 からはソースコードのデフォルトが UTF-8 になった

参考

絵文字の文字カウントについて

以下の記事には、文字コード、変換、サロゲートペア、異体字セレクタについてよくまとまっていました。

15
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
2