Skip to Content
Course content

147: The Comparable Module Edge Cases

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of developers jump into the Comparable module thinking it's a "magic switch." They implement the spaceship operator <=>, include the module, and assume that any comparison they throw at the object will either be true or false. The biggest misconception I encounter is the belief that if <=> returns nil (which happens when two objects are incomparable), the resulting comparison—like < or >=—will simply return false.

Let's look at why that's a dangerous assumption. I used to do this myself until I crashed a production job because I forgot how Comparable actually wraps the spaceship operator.

class Version
  include Comparable
  attr_reader :value

  def initialize(value)
    @value = value
  end

  def <=>(other)
    return nil unless other.is_a?(Version)
    @value <=> other.value
  end
end

v1 = Version.new(1.0)
# This doesn't return false; it raises an ArgumentError
puts v1 < "1.0" 

If you run that, Ruby doesn't say "No, a Version is not less than a String." It throws an ArgumentError: comparison of Version with String failed. This happens because the Comparable module's implementation of < basically says: "Call <=>; if the result is nil, I have no idea what to do, so I'm crashing."

The "Nil implies False" Fallacy

The correction here is realizing that Comparable is not designed to handle type-mismatching gracefully on its own. The spaceship operator is the source of truth, and the other methods (<, >, between?) are just convenience wrappers. If the source of truth returns nil, the wrappers fail.

If you want your objects to be compared against different types without crashing, you have two choices. You can either make your <=> method more flexible (by coercion), or you have to accept that comparing incompatible types is a programmer error that should raise an exception. In my experience, coercion is usually the cleaner path if the comparison actually makes sense logically.

def <=>(other)
  # Coerce strings into Version objects so the comparison can actually happen
  other = Version.new(other) if other.is_a?(String)
  return nil unless other.is_a?(Version)
  
  @value <=> other.value
end

The Equality Divergence Trap

There is another edge case that trips people up: the relationship between Comparable and the == operator. By default, including Comparable provides an implementation of == that simply checks if (self <=> other) == 0.

The trap happens when you override == separately to be "helpful," but forget to update <=>. I once worked on a codebase where a User object was considered equal to another user if their IDs matched, but the <=> operator was comparing their created_at timestamps for sorting. This created a nightmare where user_a == user_b was true, but user_a <= user_b and user_a >= user_b were both true, yet Comparable's logic was internally fighting with the custom ==.

If you override ==, you must ensure it is logically consistent with your spaceship operator. If a == b is true, then a <=> b must return 0. If they diverge, you'll find that Ruby's Array#sort or Enumerable#min will behave unpredictably because they rely on the consistency of these operators.




📋 Practical Task

Implementing a Robust ProductRank Comparison

You are building a ranking system for a marketplace. You have a ProductRank class. Some products have a numerical rank (1 being the best), while others are "Unranked."

Your Task: Implement the ProductRank class so that it includes Comparable. You must handle the following edge cases:

  • If a ProductRank is compared to another ProductRank, compare their numerical values.
  • If one of the products is "Unranked" (represented by the value nil), it should always be considered "greater" (worse) than any ranked product.
  • If both are "Unranked", they are equal.
  • If the ProductRank is compared to something that is neither a ProductRank nor nil, it should return nil (allowing the standard ArgumentError to occur).
  • Ensure that == remains consistent with your <=> implementation.
# Test cases to satisfy:
rank1 = ProductRank.new(1)
rank2 = ProductRank.new(5)
unranked = ProductRank.new(nil)
unranked2 = ProductRank.new(nil)

puts rank1 < rank2      # Expected: true
puts rank1 < unranked   # Expected: true
puts unranked < rank1   # Expected: false
puts unranked == unranked2 # Expected: true
puts unranked < unranked2 # Expected: false
Rating
0 0

There are no comments for now.

to be the first to leave a comment.