本笔记为【彻底搞懂C++移动语义/左值/右值/引用!!!】 https://www.bilibili.com/video/BV17ce7zLEzu/?p=2&share_source=copy_web&vd_source=92a7dafd94e8cc1bfc97784a3732ea8d的总结整理
左值和右值 左值指的是一个指向特定内存的具有名称的值即具名对象。
如果一个值有名字,那么它就是左值。
右值为临时短暂的表达式或值,生命周期较短,没有一个稳定的、可识别的内存地址。
右值通常代表一个计算的中间结果,一个字面量或者一个即将被销毁的临时对象。
临时的,即用即弃的值,就是右值。
举例:
1 2 3 int a = 1 ;int b = 2 ;int c = a+b;
其中,a,b,c都是左值,1和2都是没有名字的字面值,为右值。a+b的结果也是右值,为临时的中间变量值。
其中,x++是右值,编译器先生成一份x值的临时复制,然后才对x递增,返回的是没有自加之前的临时版本。
++x是对x递增后,马上返回其自身,x和++x具有相同的地址,++x是一个左值。
++x可以进行赋值操作。x++作为一个右值,赋值会报错。
1 2 3 4 int get_val (int val) { return x; }
其中,x也是一个右值,因为返回的不是x本身,而是x的一个临时复制。
1 2 3 4 5 6 7 8 9 10 int set_val (int val) { int *p = &val; x=val; } int main () { set_val (2 ); }
进入函数后,形参val变成了左值,可以取地址。函数形参一定是左值,因为有名字。要注意:字符串字面量是一个左值,因为字符串字面量在C++中是一个常量字符数组,编译器会将其存储到程序的只读数据段中。程序运行开始到结束会一直存在。
左值引用 我们使用指针可以对左值取地址,但是,指针指向的位置可被任意修改。
左值引用最常见的使用场景就是:希望函数能够修改传入的参数,虽然指针可以做到,但是引用会更清晰安全。
举例:值交换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void swap_ptr (int *x, int *y) { int temp = *x; *x= *y; *y= temp; } void swap_ref (int & x, int &y) { int temp=x; x=y; y=temp; } int main () { int a=1 , b=2 , c=3 , d=4 ; swap_ptr (&a,&b); swap_ref (c,d); }
可以发现引用更加简洁。
非常量左值的引用对象必须是左值。
但是常量左值引用,不仅可以引用左值,还能引用右值:
但引用右值的作用没多大。
常量左值引用在于复制构造函数、复制赋值运算符的形参。
举例:类
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 #include "Person.h" Person::Person (const std::string& nameVal, int ageVal): name (nameVal), age (ageVal) { std::cout << "普通构造函数被调用 for: " << nameVal << std::endl; } Person::~Person () { std::cout << "析构函数被调用 for: " << name << " at " << this << std::endl; } Person::Person (const Person& other) : name (other.name), age (other.age) { std::cout << "拷贝构造函数被调用 from: " << other.name << std::endl; } Person& Person::operator =(const Person& other) { std::cout << "拷贝赋值运算符被调用 from: " << other.name << " to: " << name << std::endl; if (this == &other) { return *this ; } name = other.name; age = other.age; return *this ; } void Person::printInfo () const { std::cout << "Name: " << name << ", Age: " << age << std::endl; } #include "Person.h" #include <iostream> #include <stdio.h> #include <windows.h> void printPersonInfo (const Person& person) { std::cout << "在 printPersonInfo 函数中: " ; person.printInfo (); } Person createPerson (const char * name, int age) { Person p (name, age) ; return p; } int main () { std::cout << "当前控制台代码页: " << GetConsoleOutputCP () << std::endl; std::cout << "----- 创建对象 p1 -----" << std::endl; Person p1 ("Alice" , 25 ) ; std::cout << "\n----- 通过拷贝构造创建 p2 -----" << std::endl; Person p2 = p1; p2. printInfo (); std::cout << "\n----- 通过拷贝赋值给 p2 -----" << std::endl; Person p3 ("Bob" , 30 ) ; p2 = p3; p2. printInfo (); std::cout << "\n----- 传递常量对象 -----" << std::endl; const Person constP ("Charlie" , 40 ) ; Person p4 = constP; p4. printInfo (); std::cout << "\n----- 使用临时对象(右值) -----" << std::endl; p4 = Person ("David" , 50 ); p4. printInfo (); std::cout << "\n----- 函数按常量引用传参 -----" << std::endl; printPersonInfo (p4); std::cout << "\n----- 函数返回值(可能涉及拷贝) -----" << std::endl; Person p5 = createPerson ("Eve" , 60 ); p5. printInfo (); std::cout << "\n----- Main 函数结束,开始析构 -----" << std::endl; return 0 ; }
输出:
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 ----- 创建对象 p1 ----- 普通构造函数被调用 for : Alice ----- 通过拷贝构造创建 p2 ----- 拷贝构造函数被调用 from: Alice Name: Alice, Age: 25 ----- 通过拷贝赋值给 p2 ----- 普通构造函数被调用 for : Bob 拷贝赋值运算符被调用 from: Bob to: Alice Name: Bob, Age: 30 ----- 传递常量对象 ----- 普通构造函数被调用 for : Charlie 拷贝构造函数被调用 from: Charlie ----- 使用临时对象(右值) ----- 普通构造函数被调用 for : David 拷贝赋值运算符被调用 from: David to: Charlie 析构函数被调用 for : David at 000000 BDF61BF888 ----- 函数按常量引用传参 ----- 在 printPersonInfo 函数中: Name: David, Age: 50 ----- 函数返回值(可能涉及拷贝) ----- 普通构造函数被调用 for : Eve Name: Eve, Age: 60 ----- Main 函数结束,开始析构 ----- 析构函数被调用 for : Eve at 000000 BDF61BF5F8 析构函数被调用 for : David at 000000 BDF61BF5A8 析构函数被调用 for : Charlie at 000000 BDF61BF558 析构函数被调用 for : Bob at 000000 BDF61BF508 析构函数被调用 for : Bob at 000000 BDF61BF4B8 析构函数被调用 for : Alice at 000000 BDF61BF468 Process finished with exit code 0 .
复制构造函数的参数为什么必须是引用类型呢?
如果没有引用,other是按值传参,为了把a的参数传到other,编译器需要先把a拷贝到other,
那拷贝 a
的时候,需要调用 拷贝构造函数 —— 可问题是这个拷贝构造函数就是我们正在定义的函数! 于是就形成了一个无限递归 调用 ,编译器根本无法完成参数传递。
因此,C++ 标准规定:拷贝构造函数必须以引用的形式接收参数 ,否则无法定义。
左值引用和右值引用都是新名字而已,所以都是左值。
右值引用和移动语义 复习一下拷贝构造:
1 2 3 4 5 6 + | Person p1 | | Person p2 | | this= 0x1234 | | this= 0x5678 | | name - > 0xAAAA | = = = = = > | name - > 0xBBBB | | "Alice" | | "Alice" | +
说明:
p1
的 name
指针指向堆内存 0xAAAA
。
调用拷贝构造时,p2
重新分配了一块堆内存 0xBBBB
,把 "Alice"
复制过去。
两个对象互不影响,拥有各自的资源。
移动构造:
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 Person::Person (Person&& other) noexcept : name (other.name), age (other.age) { other.name = nullptr ; std::cout << "移动构造函数 from (对象地址=" << &other << ", 旧name指针=" << static_cast <void *>(name) << ") → 新对象地址=" << this << "\n" ; } Person& Person::operator =(Person&& other) noexcept { if (this == &other) return *this ; delete [] name; name = other.name; age = other.age; other.name = nullptr ; std::cout << "移动赋值运算符 (接管指针 " << static_cast <void *>(name) << ") 到对象地址=" << this << "\n" ; return *this ; } int main () { ...... p2 = Person ("Charlie" , 40 ); ...... } 构造前: +----------------+ | 临时Person tmp | | this =0x9999 | | name -> 0xCCCC | | "Bob" | +----------------+ 移动构造后: +----------------+ +----------------+ | 临时Person tmp | | Person p3 | | this =0x9999 | | this =0x8888 | | name -> null | =====> | name -> 0xCCCC | | | | "Bob" | +----------------+ +----------------+
说明:
tmp
是一个临时对象,原本持有堆内存 0xCCCC
。
移动构造时,p3
直接接管 tmp
的指针 0xCCCC
。
tmp.name
被置为 nullptr
,所以它析构时不会释放这块内存。
避免了深拷贝 的开销。
总结:
1 2 3 4 5 6 7 拷贝构造: 移动构造: p1(name- > 0xAAAA ) tmp(name- > 0xCCCC ) | | | | copy "Alice" | tmp.name= null | | p2(name- > 0xBBBB ) p3(name- > 0xCCCC )
拷贝 = 重新分配一块新内存 ,复制内容 。
移动 = 直接偷走旧对象的指针,旧对象清空指针 。
*为什么移动构造函数后面要写noexpect?(GPT 生成总结) *
背景:STL 容器的元素移动
比如 std::vector
扩容时,它会把旧元素转移到新分配的内存里:
1 2 3 std::vector<Person> v; v.push_back (Person ("Alice" , 20 )); v.push_back (Person ("Bob" , 30 ));
当 vector
空间不够,需要重新分配时,它要把旧的元素(Alice
)搬到新的内存。 此时有两个选择:
调用 移动构造函数 (更快)
调用 拷贝构造函数 (更安全)
如果移动构造函数可能抛异常
vector
在扩容时,搬到一半,结果某个元素的移动构造抛出了异常。
这时新内存里的部分对象构造完成,部分对象失败,旧内存里的对象可能已经被破坏(移动后被置空)。
整个容器就可能进入 不一致状态 (既不能保证强异常安全,也不能 rollback)。
为了避免这种情况,标准库****要求:只有在移动构造函数被声明为 noexcept
时,容器才会使用它 。否则容器会退而求其次,调用 拷贝构造函数 (因为拷贝一般不会抛异常)。
因此在自定义类里实现移动构造函数时,加上 noexcept
:
1 2 3 4 5 Person (Person&& other) noexcept : name (std::move (other.name)), age (other.age) { std::cout << "移动构造函数被调用 from: " << other.name << std::endl; other.age = 0 ; }
总结
没有 noexcept
STL 容器在需要移动元素时,可能会选择 拷贝构造 ,牺牲性能保证安全。
有 noexcept
STL 容器可以放心大胆地使用移动构造,获得最大性能提升。
所以 noexcept
是一个 性能保证的承诺 : 告诉编译器“移动构造不会抛异常”,容器才敢优化。