なぜSwiftの文字列APIは難しいのか

(訳注:記事をご覧の環境によって文字列が正しく表示されない場合がございます。)

投稿が遅れたFriday Q&Aにようこそ。Swiftユーザの最大の不満の一つに、String APIがあります。Swiftの文字列APIは難しく鈍いため、多くのユーザが他言語の文字列APIのようであればと感じているのではないでしょうか。今日はなぜSwiftのString APIがこのように設計されているのか(少なくとも私がなぜそう設計されていると思うのか)を説明します。そして、基本的設計の観点から見て、なぜこれが最高の文字列APIなのかを説明します。

文字列とは何か

説明に入る前に、まず基本的な概念を構築しましょう。文字列について、漠然とは理解しているものの、あまり深くは考えないものなのではないでしょうか。文字列をじっくり考えることで、どのようなことが起きているのか理解することができます。

概念としての文字列とは何なのでしょうか。文字列は簡単に言うとテキストです。”Hello, World“も”/Users/mikeash“と”Robert'); DROP TABLE Students;--“同様、文字列です。

(ちなみに、私は、異なるテキストの概念をたった1つの文字列のタイプにまとめてしまうのは間違っていると思います。人間が読む文章、ファイルパス、SQL命令文など、それぞれ概念が異なります。そのため、言語レベルで異なるタイプのものとして表示されるべきなのです。私は、概念の異なる文字を特徴的なタイプに分類することで、多くのバグを排除できると思います。しかしながら、このようなことを行っている言語も基本的なライブラリも私は知りません。)

では、機械レベルでは”テキスト”の一般的な概念はどのように表示されているのでしょうか。それは表示によって異なります。表示方法は山ほどあります。

多くの言語において文字列は、バイトの配列です。これらバイトが何を意味するのかは、プログラムによるのです。例えば、C++のstd::stringをPython 2やGoなどの多くの言語で使った時の文字列の状態です。

C言語は中でも特異なケースです。Cでは、文字列は非ゼロのバイトのシーケンスを指すポインタで、null終端します。基本的な効果は同じですが、C配列にはゼロバイトを含むことができず、配列の長さなどを見つけるような操作にはメモリのスキャンが必要になります。

最近の他の新しい言語では、配列をUCS-2やUTF-16のコードユニットのシーケンスとして定義しています。例えば、JavaやC#、JavaScript、そしてCocoaとNSStringを使用するObjective-Cもこのように定義しています。これは歴史的な事件から生まれたものです。Unicodeが初めて1991年に登場した時は、純粋な16ビット固定長のシステムでした。人気のある言語のいくつかもこの時期に設計されていて、Unicodeが文字列の基盤として使用されています。そのため、1996年にUnicodeが16ビットモデルから脱却した後に、これら言語の機能に変更を加えるのは難しくなってしまいました。しかし、UTF-16を使用すれば長い数字を2つの16ビットコードユニットに符号化することができるため、基本的な概念を変更することなく、文字列を16ビットコードユニットのシーケンスで表示することができます。

この概念から変異したのが、UTF-8が定義する8ビットコードユニットシーケンスの文字列です。UTF-16に似ていますが、ASCII対応の文字列でコンパクトな表示方法を実現しています。このことにより、UTF-8文字列を受け入れるように、Cスタイルの文字列を期待する関数に文字列を渡す時の変換を回避することができます。

言語によってはUnicodeコードポイントのシーケンスとして文字列を定義しています。Python 3だけでなく、wchar_t型が内蔵された多くのC実装言語が、このように機能します。

簡単に言うと、文字列は何らかの文字のシーケンスとされています。多くの場合、文字はバイトやUTF-16コードユニットであり、あるいはUnicodeコードポイントなのです。

問題点

文字列を特定の”文字”型のシーケンスとして持つのは便利です。多くの場合、文字列を配列として扱う(多くの場合、実際に配列になっています)ため、コードを1行ずつ取得したり、最初や最後の部分だけを切り取ったり、部分的に削除したり、要素を数えたりすることなどが簡単にできます。

