skip to content

Custom Collection Classes

Including Enumerable and defining each gives a class map, select, sort, min, to_a and lazy for free, as long as elements answer <=> for ordering. Interviewers ask why this beats subclassing Array.

on this pageshow

explore

questions

5

In Ruby, what must a Playlist class define to include Enumerable, and which methods does it get for free?

level: middleimportance: must knowfreq 55%

answer

  1. one method is the contract
  2. each yields every element once
  3. map, select, sort, min, lazy, each_slice
  4. results come back as Arrays
  5. no size, [], last or empty?

basics

~20 s

Define each so it yields every element, then include Enumerable. Every Enumerable method (map, select, reject, find, include?, reduce, sort_by, min_by, first, to_a, each_slice, group_by, tally, lazy) is built on that each; collection results come back as Arrays.

solid answer

~40 s

`Enumerable` is a mixin whose only requirement is an instance method `each` that yields each element. After `include Enumerable`, a `Playlist` gains about sixty methods written in terms of `each`: `map`, `select`/`filter`, `reject`, `find`, `include?`, `count`, `reduce`, `sum`, `sort`, `sort_by`, `min`, `max`, `min_by`, `first`, `take`, `to_a`, `each_with_index`, `each_slice`, `group_by`, `partition`, `tally`, `zip`, `uniq`, `lazy` and more. Two limits matter in an interview: methods that build a collection return a plain **Array**, not a Playlist; and Enumerable gives **no** `size`, `length`, `[]`, `last` or `empty?`, because those need knowledge `each` cannot provide cheaply. `sort`, `min` and `max` additionally need the elements to answer `<=>`.

code

ruby · 22 lines
ruby
Track = Data.define(:title, :seconds)

class Playlist
  include Enumerable

  def initialize(tracks = [])
    @tracks = tracks.dup
  end

  def each
    return enum_for(:each) unless block_given?

    @tracks.each { |track| yield track }
    self
  end
end

list = Playlist.new([Track.new("Intro", 95), Track.new("Theme", 240), Track.new("Coda", 130)])
p list.sum { |t| t.seconds }                 # => 465
p list.max_by { |t| t.seconds }.title        # => "Theme"
p list.select { |t| t.seconds < 200 }.class  # => Array
p list.respond_to?(:size)                    # => false

go deeper

for a junior

Recall the recipe: define each that yields every element, include Enumerable, and methods like map and select appear.

for a middle

Explain that every Enumerable method is built on each, that collection results are Arrays, and which methods such as size and last are absent.

for a senior

Decide which Enumerable results should return the domain type, add a cheap size when you can, and check element requirements like <=> before relying on sort or max.

for a principal

Use the one-method contract to give domain collections a familiar API while keeping storage and mutation rules private to the class.

