Como mapear e remover valores nulos no Ruby

361

Eu tenho um mapque altera um valor ou define como nulo. Desejo então remover as entradas nulas da lista. A lista não precisa ser mantida.

Isto é o que eu tenho atualmente:

# 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]

Estou ciente de que eu poderia fazer um loop e coletar condicionalmente em outra matriz como esta:

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

Mas não parece tão idiomático. Existe uma boa maneira de mapear uma função em uma lista, removendo / excluindo os nils à medida que avança?

Pete Hamilton
fonte
3
Introduz o Ruby 2.7 filter_map, o que parece ser perfeito para isso. Economiza a necessidade de reprocessar a matriz, obtendo-a como desejado na primeira vez. Mais informações aqui.
SRack 02/08/19

Respostas:

21

Ruby 2.7+

Existe agora!

O Ruby 2.7 está sendo apresentado filter_mappara esse fim exato. É idiomático e com bom desempenho, e eu espero que isso se torne a norma muito em breve.

Por exemplo:

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

No seu caso, como o bloco é avaliado como falsey, basta:

items.filter_map { |x| process_x url }

" Ruby 2.7 adiciona o enumerável # filter_map " é uma boa leitura sobre o assunto, com alguns benchmarks de desempenho em relação a algumas das abordagens anteriores para esse problema:

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)
SRack
fonte
11
Agradável! Obrigado pela atualização :) Depois que o Ruby 2.7.0 for lançado, acho que provavelmente faz sentido mudar a resposta aceita para esta. Não sei ao certo qual é a etiqueta aqui, se você geralmente dá à resposta aceita existente a chance de atualizar? Eu diria que esta é a primeira resposta que faz referência à nova abordagem no 2.7, portanto deve se tornar a aceita. @ the-tin-man, você concorda com esta tomada?
Pete Hamilton
Obrigado @ PeterHamilton - aprecie o feedback e espero que seja útil para muitas pessoas. Estou feliz de ir com a sua decisão, embora, obviamente, eu como o argumento que você fez :)
SRack
Sim, essa é a coisa boa sobre idiomas que têm equipes principais que ouvem.
the Tin Man
É um bom gesto recomendar que as respostas selecionadas sejam alteradas, mas isso raramente acontece. O SO não fornece um código para lembrar as pessoas, e as pessoas não costumam revisar perguntas antigas que fizeram, a menos que o SO diga que houve atividade. Como barra lateral, recomendo olhar para o Fruity em busca de benchmarks, porque é muito menos complicado e facilita a realização de testes sensíveis.
the Tin Man
930

Você poderia usar compact:

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

Gostaria de lembrar às pessoas que se você estiver obtendo uma matriz contendo nils como a saída de um map bloco, e esse bloco tenta retornar valores condicionalmente, então você tem cheiro de código e precisa repensar sua lógica.

Por exemplo, se você estiver fazendo algo que faça isso:

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

Então não. Em vez disso, antes do map, rejecto que você não deseja ou selecto que deseja:

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

Considero usar compacta limpeza de uma bagunça como um esforço de última hora para nos livrar de coisas que não lidamos corretamente, geralmente porque não sabíamos o que estava vindo para nós. Sempre devemos saber que tipo de dados estão sendo lançados em nosso programa; Dados inesperados / desconhecidos estão incorretos. Sempre que vejo nulos em uma matriz em que estou trabalhando, analiso por que eles existem e vejo se posso melhorar o código que gera a matriz, em vez de permitir que Ruby perca tempo e nils geradores de memória e, em seguida, vasculhando a matriz para remover depois.

'Just my $%0.2f.' % [2.to_f/100]
o homem de lata
fonte
29
Agora isso é rubi-esque!
Christophe Marois
4
Por que deveria? O OP precisa retirar as nilentradas, não as cadeias vazias. BTW, nilnão é o mesmo que uma string vazia.
the Tin Man
9
Ambas as soluções repetem a coleção duas vezes ... por que não usar reduceou inject?
Ziggy
4
Não parece que você leu a pergunta ou a resposta dos OPs. A questão é como remover nils de uma matriz. compacté mais rápido, mas escrever o código corretamente no início remove a necessidade de lidar com nils completamente.
the Tin Man
3
Discordo! A pergunta é "Mapeie e remova valores nulos". Bem, mapear e remover valores nulos é reduzir. No exemplo deles, o OP mapeia e, em seguida, seleciona os nada. Ligar para o mapa e então compactar, ou selecionar e depois mapear, equivale a cometer o mesmo erro: como você aponta na sua resposta, é um cheiro de código.
Ziggy
96

