Map an array modifying only elements matching a certain condition
Asked Answered
R

9

19

In Ruby, what is the most expressive way to map an array in such a way that certain elements are modified and the others left untouched?

This is a straight-forward way to do it:

old_a = ["a", "b", "c"]                         # ["a", "b", "c"]
new_a = old_a.map { |x| (x=="b" ? x+"!" : x) }  # ["a", "b!", "c"]

Omitting the "leave-alone" case of course if not enough:

new_a = old_a.map { |x| x+"!" if x=="b" }       # [nil, "b!", nil]

What I would like is something like this:

new_a = old_a.map_modifying_only_elements_where (Proc.new {|x| x == "b"}) 
        do |y|
          y + "!"
        end
# ["a", "b!", "c"]

Is there some nice way to do this in Ruby (or maybe Rails has some kind of convenience method that I haven't found yet)?


Thanks everybody for replying. While you collectively convinced me that it's best to just use map with the ternary operator, some of you posted very interesting answers!

Religion answered 5/3, 2009 at 11:3 Comment(5)
I think the #map thing is fine as it is. ;-)Lorelle
Yeah, I agree. You can take out the parens if that makes you like it more!Rife
Closed? FTL. Look at my post =PSchooling
I came here looking for a way to keep only certain elements, and I found this quite helpful: #5152598Heth
Ruby 2.7 introduces a built in solution for this: filter_map, more info here.Faradism
K
6

I agree that the map statement is good as it is. It's clear and simple,, and would easy for anyone to maintain.

If you want something more complex, how about this?

module Enumerable
  def enum_filter(&filter)
    FilteredEnumerator.new(self, &filter)
  end
  alias :on :enum_filter
  class FilteredEnumerator
    include Enumerable
    def initialize(enum, &filter)
      @enum, @filter = enum, filter
      if enum.respond_to?(:map!)
        def self.map!
          @enum.map! { |elt| @filter[elt] ? yield(elt) : elt }
        end
      end
    end
    def each
      @enum.each { |elt| yield(elt) if @filter[elt] }
    end
    def each_with_index
      @enum.each_with_index { |elt,index| yield(elt, index) if @filter[elt] } 
    end
    def map
      @enum.map { |elt| @filter[elt] ? yield(elt) : elt }
    end
    alias :and :enum_filter
    def or
      FilteredEnumerator.new(@enum) { |elt| @filter[elt] || yield(elt) }
    end
  end
end

%w{ a b c }.on { |x| x == 'b' }.map { |x| x + "!" } #=> [ 'a', 'b!', 'c' ]

require 'set'
Set.new(%w{ He likes dogs}).on { |x| x.length % 2 == 0 }.map! { |x| x.reverse } #=> #<Set: {"likes", "eH", "sgod"}>

('a'..'z').on { |x| x[0] % 6 == 0 }.or { |x| 'aeiouy'[x] }.to_a.join #=> "aefiloruxy"
Kathaleenkatharevusa answered 5/3, 2009 at 14:59 Comment(2)
You've turned one line into thirty-something. I like your style.Classic
I did say that sticking the conditional w/in the map was best :)Kathaleenkatharevusa
S
25

Because arrays are pointers, this also works:

a = ["hello", "to", "you", "dude"]
a.select {|i| i.length <= 3 }.each {|i| i << "!" }

puts a.inspect
# => ["hello", "to!", "you!", "dude"]

In the loop, make sure you use a method that alters the object rather than creating a new object. E.g. upcase! compared to upcase.

The exact procedure depends on what exactly you are trying to achieve. It's hard to nail a definite answer with foo-bar examples.

Schooling answered 9/3, 2009 at 10:35 Comment(0)
R
9
old_a.map! { |a| a == "b" ? a + "!" : a }

gives

=> ["a", "b!", "c"]

map! modifies the receiver in place, so old_a is now that returned array.

Rycca answered 5/3, 2009 at 21:54 Comment(0)
K
6

I agree that the map statement is good as it is. It's clear and simple,, and would easy for anyone to maintain.

If you want something more complex, how about this?

