In Ruby, why is a Playlist that includes Enumerable usually a better design than class Playlist < Array?
answer
- every Array mutator comes along
- invariants bypassed by << and fill
- many methods return plain Array
- Ruby 3.0 changed slice, take, uniq
- composition: expose only what you mean
basics
~20 sSubclassing 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.
solid answer
~40 sA `Playlist < Array` **is** an Array: it inherits `<<`, `push`, `insert`, `concat`, `fill`, `replace`, `clear`, `shuffle!` and the rest, so an invariant such as "at most 100 tracks, no duplicates" must be defended in every one of them, and anyone can bypass it. Its results are also inconsistent: `map`, `select` and `+` return plain Arrays, and since Ruby 3.0 so do `slice`/`[]`, `take`, `drop`, `uniq`, `flatten` and `*`, while `dup` keeps the subclass. It still passes `is_a?(Array)` and serialises like one, leaking representation. Composition fixes this: hold `@tracks`, define `each`, `include Enumerable`, and add only the operations the domain needs (`add` with validation, `size`). You keep the whole read-only Enumerable vocabulary and own every mutation.
code
ruby · 10 linesclass ListSubclass < Array; end
list = ListSubclass.new([3, 1, 2, 2])
p list.dup.class # => ListSubclass
p list.take(2).class # => Array (since Ruby 3.0)
p list.uniq.class # => Array (since Ruby 3.0)
p list.select(&:odd?).class # => Array
list << 99 << 100 # nothing stops unwanted growth
p list.size # => 6go deeper
Recall that including Enumerable and defining each gives map and select without inheriting Array's methods like << and clear.
Explain which Array methods return plain Arrays on a subclass, including the Ruby 3.0 change, and why dup keeps the subclass.
Argue for composition by naming the invariants an Array subclass cannot protect and the representation leaks through is_a? and serialisation.
Set a team default that domain collections wrap storage and include Enumerable, with a documented exception for throwaway helpers.
## Two ways to build a Playlist ```ruby # Inheritance class Playlist < Array end # Composition class Playlist include Enumerable MAX = 100 def initialize = (@tracks = []) def size = @tracks.size def each(&block) return enum_for(:each) { size } unless block @tracks.each(&block) self end def add(track) raise ArgumentError, "playlist full" if @tracks.size >= MAX @tracks << track unless @tracks.include?(track) self end end ``` Both let callers write `playlist.map`, `playlist.select` and `playlist.sort_by`. They differ in everything else. ## Problem 1: every mutator is public `Array` has many methods that change the receiver: `<<`, `push`, `unshift`, `insert`, `concat`, `[]=`, `fill`, `replace`, `clear`, `delete`, `delete_if`, `keep_if`, `shuffle!`, `sort!`, `uniq!`, `compact!` and more. A subclass inherits all of them. To enforce "at most 100 tracks, no duplicates" you would have to override each one, and every future Array method is a new hole. With composition, the only way to change `@tracks` is through methods you wrote. ## Problem 2: results change class unpredictably A subclass instance does not reliably stay a subclass instance: | Call on a `Playlist < Array` | Result class | |---|---| | `map`, `select`, `reject`, `+`, `sort` | `Array` | | `slice`/`[]` with a range, `take`, `drop`, `uniq`, `flatten`, `*` | `Array` since Ruby 3.0 (Bug #6087) | | `dup`, `clone` | `Playlist` | Before Ruby 3.0, the second row returned subclass instances, so code upgraded from older Rubies may silently lose its Playlist type. With composition the rule is simple: Enumerable's collection methods return Arrays, and anything that should return a Playlist is a method you wrote. ## Problem 3: it is still an Array everywhere - `playlist.is_a?(Array)` is true, so code with `case x when Array` treats it as a bare list. - Serialisers such as JSON emit it as a plain array, losing the domain type. - It responds to `to_ary`, so it is implicitly splatted and flattened by core methods. Those are representation details the rest of the program should not depend on. ## What composition costs Composition is not free, and an interviewer will ask: 1. **Missing Array conveniences.** No `[]`, `last`, `empty?` or `size` unless you add them. Adding the few you need is usually a handful of one-line methods; the delegation helpers in the standard library can forward them. 2. **Array results from Enumerable.** `playlist.select` returns an Array. Wrap it explicitly where a Playlist is needed. 3. **A little more code** than `< Array`. ## When subclassing Array can be acceptable - A throwaway script or a test helper where invariants do not matter. - A class that adds only read-only helpers and is documented as "an Array with extras". Even then, many teams prefer a module of helper functions over a subclass. ## Interview summary - `< Array` inherits every mutator and every representation leak. - Its results flip between Array and Playlist, and Ruby 3.0 made more of them plain Arrays. - `include Enumerable` plus `each` gives the whole read-only API while the class owns every mutation. - Pay for composition with a few explicit methods such as `size`, `add` and any wrapping filters.
- Which Array methods started returning plain Arrays on subclasses in Ruby 3.0?`drop`, `drop_while`, `flatten`, `slice!`, `slice`/`[]`, `take`, `take_while`, `uniq` and `*`, per the Ruby 3.0 NEWS (Bug #6087). Methods like `map` and `select` already returned Arrays. `dup` and `clone` still keep the subclass.
- How do you give a composed Playlist size and empty? without writing each method by hand?Define them as one-line methods over `@tracks`, or forward them with the standard library's delegation helpers. Forward only read-only methods; forwarding `<<` or `delete` would reopen the invariants composition was meant to protect.
saying these in an interview costs you the question
- Subclassing Array is safer because it inherits well-tested methods
- Every Array method on a subclass returns the subclass
- A class that includes Enumerable can still be mutated with <<
- Overriding push is enough to protect a subclass's invariants
- Composition loses map, select and sort_by