Mastering the unary ampersand operator (&object) as a block argument

A complete overview of blocks, block references, and procs.

Tech - RubyCademy
RubyCademy
Published in
5 min readFeb 10, 2023

--

Feel free to have a look at The yield Keyword in Ruby if you’re not familiar the yield keyword and block arguments.

The Ampersand Colon (&:) operator

Ruby proposes a shorthand to call a method on a block argument

[1, 2, 3].map(&:to_f) # => [2.0, 4.0, 6.0]

Is equivalent to

[1, 2, 3].map { |x| x.to_f } # => [2.0, 4.0, 6.0]

By passing &:to_i to Enumerable#map, each element of the[1, 2, 3] array calls to_i.

It works just fine. It’s magic... So let’s reveal the magic trick! 😉

This operator is also called Pretzel colon operator. 🥨 ☕

Symbol#to_proc

When & prepends an argument during a method call, Ruby expects the object passed as an argument to respond to to_proc

module InspectToProc
def to_proc
puts "to_proc is called upon :#{self.to_s}"

super
end
end

Symbol.prepend InspectToProc

method_name = :to_f

[1, 2, 3].map(&method_name) # => [1.0, 2.0, 3.0]

produces

to_proc is called upon :to_f

Symbol#to_proc is called when &:to_f is passed as an argument of map — which is a method that expects a block.

Then within each iteration of map, this freshly created proc will be used as the method’s block.

So let’s re-implement Symbol#to_proc to illustrate the above explanation

module InspectToProc
def to_proc = lambda {|elem| elem.send(self)} # returns a proc that expects one argument (elem) and calls self (:to_s in this case) upon elem.
end

Symbol.prepend InspectToProc

[1, 2, 3].map(&:to_s) # => ["1", "2", "3"]

is equivalent to

to_string = lambda { |x| x.to_s }

[1, 2, 3].map {|x| to_string.yield(x) } # => ["1", "2", "3"]

to_string is a lambda proc that converts an object into a string.

Then we pass x as argument of to_string.yield (the proc acting as a block) — which calls to_s upon x.

Method#to_proc

Another class that natively responds to #to_proc is Method. An instance of Method is the object-oriented representation of a method.

Instances can only be generated through the method(method_id) shorthand

Method.new(:puts) # ❌ => NoMethodError: undefined method `new' for Method:Class
method(:puts) # ✅ => #<Method: Object(Kernel)#puts>

Let’s see how to use Method objects with the & ampersand operator

def double(x) = x * 2 # I 💚 the shorthand syntax for single line methods!

[1, 2, 3].map &method(:double) # => [2, 4, 6]

is equivalent to

def double(x) = x * 2

[1, 2, 3].map { |x| double(x) } # => [2, 4, 6]

Here x is passed as an argument of double (double(x)) while in the previous section, to_s was called upon x (x.to_s).

It’s also possible to refer to a method encapsulated in another class

require 'date'

datetimes = %w(
2001-02-03T04:05:06+07:00
2002-03-04T04:05:06+08:00
2003-04-05T04:05:06+09:00
)

datetimes.map(&DateTime.method(:strptime)) # => [#<DateTime: 2001-02-03T04:05:06+07:00 ((2451943j,75906s,0n),+25200s,2299161j)>, #<DateTime: 2002-03-04T04:05:06+08:00 ((2452337j,72306s,0n),+28800s,2299161j)>, #<DateTime: 2003-04-05T04:05:06+09:00 ((2452734j,68706s,0n),+32400s,2299161j)>]

is equivalent to

require 'date'

datetimes = %w(
2001-02-03T04:05:06+07:00
2002-03-04T04:05:06+08:00
2003-04-05T04:05:06+09:00
)

datetimes.map do |datetime|
DateTime.strptime(datetime)
end # => [#<DateTime: 2001-02-03T04:05:06+07:00 ((2451943j,75906s,0n),+25200s,2299161j)>, #<DateTime: 2002-03-04T04:05:06+08:00 ((2452337j,72306s,0n),+28800s,2299161j)>, #<DateTime: 2003-04-05T04:05:06+09:00 ((2452734j,68706s,0n),+32400s,2299161j)>]

DateTime.strptime is a helper provided by the date standard library to parse string representations of date and time.

DateTime.method(:strptime) returns the object-oriented representation of strptime scoped under the DateTime class.

So the & ampersand operator uses this instance of Method like in the previous example.

A concrete implementation: BaseBuilder.to_proc

# Input: Content of a JSON or CSV file uploaded to SFTP server for instance.
raw_users = [{email: 'a@a.com'}, {email: 'b@b.com'}, {email: 'c@c.com'}]


# Let's assume User to be an ActiveRecord model
User = Struct.new(:email, keyword_init: true)


# BaseBuilder: it defines the Builder interface
class BaseBuilder
attr :params

def initialize(user_params) = @params = user_params

# Emulated interface
def build = raise(NotImplementedError)

# Define to_proc at a class level
def self.to_proc = proc { |user_params| new(user_params).build }
end


# UserBuilder
class UserBuilder < BaseBuilder
def build = User.new(params)
end


raw_users.map &UserBuilder # => [#<struct User email="a@a.com">, #<struct User email="b@b.com">, #<struct User email="c@c.com">]

So let’s detail what happens in the above code snippet.

Here we define BaseBuilder which is a basic implementation of the Builder design pattern:

  • it inits params (Each line of raw_users)
  • it emulates an interface contract with the build method.
  • it defines to_proc at a class level. this method returns a proc that instantiates a new builder and calls the build instance method on it.

Then we define UserBuilder which derivates from BaseBuilder. So in order to be able to pass this class as a block reference to map, we must comply with the interface contract defined by BaseBuilder.

This means that we must define UserBuilder#build. If not, BaseBuilder#build is called and NotImplementedError is then raised.

As BaseBuilder.to_proc returns a proc and UserBuilder inherits from BaseBuilder then a call to raw_users.map &UserBuilder calls BaseBuilder.to_proc.

So to define another enumerable builder, you just have to follow 2 rules:

  • Make sure that your builder inherits from BaseBuilder
  • Define the #build method

Cool! Isn’t it? 😁

Now to finish, let’s have a look at a particular use case where to_proc is not called when using block reference (&object) as an argument.

When to_proc is not called

When & prepends an argument during a method call, Ruby expects the object passed as an argument to respond to to_proc.

But this is not always the case

module InspectToProc
def to_proc
puts "to_proc is called upon :#{self.to_s}"

super
end
end
Proc.prepend InspectToProc

double = proc {|x| x * 2}

[1, 2, 3].map(&double) # => [2, 4, 6]

Here InspectToProc#to_proc is never called as double is already an instance of Proc.

Conclusion

The Ampersand operator + Block pattern is widely used within the Ruby Standard Library and within multiple well-known gems (devise, etc..).

We all know this pattern through the famous Ampersand Colon operator (&:).

But with a bit more knowledge, you are now able to extend the possibility of your Ruby code to another level!

Ruby Mastery

We’re currently finalizing our first online course: Ruby Mastery.

Join the list for an exclusive release alert! 🔔

🔗 Ruby Mastery by RubyCademy

Also, you can follow us on x.com as we’re very active on this platform. Indeed, we post elaborate code examples every day.

💚

--

--