0%

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

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

std::ref 用法

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

用于 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 的构造函数会将提供的值进行拷贝,而不会转换为预期的参数类型。如果形参声明为引用,而不传入引用,不写 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 的参数必须是一个万能引用,否则会导致编译错误。

构造函数的扩展

完美转发的东西到这里就结束了,但是看到上面满天飞的构造和赋值函数,结合 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 函数
}

那么在文章末尾,将学习一些模板的入门用法:可变参数模板。

参考

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

欢迎订阅我的文章