python / cpython

The Python programming language
https://www.python.org
Other
63.06k stars 30.2k forks source link

Make ABCs compatible with match-case syntax #108631

Open malemburg opened 1 year ago

malemburg commented 1 year ago

Has this already been discussed elsewhere?

This is a minor feature, which does not need previous discussion elsewhere

Links to previous discussion of this feature:

No response

Proposal:

I believe this is a minor feature request, but could be wrong.

Many builtin types and user defined classes can use capture variables as first argument, e.g.

match instance:
     case {'name': str(name), 'price': float(price), **extra}:
         pass

When trying to match a number which can be both float or int, you can use the as construct and an or pattern to define the match:

match instance:
     case {'name': str(name), 'price': (float() | int()) as price, **extra}:
         pass

However, this is lengthy and rather annoying if you have to repeat this over and over again. A more natural approach would be to use the numbers module and check against numbers.Real:

import numbers
match instance:
     case {'name': str(name), 'price': numbers.Real(price), **extra}:
         pass

Unfortunately, this fails with a TypeError:

TypeError: Real() accepts 0 positional sub-patterns (1 given)

There is a work-around, but it's not very newbie friendly:

import numbers
match instance:
     case {'name': str(name), 'price': numbers.Real() as price, **extra}:
         pass

I'm not sure whether adding a .__match_args__ = ('x',) to e.g. the numbers.Real ABC would be enough (or even correct), or whether something else needs to be done to make the above work.

Linked PRs

erlend-aasland commented 1 year ago

cc. @brandtbucher

sobolevn commented 1 year ago

Looks like it is possible: https://github.com/python/cpython/pull/108653 My PR serves as a demo.

I am not sure about all the corner cases though, because I know that numeric tower is quite complex to get right.

brandtbucher commented 1 year ago

I'm assuming we're just talking about the numeric tower, and not about ABCs in general (like the collections)?

One wrinkle is types like Complex and Rational, which have more than one "part". There's a reason that we didn't implement __match_args__ on complex and Fraction... basically, you can get weird results when people only match one of the two positional subpatterns. Cases like these would be another point in favor of adding something like __match_args_required__.

I'm also a bit worried about inheritance. If something inherits from these types, they now get automatically get __match_args__, which feels a bit weird because it isn't actually part of the protocol (note that virtual subclasses wouldn't have __match_args__ unless explicitly added). I'm not an expert on ABCs, but I'm generally wary of adding new "stuff" to them for this reason. Maybe it's okay, I don't know.

While I agree this might be able to be improved, I don't think the current situation is all that bad...

match ...:
    case int(x) | float(x):
        print(f"An int or float: {x}")
    case numbers.Rational(numerator=n, denominator=d) as x:
        print(f"Some other rational number: {n}/{d}")
    case numbers.Complex(real=r, imag=i) as x:
        print(f"Some other complex number: {r}+{i}j")
brandtbucher commented 1 year ago

Also, emulating the "use self for the first positional subpattern" behavior of int and float is a bit tricky to get right for ABCs. For a "normal" class, you can do something like:

class C:
    __match_args__ = ("__self__",)
    @property
    def __self__(self):
        return self

For the ABCs other than Complex and Rational, it would probably suffice to say __match_args__ = ("real",), since it's always the same as self (is this always true, though?). But I'm not sure about numbers.Number itself... I don't think registered Numbers have any attribute that you can point to and say "this is the instance's value".

brandtbucher commented 1 year ago

So I think I'm -1 (maybe -0 if we had __match_args_required__). I think the motivating case for int(x) | float(x) isn't quite strong enough to justify the subtlety of the implementation's behavior.

Using as is a normal part of pattern matching and something newbies should become comfortable using with time. I agree that it "feels good" when you can avoid it (that's why we added the "use self for the first positional subpattern" behavior for common builtins), but it's not really something to be avoided on principle alone.