移动语义时C++11引入的重要特性之一。移动语义可以用来优化拷贝和赋值操作,可以将内部资源从源对象移动到目标对象,而不复制它们。使用移动语义的前提是移动后源对象不再需要其内部值或者状态。

移动语义对模板设计有重要影响,在泛型代码中引入了特殊规则来支持移动语义。本帖将介绍这些规则。

完美转发简介

假设想要编写转发传递实参的基本属性的泛型代码,存在以下三种情况:

1.可修改对象应使用转发,以便它们仍可修改。

2.常量对象应该作为只读对象转发。

3.可移动对象应该作为可移动对象转发。

具体来说,假如对一个类A,一个转发它的函数应该有三种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A
{
};

void foo(A &&a)
{
std::cout << "foo() for movable object\n";
}
void foo(const A &a)
{
std::cout << "foo() for constant value\n";
}
void foo(A &a)
{
std::cout << "foo() for variable value\n";
}

现在我们新建一个函数func(),它需要将传进的参数转发到foo()中,在不使用模板的情况下,我们必须对以上三种转发类型都实现一个对应的func()

1
2
3
4
5
6
7
8
9
10
11
12
void func(A &&a)
{
foo(std::move(a));
}
void func(const A &a)
{
foo(a);
}
void func(A &a)
{
foo(a);
}

注意到第一个func函数void func(A &&a)内部实现与其他两个不同,我们必须使用std::move()来处理实参,因为基于编程语言规则限制,直接传递a时是不会传递实参的移动语义的,它的行为将会和void func(A &a)中的操作相同。也就是说,如果不写std::move(),那么它将会调用void foo(A &a)函数。

如果我们想使用泛型模板来统一func()的三种情况,就会有一个问题:

1
2
3
4
template <typename T>
void func(T a) {
foo(a);
}

这个模板只能适用后两个func函数,对于第一个传递可移动实参的函数无能为力。

为此,C++11引入了完美转发参数的特殊规则。实现这一目标的惯用代码如下:

1
2
3
4
template <typename T>
void func(T&& a) {
foo(std::forward<T>(a)); // 完美转发a给foo函数
}

它在一部分性质上和std::move类似:

性质 std::move std::forward
模板参数 无,std::move() 有,std::forward<>()
触发移动语义条件 无条件触发 根据模板参数来决定是否转发潜在的移动语义

注意到我们使用了T&& aT是一个模板参数,一个错误的观点是,看上去它与A&& a功能相似,但是实际上它们有不同的语法规则:

性质 A&& T&&
声明参数 右值引用 转发引用(万能引用)
作用对象 纯右值(临时对象或者将亡值) 任何对象
可变性 总是可变,总是可以移动源对象的值 取决于传入参数

值得注意的是,这里的T必须是一个模板参数,而不能仅仅是一个依赖模板参数的其他对象,如:

1
typename T::iterator&&

这个类型就仅仅是一个右值引用而非转发引用。

使用转发饮用后,原程序修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <type_traits>
#include <utility>

class A
{
};

void foo(A &&a)
{
std::cout << "foo() for movable object\n";
}
void foo(const A &a)
{
std::cout << "foo() for constant value\n";
}
void foo(A &a)
{
std::cout << "foo() for variable value\n";
}

void func(A &&a)
{
foo(std::move(a));
}
void func(const A &a)
{
foo(a);
}
void func(A &a)
{
foo(a);
}

template <typename T>
void func(T a)
{
foo(std::forward<T>(a));
}

int main()
{
A a;
const A ca;
func(a);
func(ca);
func(A());
return 0;
}

输出如下:

1
2
3
foo() for variable value
foo() for constant value
foo() for movable object

可见完美转发完美的适配了每一种类型,并根据它们的类型差异去调用对应的函数。

特殊成员函数模板

特殊成员函数也可以是模板,如构造函数,但是可能会出现意想不到的结果。考虑如下的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Person
{
private:
std::string name;

public:
explicit Person(std::string const &name) : name(name)
{
std::cout << "Copy string-const-constructor called\n";
}
explicit Person(std::string &&name) : name(std::move(name))
{
std::cout << "Move string-constructor called\n";
}
Person(Person const &other) : name(other.name)
{
std::cout << "Copy constructor called\n";
}
Person(Person &&other) : name(std::move(other.name))
{
std::cout << "Move constructor called\n";
}
};
int main()
{
std::string str = "Hello";
Person p1(str); // 调用explicit Person(std::string const &name)
Person p2("tmp"); // 调用explicit Person(std::string &&name)
Person p3(p1); // 调用Person(Person const &other)
Person p4(std::move(p1)); // 调用Person(Person &&other)
return 0;
}

程序输出:

1
2
3
4
Copy string-const-constructor called
Move string-constructor called
Copy constructor called
Move constructor called

可以看到构建的对象都按照我们的设想调用了相应的构造函数。

