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

I agree the . in .constant is too small.
using global.constant and nonlocal.constant generalises nicely to also have local.constant.

I’d be annoyed at having to figure out where my constant / value lives, something I’ve only very rarely had to do in Python, but it does look better than the alternatives so far. (Including buildins.bool and turning your sentinal into a dataclass.)

PS:
wouldn’t using Enums instead of sentinals also partially “solve” this issue?
True it’s less nice to write

class TRANS(Enum): PARENT = "TRANSPARENT" 
TRANS.PARENT

instead of

TRANSPARENT = sentinel("TRANSPARENT")
TRANSPARENT

but Enums are naturally name-spaced. Or does it not work?

This seems like it must be a less common occurrence than global namespace, at least.

I have to assume that enums are not always a suitable replacement for sentinels, because otherwise why would the sentinels PEP have been accepted?

6 Likes

If local names are to be supported, I suppose I can rationalize the .-prefix notation better if it is generalized like in relative import such that .name means strictly name in the local namespace while ..name means strictly name in the parent namespace:

class Decorator:
    def __init__(self, func):
        self.func = func
    def __call__(self):
        def method_wrapper(self): # so we can stick to naming self, self
            return f'{self} from {..self.func.__name__}'
        return method_wrapper
1 Like

What about adding case == something: and case is something: as syntactic sugar for guards like case foo if foo == something: and case foo if foo is something:?

3 Likes

As a counterpoint, Swift also has a dot-prefix syntax in its match statement (though it has a different meaning there than what I’m proposing here; see below).

The original example would be written like this in Swift:

enum Color {
    case transparent
    case rgb(r: Int, g: Int, b: Int)
}

func f(_ color: Color) {
    switch color {
    case .transparent:
        print("it's transparent!")
    case let .rgb(r, g, b):
        print("(\(r), \(g), \(b))")
    }
}

The dot here means "look up this identifier within the type of color. This is of course only possible in a statically-typed language. For Python I think it makes sense for the dot to mean "look up in current scope ".

1 Like

I’ve run into this a few times. My usual answer has been not to use pattern matching on pre-existing code where it would require changing the location of constants as a result. The churn on working good code isn’t worth it.

I’m +1 on fixing this. Lukewarm on . prefix, but I don’t dislike it. I’m not a fan of the compund keyword version proposed by @sirosen as a counter for this, but I don’t dislike it enough to fight it if more people prefer it, and would still use it.

5 Likes

In the back of my head, I realized why I didn’t like the compound version… it was the use of the word “constant”, when this applies beyond constants (and though those are only by convention in python) Once I realized that, I wrote out some examples with other compound keyword options. The ones that weren’t jarring (without exploring why certain ones jarred me, as similarly to constant, I wasn’t 100% sure as to why some did immediately)

    comparison case SomeAlreadyDefinedIdentifier:
    value case SomeAlreadyDefinedIdentifier:

I have a slight preference visually for

    case value SomeAlreadyDefinedIdentifier:

but there are other things not to like about compounding in that order.

5 Likes

I would prefer is or == being involved since they already mean equality. Maybe something like:

match obj:
    case is EMPTY: ...
    case ScalarType(is bool): ...
    case LiteralType(foo is str): ...
    # the above should be equivalent to
    case LiteralType(foo=builtins.str): ...
match obj:
    case ==EMPTY: ...
    case ScalarType(==bool): ...
    case LiteralType(foo==str): ...
    # the above should be equivalent to
    case LiteralType(foo=builtins.str): ..
7 Likes

You can do this today by exposing a reference to the current module object:

import sys

module = sys.modules[__name__]

This is only useful if you are working on a single-file program.


Knowing that sentinels are not singletons, i.e., calling sentinel() twice does not return the same object, you need to ensure you use the same sentinel across modules, which means importing it. The same applies to any other shared object. The usage examples shown in this thread are, in my opinion, not very realistic.


I second Paul’s point:

the compound version still has the problem of not dealing well with nested constants/values

match obj:
  case ScalarType(buildins.bool, captured_val): ... # currently works

match obj:
  case ScalarType(is bool, captured_val): ... # could work

match obj:
  case ScalarType(.bool, captured_val): ... # could work

match obj:
  case ScalarType(globals.bool, captured_val): ... # could probably work

match obj:
  value case ScalarType(bool, captured_val): ... # can't possibly work

match obj:
  case ScalarType(value bool, captured_val): ... # not sure

2 Likes

It’s not really a sentinel issue though, it’s a bigger issue that is a problem now because 3.9 reached EoL and people are free to use use match/case without breaking their users. A built-in easy to use sentinel will only exacerbate the issue at hand. If you look at my earlier example, you can see I’ve already had to work around the inability to match unqualified values without sentinel() being in the picture.

