Effective Modern C++ 条款27 熟悉替代重载通用引用的方法

浏览: 26 发布日期: 2016-12-01 分类: c++

熟悉替代重载通用引用的方法

条款26解释了对通用引用进行重载会导致很多问题,包括是独立函数和成员函数(尤其是构造函数)。不过条款26也展示了这种重载有用的地方,如果它的行为能像我们想象那样的话!本条款是探索如何获得我们期望的行为,既包括通过设计避免对通用引用进行重载,又包括使用通用引用重载时,约束它们能匹配的参数类型。

接下来的讨论是以条款26提出的例子开展的,如果你最近没有读条款26,你最好先去复习一下。

放弃重载

条款26的第一个例子,logAndAdd,完全可以避免重载通用引用的缺点——只需把本来重载的函数改个不同的名字。例如,logAndAdd两个重载,可以改成logAndAddNamelogAndAddNameIdx。额,这种方法不适用于第二个例子,即Person构造函数,因为构造函数的名字是固定的。另外,谁希望放弃重载了?

通过const T&传递参数

一种替代方法是回归C++98,用pass-by-lvalue-to-const(即const T&)代替pass-by-universal-reference(即通用引用,T&&),这种方法的劣势是没有通用引用高效。不过我们知道通用引用和重载带来的纠纷,放弃一些效率来保持代码的简单性也是很吸引人的。

通过值语义传递参数

一种经常让你走向高性能却不用增加复杂性的方法是,用值参数把引用参数替换掉。这种设计遵顼条款41的建议,当你知道要拷贝参数的时候,考虑直接以值传递对象,这里不讨论细节了。在这里,我会向你展示该技术如何在Person的例子中使用:

class Person {
public:
    explicit Person(std::string n)  // 替换 T&& 构造函数
    : name(std::move(n)) {}    // 使用std::move

    explicit Person(int idx)        // 和以前一样
    : name(nameFromIdx(idx)) {}

    ...

private:
    std::string name;
};

因为std::string没有只接受一个整型数的构造函数,所以所有的int或者类似int的参数(例如,std::size_t,short,long)都会调用int重载的构造函数。同样地,所有的std::string类型参数(包含std::string可以构造的参数,例如字符串“Ruth”)都会调用接受std::string的构造函数。因此不会让调用者出乎意料,我在想,你可能会说一些人会传递NULL和0来表示空指针,然后就出问题了(调用了int重载构造)。我想说这些人应该去参考条款8,然后反复研读,直到他们意识到使用NULL和0表示空指针会让他们遭到报应。

使用Tag dispatch

以lvalue-reference-to-const传递和值传递都不支持完美转发,如果我们使用通用引用的动机就是为了完美转发,那么我们一定要用通用引用,这没有其他办法,然后我们又不想放弃重载。那么,如果不想放弃重载,又不想放弃通用引用,那我们该如何避免重载通用引用呢?

实际上不是很难啦,重载函数的调用是对比重载函数的形参和调用端使用的实参,然后选择最佳匹配函数——考虑所有的形参和实参的结合情况。一个通用引用形参通常会精确匹配所有传进来的参数,不过,如果参数列表中除了通用引用还有其它的参数(该参数不是通用引用)。不是通用引用的参数不能匹配的话,就会在决策是淘汰带有通用引用参数的函数。这想法基于tag dispatch,然后用之前的例子来讲解比较容易理解。

我们对logAndAdd的例子来使用tafg dispatch,这是原来那例子的代码:

std::multiset<std::string> names;     // 全局数据结构

template<typename T>
void logAndAdd(T&&)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

对于这个函数来说,它工作得很好,但是当我们提出一个接受int的构造函数时,就会引出条款26的一堆问题。本条款的目的就是为了避免那些问题,比起添加重载函数,我们打算重新实现logAndAdd,让它代表两个函数,一个接受整型数,一个接受另外的参数,logAndAdd接受所有类型的参数,包括整型数和非整型数。

这两个函数真正所做的工作在命名为logAndAddImpl函数中,即,我们重载logAndAddImpl。其中一个函数接受通用引用,所以我们既有重载,又用通用引用,不过每一个函数都会有第二个形参,用来表示该参数是否为整型数,这就是条款26所讲的防止我们掉进坑的方法,因为我们安排第二个形参来决定选择哪个重载函数。

