The following concept from C++20 - The Complete Guide (adapted from /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?
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?
- 4 Does the book really not add any explanation for why this concept looks the way it does? – Barry Commented Jan 19 at 19:44
- 2 @Barry, no the book does not, it just says that the concept is "a short tricky requirement". Nor does P0870, it just says it uses a workaround to declare T as an array, but I fail to understand how referring to the second element of some workaround array relates to conversions between int and float. – Johan Commented Jan 19 at 19:53
1 Answer
Reset to default 26TL;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
:
- Checking for
ConvertibleWithoutNarrowing
fails because typedouble
cannot be narrowed toint
in initializer list; but - Checking for
ConvertibleWithNarrowing
passes, as narrowingdouble
toint
outside a list initialization is allowed - it just truncates it to an integer.