15
$\begingroup$

I can understand the reason for raising a warning or error when you try to convert a wider integer type to a narrower one, due to the loss of precision. Some C compilers will warn about this:

uint32_t x = 100000;
uint16_t y = x;

Swift raises an error:

let y: UInt32 = 100000
let x: UInt16 = y // error: cannot convert value of type 'UInt32' to specified type 'UInt16'

Swift also prevents widening implicit conversions even though there is no possibility for overflow or loss of precision:

let y: UInt16 = 100000
let x: UInt32 = y // error: cannot convert value of type 'UInt16' to specified type 'UInt32'

Are there any drawbacks to implicit widening conversions? In what cases could allowing them impact program correctness?

$\endgroup$
2
  • 1
    $\begingroup$ Tangentially ontopic: in C++, multiplying uint16_t's is usually done via implicit widening integer promotion to (signed) int. So multiplying (uint16_t)65535 * (uint16_t)65535 may be a signed overflow and therefore an UB. The same story can happen even with uint32_t if your system has unlucky bit widths of fundamental types. (eel.is/c++draft/expr.arith.conv#1.5, eel.is/c++draft/conv.prom) $\endgroup$
    – Vlad
    Commented Jul 20 at 21:36
  • $\begingroup$ Not sure if it merits writing an answer, but no amount of widening can make a signed value "fit" in an unsigned storage space. $\endgroup$ Commented Aug 2 at 1:49

5 Answers 5

38
$\begingroup$

In what cases could allowing them impact program correctness?

I used to work at Coverity, which makes program analysis tools seeking to identify code containing bugs that are (1) plausibly created by real programmers trying to solve real business problems, (2) highly likely to cause end-user-impacting crashes, data loss or incorrect program behaviour, and (3) unlikely to be caught immediately by simply running the code. One such bug which we added a "checker" for during my time there was "late widening conversion".

This happens surprisingly often in C#, Java and similar languages in real-world codebases:

int x = whatever(); 
// ... later, in another part of the codebase ...
double scaled_x = x / 1000;

and the end user is then quite surprised when x is 3141 but x_scaled is 3.000, not 3.141. The meaning of the program is "divide int x by int 1000 to produce a quotient rounded to int, then convert the rounded quotient to double". Plainly the developer intended to write the nigh-indistinguishable:

double scaled_x = x / 1000.0;

which has the meaning "implicitly convert x to double, then divide double by double, and assign the result to scaled_x".

If implicit numeric widening conversions are illegal in the first place then this bug is much less likely. In such a language the developer is encouraged to explicitly convert x to the wider type first.

We saw this defect pattern going from 32 bit integers to 64 bit integers as well; some operation that could overflow a 32 bit int is assigned to a 64 bit variable, but the conversion happens after the operation has already potentially overflowed because the developer has gotten confused about when the conversion happens.

$\endgroup$
16
  • 3
    $\begingroup$ Even worse, given your example, is that some "clever" developer might have done this on purpose (e.g. just learning programming, discovers "cool" floor function), and a future developer might spot it and "fix" the problem. It cuts both ways. Definitely better to disallow implicit widening, in my opinion. $\endgroup$
    – phyrfox
    Commented Jul 18 at 13:25
  • 2
    $\begingroup$ @phyrfox: Disallowing implicit widening is often a bit irritating to developer. In most scenarios, implicit widening results in a program that does what the developer wants. Further, it feels slightly inconsistent to say, "Passing a dog to func(mammal) does not require an explicit cast but passing an Int32 to func(Int64) requires an explicit cast. In SQL, explicit casts inside math are unreadable enough that I'll usually just move all my divisors to the right and do all my casts implicitly (sometimes casting the left-most numerator if there's overflow risk). $\endgroup$
    – Brian
    Commented Jul 18 at 15:05
  • 3
    $\begingroup$ You are both "right" in the sense that reasonable people can differ on this point, and that's why language design has design in the name. Design is the art of finding reasonable compromises amongst incompatible principles. In the design of C# we tended to take a middle ground between avoiding irritation and preventing errors. In the design of VB, by way of comparison, we strongly tended towards "avoid irritation". Different languages are for different developer constituencies. $\endgroup$ Commented Jul 18 at 15:31
  • 2
    $\begingroup$ @Brian "In most scenarios, implicit widening results in a program that does what the developer wants." That's survivor bias. When it doesn't do what the developer wants, that's (a) obviously never built intentionally and (b) likely to be caught as a bug somewhere during the development track. The only teams releasing implicit conversions that don't do what the developer wants are teams who don't have good test coverage and failed to notice this in whatever manual testing they will have done pre-release. $\endgroup$
    – Flater
    Commented Jul 19 at 2:03
  • 2
    $\begingroup$ @Brian: When mistakes are common enough to make, an "Are you sure?" confirmation prompt is warranted. Flagging implicit conversions and asking developers to do it explicitly is the equivalent of that confirmation prompt in a text-based format. It doesn't prevent doing what you're intending to do, it merely prevents uninformedly doing so and causing unintended behavior. $\endgroup$
    – Flater
    Commented Jul 19 at 2:04
