4

Python supports Structural Pattern Matching since version 3.10. I came to notice that matching an empty dict doesn't work by simply matching {} as it does for lists. According to my naive approach, non-empty dicts are also matched (Python 3.10.4):

def match_empty(m):
    match m:
        case []:
            print("empty list")
        case {}:
            print("empty dict")
        case _:
            print("not empty")
match_empty([])           # empty list
match_empty([1, 2])       # not empty
match_empty({})           # empty dict
match_empty({'a': 1})     # empty dict

Matching the constructors even breaks the empty list matching:

def match_empty(m):
    match m:
        case list():
            print("empty list")
        case dict():
            print("empty dict")
        case _:
            print("not empty")
match_empty([])           # empty list
match_empty([1, 2])       # empty list
match_empty({})           # empty dict
match_empty({'a': 1})     # empty dict

Here is a solution, that works as I expect:

def match_empty(m):
    match m:
        case []:
            print("empty list")
        case d:
            if isinstance(d, dict) and len(d) == 0:
                print("empty dict")
                return
            print("not empty")
match_empty([])           # empty list
match_empty([1, 2])       # not empty
match_empty({})           # empty dict
match_empty({'a': 1})     # not empty

Now my questions are:

  • Why do my first 2 approaches not work (as expected)?
  • Is there a way to use structural pattern matching to match only an empty dict (without checking the dict length explicitly)?
Tomerikoo
  • 18,379
  • 16
  • 47
  • 61
upe
  • 1,862
  • 1
  • 19
  • 33

2 Answers2

4

As specified, mapping captures are used to match on some substructure of keys/values. An empty dict is a "subdict" of any non-empty dict, so the pattern {} may capture any non-empty dict.

You can add a guard to specify that there are no "extra" items present:

>>> def match_empty(m):
...     match m:
...         case []:
...             print("empty list")
...         case {**extra} if not extra:
...             print("empty dict")
...         case _:
...             print("not empty")
... 
>>> match_empty({})
empty dict
>>> match_empty({1:1})
not empty

For some justification of why mapping patterns allow extra items by default, refer to PEP 635 – Structural Pattern Matching: Motivation and Rationale:

The mapping pattern reflects the common usage of dictionary lookup: it allows the user to extract some values from a mapping by means of constant/known keys and have the values match given subpatterns. Extra keys in the subject are ignored even if **rest is not present. This is different from sequence patterns, where extra items will cause a match to fail. But mappings are actually different from sequences: they have natural structural sub-typing behavior, i.e., passing a dictionary with extra keys somewhere will likely just work.

Finally, there was a slight misconception in the question that may be worth pointing out here:

Matching the constructors even breaks the empty list matching

This is not "matching the constructors". No actual list or dict instance will be created by the list() or dict() written there. It's just the normal way to match on types. It can't be just list or dict because that would be a name binding.

wim
  • 338,267
  • 99
  • 616
  • 750
1

Using a mapping (dict) as the match pattern works a bit differently than using a sequence (list). You can match the dict's structure by key-value pairs where the key is a literal and the value can be a capture pattern so it is used in the case.

You can use **rest within a mapping pattern to capture additional keys in the subject. The main difference with lists is - "extra keys in the subject will be ignored while matching".

So when you use {} as a case, what you're really telling Python is "match a dict, no constraints whatsoever", and not "match an empty dict". So one way that might be slightly more elegant than your last attempt is:

def match_empty(m):
    match m:
        case []:
            print("empty list")
        case {**keys}:
            if keys:
                print("non-empty dict")
            else:
                print("empty dict")
        case _:
            print("not empty")

I think the main reason this feels awkward and doesn't work good is because the feature wasn't intended to work with mixed types like this. i.e. you're using the feature as a type-checker first and then as pattern matching. If you knew that m is a dict (and wanted to match its "insides"), this would work much nicer.

Tomerikoo
  • 18,379
  • 16
  • 47
  • 61