「文字数」ってなぁに?〜String, NSString, Unicodeの基本〜

  • 74
    いいね
  • 6
    コメント

はじめに

この記事は、第5回スタートアップiOS勉強会 - connpassでの同名の発表を文章形式でリライトしたものです。
スライド版(Speaker Deck)はこちら

StringNSString

「文字列の特定の単語に色をつけて、textViewに表示したい」

…という、ヒトなら誰しもが持つ欲望をさらけ出してください。
そう言われたあなたは、以下のようなコードを書くかもしれません。

func coloredText(from str: String, target: String) -> NSAttributedString {
    // 対象単語の出現範囲( Range<String.Index> )を取得
    let range: Range<String.Index> = str.range(of: target)!

    // Range<String.Index> を NSRange に変換
    let nsRange = NSRange(location: str.distance(from: str.startIndex,
                                                 to: range.lowerBound),
                          length: target.characters.count
    )

    // 対象単語に色をつけて返す
    let result = NSMutableAttributedString(string: str)
    result.addAttributes(
        [NSForegroundColorAttributeName: UIColor.red],
        range: nsRange
    )
    return result
}

この関数に "日本チャチャチャ!""チャチャチャ" を与えれば、

no_emoji.png

このように、「チャチャチャ」だけが赤く色づきました。
一見、うまく動いているコードに見えます。

しかし、これで安心してしまったあなたは、実は見えない罠に嵌まってます。
試しに、 "日本チャチャチャ!" に絵文字 "🇯🇵" を付け加えてみましょう。

UITextView.png

「チャチャチャ」ではない部分が赤くなった上に、文字化けまで起こっています。
一体、何故こんなことが起きたのでしょうか。

それは、StringとNSStringの違いを理解せずにコードを書いてしまったからです。

String と NSString 何が違う?

struct と class?
Swift と Obj-C?

いいえ。
それ以上に重要な違いがあります。

NSString は、

  • 内部的にはUTF-16でバイト列を保持
  • UTF-16のバイト列を操作するためのAPIを提供

というデータ構造です。
それに対してSwiftの String は、

  • 内部のバイト列は隠蔽
  • 文字列操作のために、書記素クラスタおよび各種UnicodeのコードユニットのViewを提供している

というデータ構造です。
おわかりでしょうか。

…はい、よくわかりませんね。View? 書記素クラスタ?? 各種Unicodeのコードユニット??? 🤔

これらの宇宙語を理解するためには、「そもそもUnicodeとは何か」を紐解く必要があります。

Unicodeとは

コード空間とコードポイント

https://ja.wikipedia.org/wiki/Unicode

符号化文字集合や文字符号化方式などを定めた、文字コードの業界規格である。
文字集合が単一の大規模文字セットであること(「Uni」という名はそれに由来する)などが特徴である。

Unicode規格は、21bitの整数値空間(コード空間)を提供しています。これは概念的な空間です。実際のバイトデータとは切り離して考えてください。
世界中のすべての文字は、この空間内のユニークな整数値へと割り当てられます(符号化)。
そして、割り当てられた値を、コードポイントと呼びます。

コードポイントは U+(16進数) で表現されます。

  • BMP(基本多言語面) = U+0000U+FFFF
  • SMP(追加面) = U+10000

具体的には、こんな感じ👇ですね。

2016-11-13 20.06.39.png

エンコーディング

21bitのコード空間を、どうやって実際のバイト列として表現するか?

Unicodeの「コード空間」「コードポイント」という概念については理解できました。
しかし、実際にこれをバイト列として表現するにあたっては、いくつか方法が考えられます。
この方法を エンコーディング と呼びます。
同じコードポイントでも、バイト列表現にはバリエーションがあるのです。

1

各エンコーディングの違いは、 「特定のコードポイントを表現するための最小単位を何bitにするか」の違いです。
そして、最小単位を コードユニット と呼びます。
UTF-8 UTF-16 UTF-32…という、この数字は、コードユニットが何bitになるかを示すものです。

コードポイントとコードユニットという単語、紛らわしいけど、よく覚えておいてくださいね。
コードポイントってのは、概念的なidみたいなもの。
コードユニットは、そのid自体がデカいので小分けにして詰め込むための、箱のサイズです。

それでは各エンコーディングを、詳しく見てみましょう。

UTF-32

image

  • 1コードユニット = 32bit(4バイト)
  • 1コードポイント = 4バイトの固定長

UTF-32のコードユニットは、32bit。コード空間(21bit)より巨大です。そのため完全固定長で表現できる!というのがUTF-32の強みです。
たとえ1世紀後に宇宙文明とコンタクトして、収録しなきゃいけない文字が今の1000倍になったとしても、耐えきれるポテンシャルの持ち主です。
その分、データとしては無駄が大きいんですけどね。 abc と書くだけで12バイト。ヤバいですね。

UTF-16

image

  • 1コードユニット = 16bit(2バイト)
  • 1コードポイント = 2バイト or 4バイトの可変長

