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