sci

最果て風呂

mto を Swift で実装してみる

この記事で「Swift は枯れたら触ります。」と口にしてから早 7 カ月。エディタの前に向うものの、いつの間にかネットサーフィンに勤しんでしまっている自分なのでした。バージョンも 3.0 になったことですし、なぜか集中力が高まっていたので触ってみることにしました。

触ると言っても "Hello, World!" 的なもはすでにやっていますし、何もネタが思いつきません。そこでいつものように mto を実装してみることにしました。CUI(CLIどっちだ?) 版ですので iOS な人にはあまり参考にならないと思います。

それでは自分が実装していったことを順番に振り返って記述していきます。

環境変数の取得

Swift では Objective-C のようにヘッダファイルに分けたり、@interface や @implementation を書いたりせず、まるでスクリプト言語のような感覚で 1 つのファイルに記述していくことができます。ファイルの名前も適当に mto.swift としておきます(今回は単純なプログラムだからですよ)。

まずは辞書の場所を知るために必要なので、環境変数を取得してみます。Foundation を読み込むのはお約束というやつですかね。

import Foundation
let mtodir = ProcessInfo.processInfo.environment["MTODIC"]
print(mtodir)

型の宣言には letvar があるそうのですが、定数なのでとりあえず let でいきます。それでは下記のコマンドでビルドすることにします。

> xcrun --sdk `xcrun --show-sdk-path` swiftc -o mto-swift mto.swift

おっと、下記のようなエラーが出てしまいました。

warning: expression implicitly coerced from 'String?' to Any
print(mtodir)
...

実行ファイルはできていたので、とりあえず実行してみることにします。

> ./mto-swift
Optional("/Users/mto/dict")

Optional という余計なものが付いていますが、環境変数を取得することができました。どうやらこれは nil 対策らしいです。print(mtodir!) のように ! マークを付けると中身だけを取り出すことができます。ちなみに、print(mtodir as Any) とするとコンパイル時の warning を出さなくなります(が、そんなことをする状況ってあるのでしょうか?)。

環境変数が設定されていなかった場合の処理を追加して、ここまでのおさらいをします。

import Foundation
let mtodir = ProcessInfo.processInfo.environment["MTODIC"]
if mtodir == nil {
    print("環境変数 MTODIC を設定してください。")
    exit (1)
}
print(mtodir!)

print("Hello, World!") よりは進歩できました。

引数処理

コマンドラインのプログラムですので、変換オプションと読み込むファイルを引数として受け取りたいのです。調べてみましたが argc にあたるものは無いようですので、args の個数を count で引っぱることにしました。わざわざ argc を変数にする必要はないのですが、何となくやっちゃいました。

let args = ProcessInfo.processInfo.arguments
let argc = args.count
print(args)
print(argc)

コンパイルして実行してみます。今度は warning も出ずに完了しました。! マークは必要ないようです。つまり Optional 型ではないということなのでしょう(これは nil になる要素がないからかな?)。

> ./mto-swift
["/Users/mto/mto-swift"]
1
> ./mto-swift hoge
["/Users/mto/mto-swift", "hoge"]
2
> ./mto-swift hoge fuga
["/Users/mto/mto-swift", "hoge", "fuga"]
3

ProcessInfo.processInfo.arguments の一番目は自分自身になるようですね。Swift の配列は添字が何番から始まるのだろう?print(args[0]) を追記して実験すると "/Users/mto/mto-swift" が得られましたので、0 番からということになります。ちなみにここで print(args[1]) とするとコンパイルは通りますが、./mto-swift のように引数なしで実行するとエラーになります。配列の 2 番目(添字の 1 番目)は存在しないのですから。

ここまでわかれば、引数の数とオプションによって分岐処理をすることができます。制御構文は switch 文もありますが、今回は数も少ないですし if 文でいきます(ただちょっとネストが深いのが嫌なところ)。

if (argc == 1 || argc >= 4) {
    print("引数がないか3つ以上あるよ")
    exit (1)
} else {
    if argc == 2 {
        if args[1] == "checkdictkana" {
            print("checkdictkanaやな")
        } else if args[1] == "checkdictkanji" {
            print("checkdictkanjiやな")
        } else {
            print("Option ちゃうやん")
        }
    } else if argc == 3 {
        if args[1] == "tradkana" {
            print("tradkanaやな")
        } else if args[1] == "modernkana" {
            print("modernkanaやな")
        } else if args[1] == "oldkanji" {
            print("oldkanjiやな")
        } else if args[1] == "newkanji" {
            print("newkanjiやな")
        } else {
            print("Option ちゃうやん")
        }
    }
}

