Effective Modern C++ Item 27 熟悉依万能引用型别进行重载的替代方案

Item 27 熟悉依万能引用型别进行重载的替代方案

Item 26说过,万能引用和重载在一起总会产生各种各样的问题,无论是独立函数,成员函数,都最好不要和万能引用放一起重载,其中构造函数和万能引用放一起问题最为严重。不过实际业务中其实的确有这样的真实需求。本条款对这种需求的解决方案进行了探讨。提出了几种合适的解决方案。目的在于获得期望的行为,方法。

以下各个手法均以Item 26中构建的代码为基础的,如果没看或者不熟悉,那么先了解一下再继续下去。

放弃重载

Item 26的第一个例子,logAndAdd,可以作为很多函数的代表,这样的函数只需把本来准备进行重载的函数换个名字就可以避免被万能引用进行重载。以logAndAdd为例,可以分别叫logAndAddNamelogAndAddNameIdx
很可惜的是,这种方式并不适合第二个例子—————Person 类的构造函数,因为构造函数的名字是由语言固化的。而且彻底放弃重载也不是长久之计。

传递const T&型别的形参

一种替代方法是回归 C++98,使用传递左值常量引用型别代替传递万能引用型别。其实,这就是Item 26做的第一种问题解决尝试。这种方案的缺点是达不到我们想要的高效率。不过在已知万能引用型别进行重载会带来的不良效果后,放弃部分效率来保持简洁性不失为一种有吸引力的权衡结果。

传值

一种经常能够提升性能,却不用增加一点复杂性的方法,就是把传递的形参从引用型别转换成值型别,尽管这样做是反直觉的。这种设计遵循了Item 41的建议————当你知道肯定需要复制形参时,考虑按值传递对象。
书中把相关的运行原理和效率提升的细节推迟到Item 41讨论,而在此仅仅战士一下该技术在 Person 例子里的应用:

class Person {
public:
    explicit Person(std::string n)  //替换掉T&&型别的构造函数
        : name(std::move(n)) {}
    explicit Person(int idx)        //同前
        : name(nameFromIdx(ix)) {}
    ....
private:
    std::string name;
};

由于std::string型别并没有只接受单个整形形参的构造函数,所有的int或者类int型别(例如 shortlongstd::size_t)的实参都会汇集到接受int的那个构造函数重载版本的调用。类似,所有std::string型别的实参(包含可以构造出std::string型别的对象物,例如字面量"HHAA")都会被传递给接受std::string的那个构造函数重载版本的调用。

标签分派

无论是传递左值常量还是传值,都不支持完美转发。如果业务里就是要使用完美转发,那么只能采用万能引用,别无他法。但重载也不想舍弃的时候,应该怎么办呢。
其实解决之道并不是很难。
重载函数在调用时的决定逻辑如下:

考察所有重载版本的形参,以及调用端传入的实参,然后选择全局最佳匹配的函数,这个步骤需将所有的形参/实参组合都考虑在内。

一个万能引用形参通常会导致的后果是无论传入了什么都给出一个精确匹配结果,不过,如果万能引用仅仅是形参列表的一部分,该列表中还有其他非万能引用型别的形参的话,那么只要该非万能引用形参具备充分差(sufficiently poor)的匹配能力,则它就足以将这个带有万能引用形参的重载版本踢出局。这个想法就是标签分派手法的基础,为便于后续理解,先看一个例子。
先看看之前的logAndAdd的例子试试标签分派的改造,这里把代码重新列一次:

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的重载版本,以按索引查找对象时,就会重回Item26的麻烦境地。本条款的目的,就在于避免那些问题,方法是不在添加重载版本,而是重新实现logAndAdd,把它委托给另外两个函数,一个接受整型值,另一个接受其他所有型别。而logAndAdd本身则接受所有型别的实参。

这两个完成了实际工作的函数名字为logAndAddImpl,所以我们重载的实际是logAndAddImpl。而这两个函数中,还有第二个形参,该形参用来判断传入的实参是否为整型。正是这第二个形参组织了我们落入Item26所描述的陷阱中。
代码如下:

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(std::forward<T>(name), 
                    std::is_integral<T>()); //不够正确
}

