Imagine a power to convert everything to a string: integers, vectors, booleans - all transformed into a message to output wherever we want. Seems handy and not too hard, so let’s dive into it – this was my first thought when writing a debug variable watcher for my pet project.
Although the expectation of making something easy may result in a few sleep-deprived nights, I encourage the reader to embark on this journey through basic template programming concepts.
Expected prerequisites are:
- A compiler with decent support for C++20: MSVC 2022, clang 14, or gcc 11 would be good, as we aim for a future-oriented experience, not dwelling on legacy code.
- A basic familiarity with C++ template syntax: we’ve all written something like
template <typename T> T min(T a, T b);
, haven’t we?1
Throughout the journey, we’ll progress from a trivial attempt to a satisfying solution, highlighting pitfalls and techniques along the way. So don’t be alarmed if the code doesn’t look great in the early iterations – it’s all part of the plan.
There would be a References section with all the links from an article. I’d treat it as a recommended reading.
The problem
Having a variable of type T, make a function makeString(T t)
that will produce a string representation of t
. As we could not know in advance how to convert an object of an arbitrary type into a string, consider making it easily extensible for any user-defined class.
Regarding extensibility, I’d like makeString
to convert an object to a string by using the object’s std::string to_string() const
member function if it’s present. It’s not always possible, so fall back to overloading or specialization if needed.
Something to start with
Setup a project
We need an empty project to start with. It’s at least 2023 outside, so I use CMake. It’s okay to create a console application project using your favorite IDE instead, but in case you ever plan to write a cross-platform code during the long and happy C++ career path, CMake is definitely worth trying.
cmake_minimum_required(VERSION 3.12)
project(makeString VERSION 0.1.0)
add_executable(makeString main.cpp)
set(CMAKE_CXX_EXTENSIONS OFF) # no vendor-specific extensions
set_property(TARGET makeString PROPERTY CXX_STANDARD 20)
# Ask a compiler to be more demanding
if (MSVC)
target_compile_options(makeString PUBLIC /W4 /WX /Za /permissive-)
else()
target_compile_options(makeString PUBLIC -Wall -Wextra -pedantic -Werror)
endif()
A bit of test code to understand the goal
Let’s add a couple of use cases: assume we have structs A and B. A is a tag with no data, while B contains an integer. The only reason to use structs instead of classes here is to save some screen space on the public:
visibility specifiers.
#include <iostream>
#include "makeString.hpp"
struct A
{
std::string to_string() const { return "A"; }
};
struct B
{
int m_i = 0;
std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
};
int main()
{
A a;
B b = {1};
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< std::endl;
}
A trivial template in makeString.hpp
And finally, a trivial template.
#pragma once
#include <string>
template <typename Object>
std::string makeString(const Object& object)
{
return object.to_string();
}
a: A; b: B{1}
.This code has some problems, but let’s pretend we don’t see any until they arise during practical usage in the following sections of the article.
Function template specialization
But what if we’d like to convert an int
to a string?
int main()
{
A a;
B b = {1};
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< "; pi: " << makeString(3)
<< std::endl;
}
to_string()
method for integers. Fortunately, we could provide a template specialization specifying that makeString<int>
should have a special implementation. Let’s try:// rather an attempt, than a solution
template <> std::string makeString(int i)
{
return std::to_string(i);
}
Correct? Nope. The compiler does not find a matching template because the only part of the template signature allowed to specialize is the Object
parameter:
template <typename Object> std::string makeString(const Object& object)
It’s possible to substitute an Object
with int
, but we can’t drop the const and reference qualifiers. Thus, the correct approach could be:
// template specialization: Object = int
template <> std::string makeString(const int& i)
{
return std::to_string(i);
}
Well, although the function above works, it violates the “pass cheaply-copied types by value” guideline. Passing by-value is not only cheaper but also easier to optimize. Check out Arthur O’Dwyer’s blog for additional insight on the example of string_view
.
Template function overloading
Both template and non-template functions participate in overloading. Function template reference has a lot of insight, Overload resolution of function template calls article has a couple of quick examples, but a summary is enough for now: both template and non-template functions are considered during an overload resolution to find the best match possible.
// not a template. This will be the best match for makeString(3),
// unless we explicitly specify that we want a template: makeString<int>(3)
std::string makeString(int i)
{
return std::to_string(i);
}
Now we can drop the const int&
specialization, compile, run, and enjoy overloaded makeString(int)
and the approximate of pi: a: A; b: B{1}; pi: 3
For those who need precision, we could add more overloads:
// main.cpp
// ...
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< "; pi: " << makeString(3) << "; pi(double): " << makeString(3.1415926)
<< std::endl;
// makeString.hpp
//...
std::string makeString(float f) { return std::to_string(f); }
std::string makeString(double d) { return std::to_string(d); }
std::string makeString(long double d) { return std::to_string(d); }
// ... 5 more specializations ...
Selecting a single template from multiple implementations
The problem
In the middle of writing the copy-paste above, a reader might come across the idea of creating a single template instead. However, a problem arises when the compiler is unsure which template to instantiate for a call like makeString(3)
, leading to a compilation failure.
template <typename Object>
std::string makeString(const Object& object)
{
return object.to_string();
}
template <typename Numeric>
std::string makeString(Numeric value)
{
return std::to_string(value);
}
/*
main.cpp:21:40: error: call of overloaded ‘makeString(int)’ is ambiguous
| << "; pi: " << makeString(3)
| ~~~~~~~~~~^~~
note: candidate: std::string makeString(const Object&) [with Object = int;]
note: candidate: std::string makeString(Numeric) [with Numeric = int;]
*/
During the overload resolution, the compiler examines only the function declarations, requiring the developer to provide a means of distinguishing overloads using function declarations only.
As a general rule, I’d consider restricting all template functions to prevent the compiler from instantiating them for incompatible types. Following this rule prevents extensibility issues when adding new template function overloads.
Spoiler: we could have used the code above almost as it is if we had read the Concepts section in advance
// concepts HasToString, IsNumeric, and IsString should be defined above
template <HasToString Object>
std::string makeString(Object&& object)
{
return std::forward<Object>(object).to_string();
}
std::string makeString(IsNumeric auto value)
{
return std::to_string(value);
}
template <IsString String>
std::string makeString(String&& s)
{
return std::string(std::forward<String>(s));
}
For a deeper grasp of templates, we’ll proceed with SFINAE: a more verbose solution that was prevalent before the concepts appeared in C++20. However, readers who are eager to move ahead may skip this section and proceed directly to the perfect forwarding chapter and then to concepts.
Substitution Failure Is Not An Error: a harder, pre-C++20 approach
During overload resolution, the compiler will ignore the declaration if impossible to substitute the template parameter into it. That concept is known as Substitution Failure Is Not An Error.
Using wit and a little imagination, come up with a solution:
- Incorporate an
object.to_string()
member function call into the declaration ofstd::string makeString(const Object& object)
to prevent substitution of[Object = int]
. - Ensure that the declaration of
std::string makeString(Numeric value)
involvesstd::to_string(value)
to prevent substitution in case of no suchstd::to_string
overload before the compiler fails to compile the function body.
There are a few possible solutions:
1. Depend on the result of the to_string()
member function call in the type parameter.
// will match only types with to_string() member function
template <typename Object,
typename DummyType = decltype(std::declval<Object>().to_string())>
std::string makeString(const Object& object)
{
return object.to_string();
}
The code above uses a DummyType
that is the same as the type of Object::to_string()
member function. The substitution will fail if there is no Object::to_string()
thus excluding the template from the overloading resolution.
2. Trailing return type: access the function parameter name during the substitution
One could wonder, why don’t we use parameter variable names to enforce SFINAE? That’s because syntactically we’re enforcing it before the function parameters definition. Using trailing return type we could defer the SFINAE to the point where the function parameters are declared.
That’s my favorite way to use SFINAE using C++ 17, prior to the concepts introduction.
Let’s take a look:
// makeString.hpp
#pragma once
#include <string>
template <typename Object>
auto makeString(const Object& object) -> decltype(object.to_string())
{
return object.to_string();
}
template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
return std::to_string(value);
}
Here, an auto
return type forces the compiler to look at the trailing return type, so it will detect the type of expression on the right of ->
, for example, decltype(object.to_string())
. If the substitution fails, the function is omitted from the overloads candidate list. That’s simple.
There is a std::enable_if
template that could solve the same problem, but in my opinion, it’s more verbose. Therefore, I’d reserve its usage until it’s truly necessary.
Once again, compile, run, and enjoy: a: A; b: B{1}; pi: 3; pi(double): 3.141593
. We’ve spent some time on it, it seems working and I think, it’s a good start.
Collections support in makeString()
Let’s level up makeString() with support for generic containers and amp up the coding fun. First, alter out test code with some cases:
// main.cpp
#include <vector>
#include <set>
// ...
const std::vector<int> xs = {1, 2, 3};
const std::set<float> ys = {4, 5, 6};
const double zs[] = {7, 8, 9};
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< "; pi: " << makeString(3.1415926) << std::endl
<< "xs: " << makeString(xs) << "; ys: " << makeString(ys)
<< "; zs: " << makeString(zs)
<< std::endl;
std::begin
on something iterable. Thus we need a template that will accept something compatible with std::begin
and iterate on it.Now, start typing:
// makeString.hpp
// ...
template <typename Iterable>
auto makeString(const Iterable& iterable)
-> decltype(makeString(*std::begin(iterable))) // (1)
{
std::string result;
for (const auto& i : iterable)
{
if (!result.empty())
result += ';';
result += makeString(i);
}
return result;
}
std::begin
, and return the same type as the makeString
for the first element of that collection.Compile, run, so far so good:
a: A; b: B{1}; pi: 3.141593
xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
However, that code has a pitfall we’re about to discover in the next section.
String parameter support in makeString()
Just in case, why don’t? It might streamline the makeString() usage in other templates. Let’s start by adding some test cases:
// main.cpp
// ...
std::cout << makeString("Hello, ")
<< makeString(std::string_view("world"))
<< makeString(std::string("!!1"))
<< std::endl;
a: A; b: B{1}; pi: 3.141593
xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
72;101;108;108;111;44;32;0119;111;114;108;10033;33;49
Well, strings are iterable containers of chars. We also learned from the Integer Promotions chapter on the cppreference that the compiler promotes chars to integers when char
overloading is absent. To address the issue, exclude the template for containers from matching string types.
Also, it could be reasonable to mark the makeString(char)
as the deleted function to secure the pitfall forever:
std::string makeString(char c) = delete;
[build] makeString.hpp:62:6: note: candidate template ignored: substitution failure
[with Iterable = const char (&)[8]]: call to deleted function 'makeString'
[build] auto makeString(Iterable&& iterable) -> decltype(makeString(*std::begin(iterable)))
[build] ^ ~~~~~~~~~~
That’s a good time to remember about the type traits and std::enable_if
.
Type traits and enable_if: a way to specialize template on a trait or a condition
A solution is to restrict the container’s makeString
so it will fail substitution on strings, and write a new one. Consider that “string” is std::string
, std::string_view
, char*
, const char*
. Let’s express this using C++:
namespace traits
{
// generic type is not a string...
template <typename AnyType> inline constexpr bool isString = false;
// ... but these types are strings
template <> inline constexpr bool isString<std::string> = true;
template <> inline constexpr bool isString<std::string_view> = true;
template <> inline constexpr bool isString<char*> = true;
template <> inline constexpr bool isString<const char*> = true;
}
const string
, const string_view
, const char* const
? And there is a volatile
keyword as well. Fortunately, we can avoid a lot of copy-paste by using a kind of types mapping: T
-> T
, const T
-> T
. For example, we could have written something like the code below:namespace traits
{
namespace impl // a "private" implementation
{
// generic type is not a string...
template <typename AnyType>
inline constexpr bool isString = false;
// ... but these types are strings
template <> inline constexpr bool isString<std::string> = true;
template <> inline constexpr bool isString<std::string_view> = true;
template <> inline constexpr bool isString<char*> = true;
template <> inline constexpr bool isString<const char*> = true;
}
namespace Wheel
{
template <typename T> struct remove_cv { using type = T; };
template <typename T> struct remove_cv<const T> { using type = T; };
template <typename T> struct remove_cv<volatile T> { using type = T; };
template <typename T> struct remove_cv<const volatile T> { using type = T; };
// shortcut to remove_cv<T>::type, saves a bit of stamina on fingers
template <typename T> using remove_cv_t = typename remove_cv<T>::type;
// enable_if only defined as a specialization enable_if<true, T>
// thus failing substitution on any code that uses enable_if<false, T>;
template <bool Condition, typename T> struct enable_if;
template <typename T> struct enable_if<true, T> { using type = T; };
// another shortcut
template <bool B, class T> using enable_if_t = typename enable_if<B,T>::type;
}
// traits::isString<T> = traits::impl::isString<T>, but with remove_cv_t on T:
template <typename T>
inline constexpr bool isString = impl::isString<
Wheel::remove_cv_t<T>
>;
}
std::remove_cv_t
and std::enable_if_t
instead of reinventing the wheel:// makeString.hpp
#pragma once
#include <string>
#include <type_traits>
namespace traits
{
namespace impl // a "private" implementation
{
// generic type is not a string...
template <typename AnyType>
inline constexpr bool isString = false;
// ... but these types are strings
template <> inline constexpr bool isString<std::string> = true;
template <> inline constexpr bool isString<std::string_view> = true;
template <> inline constexpr bool isString<char *> = true;
template <> inline constexpr bool isString<const char *> = true;
}
// isString<T> = impl::isString<T>, but with dropped const/volatile on T:
template <typename T>
inline constexpr bool isString = impl::isString<std::remove_cv_t<T>>;
}
template <typename Object>
auto makeString(const Object& object) -> decltype(object.to_string())
{
return object.to_string();
}
template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
return std::to_string(value);
}
// will fail substitution if `traits::isString<Iterable>`
// or when can't evaluate the type of makeString(*begin(container))
template <typename Iterable>
auto makeString(const Iterable& iterable)
-> std::enable_if_t< !traits::isString<Iterable>,
decltype(makeString(*std::begin(iterable))) >
{
std::string result;
for (const auto& i : iterable)
{
if (!result.empty())
result += ';';
result += makeString(i);
}
return result;
}
// will fail substituition if `!traits::isString<String>`
template <typename String>
auto makeString(const String& s)
-> std::enable_if_t< traits::isString<String>,
std::string >
{
return std::string(s);
}
a: A; b: B{1}; pi: 3.141593
xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
72;101;108;108;111;44;32;0world!!1
Getting an insight into what the template expands to
According to the output, "Hello, "
were treated as a container. It could be easier to understand what’s happening using some tricks. There are at least a couple of ways: upload a minimal compilable example to a compiler analyzing tool or produce an intentional failure with a descriptive error message.
C++ Insights
Once the code compiles successfully, we could upload it to the cppinsights.io. There’s the link on a slightly stripped code.
And here we go, a part of the produced output:
namespace traits
{
namespace impl
{
// ...
template<>
inline constexpr const bool isString<char *> = true;
template<>
inline constexpr const bool isString<const char *> = true;
template<>
inline constexpr const bool isString<char[8]> = false;
template<>
inline constexpr const bool isString<char> = false;
}
// ...
}
// ...
/* First instantiated from: insights.cpp:59 */
#ifdef INSIGHTS_USE_TEMPLATE
template<>
std::basic_string<char> makeString<char[8]>(const char (&iterable)[8])
{
// ...
}
#endif
makeString("Hello, ")
is actually a makeString<char[8]>(/*reference-to-char[8]*/)
and we have no such overload for impl::isString
, so generic one takes place: impl::isString<char[8]> = false;
.Intentional compilation failure
It could happen, however, that code should not be submitted elsewhere or it does not compile. In this case, an intentional compilation failure could make insight into the deduced types and values. A deleted function will provide a good error context when called, while an undefined struct will provide an insight when instantiated:
// ...
template <typename... Args> bool fail_function(Args&&... args) = delete;
template <bool Value> struct fail_struct; // never defined
// ...
static bool test1 = fail_function("Hello, "); // (1)
static fail_struct< traits::isString<decltype("Hello, ")> > test2 = {}; // (2)
And here is the compilation output:
error: use of deleted function ‘bool fail_function(Args&& ...)
[with Args = {const char (&)[8]}]’
40 | static bool test1 = fail_function("Hello, "); // (1)
| ~~~~~~~~~~~~~^~~~~~~~~~~
error: variable ‘fail_struct<false> test2’ has initializer but incomplete type
41 | static fail_struct< traits::isString<decltype("Hello, ")> > test2 = {}; // (2)
| ^~~~~
- in
fail_function("Hello, ")
, the argument type is a reference to aconst char[8]
. Despite it’s eligible for implicit conversion to theconst char*
it is different from theconst char*
. - in the
fail_struct< traits::isString<...> >
variable, argument isfalse
, so the result oftraits::isString<...>
is false.
Finally, a makeString that accepts a string and works as expected
Just in case someone wants a solution to the isString<char[N]>
trait
It's that simple:namespace impl
{
// ...
template <size_t N>
inline constexpr bool isString<char[N]> = true;
// ...
}
In previous chapters, we’ve invented some wheels and understood the idea behind type traits. Now it’s time to re-think it. Given the standard library, we could handle strings better. For example, let’s consider a type to be a string if a std::string
could be explicitly constructed from it. That’s it.
namespace traits
{
template <typename T>
inline constexpr bool isString = std::is_constructible_v<std::string, T>;
}
Bring the pieces together, compile, and run.
// main.cpp
#include <iostream>
#include <vector>
#include <set>
#include "makeString.hpp"
struct A
{
std::string to_string() const { return "A"; }
};
struct B
{
int m_i = 0;
std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
};
int main()
{
A a;
B b = {1};
const std::vector<int> xs = {1, 2, 3};
const std::set<float> ys = {4, 5, 6};
const double zs[] = {7, 8, 9};
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< "; pi: " << makeString(3.1415926) << std::endl
<< "xs: " << makeString(xs) << "; ys: " << makeString(ys)
<< "; zs: " << makeString(zs)
<< std::endl;
std::cout << makeString("Hello, ")
<< makeString(std::string_view("world"))
<< makeString(std::string("!!1"))
<< std::endl;
}
// makeString.hpp
#pragma once
#include <string>
#include <type_traits>
namespace traits
{
template <typename T>
inline constexpr bool isString = std::is_constructible_v<std::string, T>;
}
template <typename Object>
auto makeString(const Object& object) -> decltype(object.to_string())
{
return object.to_string();
}
template <typename Numeric>
auto makeString(Numeric value) -> decltype(std::to_string(value))
{
return std::to_string(value);
}
// will fail substitution if `traits::isString<Iterable>`
// or when can't evaluate the type of makeString(*begin(container))
template <typename Iterable>
auto makeString(const Iterable& iterable)
-> std::enable_if_t< !traits::isString<Iterable>,
decltype(makeString(*std::begin(iterable))) >
{
std::string result;
for (const auto& i : iterable)
{
if (!result.empty())
result += ';';
result += makeString(i);
}
return result;
}
// will fail substituition if `!traits::isString<String>`
template <typename String>
auto makeString(const String& s)
-> std::enable_if_t< traits::isString<String>,
std::string >
{
return std::string(s);
}
a: A; b: B{1}; pi: 3.141593
xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
Hello, world!!1
Now criticize: it’s still a good start! At least, the problem is that the std::string
is also constructed from the parameter, even if there is a temporary that could be perfectly forwarded to a constructor. That brings us to the next chapter.
Perfect forwarding: avoid unnecessary copying and allow the use of non-copyable types
In the code below, a temporary is made by getSomeString()
and bound to an lvalue-reference parameter. The referenced string is then copied to a return value. Thanks to a copy elision, no further copies were made, but in the upshot, one unnecessary copy occurs.
template <typename String>
auto makeString(const String& s)
-> std::enable_if_t< traits::isString<String>,
std::string >
{
return std::string(s);
}
// ...
std::string getSomeString();
std::cout << makeString(getSomeString());
In addition to suboptimal performance, the previous approach does not allow non-copyable types, such as std::unique_ptr
. However, by leveraging rvalue or forwarding references, we can omit unnecessary copy and avoid the requirement of copyable type. The std::string
constructor can then efficiently steal its content without unnecessary copying.
While the only option for a non-template parameter is an rvalue overload, template parameters can utilize universal (or forwarding) references. In essence, a forwarding reference accepts the same reference type (lvalue, rvalue, const, or non-const) as passed by the caller. A forwarding reference syntax is TemplateType&& parameter
.
I strongly encourage looking at the great article above for additional details.
Just in case, a quick summary and we’re going to fix our templates.
Parameter Type | Template | Non-Template |
---|---|---|
const & | const lvalue reference | const lvalue reference |
& | lvalue reference | lvalue reference |
&& | the same as has been passed by the caller, may be: (const-) lvalue or rvalue | rvalue reference |
This way, for every pass-by-reference in makeString.hpp we’ll pass the parameter by a universal reference:
// ...
template <typename Object>
auto makeString(Object&& object) -> decltype(object.to_string())
//...
template <typename Iterable>
auto makeString(Iterable&& iterable)
// ...
template <typename String>
auto makeString(String&& s)
// ...
std::string
to steal the temporary’s content.Thoroughful developer may consider all the possible cases:
- An lvalue or (const-)lvalue reference passed to makeString(): pass further as an appropriate lvalue reference because we should not invalidate an lvalue by moving from it;
- A temporary or another type of rvalue passed to makeString(): pass it via rvalue-reference to allow moving from it.
It might sound complicated, but in essence std::forward
casts a function parameter to whatever it was at the function invocation. So, a fast developer uses std::forward
for universal reference whenever a possible move is intended.
A forwarding pitfall: a temporary may be ‘consumed’ and invalidated by the callee
However, a cautious developer would warn us that a move may occur wherever std::forward
is used. So never use a variable after forwarding:
#include <string>
#include <cassert>
#include <vector>
template <typename Arg>
void foo(Arg&& a)
{
std::vector<std::string> v;
for(int i = 0; i < 2; i++)
v.push_back(std::forward<Arg>(a)); // surprise on the 2nd iteration
assert(v.front() == v.back()); // fail
}
int main()
{
auto getMessage = []() -> std::string { return "why does it fail?"; };
foo(getMessage());
}
- There is a callback that receives a parameter using perfect forwarding:
logCallback(std::forward<Event>(logEvent));
. Looks good and conventional. - Later, a developer forgot to remove
std::forward
while implementing multiple callbacks support:for (auto& sink : logSinks) sink.logCallback(std::forward<Event>(event));
. This oversight creates a ticking time bomb. - The issue goes unnoticed during basic testing with only a single callback or an lvalue reference.
- Undefined behavior occurs in rare cases where multiple callbacks involve rvalue reference, leading to unexpected behavior.
Thus a developer should have a strong intuition to remove std::forward
once a possibility of multiple recipients of the forwarded value occurs.
Temporary containers
A tricky case is handling an rvalue container as a parameter. Although the container may be temporary, its elements are constructed in a usual way (for example, it’s possible to get an address of the element) so they are treated like lvalues. Thus, a forcible std::move
should be used to steal elements’ content from a temporary container:
// will fail substitution if `traits::isString<Iterable>`
// or when can't evaluate the type of makeString(*begin(container))
template <typename Iterable>
auto makeString(Iterable&& iterable)
-> std::enable_if_t< !traits::isString<Iterable>,
decltype(makeString(*std::begin(iterable)))
>
{
std::string result;
for (auto&& i : iterable)
{
if (!result.empty())
result += ';';
// a constexpr if, so the compiler can omit unused branch and
// allow non-copyable types usage
if constexpr (std::is_rvalue_reference_v<decltype(iterable)>)
result += makeString(std::move(i));
else
result += makeString(i);
}
return result;
}
‘if constexpr’ in this context enables selecting a strategy in the compile-time based on the container’s value category. Unlike the regular if
statement, only one branch of constexpr if/else
is compiled at template instantiation, allowing for the use of a type that is either movable or copyable.
The MS STL implementation of std::vector::resize utilizes this approach to choose between copy and move strategies. When resizing, it moves existing items to a new buffer if the value type has a ’noexcept’ move constructor, and copies them otherwise2.
Also, there are alternative approaches. For instance, introducing a separate template to encapsulate the copy-or-move decision into two overloads is demonstrated in the std::move_if_noexcept
example on cppreference.
Bringing all the pieces together, let’s look at the perfect forwarding makeString below:
|
|
|
|
to_string()
on it. And what’s the strange syntax on NonCopyable::to_string
.template <typename Object>
auto makeString(Object&& object)
-> decltype(std::forward<Object>(object).to_string())
{
return std::forward<Object>(object).to_string(); // (see a note below)
}
// ...
struct NonCopyable
{
// ...
std::string to_string() const & { return m_s; }
std::string to_string() && { return std::move(m_s); }
};
The std::forward
here forwards the object
to its object.to_string()
member function, and corersponding NonCopyable::to_string
methods feature the overloading on *this
value category. This way we can allow an rvalue-overload of NonCopyable::to_string
to steal the content of temporary avoiding unnecessary copying.
Overloading member functions on reference qualifiers
A ref-quilified member function allows compiler to choose a specific overloading based on refevence type of *this
. A simple example from the corresponding chapter of the cppreference:
#include <iostream>
struct S
{
void f() & { std::cout << "lvalue\n"; }
void f() && { std::cout << "rvalue\n"; }
};
int main()
{
S s;
s.f(); // prints "lvalue"
std::move(s).f(); // prints "rvalue"
S().f(); // prints "rvalue"
}
NonCopyable::to_string()
, in case of calling it on a temporary, it’s safe to assume that the temporary will be obsolete after the call, so we can steal its content by moving the NonCopyable::m_s
into a return value. Of course, we still don’t need &&
at the return type, because the object returned by value is rvalue itself.Perhaps, that was the only time since 2011 that I’ve seen justified use of return std::move
, because nowadays we may rely on guaranteed copy elision that usually works better than std::move
for the return value3.
There is an article on another interesting use case of ref-qualified member functions on this blog: Practical usage of ref-qualified member function overloading
Concepts: a modern approach to set requirements on a template type
Now it’s time for a pivot: with C++20, we can avoid writing SFINAE code and embrace concepts.
A concept is a named set of requirements. There’s a detailed article on cppreference, but here’s the gist: it allows distinguishing between multiple template overloads based on the template parameter, similar to SFINAE. However, concepts offer enhanced clarity by separating the declaration from usage and enabling easier combinations, resulting in a more concise code.
A trivial trait-based concept and a requirement
Let’s make minimal modifications to our makeString(String&& s)
and makeString(Numeric value)
overloads and take a look at the first attempt:
|
|
A few explanations are below:
- Line #9: a trivial concept is created to demonstrate the usage of a constexpr bool as a requirement. Although the
<concepts>
providesstd::constructible_from
, we’ll use a custom trait-based concept as a showcase. - Line #11-12:
IsString
concept is used instead of thetypename
keyword to keep typing to a minimum. Please note that there is no more need for SFINAE or trailing return type. - In line #19, a requirement is introduced:
HasStdConversion
is a concept that requires the compilation ofstd::to_string(number);
withT number
to ensure that thestd::to_string
conversion exists. Still not rocket science. - Line #21:
HasStdConversion
is utilized to constrain theauto
type of the parameter. This approach is equivalent to the one from lines #11-12 and requires even less tying. However, regarding the code clarity, my personal preference is to use thetemplate
keyword to clearly indicate that the following method is a template.
Concepts: conjunctions and disjunctions
Now the code can be further cleaned up using <concepts>
instead of hand-written traits. Also, drop the traits
namespace and rewrite every function declaration to use concepts instead of declarations. Here we go:
Assume that type
T
is a string, ifstd::string
isstd::constructible_from
it:template <typename T> concept IsString = std::constructible_from<std::string, T>; template <IsString String> std::string makeString(String&& s) { return std::string(std::forward<String>(s)); }
HasStdConversion
will require thatstd::to_string
could be called on its argument:template <typename T> concept HasStdConversion = requires (T number) { std::to_string(number); }; template <HasStdConversion Numeric> std::string makeString(Numeric number) { return std::to_string(number); }
HasToString
is a concept that requiresobject.to_string()
withObject object
parameter to be valid and return something that isstd::convertible_to<std::string>
. In this case, a requirement will check the expression type using another concept, in addition to a base requirement to compile the code in the braces:template <typename T> concept HasToString = requires (T&& object) { {object.to_string()} -> std::convertible_to<std::string>; }; template <HasToString Object> std::string makeString(Object&& object) { return std::forward<Object>(object).to_string(); }
Finally, the tricky part. Similarly to the SFINAE approach,
IsContainer
is a requirement on type to be iterable:requires (T&& container) { std::begin(container); }
. However, a string is a container, too, and this will lead to ambiguity when callingmakeString("hello")
because both overloads will match. There is a point in our code where requires clause is useful to require a boolean constraint.template <typename T> concept IsContainer = requires (T&& container) { std::begin(container); }; template <typename Iterable> requires (IsContainer<Iterable> && !IsString<Iterable>) std::string makeString(Iterable&& iterable) { //... }
I’d prefer, however, to define IsContainer
just as something iterable, and IsString
as a IsContainer
that can be used to construct an std::string
. This way when something is both IsString
and IsContainer
, the first one will have a priority because the more constrained concept is preferred by the compiler.
That are some great articles within-depht explanation of conjunctions, disjunctions and ordering by constraints that I highly recommend to look at.
Perhaps, it’s a good point to summarize the code and sync-up with a reader’s imagination by providing the full source:
// makeString.hpp
#pragma once
#include <string>
#include <type_traits>
#include <concepts>
template <typename T>
concept IsContainer = requires (T&& container) { std::begin(container); };
// IsString is more constrained than IsContainer,
// so it will have a priority wherever possible
template <typename T>
concept IsString = IsContainer<T> && std::constructible_from<std::string, T>;
template <typename T>
concept HasStdConversion = requires (T number) { std::to_string(number); };
template <typename T>
concept HasToString = requires (T&& object)
{
{object.to_string()} -> std::convertible_to<std::string>;
};
template <HasStdConversion Numeric>
std::string makeString(Numeric number)
{
return std::to_string(number);
}
template <HasToString Object>
std::string makeString(Object&& object)
{
return std::forward<Object>(object).to_string();
}
template <IsString String>
std::string makeString(String&& s)
{
return std::string(std::forward<String>(s));
}
template <IsContainer Iterable>
std::string makeString(Iterable&& iterable)
{
std::string result;
for (auto&& i : iterable)
{
if (!result.empty())
result += ';';
// a constexpr if, so the compiler can omit unused branch and
// allow non-copyable types usage
if constexpr (std::is_rvalue_reference_v<decltype(iterable)>)
result += makeString(std::move(i));
else
result += makeString(i);
}
return result;
}
Compile, run: it works!
Ordering overloads by constraints
Speaking of extensibility, let’s imagine that our makeString
is a library header, and we’re unwilling to modify it. In such a scenario, consider the class that is both IsContainer
and HasToString
:
struct C
{
std::string m_string;
auto begin() const { return std::begin(m_string); }
auto begin() { return std::begin(m_string); }
auto end() const { return std::end(m_string); }
auto end() { return std::end(m_string); }
std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};
int main()
{
// ...
std::cout << makeString(makeVector())
<< std::endl
<< makeString( C { "a container with its own to_string()" } )
<< std::endl;
}
There is an ambiguity in the makeString(C{...})
call because the compiler cannot determine whether the IsContainer
or HasToString
overload is better to apply.
[build] main.cpp:58:18: error: call to 'makeString' is ambiguous
[build] << makeString( C { "a container with its own to_string()" } )
[build] ^~~~~~~~~~
[build] makeString.hpp:37:13: note: candidate function [with Object = C]
[build] std::string makeString(Object&& object)
[build] ^
[build] makeString.hpp:49:13: note: candidate function [with Iterable = C]
[build] std::string makeString(Iterable&& iterable)
[build] ^
Those familiar with the era of SFINAE can imagine the magnitude of the tragedy, and those who have already seen Andrzej’s article on ordering by constraints can imagine the solution.
The ambiguity arises because two constrained methods have the same priority, with neither constraint subsuming the other. The solution is straightforward: introduce a third overload that is more restrictive than the conflicting ones.
struct C
{
std::string m_string;
auto begin() const { return std::begin(m_string); }
auto begin() { return std::begin(m_string); }
auto end() const { return std::end(m_string); }
auto end() { return std::end(m_string); }
std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};
template <typename Container>
requires IsContainer<Container> && HasToString<Container>
std::string makeString(Container&& c)
{
return std::forward<Container>(c).to_string();
}
int main()
{
// ...
std::cout << makeString(makeVector())
<< std::endl
<< makeString( C { "a container with its own to_string()" } )
<< std::endl;
}
This way, the ambiguity is resolved by introducing a new rule, preserving the library implementation.
Variadic templates
There is another topic the developer could greatly benefit from when using it carefully. Back in the days, we had functions with a varied arguments count. Still, there is a set of printf-like functions, where the developer feeds a variable amount of parameters, and the compiler tries to save him from numerous pitfalls.
For instance, Unreal Engine incorporates the format string checks into a custom preprocessor, while std::format
or fmt
provides us an error message that is a bit cryptic but much better than an Undefined Behavior in runtime.
Having that, why don’t provide a variadic template for makeString("xs: ", xs, "; and the double is: ", PI)
? At least, that’s a nice exercise.
Basic syntax and considerations
|
|
Let me describe this masterpiece line-by-line:
- Line #1 declares a parameter pack using an obvious syntax. A parameter pack is is a template parameter that accepts zero or more template arguments.
- Line #2: ‘Args&&… args’ is a forwarding reference to a pack.
- Line #4: a special
sizeof...
that is evaluated at a compile-time to a number of pack’s arguments. - Line #9: an obvious way to use.
- Line #10 may be unobvious, but still correct: a pack of zero arguments is provided.
Parameter pack expansion and recursive functions
Function arguments may contain a regular parameter alongside a parameter pack. For recursive functions, it provides a convenient way to break the recursion once the parameter pack is empty. Consider the makeString(T&& first, Rest&&... rest)
in pseudocode:
Rest
has parameters:return makeString(first) + makeString(rest...);
.Rest
is an empty parameter pack:return makeString(first)
;
While the pseudocode above demonstrates the idea, it does not apply to our code as-is: makeString(T&& first, Rest&&... rest)
accepts one or more parameters. Since we already have a set of single-parameter implementations, an empty-pack call will be ambiguous. The solution is constraining the variadic overload to accept at least two parameters.
Here is an approach from the past: a recursive function makeString(First&& first, Second&& second, Rest&&... rest)
accepts at least two parameters plus an optional variadic pack. It converts the first parameter to a string and calls makeString(second, rest...)
recursively. Once the rest...
pack is empty, it expands to a non-recursive makeString(second)
call.
|
|
Line #8 introduces a parameter pack expansion to the reader: compiler expands expression(pack)...
to a comma-separated list of zero or more expression(argument)
patterns. For example, std::forward<Rest>(rest)...
expands to:
- If
Rest
has no parameters: expands to nothing, ignoring thestd::forward<Rest>(rest)...
expression on line #8. - If
Rest
has one parameter:std::forward<Rest_0>(rest_0)
is added, resulting in a call likemakeString(std::forward<Second>(second), std::forward<Rest_0>(rest_0));
. - If
Rest
has N parameters, each parameter is individually expanded using the enclosing expression:std::forward<Rest_0>(rest_0), std::forward<Rest_1>(rest_1), ..., std::forward<Rest_N>(rest_N)
.
In summary, amount of parameters to makeString
call on line #8 depends on the size of the parameter pack. The whole std::forward<Rest_#>(rest_#)
expression is “copy-pasted” for each added parameter. It’s important to note that this behavior applies to any kind of packed expression, not just std::forward
, so f(g(pack)...)
would be expanded as f(g(pack_0), g(pack_1), ..., g(pack_N))
accordingly.
Given that, compile, run, and enjoy!
a: A; b: B{1}; pi: 3.141593
xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
Hello, world!!1
two ; non-copyables
C{"a container with its own to_string()"}
a variadic with a double: 3.140000
Constrained version
However, we can improve further. Instead of using the Second&& second
parameter to avoid overloading conflicts, we can leverage constraints by requiring sizeof...(Rest) > 0
:
template <typename First, typename... Rest>
requires (sizeof...(Rest) > 0)
std::string makeString(First&& first, Rest&&... rest)
{
return makeString(std::forward<First>(first))
+ makeString(std::forward<Rest>(rest)...);
}
Less code is less of a mental load on the reading developer, so it’s a bit better. Can it be even better?
Fold expressions
Well, since C++ 17 we have fold expressions. Having a parameter pack pack
and an unary or binary operation ‘x’ we can expand (pack x ...)
to (pack_0 x (pack_1 x pack_2))
and so on. There are several options including a left-associative fold (... pack x)
with dots on the left, a right-associative with dots on the right, an option for unary or binary operations, an init
variable for an empty pack, and so on.
To keep this article size reasonable, I dare to forward the reader to the Fluent C++ blog for in-depth details: part 1 - basics and part 2 - advanced usage.
As we’re dealing with strings, I expect the left-associative fold over operator+=
to be more performant than using operator+
because the latter will produce temporaries for holding intermediate results in case of multiple strings involved: return ((a + b) + c) + d
result in 3 temporaries besides a, b, c
, while return ((a += b) += c) += d;
avoids them.
template <typename... Pack>
requires (sizeof...(Pack) > 1)
std::string makeString(Pack&&... pack)
{
return (... += makeString(std::forward<Pack>(pack)));
}
Finally, I’d call it a day and summarize:
- A template function can (and probably should) be constrained to let the compiler fail early and provide a concise error context in case of misuse. Even if it has no overloads yet.
- SFINAE is a reasonable fallback when concepts are unavailable.
- Use static_assert and intentional compilation failures to get an insight into template expansion in case of misunderstanding.
- Perfect forwarding is our friend: it may offer better performance and relax the ‘copyable’ requirement on the arguments.
- … but not for trivial types. Some times are cheaper to pass by-copy than by-reference.
- Variadic templates are a convenient way to process multiple arguments in a row. Fold expression might be even better.
The code I agree with
Let’s sync up on the result of this journey:
// makeString.hpp
#pragma once
#include <string>
#include <type_traits>
#include <concepts>
template <typename T>
concept IsContainer = requires (T&& container) { std::begin(container); };
// IsString is more constrained than IsContainer, so it will have a priority wherever possible
template <typename T>
concept IsString = IsContainer<T> && std::constructible_from<std::string, T>;
template <typename T>
concept HasStdConversion = requires (T number) { std::to_string(number); };
template <typename T>
concept HasToString = requires (T&& object)
{
{object.to_string()} -> std::convertible_to<std::string>;
};
template <HasStdConversion Numeric>
std::string makeString(Numeric number)
{
return std::to_string(number);
}
template <HasToString Object>
std::string makeString(Object&& object)
{
return std::forward<Object>(object).to_string();
}
template <IsString String>
std::string makeString(String&& s)
{
return std::string(std::forward<String>(s));
}
template <IsContainer Iterable>
std::string makeString(Iterable&& iterable)
{
std::string result;
for (auto&& i : iterable)
{
if (!result.empty())
result += ';';
// a constexpr if, so the compiler can omit unused branch and
// allow non-copyable types usage
if constexpr (std::is_rvalue_reference_v<decltype(iterable)>)
result += makeString(std::move(i));
else
result += makeString(i);
}
return result;
}
template <typename... Pack>
requires (sizeof...(Pack) > 1)
std::string makeString(Pack&&... pack)
{
return (... += makeString(std::forward<Pack>(pack)));
}
// main.cpp
#include <iostream>
#include <vector>
#include <set>
#include "makeString.hpp"
struct A
{
std::string to_string() const { return "A"; }
};
struct B
{
int m_i = 0;
std::string to_string() const { return "B{" + std::to_string(m_i) + "}"; }
};
struct NonCopyable
{
std::string m_s;
NonCopyable(const char* s) : m_s(s) {}
NonCopyable(NonCopyable&&) = default;
NonCopyable(const NonCopyable&) = delete;
std::string to_string() const & { return m_s; }
std::string&& to_string() && { return std::move(m_s); }
};
struct C
{
std::string m_string;
auto begin() const { return std::begin(m_string); }
auto begin() { return std::begin(m_string); }
auto end() const { return std::end(m_string); }
auto end() { return std::end(m_string); }
std::string to_string() const { return "C{\"" + m_string + "\"}"; }
};
template <typename Container>
requires IsContainer<Container> && HasToString<Container>
std::string makeString(Container&& c)
{
return std::forward<Container>(c).to_string();
}
int main()
{
A a;
B b = {1};
const std::vector<int> xs = {1, 2, 3};
const std::set<float> ys = {4, 5, 6};
const double zs[] = {7, 8, 9};
std::cout << "a: " << makeString(a) << "; b: " << makeString(b)
<< "; pi: " << makeString(3.1415926) << std::endl
<< "xs: " << makeString(xs) << "; ys: " << makeString(ys)
<< "; zs: " << makeString(zs)
<< std::endl;
std::cout << makeString("Hello, ")
<< makeString(std::string_view("world"))
<< makeString(std::string("!!1"))
<< std::endl;
auto makeVector = []()
{
std::vector<NonCopyable> v;
v.emplace_back("two ");
v.emplace_back(" non-copyables");
return v;
};
std::cout << makeString(makeVector())
<< std::endl
<< makeString( C { "a container with its own to_string()" } )
<< std::endl;
std::cout << makeString("a ", std::string_view("variadic "), std::string("with a double: "), 3.14)
<< std::endl;
}
a: A; b: B{1}; pi: 3.141593
xs: 1;2;3; ys: 4.000000;5.000000;6.000000; zs: 7.000000;8.000000;9.000000
Hello, world!!1
two ; non-copyables
C{"a container with its own to_string()"}
a variadic with a double: 3.140000
References
A section is also known as “I saw an interesting link somewhere in a text wall above”:
- CMake (Wikipedia)
- cppreference: Template specialization
- Cpp Core Guidelines: pass cheaply-copied types by value
- Arthur O’Dwyer’s blog - pass string_view by value
- cppreference: Function template
- Overload resolution of function template calls
- cppreference: Substitution Failure Is Not An Error
- cppreference: Integer Promotions
- cppreference: Type Traits
- cppreference: Metaprogramming library
- isocpp blog on universal (or forwarding) references
- cppreference: member functions
- cppreference: copy elision
- me: Practical usage of ref-qualified member function overloading
- cppreference: Constraints and concepts
- cppreference: requires clause
- Andrzej’s C++ blog - ordering by constraints
- Andrzej’s C++ blog - conjunctions, disjunctions (Requires-clause)
fmt
on Github, in case your standard library has nostd::format
- cppreference: parameter pack
- cppreference: fold expressions
- Fluent C++: C++ Fold Expressions 101
- Fluent C++: What C++ Fold Expressions Can Bring to Your Code
Actually, some of use wrote
template <class T>
– that doesn’t matter, but I prefer atemplate <typename T>
because theint
is not a class name ↩︎std::vector
features strong exception safety guarantee: its remains unchanged ifresize()
throws an exception. In order to maintain that it has to keep initial buffer as a backup if element’s type move constructor is not declated asnoexcept(true)
↩︎a copy elision is better that moving a return value because the object will be constructed already in the scope of the caller without move construction. It also donsn’t take any references of the return value thus making the optimizer’s work easier. ↩︎