Tag dispatch versus concept overloading
In Bjarne Stroustrup’s paper P0557R1 “Concepts: The Future of Generic Programming” (January 2017), section 6 is titled “Concept Overloading,” which Stroustrup distinguishes from the traditional approach “using helper functions and tag dispatch.” More subtly, Stroustrup seems to distinguish between true “overloading” (asking the compiler to resolve among multiple viable overloads) and other means of giving-two-things-the-same-name:
Where we cannot overload, we need to use workarounds (e.g., traits,
enable_if
, or helper functions).
I’ll present two problems, each with two solutions (one in C++11 and one in C++20), which I think illustrate something interesting about “concept overloading” versus… other things.
1. Absolute value for signed and unsigned
Let’s write a function template that returns the absolute value
of its integral input. If the type of the input is signed, we
compute (x < 0) ? -x : x
. If it’s unsigned, we simply return x
.
Tag-dispatch solution
// A
template<class T>
T abs_impl(T x, std::false_type) {
return (x < 0) ? -x : x;
}
template<class T>
T abs_impl(T x, std::true_type) {
return x;
}
template<class T>
T abs_value(T x) {
return abs_impl(x, std::is_unsigned<T>{});
}
int main() {
assert(abs_value(-42) == 42);
assert(abs_value(42u) == 42u);
}
C++20 Concepts solution
// B
template<std::signed_integral T>
T abs_value(T x) {
return (x < 0) ? -x : x;
}
template<std::unsigned_integral T>
T abs_value(T x) {
return x;
}
int main() {
assert(abs_value(-42) == 42);
assert(abs_value(42u) == 42u);
}
Notice that no type can satisfy both
signed_integral
and
unsigned_integral
at the same time, so this Concepts-based solution is equivalent to the C++14 enable_if
approach denigrated by Stroustrup as a “workaround”:
// C
template<class T, std::enable_if_t<std::is_signed<T>::value, int> = 0>
T abs_value(T x) {
return (x < 0) ? -x : x;
}
template<class T, std::enable_if_t<std::is_unsigned<T>::value, int> = 0>
T abs_value(T x) {
return x;
}
int main() {
assert(abs_value(-42) == 42);
assert(abs_value(42u) == 42u);
}
In this kind of overload set, all the constraints are mutually exclusive, such that no more than one viable candidate is ever present. Overload resolution never has to choose the “best” overload from among a candidate set. As I understand it, this is not what we mean by “concept overloading.”
2. Advance for forward and random-access iterators
Let’s write a function template that returns its iterator argument
advanced by two positions.
For the general case of a forward iterator, we compute ++it
twice.
But if the input is additionally a random-access iterator, we can simply return it + 2
.
Tag-dispatch solution
// D
template<class It>
It plus2_impl(It it, std::forward_iterator_tag) {
++it; ++it;
return it;
}
template<class It>
It plus2_impl(It it, std::random_access_iterator_tag) {
return it + 2;
}
template<class It>
It plus2(It it) {
using Tag = typename std::iterator_traits<It>::iterator_category;
return plus2_impl(it, Tag{});
}
The term “tag dispatch” is used to refer both to snippet D
and to snippet A
above. But notice the difference (not reflected
in our terminology): In A
, our abs_impl
helpers were designed so
that only one of them would be viable. In D
, our plus2_impl
helpers
are designed so that the first overload is always viable, and then
the second overload is sometimes also viable; when it is, it’s the
better match (over.ics.rank/4.4.4).
C++20 Concepts solution (concept overloading)
// E
template<std::forward_iterator It>
It plus2(It it) {
++it; ++it;
return it;
}
template<std::random_access_iterator It>
It plus2(It it) {
return it + 2;
}
In this overload set, the constraints are not mutually exclusive.
Like D
, snippet E
relies on overload resolution to choose the
“best” overload from among a candidate set. Rather than rely on
the best-match rules regarding inheritance, E
relies on the fact
that the second overload’s constraint subsumes the first overload’s
constraint, and thus
(over.match.best.general/2.6)
the second overload is the best match whenever it is viable at all.
My understanding is that this is what Stroustrup refers to as “concept overloading.”
Final thoughts
UPDATE, 2021-06-08: I’ve changed my snippets
F
,G
, andH
to relax thesigned_integral
constraint intointegral
; originally, I’d relaxed theunsigned_integral
constraint. Reddit commenter “Quincunx271” rightly pointed out that that way was strictly worse. Our new version ofF
has one generic overload that works correctly for all integral types, plus a faster overload for unsigned types. (This is the same overall pattern asD
andE
.) My original post’sF
had a generic overload that worked correctly for unsigned types but incorrectly for signed types, and then an overload “rescuing” the correct behavior for signed types. That was unnecessarily silly of me.Reddit commenter “sphere991” observes that in real life we could use
if constexpr
. I absolutely agree; I preferif constexpr
over any multi-function approach. But this post wasn’t aboutif constexpr
; it was about “concept overloading.” :)
It seems to be the current wisdom that if you’re writing an
overload set with C++20 Concepts, you needn’t go out of your way
to make your constraints mutually exclusive. I claim that all of my
examples are reasonable C++20; but I do have the impression that some
commentators would go so far as to remove the constraint from one
or the other of the overloads in snippet B
, or relax it until
it was no longer mutually exclusive. Thus:
// F
template<std::integral T> // note: NOT signed_integral
T abs_value(T x) {
return (x < 0) ? -x : x;
}
template<std::unsigned_integral T>
T abs_value(T x) {
return x;
}
The tag-dispatch equivalent would be to replace false_type
with an ellipsis:
// G
template<class T>
T abs_impl(T x, ...) {
return (x < 0) ? -x : x;
}
template<class T>
T abs_impl(T x, std::true_type) {
return x;
}
template<class T>
T abs_value(T x) {
return abs_impl(x, std::is_unsigned<T>{});
}
or use a template of two parameters:
// H
template<class T, class U>
T abs_impl(T x, U) {
return (x < 0) ? -x : x;
}
Personally, I would vastly prefer to read snippet A
than
to read either G
or H
.
I have used
H
in situations dispatching on multiple bools at a time — where we want to do one thing ifb1
andb2
are both true, a different thing ifb1
is true andb2
is false, and a third thing ifb1
is false regardless ofb2
.)
For me personally, so far, my attitude carries over into C++20 Concepts:
I find snippet B
strictly more understandable at a glance than
snippet F
, because you can understand each candidate’s purpose
in isolation, without even knowing that the other candidate exists.
However, in cases where the overloads express a natural hierarchy
(by which I mean “a hierarchy that the educated reader ought to know
to be on the lookout for”), concept overloading could be the simplest
approach. For example, I find snippet E
reasonably understandable.
I would not be tempted to rewrite it merely to introduce
mutually exclusive constraints:
// J
template<std::forward_iterator It>
requires (!std::random_access_iterator<It>)
It plus2(It it) {
++it; ++it;
return it;
}
template<std::forward_iterator It>
requires std::random_access_iterator<It>
It plus2(It it) {
return it + 2;
}
Although honestly, if I saw that a coworker had already written snippet J
,
I would not be tempted to change it back to snippet E
!
Concept overloading allows you to increase the number of viable candidates
in your overload sets, without introducing artificial inheritance hierarchies
of tag types à la std::forward_iterator_tag
or
priority_tag
.
But ultimately it is just another tool in the C++ toolbox; you can be
allowed to use it, without feeling obliged to use it.