Feature #8421
closedadd Enumerable#find_map and Enumerable#find_all_map
Added by Hanmac (Hans Mackowiak) over 12 years ago. Updated over 1 year ago.
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 12 years ago
Actions
#1
[ruby-core:55049]
- 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 5 years ago
Actions
#2
[ruby-core:101145]
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) over 1 year ago
Actions
#3
[ruby-core:117237]
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
nillike when#findreturns nothing -
#find_mapwould be faster thanfilter_map.firstor evenlazy.filter_map.first - It would add the parity with
filter_map.#findis to#filterwhat#find_mapis to#filter_map
Updated by zverok (Victor Shepelev) over 1 year ago
Actions
#4
[ruby-core:117239]
@alexbarret 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) over 1 year ago
Actions
#5
[ruby-core:117241]
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) over 1 year ago
· Edited
Actions
#6
[ruby-core:117242]
jeremyevans0 (Jeremy Evans) wrote in #note-5:
find_mapseems 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). Combiningfindandthenseems 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) over 1 year ago
· Edited
Actions
#7
[ruby-core:117243]
zverok (Victor Shepelev) wrote in #note-4:
@alexbarret 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
andallows to chain any statements to it (note it doesn't need extra parentheses after assignment), andbreak valueallows 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