[Swift] AtCoder で Swift を使って UnitTest しながら実装する方法

Swift

     
⌛️ 3 min.
AtCoder に Swift でチャレンジするときに、UnitTest を使えた方が便利なので、その方法を説明します。

おそらく、どこかで誰かがその方法を説明してくれているんでしょうけど、見つからなかったので、自分向けにメモです。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.3
  • Xcode 12.5

AtCoder

サイトは、こちら

問題が出されて、その問題を解くためのコードを提出すると採点してくれるサイトです。

Web 上のテキストフィールドにコードを入れると、実行して コードの採点をしてくれます。その時に、様々な言語を指定することができ、その中の1つに Swift があります。

当初、Python で解こうと思っていたのですが、せっかくなので(?)、Swift (+ Xcode) を使おうとしてみました。

すこし触ってみると 環境があまり整っていないことに気づいたので、整備してみることにしたのが経緯です。

コマンドラインツールが必要です

AtCoder では、提出されたコードが(コンパイルされて)実行されます。標準入力からデータが渡されて、処理結果として出力された文字列が、正しいかどうかでコードが判定されます。

Xcode と コマンドラインツール開発の 親和性

問題を解くロジックをコードで書くのがポイントなので、コマンドラインツール という形態は 自然なのですが、Xcode で ウィザードから “Command Line Tool”を選択して 作り始めると 様々な箇所で不便を感じます。

  • ターミナルで実行しないと、標準入力を渡せない
  • UnitTest を設定できない

# Swift 使う人は、iOS/macOS/tvOS 等がターゲットでしょうから、そもそも コマンドラインツールを作ったことがない人もいるかもしれません。

自分でロジックを記述する時に、「毎回コマンドラインツールを使って、必要な値を標準入力に渡して・・・」というのは現実的ではないです。

シェルスクリプト等を使ったバッチを書いて実行するのもありですが、せっかくなら Xcode の中で全てを終わらせたいですよね。

さらには、ロジック部分がメインなので、UnitTest を使って、コードを拡張していきたいのですが、そのための用意もあまり整えられていないように見えます。

できるところと できないところ

Xcode を調べたのですが、なんとかできるところ と できないところがありそうです。

ターミナルで実行しないと、標準入力を渡せない: 回避策あり

プログラム的に、標準入力を切り替えるのに、freopen という関数が使えるようです。


_ = freopen("myfile.txt", "r", stdin)

この関数でテキストファイルを指定すると、その内容が、標準入力として扱われます。
注意点としては、freopen は、Linux 由来の古い関数で URL を引数として受け取ってくれません。

標準入力からの読み出しは、readLine という関数が用意されています。
以下のように、readLine したデータを 関数に渡すようにすることで、”標準入力からの読み込み”と”ロジック部分”を分割でき、ロジック単体でのテストが可能になります。


func main() {
    // (1)
    guard let line1 = readLine() else { fatalError("no input")}
    // (2)
    let results = LogicFunction([line1])
    // (3)
    for result in results {
        print(result)
    }
}
コード解説
  1. readLine は、デフォルトでは改行コードを除いた文字列を渡してくれます。
  2. 入力として渡された String を配列で 対象の関数に渡すようにします。(AtCoder では、複数行の入力を渡すケースがあります)
  3. 結果として、String の配列を返すので、それぞれを1行に表示します。(AtCoder では、結果の出力が複数行となるときがあります)

UnitTest を設定できない: 回避策あり

“Command Line Tool” を選択すると、”Unit Test” を設定するオプションは現れません。 Xcode の UnitTest は、バンドルの扱いも含め、コマンドラインツール以外のアプリ向けになっている気がします。

ちょっと力技ですが、自分のコードを UnitTest ターゲットにも追加することで、UnitTest を行うことが可能になります。

コマンドラインツールとしてのテストをセットアップしようとすると、おそらく シェルスクリプト等を使えば可能だと思います。
ですが、上記のように (標準入力から受けとった) String を引数とした 関数に分割し、その関数を UnitTest するのがより簡単です。

上記のように 標準入力から分離された LogicFunction は、以下のように、UnitTest の対象とすることができます。


    func testExample() throws {
        XCTAssertEqual(LogicFunction(["abcdZONefghi"]), ["1"])
    }

“abcdZONefghi” が標準入力から渡されて、”1″ が返るかのテストです。

# 上記は、こちらの問題の入出力例1を、書いたものです。

TDD の考えでいけば、ロジックの1行目を書く前に、上のテストコードを実行して、結果が Failed になることを確認することが最初の一歩になるはずです。

ここまでくれば、テスト関数を増やしたり、テストデータを作ったりと、通常通りの方法でコーディングを継続することができます。

AtCoder への提出

UnitTest をしているときは、上記で問題ありませんが、AtCoder へ提出する時には、main が実行されるようにしないといけません。

ただし、UnitTest を行う時にも main を実行するように書いてあると、標準入力を待って 止まってしまいます。ですので、main を実行するように書いたままにしてしまうと、UnitTest 時に不便です。

AtCoder のサイトにコピペするときに調整しても良いのですが、忘れる気がするので、以下のようなコードにしておくと、吉です。


import Foundation

func main() {
    guard let line1 = readLine() else { fatalError("no input")}
    let results = LogicFunction([line1])
    for result in results {
        print(result)
    }
}
#if !DEBUG    // 追加
main()        // 追加
#endif        // 追加

AtCoder の環境では、DEBUG は定義されていないようなので動いてます。(詳細環境は開示されていないので不明ですが、今のところ動いてます)

Profiler の実行

(自分都合ですが)何も考えずにコードを作成すると、ロジックを追いやすいようにするために 処理時間が犠牲になりがちです。