問題は、Unicodeの世界でプログラミングしているため、Unicodeがいろいろと複雑にしてしまうところです。では、次の例で、文字列がどのように機能するか見てみましょう。

    aé∞𝄞

Unicodeコードポイントには数字(U+ nnnnと表記される)が含まれ、人間が読める名前(何らかの理由からすべて大文字のローマ字)が付けられているため、個別のコードポイントについて説明しやすくなっています。文字列は次のようになっています。

  • U+0061 LATIN SMALL LETTER A (ローマ字小文字A)
  • U+0065 LATIN SMALL LETTER E (ローマ字小文字E)
  • U+0301 COMBINING ACUTE ACCENT (アキュートアクセントを付ける)
  • U+221E INFINITY (無限大)
  • U+1D11E MUSICAL SYMBOL G CLEF (ト音記号)

では、文字列から”文字”を取り外し、”文字”をUTF-8バイトやUTF-16コードユニット、またはUnicodeコードポイントとして扱いましょう。

まず、UTF-8から始めます。UTF-8では、文字列は次のようになります。

    61 65 cc 81 e2 88 9e f0 9d 84 9e
    -- -- ----- -------- -----------
    a  e    ´      ∞          𝄞 

では、3番目のバイトとなる3つ目の”文字”を取り外してみると、次のようになります。

    61 65 81 e2 88 9e f0 9d 84 9e 

これは、UTF-8の文字列として有効ではありません。UTF-8バイトには3つのパターンがあります。先頭のビットがゼロに設定された0xxxxxxxと表示されるバイトは、普通のASCII文字を表した独立型のものです。11xxxxxxと表示されるバイトは、マルチバイトのシーケンスの始まりを表し、長さは最初のゼロビットの位置が示しています。ccで始まるバイトは、合計2バイトの長さのマルチバイトのシーケンスの始まりを形成し、81でシーケンスは終わります。ccを外してしまうと、81は独立した状態になってしまうため、有効なUTF-8リーダーにははじかれてしまいます。このような問題は、3番目以降のバイトを取ってしまうと起こります。

では2バイト目を削除するとどうなるでしょうか。結果は次のようになります。

    61 cc 81 e2 88 9e f0 9d 84 9e 
    -- ----- -------- ----------- 
    a    ´      ∞          𝄞

これなら、まだUTF-8で有効です。しかし予想していた結果とは異なります。

    á∞𝄞

人間にとって、この文字列の”2文字目”は”é”です。しかし、2バイト目は、アクセント記号の付かない単純な”e”という文字になります。アクセント記号は”結合文字”として別に加えられます。つまり、文字列の2バイト目を削除すると”e”だけを削除することとなり、結合文字のアクセント記号は代わりに”a”に付くこととなります。

では、1バイト目を削除するとどうなるでしょうか。次のように予想通りの結果を得られます。

    65 cc 81 e2 88 9e f0 9d 84 9e
    -- ----- -------- -----------
    e    ´      ∞          𝄞

それではUTF-16について考えていきましょう。以下はUTF-16の場合の文字列です。

    0061 0065 0301 221e d834 dd1e
    ---- ---- ---- ---- ---------
     a    e    ´    ∞       𝄞

2つ目の”文字”を削除してみましょう。

    0061 0301 221e d834 dd1e
    ---- ---- ---- ---------
     a    ´    ∞       𝄞

すると、上で説明したUTF-8の場合と同じ問題が発生します。”e”だけが削除されてアクセント記号が残るため、代わりに”a”にアクセント記号が付いてしまいます。

では5文字目を削除するとどうなるでしょうか。次のようなシーケンスが得られます。

   0061 0065 0301 221e dd1e

上で説明した無効なUTF-8と同じような問題が発生します。このシーケンスは、もうUTF-16で有効ではありません。d834 dd1eというシーケンスはサロゲートペアを作っています。これは2つの16ビットで構成されるユニットのことで、16ビットの制限を超えたコードポイントを表すために使われます。このサロゲートペアの片方だけを残しても無効になってしまうのです。このような場合、通常UTF-8のコードでは完全に拒否されますが、UTF-16ではもう少し柔軟であることが多いです。例えばCocoaでは、結果として文字列を以下のように表示します。

    aé∞�

