Clarifying the float/int/complex special case

The type system has a special case where int is considered to be compatible with float, and in turn float is compatible with complex. This was added for pragmatic reasons in PEP 484.

This behavior is currently specified in the typing spec as follows (Special types in annotations — typing documentation):

Python’s numeric types complex, float and int are not subtypes of each other, but to support common use cases, the type system contains a straightforward shortcut: when an argument is annotated as having type float, an argument of type int is acceptable; similar, for an argument annotated as having type complex, arguments of type float or int are acceptable.

As @carljm pointed out, this wording is imprecise and unclear. It suggests the special case applies only to parameter annotations, but no type checker actually implements it that way.

I’d like to propose an approach that was originally [suggested](What if duck types are unions of all the ducklings · Issue #11516 · python/mypy · GitHub by @KotlinIsland: we treat annotations of float as if the user had written float | int. There is no further special casing of this type. Pyright has implemented this approach for a while and it appears to work well. Pyright does treat float and float | int differently for the purpose of user-visible messages.

I like this idea for a few reasons:

  • It naturally leads to a correct interpretation of isinstance() checks involving floats and ints. Mypy historically has had trouble with this.
  • It limits the special case to a purely syntactic rule that can be applied when a type checker is interpreting a type annotation. It does not involve any special logic in the core of the type system, such as the subtyping relation.
  • It provides both a straightforward implementation strategy for the feature and a straightforward way to describe the behavior in the spec.

I put up a draft spec change in Proposed clarification of spec for int/float/complex promotion by JelleZijlstra · Pull Request #1748 · python/typing · GitHub to codify this interpretation.

9 Likes

I’ve always assumed that the annotation int was just a stand-in for numbers.Integral, float for numbers.Real, and so on. From this standpoint, PyRight’s behavior makes perfect sense.

If we ever get the numeric tower to finally work as intended, practically no one is going to rewrite their interfaces to use the numeric tower types, and nor should they. Instead, they should continue annotating with basic types. In my idealistic world, we would aim for this future.

This is in line with your proposal: float should be treated for now as float | int. In the future though, I’d love to see it expanded to numbers.Real.

Just remove it entirely, what’s the point of it anyway?

Passing an int to a function that accepts floats is usually fine. If your type checker flags that, it’s annoying.

Also see redundant-numeric-union (PYI041) - Ruff

As such, a union that includes both int and float is redundant in the specific context of a parameter annotation, as it is equivalent to a union that only includes float . For readability and clarity, unions should omit redundant elements.

If x: float means x: float | int, how do you say “this will really only accept float”?

>>> import numpy as np
>>> def f(x: float) -> float:
...     return np.array(1) ** (-x)
>>> f(1)
ValueError: Integers to negative integer powers are not allowed.
>>> f(1.0)
1.0
3 Likes

You can’t.

1 Like

You don’t, you can’t do that currently either, so no capabilities lost there. In the original mypy issue there was a suggestion to add a special type to the typing module that means “Really just a float”. I don’t think there’s a pressing need for this, but it also wouldn’t hurt to add it.

What you do gain with the new interpretation:

x: float = 5.0
reveal_type(x)  # float | int
assert isinstance(x, float)
reveal_type(x)  # float

This is a no-op with the old interpretation, since int is still considered a subtype of float wheras with the new rule it’s just convenient syntactic sugar that you then can narrow using assertions/branching.

There are some subtleties though with the new interpretation, what about old-style type aliases without an explicit TypeAlias hint? You could imagine it being used both at runtime and within an annotation. Is the rule here that it’s expanded lazily and the original alias is unaffected, so it’s the same as float in an annotation vs. float at runtime.

Ok, maybe I have always interpreted this wrong. In my example, I currently understand f(1) to be incorrect, but silently accepted by type checkers. [1] Whereas after the change, f(1) is now correct.


  1. And I take “an argument of type int is acceptable” to mean a type checker could complain if it chose to do so ↩︎

f(1) has always been correct (well, since that PEP).

I would rather see that as float - int FWIW. I don’t think it’s ideal having types displayed differently from how they’re annotated. This syntax makes an assumption about how type differences might be implemented, but it could easily be updated.

Can’t you do it using a protocol that requires the type to have __index__? That’s what the __index__ special method is for, after all - to indentify types that can be converted to “something that can be used as an index”.

I don’t think so, floats have a lot more methods than that. I think ints are missing one method that floats have though.

1 Like

Is there some kind of problem with simply writing:

def foo(num: float | int): ...

The entire concept of treating an int as a float makes no sense to me. The effort required to write “float” vs “float | int” is so minimal that I can’t fathom the effort required to standardise this non-standard behaviour.

Any special-casing of standard types increases the mental burden of people trying to comprehend how to write programs.

It’s not an issue in any other language, and if it really is such a concern to some people, then perhaps a protocol could be introduced representing the union of float and int, perhaps named ‘Numeric’ or something.

6 Likes

I often use numbers.Number. It’s nice and readable, and little more effort than importing Iterable and Hashable etc.

I consider the special casing on the numeric tower a mistake. Wanting to only accept floats has practical applications when dealing with things that will later get passed to libraries that only enable fast math (slightly, but acceptably inaccurate) when passed floats, and it’s something that’s been flat-out missing. It’s not even sufficient to have this typed by convention as float because this special case is so well-known and relied on by people, so people will pass in ints. While this is solvable with documentation or runtime checking and erroring, the former means that people now have to think about when a type hint means what it means in the type system or not, and the latter means extra overhead, which would definitely be inappropriate in the case not supported right now.

3 Likes

While it’s a worthwhile discussion whether float (and complex) should accept int types as well, I don’t think this is what should be discussed in this thread. The status quo is (and has been for many years) that ints are accepted for float annotations. Changing this – while possibly making sense – would have major implications. But this discussion should be separate from streamlining the status quo – what this discussion is (or should be) about.

12 Likes

I don’t agree with this being off-topic for this discussion. Streamlining this and placing it in the spec is likely going to lead to it being permanent (at least based on how other things have been treated). I’m suggesting that rather than streamline it, the underlying issue be fixed removing the need for extra special cases in the specification in the process.

7 Likes

The option provided to streamline the status quo seems to work very well for me. I share the concern that this will lead to incorrect behavior being further entrenched and maybe this should be a time to consider if it can be fixed instead, but I’m fine enough with handling this in documentation as it currently needs to be for one place I need things to actually be floats.

To be clear, this special case is very much already in the spec (not to mention in the behavior of every type checker), just in an unclear formulation. It will be challenging (though not impossible) to change this, simply because of how long the behavior has been in place. I don’t think that clarifying the existing meaning of the spec will make it any more difficult to remove this special case than it would already be today.

And I really don’t think that we should actively avoid clarifying unclear existing wording of the spec, just because we might want to change that aspect of it in the future. That’s a recipe for making it very difficult to incrementally improve clarity of the spec. These things should be handled orthogonally.

10 Likes

As has been pointed out multiple times in this thread, this is not a bug, this is specified behavior, implemented by all compliant type checkers.

4 Likes
def foo(n: float) -> bool:
    return n.is_integer()

foo(123)  # int has no attribute "is_integer"

Isn’t it weird to have a discrepancy like this?

5 Likes