2

I was researching how to fiddle with special sorting mechanisms in Ruby. I ended up rewriting this neat JavaScript solution in Ruby:

class SpecialStr
  include Comparable
  attr_accessor :str
  def initialize (str)
    @str = str
  end

  def <=> (other)
    self_num, self_string = @str.split(' ')
    other_num, other_string = other.str.split(' ')
    self_num > other_num ? 1 : other_num > self_num ? -1 :
      self_string > other_string ? -1 : 1
  end
end

arr = ['2 xxx', '20 axxx', '2 m', '38 xxxx', '20 bx', '8540 xxxxxx', '2 z']
arr_object = []
arr.each { |str| arr_object << SpecialStr.new(str) }
arr_object.sort! { |x, y| y <=> x }
output_arr = []
arr_object.each { |obj| output_arr << obj.str}
puts output_arr

This has the desired output (numbers descending, then strings ascending):

8540 xxxxxx
38 xxxx
20 axxx
20 bx
2 m
2 xxx
2 z

But the code seemed unnecessarily complicated. (Ruby's supposed to be more concise than JS!) So I asked myself (and now I ask you), why can't I just do this?

def <=> (other)
  self_num, self_string = self.split(' ')
  other_num, other_string = other.split(' ')
  self_num > other_num ? 1 : other_num > self_num ? -1 :
    self_string > other_string ? -1 : 1
end
arr = ['2 xxx', '20 axxx', '2 m', '38 xxxx', '20 bx', '8540 xxxxxx', '2 z']
arr.sort! { |x, y| y <=> x }
puts arr

This outputs incorrectly, based on sort as if I had not redefined <=>:

8540 xxxxxx
38 xxxx
20 bx
20 axxx
2 z
2 xxx
2 m

The code here is shorter, but doesn't work. It uses the version of <=> built into Ruby's Comparable module, rather than my attempt to override it. Why wasn't I able to override it? Can methods be overridden only inside of classes or modules? Is there a shorter way to write that first script in Ruby? (Sorry if this is a noob question, I'm a beginner.)

Community
  • 1
  • 1
globewalldesk
  • 544
  • 1
  • 3
  • 18

3 Answers3

3

The easiest way would be to split the string in a number and word, and sort by an Array of minus number (to get decreasing numbers) and word :

arr = ['2 xxx', '20 axxx', '2 m', '38 xxxx', '20 bx', '8540 xxxxxx', '2 z']

arr.sort_by! do |number_word|
  number, word = number_word.split
  [ -number.to_i, word ]
end

puts arr
# =>
# 8540 xxxxxx
# 38 xxxx
# 20 axxx
# 20 bx
# 2 m
# 2 xxx
# 2 z

When sorting arrays, the first element (-number) has priority. If both first elements are the same, the sort uses the second element (word).

Eric Duminil
  • 52,989
  • 9
  • 71
  • 124
  • Holy crap! That's a lot easier than anything I or the other people on that earlier thread thought of! Well done. Doesn't answer the question but totally solves the original problem! – globewalldesk Jan 15 '17 at 04:45
1

When you write

arr.sort! { |x, y| y <=> x }

that is equivalent to

arr.sort! { |x, y| y.<=>(x) }

i.e. it is calling y's version of the <=> (spaceship) operator. Since y is just a String this performs the default comparison for strings.

To write your code more concisely you can just write the custom comparison logic in the block being passed to sort!:

arr.sort! do |x, y|
  x_num, x_string = x.split(' ')
  y_num, y_string = y.split(' ')
  y_num > x_num ? 1 : x_num > y_num ? -1 :
    y_string > x_string ? -1 : 1
end

or alternatively, write it as a standalone method:

def my_compare(x, y)
  x_num, x_string = x.split(' ')
  y_num, y_string = y.split(' ')
  y_num > x_num ? 1 : x_num > y_num ? -1 :
    y_string > x_string ? -1 : 1
end

and call that from sort!:

arr.sort! { |x, y| my_compare(x, y) }

A couple of things that might help clarify:

In Ruby, there are no free-floating methods (i.e. methods not attached to a class or module). When you write def ... outside of any class or module the method is added to Object as an instance method. Strictly, there are unbound methods but even these need associating with an object before they can be called.

Next thing to keep in mind is where the default implementation of <=> comes from: it's on the Kernel module which is included by class Object.

So when you write def <=>(other)... outside of a class you are overriding the method for Object:

[1] pry(main)> method(:<=>).owner
=> Kernel
[2] pry(main)> def <=>(other)
[2] pry(main)*   puts "overridden!"
[2] pry(main)* end
=> :<=>
[3] pry(main)> method(:<=>).owner
=> Object

However, the String class overrides <=> itself. For comparing a string with another object String's implementation will be used in preference to the implementation in Object, even if you've overridden the method in Object.

However, if you have a class that doesn't have its own <=> (or an overriding implementation between it and Object in the class hierarchy) then your overridden method on Object will indeed be used:

[6] pry(main)> class Something; end
=> nil
[7] pry(main)> s1 = Something.new
=> #<Something:0x007fddb4431ba8>
[8] pry(main)> s2 = Something.new
=> #<Something:0x007fddb4469760>
[9] pry(main)> s1 <=> s2
overridden!
=> nil

