In Progress
Unit 1, Lesson 1
In Progress

Splatted Send

Video transcript & code

In Episode #280, we worked on a convenience method for finding the top 10 items in a list. It uses the Pluggable Selector pattern to make it easy to alter the key attribute, the comparison criteria, and the returned representation of the original object.

def top(things, on: :itself, by: :<, as: :itself)
  things.sort{|x, y|
    x_key = x.public_send(on)
    y_key = y.public_send(on)
    if x_key.public_send(by, y_key)
      -1
    elsif y_key.public_send(by, x_key)
      1
    else
      0
    end
  }.last(10).map(&as)
end

Here's an example of using this method. We grab a list of video files in a directory tree. Then we find the top 10 files by file size, and present them in the form of basenames.

require "./top"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: :basename
# => [#<Pathname:screencast-20130630-2000.mp4>,
#     #<Pathname:screen-capture-2015-01-14_16.28.41.mp4>,
#     #<Pathname:screencast-20130612-1706.mp4>,
#     #<Pathname:screen-capture-2015-01-14_15.25.00.mp4>,
#     #<Pathname:screencast-20130604-1113.mp4>,
#     #<Pathname:screen-capture-2015-01-17_04.49.32.mp4>,
#     #<Pathname:screen-capture-2015-01-17_04.49.32.mp4>,
#     #<Pathname:screen-capture-2015-01-08_15.46.15.mp4>,
#     #<Pathname:screen-capture-2015-01-08_15.46.15.mp4>,
#     #<Pathname:screencast-20130409-1751.mp4>]

The keyword arguments to this method expect to be given symbols, which represent messages that will be sent to the items in the collection. Here we've specified the #size message and the #basename method. If we change the basename method to, for instance, :to_s instead, we get back string representations instead of pathnames.

require "./top"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: :to_s
# => ["/home/avdi/Dropbox/rubytapas/120-outside-in/screencast-20130630-2000.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-14_16.28.41.mp4",
#     "/home/avdi/Dropbox/rubytapas/116-extract-command-object/screencast-20130612-1706.mp4",
#     "/home/avdi/Dropbox/rubytapas/278-lazy/media/screen-capture-2015-01-14_15.25.00.mp4",
#     "/home/avdi/Dropbox/rubytapas/113-p/screencast-20130604-1113.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/279-audited-predicate/media/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/276-fattr/media/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/095-gem-love-6/screencast-20130409-1751.mp4"]

So far so good. But what if we decide that what we really want to get back is the pathnames' relative path to the current directory? We know that we can get this for an individual file by sending the relative_path_from message along with the path to a starting directory.

require "./top"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

files.first.relative_path_from(Pathname.pwd)
# => #<Pathname:../106-class-accessors/106-class-accessors.mp4>

How can we specify this as the argument to the as keyword? This is more than just a simple symbol representing a message. We have to also send an argument for the symbol.

The first thing we can think of is to pass an array, with the first element being the message, and the second being the argument to the message. But of course this doesn't work.

require "./top"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: [:relative_path_from, Pathname.pwd] # ~> TypeError: wrong argument type Array (expected Proc)
# =>

# ~> TypeError
# ~> wrong argument type Array (expected Proc)
# ~>
# ~> /home/avdi/Dropbox/rubytapas/281-splatted-send/top.rb:13:in `top'
# ~> xmptmp-in3294GaG.rb:6:in `<main>'

Is there a way to make this work? To begin with, let's find where the as parameter is used. At present, it is being converted to a proc and passed to map as a transforming function. Let's start by expanding this code to be a bit more explicit. Instead of taking advantage of Symbol#to_proc, we'll pass a block to map. Inside, we will send the as message to each object to be returned.

def top(things, on: :itself, by: :<, as: :itself)
  things.sort{|x, y|
    x_key = x.public_send(on)
    y_key = y.public_send(on)
    if x_key.public_send(by, y_key)
      -1
    elsif y_key.public_send(by, x_key)
      1
    else
      0
    end
  }.last(10).map{|o| o.public_send(as)}
