C++ Template-6 移动语意和 enable_if<>

移动语意(move semantics)是 C++11 中引入的一个重要特性。本章将会介绍移动语意的特性, enable_if<> 的使用和对于 concept 的简单介绍。

不是本系列第一篇文章,推荐阅读完前面的文章再看本文,以下是本系列文章目录。

1. 函数模板

2. 类模板

3. 非类型模板参数

4. 可变参数模板

5. 基础技术

6. 移动语意与 enable_if<>(本文)

6.移动语意和 enable_if<>

移动语意(move semantics)是 C++11 中引入的一个重要特性。在进行拷贝和赋值时,可通过将源对象的内部资源通过 move(“steal”) 到目标对象的方法(而不是拷贝的方法)来提升效率。要注意,这样做的前提是源对象不再需要它内部的值或者状态(因为原对象将要被丢弃)。

移动语意对于模板的设计具有重大意义,而且泛型代码中也引入了一些特殊规则来支持移动语意。本章节将会介绍移动语意的这些特性。

6.1 完美转发

假如你想要使用泛型代码来转发传递参数的基本属性,你需要做到:

  • 可修改对象在转发后依然是可修改的。
  • const 对象将被转发为只读对象。
  • 可以使用移动语义的对象转发后依然可以使用移动语义。

如果不使用模板的话,我们需要为这三种情况分别编写代码以达到转发的功能。例如,要在调用函数 f() 时将它的参数转发到函数 g()

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
#include <utility>
#include <iostream>

class X {
//...
};

void g (X&) {
std::cout << "g() for variable\n";
}
void g (X const&) {
std::cout << "g() for constant\n";
}
void g (X&&) {
std::cout << "g() for movable object\n";
}

// let f() forward argument val to g():
void f (X& val) {
g(val); // val is non-const lvalue => calls g(X&)
}
void f (X const& val) {
g(val); // val is const lvalue => calls g(X const&)
}
void f (X&& val) {
g(std::move(val)); // val is non-const lvalue => needs std::move() to call g(X&&)
}

int main()
{
X v; // create variable
X const c; // create constant

f(v); // f() for nonconstant object calls f(X\&) => calls g(X\&)
f(c); // f() for constant object calls f(X const\&) => calls g(X const\&)
f(X()); // f() for temporary calls f(X\&\&) => calls g(X\&\&)
f(std::move(v)); // f() for movable variable calls f(X\&\&) => calls g(X\&\&)
}

这里定义了三种不同的 f() 用来将它的参数转发给函数 g()

1
2
3
4
5
6
7
8
9
void f(X& val){
g(val); // val is non-const lvalue => calls g(X&)
}
void f(X const& val){
g(val); // val is const lvalue => calls g(X const&)
}
void f(X&& val){
g(std::move(val)); // val is non-const lvalue => needs std::move() to call g(X&&)
}

注意针对可移动对象(通过一个右值引用)编写的代码和其他代码不同:它需要 std::move(),因为根据语法规则,参数的移动语意不能被传递。尽管 val 在第三个 f() 被定义为右值引用,但它在表达式中使用时,是一个非常量左值(nonconstant lvalues),它的作用和第一个 f() 相同。如果没有使用 move(),将会调用的是针对非常量左值的函数 g(X&),而并非 g(X&&)

如果我们想要将三种情况的代码合并为一个泛型代码,我们会遇到一个问题:

1
2
3
4
template<typename T>
void f(T& val){
g(val);
}

在前两种情况下是适用的,但是在第三种情况是不适用的。

基于此,C++11 引入了特殊的规则来实现对参数的完美转发。实现这个特性惯用的代码如下:

1
2
3
4
template<typename T>
void f(T&& val){
g(std::forward<T>(val)); //perfect forward val to g()
}

注意 std::move() 并没有模板参数,而且会对传递的参数触发移动语意。然而 std::forward<>() 只会根据传递的模板参数情况决定是否转发其潜在的移动语意。

