SwiftでのUnicode正規化問題 続編:HFS+との整合性

  • 64
    いいね
  • 1
    コメント
この記事は最終更新日から1年以上が経過しています。

前回の記事の続編です。

HFS+ における Modified NFD

Apple が OS X でファイルシステムとして採用しているHFS+では,ファイル名を原則としてNFDで分解して保持するようになっています。

2種類の「が」は分解形で統一される

たとえば,ユーザが が.txt(「が」はU+304Cの1文字)というファイル名でファイルを保存しても,ファイルシステム上は が.txt(「が」は U+304B U+3099 の合成文字)として保存されます。

実際,が.txt(「が」はU+304Cの1文字)としてファイルを保存した後,Finderでファイル名変更モードに入り,「が」という文字をコピーすると,U+304C ではなく,U+304B U+3099 という2文字がコピーされるのが確認できます。

Finder.png → か(U+304B) + 結合用濁点(U+3099) がコピーされる

CJK互換漢字を置き換えない Modified NFD

この正規化形式は,一見すると普通のNFDのようです。ところが,単純にNFDで分解してしまうと,CJK互換漢字が化けてしまう問題が生じます。
すなわち,「神.txt」を保存しても「神.txt」に化けてしまうことになり,実用上不都合が生じます。

そこでAppleは,HFS+での正規化においては,Unicodeの標準的な正規化仕様に従わず,CJK互換漢字など正規化で化けてしまう文字を除外した,独自の正規化を行っています。これは,(正式名称ではないものの,NAOIさんの提唱に従い)Modified NFDと呼ばれています。
Modified NFDの仕様については,ものかのさんの解説に非常にわかりやすくまとめられています。

このModified NFDは,CJK互換漢字は置き換えられず,かつ2種類の「が」は統一されるので,我々日本人としては都合のよい正規化方式です。ですが,AppleがこれをUnicode技術委員会に提案した際には否決されてしまったそうです。その結果,このModified NFDは,今に至るまで標準化されていないApple独自の正規化形式となってしまっています。

Swiftの文字列正規化との整合性

一方,Swiftの文字列比較時に内部的に発動する正規化は,CJK互換漢字も置き換えてしまう通常のNFDまたはNFC(どちらであるかは挙動からは分かりません)です。
ファイルシステムではModified NFDを使いつつ,プログラミング言語側では暗黙のうちにNFD/NFCを使っているとなると,その整合性は大丈夫かと心配になります。

ですが,

  • SwiftのStringのNFC/NFD正規化は,文字列同士を比較するタイミングでのみ発動し,それ以外では発動しない。
  • ファイルの存在判定などにおける正規化はファイルシステム側がModified NFDで行う。

という仕様になっているようなので,一応整合性はとれています。
以下,コードで確認してみます。

// テキストファイルの内容を読み込んで表示する関数
func printFile(filename : String) {
    println(NSString(contentsOfFile: filename, encoding: NSUTF8StringEncoding, error: nil)!)
}

let gaC = "\u{304C}.txt" // 結合形の「が」
let gaD = "\u{304B}\u{3099}.txt" // 分解形の「が」
let  = "神.txt" // U+795E
let  = "神.txt" // U+FA19

let manager = NSFileManager.defaultManager()

if manager.fileExistsAtPath(gaC) {
    println("結合形でヒットしました")
    printFile(gaC) // が.txt の内容を表示
}
if manager.fileExistsAtPath(gaD) {
    println("分解形でヒットしました")
    printFile(gaD) // が.txt の内容を表示
}

 ==  // => true; 文字列としては同一と判定される

if manager.fileExistsAtPath() {
    println("神は存在します")
    printFile() // 神.txt の内容を表示
} else {
    println("神は存在しません")
}

if manager.fileExistsAtPath() {
    println("神は存在します")
    printFile() // 神.txt の内容を表示
} else {
    println("神は存在しません")
}

このコードを実行すると,ファイルシステムがどちらの「が」でアクセスされても分解形に統一して解釈してくれるので,ファイルシステム上に が.txt が存在する場合,fileExistsAtPath(gaC)fileExistsAtPath(gaD) の両方が真として判定されます。また,同一のファイル が.txt を読み出してくれます。

一方,後半の 神.txt神.txt においては,Swift上は文字列として同一と判定されるものの,ファイルの存在判定や内容の読み込みにおいては,別個に扱われます。
たとえば,ファイルシステム上に 神.txt は存在せず 神.txt が存在する場合,上記コードを実行すると

神は存在しません
神は存在します

と表示されます。

よって,HFS+へのアクセスにおいては,とりあえずプログラマが期待する結果を得ることができていると言えるでしょう。

潜在的なバグの温床となりうるか?

しかし,文字列比較において暗黙のうちに正規化が発動する現象は,潜在的にバグを引き起こす要因になるかもしれません。

たとえば,くだらない例かもしれませんが,次のコード。

let adminName = "神"

// テキストファイルの内容を読み込んで表示する関数
func printFile(filename : String) {
    println(NSString(contentsOfFile: filename, encoding: NSUTF8StringEncoding, error: nil)!)
}

func login(userName : String) {
    if userName == adminName {
        println("あなたは管理者ユーザです")
        printFile(adminName + ".txt")
    } else {
        println("あなたは一般ユーザです")
        printFile(userName + ".txt")
    }
}

login("神")

"神" でログインして 神.txt を読み出すつもりが,ファイルシステム上の別ファイルである 神.txt が読み込まれてしまいます。
これは怖いですね。「Swiftの文字列比較は怖い」という意識を持っておくことが必要となるでしょう。