As we continue with the theme we have been pursuing in the last couple of posts, we take a brief pit stop and look at partial application before we move on to our next method we want to define.
Partial application is the ability to provide only a subset of arguments to a function or method, and return a new function/method to be called later that will keep the original context.
We will start our examples with the two methods double
and triple
.
def double(y) 2 * y end def triple(y) 3 * y end double(3) # => 6 triple(5) # => 15
We can think of these methods as defined in terms of a more generic function multiply
that we call with a hard coded value.
def multiply(x, y) x * y end def double(y) multiply(2, y) end def triple(y) multiply(3, y) end
What partial application allows us to do is to define double
and triple
in terms of multiply, by calling it with the first argument only, the 2
for the method double
, and saving the resulting function to be invoked later.
To do this in Ruby we can use Method#curry, or Proc#curry.
Method#curry
will return a new Proc
that can then be invoked with only a subset of its arguments.
method(:multiply).curry # => #<Proc:0x007fc02b225950 (lambda)>
So for our double
and triple
functionality, we can make those be variables which hold the resulting Proc
of passing in their value to multiply, and invoke them by only passing in the value we want to double, or triple.
double = method(:multiply).curry.(2) # => #<Proc:0x007fc02b1eeea0 (lambda)> double.(8) # => 16 triple = method(:multiply).curry.(3) # => #<Proc:0x007fc02b186260 (lambda)> triple.(17) # => 51
At this point you might be wondering what this gets you, as the examples of double
, triple
, and multiply
might seem a bit simplistic at best, and maybe even contrived.
I would agree; it is a simple example, but mainly to show as an example of what partial application is, and now we will take a look at our filter
, map
, and reduce
from the previous posts and update them to show some of the power of partial application.
As a reminder this is the map
, filter
, and reduce
as defined previously.
def map(items, do_map) reduce([], items, lambda do |accumulator, item| accumulator.dup << do_map.call(item) end) end def filter(items, predicate) reduce([], items, lambda do |accumulator, item| if (predicate.call(item)) accumulator.dup << item else accumulator end end) end def reduce(initial, items, operation) return nil if items.empty? accumulator = initial for item in items do accumulator = operation.call(accumulator, item) end accumulator end
We will update these definitions to be able to take the items
collection last, as that is the most general argument.
def map(f, items) do_map = lambda do |accumulator, item| accumulator.dup << f.call(item) end reduce([], do_map, items) end def filter(predicate, items) do_filter = lambda do |accumulator, item| if (predicate.call(item)) accumulator.dup << item else accumulator end end reduce([], do_filter, items) end def reduce(initial, operation, items) return nil unless items.any?{|item| true} accumulator = initial for item in items do accumulator = operation.call(accumulator, item) end accumulator end
You might be wondering why I said that the collections of items is the most general argument.
That was because, if we want to sum a sequence of numbers, double a sequence of numbers, or even pick out the even numbers, we can do that against a number of different collections of items.
reduce(0, :+.to_proc, (1..5)) # => 15 reduce(0, :+.to_proc, [2, 4, 6, 8]) # => 20 map(double, (2..7)) # => [4, 6, 8, 10, 12, 14] map(double, [1, 2, 3, 5, 8, 13]) # => [2, 4, 6, 10, 16, 26] filter(lambda{|x| x.even?}, (5..10)) # => [6, 8, 10] filter(lambda{|x| x.even?}, (0..100).step(10)) # => [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
And because we now have the generic items last, we can use Method#curry
to build up Proc
s to call that represent the parts that are the same and give those Proc
s descriptive names.
concat = method(:reduce).curry.("", :+.to_proc) # => #<Proc:0x007fc02b207f68 (lambda)> concat.(("a".."d")) # => "abcd" concat.(["alpha", "beta", "gamma", "delta"]) # => "alphabetagammadelta" sum = method(:reduce).curry.(0, :+.to_proc) # => #<Proc:0x007fc02b25c450 (lambda)> sum.((1..10)) # => 55 sum.((2..20).step(2)) # => 110 evens_only = method(:filter).curry.(lambda{|x| x.even?}) # => #<Proc:0x007fc02b155688 (lambda)> evens_only.([1, 2, 3, 5, 8, 13, 21]) # => [2, 8] evens_only.([1, 2, 4, 7, 11]) # => [2, 4] doubles = method(:map).curry.(double) # => #<Proc:0x007fc02b0c0308 (lambda)> doubles.((1..10)) # => [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] doubles.([2, 4, 6, 8]) # => [4, 8, 12, 16]
And in the last example of doubles
we did a curry of map
, and used the partially applied function double
as the argument to be partially applied to map
.
So with partial application, we can start to abstract common behavior out into Proc
s that can operate against more generic data.
One last example would be filtering items against those that are active. With partial application, we can take filter
and create a partially applied version that is given a Proc
that will check to see if an item is active?
.
active_items = method(:filter).curry.(lambda{|item| item.active?}) # => #<Proc:0x007fc02b207f68 (lambda)>
With this active_items
Proc
, we can then use that against any collection of objects as long as they support the method active?
, e.g. User
s, Order
s, Session
s, Blog Post
s, etc.
active_users = active_items.(users) active_blog_posts = active_items.(blog_posts) active_sessions = active_items.(sessions) active_orders = active_items.(orders)
As you can hopefully start to see, we can start to get some very small, focused, and powerful functions that are nicely abstracted to work against a broader range of input.
Next week, we will take the new versions of map
, filter
, and reduce
, along with partial application, to show how we can take these smaller pieces of code and reuse and assemble them together to get more advanced behaviors.
–Proctor