お察しの通り「print デバッグ」です。というかこの方法しか知りません(>_<)。漏れがあるかもしれませんが、自分で使うツールですからね。これでユーザとのやり取り部分ができました。

ファイルの読み込み

辞書ファイルにしても変換対象のファイルにしても、まずはプログラムに読み込まなければいけません。調べてみるといろいろあるようですが、do, try, catch を使うのが定番のようです。

現時点では引数のない関数にして、ファイル名も決め打ちにして実験してみます。エンコーディングは自分が UTF-8 を使っているのでね。

func createDict() {
    let dictfile = "/Users/mto/sample.txt"
    do {
        let data = try String(contentsOfFile: dictfile,
                                    encoding: String.Encoding.utf8)
        print(data)
    } catch {
        print(error)
    }

}
createDict()

標準出力にドバーッと表示されて上手くいきました。

続いてはファイル名を引数で渡して指定してみます。本当はハマって一番最後に解決した部分なのですけどね……。結論から先に書くと下記のようにします。

func createDict(dictfile:String) {
    do {
    ...
    }
}

let dictfilepath = mtodir! + "/kana-jisyo"
createDict(dictfile:dictfilepath)

関数へ引数を渡すときはラベルというものを指定するらしく、それを知らなかった当初は下記のようにしていました。そして「なぜ動かないんだろう?」と食事をしたり洗濯をしたり風呂に入ったりトイレ用を足しながら考えまくっていました。

func createDict(dictfile) {
    do {
    ...
    }
}

let dictfilepath = mtodir! + "/kana-jisyo"
createDict(dictfilepath)

今でもよく理解していなくて、ソースのコメントにも書いたように、下記のような認識のままです。とりあえず動いてるからいいやって感じです。

// 引数を渡す時にラベルをつけないといけない
// dictfile という仮引数に dictfilepath の内容を渡すよって感じかな

一行ずつ処理する

ファイルを読み込むことができましたので、内部辞書の作成をします。辞書といっても二次元配列なのですけどね。(("a" . "a'") ("b" . "b'") ...) みたいな。そのために読み込んだファイルを一行ずつ処理していくわけですが、その方法がなかなかわからない。enumerateLines を使う方法をみつけたので、今回はこれを採用しました。

func createDict(dictfile:String) {
    do {
        let data = try String(contentsOfFile: dictfile,
                              encoding: String.Encoding.utf8)

        data.enumerateLines(invoking: {      // ファイルを一行ずつ
            line, stop in                    // 読み込んで
            print("なんやねん: \(line)")    // 何かの処理をする
        })                                   // 部分なのだ

    } catch {
        print(error)
    }
}

let dictfilepath = mtodir! + "/kana-jisyo"
createDict(dictfile:dictfilepath)

for を使って行頭に番号を付けていく例がありましたが、構文を把握しきれなくなるため、ここでは単純に「なんやねん:」を付けて出力させることにしました。うまくいきましたね。

正規表現

ここまでくればあとは正規表現を使ってパースして配列に代入して...と進んでいくわけなのですが...。Swift はモダンな言語とのことなので、文字列の扱いも楽ラクになっているのかと思っていましたが、ほぼ Objective-C でした。以前に Objective-C で書いていたのでなんとなくわかりましたが、プログラミングを Swift ではじめようとしている人には難所なんじゃないかな?と思いました。

自分もまだよくわかっていませんが、次のようにしました。

func createDict(dictfile:String) {
    var tmpdict = [[]]

    do {
        // 辞書ファイルのコメント行を削除するための正規表現
        let regexp1 = try NSRegularExpression(pattern: "^;.*|^$",
                                              options: [])

        // 辞書ファイルのコメント部分を削除するための正規表現
        let regexp2 = try NSRegularExpression(pattern: "\\s+;.*",
                                              options: [])

        // ファイルを開く
        let data = try String(contentsOfFile: dictfile,
                                    encoding: String.Encoding.utf8)
        // 一行ずつ処理する
        data.enumerateLines(invoking: {
            line, stop in
            // コメント行を探す
            let match = regexp1.matches(in: line,
                                   options: [],
                                     range: NSMakeRange(0, (line as NSString).length))

            // コメント行ではなかったものについて処理
            if (match.count != 0) {
                // コメント行は無視
            } else {
                // 備考部分を削除
                let result = regexp2.stringByReplacingMatches(in: line,
                                                         options: [],
                                                           range: NSMakeRange(0, (line as NSString).length),
                                                    withTemplate: "")
                // 二次元配列に代入
                tmpdict.append(result.components(separatedBy: " /"))
            }
        })
    } catch {
        print(error)
    }
    print(tmpdict)
    print(tmpdict.count)
}

