書いて覚えるSwift入門

第26回等しさと文字列と型の強さと

APFSの秘密を解く

前回予告で今回は「総称型に焦点を当てる」と宣言しましたが、その前にやっておくべきことがありました。Swiftにおける文字の扱いです。なぜ今なのか。こちらをご覧ください図1⁠。

図1 APFS‐lename
図1 APFS‐lename

なぜこうなってしまうのか。その秘密はファイル名にあります。

リスト1 Perlによる検証コード
#!/usr/bin/env perl
use strict;
use warnings;
use Encode;
use feature 'say';
use utf8;
binmode STDOUT, ':utf8';

sub listdir {
    my $dn = shift;
    opendir my $dh, $dn or die "$dn: $!";
    my @fn = map { decode_utf8($_) }
      grep !/\A\./, readdir $dh;
    closedir $dh;
    @fn;
}

my $dir    = shift or die "usage: $0 dirname";
my $fn_nfc = "\x{304b}\x{3070}\x{3093}.txt"; # かばん
my $fn_nfd = "\x{304b}\x{306f}\x{3099}\x{3093}.txt";
eval {
    my $path = "$dir/$fn_nfc";
    open my $fh, '>:utf8', $path
      or die "$path:$!";
    say $fh "食べないでくださーい";
    close $fh;
};
say $@ if $@;
eval {
    my $path = "$dir/$fn_nfd";
    open my $fh, '<:utf8', $path
      or die "$path:$!";
    say <$fh>;
    close $fh;
};
say $@ if $@;
eval {
    my $path = "$dir/$fn_nfd";
    open my $fh, '>>:utf8', $path
      or die "$path:$!";
    say $fh "食べないよー";
    close $fh;
};
say $@ if $@;
eval {
    my $path = "$dir/$fn_nfc";
    open my $fh, '<:utf8', $path
      or die "$path:$!";
    say <$fh>;
    close $fh;
};
say $@ if $@;
for (listdir($dir)) {
    say "$_:", encode 'ascii', $_, Encode::FB_PERLQQ;
}
unlink "$dir/$fn_nfc","$dir/$fn_nfd"

Perlで書かれたリスト1の検証コードでHFS+の空ディレクトリを指定して実行すると、こうなります。

食べないでくださーい

食べないでくださーい
食べないよー

かばん.txt:\x{304b}\x{306f}\x{3099}\x{3093}.txt

ところがAPFSだとこうなるのです。

/Volumes/apfs/utf8test/test.d/かばん.txt:Nosuch file or directory at test.pl line 31.

食べないでくださーい

かばん.txt:\x{304b}\x{306f}\x{3099}\x{3093}.txt
かばん.txt:\x{304b}\x{3070}\x{3093}.txt

いったい何が起きているのでしょう?

HFS+では\x{304b}\x{3070}\x{3093}.txt\x{304b}\x{306f}\x{3099}\x{3093}.txtは同じファイル名として扱われていますが、APFSでは異なるファイル名として扱われているのです。

そしてSwiftも、\u{304b}\u{3070}\u{3093}\u{304b}\u{306f}\u{3099}\u{3093}を等しいとみなしているのです。

1> "\u{304b}\u{3070}\u{3093}" == "\u{304b}\u{306f}\u{3099}\u{3093}"
$R0: Bool = true

文字列が単なるバイト列だった前世紀には驚きの結果ですが、Unicodeが普及した今世紀にはむしろUnicode正規化を前提に比較するというのはUnicodeの理念からすると実は正しい。しかしUnicode的に「正しく」==を実装しているのは、筆者の知る限り現時点においてSwiftぐらいのものでしょう。

SwiftのUnicode原理主義ぶりは、==にとどまりません。先のPerlスクリプトをFoundationを使って移植した検証コードリスト2では、APFSでもHFS+と同じ結果が出るのです。

リスト2 Swiftによる検証コード
#!/usr/bin/env swift
// utility functions and methods
#if os(Linux)
    import Glibc
#else
    import Darwin
