You had the right idea. Everything that is needed is encoded in the deduced type of Args. Though, if you want to account for all of the cv-qualified cases, there will be a lot to go through. Let's first recognize the different cases that might arise:
- Construction (implicit conversions are construction)
- Copy construction (commonly
T(const T&))
- Move construction (commonly
T(T&&))
- Slicing (calling
Base(const Base&) or Base(Base&&) with a Derived)
If weird move or copy constructors are not considered (ones with default parameters), the cases 2-4 could only happen a single argument is passed, everything else is construction. Hence, it is sensible to provide an overload for the single argument case. Trying to do all of these cases in the variadic template is going to be ugly, as you have to use fold expressions or something like std::conjuction/std::disjuction for the if statements to be valid.
We will also find out that recognizing move and copy separately in every single case is impossible. If there is no need to consider copies and moves separately, the solution is easy. But if these cases need to be separated, one can only make a good guess, which should work almost always.
What comes to to slicing, I would probably opt to disable it with a static_assert.
Move and copy combined
Here is the solution using a single argument overload. Let's go over it in detail next.
#include <utility>
#include <type_trait>
#include <iostream>
// Multi-argument case is almost always construction
template<typename T, typename... Args>
void CreateTAndDoSomething(Args&&... args)
{
std::cout << "Constructed" << '\n';
T val(std::forward<Args>(args)...);
}
template<typename T, typename U>
void CreateTAndDoSomething(U&& arg)
{
// U without references and cv-qualifiers
// std::remove_cvref_t in C++20
using StrippedU = std::remove_cv_t<std::remove_reference_t<U>>;
// Extra check is needed because T is a base for itself
static_assert(
std::is_same_v<StrippedU, T> || !std::is_base_of_v<T, StrippedU>,
"Attempting to slice"
);
if constexpr (std::is_same_v<StrippedU, T>)
{
std::cout << "Copied or moved" << '\n';
}
else
{
std::cout << "Constructed" << '\n';
}
T val(std::forward<U>(arg));
}
Here we make use of the fact that U&& (and Args&&) is a forwarding reference. With forwarding references the deduced template argument U is different depending on the value category of the passed arg. Given an arg of type T, the U is deduced such that:
- If the
arg was an lvalue, the deduced U is T& (cv-qualifiers included).
- If the
arg was an rvalue, the deduced U is T (cv-qualifiers included).
NOTE: U might deduce to a cv-qualified reference (eg. const Foo&). std::remove_cv only removes toplevel cv-qualifiers, and references can't have toplevel cv-qualifiers. This is why std::remove_cv needs to applied to a non-reference type. If only std::remove_cv was used, the template would fail to recognize cases where U would be const T&, volatile T& or const volatile T&.
Only copy
A copy constructor is called (usually, see note) when U is deduced to T& const T&, volatile T& or const volatile T&. Because we have three cases where the deduced U is a cv-qualified reference and std::remove_cv doesn't work with these, we should just check these cases explicitly:
template<typename T, typename U>
void CreateTAndDoSomething(U&& arg)
{
// U without references and cv-qualifiers
// std::remove_cvref_t in C++20
using StrippedU = std::remove_cv_t<std::remove_reference_t<U>>;
// Extra check is needed because T is a base for itself
static_assert(
std::is_same_v<StrippedU, T> || !std::is_base_of_v<T, StrippedU>,
"Attempting to slice"
);
if constexpr (std::is_same_v<T&, U>
|| std::is_same_v<const T&, U>
|| std::is_same_v<volatile T&, U>
|| std::is_same_v<const volatile T&, U>)
{
std::cout << "Copied" << '\n';
}
else
{
std::cout << "Constructed" << '\n';
}
T val(std::forward<U>(arg));
}
NOTE: This does not recognize copy construction when a move constructor is not available, and the copy constructor with the signature T(const T&) is available. This is because the result of the call to std::forward with an rvalue arg is an xvalue, which can bind to const T&.
Move and copy seperated
DISCLAIMER: this solution only works for the general case (see the pitfalls)
Let's assume that T has a copy constructor with the signature T(const T&) and move constructor with the signature T(T&&), which is really common. const-qualified move constructors do not really make sense, as the moved object needs to be modified almost always.
With this assumption the expression T val(std::forward<U>(arg)); move constructs val, if U was deduced to a non-const T (arg is an non-const rvalue). This gives us two cases:
- U is deduced to
T
- U is deduced to
volatile T
By first removing the volatile qualifier from U we can account for both of these cases. When the move construction recognized first, the rest are copy construction:
template<typename T, typename U>
void CreateTAndDoSomething(U&& arg)
{
// U without references and cv-qualifiers
using StrippedU = std::remove_cv_t<std::remove_reference_t<U>>;
// Extra check is needed because T is a base for itself
static_assert(
std::is_same_v<StrippedU, T> || !std::is_base_of_v<T, StrippedU>,
"Attempting to slice"
);
if constexpr (std::is_same_v<std::remove_volatile_t<U>, T>)
{
std::cout << "Moved (usually)" << '\n';
}
else if constexpr (std::is_same_v<StrippedU, T>)
{
std::cout << "Copied (usually)" << '\n';
}
else
{
std::cout << "Constructed" << '\n';
}
T val(std::forward<U>(arg));
}
If you want to play around with the solution, it's available in godbolt. I've also implemented a special class that hopefully helps to visualize the different constructor calls.
Pitfalls of the solution
When the assumption stated earlier is not true, it is impossible to determine exactly whether copy or move constructor is called. There are at least few special cases that cause ambiguity:
If move constructor for T is not available, arg is a rvalue of type T, and the copy constructor has the signature T(const T&):
The xvalue returned by std::forward<U>(arg) will bind to the const T&. This was also discussed in the "only copy" case.
Move recognized, but a copy happens.
If T has a move constructor with the signature T(const T&&) and arg is a const rvalue of type T:
Copy recognized, but a move happens. Similar case with T(const volatile T&&).
I've also decided not to account for the case, when the user explicitly specifies U (T&& and volatile T&& will compile but not recognize properly).