上面这个函数实际上把他的形参转发给了logAndAddImpl,但它还传递了另一个实参,用来表示那个形参的型别(T)是否为整型。至少,它应该做到这件事。若传给logAndAdd的实参是右值整型,它就做到了。

为何不够正确

但是,如Item28所言,如果传给万能引用name的实参是个左值,那么T就会被推导为左值引用。所以,如果传递给logAndAdd的是个左值int,则T就会被推导为int&。这不是个整型,因为引用型别都不是整型。这意味着std::is_intergral<T>在函数接受了任意左值实参时,会得到结果“假”,尽管这样的实参确实表示了一个整型值。

应该如何修改

意识到问题所在,也就相当于已经解决了问题。std::remove_reference,正如其名,也正如所需:它会移除型别所附加的一切引用饰词。因此,正确的logAndAdd如下:

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(
        std::forward<T>(name),
        std::is_intergral<typename std::remove_reference<T>::type>()
    );
}

这才算完成了整套戏法(C++14中可以少敲几下键盘,因为可以把typename后面的换成std::remove_reference_t<T>代替。欲知详情,参见Item 9。)
完成logAndAdd以后,就可以把注意力放在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));
}

编译期的true和false

只要你理解了std::flase_type的形参背后的运行机制,就能看出来这段直接了当的代码。概念上,logAndAdd会向logAndAddImpl传递一个布尔值,用来表示传递给logAndAdd的实参是否为整型。不过,truefalse都是运行期值,可是我们需要利用的是重载决议(一种编译现象)来选择正确的logAndAddImpl重载版本。这就意味着我们需要一个对应于true的型别,和一个对应于false的的不同型别。这个需求足够朴实,所以C++标准库提供了名为std::true_typestd::false_type的一对型别来满足之。
若T是整型,则经由logAndAdd传递给logAndAddImpl的实参就会是个继承自std::true_type的对象。反之,若T不是整型,该实参就会是个继承自std::false_type的对象。总的结果是,只有当T不是整型时,logAndAdd发起的调用才会从候选中选定上面这个logAndAddImpl的版本。
第二个重载版本则包含了T是整型的相反情况。

std::string nameFromIdx(int idx);               //同Item26
void logAndAddImpl(int idx, std::true_type)     //整型实参:
{                                               //查找名字并用它调用
    logAndAdd(nameFromIdx(idx));                //logAndAdd
}

通过让logAndAddImpl按索引查找对应名字,然后传递给logAndAdd(在那里,它会经由std::forward转发到另一个logAndAddImpl重载版本)。就可以避免在两个重载版本都放入记录日志的代码。

什么是标签分派

在上述设计中,型别std::true_typestd::false_type就是所谓的“标签”,运用他们的唯一目的在于强制重载决议按照我们想要的方向推进。值得注意的是,这些形参甚至没有名字。他们在运行期不起任何作用,实际上,我们希望编译器能识别出这些标签形参并未使用过,从而将他们从程序执行镜像中优化掉(某些编译器的确会这样做,至少有时会)。针对logAndAdd内的重载实现函数发起的调用把工作“分派”到正确的重载版本的手法就是创建适当的标签对象。这种设计因而得名:标签分派。它是模板元编程的标准构件,所以你越是深入地查看当下的C++库代码,你也就越是会频繁的看到这种设计。
那么这一小部分,更重要的不在于标签分派的工作细节,而是它让我们得以将万能引用和重载组合却不会引发Item26所描述的问题的能力

万能引用下,标签分派设计思路

分派函数logAndAdd接受的是个不受限制的万能引用形参,但该函数并未重载。实现函数logAndAddImpl则实施了重载,每个重载版本都接受一个万能引用形参,但重载决议却并不仅对这个万能引用形参有依赖,还对标签有依赖,这个标签值则设计保证可以命中匹配的函数不超过一个。
这样设计的结果是,只有标签值才决定了调用的是哪个重载版本。万能一样弄形参总是给出精确匹配这个事实,也就无关紧要了。

