inopinatus

Oct 22, 2017 - 3 minute read - Development

Ruby's magic ampersand does more than you think

We’ve long been enamored of Ruby’s ability to turn anything, although especially a symbol, into a block, with the magic ampersand:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Person
  attr_accessor :firstname, :lastname

  def initialize(firstname, lastname)
    self.firstname = firstname
    self.lastname = lastname
  end

  def display_name
    "#{lastname.upcase}, #{firstname.gsub(/\w+/, &:capitalize)}"
  end
end

writers = [
  Person.new('percy', 'shelley'),
  Person.new('jean-paul', 'sartre'),
  Person.new('mary', 'shelley')
]
writers.map(&:display_name)
# => ["SHELLEY, Percy", "SARTRE, Jean-Paul", "SHELLEY, Mary"]

Okay, technically it’s the “unary & operator” but in my mind, this is always pronounced Magic Ampersand.

We can also sort with the magic ampersand:

1
2
writers.sort_by(&:lastname).map(&:display_name)
# =>  ["SARTRE, Jean-Paul", "SHELLEY, Percy", "SHELLEY, Mary"]

but herein lies a problem. With that sort_by, we’re reaching inside the class and pulling out the lastname. That’s a code smell. It’s the Person class’s business how Person objects get sorted. Instead we treated each Person like a glorified struct. In abusing the Persons we made assumptions and got it wrong - our Shelleys are the wrong way around.

There’s more than one solution to this problem, and I’m going to present my favourite, to illustrate a little-known facet of &:symbol behaviour.

What it really does

We usually have this mental shorthand; that &:symbol gives us a block equivalent to :symbol.to_proc and that this behaves like { |obj| obj.send(:symbol) }. But in fact the block generated behaves more like this:

1
2
3
do |*args|
  args.shift.public_send(:symbol, *args)
end

That’s right; as we know it sends the symbol as a message to the first argument, but then it passes through additional arguments. And since we know that sort will call the comparison function with two values (a, b), we can define and use an arbitrary comparator for the class, like this:

1
2
3
4
5
6
7
8
9
class Person
  # ...
  def compare_name(b)
    [self.lastname, self.firstname] <=> [b.lastname, b.firstname]
  end
end

writers.sort(&:compare_name).map(&:display_name)
# => ["SARTRE, Jean-Paul", "SHELLEY, Mary", "SHELLEY, Percy"]

The very nice part is that this can be moved into a mixin, allowing reuse, and this also works with Rails, as in this slightly fictionalised example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
module Person
  extend ActiveSupport::Concern
  def display_name
    "%s, %s" % [lastname.upcase, firstname.gsub(/\w+/, &:capitalize)]
  end
  def compare_name(b)
    [self.lastname, self.firstname] <=> [b.lastname, b.firstname]
  end
end

class Writer < ApplicationRecord
  include Person
end
class Actor < ApplicationRecord
  include Person
end

creatives = (Writer.all + Actor.all)
creatives.sort(&:compare_name).map(&:display_name)
# => ["FUKUSHIMA, Rila", "SARTRE, Jean-Paul", "SHELLEY, Mary", "SHELLEY, Percy", "STREEP, Meryl"]

The advantage of this over simply implementing Comparable is being able to offer multiple ordering options on a model, such as age, or name, or number of published books, and then refer to them explicitly without needing to know any details of implementation. For example, I’ve used this to allow a computed priority to affect order, placing pinned items at the top of a list.

But don’t be distracted by the example usage of sorting. Today’s lesson is that the block generated by &:symbol passes trailing arguments. Comparison functions are just one of the ways you might use that.

Tags: ruby rails