Pattern matching on constants that aren't in a namespace (especially sentinels)

It’s not just value matching, but I could have written more out here. I was hoping the earlier part about the code being a good fit for pattern matching would leave it unsaid that it was more involved than just matching the value, such as various cases in this thread have been.

such dispatching tables require more driving logic in use than just dict.get as well. giving more than a toy example here isn’t something I can do with the real world code (private code base), As a hint in the general direction, when writing certain binary stream parsers, some reserved values that have no overlap with data “values” have semantic structural meanings, and it’s possible to have functions as both dict keys and dict values.

I earlier suggested keywords rather than symbolic syntax. I think I chose a bad name for the suggested keyword (constant), but it doesn’t matter: with further thought I’ve gone from neutral to -1 on this idea.

Do we really want a second way to spell

case value if value is FOO:

?

I don’t see what we’d be adding that is better than what we already have, other than being shorter.

And I think this thread led me, and possibly others, to not think about some of the cases in which you may want pattern matching to lookup a name rather than binding to a name:

b = "b"
match x:
    case ["a", .b, c]:
        print(c)

I don’t think that can be expressed reasonably by a keyword. But it can be with an if-guard:

b = "b"
match x:
    case ["a", _b, c] if _b == b:
        print(c)

In that context, I wouldn’t want to use keywords to describe this. I don’t think the motivating example for this thread – use of sentinels as the top level match – is the right one to motivate a possible change.

Somewhere between a joke and a real idea - no new keyword:

    match color:
        case TRANSPARENT as is:
            print("it's transparent!")
14 Likes

I like it as a joke. Good one. :+1:

4 Likes

That looks good if testing for a singular value is the only use case, but it’s not–ideally the new syntax should support equality tests in a nested match pattern, e.g.: case TRANSPARENT | NEGATIVE:.

EDIT: Actually it’d work if parenthesized: case (TRANSPARENT as is) | (NEGATIVE as is):, albeit a bit noisy.

EDIT 2: I think I’ve warmed up to it enough that I don’t even think it’s noisy anymore. I actually like the idea.

How about TRANSPARENT as _?

A few examples from pylint. I’ve included the match case body since those do effect the readability as well IMO. Note: The formatting is done with black. This could probably be better in some cases but that’s a different issue.

    case nodes.Attribute(expr=nodes.Name(name=name)) if name == TYPING_MODULE:
        self._type_annotation_names.append(TYPING_MODULE)
        return

source 1

    case nodes.Name(name=n) | nodes.Attribute(attrname=n) if n == typing_name:
        return True

source 2

    case nodes.If(
        test=nodes.Compare(left=nodes.Name(name=n)) | nodes.Name(name=n)
    ) if (n == name):
        return True

source 3

    case nodes.Assign(
        targets=[
            nodes.Subscript(value=nodes.Name(name=name), slice=key_node)
        ],
        value=val_node,
    ) if (
        name == dict_name
    ):
        yield f"{key_node.as_string()}: {val_node.as_string()}"

source 4

While the pattern of MatchAs + guard clause isn’t used frequently, I think the examples above illustrate some of the issues with it pretty well. Some observations:

  • If it is used, it’s usually nested inside other patterns.
  • The variable and pattern often share similar names. That’s an issue as you could end up accidentally overwriting the variable. So you often end up with something like n == name or name == dict_name which isn’t ideal.
  • Just looking at the guard clause alone, it’s difficult to known which of the two is the variable and which the newly assigned one. Looking back at the pattern, especially for the last example, it’s not immediately obvious where the variable itself is actually being checked.

There are additional limitations which haven’t been discussed yet.

  • There are no guard clauses for individual patterns inside an OR. Something like the example below is invalid. It’s possible to work around it by splitting these into two separate cases but that might require duplication of the case body.
        case nodes.Name(name=n) if n == typing_name | nodes.Class():
  • The guard clause is only evaluated after the pattern itself matches. Not being able to match a variable itself, can cause unnecessary work if it’s obvious early on that the guard would evaluate to False. E.g. no need to check targets here if val_node isn’t equal to node:
    case nodes.Assign(
        value=val_node,
        targets=[
            nodes.Subscript(value=nodes.Name(name="spam"), slice=key_node)
        ],
    ) if val_node == node:
  • In theory it’s also possible to construct cases which will never match, though one might think it should have. That’s because the pattern isn’t reevaluated if the guard evaluates to False, it’s just skipped. Even if an alternative pattern would have matched as well and the guard would be True for that. E.g. in the example below only the second case will ever fully match:
match {"a": None, "b": True, 1: "Hello", 2: "World"}:
    case {"a": var, 1: "Hello"} | {"b": var, 2: "World"} if var == True:
        print("Match 1")
    case {"a": True, 1: "Hello"} | {"b": True, 2: "World"}:
        print("Match 2")

