Skip to content

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

  1. Write a Money class with amount and currency, a deconstruct returning [amount, currency], and a deconstruct_keys(keys) that prints the keys it received and returns both fields. Match Money.new(5, "EUR") with in [a, c], with in Money(currency: "EUR") and with in {amount:, **rest}, and watch what keys was each time.
  2. Make deconstruct_keys honour its argument — return only the requested keys when keys is not nil — 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.

in [a, c]                    -> 5 EUR
   deconstruct_keys asked for [:currency]
in Money(currency: "EUR")   -> matched a euro amount
   deconstruct_keys asked for nil
in {amount:, **rest}         -> amount 5, rest {currency: "EUR"}

See also