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 使用标签分派

  1. 按常量左值引用(const T&)传递和按值传递都不支持完美转发。
  2. 如果参数列表除了通用引用之外,还包含其他的参数,那么只要普通参数的匹配度足够差,就可以使得具有通用引用的重载退出匹配的竞争。
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 限制接受通用引用的模板

  1. 如果只编写了一个构造函数,并在其中使用标签分派,一些构造函数调用也可能由编译器生成的函数处理,这些函数会绕过标签分派系统。
  2. 问题不在于编译器生成的函数有时会绕过标签分派设计,而是它们并不总是绕过它。
  3. 需要一种不同的技术,限制通用引用所属函数模板的使用: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. 前三种技术:1、放弃重载;2、按const T&传递;3、按值传递;为要调用的函数中的参数指定了一种类型。
  2. 后两种技术:1、标签分发;2、限制模板资格;使用完美转发,因此不为参数指定类型。
    是否指定类型,会产生怎么样的后果?
  3. 通常,完美转发更高效,因为它避免了仅为了符合参数声明的类型而创建临时对象。
  4. 但是完美转发也有缺点:
    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 构造函数可接受的任何类型)之间存在不匹配。结果产生的错误消息可能无法琢磨。

  1. 对于 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 要点速记

  1. 通用引用和重载结合的替代方案包括:使用不同的函数名、通过const左值引用传递参数、按值传递参数、使用标记分派。
  2. 通过 std::enable_if 禁用模板,允许同时使用通用引用和重载,但需要它控制编译器可以使用通用引用重载的条件。
  3. 通用引用参数通常具有效率优势,但使用起来并不容易。
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