Concept definition-checking and its woes
C++2a concepts (formerly known as “Concepts Lite” and/or the Concepts TS) famously do not support “definition checking.” The idea of definition checking is that the programmer might write
template<class T,
class ValueType = typename iterator_traits<T>::value_type>
concept InputIterator =
Copyable<T> &&
requires(T t) {
{ ++t } -> Same<T&>;
{ *t } -> ConvertibleTo<ValueType>;
{ t == t } -> ConvertibleTo<bool>;
};
template<class T>
concept RandomAccessIterator =
InputIterator<T> &&
requires(T t, int n) {
{ t + n } -> ConvertibleTo<T>;
};
and then an algorithm such as
template<class It, class T> requires InputIterator<It>
It find_n(It first, int n, const T& value)
{
It last = first + n;
for (It it = first; it != last; ++it) {
if (*it == value) {
return it;
}
}
return last;
}
Now, in C++2a–concepts-land, where templates are still effectively duck-typed, we can invoke this template as follows, and it’s all totally fine with the compiler. (Godbolt.)
int main() {
int a[10] = {3,1,4,1,5,9,2,6,5,3};
int *p = find_n(a, 10, 6);
assert(p == a+7);
}
By contrast, in a world with “definition checking,” the compiler would have complained
as soon as we defined function template find_n
, even before we instantiated it at all!
error: expression involving constrained parameters,
'<InputIterator> + <int>', is not part of the concept
requirements of 'InputIterator'
It last = first + n;
^~~~~~~~~
The compiler is telling us that we made a mistake here: we advertised that find_n
can accept any InputIterator
, but in fact it uses operations such as +
that are
not supported by some InputIterator
s (such as istream_iterator<int>
).
Maybe we meant that find_n
should accept only RandomAccessIterator
s;
or maybe we should refactor find_n
so that it can work with arbitrary InputIterator
s.
Either way, definition-checking has alerted us to the bug.
This is why definition-checking sounds like a cool and useful thing. Now let’s see why we can’t have it in C++.
An oft-quoted objection is no problem at all
I frequently see people opining that what makes definition-checking hard is that it doesn’t allow for temporary ad-hoc code. The canonical example is “printf debugging.” Consider:
template<class It, class T> requires InputIterator<It>
It find_n(It it, int n, const T& value)
{
for (int i = 0; i != n; void(++i), ++it) {
#ifdef DEBUG_ME
save_to_log(it);
#endif
if (*it == value) {
return it;
}
}
return last;
}
People say, “A hypothetical definition-checking compiler should be happy with this code…
but, if you pass -DDEBUG_ME
, it’ll complain.”
error: expression involving constrained parameters,
'save_to_log(<InputIterator>)', is not part of the
concept requirements of 'InputIterator'
save_to_log(it);
^~~~~~~~~~~~~~~
However, this is a bad example, because it is trivially easy to tell the compiler that
save_to_log(it)
is well-formed in this context. C++17 gives us if constexpr
to express
local constraints.
template<class T>
concept Loggable = requires(T t) {
{ save_to_log(t) };
};
template<class It, class T> requires InputIterator<It>
It find_n(It it, int n, const T& value)
{
for (int i = 0; i != n; void(++i), ++it) {
#ifdef DEBUG_ME
if constexpr (Loggable<T>) {
save_to_log(it);
} else {
save_to_log(__PRETTY_FUNCTION__, " with non-loggable iterator type");
}
#endif
if (*it == value) {
return it;
}
}
return last;
}
A hypothetical definition-checking compiler could certainly track the constraints involving
T
that apply in any particular lexical scope, including those implicitly applied by
if constexpr
and static_assert
expressions that just happened to be of the form
constraint-expression.
So the oft-quoted “printf debugging” hurdle is not a hurdle at all.
A less-quoted objection remains valid
Consider again the requirement on our concept InputIterator
:
{ *t } -> ConvertibleTo<ValueType>;
And look at our template find_n
:
template<class It, class T> requires InputIterator<It>
It find_n(It it, int n, const T& value)
{
for (int i = 0; i != n; void(++i), ++it) {
if (*it == value) {
return it;
}
}
return last;
}
Shouldn’t our hypothetical definition-checking compiler still complain about this line?
error: expression involving constrained parameters,
'<ConvertibleTo<int>> == <int>', is not part of the
concept requirements of 'ConvertibleTo'
if (*it == value) {
^~~~~~~~~~~~
Our concept definition for InputIterator
says that *it
must be convertible to
iterator_traits<It>::value_type
, but we never said anywhere that iterator_traits<It>::value_type
must be equality-comparable — and certainly never said that it must be equality-comparable
to the unconverted type of *it
, whatever that is. This is a problem for pathological types,
but also in practice for proxy iterators such as vector<bool>::iterator
.
(See “C++ idiom of the day: arrow_proxy
” (February 2019).)
Compilers don’t possess common sense
How about this line?
error: expression involving constrained parameters,
'<void> , <InputIterator>', is not part of the concept
requirements of 'InputIterator'
for (int i = 0; i != n; void(++i), ++it) {
^~~~~~~~~~~~~~~
We humans know that void() , x
is a well-formed expression for any x
; but should the compiler
be expected to know that? How much “common sense” can the programmer expect out of their
hypothetical definition-checking compiler?
Finally, consider
template<class T>
concept Addable = requires(T t) {
{ t + 1 };
};
template<class T> requires Addable<T>
void test(T x) {
x + 2; // OK??
}
Should the compiler be expected to know that if { x + 1 }
is well-formed,
then x + 2
is automatically also well-formed?
(And if so, watch out: x + 0
can still be ill-formed!)
These problems, and problems like them, are the reason definition checking hasn’t yet — and probably never will — come to C++.