Feature #8421
closedadd Enumerable#find_map and Enumerable#find_all_map
Description
currently if you have an Enumerable and you want to return the return value of #find you need eigther:
(o = enum.find(block) && block.call(o)) || nil
or
enum.inject(nil) {|ret,el| ret || block.call(el)}
neigher of them may be better than an directly maked method
same for #find_all_map
enum.lazy.map(&:block).find_all{|el| el}
it may work but it is not so good
Updated by matz (Yukihiro Matsumoto) over 11 years ago
- Status changed from Open to Feedback
Could you tell me a concrete use-case of your find_map and find_all_map?
Usually a block for find/find_all gives boolean so that I personally have never wanted the return value from it.
Matz.
Updated by modulitos (Lucas Swart) almost 4 years ago
In Ruby 2.7, I think we can use enumerable.lazy.filter_map{..}.first
as an equivalent for .find_map{..}
Updated by alexbarret (Alexandre Barret) 8 months ago
Can we reconsider introducing #find_map
please, especially since #find_all_map
has been introduced as #filter_map
in Ruby in 2.7?
Here are some examples
require "minitest/autorun"
# Option 1
def identifier(emails, pattern: /\Ausername\+(?<identifier>[a-z|0-9]+)@domain\.com\z/i)
result = nil
emails.each do |email|
if matches = pattern.match(email)
result = matches[:identifier]
break
end
end
result
end
# Option 2
def identifier(emails, pattern: /\Ausername\+(?<identifier>[a-z|0-9]+)@domain\.com\z/i)
matches = nil
matches[:identifier] if emails.find { |email| matches = pattern.match(email) }
end
class TestIdentifierMethod < Minitest::Test
def test_identifier
assert_equal 'thecode', identifier(%w[
username@domain.com
username+123@domainAcom
wrongusername+123@domain.com
username+123@wrongdomain.com
username+thecode@domain.com
])
assert_nil identifier(%w[
username@domain.com
username+123@domainAcom
wrongusername+123@domain.com
username+123@wrongdomain.com
])
end
end
Having a find_map would ease it a bit
def find_map(collection, &block)
result = nil
collection.each do |item|
break if result = yield(item)
end
result
end
def identifier(emails, pattern: /\Ausername\+(?<identifier>[a-z|0-9]+)@domain\.com\z/i)
find_map(emails) do |email|
(matches = pattern.match(email)) && matches[:identifier]
end
end
Here is a second use case
# Problem 2
Pet = Struct.new(:name)
Person = Struct.new(:name, :pet, keyword_init: true)
class TestPetIdentitifer < Minitest::Test
def setup
@some_people_with_pet = [
Person.new(name: 'Alex', pet: nil),
Person.new(name: 'Olivier', pet: nil),
Person.new(name: 'Romain', pet: Pet.new('Darwin')),
Person.new(name: 'Mariano', pet: nil),
Person.new(name: 'Sébastien', pet: nil),
Person.new(name: 'Ben', pet: nil)
]
@people_with_no_pet = [
Person.new(name: 'Mariano', pet: nil),
Person.new(name: 'Sébastien', pet: nil),
Person.new(name: 'Ben', pet: nil)
]
end
def test_pet_found
people = @some_people_with_pet
expected_pet = Pet.new('Darwin')
assert_equal expected_pet, people.find(&:pet)&.pet
assert_equal expected_pet, find_map(people, &:pet) # -> people.find_map(&:pet)
end
def test_pet_not_found
people = @people_with_no_pet
assert_nil people.find(&:pet)&.pet
assert_nil find_map(people, &:pet) # -> people.find_map(&:pet)
end
end
Having #find_map
allows these benefits
- The caller does not need to guard against
nil
like when#find
returns nothing -
#find_map
would be faster thanfilter_map.first
or evenlazy.filter_map.first
- It would add the parity with
filter_map
.#find
is to#filter
what#find_map
is to#filter_map
Updated by zverok (Victor Shepelev) 8 months ago
@alexbarret (Alexandre Barret) There is a somewhat lesser-known trick which looks pretty close to your code:
# proposal:
find_map(emails) do |email|
(matches = pattern.match(email)) && matches[:identifier]
end
# a "trick"
emails.find { |email| match = pattern.match(email) and break match[:identifier] }
# => "thecode"
It might even be considered two tricks, depending on your point of view: the control-flow and
allows to chain any statements to it (note it doesn't need extra parentheses after assignment), and break value
allows to return a non-standard value from a block.
Not saying it is beautiful, just one more option.
Updated by jeremyevans0 (Jeremy Evans) 8 months ago
find_map
seems like a bad name as there is no map. map implies calling the same function over all elements in a collection, and in this case, there would be a single element (or none if nothing was found). Combining find
and then
seems like the simplest way now if you don't want to use break
:
emails.find{ pattern.match(it) }&.then{ it[:identifier] }
Personally, I would use the following approach is I think it is clearer:
if match = emails.find{ pattern.match(it) }
match[:identifier]
end
Updated by alexbarret (Alexandre Barret) 8 months ago · Edited
jeremyevans0 (Jeremy Evans) wrote in #note-5:
find_map
seems like a bad name as there is no map. map implies calling the same function over all elements in a collection, and in this case, there would be a single element (or none if nothing was found). Combiningfind
andthen
seems like the simplest way now if you don't want to usebreak
:
This is a good point. I always thought map was the action of transforming an element but it does have a implicit reference to an array or a collection.
emails.find{ pattern.match(it) }&.then{ it[:identifier] }
Personally, I would use the following approach is I think it is clearer:
if match = emails.find{ pattern.match(it) } match[:identifier] end
The code above doesn't work ^. match
variable assigned is an email string not the matches found from the email.
We implicitly thought that find
was returning the matches not the item iterated over.
EDIT: To some extent the code above kind of validate the idea of a find_map
, the difference is that it's not applied at the same level than the code example given.
Updated by alexbarret (Alexandre Barret) 8 months ago · Edited
zverok (Victor Shepelev) wrote in #note-4:
@alexbarret (Alexandre Barret) There is a somewhat lesser-known trick which looks pretty close to your code:
# proposal: find_map(emails) do |email| (matches = pattern.match(email)) && matches[:identifier] end # a "trick" emails.find { |email| match = pattern.match(email) and break match[:identifier] } # => "thecode"
It might even be considered two tricks, depending on your point of view: the control-flow
and
allows to chain any statements to it (note it doesn't need extra parentheses after assignment), andbreak value
allows to return a non-standard value from a block.Not saying it is beautiful, just one more option.
Thanks I learned something, and it makes sense thinking about it.
Both and
and break
aren't things I'm used to using in Ruby code but I'm glad I know this trick now.
Breaking with another value than the expected value returned from the enumerable method feels "smelly" but I don't think it's any worse than this.
# Option 2
def identifier(emails, pattern: /\Ausername\+(?<identifier>[a-z|0-9]+)@domain\.com\z/i)
matches = nil
matches[:identifier] if emails.find { |email| matches = pattern.match(email) }
end
That would actually be what #find_map
is the equivalent of
emails.find_map { |email| method(email) } == emails.find { |email| result = method(email) and break result }
# more equivalent than this alternative
emails.find_map { |email| method(email) } == emails.lazy.filter_map { |email| method(email) }.first