c++ inconsistent unsigned to signed subtraction results only fails for one permutation

Viewed 309

I realize that there is a rule by which numbers with a width smaller than int can be promoted to a wider type for the addition operation. But I cannot fully explain how only one permutation of the following print_unsafe_minus will fail. How is it that only the <unsigned, long> example fails, and what is the take-away for programmers with regards to best practices?

#include <fmt/core.h>

template<typename M, typename N>
void print_unsafe_minus() {
        M a = 3, b = 4;
        N c =  a - b;
        fmt::print("{}\n", c);
}
int main() {
    // storing result of unsigned 3 minus 4 to a signed type

    print_unsafe_minus<uint8_t, int8_t>(); // -1
    print_unsafe_minus<uint16_t, int8_t>(); // -1
    print_unsafe_minus<uint32_t, int8_t>(); // -1
    print_unsafe_minus<uint64_t, int8_t>(); // -1

    print_unsafe_minus<uint8_t, int16_t>(); // -1
    print_unsafe_minus<uint16_t, int16_t>(); // -1
    print_unsafe_minus<uint32_t, int16_t>(); // -1
    print_unsafe_minus<uint64_t, int16_t>(); // -1

    print_unsafe_minus<uint8_t, int32_t>(); // -1
    print_unsafe_minus<uint16_t, int32_t>(); // -1
    print_unsafe_minus<uint32_t, int32_t>(); // -1
    print_unsafe_minus<uint64_t, int32_t>(); // -1

    print_unsafe_minus<uint8_t, int64_t>(); // -1
    print_unsafe_minus<uint16_t, int64_t>(); // -1
    print_unsafe_minus<uint32_t, int64_t>(); // 4294967295
    print_unsafe_minus<uint64_t, int64_t>(); // -1
}

(edit) Also worth noting-- if we extend the example to include 128-bit integers, then the following two permutations fail as well:

print_unsafe_minus<uint32_t, __int128>(); // 4294967295
print_unsafe_minus<uint64_t, __int128>(); // 18446744073709551615
2 Answers

Before we start, let us assume OP is using an implementation with 32-bit int type. That is, int32_t is equivalent to int.

Let X be the width of M, and Y be the width of N.

Let us divide your test cases into three categories:

First Category: X <= 16

Integer promotions applies here, which is always done before invoking an arithmetic operator.

uint8_t and uint16_t have their whole value ranges representable by int, hence they are promoted to int before doing the subtraction. Then you get a signed value of -1 from doing 3 - 4, which is then used to initialize a signed integer type, which regardless of its width can hold -1. Thus you get -1 as output.

Second Category: (X >= 32) and (X >= Y)

No promotion happens before doing the subtraction.

The rule that applies here is that unsigned integer arithmetic is always modulo 2X, where X is the width of the integer.

Hence a - b always give you 2X - 1, since this is the value that is equal to -1 modulo 2 in the range of M.

Now you assign it to a signed type. Let us assume C++20 (before C++20 it is implementation-defined behavior when assigning an unsigned value that cannot be represented by a destination signed type).

Here the result of a - b (i.e 2X - 1) is converted to the unique value that is congruent to itself modulo 2Y in the destination range (i.e from -2Y-1 to 2Y-1 - 1). Since X >= Y, this is always going to be -1.

So you get -1 as output.

Third Category: (X >= 32) and (X < Y)

There is only one case in this category, namely the case where M = uint32_t, N = uint64_t.

The subtraction is the same as in category 2, where you get 232 - 1.

The rule to convert to the signed type is still the same. However, this time, 232 - 1 is equal to itself modulo 264, so the value remains unchanged.

Note: 4294967295 == 232 - 1

Take Away

This is probably a surprising aspect of C++, and as suggested by @NathanOliver, you should avoid mixing signed types and unsigned type, and take extreme care when you do want to mix them.

You can tell the compiler to generate warnings for such conversion by turning on -Wconversion. Your code gets a lot of warnings when this is turned on.

Let's assume a sane two-complement platform where int has 32-bits and uint32_t is the same as unsigned.

    uint32_t a = 3, b = 4;
    int64_t c =  a - b;

Operands to - operator undergo integral promotions*. int cannot represent all values of uint32_t, but 32-bit unsigned can represent all values of uint32_t. The values are promoted to unsigned. The result type of - is the common type of operands after promotions - both operands are unsigned. The result type of - operator is unsigned. a - b is mathematically -1. The result is (unsigned)-1, but unsigned cannot represent negative numbers. So -1 is converted to an unsigned type, it "wraps around" and results in UINT_MAX, which is equal to UINT32_MAX, because unsigned has 32-bits. This result is representable in int64_t so no conversion happens and c is assigned the value of UINT32_MAX.

In contrast let's take for example <uint16_t, int64_t>. A 32-bit int can represent all values of an uint16_t, so uint16_t is promoted to int, so the result of a - b is just an (int)-1. There is no conversion from (int)-1 to an unsigned number. Then int64_t can represent -1, so the value -1 is just assigned to a variable with type int64_t.

* It's called integer promotions in C language...

Related