Understanding Ruby's :symbol.to_proc
At work, someone posted this code to our #rails channel and asked if thereâs a more ruby way to skip the re-assigning of @employees
, maybe using yield_self
:
class EmployeesController < ApplicationController
def index
@employees = User.employee.includes(:contact_information)
@employees = @employees.order("#{sort_column} #{sort_direction}") if sorting?
@employees = @employees.page(params[:page] || 1).per(20)
end
private
def sort_column
(["last_name","first_name"] & Array(params[:sort])).first
end
def sort_direction
(["asc", "desc"] & Array(params[:dir])).first || "asc"
end
def sorting?
!!sort_column
end
end
And the bike shedding started đ˛ I think I proposed something like:
class EmployeesController < ApplicationController
def index
@employees = User.employee.includes(:contact_information).
then(&apply_sorting).
then(&apply_pagination)
end
private
def sort_column
(["last_name","first_name"] & Array(params[:sort])).first
end
def sort_direction
(["asc", "desc"] & Array(params[:dir])).first || "asc"
end
def sorting?
!!sort_column
end
def apply_sorting
method(:_apply_sorting)
end
def _apply_sorting(relation)
return relation unless sorting?
relation.order("#{sort_column} #{sort_direction}")
end
def apply_pagination
method(:_apply_pagination)
end
def _apply_pagination(relation)
relation.page(params[:page] || 1).per(20)
end
end
And sure, sure thereâs an argument for that being far too over-engineered, but whatâs really interesting, and what this post is actually about, is someone proceed to ask
- Whatâs
method
do? - Why doesnât
then(&:apply_sorting)
(i.e. without themethod
bit) work? - And why does
[1, 2].inject(&:+)
work?
Quick rebuttal to that âover-engineeredâ argument: minus the &
that index action sure is readable, and we could slide all those private methods into a reusable Sortable
concern.
Anyway, I didnât have a super satisfactory answer to all those questions. I just kinda knew the &method(:apply_sorting)
pattern would work here, and inject
âs good with just &:+
. So I figured Iâd find out.
âď¸ All thatâs sort of the premise / some context. Here weâll flip to some simpler examples you can run right in irb.
Suppose weâve defined an apply_filter
method like so:
def apply_filter(array)
array.select { |e| e % 2 == 0 }
end
We could then run the following examples in irb:
[1, 2, 3, 4].yield_self(&method(:apply_filter)) # => [2, 4]
[1, 2, 3, 4].yield_self(&:apply_filter)
# ArgumentError (wrong number of arguments (given 0, expected 1))
[1, 2, 3, 4].inject(&:+) #=> 10
Why? Why does yield_self(&:apply_filter)
fail, but inject(&:+)
work? First off, we gotta be clear about what &
does:
In a method argument list, the &
operator takes its operand, converts it to a Proc object if it isnât already (by calling to_proc on it) and passes it to the method.
So the âin a method argument list is importantâ. In fact outside of one, we get an error:
&:+
# SyntaxError (unexpected &)
But that doesnât explain why [1, 2, 3, 4].inject(&:+)
works and [1, 2, 3, 4].yield_self(&:apply_filter)
doesnât. To explain that, weâve gotta look at how Symbol#to_proc
works.
Normal procs are associated with a Binding
object which is responsible for capturing all the bindings (i.e. variable assignments) and the receiver from the scope in which the proc was declared. We can see that in action:
x = 1
proc {}.binding.eval('x')
# => 1
eval
will run the string you give it as ruby code within that Binding object.
However, the to_proc
method on Symbol
is defined in C and returns a âC level Procâ, and therefore doesnât have a binding.
:+.to_proc.binding
# ArgumentError (Can't create Binding from C level Proc)
See how this is different if we instead call to_proc
on the +
Method object from an integer:
1.method(:+).to_proc.binding
# #<Binding:0x00007fcb18072430>
1.method(:+).to_proc.binding.receiver
# 1
Instead, the C Proc returned from :+.to_proc
expects you to give it a receiver as the first argument:
:+.to_proc.cal()
# ArgumentError (no receiver given)
:+.to_proc.call(1)
# ArgumentError (wrong number of arguments (given 0, expected 1))
:+.to_proc.call(1, 2)
# 3
Ok, armed with that we can take a look at the block signature for inject:
inject(initial) { |memo, obj| block } â obj
It actually takes two arguments, memo
and object
. So when we pass it the C proc from &:+
it lets memo
be the receiver and object
be the argument, so it works!
If we look a the block signature for yield_self:
yield_self { |x| block } â an_object
It expects only the single argument, so with [1, 2, 3, 4].yield_self(&:apply_filter)
we wind up setting the array (or x
from the signature above) as the receiver for the proc returned from apply_filter.proc
, but then fail to pass it the array
argument itâs expecting (see the method definition at the top).
Using [1, 2, 3, 4].yield_self(&method(:apply_filter))
fixes this by instead calling to_proc
on the Method
object returned by method(:apply_filter)
, not the symbol :apply_filter
. In this case, we donât get back a C Proc, we get back a normal Ruby proc which does have a binding and a receiver (which in this case is whatever self
is).