我知道,“Show me the code!”你会这样讲。没问题啊,这是一个几乎正确的版本:

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

这个函数把它的参数转发给logAndAddImpl,但它还传递了个参数用来表示参数类型T是否为整型数。至少,我们假定它是这样的。就算传给该函数的是右值整型数,也能工作。但是,就如条款28所说,如果传给通用引用name的是左值参数,那么T会被推断为左值引用,所以如果一个左值int传递给logAndAdd,T会被推断为int&。这不是整型数类型,因为引用不是整型数类型。这意味着std::is_intergral<T>在函数接受左值参数时会是false,尽管那参数真的是表示一个整型值。

意识到这个问题就相当于解决掉它了,因为C++标准库有个type traitstd::remove_reference,它所做的事情跟名字一样,也跟我们需要的那样:删除类型的引用语义。因此,logAndAdd这样写比较合适:

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

这有瑕疵。(在C++14,typename std::remove_reference<T>::type可以用std::remove_reference_t<T>代替。)

搞定了那里之后,我们可以把注意力放到被调用的函数上了(logAndAddImpl)。它有两个重载函数,第一个是只应用于非整型数上的(即std::is_integral<typename std::remove_reference<T>::type的类型是false):

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传递一个布尔值给logAndAddImpl,它表示传递到logAndAdd的实参是否为整形数类型。不过true或者false都是运行时的值,我们需要进行重载决策——编译期间的现象——来选择正确的logAndAddImpl。那意味着我们需要一个相当于true的类型,和另一个相当于false的类型。这对于C++标准库提供的std::true_typestd::false_type来说再平常不过了。如果T是整型数,那么logAndAdd传递给logAndAddImpl的参数是一个继承了std::true_type的对象,如果不是整型数就是继承std::false_type的对象。所以当logAndAdd传递过来的T不是整型数类型时,上面的重载函数是logAndImpl调用的可行的候选函数。

第二个重载覆盖相反的情况:T为整型类型。在那种情况下,logAndAddImpl只是简单地通过索引找到名字,然后把找到的名字还给logAndAdd

std::string nameFromIndx(int idx);     // 和条款26一样

