0%

重返C++:从 ref 挖到移动语义,在从 forward 挖到可变参数模板

C++ 漫游的第一部分,起因源于项目中错误的使用 std::ref 和 std::fowrad 导致了一些神奇的 bug。而 std::ref 又涉及到了引用,左右值引用又会联想到移动语义,std::forward 又常用于模板。所以以此为契机,不如仔细学习一下 C++ 中的新特性。

std::ref 用法

将一个对象作为引用传递给函数或算法,而不是按值传递。

用于 bind

std::bind 使用的是参数的拷贝而不是引用,因为 std::bind 使用的是参数的拷贝而不是引用,会拷贝参数而不是引用。因此必须显示利用 std::ref 来进行引用绑定。

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

void func(int& n1, int& n2) {
std::cout << " ============ in function ============" << std::endl;
n1 ++;
n2 ++;
std::cout << " n1 = " << n1 << " n2 = " << n2 << std::endl;
}

int main() {
int n1 = 1, n2 = 2;
std::function<void()> f = std::bind(func, n1, std::ref(n2)); // 已经按值绑定

n1 = 10;
n2 = 12;

std::cout << " ============== before ============= " << std::endl;
std::cout << " n1 = " << n1 << " n2 = " << n2 << std::endl;

f();

std::cout << " ============== after ============= " << std::endl;
std::cout << " n1 = " << n1 << " n2 = " << n2 << std::endl;

return 0;
}

输出:

1
2
3
4
5
6
============== before ============= 
n1 = 10 n2 = 12
============ in function ============
n1 = 2 n2 = 13
============== after =============
n1 = 10 n2 = 13

用于线程传参

std::thread 的构造函数基于了 bind,因此会将提供的值进行拷贝,而不会转换为预期的参数类型。如果形参声明为引用,而不传入引用,不写 ref 时会报错哦~

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

void func(std::string& str, int v) {
str = "func";
v = 12;
}

int main() {
std::string str("main");
int v = -12;

std::thread t(func, std::ref(str), v);

t.join();

std::cout << str << std::endl;
std::cout << v << std::endl;

return 0;
}

左值和右值

  • 左值:在程序中可以被寻址、具有持久存储位置的表达式。换句话说,表示一个内存位置,用于赋值表达式的左侧,可以是变量、数组或者引用等。在内存中有固定的存储位置,编译器会为其分配内存,并将地址存储到符号表中。所以在程序运行时,左值有具体的内存位置,可以通过地址访问和修改。

  • 右值:在程序中不可寻址、临时存储在寄存器中的表达式。通常是字面值、临时变量或者结算结果。不能用于赋值表达式的左侧。由于存储在寄存器或栈上,没有固定的内存位置。当编译器遇到右值时,不会为其分配内存,在内存中没有固定的位置,不能用于赋值表达式的左侧。

左值示例

1
2
3
int a = 10;
int& b = a;
b = 17; // a 会被修改为 17

以下是错误的写法:

1
2
3
4
5
6
int a = 10;
const int& b = a;
b = 17; // b 不能修改

const int a = 10;
int& b = a; // 错误,必须为 const

在下面的例子中,表达式 a + b 是一个右值,表达临时的计算结果,在内存中没有固定的存储位置。

1
2
3
int a = 42;  
int b = a;
int c = a + b;

在下面的例子中:

  • int v = func(),创建一个左值,并赋值为引用中的值,因此修改 v 时,不会修改全局变量 val
  • int& v = func(),会创建 val 的引用,因此修改 v 时会修改全局变量 val
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <functional>

int val = -1;

int& func() {
return val;
}

int main()
{
int v = func(); // 不会修改
int& v = func(); // 会修改

v = 1;

std::cout << val << std::endl;

return 0;
}

右值引用与移动语义

在下面的例子中,a_ref * 2 是临时的右值,绑定到右值引用上。因此 b 的值为 26。