对接受万能引用的模板加以限制

标签分派能够发挥作用的关键在于,存在一个单版本(无重载)函数作为客户端API。此单版本函数会把待完成的工作分派到实现函数。创建无重载的分派函数通常并不难,但Item26 所关注的第二个问题,即关于Person类的完美转发构造函数的那个问题,却是个例外。编译器可能会自行生成复制和移动构造函数,所以如果仅仅撰写一个构造函数,然后在其中运用标签分派,那么有些针对构造函数调用就可能会由编译器生成的构造函数接手处理,从而绕过了标签分派系统
实际上,真正的问题并不在于编译器生成的函数有时候会绕过标签分派设计,而在于编译器生成的函数并不能保证一定会绕过标签分派设计。当收到使用左值对象进行同性别对象的复制请求时,你机会总会期望调用到的是复制构造函数。但是,就如Item26所演示的那样,只要提供了一个接受万能引用形参的构造函数,会导致复制非常量左值时,总会调用到万能引用构造函数(而非复制构造函数)。并且此条款也解释了,如果基类中声明了一个完美转发构造函数,则派生类以传统方式实现其复制和移动构造函数时,总会调用到该构造函数,尽管正确行为应该是调用到基类的复制和移动构造函数。
对于这样的情况,也就是接受了万能引用形参的重载函数比你想要的程度更加贪婪,而却又未贪婪到能够独当一面成为单版本分派函数的程度标签分派就不是你想寻找的好伙伴了。这里应该是使用另一个方法,它可以让你把含有万能引用部分的函数模板被允许采用的条件砍掉一部分。

解决标签分派无法搞定问题的方式 std::enable_if

std::enable_if可以强制编译器表现出来的行为如同特定的模板不存在一般。这样的模板称为禁用的。默认的,所有模板都是启用的。可是,实施了std::enable_if的模板只会在满足了std::enable_if指定的条件的前提下才会启用。

看看我们的例子如何实现

需求:

仅在传递给完美转发构造函数的型别不是Person的时候才启用它。
如果传递的型别是Person,我们就会想要禁用完美转发构造函数(即,让编译器忽略它)

想实现这样的需求其实并不难,不过语法是在让人望而却步,尤其如果以前从没有见过就更是如此。
std::enable_if的条件部分可以套用公式,以下是Person类的完美转发构造函数声明,仅展示了使得std::enable_if的得以运作的够用成都。之所以仅展示该构造函数的声明部分,是因为std::enable_if对实现毫无影响,该函数的实现和Item26中的一样。

class Person {
public:
    template<typename T,
             typename = typename std::enable_if<condition>::type>
    explicit Person(T&& n);
    ...
};

如果想知道突出表示部分的代码到底怎么运作的,机理是什么,解释起来有点复杂,但是可以给你一个线索(去了解“SFINAE”)。这里,焦点集中放在控制该构造函数是否被弃用的条件表达式上。

std::is_same

我们想要指定的条件是,T不是Person型别,即,仅当T是Person以外的型别时,才启用该模板构造函数。正好有个型别特征能够判断两个型别是否同一(std::is_same),这么一来,我们想要的条件好像是!std::is_same<Person, T>::value。然而这个表达式已经接近正确,原因是使用左值初始化万能引用时,T的型别推导结果总是左值引用。这就意味着,对于这样的代码:

Person p("Nancy");
auto cloneOfP(p);   //从左值触发进行初始化

万能引用构造函数中的T的型别会被推到成Person&,型别PersonPerson&不相同,即std::is_same<Person, Person&>::value的值为false。
如果我们更加精细地加以烦死,我们说仅当T不是Person型别时才启用Person类中模板构造函数到底是什么意思的话,我们就会意识到,在审查T的时候,我们需要忽略以下因素:

  • 它是否是个引用。为判定万能引用构造函数是否应该被启用,型别PersonPerson&Person&&都应该与Person做相同处理。
  • 它是否带有const和volatile饰词。对目前关注的目的而言,const Personvolatile Personconst volatile Person都应该与Person做相同处理。

