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¶
- Write one
classify(x)method whosewhenclauses use a splat of keywords, a Range, a class, a Regexp and a lambda, and run it over7, 42, 2.5, 9.5, "case", "ruby", "Ruby", [1, 2, 3, 4], nil. Watch where2.5lands and explain it with(1..9) === 2.5. - Define
class Multiplewithinitialize(n)and===(x)returningx % n == 0, then write FizzBuzz for 1 to 15 as acasewithwhen Multiple.new(15),when Multiple.new(3)andwhen Multiple.new(5)— and check what happens if15comes 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.
See also¶
case/inmatches shape, not just value — the othercase, which destructures arrays and hashes instead of asking===- Nothing matched:
case/inraises,case/whenreturnsnil— row 8'snilagainst the pattern-matching form's exception - Four kinds of equality —
===beside==,eql?andequal? - Ranges: two dots and three —
cover?versusinclude?, which is whatwhen 1..5relies on - Operators are methods —
===is one more method a class can define - Ruby text: named captures become variables ↗ — what
when /re/leaves behind in$~, and what a named group does with it - Regex library: named groups ↗ — the same feature across engines, with a Ruby column
- Ruby docs:
case↗ — the reference forwhen,thenand the subject-less form - Procs and lambdas differ in arity and
return— Every proc and lambda is aProc, but a lambda checks its argument count andreturns from itself, while… - A pin compares; a bare name binds — Inside a pattern a bare name always binds