Ruby Local Method map Shortcut
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 ourreceiver
and*args
would be passed along.- Next, we
call
the block, against thereceiver
, withargs
*args.dup.unshift(element)
is a really ugly way of saying "put the element as the first argument in theargs
array and return the pointer". We need todup
the array or else we will be pushing each element onto theargs
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.