16
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Ramda.js / Folktale】JavaScriptで (なるべく) 関数型にブラックジャックを実装する

Last updated at Posted at 2019-05-20

改訂版のご案内
GitHub Pages にて、リファクタリング版の資料を公開しています

  • ドメインモデリングの精緻化
  • 型定義の導入
  • より高度な高階関数の活用

1. はじめに

プログラミング初心者卒業試験の題材として取り上げられることのある「ブラックジャック」ですが、
JavaScript上で関数型プログラミングを行うといったテーマで実装してみました。

なお、今回以下のライブラリを積極的に使っていきます:

  • Ramda.js: Underscoreやlodashよりも細かくユーティリティ関数を提供しており、カリー化により関数合成にも柔軟に対応可能
  • Folktale: 各種モナドのJavaScript向け実装を提供する

ブラックシャックには細かくルールがありますが、簡単のため以下の方針で実装します:

  • 初期手札は2枚とする
  • プレーヤーは山札から交互に1枚づつ、カードを引く
  • プレーヤーとディーラーがそれぞれ手札を引き終わったタイミングで得点計算する
  • 得点計算の後、ゲームの続行はプレーヤーが決定する

2. プログラムの実装

プログラムの全体像はGistにアップロードしてあります↓
https://gist.github.com/Guvalif/19dc33579df68c140f6b6d6f013078eb

2.1 外部ライブラリの読み込み

Ramda.jsとは別に、今回は Maybe モナド,Either ( Result ) モナド,および同期的に標準入力を読み取る question 関数を使用します。

const R = require('ramda');
const Maybe = require('folktale/maybe');
const Result = require('folktale/result');
const { question } = require('readline-sync');

2.2 データの定義

今回は:

  • トランプを、「文字」として定義する (e.g. 'A',…,'J''Q''K' )
  • トランプのポイントを、「数値配列」として定義する (e.g. 'A'[1, 11] )
  • ジョーカーを除いたトランプの束を、「文字配列」として定義する

上記の要領で、データを定義します。

const CARDS = {
    'A'  : [1, 11],
    'N2' : [2],
    'N3' : [3],
    'N4' : [4],
    'N5' : [5],
    'N6' : [6],
    'N7' : [7],
    'N8' : [8],
    'N9' : [9],
    'N10': [10],
    'J'  : [10],
    'Q'  : [10],
    'K'  : [10]
};

const SPADE_SUIT   = R.keys(CARDS);
const HEART_SUIT   = R.keys(CARDS);
const DIAMOND_SUIT = R.keys(CARDS);
const CLUB_SUIT    = R.keys(CARDS);
const SUIT         = [ ...SPADE_SUIT, ...HEART_SUIT, ...DIAMOND_SUIT, ...CLUB_SUIT ];

2.3 ポイントの取得

次に:

  • トランプが与えられたら、ポイントを返す関数 ( string -> [number] )
  • 手札 (トランプの配列) が与えられたら、ポイントの配列を返す関数 ( [string] -> [[number]] )

上記を実装します。

// == toPoint :: string -> [number]
const toPoint = R.prop(R.__, CARDS);

// == suitToPoints :: [string] -> [[number]]
const suitToPoints = R.map(toPoint);

R.prop はプロパティアクセスを行う関数です。今回、CARDS にオブジェクトとして (トランプ,ポイント) の組を保持しているので、例えば R.prop('A', CARDS)[1, 11] を返してきます。R.__プレースホルダ (仮置きの引数) を意味し、R.prop 関数の第1引数を後から与えても大丈夫なようにしてあります。

そして、手札をポイントの配列に変換する [string] -> [[number]] なる関数を考えます。 R.mapA -> B から F<A> -> F<B> を作る高階関数なので、toPoint 関数を持ち上げることでこれを実現しています。

2.4 スコアの計算

次に:

  • 配列が2つ与えられたら、全ての組み合わせを足し合わせて配列で返す関数 ( [number] -> [number] -> [number] )
  • ポイントの配列が与えられたら、スコアの組み合わせを配列で返す関数 ( [[number]] -> [number] )

上記を実装します。

// == addA2 :: [number] -> [number] -> [number]
const addA2 = R.lift(R.add);

// == getScores :: [[number]] -> [number]
const getScores = R.reduce(addA2, [0]);

まず、1つ目の関数が意味するところは、$\mathbb{A} = [a_0, a_1, \cdots, a_n]$ と $\mathbb{B} = [b_0, b_1, \cdots, b_n]$ が与えられたときに、$\mathbb{A} \oplus \mathbb{B} = [a_0 + b_0, a_0 + b_1, \cdots, a_1 + b_0, a_1 + b_1, \cdots, a_n + b_n ]$ となるような $\oplus$ を考えたい、ということです。

