现代C++学习:右值和移动究竟解决了什么问题?

发布时间:2026/7/8 18:02:54

现代C++学习:右值和移动究竟解决了什么问题? 从上一讲智能指针开始我们已经或多或少接触了移动语义。本讲我们就完整地讨论一下移动语义和相关的概念。移动语义是 C11 里引入的一个重要概念理解这个概念是理解很多现代 C 里的优化的基础。值分左右我们常常会说C 里有左值和右值。这话不完全对。标准里的定义实际更复杂规定了下面这些值类别value categories我们先理解一下这些名词的字面含义一个 lvalue 是通常可以放在等号左边的表达式左值一个 rvalue 是通常只能放在等号右边的表达式右值一个 glvalue 是 generalized lvalue广义左值一个 xvalue 是 expiring value将亡值一个 prvalue 是 pure rvalue纯右值还是有点晕是吧我们暂且抛开这些概念只看其中两个lvalue 和 prvalue。左值 lvalue 是有标识符、可以取地址的表达式最常见的情况有变量、函数或数据成员的名字返回左值引用的表达式如 x、x 1、cout 字符串字面量如 hello world在函数调用时左值可以绑定到左值引用的参数如 T。一个常量只能绑定到常左值引用如 const T。反之纯右值 prvalue 是没有标识符、不可以取地址的表达式一般也称之为“临时对象”。最常见的情况有返回非引用类型的表达式如 x、x 1、make_shared(42)除字符串字面量之外的字面量如 42、true在 C11 之前右值可以绑定到常左值引用const lvalue reference的参数如 const T但不可以绑定到非常左值引用non-const lvalue reference如 T。从 C11 开始C 语言里多了一种引用类型——右值引用。右值引用的形式是 T比左值引用多一个 符号。跟左值引用一样我们可以使用 const 和 volatile 来进行修饰但最常见的情况是我们不会用 const 和 volatile 来修饰右值。本专栏就属于这种情况。引入一种额外的引用类型当然增加了语言的复杂性但也带来了很多优化的可能性。由于 C 有重载我们就可以根据不同的引用类型来选择不同的重载函数来完成不同的行为。回想一下在上一讲中我们就利用了重载让 smart_ptr 的构造函数可以有不同的行为template typename U smart_ptr(const smart_ptrU other) noexcept { ptr_ other.ptr_; if (ptr_) { other.shared_count_-add_count(); shared_count_ other.shared_count_; } } template typename U smart_ptr(smart_ptrU other) noexcept { ptr_ other.ptr_; if (ptr_) { shared_count_ other.shared_count_; other.ptr_ nullptr; } }你可能会好奇使用右值引用的第二个重载函数中的变量 other 算是左值还是右值呢根据定义other 是个变量的名字变量有标识符、有地址所以它还是一个左值——虽然它的类型是右值引用。尤其重要的是拿这个 other 去调用函数时它匹配的也会是左值引用。也就是说类型是右值引用的变量是一个左值这点可能有点反直觉但跟 C 的其他方面是一致的。毕竟对于一个右值引用的变量你是可以取地址的这点上它和左值完全一致。稍后我们再回到这个话题上来。再看一下下面的代码smart_ptrshape ptr1{new circle()}; smart_ptrshape ptr2 std::move(ptr1);第一个表达式里的 new circle() 就是一个纯右值但对于指针我们通常使用值传递并不关心它是左值还是右值。第二个表达式里的 std::move(ptr) 就有趣点了。它的作用是把一个左值引用强制转换成一个右值引用而并不改变其内容。从实用的角度在我们这儿 std::move(ptr1) 等价于 static_cast(ptr1)。因此std::move(ptr1) 的结果是指向 ptr1 的一个右值引用这样构造 ptr2 时就会选择上面第二个重载。我们可以把 std::move(ptr1) 看作是一个有名字的右值。为了跟无名的纯右值 prvalue 相区别C 里目前就把这种表达式叫做 xvalue。跟左值 lvalue 不同xvalue 仍然是不能取地址的——这点上xvalue 和 prvalue 相同。所以xvalue 和 prvalue 都被归为右值 rvalue。我们用下面的图来表示会更清楚一点另外请注意“值类别”value category和“值类型”value type是两个看似相似、却毫不相干的术语。前者指的是上面这些左值、右值相关的概念后者则是与引用类型reference type相对而言表明一个变量是代表实际数值还是引用另外一个数值。在 C 里所有的原生类型、枚举、结构、联合、类都代表值类型只有引用和指针*才是引用类型。在 Java 里数字等原生类型是值类型类则属于引用类型。在 Python 里一切类型都是引用类型。生命周期和表达式类型一个变量的生命周期在超出作用域时结束。如果一个变量代表一个对象当然这个对象的生命周期也在那时结束。那临时对象prvalue呢在这儿C 的规则是一个临时对象会在包含这个临时对象的完整表达式估值完成后、按生成顺序的逆序被销毁除非有生命周期延长发生。我们先看一个没有生命周期延长的基本情况process_shape(circle(), triangle());在这儿我们生成了临时对象一个圆和一个三角形它们会在 process_shape 执行完成并生成结果对象后被销毁。我们插入一些实际的代码就可以演示这一行为#include stdio.h class shape { public: virtual ~shape() {} }; class circle : public shape { public: circle() { puts(circle()); } ~circle() { puts(~circle()); } }; class triangle : public shape { public: triangle() { puts(triangle()); } ~triangle() { puts(~triangle()); } }; class result { public: result() { puts(result()); } ~result() { puts(~result()); } }; result process_shape(const shape shape1, const shape shape2) { puts(process_shape()); return result(); } int main() { puts(main()); process_shape(circle(), triangle()); puts(something else); }输出结果可能会是circle 和 triangle 的顺序在标准中没有规定目前我让 process_shape 也返回了一个结果这是为了下一步演示的需要。你可以看到结果的临时对象最后生成、最先析构。为了方便对临时对象的使用C 对临时对象有特殊的生命周期延长规则。这条规则是我们对上面的代码只要改一行就能演示这个效果。把 process_shape 那行改成result r process_shape( circle(), triangle());我们就能看到不同的结果了现在 result 的生成还在原来的位置但析构被延到了 main 的最后。需要万分注意的是这条生命期延长规则只对 prvalue 有效而对 xvalue 无效。如果由于某种原因prvalue 在绑定到引用以前已经变成了 xvalue那生命期就不会延长。不注意这点的话代码就可能会产生隐秘的 bug。比如我们如果这样改一下代码结果就不对了#include utility // std::move … result r std::move(process_shape( circle(), triangle()));这时的代码输出就回到了前一种情况。虽然执行到 something else 那儿我们仍然有一个有效的变量 r但它指向的对象已经不存在了对 r 的解引用是一个未定义行为。由于 r 指向的是栈空间通常不会立即导致程序崩溃而会在某些复杂的组合条件下才会引致问题……对 C 的这条生命期延长规则在后面讲到视图view的时候会十分有用。那时我们会看到有些 C 的用法实际上会隐式地利用这条规则。此外你可以把一个没有虚析构函数的子类对象绑定到基类的引用变量上这个子类对象的析构仍然是完全正常的——这是因为这条规则只是延后了临时对象的析构而已不是利用引用计数等复杂的方法因而只要引用绑定成功其类型并没有什么影响。移动的意义上面我们谈了一些语法知识。就跟学外语的语法一样这些内容是比较枯燥的。虽然这些知识有时有用但往往要回过头来看的时候才觉得。初学之时更重要的是理解为什么和熟练掌握基本的用法。对于 smart_ptr我们使用右值引用的目的是实现移动而实现移动的意义是减少运行的开销——在引用计数指针的场景下这个开销并不大。移动构造和拷贝构造的差异仅在于少了一次 other.shared_count_-add_count() 的调用被移动的指针被清空因而析构时也少了一次 shared_count_-reduce_count() 的调用在使用容器类的情况下移动更有意义。我们可以尝试分析一下下面这个假想的语句假设 name 是 string 类型string result string(Hello, ) name .;在 C11 之前的年代里这种写法是绝对不推荐的。因为它会引入很多额外开销执行流程大致如下调用构造函数 string(const char*)生成临时对象 1Hello, 复制 1 次。调用 operator(const string, const string)生成临时对象 2Hello, 复制 2 次name 复制 1 次。调用 operator(const string, const char*)生成对象 3Hello, 复制 3 次name 复制 2 次. 复制 1 次。假设返回值优化能够生效最佳情况对象 3 可以直接在 result 里构造完成。临时对象 2 析构释放指向 string(Hello, ) name 的内存。临时对象 1 析构释放指向 string(Hello, ) 的内存。既然 C 是一门追求性能的语言一个合格的 C 程序员会写string result Hello, ; result name; result .;这样的话只会调用构造函数一次和 string::operator 两次没有任何临时对象需要生成和析构所有的字符串都只复制了一次。但显然代码就啰嗦多了——尤其如果拼接的步骤比较多的话。从 C11 开始这不再是必须的。同样上面那个单行的语句执行流程大致如下调用构造函数 string(const char*)生成临时对象 1Hello, 复制 1 次。调用 operator(string, const string)直接在临时对象 1 上面执行追加操作并把结果移动到临时对象 2name 复制 1 次。调用 operator(string, const char*)直接在临时对象 2 上面执行追加操作并把结果移动到 result. 复制 1 次。临时对象 2 析构内容已经为空不需要释放任何内存。临时对象 1 析构内容已经为空不需要释放任何内存。性能上所有的字符串只复制了一次虽然比啰嗦的写法仍然要增加临时对象的构造和析构但由于这些操作不牵涉到额外的内存分配和释放是相当廉价的。程序员只需要牺牲一点点性能就可以大大增加代码的可读性。而且所谓的性能牺牲也只是相对于优化得很好的 C 或 C 代码而言——这样的 C 代码的性能仍然完全可以超越 Python 类的语言的相应代码。此外很关键的一点是C 里的对象缺省都是值语义。在下面这样的代码里class A { B b_; C c_; };从实际内存布局的角度很多语言——如 Java 和 Python——会在 A 对象里放 B 和 C 的指针虽然这些语言里本身没有指针的概念。而 C 则会直接把 B 和 C 对象放在 A 的内存空间里。这种行为既是优点也是缺点。说它是优点是因为它保证了内存访问的局域性而局域性在现代处理器架构上是绝对具有性能优势的。说它是缺点是因为复制对象的开销大大增加在 Java 类语言里复制的是指针在 C 里是完整的对象。这就是为什么 C 需要移动语义这一优化而 Java 类语言里则根本不需要这个概念。一句话总结移动语义使得在 C 里返回大对象如容器的函数和运算符成为现实因而可以提高代码的简洁性和可读性提高程序员的生产率。所有的现代 C 的标准容器都针对移动进行了优化。如何实现移动要让你设计的对象支持移动的话通常需要下面几步你的对象应该有分开的拷贝构造和移动构造函数除非你只打算支持移动不支持拷贝——如 unique_ptr。你的对象应该有 swap 成员函数支持和另外一个对象快速交换成员。在你的对象的名空间下应当有一个全局的 swap 函数调用成员函数 swap 来实现交换。支持这种用法会方便别人包括你自己在将来在其他对象里包含你的对象并快速实现它们的 swap 函数。实现通用的 operator。上面各个函数如果不抛异常的话应当标为 noexcept。这对移动构造函数尤为重要。具体写法可以参考我们当前已经实现的 smart_ptrsmart_ptr 有拷贝构造和移动构造函数虽然此处我们的模板构造函数严格来说不算拷贝或移动构造函数。移动构造函数应当从另一个对象获取资源清空其资源并将其置为一个可析构的状态。smart_ptr(const smart_ptr other) noexcept { ptr_ other.ptr_; if (ptr_) { other.shared_count_ -add_count(); shared_count_ other.shared_count_; } } template typename U smart_ptr(const smart_ptrU other) noexcept { ptr_ other.ptr_; if (ptr_) { other.shared_count_ -add_count(); shared_count_ other.shared_count_; } } template typename U smart_ptr(smart_ptrU other) noexcept { ptr_ other.ptr_; if (ptr_) { shared_count_ other.shared_count_; other.ptr_ nullptr; } }smart_ptr 有 swap 成员函数。void swap(smart_ptr rhs) noexcept { using std::swap; swap(ptr_, rhs.ptr_); swap(shared_count_, rhs.shared_count_); }有支持 smart_ptr 的全局 swap 函数。template typename T void swap(smart_ptrT lhs, smart_ptrT rhs) noexcept { lhs.swap(rhs); }smart_ptr 有通用的 operator 成员函数。注意为了避免让人吃惊通常我们需要将其实现成对 a a; 这样的写法安全。下面的写法算是个小技巧对传递左值和右值都有效而且规避了 if (rhs ! this) 这样的判断。smart_ptr operator(smart_ptr rhs) noexcept { rhs.swap(*this); return *this; }不要返回本地变量的引用有一种常见的 C 编程错误是在函数里返回一个本地对象的引用。由于在函数结束时本地对象即被销毁返回一个指向本地对象的引用属于未定义行为。理论上来说程序出任何奇怪的行为都是正常的。在 C11 之前返回一个本地对象意味着这个对象会被拷贝除非编译器发现可以做返回值优化named return value optimization或 NRVO能把对象直接构造到调用者的栈上。从 C11 开始返回值优化仍可以发生但在没有返回值优化的情况下编译器将试图把本地对象移动出去而不是拷贝出去。这一行为不需要程序员手工用 std::move 进行干预——使用 std::move 对于移动行为没有帮助反而会影响返回值优化。下面是个例子引用坍缩和完美转发最后讲一个略复杂、但又不得不讲的话题引用坍缩又称“引用折叠”。这个概念在泛型编程中是一定会碰到的。我们今天既然讲了左值和右值引用也需要一起讲一下。我们已经讲了对于一个实际的类型 T它的左值引用是 T右值引用是 T。那么是不是看到 T就一定是个左值引用是不是看到 T就一定是个右值引用对于前者的回答是“是”对于后者的回答为“否”。关键在于在有模板的代码里对于类型参数的推导结果可能是引用。我们可以略过一些繁复的语法规则要点是对于 template foo(T) 这样的代码如果传递过去的参数是左值T 的推导结果是左值引用如果传递过去的参数是右值T 的推导结果是参数的类型本身。如果 T 是左值引用那 T 的结果仍然是左值引用——即 type 坍缩成了 type。如果 T 是一个实际类型那 T 的结果自然就是一个右值引用。我们之前提到过右值引用变量仍然会匹配到左值引用上去。下面的代码会验证这一行为void foo(const shape) { puts(foo(const shape)); } void foo(shape) { puts(foo(shape)); } void bar(const shape s) { puts(bar(const shape)); foo(s); } void bar(shape s) { puts(bar(shape)); foo(s); } int main() { bar(circle()); }可如果两个 bar 的重载除了调用 foo 的方式不一样其他都差不多的话我们为什么要提供两个不同的 bar 呢事实上很多标准库里的函数连目标的参数类型都不知道但我们仍然需要能够保持参数的值类别左值的仍然是左值右值的仍然是右值。这个功能在 C 标准库中已经提供了叫 std::forward。它和 std::move 一样都是利用引用坍缩机制来实现。此处我们不介绍其实现细节而是重点展示其用法。我们可以把我们的两个 bar 函数简化成template typename T void bar(T s) { foo(std::forwardT(s)); }对于下面这样的代码circle temp; bar(temp); bar(circle());现在的输出是一切如预期一样。因为在 T 是模板参数时T 的作用主要是保持值类别进行转发它有个名字就叫“转发引用”forwarding reference。因为既可以是左值引用也可以是右值引用它也曾经被叫做“万能引用”universal reference。

相关新闻