LoginSignup
12
10

More than 5 years have passed since last update.

Pythonによる副作用を排除した『ブラックジャック』開発

Posted at

TL;DR

ソースコード:
https://github.com/sh1928kd/pyblackjack

実行結果:
blackjack.PNG

元記事様:
プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし

タイトルに強い言葉1を盛り込みましたが、大した内容ではないよう

まえがき

タイトルの通り、副作用を極力排除、局所化するよう意識して、ブラックジャック開発を進めてみました。

『副作用』という言葉が指す意味合いは、文脈や個々人によって大きく揺らぐ場合がある、と理解しています。
この記事においては、「(関数内に)代入式がない」ことを以って「副作用がない(関数から副作用が排除されている)」ものとしています。
なお、副作用を排除することそのもののメリットについては、本題から脱線してしまうので触れません。

また、お仕事ではないので、興味はあっても実践する機会を持てずにいた、型ヒントを積極的に採用しています。

検証環境

  • Windows10 Pro 64bit
  • Python 3.6.0 | Anaconda 4.3.0 (64-bit)

ソースコードの解説

実装イメージを固めるにあたり、ドメインモデルを意識した設計を心がけました。
「ブラックジャック」にまつわるドメインオブジェクトを考えた結果、以下の要素を抽出しました。

  • (トランプの)カード
  • カードの束
    • デッキ
    • 手札
  • ゲームの参加者
    • ディーラー(CPU)
    • ゲスト(実行者)
  • (ブラックジャックの)ルール
    • 参加者の得点計算式
    • 勝敗の判断
  • (ゲストの)選択肢
    • ヒット(カードを1枚引く)
    • スタンド(手札で勝負する)

お仕事ではないので、UML等による作図は行わず、脳内でのイメージを元に、順に実装を進めました。

Cardクラス

トランプのカードそのものが持つ情報は、常にスートとランクだけです。
ブラックジャックの得点の計算方法は、ブラックジャックのルールが決めています。
カード自身は知る由もなく、このクラスでは取り扱うべきではないでしょう。

スートをマルチバイト文字列で管理

コミットメッセージに絵文字を使う文化も育まれている昨今ですから、Enum型の値もSNS映えを気にしていきたいですよね。

card.py
class Card:
    @enum.unique
    class Suit(enum.Enum):
        CLUB = '♧'
        DIA = '♢'
        HEART = '♡'
        SPADE = '♤'

        def __str__(self):
            return self.value

    @enum.unique
    class Rank(enum.Enum):
        ACE = 1
        TWO = 2
        THREE = 3
        FOUR = 4
        FIVE = 5
        SIX = 6
        SEVEN = 7
        EIGHT = 8
        NINE = 9
        TEN = 10
        JACK = 11
        QUEEN = 12
        KING = 13

    def __str__(self):
        return f'{self._suit.value}{self._rank.value}'

    def __init__(self, suit: Suit, rank: Rank) -> None:
        self._suit = suit
        self._rank = rank

    def suit(self) -> Suit:
        return self._suit

    def rank(self) -> Rank:
        return self._rank

Cardsクラス/Deckクラス

カードの束とデッキとを(親子関係に)分離

ブラックジャックに限らず、カードを「ドロー」するのは、必ず「デッキ」から2です。
誤ってディーラーの「手札」から「ドロー」してしまわないためにも(!)、「デッキ」は明確に分離します。

手札は参加者が所持するカードの束

「デッキ」はカードの束クラスから分離しましたが、一方の「手札」はどうするべきでしょうか。
「手札」は、参加者が所有するカードの集まりです。「手札」をどのように扱うかは、参加者に委ねられます。
「手札」そのものは特別な役割を持たないことから、「デッキ」のように分離せずとも済むはずです。

