Understanding Enumerable#lazy Proactively
Ruby 2.0.0 introduced Enumerator::Lazy
to make infinite lists possible
in finite way.
E.g. If we need first n integers
As a first attempt, we will generate integers starting from 1 to a very big number, and pick first n integers
- Very Big Number can put us in trouble, if not chosen wisely.
- If its really big, it will take time / memory to store long list of integers.
- If its not sufficiently big, we will loose some / many integers
Ruby 2.0.0 and after
2.0.0-p451 :003 > (1..Float::INFINITY).lazy.map {|x| x}.first(10)
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Above version without .lazy
becomes unresponsive as it enters into
infinite loop
Prior to Ruby 2.0.0, it would have been like
def first_elements(n)
a = []
(1..Float::INFINITY).map do |x|
a << x
break if a.size > (n - 1)
end
a
end
first_elements(10)
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Lot of hoops taken and doesn’t look very declarative / expressive too
No doubt, #lazy
is more expressive and appeals to single-line lovers :)
Though more expressive, lazy
does provide a barrier to use effectively
and it’s adoption in production ready code is rare / sparse.
Analysing the simple example above, a simple rule to overcome the
barrier of Enumerator::Lazy
use can be evaluating the condition to
break from infinite loop first and working backwards from it.
Another example to work out above rule is a blog by thoughtbot Lazy Refactoring
def self.search(query)
matching_column = [:name, :city, :title].detect do |column|
Event.where(column => query).exists?
end
if matching_column
Event.where(matching_column => query)
else
Event.fuzzy_search(query)
end
end
A simple walkthrough of above code
- Check, if any column from [:name, :city, :title] has result matching to query
- Store the matching column if any
- If there is any matching column, find the result on matched column
- If no matching column found, return result of fuzzy search from query
Here,
- Infinite loop translates to available options which is query result on any [:name, :city, :title]
results = [:name, :city, :title].map {|column| Event.where(column => query)}
- Breaking condition is match found for query from avilable query results
first_found = results.detect { |x| x.exists? }
Sample Code to test lazy execution
class Event
def self.where(o = {})
o.has_key?(:name)
self.new(o)
end
def initialize(details)
@details = details
end
def exists?
true
end
end
query = 'lazy_works'
puts "Without Lazy"
first_result = [:name, :city, :title].map {|column| p Event.where(column
=> query)}.detect { |x| p x.exists? }
p first_result
puts
puts "With Lazy"
lazy_first_result = [:name, :city, :title].lazy.map {|column| p
Event.where(column => query)}.detect { |x| p x.exists? }
p lazy_first_result
puts
Output
Without Lazy
#<Event:0x000001020a25c8 @details={:name=>"lazy_works"}>
#<Event:0x000001020a2438 @details={:city=>"lazy_works"}>
#<Event:0x000001020a22d0 @details={:title=>"lazy_works"}>
true
#<Event:0x000001020a25c8 @details={:name=>"lazy_works"}>
With Lazy
#<Event:0x000001020a1b50 @details={:name=>"lazy_works"}>
true
#<Event:0x000001020a1b50 @details={:name=>"lazy_works"}>
Observations
- Without lazy version evaluates all possible results and then filters the desired result
- With lazy, it evaluates first result and checks whether it satisfies
filtering condition
- As it does, it breaks without evalluating other results
- Ruby lazy Enumerable can come handy at places where early return is desired if first match is found