AppleScript
Swift

Swiftでの文字列比較におけるUnicode正規化を巡る注意点

More than 3 years have passed since last update.

Stringの比較は正規化をかけた上で行われる

Swiftの文字列比較は,Unicode正規化をかけた上で行われます。

たとえば,次の例をご覧ください。

let gaC = "\u{304C}" // 「が」の結合形
let gaD = "\u{304B}\u{3099}" // 「が」の分解形

// NSString としての文字数(UTF16での文字数)は異なる
(gaC as NSString).length // => 1
(gaD as NSString).length // => 2

// String としての比較
gaC == gaD // => true (!!)

これは,こちらのサイトによると,

Depending on your requirements, this may or may not be what you want, but it is certainly consistent with the overall design of the String type to abstract away as many Unicode details as possible. Rule of thumb: if two strings look equal to the user, they will be equal in your code.

つまり,「Unicodeでの実装にかかわらず,ユーザ側からの見た目が同じであるからには,コード上でも同一として扱われるべきである」という原則に基づいているとのことです。

実際,この仕様はAppleのドキュメントにも明記されています。

Two String values (or two Character values) are considered equal if their extended grapheme clusters are canonically equivalent. Extended grapheme clusters are canonically equivalent if they have the same linguistic meaning and appearance, even if they are composed from different Unicode scalars behind the scenes.

CJK互換漢字の正規化問題とIVS

しかし,日本語に関して言うと,Unicode正規化は面倒な問題を引き起こします。CJK互換漢字正規化問題です。

たとえば,次の例をご覧ください。

let god1 = "\u{795E}" // => "神"
let god2 = "\u{FA19}" // => "神"

// 新字体と旧字体の比較
god1 == god2 // => true (!!)

一方,「ユーザ側からの見た目が同じなら同一と扱うべき」と言いますが,IVSによって重複符号化されているために見た目が同一になっている文字は,String としての比較では同一と見なされません。

let god2 = "\u{FA19}" // => "神"
let god3 = "\u{795E}\u{E0100}" // => "神󠄀"

// 見た目が同一の旧字体同士の比較
god2 == god3 // => false

このように,String としての比較では,

  • 見た目が異なる「神(U+795E)」と「神(U+FA19)」は同一視される。
  • 見た目が同じ「神(U+FA19)」と「神󠄀(U+795E U+E0100)」は同一視されない。

と,直観的でない挙動を示すので,注意が必要です。
「見た目が同一なら同一と見なすべき」という原則,そしてその実現手段としてUnicode正規化を施すという手法が,破綻していると言わざるを得ないでしょう。

switch-case文での判定

Swiftのswitch-case文では,Stringオブジェクトも場合分けの判定に使えますが,上記の正規化の結果,次のように判定されますので注意が必要です。

func kindOfGod(str : String) -> String {
    switch str {
    case "\u{795E}": // 神
        return "新字体です"
    case "\u{FA19}": // 神
        return "旧字体です"
    case "\u{795E}\u{E0100}": // 神󠄀
        return "IVSです"
    default:
        return "神ではありません"
    }
}

// U+795E
kindOfGod("神") // => "新字体です"

// U+FA19
kindOfGod("神") // => "新字体です" (!!)

// U+795E U+E0100
kindOfGod("神󠄀") // => "IVSです"

このように,Stringとしての等価性判定は,正規化をかけた上で上から順番に判定されてゆきますので,U+795E と U+FA19 が同一視されます。

当然,

    case "\u{795E}": // 神
    case "\u{FA19}": // 神

の case の順序を逆にすると,U+795E と U+FA19 の両方が U+FA19 の側の case にマッチしてしまうことになります。

識別子の同一性判定では正規化されない

一方,識別子の同一性は,正規化をかけずに判定されます。
その結果,次のコードは,一見同じ定数が複数回宣言されているように見えますが,エラーにはなりません。

let  = "神" // U+795E
let  = "神" // U+FA19
let 神󠄀 = "神󠄀" // U+795E U+E0100

 ==  // (U+795E と U+FA19) => true
 == 神󠄀 // (U+FA19 と U+795E U+E0100) => false

厳密な文字列比較を行うには

NSStringにキャストして比較

正規化をかけず,UTF16のバイト列として文字列比較を行うには,NSString にキャストして比較した上で比較します。

let god1 = "\u{795E}" // => "神"
let god2 = "\u{FA19}" // => "神"

(god1 as NSString) == ("神" as NSString) // => true
(god2 as NSString) == ("神" as NSString) // => true
(god1 as NSString) == (god2 as NSString) // => false

片方だけのキャストでは不十分です。
[追記]片方のみのキャストでも大丈夫です。(※Swift 1.2 では,片方だけのキャストでもUTF16のバイト列として比較されるように変更されたようです。)

let god1 = "\u{795E}" // => "神"
let god2 = "\u{FA19}" // => "神"

(god1 as NSString) == god2 // => false
god1 == (god2 as NSString) // => false

NSString にキャストした上で isEqualToString: を使うという手もあります。

let god1 = "\u{795E}" // => "神"
let god2 = "\u{FA19}" // => "神"

(god1 as NSString).isEqualToString(god2) // => false