1
2
3
4
5
6
int a = 1;
int& a_ref = a;

a = 13;
int&& b = a_ref * 2;
std::cout << b << std::endl;

既然了解了右值引用,那么来看一下移动语义吧。下面是一个较长的使用右值引用完成移动语义的的例子:主要目的是优化临时对象的资源转移,避免不必要的拷贝动作。

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

class MyString{
public:

MyString() = default;
MyString(int* d) : _data{d} {
std::cerr << "default construct" << std::endl;
};

MyString& operator=(const MyString& other) noexcept {
this->_data = other._data;
std::cerr << "called copy assignment" << std::endl;
return *this;
}

MyString(MyString &&other) noexcept {
_data = other._data;
other._data = nullptr;
std::cerr << "called move construct" << std::endl;
}

MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete _data;
this->_data = other._data;
other._data = nullptr;
}
std::cerr << "called move assignment" << std::endl;
return *this;
}

private:
int *_data = nullptr;
};

MyString func()
{
int* d = new int();
MyString a{d};
return a;
}

int main()
{
MyString a1 = func(); // RVO,直接调用默认构造函数,构造到 a1
MyString a2;
a2 = func(); // 默认构造后,函数返回值是临时对象,属于右值,所以调用移动赋值
return 0;
}

对于第 46 行代码,由于 RVO (return value optimization,返回值优化)的存在,那么返回值将直接构造在 main 函数中的 a1 对象中,而不是在 func 函数内部创建一个临时对象并将其复制或移动到 a1 中。因此,RVO 不会调用移动构造函数或复制构造函数。

此外观察代码的 25 行,移动构造没有删除自己的 data 指针,而移动赋值删除了自己的 data 指针。这是因为:

  • 移动赋值操作符需要释放资源是因为在进行移动赋值操作时,运算表达式的左侧通常已经拥有了资源。
  • 而移动构造函数用于构造新对象,新对象的 data 指针并不拥有资源。

左右值重载

我们实现一份左右值重载的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
void printValue(const int& x) {
std::cout << "lvalue ref: " << x << std::endl;
}

void printValue(int&& x) {
std::cout << "rvalue ref: " << x << std::endl;
}

int main() {
int a = 42;
printValue(a); // lvalue ref
printValue(a * 2); // rvalue ref
}

通过这些方法,我们可以充分利用左值和右值的特性,编写更高效、易于维护的代码。同时,我们还可以在特定情况下针对左值和右值的性能差异进行相应的优化。如 vectorpush_back 函数,传入左值时,会调用拷贝构造,传入右值时,调用移动构造。

1
2
3
4
5
vector<Class> v;

Class c; // default construct
v.push_back(c); // copy construct
v.push_back(std::move(c)) // move construct

完美转发

在前文中已经实现了左右值重载的代码,但是这份代码存在一些风险,来看下面的代码示例:如果通过一个中间层 func 去调用重载的 foo 函数(这在线程池中是很常见的行为),则不管对 func 传入的是左值还是右值,一定会调用左值的函数。虽然 func 函数传入的是右值,但是右值引用 param 是一个左值,所以会调用 foo(std::string& s) 函数。

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

void foo(std::string& s) {
std::cout << " left value ref " << s << std::endl;
}

void foo(std::string&& s) {
std::cout << "right value ref " << s << std::endl;
}

void func(std::string&& param) {
foo(param);
}

int main() {
std::string s{"sad"};
func(s); // left value ref test
func("test"); // left value ref test
return 0;
}

万能引用

也许你会注意到,在 func 函数中,参数的写法为:std::string&& param,考虑一种情况,如果 func 的参数很多,比如有 n 个,那么 func() 函数就需要 2 的 n 次方个 fun() 函数,显然这不是一个好方法。也就是基于此,才有了万能引用,如果用万能引用的方式,则只需一个函数即可,如下:

1
2
template<typename T>
void func(T&& param)

