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

With the recent addition of sentinels to Python 3.15, I would like to write code like this:

from typing import NamedTuple

TRANSPARENT = sentinel("TRANSPARENT")

class Rgb(NamedTuple):
    r: int
    g: int
    b: int

type Color = TRANSPARENT | Rgb
def f(color: Color) -> None:
    match color:
        case TRANSPARENT:     # always matches!
            print("it's transparent!")
        case Rgb(r, g, b):    # never reached!
            print(f"({r}, {g}, {b})")

but this doesn’t work as-is, because the case TRANSPARENT arm always matches because it’s interpreted as a capture variable instead of as a constant.

The usual work-around is to put the constant into a namespace:

class MyNamespace:
    TRANSPARENT = sentinel("TRANSPARENT")

type Color = MyNamespace.TRANSPARENT | Rgb
def f(color: Color) -> None:
    match color:
        case MyNamespace.TRANSPARENT:
            print("it's transparent!")
        case Rgb(r, g, b):
            print(f"({r}, {g}, {b})")

This works, but it feels cumbersome.

I think one natural way to express that you want to match on a constant that’s not in a namespace, is to put a . in front of it:

match color:
    case .TRANSPARENT:   # <- dot in front of the name, not a capture variable
        print("it's transparent!")
    case Rgb(r, g, b):
        print(f"({r}, {g}, {b})")

The syntax .CONST seems to me to intuitively convey the meaning of “look up this constant in the current scope”, similar to how from .module import ... looks for module in the current directory.


As additional motivation, consider how the same code would look like if I were using string literals instead of sentinels:

from typing import Literal

type Color = Literal["transparent"] | Rgb
def f(color: Color) -> None:
    match color:
        case "transparent":
            print("it's transparent!")
        case Rgb(r, g, b):
            print(f"({r}, {g}, {b})")

I don’t need to care about namespaces here at all.

And it would be great if sentinels were to become a fully-fledged alternative to string literals in Python.

22 Likes

I’m not sure that’s actually a benefit. String literals do a fine job of carrying this sort of information. Sentinels have their place, mostly where you could use literally any object; for colours, you couldn’t use arbitrary objects, and so a string literal works fine. (Just ask CSS about that one!) In fact, I would say that this quite nicely fits your match block - you check if it’s the string "transparent" and then if it’s an Rgb object.

Just because sentinels now exist, that doesn’t mean they’re superior to every other option :slight_smile:

1 Like

I see the same issue quite frequently. It’s not uncommon to want to match parts against a variable. The workaround using a guard expression feels unnecessary cumbersome to me.

import ast

def match_name(node: ast.expr, name: str) -> bool:
    match node:
        case ast.Name(name=n) | ast.Attribute(attrname=n) if n == name:
            return True
    return False

Using a . prefix for variable matches makes sense to me here.

import ast

def match_name(node: ast.expr, name: str) -> bool:
    match node:
        case ast.Name(name=.name) | ast.Attribute(attrname=.name):
            return True
    return False

I’d like to see this being added to Python!

6 Likes

I believe this should be handled better too. It’s an annoyance that unintuitively dictates how you structure your code

2 Likes

This very .name syntax was considered and rejected in PEP 622, which originally introduced the match statement.

Note the rejection explicitly mentions the option for adding this syntax later:

If needed, the leading-dot rule (or a similar variant) could be added back later with no backward-compatibility issues.

Now (6 years later) may be a time to reconsider.

20 Likes

This would fail because, in your example, TRANSPARENT exists in the global scope, while the current scope is the local scope created by f.

That shouldn’t be a problem; the normal rules for name lookups are “keep scanning outwards until you find the thing”. (The rules for assignment are different.) So the “current scope” can reasonably be interpreted as also carrying everything at module scope and builtins.

(To be clear, I’m not advocating for this change, but I also don’t see a major issue with it. If people want .name for this lookup, I’d be fine with it)

4 Likes

That’s a fair point.


That would be the idiomatic approach. If we look at the XY problem in question, note that transparency cannot be a boolean or a sentinel value. In fact, it is a value ranging from 0 to 255.

from typing import NamedTuple


class RGBA(NamedTuple):
    r: int
    g: int
    b: int
    a: int


def f(color: RGBA) -> None:
    match color:
        case RGBA(r, g, b, 255):
            print("it's fully opaque!")
        case RGBA(r, g, b, a):
            print(f"({r}, {g}, {b}) with alpha {a}")


rgba = RGBA(0, 0, 0, 255)
f(rgba)

The workaround is using strange-looking new syntax (quoting PEP 622).

True, and then likely the code fragment would have an additional

        case RGBA(r, g, b, 0):
            print("it's fully transparent!")

The “problem” with that modelling: While fully transparent blue and fully transparent red look identical, their values differ: RGBA(255,0,0,0) != RGBA(0,0,255,0).
That’s avoided by OP’s use of singleton TRANSPARENT. Don’t know which one I’d prefer, but I think OP’s solution is defendable (esp. in more complex/less standardised problem domains; see sum types), and then match’s limitation is an inconvenience (with namespace as idiomatic workaround).

1 Like

One could also just overwrite the __new__ of the Color class to return an instance of Color if the values don’t lead to transparency, else some sentinel too, which would mean RGBA(1, 2, 3, 0) is RGBA(3, 2, 1, 0) would be true. E.g.:

TRANSPARENT: RGBA | None = None

class RGBA:
    def __new__(cls: type[RGBA], r: int, g: int, b: int, a: int) -> RGBA:
        if a == 0 and not TRANSPARENT is None:
            return TRANSPARENT

        instance = object.__new__(cls)
        instance.r = r
        instance.g = g
        instance.b = b
        instance.a = a

        if a == 0:
                global TRANSPARENT
                TRANSPARENT = instance

        return instance

