你的想法是对的。所需的一切都以Args 的推断类型编码。但是,如果您想考虑所有符合 cv 条件的案例,那么还有很多事情要做。让我们首先识别可能出现的不同情况:
- 构造(隐式转换是构造)
- 复制构造(常用
T(const T&))
- 移动构造(常用
T(T&&))
- 切片(使用
Derived 调用Base(const Base&) 或Base(Base&&))
如果不考虑奇怪的移动或复制构造函数(带有默认参数的构造函数),则情况 2-4 可能只会发生一个参数被传递,其他一切都是构造函数。因此,为单参数情况提供重载是明智的。尝试在可变参数模板中处理所有这些情况会很糟糕,因为您必须使用折叠表达式或 std::conjuction/std::disjuction 之类的东西才能使 if 语句有效。
我们还会发现,在每种情况下分别识别移动和复制是不可能的。如果不需要单独考虑复制和移动,解决方案很简单。但是,如果需要将这些情况分开,只能做出一个很好的猜测,这应该几乎总是有效。
关于切片,我可能会选择使用static_assert 禁用它。
移动和复制结合
这是使用单个参数重载的解决方案。接下来我们详细介绍一下。
#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));
}
这里我们利用U&&(和Args&&)是一个转发引用这一事实。使用转发引用,推导出的模板参数U 会根据传递的arg 的值类别而有所不同。给定一个T 类型的arg,推导出U:
- 如果
arg 是左值,则推导出的U 是T&(包括cv-限定符)。
- 如果
arg 是一个右值,则推导出的U 是T(cv-包括限定符)。
注意: U 可能会推断为 cv 限定的引用(例如 const Foo&)。 std::remove_cv 只删除顶级 cv-限定符,引用不能有顶级 cv-限定符。这就是为什么std::remove_cv 需要应用于非引用类型的原因。如果只使用std::remove_cv,模板将无法识别U 为const T&、volatile T& 或const volatile T& 的情况。
只复制
当U 被推导为T& const T&、volatile T& 或const volatile T& 时,将调用复制构造函数(通常,见注释)。因为我们有三种情况,其中推导出的 U 是一个 cv 限定引用,而 std::remove_cv 不适用于这些情况,我们应该明确检查这些情况:
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));
}
注意: 当移动构造函数不可用且具有签名T(const T&) 的复制构造函数可用时,这不会识别复制构造。这是因为使用右值arg 调用std::forward 的结果是一个xvalue,可以绑定到const T&。
移动和复制分开
免责声明:此解决方案仅适用于一般情况(请参阅陷阱)
假设T 有一个带有签名T(const T&) 的复制构造函数和带有签名T(T&&) 的移动构造函数,这很常见。 const-qualified 移动构造函数实际上没有意义,因为移动的对象需要几乎总是进行修改。
在此假设下,表达式T val(std::forward<U>(arg)); 移动构造val,如果U 被推导出为非常量T(arg 是非常量右值)。这给了我们两种情况:
- U 推导出为
T
- U 推导出为
volatile T
通过首先从U 中删除 volatile 限定符,我们可以解决这两种情况。当移动构造首先识别时,其余的都是复制构造:
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));
}
如果您想试用该解决方案,请访问godbolt。我还实现了一个特殊的类,希望有助于可视化不同的构造函数调用。
解决方案的缺陷
当前面所述的假设不成立时,无法准确确定调用的是复制构造函数还是移动构造函数。至少有一些特殊情况会导致歧义:
-
如果T 的移动构造函数不可用,则arg 是T 类型的右值,并且复制构造函数具有签名T(const T&):
std::forward<U>(arg) 返回的 xvalue 将绑定到 const T&。这也在“唯一副本”案例中进行了讨论。
已识别移动,但发生复制。
-
如果T 有一个带有签名T(const T&&) 的移动构造函数并且arg 是T 类型的const 右值:
复制已识别,但发生了移动。与T(const volatile T&&).类似的情况
当用户明确指定 U(T&& 和 volatile T&& 将编译但无法正确识别)时,我还决定不考虑这种情况。