Ruby Local Method map Shortcut

Ruby Posted on

In Ruby, we can pass blocks as directly as variables using &. This allows us to transform really simple map operations like this:

arr.map{ |a| a.downcase }

into this:

arr.map(&:downcase)

To me, the latter example is much cleaner, but there are some limitations:

  • You can only call methods which the contained elements respond to. You can't call a local method in your class. This makes it difficult to leverage your own functions without monkey-patching core libraries. For example, say you have a method like this in your local class:
class Foo
  # @param [#to_s] obj
  #   the object to prepend with 'foo' ('foo' + string)
  def prepend(obj)
    'foo' + obj.to_s
  end
end

You can't call arr.map(&:prepend) because prepend is not a method that exists on the Objects inside your array - it's an instance method.

  • All your elements must respond to the same method. This means a mixed collection, of differing Object types, could fail because the proc you're passing only exists on a subset of the contained elements. Even worse, the Objects may respond to the method, but have a different implementation. Consider Rails STI for an example:
class Person < ActiveRecord::Base
  def name
    [first_name, last_name].join(' ')
  end
end

class Child < Person
  def name
    [first_name, last_name, 'Jr.'].join(' ')
  end
end

# This will implicitly call different name methods!
Person.where(some: 'condition').map(&:name)
  • You can't pass additional arguments to these methods - this makes it impossible to call methods that require arguments (like gsub).

Suppose I have a method that converts a string into Pig Latin:

def piglatinize(value)
  alpha = ('a'..'z').to_a
  vowels = %w[a e i o u]
  consonants = alpha - vowels

  if vowels.include?(value[0])
    value + 'ay'
  elsif consonants.include?(value[0]) && consonants.include?(value[1])
    value[2..-1] + value[0..1] + 'ay'
  elsif consonants.include?(value[0])
    value[1..-1] + value[0] + 'ay'
  else
    value
  end
end

Currently, I have to do something like this to map piglatinize onto my array:

arr.map{ |a| piglatinize(a) }

I want a way to map that local method across all the elements in my Array. And I want it to look like this:

arr.map(self, &:piglatinize)

Let's hack on Array just to see if we can't get something working. I don't advocate making this a core extension, but it's easy to experiment with to get started.

class Array
  def map(receiver = nil, *args, &block)
    if receiver.nil?
      super(&block)
    else
      super() do |element|
        block.call receiver, *args.dup.unshift(element)
      end
    end
  end
  alias_method :collect, :map
end

Wow, that's a lot. Let's sit back and digest this a bit:

  • receiver is a pointer to the calling object. We need this so we can evaluate the method against the calling object.
  • args is a Ruby splat that will allow us to pass additional values to our method.
  • If the receiver is nil, map should behave just as it did before.
  • super() calls the parent map method with no arguments. This is important, because otherwise our receiver and *args would be passed along.
  • Next, we call the block, against the receiver, with args
  • *args.dup.unshift(element) is a really ugly way of saying "put the element as the first argument in the args array and return the pointer". We need to dup the array or else we will be pushing each element onto the args array each time.

Okay, let's try it out. Create a new class with the following content (or copy-paste the following code block into irb):

class Array
  def map(receiver = nil, *args, &block)
    if receiver.nil?
      super(&block)
    else
      super() do |element|
        block.call receiver, *args.dup.unshift(element)
      end
    end
  end
  alias_method :collect, :map
end

class Pigs
  def initialize(*args)
    p args.map(self, &:piglatinize)
  end

  def piglatinize(value)
    alpha = ('a'..'z').to_a
    vowels = %w(a e i o u)
    consonants = alpha - vowels

    if vowels.include?(value[0])
      value + 'ay'
    elsif consonants.include?(value[0]) && consonants.include?(value[1])
      value[2..-1] + value[0..1] + 'ay'
    elsif consonants.include?(value[0])
      value[1..-1] + value[0] + 'ay'
    else
      value
    end
  end
end

Pigs.new('orange', 'apple', 'banana')
=> ["orangeay", "appleay", "ananabay"]

This is ideally something I would like to see merged into Ruby 2.0.

Bonus

You can pass additional arguments to this new map method. It's slightly backward, because the block definition has to come last, but the rest of the arguments appear in the same order.

# @param [#to_s] obj
#   the object to prepend
# @param [String] str
#   the string to prepend the object with
def prepend(obj, str = 'foo')
  str + obj.to_s
end

p %w(orange apple banana).map(self, 'bar', &:append)
=> ["barorange", "barapple", "barbanana"]

Thanks to Zach Holman and Erik Michaels-Ober for their feedback!

Update (19 Dec 2013)

This is actually possible in Ruby 1.9 and 2.0, but it's not a different operator. Instead of passing the method name as a string to call on the object, you can give the proc a method like:

arr.map(&method(:piglatinize))

The method method does exactly what I've described above :).

About Seth

Seth Vargo is a Distinguished Software Engineer at Google. Previously he worked at HashiCorp, Chef Software, CustomInk, and some Pittsburgh-based startups. He is the author of Learning Chef and is passionate about reducing inequality in technology. When he is not writing, working on open source, teaching, or speaking at conferences, Seth advises non-profits.