Comment mapper et supprimer des valeurs nulles dans Ruby


361

J'ai un mapqui modifie une valeur ou la définit à zéro. Je souhaite ensuite supprimer les entrées nil de la liste. La liste n'a pas besoin d'être conservée.

Voici ce que j'ai actuellement:

# A simple example function, which returns a value or nil
def transform(n)
  rand > 0.5 ? n * 10 : nil }
end

items.map! { |x| transform(x) } # [1, 2, 3, 4, 5] => [10, nil, 30, 40, nil]
items.reject! { |x| x.nil? } # [10, nil, 30, 40, nil] => [10, 30, 40]

Je suis conscient que je pourrais simplement faire une boucle et collecter conditionnellement dans un autre tableau comme celui-ci:

new_items = []
items.each do |x|
    x = transform(x)
    new_items.append(x) unless x.nil?
end
items = new_items

Mais cela ne semble pas si idiomatique. Existe-t-il un bon moyen de mapper une fonction sur une liste, en supprimant / excluant les nils au fur et à mesure?


3
Ruby 2.7 introduit filter_map, ce qui semble être parfait pour cela. Enregistre le besoin de retraiter la baie, au lieu de la récupérer comme souhaité pour la première fois. Plus d'infos ici.
SRack

Réponses:


21

Ruby 2.7+

Il y a maintenant!

Ruby 2.7 introduit filter_mapdans ce but précis. C'est idiomatique et performant, et je m'attends à ce qu'il 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]

Dans votre cas, comme le bloc est évalué à falsey, simplement:

items.filter_map { |x| process_x url }

" Ruby 2.7 ajoute Enumerable # filter_map " est une bonne lecture sur le sujet, avec quelques repères de performances par rapport à certaines des approches précédentes de ce problème:

N = 1_00_000
enum = 1.upto(1_000)
Benchmark.bmbm do |x|
  x.report("select + map")  { N.times { enum.select { |i| i.even? }.map{|i| i + 1} } }
  x.report("map + compact") { N.times { enum.map { |i| i + 1 if i.even? }.compact } }
  x.report("filter_map")    { N.times { enum.filter_map { |i| i + 1 if i.even? } } }
end

# Rehearsal -------------------------------------------------
# select + map    8.569651   0.051319   8.620970 (  8.632449)
# map + compact   7.392666   0.133964   7.526630 (  7.538013)
# filter_map      6.923772   0.022314   6.946086 (  6.956135)
# --------------------------------------- total: 23.093686sec
# 
#                     user     system      total        real
# select + map    8.550637   0.033190   8.583827 (  8.597627)
# map + compact   7.263667   0.131180   7.394847 (  7.405570)
# filter_map      6.761388   0.018223   6.779611 (  6.790559)

1
Agréable! Merci pour la mise à jour :) Une fois Ruby 2.7.0 publié, je pense qu'il est probablement logique de basculer la réponse acceptée sur celle-ci. Je ne sais pas vraiment quelle est l'étiquette ici, si vous donnez généralement à la réponse acceptée existante une chance de mettre à jour? Je dirais que c'est la première réponse faisant référence à la nouvelle approche en 2.7, elle devrait donc devenir acceptée. @ the-tin-man êtes-vous d'accord avec cette prise?
Pete Hamilton

Merci @PeterHamilton - appréciez la rétroaction et espérons qu'elle se révélera utile à de nombreuses personnes. Je suis heureux de suivre votre décision, bien que j'aime bien l'argument que vous avez fait :)
SRack

Oui, c'est la bonne chose à propos des langues qui ont des équipes de base qui écoutent.
The Tin Man

C'est un bon geste de recommander que les réponses sélectionnées soient modifiées, mais cela arrive rarement. SO ne fournit pas de ticker pour rappeler aux gens et les gens ne reviennent généralement pas sur les anciennes questions qu'ils ont posées à moins que SO ne dise qu'il y a eu de l'activité. En tant que barre latérale, je recommande de regarder Fruity pour les références, car il est beaucoup moins fastidieux et facilite la réalisation de tests sensibles.
The Tin Man

