C++ Templates(4):移动与引用
1. 移动语义
C++11
的一个最突出的特性的就是移动语义,它对模版设计有着显著的影响。
1.1 完美转发
假设你想要编写泛型代码,转发参数的基本属性:
- 可变对象必须被转发,这样它们就能被继续修改;
- 常量对象应该被转发为只读对象;
- 可移动对象应该继续被转发为可移动对象。
为了在不使用模版的前提下实现这个功能,我们需要编写三种情况的代码。
#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"; }
void f(X& val) { g(val); }
void f(X const& val) { g(val); }
void f(X&& val) { g(std::move(val)); }
注意可移动对象的代码需要使用 $std::move$ ,因为当它们作为表达式使用时会被视为左值。
如果我们想要使用泛型代码,就会出现这种问题:
template<typename T>
void f(T& val) { g(val); }
上面这种形式的代码只会匹配前两种情况,不会匹配可移动对象。出于这个原因,C++11
引入了完美转发参数。
template<typename T>
void f(T&& val) { g(std::forward<T>(val)); }
注意这里使用了 $std::forward$ ,它是一个模版。不要认为模版函数中的 $T$&& 行为与非模版的 $X$&& 相同,从语法上来看,它们分别代表:
- $X$&& 是一个类型为 $X$ 的右值引用参数,只能绑定到可移动对象上,总是可变和可移动的;
- $T$&& 是一个声明为转发引用 ( $forwarding$ $reference$ ) 的模版参数 ( 也被称为通用引用 ( $universal$ $reference$ ) 。它可以绑定到可变对象、不可变对象或者可移动对象。在函数定义内,参数可以是可变的、不可变的或者可移动的。
$T$ 必须是一个模版参数,仅仅在模版内声明是不够的,比如 $typename$ $T::iterator$&& 就仅仅是一个右值引用,而不是转发引用。
1.2 特殊成员函数模版
成员函数模版也能作为特殊成员函数。
#include <utility>
#include <string>
#include <iostream>
class Person {
private:
std::string name;
public:
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";
}
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";
}
};
在上面的代码中,我们提供了拷贝构造函数和移动构造函数。现在,让我们改为泛型版本:
#include <utility>
#include <string>
#include <iostream>
class Person {
private:
std::string name;
public:
template<typename STR>
explicit Person(STR&& n) : name(std::forward<STR>(n)) {
std::cout << "TMPL-CONSTR for '" << name << "'\n";
}
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-CONST Person '" << name << "'\n";
}
};
但是上面的版本存在一些问题,当我们这样使用时:
std::string s = "sname";
Person p1(s); // TMPL-CONSTR
Person p2(p1); // 错误
Person const p2c("ctmp"); // TMPL-CONSTR
Person p3c(p2c); // COPY-CONSTR
问题在于,根据C++
的重载函数解析规则,因为模版构造函数参数没有 $const$ ,所以它对于非常量左值有着更优的匹配。而只有我们显式指定了常量左值,才能调用拷贝构造函数。你可能觉得我们只要提供一个非常量左值的拷贝构造函数就行了,但是这只能解决部分问题。假设我们的类是一个基类,那么当子类进行拷贝时,还是会匹配模版函数版本。如果我们真的想解决这个问题,需要使用 $std::enable_-if$ 。
2. enable_if
2.1 模版禁用
从C++11
开始,标准库提供了一个帮助模版 $std::enable_-if$ 来在编译期忽略模版函数。例如,你可以这样使用:
template<typename T>
typename std::enable_if<(sizeof(T) > 4>::type
foo() {}
当 $sizeof(T)$ 的值不大于 $4$ 时,$foo$ 的声明会被忽略。
$enable_-if$ 是一个 $type$ $traits$ 模版类,在编译期计算其第一个模版参数定义的表达式,根据结果,会有:
- 如果表达式值为 $true$ ,它的成员 $type$ 会表示一个类型:
- 如果未指定第二个模版参数,那么类型为 $void$ ;
- 否则,类型为第二个模版参数指定的类型;
- 如果表达式值为 $false$ ,成员 $type$ 不会被定义,这是因为模版具有
SFINAE
( $substitution$ $failure$ $is$ $not$ $an$ $error$ ) 特性,从而使得使用 $enable_-if$ 声明的函数模版会被忽略。
C++14
提供了一个对应的别名模版 $std::enable_-if_-t$ ,这样我们就可以不用声明 $typename$ 和 $::type$ 了。
注意到使用 $enable_-if$ 表达式作为返回值有点丑陋,所以,我们可以在模版参数中使用:
template<typename T,
typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {}
如果还是觉得丑,并且你想让约束更加明显,你可以使用 $using$ 定义一个别名模版:
template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;
template<typename T,
typename = EnableIfSizeGreater4<T>>
void foo() {}
2.2 使用enable_if
在上一节中我们演示了使用完美转发构造函数带来的问题,我们可以使用 $enable_-if$ 解决这个问题。这里要用到另一个标准 $type$ $traits$ ,$std::is_-convertible$ ,C++17
中可以这样写:
template<typename STR,
typename = std::enable_if<
std::is_convertible_v<STR, std::string>>>
Person(STR&& n);
如果 $STR$ 不能转为 $std::string$ ,这个函数就会被忽略。
我们也可以使用别名模版:
template<typename T>
using EnableIsString = std::enable_if_t<
std::is_convrtible_v<T, std::string>>;
template<typename STR, typename EnableIfString<STR>>
Person(STR&& n);
在C++11
,我们需要这样定义:
template<typename T>
using EnableIfString =
typename std::enable_if<
std::is_convertible<T, std::string>::value>::type;
注意 $is_-convertible$ 表示的是类型可以被隐式转换。通过 $is_-constructible$ ,我们可以检查类型是否可以显式转换,但是顺序要反过来。
template<typename T>
using EnableIfString =
std::enable_if_t<
std::is_constructible_v<std::string, T>>;
我们不能使用 $enable_-if$ 来禁用预定义的拷贝或移动构造函数以及相应的赋值运算符,因为成员函数模版不会被视为特殊成员函数,并且会在需要拷贝构造时被忽略。例如:
class C {
public:
template<typename T>
C(T const&) {
std::cout << "tmpl copy constructor\n";
}
};
C x;
C y{x}; // 仍然使用预定义的拷贝构造函数
删除预定义的拷贝构造函数也不行,这会导致对 $C$ 的拷贝产生错误。这个问题有一个麻烦的解决方案:我们可以声明一个 $const$ $volatile$ 参数的拷贝构造函数,并且删除它。这样可以阻止编译器生成默认拷贝构造函数,并且会让我们的模版拷贝构造函数被优先匹配。
class C {
public:
C(C const volatile&) = delete;
template<typename T>
C(T const&) {
std::cout << "tmpl copy constructor\n";
}
};
在这样的模版构造函数中我们可以使用 $enable_-if$ 进行额外约束,例如如果模版参数是整型,那么无法进行拷贝。
template<typename T>
class C {
public:
C(C const volatile&) = delete;
template<typename U,
typename = std::enable_if_t<
!std::is_integral<U>::value>>
C(C<U> const&) {
// ...
}
};
2.3 使用概念
即使通过别名模版,$enable_-if$ 表达式还是有些丑陋,因为它需要额外添加一个模版参数,这会让代码难以阅读和理解。
原则上,我们需要一个允许我们指定或者约束函数的语言特性,并且在限制未满足的时候可以忽略函数。这个特性就是概念 ( $concepts$ ),它允许我们定制模版需求。概念在C++20
被引入。
template<typename STR>
requires std::is_convertible_v<STR, std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
// ...
}
我们也可以自定义一个概念:
template<typename T>
concept ConvertibleToString = std::is_convertible_v<T, std::string>;
template<typename STR>
requires ConvertibleToSTring<STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
// ...
}
也可以这样使用:
template<ConvertibleToString T>
Person(STR&& n) : name(std::forward<STR>(n)) {
// ...
}
3. 值传递
当使用值传递时,每个参数都会被拷贝。对于类对象,它们一般通过拷贝构造函数构造。调用一个拷贝构造函数的开销可能很大,然而,有几种方式可以在传值时避免拷贝。事实上,编译器可能也会优化掉拷贝操作并且也可以对复杂对象采用移动操作。例如:
template<typename T>
void printV(T arg);
当通过整型调用时,实例化的代码为:
void printV(int arg);
这时会通过拷贝传递参数,无论它是一个对象、字面量或者函数返回值。
如果我们指定 $std::string$ ,函数会被实例化为:
void printV(std::string arg);
这次参数也会通过拷贝构造函数构造,但是可能开销很大,因为原则上需要进行深拷贝。然而,拷贝构造函数不一定会被调用,考虑以下例子:
std::string returnString();
std::string s = "hi";
printV(s); // 拷贝构造函数
printV(std::string("hi")); // 拷贝通常会被优化为移动
printV(returnString()); // 拷贝通常会被优化为移动
printV(std::move(s)); // 移动构造函数
第二个和第三个调用的共同点是它们都是临时对象,编译器通常会优化这种传参。从C++17
开始,这种优化是强制的。在这之前,不会优化拷贝的编译器至少会尝试使用移动。因此,值传递的函数只会在我们以左值传递参数时才可能会出现较大开销。不幸的是,这种情况很常见。
值传递还有另一种属性,当我们值传递参数时,类型会退化。这意味着原始数组会转换为指针并且它们的标识符比如 $const$ 和 $volatile$ 会被移除。这种行为是从C
继承而来的,它能在大部分时候简化对字符串常量的处理,但缺点是我们无法区分传递的是指针还是数组。
4. 引用传递
4.1 常量引用
为了避免不必要的拷贝,当传递非临时对象时,我们可以使用常量引用,例如:
template<typename T>
void printR(T const& arg);
这样的话我们传递一个对象的时候就永远不会拷贝,即使是基础类型也是一样,虽然对基础类型使用引用可能会适得其反。在底层实现中,传递引用其实就是传递变量的地址。地址会被紧凑地编码,从而使得传递变得高效。然而,传递地址可能会在编译调用者代码时产生不确定行为,因为无法确定被调用者会对该地址进行怎样的操作。理论上,被调用者可以在可用范围内对引用值进行任何修改,从而,编译器必须假设所有在缓存中的引用值在调用结束后变为不可用。之后再次使用这些值时需要重新读入,这个开销可能会很大。你可能会觉得我们传递的是常量引用,编译器难道不能推导出常量引用不会被修改吗?不幸的是,事实并非如此,因为调用者持有的非常量引用可能会修改自己引用的对象。
这个问题可以通过内联缓解,如果编译器可以内联函数,那么它就可以推理出调用者和被调用者,并且在大部分情况下,能够发现地址是否除了传递引用值之外没有被使用。函数模版通常很短,因此很可能成为内联函数。然而,如果一个模版封装了一个更复杂的算法,它就几乎不可能被内联。
当使用引用传递参数时,它们的类型不会退化。这意味着原始数组不会转为指针,标识符也不会被移除。然而,因为我们上面的调用指定了 $const$ ,所以推导出来的 $T$ 是不带 $const$ 的。例如:
template<typename T>
void printR(T const& arg);
std::string const c = "hi";
printR(c); // T被推导为std::string,参数类型是std::string const&
printR("hi"); // T被推导为char[3], 参数类型是char const(&)[3]
int arr[4];
printR(arr); // T 被推导为int[4],参数类型是int const(&)[4]
4.2 非常量引用
当你想要通过参数获取返回值时,你必须使用非常量引用 ( 除非你更喜欢指针 )。同样的,这种方式也不会调用拷贝构造函数。
template<typename T>
void outR(T& arg);
注意这种方式下无法传递右值:
std::string returnString();
std::string s = "hi";
outR(s);
outR(std::string("hi")); // 错误
outR(returnString()); // 错误
outR(std::move(s)); // 错误
你也可以传递数组,它们同样不会退化:
int arr[4];
outR(arr); // T被推导为int[4], 参数类型为int (&)[4]
因此,你可以修改元素,例如,处理数组大小:
template<typename T>
void outR(T& arg) {
if (std::is_array<T>::value) {
std::cout << "got array of " << std::extent<T>::value << " elems\n";
}
};
然而,模版在这里有点麻烦。如果你传递一个常量,参数类型也会被推导为常量,这意味着传递右值变得可行:
const std::string returnString();
std::string const c = "hi";
outR(c);
outR(returnString());
outR(std::move(c));
outR("hi");
当然,任何对这些常量的修改都是不允许的。如果你想要禁止向非常量引用传递常量对象,你可以这样做:
- 使用静态断言来产生一个编译期错误:
template<typename T>
void outR(T& arg) {
static_assert(!std::is_const<T>::value,
"out parameter of foo<T>(T&) is const");
}
- 通过 $enable_-if$ 禁用:
template<typename T,
typename = std::enable_if_t<!std::is_const<T>::value>>
void outR(T& arg);
- 通过概念约束:
template<typename T>
requires !std::is_const_v<T>
void outR(T& arg);
4.3 完美转发
使用引用传递的一个理由就是完美转发。
template<typename T>
void passR(T&& arg);
这样你就可以传递任何类型,并且不会调用拷贝构造函数。
std::string const c = "hi";
passR(c); // T被推导为std::string const&
passR("hi"); // T被推导为char const(&)[3]
int arr[4];
passR(arr); // T被推导为int (&)[4]
在上面的情况中,$passR$ 函数可以知道传递过来的参数是一个左值还是一个右值,这也是唯一一种我们能够辨别传递类型的传递办法。
这可能会让你觉得转发引用是最完美的传递方式,但是它也是存在问题的。例如:
template<typename T>
void passR(T&& arg) {
T x;
// ...
}
passR(42); // T被推导为int
int i;
passR(i); // 错误,T被推导为int&,从而x的声明错误
这种情况下转发引用就会导致一个错误。
4.3 std::ref
和std::cref
从C++11
开始,你可以让调用者决定一个函数模版参数是传值还是传引用。当一个模版以值方式声明时,调用者可以通过 $std::ref$ 和 $std::cref$ 来传递引用。
#include <functional>
template<typename T>
void printT(T arg);
std::string s = "hello";
printT(s); // 值传递
printT(std::cref(s)); // 类似于引用传递
要注意 $std::cref$ 不会改变模版函数中对参数的处理方式。相反,它用一个类包装了参数 $s$ ,这个类的行为类似于引用。事实上,它会创建一个 $std::reference_-wrapper$ 对象,这个对象引用原始参数并且会被以值方式传递。这个包装器类仅支持一种操作:隐式地将类型转换为原始类型,返回原始对象。所以,无论何时,只要你传递的对象存在一个有效操作,你就可以使用引用包装器。例如:
#include <functional>
#include <string>
#include <iostream>
void printString(std::string const& s) {
std::cout << s << '\n';
}
template<typename T>
void printT(T arg) { printString(arg); }
int main() {
std::string s = "hello";
printT(s);
printT(std::cref(s));
}
注意到编译器必须要知道先隐式转换为原始类型是必要的。出于这个原因,$std::ref$ 和 $std::cref$ 通常只有在你通过泛型代码传递对象给非泛型函数时才能正常使用。例如,直接输出泛型类型 $T$ 的对象会失败,因为 $std::reference_-wrapper$ 没有定义输出运算符。
template<typename T>
void printV(T arg) {
std::cout << arg << '\n';
}
std::string s = "hello";
printV(s);
rpintV(std::cref(s)); // 错误
因此,$std::reference_-wrapper$ 类的作用是允许你使用引用作为第一级对象,这样你可以拷贝,从而可以以值方式传递给函数模版。你也可以在类中使用它,例如,在容器内持有对象引用。但是你最后你总是需要把它转回底层类型使用。
5. 字符串常量和原始数组
到目前为止,我们已经了解了模版参数在使用字符串常量和原始数组时产生的不同效果:
- 值传递会将它们退化为指向元素类型的指针;
- 任何形式的引用传递都不会发生类型退化;
两者都有好处和坏处。当数组退化为指针,你就没有办法知道它是一个数组还是一个指针。另一方面,当参数可能是一个字符串常量时,不退化会导致不同长度的字符串代表不同的类型。
template<typename T>
void foo(T const& arg1, T const& arg2);
foo("hi", "guy"); // 错误
传值方式可以解决上面的问题:
template<typename T>
void foo(T arg1, T arg2);
foo("hi", "guy");
但是那会带来更糟糕的运行时问题:
template<typename T>
void foo(T arg1, T arg2) {
if (arg1 == arg2) { // 错误
// ...
}
}
foo("hi", "guy");
就像上面的例子展示的,你知道我们传入的是字符串,但是模版函数不知道,因为它也要处理其他方式传入的已退化的字符串常量,比如从另一个函数调用返回的字符串常量。
无论如何,在许多情况下,退化很有用,尤其是在检查两个对象是否具有或者可以转化为相同类型时,一个典型的应用就是完美转发。但是如果你想要使用完美转发,你必须声明为转发引用,那样,你可能需要显式退化。
注意其他 $type$ $traits$ 也会隐式退化,比如 $std::common_-type$ 。
5.1 字符串常量和原始数组的特殊实现
你可能会在你的实现中区分指针或者数组,当然,这需要数组传递的时候不被退化。有两种方式可以检测数组:
- 声明数组模版参数:
template<typename T, std::size_t L1, std::size_t L2>
void foo(T (&arg1)[L1], T (&arg2)[L2]) {
T* pa = arg1;
T* pb = arg2;
if (compareArrays(pa, L1, pb, L2)) {
// ...
}
}
- 使用 $type$ $traits$ 来检测数组:
template<typename T,
typename = std::enable_if_t<std::is_array_v<T>>>
void foo(T&& arg1, T&& arg2);
通常最好的办法是使用不同的函数名,当然,更好的办法是不使用数组,而是使用 $std::vector$ 或者 $std::array$ 。但是只要字符串常量存在,我们就应该要考虑它们。
6. 返回值
对于返回值,你也可以设置值返回或者引用返回。然而,返回引用可能会带来一系列潜在的问题,因为可能会引用一些你无法控制的东西。以下几种情况你应该返回一个引用:
- 返回容器或数组元素;
- 允许对类成员的写入;
- 链式调用的返回对象。
此外,读成员时返回常量引用也是很常见的。
要注意这些情况都可能因为错误使用而导致问题,例如:
std::string* s = new std::string("whatever");
auto& c = (*s)[0];
delete s;
std::cout << c; // 运行时错误
这里我们引用了字符串的元素,但是之后底层字符串不再存在,从而产生了未定义行为。这个例子虽然是故意的,但是有些情况下你很难发现这种错误,例如:
auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c; // 运行时错误
因此我们应该确保函数模版以值方式返回。然而,使用模版参数 $T$ 并不能保证它不是引用,因为 $T$ 也可以被推导为引用。甚至当 $T$ 是从一个值方式调用中推导的出来的时候,它也可以被显式指定为引用:
template<typename T>
T retV(T p);
int x;
retV<int&>(x); // T被推导为int&
为了安全,你有两种方法:
- 使用 $std::remove_-reference$ 或者 $std::decay$ :
template<typename T>
typename std::remove_reference<T>::type
retV(T p);
- 使用 $auto$ 自动类型推导:
template<typename T>
auto retV(T p);
7. 推荐的模版参数声明
在模版函数中,我们有许多不同的参数声明方式:
- 值传递:这种方式很简单,它会退化字符串常量和原始数组,但是对于大对象性能较差。调用者也可以通过 $std::cref$ 和 $std::ref$ 传递引用,但是要在可以这么做的前提下进行;
- 引用传递:这种方式通常有着更好的性能,尤其是传递:
- 已存在对象到左值引用;
- 临时对象或可移动对象到右值引用;
- 或者上面的对象到转发引用;
- 引用传递也不会退化,除了在传递字符串常量和原始数组时要特殊处理。对于转发引用,你也要意识到模版参数可能被隐式推导为引用。
对于函数模版,我们推荐:
- 默认情况下使用值传递。它简单且有效,就算对于字符串常量也是如此,在传递小参数、临时对象或者可移动对象时性能也很好。调用者也可以使用 $std::ref$ 和 $std::cref$ 来传递已存在的大对象,从而避免拷贝;
- 对于以下情况:
- 如果你需要参数作为返回值或者同时作为传入值和返回值,就需要将参数作为非常量引用传入。然而,你可能需要禁用会导致意外的常量对象;
- 如果一个模版负责转发参数,使用完美转发。这时可能需要 $std::decay$ 或者 $std::common_-type$ 来处理不同类型的字符串常量和原始数组;
- 如果性能很重要并且拷贝开销很大,使用常量引用;
- 如果你了解得够多,那就不需要遵循这些建议。然而要记住,不要根据直觉来判断性能,这样即使专家也会有判断失误的时候。
在实践中,函数模版通常不是为了任意类型的参数,而是为了应用某些限制。例如你可能知道只有某些类型的 $vector$ 才会被传递,这时,最好不要把函数声明得太泛型,因为这可能带来意想不到的副作用。例如:
template<typename T>
void printVector(std::vector<T> const& v);
这种声明可以确保 $T$ 不是引用,因为 $vector$ 不接受引用类型。以值方式传递一个 $vector$ 大部分情况下开销都很大,因为拷贝函数需要拷贝所有元素。出于这个原因,我们不建议使用值方式传递 $vector$ 。如果我们只是单纯地使用 $T$ 声明参数,那么是传值还是传引用就很难判断了。
$std::make_-pair$ 是一个展示判断参数传递机制的陷阱的好例子。它是一个用于创建 $std::pair$ 的函数模版,通过类型推导确定类型。在不同的C++
标准中,它的声明也在变化。
- 在第一个
C++
标准C++98
中,$make_-pair$ 使用引用传递来避免拷贝:
template<typename T1, typename T2>
pair<T1, T2> make_pair(T1 const& a, T2 const& b) {
return pair<T1, T2>(a, b);
}
这种方式会在传入字符串常量和原始数组时产生很大的问题。
- 因此,
C++03
中改为值传递:
template<typename T1, typename T2>
pair<T1, T2> make_pair(T1 a, T2 b) {
return pair<T1, T2>(a, b);
}
你可以在问题解法的理由中读到:“比起其他两个建议,这看起来是标准的一个很小的改变,并且解决方案的优势足以抵消任何效率问题。”
- 然而,
C++11
中,$make_-pair$ 支持移动语义,从而参数变为了转发引用:
template<typename T1, typename T2>
constexpr pair<typename decay<T1>::type, typename decay<T2>::type>
make_pair(T1&& a, T2&& b) {
return pair<typename decay<T1>::type,
typename decay<T2>::type>(forward<T1>(a),
forward<T2>(b));
}
这个实现很复杂,因为要支持 $std::ref$ 和 $std::cref$ ,所以函数通过 $std::decay$ 将 $std::reference_-wrapper$ 转为真实引用。
C++
标准库现在在许多地方使用类似的方式来完美地转发,通常也会结合 $std::decay$ 使用。