Project

General

Profile

Actions

Feature #8421

closed

add Enumerable#find_map and Enumerable#find_all_map

Added by Hanmac (Hans Mackowiak) almost 11 years ago. Updated about 1 month ago.

Status:
Feedback
Assignee:
-
Target version:
-
[ruby-core:55045]

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) almost 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) over 3 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) about 1 month 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 than filter_map.first or even lazy.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) about 1 month 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) about 1 month 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) about 1 month 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). Combining find and then seems like the simplest way now if you don't want to use break:

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) about 1 month 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), and break 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
Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0Like0Like0