skip to content

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%

answer

  1. the elements are compared, not the Playlist
  2. Object#<=> returns nil unless ==
  3. comparison of Track with Track failed
  4. define <=> on Track
  5. or use sort_by and max_by

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.

solid answer

~40 s

Enumerable's `sort`, `min`, `max` and `minmax` compare the **elements** that `each` yields, using `<=>`. A `Track` class (or a `Data.define` value) without its own `<=>` inherits `Object#<=>`, which returns `0` only for the same object or an `==` equal one and `nil` otherwise, so the comparison fails with `ArgumentError: comparison of Track with Track failed`. Defining `<=>` on the **Playlist** does not help, because that compares playlists to each other. Two fixes: give `Track` a `<=>` that defines its natural order (usually with `Comparable`), or, when there is no single natural order, keep `Track` as is and pass the ordering at the call site with `sort_by`, `max_by` or a comparator block to `sort`/`max`.

code

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

class Playlist
  include Enumerable
  def initialize(tracks) = (@tracks = tracks)

  def each(&block)
    return enum_for(:each) unless block

    @tracks.each(&block)
    self
  end
end

list = Playlist.new([Track.new("Intro", 95), Track.new("Theme", 240)])

begin
  list.max
rescue ArgumentError => e
  puts e.message                      # comparison of Track with Track failed
end

p list.max_by { |t| t.seconds }.title  # => "Theme"
p list.min { |a, b| a.title <=> b.title }.title  # => "Intro"

go deeper

for a junior

Recall that sort, min and max compare the elements with <=>, and that max_by or sort_by with a block avoids needing it.

for a middle

Explain Object#<=> returning nil for unrelated objects, why that raises ArgumentError, and why <=> on the collection class does not help.

for a senior

Choose between a natural order on the element and call-site keys based on how many orderings the domain has, and guard mixed-type collections.

for a principal

Set a convention for when value objects get a natural order so sorting behaviour stays predictable across teams and services.

## Where the comparison happens Including `Enumerable` gives a Playlist `sort`, `min`, `max` and `minmax`. None of them compare Playlists; they compare the **elements** yielded by `each`, pairwise, with the `<=>` (spaceship) operator. ```ruby Track = Data.define(:title, :seconds) playlist = Playlist.new([Track.new("Intro", 95), Track.new("Theme", 240)]) playlist.max # ArgumentError: comparison of Track with Track failed ``` ## Why the error appears Every object has a `<=>`, inherited from `Object`. Its rdoc in `object.c` says it returns: - `0` if the two are the same object, or if `self == other`; - `nil` otherwise. `nil` means "these cannot be ordered". When `max` or `sort` gets `nil` back, CRuby's comparison helper raises `ArgumentError` with `comparison of X with Y failed`. So the error is not about a missing method; `<=>` exists but has no ordering to offer. ## Fix 1: give the element a natural order If tracks have one obvious order, define `<=>` on `Track`: ```ruby Track = Data.define(:title, :seconds) do include Comparable def <=>(other) = [seconds, title] <=> [other.seconds, other.title] end playlist.max.title # => "Theme" playlist.sort # Array of tracks, shortest first ``` - `<=>` must return a negative number, zero, a positive number, or `nil` for incomparable values. - Including `Comparable` adds `<`, `>`, `between?` and `clamp` on top of it; how `Comparable` and `<=>` interact with equality belongs to the material on object equality. ## Fix 2: order at the call site Often there is no single natural order for a track: by length, by title, by play count. Then leave `Track` alone and pass the rule each time: | Need | Call | |---|---| | longest track | `playlist.max_by { \|t\| t.seconds }` | | tracks by title | `playlist.sort_by { \|t\| t.title }` | | custom comparator | `playlist.max { \|a, b\| a.seconds <=> b.seconds }` | | shortest and longest | `playlist.minmax_by { \|t\| t.seconds }` | These never call `Track#<=>`, so they work on any element type. ## A common wrong fix Developers sometimes add `<=>` to the **Playlist** class. That makes `playlist_a <=> playlist_b` meaningful and lets an Array of playlists be sorted, but `playlist.max` still compares tracks and still fails. ## Other element requirements Ordering is one of several places where Enumerable methods lean on the elements: 1. `sort`, `min`, `max`, `minmax` need `<=>` that returns numbers. 2. `include?` uses `==`. 3. `uniq`, `tally` and `group_by` keys rely on hashing. 4. `sum` without a block uses `+` starting from `0`, so `playlist.sum` raises `TypeError` for Tracks; `playlist.sum { |t| t.seconds }` works. `Data.define` already gives `==`, `eql?` and `hash` based on the members, so the ordering methods and `sum` are the usual surprises. ## Mixed element types A collection holding different classes, such as tracks and podcast episodes, fails the same way unless their `<=>` implementations accept each other. In that case the call-site form with a shared key (`max_by { |item| item.seconds }`) is the robust choice. ## Summary Enumerable orders **elements**, via `<=>`. Give the element class a `<=>` when it has a natural order, otherwise use `sort_by`, `max_by` or a comparator block, and never expect `<=>` on the collection class to help.

  • Why does Object#<=> return 0 for a Track compared with itself but nil for a different Track?
    `Object#<=>` only knows identity and `==`: it returns 0 when the objects are the same or `==` equal, and nil otherwise, meaning no order is defined. For `Data` values with equal members, `==` is true, so even two distinct but equal tracks compare as 0.
  • When would you choose max_by over defining Track#<=>?
    When tracks have no single natural order, for example longest in one screen and alphabetical in another. A call-site key keeps each rule explicit and avoids a `<=>` that silently favours one ordering everywhere, including in `sort` calls elsewhere.

saying these in an interview costs you the question

  • Defining <=> on the Playlist class makes playlist.max work
  • Track raises NoMethodError because objects have no <=> by default
  • max_by also needs Track#<=> to be defined
  • Including Comparable in Playlist fixes sort on its tracks
  • Data.define classes are ordered by their members automatically