Numeric Conversions

In the previous lesson, we covered numeric promotions, which are conversions of specific narrower numeric types to wider numeric types (typically int or double) that can be processed efficiently.

C++ supports another category of numeric type conversions, called numeric conversions. These numeric conversions cover additional type conversions between fundamental types.

Key Concept
Any type conversion covered by the numeric promotion rules is called a numeric promotion, not a numeric conversion.

There are five basic types of numeric conversions:

1. Converting an integral type to any other integral type (excluding integral promotions):

short quantity = 3; // convert int to short
long distance = 3; // convert int to long
char letter = quantity; // convert short to char
unsigned int count = 3; // convert int to unsigned int

2. Converting a floating point type to any other floating point type (excluding floating point promotions):

float price = 3.0; // convert double to float
long double bigValue = 3.0; // convert double to long double

3. Converting a floating point type to any integral type:

int result = 3.5; // convert double to int

4. Converting an integral type to any floating point type:

double percentage = 3; // convert int to double

5. Converting an integral type or a floating point type to a bool:

bool flag1 = 3; // convert int to bool
bool flag2 = 3.0; // convert double to bool
Note
Because brace initialization strictly disallows some types of numeric conversions (more on this in the next lesson), we use copy initialization in this lesson (which doesn't have any such limitations) to keep examples simple.

Safe and Unsafe Conversions

Unlike numeric promotions (which are always value-preserving and thus "safe"), many numeric conversions are unsafe. An unsafe conversion is one where at least one value of the source type cannot be converted into an equal value of the destination type.

Numeric conversions fall into three general safety categories:

1. Value-preserving conversions are safe numeric conversions where the destination type can exactly represent all possible values in the source type.

For example, int to long and short to double are safe conversions, as the source value can always be converted to an equal value of the destination type.

int main()
{
    int score{ 5 };
    long largeScore = score; // okay, produces long value 5

    short health{ 5 };
    double healthPercent = health; // okay, produces double value 5.0

    return 0;
}

Compilers will typically not issue warnings for implicit value-preserving conversions.

A value converted using a value-preserving conversion can always be converted back to the source type, resulting in a value equivalent to the original value:

#include <iostream>

int main()
{
    int original = static_cast<int>(static_cast<long>(3)); // convert int 3 to long and back
    std::cout << original << '\n';                         // prints 3

    char letter = static_cast<char>(static_cast<double>('c')); // convert 'c' to double and back
    std::cout << letter << '\n';                               // prints 'c'

    return 0;
}

2. Reinterpretive conversions are unsafe numeric conversions where the converted value may be different than the source value, but no data is lost. Signed/unsigned conversions fall into this category.

For example, when converting a signed int to an unsigned int:

int main()
{
    int posValue{ 5 };
    unsigned int uPosValue{ posValue }; // okay: will be converted to unsigned int 5 (value preserved)

    int negValue{ -5 };
    unsigned int uNegValue{ negValue }; // bad: will result in large integer outside range of signed int

    return 0;
}

In the case of uPosValue, the signed int value 5 is converted to unsigned int value 5. Thus, the value is preserved in this case.

In the case of uNegValue, the signed int value -5 is converted to an unsigned int. Since an unsigned int can't represent negative numbers, the result will be modulo wrapped to a large integral value that is outside the range of a signed int. The value is not preserved in this case.

Such value changes are typically undesirable and will often cause the program to exhibit unexpected or implementation-defined behavior.

Warning
Even though reinterpretive conversions are unsafe, most compilers leave implicit signed/unsigned conversion warnings disabled by default.

This is because in some areas of modern C++ (such as when working with standard library arrays), signed/unsigned conversions can be hard to avoid. And practically speaking, the majority of such conversions do not actually result in a value change. Therefore, enabling such warnings can lead to many spurious warnings for signed/unsigned conversions that are actually okay (drowning out legitimate warnings).

If you choose to leave such warnings disabled, be extra careful of inadvertent conversions between these types (particularly when passing an argument to a function taking a parameter of the opposite sign).

Values converted using a reinterpretive conversion can be converted back to the source type, resulting in a value equivalent to the original value (even if the initial conversion produced a value out of range of the source type). As such, reinterpretive conversions don't lose data during the conversion process.

#include <iostream>

int main()
{
    int recovered = static_cast<int>(static_cast<unsigned int>(-5)); // convert '-5' to unsigned and back
    std::cout << recovered << '\n'; // prints -5

    return 0;
}

3. Lossy conversions are unsafe numeric conversions where data may be lost during the conversion.

For example, double to int is a conversion that may result in data loss:

int whole = 3.0; // okay: will be converted to int value 3 (value preserved)
int truncated = 3.5; // data lost: will be converted to int value 3 (fractional value 0.5 lost)

Conversion from double to float can also result in data loss:

float precise = 1.2;        // okay: will be converted to float value 1.2 (value preserved)
float imprecise = 1.23456789; // data lost: will be converted to float 1.23457 (precision lost)

Converting a value that has lost data back to the source type will result in a value that is different than the original value:

#include <iostream>

int main()
{
    double recovered{ static_cast<double>(static_cast<int>(3.5)) }; // convert double 3.5 to int and back
    std::cout << recovered << '\n'; // prints 3

    double recovered2{ static_cast<double>(static_cast<float>(1.23456789)) }; // convert double 1.23456789 to float and back
    std::cout << recovered2 << '\n'; // prints 1.23457

    return 0;
}

For example, if double value 3.5 is converted to int value 3, the fractional component 0.5 is lost. When 3 is converted back to a double, the result is 3.0, not 3.5.

Compilers will generally issue a warning (or in some cases, an error) when an implicit lossy conversion would be performed at runtime.

Warning
Some conversions may fall into different categories depending on the platform.

For example, `int` to `double` is typically a safe conversion, because `int` is usually 4 bytes and `double` is usually 8 bytes, and on such systems, all possible `int` values can be represented as a `double`. However, there are architectures where both `int` and `double` are 8 bytes. On such architectures, `int` to `double` is a lossy conversion!

We can demonstrate this by casting a long long value (which must be at least 64 bits) to double and back:

#include <iostream>

int main()
{
    std::cout << static_cast<long long>(static_cast<double>(10000000000000001LL));

    return 0;
}

This prints:

10000000000000000

Note that our last digit has been lost!

Unsafe conversions should be avoided as much as possible. However, this isn't always possible. When unsafe conversions are used, it is most often when:

  • We can constrain the values to be converted to just those that can be converted to equal values. For example, an int can be safely converted to an unsigned int when we can guarantee that the int is non-negative.
  • We don't mind that some data is lost because it isn't relevant. For example, converting an int to a bool results in the loss of data, but we're typically okay with this because we're just checking if the int has value 0 or not.

More on Numeric Conversions

The specific rules for numeric conversions are complicated and numerous, so here are the most important things to remember:

  • In all cases, converting a value into a type whose range doesn't support that value will lead to unexpected results. For example:
int main()
{
    int largeValue{ 30000 };
    char smallChar = largeValue; // chars have range -128 to 127

    std::cout << static_cast<int>(smallChar) << '\n';

    return 0;
}

In this example, we've assigned a large integer to a variable with type char (that has range -128 to 127). This causes the char to overflow, producing an unexpected result:

48
  • Remember that overflow is well-defined for unsigned values and produces undefined behavior for signed values.
  • Converting from a larger integral or floating point type to a smaller type from the same family will generally work so long as the value fits in the range of the smaller type. For example:
int whole{ 2 };
short smallWhole = whole; // convert from int to short
std::cout << smallWhole << '\n';

double precise{ 0.1234 };
float lessPrec ise = precise;
std::cout << lessPrecise << '\n';

This produces the expected result:

2
0.1234
  • In the case of floating point values, some rounding may occur due to a loss of precision in the smaller type. For example:
float lessAccurate = 0.123456789; // double value 0.123456789 has 9 significant digits, but float can only support about 7
std::cout << std::setprecision(9) << lessAccurate << '\n'; // std::setprecision defined in iomanip header

In this case, we see a loss of precision because the float can't hold as much precision as a double:

0.123456791
  • Converting from an integer to a floating point number generally works as long as the value fits within the range of the floating point type. For example:
int whole{ 10 };
float realNumber = whole;
std::cout << realNumber << '\n';

This produces the expected result:

10
  • Converting from a floating point to an integer works as long as the value fits within the range of the integer, but any fractional values are lost. For example:
int truncated = 3.5;
std::cout << truncated << '\n';

In this example, the fractional value (.5) is lost, leaving the following result:

3

While the numeric conversion rules might seem scary, in reality the compiler will generally warn you if you try to do something dangerous (excluding some signed/unsigned conversions).

Summary

Numeric conversions: Type conversions between fundamental types that are not covered by the numeric promotion rules.

Value-preserving conversions: Safe conversions where the destination type can exactly represent all possible values in the source type (e.g., int to long, short to double).

Reinterpretive conversions: Unsafe conversions where the converted value may differ from the source value, but no data is lost (primarily signed/unsigned conversions). Can be converted back to the original type to recover the original value.

Lossy conversions: Unsafe conversions where data may be permanently lost during conversion (e.g., double to int loses fractional component, double to float may lose precision). Converting back will not recover the original value.

Compiler warnings: Compilers typically warn about lossy conversions but may not warn about signed/unsigned conversions by default (due to their frequency in modern C++ code).

Best practices: Avoid unsafe conversions when possible. When they're unavoidable, ensure values are constrained to safe ranges or that data loss is intentional and acceptable for your use case.

Understanding numeric conversions helps you write code that handles type mismatches correctly and avoids unexpected data loss or value changes.