不要假想模板参数 TT&& 和具体类型 XX&& 作用是相同的。他们有不同的规则,尽管他们在语法上看起来很类似:

  • 具体类型 XX&& 是一个被定义为右值引用的参数。它一定是一个可移动对象(一个 prvalue,例如临时对象,和一个 xvalue,例如通过 std::move() 传递的参数)。它永远是可变的所以你可以一直窃取它的值。
  • 模板参数 TT&&是一个被定义为 forwarding reference(universal reference) 的参数。它一定是一个可变的,不可变的(例如 const),或者可以懂的对象。在函数定义中,该参数可能是可变的,不可变的,或者是一个你可以窃取内部数据的值。

注意 T 必须是一个模板参数的名字,只是依赖于模板参数是不行的。对于模板参数 T,如同 typename T::iterator&& 的声明只是一个右值引用,而不是一个 forwarding reference。

最后,能够实现完美转发的程序如下所示:

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
#include <utility>
#include <iostream>

class X {
//...
};

void g (X&) {
std::cout << "g() for variable\n";
}
void g (X const&) {
std::cout << "g() for constant\n";
}
void g (X&&) {
std::cout << "g() for movable object\n";
}

// let f() perfect forward argument val to g():
template<typename T>
void f (T&& val) {
g(std::forward<T>(val)); // call the right g() for any passed argument val
}

int main()
{
X v; // create variable
X const c; // create constant

f(v); // f() for variable calls f(X&) => calls g(X&)
f(c); // f() for constant calls f(X const&) => calls g(X const&)
f(X()); // f() for temporary calls f(X&&) => calls g(X&&)
f(std::move(v)); // f() for move-enabled variable calls f(X&&) => calls g(X&&)
}

当然,完美转发也可以被用于可变参数模板(参见 4.3 节)。更多关于完美转发的细节参见15.6.3 节(未写)

6.2 特殊成员的函数模板

特殊成员函数(例如构造函数)也可以使用成员函数模板,但有时可能会带来意想不到的行为。

考虑下面的这个例子:

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
#include <utility>
#include <string>
#include <iostream>

class Person
{
private:
std::string name;
public:
// constructor for passed initial name:
explicit Person(std::string const& n) : name(n) {
std::cout << "copying string-CONSTR for '" << name << "'\n";
}
explicit Person(std::string&& n) : name(std::move(n)) {
std::cout << "moving string-CONSTR for '" << name << "'\n";
}
// copy and move constructor:
Person (Person const& p) : name(p.name) {
std::cout << "COPY-CONSTR Person '" << name << "'\n";
}
Person (Person&& p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person '" << name << "'\n";
}
};

int main()
{
std::string s = "sname";
Person p1(s); // init with string object => calls copying string-CONSTR
Person p2("tmp"); // init with string literal => calls moving string-CONSTR
Person p3(p1); // copy Person => calls COPY-CONSTR
Person p4(std::move(p1)); // move Person => calls MOVE-CONST
}

Person 类中有一个 string 类型成员 name,我们对它提供了几个初始化构造函数。为了支持移动语意,我们重载了接受 std::string 类型参数的构造函数:

  • 针对调用者仍需要使用的 string 对象,name 被传递参数的拷贝完成初始化:

    1
    2
    3
    Person(std::string const& n) : name(n){
    std::cout << "coping string-CONSTR for '" << name << "'\n";
    }
  • 针对可移动的 string 对象,name 通过 std::move 将值从参数中窃取完成初始化:

    1
    2
    3
    Person(std::string&& n) : name(std::move(n)){
    std::cout << "moving string-CONSTR for'" << name << "'\n";
    }

按照预期,传递一个正在使用的值(左值)时,第一个函数会被调用,当传递一个可移动的对象时(右值),第二个函数会被调用:

1
2
3
std::string s = "sname";
Person p1(s); // init with string object => calls copying string-CONSTR
Person p2("tmp"); // init with string literal => calls moving string-CONSTR

除了以上的这些构造函数,例子中还提供了拷贝和移动构造函数用于观察 Person 对象是如何被拷贝和移动的:

1
2
Person p3(p1);             // copy Person => calls COPY-CONSTR
Person p4(std::move(p1)); // move Person => calls MOVE-CONST

