条款27:熟悉通用引用重载的替代方案
Effective Modern C++之条款27
1 替代方案
条款26解释了对通用引用进行重载可能会导致各种问题,无论是对于独立的普通函数还是成员函数(尤其是构造函数)。现在探讨实现期望行为的方法:
1)要么通过避免对通用引用进行重载的设计。
2)要么通过以限制它们可以匹配的参数类型的方式使用它们。
1.1 放弃重载
可以通过为可能的重载使用不同的名称来避免通用引用重载,但对于构造函数来说是行不通的,因为构造函数的名称是由语言固定的。此外,谁愿意放弃重载呢?
1.2 按 const T&传递
另一种选择是恢复到 C++98,并将通用引用传递替换为按常量左值引用传递。缺点是设计没有希望的那么高效。放弃一些效率以保持简单,也是一种不错的权衡。
1.3 值传递
一种通常可以在不增加复杂度的情况下提高性能的方法是(与直觉相反):即当知道要拷贝对象时,考虑按值传递对象:
class Person { //基于条款26中的例子
public:
explicit Person(std::string n) : name(std::move(n)) {}// 替换 T&& ctor;
explicit Person(int idx) : name(nameFromIdx(idx)) {}
...
private:
std::string name;
};
1.4 使用标签分派
- 按常量左值引用(const T&)传递和按值传递都不支持完美转发。
- 如果参数列表除了通用引用之外,还包含其他的参数,那么只要普通参数的匹配度足够差,就可以使得具有通用引用的重载退出匹配的竞争。
std::multiset<std::string> names; // 全局数据结构
template<typename T> // 进行日志记录并添加名称到数据结构
void logAndAdd(T&& name) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
如果引入按索引查找对象的 int 重载,将回到条款 26 中的麻烦:
1)不添加重载,而是重新实现 logAndAdd,将其委托给另外两个函数,一个用于整型值,一个用于其他所有值。
2)真正工作的是两个命名为 logAndAddImpl的重载函数(其中一个函数将采用通用引用)。每个函数都接受2个参数,其中一个参数表明正在传递的参数是否为整型。
template<typename T>
void logAndAdd(T&& name){
//将左值参数传递给通用引用,对 T 的类型推导将是一个左值引用。引用不是整型。对于任何左值参数,std::is_integral<T>都将为假
logAndAddImpl(std::forward<T>(name),
std::is_integral<T>()); // 不完全正确
}
使用标准 C++库有一个类型特征std::remove_reference:从类型中删除任何引用限定符:
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
//C++14中可以使用std::remove_reference_t<T>
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}
函数logAndAddImpl上有两个重载,第一个仅适用于非整型类型。
1)std::is_integral<typename std::remove_reference::type>为假的类型
//但是真和假是运行时的值,需要使用重载解析(一种编译时现象)来选择正确的 logAndAddImpl 重载
template<typename T> // 非整型参数:将其添加到全局数据结构
void logAndAddImpl(T&& name, std::false_type) {
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
//传递给 logAndAdd 的参数是一个对象,如果 T 是整型,则该对象的类型继承自 std::true_type;如果 T 不是整型,则继承自 std::false_type
第二个重载涵盖T 是一个整型类型的情况。
1)logAndAddImpl只是找到与传入的索引相对应的名称,并将该名称传递回logAndAdd。
2)std::true_type和std::false_type类型是“标签”,将工作“分派”到正确的重载。
3)这些形参命名,希望编译器能够认识到标签形参没有被使用,并将其在程序的执行代码中优化掉。
std::string nameFromIdx(int idx); // 如条款26所示
void logAndAddImpl(int idx, std::true_type) { // 整型参数:
logAndAdd(nameFromIdx(idx)); // 查找名称并用它调用 logAndAdd
}
1.5 限制接受通用引用的模板
- 如果只编写了一个构造函数,并在其中使用标签分派,一些构造函数调用也可能由编译器生成的函数处理,这些函数会绕过标签分派系统。
- 问题不在于编译器生成的函数有时会绕过标签分派设计,而是它们并不总是绕过它。
- 需要一种不同的技术,限制通用引用所属函数模板的使用:std::enable_if提供了一种方法,可以禁用模板。
这是Person中完美转发构造函数的声明,这里仅展示使用std::enable_if所需的部分,std::enable_if的使用对函数的实现没有影响。
class Person {
public:
//condition 可以是一个检查传递给构造函数的类型是否为 Person 的条件
//condition 是一个条件表达式,用于确定是否启用模板
//std::enable_if<condition>::type 是一个类型别名,当condition为真时,它将是一个有效的类型
template<typename T,
typename = typename std::enable_if<condition>::type>
explicit Person(T&& n);
...
};
T 不是 Person 类型时,才启用模板化构造函数。想要的condition似乎是 !std::is_same<Person, T>::value,但不完全正确,因为用左值初始化的通用引用T 将被推导为 Person&。
//Person 和 Person& 是不同的类型
//Person& 、 Person&& 、const Person、volatile Person 、 const volatile Person 都应该与 Person 相同
Person p("Nancy");
auto cloneOfP(p); // 从左值初始化
std::decay::type 与 T 相同,但引用和 cv-qualifier(即 const 或 volatile 限定符)被移除。(std::decay 还将数组和函数类型转换为指针)。
!std::is_same<Person, typename std::decay<T>::type>::value
将条件插入上面的 std::enable_if 模板中,得到 Person 的完美转发构造函数的声明如下:
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_same<Person,typename std::decay<T>::type>::value
>::type
>
explicit Person(T&& n);
...
};
问题还没完全解决,假设从Person派生的一个类以常规方式实现了拷贝和移动操作:
class SpecialPerson: public Person {
public:
SpecialPerson(const SpecialPerson& rhs) // 拷贝 ctor;
: Person(rhs) // 调用基类转发ctor
{ … }
SpecialPerson(SpecialPerson&& rhs) // 移动 ctor;
: Person(std::move(rhs)) // 调用基类转发ctor
{ … }
//即使在应用了std::decay之后,因为 SpecialPerson 与 Person 也不一样
…
};
std::is_base_of:
1)如果T2派生自T1,则std::is_base_of<T1, T2>::value为真。
2)类型被认为是从自身派生的,因此std::is_base_of<T, T>::value为真。
使用std::is_base_of而不是std::is_same可以满足需求:
class Person {
public:
template<
typename T,
typename = typename std::enable_if<
!std::is_base_of<Person,typename std::decay<T>::type>::value
>::type
>
explicit Person(T&& n);
…
};
class Person { // C++14
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person,std::decay_t<T> >::value
>
>
explicit Person(T&& n);
…
};
现在知道如何使用std::enable_if有选择地禁用Person的通用引用构造函数,但如何将其应用于区分整型和非整型参数?
(1)添加一个处理整型参数的Person构造函数重载。
(2)进一步限制模板化构造函数,使其对这些参数禁用。
class Person {
public:
template<
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value
&& //并且
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n) // 用于可转换为 std::string 的参数的构造函数
: name(std::forward<T>(n)) {... }
explicit Person(int idx) // 用于整型参数的构造函数
: name(nameFromIdx(idx)){... }
... // 拷贝和移动构造函数等
private:
std::string name;
};
1.6 权衡
- 前三种技术:1、放弃重载;2、按const T&传递;3、按值传递;为要调用的函数中的参数指定了一种类型。
- 后两种技术:1、标签分发;2、限制模板资格;使用完美转发,因此不为参数指定类型。
是否指定类型,会产生怎么样的后果? - 通常,完美转发更高效,因为它避免了仅为了符合参数声明的类型而创建临时对象。
- 但是完美转发也有缺点:
1)其中之一是某些类型的参数不能进行完美转发。
2)第二个问题是当客户端传递无效参数时错误消息的可理解性。例如,假设一个客户端创建一个 Person 对象时传递了一个由 char16_t 组成的字符串字面值(这是 C++11 中引入的一种类型,表示 16 位字符),而不是(组成std::string的 )字符。
Person p(u"Konrad Zuse"); // "Konrad Zuse"由const char16_t 类型的字符组成
//u"表示 Unicode 字符的字符串,其中的字符是宽字符(如 char16_t 或 char32_t)
1)前三种技术:编译器将看到可用的构造函数接受int或std::string,直接的错误消息,解释从const char16_t[12]到int或std::string的转换是不可能的。
2)基于完美转发的技术:const char16_t 数组可以无报错地绑定到构造函数的参数上。然后被转发到 Person 的 std::string 数据成员的构造函数,才发现调用者传入的(const char16_t 数组)与所需的(std::string 构造函数可接受的任何类型)之间存在不匹配。结果产生的错误消息可能无法琢磨。
- 对于 Person 类,已知转发函数的通用引用参数应该是 std::string 的初始化器,因此可以使用 static_assert 来验证它是否可以扮演这个角色:
class Person {
public:
template< // 与之前一样
typename T,
typename = std::enable_if_t<
!std::is_base_of<Person, std::decay_t<T>>::value &&
!std::is_integral<std::remove_reference_t<T>>::value
>
>
explicit Person(T&& n)
: name(std::forward<T>(n)){
// 断言可以从 T 对象创建一个 std::string
//std::is_constructible 类型特征在编译时进行测试,以确定一个类型的对象是否可以从另一个类型(或一组类型)的对象(或一组对象)构造
static_assert(std::is_constructible<std::string, T>::value,
"Parameter n can't be used to construct a std::string"
);
… // 通常的 ctor 工作在这里进行
}
… // Person 类的其余部分(与之前一样)
};
不幸的是,这里的static_assert 在构造函数的主体中,但是转发代码是成员初始化列表的一部分,在 static_assert 之前。结果是只有在发出通常的难以理解的错误消息之后,才会出现由 static_assert 产生的良好可读消息。
2 要点速记
- 通用引用和重载结合的替代方案包括:使用不同的函数名、通过const左值引用传递参数、按值传递参数、使用标记分派。
- 通过 std::enable_if 禁用模板,允许同时使用通用引用和重载,但需要它控制编译器可以使用通用引用重载的条件。
- 通用引用参数通常具有效率优势,但使用起来并不容易。
更多推荐
所有评论(0)