3 Likes

But how often do you match against specific unqualified values that aren’t sentinels? I’m still of the opinion that, between modules, enums, and string literals, the vast majority of cases should be handled. Do you have any non-toy examples you can link to where none of those is viable?

1 Like

Personally I like the global/local/nonlocal.name syntax the most. It could also allow for a shorter body in other situations, e.g. the previously given example of

_x = 1
def reset_x():
    global _x
    _x = 1

being turned into

_x = 1
def reset_x():
    global._x = 1

Alternatively, globals and locals in the builtins could be extended with nonlocals, and be turned into functions returning namespaces instead of mappings, by using frames to inspect namespaces, then getting attributes from there using get/set/delattr. This would however require python versions with frames, and as far as I remember, some implementations don’t store frames for performance reasons.

The dot syntax makes sense imo, especially when compared to e.g. paths, which show a similarity with attribute ‘paths’ for namespaces. However is is hard to spot, depending on how editors highlight them. Therefore I’d be a -1 on the dot syntax, although the general idea of simplifying namespace traversal sounds pretty neat.

3 Likes

Ah yes instead of repositioning keywords global and nonlocal as expressions, which is a bigger change and still doesn’t solve the problem of accessing locals in a match pattern, it’d be much simpler to make globals and locals perform double duties of being callables like they are now and offering get/set/delattr for namespace access, while adding a nonlocals builtin won’t break things either.

EDIT: One reservation I have is that locals() currently returns nonlocals too, so restricting locals.name to look up name in strictly the local namespace would make it inconsistent with its current meaning.

Only if you want to implement them in pure Python. It isn’t a problem when they’re builtins.

I’m really not a fan of there being a peculiar way to reference things from specific scopes, potentially at the same time. It enables messy code without really giving anything particularly useful. Being able to say globals.spam and locals.spam would only encourage people to do exactly that, and then to insist on writing globals.spam everywhere that a global is used.

If there’s any feature needed here (which I’m yet to be convinced of, but suppose there is), it’s a feature specific to match blocks to allow them to reference unqualified names. Those names should then be looked up the exact same way that any other name lookups happen. Of all the proposals so far, the one I’m most supportive of is - again, still not convinced it’s needed at all, but if it is, this is the syntax I’d prefer:

match EXPR:
    case .spam: ...
    # which would be like:
    case x if x == spam: ...
    # or if you prefer:
    case _ if _ == spam: ...
    # since it wouldn't assign to anything

This would use the same sorts of name lookup rules as would be used for any other name reference, eg LOAD_GLOBAL or LOAD_FAST based on compile-time information.

This definitely does not need to be overengineered. We don’t need a general-purpose way to reference globals, nonlocals, and locals, just to be able to do this. It would be exactly equivalent to a “capture anything but check for equality” match, minus the part where it captures into a name. That’s all it takes.

4 Likes

This does beg the question, what’s actually wrong with case x if x == spam:? The temporary variable x isn’t that bad of a cost, it’s very similar in form to the comprehension [x for x in ...], which has been around for ages now without any major issues.

3 Likes

most of the time actually. That’s why I haven’t used a match-case pattern in at least a year. The unqualified values are for example arguments to a function.

Not a disaster, because I don’t actually need to deconstruct complex objects, so using if-elif-else is fine for my code.

Yes that would be nasty

There’s issues with readability. If you have a class with an attribute called spam, inside a function with a kwarg called spam, it doesn’t look proper to call spam ‘x’. The names have meaning. In the context I’m talking about here it could look like case BreakFast(spam=x) if x == spam:
If you’re dealing with case x if x is SENTINAL: you have to get trained to ignore the x if x is clause, probably by repeated exposure.

Of course we have to make sure any cure is better than the illness. I could see the perspective that all the proposed syntaxes so far have problems. the . in .spam in particular is too small. So let me propose some more syntaxes, in case there’s one people like:

match expression:
  case .spam: ...
  case ?spam: ...
  case !spam: ...
  case $spam: ...
  case spam.__value__: ...
  case value(spam): ...
1 Like

In my experience, needing this has left the non-pattern-matching versions more legible, even though conceptually the code in question should be a good fit for pattern matching.

At the point I have such cases, it’s better to just make a dict and dispatch on it than use what’s supposed to be syntax sugar to make the experience better.

3 Likes

For me personally? Nothing. I am fine with the status quo.

If match is being used as an alternative to dict dispatch, it’s the wrong tool for the job, and dict dispatch should be used instead :slight_smile: A match block should be matching on the shape of a thing, not its value.

1 Like