技術memo

関数型ゴースト

F# でTDDした話 前編? #FsAdvent #fsharp

この記事はF# Advent Calendar 2015の4日めの記事です。3日めは@gab_kmさんの実践 Persimmon.Dried #FsAdvent — a wandering wolfでした。

前置き

ご無沙汰してます。こんばんは、まだ生きています。なごやかされて早3ヶ月、色々なことがありましたが、まだやっていけています。

それから色々ありましたが、正直あまりプログラミング系の具体的なところ*1では、さほど進展はない気がしています。しかたがないので、例によって何かしらの演習問題をやってみよう、というのがこのブログ記事になります。

TDD(テスト駆動開発)とか

そうそう、TDDとかBDDとかアジャイルとかスクラムとかエクストリームプログラミングとか、色々ウェッブ系で*2謎の用語*3が異様に流行ってた時期が数年前あったような気がするんですけど*4、最近は落ち着きを見せて、

  • 「TDDとか理想論でしょw 現実には役に立たないしコストパフォーマンス的にありえないw」派と
  • 「TDDとか常識でしょw テストコードのない実装とかありえないw」派

に分かれている印象があります*5

まあその辺の何派がどうこうという話は「俺の考えた最強の関数型プログラミングが云々」並にどうでもいいので脇に置きますが、個人的には最近そこそこ「TDDちゃんとやれると捗る」みたいな感覚が得られたので、今TDDがアツい、という感じになっています。時代の流行りなんて知りません。

ということで本題

「F# スクリプトをひとつTDDで書いてみよう」

という話。テスティングフレームワークも何も出てきません。ちなみにタイトルからわかるように完結しません。

開発環境

  • Windows 7
  • Visual Studio Express 2012 for Web
    • ↑今時ありえないので Visual Studio Community 2013 あたりを使いましょう。
    • VS2015はまだ不安って話を聞きますが、どうなんでしょうね

仕様

「n * n フィールドで m 目並べゲームを作る」

  • 最初にn=3, m=3とでもしましょう。うまく行ったら適当に一般化しましょう。
    • 本当は五目並べが作りたいけれど、ハードルが高そうなので三目並べを作る、という話です
  • 座標は 0,0 ~ n-1,n-1 で表しましょう。
  • player A, Bが交互に座標を入力して石を置き、タテ・ヨコ・ナナメでm個連続したらその時点で勝ちとしてゲーム終了としましょう。

やってみる

Testing Frameworkのことを考えるのが面倒なので、TestsモジュールをF# Interactiveで読み込んで*6、全てのモジュール変数がtrueになればいいことにしましょう。

まずはfsxファイルを作って、こんな感じに書いてみます。

module Tests =
    let ``プレイヤーABそれぞれが横方向に置いてみる`` =
       let input = [(0,0); (1,0); (0,1); (1,1); (0,2)]
       resolve input = AWin

この時点で、

  • 手の入力は int * int のタプルで表す
  • プレイヤーA、Bの入力を上記タプルのリストで表し、先頭から交互にAの入力・Bの入力とみなす
  • resolve関数にこのリストを渡すと、Aが勝利した場合の結果がAWinとして帰ってくる

というぼんやりとしたイメージが湧いてきます。

今回の入力イメージはこんな感じ

0 1 2
0 A A A
1 B B
2

当然この時点ではF# Interactiveに投げてもThe value or constructor 'resolve' is not definedと言われます。Red*7です。resolve関数なんてないので当然ですね。次は実装を書きます。

type Result = AWin
let resolve input =
    AWin

必ずAが勝ちます。それでいいのか。でもこれだけ書けばOKです。これをF# Interactiveに読み込んでから上記のTestsモジュールを読みこめば、「プレイヤーA、Bそれぞれが横方向に置いてみる」の値はtrueになります。Green*8です。大勝利です。

それでいいわけがないのでテストを追加します。 Testsモジュールに次の定義を追加します。

    let ``プレイヤーBが勝つパターン`` =
       let input = [(0,0); (0,1); (0,2); (1,1); (1,0); (2,1)]
       resolve input = BWin
0 1 2
0 A B A
1 A B
2 B

当然ながらBWinなんて無いのでエラーになります。Redですね。実装を修正します。

type Result = AWin | BWin
let resolve (input: (int * int) list) =
    if input.Length = 5 then AWin else BWin

BWinが増えました。そして勝利判定は面倒なのでリストの長さで確認します。最低ですね。それでもテストは通ります。Greenです。サイコーにハイって感じですね。

それでいつまでも許されるわけが無いのでテストを追加します。

    let ``プレイヤーAが勝つパターン`` =
       let input = [(0,0); (0,1); (1,0); (2,0); (1,1); (2,2); (1,2)]
       resolve input = AWin
0 1 2
0 A B
1 A A A
2 B B

そろそろテストの名前が投げやりになってきましたが、上手い手が思いつきません。放置しましょう。この状態でテストを実行すると、当然ながら通りません。入力が5手以外は全てBWinになる馬鹿実装ですから。Redです。

