In Progress
Unit 1, Lesson 1
In Progress

Array Function

Video transcript & code

I seriously can't believe I haven't done an episode on today's topic before, but my site search tells me I haven't. So! Today we're going to talk about one of my favorite Ruby core methods.

Let's say we're making a wish list class.

To add stuff to a wish list, we'll first instantiate it.

For maximum convenience of client programmers, we want to support all likely calling conventions for adding objects to the list.

Naturally, we want them to be able to add a single object to the list.

But if they pass an array of multiple items, we want to support this form as well.

And if for some reason a nil is passed, we want to just quietly ignore it, instead of adding it to the list or raising an exception.

class WishList
  def initialize
    @items = []
  end
end

list = WishList.new
list.add("Rancilio Silvia Espresso Machine")
list.add(["Fancy boots", "Boot socks"])
list.add(nil)

Let's think about how we might filter the method inputs in order to support all of these possibilities.

The first solution that comes to mind is to use the to_a conversion method to normalize all inputs to an array. For instance, when we use to_a on an array of wish list items, we get the same array back.

And when we send it to nil, we get an empty array, which is exactly what we want.

But when we use it on a singular, scalar value, we get a NoMethodError.

["Bookmarks", "Art Deco Posters"].to_a # => ["Bookmarks", "Art Deco Posters"]
nil.to_a                               # => []
"Hiking poles".to_a                    # => NoMethodError: undefined method `to_a' for "Hiking poles":String

# ~> NoMethodError
# ~> undefined method `to_a' for "Hiking poles":String
# ~>
# ~> xmptmp-in12195WFb.rb:3:in `<main>'

So clearly this is not a universal solution.

Ruby provides an alternative way to convert arbitrary values to an array, and it looks like this.

When we pass in an array, we get the same array back, just like with to_a.

If we pass nil to it, we get an empty array.

And if we pass a singular object, instead of an error, we get an array with just that one object inside it.

Array(["Bookmarks", "Art Deco Posters"]) # => ["Bookmarks", "Art Deco Posters"]
Array(nil)                      # => []
Array("Hiking poles")           # => ["Hiking poles"]

We've seen funny-looking capitalized methods like these before, in some earlier episodes. They are called conversion functions, and Ruby provides them for a number of built-in types. We talked in depth about conversion functions in episode #207.

It's worth noting, however, that the Array() conversion function has differing semantics from some of Ruby's other conversion functions.

Many of Ruby's conversion functions are stricter than their corresponding conversion methods.

For instance, when we use the Integer() function to convert a purely numeric string to an integer, it works.

…but when we include any non-numeric content in the string, it fails.

Integer("12")                   # => 12
Integer("12 monkeys")           # => ArgumentError: invalid value for Integer(): "12 monkeys"

# ~> ArgumentError
# ~> invalid value for Integer(): "12 monkeys"
# ~>
# ~> xmptmp-in12195-k0.rb:2:in `Integer'
# ~> xmptmp-in12195-k0.rb:2:in `<main>'

By contrast, the Array() function is the most lenient and accommodating of all the ways Ruby provides to turn things into arrays.

It will turn scalar values into arrays.

It will convert hashes to arrays.

In other episodes you might have heard me refer to this function as the "arrayification function", because it has the effect of turning anything it touches into an array.

In fact, about the only way I know of to not get an array out of this function is to give it an object with a broken array conversion method.

Array(123)                      # => [123]
Array(:rowsdower)               # => [:rowsdower]
Array(foo: 1, bar: 2)           # => [[:foo, 1], [:bar, 2]]

o = Object.new
def o.to_a
  fail "Gotcha!" # ~> RuntimeError: Gotcha!
end

Array(o)                        # =>

# ~> RuntimeError
# ~> Gotcha!
# ~>
# ~> xmptmp-in12195A8e.rb:7:in `to_a'
# ~> xmptmp-in12195A8e.rb:10:in `Array'
# ~> xmptmp-in12195A8e.rb:10:in `<main>'