では、文字列がUnicodeのコードポイントのシーケンスだと、どうなるでしょうか。結果は次のようになります。

    00061 00065 00301 0221E 1D11E
    ----- ----- ----- ----- -----
      a     e     ´     ∞     𝄞

この場合、どの”文字”を消しても文字列の有効性を保つことが可能です。しかし結合文字のアクセント記号に関する問題はまだ残っています。2文字目を削除すると次のようになります。

    00061 00301 0221E 1D11E
    ----- ----- ----- -----
      a     ´     ∞     𝄞

この方法でも、直感的に正しくない結果を避けることはできません。

これらは人為的に処理した場合の懸念事項でもあります。英語は純粋なASCIIで書ける数少ない言語の1つです。そのため、”résumé(履歴書)”を”resume”と書いたまま、求人に応募しようと思わない限り問題がついてまわります。ASCIIで表示できる範囲を超えた瞬間に、このようなおかしな表示が現われ始めるのです。

書記素クラスタ

Unicodeには書記素クラスタという概念が存在します。これは原則的に、人間が”文字”と認識する最小ユニットを指します。多くのコードポイントにとって、書記素クラスタは単一のコードポイントと同じことを意味します。しかし、結合文字のアクセント記号などを含むように拡張することもできます。例として使用している文字列を書記素クラスタで分割すると、とても理にかなった結果が得られます。

a é ∞ 𝄞 

ここから書記素クラスタをどれか1つ削除すると、直感的に筋が通っていると思えるような結果を得ることができます。

例にしている文字列には、数値に相当する文字を含めていないことに注意してください。UTF-8やUTF-16、または単純なUnicodeのコードポイントと異なり、通常、書記素クラスタでは単一の数字を表示することができません。書記素クラスタは1つ以上のコードポイントのシーケンスなのです。単一の書記素クラスタは、1つか2つのコードポイントであることが多いのですが、Zalgoのようにたくさんのコードポイントを含むことも可能です。例として次の文字列を考えてみましょう。

    e⃝⃞⃟⃠⃣⃤⃥⃦⃪⃧꙰꙲꙱

このめちゃくちゃな文字列は、14のコードポイントで構成されています。

  • U+0065
  • U+20DD
  • U+20DE
  • U+20DF
  • U+20E0
  • U+20E3
  • U+20E4
  • U+20E5
  • U+20E6
  • U+20E7
  • U+20EA
  • U+A670
  • U+A672
  • U+A671

この全てのコードポイントで1つの書記素クラスタを作ります。

ここで、興味深い例を挙げましょう。スイスの国旗を含む文字列について考えてみます。

    🇨🇭

この1つの記号は、実際にはU+1F1E8U+1F1EDという2つのコードポイントでできています。これらのコードポイントは一体何なのでしょうか。

  • U+1F1E8はREGIONAL INDICATOR SYMBOL LETTERS(地域識別用の記号文字)のCを表す。
  • U+1F1EDはREGIONAL INDICATOR SYMBOL LETTERS(地域識別用の記号文字)のHを表す。

世界中の全ての国旗にそれぞれ別のコードポイントを割り当てるのではなく、Unicodeではたった26の”REGIONAL INDICATOR SYMBOL”が存在するだけです。そして、識別用の記号であるCとHを組み合わせることでスイスの国旗を表示できるのです。また、MとXを組み合わせればメキシコの国旗となります。それぞれの国旗が1つの書記素クラスタとなりますが、コードポイントは2つとなり、UTF-16のコードユニットは4つ、UTF-8のバイト数は8つとなります。

実装への影響