std::decay

这意味着在判定T是否与Person相同之间,需要一种手段来移除T型别带有的所有引用,cv饰词。再一次的,标准库为我们准备好了工具,叫做std::decay。这个工具的作用是移除T的引用和cv饰词(注:这个词就是退化的意思)。也就是说std::decay<T>::type和T的类型相同,只是移除了引用和cv饰词。
有了这些工具,我们判定构造函数是否启动的条件就变成了如下这样:

!std::is_same<Person, typename std::decay<T>::type>::value
//同样,如果你是顺着一条一条看下来的,你会明白,c++14里可以写的更简单
// in C++14
!std::is_same<Person, std::decay_t<T>>::value

把上述条件表达式套到上面的公式中去,再把得到的代码结果重排一下格式,这样就能够更容易看清楚各个部分是如何就为的,最后得出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型别的对象(无论是左右值,cv饰词)来构造另一个Person型别的对象时,将永不会调用到接受万能引用的构造函数。
成功了对吧?完工了!
等等,还差一点点。Item26的还有一个地方有点松动,我们得把这个地方扎牢了。
给定一继承Person的类,以传统的方式实现其复制和移动构造函数:

class SpecialPerson: public Person{
public:
    SpecialPerson(const SpecialPerson& rhs) //复制构造函数;
    : Person(rhs)                           //调用的是
    {...}                                   //基类的完美转发构造函数!
    SpecialPerson(SpecialPerson&& rhs)      //移动构造函数
    : Person(std::move(rhs))                //调用的是
    {...}                                   //基类的完美转发构造函数!
    ...
};

以上和Item26展示的是同一段代码,连注释都未改一字,并且这注释仍然成立。
原因在于:
当复制或者移动一个SpecialPerson型别的对象时,我们会期望通过基类的复制或 移动构造函数来完成该对象基类部分的复制或移动,不过在这些函数中,我们传递给基类构造函数的是SpecialPerson型别的对象,因为SpecialPersonPerson型别不同(实施过std::decay仍然不同),基类的万能引用构造函数会启用,后者很乐意在精确匹配了SpecialPerson型别的实参后执行实例化。原因在于,这个精确匹配比Person累中的复制和移动构造函数所要求的从派生类到基类的强制转换才能把SpecialPerson型别的对象绑定到Person型别的形参来说,是更好的匹配。所以,我们现有的代码在复制和移动SpecialPerson型别的对象是,会使用Person类的完美转发构造函数来复制和移动它们的基类部分。

继承参与后的解决方案

因为派生类只是遵从正常规则在实现其复制和移动构造函数,所以问题的解决还须在基类中完成。更准确的说,就在那个决定了Person类的万能引用构造函数是否启用的条件中。
我们现在有了新的意识:

  • 我们想要的是为与Person或者继承自Person的型别都不同一的实参型别才启用模板构造。

std::is_base_of

这个工具就是用来解决上述问题的。若T2由T1派生而来,则std::is_base_of<T1, T2>::value是真。所有型别都可以认为是从它自身派生而来,所以std::is_base_of<T, T>::value也是真。

进一步完善

有了上述工具,解决问题就简单了:

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);
    ...
};

到这里,才算终于完工了。当然,如果用的是C++14,可以写更简单点:

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);
    ...
};

最后一点修补

之前的需求中还有剃刀,对于实参是整型和非整型需要有区分。
我们整理一下我们需要做的:

  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_intergral<std::remove_reference_t<T>>::value
                >
                               
            >                                  
    explicit Person(T&& n): name(std::forward<T>(n)) 
    {...}
    explicit Person(int idx): name(nameFromIdx(idx)) 
    {...}
    ...
private:
    std::string name;
};

终于,以上代码实现了所有需求的功能。实际上,这个方法不仅能够运作,还表现出一种独特的从容。这么说的理由:

  1. 它利用了完美转发,达成了最高效率。
  2. 它又控制了万能引用和重载的组合,而非简单地禁用之。
    该技术可以实施于重载无法避免的场合(如构造函数)。

权衡

