7
$\begingroup$

In some languages, a number literal's type can be inferred from nearby expressions. For example, 1u64 + 2 might give a u64 value of 3, since the 2's type is inferred to be u64. This can similarly be done with floats/doubles. One language I've used that works like this is Rust; a number literal without a decimal point is an int, and a number literal with a decimal point is a float or double.

However, this can make working with floats a little inconvenient. E.g., in a weakly typed language, I could write an expression like (some_float + 1) / 2; however, since 1 and 2 are int literals in Rust, this gives an error:

let out0 = (some_float + 1) / 2;
                       ^ no implementation for `f64 + {integer}`

This makes it necessary to write something like (some_float + 1.) / 2.. So, my idea was to make number literals with a decimal point always act as floats/doubles, but allow decimal-point-less literals to be treated either as ints or floats.

I'm not sure if there any languages which take this approach. Are there any potential downsides or ambiguities that could arise from this?

$\endgroup$
9
  • 6
    $\begingroup$ Haskell has overloaded number literals (which are a bit more general than your idea), you might want to look into that $\endgroup$
    – user
    Commented Dec 8, 2023 at 16:02
  • $\begingroup$ Another option is to have different operators for integer and floating-point operations: this would inform the type checker about the expected types of the arguments. But syntactically this is probably more cumbersome than giving number literals unambiguous types. OCaml uses + and +. for example and it is not popular. $\endgroup$ Commented Dec 8, 2023 at 16:42
  • $\begingroup$ Perhaps, if your language allows, you could overload the + operator for both floats and integers to take an operand of a different type. $\endgroup$ Commented Dec 8, 2023 at 17:32
  • 3
    $\begingroup$ The meaning of the first paragraph is unclear to me. You suggest that 2's type is inferred from the nearby u64 qualifier, then you say that Rust works that way, then you say (and show by example) that Rust doesn't work that way. $\endgroup$
    – benrg
    Commented Dec 8, 2023 at 19:03
  • 1
    $\begingroup$ @benrg It does work that way for ints; 2 could be an i32 or a u8 or a u64 and so on. But unless there's a . it won't interpret it as a float. $\endgroup$ Commented Dec 8, 2023 at 19:12

6 Answers 6

14
$\begingroup$

Negative zero

This barely feels like an answer, but it's the biggest "wut" I've noticed with it so far in Swift.

In Swift, if there is no separation between a unary minus and the number, and no method calls on the literal, the minus sign is interpreted as part of the literal. So, let x: Double = -0 is interpreted as converting the integer -0 (i.e. 0) to a Double, giving 0.0, while let y: Double = -(0) is interpreted as converting the integer 0 to a Double and then negating it, giving -0.0. Writing -0.0 avoids this by always being negative zero.