现在,我们使用完美转发对前两个构造函数使用模板:

1
2
3
4
5
  template <typename T>	
explicit Person(T &&name) : name(std::forward<T>(name))
{
std::cout << "Template constructor called\n";
}

现在我们构造p1,p2两个对象,发现它们可以正常调用构造函数。但当我们使用拷贝构造函数构造p3时,却出现了问题。同时,注释掉p3的定义,发现p4可以被正常构造。

这是因为,根据C++重载解析规则,对于非常量左值的Person对象p,成员模板

1
2
template <typename T>	
Person(T &&name)

要比预定义的拷贝构造函数更匹配:

1
Person(Person const &other)

这是因为对于一个Person类型的对象,将它转化成Person const &other需要加上const限定符,而对T &&来说,只需要将T实例化为Person即可。所以如果我们想要解决这个问题,需要重写一个更匹配它的拷贝构造函数版本,也就是Person &other

1
2
3
4
Person(Person &other) : name(other.name)
{
std::cout << "Copy constructor called\n";
}

这样构造p3时就会优先调用这个匹配程度更高的拷贝构造函数。

但这样只是治标不治本,因为对于派生类来说,成员模板仍然更为匹配,而大规模写高度重复的代码又是我们极力想要避免的,并且更为严重的问题是,如果我们传入的参数不是构造Person类所需要的(传入了一个根本无法匹配的错误参数,模板参数仍会试图去匹配,如传入一个int类型的实参),那么编译器仍会报错,我们需要一个方法来禁用成员模板才能解决问题。

通过std::enable_if<>禁用模板

从C++11开始,标准库提供了一个辅助模板std::enable_if,用以在某些编译期条件下忽略函数模板。如果一个函数foo()的定义如下:

1
2
3
4
5
template <typename T>
typename std::enable_if<(sizeof(T) > 4)>::type // 注意在其<>内部加括号以免混淆大于号和尖括号
foo()
{
}

如果sizeof(T) > 4false,那么编译器会忽略掉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
2
3
4
5
template <typename T,
typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo()
{
}

enable_if<>卸载了模板参数中,如果匹配成功的话,会实例化出函数void foo(),增强代码可读性。

使用std::enable_if<>

我们现在着手解决之前的拷贝构造函数遗留下来的问题:重新回顾一下,如果我们传入的实参类型不正确时,就禁用构造函数模板的声明。为此,我们使用另一个标准类型特征——std::is_convertible<FROM, TO>,对于C++17来说,相应的函数模板声明如下:

1
2
3
4
5
6
7
8
template <typename T>
using Enable_If_String = std::enable_if_t<std::is_convertible_v<T, std::string>>;
template <typename T,
typename = Enable_If_String<T>>
explicit Person(T &&name) : name(std::forward<T>(name))
{
std::cout << "Template constructor called\n";
}

我们使用了别名模板以避免模板参数过于冗长。

下面是完整的Person类的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
template <typename STRING>
using Enable_If_String = std::enable_if_t<
std::is_convertible_v<STRING, std::string>>;
class Person
{
private:
std::string name;

public:
template <typename STRING,
typename = Enable_If_String<STRING>>
explicit Person(STRING &&name) : name(std::forward<STRING>(name))
{
std::cout << "Template constructor called\n";
}
Person(Person const &other) : name(other.name)
{
std::cout << "Copy constructor called\n";
}
Person(Person &&other) : name(std::move(other.name))
{
std::cout << "Move constructor called\n";
}
};

执行

1
2
3
4
5
6
7
8
9
int main()
{
std::string str = "Hello";
Person p1(str);
Person p2("tmp");
Person p3(p1);
Person p4(std::move(p1));
return 0;
}

结果如下

1
2
3
4
Template constructor called
Template constructor called
Copy constructor called
Move constructor called

构造函数按我们所预期的方式调用。

禁用特殊成员函数

一般来说,我们不能使用std::enable_if<>来禁用预定义的拷贝/移动构造函数和赋值运算符,因为成员函数模板并不能算作特殊成员函数,因此编译器仍会使用默认对应的构造函数。即时我们删除delete掉默认的构造函数,我们仍需要指定其模板参数,并且在一些隐式构造下会产生错误。

1
2
3
4
5
template <typename PERSON>
Person(PERSON const &other) : name(other.name)
{
std::cout << "Template copy constructor called\n";
}

有一个复杂的解决方案是:可以为const volatile实参声明一个拷贝构造函数并将其标记为deleted(即用= delete来定义它)。这样做可与防止隐式声明另一个拷贝构造函数。有了该方案,我们可以定义一个构造函数模板,对于nonvolatile类型,其优先级要比标记为deleted的拷贝构造函数更高:

添加:

1
Person(Person const volatile& other) = delete;

这样,即使是对于常规的拷贝构造,也会调用成员函数模板。