書いて覚えるSwift入門

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

この記事を読むのに必要な時間:およそ 4 分

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 2017年7月号

2017年6月17日発売
B5判/184ページ
定価(本体1,220円+税)

  • 第1特集
    理論&応用で
    シェル力の幅を広げる
  • 第2特集
    データの抽出・加工に強くなる!
    MySQL[SELECT文]集中講座
  • 一般記事
    ハッシュ関数を使いこなしていますか?【後編】
    ソフトウェア開発での実装ポイント
  • 一般記事
    Windows Server 2016で構築する最新ファイルサーバ【後編】
    進化した機能で効率化を推進
  • 一般記事
    Jamesのセキュリティレッスン【10】
    Jamesの挑戦状! Wireshark実践問題

著者プロフィール

小飼弾(こがいだん)

1969年生まれ,東京都出身。元ライブドア取締役の肩書きよりも,最近はPokemon GOのガチトレーナーのほうが有名になりつつある……かもしれない永遠のエンジニアオヤジ。

活躍の場はIT業界だけでなく,サブカルからアカデミックまで多方面にわたり,ネットからの情報発信は気の向くまま毎日毎秒! https://twitter.com/dankogai,ニコニコチャンネルは,http://ch.nicovideo.jp/dankogai,blogはhttp://blog.livedoor.jp/dankogai/

当社刊行書籍は『小飼弾のアルファギークに逢ってきた』『小飼弾のコードなエッセイ』など。他にも著書多数。

コメント

コメントの記入