ここで例えば「奇数手で終わっていればAWin」などと判定するように変えてもいいでしょうか。そもそも、ゲームが終了した時点で入力が終わるとは誰が保証してくれるのでしょうか。詰んだのにまだ手が入力されていたらどうしましょう。それもテストした方がいいでしょうか。不安になってきました。

何はともあれRedをGreenにしないと前に進めそうにありません。Greenにすることだけを考えて「奇数手で終わっていればAWin」を実装しましょう。

type Result = AWin | BWin
let resolve (input: (int * int) list) =
    if input.Length % 2 = 1 then AWin else BWin

これでひとまずGreenです。テスト3つは全てtrueです。安心しました。

ここで、さっきの課題の「勝利判定が出た時点でゲームセット」を実装する、つまりちゃんとゲームの勝敗を判定するロジックを実装してもいいのですが、まだ不安があります。それは異常系の入力です。とりあえず今は3*3マスの3目並べなので、それ以外の入力が来た場合のことは考えたくありません。テストを追加します。

    let ``3目並べの盤面の外に置いたらエラー`` =
        let ``エラーになるべき`` input =
            try
                resolve input |> ignore
                false
            with
            | _ -> true
        [
            ``エラーになるべき`` [(-1,0)]
            ``エラーになるべき`` [(0,-1)]
            ``エラーになるべき`` [(3,2)]
            ``エラーになるべき`` [(2,3)]
            ``エラーになるべき`` [(0,0); (-1,-1)] // 2手目以降に盤面外でもエラーになる
        ]
        |> List.forall id

急に難しくなった気がしますが気のせいです。単に盤面、つまり(0,0)~(2,2)の外側に来た場合には例外が発生することを確かめたいだけです。複数のテストしたい値について、List.forallでまとめています。

List.forall id の挙動が不安ですか?私も不安です。F# Interactiveで動作確認しましょう。

> List.forall id [true; true; true];;
val it : bool = true
> List.forall id [true; true; false];;
val it : bool = false

大丈夫そうですね。リストの全ての要素のANDをとった結果が返ってきます。

さて、テストを追加してRedになってしまったので、実装を修正しましょう。

type Result = AWin | BWin
let resolve (input: (int * int) list) =
    let check (x,y) =
        let check x =
           if x < 0 || 2 < x
           then failwith "3目並べの盤面からはみ出ています"
           else ()
        check x
        check y
    input
    |> List.iter check
    if input.Length % 2 = 1 then AWin else BWin

チェック処理がだいぶ鬱陶しいですが、これで良さそうです。Greenになります。

異常系といえば、同じマスに二度(A, B問わず)置かれたら困りますよね。ついでなのでエラーにしましょう。

module TestUtils =
    let ``エラーになるべき`` input =
        try
            resolve input |> ignore
            false
        with
        | _ -> true

module Tests =
    open TestUtils

    // 中略

    let ``3目並べの盤面の外に置いたらエラー`` =
        [
            ``エラーになるべき`` [(-1,0)]
            ``エラーになるべき`` [(0,-1)]
            ``エラーになるべき`` [(3,2)]
            ``エラーになるべき`` [(2,3)]
            ``エラーになるべき`` [(0,0); (-1,-1)] // 2手目以降に盤面外でもエラーになる
        ]
        |> List.forall id

    let ``同じマスに2回置いたらエラー`` =
       ``エラーになるべき`` [(0,0); (1,0); (0,1); (0,0)]

さっきのテストで作った「エラーになるべき」関数をTestUtilsモジュールに切り出して、「同じマスに2回置いたらエラー」テストを追加しました。同じマスに複数回置くパターンは幾らでもありそうですが、とりあえず1つだけあればいいことにしましょう。

ここでテストはRedになったので、実装を修正します。

type Result = AWin | BWin
let resolve (input: (int * int) list) =
    // 盤面範囲チェック
    let checkOutOfBoard (x,y) =
        let check x =
           if x < 0 || 2 < x
           then failwith "3目並べの盤面からはみ出ています"
           else ()
        check x
        check y
    input
    |> List.iter checkOutOfBoard
    // 同じマスに二度打ちチェック
    let checkDuplicate lis =
        lis
        |> List.fold (fun acc x -> if acc |> List.exists (fun y -> x = y) then failwith "2度も同じ手を打ちましたね" else x::acc) []
        |> ignore
    input
    |> checkDuplicate
    // 本処理
    if input.Length % 2 = 1 then AWin else BWin

チェック処理が更に鬱陶しくなってきましたが、直すのも面倒です。まだ暫くは大丈夫でしょう。きっと。

異常系といえば(その2)、まだ勝敗が決まっていない段階のリストを渡されたらどうしましょうか。例外にしましょうか、それとも引き分けみたいなものにしましょうか。正常系として必要になりそうなので、判定不能っぽい感じが良さそうですね。テストを追加します。

    let ``勝敗が決まらない`` =
        let input = [(0,0); (0,1); (1,0); (1,1)]
        resolve input = Undefined