This Array() conversion function seems like a good bet for our wishlist adding method. All we need to do inside the method is concatenate the array-ified items variable to the current list.

When we take a look at the items list after our various experimental add sends, everything looks good.

Single objects, arrays, and nil values are all handled correctly.

class WishList
  attr_reader :items

  def initialize
    @items = []
  end

  def add(items)
    @items.concat(Array(items))
  end
end

list = WishList.new
list.add("Rancilio Silvia Espresso Machine")
list.add(["Fancy boots", "Boot socks"])
list.add(nil)
list.items
# => ["Rancilio Silvia Espresso Machine", "Fancy boots", "Boot socks"]

But there's one more calling convention we'd like to support. What if we pass multiple items in as separate arguments, instead of inside an array?

list.add("A Zeppelin", "200,000 cubic meters of helium")

Right now our add method doesn't accept this kind of call, because it's only set up to take a single argument.

To change this, we'll need to add a star to cause it to "slurp" an arbitrary number of arguments into a single array.

(We first talked about slurping parameters back in episode #80)

When we run this code, nothing blows up.

But examining the output, we can see that we've broken the semantics of our add method.

The array we passed has been preserved, instead of being broken down into its component items.

And the nil we passed has been preserved as well.

class WishList
  attr_reader :items

  def initialize
    @items = []
  end

  def add(*items)
    @items.concat(Array(items))
  end
end

list = WishList.new
list.add("Rancilio Silvia Espresso Machine")
list.add(["Fancy boots", "Boot socks"])
list.add(nil)
list.add("A Zeppelin", "200,000 cubic meters of helium")
list.items
# => ["Rancilio Silvia Espresso Machine",
#     ["Fancy boots", "Boot socks"],
#     nil,
#     "A Zeppelin",
#     "200,000 cubic meters of helium"]

Now that we are having Ruby combine all arguments into an array, what we really need is to array-ify each element of this input array, and then combine all the resulting arrays into one.

class WishList
  attr_reader :items

  def initialize
    @items = []
  end

  def add(*items)
    @items.concat(items.map{|i| Array(i)}.flatten)
  end
end

list = WishList.new
list.add(["Fancy boots", "Boot socks"])
list.add(nil)
list.add("Rancilio Silvia Espresso Machine")
list.add("A Zeppelin", "200,000 cubic meters of helium")
list.items
# => ["Fancy boots",
#     "Boot socks",
#     "Rancilio Silvia Espresso Machine",
#     "A Zeppelin",
#     "200,000 cubic meters of helium"]

Another way to express the same operation is as a flat_map, where the mapping function is the Array conversion function.

class WishList
  attr_reader :items

  def initialize
    @items = []
  end

  def add(*items)
    @items.concat(items.flat_map(&method(:Array)))
  end
end

list = WishList.new
list.add(["Fancy boots", "Boot socks"])
list.add(nil)
list.add("Rancilio Silvia Espresso Machine")
list.add("A Zeppelin", "200,000 cubic meters of helium")
list.items
# => ["Fancy boots",
#     "Boot socks",
#     "Rancilio Silvia Espresso Machine",
#     "A Zeppelin",
#     "200,000 cubic meters of helium"]

Either way, we get the results we want: individual items are added, arrays are split up, and nils are squashed, whether there is a single argument or more than one.

class WishList
  attr_reader :items

  def initialize
    @items = []
  end

  def add(*items)
    @items.concat(items.flat_map(&method(:Array)))
  end
end

list = WishList.new
list.add(["Fancy boots", "Boot socks"])
list.add(nil)
list.add("Rancilio Silvia Espresso Machine")
list.add("A Zeppelin", "200,000 cubic meters of helium")
list.items
# => ["Fancy boots",
#     "Boot socks",
#     "Rancilio Silvia Espresso Machine",
#     "A Zeppelin",
#     "200,000 cubic meters of helium"]

The array conversion function is one of the most well-worn tools in my Ruby toolbox. Any time I want to clean up and normalize some uncertain input to always be an array, I throw this arrayification function around it and move on.

And that's it for today. Happy hacking!

Responses