ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++右值引用与移动语义详解:性能优化必知

C++右值引用与移动语义详解:性能优化必知 C11出来十几年了到现在面试还在追着问右值引用和移动语义。我当年刚接触那会儿也觉得不就是多了一个符号吗搞这么复杂干什么。直到有一次我写一个字符串处理模块处理大量临时对象赋值性能瓶颈亮红灯才老老实实把这块啃明白。这篇东西我不讲空理论就说清楚右值引用到底是什么、移动语义解决了什么问题、怎么写才对、有哪些坑把这些搞通了你的C代码能提升一个档次。1. 右值引用C11为什么非要引入这个新东西1.1 深拷贝的性能痛点在C11之前写代码经常遇到一个让人头疼的场景函数返回一个比较大的对象比如std::vector、std::string或者把一个临时对象赋值给另一个变量编译器会调用拷贝构造函数把数据一份一份地复制。一次两次没问题但如果这个对象内部持有堆内存、文件句柄、网络连接这种资源拷贝的开销就不是单纯几个字节的复制那么简单了。举个例子你写一个函数返回一个std::string这个字符串有10万个字符在内存里是一个动态分配的堆数组。老版本的C里这个函数从内到外至少要经历一次完整的深拷贝临时字符串分配内存、逐字节复制、然后析构释放掉自己那份内存。这相当于你有一套房子的钥匙搬家的时候非要复制一套房子出来原房子还得拆掉纯纯的浪费。我刚学C的时候读到这段背景很困惑为什么不直接把那块堆内存的所有权转交给接收方呢可老标准确实做不到因为拷贝和赋值的行为只有一份定义编译器分不清你传入的是一个“用完就扔”的临时对象还是一个以后还要用的命名对象。一直到C11引入了右值引用和移动语义这个问题才算彻底解决。1.2 左右值到底是什么以及右值引用的作用先别急着看语法左右值的区分是这个特性的地基。简单的判断方法凡是能取地址、有名字、可以跨越当前表达式存活的就是左值凡是临时生成的、用完就要销毁的就是右值。int a 42; // a 是左值它有名字a 是合法的 int b a 1; // a 1 的结果是临时值是右值这里a 1产生了一个临时整数这个整数没有名字表达式结束它就被销毁了。右值引用T就是专门用来绑定这种“临死前”的对象的类型。有读者可能说那我用const T也能绑定临时对象啊以前不都这么干吗没错const T确实能接住临时对象但它只能读不能写。右值引用的真正意义在于它给了一个只对临时对象生效的、可修改的引用通道。因为临时对象马上要销毁了我们就可以安全地把它的资源“掏空”过来而不必担心影响其他变量。用生活类比来说左值引用是你把自家厨房共享给别人做饭所有权还在自己手里右值引用是你把整个厨房让渡给别人去拆搬反正你明天就要搬家了资源随便拿。这个区别是移动语义能成立的前提。1.3 std::move一个反直觉的cast很多初学者以为std::move真的“移动”了什么其实它什么也没移动它只是一个类型转换工具。源码层面看std::move本质上就是一个static_castT把一个左值强行伪装成右值从而骗过重载决议让移动构造函数有机会被调用。std::string a hello; std::string b std::move(a); // 不是把 a 的内容搬过去而是把 a 转成右值让 b 的移动构造函数接管 a 的资源这个细节很多人忽略但理解了它你就能看懂很多奇怪行为为什么std::move之后a的内容变成空字符串了因为真正干活的是std::string的移动构造函数它把a内部的堆内存指针直接偷走然后把a的指针置空避免两个对象共用内存导致双析构。std::move只是触发这个行为的扳机。强调一下std::move本身不做任何搬移动作它只是告诉编译器“我允许被移动”。这也是为什么对一个常对象const对象执行std::move只会得到const T最后还是调用拷贝构造——因为移动构造函数往往需要修改参数对象要把它置空参数类型是T绑定不了const对象。2. 移动语义落地移动构造函数和移动赋值运算符2.1 移动构造函数到底怎么写理论说再多不落地都是空的。我这里用一个自定义的MyString类来演示标准写法class MyString { public: // 普通构造函数 MyString(const char* s) { size_ strlen(s); data_ new char[size_ 1]; memcpy(data_, s, size_ 1); } // 拷贝构造 MyString(const MyString other) { size_ other.size_; data_ new char[size_ 1]; memcpy(data_, other.data_, size_ 1); } // 移动构造 MyString(MyString other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; } ~MyString() { delete[] data_; } private: char* data_; size_t size_; };移动构造的核心就三步接管对方资源直接把data_指针拿过来、把对方指针置空、把对方大小清零。没有任何内存分配动作只是指针的交接成本常数级别。注意noexcept必须加上。理由我后面详细讲这里先记住移动操作如果你能确定不会抛异常一定标记noexcept否则标准库容器扩容时会放弃移动退回拷贝。2.2 移动赋值运算符几个容易翻车的细节移动赋值比移动构造多一个难点对象已经持有资源了接管对方资源之前要先把自己当前的资源释放掉否则就是内存泄漏。MyString operator(MyString other) noexcept { if (this ! other) { delete[] data_; // 释放自己当前持有的内存 size_ other.size_; data_ other.data_; // 接管对方的资源 other.size_ 0; other.data_ nullptr; // 置空对方 } return *this; }这里最容易被忽略的就是this ! other判断。我见过一个人写移动赋值没加这个自移动判断结果代码在某个极端场景下执行了a std::move(a)先把自己资源释放了然后又把已经被释放的指针接管过来等于把垃圾变成有效对象后面析构直接崩。还有一个细节移动赋值操作里如果资源类型是智能指针或者STL容器其实可以借助它们的移动赋值来简化自己类的实现。但要注意用了STL容器不等于可以偷懒你的外层逻辑还是要把对方对象设置成有效状态。2.3 noexcept决定了移动语义能否真正生效这一节我认为是移动语义里最重要也最容易被忽略的细节。先说结论如果你的移动构造函数不是noexcept很多场景下编译器宁可拷贝也不会移动。原因是标准库容器的强异常安全保证。比如std::vector扩容时它在旧内存块上把元素搬到新内存块如果搬的过程中某个元素抛异常了老元素已经被搬走了容器就处于一个不一致的状态。为了保证异常安全std::vector内部用的是move_if_noexcept这个工具如果移动操作不会抛异常就移动否则就拷贝。也就是说你的类明明写了移动构造函数但没加noexcept扩容时从头到尾调的还是拷贝构造移动语义白写了性能提升根本看不到。我建议你写个类测一下#include vector #include chrono #include iostream struct Test { std::string s; // 故意省略移动构造的 noexcept }; // 编译器可能为 Test 默认生成移动操作但如果不是 noexceptvector 扩容会退化为拷贝实测下来noexcept版本移动扩容快好几个数量级。这个在面试里也经常问std::vector什么时候用移动构造什么时候用拷贝构造答案就是看is_nothrow_move_constructible。3. 完美转发移动语义的进阶玩法3.1 引用折叠规则前面讲的是右值引用的直接用法但实际工程里移动语义还有一个硬核搭档模板转发。如果你写过通用工厂函数或者封装性的模板代码一定遇到过这个问题模板参数到底折叠成左值引用还是右值引用C11引入了引用折叠规则简单说就是当类型推导遇到T时如果传入的是左值T会被推导成T最终参数类型是T即T 折叠为T如果传入的是右值T推导成普通类型参数类型就是T。template typename T void Forward(T arg) { // arg 的类型取决于传进来的是左值还是右值 }这个特性让T变成了万能引用forwarding reference——既能接左值也能接右值。但问题来了在函数内部arg本身是一个有名字的变量它是一个左值。如果你直接把它传给下一个函数右值信息就丢失了移动语义无法继续传递。3.2 std::forward 和 std::move 的区别这时候就需要std::forward出马。它做的事情是如果原来的实参是右值就把它转回右值如果实参是左值就保持左值。所以std::forward本质上是一个条件转换版本std::move。使用场景一般是转发参数到构造函数或者其他函数template typename T void Process(T arg) { // 把 arg 以原始的值类别继续转发 Store(std::forwardT(arg)); }这里如果不用std::forwardT(arg)而是直接写Store(arg)那么即使调用方传了一个右值进来到了Store里arg仍然是一个左值还是会触发拷贝移动语义就被吃掉了。我一开始总搞混这两个工具后来记了个口诀std::move是“无条件转右值”用在明确希望当前对象可以被掏空的场景std::forward是“条件性转发”用在模板函数里保留参数原有的左右值属性。理解了这个区别看到std::move和std::forward同时出现在代码里就不会懵了。3.3 一个实用场景工厂函数、make_unique的实现说白了完美转发最大的应用场景就是标准库的std::make_unique、std::make_shared这类工厂函数。拿std::make_unique举例它内部需要把构造函数参数以原始值类别转发给new表达式这样才能做到“传左值就拷贝传右值就移动”。template typename T, typename... Args unique_ptrT make_unique(Args... args) { return unique_ptrT(new T(std::forwardArgs(args)...)); }如果没有完美转发像std::make_sharedstd::string(hello)这种带右值临时参数的情况编译器就只能匹配拷贝构造白白多一次深拷贝。有了完美转发右值参数一路传递给std::string的移动构造性能损耗直接减半。这个套路你在写自己的工厂函数、注册表、事件分发器时非常实用。比如你写一个通用的组件创建器参数本来就是要透传给组件构造函数用Args加std::forward是标准做法别再用const Args了。4. 移动语义的坑与经验总结4.1 编译器默认生成的移动操作你以为是移动其实没有C11之后如果一个类没有声明拷贝构造、拷贝赋值、析构函数、移动构造、移动赋值中的任何一个编译器才会自动生成默认的移动构造和移动赋值。注意这个“任何一个”的条件——只要你自定义了析构函数编译器就不会自动生成移动操作。这是很多人踩过的大坑。比如你写了个日志类为了调试加了个析构函数打印日志结果类里的std::string成员在容器扩容时全部退化为拷贝。你看着一堆深层复制不知道哪儿出的问题其实就是因为析构函数的存在抑制了默认移动操作的生成。正确姿势是如果你自定义了析构函数并且类内部有需要高效移动的资源主动声明并实现移动构造和移动赋值。或者用 default显式要求编译器生成class Logger { public: ~Logger() { /* 自定义析构逻辑 */ } Logger(Logger) noexcept default; Logger operator(Logger) noexcept default; };面试里这个问题也经常拿出来考什么时候编译器会生成移动构造函数答案不是“有非静态成员就生成”而是上面说的那条限制。4.2 const对象永远是拷贝这是一个反直觉的细节。const std::string对象能用std::move吗能但把它转成右值后类型是const std::string移动构造的参数是std::string绑定不了常量右值引用重载决议只好退回拷贝构造。所以如果你写的函数返回一个const临时对象调用方想用移动语义接收是做不到的。返回const std::string这种写法不仅没有性能优势还阻止了移动优化的可能性。有人在代码评审里总喜欢给所有函数都加const返回类型理由是“防止调用方修改返回值”这种观念在C11时代已经过时了。该返回非const值类型就返回非const值类型让调用方决定是否移动。4.3 移动后对象必须是“有效但未指定”标准库有一个约定被移动过的对象处于“有效但未指定”的状态。意思是说你不能假设它为空也不能假设它还保留原来的数据但它必须满足类的正常不变量可以安全地析构、赋值、调用不依赖具体值的成员函数。我见过一个人移动完对象后直接拿它继续用觉得std::move只是优化没副作用结果那个对象内部指针已经被置空了崩溃现场非常难看。反过来讲你实现移动构造函数时一定要保证把原对象置成安全状态。最省心的做法就是让语义简单直白指针给nullptr、大小给0、容器给默认构造。别整那些花里胡哨的“部分移动”逻辑坑人坑己。4.4 自移动与移动后赋值是未定义行为严格来说标准库容器自身的自移动赋值行为在不同版本里有差异std::vector自移动赋值在旧标准里是未定义行为后来标准做了修订让它变成未定义行为但某些实现里实际上是安全的我们自定义类就别赌这个了。在移动赋值运算符里加上if (this ! other)检查成本极低但能避免一次UB级别的灾难。还有一个相关细节被移动的对象再次赋值是安全的。比如a std::move(b)之后a接管了b的资源b变成空壳但你再执行b new content是完全可以的。这个特性在对象池、复用缓冲区的场景里很好用移动后对象不是一个“废品”。4.5 移动语义不等于优化万能药最后说一点可能很多人不爱听的移动语义是性能优化的重要手段但别乱用。有三种情况你加了std::move反而更糟。第一种对POD类型或者没有资源管理的简单类型用std::move没有意义。一个int移动和复制完全一样你写int b std::move(a)纯属给代码增加噪音。第二种依赖了返回值优化RVO/NRVO的返回语句不要强行std::move。比如return std::move(local);这种写法不仅多此一举反而可能抑制编译器本来能做的返回值优化性能不升反降代码还变得难读。第三种过度使用std::move让一个变量失去后续使用价值。如果你在一个对象上反复移动然后又尝试读取原对象的数据这种行为本身就是逻辑错误。移动不是拷贝它是资源的转移知道自己在做什么再动手。根据我实际工程里的经验移动语义在重字符串处理、大数据量容器传递、通用封装库参数转发这几个领域收益最大。处理类型本质是小整数、简单结构体、或者有强引用关系的对象时别为了炫技去加移动操作优化之前先测性能别拍脑袋。说实话右值引用这套东西我前前后后读了好几遍书才真正消化。第一次看懂std::move只是cast的时候感觉自己之前写的代码里一半效率都白丢了。后来在项目里用起来才意识到性能优化最爽的时刻不是用了多高深的算法而是把深拷贝换成指针交接的那一行代码。你现在再看网上那些“C面试必问移动语义”的题目基本就是揪着std::move原理、noexcept有什么用、移动后必须保持有效这几个点来回问。把这些真正理解透了面试过不过倒是次要的至少你的代码是实打实地变快了。
返回列表