// 整型参数,找到name,并以它调用logAndAdd
void logAndAddImpl(int idx, std::true_type)
{
    logAndAdd(nameFromIdx(idx);
}

通过让logAndAddImpl用索引查找名字从而传递给logAndAdd(在那里名字会被转发到另一个重载的logAndAddImpl),我们可以避免在两个重载函数都加上记录日志的代码。

在这个设计中,类型std::false_typestd::true_type是“tags”(标签),使用它们的目的是为了强迫重载决策往我们想要的方向发展。注意一下,我们没有为这些类型参数命名,因为它们在运行期间没有任何作用,实际上,我们希望编译器可以辨别出标签参数是不同寻常的,然后优化它们在运行期间的开销。(一些编译器可以这样做,至少在某些时候。)在logAndAdd里调用重载实现函数,是通过创建合适的标签对象把工作“dispatch”(调度)到正确重载函数,因此这种设计的名字是:tag dispatch。它是模板元编程的基本构件,现代C++库你看得越多,你遇到它就越多。

对于我们的目的,tag dispatch重要的不是它如何工作,而是它让我们结合通用引用和重载,却不会引起条款26描述的问题。dispatching函数——logAndAdd——接受一个不受限制的通用引用参数,但这个函数并没有进行重载。实现的函数——logAndAddImpl——被重载,一个接受通用引用参数,但决策调用哪个函数并不只是取决于通用引用参数,还要取决于标签,而标签的值被设计来保证不超过一个函数能切实可行的匹配。这样的结果是,标签的值决定了调用哪个重载函数,那么通用引用形参总能精确匹配它的实得变得无关紧要。

约束接受通用引用的模板

tag dispatch的重点是存在一个(不被重载的)函数作为用户的API,这个函数把工作调度到实现的函数。创建一个不被重载的调度函数很容易,但思考条款26的第二个问题,Person类中的完美转发构造函数,是个例外。编译器可能会生成拷贝和移动构造函数,所以如果你只写一个构造函数然后在函数体中使用tag dispatch,一些构造调用绕过了tag dispatch系统,被编译器生成的构造函数处理了。

事实上,真正的问题是:编译器生成的函数不是有时候绕过tag dispatch设计,而是从来没绕过(即从来不会调用编译器生成的函数,因为tag dispatch也是通用引用参数,回顾条款26)。当你用一个左值对象去初始化同类型的对象时,你总是想要进行拷贝构造的,不过,就像条款26展示那样,提供一个接受通用引用的构造函数后,会导致拷贝非const左值时一直调用通用引用构造。那条款也解释了当基类声明了完美转发构造后,派生类以传统方式实现它们拷贝和移动构造时,总会调用完美构造函数,尽管正确的行为是调用基类拷贝和移动构造。

对于这种情况,对通用引用重载的函数比你想象中要贪婪,tag dispatch不贪婪,但是却不是你想要的技术。你需要另外一种技术,这种技术可以让你控制调用通用引用重载函数的条件。你需要的东西,是我朋友,叫std::enable_if

std::enable_if可以让你强迫编译器当作模板不存在。这样的目标被称为是disable的。默认地,所有模板都是enable(使能)的,不过模板使用std::enable_if后,只会在满足std::enable_if指定的条件才会被使能。在我们的例子中,我们想要在传递给构造函数的参数类型不是Person时才使能完美转发构造函数,如果传递的参数类型是Person,我们打算disable完美转发构造函数(即让编译器忽略它),因为这样,当我们想要用一个Person来初始化另一个Person时,就会由类的拷贝或移动构造来处理这次调用了。

表示这种想法的方式不是很难,不过语法比较恶心,尤其是你以前没见过它,所以我们慢慢来。除了条件部分,std::enable_if有公式可以代,所以我们从那里开始。这里是Person类的完美转发构造的声明,只是展示了使用std::enable_if的最简单的方式。我只是展示了声明,因为std::enable_if不会影响到定义,所以这个函数的实现和条款26的实现相同。

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

    ...
};

想要知道第4行的代码做了什么,我只能抱歉地表明你去看源码吧,因为很复杂。在这里,我想着重讲一下condition(条件)表达式,它会控制该构造函数是否被使能。

我们想指定的条件是,T不可以是Person类型,即当T的类型和Person不同时,该模板构造才能使能。幸好有一个type trait可以决定两个类型是否相同(std::is_same),看来我们的想要的条件是!std::is_same<Person, T>::value。(注意有个“!”)。这已经很接近我们想要的了,但并不是完全正确,因为,就像条款28解释那样,当用左值初始化通用引用时,类型推断总会把T推断为左值引用。这意味着当代码是这样时,

Person p("Nancy");

auto cloneOfP(p);           // 以左值进行初始化

通用引用构造会把T推断为Person&。类型PersonPerson&是不相同的,这样的结果是std::is_same<Person, Person&>::valuefalse

如果我们仔细想想——我们所说的只有当T不是Person类型时才使能模板构造意味着什么,我们会意识到在审视T时,我们应该忽略:

  • 它是否是个引用。为了决定通用引用构造函数是否应该被使能,类型Person&Person&&都应该像Person那样处理。
  • 它是否是constvolatile。对我们来说,const Personvolatile const Personvolatile Person都应该像Person那样处理。

这意味着我们在检查T的类型和Person是否相同之前,需要一种方式来去除T类型的引用,const&&volatile。再一次,标准库以type trait的形式给予我们需要的东西,它就是std::decaystd::decay<T>::type和T相同,除了删掉了引用和cv描述符(即const和volatile)。(我在这里捏造了事实,因为std::decay就像它的名字表示那样,也可以把数组和函数类型转换成指针类型(看条款1),不过对于这里的讨论,std::decay的行为和我描述的一样。)那么。我们想要的控制使能构造函数的条件是这样的:

!std::is_same<Person, typename std::decay<T>::type>::value

