1. Computing

Why Does 'map' Return an Array?

By

Why Does 'map' Return an Array?

Imagine the following situation: you've subclassed the Array class. Your class is called CountedArray, it counts the number of times push and pop have been called on it to give the programmer a metric to measure the amount of times the array is modified. However, when you call map on an instance of this CountedArray class, map returns a normal Array object. Why?

The CountedArray Class

The objective of this class is very simple: Count the number of times push and pop are called, and provide methods to retrieve these counts. By convention, other methods to grow or shrink the array will not be used, but there will be nothing stopping a programmer from using them. For instance, a programmer could use the []= method with an index larger than the size of the array, and this class wouldn't catch it.


class CountedArray < Array
  attr_reader :push_count, :pop_count

  def initialize(*a)
    @push_count = 0
    @pop_count = 0
    super(*a)
  end

  def push(*a)
    @push_count += 1
    super(*a)
  end

  def pop(*a)
    @pop_count += 1
    super(*a)
  end
end

As you can see, the splat and soak operators are used quite a bit here, as is the super method call keyword. But other than that, it's very straightforward. Just overload the push and pop methods, add in the counter, implement the access methods with attr_reader and let super do all the work.

What's Wrong With 'map'?

So on to the problem. You have a CountedArray object. You need a copy of this object, except every element in the array needs to be incremented. Your first idea might be to use the map method.

The map method makes a copy of the object (or doesn't, if you use the destructive map!, note the exclamation point idiom) and, for each object in the collection, pass that object from the collection to a block and replace it with the result of the block. It's a very useful method, it's used everywhere. But in this case, it's misunderstood.

Look at the following code. The programmer wanted to create a CountedArray object, push some numbers onto it and then send a CountedArray object whose elements were incremented by one (but the push and pop counts remained the same) to an analysis method. But instead, the programmer just gets an error message (via an uncaught exception).


def analyze(a)
  puts "Length of a: #{a.length}"
  puts "Push count:  #{a.push_count}"
  puts "Pop count:   #{a.pop_count}"
  puts "Total:       #{a.inject(:+)}"
end

a = CountedArray.new
a.push 1
a.push 2
a.push 3
a.pop
a.push 4
analyze( a.map{|o| o+1 } )

The error produced is undefined method `push_count' for [2, 3, 5]:Array (NoMethodError). It's saying that the object passed to the analyze method is a simple Array object, not a CountedArray object. Clearly, the map method is at fault here, it's the only thing that could have done this. The programmer has made a mistake, he's misunderstood what map is, what it does and how it does it.

The map method doesn't live in the Array class. It doesn't know anything about Arrays. It doesn't know it's operating on Arrays, or Hashes, or any other kind of object. The map method only knows that the object it's operating on is Enumerable, and that it's building an Array of what a block thinks of its elements. Other than that, even though map appears to be a member function of Array and SortedArray, it doesn't know anything about those. The map method lives in the Enumerable module. It's part of what's called a mixin module.

So, as you can see, the programmer's mistake is possibly an easy one to make. It looks like the map method would duplicate the object and work its magic. It looks like it's self aware. But really, the map method is a faceless automaton, trudging away on any object that meets its rather bleak parameters (that each can be called on it). So, clearly, if you need to increment all the elements of a CountedArray, then map is not the way to go.

Solutions

"OK," you say, "I'll change the behavior of map so it works how I want!" Wait a minute there, this is a bad idea. Yes, you can do this, but it's really, really not recommended. If Alice, who didn't understand what map did and spent the afternoon figuring it out, changes the behavior or a staple of the Ruby landscape, then when Bob goes to use map on a CountedArray object, he's going to be even more confused. Or, even worse, when a year from then Alice herself does the same thing. This is often referred to as object specific behavior, or "monkeypatching," or changing the way Ruby works to suit your needs. This is akin to blazing a trail right through trees, instead of simply going around them. Rarely appropriate, but (with the right equipment, which Ruby has no shortage of), less thought is required.

So, before you implement a solution, you need to ask yourself whether this is a common thing to do or not. Do you do a one-off right in the method body in place of map? Or do you implement a method specifically for this purpose? Below are both solutions.


a = CountedArray.new
a.push 1
a.push 2
a.push 3
a.pop
a.push 4
analyze( a.dup.tap{|arry| arry.each_with_index{|o,i| arry[i] = o+1 } } )

This is pretty convoluted. The array is duplicated, tap is used to get an instance to the new object (which hasn't been assigned to a variable), and each_with_index is used to do the incrementing. Map isn't used at all, and the arrays stay as CountedArrays. If you want this to be more than one line, you can make it a bit more clear (and eliminate the tap method, a method that isn't used very often and may throw other programmers for a loop).


class CountedArray
  def increment
    each_with_index do|o,i|
      self[i] = o+1
    end
  end
end

a = CountedArray.new
a.push 1
a.push 2
a.push 3
a.pop
a.push 4
analyze( a.dup.increment )

This time, a method hides the implementation. It's more clear, since the semantic meaning of the action is represented as it's run, but flexibility is lost. Neither solution is better, it depends on how such code is actually used. But at the end of the day, remember how map is implemented, and its caveats.

  1. About.com
  2. Computing
  3. Ruby
  4. Advanced Ruby
  5. Why Does 'map' Return an Array?

©2014 About.com. All rights reserved.