module Enumerable
  def enum_filter(&filter)
    FilteredEnumerator.new(self, &filter)
  end
  alias :on :enum_filter
  class FilteredEnumerator
    include Enumerable
    def initialize(enum, &filter)
      @enum, @filter = enum, filter
      if enum.respond_to?(:map!)
        def self.map!
          @enum.map! { |elt| @filter[elt] ? yield(elt) : elt }
        end
      end
    end
    def each
      @enum.each { |elt| yield(elt) if @filter[elt] }
    end
    def each_with_index
      @enum.each_with_index { |elt,index| yield(elt, index) if @filter[elt] } 
    end
    def map
      @enum.map { |elt| @filter[elt] ? yield(elt) : elt }
    end
    alias :and :enum_filter
    def or
      FilteredEnumerator.new(@enum) { |elt| @filter[elt] || yield(elt) }
    end
  end
end

%w{ a b c }.on { |x| x == 'b' }.map { |x| x + "!" } #=> [ 'a', 'b!', 'c' ]

require 'set'
Set.new(%w{ He likes dogs}).on { |x| x.length % 2 == 0 }.map! { |x| x.reverse } #=> #<Set: {"likes", "eH", "sgod"}>

('a'..'z').on { |x| x[0] % 6 == 0 }.or { |x| 'aeiouy'[x] }.to_a.join #=> "aefiloruxy"
Kathaleenkatharevusa answered 5/3, 2009 at 14:59 Comment(2)
You've turned one line into thirty-something. I like your style.Classic
I did say that sticking the conditional w/in the map was best :)Kathaleenkatharevusa
C
4

Your map solution is the best one. I'm not sure why you think map_modifying_only_elements_where is somehow better. Using map is cleaner, more concise, and doesn't require multiple blocks.

Classic answered 5/3, 2009 at 13:50 Comment(0)
G
3

One liner:

["a", "b", "c"].inject([]) { |cumulative, i| i == "b" ? (cumulative << "#{i}!") : cumulative }

In the code above, you start with [] "cumulative". As you enumerate through an Enumerator (in our case the array, ["a", "b", "c"]), cumulative as well as "the current" item get passed to our block (|cumulative, i|) and the result of our block's execution is assigned to cumulative. What I do above is keep cumulative unchanged when the item isn't "b" and append "b!" to cumulative array and return it when it is a b.

There is an answer above that uses select, which is the easiest way to do (and remember) it.

You can combine select with map in order to achieve what you're looking for:

 arr = ["a", "b", "c"].select { |i| i == "b" }.map { |i| "#{i}!" }
 => ["b!"]

Inside the select block, you specify the conditions for an element to be "selected". This will return an array. You can call "map" on the resulting array to append the exclamation mark to it.

Gregorio answered 4/3, 2013 at 10:18 Comment(0)
F
3

Ruby 2.7+

As of 2.7 there's a definitive answer.

Ruby 2.7 is introducing filter_map for this exact purpose. It's idiomatic and performant, and I'd expect it to become the norm very soon.

For example:

numbers = [1, 2, 5, 8, 10, 13]
enum.filter_map { |i| i * 2 if i.even? }
# => [4, 16, 20]

Here's a good read on the subject.

Hope that's useful to someone!

Faradism answered 2/8, 2019 at 9:38 Comment(0)
F
1

If you don't need the old array, I prefer map! in this case because you can use the ! method to represent you are changing the array in place.

self.answers.map!{ |x| (x=="b" ? x+"!" : x) }

I prefer this over:

new_map = self.old_map{ |x| (x=="b" ? x+"!" : x) }
Flavopurpurin answered 5/3, 2009 at 21:10 Comment(0)
B
1

It's a few lines long, but here's an alternative for the hell of it:

oa = %w| a b c |
na = oa.partition { |a| a == 'b' }
na.first.collect! { |a| a+'!' }
na.flatten! #Add .sort! here if you wish
p na
# >> ["b!", "a", "c"]

The collect with ternary seems best in my opinion.

Berzelius answered 5/3, 2009 at 22:20 Comment(0)
N
1

I've found that the best way to accomplish this is by using tap

arr = [1,2,3,4,5,6]
[].tap do |a|
  arr.each { |x| a << x if x%2==0 }
end
Neuberger answered 7/12, 2011 at 22:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.