Harry Wood
Harry Wood

Reputation: 2351

Best way to interleave two enums in ruby?

I'm looking for a more elegant way of blending together two SQL resultsets with a given ratio. Within each of them I want them to be worked through in the same order they come in, but I want to interleave the processing to achieve a desired blend.

I realised this can be made into a very generic method working with two enums and yielding items to process, so I've written this method which I'm simultaneously quite proud of (nice generic solution) and quite ashamed of.

def combine_enums_with_ratio(enum_a, enum_b, desired_ratio)
  a_count = 1
  b_count = 1
  a_finished = false
  b_finished = false
  loop do
    ratio_so_far = a_count / b_count.to_f
    if !a_finished && (b_finished || ratio_so_far <= desired_ratio)
      begin
        yield enum_a.next
        a_count += 1
      rescue StopIteration
        a_finished = true
      end
    end

    if !b_finished && (a_finished || ratio_so_far > desired_ratio)
      begin
        yield enum_b.next
        b_count += 1
      rescue StopIteration
        b_finished = true
      end
    end

    break if a_finished && b_finished
  end
end

Ashamed because it's clearly written in a very imperative style. Not looking very rubyish. Maybe there's a way of using one of ruby's nice declarative looping methods, except they don't seem to work holding open two enums like this. So then I believe I'm left having to rescue an exception as part of control flow like this, which feels very dirty. I'm missing java's hasNext() method.

Is there a better way?

I did find a similar question about comparing enums: Ruby - Compare two Enumerators elegantly . Some compact answers, but not particularly solving it, and my problem involving unequal lengths and unequal yielding seems trickier.

Upvotes: 2

Views: 198

Answers (1)

Stefan
Stefan

Reputation: 114228

Here's a shorter and more general approach:

def combine_enums_with_ratio(ratios)
  return enum_for(__method__, ratios) unless block_given?

  counts = ratios.transform_values { |value| Rational(1, value) }

  until counts.empty?
    begin
      enum, _ = counts.min_by(&:last)
      yield enum.next
      counts[enum] += Rational(1, ratios[enum])
    rescue StopIteration
      counts.delete(enum)
    end
  end
end

Instead of two enums, it takes a hash of enum => ratio pairs.

At first, it creates a counts hash using the ratio's reciprocal, i.e. enum_a => 3, enum_b => 2 becomes:

counts = { enum_a => 1/3r, enum_b => 1/2r }

Then, within a loop, it fetches the hash's minimum value, which is enum_a in the above example. It yields its next value and increment its counts ratio value:

counts[enum_a] += 1/3r

counts #=> {:enum_a=>(2/3), :enum_b=>(1/2)}

On the next iteration, enum_b has the smallest value, so its next value will be yielded and its ratio be incremented:

counts[enum_b] += 1/2r

counts #=> {:enum_a=>(2/3), :enum_b=>(1/1)}

If you keep incrementing enum_a by (1/3) and enum_b by (1/2), the yield ratio of their elements will be 3:2.

Finally, the rescue clause handles enums running out of elements. If this happens, that enum is removed from the counts hash.

Once the counts hash is empty, the loop stops.

Example usage with 3 enums:

enum_a = (1..10).each
enum_b = ('a'..'f').each
enum_c = %i[foo bar baz].each

combine_enums_with_ratio(enum_a => 3, enum_b => 2, enum_c => 1).to_a
#=> [1, "a", 2, 3, "b", :foo, 4, "c", 5, 6, "d", :bar, 7, "e", 8, 9, "f", :baz, 10]
#    <--------------------->  <--------------------->  <--------------------->
#             3:2:1                    3:2:1                     3:2:1

Upvotes: 3

Related Questions