Skip to content

case/when asks ===, and every class answers it its own way

Level: 201 · a Python programmer who has seen match and wonders where the isinstance went

One line: case x when y calls y === x, and === is an ordinary method: a class answers with is_a?, a Range with cover?, a Regexp with a match that also sets $~, a lambda by calling itself, and any other object with == — so one case can sort by type, range, pattern and predicate, with no fall-through and a value at the end.

case walks its when clauses in order and, for each value listed after when, asks value === subject. That is the whole mechanism; what makes it useful is that === is a method each class defines for itself. Integer === 3 is 3.is_a?(Integer); (1..5) === 3 is (1..5).cover?(3); /(\d+)/ === "id 42" runs the match and sets $~ and $1; ->(n) { n > 100 } === 150 calls the lambda; a plain value falls back to ==, so when 1 also matches 1.0. A when may list several values, or splat an array into the list. The first match wins and nothing falls through, then puts the body on the same line, a case with no subject turns each when into a plain condition, and the whole thing is an expression: it returns the body's value, nil when nothing matched, or the else body. The program measures all of it, including a class of our own that defines ===.

Verified output of case_when_uses_threequals_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. 3: when Integer, Float                 -> "number"
 2. 3: when 1..5                           -> "small"  (("a".."z") === "bb" -> true: cover?, since include? -> false)
 3. "id 42": when /(\d+)/ sets $~          -> "digits 42"; $~ is a MatchData
 4. 150: when ->(n) { n > 100 }            -> "big"
 5. "e": when *VOWELS                      -> "vowel"
 6. 1: when 1 ... when Integer             -> "first"  (no fall-through)
 7. case with no subject, n = -4           -> "negative"
 8. 99: no when matches                    -> nil; with else -> "other"
 9. 4, 7: when Even (a custom ===)         -> even / odd
10. 1.0: when 1                            -> matched  (1 === 1.0 -> true)
11. nil: when nil                          -> matched  (NilClass === nil -> true)
12. what when calls                        -> Integer === 3 -> true; (1..5) === 3 -> true; /ab/ === "cab" -> true; lambda === 150 -> true

Reading the output

Rows 1 to 4 are the four kinds of === at work: a class (Integer, Float is two tests in one when), a Range (1..5, and the endless 6.. for the rest — and row 2 adds the proof that Range#=== is cover?, not include?: ("a".."z") === "bb" is true while include? says false), a Regexp — which leaves $~ holding a MatchData so the body can read $1 — and a lambda. Row 5 splats VOWELS into the when, which is the same as writing when "a", "e", "i", "o", "u". Row 6: when 1 matched and the later when Integer, which would also have matched, never ran; there is no fall-through and no break.

Row 7 is the subject-less form: case followed directly by when n < 0 is an if/elsif chain that reads as a table. Row 8: with no else, a case that matches nothing is nil; with else, it is the else body. Row 9 is the reason the mechanism is worth knowing: Even defines self.===, and from then on when Even sorts numbers by parity — the same door that Class, Range, Regexp and Proc walked through. Row 10: for a plain value === is ==, and Integer#== is numeric, so when 1 matches 1.0. Row 11: nil is matched by when nil through NilClass#===. Row 12 spells out the four calls behind rows 1 to 4, each answering true.

Compared with Python

Python's match (3.10) covers rows 1, 5, 6, 8, 10 and 11 with patterns: a class pattern int() | float() is an isinstance test with | for alternatives (row 1), literals are compared with == so case 1: matches 1.0 (row 10), None, True and False are compared with is (row 11), the first matching case wins (row 6), and case _: is the else (row 8). What Ruby does with a Range, a Regexp or a lambda, Python does with a guard — case int(x) if 1 <= x <= 5:, case str() if (m := re.search(...)):, case n if n > 100: (rows 2 to 4) — because a pattern can only be a literal, a name, a class, a sequence or a mapping; there is no protocol like === for a value to answer a case for itself. The one hook is row 9: a class pattern calls isinstance, and isinstance honours __instancecheck__ on a metaclass, so case Even(): can be taught parity. Row 5 shows why the splat has no counterpart: a bare name in a case line captures (the next lesson measures it), so a list of allowed values has to be spelled out with |. Row 7: match needs a subject, so the subject-less table becomes an if/elif chain. Row 12 is the same four tests written as Python expressions.

Verified output of case_when_uses_threequals_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. 3: when Integer, Float                 -> 'number'
 2. 3: when 1..5                           -> 'small'  (guard: case int(x) if 1 <= x <= 5)
 3. "id 42": when /(\d+)/ sets $~          -> 'digits 42'  (guard: if (m := re.search(...)))
 4. 150: when ->(n) { n > 100 }            -> 'big'  (guard: case n if n > 100)
 5. "e": when *VOWELS                      -> 'vowel'  (case "a" | "e" | "i" | "o" | "u")
 6. 1: when 1 ... when Integer             -> 'first'  (no fall-through)
 7. case with no subject, n = -4           -> SyntaxError for a bare match:; if/elif gives 'negative'
 8. 99: no when matches                    -> None; with case _: -> 'other'
 9. 4, 7: when Even (a custom ===)         -> even / odd  (case Even(): via __instancecheck__)
10. 1.0: when 1                            -> matched  (1 == 1.0 -> True)
11. nil: when nil                          -> matched  (case None: uses `is`)
12. what when calls                        -> isinstance(3, int) -> True; 3 in range(1, 6) -> True; bool(re.search("ab", "cab")) -> True; (lambda n: n > 100)(150) -> True

The Python library has no page on match; the reference is the match statement ↗ and the tutorial is PEP 636 ↗.

Try it

  1. Write one classify(x) method whose when clauses use a splat of keywords, a Range, a class, a Regexp and a lambda, and run it over 7, 42, 2.5, 9.5, "case", "ruby", "Ruby", [1, 2, 3, 4], nil. Watch where 2.5 lands and explain it with (1..9) === 2.5.
  2. Define class Multiple with initialize(n) and ===(x) returning x % n == 0, then write FizzBuzz for 1 to 15 as a case with when Multiple.new(15), when Multiple.new(3) and when Multiple.new(5) — and check what happens if 15 comes last.
Solution to 1

case_when_uses_threequals_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: one classify method using a class, a Range, a Regexp, a lambda
# and a splat in its when clauses, run over a mixed list of inputs.

KEYWORDS = %w[if unless case]

def classify(x)
  case x
  when *KEYWORDS then "keyword"
  when 1..9 then "digit"
  when Integer then "integer"
  when Float then "float"
  when /\A[a-z]+\z/ then "word"
  when ->(v) { v.is_a?(Array) && v.size > 3 } then "long array"
  else "something else"
  end
end

[7, 42, 2.5, 9.5, "case", "ruby", "Ruby", [1, 2, 3, 4], nil].each do |x|
  puts format("%-14s -> %s", x.inspect, classify(x))
end

puts
puts "(1..9) === 2.5 -> #{(1..9) === 2.5}: a Range answers === with cover?, so 2.5 is a \"digit\""
puts "move `when Float` above the Range if that is not what you meant"

Verified output of case_when_uses_threequals_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

7              -> digit
42             -> integer
2.5            -> digit
9.5            -> float
"case"         -> keyword
"ruby"         -> word
"Ruby"         -> something else
[1, 2, 3, 4]   -> long array
nil            -> something else

(1..9) === 2.5 -> true: a Range answers === with cover?, so 2.5 is a "digit"
move `when Float` above the Range if that is not what you meant

See also