3
$\begingroup$

In Tyr, both implicit conversions are not allowed. There isn't even a warning, they types are just viewed as distinct. The rationale behind this was that even when working just with integers, if overflow semantics are intended behaviour, implicit widening can change the result in a sequence of operations if widening happens too early. This often happens in places, where space matters, like serialization. A somewhat artifical example is:

r : short = x : byte + y : byte << 16 | z : byte + w : byte

Here, it makes a difference if z + w can be larger than 255. In that case, you want the programmer to make an explicit choice. Also, such situations are really uncommon. If you look at most Java code, you'll see lots of integers and barely any other number type.

The difference to what we did in the 90s is that the extra kilobytes lost by using int over short or byte for values that cannot really get large is just not important. Having simple, readable and maintainable code is much more important.

$\endgroup$
1
  • 2
    $\begingroup$ FWIW, Kotlin treats this similarly, needing explicit conversions between byte/char/short/int/long/float/double in most cases.  In my experience this is very occasionally annoying — but more often the extra safety is helpful, eliminating this whole class of subtle bugs.  (And yes: clear, simple, straightforward code is a major goal!) $\endgroup$
    – gidds
    Commented Jul 18 at 21:29
3
$\begingroup$

If a program performs an operation like int1 = long1*long2;, the operation may be seen as performing the following sequence of steps:

  1. Convert long1 and long2 into "mathematical integers" and multiply them.

  2. Perform a narrowing conversion from "mathematical integer" to long.

  3. Perform a narrowing conversion from long to int.

Here, the second conversion would be invisible, but it would succeed in all cases where a conversion directly from "mathematical integer" to int would have succeeded, yielding the same value.

If the types were reversed: long1 = int1*int2;, then the sequence of steps would be:

  1. Convert int1 and int2 into "mathematical integers" and multiply them.

  2. Perform a narrowing conversion from "mathematical integer" to int.

  3. Perform a widening conversion from int to long.

In the former case, someone who knew the type of int1 would be able to see that the store would perform a narrowing conversion from any possible wider type to int. In the latter case, there is an invisible conversion from "mathematical integer" to int which occurs before the widening conversion, and which could affect the result. Some language designers want to issue diagnostics to call attention to such hidden conversions.

While it's possible to accomplish this by treating computations as yielding special data types which can be implicitly converted to the operand type, but cannot be implicitly converted to the same types as the operand type could, specifying things precisely is complicated. It's simpler from a language-specification standpoint to forbid all implicit widening conversions even when they don't follow an implicit narrowing conversion, and even though that may ironically have the opposite of the desired effect. While I don't know if any languages actually do this, a language designer could forbid explicit as well as implicit widening conversions for the aforementioned special types, requiring that the result of a computation first be explicitly converted to the narrow type. Given the three code snippets:

long1 = int1*int2;
long1 = (int)(int1*int2);
long1 = (long)(int1*int2);

I'd argue that the first leaves room for doubt as to whether the programmer might have intended to avoid having the result pass through type int, and the second removes any such doubt. The third, however, is even worse than the first because the extra cast suggests that the programmer intended to achieve something other than the normal sequence of operations.

$\endgroup$
2
$\begingroup$

Eric Lippert's answer mentions "late widening conversions" as a source of bugs, i.e. those where the implicit conversion occurs later than the programmer expects. A less common but also possible source of bugs is early widening conversions, like this in Java:

byte x = (byte) 0xFF;
int y = x >>> 4;