実行してみるとうまく二次元配列が作られています。

> ./mto-swift
..., ["もちいれ", "もちゐれ"], ["そう", "さう"], ["もつたゐない", "もつたいない"], ["ならふとも", "ならうとも"]]
4238

あれ?でも要素の数がおかしい!4237 でなければいけないのに...。ターミナルをスクロールして上の方を見てみると...

[[], ["植え", "植ゑ"], ["据え", "据ゑ"], ["飢え", "飢ゑ"], ["餓え", "餓ゑ"],

のようになっていました。どうやら var tmpdict = [[]] では二次元配列にはなるのですが、空の配列が入った状態で初期化されてしまうのですね。まっさらなものが欲しい場合は var tmpdict:[[String]] = [] のようにしないといけないようです。

繰り返し

なんやかんやで無事に内部辞書を作成することができました。あとは指定したファイルを読み込んで Brute-force していくだけです。読み込んだ文字列に対して配列の 0 番から順番に検索・置換を繰り返すという力技です(^^)。

読み込んだファイルを一行ずつ処理する部分では enumerateLines を使いましたが、配列を回す部分は for ... in ... を使います。これは要素をすべてなめる場合は本当に便利ですよね。

使い慣れているので大丈夫だとは思いますが念のため確認してみます。

print(tmpdict)

の部分を下記のように変更してコンパイル&実行。

for x in tmpdict {
    print(x)
}

要素がひとつずつ標準出力に表示されていて問題ないようです。

リテラルな文字列の置換には replacingOccurrences(of: , with: ) を使うそうです。これは簡単でいいですね。調べていませんが、いわゆる /g オプションが付いた感じで発見したすべての文字列を置換してくれるようです。

下記のようにして読み込んだファイルを一行ずつ取り出して、それぞれに対して辞書の要素すべてを走査させてみます。O(m*n) なのでアレですけれども。

data.enumerateLines(invoking: {
    line, stop in
    for cons in tmpdict {
        line = line.replacingOccurrences(of: cons[0], with: cons[1])
    }
    print(line)
})

なんかエラーが出てしまった...。

error: cannot assign to value: 'line' is a 'let' constant

immutable のようなので、ここはひとつコピーを噛ませてみます。

data.enumerateLines(invoking: {
    line, stop in
    var str = line
    for cons in tmpdict {
        str = str.replacingOccurrences(of: cons[0], with: cons[1])
    }
    print(str)
})

これでいいのかわかりませんが、期待したものを得ることができました。しかし速度が遅いのが気になります。計算量を減らすために読み込んだファイルを一本の文字列と考えて処理させてみます。メモリをたくさん使うし、巨大なファイルを読み込ませたら死んでしまうでしょうけれど...。

var str = data
for cons in tmpdict {
    str = str.replacingOccurrences(of: cons[0], with: cons[1])
}
print(str)

2.875s だったものが 1.572s になりました。まぁ、自分の扱うファイルのサイズなら、こちらを使っても問題ないでしょう(というか Python3 版を使います)。

テスト

ひと通り完成したのでテストをしてみます。テストといっても簡単なもので、あらかじめ期待する文字列を格納したファイルと diff で比べるだけのものです。

> make test
...
Objective-C Test!
  tradkana test:   OK
  modernkana test: OK
  oldkanji test:   OK
  newkanji test:   OK
Swift Test!
  tradkana test:   NG
  modernkana test: NG
  oldkanji test:   NG
  newkanji test:   NG

なんかしらんけどエラーになっとるやんけ!どうやら改行が余計に付いているようですね。print(str)print(str, terminator: "") にすれば OK になりました。稚拙なものでもテストは大切ですね。

感想

知らない部分がたくさんあるせいか、書いていてあまり楽しくありませんでした。見通せないので苦しいといいますか。ある程度までいけば視界が開けてきて楽しくなるのでしょうけれど、そこまでする動機が見つかりません。iOS アプリなどには興味はありますが、Xcode が難解なので、次に触るのは来年末あたりになりそうです。

速度に関しては Foundation が同じ(?)せいか Objective-C で実装したものと変わりありませんでした(前の結果はここ)。検索・置換の部分は自力で実装できないといけないのでしょうけれど、ポインタとか malloc とか面倒臭そうです。

Swift は 3 系になってから日が浅いためか、検索しても古い情報ばかりがヒットしてしまい、なかなか思うような情報に辿り着けませんでした。コンパイラが「それもう古いわ」と教えてくれるので助かりますが、2 系が一掃されるまでは混沌とした状態が続くように思われます。developer.apple.com の一次資料は日本語化されていないので辛いところですね。

今回のソースはいつものようにリポジトリにプッシュしてあります。