True, or one could define __eq__, or some other smart solution.

But that’s besides the point: If one chose to model as OP did, which isn’t ”wrong” AFAIK, then one cannot simply use match, although workarounds for the latter also exist (namespace or e.g. guard).

So nothing that can’t be solved, but inconvenient nonetheless. The topic at hand is whether that inconvenience merits a change and (as per PEP) “strange-looking new syntax”.

4 Likes

Given that sentinels will be in 3.15, and any change coming from this discussion will be in 3.16 at the earliest, maybe the best thing to do will be to wait a while and see how people use sentinels in practice, once 3.15 starts getting used for real. That way we’ll have real-world code to base any decision on.

1 Like

It is the same: the OP ignores RGB because there may be none, but the RGBA model still works even when RGB values are present.

All of these print: "it's fully transparent!"

"""
        case RGBA(r, g, b, 0):
            print("it's fully transparent!")
"""
f(RGBA(1, 0, 0, 0))
f(RGBA(0, 1, 0, 0))
f(RGBA(0, 0, 1, 0))

I deliberately used 0 for RGB to match the OP’s use case:


Yes, abstraction can indeed feel disconnected from reality, especially when it moves too far from concrete experience. You often cannot trace it back to what it originally described, nor can you confidently determine what the person who created it actually understands.

1 Like

Why track an alpha channel if it can’t be used, though? Not every drawing system supports alpha blending. Plenty support only “transparent” and “opaque”.

But, again, you’re playing games with the exact example used, which really doesn’t have any relevance to the proposal. There are PLENTY of situations where a specific constant may be used in place of some other value. Personally, I’m entirely happy with using string literals (assuming the other values can’t possibly be strings), but I can completely understand the desire to use dedicated objects for this purpose.

7 Likes

That makes sense.

Of course, but match only really shines when the object supports structural patterns. Otherwise, it often loses much of its clarity and becomes closer to a verbose if/elif chain, making it harder to reason about without clear capture-and-compare structure or well-used guards.

The status quo wins, and introducing new syntax is costly, especially syntax that would only work within match. match wasn’t meant to replace if/else blocks.

These are readable:

        case _:
            if color is TRANSPARENT:
                print("it's transparent!")
        case Name(name=n) | Attribute(attrname=n):
            return n == target

Obviously yes, you should only be using match if there are structural patterns involved. The question is what to do about the unique cases. Currently there are multiple options that work nicely (a namespaced lookup, a string literal) and one that works less nicely (a global name). Whether the cost of the new syntax is worthy of the improvement is entirely orthogonal to the question of whether pattern matching is the right tool for the job. We can assume that, since the OP is asking about the unique cases, there IS justification enough for the others.

Arguing about the periphery is a distraction.

5 Likes

Since Python 3.9 has reached EoL, I’ve been switching to match/case where appropriate, and it’s definitely an improvement. However, it’s also led me to run into this unintuitive edge case quite frequently.

For example, I recently refactored code like this:

EMPTY = object() # TODO: sentinel()


@dataclass
class Scalar:
    cls: type


def parse(obj: Any):
    if obj is EMPTY:
        return ...

    if not isinstance(obj, Scalar):
        return ...

    if obj.cls is bool:
        return ...

    return ...

into:

import builtins


# Turned that sentinel into a class
# In this case, I had to adjust every use of the older EMPTY sentinel
# but I deemed it worth it this time around but that's not always true
@dataclass
class EmptyType: ...


@dataclass
class Scalar:
    cls: type


def parse(obj: Any):
    match obj:
        case EmptyType():
            return ...
        case Scalar(builtins.bool): # yes, type[bool] is intentional, I'm not looking for an instance
            return ...
        case Scalar(cls):
            return ...

I still prefer the match version, but I find both workarounds a little unfortunate. The sentinel has to become a class just so it can participate in a class pattern, and bool (or any user-defined type) has to be qualified. builtins is just an import away, but for my own types, I had to decide whether I want to move them to another module or stick to if/else.

7 Likes

The issue here is broader than 3.15 sentinels. You can run into it with any constant that you want to use as a special case in a match-case.
I like @Monarch’s example above: I think it makes this very clear.

It’s also broader than match-case. We run into the same naming ambiguity every time nonlocal and global are used.

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

If match gains a special syntax for disambiguating lookup versus assignment, we should consider whether or not that syntax generalizes:

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

Personally, I don’t like this. The dot is very easy to miss and is essential to the semantics. That holds in match-case as well.

But I think the comparison is still instructive. Perhaps, given that we’ve already solved a version of this ambiguity with keywords once, we should do it again.

match color:
    case Rgb(r, g, b):
        print(f"({r}, {g}, {b})")
    constant case TRANSPARENT: 
        print("it's transparent!")
    case _ as unreachable:
        assert_never(unreachable)

I don’t feel strongly that we need this addition, but if we are making a change, consider a keyword. It’s much more readable than the dot, and it’s more consistent, thematically, with global and nonlocal.

Personally I find the proposed dotted-prefix syntax not very intuitively readable.

I would instead propose making global and nonlocal expressions that provide access to respective namespaces so that we can simply spell out the namespace a name belongs to:

TRANSPARENT = sentinel("TRANSPARENT")
class Rgb: ...
type Color = TRANSPARENT | Rgb
def f(color: Color) -> None:
    match color:
        case global.TRANSPARENT:
            ...

This has the additional benefit of allowing mixing same names from different namespaces in the same scope:

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 {nonlocal.self.func.__name__}'
        return method_wrapper

The downside with this approach is that we still won’t be able to reference a name in the local namespace in a match pattern though.

2 Likes