The programmer might reasonably expect the value of y to be 0x0F, i.e. 15, since the shift is not sign-preserving. However, the value of y is actually 0x0FFFFFFF, since the widening conversion from byte to int occurs before the shift, and the widening conversion is sign-preserving.

$\endgroup$
1
  • $\begingroup$ Though do note, however, that this problem could be avoided if the wider type was unsigned, which Java unfortunately does not support. $\endgroup$
    – CPlus
    Commented Jul 30 at 18:07
1
$\begingroup$

On dynamic typed languages, widening (and narrowing) is somewhat expected to occur, as some operations can change the value type. It's common, for example, that exceeding some INT_MAX threshold changes the type from an integer type (where additions, subtractions and multiplications are always exact) to a floating point format, where, well... a lot of other things happen.

When talking about correctness, in the context of statically typed languages, we can abandon the notion of a numeric tower or progressive implicit widening, in favor of allowing only implicit correct operations. So we can frame the discussion not in terms of allowing or prohibiting widening/narrowing, but in terms of only allowing implicit exact operations, and requiring that all other operations be made explicit.

I will write some code in a hypothetical language to make the issues and possible solutions more explicit.

In the question, there is the example of assignment of an u32 number in u16. Let's first code the inverse operation:

type u32
    def implicit constructor (num u16) {}

That is, is declared possible to implicit construct a number in u32 from a u16. This is intuitively correct, as u32 number range overlaps and exceeds the u16 number range, so let it pass. Now, let's examine the conversion from question:

type u16
    def implicit constructor (num u32)
    {
        if ( num <= u32( u16.MaxInteger ) )
        { /* number ranges overlaps, ok */ }
        else
        { /* What is correct here? */ }

I would reply: nothing. There is no correct implict conversion from u32 to u16, as this may cause data loss. This conversion should be always explicit.

Correcting the code above:

type u16
    def explicit constructor (num u32)    // Now explicit
    {
        // The programmer really wants this u32 as u16
        // Let's preserve the common bits of both (truncate high)

And in documentation, it's easier and unambiguous what this "casting" does.

Note that there is some ambiguity even in numbers in widening, not only in narrowing. For example, Solidify v0.8.0 made a breaking change in number conversion:

There are new restrictions on explicit type conversions. The conversion is only allowed when there is at most one change in sign, width or type-category (int, address, bytesNN, etc.). To perform multiple changes, use multiple conversions. [...] An example of such a disallowed conversion would be uint16(int8) since it changes both width (8 bits to 16 bits) and sign (signed integer to unsigned integer). In order to do the conversion, one has to go through an intermediate type. In the previous example, this would be uint16(uint8(int8)) or uint16(int16(int8)). Note that the two ways to convert will produce different results e.g., for -1.

So no Solidify programmer will need to answer this:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

int main() {
    int8_t x = -1;
    printf("%d\n", (uint32_t)(int16_t)  x); // Prints -1
    printf("%d\n", (uint32_t)(uint16_t) x); // Prints 65535
    printf("%d\n", (uint32_t)           x); // Prints ... what?
}

After the notion of only correct operations are implicitly allowed, the other cases are now more obvious. Integer to floating point conversion should be explicit for avoiding possible inexact conversion, but operations between floats and ints can be allowed (see the Eric Lippert answer), unary and shifting operations should not changes the operated number type (see the kaya3 example), and operations between same types should not widen the types implicit (see the supercat answer).

These specific rules can be generalized for documentation, but I think that explicitly laying out each one of possible number operations and implicit conversion is the best documentation.

$\endgroup$
3
  • $\begingroup$ Dynamically typed languages commonly don't bother implementing more than one width of integral value in the first place. For that matter, they often have an arbitrary-precision integer as the "native" type. $\endgroup$ Commented Aug 2 at 1:52
  • $\begingroup$ > It's common, for example, that exceeding some INT_MAX threshold changes the type from an integer type (where additions, subtractions and multiplications are always exact) to a floating point format what mainstream languages other than JavaScript do this? $\endgroup$
    – John
    Commented Aug 8 at 10:05
  • $\begingroup$ Bash, PHP, Lua. And basically any languages that the only numeric type is floats/doubles. $\endgroup$ Commented Aug 8 at 12:47

You must log in to answer this question.

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