即,Person类型和T类型不一样,其中忽略了T的引用、cv描述符。(就如条款9解释那样,std::decay之前的“typename”是必须的,因为std::decay<T>::type取决于模板参数T。)

把这个条件插入到上面的公式中,再把代码格式化,使人更容易看出是怎样组合起来的,从而产生了下面这个Person完美转发构造的声明:

class Person {
public:
    template<
      typename T,
      typename = typename std::enable_if<
         !std::is_same<Person,
                       typename std::decay<T>::type
                       >::value
    >
    explicit Person(T&& n);

    ...
};

如果之前你没看过这样的东西,知足吧!我把这个设计放到最后是有原因的。当你可以使用其他技术来避免混合通用引用和重载(你几乎总是可以避免),你应该使用它。然后,当你习惯了这种函数语法,这种方法也不算差。而且,它的行为是通过你的努力而来的。给定上面的构造函数,当我们用一个Person(左值或右值,const或非const,volatile或非volatile)构造另一个Person时,将永远不会调用接受通用引用的构造函数。

是不是很成功?我们做到啦!

额,等等。为了确保最终的庆典,我们还要搞定来自条款26的某个问题。

假如一个继承Person的类用传统的方式实现拷贝和移动构造:

class SpecialPerson : public Person {
public: 
    SpecialPerson(const SpecialPerson& rhs)    // 拷贝构造函数
    : Person(rhs)       // 调用了基类的完美转发构造
    { ... }

    SpecialPerson(SpecialPerson&& rhs)    // 移动构造函数
    : Person(std::move(rhs))    // 调用了基类的完美转发构造
    { ... }

    ...
};

这代码和我在条款26展示的一样,包括注释,在这里呢,依然是正确的。当我们拷贝或者移动一个SpecialPerson对象时,我们期望通过基类的拷贝或移动构造函数来拷贝或移动该对象的基类部分,不过在这些函数中,我们传递给基类构造函数的是SpecialPerson对象,然后因为SpecialPerson和Person不同(就算经常std::decay处理也是不同的),所以类型的通用引用构造函数被使能,然后它为了精确匹配Special对象很开心地实例化。这个精确匹配比起拷贝和移动构造所需的派生类到基类转换(即SpecialPerson对象转换为Person对象)要好,所以我们现在的代码,拷贝和移动SpecialPerson对象会使用完美转发构造函数来拷贝和移动它们的基类部分。这似曾相似,在条款26。

派生类只是遵循实现拷贝和移动构造正常规则,所以我们把问题定位在基类中,而且,定位在控制Person通用引用构造函数是否被使能的条件中。我们现在意识到,我们不是想要在参数类型与Person不同时使能模板构造,而是想在参数类型与Person以及由Person派生的类型不同时使能模板构造。坑爹的继承!

当你听到标准库type trait中有一个是判断一个类型是否由另一个类型派生而来时,你应该不会感到惊讶,它是std::is_base_of。当T2是由T1派生而来时,std::is_base_of<T1, T2>::valuetrue。因为类型被认为是从它自身派生而来,所以std::is_base_of<T, T>::valuetrue。这个很方便,因为我们想要修改我们的控制使能Personw完美转发构造的条件,而条件是:类型T,去除引用和cv描述符后,既不是Person类型,也不是Person派生的类型。使用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);

    ...
};

现在我们终于完成了。那是我们用C++11写的代码,如果我们用的是C++14,这份代码仍然可以工作,不过我们可以为std::enable_ifstd::decay使用别名模板来消除讨厌的“typename”和“::type”。因此产生了这份更易接受的代码:

class Person {
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来让接受通用引用的构造函数有选择性地失去资格,但我们还没看到如何应用它来区分整型数和非整型数实参。总的来说,这是我们最原始的目的,构造函数含糊不清的问题是我们在前进的道路上一直牵扯的东西。

我们所要做的所有事情是(1)添加一个处理整型参数的Person构造重载,然后(2)进一步约束模板构造,即disable掉整型参数。把这些原料加到我们之前讨论的东西的锅中,用慢火炖 ,然后欣赏成功的味道:

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))   // 和 可以转换为string的参数的构造函数
    { ... }

    explicit Person(int idx)     // 接受整型参数的构造函数
    : name(nameFromIdx(idx))
    { ... }

    ...    // 拷贝和移动构造, 等等

