C++模板基础(二)移动语义和enable_if<>
移动语义时C++11引入的重要特性之一。移动语义可以用来优化拷贝和赋值操作,可以将内部资源从源对象移动到目标对象,而不复制它们。使用移动语义的前提是移动后源对象不再需要其内部值或者状态。
移动语义对模板设计有重要影响,在泛型代码中引入了特殊规则来支持移动语义。本帖将介绍这些规则。
完美转发简介
假设想要编写转发传递实参的基本属性的泛型代码,存在以下三种情况:
1.可修改对象应使用转发,以便它们仍可修改。
2.常量对象应该作为只读对象转发。
3.可移动对象应该作为可移动对象转发。
具体来说,假如对一个类A,一个转发它的函数应该有三种情况:
1 | class A |
现在我们新建一个函数func(),它需要将传进的参数转发到foo()中,在不使用模板的情况下,我们必须对以上三种转发类型都实现一个对应的func()。
1 | void func(A &&a) |
注意到第一个func函数void func(A &&a)内部实现与其他两个不同,我们必须使用std::move()来处理实参,因为基于编程语言规则限制,直接传递a时是不会传递实参的移动语义的,它的行为将会和void func(A &a)中的操作相同。也就是说,如果不写std::move(),那么它将会调用void foo(A &a)函数。
如果我们想使用泛型模板来统一func()的三种情况,就会有一个问题:
1 | template <typename T> |
这个模板只能适用后两个func函数,对于第一个传递可移动实参的函数无能为力。
为此,C++11引入了完美转发参数的特殊规则。实现这一目标的惯用代码如下:
1 | template <typename T> |
它在一部分性质上和std::move类似:
| 性质 | std::move |
std::forward |
|---|---|---|
| 模板参数 | 无,std::move() | 有,std::forward<>() |
| 触发移动语义条件 | 无条件触发 | 根据模板参数来决定是否转发潜在的移动语义 |
注意到我们使用了T&& a而T是一个模板参数,一个错误的观点是,看上去它与A&& a功能相似,但是实际上它们有不同的语法规则:
| 性质 | A&& | T&& |
|---|---|---|
| 声明参数 | 右值引用 | 转发引用(万能引用) |
| 作用对象 | 纯右值(临时对象或者将亡值) | 任何对象 |
| 可变性 | 总是可变,总是可以移动源对象的值 | 取决于传入参数 |
值得注意的是,这里的T必须是一个模板参数,而不能仅仅是一个依赖模板参数的其他对象,如:
1 | typename T::iterator&& |
这个类型就仅仅是一个右值引用而非转发引用。
使用转发饮用后,原程序修改如下:
1 |
|
输出如下:
1 | foo() for variable value |
可见完美转发完美的适配了每一种类型,并根据它们的类型差异去调用对应的函数。
特殊成员函数模板
特殊成员函数也可以是模板,如构造函数,但是可能会出现意想不到的结果。考虑如下的例子:
1 | class Person |
程序输出:
1 | Copy string-const-constructor called |
可以看到构建的对象都按照我们的设想调用了相应的构造函数。
现在,我们使用完美转发对前两个构造函数使用模板:
1 | template <typename T> |
现在我们构造p1,p2两个对象,发现它们可以正常调用构造函数。但当我们使用拷贝构造函数构造p3时,却出现了问题。同时,注释掉p3的定义,发现p4可以被正常构造。
这是因为,根据C++重载解析规则,对于非常量左值的Person对象p,成员模板
1 | template <typename T> |
要比预定义的拷贝构造函数更匹配:
1 | Person(Person const &other) |
这是因为对于一个Person类型的对象,将它转化成Person const &other需要加上const限定符,而对T &&来说,只需要将T实例化为Person即可。所以如果我们想要解决这个问题,需要重写一个更匹配它的拷贝构造函数版本,也就是Person &other:
1 | Person(Person &other) : name(other.name) |
这样构造p3时就会优先调用这个匹配程度更高的拷贝构造函数。
但这样只是治标不治本,因为对于派生类来说,成员模板仍然更为匹配,而大规模写高度重复的代码又是我们极力想要避免的,并且更为严重的问题是,如果我们传入的参数不是构造Person类所需要的(传入了一个根本无法匹配的错误参数,模板参数仍会试图去匹配,如传入一个int类型的实参),那么编译器仍会报错,我们需要一个方法来禁用成员模板才能解决问题。
通过std::enable_if<>禁用模板
从C++11开始,标准库提供了一个辅助模板std::enable_if,用以在某些编译期条件下忽略函数模板。如果一个函数foo()的定义如下:
1 | template <typename T> |
如果sizeof(T) > 4为false,那么编译器会忽略掉foo<>()的定义,否则,函数模板会实例化为void foo()。也就是说,std::enable_if<>是一种类型特征,会对作为其第一个模板参数的编译期表达式求值,具体行为如下:
1.如果表达式的结果为true,则会由其类型成员type返回一个类型,该类型默认是void,可指定std::enable_if<>的第二个模板参数来指定type的类型。
2.如果表达式的结果为false,则其成员类型是未定的。由于名为SFINAE(替换失败不是错误)的模板特性,其具有忽略带有std::enable_if<>表达式的函数模板的效果。
自从C++14以来,所有类型特征的萃取所得都是类型,都使用了对应的别名模板代替,使其可以省略typename和::type:
1 | typename std::enable_if<(sizeof (T) > 4)>::value |
等价于
1 | std::enable_if_t<(sizeof (T) > 4)> |
这种写法看起开相当不便,它把函数返回值写在了enable_if<>中,因此,更为常用的使用方法是使用带默认值的附加函数模板实参:
1 | template <typename T, |
将enable_if<>卸载了模板参数中,如果匹配成功的话,会实例化出函数void foo(),增强代码可读性。
使用std::enable_if<>
我们现在着手解决之前的拷贝构造函数遗留下来的问题:重新回顾一下,如果我们传入的实参类型不正确时,就禁用构造函数模板的声明。为此,我们使用另一个标准类型特征——std::is_convertible<FROM, TO>,对于C++17来说,相应的函数模板声明如下:
1 | template <typename T> |
我们使用了别名模板以避免模板参数过于冗长。
下面是完整的Person类的定义:
1 | template <typename STRING> |
执行
1 | int main() |
结果如下
1 | Template constructor called |
构造函数按我们所预期的方式调用。
禁用特殊成员函数
一般来说,我们不能使用std::enable_if<>来禁用预定义的拷贝/移动构造函数和赋值运算符,因为成员函数模板并不能算作特殊成员函数,因此编译器仍会使用默认对应的构造函数。即时我们删除delete掉默认的构造函数,我们仍需要指定其模板参数,并且在一些隐式构造下会产生错误。
1 | template <typename PERSON> |
有一个复杂的解决方案是:可以为const volatile实参声明一个拷贝构造函数并将其标记为deleted(即用= delete来定义它)。这样做可与防止隐式声明另一个拷贝构造函数。有了该方案,我们可以定义一个构造函数模板,对于nonvolatile类型,其优先级要比标记为deleted的拷贝构造函数更高:
添加:
1 | Person(Person const volatile& other) = delete; |
这样,即使是对于常规的拷贝构造,也会调用成员函数模板。