文字列にはたくさんの異なる見方があり、”文字”と呼べるものにも数多くの種類があるということを説明してきました。書記素クラスタとしての”文字”は、人間が”文字”として考えるものに最も近いです。しかし、コード内の文字列を扱う際に使う定義はコンテキストに左右されるでしょう。矢印キーに応じて挿入位置を動かす場合は、おそらく書記素クラスタを使いたくなるはずです。ツイートの140文字制限を守るために文字列の長さを知りたい場合はUnicodeのコードポイントが便利です。80文字制限のデータベースのテーブル列に文字列を詰め込みたい場合はUTF-8のバイトを使うことになるでしょう。

文字列の実装をプログラミングするときや、性能、メモリの消費量、きれいなコードなどの相反する要求のバランスを取るときは、どの表示方法を選択したらいいのでしょうか。

一般的な答えは、「単一の標準的な表示方法を選び、他の表示方法が必要な場合のために変換できるようにしておく」ということです。例えば、NSStringは標準的な表示方法としてUTF-16を使います。API全体がUTF-16を中心に構築されます。UTF-8やUnicodeのコードポイントを使いたい場合は、UTF-8やUTF-32に変換すれば、扱うことができます。これらは文字列ではなく、データオブジェクトとして提供されるため、使いやすくありません。書記素クラスタを使いたい場合はrangeOfComposedCharacterSequencesForRange:を使って、その境界を見つけることができますが、何か面白いことをするには膨大な作業が必要となります。

Swiftの文字列型は異なった手法をとります。標準的な表示方法はなく、代わりに多岐に渡る文字列の表示方法によるビューを提供します。これにより、扱っているタスクに最も適切な表示方法を使うことが可能になります。

手短なSwiftの文字列API

Swiftの旧バージョンでは、StringCollectionTypeと一致し、それ自体でCharacterコレクションを表していました。しかしSwift 2では、もはやこの通りではありません。Stringは大部分がそこにアクセスする最適な方法として様々なビューを表します。

また完全に正しいとは言えませんが、Stringは、いまだにCharacterにある程度有利であり、ちょっとしたコレクションのようなインターフェースを表しています。

public typealias Index = String.CharacterView.Index 
    public var startIndex: Index { get } 
    public var endIndex: Index { get } 
    public subscript (i: Index) -> Character { get } 

個々のCharactersを取得するために、Stringにインデックスを付けることができますが、それだけです。留意すべきは、標準のfor inシンタックスで反復処理を使えないことです。

ではSwiftの観点でいう”文字”とは何でしょうか? これまで見てきた通り、多くの可能性があります。Swiftは”文字”の考え方を書記素クラスタに置いています。上述してきたように、文字列で”文字”として人間が考えるものによく当てはまることから、一見すると、適した選択のように見えます。

さまざまなビューはString上のプロパティであることが分かります。例えば、charactersプロパティは以下のようになります。

  public var characters: String.CharacterView { get }

CharacterViewCharacters:のコレクションになります。

  extension String.CharacterView : CollectionType { 
        public struct Index ... 
        public var startIndex: String.CharacterView.Index { get } 
        public var endIndex: String.CharacterView.Index { get } 
        public subscript (i: String.CharacterView.Index) -> Character { get } 
    }

これはString自体のインターフェースとよく似ています。異なる点は、これはCollectionTypeと一致し、それが提供する切り出しや反復、マッピングやカウントといった全ての機能を持つことです。そのため、以下は機能しません。

for x in "abc" {}

一方、以下は正しく機能します。

for x in "abc".characters {}

イニシャライザを使って、CharacterViewから文字列を得ることができます。

    public init(_ characters: String.CharacterView)

また、任意のCharacterのシーケンスから文字列を得ることもできます。

    public init(_ characters: String.CharacterView)

続いて階層を掘り進めていくと、次はUTF-32ビューになります。UTF-32コードユニットがUnicode コードポイントと完全に一致することから、SwiftはUTF-32コードのユニットを”Unicodeスカラ値”と呼んでいます。

    public var unicodeScalars: String.UnicodeScalarView 

    public struct UnicodeScalarView : CollectionType, _Reflectable, CustomStringConvertible, CustomDebugStringConvertible { 
        public struct Index ... 
        public var startIndex: String.UnicodeScalarView.Index { get } 
        public var endIndex: String.UnicodeScalarView.Index { get } 
        public subscript (position: String.UnicodeScalarView.Index) -> UnicodeScalar { get } 
    }