本Item关注的头三种技术(舍弃重载传递const T&型别的形参传值)都需要对带调用的函数形参指定型别,而后两种技术(标签分派对模板的启用资格加以限制)则利用了完美转发,因此无需指定形参型别。这个基础决定(指定,还是不指定型别)不无后果。

按理说,完美转发效率更高,因为它出于和形参声明时的型别严格保持一致的目的,会避免创建临时对象。

在Person类的构造函数一例中,完美转发就允许把型如"Nancy"的字符串字面量转发给某个接受std::string的构造函数。而未使用完美转发的技术则一定得先从字符串字面量出发创造一个临时std::string对象,方能满足Person类的构造函数形参规格。

但完美转发也有不足:

1. 针对某些型别无法实施完美转发,尽管他们可以被传递到接受特定型别的函数,Item30探索了这些完美转发失败案例。
2. 在客户传递了非法形参时,错误信息的可理解性不足。

例如,假设在创建Person型别的对象时,传递的是个char16_t型别(C++11引进的16比特字符型别)而非char型别的字符组成的字符串:

Person p (u "Konrad Zuse");

如果是使用前三种技术,编译器会发现,可用的构造函数只能接受int或std::string。所以,他们会产生出多少算是直截了当的错误信息来解释:无法将const char16_t[12]强制转型到int或std::string。
而如果使用的是基于完美转发的技术时,情况大不一样。const char16_t型别的数组在绑定到构造函数的形参时,会一声不吭。接着,它又被转发到Person的std::string型别的成员变量构造函数中。唯有在那里,调用者的传递之物(一个const char16_t型别的数组)与所要求的形参(std::string的构造函数可接受的形参型别)之间的不匹配才会被发现。如此产生的结果错误信息,很可能,嗯,会一眼难忘。在我使用的某个编译器上,它有160多行。
为何呢?在本例中,万能引用值转发了一次(从Person类的构造函数到std::string类的构造函数)。但系统越是复杂,就越有可能某个万能引用要经由数层函数调用完成转发,才能到达决定实参型别是否可接受的的场所。万能引用转发的次数越多,某些地方出错时给出的错误信息就越让人摸不着头脑。许多程序猿都发现,即使性能是首要的关注因素,在接口中也不去使用万能引用形参,根本原因就在于这一问题。

一个可能缓解上述问题的方法static_assert

在Person例子中,我们了解到转发函数的万能引用形参应该用做std::string型别的对象初始化物,所以可以使用static_assert来验证其能够扮演这个角色。并且std::is_constructible这个型别特征能够在编译期间判定具备某个型别的对象是否从另一型别的对象触发完成构造,所以这个断言会写成这样:

class Person                                        
{
public:
    template<
    typename T,
    typename = std::enable_if_t<                    
                !std::is_base_of<Person, std::decay_t<T>::value     
                &&
                !std::is_intergral<std::remove_reference_t<T>>::value
                >
                               
            >                                  
    explicit Person(T&& n): name(std::forward<T>(n)) 
    {
      static_assert(std::is_constructible<std::string, T>::value,
      "Parameter n can't be used to construct a std::string");  
    ...}
    explicit Person(int idx): name(nameFromIdx(idx)) 
    {...}
    ...
private:
    std::string name;
};

这样写会在客户代码尝试从一个无法构造出std::string型别的对象的型别触发来创建一个Person型别的对象时,产生出该指定错误的信息。不幸的是,在本例中,static_assert位于构造函数体内,而转发代码属于成员初始化列表的一部分,位于它之前。所以产生自static_assert的提示信息,仅仅会在通常错误信息(那160多行)发生完后才姗姗来迟。

要点速记
1. 如果不使用万能引用和重载的组合,则替代方案包括使用彼此不同的函数名字、传递const T&型别的形参、传值和标签分派。
2. 经由std::enable_if对模板施加限制,就可以将万能引用和重载一起使用,不过这种技术控制了编译器可以调用到接受万能引用的重载版本的条件。
3. 万能引用形参通常在性能方面具备优势,但在易用性方面一般会有劣势。
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值