Writing Python the Rust Way
UNO Card Game Implementation
The Problem: A Naive Approach
Let's start with a naive implementation of UNO card logic:
class UnoCard:
def __init__(self, color, value, is_wild=False):
self.color = color
self.value = value
self.is_wild = is_wild
def valid_to_play(card, top_card):
if top_card is None:
return True
if card.is_wild:
return True
if card.color == top_card.color:
return True
if card.value == top_card.value:
return True
return False
# Example usage:
red_7 = UnoCard("red", "7")
blue_7 = UnoCard("blue", "7")
green_skip = UnoCard("green", "skip")
wild = UnoCard("red", "wild", True) # Wild card with a color
wild_draw_4 = UnoCard("red", "wild draw 4", True)
print(valid_to_play(blue_7, red_7)) # True (same number)
print(valid_to_play(green_skip, red_7)) # False
print(valid_to_play(wild, green_skip)) # True (wild card)
print(valid_to_play(wild_draw_4, green_skip)) # True (wild draw 4)
This implementation seems to work at first glance, but it's prone to errors:
red_7 = UnoCard("red", "7")
red_5 = UnoCard("Red", 5)
blue_7 = UnoCard("blue", 7)
print(valid_to_play(red_5, red_7)) # False ("Red" is not equal to "red")
print(valid_to_play(red_7, blue_7)) # False ("7" is not equal to 7)
The Pitfall: Defensive Coding
A common approach to address these issues is to use defensive coding. Here's how we might modify our implementation:
class UnoCard:
VALID_COLORS = {"red", "green", "blue", "yellow"}
VALID_VALUES = set(range(10)) | {"skip", "reverse", "draw 2", "wild", "wild draw 4"}
def __init__(self, color, value, is_wild=False):
if not is_wild and color.lower() not in self.VALID_COLORS:
raise ValueError(f"Invalid color: {color}")
if str(value).lower() not in self.VALID_VALUES:
raise ValueError(f"Invalid value: {value}")
self.color = color.lower() if not is_wild else None
self.value = str(value).lower()
self.is_wild = is_wild
def valid_to_play(card, top_card):
if not isinstance(card, UnoCard) or (top_card is not None and not isinstance(top_card, UnoCard)):
raise TypeError("Both cards must be UnoCard instances")
if top_card is None:
return True
if card.is_wild:
return True
if card.color == top_card.color:
return True
if card.value == top_card.value:
return True
return False
While this approach catches more errors, it has several drawbacks:
- It relies on runtime checks, which can impact performance.
- The code becomes cluttered with error-handling logic.
- It doesn't leverage the compiler or static type checkers to catch errors early.
A Better Way: Leveraging Python's Type System
Instead of relying on defensive coding, we can take inspiration from Rust's type system and apply similar principles in Python. By using Python's type hinting, enums, and dataclasses, we can create a more structured and safer implementation.
Here's how we can model UNO cards using a more robust type system:
from dataclasses import dataclass
from enum import Enum, IntEnum, auto
class Color(Enum):
RED = auto()
GREEN = auto()
BLUE = auto()
YELLOW = auto()
@dataclass
class NumberCard:
class Number(IntEnum):
ZERO = 0
ONE = 1
TWO = 2
THREE = 3
FOUR = 4
FIVE = 5
SIX = 6
SEVEN = 7
EIGHT = 8
NINE = 9
color: Color
number: Number
@dataclass
class ActionCard:
class Action(Enum):
SKIP = auto()
REVERSE = auto()
DRAW_2 = auto()
color: Color
action: Action
@dataclass
class WildCard:
class WildType(Enum):
DRAW_4 = auto()
WILD = auto()
color: Color | None
wild_type: WildType
type UnoCard = NumberCard | ActionCard | WildCard
def valid_to_play(card: UnoCard, top_card: UnoCard | None) -> bool:
def valid_wild(card: UnoCard) -> bool:
match card:
case WildCard(color=color):
return color is not None
case _:
return False
def same_color(card: UnoCard, top_card: UnoCard) -> bool:
return card.color == top_card.color
def same_value(card: UnoCard, top_card: UnoCard) -> bool:
match card, top_card:
case NumberCard(number=number1), NumberCard(number=number2):
return number1 == number2
case ActionCard(action=action1), ActionCard(action=action2):
return action1 == action2
case _:
return False
match top_card:
case WildCard(color=None):
raise ValueError("Player must specify a color if using a wild card")
case _:
return (
top_card is None
or valid_wild(card)
or same_color(card, top_card)
or same_value(card, top_card)
)
This approach offers several advantages:
- Type Safety: Each card type has its own clearly defined properties, making it impossible to create invalid cards at runtime.
- Exhaustive Matching: When handling different card types, we can use pattern matching to ensure we've covered all cases.
- Self-Documenting Code: The structure itself communicates the rules and types of cards in the game.
- Early Error Detection: Many errors can be caught at compile-time or by static type checkers, rather than at runtime.
- Clean Code: The implementation focuses on game logic rather than error checking, making it more readable and maintainable.
By leveraging Python's type system, we've created a more robust implementation that enforces invariants at the type level, rather than through defensive coding. This approach leads to cleaner, more expressive code that closely models the domain it represents, while also catching potential errors early in the development process.