end

This doesn't actually fix anything.

require "./top2"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: [:relative_path_from, Pathname.pwd] # ~> TypeError: [:relative_path_from, #<Pathname:/home/avdi/Dropbox/rubytapas/281-splatted-send>] is not a symbol nor a string
# =>

# ~> TypeError
# ~> [:relative_path_from, #<Pathname:/home/avdi/Dropbox/rubytapas/281-splatted-send>] is not a symbol nor a string
# ~>
# ~> /home/avdi/Dropbox/rubytapas/281-splatted-send/top2.rb:13:in `public_send'
# ~> /home/avdi/Dropbox/rubytapas/281-splatted-send/top2.rb:13:in `block in top'
# ~> /home/avdi/Dropbox/rubytapas/281-splatted-send/top2.rb:13:in `map'
# ~> /home/avdi/Dropbox/rubytapas/281-splatted-send/top2.rb:13:in `top'
# ~> xmptmp-in3294UXr.rb:6:in `<main>'

But it gives us a place to work from.

We know that #send and #public_send can take an arbitrary number of arguments. The first is treated as the message to send, and the rest are treated as arguments to send. We can test this manually on a single file in the collection.

require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

files.first.public_send(:relative_path_from, Pathname.pwd)
# => #<Pathname:../106-class-accessors/106-class-accessors.mp4>

How would this send look if the message and arguments were bundled up into an array? Let's see. We'll pull the arguments out into an array. Then, we pass the array into public_send along with a "splat" operator, telling Ruby to expand the array into the positional arguments for the message.

require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

args = [:relative_path_from, Pathname.pwd]
files.first.public_send(*args)
# => #<Pathname:../106-class-accessors/106-class-accessors.mp4>

This gives us the clue we need. We return to the top method, and make a single change. We add a splat operator to the as argument.

def top(things, on: :itself, by: :<, as: :itself)
  things.sort{|x, y|
    x_key = x.public_send(on)
    y_key = y.public_send(on)
    if x_key.public_send(by, y_key)
      -1
    elsif y_key.public_send(by, x_key)
      1
    else
      0
    end
  }.last(10).map{|o| o.public_send(*as)}
end

That's it. We now try to invoke top again, and lo and behold, it works. We see output as relative paths to the current directory.

require "./top3"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: [:relative_path_from, Pathname.pwd]
# => [#<Pathname:../120-outside-in/screencast-20130630-2000.mp4>,
#     #<Pathname:../footage/screen-capture-2015-01-14_16.28.41.mp4>,
#     #<Pathname:../116-extract-command-object/screencast-20130612-1706.mp4>,
#     #<Pathname:../278-lazy/media/screen-capture-2015-01-14_15.25.00.mp4>,
#     #<Pathname:../113-p/screencast-20130604-1113.mp4>,
#     #<Pathname:../footage/screen-capture-2015-01-17_04.49.32.mp4>,
#     #<Pathname:../279-audited-predicate/media/footage/screen-capture-2015-01-17_04.49.32.mp4>,
#     #<Pathname:../footage/screen-capture-2015-01-08_15.46.15.mp4>,
#     #<Pathname:../276-fattr/media/screen-capture-2015-01-08_15.46.15.mp4>,
#     #<Pathname:../095-gem-love-6/screencast-20130409-1751.mp4>]

Of course, now we wonder if we've broken the simple case. So we try placing :to_s on place of our message array. To our delight, it just works. Ruby does the right thing when splatting a singular symbol into a message's argument list.

require "./top3"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: :to_s
# => ["/home/avdi/Dropbox/rubytapas/120-outside-in/screencast-20130630-2000.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-14_16.28.41.mp4",
#     "/home/avdi/Dropbox/rubytapas/116-extract-command-object/screencast-20130612-1706.mp4",
#     "/home/avdi/Dropbox/rubytapas/278-lazy/media/screen-capture-2015-01-14_15.25.00.mp4",
#     "/home/avdi/Dropbox/rubytapas/113-p/screencast-20130604-1113.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/279-audited-predicate/media/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/276-fattr/media/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/095-gem-love-6/screencast-20130409-1751.mp4"]

