Missing `__match_args__` attribute for `Fraction`

current scenario -

from fractions import Fraction

frctn = Fraction(1, 2)

match frctn:
  case Fraction(1, 2):
    print(1)

gives,

TypeError: Fraction() accepts 0 positional sub-patterns (2 given)

this could be solved by specifying Fraction(numerator=1, denominator=2)

expected scenario -

  1. Fraction(1, 2) also works
  2. which could probably be done by adding __match_args__ = ('numerator', 'denominator') to Fraction class

note -

  1. the same problem appears in other places also,
import itertools

a = itertools.product('1', '123')

match a:
  case itertools.product('1', '123'):
    print(list(a))

gives,

TypeError: itertools.product() accepts 0 positional sub-patterns (2 given)

This seems like a fairly obvious and uncontroversial addition to Fraction to me. Have you considered opening a PR?

For fractions, I can see why this might be plausible. However, it does seem like bug bait because fractions should compare with value equality instead of component equality — fractions get reduced when created:

 match Fraction(2, 6):
      case Fraction(2, 6):
             print('This can never match')
      case Fraction(1, 3):
             print('This would match instead')

For itertools,product(), this doesn’t make sense at all. I wouldn’t apply __match_args__ to any iterators. Nothing good can come from it because the state of iterator changes over time regardless of its inputs. We don’t even provide a direct API for extracting the inputs back out of the object. We can’t do that for generators and shouldn’t for iterators that happen to be implemented as classes.

Would this be significantly more of a bug magnet than the slightly more verbose status quo? You can already do this:

match Fraction(2, 6):
    case Fraction(numerator=2, denominator=6):
        print('this can never match')
    case Fraction(numerator=1, denominator=3):
        print('this would match instead')

Yes, that is just awkward enough to create a mild dissuasion. We shouldn’t go out of our way to make it easier to create bugs. Things that are good for you should be within easy reach. Things that are bad for you, less so.

Also, no one has discussed use cases. We don’t see people using fraction components in if-elif-else chains. AFAICT, no one needs this. Given the other suggestion for product(), the thread seems to just be an exercise in putting __match_args__ in random places. There’s no hint of actual user needs or thinking about whether this would even be a good practice.

What do you think? Is it a recommendation that every class have __match_args__? I don’t think that was previously ever proposed. Early-on, there was an effort to put it in “places that made sense”. Presumably, what is left is places that don’t make sense.

At some point after the best and worst practices emerge, I think we should as an FAQ with known patterns and anti-patterns.

ISTM, we should recommend against __match_args__ in any class where “match cls(a, b, c): case class(a, b, c)” would not match (with a, b, and being literals). That is completely unintuitive in a way that could easy pass a cursory code review but still be incorrect. Fraction(n, d) is one example.

We should also recommend against __match_args__ for stateful instances where the state isn’t captured by the constructor arguments. Iterators would be a prime example. Something like it = enumerate(data); next(it) should not match it = enumerate(data) because they aren’t the same. That is one reason iterators compare on identity instead of equality.

7 Likes

based on some further inspection,

from fractions import Fraction
from decimal import Decimal

frctn = Fraction('4/8')
dcml = Decimal('1')
dcml_ = Decimal(1)
dcml__ = Decimal(1.0)
cmplx = complex(1 + 2j)
flt = float(0.5)
flt_ = float('1.2')
int_ = int(1)
int__ = int('2')

for i in [frctn, dcml, dcml_, dcml__, cmplx, flt, flt_, int_, int__]:
  match i:
    case Fraction(numerator=1): print(f'Fraction(numerator=1) {i}')
    case Decimal(value='1'): print(f"Decimal(value='1') {i}")
    case Decimal(value=1): print(f"Decimal(value=1) {i}")
    case Decimal(value=1.0): print(f"Decimal(value=1.0) {i}")
    case complex(real=1, imag=2): print(f"complex(real=1, imag=2) {i}")
    case float(0.5): print(f'float(0.5) {i}')
    case float('1.2'): print(f"float('1.2') {i}")
    case float(1.2): print(f"float(1.2) {i}")
    case int(1): print(f"int(1) {i}")
    case int('2'): print(f"int('2') {i}")
    case int(2): print(f"int(2) {i}")

gives,

Fraction(numerator=1) 1/2
complex(real=1, imag=2) (1+2j)
float(0.5) 0.5
float(1.2) 1.2
int(1) 1
int(2) 2

it appears that there is some issue with matching Decimal, and probably the int('2'), float('1.2') failing is also a bit controversial.

I don’t understand the instinct to use structural pattern matching in the worst possible way. We already have simple solutions that don’t venture onto thin ice.

match x:
     case 0.5:    # No need for float('.5') or float('0.5')
     case 10:     # No need for int(value=10)
     case 3+4j    # No need for complex(real=3, imag=4)

For anything more interesting, the simplest thing to do is explictly unpack in the match-clause rather trying to do it slowly, ambiguously, awkwardly, and repetitively in the case-clause.

match somefrac.as_integer_ratio():
     case (n, 2):  print(n, 'halves')
     case (n, 3):  print(n, 'thirds')
     case (n, 4):  print(n, 'fourths')
     case (n, 5):  print(n, 'fifths')     
     case (n, d):  print(n, 'of', d)

