Writing Python the Rust Way

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:

  1. It relies on runtime checks, which can impact performance.
  2. The code becomes cluttered with error-handling logic.
  3. 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:

  1. Type Safety: Each card type has its own clearly defined properties, making it impossible to create invalid cards at runtime.
  2. Exhaustive Matching: When handling different card types, we can use pattern matching to ensure we've covered all cases.
  3. Self-Documenting Code: The structure itself communicates the rules and types of cards in the game.
  4. Early Error Detection: Many errors can be caught at compile-time or by static type checkers, rather than at runtime.
  5. 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.