再谈通用引用

在这篇文章提到了通用引用,但是更多的是什么是通用引用,这篇更多的展示使用通用引用的注意事项

避免与通用引用函数发生重载


使用通用引用通常是希望接受的类型更广泛,减少代码量,同时也可以提升效率(避免类型的强制转换、创建因转换而产生的临时变量等),例如下面的未使用通用引用的例子:

std::multiset<std::string> names; 
void logAndAdd(const std::string& name)
{
	names.emplace(name);
} 
{
	std::string petName("Darla");
	logAndAdd(petName);
	logAndAdd(std::string("Persephone"));
	logAndAdd("Patty Dog"); 
}
  1. 第1个调用logAndAdd(petName);

    实参是个左值,当添加到names时触发拷贝

  2. 第2个调用logAndAdd(std::string("Persephone"));

    实参是个临时变量即右值,形参是个左值,同样当添加到names时触发拷贝

  3. 第3个调用logAndAdd("Patty Dog");

    实参是个字符串字面值即const char*,这时用这个字面值创建了string临时变量,即实参是个右值,形参是个左值,同样当添加到names时触发拷贝

如果使用通用引用效率会有很大提升,通用引用版本:

template<typename T>
void logAndAdd(T&& name)
{
	names.emplace(std::forward<T>(name));
}

同样的调用,再分析一下每个调用:

  1. 第1个调用logAndAdd(petName);

    和之前一样

  2. 第2个调用logAndAdd(std::string("Persephone"));

    实参是个临时变量即右值,实参以移动的方式添加到names

  3. 第3个调用logAndAdd("Patty Dog");

    实参是个字符串字面值即const char*,省去了创建临时变量的过程和拷贝临时变量的过程

这两个例子展示了使用通用引用的威力,但是如果增加一个基于void logAndAdd(T&& name)的重载函数:

template<typename T>
void logAndAdd(T&& name)
{
	names.emplace(std::forward<T>(name));
}

void logAndAdd(int nId)
{
	names.emplace(FactoryFun(nId));
}

例如下面的调用:

{
	int nId = 10;
	logAndAdd(nId);
	
	logAndAdd("Darla");
	
	short nNewId = 11;
	logAndAdd(nNewId);
}

这种调用方式会导致编译不通过,在分析每个调用之前先把C++关于重载决议的一项规则拿出来:

当模板函数和非模板函数同时精确匹配了其调用,那么非模板函数则具有优先权

使用通用引用的函数通常能精确匹配任何参数类型(也有特例),基于这项规则简单分析一下编译不通过的原因:

  1. 第1个调用logAndAdd(nId);

    实参是个int,都能和模板函数、非模板精确匹配,所以调用了非模板函数

  2. 第2个调用logAndAdd("Darla");

    实参是个const char*,只能和模板函数精确匹配

  3. 第3个调用logAndAdd(nNewId);

    实参是个short,能和模板函数精确匹配,虽然一个short实参也能调用void logAndAdd(int nId),但是这里涉及到类型强制转换,所以调用了模板函数。 当匹配了模板函数,而string类型并没有针对short类型参数的构造函数,所以编译报错

我们继续把这项规则、应用场景放大一下,向下面这个包含模板构造函数例子:

class Person 
{
public:
	template<typename T>
	explicit Person(T&& n) : name(std::forward<T>(n)) {}
	explicit Person(int idx): name(GetNameFromIdx(idx)) {}
private:
	std::string name;
};

编译器也为我们生成一些函数,最后这个例子变成这样:

class Person
{
public:
	template<typename T>
	explicit Person(T&& n) : name(std::forward<T>(n)) {}
	
	explicit Person(int idx): name(GetNameFromIdx(idx)) {}
	
	Person(const Person& rhs); 
	Person(Person&& rhs); 
	
private:
	std::string name;
};

像下面的调用:

{
	Person p1("zhangsan");
	Person p2(p1);
}

当用Person实例p1初始化p2时,那么这个类变成这个样子:

class Person
{
public:
	explicit Person(Person& n) : name(std::forward<Person&>(n)) {}
	
	explicit Person(int idx): name(GetNameFromIdx(idx)) {}
	
	Person(const Person& rhs); 
	Person(Person&& rhs); 
	
private:
	std::string name;
};

此时就要在这两个函数做出选择:

explicit Person(Person& n) : name(std::forward<Person&>(n)) {}
Person(const Person& rhs);

调用拷贝构造需要const修饰,而模板函数则不需要,所以最后匹配到这个模板函数,所以最后编译不通过,修改一下调用:

{
	const Person p1("zhangsan");
	Person p2(p1);
}

这个调用正好符合重载决议的原则,若让这个类作为基类,例如:

class SpecialPerson: public Person {
public:
	SpecialPerson(const SpecialPerson& rhs):Person(rhs)
	{ … }
	
	SpecialPerson(SpecialPerson&& rhs):Person(std::move(rhs))
	{ … }
};

