TL;DR
ソースコード:
https://github.com/sh1928kd/pyblackjack
元記事様:
プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし
タイトルに強い言葉1を盛り込みましたが、大した内容ではないよう
まえがき
タイトルの通り、副作用を極力排除、局所化するよう意識して、ブラックジャック開発を進めてみました。
『副作用』という言葉が指す意味合いは、文脈や個々人によって大きく揺らぐ場合がある、と理解しています。
この記事においては、「(関数内に)代入式がない」ことを以って「副作用がない(関数から副作用が排除されている)」ものとしています。
なお、副作用を排除することそのもののメリットについては、本題から脱線してしまうので触れません。
また、お仕事ではないので、興味はあっても実践する機会を持てずにいた、型ヒントを積極的に採用しています。
検証環境
- Windows10 Pro 64bit
- Python 3.6.0 | Anaconda 4.3.0 (64-bit)
ソースコードの解説
実装イメージを固めるにあたり、ドメインモデルを意識した設計を心がけました。
「ブラックジャック」にまつわるドメインオブジェクトを考えた結果、以下の要素を抽出しました。
- (トランプの)カード
- カードの束
- デッキ
- 手札
- ゲームの参加者
- ディーラー(CPU)
- ゲスト(実行者)
- (ブラックジャックの)ルール
- 参加者の得点計算式
- 勝敗の判断
- (ゲストの)選択肢
- ヒット(カードを1枚引く)
- スタンド(手札で勝負する)
お仕事ではないので、UML等による作図は行わず、脳内でのイメージを元に、順に実装を進めました。
Cardクラス
トランプのカードそのものが持つ情報は、常にスートとランクだけです。
ブラックジャックの得点の計算方法は、ブラックジャックのルールが決めています。
カード自身は知る由もなく、このクラスでは取り扱うべきではないでしょう。
スートをマルチバイト文字列で管理
コミットメッセージに絵文字を使う文化も育まれている昨今ですから、Enum型の値もSNS映えを気にしていきたいですよね。
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です。
手札は参加者が所持するカードの束
「デッキ」はカードの束クラスから分離しましたが、一方の「手札」はどうするべきでしょうか。
「手札」は、参加者が所有するカードの集まりです。「手札」をどのように扱うかは、参加者に委ねられます。
「手札」そのものは特別な役割を持たないことから、「デッキ」のように分離せずとも済むはずです。
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を表現すれば、解決します。
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を足す」としています。
勝敗判定は考えるのが面倒だったので一般的なルールよりはディーラー有利になっています。
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枚の手札が配られた後、カードをドローするか、手札で勝負するかを選択できます。
プログラムの実行者による入力が必須であり、厳密な意味での副作用は避けられないため、局所化に留まりました。
@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クラス
力尽きました。
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)
あとがき
普段はオブジェクト指向でプログラミングしているけれど、関数プログラミングも気になる、けれど習得には至らない……と、もやもやしている中、オブジェクト指向にもすんなり採用しやすい「副作用」に焦点を当ててみました。
概念は理解できているつもりでも、実装イメージが沸かないことが多々有りましたが、腰を据えて取り組んでみると、ふわっとしていた理解がより深まった気がします。
関数プログラミングに対する理解が深まった頃に、また取り組んでみたいです。
ご意見、ご指摘をお待ちしています。
参考
- Blackjack - Wikipedia
- なぜ関数プログラミングは重要か ("Why Functional Programming Matters" 日本語訳版)
- typing --- 型ヒントのサポート Python 3.6.5 ドキュメント