Do C++20 Concepts replace other forms of constraints?
Asked Answered
E

2

8

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?

Elysian answered 27/5, 2023 at 8:51 Comment(5)
It seems the Microsoft standard library implementation didn't use requires because they needed to support compilers that didn't implement C++20 concepts fully at the time. See github.com/microsoft/STL/pull/1749. I don't think there is any situation in which SFINAE with enable_if/void_t cannot be replaced by a requires clause on a fully conforming compiler.Thursday
"I understand there are a lot of things Concepts and requires can do that enable_if and/or void_t can't" - there supposed to be just one thing that Concepts and requires could do that older metaprogramming techniques could not, however it somehow got lost somewhere in standardization committee. So i think the better question would be "are there any cases where this translation from one constraint method to another make sense?". I'm not going to use concepts in project targeting C++20 at all.Justle
So there are two questions here? Is there a reason to rewrite old code that works well? Perhaps not. Is there a reason to use old convoluted methods is new code, when there now is a dedicated way to express requirements? Not really (unless one of your compilers still has bugs).Issacissachar
This is going to be highly opinionated and based on my own experience, still it might provide some insight: I've been doing some extensive digging on this topic recently, and for most general cases, enable_if/void_t can be largely equivalent to concepts in terms of regular usage. As @Issacissachar has written: migrating one way or another just to migrate seems unnecessary. However for some specific edge cases e.g. involving concept subsuming there might be discrepancies. But then, those discrepancies might probably affect even differently stated concepts that were designed to be equivalent.Ferrocene
Hopefully concept constrained templates should generate cleaner error diagnostics in future. This was 1 of the intentions behind the idea, but currently support for this feature is poor. With old enable_if hacks, there's no luck for good diagnostics. In new code, old methods just bring more irreadability. A demand for good diagnostics with concepts must become the focus of C++ community.Koopman
F
3

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).

Filefish answered 29/5, 2023 at 22:25 Comment(1)
I'm gonna go out on a limb and say it's either a CWG defect, or a Clang bug.Disapprobation
P
1

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.

Peria answered 29/5, 2023 at 22:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.