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.9f
→ 1
.
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?
[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.array
in the first place?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
The provided concept
{ std::type_identity_t<To[]>{std::forward<From>(from)}} -> std::same_as<To[1]>;
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;
}
}();
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
:
ConvertibleWithoutNarrowing
fails because type double
cannot be narrowed to int
in initializer list; butConvertibleWithNarrowing
passes, as narrowing
double
to int
outside a list initialization is allowed -
it just truncates it to an integer.