Dans Ruby, existe-t-il une méthode Array qui combine «select» et «map»?

96

J'ai un tableau Ruby contenant des valeurs de chaîne. J'ai besoin de:

  1. Trouvez tous les éléments qui correspondent à un prédicat
  2. Exécutez les éléments correspondants via une transformation
  3. Renvoie les résultats sous forme de tableau

En ce moment, ma solution ressemble à ceci:

def example
  matchingLines = @lines.select{ |line| ... }
  results = matchingLines.map{ |line| ... }
  return results.uniq.sort
end

Existe-t-il une méthode Array ou Enumerable qui combine select et map en une seule instruction logique?

Seth Petry-Johnson
la source
5
Il n'y a pas de méthode pour le moment, mais une proposition pour en ajouter une à Ruby: bugs.ruby-lang.org/issues/5663
stefankolb
La Enumerable#grepméthode fait exactement ce qui a été demandé et est en Ruby depuis plus de dix ans. Il prend un argument de prédicat et un bloc de transformation. @hirolau donne la seule réponse correcte à cette question.
inopinatus
2
Ruby 2.7 introduit filter_mapprécisément dans ce but. Plus d'infos ici .
SRack

Réponses:

115

J'utilise généralement mapet compactavec mes critères de sélection comme suffixe if. compactse débarrasse des nils.

jruby-1.5.0 > [1,1,1,2,3,4].map{|n| n*3 if n==1}    
 => [3, 3, 3, nil, nil, nil] 


jruby-1.5.0 > [1,1,1,2,3,4].map{|n| n*3 if n==1}.compact
 => [3, 3, 3] 
Jed Schneider
la source
1
Ah-ha, j'essayais de comprendre comment ignorer les nils renvoyés par mon bloc de carte. Merci!
Seth Petry-Johnson
Pas de problème, j'adore le compact. il s'assied discrètement là-bas et fait son travail. Je préfère aussi cette méthode au chaînage de fonctions énumérables pour des critères de sélection simples car elle est très déclarative.
Jed Schneider
4
Je ne savais pas si map+ compactserait vraiment plus performant que injectet j'ai publié mes résultats de référence sur un fil de discussion connexe: stackoverflow.com/questions/310426/list-comprehension-in-ruby/…
knuton
3
cela supprimera tous les nils, à la fois les nils originaux et ceux qui ne répondent pas à vos critères. Alors attention
user1143669
1
Cela n'élimine pas entièrement le chaînage mapet selectc'est juste compactun cas particulier rejectqui fonctionne sur nils et fonctionne un peu mieux car il a été implémenté directement dans C.
Joe Atzberger
53

Vous pouvez utiliser reducepour cela, qui ne nécessite qu'un seul passage:

[1,1,1,2,3,4].reduce([]) { |a, n| a.push(n*3) if n==1; a }
=> [3, 3, 3] 