如果一个变量或者参数被声明为类型 T&&,且 T 是一个被推导的类型,那这个变量或参数就是一个万能引用。

引用折叠

考虑以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
void func(T&& param) { // a为万能引用
// do sth
}

int main() {
int a = 1;
int &b = a;
fun(a); // OK
fun(1); // OK
fun(b);
}

从上述代码可看,b 的类型为左值引用即 int &,如果不考虑引用折叠,那么 fun() 函数中 t 的类型就是 int & &&,显然这种声明方式,编译器会报错。而这里编译器却允许在一定的情况下进行隐含的多层引用推导,这就是 reference collapsing (引用折叠)。C++ 中有两种引用(左值引用和右值引用),因此引用折叠就有四种组合。如果两个引用中至少其中一个引用是左值引用,那么折叠结果就是左值引用;否则折叠结果就是右值引用。

1
2
3
4
5
6
7
using T = int &;
T& r1; // int& & r1 -> int& r1
T&& r2; // int& && r2 -> int& r2

using U = int &&;
U& r3; // int&& & r3 -> int& r3
U&& r4; // int&& && r4 -> int&& r4

完美转发

了解了这么多背景,如何让 func 函数正确工作呢?答案是使用完美转发 forwardstd::forward 能够保留传给形参 param 的实参的全部信息。func(param); 中参数 param 是左值,那么 func 传给函数 foo 的就是左值;func(foo + "bar"); 中参数 foo + "bar" 是右值,那么 func 传给函数 foo的就是右值。

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

void foo(std::string& s) {
std::cout << " left value ref " << s << std::endl;
}

void foo(std::string&& s) {
std::cout << "right value ref " << s << std::endl;
}

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

int main() {
std::string s{"val1"};
func(s);
func("val2");
return 0;
}

而完美转发的引用也必须满足以下几个条件:

  1. std::forward 只能用于模板类型和 auto 类型,不能用于普通类型;
  2. std::forward 只有在函数模板中才有意义,因为只有函数模板才能推导出参数的具体类型,从而进行转发;
  3. std::forward 的参数必须是一个万能引用,否则会导致编译错误。

额外的,forward 的外观非常具有迷惑性,又是尖括号又是圆括号的。实际上,forward 的用法非常单一:永远是 forward<T>(t) 的形式,其中 Tt 变量的类型。利用同样是 C++11decltype 就能获得 t 定义时的 T

1
2
3
void some_func(auto &&arg) {
other_func(std::forward<decltype(arg)>(arg));
}

所以 std::forward<decltype(arg)>(arg) 实际才是 forward 的正确用法,只不过因为大多数时候你是模板参数 Arg &&,有的人偷懒,就把 decltype(arg) 替换成已经匹配好的模板参数 Arg 了,实际上是等价的。我们可以定义一个宏:

1
#define FWD(arg) std::forward<decltype(arg)>(arg)

这样就可以简化为:

1
2
3
void some_func(auto &&arg) {
other_func(FWD(arg));
}

构造函数的扩展

完美转发的东西到这里就结束了,但是看到上面满天飞的构造和赋值函数,结合 vecotr 等容器使用时很容易出错,或者说导致不必要的开销。因此额外在这里扩展一些内容:

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 <vector>

class T {
public:
int a;
T() {std::cerr << " default construct " << std::endl;};
T(const T& t) {std::cerr << " copy construct " << std::endl;}
T(const T&& t) {std::cerr << "move construct" << std::endl;}
T& operator=(const T& t) {
if (this != &t) {
a = t.a;
}
return *this;
}
T& operator=(T&& t) {
if (this != &t) {
a = t.a;
}
return *this;
}
};