CharacterViewのように、UnicodeScalarViewのためのStringイニシャライザがあります。

    public init(_ unicodeScalars: String.UnicodeScalarView)

残念ながら任意のUnicodeScalarsシーケンスのためのイニシャライザがないので、もし、配列を改変したり、文字列に戻したりなどしたい場合は、ひと手間加えることが必要になってきます。任意のシーケンスUnicodeScalarsを取るUnicodeScalarViewのためのイニシャライザさえありません。しかし変更可能なappend関数があるので、3つの段階を踏んでStringを構築することができます。

    var unicodeScalarsView = String.UnicodeScalarView() 
    unicodeScalarsView.appendContentsOf(unicodeScalarsArray) 
    let unicodeScalarsString = String(unicodeScalarsView)

次はUTF-16ビューです。他とよく似ています。

    public var utf16: String.UTF16View { get } 

    public struct UTF16View : CollectionType { 
        public struct Index ... 
        public var startIndex: String.UTF16View.Index { get } 
        public var endIndex: String.UTF16View.Index { get } 
        public subscript (i: String.UTF16View.Index) -> CodeUnit { get }     }

このビューのためのStringイニシャライザには微妙な差異があります。

    public init?(_ utf16: String.UTF16View)

他とは異なり、failableイニシャライザです。どんなCharactersシーケンスやUnicodeScalarsシーケンスも有効なStringです。しかし有効な文字列を形作らないUTF-16コードユニットのシーケンスを持つことも可能です。このイニシャライザは表示された内容が有効ではないとき、nilを生成します。

任意のUTF-16コードユニットのシーケンスからStringに戻るのは、とても分かりづらいです。UTF16Viewはpublicイニシャライザを持たず、変更可能な機能をわずかばかり持つだけです。解決策としてはグローバル関数transcodeを使うことで、UnicodeCodecTypeプロトコルで機能させます。このプロトコルはUTF8、UTF16、UTF32と3つの実装があります。transcode関数は、UTF8、UTF16、 UTF32の間でStringを変換する際に使用される関数ですが、いささか乱暴なやり方です。入力では、入力値を出すGeneratorTypeを取り、出力では、それぞれのユニットの出力値を呼び出す関数を取ります。UTF32へ変換して、各UTF32UnicodeScalarに変換しStringに付け加えることで、1つずつ文字列を構築するのに使われます。

    var utf16String = "" 
    transcode(UTF16.self, UTF32.self, utf16Array.generate(), { utf16String.append(UnicodeScalar($0)) }, stopOnError: true)

ついにUTF-8ビューまでたどり着きました。これまで見てきたことからも、予想通りの結果でしょう。

    var utf16String = ""
    transcode(UTF16.self, UTF32.self, utf16Array.generate(), { utf16String.append(UnicodeScalar($0)) }, stopOnError: true)

これでイニシャライザをfailableイニシャライザすることができます。UTF16Viewのときのように、UTF-8コードユニットのシーケンスは有効ではないので、イニシャライザはfailableです。

    public init?(_ utf8: String.UTF8View)

以前のように、UTF-8コードユニットの任意のシーケンスをStringへ変換する便利な方法がありません。変換機能はここでも使うことができます。

    var utf8String = "" 
    transcode(UTF8.self, UTF32.self, utf8Array.generate(), { utf8String.append(UnicodeScalar($0)) }, stopOnError: true)

