Loading... <div class="tip inlineBlock success"> 超级省流版: </div> 1. “引用”并不像指针一样创建任何新对象,而是个别名,让编译出来的代码操作原对象。 2. “使用右值引用”本身并<span style='color:DarkTurquoise'>不对原对象做任何操作,只是给使用者指明“这是个右值”</span>,可以被移动。 3. “可被移动”不是说内容可以在内存上移动到别的地方,而是<span style='color:DarkTurquoise'>“可以放在其他对象名下”</span>。 4. “万能引用”并不是特别的约定,而是<span style='color:DarkTurquoise'>模板实参+引用折叠</span>的结果。 5. “移动语义”的实现是逐级分解的过程,最终<span style='color:DarkTurquoise'>要落实到“移动构造函数”和“移动赋值操作符”上</span>。 6. “复制消除”一开始是种优化,但<span style='color:DarkTurquoise'>C++17的值类别让它变得自然了,这也是我们目前的值类别系统</span>。 那么,下面正文开始。 # 01 值类别 基于一条赋值语句 ```cpp expr1 = expr2 ``` 我们很容易对所有的表达式做出一种分类,例如: ```cpp int x = 3; ++x; T y; //T 是类类型 vector<int> arr; ``` 这些表达式全部可以放在赋值语句的左端,而: ```cpp 123 123 + 456 x++ &y ``` 这些表达式只能出现在赋值语句的右端。 那么,我们可不可以就此依据一条赋值语句对所有的“值”进行一个分类呢? 答案是可以的。事实上,C++的鼻祖CPL语言就是这样做的。虽然现在这种分类体系被大大的拓展了,但本质上还是依据相同的原则: * 所有可以位于赋值语句左侧的值,被称为**左值** * 其他只能出现在赋值语句右侧的值,被称为**右值** 但这样依赖具体的代码行为作判断依据,不免有用现象解释本质的嫌疑了。再仔细想一想的话,是什么使得左值能够被赋值? 容易想到,这是因为左值能够“**保有值**”。就是说“左值”一定拥有着对应的内存(或等价于内存的存储)空间,使得它可以长期的保存它的值。当然,这意味着它就一定有“**地址**”[^identify]。 而相反的,不拥有长期内存空间的表达式就被称为“右值”。 <div class="tip inlineBlock info"> 因此,让我们给出一个(当然,是初步的)形式化的定义: </div> * <span style='color:DarkTurquoise'>拥有自身“地址”的值被称为**左值**</span> * <span style='color:DarkTurquoise'>不拥有“地址”的值被称为**右值**</span> 哦,你可能说:这与我看到的说法并不一致!当然。不过目前为止,这个定义还不错。接着我们再来讨论更深入的东西。 # 02 移动 ## 0201 情景1 好了,现在我们来考虑这样一个场景,使用“工厂方法”来创建新的对象(这是一种很常见的手法)[^Factory]: ```cpp template<typename T> class Factory{ T get(){ // ...... return T(); } }; ``` 因为这样我们可以拥有一个管理复杂内存来提高效率的中间件,这在STL等实际应用中非常常见。 那么,当我们想要创造一些新的对象并放入某个容器进行使用时,就可以这样写: ```cpp int main() { Factory<some_type> factory; my_container<some_type> con; //一个容器 for (int i = 1; i <= n; i++) con.push_back(factory.get()); // …… …… } ``` 这段代码看起来很正常,取出一个对象,然后 `push_back`到容器里。 但,假如我们的模板实参 `T`实际是这么一个东西: ```cpp struct A{ int* arr; //arr保管一个大数组 A(){ arr = new int[1000000]; } }; ``` 好吧,`A`类型管理的内存有点儿过于庞大了,不是吗?这意味着如果我们要复制它、移动它,总之**任何转移它的内容的行为**,都会带来很大的开销。但有的时候,我们的确面临着这种烦恼。就比如上面这段代码,由于 `factory.get()`在 `main`中会作为临时对象存在,那么我们可以把它等价成以下代码[^temporary]: ```cpp int main() { Factory<A> factory; my_container<A> con; //一个容器 for (int i = 1; i <= n; i++) { A tmp = factory.get(); //仅考虑围绕容器的操作,这和之前的代码是等价的 con.push_back(tmp); } // …… …… } ``` 它的实际内存变化就会是像这样: ![image.png](https://zclll.com/usr/uploads/2022/04/1173451995.png) 可以看到,即使不谈通过 `get()`将所需对象传回 `main`函数的开销[^elison],每一次将读入的数据送入 `con`时,我们都要从 `tmp`管理的内存空间,将内容**复制**到 `con`的管辖内存中(无论是否采用左值引用,这种复制都至少发生一次,见下图)。每一次,都要复制完整的 `tmp.arr`数组,这种开销是巨大的。 ![image.png](https://zclll.com/usr/uploads/2022/04/4280691392.png) 而这是一个非常常规的情形,对吧?可能大量的工程代码里都有类似的事情要做。而这就意味着对这类代码的优化很重要很重要。 那么,怎么做呢?一种想法是把提供数据的接口直接交给 `con`,让**读取时直接把数据读到** `con`**里面**。例如 ```cpp con.push_back_from(&factory.get); ``` ![image.png](https://zclll.com/usr/uploads/2022/04/880940294.png) 然而这需要“改造轮子”了——`my_container`得提供这种功能才行。更何况,这并不总是可行的。你不能总是期望跳过 `tmp`直接访问数据来源,因为有时数据就在某个如 `tmp`的其他对象中,而你要穿过层层工序找到源头,不仅极大的破坏了模块独立性,甚至**绝大多数时候是完全不可能的**。 <div class="tip inlineBlock error"> 事实上,即便可行,大规模耦合模块也不是个好主意 </div> --- <div class="tip inlineBlock success"> 那么,更好的做法是什么呢? </div> 那我们首先就得讲清楚,为什么原本的做法是可以被优化的?**可以被优化掉的东西是什么?** 让我们仔细看一下原本操作的全过程: ![image.png](https://zclll.com/usr/uploads/2022/04/2969079946.png) 之所以会想要优化这个过程,是因为我们全程只是在操作同一个 `A`对象。我们要做的只是把这一个对象<span style='color:Red'>**移动**</span>到别的地方去。上图中**所有的复制都是没必要产生的**。 如果我们能把这个过程改造成这样就好了: ![image.png](https://zclll.com/usr/uploads/2022/04/2652619888.png) <div class="tab-container post_tab box-shadow-wrap-lg"> <ul class="nav no-padder b-b scroll-hide" role="tablist"> <li class='nav-item active' role="presentation"><a class='nav-link active' style="" data-toggle="tab" aria-controls='tabs-0ffaa13950fccbf411f447c0a1f01e0b750' role="tab" data-target='#tabs-0ffaa13950fccbf411f447c0a1f01e0b750'>移动语义</a></li> </ul> <div class="tab-content no-border"> <div role="tabpanel" id='tabs-0ffaa13950fccbf411f447c0a1f01e0b750' class="tab-pane fade active in"> ### 移动语义 说到这儿,我们有必要讲讲**C++中的**“移动”了。说实话,很多人一开始难以理解移动语义,和它的命名有关。移动的实质并不是“将内容移动到另一个地方”,而是<span style='color:LawnGreen'>“转移了所有权”</span>。 也就是说,汉语语境下的的“移动”: ![image.png](https://zclll.com/usr/uploads/2022/04/50485588.png) C++中说的“移动”: ![image.png](https://zclll.com/usr/uploads/2022/04/3173360.png) 实际上,C++中我们所说的**移动**,似乎并不包含“移动”的含义,被称为“**让渡**”所有权更合理(哪怕是“**转移**”也好)。不过,我们姑且当做这是一种不同语言之间的偏差,毕竟我们还是要遵守对应的术语的。理解它的实质就好。 这样,我们后面就使用正式的术语“**移动**”,来指代这种操作。 </div> </div> </div> 从程序行为上来说这种改造是可能的,接下来我们考虑如何实现就好了—— ### 尝试实现“移动语义” 我们实际上要改造的是 ```cpp A tmp = factory.get(); con.push_back(tmp); ``` 这一个过程,一个 `A`对象从 `get()`中返回到 `main()`中,又被拷贝到 `push_back()`的实参中。 如果要实现如[上文](#situ1)末尾示意图所展现的完全一致的效果,那么很容易想到使用指针去实现: <div class="tip inlineBlock warning simple small"> 这样的话,就会形成一套完全的C语言手法—— </div> ```cpp template<typename T> class Factory{ T* get(){ T *somewhere = new T(); return somewhere; } }; int main() { // …… …… for (int i = 1; i <= n; i++) { A* tmp = factory.get(); con.push_back(tmp); } // …… …… } ``` 此时容器也需要提供一个接受指针参数的重载。 但我们知道,使用C++的一个优点就是可以使用各种引用去代替指针,以规避繁琐且容易出错的手法。以上手法不仅繁琐,也不满足C++的RAII原则。 <div class="tip inlineBlock info"> 那么,是否存在一种方式可以保持C++的值语义(就是说,不让指针介入),且能够有类似的效率呢? </div> 由于值语义的存在,C++中变量名所对应的内存位置是无法改变的,那么各个对象(如 `factory.get()`内部的 `A`实例,和 `main()`中的 `tmp`)之间的指称变换是不可能实现的。不可能让两个非指针类型完全交换所代表的内存地址。那么,一定程度的成员“复制”是不可避免的了。但,“移动”优化的目的是为了加速,我们只要能够把“复制”中**开销最大的环节提速**就可以了。 那么,什么开销最大呢?当然是大规模内存的迁移了。而幸运的是,这一部分内容大多存储在堆空间内——这意味着对象中**只会保有内容的指针**。那么<span style='color:BlueViolet'>这一部分</span>,我们在底层<span style='color:BlueViolet'>像C语言一样进行指针的移动,就能够既提升效率,又保持了值语义不变</span>。 那就是说,我们可以实现这样的做法: ![image.png](https://zclll.com/usr/uploads/2022/04/1213919740.png) 对于简单类型我们就进行直接拷贝,对于**堆**中的大量数据,我们不再进行拷贝,而是“移动”它的指针。 实现上,可以给 `A`类型编写这样的函数: ```cpp A::A(int* _arg) : arr(_arg){} //需要接受指针的构造函数 A* A::move_from() { A* rtn = new A(this->arr); //如果A有更多内存对象管理,也同理 this->arr = nullptr; return rtn; } ``` 这样,通过被别人调用 `move_from()`,`A`对象就可以安全的**交出自己管辖内存的所有权**,移动语义也就可以实现了。 然后,我们的 `push_back()`就可以这样写: ```cpp template<typename T> void my_container::push_back(T &value, bool can_be_occupied = false) { if (can_be_occupied) //当然,实际代码要比这复杂得多,不过我们今天需要关注的只有这个。 this->data_base[++len] = value.move_from(); else // ...... } ``` 手动指示函数能否直接占据参数的内存。有了这样全新的 `push_back()`,在之前的问题中,我们就可以有更高效率的写法了: ```cpp con.push_back(factory.get(), true); ``` <div class="tip inlineBlock success"> 好,到目前为止,我们已经初步实现了C++的移动语义了。 </div> 但这里还存在两个问题: * 其一是我们的参数<span style='color:Red'>只能是“**非常量**左值引用”类型</span>,因为我们需要给数据源设定 `ptr = nullptr`操作。但这样的话,如果我们需要 `push_back`一个右值,就只能使用另外的 `T value`作为参数的重载,而那势必造成**拷贝**操作[^mustcopy]——**这是很反常的,如果实参是右值,那应该更容易被“移动”才对**。 * 其二是每一次调用时,我们必须<span style='color:Red'>手动指定</span>`can_be_occupied`的值,而这是非常繁琐的。而且……该怎么做呢? <div class="tip inlineBlock info"> 是啊,我们到底依据什么来判定值该被移动还是拷贝呢? </div> 先看看一些常见的用法: ```cpp extern A generate_A(); extern A* generate_pA(); A a, arr[10]; A* pa; con.push_back(a, false); con.push_back(a = A(), false); con.push_back(A(), true); con.push_back(1, true); //假设有构造函数A::A(int) con.push_back(generate_A(), true); con.push_back(generate_A_ret_a_ref(), false); con.push_back(*generate_pA(), false); con.push_back(*pa, false); con.push_back(arr[1], false); ``` 整理过后便可发现,<span style='color:Red'>所有应该被**移动**的值,都是右值!</span> 事实上这一点完全不值得意外。回顾左右值的定义就知道,右值本身不具有地址,如果不被使用,它将会立刻消亡——这本身就带有**临时变量**的含义。例如某个函数 `f()`的返回值、直接构造的对象 `A()`,这些表达式如果我们不立即使用它,当前语句过后就会消亡。既然它们是这样的临时变量,那么使用时就应当**用移动语义来直接占有原本将会消亡的表达式内容**,而非拷贝。 既然如此,如果能从语法层面提供一种类似于分辨左右值的手法,那么以上功能就能够实现的非常顺畅了。类似这样: ```cpp void f(pair<reference_of_T, bool> arg_and_movable) { ... ... } ``` 幸运的是,从11标准开始,C++就提供了这样的标准语法。这,就是<span style='color:BlueViolet'>**右值引用**</span>。 ### ☆右值引用 终于引出这个无比重要的新功能了,从C++11开始,我们可以使用 `&&`代表对一个右值的引用了! 也就是说,一个表达式是左值还是右值(应该被拷贝还是移动),完全不需要我们去操心,而可以由编译器完成了!我们只需要把需要的拷贝和移动都写好就好。之前我们提到的全部想法,都可以这样实现了: ```cpp void Container::f(const A &larg) { //use larg; } void Container::f(A &&rarg) { //use rarg.move_from(); } A a{1}; con.f(a); //调用左值重载 con.f(func_return_ref_A()); //调用左值重载 con.f(A()); //调用右值重载 con.f(func_return_A()); //调用右值重载 con.f(1); //调用右值重载,调用前参数原地初始化一个A(1) ``` 其中第一个函数接受 `const`左值引用,而第二个函数接受右值引用。 <div class="tab-container post_tab box-shadow-wrap-lg"> <ul class="nav no-padder b-b scroll-hide" role="tablist"> <li class='nav-item active' role="presentation"><a class='nav-link active' style="" data-toggle="tab" aria-controls='tabs-db6b76c7a73092c9904db5bd7c2f22c270' role="tab" data-target='#tabs-db6b76c7a73092c9904db5bd7c2f22c270'>常量左值引用绑定到右值</a></li> </ul> <div class="tab-content no-border"> <div role="tabpanel" id='tabs-db6b76c7a73092c9904db5bd7c2f22c270' class="tab-pane fade active in"> 如果没有第二个重载,那么以这些函数调用第一个重载也是可以的,只不过无法完成我们“分辨值可否移动”的目的了而已。因为 const左值引用可以绑定到右值。这是由于 const左值引用不必担心修改原值,那么右值就可以被这样使用。 </div> </div> </div> ### 新的值类别定义 文章一开始我们说过,从98标准开始,C++用是否拥有地址来区分左值和右值。此时左右值的主要作用还是和C语言一样。 但从C++11开始,由于引入了移动语义,左右值的区分也据此产生了更为细化的定义。**此时**的值类别定义,很大程度上**要为移动语义的正确分类服务**——我们关注的不再是谁能够被赋值,而是谁能够被移动。所以此时有了这样的分类方式: * <span style='color:BlueViolet'>**不可被移动的表达式称为左值**</span> * <span style='color:BlueViolet'>**可以被移动的表达式称为右值**</span> 这与我们原本的定义: * 具有地址的表达式被称为左值 * 不具有地址的表达式被称为右值 目前看起来是等价的。因为我们前面区分左右值的时候就是这样分析的:具有地址的不应该被移动,没有地址的可以被移动。 <div class="tip inlineBlock error"> 而且,用移动来区分左右值,似乎有循环论证的嫌疑。 </div> ——当我“问什么样的值可以被移动的时候”,答案是右值。当我问“右值是什么”的时候,按照C++11标准,答案是可以被移动的值-_-|| 但这种通过移动语义的区分,作为定义来讲,是非常**有必要**的——因为我们后文会看到,**这两种定义现在开始不等价**了。只不过,程序员不应当按照标准这样去理解左右值——因为这样什么也理解不了。[^thatswhy] 要获得C++11值类别的全貌,我们还需要再解决一种情形才行。 ## 0202 情景2——move 那么,通过右值引用,我们已经实现了**将临时对象利用起来**这件事。但如果不是临时对象,我们还能这样做吗? 这种需求也是实际存在的,而且实践中非常常见,请看: ```cpp int main() { my_container<A> con; //一个容器 for (int i = 1; i <= n; i++) { A tmp; read(tmp); // 从某个接受左值引用的函数接收数据 con.push_back(tmp); } // …… …… } ``` 这与前节中例子的区别在于,此时没有真正的临时对象可供优化了——就是说,我们无法直接调用任何右值重载了——因为我们必须得用一个 `tmp`左值去接收数据。然而它所提供的,实际上是一种**临时载体**语义。在内存上,这个过程和[情景1](#situ1)所示的完全相同。只不过**语法上**,这里我们使用的是**左值**而非临时对象。 既然实质是完全相同的,那么我们应该可以套用完全一致的优化才对吧? 在我们自己[尝试实现“右值引用”](#try_achieve)时,使用了手动标注“是否该被移动”的手法。不过我们现在知道了,“是否该被移动”实际上指的就是左右值类别。那么,我们可以声明<span style='color:Chartreuse'>**将一个值作为右值去使用么?**</span> 事实上,C++11在提供右值特性的时候,同步为我们准备好了这样的工具。**这就是** `move()`**函数**。 我们可以使用 `move()`去生成一个值的右值表达式,来表明它是“可被移动的”,去调用对应的右值重载: ```cpp A tmp; read(tmp); con.push_back(move(tmp)); //生成右值引用 ``` 此时表达式 `move(tmp)`的值类别是 `A&&`,会调用对应的右值重载: ```cpp template <typename T> void my_container::push_back(T&& arg); ``` 这样,问题就迎刃而解了。 ### 左值到右值的变化 前面我们已经知道了 `move()`会做什么了,但问题来了,左值到右值的变化时,实际发生了什么?是产生了个新对象,还是原值上发生了什么变化? 我们不如直接来看看源码: ```cpp template<typename _Tp> _GLIBCXX_NODISCARD constexpr typename std::remove_reference<_Tp>::type&& move(_Tp&& __t) noexcept { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); } ``` 实际解读一下。以上定义看起来很复杂,但我们只需要关心函数体即可。容易看出,实际上 `move()`所做的全部工作,不过是返回了通过 `static_cast`直接转型为右值引用后的实参。而 `static_cast`作为关键字,能够神奇的对表达式进行类型转换,而不创造新对象——相当于,**在原对象上创造了一个新的使用视图**,然后返回这个视图: ![image.png](https://zclll.com/usr/uploads/2022/04/3187972509.png) (就像这样,我们只是以另一个视角去看待它) 这再次印证了:**值类型只是一种语法标记**,`move`既没有产生新对象,也不会进行任何对原值的操作。这的确是我们需要的,也和我们自己设计的相符。 ### 值类别再修正——C++11正式版本 好了,把 `move`和上一节理解完,我们就得面对一个问题了:<span style='color:BlueViolet'>`move`和 `static_cast<_Tp&&>`变换过的值是什么类别?</span> 它们能够被移动,所以应该是**右值**。但它们又有地址——因为 `static_cast`不创造新对象,它们对应的还是原来的对象及其地址,只是视角变了——所以应该是**左值**。 那么……能够出现既是左值又是右值的表达式吗? 似乎不太合理。 这就是为什么C++11不能够继续仅凭“地址”去判断对象值类别的原因:**有地址的值也可能可以被移动。** 那么,要想让我们的新值类别体系不出乱子,就得给原本的“左值”和“右值”都套个壳,造两个更大范围的“新左值”和“新右值”,让“新左值”和“新右值”包含各自原本的类型——以及这个该死的既左又右的类型。 于是,C++11的value category它来了[^standard]: ![image.png](https://zclll.com/usr/uploads/2022/04/1286701232.png "[fig:basic.lval]") * **拥有身份**且**不可被移动**的表达式被称作 <span style='color:BlueViolet'>左值 (lvalue)</span> 表达式; * **拥有身份**且**可被移动**的表达式被称作 <span style='color:BlueViolet'>亡值 (xvalue)</span> 表达式; * **不拥有身份**且**可被移动**的表达式被称作 <span style='color:BlueViolet'>纯右值 (prvalue)</span> 表达式; * **不拥有身份**且**不可被移动**的表达式无法使用 。 另[^havetochangeagain]: * 左值和亡值统称为<span style='color:BlueViolet'>泛左值</span>——**可取地址**; * 纯右值和亡值统称为<span style='color:BlueViolet'>右值</span>——**可被移动**。 <span style='color:Crimson'>这种在使用中作为右值,但同时可以取到地址的表达式,现在被称为**亡值**。</span>究其原因,它们本质上是一种类似于<span style='color:DarkOrchid'>“视图”的东西:它们是对某个左值的一种呈现</span>,但这个呈现“视图”的生命期只到它被弃置不用为止。 现在,我们原本的左值还叫左值,但和“亡值”一起被称作“泛左值”;原本的右值现在叫做“纯右值”,和“亡值”一起被当做新概念下的“右值”。 --- <div class="tip inlineBlock info"> 理解到这,已经足够认清C++11的值类别体系了。不过要是想要从更深层次去理解事物的话,就需要考究C++标准的设计哲学了: </div> 注意到,亡值完全是一种“非天然的”、自造的值类别。这个值类别几乎完全是为了实现 `move`原语而存在的。原本左值和右值的区分是完全可以通过地址去实现的,那时候,那也可以完全等价于“可否移动”——其实最朴素的定义方式就是那样子的。但问题是,左值到右值的转换就是无法失去地址,这是因为**值类别变换从不创造新对象**,转换后的表达式还是代指原本有地址的那个值。那我们要让 `move`原语实现,就必须容忍“不失去地址,但能移动”的表达式出现。 换句话说,“亡值”这种“怪东西”的出现,完全是因为我们提供了 `static_cast<T&&>()`这种语法,去临时给下游的移动操作提供对象。但我们**不得不这么做**,否则就不存在完整的移动语义了。 ## 0203 原子操作——移动构造函数 既然 `move`本身并不对变量进行任何实质性的改变,使用右值引用也只是一个“声明”,那么右值引用是怎么做到实际改变对对象的使用方式的呢? 与指针一次就能改变所指对象不同,任何一次“移动”的尝试,都是在**逐层分解地**调用移动函数(我们这样称呼使用右值参数重载的函数)。而我们都知道,一切类型被分解到终点——基本类型,它们的移动是没有任何意义的。 也就是说,右值引用真正要发挥作用,依赖的是我们给“**可以移动的类型**”**实现移动语义**。这使用的工具就是**移动构造函数**和**移动赋值运算符**——这两个函数分别是拷贝构造函数和拷贝赋值运算符的右值引用版本。 这是什么意思呢?比方我们有这样一个类 `A`: ```cpp class A{ private: struct B{ struct C{ int* arr = new int[1000000]; }c; int* arr = new int[1000000]; }; B b; string str; unique_ptr<int> pint; int i; public: //…… …… }; ``` 这个类具有很多成员,假设我们想要用一些值**原地初始化**一个它的实例该怎么做?大概是这样: ```cpp A::A(B&& _b, string&& _str, unique_ptr<int>&& _pint, int _i) : b(move(_b)), str(move(_str)), pint(move(_pint)), i(_i) {} ``` 其中 `i`作为基本类型没有移动的必要,其他三个成员变量都接受右值引用然后去调用它们的移动构造函数就可以了。 但我们知道 `move()`只是通过改变值类别进行一种标记,它实际上什么都不做。所以接着还得看 `B`、`string`、`unique_ptr`被调用的移动构造函数是怎么实现的,对吧?`string`和 `unique_ptr`我们管不着(事实上,理解了 `B`,这二者也没什么更特别的操作),那我们就看看 `B`的移动构造应该怎么实现: 显然 `B`管理成员 `c`和 `arr`,那么我挨个处理就好了: ```cpp B::B(B&& src) : c(std::move(src.c)), arr(std::move(b.arr)) {} ``` 这也是一个正常的移动构造函数该有的样子。 那么 `C`的移动构造函数又被调用了,我们接着实现它好了: ```cpp C::C(C&& src) : arr(src.arr) {} ``` 这样就行……个屁啊! 来看看我们的类结构图: ![image.png](https://zclll.com/usr/uploads/2022/04/3671692594.png) 在 `A`->`B`->`C`这一支上,我们不断调用移动构造函数,到最后实现了什么呢?那我们看看终点处发生了什么,也就是上图的叶节点。在代码中,是那些没有移动构造函数的基本类型。 在这两处,我们分别调用了 `arr(b.arr)`和 `arr(src.arr)`,其中参数都是右值——当然,因为指针是基本类型,所以左右值无所谓。而这个含义是**直接拷贝指针**!显然这不是我们想要的。那应该怎么做才对呢? 回顾我们在[尝试实现“移动语义”](#try_achieve)时所做的,“移动”的目的是在**可行**的时候减少复制的开销。显然,“右值”提示了允许我们进行这种操作,而这种操作在有指针管理大内存的时候才有意义。这两者都具备,那我们就要进行减少开销的操作的了。具体方式是什么呢?看看我们在那一节中怎么实现 `move_from()`的——让存活的对象直接夺取右值对象的指针,而不是执行内存的复制。 那么,回到我们刚才的情况,我们应该在哪些情况下执行这种**实际优化操作**呢(不考虑不归我们管的 `str`和 `pint`的类)?显然,得是针对右值——我们在讨论移动构造,满足条件;然后得管理内存,那么 `i`作为简单类型就没必要,而 `B`和 `C`很有必要,因此它们的<span style='color:Red'>移动构造函数就应该这样实现</span>: ```cpp B::B(B&& src) : c(src.c){ this->arr = src.arr; src.arr = nullptr; } C::C(C&& src){ this->arr = src.arr; src.arr = nullptr; } ``` 而 `A`对象不直接管理内存,因此它可以有朴素的移动构造函数: ```cpp A::A(A&& src) : b(src.b), str(src.str), pint(src.str), i(str.i) {} ``` <div class="tip inlineBlock success"> 一切移动函数要发挥作用,都得依赖移动构造函数和移动赋值运算符的正确实现。它们就是移动语义下的“原子操作”。 </div> --- 好了,到这里我们应当可以理解了: **产生一个值的右值引用,或将它作为右值引用去使用,并不会对它本身产生任何影响**。[^toright]换句话说,右值引用只是个**标志**。只是让开发者可以便捷的用以右值引用为参数的函数重载,去实现如我们上面所做的,一些更高效的重载 # 03 万能引用与完美转发 现在我们有了移动语义,本质上具有了解决“就地改变对象所有权”问题的基本工具。但对于更加实际、更为复杂的情形,我们还是需要一套完整的工具,来让复杂代码的编写便捷起来。 例如,对于最简单的单一参数情形,我们只需要两个重载即可(见[右值引用](#rref)一节)。但是当参数数量足够多的时候,比如上一节中的 `A`类型,我们就需要有若干个重载的构造函数来应对不同的调用: ```cpp A::A(B&& _b, string&& _str, unique_ptr<int>&& _pint, int _i) : b(move(_b)), str(move(_str)), pint(move(_pint)), i(_i) {} A::A(B _b, string _str, unique_ptr<int>&& _pint, int _i) : b(_b), str(_str), pint(move(_pint)), i(_i) {} A::A(B _b, string&& _str, unique_ptr<int>&& _pint, int _i) : b(_b), str(move(_str)), pint(move(_pint)), i(_i) {} A::A(B&& _b, string&& _str, unique_ptr<int> _pint, int _i) : b(move(_b)), str(move(_str)), pint(_pint), i(_i) {} //……………………………… ``` (准确的说,需要8个重载才行) 如果需要赋值的话,那还需要同样多的赋值运算符……o(╯□╰)o 形式化的说,非简旧类型的参数量为$n$,那我们就需要写$2^n$个重载,这显然是不可接受的! ![image.png](https://zclll.com/usr/uploads/2022/04/3053860378.png) (↑哦,要是像这样,参数类型要是还容许其他构造方法,就更离谱了)[^quoteytb] 但幸好,C++11想到了这一点,通过两条特别的规则,借助了模板解决了这一问题。 <div class="tab-container post_tab box-shadow-wrap-lg"> <ul class="nav no-padder b-b scroll-hide" role="tablist"> <li class='nav-item active' role="presentation"><a class='nav-link active' style="" data-toggle="tab" aria-controls='tabs-99a0a1316d3f63c83a8637c2f47f5d7e490' role="tab" data-target='#tabs-99a0a1316d3f63c83a8637c2f47f5d7e490'>引用折叠</a></li><li class='nav-item ' role="presentation"><a class='nav-link ' style="" data-toggle="tab" aria-controls='tabs-a03312ada55857fca844faa5def9195f711' role="tab" data-target='#tabs-a03312ada55857fca844faa5def9195f711'>转发引用</a></li> </ul> <div class="tab-content no-border"> <div role="tabpanel" id='tabs-99a0a1316d3f63c83a8637c2f47f5d7e490' class="tab-pane fade active in"> ## 0301 引用折叠 > 因作为模板或typedef参数出现的类型可能出现“引用的引用”,**右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用**。(9.3.4.3.6)[^standard] </div><div role="tabpanel" id='tabs-a03312ada55857fca844faa5def9195f711' class="tab-pane fade "> ### 转发引用 > 非类模板中,无cv限定的右值引用被称作**转发引用**。如果函数形参P是转发引用,且以左值调用它,那么对应的模板形参T会被特化为**左值引用**而非拷贝(13.10.3.2.3)[^standard] 转发引用是与引用折叠相配套的一个必然规则。这是因为:当我们使用万能引用时,希望推导出来的类型要么是左值引用,要么是右值引用。但我们用一个左值类型去调用,那按一般规则会把模板实参推导成不带引用的拷贝类型,例如: ```cpp template<typename T1, T2> void f(T1&& arg1, T2&& arg2){ //...... } A a; f(a, 1); //此时会推导f为void f(A, int&&) ``` 而这不是我们想要的。 所以,可以认为,模板函数中 `non-cv`限定的右值引用形参,**就是用来做万能引用的**,那么此时对模板实参**应用特殊的推导规则**:当以左值调用时,<span style='color:BlueViolet'>推导模板实参为左值而非拷贝类型。</span> 这样,结合引用折叠,我们后面的代码就容易实现了。 </div> </div> </div> 这样,显式的右值引用可以在模板匹配时起到类似“幺元”的作用。基于此,我们就能写出这样的代码: ```cpp template<typename T1, T2, T3> void f(T1&& arg1, T2 &&arg2, T3 &&arg3){ //………… } ``` 由于模板参数可以直接指代带引用的类型,所以在模板实参匹配时,可能出现“引用的引用”情况。根据引用折叠,实际参数的引用类型将取决于实参的引用类型。即:这段代码对于每一个参数来说,都可以任意匹配它的左值和右值。 <div class="tip inlineBlock success"> 这种引用,就被称为“**万能引用**”。 </div> <div class="tab-container post_tab box-shadow-wrap-lg"> <ul class="nav no-padder b-b scroll-hide" role="tablist"> <li class='nav-item active' role="presentation"><a class='nav-link active' style="" data-toggle="tab" aria-controls='tabs-0225ddf232ba35e3c1724418c49a92b6970' role="tab" data-target='#tabs-0225ddf232ba35e3c1724418c49a92b6970'>“类型”和“值类别”</a></li> </ul> <div class="tab-content no-border"> <div role="tabpanel" id='tabs-0225ddf232ba35e3c1724418c49a92b6970' class="tab-pane fade active in"> ## 0302 区分“类型”和“值类别” 现在我们有了可以任意匹配左右值的代码了,但请务必分清表达式的**类型**和**值类别**。 例如以下代码: ```cpp void f(T&& arg) { arg = //………… } ``` 这里 `arg`的类型是“对 `T`的右值引用”,而 `arg`本身在 `f()`内部**是左值**!!!因为很显然,实参在函数调用栈帧中有地址。所以上述代码是可以运行的,虽然 `arg`是右值引用类型,但我们可以给它赋值。 所以,在万能引用中,每个参数的“值类别”都是左值,但我们得想办法搞清楚它们的“类型”是左值引用还是右值引用。 </div> </div> </div> 有了这个东西,我们就能想到,前面 `A`的构造函数~~们~~就不需要那么多重载了,可以用一个模板函数全部囊括: ```cpp template<typename T1, T2, T3> A::A(T1&& _b, T2&& _str, T3&& _pint, int _i) { //………… } ``` 它可以匹配所有的调用,无论每个参数是左值还是右值。但是,它不能实现我们想要的“转发引用”给下一层使用者的目的——因为调用下一级函数时,这里的参数**全都是左值**。 然而,我们提出右值引用的目的,是能让对象实现真正的移动操作,根据[前文](#atom)论述的,这在宏观上靠的是逐级调用子对象的“移动”操作,直至分解到原子操作。不使用模板时,对于右值参数,这种逐级调用可以自动完成,没什么额外要做的。但使用了模板,我们的参数匹配的**可能是左值,也可能是右值**。此时我们就必须想办法**把它们转发到正确的下一级函数中去**。 就是说,如果我们这样调用上面的函数: ```cpp string str; unique_ptr<int> up; A a(B(), str, up, 1); ``` 那我们希望它能调用到这样的构造函数: ```cpp A::A(B&& _b, string _str, unique_ptr<int> _pint, int _i) { //………… } ``` 幸而,像 `move`一样,C++11也给我们提供了这样的原语—— ## 0303 完美转发——forward 那就是 `forward`函数。它能够识别模板实参的值类别,从而以它原本的类别去调用对应的函数。 也就是说,前述 `A`类型的构造函数我们可以这样写: ```cpp template<typename T1, T2, T3> A::A(T1&& _b, T2&& _str, T3&& _pint, int _i) : b(forward<T1>(_b)), str(forward<T2>(_str)), pint(forward<T3>(_pint)), i(_i) {} //那么,以下调用 string str; unique_ptr<int> up; A a(B(), str, up, 1); //就会使用这样的模板实例: template<> A::A<B&&, string, unique_ptr<int>>(B&& _b, string _str, unique_ptr<int> _pint, int _i) : b(forward< B >(_b)), str(forward< string >(_str)), pint(forward< unique_ptr<int> >(_pint)), i(_i) {} //而最终被转发成: template<> A::A<B&&, string, unique_ptr<int>>(B&& _b, string _str, unique_ptr<int> _pint, int _i) : b(static_cast< B&& >(_b)), str(static_cast< string& >(_str)), pint(static_cast< unique_pt<int>& >(_pint)), i(_i) {} ``` 我们想要的就顺利实现了。 这个小工具的实现原理也非常巧妙,主要靠模板实参匹配的手法先完成分类: ```cpp template<typename _Tp> _GLIBCXX_NODISCARD constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept { return static_cast<_Tp&&>(__t); } template<typename _Tp> _GLIBCXX_NODISCARD constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept { static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" " substituting _Tp must not be an lvalue reference type"); return static_cast<_Tp&&>(__t); } ``` (注意,这里形参前的关键字 `typename`是不可省略的,因为 `std::remove_reference<_Tp>::type`是“待决有限定名称”,它不一定是个类型——就像 `true_type::value`——所以我们得显式说明它是 `__t`的类型声明而不是别的什么东西。) 可以看到这里有两个重载,通过 `remove_reference<_Tp>`,形参的引用类型就是写出来的 `&`或 `&&`,并不存在引用折叠。从而实参就能匹配到它“真实值类别”所对应的那个重载。 然后通过 `static_cast`,我们就能够得到对应的左值/右值引用啦!因为,别忘了 `_Tp`是带引用的类型!所以 `static_cast<_Tp&&>`和函数返回值 `_Tp&&`都适用引用折叠,会**折叠到实参实际的值类别**上去。 <div class="tip inlineBlock info"> 为什么要这样写呢?为什么不能像 `move`一样用万能引用? </div> 比如,我可以直接利用万能引用,写出这样的代码: ```cpp template <typename _Tp> _GLIBCXX_NODISCARD constexpr _Tp && my_forward(_Tp &&__t) noexcept { return static_cast<_Tp &&>(__t); } ``` 这看起来不是简洁多了嘛?和 `move()`的唯一区别是 `move()`转发的是纯右值,而 `my_forward()`转发的是利用引用折叠规则得到的可左可右的值。 但很遗憾,这样是行不通的。别说实际效果不对,这东西在语法上就已经遇到大问题了。因为 `forward()`往往用在“**转发引用**”时。这种情景是: ```cpp template <typename _Tp> _Tp&& my_forward(_Tp &&__t) { return static_cast<_Tp &&>(__t); } template <typename T> void f(T &&arg) { another_f(my_forward(arg)); } ``` 分析一下: 当我们使用右值去调用 `f()`时——比如说我们的参数是 `1`,那么**类型**是 `int&&`,同时 `T = int`。但别忘了[0302 区分“类型”和“值类别”](#differ)里面说的,此时 `arg`在 `f()`内是个**左值**!于是,基于万能引用的 `my_forward()`转发结果将会永远是一个左值! <div class="tip inlineBlock info"> 接下来,再从更深的层次去理解一下吧: </div> 因为此时,我们要解决的问题是**从表达式中剥离出属于类型的值类别**: ![image.png](https://zclll.com/usr/uploads/2022/04/256584644.png) 注意到,我们要实现“完美转发”的任务,就是要按照原类别去转发到对应的函数,而遇到的问题是当我们使用万能引用时, 那么怎么办呢?**手动把第二层匹配掉**,得到的不就是第一层了吗?怎么做呢,用两个重载显式的匹配第二层引用就好了呀: ```cpp template<typename T> T&& f(remove_reference<T>::value &arg); template<typename T> T&& f(remove_reference<T>::value &&arg); ``` 其中因为 `T`可能是引用类型,所以我们通过 `remove_reference`获得**非引用原类型**。这样调用到具体的重载时,我们就能保证实参的确是形参显式声明的类型了!这样,就能够把第二层引用剥离掉,使 `T`是真实的原引用类型。 而这,正是上面 `std::forward()`的实现方式。 --- 至于 `remove_reference`是怎么实现的?这更是一种标志性的手法: ```cpp template< class T > struct remove_reference {typedef T type;}; template< class T > struct remove_reference<T&> {typedef T type;}; template< class T > struct remove_reference<T&&> {typedef T type;}; ``` 通过显式模板特化解决了问题。这一目了然,就不多作说明了。 # 04 复制消除 前面我们在[02 移动](#move)这一节中提到了工厂方法模型,但实际上,即使有了移动构造函数,由特定函数构造对象并交给我们的整个过程还是够繁琐的。因为我们只是想要通过工厂方法得到一个对象,但实际却有很多不必要的开销,接下来我们用具体代码研究: ```cpp A f(){ return A(); } int main(){ auto a = f(); } ``` 这一段代码执行了怎样的过程? 1. 进入 `f()`调用 2. 调用 `A`类构造函数,**构造**临时对象 3. `f()`返回,临时对象**拷贝**或**移动**至 `main()`中 4. 然后 `f()`中的临时对象被**析构** 好,我们只是想要一个工厂方法代理生成一个对象而已,理论上我们只需要**1次构造**的开销,但现在我们却不得不面对至少**1次构造+1次移动+一次析构**。除了这种开销外,我们还有可能不得不为这种情况编写一个没有其他用途的**拷贝或移动函数**。 那从**使用目的上来讲**,以上代码完全可以等价成 ```cpp int main(){ auto a = A(); } ``` 那么,有什么道理不去这样做呢? 于是,从很久以前,主流编译器就已经会默认进行这样的操作了:**将上面的代码自动优化成等价于下面这种**。而这种操作称之为**复制消除**中的**返回值优化(Return Value Optimization)**,一般简称为“**RVO**”。需要特别注意的是,这种优化的进行,<span style='color:BlueViolet'>不会考虑被消除的拷贝/移动+析构函数是否带有副作用。</span>也就是说,即使你写的是上方那种工厂方法的代码,而 `A`类型的析构函数会进行一些额外的工作,例如输出一些字符等等,那它也会被无情地**消除**掉。 --- 除了RVO,工厂方法还有另一种常见的形式,比如: ```cpp A f(){ A tmp{}; tmp.xxx = 2; cout<<tmp.yyy; return tmp; } int main(){ auto a = f(); } ``` 这段代码中,`f()`的返回值不再是一个临时变量了,它是一个货真价实的左值。但问题是,和原本的代码在效果上好像是相同的? 既然如此,这也可以施加同样的优化,也是优化为在 `a`处直接初始化 `A()`。这种情形,称为“**具名返回值优化(Named Return Value Optimization)**”,简称为“**NRVO**”。可以看到,这里我们还对返回对象进行了包括修改和使用在内的更多操作,但<span style='color:BlueViolet'>如果编译器愿意触发NRVO,这一切都不会产生阻碍</span>。因为我们的返回值优化,相当于直接把要对象构建在了目标位置。 从规定上来说: > [return 语句](https://zh.cppreference.com/w/cpp/language/return "cpp/language/return")中,当操作数是拥有自动存储期的非 volatile 对象的名字,该名字不是函数形参或 catch 子句形参,且其具有与函数返回类型相同的类类型(忽略 [cv 限定](https://zh.cppreference.com/w/cpp/language/cv "cpp/language/cv"))时。这种复制消除的变体被称为 NRVO,“具名返回值优化 (named return value optimization)”[^cpprefNRVO]。 也完全不考虑中途对它的使用。 我们拿个例子来详细分析一下: ```cpp struct A{int x;}; A h() { A a{}; a.x = 2; cout<<a.x; return a; } int main() { A a = h(); //……………… return 0; } ``` 这段代码算是涉及了一种最复杂情形了——具名返回值,有初始化、有修改、有使用。那么我们看看编译结果[^compiler]: * 首先是使用编译选项`-O0 -std=c++17 -fno-elide-constructors`,手动关闭了非强制的复制消除(也就是具名返回值优化): ```cpp h(): pushq %rbp movq %rsp, %rbp subq $16, %rsp movl $0, -8(%rbp) movl $2, -8(%rbp) movl -8(%rbp), %eax movl %eax, %esi movl $_ZSt4cout, %edi call std::basic_ostream<char, std::char_traits<char> >::operator<<(int) leaq -8(%rbp), %rdx leaq -4(%rbp), %rax movq %rdx, %rsi movq %rax, %rdi call A::A(A&&) [complete object constructor] movl -4(%rbp), %eax leave ret ``` * 然后是使用编译选项`-O0 -std=c++17`,允许复制消除: ```cpp h(): pushq %rbp movq %rsp, %rbp subq $16, %rsp movl $0, -4(%rbp) movl $2, -4(%rbp) movl -4(%rbp), %eax movl %eax, %esi movl $_ZSt4cout, %edi call std::basic_ostream<char, std::char_traits<char> >::operator<<(int) movl -4(%rbp), %eax leave ret ``` 再看这二者主程序中对返回值的处理: ```cpp main: pushq %rbp movq %rsp, %rbp subq $16, %rsp call h() movl %eax, -4(%rbp) ``` 就知道`-4(%rbp)`就是`main()`中`a`的地址,所以开启了复制消除时,NRVO在这种复杂情况下仍然被应用了——对象直接被构建到目标位置。说明**复制消除的确与工厂方法中其他操作无关**。 但问题来了,我们前面说过这种操作是许多编译器自发进行的,也就是说不是标准所规定的。那我们就不能依赖这种特性构建我们的功能,因为哪天、什么情况下编译器不进行这样的优化了,也是合理的。 ## 0401 ☆新返回值优化——依赖C++17值类别版本 这个问题确实很棘手。要解决就非得产生一系列复杂的规则不可,而这对开发人员负担很大。 但,在C++17标准来临时,一种简单的变革方式出现,解决了这个问题。同时使得值类别变得更加简单、清晰了。这个改变来自:[Guaranteed copy elision through simplified value categories.pdf](https://zclll.com/usr/uploads/2022/04/1781936573.pdf) 这份提案(落实进入了C++17标准)对于值类别定义进行了修改,而这种修改非常简明: <span style='color:BlueViolet'>**纯右值提供初始化,泛左值提供定位器。**</span> 也就是说,我们再也不用通过表达式是否有地址(是否是定位器值)来区分左右值了:右值就是对对象的创造、初始化,而左值不仅是对象,还是容纳它的场所: ![image.png](https://zclll.com/usr/uploads/2022/04/1587445464.png) 这张图就一目了然了:纯右值只是初始化了一个对象出来,**但并没有把它放在任何地方**。所以原本的“拷贝”,现在只是简单的把它“放到一个地方”而已,并没有任何拷贝发生。 在这种左右值体系下,返回值优化不再用“复制消除”来描述了,而是——**原本就没有复制,而是从工厂方法出来的右值,放到了某个左值位置**! 当然,这种新理解仅限于RVO,因为NRVO针对的对象不是纯右值,它自己有地址。所以,自C++17开始,RVO是<span style='color:BlueViolet'>强制执行</span>的(并且不再是一种“优化”),NRVO仍然是**可选**的。 ### 临时量实质化 那么纯右值既然只是一种初始化语义,而不能代表一个完整的对象,那么我们怎么还能完成`A().x`这样的操作呢?按照C++17的新概念,“成员访问”、“数组下标访问”这种涉及到既有内存的,就应该**只能在泛左值上**进行。此时我们就得借助一个新工具来完善这种操作: > `T`的纯右值类型可以转换为亡值,这种操作会从纯右值初始化一个临时对象,并使用这个亡值表示它 (7.3.5)[^standard] 这种操作称之为**临时量实质化**,它会在我们需要对一个纯右值进行一些只能在泛左值上进行的操作时自动进行,例如 ```cpp a; //is prvalue a[5]; a.x = 10; ``` 这里的两个操作都会首先隐式地用`a`实质化一个临时变量(亡值)来供操作。 ## 0402 返回值的值类别 不光是发生了RVO或NRVO的情形,任何返回类型是非引用的函数调用表达式,其值类别就是**纯右值**。这其实蛮好理解的。因为这涉及到两个函数栈帧,分别是调用者和被调用者。一个对象在不同栈帧中转移的时候显然会作为不同的值类别。 比如我们看这段代码: ```cpp auto f() //把一个纯右值返回 { return T(); } auto g() //把一个左值返回 { T x; return x; } int main() { f(); g(); //1* auto a = g(); //2* return 0; } ``` 其中我们调用了`f()`和`g()`,`f()`的值类别是纯右值很好理解,因为本身被返回的`T()`在`f()`内也是个纯右值,返回出来没有任何道理改变——这一点也可以参见上一节的示意图一目了然。但`g()`返回的对象在函数体内是左值啊,那么返回值是什么呢?只要理解了栈帧之间的不同,就容易了: ![image.png](https://zclll.com/usr/uploads/2022/04/4108579839.png) 无论它们在自己的函数体内是什么值类别,当它们**来到另一个栈帧当中且没有找到“住所”**的时候,就只能是**纯右值**。 再把引用类型考虑进来,就得到了这个问题的完整答案: <div class="tip inlineBlock info"> 返回左值引用为左值,返回右值引用为亡值,返回非引用为纯右值。 </div> 第一条没有什么疑问,左值引用背后必然有一左值;第三条我们也解决了,那返回右值引用为什么是亡值而不是右值呢? <div class="tab-container post_tab box-shadow-wrap-lg"> <ul class="nav no-padder b-b scroll-hide" role="tablist"> <li class='nav-item active' role="presentation"><a class='nav-link active' style="" data-toggle="tab" aria-controls='tabs-93396ca0cb74aaa2feda0560ad54401c280' role="tab" data-target='#tabs-93396ca0cb74aaa2feda0560ad54401c280'>引用延长生存期</a></li> </ul> <div class="tab-content no-border"> <div role="tabpanel" id='tabs-93396ca0cb74aaa2feda0560ad54401c280' class="tab-pane fade active in"> ### 临时对象生存期的延长 > 一旦引用被绑定到临时对象或它的子对象,临时对象的生存期就被延续以匹配引用的生存期。[^cppreftemplive] </div> </div> </div> 从而,引用的背后会是有地址的值,而引用一旦被弃置,原值生存期也将消亡——符合C++17版本亡值的定义。 <div class="tip inlineBlock warning"> `static_cast`等内建操作,也符合以上规律。 </div> # 0e 梳理 ![image.png](https://zclll.com/usr/uploads/2022/04/1589964483.png) # 0f 总结(碎碎念) 这篇文章花了将近3万字,详细梳理了C++的值类别系统和与此相关的全部重要知识。重点和难点,都在上一节进行了梳理。 我认为,对于一项技术来说,懂得如何使用它以及何时使用它,是远不足谓之“掌握”的。更重要的是懂得它**为什么是这样**。好处是,如此掌握一项技术,有助于在更深层次上整体把握大的理论脉络,而这带来的,则不仅仅是技术上的进步。没有实际开发经验的朋友,也许非常难以有所体会。我自己尚且如此——例如,引入右值引用时使用的几个例子就不够practical(如果有人能够提供一点更加engineered的代码会对本文帮助很大)我想,这样寻根问底的思维方式,是任何一个学科、一个分支的学习都非常需要的。 最使我满意的一点是,全文的撰写贯彻了“理解事物要从非常深的层次向外延伸”的思路,从设计哲学、目的驱动的角度阐释了大量的“**为什么是这样**”。例如“亡值”作为一种非常不自然的值类别,本不应该存在于分类中,但我们为什么必须容纳这个类别的存在,从而导致了看起来有些循环论证的C++11版本value category。 还有一点,就是整套知识脉络的展开方式。按照国内传统教材“搭积木”的方式,我们应该从基本的“右值”开始,沿着“移动构造函数”、“万能引用”、“move”的路径一步一步的搭建移动语义。但这种学习方法是非常反人类的。正常的对任何知识的学习,都应该从解决一个大的、实际最可能遇到的问题开始,逐步分解,“遇山开路,遇水搭桥”,直到根基稳固,问题解决。本文即是这样做的。 但,每个人的思考方式不尽相同,对同一件事物的学习,可能会在截然不同的地方产生疑问。本文中,私以为厘清了我在初学相关知识时的重大困惑,例如“‘右值引用’本身做了什么”,“函数返回时值类别的变动”,等等。但如果因为考虑不周,文章有重点、难点没有提及,欢迎留言告诉我。 时间仓促,疏漏难免,欢迎指出。 [^cppreftemplive]: [引用初始化 - cppreference.com](https://zh.cppreference.com/w/cpp/language/reference_initialization) [^compiler]: 使用编译器x86-64 gcc 11.2,来自[https://godbolt.org/](https://godbolt.org/) [^cpprefNRVO]: [复制消除 - cppreference.com](https://zh.cppreference.com/w/cpp/language/copy_elision) [^havetochangeagain]: 哦不过,这种分类在C++17来临时,将会迎来又一次变革。 [^standard]: ISO/IEC 14882:2020(E) [^thatswhy]: 这也是为什么我前面用“消亡”去描述右值而不是亡值,我后面会解释这种理解的合理性。 [^quoteytb]: 来自[CppCon 2017: Nicolai Josuttis “The Nightmare of Move Semantics for Trivial Classes”](https://www.youtube.com/watch?v=PNRju6_yn3o) [^mustcopy]: 要么使用 `T arg`在传参时拷贝,要么使用 `const T& arg`在实际存入容器时拷贝。 [^toright]: 当然,会有一些别的影响,在[05 值类别变化](#cate_change)中我们会提到。 [^identify]: “可指明对象”和“有地址”是等价的。因为左值一定可以取地址,右值一定不可以取地址(这两点可以查阅各种文档),所以这两个描述所构成的集合完全等价。 [^elison]: 实际上,这里会有另一种叫做“复制消除”的手法来解决这一部分开销,我们之后会提及。 [^Factory]: 仅举例说明,与实际的工厂方法差距很大。 [^temporary]: 实际上,这里真正的临时对象生存期很短,并不像 `tmp`一样。 © 允许规范转载 打赏 赞赏作者 赞 18 如果觉得我的文章对你有用,请随意赞赏
1 条评论
好吧,A类型管理的内存有点儿过于庞大了,不是吗?这意味着如果我们要复制它、移动它,总之任何转移它的内容的行为,都会带来很大的开销。
这一段应该有点错误?图示的A类型中内存在堆上,复制/移动只会做浅拷贝。虽然能明白作者想说的是在栈上开的数组