ですので、Profiler (Xcode での instrument) を実行できるようにしておくと便利です。

Project に新しい Configuration “Profile” を追加し そこで “Swift Compiler – Custom Flags” で Configuration “Profile” に “PROFILE” をセットする設定にしておき、以下のようなコードを書いておくと、Profile 設定で instrument を実行すると ホームディレクトリの “profile.txt” を標準入力としてくれるようになります。


#if PROFILE
var testfile = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("profile.txt")
let result = freopen(testfile.path, "r", stdin)
#endif

正直、Configuration “Debug” でコマンドを実行することはない気がするので、Configuration “Debug” の設定を変更しても良いかもしれません。

# ただし、!DEBUG で main を実行するようにしていることを忘れてはいけません。AtCoder での実行時に、標準入力を切り替えてしまうのは NG です。

この設定を行い、profile 用の scheme で Configuration “Profile” を設定しておけば、”⌘ – I” で 簡単に profile することができるようになります。

Tips

複数行入力への対応

最初の行に行数の情報が渡され、その数分の行として情報が入力されるケースが多くあります。

そのような時には、以下のようにすると、対応できます。(1行目の最初の情報が入力される行数というケースを想定して書いてます。)


func main() {
    guard let line0 = readLine() else { fatalError("no input")}
    var lines = [line0]
    let lineNum = line0.components(separatedBy: " ").map{Int($0)!}.first!
    for _ in 0..<lineNum {
        guard let line = readLine() else { fatalError("no input")}
        lines.append(line)
    }
    let results = LogicFunctions(lines)
    for result in results {
        print(result)
    }
}

#if !DEBUG
main()
#endif

問題によっては、入力行が1行や2行で固定の時もあります。そのときは、必要数の readLine() を書くのが簡単です。

複数行の入力のテストコード記述

AtCoder では、複数行の それも それなりに行数のあるものが 入力例として提示されていることがあります。
これを 1行1行 入力するのも手間なので、以下のようにすることで、コピペで入力データとすることができます。


3 3
10 1
10 10
1 10
1 10 1
1 10 1

上記のような入力例があった時に、以下のようにすると、各行を それぞれ String とした [String] に変換することができます。


let lines = """
    3 3
    10 1
    10 10
    1 10
    1 10 1
    1 10 1
    """.split(separator: "\n").map{ String($0)}
print(lines)
// print
["3 3", "10 1", "10 10", "1 10", "1 10 1", "1 10 1"]

使用例

以下は、こちらの問題に対しての回答コードと、テストコードです。

ロジック部分


//
//  main.swift
//
//  Created by : Tomoaki Yagishita on 2021/05/13
//  © 2021  SmallDeskSoftware
//

import Foundation

func main() {
    guard let line0 = readLine() else { fatalError("no input")}
    var lines = [line0]
    let lineNum = line0.components(separatedBy: " ").map{Int($0)!}.first!
    for _ in 0..<lineNum {
        guard let line = readLine() else { fatalError("no input")}
        lines.append(line)
    }
    let results = HelloSpaceB(lines)
    for result in results {
        print(result)
    }
}

#if !DEBUG
main()
#endif

func HelloSpaceB(_ lines: [String]) -> [String] {
    let line0Values = lines[0].components(separatedBy: " ").map{Double($0)!}
    let d = line0Values[1]
    let h = line0Values[2]
    var maxAtY = 0.0
    
    for line in lines.dropFirst() {
        let values = line.components(separatedBy: " ").map{ Double($0)! }
        let slope =  (h - values[1]) / (d - values[0])
        let atY = h - slope * d
        if maxAtY < atY {
            maxAtY = atY
        }
    }
    
    return ["\(maxAtY)"]
}

テストコード


import XCTest

class UnitTest: XCTestCase {
    func testExampleB() throws {
        let input1 = """
            1 10 10
            3 5
            """.split(separator: "\n").map{ String($0)}
        XCTAssertEqual(HelloSpaceB(input1), ["2.8571428571428568"]) // Failed を避けるために、16桁目の数値を調整しています。
        let input2 = """
            1 10 10
            3 2
            """.split(separator: "\n").map{ String($0)}
        XCTAssertEqual(HelloSpaceB(input2), ["0.0"])

        let input3 = """
            5 896 483
            228 59
            529 310
            339 60
            78 266
            659 391
            """.split(separator: "\n").map{ String($0)}
        XCTAssertEqual(HelloSpaceB(input3), ["245.30806845965773"]) // Failed を避けるために、14桁目の数値を追加しました。
    }
}

まとめ:AtCoder に Swift で挑戦するときの環境構築

AtCoder に Swift で挑戦するときの環境構築
  • 以下のコードをテンプレートに作ると、UnitTest しやすい
    
    import Foundation
    
    func main() {
        guard let line0 = readLine() else { fatalError("no input")}
        var lines = [line0]
        let lineNum = line0.components(separatedBy: " ").map{Int($0)!}.first!
        for _ in 0..<lineNum {
            guard let line = readLine() else { fatalError("no input")}
            lines.append(line)
        }
        let results = LogicFunctions(lines) // adjust argments
        for result in results {
            print(result)
        }
    }
    #if PROFILE
    var testfile = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("profile.txt")
    let result = freopen(testfile.path, "r", stdin)
    #endif
    #if !DEBUG
    main()
    #endif
    
  • 入力行数が固定の時は、readLine を必要数記述するのが簡単
  • 入力例をコードに落とす時は、以下のようにすると簡単
    
            let input = """
                1 10 10
                3 5
                """.split(separator: "\n").map{ String($0)} // ["1 10 10", "3 5"]
            XCTAssertEqual(LogicFunction(input), ["2.8571428571428568"])
    

AtCoder にチャレンジするなら

以下の本が、定番本です。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です