931

Vous pouvez utiliser compact:

[1, nil, 3, nil, nil].compact
=> [1, 3] 

Je voudrais rappeler aux gens que si vous obtenez un tableau contenant nils en sortie d'un mapbloc, et que ce bloc essaie de renvoyer des valeurs de manière conditionnelle, alors vous avez une odeur de code et vous devez repenser votre logique.

Par exemple, si vous faites quelque chose qui fait ceci:

[1,2,3].map{ |i|
  if i % 2 == 0
    i
  end
}
# => [nil, 2, nil]

Alors ne le fais pas. Au lieu de cela, avant le map, rejectles choses que vous ne voulez pas ou selectce que vous voulez:

[1,2,3].select{ |i| i % 2 == 0 }.map{ |i|
  i
}
# => [2]

J'envisage d'utiliser compactpour nettoyer un gâchis comme un ultime effort pour se débarrasser de choses que nous n'avons pas traitées correctement, généralement parce que nous ne savions pas ce qui nous arrivait. Nous devons toujours savoir quel type de données est diffusé dans notre programme; Les données inattendues / inconnues sont mauvaises. Chaque fois que je vois des nils dans un tableau sur lequel je travaille, je cherche pourquoi ils existent et je vois si je peux améliorer le code générant le tableau, plutôt que de permettre à Ruby de perdre du temps et de la mémoire à générer des nils puis de parcourir le tableau pour le supprimer les plus tard.

'Just my $%0.2f.' % [2.to_f/100]

29
Maintenant, c'est rubis-esque!
Christophe Marois

4
Pourquoi cela? L'OP doit supprimer les nilentrées, pas les chaînes vides. BTW, niln'est pas la même chose qu'une chaîne vide.
The Tin Man

9
Les deux solutions parcourent deux fois la collection ... pourquoi ne pas utiliser reduceou inject?
Ziggy

4
Il ne semble pas que vous ayez lu la question OP ou la réponse. La question est de savoir comment supprimer nils d'un tableau. compactest plus rapide, mais en réalité, l'écriture correcte du code au début élimine la nécessité de traiter complètement les nils.
le Tin Man

3
Je ne suis pas d'accord! La question est "Mapper et supprimer les valeurs nulles". Eh bien, mapper et supprimer des valeurs nulles, c'est réduire. Dans leur exemple, l'OP mappe puis sélectionne les nils. Appeler map puis compact, ou select puis map, revient à faire la même erreur: comme vous le signalez dans votre réponse, c'est une odeur de code.
Ziggy

96

Essayez d'utiliser reduceou inject.

[1, 2, 3].reduce([]) { |memo, i|
  if i % 2 == 0
    memo << i
  end

  memo
}

Je suis d'accord avec la réponse acceptée que nous ne devrions pas mapet compact, mais pas pour les mêmes raisons.

Je me sens à l' intérieur profond qui mapalors compactest équivalent à selectalors map. Considérez: mapest une fonction un-à-un. Si vous mappez à partir d'un ensemble de valeurs, et vous map, alors vous voulez une valeur dans le jeu de sortie pour chaque valeur dans le jeu d'entrée. Si vous devez au selectpréalable, vous ne voulez probablement pas un mapsur le plateau. Si vous devez le faire par la selectsuite (ou compact), vous ne voudrez probablement pas en avoir mapsur le plateau. Dans les deux cas, vous répétez deux fois l'ensemble du jeu, alors qu'un reducene doit y aller qu'une seule fois.

De plus, en anglais, vous essayez de "réduire un ensemble d'entiers en un ensemble d'entiers pairs".


4
Pauvre Ziggy, pas d'amour pour ta suggestion. lol. plus un, quelqu'un d'autre a des centaines de votes positifs!
DDDD

2
Je crois qu'un jour, avec votre aide, cette réponse dépassera l'acceptable. ^ o ^ //
Ziggy

2
+1 la réponse actuellement acceptée ne vous permet pas d'utiliser les résultats des opérations que vous avez effectuées pendant la phase de sélection
chees