This leaves with only one question. Is there any way to make this work with the original, concise top code? Remember, before, we top used Symbol#to_proc to avoid passing an explicit block to #map.

In unmodified Ruby this won't work. But we can make a very small change to make possible. All we have to do is define the #to_proc implicit conversion method on Array. This is the method that the & operator relies on to convert symbols and other objects into procs.

def top(things, on: :itself, by: :<, as: :itself)
  things.sort{|x, y|
    x_key = x.public_send(on)
    y_key = y.public_send(on)
    if x_key.public_send(by, y_key)
      -1
    elsif y_key.public_send(by, x_key)
      1
    else
      0
    end
  }.last(10).map(&as)
end

class Array
  def to_proc
    proc {|o| o.public_send(*self)}
  end
end
require "./top3"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: :to_s
# => ["/home/avdi/Dropbox/rubytapas/120-outside-in/screencast-20130630-2000.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-14_16.28.41.mp4",
#     "/home/avdi/Dropbox/rubytapas/116-extract-command-object/screencast-20130612-1706.mp4",
#     "/home/avdi/Dropbox/rubytapas/278-lazy/media/screen-capture-2015-01-14_15.25.00.mp4",
#     "/home/avdi/Dropbox/rubytapas/113-p/screencast-20130604-1113.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/279-audited-predicate/media/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/276-fattr/media/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/095-gem-love-6/screencast-20130409-1751.mp4"]

After this modification, we can pass both arrays and simple symbols as pluggable selectors to our top method.

require "./top4"
require "pathname"

files = Pathname.glob("/home/avdi/Dropbox/rubytapas/**/*.mp4")

top files, on: :size, as: [:relative_path_from, Pathname.pwd]
# => [#<Pathname:../120-outside-in/screencast-20130630-2000.mp4>,
#     #<Pathname:../footage/screen-capture-2015-01-14_16.28.41.mp4>,
#     #<Pathname:../116-extract-command-object/screencast-20130612-1706.mp4>,
#     #<Pathname:../278-lazy/media/screen-capture-2015-01-14_15.25.00.mp4>,
#     #<Pathname:../113-p/screencast-20130604-1113.mp4>,
#     #<Pathname:../footage/screen-capture-2015-01-17_04.49.32.mp4>,
#     #<Pathname:../279-audited-predicate/media/footage/screen-capture-2015-01-17_04.49.32.mp4>,
#     #<Pathname:../footage/screen-capture-2015-01-08_15.46.15.mp4>,
#     #<Pathname:../276-fattr/media/screen-capture-2015-01-08_15.46.15.mp4>,
#     #<Pathname:../095-gem-love-6/screencast-20130409-1751.mp4>]


top files, on: :size, as: :to_s
# => ["/home/avdi/Dropbox/rubytapas/120-outside-in/screencast-20130630-2000.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-14_16.28.41.mp4",
#     "/home/avdi/Dropbox/rubytapas/116-extract-command-object/screencast-20130612-1706.mp4",
#     "/home/avdi/Dropbox/rubytapas/278-lazy/media/screen-capture-2015-01-14_15.25.00.mp4",
#     "/home/avdi/Dropbox/rubytapas/113-p/screencast-20130604-1113.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/279-audited-predicate/media/footage/screen-capture-2015-01-17_04.49.32.mp4",
#     "/home/avdi/Dropbox/rubytapas/footage/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/276-fattr/media/screen-capture-2015-01-08_15.46.15.mp4",
#     "/home/avdi/Dropbox/rubytapas/095-gem-love-6/screencast-20130409-1751.mp4"]

And we have instantly made this trick available anywhere else in our program we would like to package a message up along with some arguments. Here's a trivial example where we multiply a series of numbers by two without writing out a block.

class Array
  def to_proc
    proc {|o| o.public_send(*self)}
  end
end

(1..10).map(&[:*, 2])
# => [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

And with that, I think we've seen enough for today. Happy hacking!

Responses