このようにtranscodeで呼び出すのは、とても骨が折れます。そこで、ちょうどいいペアのfailableイニシャライズでラップします。

    extension String { 
        init?<Seq: SequenceType where Seq.Generator.Element == UInt16>(utf16: Seq) { 
            self.init()

             guard transcode(UTF16.self,
                             UTF32.self,
                             utf16.generate(),
                             { self.append(UnicodeScalar($0)) },
                             stopOnError: true)
                             == false else { return nil }
         }

          init?<Seq: SequenceType where Seq.Generator.Element == UInt8>(utf8: Seq) {             self.init()

              guard transcode(UTF8.self,
                             UTF32.self,
                             utf8.generate(),
                             { self.append(UnicodeScalar($0)) },
                             stopOnError: true)
                             == false else { return nil }
         }
     } 

これでUTF-16かUTF-8の任意のシーケンスから、Stringを作り出すことができるようになりました。

インデックス

様々なビューは全てインデックス可能なコレクションですが、配列とは異なります。インデックスタイプは奇妙なカスタムのstructです。つまり、ビューを数字でインデックスすることはできないということです。

// all errors
    string[2]
    string.characters[2]
    string.unicodeScalars[2]
    string.utf16[2]
    string.utf8[2]

代わりに、コレクションのstartIndexendIndexから始め、移動にはsuccessor()あるいはadvanceBy()といったメソッドを使わねばなりません。

// these work
    string[string.startIndex.advancedBy(2)]
    string.characters[string.characters.startIndex.advancedBy(2)]
    string.unicodeScalars[string.unicodeScalars.startIndex.advancedBy(2)]
    string.utf16[string.utf16.startIndex.advancedBy(2)]
    string.utf8[string.utf8.startIndex.advancedBy(2)]

これは困りました。何が起こっているのでしょうか?

これらは全て、文字列オブジェクト内に正準形式で格納されている、同一の基本データのビューであることを思い出してください。正準形式に適合しないビューを使う場合は、データのアクセスには変換が必要です。

上述のとおり、これら数種類のエンコーディングのサイズと長さは様々です。それは、ある1つのビューのあるロケーションを別のビューのロケーションに割り当てる簡単な方法はないということを意味します。マッピングは基本データに依存するからです。例を使って説明します。

AƎ工🄞

Stringの正準表現がUTF-32だとしましょう。その表示は32ビット整数の配列になります。

0x00041 0x0018e 0x05de5 0x1f11e

次に、このデータのUTF-8のビューを取得すると考えてみましょう。概念として、データは8ビット整数のシーケンスです。

0x41 0xc6 0x8e 0xe5 0xb7 0xa5 0xf0 0x9f 0x84 0x9e

このシーケンスを元のUTF-32に割り当てし直すと、以下のようになります。

| 0x00041 |  0x0018e  |     0x05de5    |       0x1f11e       |
    |         |           |                |                     |
    |  0x41   | 0xc6 0x8e | 0xe5 0xb7 0xa5 | 0xf0 0x9f 0x84 0x9e |

UTF-8のビューにインデックス6の値を要求すると、ビューはその値がどこにあり何を含んでいるか把握するため、UTF-32の配列の先頭からスキャンしなければなりません。

当然、この作業は可能です。Swiftは、シンプルではないものの、そのために必須の関数、string.utf8[string.utf8.startIndex.advancedBy(6)]を備えています。なぜもっと簡潔に、整数でインデックスできるようにしないのでしょうか。これは、Swift的に、そのオペレーションにはコストがかかるという事実を補強しているのです。UTF8Viewsubscript(Int)を提供するのが当然の世界なら、これら2つのコードは同等だろうと私たちは考えます。

for c in string.utf8 {
        ...
    }

    for i in 0..<string.utf8.count {
        let c = string.utf8[i]
        ...
    }

2つは同じように機能しますが、2つ目は極端に遅くなるでしょう。初めのループは問題のないリニアスキャンですが、次のループは各反復処理においてリニアスキャンを行うので、ループ全体に2乗のランタイムを要します。100万字の文字列のスキャンにかかる時間が10分の1秒か3時間かの違いです(私の2013 MacBook Proで行った場合の概算です)。

