Python でトランプカードを作る方法 n 選 (クラスの書き方紹介)

こんにちは。日々プログラミングをしていると、ふとトランプカードが欲しくなるときがありますよね。そこで今回はいろんな方法で52枚のトランプカードを Python で表現する方法を紹介します。

前提

話を簡単にするため、この記事では4つのスート(♠, ♣, ♡, ♢)と13種類の数字(A, 1, 2, ..., 10, J, Q, K)からなる52枚を作ります。Joker はお好みのやりかたで ad-hoc に足してください。

方法その1: 文字列を全列挙

シンプルに文字列を全部書いて入れます。

deck = ['♠A', '♠2', '♠3', '♠4', '♠5', '♠6', '♠7', '♠8', '♠9', '♠10', '♠J', '♠Q', '♠K', '♣A', '♣2', '♣3', '♣4', '♣5', '♣6', '♣7', '♣8', '♣9', '♣10', '♣J', '♣Q', '♣K', '♡A', '♡2', '♡3', '♡4', '♡5', '♡6', '♡7', '♡8', '♡9', '♡10', '♡J', '♡Q', '♡K', '♢A', '♢2', '♢3', '♢4', '♢5', '♢6', '♢7', '♢8', '♢9', '♢10', '♢J', '♢Q', '♢K']

「さっき Python に入門しました!!」という人でも分かる実装ですが、数字同士の比較やマーク同士の比較がかなり厳しいので、あまりよい実装とは言えないでしょう。
若干効率化するには

