How to Build Your Own Map Method in Ruby

How to Build Your Own Map Method in Ruby
Flowers on the shelves

One of Ruby's most powerful features is the ability to extend the language by writing your own methods. In this article, we'll explore how Ruby's popular map method works and build our own implementation from scratch.

Why Build Our Own Map Method?

Ruby already has a map method, so why create our own implementation? Here are several reasons:

  • Learning purpose: Better understand Ruby's internal workings
  • Customization: Extend the map method for your specific needs
  • Performance optimization: Create optimized versions for specific use cases
  • Technical interview preparation: These types of questions are frequently asked

Understanding the Map Method Logic

The map method transforms each element of a collection using a block and returns a new array:

# Ruby's built-in map method
[1, 2, 3, 4].map { |x| x * 2 }
# => [2, 4, 6, 8]

Our First Implementation

Let's start with the simplest map implementation:

class Array
  def my_map
    result = []
    each { |element| result << yield(element) }
    result
  end
end

# Let's test it
numbers = [1, 2, 3, 4, 5]
doubled = numbers.my_map { |x| x * 2 }
puts doubled.inspect  # [2, 4, 6, 8, 10]

This implementation works, but there's a problem: we get an error when no block is provided.

Adding Enumerator Support

Ruby's original map method returns an Enumerator when no block is given. Let's add this to our implementation:

class Array
  def my_map
    # Return Enumerator if no block is given
    return enum_for(:my_map) unless block_given?
    
    result = []
    each { |element| result << yield(element) }
    result
  end
end

# Test: With block
[1, 2, 3].my_map { |x| x ** 2 }  # => [1, 4, 9]

# Test: Without block (returns Enumerator)
enum = [1, 2, 3].my_map
enum.with_index { |x, i| "#{i}: #{x}" }  # => ["0: 1", "1: 2", "2: 3"]

More Flexible Approach: Using Modules

Instead of just extending the Array class, let's create a module:

module MyEnumerable
  def my_map
    return enum_for(:my_map) unless block_given?
    
    result = []
    each { |element| result << yield(element) }
    result
  end
  
  def my_map_with_index
    return enum_for(:my_map_with_index) unless block_given?
    
    result = []
    each_with_index { |element, index| result << yield(element, index) }
    result
  end
end

# Include in different classes
class MyArray
  include MyEnumerable
  
  def initialize(items)
    @items = items
  end
  
  def each
    return enum_for(:each) unless block_given?
    @items.each { |item| yield(item) }
  end
end

# Usage
my_array = MyArray.new([1, 2, 3])
result = my_array.my_map { |x| x * 3 }
puts result.inspect  # [3, 6, 9]

Performance Optimization

An optimized version for large datasets:

module FastEnumerable
  def fast_map
    return enum_for(:fast_map) unless block_given?
    
    # Pre-allocate array with known size
    if respond_to?(:size)
      result = Array.new(size)
      each_with_index { |element, i| result[i] = yield(element) }
    else
      result = []
      each { |element| result << yield(element) }
    end
    
    result
  end
end

Error Handling

Let's add error handling for a robust implementation:

module SafeEnumerable
  def safe_map
    return enum_for(:safe_map) unless block_given?
    
    result = []
    each_with_index do |element, index|
      begin
        result << yield(element)
      rescue => e
        warn "Error at index #{index}: #{e.message}"
        result << nil  # or another default value
      end
    end
    
    result
  end
end

Real-World Example

A custom map method for processing API data:

class APIResponse
  include MyEnumerable
  
  def initialize(data)
    @data = data
  end
  
  def each
    return enum_for(:each) unless block_given?
    @data.each { |item| yield(item) }
  end
  
  def transform_users
    my_map do |user_data|
      {
        id: user_data['id'],
        name: user_data['full_name']&.strip,
        email: user_data['email']&.downcase,
        created_at: Time.parse(user_data['created_at'])
      }
    end
  end
end

# Usage
response = APIResponse.new([
  {'id' => 1, 'full_name' => 'John Doe', 'email' => 'JOHN@EMAIL.COM'},
  {'id' => 2, 'full_name' => 'Jane Smith', 'email' => 'JANE@EMAIL.COM'}
])

users = response.transform_users

Benchmark: Performance Comparison

Let's test our implementation's performance:

require 'benchmark'

array = (1..100_000).to_a

Benchmark.bm do |x|
  x.report("Built-in map:") { array.map { |x| x * 2 } }
  x.report("Custom my_map:") { array.my_map { |x| x * 2 } }
end

Testing Our Implementation

Don't forget to test your custom methods:

require 'minitest/autorun'

class TestMyMap < Minitest::Test
  def setup
    @array = [1, 2, 3, 4, 5]
  end
  
  def test_my_map_with_block
    result = @array.my_map { |x| x * 2 }
    assert_equal [2, 4, 6, 8, 10], result
  end
  
  def test_my_map_without_block
    enum = @array.my_map
    assert_instance_of Enumerator, enum
  end
  
  def test_my_map_preserves_original
    original = @array.dup
    @array.my_map { |x| x * 2 }
    assert_equal original, @array
  end
end

Best Practices

When implementing custom enumerable methods:

  1. Always check for blocks: Use block_given? and return enumerators appropriately
  2. Don't mutate the original: Create new collections instead
  3. Handle edge cases: Empty collections, nil values, etc.
  4. Consider performance: Pre-allocate arrays when size is known
  5. Add comprehensive tests: Cover all scenarios
  6. Document your methods: Explain parameters and return values

Conclusion

Building your own map method is an excellent exercise for understanding Ruby's internals. What we've learned:

  • ✅ Basic map implementation
  • ✅ Adding Enumerator support
  • ✅ Writing reusable code with modules
  • ✅ Error handling and performance optimization
  • ✅ Real-world usage examples
  • ✅ Advanced features like lazy evaluation

Which Ruby enumerable method would you most want to implement next, and why? Drop your answers in the comments!