c++unsignedunderflow

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


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

Solution

  • 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.