Sonntag, 30. November 2014

Sort your Ruby objects!

Sorting non persisted data collections in Ruby is done by Enumerable#sort or its more flexible companion Enumerable#sort_by. And even persisted data has to be sorted by them in rare cases. A simple example:
%w(Ruby Python Javascript).sort
=> ["Javascript", "Python", "Ruby"]
or
%w(Ruby Python Javascript).sort_by(&:reverse)
=> ["Python", "Javascript", "Ruby"]
But what about a simple class like:
class Food
  attr_accessor :name, :kcal # acronym for kilocalories
  def initialize name, kcal
    @name = name
    @kcal = kcal
  end
end
Assuming the foods collection:
foods = [
  Food.new('Pear', 57), 
  Food.new('Apple', 52), 
  Food.new('Avocado', 164)
]
could be sorted with Enumerable#sort_by:
foods.sort_by(&:name)
=> [
  #<Food @name="Apple", @kcal=52>, 
  #<Food @name="Avocado", @kcal=164>, 
  #<Food @name="Pear", @kcal=57>
]
but can not with not be sorted with Enumerable#sort:
foods.sort
=> ArgumentError: comparison of Food with Food failed
because Ruby expects a sorting method in the Food class definition. Passing the sorting block works:
foods.sort { |a, b| a.name <=> b.name }
=> [
  #<Food @name="Apple", @kcal=52>, 
  #<Food @name="Avocado", @kcal=164>, 
  #<Food @name="Pear", @kcal=57>
]
in which String#<=> satisfies the sorting expectation.
The sorting logic is worth a move into the class definition itself:
class Food
  attr_accessor :name, :kilocalories
  def initialize name, kilocalories # acronym for kilocalories
    @name = name
    @kilocalories = kilocalories
  end

  def <=> other
    name <=> other.name
  end
end
which expects an object responding to a name method, which obviously should return a String. It works like:
foods.sort
=> [
  #<Food @name="Apple", @kcal=52>, 
  #<Food @name="Avocado", @kcal=164>, 
  #<Food @name="Pear", @kcal=57>
]
A more complicated sorting requirements can be defined like:
class Food
  attr_accessor :name, :kcal
  def initialize name, kcal
    @name = name
    @kcal = kcal
  end

  def <=> other
    return -(kcal <=> other.kcal) if name.first.eql? other.name.first
    name <=> other.name
  end
end
which compares the foods by their name, but if the first letter is equal, then sort compares them by their kilocalories but in descending order. Descending is achieved by inverting comparison value, which can be negative, zero or positive.
The rule (whereas 'a' is self and 'b' is the other):
  1. if a < b then return -1
  2. if a = b then return 0
  3. if a > b then return 1
  4. if a and b are not comparable then return nil
The result:
[
  Food.new('Pear', 57), 
  Food.new('Apple', 52), 
  Food.new('Avocado', 164)
].sort
=> [
  #<Food @name="Avocado", @kcal=164>, 
  #<Food @name="Apple", @kcal=52>, 
  #<Food @name="Pear", @kcal=57>
]
Please note that Avocado is before Apple, just because of its higher kilocalories.

Supported by Ruby 2.1.1