Sorting objects by secondary key when first is equal

Viewed 114

How can I sort multiple objects by a secondary key when the first one is equal?

In my Book class I have the following method used for sorting

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

Now I need the books printed the same day (printed_on = other.printed_on) to be sorded by page_number.

Since with sort_by you can pass an array of keys, I have tried

 def <=>(other)
   [printed_on <=> other.printed_on, page_number <=> other.page_number]
 end

but I get

undefined method `>' for [1, 1]:Array

3 Answers

Arrays are compared lexicographically (bold emphasis mine):

Arrays are compared in an “element-wise” manner; the first element of ary is compared with the first one of other_ary using the <=> operator, then each of the second elements, etc… As soon as the result of any such comparison is non zero (i.e. the two corresponding elements are not equal), that result is returned for the whole array comparison.

[This is just a convoluted way of saying "Arrays are compared lexicographically".]

So you could simply use an Array for your comparison:

def <=>(other)
  [printed_on, page_number] <=> [other.printed_on, other.page_number]
end

Your method violates the contract of <=> (bold emphasis mine):

Your implementation of #<=> should return one of the following values: -1, 0, 1 or nil.

There are only four allowed return values for a conforming implementation of <=>:

  • -1: less-than
  • 0: equal
  • +1: greater-than
  • nil: uncomparable (partially ordered)

You are returning neither of those four, you are returning an Array. As always, if you break the contract, strange things can happen.

You are looking for:

[printed_on, page_number] <=> [other.printed_on, other.page_number]

<=> has to return a number (conventionally it should be 1, 0 or 1, however ruby allows any numeric value) or nil if comparison is not possible). Your implementation returns an array of two numbers which causing the issue as it is trying to compare said array with 0.

You can also chain the comparisons via nonzero? and ||:

def <=>(other)
  (printed_on <=> other.printed_on).nonzero? || page_number <=> other.page_number
end

The expression returns printed_on <=> other.printed_on if its result is non-zero, and page_number <=> other.page_number otherwise.

You might want to add a guard clause for non-Book objects:

def <=>(other)
  return nil unless other.is_a?(Book)

  (printed_on <=> other.printed_on).nonzero? || page_number <=> other.page_number
end
Related