Do C++20 Concepts replace other forms of constraints?

464 Views Asked by At

C++20 has landed, bringing with it Concepts. If a project were to start now and target only C++20 and later standards, would it be appropriate to say previous forms of constraints are now superseded by Concepts and the requires clause?

Are there any cases where enable_if or void_t are still required, where Concepts cannot be made to replace them?

For example, std::bit_cast is constrained to require both To and From types to be of the same size and both types to be trivially copyable.

From cppreference:

This overload participates in overload resolution only if sizeof(To) == sizeof(From) and both To and From are TriviallyCopyable types.

The MSVC standard library constrains this through an enable_if_t expression, while libcxx opts for a requires clause.

MSVC:

enable_if_t<conjunction_v<bool_constant<sizeof(_To) == sizeof(_From)>, is_trivially_copyable<_To>, is_trivially_copyable<_From>>, int> = 0

libcxx:

requires(sizeof(_ToType) == sizeof(_FromType) &&
         is_trivially_copyable_v<_ToType> &&
         is_trivially_copyable_v<_FromType>)

They perform the same logical operations through different language features.

To reiterate my question, are there any cases where this translation from one constraint method to another is not possible? I understand there are a lot of things Concepts and requires can do that enable_if and/or void_t can't, but can the same be said looking in the other direction? Would a wholly new codebase targeting C++20 and later ever need to fall back on these older language constructions?

2

There are 2 best solutions below

0
sigma On

I'm not aware of such cases, and indeed you could directly copy the conjunction from the MSVC example into a requires clause, as you can do with any compile-time boolean-testable expression. Though ideally the requirements would be captured in an actual concept:

template<typename FromType, typename ToType>
concept bit_castable_to = requires (...);

which is easier to reuse and allows you to benefit from the more expressive syntax:

template<typename ToType, bit_castable_to<ToType> FromType>
// or
template<typename ToType>
ToType bit_cast(bit_castable_to<ToType> const auto& from);

This also improves the structure of the error output, which now directly mentions that your chosen types failed to satisfy the bit_castable_to concept, for example because is_trivially_copyable evaluated to false for your class Foo. However, a practical issue is that concepts can be composed of other concepts to arbitrary depths, so that the source of the failure can get buried under a long stream of messages.

Other than this simpler example, enable_if can be used to provide different implementations based on compile-time constraints, including different return types in the case of function overloads. This can also be done in a more readable fashion with some combination of concepts, if constexpr and auto return type deduction (which can also be constrained by a concept).

The main use of void_t I know of is described in this question, but this too can be expressed more elegantly using concepts, for example

template<typename Type>
concept has_member =
    requires (Type t) { t.member; };
// or
    requires { typename Type::member; };

You can also make this more specific:

template<typename Type>
concept has_int_member = 
    requires (Type t) { 
        { t.member } -> std::same_as<int&>;
    };
// or
    std::same_as<int, typename Type::member>;

I think this syntax is much easier on the eyes than the tangle of decltype and std::declval that was sometimes a necessary evil.

All in all, I see no reason to continue using enable_if or void_t contraption, unless required to support older compilers. I personally would be happy to never read or write one again. Concepts are just more expressive, and because it's very common for generic C++ code to have constraints, I think having an easy way of writing at least the syntactic ones as code is a great asset.

1
HolyBlackCat On

Yes, sadly the old SFINAE is still necessary in some cases. The problem with requires (and constraints introduced by concepts) is them being checked late.

For example, consider std::make_[un]signed, which causes a hard error (aka is SFINAE-unfriendly) if given a non-integral type. You can't really stop that from happening with a requires/concept, because they would be checked after make_[un]signed is instantiated.

The code below fails with a hard error because of this: run on gcc.godbolt.org

#include <concepts>
#include <string>
#include <type_traits>

template <std::signed_integral T>
std::make_unsigned_t<T> to_unsigned(T t)
{
    return t;
}

template <typename T>
concept CanMakeUnsigned = requires(const T &t){to_unsigned(t);};
static_assert(!CanMakeUnsigned<std::string>);

On the other hand, the ol' SFINAE is checked exactly in the order it appears in the source, so the version below does compile: run on gcc.godbolt.org

#include <concepts>
#include <cstddef>
#include <string>
#include <type_traits>

template <typename T, std::enable_if_t<std::signed_integral<T>, std::nullptr_t> = nullptr>
std::make_unsigned_t<T> to_unsigned(T t)
{
    return t;
}

template <typename T>
concept CanMakeUnsigned = requires(const T &t){to_unsigned(t);};
static_assert(!CanMakeUnsigned<std::string>);

If you wanted to fix the code using only the modern requires/concepts, you'd have to wrap std::make_unsigned in something like this:

template <std::signed_integral T>
using CheckedMakeUnsigned = std::make_unsigned_t<T>;

AFAIK there isn't a way to do it that can be done locally when defined the function, you need a separate typedef (or the old SFINAE).