Adding support for names to value patterns would help to deal with these. Regarding the proposed alternatives

  • I agree that global. or local. prefixes would risk people wanting to use them elsewhere. Match already has it’s one mini syntax, so we might as well add something just for that too.
  • Additional (soft-) keywords before / after case, like case value ... don’t really work for nested patterns.
  • case spam as is: ... The as suggest that it’s an AS pattern were in reality it would be a value pattern.
  • ==spam / is spam As a student, I’d wonder why this is needed for names but not attributes. Similar for $spam.
  • value(spam) is already defined as a class pattern.

I keep coming back to the leading dot .spam. Though I’m sympathetic to the argument that it can be hard to spot, it’s not more so than a name assignment with the existing uses in deeply nested patterns. In the end it’s a tradeoff, but one I think is worth it. It’s also easy to explain that a value pattern should always contain a “dot”.

14 Likes

What about new mud keyword so you could do case clear as mud?

5 Likes

Like I said above, it can work in nested patterns by enclosing the value pattern in parentheses, so if we are to introduce a new soft keyword value for the mini-language, this is how it can be used in a nested pattern:

case (value TRANSPARENT) | (value NEGATIVE): ...
case (value is TRANSPARENT) | (value is NEGATIVE): ...

We can use a keyword that has no existing meaning in the mini-language instead.

How about:

case with TRANSPARENT: ...

as in “as is the case with TRANSPARENT”.

or:

case for TRANSPARENT: ...

which is pretty self-explanatory.

.spam is just not very intuitively comprehensible to me. I hope that all the more intuitively readable options are fully explored before we settle for something like this.

As a bonus, a keyword-based notation can be generalized to allow evaluation of arbitrary expressions:

case value STOP - 1:

which would look even more awkward with a leading-dot syntax (case .(STOP - 1): :sweat_smile:).

As others have said, in comparing potential new syntax for “value patterns” to what’s currently writeable, we shouldn’t just look at simple examples like case x if x == FOO. You really want to look at scenarios with nested patterns, or repeatedly using the same value, or multiple different value patterns in one case.

How about this (admittedly artificial) example of checking if some JSON-like object contains certain values in an expected way:

# using a SimpleNamespace we create just to have qualified names
def search1(username: str, teamname: str, data):
    ns = types.SimpleNamespace(username=username, teamname=teamname)
    match data:
        case ns.username | ns.teamname | {'user': ns.username} | {'team': ns.teamname}:
            return True
        case [(ns.username | ns.teamname), *_]:
            return True
        case _:
            return False

Let’s see how the “add a guard with ==” style looks:

# don't use value patterns, just guards
def search2(username: str, teamname: str, data): 
    match data:
        case x if x == username or x == teamname:
            return True
        case {'user': x}  if x == username:
            return True
        case {'team': x} if x == teamname:
            return True
        case [x, *_] if x == username or x == teamname:
            return True
        case _:
            return False

Not great. Although you can probably tweak the structure, I’m not sure you can make it nicer.

Now the proposed syntax:

# not currently valid, but if we adopt leading-dot idea
# (substitute a different leading symbol or `value ...` if you prefer)
def search3(username: str, teamname: str, data):
    match data:
        case .username | .teamname | {'user': .username} | {'team': .teamname}:
            return True
        case [(.username | .teamname), *_]:
            return True
        case _:
            return False

IMO there’s an opportunity for worthwhile improvement here.

4 Likes

What about case Some({some_expr}, x={var+1}), f-string style? This is currently invalid syntax.

It could be read as matching a set, but this would be mitigated by:

  1. Not allowing commas (tuples would be need to parenthesized),
  2. Linters could warn about undefined names and hint that sets can’t be matched by contents,
  3. NameError inside a value expression pattern could provide a similar hint in trackbacks.

Any syntax that uses keywords or other elaborate mechanisms has, IMO, the problem that it introduces an inconsistency with the existing “dotted name rule”:

import enum

class Enum(enum Enum):
    VARIANT = enum.auto()

MY_SENTINEL = sentinel("MY_SENTINEL")

class Ns:
    OTHER_SEN = sentinel("OTHER_SEN")

x = ...
match x:
    case Enum.VARIANT:  # just dotted name
        pass
    case keyword MY_SENTINEL:  # why different?
        pass
    case Ns.OTHER_SEN:  # just dotted name again?
        pass

So I think for consistency, the new syntax should be of the form

<scope>.variable_name

And an empty scope (i.e., leading-dot syntax) still makes the most sense to me here but it’s possible that a better scope name exists.

4 Likes

Thanks for the examples! Taking each in turn:

This seems to be using TYPING_MODULE = "typing" from line 56 where there are several other constants being assigned. It would possibly be a backward-incompatible change to make these into a set of class attributes, but if that can be done without breaking things, it would solve the problem:

class Const:
    FUTURE = "__future__"
    ...
    TYPING_MODULE = "typing"

Though personally, I don’t really see the value of having constants for these. String literals should be fine. Unless people expect that the name of the __future__ module will change in the future, or something. I’d have to say it’s rather unlikely, but then again, Valve did leave open the possibility of M_PI changing.