UTF16表現を配列にスライスして比較

あるいは,String.UTF16View 型のUTF16表現を取得して,それを配列にスライスしたものを比較するという手もあります。

let god1 = "\u{795E}" // => "神"
let god2 = "\u{FA19}" // => "神"

Array(god1.utf16) == Array("神".utf16) // => true
Array(god2.utf16) == Array("神".utf16) // => true
Array(god1.utf16) == Array(god2.utf16) // => false

String クラスを拡張して utf16Array というプロパティを追加しておくと見やすいかもしれません。

extension String {
    var utf16Array : [UInt16] {
        get {
            return Array(self.utf16)
        }
    }
}

let god1 = "\u{795E}" // => "神"
let god2 = "\u{FA19}" // => "神"

god1.utf16Array == "神".utf16Array // => true
god2.utf16Array == "神".utf16Array // => true
god1.utf16Array == god2.utf16Array // => false

switch-case文での厳密な比較

switch-case文での比較では,switchの方だけ NSString にキャストしておけばよいです。

func kindOfGod(str : String) -> String {
    switch str as NSString {
    case "\u{795E}": // 神
        return "新字体です"
    case "\u{FA19}": // 神
        return "旧字体です"
    case "\u{795E}\u{E0100}": // 神󠄀
        return "IVSです"
    default:
        return "神ではありません"
    }
}

// U+795E
kindOfGod("神") // => "新字体です"

// U+FA19
kindOfGod("神") // => "旧字体です"

// U+795E U+E0100
kindOfGod("神󠄀") // => "IVSです"

Swiftの文字列比較における等価性の種類

Unicode正規化には普通4種類ありますが,Swiftの文字列比較はこのうちNFC/NFDによる正規化をかけた意味での等価性( 正準等価 )となっています。

NFKC/NFKDでの正規化はもっと激しく,たとえば半角英数字と全角英数字,「㍑」のような組文字と「リットル」のような文字がすべて同一視されてしまいます( 互換等価 )。Swiftの文字列比較はそこまで激しくはありません。

let hanA = "A"
let zenA = "A"
let liter1 = "リットル"
let liter2 = "㍑"

// NFKCで正規化すると同一視される
hanA.precomposedStringWithCompatibilityMapping == zenA.precomposedStringWithCompatibilityMapping // => true
liter1.precomposedStringWithCompatibilityMapping == liter2.precomposedStringWithCompatibilityMapping // => true

// Stringとしての比較では同一視されない
hanA == zenA // => false
liter1 == liter2 // => false

AppleScriptの文字列比較はもっと激しい(NFKC_Casefold)

AppleScriptの文字列比較はもっと激しく,NFKC_Casefoldという正規化方式が行われます。
NFKC_Casefoldによる正規化では,全角と半角,組文字などに加え,「大文字的なもの」と「小文字的なもの」も同一視されます。
その結果,しばしば驚くような判定結果をもたらしますので,十分な注意が必要です。

"ABC0123あいうえお" is "abc0123アイウエオ" -- => true (!!)
"㍑㏀" is "りっとるKω" -- => true (!!)

NFKC_Casefoldによる正規化は,他にも,Safariのページ内検索などにおいても用いられています1。NFKC_Casefoldは,ブラウザのページ内検索のように,「ユーザからの入力を緩やかに解釈して同一性を判定する」のが好まれる場面には最適です。ですが,プログラミング言語における文字列比較に用いるには,基準が緩すぎて困る場面が多いと思います。
AppleScriptにおける文字列比較ではこの点に十分注意が必要です。Swiftでも,AppleScriptほど激しくはないものの,正規化した上で文字列比較が行われる以上,この点について意識を向ける必要があるでしょう。

追記:異体字全てが同一視されるわけではない

「異体字を同一視してくれるのなら便利なのでは?」という感想も寄せられましたので,補足しておきます。
NFC/NFDの正規化で同一視される異体字は,あくまでCJK互換漢字CJK統合漢字だけであり,異体字のすべてが同一視されるわけではありません。

たとえば,日本の人名異体字で代表的な「高」と「髙」,「崎」と「﨑」,「邊」と「邉」,「吉」と「𠮷」などは,正規化によって変化しませんので,これらはすべてSwift上でも区別されます。これらの異体字は区別されるのに,「神」と「神」,「社」と「社」は同一視されるというルールは不合理ですし,便利でも分かりやすくもありません。「高」と「髙」を同一視するような正規化はUnicodeには存在しませんので,「異体字の同一視」という目的にUnicode正規化を用いるのは筋違いです。

さらに面倒なことには,MacのファルシステムであるHFS+では,「神」と「神」,「社」と「社」も区別されます。すなわち,Swift上では同一と見なされるこれらの文字は,ファイル名としては区別されるのです。

HFS+との整合性に関する問題は続編で考察します。

続編につづく


  1. 厳密には,AppleScriptの文字列比較や Safariのページ内検索では,NFKC_Casefoldでも同一視されない「ひらがなとカタカナ」も同一視されていますので,これらの文字列一致判定はNFKC_Casefoldよりもさらに緩い,独自の正規化方式を用いていると考えられます。