In Progress
Unit 1, Lesson 1
In Progress

Concat

Video transcript & code

You almost certainly know how to append objects to an Array idiomatically, using the so-called "shovel" (<<) operator.

shopping_list = ["bread", "milk", "granola"]
shopping_list << "cheese"

Sometimes, rather than an individual object, we want to append the contents of another array. The shovel doesn't work so well for this, because it adds the entire second array as an element to the first array.

shopping_list = ["bread", "milk", "granola"]
shopping_list << ["swiss", "brie", "cheddar"]

If we want to keep using the shovel for this, we have to subsequently flatten the target array to get the results we want.

shopping_list = ["bread", "milk", "granola"]
shopping_list << ["swiss", "brie", "cheddar"]
shopping_list.flatten!

Another way to append arrays is to use the '+=' operator.

shopping_list = ["bread", "milk", "granola"]
shopping_list += ["swiss", "brie", "cheddar"]

At first glance this seems like an ideal solution. But there are actually a few problems with it.

Consider the case when we are appending to an object attribute - one that is read-only. For instance, here's a Shopper class that has read-only :list attribute. When we try to append to the list externally using +=, we get an exception informing us that there is no list= method.

class Shopper
  def initialize(list)
    @list = list
  end

  attr_reader :list
end

s = Shopper.new(["bread", "milk", "granola"])
s.list += ["swiss", "brie", "cheddar"]
# ~> -:10:in `<main>': undefined method `list=' for #<Shopper:0x00000001b6e120 @list=["bread", "milk", "granola"]> (NoMethodError)

In order to understand what happened we first need to better understand how += works. In effect, using += is equivalent to writing this code, where the two arrays are first added together and the result assigned to the original attribute using the writer method.

s.list = (s.list + ["swiss", "brie", "cheddar"])

…but there is no writer method for list, so this fails.

We can fix this by making list a read/write attribute, using attr_accessor. But now we have a more insidious problem. Consider two shoppers, Stacey and Avdi, who want to share a single shopping list. When one adds an item to the list, they should both see the change. To accomplish this, we pass the same array into the initializer for both shoppers.

Unfortunately, when we use += to add items to Stacey's list, the change is not reflected in Avdi's list. The reason for this is that += makes a new combined array and assigns that new array to the attribute. So we haven't really added items to the shared list; we've just replaced Stacey's list with a new one.

class Shopper
  def initialize(list)
    @list = list
  end

  attr_accessor :list
end

shared_list = ["bread", "milk", "granola"]
stacey = Shopper.new(shared_list)
avdi   = Shopper.new(shared_list)
stacey.list += ["swiss", "brie", "cheddar"]
stacey.list                     # => ["bread", "milk", "granola", "swiss", "brie", "cheddar"]
avdi.list                       # => ["bread", "milk", "granola"]
stacey.list.object_id           # => 4977680
avdi.list.object_id             # => 4977820

The right way to append items to an existing array is with #concat. When we send the message #concat to the shared list, with the array of new items as an argument, the target array is updated in-place. We get the behavior we expect, with both shopper attributes reflecting the new contents.

class Shopper
  def initialize(list)
    @list = list
  end

  attr_accessor :list
end

shared_list = ["bread", "milk", "granola"]
stacey = Shopper.new(shared_list)
avdi   = Shopper.new(shared_list)
stacey.list.concat(["swiss", "brie", "cheddar"])
stacey.list                     # => ["bread", "milk", "granola", "swiss", "brie", "cheddar"]
avdi.list                       # => ["bread", "milk", "granola", "swiss", "brie", "cheddar"]

But using #concat isn't just a matter of elegance or semantics. It's also faster. To show this, let's benchmark all three approaches: shovel plus flatten, +=, and #concat. We'll use each approach to append to a single array a thousand times.

require 'benchmark'

Benchmark.bm do |bm|
  bm.report("<<+flatten:  ") do
    list = ["bread", "milk", "granola"]
    1000.times do
      list << ["swiss", "brie", "cheddar"]
      list.flatten!
    end
  end
  bm.report("+=:          ") do
    list = ["bread", "milk", "granola"]
    1000.times do
      list += ["swiss", "brie", "cheddar"]
    end
  end
  bm.report("#concat:     ") do
    list = ["bread", "milk", "granola"]
    1000.times do
      list.concat(["swiss", "brie", "cheddar"])
    end
  end
end
       user     system      total        real
<<+flatten:    0.070000   0.000000   0.070000 (  0.075663)
+=:            0.000000   0.000000   0.000000 (  0.003620)
#concat:       0.000000   0.000000   0.000000 (  0.000341)

We can see that there is a major difference in performance between the three approaches. #concat is considerably faster than its closest competitor, the += operator. Meanwhile the shovel-plus-flatten approach trails far behind.

So in summary: unless you specifically want to create a new combined array rather than append to an existing one, prefer Array#concat over +=. And whatever you do, don't concatenate arrays using shovel-and-flatten.

Happy hacking!

Responses