The Decimal() class is completely futile with _match_args_. The most common way to build a decimal is with a string but it can also be built from ints, other decimals, and from a tuple. Which would you choose for a case statement to extract?

The Decimal() class not only tracks values, it also tracks precision. So we values that are equal, but that have distinct strings with distinct properties:

>>> x = Decimal('3.1')
>>> y = Decimal('3.10')
>>> x == y
True
>>> repr(x) == repr(y)
False
>>> x.as_tuple()
DecimalTuple(sign=0, digits=(3, 1), exponent=-1)
>>> y.as_tuple()
DecimalTuple(sign=0, digits=(3, 1, 0), exponent=-2)

So, which of these would you expect this to succeed?

match Decimal('2.05') + Decimal('1.05'):
    case Decimal(3.1):    # probably not
    case Decimal('3.1'):  # equal but with different precision
    case Decimal('3.10'):  # equal with same precision
1 Like

I prefer specifying the type because I found one example, where not specifying the type would create an issue, that is in the case of list vs tuple,

tpl = (1, 2, 3)
match tpl:
  case [1, 2, 3]: print(1)
  case (1, 2, 3): print(2)

gives, 1 as output
so, I specify the type,

tpl = (1, 2, 3)
match tpl:
  case list([1, 2, 3]): print(1)
  case tuple((1, 2, 3)): print(2)

which gives 2 as expected.
so I prefer case float(0.5) or case int(10) to case 0.5 or case 10, otherwise it is like specify type at some places and dont specify type at other places.

regarding the Decimal() class, I have recently started using it, so dont really know that much about it, and it appears that for some reason Decimal(3.1) gives, Decimal('3.100000000000000088817841970012523233890533447265625')
and most of the operators do not work when using Decimal with other types, like Decimal * {float/complex/Fraction} is all invalid.
maybe if both, x == z and repr(x) == repr(z) hold True then it should be a match.
that is,

x = Decimal('3.10')
z = Decimal('2.05') + Decimal('1.05')
x == z, repr(x) == repr(z)

gives,

(True, True)

one more thing is that operators in certain places are not valid in case statements, that is,

x = 'ab'
match x:
  case 'a' + 'b': print(1)

and,

x = 5
match x:
  case 2 + 3: print(1)

both give error, so I would prefer case complex(real=1, imag=2) over case 1 + 2j, otherwise it is again like here it understands + is for complex numbers, here it does not understand that + is for string concatenation / addition of integers.

Experimentation is all well and good, but just experimenting without reading the specification has limitations when learning a new feature from scratch. I’d suggest taking a look at the docs for the match statement

Some other thoughts:

regarding the Decimal() class, I have recently started using it, so dont really know that much about it

See the decimal module docs, but the point is that the float value 3.1 always means 3.100000000000000088817841970012523233890533447265625 because that’s closest the number that happens to fit in the binary format.

maybe if both, x == z and repr(x) == repr(z) hold True then it should be a match.

It seems needlessly expensive to compute repr for different objects just to check equality. And nothing else in the language works this way.

it is again like here it understands + is for complex numbers, here it does not understand that + is for string concatenation

I think the main thing to remember here is that patterns/cases are not expressions, so they are not evaluated as expressions. Writing case MyClass(x=1): is completely different from y = MyClass(x=1). The case statement translates to something like if isinstance(subject, MyClass) and hasattr(subject, 'x') and subject.x == 1. You can’t make function calls or use operators or use list comprehensions or anything like that as a pattern.

1 Like

Raymond’s examples are a long way from the sort of thing I was thinking about here. I’m not thinking about switch-case style matching of values of a particular type; I’m much more interested in the use of match / case for type-matching and destructuring. Here’s an example of how one might use match to emulate Python’s numeric hashing:

import math
import fractions


P = 2305843009213693951


def hash_numeric(x):
    """
    Hash of a number, ensuring that x == y -> hash(x) == hash(y) across types.
    """
    match abs(x):
        case int(n):
            hash_value = n % P
        case float(f):
            m, e = math.frexp(f)
            hash_value = int(m * 2**53) * pow(2, e-53, P) % P
        case fractions.Fraction(numerator=n, denominator=d):
            hash_value = n * pow(d, -1, P) % P
        case complex(real=xr, imag=xi):
            ...
        case _:
            raise ValueError("Hash not supported")

    if x < 0:
        hash_value = -hash_value
    return hash_value if hash_value != -1 else -2

This seems to me to be a simple and natural way of decomposing a Fraction instance into its pieces so that those pieces can be used. It is of course no more than a convenience: we could do case fractions.Fraction(f): and then pull out the numerator and denominator inside the case clause, but this code works right now and seems to me a reasonable use of the existing destructuring capabilities of match.

The proposal that began this thread was a very minor one: simply to also allow case fractions.Fraction(n, d): to work as well as case fractions.Fraction(numerator=n, denominator=d):. That turns out to be technically problematic because it would allow case fractions.Fraction(n):, which would match only the numerator - I agree that that would be surprising. But that technicality aside, I think it’s a reasonable thing to do.

I don’t think this sort of language is helpful.

4 Likes