string_view
operator+
vs. StrCat()
absl::Status
std::bind
absl::optional
and std::unique_ptr
absl::StrFormat()
make_unique
and private
Constructors.bool
explicit
= delete
)switch
Statements Responsibly= delete
AbslHashValue
and Youcontains()
std::optional
parametersif
and switch
statements with initializersinline
Variablesstd::unique_ptr
Must Be MovedAbslStringify()
vector.at()
auto
for Variable DeclarationsOriginally posted as TotW #229 on February 5, 2024
By Miguel Young de la Sota and Matt Kulukundis
Updated 2024-02-20
Quicklink: abseil.io/tips/229
Warning: This is an advanced tip for folks doing template metaprogramming. In general, avoid template metaprogramming unless you have a very, very good reason. If you are reading this, it’s because you need to do some template metaprogramming or just want to learn something nifty.
Ordinarily, C++ requires every function invocation to resolve to a single “best” function or it produces an ambiguity error. The exact definition of “best” is more complex than we want to go into, but involves things like implicit conversions and type qualifiers.
In situations that would produce ambiguity errors, we can use explicit class
hierarchies to force the definition of “best” to be what we prefer. This “ranked
overloads” technique uses structures with a class hierarchy so they have a
priority ordering and the compiler will select the highest priority method
first. We’ll define a family of empty tag types Rank0
, Rank1
,
etc., that are related by inheritance, and use those to guide the overload
resolution process.1
// Public API with good comments. template <typename T> size_t Size(const T& t); // Everything below here is a working example of ranked overloads, that // you can copy and paste to get you started! namespace internal_size { // Use [Tip #229](/tips/229) for dispatching. struct Rank0 {}; struct Rank1 : Rank0 {}; struct Rank2 : Rank1 {}; struct Rank3 : Rank2 {}; template <typename T> size_t SizeImpl(Rank3, const std::optional<T>& x) { return x.has_value() ? Size(*x) : 0; } template <typename T> size_t SizeImpl(Rank3, const std::vector<T>& v) { size_t res = 0; for (const auto& e : v) res += Size(e); return res; } template <typename T> size_t SizeImpl(Rank3, const T& t) requires std::convertible_to<T, absl::string_view> { return absl::string_view{t}.size(); } template <typename T> size_t SizeImpl(Rank2, const T& x) requires requires { x.length(); } { return x.length(); } template <typename T> size_t SizeImpl(Rank1, const T& x) requires requires { x.size(); } { return x.size(); } template <typename T> size_t SizeImpl(Rank0, const T& x) { return 1; } } // namespace internal_size template <typename T> size_t Size(const T& t) { // Start with the highest rank, Rank3. return internal_size::SizeImpl(internal_size::Rank3{}, t); } auto i = Size("foo"); // hits the string_view overload auto j = Size(std::vector<int>{1, 2, 3}); // hits the vector overload auto k = Size(17); // hits the lowest rank "catch all"
Note that the absl::string_view
, std::optional
, and std::vector
overloads
use Rank3
. When overloads are mutually incompatible (the call can’t be
ambiguous by construction), the same rank type can be used. You can think of all
overloads with the same rank as being tried in parallel.
NOTE: The astute reader may wonder why the absl::string_view
overload is
declared as a template. Doing so ensures that no implicit conversions will take
place in the signature other than the one for the rank structs. If this overload
were declared with an absl::string_view
parameter then the call would be
ambiguous: Rank2{}
-> Rank0{}
would count as a conversion but const char[]
to absl::string_view
would also and the call would become
ambiguous again.
Let’s suppose we want Size(x)
to return x.length()
, x.size()
, or 1
depending on what the passed type x
implements. The naive approach does not
work:
template <typename T> size_t Size(const T& x) requires requires { x.length(); } { return x.length(); } template <typename T> size_t Size(const T& x) requires requires { x.size(); } { return x.size(); } template <typename T> size_t Size(const T& x) { return 1; } auto i = Size(std::string("foo")); // Ambiguous.
Because the size and length overloads are of equal rank, they are equally good
matches for the call. Because overload resolution does not eliminate all but one
candidate, the compiler declares the callsite ambiguous. There are clever tricks
using variadic functions or int
/long
promotion to create an ordering for two
options, but these do not scale to having N descending ranks of overloads.
Using ranked overloads as we’ve suggested attaches explicit ranks in the form of inheritance to specific overloads. This rank is based on the following rule: overloads with more derived classes have higher rank than overloads with less specific ones. That is, if two overloads differ by a single argument’s type, and both are bases of the argument, the type closest in the inheritance hierarchy has higher rank and is a better match.
This means we can build a tower of empty structs, each deriving from the
previous, to put an explicit, numeric rank on each overload. Using this trick,
we would write Size
like this:
// Public API with good comments. template <typename T> size_t Size(const T& t); namespace internal_size { // Use go/ranked-overloads for dispatching. struct Rank0 {}; struct Rank1 : Rank0 {}; struct Rank2 : Rank1 {}; template <typename T> size_t SizeImpl(Rank2, const T& x) requires requires { x.length(); } { return x.length(); } template <typename T> size_t SizeImpl(Rank1, const T& x) requires requires { x.size(); } { return x.size(); } template <typename T> size_t SizeImpl(Rank0, const T& x) { return 1; } } // namespace internal_size template <typename T> size_t Size(const T& t) { // Start with the highest rank return internal_size::SizeImpl(internal_size::Rank2{}, t); } auto i = Size(std::string("foo")); // 3
The overloads can now be read as an if
/else
chain. First we try the Rank2
overload; if substitution fails, we fall back to the next rank, Rank1
, and
then Rank0
. Of course, this particular method will treat
Size(std::string("foo"))
differently from Size("foo")
. This highlights some
of the dangers of generic programming, though the fix is relatively
straightforward: add an explicit rank to handle strings, as below.
// Public API with good comments. template <typename T> size_t Size(const T& t); namespace internal_size { // Use go/ranked-overloads for dispatching. struct Rank0 {}; struct Rank1 : Rank0 {}; struct Rank2 : Rank1 {}; struct Rank3 : Rank2 {}; template <typename T> size_t SizeImpl(Rank3, const T& t) requires std::convertible_to<T, absl::string_view> { return absl::string_view{t}.size(); } template <typename T> size_t SizeImpl(Rank2, const T& x) requires requires { x.length(); } { return x.length(); } template <typename T> size_t SizeImpl(Rank1, const T& x) requires requires { x.size(); } { return x.size(); } template <typename T> size_t SizeImpl(Rank0, const T& x) { return 1; } } // namespace internal_size template <typename T> size_t Size(const T& t) { // Start with the highest rank return internal_size::SizeImpl(internal_size::Rank3{}, t); } auto i = Size("foo"); // 3
Now extending this to vector
and optional
is quite straightforward!
// Public API with good comments. template <typename T> size_t Size(const T& t); namespace internal_size { // Use go/ranked-overloads for dispatching. struct Rank0 {}; struct Rank1 : Rank0 {}; struct Rank2 : Rank1 {}; struct Rank3 : Rank2 {}; template <typename T> size_t SizeImpl(Rank3, const std::optional<T>& x) { return x.has_value() ? Size(*x) : 0; } template <typename T> size_t SizeImpl(Rank3, const std::vector<T>& v) { size_t res = 0; for (const auto& e : v) res += Size(e); return res; } template <typename T> size_t SizeImpl(Rank3, const T& t) requires std::convertible_to<T, absl::string_view> { return absl::string_view{t}.size(); } template <typename T> size_t SizeImpl(Rank2, const T& x) requires requires { x.length(); } { return x.length(); } template <typename T> size_t SizeImpl(Rank1, const T& x) requires requires { x.size(); } { return x.size(); } template <typename T> size_t SizeImpl(Rank0, const T& x) { return 1; } } // namespace internal_size template <typename T> size_t Size(const T& t) { // Start with the highest rank return internal_size::SizeImpl(internal_size::Rank3{}, t); } auto i = Size("foo"); // hits the string_view overload auto j = Size(std::vector<int>{1, 2, 3}); // hits the vector overload auto k = Size(17); // hits the lowest rank "catch all"
Now that you have learned this awesome power, please remember to use it
sparingly. As we saw with the absl::string_view
overload, generic programming
is subtle and can lead to unexpected results.
Note that “rank” here is used in a colloquial sense and is unrelated to conversion ranks for integers and floating point values. ↩