card.py
class Cards(collections.abc.Sequence):
    def __getitem__(self, key):
        return self._cards[key]

    def __len__(self):
        return len(self._cards)

    def __add__(self, other: 'Cards') -> 'Cards':
        if not isinstance(other, Cards):
            raise TypeError(
                f'unsupported operand type(s) for +: '
                f"'{self.__class__.__name__}' and '{other.__class__.__name__}'"
            )
        return Cards(self[:] + other[:])

    def __init__(self, cards: typing.Sequence[Card]=tuple()) -> None:
        self._cards = cards


class Deck(Cards):
    @staticmethod
    def oneset() -> typing.Sequence[Card]:
        return tuple(
            Card(suit, rank)
            for suit in Card.Suit for rank in Card.Rank)

    def __init__(self, cards: typing.Sequence[Card]=None) -> None:
        super().__init__(cards or Deck.oneset())

    def shuffle(self) -> 'Deck':
        return Deck(tuple(random.sample(self, k=len(self))))

Playerクラス

ディーラーとゲスト、分離するべきか、せざるべきか

ブラックジャックには、ゲームを進行するディーラーと、参加者であるゲストと、二通りの人物が登場します。
それぞれ、ゲーム中における役割や選択肢は異なるため、クラスも分離するべき、とも考えられます。
一方で、どちらも同じルールのもとで、手札の得点を比べることになるため、人物そのものに役割を与えるべきではない、という見方もできます。

1対多、のようなルール追加を考慮するのであれば、前者のほうが好都合かもしれません。
ブラックジャック以外のゲームへの対応も考えるのであれば、ルールの役割を依存させやすい後者に軍配が上がりそうです。
その他、掛け金の取り扱い等、ゲームをどのように拡張するかに依るところが大きい部分だと思います

如何様にもできそうですが、今回の実装では、後者を採用しています。

「ドローする」のは「参加者」

カードの束とデッキとを(親子関係に)分離 で取り上げたとおり、「ドローされる」のは「デッキ」であり、「ドローする」のは「参加者」です。

ドローと副作用

「ドロー」という用語は「(参加者が)カードを(デッキトップから)引く」という動作を意味します。
つまり、「デッキから、トップのカードを取り上げる」、「(参加者の)手札に、取り上げたカードを加える」という、2つのドメインオブジェクトに対する作用が生じます。
これを、プログラムで、副作用なく表現するためには、どうすればよいでしょうか。

「ドローする」のは「参加者」 ですので、Playerクラスにdrawメソッドを実装することになるでしょう。
まずは、drawメソッドを、インスタンスメソッドとして実装した場合を考えます。
drawメソッドを呼び出したPlayerインスタンスは、自身の手札であるhandに対して、「カードを加える」という副作用を生むことになりそうです。
気をつけて実装すれば、副作用を生まない実装もできますが、インスタンスメソッドである以上、その可能性の排除はできません。

この副作用を生む可能性は、Playerインスタンスのインスタンス変数へのアクセスを制限できれば、解消できます。
つまり、Playerクラスのクラスメソッドとしてdrawメソッドを実装し、引数と戻り値で、draw前後のPlayerとDeckを表現すれば、解決します。

player.py
class Player:
    @staticmethod
    def draw(
        player: 'Player',
        deck: Deck,
        num: int=1
    ) -> typing.Tuple['Player', Deck]:
        if num not in range(1, len(deck) + 1):
            return player, deck
        return (
            Player(player.hand() + Cards(deck[:num]), name=player.name()),
            Deck(deck[num:]))

    def __init__(self, hand: Cards=Cards(), *, name: str='no name') -> None:
        self._hand = hand
        self._name = name

    def hand(self) -> Cards:
        return self._hand

    def name(self) -> str:
        return self._name

BlackjackRuleクラス

この記事のキモだった、Player.draw()の解説が済んだので、ここからは駆け足気味になります。

ブラックジャックのルールは、自分で考えるまでもなく、決められている通り愚直に実装しています。

エースの得点計算は、「基本的には1として扱い、エースを持っていて合計点が11以下なら10を足す」としています。
勝敗判定は考えるのが面倒だったので一般的なルールよりはディーラー有利になっています。

