Most loops you would write are one Enumerable call¶
Level: 201 · anyone who has written each with an accumulator and suspects there is a method for that
One line: map, select, reject, find, partition, group_by, tally, each_slice, each_cons, zip, flat_map, sum, minmax, chunk_while, each_with_index, filter_map and sort_by each replace one shape of loop, and Python spells the same thirty rows with comprehensions, builtins, itertools and collections — with one trap: itertools.groupby is Ruby's chunk, not its group_by.
Every method below is a loop shape with a name. map is "build a list of the block's results", select is "keep the ones where the block is true", group_by is "a hash from the block's value to the items that gave it", each_cons(2) is "every neighbouring pair", chunk_while is "split where the block stops holding". Reaching for the name instead of the loop is most of what reads as idiomatic Ruby, and because they all come from Enumerable they work on anything with each — arrays here, but equally a Hash, a Range, a Set or your own class. Two collections run through thirty of them, six or so per group, so the rows can be read side by side with the Python twin.
Verified output of the_enumerable_toolbox_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
Transform, keep, drop, find
1. map(&:upcase) ["FIG", "APPLE", "KIWI", "BANANA", "PLUM", "CHERRY"]
2. select { size == 4 }, filter is the same method [["kiwi", "plum"], true]
3. reject { |w| w.size == 4 } ["fig", "apple", "banana", "cherry"]
4. find { start_with?("b") }, find { ...("z") } ["banana", nil]
5. partition { |w| w.size < 5 } [["fig", "kiwi", "plum"], ["apple", "banana", "cherry"]]
6. filter_map { |w| w.upcase if w.size == 4 } ["KIWI", "PLUM"]
Group and count
7. group_by(&:size) {3 => ["fig"], 5 => ["apple"], 4 => ["kiwi", "plum"], 6 => ["banana", "cherry"]}
8. nums.group_by(&:odd?) (unsorted input) {true => [3, 1, 1, 5, 9], false => [4, 2, 6]}
9. nums.chunk(&:odd?).to_a (runs, not groups) [[true, [3, 1]], [false, [4]], [true, [1, 5, 9]], [false, [2, 6]]]
10. nums.tally {3 => 1, 1 => 2, 4 => 1, 5 => 1, 9 => 1, 2 => 1, 6 => 1}
11. nums.count(&:odd?), words.count [5, 6]
Windows and slices
12. nums.each_slice(3).to_a [[3, 1, 4], [1, 5, 9], [2, 6]]
13. nums.each_cons(2).to_a [[3, 1], [1, 4], [4, 1], [1, 5], [5, 9], [9, 2], [2, 6]]
14. steps.chunk_while { |a, b| b == a + 1 }.to_a [[1, 2], [4], [9, 10, 11, 12], [15]]
15. steps.slice_when { |a, b| b != a + 1 }.to_a [[1, 2], [4], [9, 10, 11, 12], [15]]
16. words.zip(nums) [["fig", 3], ["apple", 1], ["kiwi", 4], ["banana", 1], ["plum", 5], ["cherry", 9]]
17. words.flat_map(&:chars).first(5) ["f", "i", "g", "a", "p"]
Numbering
18. each_with_index.map { |w, i| "#{i}:#{w}" } ["0:fig", "1:apple", "2:kiwi", "3:banana", "4:plum", "5:cherry"]
19. map.with_index(1) { |w, i| "#{i}. #{w}" } ["1. fig", "2. apple", "3. kiwi", "4. banana", "5. plum", "6. cherry"]
Reduce to one value
20. nums.sum, nums.sum { |x| x * x } [31, 173]
21. nums.minmax, words.minmax_by(&:size) [[1, 9], ["fig", "banana"]]
22. words.min(2), words.max(2) [["apple", "banana"], ["plum", "kiwi"]]
23. any? { > 8 }, all?(Integer), none?(String) [true, true, true]
Order and slice
24. sort_by { |w| [w.size, w] } (array key) ["fig", "kiwi", "plum", "apple", "banana", "cherry"]
25. sort_by { |w| [-w.size, w] } ["banana", "cherry", "apple", "kiwi", "plum", "fig"]
26. take_while { < 5 }, drop_while { < 5 } [[3, 1, 4, 1], [5, 9, 2, 6]]
27. nums.uniq [3, 1, 4, 5, 9, 2, 6]
28. words.cycle.first(8) ["fig", "apple", "kiwi", "banana", "plum", "cherry", "fig", "apple"]
Two Ruby-only spellings
29. words.to_h { |w| [w, w.size] } {"fig" => 3, "apple" => 5, "kiwi" => 4, "banana" => 6, "plum" => 4, "cherry" => 6}
30. grep(/an/), nums.grep(2..5) (grep uses ===) [["banana"], [3, 4, 5, 2]]
Reading the output¶
Rows 1–6, transform, keep, drop, find: map returns a new array of the block's values; select keeps, reject drops, and filter is the very same method as select (row 2 prints that the two UnboundMethods are equal, not merely alike). find returns the first hit or nil (row 4); partition returns both halves at once (row 5); filter_map is a map whose nil results are dropped (row 6), so one block does the keeping and the transforming.
Rows 7–11, group and count: group_by builds a hash whose keys are the block's values in first-seen order, and whose values keep the input order; row 8 groups an unsorted list into two buckets, because group_by does not care about neighbours. chunk (row 9) does care: it cuts the list into runs of neighbours that give the same block value, so the same input yields four runs — that is the method Python's itertools.groupby corresponds to. tally (row 10) counts occurrences into a hash, again in first-seen order, and count takes a block.
Rows 12–17, windows and slices: each_slice(3) cuts into non-overlapping pieces and each_cons(2) slides a window; chunk_while { |a, b| b == a + 1 } and slice_when { |a, b| b != a + 1 } are the same cut described from opposite sides (rows 14 and 15 agree). zip pairs positions; flat_map maps and flattens one level.
Rows 18–19, numbering: each_with_index yields the index second, and map.with_index(1) — map without a block returns an Enumerator, and with_index takes a starting offset — is how a one-based numbering is written. Rows 20–23 reduce to a value: sum takes a block, minmax returns a pair, min(2) and max(2) return the two smallest and two largest, and any?/all?/none? accept a pattern instead of a block — all?(Integer) matches the way case does, with === (Not machine-checked here.).
Rows 24–28, order and slice: sort_by with an array key sorts by the first element and breaks ties by the second, so [w.size, w] is "by length, then alphabetically" and [-w.size, w] reverses only the first criterion. take_while/drop_while split at the first failing element, uniq keeps first occurrences, and cycle repeats forever, which is why the row takes first(8). Rows 29–30 are two spellings Python does not have: to_h with a block builds a hash from pairs, and grep selects with ===, so a Regexp, a Range or a Class all work as the pattern.
Compared with Python¶
Python's loop shapes are mostly syntax: a comprehension with an if does select, reject and filter_map (rows 2, 3 and 6 are one form), next(genexp, None) is find (row 4), and partition is two comprehensions (row 5). The rest is spread over four homes: builtins (sorted, min, max, sum, zip, enumerate, any, all), itertools (batched for each_slice — new in 3.12 — pairwise for each_cons(2), takewhile, dropwhile, cycle, islice, chain.from_iterable for flat_map), collections (Counter for tally, defaultdict(list) for group_by) and heapq (nsmallest/nlargest for min(2)/max(2), row 22).
The trap is rows 7–9. Python has no group_by; the loop over a defaultdict(list) in row 7 is the idiom. itertools.groupby looks like the answer but groups consecutive runs, so on the unsorted list in row 8 it returns four groups where Ruby's group_by returned two — it is Ruby's chunk, and row 9 shows the standard fix: sort by the key first, which also changes the order of the groups (False before True). pairwise gives only pairs; a wider window such as each_cons(3) needs a loop or a collections.deque (Not machine-checked here.). chunk_while and slice_when have no counterpart at all, so rows 14–15 use a five-line runs helper, and grep becomes a comprehension with re.search or a range test (row 30). What Python does have that reads the same way: sorted with a tuple key (rows 24–25) breaks ties exactly as Ruby's array key does, and enumerate(words, 1) is with_index(1).
Verified output of the_enumerable_toolbox_py.py — regenerated by tools/run_examples.py, never hand-typed.
Transform, keep, drop, find
1. [w.upper() for w in words] ['FIG', 'APPLE', 'KIWI', 'BANANA', 'PLUM', 'CHERRY']
2. [w for w in words if len(w) == 4] ['kiwi', 'plum']
3. [w for w in words if len(w) != 4] ['fig', 'apple', 'banana', 'cherry']
4. next((w ... if w.startswith("b")), None), ..."z" ['banana', None]
5. two comprehensions (no partition) (['fig', 'kiwi', 'plum'], ['apple', 'banana', 'cherry'])
6. [w.upper() for w in words if len(w) == 4] ['KIWI', 'PLUM']
Group and count
7. defaultdict(list) loop (no group_by) {3: ['fig'], 5: ['apple'], 4: ['kiwi', 'plum'], 6: ['banana', 'cherry']}
8. groupby(nums, key=odd) (unsorted input) [(True, [3, 1]), (False, [4]), (True, [1, 5, 9]), (False, [2, 6])]
9. groupby(sorted(nums, key=odd), key=odd) [(False, [4, 2, 6]), (True, [3, 1, 1, 5, 9])]
10. dict(Counter(nums)) {3: 1, 1: 2, 4: 1, 5: 1, 9: 1, 2: 1, 6: 1}
11. sum(1 for x in nums if odd(x)), len(words) [5, 6]
Windows and slices
12. list(batched(nums, 3)) (3.12+) [(3, 1, 4), (1, 5, 9), (2, 6)]
13. list(pairwise(nums)) [(3, 1), (1, 4), (4, 1), (1, 5), (5, 9), (9, 2), (2, 6)]
14. runs(steps, lambda a, b: b == a + 1) [[1, 2], [4], [9, 10, 11, 12], [15]]
15. runs(steps, lambda a, b: not b != a + 1) [[1, 2], [4], [9, 10, 11, 12], [15]]
16. list(zip(words, nums)) [('fig', 3), ('apple', 1), ('kiwi', 4), ('banana', 1), ('plum', 5), ('cherry', 9)]
17. list(chain.from_iterable(words))[:5] ['f', 'i', 'g', 'a', 'p']
Numbering
18. [f"{i}:{w}" for i, w in enumerate(words)] ['0:fig', '1:apple', '2:kiwi', '3:banana', '4:plum', '5:cherry']
19. [f"{i}. {w}" for i, w in enumerate(words, 1)] ['1. fig', '2. apple', '3. kiwi', '4. banana', '5. plum', '6. cherry']
Reduce to one value
20. sum(nums), sum(x * x for x in nums) [31, 173]
21. (min, max)(nums), (min, max)(words, key=len) [(1, 9), ('fig', 'banana')]
22. heapq.nsmallest(2, words), .nlargest(2, words) [['apple', 'banana'], ['plum', 'kiwi']]
23. any(...), all(isinstance(...)), not any(...) [True, True, True]
Order and slice
24. sorted(words, key=lambda w: (len(w), w)) ['fig', 'kiwi', 'plum', 'apple', 'banana', 'cherry']
25. sorted(words, key=lambda w: (-len(w), w)) ['banana', 'cherry', 'apple', 'kiwi', 'plum', 'fig']
26. list(takewhile(...)), list(dropwhile(...)) [[3, 1, 4, 1], [5, 9, 2, 6]]
27. list(dict.fromkeys(nums)) [3, 1, 4, 5, 9, 2, 6]
28. list(islice(cycle(words), 8)) ['fig', 'apple', 'kiwi', 'banana', 'plum', 'cherry', 'fig', 'apple']
The two Ruby-only spellings, by hand
29. {w: len(w) for w in words} {'fig': 3, 'apple': 5, 'kiwi': 4, 'banana': 6, 'plum': 4, 'cherry': 6}
30. re.search / a range test in a comprehension [['banana'], [3, 4, 5, 2]]
The itertools ↗ page has a recipes section — sliding_window, partition, unique_everseen — for most of the rows Ruby answers with a method.
Try it¶
- Ten days of temperature readings,
[12, 15, 15, 9, 21, 22, 22, 22, 18, 7]: print, one Enumerable call each, the days above 20 with their index, the runs of non-decreasing readings, the day-to-day changes, the three-day moving averages, how often each reading occurs, the most frequent reading, the readings in groups of four, the coldest and warmest, the readings sorted by distance from 15 (ties broken by value), and the first warm day together with the days before it. - Rewrite the same ten rows in Python. Which need
itertools.pairwise, whichbatched, which aCounter— and which of them have no standard-library spelling and need a loop? Check the three-day averages:pairwisewill not do it.
Solution to 1
the_enumerable_toolbox_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: ten questions about a week and a half of temperature readings,
# each answered by one Enumerable method.
def row(n, label, value) = puts(format("%2d. %-46s %s", n, label, value.inspect))
temps = [12, 15, 15, 9, 21, 22, 22, 22, 18, 7]
row 1, "days above 20, with their index", temps.each_with_index.select { |t, _| t > 20 }.map { |t, i| "day #{i}: #{t}" }
row 2, "runs of non-decreasing readings", temps.chunk_while { |a, b| b >= a }.to_a
row 3, "day-to-day changes", temps.each_cons(2).map { |a, b| b - a }
row 4, "three-day moving averages", temps.each_cons(3).map { |w| (w.sum / 3.0).round(1) }
row 5, "how often each reading occurs", temps.tally
row 6, "the most frequent reading", temps.tally.max_by { |_, n| n }.first
row 7, "readings in groups of four", temps.each_slice(4).to_a
row 8, "coldest and warmest", temps.minmax
row 9, "readings sorted by distance from 15", temps.sort_by { |t| [(t - 15).abs, t] }
row 10, "first warm day, and the days before it", [temps.find { |t| t > 20 }, temps.take_while { |t| t <= 20 }]
Verified output of the_enumerable_toolbox_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. days above 20, with their index ["day 4: 21", "day 5: 22", "day 6: 22", "day 7: 22"]
2. runs of non-decreasing readings [[12, 15, 15], [9, 21, 22, 22, 22], [18], [7]]
3. day-to-day changes [3, 0, -6, 12, 1, 0, 0, -4, -11]
4. three-day moving averages [14.0, 13.0, 15.0, 17.3, 21.7, 22.0, 20.7, 15.7]
5. how often each reading occurs {12 => 1, 15 => 2, 9 => 1, 21 => 1, 22 => 3, 18 => 1, 7 => 1}
6. the most frequent reading 22
7. readings in groups of four [[12, 15, 15, 9], [21, 22, 22, 22], [18, 7]]
8. coldest and warmest [7, 22]
9. readings sorted by distance from 15 [15, 15, 12, 18, 9, 21, 22, 22, 22, 7]
10. first warm day, and the days before it [21, [12, 15, 15, 9]]
See also¶
- Enumerable is a mixin — why every one of these rows also works on a class of your own with an
each injectandeach_with_object— the general fold, for the shapes this page has no name for- Sort stability and
sort_by— what rows 24–25 quietly rely on, and what they must not - Lazy enumerators — the same names on an infinite source, one element at a time
- Symbol to proc — the
&:upcasein row 1 and the&:odd?in row 8 - Hashes and default values —
Hash.new { |h, k| h[k] = [] }, the loop thatgroup_byreplaces - Bang and question methods —
sortversussort!,any?and friends collectandFromIterator↗ — Rust's toolbox, where the consumer decides the container- Past the end, an array gives nil, not an error —
a[10]on a four-element array isnil,a[5] = 99pads the gap withnil, and a slice isa[1..2]… Random.new(42)gives the same numbers every run — A seededRandomis a function of its seed- There is more than one way, and one obvious way — Ruby's idioms