今回詳細は控えますが、これは配列の「アプリカティブ (Applicative)」としての性質を用いることで、簡単に実装することができます。(アプリカティブは、ファンクターとモナドの中間にあたる概念です。)

そのような2引数関数 $\oplus$ を実装できれば、あとはそれを R.reduce に初期値 $\mathbb{A} = [0]$ とともに渡すことで、スコアの組み合わせを配列で得る関数ができあがります。

\mathrm{reduce}(\oplus, \mathbb{A}, [\mathbb{B}_0, \mathbb{B}_1, \cdots, \mathbb{B}_n]) = ((((\mathbb{A} \oplus \mathbb{B}_0) \oplus \mathbb{B}_1) \oplus \mathbb{B}_2) \oplus \cdots) \oplus \mathbb{B}_n

2.5 有効なスコアの抽出

次に、全てのスコアの組み合わせがブラックジャックにとって有効ではないので、これを適切に抽出する関数を実装します。

// == getValidScore :: [number] -> Maybe<number>
const getValidScore = R.pipe(
    R.filter(R.gte(21)),
    R.ifElse(
        R.isEmpty,
        R.always(Maybe.Nothing()),
        R.o(Maybe.Just, R.reduce(R.max, 0))
    )
);

R.pipeR.ifElse によって、以下のような流れが作られています:

  • 受け取った引数を R.filter(R.gte(21)) に流し、21以下の値のみの配列を作る
  • R.isEmpty により、↑の結果が空配列かを調べる
    • もし空配列の場合、Maybe.Nothing に変換する
    • そうでない場合:
      • R.reduce(R.max, 0) によって配列内の最大値を求める
      • ↑の結果を Maybe.Just に変換する

R.always は、いかなる値を受け取っても固定の値を返す関数 _ => CONST を、作るための高階関数です。これにより、有効なスコアが存在しない場合 Maybe.Nothing() が最終的な出力となります。

R.o は、2つの関数用の R.compose です。Maybe.Just $\circ$ R.reduce(R.max, 0) とすることで、有効なスコアの最大値を Maybe.Just に包んで返すことができます。

2.6 スートのシャッフル

次に、シャッフルされたスートを返す関数を実装します。この部分に関しては、手続き型プログラミングの手法で記述しています。

// == getShuffledSuit :: [string] -> [string] !impure
const getShuffledSuit = (suit) =>
{
    let shuffled_suit = R.clone(suit);

    for (let i = suit.length - 1; i > 0; i--)
    {
        const j = Math.floor(Math.random() * (i + 1));

        [ shuffled_suit[i], shuffled_suit[j] ] = [ shuffled_suit[j], shuffled_suit[i] ];
    }

    return shuffled_suit;
};

乱数の生成,添え字の参照,配列の書き換えが頻繁に行われるようなアルゴリズムの場合、場合によっては手続き型プログラミングの手法の方が、簡潔かつわかりやすく記述できるかと思います。

2.7 スートの分割

次に:

  • スートが与えられたら、偶数番目だけを抜き出したスートを返す関数 ( [string] -> [string] )
  • スートが与えられたら、奇数番目だけを抜き出したスートを返す関数 ( [string] -> [string] )

上記を実装します。

// == getEvenSuit :: [string] -> [string]
const getEvenSuit = R.addIndex(R.filter)((_, i) => i % 2 === 0);

// == getOddSuit :: [string] -> [string]
const getOddSuit = R.addIndex(R.filter)((_, i) => i % 2 !== 0);

R.addIndex は、単一引数関数を受け取る高階関数に、各処理ステップにおけるインデックスを付与する高階関数です。

これと R.filter により:

  • インデックスが偶数の要素だけを選択する関数
  • インデックスが奇数の要素だけを選択する関数

上記を簡潔に実装することができます。

2.8 ゲーム続行の確認

次に、ゲームの続行を確認する関数を実装します。この部分に関しては、手続き型プログラミングの手法で記述しています。

// == continuePrompt :: () -> () !impure
const continuePrompt = () =>
{
    process.stdin.isTTY = process.stdout.isTTY = true;

    const input = question('Do you want to continue the game (Enter/Others) ? ');

    if (input === '') return;

    process.exit();
};

正直 Maybe モナド等を利用したもっと上手い方法がありそうですが、いったんこれを使います。(リファクタリング案があればぜひコメントまで。)

2.9 メインルーチンの実装

最後に、ここまでで作成した関数群を組み合わせて、メインルーチンを構築します。

const SHUFFLED_SUIT = getShuffledSuit(SUIT);
const PLAYER_SUIT   = getEvenSuit(SHUFFLED_SUIT);
const DEALER_SUIT   = getOddSuit(SHUFFLED_SUIT);