実は、Unicode 1.0.0の頃は、このUTF-16の範囲内に世界中の文字が収まる想定でした。かつてコード空間は16bitだったのです。
しかし、想定外の事件が次々に起こり、コード空間は21bitに拡大を余儀なくされました。結果、UTF-16はサロゲートペアという苦し紛れの策を取ったのですが、それについては省略。
我々プログラマが憂慮すべきは、その頃生まれたプログラム言語が「Unicodeの最終形はUTF-16だ」と思い込んで設計されていることです。
Objective-Cも、例外ではありません。

UTF-8

image

  • 1コードユニット = 8bit(1バイト)
  • 1コードポイント = 1バイト〜4バイトの可変長

今回のトピック絡みではあまり説明することのないエンコーディング形式です。おじいちゃん(ASCIIコード)とも意気投合できる、良い奴ですよ。

抽象文字、書記素クラスタ

ここまで読まれたあなたは、「コードポイントって用語、分かりづらいな」と思われているかもしれません。
「要は『一文字』のことでしょ」と理解しているかもしれませんね。
けど、実は違うんです。

「一文字」は、Unicode界隈では抽象文字(abstract character)と呼ばれるそうです。
言い換えると、「カーソルキーの移動単位」と考えるといいかもしれません。

で。抽象文字と符号化文字は 多対多 の関係なのです。

さらに、コードポイントにはそれ単体では成立せず、組み合わせて使うものもあります。
組み合わされて成立した抽象文字を 書記素クラスタ (grapheme cluster) といいます。

Unicodeとは? その歴史と進化、開発者向け基礎知識より

余談:グリフ(glyph)

グリフ(glyph)というまた別の概念もありまして、

  • 文字の見た目(位置、サイズ)の情報
    • 文字は必ず左から右に流れるわけじゃないでしょ、ってこと
  • CoreTextで管理される

…のだけど、今回は省略。

ここまでのまとめ

Unicode自体の話は、これでおしまいです。
だいぶ用語が増えて混乱してきた頃合いでしょうから、一旦まとめましょう。

  • 抽象文字の作りかた
    • コード空間(21bit)内にコードポイントがあるので、
    • 割り当てられた符号化文字 u0041.png , u030A.png を組み合わせて
    • 抽象文字 / 書記素クラスタ 合字.png を作る
  • エンコーディングとは
    • 特定の コードポイント (最大21bit)
    • 規定サイズの コードユニット (8bit / 16bit / 32bit) に詰め込む方法

Unicode と Swift.String のAPI

ここまで、Unicodeとは何か、どのように文字が表現され、そこにエンコーディングがどう関わるかを説明してきました。
ここからは、Unicodeがどのように Swift.String のAPIで表現されているかを説明します。2

Swift.String は、書記素クラスタおよび各種UnicodeのコードユニットのViewを提供している、と述べました。

  • var characters: String.CharacterView
  • var unicodeScalars: String.UnicodeScalarView
  • var utf16: String.UTF16View
  • var utf8: String.UTF8View

Stringは、これらの4つのViewを通して操作することができます。

※UTF-32はUnicodeのコード空間を内包するので、 UnicodeScalarView という名前

4つのViewを使ってみよう

👆の図に登場する文字列 "\u{41}\u{3A9}\u{8A9E}\u{10384}" を各Viewで表現したとき、そのcountはいくつになるのか確かめてみましょう。

let str = "\u{41}\u{3A9}\u{8A9E}\u{10384}"

str.characters.count //4 👈書記素クラスタ
str.unicodeScalars.count // 4 👈UTF-32でのコードユニット数
str.utf16.count // 5 👈UTF-16でのコードユニット数
str.utf8.count // 10 👈UTF-8でのコードユニット数

図と出力結果の辻褄、合っていますね!

String.CharacterView は 強い

書記素クラスタは、人間の考える「一文字」だと先程述べました。
つまり、「一文字」をカウントするのは String.CharacterView であると言えます。
このイケメンは、Unicodeの複雑な仕様を吸収してくれます。合字もいい感じに処理してくれます。

var cafe = "Cafe" // "Cafe"
cafe.characters.last // "e"
cafe.characters.count // 4

cafe += "\u{301}" // "Café"
cafe.characters.last // "é"
cafe.characters.count // 4

うーん強い。

String.CharacterView は 常に最強ではない

…ただ、物によってはCharacterViewをもってしても「1文字」を認識できないこともあります。

"👩‍👩‍👧‍👦".characters.count // 4

何故でしょうか。
Unicodeとは? その歴史と進化、開発者向け基礎知識 - Build Insiderから引用します。

ただし、これらのルールや新しい文字は、Unicodeのバージョンアップに伴って随時追加されているわけで、実際にこれらが1文字として描画されるかどうかは環境依存である。OSがそのバージョンによって異なるのはもちろん、フォントによっても1文字で表示できるかどうかが変わる。結合文字などは古くからあり対応しているものも多いが、絵文字に関しては最近の仕様なのもあってかなりばらつきがある。

どれくらいバージョンアップがあるのかは、Unicode - Wikipedia #各バージョンとその特徴で確認できます。
結構頻繁ですね。