#endif
import Foundation
func listDir(_ path:String)->[String] {
    let fm = FileManager.default
    do {
        let items = try fm
            .contentsOfDirectory(atPath:path)
        return items
    } catch let e {
        fatalError("\(e)")
    }
}
func slurpFile(_ path:String) throws ->String {
    do {
        let str = try String(
            contentsOfFile:path,
            encoding:.utf8
        )
        return str
    } catch let e {
        throw e
    }
}
extension String {
    func append(path: String) throws {
        let fm = FileManager.default
        if fm.isWritableFile(atPath:path) {
            do {
                let url = URL(fileURLWithPath:path)
                let fh = try FileHandle(
                    forWritingTo: url
                )
                _ = fh.seekToEndOfFile()
                _ = fh.write(self.data(using:.utf8)!)
            } catch let e {
                throw e
            }
        } else {
            let created = fm.createFile(
                atPath:path,
                contents:self.data(using:.utf8)!,
                attributes:nil
            )
            if !created {
                throw NSError(
                    domain:
                      "failed to create \"\(path)\"",
                    code:500
                )
            }
        }
    }
}
// main part
let args = CommandLine.arguments
if args.count < 2 {
    fatalError("\(args[0]) directory")
}
let dir = args[1]
let fn_nfc = "\u{304b}\u{3070}\u{3093}.txt" // かばん
let fn_nfd = "\u{304b}\u{306f}\u{3099}\u{3093}.txt"
do {
    let path = "\(dir)/\(fn_nfc)"
    try "食べないでくださーい\n".append(path:path)
} catch let e {
    print("\(#line):\(e)")
}
do {
    let path = "\(dir)/\(fn_nfd)"
    try print(slurpFile(path))
} catch let e {
    print("\(#line):\(e)")
}
do {
    let path = "\(dir)/\(fn_nfd)"
    try "食べないよー\n".append(path:path)
} catch let e {
    print("\(#line):\(e)")
}
do {
    let path = "\(dir)/\(fn_nfc)"
    try print(slurpFile(path))
} catch let e {
    print("\(#line):\(e)")
}
for item in listDir(dir) {
    print("\(item):", Array(item.unicodeScalars))
}
let fm = FileManager.default
for item in [fn_nfc, fn_nfd] {
    let path = "\(dir)/\(item)"
    if fm.fileExists(atPath:path) {
        try fm.removeItem(atPath:path)
    }
}

ただしそうなるのはmacOSの場合。Linux上ではPerlと同じように振る舞います。

てんでんばらばらちんぷんかんぷんまとまらない?

なんともややこしそうですが、ベストプラクティスはすでに存在します。NFC(Normalization Form Canonical Compression)にしてしまうのです。

・IMなどで文字列入力した場合、ほぼ100%NFCになる。つまりソースコード中の Unicode文字列は当初からNFC ・NFCのほうがバイト数が少ない ・HFS+は双方受け付けるので、あえてNFDにする必要はない

SwiftでStringをNFCにするには、import Foundationしてから.precomposedStringWithCanonicalMappingにアクセスするだけです。Swift 3.1ならLinuxもサポートしています。こんな長いの覚えられないというのであれば、次のようにしても良いでしょう。

import Foundation
extension String {
    var nfc:String {
        return self.precomposedStringWithCanonicalMapping
    }
    var nfd:String {
        return self.decomposedStringWithCanonicalMapping
    }
    var nfkc:String {
        return self.precomposedStringWithCompatibilityMapping
    }
    var nfkd:String {
        return self.decomposedStringWithCompatibilityMapping
    }
}

これで"\u{304b}\u{306f}\u{3099}\u{3093}".nfcとすればかばんは3文字で収まります。

もうひとつ念のために、同等な比較に加えて同値な比較も用意しておくのもよいでしょう。たとえば===を次のように定義しておくのです。

func ===(_ lhs:String, _ rhs:String) -> Bool {
return Array(lhs.utf8) == Array(rhs.utf8)
}

そうすると、次のようになります。

let ue9 = "\u{E9}" // é
let u65u301 = "\u{65}\u{301}" // é + ́

ue9 == u65u301 // true
ue9 === u65u301 // false
ue9.nfd === u65u301 // true
ue9 === u64u301.nfc // true

Perlに慣れた筆者から見ると、Swiftの文字列処理はまだまだひよっこもいいところなのですが、はじめからUnicodeをきちんと扱っているのはとてもうらやましい。Our goal is to be better at string processing than Perlという公約が実現するかどうかはさておき、PerlやPHPやPythonやRubyはUnicodeが後付けなおかげで今でも苦労しているのですから。

次号予告

ところで==は、どんな型にも必ず存在するわけではありません。

let a:Any = 1
let b:Any = 1

ここでa == bとしても、error: binary ope rator '==' cannot be applied to two 'Any' operandsと怒られてしまいます。Equatableプロトコルに準拠している型だけが等しさを享受できるのです。

プロトコル? 準拠? 次回はいよいよSwift最大の特長であるプロトコルを、総称型と絡めつつ紹介します。

Software Design

本誌最新号をチェック!
Software Design 2022年9月号

2022年8月18日発売
B5判/192ページ
定価1,342円
(本体1,220円+税10%)

  • 第1特集
    MySQL アプリ開発者の必修5科目
    不意なトラブルに困らないためのRDB基礎知識
  • 第2特集
    「知りたい」「使いたい」「発信したい」をかなえる
    OSSソースコードリーディングのススメ
  • 特別企画
    企業のシステムを支えるOSとエコシステムの全貌
    [特別企画]Red Hat Enterprise Linux 9最新ガイド
  • 短期連載
    今さら聞けないSSH
    [前編]リモートログインとコマンドの実行
  • 短期連載
    MySQLで学ぶ文字コード
    [最終回]文字コードのハマりどころTips集
  • 短期連載
    新生「Ansible」徹底解説
    [4]Playbookの実行環境(基礎編)

おすすめ記事

記事・ニュース一覧