当调用这两个子类的构造函数时,预想是要调用基类对应的函数,可是这样的调用被基类的模板函数精确匹配了,因为要想调用对应的基类函数就涉及到类型转换,所以同样编译不通过

通用引用重载的解决方案


上节提出了若使用通用引用重载引发的一些问题,那么解决这些问题有以下方案:

  • 避免基于通用引用重载

    解决问题最重要的方法就是在源头避免——不基于通用引用做重载,例如使用重构手法里的Rename:

      void logAndAddById(int nId)
      {
          ...
      }
    
  • 传递const T&

    上节提到当增加const修饰符,那么就与Person类的复制拷贝构造函数达成精确匹配,这样做的缺点就是牺牲了效率

  • 去除通用引用以传值的方式

    像上面的例子可以按照下面方式修改:

      class Person
      {
      public:
          explicit Person(std::string n) : name(std::move(n)) {}
    		
          explicit Person(int idx): name(GetNameFromIdx(idx)) {}
    		
      private:
          std::string name;
      };
    

    此时我们传递string类型、const char*类型、int、short等均会有对应的匹配函数,只不过可能牺牲了效率,代码变的通俗易懂

  • 标签分派

    当使用通用引用又不舍弃重载,那么可以使通用引用作为形参的一部分,利用其它形参作重载函数的区分,像下面的例子,引入一个分派函数,内部对其精确区分:

      string FactoryFun(int nId)
      {
          return "";
      }
    	
      std::multiset<std::string> names;
      template<typename T>
      void logAndAdd(T&& name,std::false_type)
      {
          names.emplace(std::forward<T>(name));
      }
    	
      void logAndAdd(int nId,std::true_type)
      {
          names.emplace(FactoryFun(nId));
      }
    	
      template<typename T>
      void LogFunDispatch(T&& param)
      {
          logAndAdd(std::forward<T>(param), std::is_integral<std::remove_reference_t<T>>());
      }
    	
      {
          LogFunDispatch("zhangsan");
          short nId1 = 10;
          int nId2 = 11;
          LogFunDispatch(nId1);
          LogFunDispatch(nId2);
      }
    

    我们期望整型分派给void logAndAdd(int nId,std::true_type),其它型别分派给void logAndAdd(T&& name,std::false_type),我们使用标准库提供is_integral判断传入的实参型别, 但是前提必须移除附加在T上的修饰词——std::remove_reference_t,否则当我们传入一个左值int时,那么T被推导成int&,而int&并不是整型,进而导致调用了void logAndAdd(T&& name,std::false_type)函数

    std::remove_reference_t可以移除附加在型别上的所有引用修饰词,std::remove_reference_t是C++14的写法,C++11的写法是typename std::remove_reference<T>::type

    std::false_type和std::true_type就是标签,标签的作用就是使重载按照我们的预期调用,这些标签在运行期不起任何作用,重载的决议不再依赖通用引用参数而是依赖标签,这种设计就是 标签分派

    对于标签分派系统的实现是有一个面向客户端的、非重载的API,像LogFunDispatch,在API内部针对重载函数发起调用

  • 对通用引用模板增加条件

    在上一节提到了使用标签分派系统,如果将标签分派系统应用于类Person时并不合适,例如有些针对构造函数的调用可能绕过标签分派系统,但又不能一定会绕过标签分派系统,可以结合上面的解释想想 什么情况会绕过标签分派系统,什么情况又不一定绕过标签分派系统

    那么如何解决类Person遇到的通用引用问题,这里我们可以先针对类Person遇到的问题提出解决的条件(我们的期望):

    1. 对于模板构造函数Person(T&& n)

      期望接受实参string类型对象、const char*对象,相反,除此之外任何其他类型对象拒绝接收

    2. 对于构造函数Person(int idx)

      只期望接受整型对象

    3. 对于编译器生成的构造函数

      对于外部的调用更符合我们的期望,例如调用复制拷贝构造函数、移动构造函数等等,言外之意就是只针对Person类型、Person子类

    有了我们的期望,那么剩下的就是如何实现,这里可以借助标准库的std::enable_if、std::decay、std::is_base_of,我们先解释一下这三个模板的语义:

    • std::enable_if

      只有满足std::enable_if指定的条件才会启用

    • std::decay

      移除参数上的const、volatile、引用修饰词

    • std::is_base_of

      判定一个型别是否由另一个型别派生而来

    所以这个类Person通用引用模板变成这样:

      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)
    

    这也正符合我们对这个模板的期望:不接受整型、不接受类似Person类型(Person本身、Person子类)。只有这些条件为真时通用引用模板才会启用

费了这么多精力就是为了达到我们的目的:

  1. 利用完美转发,达到高效
  2. 控制通用引用和重载的组合,解决了无法避免使用重载的场合

对于服务器工程师来讲,高效这一特性就足以让工程师疯狂,当然使用通用引用也有缺点,易用性上有劣势,可读性上有劣势等等

优势、劣势并存,在实际开发中经常会遇到并存权衡问题,其实个人认为权衡之术就是我们的初心