Narrowing Conversions, List Initialization, and Constexpr Initializers

In our previous exploration of numeric conversions, we learned that C++ can automatically convert between various numeric types. However, not all conversions are created equal, and some carry significant risks.

What Are Narrowing Conversions?

A narrowing conversion is a potentially dangerous numeric conversion where the target type might not accommodate all possible values from the source type.

The C++ standard defines these conversions as narrowing:

  • Floating-point types to integral types.
  • Wider floating-point types to narrower ones (unless the value is constexpr and fits within the target range, even without full precision).
  • Integral types to floating-point types (unless the value is constexpr and representable exactly).
  • Wider integral types to narrower ones (including signed/unsigned conversions), unless the value is constexpr and representable exactly in the target type.

Most implicit narrowing conversions trigger compiler warnings, although signed/unsigned conversions may not always warn depending on compiler settings.

These conversions are problematic because they can silently lose data or produce unexpected results.

Best Practice
Avoid narrowing conversions whenever possible since they introduce potential bugs and data loss.

Making Intentional Narrowing Conversions Explicit

Sometimes narrowing conversions cannot be avoided, particularly when calling functions where parameter types don't match your arguments perfectly.

When you must perform a narrowing conversion, make it explicit using static_cast. This documents your intention and silences compiler warnings.

void processTemperature(int celsius)
{
}

int main()
{
    double temperature{ 98.6 };

    processTemperature(temperature); // implicit narrowing: compiler warning

    processTemperature(static_cast<int>(temperature)); // explicit: no warning

    return 0;
}
Best Practice
Use `static_cast` for any necessary narrowing conversions to make them explicit.

List Initialization Prevents Narrowing Conversions

One of the primary advantages of brace initialization is its rejection of narrowing conversions, producing a compilation error instead of silently losing data.

int main()
{
    int score { 95.7 }; // compilation error: narrowing conversion

    return 0;
}

A typical error message might read:

error: narrowing conversion of '95.7' from 'double' to 'int'

To intentionally narrow during brace initialization, use static_cast:

int main()
{
    double percentage { 87.5 };

    int rounded { static_cast<int>(percentage) }; // explicit narrowing is allowed

    return 0;
}

Constexpr Conversion Exceptions

When a narrowing conversion involves a constexpr source value, the compiler can evaluate whether the conversion preserves the value. If it does, the conversion isn't considered narrowing.

Consider this runtime example where the value isn't known until execution:

#include <iostream>

void displayUnsigned(unsigned int value)
{
    std::cout << value << '\n';
}

int main()
{
    std::cout << "Enter a number: ";
    int input{};
    std::cin >> input; // could be positive or negative
    displayUnsigned(input); // signed-to-unsigned conversion: may warn

    return 0;
}

Since input isn't known at compile-time, the compiler cannot verify whether the conversion from int to unsigned int preserves the value, so it may issue a warning.

However, constexpr values allow compile-time verification. The compiler can check if the value survives the conversion intact. If it does, the conversion isn't narrowing.

#include <iostream>

int main()
{
    constexpr int positive{ 42 };
    unsigned int u1 { positive }; // OK: 42 fits in unsigned int

    constexpr int negative{ -42 };
    unsigned int u2 { negative }; // error: -42 cannot be represented as unsigned

    return 0;
}

For positive and u1, since positive is constexpr with value 42, which converts exactly to unsigned value 42, this isn't considered narrowing.

For negative and u2, although negative is constexpr, its value -42 has no exact unsigned representation, making this a narrowing conversion that brace initialization rejects.

Oddly, floating-point to integral conversions lack a constexpr exception, so they're always considered narrowing:

int count { 10.0 }; // error: narrowing even though 10.0 is exactly representable

Even stranger, constexpr floating-point to narrower floating-point conversions aren't narrowing even with precision loss:

constexpr double precise { 0.123456789 };
float approximate { precise }; // not narrowing despite precision loss
Warning
Converting from constexpr floating-point to narrower floating-point types isn't considered narrowing even when precision is lost.

List Initialization With Constexpr Initializers

The constexpr exceptions are extremely useful for list initialization of non-int/non-double types, allowing us to use literal values without suffixes or static_cast.

This lets us:

  • Avoid literal suffixes in most situations
  • Keep initializations clean and readable
int main()
{
    // No suffix needed
    unsigned int players { 4 }; // OK (no need for 4u)
    float pi { 3.14159 }; // OK (no need for 3.14159f)

    // No static_cast needed
    constexpr int maxValue{ 100 };
    double ratio { maxValue }; // OK (no static_cast required)
    short limit { 50 }; // OK (no short suffix exists anyway)

    return 0;
}

This works with copy and direct initialization too.

One caveat: initializing narrower floating-point types with constexpr values succeeds if the value fits the range, regardless of precision loss!

Key Concept
Floating-point types are ranked (highest to lowest): long double, double, float.

This makes the following legal despite precision loss:

int main()
{
    float imprecise { 3.141592653589793 }; // legal despite truncation

    return 0;
}

Some compilers warn about this (GCC and Clang with -Wconversion).

Summary

Narrowing conversions: Potentially dangerous numeric conversions where the target type might not accommodate all possible values from the source type. Include float-to-int, wider-to-narrower floating-point, int-to-float (sometimes), and wider-to-narrower integral conversions.

List initialization protection: Brace initialization rejects narrowing conversions at compile time, preventing silent data loss. This is a major advantage over copy and direct initialization.

Constexpr exceptions: When the source value is constexpr and can be represented exactly in the target type, the conversion isn't considered narrowing. This allows clean initialization of non-int/non-double types without suffixes or casts.

Floating-point caveat: Constexpr floating-point to narrower floating-point conversions aren't considered narrowing even when precision is lost, as long as the value fits the range.

Best practices: Avoid narrowing conversions when possible. When unavoidable, use static_cast to make them explicit and document intent. Prefer list initialization to catch unintentional narrowing at compile time.

Understanding narrowing conversions and how list initialization handles them helps you write safer code that prevents subtle bugs from data loss during type conversions.