This is a match block with a single case in it that then returns True, and if it fails to match, it returns False. Seems like a code smell to me. There’s already a couple of isinstance checks above; why not two more here?

if isinstance(annotation, nodes.Name): return annotation.name == typing_name
if isinstance(annotation, nodes.Attribute): return annotation.attrname == typing_name
return False

Another one-case match block that comes immediately after if statements that do isinstance checks. Not sure why this isn’t done the same way.

This one’s the most interesting. It has real capturing going on (the key_node and val_node get used in the yield), in addition to the capture-for-comparison. It’s also not a constant (dict_name is a function parameter - in fact, it’s a nonlocal since this is a nested function) so it can’t be replaced with a literal or stuck into a class. So I think this one is the strongest recommendation for having syntax for this.

However, that’s still only one example. Unless there are a lot of other cases like it, the slightly clunky existing syntax is good enough for the rare occasions.

It’s not so much about the name changing. I use this pattern because my linter will freak out if I write FUTRE, but couldn’t care less about '__futre__'.

1 Like

Ehh, fair enough I guess, but it’s a lot of hassle to catch typos, and still can only catch a very specific category of them. I don’t think it’s a use-case that can justify new syntax, especially given that the “stick it in a class” option works fine for those.

This is a nice optimization to have, but it is not related to the syntax in question :slightly_smiling_face:. I am not sure it can be optimized when using guards, i.e., captures happen first, then guards are applied.


I don’t know if this approach looks nicer, but it keeps the structure clear:

def search4(username: str, teamname: str, data):
    if data in (username, teamname):
        return True

    match data:
        case {'user': user, 'team': team}:
            return bool({user, team} & {username, teamname})

        case [x, *_]:
            return x in (username, teamname)

    return False


data = 'Bob'
print(search4('Bob', 'Transceiver', data, ))  # True

data = {'user': 'Bob', 'team': 'Transceiver'}
print(search4('Bob', 'Transceiver', data))  # True
print(search4('Bob', 'Wonderland', data))  # True
print(search4('Alice', 'Wonderland', data))  # False

data = ['Bob', *range(10)]
print(search4('Bob', 'Transceiver', data))  # True
print(search4('Bob', 'Wonderland', data))  # True
print(search4('Alice', 'Wonderland', data))  # False

Note the use of if; match is not meant to replace if, elif, and else.

Thanks for the reply @Rosuav!

The constraint I had when I added the match statement was that it needed to be fully backwards compatible. There shouldn’t be any code changes besides moving from if to match. As it was pointed out multiple times already, of course it’s possible to rewrite existing code so it fits within the constraints of the current syntax but that misses the point here unfortunately.

You’re right that it’s possible to use if statements here. This was the example before moving to a match statement:

if (isinstance(annotation, nodes.Name) and annotation.name == typing_name) or (
    isinstance(annotation, nodes.Attribute) and annotation.attrname == typing_name
):
    return True

IMO though, even with the guard expression, the match statement is easier to read and comprehend. Others thought so too, otherwise it wouldn’t have been merged. Regarding the single case, I don’t think it’s a code spell. Sure it could be improved, maybe at some point a match expression is added, there has been some initial discussion in this topic already. However, even the first match PEP 622 recognized that single case statements would be common and even added it to the deferred ideas section.

This was even worse as an if statement. IMO a prime example where match statements really shine and improve readability.

if (
    next_if_node is not None
    and (
        (
            isinstance(next_if_node.test, nodes.Compare)
            and isinstance(next_if_node.test.left, nodes.Name)
            and next_if_node.test.left.name == name
        )
        or (
            isinstance(next_if_node.test, nodes.Name)
            and next_if_node.test.name == name
        )
    )
):
    return True

I agree that I wouldn’t write this myself. However it was only meant to show a potential pitfall which can happen with other code as well. True, the easiest workaround would be to split these up into two separate cases. However, that will at minimum require duplicating the case body and won’t always make sense. Just consider that the OR pattern is nested 2 or 3 layers deep.


In summary, of course it’s possible to write any example using the existing syntax. I’m not arguing here that everybody should go out there and add the leading-dot everywhere if it is added. However, it expands the toolbox of what is possible to read and write easily with the match statement and that I believe is a worthy improvement.

4 Likes

It can be optimized though if the value pattern supports simple names and not just attributes, e.g. with .node :slightly_smiling_face: That would be checked immediately.

1 Like

FWIW, this is not equivalent: search4('myuser','myteam',data={'user': 'myteam', 'team': 'foo'}) is True, while search1 returns False.

Yes, it should be:

"""
        case {'user': user, 'team': team}:
            return user == username or team == teamname
"""

print(search4('myuser','myteam',data={'user': 'myteam', 'team': 'foo'}))  # False

I tried to make it less verbose than using if/else, but I forgot about the constraint of validating the user and team against their respective variables.