$\endgroup$
5
  • $\begingroup$ Haskell allows decimal-point-less number literals to be interpreted as floats, but it doesn't have this problem because it doesn't first treat the literal as an integer to later convert. Rather, it understands the type as Double from the start by using the surrounding context: let { x :: Double; x = -0 } in x is -0.0. $\endgroup$
    – JoL
    Commented Dec 10, 2023 at 6:08
  • $\begingroup$ Kind-of scratch what I said. Now that I've just checked, the way it works is that -0 is an Integer, which is this ADT that can represent both positive and negative zero. That's then converted to other number types via their instance to type class Num, which has the function fromInteger :: Num a => Integer -> a. So conversion does happen, it's just that -0 is first an integer type that's able to represent negative zero to cover this case. $\endgroup$
    – JoL
    Commented Dec 10, 2023 at 6:26
  • 1
    $\begingroup$ @JoL: That's not right either. Thanks to the invariants on Integer (that small integers are always represented by a machine Int#), Integer cannot represent a distinct negative zero. (Which is good, because there's no such integer!) What's happening is that - is not part of the literal in Haskell: -0 is desugared to negate (fromIntegral (0 :: Integer)), which is the same as what's happening in Swift with -(0). $\endgroup$ Commented Dec 10, 2023 at 19:48
  • 1
    $\begingroup$ I wanted to say you could see the difference with -XNegativeLiterals, but there seems to be some special-casing for 0: eliding a lot of noise, using -ddump-simpl says that -0 becomes GHC.Num.negate @GHC.Num.Integer.Integer GHC.Num.$fNumInteger (GHC.Num.Integer.IS 0#) either way, but -1 desugars like the former by default but as GHC.Num.Integer.IS -1# if -XNegativeLiterals is enabled. I'm not sure what the exact rules are there. $\endgroup$ Commented Dec 10, 2023 at 19:50
  • $\begingroup$ @AntalSpector-Zabusky You know what, that makes a ton more sense, and now that I look back, I could've also realized I was wrong if I looked at fromInteger (-0 :: Integer) :: Double which is 0.0. $\endgroup$
    – JoL
    Commented Dec 10, 2023 at 21:48
4
$\begingroup$

Inexact representation

1 is 1 in u64, u32, u16 and even u1. That is, they compare equals in all these types.

But floats don't have this property. 0.1f16 is represented very differently in memory than 0.1f32 or 0.1f64, and almost certainly does not compare equals in any of these cases. This is the principal reason that a lot of languages don't treat them as the same as in the integral types.

Language ergonomics

But your example of (some_float + 1) / 2, 1 and 2 almost certainly have exact representations in some_float's format, and if the language detects that, it can overcome the very reason the code is rejected in the first place.

Detecting if a particular int has exact representation in some float format normally goes over what is expected of the parser/compiler of the language, but this question is about alternatives.

Accept exact representations

Instead of rejecting (some_float some_operation some_int) in all cases, at parser level, run an extra step of detecting if the some_int have exact representation on some_float format, and accept that.

Or, in other cases, reject with a much more explanatory error diagnostic. Something like some_int has no exact representation in f64.


Note that placing a . on integer literals or operators to "resolve" this compilation error is a bit misleading, as there is no guarantee that any float literal will have an exact representation, in any floating point format. It only directs the compiler to call some float parsing code. Having the compiler reject this construct in some cases, with a very specific error message, could pose as an ideal starting point in educating programmers about the inexact nature of most float literals.

$\endgroup$
5
  • 2
    $\begingroup$ I don't buy the argument about inexact representation. Sure, 0.1 represents different exact values depending on the level of precision, but the question is asking about "decimal-point-less number literals". Surely 1f16, 1f32, 1f64, 1u1, 1u32, 1i64 etc all represent the exact same value? $\endgroup$
    – Bergi
    Commented Dec 9, 2023 at 20:56
  • 2
    $\begingroup$ @Bergi For small integral values yes, they all represent the same ‘exact’ numeric value (although the floats do so slightly differently from each other). But small integral values are never the issue with floats, large integral values are, and the definition of ‘large’ depends on your float size. For example 4000f16 is likely not the exact same number as 4000u16, because most 16-bit float representations don’t have enough significand bits to represent the integer 4000 exactly. $\endgroup$ Commented Dec 10, 2023 at 14:36
  • $\begingroup$ @AustinHemmelgarn Same issue with 4000u8 though $\endgroup$
    – Bergi
    Commented Dec 10, 2023 at 14:39
  • 2
    $\begingroup$ @Bergi No. 4000u8 is an error, 4000 is not representable as an u8, but 4000f16 is (so far) not an error, because 4000 is (imprecisely, but validly) representable as an f16. $\endgroup$
    – Pablo H
    Commented Dec 11, 2023 at 16:01
  • 2
    $\begingroup$ @Bergi There’s a difference between being able to provide an exact representation, being able to provide an approximate representation, and not being able to provide any representation. The integer 4000 cannot be represented in a standard u8 type (it will overflow or be truncated), but it can be approximated with most typical f16 types even if they cannot provide an exact representation. $\endgroup$ Commented Dec 15, 2023 at 2:48
2
$\begingroup$

It seems to me that several ambiguities arise.

If your language has function overloading, then given fn foo(u64); and fn foo(f64); with different behaviors, which one is called by foo(3)?

If you have type inference, then given let x = 3;, what is the type of x?

Given f64 y = (9223372036854775808 + 9223372036854775808) as f64;, does y get assigned the value 18446744073709551616.0, or 0.0, or does it cause an integer overflow trap?

$\endgroup$
1
  • 1
    $\begingroup$ "If you have type inference, then given let x = 3;, what is the type of x?" This is actually only an issue in languages with half-baked inference. In Haskell, a local let x = 3 causes the type of x to be inferred from how x is used, i.e. the expression let x = 3 in x + (5 :: Int) has x :: Int whereas let x = 3 in x + (pi :: Double) has x :: Double. $\endgroup$ Commented Dec 10, 2023 at 0:59
0
$\begingroup$

There is a syntactic drawback and I created a small Tyr example explaining it here. The thing is that, if you use . for member access, you need to choose if 0.f is 0 . f or a single float literal with value 0. My personal preference is to use member access with . and allow member access on integers. If you agree on that, you will see why 0. should be interpreted as 0 . wrt. to tokenization. If you now think about something like "let's fix it later in the parser" you will run into all sorts of ambiguities in the 0.f case.

But the question itself is valid. Hence, Tyr interprets 0f as float32 0. Wrt. to the question, I simply exchanged the . with an f to fix the ambiguity with member access. Actually, these ambiguities are far worse than all the implicit conversions between signed, unsigned and float.

One could also add 0d for double, but I doubt that it is really needed because most users will be used to writing 0.0.

$\endgroup$
8
  • 2
    $\begingroup$ Javascript also uses . for member access and allows member accesses on numbers, but syntactically the . is part of the number token (based on the longest-match rule). So I don't buy your argument that "If you agree on that, you will see why 0. should be interpreted as 0 ." ─ Javascript's approach doesn't seem to cause any problems. In practice I think it's quite rare that you need to access a member of a literal, because the result will be some constant value which you could have written instead, but if you do ever need this then having to write (0).foo is not a dealbreaker. $\endgroup$
    – kaya3
    Commented Dec 9, 2023 at 12:31
  • $\begingroup$ Or you can write 0..foo, which is a little more confusing at first but technically shorter. $\endgroup$
    – Bbrk24
    Commented Dec 9, 2023 at 13:24
  • $\begingroup$ I would need to have a look at how JavaScript does it and what the limitations really are. My experience with moving token distinction to the parser is that it always creates odd corner cases like no longer being able to split lines after a . without changing semantics. For me (0).f not being equal to 0.f would be a deal breaker. The 0..f comment is not valid as it would have the semantics of 0f.f in contrast to the intended (0).f. Should I extend my answer by these two boundary conditions? $\endgroup$
    – feldentm
    Commented Dec 9, 2023 at 13:43
  • $\begingroup$ @feldentm it seems a bit dramatic for something that minor to be a "deal-breaker". As Bbrk said, it's likely you'll almost never need to access a member of a constant, and when you do, wrapping it in parentheses is perfectly intuitive and works fine. Also, I'm not sure I understand what you mean about moving logic to the parser; "a . after a number is part of the same token" is a simple rule that works just fine right? $\endgroup$ Commented Dec 9, 2023 at 22:57
  • $\begingroup$ @RydwolfPrograms your position on . makes sense if you consider 0..f and (0).f good solutions for something like (0).show(). If you want it to be 0.show() you need to do some parser and magic words logic. But, the more we discuss it, the more my position against having it is that 0.show() yields a parse error and 0..show() yields "0.0f". $\endgroup$
    – feldentm
    Commented Dec 10, 2023 at 6:55
0
$\begingroup$

In Swift, “3” on its own without the quotes is not a number, it is an IntegerConvertibleLiteral (or something like that). Type interference is complicated. In

let x: Int8 = 3

it becomes an Int8. In

let d: Double = 3 + 5

3 + 5 must be Double, therefore 3 and 5 must be Double, so 3 becomes a Double. 3 prefers to be an Int during type inference and 3.0 prefers to be a Double, so

let x = 3

turns 3 and x into Int since there is no stronger inference rule.

$\endgroup$
0
$\begingroup$

Haskell does this. In Haskell, the type of the literal 2 is Num a => a which can be instantiated as Int, or Float, or some matrix type, or whatever else satisfies Num

$\endgroup$
1
  • $\begingroup$ As it’s currently written, your answer is unclear. Please edit to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers in the help center. $\endgroup$ Commented Dec 19, 2023 at 11:09

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .