Clarifying the float/int/complex special case

Which has been fixed in py3.12 by adding is_integer to int. I would much prefer to have int gain all at least somewhat sensible methods that float has to make it a proper subtype instead of having to remember to write 123.0 instead of 123 everywhere. This is IMO one of the worst “features” in quite a few other languages.

But aren’t hex() and fromhex() still missing? It seems like an awful lot of trouble for very little benefit, i.e., not having to write float | int.

4 Likes

Yes, those two are missing. They don’t make sense to add to int() because they output a format that is tied to the internal representation of floats and does not make sense for ints.

Perhaps this is the wrong place to ask and there is discussion elsewhere that I am not aware of, but I had been hoping that this would work.

import numbers
import math

def log3(x: numbers.Real) -> numbers.Real:
    result = math.log(x, 3)
    return result

But both mypy and pylance tell me that float is incompatible with numbers.Real.

number_types.py:6: error: Incompatible return value type (got "float", expected "Real")  [return-value]

I assume that there must be a good reason for that not working, but I was very disappointed to discover that it doesn’t. Still, it feels to me that the abstract Number types really should address the problem.

Numeric Generics - Where do we go from PEP 3141 and present day Mypy? this thread discusses numbers related classes. There have been couple other lengthy discussions elsewhere too.

At it’s core type system relies on two mechanisms,

  1. Nominal types: This is normal subclass relation. class Bar(Foo), Bar is a subclass of Foo.
  2. Structural types: This is protocols/duck like typing. Here we define interface.

The numeric types mostly do not offer a useful nominal relationship. Basic subclass relationships don’t exist across most of them and are missing a few methods. For structural side there’s little that can be usefully said with existing abcs and adding something meaningful there breaks backward compatibility. So they’re mostly unusable classes today and fit in very badly with two type system approaches. You could invent approach 3 only for sake of numbers system, but given it’s status today would still be major lift to define reasonable rules/add stubs/hints everywhere across ecosystem.

I think main way to get something like them is to use protocols with specific methods you want (multiply/add/etc), but main friction there is most typing ecosystem is not written with that in mind. That is not a change for type checkers, but change for stubs/libraries throughout to agree on protocol(s) and use it consistently.

1 Like

I figured there must be a reason for the behavior. And it is a good reason. But still disappointing.

I haven’t yet made use of protocols, but that may well be the way to go in a duck-typing world.

Today, playing around with pyright playground I noticed that pyright has more complex special casing when it comes to float vs. float | int than is proposed here i.e. the two annotations are not equivalent, see the following example:

Code sample in pyright playground

class Foo:
    x: float | int = 2
    y: float = 2

Foo.x.hex()  # errors
Foo.y.hex()  # doesn't error despite being just as unsafe

So it looks like pyright only expands the type to the union when checking for assignability, but not for anything else. In procedural code that uses literals this is less of an issue, since the type will be correctly narrowed:

Code sample in pyright playground

x: float | int = 2
y: float = 2

x.hex()  # errors
y.hex()  # also errors

x = 5.0
x.hex()  # this is fine

So it seems like a good idea to test the proposed rule against mypy_primer output, since I fear that the more simple rule of always expanding the type may lead to new errors, that are currently hidden by pyright’s approach.

2 Likes

.hex() on int could first convert the integer to float and then call .hex() on it. That’s probably what I would expect to happen when I’m accidentally calling .hex() on an int.

The missing class method seems less like a problem (because it’s usually called with float.fromhex()), though you could do a similar thing there.

Absolutely right.

But in an ideal world, we would get a third mechanism, which supports ABC registration. Then, the numeric tower would work, and so would a variety of other ABCs. The difficulties are discussed in the linked thread for anyone interested.

Overall, I’m in favor of Jelle’s proposed change. The wording is clearer and better matches how type checkers behave today.

I do want to point out that this is going to be a little confusing/surprising for anyone who isn’t deeply familiar with the type system:

def func1(f: float):
  f.hex()  # attribute error on `int`? where did `int` come from?

The current wording already implies that it would be correct to report an attribute error here, but none of the type checkers I tested actually do so. So adding this case is to the conformance test suite is effectively requiring a change in behavior for type checkers that wish to stay conformant.

I think this is acceptable, given that hex and fromhex aren’t particularly widely used, and int and float are pretty compatible otherwise (and intentionally so, from what I understand, so the situation shouldn’t worsen over time). But I think it’s worth explicitly calling out that we’d be codifying this unfortunate (IMO) consequence of the int/float/complex special case.

4 Likes