c++c++20c++-concepts

What does the To[1] mean in the concept is_convertible_without_narrowing?


The following concept from C++20 - The Complete Guide (adapted from http://wg21.link/p0870), forbids narrowing conversions. E.g., float to int, as in 1.9f1.

template <typename From, typename To>
concept ConvertibleWithoutNarrowing = requires (From&& from) {
  { std::type_identity_t<To[]>{std::forward<From>(from)}} -> std::same_as<To[1]>;
};

The book uses this for a collection C that must not have narrowing conversions when adding data:

template<typename C, typename T>
requires ConvertsWithoutNarrowing<T, typename C::value_type>
void add(C& collection, const T& val) {…}

// Usage:
std::vector<int> vec_i;
add(vec_i, 1); // OK
add(vec_i, 1.3); // Does not compile.

I get the general idea behind the concept, but what does the [1] in the last part, std::same_as<To[1]>;, do?


Solution

  • TL;DR the [1] explicitly specifies that the size of the array being used for comparison has to be, well, 1. And using an array saves a ton of work.


    Further explanation:

    1. But why use an array in the first place?

    C++ constrains on array type narrowing

    When creating an array, C++ requires that the type of elements used to initialize the array must not involve any narrowing conversions

    So if you try to compile

    int arr[1] = {1.3};
    

    You'll get an error along the lines of narrowing conversion from double to int. Initialization fails because you cannot directly assign a double to an int array without explicitly truncating or converting the value.

        int x = 10.5; // WARNING: Implicit conversion from 'double' to 'int' changes value from 10.5 to 10
        int y[1] = {10.5}; // ERROR: Type 'double' cannot be narrowed to 'int' in initializer list
    

    2. About that "short tricky requirement"

    The provided concept

    { std::type_identity_t<To[]>{std::forward<From>(from)}} -> std::same_as<To[1]>;
    

    Ensures that a value of type From can be converted to type To without losing information (i.e., no narrowing conversions are allowed). The trick lies in leveraging the stricter rules arround array initialization and narrowing conversions as to avoid the need to explicitly write checks for those cases (e.g., integral-to-floating-point, floating-point-to-integral).

    Without it you'd need a lot more involvement to achive a similar result: that concept would explode out to:

    template <typename From, typename To>
    concept ConvertibleWithoutNarrowing = requires(From&& from) {
            { static_cast<To>(std::forward<From>(from)) } -> std::convertible_to<To>;
        } &&
        []() constexpr {
            if constexpr (std::is_integral_v<From> && std::is_integral_v<To>) {
                return sizeof(From) <= sizeof(To);
            } else if constexpr (std::is_floating_point_v<From> && std::is_integral_v<To>) {
                return false;
            } else {
                return true;
            }
        }();
    

    And that only covers the case of aritmethic types! for a bonafide equivalent implementation with out relying on array initialization constraints you'd need to add each possible narrowing case again, by hand.


    3. Another way to think about concepts: they ask questions about types

    A simple way of getting arround to what using ConvertibleWithoutNarrowing does, is to think of it as "asking" the following question:

    To array[1] = {From}; // Is <- that valid C++?
    

    The answer is "yes" if From can be converted to To whitout narrowing.

    For comparison, a possible implementation of a concept that checks conversion that allows narrowing:

    template <typename From, typename To>
    concept ConvertibleWithNarrowing = requires (From&& from) {
        { static_cast<To>(std::forward<From>(from)) } -> std::same_as<To>;
    };
    

    You could think of this concept as "asking":

    To x = From; // Is <- that valid C++?
    

    Now consider those "questions" for the case that From = double and To = int:

    1. Checking for ConvertibleWithoutNarrowing fails because type double cannot be narrowed to int in initializer list; but
    2. Checking for ConvertibleWithNarrowing passes, as narrowing double to int outside a list initialization is allowed - it just truncates it to an integer.