现在,我们将这两个 string 的构造函数用一个泛型构造函数来将参数完美转发给成员 name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <utility>
#include <string>
#include <iostream>

class Person
{
private:
std::string name;
public:
// generic constructor for passed initial name:
template<typename STR>
explicit Person(STR&& n) : name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for '" << name << "'\n";
}

// copy and move constructor:
Person (Person const& p) : name(p.name) {
std::cout << "COPY-CONSTR Person '" << name << "'\n";
}
Person (Person&& p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person '" << name << "'\n";
}
};

当传递参数为 string,能够正常工作:

1
2
3
std::string s = "sname";
Person p1(s); // init with string object => calls copying string-CONSTR
Person p2("tmp"); // init with string literal => calls moving string-CONSTR

注意在这个例子中 p2 调用的构造函数并不会创建一个临时的 string 对象:参数 STR 会被推导为 char const[4] 类型。将 std::forward<STR> 用于指针没有多大的意义,成员 name 会被一个以 null 结尾的字符串构造。

但是当我们尝试调用拷贝构造函数式,遇到了错误:

1
Person p3(p1);            // ERROR 

而当我们用可移动对象初始化新的 Person 对象时却能够正常工作:

1
Person p4(std::move(p1)); // OK: move Person => calls COPY-CONSTR

注意拷贝一个 const 类型的 Person 对象也可以正常工作:

1
2
Person const p2c("ctmp"); // init constant object with string literal
Person p3c(p2c); // OK: copy constant Person => calls COPY-CONSTR

问题出在,根据 C++ 的重载解析规则(参见 16.2.4(未写)),对于一个非常量的左值 Person p,成员模板

1
2
template<typename STR>
Person(STR&& n)

会比拷贝构造函数(通常已经预先定义)更加匹配:

1
Person(Person const& p)

STR 可以替换为 Person&,但拷贝构造函数还需要转化为 const 类型对象。

你可能认为解决这个问题只要在提供一个支持非常量的拷贝构造函数:

1
Person(Person& p)

但是,这只能解决部分问题,以为对于子类对象,成员模板还是比拷贝构造函数更加匹配。你真正需要的是对于传递 Person 参数的对象或者是可以转化为 Person 类型参数的对象禁止成员模板。这可以通过使用 std::enable_if<> 实现,我们将在下一节介绍它。

6.3 使用 enable_if<> 禁用模板

从 C++11 开始,C++ 标准库提供了一个辅助的模板 std::enable_if<>,用于在特定的编译期条件下忽略某则函数模板。

例如,如果函数模板 foo<>() 的定义如下:

1
2
3
template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type
foo(){}

这一模板会在 sizeof(T) > 4 不为真时被忽略掉。如果 sizeof(T) > 4 为真,那么函数模板会被实例化为

1
void foo(){}

也就是说 std::enable_if 是一个 type trait,它会根据第一个模板参数给定的编译期表达式来决定其行为:

  • 如果这个表达式为 true,它的 type 成员会返回一个类型:
    • 如果没有第二个模板参数,这个类型是 void
    • 否则,这个类型是第二个模板参数。
  • 如果这个表达式为 false,它的 type 成员不会被定义。依据模板特性 SFINAE(substitute failure is not an error)参见 8.4 节(未写)),这会导致包含 enable_if 表达式的函数模板会被忽略掉。

从 C++14 起,所有的 type trait 都返回一个类型,因此有一个相应的别名模板 std::enable_if_t<>,它可以允许你省略 typename::type参见 2.8 节)。因此,在 C++14 你可以写

1
2
3
template<typename T>
std::enable_if_t<(sizeof(T) > 4)>
foo(){}

当有第二个参数传递给 enable_if<>enable_if_t<> 时:

1
2
3
4
5
template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo(){
return T();
}

当表达式为 true 时,enable_if 会被或者为第二个参数。因此如果 T 被推导为 MyType(它的 size 大于 4),那么就等效于

1
MyType foo();

注意将 enable_if 表达式放在中间是一个很笨拙的做法。因此,enable_if 通常的使用方法是添加一个额外的有默认值的函数模板参数:

1
2
3
template<typename T,
typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo(){}