blackjack.py
class BlackjackRule:
    @staticmethod
    def point(player: Player) -> int:
        hand = player.hand()
        result = sum(min(card.rank().value, 10) for card in hand)
        if result <= 11 and Card.Rank.ACE in (card.rank() for card in hand):
            return result + 10
        return result

    @staticmethod
    def busts(player: Player) -> bool:
        return BlackjackRule.point(player) > BLACKJACK

    @staticmethod
    def judge(*, dealer: Player, guest: Player) -> Player:
        if BlackjackRule.busts(guest):
            return dealer
        if BlackjackRule.busts(dealer):
            return guest
        if BlackjackRule.point(dealer) < BlackjackRule.point(guest):
            return guest
        return dealer

GuestDecisionクラス

ゲストは、最初に2枚の手札が配られた後、カードをドローするか、手札で勝負するかを選択できます。
プログラムの実行者による入力が必須であり、厳密な意味での副作用は避けられないため、局所化に留まりました。

blackjack.py
@enum.unique
class GuestDecision(enum.Enum):
    HIT = enum.auto()
    STAND = enum.auto()

    @staticmethod
    def decide() -> 'GuestDecision':
        command_table = dict((v.name.lower()[0], v) for v in GuestDecision)
        print(f'{"/".join(v.name.title() for v in command_table.values())}?')
        while True:
            command = input(f'[{",".join(command_table.keys())}]: ')
            if command in command_table:
                return command_table[command]
            else:
                print(f'Oops, "{command}" is invalid!')

Blackjackクラス

力尽きました。

blackjack.py
class Blackjack:
    @staticmethod
    def _show_hand(player: Player):
        print("{}'s cards: {}".format(
            player.name(),
            ' '.join(str(card) for card in player.hand())))

    @staticmethod
    def _result(*, dealer: Player, guest: Player):
        print('')
        print("* Judge *")
        print(f'{BlackjackRule.judge(dealer=dealer, guest=guest).name()} win!')

    @staticmethod
    def play(deck: Deck=Deck().shuffle().shuffle()):
        print('********************')
        print('* Blackjack')

        dealer = Player(name='Dealer')
        guest = Player(name='Guest')

        for _ in range(2):
            dealer, deck = Player.draw(dealer, deck)
            guest, deck = Player.draw(guest, deck)

        print('')
        print(f"{dealer.name()}'s 1st card: " + str(dealer.hand()[0]))

        print('')
        print(f"* {guest.name()}'s turn *")
        Blackjack._show_hand(guest)
        while not BlackjackRule.busts(guest):
            decision = GuestDecision.decide()
            if decision is GuestDecision.STAND:
                break
            if decision is GuestDecision.HIT:
                guest, deck = Player.draw(guest, deck)
                Blackjack._show_hand(guest)
        else:
            print('Bust!')
            Blackjack._result(dealer=dealer, guest=guest)
            return

        print('')
        print(f"* {dealer.name()}'s turn *")
        Blackjack._show_hand(dealer)
        while not BlackjackRule.busts(dealer):
            if BlackjackRule.point(dealer) >= 17:
                break
            else:
                dealer, deck = Player.draw(dealer, deck)
                Blackjack._show_hand(dealer)
        else:
            print('Bust!')
            Blackjack._result(dealer=dealer, guest=guest)
            return

        Blackjack._result(dealer=dealer, guest=guest)

あとがき

普段はオブジェクト指向でプログラミングしているけれど、関数プログラミングも気になる、けれど習得には至らない……と、もやもやしている中、オブジェクト指向にもすんなり採用しやすい「副作用」に焦点を当ててみました。
概念は理解できているつもりでも、実装イメージが沸かないことが多々有りましたが、腰を据えて取り組んでみると、ふわっとしていた理解がより深まった気がします。
関数プログラミングに対する理解が深まった頃に、また取り組んでみたいです。

ご意見、ご指摘をお待ちしています。

参考


  1. i.e. 一意に解釈されづらい言葉 

  2. 例外もあるかもしれませんが、説明の簡単のため、見逃してください。 

12
10
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
12
10