En d'autres termes, initialisez l'état pour qu'il soit ce que vous voulez (dans notre cas, une liste vide à remplir: [] , puis assurez-vous toujours de renvoyer cette valeur avec des modifications pour chaque élément de la liste d'origine (dans notre cas, l'élément modifié poussé dans la liste).

C'est le plus efficace car il ne parcourt la liste qu'en un seul passage ( map+ selectoucompact nécessite deux passes).

Dans ton cas:

def example
  results = @lines.reduce([]) do |lines, line|
    lines.push( ...(line) ) if ...
    lines
  end
  return results.uniq.sort
end
Adam Lindberg
la source
20
Cela n'a pas each_with_objectun peu plus de sens? Vous n'êtes pas obligé de renvoyer le tableau à la fin de chaque itération du bloc. Vous pouvez simplement le faire my_array.each_with_object([]) { |i, a| a << i if i.condition }.
henrebotha
@henrebotha C'est peut-être le cas. Je viens d'un milieu fonctionnel, c'est pourquoi j'ai trouvé en reducepremier 😊
Adam Lindberg
34

Ruby 2.7+

Il y a maintenant!

Ruby 2.7 présente filter_map précisément dans ce but. C'est idiomatique et performant, et je m'attendrais à ce que cela devienne la norme très bientôt.

Par exemple:

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

Voici une bonne lecture sur le sujet .

J'espère que c'est utile à quelqu'un!

SRack
la source
1
Peu importe la fréquence à laquelle je mets à niveau, une fonctionnalité intéressante est toujours dans la prochaine version.
mlt
Agréable. Un problème pourrait être que puisque filter, selectet find_allsont synonymes, tels que mapet collectsont, il peut être difficile de se souvenir du nom de cette méthode. Est - ce filter_map, select_collect, find_all_mapou filter_collect?
Eric Duminil le
19

Une autre façon différente d'aborder cela consiste à utiliser le nouveau (par rapport à cette question) Enumerator::Lazy:

def example
  @lines.lazy
        .select { |line| line.property == requirement }
        .map    { |line| transforming_method(line) }
        .uniq
        .sort
end

La .lazyméthode renvoie un énumérateur paresseux. L'appel de .selectou .mapsur un énumérateur paresseux renvoie un autre énumérateur paresseux. Ce n'est qu'une fois que vous appelez .uniqqu'il force réellement l'énumérateur et renvoie un tableau. Donc, ce qui se passe effectivement, c'est que vos appels .selectet vos .mapappels sont combinés en un seul - vous ne répétez qu'une seule @linesfois pour faire les deux .selectet.map .

Mon instinct est que la reduceméthode d'Adam sera un peu plus rapide, mais je pense que c'est beaucoup plus lisible.


La principale conséquence de ceci est qu'aucun objet de tableau intermédiaire n'est créé pour chaque appel de méthode suivant. Dans une @lines.select.mapsituation normale , selectrenvoie un tableau qui est ensuite modifié par map, renvoyant à nouveau un tableau. Par comparaison, l'évaluation paresseuse ne crée un tableau qu'une seule fois. Ceci est utile lorsque votre objet de collection initial est volumineux. Il vous permet également de travailler avec des recenseurs infinis - par exemple random_number_generator.lazy.select(&:odd?).take(10).

Henrebotha
la source
4
À chacun le sien. Avec mon type de solution, je peux jeter un coup d'œil sur les noms des méthodes et savoir immédiatement que je vais transformer un sous-ensemble des données d'entrée, le rendre unique et le trier. reducecomme une transformation «tout faire» me semble toujours assez désordonnée.
henrebotha
2
@henrebotha: Pardonnez-moi si j'ai mal compris ce que vous vouliez dire, mais c'est un point très important: il n'est pas correct de dire que "vous ne répétez qu'une @linesfois pour faire les deux .selectet .map". L'utilisation .lazyne signifie pas que les opérations chaînées sur un énumérateur paresseux seront «réduites» en une seule itération. Il s'agit d'un malentendu courant de l'évaluation paresseuse des opérations de chaînage sur une collection. (Vous pouvez tester cela en ajoutant une putsinstruction au début des blocs selectet mapdans le premier exemple. Vous constaterez qu'ils impriment le même nombre de lignes)
pje
1
@henrebotha: et si vous supprimez le, .lazyil imprime le même nombre de fois. C'est mon point - votre mapbloc et votre selectbloc sont exécutés le même nombre de fois dans les versions paresseuses et avides. La version paresseuse ne "combine pas votre .selectet vos .mapappels"
pje
1
@pje: En effet, les lazy combine car un élément qui échoue à la selectcondition n'est pas passé au map. En d 'autres termes: le préfixe lazyéquivaut à peu près à remplacer selectet mappar un seul reduce([]), et "intelligemment", faire selectdu bloc de une condition préalable à l' inclusion dans reducele résultat de.
henrebotha
1
@henrebotha: Je pense que c'est une analogie trompeuse pour l'évaluation paresseuse en général, car la paresse ne change pas la complexité temporelle de cet algorithme. C'est mon point: dans tous les cas, une sélection paresseuse puis une carte effectuera toujours le même nombre de calculs que sa version impatiente. Cela n'accélère rien, cela change simplement l'ordre d'exécution de chaque itération - la dernière fonction de la chaîne «tire» les valeurs nécessaires des fonctions précédentes dans l'ordre inverse.
pje
13

Si vous avez un selectqui peut utiliser l' caseopérateur ( ===), grepest une bonne alternative:

p [1,2,'not_a_number',3].grep(Integer){|x| -x } #=> [-1, -2, -3]

p ['1','2','not_a_number','3'].grep(/\D/, &:upcase) #=> ["NOT_A_NUMBER"]

Si nous avons besoin d'une logique plus complexe, nous pouvons créer des lambdas:

my_favourite_numbers = [1,4,6]

is_a_favourite_number = -> x { my_favourite_numbers.include? x }

make_awesome = -> x { "***#{x}***" }

my_data = [1,2,3,4]

p my_data.grep(is_a_favourite_number, &make_awesome) #=> ["***1***", "***4***"]
Hirolau
la source
Ce n'est pas une alternative - c'est la seule bonne réponse à la question.
inopinatus
@inopinatus: Pas plus . Ceci reste cependant une bonne réponse. Je ne me souviens pas avoir vu grep avec un bloc autrement.
Eric Duminil le
8

Je ne suis pas sûr qu'il y en ait un. Le module Enumerable , qui ajoute selectet map, n'en affiche pas.

Vous seriez obligé de passer en deux blocs à la select_and_transformméthode, ce qui serait un peu peu intuitif à mon humble avis.

De toute évidence, vous pouvez simplement les enchaîner, ce qui est plus lisible:

transformed_list = lines.select{|line| ...}.map{|line| ... }
Gishu
la source
3

Réponse simple:

Si vous avez n enregistrements et que vous voulez selectet en mapfonction de la condition, alors

records.map { |record| record.attribute if condition }.compact

Ici, l'attribut est ce que vous voulez de l'enregistrement et la condition que vous pouvez vérifier.

compact est de rincer les nil inutiles qui sont sortis de cette condition si

Sk. Irfan
la source
1
Vous pouvez également utiliser la même chose avec la condition sauf. Comme mon ami l'a demandé.
Sk. Irfan
2

Non, mais vous pouvez le faire comme ceci:

lines.map { |line| do_some_action if check_some_property  }.reject(&:nil?)

Ou encore mieux:

lines.inject([]) { |all, line| all << line if check_some_property; all }
Daniel O'Hara
la source
14
reject(&:nil?)est fondamentalement le même que compact.
Jörg W Mittag
Ouais, donc la méthode d'injection est encore meilleure.
Daniel O'Hara
2

Je pense que cette façon est plus lisible, car divise les conditions de filtre et la valeur mappée tout en restant clair que les actions sont connectées:

results = @lines.select { |line|
  line.should_include?
}.map do |line|
  line.value_to_map
end

Et, dans votre cas spécifique, éliminez la resultvariable tous ensemble:

def example
  @lines.select { |line|
    line.should_include?
  }.map { |line|
    line.value_to_map
  }.uniq.sort
end
fotanus
la source
1
def example
  @lines.select {|line| ... }.map {|line| ... }.uniq.sort
end

Dans Ruby 1.9 et 1.8.7, vous pouvez également enchaîner et encapsuler les itérateurs en ne leur passant simplement pas de bloc:

enum.select.map {|bla| ... }

Mais ce n'est pas vraiment possible dans ce cas, car les types du bloc renvoient des valeurs de selectet mapne correspondent pas. Cela a plus de sens pour quelque chose comme ça:

enum.inject.with_index {|(acc, el), idx| ... }

AFAICS, le mieux que vous puissiez faire est le premier exemple.

Voici un petit exemple:

%w[a b 1 2 c d].map.select {|e| if /[0-9]/ =~ e then false else e.upcase end }
# => ["a", "b", "c", "d"]

%w[a b 1 2 c d].select.map {|e| if /[0-9]/ =~ e then false else e.upcase end }
# => ["A", "B", false, false, "C", "D"]

Mais ce que vous voulez vraiment, c'est ["A", "B", "C", "D"].

Jörg W Mittag
la source
J'ai fait une très brève recherche sur le Web la nuit dernière pour "chaînage de méthodes dans Ruby" et il semblait que ce n'était pas bien pris en charge. Tho, j'aurais probablement dû l'essayer ... aussi, pourquoi dites-vous que les types d'arguments de bloc ne correspondent pas? Dans mon exemple, les deux blocs prennent une ligne de texte de mon tableau, non?
Seth Petry-Johnson
@Seth Petry-Johnson: Ouais, désolé, je parlais des valeurs de retour. selectrenvoie une valeur booléenne qui décide de conserver l'élément ou non, mapretourne la valeur transformée. La valeur transformée elle-même va probablement être véridique, donc tous les éléments sont sélectionnés.
Jörg W Mittag
1

Vous devriez essayer d'utiliser ma bibliothèque Rearmed Ruby dans laquelle j'ai ajouté la méthode Enumerable#select_map. Voici un exemple:

items = [{version: "1.1"}, {version: nil}, {version: false}]

items.select_map{|x| x[:version]} #=> [{version: "1.1"}]
# or without enumerable monkey patch
Rearmed.select_map(items){|x| x[:version]}
Weston Ganger
la source
select_mapdans cette bibliothèque implémente simplement la même select { |i| ... }.map { |i| ... }stratégie à partir de nombreuses réponses ci-dessus.
Jordan Sitkin
1

Si vous ne souhaitez pas créer deux tableaux différents, vous pouvez utiliser compact!mais faites attention à ce sujet.

array = [1,1,1,2,3,4]
new_array = map{|n| n*3 if n==1}
new_array.compact!

Fait intéressant, compact!est-ce qu'une suppression en place de zéro. La valeur de retour de compact!est le même tableau s'il y a eu des changements mais nil s'il n'y a pas eu de nils.

array = [1,1,1,2,3,4]
new_array = map{|n| n*3 if n==1}.tap { |array| array.compact! }

Serait une ligne unique.

bibstha
la source
0

Ta version:

def example
  matchingLines = @lines.select{ |line| ... }
  results = matchingLines.map{ |line| ... }
  return results.uniq.sort
end

Ma version:

def example
  results = {}
  @lines.each{ |line| results[line] = true if ... }
  return results.keys.sort
end

Cela fera 1 itération (sauf le tri), et a l'avantage supplémentaire de garder l'unicité (si vous ne vous souciez pas d'uniq, alors faites simplement des résultats un tableau et results.push(line) if ...

Jordan Michael Rushing
la source
-1

Voici un exemple. Ce n'est pas le même que votre problème, mais peut être ce que vous voulez ou peut donner un indice sur votre solution:

def example
  lines.each do |x|
    new_value = do_transform(x)
    if new_value == some_thing
      return new_value    # here jump out example method directly.
    else
      next                # continue next iterate.
    end
  end
end
zw963
la source