In Progress
Unit 1, Lesson 21
In Progress

Surrogate Ordering

Video transcript & code

Suppose we have a task to build a class to represent playing cards. Every card has a rank, such as "5" or "Jack", and a suit, such as hearts or diamonds.

class Card
  attr_reader :rank, :suit

  def initialize(rank, suit)
    @suit = suit
    @rank = rank
  end
end

When instantiating Card objects, we'll use numbers to represent the numbered cards, and symbols for the face cards. Suits will always be symbols.

require "./card"

Card.new(2, :hearts)
Card.new(:jack, :clubs)

One feature we might want to have in such a class is the ability to compare two cards to find out which is more valuable. Of course, the rules for this relative ordering of cards varies from card game to card game. For the sake of this example, we'll use Bridge ordering.

As we know from Episode #205, we can implement all of the comparison operators in one shot by including Comparable and defining the "spaceship" operator.

class Card
  include Comparable
  attr_reader :rank, :suit

  def initialize(rank, suit)
    @suit = suit
    @rank = rank
  end

  def <=>(other)
    # ...
  end
end

But how shall we define the spaceship operator? Ordinarily, we might do it something like this: first, check that the other object is also a Card. Then throw both of the attributes that matter to the card's ordering into an array, and then compare that array to another array of the same properties from the other object. Ruby's Array defines the spaceship operator to compare each array element with the spaceship in turn. So this is an easy way to compare multiple attributes at once.

def <=>(other)
  return nil unless other.is_a?(Class)
  [rank, suit] <=> [other.rank, other.suit]
end

But this won't work for our Card class! In Bridge, as in many games, aces are high. But of course when we compare a 1 to a 2, Ruby considers the 1 to be lower. And there is no comparison possible at once symbols enter the mix.

1 <=> 2                         # => -1
1 <=> :queen                    # => nil

I run into problems like this periodically in programming. I'll have an object that needs to be compared to other objects using some external ordering scheme. Usually, the first thing I think of is to define some sort of mapping between attribute values and numeric rankings. So for instance the ace would map to 14, the King to 13, the Queen to 12, and so on.

class Card
  RANKS = {
    1      => 14,
    :king  => 13,
    :queen => 12,
    :jack  => 11,
    # ...
  }

But this is tedious and also hard to maintain. What happens if we have to change the ordering or add a new value? We might wind up having to renumber most of the entries.

There is a much simpler technique, which I eventually remember if I think about if long enough. Instead of a map, we can put all of the possible values into an array, from lowest-scored to highest-scored.

RANKS = [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, 1]
SUITS = [:clubs, :diamonds, :hearts, :spades]

The great thing about these arrays is that they implicitly define a mapping from values to scores for us. However, to look up these mappings we have to use the array "backwards" from how we usually do. Most of the time we use the subscript operator to find out what element is at a given index in an array. But in this case, we need to find out the index of a given element.

To do this, we use the #index method. It takes a value and returns the first index at which that value can be found. Looking up 2, we see it has an index of 0. The Jack has an index of 9. And the ace has an index of 12.

RANKS = [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, 1]

RANKS[0]                        # => 2
RANKS.index(2)                  # => 0
RANKS.index(:jack)              # => 9
RANKS.index(1)                  # => 12

Not only is this way of defining the relative worth of different values more concise, but if we ever have to add or move a value, the others will be renumbered automatically.

Now that we have our scoring, let's define a pair of helper methods: #rank_ordinal and #suit_ordinal. Each will return the index of the given value.

def rank_ordinal
  RANK_ORDER.index(rank)
end

def suit_ordinal
  SUIT_ORDER.index(suit)
end

And now we have what we need to define the spaceship operator. Instead of comparing ranks and suits directly, we compare the #rank_ordinal and #suit_ordinal values. Together these methods provide a surrogate ordering for our Card class.

def <=>(other)
  return nil unless other.is_a?(Class)
  [rank_ordinal, suit_ordinal] <=> [other.rank_ordinal, other.suit_ordinal]
end

One thing to remember when using arrays for comparisons is that the higher-order properties should go earlier in the arrays. A three will always outrank a two no matter what the suit is, so we put the rank ordinals at the start of the arrays.

And there we have it: cards that can be compared to each other for relative worth, without too much effort.

class Card
  include Comparable

  RANKS = [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, 1]
  SUITS = [:clubs, :diamonds, :hearts, :spades]

  attr_reader :suit, :rank

  def initialize(rank, suit)
    fail ArgumentError unless SUITS.include?(suit)
    fail ArgumentError unless RANKS.include?(rank)
    @suit = suit
    @rank = rank
  end

  def <=>(other)
    return nil unless other.is_a?(self.class)
    [rank_ordinal, suit_ordinal] <=> [other.rank_ordinal, other.suit_ordinal]
  end

  def rank_ordinal
    RANKS.index(rank)
  end

  def suit_ordinal
    SUITS.index(suit)
  end
end

Let's try a few comparisons, just to test it out. A 3 of hearts is less than a 4 of hearts. The ace of hearts is not lesser than the 4 of clubs. And the jack of clubs is lesser than the jack of diamonds. Looks like our surrogate ordering is working!

require "./card3"

Card.new(3, :hearts) < Card.new(4, :hearts) # => true
Card.new(1, :clubs) < Card.new(4, :clubs) # => false
Card.new(:jack, :clubs) < Card.new(:jack, :diamonds) # => true

One last thing: as a result of defining lists of valid values for suit and rank, we get a bonus feature almost for free: inside the initializer, we can now add guard clauses that check the validity of arguments against the lists.

def initialize(rank, suit)
  fail ArgumentError unless SUITS.include?(suit)
  fail ArgumentError unless RANKS.include?(rank)
  @suit = suit
  @rank = rank
end

OK, that's it for today. Happy hacking!

Responses