C++拷贝语义和移动语义,左值引用与右值引用
一般的左右值
//函数的返回值是右值
//函数的形参是左值
//一般来说字面量是右值,但字符串字面量是左值
//x++返回值是一个临时变量(右值)
//++x是左值
//临时运算(a+b)是右值
核心:函数重载决议
C++ 编译器根据你传递的参数的值类别(是左值还是右值)来决定调用哪个重载版本的函数(拷贝构造函数还是移动构造函数)。
1. 常左值引用 (const T&
)
- 它能绑定到什么? 几乎任何东西。
- 左值(有名字的、有持久状态的对象)
- 右值(临时对象、字面量)
- 为什么是
const
?- 因为拷贝操作的目的是创建副本,而不应该修改源对象。使用
const
引用保证了这一点,并且使得它可以绑定到临时对象(右值)。
- 因为拷贝操作的目的是创建副本,而不应该修改源对象。使用
- 何时触发?
- 当你传递一个左值(例如一个具名变量)时,编译器必须选择这个版本,因为左值不能绑定到非常量的右值引用 (
T&&
)。 - 当你传递一个右值时,编译器也可以选择这个版本(因为
const T&
能绑定右值),但如果有更匹配的(即接受T&&
的),它会优先选择更匹配的。
- 当你传递一个左值(例如一个具名变量)时,编译器必须选择这个版本,因为左值不能绑定到非常量的右值引用 (
简单说:const T&
是一个“万能”的引用,用于表示“我只需要读你的数据,用来做一个副本”。
左值引用在拷贝构造函数中的必要性
如果拷贝构造函数的参数不是引用,确实会导致无限递归。
核心原因:值传递(Pass-by-value)本身就是在调用拷贝构造函数
在 C++ 中,当你以值的方式(而不是引用或指针)传递一个对象给函数时,编译器必须在内存中创建这个参数的一个副本。创建这个副本的标准方式就是调用该对象的拷贝构造函数。
现在,让我们看看如果拷贝构造函数本身采用值传递会发生什么。
错误示例分析
假设我们错误地这样定义拷贝构造函数:
class MyClass {
public:int data;// 错误的拷贝构造函数!参数是值传递 (MyClass other)MyClass(MyClass other) { // 注意:这里没有 & this->data = other.data;std::cout << "Copy Constructor (wrongly) called\n";}// 正常的构造函数MyClass(int d) : data(d) {}
};
现在,当我们尝试用一个 MyClass
对象来初始化另一个时:
MyClass objA(10);
MyClass objB = objA; // 这里会发生什么?
编译器看到 MyClass objB = objA;
,它需要调用 MyClass
的拷贝构造函数来创建 objB
。
让我们一步步模拟编译器的行为:
- 第一次调用:为了调用拷贝构造函数
MyClass(MyClass other)
,它需要将实参objA
传递给形参other
。 - 创建形参:形参
other
是 值传递 的。这意味着需要创建objA
的一个副本来初始化other
。 - 如何创建副本? 创建
objA
的副本,需要调用MyClass
的… 拷贝构造函数! - 第二次调用:于是,编译器准备调用
MyClass(MyClass other)
来创建other
这个形参。同样,为了这次调用,它需要将objA
传递给新的other
形参。 - 创建新的形参:这个新的
other
形参又是值传递的,所以需要再次创建objA
的副本… - 第三次调用:于是,编译器再次准备调用
MyClass(MyClass other)
… - 无限循环:这个过程会永无止境地进行下去,直到堆栈空间被耗尽(Stack Overflow)。
这个过程可以可视化如下:
MyClass objB = objA;-> 调用 MyClass(MyClass other) 【第一次调用】-> 为了传递参数,需要创建 objA 的副本给 other-> 调用 MyClass(MyClass other) 【第二次调用】-> 为了传递参数,需要创建 objA 的副本给 other-> 调用 MyClass(MyClass other) 【第三次调用】-> ... (无限递归,直到栈溢出)
正确的解决方案:使用引用传递
为了解决这个问题,拷贝构造函数的参数必须是引用。引用本质上是一个别名,是对象的另一个名字。传递引用不会创建对象的副本,因此也不需要调用拷贝构造函数。
class MyClass {
public:int data;// 正确的拷贝构造函数!参数是常左值引用 (const MyClass& other)MyClass(const MyClass& other) { // 注意:这里有 &this->data = other.data;std::cout << "Copy Constructor (correctly) called\n";}MyClass(int d) : data(d) {}
};
现在,同样执行 MyClass objB = objA;
:
- 调用:编译器需要调用
MyClass(const MyClass& other)
。 - 传递参数:形参
other
是一个引用,它直接绑定到objA
。不需要创建任何副本。 - 执行函数体:函数体正常执行,用
objA.data
来初始化objB.data
。 - 完成:只有一次拷贝构造调用,成功创建
objB
。
为什么是 const
引用?
- 保证不修改源对象:拷贝操作的目的是创建副本,不应该修改原始对象。
const
关键字确保了这一点。 - 允许绑定到右值:
const
引用可以绑定到临时对象(右值),虽然这在拷贝构造的场景中不常见,但增加了灵活性。(例如MyClass obj = MyClass(10);
,虽然这里更可能触发移动构造或优化)。
特性 | 错误的方式 (值传递) | 正确的方式 (引用传递) |
---|---|---|
参数声明 | MyClass(MyClass other) | MyClass(const MyClass& other) |
传递机制 | 创建实参的副本 | 创建实参的别名(引用) |
初始化形参 other | 需要调用拷贝构造函数 | 不需要调用任何构造函数 |
结果 | 无限递归,导致栈溢出 | 正常工作,只调用一次 |
所以,拷贝构造函数的参数必须是一个引用,以避免在传递参数时发生无限递归的自我调用。这是 C++ 语法规则中一个非常基础和强制性的要求。
2. 右值引用 (T&&
)
- 它能绑定到什么? 只能绑定到右值(临时对象、被
std::move
转换后的对象)。 - 为什么不是
const
?- 因为移动操作的目的是**“偷”走源对象的资源**!这必然需要修改源对象(例如,将它的内部指针设为
nullptr
)。所以它不能是const
。
- 因为移动操作的目的是**“偷”走源对象的资源**!这必然需要修改源对象(例如,将它的内部指针设为
- 何时触发?
- 只有当你传递一个右值时,编译器才会选择这个版本。这是一个更精确、更匹配的选项。
简单说:T&&
是一个“挑剔”的引用,专门用于表示“你是一个即将消亡的临时对象,我被允许掏空你”。
实战对比:编译器如何选择?
假设我们有一个类 MyClass
,它完整定义了拷贝和移动构造函数:
class MyClass {
public:// 拷贝构造函数 (参数:常左值引用)MyClass(const MyClass& other) {std::cout << "Copy Constructor called\n";// ... 深拷贝逻辑 ...}// 移动构造函数 (参数:右值引用)MyClass(MyClass&& other) noexcept {std::cout << "Move Constructor called\n";// ...“偷”资源的逻辑,例如:// this->data_ptr = other.data_ptr;// other.data_ptr = nullptr;}// ... 其他成员 ...
};
现在看几个不同的初始化场景:
场景 1:用一个左值初始化
MyClass obj1;
MyClass obj2 = obj1; // 源是左值 obj1
obj1
是一个左值(有名字的变量)。- 它可以匹配
MyClass(const MyClass&)
(完美匹配)。 - 它不能匹配
MyClass(MyClass&&)
(因为左值不能绑定给MyClass&&
)。 - 结果:调用拷贝构造函数。
场景 2:用一个显式转换的右值初始化
MyClass obj1;
MyClass obj2 = std::move(obj1); // std::move(obj1) 将左值转换为右值
std::move(obj1)
的结果是一个右值。- std::move 是一个“强制搬家许可”。你对编译器说:“虽然 obj1 是我的常住地址,但我现在授权你,可以把它当成一个临时的快递盒来处理。我保证以后不用它了。”
- 它可以匹配
MyClass(const MyClass&)
(但没那么匹配)。 - 它更匹配
MyClass(MyClass&&)
(精确匹配)。 - 结果:调用移动构造函数。
场景 3:用一个临时对象(纯右值)初始化
MyClass obj2 = MyClass(); // MyClass() 创建一个临时对象(右值)
MyClass()
产生一个右值(临时对象)。- 它可以匹配
MyClass(const MyClass&)
。 - 它更匹配
MyClass(MyClass&&)
。 - 结果:调用移动构造函数。 (注意:编译器可能会直接优化掉这次构造,但理论上移动构造是可用的最佳选择)。
场景 4:从一个返回值的函数初始化
MyClass createMyClass() {return MyClass(); // 返回一个临时对象
}MyClass obj2 = createMyClass(); // 函数返回值是右值
createMyClass()
的返回值是一个右值。- 同样,它更匹配
MyClass(MyClass&&)
。 - 结果:调用移动构造函数。 (同样,RVO/NRVO 优化可能会发生,但语言标准保证移动是底线)。
noexcept 是一个承诺。
你把它加在函数后面,就是在对编译器和所有调用你这个函数的代码做出一个庄严的承诺:
“我保证,这个函数绝对、绝对不会抛出任何异常!”
参数类型 | 可绑定的实参类型 | 语义 | 典型用途 |
---|---|---|---|
const T& | 左值、右值 | “借我来读一下,做个副本” | 拷贝构造函数、拷贝赋值运算符 |
T&& | 右值 | “你快要死了,把资源给我吧!” | 移动构造函数、移动赋值运算符 |
这种参数类型的区分,是 C++ 标准委员会设计的一种非常巧妙的重载机制。它让编译器能根据你提供的参数是“持久的”还是“临时的”,自动选择最合适、最高效的操作:是安全地拷贝,还是高效地移动。
专业术语 | 我的比喻 | 一句话解释 |
---|---|---|
拷贝语义 | 复印机模式 | 创建一个全新的、一模一样的副本,原件不变。成本高,但安全。 |
移动语义 | 搬家模式 | 掏空原件,把内部资源(如内存)转移给新主人,原件变空壳。成本极低。 |
左值 (T& ) | 常住地址 | 有名字、有持久身份的对象(如变量)。可以放在赋值语句的左边。 |
右值 (T&& ) | 临时快递盒 | 没名字、马上要消失的临时对象(如函数返回值、字面量)。用完即弃。 |
const T& 参数 | 复印专线 | “我只读不改”,可以接收任何类型的对象(左值、右值都可以),用来安全地创建副本。 |
T&& 参数 | 搬家专线 | “我要掏空你”,只接收临时对象(右值),用来高效地转移资源。 |