ガラパゴスのコードヒーヨアン(twitter: @luinily)です。
社内iOS勉強会の発表準備のため、Swift 4でStringの変更の説明を調べているうちに、そもそも現在のSwiftのStringはどうなっているのか、正確に把握していなかったことに気づいて、発表内容を現在のSwiftのStringの説明の方に変えました。
今回はその発表の内容を説明しようと思います。
Swiftの文字列はUnicodeの考え方に従っていますので、Swiftの文字列を理解するには、Unicodeを理解せねばなりません。
Unicode
アメリカで最初文字をパソコンで処理をしようとしたときは、まだメモリーも少なかったし、英語の使う文字だけで十分だったので1文字は1バイトで表現していました。そのあと、表現したい文字数が増えたり、国際化が進んで、各言語の文字を扱うためのエンコードができたりしていました。大学生の時、フランス語のWindows XPで日本語表示のインストールなどをした覚えがあります。ネットになると、複数のエンコードがありうるので、サイトによって全ての文字が化けて、表示に使うエンコードを変えたりしていました。
こういった問題を解決するため、すべての文字を表現できるエンコード、Unicode、を作成することになりました。なぜUnicodeができるまでの歴史を簡単に説明したかというと、その歴史がUnicodeに影響を与えているからです。
Unicodeはどういうふうに文字をエンコードしているのか
Unicodeの文字はビットから文字まで複数の様子で定義されています:
Character
Unicode Scalar
Code Unit
一つの文字は一つか複数のUnicode Scalarで定義されています。一つのUnicode Scalarは一つか複数のCode Unitで定義されています。
Code Unit
Code Unitはファイルなどに文字列を書き出すときに書かれる値です。複数のサイズが可能:8ビット(UTF-8)、16ビット(UTF-16)、32ビット(UTF-32)など。 複数のサイズが存在するのは、いろんな構築をしたシステムに対応できるためのと、英文など最初の8ビットで表現できていた文字列を使うと、1個の8ビットのCode Unitでかける文字がほとんど、メモリーの節約にもなります。
Unicode Scalar
Unicode Scalarは「文字」と「文字の変化様子」の2種類に分けられて、16進で表現される番号をふられている。例えば、日本語だと「す」という文字のUnicode Scalarは「3059」、「゙」でのUnicode Scalarは「3099」。二つ合わせれば、「ず」ができます。 つのUnicode ScalarをCode Unitで出力する場合、UTF-8では文字によって1つから4つのCode Unitが必要になります。
文字
一つの文字は一つか複数のUnicode Scalarの組み合わせでできています。Unicode Scalarの数には特に上限がありません。先の例で「す」と「゙」を組み合わせると「ず」になります。
同じ文字で複数の書き方が可能
一つの文字をUnicodeでエンコードする場合、複数のやり方が存在することがあります。 例えば、さっきの「ず」は二つの Unicode Scalarを使ってできましたが、「ず」という文字にも一つのUnicode Scalarが定義されていますので、 Unicodeでは二つのUnicode Scalarの組み合わせで書けます:
305A
3059 + 3099
Swift
SwistのStringはこの考え方を反映しています。 Stringのプロパティーを見ると四つのビューが存在しています: String.characters: CharacterView String.unicodeScalars: UnicodeScalarView String.utf16: UTF16View String.utf8: UTF8View
見覚えありますよね?Unicodeの文字の定義を見たときにでた様子です!
CharacterViewはUnicodeの文字のコレクション
unicodeScalarsはUnicode Scalarのコレクション
utf16とutf8はCode Unitの16ビットのコレクションと8ビットのコレクション
Unicode Scalarの入出力
Stringの文字をUnicode Scalarから定義したいときは文字列の中に「\u{番号}」というふうに書きます。 例として「Pokémon」の「é」を使いましょう。「é」は「ず」と同じく、Unicode Scalarで二つの定義方法があります:「00E9」 (é)と「0065+0301」(e + ́)。Swiftで書くと下記のようになります。
文字列から、Unicode Scalarを出力したいときは、String.unicodeScalarsにあるアイテムのvalueを取ります:
char.valueをそのまま使うと、10進で表示されますので、16進でStringとして表します。
UTF-8とUTF-16の出力
UTF-8とUTF-16の出力はString.utf8、String.utf16プロパティーを使います。
お分かりになったと思いますが、8ビットで表現できるUnicode ScalarはUTF-8やUTF-16で一つのCode Unitになります、16ビット表現されるUnicode ScalarはUTF-8で二つのCode Unitになり、UTF-16で一つのCode Unitになります。
従来のStringとの違い
Stringという型は、従来の言語でただの文字の配列です。Androidでよく使われているJavaのStringと比べて見ましょう。
JavaのStringはどういうものかというとStringのUTF-16のCode Unitの配列になります。 SwiftとAndroidのStringの文字数を比べて見ましょう。 例として、先ほどのPokémonを使います。まずは「é」が一つのUnicode Scalarで定義されている場合:
Swift:
Java:
この場合は同じですね。面白くなるのは「é」を二つのUnicode Scalarで定義した時:
Swift:
Java:
Swiftは7つのままですが、Javaの方は8つになりました!
今回Unicode Scalarが8つあって、Javaのcountが8つになりました。JavaはUnicode Scalarの配列なのか、確認したいですね。そのために32ビットのUnicode Scalarを使って見ます。
どういうことかというと、JavaのStringは文字の配列ではなく、Unicode Scalarの配列ですので、今回は「e」と「 ́」が一つずつ数えられる。Swiftの方はUnicode Scalarではなく、文字を数えてるので、「é」の両方の定義でも数が変わりません。 SwiftでUnicode Scalarを数えたいときは、unicodeSclarsを使います:
SwiftでUnicode Scalarが一つなのに、utf16の方で二つになります。Javaの方はどう?
Javaも2になりますが、もうちょっと詳しく見ると「\u10000」の最後の「0」の色が違う、Unicode Scalarのコードの一部として認識されるのではなく、「0」という文字として認識されて、「𐀀」が「က0」として認識されてしまった。
結論として、SwiftのStringはUnicodeの文字の配列で、JavaのStringはStringのUTF-16のCode Unitの配列。 ただ、SwiftのStringは本当に配列なのか?配列だったら、Intをインデックスとして、文字にアクセスできますね?テストして見ると、コンパイルが通らない、SwiftのStringはIntをインデックスとして使える配列ではない。
なぜそうなったかというと、もともと配列は中に入れるアイテムのサイズが全部同じという前提で定義されていて、Intを使ってアクセスすとき、メモリーアドレスは単純に「配列のアドレス+インデックスxエレメントのサイズ」で計算されています。ただし、SwiftのStringが使っているUnicodeの文字のサイズはバラバラです。Unicode Scalarによってサイズも違いますし、複数のUnicode Scalarを使った文字もあります。Stringのいくつ目の文字のアドレスを計算するには、単純な掛け算ではなく、一文字目から一個ずと文字のサイズを足して計算します。
その原因でSwiftのStringはただの配列ではないし、Intのインデックスから文字の場所の計算のコストが高いので、Collection扱いもSwift 2でなくなりました。
Pokémonの場合、「é」以外の文字は1バイトですが、「é」は書き方のよって2バイトか3バイトが必要です。
インデックスの扱い
SwiftでStringの文字にアクセスするには他の言語と違ってIntのインデックスが使えないことがわかりましたので、Swiftでどういうインデックスが使えるか紹介します。
Swiftではビューによって専用のインデックスタイプが用意されています:
String.characters: String.CharacterView.Index
String.unicodeScalars: String.UnicodeScalarView.Index
String.utf16: String.Utf16View.Index
String.utf8: String.Utf8View.Index
各インデックスの使い方は同じ:
startIndex:一つ目の文字のインデックス 例:myString.characters.startIndex
endIndex:最後の文字のインデックス+1 例:myString.unicodeScalars.endIndex
index(i: Index, offsetBy: Int):一つのインデックスから別のインデックスの計算。 例:myString.utf16.index(myString.utf16.startIndex, offsetBy: 2)
index(after: Index):次のインデックス
index(of: T):文字、UnicodeScalarなどのインデックス
ビューの間のインデックスの変換が可能です:String.CharacterView.Index(String.UnicodeScalarView.Index, in: String)、String.Utf16View.Index(String.Utf8View.Index, in: String)
Stringに直接インデックスも使えますが、String.CharacterViewのインデックスです。
StringのビューはCollectionになっているため、ループができますが、パーフォマンスの問題がそのまま残っているため、使うときに注意したほうがいいです。
サブストリング
Stringからサブストリングを取るときにSwiftは単純にその文字をメモリーの別のところにコピーしているわけではなく、新しいStringは元のStringへのポインターとサブストリングのインデックスを持っています。そうすることによって、サブストリングで処理を行うときに、Stringはコピーされないので、処理が速い。ただし、サブストリングが残ってる限り、元のStringを消しても、メモリーに残ってしまいます。長い文字列から一つの単語をサブストリングにしてから、元の長いStringを消してもメモリー上、全部残ってしまいます。Swiftのサブストリングの処理はメモリーより速さを優先している。
SwiftのStringの不思議なところ
文字列がUnicode Scalarの変化様子字で始めれば、たのStringと合わせる場合、文字がまーじされることがあります。
絵文字の場合、想定外の文字数になることがあります:
複数の国旗絵文字を並べても文字数が1になります
四人の絵文字の文字数は・・4
これでSwiftのStringはちょっとわかりやすくなりましたかな?中を見てみると意外と面白いですね。
SwiftのStringについて調べたときに役に立った記事: