skip to content

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