deconstruct and deconstruct_keys let any object be matched¶
Level: 301 · a reader who wants their own classes inside case/in
One line: An array pattern calls the subject's deconstruct and a hash pattern calls deconstruct_keys(keys) — two ordinary methods, so any class can join pattern matching, and Struct, Data, Array, Hash and MatchData already have them; Python's match instead reads __match_args__ for positional class patterns, attributes for keyword ones, and admits only registered Sequence/Mapping types to sequence and mapping patterns.
Pattern matching is duck-typed. When case obj in [a, b] runs, Ruby asks obj.respond_to?(:deconstruct), calls it, and matches the array it returns; Point[a, b] and Point(a, b) first check Point === obj and then do the same. When case obj in {x:, y:} runs, Ruby calls obj.deconstruct_keys(keys) with the array of keys the pattern names — [:x, :y] — so an expensive object can build only what is asked for; with **rest in the pattern every key is needed, and keys is nil. Neither method has to return everything: a Hash with more keys than asked is fine, and the returned Array or Hash is matched exactly as a literal would be. The return types are checked — a deconstruct that returns a String is a TypeError — and an object with neither method simply fails to match, without an error. The program defines both on a Point, records every call, and then looks at the classes that come with the methods built in.
Verified output of deconstruct_and_deconstruct_keys_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. in [a, b] on a Point -> [1, 2] via deconstruct
2. in Point[a, b] -> [1, 2] via deconstruct
3. in {x:, y:} -> [1, 2] via deconstruct_keys([:x, :y])
4. in Point(x:) -> 1 via deconstruct_keys([:x])
5. in {x:, **rest} -> rest = {y: 2} via deconstruct_keys(nil)
6. Struct / Data: deconstruct, deconstruct_keys -> [1, 2] / {x: 1, y: 2}; Data: [1, 2] / {x: 1, y: 2}; keys [:x] -> {x: 1} / {x: 1}
7. Array#deconstruct, Hash#deconstruct_keys -> [1, 2] / {a: 1}; "ab" in [_, _] -> false
8. 5 in [_] (no deconstruct) -> false; 5.respond_to?(:deconstruct) -> false
9. deconstruct returning a String -> TypeError: deconstruct must return Array; TypeError: deconstruct_keys must return Hash
10. MatchData: deconstruct / deconstruct_keys -> ["10", "20"] / {a: "10", b: "20"}
Reading the output¶
Rows 1 and 2: the array pattern [a, b] and the class-checked form Point[a, b] both called deconstruct once. Row 3: the hash pattern {x:, y:} called deconstruct_keys([:x, :y]) — the pattern told the object which keys it wanted. Row 4 narrows it: Point(x:) asked for [:x] only. Row 5 is the **rest case: because the pattern must see every key to know what the rest is, deconstruct_keys(nil) was called, and rest came back as {y: 2}.
Row 6: a Struct and a Data have both methods already — deconstruct is the members in order, deconstruct_keys(nil) is a Hash of all of them, and deconstruct_keys([:x]) honours the request and returns only {x: 1}. Row 7: an Array is its own deconstruct and a Hash its own deconstruct_keys, which is why the literal patterns of the earlier lessons work, and a String has neither, so "ab" in [_, _] is false. Row 8: an Integer has neither method, and 5 in [_] is false rather than an error — a missing method means "does not match". Row 9: the return types are checked; a deconstruct returning a String raises TypeError: deconstruct must return Array, and deconstruct_keys returning a String raises the matching TypeError. Row 10: a MatchData deconstructs into its captures, so a regex match can be pattern-matched — deconstruct gives the groups in order and deconstruct_keys the named groups.
Compared with Python¶
Python's match is nominal where Ruby's is duck-typed. A sequence pattern such as [a, b] does not ask for __len__ and __getitem__: the subject has to be an instance of collections.abc.Sequence, and row 1 shows a class with both methods failing to match until Sequence.register(Pair) — and a str, though a sequence, is excluded on purpose (row 7). A mapping pattern likewise needs a Mapping. What corresponds to deconstruct is the positional class pattern, Point(a, b), which reads the class attribute __match_args__ to learn which attributes the positions mean: without it, row 2 is a TypeError, and with ("x", "y") it matches, reading x and y. What corresponds to deconstruct_keys is the keyword class pattern, Point(x=a, y=b), which needs no declaration at all and reads only the attributes it names, as rows 3 and 4 log; there is no **rest in a class pattern (row 5). namedtuple and dataclass set __match_args__ for you (row 6), just as Struct and Data define the two methods. Row 8: an int is neither a sequence nor a mapping and simply fails to match. Row 9: the type check is on __match_args__, which must be a tuple. Row 10: re.Match has no __match_args__, so a regex result is taken apart with groups() and groupdict() before a pattern can see it.
Verified output of deconstruct_and_deconstruct_keys_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. in [a, b] on a Point -> 'no match' before Sequence.register(Pair); [1, 2] after
2. in Point[a, b] -> TypeError without __match_args__; [1, 2] with ('x', 'y') (read ['x', 'y'])
3. in {x:, y:} -> [1, 2] via getattr: read ['x', 'y']
4. in Point(x:) -> 1 via getattr: read ['x']
5. in {x:, **rest} -> SyntaxError for case Point(x=a, **rest):
6. Struct / Data: deconstruct, deconstruct_keys -> namedtuple __match_args__ ('x', 'y'); dataclass ('x', 'y')
7. Array#deconstruct, Hash#deconstruct_keys -> isinstance([], Sequence) -> True; isinstance({}, Mapping) -> True; "ab" as [_, _] -> 'no match'
8. 5 in [_] (no deconstruct) -> 'no match'; isinstance(5, Sequence) -> False
9. deconstruct returning a String -> TypeError for a list-valued __match_args__
10. MatchData: deconstruct / deconstruct_keys -> __match_args__ on re.Match -> False; groups() ('10', '20') / groupdict() {'a': '10', 'b': '20'}
The Python library has no page on match; class patterns and __match_args__ are specified in PEP 634 ↗, and the sequence and mapping rules in the reference's class patterns ↗ section.
Try it¶
- Write a
Moneyclass withamountandcurrency, adeconstructreturning[amount, currency], and adeconstruct_keys(keys)that prints thekeysit received and returns both fields. MatchMoney.new(5, "EUR")within [a, c], within Money(currency: "EUR")and within {amount:, **rest}, and watch whatkeyswas each time. - Make
deconstruct_keyshonour its argument — return only the requested keys whenkeysis notnil— and prove the three patterns still match. Then return a Hash missing a requested key and see which pattern fails and which exception the=>form raises.
Solution to 1
deconstruct_and_deconstruct_keys_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Exercise 1: a Money class that answers array and hash patterns, printing
# which keys each hash pattern asked for.
class Money
attr_reader :amount, :currency
def initialize(amount, currency) = (@amount, @currency = amount, currency)
def deconstruct = [amount, currency]
def deconstruct_keys(keys)
puts " deconstruct_keys asked for #{keys.inspect}"
{amount: amount, currency: currency}
end
end
price = Money.new(5, "EUR")
case price
in [a, c]
puts "in [a, c] -> #{a} #{c}"
end
case price
in Money(currency: "EUR")
puts "in Money(currency: \"EUR\") -> matched a euro amount"
end
case price
in {amount:, **rest}
puts "in {amount:, **rest} -> amount #{amount}, rest #{rest.inspect}"
end
Verified output of deconstruct_and_deconstruct_keys_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
case/inmatches shape, not just value — the patterns that call these two methods- A pin compares; a bare name binds — pins and guards inside
Point[x, ^y] - Nothing matched:
case/inraises,case/whenreturnsnil— what happens whendeconstructhands back the wrong shape - Struct and Data — the two record types that come with both methods
- Duck typing and
respond_to?— the protocol idea: a method's presence, not a class, decides - Ruby text: named captures become variables ↗ — the other thing a
MatchDatawith named groups can do - Rust: destructuring structs ↗ — where the compiler, not a method, knows the fields
- Ruby docs: pattern matching ↗ — the
deconstructanddeconstruct_keyscontract in the reference