別の例を見てみましょう。単に文字列の最後の文字を取得します。

 let lastCharacter = string.characters[string.characters.endIndex.predecessor()]

 let lastCharacter = string.characters[string.characters.count - 1]

1つ目のバージョンは速いです。文字列の末尾から先頭に瞬時にさかのぼってスキャンし、最後のCharacterの始まりを特定して、それを取得します。2つ目のバージョンは文字列全体をスキャンします… それも2度です。文字列にCharacterがいくつ含まれているかを数えるため全体をスキャンしてから、特定の数値インデックスの場所を探すために再度スキャンします。

このようなAPIでできることは、SwiftのAPIでなお可能です。ただ、別のものであり、やや困難であるだけです。これらの違いによって、プログラマは、以上のビューが配列ではなく、配列のように実行されるわけでもないと知るのです。サブスクリプトのインデックス化を見ると、当然、インデックス化のオペレーションは速いものと考えます。Stringのビューが整数サブスクリプティングに対応している場合は、その仮定は崩れ、非常に遅いコードになってしまいます。

Stringでコードを書く

ここまで見てきたことを考慮すると、実用目的でStringを使ってコードを書く場合はどのようにするのがよいでしょうか。

可能な限り最高レベルのAPIを使うことです。例えば、文字列がある文字から始まっていることを確認する必要がある場合、最初の文字を探し、比較するのに文字列にインデックスしないことです。hasPrefixメソッドを使えば、詳細もカバーされます。FoundationのインポートとNSStringメソッドの使用を躊躇しないでください。例を挙げると、Stringの始まりと終わりの空白を削除したい場合、手動で反復、文字検索を行わず、stringByTrimmingCharactersInSetを使うのです。

文字レベルの操作を自身で行わなければならない時は、その特定のケースにおいて”文字”が明確に何を意味するのかをよく考慮してください。その解は、SwiftのCharacter型とcharactersビューで表される書記素クラスタだった、ということは往々にしてあります。

テキストを扱う場合は、先頭または末尾からのリニアスキャンの点からも検討してください。文字数のカウントや検索のようなオペレーションは、いずれにしてもリニアタイムスキャンになる可能性が高いので、明示的にそれを前提としたコードを作成したほうが良いでしょう。適切なビューから先頭あるいは末尾のインデックスを取得し、advancedBy()と類似の関数を利用して、そのインデックスに必要な操作を行いましょう。

ランダムアクセスが本当に必須である場合や、効率性の低下を鑑みたうえでより単純なコンテナの利便性を求める場合は、ビューを、そのビューが内包する1つのArrayに変換することが可能です。例えば、Array(string.characters)は、その文字列内の書記素クラスタの配列を生成します。この表示方法は決して効率的ではなく、いくらか余分にメモリを消費しますが、作業はかなり簡単になります。完了したら、再度Stringに変換します。

まとめ

SwiftのStringタイプは、一般とは異なる方法で文字列にアプローチします。他の多くの言語では、文字列に対して1つの正準表現をとっており、他のものを使いたければ何とかできる余地が残されています。それらの言語は、単に「正確には、文字とは何か?」という重要な問いを突き詰めず、コード内で作業しやすい方法を取っていますが、それは文字列のプロセスにおいて固有に遭遇する困難な問題を覆い隠します。Swiftはそのような覆い隠しをせず、代わりに起きている現実をはっきりと表示します。難しいかもしれませんが、無駄に難しいのではなく、筋は通っているのです。

StringAPIには弱点があるのも確かなので、少しでも問題を減らすために特別な機能を使うことも考えてもよいでしょう。特に、UTF-8やUTF-16からStringに変換するのは非常に困難かつわずらわしいものです。コード単位の任意のシーケンスからUTF8ViewUTF16Viewを初期化するファシリティと、これらのビューを直接操作できるよう、自ら変更を加える使いやすい関数があると便利です。

今日はここまでです。次回もますます多くのトリックとやっかいごとを用意したいと思います。Friday Q&Aは読者のアイデアで作られていますので、何はともあれ取り上げてほしいことをメールでリクエストしてください