skip to content

A Ruby job builds every row, seat, price band and show date combination with Array#product and memory spikes; why, and what does passing a block change?

level: seniorimportance: nice to knowfreq 22%

answer

  1. size is the product of the lengths
  2. result allocated up front
  3. RangeError: too big to product
  4. block form returns self
  5. one combination at a time

basics

~20 s

Without a block, Array#product builds one result array holding a new sub-array per combination, so memory grows with the product of all input sizes. With a block it yields each combination and returns the receiver, building no result array.

solid answer

~50 s

`a.product(b, c)` returns every combination as an array of arrays. Its length is the **product** of the input lengths, and without a block Ruby allocates that result array up front and fills it with a new sub-array per combination, so 20 rows x 30 seats x 2 bands x 90 dates is 108,000 four-element arrays alive at once. If the lengths multiply past what an array can hold, it raises `RangeError` (`too big to product`). With a block, `product` yields each combination as it is built and **returns `self`**, so each sub-array can be collected once the block moves on; `result = rows.product(...) { }` gives you `rows`, not the combinations. An empty input gives `[]` or no block calls. Stream the combinations to their destination inside the block, in batches, rather than materialising them.

code

ruby · 14 lines
ruby
rows  = ("A".."T").to_a      # 20
seats = (1..30).to_a         # 30
bands = %w[standard premium] # 2
dates = (1..90).to_a         # 90

all = rows.product(seats, bands, dates)
all.size    # => 108000, every one a new 4-element array

count = 0
result = rows.product(seats, bands, dates) do |row, seat, band, date|
  count += 1 # write this price row out here instead of keeping it
end
result.equal?(rows) # => true, the block form returns the receiver
count               # => 108000

go deeper

for a junior

Recall that product returns every pairing across arrays, and that its size is the input sizes multiplied together.

for a middle

Explain that the blockless form materialises all combinations while the block form yields them and returns the receiver.

for a senior

Estimate combination counts before calling product in a job, stream through the block form, and catch code that assigns the block form's return value.

for a principal

Judge when combinatorial generation belongs in application memory at all, and set size limits that fail early for jobs that multiply inputs.

## What Array#product computes `Array#product` returns the **Cartesian product** of the receiver and its arguments: every way of picking one element from each array. `[0, 1].product([2, 3])` is `[[0, 2], [0, 3], [1, 2], [1, 3]]`. With no arguments it returns one-element arrays, `[[0], [1]]`. The number of combinations is the **product of the sizes**, not the sum. That is the whole memory story. ## Why memory spikes Without a block, the implementation: 1. Multiplies the input lengths to get the result size, raising `RangeError` with `too big to product` if the multiplication overflows. 2. Allocates one result array of that size up front. 3. Builds a **new sub-array** for every combination and stores it. For a theatre pricing job: | Input | Size | |---|---| | rows `A`..`T` | 20 | | seats per row | 30 | | price bands | 2 | | show dates | 90 | | **combinations** | **108,000** | That is 108,000 four-element arrays plus a 108,000-slot result array, all reachable until the result goes out of scope. Add one more dimension, say 12 ticket types, and it becomes 1,296,000. Each extra input multiplies the cost. ## The block form With a block, `product` changes shape: - It **yields** each combination to the block as soon as it is built, one at a time. - It **does not build** the result array; after the block returns, the sub-array is garbage unless the block kept it. - It **returns `self`**, the receiver, so `x = rows.product(seats) { ... }` sets `x` to `rows`. - It skips the up-front size multiplication, so the `RangeError` check applies only to the blockless form. A block with several parameters receives the combination destructured: `rows.product(seats, bands, dates) { |row, seat, band, date| ... }`. ## Edge behaviours - **An empty input** makes the product empty: `[]` without a block, and no block calls with one. - **Order**: Ruby's documentation describes the order of combinations as indeterminate. Do not make correctness depend on it; sort afterwards if order matters. - **Not lazy**: without a block, `product` returns a fully built `Array`, not an `Enumerator`. ## product versus nested loops The block form is equivalent to nested `each` loops, one per input, with the combination packed into an array for you: ```ruby rows.each do |row| seats.each do |seat| # same pairs as rows.product(seats) { |row, seat| ... } end end ``` Nested loops let you skip a whole inner loop early, for example skipping every seat of a row that is closed for renovation, which `product` cannot do: its block can `break` out of the whole run, but it cannot skip one row's seats without being called for each of those combinations. Reach for `product` when every combination is genuinely needed and the dimension list is dynamic, such as `first.product(*rest)` over a list of option arrays. ## Production practice - **Estimate first.** Multiply the sizes before calling `product`; a harmless-looking fifth argument can turn thousands into millions. - **Stream.** Use the block form and write each combination, or small batches of them, to their destination inside the block. - **Keep only what you need.** If the job filters combinations, filter inside the block instead of building everything and discarding most of it. - **Pick the right method.** `product` pairs every element with every other element across arrays. `zip` pairs by index, and `combination(n)` picks subsets of one array; neither is a cheaper `product`.

  • What does Array#product return when one of the arrays is empty?
    An empty product. Without a block it returns `[]`; with a block it never calls the block and returns the receiver. One empty dimension, such as a show with no dates yet, silently yields zero combinations rather than an error.
  • When does Array#product raise RangeError, and does the block form raise it too?
    Without a block, `product` multiplies the input lengths to size its result and raises `RangeError` with `too big to product` when that overflows. The block form never computes the total, so it does not raise this error; it simply keeps yielding.

saying these in an interview costs you the question

  • product returns a lazy enumerator, so it costs nothing until iterated
  • The block form still returns the full array of combinations
  • The number of combinations is the sum of the input sizes
  • combination gives the same cross pairs as product with less memory
  • zip pairs every seat with every date