如果sizeof(T) > 4,它会扩展为

1
2
3
template<typename T,
typename = void>>
void foo(){}

如果这看起来依然很笨拙,而且你想要使约束更加明显,你可以用别名模板(Alias Templates)定义你自己想要的名称:

1
2
3
4
5
6
template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;

template<typename T,
typename EnableIfSizeGreater4<T>>
void foo(){}

关于 std::enable_if 是如何实现的,参见 20.3 节(未写)

6.4 使用 enable_if<>

我们可以使用 enable_if<> 来解决 6.2 节我们遇到的问题。

在这个问题中,我们需要禁止的是模板构造函数的声明是:

1
2
template<typename STR>
Person(STR&& n);

如果传递的参数 STR 有它的右值(它是一个 std::string 类型或者是可以转化为 std::string 类型的类型)。

对于此,我们使用另一个标准库中的 type trait,std::is_convertible<FROM,TO>。在 C++ 17 中,对应的函数声明如下所示:

1
2
3
4
template<typename STR,
typename = std::enable_if_t<
std::is_convertible_v<STR, std::string>>>
Person(STR&& n);

如果 STR 可以被转换为 std::string,这个声明会被扩展为

1
2
3
template<typename STR,
typename = void>
Person(STR&& n);

如果 STR 不能转换为 std::string,那么这个函数模板会被忽略。

同样我们可以通过别名模板来为这个约束定义名称:

1
2
3
4
5
6
template<typename T>
using EnableIfString = std::enable_if_t<
std::is_convertible_v<T, std::string>>;
...
template<typename STR, typename = EnableIfString<STR>>
Person(STR&& 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
25
26
27
28
29
// specialmemtmpl3.hpp
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>

template<typename T>
using EnableIfString = std::enable_if_t<
std::is_convertible_v<T,std::string>>;

class Person
{
private:
std::string name;
public:
// generic constructor for passed initial name:
template<typename STR, typename = EnableIfString<STR>>
explicit Person(STR&& n)
: name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for '" << name << "'\n";
}
// copy and move constructor:
Person (Person const& p) : name(p.name) {
std::cout << "COPY-CONSTR Person '" << name << "'\n";
}
Person (Person&& p) : name(std::move(p.name)) {
std::cout << "MOVE-CONSTR Person '" << name << "'\n";
}
};

现在,所有的调用都符合预期:

1
2
3
4
5
6
7
8
9
10
#include "specialmemtmpl3.hpp"

int main()
{
std::string s = "sname";
Person p1(s); // init with string object => calls TMPL-CONSTR
Person p2("tmp"); // init with string literal => calls TMPL-CONSTR
Person p3(p1); // OK => calls COPY-CONSTR
Person p4(std::move(p1)); // OK => calls MOVE-CONST
}

注意,在 C++14 中,因为返回一个值的 _v 的 type trait 没有被定义,我们需要这样定义别名模板:

1
2
3
template<typename T>
using EnableIfString = std::enable_if<
std::is_convertible<T, std::string>::value>::type;

但是通过定义 EnableIfString<>,这些复杂的语法都被隐藏了。

同样注意,此处有一个可以替代 std::is_converible<> 的做法,因为它需要的类型是通过隐式转换的。通过使用 std::is_constructible<>,我们可以允许显式转换来进行初始化。但是,它的参数使用的顺序和 std::convertible<> 相反:

1
2
3
template<typename T>
using EnableIfString = std::enable_if_t<
std::is_constructible_v<std::string, T>>;

std::is_constructible<>更多信息,参见附录 D.3.2(未写);std::is_convertible<> 更多信息,参见附录 D.3.3(未写)。关于 enable_if<> 在可变参数模板的应用,参见附录 D.6(未写)

禁止特殊的成员函数

通常,我们不能使用 enable_if<> 去禁止先前定义好的拷贝/移动构造函数或者是赋值函数。这是因为成员函数模板不算为特殊成员函数,而且在拷贝构造需要时,相应的成员函数模板会被忽略。因此,可以向下面这样定义:

1
2
3
4
5
6
7
8
class C{
public:
template<typename T>
C (T const&){
std::cout << "tmpl copy constructor\n";
}
...
}

当需要拷贝 C 时,先前定义好的拷贝构造函数还能够使用:

1
2
C x;
C y{x}; // still uses the predefined copy constructor(not the member template)

(这里实际上没有方法可以使用成员模板因为这里不能够特指或者推导模板参数 T。)

没有办法可以删除先前定义好的拷贝构造函数,否则随后对于 C 的拷贝会产生错误。

但是,有一个小技巧:我们可以对于 const volatile 的参数声明拷贝构造函数然后将其标记为“删除”(定义时加上= delete)。这么做会能够避免使用其他拷贝构造函数被非显示地声明。在此基础上,我们可以定义一个构造函数模板,对于 nonvolatile 类型的参数,它会被优先匹配(相较于删除的拷贝构造):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class C
{
public:
...
// user-define the predefined copy constructor as deleted
// (with conversion to volatile to enable better matchs)
C(C const volatile&) = delete;

// implement copy constructor template with better match:
template<typename T>
C(T const&){
std::cout << "tmpl constructor\n";
}
};

现在,甚至在一般的拷贝中也会使用到模板构造函数:

1
2
C x;
C y{x}; // uses the member template

在这个模板构造函数中我们还可以通过 enable_if<> 添加一些约束。例如,当模板参数类型为整数类型时,我们要禁止拷贝对象,我们可以这么实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
class C
{
public:
...
// user-define the predefined copy constructor as deleted
// (with conversion to volatile to enable better matchs)
C(C const volatile&) = delete;

// if T is no integral type, provide copy constructor template with better match:
template<typename U,
typename = std::enable_if_t<!std::is_integral<U>::value>>
C(C<U> const&){
...
}
...
};

6.5 使用 concepts 简化 enable_if<> 表达式

即使使用了别名模板,enable_if 的语法也显得有些愚拙,因为它是用了这样的解决方法:为了达到需要的效果,添加了一个额外的模板参数,通过滥用这个参数来对函数模板添加一些特性的限制。这样的代码可读性很差而且使其余的函数模板也变得难以理解。

原则上来说,我们需要一个语言特性来允许我们对函数施加需求或者限制,当这个需求或者限制不满足时,函数会被忽略掉。

这个语言特性就是期待已久的 concepts ,它允许我们通过简单的语法就能对函数施加要求或者限制,但是 concepts 还没有进入 C++17 标准中。一些编译器对这个特性提供了试验性的支持,但是, concepts 很有可能成为 C++17 的下一个标准中得到支持。(C++20 中已经添加了对 concepts 的支持。)

借助 concepts,根据已经提议的使用方法,我们可以很简单地写出如下的代码:

1
2
3
4
5
template<typename STR>
requires std::is_convertible_v<STR,std::string>
Person(STR&& n) : name(std::forward<STR>(n)){
...
}

我们也可以将这个条件定义成一个普通的 concept

1
2
template<typename T>
concept ConvertibleToString = std::is_convertible_v<T,std::string>;

然后将这个 concept 作为条件:

1
2
3
4
template<ConvertibleToString STR>
Person(STR&& n) : name(std::forward<STR>(n)){
...
}

更多关于 concepts 的讨论参见附录 E(未写)

6.6 小结

  • 在模板中,你可以通过声明参数为一个 forwarding references(在声明的参数类型后加上 &&)并且使用 std::forward<> 实现参数的完美转发。
  • 当完美转发成员函数模板时,在拷贝或者移动对象是,它们可能比预先定义的特殊的成员函数更加匹配。
  • 通过使用 std::enable_if<>,你可以设置一个编译期的条件,当条件为否时,可以禁止这个函数模板。
  • 通过使用 std::enable_if<>,你可以因为避免构造函数模板和赋值函数模板比隐式产生的特殊成员函数更加匹配而产生的问题。
  • 你可以通过删除针对 const volatile 类型的特殊成员函数来讲特殊成员函数模板化(并且可以使用 enable_if<>)。
  • Concepts 将会让我们能够使用更直观的语法为函数模板施加限制。