1
itérer deux fois sur des infrastructures de données énumérables si seulement une passe est nécessaire, comme dans la réponse acceptée, semble inutile. Réduisez ainsi le nombre de passes en utilisant réduire! Merci @Ziggy
sebisnow

C'est vrai! Mais faire deux passes sur une collection de n éléments est toujours O (n). À moins que votre collection soit si grande qu'elle ne rentre pas dans votre cache, faire deux passes est probablement bien (je pense juste que c'est plus élégant, expressif et moins susceptible de conduire à des bugs à l'avenir lorsque, disons, les boucles tomberont désynchronisés). Si vous aimez faire les choses en un seul passage aussi, vous pourriez être intéressé à en apprendre davantage sur les transducteurs! github.com/cognitect-labs/transducers-ruby
Ziggy

33

Dans votre exemple:

items.map! { |x| process_x url } # [1, 2, 3, 4, 5] => [1, nil, 3, nil, nil]

il ne semble pas que les valeurs aient changé autre que d'être remplacées par nil. Si tel est le cas, alors:

items.select{|x| process_x url}

suffira.


27

Si vous vouliez un critère de rejet plus lâche, par exemple, pour rejeter les chaînes vides ainsi que zéro, vous pouvez utiliser:

[1, nil, 3, 0, ''].reject(&:blank?)
 => [1, 3, 0] 

Si vous vouliez aller plus loin et rejeter des valeurs nulles (ou appliquer une logique plus complexe au processus), vous pourriez passer un bloc à rejeter:

[1, nil, 3, 0, ''].reject do |value| value.blank? || value==0 end
 => [1, 3]

[1, nil, 3, 0, '', 1000].reject do |value| value.blank? || value==0 || value>10 end
 => [1, 3]

5
.Vide? n'est disponible qu'en rails.
ewalk

Pour référence future, étant donné qu'il blank?n'est disponible que dans les rails, nous pourrions utiliser items.reject!(&:nil?) # [1, nil, 3, nil, nil] => [1, 3]ce qui n'est pas couplé aux rails. (n'exclurait pas les chaînes vides ou les 0 cependant)
Fotis

27

C'est certainement compactla meilleure approche pour résoudre cette tâche. Cependant, nous pouvons obtenir le même résultat avec une simple soustraction:

[1, nil, 3, nil, nil] - [nil]
 => [1, 3]

4
Oui, la soustraction d'ensemble fonctionnera, mais elle est environ deux fois moins rapide en raison de ses frais généraux.
The Tin Man

4

each_with_object est probablement le moyen le plus propre d'aller ici:

new_items = items.each_with_object([]) do |x, memo|
    ret = process_x(x)
    memo << ret unless ret.nil?
end

À mon avis, each_with_objectc'est mieux que inject/ reducedans des cas conditionnels car vous n'avez pas à vous soucier de la valeur de retour du bloc.


0

Une autre façon de le faire sera comme indiqué ci-dessous. Ici, nous utilisons Enumerable#each_with_objectpour collecter des valeurs, et utilisons Object#tappour se débarrasser de la variable temporaire qui est autrement nécessaire pour nilvérifier le résultat de la process_xméthode.

items.each_with_object([]) {|x, obj| (process x).tap {|r| obj << r unless r.nil?}}

Exemple complet pour illustration:

items = [1,2,3,4,5]
def process x
    rand(10) > 5 ? nil : x
end

items.each_with_object([]) {|x, obj| (process x).tap {|r| obj << r unless r.nil?}}

Approche alternative:

En regardant la méthode que vous appelez process_x url, il n'est pas clair quel est le but de l'entrée xdans cette méthode. Si je suppose que vous allez traiter la valeur de en xen passant un peu urlet déterminer lequel des xs est vraiment traité en résultats non nuls valides - alors, peut-être Enumerabble.group_byest une meilleure option que Enumerable#map.

h = items.group_by {|x| (process x).nil? ? "Bad" : "Good"}
#=> {"Bad"=>[1, 2], "Good"=>[3, 4, 5]}

h["Good"]
#=> [3,4,5]
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.