private:
    std::string name;
};

瞧!多么美丽的东西!好吧,好吧,这声美丽可能是从那些迷恋模板元编程的人口中发出的,不过事实上,这方法不仅能完成工作,它还能以独特的沉着来完成工作。因为它使用完美转发,提供了最高的效率,又因为它控制了通用引用和重载的结合,而不是禁用它。这项技术可以用于无法避免重载的场合(例如构造函数)。

权衡

本条款讲解的前三种技术——放弃重载、通过const T&传递参数、通过值语义传递参数——指定了函数的参数类型,而后两种技术——tag dispatch和限制模板资格——使用了完美转发,因此不用指定参数的类型。这个基本的决定——是否指定类型——有不一样的结果。

一般来说,完美转发更具效率,因为它为了保持声明参数时的类型,它避免创建临时对象。在Person构造的例子中,完美转发允许一个把类似“Nancy”的字符串转发到接受std::string的构造函数中,但是没有使用完美转发的技术一定要先从字符串中创建一个临时std::string对象,来满足Person构造函数指定的参数类型。

但是完美转发有缺点,一个是某些类型不能被完美转发,尽管它们可以被传递到函数,条款30会探索这些完美转发失败的例子。

第二个问题是是当用户传递无效参数时,错误信息的可理解性。例如,假设在创建Person对象的时候传递了个char16_t(C++11引进的一种以16位表示一个字符的类型)字符组成的字符串,而不是char

Person p(u"Konrad Zusz");    // "Konrad Zusz"是由char16_t类型字符组成

当使用本条款的前三种技术时,编译器看到可执行的构造函数只接受intstd::string,然后编译器或多或少会产生一些直观的错误信息表明:无法将const char16_t[12]转换到intstd::string

而使用基于完美转发的技术时,构造函数的通用引用绑定char16_t类型的数组不会发出任何抱怨。构造函数再把数组转发到Person的std::string成员变量的构造中,而只有在那个时刻,调用者传递的参数(一个const char16_t数组)和需要的参数(std::string构造函数可接受的参数类型)的不匹配才被发现。这样的结果是,错误信息很可能,额,令人十分深刻。在我使用的某个编译器中,错误信息有160行。

在这个例子中,通用引用只需完美转发一次(从Person构造函数到std::string构造函数),但在更复杂的系统中,很有可能一个通用引用要通过函数进行几层转发才能到达决定实参类型是否能接受的地方。通用引用转发的时间越长,出错时的错误信息就越迷惑。许多开发者发现,在性能是首先关心的场合中,接口也不会使用通用引用参数,就是因为这个原因。

在Person的那个例子中,我们知道完美转发构造函数的通用引用参数应该是std::string类型第一个初始值,所以我们的可以使用static_assert来证明通用引用参数就是扮演这个角色。std::is_constructible这个type trait可以在编译期间测试一个类型的对象是否能被另一个不同类型(或一些不同类型)的对象(或者另一些对象)构造,所以这个断言很容易写:

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
        static_assert(
          std::is_constructible<std::string, T>::value,
          "Parameter n can't be used to construct a std::string"
        );
        ...        // 构造函数的常规工作
    };

    ...
};

这样之后,如果用户代码尝试使用一个不能构造std::string的类型创建Person对象时,就会产生上面指定的错误信息。不幸的是,在这个例子中,static_assert是在构造函数体内,但完美转发的代码是成员初始化列表的一部分,在static_assert之前。在我使用的编译器,来自static_assert的漂亮、易读的错误信息会跟在常规错误信息(即,那160行错误信息)之后出现。

总结

需要记住的3点:

  • 替代结合通用引用和重载的方法包括使用有区别的函数名、以lvalue-reference-to-const形式传递参数、以值语义传递参数和使用tag dispatch
  • 借助std::enable_if限制模板容许一起使用通用引用和重载,不过它控制着编译器可以使用通用引用重载的条件。
  • 通用引用参数一般具有性能优势,但它们通常有适用性劣势。
返回顶部