skip to content

In Ruby, when do you use tally, group_by or to_h with a block to turn a list of franchise sales into a hash?

level: middleimportance: should knowfreq 50%

answer

  1. counts vs lists vs pairs
  2. tally: element => count
  3. group_by: key => array
  4. to_h block returns [k, v]
  5. duplicate keys: last one wins

basics

~20 s

tally counts equal elements into element => count; group_by collects elements under the block's key into key => array; to_h with a block builds one key-value pair per element, where a repeated key silently keeps the last value.

solid answer

~40 s

Pick by what each hash value should be. **`tally`** counts: `sales.map(&:region).tally` gives `{"north" => 2, "south" => 1}`, and since Ruby 3.1 `tally(hash)` adds to an existing hash across batches. **`group_by`** keeps the elements: `sales.group_by(&:region)` gives region => array of Sale objects, which you can then reduce, e.g. `.transform_values { |s| s.sum(&:amount) }` for totals. **`to_h` with a block** makes exactly one pair per element: the block must return a two-element array, `[key, value]`, or Ruby raises `TypeError` or `ArgumentError`; and when two elements produce the same key, the **last one wins** with no warning. That makes `to_h` right for unique keys and wrong for totals.

code

ruby · 17 lines
ruby
Sale = Data.define(:region, :amount)
sales = [
  Sale.new(region: "north", amount: 300),
  Sale.new(region: "south", amount: 120),
  Sale.new(region: "north", amount: 50)
]

sales.map(&:region).tally  # => {"north" => 2, "south" => 1}

sales.group_by(&:region).transform_values { |list| list.sum(&:amount) }
# => {"north" => 350, "south" => 120}

sales.to_h { |s| [s.region, s.amount] }
# => {"north" => 50, "south" => 120}  the 300 was overwritten

sales.to_h { |s| s.amount }
# TypeError: wrong element type Integer at 0 (expected array)

go deeper

for a junior

Recall that tally counts elements and group_by collects elements under a key.

for a middle

Explain the value shape of tally, group_by and to_h, to_h's pair rules and errors, and that duplicate keys in to_h overwrite silently.

for a senior

Catch to_h used for aggregation in review, and weigh group_by's memory against accumulating totals for large inputs.

for a principal

Standardise reporting code on explicit aggregation steps so a later reader cannot mistake a lookup for a total.

## Three methods, three shapes of value A franchise report starts from a flat list of sales and needs a hash keyed by region. Ruby's `Enumerable` gives three direct ways to build one, and they differ in **what ends up as the value**: | Method | Key | Value | Duplicate keys | |---|---|---|---| | `tally` | each distinct element | how many times it appears | counted | | `group_by { }` | the block's result | array of elements with that key | collected | | `to_h { }` | first item of the block's pair | second item of the pair | last one wins | ## `tally`: counting `tally` returns a new hash whose keys are the distinct elements and whose values are their counts: ```ruby sales.map(&:region).tally # => {"north" => 2, "south" => 1} ``` Keys are compared the way hash keys always are, so equal strings count together. Since **Ruby 3.1** `tally` also accepts a hash to accumulate into, which is useful when sales arrive in batches: ```ruby counts = {} batch_one.map(&:region).tally(counts) batch_two.map(&:region).tally(counts) ``` On a `Hash` receiver the elements are `[key, value]` pairs, so those pairs become the keys. ## `group_by`: keeping the elements `group_by` calls the block for each element and files the **element itself** under the block's result: ```ruby sales.group_by(&:region) # => {"north" => [sale1, sale3], "south" => [sale2]} ``` The keys appear in the order they were first seen. Because the values are arrays of the original objects, `group_by` is the flexible starting point: - totals per region: `.transform_values { |list| list.sum(&:amount) }`; - counts per region: `.transform_values(&:size)`, though `tally` says it more directly; - best sale per region, averages, or anything else computed from the group. The trade-off is memory: every element is held in a group until you reduce it, which is fine for a report and worth noticing for very large streams. ## `to_h` with a block: one pair per element `to_h` with a block calls the block for each element and uses the returned **two-element array** as a key-value pair: ```ruby stores.to_h { |s| [s.code, s.manager] } ``` Its rules are strict: 1. The block must return an array (or something that converts with `to_ary`). Returning an Integer raises **`TypeError`** (wrong element type, expected array). 2. The array must have exactly two elements. Returning three raises **`ArgumentError`** about the wrong array length. 3. If two elements produce the **same key**, the later pair **overwrites** the earlier one. Nothing warns you. Rule 3 is the trap in reporting code. `sales.to_h { |s| [s.region, s.amount] }` looks like "amount per region" but keeps only the **last** sale of each region. `to_h` is the right tool when keys are unique by construction, such as store codes, and the wrong tool for aggregation. ## Choosing - Need **how many**: `tally`. - Need **the items themselves**, or any aggregate other than a count: `group_by`, then transform the values. - Need a **lookup** from a unique key to one value: `to_h` with a block. - Need a running **total** without keeping the groups: accumulating into a hash while iterating is the alternative, and avoids holding every element. ## Keys and ordering A few details decide what the resulting hash looks like: - **Order.** All three methods insert keys in the order they are first seen, and Ruby hashes preserve insertion order, so the report lists regions in the order the data first mentions them. Sorting is a separate step. - **A `nil` key.** If `group_by`'s block returns `nil` for some sales, for example a missing region, they are grouped under the key `nil` rather than dropped. That is often the first sign of dirty data. - **Pairs from positions.** `to_h` also works without a block on an array of two-element arrays, so `regions.zip(totals).to_h` builds a lookup from two parallel lists. ## What the interviewer wants They are checking that you know the value shape each method produces and the one silent failure among them: `to_h` overwriting duplicates. Mentioning `tally(hash)` for batches, and that `group_by` holds every element in memory, shows depth.

  • How would you tally regions across several batches of sales?
    Pass the same hash to each call: `counts = {}; batch.map(&:region).tally(counts)` for every batch. Since Ruby 3.1 `tally` adds to the given hash, creating missing keys at zero and incrementing existing ones, and returns that hash. A frozen hash raises `FrozenError`.
  • Why is to_h with a block dangerous for per-region totals?
    It makes one pair per element, and a later pair with an existing key overwrites the earlier value. With several sales per region, only the last sale's amount survives, and no error or warning appears. Totals need grouping or accumulation instead.

saying these in an interview costs you the question

  • to_h with a block adds up values that share a key
  • group_by returns counts per key
  • tally takes a block to choose the key
  • to_h ignores a block result that is not a pair
  • group_by values are the block's results rather than the elements