deck = []
numbers = ['A', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
suits = ['♠', '♣', '♡', '♢']
for suit in suits:
    for num in numbers:
        deck.append(suit + num)

とか、あるいはリスト内包を使って

numbers = ['A', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
suits = ['♠', '♣', '♡', '♢']
deck = [suit + num for suit in suits for num in numbers]

とかやる方法もあります。

方法その2: 各カードをタプルで表現

カードは要はスートと数字のペアがあれば特定できるので、長さが2のタプルで表現できます。例えば第一要素については 0=♠, 1=♣, 2=♡, 3=♢ という風に約束し、第二要素については 1=A, 2=2, ..., 13=K という風に約束すれば、次のように書くことができます。

deck = [(suit, num) for suit in range(4) for num in range(1, 14)]

今回は数字をちゃんと数字として持っているので、大きさの比較が簡単にできるようになっています。

card0 = deck.pop()
card1 = deck.pop()
print(card0[1] > card1[1])  # True or False

方法その3: 各カードを名前付きタプルで表現

「第一要素と第二要素が何かとか覚えてらんねーよ」という向きのために、card.suit や card.number でアクセスできるようにします。こういう目的のためには名前付きタプル(named tuple)が便利です。

from collections import namedtuple

Card = namedtuple("Card", ("suit", "number"))
deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

Card という名前付きタプルのクラスを新しく作り、Card インスタンスとして個々のカードを表現しています。ドット記法を使って書く要素にアクセスできるので、より可読性が高いです。

card0 = deck.pop()
card1 = deck.pop()
print(card0.number > card1.number)  # True or False
print(card0[1] > card1[1])  # タプルなので index でのアクセスも可能

名前付きタプルは collections.py に入っているものの他に、typing.py に入っている NamedTuple もあります。こちらは namedtuple と比較すると

  1. NamedTuple を継承してクラスを定義するような形式で書く
  2. 各要素の型を指定する必要がある

といった違いがあります。その他はあまり変わりません。お好みの方を使いましょう(私は typing のほうが好きです)。

from typing import NamedTuple

class Card(NamedTuple):
    suit: int
    number: int

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

〜追記(2021/5/30)〜
NamedTuple も namedtuple のように一文で書くことが可能です。

Card = NamedTuple("Card", [("suit", int), ("number", int)])

やはり型を付けるのを忘れないように。なお型を指定したくない場合は from typing import Any して、Any 型と指定すればOKです。

方法その4: 単純なクラスとして表現

「index で値をアクセスできる必要はないんじゃね?」みたいに tuple の構造が too much に感じる人は、シンプルにクラスで定義すればよいでしょう。

class Card:
    def __init__(self, suit, number):
        self.suit = suit
        self.number = number

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

「値が書き換えられるのはちょっと・・・」という方はさらにメンバ変数の直前に __ を付けて秘匿し、@property で値を見られる形にするのもアリです。

class Card:
    def __init__(self, suit, number):
        self.__suit = suit
        self.__number = number

    @property
    def suit(self):
        return self.__suit

    @property
    def number(self):
        return self.__number

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

以下のように値を書き換えようとするとエラーが出ます。

card = deck.pop()
print(card.suit)  # スートの番号を表示
print(card.number)  # 数字を表示
card.number = 5  # エラー!
card.__number = 5  # こちらもエラー!
card._Card__number = 5  # 実はこれで書き換えられるので厳密には秘匿していない

方法その5: クラスとして表現し、見た目を整える

(最初の方法を除いて)ここまでの方法だと print したときのカードの見た目がイマイチです。そこで __str__ および __repr__ を追加します。これらは関数 str や関数 repr に入れたときの表示方法を制御します。

class Card:
    def __init__(self, suit, number):
        self.suit = suit
        self.number = number

    def __str__(self):
        suit_str = ['♠', '♣', '♡', '♢'][self.suit]
        number_str = '0 A 1 2 3 4 5 6 7 8 9 10 J Q K'.split()[self.number]
        return suit_str + number_str

    def __repr__(self):
        return f'Card({self.suit}, {self.number})'

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

__repr__ の方は原則として「その情報をみればオブジェクトを再構成できる」というのがお作法です。__str__ の中で作っているリストはこのクラスから作られたインスタンスなら共通なので、クラス変数として持っておく考え方もあるでしょう。

class Card:
    __suits = ('♠', '♣', '♡', '♢')  # 全インスタンス共通!
    __numbers = tuple('0 A 1 2 3 4 5 6 7 8 9 10 J Q K'.split())  # 全インスタンス共通!

    def __init__(self, suit, number):
        self.suit = suit
        self.number = number

    def __str__(self):
        suit_str = self.__suits[self.suit]
        number_str = self.__numbers[self.number]
        return suit_str + number_str

    def __repr__(self):
        return f'Card({self.suit}, {self.number})'

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

ところでこの実装をした時点で self.suit が 0~3 以外、self.number が 1~13 以外のときは変な挙動になるようになりました。トランプとしては本来あってはならない状態なので、初期化の時点で validation を挟んでおきましょう。

class Card:
    __suits = ('♠', '♣', '♡', '♢')
    __numbers = tuple('0 A 1 2 3 4 5 6 7 8 9 10 J Q K'.split())

    def __init__(self, suit, number):
        if suit not in range(4) or number not in range(1, 14):
            raise ValueError
        self.suit = suit
        self.number = number

    def __str__(self):
        suit_str = self.__suits[self.suit]
        number_str = self.__numbers[self.number]
        return suit_str + number_str

    def __repr__(self):
        return f'Card({self.suit}, {self.number})'

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

あるいは先ほどの @property の方法に追加して setter を使って validation を加えることもできます。ついでに上書きを禁止しておくことも可能です。

class Card:
    __suits = ('♠', '♣', '♡', '♢')
    __numbers = tuple('0 A 1 2 3 4 5 6 7 8 9 10 J Q K'.split())

    def __init__(self, suit, number):
        self.suit = suit  # setter の関数が呼ばれるので実際は self.__suit に代入
        self.number = number  # setter の関数が呼ばれるので実際は self.__number に代入

    @property
    def suit(self):
        return self.__suit

    @suit.setter
    def suit(self, value):
        if value not in range(4):
            raise ValueError
        if hasattr(self, "_Card__suit"):  # なぜか変形した方で指定しないとだめっぽい
            raise Exception("cannot overwrite suit")
        self.__suit = value

    @property
    def number(self):
        return self.__number
    
    @number.setter
    def number(self, value):
        if value not in range(1, 14):
            raise ValueError
        if hasattr(self, "_Card__number"):  # なぜか変形した方で指定しないとだめっぽい
            raise Exception("cannot overwrite number")
        self.__number = value

    def __str__(self):
        suit_str = self.__suits[self.suit]
        number_str = self.__numbers[self.number]
        return suit_str + number_str

    def __repr__(self):
        return f'Card({self.suit}, {self.number})'

deck = [Card(suit, number) for suit in range(4) for number in range(1, 14)]

この方法はかなり細かい制御を可能にしますが、反面コードが長くなりすぎる嫌いがあるので、場合によっては too much かもしれません。

方法その6: Enum を活用

先ほどの方法ではスートや数字の表示をタプルで管理しました。こういった定数の類を扱うのに、Enum という方法があります。

from enum import Enum, auto

class Suit(Enum):
    SPADE = auto()
    CLUB = auto()
    HEART = auto()
    DIAMOND = auto()

値の取る範囲が決まっているような対象がある場合にいい仕事をします。ここで auto() は適当な数値を自動で割り当てる関数で、項目を増やしても実行時に適当に数値を割り当ててくれます。詳しくは公式ドキュメントなどを読んでみてください。
Enum として定義した状態で、個々にマークを割り当てておきます。

from enum import Enum

class Suit(Enum):
    SPADE = '♠'
    CLUB = '♣'
    HEART = '♡'
    DIAMOND = '♢'

    def __str__(self):
        return self.value  # 上のイコールの右側の値

    def __repr__(self):
        return f"Suit.{self.name}"  # 上のイコールの左側の文字列

鋭い方は「クラス変数なのになぜインスタンスの関数っぽく書くの?」と疑問に思われたかと思いますが、実は Enum はちょっと特殊で、Suit.SPADE, Suit.CLUB, Suit.HEART, Suit.DIAMOND というインスタンスが生成されるような振る舞いをします。つまりは以下とだいたい同じイメージです(細々とした挙動は違います):

class Suit:
    def __init__(name, value):
        self.name = name
        self.value = value

SPADE = Suit('SPADE', '♠')
CLUB = Suit('CLUB', '♣')
HEART = Suit('HEART', '♡')
DIAMOND = Suit('DIAMOND', '♢')

この性質を活用して、各スートごとの性質をここにまとめておけば見通しが良くなります。value に当たる部分は複数の値を持たせることも可能で、例えば

from enum import Enum

class Suit(Enum):
    SPADE = ('♠', True)
    CLUB = ('♣', True)
    HEART = ('♡', False)
    DIAMOND = ('♢', False)

    def __init__(self, string, is_black):
        self.string = string  # 例えば Suit.SPADE なら '♠'
        self.is_black = is_black  # 例えば Suit.SPADE なら True

    def __str__(self):
        return self.string

    def __repr__(self):
        return f"Suit.{self.name}"


print(Suit.SPADE.is_black)  # True
print(Suit.HEART.is_black)  # False

というように属性を手際よくまとめられます。量が増えてきたらタプル部分を NamedTuple に置き換えてもよいかもしれません。
Enum を使って Card クラスを再定義するとこんな感じになります。

from enum import Enum


class Suit(Enum):
    SPADE = '♠'
    CLUB = '♣'
    HEART = '♡'
    DIAMOND = '♢'

    def __str__(self):
        return self.value

    def __repr__(self):
        return f"Suit.{self.name}"


class Number(Enum):
    ACE = (1, 'A')
    TWO = (2, '2')
    THREE = (3, '3')
    FOUR = (4, '4')
    FIVE = (5, '5')
    SIX = (6, '6')
    SEVEN = (7, '7')
    EIGHT = (8, '8')
    NINE = (9, '9')
    TEN = (10, '10')
    JACK = (11, 'J')
    QUEEN = (12, 'Q')
    KING = (13, 'K')

    def __init__(self, val, string):
        self.val = val
        self.string = string

    def __str__(self):
        return self.string

    def __repr__(self):
        return f"Number.{self.name}"


class Card:
    def __init__(self, suit, number):
        if not (isinstance(suit, Suit) and isinstance(number, Number)):
            raise ValueError  # Enum じゃないとエラー
        self.suit = suit
        self.number = number

    def __str__(self):
        return str(self.suit) + str(self.number)

    def __repr__(self):
        return f"Card({self.__str__()})"


# Enum を継承したクラスは中身を取り出すイテラブルとしても使える!
deck = [Card(suit, number) for suit in Suit for number in Number]  
card0 = deck.pop()
card1 = deck.pop()
print(card0.number.val > card1.number.val)  # True or False

個々のカードに関する性質を Enum 側に押し付けたので Card の実装はスッキリしました。一方今回各カードに持たせている属性は大したことのない量なので、行数的にはやや too much かもしれません。ケースバイケースで使うか考えてみてください。

方法その7: 山札もクラス化する

最後に山札もクラス化して、山札をシャッフルする機能や「上から一枚引く」機能を自然に使えるようにしてみましょう。基本的にはリストとして振る舞うものを作りたいので、最初から list を継承して作ってみます。

from random import shuffle

# ~ 中略 ~

class Deck(list):
    def __init__(self):
        super().__init__(
            Card(suit, number) for suit in Suit for number in Number
        )  # list の初期化を呼び出す
        self.shuffle()  # 最初にシャッフル

    def shuffle(self):
        shuffle(self)

    def draw(self):
        return self.pop()


deck = Deck()
card0 = deck.draw()
card1 = deck.draw()
print(card0.number.val > card1.number.val)  # True or False

もはや内部構造を気にせずとも、ほぼほぼ普通のトランプを扱っているような使用感になりました!

まとめ

最後の方の手法を使ったものをまとめておきます。

from enum import Enum
from random import shuffle


class Suit(Enum):
    SPADE = '♠'
    CLUB = '♣'
    HEART = '♡'
    DIAMOND = '♢'

    def __str__(self):
        return self.value

    def __repr__(self):
        return f"Suit.{self.name}"


class Number(Enum):
    ACE = (1, 'A')
    TWO = (2, '2')
    THREE = (3, '3')
    FOUR = (4, '4')
    FIVE = (5, '5')
    SIX = (6, '6')
    SEVEN = (7, '7')
    EIGHT = (8, '8')
    NINE = (9, '9')
    TEN = (10, '10')
    JACK = (11, 'J')
    QUEEN = (12, 'Q')
    KING = (13, 'K')

    def __init__(self, val, string):
        self.val = val
        self.string = string

    def __str__(self):
        return self.string

    def __repr__(self):
        return f"Number.{self.name}"


class Card:
    def __init__(self, suit, number):
        if not (isinstance(suit, Suit) and isinstance(number, Number)):
            raise ValueError  # Enum じゃないとエラー
        self.suit = suit
        self.number = number

    def __str__(self):
        return str(self.suit) + str(self.number)

    def __repr__(self):
        return f"Card({self.__str__()})"


class Deck(list):
    def __init__(self):
        super().__init__(
            Card(suit, number) for suit in Suit for number in Number
        )  # list の初期化を呼び出す
        self.shuffle()  # 最初にシャッフル

    def shuffle(self):
        shuffle(self)

    def draw(self):
        return self.pop()


deck = Deck()
card0 = deck.draw()
card1 = deck.draw()
print(card0.number.val > card1.number.val)  # True or False

おまけ: ゲームごとの「カードの強さ」を表現するには

一般的なトランプはすでにできましたが、カード同士の強さを比較したい場合がよくあります。例えば大富豪だと

3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A < 2

という順序があります(革命とか Joker とか無視して)。これを使いやすくするためには、Card クラスに不等号を定義すれば便利です。

class DaifugoCard(Card):
    def _strength(self):
        return (self.number.val - 3) % 13  # 3->0, 4->1, 5->2, ..., A->11, 2->12

    def __gt__(self, other):  # greater than, > のこと
        if isinstance(other, DaifugoCard):
            return self._strength() > other._strength()
        raise NotImplemented  # 二項関係が定まらないとき専用の例外

    def __ge__(self, other):  # greater or equal, >= のこと
        if isinstance(other, DaifugoCard):
            return self._strength() >= other._strength()
        raise NotImplemented

    def __lt__(self, other):  # less than, < のこと
        if isinstance(other, DaifugoCard):
            return self._strength() < other._strength()
        raise NotImplemented

    def __le__(self, other):  # less equal, <= のこと
        if isinstance(other, DaifugoCard):
            return self._strength() <= other._strength()
        raise NotImplemented

Deck クラスもこちらの Card で構成するように書き換えればOKです。こういうことをすることを考えるなら、Deck の __init__ の引数に Card としてどのようなクラスを使うかを持ってもよいかもしれません。

おまけその2

よりクラスでゴテゴテにした感じの実装を github で公開しました↓
github.com