0 1 2
0 A B
1 A B
2

Undefinedなんて定義していないので実行できません。実装を修正します。

type Result = AWin | BWin | Undefined
let resolve (input: (int * int) list) =
    // 盤面範囲チェック
    let checkOutOfBoard (x,y) =
        let check x =
           if x < 0 || 2 < x
           then failwith "3目並べの盤面からはみ出ています"
           else ()
        check x
        check y
    input
    |> List.iter checkOutOfBoard
    // 同じマスに二度打ちチェック
    let checkDuplicate lis =
        lis
        |> List.fold (fun acc x -> if acc |> List.exists (fun y -> x = y) then failwith "2度も同じ手を打ちましたね" else x::acc) []
        |> ignore
    input
    |> checkDuplicate
    // 本処理
    if input.Length < 5 then Undefined
    else if input.Length % 2 = 1 then AWin else BWin

Result型にUndefinedを追加して、本処理のところでその場しのぎの実装を追加しています。今はゴミのような実装ですが、そのうち良くなるでしょう。

この辺りでいい加減に、「勝利判定が出た時点でゲームセット」のテストを追加してみましょう。そうすればちゃんとまともな判定ロジックを実装せざるを得なくなるはずです。

    let ``リストの末尾まで行く前に勝敗が決定する`` =
       let input = [(0,0); (1,0); (0,1); (1,1); (0,2); (1,2)]
       resolve input = AWin
0 1 2
0 A A A
1 B B (B)
2

図で示した(B)を打つ前に、Aが勝利する結果にしたい、というわけです。

さて、このテストを追加するだけでRedになります。これは厳しい結果です。悲しみのあまり、三日三晩酒を浴びるように飲みたいところです。そんな休暇があればの話ですが。

ひとしきり泣いたら実装を直しましょう。どうすればいいでしょうか。とりあえずAとBの手を振り分けるところから考える必要がありそうです。そうしたら、一手ずつ追加しながら判定して、横一列で揃ったらその時点で勝利、とするのが良さそうです。

といったところで

そろそろ長くなってきたため、後編に続きます(予定は未定)。

現在のソース

type Result = AWin | BWin | Undefined
let resolve (input: (int * int) list) =
    // 盤面範囲チェック
    let checkOutOfBoard (x,y) =
        let check x =
           if x < 0 || 2 < x
           then failwith "3目並べの盤面からはみ出ています"
           else ()
        check x
        check y
    input
    |> List.iter checkOutOfBoard
    // 同じマスに二度打ちチェック
    let checkDuplicate lis =
        lis
        |> List.fold (fun acc x -> if acc |> List.exists (fun y -> x = y) then failwith "2度も同じ手を打ちましたね" else x::acc) []
        |> ignore
    input
    |> checkDuplicate
    // 本処理
    if input.Length < 5 then Undefined
    else if input.Length % 2 = 1 then AWin else BWin


module TestUtils =
    let ``エラーになるべき`` input =
        try
            resolve input |> ignore
            false
        with
        | _ -> true

module Tests =
    open TestUtils
    let ``プレイヤーABそれぞれが横方向に置いてみる`` =
       let input = [(0,0); (1,0); (0,1); (1,1); (0,2)]
       resolve input = AWin

    let ``プレイヤーBが勝つパターン`` =
       let input = [(0,0); (0,1); (0,2); (1,1); (1,0); (2,1)]
       resolve input = BWin

    let ``プレイヤーAが勝つパターン`` =
       let input = [(0,0); (0,1); (1,0); (2,0); (1,1); (2,2); (1,2)]
       resolve input = AWin

    let ``3目並べの盤面の外に置いたらエラー`` =
        [
            ``エラーになるべき`` [(-1,0)]
            ``エラーになるべき`` [(0,-1)]
            ``エラーになるべき`` [(3,2)]
            ``エラーになるべき`` [(2,3)]
            ``エラーになるべき`` [(0,0); (-1,-1)] // 2手目以降に盤面外でもエラーになる
        ]
        |> List.forall id

    let ``同じマスに2回置いたらエラー`` =
       ``エラーになるべき`` [(0,0); (1,0); (0,1); (0,0)]

    let ``勝敗が決まらない`` =
        let input = [(0,0); (0,1); (1,0); (1,1)]
        resolve input = Undefined

    let ``リストの末尾まで行く前に勝敗が決定する`` =
       let input = [(0,0); (1,0); (0,1); (1,1); (0,2); (1,2)]
       resolve input = AWin

さて後編、「3目並べは果たして実装されるのか、n * n マス m 目並べは?」、期待しないでください。

以上!

*1:ブログ記事のネタにしやすい何か

*2:要出典

*3:謎ではない

*4:要出典

*5:要出典

*6:Visual Studioのエディタ上でコードを範囲選択して、右クリックから送信するかAlt+Enterで送信

*7:コンパイルできなかったり、テストでチェック結果がNGとなった場合のことをRedと呼びます。

*8:コンパイルが通ってテストのチェック結果が全てOKとなった状態をGreenと呼びます。