Swift & Objective-C

[Swift基礎 #6] なぜ文字列では text[0] が使えないのか

Swift Stringが整数インデックスを許可しない理由を、拡張書記素クラスタから説明します。ハングルの組み合わせ文字列におけるCharacter・Unicode scalar・UTF-8の違いを実行結果で確認します。

読了 6 分
[Swift基礎 #6] なぜ文字列では text[0] が使えないのかのカバー画像

別の言語からSwiftに移った開発者が最も戸惑う点の一つが文字列です。text[0]は使えず、text[2..<5]も使えず、別のインデックス型を作ってtext.index(text.startIndex, offsetBy: 2)と書く必要があります。Pythonなら1文字で済む処理です。

SwiftチームがAPIを作れなかったからではありません。むしろ逆です。「文字列のn番目の文字」という概念自体が単純ではないことを正直に示しています。他の言語は複雑さを隠し、ときに誤った答えを返す道を選びました。Swiftは不便でも常に正しい答えを返す道を選びました。Swift基礎シリーズ第6回にして最終回、文字列が本当に難しい理由を掘り下げます。

文字列を数値へ変換する際の失敗する流れは、エラーモデルとともにSwiftのthrows・try・Resultの選び方で扱います。

「何文字ですか?」が難しい理由 — Unicodeの世界

出発点はこの問いです。「café」は何文字でしょうか。4文字に見えますが、コンピューター上では2通りあります。éは1つの完成形コードポイント(U+00E9)でも、e(U+0065)と結合アクセント(U+0301)の2つでも保存できます。見た目は同じでも、内部のコードポイント数は1と2で異なります。

ハングルはこの問題をさらに実感させます。韓国語の1音節は、完成形の音節コードポイント1つ(U+AC01)にも、3つのジャモ字母の組み合わせにもなります。macOSのファイル名を扱ったことがあれば、NFD(Normalization Form D — 文字をジャモ単位に分解して保存するUnicode正規化形式)で保存された韓国語ファイル名が、別のシステムでジャモに分解されて見えた経験があるかもしれません。同じ「文字」がコードポイント1つにも3つにもなる世界です。

ここに絵文字が加わります。家族の絵文字👨‍👩‍👧‍👦は、4つの人物絵文字をゼロ幅接合子(ZWJ、Zero Width Joiner — 絵文字を1文字のようにまとめる不可視文字)でつないだもので、コードポイントは7つです。UTF-16コードユニットでは11個です。しかし人の目には1文字です。

そこでUnicode標準には「人が認識する1文字」に対応する単位が定義されています。拡張書記素クラスタ(extended grapheme cluster)です。éも、合成された韓国語音節も、家族の絵文字も、それぞれ1つの書記素クラスタです。

言語ごとの選択 — 間違った答えが速いのか、正しい答えが遅いのか

各言語が「n番目の文字」をどう処理するか比較できます。

JavaScriptの"👨‍👩‍👧‍👦".lengthは11です。UTF-16コードユニット数を数えるからです。Javaも同じです。Python 3のlenは7を返します。コードポイント数を数えるためです。どれも「1文字」という人間の直感とは一致しません。絵文字を含む文字列を単純に切ると、絵文字の途中で分割され壊れた文字になることがあります。

Swiftの"👨‍👩‍👧‍👦".countは1です。SwiftのCharacter型はコードポイントではなく書記素クラスタを表すためです。éの保存方法に関係なくcountは同じで、==も正規化を考慮してtrueになります。「人が見る文字」を基準に、常に正しい答えを返します。

ただし、この正確さにはコストがあります。書記素クラスタは可変長なので、n番目の文字を探すには先頭から境界を1つずつ判定して数える必要があります。だからSwiftの文字列には整数インデックスがありません。text[7]がO(1)に見えるAPIを提供すると、実際にはO(n)のコストを隠すことになるからです。ループ内でtext[i]を使うとO(n²)になりますが、Swiftはそれを促さない設計を選びました。不透明なString.Index型は、「数えて見つけた位置」であることを型で示します。哲学シリーズ第1回で扱った「コストを隠さない」という原則は、文字列にも当てはまります。

同じ文字列でも言語ごとに測定結果が異なります
同じ文字列でも言語ごとに測定結果が異なります

実務ツールボックス — インデックスなしで文字列を扱う

原理が分かったところで実践です。整数インデックスがなくても、安全なツールで大半の作業を解決できます。

前後の切り出しにはprefixとsuffixを使います。 text.prefix(10)は先頭10文字(書記素基準)を安全に返し、範囲を超えてもクラッシュせず存在する分だけ返します。プレビュー用テキストでsubstringのインデックス計算は不要です。

**検索にはrange(of:)とfirstIndex(of:)を使います。**位置が必要な場合、その多くは「特定内容の位置」です。返されたRangeやIndexをそのままサブスクリプトに渡せるため、整数変換は不要です。

分割にはsplitを使います。 text.split(separator: ",")が手動のインデックス走査に代わります。

**本当にn番目が必要ならArrayを使います。**文字単位のアルゴリズム問題などでランダムアクセスが必要なら、Array(text)へ変換するのが定石です。変換コストO(n)を一度払えば、以降のアクセスはすべてO(1)です。ループごとにindex(offsetBy:)を呼ぶよりはるかに効率的です。

**バイトが必要ならビューを明示します。**ネットワーク転送量やDBカラム制限など、「文字数」ではなく「バイト数」が必要な場面があります。Swiftではtext.utf8.counttext.utf16.counttext.unicodeScalars.countによって数える単位をコード上で明示できます。なお、UserDefaultsや古いAPIで使うNSStringのlengthはUTF-16基準で、Swiftのcountと異なる場合があります。

もう一つの罠です。異なる文字列のインデックスは互換性がありません。aから得たString.Indexをbに使うと、クラッシュしたり意図しない結果になったりします。インデックスは「その文字列のその時点」専用です。文字列を変更したら、以前のインデックスも無効だと考えるのが安全です。

整数インデックスがなくても文字列作業の大半は解決できます
整数インデックスがなくても文字列作業の大半は解決できます

韓国語開発者にとって特に重要な理由

韓国語サービスを作っていると、この知識が観念ではなく実務になる瞬間があります。

代表例はニックネームの文字数制限です。「10文字以内」を検証するとき、サーバー(別言語)とクライアント(Swift)が異なる単位で数えると、クライアントを通過したニックネームがサーバーで拒否されるバグになります。サーバーがUTF-8バイト数で制限するなら、韓国語は1文字3バイトなので10文字は30バイトです。「何を数えるか」をチームで合意するのが先で、Swiftではcount(書記素)かutf8.count(バイト)を意図的に選びます。

字母分解の問題もあります。前述のNFDにより、外部システムから来た韓国語文字列は見た目が同じでもコードポイントが異なる場合があります。Swiftの==は正規化を考慮するため通常は動きますが、外部文字列をハッシュベースの辞書キーにしたり別言語へ渡したりする場合は、precomposedStringWithCanonicalMappingでNFC(Normalization Form C — 字母を完成形の文字に結合して保存する正規化形式)へ明示的に正規化すると安全です。

検索やオートコンプリートで初声検索などを実装するときは、逆に書記素を字母単位へ分解する必要があり、unicodeScalarsビューが出発点になります。ビューの概念を理解すれば、これは「特別なライブラリが必要な処理」ではなく「別のビューを走査する処理」と整理できます。

実行して確認した結果

Apple Swift 6.3.3で、完成形のと3つの字母で作った合成形の한を比較しました。合成形文字列のUnicode scalarは3つですが、String.countは1文字と数え、2つの表現は正規等価性の比較で同一でした。

string=characters:1,scalars:3,canonically-equal:true

この結果を確認してから、文字数制限を実装するときは変数名に単位を示しています。characterLimitならcount、転送サイズならutf8.countを使い、サーバー契約にも同じ単位を記載します。単位を省略したlengthだけで合意すると、韓国語や絵文字でクライアントとサーバーが簡単にずれます。

まとめ

  • 「n番目の文字」が難しいのは、Unicodeの1文字(書記素クラスタ)が可変数のコードポイントで構成されるためです。é、合成された韓国語、ZWJ絵文字が代表例です。
  • 他の言語のlengthはコードユニットやコードポイントを数え、人間の直感とずれることがあります。Swiftのcountは書記素を数え、常に人間基準の答えを返します。
  • その代償としてランダムアクセスはO(n)になり、Swiftはこのコストを隠さないため整数インデックスをなくしました。
  • 実務ではprefix/suffix、range(of:)、splitで大半を解決し、ランダムアクセスにはArray変換、バイト計算にはutf8ビューを明示的に使います。
  • 韓国語サービスでは、文字数検証の単位を合意し、NFC/NFDを正規化することが実務上のポイントです。

これで基礎シリーズ全6回が終わります。次回からは中級シリーズです。最初のテーマはSwiftメモリ管理の核心、ARC(Automatic Reference Counting、自動参照カウント)とweak・unownedの選び方です。クロージャ編で予告した循環参照の本編になります。

あわせて読みたい

出典と確認基準

  • Swift StringApple Developer Documentation · 公式ドキュメント · 確認日 2026年8月26日根拠: StringのCharacterコレクションモデル、Unicodeの正規等価性、文字列ビューとインデックスAPI
  • The Swift Programming Language: Strings and CharactersSwift.org · 公式ドキュメント · 確認日 2026年8月26日根拠: 拡張書記素クラスタ、ハングルの合成表現、countと整数インデックスのコスト