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.
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 withuint32_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$