I'm encountering an unexpected difference in behavior between traditional SFINAE (using type_traits
and std::void_t
) and modern C++20 concepts when defining a generic fallback operator<<
. The purpose is straightforward: to create a generic operator<<
that is enabled only if no existing custom-defined operator<<
is found via Argument-Dependent Lookup (ADL).
The old-school SFINAE-based detection using traits (is_std_streamable
) works as expected, defined as:
template <class T, class = void>
struct is_std_streamable : std::false_type {};
template <class T>
struct is_std_streamable<T, std::void_t<decltype(std::declval<std::ostream&>() << std::declval<const T&>())>> : std::true_type {};
And the concepts-based detection (StdStreamable
) is defined as:
template <class T>
concept StdStreamable = requires(const T t, std::ostream& os) {
{ os << t } -> std::same_as<std::ostream&>;
};
The generic fallback operator<<
looks like this (requires
clauses commented out):
template <StdPrintable T>
// requires(!StdStreamable<T>)
// requires(!is_std_streamable<T>::value)
std::enable_if_t<!is_std_streamable<T>::value, std::ostream&>
operator<<(std::ostream& os, T const& val) {
...
}
When uncommenting the concepts-based requires
clause (either requires(!StdStreamable<T>)
or requires(!is_std_streamable<T>::value)
), both GCC and Clang produce the following cyclic constraint error:
error: satisfaction of constraint 'StdStreamable<T>' depends on itself
I understand, that using the std::declval<std::ostream&>() << std::declval<const T&>()
expression in a requires
clause when defining a new version of operator<<
can be interpreted by the compiler as a cyclic dependency. But why do C++20 concepts trigger this cyclic constraint issue, whereas traditional SFINAE does not? Is this behavior mandated by the standard, a known limitation of concepts, or potentially a compiler bug?
Full minimal reproducible example and additional details:
Thanks in advance.
This is an ODR violation, your program is ill-formed, you are trying to do
if (not_defined(X)) define(X)
this is forbidden and the standard says
If two different points of instantiation give a template specialization different meanings according to the one-definition rule, the program is ill-formed, no diagnostic required.
StdStreamable<T>
or is_std_streamable<T>
would give different meaning depending on where it is instantiated in the program.
SFINAE wasn't required to diagnose this bug, because substitution failure is not an error, and the fact that it is a no diagnostics required bug, so they just accepted this bug.
Compilers were able to implement concepts in a way that detects this form of bug.
you can use a free function that picks from std::format
or object.print
or ostream.operator<<
depending on which of them is defined, but you cannot define one of them if it doesn't exist using template metaprogramming.