## The contract: one method `Enumerable` is a module in Ruby's core. A class opts in with `include Enumerable` and by defining **`each`**, which must call the block once per element: ```ruby Track = Data.define(:title, :seconds) class Playlist include Enumerable def initialize(tracks = []) = (@tracks = tracks.dup) def each return enum_for(:each) unless block_given? @tracks.each { |track| yield track } self end end ``` Every Enumerable method is implemented by calling `each` with its own block. In CRuby they call `each` through `rb_block_call(obj, id_each, ...)` in `enum.c`, so they work on arrays, ranges, hashes, IO objects and your Playlist alike. ## What the Playlist gains | Kind | Methods (a selection) | |---|---| | Querying | `include?`/`member?`, `any?`, `all?`, `none?`, `one?`, `count`, `tally` | | Fetching | `first`, `take`, `take_while`, `drop`, `find`/`detect`, `find_index`, `min`, `max`, `min_by`, `max_by` | | Filtering | `select`/`filter`, `reject`, `filter_map`, `grep`, `partition`, `uniq`, `compact` | | Transforming | `map`, `flat_map`, `zip`, `each_with_object`, `to_a`, `to_h`, `sum`, `reduce` | | Ordering | `sort`, `sort_by`, `minmax`, `minmax_by` | | Iterating | `each_with_index`, `each_slice`, `each_cons`, `reverse_each`, `cycle`, `each_entry` | | Chaining | `lazy`, `chain`, `chunk_while`, `slice_when` | So `playlist.sum { |t| t.seconds }`, `playlist.max_by { |t| t.seconds }`, `playlist.each_slice(10).to_a` and `playlist.lazy.select { ... }.first(3)` all work with no further code. ## What it does not gain The missing methods are the ones `each` cannot answer without walking everything or that imply ordered, indexed storage: - **`size` / `length`**: Enumerable has `count`, which iterates every time. Define `size` yourself if the class knows it cheaply; Enumerable's own Enumerators will use it. - **`[]`, `last`, `empty?`**: not in Enumerable. Use `first`, `to_a.last`, `none?`, or define them. - **Mutation**: no `<<`, `delete` or `push`. The class decides which changes are allowed. ## Return types Collection-building methods return an **Array**: ```ruby playlist.select { |t| t.seconds < 240 }.class # => Array playlist.sort_by { |t| t.title }.class # => Array ``` If callers need a Playlist back, add explicit methods such as `def short = Playlist.new(select { |t| t.seconds < 240 })`. Enumerable will not wrap results for you. ## Requirements on the elements Some gained methods depend on what the **elements** can do: 1. `sort`, `min`, `max` and `minmax` compare elements with `<=>`. 2. `include?` compares with `==`. 3. `uniq` and `tally` use the elements as Hash keys. 4. `sum` without a block adds elements with `+`, starting from `0`. A `Track` built with `Data.define` has `==` and hashing, but not `<=>`, so `playlist.max` raises `ArgumentError`, while `playlist.max_by { |t| t.seconds }` works. ## Why it is designed this way Keeping the contract to one method means any object that can walk its elements, however it stores them (an array, a linked list, a database cursor, a file), gets the whole vocabulary. That is also why Enumerable avoids methods that assume cheap indexing or a known size. ## Key points - Define `each` that yields every element, then `include Enumerable`. - You get dozens of methods, all built on `each`. - Collection results are Arrays; no `size`, `[]`, `last` or `empty?`. - Ordering methods need `<=>` on the elements.

  • Why does Enumerable offer count but not size?
    `count` can always be answered by walking `each`, so Enumerable provides it. `size` implies the object knows its length without iterating, which only the class can promise. If Playlist defines `size`, Enumerable's Enumerators call it to report their own sizes.
  • How would you make playlist.select return a Playlist instead of an Array?
    Enumerable always builds Arrays, so define the method yourself: `def select(&block) = Playlist.new(super)` or a named method such as `short_tracks`. Overriding core names changes expectations for other developers, so a named method is often clearer.

saying these in an interview costs you the question

  • Including Enumerable requires defining map, select and each
  • Enumerable's select on a Playlist returns a Playlist
  • Enumerable provides size and last once each is defined
  • include Enumerable is enough; each is inherited from Object
  • sort works on any Playlist regardless of what its elements are
open as a page

In Ruby, why does playlist.max or playlist.sort raise ArgumentError on an Enumerable Playlist of Track objects, and what fixes it?

level: juniorimportance: should knowfreq 40%

basics

~20 s

sort, min, max and minmax compare the elements with <=>, and a plain Track inherits Object#<=>, which returns nil for different objects, so the call raises ArgumentError. Define <=> on Track, or pass a key with sort_by or max_by.

open as a page

In a Ruby class that includes Enumerable, what should each return with and without a block, and why does it matter?

level: seniorimportance: should knowfreq 30%

basics

~10 s

Without a block, each should return an Enumerator (return enum_for(:each) unless block_given?); with a block it should return self, like Array#each. That keeps next, each.with_index and chaining working and avoids leaking the internal Array.

open as a page

In Ruby, why is a Playlist that includes Enumerable usually a better design than class Playlist < Array?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Subclassing Array exposes every Array mutator, so rules like a track limit are easy to bypass, and many Array methods return plain Arrays, more of them since Ruby 3.0. Wrapping an Array and including Enumerable exposes only the API you choose.

open as a page

In Ruby, when a custom each yields two values, such as yield track, position, what do map, select and each_entry see?

level: middleimportance: nice to knowfreq 12%

basics

~20 s

Enumerable methods disagree: map { |x| } passes both values, so a one-parameter block gets only the first, while select, to_a and each_entry pack them into [track, position]. each_entry always yields one object per step.

open as a page