Introduction
We've just covered the idea of Block Functions, Proc Functions, and Lambda Functions in the last post, as well as a brief mention of to_proc
and how exactly we can call functions. This post is going to pursue those concepts in a bit more depth
Difficulty
Foundational
Some prerequisite knowledge needed of functions. This post focuses on foundational and fundamental knowledge for Ruby programmers.
Prerequisite Reading:
to_proc
and Function Interfaces
What makes a function in Ruby a function? What interface, or rather methods, lead Ruby to think it's dealing with something like a function?
In Ruby we have a concept of duck typing: If it quacks like a duck, walks like a duck, and looks like a duck it must be a duck.
That is to say if two classes in Ruby each implement the same interface we can use them interchangeably with each other.
For functions in Ruby that interface includes:
-
to_proc
- How to present itself as a function -
call
- How to call the underlying function -
[]
,===
,.()
- Other methods of calling the function
So to get back to the point at hand: What makes a function a function?
That's what we're going to explore in this.
Standard Library Implementations of to_proc
We can start out by asking Ruby precisely which classes have an instance method to_proc
to find out how it's currently implemented:
ObjectSpace.each_object(Class).select { |klass| klass.instance_methods.include?(:to_proc) }
# => [#<Class:#<Hash:0x1234>>, Enumerator::Yielder, Method, Proc, Hash, Symbol]
We'll focus on the last four for now: Method
, Proc
, Hash
, and Symbol
. We'll get into Enumerator
later.
Note: I do not currently know what the first
#<Class:#<Hash:0x1234>>
is, but will look into this more at a later date. If you happen to know, leave a comment!
Method
I had mentioned in the previous post that Methods can act as functions as well. This occurs through method(:name)
and instance_method(:name)
respectively:
raw_json.then(&JSON.method(:parse))
# => parsed_json
to_proc
in this case is telling Ruby to treat a method as a Block Function. It should be noted, however, that this syntax has fallen out of favor in lieu of the introduction of numbered params:
raw_json.then { JSON.parse(_1) }
...in which _1
is the implied first argument to the function.
Proc Function
A Proc Function uses to_proc
much like a Method does: to tell Ruby it's a Block Function, and to treat it accordingly:
add_one = proc { |a| a + 1 }
add_two = -> b { b + 2 }
[1, 2, 3].map(&add_one)
# => [2, 3, 4]
[1, 2, 3].map(&add_two)
# => [3, 4, 5]
Do note that Lambda Functions (-> {}
or lambda {}
) are a subset of more restrictive Proc Functions, as mentioned in the last post.
Hash
This one is interesting, and one I learned fairly recently even myself. Hashes in Ruby support to_proc
:
hash = { a: 1, b: 2, c: 3, d: 4 }
%i(a b c).map(&hash)
# => [1, 2, 3]
Its implementation might look something like this:
class Hash
def to_proc() = -> key { self[key] }
end
...in which each value passed in to the new function generated from Hash#to_proc
retrieves the value at a certain key. I'm not quite sure where I would use this at the moment, as Hash#values_at
seems to fit a similar role and is arguably clearer in its intent:
hash = { a: 1, b: 2, c: 3, d: 4 }
hash.values_at(*%i(a b c))
# => [1, 2, 3]
Note: The Splat Operator (
*
) takes a list and treats it as a flat list of arguments to a function, sovalues_at
above receives:a, :b, :c
instead of a singleArray
argument containing all of those values.
Symbol
This can be a more confusing one for newer Rubyists as it's ubiquitous in Ruby code, and finding the meaning of &
can be complicated:
[1, 2, 3].select(&:even?)
It's a lovely shorthand for a very powerful concept, and its implementation might look a bit like this:
class Symbol
def to_proc() = -> v { v.send(self) }
end
Where self
is the Symbol
:even?
in the above case. We're treating the Symbol
as a method to be called on every item in the list. For select
this could be predicates, for map
it could be a transformation, but the power in this little syntax drives a lot of Ruby code so it's good to know how it works.
Custom Interfaces
Where this gets interesting is when you define your own interfaces which act like functions. For this example we'll consider an older library of mine, Qo, which was one of the predecessors of Ruby Pattern Matching.
We'll call this Where
for the sake of this example.
Note: The actual gem
Where
does something entirely different (geolocation), so don't expect this gem to actually exist.
Creating Where
To start out with we want to express the idea of our new Where
class:
class Where
def initialize(*array_matches, **keyword_matches)
@array_matches = array_matches
@keyword_matches = keyword_matches
end
end
We want to express the idea of matching against a series of values, either array or keyword style, and have that work like a function. To start out with we need to make a match?
method to capture the idea of matching:
class Where
def match?(value)
@array_matches.all? { |matcher| matcher === value } &&
@keyword_matches.all? { |method_name, matcher|
matcher === value.send(method_name)
}
end
end
This allows us to do something like this:
Where.new(1..10, odd?: true).match?(3)
# => true
Each array-like match is compared with ===
and each keyword-like match uses the keyword as a method call to send to the matched against object, and it uses ===
to compare the value it extracts from there.
Turns out you can do a lot in a few lines of code in Ruby.
Constructor Shorthands
Personally I prefer this syntax:
Where[1..10, odd?: true].match?(3)
# => true
To get this we need to add one more method:
class Where
def self.[](...) = new(...)
end
Brackets can be defined as a method on classes as well.
Note: If you ever see something like
Integer('1')
in Ruby these are methods with the same name as a class defined in Kernel.
to_proc
Implementing to_proc
for Where
will look like wrapping the match?
function:
class Where
def to_proc() = -> v { match?(v) }
end
match?
is more clear of a name for intentions, but exposing this interface allows us to do this:
(1..10).select(&Where[1..10, odd?: true])
# => [1, 3, 5, 7, 9]
How's that for interesting?
call
, ===
, and []
These two methods often times are aliases of eachother, starting from the implementation of call
. In this case call
itself is effectively an alias for match?
:
class Where
alias_method :call, :match?
alias_method :===, :match?
alias_method :[], :match?
end
...which we can test directly as so:
Where[1..10, odd?: true].call 3
# => true
Where[1..10, odd?: true] === 3
# => true
Where[1..10, odd?: true].(3)
# => true
Where[1..10, odd?: true][3]
# => true
Note:
Where.(v)
is syntactic sugar forWhere.call(v)
. Definingcall
gives us this syntax for free.
...but you likely won't be using those in favor of match?
when calling this explicitly, so let's take a look at when you would use it:
(1..10).map do |n|
case n
when Where[odd?: true] then n / 2
when Where[even?: true] then n * 2
else 0
end
end
# => [0, 4, 1, 8, 2, 12, 3, 16, 4, 20]
Remember, case
uses ===
for conditions, allowing us to do this.
Wrapping Up
In very few lines of Ruby we've made our own variant of Pattern Matching by leveraging the functional interfaces provided to us. Ruby interfaces and implicits have a great deal of power behind them, and knowing about them will allow you to write exceptionally succinct and powerful code.
It should be mentioned that you should not actually use Where
in your actual code as Pattern Matching has taken over many of the things that it would be used for.
Some of the next few articles will cover other interfaces in Ruby that may be of interest, such as Enumerable
and Comparable
.
Remember, with great quacking comes great responsibility, enjoy your newfound knowledge of the functional interface in Ruby!
Want to keep up to date on what I'm writing and working on? Take a look at my new newsletter: The Lapidary Lemur
Top comments (5)
The heading "Standard Library Implementations of to_proc" shows core library methods, rather than standard library methods. It may cause confusion for those that go looking for these methods documentation in Standard Library instead of Core Library. See Standard Library on the official documentation site for Ruby docs.ruby-lang.org/en/master/doc/s...
That those two have distinct names is already confusing.
It does communicate that we have to include or require when needing "Standard Library" where "Core Library" is available with no additional action to take though. So it can be helpful communication.
This is fantastic!
I think I love you :D