Tente usar reduceou inject.

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

  memo
}

Concordo com a resposta aceita de que não devemos mape compact, mas não pelas mesmas razões.

Sinto profundamente que mapisso compacté equivalente a selectentão map. Considere: mapé uma função individual. Se você estiver mapeando a partir de algum conjunto de valores e desejar map, deseja um valor no conjunto de saída para cada valor no conjunto de entrada. Se você está precisando selectde antemão, provavelmente não quer um mapno set. Se você tiver que fazer isso selectdepois (ou compact), provavelmente não quer um mapno set. Em ambos os casos, você está iterando duas vezes em todo o conjunto, quando reduceé necessário apenas uma vez.

Além disso, em inglês, você está tentando "reduzir um conjunto de números inteiros para um conjunto de números pares".

Ziggy
fonte
4
Pobre Ziggy, sem amor pela sua sugestão. ri muito. mais um, alguém tem centenas de votos positivos!
DDDD 25/03
2
Acredito que um dia, com sua ajuda, essa resposta superará a aceita. ^ o ^ //
Ziggy
2
+1 na resposta atualmente aceita não permite que você use os resultados das operações que você executou durante a fase de seleção
23/07/2015
11
iterar sobre inúmeras estruturas de dados duas vezes se apenas for necessário, como na resposta aceita, parece um desperdício. Assim, reduza o número de passes usando reduzir! Obrigado @Ziggy
sebisnow
Isso é verdade! Mas fazer duas passagens sobre uma coleção de n elementos ainda é O (n). A menos que sua coleção seja tão grande que não caiba no seu cache, é provável que seja bom fazer duas passagens (acho que isso é mais elegante, expressivo e tem menos probabilidade de causar erros no futuro quando, digamos, os loops caírem fora de sincronia). Se você gosta de fazer as coisas de uma só vez, pode estar interessado em aprender sobre transdutores! github.com/cognitect-labs/transducers-ruby
Ziggy
33

No seu exemplo:

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

não parece que os valores mudaram além de serem substituídos por nil. Se for esse o caso, então:

items.select{|x| process_x url}

será suficiente.

sawa
fonte
27

Se você deseja um critério mais flexível para rejeição, por exemplo, para rejeitar cadeias vazias e nulas, você pode usar:

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

Se você quiser ir além e rejeitar valores zero (ou aplicar lógica mais complexa ao processo), poderá passar um bloco para rejeitar:

[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]
Fred Willmore
fonte
5
.em branco? está disponível apenas em trilhos.
ewalk
Para referência futura, como blank?está disponível apenas nos trilhos, poderíamos usar o items.reject!(&:nil?) # [1, nil, 3, nil, nil] => [1, 3]que não está acoplado aos trilhos. (não excluiria seqüências de caracteres vazias ou 0s) #
748
27

Definitivamente, compacté a melhor abordagem para resolver esta tarefa. No entanto, podemos alcançar o mesmo resultado apenas com uma simples subtração:

[1, nil, 3, nil, nil] - [nil]
 => [1, 3]
Evgenia Manolova
fonte
4
Sim, a subtração de conjunto funcionará, mas é cerca da metade da velocidade devido a sua sobrecarga.
o homem de lata
4

each_with_object é provavelmente a maneira mais limpa de ir aqui:

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

Na minha opinião, each_with_objecté melhor que inject/ reduceem casos condicionais, porque você não precisa se preocupar com o valor de retorno do bloco.

pnomolos
fonte
0

Mais uma maneira de conseguir isso será como mostrado abaixo. Aqui, usamos Enumerable#each_with_objectpara coletar valores e utilizamos Object#tappara nos livrar da variável temporária que é necessária para nilverificar o resultado do process_xmétodo.

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

Exemplo completo para ilustração:

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?}}

Abordagem alternativa:

Observando o método que você está chamando process_x url, não está claro qual é o objetivo da entrada xnesse método. Se eu presumir que você vai processar o valor xpassando um pouco para ele urle determinar qual dos xrealmente é processado em resultados válidos e não nulos - então, pode ser Enumerabble.group_byuma opção melhor do 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]
Wand Maker
fonte