Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

はじめに

この記事は、第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」が参考になると思います。  

takasek
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away