Re: FSharperならこう書く(A Poker Game Program) #FsAdvent
FSharperならこう書く(A Poker Game Program) #FsAdventというエントリがF# Advent Calendar 2014の8日目の記事として公開されました。 この記事自体は、主に設計よりな話をしていますが、ここでは実装寄りの話を中心に、 勝手に添削してみました。 また、記事の中に書かれていた疑問にも回答したりしています。
添削その1(全体)
型とモジュールの名前
元のコードでは、モジュールの中にT
という型を入れるスタイルのようです。
(* 柄 *) module Suit = (* 柄 *) type T = | Spade | Heart | Diamond | Club (* 全ての柄 *) let values = [Spade; Heart; Diamond; Club]
自分も昔はこんな感じにしていたのですが、現在のところこのスタイルはやめ、モジュール名と型名に同じ名前を付けるスタイルを採用しています。
type Suit = Spade | Heart | Diamond | Club [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module Suit = let values = [Spade; Heart; Diamond; Club]
なんでかというと、
あたりが大きな理由です。
CompilationRepresentation
とかCompilationRepresentationFlags.ModuleSuffix
とか面倒ですが、覚えてしまえばどうということはないですよ。
回答その1(Card)
レコード生成の制限
こんな疑問が書かれていました。
番号は、1から13に含まれない値は生成できないようにしたいので、classになりそうです。 classにするとinterfaceの実装が煩わしいところですが、constructorを制限しようと思うと、レコードや判別共用体では厳しそうです。 もし他に良い方法をご存知の方がいらっしゃったら教えてください。
これに対する回答としては、2通りあります。
- シグネチャファイルで型の実装を隠蔽する
private
で型の実装を隠蔽する
前者はfsiファイルを書かなければならず、面倒というのと、型の実装を隠蔽してしまうとcomparison
制約とかも自分でやる必要が出てきてそれも面倒なので、今回はお手軽にprivate
で隠蔽する方法を紹介します。
元のコードはこんな感じです。
(* 番号 *) module Number = type T(value:int)= do (* 数値範囲チェック *) if not (min <= value && value <= max) then failwith "The Number is required 1 to 13."
これを、型名を変更しつつレコードで実現してみましょう。
type Number = private { Value: int } [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module Number = let min = 1 let max = 13 let value x = x.Value let create x = (* 数値範囲チェックをこちらに書く *) if not (min <= x && x <= max) then failwith "The Number is required 1 to 13." { Value = x }
こうすることによって、それを含むモジュール(ここではファイルと思ってください)の外からは{ Value = 42 }
のようには書けなくなりますし、当然let f (x: Number) = x.Value
というコードもコンパイルエラーになります。
パターンマッチも(それを含むモジュールの外では)できなくなるので注意してくださいね。
IComparable
レコードにしたため、IComparable
等の実装が不要になりましたが、元のバージョンではこれらを実装していました。
さて、これらを実装しているのはいいのですが、実は大小比較だけしたいだけであれば実装するインターフェイスはかなり減らせます。
type T(value: int) = (* snip *) member this.Value = value interface System.IComparable with member this.CompareTo(other) = match other with | :? T as x -> compare value x.Value | _ -> failwith "other is not T" (* snip *)
これだけで、警告は出るものの=
も<
も他の比較演算子も使えるクラスになります。
このあたりは、言語仕様(PDF)の8.15.3を参照してください。
ざっくり説明すると、System.IComparable
を明示的に実装している場合、Equals
メソッドは
override x.Equals(y : obj) = ((x :> System.IComparable).CompareTo(y) = 0)
という実装が生成されるという感じです。
CompareTo
はジェネリック版ではなく非ジェネリック版が呼び出されるので、System.IComparable
だけ実装すればよい、ということですね。
また、compare
関数を使えばCompareTo
メソッドは簡単に実装できます。
ただし、これに頼る場合は警告が出るみたいです。言語仕様にはその記述はなかったように思いますが・・・
添削その2(Deck)
単純なfor式
山札を表すDeck
モジュールのdeck_without_joker
関数ですが、一番内側のfor
式はyield
しか含みません。
(* デッキ(ジョーカー抜き) *) let deck_without_joker = List.ofSeq(seq{ (* シーケンス式の二重ループでNumberとSuitの組み合わせを列挙する *) for num in Card.Number.min .. Card.Number.max do for suit in Card.Suit.values do yield { Card.Number = Card.Number.create num; Card.Suit = suit } })
このような場合、do yield
の代わりに->
が使えます。
名前もlikeThis
形式に変えておきましょう。
let deckWithoutJoker = List.ofSeq (seq { for num in Card.Number.min .. Card.Number.max do for suit in Card.Suit.values -> { Card.Number = Card.Number.create num; Card.Suit = suit } })
添削その3(List)
take関数の名前
既存のList
モジュールに足りない処理を追加するために、独自のList
モジュールを作っています。
その中で、take
という関数を追加しているんですが、これは標準のtake
を隠してしまううえ、やっていることもtake
ではない気がします。
やっていることは、count
でのリストの分割ですので、splitAt
がいいでしょう。
名前は、Hoogleで探しました。
また、option
を返す関数はF#ではtry
プレフィックスを付けるのが慣習ですので、trySplitAt
としてみました。
let trySplitAt count list = let rec loop i acc tail = if i = count then Some ((List.rev acc), tail) else match tail with | [] -> None | h::t -> loop (i + 1) (h::acc) t loop 0 [] list
tail |> function
としている部分は、好みもありますが、個人的にはmatch tail with
の方が好きなのでその部分も変更してあります。
また、Deck.pick
もoption
を返すので、Deck.tryPick
にしました。
回答その2(Hand)
単一ラベルの判別共用体
こんな疑問がありました。
単一ラベルの判別共用体ってどうなのでしょうか。
これは、ありだと思います。 FsControlを使うと、単一ラベルの判別共用体を多用するようになります(そうじゃない)。
添削その4(Hand)
System.String.Join
.NETの便利関数にSystem.String.Join
というのがあるのですが、
これはBasis.CoreのStr.joinを使いましょう。
嘘です。String
モジュールのconcat
関数を使いましょう。
ToString
ToString
の呼び出しは面倒なので、string
関数を使いましょう。
コードは2つ下を見てください。
List.toArray呼び出し
String.concat
は'T seq
を受け取り、'T list
は'T seq
なのでtoArray
する必要はありません。
コードは1つ下を見てください。
逆向きパイプライン演算子
ToString
の実装で、逆向きのパイプライン演算子を使っています。
(* CUIの選択肢表示用ToString *) member this.ToString indexes = this |> function | Hand xs -> xs |> List.map2 (fun x y -> sprintf "[%s]:%s" y (x.ToString()) ) <| indexes (* 逆向きパイプライン演算子<|はロマン *) |> List.toArray |> curry System.String.Join "/"
ここは、||>
の出番です。
member this.ToString indexes = match this with | Hand xs -> (xs, indexes) ||> List.map2 (fun x y -> sprintf "[%s]:%s" y (string x)) |> String.concat "/"
あ、curry
関数は使わなくなったので削除しました。
引数の位置でのパターンマッチ
createBy
関数やchange
関数で、レコードや判別共用体のパターンマッチをしている部分が何か所かあります。
(* 山札から最初のcount枚を取得して手札を作り、残りの山札とペアにして返す。 山札の枚数が足りない場合はNone *) let createBy deck = Deck.pick count deck |> Option.map (fun pickResult -> (* let束縛のレコードパターン *) let { Deck.Picked = picked; Deck.Deck = d } = pickResult ((Hand picked), d) ) (* 手札のうちchangeCardsで指定されたカードを捨て、山札から同じ枚数を加え、残りの山札とペアにして返す。 *) let change hand deck changeCards = Deck.pick (Set.count changeCards) deck |> Option.map (fun pickResult -> let { Deck.Picked = picked; Deck.Deck = d } = pickResult let hand = hand |> function | Hand xs -> xs |> List.filter (changeCards.Contains>>not) Hand (List.append picked hand), d )
これらは、引数の位置でパターンマッチすればいいので、こう書き直せます。
let tryCreateBy deck = Deck.tryPick count deck |> Option.map (fun { Deck.Picked = picked; Deck.Deck = d } -> ((Hand picked), d)) (* ↑ラムダ式の引数の位置でレコードを分解 *) (* ↓関数の引数の位置で判別共用体を分解 *) let change (Hand xs) deck changeCards = Deck.tryPick (Set.count changeCards) deck |> Option.map (fun { Deck.Picked = picked; Deck.Deck = d } -> (* ↑ラムダ式の引数の位置でレコードを分解 *) let hand = xs |> List.filter (changeCards.Contains >> not) Hand (List.append picked hand), d )
添削その5(Rank of Hands)
役の強弱
F#の話ではないですが、役の強弱がおかしい気がしますね。 一般的なルールに従って、修正します。
type Rank = | OnePair | TwoPair | ThreeCards | Straight | Flush | FullHouse | FourCards | StraightFlush | RoyalStraightFlush
型の別名
役判定関数モジュールで、Card.NormalCard array
という型名を大量に使っています。
こういう場合、型略称を定義してしまうと楽でしょう。
type private Cards = Card.NormalCard array
pairwise
straight
やflush
の判定に、前のカードと今のカードをペアにしたシーケンスを得るために、
xs |> Seq.ofArray |> (fun xs -> Seq.zip xs (Seq.skip 1 xs)) |> Seq.forall (fun t -> (fst t).Number ++ 1 = (snd t).Number)
というコードが出てきます。
これは、Seq.pairwise
というそのままな関数があるので置き換えてしまいましょう。
cards |> Seq.pairwise |> Seq.forall (fun t -> (fst t).Number ++ 1 = (snd t).Number)
タプルを引数の位置で分解する
上のコードはラムダ式の中でfst
とsnd
を使っているので、これを引数の位置でのパターンマッチにしてしまいましょう。
cards |> Seq.pairwise |> Seq.forall (fun (x, y) -> x.Number ++ 1 = y.Number)
配列 vs リスト
型略称を用意したCards
は現在、配列です。
リストではなく配列にしたのは、
引数の型を配列にしたのは、インデックスでのアクセスが多そうだったから、という程度の理由です。
とありました。 しかし、リストでもインデックスでアクセスできるうえ、引数として渡される配列もしくはリストの要素数は、5です。 この部分がクリティカルに効いてくるとは思えないので、リストに変更しました。
type private Cards = Card.NormalCard list
もし問題が起こったら、この型略称の部分を戻して、呼び出し部分の型を変えればいいでしょう。
SequenceEqual
royalStraightFlush
の判定で、System.Linq.Enumerable.SequenceEqual
を使って判定していますが、
List
をそのまま扱えば=
で比較できるので、変更してしまいましょう。
let royalStraightFlush (cards: Cards) = (straightFlush cards) && ( let royal = [10..13]@[1] (cards |> List.map (fun x -> x.Number |> Card.Number.value)) = royal )
List.filter Option.isSomeしてList.map Option.get
Evaluator.evaluate
等の関数で、List.filter Option.isSome
してからList.map Option.get
している箇所があります。
evals |> List.map (fun f -> f x) |> List.filter Option.isSome |> List.map Option.get
こういった処理には、List.choose
という関数が使えます。
evals |> List.map (fun f -> f x) |> List.choose id
回答その3(Rank of Hands)
判別共用体の大小
判別共用体の大小に関して、
大小比較が定義順に依存するのはちょっとしたポイントです。ソースは忘れました。
とありました。 言語仕様を参照してみると、8.15.4に、
If
T
is a union type, invokeMicrosoft.FSharp.Core.Operators.compare
first on the index of the union cases for the two values, and then on each corresponding field pair ofx
andy
for the data carried by the union case. Return the first non-zero result.
とあります。
- 2つの値の共用体ケースのインデックスが
Microsoft.FSharp.Core.Operators.compare
に渡される - 付随するデータがある場合、それらが
Microsoft.FSharp.Core.Operators.compare
に渡される 0
ではない最初の値が返される
ということのようですね。 共用体ケースのインデックスは、定義順に振られる*1ため、大小比較が定義順に依存するのです。
添削その6(メイン処理)
リストの生成
input_choices
の生成に、リストリテラルを使っています。
let input_choices = ["a"; "b"; "c"; "d"; "e"]
好みはわかれるかもしれませんが、ここは
let inputChoices = ['a'..'e'] |> List.map string
のように、文字のリストを..
で作ってから文字列化したほうが楽な気がします。
まとめ
テストとかしてない*2ので動くかどうかわからないですが、コード全体はGistに上げました。
元のコードがとても読みやすく素晴らしかったです。 引き続き、F# Advent Calendarをお楽しみください!