这是c++ 11新添加的特性,设计的目的是为了避免不必要的拷贝开销,通过资源所有权的转移提升性能,它可以在不拷贝的情况下移动大型资源,它所做的就是将现有资源的所有权交给其他对象,而传统的拷贝需要进行一次内存分配这是很大的开销,移动语义相较于拷贝性能提升相当明显,移动后的源对象处于有效但未指定状态,即保证可安全析构和重新赋值,但具体成员值由实现决定(可能为空、残留值等)。继续访问其值前必须先重置状态。
- 标准规定,移动后的原对象必须仍然可安全析构,但其内容可能为空或残留旧数据(具体取决于标准库实现)。
std::string str = "hello world";
std::string str1 = std::move(str);
std::cout << "str1: " << str1 << std::endl;
std::cout << "str: " << str << std::endl;
str = "test";
std::cout << "str: " << str << std::endl;
// 输出
str1: hello world
str:
str: test
我们可以看到触发移动语义后,原始的str被置为空了,这时候是不建议再访问它的,因为这是未定义的,不过我们可以为被移动的对象重新赋值,这是合法且安全的行为
在继续之前我们先讲两个基础概念
右值引用与std::move函数
c++中有左值与右值的区分,左值可以理解为一个长期存在的对象,而临时对象 或 即将销毁的对象就是右值,像我们创建的变量与new出来的对象都是左值,而函数的返回值与由std::move标记的对象等都是右值
在c++ 11引入了一个新的类型概念,右值引用使用&&
符号定义,例如 int&& rref
,作用就是绑定到 临时对象(右值) 或 即将销毁的对象,标识可以安全“移动”资源的对象,从而优化资源管理。
int func1(){
return 1;
}
int a = 10; // a 是左值
int& ref = a; // ref 是左值引用
42; // 字面量是右值
a + 5; // 表达式结果是右值
std::move(a); // 返回右值引用
int&& p = std::move(a);// 右值引用
int&& pp = func1();// 右值引用
需要注意std::move函数的作用是,将对象 强制转换为右值,表示其资源可被移动,此函数并没有实现移动,只是标记了某个对象为可移动的,这一点很重要,不要移动调用了std::move就实现了移动
上面的代码只是作为演示,在实际使用中int
等基本类型的移动与拷贝无区别,直接赋值即可。
移动构造与移动赋值运算符
几个关键的知识点了解完了,下一步就需要来实现移动了,我们需要实现移动构造与移动赋值运算符,来实现移动对象的功能
class Test {
public:
// 构造函数
Test(const std::string& s) : str(s) {
std::cout << "Test(const std::string&)" << std::endl;
}
// 移动构造函数(标记 noexcept)
Test(Test&& s) noexcept
: str(std::move(s.str)), // 调用 string 的移动构造
i(s.i) { // 直接拷贝 int
std::cout << "Test(Test&&)" << std::endl;
}
// 拷贝构造函数
Test(const Test& s) : str(s.str), i(s.i) {
std::cout << "Test(const Test&)" << std::endl;
}
// 移动赋值运算符(标记 noexcept)
Test& operator=(Test&& s) noexcept {
std::cout << "operator=(Test&&)" << std::endl;
// 自赋值检查
if (this != &s) {
str = std::move(s.str); // string 的移动赋值
i = s.i; // 直接拷贝 int
}
return *this; // 正确返回 Test&
}
~Test() = default; // 默认析构
std::string getStr() const { return str; }
private:
std::string str;
int i = 15; // 默认值
};
// 测试代码
int main() {
Test t("hello");
Test t1(t);
std::cout << "t1: " << t1.getStr() << std::endl;
std::cout << "t: " << t.getStr() << std::endl;
Test t2(std::move(t1));
std::cout << "t2: " << t2.getStr() << std::endl;
std::cout << "t1: " << t1.getStr() << std::endl;
std::cout << "t: " << t.getStr() << std::endl;
t1 = std::move(t);
std::cout << "t2: " << t2.getStr() << std::endl;
std::cout << "t1: " << t1.getStr() << std::endl;
std::cout << "t: " << t.getStr() << std::endl;
return 0;
}
// 输出
Test(const std::string&)
Test(const Test&)
t1: hello
t: hello
Test(Test&&)
t2: hello
t1:
t: hello
operator=(Test&&)
t2: hello
t1: hello
t:
这里我们实现了Test的移动构造与移动赋值函数,他们的参数都是Test&&一个右值引用,注意赋值运算符的,返回值是左值引用不是不是右值引用,函数的实现很简单对于基础类型,我们直接赋值后置0就可以,当然也可以不置0
在测试代码的输出中可以看出,使用移动语义构造t2后t1内部的str空了,并且t2获得了hello,移动赋值也实现了同样的效果,我们成功实现了移动
noexcept
在代码中出现的noexcept,很多人可能没见过,这也是移动语义的重要一环noexcept
是 C++11 引入的关键字,用于指示函数不会抛出异常。它的核心作用是优化代码性能和明确无异常。
它的重要性主要体现在两方面:
- 编译器
编译器知道函数不会抛异常后,可省略生成异常处理代码(如栈展开逻辑),生成更高效的机器码。 - 标准库优化:
这是最重要的,如果不知道这个和没学移动是一样的,下面我们通过一段代码来讲解
// 为标记 noexcept
Test(Test&& s): str(std::move(s.str)),i(s.i) {
std::cout << "Test(Test&&)" << std::endl;
}
int main() {
std::vector<Test> vec;
vec.push_back(Test("hello"));
vec.push_back(Test("hello"));
vec.push_back(Test("hello"));
return 0;
}
// 输出
Test(const std::string&)
Test(Test&&)
Test(const std::string&)
Test(Test&&)
Test(const Test&)
Test(const std::string&)
Test(Test&&)
Test(const Test&)
Test(const Test&)
我先来简单的介绍一下vector,它是一个可扩容的数组,当我们向其添加新内容时,如果已有大小不够了会自动扩容,而扩容就需要开辟新的内存空间,这个新空间是什么都没有的,需要将新老资源迁移过来
在看到输出后你可能会很惊讶,我们不是都实现了移动构造函数了吗,为什么vector在扩容的时候,会调用拷贝构造而不是移动构造吗,对于vector的扩容这件事来说移动构造完美太合适了呀,但是为什么没有使用移动构造呢。
问题就出在没有使用noexcept,我们为将移动构造函数标记为无异常。std::vector
在扩容时,是否使用移动构造函数取决于移动操作是否标记为 noexcept
。若未标记,标准库会优先选择拷贝构造函数以保证异常安全。这是 C++ 标准(C++11 及后续版本)的强制要求。
我们假设在扩容是调用了无noexcept的移动构造函数,中途发送了异常导致扩容失败,这是时候就会出现严重的数据安全问题,可以会有部分数据为被迁移到新内存,并且老的那一部分已经被清除,标准库要求容器操作(如 vector::push_back
)在发生异常时,保持原有数据不变,那为什么会默认选择拷贝构造呢,因为拷贝构造函数通常不修改源对象,即使抛异常,原数据仍完整。
// 将移动构造函数加上noexcept
// 输出
Test(const std::string&)
Test(Test&&)
Test(const std::string&)
Test(Test&&)
Test(Test&&)
Test(const std::string&)
Test(Test&&)
Test(Test&&)
Test(Test&&)
移动构造函数和移动赋值运算符应标记 noexcept
,以便标准库(如 std::vector
)优先选择移动而非拷贝。
最后总结几点注意事项
- 移动后的源对象:有效但不可依赖
- 移动操作必须标记
noexcept
- 避免自移动赋值
- 默认生成的移动操作可能不符合预期
- 移动语义不总是优于拷贝