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