int main() {
// Write C++ code here
std::vector<T*> v1;
std::vector<T*> v2; // 指针类型

T *t = new T(); // 调用 default construct
v1.push_back(t);

v2 = v1; // 不调用任何构造函数

// -------------------------

std::vector<T> v3; // 值类型
std::vector<T> v4;

v3.reserve(10);
v4.reserve(10);

T t1; // 调用 default construct
v3.push_back(t1); // 将 t1 赋值给 vector 内部的元素,需要调用一次 copy construct
v3.emplace_back(); // 调用一次默认构造,优于上面的两行代码

v4 = v3; // 将 v3 中的元素赋值给 v4,需要调用两次 copy construct

return 0;
}

因此更建议在 vector 中使用指针,或者使用 emplace_back。函数可以在容器中直接构造对象,而不是将对象拷贝或移动到容器中。这可以提高插入性能,特别是对于那些昂贵的拷贝操作或右值资源转移的对象。但是很多项目中 vectoremplace_back 用法不恰当,这会调用很多次拷贝构造,导致资源的移动:

1
2
3
4
vector<Mat> v;
for (int i = 0; i < n; i++) {
v.emplace_back(Mat(size, elem_type, ...));
}

正确用法:

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

class Test {
public:
Test() = delete;
int x, y;
Test(int a, int b) : x{a}, y{b} {
std::cerr << "default cons" << std::endl;
};

Test(Test&& a) noexcept {
std::cerr << " move cons " << std::endl;
};
};

int main() {
std::vector<Test> v1;
v1.reserve(2);
v1.emplace_back(1, 2); // 正确
v1.emplace_back(Test(3, 4)); // 错误,多走一次移动构造
return 0;
}

但是当 vector 内元素不是指针时:对于数据拷贝开销较大的对象,移动构造函数必须标注 noexcept 关键字,否则扩容时会走拷贝构造带来开销。因为当 push_back、insert、reserve、resize 等函数导致内存重分配时,或当 insert、erase 导致元素位置移动时,vector 会试图把元素“移动”到新的内存区域。vector 通常保证强异常安全性,如果元素类型没有提供一个保证不抛异常的移动构造函数,vector 通常会使用拷贝构造函数。因此,对于拷贝代价较高的自定义元素类型,我们应当定义移动构造函数,并标其为 noexcept。额外的,上面的代码之中:如果我提供了移动构造函数而没有手动提供拷贝构造函数,那后者自动被禁用。

可变参数模板

也许已经看到了,完美转发通常会配合模板一起使用。我对模板的认知仅限于以下简单的函数:

1
2
3
4
5
6
7
8
9
template <typename T>
T add(const T& a, const T& b) {
return a + b;
}

int main() {
int result1 = add(1, 2); // 实例化为 int 类型的 add 函数
double result2 = add(1.5, 2.5); // 实例化为 double 类型的 add 函数
}

那么在文章末尾,将学习一些模板的入门用法:可变参数模板。来看一个代码例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream> 

template<typename T>
void print_sum(T a, T b) {
std::cout << " a + b = " << a + b << std::endl;
}

template <typename Func, typename... Args>
auto perfect_forward(Func&& func, Args&&... args) {
return func(std::forward<Args>(args)...);
}

int main() {
perfect_forward(print_sum<int>, 2, 3);
}

写到这里,感觉代码难度忽然有所提升,主要的难点是perfect_forward 这个函数,而他常出现在各种线程池中或者作为中间层被调用,还是有必要来学习一下。

函数相关

首先使用 Func&& func 以万能引用的形式来接收一个函数,这个在上一节介绍过。

使用 func(std::forward<Args>(args)...) 来调用函数,并获取返回值。其中,std::forward<Args>(args)... 是可变参数模板,能接收任意长度的参数。在这里的意思就是将函数的参数,也就是 2 和 3 以完美转发的形式传递给 func 函数,执行后获取返回值。那么接下来看一看 args... 到底是个什么。

参数包

可变参数模板和普通模板在语义上是一样的,但是在写法上有所区别:在 typename 后面添加省略号:

1
template<typename ... Args>