Explanation of what was being demonstrated in pry

The first snippet is using the method method to grab hold of a method and then using owner to find out where in the class hierarchy that method is defined.

So another example:

class Animal
  def eat
    puts "Munch!"
  end
end

class Dog < Animal
  def bark
    puts "yap!"
  end
end

So if we've got a dog:

buddy = Dog.new

we can find out where its methods come from:

[10] pry(main)> buddy.method(:eat).owner
=> Animal
[11] pry(main)> buddy.method(:bark).owner
=> Dog

so in the original example we could see that <=> started out referring to the method from the Kernel module, but when we did def <=>... this added a method directly to Object which was now overriding the included method.

The second example was showing what happens when there's a minimal class without its own implementation of <=>. instance_methods(false) can show us the instance methods that are directly implemented on a class. The empty Something class doesn't have any :)

[14] pry(main)> Something.instance_methods(false)
=> []

so it will be using the inherited <=> method.

mikej
  • 65,295
  • 17
  • 152
  • 131
  • 1
    Or go straight to `arr.sort!(&method(:my_compare))` and skip the extra noise of the explicit block. – mu is too short Jan 14 '17 at 18:23
  • Very nice. Yes, of course I don't have to use Comparable. I could have just written a standalone method. Makes sense. Solves the problem, but still doesn't (does it?) answer the question whether it's possible to override the definition of `<=>` outside of a class or module. – globewalldesk Jan 15 '17 at 04:47
  • @globewalldesk I've added some more background at the end of the answer that should help with the answer to that original question. If it still doesn't seem clear then let me know and I'll take another crack at it. – mikej Jan 15 '17 at 13:11
  • Thanks @mikej I appreciate it. I hadn't fully appreciated the fact that there are no free-floating methods in Ruby. The only thing that doesn't seem clear now is that the high-level classes like String and Object etc. always seemed like black boxes to me and I totally lack confidence to think I can really make sense of what you're doing with `pry`...and maybe it doesn't matter at this point. Thanks! – globewalldesk Jan 15 '17 at 16:48
  • Added a bit more background on what the `pry` examples were doing. Cheers! – mikej Jan 15 '17 at 19:54
1

Your problem is that this:

y <=> x

is just a fancy (and human-friendly) way of writing:

y.<=>(x)

so the <=> operator isn't a function call, it is a method call on the operator's left hand side. That method call won't use your def <=> because your comparator method isn't defined on the objects in the array you're sorting, you've created your <=> method on some other class.

In JavaScript, you say things like this:

a.sort(function(a, b) { ... })

or in more modern times:

a.sort((a, b) => ...)

so you're handing sort a function to use a comparator, you're not defining a comparator operator anywhere, just a function that takes two arguments and returns the desired value.

In Ruby, you generally use blocks as "callbacks":

arr.sort! do |a, b|
  a_num, a_string = a.split(' ')
  b_num, b_string = b.split(' ')
  a_num > b_num ? 1 : b_num > a_num ? -1 : a_string > b_string ? -1 : 1
end

Before we continue, you have a problem with your comparator logic because the block for Enumerable#sort is supposed to

return -1, 0, or +1 depending on the comparison between a and b.

and your block doesn't handle the 0 (equality) case. Also, your _nums are still strings so they won't compare like numbers. The first problem can be solved by using Array#<=> (which compares arrays element by element) and then second can be fixed with a simple to_i call:

arr.sort! do |a, b|
  a_num, a_string = a.split(' ')
  b_num, b_string = b.split(' ')
  [a_num.to_i, a_string] <=> [b_num.to_i, b_string]
end

You can go a step further by switching to sort_by!:

arr.sort_by! do |e|
  i, s = e.split(' ')
  [i.to_i, s]
end

If you want to use the block's logic in multiple places, you can get closer to the JavaScript version using a lambda:

cmp = ->(a, b) do
  a_num, a_string = a.split(' ')
  b_num, b_string = b.split(' ')
  [a_num.to_i, a_string] <=> [b_num.to_i, b_string]
end
arr1.sort!(&cmp)
arr2.sort!(&cmp)

natural = ->(e) do
  i, s = e.split(' ')
  [i.to_i, s]
end
arr1.sort_by!(&natural)
arr2.sort_by!(&natural)

or a separate method:

def cmp(a, b)
  a_num, a_string = a.split(' ')
  b_num, b_string = b.split(' ')
  [a_num.to_i, a_string] <=> [b_num.to_i, b_string]
end

def some_other_method
  arr1.sort!(&method(:cmp))
  arr2.sort!(&method(:cmp))
end

def natural(e)
  i, s = e.split(' ')
  [i.to_i, s]
end

def some_other_other_method
  arr1.sort_by!(&method(:natural))
  arr2.sort_by!(&method(:natural))
end

If you really mean to compare the self_number and other_number values as strings, then leave out the to_i calls and further simplify the blocks/lambdas:

arr.sort! { |a, b| a.split(' ') <=> b.split(' ') }
arr.sort_by! { |e| e.split(' ') }
mu is too short
  • 426,620
  • 70
  • 833
  • 800
  • This is the piece I was missing: "because your comparator method isn't defined on the objects in the array you're sorting, you've created your <=> method on some other class." Thanks. – globewalldesk Jan 15 '17 at 04:48