How to Build Your Own Map Method in Ruby
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:
- Always check for blocks: Use
block_given?
and return enumerators appropriately - Don't mutate the original: Create new collections instead
- Handle edge cases: Empty collections, nil values, etc.
- Consider performance: Pre-allocate arrays when size is known
- Add comprehensive tests: Cover all scenarios
- 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!