SwiftのStringの == 演算子とUnicodeの関係


SwiftのStringはEquatableプロトコルに準拠しています。したがって、以下のような比較ができます。

let string1 = "abc"
let string2 = "bcb"

string1 == string2 // => false

こうみると単純ですが、他の言語と比べて変わった点があります。
例としてRubyと比べてみましょう。

Swift
let pokemon1 = "Poke\u{0301}mon" // Pokémon
let pokemon2 = "Pok\u{00e9}mon" // Pokémon

pokemon1 == pokemon2 // true
Ruby
pokemon1 = "Poke\u{0301}mon" # Pokémon
pokemon2 = "Pok\u{00e9}mon" # Pokémon

pokemon1 == pokemon2 # false

SwiftではtrueなのにRubyではfalseになっています。
この記事ではなぜこのような違いが生まれるのか、詳細に解説します。

その前に é について少し解説

let pokemon1 = "Poke\u{0301}mon"
let pokemon2 = "Pok\u{00e9}mon"

の違いは という文字の表しかたです。
pokemon1 ではアルファベットのe と アキュート・アクセント´を合成して という一つの文字を作っています。0301´ の符号位置です。
pokemon2 ではそれ単体で é を表す文字で é を表しています。00e9はその文字の符号位置です。

Unicodeの等価とSwiftの等価

先ほどのSwiftのコードでは == で比較した結果がtrueになっていました。
つまり、Swiftの考え方では "Poke\u{0301}mon""Pok\u{00e9}mon" は等価だということですね。

結論から言うと、SwiftのStringの等価性の評価はUnicodeの正準等価(英語だとCannonical Equivalent)という定義に従っています1。Unicodeでは「等価と見なせるもの」を正準等価と呼びます。 2
実は、pokemon1pokemon2 のそれぞれの文字は正準等価です。だからこそ == で比較するとtrueになるわけです。

Unicodeの正準等価

SwiftのStringの == は正準等価か否かを判断していることは分かりました。それでは正準等価の定義やその性質について確認していきましょう。そうすることで == が何をやっているか深く理解できるはずです。

同じ文字でも複数の表現方法がある

まず前提として、Unicodeには同じ文字でも複数の表現方法があります。以下に例を示します。

  • Ç = C + ◌̧
    • 「Ç」単体は、「C」と「 ¸ 」を合成したものと等しい
  • é = e + ◌́
    • 「é」単体は、「か」と「 ´ 」を合成したものと等しい
  • = か + ◌゙
    • 「が」単体は、「か」と「 ゛」を合成したものと等しい

上記以外にも組み合わせは多数あります。

同じ文字でも複数の表現方法があるのは歴史的経緯や他の文字コードとの互換性のためですが、ソフトウェアを使っているユーザーにとっては同じものとして表示したいです。たとえば、 を含む文字列に対して か + ◌゙ で文字列を検索してもヒットして欲しいですよね。
この「同じもの」(つまり等価)を規格としてきちんと定義したものが正準等価です。

正準等価の定義

二つの文字の並びを最後まで 正準分解(Canonical Decomposition) した結果が等しければそれらは互いに正準等価です。 3

正準分解とは正規化のひとつで以下のようなアルゴリズムです(公式な定義はドキュメントのD68を参照してください )。

  • データベースに従ってそれぞれの文字を分解する。

  • 分解した文字がさらに分解可能なら再起的に分解する。

  • Canonical Ordering Algorithmにしたがって順番を揃える。(アルゴリズムについてはドキュメントの 3.11を参照してください)

この定義をもとに例を見てみます。

例1

(3071) と は + ◌゚ (306F + 309A)は正準等価か

データベースによると「ぱ」は(306F + 309A)に分解可能です 4
◌゜ もこれ以上分解できず、順番も変わらないので分解は以上です。
二つの分解結果が一致するので、(3071) と は + ◌゚ (306F + 309A)と正準等価です。

例2

(1F89) と Α + ◌ͺ + ◌̔ (0391 + 0345 + 0314)は正準等価か

データベースによるとは以下のように分解できます。

1F86 -> 1F09 + 0345
1F09は、さらに0391 + 0314に分解することができます。したがって、

1F89 -> 0391 + 0314 + 0345 に分解されます。
また、0391 + 0345 + 0314 は Canonical Ordering Algorithmによって順番が変わり、0391 + 0314 + 0345 になります。

これ以上はどちらも分解できず、順番も変わりません。その結果、両者は一致するので正準等価です。


分かりづらそうなので表にしました。二つの表の一番下の行が一致することに着目してください。

注記: ᾉはギリシャ語の文字らしいです。

Swiftで正準分解してみる

NSStringのdecomposedStringWithCanonicalMappingというメソッドを使います。

// NSStringのメソッドなのでFoundationのインポートが必要です。
import Foundation

let greekLetter = "ᾉ"

let letters = greekLetter.decomposedStringWithCanonicalMapping.unicodeScalars.map { scalar in
    String(format: "%04x", scalar.value)
}
print(letters) // ["0391", "0314", "0345"]

Swiftで他の言語風の比較をする

最初の例で示したRubyのやっているような比較(つまり符号単位での比較)をしたければ以下のようにします。

let pokemon1 = "Poke\u{0301}mon" // Pokémon
let pokemon2 = "Pok\u{00e9}mon" // Pokémon

pokemon1.utf8.elementsEqual(pokemon2.utf8) // false

pokemon1.utf16.elementsEqual(pokemon2.utf16) でも良いですが、Swift 5.0以降はStringの内部表現はUTF-8になっているので.utf8で比較する方がパフォーマンスが良いです。


凡例

この記事のサンプルコードは以下の環境で試しました。

  • macOS Catalina 10.15.1
  • Swift 5.1.2
  • Ruby 2.6.5

参考

この記事を書くにあたり以下の資料を参考にしました。

SwiftのStringのcountについて

記事を読んでいただきありがとうございます!
iOSDC Japan 2019で「SwiftのStringの文字の数え方を完全理解する」というタイトルで30分トークしました。
この記事に大いに関連する内容になっているので、もしタイトルに興味をひかれたらそちらもチェックしてください。

この記事では文字とは抽象文字(Abstract Character)という意味で、iOSDCでのトークでは拡張書記素クラスタ(Extended Grapheme Cluster)という意味で使っています。
(Unicodeの専門知識レベルの話で、Swiftのコードを書くのには気にはならないはずです。)

最後に

間違いがないように気をつけて調べて書きましたが、もしかしたら間違いがあるかもしれません。もしあればご指摘よろしくお願いいたします


  1. https://developer.apple.com/documentation/swift/string#overview に Comparing strings for equality using the equal-to operator (==) or a relational operator (like < or >=) is always performed using Unicode canonical representation とあるとおり、ドキュメントに明記されています。 

  2. 似た定義に互換等価(Compatibility Equivalent)というものがあるのですが、SwiftのStringにはあまり関わりはありません。互換等価は正準等価より広い概念で、たとえばメートルは互換等価ですが、正準等価ではありません。 

  3. 英語による定義 "Two character sequences are said to be canonical equivalents if their full canonical decompositions are identical." を筆者が訳しました。full はどうやって訳すか迷ったのですが「最後まで」としました。「再起的に」と訳しても良かったかもしれません。 

  4. データベースを「3071;」で検索してヒットする行の6列目を見てください。