Symbol#to_proc is one of the finest examples of the flexibility and beauty of Ruby. This syntax sugar allows us to take a statement such as
| words.map { |s| s.length } |
and turn it into something more succinct:
| words.map(&:length) |
Let’s unravel this syntactical sleight of hand by figuring out how this works.
The first step is to figure out the role of the &:symbol. How does Ruby know that it has to call a to_proc method, and why is this only specific to the Symbol class?
When Ruby sees an & and an object—any object—it will try to turn it into a block. This is simply a form of type coercion.
Take to_s, for example. Ruby allows you to do 2.to_s, which returns the string representation of the integer 2. Similarly, to_proc will attempt to turn an object—again, any object—into a Proc.
This might seem a little abstract, so in order to make things more concrete, it’s time to open irb.
In order to understand what happens behind the scenes, you’ll create an object and then pass it into map. If you’re expecting this to fail, you are absolutely right, but that is the whole point. The error messages that Ruby provides will guide you to enlightenment.
| >> obj = Object.new |
| >> [1,2,3].map &obj |
| TypeError: wrong argument type Object (expected Proc) |
The error message is telling us exactly what you need to know. It’s saying that obj is, well, an Object and not a Proc. In other words, we must teach the Object class how to turn itself into a Proc. Therefore, the Object class must have a to_proc method that returns a Proc. Let’s do the simplest thing possible:
| >> class Object |
| >> def to_proc |
| >> proc {} |
| >> end |
| >> end |
| => :to_proc |
| |
| >> obj = Object.new |
| |
| >> [1, 2, 3].map &obj |
| => [nil, nil, nil] |
When you run this again, you’ll get no errors. But notice that the result is an array of nils. How can each element be accessed and, say, printed out? The Proc needs to accept arguments:
| >> class Object |
| >> def to_proc |
| >> proc { |x| "Here's #{x}!" } |
| >> end |
| >> end |
| => :to_proc |
| |
| >> obj = Object.new |
| |
| >> [1,2,3].map(&obj) |
| => ["Here's 1!", "Here's 2!", "Here's 3!"] |
This hints at a possible implementation of Symbol#to_proc. Let’s start with what you know and redefine to_proc in the Symbol class:
| >> class Symbol |
| >> def to_proc |
| >> proc { |obj| obj } |
| >> end |
| >> end |
| => :to_proc |
You now know that an expression such as
| words.map(&:length) |
is equivalent to
| words.map { |w| w.length } |
Here, the Symbol instance is :length. The value of the symbol corresponds to the name of the method. You also know how to access each yielded object by making the Proc return value in to_proc take in an argument.
For the preceding example, this is what you want to achieve:
| class Symbol |
| def to_proc |
| proc { |obj| obj.length } |
| end |
| end |
You can even try this out:
| >> class Symbol |
| >> def to_proc |
| >> proc { |obj| obj.length } |
| >> end |
| >> end |
| => :to_proc |
| |
| >> ["symbol", "cymbals", "sambal"].map(&:obj) |
| => [6, 7, 6] |
Unfortunately, this only works on objects that have the length method. How can Symbol#to_proc be made more general?
Well, how can the name of the symbol be turned into a method call on the obj? This can be answered in two parts.
First, using Kernel#send, any method can be invoked on an object dynamically as long as the right symbol is supplied. For example:
| >> "ohai".send(:length) |
| => 4 |
In other words, send allows you to dynamically invoke methods using a symbol. In this example, :length is hard-coded in the Symbol#to_proc method. The next step is to make the method more general, which brings us to the next part of the answer.
Instead of hard-coding :length, you can make use of self, which in the case of a Symbol, returns the value of the symbol.
Therefore, you can make use of self, which holds the value of the name of the method such as :length, and pass it to the send method to invoke the method on obj. I hereby present you our own implementation of Symbol#to_proc:
| class Symbol |
| def to_proc |
| proc { |obj| obj.send(self) } |
| end |
| end |
Try it out. Save this code to a file called symbol_to_proc.rb and then load it in irb:
| $ irb -r ./symbol_to_proc.rb |
Then test it out:
| >> ["symbols", "cymbals", "sambal"].map(&:length) |
| => [7, 7, 6] |
| |
| >> ["symbols", "cymbals", "sambal"].map(&:upcase) |
| => ["SYMBOLS", "CYMBALS", "SAMBAL"] |
self is the symbol object (:length in our example), which is exactly what #send expects.
The initial implementation of Symbol#to_proc is naïve. The reason is that only the obj in the body of the Proc in considered, while the arguments are totally ignored.
Recall that unlike lambdas, Procs are more relaxed when it comes to the number of arguments they’re given. It’s therefore easy to circumvent this limitation. It’s instructive to see what happens when a lambda is used instead of a Proc.
First, we return a lambda instead of a Proc in to_proc. Recall that a lambda is a Proc, so everything should work as normal:
| class Symbol |
| def to_proc |
» | lambda { |obj| obj.send(self) } |
| end |
| end |
| |
| words = %w(underwear should be worn on the inside) |
| words.map &:length # => [9, 6, 2, 4, 2, 3, 6] |
Since lambdas are picky when it comes to the number of arguments, is there a method that requires two arguments? Of course: inject/reduce. The usual way of writing inject is:
| [1, 2, 3].inject(0) { |result, element| result + element } # => 6 |
As you can see, the block in inject takes two arguments. Let’s see how our implementation does by using the &:symbol notation:
| [1, 2, 3].inject(&:+) |
Here’s the error we get:
| ArgumentError: wrong number of arguments (2 for 1) |
| from (irb):10:in `block in to_proc' |
| from (irb):14:in `each' |
| from (irb):14:in `inject' |
| ... |
You can now clearly see that an argument is missing. The lambda currently accepts only one argument, but what it received was two arguments. You need to allow the lambda to take in more arguments:
| class Symbol |
| def to_proc |
» | lambda { |obj, args| obj.send(self, *args) } |
| end |
| end |
| |
| [1, 2, 3].inject(&:+) # => 6 |
Now it works as expected! The splat operator (that’s the * in *args) enables the method to support a variable number of arguments. Before you go about celebrating, there’s one problem. The following code doesn’t work anymore:
| words = %w(underwear should be worn on the inside) |
| words.map &:length # => [9, 6, 2, 4, 2, 3, 6] |
You’ll see the following output when you run it:
| ArgumentError: wrong number of arguments (1 for 2) |
| from (irb):3:in `block in to_proc' |
| from (irb):8:in `map' |
| ... |
There are two ways to fix this. First, you can supply args with a default value:
| class Symbol |
| def to_proc |
» | lambda { |obj, args=nil| obj.send(self, *args) } |
| end |
| end |
| |
| words = %w(underwear should be worn on the inside) |
| words.map &:length # => [9, 6, 2, 4, 2, 3, 6] |
| |
| [1, 2, 3].inject(&:+) # => 6 |
Alternatively, you can just use a Proc again:
| class Symbol |
| def to_proc |
» | proc { |obj, args| obj.send(self, *args) } |
| end |
| end |
| |
| words = %w(underwear should be worn on the inside) |
| words.map &:length # => [9, 6, 2, 4, 2, 3, 6] |
| |
| [1, 2, 3].inject(&:+) # => 6 |
This is one of the few places where having a more relaxed requirement with respect to arities is important and even required.
18.118.93.64