// == main :: number -> ()
const main = (hand) =>
{
    const scores = R.map(
        R.pipe(
            R.take(hand),
            suitToPoints,
            getScores,
            getValidScore
        ),
        { player: PLAYER_SUIT, dealer: DEALER_SUIT }
    );

    const results = R.map(
        m => m.fold(
            () => Result.Error('Bust!'),
            x  => (x === 21) ? Result.Error('Black Jack!') : Result.Ok(x)
        ),
        scores
    );

    const situation = results.player.chain(R.always(results.dealer));

    const report     = R.map(m => m.merge(), results);
    const report_txt = `Player: ${report.player}, Dealer: ${report.dealer}`;

    console.log(report_txt);

    situation.fold(
        _ => process.exit(),
        _ => { continuePrompt(); main(hand + 1); }
    );
};

main(2);

大まかな処理の流れは以下の通りです:

  1. シャッフルしたスートを用意する
  2. ↑のスートをプレイヤー用,ディーラー用に分割する
  3. 各プレイヤーは手札を2枚増やす
  4. スコアを計算する
    • 有効なスコアが存在しないかブラックジャックの場合、Either.Left 相当の値に変換する
    • 有効なスコアが存在する場合、Either.Right 相当の値に変換する
  5. ↑の結果が Either.Left 相当の値であれば、メッセージを表示し終了する
    • そうでなければ、現在のスコアを表示し、手札を1枚増やして再度4の処理から実行する

スコアの計算は R.pipe によって:

  • R.take(hand) : 配列の先頭から、指定した個数を部分配列として返す関数
  • suitToPoints : トランプの配列が与えられたら、ポイントの配列を返す関数
  • getScores : ポイントの配列が与えられたら、スコアの組み合わせを配列で返す関数
  • getValidScore : 有効なスコアの抽出を行う関数

上記をそれぞれ繋ぎ合わせることで実現しています。以下の部分がそれにあたります:

const scores = R.map(
    R.pipe(
        R.take(hand),
        suitToPoints,
        getScores,
        getValidScore
    ),
    { player: PLAYER_SUIT, dealer: DEALER_SUIT }
);

Ramda.jsではオブジェクトに対して R.map を適用すると、全ての value に対して関数を適用してくれます。これにより、プレイヤー側,ディーラー側のスコアをそれぞれ計算しています。


そして、算出されたスコアに対して:

  • Maybe.Nothing の場合は、Either.Left ( Result.Error ) でゲームオーバーの旨を、
  • Maybe.Just で値が 21 の場合は、Either.Left ( Result.Error ) でゲームクリアの旨を、
  • Maybe.Just で値が 21 以外の場合は、Either.Right ( Result.Ok ) で現在のスコアを、

与えるような変換を、.fold メソッドで行います。以下の部分がそれにあたります:

const results = R.map(
    m => m.fold(
        () => Result.Error('Bust!'),
        x  => (x === 21) ? Result.Error('Black Jack!') : Result.Ok(x)
    ),
    scores
);

最後に、Either ( Result ) モナドをチェインすることで、2プレーヤーの内1人でもゲーム終了となるプレーヤーがいないかを判断します。( situation というモナド値が下表に従って変化することを利用しています。)

results.player results.dealer situation
Either.Right Either.Right Either.Right
Either.Right Either.Left Either.Left
Either.Left Either.Right Either.Left
Either.Left Either.Left Either.Left

また、results オブジェクトに含まれる全ての Either モナドから .merge メソッドで値を取り出し、各ゲームターンにおける結果を文字列として report_txt に代入します。

そして、situation モナドを .fold メソッドで畳み込み:

  • Either.Left の場合は、ゲームを終了
  • Either.Right の場合は、continuePrompt 関数で続行を確認し、続行する場合 main 関数を手札を1枚増やして再帰呼び出し

上記によって、ゲームの進行を実現します。

const situation = results.player.chain(R.always(results.dealer));

const report     = R.map(m => m.merge(), results);
const report_txt = `Player: ${report.player}, Dealer: ${report.dealer}`;

console.log(report_txt);

situation.fold(
    _ => process.exit(),
    _ => { continuePrompt(); main(hand + 1); }
);

3. 実際の動作イメージ

実行すると、以下のような結果が得られます:

Player: 15, Dealer: 5
Do you want to continue the game (Enter/Others) ?
Player: 15, Dealer: 12
Do you want to continue the game (Enter/Others) ?
Player: Bust!, Dealer: 17

4. 感想

奇しくも非純粋な関数 getShuffledSuit ,および continuePrompt が手続き型での実装となっているので、かっこよくリファクタリングしたいところですね。あとはモノイドとかを使えるときれいにかけそうな部分がちらほら。

何はともあれ、mapfilterreduceMaybeEither を単独で扱うだけの関数型プログラミングの紹介ではなく、複合的に利用したサンプルプログラムを作れた (と思っている) ので、満足です。

「こうするとより良いんじゃない?」など、ぜひお気軽にコメントいただければ幸いです :)

16
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?