这就表示 Args 是一个模板参数包,其中可能包含了 0 个或者多个模板参数。而随后的 Args&&... args 就是函数参数包,以万能引用的形式来接收参数。看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream> 

template<typename ... Args>
void func(Args ... args) {
std::cout << sizeof...(args) << std::endl;
}

int main() {
func(); // 0
func(1); // 1
func(1, 2, 3, 4); // 4
func(2, "test"); // 2
return 0;
}

另外,... 可以接受 0 个或者任意数量的参数,但是可以通过添加一个额外的类型参数,强制模板必须接受一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
template<typename Head, typename ... Args> 

void func(Head h, Args ... args) {
std::cout << sizeof...(args) << std::endl;
}

int main() {
func(1); // 0
func(1, 2, 3, 4); // 3
func(2, "test"); // 1
return 0;
}

参数包展开

递归展开

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

template<typename T>
T sum(T val) {
return val;
}

template<typename T, typename ... Args>
T sum(T first, Args ... args) {
return first + sum<T>(args...);
}

int main() {
int v = sum(1, 2, 3);
std::cout << v << std::endl;

v = sum(1, 2, 3, 4, 5);
return 0;
}

在递归体函数中,我们将函数参数包的首个元素取出来,参数包 Args... 在展开的过程中递归调用自己,每调用一次参数包中的参数就会少一个,直到所有的参数都展开为止,当没有参数时,则调用非模板函数 sum 终止递归过程。可以通过这种方式实现一个简单的打印多组内容的日志函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream> 

template <class T>
void log(T t) {
std::cout << t << std::endl;
}

template <typename T, typename ... Args>
void log(T first, Args... args) {
std::cout << first << " ";
log(args...);
}

int main() {
log("[warning]", "some thing wrong");
log("[ error]", "some thing fatal");
return 0;
}

逗号表达式展开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream> 

template <class T>
void printarg(T t) {
std::cout << t << std::endl;
}

template <class ...Args>
void expand(Args... args) {
int arr[] = {(printarg(args), 0)...};
}

int main() {
expand(1, 2, 3, 4);
return 0;
}

这种展开参数包的方式,不需要通过递归终止函数,是直接在 expand 函数体中展开的。printarg 不是一个递归终止函数,只是一个处理参数包中每一个参数的函数。这种就地展开参数包的方式实现的关键是逗号表达式。我们知道逗号表达式会按顺序执行逗号前面的表达式,比如:

1
d = (a = b, c); 

这个表达式会按顺序执行:b 会先赋值给 a,接着括号中的逗号表达式返回 c 的值,因此 d 将等于 c

expand 函数中的逗号表达式:(printarg(args), 0),也是按照这个执行顺序,先执行 printarg(args),再得到逗号表达式的结果 0。同时还用到了 C++11 的另外一个特性——初始化列表,通过初始化列表来初始化一个变长数组, {(printarg(args), 0)...} 将会展开成:

1
((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0)), ...

最终会创建一个元素值都为 0 的数组 int arr[sizeof...(Args)]。由于是逗号表达式,在创建数组的过程中会先执行逗号表达式前面的部分 printarg(args) 打印出参数,也就是说在构造 int 数组的过程中就将参数包展开了,这个数组的目的纯粹是为了在数组构造的过程展开参数包。

  • 递归包扩展方式:
    • 优点:实现更加灵活,我们可以针对递归终止条件进行不同于递归体函数的操作
    • 缺点:递归函数会反复压栈弹栈,因此运行时会消耗更多资源

若递归终止条件没有声明在递归体的作用域内,则会导致无限循环(不过所幸的是编译器可以检查出这样的问题)。

  • 逗号表达式扩展方式:
    • 优点:执行的效率高于递归的方式;
    • 缺点:只能适用于对参数包中的每一个参数都执行相同操作的场景;

浪费了一部分的内存空间,构造出来的初始化列表没有任何作用。

参考

感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章