ということは、わかりますか?
「文字数に従って云々してくれ」というアプリの仕様が提示されたときには、心して挑まなければなりませんよ、ということです。


(追記 12/6)
ちなみに "👩‍👩‍👧‍👦" の正体については、丁度タイムリーに解説記事が上がっていました。

家族👨‍👩‍👦‍👦はreplaceされてしまうのか?あるいはZWJの話😂 - Qiita
http://qiita.com/todokr/items/8b813e14d3fdb4111cb7


いずれにせよ、SwiftのString APIはよく考えられています。
UTF-16が前提であるNSStringよりも、もっと本質的に「文字」を扱うことができるのが、 Swift.String のAPIであるといえます。

というわけで。
みなさん、もうNSStringのことはもう忘れて、今後は Swift.String と楽しく過ごして下さい💮

…。
…ってわけには、いかないんですよね。

Foundationに根を下ろし、NSStringと共に生きよう

UITextView.png

そもそもの話の発端は、 NSAttributedStringUITextView でした。
どんなに良く設計されたValueTypeを作っても、Cocoaから離れては生きられないのです。
現実を受け入れた上で、ではどう生きるかを考えましょう。

NSStringは UTF-16

もう一度、バグを生んでいるコードを確認しましょう。

func coloredText(from str: String, target: String) -> NSAttributedString {
    // 対象単語の出現範囲( Range<String.Index> )を取得
    let range: Range<String.Index> = str.range(of: target)!

    // Range<String.Index> を NSRange に変換
    let nsRange = NSRange(location: str.distance(from: str.startIndex, to: range.lowerBound),
                          length: target.characters.count
    )

    // 対象単語に色をつけて返す
    let result = NSMutableAttributedString(string: str)
    result.addAttributes(
        [NSForegroundColorAttributeName: UIColor.red],
        range: nsRange
    )
    return result
}

Range<String.Index> を NSRange に変換 している部分が、なんだか臭いますね。
"🇯🇵" に対する String.charactersNSString でカウントの方式の違いを確かめてみましょう。

let flag = "\u{1F1EF}\u{1F1F5}" // "🇯🇵"

flag.characters.count // 1 👈書記素クラスタは 1
flag.unicodeScalars.count // 2 👈コードポイント2個
flag.utf16.count // 4 👈2コードポイント × 2コードユニット
flag.utf8.count // 8 👈2コードポイント × 4コードユニット

(flag as NSString).length // 4 👈utf16.countと一致

NSStringの内部表現はUTF-16です。納得の結果ですね。

じゃあ、rangeを取得する際には、 String.characters のかわりに String.utf16 を使えばいい?
いや、取得した範囲のRangeを構成する String.IndexInt に変換するのは手間ですよね?
そういうところでミスを犯したくないですよね?3

いえいえ、もっとシンプルで分かりやすい解決策があるのです。

NSStringを使わなきゃいけないなら、最初からもう、Cocoaの世界で処理を閉じたほうが良い

つまり、

let range = str.range(of: target)!

// Range<String.Index> を NSRange に変換
let nsRange = NSRange(
    location: str.distance(from: str.startIndex, to: range.lowerBound),
    length: target.characters.count
)

👆こう書くのではなく、
👇こう書けばいいのです。

let nsStr = NSString(string: str)
// 一度NSStringの世界に入れば…
let nsRange = nsStr.range(of: target)
// Range<String.Index>を介さないので、変換処理不要

cocoaの世界.png

範囲ズレも文字化けも解決し、コードも分かりやすくなりました :tada:

今日の結論。

  • StringNSString は別物
    • Range<String.Index>NSRange も別物
    • Cocoa世界が関わる文字列操作は、必ず NSString のAPIを通しましょう
  • あと、仕様に「文字数をカウントして云々」が出てきたら身構えましょう

以上、みなさん気をつけていきましょうね。

参考リンク

Unicode のサロゲートペアとは何か - ひだまりソケットは壊れない
http://vividcode.hatenablog.com/entry/unicode/surrogate-pair

なぜSwiftの文字列APIは難しいのか | プログラミング | POSTD
http://postd.cc/why-is-swifts-string-api-so-hard/

Unicodeとは? その歴史と進化、開発者向け基礎知識 - Build Insider
http://www.buildinsider.net/language/csharpunicode/01

Unicodeと、C#での文字列の扱い - Build Insider
http://www.buildinsider.net/language/csharpunicode/02

Swift 3のStringのViewに対して、Intでsubscript出来ない理由 – Swift・iOSコラム – Medium
https://medium.com/swift-column/swift-string-7147f3f496b1#.x1n3vrh1p


  1. http://www.unicode.org/versions/Unicode6.2.0/ch02.pdf より 

  2. 正直、公式リファレンス( https://developer.apple.com/reference/swift/string )がとても分かりやすいのでそっち読むといいです。 

  3. あえてそういうデザインになっている理由を考えるにあたっては、「Swift 3のStringのViewに対して、Intでsubscript出来ない理由 – Swift・iOSコラム – Medium」が参考になると思います。  

この投稿は iOS Advent Calendar 20164日目の記事です。