In Progress
Unit 1, Lesson 1
In Progress

Contextual Identity Part 1

Sometimes we identify a domain concept which is defined entirely in terms of its attributes, like a measurement or an X/Y coordinate. A popular choice for concepts like these is to model them as immutable Value Objects. But sometimes, the context an object lives in can complicate the question of whether it truly lacks a distinct identity of its own.

Video transcript & code

One of the classic programming exercises is to model a playing-card game, like Poker or Blackjack.

When we think about modeling a card game, the first object we think about modeling is usually a Card. After all, cards are the central artifact of any card game.

So let's consider a playing card. What kind of object is this? How should we model it?

When I look at a playing card and think about representing it in an object-oriented language, the first thought that springs to my mind is: this is a Value Object.

We've talked about Value Objects a fair amount on this show in the past. Value Objects are objects completely defined by their attributes.

In this case, the card is defined by its suit and its rank. Everything about its treatment and use in the game is determined by those two attributes.

When we talk about Value Objects, it's usually to differentiate them from entities. Entities are objects that are defined by having a unique identity.

Does a playing card have an identity? Well, I could tear this card up and replace it with a duplicate from a fresh deck, and the duplicate would play the exact same role. So it seems like identity is not a meaningful concept when it comes to cards.

Let's say we model cards as Value Objects using a simple Ruby struct.

To reinforce their role as Value Objects, we make them immutable after initialization.

We also supply a customized inspect method, to make them display more readably.

We'll create a deck as an array, with a full set of 52 cards.

To get a draw pile for a game, we'll duplicate the deck and shuffle it.

Then we'll deal 5 cards off the deck into a hand.

Let's dump this hand of cards.

Everything seems to have worked fine. We're well on our way to modeling a card game!

There's just one little problem: if we were in Vegas, right about now we'd find ourselves being escorted firmly to the door by some large men in black suits.

Because we just inadvertently cheated. There are now 57 cards in play in this game.

Card = Struct.new(:suit, :rank) do
  def initialize(*)
    super
    freeze
  end

  RANK_NAMES = [nil, "Ace", *(2..10), "Jack", "Queen", "King"]
  def inspect
    "Card(#{RANK_NAMES[rank]} of #{suit})"
  end
end
START_DECK = %w[Hearts Spades Clubs Diamonds]
               .product((1..13).to_a)
               .map{|r,s|
  Card[r,s]
}
deck = START_DECK.shuffle

hand = deck.take(5)
# => [Card(7 of Diamonds), Card(King of Hearts), Card(3 of Diamonds), Card(10 of Spades), Card(7 of Clubs)]

(deck + hand).size
# => 57

Why? Well, we used the take() method to draw cards from the deck. Unlike pop() or shift(), this method doesn't modify the underlying collection. So we've effectively pulled 5 cards out of our sleeves.

This is just one example of a mistake we might make. But the way we've chosen to model a card game, using containers full of value objects, means bugs like this are going to be a constant threat. We'll need to carefully audit every method that adds or removes cards from a deck, pile, or hand, to make sure it never allows cards to be accidentally removed from play, or duplicated.

We could say that this is really just an issue of constraints.

We could even write a constraints validation method that combines all of the current card collections in the game, sorts the result, and verifies that it is an identical set to the sorted starting deck.

In order to enable this, we have to make our cards sortable, by including Comparable and defining a comparison method.

Once that's done, we could regularly call our auditor method to make sure we haven't messed up.

Card = Struct.new(:suit, :rank) do
  def initialize(*)
    super
    freeze
  end

  RANK_NAMES = [nil, "Ace", *(2..10), "Jack", "Queen", "King"]
  def inspect
    "Card(#{RANK_NAMES[rank]} of #{suit})"
  end

  include Comparable
  def <=>(other)
    [rank, suit] <=> [other.rank, other.suit]
  end
end

START_DECK = %w[Hearts Spades Clubs Diamonds]
               .product((1..13).to_a)
               .map{|r,s|
  Card[r,s]
}

deck = START_DECK.shuffle

hand = deck.take(5)

def audit_game(*stacks)
  golden_deck = START_DECK.sort
  game_deck   = stacks.reduce(:+).sort
  unless golden_deck == game_deck
    fail "Security, escort this player off of the floor!"
  end
end

audit_game(deck, hand)
# =>

# ~> RuntimeError
# ~> Security, escort this player off of the floor!
# ~>
# ~> xmptmp-in6526KT1.rb:31:in `audit_game'
# ~> xmptmp-in6526KT1.rb:35:in `<main>'

But this seems like a clunky, after-the-fact approach. Not to mention an approach that promises to be a pain to maintain. We'll have to make sure that we are always supplying every active pile and hand to this method.

Honestly, it seems smelly that it's so easy to get our card collections into an inconsistent state in the first place.

Now let's add a new complication. Some card games, like Rummy, can be played with a doubled deck of cards. So instead of there being just one queen of hearts in the whole game, there are now two. And so on, for every other combination of rank and suit.

At first glance, it seems like this would reinforce the case for cards being Value Objects. After all, these two cards function identically. Two players who both had the queen of hearts in their hands could exchange the two cards, and their individual hands would be unchanged from the point of view of the game's rules.

On the other hand, if one of these queens gets lost under the table… or if a third queen mysteriously appears… then we have a problem.

So that's the modeling puzzle we're presented with. We have domain objects which for all the world seem like they should be value objects. But in the context of a card game, they take on some identity-like aspects. A given card can't ever have duplicates in play, and it can only belong to one collection at a time.

In the next episode, I'll show you an approach I've come up with for this problem. Until then, I encourage you to think it over for yourself. How would you model a card game? I'd love to hear your perspective in the comments.

Happy hacking!

Responses